diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index fd5e65b..03a9a40 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -11,6 +11,12 @@ on:
paths:
- "web/**"
- "Resources/Marketing/app-icon.png"
+ - "Tools/**"
+ - "Localization/**"
+ - "docs/**"
+ - "DemoLab/publication.json"
+ - "DemoLab/Localization/**"
+ - "DemoLab/scenes/**"
- ".github/workflows/deploy-site.yml"
workflow_dispatch:
@@ -48,6 +54,11 @@ jobs:
echo "should-deploy=true" >> "$GITHUB_OUTPUT"
echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
+ - uses: swift-actions/setup-swift@v2
+ if: steps.release.outputs.should-deploy == 'true'
+ with:
+ swift-version: "6.2"
+
- name: Assemble site
if: steps.release.outputs.should-deploy == 'true'
env:
@@ -64,15 +75,8 @@ jobs:
# index.html (the page also resolves this live via the API).
echo "Latest version: $VERSION"
- cp web/*.html web/*.css "$SITE/"
- cp web/*.mp4 web/*.jpg "$SITE/" 2>/dev/null || true
- sed -i "s/__VERSION__/${VERSION}/g" "$SITE/index.html"
- cp Resources/Marketing/app-icon.png "$SITE/icon.png"
-
- # Cache-bust the stylesheet so a CSS change is never masked by a
- # stale browser cache (HTML + CSS stay in lockstep per deploy).
- BUST="${GITHUB_SHA:0:8}"
- sed -i "s#\"./style.css\"#\"./style.css?v=${BUST}\"#g" "$SITE"/*.html
+ swift test --package-path Tools
+ swift run --package-path Tools tatami-tools build-site --output "$SITE" --version "$VERSION"
# Preserve the Sparkle feed. GitHub Pages replaces the whole site on
# deploy, so re-publish appcast.xml. The canonical copy is the latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0df2b88..35cce30 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -40,6 +40,11 @@ jobs:
- uses: jdx/mise-action@v4
+ - name: Build and test automation tools
+ run: |
+ swift test --package-path Tools
+ swift build --package-path Tools -c release
+
- name: Import Developer ID certificate
env:
CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }}
@@ -132,8 +137,8 @@ jobs:
--issuer "$AC_API_ISSUER_ID" \
--output-format json --wait)"
echo "$SUBMIT_JSON"
- SUB_ID="$(echo "$SUBMIT_JSON" | python3 -c 'import sys, json; print(json.load(sys.stdin)["id"])')"
- SUB_STATUS="$(echo "$SUBMIT_JSON" | python3 -c 'import sys, json; print(json.load(sys.stdin)["status"])')"
+ SUB_ID="$(echo "$SUBMIT_JSON" | Tools/.build/release/tatami-tools json-field id)"
+ SUB_STATUS="$(echo "$SUBMIT_JSON" | Tools/.build/release/tatami-tools json-field status)"
if [ "$SUB_STATUS" != "Accepted" ]; then
echo "Notarization failed with status: $SUB_STATUS. Fetching log:"
xcrun notarytool log "$SUB_ID" \
@@ -172,73 +177,8 @@ jobs:
# stopping at the previous minor (those users already had it). The
# website / GitHub release show only this version's own section —
# CHANGELOG.md stays one-section-per-version, no hand-carried notes.
- APPCAST="$APPCAST" VERSION="$VERSION" python3 - <<'PY'
- import os, re, html as H
-
- version = os.environ["VERSION"]
- minor = ".".join(version.split(".")[:2]) # 1.3.3 -> 1.3
-
- # Split CHANGELOG into (heading, body-lines) version sections.
- sections, cur = [], None
- for line in open("CHANGELOG.md", encoding="utf-8").read().splitlines():
- if line.startswith("## "):
- cur = {"head": line[3:].strip(), "lines": []}
- sections.append(cur)
- elif cur is not None:
- cur["lines"].append(line)
-
- def secver(head):
- m = re.match(r"v?(\d+\.\d+\.\d+)", head)
- return m.group(1) if m else None
-
- collected, started = [], False
- for s in sections:
- v = secver(s["head"])
- if v == version:
- started = True
- if not started:
- continue
- if v is None or ".".join(v.split(".")[:2]) != minor:
- break
- collected.append(s)
-
- def inline(s):
- s = H.escape(s)
- s = re.sub(r"`([^`]+)`", r"\1", s)
- s = re.sub(r"\*\*([^*]+)\*\*", r"\1 ", s)
- s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1 ', s)
- s = re.sub(r"(?\1", s)
- return s
-
- out, in_ul = [], False
- for s in collected:
- if in_ul: out.append(""); in_ul = False
- out.append("
" + inline(s["head"]) + " ")
- for line in s["lines"]:
- if line.startswith("### "):
- if in_ul: out.append(""); in_ul = False
- out.append("" + inline(line[4:].strip()) + " ")
- elif line.startswith("- "):
- if not in_ul: out.append(""); in_ul = True
- out.append("" + inline(line[2:].strip()) + " ")
- elif line.strip() == "":
- if in_ul: out.append(" "); in_ul = False
- else:
- if in_ul: out.append(""); in_ul = False
- out.append("" + inline(line.strip()) + "
")
- if in_ul: out.append("")
-
- html = "\n".join(out).strip()
- appcast = os.environ["APPCAST"]
- xml = open(appcast, encoding="utf-8").read()
- if html and "" not in xml:
- desc = " \n "
- xml = xml.replace("", desc + "", 1)
- open(appcast, "w", encoding="utf-8").write(xml)
- print("appcast: embedded release notes (%d section(s))" % len(collected))
- else:
- print("appcast: skipped (no notes or description already present)")
- PY
+ Tools/.build/release/tatami-tools embed-release-notes \
+ --appcast "$APPCAST" --version "$VERSION"
- name: Build release notes from CHANGELOG
env:
@@ -323,19 +263,7 @@ jobs:
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/site"
- # Static pages (configuration.html / releases.html render their source
- # of truth at runtime; index.html gets the release version injected).
- cp web/*.html web/*.css "$RUNNER_TEMP/site/"
- cp web/*.mp4 web/*.jpg "$RUNNER_TEMP/site/" 2>/dev/null || true
- sed "s/__VERSION__/${VERSION}/g" web/index.html > "$RUNNER_TEMP/site/index.html"
- cp Resources/Marketing/app-icon.png "$RUNNER_TEMP/site/icon.png"
- # Cache-bust the stylesheet so a CSS change is never masked by a
- # stale browser cache.
- BUST="${GITHUB_SHA:0:8}"
- # `sed -i` with no backup suffix is GNU-only; this job runs on macOS,
- # whose BSD sed needs `-i.bak` (which GNU sed also accepts). Clean up.
- sed -i.bak "s#\"./style.css\"#\"./style.css?v=${BUST}\"#g" "$RUNNER_TEMP/site"/*.html
- rm -f "$RUNNER_TEMP/site"/*.html.bak
+ Tools/.build/release/tatami-tools build-site --output "$RUNNER_TEMP/site" --version "$VERSION"
# Sparkle feed.
cp "$RUNNER_TEMP/release/appcast.xml" "$RUNNER_TEMP/site/appcast.xml"
diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml
new file mode 100644
index 0000000..05c2f96
--- /dev/null
+++ b/.github/workflows/tools.yml
@@ -0,0 +1,32 @@
+name: Automation tools
+
+on:
+ pull_request:
+ paths:
+ - "Tools/**"
+ - "DemoLab/**"
+ - "Localization/**"
+ - "docs/**"
+ - "README*.md"
+ - "NOTICE*.md"
+ - "THIRD_PARTY_NOTICES*.md"
+ - "web/**"
+ - ".github/workflows/tools.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ portable-tools:
+ runs-on: ubuntu-latest
+ container: swift:6.2-noble
+ steps:
+ - uses: actions/checkout@v6
+ - name: Test native automation and generated content
+ run: |
+ swift test --package-path Tools
+ swift run --package-path Tools tatami-tools build-tool-notices --check
+ swift run --package-path Tools tatami-tools build-docs --check
+ swift run --package-path Tools tatami-tools localize-scenes --check
+ swift run --package-path Tools tatami-tools build-site --output /tmp/tatami-site
diff --git a/DemoLab/.gitignore b/DemoLab/.gitignore
new file mode 100644
index 0000000..660fa2c
--- /dev/null
+++ b/DemoLab/.gitignore
@@ -0,0 +1,7 @@
+.build/
+recordings/
+*.mov
+.swiftpm/
+.sync/
+
+__pycache__/
diff --git a/DemoLab/Localization/Films.json b/DemoLab/Localization/Films.json
new file mode 100644
index 0000000..c9b5fda
--- /dev/null
+++ b/DemoLab/Localization/Films.json
@@ -0,0 +1,1300 @@
+{
+ "sourceLanguage": "en",
+ "strings": {
+ "A Shared Always on Top monitor receives real workspace and HUD hook events.": {
+ "en": "A Shared Always on Top monitor receives real workspace and HUD hook events.",
+ "ko": "항상 위에 둔 공용 상태 창에 실제 작업 공간·화면 알림 훅 이벤트가 도착해요.",
+ "ja": "常に手前に置いた共有の状態ウインドウに、実際のワークスペースと操作フィードバックのフックが届きます。",
+ "zh-Hans": "置顶的共享状态窗口接收实际的工作区和操作反馈钩子事件。",
+ "zh-Hant": "置頂的共用狀態視窗接收實際的工作空間與操作回饋掛鉤事件。"
+ },
+ "A profile changes the workspace set and restores the arrangement belonging to that context.": {
+ "en": "A profile changes the workspace set and restores the arrangement belonging to that context.",
+ "ko": "프로필을 바꾸면 작업 공간 구성과 해당 상황에 맞는 배치가 돌아와요.",
+ "ja": "プロファイルを切り替えると、作業環境に合ったワークスペースと配置が戻ります。",
+ "zh-Hans": "切换配置方案,恢复适合当前场景的工作区组合和布局。",
+ "zh-Hant": "切換設定組合,還原適合目前情境的工作空間與排列。"
+ },
+ "A small script edits the real gap setting. Tatami reloads it without a restart.": {
+ "en": "A small script edits the real gap setting. Tatami reloads it without a restart.",
+ "ko": "작은 스크립트로 실제 창 간격을 바꿔요. Tatami를 다시 실행할 필요가 없어요.",
+ "ja": "小さなスクリプトで実際の余白を変更します。Tatami の再起動は不要です。",
+ "zh-Hans": "用小脚本修改实际的窗口间距,无需重启 Tatami。",
+ "zh-Hant": "用小型指令稿修改實際的視窗間距,不必重新啟動 Tatami。"
+ },
+ "A workspace chain changes both displays.": {
+ "en": "A workspace chain changes both displays.",
+ "ko": "작업 공간 체인으로 두 화면을 함께 바꿔요.",
+ "ja": "ワークスペースチェーンで、両方の画面を切り替えます。",
+ "zh-Hans": "用工作区链一起切换两个显示器。",
+ "zh-Hant": "用工作空間鏈一起切換兩部顯示器。"
+ },
+ "AI PLANNING / EXAMPLE PROPOSAL": {
+ "en": "AI PLANNING / EXAMPLE PROPOSAL",
+ "ko": "AI 구성 / 예시 제안",
+ "ja": "AI による構成 / 提案例",
+ "zh-Hans": "AI 规划 / 示例建议",
+ "zh-Hant": "AI 規劃 / 範例建議"
+ },
+ "ALWAYS ON TOP": {
+ "en": "ALWAYS ON TOP",
+ "ko": "항상 위",
+ "ja": "常に手前",
+ "zh-Hans": "置顶",
+ "zh-Hant": "置頂"
+ },
+ "APP MEMBERSHIP": {
+ "en": "APP MEMBERSHIP",
+ "ko": "앱 배정",
+ "ja": "アプリの割り当て",
+ "zh-Hans": "应用分配",
+ "zh-Hant": "App 指派"
+ },
+ "AUTO-OPEN": {
+ "en": "AUTO-OPEN",
+ "ko": "자동 열기",
+ "ja": "自動で開く",
+ "zh-Hans": "自动打开",
+ "zh-Hant": "自動開啟"
+ },
+ "AUTOMATIC TILING": {
+ "en": "AUTOMATIC TILING",
+ "ko": "자동 타일링",
+ "ja": "自動タイル表示",
+ "zh-Hans": "自动平铺",
+ "zh-Hant": "自動並排"
+ },
+ "Add a follow-up in a borrow-only workspace. It is still there the next time you summon it.": {
+ "en": "Add a follow-up in a borrow-only workspace. It is still there the next time you summon it.",
+ "ko": "빌려오기 전용 임시 공간에 다음 할 일을 적어요. 다시 꺼내도 그대로 남아 있어요.",
+ "ja": "借りるための一時スペースに次の作業を残します。呼び出し直しても、そのままです。",
+ "zh-Hans": "在仅供借用的暂存区中记下待办事项,下次调出时仍然保留。",
+ "zh-Hant": "在僅供借用的暫存區記下待辦事項,下次叫出時仍然保留。"
+ },
+ "Answer a teammate without replacing the canvas and reference you are using.": {
+ "en": "Answer a teammate without replacing the canvas and reference you are using.",
+ "ko": "보고 있던 Canvas와 참고 자료를 그대로 두고 동료에게 답해요.",
+ "ja": "Canvas と参考資料を開いたまま、同僚に返事をします。",
+ "zh-Hans": "保留正在使用的 Canvas 和参考资料,同时回复同事。",
+ "zh-Hant": "保留正在使用的 Canvas 和參考資料,同時回覆同事。"
+ },
+ "Arrange it by hand": {
+ "en": "Arrange it by hand",
+ "ko": "마우스로 배치하기",
+ "ja": "マウスで並べる",
+ "zh-Hans": "用鼠标排列",
+ "zh-Hant": "用滑鼠排列"
+ },
+ "BORROW A WORKSPACE": {
+ "en": "BORROW A WORKSPACE",
+ "ko": "작업 공간 빌려오기",
+ "ja": "ワークスペースを借りる",
+ "zh-Hans": "借用工作区",
+ "zh-Hant": "借用工作空間"
+ },
+ "BORROW PLACEMENT": {
+ "en": "BORROW PLACEMENT",
+ "ko": "빌려올 위치 선택",
+ "ja": "借りる位置を選ぶ",
+ "zh-Hans": "选择借用位置",
+ "zh-Hant": "選擇借用位置"
+ },
+ "Bring a conversation alongside": {
+ "en": "Bring a conversation alongside",
+ "ko": "대화를 옆으로 가져오기",
+ "ja": "会話を横に呼び出す",
+ "zh-Hans": "把对话放到旁边",
+ "zh-Hant": "把對話放到旁邊"
+ },
+ "Build a focus session with the CLI": {
+ "en": "Build a focus session with the CLI",
+ "ko": "CLI로 집중할 환경 준비하기",
+ "ja": "CLI で集中できる環境を用意する",
+ "zh-Hans": "用 CLI 准备专注环境",
+ "zh-Hant": "用 CLI 準備專注環境"
+ },
+ "COMMAND LINE": {
+ "en": "COMMAND LINE",
+ "ko": "커맨드라인",
+ "ja": "コマンドライン",
+ "zh-Hans": "命令行",
+ "zh-Hant": "命令列"
+ },
+ "Check the preview at a smaller size.": {
+ "en": "Check the preview at a smaller size.",
+ "ko": "작은 크기에서도 미리보기를 확인해요.",
+ "ja": "小さいサイズでもプレビューを確認します。",
+ "zh-Hans": "检查较小尺寸下的预览。",
+ "zh-Hant": "檢查較小尺寸下的預覽。"
+ },
+ "Choose the edge and the context": {
+ "en": "Choose the edge and the context",
+ "ko": "위치와 작업 맥락 고르기",
+ "ja": "位置と作業の場を選ぶ",
+ "zh-Hans": "选择位置和工作环境",
+ "zh-Hant": "選擇位置與工作情境"
+ },
+ "Choose the window you need": {
+ "en": "Choose the window you need",
+ "ko": "필요한 창 고르기",
+ "ja": "必要なウインドウを選ぶ",
+ "zh-Hans": "选择需要的窗口",
+ "zh-Hant": "選擇需要的視窗"
+ },
+ "Compare the three color directions.": {
+ "en": "Compare the three color directions.",
+ "ko": "세 가지 색상 방향을 비교해요.",
+ "ja": "3 つの配色を比べます。",
+ "zh-Hans": "比较三种配色方向。",
+ "zh-Hant": "比較三種配色方向。"
+ },
+ "Connect a second display to activate the Desk profile. Disconnect it to return to the Laptop arrangement.": {
+ "en": "Connect a second display to activate the Desk profile. Disconnect it to return to the Laptop arrangement.",
+ "ko": "두 번째 화면을 연결하면 Desk 프로필이 활성화돼요. 연결을 해제하면 Laptop 배치로 돌아와요.",
+ "ja": "2 台目を接続すると Desk プロファイルに切り替わります。外すと Laptop の配置に戻ります。",
+ "zh-Hans": "连接第二个显示器,激活 Desk 配置方案;断开后恢复 Laptop 布局。",
+ "zh-Hant": "連接第二部顯示器,啟用 Desk 設定組合;中斷連接後還原 Laptop 排列。"
+ },
+ "DISPLAY-AWARE PROFILES": {
+ "en": "DISPLAY-AWARE PROFILES",
+ "ko": "화면에 맞는 프로필",
+ "ja": "画面に合わせたプロファイル",
+ "zh-Hans": "匹配显示器的配置方案",
+ "zh-Hant": "配合顯示器的設定組合"
+ },
+ "DRAG & RESIZE": {
+ "en": "DRAG & RESIZE",
+ "ko": "드래그와 크기 조절",
+ "ja": "ドラッグとサイズ変更",
+ "zh-Hans": "拖移与调整大小",
+ "zh-Hant": "拖移與調整大小"
+ },
+ "Design, write, review, borrow, automate, and adapt to a bigger desk.": {
+ "en": "Design, write, review, borrow, automate, and adapt to a bigger desk.",
+ "ko": "디자인하고, 쓰고, 검토하고, 빌려오고, 자동화해요. 넓은 책상에 맞춰 구성도 바꿔요.",
+ "ja": "デザイン、執筆、レビュー、借りる操作、自動化。広いデスクにも合わせて切り替えます。",
+ "zh-Hans": "设计、写作、评审、借用、自动化,再适应更大的桌面。",
+ "zh-Hant": "設計、撰寫、審閱、借用、自動化,再配合更大的桌面。"
+ },
+ "Dock a workspace left or below, cross the boundary with focus, then activate it fully.": {
+ "en": "Dock a workspace left or below, cross the boundary with focus, then activate it fully.",
+ "ko": "왼쪽이나 아래로 작업 공간을 빌려와요. 경계를 넘어 포커스를 옮긴 뒤 해당 작업 공간으로 전환해요.",
+ "ja": "ワークスペースを左や下に借りて、境界を越えてフォーカスを移し、その作業に切り替えます。",
+ "zh-Hans": "把工作区借到左侧或下方,跨过边界移动焦点,再完整切换过去。",
+ "zh-Hant": "把工作空間借到左側或下方,跨過邊界移動焦點,再完整切換過去。"
+ },
+ "Drag one window onto another, resize an edge, and return to the saved arrangement.": {
+ "en": "Drag one window onto another, resize an edge, and return to the saved arrangement.",
+ "ko": "창을 다른 창 위로 옮기고 가장자리를 조절해요. 그런 다음 저장한 배치로 돌아와요.",
+ "ja": "ウインドウを重ねるようにドラッグして端を調整し、保存した配置に戻ります。",
+ "zh-Hans": "拖移窗口、调整边缘,再恢复保存的布局。",
+ "zh-Hant": "拖移視窗、調整邊緣,再還原儲存的排列。"
+ },
+ "Duplicate a profile and a workspace through the real CLI, then inspect the resulting configuration.": {
+ "en": "Duplicate a profile and a workspace through the real CLI, then inspect the resulting configuration.",
+ "ko": "실제 CLI로 프로필과 작업 공간을 복제한 뒤 결과를 확인해요.",
+ "ja": "実際の CLI でプロファイルとワークスペースを複製し、結果を確認します。",
+ "zh-Hans": "用实际 CLI 复制配置方案和工作区,再检查结果。",
+ "zh-Hant": "用實際 CLI 複製設定組合與工作空間,再檢查結果。"
+ },
+ "Duplicate a workspace for a new variation.": {
+ "en": "Duplicate a workspace for a new variation.",
+ "ko": "작업 공간을 복제해서 다른 구성을 만들어요.",
+ "ja": "ワークスペースを複製して、別の構成を作ります。",
+ "zh-Hans": "复制工作区,创建不同的组合。",
+ "zh-Hant": "複製工作空間,建立不同的組合。"
+ },
+ "Edit plain TOML, see a live change": {
+ "en": "Edit plain TOML, see a live change",
+ "ko": "TOML을 수정하고 바로 확인하기",
+ "ja": "TOML を編集して、その場で確認する",
+ "zh-Hans": "编辑 TOML,立即查看变化",
+ "zh-Hant": "編輯 TOML,立即查看變化"
+ },
+ "FOCUS & POINTER": {
+ "en": "FOCUS & POINTER",
+ "ko": "포커스와 포인터",
+ "ja": "フォーカスとポインタ",
+ "zh-Hans": "焦点与指针",
+ "zh-Hant": "焦點與游標"
+ },
+ "Focus follows your intent": {
+ "en": "Focus follows your intent",
+ "ko": "내 의도에 따라 포커스 이동하기",
+ "ja": "意図に合わせてフォーカスを移す",
+ "zh-Hans": "让焦点跟随你的意图",
+ "zh-Hant": "讓焦點跟隨你的意圖"
+ },
+ "Focus with workspace zoom": {
+ "en": "Focus with workspace zoom",
+ "ko": "작업 공간 확대해서 집중하기",
+ "ja": "ワークスペースを広げて集中する",
+ "zh-Hans": "放大工作区,集中注意力",
+ "zh-Hant": "放大工作空間,集中注意力"
+ },
+ "GUIDED SETUP": {
+ "en": "GUIDED SETUP",
+ "ko": "시작 안내",
+ "ja": "使いながら設定",
+ "zh-Hans": "设置向导",
+ "zh-Hant": "設定導覽"
+ },
+ "Hold the switcher shortcut to inspect the windows in this workspace, then switch back with a quick tap.": {
+ "en": "Hold the switcher shortcut to inspect the windows in this workspace, then switch back with a quick tap.",
+ "ko": "전환 단축키를 길게 눌러 현재 창들을 확인해요. 짧게 누르면 바로 돌아갈 수 있어요.",
+ "ja": "切り替えキーを長く押してウインドウを確認し、短く押して戻れます。",
+ "zh-Hans": "按住切换快捷键查看当前窗口,轻按即可快速返回。",
+ "zh-Hant": "按住切換快速鍵查看目前視窗,輕按即可快速返回。"
+ },
+ "I am comparing the color directions.": {
+ "en": "I am comparing the color directions.",
+ "ko": "색상 방향을 비교하고 있어요.",
+ "ja": "配色の方向性を比べています。",
+ "zh-Hans": "我正在比较配色方向。",
+ "zh-Hant": "我正在比較配色方向。"
+ },
+ "Inspect one tile at full workspace size, then return to the exact arrangement.": {
+ "en": "Inspect one tile at full workspace size, then return to the exact arrangement.",
+ "ko": "창 하나를 작업 공간 크기로 펼쳐서 확인한 뒤 정확히 이전 배치로 돌아와요.",
+ "ja": "一つのウインドウをワークスペースいっぱいに広げ、元の配置に戻します。",
+ "zh-Hans": "将一个窗口放大到工作区大小,再准确恢复原来的布局。",
+ "zh-Hant": "將一個視窗放大到工作空間大小,再完整還原原來的排列。"
+ },
+ "Inspect the real workspace list, then run a short script that activates, balances and borrows.": {
+ "en": "Inspect the real workspace list, then run a short script that activates, balances and borrows.",
+ "ko": "실제 작업 공간 목록을 확인한 뒤 활성화·균등 배치·빌려오기를 묶은 스크립트를 실행해요.",
+ "ja": "実際のワークスペース一覧を確認し、切り替え・均等配置・借りる操作をまとめたスクリプトを実行します。",
+ "zh-Hans": "查看实际工作区列表,再运行包含激活、均衡布局和借用操作的脚本。",
+ "zh-Hant": "查看實際工作空間清單,再執行包含啟用、平均排列與借用操作的指令稿。"
+ },
+ "Keep a note above this workspace": {
+ "en": "Keep a note above this workspace",
+ "ko": "작업 공간 위에 메모 두기",
+ "ja": "ワークスペースの手前にメモを置く",
+ "zh-Hans": "把便笺放在工作区上方",
+ "zh-Hant": "把便箋放在工作空間上方"
+ },
+ "Keep a reusable scratchpad": {
+ "en": "Keep a reusable scratchpad",
+ "ko": "다시 꺼내 쓰는 임시 공간",
+ "ja": "何度でも呼び出せる一時スペース",
+ "zh-Hans": "随时调出的暂存区",
+ "zh-Hant": "隨時叫出的暫存區"
+ },
+ "Keep design and reference on separate displays; switch a workspace chain with one shortcut.": {
+ "en": "Keep design and reference on separate displays; switch a workspace chain with one shortcut.",
+ "ko": "디자인과 참고 자료를 다른 화면에 두고, 단축키 하나로 작업 공간 체인을 전환해요.",
+ "ja": "デザインと資料を別々の画面に置き、一つのショートカットでチェーンを切り替えます。",
+ "zh-Hans": "把设计和参考资料放在不同显示器上,用一个快捷键切换工作区链。",
+ "zh-Hant": "把設計和參考資料放在不同顯示器上,用一個快速鍵切換工作空間鏈。"
+ },
+ "Keep your tools. Keep your place. | A macOS workspace manager with BSP window tiling.": {
+ "en": "Keep your tools. Keep your place. | A macOS workspace manager with BSP window tiling.",
+ "ko": "도구도, 하던 자리도 그대로예요. | BSP 타일링을 갖춘 macOS 작업 공간 관리자예요.",
+ "ja": "ツールも、作業の場所も、そのまま。 | BSP タイル表示を備えた macOS 用ワークスペースマネージャです。",
+ "zh-Hans": "工具和工作位置,都为你保留。 | 支持 BSP 窗口平铺的 macOS 工作区管理器。",
+ "zh-Hant": "工具與工作位置,都為你保留。 | 支援 BSP 視窗並排的 macOS 工作空間管理工具。"
+ },
+ "LAYOUT CONTROLS": {
+ "en": "LAYOUT CONTROLS",
+ "ko": "배치 조절",
+ "ja": "配置の操作",
+ "zh-Hans": "布局控制",
+ "zh-Hant": "排列控制"
+ },
+ "LAYOUT EDITOR": {
+ "en": "LAYOUT EDITOR",
+ "ko": "배치 편집기",
+ "ja": "レイアウトエディタ",
+ "zh-Hans": "布局编辑器",
+ "zh-Hant": "排列編輯器"
+ },
+ "LEAVE AS IS": {
+ "en": "LEAVE AS IS",
+ "ko": "그대로 두기",
+ "ja": "そのまま",
+ "zh-Hans": "保持原样",
+ "zh-Hant": "維持原狀"
+ },
+ "LIFECYCLE & HUD HOOKS": {
+ "en": "LIFECYCLE & HUD HOOKS",
+ "ko": "작업 공간과 화면 알림 훅",
+ "ja": "状態と操作フィードバックのフック",
+ "zh-Hans": "工作区与操作反馈钩子",
+ "zh-Hant": "工作空間與操作回饋掛鉤"
+ },
+ "Learn by doing": {
+ "en": "Learn by doing",
+ "ko": "직접 사용하며 익히기",
+ "ja": "使いながら覚える",
+ "zh-Hans": "边用边学",
+ "zh-Hant": "邊用邊學"
+ },
+ "Leave a window where you put it": {
+ "en": "Leave a window where you put it",
+ "ko": "놓아둔 자리에 창 유지하기",
+ "ja": "置いた場所にウインドウを残す",
+ "zh-Hans": "让窗口留在原处",
+ "zh-Hant": "讓視窗留在原處"
+ },
+ "Let new windows find their place": {
+ "en": "Let new windows find their place",
+ "ko": "새 창이 제자리를 찾도록 하기",
+ "ja": "新しいウインドウが自然に収まる",
+ "zh-Hans": "让新窗口找到位置",
+ "zh-Hant": "讓新視窗找到位置"
+ },
+ "MULTIPLE DISPLAYS": {
+ "en": "MULTIPLE DISPLAYS",
+ "ko": "여러 화면",
+ "ja": "複数のディスプレイ",
+ "zh-Hans": "多个显示器",
+ "zh-Hant": "多部顯示器"
+ },
+ "Make room for the whole workflow.": {
+ "en": "Make room for the whole workflow.",
+ "ko": "모든 작업에 알맞은 자리를 만들어요.",
+ "ja": "仕事の流れに、必要な場所を。",
+ "zh-Hans": "为整个工作流程腾出空间。",
+ "zh-Hant": "為整個工作流程騰出空間。"
+ },
+ "Make workspace changes useful elsewhere": {
+ "en": "Make workspace changes useful elsewhere",
+ "ko": "작업 공간 변경을 다른 도구에 연결하기",
+ "ja": "ワークスペースの切り替えをほかのツールに届ける",
+ "zh-Hans": "把工作区变化连接到其他工具",
+ "zh-Hant": "把工作空間變化連接到其他工具"
+ },
+ "Move an app with the task": {
+ "en": "Move an app with the task",
+ "ko": "작업에 맞춰 앱 옮기기",
+ "ja": "作業に合わせてアプリを移す",
+ "zh-Hans": "随任务移动应用",
+ "zh-Hant": "隨工作移動 App"
+ },
+ "Move between tasks": {
+ "en": "Move between tasks",
+ "ko": "작업 사이를 오가기",
+ "ja": "作業を切り替える",
+ "zh-Hans": "在任务之间切换",
+ "zh-Hant": "在工作之間切換"
+ },
+ "Move focus by direction, let the pointer follow, then focus a reference simply by hovering.": {
+ "en": "Move focus by direction, let the pointer follow, then focus a reference simply by hovering.",
+ "ko": "방향으로 포커스를 옮기면 포인터가 따라와요. 마우스를 올려서 참고 자료를 선택할 수도 있어요.",
+ "ja": "方向キーでフォーカスを移すとポインタも追従します。資料にポインタを置くだけでも選べます。",
+ "zh-Hans": "按方向移动焦点,让指针跟随;也可以悬停在参考资料上切换焦点。",
+ "zh-Hant": "依方向移動焦點,讓游標跟隨;也可以將游標移到參考資料上切換焦點。"
+ },
+ "Nothing changes until you apply the draft.": {
+ "en": "Nothing changes until you apply the draft.",
+ "ko": "초안을 적용하기 전까지 실제 환경은 그대로예요.",
+ "ja": "下書きを適用するまで、実際の環境は変わりません。",
+ "zh-Hans": "应用草稿前,实际桌面保持不变。",
+ "zh-Hant": "套用草稿前,實際桌面保持不變。"
+ },
+ "One status window across tasks": {
+ "en": "One status window across tasks",
+ "ko": "여러 작업에서 같은 상태 창 보기",
+ "ja": "作業が変わっても同じ状態ウインドウを使う",
+ "zh-Hans": "跨任务使用同一个状态窗口",
+ "zh-Hant": "跨工作使用同一個狀態視窗"
+ },
+ "One workspace shortcut launches the preview and reference. Close a window and re-enter to reopen it.": {
+ "en": "One workspace shortcut launches the preview and reference. Close a window and re-enter to reopen it.",
+ "ko": "단축키 하나로 미리보기와 참고 자료가 열려요. 창을 닫아도 다시 들어가면 열어줘요.",
+ "ja": "一つのショートカットでプレビューと資料が開きます。閉じたウインドウも、入り直すと開きます。",
+ "zh-Hans": "一个工作区快捷键打开预览和参考资料。关闭窗口后,重新进入即可再次打开。",
+ "zh-Hant": "一個工作空間快速鍵開啟預覽和參考資料。關閉視窗後,重新進入即可再次開啟。"
+ },
+ "Only the draft changes. Your desktop stays put.": {
+ "en": "Only the draft changes. Your desktop stays put.",
+ "ko": "초안만 바뀌고 실제 화면은 그대로예요.",
+ "ja": "変わるのは下書きだけ。実際のデスクトップはそのままです。",
+ "zh-Hans": "只修改草稿,实际桌面保持不变。",
+ "zh-Hant": "只修改草稿,實際桌面保持不變。"
+ },
+ "Open a second reference next to the canvas. Close it and the layout reclaims the space.": {
+ "en": "Open a second reference next to the canvas. Close it and the layout reclaims the space.",
+ "ko": "Canvas 옆에 참고 자료를 하나 더 열어요. 닫으면 나머지 창이 공간을 채워요.",
+ "ja": "Canvas の横に資料をもう一つ開きます。閉じると、ほかのウインドウが広がります。",
+ "zh-Hans": "在 Canvas 旁再打开一份参考资料。关闭后,其余窗口填补空位。",
+ "zh-Hant": "在 Canvas 旁再開啟一份參考資料。關閉後,其餘視窗填補空位。"
+ },
+ "Open the whole toolset": {
+ "en": "Open the whole toolset",
+ "ko": "필요한 도구 한 번에 열기",
+ "ja": "必要なツールをまとめて開く",
+ "zh-Hans": "一次打开所需工具",
+ "zh-Hant": "一次開啟所需工具"
+ },
+ "Or move through your workspaces in order.": {
+ "en": "Or move through your workspaces in order.",
+ "ko": "작업 공간을 순서대로 오갈 수도 있어요.",
+ "ja": "ワークスペースを順番に切り替えることもできます。",
+ "zh-Hans": "也可以按顺序切换工作区。",
+ "zh-Hant": "也可以依序切換工作空間。"
+ },
+ "PAUSE & RESUME": {
+ "en": "PAUSE & RESUME",
+ "ko": "일시 정지와 다시 시작",
+ "ja": "一時停止と再開",
+ "zh-Hans": "暂停与恢复",
+ "zh-Hant": "暫停與恢復"
+ },
+ "PLAIN TOML / LIVE RELOAD": {
+ "en": "PLAIN TOML / LIVE RELOAD",
+ "ko": "TOML / 바로 적용",
+ "ja": "TOML / その場で反映",
+ "zh-Hans": "TOML / 实时重载",
+ "zh-Hant": "TOML / 即時重新載入"
+ },
+ "PROFILES": {
+ "en": "PROFILES",
+ "ko": "프로필",
+ "ja": "プロファイル",
+ "zh-Hans": "配置方案",
+ "zh-Hant": "設定組合"
+ },
+ "Pause automatic tiling, position a window freely, and resume the arranged workspace.": {
+ "en": "Pause automatic tiling, position a window freely, and resume the arranged workspace.",
+ "ko": "자동 타일링을 잠시 멈추고 창을 자유롭게 놓아요. 다시 켜면 정돈된 배치로 돌아와요.",
+ "ja": "自動タイル表示を止めて自由に配置し、再開すると整った配置に戻ります。",
+ "zh-Hans": "暂停自动平铺,自由摆放窗口,再恢复整理好的工作区。",
+ "zh-Hant": "暫停自動並排,自由擺放視窗,再恢復整理好的工作空間。"
+ },
+ "Pause tiling when the task needs free placement.": {
+ "en": "Pause tiling when the task needs free placement.",
+ "ko": "자유롭게 놓고 싶을 때 타일링을 잠시 멈춰요.",
+ "ja": "自由に置きたいときは、タイル表示を一時停止します。",
+ "zh-Hans": "需要自由摆放时,暂停平铺。",
+ "zh-Hant": "需要自由擺放時,暫停並排。"
+ },
+ "Plan the layout visually": {
+ "en": "Plan the layout visually",
+ "ko": "눈으로 보며 배치 정하기",
+ "ja": "見ながら配置を決める",
+ "zh-Hans": "直观规划布局",
+ "zh-Hant": "直覺規劃排列"
+ },
+ "Prepare the release announcement.": {
+ "en": "Prepare the release announcement.",
+ "ko": "출시 안내를 준비해요.",
+ "ja": "公開のお知らせを準備します。",
+ "zh-Hans": "准备发布公告。",
+ "zh-Hant": "準備發布公告。"
+ },
+ "Preview gesture actions and practise real shortcuts without moving the live desktop. Physical trackpad input is not shown.": {
+ "en": "Preview gesture actions and practise real shortcuts without moving the live desktop. Physical trackpad input is not shown.",
+ "ko": "실제 화면을 움직이지 않고 제스처 동작과 단축키를 익혀요. 물리 트랙패드 입력 장면은 포함하지 않았어요.",
+ "ja": "実際の画面を動かさずに、ジェスチャのプレビューとショートカットを練習します。実機のトラックパッド入力は含みません。",
+ "zh-Hans": "不移动实际桌面,预览手势操作并练习快捷键。本片不展示物理触控板输入。",
+ "zh-Hant": "不移動實際桌面,預覽手勢操作並練習快速鍵。本片不展示實體觸控式軌跡板輸入。"
+ },
+ "Preview gesture actions with the built-in controls.": {
+ "en": "Preview gesture actions with the built-in controls.",
+ "ko": "안내에 있는 버튼으로 제스처 동작을 미리 봐요.",
+ "ja": "ガイド内のボタンで、ジェスチャの動きを確認します。",
+ "zh-Hans": "用向导内的按钮预览手势操作。",
+ "zh-Hant": "用導覽內的按鈕預覽手勢操作。"
+ },
+ "REUSE A SETUP": {
+ "en": "REUSE A SETUP",
+ "ko": "기존 구성 재사용",
+ "ja": "構成を再利用",
+ "zh-Hans": "复用现有配置",
+ "zh-Hant": "重複使用現有設定"
+ },
+ "Ready. The preview is exported.": {
+ "en": "Ready. The preview is exported.",
+ "ko": "준비됐어요. 미리보기도 내보냈어요.",
+ "ja": "準備できました。プレビューも書き出しました。",
+ "zh-Hans": "准备好了,预览也已导出。",
+ "zh-Hant": "準備好了,預覽也已輸出。"
+ },
+ "Real lifecycle and HUD events update an external status window as you switch, zoom and borrow.": {
+ "en": "Real lifecycle and HUD events update an external status window as you switch, zoom and borrow.",
+ "ko": "전환·확대·빌려오기 중에 실제 작업 공간·화면 알림 이벤트로 외부 상태 창이 갱신돼요.",
+ "ja": "切り替え・拡大・借りる操作に合わせて、実際のイベントが外部の状態ウインドウを更新します。",
+ "zh-Hans": "切换、放大或借用时,实际的生命周期和操作反馈事件会更新外部状态窗口。",
+ "zh-Hant": "切換、放大或借用時,實際的生命週期與操作回饋事件會更新外部狀態視窗。"
+ },
+ "Resume. Tatami arranges the workspace again.": {
+ "en": "Resume. Tatami arranges the workspace again.",
+ "ko": "다시 켜면 Tatami가 창을 정리해요.",
+ "ja": "再開すると、Tatami がウインドウを整えます。",
+ "zh-Hans": "恢复后,Tatami 会重新排列窗口。",
+ "zh-Hant": "恢復後,Tatami 會重新排列視窗。"
+ },
+ "Reuse an existing setup": {
+ "en": "Reuse an existing setup",
+ "ko": "기존 구성 재사용하기",
+ "ja": "今ある構成を再利用する",
+ "zh-Hans": "复用现有配置",
+ "zh-Hant": "重複使用現有設定"
+ },
+ "Review a suggested setup": {
+ "en": "Review a suggested setup",
+ "ko": "제안받은 구성 검토하기",
+ "ja": "提案された構成を確認する",
+ "zh-Hans": "检查建议的配置",
+ "zh-Hant": "檢查建議的設定"
+ },
+ "Rotate, mirror and balance a saved layout in Tatami’s native editor, then activate it.": {
+ "en": "Rotate, mirror and balance a saved layout in Tatami’s native editor, then activate it.",
+ "ko": "Tatami 배치 편집기에서 회전·뒤집기·균등 배치를 해보고 작업 공간에 적용해요.",
+ "ja": "Tatami のエディタで回転・反転・均等配置を試してから、ワークスペースを開きます。",
+ "zh-Hans": "在 Tatami 原生编辑器中旋转、镜像并均衡保存的布局,然后激活工作区。",
+ "zh-Hant": "在 Tatami 原生編輯器中旋轉、鏡像並平均調整儲存的排列,然後啟用工作空間。"
+ },
+ "SCRATCHPADS": {
+ "en": "SCRATCHPADS",
+ "ko": "임시 공간",
+ "ja": "一時スペース",
+ "zh-Hans": "暂存区",
+ "zh-Hant": "暫存區"
+ },
+ "SHARED APPS": {
+ "en": "SHARED APPS",
+ "ko": "공용 앱",
+ "ja": "共有アプリ",
+ "zh-Hans": "共享应用",
+ "zh-Hant": "共用 App"
+ },
+ "Send the reference from Write to Review, then move it back using the keyboard.": {
+ "en": "Send the reference from Write to Review, then move it back using the keyboard.",
+ "ko": "키보드로 참고 자료를 Write에서 Review로 옮겼다가 다시 가져와요.",
+ "ja": "キーボードで資料を Write から Review に移し、また戻します。",
+ "zh-Hans": "用键盘把参考资料从 Write 移到 Review,再移回来。",
+ "zh-Hant": "用鍵盤把參考資料從 Write 移到 Review,再移回來。"
+ },
+ "Shape the layout from the keyboard": {
+ "en": "Shape the layout from the keyboard",
+ "ko": "키보드로 배치 다듬기",
+ "ja": "キーボードで配置を整える",
+ "zh-Hans": "用键盘调整布局",
+ "zh-Hant": "用鍵盤調整排列"
+ },
+ "Shared membership with normal stacking: the window keeps its position and size across tasks.": {
+ "en": "Shared membership with normal stacking: the window keeps its position and size across tasks.",
+ "ko": "공용 앱을 일반 창으로 두면 작업을 바꿔도 위치와 크기가 유지돼요.",
+ "ja": "共有アプリを通常の重なり順にすると、作業が変わっても位置と大きさが保たれます。",
+ "zh-Hans": "让共享应用保持普通堆叠顺序,跨任务保留窗口的位置和大小。",
+ "zh-Hant": "讓共用 App 維持一般堆疊順序,跨工作保留視窗的位置和大小。"
+ },
+ "Start with the setup that already works.": {
+ "en": "Start with the setup that already works.",
+ "ko": "잘 쓰던 구성에서 시작해요.",
+ "ja": "使い慣れた構成から始めます。",
+ "zh-Hans": "从已经顺手的配置开始。",
+ "zh-Hant": "從已經順手的設定開始。"
+ },
+ "Swap windows, change a split, resize it, and balance the workspace.": {
+ "en": "Swap windows, change a split, resize it, and balance the workspace.",
+ "ko": "창을 바꾸고 나누는 방향과 크기를 조절한 뒤 전체 비율을 맞춰요.",
+ "ja": "ウインドウを入れ替え、分割の向きと大きさを変え、全体を整えます。",
+ "zh-Hans": "交换窗口、改变分割方向、调整大小,再均衡整个布局。",
+ "zh-Hant": "交換視窗、變更分割方向、調整大小,再平均調整整個排列。"
+ },
+ "Switch a note between Always on Top and tiled placement, and interact with its real controls.": {
+ "en": "Switch a note between Always on Top and tiled placement, and interact with its real controls.",
+ "ko": "메모를 항상 위에 두거나 타일링에 포함하고 실제 버튼과 입력창을 사용해요.",
+ "ja": "メモを常に手前に置いたりタイル表示に戻したりしながら、実際のボタンや入力欄を使います。",
+ "zh-Hans": "在置顶和参与平铺之间切换便笺,并使用其实际控件。",
+ "zh-Hant": "在置頂和參與並排之間切換便箋,並使用其實際控制項。"
+ },
+ "Switch the complete setup": {
+ "en": "Switch the complete setup",
+ "ko": "전체 구성 전환하기",
+ "ja": "作業環境ごと切り替える",
+ "zh-Hans": "切换整套配置",
+ "zh-Hant": "切換整套設定"
+ },
+ "Switch to a conversation, return to design, then move through the workspace order.": {
+ "en": "Switch to a conversation, return to design, then move through the workspace order.",
+ "ko": "대화로 전환했다가 디자인으로 돌아와요. 작업 공간 순서대로 이동하는 모습도 보여줘요.",
+ "ja": "会話に切り替えてからデザインに戻り、ワークスペースを順番にも切り替えます。",
+ "zh-Hans": "切换到对话,再返回设计,然后按工作区顺序移动。",
+ "zh-Hant": "切換到對話,再返回設計,然後依工作空間順序移動。"
+ },
+ "TATAMI": {
+ "en": "TATAMI",
+ "ko": "TATAMI",
+ "ja": "TATAMI",
+ "zh-Hans": "TATAMI",
+ "zh-Hant": "TATAMI"
+ },
+ "TATAMI / ADAPT": {
+ "en": "TATAMI / ADAPT",
+ "ko": "Tatami / 화면에 맞추기",
+ "ja": "Tatami / 環境に合わせる",
+ "zh-Hans": "Tatami / 适应环境",
+ "zh-Hant": "Tatami / 配合環境"
+ },
+ "TATAMI / ALWAYS ON TOP": {
+ "en": "TATAMI / ALWAYS ON TOP",
+ "ko": "Tatami / 항상 위",
+ "ja": "Tatami / 常に手前",
+ "zh-Hans": "Tatami / 置顶",
+ "zh-Hant": "Tatami / 置頂"
+ },
+ "TATAMI / AUTOMATE": {
+ "en": "TATAMI / AUTOMATE",
+ "ko": "Tatami / 자동화",
+ "ja": "Tatami / 自動化",
+ "zh-Hans": "Tatami / 自动化",
+ "zh-Hant": "Tatami / 自動化"
+ },
+ "TATAMI / BORROW": {
+ "en": "TATAMI / BORROW",
+ "ko": "Tatami / 빌려오기",
+ "ja": "Tatami / 借りる",
+ "zh-Hans": "Tatami / 借用",
+ "zh-Hant": "Tatami / 借用"
+ },
+ "TATAMI / DESIGN": {
+ "en": "TATAMI / DESIGN",
+ "ko": "Tatami / 디자인",
+ "ja": "Tatami / デザイン",
+ "zh-Hans": "Tatami / 设计",
+ "zh-Hant": "Tatami / 設計"
+ },
+ "TATAMI / REVIEW": {
+ "en": "TATAMI / REVIEW",
+ "ko": "Tatami / 검토",
+ "ja": "Tatami / レビュー",
+ "zh-Hans": "Tatami / 评审",
+ "zh-Hant": "Tatami / 審閱"
+ },
+ "TATAMI / WRITE": {
+ "en": "TATAMI / WRITE",
+ "ko": "Tatami / 글쓰기",
+ "ja": "Tatami / 執筆",
+ "zh-Hans": "Tatami / 写作",
+ "zh-Hant": "Tatami / 撰寫"
+ },
+ "Take manual control": {
+ "en": "Take manual control",
+ "ko": "직접 창 배치하기",
+ "ja": "自分でウインドウを配置する",
+ "zh-Hans": "手动控制布局",
+ "zh-Hant": "手動控制排列"
+ },
+ "The sand direction is ready to review.": {
+ "en": "The sand direction is ready to review.",
+ "ko": "샌드 색상 안을 검토할 수 있어요.",
+ "ja": "サンドの案をレビューできます。",
+ "zh-Hans": "沙金配色已经可以评审了。",
+ "zh-Hant": "沙金配色已經可以審閱了。"
+ },
+ "Two displays, one workflow": {
+ "en": "Two displays, one workflow",
+ "ko": "두 화면으로 이어지는 작업",
+ "ja": "二つの画面で、一つの作業",
+ "zh-Hans": "两个显示器,一套工作流程",
+ "zh-Hant": "兩部顯示器,一套工作流程"
+ },
+ "Validate an example AI proposal and apply it to the draft. No external AI service is called in this recording.": {
+ "en": "Validate an example AI proposal and apply it to the draft. No external AI service is called in this recording.",
+ "ko": "예시 AI 제안을 검증하고 초안에 적용해요. 이 영상에서는 외부 AI 서비스를 호출하지 않아요.",
+ "ja": "AI 提案の例を検証し、下書きに反映します。この録画では外部の AI サービスを呼び出していません。",
+ "zh-Hans": "验证示例 AI 建议并应用到草稿。本次录制未调用外部 AI 服务。",
+ "zh-Hant": "驗證範例 AI 建議並套用到草稿。本次錄製未呼叫外部 AI 服務。"
+ },
+ "WINDOW SWITCHER": {
+ "en": "WINDOW SWITCHER",
+ "ko": "창 전환",
+ "ja": "ウインドウ切り替え",
+ "zh-Hans": "窗口切换",
+ "zh-Hant": "視窗切換"
+ },
+ "WORKSPACES": {
+ "en": "WORKSPACES",
+ "ko": "작업 공간",
+ "ja": "ワークスペース",
+ "zh-Hans": "工作区",
+ "zh-Hant": "工作空間"
+ },
+ "Your setup follows the displays": {
+ "en": "Your setup follows the displays",
+ "ko": "화면 구성에 맞춰 자동으로 바꾸기",
+ "ja": "画面に合わせて環境を切り替える",
+ "zh-Hans": "配置跟随显示器变化",
+ "zh-Hant": "設定跟隨顯示器變化"
+ },
+ "ZOOM & FULLSCREEN": {
+ "en": "ZOOM & FULLSCREEN",
+ "ko": "작업 공간 확대",
+ "ja": "ワークスペースの拡大",
+ "zh-Hans": "放大工作区",
+ "zh-Hant": "放大工作空間"
+ },
+ "headline": {
+ "en": "headline",
+ "ko": "제목",
+ "ja": "見出し",
+ "zh-Hans": "标题",
+ "zh-Hant": "標題"
+ },
+ "Each workspace keeps the apps and layout for one task.": {
+ "en": "Each workspace keeps the apps and layout for one task.",
+ "ko": "작업 공간마다 필요한 앱과 창 배치를 따로 관리해요.",
+ "ja": "作業ごとに、使うアプリとウインドウ配置をまとめて管理できます。",
+ "zh-Hans": "每个工作区分别保存一项任务所需的应用和窗口布局。",
+ "zh-Hant": "每個工作空間分別保存一項工作所需的 App 與視窗排列。"
+ },
+ "Enlarge one window to fill the workspace.": {
+ "en": "Enlarge one window to fill the workspace.",
+ "ko": "창 하나를 작업 공간 전체 크기로 확대해요.",
+ "ja": "一つのウインドウをワークスペースいっぱいに広げます。",
+ "zh-Hans": "将一个窗口放大到整个工作区。",
+ "zh-Hant": "將一個視窗放大至整個工作空間。"
+ },
+ "Exit zoom to restore the previous window layout.": {
+ "en": "Exit zoom to restore the previous window layout.",
+ "ko": "확대를 해제하면 이전 창 배치로 돌아와요.",
+ "ja": "拡大を解除すると、元のウインドウ配置に戻ります。",
+ "zh-Hans": "退出放大,恢复原来的窗口布局。",
+ "zh-Hant": "取消放大後,恢復原本的視窗排列。"
+ },
+ "Switch workspaces to bring up the apps for your next task.": {
+ "en": "Switch workspaces to bring up the apps for your next task.",
+ "ko": "작업 공간을 바꾸면 다음 작업에 필요한 앱이 함께 나타나요.",
+ "ja": "ワークスペースを切り替えると、次の作業で使うアプリが表示されます。",
+ "zh-Hans": "切换工作区,一起调出下一项任务需要的应用。",
+ "zh-Hant": "切換工作空間,一次顯示下一項工作需要的 App。"
+ },
+ "Return to Design; its apps are ready where you left them.": {
+ "en": "Return to Design; its apps are ready where you left them.",
+ "ko": "Design으로 돌아오면 사용하던 앱이 제자리에 나타나요.",
+ "ja": "Design に戻ると、使っていたアプリが元の位置に表示されます。",
+ "zh-Hans": "回到 Design,之前的应用仍在原来的位置。",
+ "zh-Hant": "回到 Design,先前使用的 App 仍在原本的位置。"
+ },
+ "A workspace shortcut switches the whole set of apps.": {
+ "en": "A workspace shortcut switches the whole set of apps.",
+ "ko": "작업 공간 단축키로 사용하는 앱 구성을 한 번에 바꿔요.",
+ "ja": "ショートカット一つで、表示するアプリの組み合わせを切り替えます。",
+ "zh-Hans": "用工作区快捷键一次切换整组应用。",
+ "zh-Hant": "用工作空間快速鍵一次切換整組 App。"
+ },
+ "Window positions and sizes are restored when you return.": {
+ "en": "Window positions and sizes are restored when you return.",
+ "ko": "돌아오면 창의 위치와 크기가 그대로 복원돼요.",
+ "ja": "戻ると、ウインドウの位置と大きさが復元されます。",
+ "zh-Hans": "返回时,窗口的位置和大小会恢复。",
+ "zh-Hant": "返回時,視窗的位置與大小會恢復。"
+ },
+ "Borrow adds another workspace beside the current one.": {
+ "en": "Borrow adds another workspace beside the current one.",
+ "ko": "빌려오기로 다른 작업 공간을 현재 작업 옆에 띄워요.",
+ "ja": "借りる機能で、別のワークスペースを今の作業の隣に表示します。",
+ "zh-Hans": "用借用功能,把另一个工作区显示在当前工作区旁边。",
+ "zh-Hant": "用借用功能,將另一個工作空間顯示在目前的工作空間旁。"
+ },
+ "Always on Top keeps a window visible above other apps.": {
+ "en": "Always on Top keeps a window visible above other apps.",
+ "ko": "항상 위로 설정한 창은 다른 앱 위에 계속 보여요.",
+ "ja": "最前面表示にすると、ほかのアプリの手前にウインドウを保てます。",
+ "zh-Hans": "设为始终置顶的窗口会一直显示在其他应用上方。",
+ "zh-Hant": "設為保持最上層的視窗,會持續顯示在其他 App 上方。"
+ },
+ "Use the CLI to combine workspace actions in a script.": {
+ "en": "Use the CLI to combine workspace actions in a script.",
+ "ko": "CLI로 작업 공간 동작들을 스크립트에 묶어 실행해요.",
+ "ja": "CLI を使うと、複数のワークスペース操作をスクリプトにまとめられます。",
+ "zh-Hans": "用 CLI 将多项工作区操作组合成脚本。",
+ "zh-Hant": "用 CLI 將多個工作空間操作組合成指令稿。"
+ },
+ "Connecting a display automatically activates the matching profile.": {
+ "en": "Connecting a display automatically activates the matching profile.",
+ "ko": "화면을 연결하면 조건에 맞는 프로필이 자동으로 켜져요.",
+ "ja": "ディスプレイを接続すると、条件に合うプロファイルへ自動で切り替わります。",
+ "zh-Hans": "连接显示器后,自动切换到符合条件的配置方案。",
+ "zh-Hant": "連接顯示器後,自動切換至符合條件的設定組合。"
+ },
+ "Disconnecting the display restores the laptop profile and layout.": {
+ "en": "Disconnecting the display restores the laptop profile and layout.",
+ "ko": "화면 연결을 해제하면 노트북용 프로필과 배치로 돌아와요.",
+ "ja": "接続を外すと、ノートブック用のプロファイルと配置に戻ります。",
+ "zh-Hans": "断开显示器后,恢复笔记本的配置方案和布局。",
+ "zh-Hant": "中斷顯示器連接後,恢復筆電的設定組合與排列。"
+ },
+ "A shortcut switches to another workspace and its apps.": {
+ "en": "A shortcut switches to another workspace and its apps.",
+ "ko": "단축키로 작업 공간과 그 안의 앱을 함께 전환해요.",
+ "ja": "ショートカットで、ワークスペースと使用するアプリをまとめて切り替えます。",
+ "zh-Hans": "用快捷键切换工作区及其中的应用。",
+ "zh-Hant": "用快速鍵切換工作空間及其中的 App。"
+ },
+ "The recent-workspace shortcut returns to the previous task.": {
+ "en": "The recent-workspace shortcut returns to the previous task.",
+ "ko": "최근 작업 공간 단축키로 방금 하던 작업에 돌아가요.",
+ "ja": "直前のワークスペースに戻るショートカットで、前の作業を再開できます。",
+ "zh-Hans": "用最近工作区快捷键,回到刚才的任务。",
+ "zh-Hant": "用最近工作空間快速鍵,回到剛才的工作。"
+ },
+ "Activating a workspace automatically opens its assigned apps.": {
+ "en": "Activating a workspace automatically opens its assigned apps.",
+ "ko": "작업 공간을 켜면 배정한 앱들이 자동으로 열려요.",
+ "ja": "ワークスペースを開くと、割り当てたアプリが自動で起動します。",
+ "zh-Hans": "激活工作区时,自动打开分配给它的应用。",
+ "zh-Hant": "啟用工作空間時,自動開啟指派給它的 App。"
+ },
+ "With Auto-open enabled, returning also reopens closed windows.": {
+ "en": "With Auto-open enabled, returning also reopens closed windows.",
+ "ko": "자동 열기를 켜두면 다시 들어올 때 닫았던 창도 열려요.",
+ "ja": "自動起動を有効にすると、戻ったときに閉じたウインドウも開きます。",
+ "zh-Hans": "启用自动打开后,返回工作区也会重新打开已关闭的窗口。",
+ "zh-Hant": "啟用自動開啟後,返回工作空間也會重新開啟已關閉的視窗。"
+ },
+ "Move the focused app to a different workspace with a shortcut.": {
+ "en": "Move the focused app to a different workspace with a shortcut.",
+ "ko": "단축키로 현재 앱을 다른 작업 공간에 옮겨요.",
+ "ja": "ショートカットで、選択中のアプリを別のワークスペースへ移します。",
+ "zh-Hans": "用快捷键把当前应用移到另一个工作区。",
+ "zh-Hant": "用快速鍵將目前的 App 移至另一個工作空間。"
+ },
+ "The moved app is no longer assigned to its original workspace.": {
+ "en": "The moved app is no longer assigned to its original workspace.",
+ "ko": "옮긴 앱은 원래 작업 공간에서 빠져요.",
+ "ja": "移したアプリは、元のワークスペースから外れます。",
+ "zh-Hans": "移动后,该应用不再属于原来的工作区。",
+ "zh-Hant": "移動後,該 App 不再屬於原本的工作空間。"
+ },
+ "Use the same shortcut to move the app back.": {
+ "en": "Use the same shortcut to move the app back.",
+ "ko": "같은 방식의 단축키로 앱을 다시 옮길 수 있어요.",
+ "ja": "同じ操作で、アプリを元のワークスペースへ戻せます。",
+ "zh-Hans": "用同样的快捷键操作将应用移回去。",
+ "zh-Hant": "用相同的快速鍵操作將 App 移回去。"
+ },
+ "Switching profiles changes the complete workspace configuration.": {
+ "en": "Switching profiles changes the complete workspace configuration.",
+ "ko": "프로필을 전환하면 작업 공간 구성이 통째로 바뀌어요.",
+ "ja": "プロファイルを切り替えると、ワークスペースの構成全体が変わります。",
+ "zh-Hans": "切换配置方案会更换整套工作区配置。",
+ "zh-Hant": "切換設定組合會更換整套工作空間設定。"
+ },
+ "Each profile restores its own window layout.": {
+ "en": "Each profile restores its own window layout.",
+ "ko": "프로필마다 저장해둔 창 배치로 돌아올 수 있어요.",
+ "ja": "プロファイルごとに保存したウインドウ配置へ戻れます。",
+ "zh-Hans": "每个配置方案都会恢复各自保存的窗口布局。",
+ "zh-Hant": "每個設定組合都會恢復各自儲存的視窗排列。"
+ },
+ "A new window splits the space beside the focused window.": {
+ "en": "A new window splits the space beside the focused window.",
+ "ko": "새 창을 열면 선택한 창의 영역을 나눠 함께 배치해요.",
+ "ja": "新しいウインドウは、選択中のウインドウの領域を分割して配置されます。",
+ "zh-Hans": "新窗口会分割当前选中窗口的区域,自动排列。",
+ "zh-Hant": "新視窗會分割目前選取視窗的區域,自動排列。"
+ },
+ "Other tiles keep their positions while the new window fits in.": {
+ "en": "Other tiles keep their positions while the new window fits in.",
+ "ko": "새 창이 들어와도 다른 영역의 창은 제자리를 유지해요.",
+ "ja": "新しいウインドウが入っても、ほかの領域の配置は変わりません。",
+ "zh-Hans": "新窗口加入时,其他区域的窗口保持原位。",
+ "zh-Hant": "新視窗加入時,其他區域的視窗保持原位。"
+ },
+ "Closing a window lets the remaining tiles fill the space.": {
+ "en": "Closing a window lets the remaining tiles fill the space.",
+ "ko": "창을 닫으면 남은 창이 빈 공간을 채워요.",
+ "ja": "ウインドウを閉じると、残ったウインドウが空いた領域を埋めます。",
+ "zh-Hans": "关闭窗口后,其余窗口会填满腾出的空间。",
+ "zh-Hant": "關閉視窗後,其餘視窗會填滿空出的空間。"
+ },
+ "Swap the focused window with the window beside it.": {
+ "en": "Swap the focused window with the window beside it.",
+ "ko": "선택한 창과 옆 창의 위치를 바꿔요.",
+ "ja": "選択中のウインドウと隣のウインドウを入れ替えます。",
+ "zh-Hans": "交换当前选中窗口与相邻窗口的位置。",
+ "zh-Hant": "交換目前選取視窗與相鄰視窗的位置。"
+ },
+ "Switch between left/right and top/bottom splits.": {
+ "en": "Switch between left/right and top/bottom splits.",
+ "ko": "창을 나누는 방향을 좌우에서 위아래로 바꿔요.",
+ "ja": "左右分割と上下分割を切り替えます。",
+ "zh-Hans": "在左右分割和上下分割之间切换。",
+ "zh-Hant": "在左右分割與上下分割之間切換。"
+ },
+ "Grow the focused tile along its current split.": {
+ "en": "Grow the focused tile along its current split.",
+ "ko": "선택한 창이 속한 영역을 더 크게 늘려요.",
+ "ja": "選択中のウインドウの領域を広げます。",
+ "zh-Hans": "扩大当前选中窗口所占的区域。",
+ "zh-Hant": "擴大目前選取視窗所占的區域。"
+ },
+ "Balance resets the BSP layout when Auto-balance is off.": {
+ "en": "Balance resets the BSP layout when Auto-balance is off.",
+ "ko": "자동 균등 배치가 꺼져 있으면 기본 BSP 배치로 되돌려요.",
+ "ja": "自動均等配置がオフの場合、基本の BSP 配置に戻します。",
+ "zh-Hans": "关闭自动均衡时,均衡命令会恢复默认 BSP 布局。",
+ "zh-Hant": "關閉自動平衡時,平衡指令會恢復預設 BSP 排列。"
+ },
+ "Drag onto another window to swap or reposition it.": {
+ "en": "Drag onto another window to swap or reposition it.",
+ "ko": "창을 다른 창 위로 드래그해 위치를 바꾸거나 끼워 넣어요.",
+ "ja": "別のウインドウへドラッグして、入れ替えたり隣へ配置したりできます。",
+ "zh-Hans": "将窗口拖到另一个窗口上,交换位置或插入布局。",
+ "zh-Hant": "將視窗拖到另一個視窗上,交換位置或插入排列。"
+ },
+ "Drag a window edge; Tatami saves the new split ratio.": {
+ "en": "Drag a window edge; Tatami saves the new split ratio.",
+ "ko": "창 가장자리를 드래그하면 바꾼 비율이 저장돼요.",
+ "ja": "ウインドウの端をドラッグすると、変更した分割比率が保存されます。",
+ "zh-Hans": "拖动窗口边缘,Tatami 会保存新的分割比例。",
+ "zh-Hant": "拖曳視窗邊緣,Tatami 會儲存新的分割比例。"
+ },
+ "Enlarge one tile without losing the saved layout.": {
+ "en": "Enlarge one tile without losing the saved layout.",
+ "ko": "기존 배치를 유지한 채 창 하나만 크게 확대해요.",
+ "ja": "元の配置を保ったまま、一つのウインドウを大きく表示できます。",
+ "zh-Hans": "放大一个窗口,同时保留原来的布局。",
+ "zh-Hant": "放大一個視窗,同時保留原本的排列。"
+ },
+ "Press the shortcut again to restore the previous layout.": {
+ "en": "Press the shortcut again to restore the previous layout.",
+ "ko": "같은 단축키를 다시 누르면 이전 배치로 돌아와요.",
+ "ja": "同じショートカットをもう一度押すと、元の配置に戻ります。",
+ "zh-Hans": "再按一次快捷键,恢复之前的布局。",
+ "zh-Hant": "再按一次快速鍵,恢復先前的排列。"
+ },
+ "Select a window with a shortcut; the pointer moves there too.": {
+ "en": "Select a window with a shortcut; the pointer moves there too.",
+ "ko": "단축키로 창을 선택하면 마우스 포인터도 그 창으로 이동해요.",
+ "ja": "ショートカットでウインドウを選ぶと、ポインタもそのウインドウへ移動します。",
+ "zh-Hans": "用快捷键选中窗口时,鼠标指针也会移到该窗口。",
+ "zh-Hant": "用快速鍵選取視窗時,滑鼠游標也會移到該視窗。"
+ },
+ "With Focus Follows Mouse enabled, hovering selects a window.": {
+ "en": "With Focus Follows Mouse enabled, hovering selects a window.",
+ "ko": "마우스 포인터를 올린 창을 자동으로 선택하도록 설정할 수도 있어요.",
+ "ja": "ポインタを重ねたウインドウが自動で選ばれる設定もあります。",
+ "zh-Hans": "启用焦点跟随鼠标后,指针移入哪个窗口就选中哪个窗口。",
+ "zh-Hant": "啟用焦點跟隨滑鼠後,游標移入哪個視窗就選取哪個視窗。"
+ },
+ "When a window closes, focus moves to a remaining window.": {
+ "en": "When a window closes, focus moves to a remaining window.",
+ "ko": "창을 닫으면 남아 있는 창이 자동으로 선택돼요.",
+ "ja": "ウインドウを閉じると、残っているウインドウへフォーカスが移ります。",
+ "zh-Hans": "关闭窗口后,自动选中剩下的窗口。",
+ "zh-Hant": "關閉視窗後,自動選取剩下的視窗。"
+ },
+ "Hold the shortcut to choose a window in the current workspace.": {
+ "en": "Hold the shortcut to choose a window in the current workspace.",
+ "ko": "단축키를 길게 눌러 현재 작업 공간의 창을 골라요.",
+ "ja": "ショートカットを押し続けて、今のワークスペースのウインドウを選びます。",
+ "zh-Hans": "按住快捷键,选择当前工作区中的窗口。",
+ "zh-Hant": "按住快速鍵,選擇目前工作空間中的視窗。"
+ },
+ "Tap the shortcut to return to the last-used window.": {
+ "en": "Tap the shortcut to return to the last-used window.",
+ "ko": "단축키를 짧게 누르면 직전에 사용한 창으로 돌아가요.",
+ "ja": "短く押すと、直前に使っていたウインドウへ戻ります。",
+ "zh-Hans": "轻按快捷键,返回上一个使用的窗口。",
+ "zh-Hant": "輕按快速鍵,返回上一個使用的視窗。"
+ },
+ "Borrow another workspace without leaving the current task.": {
+ "en": "Borrow another workspace without leaving the current task.",
+ "ko": "현재 작업을 유지하면서 다른 작업 공간을 옆에 빌려와요.",
+ "ja": "今の作業を続けながら、別のワークスペースを隣へ借りられます。",
+ "zh-Hans": "无需离开当前任务,就能借用另一个工作区。",
+ "zh-Hant": "不必離開目前工作,就能借用另一個工作空間。"
+ },
+ "Dismiss the borrowed workspace to restore the full layout.": {
+ "en": "Dismiss the borrowed workspace to restore the full layout.",
+ "ko": "빌려온 작업 공간을 돌려보내면 원래 영역을 다시 채워요.",
+ "ja": "借りたワークスペースを閉じると、元の領域全体を使えます。",
+ "zh-Hans": "收起借用的工作区,恢复完整布局。",
+ "zh-Hant": "收起借用的工作空間,恢復完整排列。"
+ },
+ "A scratchpad opens only when you borrow it.": {
+ "en": "A scratchpad opens only when you borrow it.",
+ "ko": "임시 작업 공간은 빌려올 때만 열려요.",
+ "ja": "スクラッチパッドは、借りる操作でのみ表示できます。",
+ "zh-Hans": "临时工作区只在借用时打开。",
+ "zh-Hant": "暫存工作空間只會在借用時開啟。"
+ },
+ "Borrow the scratchpad again; its apps keep their contents.": {
+ "en": "Borrow the scratchpad again; its apps keep their contents.",
+ "ko": "다시 빌려와도 앱에서 작업한 내용은 그대로 남아 있어요.",
+ "ja": "もう一度借りると、アプリの作業内容がそのまま残っています。",
+ "zh-Hans": "再次借用时,应用中的内容仍然保留。",
+ "zh-Hant": "再次借用時,App 中的內容仍然保留。"
+ },
+ "Choose which edge to place the borrowed workspace on.": {
+ "en": "Choose which edge to place the borrowed workspace on.",
+ "ko": "빌려온 작업 공간을 화면의 어느 쪽에 둘지 골라요.",
+ "ja": "借りるワークスペースを、画面のどの辺に置くか選べます。",
+ "zh-Hans": "选择将借用的工作区放在屏幕的哪一侧。",
+ "zh-Hant": "選擇將借用的工作空間放在螢幕的哪一側。"
+ },
+ "Borrow can also place a workspace below the current one.": {
+ "en": "Borrow can also place a workspace below the current one.",
+ "ko": "현재 작업 공간 아래쪽으로 빌려올 수도 있어요.",
+ "ja": "今のワークスペースの下側に借りることもできます。",
+ "zh-Hans": "也可以把借用的工作区放在当前工作区下方。",
+ "zh-Hant": "也可以將借用的工作空間放在目前工作空間下方。"
+ },
+ "Activate the borrowed workspace to switch to it completely.": {
+ "en": "Activate the borrowed workspace to switch to it completely.",
+ "ko": "빌려온 작업 공간을 활성화하면 그 공간으로 완전히 전환해요.",
+ "ja": "借りているワークスペースを開くと、その作業へ完全に切り替わります。",
+ "zh-Hans": "激活借用的工作区,就会完全切换过去。",
+ "zh-Hant": "啟用借用的工作空間,就會完全切換過去。"
+ },
+ "Always on Top keeps this window above the workspace.": {
+ "en": "Always on Top keeps this window above the workspace.",
+ "ko": "항상 위로 설정한 창은 작업 공간 위에 계속 보여요.",
+ "ja": "最前面表示にしたウインドウは、ワークスペースの手前に表示されます。",
+ "zh-Hans": "设为始终置顶的窗口会保持在工作区上方。",
+ "zh-Hant": "設為保持最上層的視窗,會持續顯示在工作空間上方。"
+ },
+ "Switch to tiled placement to include the window in the layout.": {
+ "en": "Switch to tiled placement to include the window in the layout.",
+ "ko": "타일링으로 바꾸면 다른 창과 함께 나눠 배치돼요.",
+ "ja": "タイル表示に切り替えると、ほかのウインドウと並んで配置されます。",
+ "zh-Hans": "切换为平铺,让窗口参与自动布局。",
+ "zh-Hant": "切換為並排,讓視窗加入自動排列。"
+ },
+ "Switch back to Always on Top whenever you need it.": {
+ "en": "Switch back to Always on Top whenever you need it.",
+ "ko": "필요할 때 다시 항상 위로 바꿀 수 있어요.",
+ "ja": "必要なときに、再び最前面表示へ戻せます。",
+ "zh-Hans": "需要时,可以再次切换为始终置顶。",
+ "zh-Hant": "需要時,可以再次切換為保持最上層。"
+ },
+ "A shared Always on Top window stays visible across workspaces.": {
+ "en": "A shared Always on Top window stays visible across workspaces.",
+ "ko": "공용 앱을 항상 위로 두면 어느 작업 공간에서도 보여요.",
+ "ja": "共有アプリを最前面表示にすると、どのワークスペースでも表示されます。",
+ "zh-Hans": "将共享应用设为始终置顶,在每个工作区都能看到它。",
+ "zh-Hant": "將共用 App 設為保持最上層,在每個工作空間都能看到它。"
+ },
+ "The shared window remains visible even over a zoomed tile.": {
+ "en": "The shared window remains visible even over a zoomed tile.",
+ "ko": "창 하나를 전체 크기로 확대해도 공용 창은 계속 보여요.",
+ "ja": "一つのウインドウを拡大しても、共有ウインドウは手前に表示されます。",
+ "zh-Hans": "即使放大一个平铺窗口,共享窗口仍然可见。",
+ "zh-Hant": "即使放大一個並排視窗,共用視窗仍然可見。"
+ },
+ "Leave As Is keeps a window out of automatic tiling.": {
+ "en": "Leave As Is keeps a window out of automatic tiling.",
+ "ko": "그대로 두기로 설정한 창은 자동 타일링에서 제외돼요.",
+ "ja": "そのまま表示にしたウインドウは、自動タイル配置の対象外になります。",
+ "zh-Hans": "设为保持原样的窗口不参与自动平铺。",
+ "zh-Hant": "設為維持原狀的視窗不會參與自動並排。"
+ },
+ "Shared membership keeps it in place when workspaces change.": {
+ "en": "Shared membership keeps it in place when workspaces change.",
+ "ko": "공용 앱으로 등록하면 작업 공간을 바꿔도 위치와 크기가 유지돼요.",
+ "ja": "共有アプリにすると、ワークスペースを変えても位置と大きさを保てます。",
+ "zh-Hans": "设为共享应用后,切换工作区也会保持它的位置和大小。",
+ "zh-Hant": "設為共用 App 後,切換工作空間也會保留它的位置與大小。"
+ },
+ "The CLI can list workspaces and control the running Tatami.": {
+ "en": "The CLI can list workspaces and control the running Tatami.",
+ "ko": "CLI로 작업 공간 목록을 확인하고 실행 중인 Tatami를 조작해요.",
+ "ja": "CLI からワークスペース一覧を確認し、起動中の Tatami を操作できます。",
+ "zh-Hans": "通过 CLI 查看工作区列表并控制正在运行的 Tatami。",
+ "zh-Hant": "透過 CLI 查看工作空間清單,並操作執行中的 Tatami。"
+ },
+ "One script activates, arranges, and borrows workspaces.": {
+ "en": "One script activates, arranges, and borrows workspaces.",
+ "ko": "스크립트 하나로 작업 공간 전환·배치·빌려오기를 실행해요.",
+ "ja": "一つのスクリプトで、ワークスペースの切り替え・配置・借用を実行します。",
+ "zh-Hans": "一个脚本即可切换、排列和借用工作区。",
+ "zh-Hant": "一個指令稿即可切換、排列及借用工作空間。"
+ },
+ "Hooks notify external tools when the workspace changes.": {
+ "en": "Hooks notify external tools when the workspace changes.",
+ "ko": "작업 공간이 바뀌면 훅으로 외부 도구에 알려요.",
+ "ja": "ワークスペースが変わると、フックで外部ツールに通知できます。",
+ "zh-Hans": "工作区变化时,通过钩子通知外部工具。",
+ "zh-Hant": "工作空間變更時,透過掛鉤通知外部工具。"
+ },
+ "HUD hooks also send action feedback to external tools.": {
+ "en": "HUD hooks also send action feedback to external tools.",
+ "ko": "HUD 훅으로 동작 알림도 외부 도구에 전달해요.",
+ "ja": "HUD フックを使うと、操作結果の通知も外部ツールへ送れます。",
+ "zh-Hans": "HUD 钩子还能把操作提示发送给外部工具。",
+ "zh-Hant": "HUD 掛鉤也能將操作提示傳送給外部工具。"
+ },
+ "Edit TOML to change window gaps while Tatami is running.": {
+ "en": "Edit TOML to change window gaps while Tatami is running.",
+ "ko": "실행 중에 TOML 파일을 수정해 창 사이 간격을 바꿔요.",
+ "ja": "Tatami の起動中に TOML を編集して、ウインドウの間隔を変えられます。",
+ "zh-Hans": "Tatami 运行时,编辑 TOML 即可调整窗口间距。",
+ "zh-Hant": "Tatami 執行時,編輯 TOML 即可調整視窗間距。"
+ },
+ "Supported settings reload automatically without restarting.": {
+ "en": "Supported settings reload automatically without restarting.",
+ "ko": "지원하는 설정은 재시작하지 않아도 자동으로 반영돼요.",
+ "ja": "対応している設定は、再起動せずに自動で反映されます。",
+ "zh-Hans": "支持的设置会自动生效,无需重启。",
+ "zh-Hant": "支援的設定會自動生效,不必重新啟動。"
+ },
+ "Pin different workspaces to different displays.": {
+ "en": "Pin different workspaces to different displays.",
+ "ko": "화면마다 다른 작업 공간을 고정해 사용할 수 있어요.",
+ "ja": "ディスプレイごとに、別のワークスペースを固定できます。",
+ "zh-Hans": "将不同的工作区固定到不同的显示器。",
+ "zh-Hant": "將不同的工作空間固定至不同的顯示器。"
+ },
+ "One shortcut restores the previous workspace pair.": {
+ "en": "One shortcut restores the previous workspace pair.",
+ "ko": "단축키 하나로 이전에 함께 쓰던 작업 공간들로 돌아와요.",
+ "ja": "ショートカット一つで、前のワークスペースの組み合わせに戻れます。",
+ "zh-Hans": "一个快捷键,恢复之前搭配使用的工作区。",
+ "zh-Hant": "一個快速鍵,恢復先前搭配使用的工作空間。"
+ },
+ "Connect a second display to trigger the two-display profile rule.": {
+ "en": "Connect a second display to trigger the two-display profile rule.",
+ "ko": "두 번째 화면을 연결하면 두 화면용 프로필이 자동으로 켜져요.",
+ "ja": "2 台目を接続すると、2 画面用の条件でプロファイルが自動切り替えされます。",
+ "zh-Hans": "连接第二个显示器,自动触发双屏配置方案。",
+ "zh-Hant": "連接第二部顯示器,自動啟用雙螢幕的設定組合。"
+ },
+ "Disconnect it to trigger the single-display profile rule.": {
+ "en": "Disconnect it to trigger the single-display profile rule.",
+ "ko": "연결을 해제하면 한 화면용 프로필로 자동 전환돼요.",
+ "ja": "接続を外すと、1 画面用のプロファイルに自動で戻ります。",
+ "zh-Hans": "断开连接,自动切换回单屏配置方案。",
+ "zh-Hant": "中斷連接,自動切換回單螢幕的設定組合。"
+ },
+ "Edit the saved layout before activating the workspace.": {
+ "en": "Edit the saved layout before activating the workspace.",
+ "ko": "작업 공간을 켜기 전에 저장된 창 배치를 편집해요.",
+ "ja": "ワークスペースを開く前に、保存するウインドウ配置を編集できます。",
+ "zh-Hans": "激活工作区之前,先编辑保存的窗口布局。",
+ "zh-Hant": "啟用工作空間之前,先編輯儲存的視窗排列。"
+ },
+ "Flip the top and bottom positions in the layout preview.": {
+ "en": "Flip the top and bottom positions in the layout preview.",
+ "ko": "배치 미리보기에서 위아래 위치를 뒤집어요.",
+ "ja": "配置プレビューで、上下の位置を入れ替えます。",
+ "zh-Hans": "在布局预览中交换上下位置。",
+ "zh-Hant": "在排列預覽中交換上下位置。"
+ },
+ "Activating the workspace applies the layout you just edited.": {
+ "en": "Activating the workspace applies the layout you just edited.",
+ "ko": "작업 공간을 켜면 방금 편집한 배치가 실제 창에 적용돼요.",
+ "ja": "ワークスペースを開くと、編集した配置が実際のウインドウに反映されます。",
+ "zh-Hans": "激活工作区后,刚才编辑的布局会应用到实际窗口。",
+ "zh-Hant": "啟用工作空間後,剛才編輯的排列會套用至實際視窗。"
+ },
+ "Guided Setup lets you practise without moving your real windows.": {
+ "en": "Guided Setup lets you practise without moving your real windows.",
+ "ko": "가이드 설정에서는 실제 창을 움직이지 않고 기능을 연습해요.",
+ "ja": "ガイド付き設定では、実際のウインドウを動かさずに操作を練習できます。",
+ "zh-Hans": "在引导设置中练习功能,不会移动实际窗口。",
+ "zh-Hant": "在引導設定中練習功能,不會移動實際視窗。"
+ },
+ "Practise the configured shortcuts inside the preview.": {
+ "en": "Practise the configured shortcuts inside the preview.",
+ "ko": "설정한 단축키를 미리보기 안에서 직접 연습해요.",
+ "ja": "設定したショートカットを、プレビュー内で実際に試せます。",
+ "zh-Hans": "在预览中练习已设置的快捷键。",
+ "zh-Hant": "在預覽中練習已設定的快速鍵。"
+ },
+ "Try tiling controls on the virtual windows first.": {
+ "en": "Try tiling controls on the virtual windows first.",
+ "ko": "가상 창으로 타일링 동작을 먼저 익혀요.",
+ "ja": "仮想ウインドウで、タイル配置の操作を先に試せます。",
+ "zh-Hans": "先用虚拟窗口练习平铺操作。",
+ "zh-Hant": "先用虛擬視窗練習並排操作。"
+ },
+ "Review an AI-proposed configuration before applying it.": {
+ "en": "Review an AI-proposed configuration before applying it.",
+ "ko": "AI가 제안한 구성을 적용하기 전에 검토해요.",
+ "ja": "AI が提案した構成を、適用する前に確認できます。",
+ "zh-Hans": "应用 AI 建议的配置之前,先进行检查。",
+ "zh-Hant": "套用 AI 建議的設定之前,先進行檢查。"
+ },
+ "Check which apps are assigned to each proposed workspace.": {
+ "en": "Check which apps are assigned to each proposed workspace.",
+ "ko": "제안된 작업 공간마다 어떤 앱이 배정됐는지 확인해요.",
+ "ja": "提案された各ワークスペースに、どのアプリが割り当てられたか確認します。",
+ "zh-Hans": "检查建议的各个工作区分配了哪些应用。",
+ "zh-Hant": "檢查建議的各個工作空間指派了哪些 App。"
+ },
+ "Duplicate a profile in the settings window and review the workspaces to include.": {
+ "en": "Duplicate a profile in the settings window and review the workspaces to include.",
+ "ko": "설정 창에서 프로필을 복제하고 포함할 작업 공간을 검토해요.",
+ "ja": "設定画面でプロファイルを複製し、含めるワークスペースを確認します。",
+ "zh-Hans": "在设置窗口中复制配置方案,并检查要包含的工作区。",
+ "zh-Hant": "在設定視窗中複製設定組合,並檢查要包含的工作空間。"
+ },
+ "Open Duplicate from a profile’s context menu.": {
+ "en": "Open Duplicate from a profile’s context menu.",
+ "ko": "프로필을 오른쪽 클릭해 복제를 선택해요.",
+ "ja": "プロファイルを右クリックして、複製を選びます。",
+ "zh-Hans": "右键单击配置方案,选择复制。",
+ "zh-Hant": "在設定組合上按右鍵,選擇複製。"
+ },
+ "Choose the workspaces and settings to include.": {
+ "en": "Choose the workspaces and settings to include.",
+ "ko": "복제할 작업 공간과 설정을 골라요.",
+ "ja": "複製するワークスペースと設定を選べます。",
+ "zh-Hans": "选择要复制的工作区和设置。",
+ "zh-Hant": "選擇要複製的工作空間與設定。"
+ },
+ "The new profile can be edited independently.": {
+ "en": "The new profile can be edited independently.",
+ "ko": "새 프로필은 원본과 별개로 편집할 수 있어요.",
+ "ja": "新しいプロファイルは、元の構成とは別に編集できます。",
+ "zh-Hans": "新配置方案可以独立于原方案进行编辑。",
+ "zh-Hant": "新的設定組合可以獨立於原組合進行編輯。"
+ },
+ "Copy settings between workspaces": {
+ "en": "Copy settings between workspaces",
+ "ko": "작업 공간 간 설정 복사하기",
+ "ja": "ワークスペース間で設定をコピー",
+ "zh-Hans": "在工作区之间复制设置",
+ "zh-Hant": "在工作空間之間複製設定"
+ },
+ "Use Copy from to review another workspace’s apps and settings, then apply the selected changes.": {
+ "en": "Use Copy from to review another workspace’s apps and settings, then apply the selected changes.",
+ "ko": "다른 작업 공간에서 복사할 앱과 설정을 검토한 뒤 선택한 변경을 적용해요.",
+ "ja": "別のワークスペースのアプリと設定を確認し、選んだ変更を適用します。",
+ "zh-Hans": "检查另一个工作区的应用和设置,然后应用所选更改。",
+ "zh-Hant": "檢查另一個工作空間的 App 與設定,再套用選取的變更。"
+ },
+ "COPY WORKSPACE SETTINGS": {
+ "en": "COPY WORKSPACE SETTINGS",
+ "ko": "작업 공간 설정 복사",
+ "ja": "ワークスペース設定のコピー",
+ "zh-Hans": "复制工作区设置",
+ "zh-Hant": "複製工作空間設定"
+ },
+ "Choose the workspace whose settings you want to copy.": {
+ "en": "Choose the workspace whose settings you want to copy.",
+ "ko": "설정을 가져올 작업 공간을 선택해요.",
+ "ja": "設定のコピー元にするワークスペースを選びます。",
+ "zh-Hans": "选择要从中复制设置的工作区。",
+ "zh-Hant": "選擇要從中複製設定的工作空間。"
+ },
+ "Review the changes before copying them into this workspace.": {
+ "en": "Review the changes before copying them into this workspace.",
+ "ko": "현재 작업 공간에 복사할 변경 내용을 먼저 확인해요.",
+ "ja": "今のワークスペースへコピーする変更を、適用前に確認できます。",
+ "zh-Hans": "复制到当前工作区前,先检查更改内容。",
+ "zh-Hant": "複製至目前工作空間前,先檢查變更內容。"
+ },
+ "The selected apps and settings are now copied.": {
+ "en": "The selected apps and settings are now copied.",
+ "ko": "선택한 앱과 설정이 현재 작업 공간에 복사됐어요.",
+ "ja": "選んだアプリと設定が、今のワークスペースにコピーされました。",
+ "zh-Hans": "所选应用和设置已复制到当前工作区。",
+ "zh-Hant": "選取的 App 與設定已複製至目前的工作空間。"
+ },
+ "Use shortcuts to select neighboring windows and move the pointer, or select windows by hovering.": {
+ "en": "Use shortcuts to select neighboring windows and move the pointer, or select windows by hovering.",
+ "ko": "단축키로 이웃한 창을 선택하고 포인터도 함께 옮겨요. 마우스를 창 위에 올려 선택하는 방법도 보여줘요.",
+ "ja": "ショートカットで隣のウインドウを選び、ポインタも移動します。マウスを重ねて選ぶ方法も紹介します。",
+ "zh-Hans": "用快捷键选择相邻窗口并移动鼠标指针,也可以将指针移入窗口来选中它。",
+ "zh-Hant": "用快速鍵選擇相鄰視窗並移動滑鼠游標,也可以將游標移入視窗來選取它。"
+ }
+ }
+}
diff --git a/DemoLab/Localization/Interface.json b/DemoLab/Localization/Interface.json
new file mode 100644
index 0000000..4e1cfbd
--- /dev/null
+++ b/DemoLab/Localization/Interface.json
@@ -0,0 +1,117 @@
+{
+ "en": {
+ "explore": "Explore the workflow",
+ "previous": "Previous video",
+ "next": "Next video",
+ "playHint": "Use the play control",
+ "collection": "{name} video collection",
+ "choose": "Choose a {name} demonstration",
+ "watch": "Watch {title}",
+ "library": "Demo library",
+ "headline": "Make room for the whole workflow.",
+ "intro": "Real Tatami, interactive demo apps, and focused demonstrations.",
+ "sections": {
+ "demo": "The complete workflow",
+ "workspaces": "Workspaces",
+ "profiles": "Profiles and displays",
+ "tiling": "Tiling and focus",
+ "borrow": "Borrow",
+ "floating": "Window modes",
+ "automation": "CLI and hooks",
+ "guided-setup": "Guided Setup"
+ },
+ "relatedSettings": "Related settings"
+ },
+ "ko": {
+ "explore": "실제 작업 살펴보기",
+ "previous": "이전 영상",
+ "next": "다음 영상",
+ "playHint": "재생 버튼을 눌러주세요",
+ "collection": "{name} 영상 모음",
+ "choose": "{name} 영상 선택",
+ "watch": "{title} 보기",
+ "library": "데모 영상 모음",
+ "headline": "모든 작업에 알맞은 자리를 만들어요.",
+ "intro": "실제 Tatami와 직접 조작하는 데모 앱으로 주요 기능을 살펴봐요.",
+ "sections": {
+ "demo": "전체 작업 흐름",
+ "workspaces": "작업 공간",
+ "profiles": "프로필과 화면",
+ "tiling": "타일링과 포커스",
+ "borrow": "빌려오기",
+ "floating": "창 표시 방식",
+ "automation": "CLI와 훅",
+ "guided-setup": "시작 안내"
+ },
+ "relatedSettings": "관련 설정"
+ },
+ "ja": {
+ "explore": "実際の作業を見る",
+ "previous": "前の動画",
+ "next": "次の動画",
+ "playHint": "再生ボタンを押してください",
+ "collection": "{name}の動画集",
+ "choose": "{name}の動画を選ぶ",
+ "watch": "{title}を見る",
+ "library": "デモ動画集",
+ "headline": "仕事の流れに、必要な場所を。",
+ "intro": "実際の Tatami と操作できるデモアプリで、主な機能を紹介します。",
+ "sections": {
+ "demo": "一連の作業",
+ "workspaces": "ワークスペース",
+ "profiles": "プロファイルと画面",
+ "tiling": "タイル表示とフォーカス",
+ "borrow": "借りる",
+ "floating": "ウインドウの表示方法",
+ "automation": "CLI とフック",
+ "guided-setup": "使いながら設定"
+ },
+ "relatedSettings": "関連する設定"
+ },
+ "zh-Hans": {
+ "explore": "看看实际工作",
+ "previous": "上一个视频",
+ "next": "下一个视频",
+ "playHint": "请使用播放按钮",
+ "collection": "{name}视频集",
+ "choose": "选择{name}演示",
+ "watch": "观看{title}",
+ "library": "演示视频集",
+ "headline": "为整个工作流程腾出空间。",
+ "intro": "通过实际的 Tatami 和可操作的演示应用了解主要功能。",
+ "sections": {
+ "demo": "完整工作流程",
+ "workspaces": "工作区",
+ "profiles": "配置方案与显示器",
+ "tiling": "平铺与焦点",
+ "borrow": "借用",
+ "floating": "窗口显示方式",
+ "automation": "CLI 与钩子",
+ "guided-setup": "设置向导"
+ },
+ "relatedSettings": "相关设置"
+ },
+ "zh-Hant": {
+ "explore": "看看實際工作",
+ "previous": "上一部影片",
+ "next": "下一部影片",
+ "playHint": "請使用播放按鈕",
+ "collection": "{name}影片集",
+ "choose": "選擇{name}示範",
+ "watch": "觀看{title}",
+ "library": "示範影片集",
+ "headline": "為整個工作流程騰出空間。",
+ "intro": "透過實際的 Tatami 和可操作的示範 App 認識主要功能。",
+ "sections": {
+ "demo": "完整工作流程",
+ "workspaces": "工作空間",
+ "profiles": "設定組合與顯示器",
+ "tiling": "並排與焦點",
+ "borrow": "借用",
+ "floating": "視窗顯示方式",
+ "automation": "CLI 與掛鉤",
+ "guided-setup": "設定導覽"
+ },
+ "relatedSettings": "相關設定"
+ }
+}
diff --git a/DemoLab/Localization/Localizable.xcstrings b/DemoLab/Localization/Localizable.xcstrings
new file mode 100644
index 0000000..ec243eb
--- /dev/null
+++ b/DemoLab/Localization/Localizable.xcstrings
@@ -0,0 +1,5766 @@
+{
+ "sourceLanguage": "en",
+ "strings": {
+ "# launch": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "# launch"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "# launch"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "# launch"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "# launch"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "# launch"
+ }
+ }
+ }
+ },
+ "#launch": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch"
+ }
+ }
+ }
+ },
+ "#team": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#team"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#team"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#team"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#team"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#team"
+ }
+ }
+ }
+ },
+ "%@ · Revision %lld": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@ · Revision %lld"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ · 버전 %2$lld"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ · リビジョン %2$lld"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ · 修订版 %2$lld"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ · 修訂版 %2$lld"
+ }
+ }
+ }
+ },
+ "%lld / 3 checks passed": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld / 3 checks passed"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검사 3개 중 %lld개 통과"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "3 件中 %lld 件のチェックに合格"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "3 项检查中 %lld 项通过"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "3 項檢查中通過 %lld 項"
+ }
+ }
+ }
+ },
+ "%lld / 40": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld / 40"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld / 40"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld / 40"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld / 40"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld / 40"
+ }
+ }
+ }
+ },
+ "%lld messages": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld messages"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "메시지 %lld개"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "メッセージ %lld 件"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld 条消息"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld 則訊息"
+ }
+ }
+ }
+ },
+ "%lld of %lld done": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld of %lld done"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "전체 %2$lld개 중 %1$lld개 완료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "全 %2$lld 件中 %1$lld 件完了"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "共 %2$lld 项,已完成 %1$lld 项"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "共 %2$lld 項,已完成 %1$lld 項"
+ }
+ }
+ }
+ },
+ "/": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "/"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "/"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "/"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "/"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "/"
+ }
+ }
+ }
+ },
+ "A better way to manage all the windows on your desktop.": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A better way to manage all the windows on your desktop."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "데스크톱에 열려 있는 모든 창을 더 편리하게 정리하고 원하는 작업을 이어가는 새로운 방법이에요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "デスクトップに開いたすべてのウインドウをもっと便利に整理して、やりたい作業をそのまま続ける新しい方法です。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "用一种更方便的方法整理桌面上打开的所有窗口,把需要的应用放在一起,随时回到刚才的工作继续操作。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "用更方便的方式整理桌面上開啟的所有視窗,把需要的 App 放在一起,隨時回到剛才的工作繼續操作。"
+ }
+ }
+ }
+ },
+ "A calmer way to work.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A calmer way to work."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "더 차분하게 일해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "落ち着いて、仕事を進めよう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "让工作更从容。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "讓工作更從容。"
+ }
+ }
+ }
+ },
+ "A good follow-up is small enough to do next.": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A good follow-up is small enough to do next."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "다음에 바로 할 수 있는 작은 일을 적어보세요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "次にすぐ取りかかれる、小さな作業を残しましょう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "记下接下来就能完成的一件小事。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "記下接下來就能完成的一件小事。"
+ }
+ }
+ }
+ },
+ "A place to begin": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A place to begin"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "시작할 자리"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "作業を始める場所"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "开始工作的地方"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "開始工作的地方"
+ }
+ }
+ }
+ },
+ "A place to return": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A place to return"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "돌아올 자리"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "作業に戻る場所"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "随时回来的地方"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "隨時回來的地方"
+ }
+ }
+ }
+ },
+ "A real piece of work moving from draft to review. Keep the tools visible and let the workflow explain itself.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A real piece of work moving from draft to review. Keep the tools visible and let the workflow explain itself."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "초안을 작성하고 검토하는 실제 작업을 보여줘요. 필요한 도구를 나란히 두고 흐름을 자연스럽게 보여주세요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "下書きからレビューまで、実際の作業を見せます。必要なツールを並べて、仕事の流れを伝えましょう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "展示从草稿到评审的实际工作。把需要的工具放在眼前,让操作过程自然说明用途。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "展示從草稿到審閱的實際工作。把需要的工具放在眼前,讓操作過程自然說明用途。"
+ }
+ }
+ }
+ },
+ "A short headline, followed by a concrete explanation.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A short headline, followed by a concrete explanation."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "짧은 제목 뒤에 구체적인 설명을 더해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "短い見出しに、具体的な説明を添えます。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "用简短的标题搭配具体说明。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "用簡短的標題搭配具體說明。"
+ }
+ }
+ }
+ },
+ "A short introduction for people meeting the product for the first time.": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A short introduction for people meeting the product for the first time."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제품을 처음 만나는 사람을 위한 짧은 소개예요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "初めてこの製品を知る人に向けた、短い紹介です。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "为第一次了解产品的人写一段简短介绍。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "為第一次認識產品的人寫一段簡短介紹。"
+ }
+ }
+ }
+ },
+ "A small change. A clearer idea.": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A small change. A clearer idea."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "조금 바꿔도 더 분명해져요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "少し変えて、もっと伝わる文章に。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "小改动,让表达更清楚。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "小改動,讓表達更清楚。"
+ }
+ }
+ }
+ },
+ "A workspace brings together the apps and layout for one task. Switch away, then return to the place you left.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A workspace brings together the apps and layout for one task. Switch away, then return to the place you left."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "하나의 작업에 필요한 앱과 배치를 작업 공간에 모아요. 다른 일을 마치면 이전 자리로 돌아올 수 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "一つの作業に必要なアプリと配置をワークスペースにまとめます。別の作業を終えたら、元の場所に戻れます。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "工作区把一项任务需要的应用和布局放在一起。切换去做别的事后,还能回到原来的位置。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "工作空間把一項工作需要的 App 和排列放在一起。切換去做別的事後,還能回到原來的位置。"
+ }
+ }
+ }
+ },
+ "Add": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "추가"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "追加"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "添加"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新增"
+ }
+ }
+ }
+ },
+ "Add a product screenshot": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add a product screenshot"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제품 스크린샷 추가"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "製品のスクリーンショットを追加"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "添加产品截图"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新增產品截圖"
+ }
+ }
+ }
+ },
+ "Add a task": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add a task"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "할 일 추가"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "タスクを追加"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "添加任务"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新增待辦事項"
+ }
+ }
+ }
+ },
+ "Add note": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add note"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "의견 추가"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コメントを追加"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "添加意见"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新增意見"
+ }
+ }
+ }
+ },
+ "All changes saved": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "All changes saved"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "변경 사항을 모두 저장했어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "変更をすべて保存しました。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "所有更改已保存。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已儲存所有變更。"
+ }
+ }
+ }
+ },
+ "Approve": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Approve"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "승인"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "承認"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "批准"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "核准"
+ }
+ }
+ }
+ },
+ "Approved": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Approved"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "승인 완료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "承認済み"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已批准"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已核准"
+ }
+ }
+ }
+ },
+ "Approved for the launch page": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Approved for the launch page"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 페이지에 사용할 수 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開ページへの掲載を承認しました。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已批准用于发布页面。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已核准用於發布頁面。"
+ }
+ }
+ }
+ },
+ "Before we share it.": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Before we share it."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "공유하기 전에 확인해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "共有する前に確認しよう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "分享前,检查一下。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "分享前,檢查一下。"
+ }
+ }
+ }
+ },
+ "Before you share": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Before you share"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "공유 전 확인"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "共有前の確認"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "分享前检查"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "分享前檢查"
+ }
+ }
+ }
+ },
+ "Bring the tools for this task together.": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Bring the tools for this task together."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "이 작업에 필요한 도구를 모아요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "この作業に必要なツールをまとめます。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "把这项任务需要的工具放在一起。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "把這項工作需要的工具放在一起。"
+ }
+ }
+ }
+ },
+ "CONTENT / DIRECTION": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "CONTENT / DIRECTION"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "콘텐츠 / 방향"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コンテンツ / 方針"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "内容 / 方向"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "內容 / 方向"
+ }
+ }
+ }
+ },
+ "Canvas / Live preview": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Canvas / Live preview"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Canvas / 실시간 미리보기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Canvas / ライブプレビュー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Canvas / 实时预览"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Canvas / 即時預覽"
+ }
+ }
+ }
+ },
+ "Check copy": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Check copy"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "문구 검사"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文章をチェック"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "检查文案"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "檢查文案"
+ }
+ }
+ }
+ },
+ "Check the copy, request a review, and add one clear product screenshot.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Check the copy, request a review, and add one clear product screenshot."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "문구를 확인하고 검토를 요청해요. 제품이 잘 보이는 스크린샷도 하나 추가해주세요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文章を確認してレビューを依頼し、製品がよくわかるスクリーンショットを 1 枚追加しましょう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "检查文案,请同事评审,再添加一张能清楚展示产品的截图。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "檢查文案,請同事審閱,再新增一張能清楚展示產品的截圖。"
+ }
+ }
+ }
+ },
+ "Close": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Close"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "닫기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "閉じる"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "关闭"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "關閉"
+ }
+ }
+ }
+ },
+ "Cobalt": {
+ "comment": "DemoLocalization.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Cobalt"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "코발트"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コバルト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "钴蓝"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "鈷藍"
+ }
+ }
+ }
+ },
+ "Command in progress": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command in progress"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "명령을 실행하고 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コマンドを実行しています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "正在执行命令。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "正在執行指令。"
+ }
+ }
+ }
+ },
+ "Content": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Content"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "콘텐츠"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コンテンツ"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "内容"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "內容"
+ }
+ }
+ }
+ },
+ "Content direction": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Content direction"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "콘텐츠 방향"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コンテンツの方針"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "内容方向"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "內容方向"
+ }
+ }
+ }
+ },
+ "Copy": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copy"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "복사"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コピー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "复制"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "拷貝"
+ }
+ }
+ }
+ },
+ "Copy / Draft": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copy / Draft"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "문구 / 초안"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文章 / 下書き"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文案 / 草稿"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文案 / 草稿"
+ }
+ }
+ }
+ },
+ "Copy checks pending": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copy checks pending"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "문구를 아직 검사하지 않았어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文章はまだチェックしていません。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文案尚未检查。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "尚未檢查文案。"
+ }
+ }
+ }
+ },
+ "Could not render the preview": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Could not render the preview"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "미리보기를 만들지 못했어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "プレビューを作成できませんでした。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "无法生成预览。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "無法產生預覽。"
+ }
+ }
+ }
+ },
+ "Cut": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Cut"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "오려두기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "カット"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "剪切"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "剪下"
+ }
+ }
+ }
+ },
+ "DESCRIPTION": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "DESCRIPTION"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "설명"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "説明"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "说明"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "說明"
+ }
+ }
+ }
+ },
+ "Description explains the workflow": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Description explains the workflow"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "설명에 작업 흐름이 담겨 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "説明に作業の流れが書かれています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "说明介绍了工作流程。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "說明介紹了工作流程。"
+ }
+ }
+ }
+ },
+ "Draft · revision %lld": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Draft · revision %lld"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "초안 · 버전 %lld"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "下書き · リビジョン %lld"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "草稿 · 修订版 %lld"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "草稿 · 修訂版 %lld"
+ }
+ }
+ }
+ },
+ "Edit": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Edit"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "편집"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "編集"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "编辑"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "編輯"
+ }
+ }
+ }
+ },
+ "Enter Full Screen": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Enter Full Screen"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "전체 화면 시작"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "フルスクリーンにする"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "进入全屏幕"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "進入全螢幕"
+ }
+ }
+ }
+ },
+ "Exit %d": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Exit %d"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "종료 코드 %d"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "終了コード %d"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "退出码 %d"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "結束代碼 %d"
+ }
+ }
+ }
+ },
+ "Export": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Export"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "내보내기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "書き出す"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "导出"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "輸出"
+ }
+ }
+ }
+ },
+ "FOLLOW-UP": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "FOLLOW-UP"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "후속 작업"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "次の作業"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "后续任务"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "後續工作"
+ }
+ }
+ }
+ },
+ "FORM / FIELD": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "FORM / FIELD"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "FORM / FIELD"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "FORM / FIELD"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "FORM / FIELD"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "FORM / FIELD"
+ }
+ }
+ }
+ },
+ "Find in brief": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Find in brief"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "안내에서 찾기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "資料内を検索"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "在说明中查找"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "在說明中尋找"
+ }
+ }
+ }
+ },
+ "Find your flow": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Find your flow"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "내 흐름 찾기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自分のペースで始める"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "找到工作节奏"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "找到工作節奏"
+ }
+ }
+ }
+ },
+ "Forest": {
+ "comment": "DemoLocalization.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Forest"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "포레스트"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "フォレスト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "森林绿"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "森林綠"
+ }
+ }
+ }
+ },
+ "Give every task its own workspace. Bring the apps you need together, keep their layout, and return to your work without rearranging windows.": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Give every task its own workspace. Bring the apps you need together, keep their layout, and return to your work without rearranging windows."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "작업마다 필요한 앱을 작업 공간에 모아두세요. 앱의 배치를 기억하니, 창을 다시 정리하지 않고 하던 일을 이어갈 수 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "作業ごとにワークスペースを用意しましょう。必要なアプリと配置をまとめておけば、ウインドウを並べ直さずに、途中の作業へ戻れます。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "为每项任务准备一个工作区。把需要的应用放在一起,保留它们的布局。再次回来时,不用重新整理窗口,就能接着工作。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "為每項工作準備一個工作空間。把需要的 App 放在一起,保留它們的排列。再次回來時,不用重新整理視窗,就能接著工作。"
+ }
+ }
+ }
+ },
+ "HEADLINE": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "HEADLINE"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제목"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "見出し"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "标题"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "標題"
+ }
+ }
+ }
+ },
+ "Headline": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Headline"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제목"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "見出し"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "标题"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "標題"
+ }
+ }
+ }
+ },
+ "Headline is 1–40 characters": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Headline is 1–40 characters"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제목이 1~40자예요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "見出しが 1〜40 文字です。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "标题为 1 至 40 个字符。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "標題為 1 到 40 個字元。"
+ }
+ }
+ }
+ },
+ "Hooks": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Hooks"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "훅"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "フック"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "钩子"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "掛鉤"
+ }
+ }
+ }
+ },
+ "Keep it short": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Keep it short"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "짧게 쓰기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "短く書く"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保持简短"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保持簡短"
+ }
+ }
+ }
+ },
+ "Keep the arrangement that works for you.": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Keep the arrangement that works for you."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "나에게 맞는 배치를 기억해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "使いやすい配置をそのまま残します。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保留适合你的布局。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保留適合你的排列。"
+ }
+ }
+ }
+ },
+ "Keep the conversation here.": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Keep the conversation here."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "대화는 여기서 이어가요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "会話は、ここで続けよう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "在这里继续讨论。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "在這裡繼續討論。"
+ }
+ }
+ }
+ },
+ "Keep the next small steps beside the work.": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Keep the next small steps beside the work."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "다음에 할 작은 일을 작업 옆에 두세요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "次にする小さな作業を、手元に置いておきましょう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "把接下来要做的小事放在工作旁边。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "把接下來要做的小事放在工作旁邊。"
+ }
+ }
+ }
+ },
+ "LAUNCH / WEBSITE": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "LAUNCH / WEBSITE"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 / 웹사이트"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開 / Web サイト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布 / 网站"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布 / 網站"
+ }
+ }
+ }
+ },
+ "Launch brief": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch brief"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 안내"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開に向けた資料"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布说明"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布說明"
+ }
+ }
+ }
+ },
+ "Launch brief / Team reference": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch brief / Team reference"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 안내 / 팀 참고 자료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開に向けた資料 / チームの参考情報"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布说明 / 团队参考"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布說明 / 團隊參考"
+ }
+ }
+ }
+ },
+ "Launch checklist": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch checklist"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 체크리스트"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開前のチェックリスト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布检查清单"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布檢查清單"
+ }
+ }
+ }
+ },
+ "Launch checks": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch checks"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 검사"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開前のチェック"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布检查"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布檢查"
+ }
+ }
+ }
+ },
+ "Launch checks — second session": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch checks · second session"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 검사 · 두 번째 세션"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開前のチェック · 2 つ目のセッション"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布检查 · 第二个会话"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布檢查 · 第二個工作階段"
+ }
+ }
+ }
+ },
+ "Launch copy": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch copy"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 문구"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開用の文章"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布文案"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布文案"
+ }
+ }
+ }
+ },
+ "Launch copy — review": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch copy · review"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 문구 · 검토"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開用の文章 · レビュー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布文案 · 评审"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布文案 · 審閱"
+ }
+ }
+ }
+ },
+ "Launch copy — second review": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch copy · second review"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 문구 · 두 번째 검토"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開用の文章 · 2 回目のレビュー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布文案 · 第二次评审"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布文案 · 第二次審閱"
+ }
+ }
+ }
+ },
+ "Launch page": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch page"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 페이지"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開ページ"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布页面"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布頁面"
+ }
+ }
+ }
+ },
+ "Launch page — alternate": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch page · alternate"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 페이지 · 다른 안"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開ページ · 別案"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布页面 · 备选方案"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布頁面 · 替代方案"
+ }
+ }
+ }
+ },
+ "Launch page — canvas": {
+ "comment": "DemoCatalog.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch page · canvas"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 페이지 · Canvas"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開ページ · Canvas"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布页面 · Canvas"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布頁面 · Canvas"
+ }
+ }
+ }
+ },
+ "Launch status": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch status"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 진행 상황"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開までの進み具合"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布进度"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布進度"
+ }
+ }
+ }
+ },
+ "Launch tools\n\n check copy Validate the saved draft\n tatami … Run the real Tatami CLI\n focus-session Run the workspace setup script\n set-spacing 24 Edit the live TOML configuration\n": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Launch tools\n\n check copy Validate the saved draft\n tatami … Run the real Tatami CLI\n focus-session Run the workspace setup script\n set-spacing 24 Edit the live TOML configuration\n"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 도구\n\n check copy 저장한 초안 검사\n tatami … 실제 Tatami CLI 실행\n focus-session 작업 공간 준비 스크립트 실행\n set-spacing 24 실제 TOML 설정 수정\n"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開用ツール\n\n check copy 保存した下書きをチェック\n tatami … 実際の Tatami CLI を実行\n focus-session ワークスペースの準備スクリプトを実行\n set-spacing 24 実際の TOML 設定を編集\n"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布工具\n\n check copy 检查已保存的草稿\n tatami … 执行实际的 Tatami CLI\n focus-session 运行工作区准备脚本\n set-spacing 24 修改实际的 TOML 配置\n"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布工具\n\n check copy 檢查已儲存的草稿\n tatami … 執行實際的 Tatami CLI\n focus-session 執行工作空間準備指令稿\n set-spacing 24 修改實際的 TOML 設定\n"
+ }
+ }
+ }
+ },
+ "Leave a review note": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Leave a review note"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토 의견 남기기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "レビューのコメントを入力"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "留下评审意见"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "留下審閱意見"
+ }
+ }
+ }
+ },
+ "Let readers see the draft, the review and the saved layout. A quiet moment of real work says more than a list of shortcuts.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Let readers see the draft, the review and the saved layout. A quiet moment of real work says more than a list of shortcuts."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "초안과 검토 결과, 저장한 배치를 보여주세요. 실제로 작업하는 모습이 단축키 목록보다 더 잘 전달돼요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "下書き、レビュー、保存した配置を見せましょう。実際に作業する場面のほうが、ショートカットの一覧より伝わります。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "让读者看到草稿、评审和保存的布局。实际工作的片刻,比一串快捷键更能说明用途。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "讓讀者看到草稿、審閱和儲存的排列。實際工作的片刻,比一串快速鍵更能說明用途。"
+ }
+ }
+ }
+ },
+ "Live from the saved draft": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Live from the saved draft"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "저장한 초안을 바로 보여줘요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保存した下書きをそのまま表示します。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "实时显示已保存的草稿。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "即時顯示已儲存的草稿。"
+ }
+ }
+ }
+ },
+ "Local automation": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Local automation"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "로컬 자동화"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ローカルの自動化"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "本地自动化"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "本機自動化"
+ }
+ }
+ }
+ },
+ "Local demo conversation": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Local demo conversation"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "로컬 데모 대화"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ローカルのデモ会話"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "本地演示对话"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "本機示範對話"
+ }
+ }
+ }
+ },
+ "Local review": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Local review"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "로컬 검토"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ローカルのレビュー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "本地评审"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "本機審閱"
+ }
+ }
+ }
+ },
+ "M": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "M"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "M"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "M"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "M"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "M"
+ }
+ }
+ }
+ },
+ "Make room for better work.": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Make room for better work."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "더 편하게 일할 자리를 만들어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "仕事が進む場所をつくろう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "为更顺手的工作腾出空间。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "為更順手的工作騰出空間。"
+ }
+ }
+ }
+ },
+ "Message #launch": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Message #launch"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch에 메시지 보내기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "#launch にメッセージを入力"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "给 #launch 发消息"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "傳送訊息到 #launch"
+ }
+ }
+ }
+ },
+ "Mina": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mina"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mina"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mina"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mina"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mina"
+ }
+ }
+ }
+ },
+ "Minimize": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Minimize"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "최소화"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "しまう"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "最小化"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "縮小"
+ }
+ }
+ }
+ },
+ "New Window": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "New Window"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "새 윈도우"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新規ウインドウ"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新建窗口"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "新增視窗"
+ }
+ }
+ }
+ },
+ "No matches.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "No matches."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검색 결과가 없어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "一致する項目がありません。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "没有匹配结果。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "找不到符合的結果。"
+ }
+ }
+ }
+ },
+ "Notes": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Notes"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Notes"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Notes"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Notes"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Notes"
+ }
+ }
+ }
+ },
+ "On it. I’ll send the updated copy here.": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "On it. I’ll send the updated copy here."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "좋아요. 수정한 문구를 여기로 보낼게요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "了解です。直した文章をここに送ります。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "好的,修改后我会把文案发到这里。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "好,修改後我會把文案傳到這裡。"
+ }
+ }
+ }
+ },
+ "One clear idea": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "One clear idea"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "분명한 생각 하나"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "伝えたいことを一つに"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "一个清楚的想法"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "一個清楚的想法"
+ }
+ }
+ }
+ },
+ "Overview": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Overview"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "개요"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "概要"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "概览"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "概覽"
+ }
+ }
+ }
+ },
+ "PROJECT / LAUNCH": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "PROJECT / LAUNCH"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "프로젝트 / 출시"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "プロジェクト / 公開"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "项目 / 发布"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "專案 / 發布"
+ }
+ }
+ }
+ },
+ "Paste": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Paste"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "붙여넣기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ペースト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "粘贴"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "貼上"
+ }
+ }
+ }
+ },
+ "People who switch between writing, reviewing and talking to their team. Their tools change with the task.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "People who switch between writing, reviewing and talking to their team. Their tools change with the task."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "글을 쓰고, 검토하고, 팀과 대화하는 사람을 위한 제품이에요. 하는 일에 따라 필요한 도구도 달라져요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文章を書く、レビューする、チームと話す。作業に合わせて使うツールが変わる人に向けた製品です。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "面向经常在写作、评审和团队沟通之间切换的人。任务变了,需要的工具也会变。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "提供給經常在撰寫、審閱和團隊溝通之間切換的人。工作變了,需要的工具也會變。"
+ }
+ }
+ }
+ },
+ "Plain language": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Plain language"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "쉬운 말"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "わかりやすい言葉"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "易懂的语言"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "易懂的文字"
+ }
+ }
+ }
+ },
+ "Product Journal": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Product Journal"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제품 이야기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "製品 ジャーナル"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "产品 文章"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "產品 文章"
+ }
+ }
+ }
+ },
+ "Project": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Project"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "프로젝트"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "プロジェクト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "项目"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "專案"
+ }
+ }
+ }
+ },
+ "Quit %@": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Quit %@"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@ 종료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@ を終了"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "退出 %@"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "結束 %@"
+ }
+ }
+ }
+ },
+ "READY FOR REVIEW": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "READY FOR REVIEW"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토 준비 완료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "レビューの準備完了"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "准备评审"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "準備審閱"
+ }
+ }
+ }
+ },
+ "REFERENCE": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "REFERENCE"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "참고 자료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "参考資料"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "参考资料"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "參考資料"
+ }
+ }
+ }
+ },
+ "REVIEW NOTE": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "REVIEW NOTE"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토 의견"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "レビューのコメント"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "评审意见"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "審閱意見"
+ }
+ }
+ }
+ },
+ "ROOM TO THINK": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ROOM TO THINK"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "생각할 여유"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "考える余白"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "留出思考的空间"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "留出思考的空間"
+ }
+ }
+ }
+ },
+ "Ready": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ready"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "준비 완료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "準備完了"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "就绪"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "準備就緒"
+ }
+ }
+ }
+ },
+ "Ready to share": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ready to share"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "공유할 준비가 됐어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "共有する準備ができました。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "可以分享了。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "可以分享了。"
+ }
+ }
+ }
+ },
+ "Reference": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reference"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "참고 자료"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "参考資料"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "参考资料"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "參考資料"
+ }
+ }
+ }
+ },
+ "Review / Website": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Review / Website"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토 / 웹사이트"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "レビュー / Web サイト"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "评审 / 网站"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "審閱 / 網站"
+ }
+ }
+ }
+ },
+ "Review approved": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Review approved"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토를 승인했어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "レビューを承認しました。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "评审已批准。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已核准審閱。"
+ }
+ }
+ }
+ },
+ "Review the launch copy": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Review the launch copy"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 문구 검토"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開用の文章をレビュー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "评审发布文案"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "審閱發布文案"
+ }
+ }
+ }
+ },
+ "Reviewing revision %lld": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reviewing revision %lld"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토하고 있어요 · 버전 %lld"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "リビジョン %lld をレビュー中です。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "正在评审修订版 %lld。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "正在審閱修訂版 %lld。"
+ }
+ }
+ }
+ },
+ "Revision %lld": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Revision %lld"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "버전 %lld"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "リビジョン %lld"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "修订版 %lld"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "修訂版 %lld"
+ }
+ }
+ }
+ },
+ "Running": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Running"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "실행 중"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "実行中"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "运行中"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "執行中"
+ }
+ }
+ }
+ },
+ "SEARCH": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "SEARCH"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검색"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "検索"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "搜索"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "搜尋"
+ }
+ }
+ }
+ },
+ "SUGGESTED HEADLINE": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "SUGGESTED HEADLINE"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제안 제목"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "見出しの提案"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "建议标题"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "建議標題"
+ }
+ }
+ }
+ },
+ "Sand": {
+ "comment": "DemoLocalization.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sand"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "샌드"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "サンド"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "沙金"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "沙金"
+ }
+ }
+ }
+ },
+ "Save": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Save"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "저장"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保存"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保存"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "儲存"
+ }
+ }
+ }
+ },
+ "Saved locally": {
+ "comment": "NotesView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Saved locally"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "이 기기에 저장했어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "この Mac に保存しました。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已保存在本机。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已儲存在這部 Mac。"
+ }
+ }
+ }
+ },
+ "Say one thing clearly.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Say one thing clearly."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "하나만 분명하게 전해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "一つのことを、はっきり伝えよう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "把一件事说清楚。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "把一件事說清楚。"
+ }
+ }
+ }
+ },
+ "Select All": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Select All"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "모두 선택"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "すべてを選択"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "全选"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "全選"
+ }
+ }
+ }
+ },
+ "Send": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Send"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "보내기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "送信"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发送"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "傳送"
+ }
+ }
+ }
+ },
+ "Show the result": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Show the result"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "결과 보여주기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "結果を見せる"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "展示结果"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "展示結果"
+ }
+ }
+ }
+ },
+ "Synced with the saved draft": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Synced with the saved draft"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "저장한 초안과 동기화했어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保存した下書きと同期しています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已与保存的草稿同步。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已與儲存的草稿同步。"
+ }
+ }
+ }
+ },
+ "TEAM": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "TEAM"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "팀"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "チーム"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "团队"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "團隊"
+ }
+ }
+ }
+ },
+ "Team / 2 members": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Team / 2 members"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "팀 / 멤버 2명"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "チーム / メンバー 2 人"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "团队 / 2 位成员"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "團隊 / 2 位成員"
+ }
+ }
+ }
+ },
+ "Terminal": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Terminal"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Terminal"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Terminal"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Terminal"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Terminal"
+ }
+ }
+ }
+ },
+ "Thanks! Let’s keep it calm and easy to understand.": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Thanks! Let’s keep it calm and easy to understand."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "고마워요! 차분하고 쉽게 이해할 수 있게 써봐요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ありがとうございます。落ち着いた、わかりやすい文章にしましょう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "谢谢!我们写得平实一点,让人容易理解。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "謝謝!我們寫得平實一點,讓人容易理解。"
+ }
+ }
+ }
+ },
+ "The launch brief is ready. Can you make the headline a little shorter?": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The launch brief is ready. Can you make the headline a little shorter?"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "출시 안내를 준비했어요. 제목을 조금 더 짧게 다듬어줄 수 있나요?"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "公開に向けた資料ができました。見出しをもう少し短くできますか?"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "发布说明准备好了,可以把标题再写短一点吗?"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "發布說明準備好了,可以把標題再寫短一點嗎?"
+ }
+ }
+ }
+ },
+ "The main idea is workspaces": {
+ "comment": "Story.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The main idea is workspaces"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "작업 공간이 중심 내용이에요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ワークスペースが中心に書かれています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文案围绕工作区展开。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文案圍繞工作空間展開。"
+ }
+ }
+ }
+ },
+ "The product should feel useful before it feels complicated.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The product should feel useful before it feels complicated."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "복잡한 기능보다 쓸모가 먼저 느껴져야 해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "仕組みの複雑さより先に、役立つことが伝わる製品にしましょう。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "先让人感到有用,再了解功能。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "先讓人感到實用,再認識功能。"
+ }
+ }
+ }
+ },
+ "The saved draft from Editor, ready for a second look.": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The saved draft from Editor, ready for a second look."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Editor에서 저장한 초안을 한 번 더 살펴봐요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Editor で保存した下書きを、もう一度確認します。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "再看一遍 Editor 中保存的草稿。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "再看一遍 Editor 中儲存的草稿。"
+ }
+ }
+ }
+ },
+ "Try headline, content, or screenshot.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Try headline, content, or screenshot."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제목, 콘텐츠, 스크린샷으로 찾아보세요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "見出し、コンテンツ、スクリーンショットで検索してみてください。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "试试搜索标题、内容或截图。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "試著搜尋標題、內容或截圖。"
+ }
+ }
+ }
+ },
+ "Undo": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Undo"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "실행 취소"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "取り消す"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "撤销"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "復原"
+ }
+ }
+ }
+ },
+ "Unsaved changes": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Unsaved changes"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "아직 저장하지 않은 변경 사항이 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "まだ保存していない変更があります。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "还有未保存的更改。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "還有未儲存的變更。"
+ }
+ }
+ }
+ },
+ "Updated by actual Tatami hooks": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Updated by actual Tatami hooks"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tatami 훅이 보낸 상태예요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tatami のフックから届いた状態です。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "状态来自实际的 Tatami 钩子事件。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "狀態來自實際的 Tatami 掛鉤事件。"
+ }
+ }
+ }
+ },
+ "Use no more than 40 characters. Lead with the benefit, then explain workspaces in the description.": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Use no more than 40 characters. Lead with the benefit, then explain workspaces in the description."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "40자 이내로 써주세요. 좋은 점을 먼저 전하고, 설명에서 작업 공간을 소개해요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "40 文字以内で書きましょう。よい点を先に伝え、説明でワークスペースを紹介します。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "请控制在 40 个字符以内。先说明好处,再在说明中介绍工作区。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "請控制在 40 個字元以內。先說明好處,再在說明中介紹工作空間。"
+ }
+ }
+ }
+ },
+ "WEBSITE / LAUNCH": {
+ "comment": "EditorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "WEBSITE / LAUNCH"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "웹사이트 / 출시"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Web サイト / 公開"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "网站 / 发布"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "網站 / 發布"
+ }
+ }
+ }
+ },
+ "WEBSITE PREVIEW": {
+ "comment": "ReviewView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "WEBSITE PREVIEW"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "웹사이트 미리보기"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Web サイトのプレビュー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "网站预览"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "網站預覽"
+ }
+ }
+ }
+ },
+ "Waiting for review": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Waiting for review"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검토를 기다리고 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "レビューを待っています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "等待评审。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "等待審閱。"
+ }
+ }
+ }
+ },
+ "Waiting for the next action…": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Waiting for the next action…"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "다음 동작을 기다리고 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "次の操作を待っています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "等待下一步操作。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "等待下一個操作。"
+ }
+ }
+ }
+ },
+ "What should they understand?": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "What should they understand?"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "무엇을 이해하면 좋을까요?"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "何を理解してほしいですか?"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "希望他们了解什么?"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "希望他們了解什麼?"
+ }
+ }
+ }
+ },
+ "What should we show?": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "What should we show?"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "무엇을 보여줄까요?"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "何を見せますか?"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "应该展示什么?"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "應該展示什麼?"
+ }
+ }
+ }
+ },
+ "Who is this for?": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Who is this for?"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "누구를 위한 제품인가요?"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "誰に向けた製品ですか?"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "这是为谁准备的?"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "這是為誰準備的?"
+ }
+ }
+ }
+ },
+ "Window": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Window"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "윈도우"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ウインドウ"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "窗口"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "視窗"
+ }
+ }
+ }
+ },
+ "Work in progress": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Work in progress"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "작업하고 있어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "作業を進めています。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "工作进行中。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "工作進行中。"
+ }
+ }
+ }
+ },
+ "Workspace status": {
+ "comment": "MonitorView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Workspace status"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "작업 공간 상태"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ワークスペースの状態"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "工作区状态"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "工作空間狀態"
+ }
+ }
+ }
+ },
+ "Y": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Y"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Y"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Y"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Y"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Y"
+ }
+ }
+ }
+ },
+ "YOUR WORK": {
+ "comment": "CanvasView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "YOUR WORK"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "내 작업"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自分の作業"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "你的工作"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "你的工作"
+ }
+ }
+ }
+ },
+ "You": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "You"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "나"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自分"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "我"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "我"
+ }
+ }
+ }
+ },
+ "Zoom": {
+ "comment": "DemoAppHost.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zoom"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "확대/축소"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "拡大/縮小"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "缩放"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "縮放"
+ }
+ }
+ }
+ },
+ "command": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "command"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "명령 입력"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "コマンドを入力"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "输入命令"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "輸入指令"
+ }
+ }
+ }
+ },
+ "general": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "general"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "general"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "general"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "general"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "general"
+ }
+ }
+ }
+ },
+ "headline short content 40 characters calmer work description screenshot": {
+ "comment": "DocsView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "headline short content 40 characters calmer work description screenshot"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "제목 짧게 콘텐츠 40자 차분하게 작업 설명 스크린샷"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "見出し 短い コンテンツ 40文字 落ち着く 作業 説明 スクリーンショット"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "标题 简短 内容 40个字符 从容 工作 说明 截图"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "標題 簡短 內容 40個字元 從容 工作 說明 截圖"
+ }
+ }
+ }
+ },
+ "launch": {
+ "comment": "ChatView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch"
+ }
+ }
+ }
+ },
+ "launch ❯": {
+ "comment": "TerminalView.stringsdata",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch ❯"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch ❯"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch ❯"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch ❯"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "launch ❯"
+ }
+ }
+ }
+ },
+ "Reading saved revision %lld": {
+ "comment": "Local Terminal copy-check result. Command text and machine output remain unchanged.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reading saved revision %lld"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "저장한 버전 %lld 읽는 중"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "保存したリビジョン %lld を読み込み中"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "正在读取保存的修订版 %lld"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "正在讀取儲存的修訂版 %lld"
+ }
+ }
+ }
+ },
+ "%lld of 3 checks passed.": {
+ "comment": "Local Terminal copy-check result. Command text and machine output remain unchanged.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld of 3 checks passed."
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "검사 3개 중 %lld개를 통과했어요."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "3 件中 %lld 件のチェックに合格しました。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "3 项检查中 %lld 项通过。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "3 項檢查中通過 %lld 項。"
+ }
+ }
+ }
+ }
+ },
+ "version": "1.0"
+}
diff --git a/DemoLab/Package.swift b/DemoLab/Package.swift
new file mode 100644
index 0000000..af55a63
--- /dev/null
+++ b/DemoLab/Package.swift
@@ -0,0 +1,81 @@
+// swift-tools-version: 6.0
+
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import PackageDescription
+
+// Tatami Demo Lab.
+//
+// Deliberately a *standalone* SwiftPM package with zero external dependencies:
+// `swift build` works offline with only the Command Line Tools, and nothing
+// here can reach the product's Tuist graph. Tuist resolves its root as the
+// closest directory containing `Tuist/` or `.git/`, so a nested Tuist manifest
+// would have shared the app's dependency resolution — this does not.
+let package = Package(
+ name: "TatamiDemoLab",
+ platforms: [.macOS(.v14)],
+ products: [
+ .executable(name: "democtl", targets: ["democtl"]),
+ .executable(name: "demokey", targets: ["demokey"]),
+ .executable(name: "DemoRecorder", targets: ["DemoRecorder"]),
+ .executable(name: "demoicon", targets: ["demoicon"]),
+ .executable(name: "Canvas", targets: ["Canvas"]),
+ .executable(name: "demohook", targets: ["demohook"]),
+ .executable(name: "Editor", targets: ["Editor"]),
+ .executable(name: "Terminal", targets: ["Terminal"]),
+ .executable(name: "Review", targets: ["Review"]),
+ .executable(name: "Docs", targets: ["Docs"]),
+ .executable(name: "Chat", targets: ["Chat"]),
+ .executable(name: "Notes", targets: ["Notes"]),
+ .executable(name: "Monitor", targets: ["Monitor"]),
+ .executable(name: "DemoOverlay", targets: ["DemoOverlay"]),
+ ],
+ targets: [
+ // Shared window shell, catalog, and deterministic fixtures for the demo apps.
+ .target(name: "DemoAppKit", path: "Sources/DemoAppKit"),
+
+ .executableTarget(name: "Canvas", dependencies: ["DemoAppKit"], path: "Sources/Apps/Canvas"),
+ .executableTarget(name: "demohook", dependencies: ["DemoAppKit"], path: "Sources/demohook"),
+ .executableTarget(name: "Editor", dependencies: ["DemoAppKit"], path: "Sources/Apps/Editor"),
+ .executableTarget(name: "Terminal", dependencies: ["DemoAppKit"], path: "Sources/Apps/Terminal"),
+ .executableTarget(name: "Review", dependencies: ["DemoAppKit"], path: "Sources/Apps/Review"),
+ .executableTarget(name: "Docs", dependencies: ["DemoAppKit"], path: "Sources/Apps/Docs"),
+ .executableTarget(name: "Chat", dependencies: ["DemoAppKit"], path: "Sources/Apps/Chat"),
+ .executableTarget(name: "Notes", dependencies: ["DemoAppKit"], path: "Sources/Apps/Notes"),
+ .executableTarget(name: "Monitor", dependencies: ["DemoAppKit"], path: "Sources/Apps/Monitor"),
+
+ // The caption and keystroke overlay. Never assigned to a workspace and never
+ // tiled: it rides above the demo on its own window level, and Tatami is told
+ // about it through `settings.visibility.overlayAwareApps`.
+ .executableTarget(name: "DemoOverlay", dependencies: ["DemoAppKit"], path: "Sources/DemoOverlay"),
+
+ // Synthesizes real key events for Tatami's *published* shortcuts. It never
+ // touches Tatami state directly.
+ .target(name: "DemoDriverKit", path: "Sources/DemoDriverKit"),
+ .executableTarget(name: "demokey", dependencies: ["DemoDriverKit"], path: "Sources/demokey"),
+
+ // ScreenCaptureKit -> AVAssetWriter screen recorder.
+ .target(name: "DemoRecorderKit", path: "Sources/DemoRecorderKit"),
+ .executableTarget(
+ name: "DemoRecorder",
+ dependencies: ["DemoRecorderKit"],
+ path: "Sources/DemoRecorder"
+ ),
+
+ // Renders app icons from SF Symbols at build time so the demo apps are
+ // distinguishable in the Dock, the app switcher, and Tatami's own HUD.
+ .executableTarget(name: "demoicon", dependencies: ["DemoAppKit"], path: "Sources/demoicon"),
+
+ // Orchestration: config rendering, deterministic reset, app lifecycle,
+ // scene playback, recorder control.
+ .target(name: "DemoCtlKit", dependencies: ["DemoAppKit", "DemoDriverKit"], path: "Sources/DemoCtlKit"),
+ .executableTarget(name: "democtl", dependencies: ["DemoCtlKit"], path: "Sources/democtl"),
+
+ .testTarget(
+ name: "DemoLabTests",
+ dependencies: ["DemoCtlKit", "DemoAppKit", "DemoDriverKit", "DemoRecorderKit"],
+ path: "Tests/DemoLabTests"
+ ),
+ ]
+)
diff --git a/DemoLab/README.md b/DemoLab/README.md
new file mode 100644
index 0000000..9643640
--- /dev/null
+++ b/DemoLab/README.md
@@ -0,0 +1,228 @@
+
+[English](README.md) · [한국어](ko/README.md) · [日本語](ja/README.md) · [简体中文](zh-Hans/README.md) · [繁體中文](zh-Hant/README.md)
+
+
+# Tatami Demo Lab
+
+Record the real Tatami, export a consistent set of marketing videos, and place
+those videos beside the product promises they demonstrate. The apps and their
+contents are fixtures; workspace switches, tiling, Borrow and window focus are
+performed by the installed Tatami.
+
+## Publication contract
+
+[`publication.json`](publication.json) is the inventory and editorial budget.
+It maps each scene to its website section and limits duration and delivery size.
+
+The introduction is a complete design → writing → review → Borrow → automation
+workflow, ending with a real display-topology change. Focused collections cover
+workspaces, profiles and displays, tiling and focus, Borrow, window modes,
+CLI/hooks, and Guided Setup. The inventory is generated from `publication.json`;
+there is no separate hard-coded list to keep in sync.
+
+The hero teaches the central promise: **switch contexts and keep your place**.
+Feature scenes demonstrate different activities instead of replaying the hero.
+See [coverage and evidence boundaries](docs/COVERAGE.md) for the complete map.
+
+## Capture → export → review → install locally
+
+Use the existing dedicated Tart VM, not your everyday desktop. `reset` and
+`seed` quit Tatami and change its preferences in whichever machine runs them.
+Config and layout files are isolated under `.build/lab/`; the preferences domain
+is backed up separately and can be restored with `democtl restore`.
+
+```sh
+# Host: run from the repository root.
+swift build --package-path Tools -c release
+TOOL=Tools/.build/release/tatami-tools
+"$TOOL" vm-sync
+
+# Guest: a fresh seed for every scene. Existing takes are never overwritten.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --output /Users/admin/DemoLab/recordings/publish
+
+# Host: fetch that exact batch, including originals, metadata and scene snapshots.
+GUEST_DIR=DemoLab/recordings/publish "$TOOL" vm-fetch-recordings DemoLab/recordings/publish
+
+# Host: new output directory; no ambiguous “latest take” selection.
+"$TOOL" export --takes DemoLab/recordings/publish --output ~/Downloads/TatamiDemoLab-review
+
+# Open index.html and inspect playback, opening frames, actions and every feature.
+# Install the verified bundle into this checkout only; this does not push or deploy.
+"$TOOL" install-assets ~/Downloads/TatamiDemoLab-review
+```
+
+Host requirements: Swift 6.2+, `tart`, `mpv` with libass/libx264, `ffmpeg` and
+`ffprobe`. Run host commands from the repository root. The guest needs Xcode
+Command Line Tools. Source sync carries the compiled automation executable, so
+the guest does not download packages. The recorder remains an independent
+SwiftPM package outside Tatami's Tuist graph.
+
+Automation uses `swift-subprocess`, ArgumentParser, SwiftSoup, Hummingbird,
+`swift-markdown`, `swift-cmark` and Swift Crypto. Foundation handles JSON and
+property lists. Capture acceptance, localization units and editorial budgets
+remain Tatami-specific rules. `Tools/Package.resolved` pins the dependency graph.
+
+The export host also needs Fontconfig. Before encoding, the lab verifies the requested font family and coverage of every caption character. The OCR audit separately compares overlaid narration with the narration timeline.
+
+## A small set of useful apps
+
+| Workspace | Apps | Actual work |
+| --- | --- | --- |
+| Design | Canvas + Docs | Change the visual theme, inspect the saved draft, export a PNG. |
+| Write | Editor + Docs | Read the brief, edit the headline, save the draft. |
+| Review | Review + Docs | Read saved copy, run checks, comment and approve. |
+| Chat | Chat | Type and send a local demo reply. |
+| Build | Terminal | Execute the real Tatami CLI and bundled automation scripts. |
+| Notes (Borrow only) | Notes | Add a follow-up and complete a checklist item. |
+| Focus | Canvas + Notes | Work with a per-workspace Always on Top note. |
+| Shared | Monitor | Observe saved project state or real workspace/HUD hook events. |
+
+All eight apps use persisted local data. Saving changes what Review and Canvas
+read. The Canvas export writes an actual image. Notes and messages survive
+workspace switches. Terminal executes an explicit command allowlist and bundled
+scripts; hooks receive Tatami's real environment. No fixture connects to a chat
+service, and the AI proposal demonstration uses a clearly labelled local example.
+
+## What changed in the capture contract
+
+A scene has two phases:
+
+- `setup` runs **before** recording: teardown, app launches, workspace warm-up,
+ fixture state and layout preparation.
+- `steps` are the visible demonstration. `openingApps` must have visible windows
+ with stable geometry before the recorder starts. `autoopen` is the deliberate
+ exception: a short empty opening is the point of that feature.
+
+The capture gate checks system permission/settings windows before recording and
+around every action. It also sees a permission dialog that is behind another
+window. Resolve that dialog; do not crop it out or suppress the error.
+
+**Recorder access is not Tatami access.** Earlier takes contained a stale
+Tatami Screen Recording request despite the recorder passing `doctor`.
+Tatami's Always on Top uses its own ScreenCaptureKit stream. A throwaway recorder
+warm-up alone cannot validate that path. Warm up the `shared` scene and inspect
+its actual monitor before accepting it. The default shared Monitor no longer
+auto-opens in every scene; `shared` explicitly launches it off camera.
+
+`waitWindows` waits for the named apps and stable window geometry.
+`saveLayout` / `assertLayout` compare the same window IDs and their bounds after
+workspace switches, Borrow returns, and zoom restoration. Missing or additional
+windows and coordinate changes beyond 4 points fail the take.
+
+`key` and `hold` generate their own keycasts from the actual input. Published
+scenes may not use a `keys` label to pretend a CLI operation was a keystroke.
+The recorder refuses startup without an encoded frame, and gives the scene its
+first-frame monotonic timestamp so subtitles share the movie's clock.
+
+## Presentation
+
+Original MOV files contain the full captured desktop. Exports keep that image at
+1920×1200, or 1920×600 for two screens. Narration appears over the lower part of
+the image, with a chapter label at the upper left and actual keystrokes at the
+upper right. Translucent backgrounds keep text readable over light and dark apps.
+The colors come from the website palette; changing presentation needs no new capture.
+
+Caption edits may reuse an original only when every recorded action, typed input,
+assertion and delay is unchanged. The exporter verifies the frozen scene hash and
+compares those actions before replacing narration on the original timeline.
+Original and edited scenes and timelines are both included in the evidence.
+
+Every take has:
+
+- `.mov`: clean camera original.
+- `.ass`: editable narration and actual keycast timing.
+- `.timeline.json`: all events with start/end times.
+- `.take.json`: pass/fail, scene hash, Tatami version, locale, per-output frame/drop counts, capture epochs and overlay mode.
+- `.scene.json`: the exact scene bytes frozen when recording began.
+
+A failed take stays available for diagnosis but cannot enter the exporter.
+Exports require a verified capture contract, clean narration, less than 1%
+dropped frames, timely opening captions and matching movie/timeline duration.
+The output must fit its time/size budget, use H.264/yuv420p, and survive a complete
+FFmpeg decode. `faststart` places the MP4 header before the media payload.
+
+The export bundle includes a playback gallery, posters, sampled evidence frames,
+source/delivery hashes and all sidecars. Automated acceptance does not replace
+watching the action: in particular, shared-window mirroring and focus targets
+still need visible verification. Website players use native controls and
+`preload="none"`; playback is deliberate and only one video plays at a time.
+Every collection exposes its thumbnail playlist, count and previous/next buttons.
+Each film links to its related configuration keys.
+Arrow keys, Home/End and per-film links work without opening a disclosure.
+
+For two-screen capture, the suite connects a real guest-side virtual display,
+records each display independently, and aligns them by first-frame timestamps.
+See [the verified VM setup](docs/MULTI-DISPLAY.md).
+
+## Work on one scene
+
+Run the capture commands inside the dedicated guest:
+
+```sh
+.build/DemoLab/bin/democtl doctor
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl scene tour --dry-run # prints setup and visible steps
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+# Host: after fetching that batch; run from the repository root.
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/iteration \
+ --output ~/Downloads/TatamiDemoLab-iteration --scenes tour
+```
+
+`democtl scene` rehearses with a live overlay. Recorded `take` defaults to
+`--overlay off`; only that mode is accepted for publication. The live rehearsal
+panel is not the export design. `subtitle burn` can render the sidecar into a
+viewing copy, but use `tatami-tools export` for budget checks, web encoding and evidence.
+
+Finish a session with `democtl quit` and `democtl restore` in the guest.
+Original host preferences and host Tatami are not involved in the VM workflow.
+
+## Development and reference
+
+```sh
+swift test --package-path Tools
+swift run --package-path Tools tatami-tools build-tool-notices --check
+swift run --package-path Tools tatami-tools bundle-apps
+DEMOLAB_LOCALIZATION_DIR="$PWD/DemoLab/.build/DemoLab/Localization" \
+ swift test --package-path DemoLab
+```
+
+- [Scene vocabulary](docs/SCENES.md)
+- [VM setup](docs/VM-TART.md)
+- [Permissions](docs/PERMISSIONS.md)
+- [Real multi-display capture](docs/MULTI-DISPLAY.md)
+
+## Five-language production
+
+The supported languages are `en`, `ko`, `ja`, `zh-Hans` and `zh-Hant`.
+`Localization/Localizable.xcstrings` owns fixture UI and seeded content;
+`Localization/Films.json` owns film titles, narration and scripted human input.
+`Localization/Interface.json` owns the review gallery. The product's existing
+catalog supplies native Tatami AX labels when a stable control identifier is
+not available. User-chosen app, workspace and profile names remain unchanged.
+
+```sh
+Tools/.build/release/tatami-tools localize-scenes
+Tools/.build/release/tatami-tools vm-sync
+# Inside the guest:
+.build/tools/tatami-tools capture --locale ko --output recordings/ko-batch
+# After fetching that explicit batch to the host:
+Tools/.build/release/tatami-tools export --locale ko --takes DemoLab/recordings/ko-batch --output ~/Downloads/Tatami-ko
+```
+
+Each recording seeds the fixture and Tatami language together. Human input and
+its assertions come from the same reviewed text. CLI commands and their actual
+machine output remain unchanged. The final delivery is 30 fps, so the batch
+also captures at 30 fps without unnecessary 60 fps sampling.
+
+`tatami-tools record-locales` identifies missing or rejected takes for each locale.
+It never treats an English capture as proof of a translated UI. A failed scene
+stays available for diagnosis; another language can continue independently.
+
+Small utility windows start near the lower-right corner. The hero moves the
+status window to the lower-left corner as a visible action. Keep the document's
+main reading area clear when adjusting a scenario.
+
+For a local website preview that outlives the terminal command, run
+`Tools/.build/release/tatami-tools preview-site --background` from the repository root.
diff --git a/DemoLab/Sources/Apps/Canvas/CanvasView.swift b/DemoLab/Sources/Apps/Canvas/CanvasView.swift
new file mode 100644
index 0000000..0f1f4b2
--- /dev/null
+++ b/DemoLab/Sources/Apps/Canvas/CanvasView.swift
@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct CanvasView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ var body: some View {
+ DemoWindow {
+ DemoTitle("Launch page", symbol: "square.stack.3d.up", accent: .purple, subtitle: "Canvas / Live preview")
+ Spacer()
+ Button("Export") { exportPreview() }.accessibilityIdentifier("canvas.export")
+ ForEach(["cobalt", "sand", "forest"], id: \.self) { theme in
+ Button { session.update { $0.designTheme = theme } } label: { Text(DemoTheme.name(theme)) }
+ .accessibilityIdentifier("canvas.\(theme)")
+ .buttonStyle(.bordered)
+ .tint(session.story.designTheme == theme ? Color.purple : Color.gray)
+ }
+ } content: {
+ ScrollView {
+ PagePreview(headline: session.story.headline, bodyText: session.story.body, theme: session.story.designTheme)
+ }.background(Color(red: 0.08, green: 0.09, blue: 0.13))
+ } status: {
+ StoryStatus("Live from the saved draft", error: session.error)
+ Spacer()
+ Text("\(String(localized: DemoTheme.name(session.story.designTheme))) · Revision \(session.story.revision)")
+ }.onAppear { session.start() }
+ }
+ private func exportPreview() {
+ let renderer=ImageRenderer(content:PagePreview(headline:session.story.headline,bodyText:session.story.body,theme:session.story.designTheme).frame(width:1280).background(Color(red:0.08,green:0.09,blue:0.13)))
+ guard let image=renderer.nsImage,let tiff=image.tiffRepresentation,let bitmap=NSBitmapImageRep(data:tiff),let png=bitmap.representation(using:.png,properties:[:]) else {session.error=String(localized: "Could not render the preview");return}
+ do {
+ try png.write(to:DemoControl.directory.appendingPathComponent("launch-preview.png"),options:.atomic)
+ session.update {story in story.exported=true;if let index=story.tasks.firstIndex(where:{$0.id==2}) {story.tasks[index].done=true}}
+ } catch {session.error=String(describing:error)}
+ }
+
+}
+private struct PagePreview: View {
+ let headline: String
+ let bodyText: String
+ let theme: String
+ var tint: Color {
+ switch theme {
+ case "sand": Color(red: 0.91, green: 0.80, blue: 0.59)
+ case "forest": Color(red: 0.44, green: 0.76, blue: 0.62)
+ default: Color(red: 0.47, green: 0.60, blue: 1.0)
+ }
+ }
+ var body: some View {
+ VStack(alignment: .leading, spacing: 32) {
+ HStack {
+ Label("FORM / FIELD", systemImage: "square.grid.2x2.fill").font(.system(size: 13, weight: .semibold))
+ Spacer()
+ Text("Product Journal").font(.system(size: 12))
+ }.foregroundStyle(.white.opacity(0.65))
+ Text("ROOM TO THINK").font(.system(size: 12, weight: .semibold)).tracking(2).foregroundStyle(tint).padding(.top, 32)
+ Text(headline).font(.system(size: 48, weight: .semibold)).tracking(-2).foregroundStyle(.white).fixedSize(horizontal: false, vertical: true)
+ Text(bodyText).font(.system(size: 18)).lineSpacing(6).foregroundStyle(.white.opacity(0.65)).frame(maxWidth: 640, alignment: .leading)
+ HStack { Text("Find your flow").font(.system(size: 15, weight: .semibold)); Image(systemName: "arrow.up.right") }
+ .foregroundStyle(.black).padding(.horizontal, 22).padding(.vertical, 14).background(tint, in: Capsule())
+ WorkspaceArtwork(tint: tint).frame(height: 240).padding(.top, 12)
+ HStack(spacing: 26) {
+ PreviewDetail("01", "A place to begin", "Bring the tools for this task together.")
+ PreviewDetail("02", "A place to return", "Keep the arrangement that works for you.")
+ }
+ }.padding(40).frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+private struct WorkspaceArtwork: View {
+ let tint: Color
+ var body: some View {
+ GeometryReader { proxy in
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 15) {
+ HStack(spacing: 5) { ForEach(0..<3) { _ in Circle().fill(.white.opacity(0.28)).frame(width: 6,height: 6) } }
+ RoundedRectangle(cornerRadius: 5).fill(tint).frame(width: proxy.size.width * 0.22, height: 16)
+ RoundedRectangle(cornerRadius: 4).fill(.white.opacity(0.15)).frame(height: 8)
+ RoundedRectangle(cornerRadius: 4).fill(.white.opacity(0.10)).frame(width: proxy.size.width * 0.25,height: 8)
+ Spacer()
+ Text("YOUR WORK").font(.system(size: 10, weight: .medium)).tracking(2).foregroundStyle(.white.opacity(0.4))
+ }.padding(24).frame(maxWidth: .infinity).background(.white.opacity(0.06), in: .rect(cornerRadius: 18))
+ VStack(spacing: 12) {
+ RoundedRectangle(cornerRadius: 18).fill(tint.opacity(0.22)).overlay(Image(systemName:"sparkle").font(.system(size:40)).foregroundStyle(tint))
+ RoundedRectangle(cornerRadius: 18).fill(.white.opacity(0.05)).overlay(Image(systemName:"text.alignleft").font(.system(size:28)).foregroundStyle(.white.opacity(0.45)))
+ }.frame(width: proxy.size.width * 0.35)
+ }
+ }
+ }
+}
+private struct PreviewDetail: View {
+ let number: String; let title: LocalizedStringResource; let description: LocalizedStringResource
+ init(_ number: String,_ title: LocalizedStringResource,_ description: LocalizedStringResource) { self.number=number;self.title=title;self.description=description }
+ var body: some View {
+ VStack(alignment: .leading,spacing:10) {
+ Text(number).font(.system(size:12)).foregroundStyle(.white.opacity(0.4))
+ Text(title).font(.system(size:18,weight:.medium)).foregroundStyle(.white)
+ Text(description).font(.system(size:14)).foregroundStyle(.white.opacity(0.55))
+ }.frame(maxWidth:.infinity,alignment:.leading)
+ }
+}
diff --git a/DemoLab/Sources/Apps/Canvas/main.swift b/DemoLab/Sources/Apps/Canvas/main.swift
new file mode 100644
index 0000000..2b6b1fe
--- /dev/null
+++ b/DemoLab/Sources/Apps/Canvas/main.swift
@@ -0,0 +1,5 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+DemoAppHost.run(.canvas) { CanvasView(context: $0) }
diff --git a/DemoLab/Sources/Apps/Chat/ChatView.swift b/DemoLab/Sources/Apps/Chat/ChatView.swift
new file mode 100644
index 0000000..cb089b8
--- /dev/null
+++ b/DemoLab/Sources/Apps/Chat/ChatView.swift
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct ChatView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ @State private var message = ""
+ var body: some View {
+ DemoWindow {
+ DemoTitle("# launch", symbol: "bubble.left.and.bubble.right", accent: .indigo, subtitle: "Team / 2 members")
+ Spacer()
+ } content: {
+ HStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 20) {
+ Text("TEAM").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
+ Label("launch", systemImage: "number").font(.system(size: 15, weight: .semibold))
+ Label("general", systemImage: "number").foregroundStyle(.secondary)
+ Spacer()
+ Label("Mina", systemImage: "circle.fill").font(.system(size: 13)).foregroundStyle(.green)
+ }.padding(24).frame(width: 160).background(.quaternary.opacity(0.4))
+ Divider()
+ VStack(spacing: 0) {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ StoryHeading("LAUNCH / WEBSITE", "Keep the conversation here.")
+ ForEach(session.story.messages) { item in ChatMessageRow(message: item).id(item.id) }
+ }.padding(32).frame(maxWidth: 860, alignment: .leading).frame(maxWidth: .infinity)
+ }.onChange(of: session.story.messages.count) { _, _ in
+ if let id = session.story.messages.last?.id { proxy.scrollTo(id, anchor: .bottom) }
+ }
+ }
+ Divider()
+ HStack {
+ TextField("Message #launch", text: $message).textFieldStyle(.plain)
+ .font(.system(size: 18)).accessibilityIdentifier("chat.message")
+ .onSubmit { send() }
+ Button("Send", systemImage: "arrow.up") { send() }
+ .disabled(message.trimmingCharacters(in: .whitespaces).isEmpty)
+ .accessibilityIdentifier("chat.send")
+ }.padding(18).background(Color.indigo.opacity(0.035))
+ }.background(Color(nsColor: .textBackgroundColor))
+ }
+ } status: {
+ StoryStatus("Local demo conversation", error: session.error)
+ Spacer()
+ Text("\(session.story.messages.count) messages")
+ }.onAppear { session.start() }
+ }
+ private func send() {
+ let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ session.update { story in
+ story.messages.append(StoryMessage(id: (story.messages.map(\.id).max() ?? 0) + 1, author: "You", text: text))
+ }; message = ""
+ }
+}
+private struct ChatMessageRow: View {
+ let message: StoryMessage
+ var body: some View {
+ HStack(alignment: .top, spacing: 14) {
+ Text(message.author == "You" ? "Y" : "M").font(.system(size: 15, weight: .semibold))
+ .frame(width: 38, height: 38).background(message.author == "You" ? Color.blue.opacity(0.12) : Color.purple.opacity(0.12), in: .rect(cornerRadius: 12))
+ VStack(alignment: .leading, spacing: 8) {
+ Text(message.author == "You" ? String(localized: "You") : message.author).font(.system(size: 15, weight: .semibold))
+ Text(message.text).font(.system(size: 18)).lineSpacing(5)
+ }
+ }.frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/DemoLab/Sources/Apps/Chat/main.swift b/DemoLab/Sources/Apps/Chat/main.swift
new file mode 100644
index 0000000..01c60bd
--- /dev/null
+++ b/DemoLab/Sources/Apps/Chat/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.chat) { context in ChatView(context: context) }
diff --git a/DemoLab/Sources/Apps/Docs/DocsView.swift b/DemoLab/Sources/Apps/Docs/DocsView.swift
new file mode 100644
index 0000000..8a6e304
--- /dev/null
+++ b/DemoLab/Sources/Apps/Docs/DocsView.swift
@@ -0,0 +1,73 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct DocsView: View {
+ let context: DemoRenderContext
+ @State private var page = "brief"
+ @State private var query = ""
+ var body: some View {
+ DemoWindow {
+ DemoTitle("Launch brief", symbol: "book.closed", accent: .teal, subtitle: "Reference")
+ Spacer()
+ TextField("Find in brief", text: $query).textFieldStyle(.roundedBorder).frame(maxWidth: 170)
+ .accessibilityIdentifier("docs.search")
+ } content: {
+ HStack(spacing: 0) {
+ BriefSidebar(page: $page)
+ Divider()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ if !query.isEmpty && !String(localized: "headline short content 40 characters calmer work description screenshot").localizedCaseInsensitiveContains(query) {
+ StoryHeading("SEARCH", "No matches.", "Try headline, content, or screenshot.")
+ } else if page == "brief" && query.isEmpty {
+ StoryHeading("PROJECT / LAUNCH", "A calmer way to work.", "The product should feel useful before it feels complicated.")
+ BriefSection("01", "Who is this for?", "People who switch between writing, reviewing and talking to their team. Their tools change with the task.")
+ BriefSection("02", "What should they understand?", "A workspace brings together the apps and layout for one task. Switch away, then return to the place you left.")
+ BriefSection("03", "What should we show?", "A real piece of work moving from draft to review. Keep the tools visible and let the workflow explain itself.")
+ } else {
+ StoryHeading("CONTENT / DIRECTION", "Say one thing clearly.", "A short headline, followed by a concrete explanation.")
+ BriefSection("01", "Keep it short", "Use no more than 40 characters. Lead with the benefit, then explain workspaces in the description.")
+ VStack(alignment: .leading, spacing: 12) {
+ Text("SUGGESTED HEADLINE").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
+ Text("A calmer way to work.").font(.system(size: 27, weight: .semibold))
+ }.padding(22).frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.teal.opacity(0.08), in: .rect(cornerRadius: 12))
+ BriefSection("02", "Show the result", "Let readers see the draft, the review and the saved layout. A quiet moment of real work says more than a list of shortcuts.")
+ BriefSection("03", "Before you share", "Check the copy, request a review, and add one clear product screenshot.")
+ }
+ }.padding(30)
+ }.accessibilityIdentifier("docs.article").background(Color(nsColor: .textBackgroundColor))
+ }
+ } status: {
+ StoryStatus("Launch brief / Team reference")
+ Spacer()
+ Text(page == "brief" && query.isEmpty ? LocalizedStringResource("Overview") : LocalizedStringResource("Content direction"))
+ }.onAppear { if context.ordinal % 2 == 1 { page = "content" } }
+ }
+}
+private struct BriefSidebar: View {
+ @Binding var page: String
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("REFERENCE").font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary).padding(.bottom, 8)
+ Button { page = "brief" } label: { Label("Overview", systemImage: "doc.text") }
+ .accessibilityIdentifier("docs.overview")
+ Button { page = "content" } label: { Label("Content", systemImage: "text.alignleft") }
+ .accessibilityIdentifier("docs.content")
+ Spacer()
+ }.buttonStyle(.plain).font(.system(size: 14)).padding(18)
+ .frame(width: 145).frame(maxHeight: .infinity).background(.quaternary.opacity(0.4))
+ }
+}
+private struct BriefSection: View {
+ let number: String; let title: LocalizedStringResource; let text: LocalizedStringResource
+ init(_ number: String, _ title: LocalizedStringResource, _ text: LocalizedStringResource) { self.number = number; self.title = title; self.text = text }
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack { Text(number); Text("/"); Text(title) }.font(.system(size: 19, weight: .semibold))
+ Text(text).font(.system(size: 16)).foregroundStyle(.secondary).lineSpacing(5)
+ }.frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/DemoLab/Sources/Apps/Docs/main.swift b/DemoLab/Sources/Apps/Docs/main.swift
new file mode 100644
index 0000000..4de4303
--- /dev/null
+++ b/DemoLab/Sources/Apps/Docs/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.docs) { context in DocsView(context: context) }
diff --git a/DemoLab/Sources/Apps/Editor/EditorView.swift b/DemoLab/Sources/Apps/Editor/EditorView.swift
new file mode 100644
index 0000000..7839fe1
--- /dev/null
+++ b/DemoLab/Sources/Apps/Editor/EditorView.swift
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct EditorView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ @State private var headline = LaunchStory().headline
+ @State private var description = LaunchStory().body
+ var body: some View {
+ DemoWindow {
+ DemoTitle("Launch page", symbol: "doc.text", accent: .blue, subtitle: "Copy / Draft")
+ Spacer()
+ Button("Save") {
+ session.update { story in
+ story.headline = headline; story.body = description
+ story.revision += 1; story.approved = false; story.checks = []
+ }
+ }.keyboardShortcut("s").accessibilityIdentifier("editor.save")
+ } content: {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 28) {
+ StoryHeading("WEBSITE / LAUNCH", "Make room for better work.", "A short introduction for people meeting the product for the first time.")
+ DraftFields(headline: $headline, description: $description)
+ Divider()
+ HStack {
+ Label("Plain language", systemImage: "text.bubble")
+ Spacer()
+ Label("One clear idea", systemImage: "scope")
+ }.font(.system(size: 13)).foregroundStyle(.secondary)
+ }.padding(32)
+ }.background(Color(nsColor: .textBackgroundColor))
+ } status: {
+ StoryStatus(headline == session.story.headline && description == session.story.body ? "All changes saved" : "Unsaved changes", error: session.error)
+ Spacer()
+ Text("Revision \(session.story.revision)")
+ }
+ .onAppear {
+ session.start(); headline = session.story.headline; description = session.story.body
+ }
+ }
+}
+
+private struct DraftFields: View {
+ @Binding var headline: String
+ @Binding var description: String
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("HEADLINE").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
+ Spacer()
+ Text("\(headline.count) / 40").font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(headline.count > 40 ? Color.orange : Color.secondary)
+ }
+ TextField("Headline", text: $headline, axis: .vertical)
+ .textFieldStyle(.plain).font(.system(size: 28, weight: .semibold))
+ .padding(16).background(Color.blue.opacity(0.045), in: .rect(cornerRadius: 10))
+ .accessibilityIdentifier("editor.headline")
+ Text("DESCRIPTION").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary).padding(.top, 14)
+ TextEditor(text: $description).font(.system(size: 18)).lineSpacing(6)
+ .scrollContentBackground(.hidden).frame(minHeight: 185)
+ .accessibilityIdentifier("editor.description")
+ }
+ }
+}
diff --git a/DemoLab/Sources/Apps/Editor/main.swift b/DemoLab/Sources/Apps/Editor/main.swift
new file mode 100644
index 0000000..e08545f
--- /dev/null
+++ b/DemoLab/Sources/Apps/Editor/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.editor) { context in EditorView(context: context) }
diff --git a/DemoLab/Sources/Apps/Monitor/MonitorView.swift b/DemoLab/Sources/Apps/Monitor/MonitorView.swift
new file mode 100644
index 0000000..36d32fe
--- /dev/null
+++ b/DemoLab/Sources/Apps/Monitor/MonitorView.swift
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct MonitorView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ @State private var hooks = HookSession()
+ @State private var automation = false
+ var body: some View {
+ DemoWindow {
+ DemoTitle(automation ? "Workspace status" : "Launch status", symbol: automation ? "bolt" : "checkmark.seal", accent: .green)
+ Spacer()
+ Button { automation.toggle() } label: { Text(automation ? LocalizedStringResource("Project") : LocalizedStringResource("Hooks")) }.accessibilityIdentifier("monitor.mode")
+ } content: {
+ if automation {
+ HookMonitor(workspace:hooks.workspace,hud:hooks.hud)
+ } else {
+ ProjectMonitor(story:session.story)
+ }
+ } status: {
+ StoryStatus(automation ? "Updated by actual Tatami hooks" : "Synced with the saved draft", error: automation ? hooks.error : session.error)
+ }.onAppear {session.start();hooks.start()}
+ }
+}
+private struct HookMonitor:View {
+ let workspace:HookActivity;let hud:HookActivity
+ var body: some View {
+ VStack(alignment:.leading,spacing:16) {
+ Text(workspace.workspace.isEmpty ? workspace.profile : workspace.workspace).font(.system(size:25,weight:.semibold))
+ Label(workspace.profile,systemImage:"rectangle.stack").foregroundStyle(.secondary)
+ Divider()
+ Text(hud.title.isEmpty ? workspace.event : hud.title).font(.system(size:16,weight:.medium))
+ Text(hud.subtitle.isEmpty ? String(localized: "Waiting for the next action…") : hud.subtitle).font(.system(size:13)).foregroundStyle(.secondary).lineLimit(3)
+ }.padding(22).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.topLeading).background(Color(nsColor:.textBackgroundColor))
+ }
+}
+private struct ProjectMonitor:View {
+ let story:LaunchStory
+ var body: some View {
+ VStack(alignment:.leading,spacing:18) {
+ Text(story.approved ? LocalizedStringResource("Ready to share") : LocalizedStringResource("Work in progress")).font(.system(size:22,weight:.semibold))
+ Label("Draft · revision \(story.revision)",systemImage:"doc.text")
+ Label { Text(story.checks.isEmpty ? LocalizedStringResource("Copy checks pending") : LocalizedStringResource("\(story.checks.filter(\.passed).count) / 3 checks passed")) } icon: { Image(systemName:"checkmark.circle") }
+ .foregroundStyle(story.checks.count == 3 && story.checks.allSatisfy(\.passed) ? Color.green : Color.secondary)
+ Label { Text(story.approved ? LocalizedStringResource("Review approved") : LocalizedStringResource("Waiting for review")) } icon: { Image(systemName:story.approved ? "checkmark.seal.fill" : "clock") }
+ .foregroundStyle(story.approved ? Color.green : Color.secondary)
+ }.font(.system(size:15)).padding(22).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.topLeading).background(Color(nsColor:.textBackgroundColor))
+ }
+}
diff --git a/DemoLab/Sources/Apps/Monitor/main.swift b/DemoLab/Sources/Apps/Monitor/main.swift
new file mode 100644
index 0000000..e017327
--- /dev/null
+++ b/DemoLab/Sources/Apps/Monitor/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.monitor) { context in MonitorView(context: context) }
diff --git a/DemoLab/Sources/Apps/Notes/NotesView.swift b/DemoLab/Sources/Apps/Notes/NotesView.swift
new file mode 100644
index 0000000..5e27771
--- /dev/null
+++ b/DemoLab/Sources/Apps/Notes/NotesView.swift
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct NotesView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ @State private var newTask = ""
+ var body: some View {
+ DemoWindow {
+ DemoTitle("Launch checklist", symbol: "checklist", accent: .orange, subtitle: "Notes")
+ Spacer()
+ } content: {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 28) {
+ StoryHeading("FOLLOW-UP", "Before we share it.", "Keep the next small steps beside the work.")
+ VStack(spacing: 18) {
+ ForEach(session.story.tasks) { item in
+ ChecklistRow(item: item) {
+ session.update { story in
+ guard let index = story.tasks.firstIndex(where: { $0.id == item.id }) else { return }
+ story.tasks[index].done.toggle()
+ }
+ }
+ }
+ }
+ Divider()
+ HStack(alignment: .top) {
+ Image(systemName: "plus.circle").foregroundStyle(.orange).padding(.top, 5)
+ TextField("Add a task", text: $newTask).textFieldStyle(.plain)
+ .font(.system(size: 17)).accessibilityIdentifier("notes.newTask")
+ .onSubmit { addTask() }
+ Button("Add") { addTask() }.disabled(newTask.trimmingCharacters(in: .whitespaces).isEmpty)
+ .accessibilityIdentifier("notes.add")
+ }.padding(13).background(Color.orange.opacity(0.055), in: .rect(cornerRadius: 10))
+ Text("A good follow-up is small enough to do next.").font(.system(size: 14)).foregroundStyle(.secondary)
+ }.padding(26)
+ }.background(Color(nsColor: .textBackgroundColor))
+ } status: {
+ StoryStatus("Saved locally", error: session.error)
+ Spacer()
+ Text("\(session.story.tasks.filter(\.done).count) of \(session.story.tasks.count) done")
+ }.onAppear { session.start() }
+ }
+ private func addTask() {
+ let text = newTask.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ session.update { story in
+ story.tasks.append(StoryTask(id: (story.tasks.map(\.id).max() ?? 0) + 1, text: text, done: false))
+ }; newTask = ""
+ }
+}
+private struct ChecklistRow: View {
+ let item: StoryTask
+ let toggle: () -> Void
+ var body: some View {
+ Button(action: toggle) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: item.done ? "checkmark.circle.fill" : "circle").foregroundStyle(item.done ? Color.orange : Color.secondary)
+ Text(item.text).strikethrough(item.done).foregroundStyle(item.done ? Color.secondary : Color.primary)
+ Spacer(minLength: 0)
+ }.font(.system(size: 18)).frame(maxWidth: .infinity, alignment: .leading)
+ }.buttonStyle(.plain).accessibilityIdentifier("notes.task.\(item.id)")
+ }
+}
diff --git a/DemoLab/Sources/Apps/Notes/main.swift b/DemoLab/Sources/Apps/Notes/main.swift
new file mode 100644
index 0000000..947a73d
--- /dev/null
+++ b/DemoLab/Sources/Apps/Notes/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.notes) { context in NotesView(context: context) }
diff --git a/DemoLab/Sources/Apps/Review/ReviewView.swift b/DemoLab/Sources/Apps/Review/ReviewView.swift
new file mode 100644
index 0000000..32291f6
--- /dev/null
+++ b/DemoLab/Sources/Apps/Review/ReviewView.swift
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct ReviewView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ @State private var comment = ""
+ var body: some View {
+ DemoWindow {
+ DemoTitle("Launch copy", symbol: "checkmark.bubble", accent: .purple, subtitle: "Review / Website")
+ Spacer()
+ Button("Check copy") { session.update { $0.checks = $0.evaluateCopy() } }
+ .accessibilityIdentifier("review.check")
+ Button {
+ session.update { $0.approved = true }
+ } label: { Text(session.story.approved ? LocalizedStringResource("Approved") : LocalizedStringResource("Approve")) }
+ .disabled(session.story.checks.isEmpty || session.story.checks.contains { !$0.passed } || session.story.approved)
+ .accessibilityIdentifier("review.approve")
+ } content: {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 26) {
+ StoryHeading("READY FOR REVIEW", "A small change. A clearer idea.", "The saved draft from Editor, ready for a second look.")
+ DraftPreview(headline: session.story.headline, description: session.story.body)
+ if !session.story.checks.isEmpty { CopyChecksView(session.story.checks) }
+ Divider()
+ Text("REVIEW NOTE").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
+ if !session.story.reviewComment.isEmpty {
+ Label(session.story.reviewComment, systemImage: "bubble.left.fill")
+ .font(.system(size: 17)).padding(16).frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.purple.opacity(0.06), in: .rect(cornerRadius: 10))
+ }
+ HStack {
+ TextField("Leave a review note", text: $comment).textFieldStyle(.roundedBorder)
+ .accessibilityIdentifier("review.comment")
+ .onSubmit { submit() }
+ Button("Add note") { submit() }.disabled(comment.trimmingCharacters(in: .whitespaces).isEmpty)
+ .accessibilityIdentifier("review.submit")
+ }.controlSize(.large)
+ }.padding(32)
+ }.background(Color(nsColor: .textBackgroundColor))
+ } status: {
+ StoryStatus(session.story.approved ? "Approved for the launch page" : "Reviewing revision \(session.story.revision)", error: session.error)
+ Spacer()
+ Text("Local review")
+ }.onAppear { session.start() }
+ }
+ private func submit() {
+ let text = comment.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ session.update { $0.reviewComment = text }; comment = ""
+ }
+}
+private struct DraftPreview: View {
+ let headline: String
+ let description: String
+ var body: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ Text("WEBSITE PREVIEW").font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary)
+ Text(headline).font(.system(size: 32, weight: .bold)).tracking(-0.6)
+ Text(description).font(.system(size: 18)).foregroundStyle(.secondary).lineSpacing(5)
+ }.padding(26).frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.purple.opacity(0.045), in: .rect(cornerRadius: 14))
+ }
+}
diff --git a/DemoLab/Sources/Apps/Review/main.swift b/DemoLab/Sources/Apps/Review/main.swift
new file mode 100644
index 0000000..5933dac
--- /dev/null
+++ b/DemoLab/Sources/Apps/Review/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.review) { context in ReviewView(context: context) }
diff --git a/DemoLab/Sources/Apps/Terminal/TerminalView.swift b/DemoLab/Sources/Apps/Terminal/TerminalView.swift
new file mode 100644
index 0000000..64ea254
--- /dev/null
+++ b/DemoLab/Sources/Apps/Terminal/TerminalView.swift
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import SwiftUI
+
+struct TerminalView: View {
+ let context: DemoRenderContext
+ @State private var session = StorySession()
+ @State private var command = ""
+ @State private var running = false
+ @State private var exitCode: Int32 = 0
+ @State private var output = String(localized: "Launch tools\n\n check copy Validate the saved draft\n tatami … Run the real Tatami CLI\n focus-session Run the workspace setup script\n set-spacing 24 Edit the live TOML configuration\n")
+ var body: some View {
+ DemoWindow {
+ DemoTitle("Terminal", symbol: "terminal", accent: .graphite, subtitle: "Local automation")
+ Spacer()
+ Text(running ? LocalizedStringResource("Running") : LocalizedStringResource("Ready")).foregroundStyle(.secondary)
+ } content: {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ Text(output).font(.system(size: 23, design: .monospaced)).lineSpacing(6)
+ .foregroundStyle(Color(white: 0.9)).textSelection(.enabled)
+ HStack {
+ Text("launch ❯").foregroundStyle(Color(red:0.91,green:0.80,blue:0.59))
+ TextField("command", text: $command).textFieldStyle(.plain).foregroundStyle(.white)
+ .accessibilityIdentifier("terminal.command").onSubmit { submit() }.disabled(running)
+ }.font(.system(size: 23, design: .monospaced)).id("prompt")
+ }.padding(26).frame(maxWidth: .infinity, alignment: .leading)
+ }.accessibilityIdentifier("terminal.output")
+ .onChange(of: output) { _, _ in proxy.scrollTo("prompt", anchor: .bottom) }
+ }.background(Color(red: 0.10, green: 0.10, blue: 0.11))
+ } status: {
+ StoryStatus(running ? "Command in progress" : "Exit \(exitCode)", error: session.error)
+ }.onAppear { session.start() }
+ }
+ private func submit() {
+ let entered=command.trimmingCharacters(in:.whitespacesAndNewlines)
+ guard !entered.isEmpty, !running else {return}
+ command="";running=true
+ output += "\nlaunch ❯ \(entered)\n"
+ let resultFile=DemoControl.directory.appendingPathComponent("terminal-result.json")
+ do {
+ if FileManager.default.fileExists(atPath:resultFile.path) {try FileManager.default.removeItem(at:resultFile)}
+ } catch {output += "\(error)\n";running=false;return}
+ Task {
+ defer {running=false}
+ do {
+ let result:TerminalResult
+ if entered == "check copy" {
+ session.update {$0.checks=$0.evaluateCopy()}
+ let checks=session.story.checks
+ let text=String(localized: "Reading saved revision \(session.story.revision)") + "\n\n" + checks.map {"\($0.passed ? "✓" : "✕") \($0.title)"}.joined(separator:"\n") + "\n\n" + String(localized: "\(checks.filter(\.passed).count) of 3 checks passed.") + "\n"
+ result=TerminalResult(command:entered,output:text,exitCode:checks.allSatisfy(\.passed) ? 0 : 1)
+ try JSONEncoder().encode(result).write(to:resultFile,options:.atomic)
+ } else {
+ result=try await DemoCommandRunner.run(entered)
+ }
+ output += result.output + "\n";exitCode=result.exitCode
+ } catch {output += "\(error)\n";exitCode=1}
+ }
+ }
+}
diff --git a/DemoLab/Sources/Apps/Terminal/main.swift b/DemoLab/Sources/Apps/Terminal/main.swift
new file mode 100644
index 0000000..2a18743
--- /dev/null
+++ b/DemoLab/Sources/Apps/Terminal/main.swift
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import SwiftUI
+
+DemoAppHost.run(.terminal) { context in TerminalView(context: context) }
diff --git a/DemoLab/Sources/DemoAppKit/Automation.swift b/DemoLab/Sources/DemoAppKit/Automation.swift
new file mode 100644
index 0000000..7bab977
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/Automation.swift
@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import Foundation
+import Observation
+
+public struct DemoCLIContext: Codable, Sendable {
+ public let executable: String
+ public let socket: String
+ public let automationExecutable: String
+ public let config: String
+ public init(executable:String,socket:String,automationExecutable:String,config:String) {
+ self.executable=executable;self.socket=socket;self.automationExecutable=automationExecutable;self.config=config
+ }
+ public static var file: URL { DemoControl.directory.appendingPathComponent("cli-context.json") }
+}
+public struct TerminalResult: Codable, Sendable {
+ public let command: String
+ public let output: String
+ public let exitCode: Int32
+ public init(command:String,output:String,exitCode:Int32) {self.command=command;self.output=output;self.exitCode=exitCode}
+}
+
+public enum DemoCommandRunner {
+ public static func run(_ command:String) async throws -> TerminalResult {
+ let context=try JSONDecoder().decode(DemoCLIContext.self,from:Data(contentsOf:DemoCLIContext.file))
+ let resultFile=DemoControl.directory.appendingPathComponent("terminal-result.json")
+ return try await Task.detached {
+ let words=command.split(separator:" ").map(String.init)
+ let executable:URL
+ let arguments:[String]
+ if words.first == "tatami" {
+ executable=URL(fileURLWithPath:context.executable)
+ arguments=Array(words.dropFirst())
+ } else if words.first == "focus-session", words.count == 1 {
+ executable=URL(fileURLWithPath:context.automationExecutable)
+ arguments=["focus-session"]
+ } else if words.first == "set-spacing", words.count == 2, let gap=Int(words[1]), (4...40).contains(gap) {
+ executable=URL(fileURLWithPath:context.automationExecutable)
+ arguments=["set-spacing", String(gap)]
+ } else {
+ return TerminalResult(command:command,output:"Available: tatami , focus-session, set-spacing <4...40>",exitCode:64)
+ }
+ let process=Process();process.executableURL=executable;process.arguments=arguments
+ var env=ProcessInfo.processInfo.environment
+ env["TATAMI_SOCKET_PATH"]=context.socket;env["TATAMI_CLI"]=context.executable;env["DEMO_CONFIG"]=context.config
+ process.environment=env
+ let output=Pipe();process.standardOutput=output;process.standardError=output;process.standardInput=FileHandle.nullDevice
+ try process.run()
+ let data=output.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ let result=TerminalResult(command:command,output:String(decoding:data,as:UTF8.self),exitCode:process.terminationStatus)
+ try JSONEncoder().encode(result).write(to:resultFile,options:.atomic)
+ return result
+ }.value
+ }
+}
+
+public struct HookActivity: Codable, Sendable {
+ public var event: String
+ public var profile: String
+ public var workspace: String
+ public var title: String
+ public var subtitle: String
+ public init(event:String="Waiting for an event",profile:String="",workspace:String="",title:String="",subtitle:String="") {
+ self.event=event;self.profile=profile;self.workspace=workspace;self.title=title;self.subtitle=subtitle
+ }
+}
+@MainActor @Observable
+public final class HookSession {
+ public var workspace = HookActivity()
+ public var hud = HookActivity()
+ public var error = ""
+ @ObservationIgnored private var observation: NSObjectProtocol?
+ public init() {}
+ public static let changed=Notification.Name("dev.PangMo5.DemoLab.hookChanged")
+ public func start() {
+ refresh()
+ guard observation == nil else {return}
+ observation=DistributedNotificationCenter.default().addObserver(forName:Self.changed,object:nil,queue:.main) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
+ }
+ }
+ public func refresh() {
+ do {
+ for (name,isHUD) in [("workspace-hook.json",false),("hud-hook.json",true)] {
+ let file=DemoControl.directory.appendingPathComponent(name)
+ guard FileManager.default.fileExists(atPath:file.path) else {continue}
+ let value=try JSONDecoder().decode(HookActivity.self,from:Data(contentsOf:file))
+ if isHUD {hud=value} else {workspace=value}
+ }
+ error=""
+ } catch {self.error=String(describing:error)}
+ }
+}
diff --git a/DemoLab/Sources/DemoAppKit/DemoAppHost.swift b/DemoLab/Sources/DemoAppKit/DemoAppHost.swift
new file mode 100644
index 0000000..4a4aaf9
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/DemoAppHost.swift
@@ -0,0 +1,421 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import SwiftUI
+
+// MARK: - DemoLaunchOptions
+
+/// Launch-time overrides. Everything here has a fixed default in
+/// ``DemoAppSpec`` so an app that Tatami auto-opens (no argv) still produces
+/// exactly the same windows as one `democtl` launched.
+public struct DemoLaunchOptions: Sendable, Equatable {
+
+ // MARK: Lifecycle
+
+ public init(windowCount: Int? = nil, frame: CGRect? = nil, variant: Int = 0) {
+ self.windowCount = windowCount
+ self.frame = frame
+ self.variant = variant
+ }
+
+ // MARK: Public
+
+ /// Parses `--windows N`, `--frame x,y,w,h`, and `--variant N`. Unknown
+ /// arguments are rejected loudly: a typo in a scene file must not silently
+ /// produce a different window count on camera.
+ public static func parse(_ arguments: [String]) throws -> DemoLaunchOptions {
+ var options = DemoLaunchOptions()
+ var index = arguments.startIndex
+
+ while index < arguments.endIndex {
+ let argument = arguments[index]
+ func value() throws -> String {
+ let next = arguments.index(after: index)
+ guard next < arguments.endIndex else {
+ throw DemoLaunchError.missingValue(argument)
+ }
+ index = next
+ return arguments[next]
+ }
+
+ switch argument {
+ case "--windows":
+ guard let count = Int(try value()), count >= 0, count <= 8 else {
+ throw DemoLaunchError.invalidValue(argument)
+ }
+ options.windowCount = count
+
+ case "--frame":
+ let parts = try value().split(separator: ",").compactMap { Double($0) }
+ guard parts.count == 4, parts[2] > 0, parts[3] > 0 else {
+ throw DemoLaunchError.invalidValue(argument)
+ }
+ options.frame = CGRect(x: parts[0], y: parts[1], width: parts[2], height: parts[3])
+
+ case "--variant":
+ guard let variant = Int(try value()), variant >= 0 else {
+ throw DemoLaunchError.invalidValue(argument)
+ }
+ options.variant = variant
+
+ // LaunchServices hands `-psn_…` to apps opened through `open`.
+ case let other where other.hasPrefix("-psn_"):
+ break
+
+ case let other:
+ throw DemoLaunchError.unknownArgument(other)
+ }
+
+ index = arguments.index(after: index)
+ }
+
+ return options
+ }
+
+ public var windowCount: Int?
+ public var frame: CGRect?
+ /// Selects between fixed content variants (never random, never time-based).
+ /// It shifts a window's title with its content, because both read one index.
+ public var variant: Int
+
+}
+
+// MARK: - DemoLaunchError
+
+public enum DemoLaunchError: Error, CustomStringConvertible {
+ case unknownArgument(String)
+ case missingValue(String)
+ case invalidValue(String)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .unknownArgument(let name): "unknown argument: \(name)"
+ case .missingValue(let name): "missing value for \(name)"
+ case .invalidValue(let name): "invalid value for \(name)"
+ }
+ }
+}
+
+// MARK: - DemoAppHost
+
+/// The AppKit shell every demo app runs on.
+///
+/// AppKit rather than a SwiftUI `App` on purpose: SwiftUI's scene restoration
+/// reuses and repositions windows between launches, which is exactly the
+/// non-determinism a repeatable recording cannot have. Here the window count,
+/// titles, and frames are computed from fixed inputs only.
+@MainActor
+public enum DemoAppHost {
+
+ /// Boots the app. Never returns.
+ ///
+ /// `content` receives stable window identity; the app owns its editable state.
+ public static func run(
+ _ id: DemoAppID,
+ @ViewBuilder content: @escaping (DemoRenderContext) -> some View
+ ) -> Never {
+ let spec = DemoCatalog.spec(id)
+ let arguments = Array(CommandLine.arguments.dropFirst())
+
+ let options: DemoLaunchOptions
+ do {
+ options = try DemoLaunchOptions.parse(arguments)
+ } catch {
+ FileHandle.standardError.write(Data("\(spec.name): \(error)\n".utf8))
+ exit(2)
+ }
+
+ let application = NSApplication.shared
+ let delegate = DemoAppDelegate(spec: spec, options: options) { context in
+ AnyView(content(context))
+ }
+ application.delegate = delegate
+ application.setActivationPolicy(.regular)
+ application.run()
+ // `run()` does not return; this keeps the delegate alive for the compiler.
+ withExtendedLifetime(delegate) {}
+ exit(0)
+ }
+
+}
+
+// MARK: - DemoAppDelegate
+
+@MainActor
+final class DemoAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
+
+ // MARK: Lifecycle
+
+ init(
+ spec: DemoAppSpec,
+ options: DemoLaunchOptions,
+ content: @escaping (DemoRenderContext) -> AnyView
+ ) {
+ self.spec = spec
+ self.options = options
+ self.content = content
+ }
+
+ // MARK: Internal
+
+ func applicationDidFinishLaunching(_: Notification) {
+ buildMenu()
+ let count = options.windowCount ?? spec.defaultWindowCount
+ for _ in 0.. Bool { true }
+
+ /// Clicking the Dock icon of an app whose windows Tatami hid must not spawn
+ /// an extra window — that would change the window count mid-take.
+ func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows: Bool) -> Bool {
+ if !hasVisibleWindows, windows.isEmpty { openWindow(nil) }
+ return true
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ guard let window = notification.object as? NSWindow else { return }
+ windows.removeAll { $0 === window }
+ // Ordinals are never reused, so a window opened after a close keeps a
+ // distinct title instead of colliding with the one that just went away.
+ }
+
+ /// Opens or closes windows until exactly `wanted` are on screen. Closing takes
+ /// the most recently opened first, so the windows that remain keep the
+ /// ordinals, titles and fixtures they already had.
+ func setWindowCount(_ wanted: Int) {
+ while windows.count > wanted, let last = windows.last {
+ last.close()
+ windows.removeAll { $0 === last }
+ }
+ while windows.count < wanted { openWindow(nil) }
+ }
+
+ @objc
+ func openWindow(_: Any?) {
+ let ordinal = nextOrdinal
+ nextOrdinal += 1
+ // Title and content read one index, so `--variant` shifts both. Split, a
+ // window is titled after one fixture and renders another, and Tatami's
+ // switcher and HUD show that title on camera.
+ let contentIndex = options.variant + ordinal
+
+ let window = NSWindow(
+ contentRect: CGRect(origin: .zero, size: spec.windowSize),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = spec.windowTitle(at: contentIndex)
+ // The tiler dictates every frame these windows ever have, so the SwiftUI
+ // content gets no vote in their size. `sizingOptions` defaults to
+ // `.standardBounds`, which pushes the content's minimum onto the window's
+ // `contentMinSize`; AppKit then clamps the tiler's resize to it while the
+ // accessibility write still reports success, so a refused tile reaches the
+ // recording as a wrong frame with no error on either side.
+ let hosting = NSHostingView(
+ rootView: content(DemoRenderContext(ordinal: contentIndex))
+ )
+ hosting.sizingOptions = []
+ window.contentView = hosting
+ window.isReleasedWhenClosed = false
+ // Native tabs make a tiling window manager see one AX window where the user
+ // sees several; Tatami has a documented regression class around them.
+ window.tabbingMode = .disallowed
+ window.delegate = self
+ requireAnyFrameIsAccepted(window, ordinal: ordinal)
+ window.setFrame(frame(for: ordinal, of: window), display: true)
+ // Stable AX identity so a driver can address "Editor window 2" without
+ // depending on the localized title.
+ window.setAccessibilityIdentifier("\(spec.bundleIdentifier).window.\(ordinal)")
+ window.makeKeyAndOrderFront(nil)
+ windows.append(window)
+ }
+
+ // MARK: Private
+
+ let spec: DemoAppSpec
+ var control: DemoControlServer?
+
+ /// Windows currently on screen. A scene can grow or shrink this to film a
+ /// window being opened or closed, which is what the tiler reacts to.
+ var windowCount: Int { windows.count }
+
+ private let options: DemoLaunchOptions
+ private let content: (DemoRenderContext) -> AnyView
+
+ /// The smallest content size a take can ask a window for. A four-way split
+ /// of the 1920x1200 recording display is 286pt wide and `resize shrink` steps
+ /// go under that, so anything a window refuses below this lands on camera.
+ private static let smallestTile = CGSize(width: 200, height: 120)
+
+ private var windows = [NSWindow]()
+ /// Monotonic, so titles and AX identifiers stay unique for the whole session.
+ private var nextOrdinal = 0
+
+ /// Proves, before the window is ever on screen, that AppKit hands out any
+ /// size the tiler asks for.
+ ///
+ /// A clamped resize is silent on both sides: AppKit stops at the window's
+ /// `contentMinSize`, `AXUIElementSetAttributeValue` still returns success, and
+ /// the only trace is a tile overlapping its neighbour in the recording.
+ /// Quitting is the loudest signal a demo app has, and it beats shipping a take
+ /// whose frames differ from the last one for no visible reason.
+ private func requireAnyFrameIsAccepted(_ window: NSWindow, ordinal: Int) {
+ // A minimum pushed by SwiftUI content only exists after a layout pass.
+ window.layoutIfNeeded()
+ window.setContentSize(Self.smallestTile)
+ let granted = window.contentRect(forFrameRect: window.frame).size
+ let honored = abs(granted.width - Self.smallestTile.width) < 0.5
+ && abs(granted.height - Self.smallestTile.height) < 0.5
+ guard !honored else { return }
+
+ func describe(_ value: CGSize) -> String { String(format: "%.0fx%.0f", value.width, value.height) }
+ let message = "\(spec.name): window \(ordinal) refused a \(describe(Self.smallestTile)) content size and "
+ + "took \(describe(granted)) instead (contentMinSize \(describe(window.contentMinSize))). "
+ + "A window that clamps the tiler records a wrong frame with no error anywhere."
+ FileHandle.standardError.write(Data("\(message)\n".utf8))
+ exit(3)
+ }
+
+ /// Deterministic placement: anchored to the *primary* screen's visible frame
+ /// with a fixed step, never to a running window count (which drifts as windows
+ /// open and close during a take). Tatami retiles managed windows immediately;
+ /// this is what a **Leave As Is** window keeps.
+ ///
+ /// Deliberately not `NSScreen.main`: that is the screen holding the key
+ /// window, so it follows whichever display the app that was active *before*
+ /// the launch happened to be on, and two takes on one machine can then put the
+ /// same untouched window on different displays.
+ private func frame(for ordinal: Int, of window: NSWindow) -> CGRect {
+ if let explicit = options.frame { return explicit }
+ // `screens[0]` is the menu-bar screen; testing the origin pins the choice to
+ // the display at (0, 0) whatever order the array comes back in.
+ let primary = NSScreen.screens.first { $0.frame.origin == .zero } ?? NSScreen.screens.first
+ let visible = primary?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1512, height: 945)
+ let step = CGFloat(ordinal % 4) * 36
+ // `spec.windowSize` is a content size, which is how the window is created
+ // above, and `setFrame` takes a frame rect. Without adding the title bar
+ // back, every window's content is a title bar shorter than the catalog says.
+ let size = window.frameRect(forContentRect: CGRect(origin: .zero, size: spec.windowSize)).size
+ let utility = spec.id == .monitor || spec.id == .notes
+ let x = utility ? visible.maxX - size.width - 32 : visible.minX + 80 + step
+ let y = utility ? visible.minY + 40 : visible.maxY - size.height - 60 - step
+ return CGRect(x: x, y: max(visible.minY, y), width: size.width, height: size.height)
+ }
+
+ private func buildMenu() {
+ let mainMenu = NSMenu()
+
+ let appItem = NSMenuItem()
+ mainMenu.addItem(appItem)
+ let appMenu = NSMenu()
+ appItem.submenu = appMenu
+ appMenu.addItem(
+ withTitle: String(localized: "Quit \(spec.name)"),
+ action: #selector(NSApplication.terminate(_:)),
+ keyEquivalent: "q"
+ )
+
+ let editItem = NSMenuItem()
+ mainMenu.addItem(editItem)
+ let editMenu = NSMenu(title: String(localized: "Edit"))
+ editItem.submenu = editMenu
+ let editActions: [(LocalizedStringResource, String, String)] = [("Undo", "undo:", "z"), ("Cut", "cut:", "x"),
+ ("Copy", "copy:", "c"), ("Paste", "paste:", "v"), ("Select All", "selectAll:", "a")]
+ for (title, selector, key) in editActions {
+ editMenu.addItem(withTitle: String(localized: title), action: Selector(selector), keyEquivalent: key)
+ }
+
+ let windowItem = NSMenuItem()
+ mainMenu.addItem(windowItem)
+ let windowMenu = NSMenu(title: String(localized: "Window"))
+ windowItem.submenu = windowMenu
+
+ let newItem = NSMenuItem(
+ title: String(localized: "New Window"),
+ action: #selector(openWindow(_:)),
+ keyEquivalent: "n"
+ )
+ newItem.target = self
+ windowMenu.addItem(newItem)
+ windowMenu.addItem(
+ withTitle: String(localized: "Close"),
+ action: #selector(NSWindow.performClose(_:)),
+ keyEquivalent: "w"
+ )
+ windowMenu.addItem(
+ withTitle: String(localized: "Minimize"),
+ action: #selector(NSWindow.performMiniaturize(_:)),
+ keyEquivalent: "m"
+ )
+ windowMenu.addItem(
+ withTitle: String(localized: "Zoom"),
+ action: #selector(NSWindow.performZoom(_:)),
+ keyEquivalent: ""
+ )
+ // Exercises the native-fullscreen path Tatami has to detect and step out of.
+ let fullscreenItem = NSMenuItem(title: String(localized: "Enter Full Screen"), action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
+ fullscreenItem.keyEquivalentModifierMask = [.control, .command]
+ windowMenu.addItem(fullscreenItem)
+ NSApp.windowsMenu = windowMenu
+
+ NSApp.mainMenu = mainMenu
+ }
+
+}
+
+// MARK: - DemoRenderContext
+
+/// Stable window identity. Editable content is owned by each app's views and
+/// shared saved work lives in StoryRepository.
+public struct DemoRenderContext: Equatable, Sendable {
+ public init(ordinal: Int) { self.ordinal = ordinal }
+ public let ordinal: Int
+}
+
+// MARK: - Control channel
+
+extension DemoAppDelegate {
+
+ /// Commands a scene can send. Every one is absolute, and every one is
+ /// acknowledged, so the scene runner waits on a real answer rather than a
+ /// sleep. See ``DemoControl``.
+ func startControlServer() {
+ let server = DemoControlServer(bundleIdentifier: spec.bundleIdentifier) { [weak self] request in
+ guard let self else { return "err gone" }
+ return handle(request)
+ }
+ server.start()
+ control = server
+ }
+
+ private func handle(_ request: DemoControlRequest) -> String {
+ switch request.verb {
+ case "ping":
+ return "ok \(spec.name) windows=\(windowCount)"
+
+ case "windows":
+ guard let wanted = request.integerArgument, wanted >= 0, wanted <= 8 else {
+ return "err windows must be 0...8"
+ }
+ setWindowCount(wanted)
+ return "ok windows=\(windowCount)"
+
+ case "quit":
+ // Reply first: the caller is waiting on this socket, and terminating
+ // inside the handler would drop the acknowledgement.
+ DispatchQueue.main.async { NSApp.terminate(nil) }
+ return "ok quitting"
+
+ default:
+ return "err unknown command \"\(request.verb)\""
+ }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoAppKit/DemoCatalog.swift b/DemoLab/Sources/DemoAppKit/DemoCatalog.swift
new file mode 100644
index 0000000..402533d
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/DemoCatalog.swift
@@ -0,0 +1,256 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - DemoAppID
+
+/// Every app the Demo Lab ships. The raw value is the executable *and* bundle
+/// name, so one identifier drives the SwiftPM target, the `.app` bundle, the
+/// bundle identifier, and the Tatami config.
+///
+/// The names say what each app *is*, not what it is branded. A viewer reads a
+/// window title and the menu-bar workspace name in about a second, and
+/// "Terminal" costs them none of it while an invented product name costs them
+/// the whole second. Each name also matches the workspace it belongs to, so the
+/// menu bar and the screen say the same word.
+public enum DemoAppID: String, CaseIterable, Sendable, Codable {
+ case canvas = "Canvas"
+ case editor = "Editor"
+ case terminal = "Terminal"
+ case review = "Review"
+ case docs = "Docs"
+ case chat = "Chat"
+ case notes = "Notes"
+ case monitor = "Monitor"
+}
+
+// MARK: - DemoAppSpec
+
+/// The single source of truth for one demo app. `democtl`, the bundler, the
+/// icon renderer, and the Tatami config template all read this, so a new app
+/// is added in exactly one place.
+public struct DemoAppSpec: Sendable, Equatable {
+
+ // MARK: Lifecycle
+
+ public init(
+ id: DemoAppID,
+ role: String,
+ symbolName: String,
+ accent: DemoAccent,
+ defaultWindowCount: Int,
+ windowSize: CGSize,
+ windowTitles: [LocalizedStringResource],
+ categoryType: String
+ ) {
+ self.id = id
+ self.role = role
+ self.symbolName = symbolName
+ self.accent = accent
+ self.defaultWindowCount = defaultWindowCount
+ self.windowSize = windowSize
+ self.windowTitles = windowTitles
+ self.categoryType = categoryType
+ }
+
+ // MARK: Public
+
+ public let id: DemoAppID
+ /// One line describing what the app stands in for, used in `--help` and docs.
+ public let role: String
+ public let symbolName: String
+ public let accent: DemoAccent
+ /// Windows opened at launch when no `--windows` override is given. Fixed so
+ /// every take starts with the same window count.
+ public let defaultWindowCount: Int
+ /// The **content** size, excluding the title bar. ``DemoAppHost`` converts it
+ /// to a frame rect before placing the window.
+ public let windowSize: CGSize
+ /// One title per content variant, in the same order the app's view picks its
+ /// fixtures, and never a date, a counter that survives across launches, or
+ /// anything else that drifts between takes. A title array shorter than the
+ /// view's fixture list would name a window after something it is not showing.
+ public let windowTitles: [LocalizedStringResource]
+ /// LaunchServices category used by the app bundler.
+ public let categoryType: String
+
+ public var name: String { id.rawValue }
+
+ /// Deliberately **not** under `dev.PangMo5.Tatami.` — `MacApp.isTatami`
+ /// treats that exact prefix as Tatami itself, and a demo app must never be
+ /// mistaken for the window manager driving it.
+ public var bundleIdentifier: String { "dev.PangMo5.DemoLab.\(id.rawValue)" }
+
+
+ public func windowTitle(at index: Int) -> String {
+ guard !windowTitles.isEmpty else { return name }
+ // The same wrap every view uses to pick its fixture (`index % fixtures`),
+ // so a title always names what its window is actually showing.
+ let base = String(localized: windowTitles[index % windowTitles.count])
+ guard index >= windowTitles.count else { return base }
+ // Extra windows opened live on camera stay identifiable and stable: the
+ // suffix keeps them distinct from the titles they wrap onto.
+ return "\(base) \(index + 1)"
+ }
+
+}
+
+// MARK: - DemoAccent
+
+/// A small, fixed palette. Values are sRGB components so the icon renderer and
+/// the SwiftUI views agree without a shared asset catalog.
+public struct DemoAccent: Sendable, Equatable {
+
+ // MARK: Lifecycle
+
+ public init(red: Double, green: Double, blue: Double) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ // MARK: Public
+
+ public static let blue = DemoAccent(red: 0.20, green: 0.47, blue: 0.96)
+ public static let purple = DemoAccent(red: 0.51, green: 0.36, blue: 0.85)
+ public static let teal = DemoAccent(red: 0.13, green: 0.55, blue: 0.58)
+ public static let indigo = DemoAccent(red: 0.35, green: 0.40, blue: 0.78)
+ public static let green = DemoAccent(red: 0.18, green: 0.55, blue: 0.34)
+ public static let orange = DemoAccent(red: 0.85, green: 0.48, blue: 0.13)
+ public static let graphite = DemoAccent(red: 0.36, green: 0.40, blue: 0.45)
+ /// Selection fill for source lists.
+ ///
+ /// Deliberately a fixed color rather than `Color.accentColor`: the system
+ /// accent is a per-machine preference, so a selected row would be pink on one
+ /// recording machine and blue on the next. This is macOS's own default accent
+ /// blue, so the apps still look stock while every take matches.
+ public static let selection = DemoAccent(red: 0.00, green: 0.48, blue: 1.00)
+
+ public let red: Double
+ public let green: Double
+ public let blue: Double
+
+}
+
+// MARK: - DemoCatalog
+
+public enum DemoCatalog {
+
+ // MARK: Public
+
+ public static let all: [DemoAppSpec] = [
+ DemoAppSpec(id: .canvas, role: "Design preview of the saved draft", symbolName: "square.stack.3d.up", accent: .purple,
+ defaultWindowCount: 1, windowSize: CGSize(width: 1100, height: 800), windowTitles: ["Launch page — canvas", "Launch page — alternate"], categoryType: "public.app-category.graphics-design"),
+ DemoAppSpec(
+ id: .editor,
+ role: "Draft editor: edit and save the launch page",
+ symbolName: "doc.text",
+ accent: .blue,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 980, height: 640),
+ windowTitles: ["Launch page", "Launch page — alternate"],
+ categoryType: "public.app-category.developer-tools"
+ ),
+ DemoAppSpec(
+ id: .terminal,
+ role: "Terminal: run checks against the saved draft",
+ symbolName: "terminal",
+ accent: .graphite,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 860, height: 520),
+ windowTitles: ["Launch checks", "Launch checks — second session"],
+ categoryType: "public.app-category.developer-tools"
+ ),
+ DemoAppSpec(
+ id: .review,
+ role: "Copy review: validate the saved draft and approve it",
+ symbolName: "checkmark.bubble",
+ accent: .purple,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 1100, height: 700),
+ windowTitles: ["Launch copy — review", "Launch copy — second review"],
+ categoryType: "public.app-category.developer-tools"
+ ),
+ DemoAppSpec(
+ id: .docs,
+ role: "Documentation and reference",
+ symbolName: "book.closed",
+ accent: .teal,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 900, height: 660),
+ windowTitles: ["Launch brief", "Content direction"],
+ categoryType: "public.app-category.reference"
+ ),
+ DemoAppSpec(
+ id: .chat,
+ role: "Team conversation",
+ symbolName: "bubble.left.and.bubble.right",
+ accent: .indigo,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 960, height: 660),
+ windowTitles: ["#launch", "#team"],
+ categoryType: "public.app-category.social-networking"
+ ),
+ DemoAppSpec(
+ id: .notes,
+ role: "Checklist: capture and complete the next small task",
+ symbolName: "square.and.pencil",
+ accent: .orange,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 560, height: 620),
+ windowTitles: ["Launch checklist"],
+ categoryType: "public.app-category.productivity"
+ ),
+ DemoAppSpec(
+ id: .monitor,
+ role: "Small utility: the natural Always on Top and Leave As Is subject",
+ symbolName: "waveform.path.ecg",
+ accent: .green,
+ defaultWindowCount: 1,
+ windowSize: CGSize(width: 400, height: 310),
+ windowTitles: ["Launch status"],
+ categoryType: "public.app-category.developer-tools"
+ ),
+ ]
+
+ /// The caption and keystroke overlay. Not a demo app: it is never assigned to
+ /// a workspace and never tiled, so it stays out of ``all`` and is registered in
+ /// Tatami's `settings.visibility.overlayAwareApps` instead.
+ public static let overlayBundleIdentifier = "dev.PangMo5.DemoLab.Overlay"
+
+ public struct BundleSpecification: Codable, Sendable {
+ public let name: String
+ public let bundleIdentifier: String
+ public let categoryType: String
+ public let agent: Bool
+ public let captureUsageDescription: String?
+ }
+
+ public static var bundles: [BundleSpecification] {
+ all.map {
+ BundleSpecification(name: $0.name, bundleIdentifier: $0.bundleIdentifier,
+ categoryType: $0.categoryType, agent: false, captureUsageDescription: nil)
+ } + [
+ BundleSpecification(name: "DemoRecorder", bundleIdentifier: "dev.PangMo5.DemoLab.DemoRecorder",
+ categoryType: "public.app-category.video", agent: true,
+ captureUsageDescription: "Demo Lab records the screen to produce Tatami demo videos."),
+ BundleSpecification(name: "DemoOverlay", bundleIdentifier: overlayBundleIdentifier,
+ categoryType: "public.app-category.utilities", agent: true, captureUsageDescription: nil),
+ ]
+ }
+
+ public static func spec(_ id: DemoAppID) -> DemoAppSpec {
+ // Every case is present in `all`; a missing one is a programming error we
+ // want to see immediately rather than paper over with a default app.
+ guard let match = all.first(where: { $0.id == id }) else {
+ fatalError("DemoCatalog is missing a spec for \(id.rawValue)")
+ }
+ return match
+ }
+
+ public static func spec(named name: String) -> DemoAppSpec? {
+ all.first { $0.name.caseInsensitiveCompare(name) == .orderedSame }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoAppKit/DemoChrome.swift b/DemoLab/Sources/DemoAppKit/DemoChrome.swift
new file mode 100644
index 0000000..1ad0281
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/DemoChrome.swift
@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import SwiftUI
+
+// MARK: - Accent bridging
+
+extension Color {
+ public init(_ accent: DemoAccent) {
+ self.init(.sRGB, red: accent.red, green: accent.green, blue: accent.blue, opacity: 1)
+ }
+}
+
+// MARK: - DemoWindow
+
+/// The standard three-band layout every demo app uses: a toolbar, the content,
+/// and an optional status bar.
+///
+/// The look is deliberately plain — system materials, semantic colors, system
+/// typography, SF Symbols. No gradients, no glass, no marketing flourishes:
+/// these windows have to read as ordinary macOS productivity apps in a frame
+/// that is on screen for one second.
+public struct DemoWindow: View {
+
+ // MARK: Lifecycle
+
+ public init(
+ @ViewBuilder toolbar: () -> Toolbar,
+ @ViewBuilder content: () -> Content,
+ @ViewBuilder status: () -> Status
+ ) {
+ self.toolbar = toolbar()
+ self.content = content()
+ self.status = status()
+ }
+
+ // MARK: Public
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 12) { toolbar }
+ .font(.system(size: 12))
+ .padding(.horizontal, 14)
+ .frame(height: 40)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.bar)
+ Divider()
+ content
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ if !(status is EmptyView) {
+ Divider()
+ HStack(spacing: 14) { status }
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 14)
+ .frame(height: 26)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.bar)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(nsColor: .windowBackgroundColor))
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("demolab.window.content")
+ }
+
+ // MARK: Private
+
+ private let toolbar: Toolbar
+ private let content: Content
+ private let status: Status
+
+}
+
+extension DemoWindow where Status == EmptyView {
+ public init(
+ @ViewBuilder toolbar: () -> Toolbar,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.init(toolbar: toolbar, content: content, status: { EmptyView() })
+ }
+}
+
+// MARK: - DemoTitle
+
+/// Toolbar title with the app's symbol, matching the window title.
+public struct DemoTitle: View {
+
+ // MARK: Lifecycle
+
+ public init(_ text: LocalizedStringResource, symbol: String, accent: DemoAccent, subtitle: LocalizedStringResource? = nil) {
+ self.text = text
+ self.symbol = symbol
+ self.accent = accent
+ self.subtitle = subtitle
+ }
+
+ // MARK: Public
+
+ public var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: symbol)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(Color(accent))
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(text)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.primary)
+ if let subtitle {
+ Text(subtitle)
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .accessibilityElement(children: .combine)
+ }
+
+ // MARK: Private
+
+ private let text: LocalizedStringResource
+ private let symbol: String
+ private let accent: DemoAccent
+ private let subtitle: LocalizedStringResource?
+
+}
diff --git a/DemoLab/Sources/DemoAppKit/DemoControl.swift b/DemoLab/Sources/DemoAppKit/DemoControl.swift
new file mode 100644
index 0000000..bf397de
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/DemoControl.swift
@@ -0,0 +1,260 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Darwin
+import Foundation
+
+// MARK: - DemoControl
+
+/// Where a demo app listens for commands, and where `democtl` sends them.
+///
+/// The control channel owns app lifetime, activation and window count. Editable
+/// work is driven through native controls and persisted by StoryRepository;
+/// there is no remote command for jumping a view to a staged content state.
+public enum DemoControl {
+
+ // MARK: Public
+
+ /// Directory holding one socket per running demo app.
+ ///
+ /// `DEMOLAB_CONTROL_DIR` overrides it so a lab run can keep its sockets with
+ /// the rest of its state; the default keeps them out of the way when an app is
+ /// opened by hand. A UNIX socket path has a hard `sun_path` limit, so both are
+ /// kept short.
+ public static var directory: URL {
+ if let override = ProcessInfo.processInfo.environment["DEMOLAB_CONTROL_DIR"], !override.isEmpty {
+ return URL(fileURLWithPath: override, isDirectory: true)
+ }
+ return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ .appendingPathComponent("demolab", isDirectory: true)
+ }
+
+ public static func socket(for bundleIdentifier: String) -> URL {
+ // The last identifier component keeps the path well inside `sun_path`.
+ let leaf = bundleIdentifier.split(separator: ".").last.map(String.init) ?? bundleIdentifier
+ return directory.appendingPathComponent("\(leaf).sock")
+ }
+
+ public static func ensureDirectory() throws {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+
+}
+
+// MARK: - DemoControlServer
+
+/// A one-line-per-request UNIX socket server.
+///
+/// Deliberately tiny and synchronous: one connection at a time, one line in, one
+/// line out. A demo app is never under load, and a simple accept loop cannot
+/// reorder or coalesce commands the way a best-effort notification can.
+public final class DemoControlServer: @unchecked Sendable {
+
+ // MARK: Lifecycle
+
+ /// - Parameter handler: runs on the main actor and returns the reply line.
+ public init(bundleIdentifier: String, handler: @escaping @MainActor (DemoControlRequest) -> String) {
+ self.bundleIdentifier = bundleIdentifier
+ self.handler = handler
+ }
+
+ // MARK: Public
+
+ public let bundleIdentifier: String
+
+ /// Binds and starts accepting. A failure here is reported and then ignored:
+ /// an app that cannot be scripted is still a perfectly good window to tile,
+ /// and refusing to launch would break every scene that only needs the window.
+ public func start() {
+ do {
+ try bind()
+ } catch {
+ FileHandle.standardError.write(
+ Data("\(bundleIdentifier): control channel unavailable: \(error)\n".utf8)
+ )
+ return
+ }
+ let thread = Thread { [weak self] in self?.acceptLoop() }
+ thread.name = "dev.PangMo5.DemoLab.control"
+ thread.start()
+ }
+
+ public func stop() {
+ lock.lock()
+ let fd = listener
+ listener = -1
+ lock.unlock()
+ if fd >= 0 { close(fd) }
+ try? FileManager.default.removeItem(at: DemoControl.socket(for: bundleIdentifier))
+ }
+
+ // MARK: Private
+
+ private let handler: @MainActor (DemoControlRequest) -> String
+ private let lock = NSLock()
+ private var listener: Int32 = -1
+
+ private func bind() throws {
+ try DemoControl.ensureDirectory()
+ let url = DemoControl.socket(for: bundleIdentifier)
+ // A socket left behind by a crashed run would make bind() fail with EADDRINUSE.
+ try? FileManager.default.removeItem(at: url)
+
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard fd >= 0 else { throw DemoControlError.socketFailed(errno) }
+
+ var address = sockaddr_un()
+ address.sun_family = sa_family_t(AF_UNIX)
+ let path = url.path
+ let capacity = MemoryLayout.size(ofValue: address.sun_path)
+ guard path.utf8.count < capacity else {
+ close(fd)
+ throw DemoControlError.pathTooLong(path)
+ }
+ _ = withUnsafeMutablePointer(to: &address.sun_path) { pointer in
+ path.withCString { source in
+ strncpy(UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self), source, capacity - 1)
+ }
+ }
+ let size = socklen_t(MemoryLayout.size)
+ let bound = withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, size) }
+ }
+ guard bound == 0 else {
+ close(fd)
+ throw DemoControlError.bindFailed(errno, path)
+ }
+ guard Darwin.listen(fd, 8) == 0 else {
+ close(fd)
+ throw DemoControlError.listenFailed(errno)
+ }
+ lock.lock()
+ listener = fd
+ lock.unlock()
+ }
+
+ private func acceptLoop() {
+ while true {
+ lock.lock()
+ let fd = listener
+ lock.unlock()
+ guard fd >= 0 else { return }
+
+ let client = Darwin.accept(fd, nil, nil)
+ guard client >= 0 else {
+ if errno == EINTR { continue }
+ return
+ }
+ defer { close(client) }
+
+ guard let line = Self.readLine(from: client) else { continue }
+ let request = DemoControlRequest(line: line)
+ // Every command touches window or view state, so it has to land on the
+ // main actor; the reply is only sent once it actually has.
+ let reply = DispatchQueue.main.sync { MainActor.assumeIsolated { handler(request) } }
+ _ = (reply + "\n").withCString { Darwin.send(client, $0, strlen($0), 0) }
+ }
+ }
+
+ private static func readLine(from fd: Int32) -> String? {
+ var buffer = [UInt8]()
+ var byte: UInt8 = 0
+ while buffer.count < 4096 {
+ let read = recv(fd, &byte, 1, 0)
+ if read <= 0 { break }
+ if byte == UInt8(ascii: "\n") { break }
+ buffer.append(byte)
+ }
+ guard !buffer.isEmpty else { return nil }
+ return String(decoding: buffer, as: UTF8.self)
+ }
+
+}
+
+// MARK: - DemoControlRequest
+
+public struct DemoControlRequest: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(line: String) {
+ let fields = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
+ verb = fields.first.map { String($0).lowercased() } ?? ""
+ argument = fields.count > 1 ? String(fields[1]) : ""
+ }
+
+ // MARK: Public
+
+ public let verb: String
+ /// Everything after the verb, unsplit, so a caption can contain spaces.
+ public let argument: String
+
+ public var integerArgument: Int? { Int(argument.trimmingCharacters(in: .whitespaces)) }
+
+}
+
+// MARK: - DemoControlError
+
+public enum DemoControlError: Error, CustomStringConvertible {
+ case socketFailed(Int32)
+ case bindFailed(Int32, String)
+ case listenFailed(Int32)
+ case pathTooLong(String)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .socketFailed(let code): "socket() failed (errno \(code))"
+ case .bindFailed(let code, let path): "bind(\(path)) failed (errno \(code))"
+ case .listenFailed(let code): "listen() failed (errno \(code))"
+ case .pathTooLong(let path): "socket path is too long for sun_path: \(path)"
+ }
+ }
+}
+
+// MARK: - DemoControlClient
+
+/// The sending half, used by `democtl`.
+public enum DemoControlClient {
+
+ // MARK: Public
+
+ public static func isListening(bundleIdentifier: String) -> Bool {
+ (try? send("ping", to: bundleIdentifier)) != nil
+ }
+
+ @discardableResult
+ public static func send(_ line: String, to bundleIdentifier: String) throws -> String {
+ let url = DemoControl.socket(for: bundleIdentifier)
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard fd >= 0 else { throw DemoControlError.socketFailed(errno) }
+ defer { close(fd) }
+
+ var address = sockaddr_un()
+ address.sun_family = sa_family_t(AF_UNIX)
+ let path = url.path
+ let capacity = MemoryLayout.size(ofValue: address.sun_path)
+ guard path.utf8.count < capacity else { throw DemoControlError.pathTooLong(path) }
+ _ = withUnsafeMutablePointer(to: &address.sun_path) { pointer in
+ path.withCString { source in
+ strncpy(UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self), source, capacity - 1)
+ }
+ }
+ let size = socklen_t(MemoryLayout.size)
+ let connected = withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, size) }
+ }
+ guard connected == 0 else { throw DemoControlError.bindFailed(errno, path) }
+
+ _ = (line + "\n").withCString { Darwin.send(fd, $0, strlen($0), 0) }
+
+ var buffer = [UInt8](repeating: 0, count: 1024)
+ let read = recv(fd, &buffer, buffer.count, 0)
+ guard read > 0 else { return "" }
+ return String(decoding: buffer[0.. LocalizedStringResource {
+ switch value {
+ case "cobalt": "Cobalt"
+ case "sand": "Sand"
+ case "forest": "Forest"
+ default: preconditionFailure("Unknown demo theme: \(value)")
+ }
+ }
+}
+
+extension DemoLocale {
+ /// The apps own their main-bundle catalog. The plain capture controller uses
+ /// the same compiled catalog at an explicit path when it seeds fixture data.
+ public func string(_ resource: LocalizedStringResource) -> String {
+ var resource = resource
+ resource.locale = Locale(identifier: rawValue)
+ let base = ProcessInfo.processInfo.environment["DEMOLAB_LOCALIZATION_DIR"].map(URL.init(fileURLWithPath:))
+ ?? Bundle.main.resourceURL!
+ let directory = base.appendingPathComponent(rawValue + ".lproj")
+ if FileManager.default.fileExists(atPath: directory.path) {
+ guard let bundle = Bundle(url: directory) else { preconditionFailure("Invalid localization bundle") }
+ return String(localized: resource.defaultValue, table: resource.table, bundle: bundle, locale: resource.locale)
+ } else {
+ precondition(self == .en, "Missing compiled demo localization: \(directory.path)")
+ }
+ return String(localized: resource)
+ }
+}
diff --git a/DemoLab/Sources/DemoAppKit/KeyChordFormatter.swift b/DemoLab/Sources/DemoAppKit/KeyChordFormatter.swift
new file mode 100644
index 0000000..c98fbde
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/KeyChordFormatter.swift
@@ -0,0 +1,82 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - KeyChordFormatter
+
+/// Turns a Tatami shortcut string into the keycaps a viewer recognises.
+///
+/// Scene files carry shortcuts in Tatami's own skhd spelling, the same text that
+/// is in `config.toml`, so a scene never keeps a prettified copy in sync with
+/// the binding it is demonstrating. Two very different renderers then have to
+/// agree on what that text looks like: the live overlay draws each cap as a
+/// rounded key, and the ASS sidecar writes them as one run of glyphs. They share
+/// this so a burned take and a live take show the same thing. When they did not,
+/// the sidecar printed a literal `ctrl + alt - l` under the overlay's `⌃⌥L`.
+public enum KeyChordFormatter {
+
+ // MARK: Public
+
+ /// One string per cap, in canonical macOS modifier order.
+ public static func caps(from chord: String) -> [String] {
+ let text = chord.lowercased().trimmingCharacters(in: .whitespaces)
+ guard !text.isEmpty else { return [] }
+
+ var modifiers = [String]()
+ var key = text
+ // The separator is searched from the END, exactly as Tatami's parser does,
+ // so a literal `-` key ("ctrl + alt - -") still resolves.
+ if let separator = text.range(of: " - ", options: .backwards) {
+ modifiers = text[text.startIndex.. String {
+ caps(from: chord).joined(separator: separator)
+ }
+
+ // MARK: Private
+
+ /// Canonical macOS order, so the caps read the way the keyboard is laid out
+ /// rather than the order the config happened to list them in.
+ private static let order = ["ctrl", "alt", "shift", "cmd"]
+
+ private static let modifierSymbols: [String: String] = [
+ "ctrl": "⌃", "alt": "⌥", "shift": "⇧", "cmd": "⌘",
+ ]
+
+ private static let keySymbols: [String: String] = [
+ "return": "↩", "enter": "↩", "tab": "⇥", "space": "space", "delete": "⌫",
+ "forwarddelete": "⌦", "escape": "⎋", "esc": "⎋",
+ "left": "←", "right": "→", "up": "↑", "down": "↓",
+ "pageup": "⇞", "pagedown": "⇟", "home": "↖", "end": "↘",
+ ]
+
+ private static func canonical(_ token: String) -> String {
+ switch token {
+ case "control": "ctrl"
+ case "opt", "option": "alt"
+ case "command": "cmd"
+ case "⌃": "ctrl"
+ case "⌥": "alt"
+ case "⇧": "shift"
+ case "⌘": "cmd"
+ default: token
+ }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoAppKit/Story.swift b/DemoLab/Sources/DemoAppKit/Story.swift
new file mode 100644
index 0000000..d98fc3f
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/Story.swift
@@ -0,0 +1,99 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import Foundation
+import Observation
+
+public struct StoryTask: Codable, Identifiable, Equatable, Sendable {
+ public let id: Int
+ public var text: String
+ public var done: Bool
+ public init(id: Int, text: String, done: Bool) { self.id = id; self.text = text; self.done = done }
+}
+public struct StoryMessage: Codable, Identifiable, Equatable, Sendable {
+ public let id: Int
+ public init(id: Int, author: String, text: String) { self.id = id; self.author = author; self.text = text }
+ public let author: String
+ public let text: String
+}
+public struct CopyCheck: Codable, Identifiable, Equatable, Sendable {
+ public let id: String
+ public let title: String
+ public let passed: Bool
+}
+public struct LaunchStory: Codable, Equatable, Sendable {
+ public let locale: DemoLocale
+ public var headline: String
+ public var body: String
+ public var designTheme = "cobalt"
+ public var exported = false
+ public var revision = 1
+ public var approved = false
+ public var reviewComment = ""
+ public var checks: [CopyCheck] = []
+ public var tasks: [StoryTask]
+ public var messages: [StoryMessage]
+
+ public init(locale: DemoLocale = .selected) {
+ self.locale = locale
+ headline = locale.string("A better way to manage all the windows on your desktop.")
+ body = locale.string("Give every task its own workspace. Bring the apps you need together, keep their layout, and return to your work without rearranging windows.")
+ tasks = [StoryTask(id: 1, text: locale.string("Review the launch copy"), done: false),
+ StoryTask(id: 2, text: locale.string("Add a product screenshot"), done: false)]
+ messages = [StoryMessage(id: 1, author: "Mina", text: locale.string("The launch brief is ready. Can you make the headline a little shorter?")),
+ StoryMessage(id: 2, author: "You", text: locale.string("On it. I’ll send the updated copy here.")),
+ StoryMessage(id: 3, author: "Mina", text: locale.string("Thanks! Let’s keep it calm and easy to understand."))]
+ }
+
+ public func evaluateCopy() -> [CopyCheck] {
+ [CopyCheck(id: "headline", title: locale.string("Headline is 1–40 characters"), passed: (1...40).contains(headline.count)),
+ CopyCheck(id: "body", title: locale.string("Description explains the workflow"), passed: body.count >= 40),
+ CopyCheck(id: "workspace", title: locale.string("The main idea is workspaces"), passed: body.localizedCaseInsensitiveContains(locale.workspaceTerm))]
+ }
+}
+
+public struct StoryRepository: Sendable {
+ public let file: URL
+ public init(file: URL = DemoControl.directory.appendingPathComponent("launch-story.json")) { self.file = file }
+ public func load() throws -> LaunchStory {
+ guard FileManager.default.fileExists(atPath: file.path) else { return LaunchStory() }
+ return try JSONDecoder().decode(LaunchStory.self, from: Data(contentsOf: file))
+ }
+ public func write(_ story: LaunchStory) throws {
+ try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ try encoder.encode(story).write(to: file, options: .atomic)
+ DistributedNotificationCenter.default().postNotificationName(Self.changed, object: nil, userInfo: nil, deliverImmediately: true)
+ }
+ public static let changed = Notification.Name("dev.PangMo5.DemoLab.storyChanged")
+}
+
+/// Shared persisted work, observed across the eight independent app processes.
+@MainActor @Observable
+public final class StorySession {
+ public var story = LaunchStory()
+ public var error = ""
+ @ObservationIgnored private var observation: NSObjectProtocol?
+ @ObservationIgnored private let repository = StoryRepository()
+ public init() {}
+ public func start() {
+ refresh()
+ guard observation == nil else { return }
+ observation = DistributedNotificationCenter.default().addObserver(forName: StoryRepository.changed, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
+ }
+ }
+ public func refresh() {
+ do { story = try repository.load(); error = "" }
+ catch { self.error = String(describing: error) }
+ }
+ public func update(_ change: (inout LaunchStory) -> Void) {
+ do {
+ var latest = try repository.load()
+ change(&latest)
+ try repository.write(latest)
+ story = latest
+ error = ""
+ } catch { self.error = String(describing: error) }
+ }
+}
diff --git a/DemoLab/Sources/DemoAppKit/StoryChrome.swift b/DemoLab/Sources/DemoAppKit/StoryChrome.swift
new file mode 100644
index 0000000..de20a9f
--- /dev/null
+++ b/DemoLab/Sources/DemoAppKit/StoryChrome.swift
@@ -0,0 +1,45 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import SwiftUI
+
+public struct StoryHeading: View {
+ let eyebrow: LocalizedStringResource
+ let title: LocalizedStringResource
+ let subtitle: LocalizedStringResource?
+ public init(_ eyebrow: LocalizedStringResource, _ title: LocalizedStringResource, _ subtitle: LocalizedStringResource? = nil) {
+ self.eyebrow = eyebrow; self.title = title; self.subtitle = subtitle
+ }
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(eyebrow).font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
+ Text(title).font(.system(size: 30, weight: .bold)).tracking(-0.7)
+ if let subtitle { Text(subtitle).font(.system(size: 15)).foregroundStyle(.secondary).lineSpacing(4) }
+ }.frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+public struct StoryStatus: View {
+ let text: LocalizedStringResource
+ let error: String
+ public init(_ text: LocalizedStringResource, error: String = "") { self.text = text; self.error = error }
+ public var body: some View {
+ Label {
+ if error.isEmpty { Text(text) } else { Text(error) }
+ } icon: { Image(systemName: error.isEmpty ? "checkmark.circle" : "exclamationmark.triangle") }
+ .foregroundStyle(error.isEmpty ? Color.secondary : Color.red).lineLimit(1)
+ }
+}
+
+public struct CopyChecksView: View {
+ let checks: [CopyCheck]
+ public init(_ checks: [CopyCheck]) { self.checks = checks }
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(checks) { check in
+ Label(check.title, systemImage: check.passed ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
+ .foregroundStyle(check.passed ? Color.green : Color.orange)
+ .font(.system(size: 15, weight: .medium))
+ }
+ }.frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/DemoLab/Sources/DemoCtlKit/CaptureGate.swift b/DemoLab/Sources/DemoCtlKit/CaptureGate.swift
new file mode 100644
index 0000000..5920df4
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/CaptureGate.swift
@@ -0,0 +1,133 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import ApplicationServices
+import CoreGraphics
+import DemoAppKit
+import Foundation
+
+/// Recording acceptance checks use visible windows, not a launch acknowledgement.
+@MainActor
+public enum CaptureGate {
+ static func windows() throws -> [[String: Any]] {
+ guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], 0)
+ as? [[String: Any]] else {
+ throw DemoCtlError.usage("cannot inspect visible windows before recording")
+ }
+ return windows
+ }
+
+ public static func requireCleanDesktop() throws {
+ var problems: [String] = []
+ for window in try windows() {
+ let owner = window[kCGWindowOwnerName as String] as? String ?? ""
+ let title = window[kCGWindowName as String] as? String ?? ""
+ let normalized = title.lowercased()
+ let permission = ["screen recording", "screen capture", "화면 기록", "화면 녹화", "accessibility", "permissions needed"]
+ .contains { normalized.contains($0) }
+ let systemDialog = ["UserNotificationCenter", "SecurityAgent", "System Settings", "Software Update", "universalAccessAuthWarn"]
+ .contains(owner)
+ // Notification Center also owns permanent desktop widgets and a
+ // transparent full-screen host. Inspect actual notification text;
+ // the existence of that host window alone does not mean a banner exists.
+ if ["NotificationCenter", "Notification Center"].contains(owner),
+ (window[kCGWindowLayer as String] as? Int ?? 0) > 0,
+ let pid = window[kCGWindowOwnerPID as String] as? Int32 {
+ problems += notificationTitles(pid: pid, windowTitle: title).map { "Notification: \($0)" }
+ }
+ let updateDialog = ["software update", "소프트웨어 업데이트", "ソフトウェアアップデート", "软件更新", "軟體更新"].contains(normalized)
+ if permission || systemDialog || updateDialog { problems.append("\(owner): \(title)") }
+ }
+ guard problems.isEmpty else {
+ throw DemoCtlError.usage("capture blocked by visible system UI: \(problems.joined(separator: "; ")). "
+ + "Resolve it before recording. Recorder access does not prove Tatami has Screen Recording access.")
+ }
+ }
+
+ private static func notificationTitles(pid: Int32, windowTitle: String) -> [String] {
+ func attribute(_ element: AXUIElement, _ key: String) -> CFTypeRef? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, key as CFString, &value) == .success else { return nil }
+ return value
+ }
+ var titles: [String] = []
+ func walk(_ element: AXUIElement, depth: Int) {
+ guard depth < 12 else { return }
+ if attribute(element, kAXIdentifierAttribute) as? String == "title",
+ let text = attribute(element, kAXValueAttribute) as? String, !text.isEmpty {
+ titles.append(text)
+ }
+ for child in attribute(element, kAXChildrenAttribute) as? [AXUIElement] ?? [] {
+ walk(child, depth: depth + 1)
+ }
+ }
+ let app = AXUIElementCreateApplication(pid)
+ for window in attribute(app, kAXWindowsAttribute) as? [AXUIElement] ?? []
+ where attribute(window, kAXTitleAttribute) as? String == windowTitle {
+ walk(window, depth: 0)
+ }
+ return titles
+ }
+
+ public static func requireOpening(_ names: [String]) throws {
+ let expected = try names.map { name -> String in
+ guard let spec = DemoCatalog.spec(named: name) else { throw DemoCtlError.usage("unknown opening app \(name)") }
+ return spec.bundleIdentifier
+ }.sorted()
+ let actual = try demoFrames().keys.map { String($0.split(separator: ":")[0]) }.sorted()
+ guard expected == actual else {
+ throw DemoCtlError.usage("opening window set differs: expected \(expected), found \(actual). Re-seed before recording.")
+ }
+ }
+
+ public static func waitForApps(_ names: [String], timeout: Duration) throws {
+ let specs = try names.map { name -> DemoAppSpec in
+ guard let spec = DemoCatalog.spec(named: name) else {
+ throw DemoCtlError.usage("unknown opening app \(name)")
+ }
+ return spec
+ }
+ var last: [String: CGRect] = [:]
+ var stableSince = ContinuousClock.now
+ let ready = try Shell.wait(timeout: timeout, poll: .milliseconds(100)) {
+ try requireCleanDesktop()
+ let frames = try demoFrames()
+ let visible = specs.allSatisfy { spec in frames.keys.contains { $0.hasPrefix(spec.bundleIdentifier + ":") } }
+ if !visible || !matches(frames, last) { stableSince = .now }
+ last = frames
+ return visible && ContinuousClock.now - stableSince >= .milliseconds(400)
+ }
+ guard ready else {
+ throw DemoCtlError.usage("expected apps did not reach a stable visible layout: \(names.joined(separator: ", "))")
+ }
+ }
+
+ static func demoFrames() throws -> [String: CGRect] {
+ let identifiers = Dictionary(uniqueKeysWithValues: DemoCatalog.all.flatMap { spec in
+ NSRunningApplication.runningApplications(withBundleIdentifier: spec.bundleIdentifier)
+ .map { ($0.processIdentifier, spec.bundleIdentifier) }
+ })
+ var result: [String: CGRect] = [:]
+ for window in try windows() {
+ guard let pid = window[kCGWindowOwnerPID as String] as? Int32,
+ let id = identifiers[pid],
+ window[kCGWindowLayer as String] as? Int == 0,
+ let number = window[kCGWindowNumber as String] as? Int,
+ let bounds = window[kCGWindowBounds as String] as? NSDictionary,
+ let frame = CGRect(dictionaryRepresentation: bounds), frame.width > 80, frame.height > 80
+ else { continue }
+ result["\(id):\(number)"] = frame
+ }
+ return result
+ }
+
+ public static func matches(_ actual: [String: CGRect], _ expected: [String: CGRect], tolerance: CGFloat = 4) -> Bool {
+ guard actual.keys.sorted() == expected.keys.sorted() else { return false }
+ return expected.allSatisfy { key, value in
+ guard let frame = actual[key] else { return false }
+ return abs(frame.minX - value.minX) <= tolerance && abs(frame.minY - value.minY) <= tolerance
+ && abs(frame.width - value.width) <= tolerance && abs(frame.height - value.height) <= tolerance
+ }
+ }
+}
diff --git a/DemoLab/Sources/DemoCtlKit/ConfigRenderer.swift b/DemoLab/Sources/DemoCtlKit/ConfigRenderer.swift
new file mode 100644
index 0000000..9db4a51
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/ConfigRenderer.swift
@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - SeedOptions
+
+public struct SeedOptions: Sendable, Equatable {
+
+ // MARK: Lifecycle
+
+ public init(
+ pulseLayout: String = "floating",
+ borrowEdge: String = "right",
+ focusFollowsMouse: Bool = false,
+ mouseFollowsFocus: Bool = true,
+ debugLogging: Bool = false
+ ) {
+ self.pulseLayout = pulseLayout
+ self.borrowEdge = borrowEdge
+ self.focusFollowsMouse = focusFollowsMouse
+ self.mouseFollowsFocus = mouseFollowsFocus
+ self.debugLogging = debugLogging
+ }
+
+ // MARK: Public
+
+ public static let pulseLayouts = ["floating", "unmanaged", "tiled"]
+ public static let borrowEdges = ["top", "bottom", "left", "right"]
+
+ /// `floating` = Always on Top (needs Screen Recording, because the always-on-top
+ /// window is a ScreenCaptureKit mirror). `unmanaged` = Leave As Is (needs no
+ /// permission at all). `tiled` folds Pulse into every layout.
+ public var pulseLayout: String
+ public var borrowEdge: String
+ public var focusFollowsMouse: Bool
+ public var mouseFollowsFocus: Bool
+ public var debugLogging: Bool
+
+ public func validated() throws -> SeedOptions {
+ var problems = [String]()
+ if !Self.pulseLayouts.contains(pulseLayout) {
+ problems.append("--pulse must be one of \(Self.pulseLayouts.joined(separator: ", "))")
+ }
+ if !Self.borrowEdges.contains(borrowEdge) {
+ problems.append("--borrow-edge must be one of \(Self.borrowEdges.joined(separator: ", "))")
+ }
+ guard problems.isEmpty else { throw DemoCtlError.usage(problems.joined(separator: "\n")) }
+ return self
+ }
+
+}
+
+// MARK: - ConfigRenderer
+
+/// Turns `config/tatami-demo.toml.in` into the config file the lab's Tatami
+/// instance reads.
+public struct ConfigRenderer: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(paths: LabPaths) {
+ self.paths = paths
+ }
+
+ // MARK: Public
+
+ public let paths: LabPaths
+
+ public func render(_ options: SeedOptions) throws -> String {
+ guard let template = try? String(contentsOf: paths.templateFile, encoding: .utf8) else {
+ throw DemoCtlError.templateMissing(paths.templateFile.path)
+ }
+
+ let substitutions = [
+ "@STATUS_BINARY@": paths.bundlesRoot.appendingPathComponent("bin/demohook").path,
+ "@CONTROL_DIR@": paths.controlDirectory.path,
+ "@HOOK@": paths.hookExecutable.path,
+ "@EVENT_LOG@": paths.eventLog.path,
+ "@PULSE_LAYOUT@": options.pulseLayout,
+ "@BORROW_EDGE@": options.borrowEdge,
+ "@FOCUS_FOLLOWS_MOUSE@": options.focusFollowsMouse ? "true" : "false",
+ "@MOUSE_FOLLOWS_FOCUS@": options.mouseFollowsFocus ? "true" : "false",
+ "@DEBUG_LOGGING@": options.debugLogging ? "true" : "false",
+ ]
+
+ var rendered = template
+ for (placeholder, value) in substitutions {
+ rendered = rendered.replacingOccurrences(of: placeholder, with: value)
+ }
+
+ // A leftover placeholder would reach Tatami as a plain string and be
+ // silently accepted or silently defaulted. Catch it here instead.
+ let leftovers = Self.placeholders(in: rendered)
+ guard leftovers.isEmpty else { throw DemoCtlError.unresolvedPlaceholders(leftovers) }
+
+ return rendered
+ }
+
+ /// Writes the rendered config atomically.
+ ///
+ /// `Data.write(options: .atomic)` is a temporary file plus a rename, which is
+ /// exactly the write pattern Tatami documents for external writers and which
+ /// its file watcher picks up. Writing in place through a long-lived file
+ /// descriptor is the one pattern that does *not* work.
+ @discardableResult
+ public func write(_ options: SeedOptions) throws -> String {
+ let rendered = try render(options)
+ try paths.ensureRuntimeDirectories()
+ let problems = ConfigValidator.problems(in: rendered)
+ guard problems.isEmpty else { throw DemoCtlError.configInvalid(problems) }
+ try Data(rendered.utf8).write(to: paths.configFile, options: .atomic)
+ return rendered
+ }
+
+ // MARK: Private
+
+ private static let placeholderPattern = try? NSRegularExpression(pattern: "@[A-Z][A-Z0-9_]*@")
+
+ private static func placeholders(in text: String) -> [String] {
+ guard let placeholderPattern else { return [] }
+ let range = NSRange(text.startIndex.. = [],
+ tables: [String: TableSchema] = [:],
+ tableArrays: [String: TableSchema] = [:]
+ ) {
+ self.keys = keys
+ self.tables = tables
+ self.tableArrays = tableArrays
+ }
+
+ // MARK: Internal
+
+ let keys: Set
+ let tables: [String: TableSchema]
+ let tableArrays: [String: TableSchema]
+
+}
+
+// MARK: - ConfigValidator
+
+/// Checks a rendered `config.toml` against what Tatami's decoder actually does.
+///
+/// This exists because of one asymmetry in Tatami: some mistakes reject the
+/// whole file loudly, while others — a misspelled key name, a misspelled
+/// `layout`, an unknown `autoBalance`, a missing `borrowDefaultEdge`, a bad
+/// `displayCount`, a quoted `autoOpen` — are **silently defaulted with no
+/// banner, no log line, and no CLI signal**. In a recording lab that is the
+/// worst possible failure: the demo runs, looks almost right, and differs from
+/// the previous take for no visible reason.
+///
+/// The rules encoded here were read out of Tatami's decoder and domain types,
+/// not inferred from the documentation. A rule that is too strict is just as
+/// bad as a missing one: `ConfigRenderer.write` throws on any problem, so a
+/// false rejection blocks `democtl seed` on config Tatami would have accepted.
+public enum ConfigValidator {
+
+ // MARK: Public
+
+ public static let workspaceKinds: Set = ["normal", "scratchpad"]
+ public static let layoutModes: Set = ["tiled", "floating", "unmanaged"]
+ public static let borrowEdges: Set = ["top", "bottom", "left", "right"]
+ public static let autoBalanceModes: Set = ["none", "horizontal", "vertical", "both"]
+ public static let splitTypes: Set = ["auto", "horizontal", "vertical"]
+ public static let windowPlacements: Set = ["first", "second"]
+ public static let hudPositions: Set = [
+ "topLeading", "top", "topTrailing", "leading", "center", "trailing",
+ "bottomLeading", "bottom", "bottomTrailing",
+ ]
+ public static let hudSizes: Set = ["small", "default", "large"]
+ public static let markerCorners: Set = [
+ "topLeading", "topTrailing", "bottomLeading", "bottomTrailing",
+ ]
+ public static let checkIntervals: Set = ["hourly", "daily", "weekly"]
+ /// Only `none` is lowercase. `None` fails and falls back with a banner.
+ public static let ffmDisableHotkeys: Set = ["none", "Alt", "Cmd", "Ctrl", "Shift"]
+ public static let hookEvents: Set = [
+ "tatamiLaunched", "profileChanged", "workspaceActivated", "hud",
+ ]
+ public static let modifierTokens: Set = ["ctrl", "alt", "shift", "cmd"]
+
+ /// Exactly the key names Tatami's shortcut parser accepts. Anything else
+ /// leaves the action unbound (with a Problem banner, which nobody is watching
+ /// during a take).
+ public static let keyNames: Set = {
+ var names = Set()
+ for scalar in UnicodeScalar("a").value...UnicodeScalar("z").value {
+ names.insert(String(UnicodeScalar(scalar)!))
+ }
+ for digit in 0...9 { names.insert(String(digit)) }
+ names.formUnion(["`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/"])
+ names.formUnion([
+ "return", "enter", "tab", "space", "delete", "escape", "help", "home", "end",
+ "pageup", "pagedown", "forwarddelete", "left", "right", "up", "down",
+ ])
+ for index in 1...13 { names.insert("f\(index)") }
+ names.formUnion(["esc", "backspace", "del", "spacebar", "pgup", "pgdn"])
+ return names
+ }()
+
+ public static func problems(in text: String) -> [String] {
+ report(text).problems
+ }
+
+ public static func report(_ text: String) -> ConfigReport {
+ var problems = [String]()
+ var warnings = [String]()
+
+ let root: [String: TomlValue]
+ do {
+ root = try TomlLite.parse(text)
+ } catch {
+ return ConfigReport(problems: ["config is not parseable TOML: \(error)"], warnings: [])
+ }
+
+ checkUnknownKeys(root, against: configSchema, path: "", into: &problems)
+ checkSettings(root["settings"], problems: &problems, warnings: &warnings)
+ checkSharedApps(root["sharedApps"], problems: &problems, warnings: &warnings)
+ checkHooks(root["hooks"], problems: &problems)
+
+ let globalBorrowEdge = root["settings"]?["switching"]?["borrowDefaultEdge"]?.stringValue
+ // Tatami resolves a workspace's focus pin against its own apps *and* the
+ // shared apps, so the shared set has to reach the workspace check.
+ let sharedBundleIdentifiers = Set(
+ (root["sharedApps"]?.tableArray ?? []).compactMap { $0["bundleIdentifier"]?.stringValue }
+ )
+ checkProfiles(
+ root["profiles"],
+ globalBorrowEdge: globalBorrowEdge,
+ sharedBundleIdentifiers: sharedBundleIdentifiers,
+ problems: &problems,
+ warnings: &warnings
+ )
+
+ return ConfigReport(problems: problems, warnings: warnings)
+ }
+
+ // MARK: Private
+
+ private static let knownBundleIdentifiers = Set(DemoCatalog.all.map(\.bundleIdentifier))
+
+ /// Every coding key of `AppSettings.Shortcuts`, including the six explicit
+ /// assign/borrow overrides the template does not use.
+ private static let shortcutKeys: Set = [
+ "focusLeft", "focusRight", "focusUp", "focusDown",
+ "switchToNextWorkspace", "switchToPreviousWorkspace", "switchToRecentWorkspace",
+ "moveToNextWorkspace", "moveToPreviousWorkspace",
+ "focusNextDisplay", "focusPreviousDisplay",
+ "cycleNextWindow", "cyclePreviousWindow",
+ "resizeGrow", "resizeShrink",
+ "swapLeft", "swapRight", "swapUp", "swapDown",
+ "toggleOrientation", "toggleFullscreen", "balance",
+ "toggleFloating", "toggleSharedFloating", "toggleSpaceActivated",
+ "toggleFocusedAppInActiveWorkspace", "toggleAppInSharedApps",
+ "keyEquivalentModifiers", "assignModifiers", "borrowModifiers",
+ "recentWorkspaceKey", "nextWorkspaceKey", "previousWorkspaceKey",
+ "assignRecentWorkspace", "assignNextWorkspace", "assignPreviousWorkspace",
+ "borrowRecentWorkspace", "borrowNextWorkspace", "borrowPreviousWorkspace",
+ "dismissBorrow",
+ ]
+
+ private static let gestureBindingsSchema = TableSchema(keys: ["left", "right", "up", "down"])
+
+ /// `AppAssignment` and `SharedApp` share a key set. `floating` is the pre-1.4
+ /// Bool that `layout` replaced and still decodes, so it is not a typo.
+ private static let appSchema = TableSchema(
+ keys: ["bundleIdentifier", "name", "iconPath", "autoOpen", "layout", "floating"]
+ )
+
+ private static let settingsSchema = TableSchema(
+ tables: [
+ "general": TableSchema(keys: [
+ "launchAtLogin", "checkForUpdatesAutomatically", "checkInterval", "debugLogging",
+ ]),
+ "visibility": TableSchema(keys: ["overlayAwareApps"]),
+ "menuBar": TableSchema(keys: [
+ "showWorkspaceIcon", "showWorkspaceName", "showProfileIcon", "showProfileName",
+ ]),
+ "hud": TableSchema(keys: [
+ "enabled", "workspaceSwitch", "windowCycle", "profileSwitch", "floating", "appMembership",
+ "tilingPaused", "fullscreen", "layout", "borrow", "position", "size", "durationMs",
+ ]),
+ "marker": TableSchema(keys: [
+ "floatingEnabled", "floatingColorHex", "fullscreenEnabled", "fullscreenColorHex",
+ "borrowEnabled", "borrowColorHex", "size", "corner", "hideOnHover",
+ ]),
+ "layout": TableSchema(keys: [
+ "gapInner", "gapOuter", "autoBalance", "splitType", "windowPlacement",
+ ]),
+ "focus": TableSchema(keys: [
+ "mouseFollowsFocus", "mouseHidesOnFocus", "focusFollowsMouse",
+ "focusFollowsMouseDisableHotkey", "focusFollowsMouseIgnoreFullscreen", "refocusOnClose",
+ ]),
+ "switching": TableSchema(keys: [
+ "loop", "skipEmpty", "followAppFocus", "cycleAcrossDisplays", "recentAcrossDisplays",
+ "switchToRecentWhenEmpty", "cycleSameAppWindows", "includeSharedAppsInWindowSwitcher",
+ "toggleBorrowOnRepeat", "borrowDefaultEdge", "borrowFraction",
+ ]),
+ "gestures": TableSchema(
+ // `fingerCount` and `sensitivity` are decode-only survivors of older builds.
+ keys: ["enabled", "threshold", "fingerCount", "sensitivity"],
+ tables: ["threeFinger": gestureBindingsSchema, "fourFinger": gestureBindingsSchema]
+ ),
+ "shortcuts": TableSchema(keys: shortcutKeys),
+ ]
+ )
+
+ private static let workspaceSchema = TableSchema(
+ keys: [
+ "id", "name", "displayHint", "activateShortcut", "assignAppShortcut", "symbolIconName",
+ "appToFocusBundleId", "kind", "keyEquivalent", "borrowShortcut", "borrowEdge", "borrowFraction",
+ ],
+ tableArrays: ["apps": appSchema]
+ )
+
+ private static let profileSchema = TableSchema(
+ keys: ["id", "name", "symbolIconName", "shortcut"],
+ tables: [
+ "autoActivation": TableSchema(keys: [
+ "whenConnectedMatch", "whenConnected", "whenDisconnected", "displayCount",
+ ]),
+ ],
+ tableArrays: [
+ "workspaces": workspaceSchema,
+ "workspaceChains": TableSchema(keys: ["id", "name", "workspaceIds", "dynamicWorkspaceIds"]),
+ ]
+ )
+
+ /// Exactly the keys Tatami's decoders read, table by table.
+ ///
+ /// Copied from the `CodingKeys` of `AppConfig`, `AppSettings`, `Profile`,
+ /// `Workspace`, `AppAssignment`, `SharedApp`, `HookDefinition`,
+ /// `WorkspaceChain` and `ProfileActivation` — decode-only legacy keys
+ /// included, so an older hand-written config is not called broken.
+ ///
+ /// `activeProfileId` is deliberately absent: Tatami never decodes it from
+ /// config.toml (it lives in ProfileSessionStore), so writing it here would
+ /// do nothing.
+ private static let configSchema = TableSchema(
+ tables: ["settings": settingsSchema],
+ tableArrays: [
+ "profiles": profileSchema,
+ "sharedApps": appSchema,
+ "hooks": TableSchema(keys: [
+ "id", "event", "enabled", "command", "timeoutMs", "workingDirectory", "environment",
+ ]),
+ // Pre-1.4 shape, decoded once and migrated into `sharedApps`.
+ "floatingApps": TableSchema(keys: ["bundleIdentifier", "name", "iconPath"]),
+ ]
+ )
+
+ /// Reports a key name Tatami's decoders do not contain.
+ ///
+ /// Every other rule in this file inspects a *value*; this is the only one
+ /// that can catch a misspelled *key*. Tatami decodes into typed models and
+ /// never looks at a key it does not name, so `borrowFractoin` costs the whole
+ /// setting with no banner, no log line and no CLI signal.
+ private static func checkUnknownKeys(
+ _ table: [String: TomlValue],
+ against schema: TableSchema,
+ path: String,
+ into problems: inout [String]
+ ) {
+ for key in table.keys.sorted() {
+ let keyPath = path.isEmpty ? key : "\(path).\(key)"
+ if let nested = schema.tables[key] {
+ if let child = table[key]?.tableValue {
+ checkUnknownKeys(child, against: nested, path: keyPath, into: &problems)
+ }
+ continue
+ }
+ if let element = schema.tableArrays[key] {
+ for (index, row) in (table[key]?.tableArray ?? []).enumerated() {
+ checkUnknownKeys(row, against: element, path: "\(keyPath)[\(index)]", into: &problems)
+ }
+ continue
+ }
+ guard !schema.keys.contains(key) else { continue }
+ let known = schema.keys.union(schema.tables.keys).union(schema.tableArrays.keys)
+ let hint = nearestKey(to: key, in: known).map { " (did you mean \($0)?)" } ?? ""
+ problems.append(
+ "\(keyPath) is not a key Tatami decodes\(hint); it is dropped in silence, so whatever it was "
+ + "meant to configure keeps its default"
+ )
+ }
+ }
+
+ /// A deliberately cheap "did you mean": the same letters in a different order
+ /// or a different case. Those are the two typos that actually get made, and a
+ /// wrong guess here would be worse than none.
+ private static func nearestKey(to key: String, in known: Set) -> String? {
+ let fingerprint = key.lowercased().sorted()
+ return known.sorted().first { $0.lowercased().sorted() == fingerprint }
+ }
+
+ private static func checkEnum(
+ _ value: TomlValue?,
+ _ allowed: Set,
+ _ path: String,
+ into problems: inout [String]
+ ) {
+ guard let value else { return }
+ guard let raw = value.stringValue else {
+ problems.append("\(path) must be a string")
+ return
+ }
+ guard allowed.contains(raw) else {
+ problems.append("\(path) = \"\(raw)\" is not one of \(allowed.sorted().joined(separator: ", "))")
+ return
+ }
+ }
+
+ /// `autoOpen` is the one app-row key that neither reports nor rejects a bad
+ /// value: both `AppAssignment` and `SharedApp` decode it with a bare `try?`,
+ /// so `autoOpen = "true"` and a missing key both mean "do not open" with no
+ /// banner anywhere. Every scene relies on activation bringing its apps up, so
+ /// the take would be of an empty desktop while every step reports success.
+ private static func checkAutoOpen(
+ _ row: [String: TomlValue],
+ _ path: String,
+ requirePresence: Bool = true,
+ into problems: inout [String]
+ ) {
+ guard let value = row["autoOpen"] else {
+ if requirePresence {
+ problems.append(
+ "\(path) has no autoOpen, so it decodes to false and activating its workspace opens nothing"
+ )
+ }
+ return
+ }
+ if value.boolValue == nil {
+ problems.append(
+ "\(path).autoOpen must be a bare true/false; a quoted or numeric value is silently decoded as false"
+ )
+ }
+ }
+
+ private static func checkSettings(
+ _ settings: TomlValue?,
+ problems: inout [String],
+ warnings: inout [String]
+ ) {
+ guard let settings else {
+ warnings.append("no [settings] table; every setting falls back to its default")
+ return
+ }
+
+ checkEnum(settings["general"]?["checkInterval"], checkIntervals, "settings.general.checkInterval", into: &problems)
+
+ let layout = settings["layout"]
+ checkEnum(layout?["autoBalance"], autoBalanceModes, "settings.layout.autoBalance", into: &problems)
+ checkEnum(layout?["splitType"], splitTypes, "settings.layout.splitType", into: &problems)
+ checkEnum(layout?["windowPlacement"], windowPlacements, "settings.layout.windowPlacement", into: &problems)
+ for key in ["gapInner", "gapOuter"] {
+ if let gap = layout?[key]?.intValue, !(0...100).contains(gap) {
+ warnings.append("settings.layout.\(key) = \(gap) is outside the range the app's UI offers (0...100)")
+ }
+ }
+
+ let hud = settings["hud"]
+ checkEnum(hud?["position"], hudPositions, "settings.hud.position", into: &problems)
+ checkEnum(hud?["size"], hudSizes, "settings.hud.size", into: &problems)
+ if let duration = hud?["durationMs"]?.intValue, !(300...3000).contains(duration) {
+ // Not range-checked at decode time, so an absurd value is accepted and
+ // then leaves feedback on screen for the whole take.
+ warnings.append("settings.hud.durationMs = \(duration) is outside the app's 300...3000 range")
+ }
+
+ checkEnum(
+ settings["focus"]?["focusFollowsMouseDisableHotkey"],
+ ffmDisableHotkeys,
+ "settings.focus.focusFollowsMouseDisableHotkey",
+ into: &problems
+ )
+
+ checkEnum(settings["marker"]?["corner"], markerCorners, "settings.marker.corner", into: &problems)
+
+ let switching = settings["switching"]
+ if let edge = switching?["borrowDefaultEdge"] {
+ checkEnum(edge, borrowEdges, "settings.switching.borrowDefaultEdge", into: &problems)
+ } else {
+ problems.append(
+ """
+ settings.switching.borrowDefaultEdge is missing. Without it (and without a per-workspace \
+ borrowEdge) a CLI borrow arms a keyboard-only direction picker that silently cancels itself \
+ after 5 seconds, so every scripted Borrow would appear to do nothing.
+ """
+ )
+ }
+ if let fraction = switching?["borrowFraction"]?.doubleValue, !(0.1...0.9).contains(fraction) {
+ problems.append("settings.switching.borrowFraction = \(fraction) is outside 0.1...0.9 and is never clamped")
+ }
+
+ checkShortcuts(settings["shortcuts"], problems: &problems)
+ }
+
+ private static func checkShortcuts(_ shortcuts: TomlValue?, problems: inout [String]) {
+ guard let shortcuts, let table = shortcuts.tableValue else { return }
+
+ for key in ["keyEquivalentModifiers", "assignModifiers", "borrowModifiers"] {
+ guard let value = table[key] else {
+ problems.append(
+ "settings.shortcuts.\(key) is missing; a hand-authored config falls back to the decode "
+ + "default, not the recommended set a fresh install gets"
+ )
+ continue
+ }
+ guard let tokens = value.stringArray else {
+ problems.append("settings.shortcuts.\(key) must be an array of strings")
+ continue
+ }
+ let unknown = tokens.filter { !modifierTokens.contains($0) }
+ if !unknown.isEmpty {
+ problems.append("settings.shortcuts.\(key) has unknown modifiers: \(unknown.joined(separator: ", "))")
+ }
+ if tokens.isEmpty {
+ problems.append("settings.shortcuts.\(key) is empty, which disables that whole action")
+ }
+ }
+
+ for key in ["recentWorkspaceKey", "nextWorkspaceKey", "previousWorkspaceKey"] {
+ guard let name = table[key]?.stringValue else { continue }
+ if !keyNames.contains(name.lowercased()) {
+ problems.append("settings.shortcuts.\(key) = \"\(name)\" is not a key name Tatami knows")
+ }
+ }
+
+ let modifierArrays = ["keyEquivalentModifiers", "assignModifiers", "borrowModifiers"]
+ let navigationKeys = ["recentWorkspaceKey", "nextWorkspaceKey", "previousWorkspaceKey"]
+ for (key, value) in table {
+ guard !modifierArrays.contains(key), !navigationKeys.contains(key) else { continue }
+ guard let raw = value.stringValue else {
+ problems.append("settings.shortcuts.\(key) must be a shortcut string")
+ continue
+ }
+ if let issue = shortcutProblem(raw) {
+ problems.append("settings.shortcuts.\(key) = \"\(raw)\": \(issue)")
+ }
+ }
+ }
+
+ /// Mirrors all three separator forms of `HotKey.init(parsing:)`: the padded
+ /// ` - `, the unpadded trailing dash, and the `+`-only form.
+ ///
+ /// Both directions of a mismatch are expensive. A missed typo leaves an
+ /// action unbound mid-take; a spelling this rejects but Tatami accepts blocks
+ /// `democtl seed` entirely, because any problem throws before the config is
+ /// written.
+ private static func shortcutProblem(_ raw: String) -> String? {
+ let text = raw.lowercased().trimmingCharacters(in: .whitespaces)
+ guard !text.isEmpty else { return "empty" }
+
+ var modifiers = [String]()
+ var keyName = text
+
+ if let separator = text.range(of: " - ", options: .backwards) {
+ modifiers = splitModifiers(String(text[text.startIndex.. [String] {
+ part
+ .split(whereSeparator: { $0 == "+" || $0 == "-" })
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ }
+
+ private static func checkSharedApps(
+ _ shared: TomlValue?,
+ problems: inout [String],
+ warnings: inout [String]
+ ) {
+ guard let rows = shared?.tableArray else { return }
+ for (index, row) in rows.enumerated() {
+ let label = "sharedApps[\(index)]"
+ guard let bundleIdentifier = row["bundleIdentifier"]?.stringValue, !bundleIdentifier.isEmpty else {
+ problems.append("\(label) has no bundleIdentifier")
+ continue
+ }
+ if row["name"]?.stringValue == nil { problems.append("\(label) has no name") }
+ checkAutoOpen(row, label, into: &problems)
+ checkEnum(row["layout"], layoutModes, "\(label).layout", into: &problems)
+ if !knownBundleIdentifiers.contains(bundleIdentifier) {
+ warnings.append("\(label) references \(bundleIdentifier), which is not a Demo Lab app")
+ }
+ }
+ }
+
+ private static func checkHooks(_ hooks: TomlValue?, problems: inout [String]) {
+ guard let rows = hooks?.tableArray else { return }
+ var seen = Set()
+ for (index, row) in rows.enumerated() {
+ let label = "hooks[\(index)]"
+ guard let identifier = row["id"]?.stringValue, !identifier.isEmpty else {
+ problems.append("\(label) has no id")
+ continue
+ }
+ if !seen.insert(identifier).inserted { problems.append("\(label) reuses hook id \(identifier)") }
+ if identifier.count > 64 { problems.append("\(label) id is longer than 64 characters") }
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
+ if identifier.unicodeScalars.contains(where: { !allowed.contains($0) }) {
+ problems.append("\(label) id may only use ASCII letters, digits, '.', '_' and '-'")
+ }
+ checkEnum(row["event"], hookEvents, "\(label).event", into: &problems)
+ guard let command = row["command"]?.stringArray, let executable = command.first,
+ !executable.isEmpty
+ else {
+ problems.append("\(label).command must be a non-empty array of strings")
+ continue
+ }
+ if !FileManager.default.isExecutableFile(atPath: executable) {
+ problems.append("\(label).command[0] is not an executable file: \(executable)")
+ }
+ if let timeout = row["timeoutMs"]?.intValue, !(100...300_000).contains(timeout) {
+ problems.append("\(label).timeoutMs = \(timeout) is outside 100...300000")
+ }
+ }
+ }
+
+ private static func checkProfiles(
+ _ profiles: TomlValue?,
+ globalBorrowEdge: String?,
+ sharedBundleIdentifiers: Set,
+ problems: inout [String],
+ warnings: inout [String]
+ ) {
+ guard let rows = profiles?.tableArray, !rows.isEmpty else {
+ problems.append("[[profiles]] is missing or empty")
+ return
+ }
+
+ var allWorkspaceIDs = Set()
+ var profileIDs = Set()
+
+ for (index, profile) in rows.enumerated() {
+ let name = profile["name"]?.stringValue ?? "profiles[\(index)]"
+ let label = "profile \"\(name)\""
+
+ guard let identifier = profile["id"]?.stringValue, UUID(uuidString: identifier) != nil else {
+ problems.append("\(label) has a missing or malformed id (a bad id rejects the whole file)")
+ continue
+ }
+ if !profileIDs.insert(identifier).inserted { problems.append("\(label) reuses profile id \(identifier)") }
+ if profile["name"]?.stringValue == nil { problems.append("profiles[\(index)] has no name") }
+
+ if let shortcut = profile["shortcut"]?.stringValue, let issue = shortcutProblem(shortcut) {
+ // A profile shortcut is decoded with a hard `try`: a bad one rejects
+ // the entire config rather than degrading.
+ problems.append("\(label).shortcut = \"\(shortcut)\": \(issue) — this rejects the whole file")
+ }
+
+ checkAutoActivation(profile["autoActivation"], label: label, problems: &problems, warnings: &warnings)
+
+ guard let workspaces = profile["workspaces"]?.tableArray else {
+ problems.append("\(label) has no [[profiles.workspaces]] (a profile without workspaces rejects the file)")
+ continue
+ }
+
+ var workspaceIDsInProfile = Set()
+ var scratchpadIDs = Set()
+ var keyEquivalents = [String: String]()
+
+ for (position, workspace) in workspaces.enumerated() {
+ let workspaceName = workspace["name"]?.stringValue ?? "workspaces[\(position)]"
+ let workspaceLabel = "\(label) / workspace \"\(workspaceName)\""
+
+ guard let workspaceID = workspace["id"]?.stringValue, UUID(uuidString: workspaceID) != nil else {
+ problems.append("\(workspaceLabel) has a missing or malformed id")
+ continue
+ }
+ if !workspaceIDsInProfile.insert(workspaceID).inserted {
+ problems.append("\(workspaceLabel) duplicates a workspace id inside its profile — this rejects the whole file")
+ }
+ if !allWorkspaceIDs.insert(workspaceID).inserted {
+ problems.append("\(workspaceLabel) reuses a workspace id from another profile; ids must be globally unique")
+ }
+ if workspace["name"]?.stringValue == nil { problems.append("\(workspaceLabel) has no name") }
+
+ checkEnum(workspace["kind"], workspaceKinds, "\(workspaceLabel).kind", into: &problems)
+ let kind = workspace["kind"]?.stringValue ?? "normal"
+ if kind == "scratchpad" { scratchpadIDs.insert(workspaceID) }
+
+ if let edge = workspace["borrowEdge"] {
+ checkEnum(edge, borrowEdges, "\(workspaceLabel).borrowEdge", into: &problems)
+ }
+ if let fraction = workspace["borrowFraction"]?.doubleValue, !(0.1...0.9).contains(fraction) {
+ problems.append("\(workspaceLabel).borrowFraction = \(fraction) is outside 0.1...0.9 and is never clamped")
+ }
+ if kind == "scratchpad", workspace["borrowEdge"]?.stringValue == nil, globalBorrowEdge == nil {
+ problems.append(
+ "\(workspaceLabel) is a scratchpad with no borrowEdge and no settings.switching.borrowDefaultEdge, "
+ + "so summoning it would wait for a keystroke and then cancel"
+ )
+ }
+
+ for key in ["activateShortcut", "assignAppShortcut", "borrowShortcut"] {
+ guard let shortcut = workspace[key]?.stringValue else { continue }
+ if let issue = shortcutProblem(shortcut) {
+ problems.append("\(workspaceLabel).\(key) = \"\(shortcut)\": \(issue) — this rejects the whole file")
+ }
+ }
+
+ if let equivalent = workspace["keyEquivalent"]?.stringValue {
+ if !keyNames.contains(equivalent.lowercased()) {
+ problems.append("\(workspaceLabel).keyEquivalent = \"\(equivalent)\" is not a key name Tatami knows")
+ }
+ if let previous = keyEquivalents.updateValue(workspaceName, forKey: equivalent.lowercased()) {
+ problems.append("\(label) uses key equivalent \"\(equivalent)\" for both \(previous) and \(workspaceName)")
+ }
+ }
+
+ if let hint = workspace["displayHint"]?.stringValue {
+ if hint.hasPrefix("::") {
+ problems.append("\(workspaceLabel).displayHint starts with '::', which yields an empty UUID that can never match")
+ } else if hint.contains("::") {
+ warnings.append("\(workspaceLabel).displayHint pins to a specific machine's display and will not resolve elsewhere")
+ }
+ }
+
+ let apps = workspace["apps"]?.tableArray ?? []
+ var bundleIdentifiers = Set()
+ var opensAnApp = false
+ for (appIndex, app) in apps.enumerated() {
+ let appLabel = "\(workspaceLabel) / apps[\(appIndex)]"
+ guard let bundleIdentifier = app["bundleIdentifier"]?.stringValue, !bundleIdentifier.isEmpty else {
+ problems.append("\(appLabel) has no bundleIdentifier")
+ continue
+ }
+ bundleIdentifiers.insert(bundleIdentifier)
+ if app["name"]?.stringValue == nil { problems.append("\(appLabel) has no name") }
+ // A borrow forces autoOpen on a scratchpad's apps, so only the type
+ // matters there.
+ checkAutoOpen(app, appLabel, requirePresence: kind != "scratchpad", into: &problems)
+ if app["autoOpen"]?.boolValue == true { opensAnApp = true }
+ checkEnum(app["layout"], layoutModes, "\(appLabel).layout", into: &problems)
+ if !knownBundleIdentifiers.contains(bundleIdentifier) {
+ warnings.append("\(appLabel) references \(bundleIdentifier), which is not a Demo Lab app")
+ }
+ }
+
+ if !apps.isEmpty, !opensAnApp, kind != "scratchpad" {
+ warnings.append(
+ "\(workspaceLabel) has apps but none with autoOpen = true, so activating it opens nothing"
+ )
+ }
+
+ // Tatami resolves the pin against workspace ∪ shared ∪ borrowed apps
+ // (WorkspaceManagerClient's `keepVisible`), so a shared app such as
+ // Pulse is a legitimate focus target. Only an id in neither set
+ // silently falls back to most-recently-used, which is exactly the kind
+ // of drift between takes this lab exists to prevent.
+ if
+ let focus = workspace["appToFocusBundleId"]?.stringValue,
+ !bundleIdentifiers.contains(focus),
+ !sharedBundleIdentifiers.contains(focus)
+ {
+ problems.append(
+ "\(workspaceLabel).appToFocusBundleId = \(focus) is neither one of its apps nor a shared app, "
+ + "so it has no effect"
+ )
+ } else if
+ let focus = workspace["appToFocusBundleId"]?.stringValue,
+ !bundleIdentifiers.contains(focus)
+ {
+ warnings.append(
+ "\(workspaceLabel).appToFocusBundleId pins the shared app \(focus); Tatami honours it at "
+ + "activation, but its Focus app picker only lists the workspace's own apps and "
+ + "duplicating the workspace clears the pin"
+ )
+ }
+ }
+
+ checkChains(
+ profile["workspaceChains"],
+ label: label,
+ workspaceIDs: workspaceIDsInProfile,
+ scratchpadIDs: scratchpadIDs,
+ problems: &problems
+ )
+ }
+ }
+
+ private static func checkAutoActivation(
+ _ activation: TomlValue?,
+ label: String,
+ problems: inout [String],
+ warnings: inout [String]
+ ) {
+ guard let activation, let table = activation.tableValue else { return }
+
+ if let count = table["displayCount"] {
+ guard let raw = count.stringValue else {
+ // Decoded with `try?` against a String: an integer is dropped without a
+ // trace and the rule quietly becomes "no display-count condition".
+ problems.append("\(label).autoActivation.displayCount must be a STRING like \"==1\" or \">=2\"")
+ return
+ }
+ let pattern = try? NSRegularExpression(pattern: "^\\s*(==|>=|<=)?\\s*\\d+\\s*$")
+ let range = NSRange(raw.startIndex..=N\", \"<=N\" or \"N\"")
+ }
+ }
+
+ if let match = table["whenConnectedMatch"]?.stringValue, match != "exactly", match != "contains" {
+ // Only the literal "exactly" is special; anything else means "contains",
+ // including a typo of "exactly".
+ problems.append("\(label).autoActivation.whenConnectedMatch = \"\(match)\" silently means \"contains\"")
+ }
+
+ if table["whenConnected"] != nil || table["whenDisconnected"] != nil {
+ warnings.append("\(label).autoActivation names specific displays and will not match on another machine or in a VM")
+ }
+ }
+
+ private static func checkChains(
+ _ chains: TomlValue?,
+ label: String,
+ workspaceIDs: Set,
+ scratchpadIDs: Set,
+ problems: inout [String]
+ ) {
+ guard let rows = chains?.tableArray else { return }
+ var chainIDs = Set()
+ var claimed = [String: String]()
+
+ for (index, chain) in rows.enumerated() {
+ let name = chain["name"]?.stringValue ?? "workspaceChains[\(index)]"
+ let chainLabel = "\(label) / chain \"\(name)\""
+
+ guard let identifier = chain["id"]?.stringValue, UUID(uuidString: identifier) != nil else {
+ problems.append("\(chainLabel) has a missing or malformed id")
+ continue
+ }
+ if !chainIDs.insert(identifier).inserted { problems.append("\(chainLabel) reuses a chain id") }
+
+ guard let members = chain["workspaceIds"]?.stringArray else {
+ problems.append("\(chainLabel).workspaceIds must be an array of workspace ids")
+ continue
+ }
+ // Any of these makes Tatami skip the entire chain at run time — silently,
+ // as far as a script can tell.
+ if members.count < 2 { problems.append("\(chainLabel) needs at least two members") }
+ if Set(members).count != members.count { problems.append("\(chainLabel) lists a workspace twice") }
+
+ for member in members {
+ if !workspaceIDs.contains(member) {
+ problems.append("\(chainLabel) references \(member), which is not a workspace in this profile")
+ }
+ if scratchpadIDs.contains(member) {
+ problems.append("\(chainLabel) includes a scratchpad, which chains do not accept")
+ }
+ if let other = claimed.updateValue(name, forKey: member), other != name {
+ problems.append("\(label): workspace \(member) is in both \"\(other)\" and \"\(name)\"; one chain each")
+ }
+ }
+
+ if let dynamic = chain["dynamicWorkspaceIds"]?.stringArray {
+ if Set(dynamic).count != dynamic.count { problems.append("\(chainLabel).dynamicWorkspaceIds has duplicates") }
+ for entry in dynamic where !members.contains(entry) {
+ problems.append("\(chainLabel).dynamicWorkspaceIds contains \(entry), which is not one of its members")
+ }
+ }
+ }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/DefaultsGuard.swift b/DemoLab/Sources/DemoCtlKit/DefaultsGuard.swift
new file mode 100644
index 0000000..6f7aa7c
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/DefaultsGuard.swift
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - DefaultsGuard
+
+/// The one piece of state the lab cannot isolate.
+///
+/// `--tatami-config-home` moves `config.toml`, `layouts.json`,
+/// `profile-session.json`, and the log into the lab's own directory, but
+/// `UserDefaults` is keyed by bundle identifier, so the lab's Tatami and a real
+/// installed Tatami share one preferences domain. Three windows live in there
+/// that would ruin a take:
+///
+/// - **Guided Setup** opens when `onboarding.resumeAfterRelaunch` is true, or
+/// when onboarding was never completed and the active profile is empty. The
+/// `resumeAfterRelaunch` branch short-circuits the others, so a leftover
+/// `true` reopens it even on a fully configured install.
+/// - **What's New** floats above everything and activates the app when the
+/// stored version differs from the running one.
+/// - **Any window AppKit restored from the last session**, Settings above all.
+/// Window restoration state is stored per bundle id, not per config home, so
+/// an operator who last used Settings gets Settings back on camera.
+///
+/// Rather than write those keys and leave them changed, the lab snapshots the
+/// whole domain first and can put it back exactly. The snapshot lives outside
+/// ``LabPaths/labRoot`` so `democtl reset` cannot destroy it between takes; see
+/// ``LabPaths/defaultsSnapshotRoot``.
+public struct DefaultsGuard: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(domain: String, snapshotDirectory: URL) {
+ self.domain = domain
+ self.snapshotDirectory = snapshotDirectory
+ }
+
+ // MARK: Public
+
+ public let domain: String
+ public let snapshotDirectory: URL
+
+ /// The exported domain. `defaults export` writes a plist, and it records only
+ /// the key/value pairs — never which domain they came from.
+ public static func backupURL(in directory: URL) -> URL {
+ directory.appendingPathComponent("defaults-backup.plist")
+ }
+
+ /// The missing half of the snapshot: the bundle identifier the export was
+ /// taken from. `defaults import` replaces whichever domain it is handed, and
+ /// this repo ships two bundle ids on purpose (`dev.PangMo5.Tatami` and
+ /// `dev.PangMo5.Tatami.debug`), so a restore that guessed the domain would
+ /// eventually overwrite the wrong Tatami's real preferences.
+ public static func domainStampURL(in directory: URL) -> URL {
+ directory.appendingPathComponent("domain.json")
+ }
+
+ /// The domain a previous ``captureIfNeeded()`` snapshotted, or nil when the
+ /// stamp is absent or unreadable.
+ public static func capturedDomain(in directory: URL) -> String? {
+ guard
+ let data = try? Data(contentsOf: domainStampURL(in: directory)),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let domain = object["domain"] as? String,
+ !domain.isEmpty
+ else { return nil }
+ return domain
+ }
+
+ public var backup: URL { Self.backupURL(in: snapshotDirectory) }
+ public var domainStamp: URL { Self.domainStampURL(in: snapshotDirectory) }
+
+ public var hasBackup: Bool { FileManager.default.fileExists(atPath: backup.path) }
+
+ /// Snapshots the domain, once. A second call is a no-op so `democtl seed`
+ /// stays idempotent and never overwrites the pristine snapshot with one taken
+ /// mid-demo — and, because the snapshot outlives `democtl reset`, that holds
+ /// across a whole shoot rather than only within one lab lifetime.
+ ///
+ /// An existing snapshot from a *different* bundle id is an error rather than a
+ /// no-op: it means an earlier shoot against another Tatami was never restored,
+ /// and suppressing this domain too would leave it with nothing to restore from.
+ public func captureIfNeeded() throws {
+ if hasBackup {
+ guard let captured = Self.capturedDomain(in: snapshotDirectory) else {
+ throw DemoCtlError.defaultsSnapshotUnstamped(backup: backup.path, stamp: domainStamp.path)
+ }
+ guard captured == domain else {
+ throw DemoCtlError.defaultsSnapshotForeign(
+ captured: captured,
+ requested: domain,
+ backup: backup.path
+ )
+ }
+ return
+ }
+ try FileManager.default.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true)
+ try Shell.require(Self.defaultsTool, ["export", domain, backup.path])
+ // Stamp after the export, so a half-made snapshot is never mistaken for a
+ // complete one: no export means no stamp, and no stamp is a loud failure.
+ let stamp = try JSONSerialization.data(
+ withJSONObject: ["domain": domain],
+ options: [.prettyPrinted, .sortedKeys]
+ )
+ try stamp.write(to: domainStamp, options: .atomic)
+ }
+
+ /// Suppresses Guided Setup, What's New, and window restoration for the given
+ /// app version.
+ ///
+ /// Writes go through the `defaults` tool rather than straight to the plist so
+ /// `cfprefsd` stays coherent; that is also why Tatami must already be stopped
+ /// when this runs, since a live process holds its own defaults image and
+ /// would write it back over these values.
+ public func suppressFirstRunWindows(appVersion: String?) throws {
+ // Start from an empty domain rather than patching keys.
+ //
+ // Setting `ApplePersistenceIgnoreState` and `NSQuitAlwaysKeepsWindows` is
+ // not enough on its own: SwiftUI restores a `Window` scene through its own
+ // state, and a measured take still opened Tatami's Settings window into the
+ // middle of the shot. Patching individual keys also cannot remove state
+ // whose name we do not know. Wiping first is only safe because the whole
+ // domain was exported a moment ago and `democtl restore` puts it back
+ // byte for byte, which is exactly why the snapshot is taken before this.
+ _ = try? Shell.run(Self.defaultsTool, ["delete", domain])
+
+ try Shell.require(
+ Self.defaultsTool,
+ ["write", domain, "onboarding.completedSchemaVersion", "-int", "1"]
+ )
+ if let appVersion {
+ try Shell.require(
+ Self.defaultsTool,
+ ["write", domain, "whatsNew.lastShownVersion", "-string", appVersion]
+ )
+ }
+
+ // Belt and braces on the now-empty domain: these also stop AppKit writing
+ // restoration state back during the session, so a second `seed` in the same
+ // shoot starts from the same place as the first.
+ try Shell.require(Self.defaultsTool, ["write", domain, "ApplePersistenceIgnoreState", "-bool", "true"])
+ try Shell.require(Self.defaultsTool, ["write", domain, "NSQuitAlwaysKeepsWindows", "-bool", "false"])
+ }
+
+ /// Puts the domain back exactly as it was and drops the snapshot.
+ public func restore() throws {
+ guard hasBackup else { return }
+ try Shell.require(Self.defaultsTool, ["import", domain, backup.path])
+ // The import already landed, so this is not a failed restore — but a
+ // snapshot left on disk would be imported again by the next `restore`, over
+ // a domain that is already pristine, so it has to be said out loud.
+ do {
+ try FileManager.default.removeItem(at: snapshotDirectory)
+ } catch {
+ throw DemoCtlError.defaultsSnapshotNotCleared(
+ directory: snapshotDirectory.path,
+ domain: domain,
+ reason: "\(error)"
+ )
+ }
+ }
+
+ // MARK: Private
+
+ private static let defaultsTool = URL(fileURLWithPath: "/usr/bin/defaults")
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/DemoApps.swift b/DemoLab/Sources/DemoCtlKit/DemoApps.swift
new file mode 100644
index 0000000..6ae0b98
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/DemoApps.swift
@@ -0,0 +1,455 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import DemoAppKit
+import Foundation
+
+// MARK: - LabControl
+
+/// Where every lab process listens for control commands, decided once.
+///
+/// The demo apps and the overlay derive their socket path from
+/// `DEMOLAB_CONTROL_DIR`, so `democtl` picks the directory, exports it for
+/// itself, and hands the same value to everything it launches. Two properties
+/// follow from putting it under ``LabPaths/runRoot``: the sockets live with the
+/// rest of the lab state, and `democtl reset` removes them with it instead of
+/// leaving stale sockets behind in the system temporary directory.
+@MainActor
+public enum LabControl {
+
+ // MARK: Public
+
+ /// Not isolated: it is an immutable name, and the failure messages that quote
+ /// it are built wherever an error is printed.
+ public nonisolated static let variable = "DEMOLAB_CONTROL_DIR"
+
+ /// Creates the directory, exports it for this process, and returns the
+ /// environment every launched lab process must be given.
+ ///
+ /// One call site (`democtl`'s path resolution) on purpose: a command that
+ /// exported one directory while launching an app into another would send its
+ /// state changes into the void.
+ @discardableResult
+ public static func adopt(_ paths: LabPaths) throws -> [String: String] {
+ let directory = paths.controlDirectory
+ try requireSocketBudget(directory)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ setenv(variable, directory.path, 1)
+ return environment(paths)
+ }
+
+ public static func environment(_ paths: LabPaths) -> [String: String] {
+ [variable: paths.controlDirectory.path]
+ }
+
+ /// Sends one command and insists on an acknowledgement.
+ ///
+ /// Every failure here is fatal to the caller. A scene that silently skipped a
+ /// state change would film the wrong pixels and nothing would say so, which is
+ /// the one failure this rig must never have.
+ @discardableResult
+ public static func send(_ line: String, to bundleIdentifier: String, named name: String) throws -> String {
+ let socket = DemoControl.socket(for: bundleIdentifier).path
+ guard !NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).isEmpty else {
+ throw DemoControlFailure.notRunning(app: name, socket: socket)
+ }
+ let reply: String
+ do {
+ reply = try DemoControlClient.send(line, to: bundleIdentifier)
+ } catch {
+ throw DemoControlFailure.unreachable(app: name, socket: socket, detail: "\(error)")
+ }
+ guard !reply.isEmpty else {
+ throw DemoControlFailure.unreachable(app: name, socket: socket, detail: "the app answered with nothing")
+ }
+ guard !reply.hasPrefix("err") else {
+ throw DemoControlFailure.rejected(app: name, command: line, reply: reply)
+ }
+ return reply
+ }
+
+ // MARK: Private
+
+ /// `sun_path` is 104 bytes, and a socket path longer than that cannot be
+ /// bound at all. An app reports that on stderr and then runs on without a
+ /// control channel, which surfaces much later as "the app is not answering",
+ /// so the budget is checked where the directory is chosen.
+ private static func requireSocketBudget(_ directory: URL) throws {
+ let leaf = (DemoCatalog.all.map(\.bundleIdentifier) + [DemoCatalog.overlayBundleIdentifier])
+ .map { ($0.split(separator: ".").last.map(String.init) ?? $0).utf8.count }
+ .max() ?? 0
+ // directory + "/" + leaf + ".sock" + the terminating NUL.
+ let needed = directory.path.utf8.count + 1 + leaf + 5 + 1
+ guard needed <= 104 else {
+ throw DemoControlFailure.directoryTooLong(directory: directory.path, needed: needed)
+ }
+ }
+
+}
+
+// MARK: - DemoControlFailure
+
+/// A control-channel failure. Every case aborts whatever asked for it.
+public enum DemoControlFailure: Error, CustomStringConvertible {
+ case notRunning(app: String, socket: String)
+ case unreachable(app: String, socket: String, detail: String)
+ case rejected(app: String, command: String, reply: String)
+ case launchTimedOut(app: String, socket: String, seconds: Double)
+ case directoryTooLong(directory: String, needed: Int)
+ case bundleMissing(app: String, path: String)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .notRunning(let app, let socket):
+ "\(app) is not running, so it cannot be driven (no control socket at \(socket))"
+
+ case .unreachable(let app, let socket, let detail):
+ """
+ \(app) is running but not answering on \(socket): \(detail)
+ An app only listens there when it was launched with \(LabControl.variable) set to that
+ directory. `democtl launch` and `democtl seed` do that; an app opened by hand does not.
+ """
+
+ case .rejected(let app, let command, let reply):
+ "\(app) refused \"\(command)\": \(reply)"
+
+ case .launchTimedOut(let app, let socket, let seconds):
+ """
+ \(app) was launched but never answered on \(socket) within \(String(format: "%.1f", seconds))s.
+ Check that the bundle is the one `democtl build` produced and that \(LabControl.variable)
+ names a writable directory.
+ """
+
+ case .directoryTooLong(let directory, let needed):
+ """
+ \(LabControl.variable) would be \(directory), and a socket in it needs \(needed) bytes of the
+ 104-byte sun_path limit. Move the package somewhere with a shorter path.
+ """
+
+ case .bundleMissing(let app, let path):
+ "\(app).app is missing at \(path); run `democtl build`"
+ }
+ }
+}
+
+// MARK: - DemoAppsController
+
+/// Starts and stops the demo apps.
+///
+/// Launching is always "terminate, then launch": relaunching an app that is
+/// already up is the only way to guarantee its window count, since there is no
+/// way to ask a running app to return to its initial state. That makes
+/// `democtl launch` idempotent in the sense that matters — the same command
+/// always produces the same windows.
+@MainActor
+public struct DemoAppsController {
+
+ // MARK: Lifecycle
+
+ public init(paths: LabPaths) {
+ self.paths = paths
+ }
+
+ // MARK: Public
+
+ public let paths: LabPaths
+
+ public static func running() -> [NSRunningApplication] {
+ let identifiers = Set(DemoCatalog.all.map(\.bundleIdentifier))
+ return NSWorkspace.shared.runningApplications.filter {
+ guard let identifier = $0.bundleIdentifier else { return false }
+ return identifiers.contains(identifier) && Shell.processExists($0.processIdentifier)
+ }
+ }
+
+ public static func isRunning(_ spec: DemoAppSpec) -> Bool {
+ NSRunningApplication.runningApplications(withBundleIdentifier: spec.bundleIdentifier).contains { Shell.processExists($0.processIdentifier) }
+ }
+
+ /// Whether the app answers on its control socket. `isRunning` is not enough:
+ /// an app launched without the lab's `DEMOLAB_CONTROL_DIR` is up on screen and
+ /// deaf to every command.
+ public static func isListening(_ spec: DemoAppSpec) -> Bool {
+ DemoControlClient.isListening(bundleIdentifier: spec.bundleIdentifier)
+ }
+
+ /// Counts an app's ordinary on-screen windows.
+ ///
+ /// `kCGWindowListOptionOnScreenOnly` + a zero window layer is the same shape
+ /// Tatami's own window discovery uses. Window *titles* would need the Screen
+ /// Recording permission, but owner, layer, and bounds do not — and a count is
+ /// all a launch barrier needs.
+ public static func onScreenWindowCount(for spec: DemoAppSpec) -> Int {
+ let processIDs = Set(
+ NSRunningApplication.runningApplications(withBundleIdentifier: spec.bundleIdentifier)
+ .map(\.processIdentifier)
+ )
+ guard !processIDs.isEmpty else { return 0 }
+ let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
+ guard let entries = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
+ return 0
+ }
+ return entries.filter { entry in
+ guard let owner = entry[kCGWindowOwnerPID as String] as? pid_t, processIDs.contains(owner) else {
+ return false
+ }
+ let layer = entry[kCGWindowLayer as String] as? Int ?? 0
+ guard layer == 0 else { return false }
+ guard let bounds = entry[kCGWindowBounds as String] as? [String: Any],
+ let width = bounds["Width"] as? Double, let height = bounds["Height"] as? Double
+ else { return false }
+ // Filter out the zero-size helper surfaces AppKit sometimes keeps around.
+ return width > 40 && height > 40
+ }.count
+ }
+
+ public func missingBundles(_ specs: [DemoAppSpec]) -> [String] {
+ specs
+ .filter { !FileManager.default.fileExists(atPath: paths.appBundle($0.name).path) }
+ .map(\.name)
+ }
+
+ /// Terminates the named apps and waits for them to actually be gone.
+ @discardableResult
+ public func quit(_ specs: [DemoAppSpec], timeout: Duration = .seconds(8)) -> [String] {
+ let identifiers = Set(specs.map(\.bundleIdentifier))
+ let victims = Self.running().filter { identifiers.contains($0.bundleIdentifier ?? "") }
+ guard !victims.isEmpty else { return [] }
+ for application in victims { application.terminate() }
+
+ let stopped = Shell.wait(timeout: timeout) {
+ Self.running().allSatisfy { !identifiers.contains($0.bundleIdentifier ?? "") }
+ }
+ if !stopped {
+ for application in Self.running() where identifiers.contains(application.bundleIdentifier ?? "") {
+ application.forceTerminate()
+ }
+ _ = Shell.wait(timeout: .seconds(4)) {
+ Self.running().allSatisfy { !identifiers.contains($0.bundleIdentifier ?? "") }
+ }
+ }
+ return victims.compactMap(\.bundleIdentifier)
+ }
+
+ /// Quits every demo app, and nothing else.
+ ///
+ /// Deliberately **not** the overlay. A scene's `quitApps` step uses this to
+ /// clear the stage between chapters, and the narration has to survive that:
+ /// killing it mid-take leaves every later `caption` step failing against an
+ /// app that is gone.
+ @discardableResult
+ public func quitAll(timeout: Duration = .seconds(8)) -> [String] {
+ quit(DemoCatalog.all, timeout: timeout)
+ }
+
+ /// Quits the demo apps *and* the narration overlay, for tearing a session
+ /// down rather than clearing the stage inside one. An overlay left running
+ /// would caption the next take with the last one's words.
+ @discardableResult
+ public func quitAllIncludingOverlay(timeout: Duration = .seconds(8)) -> [String] {
+ quitAll(timeout: timeout) + OverlayController(paths: paths).quit()
+ }
+
+ /// Sends one command to a demo app and returns its reply.
+ @discardableResult
+ public func send(_ line: String, to spec: DemoAppSpec) throws -> String {
+ try LabControl.send(line, to: spec.bundleIdentifier, named: spec.name)
+ }
+
+ /// Sets the number of real native windows for off-camera preparation.
+ @discardableResult
+ public func setWindowCount(_ count: Int, of spec: DemoAppSpec) throws -> String {
+ guard (0...8).contains(count) else {
+ throw DemoCtlError.usage("\(spec.name): windows must be 0...8, got \(count)")
+ }
+ return try send("windows \(count)", to: spec)
+ }
+
+ /// Launches apps one at a time, in the given order, waiting for each one's
+ /// windows before starting the next.
+ ///
+ /// Order matters on camera: Tatami inserts each new window at the shallowest
+ /// tile, so a fixed launch order is what makes the resulting BSP layout the
+ /// same in every take. Launching them concurrently would race.
+ public func launch(
+ _ specs: [DemoAppSpec],
+ windowCounts: [DemoAppID: Int] = [:],
+ settle: Duration = .milliseconds(350),
+ timeout: Duration = .seconds(15)
+ ) throws {
+ let missing = missingBundles(specs)
+ guard missing.isEmpty else { throw DemoCtlError.bundlesMissing(missing) }
+
+ // Relaunch rather than reuse: a running app cannot be reset to its initial
+ // window count.
+ quit(specs)
+
+ for spec in specs {
+ let bundle = paths.appBundle(spec.name)
+ var arguments = ["-n", "-a", bundle.path, "--args"]
+ let requested = windowCounts[spec.id] ?? spec.defaultWindowCount
+ arguments += ["--windows", String(requested)]
+ // The control directory is handed over at launch because it cannot be
+ // passed any other way: Tatami's `autoOpen` starts these apps with no argv
+ // at all, so the address is derived from the environment instead.
+ try Shell.require(
+ URL(fileURLWithPath: "/usr/bin/open"),
+ arguments,
+ environment: LabControl.environment(paths)
+ )
+
+ let expected = max(requested, 0)
+ if expected > 0 {
+ let appeared = Shell.wait(timeout: timeout) {
+ Self.onScreenWindowCount(for: spec) >= expected
+ }
+ guard appeared else {
+ throw DemoCtlError.waitTimedOut(
+ what: "\(spec.name) to show \(expected) window(s)",
+ seconds: 15
+ )
+ }
+ }
+ // The control channel is the barrier every later activation step depends
+ // on, and its absence is invisible until one of them fails mid-take.
+ let control = Duration.seconds(8)
+ let answered = Shell.wait(timeout: control) { Self.isListening(spec) }
+ guard answered else {
+ throw DemoControlFailure.launchTimedOut(
+ app: spec.name,
+ socket: DemoControl.socket(for: spec.bundleIdentifier).path,
+ seconds: Shell.seconds(control)
+ )
+ }
+ // A short settle after the window appears lets Tatami's AX observers see
+ // it and finish tiling before the next app lands on top of the work.
+ Shell.sleep(settle)
+ }
+ }
+
+}
+
+// MARK: - OverlayController
+
+/// Starts, stops and drives the narration overlay.
+///
+/// The overlay is not a demo app. It is never assigned to a workspace, never
+/// tiled, and never takes focus, so it is addressed by bundle identifier rather
+/// than by a catalog spec and lives outside ``DemoAppsController``'s app set.
+/// What it shares with the demo apps is the control channel: every command is
+/// absolute and acknowledged, so a scene can put a caption on screen and know it
+/// is there before the next step runs.
+@MainActor
+public struct OverlayController {
+
+ // MARK: Lifecycle
+
+ public init(paths: LabPaths) {
+ self.paths = paths
+ }
+
+ // MARK: Public
+
+ /// The bundle is `DemoOverlay.app`; the identifier's last component, and so
+ /// the socket's leaf, is `Overlay`.
+ public static let bundleName = "DemoOverlay"
+
+ public let paths: LabPaths
+
+ public static var socketPath: String {
+ DemoControl.socket(for: DemoCatalog.overlayBundleIdentifier).path
+ }
+
+ /// Whether the overlay answers on its control socket, which is the only state
+ /// worth testing: a running overlay that cannot be driven shows nothing.
+ public static var isListening: Bool {
+ DemoControlClient.isListening(bundleIdentifier: DemoCatalog.overlayBundleIdentifier)
+ }
+
+ public var bundle: URL { paths.appBundle(Self.bundleName) }
+
+ public var bundleExists: Bool { FileManager.default.fileExists(atPath: bundle.path) }
+
+ public static func running() -> [NSRunningApplication] {
+ NSRunningApplication.runningApplications(withBundleIdentifier: DemoCatalog.overlayBundleIdentifier).filter { Shell.processExists($0.processIdentifier) }
+ }
+
+ /// Launches the overlay and waits for it to answer.
+ ///
+ /// `open` rather than the executable so LaunchServices reads the bundle's
+ /// `LSUIElement`: as an accessory app the overlay never takes focus, never
+ /// appears in the Dock or the app switcher, and so cannot steal the keystroke
+ /// a scene is about to send to Tatami.
+ public func launch(timeout: Duration = .seconds(15)) throws {
+ guard bundleExists else {
+ throw DemoControlFailure.bundleMissing(app: Self.bundleName, path: bundle.path)
+ }
+ // Relaunch rather than reuse: an overlay left over from an earlier run still
+ // holds that run's caption, and it bound its socket in that run's control
+ // directory.
+ quit()
+ try Shell.require(
+ URL(fileURLWithPath: "/usr/bin/open"),
+ ["-n", "-a", bundle.path],
+ environment: LabControl.environment(paths)
+ )
+ let answered = Shell.wait(timeout: timeout) { Self.isListening }
+ guard answered else {
+ throw DemoControlFailure.launchTimedOut(
+ app: "Overlay",
+ socket: Self.socketPath,
+ seconds: Shell.seconds(timeout)
+ )
+ }
+ // Every take starts on a blank layer.
+ try clear()
+ }
+
+ @discardableResult
+ public func quit(timeout: Duration = .seconds(6)) -> [String] {
+ let victims = Self.running()
+ guard !victims.isEmpty else { return [] }
+ for application in victims { application.terminate() }
+ let stopped = Shell.wait(timeout: timeout) { Self.running().isEmpty }
+ if !stopped {
+ for application in Self.running() { application.forceTerminate() }
+ _ = Shell.wait(timeout: .seconds(3)) { Self.running().isEmpty }
+ }
+ return victims.compactMap(\.bundleIdentifier)
+ }
+
+ @discardableResult
+ public func send(_ line: String) throws -> String {
+ try LabControl.send(line, to: DemoCatalog.overlayBundleIdentifier, named: "Overlay")
+ }
+
+ /// The small pill at the top of the screen.
+ @discardableResult
+ public func chapter(_ text: String) throws -> String {
+ try send("chapter \(text)")
+ }
+
+ /// The card at the bottom. Everything after `" | "` is the "why" line, and an
+ /// empty text clears the card.
+ @discardableResult
+ public func caption(_ text: String) throws -> String {
+ try send("caption \(text)")
+ }
+
+ /// The keycaps at the bottom right, written in Tatami's own shortcut spelling
+ /// (`"ctrl + alt + shift - 2"`). An empty chord clears them.
+ @discardableResult
+ public func keys(_ chord: String) throws -> String {
+ try send("keys \(chord)")
+ }
+
+ @discardableResult
+ public func clear() throws -> String {
+ try send("clear")
+ }
+
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/DemoCtl.swift b/DemoLab/Sources/DemoCtlKit/DemoCtl.swift
new file mode 100644
index 0000000..b255d34
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/DemoCtl.swift
@@ -0,0 +1,1317 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import CoreGraphics
+import DemoAppKit
+import DemoDriverKit
+import Foundation
+import CryptoKit
+
+// MARK: - DemoCtl
+
+/// The Demo Lab's command surface.
+///
+/// Argument parsing is hand-rolled because the package deliberately has zero
+/// external dependencies: `swift build` has to work offline inside a freshly
+/// cloned VM that has only the Command Line Tools.
+@MainActor
+public enum DemoCtl {
+
+ // MARK: Public
+
+ /// App sets that mirror the workspaces in `config/tatami-demo.toml.in`.
+ /// Workspace apps have `autoOpen = true`, so activating a workspace already
+ /// opens them; these sets exist for staging a shot by hand.
+ public static let appSets: [String: [DemoAppID]] = [
+ "code": [.editor, .terminal, .docs],
+ "review": [.review, .docs],
+ "chat": [.chat],
+ "docs": [.docs],
+ "notes": [.notes],
+ "monitor": [.monitor],
+ "all": DemoAppID.allCases,
+ ]
+
+ public static func main() -> Int32 {
+ var arguments = Array(CommandLine.arguments.dropFirst())
+ guard let command = arguments.first else {
+ print(usage)
+ return 2
+ }
+ arguments.removeFirst()
+
+ do {
+ switch command {
+ case "help", "--help", "-h": print(usage)
+ case "catalog":
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ print(String(decoding: try encoder.encode(DemoCatalog.bundles), as: UTF8.self))
+ case "build": try build(arguments)
+ case "inspect":
+ let paths = try resolvePaths()
+ let name = arguments.first ?? "Tatami"
+ let install = try resolveInstall(explicit: nil)
+ let bundle = name == "Tatami" ? install.bundleIdentifier : DemoCatalog.spec(named:name)?.bundleIdentifier
+ guard let bundle else {throw DemoCtlError.usage("unknown app")}
+ _ = paths
+ print(try NativeInteractionDriver.snapshot(bundleIdentifier:bundle))
+ case "display":
+ let paths = try resolvePaths()
+ if arguments.first == "connect" {try VirtualDisplayController.connect(paths)}
+ else if arguments.first == "disconnect" {try VirtualDisplayController.disconnect(paths)}
+ else {throw DemoCtlError.usage("display connect|disconnect")}
+ case "doctor": try doctor(arguments)
+ case "displays": try displays()
+ case "reset": try reset(arguments)
+ case "seed": try seed(arguments)
+ case "launch": try launch(arguments)
+ case "app": try app(arguments)
+ case "overlay": try overlay(arguments)
+ case "quit": try quit()
+ case "scene": try scene(arguments)
+ case "take": try take(arguments)
+ case "record": try record(arguments)
+ case "subtitle": try subtitle(arguments)
+ case "events": try events(arguments)
+ case "config": try config(arguments)
+ case "restore": try restore(arguments)
+ default:
+ FileHandle.standardError.write(Data("unknown command \"\(command)\"\n\n".utf8))
+ print(usage)
+ return 2
+ }
+ return 0
+ } catch let error as DemoCtlError {
+ FileHandle.standardError.write(Data("democtl: \(error.description)\n".utf8))
+ return error.isUsage ? 2 : 1
+ } catch {
+ FileHandle.standardError.write(Data("democtl: \(error)\n".utf8))
+ return 1
+ }
+ }
+
+ // MARK: Private
+
+ private static let usage = """
+ democtl — Tatami Demo Lab control
+
+ Setup
+ catalog Print bundle metadata from the shared app catalog as JSON
+ build Build every demo app, the recorder and the tools into .build/DemoLab
+ doctor Check everything that must be true before a take
+ displays Print each screen's Tatami display-hint string
+
+ Run
+ reset [--restore-defaults] [--tatami-app ]
+ Quit the demo apps and Tatami, then delete all lab state
+ seed [options] Render the config, start an isolated Tatami, verify it round-trips
+ launch Launch an app set (\(appSets.keys.sorted().joined(separator: ", ")))
+ app windows Open or close that app's windows until it has n
+ app --list Every app, its role, and whether it answers
+ overlay caption Narrate: " | ", or empty text to clear the card
+ overlay chapter The pill at the top of the screen, or empty text to clear it
+ overlay keys Keycaps for an action that was not a keystroke
+ overlay mode all|keys|off How much of the narration layer is drawn at all
+ overlay clear Wipe the whole narration layer
+ quit Quit the demo apps, the overlay, and the lab's Tatami
+
+ Record
+ scene [--dry-run] [--overlay full|keys|off] [--keycast-hold ]
+ Play a scene
+ take [options] Record a scene end to end and write the .mov plus its sidecars
+ record start|stop|status Drive the recorder on its own
+ record warmup [--seconds N] Throw away a short recording so the screen-capture consent panel,
+ which macOS 15+ re-shows periodically, appears now and not mid-take
+ record preflight Report whether DemoRecorder holds Screen Recording access
+ subtitle burn [--ass ] [--output ]
+ Burn a take's .ass sidecar into a copy of the movie. Needs an
+ ffmpeg with libass, or mpv. Neither is in the recording VM, so
+ this is a host-side step after tatami-tools vm-fetch-recordings
+ events [--all] Print the Tatami lifecycle events the hooks logged
+
+ Inspect
+ config [--print] Show or validate the rendered config
+ restore [--tatami-app ]
+ Put Tatami's UserDefaults domain back as it was before `seed`
+
+ seed options
+ --pulse floating|unmanaged|tiled Shared Pulse as Always on Top (default), Leave As Is, or Tiled
+ --borrow-edge top|bottom|left|right
+ --focus-follows-mouse Turn FFM on (off by default: it is hard to film)
+ --no-mouse-follows-focus Turn MFF off (on by default)
+ --debug-logging Ask Tatami to write its debug log into the lab directory
+ --no-launch Render and verify the config without starting Tatami
+
+ take options
+ --output --display --fps --keep-running
+ --display takes an index from `democtl displays` (0-based, the same index a
+ scene `pointer` step uses), or `main`, or `all` for one file per display
+ --overlay full|keys|off How much narration is drawn into the frame. Default `off` for a
+ take: every word and keycap goes to .ass instead, where a
+ capture stream cannot drop it and an editor can restyle it.
+ Default `full` for `scene`, which nobody is recording
+ --keycast-hold How long the keycaps are held, and so how long they run in the
+ sidecar (default \(SceneRunner.defaultKeycastHoldMilliseconds))
+
+ Every take writes two sidecars next to the .mov, with the same basename:
+ .ass the subtitles, restyle or translate them and burn them in later
+ .timeline.json every narration event with its start and end, for any other tool
+ They are written even when the scene fails partway, so a failed take is still diagnosable.
+
+ Global
+ --tatami-app Use a specific Tatami.app (or set DEMOLAB_TATAMI_APP)
+ """
+
+ private static func resolvePaths() throws -> LabPaths {
+ guard let root = LabPaths.discoverPackageRoot() else { throw DemoCtlError.packageRootNotFound }
+ let paths = LabPaths(packageRoot: root)
+ // Every command goes through here, which is exactly why the control
+ // directory is decided here: `democtl` and everything it launches then read
+ // the same `DEMOLAB_CONTROL_DIR`, and the sockets are removed by `reset`
+ // with the rest of the lab state.
+ try LabControl.adopt(paths)
+ return paths
+ }
+
+ private static func resolveInstall(_ arguments: inout [String]) throws -> TatamiInstall {
+ try resolveInstall(explicit: takeOption(&arguments, "--tatami-app"))
+ }
+
+ private static func resolveInstall(explicit: String?) throws -> TatamiInstall {
+ let install = try TatamiInstall.locate(explicit: explicit)
+ try install.validate()
+ return install
+ }
+
+ private static func takeOption(_ arguments: inout [String], _ name: String) throws -> String? {
+ guard let index = arguments.firstIndex(of: name) else { return nil }
+ arguments.remove(at: index)
+ // A missing value used to be reported as "option absent", and `rejectUnknown`
+ // could not catch it because the token was already gone. A recording
+ // parameter that quietly reverts to its default is the one failure this rig
+ // must never have.
+ guard index < arguments.count else {
+ throw DemoCtlError.usage("\(name) requires a value")
+ }
+ let value = arguments.remove(at: index)
+ // No option here takes a value that looks like a flag, and swallowing the
+ // next flag as a value is the same silent failure in a different shape.
+ guard !value.hasPrefix("--") else {
+ throw DemoCtlError.usage("\(name) requires a value, got the option \"\(value)\"")
+ }
+ return value
+ }
+
+ /// Resolves `--display` to the `id:` form the recorder
+ /// takes.
+ ///
+ /// Two different display enumerations exist and they do not agree: everything
+ /// `democtl` prints (`displays`, and a scene's `pointer` step) is 0-based over
+ /// `NSScreen`s sorted left to right, the same order Tatami uses, while the
+ /// recorder's own index is 1-based over whatever order ScreenCaptureKit hands
+ /// back. Passing a bare number through would silently record a different
+ /// screen than the one the operator picked — and on a two-display take, the
+ /// screen the action is not happening on. Resolving to the display's stable
+ /// identifier here removes the ambiguity instead of documenting it.
+ private static func displayOption(_ arguments: inout [String]) throws -> String {
+ guard let value = try takeOption(&arguments, "--display") else { return "main" }
+ if value == "main" || value == "all" { return value }
+ if value.hasPrefix("id:") { return value }
+
+ let displays = DisplayInfo.all()
+ guard let index = Int(value), displays.indices.contains(index) else {
+ let known = displays
+ .map { "\($0.index) = \($0.name)" }
+ .joined(separator: ", ")
+ throw DemoCtlError.usage(
+ "--display expects \"main\", \"all\", or a display index from `democtl displays` "
+ + "(\(known.isEmpty ? "none detected" : known)), got \"\(value)\""
+ )
+ }
+ guard displays[index].displayID != 0 else {
+ throw DemoCtlError.usage("display \(index) reports no CGDirectDisplayID and cannot be recorded")
+ }
+ return "id:\(displays[index].displayID)"
+ }
+
+ /// Mirrors `RecorderCommand.parseInt`'s 1...240 bound, so a bad rate is a
+ /// usage error here instead of a 20s wait for a recorder that already exited.
+ private static func fpsOption(_ arguments: inout [String]) throws -> Int {
+ guard let raw = try takeOption(&arguments, "--fps") else { return 60 }
+ guard let fps = Int(raw), (1...240).contains(fps) else {
+ throw DemoCtlError.usage("--fps expects an integer in 1...240, got \"\(raw)\"")
+ }
+ return fps
+ }
+
+ /// Resolves `--overlay`.
+ ///
+ /// The default differs by command and that is the point: `scene` is played to
+ /// be watched, so it draws everything, while `take` is played to be recorded
+ /// and its captions live in the sidecar instead.
+ private static func overlayModeOption(
+ _ arguments: inout [String],
+ default fallback: OverlayMode
+ ) throws -> OverlayMode {
+ guard let raw = try takeOption(&arguments, "--overlay") else { return fallback }
+ guard let mode = OverlayMode(rawValue: raw) else {
+ throw DemoCtlError.usage(
+ "--overlay expects \(OverlayMode.allCases.map(\.rawValue).joined(separator: ", ")), got \"\(raw)\""
+ )
+ }
+ return mode
+ }
+
+ /// How long the keycaps stay up after a keystroke. Zero is allowed and clears
+ /// them the instant the key is posted, which is what they used to do.
+ private static func keycastHoldOption(_ arguments: inout [String]) throws -> Int {
+ guard let raw = try takeOption(&arguments, "--keycast-hold") else {
+ return SceneRunner.defaultKeycastHoldMilliseconds
+ }
+ guard let milliseconds = Int(raw), (0...10_000).contains(milliseconds) else {
+ throw DemoCtlError.usage("--keycast-hold expects an integer in 0...10000, got \"\(raw)\"")
+ }
+ return milliseconds
+ }
+
+ private static func takeFlag(_ arguments: inout [String], _ name: String) -> Bool {
+ guard let index = arguments.firstIndex(of: name) else { return false }
+ arguments.remove(at: index)
+ return true
+ }
+
+ private static func rejectUnknown(_ arguments: [String], command: String) throws {
+ let unknown = arguments.filter { $0.hasPrefix("--") }
+ guard unknown.isEmpty else {
+ throw DemoCtlError.usage("\(command): unknown option(s) \(unknown.joined(separator: ", "))")
+ }
+ }
+
+ // MARK: - Commands
+
+ private static func build(_ arguments: [String]) throws {
+ let paths = try resolvePaths()
+ let executable = paths.automationExecutable
+ guard FileManager.default.isExecutableFile(atPath: executable.path) else {
+ throw DemoCtlError.usage("Run swift run --package-path Tools tatami-tools bundle-apps in the checkout first")
+ }
+ let result = try Shell.run(executable, ["bundle-apps", "--root", paths.packageRoot.deletingLastPathComponent().path] + arguments, currentDirectory: paths.packageRoot)
+ print(result.standardOutput, terminator: "")
+ if !result.standardError.isEmpty { FileHandle.standardError.write(Data(result.standardError.utf8)) }
+ guard result.succeeded else { throw DemoCtlError.usage("tatami-tools bundle-apps failed with exit \(result.status)") }
+ }
+
+ private static func doctor(_ arguments: [String]) throws {
+ var arguments = arguments
+ let paths = try resolvePaths()
+ // The option is taken out here rather than inside `resolveInstall` so a
+ // missing value stays a usage error; only the *lookup* is allowed to fail
+ // quietly, because reporting that is what doctor is for.
+ let explicit = try takeOption(&arguments, "--tatami-app")
+ let install = try? resolveInstall(explicit: explicit)
+ let checks = Doctor(paths: paths, install: install).run()
+ for check in checks {
+ let detail = check.detail.isEmpty ? "" : " \(check.detail)"
+ print("[\(check.status.marker)] \(check.title)\(detail)")
+ }
+ if checks.contains(where: { $0.status == .fail }) {
+ throw DemoCtlError.usage("doctor found blocking problems (see FAIL rows above)")
+ }
+ }
+
+ private static func displays() throws {
+ let displays = DisplayInfo.all()
+ print("Tatami identifies a display as \"::\". Paste one of these into a")
+ print("workspace's `displayHint` to pin it. The shipped config deliberately pins nothing.")
+ print("")
+ for display in displays {
+ print("[\(display.index)] \(display.displayHint)")
+ print(
+ " \(Int(display.frame.width))x\(Int(display.frame.height)) at "
+ + "(\(Int(display.frame.minX)), \(Int(display.frame.minY))) scale \(display.scale)x"
+ )
+ }
+ }
+
+ /// The guard for an existing defaults snapshot, or nil when there is nothing
+ /// to restore.
+ ///
+ /// The domain comes from the stamp `seed` wrote, never from `locate()`:
+ /// `defaults import` replaces whichever domain it is handed, this repo ships
+ /// two bundle ids on purpose, and a second source of truth for "which Tatami"
+ /// is how a snapshot ends up in the wrong one. `--tatami-app` is accepted only
+ /// as a cross-check.
+ private static func restoreGuard(paths: LabPaths, explicit: String?) throws -> DefaultsGuard? {
+ let directory = paths.defaultsSnapshotRoot
+ let backup = DefaultsGuard.backupURL(in: directory)
+ guard FileManager.default.fileExists(atPath: backup.path) else { return nil }
+ guard let domain = DefaultsGuard.capturedDomain(in: directory) else {
+ throw DemoCtlError.defaultsSnapshotUnstamped(
+ backup: backup.path,
+ stamp: DefaultsGuard.domainStampURL(in: directory).path
+ )
+ }
+ if let explicit {
+ let install = try TatamiInstall.locate(explicit: explicit)
+ guard install.bundleIdentifier == domain else {
+ throw DemoCtlError.defaultsSnapshotForeign(
+ captured: domain,
+ requested: install.bundleIdentifier,
+ backup: backup.path
+ )
+ }
+ }
+ return DefaultsGuard(domain: domain, snapshotDirectory: directory)
+ }
+
+ private static func reset(_ arguments: [String]) throws {
+ var arguments = arguments
+ let restoreDefaults = takeFlag(&arguments, "--restore-defaults")
+ let explicit = try takeOption(&arguments, "--tatami-app")
+ try rejectUnknown(arguments, command: "reset")
+
+ let paths = try resolvePaths()
+ // Resolved before anything is quit or deleted: a snapshot that cannot be
+ // matched to a domain has to stop the command, not be discovered halfway
+ // through it.
+ var guardian: DefaultsGuard?
+ if restoreDefaults { guardian = try restoreGuard(paths: paths, explicit: explicit) }
+ let apps = DemoAppsController(paths: paths)
+
+ // `quitAll` covers the narration overlay as well as the demo apps: one left
+ // running would caption the next take with the last one's words.
+ let quitApps = apps.quitAllIncludingOverlay()
+ if quitApps.isEmpty { print("demo apps: none running") } else {
+ print("demo apps: quit \(quitApps.count) (\(quitApps.joined(separator: ", ")))")
+ }
+
+ // Every Tatami, not only one the lab started: two instances would both tile
+ // the same windows. Their configuration is untouched — it lives elsewhere.
+ let quitTatami = TatamiProcess.quitAll()
+ print(quitTatami.isEmpty ? "Tatami: none running" : "Tatami: quit \(quitTatami.joined(separator: ", "))")
+
+ if restoreDefaults {
+ if let guardian {
+ try guardian.restore()
+ print("defaults: restored \(guardian.domain)")
+ } else {
+ print("defaults: no snapshot at \(paths.defaultsSnapshotRoot.path), nothing to restore")
+ }
+ } else if let held = DefaultsGuard.capturedDomain(in: paths.defaultsSnapshotRoot) {
+ // The snapshot survives reset on purpose, which also means the suppression
+ // survives it: say so, or a shoot ends with the user's real Tatami still
+ // told that onboarding is done.
+ print("defaults: \(held) is still suppressed; finish the shoot with `democtl restore`")
+ }
+
+ // The whole point of running Tatami against an isolated config home: reset
+ // is one directory removal, and it is idempotent by construction. The
+ // defaults snapshot deliberately lives outside this tree — removing it here
+ // would make the next `seed` snapshot an already-suppressed domain and call
+ // it pristine, and `democtl restore` could never undo the suppression again.
+ if FileManager.default.fileExists(atPath: paths.labRoot.path) {
+ try FileManager.default.removeItem(at: paths.labRoot)
+ print("lab state: removed \(paths.labRoot.path)")
+ } else {
+ print("lab state: already clean")
+ }
+ try paths.ensureRuntimeDirectories()
+ }
+
+ private static func seed(_ arguments: [String]) throws {
+ var arguments = arguments
+ let install = try resolveInstall(&arguments)
+ let paths = try resolvePaths()
+
+ guard FileManager.default.isExecutableFile(atPath: paths.bundlesRoot.appendingPathComponent("bin/demohook").path) else {
+ throw DemoCtlError.bundlesMissing(["demohook"])
+ }
+ var options = SeedOptions()
+ if let pulse = try takeOption(&arguments, "--pulse") { options.pulseLayout = pulse }
+ if let edge = try takeOption(&arguments, "--borrow-edge") { options.borrowEdge = edge }
+ if takeFlag(&arguments, "--focus-follows-mouse") { options.focusFollowsMouse = true }
+ if takeFlag(&arguments, "--no-mouse-follows-focus") { options.mouseFollowsFocus = false }
+ if takeFlag(&arguments, "--debug-logging") { options.debugLogging = true }
+ let skipLaunch = takeFlag(&arguments, "--no-launch")
+ let keepWindow = takeFlag(&arguments, "--keep-window")
+ try rejectUnknown(arguments, command: "seed")
+ options = try options.validated()
+
+ // Tatami must be stopped before its defaults domain is touched: a live
+ // process keeps its own image of that domain and writes it back on change.
+ TatamiProcess.quitAll()
+ try paths.ensureRuntimeDirectories()
+
+ let guardian = DefaultsGuard(
+ domain: install.bundleIdentifier,
+ snapshotDirectory: paths.defaultsSnapshotRoot
+ )
+ try guardian.captureIfNeeded()
+ try guardian.suppressFirstRunWindows(appVersion: install.version)
+ for domain in [install.bundleIdentifier] + DemoCatalog.all.map(\.bundleIdentifier) {
+ _ = try Shell.require(URL(fileURLWithPath: "/usr/bin/defaults"), ["write", domain, "AppleLanguages", "-array", DemoLocale.selected.rawValue])
+ }
+ print(
+ "defaults: Guided Setup, What's New and window restoration suppressed for "
+ + "\(guardian.domain) (`democtl restore` puts them back)"
+ )
+
+ let renderer = ConfigRenderer(paths: paths)
+ try StoryRepository(file: paths.controlDirectory.appendingPathComponent("launch-story.json")).write(LaunchStory())
+ let cliContext = DemoCLIContext(executable: install.cli.path, socket: paths.socketPath.path,
+ automationExecutable: paths.automationExecutable.path, config: paths.configFile.path)
+ try JSONEncoder().encode(cliContext).write(to: DemoCLIContext.file, options: .atomic)
+ try renderer.write(options)
+ print("config: wrote \(paths.configFile.path)")
+
+ let events = EventLog(url: paths.eventLog)
+ try events.reset()
+
+ guard !skipLaunch else {
+ print("seed: --no-launch given, Tatami not started")
+ return
+ }
+
+ let mark = events.mark
+ try TatamiProcess.launch(install: install, paths: paths)
+ print("Tatami: launched with an isolated config home and socket")
+
+ let client = TatamiClient(install: install, paths: paths)
+ let answered = Shell.wait(timeout: .seconds(30)) { client.isRunning }
+ guard answered else { throw DemoCtlError.tatamiNotRunning(socket: paths.socketPath.path) }
+
+ // `tatamiLaunched` fires once startup has resolved the active profile, so
+ // it confirms both that the hooks work and that the config was accepted.
+ _ = try events.wait(
+ since: mark,
+ timeout: .seconds(20),
+ describing: "Tatami's tatamiLaunched hook"
+ ) { $0.event == "tatamiLaunched" }
+ print("hooks: tatamiLaunched received — the scene barrier works")
+
+ // Tatami's own window opens itself at launch (its `Window` scene is first,
+ // and macOS 14 has no way to suppress that). Closing it here keeps it out of
+ // every take; `scenes/settings.json` opens it again on purpose.
+ switch keepWindow ? 0 : TatamiProcess.closeOwnWindows() {
+ case -1:
+ print("Tatami window: still open — this process is not trusted for Accessibility, close it by hand")
+ case 0:
+ break
+ case let count:
+ print("Tatami window: closed \(count) (scenes/settings.json reopens it with Command-comma)")
+ }
+
+ // Tatami rewrites config.toml on every launch. Re-validating what is now on
+ // disk is the only way to know it read what we meant: unknown keys and bad
+ // enum spellings are silently defaulted with no warning anywhere.
+ let roundTripped = try String(contentsOf: paths.configFile, encoding: .utf8)
+ let report = ConfigValidator.report(roundTripped)
+ for warning in report.warnings { print("config warning: \(warning)") }
+ guard report.isClean else { throw DemoCtlError.configInvalid(report.problems) }
+
+ let profiles = try client.profiles().compactMap { $0["name"] as? String }
+ let workspaces = try client.workspaceNames()
+ print("profiles: \(profiles.joined(separator: ", "))")
+ print("workspaces (active profile): \(workspaces.joined(separator: ", "))")
+
+ // Last, and only once Tatami is up: the overlay draws at `.screenSaver`
+ // level over windows Tatami has already tiled, and it is what a scene's
+ // captions and keycaps talk to.
+ let overlay = OverlayController(paths: paths)
+ try overlay.launch()
+ print("overlay: answering on \(OverlayController.socketPath)")
+ print("seed: ready")
+ }
+
+ private static func launch(_ arguments: [String]) throws {
+ try rejectUnknown(arguments, command: "launch")
+ guard !arguments.isEmpty else {
+ throw DemoCtlError.usage("launch needs an app set (\(appSets.keys.sorted().joined(separator: ", "))) or app names")
+ }
+
+ let paths = try resolvePaths()
+ var specs = [DemoAppSpec]()
+ for token in arguments {
+ if let set = appSets[token.lowercased()] {
+ specs += set.map { DemoCatalog.spec($0) }
+ } else if let spec = DemoCatalog.spec(named: token) {
+ specs.append(spec)
+ } else {
+ throw DemoCtlError.usage("unknown app set or app \"\(token)\"")
+ }
+ }
+ // Preserve order, drop repeats: launch order decides the resulting BSP tree.
+ var seen = Set()
+ specs = specs.filter { seen.insert($0.id).inserted }
+
+ try DemoAppsController(paths: paths).launch(specs)
+ print("launched: \(specs.map(\.name).joined(separator: ", "))")
+ }
+
+ /// Drives one running demo app over its control channel.
+ ///
+ /// Nothing here is best effort. An app that is not running, or that answers
+ /// `err`, aborts the command with a non-zero exit: a scene that silently
+ /// skipped a state change would film the wrong pixels and say nothing.
+ private static func app(_ arguments: [String]) throws {
+ var arguments = arguments
+ let paths = try resolvePaths()
+
+ let list = takeFlag(&arguments, "--list")
+ try rejectUnknown(arguments, command: "app")
+ guard !list, !arguments.isEmpty else {
+ listApps(paths)
+ return
+ }
+
+ let controller = DemoAppsController(paths: paths)
+ let name = arguments.removeFirst()
+ guard let spec = DemoCatalog.spec(named: name) else {
+ throw DemoCtlError.usage(
+ "unknown demo app \"\(name)\" (\(DemoCatalog.all.map(\.name).joined(separator: ", ")))"
+ )
+ }
+ guard !arguments.isEmpty else {
+ throw DemoCtlError.usage("app \(spec.name) needs an action: windows ")
+ }
+ let action = arguments.removeFirst()
+ guard let raw = arguments.first else {
+ throw DemoCtlError.usage("app \(spec.name) \(action) needs a number")
+ }
+ arguments.removeFirst()
+ guard let value = Int(raw) else {
+ throw DemoCtlError.usage("app \(spec.name) \(action): \"\(raw)\" is not a number")
+ }
+ guard arguments.isEmpty else {
+ throw DemoCtlError.usage("app: unexpected argument \"\(arguments[0])\"")
+ }
+
+ switch action {
+ case "windows": print(try controller.setWindowCount(value, of: spec))
+ default: throw DemoCtlError.usage("app: unknown action \"\(action)\" (windows)")
+ }
+ }
+
+ /// Every app, its role in the workflow, and whether it is
+ /// reachable right now. The control directory is printed first because a whole
+ /// column of "not answering" usually means an app was launched without it.
+ private static func listApps(_ paths: LabPaths) {
+ print("control sockets: \(DemoControl.directory.path)")
+ for spec in DemoCatalog.all {
+ print("")
+ print("\(spec.name) \(spec.bundleIdentifier) [\(status(of: spec))]")
+ print(" \(spec.role)")
+ }
+ print("")
+ let overlayStatus = OverlayController.isListening
+ ? "answering"
+ : (OverlayController.running().isEmpty ? "not running" : "running, not answering")
+ print("Overlay \(DemoCatalog.overlayBundleIdentifier) [\(overlayStatus)] narration only, no states")
+ }
+
+ private static func status(of spec: DemoAppSpec) -> String {
+ if DemoAppsController.isListening(spec) { return "answering" }
+ return DemoAppsController.isRunning(spec) ? "running, not answering" : "not running"
+ }
+
+ /// Drives the narration overlay. Same rule as `app`: a caption that did not
+ /// land is a failure, never a shrug.
+ private static func overlay(_ arguments: [String]) throws {
+ var arguments = arguments
+ let paths = try resolvePaths()
+ let overlay = OverlayController(paths: paths)
+
+ guard let action = arguments.first else {
+ throw DemoCtlError.usage(
+ "overlay needs caption , chapter , keys , mode or clear"
+ )
+ }
+ arguments.removeFirst()
+ // Joined rather than required as one token: an unquoted caption arrives as
+ // several arguments, and refusing it would be a papercut with no upside.
+ let text = arguments.joined(separator: " ")
+
+ switch action {
+ case "caption": print(try overlay.caption(text))
+ case "chapter": print(try overlay.chapter(text))
+ case "keys": print(try overlay.keys(text))
+ // The same command `scene` and `take` send through --overlay, reachable by
+ // hand for staging a shot. The overlay validates the value, so a typo comes
+ // back as a rejection rather than as a silently different mode.
+ case "mode": print(try overlay.send("mode \(text)"))
+ case "clear":
+ guard text.isEmpty else { throw DemoCtlError.usage("overlay clear takes no text") }
+ print(try overlay.clear())
+ default:
+ throw DemoCtlError.usage(
+ "overlay: unknown action \"\(action)\" (caption, chapter, keys, mode or clear)"
+ )
+ }
+ }
+
+ private static func quit() throws {
+ let paths = try resolvePaths()
+ let apps = DemoAppsController(paths: paths).quitAllIncludingOverlay()
+ let tatami = TatamiProcess.quitAll()
+ print("quit demo apps and overlay: \(apps.isEmpty ? "none" : apps.joined(separator: ", "))")
+ print("quit Tatami: \(tatami.isEmpty ? "none" : tatami.joined(separator: ", "))")
+ }
+
+ private static func scene(_ arguments: [String]) throws {
+ var arguments = arguments
+ let install = try resolveInstall(&arguments)
+ let dryRun = takeFlag(&arguments, "--dry-run")
+ // `full`: a scene is played to be watched, live, by whoever is tuning it.
+ // Nothing is being recorded, so there is no sidecar for the captions to
+ // duplicate and every reason to see them.
+ let overlayMode = try overlayModeOption(&arguments, default: .full)
+ let keycastHold = try keycastHoldOption(&arguments)
+ let paths = try resolvePaths()
+
+ if takeFlag(&arguments, "--list") || arguments.isEmpty {
+ print("scenes: \(SceneLoader.available(in: paths).joined(separator: ", "))")
+ guard !arguments.isEmpty else { return }
+ }
+ try rejectUnknown(arguments, command: "scene")
+
+ let name = arguments[0]
+ let scene = try SceneLoader.load(name, paths: paths)
+ let client = TatamiClient(install: install, paths: paths)
+ guard dryRun || client.isRunning else {
+ throw DemoCtlError.tatamiNotRunning(socket: paths.socketPath.path)
+ }
+
+ // No timeline: there is no movie here for a sidecar to sit beside, and an
+ // .ass file with nothing to caption is a file someone would later burn into
+ // the wrong take.
+ let runner = SceneRunner(
+ paths: paths,
+ client: client,
+ apps: DemoAppsController(paths: paths),
+ log: { print($0); fflush(stdout) },
+ overlayMode: overlayMode,
+ keycastHoldMilliseconds: keycastHold
+ )
+ defer {runner.restoreClipboard()}
+ try runner.prepare(scene, dryRun: dryRun)
+ try runner.run(scene, dryRun: dryRun)
+ }
+
+ private static func take(_ arguments: [String]) throws {
+ var arguments = arguments
+ let install = try resolveInstall(&arguments)
+ let paths = try resolvePaths()
+
+ let display = try displayOption(&arguments)
+ let fps = try fpsOption(&arguments)
+ let explicitOutput = try takeOption(&arguments, "--output")
+ let keepRunning = takeFlag(&arguments, "--keep-running")
+ // Keep the original footage clean; all presentation is rendered from sidecars.
+ let overlayMode = try overlayModeOption(&arguments, default: .off)
+ let keycastHold = try keycastHoldOption(&arguments)
+ try rejectUnknown(arguments, command: "take")
+
+ guard let name = arguments.first else { throw DemoCtlError.usage("take needs a scene name") }
+ let sceneData = try Data(contentsOf: paths.sceneFile(name))
+ let scene = try JSONDecoder().decode(Scene.self, from: sceneData)
+
+ let client = TatamiClient(install: install, paths: paths)
+ guard client.isRunning else { throw DemoCtlError.tatamiNotRunning(socket: paths.socketPath.path) }
+
+ let recorder = RecorderController(paths: paths)
+ // The stamp is the only value in the lab that is allowed to vary: it names
+ // the file, and never appears on screen.
+ let stamp = Self.stamp()
+ let output = explicitOutput.map { URL(fileURLWithPath: $0) }
+ ?? recorder.nextOutputURL(scene: name, stamp: stamp)
+
+ guard !FileManager.default.fileExists(atPath: output.path) else {
+ throw DemoCtlError.usage("refusing to overwrite an existing take: \(output.path)")
+ }
+ let preparation = SceneRunner(paths: paths, client: client,
+ apps: DemoAppsController(paths: paths), log: { print($0); fflush(stdout) }, overlayMode: .off)
+ defer {preparation.restoreClipboard()}
+ try preparation.prepare(scene)
+ try recorder.start(output: output, display: display, fps: fps)
+ defer {
+ if recorder.runningPID != nil {
+ do { try recorder.stop() }
+ catch { FileHandle.standardError.write(Data("democtl: recorder cleanup failed: \(error)\n".utf8)) }
+ }
+ }
+ // Sidecar zero is the first encoded frame, not the controller's poll of a PID.
+ let elapsed = try recorder.elapsedSinceFirstFrame()
+ let timeline = SceneTimeline(scene: name, t0: .now.advanced(by: .seconds(-elapsed)))
+ // Announced as a destination, not as a result: `--display all` writes one
+ // file per display, and the real paths only exist once the recorder says so.
+ if display == "all" {
+ print("recording: one file per display, next to \(output.path)")
+ } else {
+ print("recording: \(output.path)")
+ }
+ // A moment of clean screen before the first action still reads better than
+ // cutting in mid-transition, but half of what it was: the reviewer's note
+ // was that a long take opens with dead air, and this is the part of that
+ // dead air no scene file can shorten.
+ let runner = SceneRunner(
+ paths: paths,
+ client: client,
+ apps: DemoAppsController(paths: paths),
+ log: { print($0); fflush(stdout) },
+ timeline: timeline,
+ overlayMode: overlayMode,
+ keycastHoldMilliseconds: keycastHold
+ )
+
+ if scene.captureSecondary == true {
+ runner.secondaryCapture = (output.deletingPathExtension().appendingPathExtension("secondary.mov"), fps)
+ }
+ defer {runner.restoreClipboard()}
+ var sceneFailure: (any Error)?
+ do {
+ try CaptureGate.requireCleanDesktop()
+ try runner.run(scene)
+ Shell.sleep(.milliseconds(200))
+ } catch {
+ sceneFailure = error
+ }
+ // Before the recorder is asked to stop, so the last caption ends with the
+ // last frame rather than with however long the writer takes to drain.
+ do { try runner.finishSecondaryCapture() } catch { if sceneFailure == nil { sceneFailure = error } }
+ timeline.finish()
+
+ var outcome: RecordingOutcome?
+ var stopFailure: (any Error)?
+ do { outcome = try recorder.stop() } catch { stopFailure = error }
+
+ // A failed take gets its sidecars too: they are the record of how far it
+ // got. When the recorder itself failed there is no list of finalized files,
+ // so they go next to the path the take was aiming at.
+ var movies = outcome?.outputs ?? []
+ if movies.isEmpty { movies = [output] }
+
+ let resolution = scriptResolution(display: display, paths: paths)
+ var sidecarFailure: (any Error)?
+ do {
+ for movie in movies {
+ for file in try SceneSidecars.write(
+ timeline,
+ besides: movie,
+ resolution: resolution,
+ drawnLive: overlayMode.liveTracks
+ ) {
+ print("wrote \(file.path)")
+ }
+ }
+ } catch {
+ sidecarFailure = error
+ }
+
+ // The scene failure is the headline, the recorder's is next, and a sidecar
+ // that could not be written is last. Whichever are not thrown are still said
+ // out loud, because a second failure hidden behind the first is how a take
+ // gets re-shot for the wrong reason.
+ let failures = [sceneFailure, stopFailure, sidecarFailure].compactMap { $0 }
+ for extra in failures.dropFirst() {
+ FileHandle.standardError.write(Data("democtl: this take also failed: \(extra)\n".utf8))
+ }
+ for movie in movies {
+ let stats = outcome == nil ? [:] : try recorder.outputStatistics(for: movie)
+ let metadata = outcome == nil ? [:] : try recorder.captureMetadata(for: movie)
+ let record: [String: Any] = [
+ "capture": metadata,
+ "locale": DemoLocale.selected.rawValue,
+ "schemaVersion": 2, "scene": name, "status": failures.isEmpty ? "passed" : "failed",
+ "tatamiVersion": install.version ?? "unknown", "overlay": overlayMode.rawValue,
+ "frames": stats["frames"] ?? 0, "droppedFrames": stats["dropped"] ?? 0,
+ "durationSeconds": timeline.durationSeconds,
+ "sceneSHA256": SHA256.hash(data: sceneData).map { String(format: "%02x", $0) }.joined(),
+ ]
+ try sceneData.write(to: movie.deletingPathExtension().appendingPathExtension("scene.json"), options: .atomic)
+ try JSONSerialization.data(withJSONObject: record, options: [.prettyPrinted, .sortedKeys])
+ .write(to: movie.deletingPathExtension().appendingPathExtension("take.json"), options: .atomic)
+ }
+ if let headline = failures.first { throw headline }
+ if let outcome { report(outcome) }
+ if let movie = movies.first {
+ print("subtitles: burn them in where ffmpeg lives with `democtl subtitle burn \(movie.path)`")
+ }
+
+ if !keepRunning {
+ DemoAppsController(paths: paths).quitAllIncludingOverlay()
+ }
+ }
+
+ /// The pixel size the sidecars declare.
+ ///
+ /// An ASS file positions and sizes everything in `PlayResX`/`PlayResY` units
+ /// that a player maps onto the movie, so declaring a size the movie does not
+ /// have never fails: it silently scales every font and margin by the
+ /// mismatch. Hence three sources in order of authority, and the last one says
+ /// out loud that it is a guess.
+ private static func scriptResolution(display: String, paths: LabPaths) -> SceneSubtitles.Resolution {
+ if let reported = reportedResolution(paths.recorderResultFile) {
+ print("subtitles: \(reported.width)x\(reported.height), as the recorder reported it")
+ return reported
+ }
+ if let screen = recordedDisplay(display) {
+ let size = SceneSubtitles.Resolution(
+ width: evenFloor(Int((screen.frame.width * screen.scale).rounded())),
+ height: evenFloor(Int((screen.frame.height * screen.scale).rounded()))
+ )
+ print("subtitles: \(size.width)x\(size.height), the backing pixels of display \(screen.index)")
+ if display == "all" {
+ print(
+ "subtitles: --display all records one movie per display and they need not be the same "
+ + "size; check PlayResY in the sidecar of any movie from another display"
+ )
+ }
+ return size
+ }
+ let fallback = SceneSubtitles.Resolution.fallback
+ print(
+ "subtitles: no display resolved for --display \(display), so the sidecars declare "
+ + "\(fallback.width)x\(fallback.height); correct PlayResX/PlayResY by hand if the movie differs"
+ )
+ return fallback
+ }
+
+ /// The display a take was recorded from, in the recorder's own `id:` spelling.
+ private static func recordedDisplay(_ display: String) -> DisplayInfo? {
+ let displays = DisplayInfo.all()
+ if display.hasPrefix("id:") {
+ guard let identifier = UInt32(display.dropFirst(3)) else { return nil }
+ return displays.first { $0.displayID == identifier }
+ }
+ // `all` lands here too. With several displays recorded at once there is no
+ // way from this side to tell which movie came from which, so the main
+ // display is the answer and the caller says so.
+ return displays.first { $0.displayID == CGMainDisplayID() } ?? displays.first
+ }
+
+ /// A size out of the recorder's result file, if it reports one.
+ ///
+ /// Read leniently on purpose: DemoCtlKit does not link DemoRecorderKit, that
+ /// file is a cross-process contract like the exit codes, and a recorder that
+ /// does not report a size has to fall through to the next source rather than
+ /// fail a take that is otherwise finished.
+ private static func reportedResolution(_ file: URL) -> SceneSubtitles.Resolution? {
+ guard
+ let data = try? Data(contentsOf: file),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let width = firstInt(object["width"]),
+ let height = firstInt(object["height"]),
+ width > 0, height > 0
+ else { return nil }
+ return SceneSubtitles.Resolution(width: width, height: height)
+ }
+
+ /// One number, whether the recorder reported one or a list of them.
+ private static func firstInt(_ value: Any?) -> Int? {
+ if let number = value as? Int { return number }
+ if let numbers = value as? [Int] { return numbers.first }
+ return nil
+ }
+
+ /// Mirrors `DisplayRecorder.evenFloor`. Encoders reject odd dimensions, so the
+ /// movie is even and a sidecar that claimed otherwise would be off by a pixel.
+ private static func evenFloor(_ value: Int) -> Int {
+ max(2, value - (value % 2))
+ }
+
+ private static func record(_ arguments: [String]) throws {
+ var arguments = arguments
+ let paths = try resolvePaths()
+ let recorder = RecorderController(paths: paths)
+ guard let action = arguments.first else {
+ throw DemoCtlError.usage("record needs start, stop, status, warmup or preflight")
+ }
+ arguments.removeFirst()
+
+ switch action {
+ case "start":
+ let display = try displayOption(&arguments)
+ let fps = try fpsOption(&arguments)
+ let output = try takeOption(&arguments, "--output").map { URL(fileURLWithPath: $0) }
+ ?? recorder.nextOutputURL(scene: "manual", stamp: Self.stamp())
+ try rejectUnknown(arguments, command: "record start")
+ try recorder.start(output: output, display: display, fps: fps)
+ if display == "all" {
+ print("recording: one file per display, next to \(output.path)")
+ } else {
+ print("recording: \(output.path)")
+ }
+
+ case "stop":
+ report(try recorder.stop())
+
+ case "warmup":
+ try warmup(&arguments, recorder: recorder, paths: paths)
+
+ case "status":
+ if let pid = recorder.runningPID { print("recording (pid \(pid))") } else { print("idle") }
+
+ case "preflight":
+ let granted = try recorder.preflight()
+ print(granted ? "screen recording: granted" : "screen recording: denied")
+ if !granted { throw DemoCtlError.usage("grant Screen Recording to DemoRecorder.app, then relaunch it") }
+
+ default:
+ throw DemoCtlError.usage("record: unknown action \"\(action)\"")
+ }
+ }
+
+ // MARK: - Subtitles
+
+ /// Burns a take's ASS sidecar into a copy of the movie.
+ ///
+ /// Deliberately a host-side step. ffmpeg is not installed in the recording VM
+ /// and is on a typical developer's Mac, and putting one in the guest would
+ /// spend VM time re-encoding a movie that has to be copied to the host anyway
+ /// (`tatami-tools vm-fetch-recordings`). So a missing ffmpeg is reported as exactly
+ /// that, with the command to run somewhere else, and never quietly skipped: a
+ /// "burned" take that turns out to have no subtitles in it is precisely the
+ /// silent difference between two takes this lab exists to prevent.
+ private static func subtitle(_ arguments: [String]) throws {
+ var arguments = arguments
+ guard let action = arguments.first else {
+ throw DemoCtlError.usage("subtitle needs an action: burn [--ass ] [--output ]")
+ }
+ arguments.removeFirst()
+ guard action == "burn" else {
+ throw DemoCtlError.usage("subtitle: unknown action \"\(action)\" (burn)")
+ }
+
+ let assOption = try takeOption(&arguments, "--ass")
+ let outputOption = try takeOption(&arguments, "--output")
+ try rejectUnknown(arguments, command: "subtitle burn")
+ guard let moviePath = arguments.first else {
+ throw DemoCtlError.usage("subtitle burn needs the movie to burn the subtitles into")
+ }
+ guard arguments.count == 1 else {
+ throw DemoCtlError.usage("subtitle burn: unexpected argument \"\(arguments[1])\"")
+ }
+
+ let movie = URL(fileURLWithPath: moviePath)
+ let subtitles = assOption.map { URL(fileURLWithPath: $0) } ?? LabPaths.subtitleFile(for: movie)
+ let output = outputOption.map { URL(fileURLWithPath: $0) } ?? LabPaths.burnedInFile(for: movie)
+
+ guard FileManager.default.fileExists(atPath: movie.path) else {
+ throw DemoCtlError.usage("no movie at \(movie.path)")
+ }
+ guard FileManager.default.fileExists(atPath: subtitles.path) else {
+ throw DemoCtlError.usage(
+ """
+ no subtitle sidecar at \(subtitles.path).
+ `democtl take` writes one next to every movie it records, and
+ tatami-tools vm-fetch-recordings copies it out of the guest with the movie.
+ Pass --ass to burn a different one in.
+ """
+ )
+ }
+ // Reading and writing one path truncates it, and the source is the single
+ // copy of a take that cannot be regenerated.
+ guard output.standardizedFileURL != movie.standardizedFileURL else {
+ throw DemoCtlError.usage("--output must not be the movie itself (\(movie.path))")
+ }
+
+ let ffmpegArguments = burnArguments(movie: movie, subtitles: subtitles, output: output)
+
+ // Two renderers, tried in order, because "ffmpeg is installed" is not the
+ // same question as "ffmpeg can read an ASS file". A stock Homebrew ffmpeg
+ // on this machine reports no `ass` filter at all, while mpv links libass
+ // directly and always can. Falling through to mpv turns a hard stop into a
+ // burn that works, and the choice is printed so nobody has to guess which
+ // one produced the file.
+ let renderer: (name: String, tool: URL, arguments: [String])
+ if let ffmpeg = locateFFmpeg(), rendersASS(ffmpeg) {
+ renderer = ("ffmpeg", ffmpeg, ffmpegArguments)
+ } else if let mpv = locate("mpv") {
+ renderer = ("mpv", mpv, mpvBurnArguments(movie: movie, subtitles: subtitles, output: output))
+ } else {
+ let ffmpeg = locateFFmpeg()
+ throw DemoCtlError.usage(
+ """
+ nothing on this machine can burn the subtitles in, so nothing was written.
+ \(ffmpeg.map { "\($0.path) exists but was built without libass, so it has no `ass` filter." }
+ ?? "ffmpeg is not installed.")
+ mpv is not installed either, and it is the easier fix because it always ships libass.
+
+ This step is host side on purpose: the recording VM has no ffmpeg, and the movie and its
+ sidecar have to be copied to the host anyway (tatami-tools vm-fetch-recordings).
+
+ brew install mpv # or a build of ffmpeg that lists `ass` in -filters
+
+ The sidecar itself is fine. Any player that reads ASS can also just load it beside the
+ movie without burning anything: mpv \(quoted(movie.path)) --sub-file \(quoted(subtitles.path))
+ """
+ )
+ }
+
+ print("burning \(subtitles.path)")
+ print(" into \(output.path)")
+ print(" with \(renderer.name)")
+ let process = Process()
+ process.executableURL = renderer.tool
+ process.arguments = renderer.arguments
+ // Inherited stdio: a burn is minutes of re-encoding, and the renderer's own
+ // progress is the only sign it is still working.
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ throw DemoCtlError.usage(
+ "\(renderer.name) failed (exit \(process.terminationStatus)); its own diagnosis is above this line."
+ )
+ }
+
+ // ffmpeg can exit 0 having written a header and little else, so the result
+ // is checked rather than trusted, exactly as a recording is.
+ let attributes = try? FileManager.default.attributesOfItem(atPath: output.path)
+ let size = attributes?[.size] as? Int
+ guard let size, size >= minimumUsableMovieBytes else {
+ throw DemoCtlError.recordingFailed([
+ "\(output.path) is \(size.map { "only \($0) bytes" } ?? "not on disk") "
+ + "after ffmpeg reported success",
+ ])
+ }
+ print("wrote \(output.path) (\(size / 1_048_576) MB)")
+ }
+
+ /// A finalized movie carries a moov atom and real frames. Anything smaller is
+ /// a stub, the same threshold `RecorderController` holds a take to.
+ private static let minimumUsableMovieBytes = 65_536
+
+ /// mpv renders the subtitles through libass in its own video chain, so `--vf=sub`
+ /// is what actually bakes them in; without it the encode is a clean copy.
+ /// h264 rather than HEVC because mpv tags its HEVC output `hev1`, which
+ /// AVFoundation refuses to open, and a file QuickTime cannot play is not a
+ /// deliverable.
+ ///
+ /// The bitrate is capped because `h264_videotoolbox` left to itself targets a
+ /// quality, not a size, and a 60fps desktop capture is nearly still: it spent
+ /// 221 MB on 114 seconds of a take that is mostly flat panels. 12 Mbps is far
+ /// more than this content needs and an order of magnitude friendlier to a
+ /// README or a web page.
+ private static func mpvBurnArguments(movie: URL, subtitles: URL, output: URL) -> [String] {
+ [
+ movie.path,
+ "--sub-file=\(subtitles.path)",
+ "--sub-visibility=yes",
+ "--no-config",
+ "--vf=lavfi=[scale=iw*0.8666666667:ih*0.8666666667:flags=lanczos,pad=iw/0.8666666667:ih/0.8666666667:(ow-iw)/2:oh*0.04:color=0x\(FilmTheme.background)],sub",
+ "--no-audio",
+ "--o=\(output.path)",
+ "--ovc=h264_videotoolbox",
+ "--ovcopts=profile=high,b=12000000,maxrate=16000000,bufsize=24000000",
+ "--really-quiet",
+ ]
+ }
+
+ private static func locate(_ tool: String) -> URL? {
+ for directory in searchPath() {
+ let candidate = URL(fileURLWithPath: directory).appendingPathComponent(tool)
+ if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate }
+ }
+ return nil
+ }
+
+ private static func burnArguments(movie: URL, subtitles: URL, output: URL) -> [String] {
+ [
+ "-y",
+ "-i", movie.path,
+ "-vf", "scale=iw*0.8666666667:ih*0.8666666667:flags=lanczos,pad=iw/0.8666666667:ih/0.8666666667:(ow-iw)/2:oh*0.04:color=0x\(FilmTheme.background),ass=filename=\(filterEscaped(subtitles.path))",
+ // Named rather than left to the container's default: a demo has to play
+ // everywhere, and a silently chosen mpeg4 stream does not.
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
+ // Copied, never re-encoded. A take with no audio track simply has none.
+ "-c:a", "copy",
+ output.path,
+ ]
+ }
+
+ /// Escapes a path for an ffmpeg filter argument.
+ ///
+ /// ffmpeg unescapes a filtergraph twice: `:` separates one filter option from
+ /// the next, and `,` and `;` separate filters from each other. A path holding
+ /// any of them would be read as graph syntax and produce a confusing parse
+ /// error, or worse, a graph that runs without the subtitles in it.
+ private static func filterEscaped(_ path: String) -> String {
+ var out = path.replacingOccurrences(of: "\\", with: "\\\\")
+ for character in [":", "'", "[", "]", ",", ";"] {
+ out = out.replacingOccurrences(of: character, with: "\\" + character)
+ }
+ return out
+ }
+
+ /// Where ffmpeg is looked for: `PATH`, plus Homebrew's two prefixes.
+ ///
+ /// Those two are added because `democtl` is often started by something with a
+ /// minimal environment (`tart exec`, launchd), where a perfectly good Homebrew
+ /// ffmpeg is installed and invisible.
+ private static func searchPath() -> [String] {
+ let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
+ let entries = path.split(separator: ":").map(String.init).filter { !$0.isEmpty }
+ var seen = Set()
+ return (entries + ["/opt/homebrew/bin", "/usr/local/bin"]).filter { seen.insert($0).inserted }
+ }
+
+ private static func locateFFmpeg() -> URL? {
+ for directory in searchPath() {
+ let candidate = URL(fileURLWithPath: directory).appendingPathComponent("ffmpeg")
+ if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate }
+ }
+ return nil
+ }
+
+ /// Whether this ffmpeg can render subtitles at all.
+ ///
+ /// The `ass` filter is libass, and libass is an optional build dependency: a
+ /// perfectly working ffmpeg can have been built without it, and Homebrew has
+ /// shipped such a bottle. Asking first turns ffmpeg's "No such filter", buried
+ /// in a page of filtergraph noise, into a sentence saying what to install.
+ private static func rendersASS(_ ffmpeg: URL) -> Bool {
+ // An unreadable answer is not proof of absence: let the burn run and let
+ // ffmpeg speak for itself rather than refusing the take on a guess.
+ guard let result = try? Shell.run(ffmpeg, ["-hide_banner", "-filters"]), result.succeeded else {
+ return true
+ }
+ // A listing row reads `` .. ass V->V Render ASS subtitles ...``, so the
+ // name is the second field. Matched as a field and not as a substring,
+ // because plenty of filter descriptions contain the word.
+ return result.standardOutput.split(separator: "\n").contains { line in
+ let fields = line.split(separator: " ", omittingEmptySubsequences: true)
+ return fields.count >= 2 && fields[1] == "ass"
+ }
+ }
+
+ /// Shell quoting, for a command printed for somebody to paste into a terminal
+ /// on another machine. Display only: nothing here is ever run through a shell.
+ private static func quoted(_ token: String) -> String {
+ let safe = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/:=-")
+ guard !token.isEmpty, token.allSatisfy({ safe.contains($0) }) else {
+ return "'" + token.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+ return token
+ }
+
+ /// Records a few seconds and throws the file away.
+ ///
+ /// macOS 15 and later periodically re-confirm screen capture for any app that
+ /// does not use `SCContentSharingPicker`, and that panel has already appeared
+ /// on camera mid-take in a real run. Provoking it deliberately before the
+ /// golden snapshot is the only way to keep it out of a take: the entitlement
+ /// that suppresses the panel needs Apple's approval, and the MDM key that
+ /// suppresses it needs a real MDM server.
+ private static func warmup(
+ _ arguments: inout [String],
+ recorder: RecorderController,
+ paths: LabPaths
+ ) throws {
+ let seconds = try secondsOption(&arguments, default: 3)
+ try rejectUnknown(arguments, command: "record warmup")
+
+ // Written into the lab's run directory, never into `recordings/`: this file
+ // is a probe, and it must not be mistaken for a take.
+ let output = paths.runRoot.appendingPathComponent("warmup-\(Self.stamp()).mov")
+ try recorder.start(output: output, display: "main", fps: 60)
+ print("warmup: recording \(seconds)s — approve the screen-capture panel now if it appears")
+ Shell.sleep(.seconds(seconds))
+
+ // Verified like any other take on purpose: a warmup that quietly produced
+ // nothing would have proved nothing about the consent state either.
+ let outcome = try recorder.stop()
+ for file in outcome.outputs {
+ try FileManager.default.removeItem(at: file)
+ }
+ print("warmup: capture confirmed, \(outcome.outputs.count) throwaway file(s) deleted")
+ }
+
+ /// Prints what the recorder actually wrote. Dropped frames are a visible
+ /// stutter and the classic way two takes differ without anyone noticing, so
+ /// they are reported even on the success path.
+ private static func report(_ outcome: RecordingOutcome) {
+ for file in outcome.outputs { print("wrote \(file.path)") }
+ if let dropped = outcome.droppedFrames, dropped > 0 {
+ let total = outcome.frames.map { " of \($0)" } ?? ""
+ print("warning: the recorder dropped \(dropped) frame(s)\(total); re-shoot if the motion stutters")
+ }
+ }
+
+ private static func secondsOption(_ arguments: inout [String], default fallback: Int) throws -> Int {
+ guard let raw = try takeOption(&arguments, "--seconds") else { return fallback }
+ guard let seconds = Int(raw), (1...600).contains(seconds) else {
+ throw DemoCtlError.usage("--seconds expects an integer in 1...600, got \"\(raw)\"")
+ }
+ return seconds
+ }
+
+ private static func events(_ arguments: [String]) throws {
+ var arguments = arguments
+ _ = takeFlag(&arguments, "--all")
+ try rejectUnknown(arguments, command: "events")
+ let paths = try resolvePaths()
+ let log = EventLog(url: paths.eventLog)
+ let all = log.events(since: 0)
+ guard !all.isEmpty else {
+ print("no events yet (\(paths.eventLog.path))")
+ return
+ }
+ for event in all {
+ print("\(event.event)\tprofile=\(event.profile)\tworkspace=\(event.workspace)\tdisplay=\(event.display)")
+ }
+ }
+
+ private static func config(_ arguments: [String]) throws {
+ var arguments = arguments
+ let printOnly = takeFlag(&arguments, "--print")
+ try rejectUnknown(arguments, command: "config")
+ let paths = try resolvePaths()
+
+ guard let text = try? String(contentsOf: paths.configFile, encoding: .utf8) else {
+ throw DemoCtlError.usage("no rendered config at \(paths.configFile.path); run `democtl seed`")
+ }
+ if printOnly {
+ print(text)
+ return
+ }
+ let report = ConfigValidator.report(text)
+ for warning in report.warnings { print("warn: \(warning)") }
+ guard report.isClean else { throw DemoCtlError.configInvalid(report.problems) }
+ print("config ok: \(paths.configFile.path)")
+ }
+
+ private static func restore(_ arguments: [String]) throws {
+ var arguments = arguments
+ let explicit = try takeOption(&arguments, "--tatami-app")
+ try rejectUnknown(arguments, command: "restore")
+ let paths = try resolvePaths()
+
+ guard let guardian = try restoreGuard(paths: paths, explicit: explicit) else {
+ print("nothing to restore: no defaults snapshot at \(paths.defaultsSnapshotRoot.path)")
+ return
+ }
+ // Tatami holds its own image of the domain and writes it back on change, so
+ // the import has to land on a stopped app.
+ TatamiProcess.quitAll()
+ try guardian.restore()
+ print("restored the \(guardian.domain) defaults domain")
+ }
+
+ private static func stamp() -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "yyyyMMdd-HHmmss"
+ return formatter.string(from: Date())
+ }
+
+}
+
+extension DemoCtlError {
+ fileprivate var isUsage: Bool {
+ if case .usage = self { return true }
+ return false
+ }
+}
diff --git a/DemoLab/Sources/DemoCtlKit/DemoCtlError.swift b/DemoLab/Sources/DemoCtlKit/DemoCtlError.swift
new file mode 100644
index 0000000..634f200
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/DemoCtlError.swift
@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - DemoCtlError
+
+public enum DemoCtlError: Error, CustomStringConvertible {
+ case packageRootNotFound
+ case tatamiNotFound([String])
+ case tatamiCLIMissing(String)
+ case tatamiNotRunning(socket: String)
+ case tatamiCommandFailed(command: String, message: String)
+ case templateMissing(String)
+ case unresolvedPlaceholders([String])
+ case configInvalid([String])
+ case bundlesMissing([String])
+ case sceneNotFound(String, available: [String])
+ case sceneStepFailed(index: Int, step: String, message: String)
+ case waitTimedOut(what: String, seconds: Double)
+ case recorderNotRunning
+ case recorderAlreadyRunning(pid: Int32)
+ case recorderFailedToStart(detail: String?)
+ case recordingFailed([String])
+ case defaultsSnapshotUnstamped(backup: String, stamp: String)
+ case defaultsSnapshotForeign(captured: String, requested: String, backup: String)
+ case defaultsSnapshotNotCleared(directory: String, domain: String, reason: String)
+ case usage(String)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .packageRootNotFound:
+ "could not locate the DemoLab package root; set DEMOLAB_ROOT to its path"
+
+ case .tatamiNotFound(let searched):
+ """
+ Tatami.app not found. Looked in:
+ \(searched.map { " - \($0)" }.joined(separator: "\n"))
+ Pass --tatami-app or set DEMOLAB_TATAMI_APP.
+ """
+
+ case .tatamiCLIMissing(let path):
+ "the Tatami bundle has no embedded CLI at \(path); reinstall or rebuild Tatami"
+
+ case .tatamiNotRunning(let socket):
+ """
+ Tatami is not answering on \(socket).
+ Run `democtl seed` first, or `democtl doctor` to see what is wrong.
+ """
+
+ case .tatamiCommandFailed(let command, let message):
+ "tatami \(command): \(message)"
+
+ case .templateMissing(let path):
+ "config template not found at \(path)"
+
+ case .unresolvedPlaceholders(let names):
+ "config template still contains placeholders: \(names.joined(separator: ", "))"
+
+ case .configInvalid(let problems):
+ """
+ the rendered config would be rejected or silently misread by Tatami:
+ \(problems.map { " - \($0)" }.joined(separator: "\n"))
+ """
+
+ case .bundlesMissing(let names):
+ """
+ these demo app bundles are missing: \(names.joined(separator: ", "))
+ Run `swift run --package-path Tools tatami-tools bundle-apps` from the checkout root first.
+ """
+
+ case .sceneNotFound(let name, let available):
+ "no scene named \(name). Available: \(available.joined(separator: ", "))"
+
+ case .sceneStepFailed(let index, let step, let message):
+ "scene step \(index) (\(step)) failed: \(message)"
+
+ case .waitTimedOut(let what, let seconds):
+ "timed out after \(String(format: "%.1f", seconds))s waiting for \(what)"
+
+ case .recorderNotRunning:
+ "no recorder is running (no pid file, or the process is gone)"
+
+ case .recorderAlreadyRunning(let pid):
+ "a recorder is already running (pid \(pid)); stop it first"
+
+ case .recorderFailedToStart(let detail):
+ """
+ the recorder exited during startup, before capture began\(detail.map { ": \($0)" } ?? "")
+ Common causes: an unwritable --output path, a --display index that does not exist,
+ no encoder for this resolution, or Screen Recording access revoked.
+ """
+
+ case .recordingFailed(let problems):
+ """
+ the take did not produce a usable recording:
+ \(problems.map { " - \($0)" }.joined(separator: "\n"))
+ """
+
+ case .defaultsSnapshotUnstamped(let backup, let stamp):
+ """
+ the defaults snapshot at \(backup) records no domain (\(stamp) is missing),
+ so it cannot be imported safely: `defaults import` replaces whichever domain it is
+ handed. Import it by hand with `defaults import \(backup)`, then delete
+ the snapshot directory.
+ """
+
+ case .defaultsSnapshotForeign(let captured, let requested, let backup):
+ """
+ the defaults snapshot at \(backup) was taken from \(captured), but this command
+ targets \(requested). Importing it there would replace \(requested)'s real preferences.
+ Finish the earlier shoot first: `democtl restore --tatami-app `.
+ """
+
+ case .defaultsSnapshotNotCleared(let directory, let domain, let reason):
+ """
+ \(domain) was restored, but the snapshot at \(directory) could not be removed: \(reason)
+ Delete it by hand — a later `democtl restore` would otherwise import it a second time.
+ """
+
+ case .usage(let message):
+ message
+ }
+ }
+}
diff --git a/DemoLab/Sources/DemoCtlKit/Doctor.swift b/DemoLab/Sources/DemoCtlKit/Doctor.swift
new file mode 100644
index 0000000..ef411eb
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/Doctor.swift
@@ -0,0 +1,343 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import ApplicationServices
+import CoreGraphics
+import DemoAppKit
+import Foundation
+
+// MARK: - DisplayInfo
+
+public struct DisplayInfo: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(index: Int, displayID: CGDirectDisplayID, name: String, uuid: String?, frame: CGRect, scale: CGFloat) {
+ self.index = index
+ self.displayID = displayID
+ self.name = name
+ self.uuid = uuid
+ self.frame = frame
+ self.scale = scale
+ }
+
+ // MARK: Public
+
+ /// Sorted by `(frame.minX, frame.minY)` — the same order Tatami uses for
+ /// `display focus next`, so an index here means the same display there.
+ public static func all() -> [DisplayInfo] {
+ NSScreen.screens
+ .sorted { lhs, rhs in
+ lhs.frame.minX == rhs.frame.minX
+ ? lhs.frame.minY < rhs.frame.minY
+ : lhs.frame.minX < rhs.frame.minX
+ }
+ .enumerated()
+ .map { index, screen in
+ let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
+ let displayID = CGDirectDisplayID(number?.uint32Value ?? 0)
+ return DisplayInfo(
+ index: index,
+ displayID: displayID,
+ name: screen.localizedName,
+ uuid: uuidString(for: displayID),
+ frame: screen.frame,
+ scale: screen.backingScaleFactor
+ )
+ }
+ }
+
+ public let index: Int
+ public let displayID: CGDirectDisplayID
+ public let name: String
+ public let uuid: String?
+ public let frame: CGRect
+ public let scale: CGFloat
+
+ /// The exact string a `displayHint` needs. Tatami stores a display as
+ /// `"::"`, splitting on the first `::`.
+ public var displayHint: String {
+ guard let uuid else { return name }
+ return "\(uuid)::\(name)"
+ }
+
+ // MARK: Private
+
+ private static func uuidString(for displayID: CGDirectDisplayID) -> String? {
+ guard displayID != 0, let reference = CGDisplayCreateUUIDFromDisplayID(displayID) else { return nil }
+ let value = reference.takeRetainedValue()
+ return CFUUIDCreateString(nil, value) as String?
+ }
+
+}
+
+// MARK: - CheckStatus
+
+public enum CheckStatus: String, Sendable {
+ case ok = "ok"
+ case warn = "warn"
+ case fail = "fail"
+
+ // MARK: Public
+
+ public var marker: String {
+ switch self {
+ case .ok: " ok "
+ case .warn: " warn "
+ case .fail: " FAIL "
+ }
+ }
+}
+
+// MARK: - Check
+
+public struct Check: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(_ status: CheckStatus, _ title: String, _ detail: String = "") {
+ self.status = status
+ self.title = title
+ self.detail = detail
+ }
+
+ // MARK: Public
+
+ public let status: CheckStatus
+ public let title: String
+ public let detail: String
+
+}
+
+// MARK: - Doctor
+
+/// Everything that has to be true before a take, checked in one place.
+///
+/// Two of these checks exist because the corresponding failure is *invisible*
+/// at run time: without an Accessibility grant Tatami's socket still accepts and
+/// acknowledges every command while no window ever moves, and a recorder
+/// launched down the wrong path reports "no access" without prompting.
+@MainActor
+public struct Doctor {
+
+ // MARK: Lifecycle
+
+ public init(paths: LabPaths, install: TatamiInstall?) {
+ self.paths = paths
+ self.install = install
+ }
+
+ // MARK: Public
+
+ public let paths: LabPaths
+ public let install: TatamiInstall?
+
+ public func run() -> [Check] {
+ var checks = [Check]()
+
+ checks.append(Check(.ok, "package root", paths.packageRoot.path))
+
+ if let install {
+ checks.append(Check(
+ .ok,
+ "Tatami",
+ "\(install.bundle.path) (\(install.bundleIdentifier), \(install.version ?? "unknown version"))"
+ ))
+ checks.append(
+ FileManager.default.isExecutableFile(atPath: install.cli.path)
+ ? Check(.ok, "tatami CLI", install.cli.path)
+ : Check(.fail, "tatami CLI", "missing at \(install.cli.path)")
+ )
+ } else {
+ checks.append(Check(.fail, "Tatami", "not found — pass --tatami-app or set DEMOLAB_TATAMI_APP"))
+ }
+
+ checks.append(bundlesCheck())
+ checks.append(contentsOf: overlayChecks())
+ checks.append(hookCheck())
+ checks.append(accessibilityCheck())
+ checks.append(contentsOf: screenRecordingChecks())
+ checks.append(contentsOf: tatamiRuntimeChecks())
+ checks.append(configCheck())
+ checks.append(contentsOf: displayChecks())
+
+ let scenes = SceneLoader.available(in: paths)
+ checks.append(
+ scenes.isEmpty
+ ? Check(.warn, "scenes", "none found in \(paths.scenesRoot.path)")
+ : Check(.ok, "scenes", scenes.joined(separator: ", "))
+ )
+
+ do {
+ try CaptureGate.requireCleanDesktop()
+ checks.append(Check(.ok, "capture surface", "no permission or settings window detected"))
+ } catch {
+ checks.append(Check(.fail, "capture surface", String(describing: error)))
+ }
+ return checks
+ }
+
+ // MARK: Private
+
+ private func bundlesCheck() -> Check {
+ let controller = DemoAppsController(paths: paths)
+ let missing = controller.missingBundles(DemoCatalog.all)
+ if missing.isEmpty {
+ return Check(.ok, "demo apps", "\(DemoCatalog.all.count) bundles in \(paths.bundlesRoot.path)")
+ }
+ return Check(.fail, "demo apps", "missing \(missing.joined(separator: ", ")) — run `democtl build`")
+ }
+
+ /// The narration overlay, which is not part of ``DemoCatalog/all`` and so is
+ /// missed by every check that walks the demo apps.
+ ///
+ /// Both halves are reported because they fail independently: the bundle can be
+ /// missing (never built), and a built, running overlay can still be deaf if it
+ /// was launched without the lab's `DEMOLAB_CONTROL_DIR`, in which case every
+ /// caption in a scene lands nowhere.
+ private func overlayChecks() -> [Check] {
+ let overlay = OverlayController(paths: paths)
+ var checks = [Check]()
+ checks.append(
+ overlay.bundleExists
+ ? Check(.ok, "overlay bundle", "\(overlay.bundle.path) (\(DemoCatalog.overlayBundleIdentifier))")
+ : Check(.fail, "overlay bundle", "missing at \(overlay.bundle.path). Run `democtl build`")
+ )
+ if OverlayController.isListening {
+ checks.append(Check(.ok, "overlay control", "answering on \(OverlayController.socketPath)"))
+ } else if OverlayController.running().isEmpty {
+ checks.append(Check(.warn, "overlay control", "not running. `democtl seed` starts it"))
+ } else {
+ checks.append(Check(
+ .fail,
+ "overlay control",
+ "running but silent on \(OverlayController.socketPath). It binds that socket only when it is "
+ + "launched with \(LabControl.variable) set to \(paths.controlDirectory.path); "
+ + "`democtl reset && democtl seed` relaunches it correctly."
+ ))
+ }
+ return checks
+ }
+
+ private func hookCheck() -> Check {
+ FileManager.default.isExecutableFile(atPath: paths.hookExecutable.path)
+ ? Check(.ok, "hook executable", paths.hookExecutable.path)
+ : Check(.fail, "hook executable", "not executable: \(paths.hookExecutable.path) (run tatami-tools bundle-apps)")
+ }
+
+ private func accessibilityCheck() -> Check {
+ // The lab only needs its own trust for scenes that type shortcuts. Tatami
+ // needs its own, separately, for every window operation.
+ AXIsProcessTrusted()
+ ? Check(.ok, "Accessibility (this process)", "trusted — scenes may type shortcuts")
+ : Check(
+ .warn,
+ "Accessibility (this process)",
+ "not trusted; CLI-driven scenes still work, `key`/`hold` steps will refuse to run"
+ )
+ }
+
+ private func screenRecordingChecks() -> [Check] {
+ var checks = [Check]()
+ checks.append(
+ CGPreflightScreenCaptureAccess()
+ ? Check(.ok, "Screen Recording (this process)", "granted")
+ : Check(.warn, "Screen Recording (this process)", "not granted (only the recorder needs it)")
+ )
+
+ let recorder = RecorderController(paths: paths)
+ guard recorder.bundleExists else {
+ checks.append(Check(.fail, "DemoRecorder.app", "missing — run `democtl build`"))
+ return checks
+ }
+ if let granted = try? recorder.preflight() {
+ checks.append(
+ granted
+ ? Check(.ok, "DemoRecorder screen access", "granted when run directly")
+ : Check(
+ .warn,
+ "DemoRecorder screen access",
+ "denied when run directly. That is expected before the one-time grant, and can also "
+ + "mean the parent process owns TCC responsibility. `democtl record start` launches it "
+ + "through LaunchServices, which is the path that can be granted."
+ )
+ )
+ }
+ return checks
+ }
+
+ private func tatamiRuntimeChecks() -> [Check] {
+ var checks = [Check]()
+ let others = TatamiProcess.running()
+ if others.isEmpty {
+ checks.append(Check(.warn, "Tatami process", "not running — `democtl seed` starts it"))
+ } else {
+ checks.append(Check(.ok, "Tatami process", "\(others.count) running"))
+ }
+
+ guard let install else { return checks }
+ let client = TatamiClient(install: install, paths: paths)
+ if let version = try? client.runningVersion() {
+ checks.append(Check(.ok, "lab socket", "\(paths.socketPath.path) answering, Tatami \(version)"))
+ if let hooks = try? client.hooks() {
+ let invalid = hooks.filter { ($0["valid"] as? Bool) == false }.compactMap { $0["id"] as? String }
+ checks.append(
+ invalid.isEmpty
+ ? Check(.ok, "hooks", "\(hooks.count) registered, all valid")
+ : Check(.fail, "hooks", "invalid: \(invalid.joined(separator: ", "))")
+ )
+ }
+ } else if !others.isEmpty {
+ checks.append(Check(
+ .fail,
+ "lab socket",
+ "\(paths.socketPath.path) is not answering, but a Tatami is running. It was probably started "
+ + "without the lab's isolation flags; run `democtl reset && democtl seed`."
+ ))
+ }
+ return checks
+ }
+
+ private func configCheck() -> Check {
+ guard let text = try? String(contentsOf: paths.configFile, encoding: .utf8) else {
+ return Check(.warn, "lab config", "not rendered yet — run `democtl seed`")
+ }
+ let report = ConfigValidator.report(text)
+ if !report.problems.isEmpty {
+ return Check(.fail, "lab config", report.problems.joined(separator: "; "))
+ }
+ if !report.warnings.isEmpty {
+ return Check(.warn, "lab config", report.warnings.joined(separator: "; "))
+ }
+ return Check(.ok, "lab config", paths.configFile.path)
+ }
+
+ private func displayChecks() -> [Check] {
+ let displays = DisplayInfo.all()
+ guard !displays.isEmpty else {
+ return [Check(.fail, "displays", "AppKit reports no screens")]
+ }
+ var checks = displays.map { display in
+ Check(
+ display.name.isEmpty ? .fail : .ok,
+ "display \(display.index)",
+ display.name.isEmpty
+ // Tatami drops a screen whose localizedName is empty, and an empty
+ // display set makes it skip the entire startup restore.
+ ? "reports an EMPTY name; Tatami will ignore this screen entirely"
+ : "\(display.displayHint) \(Int(display.frame.width))x\(Int(display.frame.height)) @\(display.scale)x"
+ )
+ }
+ if displays.count == 1 {
+ checks.append(Check(
+ .warn,
+ "multi-display scenes",
+ "one display connected: cross-display scenes need a secondary screen. "
+ + "The lab can add a guest-side CoreGraphics screen with `democtl display connect`."
+ ))
+ }
+ return checks
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/EventLog.swift b/DemoLab/Sources/DemoCtlKit/EventLog.swift
new file mode 100644
index 0000000..e22dfd1
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/EventLog.swift
@@ -0,0 +1,124 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - TatamiEvent
+
+public struct TatamiEvent: Sendable, Equatable {
+
+ // MARK: Lifecycle
+
+ public init(event: String, profile: String, workspace: String, kind: String, display: String) {
+ self.event = event
+ self.profile = profile
+ self.workspace = workspace
+ self.kind = kind
+ self.display = display
+ }
+
+ // MARK: Public
+
+ public let event: String
+ public let profile: String
+ public let workspace: String
+ public let kind: String
+ public let display: String
+
+ public var summary: String {
+ switch event {
+ case "workspaceActivated":
+ display.isEmpty ? "workspace \(workspace)" : "workspace \(workspace) on \(display)"
+ case "profileChanged":
+ "profile \(profile)"
+ default:
+ event
+ }
+ }
+
+}
+
+// MARK: - EventLog
+
+/// Reads the append-only log the `demohook event-log` executable writes.
+///
+/// This is the lab's only reliable "it actually happened" signal for anything
+/// other than `workspace activate` / `profile activate`. Tatami's dispatcher
+/// answers `accepted` as soon as a command is enqueued, which is true and
+/// useless for a scene: it says nothing about whether a window moved. The hook
+/// fires from inside the activation pipeline, so a line here means a display
+/// really published new state.
+public struct EventLog: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ // MARK: Public
+
+ public let url: URL
+
+ /// Byte offset to read from. Take one *before* issuing a command, so a
+ /// matching line from an earlier take can never satisfy the wait.
+ public var mark: UInt64 {
+ let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
+ return (attributes?[.size] as? NSNumber)?.uint64Value ?? 0
+ }
+
+ public func reset() throws {
+ try? FileManager.default.removeItem(at: url)
+ FileManager.default.createFile(atPath: url.path, contents: Data())
+ }
+
+ public func events(since offset: UInt64) -> [TatamiEvent] {
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return [] }
+ defer { try? handle.close() }
+ do {
+ try handle.seek(toOffset: offset)
+ let data = try handle.readToEnd() ?? Data()
+ return Self.parse(String(decoding: data, as: UTF8.self))
+ } catch {
+ return []
+ }
+ }
+
+ @discardableResult
+ public func wait(
+ since offset: UInt64,
+ timeout: Duration,
+ describing description: String,
+ matching predicate: @escaping (TatamiEvent) -> Bool
+ ) throws -> TatamiEvent {
+ var found: TatamiEvent?
+ let satisfied = Shell.wait(timeout: timeout, poll: .milliseconds(40)) {
+ found = events(since: offset).first(where: predicate)
+ return found != nil
+ }
+ guard satisfied, let found else {
+ throw DemoCtlError.waitTimedOut(
+ what: description,
+ seconds: Double(timeout.components.seconds)
+ )
+ }
+ return found
+ }
+
+ // MARK: Private
+
+ private static func parse(_ text: String) -> [TatamiEvent] {
+ text.split(separator: "\n").compactMap { line in
+ let fields = line.components(separatedBy: "\t")
+ guard fields.count >= 6 else { return nil }
+ return TatamiEvent(
+ event: fields[1],
+ profile: fields[2],
+ workspace: fields[3],
+ kind: fields[4],
+ display: fields[5]
+ )
+ }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/FilmTheme.swift b/DemoLab/Sources/DemoCtlKit/FilmTheme.swift
new file mode 100644
index 0000000..9203a77
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/FilmTheme.swift
@@ -0,0 +1,9 @@
+// Generated by tatami-tools video-theme from web/style.css.
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+enum FilmTheme {
+ static let background = "1D1D1F"
+ static let text = "&H00F7F5F5"
+ static let secondary = "&H008B8686"
+ static let accent = "&H0096CDE9"
+}
diff --git a/DemoLab/Sources/DemoCtlKit/LabPaths.swift b/DemoLab/Sources/DemoCtlKit/LabPaths.swift
new file mode 100644
index 0000000..23ca15e
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/LabPaths.swift
@@ -0,0 +1,180 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+import DemoAppKit
+
+// MARK: - LabPaths
+
+/// Every path the Demo Lab touches.
+///
+/// The central design decision lives here: the lab **isolates** rather than
+/// resets. Tatami accepts `--tatami-config-home` and `--tatami-socket-path`, so
+/// the whole demo runs against its own config directory and its own socket, and
+/// a real installed Tatami's `~/.config/tatami` is never read, written, or
+/// backed up. "Reset" then means deleting one directory, which is inherently
+/// idempotent.
+///
+/// The one thing isolation does *not* cover is `UserDefaults` — Tatami's
+/// preferences domain is keyed by bundle id, not by config home. ``DefaultsGuard``
+/// handles that separately by backing up the few keys the lab has to change.
+public struct LabPaths: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(packageRoot: URL) {
+ self.packageRoot = packageRoot
+ }
+
+ // MARK: Public
+
+ /// The DemoLab package directory, located by walking up from the running
+ /// executable until a directory containing `Package.swift` and `Sources` is
+ /// found. Works for `.build/debug/democtl`, `.build/release/democtl`, and a
+ /// copy placed in `.build/DemoLab/bin`.
+ public static func discoverPackageRoot(
+ from executable: URL = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
+ ) -> URL? {
+ if let override = ProcessInfo.processInfo.environment["DEMOLAB_ROOT"], !override.isEmpty {
+ return URL(fileURLWithPath: override, isDirectory: true)
+ }
+ // The executable path covers `.build/{debug,release}/democtl` and a copy in
+ // `.build/DemoLab/bin`. `#filePath` covers the test bundle, whose argv[0] is
+ // the test runner rather than anything under the package. Both are verified
+ // against real files before being accepted, so a stale compile-time path
+ // simply fails the check instead of pointing somewhere wrong.
+ let starts = [
+ executable.deletingLastPathComponent(),
+ URL(fileURLWithPath: #filePath).deletingLastPathComponent(),
+ Bundle.main.bundleURL,
+ ]
+ for start in starts {
+ var directory = start
+ for _ in 0..<8 {
+ let manifest = directory.appendingPathComponent("Package.swift")
+ let sources = directory.appendingPathComponent("Sources/DemoAppKit")
+ if FileManager.default.fileExists(atPath: manifest.path),
+ FileManager.default.fileExists(atPath: sources.path) {
+ return directory
+ }
+ let parent = directory.deletingLastPathComponent()
+ if parent == directory { break }
+ directory = parent
+ }
+ }
+ return nil
+ }
+
+ public let packageRoot: URL
+
+ public var buildRoot: URL { packageRoot.appendingPathComponent(".build") }
+ /// Where `tatami-tools bundle-apps` assembles the `.app` bundles.
+ public var bundlesRoot: URL { buildRoot.appendingPathComponent("DemoLab") }
+ public var binRoot: URL { bundlesRoot.appendingPathComponent("bin") }
+
+ /// The pristine `UserDefaults` snapshot and the stamp naming the domain it
+ /// came from. Deliberately **outside** ``labRoot``: `democtl reset` removes
+ /// that whole tree between takes, and a snapshot destroyed there would let the
+ /// next `seed` export an already-suppressed domain and call it pristine —
+ /// after which `democtl restore` can never undo the suppression on the user's
+ /// real Tatami. ``DefaultsGuard`` owns the file names inside this directory so
+ /// the stamp cannot drift away from the export it describes.
+ public var defaultsSnapshotRoot: URL { buildRoot.appendingPathComponent("defaults-snapshot") }
+
+ /// Everything the lab creates at run time. `democtl reset` deletes this whole
+ /// tree, so nothing outside it may hold demo state.
+ public var labRoot: URL { buildRoot.appendingPathComponent("lab") }
+ /// Passed to Tatami as `--tatami-config-home`; it appends `tatami/`.
+ public var configHome: URL { labRoot.appendingPathComponent("xdg") }
+ /// The directory Tatami actually reads, i.e. `/tatami`.
+ public var tatamiConfigDirectory: URL { configHome.appendingPathComponent("tatami") }
+ public var configFile: URL { tatamiConfigDirectory.appendingPathComponent("config.toml") }
+ public var layoutsFile: URL { tatamiConfigDirectory.appendingPathComponent("layouts.json") }
+ public var profileSessionFile: URL {
+ tatamiConfigDirectory.appendingPathComponent("profile-session.json")
+ }
+
+ public var runRoot: URL { labRoot.appendingPathComponent("run") }
+ /// Kept short on purpose: a UNIX socket path has a hard `sun_path` limit and
+ /// Tatami's CLI does not length-check an override before truncating it.
+ public var socketPath: URL { runRoot.appendingPathComponent("t.sock") }
+ /// Where the demo apps and the overlay bind their control sockets.
+ ///
+ /// `democtl` exports it as `DEMOLAB_CONTROL_DIR` for itself and for everything
+ /// it launches, so the lab's sockets live with the rest of the lab state and
+ /// `democtl reset` removes them with it. Kept to one short component for the
+ /// same reason ``socketPath`` is: a UNIX socket path has a hard `sun_path`
+ /// limit, and the socket's own leaf name is added on top of this directory.
+ public var controlDirectory: URL { runRoot.appendingPathComponent("ctl") }
+ public var eventLog: URL { runRoot.appendingPathComponent("events.tsv") }
+ public var recorderPidFile: URL { runRoot.appendingPathComponent("recorder.pid") }
+ /// The recorder's machine-readable outcome: it shares the pid file's basename
+ /// and is written before the pid file goes away, so the pid-file wait doubles
+ /// as the barrier for reading it. A cross-process contract with DemoRecorder,
+ /// like the exit codes.
+ public var recorderResultFile: URL {
+ recorderPidFile.deletingPathExtension().appendingPathExtension("json")
+ }
+
+ public var seedStamp: URL { runRoot.appendingPathComponent("seed.json") }
+
+ public var recordingsRoot: URL { packageRoot.appendingPathComponent("recordings") }
+
+ // MARK: - Take sidecars
+
+ /// The subtitle sidecar for a take: same directory, same basename, `.ass`.
+ ///
+ /// Static, and shaped only by the movie's own path, because three places have
+ /// to agree on it and none of them can ask another: `democtl take` writes it,
+ /// `democtl subtitle burn` reads it, and `tatami-tools vm-fetch-recordings` copies
+ /// it out of the guest. A sidecar named by a rule that lives in only one of
+ /// them is a sidecar the other two lose.
+ public static func subtitleFile(for movie: URL) -> URL {
+ movie.deletingPathExtension().appendingPathExtension("ass")
+ }
+
+ /// The machine-readable half of the same record: every narration event with
+ /// its times, for a tool that is not a subtitle renderer.
+ public static func timelineFile(for movie: URL) -> URL {
+ movie.deletingPathExtension().appendingPathExtension("timeline.json")
+ }
+
+ /// Where `democtl subtitle burn` puts the burned-in copy.
+ ///
+ /// Beside the source and never over it. Burning is a re-encode, the source is
+ /// the one copy of a take that cannot be regenerated, and ffmpeg reading and
+ /// writing the same path truncates it.
+ public static func burnedInFile(for movie: URL) -> URL {
+ let base = movie.deletingPathExtension().lastPathComponent
+ let ext = movie.pathExtension.isEmpty ? "mov" : movie.pathExtension
+ return movie.deletingLastPathComponent().appendingPathComponent("\(base)-subtitled.\(ext)")
+ }
+
+ public var templateFile: URL {
+ packageRoot.appendingPathComponent("config/tatami-demo.toml.in")
+ }
+
+ public var hookExecutable: URL { binRoot.appendingPathComponent("demohook") }
+
+ public var automationExecutable: URL { buildRoot.appendingPathComponent("tools/tatami-tools") }
+
+ public var scenesRoot: URL { packageRoot.appendingPathComponent("scenes") }
+
+ public func sceneFile(_ name: String) -> URL {
+ (DemoLocale.selected == .en ? scenesRoot : scenesRoot.appendingPathComponent(DemoLocale.selected.rawValue))
+ .appendingPathComponent("\(name).json")
+ }
+
+ public func appBundle(_ name: String) -> URL {
+ bundlesRoot.appendingPathComponent("\(name).app")
+ }
+
+ public var recorderBundle: URL { bundlesRoot.appendingPathComponent("DemoRecorder.app") }
+
+ public func ensureRuntimeDirectories() throws {
+ for directory in [labRoot, configHome, tatamiConfigDirectory, runRoot, controlDirectory] {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/LiveConfigEditor.swift b/DemoLab/Sources/DemoCtlKit/LiveConfigEditor.swift
new file mode 100644
index 0000000..9c00628
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/LiveConfigEditor.swift
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import Foundation
+public enum LiveConfigEditor {
+ public static func update(file:URL,key:String,value:String) throws {
+ let sections=["borrowDefaultEdge":"switching","borrowFraction":"switching","mouseFollowsFocus":"focus","focusFollowsMouse":"focus","gapInner":"layout","gapOuter":"layout"]
+ guard let section=sections[key] else {throw DemoCtlError.usage("unsupported live setting: \(key)")}
+ let valid:Bool
+ switch key {
+ case "borrowDefaultEdge":valid=["remove","\"top\"","\"bottom\"","\"left\"","\"right\""].contains(value)
+ case "borrowFraction":valid=Double(value).map {(0.15...0.65).contains($0)} ?? false
+ case "mouseFollowsFocus","focusFollowsMouse":valid=["true","false"].contains(value)
+ default:valid=Int(value).map {(0...40).contains($0)} ?? false
+ }
+ guard valid else {throw DemoCtlError.usage("invalid value for \(key): \(value)")}
+ var text=try String(contentsOf:file,encoding:.utf8)
+ guard let header=text.range(of:"[settings.\(section)]") else {throw DemoCtlError.usage("missing settings.\(section)")}
+ let tail=text[header.upperBound...]
+ let end=tail.range(of:"\n[")?.lowerBound ?? text.endIndex
+ let range=header.upperBound.. 0
+ else { return nil }
+ // kill(pid, 0) only tests for existence and permission; it sends nothing.
+ return kill(pid, 0) == 0 ? pid : nil
+ }
+
+ private var readyFile: URL { pidFile.deletingPathExtension().appendingPathExtension("ready.json") }
+
+ public func elapsedSinceFirstFrame() throws -> Double {
+ let object = try JSONSerialization.jsonObject(with: Data(contentsOf: readyFile)) as? [String: Any]
+ guard let start = object?["firstFrameUptimeNanoseconds"] as? UInt64 else {
+ throw DemoCtlError.usage("recorder did not report its first-frame clock")
+ }
+ let now = DispatchTime.now().uptimeNanoseconds
+ guard now >= start else { throw DemoCtlError.usage("invalid recorder clock") }
+ return Double(now - start) / 1_000_000_000
+ }
+
+ public func captureMetadata(for movie:URL) throws -> [String:Double] {
+ let object = try JSONSerialization.jsonObject(with: Data(contentsOf: readyFile)) as? [String:Any]
+ guard let offsets = object?["outputOffsets"] as? [String:Double], let offset = offsets[movie.lastPathComponent] else {
+ throw DemoCtlError.usage("recorder did not report output offsets; rebuild the recorder bundle")
+ }
+ var metadata = ((object?["displays"] as? [String:[String:Double]])?[movie.lastPathComponent]) ?? [:]
+ metadata["captureOffsetSeconds"] = offset
+ return metadata
+ }
+ public func outputStatistics(for movie:URL) throws -> [String:Int] {
+ let object = try JSONSerialization.jsonObject(with: Data(contentsOf: resultFile)) as? [String:Any]
+ guard let stats=(object?["outputStatistics"] as? [String:[String:Int]])?[movie.lastPathComponent] else {
+ throw DemoCtlError.usage("recorder did not report per-output statistics")
+ }
+ return stats
+ }
+
+ public var bundleExists: Bool {
+ FileManager.default.fileExists(atPath: paths.recorderBundle.path)
+ }
+
+ public var executable: URL {
+ paths.recorderBundle.appendingPathComponent("Contents/MacOS/DemoRecorder")
+ }
+
+ /// How the recorder process is started.
+ ///
+ /// This is not a preference, it is a correctness question. macOS attributes a
+ /// Screen Recording grant to the *responsible* process:
+ ///
+ /// - On an ordinary desktop, `democtl` runs from a terminal that has no such
+ /// grant, so the recorder has to become its own responsible process. That
+ /// means going through LaunchServices, and it is why the recorder ships as
+ /// an `.app` bundle at all.
+ /// - Inside a VM driven by `tart exec`, the parent already holds the grant
+ /// (the guest agent is pre-authorized), and a direct child inherits it.
+ /// Going through LaunchServices there would *lose* the grant, because the
+ /// ad-hoc-signed bundle has none of its own.
+ public enum LaunchPath: Sendable {
+ case auto
+ case viaLaunchServices
+ case direct
+ }
+
+ @discardableResult
+ public func start(
+ output: URL,
+ display: String,
+ fps: Int,
+ launchPath: LaunchPath = .auto
+ ) throws -> URL {
+ guard bundleExists else { throw DemoCtlError.bundlesMissing(["DemoRecorder"]) }
+ if let pid = runningPID { throw DemoCtlError.recorderAlreadyRunning(pid: pid) }
+
+ try FileManager.default.createDirectory(
+ at: output.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ // Both handshake files, and loudly: a result file left by the previous take
+ // would be read as this one's verdict, which is the exact silent difference
+ // between takes this lab exists to prevent.
+ for stale in [pidFile, resultFile, readyFile]
+ where FileManager.default.fileExists(atPath: stale.path) {
+ try FileManager.default.removeItem(at: stale)
+ }
+
+ let arguments = [
+ "--output", output.path,
+ "--display", display,
+ "--fps", String(fps),
+ "--pidfile", pidFile.path,
+ ]
+
+ // `auto`: if this process can already capture the screen, a direct child
+ // inherits that. If it cannot, LaunchServices is the only path that can
+ // ever be granted.
+ let resolved: LaunchPath = {
+ switch launchPath {
+ case .auto: CGPreflightScreenCaptureAccess() ? .direct : .viaLaunchServices
+ case let explicit: explicit
+ }
+ }()
+
+ switch resolved {
+ case .direct, .auto:
+ // Detached for the same reason Tatami is: the recorder runs until it is
+ // signalled, and holding the caller's stdout would hang any pipeline
+ // `democtl` was started from.
+ try Shell.launchDetached(
+ executable,
+ arguments,
+ log: paths.runRoot.appendingPathComponent(recordingName + "-stdout.log")
+ )
+
+ case .viaLaunchServices:
+ try Shell.require(
+ URL(fileURLWithPath: "/usr/bin/open"),
+ ["-n", "-a", paths.recorderBundle.path, "--args"] + arguments
+ )
+ }
+
+ // The pid file says the recorder is signalable, not that ScreenCaptureKit
+ // accepted the stream — it is written during startup, so it is not a
+ // "capture started" barrier. What it can do is catch a startup that died:
+ // the recorder writes its result file before dropping the pid file, so a
+ // result during startup means the run is already over. Failing here beats
+ // driving a whole scene against a recorder that exited seconds in.
+ let started = try Shell.wait(timeout: .seconds(20)) { () throws -> Bool in
+ if FileManager.default.fileExists(atPath: resultFile.path) {
+ throw DemoCtlError.recorderFailedToStart(detail: try? readResult().failureReason)
+ }
+ return runningPID != nil
+ }
+ guard started else {
+ throw DemoCtlError.waitTimedOut(what: "the recorder to start capturing", seconds: 20)
+ }
+ return output
+ }
+
+ /// Asks the recorder to finish, then reports what it actually wrote. SIGINT
+ /// rather than SIGKILL on purpose: the recorder has to stop the stream, drain
+ /// its sample queue, end the writer session and flush the movie atom. Killing
+ /// it leaves an unplayable file.
+ ///
+ /// The recorder's own result file is the verdict. A vanished pid file only
+ /// means the process is gone, and it is gone on the failure paths too — that
+ /// is how a disk-full take used to be reported as "wrote ".
+ @discardableResult
+ public func stop(timeout: Duration = .seconds(30)) throws -> RecordingOutcome {
+ guard let pid = runningPID else { throw DemoCtlError.recorderNotRunning }
+ kill(pid, SIGINT)
+ let finished = Shell.wait(timeout: timeout) { runningPID == nil }
+ guard finished else {
+ throw DemoCtlError.waitTimedOut(
+ what: "the recorder to finish writing",
+ seconds: Shell.seconds(timeout)
+ )
+ }
+ return try verifiedOutcome()
+ }
+
+ /// Reports whether the recorder can capture. Runs the binary directly, so the
+ /// answer describes *this* launch path; the authoritative check for recording
+ /// is the same binary launched through `open`, which is what `start` does.
+ public func preflight() throws -> Bool {
+ guard bundleExists else { throw DemoCtlError.bundlesMissing(["DemoRecorder"]) }
+ let result = try Shell.run(executable, ["--preflight"])
+ return result.status == 0
+ }
+
+ public func nextOutputURL(scene: String, stamp: String) -> URL {
+ paths.recordingsRoot.appendingPathComponent("\(scene)-\(stamp).mov")
+ }
+
+ // MARK: Private
+
+ /// A finalized MOV carries a moov atom and at least a few frames of mdat.
+ /// Anything smaller is a stub the writer never flushed.
+ private static let minimumUsableRecordingBytes = 65_536
+
+ private func verifiedOutcome() throws -> RecordingOutcome {
+ let result = try readResult()
+ guard result.isOK else { throw DemoCtlError.recordingFailed([result.failureReason]) }
+ guard !result.outputs.isEmpty else {
+ throw DemoCtlError.recordingFailed(["the recorder reported success but finalized no file"])
+ }
+
+ var problems = [String]()
+ var outputs = [URL]()
+ for path in result.outputs {
+ let attributes = try? FileManager.default.attributesOfItem(atPath: path)
+ guard let size = attributes?[.size] as? Int else {
+ problems.append("\(path) is not on disk, though the recorder reported finalizing it")
+ continue
+ }
+ guard size >= Self.minimumUsableRecordingBytes else {
+ problems.append("\(path) is only \(size) bytes, which is a stub with no usable video in it")
+ continue
+ }
+ outputs.append(URL(fileURLWithPath: path))
+ }
+ guard problems.isEmpty else { throw DemoCtlError.recordingFailed(problems) }
+ return RecordingOutcome(outputs: outputs, frames: result.frames, droppedFrames: result.dropped)
+ }
+
+ /// The recorder's verdict. Absent or unparseable is a failure, never a
+ /// success: an unverified take is exactly the thing that must not pass.
+ private func readResult() throws -> RecorderResult {
+ let file = resultFile
+ guard let data = try? Data(contentsOf: file) else {
+ throw DemoCtlError.recordingFailed([
+ "the recorder exited without writing \(file.path), so nothing about this take was verified",
+ ])
+ }
+ guard let result = RecorderResult(data: data) else {
+ throw DemoCtlError.recordingFailed([
+ "\(file.path) is not a result this democtl understands, so the take was not verified",
+ ])
+ }
+ return result
+ }
+
+}
+
+// MARK: - RecorderResult
+
+/// Mirrors the payload DemoRecorder writes next to its pid file. DemoCtlKit does
+/// not link DemoRecorderKit, so this is a cross-process contract like the exit
+/// codes. `status` and `outputs` decide the verdict and are read strictly;
+/// `frames`/`dropped` are diagnostics and are read leniently, since a
+/// `--display all` take reports them per output and a single-display take
+/// reports one number.
+private struct RecorderResult {
+
+ // MARK: Lifecycle
+
+ init?(data: Data) {
+ guard
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let status = object["status"] as? String
+ else { return nil }
+ self.status = status
+ outputs = (object["outputs"] as? [String]) ?? []
+ error = object["error"] as? String
+ frames = Self.total(object["frames"])
+ dropped = Self.total(object["dropped"])
+ }
+
+ // MARK: Internal
+
+ let status: String
+ let outputs: [String]
+ let error: String?
+ let frames: Int?
+ let dropped: Int?
+
+ var isOK: Bool { status == "ok" }
+
+ var failureReason: String {
+ guard let error, !error.isEmpty else { return "the recorder reported status \"\(status)\"" }
+ return error
+ }
+
+ // MARK: Private
+
+ private static func total(_ value: Any?) -> Int? {
+ if let number = value as? Int { return number }
+ if let numbers = value as? [Int] { return numbers.reduce(0, +) }
+ return nil
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/Scene.swift b/DemoLab/Sources/DemoCtlKit/Scene.swift
new file mode 100644
index 0000000..045eca7
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/Scene.swift
@@ -0,0 +1,355 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - Scene
+
+/// A recorded take, described as an ordered list of steps.
+///
+/// Scenes are data rather than code so a take can be re-timed or re-ordered
+/// without rebuilding anything, and so the exact sequence that produced a video
+/// is reviewable in a diff.
+public struct Scene: Sendable, Decodable {
+
+ // MARK: Public
+
+ public var name: String
+ public var title: String
+ public var summary: String?
+ /// Printed before the run so the operator knows what has to be true first.
+ public var requires: SceneRequirements?
+ /// Runs before the recorder starts; app launches and layout preparation never enter the movie.
+ public var setup: [SceneStep]?
+ public var openingApps: [String]?
+ public var captureSecondary: Bool?
+ public var steps: [SceneStep]
+
+}
+
+// MARK: - SceneRequirements
+
+public struct SceneRequirements: Sendable, Decodable {
+ public var displays: Int?
+ public var profile: String?
+ public var apps: [String]?
+ public var note: String?
+}
+
+// MARK: - SceneStep
+
+public enum SceneStep: Sendable {
+ /// Narration only. Printed with its cue time so the voiceover script and the
+ /// recording stay in sync.
+ case clipboard(text:String)
+ case closeSettings
+ case prepareSettings
+ case saveControlFrame(app:String,identifier:String,name:String)
+ case expectControlMoved(app:String,identifier:String,name:String)
+ case saveWindow(app:String)
+ case restoreWindow(app:String)
+ case assertWindow(app:String)
+ case expectPointer(app:String)
+ case hover(app:String,identifier:String)
+ case dragWindow(app:String,target:String,x:Double,y:Double)
+ case resizeWindow(app:String,dx:Double,dy:Double)
+ case expectFront(app:String)
+ case expectLayoutChanged(name:String)
+ case configure(key:String,value:String)
+ case virtualDisplay(connected:Bool)
+ case expectCommand(command:String,code:Int)
+ case expectHook(field:String,value:String)
+ case click(app: String, identifier: String)
+ case rightClick(app: String, identifier: String)
+ case expectPlacement(app: String, target: String, edge: String)
+ case expectProfileCount(Int)
+ case expectAssignment(app: String, workspace: String, profile: String)
+ case typeText(app: String, text: String, intervalMilliseconds: Int)
+ case expectStory(field: String, value: String)
+ case expectValue(app: String, identifier: String, value: String)
+ case scroll(app: String, identifier: String, pixels: Int)
+ case note(text: String)
+ case beat(milliseconds: Int, note: String?)
+ case launch(apps: [String], windows: [String: Int])
+ case quitApps(apps: [String]?)
+ case pointer(display: Int, x: Double, y: Double)
+ case activateWorkspace(workspace: String, profile: String?)
+ case activateProfile(profile: String)
+ case cli(arguments: [String], expect: String)
+ case key(chord: String, repeats: Int, holdMilliseconds: Int)
+ case hold(modifiers: String, keys: [String], gapMilliseconds: Int, releaseAfterMilliseconds: Int)
+ case waitWorkspace(workspace: String, timeoutMilliseconds: Int)
+ case waitProfile(profile: String, timeoutMilliseconds: Int)
+ case borrow(workspace: String, expectApps: [String], timeoutMilliseconds: Int)
+ case dismissBorrow(settleMilliseconds: Int)
+ case appWindows(app: String, count: Int)
+ /// Narration. The overlay holds whatever it was last told, so a caption stays
+ /// until a later step replaces or clears it.
+ case chapter(text: String)
+ case caption(text: String)
+ /// Keycaps for an action that was **not** a keystroke. A `key` or `hold` step
+ /// shows its own chord, so a scene file never repeats one next to a keystroke.
+ case keys(chord: String)
+ case clearOverlay
+ case activateApp(app: String)
+ case waitWindows(apps: [String], timeoutMilliseconds: Int)
+ case saveLayout(name: String)
+ case assertLayout(name: String)
+
+ // MARK: Public
+
+ public var label: String {
+ switch self {
+ case .rightClick(let app, let id): "context menu \(app) / \(id)"
+ case .expectPlacement(let app, let target, let edge): "verify \(app) is \(edge) of \(target)"
+ case .expectProfileCount(let count): "verify \(count) profiles"
+ case .expectAssignment(let app, let workspace, let profile): "verify \(app) belongs to \(profile)/\(workspace)"
+ case .clipboard: "prepare example clipboard content"
+ case .closeSettings: "close Tatami window"
+ case .prepareSettings: "size and position the native Tatami window"
+ case .saveControlFrame(_,_,let name): "save control frame \(name)"
+ case .expectControlMoved(_,_,let name): "verify control moved \(name)"
+ case .saveWindow(let app): "save window \(app)"
+ case .restoreWindow(let app): "restore window by dragging \(app)"
+ case .assertWindow(let app): "verify window frame \(app)"
+ case .expectPointer(let app): "verify pointer in \(app)"
+ case .hover(let app,let id): "hover \(app) / \(id)"
+ case .dragWindow(let app,let target,_,_): "drag \(app) to \(target)"
+ case .resizeWindow(let app,_,_): "resize \(app)"
+ case .expectFront(let app): "verify focused app \(app)"
+ case .expectLayoutChanged(let name): "verify changed layout \(name)"
+ case .configure(let key,let value): "configure \(key) = \(value)"
+ case .virtualDisplay(let connected): connected ? "connect secondary display" : "disconnect secondary display"
+ case .expectCommand(let command,_): "verify command \(command)"
+ case .expectHook(let field,let value): "verify hook \(field) = \(value)"
+ case .waitWindows: "waitWindows"
+ case .saveLayout: "saveLayout"
+ case .assertLayout: "assertLayout"
+ case .click(let app, let id): "click \(app) / \(id)"
+ case .typeText(let app, let text, _): "type in \(app): \(text)"
+ case .expectStory(let field, let value): "verify \(field) = \(value)"
+ case .expectValue(let app, let id, _): "verify native input \(app) / \(id)"
+ case .scroll(let app, _, _): "scroll \(app)"
+ case .note: "note"
+ case .beat: "beat"
+ case .launch(let apps, _): "launch \(apps.joined(separator: "+"))"
+ case .quitApps: "quit-apps"
+ case .pointer(let display, _, _): "pointer display \(display)"
+ case .activateWorkspace(let workspace, _): "activate-workspace \(workspace)"
+ case .activateProfile(let profile): "activate-profile \(profile)"
+ case .cli(let arguments, _): "cli \(arguments.joined(separator: " "))"
+ case .key(let chord, _, _): "key \(chord)"
+ case .hold(let modifiers, let keys, _, _): "hold \(modifiers) + \(keys.joined(separator: ","))"
+ case .waitWorkspace(let workspace, _): "wait-workspace \(workspace)"
+ case .waitProfile(let profile, _): "wait-profile \(profile)"
+ case .borrow(let workspace, _, _): "borrow \(workspace)"
+ case .dismissBorrow: "dismiss-borrow"
+ case .appWindows(let app, let count): "app-windows \(app) \(count)"
+ case .chapter(let text): text.isEmpty ? "chapter (clear)" : "chapter \(text)"
+ case .caption(let text): text.isEmpty ? "caption (clear)" : "caption \(text)"
+ case .keys(let chord): chord.isEmpty ? "keys (clear)" : "keys \(chord)"
+ case .clearOverlay: "clear-overlay"
+ case .activateApp(let app): "activate-app \(app)"
+ }
+ }
+}
+
+// MARK: Decodable
+
+extension SceneStep: Decodable {
+
+ // MARK: Lifecycle
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let kind = try container.decode(String.self, forKey: .kind)
+
+ switch kind {
+ case "rightClick": self = .rightClick(app: try container.decode(String.self, forKey: .app), identifier: try container.decode(String.self, forKey: .identifier))
+ case "expectPlacement": self = .expectPlacement(app: try container.decode(String.self, forKey: .app), target: try container.decode(String.self, forKey: .target), edge: try container.decode(String.self, forKey: .value))
+ case "expectProfileCount": self = .expectProfileCount(try container.decode(Int.self, forKey: .count))
+ case "expectAssignment": self = .expectAssignment(app: try container.decode(String.self, forKey: .app), workspace: try container.decode(String.self, forKey: .workspace), profile: try container.decode(String.self, forKey: .profile))
+ case "waitWindows":
+ self = .waitWindows(apps: try container.decode([String].self, forKey: .apps),
+ timeoutMilliseconds: try container.decodeIfPresent(Int.self, forKey: .timeoutMs) ?? 12000)
+ case "saveLayout":
+ self = .saveLayout(name: try container.decode(String.self, forKey: .text))
+ case "assertLayout":
+ self = .assertLayout(name: try container.decode(String.self, forKey: .text))
+ case "clipboard": self = .clipboard(text:try container.decode(String.self,forKey:.text))
+ case "closeSettings": self = .closeSettings
+ case "prepareSettings": self = .prepareSettings
+ case "saveControlFrame": self = .saveControlFrame(app:try container.decode(String.self,forKey:.app),identifier:try container.decode(String.self,forKey:.identifier),name:try container.decode(String.self,forKey:.text))
+ case "expectControlMoved": self = .expectControlMoved(app:try container.decode(String.self,forKey:.app),identifier:try container.decode(String.self,forKey:.identifier),name:try container.decode(String.self,forKey:.text))
+ case "saveWindow": self = .saveWindow(app:try container.decode(String.self,forKey:.app))
+ case "restoreWindow": self = .restoreWindow(app:try container.decode(String.self,forKey:.app))
+ case "assertWindow": self = .assertWindow(app:try container.decode(String.self,forKey:.app))
+ case "expectPointer": self = .expectPointer(app:try container.decode(String.self,forKey:.app))
+ case "hover": self = .hover(app:try container.decode(String.self,forKey:.app),identifier:try container.decode(String.self,forKey:.identifier))
+ case "dragWindow": self = .dragWindow(app:try container.decode(String.self,forKey:.app),target:try container.decode(String.self,forKey:.target),x:try container.decodeIfPresent(Double.self,forKey:.x) ?? 0.5,y:try container.decodeIfPresent(Double.self,forKey:.y) ?? 0.5)
+ case "resizeWindow": self = .resizeWindow(app:try container.decode(String.self,forKey:.app),dx:try container.decode(Double.self,forKey:.x),dy:try container.decodeIfPresent(Double.self,forKey:.y) ?? 0)
+ case "expectFront": self = .expectFront(app:try container.decode(String.self,forKey:.app))
+ case "expectLayoutChanged": self = .expectLayoutChanged(name:try container.decode(String.self,forKey:.text))
+ case "configure": self = .configure(key:try container.decode(String.self,forKey:.field),value:try container.decode(String.self,forKey:.value))
+ case "virtualDisplay": self = .virtualDisplay(connected:try container.decode(Bool.self,forKey:.connected))
+ case "expectCommand": self = .expectCommand(command:try container.decode(String.self,forKey:.text),code:try container.decodeIfPresent(Int.self,forKey:.code) ?? 0)
+ case "expectHook": self = .expectHook(field:try container.decode(String.self,forKey:.field),value:try container.decode(String.self,forKey:.value))
+ case "click":
+ self = .click(app: try container.decode(String.self, forKey: .app), identifier: try container.decode(String.self, forKey: .identifier))
+ case "typeText":
+ self = .typeText(app: try container.decode(String.self, forKey: .app), text: try container.decode(String.self, forKey: .text), intervalMilliseconds: try container.decodeIfPresent(Int.self, forKey: .ms) ?? 55)
+ case "expectStory":
+ self = .expectStory(field: try container.decode(String.self, forKey: .field), value: try container.decode(String.self, forKey: .value))
+ case "expectValue":
+ self = .expectValue(app: try container.decode(String.self, forKey: .app), identifier: try container.decode(String.self, forKey: .identifier), value: try container.decode(String.self, forKey: .value))
+ case "scroll":
+ self = .scroll(app: try container.decode(String.self, forKey: .app), identifier: try container.decode(String.self, forKey: .identifier), pixels: try container.decode(Int.self, forKey: .pixels))
+ case "note":
+ self = .note(text: try container.decode(String.self, forKey: .text))
+
+ case "beat":
+ self = .beat(
+ milliseconds: try container.decode(Int.self, forKey: .ms),
+ note: try container.decodeIfPresent(String.self, forKey: .note)
+ )
+
+ case "launch":
+ self = .launch(
+ apps: try container.decode([String].self, forKey: .apps),
+ windows: try container.decodeIfPresent([String: Int].self, forKey: .windows) ?? [:]
+ )
+
+ case "quitApps":
+ self = .quitApps(apps: try container.decodeIfPresent([String].self, forKey: .apps))
+
+ case "pointer":
+ self = .pointer(
+ display: try container.decodeIfPresent(Int.self, forKey: .display) ?? 0,
+ x: try container.decodeIfPresent(Double.self, forKey: .x) ?? 0.5,
+ y: try container.decodeIfPresent(Double.self, forKey: .y) ?? 0.5
+ )
+
+ case "activateWorkspace":
+ self = .activateWorkspace(
+ workspace: try container.decode(String.self, forKey: .workspace),
+ profile: try container.decodeIfPresent(String.self, forKey: .profile)
+ )
+
+ case "activateProfile":
+ self = .activateProfile(profile: try container.decode(String.self, forKey: .profile))
+
+ case "cli":
+ self = .cli(
+ arguments: try container.decode([String].self, forKey: .args),
+ expect: try container.decodeIfPresent(String.self, forKey: .expect) ?? "accepted"
+ )
+
+ case "key":
+ self = .key(
+ chord: try container.decode(String.self, forKey: .chord),
+ repeats: try container.decodeIfPresent(Int.self, forKey: .repeats) ?? 1,
+ holdMilliseconds: try container.decodeIfPresent(Int.self, forKey: .holdMs) ?? 24
+ )
+
+ case "hold":
+ self = .hold(
+ modifiers: try container.decode(String.self, forKey: .modifiers),
+ keys: try container.decode([String].self, forKey: .keys),
+ gapMilliseconds: try container.decodeIfPresent(Int.self, forKey: .gapMs) ?? 320,
+ releaseAfterMilliseconds: try container.decodeIfPresent(Int.self, forKey: .releaseAfterMs) ?? 700
+ )
+
+ case "waitWorkspace":
+ self = .waitWorkspace(
+ workspace: try container.decode(String.self, forKey: .workspace),
+ timeoutMilliseconds: try container.decodeIfPresent(Int.self, forKey: .timeoutMs) ?? 10_000
+ )
+
+ case "waitProfile":
+ self = .waitProfile(
+ profile: try container.decode(String.self, forKey: .profile),
+ timeoutMilliseconds: try container.decodeIfPresent(Int.self, forKey: .timeoutMs) ?? 10_000
+ )
+
+ case "borrow":
+ self = .borrow(
+ workspace: try container.decode(String.self, forKey: .workspace),
+ expectApps: try container.decodeIfPresent([String].self, forKey: .expectApps) ?? [],
+ timeoutMilliseconds: try container.decodeIfPresent(Int.self, forKey: .timeoutMs) ?? 10_000
+ )
+
+ case "dismissBorrow":
+ self = .dismissBorrow(
+ settleMilliseconds: try container.decodeIfPresent(Int.self, forKey: .settleMs) ?? 900
+ )
+
+ case "appWindows":
+ self = .appWindows(
+ app: try container.decode(String.self, forKey: .app),
+ count: try container.decode(Int.self, forKey: .count)
+ )
+
+ case "chapter":
+ self = .chapter(text: try container.decode(String.self, forKey: .text))
+
+ case "caption":
+ // Required even when it is empty: an omitted text would read as "leave the
+ // caption alone", and a step that changes nothing is never what a scene
+ // meant to write.
+ self = .caption(text: try container.decode(String.self, forKey: .text))
+
+ case "keys":
+ self = .keys(chord: try container.decode(String.self, forKey: .chord))
+
+ case "clearOverlay":
+ self = .clearOverlay
+
+ case "activateApp":
+ self = .activateApp(app: try container.decode(String.self, forKey: .app))
+
+ default:
+ throw DecodingError.dataCorruptedError(
+ forKey: .kind,
+ in: container,
+ debugDescription: "unknown scene step kind \"\(kind)\""
+ )
+ }
+ }
+
+ // MARK: Private
+
+ private enum CodingKeys: String, CodingKey {
+ case kind, text, ms, note, apps, windows, display, x, y, app, state, count
+ case identifier, field, value, pixels, target, connected, code
+ case workspace, profile, args, expect, chord, repeats, holdMs
+ case modifiers, keys, gapMs, releaseAfterMs, timeoutMs, expectApps, settleMs
+ }
+
+}
+
+// MARK: - SceneLoader
+
+public enum SceneLoader {
+
+ // MARK: Public
+
+ public static func available(in paths: LabPaths) -> [String] {
+ let contents = (try? FileManager.default.contentsOfDirectory(
+ at: paths.scenesRoot,
+ includingPropertiesForKeys: nil
+ )) ?? []
+ return contents
+ .filter { $0.pathExtension == "json" }
+ .map { $0.deletingPathExtension().lastPathComponent }
+ .sorted()
+ }
+
+ public static func load(_ name: String, paths: LabPaths) throws -> Scene {
+ let url = paths.sceneFile(name)
+ guard let data = try? Data(contentsOf: url) else {
+ throw DemoCtlError.sceneNotFound(name, available: available(in: paths))
+ }
+ return try JSONDecoder().decode(Scene.self, from: data)
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/SceneRunner.swift b/DemoLab/Sources/DemoCtlKit/SceneRunner.swift
new file mode 100644
index 0000000..5f167c1
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/SceneRunner.swift
@@ -0,0 +1,736 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import CoreGraphics
+import DemoAppKit
+import DemoDriverKit
+import Foundation
+
+// MARK: - OverlayMode
+
+/// How much of the live narration layer is drawn while a scene plays.
+///
+/// A *recorded* take draws none of it. Narration is an ASS sidecar, and a line
+/// drawn into the frame as well as written to the sidecar would appear twice the
+/// moment anyone burned the sidecar in, with neither copy restylable.
+///
+/// The keycast used to stay live, on the reasoning that its timing was the thing
+/// it was showing. It cost three takes to establish that the timing is not at
+/// risk and the pixels are: ``SceneTimeline`` stamps the caps from the same
+/// clock that drives the overlay, so a burned copy is exact to the centisecond,
+/// while the live panel raced the capture stream. Takes came back with the first
+/// one to three keycasts simply missing from the frames, at a recovery point
+/// that moved between runs, while `screencapture` showed them on screen the
+/// whole time. Ordering the panel front, rebuilding it into the live stream and
+/// priming it with a first draw were all tried, and all of them only moved the
+/// recovery point. A track that is written to a file cannot be lost by a capture
+/// at all, which is why `off` is what a take uses.
+///
+/// The live modes remain for `democtl scene`, which narrates a rehearsal that
+/// nothing is recording.
+public enum OverlayMode: String, Sendable, CaseIterable {
+ case full
+ case keys
+ case off
+
+ // MARK: Public
+
+ /// The overlay's own spelling of the same three states. `full` is `all`
+ /// there: the overlay names what it draws, this names what a take wants.
+ public var command: String {
+ switch self {
+ case .full: "mode all"
+ case .keys: "mode keys"
+ case .off: "mode off"
+ }
+ }
+
+ /// The tracks this mode paints into the frame while the take is running.
+ ///
+ /// The burnable sidecar carries the complement of this, so burning it can
+ /// never draw a second copy of something already in the pixels. A take shot
+ /// with `keys` once produced exactly that: styled caps bottom-right from the
+ /// overlay, and the raw `ctrl + alt - l` from the sidecar just above them.
+ public var liveTracks: Set {
+ switch self {
+ case .full: Set(SceneTimeline.Track.allCases)
+ case .keys: [.keys]
+ case .off: []
+ }
+ }
+}
+
+// MARK: - SceneRunner
+
+/// Plays a scene against the real Tatami.
+///
+/// Two rules run through every step:
+///
+/// 1. **Never treat `accepted` as confirmation.** Tatami's dispatcher answers
+/// the moment a command is enqueued. Where a real barrier exists — an
+/// activation reply, a hook event, a window appearing — the runner waits for
+/// it. Where none exists, it waits a fixed, declared amount of time and the
+/// scene file says so.
+/// 2. **Fail loudly.** A step that cannot be confirmed aborts the take. A demo
+/// that silently records nothing happening is worse than one that stops.
+@MainActor
+public final class SceneRunner {
+
+ // MARK: Lifecycle
+
+ public init(
+ paths: LabPaths,
+ client: TatamiClient,
+ apps: DemoAppsController,
+ log: @escaping (String) -> Void,
+ timeline: SceneTimeline? = nil,
+ overlayMode: OverlayMode = .full,
+ keycastHoldMilliseconds: Int = SceneRunner.defaultKeycastHoldMilliseconds
+ ) {
+ self.paths = paths
+ self.client = client
+ self.apps = apps
+ self.log = log
+ self.timeline = timeline
+ self.overlayMode = overlayMode
+ self.keycastHoldMilliseconds = keycastHoldMilliseconds
+ overlay = OverlayController(paths: paths)
+ }
+
+ // MARK: Public
+
+ /// How long the keycaps stay up after the keystroke has been posted.
+ ///
+ /// They used to be cleared the instant the driver returned, which is a few
+ /// tens of milliseconds: on camera the caps flashed past and a viewer could
+ /// not read which shortcut was pressed. The scene files shortened their beats
+ /// after each key step to pay for this hold, so a take does not get longer.
+ public static let defaultKeycastHoldMilliseconds = 1200
+
+ public let paths: LabPaths
+ public let client: TatamiClient
+ public let apps: DemoAppsController
+ public let log: (String) -> Void
+ /// Records what the narration said and when, for the sidecars written next to
+ /// the movie. Nil when nothing is being recorded, which is why every use of it
+ /// is optional rather than the runner carrying a timeline nobody reads.
+ public let timeline: SceneTimeline?
+ public let overlayMode: OverlayMode
+ public let keycastHoldMilliseconds: Int
+
+ // MARK: Private
+
+ private let overlay: OverlayController
+ private var clipboardBackup: [[NSPasteboard.PasteboardType:Data]]?
+ private var windowFrames: [String:CGRect] = [:]
+ private var layouts: [String: [String: CGRect]] = [:]
+ private var markBeforePreviousStep: UInt64 = 0
+ private var markBeforeCurrentStep: UInt64 = 0
+
+ public func prepare(_ scene: Scene, dryRun: Bool = false) throws {
+ if !dryRun, let count = scene.requires?.displays, DisplayInfo.all().count < count {
+ throw DemoCtlError.usage("scene requires \(count) displays")
+ }
+ var preparation = scene
+ preparation.steps = scene.setup ?? []
+ try run(preparation, dryRun: dryRun)
+ if !dryRun {
+ try CaptureGate.waitForApps(scene.openingApps ?? [], timeout: .seconds(15))
+ if let names = scene.openingApps { try CaptureGate.requireOpening(names) }
+ try CaptureGate.requireCleanDesktop()
+ }
+ }
+
+ public func run(_ scene: Scene, dryRun: Bool = false) throws {
+ let events = EventLog(url: paths.eventLog)
+
+ log("scene \(scene.name): \(scene.title)")
+ if let summary = scene.summary { log(" \(summary)") }
+ if let requires = scene.requires { logRequirements(requires) }
+ log("")
+
+ // Key steps need Accessibility. Check once, up front, rather than failing
+ // in the middle of a take: a driver that silently posts nothing would
+ // record a video of a demo that never happens.
+ if !dryRun, scene.steps.contains(where: \.needsKeyboard), !KeyDriver.isTrusted {
+ throw DemoCtlError.usage(
+ """
+ this scene types real shortcuts, but this process is not trusted for Accessibility.
+ Grant it in System Settings > Privacy & Security > Accessibility, then run the scene again.
+ (`democtl doctor` shows the current state.)
+ """
+ )
+ }
+
+ if !dryRun { try applyOverlayMode() }
+
+ for (index, step) in scene.steps.enumerated() {
+ let ordinal = String(format: "%2d", index + 1)
+ log("[\(ordinal)] \(step.label)")
+ guard !dryRun else { continue }
+ // A `wait` step must be able to see the events produced by the step
+ // *before* it. Sampling the log offset only when the wait runs would race:
+ // a fast activation can land before the wait ever looks.
+ markBeforePreviousStep = markBeforeCurrentStep
+ markBeforeCurrentStep = events.mark
+ do {
+ try CaptureGate.requireCleanDesktop()
+ try perform(step, events: events)
+ try CaptureGate.requireCleanDesktop()
+ } catch let error as DemoCtlError {
+ throw DemoCtlError.sceneStepFailed(index: index + 1, step: step.label, message: error.description)
+ } catch {
+ throw DemoCtlError.sceneStepFailed(index: index + 1, step: step.label, message: "\(error)")
+ }
+ }
+
+ log("")
+ log("scene \(scene.name) complete")
+ }
+
+ public var secondaryCapture: (output: URL, fps: Int)?
+ private var secondaryOffset: Double?
+
+ public func finishSecondaryCapture() throws {
+ guard let capture = secondaryCapture, let offset = secondaryOffset else { return }
+ let recorder = RecorderController(paths: paths, recordingName: "recorder-secondary")
+ let end = try RecorderController(paths: paths).elapsedSinceFirstFrame()
+ _ = try recorder.stop()
+ let stats = try recorder.outputStatistics(for: capture.output)
+ guard let frames = stats["frames"], frames > 0,
+ Double(stats["dropped"] ?? 0) / Double(frames) <= 0.01
+ else { throw DemoCtlError.usage("secondary display capture failed quality checks") }
+ let data: [String: Any] = ["start": offset, "end": end, "frames": frames,
+ "droppedFrames": stats["dropped"] ?? 0, "capture": try recorder.captureMetadata(for: capture.output)]
+ try JSONSerialization.data(withJSONObject: data, options: [.prettyPrinted, .sortedKeys])
+ .write(to: capture.output.deletingPathExtension().appendingPathExtension("json"), options: .atomic)
+ secondaryOffset = nil
+ }
+
+ private func logRequirements(_ requires: SceneRequirements) {
+ var lines = [String]()
+ if let displays = requires.displays { lines.append("displays: \(displays)") }
+ if let profile = requires.profile { lines.append("starting profile: \(profile)") }
+ if let apps = requires.apps { lines.append("apps: \(apps.joined(separator: ", "))") }
+ if let note = requires.note { lines.append(note) }
+ for line in lines { log(" requires \(line)") }
+ }
+
+ private func specs(_ names: [String]) throws -> [DemoAppSpec] {
+ try names.map { try spec($0) }
+ }
+
+ private func perform(_ step: SceneStep, events: EventLog) throws {
+ switch step {
+ case .clipboard(let text):
+ if clipboardBackup == nil {
+ clipboardBackup=(NSPasteboard.general.pasteboardItems ?? []).map {item in
+ Dictionary(uniqueKeysWithValues:item.types.compactMap {type in item.data(forType:type).map {(type,$0)}})
+ }
+ }
+ NSPasteboard.general.clearContents()
+ guard NSPasteboard.general.setString(text,forType:.string) else {throw DemoCtlError.usage("could not prepare clipboard")}
+ case .closeSettings:
+ guard TatamiProcess.closeOwnWindows()>0 else {throw DemoCtlError.usage("Tatami window could not be closed")}
+ case .saveControlFrame(let app,let id,let name): windowFrames[name]=try NativeInteractionDriver.controlFrame(bundleIdentifier:bundle(app),identifier:id)
+ case .expectControlMoved(let app,let id,let name):
+ guard let expected=windowFrames[name] else {throw DemoCtlError.usage("missing control checkpoint")}
+ guard try Shell.wait(timeout:.seconds(4),until:{!CaptureGate.matches([name:try NativeInteractionDriver.controlFrame(bundleIdentifier:bundle(app),identifier:id)],[name:expected])}) else {throw DemoCtlError.usage("control geometry did not change: \(name)")}
+ case .saveWindow(let app): windowFrames[app]=try NativeInteractionDriver.windowFrame(bundleIdentifier:bundle(app))
+ case .restoreWindow(let app):
+ guard let saved = windowFrames[app] else { throw DemoCtlError.usage("missing saved window: \(app)") }
+ try activate(app: app)
+ let identifier = try bundle(app)
+ var current = try NativeInteractionDriver.windowFrame(bundleIdentifier: identifier)
+ if abs(current.width - saved.width) > 4 || abs(current.height - saved.height) > 4 {
+ let corner = CGPoint(x: current.maxX - 4, y: current.maxY - 4)
+ try NativeInteractionDriver.drag(from: corner, to: CGPoint(x: corner.x + saved.width - current.width, y: corner.y + saved.height - current.height))
+ }
+ current = try NativeInteractionDriver.windowFrame(bundleIdentifier: identifier)
+ try NativeInteractionDriver.drag(from: CGPoint(x: current.midX, y: current.minY + 14), to: CGPoint(x: saved.midX, y: saved.minY + 14))
+ case .assertWindow(let app):
+ guard let expected=windowFrames[app] else {throw DemoCtlError.usage("missing window checkpoint")}
+ let actual=try NativeInteractionDriver.windowFrame(bundleIdentifier:bundle(app))
+ guard CaptureGate.matches([app:actual],[app:expected]) else {throw DemoCtlError.usage("window frame changed: \(app); expected \(expected); actual \(actual)")}
+ case .expectPointer(let app):
+ let frame=try NativeInteractionDriver.windowFrame(bundleIdentifier:bundle(app))
+ guard let point=CGEvent(source:nil)?.location,frame.contains(point) else {throw DemoCtlError.usage("pointer did not follow focus to \(app)")}
+ case .hover(let app,let id): try NativeInteractionDriver.hover(bundleIdentifier:bundle(app),identifier:id)
+ case .dragWindow(let app,let target,let x,let y):
+ try activate(app:app)
+ let source=try NativeInteractionDriver.windowFrame(bundleIdentifier:bundle(app))
+ let destination=try NativeInteractionDriver.windowFrame(bundleIdentifier:bundle(target))
+ try NativeInteractionDriver.drag(from:CGPoint(x:source.midX,y:source.minY+14),to:CGPoint(x:destination.minX+destination.width*x,y:destination.minY+destination.height*y))
+ case .resizeWindow(let app,let dx,let dy):
+ try activate(app:app)
+ let frame=try NativeInteractionDriver.windowFrame(bundleIdentifier:bundle(app))
+ let point=CGPoint(x:frame.maxX-1,y:frame.midY)
+ try NativeInteractionDriver.drag(from:point,to:CGPoint(x:point.x+dx,y:point.y+dy))
+ case .prepareSettings:
+ try activate(app: "Tatami")
+ try NativeInteractionDriver.prepareSettingsWindow(bundleIdentifier: client.install.bundleIdentifier)
+ case .rightClick(let app, let identifier):
+ try NativeInteractionDriver.rightClick(bundleIdentifier: bundle(app), identifier: identifier)
+ case .expectProfileCount(let count):
+ guard try Shell.wait(timeout: .seconds(4), until: { try client.profiles().count == count })
+ else { throw DemoCtlError.usage("unexpected profile count") }
+ case .expectAssignment(let app, let workspace, let profile):
+ let identifier = try bundle(app)
+ guard try Shell.wait(timeout: .seconds(5), until: {
+ let rows = try client.json(["workspace", "apps", workspace, "--profile", profile]) as? [[String: Any]]
+ return rows?.contains { $0["bundleIdentifier"] as? String == identifier } == true
+ }) else { throw DemoCtlError.usage("copied app is missing from the target workspace") }
+ case .expectPlacement(let app, let target, let edge):
+ let sourceBundle = try bundle(app), targetBundle = try bundle(target)
+ guard ["left", "right", "above", "below"].contains(edge) else { throw DemoCtlError.usage("unknown placement edge") }
+ let passed = try Shell.wait(timeout: .seconds(5)) {
+ let frames = try CaptureGate.demoFrames()
+ let sources = frames.filter { $0.key.hasPrefix(sourceBundle + ":") }.map(\.value)
+ let targets = frames.filter { $0.key.hasPrefix(targetBundle + ":") }.map(\.value)
+ return !sources.isEmpty && !targets.isEmpty && sources.allSatisfy { source in
+ targets.allSatisfy { target in
+ switch edge {
+ case "left": source.maxX <= target.minX + 4
+ case "right": source.minX >= target.maxX - 4
+ case "above": source.maxY <= target.minY + 4
+ default: source.minY >= target.maxY - 4
+ }
+ }
+ }
+ }
+ guard passed else { throw DemoCtlError.usage("\(app) did not appear \(edge) of \(target)") }
+ case .expectFront(let app):
+ let id=try bundle(app)
+ guard Shell.wait(timeout:.seconds(4),until:{NativeInteractionDriver.frontmostBundleIdentifier()==id}) else {throw DemoCtlError.usage("focus did not reach \(app)")}
+ case .expectLayoutChanged(let name):
+ guard let expected=layouts[name] else {throw DemoCtlError.usage("missing layout checkpoint")}
+ guard try Shell.wait(timeout:.seconds(4),until:{!CaptureGate.matches(try CaptureGate.demoFrames(),expected)}) else {throw DemoCtlError.usage("layout \(name) did not change")}
+ case .configure(let key,let value):
+ try LiveConfigEditor.update(file:paths.configFile,key:key,value:value)
+ Shell.sleep(.milliseconds(800))
+ case .virtualDisplay(let connected):
+ if connected {
+ try VirtualDisplayController.connect(paths)
+ if let capture = secondaryCapture {
+ var ids = [CGDirectDisplayID](repeating: 0, count: 16)
+ var count: UInt32 = 0
+ guard secondaryOffset == nil, !FileManager.default.fileExists(atPath: capture.output.path),
+ CGGetOnlineDisplayList(16, &ids, &count) == .success,
+ let display = ids.prefix(Int(count)).first(where: { CGDisplayVendorNumber($0) == 0x5050 && CGDisplayModelNumber($0) == 0x1001 })
+ else { throw DemoCtlError.usage("expected a new secondary display capture") }
+ let secondary = RecorderController(paths: paths, recordingName: "recorder-secondary")
+ try secondary.start(output: capture.output, display: "id:\(display)", fps: capture.fps)
+ secondaryOffset = try RecorderController(paths: paths).elapsedSinceFirstFrame() - secondary.elapsedSinceFirstFrame()
+ }
+ } else {
+ try finishSecondaryCapture()
+ try VirtualDisplayController.disconnect(paths)
+ }
+ case .expectCommand(let command,let code):
+ let file=paths.controlDirectory.appendingPathComponent("terminal-result.json")
+ guard Shell.wait(timeout:.seconds(15),until:{
+ guard let data=try? Data(contentsOf:file),let result=try? JSONDecoder().decode(TerminalResult.self,from:data) else {return false}
+ return result.command==command && result.exitCode==Int32(code)
+ }) else {throw DemoCtlError.usage("command did not complete successfully: \(command)")}
+ case .expectHook(let field,let value):
+ let file=paths.controlDirectory.appendingPathComponent(field == "title" ? "hud-hook.json" : "workspace-hook.json")
+ guard Shell.wait(timeout:.seconds(5),until:{
+ guard let data=try? Data(contentsOf:file),let hook=try? JSONDecoder().decode(HookActivity.self,from:data) else {return false}
+ switch field {case "workspace":return hook.workspace==value;case "profile":return hook.profile==value;case "title":return hook.title.contains(value);default:return hook.event==value}
+ }) else {throw DemoCtlError.usage("hook did not report \(field) = \(value)")}
+ case .click(let name, let identifier):
+ // Reactivating an app while its popup menu is open can dismiss the menu
+ // before the intended item receives the click.
+ let menuItem = name == "Tatami"
+ ? try NativeInteractionDriver.isMenuItem(bundleIdentifier: bundle(name), identifier: identifier)
+ : false
+ if !menuItem { try activate(app: name) }
+ try NativeInteractionDriver.click(bundleIdentifier: bundle(name), identifier: identifier)
+ case .typeText(let name, let text, let interval):
+ let identifier = try bundle(name)
+ guard NativeInteractionDriver.frontmostBundleIdentifier() == identifier else {
+ throw DemoCtlError.usage("refusing to type into an unexpected frontmost app; expected \(name)")
+ }
+ try NativeInteractionDriver.type(text, intervalMilliseconds: interval)
+ case .expectValue(let name, let identifier, let value):
+ let actual = try NativeInteractionDriver.value(bundleIdentifier: bundle(name), identifier: identifier)
+ guard actual == value else { throw DemoCtlError.usage("native value mismatch for \(identifier): \(actual)") }
+ case .expectStory(let field, let value):
+ let repository = StoryRepository(file: paths.controlDirectory.appendingPathComponent("launch-story.json"))
+ let passed = try Shell.wait(timeout: .seconds(4)) {
+ let story = try repository.load()
+ switch field {
+ case "exported": return String(story.exported) == value
+ case "theme": return story.designTheme == value
+ case "headline": return story.headline == value
+ case "approved": return String(story.approved) == value
+ case "reviewComment": return story.reviewComment == value
+ case "lastTask": return story.tasks.last?.text == value
+ case "lastMessage": return story.messages.last?.text == value
+ case "checksPassed": return String(story.checks.filter(\.passed).count) == value
+ case "completedTasks": return String(story.tasks.filter(\.done).count) == value
+ default: throw DemoCtlError.usage("unknown story assertion: \(field)")
+ }
+ }
+ guard passed else { throw DemoCtlError.usage("saved work did not match: \(field) = \(value)") }
+ case .scroll(let name, let identifier, let pixels):
+ try NativeInteractionDriver.scroll(bundleIdentifier: bundle(name), identifier: identifier, pixels: pixels)
+ case .waitWindows(let names, let milliseconds):
+ try CaptureGate.waitForApps(names, timeout: .milliseconds(milliseconds))
+ case .saveLayout(let name):
+ layouts[name] = try CaptureGate.demoFrames()
+ case .assertLayout(let name):
+ guard let expected = layouts[name], !expected.isEmpty else {
+ throw DemoCtlError.usage("no layout checkpoint named \(name)")
+ }
+ let restored = try Shell.wait(timeout: .seconds(5)) {
+ CaptureGate.matches(try CaptureGate.demoFrames(), expected)
+ }
+ guard restored else { throw DemoCtlError.usage("layout \(name) was not restored") }
+ log(" verified layout restoration: \(name)")
+ case .note(let text):
+ log(" \(text)")
+
+ case .beat(let milliseconds, let note):
+ if let note { log(" \(note)") }
+ Shell.sleep(.milliseconds(milliseconds))
+
+ case .launch(let names, let windows):
+ var counts = [DemoAppID: Int]()
+ for (name, count) in windows {
+ guard let spec = DemoCatalog.spec(named: name) else {
+ throw DemoCtlError.usage("unknown demo app \"\(name)\" in windows map")
+ }
+ counts[spec.id] = count
+ }
+ try apps.launch(try specs(names), windowCounts: counts)
+
+ case .quitApps(let names):
+ if let names {
+ apps.quit(try specs(names))
+ } else {
+ apps.quitAll()
+ }
+
+ case .pointer(let display, let x, let y):
+ // Placement of a dynamic workspace follows the pointer, so parking the
+ // cursor is what makes multi-display placement reproducible.
+ try Pointer.warp(toDisplayIndex: display, unitX: x, unitY: y)
+
+ case .activateWorkspace(let workspace, let profile):
+ try client.activateWorkspace(workspace, profile: profile)
+
+ case .activateProfile(let profile):
+ try client.activateProfile(profile)
+
+ case .cli(let arguments, let expect):
+ switch expect {
+ case "any": _ = try client.json(arguments)
+ case "completed", "accepted": try expectStatus(expect, arguments)
+ default: throw DemoCtlError.usage("cli step expect must be accepted, completed or any")
+ }
+
+ case .key(let chord, let repeats, let holdMilliseconds):
+ var notes: [String] = []
+ let parsed = try ChordParser.parse(chord, notes: ¬es)
+ // A layout note means the demo is pressing a different physical key than
+ // the shortcut text reads as. It belongs in the take's log, not nowhere.
+ for note in notes { log(" \(note)") }
+ // The keycaps belong to the step, not to a separate scene line: the runner
+ // already knows the chord it is about to press, and a scene file that had
+ // to repeat it could show one shortcut while pressing another.
+ try showKeycaps(chord)
+ do {
+ for _ in 0.. 0 }
+ }
+ guard appeared else {
+ throw DemoCtlError.waitTimedOut(
+ what: "borrowed \(workspace) to show \(expectApps.joined(separator: ", "))",
+ seconds: Double(timeoutMilliseconds) / 1000
+ )
+ }
+
+ case .dismissBorrow(let settleMilliseconds):
+ try client.dispatch(["workspace", "dismiss-borrow"])
+ // Returning a borrow re-activates the host, but does not publish an event
+ // the lab can key on, so this settle is a declared wait rather than a
+ // barrier. It is in the scene file so it is visible and tunable.
+ Shell.sleep(.milliseconds(settleMilliseconds))
+
+ case .appWindows(let name, let count):
+ let target = try spec(name)
+ let reply = try apps.setWindowCount(count, of: target)
+ log(" \(reply)")
+ // Windows are Tatami's input: give the AX observers the same moment to
+ // retile that a launch step gives them.
+ Shell.sleep(.milliseconds(350))
+
+ // Narration a scene asked for by name is load bearing: a take that recorded
+ // the actions without the words explaining them is the wrong video, so these
+ // steps fail when the overlay is not there. The keycaps a `key` step adds by
+ // itself are the opposite case, and stay quiet.
+ //
+ // The timeline is stamped *before* the overlay is told, so a sidecar records
+ // the narration a take asked for even when the overlay is the thing that
+ // failed. In `--overlay keys` the overlay draws none of this, and the
+ // sidecar is then the only copy of the words.
+ case .chapter(let text):
+ timeline?.record(.chapter, text: text)
+ if overlayMode == .full { try overlay.chapter(text) }
+
+ case .caption(let text):
+ timeline?.record(.caption, text: text)
+ if overlayMode == .full { try overlay.caption(text) }
+
+ case .keys(let chord):
+ timeline?.record(.keys, text: chord)
+ if overlayMode != .off { try overlay.keys(chord) }
+
+ case .clearOverlay:
+ for track in SceneTimeline.Track.allCases { timeline?.record(track, text: "") }
+ if OverlayController.isListening { try overlay.clear() }
+
+ case .activateApp(let name):
+ try activate(app: name)
+ }
+ }
+
+ public func restoreClipboard() {
+ guard let backup=clipboardBackup else {return}
+ let items=backup.map {values in let item=NSPasteboardItem();for (type,data) in values {item.setData(data,forType:type)};return item}
+ NSPasteboard.general.clearContents();NSPasteboard.general.writeObjects(items)
+ clipboardBackup=nil
+ }
+
+ private func bundle(_ name:String) throws -> String {
+ if name == "Tatami" {return client.install.bundleIdentifier}
+ return try spec(name).bundleIdentifier
+ }
+
+ private func spec(_ name: String) throws -> DemoAppSpec {
+ guard let spec = DemoCatalog.spec(named: name) else {
+ throw DemoCtlError.usage(
+ "unknown demo app \"\(name)\" (\(DemoCatalog.all.map(\.name).joined(separator: ", ")))"
+ )
+ }
+ return spec
+ }
+
+ /// Shows or clears the keycaps a `key` or `hold` step is about to press.
+ ///
+ /// Silent when the overlay is not running: those keycaps are something the
+ /// runner adds on its own, and a scene that never asked for narration must
+ /// still play on a machine with no overlay. A *running* overlay that refuses
+ /// the command is a real failure and is reported as one, exactly like the
+ /// explicit `caption` and `chapter` steps.
+ ///
+ /// The timeline is stamped either way. A take shot without the overlay still
+ /// deserves a sidecar that says which shortcut was pressed and when.
+ private func showKeycaps(_ chord: String) throws {
+ timeline?.record(.keys, text: chord)
+ guard overlayMode != .off else { return }
+ guard OverlayController.isListening else {
+ // Silent until now, and the silence was the bug: a scene that lost the
+ // overlay mid-take went on stamping keycasts into the sidecar that were
+ // never on screen.
+ log("warning: no overlay is listening, so \(chord) is in the sidecar but not in the frames")
+ return
+ }
+ let reply = try overlay.keys(chord)
+ // A live rehearsal should report a hidden panel. Recorded takes use sidecars.
+ if reply.contains("hidden=true") || reply.contains("panels=0/") {
+ log("warning: the overlay could not draw \(chord) (\(reply)); this keycast is missing from the frames")
+ }
+ }
+
+ /// Leaves the keycaps up long enough to read.
+ ///
+ /// The sidecar gets the same duration for free: the ASS entry runs from the
+ /// moment the caps went up to the moment ``showKeycaps`` clears them, and both
+ /// ends are stamped off the same clock the live overlay was driven by. There
+ /// is no second number that could drift out of agreement with this one.
+ private func holdKeycaps() {
+ guard keycastHoldMilliseconds > 0 else { return }
+ Shell.sleep(.milliseconds(keycastHoldMilliseconds))
+ }
+
+ /// Tells a running overlay how much of the narration layer to draw.
+ ///
+ /// Silent when the overlay is not running, for the same reason ``showKeycaps``
+ /// is. A *running* overlay that refuses is a real failure and stops the take:
+ /// an overlay too old to know `mode` would otherwise burn captions into a take
+ /// that also ships them as a sidecar, and nothing would say so until the
+ /// duplicates showed up in a burned copy.
+ private func applyOverlayMode() throws {
+ guard OverlayController.isListening else { return }
+ _ = try overlay.send(overlayMode.command)
+ log("overlay: \(overlayMode.rawValue)")
+ log("")
+ }
+
+ /// The keycap label for a `hold` step: the modifiers that stay down, then the
+ /// key or keys tapped under them. Consecutive repeats collapse, so holding
+ /// ctrl+alt and tapping `2` three times reads as one keycap rather than three.
+ private static func holdLabel(modifiers: String, keys: [String]) -> String {
+ var tapped = [String]()
+ for key in keys where tapped.last != key { tapped.append(key) }
+ return "\(modifiers) - \(tapped.joined(separator: " "))"
+ }
+
+ /// Brings an app to the front. `"Tatami"` means the Tatami this lab drives,
+ /// resolved through the located install so a Debug build is addressed by its
+ /// own bundle identifier.
+ private func activate(app name: String) throws {
+ let identifier: String
+ var demoApp: DemoAppSpec?
+ if name.caseInsensitiveCompare("Tatami") == .orderedSame {
+ identifier = client.install.bundleIdentifier
+ } else {
+ let spec = try spec(name)
+ demoApp = spec
+ identifier = spec.bundleIdentifier
+ }
+ guard let application = NSRunningApplication
+ .runningApplications(withBundleIdentifier: identifier).first
+ else {
+ throw DemoCtlError.usage("activateApp: \(name) (\(identifier)) is not running")
+ }
+ if NativeInteractionDriver.frontmostBundleIdentifier() == identifier { return }
+ if demoApp != nil {
+ // A real title-bar click has a user activation context. Background
+ // activate() requests can be declined even when the control socket replies.
+ try NativeInteractionDriver.clickWindowTitle(bundleIdentifier:identifier)
+ } else {
+ try NativeInteractionDriver.clickSettingsWindowTitle(bundleIdentifier: identifier)
+ }
+ // Only an app with a Dock tile can become frontmost. Tatami is an accessory
+ // app, so waiting for it there would time out on a step that worked.
+ guard application.activationPolicy == .regular else {
+ Shell.sleep(.milliseconds(200))
+ return
+ }
+ let front = Shell.wait(timeout: .seconds(4)) {
+ NativeInteractionDriver.frontmostBundleIdentifier() == identifier
+ }
+ guard front else {
+ throw DemoCtlError.waitTimedOut(what: "\(name) to become frontmost", seconds: 4)
+ }
+ }
+
+ private func expectStatus(_ status: String, _ arguments: [String]) throws {
+ if status == "completed" {
+ let value = try client.json(arguments)
+ guard let object = value as? [String: Any], object["status"] as? String == "completed" else {
+ throw DemoCtlError.tatamiCommandFailed(
+ command: arguments.joined(separator: " "),
+ message: "expected status completed"
+ )
+ }
+ } else {
+ try client.dispatch(arguments)
+ }
+ }
+
+ /// The modifier half of a `hold` step, read with the same grammar as the
+ /// modifier half of a chord, so the two front ends cannot drift apart.
+ private static func flags(from text: String) throws -> CGEventFlags {
+ let flags: CGEventFlags
+ do {
+ flags = try ChordParser.parseModifiers(text)
+ } catch {
+ throw DemoCtlError.usage("bad modifiers \"\(text)\" in hold step: \(error)")
+ }
+ guard !flags.isEmpty else { throw DemoCtlError.usage("hold step needs at least one modifier") }
+ return flags
+ }
+
+}
+
+extension SceneStep {
+ fileprivate var needsKeyboard: Bool {
+ switch self {
+ case .key, .hold, .click, .rightClick, .prepareSettings, .typeText, .scroll, .hover, .dragWindow, .resizeWindow: true
+ default: false
+ }
+ }
+}
diff --git a/DemoLab/Sources/DemoCtlKit/SceneTimeline.swift b/DemoLab/Sources/DemoCtlKit/SceneTimeline.swift
new file mode 100644
index 0000000..42bb565
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/SceneTimeline.swift
@@ -0,0 +1,448 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import Foundation
+
+// MARK: - SceneTimeline
+
+/// Every word the narration put on screen during a take, and the times it was
+/// there.
+///
+/// Captions used to be burned into the frame as the take was shot, which made
+/// the words as permanent as the pixels: restyling them, fixing a typo, or
+/// translating them all meant shooting the take again. This records them
+/// instead, and ``SceneSubtitles`` turns the record into an ASS sidecar that any
+/// player can restyle and any translator can edit.
+///
+/// Times are measured from a `t0` the caller supplies rather than from the first
+/// event, because a sidecar has to line up with the movie: `t0` is the moment
+/// the recorder started, so a take's pre-roll is part of the offset.
+///
+/// This is the one part of the lab that reads a clock, and it is measuring the
+/// take rather than rendering it. `ContinuousClock` and never `Date`: these are
+/// elapsed times, and a wall clock can step underneath them.
+@MainActor
+public final class SceneTimeline {
+
+ // MARK: Lifecycle
+
+ /// - Parameters:
+ /// - scene: named in both sidecars, so a stray file can be traced back.
+ /// - t0: the instant recording started. A caller that samples this *after*
+ /// the pre-roll shifts every subtitle in the file by that pre-roll.
+ public init(scene: String, t0: ContinuousClock.Instant) {
+ self.scene = scene
+ self.t0 = t0
+ }
+
+ // MARK: Public
+
+ /// The three things the overlay draws, and so the three things a sidecar has
+ /// to carry. Each is independent: a caption replaces a caption, never a
+ /// chapter.
+ public enum Track: String, Sendable, CaseIterable {
+ case chapter
+ case caption
+ case keys
+ }
+
+ // MARK: - Entry
+
+ /// One stretch of narration: what was on that track, and between which times.
+ public struct Entry: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(track: Track, text: String, start: Double, end: Double) {
+ self.track = track
+ self.text = text
+ self.start = start
+ self.end = end
+ }
+
+ // MARK: Public
+
+ public let track: Track
+ public let text: String
+ public let start: Double
+ public let end: Double
+
+ /// A clear. It carries no words, so it becomes a gap in the subtitles rather
+ /// than a line, but it still records the moment the words left the screen.
+ public var isClear: Bool { text.isEmpty }
+
+ }
+
+ /// The separator between a caption's headline and the why behind it, spelled
+ /// once. The live overlay splits on the same string, so the sidecar and the
+ /// screen cannot disagree about where the second line begins.
+ ///
+ /// `nonisolated` because it is an immutable name: the writer that splits on it
+ /// renders text and has no business hopping to the main actor to read a
+ /// constant.
+ public nonisolated static let captionSeparator = " | "
+
+ public let scene: String
+
+ /// Every recorded event with its end resolved: the next event on the same
+ /// track, or the end of the take.
+ public var entries: [Entry] {
+ records.map { record in
+ let close = record.end ?? endOfTake ?? record.start
+ return Entry(
+ track: record.track,
+ text: record.text,
+ start: Self.rounded(record.start),
+ end: Self.rounded(max(close, record.start))
+ )
+ }
+ }
+
+ /// How long the take ran, or how far it got before it failed.
+ public var durationSeconds: Double {
+ Self.rounded(endOfTake ?? records.map(\.start).max() ?? 0)
+ }
+
+ /// Stamps `text` onto `track` at the current elapsed time.
+ ///
+ /// Empty text is a clear, and is recorded rather than dropped: whoever
+ /// retimes or translates this needs to know when the words left the screen,
+ /// not only when they arrived.
+ public func record(_ track: Track, text: String) {
+ record(track, text: text, at: elapsed)
+ }
+
+ public func record(_ track: Track, text: String, at seconds: Double) {
+ // The overlay holds whatever it was last told, so re-sending the same words
+ // changes nothing on screen. Splitting them into two entries would put a
+ // one-frame flicker into a burned copy that the live take never had.
+ if let index = openIndex(of: track), records[index].text == text { return }
+ closeOpen(track, at: seconds)
+ records.append(Record(track: track, text: text, start: seconds, end: nil))
+ }
+
+ /// Ends the take. Whatever each track was still showing ends here, which is
+ /// what makes the last caption run to the last frame instead of vanishing.
+ public func finish(at seconds: Double? = nil) {
+ let end = seconds ?? elapsed
+ endOfTake = end
+ for track in Track.allCases { closeOpen(track, at: end) }
+ }
+
+ // MARK: Private
+
+ // MARK: - Record
+
+ /// The mutable form. `end` is nil while that stretch of narration is still on
+ /// screen, which is what lets a later event close it.
+ private struct Record {
+ let track: Track
+ let text: String
+ let start: Double
+ var end: Double?
+ }
+
+ private let t0: ContinuousClock.Instant
+ private var records = [Record]()
+ private var endOfTake: Double?
+
+ /// Centiseconds, because that is all an ASS timestamp can express. Rounding
+ /// here rather than in the writer keeps the JSON sidecar and the ASS sidecar
+ /// describing the same instants rather than two roundings of them.
+ private static func rounded(_ seconds: Double) -> Double {
+ (max(0, seconds) * 100).rounded() / 100
+ }
+
+ private var elapsed: Double {
+ Shell.seconds(ContinuousClock.now - t0)
+ }
+
+ private func openIndex(of track: Track) -> Int? {
+ records.lastIndex { $0.track == track && $0.end == nil }
+ }
+
+ private func closeOpen(_ track: Track, at seconds: Double) {
+ guard let index = openIndex(of: track) else { return }
+ // Never before its own start: a clock that measured the two events in the
+ // wrong order must not produce a negative duration downstream.
+ records[index].end = max(seconds, records[index].start)
+ }
+
+}
+
+// MARK: - SceneSubtitles
+
+/// Renders a ``SceneTimeline`` as the two sidecars that travel with a take: the
+/// ASS subtitles, and the raw event list for anything else that wants them.
+///
+/// Getting ASS wrong is quiet. A malformed timestamp, an unescaped brace or a
+/// style name that does not exist does not raise anything: the line simply never
+/// appears. Everything here is written for that.
+public enum SceneSubtitles {
+
+ // MARK: Public
+
+ // MARK: - Resolution
+
+ /// The pixel size of the movie the subtitles belong to.
+ public struct Resolution: Sendable, Equatable {
+
+ // MARK: Lifecycle
+
+ public init(width: Int, height: Int) {
+ self.width = width
+ self.height = height
+ }
+
+ // MARK: Public
+
+ /// What the demo VM records at, and only a last resort. `PlayResX`/`PlayResY`
+ /// that do not match the movie scale every font size and margin in the file
+ /// by the mismatch, silently.
+ public static let fallback = Resolution(width: 1920, height: 1200)
+
+ public let width: Int
+ public let height: Int
+
+ }
+
+ /// The whole `.ass` file.
+ ///
+ /// - Parameter drawnLive: tracks the overlay already painted into the frames.
+ /// They are left out, because this file exists to be burned in and burning
+ /// a track that is already in the pixels draws it twice. A take shot with
+ /// `--overlay keys` did exactly that once: the overlay's `⌃ ⌥ L` with the
+ /// sidecar's copy stacked above it. The full record of the take, live
+ /// tracks included, is in the `.timeline.json` beside this.
+ @MainActor
+ public static func ass(
+ _ timeline: SceneTimeline,
+ resolution: Resolution,
+ drawnLive: Set = []
+ ) -> String {
+ let omitted = SceneTimeline.Track.allCases
+ .filter { drawnLive.contains($0) }
+ .map(\.rawValue)
+ var lines = [
+ "[Script Info]",
+ "; Written by democtl for scene \(timeline.scene).",
+ "; Restyle or translate this file and burn it in with:",
+ "; democtl subtitle burn --ass ",
+ omitted.isEmpty
+ ? "; Carries every track: the take was shot with the overlay off."
+ : "; Omits \(omitted.joined(separator: ", ")): the take already has "
+ + "\(omitted.count == 1 ? "that" : "those") drawn into the frames.",
+ "Title: \(timeline.scene)",
+ "ScriptType: v4.00+",
+ // 0 = wrap inside the margins, evenly. `\N` is still a hard break in this
+ // mode, so a caption keeps its two lines; what this buys is that a long
+ // one folds rather than running off both edges of the frame, which is what
+ // WrapStyle 2 does and does silently.
+ "WrapStyle: 0",
+ "ScaledBorderAndShadow: yes",
+ "YCbCr Matrix: None",
+ "PlayResX: \(resolution.width)",
+ "PlayResY: \(resolution.height)",
+ "",
+ "[V4+ Styles]",
+ "Format: \(styleFormat)",
+ ]
+ lines += styles(for: resolution).map(\.row)
+ lines += ["", "[Events]", "Format: \(eventFormat)"]
+ lines += timeline.entries
+ .filter { !drawnLive.contains($0.track) }
+ .compactMap { dialogue($0, resolution: resolution) }
+ // A trailing newline: a file that ends mid-line is a file some parsers drop
+ // the last event from.
+ return lines.joined(separator: "\n") + "\n"
+ }
+
+ /// The same events as data, for any tool that is not a subtitle renderer.
+ /// Clears are included: "the words left here" is as much of the timeline as
+ /// "the words arrived here".
+ @MainActor
+ public static func timelineJSON(_ timeline: SceneTimeline, resolution: Resolution) throws -> Data {
+ let events: [[String: Any]] = timeline.entries.map { entry in
+ [
+ "track": entry.track.rawValue,
+ "text": entry.text,
+ "start": seconds(entry.start),
+ "end": seconds(entry.end),
+ ]
+ }
+ let payload: [String: Any] = [
+ "scene": timeline.scene,
+ "captionSeparator": SceneTimeline.captionSeparator,
+ "durationSeconds": seconds(timeline.durationSeconds),
+ "resolution": ["width": resolution.width, "height": resolution.height],
+ "events": events,
+ ]
+ // Sorted and pretty on purpose: this file lands in a repository next to the
+ // movie, and a diffable sidecar is worth the bytes.
+ return try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
+ }
+
+ /// ASS escaping.
+ ///
+ /// `{` opens an override block, so a caption holding one would render as
+ /// styling, or not at all, with nothing said about it.
+ ///
+ /// A backslash is the awkward one: ASS has no escape for it, and doubling it
+ /// the way most formats would is simply wrong here. It rendered the `\` key's
+ /// keycast as two backslashes. Only three sequences are special — `\N`, `\n`
+ /// and `\h` — so the backslash is emitted as itself and an empty override
+ /// block is slipped in front of the letter that would have made it a command.
+ /// `{}` draws nothing and breaks the sequence.
+ public static func escaped(_ text: String) -> String {
+ // Braces first, so the empty block inserted below is not escaped in turn.
+ var out = text.replacingOccurrences(of: "{", with: "\\{")
+ out = out.replacingOccurrences(of: "}", with: "\\}")
+ out = out.replacingOccurrences(
+ of: "\\\\([nNh])",
+ with: "\\\\{}$1",
+ options: .regularExpression
+ )
+ // A hard line break inside an event is `\N`, and this runs last so the
+ // break it produces is not itself neutralised. A real newline would end the
+ // Dialogue row and truncate the caption to its first line.
+ out = out.replacingOccurrences(of: "\r\n", with: "\\N")
+ out = out.replacingOccurrences(of: "\n", with: "\\N")
+ out = out.replacingOccurrences(of: "\r", with: "\\N")
+ return out
+ }
+
+ /// `H:MM:SS.cc`, the only time format ASS accepts.
+ public static func timestamp(_ seconds: Double) -> String {
+ let total = Int((max(0, seconds) * 100).rounded())
+ return String(
+ format: "%d:%02d:%02d.%02d",
+ total / 360_000,
+ (total / 6_000) % 60,
+ (total / 100) % 60,
+ total % 100
+ )
+ }
+
+ // MARK: Private
+
+ private static let styleFormat = "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
+ private static let eventFormat = "Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
+ private static let referenceHeight = 1200.0
+
+ private struct Style {
+ let row: String
+ }
+
+ /// Narration, chapters and actual keys are drawn inside the desktop image.
+ /// A translucent backing keeps them readable over both light and dark apps.
+ private static func styles(for resolution: Resolution) -> [Style] {
+ func style(_ name: String, size: Double, color: String, bold: Int, alignment: Int,
+ left: Double, right: Double, vertical: Double) -> Style {
+ Style(row: "Style: \(name),Helvetica Neue,\(scaled(size, resolution)),\(color),\(color),&H40141414,&H40141414,\(bold),0,0,0,100,100,0,0,3,10,0,\(alignment),\(scaled(left, resolution)),\(scaled(right, resolution)),\(scaled(vertical, resolution)),1")
+ }
+ return [
+ style("Caption", size: 42, color: FilmTheme.text, bold: -1, alignment: 2,
+ left: 110, right: 110, vertical: 96),
+ style("Chapter", size: 26, color: FilmTheme.accent, bold: -1, alignment: 7,
+ left: 32, right: 700, vertical: 54),
+ style("Keys", size: 38, color: FilmTheme.accent, bold: 0, alignment: 9,
+ left: 1300, right: 32, vertical: 54),
+ ]
+ }
+
+ /// A time, as two decimals rather than as a `Double`.
+ ///
+ /// A double already rounded to centiseconds still serializes as
+ /// `0.80000000000000004`, because 0.8 has no exact binary form. The number is
+ /// correct and the file is unreadable, and this one is meant to be read and
+ /// diffed by people.
+ private static func seconds(_ value: Double) -> NSDecimalNumber {
+ NSDecimalNumber(string: String(format: "%.2f", value))
+ }
+
+ private static func scaled(_ value: Double, _ resolution: Resolution) -> Int {
+ Int((value * Double(resolution.height) / referenceHeight).rounded())
+ }
+
+ private static func dialogue(
+ _ entry: SceneTimeline.Entry,
+ resolution: Resolution
+ ) -> String? {
+ guard !entry.isClear else { return nil }
+ // A centisecond is the finest gap an ASS timestamp can express, so a line
+ // whose end rounds onto its start renders for no frames at all. Nudged
+ // rather than dropped: the words stay in the file, where a person editing
+ // it can see the scene replaced them instantly and fix the scene.
+ let end = max(entry.end, entry.start + 0.01)
+ let style: String
+ let text: String
+ switch entry.track {
+ case .chapter:
+ style = "Chapter"
+ text = escaped(entry.text)
+ case .caption:
+ style = "Caption"
+ text = captionText(entry.text, resolution: resolution)
+ case .keys:
+ style = "Keys"
+ // The caps, not the config spelling. A scene records the chord exactly as
+ // `config.toml` writes it, and a viewer reads `⌃⌥L`, not `ctrl + alt - l`.
+ text = escaped(KeyChordFormatter.display(from: entry.text))
+ }
+ // The keycast is on its own layer so it draws over the caption card if a
+ // long caption ever reaches across to it.
+ let layer = entry.track == .keys ? 1 : 0
+ return "Dialogue: \(layer),\(timestamp(entry.start)),\(timestamp(end)),\(style),,0,0,0,,{\\fad(100,100)}\(text)"
+ }
+
+ /// A caption is `" | "`. The headline carries the sentence, the
+ /// explanation goes below it, smaller and dimmer, over the captured desktop.
+ private static func captionText(_ raw: String, resolution: Resolution) -> String {
+ let parts = raw.components(separatedBy: SceneTimeline.captionSeparator)
+ let headline = parts.first?.trimmingCharacters(in: .whitespaces) ?? ""
+ let why = parts.count > 1
+ ? parts.dropFirst().joined(separator: SceneTimeline.captionSeparator)
+ .trimmingCharacters(in: .whitespaces)
+ : ""
+ let pad = ""
+ guard !why.isEmpty else { return pad + escaped(headline) + pad }
+ let whySize = "\\fs\(scaled(25, resolution))"
+ return pad + escaped(headline) + pad
+ + "\\N{\(whySize)\\b0\\1c&H\(FilmTheme.secondary.dropFirst(4))&}"
+ + pad + escaped(why) + pad
+ }
+
+}
+
+// MARK: - SceneSidecars
+
+/// Writes the two sidecars next to a take.
+public enum SceneSidecars {
+
+ /// Writes `.ass` and `.timeline.json`.
+ ///
+ /// Called even when the scene failed partway through: the half of a take that
+ /// did happen is exactly the part somebody has to diagnose, and a failed take
+ /// with no record of what it managed to narrate is a take nobody can read.
+ @MainActor
+ @discardableResult
+ public static func write(
+ _ timeline: SceneTimeline,
+ besides movie: URL,
+ resolution: SceneSubtitles.Resolution,
+ drawnLive: Set = []
+ ) throws -> [URL] {
+ let subtitles = LabPaths.subtitleFile(for: movie)
+ let json = LabPaths.timelineFile(for: movie)
+ try FileManager.default.createDirectory(
+ at: movie.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let ass = SceneSubtitles.ass(timeline, resolution: resolution, drawnLive: drawnLive)
+ try Data(ass.utf8).write(to: subtitles, options: .atomic)
+ try SceneSubtitles.timelineJSON(timeline, resolution: resolution).write(to: json, options: .atomic)
+ return [subtitles, json]
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/Shell.swift b/DemoLab/Sources/DemoCtlKit/Shell.swift
new file mode 100644
index 0000000..d2c7fd0
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/Shell.swift
@@ -0,0 +1,211 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+import Darwin
+
+// MARK: - CommandResult
+
+public struct CommandResult: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(status: Int32, standardOutput: String, standardError: String) {
+ self.status = status
+ self.standardOutput = standardOutput
+ self.standardError = standardError
+ }
+
+ // MARK: Public
+
+ public let status: Int32
+ public let standardOutput: String
+ public let standardError: String
+
+ public var succeeded: Bool { status == 0 }
+
+}
+
+// MARK: - CommandError
+
+public struct CommandError: Error, CustomStringConvertible {
+
+ // MARK: Lifecycle
+
+ public init(command: String, result: CommandResult) {
+ self.command = command
+ self.result = result
+ }
+
+ // MARK: Public
+
+ public let command: String
+ public let result: CommandResult
+
+ public var description: String {
+ let detail = result.standardError.isEmpty ? result.standardOutput : result.standardError
+ let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
+ return "\(command) failed (exit \(result.status))\(trimmed.isEmpty ? "" : ": \(trimmed)")"
+ }
+
+}
+
+// MARK: - DataBox
+
+/// Carries one pipe's bytes back from the draining queue. The write and the read
+/// are ordered by the `DispatchGroup`, so no lock is needed.
+private final class DataBox: @unchecked Sendable {
+ var value = Data()
+}
+
+// MARK: - Shell
+
+public enum Shell {
+
+ /// NSWorkspace process notifications need a main run loop. A synchronous
+ /// command must check the kernel before trusting cached app enumeration.
+ public static func processExists(_ pid: pid_t) -> Bool {
+ guard pid > 0 else { return false }
+ return Darwin.kill(pid, 0) == 0 || errno == EPERM
+ }
+
+ // MARK: Public
+
+ /// Runs an executable directly. No shell is involved: arguments are passed as
+ /// argv, never word-split or glob-expanded, which matches how Tatami itself
+ /// runs hook commands and keeps demo scripts free of quoting surprises.
+ @discardableResult
+ public static func run(
+ _ executable: URL,
+ _ arguments: [String] = [],
+ environment: [String: String]? = nil,
+ currentDirectory: URL? = nil
+ ) throws -> CommandResult {
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = arguments
+ if let environment {
+ var merged = ProcessInfo.processInfo.environment
+ for (key, value) in environment { merged[key] = value }
+ process.environment = merged
+ }
+ if let currentDirectory { process.currentDirectoryURL = currentDirectory }
+
+ let outPipe = Pipe()
+ let errPipe = Pipe()
+ process.standardOutput = outPipe
+ process.standardError = errPipe
+
+ try process.run()
+ // Both pipes have to drain at the same time. Reading them one after the
+ // other deadlocks: the child parks in write(2) on whichever ~64 KiB pipe
+ // buffer we are not reading yet, so it never exits, so the read we are
+ // parked in never reaches EOF. `swift build` hits this the moment a broken
+ // build puts more than a buffer's worth of diagnostics on stderr.
+ let errBox = DataBox()
+ let group = DispatchGroup()
+ DispatchQueue.global(qos: .userInitiated).async(group: group) {
+ errBox.value = errPipe.fileHandleForReading.readDataToEndOfFile()
+ }
+ let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
+ group.wait()
+ let errData = errBox.value
+ process.waitUntilExit()
+
+ return CommandResult(
+ status: process.terminationStatus,
+ standardOutput: String(decoding: outData, as: UTF8.self),
+ standardError: String(decoding: errData, as: UTF8.self)
+ )
+ }
+
+ @discardableResult
+ public static func require(
+ _ executable: URL,
+ _ arguments: [String] = [],
+ environment: [String: String]? = nil,
+ currentDirectory: URL? = nil
+ ) throws -> CommandResult {
+ let result = try run(executable, arguments, environment: environment, currentDirectory: currentDirectory)
+ guard result.succeeded else {
+ throw CommandError(command: executable.lastPathComponent, result: result)
+ }
+ return result
+ }
+
+ /// Starts a long-lived process and detaches its standard streams.
+ ///
+ /// A direct child inherits whatever stdout it was given, and a process that
+ /// outlives its launcher then holds that pipe open forever. `democtl seed`
+ /// launches Tatami and the overlay and exits; a shell running
+ /// `democtl seed | tail` waited **two hours** on a `tail` that could never see
+ /// EOF, because Tatami was still holding the write end. Detaching the streams
+ /// is what makes "launch it and leave it running" safe to script.
+ ///
+ /// Output goes to a log rather than `/dev/null`: these are exactly the
+ /// processes whose first complaint is the one worth reading.
+ @discardableResult
+ public static func launchDetached(
+ _ executable: URL,
+ _ arguments: [String] = [],
+ log: URL,
+ environment: [String: String]? = nil
+ ) throws -> Process {
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = arguments
+ if let environment {
+ var merged = ProcessInfo.processInfo.environment
+ for (key, value) in environment { merged[key] = value }
+ process.environment = merged
+ }
+
+ try FileManager.default.createDirectory(
+ at: log.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ if !FileManager.default.fileExists(atPath: log.path) {
+ FileManager.default.createFile(atPath: log.path, contents: nil)
+ }
+ let handle = try FileHandle(forWritingTo: log)
+ // Appending rather than truncating: a log that only holds the last launch
+ // is a log that has thrown away the launch that went wrong.
+ _ = try? handle.seekToEnd()
+ process.standardOutput = handle
+ process.standardError = handle
+ // Never the caller's stdin: a detached process must not compete for a
+ // terminal that may not even exist.
+ process.standardInput = FileHandle.nullDevice
+
+ try process.run()
+ return process
+ }
+
+ /// Blocks until `condition` is true or the deadline passes. Used instead of a
+ /// fixed sleep everywhere a real signal exists, so scenes stay as fast as the
+ /// machine allows without ever racing.
+ @discardableResult
+ public static func wait(
+ timeout: Duration,
+ poll: Duration = .milliseconds(60),
+ until condition: () throws -> Bool
+ ) rethrows -> Bool {
+ let deadline = ContinuousClock.now.advanced(by: timeout)
+ while ContinuousClock.now < deadline {
+ if try condition() { return true }
+ Thread.sleep(forTimeInterval: seconds(poll))
+ }
+ return try condition()
+ }
+
+ public static func sleep(_ duration: Duration) {
+ Thread.sleep(forTimeInterval: seconds(duration))
+ }
+
+ /// One place to spell the conversion, so a timeout and the message that
+ /// reports it can never disagree about how long the wait actually was.
+ public static func seconds(_ duration: Duration) -> Double {
+ Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/TatamiInstall.swift b/DemoLab/Sources/DemoCtlKit/TatamiInstall.swift
new file mode 100644
index 0000000..ebcf470
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/TatamiInstall.swift
@@ -0,0 +1,376 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import ApplicationServices
+import CoreGraphics
+import Foundation
+
+// MARK: - TatamiInstall
+
+/// A located Tatami installation. The Demo Lab drives the **real** app — none
+/// of its window, workspace, profile, Borrow, or tiling behavior is simulated
+/// here — so everything below is about finding that app and talking to its
+/// published interfaces.
+public struct TatamiInstall: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(bundle: URL, bundleIdentifier: String, version: String?) {
+ self.bundle = bundle
+ self.bundleIdentifier = bundleIdentifier
+ self.version = version
+ }
+
+ // MARK: Public
+
+ /// `dev.PangMo5.Tatami` or `dev.PangMo5.Tatami.debug`. Tatami's own
+ /// `MacApp.isTatami` uses exactly this test, and the lab's demo apps live
+ /// under `dev.PangMo5.DemoLab.` specifically so they can never match it.
+ public static func isTatami(_ bundleIdentifier: String) -> Bool {
+ bundleIdentifier == "dev.PangMo5.Tatami" || bundleIdentifier.hasPrefix("dev.PangMo5.Tatami.")
+ }
+
+ public static func locate(explicit: String? = nil) throws -> TatamiInstall {
+ var candidates = [URL]()
+ if let explicit { candidates.append(URL(fileURLWithPath: explicit)) }
+ if let fromEnvironment = ProcessInfo.processInfo.environment["DEMOLAB_TATAMI_APP"],
+ !fromEnvironment.isEmpty {
+ candidates.append(URL(fileURLWithPath: fromEnvironment))
+ }
+ candidates.append(URL(fileURLWithPath: "/Applications/Tatami.app"))
+ candidates.append(
+ URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Applications/Tatami.app")
+ )
+ // The repository's local Debug build, so the lab works on a dev machine
+ // before a release is installed.
+ if let root = LabPaths.discoverPackageRoot() {
+ let repository = root.deletingLastPathComponent()
+ candidates.append(
+ repository.appendingPathComponent("DerivedData/Build/Products/Debug/Tatami.app")
+ )
+ candidates.append(
+ repository.appendingPathComponent("DerivedData/Build/Products/Release/Tatami.app")
+ )
+ }
+
+ for candidate in candidates {
+ guard FileManager.default.fileExists(atPath: candidate.path),
+ let bundle = Bundle(url: candidate),
+ let identifier = bundle.bundleIdentifier,
+ isTatami(identifier)
+ else { continue }
+ let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ return TatamiInstall(bundle: candidate, bundleIdentifier: identifier, version: version)
+ }
+
+ throw DemoCtlError.tatamiNotFound(candidates.map(\.path))
+ }
+
+ public let bundle: URL
+ public let bundleIdentifier: String
+ public let version: String?
+
+ /// Tatami embeds its CLI in the app bundle; using this copy rather than
+ /// `/usr/local/bin/tatami` guarantees the CLI and the running app are the
+ /// same build, which matters because a newer CLI refuses plain output from an
+ /// older app when `--json` was requested.
+ public var cli: URL { bundle.appendingPathComponent("Contents/Resources/tatami") }
+
+ public var executable: URL {
+ bundle.appendingPathComponent("Contents/MacOS")
+ .appendingPathComponent(bundle.deletingPathExtension().lastPathComponent)
+ }
+
+ public func validate() throws {
+ guard FileManager.default.isExecutableFile(atPath: cli.path) else {
+ throw DemoCtlError.tatamiCLIMissing(cli.path)
+ }
+ }
+
+}
+
+// MARK: - TatamiProcess
+
+/// Starts and stops the Tatami process the lab owns.
+@MainActor
+public enum TatamiProcess {
+
+ // MARK: Public
+
+ public static func running() -> [NSRunningApplication] {
+ NSWorkspace.shared.runningApplications.filter {
+ guard let identifier = $0.bundleIdentifier else { return false }
+ return TatamiInstall.isTatami(identifier) && Shell.processExists($0.processIdentifier)
+ }
+ }
+
+ /// Quits every running Tatami, not only one the lab started.
+ ///
+ /// Two Tatami processes would both tile the same windows, so a demo run
+ /// cannot share the machine with another instance. This is deliberately
+ /// visible in `democtl`'s output rather than silent: on a developer's own
+ /// Mac it stops the app they were using. It never touches that app's
+ /// configuration, which lives in a separate config home.
+ @discardableResult
+ public static func quitAll(timeout: Duration = .seconds(10)) -> [String] {
+ let victims = running()
+ guard !victims.isEmpty else { return [] }
+ let names = victims.map { $0.bundleIdentifier ?? "Tatami" }
+ for application in victims { application.terminate() }
+ let stopped = Shell.wait(timeout: timeout) { running().isEmpty }
+ if !stopped {
+ for application in running() { application.forceTerminate() }
+ _ = Shell.wait(timeout: .seconds(5)) { running().isEmpty }
+ }
+ return names
+ }
+
+ /// Closes Tatami's own window if it opened at launch.
+ ///
+ /// `Window("Tatami", id: "main")` is the first scene in Tatami's `body`, and
+ /// on macOS 14 the first window scene opens automatically. There is no
+ /// `defaultLaunchBehavior(.suppressed)` before macOS 15, and neither
+ /// `ApplePersistenceIgnoreState` nor wiping the preferences domain suppresses
+ /// it, because it is not restoration at all. So a seeded lab would start every
+ /// take with Tatami's Settings window sitting in the middle of the shot.
+ ///
+ /// This presses the window's close button through Accessibility, which is the
+ /// same thing as clicking the red dot. It reads no Tatami state and changes
+ /// none: the scene that actually wants that window opens it again with the
+ /// published Command-comma shortcut.
+ ///
+ /// - Returns: how many windows were closed.
+ @discardableResult
+ public static func closeOwnWindows(timeout: Duration = .seconds(6)) -> Int {
+ guard AXIsProcessTrusted() else { return -1 }
+ var closed = 0
+ _ = Shell.wait(timeout: timeout, poll: .milliseconds(250)) {
+ var sawWindow = false
+ for application in running() {
+ let element = AXUIElementCreateApplication(application.processIdentifier)
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, kAXWindowsAttribute as CFString, &value) == .success,
+ let windows = value as? [AXUIElement]
+ else { continue }
+ for window in windows {
+ sawWindow = true
+ var button: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(
+ window,
+ kAXCloseButtonAttribute as CFString,
+ &button
+ ) == .success, let raw = button, CFGetTypeID(raw) == AXUIElementGetTypeID() else { continue }
+ let closeButton = unsafeDowncast(raw, to: AXUIElement.self)
+ if AXUIElementPerformAction(closeButton, kAXPressAction as CFString) == .success {
+ closed += 1
+ }
+ }
+ }
+ // Stop as soon as a pass finds nothing left to close. The window can take
+ // a moment to exist at all, which is why this polls rather than looking once.
+ return !sawWindow && closed > 0
+ }
+ return closed
+ }
+
+ /// How the Tatami process is started.
+ ///
+ /// The same correctness question as the recorder's ``RecorderController/LaunchPath``,
+ /// with the same answer for the same reason. macOS attributes a Screen
+ /// Recording grant to the *responsible* process, and Tatami needs one for
+ /// **Always on Top**, whose windows are ScreenCaptureKit mirrors.
+ ///
+ /// - On a desktop, `democtl` runs from a terminal that holds no such grant,
+ /// so Tatami has to become its own responsible process. That means
+ /// LaunchServices, and it is the only path a person can grant.
+ /// - In a VM driven by `tart exec`, the parent already holds the grant and a
+ /// direct child inherits it. This is not a nicety: a hand-written TCC row
+ /// for Tatami was measured being ignored by macOS 26's tccd even with SIP
+ /// off, so inheritance is the only way Always on Top works in a guest.
+ /// Without it Tatami raises a standing Problem and puts a warning triangle
+ /// in the menu bar, in shot, for the whole take.
+ public enum LaunchPath: Sendable {
+ case auto
+ case viaLaunchServices
+ case direct
+ }
+
+ /// Launches Tatami with the lab's isolation flags.
+ public static func launch(
+ install: TatamiInstall,
+ paths: LabPaths,
+ launchPath: LaunchPath = .auto
+ ) throws {
+ let arguments = [
+ "--tatami-config-home", paths.configHome.path,
+ "--tatami-socket-path", paths.socketPath.path,
+ ]
+ let resolved: LaunchPath = {
+ switch launchPath {
+ case .auto: CGPreflightScreenCaptureAccess() ? .direct : .viaLaunchServices
+ case let explicit: explicit
+ }
+ }()
+
+ switch resolved {
+ case .direct, .auto:
+ // Detached: Tatami outlives `democtl`, and a child holding the caller's
+ // stdout keeps a shell pipeline open forever after `democtl` has exited.
+ try Shell.launchDetached(
+ install.executable,
+ arguments,
+ log: paths.runRoot.appendingPathComponent("tatami-stdout.log")
+ )
+
+ case .viaLaunchServices:
+ try Shell.require(
+ URL(fileURLWithPath: "/usr/bin/open"),
+ ["-n", "-a", install.bundle.path, "--args"] + arguments
+ )
+ }
+ }
+
+}
+
+// MARK: - TatamiClient
+
+/// A thin, faithful wrapper over the `tatami` CLI.
+///
+/// It only ever runs commands that exist. The command surface was read out of
+/// `TatamiCLI.swift` rather than remembered, and two of its rules shape every
+/// call site here:
+///
+/// - `--json` is a *leaf* option. `tatami --json workspace list` is a parse
+/// error; `tatami workspace list --json` is correct.
+/// - Only `profile activate` and `workspace activate` report `"completed"`.
+/// Every other dispatcher route replies `"accepted"` the moment the command
+/// is enqueued, which says nothing about whether a window moved. Scene
+/// barriers therefore use activation replies or hook events, never exit codes.
+public struct TatamiClient: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(install: TatamiInstall, paths: LabPaths) {
+ self.install = install
+ self.paths = paths
+ }
+
+ // MARK: Public
+
+ public let install: TatamiInstall
+ public let paths: LabPaths
+
+ public var isRunning: Bool {
+ (try? runRaw(["version", "--json"]))?.succeeded ?? false
+ }
+
+ /// Runs a leaf command with `--json` appended and returns the decoded value.
+ @discardableResult
+ public func json(_ arguments: [String]) throws -> Any {
+ let result = try runRaw(arguments + ["--json"])
+ guard result.succeeded else {
+ throw DemoCtlError.tatamiCommandFailed(
+ command: arguments.joined(separator: " "),
+ message: Self.errorMessage(from: result)
+ )
+ }
+ let text = result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let data = text.data(using: .utf8), !data.isEmpty,
+ let value = try? JSONSerialization.jsonObject(with: data)
+ else {
+ throw DemoCtlError.tatamiCommandFailed(
+ command: arguments.joined(separator: " "),
+ message: "expected JSON on stdout, got: \(text)"
+ )
+ }
+ return value
+ }
+
+ /// Activates a workspace and waits for the activation pipeline to finish.
+ /// This is the only synchronous confirmation Tatami offers that a switch
+ /// actually landed, so scenes lean on it wherever they can.
+ public func activateWorkspace(_ name: String, profile: String? = nil) throws {
+ var arguments = ["workspace", "activate", name]
+ if let profile { arguments += ["--profile", profile] }
+ let value = try json(arguments)
+ try expect(status: "completed", in: value, command: arguments.joined(separator: " "))
+ }
+
+ public func activateProfile(_ name: String) throws {
+ let arguments = ["profile", "activate", name]
+ let value = try json(arguments)
+ try expect(status: "completed", in: value, command: arguments.joined(separator: " "))
+ }
+
+ /// Dispatcher routes that only ever report `accepted`.
+ public func dispatch(_ arguments: [String]) throws {
+ let value = try json(arguments)
+ try expect(status: "accepted", in: value, command: arguments.joined(separator: " "))
+ }
+
+ public func workspaceNames(profile: String? = nil) throws -> [String] {
+ var arguments = ["workspace", "list"]
+ if let profile { arguments += ["--profile", profile] }
+ guard let rows = try json(arguments) as? [[String: Any]] else { return [] }
+ return rows.compactMap { $0["name"] as? String }
+ }
+
+ public func workspaces(profile: String? = nil) throws -> [[String: Any]] {
+ var arguments = ["workspace", "list"]
+ if let profile { arguments += ["--profile", profile] }
+ return (try json(arguments) as? [[String: Any]]) ?? []
+ }
+
+ public func profiles() throws -> [[String: Any]] {
+ (try json(["profile", "list"]) as? [[String: Any]]) ?? []
+ }
+
+ public func activeProfileName() throws -> String? {
+ try profiles().first { ($0["isActive"] as? Bool) == true }?["name"] as? String
+ }
+
+ public func hooks() throws -> [[String: Any]] {
+ (try json(["hook", "list"]) as? [[String: Any]]) ?? []
+ }
+
+ public func runningVersion() throws -> String? {
+ (try json(["version"]) as? [String: Any])?["version"] as? String
+ }
+
+ // MARK: Private
+
+ private static func errorMessage(from result: CommandResult) -> String {
+ let stderr = result.standardError.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let data = stderr.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let message = object["error"] as? String {
+ return message
+ }
+ return stderr.isEmpty ? result.standardOutput : stderr
+ }
+
+ private func runRaw(_ arguments: [String]) throws -> CommandResult {
+ try Shell.run(
+ install.cli,
+ arguments,
+ environment: ["TATAMI_SOCKET_PATH": paths.socketPath.path]
+ )
+ }
+
+ private func expect(status: String, in value: Any, command: String) throws {
+ guard let object = value as? [String: Any] else {
+ throw DemoCtlError.tatamiCommandFailed(command: command, message: "unexpected JSON shape")
+ }
+ guard let actual = object["status"] as? String else {
+ throw DemoCtlError.tatamiCommandFailed(command: command, message: "reply carried no status")
+ }
+ guard actual == status else {
+ throw DemoCtlError.tatamiCommandFailed(
+ command: command,
+ message: "expected status \(status), got \(actual)"
+ )
+ }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/TomlLite.swift b/DemoLab/Sources/DemoCtlKit/TomlLite.swift
new file mode 100644
index 0000000..5827d56
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/TomlLite.swift
@@ -0,0 +1,335 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - TomlValue
+
+public indirect enum TomlValue: Equatable, Sendable {
+ case string(String)
+ case integer(Int)
+ case double(Double)
+ case bool(Bool)
+ case array([TomlValue])
+ case table([String: TomlValue])
+
+ // MARK: Public
+
+ public var stringValue: String? { if case .string(let value) = self { value } else { nil } }
+ public var boolValue: Bool? { if case .bool(let value) = self { value } else { nil } }
+ public var intValue: Int? { if case .integer(let value) = self { value } else { nil } }
+
+ public var doubleValue: Double? {
+ switch self {
+ case .double(let value): value
+ case .integer(let value): Double(value)
+ default: nil
+ }
+ }
+
+ public var arrayValue: [TomlValue]? { if case .array(let value) = self { value } else { nil } }
+ public var tableValue: [String: TomlValue]? { if case .table(let value) = self { value } else { nil } }
+
+ public var stringArray: [String]? {
+ guard let array = arrayValue else { return nil }
+ let strings = array.compactMap(\.stringValue)
+ return strings.count == array.count ? strings : nil
+ }
+
+ public var tableArray: [[String: TomlValue]]? {
+ guard let array = arrayValue else { return nil }
+ let tables = array.compactMap(\.tableValue)
+ return tables.count == array.count ? tables : nil
+ }
+
+ public subscript(key: String) -> TomlValue? { tableValue?[key] }
+
+}
+
+// MARK: - TomlLiteError
+
+public struct TomlLiteError: Error, CustomStringConvertible {
+
+ // MARK: Lifecycle
+
+ public init(line: Int, message: String) {
+ self.line = line
+ self.message = message
+ }
+
+ // MARK: Public
+
+ public let line: Int
+ public let message: String
+
+ public var description: String { "line \(line): \(message)" }
+
+}
+
+// MARK: - TomlLite
+
+/// A deliberately small TOML reader covering exactly the subset the Demo Lab
+/// writes: comments, `[table]`, `[[array of tables]]`, basic and literal
+/// strings, integers, floats, booleans, arrays (including multi-line), and
+/// single-line inline tables.
+///
+/// Its purpose is validation, not general parsing. Tatami accepts a config file
+/// where a misspelled key, a wrong enum spelling, or an out-of-range value is
+/// **silently defaulted** with no warning anywhere — so the failure mode this
+/// guards against is a recording that quietly uses different settings than the
+/// one before it. Parsing the file the lab just wrote and asserting on it is
+/// the only way to catch that before the camera rolls.
+public enum TomlLite {
+
+ // MARK: Public
+
+ public static func parse(_ text: String) throws -> [String: TomlValue] {
+ let root = Node()
+ var current = root
+ let lines = text.components(separatedBy: .newlines)
+ var index = 0
+
+ while index < lines.count {
+ let number = index + 1
+ var line = strippingComment(lines[index]).trimmingCharacters(in: .whitespaces)
+ index += 1
+ if line.isEmpty { continue }
+
+ if line.hasPrefix("[[") {
+ guard line.hasSuffix("]]") else { throw TomlLiteError(line: number, message: "unterminated [[") }
+ let path = try keyPath(String(line.dropFirst(2).dropLast(2)), line: number)
+ current = root.appendTableArray(at: path)
+ continue
+ }
+
+ if line.hasPrefix("[") {
+ guard line.hasSuffix("]") else { throw TomlLiteError(line: number, message: "unterminated [") }
+ let path = try keyPath(String(line.dropFirst().dropLast()), line: number)
+ current = root.table(at: path)
+ continue
+ }
+
+ guard let separator = line.firstIndex(of: "=") else {
+ throw TomlLiteError(line: number, message: "expected key = value")
+ }
+ let key = unquote(String(line[line.startIndex.. Node {
+ var node = self
+ for part in path { node = node.descend(part) }
+ return node
+ }
+
+ func appendTableArray(at path: [String]) -> Node {
+ guard let last = path.last else { return self }
+ var node = self
+ for part in path.dropLast() { node = node.descend(part) }
+ let fresh = Node()
+ node.childArrays[last, default: []].append(fresh)
+ if !node.order.contains(last) { node.order.append(last) }
+ return fresh
+ }
+
+ func materialize() -> [String: TomlValue] {
+ var result = values
+ for (key, child) in children { result[key] = .table(child.materialize()) }
+ for (key, array) in childArrays {
+ result[key] = .array(array.map { .table($0.materialize()) })
+ }
+ return result
+ }
+
+ // MARK: Private
+
+ /// Walks into an existing sub-table, or the *last* element of an existing
+ /// array of tables — that is the TOML rule that makes
+ /// `[[profiles.workspaces]]` attach to the profile declared above it.
+ private func descend(_ part: String) -> Node {
+ if let existing = childArrays[part]?.last { return existing }
+ if let existing = children[part] { return existing }
+ let fresh = Node()
+ children[part] = fresh
+ order.append(part)
+ return fresh
+ }
+
+ }
+
+ private static func strippingComment(_ line: String) -> String {
+ var inBasic = false
+ var inLiteral = false
+ var escaped = false
+ for (offset, character) in line.enumerated() {
+ if escaped { escaped = false; continue }
+ switch character {
+ case "\\" where inBasic: escaped = true
+ case "\"" where !inLiteral: inBasic.toggle()
+ case "'" where !inBasic: inLiteral.toggle()
+ case "#" where !inBasic && !inLiteral:
+ return String(line.prefix(offset))
+ default: break
+ }
+ }
+ return line
+ }
+
+ private static func unbalanced(_ text: String) -> Bool {
+ var depth = 0
+ var inBasic = false
+ var inLiteral = false
+ var escaped = false
+ for character in text {
+ if escaped { escaped = false; continue }
+ switch character {
+ case "\\" where inBasic: escaped = true
+ case "\"" where !inLiteral: inBasic.toggle()
+ case "'" where !inBasic: inLiteral.toggle()
+ case "[" where !inBasic && !inLiteral: depth += 1
+ case "]" where !inBasic && !inLiteral: depth -= 1
+ default: break
+ }
+ }
+ return depth != 0
+ }
+
+ private static func keyPath(_ text: String, line: Int) throws -> [String] {
+ let parts = text.split(separator: ".").map {
+ unquote($0.trimmingCharacters(in: .whitespaces))
+ }
+ guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
+ throw TomlLiteError(line: line, message: "empty table path")
+ }
+ return parts
+ }
+
+ private static func unquote(_ text: String) -> String {
+ if text.count >= 2, text.hasPrefix("\""), text.hasSuffix("\"") {
+ return unescape(String(text.dropFirst().dropLast()))
+ }
+ if text.count >= 2, text.hasPrefix("'"), text.hasSuffix("'") {
+ return String(text.dropFirst().dropLast())
+ }
+ return text
+ }
+
+ private static func unescape(_ text: String) -> String {
+ var result = ""
+ var iterator = text.makeIterator()
+ while let character = iterator.next() {
+ guard character == "\\" else { result.append(character); continue }
+ switch iterator.next() {
+ case "n": result.append("\n")
+ case "t": result.append("\t")
+ case "r": result.append("\r")
+ case "\"": result.append("\"")
+ case "\\": result.append("\\")
+ case let other?: result.append(other)
+ case nil: result.append("\\")
+ }
+ }
+ return result
+ }
+
+ private static func value(_ raw: String, line: Int) throws -> TomlValue {
+ let text = raw.trimmingCharacters(in: .whitespaces)
+ if text.isEmpty { throw TomlLiteError(line: line, message: "missing value") }
+
+ if text.hasPrefix("\"") || text.hasPrefix("'") { return .string(unquote(text)) }
+ if text == "true" { return .bool(true) }
+ if text == "false" { return .bool(false) }
+
+ if text.hasPrefix("[") {
+ guard text.hasSuffix("]") else { throw TomlLiteError(line: line, message: "unterminated array") }
+ let inner = String(text.dropFirst().dropLast())
+ return .array(try splitTopLevel(inner, line: line).map { try value($0, line: line) })
+ }
+
+ if text.hasPrefix("{") {
+ guard text.hasSuffix("}") else { throw TomlLiteError(line: line, message: "unterminated table") }
+ let inner = String(text.dropFirst().dropLast())
+ var table = [String: TomlValue]()
+ for entry in try splitTopLevel(inner, line: line) {
+ guard let separator = entry.firstIndex(of: "=") else {
+ throw TomlLiteError(line: line, message: "expected key = value in inline table")
+ }
+ let key = unquote(String(entry[entry.startIndex.. [String] {
+ var parts = [String]()
+ var current = ""
+ var depth = 0
+ var inBasic = false
+ var inLiteral = false
+ var escaped = false
+
+ for character in text {
+ if escaped { current.append(character); escaped = false; continue }
+ switch character {
+ case "\\" where inBasic: current.append(character); escaped = true
+ case "\"" where !inLiteral: inBasic.toggle(); current.append(character)
+ case "'" where !inBasic: inLiteral.toggle(); current.append(character)
+ // Each pattern needs its own `where`: in Swift a where clause binds to the
+ // preceding pattern only, so `case "[", "{" where …` would leave "[" unguarded.
+ case "[" where !inBasic && !inLiteral, "{" where !inBasic && !inLiteral:
+ depth += 1
+ current.append(character)
+
+ case "]" where !inBasic && !inLiteral, "}" where !inBasic && !inLiteral:
+ depth -= 1
+ current.append(character)
+
+ case "," where depth == 0 && !inBasic && !inLiteral:
+ parts.append(current)
+ current = ""
+ default: current.append(character)
+ }
+ }
+ parts.append(current)
+
+ return parts
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ }
+
+}
diff --git a/DemoLab/Sources/DemoCtlKit/VirtualDisplayController.swift b/DemoLab/Sources/DemoCtlKit/VirtualDisplayController.swift
new file mode 100644
index 0000000..beeecb3
--- /dev/null
+++ b/DemoLab/Sources/DemoCtlKit/VirtualDisplayController.swift
@@ -0,0 +1,45 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import Foundation
+import CoreGraphics
+
+@MainActor
+public enum VirtualDisplayController {
+ private static func hasSecondary() -> Bool {
+ var ids=[CGDirectDisplayID](repeating:0,count:16);var count:UInt32=0
+ guard CGGetOnlineDisplayList(16,&ids,&count) == .success else {return false}
+ return ids.prefix(Int(count)).contains {CGDisplayVendorNumber($0)==0x5050 && CGDisplayModelNumber($0)==0x1001}
+ }
+ private static func pidFile(_ paths:LabPaths)->URL {paths.packageRoot.appendingPathComponent(".build/virtual-display.pid")}
+ public static func connect(_ paths:LabPaths) throws {
+ if hasSecondary() {return}
+ let binary=paths.packageRoot.appendingPathComponent(".build/tools/demodisplay")
+ guard FileManager.default.isExecutableFile(atPath:binary.path) else {throw DemoCtlError.usage("run tatami-tools build-virtual-display before adding a display")}
+ let process=try Shell.launchDetached(binary,["3600"],log:paths.packageRoot.appendingPathComponent(".build/virtual-display.log"))
+ try Data(String(process.processIdentifier).utf8).write(to:pidFile(paths),options:.atomic)
+ guard Shell.wait(timeout:.seconds(12),until:{hasSecondary()}) else {
+ throw DemoCtlError.usage("CoreGraphics did not create the secondary display")
+ }
+ }
+ public static func disconnect(_ paths:LabPaths) throws {
+ let file=pidFile(paths)
+ guard FileManager.default.fileExists(atPath:file.path) else {
+ guard !hasSecondary() else {
+ throw DemoCtlError.usage("secondary display has no managed PID; close its creator first")
+ }
+ return
+ }
+ let raw=try String(contentsOf:file,encoding:.utf8)
+ guard let pid=Int32(raw),pid>0 else {throw DemoCtlError.usage("invalid virtual display PID")}
+ // Verify identity before signalling a potentially reused PID.
+ let result=try Shell.run(URL(fileURLWithPath:"/bin/ps"),["-p",String(pid),"-o","comm="])
+ if result.succeeded {
+ guard result.standardOutput.contains("demodisplay") else {throw DemoCtlError.usage("virtual display PID belongs to another process")}
+ kill(pid,SIGTERM)
+ }
+ guard Shell.wait(timeout:.seconds(12),until:{!hasSecondary()}) else {
+ throw DemoCtlError.usage("secondary display did not disconnect")
+ }
+ try FileManager.default.removeItem(at:file)
+ }
+}
diff --git a/DemoLab/Sources/DemoDriverKit/Chord.swift b/DemoLab/Sources/DemoDriverKit/Chord.swift
new file mode 100644
index 0000000..57eb853
--- /dev/null
+++ b/DemoLab/Sources/DemoDriverKit/Chord.swift
@@ -0,0 +1,278 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Carbon
+import CoreGraphics
+import Foundation
+
+// MARK: - Chord
+
+/// One keystroke to synthesize: a virtual key code plus the modifier flags to
+/// post with it.
+///
+/// A chord is what Tatami's *published* shortcut string means on this machine
+/// right now — the parse resolves the key through the active keyboard layout in
+/// the chord's own ⌘ state, because that is the key code Magnet registered with
+/// Carbon.
+public struct Chord: Equatable, Sendable {
+
+ // MARK: Lifecycle
+
+ public init(keyCode: CGKeyCode, flags: CGEventFlags, source: String) {
+ self.keyCode = keyCode
+ self.flags = flags
+ self.source = source
+ }
+
+ // MARK: Public
+
+ /// The virtual key code to post. Not necessarily the QWERTY code: see
+ /// `KeyCodes.activeLayout(forCharacter:withCommand:)`.
+ public let keyCode: CGKeyCode
+
+ /// Modifier flags, set on both the key-down and the key-up event.
+ public let flags: CGEventFlags
+
+ /// The shortcut text this chord was parsed from, verbatim (not lowercased),
+ /// so diagnostics can quote what the caller actually wrote.
+ public let source: String
+
+}
+
+// MARK: - ChordError
+
+public enum ChordError: Error, Equatable, CustomStringConvertible {
+ case emptyInput
+ case unknownKey(String)
+ case unknownModifier(String)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .emptyInput:
+ "empty shortcut string"
+ case .unknownKey(let name):
+ "unknown key \"\(name)\" — expected a-z, 0-9, one of ` - = [ ] \\ ; ' , . / "
+ + "or a named key (return, enter, tab, space, delete, escape, help, home, end, "
+ + "pageup, pagedown, forwarddelete, left, right, up, down, f1-f13)"
+ case .unknownModifier(let token):
+ "unknown modifier \"\(token)\" — expected cmd/command, shift, alt/opt/option, or ctrl/control"
+ }
+ }
+
+}
+
+// MARK: - KeyCodes
+
+/// Virtual key codes for the key names Tatami's skhd-style grammar accepts.
+///
+/// Two answers exist for a printable key, and they differ on a non-QWERTY
+/// layout. Tatami *stores* the QWERTY code, but Magnet registers the code of
+/// the currently active layout — read in the ⌘ state of the shortcut itself, as
+/// Sauce builds Magnet's table — so the physical key a demo must press is the
+/// active-layout one.
+public enum KeyCodes {
+
+ // MARK: Public
+
+ /// The QWERTY (US ANSI) virtual key code for a key name, aliases included.
+ /// Nil for any name outside Tatami's accepted set.
+ public static func qwerty(_ name: String) -> CGKeyCode? {
+ let key = name.lowercased()
+ let resolved = aliases[key] ?? key
+ return characterKeys[resolved] ?? namedKeys[resolved]
+ }
+
+ /// The virtual key code that produces `character` on the active keyboard
+ /// layout, read in the modifier state Sauce used to build Magnet's table.
+ ///
+ /// `withCommand` must be true whenever the chord carries ⌘. Sauce keys its
+ /// key-code map on `(carbonModifiers & cmdKey) != 0` and on nothing else, so
+ /// on a layout that rearranges under ⌘ — "Dvorak - QWERTY ⌘" is the shipped
+ /// example — a ⌘ chord registers a different code than the unmodified layout
+ /// gives. shift/ctrl/alt do not split that table, so they are not passed.
+ ///
+ /// Nil when the current input source exposes no Unicode layout data (most
+ /// input methods, e.g. Korean or Japanese, do not) or when the character is
+ /// unreachable in that state on this layout. Callers fall back to QWERTY.
+ public static func activeLayout(forCharacter character: Character, withCommand: Bool = false) -> CGKeyCode? {
+ guard let layoutData = currentLayoutData() else { return nil }
+ let wanted = String(character)
+ // Carbon packs the modifier state into UCKeyTranslate's high byte — the
+ // same `carbonModifiers >> 8` Sauce feeds it when it builds its tables.
+ let modifierKeyState = UInt32(withCommand ? (cmdKey >> 8) & 0xFF : 0)
+ return layoutData.withUnsafeBytes { raw -> CGKeyCode? in
+ guard let layout = raw.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) else { return nil }
+ let keyboardType = UInt32(LMGetKbdType())
+ for code in UInt16(0)...UInt16(127) where !keypadKeyCodes.contains(code) {
+ var deadKeyState = UInt32(0)
+ var length = 0
+ var characters = [UniChar](repeating: 0, count: 4)
+ let status = UCKeyTranslate(
+ layout,
+ code,
+ UInt16(kUCKeyActionDown),
+ modifierKeyState,
+ keyboardType,
+ OptionBits(kUCKeyTranslateNoDeadKeysBit),
+ &deadKeyState,
+ characters.count,
+ &length,
+ &characters
+ )
+ guard status == noErr, length > 0 else { continue }
+ if String(utf16CodeUnits: characters, count: length) == wanted { return CGKeyCode(code) }
+ }
+ return nil
+ }
+ }
+
+ // MARK: Private
+
+ /// QWERTY positions of the printable keys, copied from Tatami's own table so
+ /// a config string and a demo keystroke can never drift apart.
+ private static let characterKeys: [String: CGKeyCode] = [
+ "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
+ "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17,
+ "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "=": 24, "9": 25,
+ "7": 26, "-": 27, "8": 28, "0": 29, "]": 30, "o": 31, "u": 32, "[": 33,
+ "i": 34, "p": 35, "l": 37, "j": 38, "'": 39, "k": 40,
+ ";": 41, "\\": 42, ",": 43, "/": 44, "n": 45, "m": 46, ".": 47, "`": 50,
+ ]
+
+ /// Keys whose virtual code does not move between keyboard layouts.
+ private static let namedKeys: [String: CGKeyCode] = [
+ "return": 36, "enter": 76, "tab": 48, "space": 49, "delete": 51, "escape": 53,
+ "help": 114, "home": 115, "pageup": 116, "forwarddelete": 117, "end": 119,
+ "pagedown": 121, "left": 123, "right": 124, "down": 125, "up": 126,
+ "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98,
+ "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, "f13": 105,
+ ]
+
+ /// Friendly aliases, matching Tatami's table exactly. Note `del`: Tatami maps
+ /// it to `delete` (key code 51, the ⌫ key), *not* to `forwarddelete`. Pressing
+ /// 117 here would miss a `del` shortcut entirely, so this table follows the
+ /// parser that actually registers the hotkey.
+ private static let aliases: [String: String] = [
+ "esc": "escape",
+ "backspace": "delete",
+ "del": "delete",
+ "spacebar": "space",
+ "pgup": "pageup",
+ "pgdn": "pagedown",
+ ]
+
+ /// Keypad and function-row codes, skipped while scanning the active layout:
+ /// the keypad also produces digits, `=`, `/`, `.` and `,`, and the main row
+ /// is the key a person would press.
+ private static let keypadKeyCodes: ClosedRange = 65...92
+
+ private static func currentLayoutData() -> Data? {
+ guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { return nil }
+ guard let pointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil }
+ return Unmanaged.fromOpaque(pointer).takeUnretainedValue() as Data
+ }
+
+}
+
+// MARK: - ChordParser
+
+/// Parses Tatami's skhd-style shortcut strings.
+///
+/// The grammar is mirrored from `HotKey.init(parsing:)`, separator search from
+/// the END included, so `"ctrl + alt - -"` means what Tatami says it means.
+public enum ChordParser {
+
+ // MARK: Public
+
+ public static func parse(_ text: String) throws -> Chord {
+ var notes: [String] = []
+ return try parse(text, notes: ¬es)
+ }
+
+ /// Same parse, appending human-readable notes worth showing in verbose mode —
+ /// most importantly when the active layout and QWERTY disagree about which
+ /// physical key carries a character.
+ public static func parse(_ text: String, notes: inout [String]) throws -> Chord {
+ let normalized = text.lowercased().trimmingCharacters(in: .whitespaces)
+ guard !normalized.isEmpty else { throw ChordError.emptyInput }
+ let split = splitModifiersAndKey(normalized)
+ let flags = try modifiers(from: split.modifiers)
+ let keyName = split.key.trimmingCharacters(in: .whitespaces)
+ // The modifiers are read first because the key resolution depends on them:
+ // a ⌘ chord is looked up in the layout's ⌘ state.
+ let keyCode = try resolve(keyName: keyName, withCommand: flags.contains(.maskCommand), notes: ¬es)
+ return Chord(keyCode: keyCode, flags: flags, source: text)
+ }
+
+ /// Parses a modifier-only string such as `"alt"` or `"ctrl + alt"`, for the
+ /// held-modifier switcher. Unknown tokens throw rather than being dropped: a
+ /// dropped modifier would press a different, silently wrong shortcut.
+ public static func parseModifiers(_ text: String) throws -> CGEventFlags {
+ let normalized = text.lowercased().trimmingCharacters(in: .whitespaces)
+ guard !normalized.isEmpty else { throw ChordError.emptyInput }
+ return try modifiers(from: normalized)
+ }
+
+ // MARK: Private
+
+ /// The separator search Tatami performs, in the same order: padded `" - "`
+ /// from the end, then a bare `"-"` that is neither first nor last, then a
+ /// `"+"` split, then a bare key.
+ private static func splitModifiersAndKey(_ text: String) -> (modifiers: String?, key: String) {
+ if let range = text.range(of: " - ", options: .backwards) {
+ return (String(text[.. CGEventFlags {
+ guard let part else { return [] }
+ var flags: CGEventFlags = []
+ for token in part.split(whereSeparator: { $0 == "+" || $0 == "-" }) {
+ switch token.trimmingCharacters(in: .whitespaces) {
+ case "cmd", "command", "⌘": flags.insert(.maskCommand)
+ case "shift", "⇧": flags.insert(.maskShift)
+ case "alt", "opt", "option", "⌥": flags.insert(.maskAlternate)
+ case "ctrl", "control", "⌃": flags.insert(.maskControl)
+ case "", "fn": continue
+ default: throw ChordError.unknownModifier(String(token))
+ }
+ }
+ return flags
+ }
+
+ private static func resolve(keyName: String, withCommand: Bool, notes: inout [String]) throws -> CGKeyCode {
+ guard let qwerty = KeyCodes.qwerty(keyName) else { throw ChordError.unknownKey(keyName) }
+ guard keyName.count == 1, let character = keyName.first else { return qwerty }
+ let state = withCommand ? "the active layout with ⌘ held" : "the active layout"
+ guard let active = KeyCodes.activeLayout(forCharacter: character, withCommand: withCommand) else {
+ notes.append(
+ "key \"\(keyName)\": \(state) exposes no key that produces it; "
+ + "using the QWERTY key code \(qwerty), which is wrong if the layout is not QWERTY"
+ )
+ return qwerty
+ }
+ if active != qwerty {
+ notes.append(
+ "key \"\(keyName)\": \(state) says key code \(active), QWERTY says \(qwerty); "
+ + "pressing \(active), because that is the code Magnet registered"
+ )
+ }
+ return active
+ }
+
+}
diff --git a/DemoLab/Sources/DemoDriverKit/KeyDriver.swift b/DemoLab/Sources/DemoDriverKit/KeyDriver.swift
new file mode 100644
index 0000000..f460688
--- /dev/null
+++ b/DemoLab/Sources/DemoDriverKit/KeyDriver.swift
@@ -0,0 +1,309 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import ApplicationServices
+import Carbon
+import CoreGraphics
+import Foundation
+
+// MARK: - DriverError
+
+public enum DriverError: Error, Equatable, CustomStringConvertible {
+ case notTrusted
+ case eventSourceUnavailable
+ case eventCreationFailed(CGKeyCode)
+ case noModifiersToHold
+ case noKeysToTap
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .notTrusted:
+ "this process is not trusted for Accessibility, so no key events were posted — "
+ + "grant it in System Settings > Privacy & Security > Accessibility"
+ case .eventSourceUnavailable:
+ "CGEventSource(stateID: .hidSystemState) returned nil; no key events were posted"
+ case .eventCreationFailed(let keyCode):
+ "CGEvent could not be created for key code \(keyCode)"
+ case .noModifiersToHold:
+ "hold needs at least one modifier — a switcher session stays open only while a modifier is down"
+ case .noKeysToTap:
+ "hold needs at least one key to tap"
+ }
+ }
+
+}
+
+// MARK: - HeldKeys
+
+/// The keys this process has posted down and not yet posted up.
+///
+/// Process-global on purpose. A synthesized modifier key-down enters
+/// `CGEventSource.flagsState` and stays there until *something* posts the
+/// matching key-up: die in between and the modifier is logically held for the
+/// rest of the login session, so every later take records a corrupted machine.
+/// The ledger is what lets any thread — including the one that only learns the
+/// process is dying — finish the release.
+private enum HeldKeys {
+
+ // MARK: Internal
+
+ struct Entry {
+ /// The virtual key code posted down.
+ let code: CGKeyCode
+ /// The HID flag this key contributes while it is down; empty for a key that
+ /// is not a modifier.
+ let flag: CGEventFlags
+ }
+
+ static var isEmpty: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return entries.isEmpty
+ }
+
+ /// The flags this process currently owns: the union of the modifier keys it
+ /// is holding down. Every event it posts carries exactly this set.
+ static var flags: CGEventFlags {
+ lock.lock()
+ defer { lock.unlock() }
+ return entries.reduce(into: CGEventFlags()) { $0.formUnion($1.flag) }
+ }
+
+ static func add(code: CGKeyCode, flag: CGEventFlags) {
+ lock.lock()
+ defer { lock.unlock() }
+ entries.append(Entry(code: code, flag: flag))
+ }
+
+ static func drop(code: CGKeyCode) {
+ lock.lock()
+ defer { lock.unlock() }
+ if let index = entries.lastIndex(where: { $0.code == code }) { entries.remove(at: index) }
+ }
+
+ /// Takes everything still held, in press order. Emptying the ledger is what
+ /// makes releasing idempotent, so the signal path and the `atexit` path can
+ /// both run without posting a key-up twice.
+ static func drain() -> [Entry] {
+ lock.lock()
+ defer { lock.unlock() }
+ let taken = entries
+ entries.removeAll()
+ return taken
+ }
+
+ // MARK: Private
+
+ private static let lock = NSLock()
+
+ /// Reached from the main thread and from whichever thread handles a signal,
+ /// so `lock` guards every access.
+ private nonisolated(unsafe) static var entries: [Entry] = []
+
+}
+
+// MARK: - KeyDriver
+
+/// Synthesizes real key events. It knows nothing about Tatami beyond the
+/// shortcut text it is handed: no socket, no config, no process, no state.
+@MainActor
+public enum KeyDriver {
+
+ // MARK: Public
+
+ /// Whether this process may post events at all. Everything here refuses to
+ /// run when it is false — a demo that records nothing happening is worse
+ /// than a demo that stops with an error.
+ public static var isTrusted: Bool { AXIsProcessTrusted() }
+
+ /// Taps one chord: key down with the modifier flags set, hold, key up with
+ /// the same flags. Carbon hotkeys (`RegisterEventHotKey`, which is what
+ /// Magnet uses) match on the flags carried by the key event itself.
+ public static func press(_ chord: Chord, holdMilliseconds: Int = 24) throws {
+ let source = try eventSource()
+ // Ledger first, post second: an interrupt landing between the two must err
+ // towards a stray key-up, never towards a key left down.
+ HeldKeys.add(code: chord.keyCode, flag: [])
+ do {
+ try post(keyCode: chord.keyCode, keyDown: true, flags: chord.flags, source: source)
+ sleep(milliseconds: holdMilliseconds)
+ try post(keyCode: chord.keyCode, keyDown: false, flags: chord.flags, source: source)
+ } catch {
+ releaseHeldKeys(using: source)
+ throw error
+ }
+ HeldKeys.drop(code: chord.keyCode)
+ }
+
+ /// Holds `modifiers` down, taps each key in turn, waits, then releases.
+ ///
+ /// The modifiers are pressed as real key events, not merely set as flags on
+ /// the key taps: Tatami's held-modifier app/window switcher polls
+ /// `CGEventSource.flagsState`, which flags on a key event alone never reach.
+ /// The modifiers are always released — when a tap fails partway, and when the
+ /// operator interrupts the take mid-hold (see `installReleaseGuard`).
+ public static func hold(
+ modifiers: CGEventFlags,
+ tapping keys: [CGKeyCode],
+ gapMilliseconds: Int,
+ holdAfterMilliseconds: Int
+ ) throws {
+ guard !modifiers.isEmpty else { throw DriverError.noModifiersToHold }
+ guard !keys.isEmpty else { throw DriverError.noKeysToTap }
+ let source = try eventSource()
+ let held = modifierKeys.filter { modifiers.contains($0.flag) }
+
+ do {
+ for entry in held {
+ HeldKeys.add(code: entry.code, flag: entry.flag)
+ try post(keyCode: entry.code, keyDown: true, flags: HeldKeys.flags, source: source)
+ }
+ sleep(milliseconds: modifierSettleMilliseconds)
+ for (index, key) in keys.enumerated() {
+ HeldKeys.add(code: key, flag: [])
+ try post(keyCode: key, keyDown: true, flags: HeldKeys.flags, source: source)
+ sleep(milliseconds: tapHoldMilliseconds)
+ try post(keyCode: key, keyDown: false, flags: HeldKeys.flags, source: source)
+ HeldKeys.drop(code: key)
+ if index < keys.count - 1 { sleep(milliseconds: gapMilliseconds) }
+ }
+ sleep(milliseconds: holdAfterMilliseconds)
+ } catch {
+ releaseHeldKeys(using: source)
+ throw error
+ }
+ releaseHeldKeys(using: source)
+ }
+
+ // MARK: Fileprivate
+
+ /// Posts the key-up for everything still on the ledger, newest first, in the
+ /// exact reverse of the order it went down, and reports how many it released.
+ ///
+ /// Nonisolated because the interrupt path calls it off the main thread, and
+ /// posting a CGEvent does not need the main thread.
+ @discardableResult
+ fileprivate nonisolated static func releaseHeldKeys(using source: CGEventSource? = nil) -> Int {
+ guard let poster = source ?? CGEventSource(stateID: .hidSystemState) else {
+ // Draining now would lose the record of what is still down, and nothing
+ // could be posted anyway. Say so instead of failing quietly.
+ if !HeldKeys.isEmpty {
+ complain("no CGEventSource to release the keys this process is holding — they are still down")
+ }
+ return 0
+ }
+ let held = HeldKeys.drain()
+ var flags = held.reduce(into: CGEventFlags()) { $0.formUnion($1.flag) }
+ for entry in held.reversed() {
+ flags.remove(entry.flag)
+ guard let event = CGEvent(keyboardEventSource: poster, virtualKey: entry.code, keyDown: false) else { continue }
+ event.flags = eventFlags(for: entry.code, modifiers: flags)
+ event.post(tap: .cghidEventTap)
+ }
+ return held.count
+ }
+
+ fileprivate nonisolated static func complain(_ message: String) {
+ let name = ProcessInfo.processInfo.processName
+ FileHandle.standardError.write(Data("\(name): \(message)\n".utf8))
+ }
+
+ // MARK: Private
+
+ /// Canonical press order, matching how Tatami writes a combo (`ctrl + alt +
+ /// shift + cmd`). Release is the exact reverse.
+ private static let modifierKeys: [(flag: CGEventFlags, code: CGKeyCode)] = [
+ (.maskControl, CGKeyCode(kVK_Control)),
+ (.maskAlternate, CGKeyCode(kVK_Option)),
+ (.maskShift, CGKeyCode(kVK_Shift)),
+ (.maskCommand, CGKeyCode(kVK_Command)),
+ ]
+
+ /// Time for a modifier press to reach the HID flags state before the first tap.
+ private static let modifierSettleMilliseconds = 32
+
+ /// Down-to-up time of one tap inside a held-modifier session.
+ private static let tapHoldMilliseconds = 24
+
+ /// Written once, from the main thread, by the first call that posts anything.
+ private nonisolated(unsafe) static var releaseGuardSources: [DispatchSourceSignal] = []
+
+ /// Ctrl-C during a hold is the one interrupt that outlives the process: the
+ /// modifier it leaves down stays down system-wide until a physical key
+ /// releases it, silently corrupting every take that follows. Installed on the
+ /// first posted event rather than in a `main`, so a new entry point cannot
+ /// forget it.
+ private nonisolated static func installReleaseGuard() {
+ guard releaseGuardSources.isEmpty else { return }
+ atexit(releaseHeldKeysAtExit)
+ // SIGINT and SIGTERM only, the same two DemoRecorder disarms: the guard
+ // takes over the process disposition for whatever it names, so it names no
+ // signal the operator may have deliberately arranged to ignore.
+ for (number, name) in [(SIGINT, "SIGINT"), (SIGTERM, "SIGTERM")] {
+ // The dispatch source only sees the signal once the default disposition,
+ // which kills the process outright, is disarmed.
+ signal(number, SIG_IGN)
+ let source = DispatchSource.makeSignalSource(signal: number, queue: .global(qos: .userInitiated))
+ source.setEventHandler {
+ let released = releaseHeldKeys()
+ if released > 0 {
+ complain("\(name) arrived mid-keystroke — released \(released) key(s) that were still down")
+ }
+ exit(128 + number)
+ }
+ source.resume()
+ releaseGuardSources.append(source)
+ }
+ }
+
+ private static func eventSource() throws -> CGEventSource {
+ guard isTrusted else { throw DriverError.notTrusted }
+ installReleaseGuard()
+ guard let source = CGEventSource(stateID: .hidSystemState) else {
+ throw DriverError.eventSourceUnavailable
+ }
+ return source
+ }
+
+ /// Arrow keys carry keypad/function identity in addition to held modifiers.
+ /// Dropping those bits made Carbon shortcuts ignore otherwise correct arrow events.
+ public nonisolated static func eventFlags(for keyCode: CGKeyCode, modifiers: CGEventFlags) -> CGEventFlags {
+ switch keyCode {
+ case 123...126: modifiers.union([.maskNumericPad, .maskSecondaryFn])
+ default: modifiers
+ }
+ }
+
+ private static func post(
+ keyCode: CGKeyCode,
+ keyDown: Bool,
+ flags: CGEventFlags,
+ source: CGEventSource
+ ) throws {
+ guard let event = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: keyDown) else {
+ throw DriverError.eventCreationFailed(keyCode)
+ }
+ event.flags = eventFlags(for: keyCode, modifiers: flags)
+ event.post(tap: .cghidEventTap)
+ }
+
+ private static func sleep(milliseconds: Int) {
+ guard milliseconds > 0 else { return }
+ Thread.sleep(forTimeInterval: Double(milliseconds) / 1000)
+ }
+
+}
+
+// MARK: - releaseHeldKeysAtExit
+
+/// `atexit` takes a C function, so this is a free function: it can carry no
+/// captured context and no actor isolation. It covers the orderly exits a
+/// signal source never sees — an error path that ends in `exit`, above all.
+private func releaseHeldKeysAtExit() {
+ let released = KeyDriver.releaseHeldKeys()
+ if released > 0 {
+ KeyDriver.complain("released \(released) key(s) that were still down at exit")
+ }
+}
diff --git a/DemoLab/Sources/DemoDriverKit/NativeInteractionDriver.swift b/DemoLab/Sources/DemoDriverKit/NativeInteractionDriver.swift
new file mode 100644
index 0000000..96dc656
--- /dev/null
+++ b/DemoLab/Sources/DemoDriverKit/NativeInteractionDriver.swift
@@ -0,0 +1,269 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import AppKit
+import ApplicationServices
+import CoreGraphics
+import Foundation
+
+/// Drives the fixture apps through their native controls. No model-state injection.
+@MainActor
+public enum NativeInteractionDriver {
+ public static func frontmostBundleIdentifier() -> String? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(AXUIElementCreateSystemWide(), kAXFocusedApplicationAttribute as CFString, &value) == .success,
+ let value, CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil }
+ var pid: pid_t = 0
+ guard AXUIElementGetPid(unsafeDowncast(value, to: AXUIElement.self), &pid) == .success else { return nil }
+ return NSRunningApplication(processIdentifier: pid)?.bundleIdentifier
+ }
+
+ public static func click(bundleIdentifier: String, identifier: String) throws {
+ let target = try element(bundleIdentifier: bundleIdentifier, identifier: identifier)
+ try reveal(target)
+ let point = try center(of: target)
+ try clickPoint(point)
+ }
+
+ public static func isMenuItem(bundleIdentifier: String, identifier: String) throws -> Bool {
+ let target = try element(bundleIdentifier: bundleIdentifier, identifier: identifier)
+ return attribute(target, kAXRoleAttribute) as? String == kAXMenuItemRole
+ }
+
+ public static func rightClick(bundleIdentifier: String, identifier: String) throws {
+ let target = try element(bundleIdentifier: bundleIdentifier, identifier: identifier)
+ try reveal(target)
+ let point = try center(of: target)
+ try move(to: point)
+ guard let down = CGEvent(mouseEventSource: nil, mouseType: .rightMouseDown, mouseCursorPosition: point, mouseButton: .right),
+ let up = CGEvent(mouseEventSource: nil, mouseType: .rightMouseUp, mouseCursorPosition: point, mouseButton: .right)
+ else { throw DriverError.eventSourceUnavailable }
+ down.flags = []; up.flags = []
+ down.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: 0.06)
+ up.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: 0.25)
+ }
+
+ public static func prepareSettingsWindow(bundleIdentifier: String) throws {
+ let window = try element(bundleIdentifier: bundleIdentifier, identifier: "main")
+ var frame = try bounds(of: window)
+ try drag(from: CGPoint(x: frame.minX + 110, y: frame.minY + 15), to: CGPoint(x: 260, y: 125))
+ frame = try bounds(of: window)
+ try drag(from: CGPoint(x: frame.maxX - 2, y: frame.maxY - 2), to: CGPoint(x: 1770, y: 1050))
+ }
+
+ public static func clickSettingsWindowTitle(bundleIdentifier: String) throws {
+ let window = try element(bundleIdentifier: bundleIdentifier, identifier: "main")
+ // Auto-opened fixture apps can cover Tatami during launch. Raise the
+ // actual window first so the title click cannot hit an app behind it.
+ guard AXUIElementPerformAction(window, kAXRaiseAction as CFString) == .success else {
+ throw InteractionError("could not raise the Tatami window")
+ }
+ Thread.sleep(forTimeInterval: 0.12)
+ let sheet = (attribute(window, kAXChildrenAttribute) as? [AXUIElement])?
+ .first { attribute($0, kAXRoleAttribute) as? String == kAXSheetRole }
+ let frame = try bounds(of: sheet ?? window)
+ try clickPoint(CGPoint(x: sheet == nil ? frame.minX + 110 : frame.midX, y: frame.minY + 15))
+ }
+
+ public static func clickWindowTitle(bundleIdentifier:String) throws {
+ let frame=try windowFrame(bundleIdentifier:bundleIdentifier)
+ try clickPoint(CGPoint(x:frame.midX,y:frame.minY+14))
+ }
+
+ private static func clickPoint(_ point:CGPoint) throws {
+ try move(to: point)
+ guard let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
+ let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)
+ else { throw DriverError.eventSourceUnavailable }
+ down.flags = []; up.flags = []
+ down.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: 0.06)
+ up.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: 0.12)
+ }
+
+ public static func controlFrame(bundleIdentifier:String,identifier:String) throws -> CGRect {try bounds(of:element(bundleIdentifier:bundleIdentifier,identifier:identifier))}
+ public static func hover(bundleIdentifier:String,identifier:String) throws {
+ let target=try element(bundleIdentifier:bundleIdentifier,identifier:identifier)
+ try reveal(target)
+ try move(to:center(of:target))
+ }
+ public static func windowFrame(bundleIdentifier:String) throws -> CGRect {
+ guard let app=NSRunningApplication.runningApplications(withBundleIdentifier:bundleIdentifier).first else {throw InteractionError("app is not running")}
+ let root=AXUIElementCreateApplication(app.processIdentifier)
+ guard let windows=attribute(root,kAXWindowsAttribute) as? [AXUIElement],
+ let window=windows.first(where: { search($0, identifier: "demolab.window.content", depth: 0) != nil })
+ else {throw InteractionError("app has no identified demo window") }
+ return try bounds(of:window)
+ }
+ public static func drag(from:CGPoint,to:CGPoint) throws {
+ try move(to:from)
+ guard let down=CGEvent(mouseEventSource:nil,mouseType:.leftMouseDown,mouseCursorPosition:from,mouseButton:.left) else {throw DriverError.eventSourceUnavailable}
+ down.flags=[];down.post(tap:.cghidEventTap)
+ Thread.sleep(forTimeInterval:0.12)
+ for step in 1...40 {
+ let t=Double(step)/40;let eased=t*t*(3-2*t)
+ let point=CGPoint(x:from.x+(to.x-from.x)*eased,y:from.y+(to.y-from.y)*eased)
+ guard let drag=CGEvent(mouseEventSource:nil,mouseType:.leftMouseDragged,mouseCursorPosition:point,mouseButton:.left) else {throw DriverError.eventSourceUnavailable}
+ drag.flags=[];drag.post(tap:.cghidEventTap);Thread.sleep(forTimeInterval:0.018)
+ }
+ Thread.sleep(forTimeInterval:0.35)
+ guard let up=CGEvent(mouseEventSource:nil,mouseType:.leftMouseUp,mouseCursorPosition:to,mouseButton:.left) else {throw DriverError.eventSourceUnavailable}
+ up.flags=[];up.post(tap:.cghidEventTap)
+ Thread.sleep(forTimeInterval:0.5)
+ }
+ public static func snapshot(bundleIdentifier:String) throws -> String {
+ guard let app=NSRunningApplication.runningApplications(withBundleIdentifier:bundleIdentifier).first else {throw InteractionError("app is not running")}
+ var lines:[String]=[]
+ func visit(_ node:AXUIElement,_ depth:Int) {
+ guard depth<24,lines.count<650 else {return}
+ let role=attribute(node,kAXRoleAttribute) as? String ?? "?"
+ let attrs=[kAXIdentifierAttribute,kAXTitleAttribute,kAXValueAttribute,kAXHelpAttribute,kAXDescriptionAttribute]
+ .compactMap { key -> String? in guard let value=attribute(node,key) as? String,!value.isEmpty else {return nil};return "\(key)=\(value.prefix(180))" }
+ lines.append(String(repeating:" ",count:depth)+role+" "+attrs.joined(separator:" | "))
+ for child in attribute(node,kAXChildrenAttribute) as? [AXUIElement] ?? [] {visit(child,depth+1)}
+ }
+ visit(AXUIElementCreateApplication(app.processIdentifier),0)
+ return lines.joined(separator:"\n")
+ }
+
+ public static func type(_ text: String, intervalMilliseconds: Int) throws {
+ guard KeyDriver.isTrusted else { throw DriverError.notTrusted }
+ for character in text {
+ let units = Array(String(character).utf16)
+ guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true),
+ let up = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false)
+ else { throw DriverError.eventSourceUnavailable }
+ units.withUnsafeBufferPointer { buffer in
+ down.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: buffer.baseAddress!)
+ up.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: buffer.baseAddress!)
+ }
+ down.flags = []; up.flags = []
+ down.post(tap: .cghidEventTap); up.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: Double(intervalMilliseconds) / 1000)
+ }
+ }
+
+ public static func scroll(bundleIdentifier: String, identifier: String, pixels: Int) throws {
+ try move(to: center(of: element(bundleIdentifier: bundleIdentifier, identifier: identifier)))
+ for _ in 0..<12 {
+ guard let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 1, wheel1: Int32(pixels / 12), wheel2: 0, wheel3: 0)
+ else { throw DriverError.eventSourceUnavailable }
+ event.flags = []
+ event.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: 0.025)
+ }
+ }
+
+ public static func value(bundleIdentifier: String, identifier: String) throws -> String {
+ let target = try element(bundleIdentifier: bundleIdentifier, identifier: identifier)
+ guard let value = attribute(target, kAXValueAttribute) as? String else {
+ throw InteractionError("native control value is unavailable: \(identifier)")
+ }
+ return value
+ }
+
+ /// AX exposes controls outside a scroll viewport too. Scroll them into view
+ /// before posting a real mouse event; an offscreen coordinate is not a click.
+ private static func reveal(_ target:AXUIElement) throws {
+ var ancestors:[AXUIElement]=[]
+ var node=target
+ for _ in 0..<24 {
+ guard let raw=attribute(node,kAXParentAttribute),CFGetTypeID(raw)==AXUIElementGetTypeID() else {break}
+ node=unsafeDowncast(raw,to:AXUIElement.self)
+ // A popup menu floats above its owner's scroll view; scrolling that
+ // view cannot reveal a submenu item and can instead close the menu.
+ if attribute(node,kAXRoleAttribute) as? String == kAXMenuRole { return }
+ if attribute(node,kAXRoleAttribute) as? String == kAXScrollAreaRole {ancestors.append(node)}
+ }
+ for ancestor in ancestors.reversed() {
+ var visible=false
+ for _ in 0..<10 {
+ let frame=try bounds(of:target)
+ let viewport=try bounds(of:ancestor).insetBy(dx:6,dy:12)
+ if viewport.contains(CGPoint(x:frame.midX,y:frame.midY)) {visible=true;break}
+ let delta:Double
+ if frame.midY>viewport.maxY {delta = -min(400,max(100,frame.maxY-viewport.maxY+24))}
+ else if frame.midY AXUIElement {
+ guard KeyDriver.isTrusted else { throw DriverError.notTrusted }
+ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first else {
+ throw InteractionError("app is not running: \(bundleIdentifier)")
+ }
+ let root = AXUIElementCreateApplication(app.processIdentifier)
+ let deadline = ContinuousClock.now.advanced(by: .seconds(4))
+ repeat {
+ if let found = search(root, identifier: identifier, depth: 0) { return found }
+ Thread.sleep(forTimeInterval: 0.08)
+ } while ContinuousClock.now < deadline
+ throw InteractionError("native control not found: \(bundleIdentifier) / \(identifier)")
+ }
+ private static func search(_ element: AXUIElement, identifier: String, depth: Int) -> AXUIElement? {
+ guard depth < 30 else { return nil }
+ let selectors=["title:":kAXTitleAttribute,"help:":kAXHelpAttribute,"text:":kAXValueAttribute,"description:":kAXDescriptionAttribute]
+ let roleSelectors = ["button:": kAXButtonRole, "heading:": "AXHeading"]
+ if let selector = roleSelectors.first(where: { identifier.hasPrefix($0.key) }) {
+ let label = String(identifier.dropFirst(selector.key.count))
+ if attribute(element, kAXRoleAttribute) as? String == selector.value,
+ [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute].contains(where: { attribute(element, $0) as? String == label }) {
+ return element
+ }
+ } else if let selector=selectors.first(where:{identifier.hasPrefix($0.key)}) {
+ if attribute(element,selector.value) as? String == String(identifier.dropFirst(selector.key.count)) {return element}
+ } else if attribute(element,kAXIdentifierAttribute) as? String == identifier {return element}
+ for child in attribute(element, kAXChildrenAttribute) as? [AXUIElement] ?? [] {
+ if let found = search(child, identifier: identifier, depth: depth + 1) { return found }
+ }
+ return nil
+ }
+ private static func attribute(_ element: AXUIElement, _ name: String) -> CFTypeRef? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil }
+ return value
+ }
+ private static func center(of element:AXUIElement) throws -> CGPoint {
+ let frame=try bounds(of:element);return CGPoint(x:frame.midX,y:frame.midY)
+ }
+ private static func bounds(of element: AXUIElement) throws -> CGRect {
+ guard let rawPosition = attribute(element, kAXPositionAttribute),
+ let rawSize = attribute(element, kAXSizeAttribute),
+ CFGetTypeID(rawPosition) == AXValueGetTypeID(), CFGetTypeID(rawSize) == AXValueGetTypeID()
+ else { throw InteractionError("control has no accessible bounds") }
+ var position = CGPoint.zero; var size = CGSize.zero
+ guard AXValueGetValue(unsafeDowncast(rawPosition, to: AXValue.self), .cgPoint, &position),
+ AXValueGetValue(unsafeDowncast(rawSize, to: AXValue.self), .cgSize, &size), size.width > 0, size.height > 0
+ else { throw InteractionError("control has invalid bounds") }
+ return CGRect(origin:position,size:size)
+ }
+ private static func move(to point: CGPoint) throws {
+ guard let start = CGEvent(source: nil)?.location else { throw DriverError.eventSourceUnavailable }
+ for step in 1...18 {
+ let t = Double(step) / 18
+ let eased = t * t * (3 - 2 * t)
+ let next = CGPoint(x: start.x + (point.x - start.x) * eased, y: start.y + (point.y - start.y) * eased)
+ guard let event = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: next, mouseButton: .left)
+ else { throw DriverError.eventSourceUnavailable }
+ event.flags = []
+ event.post(tap: .cghidEventTap)
+ Thread.sleep(forTimeInterval: 0.012)
+ }
+ }
+}
+public struct InteractionError: Error, CustomStringConvertible {
+ public let description: String
+ public init(_ description: String) { self.description = description }
+}
diff --git a/DemoLab/Sources/DemoDriverKit/Pointer.swift b/DemoLab/Sources/DemoDriverKit/Pointer.swift
new file mode 100644
index 0000000..3e8754a
--- /dev/null
+++ b/DemoLab/Sources/DemoDriverKit/Pointer.swift
@@ -0,0 +1,107 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import CoreGraphics
+import Foundation
+
+// MARK: - PointerError
+
+public enum PointerError: Error, Equatable, CustomStringConvertible {
+ case noDisplays
+ case displayOutOfRange(index: Int, count: Int)
+ case warpFailed(CGError)
+ case positionUnreadable
+ case warpDidNotTake(expected: CGPoint, actual: CGPoint)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .noDisplays:
+ "no displays are attached"
+ case .displayOutOfRange(let index, let count):
+ "display index \(index) is out of range — \(count) display(s) attached, indices 0...\(count - 1)"
+ case .warpFailed(let error):
+ "CGWarpMouseCursorPosition failed with CGError \(error.rawValue)"
+ case .positionUnreadable:
+ "the cursor position could not be read back, so the warp could not be confirmed"
+ case .warpDidNotTake(let expected, let actual):
+ "the cursor did not move: asked for (\(Int(expected.x)), \(Int(expected.y))), "
+ + "it sits at (\(Int(actual.x)), \(Int(actual.y)))"
+ }
+ }
+
+}
+
+// MARK: - Pointer
+
+/// Cursor placement.
+///
+/// This matters for more than looks: Tatami puts a dynamic workspace on the
+/// display under the cursor, so a scene that means "create this on the right
+/// display" has to move the cursor there first, exactly as a person would.
+public enum Pointer {
+
+ // MARK: Public
+
+ /// Warps the cursor to a point in global display coordinates (top-left
+ /// origin, the space `CGWarpMouseCursorPosition` uses), then confirms it
+ /// landed. A blocked warp throws instead of quietly leaving the cursor put.
+ public static func warp(to point: CGPoint) throws {
+ let status = CGWarpMouseCursorPosition(point)
+ guard status == .success else { throw PointerError.warpFailed(status) }
+ // A warp detaches the physical mouse from the cursor for a moment;
+ // re-associating hands control straight back.
+ CGAssociateMouseAndMouseCursorPosition(1)
+ guard let actual = CGEvent(source: nil)?.location else { throw PointerError.positionUnreadable }
+ guard abs(actual.x - point.x) <= tolerance, abs(actual.y - point.y) <= tolerance else {
+ throw PointerError.warpDidNotTake(expected: point, actual: actual)
+ }
+ }
+
+ /// Warps the cursor to a unit position on one display, where (0, 0) is the
+ /// display's top-left corner and (1, 1) its bottom-right.
+ ///
+ /// `displayIndex` is zero-based into `NSScreen.screens` sorted by
+ /// (frame.minX, frame.minY) — the same order Tatami's `DisplayClient` uses,
+ /// so display 0 here and display 0 there are the same screen.
+ @MainActor
+ public static func warp(toDisplayIndex displayIndex: Int, unitX: Double, unitY: Double) throws {
+ let screens = NSScreen.screens.sorted {
+ ($0.frame.minX, $0.frame.minY) < ($1.frame.minX, $1.frame.minY)
+ }
+ guard let primary = NSScreen.screens.first, !screens.isEmpty else { throw PointerError.noDisplays }
+ guard screens.indices.contains(displayIndex) else {
+ throw PointerError.displayOutOfRange(index: displayIndex, count: screens.count)
+ }
+
+ // Cocoa's bottom-left origin -> the top-left origin the warp expects.
+ let frame = screens[displayIndex].frame
+ let top = primary.frame.maxY - frame.maxY
+ let x = frame.minX + clamped(unitX) * frame.width
+ let y = top + clamped(unitY) * frame.height
+
+ // Stay a point inside the frame: Tatami resolves the display under the
+ // cursor with `frame.contains`, which excludes the far edges, so a cursor
+ // parked exactly on an edge reads as the neighbouring display or none.
+ let point = CGPoint(
+ x: min(max(x, frame.minX + edgeInset), frame.maxX - edgeInset),
+ y: min(max(y, top + edgeInset), top + frame.height - edgeInset)
+ )
+ try warp(to: point)
+ }
+
+ // MARK: Private
+
+ /// How far the cursor may sit from the requested point before the warp counts
+ /// as having failed.
+ private static let tolerance = 2.0
+
+ private static let edgeInset = 1.0
+
+ private static func clamped(_ unit: Double) -> Double {
+ min(max(unit, 0), 1)
+ }
+
+}
diff --git a/DemoLab/Sources/DemoOverlay/main.swift b/DemoLab/Sources/DemoOverlay/main.swift
new file mode 100644
index 0000000..20e548c
--- /dev/null
+++ b/DemoLab/Sources/DemoOverlay/main.swift
@@ -0,0 +1,378 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import DemoAppKit
+import SwiftUI
+
+// MARK: - OverlayMode
+
+/// How much of the narration the overlay is allowed to draw.
+///
+/// `democtl` can emit the captions as an ASS subtitle sidecar instead of burning
+/// them into the frame, so a take can be restyled or translated without being
+/// re-shot. When it does, it turns the live captions off here rather than asking
+/// every scene to stop sending them: the scene file stays the one source of the
+/// narration text whichever way it is rendered.
+enum OverlayMode: String, CaseIterable {
+ /// Chapter, caption and keycaps. What a take renders unless told otherwise.
+ case all
+ /// Keycaps only. The captions are coming from the sidecar instead.
+ case keys
+ /// Nothing at all, for a take that wants untouched pixels.
+ case off
+
+ // MARK: Internal
+
+ var showsChapter: Bool { self == .all }
+
+ var showsCaption: Bool { self == .all }
+
+ var showsKeys: Bool { self != .off }
+}
+
+// MARK: - OverlayModel
+
+/// What the overlay is currently saying. Every field is set explicitly by a
+/// scene and never expires on its own: a caption that faded on a timer would
+/// land on a different frame in every take.
+///
+/// `mode` hides text, it never discards it. A scene may change the mode part way
+/// through a run, and going back to `.all` has to show whatever was last set
+/// rather than a blank layer.
+@MainActor
+final class OverlayModel: ObservableObject {
+ @Published var chapter = ""
+ @Published var caption = ""
+ @Published var detail = ""
+ @Published var keys = ""
+ @Published var mode = OverlayMode.all
+}
+
+// MARK: - OverlayApp
+
+/// A click-through, always-on-top layer that narrates the demo.
+///
+/// A tiling demo is hard to follow without it. The viewer sees three windows
+/// rearrange and has no idea whether a key was pressed, which one, or why that
+/// key was the right thing to reach for. This draws the keystroke and one line
+/// of reasoning over the top.
+///
+/// Three properties keep it out of the demo's way:
+///
+/// - It is an accessory app (`LSUIElement`) on the `.screenSaver` window level,
+/// so it is never in the Dock, never in the app switcher, and always above the
+/// tiles.
+/// - It ignores mouse events entirely, so it cannot steal a click.
+/// - Its bundle id goes in Tatami's `settings.visibility.overlayAwareApps`,
+/// which is precisely the feature for an app that owns persistent elevated
+/// controls: Tatami then excludes its window from focus, cycling, layout and
+/// membership instead of trying to tile the narration.
+@MainActor
+final class OverlayApp: NSObject, NSApplicationDelegate {
+
+ // MARK: Internal
+
+ func applicationDidFinishLaunching(_: Notification) {
+ rebuildPanels()
+ // A display change (or a workspace switch onto another screen) must not
+ // leave the narration stranded on a screen that is gone.
+ NotificationCenter.default.addObserver(
+ forName: NSApplication.didChangeScreenParametersNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.rebuildPanels() }
+ }
+ startControl()
+ }
+
+ // MARK: Private
+
+ private let model = OverlayModel()
+ private var panels = [NSPanel]()
+ private var control: DemoControlServer?
+
+ /// One panel per screen: a scene can caption a two-display take without the
+ /// text living on only one of them.
+ private func rebuildPanels() {
+ for panel in panels { panel.orderOut(nil) }
+ panels.removeAll()
+
+ for screen in NSScreen.screens {
+ let panel = NSPanel(
+ contentRect: screen.frame,
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.isFloatingPanel = true
+ panel.level = .screenSaver
+ panel.backgroundColor = .clear
+ panel.isOpaque = false
+ panel.hasShadow = false
+ // Narration must never take focus or eat a click meant for a window.
+ panel.ignoresMouseEvents = true
+ panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
+ panel.hidesOnDeactivate = false
+ let hosting = NSHostingView(rootView: OverlayView(model: model))
+ hosting.sizingOptions = []
+ panel.contentView = hosting
+ panel.setFrame(screen.frame, display: true)
+ panel.orderFrontRegardless()
+ panels.append(panel)
+ }
+ }
+
+ /// What the panels are doing, in one line: how many, how many are on screen,
+ /// and whether the app itself is hidden. Every one of those has been the
+ /// reason narration failed to appear in a take.
+ private func panelReport() -> String {
+ let visible = panels.filter(\.isVisible).count
+ return "panels=\(visible)/\(panels.count) hidden=\(NSApp.isHidden)"
+ }
+
+ /// Puts the panels back on top, and does it on every beat rather than once at
+ /// launch.
+ ///
+ /// Ordering front once was not enough. Panels reported themselves visible for
+ /// a whole take while the first two keycasts were missing from the recording:
+ /// the demo's own windows had all been created after the panels were ordered
+ /// front, and until the panels were ordered again the capture composited them
+ /// underneath. Narration is only ever asked to draw a couple of dozen times in
+ /// a take, so re-asserting the one thing it must be true of costs nothing.
+ private func present() {
+ for panel in panels { panel.orderFrontRegardless() }
+ }
+
+ private func startControl() {
+ let server = DemoControlServer(bundleIdentifier: DemoCatalog.overlayBundleIdentifier) { [weak self] request in
+ guard let self else { return "err gone" }
+ return handle(request)
+ }
+ server.start()
+ control = server
+ }
+
+ private func handle(_ request: DemoControlRequest) -> String {
+ switch request.verb {
+ case "ping":
+ return "ok Overlay \(panelReport()) mode=\(model.mode.rawValue)"
+
+ case "chapter":
+ model.chapter = request.argument
+ present()
+ return "ok chapter"
+
+ case "caption":
+ // `caption | `: the headline says what is happening,
+ // the optional detail after the pipe says why it is worth doing.
+ let parts = request.argument.components(separatedBy: " | ")
+ model.caption = parts.first?.trimmingCharacters(in: .whitespaces) ?? ""
+ model.detail = parts.count > 1
+ ? parts.dropFirst().joined(separator: " | ").trimmingCharacters(in: .whitespaces)
+ : ""
+ present()
+ return "ok caption"
+
+ case "keys":
+ model.keys = request.argument
+ present()
+ // Reported back rather than assumed: a keycast that is on screen for
+ // 1.4 seconds is the one piece of narration a take cannot re-render
+ // afterwards, so `democtl` gets told whether the panel was actually able
+ // to draw it.
+ return "ok keys \(panelReport())"
+
+ case "mode":
+ // Rejected rather than coerced. A misspelled mode that quietly fell back
+ // to `all` would burn captions into a take that is also shipping them as a
+ // sidecar, and nothing would say so until the doubled text was on screen.
+ let raw = request.argument.trimmingCharacters(in: .whitespaces).lowercased()
+ guard let mode = OverlayMode(rawValue: raw) else {
+ let known = OverlayMode.allCases.map(\.rawValue).joined(separator: ", ")
+ return "err mode must be one of \(known), got \"\(request.argument)\""
+ }
+ model.mode = mode
+ present()
+ return "ok mode=\(mode.rawValue)"
+
+ case "clear":
+ // Text only. The mode is a property of how this run is being rendered, not
+ // of the current beat, so clearing a caption must not switch burned-in
+ // captions back on behind `democtl`'s back.
+ model.chapter = ""
+ model.caption = ""
+ model.detail = ""
+ model.keys = ""
+ return "ok clear"
+
+ case "quit":
+ DispatchQueue.main.async { NSApp.terminate(nil) }
+ return "ok quitting"
+
+ default:
+ return "err unknown command \"\(request.verb)\""
+ }
+ }
+
+}
+
+// MARK: - OverlayView
+
+/// Everything here is sized for video, not for a document: a 1920x1200 take is
+/// often watched in a half-width embed, where anything set at reading sizes is
+/// gone before it can be read. Type is large, cards are wide, and contrast is
+/// carried by a solid dark fill plus a hairline edge so the narration survives
+/// both a white editor and a black terminal in the same frame.
+private struct OverlayView: View {
+
+ // MARK: Internal
+
+ @ObservedObject var model: OverlayModel
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if model.mode.showsChapter, !model.chapter.isEmpty {
+ // Top left, not top centre. Tatami draws its own on-screen feedback at
+ // the top centre of the active display, and that feedback is one of the
+ // things being demonstrated, so the whole top centre belongs to Tatami.
+ // A pill parked there covers the very thing the take is about.
+ HStack(spacing: 0) {
+ chapter
+ Spacer(minLength: 0)
+ }
+ .padding(.leading, 44)
+ .padding(.top, 46)
+ }
+
+ Spacer(minLength: 0)
+
+ HStack(alignment: .bottom, spacing: 18) {
+ Spacer(minLength: 0)
+ if model.mode.showsCaption, !model.caption.isEmpty { caption }
+ Spacer(minLength: 0)
+ }
+ // A symmetric gutter, so the card stays centred on the screen and still
+ // cannot grow underneath the keycaps on a display narrower than the 1920
+ // the sizes above are tuned for. Measuring the caps instead would make the
+ // card width depend on the chord, and the card would jump between beats.
+ .padding(.horizontal, 340)
+ .overlay(alignment: .bottomTrailing) {
+ if model.mode.showsKeys, !model.keys.isEmpty {
+ KeycapRow(chord: model.keys)
+ .padding(.trailing, 44)
+ .padding(.bottom, burnedCaptionInset)
+ }
+ }
+ .padding(.bottom, 56)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // The narration is drawn onto a fully transparent layer; nothing here may
+ // tint the demo underneath it.
+ .background(Color.clear)
+ }
+
+ // MARK: Private
+
+ /// Room left at the bottom for a caption this overlay is *not* drawing.
+ ///
+ /// In `all` the caps sit beside the live card and the layout already keeps
+ /// them apart. In `keys` the card is coming from the ASS sidecar and will be
+ /// burned into these very pixels, so the space it will occupy has to be
+ /// reserved here or the caps land on top of it. They did: a take showed
+ /// `⌃` sitting on the right edge of the burned card.
+ ///
+ /// The number is the sidecar's own geometry, which is why it is spelled out
+ /// rather than eyeballed: `Caption` is two lines at 54 and 38 with a
+ /// `MarginV` of 84, all measured against a 1200pt-tall frame.
+ private var burnedCaptionInset: CGFloat {
+ model.mode.showsCaption ? 0 : 150
+ }
+
+ private var chapter: some View {
+ Text(model.chapter)
+ .font(.system(size: 19, weight: .semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 22)
+ .padding(.vertical, 11)
+ .background(
+ Capsule(style: .continuous)
+ .fill(Color.black.opacity(0.82))
+ .overlay(Capsule(style: .continuous).strokeBorder(Color.white.opacity(0.20), lineWidth: 1))
+ )
+ }
+
+ private var caption: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ Text(model.caption)
+ .font(.system(size: 30, weight: .semibold))
+ .foregroundStyle(.white)
+ if !model.detail.isEmpty {
+ // Dimmed enough to read as the secondary line, bright enough that it is
+ // still legible where the card sits over a white editor window.
+ Text(model.detail)
+ .font(.system(size: 20, weight: .regular))
+ .foregroundStyle(.white.opacity(0.88))
+ }
+ }
+ .multilineTextAlignment(.leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 1000, alignment: .leading)
+ .padding(.horizontal, 34)
+ .padding(.vertical, 24)
+ .background(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .fill(Color.black.opacity(0.82))
+ .overlay(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.20), lineWidth: 1)
+ )
+ )
+ }
+
+}
+
+// MARK: - KeycapRow
+
+/// Renders a Tatami shortcut string as keycaps.
+///
+/// The spelling-to-glyph rules live in `KeyChordFormatter` because the burned
+/// `.ass` sidecar has to print the very same caps: when the two disagreed, a
+/// burned take showed a literal `ctrl + alt - l` sitting under this row's
+/// `⌃ ⌥ L`.
+private struct KeycapRow: View {
+
+ let chord: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ForEach(Array(KeyChordFormatter.caps(from: chord).enumerated()), id: \.offset) { _, cap in
+ Text(cap)
+ .font(.system(size: 32, weight: .medium, design: cap.count > 2 ? .rounded : .default))
+ .foregroundStyle(.white)
+ .frame(minWidth: 58, minHeight: 58)
+ .padding(.horizontal, cap.count > 2 ? 16 : 0)
+ .background(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(Color.black.opacity(0.82))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.30), lineWidth: 1.5)
+ )
+ )
+ }
+ }
+ }
+
+}
+
+// MARK: - Entry
+
+let application = NSApplication.shared
+let delegate = MainActor.assumeIsolated { OverlayApp() }
+application.delegate = delegate
+// Accessory, not regular: no Dock tile, no menu bar, nothing in the app
+// switcher. The overlay is scenery, not an app the demo is about.
+application.setActivationPolicy(.accessory)
+application.run()
diff --git a/DemoLab/Sources/DemoRecorder/main.swift b/DemoLab/Sources/DemoRecorder/main.swift
new file mode 100644
index 0000000..89d24d8
--- /dev/null
+++ b/DemoLab/Sources/DemoRecorder/main.swift
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoRecorderKit
+import Dispatch
+import Foundation
+
+// DemoRecorder — a 60 fps ScreenCaptureKit display recorder that writes a .mov.
+//
+// This file is deliberately only a shell. Both recording and display listing
+// finish on background tasks — ScreenCaptureKit delivers frames to a queue and
+// AVAssetWriter finalizes asynchronously — so the process parses its arguments,
+// hands off to DemoRecorderKit, and parks the main thread in `dispatchMain()`.
+// Every exit runs through the kit, which finalizes the file first: exiting
+// straight out of a signal handler leaves the moov atom unwritten and the .mov
+// unplayable.
+
+do {
+ switch try RecorderCommand.parse(Array(CommandLine.arguments.dropFirst())) {
+ case .help:
+ print(RecorderCommand.usage)
+ exit(RecorderExit.ok.rawValue)
+
+ case .preflight:
+ // How the VM bootstrap verifies the TCC grant without recording anything.
+ let granted = ScreenAccess.isGranted()
+ print("screen-recording-access: \(granted ? "granted" : "denied")")
+ guard granted else {
+ StandardError.write(ScreenAccess.deniedMessage)
+ exit(RecorderExit.noScreenRecordingAccess.rawValue)
+ }
+ exit(RecorderExit.ok.rawValue)
+
+ case .listDisplays:
+ DisplayCatalog.printCatalogAndExit()
+
+ case .record(let options):
+ RecorderController(options: options).run()
+ }
+} catch let error as UsageError {
+ StandardError.write("DemoRecorder: \(error.description)")
+ StandardError.write(RecorderCommand.usage)
+ exit(RecorderExit.usage.rawValue)
+} catch {
+ StandardError.write("DemoRecorder: \(StandardError.describe(error))")
+ exit(RecorderExit.captureFailure.rawValue)
+}
+
+dispatchMain()
diff --git a/DemoLab/Sources/DemoRecorderKit/CadenceAccounting.swift b/DemoLab/Sources/DemoRecorderKit/CadenceAccounting.swift
new file mode 100644
index 0000000..08fc617
--- /dev/null
+++ b/DemoLab/Sources/DemoRecorderKit/CadenceAccounting.swift
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+/// A presentation slot is reserved exactly once, even if encoding rejects it.
+/// Startup waits do not reserve slots because the movie clock has not started.
+struct CadenceAccounting: Sendable {
+ private(set) var nextFrameIndex = 0
+ private(set) var droppedFrameCount = 0
+
+ mutating func reserve(elapsed: Double, fps: Int) -> Int {
+ let target = max(nextFrameIndex, Int((elapsed * Double(fps)).rounded()))
+ droppedFrameCount += target - nextFrameIndex
+ nextFrameIndex = target + 1
+ return target
+ }
+
+ mutating func rejectReservedFrame() {
+ droppedFrameCount += 1
+ }
+}
diff --git a/DemoLab/Sources/DemoRecorderKit/CadenceWriter.swift b/DemoLab/Sources/DemoRecorderKit/CadenceWriter.swift
new file mode 100644
index 0000000..07cceae
--- /dev/null
+++ b/DemoLab/Sources/DemoRecorderKit/CadenceWriter.swift
@@ -0,0 +1,425 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AVFoundation
+import CoreMedia
+import CoreVideo
+import Foundation
+
+// MARK: - RecordingSummary
+
+/// What one finished file turned out to be.
+public struct RecordingSummary: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(
+ outputURL: URL,
+ width: Int,
+ height: Int,
+ codec: String,
+ frameCount: Int,
+ capturedFrameCount: Int,
+ droppedFrameCount: Int,
+ duration: Double
+ ) {
+ self.outputURL = outputURL
+ self.width = width
+ self.height = height
+ self.codec = codec
+ self.frameCount = frameCount
+ self.capturedFrameCount = capturedFrameCount
+ self.droppedFrameCount = droppedFrameCount
+ self.duration = duration
+ }
+
+ // MARK: Public
+
+ public let outputURL: URL
+ public let width: Int
+ public let height: Int
+ public let codec: String
+ /// Frames written to the file: cadence ticks, not capture callbacks.
+ public let frameCount: Int
+ /// Frames ScreenCaptureKit actually delivered. Lower than `frameCount` on a
+ /// still screen, which is the cadence doing its job.
+ public let capturedFrameCount: Int
+ public let droppedFrameCount: Int
+ public let duration: Double
+
+}
+
+// MARK: - CadenceWriter
+
+/// A fixed-rate encoder placed in front of an event-driven capture source.
+///
+/// This is the load-bearing decision of the recorder. ScreenCaptureKit only
+/// delivers a frame when something on screen changes, so appending capture
+/// buffers as they arrive turns a ten second take of a still desktop into a
+/// thirty millisecond file. This class inverts the relationship: a
+/// `DispatchSourceTimer` ticks at exactly `fps` Hz on its own serial queue and,
+/// on every tick, appends a *retimed copy* of the most recent capture buffer at
+/// `startPTS + n/fps`. A still screen therefore encodes as repeated frames and
+/// the file's duration matches the wall clock of the take.
+///
+/// The capture callback only swaps the latest-buffer slot under a lock and
+/// returns. Encoding on ScreenCaptureKit's own sample handler queue drains the
+/// IOSurface pool and stalls capture, so no work beyond the swap happens there.
+///
+/// The trade-off of holding the newest frame only: exactly one buffer out of the
+/// stream's `queueDepth` of 6 is retained at a time, and every frame produced
+/// between two ticks is discarded. That is deliberate — the cadence, not the
+/// capture source, defines the output frame rate, and holding more surfaces
+/// would starve the pool for no benefit.
+public final class CadenceWriter: @unchecked Sendable {
+
+ // MARK: Lifecycle
+
+ public init(
+ outputURL: URL,
+ width: Int,
+ height: Int,
+ fps: Int,
+ bitrateMbps: Double?,
+ label: String
+ ) throws {
+ self.outputURL = outputURL
+ self.width = width
+ self.height = height
+ self.fps = fps
+ frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))
+ cadenceQueue = DispatchQueue(label: "dev.pangmo5.demolab.recorder.cadence.\(label)", qos: .userInitiated)
+
+ try FileManager.default.createDirectory(
+ at: outputURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ if FileManager.default.fileExists(atPath: outputURL.path) {
+ try FileManager.default.removeItem(at: outputURL)
+ }
+
+ let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
+ writer.movieTimeScale = CMTimeScale(fps * 1000)
+
+ var chosenCodec = AVVideoCodecType.hevc
+ var settings = CadenceWriter.videoSettings(
+ codec: .hevc,
+ width: width,
+ height: height,
+ fps: fps,
+ bitrateMbps: bitrateMbps
+ )
+ if !writer.canApply(outputSettings: settings, forMediaType: .video) {
+ // A machine without a hardware HEVC encoder — a VM, typically — rejects
+ // these settings outright. Fall back, but say so: a silently different
+ // codec turns into a surprise three takes later.
+ StandardError.write("DemoRecorder: HEVC rejected for \(width)x\(height); falling back to H.264")
+ chosenCodec = .h264
+ settings = CadenceWriter.videoSettings(
+ codec: .h264,
+ width: width,
+ height: height,
+ fps: fps,
+ bitrateMbps: bitrateMbps
+ )
+ guard writer.canApply(outputSettings: settings, forMediaType: .video) else {
+ throw RecorderError.noApplicableEncoder(width: width, height: height)
+ }
+ }
+
+ let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
+ // Set before `startWriting()`: it tells the writer the source is live and
+ // must not be waited on. macOS 26 deprecates this in favour of a 26-only
+ // replacement; we target macOS 14, so the deprecation warning stays.
+ input.expectsMediaDataInRealTime = true
+ guard writer.canAdd(input) else {
+ throw RecorderError.writerSetupFailed("AVAssetWriter refused the video input for \(width)x\(height)")
+ }
+ writer.add(input)
+
+ self.writer = writer
+ self.input = input
+ codec = CadenceWriter.name(for: chosenCodec)
+ }
+
+ // MARK: Public
+
+ public let outputURL: URL
+ public let width: Int
+ public let height: Int
+ /// `hevc` or `h264`, whichever the writer accepted.
+ public let codec: String
+
+ /// Starts the writer and the cadence clock. Call once, after capture is live.
+ public func start() throws {
+ guard writer.startWriting() else {
+ throw RecorderError.writerSetupFailed(CadenceWriter.reason(writer.error))
+ }
+ // `.strict` keeps the timer out of dispatch's coalescing window; a leeway of
+ // zero is what makes the cadence a clock rather than a hint.
+ let timer = DispatchSource.makeTimerSource(flags: [.strict], queue: cadenceQueue)
+ timer.schedule(
+ deadline: .now(),
+ repeating: .nanoseconds(1_000_000_000 / fps),
+ leeway: .nanoseconds(0)
+ )
+ timer.setEventHandler { [weak self] in
+ self?.tick()
+ }
+ lock.lock()
+ cadenceTimer = timer
+ lock.unlock()
+ timer.resume()
+ }
+
+ /// Called on ScreenCaptureKit's sample handler queue. Stays O(1) by design.
+ public func submit(_ sampleBuffer: CMSampleBuffer) {
+ lock.lock()
+ latestBuffer = sampleBuffer
+ capturedFrameCount += 1
+ lock.unlock()
+ }
+
+ /// Startup succeeds only after the first encoded frame. The bounded wait
+ /// turns a stalled capture stream into an explicit failure before any action.
+ public var firstFrameUptime: UInt64? { lock.withLock { startUptime } }
+
+ public func awaitFirstFrame(timeout: Duration = .seconds(3)) async -> Bool {
+ let deadline = ContinuousClock.now.advanced(by: timeout)
+ while true {
+ if lock.withLock({ appendedFrameCount > 0 }) { return true }
+ guard ContinuousClock.now < deadline else { return false }
+ try? await Task.sleep(for: .milliseconds(10))
+ }
+ }
+
+ /// Step 1 of the shutdown sequence. Idempotent.
+ public func stopCadence() {
+ lock.lock()
+ let timer = cadenceTimer
+ cadenceTimer = nil
+ lock.unlock()
+ timer?.cancel()
+ // A tick may be mid-append; the barrier waits it out so nothing appends
+ // after `markAsFinished()`.
+ cadenceQueue.sync(flags: .barrier) { }
+ }
+
+ /// Steps 4 to 7 of the shutdown sequence: finish the input, end the session,
+ /// finalize the file, and verify the writer actually completed.
+ public func finish() async throws -> RecordingSummary {
+ stopCadence()
+
+ // `withLock`, not lock/unlock: `NSLock.lock()` is unavailable from an
+ // async context.
+ let (frames, captured, dropped, start, last) = lock.withLock {
+ isFinished = true
+ latestBuffer = nil
+ return (appendedFrameCount, capturedFrameCount, accounting.droppedFrameCount, startPTS, lastPresentationTime)
+ }
+
+ guard let start, frames > 0 else {
+ // Finalizing here would write a .mov with no video samples, which reads as
+ // a successful take until someone opens it. Fail loudly instead.
+ writer.cancelWriting()
+ throw RecorderError.noFramesCaptured(outputURL)
+ }
+
+ input.markAsFinished()
+ let end = CMTimeAdd(last, frameDuration)
+ writer.endSession(atSourceTime: end)
+ await finishWriting()
+
+ guard writer.status == .completed else {
+ throw RecorderError.writerFailed(CadenceWriter.reason(writer.error))
+ }
+ return RecordingSummary(
+ outputURL: outputURL,
+ width: width,
+ height: height,
+ codec: codec,
+ frameCount: frames,
+ capturedFrameCount: captured,
+ droppedFrameCount: dropped,
+ duration: CMTimeGetSeconds(CMTimeSubtract(end, start))
+ )
+ }
+
+ // MARK: Private
+
+ private let fps: Int
+ private let frameDuration: CMTime
+ private let writer: AVAssetWriter
+ private let input: AVAssetWriterInput
+ private let cadenceQueue: DispatchQueue
+
+ private let lock = NSLock()
+ /// The single retained capture buffer. Replaced, never consumed: a still
+ /// screen keeps re-encoding the last frame it produced.
+ private var latestBuffer: CMSampleBuffer?
+ private var cadenceTimer: DispatchSourceTimer?
+ private var startPTS: CMTime?
+ private var startUptime: UInt64?
+ private var lastPresentationTime = CMTime.zero
+ private var accounting = CadenceAccounting()
+ private var appendedFrameCount = 0
+ private var capturedFrameCount = 0
+ private var hasStartedSession = false
+ private var isFinished = false
+ private var hasFailed = false
+
+ private static func videoSettings(
+ codec: AVVideoCodecType,
+ width: Int,
+ height: Int,
+ fps: Int,
+ bitrateMbps: Double?
+ ) -> [String: Any] {
+ let bitrate = bitrateMbps.map { Int($0 * 1_000_000) }
+ ?? defaultBitrate(width: width, height: height, fps: fps, codec: codec)
+ var compression: [String: Any] = [
+ AVVideoAverageBitRateKey: bitrate,
+ AVVideoExpectedSourceFrameRateKey: fps,
+ AVVideoMaxKeyFrameIntervalKey: fps * 2,
+ // Screen content is encoded live and scrubbed frame by frame; B-frames buy
+ // nothing here and cost latency.
+ AVVideoAllowFrameReorderingKey: false,
+ ]
+ if codec == .h264 {
+ compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
+ }
+ return [
+ AVVideoCodecKey: codec,
+ AVVideoWidthKey: width,
+ AVVideoHeightKey: height,
+ AVVideoCompressionPropertiesKey: compression,
+ AVVideoColorPropertiesKey: [
+ AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
+ AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
+ AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
+ ],
+ ]
+ }
+
+ /// Bits per pixel per frame, clamped so a 5K panel does not ask for a
+ /// gigabit and a small window does not look like a fax.
+ private static func defaultBitrate(width: Int, height: Int, fps: Int, codec: AVVideoCodecType) -> Int {
+ let bitsPerPixel = codec == .hevc ? 0.09 : 0.16
+ let raw = Double(width * height) * Double(fps) * bitsPerPixel
+ return Int(min(max(raw, 8_000_000), 120_000_000))
+ }
+
+ /// The raw values are the fourcc codes `hvc1` and `avc1`; the log says what a
+ /// person would call them.
+ private static func name(for codec: AVVideoCodecType) -> String {
+ codec == .hevc ? "hevc" : "h264"
+ }
+
+ private static func reason(_ error: (any Error)?) -> String {
+ guard let error else { return "no error reported" }
+ return StandardError.describe(error)
+ }
+
+ /// `AVAssetWriter` still exposes the deprecated synchronous `finishWriting()`
+ /// next to the completion-handler form, so the bridge is spelled out rather
+ /// than left to overload resolution.
+ private func finishWriting() async {
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ writer.finishWriting {
+ continuation.resume()
+ }
+ }
+ }
+
+ private func tick() {
+ lock.lock()
+ let source = latestBuffer
+ let stopped = isFinished || hasFailed
+ lock.unlock()
+
+ // Before the first capture frame there is nothing to duplicate; the session
+ // starts at the first frame that actually exists.
+ guard !stopped, let source else { return }
+
+ let ready = input.isReadyForMoreMediaData
+ // Encoder warm-up precedes the first movie frame, so it is startup latency,
+ // not a hole in the delivered timeline.
+ guard ready || lock.withLock({ startUptime != nil }) else { return }
+
+ let now = DispatchTime.now().uptimeNanoseconds
+ lock.lock()
+ if startPTS == nil {
+ startPTS = source.presentationTimeStamp
+ startUptime = now
+ }
+ let start = startPTS ?? source.presentationTimeStamp
+ let startedAt = startUptime ?? now
+ // Missed ticks (a stalled encoder, a busy machine) advance the index by
+ // wall-clock time rather than by one, so the file's duration keeps matching
+ // the take instead of quietly shrinking.
+ let elapsed = Double(now &- startedAt) / 1_000_000_000
+ let target = accounting.reserve(elapsed: elapsed, fps: fps)
+ let needsSession = !hasStartedSession
+ lock.unlock()
+
+ guard ready else {
+ countDrop()
+ return
+ }
+
+ let presentationTime = CMTimeAdd(start, CMTime(value: CMTimeValue(target), timescale: CMTimeScale(fps)))
+ var timing = CMSampleTimingInfo(
+ duration: frameDuration,
+ presentationTimeStamp: presentationTime,
+ decodeTimeStamp: .invalid
+ )
+ var retimed: CMSampleBuffer?
+ let status = CMSampleBufferCreateCopyWithNewTiming(
+ allocator: kCFAllocatorDefault,
+ sampleBuffer: source,
+ sampleTimingEntryCount: 1,
+ sampleTimingArray: &timing,
+ sampleBufferOut: &retimed
+ )
+ guard status == noErr, let retimed else {
+ countDrop()
+ return
+ }
+
+ if needsSession {
+ writer.startSession(atSourceTime: presentationTime)
+ lock.lock()
+ hasStartedSession = true
+ lock.unlock()
+ }
+
+ guard input.append(retimed) else {
+ reportAppendFailure()
+ return
+ }
+
+ lock.lock()
+ appendedFrameCount += 1
+ lastPresentationTime = presentationTime
+ lock.unlock()
+ }
+
+ private func countDrop() {
+ lock.lock()
+ accounting.rejectReservedFrame()
+ lock.unlock()
+ }
+
+ /// Once an append fails the writer is `.failed` for good and every later
+ /// append is a silent no-op, so stop the clock and report it now.
+ private func reportAppendFailure() {
+ lock.lock()
+ let alreadyFailed = hasFailed
+ hasFailed = true
+ accounting.rejectReservedFrame()
+ lock.unlock()
+ guard !alreadyFailed else { return }
+ StandardError.write("DemoRecorder: append failed for \(outputURL.path): \(CadenceWriter.reason(writer.error))")
+ }
+
+}
diff --git a/DemoLab/Sources/DemoRecorderKit/DisplayRecorder.swift b/DemoLab/Sources/DemoRecorderKit/DisplayRecorder.swift
new file mode 100644
index 0000000..9a4c7b3
--- /dev/null
+++ b/DemoLab/Sources/DemoRecorderKit/DisplayRecorder.swift
@@ -0,0 +1,908 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import AVFoundation
+import CoreGraphics
+import CoreMedia
+import CoreVideo
+import Foundation
+import ScreenCaptureKit
+
+// MARK: - ScreenAccess
+
+/// The Screen Recording (TCC) gate.
+public enum ScreenAccess {
+
+ public static let deniedMessage = """
+ DemoRecorder: no Screen Recording access. Two different causes produce this, and they need
+ different fixes:
+
+ 1. The permission was never granted. Open System Settings > Privacy & Security >
+ Screen & System Audio Recording and enable the recorder.
+
+ 2. The permission belongs to another process. TCC attributes screen capture to the
+ *responsible* process, which for a binary started from a terminal, a script, or an IDE is
+ that launcher — Terminal, iTerm, Xcode — and not DemoRecorder. Either grant it to the
+ launcher, or launch the bundled DemoRecorder.app directly (`open -a DemoRecorder ...`) so
+ DemoRecorder is its own responsible process.
+
+ CGRequestScreenCaptureAccess() is deliberately not called: it returns false without showing any
+ prompt when the responsible process is the wrong one, and a grant made while this process runs
+ never reaches it. After changing the setting, relaunch DemoRecorder — and relaunch whatever
+ launched it too, when that is the process holding the grant.
+ """
+
+ public static func isGranted() -> Bool {
+ CGPreflightScreenCaptureAccess()
+ }
+
+}
+
+// MARK: - DisplayInfo
+
+/// One row of `--list-displays`.
+public struct DisplayInfo: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(
+ index: Int,
+ displayID: CGDirectDisplayID,
+ pointSize: CGSize,
+ pointPixelScale: CGFloat,
+ localizedName: String
+ ) {
+ self.index = index
+ self.displayID = displayID
+ self.pointSize = pointSize
+ self.pointPixelScale = pointPixelScale
+ self.localizedName = localizedName
+ }
+
+ // MARK: Public
+
+ /// 1-based, and what `--display ` expects.
+ public let index: Int
+ public let displayID: CGDirectDisplayID
+ public let pointSize: CGSize
+ public let pointPixelScale: CGFloat
+ public let localizedName: String
+
+}
+
+// MARK: - StartedDisplay
+
+/// What one live stream is capturing, reported once at start.
+public struct StartedDisplay: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(
+ index: Int,
+ displayID: CGDirectDisplayID,
+ pointSize: CGSize,
+ pointPixelScale: CGFloat,
+ pixelWidth: Int,
+ pixelHeight: Int,
+ codec: String,
+ outputURL: URL
+ ) {
+ self.index = index
+ self.displayID = displayID
+ self.pointSize = pointSize
+ self.pointPixelScale = pointPixelScale
+ self.pixelWidth = pixelWidth
+ self.pixelHeight = pixelHeight
+ self.codec = codec
+ self.outputURL = outputURL
+ }
+
+ // MARK: Public
+
+ public let index: Int
+ public let displayID: CGDirectDisplayID
+ public let pointSize: CGSize
+ public let pointPixelScale: CGFloat
+ public let pixelWidth: Int
+ public let pixelHeight: Int
+ public let codec: String
+ public let outputURL: URL
+
+}
+
+// MARK: - DisplayCatalog
+
+/// Display discovery and selection.
+public enum DisplayCatalog {
+
+ /// `SCShareableContent.current` enumerates offscreen windows as well and is
+ /// measurably slower; a display filter never needs them.
+ public static func shareableContent() async throws -> SCShareableContent {
+ try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
+ }
+
+ public static func load() async throws -> [DisplayInfo] {
+ let content = try await shareableContent()
+ let names = await localizedNames()
+ return content.displays.enumerated().map { offset, display in
+ let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
+ return DisplayInfo(
+ index: offset + 1,
+ displayID: display.displayID,
+ pointSize: CGSize(width: display.width, height: display.height),
+ pointPixelScale: CGFloat(filter.pointPixelScale),
+ localizedName: names[display.displayID] ?? "Display \(display.displayID)"
+ )
+ }
+ }
+
+ /// Returns the selected displays paired with their 1-based catalog index, so
+ /// logs, `--display `, and the `-1`/`-2` file suffixes all agree.
+ public static func select(
+ _ selection: DisplaySelection,
+ from displays: [SCDisplay]
+ ) throws -> [(index: Int, display: SCDisplay)] {
+ guard !displays.isEmpty else {
+ throw RecorderError.noDisplaysAvailable
+ }
+ switch selection {
+ case .all:
+ return displays.enumerated().map { (index: $0.offset + 1, display: $0.element) }
+ case .main:
+ let mainID = CGMainDisplayID()
+ guard let offset = displays.firstIndex(where: { $0.displayID == mainID }) else {
+ throw RecorderError.mainDisplayUnavailable
+ }
+ return [(index: offset + 1, display: displays[offset])]
+ case .index(let index):
+ guard index >= 1, index <= displays.count else {
+ throw RecorderError.displayIndexOutOfRange(index: index, count: displays.count)
+ }
+ return [(index: index, display: displays[index - 1])]
+
+ case .identifier(let displayID):
+ guard let offset = displays.firstIndex(where: { $0.displayID == displayID }) else {
+ throw RecorderError.displayIdentifierUnavailable(displayID: displayID)
+ }
+ return [(index: offset + 1, display: displays[offset])]
+ }
+ }
+
+ /// One file per display. There is no ScreenCaptureKit API that captures more
+ /// than one display in a single stream, and a filter rect spanning two
+ /// displays returns empty frames, so `--display all` fans out into independent
+ /// takes that are composited afterwards.
+ public static func outputURLs(base: URL, indices: [Int], suffixed: Bool) -> [URL] {
+ guard suffixed else { return [base] }
+ let directory = base.deletingLastPathComponent()
+ let ext = base.pathExtension.isEmpty ? "mov" : base.pathExtension
+ let stem = base.deletingPathExtension().lastPathComponent
+ return indices.map { index in
+ directory.appendingPathComponent("\(stem)-\(index)").appendingPathExtension(ext)
+ }
+ }
+
+ public static func formatted(_ displays: [DisplayInfo]) -> String {
+ var lines = ["index displayID points scale name"]
+ for display in displays {
+ let index = String(display.index).padding(toLength: 5, withPad: " ", startingAt: 0)
+ let identifier = String(display.displayID).padding(toLength: 9, withPad: " ", startingAt: 0)
+ let points = "\(Int(display.pointSize.width)) x \(Int(display.pointSize.height))"
+ .padding(toLength: 12, withPad: " ", startingAt: 0)
+ let scale = String(format: "%.1fx", Double(display.pointPixelScale))
+ .padding(toLength: 5, withPad: " ", startingAt: 0)
+ lines.append("\(index) \(identifier) \(points) \(scale) \(display.localizedName)")
+ }
+ return lines.joined(separator: "\n")
+ }
+
+ /// Prints the catalog from a detached task and exits. The process is a CLI
+ /// with a live main queue, so it never returns here.
+ public static func printCatalogAndExit() {
+ Task.detached {
+ guard ScreenAccess.isGranted() else {
+ StandardError.write(ScreenAccess.deniedMessage)
+ exit(RecorderExit.noScreenRecordingAccess.rawValue)
+ }
+ do {
+ let displays = try await load()
+ print(formatted(displays))
+ fflush(stdout)
+ exit(RecorderExit.ok.rawValue)
+ } catch {
+ StandardError.write("DemoRecorder: display discovery failed: \(StandardError.describe(error))")
+ exit(RecorderExit.captureFailure.rawValue)
+ }
+ }
+ }
+
+ // MARK: Private
+
+ /// `SCDisplay` has no name; `NSScreen` does. AppKit wants the main thread for
+ /// it, so the lookup hops there and comes back with a plain dictionary.
+ private static func localizedNames() async -> [CGDirectDisplayID: String] {
+ await MainActor.run {
+ var names = [CGDirectDisplayID: String]()
+ for screen in NSScreen.screens {
+ guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
+ else { continue }
+ names[CGDirectDisplayID(number.uint32Value)] = screen.localizedName
+ }
+ return names
+ }
+ }
+
+}
+
+// MARK: - DisplayRecorder
+
+/// One display, one ScreenCaptureKit stream, one `.mov`.
+///
+/// The stream output callback does nothing but hand the newest buffer to the
+/// ``CadenceWriter``; all encoding happens on the cadence queue.
+public final class DisplayRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
+
+ // MARK: Lifecycle
+
+ public init(
+ display: SCDisplay,
+ index: Int,
+ outputURL: URL,
+ options: RecorderOptions,
+ excludedApplications: [SCRunningApplication],
+ onFailure: @escaping @Sendable (any Error) -> Void
+ ) {
+ self.index = index
+ self.outputURL = outputURL
+ self.options = options
+ self.onFailure = onFailure
+ displayID = display.displayID
+ pointSize = CGSize(width: display.width, height: display.height)
+
+ // Excluding by pid rather than by bundle identifier: a SwiftPM executable
+ // has no bundle id at all, and the pid is what actually identifies us.
+ let filter = SCContentFilter(
+ display: display,
+ excludingApplications: excludedApplications,
+ exceptingWindows: []
+ )
+ let scale = CGFloat(filter.pointPixelScale)
+ let rect = filter.contentRect
+ // Backing pixels, never `SCDisplay.width * 2`: the scale is a property of the
+ // filter, and encoders reject odd dimensions.
+ let width = DisplayRecorder.evenFloor(Int(rect.width * scale))
+ let height = DisplayRecorder.evenFloor(Int(rect.height * scale))
+
+ self.filter = filter
+ pointPixelScale = scale
+ pixelWidth = width
+ pixelHeight = height
+ configuration = DisplayRecorder.makeConfiguration(width: width, height: height, options: options)
+ captureQueue = DispatchQueue(
+ label: "dev.pangmo5.demolab.recorder.capture.display-\(index)",
+ qos: .userInitiated
+ )
+ super.init()
+ }
+
+ // MARK: Public
+
+ public let index: Int
+ public let outputURL: URL
+ public let displayID: CGDirectDisplayID
+ public let pointSize: CGSize
+ public let pointPixelScale: CGFloat
+ public let pixelWidth: Int
+ public let pixelHeight: Int
+
+ public func start() async throws -> StartedDisplay {
+ let writer = try CadenceWriter(
+ outputURL: outputURL,
+ width: pixelWidth,
+ height: pixelHeight,
+ fps: options.fps,
+ bitrateMbps: options.bitrateMbps,
+ label: "display-\(index)"
+ )
+ let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: captureQueue)
+
+ lock.withLock {
+ self.writer = writer
+ self.stream = stream
+ }
+
+ try await stream.startCapture()
+ do {
+ // The cadence starts only once frames can actually arrive; buffers
+ // delivered in between simply land in the latest-buffer slot.
+ try writer.start()
+ } catch {
+ try? await stream.stopCapture()
+ throw error
+ }
+ return StartedDisplay(
+ index: index,
+ displayID: displayID,
+ pointSize: pointSize,
+ pointPixelScale: pointPixelScale,
+ pixelWidth: pixelWidth,
+ pixelHeight: pixelHeight,
+ codec: writer.codec,
+ outputURL: outputURL
+ )
+ }
+
+ /// Resolves once this stream has produced a frame. `false` means the budget
+ /// ran out with the stream live but silent, which is a note rather than a
+ /// failure: the caller must not begin a scene without an encoded frame.
+ public var firstFrameUptime: UInt64? { lock.withLock { writer?.firstFrameUptime } }
+
+ public func awaitFirstFrame() async -> Bool {
+ // `withLock`, not lock/unlock: `NSLock.lock()` is unavailable from an async
+ // context.
+ guard let target = lock.withLock({ writer }) else { return false }
+ return await target.awaitFirstFrame()
+ }
+
+ /// The shutdown order is load-bearing: stopping the cadence first, capture
+ /// second, and only then finalizing keeps the moov atom consistent with the
+ /// samples that were actually written.
+ public func stop() async throws -> RecordingSummary {
+ let (stream, writer) = lock.withLock { () -> (SCStream?, CadenceWriter?) in
+ let live = (self.stream, self.writer)
+ self.stream = nil
+ return live
+ }
+
+ guard let writer else {
+ throw RecorderError.noFramesCaptured(outputURL)
+ }
+ writer.stopCadence()
+
+ if let stream {
+ do {
+ try await stream.stopCapture()
+ } catch {
+ // Keep going: the file still has to be finalized, and a stream that is
+ // already gone is exactly when that matters most.
+ StandardError.write("DemoRecorder: stopCapture failed on display \(index): \(StandardError.describe(error))")
+ }
+ }
+ // Let any in-flight sample handler finish before the writer is torn down.
+ captureQueue.sync(flags: .barrier) { }
+ return try await writer.finish()
+ }
+
+ public func stream(_: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
+ guard DisplayRecorder.isDeliverable(sampleBuffer, of: type) else { return }
+ lock.lock()
+ let target = writer
+ lock.unlock()
+ target?.submit(sampleBuffer)
+ }
+
+ public func stream(_: SCStream, didStopWithError error: any Error) {
+ StandardError.write("DemoRecorder: stream for display \(index) stopped: \(StandardError.describe(error))")
+ onFailure(error)
+ }
+
+ // MARK: Private
+
+ private let options: RecorderOptions
+ private let filter: SCContentFilter
+ private let configuration: SCStreamConfiguration
+ private let captureQueue: DispatchQueue
+ private let onFailure: @Sendable (any Error) -> Void
+
+ private let lock = NSLock()
+ private var stream: SCStream?
+ private var writer: CadenceWriter?
+
+ private static func makeConfiguration(width: Int, height: Int, options: RecorderOptions) -> SCStreamConfiguration {
+ let configuration = SCStreamConfiguration()
+ // Leaving these unset would silently rescale the capture to the header
+ // default of 1920x1080.
+ configuration.width = width
+ configuration.height = height
+ // An upper bound on delivery, not a floor: ScreenCaptureKit still sends
+ // nothing while the screen is still. The cadence clock supplies the floor.
+ configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(options.fps))
+ configuration.queueDepth = 6
+ configuration.pixelFormat = kCVPixelFormatType_32BGRA
+ configuration.colorSpaceName = CGColorSpace.sRGB
+ configuration.showsCursor = options.showsCursor
+ configuration.capturesAudio = false
+ configuration.captureResolution = .best
+ configuration.scalesToFit = false
+ return configuration
+ }
+
+ /// Every one of these has to hold. An `.idle` buffer carries no samples and no
+ /// image buffer; appending it puts `AVAssetWriter` into `.failed`, after which
+ /// every later frame is silently lost and the file is unplayable.
+ private static func isDeliverable(_ sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) -> Bool {
+ guard type == .screen, sampleBuffer.isValid else { return false }
+ guard
+ let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false)
+ as? [[SCStreamFrameInfo: Any]],
+ let info = attachments.first,
+ let rawStatus = info[.status] as? Int,
+ let status = SCFrameStatus(rawValue: rawStatus)
+ else { return false }
+ guard CMSampleBufferGetNumSamples(sampleBuffer) > 0, sampleBuffer.imageBuffer != nil else { return false }
+ // `.started` carries the first frame of the stream and, with an image buffer
+ // present, is as usable as `.complete`.
+ return status == .complete || status == .started
+ }
+
+ private static func evenFloor(_ value: Int) -> Int {
+ max(2, value - (value % 2))
+ }
+
+}
+
+// MARK: - StopResult
+
+/// The outcome of stopping every stream in a session. Failures never hide a
+/// file that did finalize, and a file that did not finalize never reads as
+/// success.
+public struct StopResult: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(summaries: [RecordingSummary], failures: [String]) {
+ self.summaries = summaries
+ self.failures = failures
+ }
+
+ // MARK: Public
+
+ public let summaries: [RecordingSummary]
+ public let failures: [String]
+
+}
+
+// MARK: - RecordingSession
+
+/// One take: one stream and one writer per selected display.
+public final class RecordingSession: @unchecked Sendable {
+
+ // MARK: Lifecycle
+
+ public init(options: RecorderOptions) {
+ self.options = options
+ }
+
+ // MARK: Public
+
+ /// `onFailure` fires when a stream stops on its own — a display disconnecting,
+ /// the window server revoking capture — so the take ends loudly instead of
+ /// recording a frozen frame.
+ public var firstFrameUptime: UInt64? {
+ lock.withLock { recorders.compactMap(\.firstFrameUptime).min() }
+ }
+
+ public var outputOffsets: [String: Double] {
+ lock.withLock {
+ guard let origin = recorders.compactMap(\.firstFrameUptime).min() else {return [:]}
+ return Dictionary(uniqueKeysWithValues: recorders.compactMap { recorder in
+ guard let uptime = recorder.firstFrameUptime else {return nil}
+ return (recorder.outputURL.lastPathComponent, Double(uptime-origin)/1_000_000_000)
+ })
+ }
+ }
+
+ public func start(onFailure: @escaping @Sendable (any Error) -> Void) async throws -> [StartedDisplay] {
+ guard ScreenAccess.isGranted() else {
+ throw RecorderError.noScreenRecordingAccess
+ }
+ let content = try await DisplayCatalog.shareableContent()
+ let selected = try DisplayCatalog.select(options.display, from: content.displays)
+ let ownProcess = content.applications.filter { $0.processID == getpid() }
+ let urls = DisplayCatalog.outputURLs(
+ base: options.output,
+ indices: selected.map(\.index),
+ suffixed: options.display == .all
+ )
+
+ var started: [StartedDisplay] = []
+ var live: [DisplayRecorder] = []
+ for (offset, selection) in selected.enumerated() {
+ let recorder = DisplayRecorder(
+ display: selection.display,
+ index: selection.index,
+ outputURL: urls[offset],
+ options: options,
+ excludedApplications: ownProcess,
+ onFailure: onFailure
+ )
+ started.append(try await recorder.start())
+ live.append(recorder)
+ lock.withLock { recorders.append(recorder) }
+ }
+
+ // `SCStream.startCapture()` returns before the window server has delivered
+ // anything, and the caller announces "capture started" the moment this
+ // returns. Holding that announcement until a frame exists is what keeps a
+ // take from beginning in the gap and losing its opening seconds.
+ for recorder in live {
+ let delivered = await recorder.awaitFirstFrame()
+ guard !delivered else { continue }
+ throw RecorderError.noFramesCaptured(recorder.outputURL)
+ }
+ return started
+ }
+
+ public func stop() async -> StopResult {
+ let recorders = lock.withLock { () -> [DisplayRecorder] in
+ let started = self.recorders
+ self.recorders = []
+ return started
+ }
+
+ var summaries: [RecordingSummary] = []
+ var failures: [String] = []
+ for recorder in recorders {
+ do {
+ summaries.append(try await recorder.stop())
+ } catch {
+ failures.append("display \(recorder.index): \(StandardError.describe(error))")
+ }
+ }
+ return StopResult(summaries: summaries, failures: failures)
+ }
+
+ // MARK: Private
+
+ private let options: RecorderOptions
+ private let lock = NSLock()
+ private var recorders: [DisplayRecorder] = []
+
+}
+
+// MARK: - RecorderController
+
+/// The process shape around a take.
+///
+/// A CLI, but not a straight-line one: the capture delegate and the writer both
+/// need a live run loop, so `main.swift` hands control to `dispatchMain()` and
+/// every path out of the program goes through here. SIGINT and SIGTERM are
+/// handled as dispatch sources rather than left at their default disposition —
+/// exiting straight from a signal leaves the moov atom unwritten and the `.mov`
+/// unplayable.
+public final class RecorderController: @unchecked Sendable {
+
+ // MARK: Lifecycle
+
+ public init(options: RecorderOptions) {
+ self.options = options
+ session = RecordingSession(options: options)
+ }
+
+ // MARK: Public
+
+ /// Installs the signal handling, starts the streams, and returns. The caller
+ /// then parks the main thread in `dispatchMain()`.
+ public func run() {
+ // `dispatchMain()` never returns, so no caller's stack frame can own this
+ // object. Without the anchor the controller — and with it the streams, the
+ // writer, and the signal sources — would be released the moment `run()`
+ // returned.
+ RecorderController.liveLock.lock()
+ RecorderController.live = self
+ RecorderController.liveLock.unlock()
+ installSignalSources()
+ Task.detached { [self] in
+ await startRecording()
+ }
+ }
+
+ // MARK: Private
+
+ private nonisolated(unsafe) static var live: RecorderController?
+ private static let liveLock = NSLock()
+
+ private let options: RecorderOptions
+ private let controlQueue = DispatchQueue(label: "dev.pangmo5.demolab.recorder.control")
+ private let lock = NSLock()
+
+ private let session: RecordingSession
+
+ private var signalSources: [DispatchSourceSignal] = []
+ private var durationTimer: DispatchSourceTimer?
+ private var isRunning = false
+ private var stopRequested = false
+ private var isStopping = false
+ private var pidfileWritten: URL?
+
+ private var recordedOutputs: [String] = []
+ private var recordedFailures: [String] = []
+ private var recordedStopReason: String?
+ private var recordedStatistics: [String: [String: Int]] = [:]
+ private var recordedFrameCount = 0
+ private var recordedDroppedCount = 0
+ private var recordedDuration = 0.0
+
+ /// The only channel back to the caller. `democtl` launches the recorder
+ /// detached through LaunchServices, so neither the exit code nor stderr ever
+ /// reaches it; without this file a failed take is indistinguishable from a
+ /// good one.
+ private var resultFile: URL? {
+ options.pidfile.map { $0.deletingPathExtension().appendingPathExtension("json") }
+ }
+
+ private func startRecording() async {
+ // A result from an earlier run must never be mistaken for this one's: if
+ // this process is killed before it writes its own, the caller has to find
+ // nothing rather than somebody else's success.
+ removeStaleResult()
+ guard ScreenAccess.isGranted() else {
+ StandardError.write(ScreenAccess.deniedMessage)
+ noteFailure("no Screen Recording access")
+ exitNow(.noScreenRecordingAccess)
+ }
+ do {
+ let started = try await session.start(onFailure: { [weak self] error in
+ self?.requestStop(reason: "stream failure: \(StandardError.describe(error))")
+ })
+ for display in started {
+ let points = "\(Int(display.pointSize.width))x\(Int(display.pointSize.height)) pt"
+ let pixels = "\(display.pixelWidth)x\(display.pixelHeight) px"
+ let scale = String(format: "%.1fx", Double(display.pointPixelScale))
+ print("recording display \(display.index) (id \(display.displayID), \(points) @\(scale)) "
+ + "-> \(pixels) \(display.codec) -> \(display.outputURL.path)")
+ }
+ if started.count > 1 {
+ print("note: ScreenCaptureKit has no multi-display stream, so each display is its own file;")
+ print(" composite afterwards, e.g. ffmpeg -i a.mov -i b.mov -filter_complex hstack out.mov")
+ }
+ // There is a file to protect from here on, so a repeat stop has to be
+ // absorbed rather than taken as the way out of a hung startup.
+ lock.withLock { isRunning = true }
+ // Load-bearing placement. `democtl` waits on this file as its "capture
+ // really started" barrier, so it may only appear once every stream is live
+ // and has produced a frame. Written any earlier — as it used to be — that
+ // wait unblocks while ScreenCaptureKit is still negotiating, and the take
+ // begins before the recording does.
+ if let pidfile = options.pidfile, let uptime = session.firstFrameUptime {
+ let ready = pidfile.deletingPathExtension().appendingPathExtension("ready.json")
+ try JSONSerialization.data(withJSONObject: [
+ "firstFrameUptimeNanoseconds": uptime, "outputOffsets": session.outputOffsets,
+ "displays": Dictionary(uniqueKeysWithValues: started.map { display in
+ (display.outputURL.lastPathComponent, ["id": Double(display.displayID), "x": CGDisplayBounds(display.displayID).minX, "y": CGDisplayBounds(display.displayID).minY])
+ }),
+ ])
+ .write(to: ready, options: .atomic)
+ }
+ writePidfile()
+ fflush(stdout)
+ } catch {
+ StandardError.write("DemoRecorder: \(StandardError.describe(error))")
+ noteFailure(StandardError.describe(error))
+ let failed = await session.stop()
+ report(failed)
+ exitNow(isAccessError(error) ? .noScreenRecordingAccess : .captureFailure)
+ }
+
+ if let seconds = options.durationSeconds {
+ scheduleDurationStop(seconds)
+ }
+ // A stop that landed while the streams were coming up. `stopAndExit` is
+ // idempotent, so racing the detached one `requestStop` may already have
+ // spawned is harmless.
+ let stopAlreadyRequested = lock.withLock { stopRequested }
+ if stopAlreadyRequested {
+ await stopAndExit(reason: "stop requested during startup")
+ }
+ }
+
+ private func isAccessError(_ error: any Error) -> Bool {
+ guard let recorderError = error as? RecorderError else { return false }
+ if case .noScreenRecordingAccess = recorderError { return true }
+ return false
+ }
+
+ private func installSignalSources() {
+ var sources: [DispatchSourceSignal] = []
+ for number in [SIGINT, SIGTERM] {
+ // The dispatch source only sees the signal if the default disposition,
+ // which would kill us mid-file, is disarmed first.
+ signal(number, SIG_IGN)
+ let source = DispatchSource.makeSignalSource(signal: number, queue: controlQueue)
+ source.setEventHandler { [weak self] in
+ self?.requestStop(reason: number == SIGINT ? "SIGINT" : "SIGTERM")
+ }
+ source.resume()
+ sources.append(source)
+ }
+ lock.lock()
+ signalSources = sources
+ lock.unlock()
+ }
+
+ private func scheduleDurationStop(_ seconds: Double) {
+ let timer = DispatchSource.makeTimerSource(queue: controlQueue)
+ timer.schedule(deadline: .now() + seconds, leeway: .milliseconds(2))
+ timer.setEventHandler { [weak self] in
+ self?.requestStop(reason: String(format: "--duration-seconds %.3f elapsed", seconds))
+ }
+ lock.lock()
+ durationTimer = timer
+ lock.unlock()
+ timer.resume()
+ }
+
+ /// Every stop funnels here, and only the first one wins: a second SIGINT
+ /// while the file is being finalized must not tear it in half.
+ private func requestStop(reason: String) {
+ lock.lock()
+ let repeated = stopRequested
+ let running = isRunning
+ stopRequested = true
+ lock.unlock()
+
+ if repeated {
+ // A repeat while a file is being finalized is ignored on purpose: a torn
+ // moov atom is worse than a slow exit. Before capture is live there is no
+ // file to protect, so a second signal is the way out of a hung startup.
+ guard !running else { return }
+ StandardError.write("DemoRecorder: \(reason) during startup; exiting without a file")
+ noteFailure("\(reason) during startup; no file was written")
+ noteStopReason(reason)
+ exitNow(.captureFailure)
+ }
+ // Not running yet: `startRecording()` picks the request up as soon as the
+ // streams are live, so a half-built session is never torn down.
+ guard running else { return }
+ Task.detached { [self] in
+ await stopAndExit(reason: reason)
+ }
+ }
+
+ private func stopAndExit(reason: String) async {
+ let alreadyStopping = lock.withLock { () -> Bool in
+ let stopping = isStopping
+ isStopping = true
+ return stopping
+ }
+ guard !alreadyStopping else { return }
+
+ print("stopping: \(reason)")
+ noteStopReason(reason)
+ let result = await session.stop()
+ report(result)
+ exitNow(result.failures.isEmpty && !result.summaries.isEmpty ? .ok : .captureFailure)
+ }
+
+ private func report(_ result: StopResult) {
+ lock.lock()
+ // The aggregate across every file of the take: `--display all` fans out into
+ // one file per display, and the longest of them is the take's duration.
+ recordedOutputs = result.summaries.map(\.outputURL.path)
+ recordedStatistics = Dictionary(uniqueKeysWithValues: result.summaries.map {
+ ($0.outputURL.lastPathComponent, ["frames": $0.frameCount, "dropped": $0.droppedFrameCount, "width": $0.width, "height": $0.height])
+ })
+ recordedFailures.append(contentsOf: result.failures)
+ recordedFrameCount = result.summaries.reduce(0) { $0 + $1.frameCount }
+ recordedDroppedCount = result.summaries.reduce(0) { $0 + $1.droppedFrameCount }
+ recordedDuration = result.summaries.map(\.duration).max() ?? 0
+ lock.unlock()
+
+ for summary in result.summaries {
+ print("wrote \(summary.outputURL.path)")
+ print(" \(summary.width)x\(summary.height) \(summary.codec) "
+ + String(format: "duration %.3f s", summary.duration)
+ + " frames \(summary.frameCount) captured \(summary.capturedFrameCount)"
+ + " dropped \(summary.droppedFrameCount)")
+ }
+ if result.summaries.count > 1 {
+ print("note: one file per display by design; composite afterwards, e.g. ffmpeg hstack")
+ }
+ for failure in result.failures {
+ StandardError.write("DemoRecorder: \(failure)")
+ }
+ fflush(stdout)
+ }
+
+ private func writePidfile() {
+ guard let pidfile = options.pidfile else {
+ StandardError.write("DemoRecorder: no --pidfile given and DEMOLAB_ROOT is unset; "
+ + "`democtl record stop` cannot signal this process")
+ return
+ }
+ do {
+ try FileManager.default.createDirectory(
+ at: pidfile.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try Data("\(getpid())\n".utf8).write(to: pidfile, options: .atomic)
+ lock.lock()
+ pidfileWritten = pidfile
+ lock.unlock()
+ } catch {
+ StandardError.write("DemoRecorder: could not write \(pidfile.path): \(StandardError.describe(error))")
+ // Without the pidfile the caller can neither see this process nor signal
+ // it, so carrying on would leave an orphan recording forever while
+ // `democtl` times out waiting for a take that already started.
+ noteFailure("pidfile unwritable; the caller could never stop this take")
+ requestStop(reason: "pidfile unwritable")
+ }
+ }
+
+ private func removePidfile() {
+ lock.lock()
+ let pidfile = pidfileWritten
+ pidfileWritten = nil
+ lock.unlock()
+ guard let pidfile else { return }
+ try? FileManager.default.removeItem(at: pidfile)
+ }
+
+ private func noteFailure(_ reason: String) {
+ lock.lock()
+ recordedFailures.append(reason)
+ lock.unlock()
+ }
+
+ /// Only the first stop is the real one; later ones are shutdown noise.
+ private func noteStopReason(_ reason: String) {
+ lock.lock()
+ if recordedStopReason == nil { recordedStopReason = reason }
+ lock.unlock()
+ }
+
+ private func removeStaleResult() {
+ guard let resultFile else { return }
+ try? FileManager.default.removeItem(at: resultFile)
+ }
+
+ /// The take's outcome, for a caller that can read nothing else this process
+ /// says. `status` is stricter than the exit code on purpose: a run that
+ /// finalized no file, or that hit any failure at all, is not a usable take
+ /// however it happened to exit.
+ private func writeResult(_ code: RecorderExit) {
+ guard let resultFile else { return }
+ let (outputs, failures, stopReason, frames, dropped, duration) = lock.withLock {
+ (recordedOutputs, recordedFailures, recordedStopReason, recordedFrameCount, recordedDroppedCount, recordedDuration)
+ }
+ let succeeded = code == .ok && failures.isEmpty && !outputs.isEmpty
+ var payload: [String: Any] = [
+ "status": succeeded ? "ok" : "failed",
+ "code": Int(code.rawValue),
+ "outputs": outputs,
+ "error": NSNull(),
+ "stopReason": NSNull(),
+ "outputStatistics": lock.withLock {recordedStatistics},
+ "frames": frames,
+ "dropped": dropped,
+ "durationSeconds": duration,
+ ]
+ if !failures.isEmpty { payload["error"] = failures.joined(separator: "; ") }
+ if let stopReason { payload["stopReason"] = stopReason }
+ do {
+ let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
+ try FileManager.default.createDirectory(
+ at: resultFile.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ // `.atomic` is a write to a sibling temp file plus a rename, so a caller
+ // racing this exit reads the whole file or no file, never half of one.
+ try data.write(to: resultFile, options: .atomic)
+ } catch {
+ StandardError.write("DemoRecorder: could not write \(resultFile.path): \(StandardError.describe(error))")
+ }
+ }
+
+ private func exitNow(_ code: RecorderExit) -> Never {
+ // Order is the contract: `democtl` treats the pidfile disappearing as "the
+ // take is over" and reads the result straight afterwards, so the result has
+ // to be on disk before the pidfile goes.
+ writeResult(code)
+ removePidfile()
+ fflush(stdout)
+ exit(code.rawValue)
+ }
+
+}
diff --git a/DemoLab/Sources/DemoRecorderKit/RecorderOptions.swift b/DemoLab/Sources/DemoRecorderKit/RecorderOptions.swift
new file mode 100644
index 0000000..b07ac6a
--- /dev/null
+++ b/DemoLab/Sources/DemoRecorderKit/RecorderOptions.swift
@@ -0,0 +1,303 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import CoreGraphics
+import Foundation
+
+// MARK: - RecorderExit
+
+/// Process exit codes. `democtl` and the VM bootstrap branch on these, so they
+/// are part of the tool's contract and must not be renumbered.
+public enum RecorderExit: Int32, Sendable {
+ case ok = 0
+ case usage = 2
+ case noScreenRecordingAccess = 3
+ case captureFailure = 4
+}
+
+// MARK: - DisplaySelection
+
+/// Which display(s) a take covers.
+public enum DisplaySelection: Sendable, Equatable {
+ case main
+ /// A specific `CGDirectDisplayID`. The unambiguous form, and the one
+ /// `democtl` always sends: a positional index depends on which enumeration
+ /// you are counting, and the recorder's ScreenCaptureKit order is not the
+ /// NSScreen order `democtl displays` prints.
+ case identifier(CGDirectDisplayID)
+ /// 1-based, matching `--list-displays` and the `-1`, `-2` output suffixes.
+ case index(Int)
+ case all
+}
+
+// MARK: - RecorderOptions
+
+/// Everything a recording run needs. Parsed once, then immutable.
+public struct RecorderOptions: Sendable {
+
+ // MARK: Lifecycle
+
+ public init(
+ output: URL,
+ display: DisplaySelection = .main,
+ fps: Int = 60,
+ durationSeconds: Double? = nil,
+ bitrateMbps: Double? = nil,
+ showsCursor: Bool = true,
+ pidfile: URL? = nil
+ ) {
+ self.output = output
+ self.display = display
+ self.fps = fps
+ self.durationSeconds = durationSeconds
+ self.bitrateMbps = bitrateMbps
+ self.showsCursor = showsCursor
+ self.pidfile = pidfile
+ }
+
+ // MARK: Public
+
+ /// `democtl` passes `--pidfile` explicitly. This fallback keeps a hand-run
+ /// recorder signalable and mirrors `LabPaths.recorderPidFile`.
+ public static func defaultPidfile() -> URL? {
+ guard let root = ProcessInfo.processInfo.environment["DEMOLAB_ROOT"], !root.isEmpty else {
+ return nil
+ }
+ return URL(fileURLWithPath: root, isDirectory: true)
+ .appendingPathComponent(".build/lab/run/recorder.pid")
+ }
+
+ public let output: URL
+ public let display: DisplaySelection
+ /// Drives the cadence clock, the capture ceiling, and the encoder hints alike.
+ public let fps: Int
+ /// `nil` means "run until SIGINT/SIGTERM".
+ public let durationSeconds: Double?
+ /// `nil` means "derive from pixel count".
+ public let bitrateMbps: Double?
+ public let showsCursor: Bool
+ public let pidfile: URL?
+
+}
+
+// MARK: - UsageError
+
+public struct UsageError: Error, CustomStringConvertible, Sendable {
+
+ // MARK: Lifecycle
+
+ public init(_ description: String) {
+ self.description = description
+ }
+
+ // MARK: Public
+
+ public let description: String
+
+}
+
+// MARK: - RecorderError
+
+public enum RecorderError: Error, CustomStringConvertible, Sendable {
+ case noScreenRecordingAccess
+ case noDisplaysAvailable
+ case mainDisplayUnavailable
+ case displayIndexOutOfRange(index: Int, count: Int)
+ case displayIdentifierUnavailable(displayID: CGDirectDisplayID)
+ case noApplicableEncoder(width: Int, height: Int)
+ case writerSetupFailed(String)
+ case writerFailed(String)
+ case noFramesCaptured(URL)
+
+ // MARK: Public
+
+ public var description: String {
+ switch self {
+ case .noScreenRecordingAccess:
+ "no Screen Recording access"
+ case .noDisplaysAvailable:
+ "ScreenCaptureKit reported no capturable displays"
+ case .mainDisplayUnavailable:
+ "the main display is not in ScreenCaptureKit's display list"
+ case .displayIdentifierUnavailable(let displayID):
+ "no shareable display with id \(displayID); run --list-displays to see what is attached"
+ case .displayIndexOutOfRange(let index, let count):
+ "--display \(index) is out of range; \(count) display(s) are capturable (indices are 1-based)"
+ case .noApplicableEncoder(let width, let height):
+ "no HEVC or H.264 encoder accepts \(width)x\(height) on this machine"
+ case .writerSetupFailed(let reason):
+ "AVAssetWriter setup failed: \(reason)"
+ case .writerFailed(let reason):
+ "AVAssetWriter failed: \(reason)"
+ case .noFramesCaptured(let url):
+ "no frame ever reached the writer; \(url.path) was not finalized"
+ }
+ }
+
+}
+
+// MARK: - StandardError
+
+/// stdout carries the result other tools parse; everything diagnostic goes here.
+public enum StandardError {
+
+ public static func write(_ message: String) {
+ FileHandle.standardError.write(Data((message + "\n").utf8))
+ }
+
+ public static func describe(_ error: any Error) -> String {
+ if let recorderError = error as? RecorderError { return recorderError.description }
+ if let usageError = error as? UsageError { return usageError.description }
+ let nsError = error as NSError
+ return "\(nsError.localizedDescription) [\(nsError.domain) \(nsError.code)]"
+ }
+
+}
+
+// MARK: - RecorderCommand
+
+/// What the process was asked to do.
+///
+/// Parsing is hand-rolled on purpose: the Demo Lab package has zero external
+/// dependencies so it builds offline with only the Command Line Tools.
+public enum RecorderCommand: Sendable {
+ case record(RecorderOptions)
+ case listDisplays
+ case preflight
+ case help
+
+ // MARK: Public
+
+ public static let usage = """
+ DemoRecorder — 60 fps ScreenCaptureKit display recorder.
+
+ USAGE:
+ DemoRecorder --output [options]
+ DemoRecorder --list-displays
+ DemoRecorder --preflight
+
+ OPTIONS:
+ --output, -o Destination. With `--display all` the basename gains -1, -2, ... suffixes.
+ --display Display to capture. Indices are 1-based, as printed by --list-displays.
+ Default: main.
+ --fps Cadence rate and capture ceiling, 1...240. Default: 60.
+ --duration-seconds Stop and finalize after n seconds. Default: run until SIGINT/SIGTERM.
+ --bitrate-mbps Override the average bitrate derived from the pixel count.
+ --no-cursor Do not draw the pointer into the capture.
+ --pidfile Write this process's pid there for `democtl record stop`; removed on exit.
+ Written only once capture is live, and the take's outcome lands beside
+ it as .json before the pid is removed.
+ --list-displays Print the capturable displays and exit.
+ --preflight Print the Screen Recording access state; exit 0 (granted) or 3 (denied).
+ --help, -h This text.
+
+ EXIT CODES:
+ 0 ok 2 usage 3 no Screen Recording access 4 capture or writer failure
+ """
+
+ public static func parse(_ arguments: [String]) throws -> RecorderCommand {
+ var output: String?
+ var display = DisplaySelection.main
+ var fps = 60
+ var durationSeconds: Double?
+ var bitrateMbps: Double?
+ var showsCursor = true
+ var pidfile: String?
+ var cursor = 0
+
+ func nextValue(for option: String) throws -> String {
+ cursor += 1
+ guard cursor < arguments.count else {
+ throw UsageError("\(option) requires a value")
+ }
+ return arguments[cursor]
+ }
+
+ while cursor < arguments.count {
+ let argument = arguments[cursor]
+ switch argument {
+ case "--help", "-h":
+ return .help
+ case "--list-displays":
+ return .listDisplays
+ case "--preflight":
+ return .preflight
+ case "--output", "-o":
+ output = try nextValue(for: "--output")
+ case "--display":
+ display = try parseDisplay(nextValue(for: "--display"))
+ case "--fps":
+ fps = try parseInt(nextValue(for: "--fps"), option: "--fps", lowerBound: 1, upperBound: 240)
+ case "--duration-seconds":
+ durationSeconds = try parsePositiveDouble(nextValue(for: "--duration-seconds"), option: "--duration-seconds")
+ case "--bitrate-mbps":
+ bitrateMbps = try parsePositiveDouble(nextValue(for: "--bitrate-mbps"), option: "--bitrate-mbps")
+ case "--no-cursor":
+ showsCursor = false
+ case "--pidfile":
+ pidfile = try nextValue(for: "--pidfile")
+ default:
+ throw UsageError("unknown option '\(argument)'")
+ }
+ cursor += 1
+ }
+
+ guard let output else {
+ throw UsageError("--output is required")
+ }
+ return .record(RecorderOptions(
+ output: fileURL(output, defaultExtension: "mov"),
+ display: display,
+ fps: fps,
+ durationSeconds: durationSeconds,
+ bitrateMbps: bitrateMbps,
+ showsCursor: showsCursor,
+ pidfile: pidfile.map { fileURL($0, defaultExtension: nil) } ?? RecorderOptions.defaultPidfile()
+ ))
+ }
+
+ // MARK: Private
+
+ private static func parseDisplay(_ value: String) throws -> DisplaySelection {
+ switch value {
+ case "main":
+ return .main
+ case "all":
+ return .all
+ default:
+ if value.hasPrefix("id:") {
+ guard let raw = UInt32(value.dropFirst(3)), raw != 0 else {
+ throw UsageError("--display id: expects a CGDirectDisplayID, got '\(value)'")
+ }
+ return .identifier(CGDirectDisplayID(raw))
+ }
+ guard let index = Int(value), index >= 1 else {
+ throw UsageError("--display expects 'main', 'all', 'id:', or a 1-based index, got '\(value)'")
+ }
+ return .index(index)
+ }
+ }
+
+ private static func parseInt(_ value: String, option: String, lowerBound: Int, upperBound: Int) throws -> Int {
+ guard let parsed = Int(value), parsed >= lowerBound, parsed <= upperBound else {
+ throw UsageError("\(option) expects an integer in \(lowerBound)...\(upperBound), got '\(value)'")
+ }
+ return parsed
+ }
+
+ private static func parsePositiveDouble(_ value: String, option: String) throws -> Double {
+ guard let parsed = Double(value), parsed > 0, parsed.isFinite else {
+ throw UsageError("\(option) expects a positive number, got '\(value)'")
+ }
+ return parsed
+ }
+
+ private static func fileURL(_ path: String, defaultExtension: String?) -> URL {
+ var url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
+ if let defaultExtension, url.pathExtension.isEmpty {
+ url = url.appendingPathExtension(defaultExtension)
+ }
+ return url
+ }
+
+}
diff --git a/DemoLab/Sources/democtl/main.swift b/DemoLab/Sources/democtl/main.swift
new file mode 100644
index 0000000..b30d2c4
--- /dev/null
+++ b/DemoLab/Sources/democtl/main.swift
@@ -0,0 +1,9 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoCtlKit
+import Foundation
+
+// democtl runs entirely on the main actor: it drives AppKit (NSRunningApplication,
+// NSScreen) and CoreGraphics, and every operation is a deliberate sequence.
+exit(MainActor.assumeIsolated { DemoCtl.main() })
diff --git a/DemoLab/Sources/demodisplay/main.m b/DemoLab/Sources/demodisplay/main.m
new file mode 100644
index 0000000..c906310
--- /dev/null
+++ b/DemoLab/Sources/demodisplay/main.m
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// Lab-only helper; these private CoreGraphics interfaces are not linked into Tatami.
+// API reference: https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h
+#import
+#import
+@interface CGVirtualDisplayMode : NSObject
+- (id)initWithWidth:(NSUInteger)width height:(NSUInteger)height refreshRate:(CGFloat)rate;
+@end
+@interface CGVirtualDisplaySettings : NSObject
+@property(retain) NSArray *modes;
+@property unsigned int hiDPI;
+@end
+@interface CGVirtualDisplayDescriptor : NSObject
+@property(retain) NSString *name;
+@property unsigned int maxPixelsWide, maxPixelsHigh, vendorID, productID, serialNum;
+@property CGSize sizeInMillimeters;
+- (void)setDispatchQueue:(dispatch_queue_t)queue;
+@end
+@interface CGVirtualDisplay : NSObject
+@property(readonly) CGDirectDisplayID displayID;
+- (id)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
+- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
+@end
+int main(int argc, const char *argv[]) { @autoreleasepool {
+ signal(SIGTERM, SIG_DFL); signal(SIGINT, SIG_DFL);
+ int seconds = argc > 1 ? atoi(argv[1]) : 300;
+ if (seconds < 1 || seconds > 3600) return 64;
+ NSLog(@"class=%@", NSClassFromString(@"CGVirtualDisplay"));
+ CGVirtualDisplayDescriptor *descriptor = [CGVirtualDisplayDescriptor new];
+ descriptor.name = @"Tatami Lab Secondary";
+ descriptor.maxPixelsWide = 1920; descriptor.maxPixelsHigh = 1200;
+ descriptor.sizeInMillimeters = CGSizeMake(508, 318);
+ descriptor.vendorID = 0x5050; descriptor.productID = 0x1001; descriptor.serialNum = 42;
+ [descriptor setDispatchQueue:dispatch_get_main_queue()];
+ CGVirtualDisplay *display = [[CGVirtualDisplay alloc] initWithDescriptor:descriptor];
+ if (!display) { NSLog(@"creation failed"); return 2; }
+ CGVirtualDisplaySettings *settings = [CGVirtualDisplaySettings new];
+ settings.hiDPI = 0;
+ settings.modes = @[[[CGVirtualDisplayMode alloc] initWithWidth:1920 height:1200 refreshRate:60]];
+ BOOL applied = [display applySettings:settings];
+ NSLog(@"id=%u applied=%d", display.displayID, applied);
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:2];
+ [[NSRunLoop currentRunLoop] runUntilDate:deadline];
+ CGDirectDisplayID ids[16]; uint32_t count = 0;
+ CGGetOnlineDisplayList(16, ids, &count);
+ for (uint32_t i = 0; i < count; i++) NSLog(@"display=%u active=%d bounds=%@", ids[i], CGDisplayIsActive(ids[i]), NSStringFromRect(CGDisplayBounds(ids[i])));
+ NSLog(@"online=%u", count);
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:seconds]];
+ (void)display.displayID;
+ return applied && count > 1 ? 0 : 3;
+}}
diff --git a/DemoLab/Sources/demohook/main.swift b/DemoLab/Sources/demohook/main.swift
new file mode 100644
index 0000000..e361cee
--- /dev/null
+++ b/DemoLab/Sources/demohook/main.swift
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import Foundation
+import System
+
+let environment = ProcessInfo.processInfo.environment
+let arguments = Array(CommandLine.arguments.dropFirst())
+let event = environment["TATAMI_HOOK_EVENT"] ?? ""
+guard arguments.count == 2, ["event-log", "status"].contains(arguments[0]), !event.isEmpty else {
+ FileHandle.standardError.write(Data("usage: demohook event-log | status (called by Tatami)\n".utf8))
+ exit(64)
+}
+do {
+ let target = URL(fileURLWithPath: arguments[1])
+ if arguments[0] == "event-log" {
+ try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let fields = [String(Int(Date().timeIntervalSince1970)), event,
+ environment["TATAMI_PROFILE_NAME"] ?? "", environment["TATAMI_WORKSPACE_NAME"] ?? "",
+ environment["TATAMI_WORKSPACE_KIND"] ?? "", environment["TATAMI_DISPLAY_NAME"] ?? ""]
+ let data = Data((fields.joined(separator: "\t") + "\n").utf8)
+ let file = try FileDescriptor.open(FilePath(target.path), .writeOnly, options: [.create, .append], permissions: [.ownerRead, .ownerWrite])
+ defer { try? file.close() }
+ // One append write keeps concurrent display-transition records together.
+ let written = try data.withUnsafeBytes { try file.write($0) }
+ guard written == data.count else { throw CocoaError(.fileWriteUnknown) }
+ } else {
+ let value = HookActivity(event: event, profile: environment["TATAMI_PROFILE_NAME"] ?? "",
+ workspace: environment["TATAMI_WORKSPACE_NAME"] ?? "",
+ title: environment["TATAMI_HUD_TITLE"] ?? "", subtitle: environment["TATAMI_HUD_SUBTITLE"] ?? "")
+ try JSONEncoder().encode(value).write(to: target.appendingPathComponent(event == "hud" ? "hud-hook.json" : "workspace-hook.json"), options: .atomic)
+ DistributedNotificationCenter.default().postNotificationName(Notification.Name("dev.PangMo5.DemoLab.hookChanged"), object: nil, userInfo: nil, deliverImmediately: true)
+ }
+} catch {
+ FileHandle.standardError.write(Data("demohook: \(error)\n".utf8)); exit(1)
+}
diff --git a/DemoLab/Sources/demoicon/main.swift b/DemoLab/Sources/demoicon/main.swift
new file mode 100644
index 0000000..692c861
--- /dev/null
+++ b/DemoLab/Sources/demoicon/main.swift
@@ -0,0 +1,280 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import AppKit
+import DemoAppKit
+import Foundation
+
+// MARK: - IconSlot
+
+/// One rung of the `.icns` ladder: the file stem `iconutil` expects and the
+/// real pixel edge it must be rendered at.
+struct IconSlot {
+ let name: String
+ let pixels: Int
+}
+
+// MARK: - IconError
+
+enum IconError: Error, CustomStringConvertible {
+
+ /// A missing symbol is fatal on purpose. A generic fallback glyph would ship
+ /// a wrong-looking app into a recording, and nobody would notice until the
+ /// take is already cut.
+ case missingSymbol(app: String, symbol: String)
+ case renderFailed(app: String, detail: String)
+ case writeFailed(path: String, detail: String)
+
+ // MARK: Internal
+
+ var description: String {
+ switch self {
+ case let .missingSymbol(app, symbol):
+ "\(app): SF Symbol '\(symbol)' is unavailable on this macOS version"
+ case let .renderFailed(app, detail):
+ "\(app): \(detail)"
+ case let .writeFailed(path, detail):
+ "cannot write \(path): \(detail)"
+ }
+ }
+
+}
+
+// MARK: - Geometry
+
+/// macOS icon proportions: the art sits inside a transparent margin, and its
+/// corner radius is a fixed fraction of the art itself.
+let artFraction: CGFloat = 0.80
+let cornerFraction: CGFloat = 0.225
+/// The glyph is measured against the full canvas, not the art, so it keeps the
+/// same optical weight as the system icons it sits next to.
+let symbolFraction: CGFloat = 0.54
+
+let iconSlots: [IconSlot] = [
+ IconSlot(name: "icon_16x16", pixels: 16),
+ IconSlot(name: "icon_16x16@2x", pixels: 32),
+ IconSlot(name: "icon_32x32", pixels: 32),
+ IconSlot(name: "icon_32x32@2x", pixels: 64),
+ IconSlot(name: "icon_128x128", pixels: 128),
+ IconSlot(name: "icon_128x128@2x", pixels: 256),
+ IconSlot(name: "icon_256x256", pixels: 256),
+ IconSlot(name: "icon_256x256@2x", pixels: 512),
+ IconSlot(name: "icon_512x512", pixels: 512),
+ IconSlot(name: "icon_512x512@2x", pixels: 1024),
+]
+
+// MARK: - Rendering
+
+/// A bitmap whose point size equals its pixel size, so every rect below is in
+/// real pixels and the screen's backing scale never enters the render.
+@MainActor
+func makeCanvas(pixels: Int) -> NSBitmapImageRep? {
+ guard let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: pixels,
+ pixelsHigh: pixels,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ) else { return nil }
+ rep.size = NSSize(width: pixels, height: pixels)
+ return rep
+}
+
+@MainActor
+func draw(into rep: NSBitmapImageRep, _ body: () -> Void) -> Bool {
+ guard let context = NSGraphicsContext(bitmapImageRep: rep) else { return false }
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+ NSGraphicsContext.current = context
+ body()
+ context.flushGraphics()
+ return true
+}
+
+/// The glyph on its own transparent layer. Tinting happens here, where the
+/// layer is empty, so `.sourceAtop` repaints exactly the glyph and nothing of
+/// the background it will later sit on.
+@MainActor
+func symbolLayer(spec: DemoAppSpec, pixels: Int) throws -> NSImage {
+ guard let symbol = NSImage(systemSymbolName: spec.symbolName, accessibilityDescription: spec.name) else {
+ throw IconError.missingSymbol(app: spec.name, symbol: spec.symbolName)
+ }
+ let side = CGFloat(pixels)
+ let box = side * symbolFraction
+ let configuration = NSImage.SymbolConfiguration(pointSize: box, weight: .semibold, scale: .medium)
+ let sized = symbol.withSymbolConfiguration(configuration) ?? symbol
+ let natural = sized.size
+ guard natural.width > 0, natural.height > 0 else {
+ throw IconError.renderFailed(app: spec.name, detail: "symbol '\(spec.symbolName)' has an empty size")
+ }
+ guard let layer = makeCanvas(pixels: pixels) else {
+ throw IconError.renderFailed(app: spec.name, detail: "cannot allocate a \(pixels)px symbol layer")
+ }
+
+ // Fit the glyph's own aspect ratio inside the box rather than stretching it.
+ let scale = min(box / natural.width, box / natural.height)
+ let drawn = NSSize(width: natural.width * scale, height: natural.height * scale)
+ let rect = NSRect(
+ x: (side - drawn.width) / 2,
+ y: (side - drawn.height) / 2,
+ width: drawn.width,
+ height: drawn.height
+ )
+ let rendered = draw(into: layer) {
+ sized.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
+ NSColor.white.setFill()
+ NSRect(x: 0, y: 0, width: side, height: side).fill(using: .sourceAtop)
+ }
+ guard rendered else {
+ throw IconError.renderFailed(app: spec.name, detail: "no graphics context for the \(pixels)px symbol layer")
+ }
+ let image = NSImage(size: NSSize(width: side, height: side))
+ image.addRepresentation(layer)
+ return image
+}
+
+@MainActor
+func renderPNG(spec: DemoAppSpec, pixels: Int) throws -> Data {
+ let layer = try symbolLayer(spec: spec, pixels: pixels)
+ guard let canvas = makeCanvas(pixels: pixels) else {
+ throw IconError.renderFailed(app: spec.name, detail: "cannot allocate a \(pixels)px canvas")
+ }
+ let side = CGFloat(pixels)
+ let art = side * artFraction
+ let artRect = NSRect(x: (side - art) / 2, y: (side - art) / 2, width: art, height: art)
+ let radius = art * cornerFraction
+ let accent = NSColor(
+ srgbRed: CGFloat(spec.accent.red),
+ green: CGFloat(spec.accent.green),
+ blue: CGFloat(spec.accent.blue),
+ alpha: 1
+ )
+ let rendered = draw(into: canvas) {
+ accent.setFill()
+ NSBezierPath(roundedRect: artRect, xRadius: radius, yRadius: radius).fill()
+ // Explicitly `.sourceOver`: an image rep drawn bare composites with copy
+ // semantics and its transparent margin would erase the accent behind it.
+ layer.draw(
+ in: NSRect(x: 0, y: 0, width: side, height: side),
+ from: .zero,
+ operation: .sourceOver,
+ fraction: 1
+ )
+ }
+ guard rendered else {
+ throw IconError.renderFailed(app: spec.name, detail: "no graphics context for the \(pixels)px canvas")
+ }
+ guard let data = canvas.representation(using: .png, properties: [:]) else {
+ throw IconError.renderFailed(app: spec.name, detail: "PNG encoding failed at \(pixels)px")
+ }
+ return data
+}
+
+// MARK: - Iconset
+
+/// Rebuilds the directory from scratch so a renamed or dropped slot cannot
+/// survive as a stale PNG that `iconutil` would happily bundle.
+func prepareIconset(at url: URL) throws {
+ let manager = FileManager.default
+ if url.pathExtension == "iconset", manager.fileExists(atPath: url.path) {
+ try manager.removeItem(at: url)
+ }
+ try manager.createDirectory(at: url, withIntermediateDirectories: true)
+}
+
+@MainActor
+func writeIconset(spec: DemoAppSpec, to url: URL) throws {
+ do {
+ try prepareIconset(at: url)
+ } catch let error as IconError {
+ throw error
+ } catch {
+ throw IconError.writeFailed(path: url.path, detail: error.localizedDescription)
+ }
+ for slot in iconSlots {
+ let data = try renderPNG(spec: spec, pixels: slot.pixels)
+ let file = url.appendingPathComponent("\(slot.name).png")
+ do {
+ try data.write(to: file, options: .atomic)
+ } catch {
+ throw IconError.writeFailed(path: file.path, detail: error.localizedDescription)
+ }
+ }
+ print("\(spec.name): \(iconSlots.count) PNGs -> \(url.path)")
+}
+
+// MARK: - Command line
+
+let usage = """
+usage: demoicon --app --out
+ demoicon --all --out
+
+Renders the SF Symbol from DemoCatalog onto the app's accent color at every
+size an .icns needs. --all writes one .iconset per catalog app.
+
+exit codes: 0 ok, 2 usage, 4 render failure
+"""
+
+func fail(_ message: String, code: Int32) -> Never {
+ FileHandle.standardError.write(Data("demoicon: \(message)\n".utf8))
+ exit(code)
+}
+
+// AppKit needs its shared application before symbol images resolve.
+_ = NSApplication.shared
+
+var requestedApp: String?
+var outPath: String?
+var renderAll = false
+var arguments = Array(CommandLine.arguments.dropFirst())
+
+while let argument = arguments.first {
+ arguments.removeFirst()
+ switch argument {
+ case "--help", "-h":
+ print(usage)
+ exit(0)
+ case "--all":
+ renderAll = true
+ case "--app":
+ guard let value = arguments.first else { fail("--app needs a value\n\n\(usage)", code: 2) }
+ arguments.removeFirst()
+ requestedApp = value
+ case "--out":
+ guard let value = arguments.first else { fail("--out needs a value\n\n\(usage)", code: 2) }
+ arguments.removeFirst()
+ outPath = value
+ default:
+ fail("unknown argument '\(argument)'\n\n\(usage)", code: 2)
+ }
+}
+
+guard let outPath else { fail("--out is required\n\n\(usage)", code: 2) }
+if renderAll, requestedApp != nil { fail("--all and --app are mutually exclusive\n\n\(usage)", code: 2) }
+if !renderAll, requestedApp == nil { fail("one of --all or --app is required\n\n\(usage)", code: 2) }
+
+let outURL = URL(fileURLWithPath: outPath).standardizedFileURL
+
+do {
+ if renderAll {
+ try FileManager.default.createDirectory(at: outURL, withIntermediateDirectories: true)
+ for spec in DemoCatalog.all {
+ try writeIconset(spec: spec, to: outURL.appendingPathComponent("\(spec.name).iconset"))
+ }
+ } else if let requestedApp {
+ guard let spec = DemoCatalog.spec(named: requestedApp) else {
+ let known = DemoCatalog.all.map(\.name).joined(separator: ", ")
+ fail("unknown app '\(requestedApp)' (known: \(known))", code: 2)
+ }
+ try writeIconset(spec: spec, to: outURL)
+ }
+} catch let error as IconError {
+ fail(error.description, code: 4)
+} catch {
+ fail(error.localizedDescription, code: 4)
+}
diff --git a/DemoLab/Sources/demokey/main.swift b/DemoLab/Sources/demokey/main.swift
new file mode 100644
index 0000000..3aea7f6
--- /dev/null
+++ b/DemoLab/Sources/demokey/main.swift
@@ -0,0 +1,311 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import CoreGraphics
+import DemoDriverKit
+import Foundation
+
+// MARK: - ExitCode
+
+enum ExitCode {
+ static let ok: Int32 = 0
+ static let usage: Int32 = 2
+ static let notTrusted: Int32 = 3
+ static let runtime: Int32 = 4
+}
+
+// MARK: - UsageError
+
+struct UsageError: Error, CustomStringConvertible {
+ let message: String
+
+ var description: String { message }
+}
+
+// MARK: - Arguments
+
+/// A deliberately small flag parser: `--name value`, `--name=value`, and
+/// `--verbose`. The package has no dependencies and this does not need any.
+struct Arguments {
+
+ // MARK: Lifecycle
+
+ init(_ raw: [String], allowed: Set) throws {
+ var index = 0
+ while index < raw.count {
+ let token = raw[index]
+ if token == "--verbose" || token == "-v" {
+ verbose = true
+ index += 1
+ continue
+ }
+ guard token.hasPrefix("--") else {
+ positional.append(token)
+ index += 1
+ continue
+ }
+ let body = String(token.dropFirst(2))
+ let name: String
+ var value: String?
+ if let separator = body.firstIndex(of: "=") {
+ name = String(body[.. String? { values[name] }
+
+ func int(_ name: String, default fallback: Int, minimum: Int) throws -> Int {
+ guard let raw = values[name] else { return fallback }
+ guard let value = Int(raw) else {
+ throw UsageError(message: "option \"--\(name)\" expects a whole number, got \"\(raw)\"")
+ }
+ guard value >= minimum else {
+ throw UsageError(message: "option \"--\(name)\" must be at least \(minimum), got \(value)")
+ }
+ return value
+ }
+
+ func unit(_ name: String, default fallback: Double) throws -> Double {
+ guard let raw = values[name] else { return fallback }
+ guard let value = Double(raw) else {
+ throw UsageError(message: "option \"--\(name)\" expects a number, got \"\(raw)\"")
+ }
+ guard value >= 0, value <= 1 else {
+ throw UsageError(message: "option \"--\(name)\" must be between 0 and 1, got \(value)")
+ }
+ return value
+ }
+
+ func requiredPositional(_ description: String) throws -> String {
+ guard let first = positional.first else { throw UsageError(message: "missing \(description)") }
+ guard positional.count == 1 else {
+ throw UsageError(message: "expected one \(description), got \(positional.count): \(positional.joined(separator: " "))")
+ }
+ return first
+ }
+
+ // MARK: Private
+
+ private var values: [String: String] = [:]
+
+}
+
+// MARK: - DemoKey
+
+@MainActor
+enum DemoKey {
+
+ // MARK: Internal
+
+ static func main(_ arguments: [String]) -> Int32 {
+ do {
+ return try run(arguments)
+ } catch let error as UsageError {
+ complain(error.message)
+ complain(usage)
+ return ExitCode.usage
+ } catch DriverError.notTrusted {
+ complain("\(DriverError.notTrusted)")
+ complain("the binary needing the grant is: \(executablePath)")
+ return ExitCode.notTrusted
+ } catch {
+ complain("\(error)")
+ return ExitCode.runtime
+ }
+ }
+
+ // MARK: Private
+
+ private static let usage = """
+ usage:
+ demokey press "" [--hold-ms N] [--repeat N] [--gap-ms N] [--verbose]
+ demokey hold "" --keys ",,..." [--gap-ms N] [--release-after-ms N] [--verbose]
+ demokey pointer --display N [--x 0.5] [--y 0.5] [--verbose]
+ demokey check [--verbose]
+ demokey --help
+
+ exit codes: 0 ok, 2 usage error, 3 not trusted for Accessibility, 4 runtime failure
+ """
+
+ private static let help = """
+ demokey — types Tatami's published shortcuts, exactly as a person would press them.
+
+ It synthesizes key events and moves the cursor. Nothing else: it never talks to
+ Tatami's socket, reads its config, or touches its process.
+
+ \(usage)
+
+ press
+ Taps one chord. A chord is Tatami's own skhd-style syntax: "ctrl + alt - h",
+ "cmd-return", "alt+shift+tab", or a bare key like "tab". Modifiers are
+ cmd/command, shift, alt/opt/option, ctrl/control.
+ --hold-ms N key-down to key-up time (default 24)
+ --repeat N tap the chord N times (default 1)
+ --gap-ms N wait between repeats (default 80)
+
+ hold
+ Holds modifiers down as real key presses, taps each key in --keys, waits, then
+ releases. This is what Tatami's held-modifier switcher needs: it polls the HID
+ flags state, which flags on a key event alone never reach.
+ --keys LIST comma-separated chords, e.g. "tab,tab" (required)
+ --gap-ms N wait between taps (default 90)
+ --release-after-ms wait before releasing the modifiers (default 180)
+ Per-tap modifiers must be a subset of the held modifiers.
+
+ pointer
+ Warps the cursor onto a display, because Tatami places a dynamic workspace on
+ the display under the cursor.
+ --display N zero-based index into the screens sorted left to right, top to
+ bottom — the same order Tatami uses (required)
+ --x U --y U unit position on that display, (0,0) top-left, (1,1) bottom-right
+ (default 0.5, 0.5)
+
+ check
+ Prints whether this process is trusted for Accessibility. Exits 3 when it is not.
+ """
+
+ private static var executablePath: String {
+ Bundle.main.executablePath ?? CommandLine.arguments.first ?? "demokey"
+ }
+
+ private static func run(_ arguments: [String]) throws -> Int32 {
+ guard let command = arguments.first else { throw UsageError(message: "no command given") }
+ let rest = Array(arguments.dropFirst())
+ switch command {
+ case "--help", "-h", "help":
+ print(help)
+ return ExitCode.ok
+ case "press":
+ return try press(try Arguments(rest, allowed: ["hold-ms", "repeat", "gap-ms"]))
+ case "hold":
+ return try hold(try Arguments(rest, allowed: ["keys", "gap-ms", "release-after-ms"]))
+ case "pointer":
+ return try pointer(try Arguments(rest, allowed: ["display", "x", "y"]))
+ case "check":
+ return check(try Arguments(rest, allowed: []))
+ default:
+ throw UsageError(message: "unknown command \"\(command)\"")
+ }
+ }
+
+ private static func press(_ arguments: Arguments) throws -> Int32 {
+ let text = try arguments.requiredPositional("chord, e.g. \"ctrl + alt - h\"")
+ let holdMilliseconds = try arguments.int("hold-ms", default: 24, minimum: 0)
+ let repeats = try arguments.int("repeat", default: 1, minimum: 1)
+ let gapMilliseconds = try arguments.int("gap-ms", default: 80, minimum: 0)
+
+ var notes: [String] = []
+ let chord = try ChordParser.parse(text, notes: ¬es)
+ if arguments.verbose {
+ notes.forEach(note)
+ note("press \"\(chord.source)\" -> key code \(chord.keyCode), flags \(hex(chord.flags)), \(repeats)x")
+ }
+ for index in 0.. Int32 {
+ let text = try arguments.requiredPositional("modifier list, e.g. \"alt\"")
+ guard let list = arguments.string("keys") else {
+ throw UsageError(message: "hold needs --keys, e.g. --keys \"tab,tab\"")
+ }
+ let gapMilliseconds = try arguments.int("gap-ms", default: 90, minimum: 0)
+ let releaseAfterMilliseconds = try arguments.int("release-after-ms", default: 180, minimum: 0)
+
+ let modifiers = try ChordParser.parseModifiers(text)
+ guard !modifiers.isEmpty else {
+ throw UsageError(message: "\"\(text)\" names no modifier to hold")
+ }
+ var notes: [String] = []
+ var keys: [CGKeyCode] = []
+ for entry in list.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) where !entry.isEmpty {
+ let chord = try ChordParser.parse(entry, notes: ¬es)
+ // The switcher holds one modifier set for the whole session, so a tap
+ // cannot introduce its own. Dropping it silently would press a different
+ // shortcut than the caller wrote.
+ guard modifiers.contains(chord.flags) else {
+ throw UsageError(
+ message: "key \"\(entry)\" carries modifiers that are not held — put every modifier in the hold argument"
+ )
+ }
+ keys.append(chord.keyCode)
+ }
+ guard !keys.isEmpty else { throw UsageError(message: "--keys listed no keys") }
+
+ if arguments.verbose {
+ notes.forEach(note)
+ let tapped = keys.map(String.init).joined(separator: ", ")
+ note("hold \(hex(modifiers)) tapping \(tapped), releasing after \(releaseAfterMilliseconds)ms")
+ }
+ try KeyDriver.hold(
+ modifiers: modifiers,
+ tapping: keys,
+ gapMilliseconds: gapMilliseconds,
+ holdAfterMilliseconds: releaseAfterMilliseconds
+ )
+ return ExitCode.ok
+ }
+
+ private static func pointer(_ arguments: Arguments) throws -> Int32 {
+ guard arguments.string("display") != nil else {
+ throw UsageError(message: "pointer needs --display N (zero-based)")
+ }
+ let display = try arguments.int("display", default: 0, minimum: 0)
+ let unitX = try arguments.unit("x", default: 0.5)
+ let unitY = try arguments.unit("y", default: 0.5)
+ if arguments.verbose { note("pointer -> display \(display) at (\(unitX), \(unitY))") }
+ try Pointer.warp(toDisplayIndex: display, unitX: unitX, unitY: unitY)
+ return ExitCode.ok
+ }
+
+ private static func check(_ arguments: Arguments) -> Int32 {
+ let trusted = KeyDriver.isTrusted
+ print(trusted ? "accessibility: trusted" : "accessibility: not trusted")
+ if arguments.verbose { note("binary: \(executablePath)") }
+ if !trusted {
+ complain("grant Accessibility in System Settings > Privacy & Security > Accessibility for: \(executablePath)")
+ }
+ return trusted ? ExitCode.ok : ExitCode.notTrusted
+ }
+
+ private static func hex(_ flags: CGEventFlags) -> String {
+ "0x" + String(flags.rawValue, radix: 16)
+ }
+
+ private static func wait(milliseconds: Int) {
+ guard milliseconds > 0 else { return }
+ Thread.sleep(forTimeInterval: Double(milliseconds) / 1000)
+ }
+
+ private static func note(_ message: String) {
+ complain(message)
+ }
+
+ private static func complain(_ message: String) {
+ FileHandle.standardError.write(Data("demokey: \(message)\n".utf8))
+ }
+
+}
+
+// Top-level code runs on the main thread, which is where the driver lives.
+exit(MainActor.assumeIsolated { DemoKey.main(Array(CommandLine.arguments.dropFirst())) })
diff --git a/DemoLab/Tests/DemoLabTests/CadenceAccountingTests.swift b/DemoLab/Tests/DemoLabTests/CadenceAccountingTests.swift
new file mode 100644
index 0000000..99ee70f
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/CadenceAccountingTests.swift
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+@testable import DemoRecorderKit
+import Testing
+
+@Suite("Recording cadence loss accounting")
+struct CadenceAccountingTests {
+ @Test("encoder backpressure is not counted again by the next timestamp gap")
+ func backpressureIsCountedOnce() {
+ var clock = CadenceAccounting()
+ #expect(clock.reserve(elapsed: 0, fps: 30) == 0)
+ #expect(clock.reserve(elapsed: 1.0 / 30, fps: 30) == 1)
+ clock.rejectReservedFrame()
+ #expect(clock.reserve(elapsed: 2.0 / 30, fps: 30) == 2)
+ #expect(clock.droppedFrameCount == 1)
+ }
+
+ @Test("missed timer deadlines and a rejected slot are distinct losses")
+ func timerAndEncoderLosses() {
+ var clock = CadenceAccounting()
+ _ = clock.reserve(elapsed: 0, fps: 60)
+ #expect(clock.reserve(elapsed: 4.0 / 60, fps: 60) == 4)
+ clock.rejectReservedFrame()
+ #expect(clock.droppedFrameCount == 4)
+ _ = clock.reserve(elapsed: 5.0 / 60, fps: 60)
+ #expect(clock.droppedFrameCount == 4)
+ }
+
+ @Test("a rejected final slot is counted without requiring another callback")
+ func finalSlot() {
+ var clock = CadenceAccounting()
+ _ = clock.reserve(elapsed: 0, fps: 30)
+ clock.rejectReservedFrame()
+ #expect(clock.nextFrameIndex == 1)
+ #expect(clock.droppedFrameCount == 1)
+ }
+}
diff --git a/DemoLab/Tests/DemoLabTests/CaptureContractTests.swift b/DemoLab/Tests/DemoLabTests/CaptureContractTests.swift
new file mode 100644
index 0000000..017849a
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/CaptureContractTests.swift
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import Foundation
+import CoreGraphics
+import DemoDriverKit
+import Testing
+@testable import DemoCtlKit
+
+@MainActor
+@Suite("Capture contract")
+struct CaptureContractTests {
+ @Test("arrow events retain their hardware key identity without adding modifiers to letters")
+ func arrowIdentity() {
+ let modifiers: CGEventFlags = [.maskControl, .maskAlternate, .maskShift]
+ let arrow = KeyDriver.eventFlags(for: 124, modifiers: modifiers)
+ #expect(arrow.contains(.maskNumericPad))
+ #expect(arrow.contains(.maskSecondaryFn))
+ #expect(arrow.contains(modifiers))
+ #expect(KeyDriver.eventFlags(for: 37, modifiers: modifiers) == modifiers)
+ }
+
+ @Test("restoration rejects a missing, extra, moved, or resized window")
+ func restore() {
+ let original = ["Editor:1": CGRect(x: 12, y: 40, width: 900, height: 1100)]
+ #expect(CaptureGate.matches(original, original))
+ #expect(!CaptureGate.matches([:], original))
+ #expect(!CaptureGate.matches(original.merging(["Docs:2": .zero]) { a, _ in a }, original))
+ #expect(!CaptureGate.matches(["Editor:1": CGRect(x: 22, y: 40, width: 900, height: 1100)], original))
+ #expect(!CaptureGate.matches(["Editor:1": CGRect(x: 12, y: 40, width: 910, height: 1100)], original))
+ }
+
+ @Test("published scenes stage off camera and never claim synthetic key presses")
+ func scenes() throws {
+ let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ for name in ["tour", "workspaces", "tiling", "borrow", "shared", "switching", "autoopen"] {
+ let scene = try JSONDecoder().decode(Scene.self, from: Data(contentsOf: root.appendingPathComponent("scenes/\(name).json")))
+ #expect(!(scene.setup ?? []).isEmpty)
+ #expect(scene.steps.contains { if case .caption = $0 { true } else { false } })
+ for step in scene.steps {
+ if case .keys(let chord) = step { #expect(chord.isEmpty, "keycast must come from a key or hold step") }
+ if case .quitApps(let apps) = step { #expect(apps != nil, "whole-stage teardown belongs in setup") }
+ if case .beat(let ms, _) = step { #expect(ms <= 3000, "avoid long static beats") }
+ if case .caption(let text) = step {
+ let parts = text.components(separatedBy: " | ")
+ #expect(parts.first!.count <= 66)
+ #expect(parts.dropFirst().joined().count <= 78)
+ }
+ }
+ }
+ }
+}
diff --git a/DemoLab/Tests/DemoLabTests/CatalogAndSceneTests.swift b/DemoLab/Tests/DemoLabTests/CatalogAndSceneTests.swift
new file mode 100644
index 0000000..85c3fa9
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/CatalogAndSceneTests.swift
@@ -0,0 +1,242 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import Foundation
+import Testing
+
+@testable import DemoCtlKit
+
+// MARK: - Catalog
+
+@Suite("Demo app catalog")
+struct DemoCatalogTests {
+
+ @Test("has one spec per app id")
+ func coversEveryID() {
+ #expect(DemoCatalog.all.count == DemoAppID.allCases.count)
+ for id in DemoAppID.allCases {
+ #expect(DemoCatalog.spec(id).id == id)
+ }
+ }
+
+ @Test("bundle identifiers are unique")
+ func identifiersAreUnique() {
+ let identifiers = DemoCatalog.all.map(\.bundleIdentifier)
+ #expect(Set(identifiers).count == identifiers.count)
+ }
+
+ /// Tatami treats `dev.PangMo5.Tatami` and anything under
+ /// `dev.PangMo5.Tatami.` as *itself* and excludes it from management. A demo
+ /// app caught by that test would never be tiled, and the failure would look
+ /// like a tiling bug rather than a naming mistake.
+ @Test("no demo app can be mistaken for Tatami")
+ func identifiersAreNotTatami() {
+ for spec in DemoCatalog.all {
+ #expect(!TatamiInstall.isTatami(spec.bundleIdentifier), "\(spec.bundleIdentifier) reads as Tatami")
+ }
+ }
+
+ @Test("window titles are fixed, distinct and non-empty")
+ func windowTitlesAreDeterministic() {
+ for spec in DemoCatalog.all {
+ #expect(!spec.windowTitles.isEmpty, "\(spec.name) has no window titles")
+ #expect(Set(spec.windowTitles.map { String(localized: $0) }).count == spec.windowTitles.count, "\(spec.name) repeats a title")
+ for title in spec.windowTitles { #expect(!String(localized: title).isEmpty) }
+ // Same ordinal, same title — twice, in the same process and across runs.
+ #expect(spec.windowTitle(at: 0) == spec.windowTitle(at: 0))
+ #expect(spec.windowTitle(at: 0) != spec.windowTitle(at: 99))
+ #expect(spec.defaultWindowCount >= 1)
+ }
+ }
+
+ @Test("launch options parse the flags scenes use, and reject anything else")
+ func launchOptionsParse() throws {
+ let options = try DemoLaunchOptions.parse(["--windows", "2", "--variant", "1"])
+ #expect(options.windowCount == 2)
+ #expect(options.variant == 1)
+ // LaunchServices appends this when an app is opened with `open`.
+ #expect(try DemoLaunchOptions.parse(["-psn_0_12345"]).windowCount == nil)
+ #expect(throws: DemoLaunchError.self) { try DemoLaunchOptions.parse(["--window", "2"]) }
+ #expect(throws: DemoLaunchError.self) { try DemoLaunchOptions.parse(["--windows", "nope"]) }
+ #expect(throws: DemoLaunchError.self) { try DemoLaunchOptions.parse(["--windows"]) }
+ }
+
+}
+
+// MARK: - Scenes
+
+@Suite("Scenes")
+struct SceneTests {
+
+ // MARK: Internal
+
+ @Test("every shipped scene decodes")
+ func scenesDecode() throws {
+ let paths = try paths()
+ let names = SceneLoader.available(in: paths)
+ #expect(names.contains("tour"), "the long integrated take must exist")
+ for name in names {
+ let scene = try SceneLoader.load(name, paths: paths)
+ #expect(scene.name == name, "\(name).json declares name \"\(scene.name)\"")
+ #expect(!scene.steps.isEmpty)
+ }
+ }
+
+ @Test("scenes only reference apps and workspaces that exist")
+ func scenesReferenceRealThings() throws {
+ let paths = try paths()
+ let config = try TomlLite.parse(try ConfigRenderer(paths: paths).render(SeedOptions()))
+ let profiles = try #require(config["profiles"]?.tableArray)
+
+ var workspaceNames = Set()
+ var profileNames = Set()
+ for profile in profiles {
+ if let name = profile["name"]?.stringValue { profileNames.insert(name) }
+ for workspace in profile["workspaces"]?.tableArray ?? [] {
+ if let name = workspace["name"]?.stringValue { workspaceNames.insert(name) }
+ }
+ }
+
+ for name in SceneLoader.available(in: paths) {
+ let scene = try SceneLoader.load(name, paths: paths)
+ for step in (scene.setup ?? []) + scene.steps {
+ switch step {
+ case .activateWorkspace(let workspace, let profile):
+ #expect(workspaceNames.contains(workspace), "\(name): unknown workspace \(workspace)")
+ if let profile { #expect(profileNames.contains(profile), "\(name): unknown profile \(profile)") }
+
+ case .activateProfile(let profile), .waitProfile(let profile, _):
+ #expect(profileNames.contains(profile), "\(name): unknown profile \(profile)")
+
+ case .waitWorkspace(let workspace, _):
+ #expect(workspaceNames.contains(workspace), "\(name): unknown workspace \(workspace)")
+
+ case .borrow(let workspace, let apps, _):
+ #expect(workspaceNames.contains(workspace), "\(name): unknown workspace \(workspace)")
+ for app in apps { #expect(DemoCatalog.spec(named: app) != nil, "\(name): unknown app \(app)") }
+
+ case .launch(let apps, let windows):
+ for app in apps { #expect(DemoCatalog.spec(named: app) != nil, "\(name): unknown app \(app)") }
+ for app in windows.keys { #expect(DemoCatalog.spec(named: app) != nil, "\(name): unknown app \(app)") }
+
+ case .quitApps(let apps):
+ for app in apps ?? [] { #expect(DemoCatalog.spec(named: app) != nil, "\(name): unknown app \(app)") }
+
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ /// Every keystroke a scene types must actually be bound.
+ ///
+ /// This is the exact silent failure the lab exists to stop. An unbound chord
+ /// posts real key events, Tatami ignores them, `democtl` reports the step as
+ /// successful, and the recording is of nothing happening. Checking it here
+ /// means a mistyped shortcut fails `swift test` instead of a take.
+ @Test("every chord a scene types is bound in the seeded config")
+ func sceneChordsAreBound() throws {
+ let paths = try paths()
+ let bound = try boundChords(in: try ConfigRenderer(paths: paths).render(SeedOptions()))
+
+ for name in SceneLoader.available(in: paths) {
+ let scene = try SceneLoader.load(name, paths: paths)
+ for step in (scene.setup ?? []) + scene.steps {
+ switch step {
+ case .key(let chord, _, _):
+ #expect(
+ bound.contains(Self.canonical(chord)),
+ "\(name): \"\(chord)\" is not bound in the config and would do nothing"
+ )
+
+ case .hold(let modifiers, let keys, _, _):
+ for key in keys {
+ let chord = key.contains("-") || key.contains("+") ? key : "\(modifiers) - \(key)"
+ #expect(
+ bound.contains(Self.canonical(chord)),
+ "\(name): \"\(chord)\" is not bound in the config and would do nothing"
+ )
+ }
+
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ /// Shortcuts that belong to macOS or to the app being driven rather than to
+ /// Tatami, so they are correct without appearing in `config.toml`.
+ private static let systemChords: Set = [
+ "cmd-tab", "cmd-ctrl-f", "ctrl-cmd-f", "left", "right", "up", "down", "esc", "cmd-a", "return", "cmd-,", "cmd-w", "cmd-n", "cmd-s", "cmd-m", "cmd-f", "cmd-q", "cmd-shift-w",
+ ]
+
+ /// Canonical form: modifiers in a fixed order, then the key, all lowercased.
+ /// Mirrors how Tatami normalizes a binding, so `"alt + ctrl - h"` and
+ /// `"ctrl + alt - h"` compare equal.
+ private static func canonical(_ chord: String) -> String {
+ let text = chord.lowercased().trimmingCharacters(in: .whitespaces)
+ var modifiers = [String]()
+ var key = text
+ if let separator = text.range(of: " - ", options: .backwards) {
+ modifiers = text[text.startIndex.. Set {
+ let root = try TomlLite.parse(config)
+ var bound = Self.systemChords
+
+ let shortcuts = root["settings"]?["shortcuts"]?.tableValue ?? [:]
+ let modifierArrays = ["keyEquivalentModifiers", "assignModifiers", "borrowModifiers"]
+ let navigationKeys = ["recentWorkspaceKey", "nextWorkspaceKey", "previousWorkspaceKey"]
+
+ for (key, value) in shortcuts {
+ guard !modifierArrays.contains(key), !navigationKeys.contains(key) else { continue }
+ if let chord = value.stringValue { bound.insert(Self.canonical(chord)) }
+ }
+
+ let sets = modifierArrays.compactMap { shortcuts[$0]?.stringArray }
+ var keys = navigationKeys.compactMap { shortcuts[$0]?.stringValue }
+ for profile in root["profiles"]?.tableArray ?? [] {
+ if let shortcut = profile["shortcut"]?.stringValue { bound.insert(Self.canonical(shortcut)) }
+ for workspace in profile["workspaces"]?.tableArray ?? [] {
+ if let equivalent = workspace["keyEquivalent"]?.stringValue { keys.append(equivalent) }
+ for explicit in ["activateShortcut", "assignAppShortcut", "borrowShortcut"] {
+ if let chord = workspace[explicit]?.stringValue { bound.insert(Self.canonical(chord)) }
+ }
+ }
+ }
+
+ for modifiers in sets {
+ for key in keys {
+ bound.insert(Self.canonical("\(modifiers.joined(separator: " + ")) - \(key)"))
+ }
+ }
+ return bound
+ }
+
+ // MARK: Private
+
+ private func paths() throws -> LabPaths {
+ guard let root = LabPaths.discoverPackageRoot() else { throw DemoCtlError.packageRootNotFound }
+ return LabPaths(packageRoot: root)
+ }
+
+}
diff --git a/DemoLab/Tests/DemoLabTests/ConfigTests.swift b/DemoLab/Tests/DemoLabTests/ConfigTests.swift
new file mode 100644
index 0000000..a05efe1
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/ConfigTests.swift
@@ -0,0 +1,432 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+import Foundation
+import Testing
+
+@testable import DemoCtlKit
+
+// MARK: - Fixtures
+
+private func labPaths() throws -> LabPaths {
+ guard let root = LabPaths.discoverPackageRoot() else {
+ Issue.record("could not find the DemoLab package root from the test bundle")
+ throw DemoCtlError.packageRootNotFound
+ }
+ return LabPaths(packageRoot: root)
+}
+
+private func renderedConfig() throws -> String {
+ try ConfigRenderer(paths: try labPaths()).render(SeedOptions())
+}
+
+// MARK: - Template
+
+@Suite("Config template")
+struct ConfigTemplateTests {
+
+ @Test("renders with every placeholder resolved")
+ func rendersCleanly() throws {
+ let text = try renderedConfig()
+ #expect(!text.contains("@PULSE_LAYOUT@"))
+ #expect(!text.contains("@HOOK@"))
+ #expect(!text.contains("@EVENT_LOG@"))
+ }
+
+ @Test("passes the validator with no problems")
+ func validatorIsClean() throws {
+ let report = ConfigValidator.report(try renderedConfig())
+ #expect(report.problems.isEmpty, "problems: \(report.problems)")
+ }
+
+ /// The single most load-bearing setting in the whole lab: without it a
+ /// scripted Borrow arms a keyboard-only picker and then silently cancels.
+ @Test("sets a default borrow edge")
+ func borrowEdgeIsSet() throws {
+ let root = try TomlLite.parse(try renderedConfig())
+ #expect(root["settings"]?["switching"]?["borrowDefaultEdge"]?.stringValue == "right")
+ }
+
+ @Test("writes all three workspace modifier arrays explicitly")
+ func modifierArraysArePresent() throws {
+ let shortcuts = try TomlLite.parse(try renderedConfig())["settings"]?["shortcuts"]
+ #expect(shortcuts?["keyEquivalentModifiers"]?.stringArray == ["ctrl", "alt", "shift"])
+ #expect(shortcuts?["assignModifiers"]?.stringArray == ["alt", "shift", "cmd"])
+ #expect(shortcuts?["borrowModifiers"]?.stringArray == ["ctrl", "alt", "cmd"])
+ }
+
+ /// A `displayHint` names one machine's monitor. It would silently fall back
+ /// to the primary display elsewhere, and a *matching* name would be worse:
+ /// Tatami rewrites the config in place to learn that display's UUID.
+ @Test("pins no display, so the config is portable to any machine or VM")
+ func noDisplayHints() throws {
+ let profiles = try #require(TomlLite.parse(try renderedConfig())["profiles"]?.tableArray)
+ for profile in profiles {
+ for workspace in profile["workspaces"]?.tableArray ?? [] {
+ #expect(workspace["displayHint"] == nil, "\(workspace["name"]?.stringValue ?? "?") is pinned")
+ }
+ }
+ }
+
+ @Test("auto-activates profiles by display count only")
+ func autoActivationUsesCountOnly() throws {
+ let profiles = try #require(TomlLite.parse(try renderedConfig())["profiles"]?.tableArray)
+ let counts = profiles.compactMap { $0["autoActivation"]?["displayCount"]?.stringValue }
+ #expect(counts == ["==1", ">=2"])
+ for profile in profiles {
+ #expect(profile["autoActivation"]?["whenConnected"] == nil)
+ }
+ }
+
+ @Test("every workspace's focus target is one of its own apps")
+ func focusTargetsAreMembers() throws {
+ let profiles = try #require(TomlLite.parse(try renderedConfig())["profiles"]?.tableArray)
+ for profile in profiles {
+ for workspace in profile["workspaces"]?.tableArray ?? [] {
+ guard let focus = workspace["appToFocusBundleId"]?.stringValue else { continue }
+ let members = (workspace["apps"]?.tableArray ?? [])
+ .compactMap { $0["bundleIdentifier"]?.stringValue }
+ #expect(members.contains(focus), "\(focus) is not a member of \(workspace["name"]?.stringValue ?? "?")")
+ }
+ }
+ }
+
+ /// Every scene relies on activation bringing its apps up, and `autoOpen` is
+ /// `try?`-decoded: a quoted or missing value would silently mean "do not
+ /// open" and the take would be of an empty desktop.
+ @Test("every app row carries a bare boolean autoOpen")
+ func autoOpenIsAlwaysABareBool() throws {
+ let root = try TomlLite.parse(try renderedConfig())
+ for shared in try #require(root["sharedApps"]?.tableArray) {
+ #expect(shared["autoOpen"]?.boolValue == false, "Shared Monitor is launched only for its own demo")
+ }
+ for profile in try #require(root["profiles"]?.tableArray) {
+ for workspace in profile["workspaces"]?.tableArray ?? [] {
+ for app in workspace["apps"]?.tableArray ?? [] {
+ #expect(app["autoOpen"]?.boolValue == true, "\(app["name"]?.stringValue ?? "?") does not auto-open")
+ }
+ }
+ }
+ }
+
+ @Test("only Notes is borrow-only, and it has an edge to dock to")
+ func scratchpadIsConfigured() throws {
+ let profiles = try #require(TomlLite.parse(try renderedConfig())["profiles"]?.tableArray)
+ var scratchpads = [String]()
+ for profile in profiles {
+ for workspace in profile["workspaces"]?.tableArray ?? []
+ where workspace["kind"]?.stringValue == "scratchpad" {
+ scratchpads.append(workspace["name"]?.stringValue ?? "?")
+ #expect(workspace["borrowEdge"]?.stringValue == "right")
+ }
+ }
+ #expect(scratchpads == ["Notes", "Notes"])
+ }
+
+ @Test("chains reference real workspaces and never a scratchpad")
+ func chainsAreValid() throws {
+ let profiles = try #require(TomlLite.parse(try renderedConfig())["profiles"]?.tableArray)
+ let desk = try #require(profiles.first { $0["name"]?.stringValue == "Desk" })
+ let workspaces = desk["workspaces"]?.tableArray ?? []
+ let normalIDs = Set(
+ workspaces
+ .filter { ($0["kind"]?.stringValue ?? "normal") == "normal" }
+ .compactMap { $0["id"]?.stringValue }
+ )
+ let chains = try #require(desk["workspaceChains"]?.tableArray)
+ #expect(chains.count == 3)
+ var claimed = Set()
+ for chain in chains {
+ let members = try #require(chain["workspaceIds"]?.stringArray)
+ #expect(members.count >= 2)
+ for member in members {
+ #expect(normalIDs.contains(member))
+ #expect(claimed.insert(member).inserted, "\(member) is in more than one chain")
+ }
+ }
+ }
+
+ @Test("every referenced bundle identifier is a Demo Lab app")
+ func bundleIdentifiersMatchTheCatalog() throws {
+ let known = Set(DemoCatalog.all.map(\.bundleIdentifier))
+ let report = ConfigValidator.report(try renderedConfig())
+ #expect(report.warnings.filter { $0.contains("not a Demo Lab app") }.isEmpty)
+ #expect(known.count == DemoAppID.allCases.count)
+ }
+
+ @Test("seed options reject values Tatami would silently ignore")
+ func seedOptionsAreValidated() {
+ #expect(throws: DemoCtlError.self) {
+ try SeedOptions(pulseLayout: "float").validated()
+ }
+ #expect(throws: DemoCtlError.self) {
+ try SeedOptions(borrowEdge: "east").validated()
+ }
+ }
+
+}
+
+// MARK: - Validator
+
+@Suite("Config validator")
+struct ConfigValidatorTests {
+
+ /// Each of these is a mistake Tatami accepts *silently*. If the validator
+ /// stopped catching them, a take would quietly differ from the previous one
+ /// with nothing in any log to explain it.
+ @Test(
+ "catches silently-defaulted mistakes",
+ arguments: [
+ ("layout = \"tiled\"", "layout = \"tile\"", "layout"),
+ ("autoBalance = \"none\"", "autoBalance = \"nope\"", "autoBalance"),
+ ("displayCount = \"==1\"", "displayCount = 1", "displayCount"),
+ ("position = \"top\"", "position = \"topmost\"", "position"),
+ ("focusFollowsMouseDisableHotkey = \"Alt\"", "focusFollowsMouseDisableHotkey = \"None\"", "DisableHotkey"),
+ ("borrowFraction = 0.34", "borrowFraction = 2.5", "borrowFraction"),
+ ("focusLeft = \"ctrl + alt - h\"", "focusLeft = \"ctrl + hyper - h\"", "focusLeft"),
+ ]
+ )
+ func catchesSilentDefaults(original: String, broken: String, needle: String) throws {
+ let text = try renderedConfig()
+ #expect(text.contains(original), "fixture drifted: \(original) is no longer in the template")
+ let mutated = text.replacingOccurrences(of: original, with: broken)
+ let report = ConfigValidator.report(mutated)
+ #expect(
+ report.problems.contains { $0.contains(needle) },
+ "expected a problem mentioning \(needle); got \(report.problems)"
+ )
+ }
+
+ @Test("catches a missing borrow edge")
+ func catchesMissingBorrowEdge() throws {
+ let text = try renderedConfig()
+ .replacingOccurrences(of: "borrowDefaultEdge = \"right\"", with: "")
+ let report = ConfigValidator.report(text)
+ #expect(report.problems.contains { $0.contains("borrowDefaultEdge") })
+ }
+
+ @Test("catches a duplicate workspace id, which rejects the whole file")
+ func catchesDuplicateWorkspaceID() throws {
+ let text = try renderedConfig().replacingOccurrences(
+ of: "11111111-0000-4000-8000-000000000002",
+ with: "11111111-0000-4000-8000-000000000001"
+ )
+ let report = ConfigValidator.report(text)
+ #expect(report.problems.contains { $0.contains("duplicates a workspace id") })
+ }
+
+ @Test("catches a display hint that can never match")
+ func catchesEmptyUUIDHint() {
+ let text = """
+ [settings.switching]
+ borrowDefaultEdge = "right"
+
+ [[profiles]]
+ id = "d0000000-0000-4000-8000-000000000001"
+ name = "P"
+
+ [[profiles.workspaces]]
+ id = "11111111-0000-4000-8000-000000000001"
+ name = "W"
+ displayHint = "::Studio Display"
+ """
+ #expect(ConfigValidator.report(text).problems.contains { $0.contains("::") })
+ }
+
+ /// The only check that can catch a misspelled *key*. Tatami never looks at a
+ /// key its CodingKeys do not name, so the setting simply never takes effect.
+ @Test("catches a key name Tatami's decoder does not know")
+ func catchesUnknownKey() throws {
+ let text = try renderedConfig()
+ #expect(text.contains("borrowFraction = 0.3\n"), "fixture drifted: no workspace borrowFraction")
+ let typo = text.replacingOccurrences(of: "borrowFraction = 0.3\n", with: "borrowFractoin = 0.3\n")
+ let problems = ConfigValidator.problems(in: typo)
+ #expect(
+ problems.contains { $0.contains("borrowFractoin") && $0.contains("did you mean borrowFraction?") },
+ "expected an unknown-key problem; got \(problems)"
+ )
+ }
+
+ @Test("catches a misspelled settings key, which Tatami drops without a word")
+ func catchesUnknownSettingsKey() throws {
+ let text = try renderedConfig()
+ #expect(text.contains("gapInner = 10"), "fixture drifted: no gapInner")
+ let typo = text.replacingOccurrences(of: "gapInner = 10", with: "gapInnner = 10")
+ let problems = ConfigValidator.problems(in: typo)
+ #expect(problems.contains { $0.contains("settings.layout.gapInnner") }, "got \(problems)")
+ }
+
+ /// `[[floatingApps]]` is a decode-only migration table and a bare `floating`
+ /// bool is the pre-1.4 spelling of `layout`. Both still decode, so calling
+ /// them unknown would refuse a config Tatami reads fine.
+ @Test("does not call Tatami's legacy keys unknown")
+ func toleratesLegacyKeys() {
+ let text = """
+ [settings.switching]
+ borrowDefaultEdge = "right"
+
+ [[floatingApps]]
+ bundleIdentifier = "dev.PangMo5.DemoLab.Pulse"
+ name = "Pulse"
+
+ [[profiles]]
+ id = "d0000000-0000-4000-8000-000000000001"
+ name = "P"
+
+ [[profiles.workspaces]]
+ id = "11111111-0000-4000-8000-000000000001"
+ name = "W"
+
+ [[profiles.workspaces.apps]]
+ bundleIdentifier = "dev.PangMo5.DemoLab.Forge"
+ name = "Forge"
+ autoOpen = true
+ floating = true
+ """
+ let problems = ConfigValidator.problems(in: text)
+ #expect(!problems.contains { $0.contains("not a key Tatami decodes") }, "got \(problems)")
+ }
+
+ /// `autoOpen` is the one app-row key with no banner and no rejection behind
+ /// it: `AppAssignment` and `SharedApp` both decode it with a bare `try?`.
+ @Test("catches an autoOpen Tatami would silently read as false")
+ func catchesUnusableAutoOpen() throws {
+ let text = try renderedConfig()
+ #expect(text.contains("autoOpen = true\n"), "fixture drifted: no autoOpen rows")
+
+ let quoted = text.replacingOccurrences(of: "autoOpen = true", with: "autoOpen = \"true\"")
+ #expect(ConfigValidator.problems(in: quoted).contains { $0.contains("autoOpen must be a bare true/false") })
+
+ let removed = text.replacingOccurrences(of: "autoOpen = true\n", with: "")
+ let report = ConfigValidator.report(removed)
+ #expect(report.problems.contains { $0.contains("has no autoOpen") })
+ #expect(report.warnings.contains { $0.contains("none with autoOpen = true") })
+ }
+
+ /// `HotKey.init(parsing:)` takes all three spellings. Rejecting one would
+ /// block `democtl seed` on a config Tatami would have accepted, which is
+ /// worse than not checking at all.
+ @Test(
+ "accepts every shortcut spelling Tatami's parser accepts",
+ arguments: [
+ ("cycleNextWindow = \"alt - tab\"", "cycleNextWindow = \"alt-tab\""),
+ ("focusLeft = \"ctrl + alt - h\"", "focusLeft = \"ctrl-alt-h\""),
+ ("focusDown = \"ctrl + alt - j\"", "focusDown = \"fn + ctrl + alt - j\""),
+ ("balance = \"ctrl + alt - e\"", "balance = \"ctrl+alt+e\""),
+ ]
+ )
+ func acceptsLooserShortcutSpellings(original: String, rewritten: String) throws {
+ let text = try renderedConfig()
+ #expect(text.contains(original), "fixture drifted: \(original)")
+ let report = ConfigValidator.report(text.replacingOccurrences(of: original, with: rewritten))
+ #expect(report.problems.isEmpty, "problems: \(report.problems)")
+ }
+
+ /// Tatami resolves the pin against workspace ∪ shared ∪ borrowed apps, so
+ /// pinning the shared Monitor is legal and refusing it would block the seed.
+ @Test("accepts a shared app as a workspace's focus pin")
+ func acceptsSharedAppFocusPin() throws {
+ let text = try renderedConfig().replacingOccurrences(
+ of: "appToFocusBundleId = \"dev.PangMo5.DemoLab.Chat\"",
+ with: "appToFocusBundleId = \"dev.PangMo5.DemoLab.Monitor\""
+ )
+ let report = ConfigValidator.report(text)
+ #expect(report.problems.isEmpty, "problems: \(report.problems)")
+ #expect(report.warnings.contains { $0.contains("pins the shared app") })
+ }
+
+ @Test("still rejects a focus pin that is neither a member nor shared")
+ func rejectsStrayFocusPin() throws {
+ let text = try renderedConfig().replacingOccurrences(
+ // Editor is a real Demo Lab app, so this exercises the stray-pin rule on
+ // its own rather than also tripping the unknown-bundle warning.
+ of: "appToFocusBundleId = \"dev.PangMo5.DemoLab.Chat\"",
+ with: "appToFocusBundleId = \"dev.PangMo5.DemoLab.Editor\""
+ )
+ let problems = ConfigValidator.problems(in: text)
+ #expect(problems.contains { $0.contains("neither one of its apps nor a shared app") }, "got \(problems)")
+ }
+
+ @Test("accepts an empty settings table without inventing problems")
+ func toleratesMinimalConfig() {
+ let text = """
+ [settings.switching]
+ borrowDefaultEdge = "left"
+
+ [[profiles]]
+ id = "d0000000-0000-4000-8000-000000000001"
+ name = "Only"
+
+ [[profiles.workspaces]]
+ id = "11111111-0000-4000-8000-000000000001"
+ name = "One"
+ """
+ #expect(ConfigValidator.report(text).problems.isEmpty)
+ }
+
+}
+
+// MARK: - TomlLite
+
+@Suite("TomlLite")
+struct TomlLiteTests {
+
+ @Test("attaches a sub-table array to the last parent element")
+ func nestedTableArrays() throws {
+ let text = """
+ [[profiles]]
+ name = "A"
+
+ [[profiles.workspaces]]
+ name = "A1"
+
+ [[profiles.workspaces]]
+ name = "A2"
+
+ [[profiles]]
+ name = "B"
+
+ [[profiles.workspaces]]
+ name = "B1"
+ """
+ let profiles = try #require(TomlLite.parse(text)["profiles"]?.tableArray)
+ #expect(profiles.count == 2)
+ #expect(profiles[0]["workspaces"]?.tableArray?.count == 2)
+ #expect(profiles[1]["workspaces"]?.tableArray?.compactMap { $0["name"]?.stringValue } == ["B1"])
+ }
+
+ @Test("reads multi-line arrays, comments, escapes and inline tables")
+ func scalarsAndArrays() throws {
+ let text = #"""
+ # leading comment
+ name = "with \"quotes\" and # not a comment"
+ literal = 'raw \ text'
+ yes = true
+ count = 12
+ ratio = 0.34
+ ids = [
+ "one", # trailing comment
+ "two",
+ ]
+ env = { MODE = "desk", LEVEL = 2 }
+ backslash = "\\"
+ """#
+ let root = try TomlLite.parse(text)
+ #expect(root["name"]?.stringValue == "with \"quotes\" and # not a comment")
+ #expect(root["literal"]?.stringValue == #"raw \ text"#)
+ #expect(root["yes"]?.boolValue == true)
+ #expect(root["count"]?.intValue == 12)
+ #expect(root["ratio"]?.doubleValue == 0.34)
+ #expect(root["ids"]?.stringArray == ["one", "two"])
+ #expect(root["env"]?["MODE"]?.stringValue == "desk")
+ #expect(root["backslash"]?.stringValue == "\\")
+ }
+
+ @Test("rejects malformed input instead of guessing")
+ func rejectsGarbage() {
+ #expect(throws: TomlLiteError.self) { try TomlLite.parse("[unterminated") }
+ #expect(throws: TomlLiteError.self) { try TomlLite.parse("novalue") }
+ }
+
+}
diff --git a/DemoLab/Tests/DemoLabTests/LocalizationTests.swift b/DemoLab/Tests/DemoLabTests/LocalizationTests.swift
new file mode 100644
index 0000000..1cdfe89
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/LocalizationTests.swift
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import DemoCtlKit
+import Foundation
+import Testing
+
+@Suite("Localized demo contracts")
+struct LocalizationTests {
+ @Test("localized fixture data still demonstrates a failing draft and a passing revision", arguments: DemoLocale.allCases)
+ func localizedCopyChecks(locale: DemoLocale) {
+ var story = LaunchStory(locale: locale)
+ #expect(story.headline.count > 40)
+ #expect(story.evaluateCopy().filter(\.passed).count == 2)
+ story.headline = locale.string("A calmer way to work.")
+ #expect(story.evaluateCopy().allSatisfy { $0.passed })
+ if locale != .en { #expect(story.headline != "A calmer way to work.") }
+ }
+
+ @Test("every catalog entry has all five displayed values and matching placeholders")
+ func catalogCompleteness() throws {
+ let root = try #require(LabPaths.discoverPackageRoot())
+ let file = root.appendingPathComponent("Localization/Localizable.xcstrings")
+ let catalog = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any])
+ let strings = try #require(catalog["strings"] as? [String: [String: Any]])
+ let pattern = try NSRegularExpression(pattern: #"%(?:\d+\$)?(?:lld|ld|d|@)"#)
+ func placeholders(_ text: String) -> [String] {
+ pattern.matches(in: text, range: NSRange(text.startIndex..., in: text)).map {
+ String(text[Range($0.range, in: text)!]).replacingOccurrences(of: #"\d+\$"#, with: "", options: .regularExpression)
+ }.sorted()
+ }
+ for (key, entry) in strings {
+ let translations = try #require(entry["localizations"] as? [String: [String: Any]])
+ for locale in DemoLocale.allCases {
+ let unit = try #require(translations[locale.rawValue]?["stringUnit"] as? [String: String])
+ let text = try #require(unit["value"])
+ #expect(!text.isEmpty, "Missing \(locale.rawValue): \(key)")
+ #expect(placeholders(text) == placeholders(key), "Changed placeholders: \(key) / \(locale.rawValue)")
+ #expect(!text.contains("—"), "Displayed em dash: \(key) / \(locale.rawValue)")
+ }
+ }
+ }
+}
diff --git a/DemoLab/Tests/DemoLabTests/ShellTests.swift b/DemoLab/Tests/DemoLabTests/ShellTests.swift
new file mode 100644
index 0000000..6ee8641
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/ShellTests.swift
@@ -0,0 +1,76 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+import Testing
+
+@testable import DemoCtlKit
+
+// MARK: - Shell
+
+@Suite("Shell")
+struct ShellTests {
+
+ /// The bug this guards against cost two hours of a shoot.
+ ///
+ /// `democtl seed` launches Tatami and the overlay and then exits. When those
+ /// were plain `Process()` children they inherited `democtl`'s stdout, so a
+ /// shell running `democtl seed | tail` never saw EOF and the `&&` chain after
+ /// it never ran. Nothing failed; it simply hung forever.
+ ///
+ /// Writing to the log is the observable proof that the streams were replaced
+ /// rather than inherited.
+ @Test("launchDetached sends output to its log instead of inheriting stdout")
+ func detachedOutputGoesToTheLog() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("demolab-shell-\(ProcessInfo.processInfo.processIdentifier)")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let log = directory.appendingPathComponent("nested/detached.log")
+
+ let process = try Shell.launchDetached(
+ URL(fileURLWithPath: "/bin/sh"),
+ ["-c", "echo out; echo err 1>&2"],
+ log: log
+ )
+ process.waitUntilExit()
+
+ #expect(process.terminationStatus == 0)
+ let written = (try? String(contentsOf: log, encoding: .utf8)) ?? ""
+ #expect(written.contains("out"), "stdout was not redirected into the log")
+ #expect(written.contains("err"), "stderr was not redirected into the log")
+ }
+
+ /// A detached process must not hold a terminal it may not have.
+ @Test("launchDetached gives the child no stdin")
+ func detachedStdinIsClosed() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("demolab-shell-stdin-\(ProcessInfo.processInfo.processIdentifier)")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let log = directory.appendingPathComponent("stdin.log")
+
+ // `cat` reads to EOF. With the null device it sees EOF at once; with an
+ // inherited terminal it would block here forever.
+ let process = try Shell.launchDetached(
+ URL(fileURLWithPath: "/bin/sh"),
+ ["-c", "cat; echo drained"],
+ log: log
+ )
+ let finished = Shell.wait(timeout: .seconds(5)) { !process.isRunning }
+ #expect(finished, "a detached child was left waiting on stdin")
+ #expect(((try? String(contentsOf: log, encoding: .utf8)) ?? "").contains("drained"))
+ }
+
+ @Test("run drains both pipes, so a chatty child cannot deadlock it")
+ func runDrainsBothPipes() throws {
+ // More than a pipe buffer on each stream at once. Draining one fully before
+ // touching the other would wedge on whichever filled first.
+ let result = try Shell.run(
+ URL(fileURLWithPath: "/bin/sh"),
+ ["-c", "yes out | head -c 200000; yes err | head -c 200000 1>&2"]
+ )
+ #expect(result.succeeded)
+ #expect(result.standardOutput.count >= 200_000)
+ #expect(result.standardError.count >= 200_000)
+ }
+
+}
diff --git a/DemoLab/Tests/DemoLabTests/StoryTests.swift b/DemoLab/Tests/DemoLabTests/StoryTests.swift
new file mode 100644
index 0000000..6ab054c
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/StoryTests.swift
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import DemoAppKit
+import Foundation
+import Testing
+
+@Suite("Shared work across demo apps")
+struct StoryTests {
+ @Test("copy validation follows the saved content, not a staged success screen")
+ func checksFollowContent() {
+ var draft = LaunchStory(locale: .en)
+ #expect(draft.evaluateCopy().filter(\.passed).count == 2)
+ draft.headline = "A calmer way to work."
+ #expect(draft.evaluateCopy().allSatisfy { $0.passed })
+ draft.body = ""
+ #expect(draft.evaluateCopy().filter(\.passed).count == 1)
+ }
+
+ @Test("independent apps read the same saved draft, review and follow-up")
+ func persistence() throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let editor = StoryRepository(file: directory.appendingPathComponent("story.json"))
+ let reviewer = StoryRepository(file: editor.file)
+ var draft = LaunchStory(locale: .en)
+ draft.headline = "A calmer way to work."
+ try editor.write(draft)
+ var review = try reviewer.load()
+ #expect(review.headline == draft.headline)
+ review.checks = review.evaluateCopy()
+ review.approved = true
+ review.tasks.append(StoryTask(id: 3, text: "Add a screenshot", done: false))
+ try reviewer.write(review)
+ let reopened = try editor.load()
+ #expect(reopened.approved)
+ #expect(reopened.tasks.last?.text == "Add a screenshot")
+ #expect(reopened.checks.allSatisfy { $0.passed })
+ }
+
+ @Test("corrupt saved work fails instead of silently becoming the initial fixture")
+ func corruption() throws {
+ let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: file) }
+ try Data("not a draft".utf8).write(to: file)
+ #expect(throws: (any Error).self) { try StoryRepository(file: file).load() }
+ }
+}
diff --git a/DemoLab/Tests/DemoLabTests/SubtitleTests.swift b/DemoLab/Tests/DemoLabTests/SubtitleTests.swift
new file mode 100644
index 0000000..4300579
--- /dev/null
+++ b/DemoLab/Tests/DemoLabTests/SubtitleTests.swift
@@ -0,0 +1,163 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import DemoAppKit
+@testable import DemoCtlKit
+import Foundation
+import Testing
+
+// MARK: - KeyChordFormatterTests
+
+@Suite("Key chords")
+struct KeyChordFormatterTests {
+
+ @Test("reads Tatami's own spelling into caps, in keyboard order")
+ func caps() {
+ #expect(KeyChordFormatter.caps(from: "ctrl + alt - l") == ["⌃", "⌥", "L"])
+ // Config order is whatever the author typed; the caps are always canonical.
+ #expect(KeyChordFormatter.caps(from: "cmd + shift + ctrl - 3") == ["⌃", "⇧", "⌘", "3"])
+ #expect(KeyChordFormatter.caps(from: "alt - return") == ["⌥", "↩"])
+ #expect(KeyChordFormatter.caps(from: "f1") == ["F1"])
+ #expect(KeyChordFormatter.caps(from: " ").isEmpty)
+ }
+
+ @Test("finds the separator from the end, so a literal dash is still a key")
+ func literalDash() {
+ #expect(KeyChordFormatter.caps(from: "ctrl + alt - -") == ["⌃", "⌥", "-"])
+ }
+
+ @Test("spells long-form modifiers the same as short ones")
+ func synonyms() {
+ #expect(
+ KeyChordFormatter.caps(from: "control + option + command - k")
+ == KeyChordFormatter.caps(from: "ctrl + alt + cmd - k")
+ )
+ }
+
+}
+
+// MARK: - SubtitleTests
+
+@MainActor
+@Suite("Subtitles")
+struct SubtitleTests {
+
+ @Test("the sidecar carries what the overlay did not draw")
+ func complement() {
+ let timeline = SceneTimeline(scene: "t", t0: .now)
+ timeline.record(.chapter, text: "Chapter", at: 0)
+ timeline.record(.caption, text: "Headline | why", at: 1)
+ timeline.record(.keys, text: "ctrl + alt - l", at: 2)
+ timeline.finish(at: 3)
+
+ // A take shot with `--overlay keys` has the caps in its pixels already.
+ let burnable = SceneSubtitles.ass(timeline, resolution: .fallback, drawnLive: [.keys])
+ #expect(!burnable.contains("Keys,,"))
+ #expect(burnable.contains("Caption,,"))
+ #expect(burnable.contains("Chapter,,"))
+
+ // With the overlay off, the sidecar is the whole narration.
+ let complete = SceneSubtitles.ass(timeline, resolution: .fallback)
+ #expect(complete.contains("Keys,,"))
+ }
+
+ @Test("keycaps are burned as caps, never as the config spelling")
+ func keycaps() {
+ let timeline = SceneTimeline(scene: "t", t0: .now)
+ timeline.record(.keys, text: "ctrl + alt - l", at: 0)
+ timeline.finish(at: 2)
+
+ let ass = SceneSubtitles.ass(timeline, resolution: .fallback)
+ #expect(ass.contains("⌃ ⌥ L"))
+ #expect(!ass.contains("ctrl + alt - l"))
+ }
+
+ @Test("a two-line caption stays in one overlay event")
+ func captionIsOneEvent() {
+ let timeline = SceneTimeline(scene: "t", t0: .now)
+ timeline.record(.caption, text: "Headline\(SceneTimeline.captionSeparator)why it matters", at: 0)
+ timeline.finish(at: 2)
+
+ let ass = SceneSubtitles.ass(timeline, resolution: .fallback)
+ let captions = ass.split(separator: "\n").filter { $0.contains(",Caption,,") }
+ #expect(captions.count == 1)
+ let event = String(captions[0])
+ #expect(event.contains("Headline"))
+ #expect(event.contains("why it matters"))
+ // `\r` restarts the style mid-event, and with it the box: that is what put
+ // a step in the card. Inline overrides keep one box.
+ #expect(!event.contains("\\r"))
+
+ // One translucent backing protects both lines over light or dark apps.
+ #expect(styleField("BorderStyle", of: "Caption", in: ass) == "3")
+ #expect(styleField("Outline", of: "Caption", in: ass) == "10")
+ }
+
+ @Test("a clear ends the words rather than writing an empty line")
+ func clears() {
+ let timeline = SceneTimeline(scene: "t", t0: .now)
+ timeline.record(.caption, text: "Headline", at: 0)
+ timeline.record(.caption, text: "", at: 1)
+ timeline.finish(at: 2)
+
+ let ass = SceneSubtitles.ass(timeline, resolution: .fallback)
+ #expect(ass.split(separator: "\n").filter { $0.hasPrefix("Dialogue:") }.count == 1)
+ }
+
+ // MARK: Private
+
+ /// Reads a `Style:` row through its own `Format:` header rather than by a
+ /// counted offset, because the offset is exactly the thing that goes wrong
+ /// when a column is added.
+ private func styleField(_ column: String, of style: String, in ass: String) -> String? {
+ let rows = ass.split(separator: "\n", omittingEmptySubsequences: false)
+ guard
+ let format = rows.first(where: { $0.hasPrefix("Format: Name,") }),
+ let row = rows.first(where: { $0.hasPrefix("Style: \(style),") })
+ else { return nil }
+ let columns = format.dropFirst("Format: ".count)
+ .components(separatedBy: ",")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ let values = row.dropFirst("Style: ".count).components(separatedBy: ",")
+ guard let index = columns.firstIndex(of: column), values.indices.contains(index) else { return nil }
+ return values[index]
+ }
+
+}
+
+// MARK: - EscapingTests
+
+@Suite("ASS escaping")
+struct EscapingTests {
+
+ @Test("a literal backslash stays one backslash")
+ func backslash() {
+ // The `\` key's own keycast. Doubling it, which is what most formats want,
+ // put two backslashes on screen.
+ #expect(SceneSubtitles.escaped("\\") == "\\")
+ #expect(SceneSubtitles.escaped("⌃ ⌥ ⇧ \\") == "⌃ ⌥ ⇧ \\")
+ }
+
+ @Test("a backslash cannot turn the text after it into a command")
+ func neutralisesCommands() {
+ // `\N` is a hard break, `\h` a hard space: text holding either must not
+ // become one.
+ #expect(SceneSubtitles.escaped("C:\\next") == "C:\\{}next")
+ #expect(SceneSubtitles.escaped("a\\Nb") == "a\\{}Nb")
+ #expect(SceneSubtitles.escaped("a\\hb") == "a\\{}hb")
+ }
+
+ @Test("braces cannot open an override block")
+ func braces() {
+ // The point is the braces: whatever the renderer makes of the rest, it can
+ // no longer be read as styling.
+ #expect(SceneSubtitles.escaped("{\\an8}") == "\\{\\an8\\}")
+ }
+
+ @Test("a real newline becomes the hard break, and survives the pass above")
+ func newlines() {
+ #expect(SceneSubtitles.escaped("a\nb") == "a\\Nb")
+ #expect(SceneSubtitles.escaped("a\r\nb") == "a\\Nb")
+ }
+
+}
diff --git a/DemoLab/config/example-proposal.json b/DemoLab/config/example-proposal.json
new file mode 100644
index 0000000..df4afc9
--- /dev/null
+++ b/DemoLab/config/example-proposal.json
@@ -0,0 +1,33 @@
+{
+ "workspaces": [
+ {
+ "name": "Design review",
+ "kind": "workspace",
+ "bundleIdentifiers": [
+ "dev.PangMo5.DemoLab.Canvas",
+ "dev.PangMo5.DemoLab.Docs"
+ ]
+ },
+ {
+ "name": "Writing",
+ "kind": "workspace",
+ "bundleIdentifiers": [
+ "dev.PangMo5.DemoLab.Editor"
+ ]
+ },
+ {
+ "name": "Conversation",
+ "kind": "workspace",
+ "bundleIdentifiers": [
+ "dev.PangMo5.DemoLab.Chat"
+ ]
+ },
+ {
+ "name": "Quick notes",
+ "kind": "scratchpad",
+ "bundleIdentifiers": [
+ "dev.PangMo5.DemoLab.Notes"
+ ]
+ }
+ ]
+}
diff --git a/DemoLab/config/tatami-demo.toml.in b/DemoLab/config/tatami-demo.toml.in
new file mode 100644
index 0000000..52ee2fb
--- /dev/null
+++ b/DemoLab/config/tatami-demo.toml.in
@@ -0,0 +1,532 @@
+# SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+#
+# Tatami Demo Lab configuration template.
+#
+# `democtl seed` renders this into the lab's ISOLATED config home
+# (.build/lab/xdg/tatami/config.toml) and starts Tatami with
+# `--tatami-config-home` / `--tatami-socket-path`, so your real
+# ~/.config/tatami is never read or written.
+#
+# Placeholder tokens (an at-sign, an uppercase name, an at-sign) are
+# substituted by ConfigRenderer. Everything else in this file is literal.
+# Tatami rewrites the rendered file on its first launch with sorted keys and
+# no comments — that is expected; this template is the source of truth, the
+# rendered file is disposable.
+#
+# Every key below was checked against Tatami's decoder, not against memory.
+# The traps that matter here:
+# * Unknown keys are silently ignored. A typo costs you a whole feature with
+# no warning anywhere, so `democtl seed` re-parses the rendered file and
+# checks every key name against ConfigValidator's per-table allow-list,
+# copied from Tatami's CodingKeys. A key Tatami would drop fails the seed.
+# * `autoOpen` is decoded with a bare `try?`, so `autoOpen = "true"` means
+# "do not open" with no banner and the take records an empty desktop. Every
+# app row below therefore carries a bare true/false, and seed rejects
+# anything else — including a missing one.
+# * A hand-authored config gets the *decode* defaults, not the richer
+# "recommended" set a fresh install is seeded with. The three modifier
+# arrays are therefore written out explicitly.
+# * `borrowDefaultEdge` is unset by default, and without it a CLI borrow arms
+# a keyboard-only direction picker that cancels itself after 5 seconds. It
+# is set here on purpose: it is what makes Borrow scriptable.
+
+[settings.general]
+launchAtLogin = false
+# No update check during a take: a Sparkle dialog on camera ruins the shot.
+checkForUpdatesAutomatically = false
+checkInterval = "weekly"
+debugLogging = @DEBUG_LOGGING@
+
+[settings.visibility]
+# The Demo Lab's narration overlay owns persistent elevated controls: a chapter
+# pill, a caption card and keycaps that have to stay above the tiles for a whole
+# take. `overlayAwareApps` is Tatami's feature for exactly that kind of app, so
+# its windows are kept out of focus, out of window cycling, out of every layout
+# and out of workspace membership instead of being tiled like an ordinary window.
+overlayAwareApps = ["dev.PangMo5.DemoLab.Overlay"]
+
+[settings.menuBar]
+showWorkspaceIcon = true
+showWorkspaceName = true
+showProfileIcon = true
+# On for the demo: the profile name in the menu bar is how a viewer sees the
+# Desk/Laptop switch happen.
+showProfileName = true
+
+[settings.hud]
+enabled = true
+workspaceSwitch = true
+windowCycle = true
+profileSwitch = true
+floating = true
+appMembership = true
+tilingPaused = true
+fullscreen = true
+borrow = true
+layout = true
+position = "top"
+# Tatami's own feedback is a feature these takes demonstrate, so it has to be
+# legible on a recording rather than merely present. "large" is the biggest of
+# small/default/large, and 1800 ms sits inside the 300...3000 range the app's
+# own UI offers, so a switch, a borrow or a zoom can be read at normal speed.
+# The narration overlay's chapter pill is kept out of the top centre, so this
+# has that space to itself.
+size = "large"
+durationMs = 1800
+
+[settings.layout]
+gapInner = 10
+gapOuter = 12
+autoBalance = "none"
+splitType = "auto"
+windowPlacement = "second"
+
+[settings.focus]
+mouseFollowsFocus = @MOUSE_FOLLOWS_FOCUS@
+mouseHidesOnFocus = false
+focusFollowsMouse = @FOCUS_FOLLOWS_MOUSE@
+focusFollowsMouseIgnoreFullscreen = true
+focusFollowsMouseDisableHotkey = "Alt"
+refocusOnClose = true
+
+[settings.switching]
+loop = true
+skipEmpty = false
+followAppFocus = true
+cycleAcrossDisplays = false
+recentAcrossDisplays = true
+switchToRecentWhenEmpty = false
+cycleSameAppWindows = false
+includeSharedAppsInWindowSwitcher = true
+toggleBorrowOnRepeat = true
+# Required for a scriptable Borrow. See the note at the top of this file.
+borrowDefaultEdge = "@BORROW_EDGE@"
+borrowFraction = 0.34
+
+[settings.gestures]
+# Off: a VM has no trackpad, and a stray swipe mid-take is unrecoverable.
+enabled = false
+threshold = 0.3
+
+[settings.gestures.threeFinger]
+left = "nextWorkspace"
+right = "previousWorkspace"
+up = "none"
+down = "none"
+
+[settings.gestures.fourFinger]
+left = "none"
+right = "none"
+up = "none"
+down = "none"
+
+[settings.marker]
+fullscreenEnabled = true
+fullscreenColorHex = "#007AFF"
+floatingEnabled = true
+floatingColorHex = "#FF9500"
+borrowEnabled = true
+borrowColorHex = "#AF52DE"
+size = 14
+corner = "bottomTrailing"
+hideOnHover = true
+
+[settings.shortcuts]
+# The three modifier arrays that drive every workspace key equivalent. Written
+# out because the fallback for a hand-authored file is NOT the recommended set.
+keyEquivalentModifiers = ["ctrl", "alt", "shift"]
+assignModifiers = ["alt", "shift", "cmd"]
+borrowModifiers = ["ctrl", "alt", "cmd"]
+recentWorkspaceKey = "\\"
+nextWorkspaceKey = "."
+previousWorkspaceKey = ","
+
+focusLeft = "ctrl + alt - h"
+focusDown = "ctrl + alt - j"
+focusUp = "ctrl + alt - k"
+focusRight = "ctrl + alt - l"
+
+swapLeft = "ctrl + alt - left"
+swapDown = "ctrl + alt - down"
+swapUp = "ctrl + alt - up"
+swapRight = "ctrl + alt - right"
+
+resizeGrow = "ctrl + alt - ="
+resizeShrink = "ctrl + alt - -"
+toggleOrientation = "ctrl + alt - s"
+toggleFullscreen = "ctrl + alt - return"
+balance = "ctrl + alt - e"
+
+cycleNextWindow = "alt - tab"
+cyclePreviousWindow = "alt + shift - tab"
+
+moveToPreviousWorkspace = "ctrl + alt + shift - ["
+moveToNextWorkspace = "ctrl + alt + shift - ]"
+focusPreviousDisplay = "ctrl + alt + shift - left"
+focusNextDisplay = "ctrl + alt + shift - right"
+
+toggleFloating = "alt + cmd - return"
+toggleSharedFloating = "alt + shift + cmd - return"
+toggleFocusedAppInActiveWorkspace = "ctrl + alt - /"
+toggleAppInSharedApps = "ctrl + alt + shift - /"
+toggleSpaceActivated = "ctrl + alt + shift + cmd - z"
+dismissBorrow = "ctrl + alt + cmd - /"
+
+# ---------------------------------------------------------------------------
+# Shared apps: part of every workspace in every profile.
+# ---------------------------------------------------------------------------
+
+# Monitor is the small utility that demonstrates Always on Top (layout =
+# "floating", mirrored above the tiles) or Leave As Is (layout = "unmanaged",
+# left exactly where it is and never touched). Always on Top needs the Screen
+# Recording permission because the mirror is a ScreenCaptureKit capture;
+# "unmanaged" needs no permission at all. The seed option picks the mode.
+#
+# The token below is still spelled PULSE_LAYOUT because that is the name
+# ConfigRenderer substitutes. Renaming it on one side only makes `democtl seed`
+# stop on "unresolved placeholder", which is the loud failure, not a silent one.
+[[sharedApps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Monitor"
+name = "Monitor"
+layout = "@PULSE_LAYOUT@"
+autoOpen = false
+
+# ---------------------------------------------------------------------------
+# Hooks: the scene runner's completion barrier.
+#
+# Only `profile activate` and `workspace activate` block until the activation
+# pipeline finishes; every other command returns "accepted" the instant it is
+# enqueued and says nothing about whether a window moved. These hooks append a
+# line per real transition, which is what `democtl` actually waits on.
+# ---------------------------------------------------------------------------
+
+[[hooks]]
+id = "demolab-workspace"
+event = "workspaceActivated"
+enabled = true
+command = ["@HOOK@", "event-log", "@EVENT_LOG@"]
+timeoutMs = 3000
+
+[[hooks]]
+id = "demolab-profile"
+event = "profileChanged"
+enabled = true
+command = ["@HOOK@", "event-log", "@EVENT_LOG@"]
+timeoutMs = 3000
+
+[[hooks]]
+id = "demolab-launch"
+event = "tatamiLaunched"
+enabled = true
+command = ["@HOOK@", "event-log", "@EVENT_LOG@"]
+timeoutMs = 3000
+
+[[hooks]]
+id = "demolab-status-workspaceActivated"
+event = "workspaceActivated"
+enabled = true
+command = ["@STATUS_BINARY@", "status", "@CONTROL_DIR@"]
+timeoutMs = 3000
+
+[[hooks]]
+id = "demolab-status-profileChanged"
+event = "profileChanged"
+enabled = true
+command = ["@STATUS_BINARY@", "status", "@CONTROL_DIR@"]
+timeoutMs = 3000
+
+[[hooks]]
+id = "demolab-status-hud"
+event = "hud"
+enabled = true
+command = ["@STATUS_BINARY@", "status", "@CONTROL_DIR@"]
+timeoutMs = 3000
+
+# ===========================================================================
+# Profile: Laptop — one display, everything on it.
+# ===========================================================================
+
+[[profiles]]
+id = "d0000000-0000-4000-8000-000000000001"
+name = "Laptop"
+symbolIconName = "laptopcomputer"
+shortcut = "ctrl + alt + shift + cmd - 1"
+
+# Auto-activation by display *count* only. Naming a display would bind the demo
+# to one machine's monitor, and a VM's virtual display name is undocumented and
+# locale-dependent. Count is portable everywhere.
+[profiles.autoActivation]
+displayCount = "==1"
+
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000001"
+name = "Write"
+symbolIconName = "doc.text"
+kind = "normal"
+keyEquivalent = "1"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Editor"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Editor"
+name = "Editor"
+autoOpen = true
+layout = "tiled"
+
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Docs"
+name = "Docs"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000002"
+name = "Review"
+symbolIconName = "checkmark.bubble"
+kind = "normal"
+keyEquivalent = "2"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Review"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Review"
+name = "Review"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Docs"
+name = "Docs"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000003"
+name = "Chat"
+symbolIconName = "bubble.left.and.bubble.right"
+kind = "normal"
+keyEquivalent = "3"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Chat"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Chat"
+name = "Chat"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000004"
+name = "Build"
+symbolIconName = "terminal"
+kind = "normal"
+keyEquivalent = "4"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Terminal"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Terminal"
+name = "Terminal"
+autoOpen = true
+layout = "tiled"
+
+# Borrow-only. A scratchpad is excluded from ordinary switching, never
+# activates on its own, is rejected by `tatami workspace activate`, and forces
+# autoOpen on its apps when it is summoned.
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000005"
+name = "Notes"
+symbolIconName = "square.and.pencil"
+kind = "scratchpad"
+keyEquivalent = "n"
+borrowEdge = "right"
+borrowFraction = 0.3
+appToFocusBundleId = "dev.PangMo5.DemoLab.Notes"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Notes"
+name = "Notes"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000006"
+name = "Design"
+symbolIconName = "square.stack.3d.up"
+kind = "normal"
+keyEquivalent = "5"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Canvas"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Canvas"
+name = "Canvas"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Docs"
+name = "Docs"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "11111111-0000-4000-8000-000000000007"
+name = "Focus"
+symbolIconName = "scope"
+kind = "normal"
+keyEquivalent = "6"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Canvas"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Canvas"
+name = "Canvas"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Notes"
+name = "Notes"
+autoOpen = true
+layout = "floating"
+
+# ===========================================================================
+# Profile: Desk — an external display is connected.
+#
+# Each display keeps its own active workspace. Rather than pinning workspaces
+# to display names (host-specific, and unavailable in a VM), the two chains
+# below place a companion workspace on the next free display. The workspace you
+# actually switch to claims the display under the pointer and keeps focus, so
+# `democtl` parks the cursor first and the placement is deterministic.
+#
+# To pin hard instead, run `democtl displays`, then add a `displayHint` line to
+# a workspace using the exact "::" string it prints.
+# ===========================================================================
+
+[[profiles]]
+id = "d0000000-0000-4000-8000-000000000002"
+name = "Desk"
+symbolIconName = "display.2"
+shortcut = "ctrl + alt + shift + cmd - 2"
+
+[profiles.autoActivation]
+displayCount = ">=2"
+
+[[profiles.workspaceChains]]
+id = "33333333-0000-4000-8000-000000000001"
+name = "Deep work"
+workspaceIds = [
+ "22222222-0000-4000-8000-000000000001",
+ "22222222-0000-4000-8000-000000000003",
+]
+
+[[profiles.workspaceChains]]
+id = "33333333-0000-4000-8000-000000000002"
+name = "Review pass"
+workspaceIds = [
+ "22222222-0000-4000-8000-000000000002",
+ "22222222-0000-4000-8000-000000000004",
+]
+
+[[profiles.workspaceChains]]
+id = "33333333-0000-4000-8000-000000000003"
+name = "Design desk"
+workspaceIds = ["22222222-0000-4000-8000-000000000006", "22222222-0000-4000-8000-000000000007"]
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000006"
+name = "Design"
+symbolIconName = "square.stack.3d.up"
+kind = "normal"
+keyEquivalent = "5"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Canvas"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Canvas"
+name = "Canvas"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000007"
+name = "Reference"
+symbolIconName = "book.closed"
+kind = "normal"
+keyEquivalent = "7"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Docs"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Docs"
+name = "Docs"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000001"
+name = "Write"
+symbolIconName = "doc.text"
+kind = "normal"
+keyEquivalent = "1"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Editor"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Editor"
+name = "Editor"
+autoOpen = true
+layout = "tiled"
+
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000002"
+name = "Review"
+symbolIconName = "checkmark.bubble"
+kind = "normal"
+keyEquivalent = "2"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Review"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Review"
+name = "Review"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000003"
+name = "Build"
+symbolIconName = "terminal"
+kind = "normal"
+keyEquivalent = "3"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Terminal"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Terminal"
+name = "Terminal"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000004"
+name = "Chat"
+symbolIconName = "bubble.left.and.bubble.right"
+kind = "normal"
+keyEquivalent = "4"
+appToFocusBundleId = "dev.PangMo5.DemoLab.Chat"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Chat"
+name = "Chat"
+autoOpen = true
+layout = "tiled"
+
+[[profiles.workspaces]]
+id = "22222222-0000-4000-8000-000000000005"
+name = "Notes"
+symbolIconName = "square.and.pencil"
+kind = "scratchpad"
+keyEquivalent = "n"
+borrowEdge = "right"
+borrowFraction = 0.3
+appToFocusBundleId = "dev.PangMo5.DemoLab.Notes"
+
+[[profiles.workspaces.apps]]
+bundleIdentifier = "dev.PangMo5.DemoLab.Notes"
+name = "Notes"
+autoOpen = true
+layout = "tiled"
diff --git a/DemoLab/docs/COVERAGE.md b/DemoLab/docs/COVERAGE.md
new file mode 100644
index 0000000..27b30f4
--- /dev/null
+++ b/DemoLab/docs/COVERAGE.md
@@ -0,0 +1,80 @@
+
+[English](COVERAGE.md) · [한국어](ko/COVERAGE.md) · [日本語](ja/COVERAGE.md) · [简体中文](zh-Hans/COVERAGE.md) · [繁體中文](zh-Hant/COVERAGE.md)
+
+
+# Demo coverage and verification
+
+The publication inventory contains 27 scenarios: a complete workflow and 26
+focused demonstrations. Every supported locale has its own app language,
+fixture content, typed input, captions and capture assertions. Accepted takes
+are selected by locale, recorded actions and capture quality, then exported separately.
+
+## Captured coverage
+
+| Collection | Films | Demonstrated behavior |
+| --- | --- | --- |
+| Workspaces | 3 | Direct/recent/ordered switching, auto-open/reopen, app membership moves. |
+| Profiles and displays | 5 | Profile switching, GUI profile duplication, selective workspace copying, two-screen chains, cross-display focus, automatic profile activation on connect/disconnect. |
+| Tiling and focus | 7 | Insert/close, swap, split orientation, resize/balance, drag, workspace zoom, directional focus, MFF/FFM, held/quick window switcher, native visual layout editor. |
+| Borrow | 3 | Conversation alongside work, persistent scratchpad, edge selection, cross-block focus, full activation and return. |
+| Window modes | 3 | Per-workspace Always on Top, Shared Always on Top, interactive mirrors, Leave As Is and its inclusion in the window switcher. |
+| Automation | 3 | Real CLI output, a multi-command focus-session script, lifecycle/HUD hooks, atomic TOML edits with live reload. |
+| Guided Setup | 2 | Built-in gesture previews and real keyboard practice; validation and application of an example AI proposal to the draft. |
+
+The hero ties design/theme selection, actual PNG export, saved writing, review
+checks/approval, Borrow, shared status, CLI automation and display-aware profile
+changes into one continuous task. Canvas, Editor, Docs, Review, Chat, Notes,
+Terminal and Monitor are interactive local fixtures. Tatami performs the window
+management; hook and CLI outputs are produced by actual processes.
+
+## Evidence and acceptance
+
+Every accepted take must pass its real action/state/layout assertions, match the
+current localized capture actions and timing, and stay below the 1% frame-drop threshold.
+Text-only revisions preserve the source scene hash and add the edited scene hash.
+Exports check the whole MP4, H.264/yuv420p encoding, editorial duration/size
+budgets, first-caption timing and the shared movie/timeline clock.
+
+The delivery's `evidence/` directory records the actual results: frozen scene
+JSON, capture metadata, subtitles, timing, sampled frames, OCR and browser
+verification. Use those reports for a specific batch's counts and review status.
+Do not treat an old report as evidence for a new language or take.
+
+Film colors come from the website dark palette. Caption fonts are selected for
+the recorded language. Media URLs include the delivery hash, so a replacement
+movie and its poster cannot silently mix with the previous generation.
+
+## Boundaries and an unresolved reproduction
+
+- **Native macOS fullscreen return:** the original test left Canvas and Docs at
+ full-workspace bounds after exiting native fullscreen. Cause is not isolated.
+ `scenes/native-fullscreen.json` and the failed `recordings/v4-edge1/fullscreen*`
+ preserve the reproduction. It is excluded from publication. The film named
+ `fullscreen` demonstrates Tatami workspace zoom/restore, which passed.
+- **Physical gestures:** Guided Setup uses its explicit gesture-preview buttons
+ and real keyboard shortcuts. Physical three/four-finger recognition is not
+ claimed from the VM.
+- **AI:** the proposal is a labelled local example. No external AI request is
+ made. The live TOML was checked after the take and still contained the original
+ workspace names; the example was applied only to the draft.
+- **Multiple monitors:** two guest-side CoreGraphics displays were enumerated,
+ managed and independently captured, then aligned by timestamp. This does not
+ establish physical dock, mixed-DPI, HDR or cable behavior. The private helper
+ lives only in Demo Lab and needs validation on another guest OS version.
+- **Coverage:** the collection covers the main interactions above. It is not
+ exhaustive QA for every preference, language, update flow or hardware variant.
+
+## Capture defects corrected
+
+The driver now preserves numeric-pad/function identity on arrow events. An A/B
+probe on the same registered hotkey isolated that missing input metadata.
+[Apple's numericPad documentation](https://developer.apple.com/documentation/appkit/nsevent/modifierflags-swift.struct/numericpad)
+describes arrow-key identity. Native controls are scrolled into view before
+clicks; covered normal-stacking windows are selected through Tatami's real
+switcher. Source sync and fetch use unique archives to remove virtiofs filename
+reuse, and fetch verifies guest/host hashes.
+
+The old claim that a macOS VM could never have a second display was incorrect.
+The working guest-side API is documented in
+[DeskPad's CoreGraphics interface](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h).
+See [MULTI-DISPLAY.md](MULTI-DISPLAY.md) for the actual setup and reproduction.
diff --git a/DemoLab/docs/MULTI-DISPLAY.md b/DemoLab/docs/MULTI-DISPLAY.md
new file mode 100644
index 0000000..2c76a0f
--- /dev/null
+++ b/DemoLab/docs/MULTI-DISPLAY.md
@@ -0,0 +1,66 @@
+
+[English](MULTI-DISPLAY.md) · [한국어](ko/MULTI-DISPLAY.md) · [日本語](ja/MULTI-DISPLAY.md) · [简体中文](zh-Hans/MULTI-DISPLAY.md) · [繁體中文](zh-Hant/MULTI-DISPLAY.md)
+
+
+# Multiple displays inside the recording VM
+
+The macOS 26.6.2 Tart guest can create an additional **guest-side CoreGraphics
+virtual display**. This is different from adding a second graphics display to
+the host's `VZMacGraphicsDeviceConfiguration`.
+
+The previous document incorrectly treated the VZ hardware-display limit as a
+limit on every display a guest can create. The guest-side route has now been
+verified: macOS enumerated two active 1920×1200 displays, Tatami placed Review on
+the primary and Chat on the secondary through a workspace chain, and
+ScreenCaptureKit recorded both screens into independent playable movies.
+
+## Reproduce
+
+Inside the dedicated guest:
+
+```sh
+.build/tools/tatami-tools build-virtual-display
+.build/DemoLab/bin/democtl display connect
+.build/DemoLab/bin/democtl displays
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl take desk --display all --output recordings/desk.mov
+.build/DemoLab/bin/democtl quit
+.build/DemoLab/bin/democtl display disconnect
+```
+
+The helper uses private `CGVirtualDisplay` interfaces only in Demo Lab, never in
+the Tatami app. Its API reference is
+[DeskPad's CoreGraphics bridge](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h).
+It creates a 1920×1200 non-HiDPI screen and remains alive until disconnected or
+its bounded lifetime ends. The controller checks the PID's executable before
+signalling it. A missing or unsupported helper fails explicitly.
+
+## Synchronization and composition
+
+`--display all` writes one original per display. Each output records its actual
+first-frame offset against the common monotonic clock, display origin and its
+own frame/drop counts. After fetching the batch to the host:
+
+```sh
+Tools/.build/release/tatami-tools compose-displays DemoLab/recordings desk
+```
+
+The compositor orders screens by physical origin and aligns the streams using
+those recorded offsets. It retains the two originals and creates a lossless
+intermediate. The exporter presents it as a wide two-screen video with the same
+Tatami palette as the single-screen films. It does not invent the second screen
+by duplicating or animating a screenshot.
+
+## Hotplug is a separate scene
+
+The hotplug scene records the main screen continuously. Once the second virtual
+display is connected, a separate recorder captures it until just before removal.
+Both streams use their actual first-frame clocks. The compositor places them
+side by side and leaves the second region blank outside its captured interval.
+It never duplicates the main screen or keeps a frozen screen after disconnection.
+The capture manifest retains both source hashes and the secondary interval.
+
+Physical monitor cables, docking hardware, HDR behavior, mixed DPI hardware,
+and actual trackpad gesture recognition remain separate hardware checks.
+Private CoreGraphics compatibility must be rechecked on a new guest OS version.
diff --git a/DemoLab/docs/PERMISSIONS.md b/DemoLab/docs/PERMISSIONS.md
new file mode 100644
index 0000000..74c39d6
--- /dev/null
+++ b/DemoLab/docs/PERMISSIONS.md
@@ -0,0 +1,50 @@
+
+[English](PERMISSIONS.md) · [한국어](ko/PERMISSIONS.md) · [日本語](ja/PERMISSIONS.md) · [简体中文](zh-Hans/PERMISSIONS.md) · [繁體中文](zh-Hant/PERMISSIONS.md)
+
+
+# Capture permissions
+
+Check permissions through the actual launch path used for the take. A recorder
+passing preflight does not establish that Tatami can tile or mirror windows.
+
+| Process | Needed for | Evidence |
+| --- | --- | --- |
+| Tatami | Accessibility: move and focus real windows | Visible workspace changes and layout assertions |
+| Tatami | Screen Recording: Always on Top mirrors | A shared monitor remains visible over a zoomed window |
+| democtl / its launcher | Accessibility: keyboard, pointer, native controls | Real input and control-value assertions |
+| DemoRecorder / its launcher | Screen Recording: capture the display | First encoded frame, finalized movie and decode validation |
+
+Use System Settings → Privacy & Security to grant missing permissions in the
+**dedicated recording VM**. Relaunch the relevant processes after changing a
+grant, then run `democtl doctor` and a short take again. Do not alter the host's
+permissions or its personal Tatami configuration for a VM recording.
+
+`record start` selects direct launch when its caller already has capture access,
+otherwise LaunchServices. In the current Tart guest, `tart exec` runs through a
+pre-authorized agent. This is an observed property of that image and launch path;
+it is not a promise about every guest or every rebuilt executable.
+
+## A window can outlive the process that requested it
+
+The old tour contained a `Screen Recording` dialog asking for **Tatami** access.
+It was left behind a stack of app windows and appeared when those windows closed.
+The earlier recorder-only preflight did not detect it. It was not evidence of a
+periodic recorder reminder.
+
+The capture gate now rejects visible permission/settings windows even when they
+are occluded by another app. Resolve the identified dialog in the guest before
+retrying. Closing a stale request does not grant access and must not be reported
+as granting access. If the current run needs a missing grant, make that grant in
+System Settings and test the relevant behavior.
+
+A throwaway `democtl record warmup` exercises only the recorder. To exercise
+Tatami's separate ScreenCaptureKit path, rehearse the `shared` scene too. Do not
+crop permission UI out of footage or label an unverified take successful.
+
+## Repeatable recording image
+
+Build the exact tools and app bundles, resolve their permissions, verify the
+actual workload, then preserve that VM image. Rebuilding or changing the launcher
+can change permission attribution; verify again rather than copying database
+rows or assuming an old grant still applies. The former direct-TCC-database
+modification script has been removed from the lab.
diff --git a/DemoLab/docs/SCENES.md b/DemoLab/docs/SCENES.md
new file mode 100644
index 0000000..db2a7d2
--- /dev/null
+++ b/DemoLab/docs/SCENES.md
@@ -0,0 +1,111 @@
+
+[English](SCENES.md) · [한국어](ko/SCENES.md) · [日本語](ja/SCENES.md) · [简体中文](zh-Hans/SCENES.md) · [繁體中文](zh-Hant/SCENES.md)
+
+
+# Scenes and acceptance
+
+A scene requires `name`, `title`, and `steps`. Optional fields are `summary`,
+`requires`, `setup`, `openingApps`, and `captureSecondary`. `setup` executes
+before recording; `steps` are the visible story. Their actions drive the
+installed Tatami and native fixture apps.
+
+Set `captureSecondary` to `true` for a hotplug scene that records the main
+display continuously and captures the second display only while it is connected.
+
+```sh
+.build/DemoLab/bin/democtl scene tour --dry-run
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+```
+
+## Preparation
+
+Declare the apps that must be visible at frame zero in `openingApps`. The declared app multiset must
+match ordinary visible windows, including intentional duplicate references.
+`autoopen` declares an empty array because its first action opens the workspace.
+The recording gate checks this after the geometry has settled for 400 ms.
+
+Warm up workspaces through Tatami before rolling. Do not combine pending
+Auto-open launches with manual relaunches of the same apps: that can create two
+processes and two windows with one bundle identifier. A failed opening check is
+a reason to correct preparation, not to trim away the unexpected window later.
+
+## Real actions
+
+| Kind | Fields | Effect |
+| --- | --- | --- |
+| `click` | `app`, `identifier` | Resolve a native AX control; move the pointer and click. |
+| `typeText` | `app`, `text`, `ms?` | Type one character at a time into the expected focused app. |
+| `hover` | `app`, `identifier` | Reveal a native control in its scroll viewport and move the pointer to it. |
+| `scroll` | `app`, `identifier`, `pixels` | Move to a native control and scroll. |
+| `key` | `chord`, `repeats?`, `holdMs?` | Press a real shortcut and generate its own keycast. |
+| `hold` | `modifiers`, `keys`, `gapMs?`, `releaseAfterMs?` | Hold the real modifier through a switcher interaction. |
+| `activateApp` | `app` | Click the visible title bar, then verify the focused app through AX. A covered window needs a real switching action. |
+| `activateWorkspace` | `workspace`, `profile?` | Run Tatami's blocking activation command. |
+| `activateProfile` | `profile` | Run Tatami's blocking profile command. |
+| `cli` | `args`, `expect?` | Run another Tatami command; `accepted` only means enqueued. |
+| `borrow` | `workspace`, `expectApps?`, `timeoutMs?` | CLI Borrow for specialist rehearsals. Published Borrow uses real keys. |
+| `dismissBorrow` | `settleMs?` | CLI return for specialist rehearsals. |
+| `pointer` | `display`, `x`, `y` | Park the pointer using normalized display coordinates. |
+| `launch` | `apps`, `windows?` | Explicitly launch fixtures, primarily off camera. |
+| `quitApps` | `apps?` | Quit named fixtures or the entire fixture set. |
+| `dragWindow` | `app`, `target`, `x`, `y` | Drag a real title bar toward a normalized target-window point. |
+| `restoreWindow` | `app` | Resize and move the saved window with real pointer drags, then verify its original frame. |
+| `rightClick` | `app`, `identifier` | Open the native context menu for a control. |
+| `resizeWindow` | `app`, `x`, `y?` | Drag a native window edge. |
+| `virtualDisplay` | `connected` | Connect or disconnect the real guest virtual display helper. |
+| `configure` | `field`, `value` | Atomically edit an allowed live setting in the lab TOML. |
+| `clipboard` | `text` | Provide local example text, preserving and restoring all clipboard item types. |
+| `closeSettings` | — | Close the native settings window after authoring. |
+| `prepareSettings` | — | Size and position the native Tatami settings window before its controls are used. |
+| `appWindows` | `app`, `count` | Set native window count, primarily for preparation. |
+
+There is no `appState` command. Views cannot be jumped to a pre-rendered success
+state. They use normal controls and share saved work through `StoryRepository`.
+The launch copy, review, checks, tasks and messages are persisted together under
+the lab's control directory. `seed` resets the story; switching workspaces does
+not. The chat is a local fixture and has no network transport.
+
+## Assertions and pacing
+
+| Kind | Fields | Acceptance |
+| --- | --- | --- |
+| `expectPlacement` | `app`, `target`, `value` | Require every source window to be left, right, above or below the target windows. |
+| `expectProfileCount` | `count` | Verify the profile count through the real CLI. |
+| `expectAssignment` | `app`, `workspace`, `profile` | Verify that a copied app belongs to the target workspace. |
+| `expectValue` | `app`, `identifier`, `value` | Read the actual native input value back. |
+| `expectStory` | `field`, `value` | Verify saved headline, approval, comment, last task/message, checks or completed tasks. |
+| `expectFront` | `app` | Verify the actual focused app through Accessibility. |
+| `expectPointer` | `app` | Require the pointer inside the target window. |
+| `expectCommand` | `text`, `code` | Verify an actual fixture Terminal process result. |
+| `expectHook` | `field`, `value` | Inspect the payload written by a real Tatami hook. |
+| `saveWindow` / `assertWindow` | `app` | Save and compare one window across changing workspace memberships. |
+| `saveControlFrame` / `expectControlMoved` | `app`, `identifier`, `text` | Verify that a native layout-editor operation changed its preview. |
+| `waitWindows` | `apps`, `timeoutMs?` | Wait for visible windows and stable geometry. |
+| `waitWorkspace` | `workspace`, `timeoutMs?` | Observe the activation hook from the immediately preceding step. |
+| `waitProfile` | `profile`, `timeoutMs?` | Observe the profile hook. |
+| `saveLayout` | `text` | Save visible demo window IDs and bounds under a checkpoint name. |
+| `expectLayoutChanged` | `text` | Require the visible layout to differ from the named saved checkpoint. |
+| `assertLayout` | `text` | Require that exact window set and bounds to return, within 4 points. |
+| `beat` | `ms`, `note?` | A declared reading pause; no hidden startup sleep. |
+| `note` | `text` | Log-only explanation. |
+
+Every step is surrounded by checks for permission/settings windows. Failure
+aborts the take and writes a failed manifest. The exporter refuses failed takes.
+
+## Narration
+
+`chapter` and `caption` carry `text`; a caption can use `headline | explanation`.
+Empty text clears a track. `clearOverlay` clears all narration. `key` and `hold`
+create their own keycasts. A standalone `keys` label remains available for manual
+rehearsals but is forbidden in publication scenes, because it would imply a
+keyboard action that did not occur.
+
+`take` defaults to `--overlay off`: text is recorded in editable ASS and JSON
+sidecars. Exports place captions over the lower part of the captured image,
+with the chapter at the upper left and actual keycasts at the upper right.
+Translucent backgrounds keep text readable, but it can cover app content;
+keep important controls clear of those areas. `scene` uses a live rehearsal
+panel, which is separate from the final export design.
+
+Use [publication.json](../publication.json) for placement and duration/size
+budgets, and [README.md](../README.md) for capture, export and review commands.
diff --git a/DemoLab/docs/VM-TART.md b/DemoLab/docs/VM-TART.md
new file mode 100644
index 0000000..0c87b6a
--- /dev/null
+++ b/DemoLab/docs/VM-TART.md
@@ -0,0 +1,107 @@
+
+[English](VM-TART.md) · [한국어](ko/VM-TART.md) · [日本語](ja/VM-TART.md) · [简体中文](zh-Hans/VM-TART.md) · [繁體中文](zh-Hant/VM-TART.md)
+
+
+# Recording in a Tart VM
+
+The dedicated `tatami-demo` guest isolates personal windows, preferences and
+permissions from the shoot. Current captures use macOS 26.6.2, a fixed
+1920×1200 primary display, the installed Tatami app, and eight fixture apps.
+A guest-side virtual display provides a second real screen; see
+[MULTI-DISPLAY.md](MULTI-DISPLAY.md).
+
+## Prepare the guest
+
+The host needs Apple silicon, Swift 6.2+ and Tart with `exec` support. The guest
+needs the Tart agent, Command Line Tools and a copy of Tatami. Run host commands
+from the repository root; guest commands use `~/DemoLab`.
+
+```sh
+swift build --package-path Tools -c release
+Tools/.build/release/tatami-tools vm-bootstrap
+```
+
+Bootstrap fixes the display at `1920x1200px` with `--no-display-refit`, so moving
+the host's VM window cannot change the recording canvas. The lab share mounts
+at `/Volumes/My Shared Files/demolab`.
+
+Place the Tatami build in the lab share before provisioning:
+
+```sh
+ditto /Applications/Tatami.app DemoLab/.build/Tatami.app
+TATAMI_APP="/Volumes/My Shared Files/demolab/.build/Tatami.app" \
+ Tools/.build/release/tatami-tools vm-provision
+```
+
+Provisioning accepts `TATAMI_DMG` instead of `TATAMI_APP` as well. SwiftPM builds
+on the guest's local disk, not the shared filesystem. App bundling registers
+every fixture with LaunchServices so Tatami can resolve assigned bundle IDs.
+
+Open the guest through its VM window or Screen Sharing. Grant missing access
+through System Settings, following [PERMISSIONS.md](PERMISSIONS.md). Test both
+the recorder and Tatami's Always on Top path. The current agent launch path
+has capture/input access; a new image or launcher must be checked separately.
+Do not edit TCC database rows or treat a recorder-only warm-up as proof of
+Tatami's mirroring permission.
+
+## Record an explicit batch
+
+```sh
+# Host: sync current sources and rebuild in the guest.
+Tools/.build/release/tatami-tools vm-sync
+
+# Guest: each scene gets fresh lab data and preferences suppression.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --continue-on-error \
+ --output /Users/admin/DemoLab/recordings/review-batch
+
+# Host: a new destination, retaining originals and all provenance sidecars.
+GUEST_DIR=DemoLab/recordings/review-batch \
+ Tools/.build/release/tatami-tools vm-fetch-recordings DemoLab/recordings/review-batch
+
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/review-batch \
+ --output ~/Downloads/TatamiDemoLab-review
+```
+
+Use `--scenes tour cli desk` to rehearse a subset. Capture never overwrites an
+existing take. `--continue-on-error` preserves failures, cleans between scenes,
+and exits nonzero if any scene failed. `capture-report.json` names every result.
+Only passed takes matching the current scene bytes can enter the exporter.
+
+Avoid heavy host encoding or compilation while recording. The VM and host
+share CPU resources, and dropped frames are measured per display. Encode after
+the capture finishes.
+
+## Shared-filesystem correctness
+
+Both directions use a uniquely named archive. Rewriting the same file through
+virtiofs was observed to expose stale bytes or a stale inode after the host
+moved an earlier take. A new archive name removes that reuse path.
+
+Source sync mirrors only managed source directories, so deleted Swift files
+cannot survive in the guest. It preserves `.build` and recordings. Fetch proves
+the share identity with a unique marker, compares guest/host archive SHA256,
+and extracts into a new local destination. It includes movies, captions,
+timeline, take metadata, frozen scene JSON and the batch report.
+
+## Finish or preserve the image
+
+```sh
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl quit
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl display disconnect
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl restore
+```
+
+`restore` returns the preferences domain backed up before the recording session.
+Original personal-host Tatami preferences are not involved. A stopped, verified
+guest can be preserved with `tart clone`; keep originals and evidence outside
+the VM before retiring any recording image.
+
+## Verified boundary
+
+The v4 batch proves real ScreenCaptureKit capture, actual input/AX readback,
+Tatami tiling/Borrow, two independently captured guest displays, workspace
+chains, and profile activation on display connect/disconnect. Hardware docks,
+physical gesture recognition, mixed-DPI/HDR monitors and new guest OS versions
+need their own checks. Native macOS fullscreen restoration has a separate
+failed reproduction documented in [COVERAGE.md](COVERAGE.md).
diff --git a/DemoLab/docs/ja/COVERAGE.md b/DemoLab/docs/ja/COVERAGE.md
new file mode 100644
index 0000000..52e814d
--- /dev/null
+++ b/DemoLab/docs/ja/COVERAGE.md
@@ -0,0 +1,48 @@
+
+[English](../COVERAGE.md) · [한국어](../ko/COVERAGE.md) · [日本語](COVERAGE.md) · [简体中文](../zh-Hans/COVERAGE.md) · [繁體中文](../zh-Hant/COVERAGE.md)
+
+
+
+# デモの範囲と検証
+
+公開一覧は、一連の作業を示す 1 本と個別機能のデモ 26 本、計 27 シーンです。各言語に専用のアプリ表示、デモ内容、入力、字幕、撮影時の検証条件があります。言語、撮影した操作、画質をもとにテイクを選び、個別に書き出します。
+
+
+## 録画の範囲
+
+|動画集|動画数|示す動作|
+| --- | --- | --- |
+|ワークスペース|3|直接・直前・順番での切り替え、自動起動・再度開く、アプリの移動。|
+|プロファイルと画面|5|プロファイル切り替え、GUI での複製、ワークスペースの選択コピー、2 画面のチェーン、画面間フォーカス、接続・切断に応じた自動切り替え。|
+|タイル表示とフォーカス|7|ウインドウの追加・終了、入れ替え、分割方向、サイズ調整・均等配置、ドラッグ、拡大、方向指定フォーカス、MFF/FFM、長押し・短押しの切り替え、ネイティブの配置エディタ。|
+|借りる|3|横での会話、内容が残る一時スペース、方向選択、境界を越えるフォーカス、完全な切り替えと返却。|
+|ウインドウの表示方法|3|作業別・共有の手前表示、操作できるミラー、そのままの窓と切り替えへの参加。|
+|自動化|3|実際の CLI 結果、複数コマンドの準備スクリプト、状態・フィードバックのフック、原子的な TOML 編集と即時反映。|
+|ガイド設定|2|ガイドのジェスチャプレビュー、実際のキー練習、AI 提案例の確認と下書きへの反映。|
+
+主動画はデザイン、PNG 保存、執筆、確認・承認、借りる操作、共有状態、CLI、画面別プロファイルをつなぎます。Canvas、Editor、Docs、Review、Chat、Notes、Terminal、Monitor は操作できるローカル例です。窓は Tatami が管理し、結果は実際のプロセスが作ります。
+
+
+## 証拠と合格条件
+
+採用するテイクは、実際の操作・状態・配置の検証を通過し、現行の言語別シーンの操作とタイミングに一致し、フレーム欠落率が 1% 未満である必要があります。文面だけの修正では元シーンのハッシュを残し、編集後のハッシュを追加します。書き出し時に MP4 全編、H.264/yuv420p、長さ・容量の上限、冒頭字幕の時刻、映像とタイムラインの共通時計を確認します。
+
+出力の `evidence/` に固定 JSON、録画情報、字幕、時刻、画像、OCR、ブラウザ結果を残します。そのバッチの数や状態は対応する報告で確認し、古い報告を別言語や新テイクに使わないでください。
+
+色は Web の暗色パレット、書体は撮影言語に合わせます。動画とポスターの URL にハッシュを付け、旧版との混在を防ぎます。
+
+
+## 検証の境界と未解決の再現
+
+- **macOS 全画面からの復帰:** Canvas と Docs が両方とも全体サイズに残りました。原因は未特定で、`scenes/native-fullscreen.json` と失敗した `recordings/v4-edge1/fullscreen*` に再現を保存し公開から外しました。`fullscreen` は合格した Tatami 自身のズームです。
+- **実際のジェスチャ:** ガイドのプレビューボタンと実キーを使います。VM から実物の 3 本指・4 本指認識は主張しません。
+- **AI:** 明示したローカル例で、外部 AI を呼びません。録画後も実際の TOML に元の名前が残ることを確認し、下書きだけに反映しました。
+- **複数画面:** ゲストの二つの CoreGraphics 画面を認識・管理・別録画し、時刻で揃えました。実物のドック、混在 DPI、HDR、ケーブルの証明ではありません。専用ヘルパーは OS ごとに再検証が必要です。
+- **範囲:** 上記の主な操作が対象で、全設定、言語、更新経路、ハードウェアの網羅的 QA ではありません。
+
+
+## 修正した録画の問題
+
+矢印のテンキー・機能キー情報を保持し、同じキーの比較で欠落を特定しました。[Apple の説明](https://developer.apple.com/documentation/appkit/nsevent/modifierflags-swift.struct/numericpad)も参照できます。操作前に表示までスクロールし、隠れた窓は実際の一覧で選びます。転送は新規アーカイブと両側のハッシュを使います。
+
+macOS VM に二画面目を作れないという以前の説明は誤りでした。API は [DeskPad の定義](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h)、構成と再現は [MULTI-DISPLAY.md](MULTI-DISPLAY.md) にあります。
diff --git a/DemoLab/docs/ja/MULTI-DISPLAY.md b/DemoLab/docs/ja/MULTI-DISPLAY.md
new file mode 100644
index 0000000..48168dc
--- /dev/null
+++ b/DemoLab/docs/ja/MULTI-DISPLAY.md
@@ -0,0 +1,46 @@
+
+[English](../MULTI-DISPLAY.md) · [한국어](../ko/MULTI-DISPLAY.md) · [日本語](MULTI-DISPLAY.md) · [简体中文](../zh-Hans/MULTI-DISPLAY.md) · [繁體中文](../zh-Hant/MULTI-DISPLAY.md)
+
+
+
+# 録画 VM 内の複数画面
+
+macOS 26.6.2 の Tart ゲストは**内部の CoreGraphics 仮想画面**を作れます。ホストの `VZMacGraphicsDeviceConfiguration` に二つ目を追加するのとは別です。
+
+以前は VZ のハードウェア制限をすべての画面の制限と誤解していました。実際に 1920×1200 の二画面が認識され、Tatami が Review と Chat を分け、独立した動画を録画できました。
+
+
+## 再現
+
+専用ゲスト内で実行します。
+
+```sh
+.build/tools/tatami-tools build-virtual-display
+.build/DemoLab/bin/democtl display connect
+.build/DemoLab/bin/democtl displays
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl take desk --display all --output recordings/desk.mov
+.build/DemoLab/bin/democtl quit
+.build/DemoLab/bin/democtl display disconnect
+```
+
+非公開の `CGVirtualDisplay` はラボ専用で Tatami には含めません。[DeskPad の定義](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h)を参照し、1920×1200 の通常解像度の画面を作ります。切断や期限まで保持し、PID の実行ファイルを確認してから終了します。不在・未対応なら明示的に失敗します。
+
+
+## 同期と合成
+
+`--display all` は画面ごとに原本を書きます。共通時計からの最初の差、画面の原点、個別のフレームと損失数を記録し、ホストへ取得してから使います。
+
+```sh
+Tools/.build/release/tatami-tools compose-displays DemoLab/recordings desk
+```
+
+物理原点の順と時刻差で揃え、原本を残して無損失の中間ファイルを作ります。単画面と同じ配色の横長動画にし、画像の複製やアニメーションで二画面を装いません。
+
+
+## 接続・取り外しは別の場面です。
+
+画面接続のシーンでは、メイン画面を連続して録画します。2 台目の仮想ディスプレイが接続されると、別のレコーダーがその画面を録画し、切断の直前に停止します。両映像は実際の最初のフレーム時刻で同期します。合成時は左右に並べ、2 台目を録画していない区間は空白にします。メイン画面の複製や、切断後の静止画表示は行いません。撮影マニフェストには両方の元映像のハッシュと、2 台目の録画区間を保存します。
+
+実物のケーブル、ドック、HDR、混在 DPI、ジェスチャ認識は別の実機確認です。ゲスト OS が変われば非公開 API の互換性を再確認します。
diff --git a/DemoLab/docs/ja/PERMISSIONS.md b/DemoLab/docs/ja/PERMISSIONS.md
new file mode 100644
index 0000000..8155589
--- /dev/null
+++ b/DemoLab/docs/ja/PERMISSIONS.md
@@ -0,0 +1,33 @@
+
+[English](../PERMISSIONS.md) · [한국어](../ko/PERMISSIONS.md) · [日本語](PERMISSIONS.md) · [简体中文](../zh-Hans/PERMISSIONS.md) · [繁體中文](../zh-Hant/PERMISSIONS.md)
+
+
+
+# 録画の権限
+
+実際の起動経路で許可を確認します。録画ツールの事前確認だけでは、Tatami の配置・ミラー権限の証拠になりません。
+
+|プロセス|必要な用途|確認できる証拠|
+| --- | --- | --- |
+|Tatami|アクセシビリティ:実際の窓の移動・フォーカス|見える切り替えと配置の検証|
+|Tatami|画面収録:常に手前のミラー|広げた窓の上にも見える共有の状態窓|
+|democtl / 起動元|アクセシビリティ:キー、ポインタ、ネイティブ操作|実際の入力と値の確認|
+|DemoRecorder / 起動元|画面収録:画面の取得|最初の符号化フレーム、完成動画、デコード確認|
+
+**専用録画 VM**のシステム設定で必要な許可を与えます。変更後に再起動し、`democtl doctor` と短い録画で確認します。ホストの権限や個人設定は変えません。
+
+`record start` は呼び出し元に許可があれば直接、なければ LaunchServices で起動します。現在の `tart exec` は許可済みエージェント経由です。このイメージと経路での観測であり、すべてのゲストや再ビルドに保証しません。
+
+
+## 要求したプロセスが終了しても窓は残ることがある
+
+以前の動画には **Tatami** を求める `Screen Recording` が残り、他の窓を閉じると見えました。録画ツールだけの確認では検出できず、定期的な通知の証拠ではありませんでした。
+
+別アプリの背後でも権限・設定窓があれば拒否します。ゲストで解決して再試行してください。古い要求を閉じることは許可ではありません。不足していればシステム設定で許可し、動作を試します。
+
+`democtl record warmup` は録画ツールだけの確認です。Tatami の別経路には `shared` も試します。許可画面を切り取ったり、未確認を成功にしたりしないでください。
+
+
+## 繰り返し使える録画イメージ
+
+正しいツールをビルドし、許可と実作業を確認してから保存します。再ビルドや起動元の変更で許可の帰属が変わるため再確認し、DB の複製や古い許可の仮定はしません。TCC DB を直接変えるスクリプトは削除済みです。
diff --git a/DemoLab/docs/ja/SCENES.md b/DemoLab/docs/ja/SCENES.md
new file mode 100644
index 0000000..53362f9
--- /dev/null
+++ b/DemoLab/docs/ja/SCENES.md
@@ -0,0 +1,91 @@
+
+[English](../SCENES.md) · [한국어](../ko/SCENES.md) · [日本語](SCENES.md) · [简体中文](../zh-Hans/SCENES.md) · [繁體中文](../zh-Hant/SCENES.md)
+
+
+
+# 場面と合格条件
+
+シーンには `name`、`title`、`steps` が必要です。`summary`、`requires`、`setup`、`openingApps`、`captureSecondary` は任意のフィールドです。`setup` は録画前に実行され、`steps` が映像に映る操作を定義します。各操作は、インストール済みの Tatami とネイティブのデモアプリを実際に動かします。
+
+メイン画面を録画し続け、2 台目の画面は接続中だけ録画するホットプラグのシーンでは、`captureSecondary` を `true` に設定します。
+
+```sh
+.build/DemoLab/bin/democtl scene tour --dry-run
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+```
+
+
+## 準備
+
+最初に見えるアプリを `openingApps` に指定します。意図した複数窓も含め、実際の通常ウインドウと一致させます。`autoopen` は開く操作が目的なので空配列です。400 ms 安定してから検証します。
+
+録画前に Tatami で作業を準備します。自動起動中に同じアプリを手動で起動すると、同じ ID のプロセスと窓が二つできます。開始時の失敗は後で切り取らず、準備を直します。
+
+
+## 実際の操作
+
+|種類|フィールド|効果|
+| --- | --- | --- |
+|`click`|`app`, `identifier`|AX コントロールを探し、ポインタを移してクリックします。|
+|`typeText`|`app`, `text`, `ms?`|想定したアプリに、一文字ずつ入力します。|
+|`hover`|`app`, `identifier`|コントロールをスクロール内に見せ、ポインタを移します。|
+|`scroll`|`app`, `identifier`, `pixels`|コントロールへ移動してスクロールします。|
+|`key`|`chord`, `repeats?`, `holdMs?`|実際のキーを押し、その入力を表示します。|
+|`hold`|`modifiers`, `keys`, `gapMs?`, `releaseAfterMs?`|切り替え操作中、実際の修飾キーを保持します。|
+|`activateApp`|`app`|見えるタイトルバーを押して AX で確認します。隠れた窓には実際の切り替え操作が必要です。|
+|`activateWorkspace`|`workspace`, `profile?`|完了を待つ Tatami の有効化コマンドを実行します。|
+|`activateProfile`|`profile`|完了を待つプロファイルコマンドを実行します。|
+|`cli`|`args`, `expect?`|別の Tatami コマンドを実行します。`accepted` はキューに入った意味だけです。|
+|`borrow`|`workspace`, `expectApps?`, `timeoutMs?`|詳細な練習用の CLI 借りる操作です。公開動画では実際のキーを使います。|
+|`dismissBorrow`|`settleMs?`|詳細な練習用の CLI 返却です。|
+|`pointer`|`display`, `x`, `y`|正規化した画面座標にポインタを置きます。|
+|`launch`|`apps`, `windows?`|主に録画前に、デモアプリを明示して起動します。|
+|`quitApps`|`apps?`|指定したアプリ、またはデモ全体を終了します。|
+|`dragWindow`|`app`, `target`, `x`, `y`|タイトルバーを、対象窓の正規化した位置へドラッグします。|
+|`restoreWindow`|`app`|実際のドラッグで保存した大きさと位置へ戻し、元の枠を確認します。|
+|`rightClick`|`app`, `identifier`|コントロールの標準コンテキストメニューを開きます。|
+|`resizeWindow`|`app`, `x`, `y?`|実際の窓の端をドラッグします。|
+|`virtualDisplay`|`connected`|ゲストの実際の仮想画面ヘルパーを接続・切断します。|
+|`configure`|`field`, `value`|ラボの TOML で許可された設定を原子的に変更します。|
+|`clipboard`|`text`|クリップボードの全形式を保存・復元し、例文を渡します。|
+|`closeSettings`|—|編集後に設定ウインドウを閉じます。|
+|`prepareSettings`|—|コントロールを操作する前に、Tatami のネイティブ設定ウインドウのサイズと位置を整えます。|
+|`appWindows`|`app`, `count`|主に準備時に、実際のウインドウ数を設定します。|
+
+`appState` はありません。用意した成功画面へ飛ばず、通常の操作と `StoryRepository` で文章、レビュー、チェック、タスク、会話を同じ場所に保存します。`seed` でだけ初期化し、作業の切り替えでは戻しません。会話はネット接続のないローカル例です。
+
+
+## 検証とペース
+
+|種類|フィールド|合格条件|
+| --- | --- | --- |
+|`expectPlacement`|`app`, `target`, `value`|対象アプリのすべてのウインドウが、比較先の左・右・上・下の指定位置にあるか確認します。|
+|`expectProfileCount`|`count`|実際の CLI でプロファイル数を確認します。|
+|`expectAssignment`|`app`, `workspace`, `profile`|コピーしたアプリが対象ワークスペースに含まれるか確認します。|
+|`expectValue`|`app`, `identifier`, `value`|実際の入力値を読み戻します。|
+|`expectStory`|`field`, `value`|保存した見出し、承認、コメント、最後のタスク・会話、チェック、完了数を確認します。|
+|`expectFront`|`app`|アクセシビリティで実際のフォーカスを確認します。|
+|`expectPointer`|`app`|ポインタが対象窓の内側にあることを確認します。|
+|`expectCommand`|`text`, `code`|Terminal の実際のプロセス結果を確認します。|
+|`expectHook`|`field`, `value`|実際の Tatami フックが書いた内容を読みます。|
+|`saveWindow` / `assertWindow`|`app`|所属の変更をまたいで、一つの窓を保存・比較します。|
+|`saveControlFrame` / `expectControlMoved`|`app`, `identifier`, `text`|配置エディタの操作で実際のプレビューが変わったか確認します。|
+|`waitWindows`|`apps`, `timeoutMs?`|見える窓と配置の安定を待ちます。|
+|`waitWorkspace`|`workspace`, `timeoutMs?`|直前の操作による有効化フックを確認します。|
+|`waitProfile`|`profile`, `timeoutMs?`|プロファイルのフックを確認します。|
+|`saveLayout`|`text`|見えるデモ窓の ID と枠を、名前付きの確認点に保存します。|
+|`expectLayoutChanged`|`text`|現在表示されている配置が、指定した名前の保存済みチェックポイントと異なることを確認します。|
+|`assertLayout`|`text`|同じ窓と枠へ、4 pt 以内で戻ることを要求します。|
+|`beat`|`ms`, `note?`|明示した読む時間です。隠れた起動待ちはありません。|
+|`note`|`text`|ログだけに残す説明です。|
+
+操作の前後で権限・設定の窓を確認し、失敗なら録画を中断して記録します。失敗したものは書き出せません。
+
+
+## 説明と字幕
+
+`chapter`、`caption` は `text` を持ち、字幕には `headline | explanation` を使えます。空文字はそのトラック、`clearOverlay` は全説明を消します。`key`、`hold` は実際のキー表示を作ります。独立した `keys` は手動練習専用で、公開では未実行の入力に見えるため禁止します。
+
+`take` の既定値は `--overlay off` で、テキストは編集可能な ASS と JSON のサイドカーファイルに記録します。書き出した映像では、画面下部に字幕、左上にチャプター、右上に実際のキー入力を重ねます。半透明の背景で読みやすくしていますが、アプリの内容に重なるため、重要なコントロールはその領域を避けて配置してください。`scene` はライブのリハーサルパネルを使い、最終映像とは表示方法が異なります。
+
+配置と時間・容量上限は [publication.json](../../publication.json)、録画・出力・確認のコマンドは [README.md](../../ja/README.md) を参照してください。
diff --git a/DemoLab/docs/ja/VM-TART.md b/DemoLab/docs/ja/VM-TART.md
new file mode 100644
index 0000000..9c4d8e8
--- /dev/null
+++ b/DemoLab/docs/ja/VM-TART.md
@@ -0,0 +1,79 @@
+
+[English](../VM-TART.md) · [한국어](../ko/VM-TART.md) · [日本語](VM-TART.md) · [简体中文](../zh-Hans/VM-TART.md) · [繁體中文](../zh-Hant/VM-TART.md)
+
+
+
+# Tart VM で録画する
+
+専用の `tatami-demo` で個人の窓・設定・権限を分離します。macOS 26.6.2、固定 1920×1200、実際の Tatami と 8 アプリを使います。二画面目は [MULTI-DISPLAY.md](MULTI-DISPLAY.md) の方法です。
+
+
+## ゲストの準備
+
+ホストには Apple シリコン、Swift 6.2 以降、`exec` に対応した Tart が必要です。ゲストには Tart エージェント、Command Line Tools、Tatami が必要です。ホストのコマンドはリポジトリのルート、ゲストのコマンドは `~/DemoLab` で実行します。
+
+```sh
+swift build --package-path Tools -c release
+Tools/.build/release/tatami-tools vm-bootstrap
+```
+
+`1920x1200px` と `--no-display-refit` で固定し、ホストの窓サイズを変えても録画の解像度が変わらないようにします。共有は `/Volumes/My Shared Files/demolab` です。
+
+プロビジョニングの前に、使う Tatami のビルドを Demo Lab の共有フォルダへコピーします。
+
+```sh
+ditto /Applications/Tatami.app DemoLab/.build/Tatami.app
+TATAMI_APP="/Volumes/My Shared Files/demolab/.build/Tatami.app" \
+ Tools/.build/release/tatami-tools vm-provision
+```
+
+`TATAMI_APP` の代わりに `TATAMI_DMG` も使えます。SwiftPM はゲストのローカルでビルドし、全アプリを LaunchServices に登録して ID で探せるようにします。
+
+VM の窓や画面共有で開き、[PERMISSIONS.md](PERMISSIONS.md) に従って許可します。録画ツールと手前表示の両方を試します。今のエージェントには許可がありますが、新しい環境は別途確認します。DB を変えたり、録画だけでミラー権限を判断したりしません。
+
+
+## 指定したバッチを録画する
+
+```sh
+# Host: sync current sources and rebuild in the guest.
+Tools/.build/release/tatami-tools vm-sync
+
+# Guest: each scene gets fresh lab data and preferences suppression.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --continue-on-error \
+ --output /Users/admin/DemoLab/recordings/review-batch
+
+# Host: a new destination, retaining originals and all provenance sidecars.
+GUEST_DIR=DemoLab/recordings/review-batch \
+ Tools/.build/release/tatami-tools vm-fetch-recordings DemoLab/recordings/review-batch
+
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/review-batch \
+ --output ~/Downloads/TatamiDemoLab-review
+```
+
+一部だけなら `--scenes tour cli desk` を使います。上書きせず、`--continue-on-error` は失敗を残して場面ごとに初期化し、失敗があれば非ゼロで終わります。`capture-report.json` に記録し、現在の場面に合う合格テイクだけを書き出します。
+
+録画中はホストの重いエンコード・コンパイルを避けます。CPU を共有し損失は画面別に測るため、録画後に処理します。
+
+
+## 共有ファイルの正確性
+
+両方向で新しい名前のアーカイブを使います。ホストが前のファイルを移した後、同名への書き込みが古い内容や inode を参照しました。新規名でこの経路をなくします。
+
+管理対象だけを同期し、削除した Swift が残らないようにします。`.build` と録画は保持します。固有マーカーで共有を確認し、両側の SHA256 を照合して新規フォルダへ展開します。動画と全付属データを含みます。
+
+
+## 終了とイメージの保存
+
+```sh
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl quit
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl display disconnect
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl restore
+```
+
+`restore` は撮影前の設定を戻し、個人ホストには触れません。停止して確認したゲストは `tart clone` で保存できます。廃棄前に原本と証拠を外へ保管してください。
+
+
+## 検証した範囲
+
+v4 では実際の収録、入力・AX、タイルと借りる操作、二画面、チェーン、自動プロファイルを確認しました。ドック、実ジェスチャ、混在 DPI、HDR、新 OS は別検証です。全画面復帰の失敗は [COVERAGE.md](COVERAGE.md) にあります。
diff --git a/DemoLab/docs/ko/COVERAGE.md b/DemoLab/docs/ko/COVERAGE.md
new file mode 100644
index 0000000..f94c41c
--- /dev/null
+++ b/DemoLab/docs/ko/COVERAGE.md
@@ -0,0 +1,48 @@
+
+[English](../COVERAGE.md) · [한국어](COVERAGE.md) · [日本語](../ja/COVERAGE.md) · [简体中文](../zh-Hans/COVERAGE.md) · [繁體中文](../zh-Hant/COVERAGE.md)
+
+
+
+# 데모 범위와 검증
+
+공개 목록에는 전체 작업 흐름 1개와 개별 기능 데모 26개, 총 27개 장면이 있어요. 지원 언어마다 앱 UI, 데모 콘텐츠, 입력, 자막, 촬영 검증 조건이 따로 있어요. 언어, 촬영한 동작, 촬영 품질로 검증된 원본을 골라 각각 내보내요.
+
+
+## 촬영 범위
+
+|모음|영상 수|보여주는 동작|
+| --- | --- | --- |
+|작업 공간|3|직접·최근·순서대로 전환, 자동 열기·다시 열기, 앱 배정 이동.|
+|프로필과 화면|5|프로필 전환, GUI 프로필 복제, 작업 공간 선택 복사, 두 화면 체인, 화면 간 포커스, 연결·해제에 따른 자동 프로필 전환.|
+|타일링과 포커스|7|창 추가·닫기, 위치 교체, 분할 방향, 크기 조절·균등 배치, 드래그, 작업 공간 확대, 방향별 포커스, MFF/FFM, 길게·짧게 누르는 창 전환기, 기본 UI 레이아웃 편집기.|
+|빌려오기|3|옆에서 대화하기, 내용을 기억하는 임시 공간, 방향 선택, 경계 간 포커스, 완전 전환과 돌려보내기.|
+|창 표시 방식|3|작업 공간별·공용 항상 위, 직접 조작하는 미러, 그대로 두기와 창 전환 참여.|
+|자동화|3|실제 CLI 결과, 여러 명령의 집중 환경 스크립트, 수명 주기·화면 알림 훅, TOML 원자적 수정과 바로 적용.|
+|가이드 설정|2|안내의 제스처 미리보기와 실제 키 연습, 예시 AI 제안 검증과 초안 반영.|
+
+종합 영상은 디자인·색상 선택, PNG 저장, 글쓰기, 검토·승인, 빌려오기, 공용 상태, CLI, 화면별 프로필을 한 작업으로 이어줘요. Canvas, Editor, Docs, Review, Chat, Notes, Terminal, Monitor는 조작할 수 있는 로컬 앱이에요. 창 관리는 Tatami가 하고 훅·CLI 결과는 실제 프로세스가 만들어요.
+
+
+## 검증 자료와 통과 기준
+
+검증된 촬영본은 실제 동작·상태·배치 검증을 통과하고, 현재 언어별 촬영 동작과 타이밍이 일치하며, 프레임 손실이 1% 미만이어야 해요. 자막만 수정하면 원본 장면 해시를 보관하고 수정본 해시를 추가해요. 내보낼 때 MP4 전체, H.264/yuv420p, 길이·용량 제한, 첫 자막 시점, 영상과 타임라인의 공통 시계를 확인해요.
+
+출력의 `evidence/`에 고정 장면 JSON, 촬영 정보, 자막, 시간, 검수 이미지, OCR와 브라우저 결과를 남겨요. 각 배치의 숫자와 검수 상태는 그 보고서로 확인하고 이전 보고서를 새 언어·테이크의 증거로 쓰지 마세요.
+
+영상 색상은 웹의 다크 팔레트, 글꼴은 촬영 언어를 따라요. 영상과 포스터 URL에 출력 해시를 넣어 예전 버전과 섞이지 않게 해요.
+
+
+## 검증 경계와 미해결 재현
+
+- **macOS 전체화면 복귀:** 원래 검사에서 전체화면을 끝낸 뒤 Canvas와 Docs가 모두 작업 공간 전체 크기로 남았어요. 원인은 아직 분리하지 못했어요. `scenes/native-fullscreen.json`과 실패한 `recordings/v4-edge1/fullscreen*`에 재현을 보관하고 공개용에서 제외했어요. `fullscreen` 영상은 통과한 Tatami 자체 확대·복귀예요.
+- **실제 제스처:** 안내의 미리보기 버튼과 실제 키보드를 사용해요. VM만으로 물리 세 손가락·네 손가락 인식을 입증하지 않아요.
+- **AI:** 표시해둔 로컬 예시이며 외부 AI를 호출하지 않아요. 촬영 뒤 실제 TOML에 원래 공간 이름이 남아 있는지 확인했어요. 예시는 초안에만 반영됐어요.
+- **여러 화면:** 게스트의 CoreGraphics 화면 두 개를 실제로 열거·관리·개별 녹화하고 시간으로 맞췄어요. 물리 독·혼합 DPI·HDR·케이블 동작은 입증하지 않아요. 비공개 도구는 Demo Lab에만 있고 OS가 바뀌면 다시 검증해야 해요.
+- **범위:** 위 주요 동작을 다루며 모든 설정·언어·업데이트 과정·하드웨어 조합을 완전히 검사한 것은 아니에요.
+
+
+## 고친 촬영 결함
+
+화살표 이벤트의 숫자패드·기능키 정보를 유지하도록 고쳤어요. 같은 단축키로 비교해서 빠진 정보를 확인했으며 [Apple 문서](https://developer.apple.com/documentation/appkit/nsevent/modifierflags-swift.struct/numericpad)에도 설명되어 있어요. 컨트롤은 보이도록 스크롤한 뒤 누르고 가려진 창은 실제 전환기로 골라요. 동기화·가져오기는 새 아카이브를 쓰고 양쪽 해시를 확인해요.
+
+macOS VM에 두 번째 화면을 만들 수 없다는 예전 설명은 잘못됐어요. 실제 사용한 API는 [DeskPad 인터페이스](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h), 구성과 재현은 [MULTI-DISPLAY.md](MULTI-DISPLAY.md)에서 확인해요.
diff --git a/DemoLab/docs/ko/MULTI-DISPLAY.md b/DemoLab/docs/ko/MULTI-DISPLAY.md
new file mode 100644
index 0000000..9d95d72
--- /dev/null
+++ b/DemoLab/docs/ko/MULTI-DISPLAY.md
@@ -0,0 +1,46 @@
+
+[English](../MULTI-DISPLAY.md) · [한국어](MULTI-DISPLAY.md) · [日本語](../ja/MULTI-DISPLAY.md) · [简体中文](../zh-Hans/MULTI-DISPLAY.md) · [繁體中文](../zh-Hant/MULTI-DISPLAY.md)
+
+
+
+# 녹화 VM 안의 여러 화면
+
+macOS 26.6.2 Tart 게스트는 **게스트 내부 CoreGraphics 가상 화면**을 만들 수 있어요. 호스트의 `VZMacGraphicsDeviceConfiguration`에 두 번째 그래픽 화면을 추가하는 것과는 다른 경로예요.
+
+예전 문서는 VZ 하드웨어 화면 제한을 게스트가 만드는 모든 화면의 제한으로 잘못 봤어요. 실제로 활성 1920×1200 화면 두 개가 열거됐고, Tatami 체인으로 Review와 Chat을 각각 배치했으며 두 화면을 독립 영상으로 녹화했어요.
+
+
+## 재현
+
+전용 게스트 안에서 실행하세요.
+
+```sh
+.build/tools/tatami-tools build-virtual-display
+.build/DemoLab/bin/democtl display connect
+.build/DemoLab/bin/democtl displays
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl take desk --display all --output recordings/desk.mov
+.build/DemoLab/bin/democtl quit
+.build/DemoLab/bin/democtl display disconnect
+```
+
+비공개 `CGVirtualDisplay` API는 Demo Lab 도구에만 쓰고 Tatami에는 넣지 않아요. [DeskPad CoreGraphics 인터페이스](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h)를 참고했어요. 1920×1200 일반 해상도 화면을 만들고 해제나 제한 시간까지 유지해요. PID의 실행 파일을 확인한 뒤 신호를 보내며 없거나 지원하지 않으면 명시적으로 실패해요.
+
+
+## 동기화와 합성
+
+`--display all`은 화면마다 원본을 하나씩 써요. 공통 단조 시계에 대한 첫 프레임 차이, 화면 원점, 각각의 프레임·손실 수를 남겨요. 호스트로 가져온 뒤 다음처럼 사용해요.
+
+```sh
+Tools/.build/release/tatami-tools compose-displays DemoLab/recordings desk
+```
+
+화면 원점 순서와 기록한 시간 차이로 영상을 맞춰요. 원본 두 개를 유지하고 무손실 중간 파일을 만들어요. 한 화면 영상과 같은 팔레트의 넓은 영상으로 내보내며, 스크린샷을 복제하거나 움직여 두 번째 화면을 꾸미지 않아요.
+
+
+## 화면 연결·해제는 별도 장면이에요.
+
+화면 연결 데모는 주 화면을 계속 녹화해요. 두 번째 가상 화면이 연결되면 별도 녹화기가 해당 화면을 녹화하고, 연결 해제 직전에 종료해요. 두 영상은 각각의 실제 첫 프레임 시각으로 맞춰요. 합성할 때 나란히 배치하고 보조 화면을 촬영하지 않은 구간은 비워둬요. 주 화면을 복제하거나 연결 해제 뒤 정지 화면을 남기지 않아요. 촬영 명세에는 두 원본의 해시와 보조 화면 촬영 구간을 보관해요.
+
+실제 케이블·독·HDR·혼합 DPI·트랙패드 인식은 별도 하드웨어 검증이에요. 게스트 OS가 바뀌면 비공개 CoreGraphics 호환성을 다시 확인해야 해요.
diff --git a/DemoLab/docs/ko/PERMISSIONS.md b/DemoLab/docs/ko/PERMISSIONS.md
new file mode 100644
index 0000000..75da5f5
--- /dev/null
+++ b/DemoLab/docs/ko/PERMISSIONS.md
@@ -0,0 +1,33 @@
+
+[English](../PERMISSIONS.md) · [한국어](PERMISSIONS.md) · [日本語](../ja/PERMISSIONS.md) · [简体中文](../zh-Hans/PERMISSIONS.md) · [繁體中文](../zh-Hant/PERMISSIONS.md)
+
+
+
+# 촬영 권한
+
+실제 촬영에서 쓰는 실행 경로로 권한을 확인해요. 녹화기 사전 검사만으로 Tatami의 타일링·미러 권한을 증명할 수는 없어요.
+
+|프로세스|필요한 기능|검증 자료|
+| --- | --- | --- |
+|Tatami|손쉬운 사용: 실제 창 이동과 포커스|보이는 작업 공간 전환과 배치 검증|
+|Tatami|화면 기록: 항상 위 미러|확대한 창 위에서도 보이는 공용 상태 창|
+|democtl / 실행기|손쉬운 사용: 키보드·포인터·네이티브 컨트롤|실제 입력과 컨트롤 값 검증|
+|DemoRecorder / 실행기|화면 기록: 화면 캡처|첫 인코딩 프레임·완성 영상·전체 디코딩 검사|
+
+**전용 녹화 VM**의 시스템 설정 → 개인정보 보호 및 보안에서 필요한 권한을 주세요. 변경 후 프로세스를 다시 실행하고 `democtl doctor`과 짧은 촬영으로 확인하세요. 호스트의 권한이나 개인 Tatami 설정은 바꾸지 않아요.
+
+`record start`은 호출자에게 캡처 권한이 있으면 직접 실행하고, 없으면 LaunchServices를 써요. 현재 게스트의 `tart exec`은 미리 허용된 에이전트를 통해 실행돼요. 이 이미지와 경로에서 확인한 결과일 뿐, 모든 게스트나 재빌드 파일에 적용되는 약속은 아니에요.
+
+
+## 요청한 앱이 꺼져도 권한 창이 남을 수 있어요.
+
+예전 종합 영상에는 **Tatami** 권한을 요청하는 `Screen Recording` 창이 남아 있었어요. 앱 창 뒤에 가려졌다가 창을 닫으면서 나타났고, 녹화기만 검사하던 방식은 놓쳤어요. 정기적인 녹화 알림이라는 증거는 아니었어요.
+
+다른 앱 뒤에 가려져 있어도 권한·설정 창이 있으면 거부해요. 게스트에서 해결하고 다시 시도하세요. 오래된 요청을 닫는 것과 권한을 주는 것은 달라요. 실제 권한이 없다면 시스템 설정에서 허용하고 관련 동작을 확인해요.
+
+`democtl record warmup`은 녹화기만 확인해요. Tatami의 별도 캡처 경로는 `shared`도 연습해서 확인해야 해요. 권한 창을 잘라내거나 미검증 영상을 성공으로 표시하지 마세요.
+
+
+## 다시 쓸 수 있는 녹화 이미지
+
+정확한 도구와 앱을 빌드하고 권한·실제 작업을 확인한 뒤 VM 이미지를 보관하세요. 재빌드나 실행기 변경은 권한 귀속을 바꿀 수 있어 다시 확인해야 해요. DB 행을 복사하거나 예전 권한을 가정하지 않아요. TCC DB를 직접 고치던 스크립트는 삭제했어요.
diff --git a/DemoLab/docs/ko/SCENES.md b/DemoLab/docs/ko/SCENES.md
new file mode 100644
index 0000000..2b8f913
--- /dev/null
+++ b/DemoLab/docs/ko/SCENES.md
@@ -0,0 +1,91 @@
+
+[English](../SCENES.md) · [한국어](SCENES.md) · [日本語](../ja/SCENES.md) · [简体中文](../zh-Hans/SCENES.md) · [繁體中文](../zh-Hant/SCENES.md)
+
+
+
+# 장면과 통과 기준
+
+장면에는 `name`, `title`, `steps`가 필요해요. `summary`, `requires`, `setup`, `openingApps`, `captureSecondary`은 선택 필드예요. `setup`은 녹화 전에 실행되고, `steps`는 영상에 보이는 과정을 담아요. 각 동작은 설치된 Tatami와 네이티브 데모 앱을 직접 조작해요.
+
+주 화면을 계속 녹화하면서 두 번째 화면은 연결된 동안만 촬영하는 핫플러그 장면에서는 `captureSecondary`을 `true`로 설정해요.
+
+```sh
+.build/DemoLab/bin/democtl scene tour --dry-run
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+```
+
+
+## 준비
+
+첫 프레임에 보여야 할 앱을 `openingApps`에 적어요. 의도한 중복 창까지 실제 일반 창 목록과 같아야 해요. `autoopen`은 처음에 앱을 여는 기능이라 빈 배열을 써요. 배치가 400ms 동안 안정된 뒤 확인해요.
+
+촬영 전에 Tatami로 작업 공간을 준비해요. 자동 열기가 진행 중일 때 같은 앱을 수동으로 다시 열면 같은 번들 ID의 프로세스와 창이 두 개 생길 수 있어요. 첫 화면 검사가 실패하면 나중에 잘라내지 말고 준비 과정을 고쳐요.
+
+
+## 실제 동작
+
+|유형|필드|효과|
+| --- | --- | --- |
+|`click`|`app`, `identifier`|네이티브 AX 컨트롤을 찾고 포인터를 옮겨 클릭해요.|
+|`typeText`|`app`, `text`, `ms?`|포커스가 맞는 앱에 한 글자씩 입력해요.|
+|`hover`|`app`, `identifier`|스크롤 영역 안으로 컨트롤을 보여주고 포인터를 옮겨요.|
+|`scroll`|`app`, `identifier`, `pixels`|네이티브 컨트롤로 이동해서 스크롤해요.|
+|`key`|`chord`, `repeats?`, `holdMs?`|실제 단축키를 누르고 그 입력을 표시해요.|
+|`hold`|`modifiers`, `keys`, `gapMs?`, `releaseAfterMs?`|창 전환 중 실제 보조키를 계속 눌러요.|
+|`activateApp`|`app`|보이는 제목 막대를 누르고 AX로 포커스를 확인해요. 가려진 창은 실제 전환 동작으로 선택해야 해요.|
+|`activateWorkspace`|`workspace`, `profile?`|완료를 기다리는 Tatami 활성화 명령을 실행해요.|
+|`activateProfile`|`profile`|완료를 기다리는 프로필 명령을 실행해요.|
+|`cli`|`args`, `expect?`|다른 Tatami 명령을 실행해요. `accepted`은 대기열에 넣었다는 뜻일 뿐이에요.|
+|`borrow`|`workspace`, `expectApps?`, `timeoutMs?`|전문 연습용 CLI 빌려오기예요. 공개 영상은 실제 키를 써요.|
+|`dismissBorrow`|`settleMs?`|전문 연습용 CLI 돌려보내기예요.|
+|`pointer`|`display`, `x`, `y`|정규화한 화면 좌표에 포인터를 놓아요.|
+|`launch`|`apps`, `windows?`|주로 촬영 전에 데모 앱을 명시적으로 실행해요.|
+|`quitApps`|`apps?`|지정한 앱이나 데모 앱 전체를 종료해요.|
+|`dragWindow`|`app`, `target`, `x`, `y`|제목 막대를 대상 창의 정규화 좌표로 드래그해요.|
+|`restoreWindow`|`app`|실제 드래그로 저장한 창의 크기와 위치를 되돌린 뒤 원래 영역인지 확인해요.|
+|`rightClick`|`app`, `identifier`|컨트롤의 기본 컨텍스트 메뉴를 열어요.|
+|`resizeWindow`|`app`, `x`, `y?`|네이티브 창의 가장자리를 드래그해요.|
+|`virtualDisplay`|`connected`|게스트의 실제 가상 화면 도구를 연결하거나 해제해요.|
+|`configure`|`field`, `value`|실험용 TOML의 허용한 설정을 원자적으로 바꿔요.|
+|`clipboard`|`text`|클립보드의 모든 데이터 형식을 백업·복원하며 예시 텍스트를 제공해요.|
+|`closeSettings`|—|편집 뒤 네이티브 설정 창을 닫아요.|
+|`prepareSettings`|—|컨트롤을 조작하기 전에 Tatami 설정 창의 크기와 위치를 맞춰요.|
+|`appWindows`|`app`, `count`|주로 준비 단계에서 실제 창 수를 정해요.|
+
+`appState` 명령은 없어요. 미리 만든 성공 화면으로 이동하지 않고 일반 컨트롤과 `StoryRepository`로 저장한 작업을 공유해요. 문구·검토·검사·할 일·메시지는 같은 제어 폴더에 저장해요. `seed`만 이야기를 초기화하고 공간 전환은 초기화하지 않아요. 대화는 네트워크 없는 로컬 데모예요.
+
+
+## 검증과 속도
+
+|유형|필드|통과 기준|
+| --- | --- | --- |
+|`expectPlacement`|`app`, `target`, `value`|모든 대상 앱 창이 비교할 창들의 왼쪽·오른쪽·위·아래 중 지정한 위치에 있는지 확인해요.|
+|`expectProfileCount`|`count`|실제 CLI로 프로필 개수를 확인해요.|
+|`expectAssignment`|`app`, `workspace`, `profile`|복사한 앱이 대상 작업 공간에 포함됐는지 확인해요.|
+|`expectValue`|`app`, `identifier`, `value`|네이티브 입력값을 다시 읽어요.|
+|`expectStory`|`field`, `value`|저장한 제목·승인·의견·마지막 할 일과 메시지·검사·완료 수를 확인해요.|
+|`expectFront`|`app`|접근성 API로 실제 포커스 앱을 확인해요.|
+|`expectPointer`|`app`|포인터가 대상 창 안에 있어야 해요.|
+|`expectCommand`|`text`, `code`|Terminal의 실제 프로세스 실행 결과를 확인해요.|
+|`expectHook`|`field`, `value`|실제 Tatami 훅이 기록한 값을 읽어요.|
+|`saveWindow` / `assertWindow`|`app`|소속이 바뀌어도 같은 창의 위치를 저장하고 비교해요.|
+|`saveControlFrame` / `expectControlMoved`|`app`, `identifier`, `text`|배치 편집기 동작이 실제 미리보기를 바꿨는지 확인해요.|
+|`waitWindows`|`apps`, `timeoutMs?`|보이는 창과 안정된 배치를 기다려요.|
+|`waitWorkspace`|`workspace`, `timeoutMs?`|바로 앞 동작에서 나온 활성화 훅을 확인해요.|
+|`waitProfile`|`profile`, `timeoutMs?`|프로필 훅을 확인해요.|
+|`saveLayout`|`text`|보이는 데모 창 ID와 영역을 이름 있는 체크포인트로 저장해요.|
+|`expectLayoutChanged`|`text`|현재 보이는 배치가 이름으로 지정한 저장 지점과 달라졌는지 확인해요.|
+|`assertLayout`|`text`|같은 창 목록과 영역으로 4포인트 이내에 돌아와야 해요.|
+|`beat`|`ms`, `note?`|명시한 읽기 시간이에요. 숨겨진 시작 대기는 없어요.|
+|`note`|`text`|로그에만 남기는 설명이에요.|
+
+동작 전후에 권한·설정 창을 검사해요. 실패하면 촬영을 중단하고 실패 메타데이터를 남겨요. 내보내기는 실패본을 거부해요.
+
+
+## 설명과 자막
+
+`chapter`, `caption`은 `text`를 담고 자막에는 `headline | explanation`을 쓸 수 있어요. 빈 문자열은 해당 줄을 지우고 `clearOverlay`는 모든 설명을 지워요. `key`, `hold`은 실제 키 표시를 만들어요. 별도 `keys`은 수동 연습에만 허용해요. 공개 영상에 쓰면 없던 키 입력처럼 보일 수 있어요.
+
+`take`의 기본값은 `--overlay off`이며, 텍스트는 편집 가능한 ASS와 JSON 부가 파일에 기록해요. 내보낸 영상은 화면 아래쪽에 자막을, 왼쪽 위에 챕터를, 오른쪽 위에 실제 키 입력을 겹쳐 보여줘요. 반투명 배경으로 가독성을 높이지만 앱 내용을 가릴 수 있으므로 중요한 컨트롤은 해당 영역을 피해서 배치해요. `scene`는 실시간 리허설 패널을 사용하며, 최종 영상의 표시 방식과는 달라요.
+
+배치 위치와 시간·용량 예산은 [publication.json](../../publication.json), 촬영·출력·검수 명령은 [README.md](../../ko/README.md)를 참고하세요.
diff --git a/DemoLab/docs/ko/VM-TART.md b/DemoLab/docs/ko/VM-TART.md
new file mode 100644
index 0000000..38a20d0
--- /dev/null
+++ b/DemoLab/docs/ko/VM-TART.md
@@ -0,0 +1,79 @@
+
+[English](../VM-TART.md) · [한국어](VM-TART.md) · [日本語](../ja/VM-TART.md) · [简体中文](../zh-Hans/VM-TART.md) · [繁體中文](../zh-Hant/VM-TART.md)
+
+
+
+# Tart VM에서 녹화하기
+
+전용 `tatami-demo`로 개인 창·환경설정·권한을 촬영과 분리해요. macOS 26.6.2, 고정 1920×1200 주 화면, 실제 Tatami와 8개 데모 앱을 써요. 두 번째 화면은 [MULTI-DISPLAY.md](MULTI-DISPLAY.md)처럼 게스트 안에서 만들어요.
+
+
+## 게스트 준비
+
+호스트에는 Apple Silicon, Swift 6.2 이상, `exec`을 지원하는 Tart가 필요해요. 게스트에는 Tart 에이전트, Command Line Tools, Tatami가 필요해요. 호스트 명령은 저장소 루트에서, 게스트 명령은 `~/DemoLab`에서 실행해요.
+
+```sh
+swift build --package-path Tools -c release
+Tools/.build/release/tatami-tools vm-bootstrap
+```
+
+`--no-display-refit`과 함께 `1920x1200px`으로 화면을 고정해 호스트 창 크기를 바꿔도 녹화 해상도가 바뀌지 않아요. 공유 폴더는 `/Volumes/My Shared Files/demolab`에 연결돼요.
+
+프로비저닝하기 전에 사용할 Tatami 빌드를 Demo Lab 공유 폴더에 복사해요.
+
+```sh
+ditto /Applications/Tatami.app DemoLab/.build/Tatami.app
+TATAMI_APP="/Volumes/My Shared Files/demolab/.build/Tatami.app" \
+ Tools/.build/release/tatami-tools vm-provision
+```
+
+`TATAMI_APP` 대신 `TATAMI_DMG`도 사용할 수 있어요. SwiftPM은 공유 폴더가 아닌 게스트 디스크에서 빌드해요. 번들 생성 시 모든 앱을 LaunchServices에 등록해 배정한 ID를 찾을 수 있게 해요.
+
+VM 창이나 화면 공유로 게스트를 열고 [PERMISSIONS.md](PERMISSIONS.md)에 따라 시스템 설정에서 권한을 주세요. 녹화기와 항상 위 기능을 모두 검사해야 해요. 현재 에이전트는 입력·캡처 권한이 있지만 새 이미지·실행기는 따로 확인해요. TCC DB를 고치거나 녹화기 준비만으로 미러 권한을 판단하지 마세요.
+
+
+## 지정한 배치 촬영
+
+```sh
+# Host: sync current sources and rebuild in the guest.
+Tools/.build/release/tatami-tools vm-sync
+
+# Guest: each scene gets fresh lab data and preferences suppression.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --continue-on-error \
+ --output /Users/admin/DemoLab/recordings/review-batch
+
+# Host: a new destination, retaining originals and all provenance sidecars.
+GUEST_DIR=DemoLab/recordings/review-batch \
+ Tools/.build/release/tatami-tools vm-fetch-recordings DemoLab/recordings/review-batch
+
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/review-batch \
+ --output ~/Downloads/TatamiDemoLab-review
+```
+
+일부 장면은 `--scenes tour cli desk`으로 고르세요. 기존 테이크를 덮어쓰지 않아요. `--continue-on-error`은 실패본을 남기고 장면 사이를 초기화하며 하나라도 실패하면 0이 아닌 코드로 끝나요. `capture-report.json`에 결과를 기록하고 현재 장면과 맞는 성공본만 내보내요.
+
+녹화 중에는 호스트의 무거운 인코딩·컴파일을 피하세요. VM과 CPU를 나눠 쓰며 손실은 화면마다 측정해요. 촬영이 끝난 뒤 인코딩하세요.
+
+
+## 공유 파일의 정확성
+
+양방향 모두 고유한 이름의 아카이브를 써요. 호스트에서 옛 파일을 옮긴 뒤 같은 이름에 쓰면 virtiofs가 오래된 내용·inode를 보인 적이 있어요. 새 이름으로 이 재사용 경로를 없앴어요.
+
+관리하는 소스 폴더만 동기화해 삭제한 Swift 파일이 게스트에 남지 않게 해요. `.build`과 녹화본은 유지해요. 고유 마커로 공유 위치를 확인하고 양쪽 SHA256을 비교한 뒤 새 로컬 폴더에 풀어요. 영상·자막·시간·촬영 정보·장면 JSON·결과 보고서를 함께 가져와요.
+
+
+## 마치기와 이미지 보관
+
+```sh
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl quit
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl display disconnect
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl restore
+```
+
+`restore`은 촬영 전에 백업한 환경설정을 복원해요. 개인 호스트 설정은 건드리지 않아요. 종료하고 검증한 게스트는 `tart clone`으로 보관할 수 있어요. VM을 정리하기 전에 원본과 증거를 밖에 보관하세요.
+
+
+## 검증한 범위
+
+v4에서는 실제 ScreenCaptureKit, 입력·AX 확인, 타일링·빌려오기, 두 화면 개별 캡처, 체인, 연결·해제 자동 프로필을 검증했어요. 독·물리 제스처·혼합 DPI·HDR·새 OS는 별도 검사예요. macOS 전체화면 복귀 실패는 [COVERAGE.md](COVERAGE.md)에 남겨뒀어요.
diff --git a/DemoLab/docs/zh-Hans/COVERAGE.md b/DemoLab/docs/zh-Hans/COVERAGE.md
new file mode 100644
index 0000000..d2bf102
--- /dev/null
+++ b/DemoLab/docs/zh-Hans/COVERAGE.md
@@ -0,0 +1,48 @@
+
+[English](../COVERAGE.md) · [한국어](../ko/COVERAGE.md) · [日本語](../ja/COVERAGE.md) · [简体中文](COVERAGE.md) · [繁體中文](../zh-Hant/COVERAGE.md)
+
+
+
+# 演示覆盖与验证
+
+发布清单包含 27 个场景:1 个完整工作流程和 26 个功能演示。每种语言都有对应的应用语言、演示内容、输入、字幕和拍摄断言。通过验证的录制按语言、拍摄操作和质量筛选,再分别导出。
+
+
+## 录制覆盖
+
+|视频集|视频数|展示行为|
+| --- | --- | --- |
+|工作区|3|直接、最近、顺序切换,自动打开与重开,应用成员移动。|
+|配置方案与显示器|5|配置方案切换、GUI 复制配置方案、选择性复制工作区、双屏工作区链、跨屏焦点、连接和断开时自动切换配置方案。|
+|平铺与焦点|7|新增和关闭窗口、交换位置、分割方向、调整大小和均衡、拖动、工作区放大、方向焦点、MFF/FFM、长按和轻按窗口切换器、原生可视布局编辑器。|
+|借用|3|并排对话、持久暂存区、边缘选择、跨区焦点、完整激活与归还。|
+|窗口模式|3|逐工作区和共享置顶、可交互镜像、保持原样及参与切换器。|
+|自动化|3|实际 CLI 输出、多命令专注脚本、生命周期与 HUD 钩子、原子编辑 TOML 和实时重载。|
+|引导设置|2|内置手势预览和实际键盘练习,验证示例 AI 建议并应用到草稿。|
+
+主视频把设计配色、实际 PNG 导出、写作保存、评审批准、借用、共享状态、CLI 自动化和显示器相关方案切换串成一个任务。八个命名应用是可操作的本地演示;窗口由 Tatami 管理,钩子和 CLI 输出来自实际进程。
+
+
+## 证据与验收
+
+验收的录制必须通过实际操作、状态和布局断言,与当前语言的拍摄操作及时间一致,且丢帧率低于 1%。仅修改文字时会保留原场景哈希并新增编辑后的场景哈希。导出会检查完整 MP4、H.264/yuv420p、时长和大小限制、首条字幕时间,以及视频与时间线的共同时间基准。
+
+`evidence/` 保存冻结场景、录制元数据、字幕、时间、抽样帧、OCR 和浏览器结果。具体批次的数量和检查状态应以对应报告为准,不能把旧报告用于新语言或新录制。
+
+颜色来自网站深色调色板,字体按语言选择。媒体 URL 包含输出哈希,防止新旧视频和海报混用。
+
+
+## 边界与未解决问题
+
+- **原生 macOS 全屏恢复:** 退出后 Canvas 和 Docs 都保持整个工作区边界,原因未定位。`scenes/native-fullscreen.json` 与失败的 `recordings/v4-edge1/fullscreen*` 保留复现,已从发布中排除。`fullscreen` 展示通过验证的 Tatami 工作区放大与恢复。
+- **物理手势:** 使用向导预览按钮和实际键盘,不宣称 VM 验证了物理三指、四指识别。
+- **AI:** 使用标记的本地示例,不请求外部 AI。录制后确认实际 TOML 仍保留原有工作区名称,示例只应用到草稿。
+- **多显示器:** 实际枚举、管理并独立录制两个 CoreGraphics 屏幕,再按时间戳对齐。不代表验证实体扩展坞、混合 DPI、HDR 或线缆。私有工具仅在 Demo Lab,新系统需重新验证。
+- **覆盖:** 包含上述主要交互,不是所有偏好、语言、更新流程或硬件组合的穷尽 QA。
+
+
+## 已修复的录制缺陷
+
+驱动保留方向键的数字键盘与功能键标识,同一快捷键 A/B 检查确认缺失元数据,[Apple 文档](https://developer.apple.com/documentation/appkit/nsevent/modifierflags-swift.struct/numericpad)说明其身份。点击前滚动到可见控件,被遮窗口用实际切换器选择;同步和取回使用唯一归档并验证两端哈希。
+
+旧文档声称 macOS VM 无法拥有第二显示器,这是错误的。实际 API 见 [DeskPad 接口](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h),配置和复现见 [MULTI-DISPLAY.md](MULTI-DISPLAY.md)。
diff --git a/DemoLab/docs/zh-Hans/MULTI-DISPLAY.md b/DemoLab/docs/zh-Hans/MULTI-DISPLAY.md
new file mode 100644
index 0000000..f419a3c
--- /dev/null
+++ b/DemoLab/docs/zh-Hans/MULTI-DISPLAY.md
@@ -0,0 +1,46 @@
+
+[English](../MULTI-DISPLAY.md) · [한국어](../ko/MULTI-DISPLAY.md) · [日本語](../ja/MULTI-DISPLAY.md) · [简体中文](MULTI-DISPLAY.md) · [繁體中文](../zh-Hant/MULTI-DISPLAY.md)
+
+
+
+# 录制 VM 内的多显示器
+
+macOS 26.6.2 Tart VM 可以创建**虚拟机内的 CoreGraphics 虚拟显示器**,不同于向主机 `VZMacGraphicsDeviceConfiguration` 添加第二个图形显示器。
+
+旧文档错误地把 VZ 硬件显示器限制视为所有虚拟显示器的限制。实测枚举出两个活动 1920×1200 显示器,Tatami 链分别放置 Review 和 Chat,ScreenCaptureKit 成功独立录制两屏。
+
+
+## 复现
+
+在专用 VM 内执行:
+
+```sh
+.build/tools/tatami-tools build-virtual-display
+.build/DemoLab/bin/democtl display connect
+.build/DemoLab/bin/democtl displays
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl take desk --display all --output recordings/desk.mov
+.build/DemoLab/bin/democtl quit
+.build/DemoLab/bin/democtl display disconnect
+```
+
+私有 `CGVirtualDisplay` 仅用于 Demo Lab,不进入 Tatami。参考 [DeskPad 接口](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h),创建 1920×1200 非 HiDPI 显示器,持续到断开或限时结束。发送信号前检查 PID 对应执行文件;工具缺失或不支持时明确失败。
+
+
+## 同步与合成
+
+`--display all` 为每个显示器写一个原始文件,记录相对公共单调时钟的首帧偏移、显示器原点和独立帧数、丢帧数。取回主机后:
+
+```sh
+Tools/.build/release/tatami-tools compose-displays DemoLab/recordings desk
+```
+
+按物理原点排序并用记录的偏移对齐,保留两个原始文件,生成无损中间文件,再使用相同 Tatami 配色导出宽屏双屏视频。不会复制或动画化截图来伪造第二个屏幕。
+
+
+## 热插拔是独立场景
+
+显示器连接场景会持续录制主屏幕。第二个虚拟显示器连接后,由独立录制器拍摄该屏幕,并在移除前停止。两路视频按各自实际的首帧时间同步。合成器将它们并排显示,未录制副屏的时间段留空。它不会复制主屏,也不会在断开后保留冻结画面。录制清单保留两个源文件的哈希及副屏录制区间。
+
+实际线缆、扩展坞、HDR、混合 DPI 和触控板手势识别需单独硬件验证。更换虚拟机系统版本后应重新验证私有 API 兼容性。
diff --git a/DemoLab/docs/zh-Hans/PERMISSIONS.md b/DemoLab/docs/zh-Hans/PERMISSIONS.md
new file mode 100644
index 0000000..df9c3ce
--- /dev/null
+++ b/DemoLab/docs/zh-Hans/PERMISSIONS.md
@@ -0,0 +1,33 @@
+
+[English](../PERMISSIONS.md) · [한국어](../ko/PERMISSIONS.md) · [日本語](../ja/PERMISSIONS.md) · [简体中文](PERMISSIONS.md) · [繁體中文](../zh-Hant/PERMISSIONS.md)
+
+
+
+# 录制权限
+
+通过实际录制启动路径检查权限。录制器预检通过,不代表 Tatami 可以平铺或镜像窗口。
+
+|进程|用途|证据|
+| --- | --- | --- |
+|Tatami|辅助功能:移动和聚焦实际窗口|可见工作区变化和布局断言|
+|Tatami|屏幕录制:置顶镜像|共享状态窗口在放大窗口上方仍可见|
+|democtl / 启动器|辅助功能:键盘、指针、原生控件|实际输入和控件值断言|
+|DemoRecorder / 启动器|屏幕录制:捕获显示器|首个编码帧、完成的影片与解码验证|
+
+在**专用录制 VM**的系统设置 → 隐私与安全性中授予缺少的权限。重新启动相关进程,再运行 `democtl doctor` 和短片检查,不修改主机权限或个人 Tatami 配置。
+
+`record start` 在调用者已有捕获权限时直接启动,否则使用 LaunchServices。当前 `tart exec` 通过预授权代理运行;这是该镜像和启动路径的实测结果,不代表所有 VM 或重建的执行文件。
+
+
+## 请求进程退出后,对话框仍可能保留
+
+旧主视频残留请求 **Tatami** 权限的 `Screen Recording` 对话框。它被窗口遮住,关闭后才出现,旧预检只检查录制器而漏检;这不是定期录制提醒的证据。
+
+即使被其他应用遮挡,权限或设置窗口也会被拒绝。先在 VM 中解决再重试。关闭旧请求不等于授予权限;确实缺少授权时,应在系统设置中授予并验证相关行为。
+
+`democtl record warmup` 只测试录制器,Tatami 独立的捕获路径还需排练 `shared`。不能裁掉权限界面或把未验证录制标为成功。
+
+
+## 可重复使用的录制镜像
+
+构建准确的工具与应用,解决权限并验证工作后保存镜像。重建或变更启动器可能改变权限归属,应重新验证,不能复制数据库行或假定旧授权有效。直接修改 TCC 数据库的旧脚本已移除。
diff --git a/DemoLab/docs/zh-Hans/SCENES.md b/DemoLab/docs/zh-Hans/SCENES.md
new file mode 100644
index 0000000..2bf3adc
--- /dev/null
+++ b/DemoLab/docs/zh-Hans/SCENES.md
@@ -0,0 +1,91 @@
+
+[English](../SCENES.md) · [한국어](../ko/SCENES.md) · [日本語](../ja/SCENES.md) · [简体中文](SCENES.md) · [繁體中文](../zh-Hant/SCENES.md)
+
+
+
+# 场景与验收
+
+场景必须包含 `name`、`title` 和 `steps`。可选字段包括 `summary`、`requires`、`setup`、`openingApps` 和 `captureSecondary`。`setup` 在录制前执行,`steps` 定义视频中展示的过程。各项操作会实际控制已安装的 Tatami 和原生演示应用。
+
+对于持续录制主屏幕、仅在第二块屏幕连接期间录制该屏幕的热插拔场景,请将 `captureSecondary` 设为 `true`。
+
+```sh
+.build/DemoLab/bin/democtl scene tour --dry-run
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+```
+
+
+## 准备
+
+在 `openingApps` 声明首帧必须可见的应用,多重集合必须匹配实际普通窗口,包括刻意的重复资料窗口。`autoopen` 以打开工作区为首操作,因此使用空数组。几何稳定 400 ms 后检查。
+
+录制前通过 Tatami 准备工作区。不要把尚未完成的自动打开与手动重启混用,否则同一包 ID 可能出现两个进程、两个窗口。开场检查失败应修正准备过程,不在后期裁掉意外窗口。
+
+
+## 实际操作
+
+|类型|字段|效果|
+| --- | --- | --- |
+|`click`|`app`, `identifier`|定位原生 AX 控件,移动指针并点击。|
+|`typeText`|`app`, `text`, `ms?`|在预期聚焦应用中逐字输入。|
+|`hover`|`app`, `identifier`|滚动让原生控件进入可见区域,再移入指针。|
+|`scroll`|`app`, `identifier`, `pixels`|移动到原生控件并滚动。|
+|`key`|`chord`, `repeats?`, `holdMs?`|按实际快捷键并生成相应按键显示。|
+|`hold`|`modifiers`, `keys`, `gapMs?`, `releaseAfterMs?`|在切换交互期间持续按住实际修饰键。|
+|`activateApp`|`app`|点击可见标题栏,再通过 AX 确认焦点。被遮住的窗口需要实际切换操作。|
+|`activateWorkspace`|`workspace`, `profile?`|执行等待完成的 Tatami 激活命令。|
+|`activateProfile`|`profile`|执行等待完成的配置方案命令。|
+|`cli`|`args`, `expect?`|执行其他 Tatami 命令;`accepted` 只表示入队。|
+|`borrow`|`workspace`, `expectApps?`, `timeoutMs?`|高级排练用 CLI 借用,发布视频使用实际按键。|
+|`dismissBorrow`|`settleMs?`|高级排练用 CLI 归还。|
+|`pointer`|`display`, `x`, `y`|用归一化显示器坐标放置指针。|
+|`launch`|`apps`, `windows?`|明确启动演示应用,主要用于录制前。|
+|`quitApps`|`apps?`|退出指定演示应用或整个集合。|
+|`dragWindow`|`app`, `target`, `x`, `y`|把实际标题栏拖到目标窗口的归一化位置。|
+|`restoreWindow`|`app`|通过实际指针拖移恢复窗口大小和位置,再验证原始边界。|
+|`rightClick`|`app`, `identifier`|打开控件的原生上下文菜单。|
+|`resizeWindow`|`app`, `x`, `y?`|拖动原生窗口边缘。|
+|`virtualDisplay`|`connected`|连接或断开实际虚拟显示器工具。|
+|`configure`|`field`, `value`|原子修改实验 TOML 中允许的实时设置。|
+|`clipboard`|`text`|提供本地示例文本,同时保留并恢复所有剪贴板类型。|
+|`closeSettings`|—|编辑后关闭原生设置窗口。|
+|`prepareSettings`|—|操作控件前,调整 Tatami 原生设置窗口的大小和位置。|
+|`appWindows`|`app`, `count`|设置原生窗口数量,主要用于准备。|
+
+没有 `appState` 命令,视图不能跳到预制成功状态。它们使用普通控件,通过 `StoryRepository` 共享保存的文案、评审、检查、任务和消息。`seed` 重置故事,切换工作区不会。聊天是无网络传输的本地演示。
+
+
+## 断言与节奏
+
+|类型|字段|验收条件|
+| --- | --- | --- |
+|`expectPlacement`|`app`, `target`, `value`|验证源应用的所有窗口都位于目标窗口的左侧、右侧、上方或下方。|
+|`expectProfileCount`|`count`|通过实际 CLI 验证配置方案数量。|
+|`expectAssignment`|`app`, `workspace`, `profile`|验证复制的应用已分配给目标工作区。|
+|`expectValue`|`app`, `identifier`, `value`|回读实际原生输入值。|
+|`expectStory`|`field`, `value`|验证保存的标题、批准、意见、最后任务与消息、检查或完成项。|
+|`expectFront`|`app`|通过辅助功能验证实际聚焦应用。|
+|`expectPointer`|`app`|要求指针位于目标窗口内。|
+|`expectCommand`|`text`, `code`|验证演示 Terminal 的实际进程结果。|
+|`expectHook`|`field`, `value`|检查实际 Tatami 钩子写入的数据。|
+|`saveWindow` / `assertWindow`|`app`|跨成员关系变化保存并比较同一窗口。|
+|`saveControlFrame` / `expectControlMoved`|`app`, `identifier`, `text`|验证布局编辑操作实际改变了预览。|
+|`waitWindows`|`apps`, `timeoutMs?`|等待可见窗口和稳定几何。|
+|`waitWorkspace`|`workspace`, `timeoutMs?`|观察上一步产生的激活钩子。|
+|`waitProfile`|`profile`, `timeoutMs?`|观察配置方案钩子。|
+|`saveLayout`|`text`|按检查点名称保存可见演示窗口 ID 和边界。|
+|`expectLayoutChanged`|`text`|确认当前可见布局与指定名称的已保存检查点不同。|
+|`assertLayout`|`text`|要求相同窗口集合与边界在 4 点误差内恢复。|
+|`beat`|`ms`, `note?`|明确声明的阅读停顿,没有隐藏启动等待。|
+|`note`|`text`|仅记录到日志的说明。|
+
+每步前后检查权限和设置窗口,失败时中止录制并写入失败记录,导出器拒绝失败片。
+
+
+## 说明与字幕
+
+`chapter`、`caption` 携带 `text`,字幕可使用 `headline | explanation`。空文本清除当前轨道,`clearOverlay` 清除全部说明;`key`、`hold` 自动生成按键显示。独立 `keys` 标签只允许手动排练,发布场景禁用,以免暗示未发生的输入。
+
+`take` 默认为 `--overlay off`,文字保存在可编辑的 ASS 和 JSON 附属文件中。导出的视频会在画面下方叠加字幕、左上角显示章节、右上角显示实际按键。半透明背景提高了可读性,但仍可能遮挡应用内容,因此重要控件应避开这些区域。`scene` 使用实时排练面板,其呈现方式与最终视频不同。
+
+位置和时间、容量预算见 [publication.json](../../publication.json);录制、导出和检查命令见 [README.md](../../zh-Hans/README.md)。
diff --git a/DemoLab/docs/zh-Hans/VM-TART.md b/DemoLab/docs/zh-Hans/VM-TART.md
new file mode 100644
index 0000000..95ff208
--- /dev/null
+++ b/DemoLab/docs/zh-Hans/VM-TART.md
@@ -0,0 +1,79 @@
+
+[English](../VM-TART.md) · [한국어](../ko/VM-TART.md) · [日本語](../ja/VM-TART.md) · [简体中文](VM-TART.md) · [繁體中文](../zh-Hant/VM-TART.md)
+
+
+
+# 在 Tart VM 中录制
+
+专用 `tatami-demo` 隔离个人窗口、偏好和权限。当前使用 macOS 26.6.2、固定 1920×1200 主屏、实际 Tatami 和八个演示应用;第二屏来自 VM 内虚拟显示器,见 [MULTI-DISPLAY.md](MULTI-DISPLAY.md)。
+
+
+## 准备虚拟机
+
+主机需要 Apple 芯片、Swift 6.2 或更新版本,以及支持 `exec` 的 Tart。虚拟机需要 Tart 代理、Command Line Tools 和 Tatami。主机命令在仓库根目录运行,虚拟机命令在 `~/DemoLab` 中运行。
+
+```sh
+swift build --package-path Tools -c release
+Tools/.build/release/tatami-tools vm-bootstrap
+```
+
+通过 `1920x1200px` 和 `--no-display-refit` 固定分辨率,调整主机 VM 窗口不改变录制画布,共享挂载于 `/Volumes/My Shared Files/demolab`。
+
+配置虚拟机之前,先将要使用的 Tatami 构建复制到 Demo Lab 共享文件夹。
+
+```sh
+ditto /Applications/Tatami.app DemoLab/.build/Tatami.app
+TATAMI_APP="/Volumes/My Shared Files/demolab/.build/Tatami.app" \
+ Tools/.build/release/tatami-tools vm-provision
+```
+
+也接受 `TATAMI_DMG` 代替 `TATAMI_APP`。SwiftPM 在虚拟机本地磁盘构建,打包时把全部演示应用注册到 LaunchServices,以便按包 ID 解析。
+
+通过 VM 窗口或屏幕共享打开,按 [PERMISSIONS.md](PERMISSIONS.md) 在系统设置中授权,同时测试录制器与置顶。当前代理路径有捕获和输入权限,但新镜像或启动器需另查。不要修改 TCC 行,也不能仅凭录制器预热判断镜像权限。
+
+
+## 录制明确的批次
+
+```sh
+# Host: sync current sources and rebuild in the guest.
+Tools/.build/release/tatami-tools vm-sync
+
+# Guest: each scene gets fresh lab data and preferences suppression.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --continue-on-error \
+ --output /Users/admin/DemoLab/recordings/review-batch
+
+# Host: a new destination, retaining originals and all provenance sidecars.
+GUEST_DIR=DemoLab/recordings/review-batch \
+ Tools/.build/release/tatami-tools vm-fetch-recordings DemoLab/recordings/review-batch
+
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/review-batch \
+ --output ~/Downloads/TatamiDemoLab-review
+```
+
+用 `--scenes tour cli desk` 排练子集,不覆盖已有录制。`--continue-on-error` 保留失败、在场景间清理,任一失败即返回非零码。`capture-report.json` 记录各项结果,只导出匹配当前场景的通过片段。
+
+录制时避免主机进行繁重编码或编译。VM 与主机共享 CPU,丢帧逐显示器计量,应在录制结束后编码。
+
+
+## 共享文件系统正确性
+
+两向传输都使用唯一归档名。实测主机移动旧文件后,virtiofs 重写同名文件可能暴露旧字节或旧 inode;新名称消除了该复用路径。
+
+只同步受管源码目录,删除的 Swift 文件不会留在 VM,保留 `.build` 与录像。通过唯一标记确认共享位置,比较两端 SHA256,再解压到新目录。包含影片、字幕、时间线、元数据、冻结场景与批次报告。
+
+
+## 结束或保存镜像
+
+```sh
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl quit
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl display disconnect
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl restore
+```
+
+`restore` 恢复录制前备份的偏好,不涉及个人主机设置。关闭并验证的 VM 可用 `tart clone` 保存,淘汰镜像前请先把原始文件和证据存到外部。
+
+
+## 已验证边界
+
+v4 验证了实际 ScreenCaptureKit、输入与 AX 回读、平铺和借用、独立双屏捕获、工作区链及连接断开自动方案。实体扩展坞、手势、混合 DPI/HDR 和新系统需另查;原生全屏恢复失败见 [COVERAGE.md](COVERAGE.md)。
diff --git a/DemoLab/docs/zh-Hant/COVERAGE.md b/DemoLab/docs/zh-Hant/COVERAGE.md
new file mode 100644
index 0000000..38c4c7c
--- /dev/null
+++ b/DemoLab/docs/zh-Hant/COVERAGE.md
@@ -0,0 +1,48 @@
+
+[English](../COVERAGE.md) · [한국어](../ko/COVERAGE.md) · [日本語](../ja/COVERAGE.md) · [简体中文](../zh-Hans/COVERAGE.md) · [繁體中文](COVERAGE.md)
+
+
+
+# 示範涵蓋與驗證
+
+發布清單包含 27 個場景:1 個完整工作流程及 26 個功能示範。每種語言都有對應的 App 語言、示範內容、輸入、字幕及拍攝驗證條件。通過驗證的錄製依語言、拍攝操作及品質篩選,再分別匯出。
+
+
+## 錄製涵蓋
+
+|影片集|影片數|展示行為|
+| --- | --- | --- |
+|工作空間|3|直接、最近、順序切換,自動開啟與重開,App 成員移動。|
+|設定組合與顯示器|5|設定組合切換、GUI 複製設定組合、選擇性複製工作空間、雙螢幕工作空間鏈、跨螢幕焦點、連接與中斷時自動切換設定組合。|
+|並排與焦點|7|新增與關閉視窗、交換位置、分割方向、調整大小與平衡、拖曳、工作空間放大、方向焦點、MFF/FFM、長按與輕按視窗切換器、原生視覺化排列編輯器。|
+|借用|3|並排對話、保留內容的暫存區、邊緣選擇、跨區焦點、完整啟用與歸還。|
+|視窗模式|3|逐工作空間與共用置頂、可互動鏡像、維持原狀及參與切換器。|
+|自動化|3|實際 CLI 輸出、多指令專注指令稿、生命週期與 HUD Hook、不可分割編輯 TOML 和即時重新載入。|
+|引導設定|2|內建手勢預覽與實際鍵盤練習,驗證範例 AI 建議並套用到草稿。|
+
+主影片把設計配色、實際 PNG 輸出、撰寫儲存、審閱核准、借用、共用狀態、CLI 自動化與顯示器相關組合切換串成一項工作。八個命名 App 是可操作的本機示範;視窗由 Tatami 管理,Hook 和 CLI 輸出來自實際程序。
+
+
+## 證據與驗收
+
+驗收的錄製必須通過實際操作、狀態及排列驗證,與目前語言的拍攝操作及時間一致,且掉幀率低於 1%。只修改文字時會保留原場景雜湊,並新增編輯後的場景雜湊。匯出會檢查完整 MP4、H.264/yuv420p、長度及容量限制、首條字幕時間,以及影片與時間軸的共同時間基準。
+
+`evidence/` 儲存凍結場景、錄製中繼資料、字幕、時間、抽樣影格、OCR 與瀏覽器結果。特定批次的數量與檢查狀態應以對應報告為準,不能把舊報告用於新語言或新錄製。
+
+顏色來自網站深色調色盤,字型依語言選擇。媒體 URL 包含輸出雜湊,防止新舊影片與海報混用。
+
+
+## 界線與未解決問題
+
+- **原生 macOS 全螢幕還原:** 離開後 Canvas 和 Docs 都保持整個工作空間邊界,原因未定位。`scenes/native-fullscreen.json` 與失敗的 `recordings/v4-edge1/fullscreen*` 保留重現,已從發布中排除。`fullscreen` 展示通過驗證的 Tatami 工作空間放大與還原。
+- **實體手勢:** 使用導覽預覽按鈕與實際鍵盤,不宣稱 VM 驗證了實體三指、四指辨識。
+- **AI:** 使用標記的本機範例,不要求外部 AI。錄製後確認實際 TOML 仍保留原有工作空間名稱,範例只套用到草稿。
+- **多顯示器:** 實際列舉、管理並獨立錄製兩個 CoreGraphics 螢幕,再依時間戳記對齊。不代表驗證實體擴充座、混合 DPI、HDR 或線材。私有工具僅在 Demo Lab,新系統需重新驗證。
+- **涵蓋:** 包含上述主要互動,不是所有偏好、語言、更新流程或硬體組合的完整 QA。
+
+
+## 已修正的錄製問題
+
+驅動保留方向鍵的數字鍵盤與功能鍵識別,同一快速鍵 A/B 檢查確認缺少中繼資料,[Apple 文件](https://developer.apple.com/documentation/appkit/nsevent/modifierflags-swift.struct/numericpad)說明其身分。點按前捲動到可見控制項,被遮視窗用實際切換器選擇;同步與取回使用唯一封存檔並驗證兩端雜湊。
+
+舊文件聲稱 macOS VM 無法擁有第二顯示器,這是錯誤的。實際 API 見 [DeskPad 介面](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h),設定與重現見 [MULTI-DISPLAY.md](MULTI-DISPLAY.md)。
diff --git a/DemoLab/docs/zh-Hant/MULTI-DISPLAY.md b/DemoLab/docs/zh-Hant/MULTI-DISPLAY.md
new file mode 100644
index 0000000..9f43c55
--- /dev/null
+++ b/DemoLab/docs/zh-Hant/MULTI-DISPLAY.md
@@ -0,0 +1,46 @@
+
+[English](../MULTI-DISPLAY.md) · [한국어](../ko/MULTI-DISPLAY.md) · [日本語](../ja/MULTI-DISPLAY.md) · [简体中文](../zh-Hans/MULTI-DISPLAY.md) · [繁體中文](MULTI-DISPLAY.md)
+
+
+
+# 錄製 VM 內的多顯示器
+
+macOS 26.6.2 Tart VM 可以建立**虛擬機內的 CoreGraphics 虛擬顯示器**,不同於向主機 `VZMacGraphicsDeviceConfiguration` 新增第二個圖形顯示器。
+
+舊文件錯誤地把 VZ 硬體顯示器限制視為所有虛擬顯示器的限制。實測列舉出兩個作用中的 1920×1200 顯示器,Tatami 鏈分別放置 Review 與 Chat,ScreenCaptureKit 成功獨立錄製兩屏。
+
+
+## 重現
+
+在專用 VM 內執行:
+
+```sh
+.build/tools/tatami-tools build-virtual-display
+.build/DemoLab/bin/democtl display connect
+.build/DemoLab/bin/democtl displays
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl take desk --display all --output recordings/desk.mov
+.build/DemoLab/bin/democtl quit
+.build/DemoLab/bin/democtl display disconnect
+```
+
+私有 `CGVirtualDisplay` 僅用於 Demo Lab,不進入 Tatami。參考 [DeskPad 介面](https://github.com/Stengo/DeskPad/blob/main/DeskPad/CGVirtualDisplayPrivate.h),建立 1920×1200 非 HiDPI 顯示器,持續到中斷或限時結束。傳送訊號前檢查 PID 對應執行檔;工具缺少或不支援時明確失敗。
+
+
+## 同步與合成
+
+`--display all` 為每部顯示器寫一個原始檔案,記錄相對共同單調時鐘的首影格偏移、顯示器原點與獨立影格數、遺失數。取回主機後:
+
+```sh
+Tools/.build/release/tatami-tools compose-displays DemoLab/recordings desk
+```
+
+依物理原點排序並用記錄的偏移對齊,保留兩個原始檔案,產生無損中間檔,再使用相同 Tatami 配色輸出寬版雙螢幕影片。不會複製或動畫化截圖來偽造第二個螢幕。
+
+
+## 熱插拔是獨立場景
+
+顯示器連接場景會持續錄製主螢幕。第二個虛擬顯示器連接後,由獨立錄製器拍攝該螢幕,並在移除前停止。兩路影片依各自實際的首幀時間同步。合成器將它們並排顯示,未錄製副螢幕的時段留白。它不會複製主螢幕,也不會在中斷連接後保留凍結畫面。錄製清單保留兩個來源檔案的雜湊及副螢幕錄製區間。
+
+實際線材、擴充座、HDR、混合 DPI 與觸控式軌跡板手勢辨識需另行硬體驗證。更換虛擬機系統版本後應重新驗證私有 API 相容性。
diff --git a/DemoLab/docs/zh-Hant/PERMISSIONS.md b/DemoLab/docs/zh-Hant/PERMISSIONS.md
new file mode 100644
index 0000000..4d8b331
--- /dev/null
+++ b/DemoLab/docs/zh-Hant/PERMISSIONS.md
@@ -0,0 +1,33 @@
+
+[English](../PERMISSIONS.md) · [한국어](../ko/PERMISSIONS.md) · [日本語](../ja/PERMISSIONS.md) · [简体中文](../zh-Hans/PERMISSIONS.md) · [繁體中文](PERMISSIONS.md)
+
+
+
+# 錄製權限
+
+透過實際錄製啟動路徑檢查權限。錄製器預檢通過,不代表 Tatami 可以並排或鏡像視窗。
+
+|程序|用途|證據|
+| --- | --- | --- |
+|Tatami|輔助使用:移動與聚焦實際視窗|可見工作空間變化與排列斷言|
+|Tatami|螢幕錄製:置頂鏡像|共用狀態視窗在放大視窗上方仍可見|
+|democtl / 啟動器|輔助使用:鍵盤、游標、原生控制項|實際輸入與控制項值斷言|
+|DemoRecorder / 啟動器|螢幕錄製:擷取顯示器|首個編碼影格、完成的影片與解碼驗證|
+
+在**專用錄製 VM**的系統設定 → 隱私權與安全性中授予缺少的權限。重新啟動相關程序,再執行 `democtl doctor` 和短片檢查,不修改主機權限或個人 Tatami 設定。
+
+`record start` 在呼叫者已有擷取權限時直接啟動,否則使用 LaunchServices。目前 `tart exec` 透過預先授權代理程式執行;這是該映像與啟動路徑的實測結果,不代表所有 VM 或重建的執行檔。
+
+
+## 要求程序結束後,對話框仍可能保留
+
+舊主影片殘留要求 **Tatami** 權限的 `Screen Recording` 對話框。它被視窗遮住,關閉後才出現,舊預檢只檢查錄製器而漏檢;這不是定期錄製提醒的證據。
+
+即使被其他 App 遮擋,權限或設定視窗也會被拒絕。先在 VM 中解決再重試。關閉舊要求不等於授予權限;確實缺少授權時,應在系統設定中授予並驗證相關行為。
+
+`democtl record warmup` 只測試錄製器,Tatami 獨立的擷取路徑還需排練 `shared`。不能裁掉權限介面或把未驗證錄製標為成功。
+
+
+## 可重複使用的錄製映像
+
+建置確切的工具與 App,解決權限並驗證工作後保存映像。重建或變更啟動器可能改變權限歸屬,應重新驗證,不能拷貝資料庫列或假定舊授權有效。直接修改 TCC 資料庫的舊指令稿已移除。
diff --git a/DemoLab/docs/zh-Hant/SCENES.md b/DemoLab/docs/zh-Hant/SCENES.md
new file mode 100644
index 0000000..9aaf5e2
--- /dev/null
+++ b/DemoLab/docs/zh-Hant/SCENES.md
@@ -0,0 +1,91 @@
+
+[English](../SCENES.md) · [한국어](../ko/SCENES.md) · [日本語](../ja/SCENES.md) · [简体中文](../zh-Hans/SCENES.md) · [繁體中文](SCENES.md)
+
+
+
+# 場景與驗收
+
+場景必須包含 `name`、`title` 和 `steps`。選填欄位包括 `summary`、`requires`、`setup`、`openingApps` 和 `captureSecondary`。`setup` 在錄製前執行,`steps` 定義影片中呈現的過程。各項操作會實際控制已安裝的 Tatami 與原生示範 App。
+
+若場景會持續錄製主螢幕,並只在第二個螢幕連接期間錄製該螢幕,請將 `captureSecondary` 設為 `true`。
+
+```sh
+.build/DemoLab/bin/democtl scene tour --dry-run
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+```
+
+
+## 準備
+
+在 `openingApps` 宣告首影格必須可見的 App,多重集合必須符合實際一般視窗,包含刻意的重複資料視窗。`autoopen` 以開啟工作空間為首操作,因此使用空陣列。幾何穩定 400 ms 後檢查。
+
+錄製前透過 Tatami 準備工作空間。不要把尚未完成的自動開啟與手動重新啟動混用,否則同一套件 ID 可能出現兩個程序、兩個視窗。開場檢查失敗應修正準備過程,不在後製裁掉意外視窗。
+
+
+## 實際操作
+
+|類型|欄位|效果|
+| --- | --- | --- |
+|`click`|`app`, `identifier`|定位原生 AX 控制項,移動游標並點按。|
+|`typeText`|`app`, `text`, `ms?`|在預期聚焦 App 中逐字輸入。|
+|`hover`|`app`, `identifier`|捲動讓原生控制項進入可見區域,再移入游標。|
+|`scroll`|`app`, `identifier`, `pixels`|移動到原生控制項並捲動。|
+|`key`|`chord`, `repeats?`, `holdMs?`|按實際快速鍵並產生相應按鍵顯示。|
+|`hold`|`modifiers`, `keys`, `gapMs?`, `releaseAfterMs?`|在切換互動期間持續按住實際輔助鍵。|
+|`activateApp`|`app`|點按可見標題列,再透過 AX 確認焦點。被遮住的視窗需要實際切換操作。|
+|`activateWorkspace`|`workspace`, `profile?`|執行等待完成的 Tatami 啟用指令。|
+|`activateProfile`|`profile`|執行等待完成的設定組合指令。|
+|`cli`|`args`, `expect?`|執行其他 Tatami 指令;`accepted` 只表示排入佇列。|
+|`borrow`|`workspace`, `expectApps?`, `timeoutMs?`|進階排練用 CLI 借用,發布影片使用實際按鍵。|
+|`dismissBorrow`|`settleMs?`|進階排練用 CLI 歸還。|
+|`pointer`|`display`, `x`, `y`|用正規化顯示器座標放置游標。|
+|`launch`|`apps`, `windows?`|明確啟動示範 App,主要用於錄製前。|
+|`quitApps`|`apps?`|結束指定示範 App 或整個集合。|
+|`dragWindow`|`app`, `target`, `x`, `y`|把實際標題列拖到目標視窗的正規化位置。|
+|`restoreWindow`|`app`|透過實際游標拖移還原視窗大小與位置,再驗證原始邊界。|
+|`rightClick`|`app`, `identifier`|開啟控制項的原生快顯選單。|
+|`resizeWindow`|`app`, `x`, `y?`|拖移原生視窗邊緣。|
+|`virtualDisplay`|`connected`|連接或中斷實際虛擬顯示器工具。|
+|`configure`|`field`, `value`|不可分割地修改實驗 TOML 中允許的即時設定。|
+|`clipboard`|`text`|提供本機範例文字,同時保留並還原所有剪貼簿類型。|
+|`closeSettings`|—|編輯後關閉原生設定視窗。|
+|`prepareSettings`|—|操作控制項前,調整 Tatami 原生設定視窗的大小與位置。|
+|`appWindows`|`app`, `count`|設定原生視窗數量,主要用於準備。|
+
+沒有 `appState` 指令,視圖不能跳到預製成功狀態。它們使用一般控制項,透過 `StoryRepository` 共用儲存的文案、審閱、檢查、待辦與訊息。`seed` 重設故事,切換工作空間不會。聊天是沒有網路傳輸的本機示範。
+
+
+## 斷言與節奏
+
+|類型|欄位|驗收條件|
+| --- | --- | --- |
+|`expectPlacement`|`app`, `target`, `value`|驗證來源 App 的所有視窗都位於目標視窗的左側、右側、上方或下方。|
+|`expectProfileCount`|`count`|透過實際 CLI 驗證設定組合數量。|
+|`expectAssignment`|`app`, `workspace`, `profile`|驗證複製的 App 已指派給目標工作空間。|
+|`expectValue`|`app`, `identifier`, `value`|讀回實際原生輸入值。|
+|`expectStory`|`field`, `value`|驗證儲存的標題、核准、意見、最後待辦與訊息、檢查或完成項。|
+|`expectFront`|`app`|透過輔助使用驗證實際聚焦 App。|
+|`expectPointer`|`app`|要求游標位於目標視窗內。|
+|`expectCommand`|`text`, `code`|驗證示範 Terminal 的實際程序結果。|
+|`expectHook`|`field`, `value`|檢查實際 Tatami Hook 寫入的資料。|
+|`saveWindow` / `assertWindow`|`app`|跨成員關係變化儲存並比較同一視窗。|
+|`saveControlFrame` / `expectControlMoved`|`app`, `identifier`, `text`|驗證排列編輯操作實際改變了預覽。|
+|`waitWindows`|`apps`, `timeoutMs?`|等待可見視窗和穩定幾何。|
+|`waitWorkspace`|`workspace`, `timeoutMs?`|觀察上一步產生的啟用 Hook。|
+|`waitProfile`|`profile`, `timeoutMs?`|觀察設定組合 Hook。|
+|`saveLayout`|`text`|依檢查點名稱儲存可見示範視窗 ID 與邊界。|
+|`expectLayoutChanged`|`text`|確認目前顯示的配置與指定名稱的已儲存檢查點不同。|
+|`assertLayout`|`text`|要求相同視窗集合與邊界在 4 點誤差內還原。|
+|`beat`|`ms`, `note?`|明確宣告的閱讀停頓,沒有隱藏啟動等待。|
+|`note`|`text`|僅記錄到日誌的說明。|
+
+每步前後檢查權限與設定視窗,失敗時中止錄製並寫入失敗記錄,輸出器拒絕失敗片。
+
+
+## 說明與字幕
+
+`chapter`、`caption` 攜帶 `text`,字幕可使用 `headline | explanation`。空文字清除目前軌道,`clearOverlay` 清除全部說明;`key`、`hold` 自動產生按鍵顯示。獨立 `keys` 標籤只允許手動排練,發布場景停用,以免暗示未發生的輸入。
+
+`take` 預設為 `--overlay off`,文字會儲存在可編輯的 ASS 與 JSON 附屬檔案中。匯出的影片會在畫面下方疊加字幕、左上角顯示章節、右上角顯示實際按鍵。半透明背景提高了可讀性,但仍可能遮住 App 內容,因此重要控制項應避開這些區域。`scene` 使用即時排練面板,呈現方式與最終影片不同。
+
+位置與時間、容量預算見 [publication.json](../../publication.json);錄製、輸出與檢查指令見 [README.md](../../zh-Hant/README.md)。
diff --git a/DemoLab/docs/zh-Hant/VM-TART.md b/DemoLab/docs/zh-Hant/VM-TART.md
new file mode 100644
index 0000000..1bbdbec
--- /dev/null
+++ b/DemoLab/docs/zh-Hant/VM-TART.md
@@ -0,0 +1,79 @@
+
+[English](../VM-TART.md) · [한국어](../ko/VM-TART.md) · [日本語](../ja/VM-TART.md) · [简体中文](../zh-Hans/VM-TART.md) · [繁體中文](VM-TART.md)
+
+
+
+# 在 Tart VM 中錄製
+
+專用 `tatami-demo` 隔離個人視窗、偏好與權限。目前使用 macOS 26.6.2、固定 1920×1200 主螢幕、實際 Tatami 和八個示範 App;第二屏來自 VM 內虛擬顯示器,見 [MULTI-DISPLAY.md](MULTI-DISPLAY.md)。
+
+
+## 準備虛擬機
+
+主機需要 Apple 晶片、Swift 6.2 或更新版本,以及支援 `exec` 的 Tart。虛擬機需要 Tart 代理程式、Command Line Tools 和 Tatami。主機指令請在儲存庫根目錄執行,虛擬機指令則在 `~/DemoLab` 中執行。
+
+```sh
+swift build --package-path Tools -c release
+Tools/.build/release/tatami-tools vm-bootstrap
+```
+
+透過 `1920x1200px` 與 `--no-display-refit` 固定解析度,調整主機 VM 視窗不改變錄製畫布,共用掛載於 `/Volumes/My Shared Files/demolab`。
+
+設定虛擬機之前,先將要使用的 Tatami 版本複製到 Demo Lab 共用資料夾。
+
+```sh
+ditto /Applications/Tatami.app DemoLab/.build/Tatami.app
+TATAMI_APP="/Volumes/My Shared Files/demolab/.build/Tatami.app" \
+ Tools/.build/release/tatami-tools vm-provision
+```
+
+也接受 `TATAMI_DMG` 取代 `TATAMI_APP`。SwiftPM 在虛擬機本機磁碟建置,打包時把全部示範 App 註冊到 LaunchServices,以便依套件 ID 解析。
+
+透過 VM 視窗或螢幕共享開啟,依 [PERMISSIONS.md](PERMISSIONS.md) 在系統設定中授權,同時測試錄製器與置頂。目前代理路徑有擷取與輸入權限,但新映像或啟動器需另查。不要修改 TCC 列,也不能僅憑錄製器預備判斷鏡像權限。
+
+
+## 錄製明確的批次
+
+```sh
+# Host: sync current sources and rebuild in the guest.
+Tools/.build/release/tatami-tools vm-sync
+
+# Guest: each scene gets fresh lab data and preferences suppression.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --continue-on-error \
+ --output /Users/admin/DemoLab/recordings/review-batch
+
+# Host: a new destination, retaining originals and all provenance sidecars.
+GUEST_DIR=DemoLab/recordings/review-batch \
+ Tools/.build/release/tatami-tools vm-fetch-recordings DemoLab/recordings/review-batch
+
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/review-batch \
+ --output ~/Downloads/TatamiDemoLab-review
+```
+
+用 `--scenes tour cli desk` 排練子集,不覆蓋既有錄製。`--continue-on-error` 保留失敗、在場景間清理,任一失敗即回傳非零代碼。`capture-report.json` 記錄各項結果,只輸出符合目前場景的通過片段。
+
+錄製時避免主機進行繁重編碼或編譯。VM 與主機共用 CPU,遺失影格逐顯示器計量,應在錄製結束後編碼。
+
+
+## 共用檔案系統正確性
+
+雙向傳輸都使用唯一封存檔名稱。實測主機移動舊檔後,virtiofs 重寫同名檔案可能顯示舊位元組或舊 inode;新名稱消除了該重複使用路徑。
+
+只同步受管原始碼目錄,刪除的 Swift 檔案不會留在 VM,保留 `.build` 與錄影。透過唯一標記確認共用位置,比較兩端 SHA256,再解壓到新目錄。包含影片、字幕、時間軸、中繼資料、凍結場景與批次報告。
+
+
+## 結束或保存映像
+
+```sh
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl quit
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl display disconnect
+tart exec tatami-demo /Users/admin/DemoLab/.build/DemoLab/bin/democtl restore
+```
+
+`restore` 還原錄製前備份的偏好,不涉及個人主機設定。關閉並驗證的 VM 可用 `tart clone` 保存,淘汰映像前請先把原始檔案與證據存到外部。
+
+
+## 已驗證界線
+
+v4 驗證了實際 ScreenCaptureKit、輸入與 AX 讀回、並排和借用、獨立雙螢幕擷取、工作空間鏈及連接中斷自動組合。實體擴充座、手勢、混合 DPI/HDR 和新系統需另查;原生全螢幕還原失敗見 [COVERAGE.md](COVERAGE.md)。
diff --git a/DemoLab/ja/README.md b/DemoLab/ja/README.md
new file mode 100644
index 0000000..2879e96
--- /dev/null
+++ b/DemoLab/ja/README.md
@@ -0,0 +1,160 @@
+
+[English](../README.md) · [한국어](../ko/README.md) · [日本語](README.md) · [简体中文](../zh-Hans/README.md) · [繁體中文](../zh-Hant/README.md)
+
+
+
+# Tatami Demo Lab
+
+実際の Tatami を録画し、統一した紹介動画を機能説明の横に置きます。アプリと内容はデモ用ですが、切り替え、配置、借りる操作、フォーカスはインストールした Tatami が行います。
+
+
+## 公開用動画の規則
+
+[`publication.json`](../publication.json) が一覧と編集上限です。各場面を Web の区分に結び、時間とサイズを制限します。
+
+紹介動画はデザイン、執筆、レビュー、借りる操作、自動化から、実際の画面構成変更まで進みます。各動画集では作業、プロファイルと画面、配置とフォーカス、借りる操作、表示方法、CLI・フック、ガイドを扱います。一覧は `publication.json` から生成します。
+
+主動画は**作業を変えても元の場所に戻れる**ことを示します。機能別は別の活動を扱い、同じ動画を繰り返しません。[範囲と検証の境界](../docs/ja/COVERAGE.md)を参照してください。
+
+
+## 録画 → 書き出し → 確認 → ローカル反映
+
+普段のデスクトップではなく専用の Tart VM を使います。`reset` と `seed` は実行先の Tatami を終了して設定を変えます。設定と配置は `.build/lab/` に隔離し、環境設定を別途保存して `democtl restore` で復元できます。
+
+```sh
+# Host: run from the repository root.
+swift build --package-path Tools -c release
+TOOL=Tools/.build/release/tatami-tools
+"$TOOL" vm-sync
+
+# Guest: a fresh seed for every scene. Existing takes are never overwritten.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --output /Users/admin/DemoLab/recordings/publish
+
+# Host: fetch that exact batch, including originals, metadata and scene snapshots.
+GUEST_DIR=DemoLab/recordings/publish "$TOOL" vm-fetch-recordings DemoLab/recordings/publish
+
+# Host: new output directory; no ambiguous “latest take” selection.
+"$TOOL" export --takes DemoLab/recordings/publish --output ~/Downloads/TatamiDemoLab-review
+
+# Open index.html and inspect playback, opening frames, actions and every feature.
+# Install the verified bundle into this checkout only; this does not push or deploy.
+"$TOOL" install-assets ~/Downloads/TatamiDemoLab-review
+```
+
+ホストには Swift 6.2 以降、`tart`、libass/libx264 に対応した `mpv`、`ffmpeg`、`ffprobe` が必要です。ホストのコマンドはリポジトリのルートで実行します。ゲストには Xcode Command Line Tools が必要です。ソースの同期時にビルド済みの自動化実行ファイルも転送するため、ゲストでパッケージをダウンロードする必要はありません。レコーダーは Tatami の Tuist グラフから独立した SwiftPM パッケージです。
+
+自動化には `swift-subprocess`、ArgumentParser、SwiftSoup、Hummingbird、`swift-markdown`、`swift-cmark`、Swift Crypto を使います。JSON とプロパティリストは Foundation で処理します。録画の合格条件、翻訳単位、動画の時間・容量の上限は Tatami 固有のルールとして管理します。依存関係のバージョンは `Tools/Package.resolved` で固定します。
+
+書き出し側の Mac には Fontconfig も必要です。エンコード前に指定したフォントと、すべての字幕文字が表示できるかを確認します。OCR 検証では、映像に重ねた字幕をタイムラインと照合します。
+
+
+## 実際に使える小さなアプリ集
+
+|ワークスペース|アプリ|実際の作業|
+| --- | --- | --- |
+|Design|Canvas + Docs|配色を変え、保存した下書きを確認し、PNG を書き出します。|
+|Write|Editor + Docs|資料を読み、見出しを直して下書きを保存します。|
+|Review|Review + Docs|保存した文章を確認し、コメントして承認します。|
+|Chat|Chat|ローカルのデモ返信を入力して送ります。|
+|Build|Terminal|実際の Tatami CLI と同梱スクリプトを実行します。|
+|Notes(借りる専用)|Notes|次の作業を追加し、チェック項目を完了します。|
+|Focus|Canvas + Notes|その作業でメモを手前に置いて使います。|
+|共有アプリ|Monitor|保存した状態や実際の作業・フィードバックのフックを確認します。|
+
+8 個のアプリが保存済みローカルデータを使います。保存すると Review と Canvas に反映され、PNG も実際に作ります。メモや会話は切り替えても残ります。Terminal は許可したコマンドだけを実行し、フックは実際の環境を受け取ります。会話サービスには接続せず、AI は明示したローカル例です。
+
+
+## 録画規則の変更
+
+場面は二段階に分かれます。
+
+- `setup` は**録画前**に終了処理、起動、作業の準備、データと配置を整えます。
+- `steps` が映る操作です。録画前に `openingApps` の窓と配置を安定させます。`autoopen` だけは自動で開く機能を示すため、短い空画面から始めます。
+
+録画前と各操作の前後で、背後も含め権限・設定ウインドウを確認します。切り抜きやエラー抑制ではなく、そのダイアログを解決してください。
+
+**録画ツールと Tatami の許可は別です。** `doctor` に通っても古い Tatami の画面収録要求が残っていました。「常に手前」は別のストリームなので、録画の準備だけでは確認できません。`shared` を試して実際の窓を確認します。Monitor は全場面で開かず、`shared` が録画前に明示的に開きます。
+
+`waitWindows` は対象アプリと配置の安定を待ちます。`saveLayout`/`assertLayout` は切り替え、返却、ズーム復帰後の同じ ID と枠を比較し、窓の増減や 4 pt を超える差で失敗します。
+
+`key`、`hold` は実際の入力からキー表示を作ります。CLI をキー操作に見せる `keys` は使えません。最初のエンコード済みフレームがなければ開始せず、その単調時刻で字幕と動画を合わせます。
+
+
+## 動画の見せ方
+
+元の MOV には、撮影したデスクトップ全体が含まれます。書き出しは 1920×1200、2 画面の場合は 1920×600 です。説明字幕は映像の下部、章名は左上、実際のキー入力は右上に重ねます。半透明の背景により、明るいアプリでも暗いアプリでも読みやすくなります。色はウェブサイトの配色を使うため、表示の変更に再撮影は必要ありません。
+
+字幕の編集で元映像を再利用できるのは、撮影した操作、入力内容、検証条件、待機時間がすべて同じ場合だけです。書き出しツールは撮影時に固定したシーンのハッシュと操作を検証し、元のタイムライン上の説明文を差し替えます。検証資料には、編集前後のシーンとタイムラインを両方含めます。
+
+各テイクには次のファイルがあります。
+
+- `.mov`:字幕なしの原本。
+- `.ass`:編集できる説明と実際のキー入力時刻。
+- `.timeline.json`:全イベントの開始・終了時刻。
+- `.take.json`:成否、場面ハッシュ、バージョン、言語、出力別フレーム数と損失、開始時刻、表示モード。
+- `.scene.json`:録画開始時に固定した場面の正確なバイト列。
+
+失敗したテイクは診断用に残しますが、書き出しには使えません。撮影条件の検証、正常な字幕、1% 未満のフレーム欠落、遅れのない冒頭字幕、映像とタイムラインの長さの一致が必要です。出力は時間・容量の上限を守り、H.264/yuv420p を使用し、FFmpeg で全編をデコードできる必要があります。`faststart` は MP4 ヘッダーを映像データより前に配置します。
+
+書き出しには再生ギャラリー、ポスター、検証用フレーム、元映像と出力のハッシュ、すべての付随ファイルが含まれます。自動検証だけでは実際の動作確認を代替できません。特に共有ウインドウのミラーリングとフォーカス先は目視確認が必要です。ウェブプレイヤーは標準コントロールと `preload="none"` を使い、手動で再生し、同時に再生する動画は一つです。各動画集にはサムネイル一覧、件数、前後のボタンがあります。各動画から関連する設定キーにも移動できます。矢印キー、Home/End、個別動画へのリンクは、一覧を別途展開せずに使えます。
+
+2 画面ではゲストに実際の仮想画面を接続し、別々に録画して最初の時刻で揃えます。[確認済み構成](../docs/ja/MULTI-DISPLAY.md)を参照してください。
+
+
+## 一つの場面を調整する
+
+録画コマンドは専用ゲスト内で実行します。
+
+```sh
+.build/DemoLab/bin/democtl doctor
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl scene tour --dry-run # prints setup and visible steps
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+# Host: after fetching that batch; run from the repository root.
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/iteration \
+ --output ~/Downloads/TatamiDemoLab-iteration --scenes tour
+```
+
+`democtl scene` は実時間の表示で練習します。`take` の既定は `--overlay off` で、公開にはこのモードだけを使います。練習パネルと出力デザインは別です。`subtitle burn` は確認コピーを作れますが、上限・Web・証拠の検証は `tatami-tools export` を使います。
+
+終了後はゲストで `democtl quit`、`democtl restore` を実行します。ホスト側の Tatami や設定には関与しません。
+
+
+## 開発と参考情報
+
+```sh
+swift test --package-path Tools
+swift run --package-path Tools tatami-tools build-tool-notices --check
+swift run --package-path Tools tatami-tools bundle-apps
+DEMOLAB_LOCALIZATION_DIR="$PWD/DemoLab/.build/DemoLab/Localization" \
+ swift test --package-path DemoLab
+```
+
+- [場面の語彙](../docs/ja/SCENES.md)
+- [VM の準備](../docs/ja/VM-TART.md)
+- [権限](../docs/ja/PERMISSIONS.md)
+- [実際の複数画面録画](../docs/ja/MULTI-DISPLAY.md)
+
+
+## 5 言語での制作
+
+`en`、`ko`、`ja`、`zh-Hans`、`zh-Hant` に対応します。`Localization/Localizable.xcstrings` は UI と初期内容、`Localization/Films.json` は動画と入力、`Localization/Interface.json` は確認ギャラリーを管理します。固定 ID がなければ製品カタログの AX ラベルを使います。アプリ名やユーザーの作業・プロファイル名は保ちます。
+
+```sh
+Tools/.build/release/tatami-tools localize-scenes
+Tools/.build/release/tatami-tools vm-sync
+# Inside the guest:
+.build/tools/tatami-tools capture --locale ko --output recordings/ko-batch
+# After fetching that explicit batch to the host:
+Tools/.build/release/tatami-tools export --locale ko --takes DemoLab/recordings/ko-batch --output ~/Downloads/Tatami-ko
+```
+
+デモと Tatami の言語を揃え、入力と検証に同じ文を使います。実際の CLI コマンドと出力は保ちます。最終動画が 30 fps なので、録画も 30 fps にして不要な処理を減らします。
+
+`tatami-tools record-locales` は言語ごとに、未撮影または検証に失敗したテイクを見つけます。英語の映像を翻訳済み UI の検証とは見なしません。失敗したシーンは診断用に残し、ほかの言語は独立して進められます。
+
+小さなツール窓は右下から始め、主動画では左下へ実際に動かします。調整時も文書の主な読み取り領域を空けてください。
+
+ターミナルのコマンド終了後もローカルサイトのプレビューを続けるには、リポジトリのルートで `Tools/.build/release/tatami-tools preview-site --background` を実行します。
diff --git a/DemoLab/ko/README.md b/DemoLab/ko/README.md
new file mode 100644
index 0000000..41f2782
--- /dev/null
+++ b/DemoLab/ko/README.md
@@ -0,0 +1,160 @@
+
+[English](../README.md) · [한국어](README.md) · [日本語](../ja/README.md) · [简体中文](../zh-Hans/README.md) · [繁體中文](../zh-Hant/README.md)
+
+
+
+# Tatami Demo Lab
+
+실제 Tatami를 녹화해 일관된 제품 영상을 만들고 해당 기능 설명 옆에 배치해요. 앱과 내용은 데모용이며 전환·타일링·빌려오기·포커스는 설치한 Tatami가 실제로 수행해요.
+
+
+## 공개용 영상 규칙
+
+[`publication.json`](../publication.json)에 영상 목록과 편집 예산을 정해요. 각 장면을 웹 섹션에 연결하고 길이와 파일 크기를 제한해요.
+
+종합 영상은 디자인 → 글쓰기 → 검토 → 빌려오기 → 자동화에 이어 실제 화면 구성 변경으로 끝나요. 기능별 모음은 작업 공간, 프로필·화면, 타일링·포커스, 빌려오기, 창 표시, CLI·훅, 가이드 설정을 다뤄요. 목록은 `publication.json`에서 만들므로 별도로 중복 관리하지 않아요.
+
+종합 영상은 **작업을 바꿔도 하던 자리를 유지한다**는 약속을 보여줘요. 기능별 영상은 다른 활동을 보여주며 종합 영상을 반복하지 않아요. 전체 범위는 [기능과 검증 범위](../docs/ko/COVERAGE.md)에서 확인해요.
+
+
+## 촬영 → 내보내기 → 검수 → 로컬 반영
+
+평소 쓰는 데스크톱 대신 전용 Tart VM을 사용해요. `reset`과 `seed`은 실행한 컴퓨터의 Tatami를 종료하고 환경설정을 바꿔요. 설정·배치 파일은 `.build/lab/`에 격리하고 환경설정 도메인은 별도 백업해서 `democtl restore`으로 복구할 수 있어요.
+
+```sh
+# Host: run from the repository root.
+swift build --package-path Tools -c release
+TOOL=Tools/.build/release/tatami-tools
+"$TOOL" vm-sync
+
+# Guest: a fresh seed for every scene. Existing takes are never overwritten.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --output /Users/admin/DemoLab/recordings/publish
+
+# Host: fetch that exact batch, including originals, metadata and scene snapshots.
+GUEST_DIR=DemoLab/recordings/publish "$TOOL" vm-fetch-recordings DemoLab/recordings/publish
+
+# Host: new output directory; no ambiguous “latest take” selection.
+"$TOOL" export --takes DemoLab/recordings/publish --output ~/Downloads/TatamiDemoLab-review
+
+# Open index.html and inspect playback, opening frames, actions and every feature.
+# Install the verified bundle into this checkout only; this does not push or deploy.
+"$TOOL" install-assets ~/Downloads/TatamiDemoLab-review
+```
+
+호스트에는 Swift 6.2 이상, `tart`, libass/libx264를 지원하는 `mpv`, `ffmpeg`, `ffprobe`이 필요해요. 호스트 명령은 저장소 루트에서 실행해요. 게스트에는 Xcode Command Line Tools가 필요해요. 소스를 동기화할 때 빌드된 자동화 실행 파일을 함께 전달하므로 게스트에서 패키지를 다운로드하지 않아요. 녹화기는 Tatami의 Tuist 그래프와 분리된 독립 SwiftPM 패키지로 유지돼요.
+
+자동화에는 `swift-subprocess`, ArgumentParser, SwiftSoup, Hummingbird, `swift-markdown`, `swift-cmark`, Swift Crypto를 사용해요. JSON과 속성 목록은 Foundation으로 처리해요. 녹화 승인 조건, 번역 단위, 영상 길이·용량 제한은 Tatami 고유의 규칙으로 유지해요. `Tools/Package.resolved`에서 의존성 버전을 고정해요.
+
+내보내기를 실행할 Mac에는 Fontconfig도 필요해요. 인코딩 전에 지정한 글꼴과 모든 자막 글자 지원 여부를 확인해요. OCR 검증은 영상 위에 표시된 자막을 타임라인과 별도로 비교해요.
+
+
+## 실제로 쓰는 작은 앱 모음
+
+|작업 공간|앱|실제 작업|
+| --- | --- | --- |
+|Design|Canvas + Docs|색상 방향을 바꾸고 저장한 초안을 확인한 뒤 PNG를 내보내요.|
+|Write|Editor + Docs|안내를 읽고 제목을 고친 뒤 초안을 저장해요.|
+|Review|Review + Docs|저장한 문구를 검사하고 의견을 남겨 승인해요.|
+|Chat|Chat|로컬 데모 답장을 입력하고 보내요.|
+|Build|Terminal|실제 Tatami CLI와 포함된 자동화 스크립트를 실행해요.|
+|Notes(빌려오기 전용)|Notes|후속 작업을 적고 체크리스트 항목을 완료해요.|
+|Focus|Canvas + Notes|해당 작업 공간의 메모를 항상 위에 두고 사용해요.|
+|공용 앱|Monitor|저장한 프로젝트 상태나 실제 작업 공간·화면 알림 훅을 확인해요.|
+
+8개 앱이 로컬에 저장한 데이터를 사용해요. 저장하면 Review와 Canvas에 반영되고 PNG도 실제로 만들어져요. 메모와 대화는 공간을 바꿔도 남아요. Terminal은 허용한 명령과 스크립트만 실행하고 훅은 실제 Tatami 환경을 받아요. 대화 서비스에는 연결하지 않으며 AI 영상은 표시해둔 로컬 예시를 사용해요.
+
+
+## 촬영 규칙의 변화
+
+장면은 두 단계로 나뉘어요.
+
+- `setup`은 **촬영 전에** 종료·앱 실행·작업 공간 준비·데이터와 배치 설정을 해요.
+- `steps`은 영상에 보이는 동작이에요. 녹화 전에 `openingApps`의 창과 배치가 안정돼야 해요. `autoopen`만 자동 열기를 보여주기 위해 잠깐 빈 화면으로 시작해요.
+
+녹화 전과 각 동작 전후에 시스템 권한·설정 창을 검사해요. 다른 창 뒤의 권한 창도 찾아요. 오류를 숨기거나 잘라내지 말고 해당 창을 해결하세요.
+
+**녹화기 권한과 Tatami 권한은 별개예요.** `doctor`을 통과해도 오래된 Tatami 화면 기록 요청이 남은 적이 있어요. 항상 위 기능은 별도 ScreenCaptureKit 스트림을 쓰므로 녹화기 준비만으로는 확인할 수 없어요. `shared`을 미리 실행해 실제 상태 창을 확인하세요. 공용 Monitor는 모든 장면에 자동으로 열지 않고 `shared`에서 촬영 전에 명시적으로 열어요.
+
+`waitWindows`은 지정 앱과 안정된 배치를 기다려요. `saveLayout`/`assertLayout`는 공간 전환·빌려오기 종료·확대 복귀 뒤에 같은 창 ID와 영역인지 비교해요. 창이 빠지거나 늘거나 4포인트 넘게 움직이면 실패해요.
+
+`key`, `hold`은 실제 입력으로 키 표시를 만들어요. `keys` 문구로 CLI 실행을 키 입력처럼 꾸미면 안 돼요. 첫 인코딩 프레임이 없으면 시작을 거부하고, 그 프레임의 단조 시간을 넘겨 영상과 자막의 시계를 맞춰요.
+
+
+## 화면 구성
+
+원본 MOV에는 촬영한 데스크톱 전체가 담겨요. 내보낼 때는 1920×1200을 유지하고, 두 화면 영상은 1920×600으로 만들어요. 자막은 영상 아래쪽에, 장면 이름은 왼쪽 위에, 실제 키 입력은 오른쪽 위에 겹쳐 표시해요. 반투명 배경으로 밝거나 어두운 앱 위에서도 읽기 쉽게 했어요. 색상은 웹사이트 팔레트를 사용하므로 표현을 바꿀 때 다시 촬영할 필요는 없어요.
+
+자막만 고칠 때는 촬영한 동작, 입력 내용, 검증 조건, 대기 시간이 모두 같아야 원본을 재사용할 수 있어요. 내보내기 도구는 촬영 당시 고정한 장면 파일의 해시와 동작을 검증한 뒤 원래 타임라인의 자막을 교체해요. 증거 자료에는 원본과 수정본의 장면 파일 및 타임라인을 모두 포함해요.
+
+각 테이크에는 다음 파일이 있어요.
+
+- `.mov`: 자막 없는 원본 영상.
+- `.ass`: 수정할 수 있는 설명과 실제 키 입력 시간.
+- `.timeline.json`: 모든 이벤트의 시작·끝 시간.
+- `.take.json`: 성공 여부, 장면 해시, Tatami 버전, 언어, 출력별 프레임·손실 수, 시작 시각과 오버레이 모드.
+- `.scene.json`: 녹화 시작 시 고정한 정확한 장면 파일.
+
+실패한 촬영본은 진단용으로 보관하지만 내보내기에는 사용할 수 없어요. 촬영 조건 검증, 정상 자막, 1% 미만의 프레임 손실, 제때 나오는 시작 자막, 영상과 타임라인 길이 일치가 필요해요. 결과물은 시간·용량 제한을 지키고 H.264/yuv420p를 사용하며 FFmpeg 전체 디코딩을 통과해야 해요. `faststart`는 MP4 헤더를 영상 데이터 앞에 배치해요.
+
+내보내기 묶음에는 재생 갤러리, 포스터, 추출한 검증 프레임, 원본·결과물 해시와 모든 부가 파일이 들어 있어요. 자동 검증이 실제 동작 확인을 대신하지는 않아요. 특히 공용 창 미러링과 포커스 대상은 화면으로 확인해야 해요. 웹 플레이어는 기본 컨트롤과 `preload="none"`를 사용하며, 직접 재생하고 한 번에 영상 하나만 재생돼요. 각 모음에는 썸네일 목록, 개수, 이전·다음 버튼이 있어요. 영상마다 관련 설정 키로 가는 링크도 제공해요. 방향키, Home/End, 개별 영상 링크는 별도 펼치기 없이 사용할 수 있어요.
+
+두 화면 촬영은 게스트에 실제 가상 디스플레이를 연결하고 각각 녹화한 뒤 첫 프레임 시각으로 맞춰요. [확인한 VM 구성](../docs/ko/MULTI-DISPLAY.md)을 참고하세요.
+
+
+## 장면 하나 수정하기
+
+촬영 명령은 전용 게스트 안에서 실행해요.
+
+```sh
+.build/DemoLab/bin/democtl doctor
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl scene tour --dry-run # prints setup and visible steps
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+# Host: after fetching that batch; run from the repository root.
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/iteration \
+ --output ~/Downloads/TatamiDemoLab-iteration --scenes tour
+```
+
+`democtl scene`은 실시간 설명을 띄워 연습해요. 녹화 `take`의 기본값은 `--overlay off`이며 공개용으로는 이 모드만 받아요. 연습 패널과 출력 디자인은 별개예요. `subtitle burn`으로 확인용 영상을 만들 수 있지만 예산·웹 인코딩·증거 검사는 `tatami-tools export`를 사용하세요.
+
+마치면 게스트에서 `democtl quit`, `democtl restore`을 실행해요. 호스트의 Tatami와 원래 환경설정은 건드리지 않아요.
+
+
+## 개발과 참고 자료
+
+```sh
+swift test --package-path Tools
+swift run --package-path Tools tatami-tools build-tool-notices --check
+swift run --package-path Tools tatami-tools bundle-apps
+DEMOLAB_LOCALIZATION_DIR="$PWD/DemoLab/.build/DemoLab/Localization" \
+ swift test --package-path DemoLab
+```
+
+- [장면 문법](../docs/ko/SCENES.md)
+- [VM 설정](../docs/ko/VM-TART.md)
+- [권한](../docs/ko/PERMISSIONS.md)
+- [실제 두 화면 촬영](../docs/ko/MULTI-DISPLAY.md)
+
+
+## 다섯 언어 영상 제작
+
+`en`, `ko`, `ja`, `zh-Hans`, `zh-Hant`를 지원해요. `Localization/Localizable.xcstrings`는 앱 문구와 초기 데이터, `Localization/Films.json`은 영상 제목·설명·입력 문구, `Localization/Interface.json`은 검수용 모음을 관리해요. 고유 ID가 없으면 제품 카탈로그에서 Tatami의 AX 문구를 가져와요. 앱과 사용자 지정 작업 공간·프로필 이름은 유지해요.
+
+```sh
+Tools/.build/release/tatami-tools localize-scenes
+Tools/.build/release/tatami-tools vm-sync
+# Inside the guest:
+.build/tools/tatami-tools capture --locale ko --output recordings/ko-batch
+# After fetching that explicit batch to the host:
+Tools/.build/release/tatami-tools export --locale ko --takes DemoLab/recordings/ko-batch --output ~/Downloads/Tatami-ko
+```
+
+앱과 Tatami의 언어를 함께 설정해요. 입력과 검증은 같은 문구를 사용하고 실제 CLI 명령·출력은 유지해요. 최종 영상이 30fps라 촬영도 30fps로 해서 불필요한 60fps 처리를 줄여요.
+
+`tatami-tools record-locales`는 언어별로 없거나 검증에 실패한 촬영본을 찾아요. 영어 촬영본으로 번역된 UI를 검증했다고 간주하지 않아요. 실패한 장면은 진단용으로 보관하고 다른 언어는 독립적으로 진행할 수 있어요.
+
+작은 도구 창은 오른쪽 아래에서 시작해요. 종합 영상은 상태 창을 왼쪽 아래로 직접 옮겨요. 장면을 바꿀 때도 문서의 주요 읽기 영역은 비워두세요.
+
+터미널 명령이 끝난 뒤에도 로컬 웹사이트 미리보기를 유지하려면 저장소 루트에서 `Tools/.build/release/tatami-tools preview-site --background`을 실행해요.
diff --git a/DemoLab/publication.json b/DemoLab/publication.json
new file mode 100644
index 0000000..20a43c2
--- /dev/null
+++ b/DemoLab/publication.json
@@ -0,0 +1,516 @@
+{
+ "schemaVersion": 1,
+ "canvas": {
+ "width": 1920,
+ "height": 1200
+ },
+ "assets": [
+ {
+ "scene": "tour",
+ "title": "Make room for the whole workflow.",
+ "description": "Design, write, review, borrow, automate, and adapt to a bigger desk.",
+ "section": "demo",
+ "maxSeconds": 150,
+ "maxMB": 30,
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaces",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "settings.layout",
+ "anchor": "settingslayout"
+ }
+ ]
+ },
+ {
+ "scene": "workspaces",
+ "title": "Move between tasks",
+ "description": "A shortcut switches to another workspace and its apps.",
+ "section": "workspaces",
+ "maxSeconds": 32,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.keyEquivalentModifiers",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.switching.recentAcrossDisplays",
+ "anchor": "settingsswitching"
+ }
+ ]
+ },
+ {
+ "scene": "autoopen",
+ "title": "Open the whole toolset",
+ "description": "Activating a workspace automatically opens its assigned apps.",
+ "section": "workspaces",
+ "maxSeconds": 25,
+ "maxMB": 16,
+ "posterCaptionIndex": 1,
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaces.apps.autoOpen",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "profiles.workspaces.keyEquivalent",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "membership",
+ "title": "Move an app with the task",
+ "description": "Move the focused app to a different workspace with a shortcut.",
+ "section": "workspaces",
+ "maxSeconds": 28,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.assignModifiers",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.shortcuts.moveToNextWorkspace",
+ "anchor": "settingsshortcuts"
+ }
+ ]
+ },
+ {
+ "scene": "profiles",
+ "title": "Switch the complete setup",
+ "description": "Switching profiles changes the complete workspace configuration.",
+ "section": "profiles",
+ "maxSeconds": 22,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "profiles.shortcut",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "profiles.workspaces",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "profile-copy",
+ "title": "Reuse an existing setup",
+ "description": "Duplicate a profile in the settings window and review the workspaces to include.",
+ "section": "profiles",
+ "maxSeconds": 35,
+ "maxMB": 16,
+ "seedOptions": [
+ "--keep-window"
+ ],
+ "relatedSettings": [
+ {
+ "key": "profiles",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "workspace-copy",
+ "title": "Copy settings between workspaces",
+ "description": "Use Copy from to review another workspace’s apps and settings, then apply the selected changes.",
+ "section": "profiles",
+ "maxSeconds": 40,
+ "maxMB": 16,
+ "seedOptions": [
+ "--keep-window"
+ ],
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaces.apps",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "profiles.workspaces",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "tiling",
+ "title": "Let new windows find their place",
+ "description": "A new window splits the space beside the focused window.",
+ "section": "tiling",
+ "maxSeconds": 20,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.layout.splitType",
+ "anchor": "settingslayout"
+ },
+ {
+ "key": "settings.layout.windowPlacement",
+ "anchor": "settingslayout"
+ },
+ {
+ "key": "settings.layout.autoBalance",
+ "anchor": "settingslayout"
+ }
+ ]
+ },
+ {
+ "scene": "layouts",
+ "title": "Shape the layout from the keyboard",
+ "description": "Swap windows, change a split, resize it, and balance the workspace.",
+ "section": "tiling",
+ "maxSeconds": 27,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.resizeGrow",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.shortcuts.balance",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.layout.autoBalance",
+ "anchor": "settingslayout"
+ }
+ ]
+ },
+ {
+ "scene": "drag",
+ "title": "Arrange it by hand",
+ "description": "Drag one window onto another, resize an edge, and return to the saved arrangement.",
+ "section": "tiling",
+ "maxSeconds": 32,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.layout",
+ "anchor": "settingslayout"
+ },
+ {
+ "key": "settings.gestures",
+ "anchor": "settingsgestures"
+ }
+ ]
+ },
+ {
+ "scene": "fullscreen",
+ "title": "Focus with workspace zoom",
+ "description": "Inspect one tile at full workspace size, then return to the exact arrangement.",
+ "section": "tiling",
+ "maxSeconds": 30,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.toggleFullscreen",
+ "anchor": "settingsshortcuts"
+ }
+ ]
+ },
+ {
+ "scene": "focus",
+ "title": "Focus follows your intent",
+ "description": "Use shortcuts to select neighboring windows and move the pointer, or select windows by hovering.",
+ "section": "tiling",
+ "maxSeconds": 38,
+ "maxMB": 16,
+ "seedOptions": [
+ "--focus-follows-mouse"
+ ],
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.focusLeft",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.shortcuts.focusRight",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.focus.mouseFollowsFocus",
+ "anchor": "settingsfocus"
+ },
+ {
+ "key": "settings.focus.focusFollowsMouse",
+ "anchor": "settingsfocus"
+ }
+ ]
+ },
+ {
+ "scene": "switching",
+ "title": "Choose the window you need",
+ "description": "Hold the switcher shortcut to inspect the windows in this workspace, then switch back with a quick tap.",
+ "section": "tiling",
+ "maxSeconds": 20,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.cycleNextWindow",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.switching.cycleSameAppWindows",
+ "anchor": "settingsswitching"
+ }
+ ]
+ },
+ {
+ "scene": "borrow",
+ "title": "Bring a conversation alongside",
+ "description": "Borrow another workspace without leaving the current task.",
+ "section": "borrow",
+ "maxSeconds": 24,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts.borrowModifiers",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.switching.borrowDefaultEdge",
+ "anchor": "settingsswitching"
+ }
+ ]
+ },
+ {
+ "scene": "scratchpad",
+ "title": "Keep a reusable scratchpad",
+ "description": "Borrow the scratchpad again; its apps keep their contents.",
+ "section": "borrow",
+ "maxSeconds": 25,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaces.kind",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "profiles.workspaces.borrowEdge",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "borrow-placement",
+ "title": "Choose the edge and the context",
+ "description": "Dock a workspace left or below, cross the boundary with focus, then activate it fully.",
+ "section": "borrow",
+ "maxSeconds": 30,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.switching.borrowDefaultEdge",
+ "anchor": "settingsswitching"
+ },
+ {
+ "key": "settings.switching.borrowFraction",
+ "anchor": "settingsswitching"
+ }
+ ]
+ },
+ {
+ "scene": "floating",
+ "title": "Keep a note above this workspace",
+ "description": "Switch a note between Always on Top and tiled placement, and interact with its real controls.",
+ "section": "floating",
+ "maxSeconds": 28,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaces.apps.layout",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "settings.shortcuts.toggleFloating",
+ "anchor": "settingsshortcuts"
+ }
+ ]
+ },
+ {
+ "scene": "shared",
+ "title": "One status window across tasks",
+ "description": "A shared Always on Top window stays visible across workspaces.",
+ "section": "floating",
+ "maxSeconds": 28,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "sharedApps.layout",
+ "anchor": "sharedapps"
+ },
+ {
+ "key": "settings.shortcuts.toggleSharedFloating",
+ "anchor": "settingsshortcuts"
+ }
+ ]
+ },
+ {
+ "scene": "unmanaged",
+ "title": "Leave a window where you put it",
+ "description": "Shared membership with normal stacking: the window keeps its position and size across tasks.",
+ "section": "floating",
+ "maxSeconds": 27,
+ "maxMB": 16,
+ "seedOptions": [
+ "--pulse",
+ "unmanaged"
+ ],
+ "relatedSettings": [
+ {
+ "key": "sharedApps.layout",
+ "anchor": "sharedapps"
+ }
+ ]
+ },
+ {
+ "scene": "cli",
+ "title": "Build a focus session with the CLI",
+ "description": "Inspect the real workspace list, then run a short script that activates, balances and borrows.",
+ "section": "automation",
+ "maxSeconds": 34,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "profiles.workspaces",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "hooks",
+ "title": "Make workspace changes useful elsewhere",
+ "description": "Hooks notify external tools when the workspace changes.",
+ "section": "automation",
+ "maxSeconds": 30,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "hooks",
+ "anchor": "hooks"
+ }
+ ]
+ },
+ {
+ "scene": "configuration",
+ "title": "Edit plain TOML, see a live change",
+ "description": "Edit TOML to change window gaps while Tatami is running.",
+ "section": "automation",
+ "maxSeconds": 33,
+ "maxMB": 16,
+ "relatedSettings": [
+ {
+ "key": "settings.layout.gapInner",
+ "anchor": "settingslayout"
+ },
+ {
+ "key": "settings.layout.gapOuter",
+ "anchor": "settingslayout"
+ }
+ ]
+ },
+ {
+ "scene": "desk",
+ "title": "Two displays, one workflow",
+ "description": "Keep design and reference on separate displays; switch a workspace chain with one shortcut.",
+ "section": "profiles",
+ "maxSeconds": 34,
+ "maxMB": 16,
+ "presentation": "dual",
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaceChains",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "profiles.workspaces.displayHint",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "hotplug",
+ "title": "Your setup follows the displays",
+ "description": "Connect a second display to activate the Desk profile. Disconnect it to return to the Laptop arrangement.",
+ "section": "profiles",
+ "maxSeconds": 36,
+ "maxMB": 16,
+ "presentation": "dynamic-dual",
+ "relatedSettings": [
+ {
+ "key": "profiles.autoActivation.displayCount",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "layout-editor",
+ "title": "Plan the layout visually",
+ "description": "Edit the saved layout before activating the workspace.",
+ "section": "tiling",
+ "maxSeconds": 35,
+ "maxMB": 16,
+ "seedOptions": [
+ "--keep-window"
+ ],
+ "relatedSettings": [
+ {
+ "key": "settings.layout",
+ "anchor": "settingslayout"
+ },
+ {
+ "key": "profiles.workspaces.apps.layout",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ },
+ {
+ "scene": "guided-setup",
+ "title": "Learn by doing",
+ "description": "Preview gesture actions and practise real shortcuts without moving the live desktop. Physical trackpad input is not shown.",
+ "section": "guided-setup",
+ "maxSeconds": 45,
+ "maxMB": 16,
+ "seedOptions": [
+ "--keep-window"
+ ],
+ "relatedSettings": [
+ {
+ "key": "settings.shortcuts",
+ "anchor": "settingsshortcuts"
+ },
+ {
+ "key": "settings.gestures",
+ "anchor": "settingsgestures"
+ }
+ ]
+ },
+ {
+ "scene": "ai-draft",
+ "title": "Review a suggested setup",
+ "description": "Validate an example AI proposal and apply it to the draft. No external AI service is called in this recording.",
+ "section": "guided-setup",
+ "maxSeconds": 32,
+ "maxMB": 16,
+ "seedOptions": [
+ "--keep-window"
+ ],
+ "relatedSettings": [
+ {
+ "key": "profiles.workspaces.apps",
+ "anchor": "profiles-and-workspaces"
+ },
+ {
+ "key": "profiles.workspaces",
+ "anchor": "profiles-and-workspaces"
+ }
+ ]
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ai-draft.json b/DemoLab/scenes/ai-draft.json
new file mode 100644
index 0000000..c2fa037
--- /dev/null
+++ b/DemoLab/scenes/ai-draft.json
@@ -0,0 +1,109 @@
+{
+ "name": "ai-draft",
+ "title": "Review a suggested setup",
+ "summary": "Validate an example AI proposal and apply it to the draft. No external AI service is called in this recording.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Tatami"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Run Guided Setup…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Continue"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Continue"
+ },
+ {
+ "kind": "clipboard",
+ "text": "{\n \"workspaces\": [\n {\n \"name\": \"Design review\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Canvas\",\n \"dev.PangMo5.DemoLab.Docs\"\n ]\n },\n {\n \"name\": \"Writing\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Editor\"\n ]\n },\n {\n \"name\": \"Conversation\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Chat\"\n ]\n },\n {\n \"name\": \"Quick notes\",\n \"kind\": \"scratchpad\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Notes\"\n ]\n }\n ]\n}\n"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AI PLANNING / EXAMPLE PROPOSAL"
+ },
+ {
+ "kind": "caption",
+ "text": "Review an AI-proposed configuration before applying it."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3. Paste Result"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:Design review",
+ "value": "Design review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "Check which apps are assigned to each proposed workspace."
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "description:Apply Plan to Draft"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Apply Plan to Draft"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "text:Design review"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "caption",
+ "text": "Only the draft changes. Your desktop stays put."
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/automation-pilot.json b/DemoLab/scenes/automation-pilot.json
new file mode 100644
index 0000000..78651f9
--- /dev/null
+++ b/DemoLab/scenes/automation-pilot.json
@@ -0,0 +1,86 @@
+{
+ "name": "automation-pilot",
+ "title": "Automation pilot",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "CLI / WORKSPACES"
+ },
+ {
+ "kind": "caption",
+ "text": "One command. A workspace ready for work."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 70
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectStory",
+ "field": "theme",
+ "value": "sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ]
+}
diff --git a/DemoLab/scenes/autoopen.json b/DemoLab/scenes/autoopen.json
new file mode 100644
index 0000000..44caaf2
--- /dev/null
+++ b/DemoLab/scenes/autoopen.json
@@ -0,0 +1,120 @@
+{
+ "name": "autoopen",
+ "title": "Open the whole toolset",
+ "summary": "Activating a workspace automatically opens its assigned apps.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Chat",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "quitApps"
+ },
+ {
+ "kind": "beat",
+ "ms": 500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AUTO-OPEN"
+ },
+ {
+ "kind": "caption",
+ "text": "Activating a workspace automatically opens its assigned apps."
+ },
+ {
+ "kind": "beat",
+ "ms": 200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "With Auto-open enabled, returning also reopens closed windows."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": []
+}
diff --git a/DemoLab/scenes/borrow-placement.json b/DemoLab/scenes/borrow-placement.json
new file mode 100644
index 0000000..87e2e7c
--- /dev/null
+++ b/DemoLab/scenes/borrow-placement.json
@@ -0,0 +1,174 @@
+{
+ "name": "borrow-placement",
+ "title": "Choose the edge and the context",
+ "summary": "Dock a workspace left or below, cross the boundary with focus, then activate it fully.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "configure",
+ "field": "borrowDefaultEdge",
+ "value": "remove"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "BORROW PLACEMENT"
+ },
+ {
+ "kind": "caption",
+ "text": "Choose which edge to place the borrowed workspace on."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "left"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "Borrow can also place a workspace below the current one."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "down"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "Activate the borrowed workspace to switch to it completely."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/borrow.json b/DemoLab/scenes/borrow.json
new file mode 100644
index 0000000..3b297c7
--- /dev/null
+++ b/DemoLab/scenes/borrow.json
@@ -0,0 +1,126 @@
+{
+ "name": "borrow",
+ "title": "Bring a conversation alongside",
+ "summary": "Borrow another workspace without leaving the current task.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "BORROW A WORKSPACE"
+ },
+ {
+ "kind": "caption",
+ "text": "Borrow another workspace without leaving the current task."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "The sand direction is ready to review.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "The sand direction is ready to review."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "Dismiss the borrowed workspace to restore the full layout."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/checks.json b/DemoLab/scenes/checks.json
new file mode 100644
index 0000000..83eaf33
--- /dev/null
+++ b/DemoLab/scenes/checks.json
@@ -0,0 +1,172 @@
+{
+ "name": "checks",
+ "title": "Validate the saved draft alongside the brief.",
+ "summary": "Validate the saved draft alongside the brief.",
+ "requires": {
+ "displays": 1,
+ "profile": "Laptop"
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "quitApps"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Review",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.headline"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - a"
+ },
+ {
+ "kind": "typeText",
+ "app": "Editor",
+ "text": "A calmer way to work.",
+ "ms": 70
+ },
+ {
+ "kind": "expectValue",
+ "app": "Editor",
+ "identifier": "editor.headline",
+ "value": "A calmer way to work."
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.save"
+ },
+ {
+ "kind": "expectStory",
+ "field": "headline",
+ "value": "A calmer way to work."
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "BUILD / CHECKS"
+ },
+ {
+ "kind": "caption",
+ "text": "Move from writing to checking the work."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "caption",
+ "text": "The checks read the draft you saved."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "check copy",
+ "ms": 70
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "checksPassed",
+ "value": "3"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ },
+ {
+ "kind": "caption",
+ "text": "The saved draft follows the task."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ]
+}
diff --git a/DemoLab/scenes/cli.json b/DemoLab/scenes/cli.json
new file mode 100644
index 0000000..24b8f7f
--- /dev/null
+++ b/DemoLab/scenes/cli.json
@@ -0,0 +1,135 @@
+{
+ "name": "cli",
+ "title": "Build a focus session with the CLI",
+ "summary": "Inspect the real workspace list, then run a short script that activates, balances and borrows.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "COMMAND LINE"
+ },
+ {
+ "kind": "caption",
+ "text": "The CLI can list workspaces and control the running Tatami."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "tatami workspace list",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "tatami workspace list",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "One script activates, arranges, and borrows workspaces."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/configuration.json b/DemoLab/scenes/configuration.json
new file mode 100644
index 0000000..7c0b697
--- /dev/null
+++ b/DemoLab/scenes/configuration.json
@@ -0,0 +1,170 @@
+{
+ "name": "configuration",
+ "title": "Edit plain TOML, see a live change",
+ "summary": "Edit TOML to change window gaps while Tatami is running.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "PLAIN TOML / LIVE RELOAD"
+ },
+ {
+ "kind": "caption",
+ "text": "Edit TOML to change window gaps while Tatami is running."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 32",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 32",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "Supported settings reload automatically without restarting."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 10",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 10",
+ "code": 0
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/desk.json b/DemoLab/scenes/desk.json
new file mode 100644
index 0000000..dd07994
--- /dev/null
+++ b/DemoLab/scenes/desk.json
@@ -0,0 +1,147 @@
+{
+ "name": "desk",
+ "title": "Two displays, one workflow",
+ "summary": "Keep design and reference on separate displays; switch a workspace chain with one shortcut.",
+ "requires": {
+ "displays": 2
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "MULTIPLE DISPLAYS"
+ },
+ {
+ "kind": "caption",
+ "text": "Pin different workspaces to different displays."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - right"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "headline",
+ "ms": 65
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - left"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "caption",
+ "text": "A workspace chain changes both displays."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "caption",
+ "text": "One shortcut restores the previous workspace pair."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/drag.json b/DemoLab/scenes/drag.json
new file mode 100644
index 0000000..16424d3
--- /dev/null
+++ b/DemoLab/scenes/drag.json
@@ -0,0 +1,145 @@
+{
+ "name": "drag",
+ "title": "Arrange it by hand",
+ "summary": "Drag one window onto another, resize an edge, and return to the saved arrangement.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "DRAG & RESIZE"
+ },
+ {
+ "kind": "caption",
+ "text": "Drag onto another window to swap or reposition it."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Canvas",
+ "target": "Docs",
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "Drag a window edge; Tatami saves the new split ratio."
+ },
+ {
+ "kind": "resizeWindow",
+ "app": "Canvas",
+ "x": 140
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/floating.json b/DemoLab/scenes/floating.json
new file mode 100644
index 0000000..a7d0a10
--- /dev/null
+++ b/DemoLab/scenes/floating.json
@@ -0,0 +1,148 @@
+{
+ "name": "floating",
+ "title": "Keep a note above this workspace",
+ "summary": "Switch a note between Always on Top and tiled placement, and interact with its real controls.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Focus",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ALWAYS ON TOP"
+ },
+ {
+ "kind": "caption",
+ "text": "Always on Top keeps this window above the workspace."
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "Check the preview at a smaller size.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "Check the preview at a smaller size."
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "Switch to tiled placement to include the window in the layout."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Notes"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "Switch back to Always on Top whenever you need it."
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 400
+ },
+ {
+ "kind": "restoreWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ }
+ ]
+}
diff --git a/DemoLab/scenes/focus.json b/DemoLab/scenes/focus.json
new file mode 100644
index 0000000..1092f1b
--- /dev/null
+++ b/DemoLab/scenes/focus.json
@@ -0,0 +1,206 @@
+{
+ "name": "focus",
+ "title": "Focus follows your intent",
+ "summary": "Use shortcuts to select neighboring windows and move the pointer, or select windows by hovering.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "FOCUS & POINTER"
+ },
+ {
+ "kind": "caption",
+ "text": "Select a window with a shortcut; the pointer moves there too."
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "With Focus Follows Mouse enabled, hovering selects a window."
+ },
+ {
+ "kind": "hover",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "hover",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "headline",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Docs",
+ "identifier": "docs.search",
+ "value": "headline"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "When a window closes, focus moves to a remaining window."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/fullscreen.json b/DemoLab/scenes/fullscreen.json
new file mode 100644
index 0000000..f677b50
--- /dev/null
+++ b/DemoLab/scenes/fullscreen.json
@@ -0,0 +1,109 @@
+{
+ "name": "fullscreen",
+ "title": "Focus with workspace zoom",
+ "summary": "Inspect one tile at full workspace size, then return to the exact arrangement.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ZOOM & FULLSCREEN"
+ },
+ {
+ "kind": "caption",
+ "text": "Enlarge one tile without losing the saved layout."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "Press the shortcut again to restore the previous layout."
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/guided-setup.json b/DemoLab/scenes/guided-setup.json
new file mode 100644
index 0000000..a53aa81
--- /dev/null
+++ b/DemoLab/scenes/guided-setup.json
@@ -0,0 +1,187 @@
+{
+ "name": "guided-setup",
+ "title": "Learn with a safe virtual desktop",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Run Guided Setup…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Continue"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Continue"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Continue"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "GUIDED SETUP"
+ },
+ {
+ "kind": "caption",
+ "text": "Guided Setup lets you practise without moving your real windows."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "Preview gesture actions with the built-in controls."
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:4F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "Practise the configured shortcuts inside the preview."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:Continue"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:5 of 9",
+ "value": "5 of 9"
+ },
+ {
+ "kind": "caption",
+ "text": "Try tiling controls on the virtual windows first."
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Focus right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Swap left"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Next window"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Grow window"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Flip split"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Balance layout"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:You exercised the full tiling toolset",
+ "value": "You exercised the full tiling toolset"
+ },
+ {
+ "kind": "caption",
+ "text": "Nothing changes until you apply the draft."
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "summary": "Preview gesture actions and practise real shortcuts without moving the live desktop. Physical trackpad input is not shown."
+}
diff --git a/DemoLab/scenes/hooks.json b/DemoLab/scenes/hooks.json
new file mode 100644
index 0000000..11d97c7
--- /dev/null
+++ b/DemoLab/scenes/hooks.json
@@ -0,0 +1,174 @@
+{
+ "name": "hooks",
+ "title": "Make workspace changes useful elsewhere",
+ "summary": "Hooks notify external tools when the workspace changes.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "LIFECYCLE & HUD HOOKS"
+ },
+ {
+ "kind": "caption",
+ "text": "Hooks notify external tools when the workspace changes."
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Chat"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "caption",
+ "text": "HUD hooks also send action feedback to external tools."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ]
+}
diff --git a/DemoLab/scenes/hotplug.json b/DemoLab/scenes/hotplug.json
new file mode 100644
index 0000000..10b5cf1
--- /dev/null
+++ b/DemoLab/scenes/hotplug.json
@@ -0,0 +1,127 @@
+{
+ "name": "hotplug",
+ "title": "Your setup follows the displays",
+ "summary": "Connect a second display to activate the Desk profile. Disconnect it to return to the Laptop arrangement.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "DISPLAY-AWARE PROFILES"
+ },
+ {
+ "kind": "caption",
+ "text": "Connect a second display to trigger the two-display profile rule."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2500
+ },
+ {
+ "kind": "caption",
+ "text": "Disconnect it to trigger the single-display profile rule."
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ],
+ "captureSecondary": true
+}
diff --git a/DemoLab/scenes/ja/ai-draft.json b/DemoLab/scenes/ja/ai-draft.json
new file mode 100644
index 0000000..e793812
--- /dev/null
+++ b/DemoLab/scenes/ja/ai-draft.json
@@ -0,0 +1,109 @@
+{
+ "name": "ai-draft",
+ "title": "提案された構成を確認する",
+ "summary": "AI 提案の例を検証し、下書きに反映します。この録画では外部の AI サービスを呼び出していません。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Tatami"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:ガイド設定を開始…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:続ける"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:続ける"
+ },
+ {
+ "kind": "clipboard",
+ "text": "{\n \"workspaces\": [\n {\n \"name\": \"Design review\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Canvas\",\n \"dev.PangMo5.DemoLab.Docs\"\n ]\n },\n {\n \"name\": \"Writing\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Editor\"\n ]\n },\n {\n \"name\": \"Conversation\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Chat\"\n ]\n },\n {\n \"name\": \"Quick notes\",\n \"kind\": \"scratchpad\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Notes\"\n ]\n }\n ]\n}\n"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AI による構成 / 提案例"
+ },
+ {
+ "kind": "caption",
+ "text": "AI が提案した構成を、適用する前に確認できます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3. 結果を貼り付け"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:Design review",
+ "value": "Design review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "提案された各ワークスペースに、どのアプリが割り当てられたか確認します。"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "description:プランを下書きに反映"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:プランを下書きに反映"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "text:Design review"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "caption",
+ "text": "変わるのは下書きだけ。実際のデスクトップはそのままです。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ja/autoopen.json b/DemoLab/scenes/ja/autoopen.json
new file mode 100644
index 0000000..ce240d5
--- /dev/null
+++ b/DemoLab/scenes/ja/autoopen.json
@@ -0,0 +1,120 @@
+{
+ "name": "autoopen",
+ "title": "必要なツールをまとめて開く",
+ "summary": "ワークスペースを開くと、割り当てたアプリが自動で起動します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Chat",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "quitApps"
+ },
+ {
+ "kind": "beat",
+ "ms": 500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "自動で開く"
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースを開くと、割り当てたアプリが自動で起動します。"
+ },
+ {
+ "kind": "beat",
+ "ms": 200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "自動起動を有効にすると、戻ったときに閉じたウインドウも開きます。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": []
+}
diff --git a/DemoLab/scenes/ja/borrow-placement.json b/DemoLab/scenes/ja/borrow-placement.json
new file mode 100644
index 0000000..396bda9
--- /dev/null
+++ b/DemoLab/scenes/ja/borrow-placement.json
@@ -0,0 +1,174 @@
+{
+ "name": "borrow-placement",
+ "title": "位置と作業の場を選ぶ",
+ "summary": "ワークスペースを左や下に借りて、境界を越えてフォーカスを移し、その作業に切り替えます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "configure",
+ "field": "borrowDefaultEdge",
+ "value": "remove"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "借りる位置を選ぶ"
+ },
+ {
+ "kind": "caption",
+ "text": "借りるワークスペースを、画面のどの辺に置くか選べます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "left"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "今のワークスペースの下側に借りることもできます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "down"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "借りているワークスペースを開くと、その作業へ完全に切り替わります。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/borrow.json b/DemoLab/scenes/ja/borrow.json
new file mode 100644
index 0000000..a163074
--- /dev/null
+++ b/DemoLab/scenes/ja/borrow.json
@@ -0,0 +1,126 @@
+{
+ "name": "borrow",
+ "title": "会話を横に呼び出す",
+ "summary": "今の作業を続けながら、別のワークスペースを隣へ借りられます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ワークスペースを借りる"
+ },
+ {
+ "kind": "caption",
+ "text": "今の作業を続けながら、別のワークスペースを隣へ借りられます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "サンドの案をレビューできます。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "サンドの案をレビューできます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "借りたワークスペースを閉じると、元の領域全体を使えます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/cli.json b/DemoLab/scenes/ja/cli.json
new file mode 100644
index 0000000..dfe7898
--- /dev/null
+++ b/DemoLab/scenes/ja/cli.json
@@ -0,0 +1,135 @@
+{
+ "name": "cli",
+ "title": "CLI で集中できる環境を用意する",
+ "summary": "実際のワークスペース一覧を確認し、切り替え・均等配置・借りる操作をまとめたスクリプトを実行します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "コマンドライン"
+ },
+ {
+ "kind": "caption",
+ "text": "CLI からワークスペース一覧を確認し、起動中の Tatami を操作できます。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "tatami workspace list",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "tatami workspace list",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "一つのスクリプトで、ワークスペースの切り替え・配置・借用を実行します。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/ja/configuration.json b/DemoLab/scenes/ja/configuration.json
new file mode 100644
index 0000000..ded2c96
--- /dev/null
+++ b/DemoLab/scenes/ja/configuration.json
@@ -0,0 +1,170 @@
+{
+ "name": "configuration",
+ "title": "TOML を編集して、その場で確認する",
+ "summary": "Tatami の起動中に TOML を編集して、ウインドウの間隔を変えられます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "TOML / その場で反映"
+ },
+ {
+ "kind": "caption",
+ "text": "Tatami の起動中に TOML を編集して、ウインドウの間隔を変えられます。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 32",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 32",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "対応している設定は、再起動せずに自動で反映されます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 10",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 10",
+ "code": 0
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/ja/desk.json b/DemoLab/scenes/ja/desk.json
new file mode 100644
index 0000000..a0db9b5
--- /dev/null
+++ b/DemoLab/scenes/ja/desk.json
@@ -0,0 +1,147 @@
+{
+ "name": "desk",
+ "title": "二つの画面で、一つの作業",
+ "summary": "デザインと資料を別々の画面に置き、一つのショートカットでチェーンを切り替えます。",
+ "requires": {
+ "displays": 2
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "複数のディスプレイ"
+ },
+ {
+ "kind": "caption",
+ "text": "ディスプレイごとに、別のワークスペースを固定できます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - right"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "見出し",
+ "ms": 65
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - left"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースチェーンで、両方の画面を切り替えます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "caption",
+ "text": "ショートカット一つで、前のワークスペースの組み合わせに戻れます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/drag.json b/DemoLab/scenes/ja/drag.json
new file mode 100644
index 0000000..c56d0e8
--- /dev/null
+++ b/DemoLab/scenes/ja/drag.json
@@ -0,0 +1,145 @@
+{
+ "name": "drag",
+ "title": "マウスで並べる",
+ "summary": "ウインドウを重ねるようにドラッグして端を調整し、保存した配置に戻ります。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ドラッグとサイズ変更"
+ },
+ {
+ "kind": "caption",
+ "text": "別のウインドウへドラッグして、入れ替えたり隣へ配置したりできます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Canvas",
+ "target": "Docs",
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "ウインドウの端をドラッグすると、変更した分割比率が保存されます。"
+ },
+ {
+ "kind": "resizeWindow",
+ "app": "Canvas",
+ "x": 140
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/floating.json b/DemoLab/scenes/ja/floating.json
new file mode 100644
index 0000000..095ad97
--- /dev/null
+++ b/DemoLab/scenes/ja/floating.json
@@ -0,0 +1,148 @@
+{
+ "name": "floating",
+ "title": "ワークスペースの手前にメモを置く",
+ "summary": "メモを常に手前に置いたりタイル表示に戻したりしながら、実際のボタンや入力欄を使います。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Focus",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "常に手前"
+ },
+ {
+ "kind": "caption",
+ "text": "最前面表示にしたウインドウは、ワークスペースの手前に表示されます。"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "小さいサイズでもプレビューを確認します。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "小さいサイズでもプレビューを確認します。"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "タイル表示に切り替えると、ほかのウインドウと並んで配置されます。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Notes"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "必要なときに、再び最前面表示へ戻せます。"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 400
+ },
+ {
+ "kind": "restoreWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ja/focus.json b/DemoLab/scenes/ja/focus.json
new file mode 100644
index 0000000..5dfa2c8
--- /dev/null
+++ b/DemoLab/scenes/ja/focus.json
@@ -0,0 +1,206 @@
+{
+ "name": "focus",
+ "title": "意図に合わせてフォーカスを移す",
+ "summary": "ショートカットで隣のウインドウを選び、ポインタも移動します。マウスを重ねて選ぶ方法も紹介します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "フォーカスとポインタ"
+ },
+ {
+ "kind": "caption",
+ "text": "ショートカットでウインドウを選ぶと、ポインタもそのウインドウへ移動します。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "ポインタを重ねたウインドウが自動で選ばれる設定もあります。"
+ },
+ {
+ "kind": "hover",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "hover",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "見出し",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Docs",
+ "identifier": "docs.search",
+ "value": "見出し"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "ウインドウを閉じると、残っているウインドウへフォーカスが移ります。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/fullscreen.json b/DemoLab/scenes/ja/fullscreen.json
new file mode 100644
index 0000000..3bcffbf
--- /dev/null
+++ b/DemoLab/scenes/ja/fullscreen.json
@@ -0,0 +1,109 @@
+{
+ "name": "fullscreen",
+ "title": "ワークスペースを広げて集中する",
+ "summary": "一つのウインドウをワークスペースいっぱいに広げ、元の配置に戻します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ワークスペースの拡大"
+ },
+ {
+ "kind": "caption",
+ "text": "元の配置を保ったまま、一つのウインドウを大きく表示できます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "同じショートカットをもう一度押すと、元の配置に戻ります。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/guided-setup.json b/DemoLab/scenes/ja/guided-setup.json
new file mode 100644
index 0000000..75e9e07
--- /dev/null
+++ b/DemoLab/scenes/ja/guided-setup.json
@@ -0,0 +1,187 @@
+{
+ "name": "guided-setup",
+ "title": "使いながら覚える",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:ガイド設定を開始…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:続ける"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:続ける"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:続ける"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "使いながら設定"
+ },
+ {
+ "kind": "caption",
+ "text": "ガイド付き設定では、実際のウインドウを動かさずに操作を練習できます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "ガイド内のボタンで、ジェスチャの動きを確認します。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:4F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "設定したショートカットを、プレビュー内で実際に試せます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:続ける"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:5 / 9",
+ "value": "5 / 9"
+ },
+ {
+ "kind": "caption",
+ "text": "仮想ウインドウで、タイル配置の操作を先に試せます。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:右へフォーカス"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:左と入れ替え"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:次のウインドウ"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:ウインドウを拡大"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:分割を反転"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:レイアウトを均等化"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:タイル表示の操作をすべて試しました",
+ "value": "タイル表示の操作をすべて試しました"
+ },
+ {
+ "kind": "caption",
+ "text": "下書きを適用するまで、実際の環境は変わりません。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "summary": "実際の画面を動かさずに、ジェスチャのプレビューとショートカットを練習します。実機のトラックパッド入力は含みません。"
+}
diff --git a/DemoLab/scenes/ja/hooks.json b/DemoLab/scenes/ja/hooks.json
new file mode 100644
index 0000000..9f56fbe
--- /dev/null
+++ b/DemoLab/scenes/ja/hooks.json
@@ -0,0 +1,174 @@
+{
+ "name": "hooks",
+ "title": "ワークスペースの切り替えをほかのツールに届ける",
+ "summary": "ワークスペースが変わると、フックで外部ツールに通知できます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "状態と操作フィードバックのフック"
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースが変わると、フックで外部ツールに通知できます。"
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Chat"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "caption",
+ "text": "HUD フックを使うと、操作結果の通知も外部ツールへ送れます。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ja/hotplug.json b/DemoLab/scenes/ja/hotplug.json
new file mode 100644
index 0000000..3f0d472
--- /dev/null
+++ b/DemoLab/scenes/ja/hotplug.json
@@ -0,0 +1,127 @@
+{
+ "name": "hotplug",
+ "title": "画面に合わせて環境を切り替える",
+ "summary": "2 台目を接続すると Desk プロファイルに切り替わります。外すと Laptop の配置に戻ります。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "画面に合わせたプロファイル"
+ },
+ {
+ "kind": "caption",
+ "text": "2 台目を接続すると、2 画面用の条件でプロファイルが自動切り替えされます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2500
+ },
+ {
+ "kind": "caption",
+ "text": "接続を外すと、1 画面用のプロファイルに自動で戻ります。"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ],
+ "captureSecondary": true
+}
diff --git a/DemoLab/scenes/ja/layout-editor.json b/DemoLab/scenes/ja/layout-editor.json
new file mode 100644
index 0000000..df9638a
--- /dev/null
+++ b/DemoLab/scenes/ja/layout-editor.json
@@ -0,0 +1,131 @@
+{
+ "name": "layout-editor",
+ "title": "見ながら配置を決める",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "レイアウトエディタ"
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースを開く前に、保存するウインドウ配置を編集できます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:90°回転"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "配置プレビューで、上下の位置を入れ替えます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:上下反転"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースを開くと、編集した配置が実際のウインドウに反映されます。"
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "final-canvas-tile"
+ },
+ {
+ "kind": "closeSettings"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Canvas",
+ "target": "Docs",
+ "value": "above"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "summary": "ワークスペースを開く前に、保存するウインドウ配置を編集できます。"
+}
diff --git a/DemoLab/scenes/ja/layouts.json b/DemoLab/scenes/ja/layouts.json
new file mode 100644
index 0000000..7558e67
--- /dev/null
+++ b/DemoLab/scenes/ja/layouts.json
@@ -0,0 +1,170 @@
+{
+ "name": "layouts",
+ "title": "キーボードで配置を整える",
+ "summary": "ウインドウを入れ替え、分割の向きと大きさを変え、全体を整えます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Docs",
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "配置の操作"
+ },
+ {
+ "kind": "caption",
+ "text": "選択中のウインドウと隣のウインドウを入れ替えます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "左右分割と上下分割を切り替えます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - s"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "選択中のウインドウの領域を広げます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-grow"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 3
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-grow"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "自動均等配置がオフの場合、基本の BSP 配置に戻します。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-balance"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-balance"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Docs",
+ "Docs",
+ "Canvas"
+ ]
+}
diff --git a/DemoLab/scenes/ja/membership.json b/DemoLab/scenes/ja/membership.json
new file mode 100644
index 0000000..471753a
--- /dev/null
+++ b/DemoLab/scenes/ja/membership.json
@@ -0,0 +1,159 @@
+{
+ "name": "membership",
+ "title": "作業に合わせてアプリを移す",
+ "summary": "ショートカットで、選択中のアプリを別のワークスペースへ移します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "アプリの割り当て"
+ },
+ {
+ "kind": "caption",
+ "text": "ショートカットで、選択中のアプリを別のワークスペースへ移します。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ]"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "移したアプリは、元のワークスペースから外れます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "同じ操作で、アプリを元のワークスペースへ戻せます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ["
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/pause.json b/DemoLab/scenes/ja/pause.json
new file mode 100644
index 0000000..7bca437
--- /dev/null
+++ b/DemoLab/scenes/ja/pause.json
@@ -0,0 +1,119 @@
+{
+ "name": "pause",
+ "title": "自分でウインドウを配置する",
+ "summary": "自動タイル表示を止めて自由に配置し、再開すると整った配置に戻ります。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "一時停止と再開"
+ },
+ {
+ "kind": "caption",
+ "text": "自由に置きたいときは、タイル表示を一時停止します。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Docs",
+ "target": "Canvas",
+ "x": 0.65,
+ "y": 0.35
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "再開すると、Tatami がウインドウを整えます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/profile-copy.json b/DemoLab/scenes/ja/profile-copy.json
new file mode 100644
index 0000000..7c8cd2a
--- /dev/null
+++ b/DemoLab/scenes/ja/profile-copy.json
@@ -0,0 +1,74 @@
+{
+ "name": "profile-copy",
+ "title": "今ある構成を再利用する",
+ "summary": "設定画面でプロファイルを複製し、含めるワークスペースを確認します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "構成を再利用"
+ },
+ {
+ "kind": "caption",
+ "text": "プロファイルを右クリックして、複製を選びます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "rightClick",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:複製"
+ },
+ {
+ "kind": "caption",
+ "text": "複製するワークスペースと設定を選べます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:複製"
+ },
+ {
+ "kind": "expectProfileCount",
+ "count": 3
+ },
+ {
+ "kind": "caption",
+ "text": "新しいプロファイルは、元の構成とは別に編集できます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ja/profiles.json b/DemoLab/scenes/ja/profiles.json
new file mode 100644
index 0000000..8d067d2
--- /dev/null
+++ b/DemoLab/scenes/ja/profiles.json
@@ -0,0 +1,121 @@
+{
+ "name": "profiles",
+ "title": "作業環境ごと切り替える",
+ "summary": "プロファイルを切り替えると、ワークスペースの構成全体が変わります。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "プロファイル"
+ },
+ {
+ "kind": "caption",
+ "text": "プロファイルを切り替えると、ワークスペースの構成全体が変わります。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 2"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "caption",
+ "text": "プロファイルごとに保存したウインドウ配置へ戻れます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 1"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/scratchpad.json b/DemoLab/scenes/ja/scratchpad.json
new file mode 100644
index 0000000..3ea3a93
--- /dev/null
+++ b/DemoLab/scenes/ja/scratchpad.json
@@ -0,0 +1,149 @@
+{
+ "name": "scratchpad",
+ "title": "何度でも呼び出せる一時スペース",
+ "summary": "もう一度借りると、アプリの作業内容がそのまま残っています。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "一時スペース"
+ },
+ {
+ "kind": "caption",
+ "text": "スクラッチパッドは、借りる操作でのみ表示できます。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "3 つの配色を比べます。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "3 つの配色を比べます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "もう一度借りると、アプリの作業内容がそのまま残っています。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "3 つの配色を比べます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/shared.json b/DemoLab/scenes/ja/shared.json
new file mode 100644
index 0000000..e49c8c2
--- /dev/null
+++ b/DemoLab/scenes/ja/shared.json
@@ -0,0 +1,166 @@
+{
+ "name": "shared",
+ "title": "作業が変わっても同じ状態ウインドウを使う",
+ "summary": "共有アプリを最前面表示にすると、どのワークスペースでも表示されます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "共有アプリ"
+ },
+ {
+ "kind": "caption",
+ "text": "共有アプリを最前面表示にすると、どのワークスペースでも表示されます。"
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "一つのウインドウを拡大しても、共有ウインドウは手前に表示されます。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Review"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ja/switching.json b/DemoLab/scenes/ja/switching.json
new file mode 100644
index 0000000..9b6ba21
--- /dev/null
+++ b/DemoLab/scenes/ja/switching.json
@@ -0,0 +1,130 @@
+{
+ "name": "switching",
+ "title": "必要なウインドウを選ぶ",
+ "summary": "切り替えキーを長く押してウインドウを確認し、短く押して戻れます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ウインドウ切り替え"
+ },
+ {
+ "kind": "caption",
+ "text": "ショートカットを押し続けて、今のワークスペースのウインドウを選びます。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 900,
+ "releaseAfterMs": 1600
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "caption",
+ "text": "短く押すと、直前に使っていたウインドウへ戻ります。"
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/tiling.json b/DemoLab/scenes/ja/tiling.json
new file mode 100644
index 0000000..628fc80
--- /dev/null
+++ b/DemoLab/scenes/ja/tiling.json
@@ -0,0 +1,161 @@
+{
+ "name": "tiling",
+ "title": "新しいウインドウが自然に収まる",
+ "summary": "新しいウインドウは、選択中のウインドウの領域を分割して配置されます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "自動タイル表示"
+ },
+ {
+ "kind": "caption",
+ "text": "新しいウインドウは、選択中のウインドウの領域を分割して配置されます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "caption",
+ "text": "新しいウインドウが入っても、ほかの領域の配置は変わりません。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "caption",
+ "text": "ウインドウを閉じると、残ったウインドウが空いた領域を埋めます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/tour.json b/DemoLab/scenes/ja/tour.json
new file mode 100644
index 0000000..0872348
--- /dev/null
+++ b/DemoLab/scenes/ja/tour.json
@@ -0,0 +1,582 @@
+{
+ "name": "tour",
+ "title": "仕事の流れに、必要な場所を。",
+ "summary": "デザイン、執筆、レビュー、借りる操作、自動化。広いデスクにも合わせて切り替えます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "Tatami / デザイン"
+ },
+ {
+ "kind": "caption",
+ "text": "作業ごとに、使うアプリとウインドウ配置をまとめて管理できます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "caption",
+ "text": "一つのウインドウをワークスペースいっぱいに広げます。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectStory",
+ "field": "theme",
+ "value": "sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "caption",
+ "text": "拡大を解除すると、元のウインドウ配置に戻ります。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.content"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 執筆"
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースを切り替えると、次の作業で使うアプリが表示されます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.headline"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - a"
+ },
+ {
+ "kind": "typeText",
+ "app": "Editor",
+ "text": "落ち着いて、仕事を進めよう。",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Editor",
+ "identifier": "editor.headline",
+ "value": "落ち着いて、仕事を進めよう。"
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.save"
+ },
+ {
+ "kind": "expectStory",
+ "field": "headline",
+ "value": "落ち着いて、仕事を進めよう。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Design に戻ると、使っていたアプリが元の位置に表示されます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 2
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "saveLayout",
+ "text": "design"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.export"
+ },
+ {
+ "kind": "expectStory",
+ "field": "exported",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / レビュー"
+ },
+ {
+ "kind": "caption",
+ "text": "ショートカット一つで、表示するアプリの組み合わせを切り替えます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.check"
+ },
+ {
+ "kind": "expectStory",
+ "field": "checksPassed",
+ "value": "3"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.comment"
+ },
+ {
+ "kind": "typeText",
+ "app": "Review",
+ "text": "準備できました。プレビューも書き出しました。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.approve"
+ },
+ {
+ "kind": "expectStory",
+ "field": "approved",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "戻ると、ウインドウの位置と大きさが復元されます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 借りる"
+ },
+ {
+ "kind": "caption",
+ "text": "借りる機能で、別のワークスペースを今の作業の隣に表示します。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.task.1"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "公開のお知らせを準備します。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "公開のお知らせを準備します。"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 常に手前"
+ },
+ {
+ "kind": "caption",
+ "text": "最前面表示にすると、ほかのアプリの手前にウインドウを保てます。"
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Docs",
+ "x": 0.3,
+ "y": 0.7
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 自動化"
+ },
+ {
+ "kind": "caption",
+ "text": "CLI を使うと、複数のワークスペース操作をスクリプトにまとめられます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 環境に合わせる"
+ },
+ {
+ "kind": "caption",
+ "text": "ディスプレイを接続すると、条件に合うプロファイルへ自動で切り替わります。"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "caption",
+ "text": "接続を外すと、ノートブック用のプロファイルと配置に戻ります。"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI"
+ },
+ {
+ "kind": "caption",
+ "text": "ツールも、作業の場所も、そのまま。 | BSP タイル表示を備えた macOS 用ワークスペースマネージャです。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ja/unmanaged.json b/DemoLab/scenes/ja/unmanaged.json
new file mode 100644
index 0000000..056da9b
--- /dev/null
+++ b/DemoLab/scenes/ja/unmanaged.json
@@ -0,0 +1,180 @@
+{
+ "name": "unmanaged",
+ "title": "置いた場所にウインドウを残す",
+ "summary": "共有アプリを通常の重なり順にすると、作業が変わっても位置と大きさが保たれます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "そのまま"
+ },
+ {
+ "kind": "caption",
+ "text": "そのまま表示にしたウインドウは、自動タイル配置の対象外になります。"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Canvas",
+ "x": 0.8,
+ "y": 0.22
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "共有アプリにすると、ワークスペースを変えても位置と大きさを保てます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Monitor"
+ ]
+}
diff --git a/DemoLab/scenes/ja/workspace-copy.json b/DemoLab/scenes/ja/workspace-copy.json
new file mode 100644
index 0000000..7dcaedf
--- /dev/null
+++ b/DemoLab/scenes/ja/workspace-copy.json
@@ -0,0 +1,91 @@
+{
+ "name": "workspace-copy",
+ "title": "ワークスペース間で設定をコピー",
+ "summary": "別のワークスペースのアプリと設定を確認し、選んだ変更を適用します。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Desk"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ワークスペース設定のコピー"
+ },
+ {
+ "kind": "caption",
+ "text": "設定のコピー元にするワークスペースを選びます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:別のワークスペースからコピー…"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "title:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Design"
+ },
+ {
+ "kind": "caption",
+ "text": "今のワークスペースへコピーする変更を、適用前に確認できます。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:コピー"
+ },
+ {
+ "kind": "expectAssignment",
+ "app": "Docs",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "caption",
+ "text": "選んだアプリと設定が、今のワークスペースにコピーされました。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "heading:レイアウト"
+ },
+ {
+ "kind": "beat",
+ "ms": 2400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ja/workspaces.json b/DemoLab/scenes/ja/workspaces.json
new file mode 100644
index 0000000..928979f
--- /dev/null
+++ b/DemoLab/scenes/ja/workspaces.json
@@ -0,0 +1,174 @@
+{
+ "name": "workspaces",
+ "title": "作業を切り替える",
+ "summary": "ショートカットで、ワークスペースと使用するアプリをまとめて切り替えます。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ワークスペース"
+ },
+ {
+ "kind": "caption",
+ "text": "ショートカットで、ワークスペースと使用するアプリをまとめて切り替えます。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "配色の方向性を比べています。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "配色の方向性を比べています。"
+ },
+ {
+ "kind": "caption",
+ "text": "直前のワークスペースに戻るショートカットで、前の作業を再開できます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "ワークスペースを順番に切り替えることもできます。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ."
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Focus"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ,"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/ai-draft.json b/DemoLab/scenes/ko/ai-draft.json
new file mode 100644
index 0000000..235d0f4
--- /dev/null
+++ b/DemoLab/scenes/ko/ai-draft.json
@@ -0,0 +1,109 @@
+{
+ "name": "ai-draft",
+ "title": "제안받은 구성 검토하기",
+ "summary": "예시 AI 제안을 검증하고 초안에 적용해요. 이 영상에서는 외부 AI 서비스를 호출하지 않아요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Tatami"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:가이드 설정 실행…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계속"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계속"
+ },
+ {
+ "kind": "clipboard",
+ "text": "{\n \"workspaces\": [\n {\n \"name\": \"Design review\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Canvas\",\n \"dev.PangMo5.DemoLab.Docs\"\n ]\n },\n {\n \"name\": \"Writing\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Editor\"\n ]\n },\n {\n \"name\": \"Conversation\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Chat\"\n ]\n },\n {\n \"name\": \"Quick notes\",\n \"kind\": \"scratchpad\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Notes\"\n ]\n }\n ]\n}\n"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AI 구성 / 예시 제안"
+ },
+ {
+ "kind": "caption",
+ "text": "AI가 제안한 구성을 적용하기 전에 검토해요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3. 결과 붙여넣기"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:Design review",
+ "value": "Design review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "제안된 작업 공간마다 어떤 앱이 배정됐는지 확인해요."
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "description:계획을 초안에 반영"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계획을 초안에 반영"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "text:Design review"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "caption",
+ "text": "초안만 바뀌고 실제 화면은 그대로예요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ko/autoopen.json b/DemoLab/scenes/ko/autoopen.json
new file mode 100644
index 0000000..590374f
--- /dev/null
+++ b/DemoLab/scenes/ko/autoopen.json
@@ -0,0 +1,120 @@
+{
+ "name": "autoopen",
+ "title": "필요한 도구 한 번에 열기",
+ "summary": "작업 공간을 켜면 배정한 앱들이 자동으로 열려요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Chat",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "quitApps"
+ },
+ {
+ "kind": "beat",
+ "ms": 500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "자동 열기"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간을 켜면 배정한 앱들이 자동으로 열려요."
+ },
+ {
+ "kind": "beat",
+ "ms": 200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "자동 열기를 켜두면 다시 들어올 때 닫았던 창도 열려요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": []
+}
diff --git a/DemoLab/scenes/ko/borrow-placement.json b/DemoLab/scenes/ko/borrow-placement.json
new file mode 100644
index 0000000..43e1717
--- /dev/null
+++ b/DemoLab/scenes/ko/borrow-placement.json
@@ -0,0 +1,174 @@
+{
+ "name": "borrow-placement",
+ "title": "위치와 작업 맥락 고르기",
+ "summary": "왼쪽이나 아래로 작업 공간을 빌려와요. 경계를 넘어 포커스를 옮긴 뒤 해당 작업 공간으로 전환해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "configure",
+ "field": "borrowDefaultEdge",
+ "value": "remove"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "빌려올 위치 선택"
+ },
+ {
+ "kind": "caption",
+ "text": "빌려온 작업 공간을 화면의 어느 쪽에 둘지 골라요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "left"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "현재 작업 공간 아래쪽으로 빌려올 수도 있어요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "down"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "빌려온 작업 공간을 활성화하면 그 공간으로 완전히 전환해요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/borrow.json b/DemoLab/scenes/ko/borrow.json
new file mode 100644
index 0000000..954fbfe
--- /dev/null
+++ b/DemoLab/scenes/ko/borrow.json
@@ -0,0 +1,126 @@
+{
+ "name": "borrow",
+ "title": "대화를 옆으로 가져오기",
+ "summary": "현재 작업을 유지하면서 다른 작업 공간을 옆에 빌려와요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "작업 공간 빌려오기"
+ },
+ {
+ "kind": "caption",
+ "text": "현재 작업을 유지하면서 다른 작업 공간을 옆에 빌려와요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "샌드 색상 안을 검토할 수 있어요.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "샌드 색상 안을 검토할 수 있어요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "빌려온 작업 공간을 돌려보내면 원래 영역을 다시 채워요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/cli.json b/DemoLab/scenes/ko/cli.json
new file mode 100644
index 0000000..1178dc8
--- /dev/null
+++ b/DemoLab/scenes/ko/cli.json
@@ -0,0 +1,135 @@
+{
+ "name": "cli",
+ "title": "CLI로 집중할 환경 준비하기",
+ "summary": "실제 작업 공간 목록을 확인한 뒤 활성화·균등 배치·빌려오기를 묶은 스크립트를 실행해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "커맨드라인"
+ },
+ {
+ "kind": "caption",
+ "text": "CLI로 작업 공간 목록을 확인하고 실행 중인 Tatami를 조작해요."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "tatami workspace list",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "tatami workspace list",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "스크립트 하나로 작업 공간 전환·배치·빌려오기를 실행해요."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/ko/configuration.json b/DemoLab/scenes/ko/configuration.json
new file mode 100644
index 0000000..0a27fab
--- /dev/null
+++ b/DemoLab/scenes/ko/configuration.json
@@ -0,0 +1,170 @@
+{
+ "name": "configuration",
+ "title": "TOML을 수정하고 바로 확인하기",
+ "summary": "실행 중에 TOML 파일을 수정해 창 사이 간격을 바꿔요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "TOML / 바로 적용"
+ },
+ {
+ "kind": "caption",
+ "text": "실행 중에 TOML 파일을 수정해 창 사이 간격을 바꿔요."
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 32",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 32",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "지원하는 설정은 재시작하지 않아도 자동으로 반영돼요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 10",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 10",
+ "code": 0
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/ko/desk.json b/DemoLab/scenes/ko/desk.json
new file mode 100644
index 0000000..641b2b2
--- /dev/null
+++ b/DemoLab/scenes/ko/desk.json
@@ -0,0 +1,147 @@
+{
+ "name": "desk",
+ "title": "두 화면으로 이어지는 작업",
+ "summary": "디자인과 참고 자료를 다른 화면에 두고, 단축키 하나로 작업 공간 체인을 전환해요.",
+ "requires": {
+ "displays": 2
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "여러 화면"
+ },
+ {
+ "kind": "caption",
+ "text": "화면마다 다른 작업 공간을 고정해 사용할 수 있어요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - right"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "제목",
+ "ms": 65
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - left"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간 체인으로 두 화면을 함께 바꿔요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "caption",
+ "text": "단축키 하나로 이전에 함께 쓰던 작업 공간들로 돌아와요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/drag.json b/DemoLab/scenes/ko/drag.json
new file mode 100644
index 0000000..0e188aa
--- /dev/null
+++ b/DemoLab/scenes/ko/drag.json
@@ -0,0 +1,145 @@
+{
+ "name": "drag",
+ "title": "마우스로 배치하기",
+ "summary": "창을 다른 창 위로 옮기고 가장자리를 조절해요. 그런 다음 저장한 배치로 돌아와요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "드래그와 크기 조절"
+ },
+ {
+ "kind": "caption",
+ "text": "창을 다른 창 위로 드래그해 위치를 바꾸거나 끼워 넣어요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Canvas",
+ "target": "Docs",
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "창 가장자리를 드래그하면 바꾼 비율이 저장돼요."
+ },
+ {
+ "kind": "resizeWindow",
+ "app": "Canvas",
+ "x": 140
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/floating.json b/DemoLab/scenes/ko/floating.json
new file mode 100644
index 0000000..d760e83
--- /dev/null
+++ b/DemoLab/scenes/ko/floating.json
@@ -0,0 +1,148 @@
+{
+ "name": "floating",
+ "title": "작업 공간 위에 메모 두기",
+ "summary": "메모를 항상 위에 두거나 타일링에 포함하고 실제 버튼과 입력창을 사용해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Focus",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "항상 위"
+ },
+ {
+ "kind": "caption",
+ "text": "항상 위로 설정한 창은 작업 공간 위에 계속 보여요."
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "작은 크기에서도 미리보기를 확인해요.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "작은 크기에서도 미리보기를 확인해요."
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "타일링으로 바꾸면 다른 창과 함께 나눠 배치돼요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Notes"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "필요할 때 다시 항상 위로 바꿀 수 있어요."
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 400
+ },
+ {
+ "kind": "restoreWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ko/focus.json b/DemoLab/scenes/ko/focus.json
new file mode 100644
index 0000000..e1d49ec
--- /dev/null
+++ b/DemoLab/scenes/ko/focus.json
@@ -0,0 +1,206 @@
+{
+ "name": "focus",
+ "title": "내 의도에 따라 포커스 이동하기",
+ "summary": "단축키로 이웃한 창을 선택하고 포인터도 함께 옮겨요. 마우스를 창 위에 올려 선택하는 방법도 보여줘요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "포커스와 포인터"
+ },
+ {
+ "kind": "caption",
+ "text": "단축키로 창을 선택하면 마우스 포인터도 그 창으로 이동해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "마우스 포인터를 올린 창을 자동으로 선택하도록 설정할 수도 있어요."
+ },
+ {
+ "kind": "hover",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "hover",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "제목",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Docs",
+ "identifier": "docs.search",
+ "value": "제목"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "창을 닫으면 남아 있는 창이 자동으로 선택돼요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/fullscreen.json b/DemoLab/scenes/ko/fullscreen.json
new file mode 100644
index 0000000..229b63a
--- /dev/null
+++ b/DemoLab/scenes/ko/fullscreen.json
@@ -0,0 +1,109 @@
+{
+ "name": "fullscreen",
+ "title": "작업 공간 확대해서 집중하기",
+ "summary": "창 하나를 작업 공간 크기로 펼쳐서 확인한 뒤 정확히 이전 배치로 돌아와요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "작업 공간 확대"
+ },
+ {
+ "kind": "caption",
+ "text": "기존 배치를 유지한 채 창 하나만 크게 확대해요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "같은 단축키를 다시 누르면 이전 배치로 돌아와요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/guided-setup.json b/DemoLab/scenes/ko/guided-setup.json
new file mode 100644
index 0000000..4d51e11
--- /dev/null
+++ b/DemoLab/scenes/ko/guided-setup.json
@@ -0,0 +1,187 @@
+{
+ "name": "guided-setup",
+ "title": "직접 사용하며 익히기",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:가이드 설정 실행…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계속"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계속"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계속"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "시작 안내"
+ },
+ {
+ "kind": "caption",
+ "text": "가이드 설정에서는 실제 창을 움직이지 않고 기능을 연습해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "안내에 있는 버튼으로 제스처 동작을 미리 봐요."
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:4F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "설정한 단축키를 미리보기 안에서 직접 연습해요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:계속"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:5/9",
+ "value": "5/9"
+ },
+ {
+ "kind": "caption",
+ "text": "가상 창으로 타일링 동작을 먼저 익혀요."
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:오른쪽으로 포커스"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:왼쪽과 교체"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:다음 창"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:창 늘리기"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:분할 뒤집기"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:배치 균등하게 맞추기"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:타일링 도구를 모두 사용해 봤어요",
+ "value": "타일링 도구를 모두 사용해 봤어요"
+ },
+ {
+ "kind": "caption",
+ "text": "초안을 적용하기 전까지 실제 환경은 그대로예요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "summary": "실제 화면을 움직이지 않고 제스처 동작과 단축키를 익혀요. 물리 트랙패드 입력 장면은 포함하지 않았어요."
+}
diff --git a/DemoLab/scenes/ko/hooks.json b/DemoLab/scenes/ko/hooks.json
new file mode 100644
index 0000000..0101538
--- /dev/null
+++ b/DemoLab/scenes/ko/hooks.json
@@ -0,0 +1,174 @@
+{
+ "name": "hooks",
+ "title": "작업 공간 변경을 다른 도구에 연결하기",
+ "summary": "작업 공간이 바뀌면 훅으로 외부 도구에 알려요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "작업 공간과 화면 알림 훅"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간이 바뀌면 훅으로 외부 도구에 알려요."
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Chat"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "caption",
+ "text": "HUD 훅으로 동작 알림도 외부 도구에 전달해요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ko/hotplug.json b/DemoLab/scenes/ko/hotplug.json
new file mode 100644
index 0000000..160d524
--- /dev/null
+++ b/DemoLab/scenes/ko/hotplug.json
@@ -0,0 +1,127 @@
+{
+ "name": "hotplug",
+ "title": "화면 구성에 맞춰 자동으로 바꾸기",
+ "summary": "두 번째 화면을 연결하면 Desk 프로필이 활성화돼요. 연결을 해제하면 Laptop 배치로 돌아와요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "화면에 맞는 프로필"
+ },
+ {
+ "kind": "caption",
+ "text": "두 번째 화면을 연결하면 두 화면용 프로필이 자동으로 켜져요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2500
+ },
+ {
+ "kind": "caption",
+ "text": "연결을 해제하면 한 화면용 프로필로 자동 전환돼요."
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ],
+ "captureSecondary": true
+}
diff --git a/DemoLab/scenes/ko/layout-editor.json b/DemoLab/scenes/ko/layout-editor.json
new file mode 100644
index 0000000..687ab0a
--- /dev/null
+++ b/DemoLab/scenes/ko/layout-editor.json
@@ -0,0 +1,131 @@
+{
+ "name": "layout-editor",
+ "title": "눈으로 보며 배치 정하기",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "배치 편집기"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간을 켜기 전에 저장된 창 배치를 편집해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:90° 회전"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "배치 미리보기에서 위아래 위치를 뒤집어요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:상하 뒤집기"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간을 켜면 방금 편집한 배치가 실제 창에 적용돼요."
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "final-canvas-tile"
+ },
+ {
+ "kind": "closeSettings"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Canvas",
+ "target": "Docs",
+ "value": "above"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "summary": "작업 공간을 켜기 전에 저장된 창 배치를 편집해요."
+}
diff --git a/DemoLab/scenes/ko/layouts.json b/DemoLab/scenes/ko/layouts.json
new file mode 100644
index 0000000..e18411b
--- /dev/null
+++ b/DemoLab/scenes/ko/layouts.json
@@ -0,0 +1,170 @@
+{
+ "name": "layouts",
+ "title": "키보드로 배치 다듬기",
+ "summary": "창을 바꾸고 나누는 방향과 크기를 조절한 뒤 전체 비율을 맞춰요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Docs",
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "배치 조절"
+ },
+ {
+ "kind": "caption",
+ "text": "선택한 창과 옆 창의 위치를 바꿔요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "창을 나누는 방향을 좌우에서 위아래로 바꿔요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - s"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "선택한 창이 속한 영역을 더 크게 늘려요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-grow"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 3
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-grow"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "자동 균등 배치가 꺼져 있으면 기본 BSP 배치로 되돌려요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-balance"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-balance"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Docs",
+ "Docs",
+ "Canvas"
+ ]
+}
diff --git a/DemoLab/scenes/ko/membership.json b/DemoLab/scenes/ko/membership.json
new file mode 100644
index 0000000..0a27d7f
--- /dev/null
+++ b/DemoLab/scenes/ko/membership.json
@@ -0,0 +1,159 @@
+{
+ "name": "membership",
+ "title": "작업에 맞춰 앱 옮기기",
+ "summary": "단축키로 현재 앱을 다른 작업 공간에 옮겨요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "앱 배정"
+ },
+ {
+ "kind": "caption",
+ "text": "단축키로 현재 앱을 다른 작업 공간에 옮겨요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ]"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "옮긴 앱은 원래 작업 공간에서 빠져요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "같은 방식의 단축키로 앱을 다시 옮길 수 있어요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ["
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/pause.json b/DemoLab/scenes/ko/pause.json
new file mode 100644
index 0000000..aab7afe
--- /dev/null
+++ b/DemoLab/scenes/ko/pause.json
@@ -0,0 +1,119 @@
+{
+ "name": "pause",
+ "title": "직접 창 배치하기",
+ "summary": "자동 타일링을 잠시 멈추고 창을 자유롭게 놓아요. 다시 켜면 정돈된 배치로 돌아와요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "일시 정지와 다시 시작"
+ },
+ {
+ "kind": "caption",
+ "text": "자유롭게 놓고 싶을 때 타일링을 잠시 멈춰요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Docs",
+ "target": "Canvas",
+ "x": 0.65,
+ "y": 0.35
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "다시 켜면 Tatami가 창을 정리해요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/profile-copy.json b/DemoLab/scenes/ko/profile-copy.json
new file mode 100644
index 0000000..ab43469
--- /dev/null
+++ b/DemoLab/scenes/ko/profile-copy.json
@@ -0,0 +1,74 @@
+{
+ "name": "profile-copy",
+ "title": "기존 구성 재사용하기",
+ "summary": "설정 창에서 프로필을 복제하고 포함할 작업 공간을 검토해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "기존 구성 재사용"
+ },
+ {
+ "kind": "caption",
+ "text": "프로필을 오른쪽 클릭해 복제를 선택해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "rightClick",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:복제"
+ },
+ {
+ "kind": "caption",
+ "text": "복제할 작업 공간과 설정을 골라요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:복제"
+ },
+ {
+ "kind": "expectProfileCount",
+ "count": 3
+ },
+ {
+ "kind": "caption",
+ "text": "새 프로필은 원본과 별개로 편집할 수 있어요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ko/profiles.json b/DemoLab/scenes/ko/profiles.json
new file mode 100644
index 0000000..3b7a90c
--- /dev/null
+++ b/DemoLab/scenes/ko/profiles.json
@@ -0,0 +1,121 @@
+{
+ "name": "profiles",
+ "title": "전체 구성 전환하기",
+ "summary": "프로필을 전환하면 작업 공간 구성이 통째로 바뀌어요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "프로필"
+ },
+ {
+ "kind": "caption",
+ "text": "프로필을 전환하면 작업 공간 구성이 통째로 바뀌어요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 2"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "caption",
+ "text": "프로필마다 저장해둔 창 배치로 돌아올 수 있어요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 1"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/scratchpad.json b/DemoLab/scenes/ko/scratchpad.json
new file mode 100644
index 0000000..45bda60
--- /dev/null
+++ b/DemoLab/scenes/ko/scratchpad.json
@@ -0,0 +1,149 @@
+{
+ "name": "scratchpad",
+ "title": "다시 꺼내 쓰는 임시 공간",
+ "summary": "다시 빌려와도 앱에서 작업한 내용은 그대로 남아 있어요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "임시 공간"
+ },
+ {
+ "kind": "caption",
+ "text": "임시 작업 공간은 빌려올 때만 열려요."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "세 가지 색상 방향을 비교해요.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "세 가지 색상 방향을 비교해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "다시 빌려와도 앱에서 작업한 내용은 그대로 남아 있어요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "세 가지 색상 방향을 비교해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/shared.json b/DemoLab/scenes/ko/shared.json
new file mode 100644
index 0000000..7094f2c
--- /dev/null
+++ b/DemoLab/scenes/ko/shared.json
@@ -0,0 +1,166 @@
+{
+ "name": "shared",
+ "title": "여러 작업에서 같은 상태 창 보기",
+ "summary": "공용 앱을 항상 위로 두면 어느 작업 공간에서도 보여요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "공용 앱"
+ },
+ {
+ "kind": "caption",
+ "text": "공용 앱을 항상 위로 두면 어느 작업 공간에서도 보여요."
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "창 하나를 전체 크기로 확대해도 공용 창은 계속 보여요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Review"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ko/switching.json b/DemoLab/scenes/ko/switching.json
new file mode 100644
index 0000000..aff1908
--- /dev/null
+++ b/DemoLab/scenes/ko/switching.json
@@ -0,0 +1,130 @@
+{
+ "name": "switching",
+ "title": "필요한 창 고르기",
+ "summary": "전환 단축키를 길게 눌러 현재 창들을 확인해요. 짧게 누르면 바로 돌아갈 수 있어요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "창 전환"
+ },
+ {
+ "kind": "caption",
+ "text": "단축키를 길게 눌러 현재 작업 공간의 창을 골라요."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 900,
+ "releaseAfterMs": 1600
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "caption",
+ "text": "단축키를 짧게 누르면 직전에 사용한 창으로 돌아가요."
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/tiling.json b/DemoLab/scenes/ko/tiling.json
new file mode 100644
index 0000000..c709458
--- /dev/null
+++ b/DemoLab/scenes/ko/tiling.json
@@ -0,0 +1,161 @@
+{
+ "name": "tiling",
+ "title": "새 창이 제자리를 찾도록 하기",
+ "summary": "새 창을 열면 선택한 창의 영역을 나눠 함께 배치해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "자동 타일링"
+ },
+ {
+ "kind": "caption",
+ "text": "새 창을 열면 선택한 창의 영역을 나눠 함께 배치해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "caption",
+ "text": "새 창이 들어와도 다른 영역의 창은 제자리를 유지해요."
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "caption",
+ "text": "창을 닫으면 남은 창이 빈 공간을 채워요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/tour.json b/DemoLab/scenes/ko/tour.json
new file mode 100644
index 0000000..5c40705
--- /dev/null
+++ b/DemoLab/scenes/ko/tour.json
@@ -0,0 +1,582 @@
+{
+ "name": "tour",
+ "title": "모든 작업에 알맞은 자리를 만들어요.",
+ "summary": "디자인하고, 쓰고, 검토하고, 빌려오고, 자동화해요. 넓은 책상에 맞춰 구성도 바꿔요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "Tatami / 디자인"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간마다 필요한 앱과 창 배치를 따로 관리해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "caption",
+ "text": "창 하나를 작업 공간 전체 크기로 확대해요."
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectStory",
+ "field": "theme",
+ "value": "sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "caption",
+ "text": "확대를 해제하면 이전 창 배치로 돌아와요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.content"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 글쓰기"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간을 바꾸면 다음 작업에 필요한 앱이 함께 나타나요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.headline"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - a"
+ },
+ {
+ "kind": "typeText",
+ "app": "Editor",
+ "text": "더 차분하게 일해요.",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Editor",
+ "identifier": "editor.headline",
+ "value": "더 차분하게 일해요."
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.save"
+ },
+ {
+ "kind": "expectStory",
+ "field": "headline",
+ "value": "더 차분하게 일해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Design으로 돌아오면 사용하던 앱이 제자리에 나타나요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 2
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "saveLayout",
+ "text": "design"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.export"
+ },
+ {
+ "kind": "expectStory",
+ "field": "exported",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 검토"
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간 단축키로 사용하는 앱 구성을 한 번에 바꿔요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.check"
+ },
+ {
+ "kind": "expectStory",
+ "field": "checksPassed",
+ "value": "3"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.comment"
+ },
+ {
+ "kind": "typeText",
+ "app": "Review",
+ "text": "준비됐어요. 미리보기도 내보냈어요.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.approve"
+ },
+ {
+ "kind": "expectStory",
+ "field": "approved",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "돌아오면 창의 위치와 크기가 그대로 복원돼요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 빌려오기"
+ },
+ {
+ "kind": "caption",
+ "text": "빌려오기로 다른 작업 공간을 현재 작업 옆에 띄워요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.task.1"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "출시 안내를 준비해요.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "출시 안내를 준비해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 항상 위"
+ },
+ {
+ "kind": "caption",
+ "text": "항상 위로 설정한 창은 다른 앱 위에 계속 보여요."
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Docs",
+ "x": 0.3,
+ "y": 0.7
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 자동화"
+ },
+ {
+ "kind": "caption",
+ "text": "CLI로 작업 공간 동작들을 스크립트에 묶어 실행해요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 화면에 맞추기"
+ },
+ {
+ "kind": "caption",
+ "text": "화면을 연결하면 조건에 맞는 프로필이 자동으로 켜져요."
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "caption",
+ "text": "화면 연결을 해제하면 노트북용 프로필과 배치로 돌아와요."
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI"
+ },
+ {
+ "kind": "caption",
+ "text": "도구도, 하던 자리도 그대로예요. | BSP 타일링을 갖춘 macOS 작업 공간 관리자예요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/ko/unmanaged.json b/DemoLab/scenes/ko/unmanaged.json
new file mode 100644
index 0000000..431210b
--- /dev/null
+++ b/DemoLab/scenes/ko/unmanaged.json
@@ -0,0 +1,180 @@
+{
+ "name": "unmanaged",
+ "title": "놓아둔 자리에 창 유지하기",
+ "summary": "공용 앱을 일반 창으로 두면 작업을 바꿔도 위치와 크기가 유지돼요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "그대로 두기"
+ },
+ {
+ "kind": "caption",
+ "text": "그대로 두기로 설정한 창은 자동 타일링에서 제외돼요."
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Canvas",
+ "x": 0.8,
+ "y": 0.22
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "공용 앱으로 등록하면 작업 공간을 바꿔도 위치와 크기가 유지돼요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Monitor"
+ ]
+}
diff --git a/DemoLab/scenes/ko/workspace-copy.json b/DemoLab/scenes/ko/workspace-copy.json
new file mode 100644
index 0000000..2720481
--- /dev/null
+++ b/DemoLab/scenes/ko/workspace-copy.json
@@ -0,0 +1,91 @@
+{
+ "name": "workspace-copy",
+ "title": "작업 공간 간 설정 복사하기",
+ "summary": "다른 작업 공간에서 복사할 앱과 설정을 검토한 뒤 선택한 변경을 적용해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Desk"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "작업 공간 설정 복사"
+ },
+ {
+ "kind": "caption",
+ "text": "설정을 가져올 작업 공간을 선택해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:다른 작업 공간에서 복사…"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "title:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Design"
+ },
+ {
+ "kind": "caption",
+ "text": "현재 작업 공간에 복사할 변경 내용을 먼저 확인해요."
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:복사"
+ },
+ {
+ "kind": "expectAssignment",
+ "app": "Docs",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "caption",
+ "text": "선택한 앱과 설정이 현재 작업 공간에 복사됐어요."
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "heading:배치"
+ },
+ {
+ "kind": "beat",
+ "ms": 2400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/ko/workspaces.json b/DemoLab/scenes/ko/workspaces.json
new file mode 100644
index 0000000..e188182
--- /dev/null
+++ b/DemoLab/scenes/ko/workspaces.json
@@ -0,0 +1,174 @@
+{
+ "name": "workspaces",
+ "title": "작업 사이를 오가기",
+ "summary": "단축키로 작업 공간과 그 안의 앱을 함께 전환해요.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "작업 공간"
+ },
+ {
+ "kind": "caption",
+ "text": "단축키로 작업 공간과 그 안의 앱을 함께 전환해요."
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "색상 방향을 비교하고 있어요.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "색상 방향을 비교하고 있어요."
+ },
+ {
+ "kind": "caption",
+ "text": "최근 작업 공간 단축키로 방금 하던 작업에 돌아가요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "작업 공간을 순서대로 오갈 수도 있어요."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ."
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Focus"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ,"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/layout-editor.json b/DemoLab/scenes/layout-editor.json
new file mode 100644
index 0000000..8ce6891
--- /dev/null
+++ b/DemoLab/scenes/layout-editor.json
@@ -0,0 +1,131 @@
+{
+ "name": "layout-editor",
+ "title": "Plan a layout visually",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "LAYOUT EDITOR"
+ },
+ {
+ "kind": "caption",
+ "text": "Edit the saved layout before activating the workspace."
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Rotate 90°"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "Flip the top and bottom positions in the layout preview."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:Flip top ↔ bottom"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Activating the workspace applies the layout you just edited."
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "final-canvas-tile"
+ },
+ {
+ "kind": "closeSettings"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Canvas",
+ "target": "Docs",
+ "value": "above"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "summary": "Edit the saved layout before activating the workspace."
+}
diff --git a/DemoLab/scenes/layouts.json b/DemoLab/scenes/layouts.json
new file mode 100644
index 0000000..b7e616b
--- /dev/null
+++ b/DemoLab/scenes/layouts.json
@@ -0,0 +1,170 @@
+{
+ "name": "layouts",
+ "title": "Shape the layout from the keyboard",
+ "summary": "Swap windows, change a split, resize it, and balance the workspace.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Docs",
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "LAYOUT CONTROLS"
+ },
+ {
+ "kind": "caption",
+ "text": "Swap the focused window with the window beside it."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Switch between left/right and top/bottom splits."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - s"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "Grow the focused tile along its current split."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-grow"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 3
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-grow"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Balance resets the BSP layout when Auto-balance is off."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-balance"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-balance"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Docs",
+ "Docs",
+ "Canvas"
+ ]
+}
diff --git a/DemoLab/scenes/membership.json b/DemoLab/scenes/membership.json
new file mode 100644
index 0000000..0e101bf
--- /dev/null
+++ b/DemoLab/scenes/membership.json
@@ -0,0 +1,159 @@
+{
+ "name": "membership",
+ "title": "Move an app with the task",
+ "summary": "Move the focused app to a different workspace with a shortcut.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "APP MEMBERSHIP"
+ },
+ {
+ "kind": "caption",
+ "text": "Move the focused app to a different workspace with a shortcut."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ]"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "The moved app is no longer assigned to its original workspace."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "Use the same shortcut to move the app back."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ["
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/native-fullscreen.json b/DemoLab/scenes/native-fullscreen.json
new file mode 100644
index 0000000..0ddd78c
--- /dev/null
+++ b/DemoLab/scenes/native-fullscreen.json
@@ -0,0 +1,133 @@
+{
+ "name": "native-fullscreen",
+ "title": "Native fullscreen restoration diagnostic",
+ "summary": "Use Tatami zoom for a quick focus change, or enter and leave native macOS fullscreen.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "ZOOM & FULLSCREEN"
+ },
+ {
+ "kind": "caption",
+ "text": "Enlarge one tile without losing the saved layout."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "Native fullscreen stays native."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + cmd - f"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + cmd - f"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "Your workspace returns around it."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/pause.json b/DemoLab/scenes/pause.json
new file mode 100644
index 0000000..6e3d9ae
--- /dev/null
+++ b/DemoLab/scenes/pause.json
@@ -0,0 +1,119 @@
+{
+ "name": "pause",
+ "title": "Take manual control",
+ "summary": "Pause automatic tiling, position a window freely, and resume the arranged workspace.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "PAUSE & RESUME"
+ },
+ {
+ "kind": "caption",
+ "text": "Pause tiling when the task needs free placement."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Docs",
+ "target": "Canvas",
+ "x": 0.65,
+ "y": 0.35
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "Resume. Tatami arranges the workspace again."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/profile-copy.json b/DemoLab/scenes/profile-copy.json
new file mode 100644
index 0000000..7ebfdf3
--- /dev/null
+++ b/DemoLab/scenes/profile-copy.json
@@ -0,0 +1,74 @@
+{
+ "name": "profile-copy",
+ "title": "Reuse an existing setup",
+ "summary": "Duplicate a profile in the settings window and review the workspaces to include.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "REUSE A SETUP"
+ },
+ {
+ "kind": "caption",
+ "text": "Open Duplicate from a profile’s context menu."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "rightClick",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Duplicate"
+ },
+ {
+ "kind": "caption",
+ "text": "Choose the workspaces and settings to include."
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:Duplicate"
+ },
+ {
+ "kind": "expectProfileCount",
+ "count": 3
+ },
+ {
+ "kind": "caption",
+ "text": "The new profile can be edited independently."
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/profiles.json b/DemoLab/scenes/profiles.json
new file mode 100644
index 0000000..b728905
--- /dev/null
+++ b/DemoLab/scenes/profiles.json
@@ -0,0 +1,121 @@
+{
+ "name": "profiles",
+ "title": "Switch the complete setup",
+ "summary": "Switching profiles changes the complete workspace configuration.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "PROFILES"
+ },
+ {
+ "kind": "caption",
+ "text": "Switching profiles changes the complete workspace configuration."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 2"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "caption",
+ "text": "Each profile restores its own window layout."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 1"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/scratchpad.json b/DemoLab/scenes/scratchpad.json
new file mode 100644
index 0000000..806bb3f
--- /dev/null
+++ b/DemoLab/scenes/scratchpad.json
@@ -0,0 +1,149 @@
+{
+ "name": "scratchpad",
+ "title": "Keep a reusable scratchpad",
+ "summary": "Borrow the scratchpad again; its apps keep their contents.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "SCRATCHPADS"
+ },
+ {
+ "kind": "caption",
+ "text": "A scratchpad opens only when you borrow it."
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "Compare the three color directions.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "Compare the three color directions."
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "Borrow the scratchpad again; its apps keep their contents."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "Compare the three color directions."
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/settings.json b/DemoLab/scenes/settings.json
new file mode 100644
index 0000000..29153ce
--- /dev/null
+++ b/DemoLab/scenes/settings.json
@@ -0,0 +1,127 @@
+{
+ "name": "settings",
+ "title": "Tatami's own window",
+ "summary": "A short walk through the window everything else in these scenes was configured in: profiles, the workspaces under the active one, and the shared apps.",
+ "requires": {
+ "displays": 1,
+ "profile": "Laptop",
+ "apps": [
+ "Editor",
+ "Terminal",
+ "Docs",
+ "Monitor"
+ ],
+ "note": "run `democtl seed` first, and start the overlay. Tatami's window opens on the tab it was last left on, so open it once by hand and leave it on Workspaces before recording. This scene types real shortcuts, so THIS process needs Accessibility as well as Tatami."
+ },
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "Tatami itself"
+ },
+ {
+ "kind": "caption",
+ "text": "Everything those keys did is also here | Tatami is a menu bar app, and this is its window, opened the usual way"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400,
+ "note": "something real behind the window, composed while the caption above is read"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Tatami"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - ,"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200,
+ "note": "short because the runner holds the keycaps after the press: that hold covers the window opening"
+ },
+ {
+ "kind": "caption",
+ "text": "Profiles come first | Laptop and Desk are the same machine with different workspaces, and Tatami picks between them by how many displays are connected"
+ },
+ {
+ "kind": "beat",
+ "ms": 2800
+ },
+ {
+ "kind": "caption",
+ "text": "Under the active profile, its workspaces | Write, Review, Chat and Build, with the borrow-only Notes down in Scratchpads"
+ },
+ {
+ "kind": "beat",
+ "ms": 2800
+ },
+ {
+ "kind": "caption",
+ "text": "A workspace is its apps, its key and its layout rules | This list is exactly what Auto-open reads every time I switch to it"
+ },
+ {
+ "kind": "beat",
+ "ms": 2800
+ },
+ {
+ "kind": "caption",
+ "text": "Everywhere holds the shared apps | Monitor lives there, which is the whole reason it turns up in every workspace"
+ },
+ {
+ "kind": "beat",
+ "ms": 2800
+ },
+ {
+ "kind": "caption",
+ "text": "Close it and the demo underneath has not moved | Tatami never tiles its own windows"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "beat",
+ "ms": 800,
+ "note": "closing it here matters: a window left open leaks into the next take"
+ },
+ {
+ "kind": "caption",
+ "text": ""
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "clearOverlay"
+ }
+ ],
+ "setup": [
+ {
+ "kind": "note",
+ "text": "The title card goes up before the activation, so the take opens on windows arriving rather than on a staging beat."
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.55
+ },
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "quitApps"
+ }
+ ]
+}
diff --git a/DemoLab/scenes/shared.json b/DemoLab/scenes/shared.json
new file mode 100644
index 0000000..074679e
--- /dev/null
+++ b/DemoLab/scenes/shared.json
@@ -0,0 +1,166 @@
+{
+ "name": "shared",
+ "title": "One status window across tasks",
+ "summary": "A shared Always on Top window stays visible across workspaces.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "SHARED APPS"
+ },
+ {
+ "kind": "caption",
+ "text": "A shared Always on Top window stays visible across workspaces."
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "The shared window remains visible even over a zoomed tile."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Review"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/switching.json b/DemoLab/scenes/switching.json
new file mode 100644
index 0000000..0807cb7
--- /dev/null
+++ b/DemoLab/scenes/switching.json
@@ -0,0 +1,130 @@
+{
+ "name": "switching",
+ "title": "Choose the window you need",
+ "summary": "Hold the switcher shortcut to inspect the windows in this workspace, then switch back with a quick tap.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "WINDOW SWITCHER"
+ },
+ {
+ "kind": "caption",
+ "text": "Hold the shortcut to choose a window in the current workspace."
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 900,
+ "releaseAfterMs": 1600
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "caption",
+ "text": "Tap the shortcut to return to the last-used window."
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/tiling.json b/DemoLab/scenes/tiling.json
new file mode 100644
index 0000000..784659f
--- /dev/null
+++ b/DemoLab/scenes/tiling.json
@@ -0,0 +1,161 @@
+{
+ "name": "tiling",
+ "title": "Let new windows find their place",
+ "summary": "A new window splits the space beside the focused window.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AUTOMATIC TILING"
+ },
+ {
+ "kind": "caption",
+ "text": "A new window splits the space beside the focused window."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "caption",
+ "text": "Other tiles keep their positions while the new window fits in."
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "caption",
+ "text": "Closing a window lets the remaining tiles fill the space."
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/tour.json b/DemoLab/scenes/tour.json
new file mode 100644
index 0000000..ed0d0e7
--- /dev/null
+++ b/DemoLab/scenes/tour.json
@@ -0,0 +1,582 @@
+{
+ "name": "tour",
+ "title": "Make room for the whole workflow.",
+ "summary": "Design, write, review, borrow, automate, and adapt to a bigger desk.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "TATAMI / DESIGN"
+ },
+ {
+ "kind": "caption",
+ "text": "Each workspace keeps the apps and layout for one task."
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "caption",
+ "text": "Enlarge one window to fill the workspace."
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectStory",
+ "field": "theme",
+ "value": "sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "caption",
+ "text": "Exit zoom to restore the previous window layout."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.content"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI / WRITE"
+ },
+ {
+ "kind": "caption",
+ "text": "Switch workspaces to bring up the apps for your next task."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.headline"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - a"
+ },
+ {
+ "kind": "typeText",
+ "app": "Editor",
+ "text": "A calmer way to work.",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Editor",
+ "identifier": "editor.headline",
+ "value": "A calmer way to work."
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.save"
+ },
+ {
+ "kind": "expectStory",
+ "field": "headline",
+ "value": "A calmer way to work."
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Return to Design; its apps are ready where you left them."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 2
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "saveLayout",
+ "text": "design"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.export"
+ },
+ {
+ "kind": "expectStory",
+ "field": "exported",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI / REVIEW"
+ },
+ {
+ "kind": "caption",
+ "text": "A workspace shortcut switches the whole set of apps."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.check"
+ },
+ {
+ "kind": "expectStory",
+ "field": "checksPassed",
+ "value": "3"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.comment"
+ },
+ {
+ "kind": "typeText",
+ "app": "Review",
+ "text": "Ready. The preview is exported.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.approve"
+ },
+ {
+ "kind": "expectStory",
+ "field": "approved",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "Window positions and sizes are restored when you return."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI / BORROW"
+ },
+ {
+ "kind": "caption",
+ "text": "Borrow adds another workspace beside the current one."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.task.1"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "Prepare the release announcement.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "Prepare the release announcement."
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI / ALWAYS ON TOP"
+ },
+ {
+ "kind": "caption",
+ "text": "Always on Top keeps a window visible above other apps."
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Docs",
+ "x": 0.3,
+ "y": 0.7
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI / AUTOMATE"
+ },
+ {
+ "kind": "caption",
+ "text": "Use the CLI to combine workspace actions in a script."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI / ADAPT"
+ },
+ {
+ "kind": "caption",
+ "text": "Connecting a display automatically activates the matching profile."
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "caption",
+ "text": "Disconnecting the display restores the laptop profile and layout."
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI"
+ },
+ {
+ "kind": "caption",
+ "text": "Keep your tools. Keep your place. | A macOS workspace manager with BSP window tiling."
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/unmanaged.json b/DemoLab/scenes/unmanaged.json
new file mode 100644
index 0000000..6abbef6
--- /dev/null
+++ b/DemoLab/scenes/unmanaged.json
@@ -0,0 +1,180 @@
+{
+ "name": "unmanaged",
+ "title": "Leave a window where you put it",
+ "summary": "Shared membership with normal stacking: the window keeps its position and size across tasks.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "LEAVE AS IS"
+ },
+ {
+ "kind": "caption",
+ "text": "Leave As Is keeps a window out of automatic tiling."
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Canvas",
+ "x": 0.8,
+ "y": 0.22
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Shared membership keeps it in place when workspaces change."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Monitor"
+ ]
+}
diff --git a/DemoLab/scenes/workspace-copy.json b/DemoLab/scenes/workspace-copy.json
new file mode 100644
index 0000000..9eeb694
--- /dev/null
+++ b/DemoLab/scenes/workspace-copy.json
@@ -0,0 +1,91 @@
+{
+ "name": "workspace-copy",
+ "title": "Copy settings between workspaces",
+ "summary": "Use Copy from to review another workspace’s apps and settings, then apply the selected changes.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Desk"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "COPY WORKSPACE SETTINGS"
+ },
+ {
+ "kind": "caption",
+ "text": "Choose the workspace whose settings you want to copy."
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Copy from another workspace…"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "title:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Design"
+ },
+ {
+ "kind": "caption",
+ "text": "Review the changes before copying them into this workspace."
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:Copy"
+ },
+ {
+ "kind": "expectAssignment",
+ "app": "Docs",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "caption",
+ "text": "The selected apps and settings are now copied."
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "heading:Layout"
+ },
+ {
+ "kind": "beat",
+ "ms": 2400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/workspaces.json b/DemoLab/scenes/workspaces.json
new file mode 100644
index 0000000..3e64838
--- /dev/null
+++ b/DemoLab/scenes/workspaces.json
@@ -0,0 +1,174 @@
+{
+ "name": "workspaces",
+ "title": "Move between tasks",
+ "summary": "A shortcut switches to another workspace and its apps.",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "WORKSPACES"
+ },
+ {
+ "kind": "caption",
+ "text": "A shortcut switches to another workspace and its apps."
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "I am comparing the color directions.",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "I am comparing the color directions."
+ },
+ {
+ "kind": "caption",
+ "text": "The recent-workspace shortcut returns to the previous task."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "Or move through your workspaces in order."
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ."
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Focus"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ,"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/ai-draft.json b/DemoLab/scenes/zh-Hans/ai-draft.json
new file mode 100644
index 0000000..f326489
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/ai-draft.json
@@ -0,0 +1,109 @@
+{
+ "name": "ai-draft",
+ "title": "检查建议的配置",
+ "summary": "验证示例 AI 建议并应用到草稿。本次录制未调用外部 AI 服务。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Tatami"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:运行引导设置…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:继续"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:继续"
+ },
+ {
+ "kind": "clipboard",
+ "text": "{\n \"workspaces\": [\n {\n \"name\": \"Design review\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Canvas\",\n \"dev.PangMo5.DemoLab.Docs\"\n ]\n },\n {\n \"name\": \"Writing\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Editor\"\n ]\n },\n {\n \"name\": \"Conversation\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Chat\"\n ]\n },\n {\n \"name\": \"Quick notes\",\n \"kind\": \"scratchpad\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Notes\"\n ]\n }\n ]\n}\n"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AI 规划 / 示例建议"
+ },
+ {
+ "kind": "caption",
+ "text": "应用 AI 建议的配置之前,先进行检查。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3. 粘贴结果"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:Design review",
+ "value": "Design review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "检查建议的各个工作区分配了哪些应用。"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "description:将方案应用到草稿"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:将方案应用到草稿"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "text:Design review"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "caption",
+ "text": "只修改草稿,实际桌面保持不变。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/autoopen.json b/DemoLab/scenes/zh-Hans/autoopen.json
new file mode 100644
index 0000000..1ed3b40
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/autoopen.json
@@ -0,0 +1,120 @@
+{
+ "name": "autoopen",
+ "title": "一次打开所需工具",
+ "summary": "激活工作区时,自动打开分配给它的应用。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Chat",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "quitApps"
+ },
+ {
+ "kind": "beat",
+ "ms": 500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "自动打开"
+ },
+ {
+ "kind": "caption",
+ "text": "激活工作区时,自动打开分配给它的应用。"
+ },
+ {
+ "kind": "beat",
+ "ms": 200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "启用自动打开后,返回工作区也会重新打开已关闭的窗口。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": []
+}
diff --git a/DemoLab/scenes/zh-Hans/borrow-placement.json b/DemoLab/scenes/zh-Hans/borrow-placement.json
new file mode 100644
index 0000000..fe33dd6
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/borrow-placement.json
@@ -0,0 +1,174 @@
+{
+ "name": "borrow-placement",
+ "title": "选择位置和工作环境",
+ "summary": "把工作区借到左侧或下方,跨过边界移动焦点,再完整切换过去。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "configure",
+ "field": "borrowDefaultEdge",
+ "value": "remove"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "选择借用位置"
+ },
+ {
+ "kind": "caption",
+ "text": "选择将借用的工作区放在屏幕的哪一侧。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "left"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "也可以把借用的工作区放在当前工作区下方。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "down"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "激活借用的工作区,就会完全切换过去。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/borrow.json b/DemoLab/scenes/zh-Hans/borrow.json
new file mode 100644
index 0000000..0bfcc98
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/borrow.json
@@ -0,0 +1,126 @@
+{
+ "name": "borrow",
+ "title": "把对话放到旁边",
+ "summary": "无需离开当前任务,就能借用另一个工作区。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "借用工作区"
+ },
+ {
+ "kind": "caption",
+ "text": "无需离开当前任务,就能借用另一个工作区。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "沙金配色已经可以评审了。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "沙金配色已经可以评审了。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "收起借用的工作区,恢复完整布局。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/cli.json b/DemoLab/scenes/zh-Hans/cli.json
new file mode 100644
index 0000000..47b9c44
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/cli.json
@@ -0,0 +1,135 @@
+{
+ "name": "cli",
+ "title": "用 CLI 准备专注环境",
+ "summary": "查看实际工作区列表,再运行包含激活、均衡布局和借用操作的脚本。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "命令行"
+ },
+ {
+ "kind": "caption",
+ "text": "通过 CLI 查看工作区列表并控制正在运行的 Tatami。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "tatami workspace list",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "tatami workspace list",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "一个脚本即可切换、排列和借用工作区。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/configuration.json b/DemoLab/scenes/zh-Hans/configuration.json
new file mode 100644
index 0000000..c3be1a7
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/configuration.json
@@ -0,0 +1,170 @@
+{
+ "name": "configuration",
+ "title": "编辑 TOML,立即查看变化",
+ "summary": "Tatami 运行时,编辑 TOML 即可调整窗口间距。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "TOML / 实时重载"
+ },
+ {
+ "kind": "caption",
+ "text": "Tatami 运行时,编辑 TOML 即可调整窗口间距。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 32",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 32",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "支持的设置会自动生效,无需重启。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 10",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 10",
+ "code": 0
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/desk.json b/DemoLab/scenes/zh-Hans/desk.json
new file mode 100644
index 0000000..a316d53
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/desk.json
@@ -0,0 +1,147 @@
+{
+ "name": "desk",
+ "title": "两个显示器,一套工作流程",
+ "summary": "把设计和参考资料放在不同显示器上,用一个快捷键切换工作区链。",
+ "requires": {
+ "displays": 2
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "多个显示器"
+ },
+ {
+ "kind": "caption",
+ "text": "将不同的工作区固定到不同的显示器。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - right"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "标题",
+ "ms": 65
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - left"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "caption",
+ "text": "用工作区链一起切换两个显示器。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "caption",
+ "text": "一个快捷键,恢复之前搭配使用的工作区。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/drag.json b/DemoLab/scenes/zh-Hans/drag.json
new file mode 100644
index 0000000..0491218
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/drag.json
@@ -0,0 +1,145 @@
+{
+ "name": "drag",
+ "title": "用鼠标排列",
+ "summary": "拖移窗口、调整边缘,再恢复保存的布局。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "拖移与调整大小"
+ },
+ {
+ "kind": "caption",
+ "text": "将窗口拖到另一个窗口上,交换位置或插入布局。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Canvas",
+ "target": "Docs",
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "拖动窗口边缘,Tatami 会保存新的分割比例。"
+ },
+ {
+ "kind": "resizeWindow",
+ "app": "Canvas",
+ "x": 140
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/floating.json b/DemoLab/scenes/zh-Hans/floating.json
new file mode 100644
index 0000000..ae93b67
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/floating.json
@@ -0,0 +1,148 @@
+{
+ "name": "floating",
+ "title": "把便笺放在工作区上方",
+ "summary": "在置顶和参与平铺之间切换便笺,并使用其实际控件。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Focus",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "置顶"
+ },
+ {
+ "kind": "caption",
+ "text": "设为始终置顶的窗口会保持在工作区上方。"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "检查较小尺寸下的预览。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "检查较小尺寸下的预览。"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "切换为平铺,让窗口参与自动布局。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Notes"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "需要时,可以再次切换为始终置顶。"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 400
+ },
+ {
+ "kind": "restoreWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/focus.json b/DemoLab/scenes/zh-Hans/focus.json
new file mode 100644
index 0000000..b2925d9
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/focus.json
@@ -0,0 +1,206 @@
+{
+ "name": "focus",
+ "title": "让焦点跟随你的意图",
+ "summary": "用快捷键选择相邻窗口并移动鼠标指针,也可以将指针移入窗口来选中它。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "焦点与指针"
+ },
+ {
+ "kind": "caption",
+ "text": "用快捷键选中窗口时,鼠标指针也会移到该窗口。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "启用焦点跟随鼠标后,指针移入哪个窗口就选中哪个窗口。"
+ },
+ {
+ "kind": "hover",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "hover",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "标题",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Docs",
+ "identifier": "docs.search",
+ "value": "标题"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "关闭窗口后,自动选中剩下的窗口。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/fullscreen.json b/DemoLab/scenes/zh-Hans/fullscreen.json
new file mode 100644
index 0000000..9c194a9
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/fullscreen.json
@@ -0,0 +1,109 @@
+{
+ "name": "fullscreen",
+ "title": "放大工作区,集中注意力",
+ "summary": "将一个窗口放大到工作区大小,再准确恢复原来的布局。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "放大工作区"
+ },
+ {
+ "kind": "caption",
+ "text": "放大一个窗口,同时保留原来的布局。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "再按一次快捷键,恢复之前的布局。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/guided-setup.json b/DemoLab/scenes/zh-Hans/guided-setup.json
new file mode 100644
index 0000000..4833e33
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/guided-setup.json
@@ -0,0 +1,187 @@
+{
+ "name": "guided-setup",
+ "title": "边用边学",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:运行引导设置…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:继续"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:继续"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:继续"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "设置向导"
+ },
+ {
+ "kind": "caption",
+ "text": "在引导设置中练习功能,不会移动实际窗口。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "用向导内的按钮预览手势操作。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:4F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "在预览中练习已设置的快捷键。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:继续"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:5/9",
+ "value": "5/9"
+ },
+ {
+ "kind": "caption",
+ "text": "先用虚拟窗口练习平铺操作。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:向右聚焦"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:与左侧交换"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:下一个窗口"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:扩大窗口"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:翻转分区"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:均衡布局"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:你已体验完整的平铺工具集",
+ "value": "你已体验完整的平铺工具集"
+ },
+ {
+ "kind": "caption",
+ "text": "应用草稿前,实际桌面保持不变。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "summary": "不移动实际桌面,预览手势操作并练习快捷键。本片不展示物理触控板输入。"
+}
diff --git a/DemoLab/scenes/zh-Hans/hooks.json b/DemoLab/scenes/zh-Hans/hooks.json
new file mode 100644
index 0000000..0d5198a
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/hooks.json
@@ -0,0 +1,174 @@
+{
+ "name": "hooks",
+ "title": "把工作区变化连接到其他工具",
+ "summary": "工作区变化时,通过钩子通知外部工具。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "工作区与操作反馈钩子"
+ },
+ {
+ "kind": "caption",
+ "text": "工作区变化时,通过钩子通知外部工具。"
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Chat"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "caption",
+ "text": "HUD 钩子还能把操作提示发送给外部工具。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/hotplug.json b/DemoLab/scenes/zh-Hans/hotplug.json
new file mode 100644
index 0000000..13cd675
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/hotplug.json
@@ -0,0 +1,127 @@
+{
+ "name": "hotplug",
+ "title": "配置跟随显示器变化",
+ "summary": "连接第二个显示器,激活 Desk 配置方案;断开后恢复 Laptop 布局。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "匹配显示器的配置方案"
+ },
+ {
+ "kind": "caption",
+ "text": "连接第二个显示器,自动触发双屏配置方案。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2500
+ },
+ {
+ "kind": "caption",
+ "text": "断开连接,自动切换回单屏配置方案。"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ],
+ "captureSecondary": true
+}
diff --git a/DemoLab/scenes/zh-Hans/layout-editor.json b/DemoLab/scenes/zh-Hans/layout-editor.json
new file mode 100644
index 0000000..c1bf4b9
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/layout-editor.json
@@ -0,0 +1,131 @@
+{
+ "name": "layout-editor",
+ "title": "直观规划布局",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "布局编辑器"
+ },
+ {
+ "kind": "caption",
+ "text": "激活工作区之前,先编辑保存的窗口布局。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:旋转 90°"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "在布局预览中交换上下位置。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:上下翻转"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "激活工作区后,刚才编辑的布局会应用到实际窗口。"
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "final-canvas-tile"
+ },
+ {
+ "kind": "closeSettings"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Canvas",
+ "target": "Docs",
+ "value": "above"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "summary": "激活工作区之前,先编辑保存的窗口布局。"
+}
diff --git a/DemoLab/scenes/zh-Hans/layouts.json b/DemoLab/scenes/zh-Hans/layouts.json
new file mode 100644
index 0000000..ccdfd62
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/layouts.json
@@ -0,0 +1,170 @@
+{
+ "name": "layouts",
+ "title": "用键盘调整布局",
+ "summary": "交换窗口、改变分割方向、调整大小,再均衡整个布局。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Docs",
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "布局控制"
+ },
+ {
+ "kind": "caption",
+ "text": "交换当前选中窗口与相邻窗口的位置。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "在左右分割和上下分割之间切换。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - s"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "扩大当前选中窗口所占的区域。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-grow"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 3
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-grow"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "关闭自动均衡时,均衡命令会恢复默认 BSP 布局。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-balance"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-balance"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Docs",
+ "Docs",
+ "Canvas"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/membership.json b/DemoLab/scenes/zh-Hans/membership.json
new file mode 100644
index 0000000..643c3a1
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/membership.json
@@ -0,0 +1,159 @@
+{
+ "name": "membership",
+ "title": "随任务移动应用",
+ "summary": "用快捷键把当前应用移到另一个工作区。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "应用分配"
+ },
+ {
+ "kind": "caption",
+ "text": "用快捷键把当前应用移到另一个工作区。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ]"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "移动后,该应用不再属于原来的工作区。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "用同样的快捷键操作将应用移回去。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ["
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/pause.json b/DemoLab/scenes/zh-Hans/pause.json
new file mode 100644
index 0000000..ca419c4
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/pause.json
@@ -0,0 +1,119 @@
+{
+ "name": "pause",
+ "title": "手动控制布局",
+ "summary": "暂停自动平铺,自由摆放窗口,再恢复整理好的工作区。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "暂停与恢复"
+ },
+ {
+ "kind": "caption",
+ "text": "需要自由摆放时,暂停平铺。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Docs",
+ "target": "Canvas",
+ "x": 0.65,
+ "y": 0.35
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "恢复后,Tatami 会重新排列窗口。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/profile-copy.json b/DemoLab/scenes/zh-Hans/profile-copy.json
new file mode 100644
index 0000000..dc057eb
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/profile-copy.json
@@ -0,0 +1,74 @@
+{
+ "name": "profile-copy",
+ "title": "复用现有配置",
+ "summary": "在设置窗口中复制配置方案,并检查要包含的工作区。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "复用现有配置"
+ },
+ {
+ "kind": "caption",
+ "text": "右键单击配置方案,选择复制。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "rightClick",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:复制"
+ },
+ {
+ "kind": "caption",
+ "text": "选择要复制的工作区和设置。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:复制"
+ },
+ {
+ "kind": "expectProfileCount",
+ "count": 3
+ },
+ {
+ "kind": "caption",
+ "text": "新配置方案可以独立于原方案进行编辑。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/profiles.json b/DemoLab/scenes/zh-Hans/profiles.json
new file mode 100644
index 0000000..88c90b1
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/profiles.json
@@ -0,0 +1,121 @@
+{
+ "name": "profiles",
+ "title": "切换整套配置",
+ "summary": "切换配置方案会更换整套工作区配置。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "配置方案"
+ },
+ {
+ "kind": "caption",
+ "text": "切换配置方案会更换整套工作区配置。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 2"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "caption",
+ "text": "每个配置方案都会恢复各自保存的窗口布局。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 1"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/scratchpad.json b/DemoLab/scenes/zh-Hans/scratchpad.json
new file mode 100644
index 0000000..49211f1
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/scratchpad.json
@@ -0,0 +1,149 @@
+{
+ "name": "scratchpad",
+ "title": "随时调出的暂存区",
+ "summary": "再次借用时,应用中的内容仍然保留。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "暂存区"
+ },
+ {
+ "kind": "caption",
+ "text": "临时工作区只在借用时打开。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "比较三种配色方向。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "比较三种配色方向。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "再次借用时,应用中的内容仍然保留。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "比较三种配色方向。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/shared.json b/DemoLab/scenes/zh-Hans/shared.json
new file mode 100644
index 0000000..0079d51
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/shared.json
@@ -0,0 +1,166 @@
+{
+ "name": "shared",
+ "title": "跨任务使用同一个状态窗口",
+ "summary": "将共享应用设为始终置顶,在每个工作区都能看到它。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "共享应用"
+ },
+ {
+ "kind": "caption",
+ "text": "将共享应用设为始终置顶,在每个工作区都能看到它。"
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "即使放大一个平铺窗口,共享窗口仍然可见。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Review"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/switching.json b/DemoLab/scenes/zh-Hans/switching.json
new file mode 100644
index 0000000..f33c002
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/switching.json
@@ -0,0 +1,130 @@
+{
+ "name": "switching",
+ "title": "选择需要的窗口",
+ "summary": "按住切换快捷键查看当前窗口,轻按即可快速返回。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "窗口切换"
+ },
+ {
+ "kind": "caption",
+ "text": "按住快捷键,选择当前工作区中的窗口。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 900,
+ "releaseAfterMs": 1600
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "caption",
+ "text": "轻按快捷键,返回上一个使用的窗口。"
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/tiling.json b/DemoLab/scenes/zh-Hans/tiling.json
new file mode 100644
index 0000000..c4a60f6
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/tiling.json
@@ -0,0 +1,161 @@
+{
+ "name": "tiling",
+ "title": "让新窗口找到位置",
+ "summary": "新窗口会分割当前选中窗口的区域,自动排列。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "自动平铺"
+ },
+ {
+ "kind": "caption",
+ "text": "新窗口会分割当前选中窗口的区域,自动排列。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "caption",
+ "text": "新窗口加入时,其他区域的窗口保持原位。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "caption",
+ "text": "关闭窗口后,其余窗口会填满腾出的空间。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/tour.json b/DemoLab/scenes/zh-Hans/tour.json
new file mode 100644
index 0000000..6b1a0bd
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/tour.json
@@ -0,0 +1,582 @@
+{
+ "name": "tour",
+ "title": "为整个工作流程腾出空间。",
+ "summary": "设计、写作、评审、借用、自动化,再适应更大的桌面。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "Tatami / 设计"
+ },
+ {
+ "kind": "caption",
+ "text": "每个工作区分别保存一项任务所需的应用和窗口布局。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "caption",
+ "text": "将一个窗口放大到整个工作区。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectStory",
+ "field": "theme",
+ "value": "sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "caption",
+ "text": "退出放大,恢复原来的窗口布局。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.content"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 写作"
+ },
+ {
+ "kind": "caption",
+ "text": "切换工作区,一起调出下一项任务需要的应用。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.headline"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - a"
+ },
+ {
+ "kind": "typeText",
+ "app": "Editor",
+ "text": "让工作更从容。",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Editor",
+ "identifier": "editor.headline",
+ "value": "让工作更从容。"
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.save"
+ },
+ {
+ "kind": "expectStory",
+ "field": "headline",
+ "value": "让工作更从容。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "回到 Design,之前的应用仍在原来的位置。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 2
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "saveLayout",
+ "text": "design"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.export"
+ },
+ {
+ "kind": "expectStory",
+ "field": "exported",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 评审"
+ },
+ {
+ "kind": "caption",
+ "text": "用工作区快捷键一次切换整组应用。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.check"
+ },
+ {
+ "kind": "expectStory",
+ "field": "checksPassed",
+ "value": "3"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.comment"
+ },
+ {
+ "kind": "typeText",
+ "app": "Review",
+ "text": "准备好了,预览也已导出。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.approve"
+ },
+ {
+ "kind": "expectStory",
+ "field": "approved",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "返回时,窗口的位置和大小会恢复。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 借用"
+ },
+ {
+ "kind": "caption",
+ "text": "用借用功能,把另一个工作区显示在当前工作区旁边。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.task.1"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "准备发布公告。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "准备发布公告。"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 置顶"
+ },
+ {
+ "kind": "caption",
+ "text": "设为始终置顶的窗口会一直显示在其他应用上方。"
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Docs",
+ "x": 0.3,
+ "y": 0.7
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 自动化"
+ },
+ {
+ "kind": "caption",
+ "text": "用 CLI 将多项工作区操作组合成脚本。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 适应环境"
+ },
+ {
+ "kind": "caption",
+ "text": "连接显示器后,自动切换到符合条件的配置方案。"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "caption",
+ "text": "断开显示器后,恢复笔记本的配置方案和布局。"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI"
+ },
+ {
+ "kind": "caption",
+ "text": "工具和工作位置,都为你保留。 | 支持 BSP 窗口平铺的 macOS 工作区管理器。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/unmanaged.json b/DemoLab/scenes/zh-Hans/unmanaged.json
new file mode 100644
index 0000000..1a04945
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/unmanaged.json
@@ -0,0 +1,180 @@
+{
+ "name": "unmanaged",
+ "title": "让窗口留在原处",
+ "summary": "让共享应用保持普通堆叠顺序,跨任务保留窗口的位置和大小。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "保持原样"
+ },
+ {
+ "kind": "caption",
+ "text": "设为保持原样的窗口不参与自动平铺。"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Canvas",
+ "x": 0.8,
+ "y": 0.22
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "设为共享应用后,切换工作区也会保持它的位置和大小。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Monitor"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/workspace-copy.json b/DemoLab/scenes/zh-Hans/workspace-copy.json
new file mode 100644
index 0000000..a413690
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/workspace-copy.json
@@ -0,0 +1,91 @@
+{
+ "name": "workspace-copy",
+ "title": "在工作区之间复制设置",
+ "summary": "检查另一个工作区的应用和设置,然后应用所选更改。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Desk"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "复制工作区设置"
+ },
+ {
+ "kind": "caption",
+ "text": "选择要从中复制设置的工作区。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:从其他工作区复制…"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "title:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Design"
+ },
+ {
+ "kind": "caption",
+ "text": "复制到当前工作区前,先检查更改内容。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:复制"
+ },
+ {
+ "kind": "expectAssignment",
+ "app": "Docs",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "caption",
+ "text": "所选应用和设置已复制到当前工作区。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "heading:布局"
+ },
+ {
+ "kind": "beat",
+ "ms": 2400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hans/workspaces.json b/DemoLab/scenes/zh-Hans/workspaces.json
new file mode 100644
index 0000000..1cf5816
--- /dev/null
+++ b/DemoLab/scenes/zh-Hans/workspaces.json
@@ -0,0 +1,174 @@
+{
+ "name": "workspaces",
+ "title": "在任务之间切换",
+ "summary": "用快捷键切换工作区及其中的应用。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "工作区"
+ },
+ {
+ "kind": "caption",
+ "text": "用快捷键切换工作区及其中的应用。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "我正在比较配色方向。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "我正在比较配色方向。"
+ },
+ {
+ "kind": "caption",
+ "text": "用最近工作区快捷键,回到刚才的任务。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "也可以按顺序切换工作区。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ."
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Focus"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ,"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/ai-draft.json b/DemoLab/scenes/zh-Hant/ai-draft.json
new file mode 100644
index 0000000..84266f3
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/ai-draft.json
@@ -0,0 +1,109 @@
+{
+ "name": "ai-draft",
+ "title": "檢查建議的設定",
+ "summary": "驗證範例 AI 建議並套用到草稿。本次錄製未呼叫外部 AI 服務。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Tatami"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:執行引導設定…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:繼續"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:繼續"
+ },
+ {
+ "kind": "clipboard",
+ "text": "{\n \"workspaces\": [\n {\n \"name\": \"Design review\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Canvas\",\n \"dev.PangMo5.DemoLab.Docs\"\n ]\n },\n {\n \"name\": \"Writing\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Editor\"\n ]\n },\n {\n \"name\": \"Conversation\",\n \"kind\": \"workspace\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Chat\"\n ]\n },\n {\n \"name\": \"Quick notes\",\n \"kind\": \"scratchpad\",\n \"bundleIdentifiers\": [\n \"dev.PangMo5.DemoLab.Notes\"\n ]\n }\n ]\n}\n"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "AI 規劃 / 範例建議"
+ },
+ {
+ "kind": "caption",
+ "text": "套用 AI 建議的設定之前,先進行檢查。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3. 貼上結果"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:Design review",
+ "value": "Design review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "檢查建議的各個工作空間指派了哪些 App。"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "description:將規劃套用至草稿"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:將規劃套用至草稿"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "text:Design review"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "desktop"
+ },
+ {
+ "kind": "caption",
+ "text": "只修改草稿,實際桌面保持不變。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/autoopen.json b/DemoLab/scenes/zh-Hant/autoopen.json
new file mode 100644
index 0000000..2e70b3e
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/autoopen.json
@@ -0,0 +1,120 @@
+{
+ "name": "autoopen",
+ "title": "一次開啟所需工具",
+ "summary": "啟用工作空間時,自動開啟指派給它的 App。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Chat",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "quitApps"
+ },
+ {
+ "kind": "beat",
+ "ms": 500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "自動開啟"
+ },
+ {
+ "kind": "caption",
+ "text": "啟用工作空間時,自動開啟指派給它的 App。"
+ },
+ {
+ "kind": "beat",
+ "ms": 200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "啟用自動開啟後,返回工作空間也會重新開啟已關閉的視窗。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": []
+}
diff --git a/DemoLab/scenes/zh-Hant/borrow-placement.json b/DemoLab/scenes/zh-Hant/borrow-placement.json
new file mode 100644
index 0000000..71307d8
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/borrow-placement.json
@@ -0,0 +1,174 @@
+{
+ "name": "borrow-placement",
+ "title": "選擇位置與工作情境",
+ "summary": "把工作空間借到左側或下方,跨過邊界移動焦點,再完整切換過去。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "configure",
+ "field": "borrowDefaultEdge",
+ "value": "remove"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "選擇借用位置"
+ },
+ {
+ "kind": "caption",
+ "text": "選擇將借用的工作空間放在螢幕的哪一側。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "left"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "也可以將借用的工作空間放在目前工作空間下方。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "key",
+ "chord": "down"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "啟用借用的工作空間,就會完全切換過去。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/borrow.json b/DemoLab/scenes/zh-Hant/borrow.json
new file mode 100644
index 0000000..47e3c64
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/borrow.json
@@ -0,0 +1,126 @@
+{
+ "name": "borrow",
+ "title": "把對話放到旁邊",
+ "summary": "不必離開目前工作,就能借用另一個工作空間。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "借用工作空間"
+ },
+ {
+ "kind": "caption",
+ "text": "不必離開目前工作,就能借用另一個工作空間。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - 3"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "沙金配色已經可以審閱了。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "沙金配色已經可以審閱了。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "收起借用的工作空間,恢復完整排列。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/cli.json b/DemoLab/scenes/zh-Hant/cli.json
new file mode 100644
index 0000000..7f1c099
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/cli.json
@@ -0,0 +1,135 @@
+{
+ "name": "cli",
+ "title": "用 CLI 準備專注環境",
+ "summary": "查看實際工作空間清單,再執行包含啟用、平均排列與借用操作的指令稿。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "命令列"
+ },
+ {
+ "kind": "caption",
+ "text": "透過 CLI 查看工作空間清單,並操作執行中的 Tatami。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "tatami workspace list",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "tatami workspace list",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "一個指令稿即可切換、排列及借用工作空間。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/configuration.json b/DemoLab/scenes/zh-Hant/configuration.json
new file mode 100644
index 0000000..af57abb
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/configuration.json
@@ -0,0 +1,170 @@
+{
+ "name": "configuration",
+ "title": "編輯 TOML,立即查看變化",
+ "summary": "Tatami 執行時,編輯 TOML 即可調整視窗間距。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Build",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Terminal"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "TOML / 即時重新載入"
+ },
+ {
+ "kind": "caption",
+ "text": "Tatami 執行時,編輯 TOML 即可調整視窗間距。"
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 32",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 32",
+ "code": 0
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "支援的設定會自動生效,不必重新啟動。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "set-spacing 10",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "set-spacing 10",
+ "code": 0
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Terminal"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/desk.json b/DemoLab/scenes/zh-Hant/desk.json
new file mode 100644
index 0000000..38ba3a4
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/desk.json
@@ -0,0 +1,147 @@
+{
+ "name": "desk",
+ "title": "兩部顯示器,一套工作流程",
+ "summary": "把設計和參考資料放在不同顯示器上,用一個快速鍵切換工作空間鏈。",
+ "requires": {
+ "displays": 2
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "多部顯示器"
+ },
+ {
+ "kind": "caption",
+ "text": "將不同的工作空間固定至不同的顯示器。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - right"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "標題",
+ "ms": 65
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - left"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "caption",
+ "text": "用工作空間鏈一起切換兩部顯示器。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Chat"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "caption",
+ "text": "一個快速鍵,恢復先前搭配使用的工作空間。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/drag.json b/DemoLab/scenes/zh-Hant/drag.json
new file mode 100644
index 0000000..d523fc1
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/drag.json
@@ -0,0 +1,145 @@
+{
+ "name": "drag",
+ "title": "用滑鼠排列",
+ "summary": "拖移視窗、調整邊緣,再還原儲存的排列。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "拖移與調整大小"
+ },
+ {
+ "kind": "caption",
+ "text": "將視窗拖到另一個視窗上,交換位置或插入排列。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Canvas",
+ "target": "Docs",
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "拖曳視窗邊緣,Tatami 會儲存新的分割比例。"
+ },
+ {
+ "kind": "resizeWindow",
+ "app": "Canvas",
+ "x": 140
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "resized"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/floating.json b/DemoLab/scenes/zh-Hant/floating.json
new file mode 100644
index 0000000..cc7123e
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/floating.json
@@ -0,0 +1,148 @@
+{
+ "name": "floating",
+ "title": "把便箋放在工作空間上方",
+ "summary": "在置頂和參與並排之間切換便箋,並使用其實際控制項。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Focus",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "置頂"
+ },
+ {
+ "kind": "caption",
+ "text": "設為保持最上層的視窗,會持續顯示在工作空間上方。"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "檢查較小尺寸下的預覽。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "檢查較小尺寸下的預覽。"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "切換為並排,讓視窗加入自動排列。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Notes"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "需要時,可以再次切換為保持最上層。"
+ },
+ {
+ "kind": "key",
+ "chord": "alt + cmd - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 400
+ },
+ {
+ "kind": "restoreWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Notes"
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/focus.json b/DemoLab/scenes/zh-Hant/focus.json
new file mode 100644
index 0000000..b550633
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/focus.json
@@ -0,0 +1,206 @@
+{
+ "name": "focus",
+ "title": "讓焦點跟隨你的意圖",
+ "summary": "用快速鍵選擇相鄰視窗並移動滑鼠游標,也可以將游標移入視窗來選取它。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "焦點與游標"
+ },
+ {
+ "kind": "caption",
+ "text": "用快速鍵選取視窗時,滑鼠游標也會移到該視窗。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - h"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - l"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "expectPointer",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "啟用焦點跟隨滑鼠後,游標移入哪個視窗就選取哪個視窗。"
+ },
+ {
+ "kind": "hover",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "hover",
+ "app": "Docs",
+ "identifier": "docs.search"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "typeText",
+ "app": "Docs",
+ "text": "標題",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Docs",
+ "identifier": "docs.search",
+ "value": "標題"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "關閉視窗後,自動選取剩下的視窗。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/fullscreen.json b/DemoLab/scenes/zh-Hant/fullscreen.json
new file mode 100644
index 0000000..8092c77
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/fullscreen.json
@@ -0,0 +1,109 @@
+{
+ "name": "fullscreen",
+ "title": "放大工作空間,集中注意力",
+ "summary": "將一個視窗放大到工作空間大小,再完整還原原來的排列。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "放大工作空間"
+ },
+ {
+ "kind": "caption",
+ "text": "放大一個視窗,同時保留原本的排列。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "再按一次快速鍵,恢復先前的排列。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/guided-setup.json b/DemoLab/scenes/zh-Hant/guided-setup.json
new file mode 100644
index 0000000..8acfff7
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/guided-setup.json
@@ -0,0 +1,187 @@
+{
+ "name": "guided-setup",
+ "title": "邊用邊學",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "gearshape"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:執行引導設定…"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:繼續"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:繼續"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:繼續"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "設定導覽"
+ },
+ {
+ "kind": "caption",
+ "text": "在引導設定中練習功能,不會移動實際視窗。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "用導覽內的按鈕預覽手勢操作。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:3F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:4F →"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "caption",
+ "text": "在預覽中練習已設定的快速鍵。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "description:繼續"
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:5/9",
+ "value": "5/9"
+ },
+ {
+ "kind": "caption",
+ "text": "先用虛擬視窗練習並排操作。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:向右聚焦"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:與左側交換"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:下一個視窗"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:放大視窗"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:翻轉分割區"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "arrow.up.left.and.arrow.down.right"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:平衡佈局"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectValue",
+ "app": "Tatami",
+ "identifier": "text:你已體驗完整的並排工具組",
+ "value": "你已體驗完整的並排工具組"
+ },
+ {
+ "kind": "caption",
+ "text": "套用草稿前,實際桌面保持不變。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "summary": "不移動實際桌面,預覽手勢操作並練習快速鍵。本片不展示實體觸控式軌跡板輸入。"
+}
diff --git a/DemoLab/scenes/zh-Hant/hooks.json b/DemoLab/scenes/zh-Hant/hooks.json
new file mode 100644
index 0000000..c937ae0
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/hooks.json
@@ -0,0 +1,174 @@
+{
+ "name": "hooks",
+ "title": "把工作空間變化連接到其他工具",
+ "summary": "工作空間變更時,透過掛鉤通知外部工具。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "工作空間與操作回饋掛鉤"
+ },
+ {
+ "kind": "caption",
+ "text": "工作空間變更時,透過掛鉤通知外部工具。"
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Chat"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "caption",
+ "text": "HUD 掛鉤也能將操作提示傳送給外部工具。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/hotplug.json b/DemoLab/scenes/zh-Hant/hotplug.json
new file mode 100644
index 0000000..75cde3c
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/hotplug.json
@@ -0,0 +1,127 @@
+{
+ "name": "hotplug",
+ "title": "設定跟隨顯示器變化",
+ "summary": "連接第二部顯示器,啟用 Desk 設定組合;中斷連接後還原 Laptop 排列。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "配合顯示器的設定組合"
+ },
+ {
+ "kind": "caption",
+ "text": "連接第二部顯示器,自動啟用雙螢幕的設定組合。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2500
+ },
+ {
+ "kind": "caption",
+ "text": "中斷連接,自動切換回單螢幕的設定組合。"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ],
+ "captureSecondary": true
+}
diff --git a/DemoLab/scenes/zh-Hant/layout-editor.json b/DemoLab/scenes/zh-Hant/layout-editor.json
new file mode 100644
index 0000000..98f6a30
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/layout-editor.json
@@ -0,0 +1,131 @@
+{
+ "name": "layout-editor",
+ "title": "直覺規劃排列",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "排列編輯器"
+ },
+ {
+ "kind": "caption",
+ "text": "啟用工作空間之前,先編輯儲存的視窗排列。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:旋轉 90°"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "canvas-tile"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "在排列預覽中交換上下位置。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "help:上下翻轉"
+ },
+ {
+ "kind": "expectControlMoved",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "before-mirror"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "啟用工作空間後,剛才編輯的排列會套用至實際視窗。"
+ },
+ {
+ "kind": "saveControlFrame",
+ "app": "Tatami",
+ "identifier": "text:Canvas",
+ "text": "final-canvas-tile"
+ },
+ {
+ "kind": "closeSettings"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Canvas",
+ "target": "Docs",
+ "value": "above"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "summary": "啟用工作空間之前,先編輯儲存的視窗排列。"
+}
diff --git a/DemoLab/scenes/zh-Hant/layouts.json b/DemoLab/scenes/zh-Hant/layouts.json
new file mode 100644
index 0000000..015cbd6
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/layouts.json
@@ -0,0 +1,170 @@
+{
+ "name": "layouts",
+ "title": "用鍵盤調整排列",
+ "summary": "交換視窗、變更分割方向、調整大小,再平均調整整個排列。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Docs",
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "排列控制"
+ },
+ {
+ "kind": "caption",
+ "text": "交換目前選取視窗與相鄰視窗的位置。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "在左右分割與上下分割之間切換。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - s"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "擴大目前選取視窗所占的區域。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-grow"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 3
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-grow"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "關閉自動平衡時,平衡指令會恢復預設 BSP 排列。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "before-balance"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "before-balance"
+ },
+ {
+ "kind": "beat",
+ "ms": 2000
+ }
+ ],
+ "openingApps": [
+ "Docs",
+ "Docs",
+ "Canvas"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/membership.json b/DemoLab/scenes/zh-Hant/membership.json
new file mode 100644
index 0000000..59cc235
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/membership.json
@@ -0,0 +1,159 @@
+{
+ "name": "membership",
+ "title": "隨工作移動 App",
+ "summary": "用快速鍵將目前的 App 移至另一個工作空間。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "App 指派"
+ },
+ {
+ "kind": "caption",
+ "text": "用快速鍵將目前的 App 移至另一個工作空間。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ]"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "caption",
+ "text": "移動後,該 App 不再屬於原本的工作空間。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "caption",
+ "text": "用相同的快速鍵操作將 App 移回去。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ["
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/pause.json b/DemoLab/scenes/zh-Hant/pause.json
new file mode 100644
index 0000000..5d9f04b
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/pause.json
@@ -0,0 +1,119 @@
+{
+ "name": "pause",
+ "title": "手動控制排列",
+ "summary": "暫停自動並排,自由擺放視窗,再恢復整理好的工作空間。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "暫停與恢復"
+ },
+ {
+ "kind": "caption",
+ "text": "需要自由擺放時,暫停並排。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Docs",
+ "target": "Canvas",
+ "x": 0.65,
+ "y": 0.35
+ },
+ {
+ "kind": "expectLayoutChanged",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "caption",
+ "text": "恢復後,Tatami 會重新排列視窗。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - z"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - e"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/profile-copy.json b/DemoLab/scenes/zh-Hant/profile-copy.json
new file mode 100644
index 0000000..ea3b687
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/profile-copy.json
@@ -0,0 +1,74 @@
+{
+ "name": "profile-copy",
+ "title": "重複使用現有設定",
+ "summary": "在設定視窗中複製設定組合,並檢查要包含的工作空間。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "重複使用現有設定"
+ },
+ {
+ "kind": "caption",
+ "text": "在設定組合上按右鍵,選擇複製。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "rightClick",
+ "app": "Tatami",
+ "identifier": "text:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 1100
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:複製"
+ },
+ {
+ "kind": "caption",
+ "text": "選擇要複製的工作空間與設定。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:複製"
+ },
+ {
+ "kind": "expectProfileCount",
+ "count": 3
+ },
+ {
+ "kind": "caption",
+ "text": "新的設定組合可以獨立於原組合進行編輯。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/profiles.json b/DemoLab/scenes/zh-Hant/profiles.json
new file mode 100644
index 0000000..533f205
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/profiles.json
@@ -0,0 +1,121 @@
+{
+ "name": "profiles",
+ "title": "切換整套設定",
+ "summary": "切換設定組合會更換整套工作空間設定。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Editor"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "設定組合"
+ },
+ {
+ "kind": "caption",
+ "text": "切換設定組合會更換整套工作空間設定。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 2"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1700
+ },
+ {
+ "kind": "caption",
+ "text": "每個設定組合都會恢復各自儲存的視窗排列。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift + cmd - 1"
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1900
+ }
+ ],
+ "openingApps": [
+ "Editor",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/scratchpad.json b/DemoLab/scenes/zh-Hant/scratchpad.json
new file mode 100644
index 0000000..6651d49
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/scratchpad.json
@@ -0,0 +1,149 @@
+{
+ "name": "scratchpad",
+ "title": "隨時叫出的暫存區",
+ "summary": "再次借用時,App 中的內容仍然保留。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "暫存區"
+ },
+ {
+ "kind": "caption",
+ "text": "暫存工作空間只會在借用時開啟。"
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "比較三種配色方向。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "比較三種配色方向。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "caption",
+ "text": "再次借用時,App 中的內容仍然保留。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "比較三種配色方向。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/shared.json b/DemoLab/scenes/zh-Hant/shared.json
new file mode 100644
index 0000000..16b4863
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/shared.json
@@ -0,0 +1,166 @@
+{
+ "name": "shared",
+ "title": "跨工作使用同一個狀態視窗",
+ "summary": "將共用 App 設為保持最上層,在每個工作空間都能看到它。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "共用 App"
+ },
+ {
+ "kind": "caption",
+ "text": "將共用 App 設為保持最上層,在每個工作空間都能看到它。"
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Review"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "caption",
+ "text": "即使放大一個並排視窗,共用視窗仍然可見。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Review"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/switching.json b/DemoLab/scenes/zh-Hant/switching.json
new file mode 100644
index 0000000..c6e0261
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/switching.json
@@ -0,0 +1,130 @@
+{
+ "name": "switching",
+ "title": "選擇需要的視窗",
+ "summary": "按住切換快速鍵查看目前視窗,輕按即可快速返回。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "appWindows",
+ "app": "Docs",
+ "count": 2
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "視窗切換"
+ },
+ {
+ "kind": "caption",
+ "text": "按住快速鍵,選擇目前工作空間中的視窗。"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 900,
+ "releaseAfterMs": 1600
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "caption",
+ "text": "輕按快速鍵,返回上一個使用的視窗。"
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "alt - tab"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/tiling.json b/DemoLab/scenes/zh-Hant/tiling.json
new file mode 100644
index 0000000..fea10fc
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/tiling.json
@@ -0,0 +1,161 @@
+{
+ "name": "tiling",
+ "title": "讓新視窗找到位置",
+ "summary": "新視窗會分割目前選取視窗的區域,自動排列。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - left"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "自動並排"
+ },
+ {
+ "kind": "caption",
+ "text": "新視窗會分割目前選取視窗的區域,自動排列。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "expectFront",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 600
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "expectPlacement",
+ "app": "Docs",
+ "target": "Canvas",
+ "value": "right"
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "caption",
+ "text": "新視窗加入時,其他區域的視窗保持原位。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "activateApp",
+ "app": "Docs"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - w"
+ },
+ {
+ "kind": "caption",
+ "text": "關閉視窗後,其餘視窗會填滿空出的空間。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2100
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/tour.json b/DemoLab/scenes/zh-Hant/tour.json
new file mode 100644
index 0000000..d98616f
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/tour.json
@@ -0,0 +1,582 @@
+{
+ "name": "tour",
+ "title": "為整個工作流程騰出空間。",
+ "summary": "設計、撰寫、審閱、借用、自動化,再配合更大的桌面。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "Tatami / 設計"
+ },
+ {
+ "kind": "caption",
+ "text": "每個工作空間分別保存一項工作所需的 App 與視窗排列。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "caption",
+ "text": "將一個視窗放大至整個工作空間。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "expectStory",
+ "field": "theme",
+ "value": "sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.sand"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "caption",
+ "text": "取消放大後,恢復原本的視窗排列。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "click",
+ "app": "Docs",
+ "identifier": "docs.content"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 撰寫"
+ },
+ {
+ "kind": "caption",
+ "text": "切換工作空間,一次顯示下一項工作需要的 App。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 1"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Write"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.headline"
+ },
+ {
+ "kind": "key",
+ "chord": "cmd - a"
+ },
+ {
+ "kind": "typeText",
+ "app": "Editor",
+ "text": "讓工作更從容。",
+ "ms": 65
+ },
+ {
+ "kind": "expectValue",
+ "app": "Editor",
+ "identifier": "editor.headline",
+ "value": "讓工作更從容。"
+ },
+ {
+ "kind": "click",
+ "app": "Editor",
+ "identifier": "editor.save"
+ },
+ {
+ "kind": "expectStory",
+ "field": "headline",
+ "value": "讓工作更從容。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "回到 Design,先前使用的 App 仍在原本的位置。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - =",
+ "repeats": 2
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "saveLayout",
+ "text": "design"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.export"
+ },
+ {
+ "kind": "expectStory",
+ "field": "exported",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 審閱"
+ },
+ {
+ "kind": "caption",
+ "text": "用工作空間快速鍵一次切換整組 App。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.check"
+ },
+ {
+ "kind": "expectStory",
+ "field": "checksPassed",
+ "value": "3"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.comment"
+ },
+ {
+ "kind": "typeText",
+ "app": "Review",
+ "text": "準備好了,預覽也已輸出。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "click",
+ "app": "Review",
+ "identifier": "review.approve"
+ },
+ {
+ "kind": "expectStory",
+ "field": "approved",
+ "value": "true"
+ },
+ {
+ "kind": "beat",
+ "ms": 1200
+ },
+ {
+ "kind": "caption",
+ "text": "返回時,視窗的位置與大小會恢復。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "beat",
+ "ms": 800
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 借用"
+ },
+ {
+ "kind": "caption",
+ "text": "用借用功能,將另一個工作空間顯示在目前的工作空間旁。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - n"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Notes"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.task.1"
+ },
+ {
+ "kind": "click",
+ "app": "Notes",
+ "identifier": "notes.newTask"
+ },
+ {
+ "kind": "typeText",
+ "app": "Notes",
+ "text": "準備發布公告。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastTask",
+ "value": "準備發布公告。"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "assertLayout",
+ "text": "design"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 置頂"
+ },
+ {
+ "kind": "caption",
+ "text": "設為保持最上層的視窗,會持續顯示在其他 App 上方。"
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Monitor",
+ "identifier": "monitor.mode"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Docs",
+ "x": 0.3,
+ "y": 0.7
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "beat",
+ "ms": 1300
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt - return"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 自動化"
+ },
+ {
+ "kind": "caption",
+ "text": "用 CLI 將多個工作空間操作組合成指令稿。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 4"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Build"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Terminal"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Terminal",
+ "identifier": "terminal.command"
+ },
+ {
+ "kind": "typeText",
+ "app": "Terminal",
+ "text": "focus-session",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectCommand",
+ "text": "focus-session",
+ "code": 0
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs",
+ "Notes"
+ ]
+ },
+ {
+ "kind": "expectHook",
+ "field": "workspace",
+ "value": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1600
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + cmd - /"
+ },
+ {
+ "kind": "chapter",
+ "text": "Tatami / 配合環境"
+ },
+ {
+ "kind": "caption",
+ "text": "連接顯示器後,自動切換至符合條件的設定組合。"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": true
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Desk",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "expectHook",
+ "field": "profile",
+ "value": "Desk"
+ },
+ {
+ "kind": "beat",
+ "ms": 2200
+ },
+ {
+ "kind": "caption",
+ "text": "中斷顯示器連接後,恢復筆電的設定組合與排列。"
+ },
+ {
+ "kind": "virtualDisplay",
+ "connected": false
+ },
+ {
+ "kind": "waitProfile",
+ "profile": "Laptop",
+ "timeoutMs": 12000
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Canvas"
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Docs"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ },
+ {
+ "kind": "chapter",
+ "text": "TATAMI"
+ },
+ {
+ "kind": "caption",
+ "text": "工具與工作位置,都為你保留。 | 支援 BSP 視窗並排的 macOS 工作空間管理工具。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2600
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/unmanaged.json b/DemoLab/scenes/zh-Hant/unmanaged.json
new file mode 100644
index 0000000..2762054
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/unmanaged.json
@@ -0,0 +1,180 @@
+{
+ "name": "unmanaged",
+ "title": "讓視窗留在原處",
+ "summary": "讓共用 App 維持一般堆疊順序,跨工作保留視窗的位置和大小。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ },
+ {
+ "kind": "launch",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Monitor"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 700
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "維持原狀"
+ },
+ {
+ "kind": "caption",
+ "text": "設為維持原狀的視窗不會參與自動並排。"
+ },
+ {
+ "kind": "dragWindow",
+ "app": "Monitor",
+ "target": "Canvas",
+ "x": 0.8,
+ "y": 0.22
+ },
+ {
+ "kind": "saveWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "設為共用 App 後,切換工作空間也會保留它的位置與大小。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 2"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Review"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Review",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 5"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertWindow",
+ "app": "Monitor"
+ },
+ {
+ "kind": "hold",
+ "modifiers": "alt",
+ "keys": [
+ "tab"
+ ],
+ "gapMs": 800,
+ "releaseAfterMs": 1200
+ },
+ {
+ "kind": "expectFront",
+ "app": "Monitor"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs",
+ "Monitor"
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/workspace-copy.json b/DemoLab/scenes/zh-Hant/workspace-copy.json
new file mode 100644
index 0000000..589a4a4
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/workspace-copy.json
@@ -0,0 +1,91 @@
+{
+ "name": "workspace-copy",
+ "title": "在工作空間之間複製設定",
+ "summary": "檢查另一個工作空間的 App 與設定,再套用選取的變更。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "prepareSettings"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Desk"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "text:Design"
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "複製工作空間設定"
+ },
+ {
+ "kind": "caption",
+ "text": "選擇要從中複製設定的工作空間。"
+ },
+ {
+ "kind": "beat",
+ "ms": 1400
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:從其他工作空間複製…"
+ },
+ {
+ "kind": "hover",
+ "app": "Tatami",
+ "identifier": "title:Laptop"
+ },
+ {
+ "kind": "beat",
+ "ms": 900
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "title:Design"
+ },
+ {
+ "kind": "caption",
+ "text": "複製至目前工作空間前,先檢查變更內容。"
+ },
+ {
+ "kind": "beat",
+ "ms": 2300
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "button:複製"
+ },
+ {
+ "kind": "expectAssignment",
+ "app": "Docs",
+ "workspace": "Design",
+ "profile": "Desk"
+ },
+ {
+ "kind": "caption",
+ "text": "選取的 App 與設定已複製至目前的工作空間。"
+ },
+ {
+ "kind": "click",
+ "app": "Tatami",
+ "identifier": "heading:佈局"
+ },
+ {
+ "kind": "beat",
+ "ms": 2400
+ }
+ ]
+}
diff --git a/DemoLab/scenes/zh-Hant/workspaces.json b/DemoLab/scenes/zh-Hant/workspaces.json
new file mode 100644
index 0000000..98bd85e
--- /dev/null
+++ b/DemoLab/scenes/zh-Hant/workspaces.json
@@ -0,0 +1,174 @@
+{
+ "name": "workspaces",
+ "title": "在工作之間切換",
+ "summary": "用快速鍵切換工作空間及其中的 App。",
+ "requires": {
+ "displays": 1
+ },
+ "setup": [
+ {
+ "kind": "clearOverlay"
+ },
+ {
+ "kind": "pointer",
+ "display": 0,
+ "x": 0.5,
+ "y": 0.5
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Write",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Editor",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateWorkspace",
+ "workspace": "Design",
+ "profile": "Laptop"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "activateApp",
+ "app": "Canvas"
+ },
+ {
+ "kind": "cli",
+ "args": [
+ "layout",
+ "balance"
+ ]
+ },
+ {
+ "kind": "beat",
+ "ms": 1800
+ }
+ ],
+ "steps": [
+ {
+ "kind": "chapter",
+ "text": "工作空間"
+ },
+ {
+ "kind": "caption",
+ "text": "用快速鍵切換工作空間及其中的 App。"
+ },
+ {
+ "kind": "click",
+ "app": "Canvas",
+ "identifier": "canvas.forest"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "saveLayout",
+ "text": "home"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - 3"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Chat"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Chat"
+ ]
+ },
+ {
+ "kind": "click",
+ "app": "Chat",
+ "identifier": "chat.message"
+ },
+ {
+ "kind": "typeText",
+ "app": "Chat",
+ "text": "我正在比較配色方向。",
+ "ms": 65
+ },
+ {
+ "kind": "key",
+ "chord": "return"
+ },
+ {
+ "kind": "expectStory",
+ "field": "lastMessage",
+ "value": "我正在比較配色方向。"
+ },
+ {
+ "kind": "caption",
+ "text": "用最近工作空間快速鍵,回到剛才的工作。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - \\"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "waitWindows",
+ "apps": [
+ "Canvas",
+ "Docs"
+ ]
+ },
+ {
+ "kind": "assertLayout",
+ "text": "home"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "caption",
+ "text": "也可以依序切換工作空間。"
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ."
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Focus"
+ },
+ {
+ "kind": "beat",
+ "ms": 1000
+ },
+ {
+ "kind": "key",
+ "chord": "ctrl + alt + shift - ,"
+ },
+ {
+ "kind": "waitWorkspace",
+ "workspace": "Design"
+ },
+ {
+ "kind": "beat",
+ "ms": 1500
+ }
+ ],
+ "openingApps": [
+ "Canvas",
+ "Docs"
+ ]
+}
diff --git a/DemoLab/scripts/audit-video.swift b/DemoLab/scripts/audit-video.swift
new file mode 100644
index 0000000..d922915
--- /dev/null
+++ b/DemoLab/scripts/audit-video.swift
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+// Offline visual text checks; no live capture or permission changes.
+import AVFoundation
+import Vision
+import Foundation
+
+struct Cue: Decodable { let track: String; let text: String; let start: Double; let end: Double }
+struct Timeline: Decodable { let events: [Cue] }
+func normalized(_ text: String) -> [Character] { Array(text.lowercased().filter { $0.isLetter || $0.isNumber }) }
+func similarity(_ expected: String, _ observed: String) -> Double {
+ let a = normalized(expected), b = normalized(observed)
+ guard !a.isEmpty else { return 1 }
+ var previous = [Int](repeating: 0, count: b.count + 1)
+ for character in a {
+ var row = [Int](repeating: 0, count: b.count + 1)
+ for (index, other) in b.enumerated() {
+ row[index + 1] = character == other ? previous[index] + 1 : max(previous[index + 1], row[index])
+ }
+ previous = row
+ }
+ return Double(previous.last ?? 0) / Double(a.count)
+}
+
+var arguments = Array(CommandLine.arguments.dropFirst())
+let openingOnly = arguments.contains("--opening-only")
+arguments.removeAll { $0 == "--opening-only" }
+let languages = ["en":"en-US", "ko":"ko-KR", "ja":"ja-JP", "zh-Hans":"zh-Hans", "zh-Hant":"zh-Hant"]
+var locale = "en"
+if let index = arguments.firstIndex(of: "--locale") {
+ guard arguments.indices.contains(index + 1), languages[arguments[index + 1]] != nil else { fatalError("unsupported OCR locale") }
+ locale = arguments[index + 1]; arguments.removeSubrange(index...index + 1)
+}
+guard !arguments.isEmpty else { fatalError("usage: audit-video --locale ...") }
+let files = arguments
+Task {
+ do {
+ var report: [[String: Any]] = []; var failed = false
+ for path in files {
+ let url = URL(fileURLWithPath: path)
+ let asset = AVURLAsset(url: url)
+ let duration = CMTimeGetSeconds(try await asset.load(.duration))
+ let timelineURL = url.deletingLastPathComponent().appendingPathComponent("evidence")
+ .appendingPathComponent(url.deletingPathExtension().lastPathComponent + ".edited.timeline.json")
+ let timeline = openingOnly ? Timeline(events: []) : try JSONDecoder().decode(Timeline.self, from: Data(contentsOf: timelineURL))
+ let captions = timeline.events.filter { $0.track == "caption" && !$0.text.isEmpty }
+ let generator = AVAssetImageGenerator(asset: asset)
+ generator.appliesPreferredTrackTransform = true
+ generator.requestedTimeToleranceBefore = .zero
+ generator.requestedTimeToleranceAfter = .zero
+ let midpoints = captions.filter { $0.end - $0.start >= 0.4 }.map { (($0.start + $0.end) / 2 * 30).rounded() / 30 }
+ let seconds = openingOnly ? [0.0] : Set(Array(stride(from: 0.0, to: duration, by: 1.0)) + midpoints).sorted()
+ var samples: [[String: Any]] = []; var checks: [[String: Any]] = []
+ for second in seconds where second < duration {
+ let (image, actual) = try await generator.image(at: CMTime(seconds: second, preferredTimescale: 600))
+ let at = CMTimeGetSeconds(actual)
+ let request = VNRecognizeTextRequest()
+ request.recognitionLevel = .accurate
+ request.recognitionLanguages = locale == "en" ? ["en-US"] : [languages[locale]!, "en-US"]
+ try VNImageRequestHandler(cgImage: image).perform([request])
+ let observations = request.results ?? []
+ let text = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n")
+ let matches = ["would like to record", "grant access to this application", "permissions needed", "see what's new in macos"]
+ .filter { text.lowercased().contains($0) }
+ if !matches.isEmpty { failed = true }
+ samples.append(["second": at, "matches": matches, "text": text])
+ if let cue = captions.last(where: { at >= $0.start + 0.15 && at <= $0.end - 0.15 }) {
+ let band = image.height < 1000 ? 0.30 : 0.21
+ let fragments = observations.filter { $0.boundingBox.maxY <= band && $0.boundingBox.minY >= 0.035 }
+ .sorted { $0.boundingBox.midY > $1.boundingBox.midY }
+ // Vision can split one caption into left/right fragments with
+ // slightly different baselines. Read each line left to right.
+ var lines: [[VNRecognizedTextObservation]] = []
+ for fragment in fragments {
+ if let previous = lines.last?.first, abs(previous.boundingBox.midY - fragment.boundingBox.midY) < 0.012 {
+ lines[lines.count - 1].append(fragment)
+ } else { lines.append([fragment]) }
+ }
+ let overlayText = lines.flatMap { $0.sorted { $0.boundingBox.minX < $1.boundingBox.minX } }
+ .compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ")
+ let score = similarity(cue.text, overlayText)
+ let passed = score >= 0.70
+ if !passed { failed = true }
+ checks.append(["second": at, "expected": cue.text, "observed": overlayText, "matchRatio": score, "passed": passed])
+ }
+ }
+ if !openingOnly && checks.isEmpty { failed = true }
+ report.append(["movie": path, "sampleIntervalSeconds": 1, "samples": samples, "captionChecks": checks])
+ }
+ let result: [String: Any] = ["locale": locale, "status": failed ? "failed" : "passed",
+ "scope": "OCR every second plus caption midpoints; checks permission phrases and readable overlaid narration; does not replace playback review", "movies": report]
+ FileHandle.standardOutput.write(try JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]))
+ exit(failed ? 1 : 0)
+ } catch {
+ FileHandle.standardError.write(Data("audit-video: \(error)\n".utf8)); exit(1)
+ }
+}
+dispatchMain()
diff --git a/DemoLab/zh-Hans/README.md b/DemoLab/zh-Hans/README.md
new file mode 100644
index 0000000..d30332f
--- /dev/null
+++ b/DemoLab/zh-Hans/README.md
@@ -0,0 +1,160 @@
+
+[English](../README.md) · [한국어](../ko/README.md) · [日本語](../ja/README.md) · [简体中文](README.md) · [繁體中文](../zh-Hant/README.md)
+
+
+
+# Tatami Demo Lab
+
+录制实际的 Tatami,导出一致的产品视频并放在对应功能说明旁。应用和内容为演示数据,工作区切换、平铺、借用和焦点由已安装的 Tatami 实际执行。
+
+
+## 发布约定
+
+[`publication.json`](../publication.json) 定义视频清单和编辑预算,映射网站区块并限制时长和文件大小。
+
+主视频依次展示设计、写作、评审、借用、自动化,最后实际改变显示器拓扑。专题涵盖工作区、配置方案与显示器、平铺与焦点、借用、窗口模式、CLI/钩子和引导设置。清单由 `publication.json` 生成,无需维护重复列表。
+
+主视频传达**切换任务,保留工作位置**。专题展示不同活动,不重复主视频。完整范围见[覆盖与证据边界](../docs/zh-Hans/COVERAGE.md)。
+
+
+## 录制 → 导出 → 检查 → 本地安装
+
+使用专用 Tart VM。`reset` 和 `seed` 会退出其运行机器上的 Tatami 并改变偏好。配置和布局隔离在 `.build/lab/`,偏好域单独备份,可通过 `democtl restore` 恢复。
+
+```sh
+# Host: run from the repository root.
+swift build --package-path Tools -c release
+TOOL=Tools/.build/release/tatami-tools
+"$TOOL" vm-sync
+
+# Guest: a fresh seed for every scene. Existing takes are never overwritten.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --output /Users/admin/DemoLab/recordings/publish
+
+# Host: fetch that exact batch, including originals, metadata and scene snapshots.
+GUEST_DIR=DemoLab/recordings/publish "$TOOL" vm-fetch-recordings DemoLab/recordings/publish
+
+# Host: new output directory; no ambiguous “latest take” selection.
+"$TOOL" export --takes DemoLab/recordings/publish --output ~/Downloads/TatamiDemoLab-review
+
+# Open index.html and inspect playback, opening frames, actions and every feature.
+# Install the verified bundle into this checkout only; this does not push or deploy.
+"$TOOL" install-assets ~/Downloads/TatamiDemoLab-review
+```
+
+主机需要 Swift 6.2 或更新版本、`tart`、支持 libass/libx264 的 `mpv`、`ffmpeg` 和 `ffprobe`。主机命令在仓库根目录运行。虚拟机需要 Xcode Command Line Tools。同步源代码时会一并传入编译好的自动化程序,因此虚拟机无需下载依赖包。录制器仍是独立的 SwiftPM 包,不加入 Tatami 的 Tuist 构建图。
+
+自动化使用 `swift-subprocess`、ArgumentParser、SwiftSoup、Hummingbird、`swift-markdown`、`swift-cmark` 和 Swift Crypto。JSON 和属性列表由 Foundation 处理。录制验收条件、翻译单元和视频时长及大小限制仍由 Tatami 自身的规则决定。`Tools/Package.resolved` 固定依赖版本。
+
+导出主机还需要 Fontconfig。编码前会验证指定字体及其对全部字幕字符的支持。OCR 审核会另行比较画面上的字幕和说明时间线。
+
+
+## 实用的演示应用
+
+|工作区|应用|实际操作|
+| --- | --- | --- |
+|Design|Canvas + Docs|更改配色,检查保存的草稿并导出 PNG。|
+|Write|Editor + Docs|阅读说明,编辑标题并保存草稿。|
+|Review|Review + Docs|检查保存的文案,留言并批准。|
+|Chat|Chat|输入并发送本地演示回复。|
+|Build|Terminal|执行实际 Tatami CLI 和附带自动化脚本。|
+|Notes(仅借用)|Notes|添加待办并完成检查项。|
+|Focus|Canvas + Notes|在工作区内使用置顶便笺。|
+|共享应用|Monitor|观察保存的项目状态或实际工作区、HUD 钩子事件。|
+
+八个应用使用持久化本地数据。保存会改变 Review、Canvas 读取的内容,PNG 导出实际写入图片,笔记和消息在切换后保留。Terminal 只执行明确允许的命令和附带脚本;钩子接收实际 Tatami 环境。不连接聊天服务,AI 演示使用明确标记的本地示例。
+
+
+## 录制约定的改进
+
+场景分为两个阶段:
+
+- `setup` 在**录制前**完成清理、启动、工作区预热、数据和布局准备。
+- `steps` 是可见演示。录制前 `openingApps` 必须有几何稳定的可见窗口;`autoopen` 是有意的例外,短暂空画面用于展示自动打开。
+
+录制前及每个操作前后检查系统权限和设置窗口,包括被其他窗口遮住的对话框。应解决请求,不能裁掉或压制错误。
+
+**录制器权限不等于 Tatami 权限。** 以前即使通过 `doctor`,仍残留 Tatami 屏幕录制请求。置顶使用独立 ScreenCaptureKit 流,单独预热录制器不能验证它。先运行 `shared` 并检查实际状态窗口。Monitor 不再每场景自动打开,而由 `shared` 在录制前明确启动。
+
+`waitWindows` 等待指定应用和稳定几何。`saveLayout`/`assertLayout` 在切换、归还和放大恢复后比较相同窗口 ID 与边界;缺少、增加窗口或超过 4 点的位移都会失败。
+
+`key`、`hold` 根据实际输入生成按键显示,不能用 `keys` 标签把 CLI 操作伪装成按键。没有首个编码帧就拒绝启动,并共享其单调时间戳以对齐字幕与影片时钟。
+
+
+## 画面呈现
+
+原始 MOV 包含完整的桌面画面。导出保留 1920×1200 分辨率,双屏视频为 1920×600。说明字幕叠加在画面下方,章节名称位于左上角,实际按键位于右上角。半透明背景确保文字在深浅色应用上都清晰可读。颜色来自网站配色,调整呈现方式无需重新录制。
+
+仅修改字幕时,只有录制的操作、输入内容、断言和等待时间均未改变,才可复用原始视频。导出工具会验证录制时冻结的场景哈希并比较操作,然后替换原时间线上的说明文字。证据资料同时保留原始和编辑后的场景及时间线。
+
+每次录制包含:
+
+- `.mov`:干净的原始录像。
+- `.ass`:可编辑说明和实际按键时间。
+- `.timeline.json`:所有事件及起止时间。
+- `.take.json`:成败、场景哈希、Tatami 版本、语言、逐输出帧数和丢帧数、捕获时基及叠加模式。
+- `.scene.json`:录制开始时冻结的准确场景字节。
+
+失败的录制会保留用于诊断,但不能导出。导出要求拍摄条件验证通过、字幕正常、丢帧率低于 1%、开场字幕及时出现,并且视频与时间线时长一致。输出必须符合时长和大小限制,使用 H.264/yuv420p,并通过 FFmpeg 完整解码。`faststart` 会将 MP4 文件头放在媒体数据之前。
+
+导出包包含播放图库、海报、抽样验证帧、原始与输出文件的哈希及所有附属文件。自动验收不能替代观看实际操作,尤其是共享窗口镜像和焦点目标仍需目视确认。网页播放器使用原生控件和 `preload="none"`,由用户主动播放,且一次只播放一个视频。每个视频集提供缩略图列表、数量和前后按钮。每个视频还链接到相关配置键。方向键、Home/End 和单独视频链接无需展开额外面板即可使用。
+
+双屏录制连接实际的虚拟显示器,分别捕获并按首帧时间对齐。见[已验证 VM 配置](../docs/zh-Hans/MULTI-DISPLAY.md)。
+
+
+## 调整单个场景
+
+在专用虚拟机内执行录制命令:
+
+```sh
+.build/DemoLab/bin/democtl doctor
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl scene tour --dry-run # prints setup and visible steps
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+# Host: after fetching that batch; run from the repository root.
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/iteration \
+ --output ~/Downloads/TatamiDemoLab-iteration --scenes tour
+```
+
+`democtl scene` 使用实时叠加排练。录制 `take` 默认 `--overlay off`,发布只接受该模式;排练面板不是输出设计。`subtitle burn` 可生成观看副本,预算检查、网页编码和证据应使用 `tatami-tools export`。
+
+结束后在虚拟机执行 `democtl quit` 和 `democtl restore`,主机原有偏好与 Tatami 不参与此流程。
+
+
+## 开发与参考
+
+```sh
+swift test --package-path Tools
+swift run --package-path Tools tatami-tools build-tool-notices --check
+swift run --package-path Tools tatami-tools bundle-apps
+DEMOLAB_LOCALIZATION_DIR="$PWD/DemoLab/.build/DemoLab/Localization" \
+ swift test --package-path DemoLab
+```
+
+- [场景语法](../docs/zh-Hans/SCENES.md)
+- [VM 设置](../docs/zh-Hans/VM-TART.md)
+- [权限](../docs/zh-Hans/PERMISSIONS.md)
+- [实际多显示器录制](../docs/zh-Hans/MULTI-DISPLAY.md)
+
+
+## 五语言制作
+
+支持 `en`、`ko`、`ja`、`zh-Hans`、`zh-Hant`。`Localization/Localizable.xcstrings` 管理演示 UI 和初始内容,`Localization/Films.json` 管理视频标题、说明和输入,`Localization/Interface.json` 管理检查图库。没有稳定标识符时使用产品目录的 Tatami AX 标签。应用、用户命名的工作区和配置方案名称保持不变。
+
+```sh
+Tools/.build/release/tatami-tools localize-scenes
+Tools/.build/release/tatami-tools vm-sync
+# Inside the guest:
+.build/tools/tatami-tools capture --locale ko --output recordings/ko-batch
+# After fetching that explicit batch to the host:
+Tools/.build/release/tatami-tools export --locale ko --takes DemoLab/recordings/ko-batch --output ~/Downloads/Tatami-ko
+```
+
+每次录制同步设置演示应用与 Tatami 的语言,输入和断言使用相同文案,实际 CLI 命令及机器输出不变。最终视频为 30 fps,因此录制也使用 30 fps,避免不必要的 60 fps 采样。
+
+`tatami-tools record-locales` 会找出各语言缺失或未通过验证的录制。它不会将英语视频视为已验证翻译 UI 的证据。失败场景会保留用于诊断,其他语言可以独立继续。
+
+小工具窗口从右下角开始,主视频以实际动作移到左下角。调整场景时保持文档主要阅读区域清晰。
+
+如需在终端命令结束后继续运行本地网站预览,请在仓库根目录运行 `Tools/.build/release/tatami-tools preview-site --background`。
diff --git a/DemoLab/zh-Hant/README.md b/DemoLab/zh-Hant/README.md
new file mode 100644
index 0000000..7a17f5e
--- /dev/null
+++ b/DemoLab/zh-Hant/README.md
@@ -0,0 +1,160 @@
+
+[English](../README.md) · [한국어](../ko/README.md) · [日本語](../ja/README.md) · [简体中文](../zh-Hans/README.md) · [繁體中文](README.md)
+
+
+
+# Tatami Demo Lab
+
+錄製實際的 Tatami,輸出一致的產品影片並放在對應功能說明旁。App 與內容為示範資料,工作空間切換、並排、借用與焦點由已安裝的 Tatami 實際執行。
+
+
+## 發布約定
+
+[`publication.json`](../publication.json) 定義影片清單與編輯預算,對應網站區塊並限制時間和檔案大小。
+
+主影片依序展示設計、撰寫、審閱、借用、自動化,最後實際改變顯示器拓樸。專題涵蓋工作空間、設定組合與顯示器、並排與焦點、借用、視窗模式、CLI/Hook 和引導設定。清單由 `publication.json` 產生,不必維護重複清單。
+
+主影片傳達**切換工作,保留工作位置**。專題展示不同活動,不重複主影片。完整範圍見[涵蓋與證據界線](../docs/zh-Hant/COVERAGE.md)。
+
+
+## 錄製 → 輸出 → 檢查 → 本機安裝
+
+使用專用 Tart VM。`reset` 與 `seed` 會結束其執行機器上的 Tatami 並變更偏好。設定與排列隔離在 `.build/lab/`,偏好網域另行備份,可透過 `democtl restore` 還原。
+
+```sh
+# Host: run from the repository root.
+swift build --package-path Tools -c release
+TOOL=Tools/.build/release/tatami-tools
+"$TOOL" vm-sync
+
+# Guest: a fresh seed for every scene. Existing takes are never overwritten.
+tart exec tatami-demo /Users/admin/DemoLab/.build/tools/tatami-tools capture \
+ --root /Users/admin/DemoLab --output /Users/admin/DemoLab/recordings/publish
+
+# Host: fetch that exact batch, including originals, metadata and scene snapshots.
+GUEST_DIR=DemoLab/recordings/publish "$TOOL" vm-fetch-recordings DemoLab/recordings/publish
+
+# Host: new output directory; no ambiguous “latest take” selection.
+"$TOOL" export --takes DemoLab/recordings/publish --output ~/Downloads/TatamiDemoLab-review
+
+# Open index.html and inspect playback, opening frames, actions and every feature.
+# Install the verified bundle into this checkout only; this does not push or deploy.
+"$TOOL" install-assets ~/Downloads/TatamiDemoLab-review
+```
+
+主機需要 Swift 6.2 或更新版本、`tart`、支援 libass/libx264 的 `mpv`、`ffmpeg` 和 `ffprobe`。主機指令請在儲存庫根目錄執行。虛擬機需要 Xcode Command Line Tools。同步原始碼時會一併傳入編譯好的自動化程式,因此虛擬機不需要下載套件。錄製工具仍是獨立的 SwiftPM 套件,不加入 Tatami 的 Tuist 建置圖。
+
+自動化使用 `swift-subprocess`、ArgumentParser、SwiftSoup、Hummingbird、`swift-markdown`、`swift-cmark` 和 Swift Crypto。JSON 與屬性列表由 Foundation 處理。錄製驗收條件、翻譯單元,以及影片長度與大小限制仍由 Tatami 的專屬規則決定。`Tools/Package.resolved` 固定相依套件版本。
+
+匯出主機也需要 Fontconfig。編碼前會驗證指定字型及其對全部字幕字元的支援。OCR 檢查會另外比對畫面上的字幕與說明時間軸。
+
+
+## 實用的示範 App
+
+|工作空間|App|實際操作|
+| --- | --- | --- |
+|Design|Canvas + Docs|變更配色、檢查儲存的草稿並輸出 PNG。|
+|Write|Editor + Docs|閱讀說明、編輯標題並儲存草稿。|
+|Review|Review + Docs|檢查儲存的文案、留言並核准。|
+|Chat|Chat|輸入並傳送本機示範回覆。|
+|Build|Terminal|執行實際 Tatami CLI 與附帶自動化指令稿。|
+|Notes(僅借用)|Notes|新增待辦並完成檢查項。|
+|Focus|Canvas + Notes|在工作空間內使用置頂便箋。|
+|共用 App|Monitor|觀察儲存的專案狀態或實際工作空間、HUD Hook 事件。|
+
+八個 App 使用持久化本機資料。儲存會改變 Review、Canvas 讀取的內容,PNG 輸出實際寫入圖片,筆記和訊息在切換後保留。Terminal 只執行明確允許的指令與附帶指令稿;Hook 接收實際 Tatami 環境。不連接聊天服務,AI 示範使用明確標記的本機範例。
+
+
+## 錄製約定的改進
+
+場景分為兩個階段:
+
+- `setup` 在**錄製前**完成清理、啟動、工作空間預備、資料與排列準備。
+- `steps` 是可見示範。錄製前 `openingApps` 必須有幾何穩定的可見視窗;`autoopen` 是刻意的例外,短暫空白畫面用來展示自動開啟。
+
+錄製前及每個操作前後檢查系統權限與設定視窗,包含被其他視窗遮住的對話框。應解決要求,不能裁掉或壓制錯誤。
+
+**錄製器權限不等於 Tatami 權限。** 以前即使通過 `doctor`,仍殘留 Tatami 螢幕錄製要求。置頂使用獨立 ScreenCaptureKit 串流,單獨預備錄製器不能驗證它。先執行 `shared` 並檢查實際狀態視窗。Monitor 不再每個場景自動開啟,而由 `shared` 在錄製前明確啟動。
+
+`waitWindows` 等待指定 App 和穩定幾何。`saveLayout`/`assertLayout` 在切換、歸還與放大還原後比較相同視窗 ID 與邊界;缺少、增加視窗或超過 4 點的位移都會失敗。
+
+`key`、`hold` 依實際輸入產生按鍵顯示,不能用 `keys` 標籤把 CLI 操作偽裝成按鍵。沒有首個編碼影格就拒絕啟動,並共用其單調時間戳記以對齊字幕與影片時鐘。
+
+
+## 畫面呈現
+
+原始 MOV 包含完整的桌面畫面。匯出保留 1920×1200 解析度,雙螢幕影片為 1920×600。說明字幕疊加在畫面下方,章節名稱位於左上角,實際按鍵位於右上角。半透明背景確保文字在深淺色 App 上都清楚可讀。顏色來自網站配色,調整呈現方式不必重新錄製。
+
+只修改字幕時,必須確保錄製的操作、輸入內容、驗證條件及等待時間皆未變更,才能重用原始影片。匯出工具會驗證錄製時固定的場景雜湊並比較操作,再替換原時間軸上的說明文字。驗證資料同時保留原始及編輯後的場景與時間軸。
+
+每次錄製包含:
+
+- `.mov`:乾淨的原始錄影。
+- `.ass`:可編輯說明與實際按鍵時間。
+- `.timeline.json`:所有事件及起訖時間。
+- `.take.json`:成敗、場景雜湊、Tatami 版本、語言、逐輸出影格數與遺失數、擷取時基及疊加模式。
+- `.scene.json`:錄製開始時凍結的確切場景位元組。
+
+失敗的錄製會保留供診斷使用,但不能匯出。匯出要求拍攝條件驗證通過、字幕正常、掉幀率低於 1%、開場字幕及時出現,且影片與時間軸長度一致。輸出必須符合長度及容量限制,使用 H.264/yuv420p,並通過 FFmpeg 完整解碼。`faststart` 會將 MP4 標頭放在媒體資料之前。
+
+匯出套件包含播放圖庫、海報、抽樣驗證畫格、原始與輸出檔案的雜湊,以及所有附屬檔案。自動驗收不能取代觀看實際操作,尤其是共用視窗鏡像及焦點目標仍須目視確認。網頁播放器使用原生控制項和 `preload="none"`,由使用者主動播放,且一次只播放一部影片。每個影片集提供縮圖清單、數量及前後按鈕。每部影片也連結至相關設定鍵。方向鍵、Home/End 及個別影片連結,不必展開額外面板即可使用。
+
+雙螢幕錄製連接實際的虛擬顯示器,分別擷取並依首影格時間對齊。見[已驗證 VM 設定](../docs/zh-Hant/MULTI-DISPLAY.md)。
+
+
+## 調整單一場景
+
+在專用虛擬機內執行錄製指令:
+
+```sh
+.build/DemoLab/bin/democtl doctor
+.build/DemoLab/bin/democtl reset
+.build/DemoLab/bin/democtl seed
+.build/DemoLab/bin/democtl scene tour --dry-run # prints setup and visible steps
+.build/DemoLab/bin/democtl take tour --output recordings/iteration/tour.mov
+# Host: after fetching that batch; run from the repository root.
+Tools/.build/release/tatami-tools export --takes DemoLab/recordings/iteration \
+ --output ~/Downloads/TatamiDemoLab-iteration --scenes tour
+```
+
+`democtl scene` 使用即時疊加排練。錄製 `take` 預設 `--overlay off`,發布只接受該模式;排練面板不是輸出設計。`subtitle burn` 可產生觀看副本,預算檢查、網頁編碼與證據應使用 `tatami-tools export`。
+
+結束後在虛擬機執行 `democtl quit` 與 `democtl restore`,主機原有偏好與 Tatami 不參與此流程。
+
+
+## 開發與參考
+
+```sh
+swift test --package-path Tools
+swift run --package-path Tools tatami-tools build-tool-notices --check
+swift run --package-path Tools tatami-tools bundle-apps
+DEMOLAB_LOCALIZATION_DIR="$PWD/DemoLab/.build/DemoLab/Localization" \
+ swift test --package-path DemoLab
+```
+
+- [場景語法](../docs/zh-Hant/SCENES.md)
+- [VM 設定](../docs/zh-Hant/VM-TART.md)
+- [權限](../docs/zh-Hant/PERMISSIONS.md)
+- [實際多顯示器錄製](../docs/zh-Hant/MULTI-DISPLAY.md)
+
+
+## 五語言製作
+
+支援 `en`、`ko`、`ja`、`zh-Hans`、`zh-Hant`。`Localization/Localizable.xcstrings` 管理示範 UI 與初始內容,`Localization/Films.json` 管理影片標題、說明和輸入,`Localization/Interface.json` 管理檢查圖庫。沒有穩定識別碼時使用產品目錄的 Tatami AX 標籤。App、使用者命名的工作空間與設定組合名稱保持不變。
+
+```sh
+Tools/.build/release/tatami-tools localize-scenes
+Tools/.build/release/tatami-tools vm-sync
+# Inside the guest:
+.build/tools/tatami-tools capture --locale ko --output recordings/ko-batch
+# After fetching that explicit batch to the host:
+Tools/.build/release/tatami-tools export --locale ko --takes DemoLab/recordings/ko-batch --output ~/Downloads/Tatami-ko
+```
+
+每次錄製同步設定示範 App 與 Tatami 的語言,輸入和斷言使用相同文案,實際 CLI 指令及機器輸出不變。最終影片為 30 fps,因此錄製也使用 30 fps,避免不必要的 60 fps 取樣。
+
+`tatami-tools record-locales` 會找出各語言缺少或未通過驗證的錄製。它不會將英文影片視為已驗證翻譯 UI 的證據。失敗場景會保留供診斷使用,其他語言可以獨立繼續。
+
+小工具視窗從右下角開始,主影片以實際動作移到左下角。調整場景時保持文件主要閱讀區域清楚。
+
+若要在終端機指令結束後繼續執行本機網站預覽,請在儲存庫根目錄執行 `Tools/.build/release/tatami-tools preview-site --background`。
diff --git a/Localization/Docs.json b/Localization/Docs.json
new file mode 100644
index 0000000..90222d5
--- /dev/null
+++ b/Localization/Docs.json
@@ -0,0 +1,5156 @@
+{
+ "da4a07219c072685": {
+ "en": "[]({1}) []({3})  []({6})",
+ "ko": "[]({1}) []({3})  []({6})",
+ "ja": "[]({1}) []({3})  []({6})",
+ "zh-Hans": "[]({1}) []({3})  []({6})",
+ "zh-Hant": "[]({1}) []({3})  []({6})"
+ },
+ "20f9f3c06995e6ee": {
+ "en": "A macOS workspace manager with BSP window tiling.",
+ "ko": "BSP 창 타일링을 갖춘 macOS 작업 공간 관리자예요.",
+ "ja": "BSP タイル表示を備えた macOS 用ワークスペースマネージャです。",
+ "zh-Hans": "支持 BSP 窗口平铺的 macOS 工作区管理器。",
+ "zh-Hant": "支援 BSP 視窗並排的 macOS 工作空間管理工具。"
+ },
+ "d1262e479ef662e2": {
+ "en": "Tatami groups your apps into virtual workspaces you switch between with a keystroke or a configurable trackpad gesture, and tiles their windows automatically with a binary space partitioning (BSP) engine. No SIP changes and no shell scripting required.",
+ "ko": "Tatami는 앱을 가상 작업 공간에 모으고, 단축키나 설정한 트랙패드 제스처로 전환해요. BSP 엔진이 창을 자동으로 정리해줘요. SIP 설정을 바꾸거나 셸 스크립트를 작성할 필요가 없어요.",
+ "ja": "Tatami はアプリを仮想ワークスペースにまとめ、キーや設定したトラックパッドのジェスチャで切り替えます。BSP エンジンがウインドウを自動で整えるので、SIP の変更やシェルスクリプトは不要です。",
+ "zh-Hans": "Tatami 把应用组织到虚拟工作区,通过快捷键或自定义触控板手势切换,并用二叉空间分割(BSP)引擎自动平铺窗口。无需修改 SIP,也无需编写 shell 脚本。",
+ "zh-Hant": "Tatami 把 App 整理到虛擬工作空間,透過快速鍵或自訂觸控式軌跡板手勢切換,並用二元空間分割(BSP)引擎自動並排視窗。不必修改 SIP,也不必撰寫 shell 指令稿。"
+ },
+ "077f6c99a5532ad7": {
+ "en": "See Tatami in action",
+ "ko": "Tatami 사용 모습",
+ "ja": "Tatami の使い方を見る",
+ "zh-Hans": "看看 Tatami 的实际操作",
+ "zh-Hant": "看看 Tatami 的實際操作"
+ },
+ "fcb631e89f7f1399": {
+ "en": "[]({1})",
+ "ko": "[]({1})",
+ "ja": "[]({1})",
+ "zh-Hans": "[]({1})",
+ "zh-Hant": "[]({1})"
+ },
+ "f3500adbf7d3a1b8": {
+ "en": "**[Watch the complete workflow]({0})** — design, write, review, borrow a follow-up, automate a focus session, and adapt to a second display. Real Tatami window management; demo apps and sample content. The displayed shortcuts are this demo's configured bindings.",
+ "ko": "**[전체 작업 흐름 보기]({0})**. 디자인, 글쓰기, 검토, 다음 할 일 빌려오기, 집중 환경 자동화, 두 번째 화면 연결까지 이어져요. 실제 Tatami로 창을 관리하며 앱과 내용은 데모용이에요. 표시한 단축키는 이 데모에서 설정한 키예요.",
+ "ja": "**[一連の作業を見る]({0})**。デザイン、執筆、レビュー、次の作業を借りる操作、集中環境の自動化、2 台目の画面への対応まで紹介します。実際の Tatami を使い、アプリと内容はデモ用です。表示するキーはこのデモの設定です。",
+ "zh-Hans": "**[观看完整工作流程]({0})**:设计、写作、评审、借用待办事项、自动准备专注环境,再连接第二个显示器。窗口由实际的 Tatami 管理,应用和内容为演示素材。快捷键采用本次演示的配置。",
+ "zh-Hant": "**[觀看完整工作流程]({0})**:設計、撰寫、審閱、借用待辦事項、自動準備專注環境,再連接第二部顯示器。視窗由實際的 Tatami 管理,App 和內容為示範素材。快速鍵採用本次示範的設定。"
+ },
+ "5088d504cc27e8ca": {
+ "en": "Settings and Guided Setup screenshots",
+ "ko": "설정과 시작 안내 스크린샷",
+ "ja": "設定とガイドのスクリーンショット",
+ "zh-Hans": "设置与设置向导截图",
+ "zh-Hant": "設定與設定導覽截圖"
+ },
+ "afe7a957c0b98931": {
+ "en": "Tatami Guided Setup, profile and workspace settings, and a scratchpad borrowed beside a tiled workspace.",
+ "ko": "Tatami 시작 안내, 프로필과 작업 공간 설정, 타일링한 작업 공간 옆에 빌려온 임시 공간이에요.",
+ "ja": "Tatami のガイド、プロファイルとワークスペースの設定、タイル表示の隣に借りた一時スペースです。",
+ "zh-Hans": "Tatami 设置向导、配置方案与工作区设置,以及平铺工作区旁借来的暂存区。",
+ "zh-Hant": "Tatami 設定導覽、設定組合與工作空間設定,以及並排工作空間旁借來的暫存區。"
+ },
+ "b50c780ec8f2aa52": {
+ "en": "Guided Setup",
+ "ko": "가이드 설정",
+ "ja": "ガイド設定",
+ "zh-Hans": "引导设置",
+ "zh-Hant": "引導設定"
+ },
+ "1377264ba3b0a717": {
+ "en": "Workspaces",
+ "ko": "작업 공간",
+ "ja": "ワークスペース",
+ "zh-Hans": "工作区",
+ "zh-Hant": "工作空間"
+ },
+ "2edfb168b38b6272": {
+ "en": "Borrow",
+ "ko": "빌려오기",
+ "ja": "借りる",
+ "zh-Hans": "借用",
+ "zh-Hant": "借用"
+ },
+ "b3e036eef60f293f": {
+ "en": "Full size: {0} · {1} · {2}{3} Captured on an earlier version. Some labels have changed since these screenshots.",
+ "ko": "원본 크기: {0} · {1} · {2}{3} 이전 버전에서 촬영했어요. 일부 문구는 현재 버전과 달라요.",
+ "ja": "元のサイズ:{0} · {1} · {2}{3} 以前のバージョンで撮影したため、一部の表記は現在と異なります。",
+ "zh-Hans": "原图:{0} · {1} · {2}{3} 截图来自较早版本,部分标签已更新。",
+ "zh-Hant": "原圖:{0} · {1} · {2}{3} 截圖來自較早版本,部分標籤已更新。"
+ },
+ "5697d03daef4de9c": {
+ "en": "Features",
+ "ko": "주요 기능",
+ "ja": "主な機能",
+ "zh-Hans": "主要功能",
+ "zh-Hant": "主要功能"
+ },
+ "272f97cb2b06613c": {
+ "en": "[]({1})",
+ "ko": "[]({1})",
+ "ja": "[]({1})",
+ "zh-Hans": "[]({1})",
+ "zh-Hant": "[]({1})"
+ },
+ "f06c5df8b334f0ad": {
+ "en": "**Virtual workspaces:** Group apps with per-workspace assignments.",
+ "ko": "**가상 작업 공간:** 작업 공간마다 필요한 앱을 배정해서 모아요.",
+ "ja": "**仮想ワークスペース:** 作業ごとに必要なアプリを割り当ててまとめます。",
+ "zh-Hans": "**虚拟工作区:** 按工作区分配和组织应用。",
+ "zh-Hant": "**虛擬工作空間:** 依工作空間指派和整理 App。"
+ },
+ "327babee8a2b505b": {
+ "en": "**Flexible switching:** Use a hotkey, trackpad gesture, or recent-workspace action.",
+ "ko": "**편한 방식으로 전환:** 단축키, 트랙패드 제스처, 최근 작업 공간 이동을 사용할 수 있어요.",
+ "ja": "**使いやすく切り替え:** ショートカット、トラックパッドのジェスチャ、直前のワークスペースへの移動を使えます。",
+ "zh-Hans": "**灵活切换:** 使用快捷键、触控板手势或最近工作区操作。",
+ "zh-Hant": "**彈性切換:** 使用快速鍵、觸控式軌跡板手勢或最近工作空間操作。"
+ },
+ "c71ae6693e59f756": {
+ "en": "**Configurable gestures:** Bind every direction of a three- or four-finger swipe to any shortcut action, including profile- and workspace-specific commands.",
+ "ko": "**제스처 설정:** 세 손가락·네 손가락 쓸기의 모든 방향에 동작을 연결해요. 특정 프로필이나 작업 공간으로 가는 명령도 연결할 수 있어요.",
+ "ja": "**ジェスチャ設定:** 3 本指・4 本指の各方向に操作を割り当てられます。特定のプロファイルやワークスペースへの移動も選べます。",
+ "zh-Hans": "**自定义手势:** 为三指、四指滑动的各个方向绑定快捷键操作,也可以指定某个配置方案或工作区。",
+ "zh-Hant": "**自訂手勢:** 為三指、四指滑動的各個方向綁定快速鍵操作,也可以指定某個設定組合或工作空間。"
+ },
+ "fc1fce474207d8d1": {
+ "en": "**One key per workspace:** Hold the switch, assign, or borrow modifier with a **key equivalent**. The same keys also drive recent, next, and previous targets, while any action can take an explicit override.",
+ "ko": "**작업 공간마다 키 하나:** 전환·배정·빌려오기 보조키와 **작업 공간 키**를 함께 눌러요. 같은 키 체계로 최근·다음·이전 작업 공간에도 이동할 수 있고, 각 동작에 별도 단축키를 지정할 수도 있어요.",
+ "ja": "**ワークスペースごとに一つのキー:** 切り替え・割り当て・借りる操作の修飾キーと**ワークスペースキー**を組み合わせます。直前・次・前への移動にも使え、操作別の上書きもできます。",
+ "zh-Hans": "**每个工作区一个按键:** 将切换、分配或借用修饰键与**工作区按键**组合使用。同一套按键也用于最近、下一个和上一个目标,每项操作还可以单独覆盖。",
+ "zh-Hant": "**每個工作空間一個按鍵:** 將切換、指派或借用輔助鍵與**工作空間按鍵**組合使用。同一套按鍵也用於最近、下一個和上一個目標,每項操作還可以個別覆寫。"
+ },
+ "5b1bc4413e6dc9ad": {
+ "en": "**Optional switching behaviors:** Enable loop-around, skip-empty, or follow-app-focus.",
+ "ko": "**전환 방식 선택:** 끝에서 처음으로 돌아가기, 빈 공간 건너뛰기, 앱 포커스 따라가기를 설정할 수 있어요.",
+ "ja": "**切り替え方を選択:** 端での循環、空の作業をスキップ、アプリのフォーカスへの追従を設定できます。",
+ "zh-Hans": "**可选切换行为:** 启用循环、跳过空工作区或跟随应用焦点。",
+ "zh-Hant": "**可選切換行為:** 啟用循環、略過空工作空間或跟隨 App 焦點。"
+ },
+ "804ab2a6ffa55907": {
+ "en": "**[Auto-open demo]({0}):** Launch assigned apps when a workspace activates and reopen them on re-entry if their window was closed.",
+ "ko": "**[자동 열기 데모]({0}):** 작업 공간에 들어가면 배정한 앱이 열려요. 창을 닫아도 다시 들어가면 열어줘요.",
+ "ja": "**[自動で開くデモ]({0}):** 作業を開くと割り当てたアプリが起動し、閉じたウインドウも入り直すと開きます。",
+ "zh-Hans": "**[自动打开演示]({0}):** 激活工作区时启动分配的应用,重新进入时打开之前关闭的窗口。",
+ "zh-Hant": "**[自動開啟示範]({0}):** 啟用工作空間時啟動指派的 App,重新進入時開啟先前關閉的視窗。"
+ },
+ "a2a4c7eef067a833": {
+ "en": "**Per-display workspaces:** Pin a workspace to a display or have dynamic workspaces open under the mouse. Each display keeps its own active and recent workspace across relaunches, while next/previous and recent navigation can independently stay local or span every display.",
+ "ko": "**화면별 작업 공간:** 특정 화면에 고정하거나 마우스가 있는 화면에 열 수 있어요. 각 화면은 재실행 후에도 활성·최근 작업 공간을 기억해요. 다음·이전·최근 이동의 범위는 각각 현재 화면이나 전체 화면으로 정할 수 있어요.",
+ "ja": "**画面ごとの作業:** 特定の画面に固定するか、ポインタのある画面に開けます。再起動後も各画面の現在・直前の作業を記憶し、次・前・直前への移動範囲を個別に設定できます。",
+ "zh-Hans": "**每个显示器独立管理:** 固定工作区到显示器,或在指针所在屏幕打开动态工作区。各显示器在重启后仍保留当前和最近工作区;前后切换与最近切换可分别限定为当前屏幕或所有屏幕。",
+ "zh-Hant": "**每部顯示器獨立管理:** 固定工作空間到顯示器,或在游標所在螢幕開啟動態工作空間。各顯示器在重新啟動後仍保留目前與最近工作空間;前後切換與最近切換可分別限定為目前螢幕或所有螢幕。"
+ },
+ "1b16541f0161f4f9": {
+ "en": "**Workspace chains:** Link an ordered set of workspaces inside a profile. Switching to any member restores available companions in chain priority, honoring connected display pins and Chain Dynamic placement while focus stays with the workspace you chose.",
+ "ko": "**작업 공간 체인:** 프로필 안의 작업 공간을 순서대로 연결해요. 어느 구성원을 선택해도 체인 우선순위, 연결된 화면 고정, 동적 배치에 따라 함께 놓여요. 포커스는 선택한 작업에 남아요.",
+ "ja": "**ワークスペースチェーン:** プロファイル内の作業を順に連結します。どのメンバーを選んでも、優先順・接続中の画面固定・動的配置に従って仲間を復元し、選んだ作業にフォーカスを残します。",
+ "zh-Hans": "**工作区链:** 在配置方案内按顺序连接工作区。切换到任一成员时,按链优先级恢复可用伙伴,遵守已连接显示器的固定位置和链动态分配,焦点仍留在所选工作区。",
+ "zh-Hant": "**工作空間鏈:** 在設定組合內依序連接工作空間。切換到任一成員時,依鏈優先順序還原可用夥伴,遵守已連接顯示器的固定位置與鏈動態分配,焦點仍留在所選工作空間。"
+ },
+ "7f512a6c8a5d5ec1": {
+ "en": "**Cross-display control:** Jump focus between displays or move the focused app to another workspace.",
+ "ko": "**화면 간 조작:** 다른 화면으로 포커스를 옮기거나 포커스한 앱을 다른 작업 공간에 보낼 수 있어요.",
+ "ja": "**画面をまたぐ操作:** 別の画面へフォーカスを移したり、選んだアプリを別の作業へ送ったりできます。",
+ "zh-Hans": "**跨显示器控制:** 在显示器间移动焦点,或把聚焦的应用移到其他工作区。",
+ "zh-Hant": "**跨顯示器控制:** 在顯示器間移動焦點,或把聚焦的 App 移到其他工作空間。"
+ },
+ "717819c7aa6b4acd": {
+ "en": "**Shared apps:** Add apps that should join every workspace.",
+ "ko": "**공용 앱:** 모든 작업 공간에서 함께 쓸 앱을 추가해요.",
+ "ja": "**共有アプリ:** すべてのワークスペースで使うアプリを追加できます。",
+ "zh-Hans": "**共享应用:** 添加应出现在每个工作区的应用。",
+ "zh-Hant": "**共用 App:** 新增應出現在每個工作空間的 App。"
+ },
+ "9b232b9a94c63c81": {
+ "en": "Window tiling (BSP)",
+ "ko": "창 타일링(BSP)",
+ "ja": "ウインドウのタイル表示(BSP)",
+ "zh-Hans": "窗口平铺(BSP)",
+ "zh-Hant": "視窗並排(BSP)"
+ },
+ "790aa2182df007c9": {
+ "en": "[]({1})",
+ "ko": "[]({1})",
+ "ja": "[]({1})",
+ "zh-Hans": "[]({1})",
+ "zh-Hant": "[]({1})"
+ },
+ "25c0497cea23b369": {
+ "en": "**Interactive window switching:** Tap the window-switch shortcut for an immediate switch, or hold its modifier for a compact app / window switcher. It stays scoped to the display under the pointer, can include Shared Apps, shows each window's current state, and lets you choose the exact target with the keyboard or pointer.",
+ "ko": "**창 선택:** 전환 단축키를 짧게 누르면 바로 전환하고, 보조키를 길게 누르면 앱·창 목록이 나타나요. 포인터가 있는 화면 안에서 동작하고 공용 앱도 포함할 수 있어요. 각 창의 상태를 보며 키보드나 마우스로 정확히 고를 수 있어요.",
+ "ja": "**ウインドウを選択:** 短く押すとすぐ切り替わり、修飾キーを長く押すと一覧が開きます。ポインタの画面内で動作し、共有アプリも含められます。状態を見ながらキーやマウスで選べます。",
+ "zh-Hans": "**交互式窗口切换:** 轻按立即切换,按住修饰键显示紧凑的应用与窗口列表。范围限定为指针所在显示器,可包含共享应用;可查看状态并用键盘或指针选择准确目标。",
+ "zh-Hant": "**互動式視窗切換:** 輕按立即切換,按住輔助鍵顯示精簡的 App 與視窗清單。範圍限定為游標所在顯示器,可包含共用 App;可查看狀態並用鍵盤或游標選擇確切目標。"
+ },
+ "70a76958fca68b7b": {
+ "en": "**Zoom and splits:** Fill the workspace with one window or toggle split orientation.",
+ "ko": "**확대와 분할:** 창 하나를 작업 공간 크기로 펼치거나 나누는 방향을 바꿔요.",
+ "ja": "**ズームと分割:** 一つのウインドウを作業全体に広げたり、分割方向を切り替えたりできます。",
+ "zh-Hans": "**放大与分割:** 让一个窗口铺满工作区,或切换分割方向。",
+ "zh-Hant": "**放大與分割:** 讓一個視窗鋪滿工作空間,或切換分割方向。"
+ },
+ "33edd0f9856737a5": {
+ "en": "**Tree transforms:** Rotate, mirror, and balance the layout.",
+ "ko": "**배치 변환:** 회전·뒤집기·균등 배치를 사용할 수 있어요.",
+ "ja": "**配置の変換:** 回転、反転、均等配置が使えます。",
+ "zh-Hans": "**布局树变换:** 旋转、镜像和均衡布局。",
+ "zh-Hant": "**排列樹變換:** 旋轉、鏡像與平均排列。"
+ },
+ "c9ce5ad78cd364b8": {
+ "en": "**Drag editing:** Swap or re-insert a window with a live placement preview. Manual edge resizing synchronizes back into the tree.",
+ "ko": "**드래그 편집:** 미리보기를 보며 창을 교체하거나 다른 자리에 넣어요. 가장자리를 직접 조절한 비율도 배치에 반영돼요.",
+ "ja": "**ドラッグ編集:** プレビューを見ながら入れ替えや挿入ができます。端を手で調整した比率も配置に反映されます。",
+ "zh-Hans": "**拖移编辑:** 通过实时预览交换或重新插入窗口,手动调整边缘也会同步到布局树。",
+ "zh-Hant": "**拖移編輯:** 透過即時預覽交換或重新插入視窗,手動調整邊緣也會同步到排列樹。"
+ },
+ "66c55b0992b29b1b": {
+ "en": "**Persistent layouts:** Keep each workspace's tree and ratios across workspace switches, relaunches, and system sleep.",
+ "ko": "**배치 기억:** 작업 공간 전환, 앱 재실행, 시스템 잠자기 후에도 배치 트리와 비율이 남아요.",
+ "ja": "**配置を記憶:** 作業の切り替え、再起動、スリープ後も、ツリーと比率を保ちます。",
+ "zh-Hans": "**持久布局:** 切换工作区、重新启动和系统睡眠后,保留布局树及比例。",
+ "zh-Hant": "**保留排列:** 切換工作空間、重新啟動與系統睡眠後,保留排列樹及比例。"
+ },
+ "7ac86c4227bb505d": {
+ "en": "**Configurable spacing:** Set inner and outer gaps.",
+ "ko": "**여백 설정:** 창 사이와 바깥 여백을 정할 수 있어요.",
+ "ja": "**余白の設定:** 内側と外側の余白を選べます。",
+ "zh-Hans": "**可调间距:** 设置内部和外部间距。",
+ "zh-Hant": "**可調間距:** 設定內部與外部間距。"
+ },
+ "535e52e4a2616ebe": {
+ "en": "Profiles",
+ "ko": "프로필",
+ "ja": "プロファイル",
+ "zh-Hans": "配置方案",
+ "zh-Hant": "設定組合"
+ },
+ "049ce189ea067ef4": {
+ "en": "[Watch the profile and two-display collection]({0}).",
+ "ko": "[프로필과 두 화면 영상 모음 보기]({0}).",
+ "ja": "[プロファイルと 2 画面の動画を見る]({0})。",
+ "zh-Hans": "[观看配置方案与双屏演示]({0})。",
+ "zh-Hant": "[觀看設定組合與雙螢幕示範]({0})。"
+ },
+ "046fb607b89e0122": {
+ "en": "**Independent profiles:** Group workspaces and switch the whole set at once. Each profile keeps its own workspaces, app assignments, and shortcuts.",
+ "ko": "**독립 프로필:** 작업 공간을 묶어 전체 구성을 한 번에 바꿔요. 프로필마다 작업 공간·앱 배정·단축키를 따로 저장해요.",
+ "ja": "**独立したプロファイル:** 作業をまとめ、構成全体を切り替えます。作業、アプリの割り当て、ショートカットを個別に持ちます。",
+ "zh-Hans": "**独立配置方案:** 分组管理工作区,一次切换整套环境。每个方案分别保存工作区、应用分配和快捷键。",
+ "zh-Hant": "**獨立設定組合:** 分組管理工作空間,一次切換整套環境。每個組合分別儲存工作空間、App 指派與快速鍵。"
+ },
+ "ab4df6b849bc8748": {
+ "en": "**Fast profile switching:** Switch by hotkey or from the menu bar. Every display re-tiles for the new profile, and Tatami returns to the right profile after relaunch.",
+ "ko": "**빠른 프로필 전환:** 단축키나 메뉴 막대에서 전환해요. 모든 화면을 새 구성으로 정리하고 재실행 후에도 알맞은 프로필로 돌아와요.",
+ "ja": "**すばやい切り替え:** キーやメニューバーで切り替えます。全画面を新しい構成に整え、再起動後も適切なプロファイルに戻ります。",
+ "zh-Hans": "**快速切换方案:** 通过快捷键或菜单栏切换,所有显示器重新平铺,重启后返回合适的配置方案。",
+ "zh-Hant": "**快速切換組合:** 透過快速鍵或選單列切換,所有顯示器重新並排,重新啟動後返回合適的設定組合。"
+ },
+ "b59949614f017dd8": {
+ "en": "**Display-aware activation:** Auto-activate a profile by monitor count or by specific displays being connected or disconnected. Tatami warns when rules overlap at the same priority.",
+ "ko": "**화면에 맞춰 활성화:** 화면 수나 특정 화면의 연결·해제에 따라 자동 전환해요. 같은 우선순위의 규칙이 겹치면 알려줘요.",
+ "ja": "**画面に合わせて有効化:** 画面数や特定の画面の接続・取り外しで切り替えます。同じ優先度で条件が重なると警告します。",
+ "zh-Hans": "**按显示器激活:** 根据数量或指定显示器的连接、断开状态自动切换。同优先级规则重叠时会警告。",
+ "zh-Hant": "**依顯示器啟用:** 根據數量或指定顯示器的連接、中斷狀態自動切換。同優先順序規則重疊時會警告。"
+ },
+ "0ab8aa5fd7c5423b": {
+ "en": "**Profile identity:** Give each profile an SF Symbol shown in the sidebar, menu bar, and profile-switch feedback.",
+ "ko": "**프로필 아이콘:** 사이드바·메뉴 막대·전환 알림에 표시할 SF Symbol을 정해요.",
+ "ja": "**プロファイルのアイコン:** サイドバー、メニューバー、切り替え時に表示する SF Symbol を選べます。",
+ "zh-Hans": "**方案图标:** 选择显示在侧边栏、菜单栏和切换反馈中的 SF Symbol。",
+ "zh-Hant": "**組合圖像:** 選擇顯示在側邊欄、選單列與切換回饋中的 SF Symbol。"
+ },
+ "3685a20ca1899d7e": {
+ "en": "**Reuse an existing setup:** **Copy from** and **Duplicate** share one preview where you keep or skip each workspace, app, setting, and saved layout before anything changes.",
+ "ko": "**기존 구성 재사용:** **가져오기**와 **복제**에서 같은 미리보기를 사용해요. 변경 전에 작업 공간·앱·설정·저장한 배치를 골라 포함하거나 제외할 수 있어요.",
+ "ja": "**構成を再利用:** **コピー元**と**複製**は共通のプレビューで確認できます。変更前に作業、アプリ、設定、保存済み配置を含めるか選べます。",
+ "zh-Hans": "**复用现有配置:** **复制自**和**复制**共用预览,在更改前选择保留或跳过工作区、应用、设置和保存的布局。",
+ "zh-Hant": "**重複使用設定:** **拷貝自**與**複製**共用預覽,在變更前選擇保留或略過工作空間、App、設定與儲存的排列。"
+ },
+ "cffe767d9c2cf82e": {
+ "en": "Borrow: compose two workspaces",
+ "ko": "빌려오기: 두 작업 공간 함께 쓰기",
+ "ja": "借りる:二つの作業を組み合わせる",
+ "zh-Hans": "借用:组合两个工作区",
+ "zh-Hant": "借用:組合兩個工作空間"
+ },
+ "1c8ba14ba65d9ee2": {
+ "en": "[]({1})",
+ "ko": "[]({1})",
+ "ja": "[]({1})",
+ "zh-Hans": "[]({1})",
+ "zh-Hant": "[]({1})"
+ },
+ "81200624043ee970": {
+ "en": "**Side-by-side composition:** Pull another workspace beside the current one, dock it to any screen edge, and tile both blocks independently. Windows cannot cross the boundary.",
+ "ko": "**나란히 사용:** 다른 작업 공간을 화면 가장자리로 빌려와요. 두 영역은 따로 타일링되며 창은 경계를 넘지 않아요.",
+ "ja": "**並べて使う:** 別の作業を画面の端に借りて、両方を独立して配置します。ウインドウは境界を越えません。",
+ "zh-Hans": "**并排组合:** 把另一个工作区借到屏幕任一边缘,两个区域独立平铺,窗口不会越界。",
+ "zh-Hant": "**並排組合:** 把另一個工作空間借到螢幕任一邊緣,兩個區域獨立並排,視窗不會越界。"
+ },
+ "58a3645f4d099a20": {
+ "en": "**Live and bidirectional:** The borrowed block is the real workspace, so edits persist back to it.",
+ "ko": "**실제 작업 그대로:** 빌려온 영역은 실제 작업 공간이에요. 편집한 내용은 원래 공간에도 남아요.",
+ "ja": "**実際の作業をそのまま:** 借りた領域は実際の作業なので、編集内容も元の場所に残ります。",
+ "zh-Hans": "**实时双向:** 借来的区域就是实际工作区,编辑会保留下来。",
+ "zh-Hant": "**即時雙向:** 借來的區域就是實際工作空間,編輯會保留下來。"
+ },
+ "882547689e10c225": {
+ "en": "**Directional placement:** Press the borrow modifier with a workspace key, then use {0}, {1}, {2}, {3}, or an arrow. You can also set a default edge and size globally or per workspace.",
+ "ko": "**방향 선택:** 빌려오기 보조키와 작업 공간 키를 누른 뒤 {0}, {1}, {2}, {3} 또는 화살표를 눌러요. 전체 또는 작업 공간별 기본 위치와 크기도 설정할 수 있어요.",
+ "ja": "**方向を選択:** 借りる修飾キーと作業のキーを押してから、{0}、{1}、{2}、{3} または矢印を使います。全体・作業別の既定位置と大きさも設定できます。",
+ "zh-Hans": "**方向放置:** 按借用修饰键和工作区按键,再用 {0}、{1}、{2}、{3} 或方向键。也可设置全局或每工作区的默认方向和大小。",
+ "zh-Hant": "**方向放置:** 按借用輔助鍵與工作空間按鍵,再用 {0}、{1}、{2}、{3} 或方向鍵。也可設定全域或每個工作空間的預設方向與大小。"
+ },
+ "8b49382215056059": {
+ "en": "**Cross-boundary focus and switching:** Directional focus and MFF move between blocks. Host and borrowed tiled windows share one app / window switching order until the borrowed workspace is returned. Activating a borrowed workspace switches to it fully, borrowing it again returns it by default, and {0} cancels placement.",
+ "ko": "**경계를 넘는 포커스와 전환:** 방향 포커스와 포인터 따라가기가 두 영역을 오가요. 돌려보내기 전까지 두 영역의 타일링 창을 함께 전환해요. 빌려온 공간을 활성화하면 완전히 전환하고 다시 빌리면 기본적으로 돌려보내요. {0}로 위치 선택을 취소해요.",
+ "ja": "**境界を越えるフォーカスと切り替え:** 方向操作と MFF で両領域を行き来できます。返すまでは両側のタイルが同じ切り替え順に入り、有効化すると完全に移動、再度借りると通常は返却します。{0} で配置を取り消せます。",
+ "zh-Hans": "**跨边界焦点和切换:** 方向焦点与鼠标跟随焦点可跨区域移动;归还前,两侧平铺窗口共享切换顺序。激活借来的工作区会完整切换,再次借用默认归还,{0} 取消放置。",
+ "zh-Hant": "**跨邊界焦點與切換:** 方向焦點與游標跟隨焦點可跨區域移動;歸還前,兩側並排視窗共用切換順序。啟用借來的工作空間會完整切換,再次借用預設歸還,{0} 取消放置。"
+ },
+ "dc8918c7a817f2b4": {
+ "en": "**Visible ownership:** Borrowed windows show the borrowed workspace's icon.",
+ "ko": "**소속 표시:** 빌려온 창에는 해당 작업 공간의 아이콘이 보여요.",
+ "ja": "**所属を表示:** 借りたウインドウに、元のワークスペースのアイコンを表示します。",
+ "zh-Hans": "**显示归属:** 借来的窗口显示所属工作区图标。",
+ "zh-Hant": "**顯示歸屬:** 借來的視窗顯示所屬工作空間圖像。"
+ },
+ "ad35dc10905e1ccb": {
+ "en": "**Scratchpads:** Borrow-only workspaces stay out of regular switching, never activate alone, and auto-open their apps when summoned.",
+ "ko": "**임시 공간:** 빌려오기로만 쓰는 공간이에요. 일반 전환이나 단독 활성화에는 포함되지 않고, 꺼낼 때 필요한 앱이 열려요.",
+ "ja": "**一時スペース:** 借りるための作業です。通常の切り替えや単独の有効化には入らず、呼び出すとアプリが開きます。",
+ "zh-Hans": "**暂存区:** 仅供借用,不参与普通切换,也不会单独激活,调出时自动打开应用。",
+ "zh-Hant": "**暫存區:** 僅供借用,不參與一般切換,也不會單獨啟用,叫出時自動開啟 App。"
+ },
+ "25b01bd53e3c5f26": {
+ "en": "Always on Top",
+ "ko": "항상 위",
+ "ja": "常に手前",
+ "zh-Hans": "置顶",
+ "zh-Hant": "置頂"
+ },
+ "2ba6ee5674637a65": {
+ "en": "[Watch: one shared monitor above different workspaces]({0}).",
+ "ko": "[여러 작업 공간 위에서 같은 상태 창 보기]({0}).",
+ "ja": "[作業が変わっても同じ状態ウインドウを見る]({0})。",
+ "zh-Hans": "[观看跨工作区的共享状态窗口]({0})。",
+ "zh-Hant": "[觀看跨工作空間的共用狀態視窗]({0})。"
+ },
+ "bb02b50349a30601": {
+ "en": "**Per-workspace or shared:** Keep an app on top in one workspace or add it to Shared Apps to keep it on top everywhere.",
+ "ko": "**작업 공간별 또는 공용:** 한 공간에서만 앱을 항상 위에 두거나 공용 앱으로 추가해 어디서나 볼 수 있어요.",
+ "ja": "**作業別・共有:** 一つの作業だけで手前に置くか、共有アプリにしてどこでも表示できます。",
+ "zh-Hans": "**单独或共享:** 在一个工作区置顶应用,或加入共享应用后在所有工作区置顶。",
+ "zh-Hant": "**個別或共用:** 在一個工作空間置頂 App,或加入共用 App 後在所有工作空間置頂。"
+ },
+ "477662fabdef55d2": {
+ "en": "**No SIP changes:** Tatami uses always-on-top ScreenCaptureKit mirrors, then hands you the real window when you interact with it.",
+ "ko": "**SIP 변경 불필요:** ScreenCaptureKit 미러를 항상 위에 두고, 조작할 때 실제 창으로 연결해요.",
+ "ja": "**SIP 変更不要:** ScreenCaptureKit のミラーを手前に表示し、操作時に実際のウインドウへ渡します。",
+ "zh-Hans": "**无需修改 SIP:** 使用置顶的 ScreenCaptureKit 镜像,交互时切换到实际窗口。",
+ "zh-Hant": "**不必修改 SIP:** 使用置頂的 ScreenCaptureKit 鏡像,互動時切換到實際視窗。"
+ },
+ "46233148c533163c": {
+ "en": "**Predictable stacking:** Multiple always-on-top windows stack by focus recency. This requires Screen Recording permission.",
+ "ko": "**예측 가능한 순서:** 항상 위에 둔 여러 창은 최근 포커스한 순서로 쌓여요. 화면 기록 권한이 필요해요.",
+ "ja": "**わかりやすい重なり順:** 手前のウインドウは最近選んだ順に重なります。画面収録の許可が必要です。",
+ "zh-Hans": "**可预测的堆叠:** 多个置顶窗口按最近焦点顺序堆叠,需要屏幕录制权限。",
+ "zh-Hant": "**可預期的堆疊:** 多個置頂視窗依最近焦點順序堆疊,需要螢幕錄製權限。"
+ },
+ "9dc69c3c16a99f4b": {
+ "en": "**Leave As Is:** Keep an app as a workspace member for auto-open, focus, focus-follows-mouse, and window switching while preserving its current position and size. This mode needs no mirroring or Screen Recording.",
+ "ko": "**그대로 두기:** 창의 위치와 크기를 유지하면서 자동 열기·포커스·마우스에 따른 포커스·창 전환에 참여해요. 미러나 화면 기록 권한 없이 사용할 수 있어요.",
+ "ja": "**そのまま:** 位置と大きさを保ちながら、自動起動、フォーカス、マウス追従、切り替えに参加します。ミラーや画面収録の許可は不要です。",
+ "zh-Hans": "**保持原样:** 保留位置和大小,同时参与自动打开、焦点、焦点跟随鼠标和窗口切换,无需镜像或屏幕录制权限。",
+ "zh-Hant": "**維持原狀:** 保留位置和大小,同時參與自動開啟、焦點、焦點跟隨游標與視窗切換,不必使用鏡像或螢幕錄製權限。"
+ },
+ "a5f8441b2ddc775f": {
+ "en": "Focus & cursor",
+ "ko": "포커스와 커서",
+ "ja": "フォーカスとカーソル",
+ "zh-Hans": "焦点与指针",
+ "zh-Hant": "焦點與游標"
+ },
+ "75b3930ec022a97b": {
+ "en": "[Watch focus follow the keyboard and pointer]({0}).",
+ "ko": "[키보드와 포인터에 따른 포커스 이동 보기]({0}).",
+ "ja": "[キーとポインタに合わせたフォーカス移動を見る]({0})。",
+ "zh-Hans": "[观看焦点跟随键盘和指针]({0})。",
+ "zh-Hant": "[觀看焦點跟隨鍵盤和游標]({0})。"
+ },
+ "385afb896896c51a": {
+ "en": "**Two explicit focus models:** Focus-follows-mouse gives the window under the pointer keyboard focus. Mouse-follows-focus moves the pointer after Tatami changes windows, including switching to Always on Top, Shared Always on Top, or Leave As Is windows.",
+ "ko": "**두 가지 포커스 방식:** 마우스 따라가기는 포인터 아래 창에 키보드 포커스를 줘요. 포커스 따라가기는 Tatami가 창을 바꿀 때 포인터를 옮겨요. 항상 위·공용 항상 위·그대로 두기 창에서도 동작해요.",
+ "ja": "**二つの方式:** FFM はポインタの下にキーボードフォーカスを移し、MFF は Tatami が選んだウインドウへポインタを移します。手前・共有の手前・そのままのウインドウにも対応します。",
+ "zh-Hans": "**两种明确的焦点模式:** 焦点跟随鼠标让指针下方的窗口获得键盘焦点;鼠标跟随焦点在 Tatami 切换窗口后移动指针,包含置顶、共享置顶和保持原样窗口。",
+ "zh-Hant": "**兩種明確的焦點模式:** 焦點跟隨游標讓游標下方的視窗取得鍵盤焦點;游標跟隨焦點在 Tatami 切換視窗後移動游標,包含置頂、共用置頂與維持原狀視窗。"
+ },
+ "2cd01699d63a7818": {
+ "en": "**Close-window refocus:** Return to the most recently used remaining window.",
+ "ko": "**닫은 뒤 포커스:** 남은 창 중 가장 최근에 쓰던 창으로 돌아가요.",
+ "ja": "**閉じた後のフォーカス:** 残った中で最近使ったウインドウへ戻ります。",
+ "zh-Hans": "**关闭后恢复焦点:** 返回剩余窗口中最近使用的窗口。",
+ "zh-Hant": "**關閉後還原焦點:** 返回剩餘視窗中最近使用的視窗。"
+ },
+ "f343b4a95835ac35": {
+ "en": "**Cursor control:** Optionally hide the cursor during a workspace switch.",
+ "ko": "**커서 제어:** 작업 공간을 전환하는 동안 커서를 숨길 수 있어요.",
+ "ja": "**カーソル操作:** ワークスペースの切り替え中にカーソルを隠せます。",
+ "zh-Hans": "**指针控制:** 可在切换工作区时隐藏指针。",
+ "zh-Hant": "**游標控制:** 可在切換工作空間時隱藏游標。"
+ },
+ "776325d46925b735": {
+ "en": "Interface & config",
+ "ko": "화면과 설정",
+ "ja": "画面と設定",
+ "zh-Hans": "界面与配置",
+ "zh-Hant": "介面與設定"
+ },
+ "809bd7992c5d8416": {
+ "en": "[Watch real CLI commands, hooks and live configuration]({0}).",
+ "ko": "[실제 CLI·훅·설정 바로 적용 보기]({0}).",
+ "ja": "[実際の CLI、フック、設定の即時反映を見る]({0})。",
+ "zh-Hans": "[观看实际 CLI、钩子和实时配置]({0})。",
+ "zh-Hant": "[觀看實際 CLI、掛鉤與即時設定]({0})。"
+ },
+ "bd123cd78d805214": {
+ "en": "**Five interface languages:** Use Tatami in English, Korean, Japanese, Simplified Chinese, or Traditional Chinese, following your macOS app-language preference.",
+ "ko": "**다섯 언어:** macOS 앱 언어 설정에 따라 영어·한국어·일본어·중국어 간체·대만 번체로 쓸 수 있어요.",
+ "ja": "**5 言語:** macOS のアプリ言語に合わせ、英語・韓国語・日本語・簡体字・繁体字(台湾)で使えます。",
+ "zh-Hans": "**五种界面语言:** 跟随 macOS 应用语言设置,支持英语、韩语、日语、简体中文和繁体中文(台湾)。",
+ "zh-Hant": "**五種介面語言:** 跟隨 macOS App 語言設定,支援英文、韓文、日文、簡體中文與繁體中文(台灣)。"
+ },
+ "6694071a0a24d1fc": {
+ "en": "**Customizable menu bar:** Show the active workspace icon or name and, when relevant, the active profile icon or name.",
+ "ko": "**메뉴 막대 설정:** 활성 작업 공간의 아이콘·이름과 필요한 경우 프로필의 아이콘·이름을 표시해요.",
+ "ja": "**メニューバーの設定:** 現在の作業のアイコン・名前と、必要に応じてプロファイルの情報を表示できます。",
+ "zh-Hans": "**自定义菜单栏:** 显示当前工作区的图标或名称,并可显示活动配置方案的图标或名称。",
+ "zh-Hant": "**自訂選單列:** 顯示目前工作空間的圖像或名稱,並可顯示作用中設定組合的圖像或名稱。"
+ },
+ "03798b93f95245ab": {
+ "en": "**Adaptive on-screen feedback:** Compact spring feedback confirms workspace, profile, Always on Top, membership, layout, and Borrow actions on the display they affect. Choose one of nine positions and three sizes, or mirror the same localized payload to another surface with a HUD hook.",
+ "ko": "**화면 알림:** 작업 공간·프로필·항상 위·앱 배정·배치·빌려오기 결과를 해당 화면에서 짧은 애니메이션으로 알려줘요. 아홉 위치와 세 크기를 고르거나, 훅으로 같은 다국어 알림을 다른 도구에 전달할 수 있어요.",
+ "ja": "**操作フィードバック:** 作業、プロファイル、手前表示、割り当て、配置、借りる操作の結果を対象の画面に短く表示します。9 か所・3 サイズから選び、同じ多言語の内容を HUD フックでほかの場所へ送れます。",
+ "zh-Hans": "**自适应操作反馈:** 在相关显示器上以简洁的弹性动画确认工作区、配置方案、置顶、成员关系、布局和借用操作。可选九个位置、三种大小,或通过 HUD 钩子把相同的本地化数据传给其他界面。",
+ "zh-Hant": "**自適應操作回饋:** 在相關顯示器上以精簡的彈性動畫確認工作空間、設定組合、置頂、成員關係、排列與借用操作。可選九個位置、三種大小,或透過 HUD 掛鉤把相同的在地化資料傳給其他介面。"
+ },
+ "c4c2aff0ff226760": {
+ "en": "**Workspace icons:** Choose a per-workspace SF Symbol.",
+ "ko": "**작업 공간 아이콘:** 작업 공간마다 SF Symbol을 고를 수 있어요.",
+ "ja": "**作業のアイコン:** ワークスペースごとに SF Symbol を選べます。",
+ "zh-Hans": "**工作区图标:** 为每个工作区选择 SF Symbol。",
+ "zh-Hant": "**工作空間圖像:** 為每個工作空間選擇 SF Symbol。"
+ },
+ "d20a315fb3a14d89": {
+ "en": "**Native settings:** Configure Tatami in SwiftUI.",
+ "ko": "**네이티브 설정:** SwiftUI로 만든 설정 화면을 사용해요.",
+ "ja": "**ネイティブの設定:** SwiftUI の設定画面で操作できます。",
+ "zh-Hans": "**原生设置:** 在 SwiftUI 界面中配置 Tatami。",
+ "zh-Hant": "**原生設定:** 在 SwiftUI 介面中設定 Tatami。"
+ },
+ "841d0fb4c2bc1fc0": {
+ "en": "**skhd-style shortcuts:** For example, {0}.",
+ "ko": "**skhd 형식 단축키:** 예를 들어 {0}처럼 써요.",
+ "ja": "**skhd 形式:** {0} のように書けます。",
+ "zh-Hans": "**skhd 风格快捷键:** 例如 {0}。",
+ "zh-Hant": "**skhd 風格快速鍵:** 例如 {0}。"
+ },
+ "2f5ddffb1133dda1": {
+ "en": "**Plain TOML:** Edit {0} with XDG support and live reloads.",
+ "ko": "**TOML 설정:** {0}을 직접 수정할 수 있어요. XDG 경로와 설정 바로 적용을 지원해요.",
+ "ja": "**TOML 設定:** {0} を直接編集できます。XDG と即時反映に対応します。",
+ "zh-Hans": "**纯文本 TOML:** 编辑 {0},支持 XDG 和实时重载。",
+ "zh-Hant": "**純文字 TOML:** 編輯 {0},支援 XDG 與即時重新載入。"
+ },
+ "443c00df09fbf5b9": {
+ "en": "**Native hook editor:** Add, edit, delete, enable, or disable lifecycle and action-feedback hooks in **Settings → Hooks**, including their executable, argv, environment, working directory, and timeout.",
+ "ko": "**훅 편집기:** **설정 → 훅**에서 수명 주기·화면 알림 훅을 추가·수정·삭제하거나 켜고 끌 수 있어요. 실행 파일, 인자, 환경 변수, 작업 폴더, 제한 시간도 지정해요.",
+ "ja": "**フック編集:** **設定 → フック**で状態・操作フィードバックのフックを追加、編集、削除、有効・無効化できます。実行ファイル、引数、環境、作業フォルダ、時間制限も指定できます。",
+ "zh-Hans": "**原生钩子编辑器:** 在**设置 → 钩子**中添加、编辑、删除、启用或禁用生命周期和操作反馈钩子,配置可执行文件、argv、环境变量、工作目录和超时。",
+ "zh-Hant": "**原生掛鉤編輯器:** 在**設定 → Hook**中新增、編輯、刪除、啟用或停用生命週期與操作回饋掛鉤,設定執行檔、argv、環境變數、工作目錄與逾時。"
+ },
+ "2f67124ed450df78": {
+ "en": "**Scriptable CLI:** Use domain commands such as {0} and {1}.",
+ "ko": "**CLI 자동화:** {0}, {1} 같은 명령을 스크립트에서 사용할 수 있어요.",
+ "ja": "**CLI 自動化:** {0} や {1} などのコマンドをスクリプトで使えます。",
+ "zh-Hans": "**可脚本化 CLI:** 使用 {0}、{1} 等领域命令。",
+ "zh-Hant": "**可指令稿化 CLI:** 使用 {0}、{1} 等領域指令。"
+ },
+ "8c8d4d1a411bf7c2": {
+ "en": "**Automatic updates:** Receive releases through Sparkle.",
+ "ko": "**자동 업데이트:** Sparkle로 새 릴리스를 받아요.",
+ "ja": "**自動更新:** Sparkle で新しいリリースを受け取れます。",
+ "zh-Hans": "**自动更新:** 通过 Sparkle 获取新版本。",
+ "zh-Hant": "**自動更新:** 透過 Sparkle 取得新版本。"
+ },
+ "8ef7823ca89be623": {
+ "en": "Guided setup",
+ "ko": "시작 안내",
+ "ja": "使いながら設定",
+ "zh-Hans": "设置向导",
+ "zh-Hant": "設定導覽"
+ },
+ "78be28a73719fae3": {
+ "en": "[Watch shortcut practice and proposal review]({0}).",
+ "ko": "[단축키 연습과 제안 검토 보기]({0}).",
+ "ja": "[ショートカットの練習と提案の確認を見る]({0})。",
+ "zh-Hans": "[观看快捷键练习和建议检查]({0})。",
+ "zh-Hant": "[觀看快速鍵練習與建議檢查]({0})。"
+ },
+ "4a814980608feca1": {
+ "en": "**Learn by doing:** First launch walks through Workspaces, switching and gestures, BSP tiling, Borrow and scratchpads, Always on Top / Leave As Is, MFF / FFM, and app / window switching in a safe virtual display.",
+ "ko": "**직접 배우기:** 처음 실행하면 안전한 가상 화면에서 작업 공간, 전환·제스처, BSP 타일링, 빌려오기·임시 공간, 항상 위·그대로 두기, 포커스·포인터 따라가기, 앱·창 전환을 차례로 익혀요.",
+ "ja": "**使いながら学ぶ:** 初回起動時に安全な仮想画面で、作業、切り替え・ジェスチャ、BSP、借りる・一時スペース、手前・そのまま、MFF・FFM、アプリ・ウインドウ切り替えを順に学べます。",
+ "zh-Hans": "**边用边学:** 首次启动时,在安全的虚拟桌面依次学习工作区、切换与手势、BSP 平铺、借用与暂存区、置顶与保持原样、MFF/FFM 和应用窗口切换。",
+ "zh-Hant": "**邊用邊學:** 首次啟動時,在安全的虛擬桌面依序學習工作空間、切換與手勢、BSP 並排、借用與暫存區、置頂與維持原狀、MFF/FFM 和 App 視窗切換。"
+ },
+ "8cb019276eeca530": {
+ "en": "**Built from this Mac:** Start from running-app metadata and connected-display geometry, then organize apps around repeatable work rather than generic categories. No screen contents are captured.",
+ "ko": "**이 Mac에서 시작:** 실행 중인 앱 정보와 연결된 화면을 바탕으로 초안을 만들어요. 일반적인 분류보다 반복하는 작업에 맞춰 구성하고 화면 내용은 수집하지 않아요.",
+ "ja": "**この Mac から開始:** 実行中アプリの情報と接続画面から下書きを作ります。一般的な分類ではなく繰り返す作業に合わせ、画面の内容は取得しません。",
+ "zh-Hans": "**基于这台 Mac:** 使用运行中应用的信息和已连接显示器的几何数据,按重复进行的任务组织应用,而非套用通用分类。不捕获屏幕内容。",
+ "zh-Hant": "**基於這部 Mac:** 使用執行中 App 的資訊與已連接顯示器的幾何資料,依重複進行的工作組織 App,而非套用一般分類。不擷取螢幕內容。"
+ },
+ "96982cd5fa3674fc": {
+ "en": "**Optional AI planning:** Review a task-oriented proposal from ChatGPT, Claude, Gemini, another AI, or the on-device Apple Intelligence model on supported Macs. AI output remains a proposal until you apply it.",
+ "ko": "**선택할 수 있는 AI 구성:** ChatGPT, Claude, Gemini 등 원하는 AI나 지원하는 Mac의 Apple Intelligence가 제안한 구성을 검토해요. 적용하기 전까지는 제안으로만 남아요.",
+ "ja": "**任意の AI 提案:** ChatGPT、Claude、Gemini などや、対応 Mac の Apple Intelligence の作業案を確認できます。適用するまでは提案のままです。",
+ "zh-Hans": "**可选 AI 规划:** 检查 ChatGPT、Claude、Gemini、其他 AI 或支持的 Mac 上设备端 Apple Intelligence 提出的任务配置。应用前始终只是建议。",
+ "zh-Hant": "**可選 AI 規劃:** 檢查 ChatGPT、Claude、Gemini、其他 AI 或支援的 Mac 上裝置端 Apple Intelligence 提出的工作設定。套用前始終只是建議。"
+ },
+ "0fba660cb96d5315": {
+ "en": "**One cumulative practice surface:** Real shortcuts and trackpad gestures control the preview, and every command learned earlier remains available in later lessons.",
+ "ko": "**이어서 연습:** 실제 단축키와 제스처로 미리보기를 조작해요. 다음 단계에서도 앞서 익힌 명령을 계속 쓸 수 있어요.",
+ "ja": "**続けて練習:** 実際のキーやジェスチャでプレビューを操作し、前のレッスンの操作も続けて使えます。",
+ "zh-Hans": "**连续练习界面:** 实际快捷键和触控板手势控制预览,之前学过的命令在后续课程中仍可使用。",
+ "zh-Hant": "**連續練習介面:** 實際快速鍵和觸控式軌跡板手勢控制預覽,之前學過的指令在後續課程中仍可使用。"
+ },
+ "c5b88a4dacd67ea7": {
+ "en": "**Draft first:** Guided Setup does not move real windows or write {0} until **Apply Setup**. Reopen it any time from **Settings → General**.",
+ "ko": "**초안 먼저:** **설정 적용** 전에는 실제 창을 움직이거나 {0}에 쓰지 않아요. **설정 → 일반**에서 언제든 다시 열 수 있어요.",
+ "ja": "**まず下書き:** **設定を適用**するまで実際のウインドウや {0} は変えません。**設定 → 一般**からいつでも再開できます。",
+ "zh-Hans": "**先用草稿:** 点击**应用设置**前,不移动实际窗口或写入 {0}。可随时从**设置 → 通用**重新打开。",
+ "zh-Hant": "**先用草稿:** 點按**套用設定**前,不移動實際視窗或寫入 {0}。可隨時從**設定 → 一般**重新開啟。"
+ },
+ "e0cdd07f6a270b82": {
+ "en": "Requirements",
+ "ko": "필요한 환경",
+ "ja": "必要な環境",
+ "zh-Hans": "系统要求",
+ "zh-Hant": "系統需求"
+ },
+ "a22192bdc3ca0070": {
+ "en": "macOS 14.0 or later",
+ "ko": "macOS 14.0 이상",
+ "ja": "macOS 14.0 以降",
+ "zh-Hans": "macOS 14.0 或更新版本",
+ "zh-Hant": "macOS 14.0 或更新版本"
+ },
+ "0d90ede837c5adc1": {
+ "en": "Accessibility permission (System Settings → Privacy & Security → Accessibility)",
+ "ko": "손쉬운 사용 권한(시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용)",
+ "ja": "アクセシビリティの許可(システム設定 → プライバシーとセキュリティ → アクセシビリティ)",
+ "zh-Hans": "辅助功能权限(系统设置 → 隐私与安全性 → 辅助功能)",
+ "zh-Hant": "輔助使用權限(系統設定 → 隱私權與安全性 → 輔助使用)"
+ },
+ "1dcf1db8976b4835": {
+ "en": "Screen Recording permission, only if you use Always on Top. Those windows' always-on-top mirrors are ScreenCaptureKit captures (System Settings → Privacy & Security → Screen Recording)",
+ "ko": "항상 위 기능을 사용할 때만 화면 기록 권한이 필요해요. 이 기능은 ScreenCaptureKit으로 창을 캡처해 보여줘요(시스템 설정 → 개인정보 보호 및 보안 → 화면 기록).",
+ "ja": "「常に手前」を使う場合だけ画面収録の許可が必要です。ScreenCaptureKit でウインドウを表示します(システム設定 → プライバシーとセキュリティ → 画面収録)。",
+ "zh-Hans": "仅使用置顶时需要屏幕录制权限,置顶镜像来自 ScreenCaptureKit 捕获(系统设置 → 隐私与安全性 → 屏幕录制)。",
+ "zh-Hant": "僅使用置頂時需要螢幕錄製權限,置頂鏡像來自 ScreenCaptureKit 擷取(系統設定 → 隱私權與安全性 → 螢幕錄製)。"
+ },
+ "c3fc54aa5390dc2c": {
+ "en": "Installation",
+ "ko": "설치",
+ "ja": "インストール",
+ "zh-Hans": "安装",
+ "zh-Hant": "安裝"
+ },
+ "bc49b968dbe459ee": {
+ "en": "Homebrew",
+ "ko": "Homebrew",
+ "ja": "Homebrew",
+ "zh-Hans": "Homebrew",
+ "zh-Hant": "Homebrew"
+ },
+ "c3bceb16f2483724": {
+ "en": "Or download the signed & notarized {0} from the [latest release]({1}). Each release also links its exact corresponding source archive.",
+ "ko": "[최신 릴리스]({1})에서 서명·공증된 {0}을 내려받을 수도 있어요. 각 릴리스에는 해당 버전의 정확한 소스 코드도 연결되어 있어요.",
+ "ja": "[最新リリース]({1})から署名・公証済みの {0} を入手することもできます。各リリースには、その版に対応するソースもあります。",
+ "zh-Hans": "也可从[最新版本]({1})下载已签名并公证的 {0}。每个版本都链接到对应的准确源代码归档。",
+ "zh-Hant": "也可從[最新版本]({1})下載已簽名並公證的 {0}。每個版本都連結到對應的確切原始碼封存檔。"
+ },
+ "2f6a2e837148f931": {
+ "en": "Build from source",
+ "ko": "소스에서 빌드",
+ "ja": "ソースからビルド",
+ "zh-Hans": "从源代码构建",
+ "zh-Hant": "從原始碼建置"
+ },
+ "3a6046c1e983d7da": {
+ "en": "Configuration and automation",
+ "ko": "설정과 자동화",
+ "ja": "設定と自動化",
+ "zh-Hans": "配置与自动化",
+ "zh-Hant": "設定與自動化"
+ },
+ "b332c3492d5eb10a": {
+ "en": "Configuration",
+ "ko": "설정 안내",
+ "ja": "設定ガイド",
+ "zh-Hans": "配置指南",
+ "zh-Hant": "設定指南"
+ },
+ "699c2a7a7e983865": {
+ "en": "Settings live in {0}, grouped into tables such as {1}, {2}, {3}, {4}, and so on. Workspaces, their app assignments, and shared apps are stored in the same file. Lifecycle hooks can be managed in **Settings → Hooks** or as {5} entries in the file. The GUI keeps the executable and each argv value separate rather than treating {6} as a shell string. Edits made in the app or by hand are picked up live.",
+ "ko": "설정은 {0}에 저장돼요. {1}, {2}, {3}, {4} 같은 표로 나뉘며 작업 공간·앱 배정·공용 앱도 같은 파일에 있어요. 훅은 **설정 → 훅**이나 파일의 {5}에서 관리해요. GUI는 {6}을 셸 문자열로 합치지 않고 실행 파일과 각 인자를 따로 다뤄요. 앱이나 파일에서 수정하면 바로 반영돼요.",
+ "ja": "設定は {0} に保存され、{1}、{2}、{3}、{4} などのテーブルに分かれます。作業、アプリ、共有アプリも同じファイルです。フックは**設定 → フック**か {5} で管理します。GUI は {6} をシェル文字列にせず、実行ファイルと引数を分けて扱い、変更を即時反映します。",
+ "zh-Hans": "设置保存在 {0},分为 {1}、{2}、{3}、{4} 等表。工作区、应用分配和共享应用也在同一文件中。生命周期钩子可在**设置 → 钩子**或 {5} 中管理。GUI 分别保存可执行文件和各 argv,不把 {6} 当作 shell 字符串。应用内或手动修改都会实时生效。",
+ "zh-Hant": "設定儲存在 {0},分為 {1}、{2}、{3}、{4} 等表。工作空間、App 指派與共用 App 也在同一檔案中。生命週期掛鉤可在**設定 → Hook**或 {5} 中管理。GUI 分別儲存執行檔和各 argv,不把 {6} 當作 shell 字串。App 內或手動修改都會即時生效。"
+ },
+ "cd706eef00841de4": {
+ "en": "See [docs/CONFIGURATION.md]({0}) for the full reference: every key, its default, and the shortcut syntax.",
+ "ko": "모든 항목과 기본값, 단축키 문법은 [docs/CONFIGURATION.md]({0})에서 확인해요.",
+ "ja": "全項目、既定値、ショートカットの書き方は [docs/CONFIGURATION.md]({0}) を参照してください。",
+ "zh-Hans": "完整键、默认值和快捷键语法见 [docs/CONFIGURATION.md]({0})。",
+ "zh-Hant": "完整鍵、預設值與快速鍵語法請見 [docs/CONFIGURATION.md]({0})。"
+ },
+ "8047153ede9a3bd9": {
+ "en": "For app-specific window behavior, including floating meeting controls and Picture-in-Picture across workspace switches, see [Troubleshooting]({0}).",
+ "ko": "회의 조작 창이나 작업 공간 전환 중 화면 속 화면처럼 앱마다 다른 동작은 [문제 해결]({0})에서 확인해요.",
+ "ja": "会議の操作パネルや作業を切り替えたときのピクチャインピクチャなど、アプリ別の動作は[トラブルシューティング]({0})を参照してください。",
+ "zh-Hans": "关于各应用的窗口行为,包括浮动会议控件和跨工作区的画中画,请参阅[故障排查]({0})。",
+ "zh-Hant": "關於各 App 的視窗行為,包含浮動會議控制項與跨工作空間的子母畫面,請參閱[疑難排解]({0})。"
+ },
+ "c925a11891b84dfc": {
+ "en": "Command line",
+ "ko": "커맨드라인",
+ "ja": "コマンドライン",
+ "zh-Hans": "命令行",
+ "zh-Hant": "命令列"
+ },
+ "b14b1dbb23bdec00": {
+ "en": "Tatami ships a {0} CLI inside the app bundle. Install it from **Settings → General → Command Line → Install**. This symlinks {1} into {2} (you'll be asked for your password once). Then:",
+ "ko": "앱 번들 안에 {0} CLI가 들어 있어요. **설정 → 일반 → 명령줄 → 설치**에서 설치하세요. {1}을 {2}에 심볼릭 링크로 연결하며 암호를 한 번 요청해요. 설치 후에는 다음처럼 사용해요.",
+ "ja": "アプリ内に {0} CLI を同梱しています。**設定 → 一般 → コマンドライン → インストール**で {1} を {2} にリンクします。パスワードの入力は 1 回です。インストール後は次のように使います。",
+ "zh-Hans": "应用内置 {0} CLI。从**设置 → 通用 → 命令行 → 安装**进行安装,把 {1} 符号链接到 {2},会请求一次密码。之后可执行:",
+ "zh-Hant": "App 內建 {0} CLI。從**設定 → 一般 → 命令列 → 安裝**進行安裝,把 {1} 符號連結到 {2},會要求一次密碼。之後可執行:"
+ },
+ "e0e1b9e521a930e0": {
+ "en": "The CLI covers profile/workspace management, hook inspection, stable JSON output, and the same focus, layout, app, tiling, cycling, and Borrow commands available to trackpad gestures, organized under domain subcommands. Tatami must be running.",
+ "ko": "CLI는 프로필·작업 공간 관리, 훅 조회, 안정된 JSON 출력과 제스처로 실행할 수 있는 포커스·배치·앱·타일링·전환·빌려오기 명령을 분야별로 제공해요. Tatami가 실행 중이어야 해요.",
+ "ja": "CLI はプロファイル・作業の管理、フックの確認、安定した JSON 出力に加え、ジェスチャと同じフォーカス、配置、アプリ、タイル表示、切り替え、借りる操作を分類して提供します。Tatami の起動が必要です。",
+ "zh-Hans": "CLI 按领域提供配置方案与工作区管理、钩子查询、稳定 JSON 输出,以及与手势相同的焦点、布局、应用、平铺、循环切换和借用操作。Tatami 必须正在运行。",
+ "zh-Hant": "CLI 依領域提供設定組合與工作空間管理、掛鉤查詢、穩定 JSON 輸出,以及與手勢相同的焦點、排列、App、並排、循環切換與借用操作。Tatami 必須正在執行。"
+ },
+ "3fbde6eef15b8327": {
+ "en": "Read the [full CLI reference]({0}), or view its live web rendering on [pangmo5.dev/Tatami]({1}).",
+ "ko": "[전체 CLI 안내]({0})를 읽거나 [pangmo5.dev/Tatami]({1})에서 웹으로 확인할 수 있어요.",
+ "ja": "[CLI の完全なガイド]({0})、または [pangmo5.dev/Tatami]({1}) の Web 版を参照してください。",
+ "zh-Hans": "阅读[完整 CLI 参考]({0}),或在 [pangmo5.dev/Tatami]({1}) 查看网页版本。",
+ "zh-Hant": "閱讀[完整 CLI 參考]({0}),或在 [pangmo5.dev/Tatami]({1}) 查看網頁版本。"
+ },
+ "279174dbea275266": {
+ "en": "Tech stack",
+ "ko": "기술 구성",
+ "ja": "技術構成",
+ "zh-Hans": "技术栈",
+ "zh-Hant": "技術架構"
+ },
+ "1358dff8f9775ee7": {
+ "en": "**Tuist:** Project generation",
+ "ko": "**Tuist:** 프로젝트 생성",
+ "ja": "**Tuist:** プロジェクト生成",
+ "zh-Hans": "**Tuist:** 项目生成",
+ "zh-Hant": "**Tuist:** 專案產生"
+ },
+ "c583bd0bb181ffd8": {
+ "en": "**The Composable Architecture (TCA):** App architecture",
+ "ko": "**The Composable Architecture(TCA):** 앱 구조",
+ "ja": "**The Composable Architecture(TCA):** アプリの構成",
+ "zh-Hans": "**The Composable Architecture(TCA):** 应用架构",
+ "zh-Hant": "**The Composable Architecture(TCA):** App 架構"
+ },
+ "696df68a9531d480": {
+ "en": "**swift-sharing:** Cross-feature state sharing",
+ "ko": "**swift-sharing:** 기능 간 상태 공유",
+ "ja": "**swift-sharing:** 機能間の状態共有",
+ "zh-Hans": "**swift-sharing:** 跨功能状态共享",
+ "zh-Hant": "**swift-sharing:** 跨功能狀態共用"
+ },
+ "077661fc1bbf8682": {
+ "en": "**swift-collections:** Ordered sets, dictionaries, and deques on tiling hot paths",
+ "ko": "**swift-collections:** 타일링 핵심 경로의 순서 있는 집합·사전·데크",
+ "ja": "**swift-collections:** タイル処理の高速経路で使う順序付き集合、辞書、deque",
+ "zh-Hans": "**swift-collections:** 平铺关键路径中的有序集合、字典和双端队列",
+ "zh-Hant": "**swift-collections:** 並排關鍵路徑中的有序集合、字典與雙端佇列"
+ },
+ "114accbe875b0591": {
+ "en": "**swift-toml:** Config persistence",
+ "ko": "**swift-toml:** 설정 저장",
+ "ja": "**swift-toml:** 設定の保存",
+ "zh-Hans": "**swift-toml:** 配置持久化",
+ "zh-Hant": "**swift-toml:** 設定持久化"
+ },
+ "c3227f74606a1793": {
+ "en": "**swift-subprocess:** Cancellable, bounded hook execution",
+ "ko": "**swift-subprocess:** 취소 가능하고 실행 시간이 제한된 훅",
+ "ja": "**swift-subprocess:** キャンセル・時間制限付きのフック実行",
+ "zh-Hans": "**swift-subprocess:** 可取消且有时间边界的钩子执行",
+ "zh-Hant": "**swift-subprocess:** 可取消且有時間限制的掛鉤執行"
+ },
+ "e56cf558e496114f": {
+ "en": "**swift-yyjson:** Fast JSON for the layout store and CLI protocol",
+ "ko": "**swift-yyjson:** 배치 저장소와 CLI 통신의 빠른 JSON 처리",
+ "ja": "**swift-yyjson:** 配置保存と CLI 通信の高速 JSON",
+ "zh-Hans": "**swift-yyjson:** 布局存储和 CLI 协议中的快速 JSON",
+ "zh-Hant": "**swift-yyjson:** 排列儲存與 CLI 協定中的快速 JSON"
+ },
+ "12a5ed660266f1d7": {
+ "en": "**Magnet:** Carbon-based global hotkeys",
+ "ko": "**Magnet:** Carbon 기반 전역 단축키",
+ "ja": "**Magnet:** Carbon ベースのグローバルショートカット",
+ "zh-Hans": "**Magnet:** 基于 Carbon 的全局快捷键",
+ "zh-Hant": "**Magnet:** 以 Carbon 為基礎的全域快速鍵"
+ },
+ "d58e7424dfca278b": {
+ "en": "**SFSafeSymbols:** Type-safe SF Symbol catalog",
+ "ko": "**SFSafeSymbols:** 타입으로 검증하는 SF Symbol 목록",
+ "ja": "**SFSafeSymbols:** 型安全な SF Symbol カタログ",
+ "zh-Hans": "**SFSafeSymbols:** 类型安全的 SF Symbol 目录",
+ "zh-Hant": "**SFSafeSymbols:** 型別安全的 SF Symbol 目錄"
+ },
+ "1e422e021687dfe9": {
+ "en": "**Sparkle:** App updates",
+ "ko": "**Sparkle:** 앱 업데이트",
+ "ja": "**Sparkle:** アプリの更新",
+ "zh-Hans": "**Sparkle:** 应用更新",
+ "zh-Hant": "**Sparkle:** App 更新"
+ },
+ "c9015d53b9084645": {
+ "en": "Acknowledgements",
+ "ko": "도움을 준 프로젝트",
+ "ja": "参考にしたプロジェクト",
+ "zh-Hans": "致谢",
+ "zh-Hant": "致謝"
+ },
+ "3773c34a97f504c2": {
+ "en": "Tatami is inspired by [FlashSpace] by Wojciech Kulik (the virtual workspace-switching concept) and [yabai] by koekeishiya (the window-tiling model). See [NOTICE.md]({0}) for attribution and [THIRD_PARTY_NOTICES.md]({1}) for dependency licenses.",
+ "ko": "Tatami는 Wojciech Kulik의 [FlashSpace]에서 가상 작업 공간 전환 개념을, koekeishiya의 [yabai]에서 창 타일링 방식을 참고했어요. 출처는 [NOTICE.md]({0}), 의존성 라이선스는 [THIRD_PARTY_NOTICES.md]({1})에서 확인할 수 있어요.",
+ "ja": "Tatami は Wojciech Kulik の [FlashSpace](仮想作業の切り替え)と koekeishiya の [yabai](タイル表示のモデル)を参考にしています。帰属は [NOTICE.md]({0})、依存ライセンスは [THIRD_PARTY_NOTICES.md]({1}) を参照してください。",
+ "zh-Hans": "Tatami 受到 Wojciech Kulik 的 [FlashSpace](虚拟工作区切换概念)和 koekeishiya 的 [yabai](窗口平铺模型)启发。署名见 [NOTICE.md]({0}),依赖许可证见 [THIRD_PARTY_NOTICES.md]({1})。",
+ "zh-Hant": "Tatami 受到 Wojciech Kulik 的 [FlashSpace](虛擬工作空間切換概念)與 koekeishiya 的 [yabai](視窗並排模型)啟發。歸屬說明見 [NOTICE.md]({0}),相依套件授權見 [THIRD_PARTY_NOTICES.md]({1})。"
+ },
+ "c011d6097bfbc8e9": {
+ "en": "License",
+ "ko": "라이선스",
+ "ja": "ライセンス",
+ "zh-Hans": "许可证",
+ "zh-Hant": "授權條款"
+ },
+ "3018f3ea240ebe39": {
+ "en": "[AGPL-3.0-only]({0}).",
+ "ko": "[AGPL-3.0-only]({0}).",
+ "ja": "[AGPL-3.0-only]({0})。",
+ "zh-Hans": "[AGPL-3.0-only]({0})。",
+ "zh-Hant": "[AGPL-3.0-only]({0})。"
+ },
+ "51947fe41020f123": {
+ "en": "Tatami command-line reference",
+ "ko": "Tatami 명령줄 안내",
+ "ja": "Tatami コマンドラインリファレンス",
+ "zh-Hans": "Tatami 命令行参考",
+ "zh-Hant": "Tatami 命令列參考"
+ },
+ "7624c620c8375157": {
+ "en": "Tatami ships a {0} executable inside the app bundle. The CLI talks to the running app, so launch Tatami before sending commands.",
+ "ko": "앱 번들에 {0} 실행 파일이 들어 있어요. CLI는 실행 중인 앱과 통신하므로 명령을 보내기 전에 Tatami를 켜주세요.",
+ "ja": "アプリ内に {0} 実行ファイルを同梱しています。CLI は実行中のアプリと通信するため、先に Tatami を起動してください。",
+ "zh-Hans": "应用包内包含 {0} 可执行文件。CLI 与运行中的应用通信,请先启动 Tatami 再发送命令。",
+ "zh-Hant": "App 套件內包含 {0} 執行檔。CLI 與執行中的 App 通訊,請先啟動 Tatami 再傳送指令。"
+ },
+ "569ca49f4aaf7846": {
+ "en": "Install",
+ "ko": "설치",
+ "ja": "インストール",
+ "zh-Hans": "安装",
+ "zh-Hant": "安裝"
+ },
+ "b8f6103ad6738c54": {
+ "en": "Open **Settings → General → Command Line → Install**. Tatami creates a symlink at {0}; macOS asks for an administrator password when the link is installed or removed. The link points into the app bundle, so replacing Tatami at the same path also updates the CLI. Reinstall the link after moving or renaming the app.",
+ "ko": "**설정 → 일반 → 명령줄 → 설치**를 열어요. {0}에 심볼릭 링크를 만들며, 설치하거나 제거할 때 macOS가 관리자 암호를 요청해요. 링크가 앱 번들을 가리키므로 같은 경로에서 앱을 교체하면 CLI도 함께 갱신돼요. 앱을 옮기거나 이름을 바꾸면 링크를 다시 설치해주세요.",
+ "ja": "**設定 → 一般 → コマンドライン → インストール**を開きます。{0} にリンクを作り、追加・削除時に macOS が管理者パスワードを求めます。同じ場所でアプリを置き換えると CLI も更新されます。移動や名前変更後はリンクを再作成してください。",
+ "zh-Hans": "打开**设置 → 通用 → 命令行 → 安装**。Tatami 在 {0} 创建符号链接,安装或删除时 macOS 会请求管理员密码。链接指向应用包,在相同路径替换应用会同时更新 CLI;移动或重命名应用后请重新安装链接。",
+ "zh-Hant": "開啟**設定 → 一般 → 命令列 → 安裝**。Tatami 在 {0} 建立符號連結,安裝或移除時 macOS 會要求管理者密碼。連結指向 App 套件,在相同路徑取代 App 會同時更新 CLI;移動或重新命名 App 後請重新安裝連結。"
+ },
+ "a320078a4dfa5081": {
+ "en": "Verify both the executable and the running app:",
+ "ko": "실행 파일과 실행 중인 앱을 모두 확인해요.",
+ "ja": "実行ファイルと起動中のアプリを両方確認します。",
+ "zh-Hans": "同时检查可执行文件和运行中的应用:",
+ "zh-Hant": "同時檢查執行檔與執行中的 App:"
+ },
+ "78045a84c89253f0": {
+ "en": "Command overview",
+ "ko": "명령 개요",
+ "ja": "コマンドの概要",
+ "zh-Hans": "命令概览",
+ "zh-Hant": "指令概覽"
+ },
+ "6ac31fb8c2ba8ac3": {
+ "en": "Run {0}, {1}, or append {2} to a command for ArgumentParser's current usage text.",
+ "ko": "{0}, {1}을 실행하거나 명령 뒤에 {2}를 붙이면 ArgumentParser의 현재 사용법을 볼 수 있어요.",
+ "ja": "{0}、{1}、または各コマンドの後に {2} を付けると、ArgumentParser の現在の使い方を確認できます。",
+ "zh-Hans": "执行 {0}、{1},或在命令后添加 {2},查看 ArgumentParser 当前的用法。",
+ "zh-Hant": "執行 {0}、{1},或在指令後加上 {2},查看 ArgumentParser 目前的用法。"
+ },
+ "9ef783d1acd96219": {
+ "en": "Select profiles and workspaces",
+ "ko": "프로필과 작업 공간 선택",
+ "ja": "プロファイルとワークスペースを選ぶ",
+ "zh-Hans": "选择配置方案和工作区",
+ "zh-Hant": "選擇設定組合與工作空間"
+ },
+ "19f4e5d4849ab644": {
+ "en": "Every {0} and {1} selector accepts either its current name or UUID. A workspace name is resolved inside the active profile unless {2} is supplied. A workspace UUID is globally unique and can therefore select an inactive profile without {3}. {4} is the exception: Borrow only accepts a workspace in the active profile, so switch profiles first when necessary.",
+ "ko": "{0}과 {1}에는 현재 이름이나 UUID를 쓸 수 있어요. 작업 공간 이름은 {2}가 없으면 활성 프로필 안에서 찾아요. UUID는 전체에서 고유해서 {3} 없이 비활성 프로필도 선택할 수 있어요. 다만 {4}는 현재 프로필의 작업 공간만 빌릴 수 있으니 필요하면 프로필부터 바꿔주세요.",
+ "ja": "{0} と {1} は現在の名前か UUID で選べます。名前は {2} がなければ現在のプロファイル内で探します。UUID は全体で一意なので {3} なしで別のプロファイルも指定できます。ただし {4} は現在のプロファイル内だけで、必要なら先に切り替えます。",
+ "zh-Hans": "{0} 和 {1} 可使用当前名称或 UUID。未提供 {2} 时,工作区名称在活动配置方案内解析;UUID 全局唯一,因此无需 {3} 也可选中非活动方案。{4} 例外:借用只接受活动方案内的工作区,需要时先切换方案。",
+ "zh-Hant": "{0} 與 {1} 可使用目前名稱或 UUID。未提供 {2} 時,工作空間名稱在作用中設定組合內解析;UUID 全域唯一,因此不需 {3} 也可選中非作用中組合。{4} 例外:借用只接受作用中組合內的工作空間,需要時先切換組合。"
+ },
+ "5bc072dee7ffd552": {
+ "en": "If a name is duplicated in the search scope, Tatami fails without changing anything and prints every candidate UUID. Use one of those UUIDs to retry.",
+ "ko": "검색 범위 안에서 이름이 겹치면 아무것도 바꾸지 않고 실패하며 후보 UUID를 모두 보여줘요. 그중 하나로 다시 실행해주세요.",
+ "ja": "範囲内に同名があると何も変えずに失敗し、候補の UUID をすべて表示します。その UUID でやり直してください。",
+ "zh-Hans": "查找范围内名称重复时,不进行更改,返回失败并列出所有候选 UUID。请用其中一个 UUID 重试。",
+ "zh-Hant": "搜尋範圍內名稱重複時,不進行變更,回報失敗並列出所有候選 UUID。請用其中一個 UUID 重試。"
+ },
+ "f82b3e590192ca93": {
+ "en": "The original flat commands remain available as compatibility aliases for existing scripts. They are intentionally hidden from root help so new usage is organized by domain:",
+ "ko": "기존 스크립트를 위해 예전 명령도 호환 별칭으로 남겨뒀어요. 새 사용법은 분야별로 익힐 수 있도록 최상위 도움말에서는 숨겨요.",
+ "ja": "既存スクリプト向けに以前のフラットなコマンドも別名として残しています。新しい使い方を分類ごとに学べるよう、ルートのヘルプでは非表示にしています。",
+ "zh-Hans": "原有平铺式命令保留为兼容别名,供现有脚本使用。它们不出现在根帮助中,让新用法按领域组织:",
+ "zh-Hant": "原有平面式指令保留為相容別名,供既有指令稿使用。它們不出現在根說明中,讓新用法依領域組織:"
+ },
+ "8e0d2bf31a664201": {
+ "en": "{0} is {1} for the active profile.",
+ "ko": "{0}은 활성 프로필에 대한 {1}과 같아요.",
+ "ja": "{0} は現在のプロファイルに対する {1} です。",
+ "zh-Hans": "{0} 等同于对活动配置方案执行 {1}。",
+ "zh-Hant": "{0} 等同於對作用中設定組合執行 {1}。"
+ },
+ "bc447b9ac506af51": {
+ "en": "Profile/workspace rename and duplicate commands update {0} transactionally. Duplication also copies saved layouts before publishing the new config; a layout-copy failure leaves the configuration unchanged.",
+ "ko": "이름 변경과 복제는 {0}을 트랜잭션으로 갱신해요. 복제할 때는 새 설정을 공개하기 전에 저장한 배치도 복사해요. 배치 복사가 실패하면 설정은 바뀌지 않아요.",
+ "ja": "名前変更と複製は {0} をトランザクションとして更新します。複製では設定の公開前に保存済み配置もコピーし、そのコピーに失敗すると設定を変えません。",
+ "zh-Hans": "重命名和复制以事务方式更新 {0}。复制会先复制保存的布局,再发布新配置;布局复制失败时配置保持不变。",
+ "zh-Hant": "重新命名與複製以交易方式更新 {0}。複製會先拷貝儲存的排列,再發布新設定;排列拷貝失敗時設定保持不變。"
+ },
+ "3ba21439ff4a5db4": {
+ "en": "CLI duplication copies the complete item rather than opening the app's selection sheet. {0} clears the new workspace's key equivalent and explicit activate/assign/borrow shortcuts because the copy lives in the same profile. {1} clears the new profile's switch shortcut and auto-activation rule, but keeps its workspaces' shortcuts because workspace bindings are isolated by profile. Use Duplicate in the app when you want to choose individual workspaces, apps, settings, or saved layouts.",
+ "ko": "CLI 복제는 선택 창 없이 항목 전체를 복사해요. {0}은 같은 프로필 안에 생기므로 새 작업 공간의 키와 활성화·배정·빌려오기 단축키를 비워요. {1}은 새 프로필의 전환 키와 자동 활성화 규칙을 비우지만, 프로필별로 분리된 작업 공간 키는 유지해요. 일부 공간·앱·설정·배치만 고르려면 앱의 복제를 사용하세요.",
+ "ja": "CLI は選択シートを開かず全体を複製します。{0} は同じプロファイルに作るため、ワークスペースキーと有効化・割り当て・借りるキーを空にします。{1} はプロファイルの切り替えと自動規則を空にし、ワークスペースキーはプロファイル別なので保ちます。一部だけ選ぶ場合はアプリの複製を使ってください。",
+ "zh-Hans": "CLI 复制整个项目,不打开选择面板。{0} 的副本处于同一方案,因此清空新工作区按键和显式激活、分配、借用快捷键。{1} 清空新方案的切换快捷键和自动激活规则,但保留工作区快捷键,因为它们按方案隔离。要选择单独的工作区、应用、设置或布局,请在应用中复制。",
+ "zh-Hant": "CLI 複製整個項目,不開啟選擇面板。{0} 的副本位於同一組合,因此清空新工作空間按鍵與明確的啟用、指派、借用快速鍵。{1} 清空新組合的切換快速鍵與自動啟用規則,但保留工作空間快速鍵,因為它們依組合隔離。要選擇個別工作空間、App、設定或排列,請在 App 中複製。"
+ },
+ "042d237ad897c6f2": {
+ "en": "Run dispatcher commands by domain",
+ "ko": "분야별 동작 명령 실행",
+ "ja": "分類ごとの操作コマンドを実行する",
+ "zh-Hans": "按领域执行操作命令",
+ "zh-Hant": "依領域執行操作指令"
+ },
+ "4dad40198eae3c73": {
+ "en": "The {0}, {1}, {2}, {3}, {4}, and {5} domains cover every executable capability available to three- and four-finger gesture bindings. Non-activation operational leaves map through {6} and use the same {7} dispatcher as gestures and global shortcuts; there is no generic {8} escape hatch.",
+ "ko": "{0}, {1}, {2}, {3}, {4}, {5}는 세 손가락·네 손가락 제스처에 연결할 수 있는 모든 동작을 제공해요. 활성화 외 동작은 {6}을 통해 제스처·전역 단축키와 같은 {7} 디스패처로 전달해요. 범용 {8} 우회 명령은 없어요.",
+ "ja": "{0}、{1}、{2}、{3}、{4}、{5} は、3 本指・4 本指ジェスチャに割り当てられる全操作を提供します。有効化以外は {6} を経由し、同じ {7} に渡します。汎用の {8} 抜け道はありません。",
+ "zh-Hans": "{0}、{1}、{2}、{3}、{4} 和 {5} 覆盖三指、四指手势可绑定的全部执行能力。非激活操作通过 {6} 映射,使用与手势和全局快捷键相同的 {7} 分发器;没有通用 {8} 入口。",
+ "zh-Hant": "{0}、{1}、{2}、{3}、{4} 與 {5} 涵蓋三指、四指手勢可綁定的全部執行能力。非啟用操作透過 {6} 對應,使用與手勢和全域快速鍵相同的 {7} 分派器;沒有通用 {8} 入口。"
+ },
+ "6be14ec87ac92745": {
+ "en": "Most operational commands return status {0}. This means the running app validated the command and handed it to the shared reducer dispatcher. It does not claim that a later Accessibility or window-manager operation changed a window: for example, {1} can have no neighbor and a workspace app command can arrive when no app is focused.",
+ "ko": "대부분의 동작은 {0} 상태를 반환해요. 앱이 명령을 검증해서 공용 리듀서에 전달했다는 뜻이며, 이후 창 조작까지 성공했다는 뜻은 아니에요. 예를 들어 {1}은 이웃 창이 없을 수 있고, 포커스한 앱 없이 배정 명령이 도착할 수도 있어요.",
+ "ja": "多くの操作は {0} を返します。アプリが検証して共通 reducer に渡した意味で、後のウインドウ操作の成功までは示しません。{1} に隣がない場合や、フォーカスなしでアプリ操作が届く場合もあります。",
+ "zh-Hans": "大多数操作返回 {0},表示运行中的应用已验证命令并交给共享 reducer,不代表后续辅助功能或窗口操作已改变窗口。例如 {1} 可能没有相邻窗口,应用命令也可能在无聚焦应用时到达。",
+ "zh-Hant": "大多數操作回傳 {0},表示執行中的 App 已驗證指令並交給共用 reducer,不代表後續輔助使用或視窗操作已改變視窗。例如 {1} 可能沒有相鄰視窗,App 指令也可能在沒有聚焦 App 時到達。"
+ },
+ "b269b2cfa8506112": {
+ "en": "{0} and {1} instead wait for the full activation pipeline and return status {2}, or report terminal failure. Scratchpads are Borrow-only, so use {3} for them.",
+ "ko": "{0}과 {1}은 전체 활성화 과정이 끝날 때까지 기다려 {2}를 반환하거나 최종 실패를 알려줘요. 임시 공간에는 빌려오기 전용 명령 {3}을 사용하세요.",
+ "ja": "{0} と {1} は有効化の全処理を待ち、{2} または最終的な失敗を返します。一時スペースには借りる操作 {3} を使います。",
+ "zh-Hans": "{0} 和 {1} 会等待完整激活流程,返回 {2} 或最终失败。暂存区仅供借用,请使用 {3}。",
+ "zh-Hant": "{0} 與 {1} 會等待完整啟用流程,回傳 {2} 或最終失敗。暫存區僅供借用,請使用 {3}。"
+ },
+ "52d27854fc334696": {
+ "en": "Toggle commands are non-idempotent. Do not blindly retry one after an interrupted or unknown client-side result; inspect state first or the retry may undo the first invocation.",
+ "ko": "켜기·끄기 전환 명령은 반복하면 결과가 달라져요. 중단되거나 결과가 불명확하면 먼저 상태를 확인하세요. 바로 재시도하면 첫 동작을 되돌릴 수 있어요.",
+ "ja": "切り替えコマンドは繰り返すと結果が変わります。中断や結果不明の後は状態を確認してください。再試行が最初の操作を取り消すことがあります。",
+ "zh-Hans": "切换命令不具幂等性。客户端结果中断或未知时,先检查状态,不要盲目重试,否则可能撤销首次操作。",
+ "zh-Hant": "切換指令不具冪等性。用戶端結果中斷或未知時,先檢查狀態,不要盲目重試,否則可能復原首次操作。"
+ },
+ "47829dea2eccdbe1": {
+ "en": "Profiles and workspaces",
+ "ko": "프로필과 작업 공간",
+ "ja": "プロファイルとワークスペース",
+ "zh-Hans": "配置方案与工作区",
+ "zh-Hant": "設定組合與工作空間"
+ },
+ "713166971d730f81": {
+ "en": "Command",
+ "ko": "명령",
+ "ja": "コマンド",
+ "zh-Hans": "命令",
+ "zh-Hant": "指令"
+ },
+ "edf8d3f1781f32f0": {
+ "en": "Behavior",
+ "ko": "동작",
+ "ja": "動作",
+ "zh-Hans": "行为",
+ "zh-Hant": "行為"
+ },
+ "8a7c79f0ae86bcf8": {
+ "en": "Activate the profile and wait for terminal completion.",
+ "ko": "프로필을 활성화하고 최종 완료까지 기다려요.",
+ "ja": "プロファイルを有効化し、最終完了を待ちます。",
+ "zh-Hans": "激活配置方案并等待最终完成。",
+ "zh-Hant": "啟用設定組合並等待最終完成。"
+ },
+ "eb7ec88e6ca5fc55": {
+ "en": "Activate the workspace and its owning profile when necessary, then wait for completion.",
+ "ko": "작업 공간과 필요하면 소속 프로필을 활성화한 뒤 완료까지 기다려요.",
+ "ja": "ワークスペースと必要なら所属プロファイルを有効化し、完了を待ちます。",
+ "zh-Hans": "激活工作区,必要时激活所属方案,再等待完成。",
+ "zh-Hant": "啟用工作空間,必要時啟用所屬組合,再等待完成。"
+ },
+ "4f5060f361a25a35": {
+ "en": "Switch relative to workspace history/order.",
+ "ko": "작업 공간의 기록이나 순서에 따라 이동해요.",
+ "ja": "履歴や並び順に沿ってワークスペースを切り替えます。",
+ "zh-Hans": "按工作区历史或顺序切换。",
+ "zh-Hant": "依工作空間記錄或順序切換。"
+ },
+ "59cafaffffc0e5b2": {
+ "en": "Move the focused app to an adjacent workspace and switch.",
+ "ko": "포커스한 앱을 옆 작업 공간으로 옮기고 전환해요.",
+ "ja": "選んだアプリを隣のワークスペースへ移し、切り替えます。",
+ "zh-Hans": "把聚焦应用移到相邻工作区并切换。",
+ "zh-Hant": "把聚焦 App 移到相鄰工作空間並切換。"
+ },
+ "5093c305b3495a81": {
+ "en": "Add the focused app without removing its existing workspace memberships, switch profiles when necessary, and activate the target.",
+ "ko": "기존 소속을 유지한 채 포커스한 앱을 추가해요. 필요하면 프로필을 바꾸고 대상 공간을 활성화해요.",
+ "ja": "既存の所属を残してアプリを追加し、必要ならプロファイルを変えて対象を開きます。",
+ "zh-Hans": "保留现有工作区成员关系并添加聚焦应用,必要时切换方案,再激活目标。",
+ "zh-Hant": "保留現有工作空間成員關係並新增聚焦 App,必要時切換組合,再啟用目標。"
+ },
+ "e4164739c4448748": {
+ "en": "Add the focused app without removing existing memberships, then switch to the relative workspace.",
+ "ko": "기존 소속을 유지한 채 앱을 추가하고 상대 위치의 작업 공간으로 이동해요.",
+ "ja": "既存の所属を残して追加し、相対位置のワークスペースに切り替えます。",
+ "zh-Hans": "保留现有成员关系并添加聚焦应用,然后切换到相对工作区。",
+ "zh-Hant": "保留現有成員關係並新增聚焦 App,然後切換到相對工作空間。"
+ },
+ "8fce26cdb73e6027": {
+ "en": "Start the interactive Borrow direction picker for a workspace in the active profile.",
+ "ko": "활성 프로필의 작업 공간에 대해 빌려올 방향을 고르는 화면을 열어요.",
+ "ja": "現在のプロファイル内のワークスペースについて、借りる方向の選択を始めます。",
+ "zh-Hans": "为活动方案中的工作区打开交互式借用方向选择器。",
+ "zh-Hant": "為作用中組合的工作空間開啟互動式借用方向選擇器。"
+ },
+ "631129eaf952e0bf": {
+ "en": "Borrow a relative workspace.",
+ "ko": "상대 위치의 작업 공간을 빌려와요.",
+ "ja": "相対位置のワークスペースを借ります。",
+ "zh-Hans": "借用相对工作区。",
+ "zh-Hant": "借用相對工作空間。"
+ },
+ "ff600e1aedfd7a5f": {
+ "en": "Dismiss Borrow on the pointer display.",
+ "ko": "포인터가 있는 화면의 빌려오기를 끝내요.",
+ "ja": "ポインタの画面で借りる操作を終了します。",
+ "zh-Hans": "归还指针所在显示器的借用工作区。",
+ "zh-Hant": "歸還游標所在顯示器的借用工作空間。"
+ },
+ "390571bf0854ab24": {
+ "en": "Windows and displays",
+ "ko": "창과 화면",
+ "ja": "ウインドウと画面",
+ "zh-Hans": "窗口与显示器",
+ "zh-Hant": "視窗與顯示器"
+ },
+ "097e9a0e700d30ad": {
+ "en": "Focus a neighboring tiled window.",
+ "ko": "이웃한 타일링 창으로 포커스를 옮겨요.",
+ "ja": "隣のタイルウインドウへフォーカスを移します。",
+ "zh-Hans": "聚焦相邻平铺窗口。",
+ "zh-Hant": "聚焦相鄰並排視窗。"
+ },
+ "f2bb6db70bc4657a": {
+ "en": "Cycle inside the visible Tatami workspace.",
+ "ko": "현재 보이는 Tatami 작업 공간 안에서 창을 전환해요.",
+ "ja": "表示中の Tatami ワークスペース内で順に切り替えます。",
+ "zh-Hans": "在可见的 Tatami 工作区内循环切换。",
+ "zh-Hant": "在可見的 Tatami 工作空間內循環切換。"
+ },
+ "390efe0e14ada4eb": {
+ "en": "Adjust the focused BSP split by one step.",
+ "ko": "포커스한 BSP 분할 비율을 한 단계 조절해요.",
+ "ja": "選んだ BSP 分割を一段階調整します。",
+ "zh-Hans": "按一个步长调整聚焦的 BSP 分割。",
+ "zh-Hant": "依一個步長調整聚焦的 BSP 分割。"
+ },
+ "23aa714c12dc09a1": {
+ "en": "Swap the focused tiled window directionally.",
+ "ko": "포커스한 타일링 창을 해당 방향의 창과 바꿔요.",
+ "ja": "選んだタイルウインドウを指定方向に入れ替えます。",
+ "zh-Hans": "沿指定方向交换聚焦的平铺窗口。",
+ "zh-Hant": "沿指定方向交換聚焦的並排視窗。"
+ },
+ "5ac37d67aab47b44": {
+ "en": "Toggle Tatami's layout fullscreen for the focused window.",
+ "ko": "포커스한 창의 작업 공간 확대를 켜거나 꺼요.",
+ "ja": "選んだウインドウのワークスペースズームを切り替えます。",
+ "zh-Hans": "切换聚焦窗口的 Tatami 布局全屏状态。",
+ "zh-Hant": "切換聚焦視窗的 Tatami 排列全螢幕狀態。"
+ },
+ "73f3849f743033e3": {
+ "en": "Toggle the focused app's tiled/floating layout in its workspace or Shared Apps.",
+ "ko": "작업 공간 또는 공용 앱에서 포커스한 앱의 타일링·항상 위 표시를 전환해요.",
+ "ja": "ワークスペースまたは共有アプリ内で、選んだアプリのタイル・手前表示を切り替えます。",
+ "zh-Hans": "切换聚焦应用在工作区或共享应用中的平铺、置顶模式。",
+ "zh-Hant": "切換聚焦 App 在工作空間或共用 App 中的並排、置頂模式。"
+ },
+ "e166cea7e5f0f4ed": {
+ "en": "Focus the workspace on an adjacent display.",
+ "ko": "이웃 화면의 작업 공간으로 포커스를 옮겨요.",
+ "ja": "隣の画面のワークスペースへフォーカスを移します。",
+ "zh-Hans": "聚焦相邻显示器的工作区。",
+ "zh-Hant": "聚焦相鄰顯示器的工作空間。"
+ },
+ "91b8d507491f4278": {
+ "en": "CLI and gesture window cycling is immediate. The held-modifier switcher session is specific to a real global shortcut.",
+ "ko": "CLI와 제스처의 창 전환은 즉시 실행돼요. 보조키를 길게 눌러 목록을 여는 동작은 실제 전역 단축키에서만 동작해요.",
+ "ja": "CLI とジェスチャの切り替えは即時に行います。修飾キーを押し続ける一覧は、実際のグローバルショートカット専用です。",
+ "zh-Hans": "CLI 和手势窗口循环立即执行。按住修饰键的切换会话仅用于实际全局快捷键。",
+ "zh-Hant": "CLI 與手勢視窗循環立即執行。按住輔助鍵的切換工作階段僅用於實際全域快速鍵。"
+ },
+ "05613be382046ac1": {
+ "en": "Layout and tiling",
+ "ko": "배치와 타일링",
+ "ja": "配置とタイル表示",
+ "zh-Hans": "布局与平铺",
+ "zh-Hant": "排列與並排"
+ },
+ "9e91161fe9fffc07": {
+ "en": "Toggle the focused split orientation.",
+ "ko": "포커스한 분할의 방향을 바꿔요.",
+ "ja": "選んだ分割の向きを切り替えます。",
+ "zh-Hans": "切换聚焦分割的方向。",
+ "zh-Hant": "切換聚焦分割的方向。"
+ },
+ "b3350309e0d8695b": {
+ "en": "Rebalance the active layout.",
+ "ko": "활성 배치를 균등하게 맞춰요.",
+ "ja": "現在の配置を均等にします。",
+ "zh-Hans": "重新均衡活动布局。",
+ "zh-Hant": "重新平均調整作用中排列。"
+ },
+ "807a9bab039a8bbb": {
+ "en": "Pause or resume Tatami tiling globally.",
+ "ko": "Tatami 타일링을 전체적으로 잠시 멈추거나 다시 시작해요.",
+ "ja": "Tatami のタイル表示全体を一時停止・再開します。",
+ "zh-Hans": "全局暂停或恢复 Tatami 平铺。",
+ "zh-Hant": "全域暫停或恢復 Tatami 並排。"
+ },
+ "32cea6a0df444e99": {
+ "en": "Focused app",
+ "ko": "포커스한 앱",
+ "ja": "フォーカス中のアプリ",
+ "zh-Hans": "聚焦的应用",
+ "zh-Hant": "聚焦的 App"
+ },
+ "7a0652c1f3c8d902": {
+ "en": "Remove the focused app if it is already in the active workspace. Otherwise move it from any other workspace in the active profile into this one as Tiled.",
+ "ko": "이미 활성 공간에 속해 있으면 제거해요. 아니면 같은 프로필의 다른 공간에서 현재 공간으로 옮기고 타일링해요.",
+ "ja": "現在のワークスペースに所属していれば外し、そうでなければ同じプロファイルの別のワークスペースから移してタイル表示にします。",
+ "zh-Hans": "若聚焦应用已属于当前工作区,则移除;否则从活动方案内其他工作区移入,并设为平铺。",
+ "zh-Hant": "若聚焦 App 已屬於目前工作空間,則移除;否則從作用中組合內其他工作空間移入,並設為並排。"
+ },
+ "e63daca1e660d7ad": {
+ "en": "Add the focused app to Shared Apps as Tiled, or remove it if it is already shared.",
+ "ko": "포커스한 앱을 타일링 공용 앱으로 추가하거나, 이미 공용이면 제거해요.",
+ "ja": "選んだアプリをタイル表示の共有アプリに追加し、すでに共有なら外します。",
+ "zh-Hans": "把聚焦应用以平铺模式加入共享应用,已共享则移除。",
+ "zh-Hant": "把聚焦 App 以並排模式加入共用 App,已共用則移除。"
+ },
+ "d412a062620ef57b": {
+ "en": "Hooks",
+ "ko": "훅",
+ "ja": "フック",
+ "zh-Hans": "钩子",
+ "zh-Hant": "掛鉤"
+ },
+ "98a17c4ac5786b21": {
+ "en": "{0} reports every configured hook, including disabled and invalid entries. Add, edit, delete, enable, or disable hooks from **Settings → Hooks**, or edit {1} directly in {2}. Hand edits remain supported and are picked up live.",
+ "ko": "{0}은 꺼져 있거나 유효하지 않은 항목까지 모든 훅을 보여줘요. **설정 → 훅**에서 추가·편집·삭제·켜기·끄기를 하거나 {2}의 {1}을 직접 수정하세요. 파일 수정도 바로 반영돼요.",
+ "ja": "{0} は無効・不正な項目も含め全フックを表示します。**設定 → フック**で管理するか、{2} の {1} を編集できます。手での変更も即時に反映します。",
+ "zh-Hans": "{0} 列出所有配置的钩子,包括禁用和无效项。可在**设置 → 钩子**中管理,或直接编辑 {2} 中的 {1}。手动更改仍受支持并实时生效。",
+ "zh-Hant": "{0} 列出所有設定的 Hook,包含停用與無效項。可在**設定 → Hook**中管理,或直接編輯 {2} 中的 {1}。手動變更仍受支援並即時生效。"
+ },
+ "ca8b0646f352abae": {
+ "en": "Supported events are {0}, {1}, {2}, and {3}. {4} is published once per Tatami process after startup has resolved the active profile. Its payload contains that profile and omits {5}, {6}, {7}, and {8}. It is separate from the startup {9} event, so hooks can subscribe to either or both. The {10} event mirrors compact action-feedback content for integrations such as SketchyBar.",
+ "ko": "지원 이벤트는 {0}, {1}, {2}, {3}이에요. {4}는 시작 시 활성 프로필을 정한 뒤 프로세스마다 한 번 발생해요. 해당 프로필을 포함하고 {5}, {6}, {7}, {8}은 제외해요. 시작 시의 {9}와 별개라 둘 중 하나나 모두 구독할 수 있어요. {10}은 SketchyBar 같은 도구에 짧은 화면 알림을 전달해요.",
+ "ja": "イベントは {0}、{1}、{2}、{3} です。{4} は起動後にプロファイルを決めてから各プロセスで一度発生し、プロファイルを含み {5}、{6}、{7}、{8} は省きます。起動時の {9} とは別で、両方を購読できます。{10} は SketchyBar などへ短い操作情報を渡します。",
+ "zh-Hans": "支持 {0}、{1}、{2}、{3}。{4} 在启动确定活动方案后,每个 Tatami 进程仅发送一次,包含该方案并省略 {5}、{6}、{7}、{8}。它与启动时的 {9} 独立,可订阅任一或两者。{10} 向 SketchyBar 等集成传递简洁的操作反馈。",
+ "zh-Hant": "支援 {0}、{1}、{2}、{3}。{4} 在啟動確定作用中組合後,每個 Tatami 程序僅傳送一次,包含該組合並省略 {5}、{6}、{7}、{8}。它與啟動時的 {9} 獨立,可訂閱任一或兩者。{10} 向 SketchyBar 等整合傳遞精簡的操作回饋。"
+ },
+ "dc5db813cf8ea6c8": {
+ "en": "The Settings editor keeps the executable and every argument in separate fields. They map directly to {0} and its remaining argv values. Tatami does not combine them into a shell command, split arguments on spaces, interpret quotes, or expand variables. Choose a shell explicitly when shell syntax is required, such as an executable of {1} with separate {2} and script arguments. For fish, use the path returned by {3}, then add {4} and the command text as separate arguments. For example, {5} bypasses a user-defined {6} function. Tatami sends the hook event on standard input, so a command that consumes standard input receives that JSON event.",
+ "ko": "설정 편집기는 실행 파일과 각 인자를 별도 입력칸에 보관해요. 첫 값은 {0}, 나머지는 argv가 돼요. 셸 명령으로 합치거나 공백·따옴표·변수를 해석하지 않아요. 셸 문법이 필요하면 {1} 실행 파일에 {2}와 스크립트를 따로 넘기세요. fish는 {3}이 알려준 경로에 {4}와 명령을 별도 인자로 추가해요. 예를 들어 {5}는 사용자 정의 {6} 함수를 건너뛰어요. 표준 입력으로 훅의 JSON 이벤트를 전달하므로 stdin을 읽는 명령은 그 이벤트를 받아요.",
+ "ja": "実行ファイルと各引数は別フィールドで、先頭は {0}、残りは argv になります。シェル結合、空白分割、引用符解釈、変数展開は行いません。必要なら {1} を選び、{2} とスクリプトを別引数にします。fish は {3} のパスに {4} と本文を渡します。{5} はユーザーの {6} 関数を避けます。標準入力を読むコマンドにはフックの JSON イベントが届きます。",
+ "zh-Hans": "编辑器分别保存可执行文件和各参数,直接对应 {0} 与其余 argv。Tatami 不拼接 shell 命令,不按空格拆分、不解释引号或展开变量。需要 shell 语法时,明确选择 {1},分别传入 {2} 和脚本。fish 使用 {3} 返回的路径,再分别传入 {4} 和命令文本。例如 {5} 绕过用户定义的 {6} 函数。钩子事件通过标准输入发送,读取 stdin 的命令会收到该 JSON。",
+ "zh-Hant": "編輯器分別儲存執行檔和各參數,直接對應 {0} 與其餘 argv。Tatami 不串接 shell 指令,不依空白拆分、不解讀引號或展開變數。需要 shell 語法時,明確選擇 {1},分別傳入 {2} 與指令稿。fish 使用 {3} 回傳的路徑,再分別傳入 {4} 和指令文字。例如 {5} 略過使用者定義的 {6} 函式。Hook 事件透過標準輸入傳送,讀取 stdin 的指令會收到該 JSON。"
+ },
+ "0402d7d237fba869": {
+ "en": "The full event, standard-input, environment, working-directory, and timeout contracts are documented in the [configuration reference]({0}).",
+ "ko": "이벤트, 표준 입력, 환경 변수, 작업 폴더, 제한 시간의 전체 규칙은 [설정 안내]({0})에서 확인해요.",
+ "ja": "イベント、標準入力、環境、作業フォルダ、時間制限の詳しい規則は[設定ガイド]({0})にあります。",
+ "zh-Hans": "完整事件、标准输入、环境、工作目录和超时约定见[配置参考]({0})。",
+ "zh-Hant": "完整事件、標準輸入、環境、工作目錄與逾時約定見[設定參考]({0})。"
+ },
+ "22c2509beff8b98c": {
+ "en": "JSON output and exit status",
+ "ko": "JSON 출력과 종료 상태",
+ "ja": "JSON 出力と終了状態",
+ "zh-Hans": "JSON 输出与退出状态",
+ "zh-Hant": "JSON 輸出與結束狀態"
+ },
+ "355dd916c2e42727": {
+ "en": "Append {0} to a leaf command. The option belongs after that command; parent positions such as {1} are not accepted.",
+ "ko": "최종 명령 뒤에 {0}을 붙이세요. {1} 같은 상위 위치에는 쓸 수 없어요.",
+ "ja": "末端コマンドの後に {0} を付けます。{1} のような親の位置には指定できません。",
+ "zh-Hans": "在叶子命令之后加上 {0},不能放在 {1} 这样的父级位置。",
+ "zh-Hant": "在末端指令之後加上 {0},不能放在 {1} 這樣的父層位置。"
+ },
+ "1889f03338b79bba": {
+ "en": "Examples:",
+ "ko": "예시:",
+ "ja": "例:",
+ "zh-Hans": "示例:",
+ "zh-Hant": "範例:"
+ },
+ "c42e23f83950322d": {
+ "en": "Successful JSON is written to standard output. A dispatcher result has this shape:",
+ "ko": "성공한 JSON은 표준 출력으로 나와요. 디스패처 결과는 다음 형태예요.",
+ "ja": "成功した JSON は標準出力へ出ます。操作結果は次の形です。",
+ "zh-Hans": "成功的 JSON 写入标准输出,分发器结果如下:",
+ "zh-Hant": "成功的 JSON 寫入標準輸出,分派器結果如下:"
+ },
+ "5502d0f55132d741": {
+ "en": "{0} and {1} are stable scripting fields; {2} follows the running app's language and is for display. Profile, workspace, app, hook, and mutation commands return structured objects with their identifiers and relevant metadata rather than wrapping plain text.",
+ "ko": "{0}과 {1}은 스크립트용으로 안정적으로 유지해요. {2}는 앱 언어에 따른 표시 문구예요. 프로필·작업 공간·앱·훅·변경 명령은 일반 문장을 감싸지 않고 식별자와 관련 정보가 있는 구조화된 객체를 반환해요.",
+ "ja": "{0} と {1} はスクリプト向けの固定フィールドで、{2} はアプリ言語に従う表示文です。プロファイル、ワークスペース、アプリ、フック、変更操作は、文を包むのではなく識別子と情報を持つ構造を返します。",
+ "zh-Hans": "{0} 和 {1} 是稳定脚本字段;{2} 跟随运行中应用的语言,用于显示。配置方案、工作区、应用、钩子和修改命令返回包含标识符与元数据的结构化对象,不包装普通文本。",
+ "zh-Hant": "{0} 與 {1} 是穩定的指令稿欄位;{2} 跟隨執行中 App 的語言,用於顯示。設定組合、工作空間、App、Hook 和修改指令回傳包含識別碼與中繼資料的結構化物件,不包裝一般文字。"
+ },
+ "34966d167d24741e": {
+ "en": "After arguments have parsed, failures use {0} on standard error and exit nonzero. ArgumentParser usage/help diagnostics remain plain text. A new CLI also rejects plain output from an older running Tatami when {1} was requested, instead of silently treating it as machine-readable data.",
+ "ko": "인자를 해석한 뒤 실패하면 표준 오류에 {0}을 쓰고 0이 아닌 코드로 끝나요. ArgumentParser의 사용법·도움말은 일반 텍스트예요. {1}을 요청했는데 이전 앱이 일반 문장을 반환하면 새 CLI는 기계 판독 데이터로 취급하지 않고 거부해요.",
+ "ja": "引数解析後の失敗は標準エラーに {0} を書き、非ゼロで終了します。ArgumentParser の案内は平文です。{1} の要求に古いアプリが平文を返す場合、新しい CLI は機械可読データとして扱わず拒否します。",
+ "zh-Hans": "参数解析后,失败会向标准错误写入 {0} 并以非零码退出。ArgumentParser 的用法和帮助仍为普通文本。请求 {1} 时,新 CLI 会拒绝旧应用返回的普通输出,不会静默当成机器可读数据。",
+ "zh-Hant": "參數解析後,失敗會向標準錯誤寫入 {0} 並以非零代碼結束。ArgumentParser 的用法與說明仍為一般文字。要求 {1} 時,新 CLI 會拒絕舊 App 回傳的一般輸出,不會靜默當成機器可讀資料。"
+ },
+ "f18dc24c496be544": {
+ "en": "Socket and development isolation",
+ "ko": "소켓과 개발 환경 분리",
+ "ja": "ソケットと開発環境の分離",
+ "zh-Hans": "套接字与开发隔离",
+ "zh-Hant": "Socket 與開發環境隔離"
+ },
+ "5518727c533ac773": {
+ "en": "By default both processes use {0} in the current user's Darwin temporary directory. Set {1} to an absolute path to isolate a development app/CLI pair; the app and CLI must receive exactly the same value.",
+ "ko": "기본적으로 두 프로세스는 현재 사용자의 Darwin 임시 폴더 안에 있는 {0}을 사용해요. 개발용 앱과 CLI를 분리하려면 {1}을 절대 경로로 정하고 두 프로세스에 정확히 같은 값을 전달하세요.",
+ "ja": "既定では両プロセスがユーザーの Darwin 一時フォルダの {0} を使います。開発用に分けるには {1} を絶対パスにし、アプリと CLI に同じ値を渡します。",
+ "zh-Hans": "默认两个进程使用当前用户 Darwin 临时目录中的 {0}。将 {1} 设为绝对路径可隔离开发应用与 CLI,二者必须收到完全相同的值。",
+ "zh-Hant": "預設兩個程序使用目前使用者 Darwin 暫存目錄中的 {0}。將 {1} 設為絕對路徑可隔離開發 App 與 CLI,兩者必須收到完全相同的值。"
+ },
+ "b580b42ba63082f7": {
+ "en": "The app owns the socket. If it is not running, the CLI exits nonzero and asks whether Tatami is running.",
+ "ko": "소켓은 앱이 소유해요. 앱이 꺼져 있으면 CLI는 실패 코드로 끝나고 Tatami 실행 여부를 확인하라고 알려줘요.",
+ "ja": "ソケットはアプリが持ちます。起動していない場合、CLI は非ゼロで終了して Tatami の起動確認を案内します。",
+ "zh-Hans": "套接字由应用持有。应用未运行时,CLI 以非零码退出并提示确认 Tatami 是否正在运行。",
+ "zh-Hant": "Socket 由 App 持有。App 未執行時,CLI 以非零代碼結束並提示確認 Tatami 是否正在執行。"
+ },
+ "99bc285b08bd99f7": {
+ "en": "Scripting examples",
+ "ko": "스크립트 예시",
+ "ja": "スクリプトの例",
+ "zh-Hans": "脚本示例",
+ "zh-Hant": "指令稿範例"
+ },
+ "f0bbc24800850c17": {
+ "en": "Select by UUID and let {0} validate the JSON contract:",
+ "ko": "UUID로 선택하고 {0}으로 JSON 규칙을 확인해요.",
+ "ja": "UUID で選び、{0} で JSON の規則を確認します。",
+ "zh-Hans": "使用 UUID 选择,并用 {0} 验证 JSON 约定:",
+ "zh-Hant": "使用 UUID 選擇,並用 {0} 驗證 JSON 約定:"
+ },
+ "e44edcd5d7ffbd5f": {
+ "en": "Fail fast when an accepted dispatcher command cannot be submitted:",
+ "ko": "명령을 전달하지 못하면 즉시 실패하도록 해요.",
+ "ja": "操作コマンドを送信できなければ、すぐ失敗させます。",
+ "zh-Hans": "无法提交操作命令时立即失败:",
+ "zh-Hant": "無法提交操作指令時立即失敗:"
+ },
+ "18cefeedf65fc6cd": {
+ "en": "Tatami reads its configuration from:",
+ "ko": "Tatami는 다음 경로에서 설정을 읽어요.",
+ "ja": "Tatami は次の場所から設定を読みます。",
+ "zh-Hans": "Tatami 从以下路径读取配置:",
+ "zh-Hant": "Tatami 從以下路徑讀取設定:"
+ },
+ "d57960fec693ced6": {
+ "en": "The path is XDG-aware. If {0} is set, the file lives at {1}. The file is created on first launch and written back whenever you change something in the app. Hand edits are picked up live, so you can keep it in your dotfiles and edit it in your editor.",
+ "ko": "XDG 경로를 지원해요. {0}이 있으면 {1}을 사용해요. 처음 실행할 때 파일을 만들고 앱에서 설정을 바꾸면 다시 저장해요. 직접 수정해도 바로 반영되므로 dotfiles에 두고 편집기에서 관리할 수 있어요.",
+ "ja": "XDG に対応し、{0} があれば {1} を使います。初回に作成し、アプリでの変更時に保存します。手での編集も即時に反映するため、dotfiles として管理できます。",
+ "zh-Hans": "路径支持 XDG。设置 {0} 后使用 {1}。首次启动创建文件,应用内更改时写回;手动编辑也实时生效,可纳入 dotfiles 管理。",
+ "zh-Hant": "路徑支援 XDG。設定 {0} 後使用 {1}。首次啟動建立檔案,App 內變更時寫回;手動編輯也即時生效,可納入 dotfiles 管理。"
+ },
+ "4aa0c55a726ddb74": {
+ "en": "External writers must either participate in {0} or write a temporary file and atomically replace {1}. Tatami detects and rejects an atomic replacement that races one of its own configuration transactions. In-place writers that keep the live file descriptor open across a rename (for example, opening {2}, then continuing to truncate or write that same descriptor while another process replaces the path) are not supported: POSIX rename cannot redirect future writes on an already-open descriptor to the new file. Editors that save through a temporary file and atomic replace satisfy the supported contract.",
+ "ko": "외부 프로그램은 {0}에 참여하거나 임시 파일을 쓴 뒤 {1}을 원자적으로 교체해야 해요. Tatami의 설정 트랜잭션과 충돌하는 교체는 감지해서 거부해요. {2}를 연 채 다른 프로세스가 이름을 바꾼 뒤에도 같은 파일 디스크립터에 계속 쓰는 방식은 지원하지 않아요. POSIX rename은 이미 열린 디스크립터를 새 파일로 연결하지 못해요. 임시 파일 저장 후 원자적으로 바꾸는 편집기를 사용하세요.",
+ "ja": "外部から書く場合は {0} に参加するか、一時ファイルを書いて {1} を原子的に置き換えます。設定トランザクションと競合する置き換えは拒否します。{2} を開いたまま名前変更後も同じ記述子へ書く方法は未対応です。POSIX rename は開いた記述子を新しいファイルへ付け替えません。一時ファイルから原子的に保存するエディタを使えます。",
+ "zh-Hans": "外部写入者必须参与 {0},或先写临时文件,再原子替换 {1}。Tatami 会检测并拒绝与自身配置事务竞争的替换。不支持跨重命名持有活动文件描述符继续写入,例如先打开 {2},其他进程替换路径后仍截断或写入该描述符;POSIX rename 无法重定向已打开的描述符。采用临时文件与原子替换的编辑器符合约定。",
+ "zh-Hant": "外部寫入者必須參與 {0},或先寫暫存檔,再不可分割地取代 {1}。Tatami 會偵測並拒絕與自身設定交易競爭的取代。不支援跨重新命名持有作用中文件描述符繼續寫入,例如先開啟 {2},其他程序取代路徑後仍截斷或寫入該描述符;POSIX rename 無法重新導向已開啟的描述符。採用暫存檔與不可分割取代的編輯器符合約定。"
+ },
+ "095e3c962475425c": {
+ "en": "Prefer a guided start? Tatami's first launch opens **Guided Setup**, which builds a draft from app metadata and connected displays and teaches each major feature in a safe virtual display. It writes this file only when you choose **Apply Setup**. Run it again from **Settings → General → Run Guided Setup**.",
+ "ko": "안내를 따라 시작하고 싶다면 처음 실행할 때 열리는 **가이드 설정**을 사용하세요. 앱 정보와 화면으로 초안을 만들고 안전한 가상 화면에서 기능을 익혀요. **설정 적용**을 눌러야 파일에 저장해요. **설정 → 일반 → 가이드 설정 실행**에서 다시 열 수 있어요.",
+ "ja": "案内に沿って始めるなら、初回の**ガイド設定**を使います。アプリ情報と画面から下書きを作り、仮想画面で練習できます。**設定を適用**したときだけ保存します。**設定 → 一般 → ガイド設定を開始**で再開できます。",
+ "zh-Hans": "首次启动会打开**引导设置**,根据应用信息和显示器创建草稿,在安全的虚拟桌面学习功能。选择**应用设置**时才写入文件,可从**设置 → 通用 → 运行引导设置**再次打开。",
+ "zh-Hant": "首次啟動會開啟**引導設定**,依 App 資訊與顯示器建立草稿,在安全的虛擬桌面學習功能。選擇**套用設定**時才寫入檔案,可從**設定 → 一般 → 執行引導設定**再次開啟。"
+ },
+ "870bff2f9de80465": {
+ "en": "The file has four top-level parts:",
+ "ko": "파일은 네 가지 최상위 부분으로 나뉘어요.",
+ "ja": "ファイルは四つの最上位部分に分かれます。",
+ "zh-Hans": "文件分为四个顶层部分:",
+ "zh-Hant": "檔案分為四個最上層部分:"
+ },
+ "592f82c33128f8ed": {
+ "en": "**{0}:** Global preferences described below",
+ "ko": "**{0}:** 아래에서 설명하는 전체 설정",
+ "ja": "**{0}:** 以下で説明する全体設定",
+ "zh-Hans": "**{0}:** 下文说明的全局偏好",
+ "zh-Hant": "**{0}:** 下文說明的全域偏好"
+ },
+ "dce7b50cc050965b": {
+ "en": "**{0}:** Apps that are part of every workspace, either tiled or kept on top",
+ "ko": "**{0}:** 모든 작업 공간에서 타일링하거나 항상 위에 둘 앱",
+ "ja": "**{0}:** 全ワークスペースでタイル表示または手前に置くアプリ",
+ "zh-Hans": "**{0}:** 在每个工作区平铺或置顶的应用",
+ "zh-Hant": "**{0}:** 在每個工作空間並排或置頂的 App"
+ },
+ "1ac0c4ad15c6deb1": {
+ "en": "**{0}:** Workspaces and their app assignments",
+ "ko": "**{0}:** 작업 공간과 앱 배정",
+ "ja": "**{0}:** ワークスペースとアプリの割り当て",
+ "zh-Hans": "**{0}:** 工作区及其应用分配",
+ "zh-Hant": "**{0}:** 工作空間及其 App 指派"
+ },
+ "70e351de160de798": {
+ "en": "**{0}:** Programs triggered by Tatami lifecycle and action-feedback events",
+ "ko": "**{0}:** 수명 주기·화면 알림 이벤트가 실행하는 프로그램",
+ "ja": "**{0}:** 状態や操作フィードバックで実行するプログラム",
+ "zh-Hans": "**{0}:** 由生命周期和操作反馈事件触发的程序",
+ "zh-Hant": "**{0}:** 由生命週期與操作回饋事件觸發的程式"
+ },
+ "89b0a957f7fd008a": {
+ "en": "Shortcut syntax",
+ "ko": "단축키 문법",
+ "ja": "ショートカットの書き方",
+ "zh-Hans": "快捷键语法",
+ "zh-Hant": "快速鍵語法"
+ },
+ "3cf4474ffd8c387f": {
+ "en": "Shortcuts use an skhd-style string: zero or more modifiers joined by {0}, then {1}, then the key.",
+ "ko": "skhd 형식 문자열을 써요. 보조키를 0개 이상 {0}로 연결하고 {1} 뒤에 키를 적어요.",
+ "ja": "skhd 形式を使います。0 個以上の修飾キーを {0} で結び、{1} の後にキーを書きます。",
+ "zh-Hans": "快捷键使用 skhd 风格字符串:零个或多个修饰键以 {0} 连接,后接 {1} 和按键。",
+ "zh-Hant": "快速鍵使用 skhd 風格字串:零個或多個輔助鍵以 {0} 連接,後接 {1} 與按鍵。"
+ },
+ "9881146d29d7f9d2": {
+ "en": "Modifiers: {0}, {1} (option), {2}, {3}. Keys are letters, digits, {4}, {5}, arrow keys ({6}/{7}/{8}/{9}), punctuation, etc.",
+ "ko": "보조키는 {0}, {1}(option), {2}, {3}이에요. 문자, 숫자, {4}, {5}, 화살표({6}/{7}/{8}/{9}), 문장부호 등을 키로 쓸 수 있어요.",
+ "ja": "修飾キーは {0}、{1}(option)、{2}、{3}。キーには文字、数字、{4}、{5}、矢印({6}/{7}/{8}/{9})、記号などを使えます。",
+ "zh-Hans": "修饰键:{0}、{1}(option)、{2}、{3}。按键可为字母、数字、{4}、{5}、方向键({6}/{7}/{8}/{9})、标点等。",
+ "zh-Hant": "輔助鍵:{0}、{1}(option)、{2}、{3}。按鍵可為字母、數字、{4}、{5}、方向鍵({6}/{7}/{8}/{9})、標點等。"
+ },
+ "99a52df3ff3d4994": {
+ "en": "Key",
+ "ko": "키",
+ "ja": "キー",
+ "zh-Hans": "键",
+ "zh-Hant": "鍵"
+ },
+ "baaddf70fb5d432b": {
+ "en": "Type",
+ "ko": "타입",
+ "ja": "型",
+ "zh-Hans": "类型",
+ "zh-Hant": "型別"
+ },
+ "21b111cbfe6e8fca": {
+ "en": "Default",
+ "ko": "기본값",
+ "ja": "デフォルト",
+ "zh-Hans": "默认值",
+ "zh-Hant": "預設值"
+ },
+ "526e0087cc3f254d": {
+ "en": "Description",
+ "ko": "설명",
+ "ja": "説明",
+ "zh-Hans": "说明",
+ "zh-Hant": "說明"
+ },
+ "8ee4a0afc8785ee3": {
+ "en": "Register Tatami as a login item so it starts at login.",
+ "ko": "로그인할 때 시작하도록 Tatami를 로그인 항목에 등록해요.",
+ "ja": "ログイン時に起動するよう、Tatami をログイン項目に登録します。",
+ "zh-Hans": "将 Tatami 注册为登录项,登录时启动。",
+ "zh-Hant": "將 Tatami 註冊為登入項目,登入時啟動。"
+ },
+ "7690499bd5924ef9": {
+ "en": "Periodically check for new releases.",
+ "ko": "새 릴리스를 주기적으로 확인해요.",
+ "ja": "新しいリリースを定期的に確認します。",
+ "zh-Hans": "定期检查新版本。",
+ "zh-Hant": "定期檢查新版本。"
+ },
+ "5fad63b2d83e5ef9": {
+ "en": "Background update-check frequency: {0}, {1}, or {2}.",
+ "ko": "백그라운드 업데이트 확인 주기: {0}, {1}, {2}.",
+ "ja": "バックグラウンドの更新確認頻度:{0}、{1}、{2}。",
+ "zh-Hans": "后台更新检查频率:{0}、{1} 或 {2}。",
+ "zh-Hant": "背景更新檢查頻率:{0}、{1} 或 {2}。"
+ },
+ "5a0605ce962cc4cc": {
+ "en": "Append diagnostic events to {0}. Truncated when first enabled.",
+ "ko": "진단 이벤트를 {0}에 추가해요. 처음 켤 때 기존 내용은 비워요.",
+ "ja": "診断イベントを {0} に追記します。初めて有効にすると既存内容を空にします。",
+ "zh-Hans": "向 {0} 追加诊断事件,首次启用时清空原内容。",
+ "zh-Hant": "向 {0} 附加診斷事件,首次啟用時清空原內容。"
+ },
+ "ed2f40aeeae7dae6": {
+ "en": "Bundle IDs of apps that own persistent elevated controls. When a registered process has an on-screen top-level AX window on a nonzero WindowServer layer, Tatami leaves the process unhidden but excludes its ordinary windows from focus, cycling, layout, drag, and membership actions. The windows can still appear in Mission Control.",
+ "ko": "계속 떠 있는 컨트롤을 가진 앱의 번들 ID예요. 등록한 프로세스가 일반 레이어 밖에 보이는 최상위 AX 창을 소유하면 숨기지 않되, 일반 창은 포커스·전환·배치·드래그·배정에서 제외해요. Mission Control에는 보일 수 있어요.",
+ "ja": "常に浮いているコントロールを持つアプリの ID です。登録したプロセスが非ゼロレイヤーの最上位 AX ウインドウを表示している場合は隠さず、通常ウインドウを各自動操作から外します。Mission Control には表示されることがあります。",
+ "zh-Hans": "拥有持久高层控件的应用包 ID。注册进程在非零 WindowServer 层有可见顶级 AX 窗口时,Tatami 保持进程可见,但把普通窗口排除在焦点、循环切换、布局、拖移和成员操作之外;它们仍可能出现在 Mission Control。",
+ "zh-Hant": "擁有持續高層控制項的 App 套件 ID。註冊程序在非零 WindowServer 層有可見最上層 AX 視窗時,Tatami 保持程序可見,但把一般視窗排除在焦點、循環切換、排列、拖移與成員操作之外;它們仍可能出現在 Mission Control。"
+ },
+ "49759af7a06b41b7": {
+ "en": "This is an explicit per-app exception, not a blanket floating-window rule. A registered app follows normal hide behavior whenever no matching elevated top-level window is present. Tatami re-evaluates it during each workspace or Borrow visibility transaction.",
+ "ko": "앱별로 명시한 예외이며, 모든 떠 있는 창에 적용하는 규칙은 아니에요. 조건에 맞는 최상위 창이 없으면 일반 숨기기를 적용하고, 작업 공간·빌려오기 표시를 바꿀 때마다 다시 확인해요.",
+ "ja": "アプリごとの明示的な例外で、浮いたウインドウ全体への規則ではありません。条件に合う最上位ウインドウがなければ通常どおり隠し、ワークスペースや借りる表示を変えるたびに確認します。",
+ "zh-Hans": "这是明确的逐应用例外,不是所有浮动窗口的通用规则。没有匹配的高层顶级窗口时,应用恢复普通隐藏;每次工作区或借用可见性事务都会重新评估。",
+ "zh-Hant": "這是明確的逐 App 例外,不是所有浮動視窗的通用規則。沒有符合的高層最上層視窗時,App 恢復一般隱藏;每次工作空間或借用顯示交易都會重新評估。"
+ },
+ "0c78e4ce8155990c": {
+ "en": "For symptoms, app examples, and the Settings UI workflow, see [Troubleshooting]({0}).",
+ "ko": "증상과 앱 예시, 설정 화면의 조작 순서는 [문제 해결]({0})에서 확인해요.",
+ "ja": "症状、アプリ例、設定での操作は[トラブルシューティング]({0})を参照してください。",
+ "zh-Hans": "症状、应用示例和设置操作流程见[故障排查]({0})。",
+ "zh-Hant": "症狀、App 範例與設定操作流程見[疑難排解]({0})。"
+ },
+ "9ab384e5fb3e9dfe": {
+ "en": "Show the active workspace's icon in the menu bar.",
+ "ko": "메뉴 막대에 활성 작업 공간 아이콘을 보여줘요.",
+ "ja": "現在のワークスペースのアイコンをメニューバーに表示します。",
+ "zh-Hans": "在菜单栏显示活动工作区图标。",
+ "zh-Hant": "在選單列顯示作用中工作空間圖像。"
+ },
+ "d6542f975c257474": {
+ "en": "Show the active workspace's name in the menu bar.",
+ "ko": "메뉴 막대에 활성 작업 공간 이름을 보여줘요.",
+ "ja": "現在のワークスペース名をメニューバーに表示します。",
+ "zh-Hans": "在菜单栏显示活动工作区名称。",
+ "zh-Hant": "在選單列顯示作用中工作空間名稱。"
+ },
+ "fd16b7a4894a0858": {
+ "en": "Show the active profile's icon (only when more than one profile exists).",
+ "ko": "프로필이 여러 개일 때 활성 프로필 아이콘을 보여줘요.",
+ "ja": "複数のプロファイルがある場合、現在のアイコンを表示します。",
+ "zh-Hans": "有多个配置方案时显示活动方案图标。",
+ "zh-Hant": "有多個設定組合時顯示作用中組合圖像。"
+ },
+ "e5105c0ecde2b59b": {
+ "en": "Show the active profile's name (only when more than one profile exists).",
+ "ko": "프로필이 여러 개일 때 활성 프로필 이름을 보여줘요.",
+ "ja": "複数のプロファイルがある場合、現在の名前を表示します。",
+ "zh-Hans": "有多个配置方案时显示活动方案名称。",
+ "zh-Hant": "有多個設定組合時顯示作用中組合名稱。"
+ },
+ "a5a392a180524b4a": {
+ "en": "Brief on-screen feedback confirming actions. The configuration table keeps its historical {0} name for compatibility; the app calls this **On-Screen Feedback**. {1} is the master switch, and the remaining keys choose which actions show feedback.",
+ "ko": "동작 결과를 짧게 알려주는 화면 알림이에요. 호환성을 위해 설정 표의 예전 이름 {0}을 유지하지만 앱에서는 **화면 알림**이라고 불러요. {1}이 전체 스위치이고 나머지 키는 알림을 표시할 동작을 정해요.",
+ "ja": "操作の結果を短く示すフィードバックです。互換性のためテーブル名 {0} を保ちますが、アプリでは**操作フィードバック**と呼びます。{1} が全体スイッチで、ほかのキーは対象の操作を選びます。",
+ "zh-Hans": "用于确认操作的简短反馈。为兼容保留历史表名 {0},应用中称为**操作反馈**。{1} 是总开关,其余键选择哪些操作显示反馈。",
+ "zh-Hant": "用來確認操作的簡短回饋。為相容保留歷史表名 {0},App 中稱為**操作回饋**。{1} 是總開關,其餘鍵選擇哪些操作顯示回饋。"
+ },
+ "0e841e60c6baff3a": {
+ "en": "Master switch for all on-screen feedback.",
+ "ko": "모든 화면 알림의 전체 스위치예요.",
+ "ja": "すべての操作フィードバックの主スイッチです。",
+ "zh-Hans": "所有操作反馈的总开关。",
+ "zh-Hant": "所有操作回饋的總開關。"
+ },
+ "e431c216da4de570": {
+ "en": "Workspace name when switching.",
+ "ko": "전환할 때 작업 공간 이름을 보여줘요.",
+ "ja": "切り替え時にワークスペース名を表示します。",
+ "zh-Hans": "切换时显示工作区名称。",
+ "zh-Hant": "切換時顯示工作空間名稱。"
+ },
+ "34e6d00d7796557a": {
+ "en": "Compact app/window switcher while holding the window-switch shortcut modifier. Continue with the shortcut or arrow keys; commit with Return, modifier release, or a click; cancel with Escape. A quick press does not show it.",
+ "ko": "창 전환 보조키를 누르고 있으면 앱·창 목록이 나타나요. 단축키나 화살표로 이동하고 Return·보조키 해제·클릭으로 선택해요. Escape로 취소하며 짧게 누를 때는 목록을 띄우지 않아요.",
+ "ja": "切り替え修飾キーを保持すると一覧が出ます。キーや矢印で移動し、Return、修飾キーを離す、クリックで確定します。Escape で取り消し、短押しでは表示しません。",
+ "zh-Hans": "按住窗口切换修饰键显示紧凑列表。用快捷键或方向键继续,Return、释放修饰键或点击提交,Escape 取消。轻按不显示列表。",
+ "zh-Hant": "按住視窗切換輔助鍵顯示精簡清單。用快速鍵或方向鍵繼續,Return、放開輔助鍵或點按確認,Escape 取消。輕按不顯示清單。"
+ },
+ "620f171066f60a16": {
+ "en": "Profile name when switching profiles (manual or auto).",
+ "ko": "수동·자동으로 프로필을 바꿀 때 이름을 보여줘요.",
+ "ja": "手動・自動のプロファイル切り替えで名前を表示します。",
+ "zh-Hans": "手动或自动切换方案时显示名称。",
+ "zh-Hant": "手動或自動切換組合時顯示名稱。"
+ },
+ "c3c97e47acbec984": {
+ "en": "Always-on-top state changes for both workspace apps and Shared Apps. The key name is retained for compatibility.",
+ "ko": "작업 공간 앱과 공용 앱의 항상 위 상태 변경을 알려줘요. 키 이름은 호환성을 위해 유지해요.",
+ "ja": "ワークスペースのアプリと共有アプリの手前表示の変更を示します。キー名は互換性のため維持します。",
+ "zh-Hans": "显示工作区和共享应用的置顶状态变化,键名为兼容而保留。",
+ "zh-Hant": "顯示工作空間與共用 App 的置頂狀態變化,鍵名為相容而保留。"
+ },
+ "56dbc0cd51182ea9": {
+ "en": "App added to / removed from a workspace or Shared Apps.",
+ "ko": "앱을 작업 공간이나 공용 앱에 추가·제거했음을 알려줘요.",
+ "ja": "ワークスペースや共有アプリへの追加・削除を示します。",
+ "zh-Hans": "应用加入或移出工作区、共享应用时显示反馈。",
+ "zh-Hant": "App 加入或移出工作空間、共用 App 時顯示回饋。"
+ },
+ "9eaf6cf2662417f2": {
+ "en": "Tiling paused / resumed.",
+ "ko": "타일링을 멈추거나 다시 시작할 때 보여줘요.",
+ "ja": "タイル表示を一時停止/再開しました。",
+ "zh-Hans": "已暂停/继续平铺。",
+ "zh-Hant": "已暫停/繼續並排。"
+ },
+ "2b600dab21ce8e93": {
+ "en": "Fullscreen zoom entered / exited.",
+ "ko": "창 확대를 시작하거나 끝냈음을 알려줘요.",
+ "ja": "ワークスペースズームの開始・終了を示します。",
+ "zh-Hans": "进入或退出工作区全屏放大。",
+ "zh-Hant": "進入或離開工作空間全螢幕放大。"
+ },
+ "0fd8fedd7fc9a86a": {
+ "en": "Borrow or return a workspace, plus the direction-pick hint.",
+ "ko": "빌려오기·돌려보내기와 방향 선택 안내를 보여줘요.",
+ "ja": "借りる・返す操作と方向選択のヒントを表示します。",
+ "zh-Hans": "显示借用、归还及方向选择提示。",
+ "zh-Hant": "顯示借用、歸還及方向選擇提示。"
+ },
+ "944120f5ae728897": {
+ "en": "Layout commands without a visual cue of their own (balance).",
+ "ko": "균등 배치처럼 자체 시각 효과가 없는 명령 결과를 알려줘요.",
+ "ja": "均等配置など、独自の視覚変化を示さない操作の結果を表示します。",
+ "zh-Hans": "为均衡布局等没有独立视觉提示的命令显示反馈。",
+ "zh-Hant": "為平均排列等沒有獨立視覺提示的指令顯示回饋。"
+ },
+ "321c349c6eb19780": {
+ "en": "Screen position for compact action feedback: {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, or {8}. The interactive app/window switcher stays centered independently.",
+ "ko": "짧은 화면 알림의 위치예요. {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8} 중에서 골라요. 앱·창 선택 목록은 별도로 가운데에 나타나요.",
+ "ja": "短いフィードバックの位置です。{0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8} から選びます。対話式の一覧は独立して中央に表示します。",
+ "zh-Hans": "简短操作反馈的位置:{0}、{1}、{2}、{3}、{4}、{5}、{6}、{7} 或 {8}。交互式应用窗口列表独立保持居中。",
+ "zh-Hant": "簡短操作回饋的位置:{0}、{1}、{2}、{3}、{4}、{5}、{6}、{7} 或 {8}。互動式 App 視窗清單獨立維持置中。"
+ },
+ "b9fabb638c194e58": {
+ "en": "Overall size of compact action feedback: {0}, {1}, or {2}. The interactive app/window switcher keeps its fixed size.",
+ "ko": "짧은 화면 알림의 전체 크기예요. {0}, {1}, {2} 중에서 골라요. 앱·창 선택 목록은 고정 크기를 유지해요.",
+ "ja": "短いフィードバックの大きさです。{0}、{1}、{2} から選び、対話式一覧は固定サイズを保ちます。",
+ "zh-Hans": "简短操作反馈的整体大小:{0}、{1} 或 {2}。交互式应用窗口列表保持固定大小。",
+ "zh-Hant": "簡短操作回饋的整體大小:{0}、{1} 或 {2}。互動式 App 視窗清單維持固定大小。"
+ },
+ "39e3d88e9b58fe6a": {
+ "en": "How long feedback remains fully visible between its entrance and exit animations, in milliseconds. Feedback with a follow-up hint stays twice as long.",
+ "ko": "나타나고 사라지는 애니메이션 사이에 완전히 보이는 시간(ms)이에요. 후속 안내가 있으면 두 배 오래 보여줘요.",
+ "ja": "出入りのアニメーションの間、完全に表示する時間(ms)です。次の操作のヒントがある場合は 2 倍になります。",
+ "zh-Hans": "进入和退出动画之间完全可见的时长,单位毫秒。带后续提示时显示两倍时长。",
+ "zh-Hant": "進入和離開動畫之間完全可見的時間,單位毫秒。帶後續提示時顯示兩倍時間。"
+ },
+ "d41637fcd002340a": {
+ "en": "Pixels between adjacent tiled windows.",
+ "ko": "이웃한 타일링 창 사이의 픽셀 간격이에요.",
+ "ja": "隣接するタイル間のピクセル間隔です。",
+ "zh-Hans": "相邻平铺窗口之间的像素间距。",
+ "zh-Hant": "相鄰並排視窗之間的像素間距。"
+ },
+ "4243aad7f3a2ec9c": {
+ "en": "Pixels between the tiles and the screen edge.",
+ "ko": "타일과 화면 가장자리 사이의 픽셀 간격이에요.",
+ "ja": "タイルと画面の端のピクセル間隔です。",
+ "zh-Hans": "平铺区域与屏幕边缘之间的像素间距。",
+ "zh-Hant": "並排區域與螢幕邊緣之間的像素間距。"
+ },
+ "15189197a3fc7760": {
+ "en": "Re-equalize splits after every insert/remove. One of {0}, {1}, {2}, {3}. (Legacy bool values still decode: {4} → {5}, {6} → {7}.)",
+ "ko": "창을 넣거나 뺄 때 비율을 다시 맞춰요. {0}, {1}, {2}, {3} 중에서 골라요. 예전 bool 값도 {4} → {5}, {6} → {7}로 읽을 수 있어요.",
+ "ja": "追加・削除時に比率を整えます。{0}、{1}、{2}、{3} から選び、従来の bool も {4} → {5}、{6} → {7} として読めます。",
+ "zh-Hans": "每次插入或移除后重新均衡分割,可选 {0}、{1}、{2}、{3}。旧 bool 仍可解析:{4} → {5}、{6} → {7}。",
+ "zh-Hant": "每次插入或移除後重新平均調整分割,可選 {0}、{1}、{2}、{3}。舊 bool 仍可解析:{4} → {5}、{6} → {7}。"
+ },
+ "17b6da6a03976aa1": {
+ "en": "Default split axis when a new window splits a tile: {0} (aspect-based), {1}, {2}.",
+ "ko": "새 창이 들어올 때 기본으로 나누는 방향이에요. {0}(화면 비율 기준), {1}, {2}를 사용할 수 있어요.",
+ "ja": "新しいウインドウで分割するときの既定方向です。{0}(縦横比で判断)、{1}、{2} を使えます。",
+ "zh-Hans": "新窗口分割区域时的默认轴:{0}(按宽高比)、{1}、{2}。",
+ "zh-Hant": "新視窗分割區域時的預設軸:{0}(依寬高比)、{1}、{2}。"
+ },
+ "33a1d566c2760a0f": {
+ "en": "Which child of the new split holds the inserted window: {0} (top/left) or {1} (bottom/right).",
+ "ko": "새 창을 새 분할의 어느 쪽에 놓을지 정해요. {0}은 위·왼쪽, {1}은 아래·오른쪽이에요.",
+ "ja": "新しい分割のどちらへ入れるかを選びます。{0} は上・左、{1} は下・右です。",
+ "zh-Hans": "新分割中放置新窗口的一侧:{0}(上、左)或 {1}(下、右)。",
+ "zh-Hant": "新分割中放置新視窗的一側:{0}(上、左)或 {1}(下、右)。"
+ },
+ "175957a0258662a6": {
+ "en": "Workspaces always remember their layout. Split axes and ratios are persisted to disk and restored on the next launch. System sleep preserves the live tree; if macOS recreates a window surface on wake, Tatami reconnects it to the saved layout without writing the temporary wake-up state back to disk.",
+ "ko": "작업 공간은 항상 배치를 기억해요. 분할 방향과 비율을 디스크에 저장하고 재실행하면 복원해요. 잠자기 중에는 현재 트리를 유지해요. 깨어날 때 macOS가 창을 다시 만들면 저장한 배치에 연결하되 임시 상태를 디스크에 덮어쓰지 않아요.",
+ "ja": "ワークスペースは常に配置を覚えます。分割方向と比率を保存して再起動時に復元し、スリープ中はツリーを保ちます。復帰時に macOS が画面を作り直しても、保存済み配置へ再接続し、一時状態を上書きしません。",
+ "zh-Hans": "工作区始终记住布局。分割轴和比例保存到磁盘,重启时恢复;睡眠保留活动树。唤醒时若 macOS 重建窗口表面,Tatami 将其重新连接到保存的布局,不把临时唤醒状态写回磁盘。",
+ "zh-Hant": "工作空間始終記住排列。分割軸與比例儲存到磁碟,重新啟動時還原;睡眠保留作用中樹。喚醒時若 macOS 重建視窗表面,Tatami 將其重新連接到儲存的排列,不把暫時喚醒狀態寫回磁碟。"
+ },
+ "ed3fe2e278b34b23": {
+ "en": "Move the cursor to the window Tatami focuses. Directional focus, app/window switching, workspace changes, close refocus, Swap, toggling a split's orientation, and fullscreen zoom in either direction carry it to the new position. Always on Top and Leave As Is targets use their live window frame. When the switch came from activating an app outside Tatami, such as from the Dock or Spotlight, the cursor goes to the window the system actually raised rather than the workspace's most recently used one. The pointer is left alone whenever the pointer itself caused the change: clicking a window, releasing a drag, a drag that swaps or splits, and a drag that snaps back. Grow/shrink and Balance stay out too, so holding a key down does not re-center on every repeat.",
+ "ko": "Tatami가 포커스한 창으로 커서를 옮겨요. 방향 포커스, 앱·창 전환, 작업 공간 변경, 창 닫기 뒤 포커스, 교체, 분할 방향 변경, 확대·복귀에서 새 위치로 따라가요. 항상 위·그대로 두기 창은 실제 창 영역을 사용해요. Dock이나 Spotlight에서 앱을 켰다면 최근 창이 아니라 시스템이 실제로 올린 창을 따라가요. 클릭이나 드래그 놓기, 드래그 교체·삽입·원위치 복귀처럼 포인터가 시작한 변경에서는 포인터를 그대로 둬요. 늘리기·줄이기·균등 배치에서도 움직이지 않아 키를 누르고 있을 때마다 중앙으로 돌아가지 않아요.",
+ "ja": "Tatami が選んだウインドウへカーソルを移します。方向操作、アプリ・ウインドウ切り替え、ワークスペース変更、閉じた後、入れ替え、分割方向、ズームの往復に追従します。手前・そのままでは実際の枠を使います。Dock や Spotlight からの起動では、最近のウインドウでなくシステムが実際に上げたものへ移ります。クリック、ドラッグ完了、入れ替え・分割・戻しなどポインタ自身が原因の変更では動かしません。拡大・縮小・均等配置でも動かさず、キーの連続入力で毎回中央へ戻ることを防ぎます。",
+ "zh-Hans": "把指针移到 Tatami 聚焦的窗口。方向焦点、应用窗口切换、工作区变化、关闭后恢复焦点、交换、分割方向和全屏放大的进入退出都会移动指针。置顶和保持原样使用实际窗口边框。通过 Dock、Spotlight 等外部激活应用时,跟随系统实际置前的窗口,而非工作区最近窗口。点击、结束拖移、拖移交换或分割、拖移回弹等由指针引起的变化不移动指针。放大缩小和均衡也排除在外,避免按住键时每次都重新居中。",
+ "zh-Hant": "把游標移到 Tatami 聚焦的視窗。方向焦點、App 視窗切換、工作空間變化、關閉後還原焦點、交換、分割方向與全螢幕放大的進入離開都會移動游標。置頂和維持原狀使用實際視窗邊框。透過 Dock、Spotlight 等外部啟用 App 時,跟隨系統實際置前的視窗,而非工作空間最近視窗。點按、結束拖移、拖移交換或分割、拖移彈回等由游標引起的變化不移動游標。放大縮小與平均排列也排除在外,避免按住鍵時每次都重新置中。"
+ },
+ "492b3dd56892a142": {
+ "en": "Hide the cursor on a workspace switch until the mouse moves.",
+ "ko": "작업 공간을 바꾸면 마우스를 움직일 때까지 커서를 숨겨요.",
+ "ja": "ワークスペースの切り替え後、マウスを動かすまでカーソルを隠します。",
+ "zh-Hans": "切换工作区时隐藏指针,直到鼠标移动。",
+ "zh-Hant": "切換工作空間時隱藏游標,直到滑鼠移動。"
+ },
+ "c318c2f764972f72": {
+ "en": "Focus whatever window sits under the cursor as it moves.",
+ "ko": "커서가 움직이면 그 아래 창으로 포커스를 옮겨요.",
+ "ja": "カーソルの移動に合わせ、その下のウインドウを選びます。",
+ "zh-Hans": "指针移动时聚焦其下方窗口。",
+ "zh-Hant": "游標移動時聚焦其下方視窗。"
+ },
+ "cabf4dba2fdb85fd": {
+ "en": "When the focused window closes and focus would be stranded on a now-windowless app, move focus to the most recently used remaining window in the workspace (falling back through recency).",
+ "ko": "포커스한 창을 닫아 앱에 창이 남지 않으면 같은 작업 공간에서 최근에 쓴 남은 창을 순서대로 찾아 포커스를 옮겨요.",
+ "ja": "選んだウインドウを閉じてそのアプリに窓がなくなったら、同じワークスペースの残りから使用履歴順にフォーカスを移します。",
+ "zh-Hans": "聚焦窗口关闭且应用已无窗口时,按最近使用顺序,把焦点移到工作区中剩下的窗口。",
+ "zh-Hant": "聚焦視窗關閉且 App 已無視窗時,依最近使用順序,把焦點移到工作空間中剩下的視窗。"
+ },
+ "54b6fb91dbe84774": {
+ "en": "While focus-follows-mouse is on, don't shift focus to a window that fills the whole display (full-screen / maximized).",
+ "ko": "마우스에 따라 포커스를 옮길 때 화면 전체를 채운 창에는 전환하지 않아요.",
+ "ja": "マウスにフォーカスを追従させる際、画面全体を占める窓には移しません。",
+ "zh-Hans": "启用焦点跟随鼠标时,不切换到占满整个显示器的全屏或最大化窗口。",
+ "zh-Hant": "啟用焦點跟隨游標時,不切換到佔滿整部顯示器的全螢幕或最大化視窗。"
+ },
+ "5a8ea812bc5d3d1a": {
+ "en": "Modifier that temporarily suspends focus-follows-mouse: {0}, {1}, {2}, {3}, {4}.",
+ "ko": "마우스에 따른 포커스 이동을 잠시 멈출 보조키예요. {0}, {1}, {2}, {3}, {4} 중에서 골라요.",
+ "ja": "FFM を一時停止する修飾キーです。{0}、{1}、{2}、{3}、{4} から選びます。",
+ "zh-Hans": "临时暂停焦点跟随鼠标的修饰键:{0}、{1}、{2}、{3}、{4}。",
+ "zh-Hant": "暫時停止焦點跟隨游標的輔助鍵:{0}、{1}、{2}、{3}、{4}。"
+ },
+ "140b146ad27fd070": {
+ "en": "Wrap from the last workspace back to the first (and vice versa).",
+ "ko": "마지막 작업 공간 다음에는 처음으로, 처음 이전에는 마지막으로 이어져요.",
+ "ja": "最後のワークスペースから先頭へ、先頭から最後へループします。",
+ "zh-Hans": "从最后一个工作区继续切换到第一个,反之亦然。",
+ "zh-Hant": "從最後一個工作空間繼續切換至第一個,反之亦然。"
+ },
+ "ed02622e36789547": {
+ "en": "Skip workspaces with no running app when switching next/previous.",
+ "ko": "다음·이전 이동에서 실행 중인 앱이 없는 작업 공간을 건너뛰어요.",
+ "ja": "次・前への移動で、実行中アプリのないワークスペースを飛ばします。",
+ "zh-Hans": "前后切换时跳过没有运行应用的工作区。",
+ "zh-Hant": "前後切換時略過沒有執行中 App 的工作空間。"
+ },
+ "7df32864389ab7f9": {
+ "en": "Activating an app switches to the workspace that owns it.",
+ "ko": "앱을 활성화하면 소속 작업 공간으로 전환해요.",
+ "ja": "アプリを有効にすると、所属するワークスペースへ切り替えます。",
+ "zh-Hans": "激活应用时切换到其所属工作区。",
+ "zh-Hant": "啟用 App 時切換到其所屬工作空間。"
+ },
+ "abc1c4231d38f18a": {
+ "en": "Switch next/previous workspace across every display instead of only the display under the cursor. The key name is retained for compatibility.",
+ "ko": "다음·이전 작업 공간을 포인터가 있는 화면뿐 아니라 모든 화면에서 찾아요. 키 이름은 호환성을 위해 유지해요.",
+ "ja": "次・前のワークスペースをポインタの画面だけでなく全画面で探します。キー名は互換性のため保ちます。",
+ "zh-Hans": "在所有显示器间切换前后工作区,而非仅指针所在屏幕。键名为兼容而保留。",
+ "zh-Hant": "在所有顯示器間切換前後工作空間,而非僅游標所在螢幕。鍵名為相容而保留。"
+ },
+ "f815cd95e5570ed4": {
+ "en": "Use one global recent-workspace history across every display. If the target is already visible on another display, focus it there instead of moving it. Set it to {0} for a strict per-display history.",
+ "ko": "모든 화면이 하나의 최근 작업 공간 기록을 써요. 대상이 다른 화면에 이미 보이면 옮기지 않고 그 화면에서 포커스해요. 화면마다 기록을 나누려면 {0}로 설정하세요.",
+ "ja": "全画面で直前のワークスペース履歴を共有します。対象が別画面に見えていれば移動せずそこで選びます。画面別に分けるには {0} にします。",
+ "zh-Hans": "所有显示器共用最近工作区历史。目标已在其他显示器可见时,直接在那里聚焦,不移动它。设为 {0} 使用严格的逐显示器历史。",
+ "zh-Hant": "所有顯示器共用最近工作空間記錄。目標已在其他顯示器可見時,直接在那裡聚焦,不移動它。設為 {0} 使用嚴格的逐顯示器記錄。"
+ },
+ "e30974ff841a89d7": {
+ "en": "When the active workspace's last window closes with nothing tiled and no workspace-specific always-on-top window, switch to the recent workspace. Shared apps do not count because they join every workspace.",
+ "ko": "활성 공간의 마지막 창이 닫히고 타일링 창과 전용 항상 위 창이 없으면 최근 공간으로 이동해요. 공용 앱은 모든 공간에 있으므로 계산에서 제외해요.",
+ "ja": "最後の窓を閉じ、タイルやワークスペース固有の手前ウインドウがなくなったら直前のワークスペースへ移ります。全ワークスペースにいる共有アプリは数えません。",
+ "zh-Hans": "活动工作区最后一个窗口关闭,且没有平铺窗口或该工作区专属置顶窗口时,切换到最近工作区。共享应用不计入,因为它们加入所有工作区。",
+ "zh-Hant": "作用中工作空間最後一個視窗關閉,且沒有並排視窗或該工作空間專屬置頂視窗時,切換到最近工作空間。共用 App 不計入,因為它們加入所有工作空間。"
+ },
+ "baf318435e466e75": {
+ "en": "Next/previous-window switching granularity. {0} (default) switches app-by-app and recalls each app's most-recent window. {1} visits every window, including multiple windows of the same app. The active workspace's Tiled, Always on Top, and Leave As Is windows participate; while Borrow is active, the host and borrowed tiled blocks form one switching order. The key name is retained for compatibility.",
+ "ko": "창 전환 단위를 정해요. 기본값 {0}은 앱별로 전환하며 각 앱의 최근 창을 불러와요. {1}는 같은 앱의 여러 창까지 하나씩 방문해요. 활성 공간의 타일링·항상 위·그대로 두기 창이 참여하고, 빌려오는 동안 두 타일링 영역을 함께 전환해요. 키 이름은 호환성을 위해 유지해요.",
+ "ja": "切り替えの単位です。既定の {0} はアプリごとに最近の窓を選び、{1} は同じアプリの窓も一つずつ訪ねます。ワークスペース内のタイル・手前・そのままが参加し、借りている間は両側のタイルが一つの順番になります。キー名は互換性のため維持します。",
+ "zh-Hans": "前后窗口切换粒度。默认 {0} 按应用切换并恢复该应用最近窗口;{1} 遍历每个窗口,包括同一应用的多个窗口。活动工作区的平铺、置顶和保持原样窗口参与;借用时两侧平铺区域组成同一顺序。键名为兼容保留。",
+ "zh-Hant": "前後視窗切換粒度。預設 {0} 依 App 切換並還原該 App 最近視窗;{1} 走訪每個視窗,包含同一 App 的多個視窗。作用中工作空間的並排、置頂和維持原狀視窗參與;借用時兩側並排區域組成同一順序。鍵名為相容保留。"
+ },
+ "a97d3f754a86b72f": {
+ "en": "Include Shared Apps in the app/window switcher. During Borrow, shared non-tiled windows join the host and borrowed tiled blocks; {0} excludes every Shared App from switching everywhere.",
+ "ko": "앱·창 전환에 공용 앱을 포함해요. 빌려오기 중에는 타일링하지 않은 공용 창도 두 타일링 영역과 함께 참여해요. {0}면 어디서나 공용 앱을 전환 목록에서 제외해요.",
+ "ja": "共有アプリを一覧に含めます。借りている間はタイル外の共有窓も両側のタイルと一緒に参加し、{0} ならすべての共有アプリを除外します。",
+ "zh-Hans": "在应用窗口切换器中包含共享应用。借用时,非平铺共享窗口与两侧平铺区域一起参与;{0} 在所有场景中排除共享应用。",
+ "zh-Hant": "在 App 視窗切換器中包含共用 App。借用時,非並排共用視窗與兩側並排區域一起參與;{0} 在所有情境中排除共用 App。"
+ },
+ "48df6526939c7d11": {
+ "en": "Borrowing a workspace already beside the current one returns it and restores the host. {0} moves the borrowed workspace instead.",
+ "ko": "이미 옆에 있는 작업 공간을 다시 빌리면 돌려보내고 원래 공간을 복원해요. {0}면 빌려온 공간을 옮겨요.",
+ "ja": "すでに横にあるワークスペースを再度借りると返して元に戻します。{0} では借りたワークスペースを移動します。",
+ "zh-Hans": "再次借用已在旁边的工作区会归还并恢复主工作区;{0} 则移动借来的工作区。",
+ "zh-Hant": "再次借用已在旁邊的工作空間會歸還並還原主工作空間;{0} 則移動借來的工作空間。"
+ },
+ "b24130fbecb0c42e": {
+ "en": "_(unset)_",
+ "ko": "_(설정 안 함)_",
+ "ja": "_(未設定)_",
+ "zh-Hans": "_(未设置)_",
+ "zh-Hant": "_(未設定)_"
+ },
+ "9c69077de1ba5ecb": {
+ "en": "Where a borrow docks by default: {0}, {1}, {2}, {3}. Unset → the borrow combo waits for a direction key (h/j/k/l or arrows). A workspace's {4} overrides this.",
+ "ko": "기본 빌려오기 위치예요. {0}, {1}, {2}, {3}을 사용할 수 있어요. 설정하지 않으면 키 조합 뒤에 h/j/k/l 또는 화살표를 기다려요. 작업 공간의 {4}가 이 값보다 우선해요.",
+ "ja": "既定の借りる位置です。{0}、{1}、{2}、{3} を使えます。未設定ならキー操作の後に h/j/k/l または矢印を待ち、ワークスペースの {4} を優先します。",
+ "zh-Hans": "默认借用停靠位置:{0}、{1}、{2}、{3}。未设置时等待 h/j/k/l 或方向键;工作区的 {4} 会覆盖此值。",
+ "zh-Hant": "預設借用停靠位置:{0}、{1}、{2}、{3}。未設定時等待 h/j/k/l 或方向鍵;工作空間的 {4} 會覆寫此值。"
+ },
+ "25e64bfcff79432c": {
+ "en": "The borrowed block's share of the screen along the split axis (0.1…0.9). A workspace's {0} overrides this.",
+ "ko": "분할 방향에서 빌려온 영역이 차지하는 비율(0.1~0.9)이에요. 작업 공간의 {0}가 우선해요.",
+ "ja": "分割方向で借りた領域が占める割合(0.1〜0.9)です。ワークスペースの {0} が優先されます。",
+ "zh-Hans": "借用区域沿分割轴占屏幕的比例(0.1…0.9),工作区的 {0} 会覆盖此值。",
+ "zh-Hant": "借用區域沿分割軸佔螢幕的比例(0.1…0.9),工作空間的 {0} 會覆寫此值。"
+ },
+ "5043ef2deac34851": {
+ "en": "Recognize configured three- and four-finger trackpad swipes.",
+ "ko": "설정한 세 손가락·네 손가락 쓸기를 인식해요.",
+ "ja": "設定した 3 本指・4 本指のスワイプを認識します。",
+ "zh-Hans": "识别配置的三指和四指触控板滑动。",
+ "zh-Hant": "辨識設定的三指與四指觸控式軌跡板滑動。"
+ },
+ "5b08319cff52a079": {
+ "en": "Swipe distance required before the bound action runs (lower = more sensitive). Kept to two decimal places.",
+ "ko": "동작을 실행하기까지 필요한 쓸기 거리예요. 작을수록 민감하고 소수 둘째 자리까지 저장해요.",
+ "ja": "操作を実行するまでのスワイプ距離です。小さいほど敏感で、小数点以下 2 桁で保存します。",
+ "zh-Hans": "触发操作所需的滑动距离,值越低越灵敏,保留两位小数。",
+ "zh-Hant": "觸發操作所需的滑動距離,值越低越靈敏,保留兩位小數。"
+ },
+ "ab08f934f6a87e85": {
+ "en": "left → {0}, right → {1}",
+ "ko": "왼쪽 → {0}, 오른쪽 → {1}",
+ "ja": "左 → {0}、右 → {1}",
+ "zh-Hans": "左 → {0},右 → {1}",
+ "zh-Hant": "左 → {0},右 → {1}"
+ },
+ "a8f20579d609f88d": {
+ "en": "Actions for three-finger {0}, {1}, {2}, and {3} swipes. Missing directions are {4}.",
+ "ko": "세 손가락 {0}, {1}, {2}, {3} 쓸기의 동작이에요. 빠진 방향은 {4}로 처리해요.",
+ "ja": "3 本指の {0}、{1}、{2}、{3} の操作です。省略した方向は {4} です。",
+ "zh-Hans": "三指向 {0}、{1}、{2}、{3} 滑动的操作,缺少的方向为 {4}。",
+ "zh-Hant": "三指向 {0}、{1}、{2}、{3} 滑動的操作,缺少的方向為 {4}。"
+ },
+ "10b43315b4560a0c": {
+ "en": "all directions → {0}",
+ "ko": "모든 방향 → {0}",
+ "ja": "全方向 → {0}",
+ "zh-Hans": "所有方向 → {0}",
+ "zh-Hant": "所有方向 → {0}"
+ },
+ "e846378ef10ed66c": {
+ "en": "Actions for four-finger {0}, {1}, {2}, and {3} swipes.",
+ "ko": "네 손가락 {0}, {1}, {2}, {3} 쓸기의 동작이에요.",
+ "ja": "4 本指の {0}、{1}、{2}、{3} の操作です。",
+ "zh-Hans": "四指向 {0}、{1}、{2}、{3} 滑动的操作。",
+ "zh-Hant": "四指向 {0}、{1}、{2}、{3} 滑動的操作。"
+ },
+ "c5504ca7a2983bec": {
+ "en": "Each direction stores one action string. The app's nested action menu is the easiest way to configure profile/workspace actions because it writes their stable UUIDs for you.",
+ "ko": "방향마다 동작 문자열 하나를 저장해요. 앱의 하위 메뉴를 쓰면 프로필·작업 공간의 안정적인 UUID를 자동으로 써줘서 쉽게 설정할 수 있어요.",
+ "ja": "各方向に操作文字列を一つ保存します。アプリの階層メニューを使うと安定した UUID を書くため、ワークスペースやプロファイルを簡単に指定できます。",
+ "zh-Hans": "每个方向保存一个动作字符串。应用的嵌套菜单会自动写入稳定 UUID,是配置方案和工作区操作最方便的方式。",
+ "zh-Hant": "每個方向儲存一個動作字串。App 的巢狀選單會自動寫入穩定 UUID,是設定組合與工作空間操作最方便的方式。"
+ },
+ "450c30d18db52f5f": {
+ "en": "Available fixed action strings:",
+ "ko": "사용할 수 있는 고정 동작 문자열이에요.",
+ "ja": "使用できる固定の操作文字列です。",
+ "zh-Hans": "可用的固定动作字符串:",
+ "zh-Hant": "可用的固定動作字串:"
+ },
+ "328886153cf4588a": {
+ "en": "**Workspaces:** {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}",
+ "ko": "**작업 공간:** {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}",
+ "ja": "**ワークスペース:** {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}",
+ "zh-Hans": "**工作区:** {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}",
+ "zh-Hant": "**工作空間:** {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}"
+ },
+ "b65fbea3c5c0f1cf": {
+ "en": "**Focus and displays:** {0}, {1}, {2}, {3}, {4}, {5}",
+ "ko": "**포커스와 화면:** {0}, {1}, {2}, {3}, {4}, {5}",
+ "ja": "**フォーカスと画面:** {0}、{1}、{2}、{3}、{4}、{5}",
+ "zh-Hans": "**焦点与显示器:** {0}、{1}、{2}、{3}、{4}、{5}",
+ "zh-Hant": "**焦點與顯示器:** {0}、{1}、{2}、{3}、{4}、{5}"
+ },
+ "da0c222b3373ea19": {
+ "en": "**Window switching:** {0}, {1}",
+ "ko": "**창 전환:** {0}, {1}",
+ "ja": "**ウインドウ切り替え:** {0}、{1}",
+ "zh-Hans": "**窗口切换:** {0}、{1}",
+ "zh-Hant": "**視窗切換:** {0}、{1}"
+ },
+ "b355850aa6e387c7": {
+ "en": "**Layout:** {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}",
+ "ko": "**배치:** {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}",
+ "ja": "**配置:** {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8}",
+ "zh-Hans": "**布局:** {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8}",
+ "zh-Hant": "**排列:** {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8}"
+ },
+ "ee9b8ce97006583d": {
+ "en": "**Apps and tiling:** {0}, {1}, {2}, {3}, {4} ({5} remains the stable configuration identifier for **Always on Top**)",
+ "ko": "**앱과 타일링:** {0}, {1}, {2}, {3}, {4}. **항상 위**의 설정 식별자는 호환성을 위해 {5}를 유지해요.",
+ "ja": "**アプリとタイル表示:** {0}、{1}、{2}、{3}、{4}。**常に手前**の設定識別子は {5} を保ちます。",
+ "zh-Hans": "**应用与平铺:** {0}、{1}、{2}、{3}、{4}。{5} 保留为**置顶**的稳定配置标识符。",
+ "zh-Hant": "**App 與並排:** {0}、{1}、{2}、{3}、{4}。{5} 保留為**置頂**的穩定設定識別碼。"
+ },
+ "6ae03259dbacac71": {
+ "en": "**Borrow:** {0}, {1}, {2}, {3}",
+ "ko": "**빌려오기:** {0}, {1}, {2}, {3}",
+ "ja": "**借りる:** {0}、{1}、{2}、{3}",
+ "zh-Hans": "**借用:** {0}、{1}、{2}、{3}",
+ "zh-Hant": "**借用:** {0}、{1}、{2}、{3}"
+ },
+ "bd8bfb36ae0746da": {
+ "en": "**Unbound:** {0}",
+ "ko": "**연결 안 함:** {0}",
+ "ja": "**未割り当て:** {0}",
+ "zh-Hans": "**未绑定:** {0}",
+ "zh-Hant": "**未綁定:** {0}"
+ },
+ "c2bfb109aad48c9b": {
+ "en": "Target-specific actions append a stable identifier: {0}, {1}, {2}, or {3}. Borrowing a specific workspace is available while its profile is active; activating or assigning to a workspace can switch to its owning profile first.",
+ "ko": "특정 대상을 지정할 땐 안정적인 식별자를 붙여요. {0}, {1}, {2}, {3}을 사용할 수 있어요. 특정 작업 공간은 소속 프로필이 활성 상태일 때 빌릴 수 있어요. 활성화나 배정은 먼저 소속 프로필로 전환할 수 있어요.",
+ "ja": "特定の対象には {0}、{1}、{2}、{3} のように安定した ID を付けます。借りる操作は所属プロファイルが有効な間に使え、有効化・割り当てでは先にそのプロファイルへ切り替えられます。",
+ "zh-Hans": "目标专用操作追加稳定标识符:{0}、{1}、{2} 或 {3}。借用指定工作区要求其方案处于活动状态;激活或分配工作区则可以先切换到所属方案。",
+ "zh-Hant": "目標專用操作附加穩定識別碼:{0}、{1}、{2} 或 {3}。借用指定工作空間要求其組合處於作用中;啟用或指派工作空間則可以先切換到所屬組合。"
+ },
+ "d107cbcd55ba4d61": {
+ "en": "Legacy configurations with {0} or {1} migrate automatically: left/right keep their previous next/previous-workspace behavior on that finger count, and the other directions and finger count remain unbound.",
+ "ko": "예전 {0}, {1} 설정은 자동으로 옮겨져요. 해당 손가락 수의 좌우는 기존 다음·이전 작업 공간 동작을 유지하고, 나머지 방향과 손가락 수는 연결하지 않아요.",
+ "ja": "以前の {0}、{1} は自動移行します。その指の本数の左右は従来の次・前を保ち、ほかの方向と本数は未割り当てになります。",
+ "zh-Hans": "旧配置 {0} 或 {1} 会自动迁移:该指数量的左右保留原有前后工作区行为,其他方向及指数量保持未绑定。",
+ "zh-Hant": "舊設定 {0} 或 {1} 會自動移轉:該手指數量的左右保留原有前後工作空間行為,其他方向及手指數量維持未綁定。"
+ },
+ "80dbf64f8c686da8": {
+ "en": "Small corner dots that identify zoomed, always-on-top, and borrowed windows.",
+ "ko": "확대·항상 위·빌려온 창을 구분하는 작은 모서리 표시예요.",
+ "ja": "ズーム・手前・借りた窓を示す、角の小さな印です。",
+ "zh-Hans": "角落的小圆点用于识别放大、置顶和借来的窗口。",
+ "zh-Hant": "角落的小圓點用來識別放大、置頂與借來的視窗。"
+ },
+ "13072f709fad001c": {
+ "en": "Dot on the workspace's fullscreen-zoomed window (shown while it's focused).",
+ "ko": "작업 공간 크기로 확대한 창에 포커스가 있을 때 점을 표시해요.",
+ "ja": "ワークスペース全体に広げた窓が選ばれている間、点を表示します。",
+ "zh-Hans": "聚焦工作区全屏放大的窗口时显示圆点。",
+ "zh-Hant": "聚焦工作空間全螢幕放大的視窗時顯示圓點。"
+ },
+ "4a9fcf78a1d17e4d": {
+ "en": "Fullscreen dot color ({0}).",
+ "ko": "확대 표시의 색상({0})이에요.",
+ "ja": "ズームの点の色({0})です。",
+ "zh-Hans": "全屏圆点颜色({0})。",
+ "zh-Hant": "全螢幕圓點顏色({0})。"
+ },
+ "945cd60f4441bd1c": {
+ "en": "Dot on always-on-top windows, always visible so their state is clear at a glance.",
+ "ko": "항상 위 창에는 상태를 바로 알 수 있도록 점을 계속 표시해요.",
+ "ja": "手前の窓は状態がすぐわかるよう、常に点を表示します。",
+ "zh-Hans": "置顶窗口始终显示圆点,便于一眼识别状态。",
+ "zh-Hant": "置頂視窗始終顯示圓點,便於一眼辨識狀態。"
+ },
+ "29de6aaa0935da86": {
+ "en": "Always-on-top dot color ({0}).",
+ "ko": "항상 위 표시의 색상({0})이에요.",
+ "ja": "手前表示の点の色({0})です。",
+ "zh-Hans": "置顶圆点颜色({0})。",
+ "zh-Hant": "置頂圓點顏色({0})。"
+ },
+ "f93d316d77ced73c": {
+ "en": "Badge each borrowed window with its workspace icon while the borrow is on screen.",
+ "ko": "빌려온 창에 소속 작업 공간 아이콘을 표시해요.",
+ "ja": "借りている間、各窓に所属ワークスペースのアイコンを表示します。",
+ "zh-Hans": "借用可见时,为每个借来的窗口显示工作区图标。",
+ "zh-Hant": "借用可見時,為每個借來的視窗顯示工作空間圖像。"
+ },
+ "ebd5c2c8a2141f52": {
+ "en": "Borrow badge color ({0}).",
+ "ko": "빌려오기 표시의 색상({0})이에요.",
+ "ja": "借りるバッジの色({0})です。",
+ "zh-Hans": "借用标记颜色({0})。",
+ "zh-Hant": "借用標記顏色({0})。"
+ },
+ "8a88c100fd9dfc86": {
+ "en": "Dot diameter in points. The borrow badge is drawn larger so its glyph stays legible.",
+ "ko": "점의 지름을 포인트로 정해요. 빌려오기 표시는 아이콘이 잘 보이도록 더 크게 그려요.",
+ "ja": "点の直径をポイントで指定します。借りるバッジは記号が読めるよう大きく描きます。",
+ "zh-Hans": "圆点直径,单位点。借用标记绘制得更大,确保符号清晰。",
+ "zh-Hant": "圓點直徑,單位點。借用標記繪製得更大,確保符號清楚。"
+ },
+ "87e28b72b6c39f84": {
+ "en": "Window corner the dot anchors to: {0}, {1}, {2}, {3}.",
+ "ko": "점을 놓을 창 모서리예요. {0}, {1}, {2}, {3} 중에서 골라요.",
+ "ja": "点を置く窓の角です。{0}、{1}、{2}、{3} から選びます。",
+ "zh-Hans": "圆点所在窗口角落:{0}、{1}、{2}、{3}。",
+ "zh-Hant": "圓點所在視窗角落:{0}、{1}、{2}、{3}。"
+ },
+ "04ddf844990af11c": {
+ "en": "Fade the dot while the cursor is over it.",
+ "ko": "커서가 점 위에 있으면 흐리게 보여줘요.",
+ "ja": "カーソルが点に重なる間は薄くします。",
+ "zh-Hans": "指针停在圆点上方时淡化。",
+ "zh-Hant": "游標停在圓點上方時淡化。"
+ },
+ "a5d6a63952dcb3d3": {
+ "en": "Most values are skhd-style shortcut strings (see above). Omitting a key leaves that action unbound. A brand-new config (no {0} yet) is seeded with a recommended starter set. See [Recommended defaults]({1}). You can freely re-record or clear every shortcut. The exceptions are the three {2} arrays below, which drive the per-workspace **key equivalent** model.",
+ "ko": "대부분 skhd 형식 단축키 문자열이에요. 키를 생략하면 동작에 연결하지 않아요. {0}이 없는 새 설정에는 [권장 기본값]({1})을 넣어요. 모든 단축키는 다시 기록하거나 지울 수 있어요. 아래 세 {2} 배열은 작업 공간 **키**를 정하는 별도 모델이에요.",
+ "ja": "多くは skhd 形式で、省略した操作は未割り当てです。{0} がない新規設定には[推奨値]({1})を入れます。すべて変更・削除でき、下の三つの {2} 配列だけはワークスペースの**キー**を定める別の仕組みです。",
+ "zh-Hans": "多数值为 skhd 风格字符串,省略键则不绑定。没有 {0} 的新配置会加入[推荐默认值]({1}),所有快捷键都可重新录制或清除。例外是下方三个 {2} 数组,用于每工作区的**按键**模型。",
+ "zh-Hant": "多數值為 skhd 風格字串,省略鍵則不綁定。沒有 {0} 的新設定會加入[建議預設值]({1}),所有快速鍵都可重新錄製或清除。例外是下方三個 {2} 陣列,用於每個工作空間的**按鍵**模型。"
+ },
+ "a7688cdea62afca2": {
+ "en": "Workspace keys (switch / assign / borrow)",
+ "ko": "작업 공간 키: 전환·배정·빌려오기",
+ "ja": "ワークスペースのキー:切り替え・割り当て・借りる",
+ "zh-Hans": "工作区按键:切换、分配、借用",
+ "zh-Hant": "工作空間按鍵:切換、指派、借用"
+ },
+ "32aa316eb4ee0cc8": {
+ "en": "Instead of binding three separate shortcuts per workspace, give each workspace a single one-character **key equivalent** ({0} on the workspace) and hold it with one of three global modifier combos to choose the action:",
+ "ko": "작업 공간마다 단축키 세 개를 만들 필요 없이 한 글자 **키**({0})를 정해요. 세 가지 공용 보조키 조합 중 하나와 함께 눌러 동작을 골라요.",
+ "ja": "ワークスペースごとに三つ作る代わりに、一文字の**キー**({0})を決め、三つの共通修飾キーのいずれかと組み合わせて操作を選びます。",
+ "zh-Hans": "不用为每个工作区绑定三个独立快捷键,只需指定一个字符的**按键**({0}),与三种全局修饰键组合之一搭配选择操作。",
+ "zh-Hant": "不用為每個工作空間綁定三個獨立快速鍵,只需指定一個字元的**按鍵**({0}),與三種全域輔助鍵組合之一搭配選擇操作。"
+ },
+ "64cff1319d2fd2cb": {
+ "en": "Action",
+ "ko": "동작",
+ "ja": "操作",
+ "zh-Hans": "操作",
+ "zh-Hant": "操作"
+ },
+ "1645631e2a4c3505": {
+ "en": "+ a workspace's key → **switch** to it",
+ "ko": "+ 작업 공간 키 → 해당 공간으로 **전환**",
+ "ja": "+ ワークスペースのキー → そのワークスペースへ**切り替え**",
+ "zh-Hans": "+ 工作区按键 → **切换**到该工作区",
+ "zh-Hant": "+ 工作空間按鍵 → **切換**到該工作空間"
+ },
+ "c056a310489b2c4f": {
+ "en": "+ a workspace's key → **assign** the focused app to it and switch there",
+ "ko": "+ 작업 공간 키 → 포커스한 앱을 **배정**하고 이동",
+ "ja": "+ ワークスペースのキー → アプリを**割り当て**て切り替え",
+ "zh-Hans": "+ 工作区按键 → **分配**聚焦应用并切换过去",
+ "zh-Hant": "+ 工作空間按鍵 → **指派**聚焦 App 並切換過去"
+ },
+ "744cd9ef02fa8b0e": {
+ "en": "+ a workspace's key → **borrow** it into the current screen",
+ "ko": "+ 작업 공간 키 → 현재 화면으로 **빌려오기**",
+ "ja": "+ ワークスペースのキー → 現在の画面に**借りる**",
+ "zh-Hans": "+ 工作区按键 → **借用**到当前屏幕",
+ "zh-Hant": "+ 工作空間按鍵 → **借用**到目前螢幕"
+ },
+ "7de04a7405e84617": {
+ "en": "Modifier tokens are {0}, {1}, {2}, {3}. An empty array disables that action's key-equivalent combo (so a bare key can't hijack typing).",
+ "ko": "보조키 토큰은 {0}, {1}, {2}, {3}이에요. 빈 배열이면 해당 조합을 꺼서 일반 글자 키가 입력을 가로채지 않아요.",
+ "ja": "修飾キーは {0}、{1}、{2}、{3} です。空配列は組み合わせを無効にし、文字入力が奪われないようにします。",
+ "zh-Hans": "修饰键令牌为 {0}、{1}、{2}、{3}。空数组禁用该动作的按键组合,避免单独按键截获输入。",
+ "zh-Hant": "輔助鍵代碼為 {0}、{1}、{2}、{3}。空陣列停用該動作的按鍵組合,避免單獨按鍵攔截輸入。"
+ },
+ "baa7a2c43b496608": {
+ "en": "The **Default** column above is the fallback used when the key is missing from an *existing* config. A fresh install instead seeds the recommended combos {0} and {1} (see [Recommended defaults]({2})).",
+ "ko": "위 **기본값**은 *기존* 설정에 키가 없을 때 쓰는 값이에요. 새로 설치하면 {0}, {1} 권장 조합을 넣어요. [권장 기본값]({2})을 확인하세요.",
+ "ja": "上の**既定値**は*既存*の設定にキーがない場合の値です。新規インストールでは {0}、{1} を設定します。[推奨値]({2})を参照してください。",
+ "zh-Hans": "上表的**默认值**用于*已有*配置缺少键时。全新安装会使用推荐组合 {0} 和 {1},详见[推荐默认值]({2})。",
+ "zh-Hant": "上表的**預設值**用於*既有*設定缺少鍵時。全新安裝會使用建議組合 {0} 與 {1},詳見[建議預設值]({2})。"
+ },
+ "4741a1079b3998fe": {
+ "en": "The same three modifiers also drive the **recent / next / previous** navigation targets, each with its own key:",
+ "ko": "같은 세 보조키는 각각 별도 키가 있는 **최근·다음·이전** 이동에도 사용돼요.",
+ "ja": "同じ三つの修飾キーで、個別のキーを持つ**直前・次・前**へも移れます。",
+ "zh-Hans": "同样三种修饰键也用于**最近、下一个、上一个**目标,各目标有自己的按键。",
+ "zh-Hant": "同樣三種輔助鍵也用於**最近、下一個、上一個**目標,各目標有自己的按鍵。"
+ },
+ "a31387870f327184": {
+ "en": "Key for the recent workspace (held with the switch / assign / borrow modifier).",
+ "ko": "최근 작업 공간 키예요. 전환·배정·빌려오기 보조키와 함께 눌러요.",
+ "ja": "直前のワークスペースのキーです。切り替え・割り当て・借りる修飾キーと使います。",
+ "zh-Hans": "最近工作区按键,与切换、分配或借用修饰键组合。",
+ "zh-Hant": "最近工作空間按鍵,與切換、指派或借用輔助鍵組合。"
+ },
+ "7d5055684df2bb65": {
+ "en": "Key for the next workspace.",
+ "ko": "다음 작업 공간 키예요.",
+ "ja": "次のワークスペースのキーです。",
+ "zh-Hans": "下一个工作区按键。",
+ "zh-Hant": "下一個工作空間按鍵。"
+ },
+ "25fce838b6df7818": {
+ "en": "Key for the previous workspace.",
+ "ko": "이전 작업 공간 키예요.",
+ "ja": "前のワークスペースのキーです。",
+ "zh-Hans": "上一个工作区按键。",
+ "zh-Hant": "上一個工作空間按鍵。"
+ },
+ "80331f2040adbf63": {
+ "en": "Any action above can be given an **explicit override** shortcut, which wins over the modifier + key combo:",
+ "ko": "각 동작에 **별도 단축키**를 지정할 수 있어요. 보조키와 작업 공간 키 조합보다 우선해요.",
+ "ja": "各操作に**明示的な上書き**を指定でき、修飾キーと作業キーの組み合わせより優先します。",
+ "zh-Hans": "上述操作都可设置**显式覆盖**快捷键,优先于修饰键加工作区按键组合。",
+ "zh-Hant": "上述操作都可設定**明確覆寫**快速鍵,優先於輔助鍵加工作空間按鍵組合。"
+ },
+ "304e72ff4a67b3b0": {
+ "en": "Per workspace: {0}, {1}, {2} (see workspaces below).",
+ "ko": "작업 공간별로 {0}, {1}, {2}를 지정할 수 있어요. 아래 작업 공간 항목을 확인하세요.",
+ "ja": "ワークスペースごとに {0}、{1}、{2} を指定できます。後のワークスペース項目を参照してください。",
+ "zh-Hans": "每个工作区可指定 {0}、{1}、{2},见下方工作区部分。",
+ "zh-Hant": "每個工作空間可指定 {0}、{1}、{2},見下方工作空間部分。"
+ },
+ "bcb97ebb14216c90": {
+ "en": "**Per navigation target:** {0}, {1}, and {2}. All use skhd strings.",
+ "ko": "**이동 대상별:** {0}, {1}, {2}. 모두 skhd 문자열을 사용해요.",
+ "ja": "**移動先ごと:** {0}、{1}、{2}。すべて skhd 形式です。",
+ "zh-Hans": "**每个导航目标:** {0}、{1}、{2},均使用 skhd 字符串。",
+ "zh-Hant": "**每個導覽目標:** {0}、{1}、{2},均使用 skhd 字串。"
+ },
+ "f5ac8474d8202c33": {
+ "en": "Workspace key equivalents and explicit workspace shortcuts are active only in their profile, so different profiles may reuse the same keys. Global shortcuts remain global and are checked against every profile. Copy and Duplicate also validate the selected shortcut changes before saving them into the target profile.",
+ "ko": "작업 공간 키와 별도 단축키는 소속 프로필에서만 활성화돼요. 다른 프로필은 같은 키를 쓸 수 있어요. 전역 단축키는 모든 프로필과 충돌 여부를 확인해요. 복사·복제도 대상 프로필에 저장하기 전에 선택한 키 변경을 검증해요.",
+ "ja": "ワークスペースのキーと上書きは所属プロファイル内だけで有効なので、別のプロファイルで再利用できます。グローバルキーは全体と照合し、コピー・複製も保存前に選んだ変更を検証します。",
+ "zh-Hans": "工作区按键和显式快捷键只在所属方案内生效,不同方案可复用。全局快捷键仍与所有方案检查冲突,复制操作也会在保存到目标方案前验证所选更改。",
+ "zh-Hant": "工作空間按鍵與明確的快速鍵只在所屬組合內生效,不同組合可重複使用。全域快速鍵仍與所有組合檢查衝突,拷貝與複製也會在儲存到目標組合前驗證所選變更。"
+ },
+ "95d478ea6837a1c4": {
+ "en": "Borrowing waits for a direction key (h/j/k/l or arrows) to place the workspace, unless a default edge is set ({0} or the workspace's {1}). {2} returns a borrowed workspace and restores the host to full screen.",
+ "ko": "기본 위치({0} 또는 작업 공간의 {1})가 없으면 빌려오기 뒤에 방향키 h/j/k/l 또는 화살표를 기다려요. {2}로 돌려보내면 원래 작업 공간을 화면 전체에 복원해요.",
+ "ja": "既定の位置({0} またはワークスペースの {1})がなければ、h/j/k/l か矢印を待ちます。{2} で返すと、元のワークスペースを画面全体へ戻します。",
+ "zh-Hans": "未设置默认边缘({0} 或工作区的 {1})时,借用会等待 h/j/k/l 或方向键。{2} 归还借用并恢复主工作区占满屏幕。",
+ "zh-Hant": "未設定預設邊緣({0} 或工作空間的 {1})時,借用會等待 h/j/k/l 或方向鍵。{2} 歸還借用並還原主工作空間佔滿螢幕。"
+ },
+ "73da6125a769586f": {
+ "en": "Action shortcuts",
+ "ko": "동작 단축키",
+ "ja": "操作ショートカット",
+ "zh-Hans": "操作快捷键",
+ "zh-Hant": "操作快速鍵"
+ },
+ "4f0c51115feba565": {
+ "en": "Move focus to the tile in that direction (crosses into a borrowed block at the edge)",
+ "ko": "해당 방향의 타일로 포커스를 옮겨요. 가장자리에서는 빌려온 영역으로 넘어가요.",
+ "ja": "指定方向のタイルを選びます。端では借りた領域へ移ります。",
+ "zh-Hans": "聚焦指定方向的平铺区域,在边缘可跨入借用区域。",
+ "zh-Hant": "聚焦指定方向的並排區域,在邊緣可跨入借用區域。"
+ },
+ "8e63133c8f18973b": {
+ "en": "Swap the focused tile in that direction",
+ "ko": "포커스한 타일을 해당 방향으로 교체해요.",
+ "ja": "選んだタイルを指定方向に入れ替えます。",
+ "zh-Hans": "沿指定方向交换聚焦区域。",
+ "zh-Hant": "沿指定方向交換聚焦區域。"
+ },
+ "d8675a47da2e3f28": {
+ "en": "Resize the focused tile",
+ "ko": "포커스한 타일의 크기를 바꿔요.",
+ "ja": "選んだタイルの大きさを変えます。",
+ "zh-Hans": "调整聚焦区域的大小。",
+ "zh-Hant": "調整聚焦區域的大小。"
+ },
+ "05280916a8f52fda": {
+ "en": "Toggle the focused split's orientation",
+ "ko": "포커스한 분할의 방향을 전환해요.",
+ "ja": "選んだ分割の向きを切り替えます。",
+ "zh-Hans": "切换聚焦分割的方向。",
+ "zh-Hant": "切換聚焦分割的方向。"
+ },
+ "1539b82963c27cb2": {
+ "en": "Zoom the focused window to fill the workspace",
+ "ko": "포커스한 창을 작업 공간 크기로 확대해요.",
+ "ja": "選んだ窓をワークスペース全体に広げます。",
+ "zh-Hans": "把聚焦窗口放大到整个工作区。",
+ "zh-Hant": "把聚焦視窗放大到整個工作空間。"
+ },
+ "7cbbfb513558f444": {
+ "en": "Apply the configured {0} axes. With Auto-balance off, rebuild the canonical BSP topology and ratios from the current window order.",
+ "ko": "설정한 {0} 축을 적용해요. 자동 균등 배치가 꺼져 있으면 현재 창 순서로 표준 BSP 구조와 비율을 다시 만들어요.",
+ "ja": "設定した {0} の軸を適用します。自動均等化がオフなら、現在の順番から標準 BSP 構造と比率を再構築します。",
+ "zh-Hans": "应用配置的 {0} 轴。关闭自动均衡时,根据当前窗口顺序重建标准 BSP 拓扑和比例。",
+ "zh-Hant": "套用設定的 {0} 軸。關閉自動平均排列時,依目前視窗順序重建標準 BSP 拓樸與比例。"
+ },
+ "d6ad9d1029c5c7cd": {
+ "en": "Switch apps or windows inside the visible Tatami workspace. Unlike Command-Tab, this excludes unrelated running apps; unlike Command-backtick, it can cross between apps.",
+ "ko": "현재 Tatami 작업 공간 안에서 앱이나 창을 전환해요. Command-Tab처럼 관련 없는 앱을 포함하지 않고, Command-backtick처럼 한 앱에만 머물지도 않아요.",
+ "ja": "今のワークスペース内でアプリや窓を切り替えます。Command-Tab のように無関係なアプリは含まず、Command-backtick のように一つのアプリにも限定されません。",
+ "zh-Hans": "在可见的 Tatami 工作区内切换应用或窗口。排除 Command-Tab 中无关的运行应用,也能跨越 Command-backtick 的单应用范围。",
+ "zh-Hant": "在可見的 Tatami 工作空間內切換 App 或視窗。排除 Command-Tab 中無關的執行中 App,也能跨越 Command-backtick 的單一 App 範圍。"
+ },
+ "a7bb193367f8ea20": {
+ "en": "Move the focused app to the next/previous workspace and follow it there",
+ "ko": "포커스한 앱을 다음·이전 작업 공간으로 옮기고 따라가요.",
+ "ja": "アプリを次・前のワークスペースへ移して、そこへ切り替えます。",
+ "zh-Hans": "把聚焦应用移到前后工作区并跟随过去。",
+ "zh-Hant": "把聚焦 App 移到前後工作空間並跟隨過去。"
+ },
+ "d55a595dd545842e": {
+ "en": "Return the borrowed workspace and restore the host to full screen",
+ "ko": "빌려온 작업 공간을 돌려보내고 원래 공간을 화면 전체로 복원해요.",
+ "ja": "借りたワークスペースを返し、元のワークスペースを画面全体に戻します。",
+ "zh-Hans": "归还借用工作区,恢复主工作区占满屏幕。",
+ "zh-Hant": "歸還借用工作空間,還原主工作空間佔滿螢幕。"
+ },
+ "df8ce4fe5c12c84b": {
+ "en": "Focus the active workspace on the next/previous display (loops around)",
+ "ko": "다음·이전 화면의 활성 작업 공간으로 포커스를 옮겨요. 끝에서는 처음으로 돌아가요.",
+ "ja": "次・前の画面のワークスペースへフォーカスを移し、端では循環します。",
+ "zh-Hans": "聚焦前后显示器的活动工作区,到边缘后循环。",
+ "zh-Hant": "聚焦前後顯示器的作用中工作空間,到邊緣後循環。"
+ },
+ "29cfcccfd2ef67da": {
+ "en": "Keep the focused app on top in the active workspace, adding it there if needed. Use the action again to return it to Tiled.",
+ "ko": "포커스한 앱을 현재 공간에서 항상 위에 둬요. 필요하면 공간에 추가해요. 다시 실행하면 타일링으로 돌아가요.",
+ "ja": "選んだアプリを現在のワークスペースで手前に置き、必要なら追加します。もう一度実行するとタイル表示に戻ります。",
+ "zh-Hans": "让聚焦应用在当前工作区置顶,需要时加入该工作区。再次执行恢复平铺。",
+ "zh-Hant": "讓聚焦 App 在目前工作空間置頂,需要時加入該工作空間。再次執行恢復並排。"
+ },
+ "f081a7c82055b5fe": {
+ "en": "Keep the focused app on top everywhere, adding it to Shared Apps if needed. Turning it off changes it to shared *Tiled*. Use {0} to remove membership.",
+ "ko": "포커스한 앱을 어디서나 항상 위에 둬요. 필요하면 공용 앱으로 추가해요. 끄면 공용 *타일링*으로 바뀌며, 소속을 제거하려면 {0}을 사용해요.",
+ "ja": "アプリをどこでも手前に置き、必要なら共有へ追加します。オフにすると共有の*タイル表示*になり、所属を外すには {0} を使います。",
+ "zh-Hans": "让聚焦应用在所有地方置顶,需要时加入共享应用。关闭后变为共享*平铺*,用 {0} 移除成员关系。",
+ "zh-Hant": "讓聚焦 App 在所有地方置頂,需要時加入共用 App。關閉後變為共用*並排*,用 {0} 移除成員關係。"
+ },
+ "e3905c6994536ed5": {
+ "en": "Add the focused window's app to the active workspace (or remove it if already a member)",
+ "ko": "현재 창의 앱을 활성 작업 공간에 추가해요. 이미 속해 있으면 제거해요.",
+ "ja": "現在の窓のアプリをワークスペースへ追加し、すでに所属していれば外します。",
+ "zh-Hans": "把聚焦窗口的应用加入活动工作区,已属于时则移除。",
+ "zh-Hant": "把聚焦視窗的 App 加入作用中工作空間,已屬於時則移除。"
+ },
+ "1f175c4070cfd28f": {
+ "en": "Add the focused app to Shared Apps (tiled into every workspace), or remove it if already shared",
+ "ko": "포커스한 앱을 공용 앱으로 추가해 모든 공간에 타일링해요. 이미 공용이면 제거해요.",
+ "ja": "アプリを共有に追加して全ワークスペースでタイル表示し、すでに共有なら外します。",
+ "zh-Hans": "把聚焦应用加入共享应用,在每个工作区平铺;已共享则移除。",
+ "zh-Hant": "把聚焦 App 加入共用 App,在每個工作空間並排;已共用則移除。"
+ },
+ "c5b014c27dd2e72b": {
+ "en": "Pause/resume tiling",
+ "ko": "타일링 일시 정지·재개",
+ "ja": "タイル表示を一時停止・再開",
+ "zh-Hans": "暂停或恢复平铺",
+ "zh-Hant": "暫停或恢復並排"
+ },
+ "55659d12e979e535": {
+ "en": "Recommended defaults",
+ "ko": "권장 기본값",
+ "ja": "推奨の初期設定",
+ "zh-Hans": "推荐默认值",
+ "zh-Hant": "建議預設值"
+ },
+ "d1619d7460e9e411": {
+ "en": "A brand-new config (no {0} yet) is seeded with a usable starter scheme so actions work out of the box. {1} drives the window/tile operations. workspace **switch** moves to {2} and **assign** to {3}, so a workspace's one-key equivalent never collides with a {4} focus key. Anything here can be re-recorded or cleared.",
+ "ko": "{0}이 없는 새 설정에는 바로 쓸 수 있는 기본 단축키를 넣어요. 창·타일 조작은 {1}, 작업 공간 **전환**은 {2}, **배정**은 {3}을 써요. 작업 공간 키와 {4} 포커스 키가 겹치지 않도록 나눴어요. 모두 다시 기록하거나 지울 수 있어요.",
+ "ja": "{0} がない新規設定にはすぐ使えるキーを入れます。窓・タイルは {1}、ワークスペースの**切り替え**は {2}、**割り当て**は {3} です。ワークスペースキーと {4} のフォーカスキーが衝突しないよう分けており、すべて変更・削除できます。",
+ "zh-Hans": "没有 {0} 的全新配置会提供可立即使用的组合。{1} 控制窗口和平铺,工作区**切换**使用 {2},**分配**使用 {3},避免单字符工作区键与 {4} 焦点键冲突。所有设置都可重新录制或清除。",
+ "zh-Hant": "沒有 {0} 的全新設定會提供可立即使用的組合。{1} 控制視窗與並排,工作空間**切換**使用 {2},**指派**使用 {3},避免單字元工作空間鍵與 {4} 焦點鍵衝突。所有設定都可重新錄製或清除。"
+ },
+ "5753ea3780f34ad1": {
+ "en": "Shortcut",
+ "ko": "단축키",
+ "ja": "ショートカット",
+ "zh-Hans": "快捷键",
+ "zh-Hant": "快速鍵"
+ },
+ "f01ee505f448080a": {
+ "en": "Focus left / down / up / right",
+ "ko": "왼쪽·아래·위·오른쪽으로 포커스",
+ "ja": "左・下・上・右へフォーカス",
+ "zh-Hans": "向左、下、上、右聚焦",
+ "zh-Hant": "向左、下、上、右聚焦"
+ },
+ "b738aca0c8de4a80": {
+ "en": "Swap left / down / up / right",
+ "ko": "왼쪽·아래·위·오른쪽과 교체",
+ "ja": "左・下・上・右と入れ替え",
+ "zh-Hans": "向左、下、上、右交换",
+ "zh-Hant": "向左、下、上、右交換"
+ },
+ "d4316cf35027dc4c": {
+ "en": "Grow / shrink",
+ "ko": "늘리기·줄이기",
+ "ja": "拡大・縮小",
+ "zh-Hans": "放大、缩小",
+ "zh-Hant": "放大、縮小"
+ },
+ "2a4450768dca4db7": {
+ "en": "Toggle orientation",
+ "ko": "방향 전환",
+ "ja": "方向を切り替え",
+ "zh-Hans": "切换方向",
+ "zh-Hant": "切換方向"
+ },
+ "c13ff73b9098f3d2": {
+ "en": "Toggle fullscreen",
+ "ko": "전체 화면 전환",
+ "ja": "フルスクリーンを切り替え",
+ "zh-Hans": "切换全屏",
+ "zh-Hant": "切換全螢幕"
+ },
+ "d05e07b7c14e596a": {
+ "en": "Balance",
+ "ko": "균등 배치",
+ "ja": "均等化",
+ "zh-Hans": "均衡",
+ "zh-Hant": "平衡"
+ },
+ "a01d634656f7f4b1": {
+ "en": "Switch to next / previous window",
+ "ko": "다음·이전 창으로 전환",
+ "ja": "次・前のウインドウへ切り替え",
+ "zh-Hans": "切换到前后窗口",
+ "zh-Hant": "切換到前後視窗"
+ },
+ "a52cee4d86d6bb42": {
+ "en": "Toggle Always on Top",
+ "ko": "항상 위 전환",
+ "ja": "「常に手前」を切り替え",
+ "zh-Hans": "切换置顶状态",
+ "zh-Hant": "切換置頂狀態"
+ },
+ "df196231cb997098": {
+ "en": "Toggle Shared Always on Top",
+ "ko": "공용 앱 항상 위 전환",
+ "ja": "共有アプリの「常に手前」を切り替え",
+ "zh-Hans": "切换共享置顶状态",
+ "zh-Hant": "切換共用置頂狀態"
+ },
+ "85694e7686217efe": {
+ "en": "Toggle tiling (pause)",
+ "ko": "타일링 일시 정지/재개",
+ "ja": "タイル表示を一時停止/再開",
+ "zh-Hans": "切换平铺暂停",
+ "zh-Hant": "切換並排暫停"
+ },
+ "0ebc8480c57e34f8": {
+ "en": "Toggle app in workspace",
+ "ko": "작업 공간 앱 소속 전환",
+ "ja": "ワークスペースへの所属を切り替え",
+ "zh-Hans": "切换应用的工作区归属",
+ "zh-Hant": "切換 App 的工作空間歸屬"
+ },
+ "34348c4933155029": {
+ "en": "Toggle app in Shared Apps",
+ "ko": "공용 앱 소속 전환",
+ "ja": "共有アプリへの所属を切り替え",
+ "zh-Hans": "切换应用的共享状态",
+ "zh-Hant": "切換 App 的共用狀態"
+ },
+ "25ed02ce5abbd53b": {
+ "en": "Move app to previous / next workspace",
+ "ko": "앱을 이전·다음 작업 공간으로 이동",
+ "ja": "アプリを前・次のワークスペースへ移動",
+ "zh-Hans": "把应用移到前后工作区",
+ "zh-Hant": "把 App 移到前後工作空間"
+ },
+ "59eda5182550814e": {
+ "en": "Focus previous / next display",
+ "ko": "이전·다음 화면으로 포커스",
+ "ja": "前・次の画面へフォーカス",
+ "zh-Hans": "聚焦前后显示器",
+ "zh-Hant": "聚焦前後顯示器"
+ },
+ "3e612edbbcaea45e": {
+ "en": "Return Borrowed Workspace",
+ "ko": "빌려온 작업 공간 돌려보내기",
+ "ja": "借りたワークスペースを戻す",
+ "zh-Hans": "归还借用工作区",
+ "zh-Hant": "歸還借用工作空間"
+ },
+ "e9825746e3c4d389": {
+ "en": "Recent / next / previous workspace key",
+ "ko": "최근·다음·이전 작업 공간 키",
+ "ja": "直前・次・前のワークスペースキー",
+ "zh-Hans": "最近、下一个、上一个工作区按键",
+ "zh-Hant": "最近、下一個、上一個工作空間按鍵"
+ },
+ "0c47ff67596636f7": {
+ "en": "Workspace **switch / assign / borrow** combine the modifiers above with each workspace's key equivalent and with the recent / next / previous keys.",
+ "ko": "작업 공간 **전환·배정·빌려오기**는 위 보조키와 각 작업 공간 키, 최근·다음·이전 키를 조합해요.",
+ "ja": "ワークスペースの**切り替え・割り当て・借りる**は、上の修飾キーを各ワークスペースキーや直前・次・前のキーと組み合わせます。",
+ "zh-Hans": "工作区的**切换、分配、借用**将上面的修饰键与每个工作区按键,以及最近、前后按键组合使用。",
+ "zh-Hant": "工作空間的**切換、指派、借用**將上面的輔助鍵與每個工作空間按鍵,以及最近、前後按鍵組合使用。"
+ },
+ "6f5afc0fe44765a0": {
+ "en": "Apps listed here are part of **every** workspace. Each carries a {0}: {1} (the default, tiles into each workspace's layout), {2} (shown as **Always on Top** in the app; untiled and kept above the tiles everywhere via a mirror), or {3} (shown as **Leave As Is**; left exactly where it is while remaining a member, but never tiled or mirrored). Each also has an optional {4} (bool, default {5}) that launches or reopens the app on workspace activation when it has no on-screen window, and an auto-written {6} (string, machine-managed and not meant to be set by hand).",
+ "ko": "여기에 있는 앱은 **모든** 작업 공간에 속해요. {0}은 {1}(기본 타일링), {2}(**항상 위**, 미러로 모든 공간 위에 표시), {3}(**그대로 두기**, 소속만 유지하고 위치·크기는 건드리지 않음)이에요. 화면에 창이 없을 때 열어주는 {4}(bool, 기본 {5})도 설정할 수 있어요. {6}은 자동으로 기록되는 관리용 문자열이므로 직접 정하지 않아요.",
+ "ja": "ここにあるアプリは**全ワークスペース**に属します。{0} は {1}(既定のタイル)、{2}(**常に手前**、ミラーで全ワークスペースの上へ)、{3}(**そのまま**、所属だけ保ち配置もミラーもしない)です。窓がなければ開く {4}(bool、既定 {5})も選べます。{6} は自動管理の文字列なので手で設定しません。",
+ "zh-Hans": "这里的应用加入**每个**工作区。{0} 可为 {1}(默认,参与平铺)、{2}(**置顶**,通过镜像在所有区域上方显示)或 {3}(**保持原样**,保留成员关系但不移动、平铺或镜像)。可选 {4}(bool,默认 {5})在激活工作区且没有屏幕内窗口时启动或重开应用;{6} 是自动写入的管理用字符串,不应手动设置。",
+ "zh-Hant": "這裡的 App 加入**每個**工作空間。{0} 可為 {1}(預設,參與並排)、{2}(**置頂**,透過鏡像在所有區域上方顯示)或 {3}(**維持原狀**,保留成員關係但不移動、並排或鏡像)。可選 {4}(bool,預設 {5})在啟用工作空間且沒有螢幕內視窗時啟動或重開 App;{6} 是自動寫入的管理用字串,不應手動設定。"
+ },
+ "dff2864c016768fc": {
+ "en": "Always-on-top windows stay above the layout without disabling SIP: Tatami mirrors them onto its own always-on-top panels via ScreenCaptureKit, which needs the **Screen Recording** permission (Settings → General → Permissions). The mirror hides and capture stops whenever the always-on-top app itself has focus. {0} apps need no such permission because the real window is never touched.",
+ "ko": "SIP를 끄지 않아도 창을 항상 위에 둘 수 있어요. ScreenCaptureKit 미러를 별도 패널에 표시하므로 **화면 기록** 권한이 필요해요(설정 → 일반 → 권한). 앱 자체에 포커스가 있으면 미러를 숨기고 캡처를 멈춰요. {0} 앱은 실제 창을 건드리지 않아 이 권한이 필요하지 않아요.",
+ "ja": "SIP を無効にせず、ScreenCaptureKit のミラーを手前のパネルに表示します。**画面収録**の許可が必要です(設定 → 一般 → 権限)。アプリ自体にフォーカスがある間はミラーを隠して収録を止めます。{0} は実際の窓を触らないため不要です。",
+ "zh-Hans": "置顶不需要关闭 SIP。Tatami 用 ScreenCaptureKit 把窗口镜像到置顶面板,需要**屏幕录制**权限(设置 → 通用 → 权限)。应用本身获得焦点时隐藏镜像并停止捕获。{0} 不操作实际窗口,无需该权限。",
+ "zh-Hant": "置頂不需要關閉 SIP。Tatami 用 ScreenCaptureKit 把視窗鏡像到置頂面板,需要**螢幕錄製**權限(設定 → 一般 → 權限)。App 本身取得焦點時隱藏鏡像並停止擷取。{0} 不操作實際視窗,不需該權限。"
+ },
+ "dc9cc110f85d8834": {
+ "en": "Editable in the app under **Workspaces → Shared Apps** (Tiled / Always on Top / Leave As Is).",
+ "ko": "앱의 **작업 공간 → 공용 앱**에서 타일링·항상 위·그대로 두기를 편집할 수 있어요.",
+ "ja": "アプリの**ワークスペース → 共有アプリ**で、タイル・手前・そのままを設定できます。",
+ "zh-Hans": "可在**工作区 → 共享应用**中编辑平铺、置顶或保持原样。",
+ "zh-Hant": "可在**工作空間 → 共用 App**中編輯並排、置頂或維持原狀。"
+ },
+ "016a1f9dcb543b2d": {
+ "en": "Legacy configs with {0} migrate automatically on first load: each entry becomes a shared app with {1}. A pre-1.4 {2} bool on any app or shared app also migrates ({3} → {4}, else {5}).",
+ "ko": "예전 {0} 설정은 처음 읽을 때 {1}을 가진 공용 앱으로 자동 변환해요. 1.4 이전의 {2} bool도 {3} → {4}, 그 외 → {5}로 옮겨져요.",
+ "ja": "以前の {0} は初回読込時に {1} の共有アプリへ移行します。1.4 より前の {2} bool も、{3} → {4}、それ以外 → {5} に変換します。",
+ "zh-Hans": "旧 {0} 配置在首次读取时自动迁移,每项变为 {1} 的共享应用。1.4 之前的 {2} bool 也会迁移:{3} → {4},否则为 {5}。",
+ "zh-Hant": "舊 {0} 設定在首次讀取時自動移轉,每項變為 {1} 的共用 App。1.4 之前的 {2} bool 也會移轉:{3} → {4},否則為 {5}。"
+ },
+ "07f7c40decec71ff": {
+ "en": "Hooks run a program when Tatami publishes one of these events:",
+ "ko": "Tatami가 다음 이벤트를 알리면 훅이 프로그램을 실행해요.",
+ "ja": "次のイベントを発行すると、フックがプログラムを実行します。",
+ "zh-Hans": "Tatami 发布以下事件时,钩子运行程序:",
+ "zh-Hant": "Tatami 發布以下事件時,Hook 執行程式:"
+ },
+ "ba9896ffd6a06057": {
+ "en": "{0}: published once per Tatami process after startup has resolved the active profile. Matching hooks receive that profile; {1}, {2}, and {3} are absent. The event is not replayed for hooks added later in the same process.",
+ "ko": "{0}: 시작 시 활성 프로필을 정한 뒤 프로세스마다 한 번 발생해요. 해당 프로필을 전달하고 {1}, {2}, {3}은 없어요. 같은 프로세스에서 나중에 추가한 훅에는 다시 보내지 않아요.",
+ "ja": "{0}:起動時にプロファイルを決めた後、各プロセスで一度発行します。そのプロファイルを渡し、{1}、{2}、{3} はありません。後で追加したフックには再送しません。",
+ "zh-Hans": "{0}:启动确定活动方案后,每个进程发送一次,传入该方案,不包含 {1}、{2}、{3}。同一进程中后来添加的钩子不会重放此事件。",
+ "zh-Hant": "{0}:啟動確定作用中組合後,每個程序傳送一次,傳入該組合,不包含 {1}、{2}、{3}。同一程序中後來新增的 Hook 不會重播此事件。"
+ },
+ "52e993e73841d7ba": {
+ "en": "{0}: the active profile selection changed. On startup, {1} is absent.",
+ "ko": "{0}: 활성 프로필 선택이 바뀌었어요. 시작 시에는 {1}이 없어요.",
+ "ja": "{0}:現在のプロファイルが変わりました。起動時は {1} がありません。",
+ "zh-Hans": "{0}:活动配置方案发生变化。启动时不包含 {1}。",
+ "zh-Hant": "{0}:作用中設定組合發生變化。啟動時不包含 {1}。"
+ },
+ "706e3e0b1ae83af2": {
+ "en": "{0}: a workspace activation published its visible state on a display. Failed or timed-out activations do not emit it.",
+ "ko": "{0}: 작업 공간이 화면에 표시 상태를 반영했어요. 활성화가 실패하거나 시간이 초과되면 보내지 않아요.",
+ "ja": "{0}:ワークスペースが画面へ表示状態を反映しました。失敗・時間切れでは発行しません。",
+ "zh-Hans": "{0}:工作区激活已在显示器上发布可见状态,失败或超时不会发送。",
+ "zh-Hant": "{0}:工作空間啟用已在顯示器上發布可見狀態,失敗或逾時不會傳送。"
+ },
+ "11d0c43fe6fd2f66": {
+ "en": "{0}: compact action feedback was published. It carries the exact localized title, SF Symbol name, optional subtitle, duration, position, size, and optional target display sent to Tatami's HUD, so external surfaces such as SketchyBar can render the same feedback.",
+ "ko": "{0}: 짧은 화면 알림을 보냈어요. Tatami에 전달한 것과 같은 다국어 제목, SF Symbol, 선택 부제, 시간, 위치, 크기와 대상 화면을 담아요. SketchyBar 같은 도구도 같은 알림을 보여줄 수 있어요.",
+ "ja": "{0}:短いフィードバックを発行しました。Tatami と同じ翻訳済みタイトル、SF Symbol、任意の副題、時間、位置、サイズ、任意の対象画面を渡し、SketchyBar などでも同じ内容を表示できます。",
+ "zh-Hans": "{0}:已发布简短操作反馈,包含发送给 Tatami 的准确本地化标题、SF Symbol、可选副标题、持续时间、位置、大小和可选目标显示器,供 SketchyBar 等外部界面显示相同反馈。",
+ "zh-Hant": "{0}:已發布簡短操作回饋,包含傳送給 Tatami 的確切在地化標題、SF Symbol、可選副標題、持續時間、位置、大小和可選目標顯示器,供 SketchyBar 等外部介面顯示相同回饋。"
+ },
+ "0bae00ab7c94ff29": {
+ "en": "The startup {0} and {1} events are separate. Configure either or both depending on whether the hook needs process startup or profile lifecycle changes.",
+ "ko": "시작 시의 {0}과 {1}은 별도 이벤트예요. 프로세스 시작이 필요한지, 프로필 변경이 필요한지에 따라 하나나 모두 설정하세요.",
+ "ja": "起動時の {0} と {1} は別です。プロセス起動かプロファイル変更か、必要に応じて片方・両方を設定します。",
+ "zh-Hans": "启动时的 {0} 和 {1} 相互独立,根据需要选择进程启动或方案生命周期变化,也可同时订阅。",
+ "zh-Hant": "啟動時的 {0} 與 {1} 相互獨立,依需要選擇程序啟動或組合生命週期變化,也可同時訂閱。"
+ },
+ "e62730a5de3ab0a2": {
+ "en": "Open **Settings → Hooks** to add, edit, delete, enable, or disable hooks. The editor exposes the executable, each argument, working directory, timeout, and environment variables as separate fields. It writes the same {0} entries documented below, so editing {1} by hand remains fully supported and external changes are still picked up live.",
+ "ko": "**설정 → 훅**에서 훅을 추가·편집·삭제하거나 켜고 끌 수 있어요. 실행 파일, 각 인자, 작업 폴더, 제한 시간, 환경 변수를 따로 입력해요. 아래의 {0}과 같은 항목을 저장하므로 {1}을 직접 수정해도 바로 반영돼요.",
+ "ja": "**設定 → フック**で追加、編集、削除、有効・無効化できます。実行ファイル、引数、作業フォルダ、時間制限、環境を別々に入力します。下記と同じ {0} を保存するので、{1} の手編集も即時反映します。",
+ "zh-Hans": "在**设置 → 钩子**中管理钩子。可执行文件、各参数、工作目录、超时和环境变量分别输入,写入下文相同的 {0} 项,因此直接编辑 {1} 仍完整受支持并实时生效。",
+ "zh-Hant": "在**設定 → Hook**中管理 Hook。執行檔、各參數、工作目錄、逾時和環境變數分別輸入,寫入下文相同的 {0} 項,因此直接編輯 {1} 仍完整受支援並即時生效。"
+ },
+ "609466a70dadc4b5": {
+ "en": "**Run Test** validates and executes the current editor draft once with a sample event. It does not add or update the hook and does not write {0}; choose **Save** separately to keep the draft.",
+ "ko": "**테스트 실행**은 현재 초안을 검증하고 예시 이벤트로 한 번 실행해요. 훅을 추가·수정하거나 {0}에 쓰지는 않아요. 초안을 남기려면 별도로 **저장**하세요.",
+ "ja": "**テスト実行**は編集中の下書きを検証し、サンプルイベントで一度動かします。フックの追加・更新や {0} への書き込みはせず、残すには別途**保存**します。",
+ "zh-Hans": "**运行测试**验证当前编辑草稿,并用示例事件执行一次,不新增或更新钩子,也不写入 {0}。要保留草稿,请另外选择**保存**。",
+ "zh-Hant": "**執行測試**驗證目前編輯草稿,並用範例事件執行一次,不新增或更新 Hook,也不寫入 {0}。要保留草稿,請另外選擇**儲存**。"
+ },
+ "6b3fdaf680e28a19": {
+ "en": "_(required)_",
+ "ko": "_(필수)_",
+ "ja": "_(必須)_",
+ "zh-Hans": "_(必填)_",
+ "zh-Hant": "_(必填)_"
+ },
+ "4f3bd53e682801e2": {
+ "en": "Unique diagnostic identifier using ASCII letters, numbers, {0}, {1}, or {2} (up to 64 characters).",
+ "ko": "ASCII 문자, 숫자, {0}, {1}, {2}를 쓰는 고유 진단 ID예요. 최대 64자예요.",
+ "ja": "ASCII の英数字、{0}、{1}、{2} を使う一意の診断 ID です。最大 64 文字です。",
+ "zh-Hans": "唯一诊断标识符,使用 ASCII 字母、数字、{0}、{1} 或 {2},最长 64 个字符。",
+ "zh-Hant": "唯一診斷識別碼,使用 ASCII 字母、數字、{0}、{1} 或 {2},最長 64 個字元。"
+ },
+ "75b64a5c8da80680": {
+ "en": "{0}, {1}, {2}, or {3}.",
+ "ko": "{0}, {1}, {2}, {3} 중 하나예요.",
+ "ja": "{0}、{1}、{2}、{3} のいずれかです。",
+ "zh-Hans": "{0}、{1}、{2} 或 {3}。",
+ "zh-Hant": "{0}、{1}、{2} 或 {3}。"
+ },
+ "ab5954abbecec85b": {
+ "en": "Whether this hook runs. Disabled hooks remain visible to {0}.",
+ "ko": "훅 실행 여부예요. 꺼져 있어도 {0}에서 볼 수 있어요.",
+ "ja": "フックを実行するかを指定します。無効でも {0} には表示されます。",
+ "zh-Hans": "是否运行该钩子。禁用的钩子仍会显示在 {0} 中。",
+ "zh-Hant": "是否執行該 Hook。停用的 Hook 仍會顯示在 {0} 中。"
+ },
+ "62d6ac800806aaa6": {
+ "en": "Executable followed by its arguments. Each element is one argv value.",
+ "ko": "실행 파일 뒤에 인자를 적어요. 각 요소는 argv 값 하나예요.",
+ "ja": "実行ファイルの後に引数を指定します。各要素が一つの argv です。",
+ "zh-Hans": "可执行文件后接参数,每个元素是一个 argv 值。",
+ "zh-Hant": "執行檔後接參數,每個元素是一個 argv 值。"
+ },
+ "1d4460c249c1d576": {
+ "en": "Runtime limit from 100 through 300000 milliseconds.",
+ "ko": "실행 제한 시간은 100~300000ms예요.",
+ "ja": "実行時間の上限は 100〜300000 ms です。",
+ "zh-Hans": "运行时间上限为 100 至 300000 毫秒。",
+ "zh-Hant": "執行時間上限為 100 到 300000 毫秒。"
+ },
+ "2a818ce31d4cdce2": {
+ "en": "Tatami config directory",
+ "ko": "Tatami 설정 폴더",
+ "ja": "Tatami の設定フォルダ",
+ "zh-Hans": "Tatami 配置目录",
+ "zh-Hant": "Tatami 設定目錄"
+ },
+ "4fd07fde90b1972b": {
+ "en": "Absolute path, or a path beginning with {0}.",
+ "ko": "절대 경로나 {0}로 시작하는 경로예요.",
+ "ja": "絶対パス、または {0} で始まるパスです。",
+ "zh-Hans": "绝对路径,或以 {0} 开头的路径。",
+ "zh-Hant": "絕對路徑,或以 {0} 開頭的路徑。"
+ },
+ "3a414514cb5042d6": {
+ "en": "Values added to the app's inherited environment. Tatami context values below take precedence.",
+ "ko": "앱이 물려받은 환경에 추가할 값이에요. 아래 Tatami 맥락 값이 우선해요.",
+ "ja": "アプリが引き継いだ環境へ追加する値です。下の Tatami の値が優先されます。",
+ "zh-Hans": "添加到应用继承环境中的值,下方 Tatami 上下文值优先。",
+ "zh-Hant": "新增到 App 繼承環境中的值,下方 Tatami 情境值優先。"
+ },
+ "02a633f5549c1bad": {
+ "en": "Tatami executes {0} directly; it does not invoke a shell, split words, interpret quotes, or expand variables. The Settings editor preserves the same contract: the executable is {1}, and every argument row becomes one subsequent argv value. To use shell syntax, opt in explicitly, for example {2}. With fish, use the path from {3} and keep the flag and command separate, such as {4}. Tatami sends the event JSON on standard input, so a command that reads standard input receives that event. An executable containing {5} is treated as a path (with leading {6} expanded); a bare name is resolved from the app's inherited {7}. An absolute executable path is the most predictable choice when Tatami was opened from Finder.",
+ "ko": "Tatami는 {0}을 직접 실행해요. 셸을 열거나 단어·따옴표·변수를 해석하지 않아요. 편집기에서도 {1}이 실행 파일이고 각 인자 행이 argv 하나가 돼요. 셸 문법이 필요하면 {2}처럼 명시하세요. fish는 {3}의 경로를 쓰고 {4}처럼 옵션과 명령을 나눠요. 표준 입력에는 이벤트 JSON을 보내요. 실행 파일에 {5}가 있으면 경로로 처리하고 앞의 {6}를 확장해요. 이름만 있으면 앱의 {7}에서 찾아요. Finder에서 연 앱에는 절대 실행 경로가 가장 예측하기 쉬워요.",
+ "ja": "Tatami は {0} を直接実行し、シェル起動、単語分割、引用符解釈、変数展開をしません。編集画面でも {1} が実行ファイルで各行が一つの argv です。シェルが必要なら {2} と明示します。fish は {3} のパスを使い、{4} のように分けます。標準入力にはイベント JSON を送ります。{5} を含む実行名はパスとして先頭の {6} を展開し、名前だけなら {7} から探します。Finder 起動では絶対パスが確実です。",
+ "zh-Hans": "Tatami 直接执行 {0},不启动 shell、不拆词、不解释引号或展开变量。编辑器中 {1} 是执行文件,每个参数行对应一个 argv。需要 shell 时明确使用 {2};fish 使用 {3} 返回的路径,按 {4} 分开参数。事件 JSON 通过 stdin 发送。包含 {5} 的执行文件按路径处理并展开开头的 {6};裸名称从应用继承的 {7} 查找。Finder 启动时,绝对执行路径最可预测。",
+ "zh-Hant": "Tatami 直接執行 {0},不啟動 shell、不拆詞、不解讀引號或展開變數。編輯器中 {1} 是執行檔,每個參數行對應一個 argv。需要 shell 時明確使用 {2};fish 使用 {3} 回傳的路徑,依 {4} 分開參數。事件 JSON 透過 stdin 傳送。包含 {5} 的執行檔依路徑處理並展開開頭的 {6};單純名稱從 App 繼承的 {7} 尋找。Finder 啟動時,絕對執行路徑最可預期。"
+ },
+ "34c70fe8e7f81d84": {
+ "en": "Each hook receives one versioned JSON object on standard input. The object has {0}, {1}, {2}, {3}, and, when applicable, {4}, {5}, {6}, and {7}. The {8} object contains {9}, {10}, {11}, optional {12}, {13}, {14}, and {15}. Every HUD event carries the resolved {16} when Tatami can identify its screen; a workspace-chain switch emits one event per affected display, with that display's own workspace feedback. {17} is the fully visible dwell interval; an external surface adds its own entrance and exit outside that interval. Tatami also sets these convenience variables:",
+ "ko": "각 훅은 표준 입력으로 버전이 있는 JSON 객체 하나를 받아요. {0}, {1}, {2}, {3}과 해당하는 경우 {4}, {5}, {6}, {7}을 담아요. {8} 객체에는 {9}, {10}, {11}, 선택적인 {12}, {13}, {14}, {15}가 있어요. 화면을 알 수 있으면 {16}도 전달해요. 체인 전환에서는 영향을 받은 화면마다 그 공간의 알림을 보내요. {17}은 완전히 표시하는 시간이고 외부 도구는 앞뒤 애니메이션을 따로 더해요. 아래 환경 변수도 설정해요.",
+ "ja": "各フックへバージョン付き JSON を一つ stdin で送ります。{0}、{1}、{2}、{3} と、該当時は {4}、{5}、{6}、{7} を含みます。{8} には {9}、{10}、{11}、任意の {12}、{13}、{14}、{15} があります。画面が特定できれば {16} を渡し、チェーンでは影響した画面ごとにそのワークスペースの情報を送ります。{17} は完全表示の時間で、外部側は前後のアニメーションを別途加えます。次の環境変数も設定します。",
+ "zh-Hans": "每个钩子通过 stdin 接收一个带版本的 JSON,包含 {0}、{1}、{2}、{3},以及适用时的 {4}、{5}、{6}、{7}。{8} 对象包含 {9}、{10}、{11}、可选的 {12}、{13}、{14}、{15}。能识别屏幕时,HUD 事件携带解析后的 {16};工作区链按受影响显示器分别发送该屏幕的工作区反馈。{17} 是完全可见的停留时间,外部界面另加进入退出动画。还会设置以下便利变量:",
+ "zh-Hant": "每個 Hook 透過 stdin 接收一個帶版本的 JSON,包含 {0}、{1}、{2}、{3},以及適用時的 {4}、{5}、{6}、{7}。{8} 物件包含 {9}、{10}、{11}、可選的 {12}、{13}、{14}、{15}。能識別螢幕時,HUD 事件攜帶解析後的 {16};工作空間鏈依受影響顯示器分別傳送該螢幕的工作空間回饋。{17} 是完全可見的停留時間,外部介面另加進入離開動畫。還會設定以下便利變數:"
+ },
+ "cc3ad679316edc82": {
+ "en": "{0}, {1}, {2} for a workspace event",
+ "ko": "작업 공간 이벤트에는 {0}, {1}, {2}가 있어요.",
+ "ja": "ワークスペースイベントには {0}、{1}、{2} があります。",
+ "zh-Hans": "工作区事件包含 {0}、{1}、{2}。",
+ "zh-Hant": "工作空間事件包含 {0}、{1}、{2}。"
+ },
+ "b8e284a6a860f9aa": {
+ "en": "{0}, {1} when a display is known",
+ "ko": "화면을 알 수 있으면 {0}, {1}을 전달해요.",
+ "ja": "画面を特定できれば {0}、{1} を渡します。",
+ "zh-Hans": "已知显示器时包含 {0}、{1}。",
+ "zh-Hant": "已知顯示器時包含 {0}、{1}。"
+ },
+ "16035b1123872cbf": {
+ "en": "{0}, {1}, {2}, {3}, {4}, {5}, and {6} for a {7} event. Optional values are omitted rather than set to an empty string.",
+ "ko": "{7} 이벤트에는 {0}, {1}, {2}, {3}, {4}, {5}, {6}가 있어요. 선택값이 없으면 빈 문자열 대신 항목 자체를 빼요.",
+ "ja": "{7} イベントには {0}、{1}、{2}、{3}、{4}、{5}、{6} があります。任意値がなければ空文字にせず省略します。",
+ "zh-Hans": "{7} 事件包含 {0}、{1}、{2}、{3}、{4}、{5}、{6}。可选值不存在时省略,不设为空字符串。",
+ "zh-Hant": "{7} 事件包含 {0}、{1}、{2}、{3}、{4}、{5}、{6}。可選值不存在時省略,不設為空字串。"
+ },
+ "696f68a849ee630d": {
+ "en": "For example, a SketchyBar bridge can subscribe with {0} and point {1} at a small executable script that forwards these {2} variables to a custom SketchyBar event. Because the values are also available as JSON on standard input, integrations that need structured optional fields do not have to parse environment-variable conventions. HUDs reporting a hook's own failure or recovery are intentionally not republished as {3} events; this prevents a failing HUD hook from recursively launching itself.",
+ "ko": "예를 들어 SketchyBar 연결은 {0}을 구독하고 {1}에 {2} 변수를 맞춤 이벤트로 넘기는 스크립트를 지정할 수 있어요. stdin의 JSON도 쓸 수 있어 선택 필드가 필요한 도구는 환경 변수 규칙을 따로 해석하지 않아도 돼요. 훅 자체의 실패·복구 알림은 {3}으로 다시 보내지 않아 실패한 훅이 자신을 반복 실행하는 것을 막아요.",
+ "ja": "SketchyBar なら {0} を購読し、{1} に {2} を独自イベントへ渡すスクリプトを指定できます。stdin の JSON を使えば任意フィールドの環境変数規則を解析する必要もありません。フック自身の失敗・復旧は {3} に再発行せず、再帰的な起動を防ぎます。",
+ "zh-Hans": "例如 SketchyBar 桥接可订阅 {0},将 {1} 指向把 {2} 变量转发给自定义事件的小脚本。stdin 也提供 JSON,需要结构化可选字段时无需解析环境变量约定。钩子自身的失败或恢复反馈不会再次发布为 {3},以防失败钩子递归启动自己。",
+ "zh-Hant": "例如 SketchyBar 橋接可訂閱 {0},將 {1} 指向把 {2} 變數轉送給自訂事件的小型指令稿。stdin 也提供 JSON,需要結構化可選欄位時不必解析環境變數約定。Hook 自身的失敗或復原回饋不會再次發布為 {3},以防失敗 Hook 遞迴啟動自己。"
+ },
+ "9a9ef62edac81424": {
+ "en": "Standard output and error are each limited to 64 KiB. A nonzero exit, signal, spawn error, excessive output, or timeout is reported under Problems and in the debug log when debug logging is enabled. Hook failures never roll back or block the profile/workspace change. Hook commands run in an isolated process session. On timeout or app shutdown, Tatami first sends a graceful termination and then force-kills remaining descendants, including shell background-job groups. A hook must not detach itself into a new process session. If a newer event arrives while the same hook {0} is still running, both published events run independently; only the newest invocation updates that hook's standing Problem. Changing, disabling, or removing a hook cancels executions of the old definition and clears its standing Problem.",
+ "ko": "표준 출력과 오류는 각각 64KiB까지예요. 비정상 종료·신호·실행 실패·출력 초과·시간 초과는 문제 목록과 켜져 있는 디버그 로그에 남아요. 훅 실패로 프로필·작업 공간 변경을 막거나 되돌리지 않아요. 별도 프로세스 세션에서 실행하며 종료·시간 초과 시 정상 종료를 요청한 뒤 셸 백그라운드 그룹을 포함한 남은 자식을 강제 종료해요. 훅은 새 세션으로 분리되면 안 돼요. 같은 {0}이 실행 중이어도 새 이벤트는 독립 실행하고 최신 호출만 현재 문제를 갱신해요. 변경·끄기·삭제하면 이전 실행을 취소하고 문제를 지워요.",
+ "ja": "stdout と stderr は各 64 KiB までです。異常終了、シグナル、起動失敗、出力超過、時間切れは問題一覧と有効なログに残します。ワークスペースやプロファイルの変更は妨げず戻しません。独立セッションで動かし、終了・時間切れでは正常終了を求めてからシェルの背景グループを含む子を強制終了します。新セッションへ離脱してはいけません。同じ {0} の実行中も新イベントは独立し、最新だけが現在の問題を更新します。定義の変更・無効化・削除は古い実行を止め問題を消します。",
+ "zh-Hans": "stdout 和 stderr 各限 64 KiB。非零退出、信号、启动错误、输出过量或超时会显示在问题列表,并在启用时写入调试日志,不回滚或阻塞方案、工作区变化。命令运行于独立进程会话;超时或退出时先请求正常终止,再强制结束剩余后代,包括 shell 后台任务组。钩子不能自行脱离为新会话。同一 {0} 仍运行时,新事件独立执行,只有最新调用更新当前问题;修改、禁用或删除会取消旧定义的执行并清除问题。",
+ "zh-Hant": "stdout 與 stderr 各限 64 KiB。非零結束、訊號、啟動錯誤、輸出過量或逾時會顯示在問題清單,並在啟用時寫入偵錯記錄,不回復或阻擋組合、工作空間變化。指令執行於獨立程序工作階段;逾時或結束時先要求正常終止,再強制結束剩餘後代,包含 shell 背景工作群組。Hook 不能自行脫離為新工作階段。同一 {0} 仍執行時,新事件獨立執行,只有最新呼叫更新目前問題;修改、停用或刪除會取消舊定義的執行並清除問題。"
+ },
+ "c72affa5e4991351": {
+ "en": "{0} and workspaces",
+ "ko": "{0}과 작업 공간",
+ "ja": "{0} とワークスペース",
+ "zh-Hans": "{0} 与工作区",
+ "zh-Hant": "{0} 與工作空間"
+ },
+ "c67877dedc7d65dd": {
+ "en": "A profile is a named bundle of workspaces. You can define several and switch between them (a switch re-tiles every display for the new profile), and a profile can **auto-activate** based on which monitors are connected. Which profile is currently active, the workspace history for each display, and the global workspace recency order are session state stored in {0} next to {1}, never written into {2} itself. On launch, Tatami restores the last manual profile unconditionally. A profile with display conditions is restored only while those conditions still match; otherwise the normal auto-activation resolver chooses the best match.",
+ "ko": "프로필은 이름이 있는 작업 공간 묶음이에요. 여러 개를 전환하면 각 화면을 다시 정리하고, 연결된 화면에 따라 **자동 활성화**할 수도 있어요. 활성 프로필, 화면별 기록, 전체 최근 순서는 {1} 옆의 {0}에 저장하며 {2}에는 쓰지 않아요. 시작할 때 마지막 수동 프로필은 항상 복원해요. 화면 조건이 있으면 조건이 여전히 맞을 때만 복원하고, 아니면 가장 알맞은 자동 프로필을 찾아요.",
+ "ja": "プロファイルは名前付きのワークスペースセットです。切り替えると各画面を整え、接続状態で**自動有効化**もできます。選択中のプロファイル、画面別の履歴、全体の最近順は {1} の隣の {0} に保存し、{2} には書きません。起動時は最後の手動プロファイルを必ず戻します。画面条件付きなら条件が一致するときだけ戻し、そうでなければ最適な自動選択を使います。",
+ "zh-Hans": "配置方案是一组命名工作区。可定义多个并切换,为每个显示器重新平铺,也可按连接状态**自动激活**。当前方案、逐显示器历史和全局最近顺序是会话状态,存于 {1} 旁的 {0},不写入 {2}。启动时无条件恢复最后的手动方案;带显示器条件的方案仅在条件仍匹配时恢复,否则由自动解析器选择最佳匹配。",
+ "zh-Hant": "設定組合是一組命名工作空間。可定義多個並切換,為每部顯示器重新並排,也可依連接狀態**自動啟用**。目前組合、逐顯示器記錄與全域最近順序是工作階段狀態,存於 {1} 旁的 {0},不寫入 {2}。啟動時無條件還原最後的手動組合;帶顯示器條件的組合僅在條件仍符合時還原,否則由自動解析器選擇最佳符合項目。"
+ },
+ "b45ae4654ace8019": {
+ "en": "Profiles keep independent workspaces, so their apps and settings can drift apart. The app can reconcile them without hand-editing: **Copy from…** in a profile's or workspace's detail shows a reviewable diff of the apps and settings that differ and copies only the changes you keep checked.",
+ "ko": "프로필마다 앱과 설정이 독립적이라 서로 달라질 수 있어요. 상세 화면의 **다른 구성에서 복사** 기능으로 차이를 검토하고, 선택한 앱·설정 변경만 가져올 수 있어요.",
+ "ja": "ワークスペースは独立しているため、アプリや設定が異なることがあります。詳細の**コピー元を選ぶ**機能で差分を確認し、選んだ変更だけを取り込めます。",
+ "zh-Hans": "各方案的工作区独立,应用和设置可能逐渐不同。详情中的**从其他配置复制**可展示差异,只复制勾选保留的更改,无需手动编辑。",
+ "zh-Hant": "各組合的工作空間獨立,App 與設定可能逐漸不同。詳細資料中的**從其他設定拷貝**可展示差異,只拷貝勾選保留的變更,不必手動編輯。"
+ },
+ "ef53a6eb62150f27": {
+ "en": "Profile fields:",
+ "ko": "프로필 항목:",
+ "ja": "プロファイルの項目:",
+ "zh-Hans": "配置方案字段:",
+ "zh-Hant": "設定組合欄位:"
+ },
+ "ce1a0a12df56b338": {
+ "en": "Stable identifier.",
+ "ko": "안정적인 식별자예요.",
+ "ja": "安定した識別子です。",
+ "zh-Hans": "稳定标识符。",
+ "zh-Hant": "穩定識別碼。"
+ },
+ "c08f8b94d61cb776": {
+ "en": "Display name.",
+ "ko": "표시할 이름이에요.",
+ "ja": "表示する名前です。",
+ "zh-Hans": "显示名称。",
+ "zh-Hant": "顯示名稱。"
+ },
+ "7d763290c95afa9a": {
+ "en": "SF Symbol shown for the profile in the sidebar, menu bar, and profile-switch feedback. Omit for the default {0}.",
+ "ko": "프로필의 사이드바·메뉴 막대·전환 알림에 표시할 SF Symbol이에요. 생략하면 {0}을 써요.",
+ "ja": "プロファイルのサイドバー、メニュー、切り替えに使う SF Symbol です。省略時は {0} です。",
+ "zh-Hans": "侧边栏、菜单栏和切换反馈中的方案 SF Symbol,省略时使用 {0}。",
+ "zh-Hant": "側邊欄、選單列與切換回饋中的組合 SF Symbol,省略時使用 {0}。"
+ },
+ "d41e5930fd73e887": {
+ "en": "skhd-style hotkey that switches to this profile.",
+ "ko": "이 프로필로 전환할 skhd 형식 단축키예요.",
+ "ja": "このプロファイルへ切り替える skhd 形式のキーです。",
+ "zh-Hans": "切换到此方案的 skhd 风格快捷键。",
+ "zh-Hant": "切換到此組合的 skhd 風格快速鍵。"
+ },
+ "c475df0c074a2915": {
+ "en": "Auto-activate when the connected displays match (keys below). Omit for manual only. A present table with no conditions is a catch-all that matches any configuration.",
+ "ko": "아래 화면 조건이 맞으면 자동 활성화해요. 생략하면 수동으로만 선택해요. 표는 있지만 조건이 없으면 모든 구성을 만족해요.",
+ "ja": "下の画面条件が合うと自動で有効化します。省略は手動のみ、条件のないテーブルはすべてに一致します。",
+ "zh-Hans": "显示器符合下方条件时自动激活,省略则仅手动。表存在但无条件时匹配任何配置。",
+ "zh-Hant": "顯示器符合下方條件時自動啟用,省略則僅手動。表存在但無條件時符合任何設定。"
+ },
+ "175193a268f16ab0": {
+ "en": "Symmetric workspace groups that switch together across displays. Destinations are resolved at activation time, not stored in the chain. Omit when unused.",
+ "ko": "여러 화면에서 함께 전환하는 대칭 작업 공간 그룹이에요. 화면 위치는 체인에 저장하지 않고 활성화할 때 정해요. 쓰지 않으면 생략해요.",
+ "ja": "複数画面で一緒に切り替える対等なワークスペースグループです。画面はチェーンに保存せず、有効化時に決めます。不要なら省略します。",
+ "zh-Hans": "跨显示器一起切换的对称工作区组。目标在激活时解析,不存入链;不用时省略。",
+ "zh-Hant": "跨顯示器一起切換的對等工作空間群組。目標在啟用時解析,不存入鏈;不用時省略。"
+ },
+ "d806feb28fe0927c": {
+ "en": "**{0}:** All keys are optional and combined with AND. When several profiles match, the most specific wins. {1} outranks {2}, more conditions rank higher, and ties go to the earlier profile:",
+ "ko": "**{0}:** 모든 키는 선택 사항이고 AND로 묶여요. 여러 프로필이 맞으면 더 구체적인 것을 선택해요. {1}이 {2}보다 우선하고, 조건이 많을수록 우선해요. 같으면 앞에 있는 프로필을 써요.",
+ "ja": "**{0}:** 全キーは任意で AND 結合です。複数が一致すると具体的なものを優先し、{1} は {2} より上、条件が多いものが上、同じなら先のプロファイルを使います。",
+ "zh-Hans": "**{0}:** 所有键均可选,以 AND 组合。多个匹配时选最具体的:{1} 高于 {2},条件越多越优先,相同时选择排列更前的方案。",
+ "zh-Hant": "**{0}:** 所有鍵均可選,以 AND 組合。多個符合時選最具體的:{1} 高於 {2},條件越多越優先,相同時選擇排列更前的組合。"
+ },
+ "3f24fadcb9273b12": {
+ "en": "Connected-monitor count: {0}, {1}, {2}.",
+ "ko": "연결한 화면 수: {0}, {1}, {2}.",
+ "ja": "接続画面数:{0}、{1}、{2}。",
+ "zh-Hans": "已连接显示器数量:{0}、{1}、{2}。",
+ "zh-Hant": "已連接顯示器數量:{0}、{1}、{2}。"
+ },
+ "21162082b2d696f0": {
+ "en": "Displays that must be connected ({0} or {1}).",
+ "ko": "반드시 연결되어야 하는 화면({0} 또는 {1})이에요.",
+ "ja": "接続が必要な画面({0} または {1})です。",
+ "zh-Hans": "必须连接的显示器({0} 或 {1})。",
+ "zh-Hant": "必須連接的顯示器({0} 或 {1})。"
+ },
+ "0ef4ae5fa1f5df8c": {
+ "en": "{0} by default, where listed displays must be present and extras are allowed, or {1}, where the connected set equals the list.",
+ "ko": "기본값 {0}은 목록의 화면이 모두 있으면 추가 화면을 허용해요. {1}은 연결된 화면이 목록과 정확히 같아야 해요.",
+ "ja": "既定の {0} は一覧の画面があれば追加も許可します。{1} は接続集合が一覧と完全一致する必要があります。",
+ "zh-Hans": "默认 {0} 要求列表中的显示器存在,允许额外显示器;{1} 要求连接集合与列表完全相同。",
+ "zh-Hant": "預設 {0} 要求清單中的顯示器存在,允許額外顯示器;{1} 要求連接集合與清單完全相同。"
+ },
+ "57921d794387b9af": {
+ "en": "Displays that must be unplugged.",
+ "ko": "연결되어 있으면 안 되는 화면이에요.",
+ "ja": "接続されていてはいけない画面です。",
+ "zh-Hans": "必须断开的显示器。",
+ "zh-Hant": "必須中斷連接的顯示器。"
+ },
+ "520618a28ee1d78e": {
+ "en": "**{0}:** A chain is a symmetric group with no anchor or parent. It stores an ordered list of stable workspace UUIDs, never display slots. A workspace may belong to at most one chain in a profile, every chain needs at least two unique entries, and every entry must reference a normal workspace in that profile. Renaming a workspace is safe.",
+ "ko": "**{0}:** 체인은 기준이나 부모가 없는 대칭 그룹이에요. 화면 칸 대신 안정적인 작업 공간 UUID를 순서대로 저장해요. 한 공간은 프로필 안에서 하나의 체인에만 속하고, 체인은 같은 프로필의 서로 다른 일반 공간을 두 개 이상 가져야 해요. 작업 공간 이름은 바꿔도 안전해요.",
+ "ja": "**{0}:** チェーンは親や基準を持たない対等なグループです。画面枠ではなく安定したワークスペース UUID を順に保存します。ワークスペースは一つのチェーンだけに属し、同じプロファイルの異なる通常ワークスペースが 2 個以上必要です。名前変更は安全です。",
+ "zh-Hans": "**{0}:** 链是没有锚点或父级的对称组,保存有序的稳定工作区 UUID,不保存显示器槽位。每个工作区在方案内最多属于一条链;每条链至少有两个不同条目,且都指向同方案的普通工作区。重命名安全。",
+ "zh-Hant": "**{0}:** 鏈是沒有錨點或父層的對等群組,儲存有序的穩定工作空間 UUID,不儲存顯示器槽位。每個工作空間在組合內最多屬於一條鏈;每條鏈至少有兩個不同項目,且都指向同組合的一般工作空間。重新命名是安全的。"
+ },
+ "86708a166b5732d2": {
+ "en": "Only the workspace the user actually switched to starts a chain. Restores made by that chain do not recursively start another chain. The workspace explicitly selected by the user is mandatory: Tatami places it with the same pin, pointer, and global-Recent rules as a standalone activation. Other entries restore first and the triggering workspace restores last, so focus stays on the user's selection.",
+ "ko": "사용자가 직접 전환한 작업 공간만 체인을 시작해요. 체인이 복원한 공간은 다시 체인을 시작하지 않아요. 선택한 공간은 반드시 배치하며 단독 활성화와 같은 화면 고정·포인터·전체 최근 기록 규칙을 써요. 동료 공간을 먼저 복원하고 선택한 공간을 마지막에 복원해서 포커스를 유지해요.",
+ "ja": "ユーザーが直接選んだワークスペースだけがチェーンを開始し、チェーンによる復元は再帰的に開始しません。選んだワークスペースを必須とし、単独と同じ固定・ポインタ・全体履歴の規則で配置します。仲間を先に、選択を最後に復元してフォーカスを保ちます。",
+ "zh-Hans": "只有用户实际切换到的工作区启动链,链恢复的工作区不会递归触发链。用户明确选择的工作区必须放置,采用与单独激活相同的固定、指针和全局最近规则。先恢复伙伴,再恢复触发工作区,保留用户选择的焦点。",
+ "zh-Hant": "只有使用者實際切換到的工作空間啟動鏈,鏈還原的工作空間不會遞迴觸發鏈。使用者明確選擇的工作空間必須放置,採用與單獨啟用相同的固定、游標與全域最近規則。先還原夥伴,再還原觸發工作空間,保留使用者選擇的焦點。"
+ },
+ "1cf3a782324e1e82": {
+ "en": "Display placement is deliberately not a static config validation rule. Tatami resolves it against the displays connected at that moment. After reserving the triggering workspace, Tatami considers every other member from top to bottom in {0} order. A pinned member uses its configured display when that display is connected and free. A disconnected pin is skipped instead of being moved to an unrelated display. An ordinarily dynamic member uses the next available display, with dynamic placement starting at the pointer display. {1} can give that same chain-only behavior to a pinned workspace without changing its ordinary pin. If a pin overlaps a display already claimed by a higher-priority member, or no display remains, that member is skipped and Tatami continues down the list. This makes {2} the deterministic priority order instead of requiring one display per member.",
+ "ko": "화면 배치는 고정 설정 검사에 넣지 않고 현재 연결된 화면으로 정해요. 선택한 공간을 예약한 뒤 나머지를 {0} 순서로 살펴봐요. 고정 화면이 연결되어 있고 비어 있으면 쓰고, 끊겨 있으면 다른 화면으로 보내지 않고 건너뛰어요. 동적 공간은 포인터 화면부터 다음 빈 화면을 사용해요. {1}은 일반 고정을 유지한 채 체인 동료일 때만 같은 방식으로 배치해요. 우선순위가 높은 공간과 겹치거나 남은 화면이 없으면 건너뛰고 계속해요. 따라서 멤버마다 화면이 필요하지 않고 {2}가 확정적인 우선순위가 돼요.",
+ "ja": "配置は固定検証ではなく、現在の接続画面で解決します。選択を予約してから {0} 順に仲間を見ます。固定先が接続中で空なら使い、未接続なら他画面へ移さず飛ばします。動的ワークスペースはポインタ画面から空きを使います。{1} は通常の固定を保ち、仲間の場合だけ同じ動的配置にします。優先度の高いワークスペースと重なる、または空きがなければ次へ進みます。全員分の画面は不要で、{2} が決まった優先順になります。",
+ "zh-Hans": "显示器放置不是静态配置验证规则,而按当时连接状态解析。先保留触发工作区,再按 {0} 从上到下处理其他成员。固定成员使用已连接且空闲的目标,断开的固定目标直接跳过。动态成员从指针所在显示器开始使用下一个空位;{1} 可让固定工作区仅作为链伙伴时采用此行为,不改变普通固定设置。被高优先级占用或无剩余显示器时跳过并继续,因此 {2} 决定确定性优先级,无需要求每个成员都有显示器。",
+ "zh-Hant": "顯示器放置不是靜態設定驗證規則,而依當時連接狀態解析。先保留觸發工作空間,再依 {0} 從上到下處理其他成員。固定成員使用已連接且空閒的目標,中斷的固定目標直接略過。動態成員從游標所在顯示器開始使用下一個空位;{1} 可讓固定工作空間僅作為鏈夥伴時採用此行為,不改變一般固定設定。被高優先順序佔用或無剩餘顯示器時略過並繼續,因此 {2} 決定可預期的優先順序,不需要求每個成員都有顯示器。"
+ },
+ "44fd0c8f89d83278": {
+ "en": "Chain Dynamic companions claim free displays before the ordinary fallback. Displays still outside the resolved chain then use the usual restore order: recent valid history, then a workspace pinned to that display, then an unused dynamic workspace. Invalid workspace references and cross-chain membership conflicts remain visible for repair, and their chains do not execute.",
+ "ko": "체인 동적 동료가 일반 복원보다 먼저 빈 화면을 차지해요. 체인 밖 화면은 유효한 최근 기록, 해당 화면 고정 공간, 사용하지 않는 동적 공간 순으로 복원해요. 잘못된 참조나 체인 간 중복은 문제로 남겨 고칠 수 있게 하고 해당 체인은 실행하지 않아요.",
+ "ja": "チェーンの動的な仲間は通常復元より先に空きを確保します。残りは有効な履歴、その画面への固定、未使用の動的ワークスペースの順で戻します。不正参照やチェーン間の所属競合は修正できるよう表示し、そのチェーンは実行しません。",
+ "zh-Hans": "链动态伙伴优先占用空闲显示器。链之外的显示器按常规顺序恢复:有效最近历史、固定到该屏幕的工作区、未使用的动态工作区。无效引用和跨链冲突保持可见以便修复,对应链不执行。",
+ "zh-Hant": "鏈動態夥伴優先佔用空閒顯示器。鏈之外的顯示器依一般順序還原:有效最近記錄、固定到該螢幕的工作空間、未使用的動態工作空間。無效參照與跨鏈衝突保持可見以便修復,對應鏈不執行。"
+ },
+ "2399a37033b1669a": {
+ "en": "Workspace-chain fields:",
+ "ko": "작업 공간 체인 항목:",
+ "ja": "ワークスペースチェーンの項目:",
+ "zh-Hans": "工作区链字段:",
+ "zh-Hant": "工作空間鏈欄位:"
+ },
+ "8870de500d2508c2": {
+ "en": "Stable chain identifier. Must be unique within the profile.",
+ "ko": "프로필 안에서 고유한 안정적인 체인 식별자예요.",
+ "ja": "プロファイル内で一意の、安定したチェーン ID です。",
+ "zh-Hans": "稳定链标识符,在配置方案内必须唯一。",
+ "zh-Hant": "穩定鏈識別碼,在設定組合內必須唯一。"
+ },
+ "cad3d38d754524c3": {
+ "en": "Optional label shown in Settings; it does not affect activation.",
+ "ko": "설정에 보여줄 선택 이름이에요. 활성화에는 영향을 주지 않아요.",
+ "ja": "設定に表示する任意の名前です。有効化には影響しません。",
+ "zh-Hans": "设置中显示的可选名称,不影响激活。",
+ "zh-Hant": "設定中顯示的可選名稱,不影響啟用。"
+ },
+ "7c0350aae789049f": {
+ "en": "Two or more unique normal-workspace IDs in deterministic conflict-resolution order.",
+ "ko": "충돌을 해결할 순서대로 놓은 서로 다른 일반 작업 공간 ID 두 개 이상이에요.",
+ "ja": "決まった競合解決順に並べた、異なる通常ワークスペースの ID が 2 個以上必要です。",
+ "zh-Hans": "两个或更多不同的普通工作区 ID,按确定的冲突解决顺序排列。",
+ "zh-Hant": "兩個或更多不同的一般工作空間 ID,依確定的衝突解決順序排列。"
+ },
+ "ca4c7c05c3d63959": {
+ "en": "Subset of {0} whose pinned workspaces use the next free display while being placed as chain companions. Omit when unused.",
+ "ko": "체인 동료로 배치할 때 다음 빈 화면을 쓸 고정 공간이에요. {0}의 부분집합이며 사용하지 않으면 생략해요.",
+ "ja": "仲間として配置するときだけ次の空き画面を使う、{0} の固定ワークスペースの部分集合です。不要なら省略します。",
+ "zh-Hans": "{0} 的子集,其中固定工作区作为链伙伴放置时使用下一个空闲显示器。不使用时省略。",
+ "zh-Hant": "{0} 的子集合,其中固定工作空間作為鏈夥伴放置時使用下一個空閒顯示器。不使用時省略。"
+ },
+ "fdcd79544a420d95": {
+ "en": "Workspace fields:",
+ "ko": "작업 공간 항목:",
+ "ja": "ワークスペースの項目:",
+ "zh-Hans": "工作区字段:",
+ "zh-Hant": "工作空間欄位:"
+ },
+ "c0fedd7058de276b": {
+ "en": "SF Symbol used in the menu bar / sidebar.",
+ "ko": "메뉴 막대와 사이드바에 표시할 SF Symbol이에요.",
+ "ja": "メニューバーとサイドバーの SF Symbol です。",
+ "zh-Hans": "菜单栏和侧边栏使用的 SF Symbol。",
+ "zh-Hant": "選單列與側邊欄使用的 SF Symbol。"
+ },
+ "61e090eba39557fc": {
+ "en": "{0} (default) or {1}. A scratchpad is **borrow-only**: it is excluded from regular switching, never activates alone, and auto-opens its apps when borrowed beside another workspace.",
+ "ko": "{0}(기본값) 또는 {1}이에요. 임시 공간은 **빌려오기 전용**이라 일반 전환에서 제외되고 단독으로 활성화하지 않아요. 옆으로 빌려올 때 앱을 자동으로 열어요.",
+ "ja": "{0}(既定)または {1} です。一時スペースは**借りる専用**で、通常切り替えや単独有効化には入らず、借りるとアプリを開きます。",
+ "zh-Hans": "{0}(默认)或 {1}。暂存区**仅供借用**,排除普通切换,不单独激活,借到其他工作区旁边时自动打开应用。",
+ "zh-Hant": "{0}(預設)或 {1}。暫存區**僅供借用**,排除一般切換,不單獨啟用,借到其他工作空間旁邊時自動開啟 App。"
+ },
+ "749bc4321f28932b": {
+ "en": "One-character key for this workspace, held with the switch / assign / borrow modifiers (see {0}). Omit to disable key-equivalent actions for it.",
+ "ko": "전환·배정·빌려오기 보조키와 함께 누를 한 글자 키예요. {0}을 확인하세요. 생략하면 이 공간의 키 조합을 꺼요.",
+ "ja": "切り替え・割り当て・借りる修飾キーと使う一文字キーです。{0} を参照してください。省略すると無効になります。",
+ "zh-Hans": "此工作区的单字符按键,与切换、分配、借用修饰键组合,见 {0}。省略则禁用其按键操作。",
+ "zh-Hant": "此工作空間的單字元按鍵,與切換、指派、借用輔助鍵組合,見 {0}。省略則停用其按鍵操作。"
+ },
+ "dc15ec20fd5e7bb9": {
+ "en": "Explicit override for the switch combo.",
+ "ko": "전환 조합 대신 사용할 별도 단축키예요.",
+ "ja": "切り替えの組み合わせを上書きするキーです。",
+ "zh-Hans": "切换组合的显式覆盖。",
+ "zh-Hant": "切換組合的明確覆寫。"
+ },
+ "d4007591e09b4bdb": {
+ "en": "Explicit override for the assign combo (add the focused app, keeping its other memberships, and switch here).",
+ "ko": "기존 소속을 유지해 앱을 추가하고 이곳으로 전환하는 배정 조합의 별도 단축키예요.",
+ "ja": "元の所属を保ってアプリを追加し、ここへ切り替える割り当てキーの上書きです。",
+ "zh-Hans": "分配组合的显式覆盖:添加聚焦应用,保留其他成员关系并切换到此处。",
+ "zh-Hant": "指派組合的明確覆寫:新增聚焦 App,保留其他成員關係並切換到此處。"
+ },
+ "213c05bbbc130523": {
+ "en": "Explicit override for the borrow combo.",
+ "ko": "빌려오기 조합 대신 사용할 별도 단축키예요.",
+ "ja": "借りる組み合わせを上書きするキーです。",
+ "zh-Hans": "借用组合的显式覆盖。",
+ "zh-Hant": "借用組合的明確覆寫。"
+ },
+ "f59be37c779d70fe": {
+ "en": "Override the default borrow edge for this workspace: {0}, {1}, {2}, {3}. Omit to use {4} (or the direction-pick when that's unset).",
+ "ko": "이 공간의 기본 빌려오기 위치를 {0}, {1}, {2}, {3}으로 덮어써요. 생략하면 {4}를 쓰고, 그 값도 없으면 방향을 선택해요.",
+ "ja": "このワークスペースの位置を {0}、{1}、{2}、{3} で上書きします。省略時は {4}、それもなければ方向を選びます。",
+ "zh-Hans": "覆盖此工作区的默认借用边缘:{0}、{1}、{2}、{3}。省略则使用 {4},其也未设置时选择方向。",
+ "zh-Hant": "覆寫此工作空間的預設借用邊緣:{0}、{1}、{2}、{3}。省略則使用 {4},其也未設定時選擇方向。"
+ },
+ "7cec221549e4b218": {
+ "en": "Override the borrowed-block size for this workspace (0.1…0.9). Omit to use {0}.",
+ "ko": "이 공간의 빌려올 크기 비율(0.1~0.9)을 덮어써요. 생략하면 {0}을 써요.",
+ "ja": "このワークスペースの借りる割合(0.1〜0.9)を上書きし、省略時は {0} を使います。",
+ "zh-Hans": "覆盖此工作区借用区域的大小(0.1…0.9),省略使用 {0}。",
+ "zh-Hant": "覆寫此工作空間借用區域的大小(0.1…0.9),省略使用 {0}。"
+ },
+ "7d2671109821bb27": {
+ "en": "Bundle ID of the assigned app to focus on activation. Omit for most-recently-used.",
+ "ko": "활성화할 때 포커스할 배정 앱의 번들 ID예요. 생략하면 최근에 쓴 앱을 선택해요.",
+ "ja": "有効化時に選ぶ割り当て済みアプリの ID です。省略時は最近使ったものを選びます。",
+ "zh-Hans": "激活时聚焦的已分配应用包 ID,省略则使用最近应用。",
+ "zh-Hant": "啟用時聚焦的已指派 App 套件 ID,省略則使用最近 App。"
+ },
+ "27e89b9323188f5a": {
+ "en": "Pin the workspace to a display using {0} or just {1}. Omit to open the workspace on the display under the mouse. Falls back to the primary display when the pinned monitor is absent. When a dynamic workspace leaves a display, Tatami refills that display from its own history, but only with a workspace pinned to it or a dynamic one no other display is using. A workspace pinned to a monitor that is currently disconnected is never pulled in. When nothing qualifies, the display is left empty on purpose.",
+ "ko": "{0}이나 {1}로 화면에 고정해요. 생략하면 마우스가 있는 화면에 열고, 고정 화면이 없으면 주 화면을 써요. 동적 공간이 떠난 화면은 그 화면의 기록에서 다시 채우되, 해당 화면 고정 공간이나 다른 화면이 쓰지 않는 동적 공간만 골라요. 연결이 끊긴 화면에 고정된 공간은 가져오지 않아요. 맞는 공간이 없으면 화면을 비워둬요.",
+ "ja": "{0} または {1} で画面に固定します。省略ならポインタの画面、固定先不在なら主画面です。動的ワークスペースが離れた画面は自身の履歴から、その画面の固定ワークスペースか他画面が使っていない動的ワークスペースだけで補います。未接続画面への固定は呼び込まず、候補がなければ空にします。",
+ "zh-Hans": "用 {0} 或 {1} 固定到显示器。省略则在鼠标所在屏幕打开,固定显示器缺失时回到主屏。动态工作区离开后,原显示器按自身历史补位,只能使用固定到该屏幕的工作区,或未被其他屏幕使用的动态工作区。不会拉入固定到已断开显示器的工作区;无候选时保持空白。",
+ "zh-Hant": "用 {0} 或 {1} 固定到顯示器。省略則在游標所在螢幕開啟,固定顯示器缺少時回到主螢幕。動態工作空間離開後,原顯示器依自身記錄補位,只能使用固定到該螢幕的工作空間,或未被其他螢幕使用的動態工作空間。不會拉入固定到已中斷顯示器的工作空間;無候選時保持空白。"
+ },
+ "ea4d700238dd4de2": {
+ "en": "App assignment fields:",
+ "ko": "앱 배정 항목:",
+ "ja": "アプリ割り当ての項目:",
+ "zh-Hans": "应用分配字段:",
+ "zh-Hant": "App 指派欄位:"
+ },
+ "0d712f5226a24c67": {
+ "en": "The app's bundle ID.",
+ "ko": "앱의 번들 ID예요.",
+ "ja": "アプリのバンドル ID です。",
+ "zh-Hans": "应用包 ID。",
+ "zh-Hant": "App 套件 ID。"
+ },
+ "3b7fcb19e7792d74": {
+ "en": "Launch the app when the workspace activates and reopen it on re-entry if its window was closed.",
+ "ko": "작업 공간을 활성화하면 앱을 열어요. 창을 닫았더라도 다시 들어가면 열어줘요.",
+ "ja": "ワークスペースを有効にすると起動し、閉じた窓も入り直すと開きます。",
+ "zh-Hans": "激活工作区时启动应用,关闭窗口后重新进入时再次打开。",
+ "zh-Hant": "啟用工作空間時啟動 App,關閉視窗後重新進入時再次開啟。"
+ },
+ "ae61279ac71b5173": {
+ "en": "{0} (**Tiled** in the app), {1} (**Always on Top**; untiled and mirrored above the tiles), or {2} (**Leave As Is**; left in place and still a member, with no tiling, mirroring, or Screen Recording). Migrated from the pre-1.4 {3} bool.",
+ "ko": "{0}(**타일링**), {1}(**항상 위**, 타일링하지 않고 미러로 위에 표시), {2}(**그대로 두기**, 소속과 위치를 유지하며 타일링·미러·화면 기록을 사용하지 않음)예요. 1.4 이전 {3} bool에서 자동으로 옮겨져요.",
+ "ja": "{0}(**タイル表示**)、{1}(**常に手前**、ミラーで上に表示)、{2}(**そのまま**、所属と位置を保ちタイル・ミラー・画面収録なし)です。1.4 より前の {3} bool から移行します。",
+ "zh-Hans": "{0}(**平铺**)、{1}(**置顶**,不平铺,以镜像置于上方)或 {2}(**保持原样**,保留位置和成员关系,无平铺、镜像或屏幕录制)。从 1.4 前的 {3} bool 迁移。",
+ "zh-Hant": "{0}(**並排**)、{1}(**置頂**,不並排,以鏡像置於上方)或 {2}(**維持原狀**,保留位置和成員關係,無並排、鏡像或螢幕錄製)。從 1.4 前的 {3} bool 移轉。"
+ },
+ "5f238b159c4fdf57": {
+ "en": "Cached path to the app's icon, written automatically. You don't set this by hand.",
+ "ko": "자동으로 저장한 앱 아이콘 경로예요. 직접 설정하지 않아요.",
+ "ja": "自動保存するアプリアイコンのパスです。手では設定しません。",
+ "zh-Hans": "自动写入的应用图标缓存路径,不应手动设置。",
+ "zh-Hant": "自動寫入的 App 圖像快取路徑,不應手動設定。"
+ },
+ "b0684a01622039f4": {
+ "en": "Tatami Localization and UX Writing",
+ "ko": "Tatami 현지화와 UX 글쓰기",
+ "ja": "Tatami のローカライズと UX ライティング",
+ "zh-Hans": "Tatami 本地化与 UX 文案",
+ "zh-Hant": "Tatami 在地化與 UX 文案"
+ },
+ "1866e4cc73a82181": {
+ "en": "Tatami keeps one product model across languages, but it does not translate English word for word. Each locale should make the next action obvious while preserving the app's core concepts: a workspace is a task context, a profile is a group of workspaces for a setup, and Borrow temporarily places one workspace beside another.",
+ "ko": "언어가 달라도 Tatami의 제품 개념은 같아요. 영어를 단어대로 옮기기보다 다음 행동이 쉽게 보이도록 써요. 작업 공간은 작업의 맥락이고, 프로필은 환경에 맞춘 작업 공간 묶음이며, 빌려오기는 다른 작업 공간을 잠시 옆에 두는 기능이에요.",
+ "ja": "言語が変わっても製品の考え方は同じです。英語を逐語訳せず、次の操作がわかる文章にします。ワークスペースは作業の場、プロファイルは環境に合う作業の組み合わせ、「借りる」は別の作業を一時的に横へ置く機能です。",
+ "zh-Hans": "不同语言保留同一套产品模型,但不逐字翻译英语。每种语言都应让下一步操作清楚可见:工作区是任务环境,配置方案是一组适合某种环境的工作区,借用则把另一个工作区暂时放在旁边。",
+ "zh-Hant": "不同語言保留同一套產品模型,但不逐字翻譯英文。每種語言都應讓下一步操作清楚可見:工作空間是工作情境,設定組合是一組適合某種環境的工作空間,借用則把另一個工作空間暫時放在旁邊。"
+ },
+ "9abe77508a362338": {
+ "en": "Supported locales",
+ "ko": "지원 언어",
+ "ja": "対応言語",
+ "zh-Hans": "支持的语言",
+ "zh-Hant": "支援的語言"
+ },
+ "ca3dc612f47e3913": {
+ "en": "Locale",
+ "ko": "로케일",
+ "ja": "ロケール",
+ "zh-Hans": "区域语言",
+ "zh-Hant": "地區語言"
+ },
+ "545c02357695a6ff": {
+ "en": "Audience",
+ "ko": "대상",
+ "ja": "対象",
+ "zh-Hans": "受众",
+ "zh-Hant": "對象"
+ },
+ "2ebdca067028bc7f": {
+ "en": "Writing model",
+ "ko": "글쓰기 기준",
+ "ja": "文章の方針",
+ "zh-Hans": "文案风格",
+ "zh-Hant": "文案風格"
+ },
+ "2aa78d21d22d1c75": {
+ "en": "Global English",
+ "ko": "전 세계 영어 사용자",
+ "ja": "世界の英語ユーザー",
+ "zh-Hans": "全球英语用户",
+ "zh-Hant": "全球英文使用者"
+ },
+ "e11f6cd60d0a18ba": {
+ "en": "Apple-style clear, action-oriented writing: lead with the outcome, prefer familiar words, and reveal implementation detail only when it helps the next action",
+ "ko": "Apple처럼 결과와 행동을 분명하게 써요. 익숙한 말을 쓰고, 다음 행동에 도움이 될 때만 구현 방식을 설명해요.",
+ "ja": "Apple のように結果と操作を明確にします。身近な言葉を使い、次の操作に役立つ場合だけ実装を説明します。",
+ "zh-Hans": "采用 Apple 风格的清晰行动文案:先说明结果,使用熟悉的词,只在有助于下一步操作时介绍实现细节。",
+ "zh-Hant": "採用 Apple 風格的清楚行動文案:先說明結果,使用熟悉的詞,只在有助於下一步操作時介紹實作細節。"
+ },
+ "17a2bfe78e03b1c1": {
+ "en": "Korea",
+ "ko": "한국",
+ "ja": "韓国",
+ "zh-Hans": "韩国",
+ "zh-Hant": "韓國"
+ },
+ "76e7bad6b917621d": {
+ "en": "Toss-style plain language: one message at a time, remove filler, explain the benefit before implementation details",
+ "ko": "토스처럼 쉽게 써요. 한 번에 하나의 메시지를 전하고 군더더기를 빼요. 구현보다 좋은 점을 먼저 설명해요.",
+ "ja": "Toss のようにわかりやすく、一度に一つを伝えます。余分な言葉を省き、実装より先に利点を説明します。",
+ "zh-Hans": "采用 Toss 风格的通俗表达:一次传达一个信息,去掉赘词,先说好处再说实现。",
+ "zh-Hant": "採用 Toss 風格的易懂表達:一次傳達一個訊息,去掉贅詞,先說好處再說實作。"
+ },
+ "647294383b2cbc24": {
+ "en": "Japan",
+ "ko": "일본",
+ "ja": "日本",
+ "zh-Hans": "日本",
+ "zh-Hant": "日本"
+ },
+ "32c4531fb6749b3c": {
+ "en": "LINE and SmartHR-style clarity: use conversational Japanese, reduce reading effort, and make errors explain the next action without blaming the user",
+ "ko": "LINE과 SmartHR처럼 대화하듯 분명하게 써요. 읽는 수고를 줄이고, 오류에서는 사용자를 탓하지 않고 다음 행동을 안내해요.",
+ "ja": "LINE や SmartHR のように自然で明確な文章にします。読みやすさを大切にし、エラーではユーザーを責めずに次の操作を伝えます。",
+ "zh-Hans": "采用 LINE 和 SmartHR 风格:自然对话、降低阅读负担,错误信息不责怪用户,并说明下一步操作。",
+ "zh-Hant": "採用 LINE 與 SmartHR 風格:自然對話、降低閱讀負擔,錯誤訊息不責怪使用者,並說明下一步操作。"
+ },
+ "48a5cfab1632a804": {
+ "en": "Mainland China",
+ "ko": "중국 본토",
+ "ja": "中国本土",
+ "zh-Hans": "中国大陆",
+ "zh-Hant": "中國大陸"
+ },
+ "eb9b8cb4cc85b832": {
+ "en": "Ant Design-style user-centered copy: put important information first, state the result and next action, and avoid commands or internal terminology",
+ "ko": "Ant Design처럼 사용자 중심으로 써요. 중요한 정보와 결과·다음 행동을 먼저 전하고, 명령조나 내부 용어를 피해주세요.",
+ "ja": "Ant Design のようにユーザー中心で書きます。重要な情報、結果、次の操作を先に伝え、命令口調や内部用語を避けます。",
+ "zh-Hans": "采用 Ant Design 的用户中心文案:重要信息优先,说明结果和下一步,避免命令口吻及内部术语。",
+ "zh-Hant": "採用 Ant Design 的使用者中心文案:重要資訊優先,說明結果與下一步,避免命令口吻及內部術語。"
+ },
+ "dd76af196e2c7794": {
+ "en": "Taiwan",
+ "ko": "대만",
+ "ja": "台湾",
+ "zh-Hans": "台湾",
+ "zh-Hant": "台灣"
+ },
+ "d2e7b66ab12facd9": {
+ "en": "Taiwan-centered, task-oriented writing with familiar {0}/{1}/{2}/{3} terminology",
+ "ko": "대만에서 익숙한 {0}/{1}/{2}/{3}를 쓰고, 사용자가 하려는 일을 중심으로 설명해요.",
+ "ja": "台湾で使い慣れた {0}/{1}/{2}/{3} を使い、作業を中心に説明します。",
+ "zh-Hans": "面向台湾,围绕任务表述,使用熟悉的 {0}/{1}/{2}/{3} 术语。",
+ "zh-Hant": "以台灣用語與工作目的為主,使用熟悉的 {0}/{1}/{2}/{3}。"
+ },
+ "f0a05cbe9aa47afb": {
+ "en": "{0} currently targets Taiwan usage. Do not mix Hong Kong vocabulary into this locale. Add {1} separately if Tatami supports Hong Kong later.",
+ "ko": "{0}는 현재 대만 용어를 기준으로 해요. 홍콩 표현을 섞지 않아요. 나중에 홍콩을 지원하면 {1}를 별도로 추가해요.",
+ "ja": "{0} は現在、台湾の表現を対象とします。香港の語彙を混ぜず、対応する場合は {1} を別に追加します。",
+ "zh-Hans": "{0} 当前面向台湾用语,不混入香港词汇。将来支持香港时,单独添加 {1}。",
+ "zh-Hant": "{0} 目前以台灣用語為準,不混入香港詞彙。未來支援香港時,另行新增 {1}。"
+ },
+ "75e0b5d483105785": {
+ "en": "Tatami voice",
+ "ko": "Tatami의 말투",
+ "ja": "Tatami の文章",
+ "zh-Hans": "Tatami 的表达方式",
+ "zh-Hant": "Tatami 的表達方式"
+ },
+ "7455a1dd305e861e": {
+ "en": "Lead with the outcome: “Bring this workspace beside the current one.”",
+ "ko": "결과부터 알려줘요. 예: “이 작업 공간을 현재 공간 옆으로 가져와요.”",
+ "ja": "結果を先に伝えます。例:「このワークスペースを、今の作業の横に置きます。」",
+ "zh-Hans": "先说明结果,例如:“把这个工作区放到当前工作区旁边。”",
+ "zh-Hant": "先說明結果,例如:「把這個工作空間放到目前工作空間旁邊。」"
+ },
+ "b805c44b17f444b2": {
+ "en": "Use one idea per sentence. Put prerequisites in a separate sentence.",
+ "ko": "한 문장에는 하나의 생각만 담아요. 필요한 조건은 별도 문장으로 설명해요.",
+ "ja": "一文に一つの内容を入れ、前提条件は別の文にします。",
+ "zh-Hans": "一句话只表达一个意思,前提条件另起一句。",
+ "zh-Hant": "一句話只表達一個意思,前提條件另起一句。"
+ },
+ "7cc72293b36318f1": {
+ "en": "Name the user's object, not Tatami's internal subsystem.",
+ "ko": "내부 구조 대신 사용자가 보고 다루는 대상을 이름으로 써요.",
+ "ja": "内部の仕組みではなく、ユーザーが扱う対象を名指しします。",
+ "zh-Hans": "说用户操作的对象,不说 Tatami 的内部子系统。",
+ "zh-Hant": "說使用者操作的對象,不說 Tatami 的內部子系統。"
+ },
+ "65e6593a1aed17bb": {
+ "en": "Use direct actions for buttons: “Apply Setup”, “Open System Settings”.",
+ "ko": "버튼은 “구성 적용”, “시스템 설정 열기”처럼 행동을 직접 써요.",
+ "ja": "ボタンには「構成を適用」「システム設定を開く」のように操作を書きます。",
+ "zh-Hans": "按钮直接使用动作,如“应用设置”“打开系统设置”。",
+ "zh-Hant": "按鈕直接使用動作,如「套用設定」「開啟系統設定」。"
+ },
+ "cd7381dfadda7533": {
+ "en": "Explain destructive results before the user confirms them.",
+ "ko": "되돌리기 어려운 결과는 사용자가 확인하기 전에 설명해요.",
+ "ja": "破壊的な結果は、確定する前に説明します。",
+ "zh-Hans": "在用户确认前,说明破坏性操作的结果。",
+ "zh-Hant": "在使用者確認前,說明破壞性操作的結果。"
+ },
+ "ac7b528c5c97c0de": {
+ "en": "Do not hide an error behind a friendly euphemism. Say what failed and what the user can do next.",
+ "ko": "친근한 표현으로 오류를 감추지 않아요. 무엇이 실패했고 다음에 무엇을 할 수 있는지 알려줘요.",
+ "ja": "親しみやすい表現でエラーを隠しません。失敗したことと、次にできることを伝えます。",
+ "zh-Hans": "不要用委婉措辞掩盖错误。说明哪里失败,以及用户接下来可以做什么。",
+ "zh-Hant": "不要用委婉措辭掩蓋錯誤。說明哪裡失敗,以及使用者接下來可以做什麼。"
+ },
+ "479334bfa497cfcf": {
+ "en": "Keep paths, commands, keyboard glyphs, app names, and user-entered workspace or profile names unchanged.",
+ "ko": "경로, 명령, 키보드 기호, 앱 이름, 사용자가 정한 작업 공간·프로필 이름은 바꾸지 않아요.",
+ "ja": "パス、コマンド、キー記号、アプリ名、ユーザーが付けた作業・プロファイル名は変えません。",
+ "zh-Hans": "保留路径、命令、键盘符号、应用名称,以及用户输入的工作区和配置方案名称。",
+ "zh-Hant": "保留路徑、指令、鍵盤符號、App 名稱,以及使用者輸入的工作空間與設定組合名稱。"
+ },
+ "46ce91c61fc271c3": {
+ "en": "Core terminology",
+ "ko": "핵심 용어",
+ "ja": "基本用語",
+ "zh-Hans": "核心术语",
+ "zh-Hant": "核心用語"
+ },
+ "54287be80625eb6d": {
+ "en": "Concept",
+ "ko": "개념",
+ "ja": "概念",
+ "zh-Hans": "概念",
+ "zh-Hant": "概念"
+ },
+ "87bb59ba2f92f2a5": {
+ "en": "Workspace",
+ "ko": "작업 공간",
+ "ja": "ワークスペース",
+ "zh-Hans": "工作区",
+ "zh-Hant": "工作空間"
+ },
+ "88c78e6f631caba8": {
+ "en": "Workspace Chain",
+ "ko": "작업 공간 체인",
+ "ja": "ワークスペースチェーン",
+ "zh-Hans": "工作区链",
+ "zh-Hant": "工作空間鏈"
+ },
+ "d696a35bdd1883da": {
+ "en": "Profile",
+ "ko": "프로필",
+ "ja": "プロファイル",
+ "zh-Hans": "配置方案",
+ "zh-Hant": "設定組合"
+ },
+ "8eebbcba782daa25": {
+ "en": "Shared Apps",
+ "ko": "공용 앱",
+ "ja": "共有アプリ",
+ "zh-Hans": "共享应用",
+ "zh-Hant": "共用 App"
+ },
+ "17bd998ee042699a": {
+ "en": "Scratchpad",
+ "ko": "임시 공간",
+ "ja": "一時スペース",
+ "zh-Hans": "暂存区",
+ "zh-Hant": "暫存區"
+ },
+ "3fe02a548e905a06": {
+ "en": "Tiling",
+ "ko": "타일링",
+ "ja": "タイル表示",
+ "zh-Hans": "平铺",
+ "zh-Hant": "並排"
+ },
+ "56130e653e13cf2f": {
+ "en": "Always on top",
+ "ko": "항상 위",
+ "ja": "常に手前",
+ "zh-Hans": "置顶",
+ "zh-Hant": "置頂"
+ },
+ "4f303a61154d0e85": {
+ "en": "Left untouched / Ignore",
+ "ko": "그대로 두기 / 무시",
+ "ja": "そのまま / 対象外",
+ "zh-Hans": "保持原样 / 忽略",
+ "zh-Hant": "維持原狀 / 忽略"
+ },
+ "9d3cab2b5efed4ea": {
+ "en": "Focus",
+ "ko": "포커스",
+ "ja": "フォーカス",
+ "zh-Hans": "焦点",
+ "zh-Hant": "焦點"
+ },
+ "cfaa09f03150a231": {
+ "en": "Window switching",
+ "ko": "창 전환",
+ "ja": "ウインドウ切り替え",
+ "zh-Hans": "窗口切换",
+ "zh-Hant": "視窗切換"
+ },
+ "6b3a31f5387a7546": {
+ "en": "On-screen feedback",
+ "ko": "화면 알림",
+ "ja": "操作フィードバック",
+ "zh-Hans": "操作反馈",
+ "zh-Hant": "操作回饋"
+ },
+ "722fb89dbfe502c1": {
+ "en": "Use these terms consistently. A locale may rewrite an entire sentence around the term; it must not silently change the underlying behavior.",
+ "ko": "용어를 일관되게 써요. 자연스럽게 하려고 문장 전체를 바꿀 수는 있지만, 기능의 의미를 바꾸면 안 돼요.",
+ "ja": "用語を一貫させます。自然な文に組み直しても、機能の意味は変えません。",
+ "zh-Hans": "一致使用这些术语。可以为自然表达重写整句,但不能悄悄改变功能含义。",
+ "zh-Hant": "一致使用這些用語。可以為自然表達改寫整句,但不能悄悄改變功能含義。"
+ },
+ "4b010675673c0c4b": {
+ "en": "In Korean, keep only the product name {0} in English. Write functional concepts in immediately understandable Korean: 작업 공간, 프로필, 공용 앱, 빌려오기, 임시 공간, and 항상 위. Prefer an outcome such as {1} over a technical transliteration such as {2}.",
+ "ko": "한국어에서는 제품명 {0}만 영어로 남겨요. 기능은 작업 공간, 프로필, 공용 앱, 빌려오기, 임시 공간, 항상 위처럼 바로 이해할 수 있게 써요. {2} 같은 기술 음역보다 {1}처럼 결과를 알려주세요.",
+ "ja": "韓国語では製品名 {0} を英語のままにし、機能は 작업 공간、프로필、공용 앱、빌려오기、임시 공간、항상 위 と明確に書きます。技術的な音写 {2} より、結果を示す {1} を使います。",
+ "zh-Hans": "韩语中只保留产品名 {0} 为英文。功能使用易懂的韩语:작업 공간、프로필、공용 앱、빌려오기、임시 공간、항상 위。优先使用表示结果的 {1},避免技术音译 {2}。",
+ "zh-Hant": "韓文中只保留產品名 {0} 為英文。功能使用易懂的韓文:작업 공간、프로필、공용 앱、빌려오기、임시 공간、항상 위。優先使用表示結果的 {1},避免技術音譯 {2}。"
+ },
+ "15448913af879cbc": {
+ "en": "English voice",
+ "ko": "영어 말투",
+ "ja": "英語の文章",
+ "zh-Hans": "英语文案",
+ "zh-Hant": "英文文案"
+ },
+ "f5ec0ad8198393c2": {
+ "en": "Follow Apple writing guidance: make copy clear, concise, useful, and action-oriented. Put the result or benefit before instructions.",
+ "ko": "Apple 글쓰기 지침처럼 분명하고 짧고 유용하게 써요. 방법보다 결과와 좋은 점을 먼저 알려줘요.",
+ "ja": "Apple の指針に沿って、明確・簡潔・有用に書きます。手順より結果や利点を先に伝えます。",
+ "zh-Hans": "遵循 Apple 写作原则:清晰、简洁、实用、行动明确,先讲结果或好处,再讲步骤。",
+ "zh-Hant": "遵循 Apple 寫作原則:清楚、簡潔、實用、行動明確,先講結果或好處,再講步驟。"
+ },
+ "61403ced2d46773e": {
+ "en": "Prefer the user's visible object and action: {0}, {1}, and {2} replace internal terms such as {3}, {4}, {5}, and {6}.",
+ "ko": "사용자가 보는 대상과 행동인 {0}, {1}, {2}를 써요. 내부 용어 {3}, {4}, {5}, {6}는 대신 사용하지 않아요.",
+ "ja": "ユーザーに見える {0}、{1}、{2} を使い、内部用語の {3}、{4}、{5}、{6} に置き換えないでください。",
+ "zh-Hans": "使用用户可见的对象和动作 {0}、{1}、{2},代替内部术语 {3}、{4}、{5}、{6}。",
+ "zh-Hant": "使用使用者可見的對象與動作 {0}、{1}、{2},取代內部術語 {3}、{4}、{5}、{6}。"
+ },
+ "2d94fcc054b12e45": {
+ "en": "Buttons and menu commands start with a verb. Settings labels describe what happens when the setting is on.",
+ "ko": "버튼과 메뉴 명령은 동사로 시작해요. 설정 이름은 켰을 때 어떤 일이 일어나는지 설명해요.",
+ "ja": "ボタンとメニューは動詞で始め、設定ラベルは有効にしたときの動作を示します。",
+ "zh-Hans": "按钮和菜单命令以动词开头,设置标签说明启用后会发生什么。",
+ "zh-Hant": "按鈕和選單指令以動詞開頭,設定標籤說明啟用後會發生什麼。"
+ },
+ "e5bd6de59bf4c80c": {
+ "en": "Empty states name what is missing and, when useful, follow with one next action. Errors say what failed, what remains safe, and how to recover.",
+ "ko": "빈 화면에는 무엇이 없는지 쓰고 필요하면 다음 행동 하나를 알려줘요. 오류는 실패한 일, 안전하게 남은 내용, 복구 방법을 설명해요.",
+ "ja": "空の状態では何がないかを示し、必要なら次の操作を一つ伝えます。エラーでは失敗、保持されたもの、復旧方法を説明します。",
+ "zh-Hans": "空状态说明缺少什么,并在有帮助时提供一个下一步操作。错误说明失败内容、仍然安全的部分和恢复方法。",
+ "zh-Hant": "空白狀態說明缺少什麼,並在有幫助時提供一個下一步操作。錯誤說明失敗內容、仍然安全的部分與復原方法。"
+ },
+ "b7a8caacbcccc35d": {
+ "en": "Keep {0}, {1}, and file names only where the implementation itself is being inspected or configured.",
+ "ko": "구현을 살펴보거나 설정하는 곳에서만 {0}, {1}, 파일 이름을 드러내요.",
+ "ja": "実装を確認・設定する場所でだけ、{0}、{1}、ファイル名を示します。",
+ "zh-Hans": "只在检查或配置实现本身的地方显示 {0}、{1} 和文件名。",
+ "zh-Hant": "只在檢查或設定實作本身的地方顯示 {0}、{1} 與檔案名稱。"
+ },
+ "7b4a52832f9a7126": {
+ "en": "Korean voice",
+ "ko": "한국어 말투",
+ "ja": "韓国語の文章",
+ "zh-Hans": "韩语文案",
+ "zh-Hant": "韓文文案"
+ },
+ "d2090823819c41cf": {
+ "en": "Use 해요체 for titles, empty states, descriptions, confirmations, and errors. Keep buttons, picker values, and section headings as short action or noun labels when a full sentence would slow scanning.",
+ "ko": "제목·빈 화면·설명·확인·오류에는 해요체를 써요. 버튼·선택값·섹션 제목은 긴 문장보다 짧은 행동이나 명사로 빠르게 읽히게 해요.",
+ "ja": "見出し、空の状態、説明、確認、エラーには韓国語の 해요 体を使います。ボタンや選択肢、区切りの見出しは短い動作・名詞にします。",
+ "zh-Hans": "标题、空状态、说明、确认和错误使用韩语 해요 体。按钮、选项和区块标题用简短动作或名词,便于扫描。",
+ "zh-Hant": "標題、空白狀態、說明、確認與錯誤使用韓文 해요 體。按鈕、選項和區塊標題用簡短動作或名詞,便於瀏覽。"
+ },
+ "8fd3536adb54b2e5": {
+ "en": "Describe the user's action, not a system state: {0} becomes {1}, and {2} becomes {3}.",
+ "ko": "상태보다 사용자의 행동을 설명해요. {0}은 {1}로, {2}는 {3}으로 바꿔요.",
+ "ja": "システム状態よりユーザーの操作を書きます。{0} は {1} に、{2} は {3} にします。",
+ "zh-Hans": "描述用户的动作,而非系统状态:把 {0} 改为 {1},把 {2} 改为 {3}。",
+ "zh-Hant": "描述使用者的動作,而非系統狀態:把 {0} 改為 {1},把 {2} 改為 {3}。"
+ },
+ "fb31acf9849b9d1c": {
+ "en": "Follow a problem with the next useful action when one exists. Do not repeat the title in the body.",
+ "ko": "문제 뒤에는 가능한 다음 행동을 알려줘요. 제목 내용을 본문에서 되풀이하지 않아요.",
+ "ja": "問題の後には、可能なら次の操作を示します。本文で見出しを繰り返しません。",
+ "zh-Hans": "问题后说明可行的下一步,不在正文中重复标题。",
+ "zh-Hant": "問題後說明可行的下一步,不在內文中重複標題。"
+ },
+ "47c1ce193f86fc4b": {
+ "en": "Prefer everyday outcomes over implementation terms: use {0} instead of {1} or {2}, but keep necessary domain terms such as {3}, {4}, and keyboard key names.",
+ "ko": "구현 용어보다 익숙한 결과를 써요. {1}, {2} 대신 {0}을 쓰되 {3}, {4}, 키보드 키 이름처럼 필요한 용어는 유지해요.",
+ "ja": "実装用語より身近な結果を伝えます。{1}、{2} の代わりに {0} を使い、必要な {3}、{4}、キー名は残します。",
+ "zh-Hans": "优先日常结果而非实现术语,用 {0} 代替 {1} 或 {2},但保留必要的 {3}、{4} 和键名。",
+ "zh-Hant": "優先日常結果而非實作術語,用 {0} 取代 {1} 或 {2},但保留必要的 {3}、{4} 與按鍵名稱。"
+ },
+ "2645fd33b16b5620": {
+ "en": "Never attach a variable Korean postposition to a user-entered name. Rewrite {0} as {1}.",
+ "ko": "사용자가 정한 이름 뒤에 달라지는 조사를 붙이지 않아요. {0} 대신 {1}처럼 써요.",
+ "ja": "ユーザーが付けた名前に変化する韓国語の助詞を付けません。{0} は {1} と書き換えます。",
+ "zh-Hans": "不要在用户输入的名字后直接附加会变化的韩语助词。把 {0} 改写为 {1}。",
+ "zh-Hant": "不要在使用者輸入的名字後直接附加會變化的韓文助詞。把 {0} 改寫為 {1}。"
+ },
+ "d259732653a01046": {
+ "en": "Japanese voice",
+ "ko": "일본어 말투",
+ "ja": "日本語の文章",
+ "zh-Hans": "日语文案",
+ "zh-Hant": "日文文案"
+ },
+ "85f855da480cc6e6": {
+ "en": "Use familiar spoken Japanese without overusing polite filler. Descriptions use complete {0} sentences; buttons and menu commands use short actions.",
+ "ko": "익숙한 구어체를 쓰고 지나친 공손 표현은 덜어내요. 설명은 완전한 {0} 문장으로, 버튼과 메뉴는 짧은 행동으로 써요.",
+ "ja": "自然な話し言葉を使い、丁寧な前置きを重ねません。説明は {0} の文、ボタンとメニューは短い操作名にします。",
+ "zh-Hans": "使用熟悉的口语,避免过多客套。说明使用完整的 {0} 句式,按钮和菜单使用简短动作。",
+ "zh-Hant": "使用熟悉的口語,避免過多客套。說明使用完整的 {0} 句式,按鈕和選單使用簡短動作。"
+ },
+ "6d4e2e9273e3fea2": {
+ "en": "Prefer {0}, {1}, {2}, {3}, and {4} over stiff translations or unexplained loanwords.",
+ "ko": "딱딱한 번역이나 설명 없는 외래어보다 {0}, {1}, {2}, {3}, {4}를 써요.",
+ "ja": "硬い翻訳や説明のない外来語より、{0}、{1}、{2}、{3}、{4} を使います。",
+ "zh-Hans": "优先使用 {0}、{1}、{2}、{3}、{4},避免生硬直译或未经解释的外来语。",
+ "zh-Hant": "優先使用 {0}、{1}、{2}、{3}、{4},避免生硬直譯或未經解釋的外來語。"
+ },
+ "292af230b438e39a": {
+ "en": "Empty states are complete statements such as {0}. Errors explain the problem and the next useful action without blaming the user.",
+ "ko": "빈 화면도 {0}처럼 완전한 문장으로 써요. 오류에서는 사용자를 탓하지 않고 문제와 다음 행동을 알려줘요.",
+ "ja": "空の状態も {0} のような文にします。エラーではユーザーを責めず、問題と次の操作を伝えます。",
+ "zh-Hans": "空状态使用 {0} 这样的完整陈述,错误说明问题和下一步,不责怪用户。",
+ "zh-Hant": "空白狀態使用 {0} 這樣的完整陳述,錯誤說明問題與下一步,不責怪使用者。"
+ },
+ "c8243062dac9ed38": {
+ "en": "Simplified Chinese voice",
+ "ko": "중국어 간체 말투",
+ "ja": "簡体字中国語の文章",
+ "zh-Hans": "简体中文文案",
+ "zh-Hant": "簡體中文文案"
+ },
+ "279f3ee9a2a5a457": {
+ "en": "Put the outcome before the operation. Use short, complete wording and make the next action explicit when something fails.",
+ "ko": "동작보다 결과를 먼저 알려줘요. 짧고 완전하게 쓰고 실패하면 다음 행동을 분명히 안내해요.",
+ "ja": "操作より結果を先に伝えます。短く完全な文で、失敗したら次の操作を明確にします。",
+ "zh-Hans": "先说结果,再说操作。措辞简短完整,失败时明确下一步。",
+ "zh-Hant": "先說結果,再說操作。措辭簡短完整,失敗時明確說明下一步。"
+ },
+ "47876834982df010": {
+ "en": "Use {0}, {1}, {2}, {3}, {4}, and {5} consistently. Do not mix English {6} into Mainland Chinese UI copy.",
+ "ko": "{0}, {1}, {2}, {3}, {4}, {5}를 일관되게 써요. 중국 본토 UI에 영어 {6}을 섞지 않아요.",
+ "ja": "{0}、{1}、{2}、{3}、{4}、{5} を一貫して使い、中国本土向けの文に英語の {6} を混ぜません。",
+ "zh-Hans": "一致使用 {0}、{1}、{2}、{3}、{4}、{5},不要在大陆界面文案中混用英文 {6}。",
+ "zh-Hant": "一致使用 {0}、{1}、{2}、{3}、{4}、{5},不要在中國大陸介面文案中混用英文 {6}。"
+ },
+ "d85ee57bc91c2576": {
+ "en": "Keep labels free of unnecessary punctuation. Use punctuation for complete sentences and multi-step guidance.",
+ "ko": "짧은 이름에는 불필요한 문장부호를 넣지 않아요. 완전한 문장이나 여러 단계 안내에는 알맞게 사용해요.",
+ "ja": "短いラベルに不要な句読点を付けません。文や複数手順の案内では適切に使います。",
+ "zh-Hans": "标签不加多余标点,完整句子和多步骤说明使用适当标点。",
+ "zh-Hant": "標籤不加多餘標點,完整句子與多步驟說明使用適當標點。"
+ },
+ "f36c30031e2518a5": {
+ "en": "Traditional Chinese voice",
+ "ko": "중국어 번체 말투",
+ "ja": "繁体字中国語の文章",
+ "zh-Hans": "繁体中文文案",
+ "zh-Hant": "繁體中文文案"
+ },
+ "3d6a6f7d114e3aee": {
+ "en": "Target Taiwan usage. Use {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, and {8}.",
+ "ko": "대만 용어를 기준으로 해요. {0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}를 사용해요.",
+ "ja": "台湾の表現を対象に、{0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8} を使います。",
+ "zh-Hans": "面向台湾用语,使用 {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8}。",
+ "zh-Hant": "以台灣用語為準,使用 {0}、{1}、{2}、{3}、{4}、{5}、{6}、{7}、{8}。"
+ },
+ "a92858d52b21a46b": {
+ "en": "Prefer task language and visible outcomes over literal technical translations. Use {0}, {1}, {2}, and {3} in their familiar macOS contexts.",
+ "ko": "기술 용어의 직역보다 작업과 눈에 보이는 결과를 설명해요. macOS에서 익숙한 {0}, {1}, {2}, {3}를 써요.",
+ "ja": "技術用語の直訳より、作業と見える結果を説明します。macOS で使い慣れた {0}、{1}、{2}、{3} を使います。",
+ "zh-Hans": "优先描述任务和可见结果,而非逐字翻译技术术语。在 macOS 场景中使用熟悉的 {0}、{1}、{2}、{3}。",
+ "zh-Hant": "優先描述工作與可見結果,而非逐字翻譯技術用語。在 macOS 情境中使用熟悉的 {0}、{1}、{2}、{3}。"
+ },
+ "a1dc8d8bd59d483f": {
+ "en": "Keep Hong Kong-specific vocabulary out of {0}; add {1} as a separate locale if needed.",
+ "ko": "{0}에 홍콩 용어를 섞지 않아요. 필요하면 {1}를 별도 로케일로 추가해요.",
+ "ja": "{0} に香港の語彙を混ぜません。必要なら {1} を別に追加します。",
+ "zh-Hans": "不要在 {0} 中混入香港特有词汇,需要时单独添加 {1}。",
+ "zh-Hant": "不要在 {0} 中混入香港特有詞彙,需要時另行新增 {1}。"
+ },
+ "8fd23003319ca1ff": {
+ "en": "Implementation rules",
+ "ko": "구현 규칙",
+ "ja": "実装のルール",
+ "zh-Hans": "实现规则",
+ "zh-Hant": "實作規則"
+ },
+ "965fc6bf093956d7": {
+ "en": "Put app and {0} UI strings in {1}. {2} is a static framework, so Tatami intentionally owns the runtime catalog in the main app bundle.",
+ "ko": "앱과 {0}의 UI 문구는 {1}에 넣어요. {2}는 정적 프레임워크라 런타임 카탈로그를 Tatami의 메인 앱 번들이 소유해요.",
+ "ja": "アプリと {0} の UI 文言は {1} に置きます。{2} は静的フレームワークなので、実行時カタログは Tatami のメインバンドルが持ちます。",
+ "zh-Hans": "应用和 {0} 的界面字符串放在 {1}。{2} 是静态框架,因此运行时目录由 Tatami 主应用包持有。",
+ "zh-Hant": "App 與 {0} 的介面字串放在 {1}。{2} 是靜態框架,因此執行時目錄由 Tatami 主 App 套件持有。"
+ },
+ "b1beaef39eec9fd2": {
+ "en": "Keep extracted English source keys stable. Store reviewed displayed English as explicit {0} localizations in the catalog, so UX copy can improve without renaming every Swift lookup key.",
+ "ko": "추출한 영어 소스 키는 안정적으로 유지해요. 다듬은 영어 표시 문구는 카탈로그의 명시적 {0} 값에 저장해서 Swift 조회 키를 바꾸지 않고 문구를 개선해요.",
+ "ja": "抽出した英語キーは固定し、見直した英語の表示文を明示的な {0} に保存します。Swift のキーを変えずに文章を改善できます。",
+ "zh-Hans": "保持提取的英语源键稳定,把审核后的英语显示文本存为明确的 {0} 本地化值,避免每次优化文案都重命名 Swift 查询键。",
+ "zh-Hant": "保持擷取的英文來源鍵穩定,把檢查後的英文顯示文字存為明確的 {0} 在地化值,避免每次改善文案都重新命名 Swift 查詢鍵。"
+ },
+ "89777d7e29062756": {
+ "en": "Use {0} for fixed UI copy passed between models, helpers, and views. Keep user-entered names and discovered app/display names as {1}.",
+ "ko": "모델·헬퍼·뷰 사이의 고정 문구는 {0}로 전달해요. 사용자 이름과 검색된 앱·화면 이름은 {1}으로 유지해요.",
+ "ja": "モデル・ヘルパー・ビュー間の固定文は {0} で渡し、ユーザーの名前や検出したアプリ・画面名は {1} のままにします。",
+ "zh-Hans": "模型、辅助代码和视图间传递固定文案使用 {0};用户输入的名称和发现的应用、显示器名称保留为 {1}。",
+ "zh-Hant": "模型、輔助程式碼與視圖間傳遞固定文案使用 {0};使用者輸入的名稱與找到的 App、顯示器名稱保留為 {1}。"
+ },
+ "61114d59fcbb9a41": {
+ "en": "Pass string literals directly to SwiftUI controls.",
+ "ko": "문자열 리터럴은 SwiftUI 컨트롤에 직접 넘겨요.",
+ "ja": "文字列リテラルは SwiftUI コントロールへ直接渡します。",
+ "zh-Hans": "把字符串字面量直接传给 SwiftUI 控件。",
+ "zh-Hant": "把字串常值直接傳給 SwiftUI 控制項。"
+ },
+ "792b99b81c9422d1": {
+ "en": "Use {0} only when a concrete {1} is required outside a SwiftUI initializer.",
+ "ko": "SwiftUI 초기화 밖에서 실제 {1}이 필요할 때만 {0}를 써요.",
+ "ja": "SwiftUI の初期化の外で具体的な {1} が必要な場合だけ {0} を使います。",
+ "zh-Hans": "只在 SwiftUI 初始化器之外需要具体 {1} 时使用 {0}。",
+ "zh-Hant": "只在 SwiftUI 初始化器之外需要具體 {1} 時使用 {0}。"
+ },
+ "35bf479d2e8508d0": {
+ "en": "Interpolate a complete sentence. Never concatenate translated fragments.",
+ "ko": "완전한 문장에 값을 넣어요. 번역된 조각을 이어 붙이지 않아요.",
+ "ja": "文全体に値を補間し、翻訳した断片をつなぎません。",
+ "zh-Hans": "插值完整句子,不拼接已翻译的碎片。",
+ "zh-Hant": "插值完整句子,不串接已翻譯的片段。"
+ },
+ "103ec8b9668ac997": {
+ "en": "Preserve format placeholders and let each locale reorder them.",
+ "ko": "서식 자리표시자를 보존하고 언어마다 순서를 바꿀 수 있게 해요.",
+ "ja": "書式のプレースホルダーを保ち、言語ごとに順序を変えられるようにします。",
+ "zh-Hans": "保留格式占位符,允许各语言调整顺序。",
+ "zh-Hant": "保留格式預留位置,允許各語言調整順序。"
+ },
+ "676066948b5f7677": {
+ "en": "Use locale-aware {0} for user-visible numbers and dates.",
+ "ko": "숫자와 날짜는 로케일을 반영하는 {0}으로 보여줘요.",
+ "ja": "数字と日付はロケールに対応する {0} で表示します。",
+ "zh-Hans": "用户可见的数字和日期使用支持区域语言的 {0}。",
+ "zh-Hant": "使用者可見的數字與日期使用支援地區語言的 {0}。"
+ },
+ "079ee84a023ccebf": {
+ "en": "Do not uppercase localized copy at runtime.",
+ "ko": "번역 문구를 실행 중에 대문자로 바꾸지 않아요.",
+ "ja": "翻訳した文を実行時に大文字へ変換しません。",
+ "zh-Hans": "不要在运行时把本地化文案转换为大写。",
+ "zh-Hant": "不要在執行時把在地化文案轉換為大寫。"
+ },
+ "11e76b5d3ad9be3c": {
+ "en": "Do not use em dashes in interface copy. Split the thought into sentences or use punctuation that fits the locale.",
+ "ko": "UI 문구에 em dash를 쓰지 않아요. 문장을 나누거나 해당 언어에 맞는 문장부호를 써요.",
+ "ja": "UI 文言に em dash を使いません。文を分けるか、その言語に合う句読点を使います。",
+ "zh-Hans": "界面文案不用 em dash,拆成句子或使用符合语言习惯的标点。",
+ "zh-Hant": "介面文案不用 em dash,拆成句子或使用符合語言習慣的標點。"
+ },
+ "bcc4f8e0e139158c": {
+ "en": "Documentation, website and demo films",
+ "ko": "문서·웹·데모 영상",
+ "ja": "文書・Web・デモ動画",
+ "zh-Hans": "文档、网站与演示视频",
+ "zh-Hant": "文件、網站與示範影片"
+ },
+ "ee9ae79820e4fe1f": {
+ "en": "The same terminology and writing rules apply outside the app.",
+ "ko": "앱 밖에서도 같은 용어와 글쓰기 규칙을 적용해요.",
+ "ja": "アプリの外でも、同じ用語と文章のルールを使います。",
+ "zh-Hans": "应用之外也遵循相同术语和写作规则。",
+ "zh-Hant": "App 之外也遵循相同用語與寫作規則。"
+ },
+ "0e4048e57a21ff86": {
+ "en": "Keep English documentation as the source, with localized README and guide files generated from {0}. Preserve commands, code samples, identifiers, link destinations and explicit heading anchors.",
+ "ko": "영어 문서를 기준으로 두고 {0}에서 언어별 README와 안내를 만들어요. 명령, 코드 예제, 식별자, 링크 대상, 명시적인 제목 앵커를 보존해요.",
+ "ja": "英語の文書を元にし、{0} から各言語の README とガイドを生成します。コマンド、コード例、識別子、リンク先、見出しアンカーは保ちます。",
+ "zh-Hans": "以英语文档为源,从 {0} 生成各语言 README 和指南,保留命令、代码示例、标识符、链接目标及明确的标题锚点。",
+ "zh-Hant": "以英文文件為來源,從 {0} 產生各語言 README 與指南,保留指令、程式碼範例、識別碼、連結目標及明確的標題錨點。"
+ },
+ "cd1b0407da30c7be": {
+ "en": "{0} contains complete website text units. Each locale has its own URL and document language, including navigation, accessible labels, loading/error states, documentation and demo gallery controls.",
+ "ko": "{0}에는 완전한 웹 문구를 저장해요. 언어마다 별도 URL과 문서 언어를 두고 탐색, 접근성 이름, 로딩·오류, 문서, 영상 컨트롤까지 번역해요.",
+ "ja": "{0} に Web の完全な文を保存します。各言語に URL と文書言語を設け、ナビゲーション、アクセシビリティ、読み込み・エラー、文書、動画操作を翻訳します。",
+ "zh-Hans": "{0} 保存完整网站文本单元。每种语言有独立 URL 和文档语言,覆盖导航、无障碍标签、加载与错误状态、文档和视频集控件。",
+ "zh-Hant": "{0} 儲存完整網站文字單元。每種語言有獨立 URL 與文件語言,涵蓋導覽、輔助使用標籤、載入與錯誤狀態、文件和影片集控制項。"
+ },
+ "b060b70d44d74046": {
+ "en": "Demo Lab owns a separate {0} catalog. The independent fixture apps each include that catalog in their main bundle. Shared helpers pass {1}; saved fixture data resolves complete strings in the language selected for that recording.",
+ "ko": "Demo Lab은 별도의 {0}을 사용해요. 각 데모 앱의 메인 번들에 같은 카탈로그를 넣고, 공용 헬퍼는 {1}를 전달해요. 저장할 데모 내용은 해당 촬영 언어로 완전한 문장을 만들어요.",
+ "ja": "Demo Lab は独立した {0} を持ち、各デモアプリのメインバンドルに含めます。共有ヘルパーは {1} を渡し、保存する内容は撮影言語の完全な文で解決します。",
+ "zh-Hans": "Demo Lab 使用独立的 {0} 目录,各演示应用都在主包中包含它。共享辅助代码传递 {1},保存的演示内容按本次录制语言解析完整字符串。",
+ "zh-Hant": "Demo Lab 使用獨立的 {0} 目錄,各示範 App 都在主套件中包含它。共用輔助程式碼傳遞 {1},儲存的示範內容依本次錄製語言解析完整字串。"
+ },
+ "58b8e13bc91873c4": {
+ "en": "Film captions, titles and human input share {0}. Generate input assertions from those same values. Prefer stable accessibility identifiers over translated labels, which may legitimately be identical.",
+ "ko": "영상 자막·제목·입력 문구는 {0}을 함께 써요. 검증값도 같은 문구에서 만들어요. 번역된 이름은 같을 수 있으니 안정적인 접근성 식별자를 우선 사용해요.",
+ "ja": "字幕、タイトル、入力文は {0} を共有し、検証値もそこから作ります。翻訳ラベルは同じになることがあるため、安定したアクセシビリティ識別子を優先します。",
+ "zh-Hans": "字幕、标题和人工输入共用 {0},从相同值生成输入断言。翻译后的标签可能相同,因此优先使用稳定的无障碍标识符。",
+ "zh-Hant": "字幕、標題與人工輸入共用 {0},從相同值產生輸入斷言。翻譯後的標籤可能相同,因此優先使用穩定的輔助使用識別碼。"
+ },
+ "ad31e597d61767f2": {
+ "en": "Record and validate each app language. Translated subtitles over English UI do not establish that a localized app was exercised.",
+ "ko": "각 앱 언어로 녹화하고 검증해요. 영어 UI 위에 번역 자막을 넣은 것만으로는 해당 언어의 앱을 검증했다고 볼 수 없어요.",
+ "ja": "各アプリ言語で録画・検証します。英語 UI に翻訳字幕を重ねただけでは、その言語のアプリを試した証拠になりません。",
+ "zh-Hans": "按每种应用语言录制并验证。英语界面加翻译字幕,不能证明实际测试了本地化应用。",
+ "zh-Hant": "依每種 App 語言錄製並驗證。英文介面加翻譯字幕,不能證明實際測試了在地化 App。"
+ },
+ "4ecd0a99bdfdabf6": {
+ "en": "Reject missing translations and changed placeholders at build time. A translation must not quietly fall back to English to make a build pass.",
+ "ko": "번역이 빠지거나 자리표시자가 달라지면 빌드에서 거부해요. 빌드를 통과시키려고 영어로 조용히 대체하지 않아요.",
+ "ja": "翻訳の欠落やプレースホルダーの変更はビルドで拒否します。通すために英語へ黙って戻しません。",
+ "zh-Hans": "在构建时拒绝缺失翻译或被改变的占位符,不通过静默回退英语让构建通过。",
+ "zh-Hant": "在建置時拒絕缺少翻譯或被變更的預留位置,不透過靜默改用英文讓建置通過。"
+ },
+ "315166eb02947f6d": {
+ "en": "Preserve historical release notes and license/copyright texts in their original language. Translate navigation and explanatory notices, and link clearly to the authoritative original.",
+ "ko": "과거 릴리스 노트와 라이선스·저작권 문장은 원문으로 보존해요. 탐색과 설명을 번역하고 공식 원문 링크를 분명하게 제공해요.",
+ "ja": "過去のリリースノートとライセンス・著作権文は原文を保ちます。案内と説明を翻訳し、正式な原文へ明確にリンクします。",
+ "zh-Hans": "历史版本说明和许可证、版权文本保留原文。翻译导航与解释,并明确链接到权威原文。",
+ "zh-Hant": "歷史版本說明與授權、著作權文字保留原文。翻譯導覽與解釋,並明確連結到具效力的原文。"
+ },
+ "a623a46394632f2e": {
+ "en": "Review checklist",
+ "ko": "검수 항목",
+ "ja": "確認項目",
+ "zh-Hans": "检查清单",
+ "zh-Hant": "檢查清單"
+ },
+ "4bd04b79e02f08f5": {
+ "en": "Build with {0}, then sync compiler-generated {1} into the catalog.",
+ "ko": "{0}로 빌드한 뒤 컴파일러가 만든 {1}를 카탈로그에 동기화해요.",
+ "ja": "{0} でビルドし、コンパイラが作る {1} をカタログに同期します。",
+ "zh-Hans": "使用 {0} 构建,再把编译器生成的 {1} 同步到目录。",
+ "zh-Hant": "使用 {0} 建置,再把編譯器產生的 {1} 同步到目錄。"
+ },
+ "38e649f35d5ab61a": {
+ "en": "Verify every supported locale has a translation for every translatable key.",
+ "ko": "번역할 모든 키에 지원 언어별 값이 있는지 확인해요.",
+ "ja": "翻訳対象の全キーに、各対応言語の値があることを確認します。",
+ "zh-Hans": "确认每个可翻译键都有所有支持语言的翻译。",
+ "zh-Hant": "確認每個可翻譯鍵都有所有支援語言的翻譯。"
+ },
+ "3b403b3cba2d142d": {
+ "en": "Verify each translation preserves the source placeholder set.",
+ "ko": "각 번역이 원문의 자리표시자를 그대로 쓰는지 확인해요.",
+ "ja": "各翻訳が元のプレースホルダーを保つことを確認します。",
+ "zh-Hans": "确认每条翻译保留源文占位符集合。",
+ "zh-Hant": "確認每條翻譯保留來源預留位置集合。"
+ },
+ "9950187c889a623d": {
+ "en": "Launch once per locale with the scheme's Application Language override.",
+ "ko": "스킴의 Application Language 설정으로 언어마다 한 번씩 실행해요.",
+ "ja": "スキームの Application Language を指定し、各言語で一度起動します。",
+ "zh-Hans": "通过 scheme 的 Application Language 覆盖设置,分别启动各语言。",
+ "zh-Hant": "透過 scheme 的 Application Language 覆寫設定,分別啟動各語言。"
+ },
+ "a4b633b0efcf60f0": {
+ "en": "Check Settings, menu bar, destructive confirmations, empty states, and all Guided Setup steps for clipping and mixed-language text.",
+ "ko": "설정, 메뉴 막대, 삭제 확인, 빈 화면, 시작 안내 전 단계에서 잘리거나 언어가 섞이지 않는지 살펴봐요.",
+ "ja": "設定、メニューバー、破壊的操作の確認、空の状態、ガイド全体で、文字切れや言語の混在を確認します。",
+ "zh-Hans": "检查设置、菜单栏、破坏性确认、空状态和所有设置向导步骤,确认没有截断或混合语言。",
+ "zh-Hant": "檢查設定、選單列、破壞性確認、空白狀態與所有設定導覽步驟,確認沒有截斷或混合語言。"
+ },
+ "b5814afab20b17ca": {
+ "en": "Have a native reviewer check tone and terminology before release. Tatami's catalog is written from screen context; do not seed it with machine translation.",
+ "ko": "릴리스 전에는 원어민 검수자가 말투와 용어를 확인해요. 화면 맥락을 보고 문구를 작성하며 기계 번역으로 카탈로그를 채우지 않아요.",
+ "ja": "リリース前に母語話者が表現と用語を確認します。画面の文脈から文章を書き、機械翻訳でカタログを埋めません。",
+ "zh-Hans": "发布前请母语审阅者检查语气和术语。根据屏幕上下文编写目录,不以机器翻译填充。",
+ "zh-Hant": "發布前請母語審閱者檢查語氣與用語。依畫面情境撰寫目錄,不以機器翻譯填入。"
+ },
+ "b49c056da56534f1": {
+ "en": "Public references",
+ "ko": "공개 참고 자료",
+ "ja": "公開の参考資料",
+ "zh-Hans": "公开参考资料",
+ "zh-Hant": "公開參考資料"
+ },
+ "0905478cc2c0dc26": {
+ "en": "Toss: {0}",
+ "ko": "토스: {0}",
+ "ja": "Toss:{0}",
+ "zh-Hans": "Toss:{0}",
+ "zh-Hant": "Toss:{0}"
+ },
+ "6168bf846424d493": {
+ "en": "Apple Human Interface Guidelines: Writing: {0}",
+ "ko": "Apple Human Interface Guidelines 글쓰기: {0}",
+ "ja": "Apple Human Interface Guidelines の文章:{0}",
+ "zh-Hans": "Apple 人机界面指南:写作:{0}",
+ "zh-Hant": "Apple 人機介面指南:寫作:{0}"
+ },
+ "05c650c5db2967f6": {
+ "en": "Apple WWDC25: Make a big impact with small writing changes: {0}",
+ "ko": "Apple WWDC25 작은 문구로 큰 차이 만들기: {0}",
+ "ja": "Apple WWDC25 小さな文章変更で大きな効果を:{0}",
+ "zh-Hans": "Apple WWDC25:用小幅文案改动带来大影响:{0}",
+ "zh-Hant": "Apple WWDC25:用小幅文案改動帶來大影響:{0}"
+ },
+ "3c4c941c796ebdc5": {
+ "en": "LINE Voice: {0}",
+ "ko": "LINE Voice: {0}",
+ "ja": "LINE Voice:{0}",
+ "zh-Hans": "LINE Voice:{0}",
+ "zh-Hant": "LINE Voice:{0}"
+ },
+ "965a371f6d0fa88c": {
+ "en": "SmartHR writing style: {0}",
+ "ko": "SmartHR 글쓰기 방식: {0}",
+ "ja": "SmartHR のライティングスタイル:{0}",
+ "zh-Hans": "SmartHR 写作风格:{0}",
+ "zh-Hant": "SmartHR 寫作風格:{0}"
+ },
+ "b9261f7d28a85f03": {
+ "en": "SmartHR UI text: {0}",
+ "ko": "SmartHR UI 문구: {0}",
+ "ja": "SmartHR の UI テキスト:{0}",
+ "zh-Hans": "SmartHR 界面文案:{0}",
+ "zh-Hant": "SmartHR 介面文案:{0}"
+ },
+ "f16102ed3bedede2": {
+ "en": "SmartHR error messages: {0}",
+ "ko": "SmartHR 오류 메시지: {0}",
+ "ja": "SmartHR のエラーメッセージ:{0}",
+ "zh-Hans": "SmartHR 错误信息:{0}",
+ "zh-Hant": "SmartHR 錯誤訊息:{0}"
+ },
+ "ff6b4d54146c79da": {
+ "en": "Ant Design copywriting: {0}",
+ "ko": "Ant Design 문구 작성: {0}",
+ "ja": "Ant Design のコピーライティング:{0}",
+ "zh-Hans": "Ant Design 文案:{0}",
+ "zh-Hant": "Ant Design 文案:{0}"
+ },
+ "55ea017dc5df9258": {
+ "en": "Taiwan government web content guidelines: {0}",
+ "ko": "대만 정부 웹 콘텐츠 지침: {0}",
+ "ja": "台湾政府の Web コンテンツ指針:{0}",
+ "zh-Hans": "台湾政府网站内容指南:{0}",
+ "zh-Hant": "台灣政府網站內容指引:{0}"
+ },
+ "c3af076f92c5ed8d": {
+ "en": "Troubleshooting",
+ "ko": "문제 해결",
+ "ja": "トラブルシューティング",
+ "zh-Hans": "故障排查",
+ "zh-Hant": "疑難排解"
+ },
+ "b8408e770df219c6": {
+ "en": "A floating control disappears or brings its app back",
+ "ko": "별도 컨트롤이 사라지거나 앱이 다시 나타나요.",
+ "ja": "独立したコントロールが消える、またはアプリが戻る",
+ "zh-Hans": "浮动控件消失或把应用带回前台",
+ "zh-Hant": "浮動控制項消失或把 App 帶回前景"
+ },
+ "cde5e0c3429922f0": {
+ "en": "Some apps keep a meeting recorder or Picture-in-Picture surface in the same process as their ordinary windows. When Tatami switches away from that app's workspace, the normal macOS hide operation also hides the floating surface. The control can disappear, or the app may reactivate itself afterward and bring its ordinary window or Tatami workspace back into view.",
+ "ko": "일부 앱은 일반 창과 같은 프로세스에서 회의 기록이나 화면 속 화면을 보여줘요. 그 앱의 작업 공간을 떠나면 macOS의 앱 숨기기가 컨트롤도 함께 숨길 수 있어요. 컨트롤이 사라지거나 앱이 스스로 활성화되면서 일반 창이나 작업 공간이 다시 나타날 수 있어요.",
+ "ja": "一部のアプリは会議記録や PiP を通常のウインドウと同じプロセスで表示します。作業を離れると macOS の非表示操作がその面も隠し、消えたり、アプリが再度有効化されて元のウインドウや作業が戻ったりすることがあります。",
+ "zh-Hans": "部分应用把会议录制或画中画与普通窗口放在同一进程。离开该应用的工作区时,macOS 的常规隐藏也会隐藏浮动区域。控件可能消失,或应用再次激活,带回普通窗口或 Tatami 工作区。",
+ "zh-Hant": "部分 App 把會議錄製或子母畫面與一般視窗放在同一程序。離開該 App 的工作空間時,macOS 的一般隱藏也會隱藏浮動區域。控制項可能消失,或 App 再次啟用,帶回一般視窗或 Tatami 工作空間。"
+ },
+ "47279d1366356faf": {
+ "en": "Common candidates include:",
+ "ko": "대표적인 경우예요.",
+ "ja": "代表的な例です。",
+ "zh-Hans": "常见情况包括:",
+ "zh-Hant": "常見情況包含:"
+ },
+ "2b13188b190d3c09": {
+ "en": "Notion while AI Meeting Notes is recording",
+ "ko": "AI Meeting Notes로 회의를 기록 중인 Notion",
+ "ja": "AI Meeting Notes で録音中の Notion",
+ "zh-Hans": "正在使用 AI Meeting Notes 录制的 Notion",
+ "zh-Hant": "正在使用 AI Meeting Notes 錄製的 Notion"
+ },
+ "43e973d9eeba5092": {
+ "en": "Picture-in-Picture in browsers such as Google Chrome and Dia",
+ "ko": "Google Chrome이나 Dia 같은 브라우저의 화면 속 화면",
+ "ja": "Google Chrome や Dia などの PiP",
+ "zh-Hans": "Google Chrome、Dia 等浏览器的画中画",
+ "zh-Hant": "Google Chrome、Dia 等瀏覽器的子母畫面"
+ },
+ "6a85b0a3855ffe59": {
+ "en": "Register the app as a visibility exception:",
+ "ko": "앱을 표시 예외로 등록해보세요.",
+ "ja": "アプリを表示の例外として登録します。",
+ "zh-Hans": "把应用注册为可见性例外:",
+ "zh-Hant": "把 App 註冊為顯示例外:"
+ },
+ "abdbf19db6a196fe": {
+ "en": "Open **Settings → Workspaces** and scroll to **Apps With Floating Controls**.",
+ "ko": "**설정 → 작업 공간**을 열고 별도 컨트롤이 있는 앱을 설정하는 영역으로 내려가세요.",
+ "ja": "**設定 → ワークスペース**を開き、独立したフローティングコントロールを持つアプリの設定まで移動します。",
+ "zh-Hans": "打开**设置 → 工作区**,滚动到浮动控件应用设置区域。",
+ "zh-Hant": "開啟**設定 → 工作空間**,捲動到浮動控制項 App 設定區域。"
+ },
+ "d5ba0de015709337": {
+ "en": "Click **+** and choose the running app. Use **Choose from Files…** if the app is not running.",
+ "ko": "**+**를 눌러 실행 중인 앱을 고르세요. 실행 중이 아니라면 파일에서 앱을 선택할 수 있어요.",
+ "ja": "**+** で実行中のアプリを選びます。起動していなければ、ファイルから選択します。",
+ "zh-Hans": "点击 **+** 选择运行中的应用,未运行时可从文件中选择。",
+ "zh-Hant": "點按 **+** 選擇執行中的 App,未執行時可從檔案中選擇。"
+ },
+ "e1e28c45c6a3c93e": {
+ "en": "Keep the app's regular workspace assignment unchanged. This setting is narrower than adding the app to Shared Apps.",
+ "ko": "기존 작업 공간 배정은 그대로 두세요. 공용 앱으로 추가하는 것보다 좁은 범위의 설정이에요.",
+ "ja": "元の作業への割り当ては保ちます。共有アプリへの追加より範囲の狭い設定です。",
+ "zh-Hans": "保留原来的工作区分配。这项设置的范围比加入共享应用更窄。",
+ "zh-Hant": "保留原來的工作空間指派。這項設定的範圍比加入共用 App 更小。"
+ },
+ "77eeebca7d2982bc": {
+ "en": "Tatami does not exempt a registered app from hiding all the time. During a workspace or Borrow visibility change, it preserves the process only when the app owns an on-screen, top-level control outside the normal WindowServer layer. While preserved, the app's ordinary windows remain alive but stay out of Tatami focus automation, app / window switching, layout, drag, and membership actions. They can remain behind tiled windows or appear in Mission Control.",
+ "ko": "등록한 앱을 항상 숨기지 않는 것은 아니에요. 작업 공간·빌려오기 표시를 바꿀 때 일반 WindowServer 레이어 밖에 최상위 컨트롤이 실제로 보일 때만 프로세스를 남겨요. 이때 일반 창은 살아 있지만 포커스 자동화·창 전환·배치·드래그·배정에서 제외돼요. 다른 창 뒤나 Mission Control에는 보일 수 있어요.",
+ "ja": "登録したアプリを常に表示するわけではありません。作業や借りる表示の変更時、通常レイヤー外の最上位コントロールが画面にある場合だけプロセスを残します。その間、通常ウインドウは生存しますが、フォーカス、切り替え、配置、ドラッグ、所属操作から外れます。背後や Mission Control には見えることがあります。",
+ "zh-Hans": "注册应用并非始终免于隐藏。切换工作区或借用可见性时,只有应用在普通 WindowServer 层之外拥有屏幕上可见的顶级控件,才保留进程。普通窗口仍存活,但不参与焦点自动化、窗口切换、布局、拖移和成员操作,可能留在平铺窗口后面或出现在 Mission Control。",
+ "zh-Hant": "註冊 App 並非永遠免於隱藏。切換工作空間或借用顯示時,只有 App 在一般 WindowServer 層之外擁有螢幕上可見的最上層控制項,才保留程序。一般視窗仍存活,但不參與焦點自動化、視窗切換、排列、拖移與成員操作,可能留在並排視窗後面或出現在 Mission Control。"
+ },
+ "4d73c5d0e6b38ed2": {
+ "en": "When no qualifying control remains, the next visibility change uses normal workspace hiding again. Apps whose control is off-screen, transparent, on the normal window layer, owned by a helper process, or not exposed as a top-level Accessibility window do not qualify.",
+ "ko": "조건에 맞는 컨트롤이 없어지면 다음 표시 변경부터 일반 숨기기로 돌아가요. 화면 밖·투명·일반 레이어의 컨트롤, 보조 프로세스가 소유한 컨트롤, 최상위 접근성 창으로 노출되지 않은 컨트롤은 해당하지 않아요.",
+ "ja": "条件に合うコントロールがなくなると、次の変更から通常の非表示に戻ります。画面外、透明、通常レイヤー、補助プロセス所有、最上位 AX ウインドウでないものは対象外です。",
+ "zh-Hans": "没有符合条件的控件后,下次可见性变化恢复正常隐藏。屏幕外、透明、普通层、辅助进程拥有,或未作为顶级辅助功能窗口暴露的控件不符合条件。",
+ "zh-Hant": "沒有符合條件的控制項後,下次顯示變化恢復正常隱藏。螢幕外、透明、一般層、輔助程序擁有,或未作為最上層輔助使用視窗提供的控制項不符合條件。"
+ },
+ "638c559d34fd31b8": {
+ "en": "Picture-in-Picture being tiled is a separate issue. Tatami automatically excludes nonzero-layer PiP surfaces from tiling; no app registration is needed for that. Register the browser only when switching workspaces hides its PiP surface along with the browser process.",
+ "ko": "화면 속 화면이 타일링되는 문제는 별개예요. Tatami는 일반 레이어 밖의 PiP를 자동으로 타일링에서 제외하므로 앱 등록이 필요하지 않아요. 작업 공간을 바꿀 때 브라우저와 함께 PiP가 숨겨지는 경우에만 브라우저를 등록하세요.",
+ "ja": "PiP がタイル表示される問題は別です。通常レイヤー以外の PiP は自動で対象外になるため登録不要です。作業の切り替えでブラウザと一緒に PiP が隠れる場合だけ登録してください。",
+ "zh-Hans": "画中画参与平铺是另一问题。Tatami 会自动排除非零层的 PiP,无需注册应用。仅当切换工作区会随浏览器进程一起隐藏 PiP 时,才注册浏览器。",
+ "zh-Hant": "子母畫面參與並排是另一問題。Tatami 會自動排除非零層的 PiP,不必註冊 App。僅當切換工作空間會隨瀏覽器程序一起隱藏 PiP 時,才註冊瀏覽器。"
+ },
+ "0db73811c762c396": {
+ "en": "If you manage {0} directly, use the app's exact bundle identifier:",
+ "ko": "{0}을 직접 관리한다면 정확한 앱 번들 식별자를 사용하세요.",
+ "ja": "{0} を直接管理する場合は、正確なアプリのバンドル ID を使います。",
+ "zh-Hans": "直接管理 {0} 时,请使用准确的应用包标识符:",
+ "zh-Hant": "直接管理 {0} 時,請使用確切的 App 套件識別碼:"
+ },
+ "9f28b4a29ee68052": {
+ "en": "If the control still disappears, enable **Settings → General → Debug Logging**, reproduce one workspace switch, and inspect {0} for an {1} entry. A {2} result means the current control did not satisfy the conditions above.",
+ "ko": "계속 사라진다면 **설정 → 일반**에서 디버그 로그를 켜고 작업 공간을 한 번 전환해보세요. {0}의 {1} 항목을 확인하세요. {2}는 컨트롤이 위 조건을 충족하지 못했다는 뜻이에요.",
+ "ja": "消える場合は**設定 → 一般**でデバッグログを有効にし、一度作業を切り替えて {0} の {1} を確認します。{2} は条件を満たさなかったことを示します。",
+ "zh-Hans": "若仍然消失,在**设置 → 通用**启用调试日志,复现一次工作区切换,检查 {0} 的 {1} 条目。{2} 表示当前控件不符合上述条件。",
+ "zh-Hant": "若仍然消失,在**設定 → 一般**啟用偵錯記錄,重現一次工作空間切換,檢查 {0} 的 {1} 項目。{2} 表示目前控制項不符合上述條件。"
+ },
+ "5c6366e4a1e51142": {
+ "en": "Tatami Demo Lab",
+ "ko": "Tatami Demo Lab",
+ "ja": "Tatami Demo Lab",
+ "zh-Hans": "Tatami Demo Lab",
+ "zh-Hant": "Tatami Demo Lab"
+ },
+ "9ac0c5ddf2fd47cd": {
+ "en": "Record the real Tatami, export a consistent set of marketing videos, and place those videos beside the product promises they demonstrate. The apps and their contents are fixtures; workspace switches, tiling, Borrow and window focus are performed by the installed Tatami.",
+ "ko": "실제 Tatami를 녹화해 일관된 제품 영상을 만들고 해당 기능 설명 옆에 배치해요. 앱과 내용은 데모용이며 전환·타일링·빌려오기·포커스는 설치한 Tatami가 실제로 수행해요.",
+ "ja": "実際の Tatami を録画し、統一した紹介動画を機能説明の横に置きます。アプリと内容はデモ用ですが、切り替え、配置、借りる操作、フォーカスはインストールした Tatami が行います。",
+ "zh-Hans": "录制实际的 Tatami,导出一致的产品视频并放在对应功能说明旁。应用和内容为演示数据,工作区切换、平铺、借用和焦点由已安装的 Tatami 实际执行。",
+ "zh-Hant": "錄製實際的 Tatami,輸出一致的產品影片並放在對應功能說明旁。App 與內容為示範資料,工作空間切換、並排、借用與焦點由已安裝的 Tatami 實際執行。"
+ },
+ "76b237ce8ee7b2f1": {
+ "en": "Publication contract",
+ "ko": "공개용 영상 규칙",
+ "ja": "公開用動画の規則",
+ "zh-Hans": "发布约定",
+ "zh-Hant": "發布約定"
+ },
+ "1e670a260a964e43": {
+ "en": "[{0}]({1}) is the inventory and editorial budget. It maps each scene to its website section and limits duration and delivery size.",
+ "ko": "[{0}]({1})에 영상 목록과 편집 예산을 정해요. 각 장면을 웹 섹션에 연결하고 길이와 파일 크기를 제한해요.",
+ "ja": "[{0}]({1}) が一覧と編集上限です。各場面を Web の区分に結び、時間とサイズを制限します。",
+ "zh-Hans": "[{0}]({1}) 定义视频清单和编辑预算,映射网站区块并限制时长和文件大小。",
+ "zh-Hant": "[{0}]({1}) 定義影片清單與編輯預算,對應網站區塊並限制時間和檔案大小。"
+ },
+ "e63d52ca007f8733": {
+ "en": "The introduction is a complete design → writing → review → Borrow → automation workflow, ending with a real display-topology change. Focused collections cover workspaces, profiles and displays, tiling and focus, Borrow, window modes, CLI/hooks, and Guided Setup. The inventory is generated from {0}; there is no separate hard-coded list to keep in sync.",
+ "ko": "종합 영상은 디자인 → 글쓰기 → 검토 → 빌려오기 → 자동화에 이어 실제 화면 구성 변경으로 끝나요. 기능별 모음은 작업 공간, 프로필·화면, 타일링·포커스, 빌려오기, 창 표시, CLI·훅, 가이드 설정을 다뤄요. 목록은 {0}에서 만들므로 별도로 중복 관리하지 않아요.",
+ "ja": "紹介動画はデザイン、執筆、レビュー、借りる操作、自動化から、実際の画面構成変更まで進みます。各動画集では作業、プロファイルと画面、配置とフォーカス、借りる操作、表示方法、CLI・フック、ガイドを扱います。一覧は {0} から生成します。",
+ "zh-Hans": "主视频依次展示设计、写作、评审、借用、自动化,最后实际改变显示器拓扑。专题涵盖工作区、配置方案与显示器、平铺与焦点、借用、窗口模式、CLI/钩子和引导设置。清单由 {0} 生成,无需维护重复列表。",
+ "zh-Hant": "主影片依序展示設計、撰寫、審閱、借用、自動化,最後實際改變顯示器拓樸。專題涵蓋工作空間、設定組合與顯示器、並排與焦點、借用、視窗模式、CLI/Hook 和引導設定。清單由 {0} 產生,不必維護重複清單。"
+ },
+ "f02f72d670820c42": {
+ "en": "The hero teaches the central promise: **switch contexts and keep your place**. Feature scenes demonstrate different activities instead of replaying the hero. See [coverage and evidence boundaries]({0}) for the complete map.",
+ "ko": "종합 영상은 **작업을 바꿔도 하던 자리를 유지한다**는 약속을 보여줘요. 기능별 영상은 다른 활동을 보여주며 종합 영상을 반복하지 않아요. 전체 범위는 [기능과 검증 범위]({0})에서 확인해요.",
+ "ja": "主動画は**作業を変えても元の場所に戻れる**ことを示します。機能別は別の活動を扱い、同じ動画を繰り返しません。[範囲と検証の境界]({0})を参照してください。",
+ "zh-Hans": "主视频传达**切换任务,保留工作位置**。专题展示不同活动,不重复主视频。完整范围见[覆盖与证据边界]({0})。",
+ "zh-Hant": "主影片傳達**切換工作,保留工作位置**。專題展示不同活動,不重複主影片。完整範圍見[涵蓋與證據界線]({0})。"
+ },
+ "6c1907ed3b02565f": {
+ "en": "Capture → export → review → install locally",
+ "ko": "촬영 → 내보내기 → 검수 → 로컬 반영",
+ "ja": "録画 → 書き出し → 確認 → ローカル反映",
+ "zh-Hans": "录制 → 导出 → 检查 → 本地安装",
+ "zh-Hant": "錄製 → 輸出 → 檢查 → 本機安裝"
+ },
+ "1f3e7aaa3e2811b9": {
+ "en": "Use the existing dedicated Tart VM, not your everyday desktop. {0} and {1} quit Tatami and change its preferences in whichever machine runs them. Config and layout files are isolated under {2}; the preferences domain is backed up separately and can be restored with {3}.",
+ "ko": "평소 쓰는 데스크톱 대신 전용 Tart VM을 사용해요. {0}과 {1}은 실행한 컴퓨터의 Tatami를 종료하고 환경설정을 바꿔요. 설정·배치 파일은 {2}에 격리하고 환경설정 도메인은 별도 백업해서 {3}으로 복구할 수 있어요.",
+ "ja": "普段のデスクトップではなく専用の Tart VM を使います。{0} と {1} は実行先の Tatami を終了して設定を変えます。設定と配置は {2} に隔離し、環境設定を別途保存して {3} で復元できます。",
+ "zh-Hans": "使用专用 Tart VM。{0} 和 {1} 会退出其运行机器上的 Tatami 并改变偏好。配置和布局隔离在 {2},偏好域单独备份,可通过 {3} 恢复。",
+ "zh-Hant": "使用專用 Tart VM。{0} 與 {1} 會結束其執行機器上的 Tatami 並變更偏好。設定與排列隔離在 {2},偏好網域另行備份,可透過 {3} 還原。"
+ },
+ "cda94e4462fc1787": {
+ "en": "A small set of useful apps",
+ "ko": "실제로 쓰는 작은 앱 모음",
+ "ja": "実際に使える小さなアプリ集",
+ "zh-Hans": "实用的演示应用",
+ "zh-Hant": "實用的示範 App"
+ },
+ "89dd748442c19485": {
+ "en": "Apps",
+ "ko": "앱",
+ "ja": "アプリ",
+ "zh-Hans": "应用",
+ "zh-Hant": "App"
+ },
+ "22a008f65085af07": {
+ "en": "Actual work",
+ "ko": "실제 작업",
+ "ja": "実際の作業",
+ "zh-Hans": "实际操作",
+ "zh-Hant": "實際操作"
+ },
+ "1621bd27fd93a42d": {
+ "en": "Change the visual theme, inspect the saved draft, export a PNG.",
+ "ko": "색상 방향을 바꾸고 저장한 초안을 확인한 뒤 PNG를 내보내요.",
+ "ja": "配色を変え、保存した下書きを確認し、PNG を書き出します。",
+ "zh-Hans": "更改配色,检查保存的草稿并导出 PNG。",
+ "zh-Hant": "變更配色、檢查儲存的草稿並輸出 PNG。"
+ },
+ "77c60b50f4f9bc1e": {
+ "en": "Read the brief, edit the headline, save the draft.",
+ "ko": "안내를 읽고 제목을 고친 뒤 초안을 저장해요.",
+ "ja": "資料を読み、見出しを直して下書きを保存します。",
+ "zh-Hans": "阅读说明,编辑标题并保存草稿。",
+ "zh-Hant": "閱讀說明、編輯標題並儲存草稿。"
+ },
+ "7d41a613b6fc1140": {
+ "en": "Read saved copy, run checks, comment and approve.",
+ "ko": "저장한 문구를 검사하고 의견을 남겨 승인해요.",
+ "ja": "保存した文章を確認し、コメントして承認します。",
+ "zh-Hans": "检查保存的文案,留言并批准。",
+ "zh-Hant": "檢查儲存的文案、留言並核准。"
+ },
+ "4e4299cccd8e4850": {
+ "en": "Type and send a local demo reply.",
+ "ko": "로컬 데모 답장을 입력하고 보내요.",
+ "ja": "ローカルのデモ返信を入力して送ります。",
+ "zh-Hans": "输入并发送本地演示回复。",
+ "zh-Hant": "輸入並傳送本機示範回覆。"
+ },
+ "2947e86d51718c19": {
+ "en": "Execute the real Tatami CLI and bundled automation scripts.",
+ "ko": "실제 Tatami CLI와 포함된 자동화 스크립트를 실행해요.",
+ "ja": "実際の Tatami CLI と同梱スクリプトを実行します。",
+ "zh-Hans": "执行实际 Tatami CLI 和附带自动化脚本。",
+ "zh-Hant": "執行實際 Tatami CLI 與附帶自動化指令稿。"
+ },
+ "52a7697e7d362f5e": {
+ "en": "Notes (Borrow only)",
+ "ko": "Notes(빌려오기 전용)",
+ "ja": "Notes(借りる専用)",
+ "zh-Hans": "Notes(仅借用)",
+ "zh-Hant": "Notes(僅借用)"
+ },
+ "81cb7ff1424bc0e3": {
+ "en": "Add a follow-up and complete a checklist item.",
+ "ko": "후속 작업을 적고 체크리스트 항목을 완료해요.",
+ "ja": "次の作業を追加し、チェック項目を完了します。",
+ "zh-Hans": "添加待办并完成检查项。",
+ "zh-Hant": "新增待辦並完成檢查項。"
+ },
+ "251b799252aec528": {
+ "en": "Work with a per-workspace Always on Top note.",
+ "ko": "해당 작업 공간의 메모를 항상 위에 두고 사용해요.",
+ "ja": "その作業でメモを手前に置いて使います。",
+ "zh-Hans": "在工作区内使用置顶便笺。",
+ "zh-Hant": "在工作空間內使用置頂便箋。"
+ },
+ "e3c4b39d6d501347": {
+ "en": "Shared",
+ "ko": "공용 앱",
+ "ja": "共有アプリ",
+ "zh-Hans": "共享应用",
+ "zh-Hant": "共用 App"
+ },
+ "fb9f1c9371639747": {
+ "en": "Observe saved project state or real workspace/HUD hook events.",
+ "ko": "저장한 프로젝트 상태나 실제 작업 공간·화면 알림 훅을 확인해요.",
+ "ja": "保存した状態や実際の作業・フィードバックのフックを確認します。",
+ "zh-Hans": "观察保存的项目状态或实际工作区、HUD 钩子事件。",
+ "zh-Hant": "觀察儲存的專案狀態或實際工作空間、HUD Hook 事件。"
+ },
+ "353a26913a574ce8": {
+ "en": "All eight apps use persisted local data. Saving changes what Review and Canvas read. The Canvas export writes an actual image. Notes and messages survive workspace switches. Terminal executes an explicit command allowlist and bundled scripts; hooks receive Tatami's real environment. No fixture connects to a chat service, and the AI proposal demonstration uses a clearly labelled local example.",
+ "ko": "8개 앱이 로컬에 저장한 데이터를 사용해요. 저장하면 Review와 Canvas에 반영되고 PNG도 실제로 만들어져요. 메모와 대화는 공간을 바꿔도 남아요. Terminal은 허용한 명령과 스크립트만 실행하고 훅은 실제 Tatami 환경을 받아요. 대화 서비스에는 연결하지 않으며 AI 영상은 표시해둔 로컬 예시를 사용해요.",
+ "ja": "8 個のアプリが保存済みローカルデータを使います。保存すると Review と Canvas に反映され、PNG も実際に作ります。メモや会話は切り替えても残ります。Terminal は許可したコマンドだけを実行し、フックは実際の環境を受け取ります。会話サービスには接続せず、AI は明示したローカル例です。",
+ "zh-Hans": "八个应用使用持久化本地数据。保存会改变 Review、Canvas 读取的内容,PNG 导出实际写入图片,笔记和消息在切换后保留。Terminal 只执行明确允许的命令和附带脚本;钩子接收实际 Tatami 环境。不连接聊天服务,AI 演示使用明确标记的本地示例。",
+ "zh-Hant": "八個 App 使用持久化本機資料。儲存會改變 Review、Canvas 讀取的內容,PNG 輸出實際寫入圖片,筆記和訊息在切換後保留。Terminal 只執行明確允許的指令與附帶指令稿;Hook 接收實際 Tatami 環境。不連接聊天服務,AI 示範使用明確標記的本機範例。"
+ },
+ "9715464bd838a833": {
+ "en": "What changed in the capture contract",
+ "ko": "촬영 규칙의 변화",
+ "ja": "録画規則の変更",
+ "zh-Hans": "录制约定的改进",
+ "zh-Hant": "錄製約定的改進"
+ },
+ "d6dc72b958ef110c": {
+ "en": "A scene has two phases:",
+ "ko": "장면은 두 단계로 나뉘어요.",
+ "ja": "場面は二段階に分かれます。",
+ "zh-Hans": "场景分为两个阶段:",
+ "zh-Hant": "場景分為兩個階段:"
+ },
+ "ddc591e241aa6c47": {
+ "en": "{0} runs **before** recording: teardown, app launches, workspace warm-up, fixture state and layout preparation.",
+ "ko": "{0}은 **촬영 전에** 종료·앱 실행·작업 공간 준비·데이터와 배치 설정을 해요.",
+ "ja": "{0} は**録画前**に終了処理、起動、作業の準備、データと配置を整えます。",
+ "zh-Hans": "{0} 在**录制前**完成清理、启动、工作区预热、数据和布局准备。",
+ "zh-Hant": "{0} 在**錄製前**完成清理、啟動、工作空間預備、資料與排列準備。"
+ },
+ "adb318c17431f0cc": {
+ "en": "{0} are the visible demonstration. {1} must have visible windows with stable geometry before the recorder starts. {2} is the deliberate exception: a short empty opening is the point of that feature.",
+ "ko": "{0}은 영상에 보이는 동작이에요. 녹화 전에 {1}의 창과 배치가 안정돼야 해요. {2}만 자동 열기를 보여주기 위해 잠깐 빈 화면으로 시작해요.",
+ "ja": "{0} が映る操作です。録画前に {1} の窓と配置を安定させます。{2} だけは自動で開く機能を示すため、短い空画面から始めます。",
+ "zh-Hans": "{0} 是可见演示。录制前 {1} 必须有几何稳定的可见窗口;{2} 是有意的例外,短暂空画面用于展示自动打开。",
+ "zh-Hant": "{0} 是可見示範。錄製前 {1} 必須有幾何穩定的可見視窗;{2} 是刻意的例外,短暫空白畫面用來展示自動開啟。"
+ },
+ "a7b6855c48c53a73": {
+ "en": "The capture gate checks system permission/settings windows before recording and around every action. It also sees a permission dialog that is behind another window. Resolve that dialog; do not crop it out or suppress the error.",
+ "ko": "녹화 전과 각 동작 전후에 시스템 권한·설정 창을 검사해요. 다른 창 뒤의 권한 창도 찾아요. 오류를 숨기거나 잘라내지 말고 해당 창을 해결하세요.",
+ "ja": "録画前と各操作の前後で、背後も含め権限・設定ウインドウを確認します。切り抜きやエラー抑制ではなく、そのダイアログを解決してください。",
+ "zh-Hans": "录制前及每个操作前后检查系统权限和设置窗口,包括被其他窗口遮住的对话框。应解决请求,不能裁掉或压制错误。",
+ "zh-Hant": "錄製前及每個操作前後檢查系統權限與設定視窗,包含被其他視窗遮住的對話框。應解決要求,不能裁掉或壓制錯誤。"
+ },
+ "9879683f8de4a7be": {
+ "en": "**Recorder access is not Tatami access.** Earlier takes contained a stale Tatami Screen Recording request despite the recorder passing {0}. Tatami's Always on Top uses its own ScreenCaptureKit stream. A throwaway recorder warm-up alone cannot validate that path. Warm up the {1} scene and inspect its actual monitor before accepting it. The default shared Monitor no longer auto-opens in every scene; {2} explicitly launches it off camera.",
+ "ko": "**녹화기 권한과 Tatami 권한은 별개예요.** {0}을 통과해도 오래된 Tatami 화면 기록 요청이 남은 적이 있어요. 항상 위 기능은 별도 ScreenCaptureKit 스트림을 쓰므로 녹화기 준비만으로는 확인할 수 없어요. {1}을 미리 실행해 실제 상태 창을 확인하세요. 공용 Monitor는 모든 장면에 자동으로 열지 않고 {2}에서 촬영 전에 명시적으로 열어요.",
+ "ja": "**録画ツールと Tatami の許可は別です。** {0} に通っても古い Tatami の画面収録要求が残っていました。「常に手前」は別のストリームなので、録画の準備だけでは確認できません。{1} を試して実際の窓を確認します。Monitor は全場面で開かず、{2} が録画前に明示的に開きます。",
+ "zh-Hans": "**录制器权限不等于 Tatami 权限。** 以前即使通过 {0},仍残留 Tatami 屏幕录制请求。置顶使用独立 ScreenCaptureKit 流,单独预热录制器不能验证它。先运行 {1} 并检查实际状态窗口。Monitor 不再每场景自动打开,而由 {2} 在录制前明确启动。",
+ "zh-Hant": "**錄製器權限不等於 Tatami 權限。** 以前即使通過 {0},仍殘留 Tatami 螢幕錄製要求。置頂使用獨立 ScreenCaptureKit 串流,單獨預備錄製器不能驗證它。先執行 {1} 並檢查實際狀態視窗。Monitor 不再每個場景自動開啟,而由 {2} 在錄製前明確啟動。"
+ },
+ "805747b9a6abed67": {
+ "en": "{0} waits for the named apps and stable window geometry. {1} / {2} compare the same window IDs and their bounds after workspace switches, Borrow returns, and zoom restoration. Missing or additional windows and coordinate changes beyond 4 points fail the take.",
+ "ko": "{0}은 지정 앱과 안정된 배치를 기다려요. {1}/{2}는 공간 전환·빌려오기 종료·확대 복귀 뒤에 같은 창 ID와 영역인지 비교해요. 창이 빠지거나 늘거나 4포인트 넘게 움직이면 실패해요.",
+ "ja": "{0} は対象アプリと配置の安定を待ちます。{1}/{2} は切り替え、返却、ズーム復帰後の同じ ID と枠を比較し、窓の増減や 4 pt を超える差で失敗します。",
+ "zh-Hans": "{0} 等待指定应用和稳定几何。{1}/{2} 在切换、归还和放大恢复后比较相同窗口 ID 与边界;缺少、增加窗口或超过 4 点的位移都会失败。",
+ "zh-Hant": "{0} 等待指定 App 和穩定幾何。{1}/{2} 在切換、歸還與放大還原後比較相同視窗 ID 與邊界;缺少、增加視窗或超過 4 點的位移都會失敗。"
+ },
+ "947091206e3e610f": {
+ "en": "{0} and {1} generate their own keycasts from the actual input. Published scenes may not use a {2} label to pretend a CLI operation was a keystroke. The recorder refuses startup without an encoded frame, and gives the scene its first-frame monotonic timestamp so subtitles share the movie's clock.",
+ "ko": "{0}, {1}은 실제 입력으로 키 표시를 만들어요. {2} 문구로 CLI 실행을 키 입력처럼 꾸미면 안 돼요. 첫 인코딩 프레임이 없으면 시작을 거부하고, 그 프레임의 단조 시간을 넘겨 영상과 자막의 시계를 맞춰요.",
+ "ja": "{0}、{1} は実際の入力からキー表示を作ります。CLI をキー操作に見せる {2} は使えません。最初のエンコード済みフレームがなければ開始せず、その単調時刻で字幕と動画を合わせます。",
+ "zh-Hans": "{0}、{1} 根据实际输入生成按键显示,不能用 {2} 标签把 CLI 操作伪装成按键。没有首个编码帧就拒绝启动,并共享其单调时间戳以对齐字幕与影片时钟。",
+ "zh-Hant": "{0}、{1} 依實際輸入產生按鍵顯示,不能用 {2} 標籤把 CLI 操作偽裝成按鍵。沒有首個編碼影格就拒絕啟動,並共用其單調時間戳記以對齊字幕與影片時鐘。"
+ },
+ "5d5ee15775006a81": {
+ "en": "Presentation",
+ "ko": "화면 구성",
+ "ja": "動画の見せ方",
+ "zh-Hans": "画面呈现",
+ "zh-Hant": "畫面呈現"
+ },
+ "a879779436601be9": {
+ "en": "The raw {0} contains the entire captured desktop, without lab captions. The export uses a 1920×1200 canvas: a 1664×1040 desktop at (128, 48), a slim chapter header, and a footer for narration and shortcut labels. The chapter text, letterbox and captions derive directly from the website dark palette through {1}: charcoal, off-white and Tatami gold. Wide dual-screen films use a 1920×800 canvas. Changing the palette does not require re-recording. Tatami's own HUD remains inside the untouched captured desktop.",
+ "ko": "원본 {0}에는 자막 없이 데스크톱 전체가 들어 있어요. 출력은 1920×1200 안에 1664×1040 화면을 (128, 48)에 놓고 위에 장면 제목, 아래에 설명과 키를 표시해요. 색상은 {1}이 웹의 다크 팔레트에서 읽은 차콜·밝은 회색·골드예요. 두 화면 영상은 1920×800이고 색상 변경만으로 재촬영하지는 않아요. Tatami 알림은 원래 화면 안에 그대로 남아요.",
+ "ja": "元の {0} は字幕なしのデスクトップ全体です。1920×1200 に 1664×1040 の画面を (128,48) へ置き、上下に章と説明・キーを表示します。色は {1} で Web の暗色、白に近い灰色、ゴールドから作ります。2 画面は 1920×800 で、配色だけなら再撮影不要です。Tatami の表示は元画面内に残します。",
+ "zh-Hans": "原始 {0} 包含完整桌面,不带实验室字幕。输出在 1920×1200 画布的 (128,48) 放置 1664×1040 桌面,上方为章节,下方为说明和按键。{1} 从网站深色调色板读取炭灰、浅灰和金色。双屏为 1920×800,换配色无需重录;Tatami 自身反馈保留在原画面内。",
+ "zh-Hant": "原始 {0} 包含完整桌面,不帶實驗室字幕。輸出在 1920×1200 畫布的 (128,48) 放置 1664×1040 桌面,上方為章節,下方為說明與按鍵。{1} 從網站深色調色盤讀取炭灰、淺灰和金色。雙螢幕為 1920×800,換配色不必重錄;Tatami 自身回饋保留在原畫面內。"
+ },
+ "b23bcfe6ced0b7e9": {
+ "en": "Every take has:",
+ "ko": "각 테이크에는 다음 파일이 있어요.",
+ "ja": "各テイクには次のファイルがあります。",
+ "zh-Hans": "每次录制包含:",
+ "zh-Hant": "每次錄製包含:"
+ },
+ "6f19e721b835a01d": {
+ "en": "{0}: clean camera original.",
+ "ko": "{0}: 자막 없는 원본 영상.",
+ "ja": "{0}:字幕なしの原本。",
+ "zh-Hans": "{0}:干净的原始录像。",
+ "zh-Hant": "{0}:乾淨的原始錄影。"
+ },
+ "f933ebab00505ff2": {
+ "en": "{0}: editable narration and actual keycast timing.",
+ "ko": "{0}: 수정할 수 있는 설명과 실제 키 입력 시간.",
+ "ja": "{0}:編集できる説明と実際のキー入力時刻。",
+ "zh-Hans": "{0}:可编辑说明和实际按键时间。",
+ "zh-Hant": "{0}:可編輯說明與實際按鍵時間。"
+ },
+ "b6fc20fad33eabbf": {
+ "en": "{0}: all events with start/end times.",
+ "ko": "{0}: 모든 이벤트의 시작·끝 시간.",
+ "ja": "{0}:全イベントの開始・終了時刻。",
+ "zh-Hans": "{0}:所有事件及起止时间。",
+ "zh-Hant": "{0}:所有事件及起訖時間。"
+ },
+ "b5bdb4add9a6c2f9": {
+ "en": "{0}: pass/fail, scene hash, Tatami version, locale, per-output frame/drop counts, capture epochs and overlay mode.",
+ "ko": "{0}: 성공 여부, 장면 해시, Tatami 버전, 언어, 출력별 프레임·손실 수, 시작 시각과 오버레이 모드.",
+ "ja": "{0}:成否、場面ハッシュ、バージョン、言語、出力別フレーム数と損失、開始時刻、表示モード。",
+ "zh-Hans": "{0}:成败、场景哈希、Tatami 版本、语言、逐输出帧数和丢帧数、捕获时基及叠加模式。",
+ "zh-Hant": "{0}:成敗、場景雜湊、Tatami 版本、語言、逐輸出影格數與遺失數、擷取時基及疊加模式。"
+ },
+ "daa06ec81fa80934": {
+ "en": "{0}: the exact scene bytes frozen when recording began.",
+ "ko": "{0}: 녹화 시작 시 고정한 정확한 장면 파일.",
+ "ja": "{0}:録画開始時に固定した場面の正確なバイト列。",
+ "zh-Hans": "{0}:录制开始时冻结的准确场景字节。",
+ "zh-Hant": "{0}:錄製開始時凍結的確切場景位元組。"
+ },
+ "aadb96e3ad96ebe3": {
+ "en": "A failed take stays available for diagnosis but cannot enter the exporter. Exports require a matching current scene hash, clean narration, less than 1% dropped frames, timely opening captions and matching movie/timeline duration. The output must fit its time/size budget, use H.264/yuv420p, and survive a complete FFmpeg decode. {0} places the MP4 header before the media payload.",
+ "ko": "실패본은 진단용으로 남기고 내보내기에서 거부해요. 현재 장면 해시, 깨끗한 설명, 1% 미만 손실, 빠른 첫 자막, 영상·타임라인 길이가 맞아야 해요. 시간·용량 예산과 H.264/yuv420p 형식을 지키고 FFmpeg 전체 디코딩을 통과해야 해요. {0}으로 MP4 헤더를 앞에 배치해요.",
+ "ja": "失敗は診断用に残して書き出しを拒否します。現在のハッシュ、適切な説明、1% 未満の損失、早い字幕、時刻の一致が必要です。時間・容量上限、H.264/yuv420p、全体デコードも確認し、{0} で MP4 ヘッダーを先頭に置きます。",
+ "zh-Hans": "失败片保留供诊断,但不能导出。要求当前场景哈希、干净说明、低于 1% 丢帧、及时首字幕和一致时间线;输出须符合时长、容量、H.264/yuv420p,并通过完整 FFmpeg 解码。{0} 把 MP4 头置于媒体前。",
+ "zh-Hant": "失敗片保留供診斷,但不能輸出。要求目前場景雜湊、乾淨說明、低於 1% 遺失影格、及時首字幕與一致時間軸;輸出須符合時間、容量、H.264/yuv420p,並通過完整 FFmpeg 解碼。{0} 把 MP4 標頭置於媒體前。"
+ },
+ "7930772b3eae3646": {
+ "en": "The export bundle includes a playback gallery, posters, sampled evidence frames, source/delivery hashes and all sidecars. Automated acceptance does not replace watching the action: in particular, shared-window mirroring and focus targets still need visible verification. Website players use native controls and {0}; playback is deliberate and only one video plays at a time. Every collection exposes its thumbnail playlist, count and previous/next buttons. Arrow keys, Home/End and per-film links work without opening a disclosure.",
+ "ko": "출력에는 재생 모음, 포스터, 검수 프레임, 원본·출력 해시와 메타데이터가 들어 있어요. 자동 통과만으로 검수를 대신하지 않아요. 특히 미러와 포커스 대상을 직접 확인해야 해요. 웹은 기본 컨트롤과 {0}을 쓰고 한 번에 하나만 재생해요. 썸네일·개수·이전·다음이 항상 보이고 화살표, Home/End, 직접 링크를 지원해요.",
+ "ja": "出力にはギャラリー、ポスター、確認フレーム、原本と出力のハッシュ、付属データが入ります。自動判定だけで確認を代替せず、ミラーとフォーカスは目で見ます。Web は標準操作と {0} を使い、一度に一つだけ再生します。一覧、数、前後操作を常に見せ、矢印、Home/End、直接リンクに対応します。",
+ "zh-Hans": "输出包含播放器、海报、抽样证据、源与输出哈希及附属文件。自动通过不能替代观看,尤其应确认镜像和焦点目标。网站使用原生控件和 {0},一次仅播放一个视频;缩略图、数量、前后按钮始终可见,支持方向键、Home/End 和直接链接。",
+ "zh-Hant": "輸出包含播放器、海報、抽樣證據、來源與輸出雜湊及附屬檔案。自動通過不能取代觀看,尤其應確認鏡像與焦點目標。網站使用原生控制項和 {0},一次僅播放一部影片;縮圖、數量、前後按鈕始終可見,支援方向鍵、Home/End 與直接連結。"
+ },
+ "14c15da38cb17d6a": {
+ "en": "For two-screen capture, the suite connects a real guest-side virtual display, records each display independently, and aligns them by first-frame timestamps. See [the verified VM setup]({0}).",
+ "ko": "두 화면 촬영은 게스트에 실제 가상 디스플레이를 연결하고 각각 녹화한 뒤 첫 프레임 시각으로 맞춰요. [확인한 VM 구성]({0})을 참고하세요.",
+ "ja": "2 画面ではゲストに実際の仮想画面を接続し、別々に録画して最初の時刻で揃えます。[確認済み構成]({0})を参照してください。",
+ "zh-Hans": "双屏录制连接实际的虚拟显示器,分别捕获并按首帧时间对齐。见[已验证 VM 配置]({0})。",
+ "zh-Hant": "雙螢幕錄製連接實際的虛擬顯示器,分別擷取並依首影格時間對齊。見[已驗證 VM 設定]({0})。"
+ },
+ "ab844821f4b37f36": {
+ "en": "Work on one scene",
+ "ko": "장면 하나 수정하기",
+ "ja": "一つの場面を調整する",
+ "zh-Hans": "调整单个场景",
+ "zh-Hant": "調整單一場景"
+ },
+ "ec7d5c6043466211": {
+ "en": "Run the capture commands inside the dedicated guest:",
+ "ko": "촬영 명령은 전용 게스트 안에서 실행해요.",
+ "ja": "録画コマンドは専用ゲスト内で実行します。",
+ "zh-Hans": "在专用虚拟机内执行录制命令:",
+ "zh-Hant": "在專用虛擬機內執行錄製指令:"
+ },
+ "041173d011f68d7c": {
+ "en": "{0} rehearses with a live overlay. Recorded {1} defaults to {2}; only that mode is accepted for publication. The live rehearsal panel is not the export design. {3} can render the sidecar into a viewing copy, but use {4} for budget checks, web encoding and evidence.",
+ "ko": "{0}은 실시간 설명을 띄워 연습해요. 녹화 {1}의 기본값은 {2}이며 공개용으로는 이 모드만 받아요. 연습 패널과 출력 디자인은 별개예요. {3}으로 확인용 영상을 만들 수 있지만 예산·웹 인코딩·증거 검사는 {4}를 사용하세요.",
+ "ja": "{0} は実時間の表示で練習します。{1} の既定は {2} で、公開にはこのモードだけを使います。練習パネルと出力デザインは別です。{3} は確認コピーを作れますが、上限・Web・証拠の検証は {4} を使います。",
+ "zh-Hans": "{0} 使用实时叠加排练。录制 {1} 默认 {2},发布只接受该模式;排练面板不是输出设计。{3} 可生成观看副本,预算检查、网页编码和证据应使用 {4}。",
+ "zh-Hant": "{0} 使用即時疊加排練。錄製 {1} 預設 {2},發布只接受該模式;排練面板不是輸出設計。{3} 可產生觀看副本,預算檢查、網頁編碼與證據應使用 {4}。"
+ },
+ "a6873ca680abac8e": {
+ "en": "Finish a session with {0} and {1} in the guest. Original host preferences and host Tatami are not involved in the VM workflow.",
+ "ko": "마치면 게스트에서 {0}, {1}을 실행해요. 호스트의 Tatami와 원래 환경설정은 건드리지 않아요.",
+ "ja": "終了後はゲストで {0}、{1} を実行します。ホスト側の Tatami や設定には関与しません。",
+ "zh-Hans": "结束后在虚拟机执行 {0} 和 {1},主机原有偏好与 Tatami 不参与此流程。",
+ "zh-Hant": "結束後在虛擬機執行 {0} 與 {1},主機原有偏好與 Tatami 不參與此流程。"
+ },
+ "7ecbe01293cdc087": {
+ "en": "Development and reference",
+ "ko": "개발과 참고 자료",
+ "ja": "開発と参考情報",
+ "zh-Hans": "开发与参考",
+ "zh-Hant": "開發與參考"
+ },
+ "fd2dd0bea3da3d81": {
+ "en": "[Scene vocabulary]({0})",
+ "ko": "[장면 문법]({0})",
+ "ja": "[場面の語彙]({0})",
+ "zh-Hans": "[场景语法]({0})",
+ "zh-Hant": "[場景語法]({0})"
+ },
+ "16e8fc25aebd2d97": {
+ "en": "[VM setup]({0})",
+ "ko": "[VM 설정]({0})",
+ "ja": "[VM の準備]({0})",
+ "zh-Hans": "[VM 设置]({0})",
+ "zh-Hant": "[VM 設定]({0})"
+ },
+ "925c1ffe2dc22d4f": {
+ "en": "[Permissions]({0})",
+ "ko": "[권한]({0})",
+ "ja": "[権限]({0})",
+ "zh-Hans": "[权限]({0})",
+ "zh-Hant": "[權限]({0})"
+ },
+ "badca583e90cf8fc": {
+ "en": "[Real multi-display capture]({0})",
+ "ko": "[실제 두 화면 촬영]({0})",
+ "ja": "[実際の複数画面録画]({0})",
+ "zh-Hans": "[实际多显示器录制]({0})",
+ "zh-Hant": "[實際多顯示器錄製]({0})"
+ },
+ "cc3d24d14616a418": {
+ "en": "Five-language production",
+ "ko": "다섯 언어 영상 제작",
+ "ja": "5 言語での制作",
+ "zh-Hans": "五语言制作",
+ "zh-Hant": "五語言製作"
+ },
+ "dcb02b31ad2062e9": {
+ "en": "The supported languages are {0}, {1}, {2}, {3} and {4}. {5} owns fixture UI and seeded content; {6} owns film titles, narration and scripted human input. {7} owns the review gallery. The product's existing catalog supplies native Tatami AX labels when a stable control identifier is not available. User-chosen app, workspace and profile names remain unchanged.",
+ "ko": "{0}, {1}, {2}, {3}, {4}를 지원해요. {5}는 앱 문구와 초기 데이터, {6}은 영상 제목·설명·입력 문구, {7}은 검수용 모음을 관리해요. 고유 ID가 없으면 제품 카탈로그에서 Tatami의 AX 문구를 가져와요. 앱과 사용자 지정 작업 공간·프로필 이름은 유지해요.",
+ "ja": "{0}、{1}、{2}、{3}、{4} に対応します。{5} は UI と初期内容、{6} は動画と入力、{7} は確認ギャラリーを管理します。固定 ID がなければ製品カタログの AX ラベルを使います。アプリ名やユーザーの作業・プロファイル名は保ちます。",
+ "zh-Hans": "支持 {0}、{1}、{2}、{3}、{4}。{5} 管理演示 UI 和初始内容,{6} 管理视频标题、说明和输入,{7} 管理检查图库。没有稳定标识符时使用产品目录的 Tatami AX 标签。应用、用户命名的工作区和配置方案名称保持不变。",
+ "zh-Hant": "支援 {0}、{1}、{2}、{3}、{4}。{5} 管理示範 UI 與初始內容,{6} 管理影片標題、說明和輸入,{7} 管理檢查圖庫。沒有穩定識別碼時使用產品目錄的 Tatami AX 標籤。App、使用者命名的工作空間與設定組合名稱保持不變。"
+ },
+ "195b2d091991681d": {
+ "en": "Each recording seeds the fixture and Tatami language together. Human input and its assertions come from the same reviewed text. CLI commands and their actual machine output remain unchanged. The final delivery is 30 fps, so the batch also captures at 30 fps without unnecessary 60 fps sampling.",
+ "ko": "앱과 Tatami의 언어를 함께 설정해요. 입력과 검증은 같은 문구를 사용하고 실제 CLI 명령·출력은 유지해요. 최종 영상이 30fps라 촬영도 30fps로 해서 불필요한 60fps 처리를 줄여요.",
+ "ja": "デモと Tatami の言語を揃え、入力と検証に同じ文を使います。実際の CLI コマンドと出力は保ちます。最終動画が 30 fps なので、録画も 30 fps にして不要な処理を減らします。",
+ "zh-Hans": "每次录制同步设置演示应用与 Tatami 的语言,输入和断言使用相同文案,实际 CLI 命令及机器输出不变。最终视频为 30 fps,因此录制也使用 30 fps,避免不必要的 60 fps 采样。",
+ "zh-Hant": "每次錄製同步設定示範 App 與 Tatami 的語言,輸入和斷言使用相同文案,實際 CLI 指令及機器輸出不變。最終影片為 30 fps,因此錄製也使用 30 fps,避免不必要的 60 fps 取樣。"
+ },
+ "7a6d6bb98ae9d02c": {
+ "en": "{0} identifies missing or rejected v5 takes for each locale. It never treats an English capture as proof of a translated UI. A failed scene stays available for diagnosis; another language can continue independently.",
+ "ko": "{0}은 언어마다 빠졌거나 거부된 v5 테이크를 찾아요. 영어 영상을 번역 UI의 증거로 쓰지 않아요. 실패본은 남기고 다른 언어는 독립적으로 계속할 수 있어요.",
+ "ja": "{0} は言語ごとの不足・不合格の v5 テイクを探します。英語の録画を翻訳 UI の証拠にはせず、失敗は残してほかの言語を続けられます。",
+ "zh-Hans": "{0} 查找每种语言缺失或被拒绝的 v5 录制,不把英语录像当作翻译 UI 的证据。失败保留供诊断,其他语言可独立继续。",
+ "zh-Hant": "{0} 尋找每種語言缺少或被拒絕的 v5 錄製,不把英文錄影當作翻譯 UI 的證據。失敗保留供診斷,其他語言可獨立繼續。"
+ },
+ "1e4e7480ab159a74": {
+ "en": "Small utility windows start near the lower-right corner. The hero moves the status window to the lower-left corner as a visible action. Keep the document's main reading area clear when adjusting a scenario.",
+ "ko": "작은 도구 창은 오른쪽 아래에서 시작해요. 종합 영상은 상태 창을 왼쪽 아래로 직접 옮겨요. 장면을 바꿀 때도 문서의 주요 읽기 영역은 비워두세요.",
+ "ja": "小さなツール窓は右下から始め、主動画では左下へ実際に動かします。調整時も文書の主な読み取り領域を空けてください。",
+ "zh-Hans": "小工具窗口从右下角开始,主视频以实际动作移到左下角。调整场景时保持文档主要阅读区域清晰。",
+ "zh-Hant": "小工具視窗從右下角開始,主影片以實際動作移到左下角。調整場景時保持文件主要閱讀區域清楚。"
+ },
+ "ea76f5a21d5b464b": {
+ "en": "Demo coverage and verification",
+ "ko": "데모 범위와 검증",
+ "ja": "デモの範囲と検証",
+ "zh-Hans": "演示覆盖与验证",
+ "zh-Hant": "示範涵蓋與驗證"
+ },
+ "81721251786daaf3": {
+ "en": "The publication inventory contains 27 scenarios: a complete workflow and 26 focused demonstrations. Every supported locale has its own app language, fixture content, typed input, captions and capture assertions. Accepted takes are selected by locale, scene hash and capture quality, then exported separately.",
+ "ko": "공개 목록은 종합 영상과 기능별 26개를 합친 27개 시나리오예요. 언어마다 앱·내용·입력·자막·검증값을 따로 쓰고, 언어와 장면 해시·촬영 품질이 맞는 테이크만 골라 내보내요.",
+ "ja": "一覧は主動画と 26 個の個別動画、合計 27 場面です。言語別のアプリ、内容、入力、字幕、検証を使い、言語・ハッシュ・品質が合うテイクを選んで書き出します。",
+ "zh-Hans": "发布清单包含主流程和 26 个专题,共 27 个场景。各语言有独立 UI、数据、输入、字幕和断言,按语言、场景哈希和质量选择通过的录制并分别导出。",
+ "zh-Hant": "發布清單包含主流程與 26 個專題,共 27 個場景。各語言有獨立 UI、資料、輸入、字幕和斷言,依語言、場景雜湊與品質選擇通過的錄製並分別輸出。"
+ },
+ "cd4b8e23ba3742b9": {
+ "en": "Captured coverage",
+ "ko": "촬영 범위",
+ "ja": "録画の範囲",
+ "zh-Hans": "录制覆盖",
+ "zh-Hant": "錄製涵蓋"
+ },
+ "7b790708ff1b1713": {
+ "en": "Collection",
+ "ko": "모음",
+ "ja": "動画集",
+ "zh-Hans": "视频集",
+ "zh-Hant": "影片集"
+ },
+ "ef9e821ac8936499": {
+ "en": "Films",
+ "ko": "영상 수",
+ "ja": "動画数",
+ "zh-Hans": "视频数",
+ "zh-Hant": "影片數"
+ },
+ "d2924bca6043d6b3": {
+ "en": "Demonstrated behavior",
+ "ko": "보여주는 동작",
+ "ja": "示す動作",
+ "zh-Hans": "展示行为",
+ "zh-Hant": "展示行為"
+ },
+ "88308781d7def016": {
+ "en": "Direct/recent/ordered switching, auto-open/reopen, app membership moves.",
+ "ko": "직접·최근·순서대로 전환, 자동 열기·다시 열기, 앱 배정 이동.",
+ "ja": "直接・直前・順番での切り替え、自動起動・再度開く、アプリの移動。",
+ "zh-Hans": "直接、最近、顺序切换,自动打开与重开,应用成员移动。",
+ "zh-Hant": "直接、最近、順序切換,自動開啟與重開,App 成員移動。"
+ },
+ "c78fd7d1f25b5ea1": {
+ "en": "Profiles and displays",
+ "ko": "프로필과 화면",
+ "ja": "プロファイルと画面",
+ "zh-Hans": "配置方案与显示器",
+ "zh-Hant": "設定組合與顯示器"
+ },
+ "198b2ac48c6dbe85": {
+ "en": "Profile switching, profile/workspace duplication, two-screen chains, cross-display focus, automatic profile activation on connect/disconnect.",
+ "ko": "프로필 전환·복제, 작업 공간 복제, 두 화면 체인, 화면 간 포커스, 연결·해제에 따른 자동 전환.",
+ "ja": "プロファイルの切り替え・複製、作業複製、二画面チェーン、画面間フォーカス、接続による自動切り替え。",
+ "zh-Hans": "方案切换、方案与工作区复制、双屏链、跨屏焦点、连接断开自动激活。",
+ "zh-Hant": "組合切換、組合與工作空間複製、雙螢幕鏈、跨螢幕焦點、連接中斷自動啟用。"
+ },
+ "85f3ce663f0fa95f": {
+ "en": "Tiling and focus",
+ "ko": "타일링과 포커스",
+ "ja": "タイル表示とフォーカス",
+ "zh-Hans": "平铺与焦点",
+ "zh-Hant": "並排與焦點"
+ },
+ "565bb691ae2e5273": {
+ "en": "Insert/close, swap, split orientation, resize/balance, drag, workspace zoom, pause/resume, directional focus, MFF/FFM, held/quick window switcher, native visual layout editor.",
+ "ko": "창 추가·닫기, 교체, 분할 방향, 크기·균등 배치, 드래그, 확대, 정지·재개, 방향 포커스, MFF·FFM, 길게·짧게 전환, 배치 편집기.",
+ "ja": "追加・閉じる、入れ替え、分割方向、サイズ・均等化、ドラッグ、ズーム、停止・再開、方向フォーカス、MFF・FFM、長押し・短押し切り替え、配置エディタ。",
+ "zh-Hans": "插入关闭、交换、分割方向、调整大小与均衡、拖移、工作区放大、暂停恢复、方向焦点、MFF/FFM、长按轻按切换、原生布局编辑器。",
+ "zh-Hant": "插入關閉、交換、分割方向、調整大小與平均排列、拖移、工作空間放大、暫停恢復、方向焦點、MFF/FFM、長按輕按切換、原生排列編輯器。"
+ },
+ "b80ceecb5756331e": {
+ "en": "Conversation alongside work, persistent scratchpad, edge selection, cross-block focus, full activation and return.",
+ "ko": "옆에서 대화하기, 내용을 기억하는 임시 공간, 방향 선택, 경계 간 포커스, 완전 전환과 돌려보내기.",
+ "ja": "横での会話、内容が残る一時スペース、方向選択、境界を越えるフォーカス、完全な切り替えと返却。",
+ "zh-Hans": "并排对话、持久暂存区、边缘选择、跨区焦点、完整激活与归还。",
+ "zh-Hant": "並排對話、保留內容的暫存區、邊緣選擇、跨區焦點、完整啟用與歸還。"
+ },
+ "9a638a06716425c4": {
+ "en": "Window modes",
+ "ko": "창 표시 방식",
+ "ja": "ウインドウの表示方法",
+ "zh-Hans": "窗口模式",
+ "zh-Hant": "視窗模式"
+ },
+ "82dbeabc01a45937": {
+ "en": "Per-workspace Always on Top, Shared Always on Top, interactive mirrors, Leave As Is and its inclusion in the window switcher.",
+ "ko": "작업 공간별·공용 항상 위, 직접 조작하는 미러, 그대로 두기와 창 전환 참여.",
+ "ja": "作業別・共有の手前表示、操作できるミラー、そのままの窓と切り替えへの参加。",
+ "zh-Hans": "逐工作区和共享置顶、可交互镜像、保持原样及参与切换器。",
+ "zh-Hant": "逐工作空間與共用置頂、可互動鏡像、維持原狀及參與切換器。"
+ },
+ "d909750b1bbb71a3": {
+ "en": "Automation",
+ "ko": "자동화",
+ "ja": "自動化",
+ "zh-Hans": "自动化",
+ "zh-Hant": "自動化"
+ },
+ "069dc527b98a9cbd": {
+ "en": "Real CLI output, a multi-command focus-session script, lifecycle/HUD hooks, atomic TOML edits with live reload.",
+ "ko": "실제 CLI 결과, 여러 명령의 집중 환경 스크립트, 수명 주기·화면 알림 훅, TOML 원자적 수정과 바로 적용.",
+ "ja": "実際の CLI 結果、複数コマンドの準備スクリプト、状態・フィードバックのフック、原子的な TOML 編集と即時反映。",
+ "zh-Hans": "实际 CLI 输出、多命令专注脚本、生命周期与 HUD 钩子、原子编辑 TOML 和实时重载。",
+ "zh-Hant": "實際 CLI 輸出、多指令專注指令稿、生命週期與 HUD Hook、不可分割編輯 TOML 和即時重新載入。"
+ },
+ "fc8fc85c8bc23896": {
+ "en": "Built-in gesture previews and real keyboard practice; validation and application of an example AI proposal to the draft.",
+ "ko": "안내의 제스처 미리보기와 실제 키 연습, 예시 AI 제안 검증과 초안 반영.",
+ "ja": "ガイドのジェスチャプレビュー、実際のキー練習、AI 提案例の確認と下書きへの反映。",
+ "zh-Hans": "内置手势预览和实际键盘练习,验证示例 AI 建议并应用到草稿。",
+ "zh-Hant": "內建手勢預覽與實際鍵盤練習,驗證範例 AI 建議並套用到草稿。"
+ },
+ "b538894db714362f": {
+ "en": "The hero ties design/theme selection, actual PNG export, saved writing, review checks/approval, Borrow, shared status, CLI automation and display-aware profile changes into one continuous task. Canvas, Editor, Docs, Review, Chat, Notes, Terminal and Monitor are interactive local fixtures. Tatami performs the window management; hook and CLI outputs are produced by actual processes.",
+ "ko": "종합 영상은 디자인·색상 선택, PNG 저장, 글쓰기, 검토·승인, 빌려오기, 공용 상태, CLI, 화면별 프로필을 한 작업으로 이어줘요. Canvas, Editor, Docs, Review, Chat, Notes, Terminal, Monitor는 조작할 수 있는 로컬 앱이에요. 창 관리는 Tatami가 하고 훅·CLI 결과는 실제 프로세스가 만들어요.",
+ "ja": "主動画はデザイン、PNG 保存、執筆、確認・承認、借りる操作、共有状態、CLI、画面別プロファイルをつなぎます。Canvas、Editor、Docs、Review、Chat、Notes、Terminal、Monitor は操作できるローカル例です。窓は Tatami が管理し、結果は実際のプロセスが作ります。",
+ "zh-Hans": "主视频把设计配色、实际 PNG 导出、写作保存、评审批准、借用、共享状态、CLI 自动化和显示器相关方案切换串成一个任务。八个命名应用是可操作的本地演示;窗口由 Tatami 管理,钩子和 CLI 输出来自实际进程。",
+ "zh-Hant": "主影片把設計配色、實際 PNG 輸出、撰寫儲存、審閱核准、借用、共用狀態、CLI 自動化與顯示器相關組合切換串成一項工作。八個命名 App 是可操作的本機示範;視窗由 Tatami 管理,Hook 和 CLI 輸出來自實際程序。"
+ },
+ "f8461c22eded2aca": {
+ "en": "Evidence and acceptance",
+ "ko": "검증 자료와 통과 기준",
+ "ja": "証拠と合格条件",
+ "zh-Hans": "证据与验收",
+ "zh-Hant": "證據與驗收"
+ },
+ "35d81c94b8c5e9a4": {
+ "en": "Every accepted take must pass its real action/state/layout assertions, match the current localized scene SHA256, and stay below the 1% frame-drop threshold. Exports check the whole MP4, H.264/yuv420p encoding, editorial duration/size budgets, first-caption timing and the shared movie/timeline clock.",
+ "ko": "실제 동작·상태·배치 검증, 현재 번역 장면 SHA256 일치, 1% 미만 손실을 통과해야 해요. MP4 전체, H.264/yuv420p, 길이·용량, 첫 자막 시간, 영상·타임라인 시계도 확인해요.",
+ "ja": "操作・状態・配置の検証、現在の場面 SHA256、1% 未満の損失が必要です。MP4 全体、形式、時間・容量、最初の字幕と時刻の一致も確認します。",
+ "zh-Hans": "必须通过实际动作、状态和布局断言,匹配当前本地化场景 SHA256,并低于 1% 丢帧。导出检查完整 MP4、H.264/yuv420p、预算、首字幕和共享时钟。",
+ "zh-Hant": "必須通過實際動作、狀態與排列斷言,符合目前在地化場景 SHA256,並低於 1% 遺失影格。輸出檢查完整 MP4、H.264/yuv420p、預算、首字幕與共用時鐘。"
+ },
+ "9a6e6f723401143a": {
+ "en": "The delivery's {0} directory records the actual results: frozen scene JSON, capture metadata, subtitles, timing, sampled frames, OCR and browser verification. Use those reports for a specific batch's counts and review status. Do not treat an old report as evidence for a new language or take.",
+ "ko": "출력의 {0}에 고정 장면 JSON, 촬영 정보, 자막, 시간, 검수 이미지, OCR와 브라우저 결과를 남겨요. 각 배치의 숫자와 검수 상태는 그 보고서로 확인하고 이전 보고서를 새 언어·테이크의 증거로 쓰지 마세요.",
+ "ja": "出力の {0} に固定 JSON、録画情報、字幕、時刻、画像、OCR、ブラウザ結果を残します。そのバッチの数や状態は対応する報告で確認し、古い報告を別言語や新テイクに使わないでください。",
+ "zh-Hans": "{0} 保存冻结场景、录制元数据、字幕、时间、抽样帧、OCR 和浏览器结果。具体批次的数量和检查状态应以对应报告为准,不能把旧报告用于新语言或新录制。",
+ "zh-Hant": "{0} 儲存凍結場景、錄製中繼資料、字幕、時間、抽樣影格、OCR 與瀏覽器結果。特定批次的數量與檢查狀態應以對應報告為準,不能把舊報告用於新語言或新錄製。"
+ },
+ "5b918920363d6520": {
+ "en": "Film colors come from the website dark palette. Caption fonts are selected for the recorded language. Media URLs include the delivery hash, so a replacement movie and its poster cannot silently mix with the previous generation.",
+ "ko": "영상 색상은 웹의 다크 팔레트, 글꼴은 촬영 언어를 따라요. 영상과 포스터 URL에 출력 해시를 넣어 예전 버전과 섞이지 않게 해요.",
+ "ja": "色は Web の暗色パレット、書体は撮影言語に合わせます。動画とポスターの URL にハッシュを付け、旧版との混在を防ぎます。",
+ "zh-Hans": "颜色来自网站深色调色板,字体按语言选择。媒体 URL 包含输出哈希,防止新旧视频和海报混用。",
+ "zh-Hant": "顏色來自網站深色調色盤,字型依語言選擇。媒體 URL 包含輸出雜湊,防止新舊影片與海報混用。"
+ },
+ "9edc1663a1afc739": {
+ "en": "Boundaries and an unresolved reproduction",
+ "ko": "검증 경계와 미해결 재현",
+ "ja": "検証の境界と未解決の再現",
+ "zh-Hans": "边界与未解决问题",
+ "zh-Hant": "界線與未解決問題"
+ },
+ "3cd1ff3dc397879e": {
+ "en": "**Native macOS fullscreen return:** the original test left Canvas and Docs at full-workspace bounds after exiting native fullscreen. Cause is not isolated. {0} and the failed {1} preserve the reproduction. It is excluded from publication. The film named {2} demonstrates Tatami workspace zoom/restore, which passed.",
+ "ko": "**macOS 전체화면 복귀:** 원래 검사에서 전체화면을 끝낸 뒤 Canvas와 Docs가 모두 작업 공간 전체 크기로 남았어요. 원인은 아직 분리하지 못했어요. {0}과 실패한 {1}에 재현을 보관하고 공개용에서 제외했어요. {2} 영상은 통과한 Tatami 자체 확대·복귀예요.",
+ "ja": "**macOS 全画面からの復帰:** Canvas と Docs が両方とも全体サイズに残りました。原因は未特定で、{0} と失敗した {1} に再現を保存し公開から外しました。{2} は合格した Tatami 自身のズームです。",
+ "zh-Hans": "**原生 macOS 全屏恢复:** 退出后 Canvas 和 Docs 都保持整个工作区边界,原因未定位。{0} 与失败的 {1} 保留复现,已从发布中排除。{2} 展示通过验证的 Tatami 工作区放大与恢复。",
+ "zh-Hant": "**原生 macOS 全螢幕還原:** 離開後 Canvas 和 Docs 都保持整個工作空間邊界,原因未定位。{0} 與失敗的 {1} 保留重現,已從發布中排除。{2} 展示通過驗證的 Tatami 工作空間放大與還原。"
+ },
+ "6709c7c0ca9edf3c": {
+ "en": "**Physical gestures:** Guided Setup uses its explicit gesture-preview buttons and real keyboard shortcuts. Physical three/four-finger recognition is not claimed from the VM.",
+ "ko": "**실제 제스처:** 안내의 미리보기 버튼과 실제 키보드를 사용해요. VM만으로 물리 세 손가락·네 손가락 인식을 입증하지 않아요.",
+ "ja": "**実際のジェスチャ:** ガイドのプレビューボタンと実キーを使います。VM から実物の 3 本指・4 本指認識は主張しません。",
+ "zh-Hans": "**物理手势:** 使用向导预览按钮和实际键盘,不宣称 VM 验证了物理三指、四指识别。",
+ "zh-Hant": "**實體手勢:** 使用導覽預覽按鈕與實際鍵盤,不宣稱 VM 驗證了實體三指、四指辨識。"
+ },
+ "3da3748dd6606022": {
+ "en": "**AI:** the proposal is a labelled local example. No external AI request is made. The live TOML was checked after the take and still contained the original workspace names; the example was applied only to the draft.",
+ "ko": "**AI:** 표시해둔 로컬 예시이며 외부 AI를 호출하지 않아요. 촬영 뒤 실제 TOML에 원래 공간 이름이 남아 있는지 확인했어요. 예시는 초안에만 반영됐어요.",
+ "ja": "**AI:** 明示したローカル例で、外部 AI を呼びません。録画後も実際の TOML に元の名前が残ることを確認し、下書きだけに反映しました。",
+ "zh-Hans": "**AI:** 使用标记的本地示例,不请求外部 AI。录制后确认实际 TOML 仍保留原有工作区名称,示例只应用到草稿。",
+ "zh-Hant": "**AI:** 使用標記的本機範例,不要求外部 AI。錄製後確認實際 TOML 仍保留原有工作空間名稱,範例只套用到草稿。"
+ },
+ "fa91b7bb2419bcaa": {
+ "en": "**Multiple monitors:** two guest-side CoreGraphics displays were enumerated, managed and independently captured, then aligned by timestamp. This does not establish physical dock, mixed-DPI, HDR or cable behavior. The private helper lives only in Demo Lab and needs validation on another guest OS version.",
+ "ko": "**여러 화면:** 게스트의 CoreGraphics 화면 두 개를 실제로 열거·관리·개별 녹화하고 시간으로 맞췄어요. 물리 독·혼합 DPI·HDR·케이블 동작은 입증하지 않아요. 비공개 도구는 Demo Lab에만 있고 OS가 바뀌면 다시 검증해야 해요.",
+ "ja": "**複数画面:** ゲストの二つの CoreGraphics 画面を認識・管理・別録画し、時刻で揃えました。実物のドック、混在 DPI、HDR、ケーブルの証明ではありません。専用ヘルパーは OS ごとに再検証が必要です。",
+ "zh-Hans": "**多显示器:** 实际枚举、管理并独立录制两个 CoreGraphics 屏幕,再按时间戳对齐。不代表验证实体扩展坞、混合 DPI、HDR 或线缆。私有工具仅在 Demo Lab,新系统需重新验证。",
+ "zh-Hant": "**多顯示器:** 實際列舉、管理並獨立錄製兩個 CoreGraphics 螢幕,再依時間戳記對齊。不代表驗證實體擴充座、混合 DPI、HDR 或線材。私有工具僅在 Demo Lab,新系統需重新驗證。"
+ },
+ "b8d2ac6da16349d8": {
+ "en": "**Coverage:** the collection covers the main interactions above. It is not exhaustive QA for every preference, language, update flow or hardware variant.",
+ "ko": "**범위:** 위 주요 동작을 다루며 모든 설정·언어·업데이트 과정·하드웨어 조합을 완전히 검사한 것은 아니에요.",
+ "ja": "**範囲:** 上記の主な操作が対象で、全設定、言語、更新経路、ハードウェアの網羅的 QA ではありません。",
+ "zh-Hans": "**覆盖:** 包含上述主要交互,不是所有偏好、语言、更新流程或硬件组合的穷尽 QA。",
+ "zh-Hant": "**涵蓋:** 包含上述主要互動,不是所有偏好、語言、更新流程或硬體組合的完整 QA。"
+ },
+ "2252880a938b225a": {
+ "en": "Capture defects corrected",
+ "ko": "고친 촬영 결함",
+ "ja": "修正した録画の問題",
+ "zh-Hans": "已修复的录制缺陷",
+ "zh-Hant": "已修正的錄製問題"
+ },
+ "e9c062829a87f5d3": {
+ "en": "The driver now preserves numeric-pad/function identity on arrow events. An A/B probe on the same registered hotkey isolated that missing input metadata. [Apple's numericPad documentation]({0}) describes arrow-key identity. Native controls are scrolled into view before clicks; covered normal-stacking windows are selected through Tatami's real switcher. Source sync and fetch use unique archives to remove virtiofs filename reuse, and fetch verifies guest/host hashes.",
+ "ko": "화살표 이벤트의 숫자패드·기능키 정보를 유지하도록 고쳤어요. 같은 단축키로 비교해서 빠진 정보를 확인했으며 [Apple 문서]({0})에도 설명되어 있어요. 컨트롤은 보이도록 스크롤한 뒤 누르고 가려진 창은 실제 전환기로 골라요. 동기화·가져오기는 새 아카이브를 쓰고 양쪽 해시를 확인해요.",
+ "ja": "矢印のテンキー・機能キー情報を保持し、同じキーの比較で欠落を特定しました。[Apple の説明]({0})も参照できます。操作前に表示までスクロールし、隠れた窓は実際の一覧で選びます。転送は新規アーカイブと両側のハッシュを使います。",
+ "zh-Hans": "驱动保留方向键的数字键盘与功能键标识,同一快捷键 A/B 检查确认缺失元数据,[Apple 文档]({0})说明其身份。点击前滚动到可见控件,被遮窗口用实际切换器选择;同步和取回使用唯一归档并验证两端哈希。",
+ "zh-Hant": "驅動保留方向鍵的數字鍵盤與功能鍵識別,同一快速鍵 A/B 檢查確認缺少中繼資料,[Apple 文件]({0})說明其身分。點按前捲動到可見控制項,被遮視窗用實際切換器選擇;同步與取回使用唯一封存檔並驗證兩端雜湊。"
+ },
+ "cb15d0c039c26a28": {
+ "en": "The old claim that a macOS VM could never have a second display was incorrect. The working guest-side API is documented in [DeskPad's CoreGraphics interface]({0}). See [MULTI-DISPLAY.md]({1}) for the actual setup and reproduction.",
+ "ko": "macOS VM에 두 번째 화면을 만들 수 없다는 예전 설명은 잘못됐어요. 실제 사용한 API는 [DeskPad 인터페이스]({0}), 구성과 재현은 [MULTI-DISPLAY.md]({1})에서 확인해요.",
+ "ja": "macOS VM に二画面目を作れないという以前の説明は誤りでした。API は [DeskPad の定義]({0})、構成と再現は [MULTI-DISPLAY.md]({1}) にあります。",
+ "zh-Hans": "旧文档声称 macOS VM 无法拥有第二显示器,这是错误的。实际 API 见 [DeskPad 接口]({0}),配置和复现见 [MULTI-DISPLAY.md]({1})。",
+ "zh-Hant": "舊文件聲稱 macOS VM 無法擁有第二顯示器,這是錯誤的。實際 API 見 [DeskPad 介面]({0}),設定與重現見 [MULTI-DISPLAY.md]({1})。"
+ },
+ "d991b056e9f69cb3": {
+ "en": "Multiple displays inside the recording VM",
+ "ko": "녹화 VM 안의 여러 화면",
+ "ja": "録画 VM 内の複数画面",
+ "zh-Hans": "录制 VM 内的多显示器",
+ "zh-Hant": "錄製 VM 內的多顯示器"
+ },
+ "68194aa95c243a62": {
+ "en": "The macOS 26.6.2 Tart guest can create an additional **guest-side CoreGraphics virtual display**. This is different from adding a second graphics display to the host's {0}.",
+ "ko": "macOS 26.6.2 Tart 게스트는 **게스트 내부 CoreGraphics 가상 화면**을 만들 수 있어요. 호스트의 {0}에 두 번째 그래픽 화면을 추가하는 것과는 다른 경로예요.",
+ "ja": "macOS 26.6.2 の Tart ゲストは**内部の CoreGraphics 仮想画面**を作れます。ホストの {0} に二つ目を追加するのとは別です。",
+ "zh-Hans": "macOS 26.6.2 Tart VM 可以创建**虚拟机内的 CoreGraphics 虚拟显示器**,不同于向主机 {0} 添加第二个图形显示器。",
+ "zh-Hant": "macOS 26.6.2 Tart VM 可以建立**虛擬機內的 CoreGraphics 虛擬顯示器**,不同於向主機 {0} 新增第二個圖形顯示器。"
+ },
+ "c76cabdfa118f521": {
+ "en": "The previous document incorrectly treated the VZ hardware-display limit as a limit on every display a guest can create. The guest-side route has now been verified: macOS enumerated two active 1920×1200 displays, Tatami placed Review on the primary and Chat on the secondary through a workspace chain, and ScreenCaptureKit recorded both screens into independent playable movies.",
+ "ko": "예전 문서는 VZ 하드웨어 화면 제한을 게스트가 만드는 모든 화면의 제한으로 잘못 봤어요. 실제로 활성 1920×1200 화면 두 개가 열거됐고, Tatami 체인으로 Review와 Chat을 각각 배치했으며 두 화면을 독립 영상으로 녹화했어요.",
+ "ja": "以前は VZ のハードウェア制限をすべての画面の制限と誤解していました。実際に 1920×1200 の二画面が認識され、Tatami が Review と Chat を分け、独立した動画を録画できました。",
+ "zh-Hans": "旧文档错误地把 VZ 硬件显示器限制视为所有虚拟显示器的限制。实测枚举出两个活动 1920×1200 显示器,Tatami 链分别放置 Review 和 Chat,ScreenCaptureKit 成功独立录制两屏。",
+ "zh-Hant": "舊文件錯誤地把 VZ 硬體顯示器限制視為所有虛擬顯示器的限制。實測列舉出兩個作用中的 1920×1200 顯示器,Tatami 鏈分別放置 Review 與 Chat,ScreenCaptureKit 成功獨立錄製兩屏。"
+ },
+ "5efe5e51bb55f9be": {
+ "en": "Reproduce",
+ "ko": "재현",
+ "ja": "再現",
+ "zh-Hans": "复现",
+ "zh-Hant": "重現"
+ },
+ "2ac63321fee1b0b3": {
+ "en": "Inside the dedicated guest:",
+ "ko": "전용 게스트 안에서 실행하세요.",
+ "ja": "専用ゲスト内で実行します。",
+ "zh-Hans": "在专用 VM 内执行:",
+ "zh-Hant": "在專用 VM 內執行:"
+ },
+ "414d9a7fed7f3f21": {
+ "en": "The helper uses private {0} interfaces only in Demo Lab, never in the Tatami app. Its API reference is [DeskPad's CoreGraphics bridge]({1}). It creates a 1920×1200 non-HiDPI screen and remains alive until disconnected or its bounded lifetime ends. The controller checks the PID's executable before signalling it. A missing or unsupported helper fails explicitly.",
+ "ko": "비공개 {0} API는 Demo Lab 도구에만 쓰고 Tatami에는 넣지 않아요. [DeskPad CoreGraphics 인터페이스]({1})를 참고했어요. 1920×1200 일반 해상도 화면을 만들고 해제나 제한 시간까지 유지해요. PID의 실행 파일을 확인한 뒤 신호를 보내며 없거나 지원하지 않으면 명시적으로 실패해요.",
+ "ja": "非公開の {0} はラボ専用で Tatami には含めません。[DeskPad の定義]({1})を参照し、1920×1200 の通常解像度の画面を作ります。切断や期限まで保持し、PID の実行ファイルを確認してから終了します。不在・未対応なら明示的に失敗します。",
+ "zh-Hans": "私有 {0} 仅用于 Demo Lab,不进入 Tatami。参考 [DeskPad 接口]({1}),创建 1920×1200 非 HiDPI 显示器,持续到断开或限时结束。发送信号前检查 PID 对应执行文件;工具缺失或不支持时明确失败。",
+ "zh-Hant": "私有 {0} 僅用於 Demo Lab,不進入 Tatami。參考 [DeskPad 介面]({1}),建立 1920×1200 非 HiDPI 顯示器,持續到中斷或限時結束。傳送訊號前檢查 PID 對應執行檔;工具缺少或不支援時明確失敗。"
+ },
+ "d93237cfc6aad3e2": {
+ "en": "Synchronization and composition",
+ "ko": "동기화와 합성",
+ "ja": "同期と合成",
+ "zh-Hans": "同步与合成",
+ "zh-Hant": "同步與合成"
+ },
+ "06e7f1915be51986": {
+ "en": "{0} writes one original per display. Each output records its actual first-frame offset against the common monotonic clock, display origin and its own frame/drop counts. After fetching the batch to the host:",
+ "ko": "{0}은 화면마다 원본을 하나씩 써요. 공통 단조 시계에 대한 첫 프레임 차이, 화면 원점, 각각의 프레임·손실 수를 남겨요. 호스트로 가져온 뒤 다음처럼 사용해요.",
+ "ja": "{0} は画面ごとに原本を書きます。共通時計からの最初の差、画面の原点、個別のフレームと損失数を記録し、ホストへ取得してから使います。",
+ "zh-Hans": "{0} 为每个显示器写一个原始文件,记录相对公共单调时钟的首帧偏移、显示器原点和独立帧数、丢帧数。取回主机后:",
+ "zh-Hant": "{0} 為每部顯示器寫一個原始檔案,記錄相對共同單調時鐘的首影格偏移、顯示器原點與獨立影格數、遺失數。取回主機後:"
+ },
+ "cd7ba752b46d9bb1": {
+ "en": "The compositor orders screens by physical origin and aligns the streams using those recorded offsets. It retains the two originals and creates a lossless intermediate. The exporter presents it as a wide two-screen video with the same Tatami palette as the single-screen films. It does not invent the second screen by duplicating or animating a screenshot.",
+ "ko": "화면 원점 순서와 기록한 시간 차이로 영상을 맞춰요. 원본 두 개를 유지하고 무손실 중간 파일을 만들어요. 한 화면 영상과 같은 팔레트의 넓은 영상으로 내보내며, 스크린샷을 복제하거나 움직여 두 번째 화면을 꾸미지 않아요.",
+ "ja": "物理原点の順と時刻差で揃え、原本を残して無損失の中間ファイルを作ります。単画面と同じ配色の横長動画にし、画像の複製やアニメーションで二画面を装いません。",
+ "zh-Hans": "按物理原点排序并用记录的偏移对齐,保留两个原始文件,生成无损中间文件,再使用相同 Tatami 配色导出宽屏双屏视频。不会复制或动画化截图来伪造第二个屏幕。",
+ "zh-Hant": "依物理原點排序並用記錄的偏移對齊,保留兩個原始檔案,產生無損中間檔,再使用相同 Tatami 配色輸出寬版雙螢幕影片。不會複製或動畫化截圖來偽造第二個螢幕。"
+ },
+ "274db37a0ec907c9": {
+ "en": "Hotplug is a separate scene",
+ "ko": "화면 연결·해제는 별도 장면이에요.",
+ "ja": "接続・取り外しは別の場面です。",
+ "zh-Hans": "热插拔是独立场景",
+ "zh-Hant": "熱插拔是獨立場景"
+ },
+ "07326e943c429fb7": {
+ "en": "{0} captures only the primary screen while the helper connects and disconnects the secondary. These are real display-topology changes, so Tatami's display-count rules activate Desk and Laptop automatically. Recording the removed display itself would stop its capture stream; the primary-only capture is deliberate.",
+ "ko": "{0}은 두 번째 화면을 연결·해제하는 동안 주 화면만 녹화해요. 실제 화면 구성 변경이라 화면 수 규칙으로 Desk와 Laptop이 자동 활성화돼요. 해제할 화면을 녹화하면 스트림이 끝나므로 주 화면만 고른 것이에요.",
+ "ja": "{0} は二画面目の接続・切断中、主画面だけを録画します。実際の構成変更で Desk と Laptop が自動選択されます。外す画面自身を録るとストリームが止まるため、この方式にしています。",
+ "zh-Hans": "{0} 仅录制主屏,同时连接、断开第二个屏幕。实际拓扑变化触发显示器数量规则,自动激活 Desk 和 Laptop。被移除屏幕的流会停止,因此刻意只录主屏。",
+ "zh-Hant": "{0} 僅錄製主螢幕,同時連接、中斷第二個螢幕。實際拓樸變化觸發顯示器數量規則,自動啟用 Desk 與 Laptop。被移除螢幕的串流會停止,因此刻意只錄主螢幕。"
+ },
+ "c26e92c26e55c173": {
+ "en": "Physical monitor cables, docking hardware, HDR behavior, mixed DPI hardware, and actual trackpad gesture recognition remain separate hardware checks. Private CoreGraphics compatibility must be rechecked on a new guest OS version.",
+ "ko": "실제 케이블·독·HDR·혼합 DPI·트랙패드 인식은 별도 하드웨어 검증이에요. 게스트 OS가 바뀌면 비공개 CoreGraphics 호환성을 다시 확인해야 해요.",
+ "ja": "実物のケーブル、ドック、HDR、混在 DPI、ジェスチャ認識は別の実機確認です。ゲスト OS が変われば非公開 API の互換性を再確認します。",
+ "zh-Hans": "实际线缆、扩展坞、HDR、混合 DPI 和触控板手势识别需单独硬件验证。更换虚拟机系统版本后应重新验证私有 API 兼容性。",
+ "zh-Hant": "實際線材、擴充座、HDR、混合 DPI 與觸控式軌跡板手勢辨識需另行硬體驗證。更換虛擬機系統版本後應重新驗證私有 API 相容性。"
+ },
+ "867e002d48c95ebe": {
+ "en": "Capture permissions",
+ "ko": "촬영 권한",
+ "ja": "録画の権限",
+ "zh-Hans": "录制权限",
+ "zh-Hant": "錄製權限"
+ },
+ "dbe0a5026e6c99bd": {
+ "en": "Check permissions through the actual launch path used for the take. A recorder passing preflight does not establish that Tatami can tile or mirror windows.",
+ "ko": "실제 촬영에서 쓰는 실행 경로로 권한을 확인해요. 녹화기 사전 검사만으로 Tatami의 타일링·미러 권한을 증명할 수는 없어요.",
+ "ja": "実際の起動経路で許可を確認します。録画ツールの事前確認だけでは、Tatami の配置・ミラー権限の証拠になりません。",
+ "zh-Hans": "通过实际录制启动路径检查权限。录制器预检通过,不代表 Tatami 可以平铺或镜像窗口。",
+ "zh-Hant": "透過實際錄製啟動路徑檢查權限。錄製器預檢通過,不代表 Tatami 可以並排或鏡像視窗。"
+ },
+ "e083bd83e9d3e97e": {
+ "en": "Process",
+ "ko": "프로세스",
+ "ja": "プロセス",
+ "zh-Hans": "进程",
+ "zh-Hant": "程序"
+ },
+ "e22b58e29107dd1a": {
+ "en": "Needed for",
+ "ko": "필요한 기능",
+ "ja": "必要な用途",
+ "zh-Hans": "用途",
+ "zh-Hant": "用途"
+ },
+ "03867aea70acaf4c": {
+ "en": "Evidence",
+ "ko": "검증 자료",
+ "ja": "確認できる証拠",
+ "zh-Hans": "证据",
+ "zh-Hant": "證據"
+ },
+ "60dc9a95ad77a64c": {
+ "en": "Accessibility: move and focus real windows",
+ "ko": "손쉬운 사용: 실제 창 이동과 포커스",
+ "ja": "アクセシビリティ:実際の窓の移動・フォーカス",
+ "zh-Hans": "辅助功能:移动和聚焦实际窗口",
+ "zh-Hant": "輔助使用:移動與聚焦實際視窗"
+ },
+ "c958075c52ef37ad": {
+ "en": "Visible workspace changes and layout assertions",
+ "ko": "보이는 작업 공간 전환과 배치 검증",
+ "ja": "見える切り替えと配置の検証",
+ "zh-Hans": "可见工作区变化和布局断言",
+ "zh-Hant": "可見工作空間變化與排列斷言"
+ },
+ "98863f8b4dcec83b": {
+ "en": "Screen Recording: Always on Top mirrors",
+ "ko": "화면 기록: 항상 위 미러",
+ "ja": "画面収録:常に手前のミラー",
+ "zh-Hans": "屏幕录制:置顶镜像",
+ "zh-Hant": "螢幕錄製:置頂鏡像"
+ },
+ "aa2891675bc88a0a": {
+ "en": "A shared monitor remains visible over a zoomed window",
+ "ko": "확대한 창 위에서도 보이는 공용 상태 창",
+ "ja": "広げた窓の上にも見える共有の状態窓",
+ "zh-Hans": "共享状态窗口在放大窗口上方仍可见",
+ "zh-Hant": "共用狀態視窗在放大視窗上方仍可見"
+ },
+ "580eef26ceba6a22": {
+ "en": "democtl / its launcher",
+ "ko": "democtl / 실행기",
+ "ja": "democtl / 起動元",
+ "zh-Hans": "democtl / 启动器",
+ "zh-Hant": "democtl / 啟動器"
+ },
+ "6ddc6fad8b281a1d": {
+ "en": "Accessibility: keyboard, pointer, native controls",
+ "ko": "손쉬운 사용: 키보드·포인터·네이티브 컨트롤",
+ "ja": "アクセシビリティ:キー、ポインタ、ネイティブ操作",
+ "zh-Hans": "辅助功能:键盘、指针、原生控件",
+ "zh-Hant": "輔助使用:鍵盤、游標、原生控制項"
+ },
+ "4b8eb30cc843bcd7": {
+ "en": "Real input and control-value assertions",
+ "ko": "실제 입력과 컨트롤 값 검증",
+ "ja": "実際の入力と値の確認",
+ "zh-Hans": "实际输入和控件值断言",
+ "zh-Hant": "實際輸入與控制項值斷言"
+ },
+ "fa1ff876989ee7b0": {
+ "en": "DemoRecorder / its launcher",
+ "ko": "DemoRecorder / 실행기",
+ "ja": "DemoRecorder / 起動元",
+ "zh-Hans": "DemoRecorder / 启动器",
+ "zh-Hant": "DemoRecorder / 啟動器"
+ },
+ "135d80628c17a76a": {
+ "en": "Screen Recording: capture the display",
+ "ko": "화면 기록: 화면 캡처",
+ "ja": "画面収録:画面の取得",
+ "zh-Hans": "屏幕录制:捕获显示器",
+ "zh-Hant": "螢幕錄製:擷取顯示器"
+ },
+ "d3f97c0aabfaf0f5": {
+ "en": "First encoded frame, finalized movie and decode validation",
+ "ko": "첫 인코딩 프레임·완성 영상·전체 디코딩 검사",
+ "ja": "最初の符号化フレーム、完成動画、デコード確認",
+ "zh-Hans": "首个编码帧、完成的影片与解码验证",
+ "zh-Hant": "首個編碼影格、完成的影片與解碼驗證"
+ },
+ "9b9059b1cb0ef4cb": {
+ "en": "Use System Settings → Privacy & Security to grant missing permissions in the **dedicated recording VM**. Relaunch the relevant processes after changing a grant, then run {0} and a short take again. Do not alter the host's permissions or its personal Tatami configuration for a VM recording.",
+ "ko": "**전용 녹화 VM**의 시스템 설정 → 개인정보 보호 및 보안에서 필요한 권한을 주세요. 변경 후 프로세스를 다시 실행하고 {0}과 짧은 촬영으로 확인하세요. 호스트의 권한이나 개인 Tatami 설정은 바꾸지 않아요.",
+ "ja": "**専用録画 VM**のシステム設定で必要な許可を与えます。変更後に再起動し、{0} と短い録画で確認します。ホストの権限や個人設定は変えません。",
+ "zh-Hans": "在**专用录制 VM**的系统设置 → 隐私与安全性中授予缺少的权限。重新启动相关进程,再运行 {0} 和短片检查,不修改主机权限或个人 Tatami 配置。",
+ "zh-Hant": "在**專用錄製 VM**的系統設定 → 隱私權與安全性中授予缺少的權限。重新啟動相關程序,再執行 {0} 和短片檢查,不修改主機權限或個人 Tatami 設定。"
+ },
+ "f32b359f0aafa80a": {
+ "en": "{0} selects direct launch when its caller already has capture access, otherwise LaunchServices. In the current Tart guest, {1} runs through a pre-authorized agent. This is an observed property of that image and launch path; it is not a promise about every guest or every rebuilt executable.",
+ "ko": "{0}은 호출자에게 캡처 권한이 있으면 직접 실행하고, 없으면 LaunchServices를 써요. 현재 게스트의 {1}은 미리 허용된 에이전트를 통해 실행돼요. 이 이미지와 경로에서 확인한 결과일 뿐, 모든 게스트나 재빌드 파일에 적용되는 약속은 아니에요.",
+ "ja": "{0} は呼び出し元に許可があれば直接、なければ LaunchServices で起動します。現在の {1} は許可済みエージェント経由です。このイメージと経路での観測であり、すべてのゲストや再ビルドに保証しません。",
+ "zh-Hans": "{0} 在调用者已有捕获权限时直接启动,否则使用 LaunchServices。当前 {1} 通过预授权代理运行;这是该镜像和启动路径的实测结果,不代表所有 VM 或重建的执行文件。",
+ "zh-Hant": "{0} 在呼叫者已有擷取權限時直接啟動,否則使用 LaunchServices。目前 {1} 透過預先授權代理程式執行;這是該映像與啟動路徑的實測結果,不代表所有 VM 或重建的執行檔。"
+ },
+ "5d483ab6c954859c": {
+ "en": "A window can outlive the process that requested it",
+ "ko": "요청한 앱이 꺼져도 권한 창이 남을 수 있어요.",
+ "ja": "要求したプロセスが終了しても窓は残ることがある",
+ "zh-Hans": "请求进程退出后,对话框仍可能保留",
+ "zh-Hant": "要求程序結束後,對話框仍可能保留"
+ },
+ "cb1c36ea93262ae0": {
+ "en": "The old tour contained a {0} dialog asking for **Tatami** access. It was left behind a stack of app windows and appeared when those windows closed. The earlier recorder-only preflight did not detect it. It was not evidence of a periodic recorder reminder.",
+ "ko": "예전 종합 영상에는 **Tatami** 권한을 요청하는 {0} 창이 남아 있었어요. 앱 창 뒤에 가려졌다가 창을 닫으면서 나타났고, 녹화기만 검사하던 방식은 놓쳤어요. 정기적인 녹화 알림이라는 증거는 아니었어요.",
+ "ja": "以前の動画には **Tatami** を求める {0} が残り、他の窓を閉じると見えました。録画ツールだけの確認では検出できず、定期的な通知の証拠ではありませんでした。",
+ "zh-Hans": "旧主视频残留请求 **Tatami** 权限的 {0} 对话框。它被窗口遮住,关闭后才出现,旧预检只检查录制器而漏检;这不是定期录制提醒的证据。",
+ "zh-Hant": "舊主影片殘留要求 **Tatami** 權限的 {0} 對話框。它被視窗遮住,關閉後才出現,舊預檢只檢查錄製器而漏檢;這不是定期錄製提醒的證據。"
+ },
+ "ec3e9316ee024d5d": {
+ "en": "The capture gate now rejects visible permission/settings windows even when they are occluded by another app. Resolve the identified dialog in the guest before retrying. Closing a stale request does not grant access and must not be reported as granting access. If the current run needs a missing grant, make that grant in System Settings and test the relevant behavior.",
+ "ko": "다른 앱 뒤에 가려져 있어도 권한·설정 창이 있으면 거부해요. 게스트에서 해결하고 다시 시도하세요. 오래된 요청을 닫는 것과 권한을 주는 것은 달라요. 실제 권한이 없다면 시스템 설정에서 허용하고 관련 동작을 확인해요.",
+ "ja": "別アプリの背後でも権限・設定窓があれば拒否します。ゲストで解決して再試行してください。古い要求を閉じることは許可ではありません。不足していればシステム設定で許可し、動作を試します。",
+ "zh-Hans": "即使被其他应用遮挡,权限或设置窗口也会被拒绝。先在 VM 中解决再重试。关闭旧请求不等于授予权限;确实缺少授权时,应在系统设置中授予并验证相关行为。",
+ "zh-Hant": "即使被其他 App 遮擋,權限或設定視窗也會被拒絕。先在 VM 中解決再重試。關閉舊要求不等於授予權限;確實缺少授權時,應在系統設定中授予並驗證相關行為。"
+ },
+ "3d6687a41b428195": {
+ "en": "A throwaway {0} exercises only the recorder. To exercise Tatami's separate ScreenCaptureKit path, rehearse the {1} scene too. Do not crop permission UI out of footage or label an unverified take successful.",
+ "ko": "{0}은 녹화기만 확인해요. Tatami의 별도 캡처 경로는 {1}도 연습해서 확인해야 해요. 권한 창을 잘라내거나 미검증 영상을 성공으로 표시하지 마세요.",
+ "ja": "{0} は録画ツールだけの確認です。Tatami の別経路には {1} も試します。許可画面を切り取ったり、未確認を成功にしたりしないでください。",
+ "zh-Hans": "{0} 只测试录制器,Tatami 独立的捕获路径还需排练 {1}。不能裁掉权限界面或把未验证录制标为成功。",
+ "zh-Hant": "{0} 只測試錄製器,Tatami 獨立的擷取路徑還需排練 {1}。不能裁掉權限介面或把未驗證錄製標為成功。"
+ },
+ "074dcdd540340713": {
+ "en": "Repeatable recording image",
+ "ko": "다시 쓸 수 있는 녹화 이미지",
+ "ja": "繰り返し使える録画イメージ",
+ "zh-Hans": "可重复使用的录制镜像",
+ "zh-Hant": "可重複使用的錄製映像"
+ },
+ "1f52152cec122357": {
+ "en": "Build the exact tools and app bundles, resolve their permissions, verify the actual workload, then preserve that VM image. Rebuilding or changing the launcher can change permission attribution; verify again rather than copying database rows or assuming an old grant still applies. The former direct-TCC-database modification script has been removed from the lab.",
+ "ko": "정확한 도구와 앱을 빌드하고 권한·실제 작업을 확인한 뒤 VM 이미지를 보관하세요. 재빌드나 실행기 변경은 권한 귀속을 바꿀 수 있어 다시 확인해야 해요. DB 행을 복사하거나 예전 권한을 가정하지 않아요. TCC DB를 직접 고치던 스크립트는 삭제했어요.",
+ "ja": "正しいツールをビルドし、許可と実作業を確認してから保存します。再ビルドや起動元の変更で許可の帰属が変わるため再確認し、DB の複製や古い許可の仮定はしません。TCC DB を直接変えるスクリプトは削除済みです。",
+ "zh-Hans": "构建准确的工具与应用,解决权限并验证工作后保存镜像。重建或变更启动器可能改变权限归属,应重新验证,不能复制数据库行或假定旧授权有效。直接修改 TCC 数据库的旧脚本已移除。",
+ "zh-Hant": "建置確切的工具與 App,解決權限並驗證工作後保存映像。重建或變更啟動器可能改變權限歸屬,應重新驗證,不能拷貝資料庫列或假定舊授權有效。直接修改 TCC 資料庫的舊指令稿已移除。"
+ },
+ "a49000a4fd0cb23e": {
+ "en": "Scenes and acceptance",
+ "ko": "장면과 통과 기준",
+ "ja": "場面と合格条件",
+ "zh-Hans": "场景与验收",
+ "zh-Hant": "場景與驗收"
+ },
+ "cf2befb0f1a62829": {
+ "en": "Preparation",
+ "ko": "준비",
+ "ja": "準備",
+ "zh-Hans": "准备",
+ "zh-Hant": "準備"
+ },
+ "598fb996b2ecc775": {
+ "en": "Declare the apps that must be visible at frame zero in {0}. The declared app multiset must match ordinary visible windows, including intentional duplicate references. {1} declares an empty array because its first action opens the workspace. The recording gate checks this after the geometry has settled for 400 ms.",
+ "ko": "첫 프레임에 보여야 할 앱을 {0}에 적어요. 의도한 중복 창까지 실제 일반 창 목록과 같아야 해요. {1}은 처음에 앱을 여는 기능이라 빈 배열을 써요. 배치가 400ms 동안 안정된 뒤 확인해요.",
+ "ja": "最初に見えるアプリを {0} に指定します。意図した複数窓も含め、実際の通常ウインドウと一致させます。{1} は開く操作が目的なので空配列です。400 ms 安定してから検証します。",
+ "zh-Hans": "在 {0} 声明首帧必须可见的应用,多重集合必须匹配实际普通窗口,包括刻意的重复资料窗口。{1} 以打开工作区为首操作,因此使用空数组。几何稳定 400 ms 后检查。",
+ "zh-Hant": "在 {0} 宣告首影格必須可見的 App,多重集合必須符合實際一般視窗,包含刻意的重複資料視窗。{1} 以開啟工作空間為首操作,因此使用空陣列。幾何穩定 400 ms 後檢查。"
+ },
+ "3707b22c2758c4f5": {
+ "en": "Warm up workspaces through Tatami before rolling. Do not combine pending Auto-open launches with manual relaunches of the same apps: that can create two processes and two windows with one bundle identifier. A failed opening check is a reason to correct preparation, not to trim away the unexpected window later.",
+ "ko": "촬영 전에 Tatami로 작업 공간을 준비해요. 자동 열기가 진행 중일 때 같은 앱을 수동으로 다시 열면 같은 번들 ID의 프로세스와 창이 두 개 생길 수 있어요. 첫 화면 검사가 실패하면 나중에 잘라내지 말고 준비 과정을 고쳐요.",
+ "ja": "録画前に Tatami で作業を準備します。自動起動中に同じアプリを手動で起動すると、同じ ID のプロセスと窓が二つできます。開始時の失敗は後で切り取らず、準備を直します。",
+ "zh-Hans": "录制前通过 Tatami 准备工作区。不要把尚未完成的自动打开与手动重启混用,否则同一包 ID 可能出现两个进程、两个窗口。开场检查失败应修正准备过程,不在后期裁掉意外窗口。",
+ "zh-Hant": "錄製前透過 Tatami 準備工作空間。不要把尚未完成的自動開啟與手動重新啟動混用,否則同一套件 ID 可能出現兩個程序、兩個視窗。開場檢查失敗應修正準備過程,不在後製裁掉意外視窗。"
+ },
+ "777b2e02d736cb93": {
+ "en": "Real actions",
+ "ko": "실제 동작",
+ "ja": "実際の操作",
+ "zh-Hans": "实际操作",
+ "zh-Hant": "實際操作"
+ },
+ "f5387f9bb6ed7031": {
+ "en": "Kind",
+ "ko": "유형",
+ "ja": "種類",
+ "zh-Hans": "类型",
+ "zh-Hant": "類型"
+ },
+ "616f48c1db195b6f": {
+ "en": "Fields",
+ "ko": "필드",
+ "ja": "フィールド",
+ "zh-Hans": "字段",
+ "zh-Hant": "欄位"
+ },
+ "2252d5cf93767a43": {
+ "en": "Effect",
+ "ko": "효과",
+ "ja": "効果",
+ "zh-Hans": "效果",
+ "zh-Hant": "效果"
+ },
+ "bf94e54737b524b7": {
+ "en": "Resolve a native AX control; move the pointer and click.",
+ "ko": "네이티브 AX 컨트롤을 찾고 포인터를 옮겨 클릭해요.",
+ "ja": "AX コントロールを探し、ポインタを移してクリックします。",
+ "zh-Hans": "定位原生 AX 控件,移动指针并点击。",
+ "zh-Hant": "定位原生 AX 控制項,移動游標並點按。"
+ },
+ "367d59047e7ad524": {
+ "en": "Type one character at a time into the expected focused app.",
+ "ko": "포커스가 맞는 앱에 한 글자씩 입력해요.",
+ "ja": "想定したアプリに、一文字ずつ入力します。",
+ "zh-Hans": "在预期聚焦应用中逐字输入。",
+ "zh-Hant": "在預期聚焦 App 中逐字輸入。"
+ },
+ "2cdb30393bd1bd1b": {
+ "en": "Reveal a native control in its scroll viewport and move the pointer to it.",
+ "ko": "스크롤 영역 안으로 컨트롤을 보여주고 포인터를 옮겨요.",
+ "ja": "コントロールをスクロール内に見せ、ポインタを移します。",
+ "zh-Hans": "滚动让原生控件进入可见区域,再移入指针。",
+ "zh-Hant": "捲動讓原生控制項進入可見區域,再移入游標。"
+ },
+ "b4c1dc2d1aa6d26b": {
+ "en": "Move to a native control and scroll.",
+ "ko": "네이티브 컨트롤로 이동해서 스크롤해요.",
+ "ja": "コントロールへ移動してスクロールします。",
+ "zh-Hans": "移动到原生控件并滚动。",
+ "zh-Hant": "移動到原生控制項並捲動。"
+ },
+ "8c45010cefe03bb0": {
+ "en": "Press a real shortcut and generate its own keycast.",
+ "ko": "실제 단축키를 누르고 그 입력을 표시해요.",
+ "ja": "実際のキーを押し、その入力を表示します。",
+ "zh-Hans": "按实际快捷键并生成相应按键显示。",
+ "zh-Hant": "按實際快速鍵並產生相應按鍵顯示。"
+ },
+ "9c42d509ca974f32": {
+ "en": "Hold the real modifier through a switcher interaction.",
+ "ko": "창 전환 중 실제 보조키를 계속 눌러요.",
+ "ja": "切り替え操作中、実際の修飾キーを保持します。",
+ "zh-Hans": "在切换交互期间持续按住实际修饰键。",
+ "zh-Hant": "在切換互動期間持續按住實際輔助鍵。"
+ },
+ "f4c0cde997663fdc": {
+ "en": "Click the visible title bar, then verify the focused app through AX. A covered window needs a real switching action.",
+ "ko": "보이는 제목 막대를 누르고 AX로 포커스를 확인해요. 가려진 창은 실제 전환 동작으로 선택해야 해요.",
+ "ja": "見えるタイトルバーを押して AX で確認します。隠れた窓には実際の切り替え操作が必要です。",
+ "zh-Hans": "点击可见标题栏,再通过 AX 确认焦点。被遮住的窗口需要实际切换操作。",
+ "zh-Hant": "點按可見標題列,再透過 AX 確認焦點。被遮住的視窗需要實際切換操作。"
+ },
+ "ea0c36e57206c54a": {
+ "en": "Run Tatami's blocking activation command.",
+ "ko": "완료를 기다리는 Tatami 활성화 명령을 실행해요.",
+ "ja": "完了を待つ Tatami の有効化コマンドを実行します。",
+ "zh-Hans": "执行等待完成的 Tatami 激活命令。",
+ "zh-Hant": "執行等待完成的 Tatami 啟用指令。"
+ },
+ "77a5cda56c42f90f": {
+ "en": "Run Tatami's blocking profile command.",
+ "ko": "완료를 기다리는 프로필 명령을 실행해요.",
+ "ja": "完了を待つプロファイルコマンドを実行します。",
+ "zh-Hans": "执行等待完成的配置方案命令。",
+ "zh-Hant": "執行等待完成的設定組合指令。"
+ },
+ "cf754c9fe292a7e8": {
+ "en": "Run another Tatami command; {0} only means enqueued.",
+ "ko": "다른 Tatami 명령을 실행해요. {0}은 대기열에 넣었다는 뜻일 뿐이에요.",
+ "ja": "別の Tatami コマンドを実行します。{0} はキューに入った意味だけです。",
+ "zh-Hans": "执行其他 Tatami 命令;{0} 只表示入队。",
+ "zh-Hant": "執行其他 Tatami 指令;{0} 只表示排入佇列。"
+ },
+ "f42bc6e3c8b787ac": {
+ "en": "CLI Borrow for specialist rehearsals. Published Borrow uses real keys.",
+ "ko": "전문 연습용 CLI 빌려오기예요. 공개 영상은 실제 키를 써요.",
+ "ja": "詳細な練習用の CLI 借りる操作です。公開動画では実際のキーを使います。",
+ "zh-Hans": "高级排练用 CLI 借用,发布视频使用实际按键。",
+ "zh-Hant": "進階排練用 CLI 借用,發布影片使用實際按鍵。"
+ },
+ "ea2e4338401e364a": {
+ "en": "CLI return for specialist rehearsals.",
+ "ko": "전문 연습용 CLI 돌려보내기예요.",
+ "ja": "詳細な練習用の CLI 返却です。",
+ "zh-Hans": "高级排练用 CLI 归还。",
+ "zh-Hant": "進階排練用 CLI 歸還。"
+ },
+ "d2ad06848550ca1d": {
+ "en": "Park the pointer using normalized display coordinates.",
+ "ko": "정규화한 화면 좌표에 포인터를 놓아요.",
+ "ja": "正規化した画面座標にポインタを置きます。",
+ "zh-Hans": "用归一化显示器坐标放置指针。",
+ "zh-Hant": "用正規化顯示器座標放置游標。"
+ },
+ "4b55b753acfba5cf": {
+ "en": "Explicitly launch fixtures, primarily off camera.",
+ "ko": "주로 촬영 전에 데모 앱을 명시적으로 실행해요.",
+ "ja": "主に録画前に、デモアプリを明示して起動します。",
+ "zh-Hans": "明确启动演示应用,主要用于录制前。",
+ "zh-Hant": "明確啟動示範 App,主要用於錄製前。"
+ },
+ "c87158f790ccf8bf": {
+ "en": "Quit named fixtures or the entire fixture set.",
+ "ko": "지정한 앱이나 데모 앱 전체를 종료해요.",
+ "ja": "指定したアプリ、またはデモ全体を終了します。",
+ "zh-Hans": "退出指定演示应用或整个集合。",
+ "zh-Hant": "結束指定示範 App 或整個集合。"
+ },
+ "b7ae731654d1d752": {
+ "en": "Drag a real title bar toward a normalized target-window point.",
+ "ko": "제목 막대를 대상 창의 정규화 좌표로 드래그해요.",
+ "ja": "タイトルバーを、対象窓の正規化した位置へドラッグします。",
+ "zh-Hans": "把实际标题栏拖到目标窗口的归一化位置。",
+ "zh-Hant": "把實際標題列拖到目標視窗的正規化位置。"
+ },
+ "e97f6972d030075a": {
+ "en": "Drag a native window edge.",
+ "ko": "네이티브 창의 가장자리를 드래그해요.",
+ "ja": "実際の窓の端をドラッグします。",
+ "zh-Hans": "拖动原生窗口边缘。",
+ "zh-Hant": "拖移原生視窗邊緣。"
+ },
+ "b4b1e36346fdf045": {
+ "en": "Connect or disconnect the real guest virtual display helper.",
+ "ko": "게스트의 실제 가상 화면 도구를 연결하거나 해제해요.",
+ "ja": "ゲストの実際の仮想画面ヘルパーを接続・切断します。",
+ "zh-Hans": "连接或断开实际虚拟显示器工具。",
+ "zh-Hant": "連接或中斷實際虛擬顯示器工具。"
+ },
+ "bf78cf77962b898e": {
+ "en": "Atomically edit an allowed live setting in the lab TOML.",
+ "ko": "실험용 TOML의 허용한 설정을 원자적으로 바꿔요.",
+ "ja": "ラボの TOML で許可された設定を原子的に変更します。",
+ "zh-Hans": "原子修改实验 TOML 中允许的实时设置。",
+ "zh-Hant": "不可分割地修改實驗 TOML 中允許的即時設定。"
+ },
+ "3f7fdf6576bbba9b": {
+ "en": "Provide local example text, preserving and restoring all clipboard item types.",
+ "ko": "클립보드의 모든 데이터 형식을 백업·복원하며 예시 텍스트를 제공해요.",
+ "ja": "クリップボードの全形式を保存・復元し、例文を渡します。",
+ "zh-Hans": "提供本地示例文本,同时保留并恢复所有剪贴板类型。",
+ "zh-Hant": "提供本機範例文字,同時保留並還原所有剪貼簿類型。"
+ },
+ "cdf3d04277d09d21": {
+ "en": "Close the native settings window after authoring.",
+ "ko": "편집 뒤 네이티브 설정 창을 닫아요.",
+ "ja": "編集後に設定ウインドウを閉じます。",
+ "zh-Hans": "编辑后关闭原生设置窗口。",
+ "zh-Hant": "編輯後關閉原生設定視窗。"
+ },
+ "8d43fd911ab87368": {
+ "en": "Set native window count, primarily for preparation.",
+ "ko": "주로 준비 단계에서 실제 창 수를 정해요.",
+ "ja": "主に準備時に、実際のウインドウ数を設定します。",
+ "zh-Hans": "设置原生窗口数量,主要用于准备。",
+ "zh-Hant": "設定原生視窗數量,主要用於準備。"
+ },
+ "082761fdeb50c6a4": {
+ "en": "There is no {0} command. Views cannot be jumped to a pre-rendered success state. They use normal controls and share saved work through {1}. The launch copy, review, checks, tasks and messages are persisted together under the lab's control directory. {2} resets the story; switching workspaces does not. The chat is a local fixture and has no network transport.",
+ "ko": "{0} 명령은 없어요. 미리 만든 성공 화면으로 이동하지 않고 일반 컨트롤과 {1}로 저장한 작업을 공유해요. 문구·검토·검사·할 일·메시지는 같은 제어 폴더에 저장해요. {2}만 이야기를 초기화하고 공간 전환은 초기화하지 않아요. 대화는 네트워크 없는 로컬 데모예요.",
+ "ja": "{0} はありません。用意した成功画面へ飛ばず、通常の操作と {1} で文章、レビュー、チェック、タスク、会話を同じ場所に保存します。{2} でだけ初期化し、作業の切り替えでは戻しません。会話はネット接続のないローカル例です。",
+ "zh-Hans": "没有 {0} 命令,视图不能跳到预制成功状态。它们使用普通控件,通过 {1} 共享保存的文案、评审、检查、任务和消息。{2} 重置故事,切换工作区不会。聊天是无网络传输的本地演示。",
+ "zh-Hant": "沒有 {0} 指令,視圖不能跳到預製成功狀態。它們使用一般控制項,透過 {1} 共用儲存的文案、審閱、檢查、待辦與訊息。{2} 重設故事,切換工作空間不會。聊天是沒有網路傳輸的本機示範。"
+ },
+ "0a0754eaaa9dbbbc": {
+ "en": "Assertions and pacing",
+ "ko": "검증과 속도",
+ "ja": "検証とペース",
+ "zh-Hans": "断言与节奏",
+ "zh-Hant": "斷言與節奏"
+ },
+ "f190a5cb3aff17b0": {
+ "en": "Acceptance",
+ "ko": "통과 기준",
+ "ja": "合格条件",
+ "zh-Hans": "验收条件",
+ "zh-Hant": "驗收條件"
+ },
+ "d4c333918493b3d3": {
+ "en": "Read the actual native input value back.",
+ "ko": "네이티브 입력값을 다시 읽어요.",
+ "ja": "実際の入力値を読み戻します。",
+ "zh-Hans": "回读实际原生输入值。",
+ "zh-Hant": "讀回實際原生輸入值。"
+ },
+ "d450c2f51423f35a": {
+ "en": "Verify saved headline, approval, comment, last task/message, checks or completed tasks.",
+ "ko": "저장한 제목·승인·의견·마지막 할 일과 메시지·검사·완료 수를 확인해요.",
+ "ja": "保存した見出し、承認、コメント、最後のタスク・会話、チェック、完了数を確認します。",
+ "zh-Hans": "验证保存的标题、批准、意见、最后任务与消息、检查或完成项。",
+ "zh-Hant": "驗證儲存的標題、核准、意見、最後待辦與訊息、檢查或完成項。"
+ },
+ "3c737836728f9620": {
+ "en": "Verify the actual focused app through Accessibility.",
+ "ko": "접근성 API로 실제 포커스 앱을 확인해요.",
+ "ja": "アクセシビリティで実際のフォーカスを確認します。",
+ "zh-Hans": "通过辅助功能验证实际聚焦应用。",
+ "zh-Hant": "透過輔助使用驗證實際聚焦 App。"
+ },
+ "fb22a75a706c27d1": {
+ "en": "Require the pointer inside the target window.",
+ "ko": "포인터가 대상 창 안에 있어야 해요.",
+ "ja": "ポインタが対象窓の内側にあることを確認します。",
+ "zh-Hans": "要求指针位于目标窗口内。",
+ "zh-Hant": "要求游標位於目標視窗內。"
+ },
+ "92c286cb6a429da8": {
+ "en": "Verify an actual fixture Terminal process result.",
+ "ko": "Terminal의 실제 프로세스 실행 결과를 확인해요.",
+ "ja": "Terminal の実際のプロセス結果を確認します。",
+ "zh-Hans": "验证演示 Terminal 的实际进程结果。",
+ "zh-Hant": "驗證示範 Terminal 的實際程序結果。"
+ },
+ "388225faf29c8f6c": {
+ "en": "Inspect the payload written by a real Tatami hook.",
+ "ko": "실제 Tatami 훅이 기록한 값을 읽어요.",
+ "ja": "実際の Tatami フックが書いた内容を読みます。",
+ "zh-Hans": "检查实际 Tatami 钩子写入的数据。",
+ "zh-Hant": "檢查實際 Tatami Hook 寫入的資料。"
+ },
+ "2075282b1d10ba75": {
+ "en": "Save and compare one window across changing workspace memberships.",
+ "ko": "소속이 바뀌어도 같은 창의 위치를 저장하고 비교해요.",
+ "ja": "所属の変更をまたいで、一つの窓を保存・比較します。",
+ "zh-Hans": "跨成员关系变化保存并比较同一窗口。",
+ "zh-Hant": "跨成員關係變化儲存並比較同一視窗。"
+ },
+ "02063e93d515da18": {
+ "en": "Verify that a native layout-editor operation changed its preview.",
+ "ko": "배치 편집기 동작이 실제 미리보기를 바꿨는지 확인해요.",
+ "ja": "配置エディタの操作で実際のプレビューが変わったか確認します。",
+ "zh-Hans": "验证布局编辑操作实际改变了预览。",
+ "zh-Hant": "驗證排列編輯操作實際改變了預覽。"
+ },
+ "eb647ece4db12c12": {
+ "en": "Wait for visible windows and stable geometry.",
+ "ko": "보이는 창과 안정된 배치를 기다려요.",
+ "ja": "見える窓と配置の安定を待ちます。",
+ "zh-Hans": "等待可见窗口和稳定几何。",
+ "zh-Hant": "等待可見視窗和穩定幾何。"
+ },
+ "44c83b0f6f25a5ed": {
+ "en": "Observe the activation hook from the immediately preceding step.",
+ "ko": "바로 앞 동작에서 나온 활성화 훅을 확인해요.",
+ "ja": "直前の操作による有効化フックを確認します。",
+ "zh-Hans": "观察上一步产生的激活钩子。",
+ "zh-Hant": "觀察上一步產生的啟用 Hook。"
+ },
+ "552f684b59aebfd0": {
+ "en": "Observe the profile hook.",
+ "ko": "프로필 훅을 확인해요.",
+ "ja": "プロファイルのフックを確認します。",
+ "zh-Hans": "观察配置方案钩子。",
+ "zh-Hant": "觀察設定組合 Hook。"
+ },
+ "bf17dbf231c535d9": {
+ "en": "Save visible demo window IDs and bounds under a checkpoint name.",
+ "ko": "보이는 데모 창 ID와 영역을 이름 있는 체크포인트로 저장해요.",
+ "ja": "見えるデモ窓の ID と枠を、名前付きの確認点に保存します。",
+ "zh-Hans": "按检查点名称保存可见演示窗口 ID 和边界。",
+ "zh-Hant": "依檢查點名稱儲存可見示範視窗 ID 與邊界。"
+ },
+ "0583996926d56a18": {
+ "en": "Require that exact window set and bounds to return, within 4 points.",
+ "ko": "같은 창 목록과 영역으로 4포인트 이내에 돌아와야 해요.",
+ "ja": "同じ窓と枠へ、4 pt 以内で戻ることを要求します。",
+ "zh-Hans": "要求相同窗口集合与边界在 4 点误差内恢复。",
+ "zh-Hant": "要求相同視窗集合與邊界在 4 點誤差內還原。"
+ },
+ "1b0a21fd003cf60c": {
+ "en": "A declared reading pause; no hidden startup sleep.",
+ "ko": "명시한 읽기 시간이에요. 숨겨진 시작 대기는 없어요.",
+ "ja": "明示した読む時間です。隠れた起動待ちはありません。",
+ "zh-Hans": "明确声明的阅读停顿,没有隐藏启动等待。",
+ "zh-Hant": "明確宣告的閱讀停頓,沒有隱藏啟動等待。"
+ },
+ "b392d4f194907117": {
+ "en": "Log-only explanation.",
+ "ko": "로그에만 남기는 설명이에요.",
+ "ja": "ログだけに残す説明です。",
+ "zh-Hans": "仅记录到日志的说明。",
+ "zh-Hant": "僅記錄到日誌的說明。"
+ },
+ "8895bdb57db72226": {
+ "en": "Every step is surrounded by checks for permission/settings windows. Failure aborts the take and writes a failed manifest. The exporter refuses failed takes.",
+ "ko": "동작 전후에 권한·설정 창을 검사해요. 실패하면 촬영을 중단하고 실패 메타데이터를 남겨요. 내보내기는 실패본을 거부해요.",
+ "ja": "操作の前後で権限・設定の窓を確認し、失敗なら録画を中断して記録します。失敗したものは書き出せません。",
+ "zh-Hans": "每步前后检查权限和设置窗口,失败时中止录制并写入失败记录,导出器拒绝失败片。",
+ "zh-Hant": "每步前後檢查權限與設定視窗,失敗時中止錄製並寫入失敗記錄,輸出器拒絕失敗片。"
+ },
+ "d90a31d7b5657632": {
+ "en": "Narration",
+ "ko": "설명과 자막",
+ "ja": "説明と字幕",
+ "zh-Hans": "说明与字幕",
+ "zh-Hant": "說明與字幕"
+ },
+ "0dab93909ff6135e": {
+ "en": "{0} and {1} carry {2}; a caption can use {3}. Empty text clears a track. {4} clears all narration. {5} and {6} create their own keycasts. A standalone {7} label remains available for manual rehearsals but is forbidden in publication scenes, because it would imply a keyboard action that did not occur.",
+ "ko": "{0}, {1}은 {2}를 담고 자막에는 {3}을 쓸 수 있어요. 빈 문자열은 해당 줄을 지우고 {4}는 모든 설명을 지워요. {5}, {6}은 실제 키 표시를 만들어요. 별도 {7}은 수동 연습에만 허용해요. 공개 영상에 쓰면 없던 키 입력처럼 보일 수 있어요.",
+ "ja": "{0}、{1} は {2} を持ち、字幕には {3} を使えます。空文字はそのトラック、{4} は全説明を消します。{5}、{6} は実際のキー表示を作ります。独立した {7} は手動練習専用で、公開では未実行の入力に見えるため禁止します。",
+ "zh-Hans": "{0}、{1} 携带 {2},字幕可使用 {3}。空文本清除当前轨道,{4} 清除全部说明;{5}、{6} 自动生成按键显示。独立 {7} 标签只允许手动排练,发布场景禁用,以免暗示未发生的输入。",
+ "zh-Hant": "{0}、{1} 攜帶 {2},字幕可使用 {3}。空文字清除目前軌道,{4} 清除全部說明;{5}、{6} 自動產生按鍵顯示。獨立 {7} 標籤只允許手動排練,發布場景停用,以免暗示未發生的輸入。"
+ },
+ "0c00cfd4c9e6de21": {
+ "en": "Use [publication.json]({0}) for placement and duration/size budgets, and [README.md]({1}) for capture, export and review commands.",
+ "ko": "배치 위치와 시간·용량 예산은 [publication.json]({0}), 촬영·출력·검수 명령은 [README.md]({1})를 참고하세요.",
+ "ja": "配置と時間・容量上限は [publication.json]({0})、録画・出力・確認のコマンドは [README.md]({1}) を参照してください。",
+ "zh-Hans": "位置和时间、容量预算见 [publication.json]({0});录制、导出和检查命令见 [README.md]({1})。",
+ "zh-Hant": "位置與時間、容量預算見 [publication.json]({0});錄製、輸出與檢查指令見 [README.md]({1})。"
+ },
+ "4eaf3b594f672c63": {
+ "en": "Recording in a Tart VM",
+ "ko": "Tart VM에서 녹화하기",
+ "ja": "Tart VM で録画する",
+ "zh-Hans": "在 Tart VM 中录制",
+ "zh-Hant": "在 Tart VM 中錄製"
+ },
+ "5353f376dc959eb1": {
+ "en": "The dedicated {0} guest isolates personal windows, preferences and permissions from the shoot. Current captures use macOS 26.6.2, a fixed 1920×1200 primary display, the installed Tatami app, and eight fixture apps. A guest-side virtual display provides a second real screen; see [MULTI-DISPLAY.md]({1}).",
+ "ko": "전용 {0}로 개인 창·환경설정·권한을 촬영과 분리해요. macOS 26.6.2, 고정 1920×1200 주 화면, 실제 Tatami와 8개 데모 앱을 써요. 두 번째 화면은 [MULTI-DISPLAY.md]({1})처럼 게스트 안에서 만들어요.",
+ "ja": "専用の {0} で個人の窓・設定・権限を分離します。macOS 26.6.2、固定 1920×1200、実際の Tatami と 8 アプリを使います。二画面目は [MULTI-DISPLAY.md]({1}) の方法です。",
+ "zh-Hans": "专用 {0} 隔离个人窗口、偏好和权限。当前使用 macOS 26.6.2、固定 1920×1200 主屏、实际 Tatami 和八个演示应用;第二屏来自 VM 内虚拟显示器,见 [MULTI-DISPLAY.md]({1})。",
+ "zh-Hant": "專用 {0} 隔離個人視窗、偏好與權限。目前使用 macOS 26.6.2、固定 1920×1200 主螢幕、實際 Tatami 和八個示範 App;第二屏來自 VM 內虛擬顯示器,見 [MULTI-DISPLAY.md]({1})。"
+ },
+ "b82d7fb0c4d8acf5": {
+ "en": "Prepare the guest",
+ "ko": "게스트 준비",
+ "ja": "ゲストの準備",
+ "zh-Hans": "准备虚拟机",
+ "zh-Hant": "準備虛擬機"
+ },
+ "c76ab61cb35deef6": {
+ "en": "Bootstrap fixes the display at {0} with {1}, so moving the host's VM window cannot change the recording canvas. The lab share mounts at {2}.",
+ "ko": "{1}과 함께 {0}으로 화면을 고정해 호스트 창 크기를 바꿔도 녹화 해상도가 바뀌지 않아요. 공유 폴더는 {2}에 연결돼요.",
+ "ja": "{0} と {1} で固定し、ホストの窓サイズを変えても録画の解像度が変わらないようにします。共有は {2} です。",
+ "zh-Hans": "通过 {0} 和 {1} 固定分辨率,调整主机 VM 窗口不改变录制画布,共享挂载于 {2}。",
+ "zh-Hant": "透過 {0} 與 {1} 固定解析度,調整主機 VM 視窗不改變錄製畫布,共用掛載於 {2}。"
+ },
+ "8d52a8dfaeaf7dc6": {
+ "en": "Provisioning accepts {0} instead of {1} as well. SwiftPM builds on the guest's local disk, not the shared filesystem. App bundling registers every fixture with LaunchServices so Tatami can resolve assigned bundle IDs.",
+ "ko": "{1} 대신 {0}도 사용할 수 있어요. SwiftPM은 공유 폴더가 아닌 게스트 디스크에서 빌드해요. 번들 생성 시 모든 앱을 LaunchServices에 등록해 배정한 ID를 찾을 수 있게 해요.",
+ "ja": "{1} の代わりに {0} も使えます。SwiftPM はゲストのローカルでビルドし、全アプリを LaunchServices に登録して ID で探せるようにします。",
+ "zh-Hans": "也接受 {0} 代替 {1}。SwiftPM 在虚拟机本地磁盘构建,打包时把全部演示应用注册到 LaunchServices,以便按包 ID 解析。",
+ "zh-Hant": "也接受 {0} 取代 {1}。SwiftPM 在虛擬機本機磁碟建置,打包時把全部示範 App 註冊到 LaunchServices,以便依套件 ID 解析。"
+ },
+ "27769f7349eb6e8c": {
+ "en": "Open the guest through its VM window or Screen Sharing. Grant missing access through System Settings, following [PERMISSIONS.md]({0}). Test both the recorder and Tatami's Always on Top path. The current agent launch path has capture/input access; a new image or launcher must be checked separately. Do not edit TCC database rows or treat a recorder-only warm-up as proof of Tatami's mirroring permission.",
+ "ko": "VM 창이나 화면 공유로 게스트를 열고 [PERMISSIONS.md]({0})에 따라 시스템 설정에서 권한을 주세요. 녹화기와 항상 위 기능을 모두 검사해야 해요. 현재 에이전트는 입력·캡처 권한이 있지만 새 이미지·실행기는 따로 확인해요. TCC DB를 고치거나 녹화기 준비만으로 미러 권한을 판단하지 마세요.",
+ "ja": "VM の窓や画面共有で開き、[PERMISSIONS.md]({0}) に従って許可します。録画ツールと手前表示の両方を試します。今のエージェントには許可がありますが、新しい環境は別途確認します。DB を変えたり、録画だけでミラー権限を判断したりしません。",
+ "zh-Hans": "通过 VM 窗口或屏幕共享打开,按 [PERMISSIONS.md]({0}) 在系统设置中授权,同时测试录制器与置顶。当前代理路径有捕获和输入权限,但新镜像或启动器需另查。不要修改 TCC 行,也不能仅凭录制器预热判断镜像权限。",
+ "zh-Hant": "透過 VM 視窗或螢幕共享開啟,依 [PERMISSIONS.md]({0}) 在系統設定中授權,同時測試錄製器與置頂。目前代理路徑有擷取與輸入權限,但新映像或啟動器需另查。不要修改 TCC 列,也不能僅憑錄製器預備判斷鏡像權限。"
+ },
+ "57c60cbfa7ddf134": {
+ "en": "Record an explicit batch",
+ "ko": "지정한 배치 촬영",
+ "ja": "指定したバッチを録画する",
+ "zh-Hans": "录制明确的批次",
+ "zh-Hant": "錄製明確的批次"
+ },
+ "066f7720670ecd5c": {
+ "en": "Use {0} to rehearse a subset. Capture never overwrites an existing take. {1} preserves failures, cleans between scenes, and exits nonzero if any scene failed. {2} names every result. Only passed takes matching the current scene bytes can enter the exporter.",
+ "ko": "일부 장면은 {0}으로 고르세요. 기존 테이크를 덮어쓰지 않아요. {1}은 실패본을 남기고 장면 사이를 초기화하며 하나라도 실패하면 0이 아닌 코드로 끝나요. {2}에 결과를 기록하고 현재 장면과 맞는 성공본만 내보내요.",
+ "ja": "一部だけなら {0} を使います。上書きせず、{1} は失敗を残して場面ごとに初期化し、失敗があれば非ゼロで終わります。{2} に記録し、現在の場面に合う合格テイクだけを書き出します。",
+ "zh-Hans": "用 {0} 排练子集,不覆盖已有录制。{1} 保留失败、在场景间清理,任一失败即返回非零码。{2} 记录各项结果,只导出匹配当前场景的通过片段。",
+ "zh-Hant": "用 {0} 排練子集,不覆蓋既有錄製。{1} 保留失敗、在場景間清理,任一失敗即回傳非零代碼。{2} 記錄各項結果,只輸出符合目前場景的通過片段。"
+ },
+ "0019944455d7f7e7": {
+ "en": "Avoid heavy host encoding or compilation while recording. The VM and host share CPU resources, and dropped frames are measured per display. Encode after the capture finishes.",
+ "ko": "녹화 중에는 호스트의 무거운 인코딩·컴파일을 피하세요. VM과 CPU를 나눠 쓰며 손실은 화면마다 측정해요. 촬영이 끝난 뒤 인코딩하세요.",
+ "ja": "録画中はホストの重いエンコード・コンパイルを避けます。CPU を共有し損失は画面別に測るため、録画後に処理します。",
+ "zh-Hans": "录制时避免主机进行繁重编码或编译。VM 与主机共享 CPU,丢帧逐显示器计量,应在录制结束后编码。",
+ "zh-Hant": "錄製時避免主機進行繁重編碼或編譯。VM 與主機共用 CPU,遺失影格逐顯示器計量,應在錄製結束後編碼。"
+ },
+ "e6ed03fc8feceec7": {
+ "en": "Shared-filesystem correctness",
+ "ko": "공유 파일의 정확성",
+ "ja": "共有ファイルの正確性",
+ "zh-Hans": "共享文件系统正确性",
+ "zh-Hant": "共用檔案系統正確性"
+ },
+ "b38a460d60a6ed02": {
+ "en": "Both directions use a uniquely named archive. Rewriting the same file through virtiofs was observed to expose stale bytes or a stale inode after the host moved an earlier take. A new archive name removes that reuse path.",
+ "ko": "양방향 모두 고유한 이름의 아카이브를 써요. 호스트에서 옛 파일을 옮긴 뒤 같은 이름에 쓰면 virtiofs가 오래된 내용·inode를 보인 적이 있어요. 새 이름으로 이 재사용 경로를 없앴어요.",
+ "ja": "両方向で新しい名前のアーカイブを使います。ホストが前のファイルを移した後、同名への書き込みが古い内容や inode を参照しました。新規名でこの経路をなくします。",
+ "zh-Hans": "两向传输都使用唯一归档名。实测主机移动旧文件后,virtiofs 重写同名文件可能暴露旧字节或旧 inode;新名称消除了该复用路径。",
+ "zh-Hant": "雙向傳輸都使用唯一封存檔名稱。實測主機移動舊檔後,virtiofs 重寫同名檔案可能顯示舊位元組或舊 inode;新名稱消除了該重複使用路徑。"
+ },
+ "2473284c874518ca": {
+ "en": "Source sync mirrors only managed source directories, so deleted Swift files cannot survive in the guest. It preserves {0} and recordings. Fetch proves the share identity with a unique marker, compares guest/host archive SHA256, and extracts into a new local destination. It includes movies, captions, timeline, take metadata, frozen scene JSON and the batch report.",
+ "ko": "관리하는 소스 폴더만 동기화해 삭제한 Swift 파일이 게스트에 남지 않게 해요. {0}과 녹화본은 유지해요. 고유 마커로 공유 위치를 확인하고 양쪽 SHA256을 비교한 뒤 새 로컬 폴더에 풀어요. 영상·자막·시간·촬영 정보·장면 JSON·결과 보고서를 함께 가져와요.",
+ "ja": "管理対象だけを同期し、削除した Swift が残らないようにします。{0} と録画は保持します。固有マーカーで共有を確認し、両側の SHA256 を照合して新規フォルダへ展開します。動画と全付属データを含みます。",
+ "zh-Hans": "只同步受管源码目录,删除的 Swift 文件不会留在 VM,保留 {0} 与录像。通过唯一标记确认共享位置,比较两端 SHA256,再解压到新目录。包含影片、字幕、时间线、元数据、冻结场景与批次报告。",
+ "zh-Hant": "只同步受管原始碼目錄,刪除的 Swift 檔案不會留在 VM,保留 {0} 與錄影。透過唯一標記確認共用位置,比較兩端 SHA256,再解壓到新目錄。包含影片、字幕、時間軸、中繼資料、凍結場景與批次報告。"
+ },
+ "623db34162f5bf0a": {
+ "en": "Finish or preserve the image",
+ "ko": "마치기와 이미지 보관",
+ "ja": "終了とイメージの保存",
+ "zh-Hans": "结束或保存镜像",
+ "zh-Hant": "結束或保存映像"
+ },
+ "53038cf967d68773": {
+ "en": "{0} returns the preferences domain backed up before the recording session. Original personal-host Tatami preferences are not involved. A stopped, verified guest can be preserved with {1}; keep originals and evidence outside the VM before retiring any recording image.",
+ "ko": "{0}은 촬영 전에 백업한 환경설정을 복원해요. 개인 호스트 설정은 건드리지 않아요. 종료하고 검증한 게스트는 {1}으로 보관할 수 있어요. VM을 정리하기 전에 원본과 증거를 밖에 보관하세요.",
+ "ja": "{0} は撮影前の設定を戻し、個人ホストには触れません。停止して確認したゲストは {1} で保存できます。廃棄前に原本と証拠を外へ保管してください。",
+ "zh-Hans": "{0} 恢复录制前备份的偏好,不涉及个人主机设置。关闭并验证的 VM 可用 {1} 保存,淘汰镜像前请先把原始文件和证据存到外部。",
+ "zh-Hant": "{0} 還原錄製前備份的偏好,不涉及個人主機設定。關閉並驗證的 VM 可用 {1} 保存,淘汰映像前請先把原始檔案與證據存到外部。"
+ },
+ "6a922078d2573c8d": {
+ "en": "Verified boundary",
+ "ko": "검증한 범위",
+ "ja": "検証した範囲",
+ "zh-Hans": "已验证边界",
+ "zh-Hant": "已驗證界線"
+ },
+ "ba580cd794f199a9": {
+ "en": "The v4 batch proves real ScreenCaptureKit capture, actual input/AX readback, Tatami tiling/Borrow, two independently captured guest displays, workspace chains, and profile activation on display connect/disconnect. Hardware docks, physical gesture recognition, mixed-DPI/HDR monitors and new guest OS versions need their own checks. Native macOS fullscreen restoration has a separate failed reproduction documented in [COVERAGE.md]({0}).",
+ "ko": "v4에서는 실제 ScreenCaptureKit, 입력·AX 확인, 타일링·빌려오기, 두 화면 개별 캡처, 체인, 연결·해제 자동 프로필을 검증했어요. 독·물리 제스처·혼합 DPI·HDR·새 OS는 별도 검사예요. macOS 전체화면 복귀 실패는 [COVERAGE.md]({0})에 남겨뒀어요.",
+ "ja": "v4 では実際の収録、入力・AX、タイルと借りる操作、二画面、チェーン、自動プロファイルを確認しました。ドック、実ジェスチャ、混在 DPI、HDR、新 OS は別検証です。全画面復帰の失敗は [COVERAGE.md]({0}) にあります。",
+ "zh-Hans": "v4 验证了实际 ScreenCaptureKit、输入与 AX 回读、平铺和借用、独立双屏捕获、工作区链及连接断开自动方案。实体扩展坞、手势、混合 DPI/HDR 和新系统需另查;原生全屏恢复失败见 [COVERAGE.md]({0})。",
+ "zh-Hant": "v4 驗證了實際 ScreenCaptureKit、輸入與 AX 讀回、並排和借用、獨立雙螢幕擷取、工作空間鏈及連接中斷自動組合。實體擴充座、手勢、混合 DPI/HDR 和新系統需另查;原生全螢幕還原失敗見 [COVERAGE.md]({0})。"
+ },
+ "65659145569b894b": {
+ "en": "Notice",
+ "ko": "프로젝트 고지",
+ "ja": "プロジェクトの告知",
+ "zh-Hans": "项目声明",
+ "zh-Hant": "專案聲明"
+ },
+ "b40bd55a32935bde": {
+ "en": "Tatami is an independent project, not a fork. Its design draws on prior work:",
+ "ko": "Tatami는 포크가 아닌 독립 프로젝트예요. 다음 선행 작업을 참고했어요.",
+ "ja": "Tatami はフォークではない独立プロジェクトです。次の先行成果を参考にしています。",
+ "zh-Hans": "Tatami 是独立项目,并非分支,其设计参考了以下工作:",
+ "zh-Hant": "Tatami 是獨立專案,並非分支,其設計參考了以下成果:"
+ },
+ "b23ecc7ed0665cae": {
+ "en": "The virtual workspace-switching concept is inspired by [FlashSpace] by Wojciech Kulik (GPL-3.0). Tatami reimplements comparable behavior on its own stack rather than copying FlashSpace source code.",
+ "ko": "가상 작업 공간 전환 개념은 GPL-3.0으로 제공되는 Wojciech Kulik의 [FlashSpace]에서 영감을 받았어요. 소스를 복사하지 않고 Tatami의 구조에서 동작을 다시 구현했어요.",
+ "ja": "仮想ワークスペースの切り替えは Wojciech Kulik の [FlashSpace](GPL-3.0)に着想を得ました。ソースを複製せず、独自の構成で実装しています。",
+ "zh-Hans": "虚拟工作区切换概念受到 Wojciech Kulik 的 [FlashSpace](GPL-3.0)启发。Tatami 未复制其源码,而是在自身架构中重新实现相似行为。",
+ "zh-Hant": "虛擬工作空間切換概念受到 Wojciech Kulik 的 [FlashSpace](GPL-3.0)啟發。Tatami 未拷貝其原始碼,而是在自身架構中重新實作相似行為。"
+ },
+ "5ab64e59408172c1": {
+ "en": "The window-tiling model and its BSP operations (warp, balance, rotate, mirror, fence-based resize) are adapted from [yabai] by koekeishiya, which is MIT-licensed. The relevant copyright notice is reproduced below.",
+ "ko": "창 타일링 모델과 이동·균등 배치·회전·뒤집기·경계 기반 크기 조절은 MIT 라이선스인 koekeishiya의 [yabai]를 바탕으로 했어요. 해당 저작권 고지를 아래에 원문으로 실었어요.",
+ "ja": "タイルのモデルと移動、均等化、回転、反転、境界によるサイズ変更は、MIT の koekeishiya による [yabai] を基にしています。該当する著作権表示を以下に原文で掲載します。",
+ "zh-Hans": "窗口平铺模型及 BSP 的移动、均衡、旋转、镜像、边界调整操作改编自 koekeishiya 的 MIT 许可项目 [yabai]。相关版权声明原文列于下方。",
+ "zh-Hant": "視窗並排模型及 BSP 的移動、平均排列、旋轉、鏡像、邊界調整操作改編自 koekeishiya 的 MIT 授權專案 [yabai]。相關著作權聲明原文列於下方。"
+ },
+ "7588c2b0cdc51964": {
+ "en": "Tatami is licensed under [AGPL-3.0-only]({0}). It is not affiliated with, endorsed by, or otherwise connected to FlashSpace, yabai, or their authors.",
+ "ko": "Tatami는 [AGPL-3.0-only]({0})로 제공해요. FlashSpace, yabai 또는 저자와 제휴·보증 등의 관계는 없어요.",
+ "ja": "Tatami は [AGPL-3.0-only]({0}) で提供します。FlashSpace、yabai、その作者との提携や承認などの関係はありません。",
+ "zh-Hans": "Tatami 采用 [AGPL-3.0-only]({0}) 许可证,与 FlashSpace、yabai 及其作者不存在关联、背书或其他联系。",
+ "zh-Hant": "Tatami 採用 [AGPL-3.0-only]({0}) 授權,與 FlashSpace、yabai 及其作者不存在關聯、背書或其他聯繫。"
+ },
+ "b24dc25b5e6e4b2b": {
+ "en": "The licenses and copyright notices for Tatami's package dependencies are reproduced in [THIRD_PARTY_NOTICES.md]({0}).",
+ "ko": "패키지 의존성의 라이선스와 저작권 원문은 [THIRD_PARTY_NOTICES.md]({0})에 실었어요.",
+ "ja": "依存パッケージのライセンスと著作権の原文は [THIRD_PARTY_NOTICES.md]({0}) に掲載しています。",
+ "zh-Hans": "依赖包的许可证与版权声明原文见 [THIRD_PARTY_NOTICES.md]({0})。",
+ "zh-Hant": "相依套件的授權與著作權聲明原文見 [THIRD_PARTY_NOTICES.md]({0})。"
+ },
+ "0c53c862d5cbbaeb": {
+ "en": "yabai (MIT License)",
+ "ko": "yabai(MIT 라이선스)",
+ "ja": "yabai(MIT ライセンス)",
+ "zh-Hans": "yabai(MIT 许可证)",
+ "zh-Hant": "yabai(MIT 授權條款)"
+ },
+ "8fadbc8de2733658": {
+ "en": "Third-party notices",
+ "ko": "외부 라이브러리 고지",
+ "ja": "外部ライブラリの告知",
+ "zh-Hans": "第三方声明",
+ "zh-Hant": "第三方聲明"
+ },
+ "2c959ad8d949c5b1": {
+ "en": "The export host also needs Fontconfig. Before encoding, the lab verifies the requested font family and coverage of every caption character. The OCR audit separately compares rendered footer text with the narration timeline.",
+ "ko": "내보내기 호스트에는 Fontconfig도 필요해요. 인코딩 전에 글꼴 이름이 실제로 해석되는지, 모든 자막 문자를 지원하는지 확인해요. OCR은 렌더링한 하단 문구를 설명 타임라인과 별도로 비교해요.",
+ "ja": "書き出しホストには Fontconfig も必要です。処理前に指定書体と全文字の対応を確認し、OCR では描画された下部の文字を説明タイムラインと照合します。",
+ "zh-Hans": "导出主机还需 Fontconfig。编码前检查指定字体是否实际存在,并覆盖全部字幕字符;OCR 另外比较渲染后的底部文本与说明时间线。",
+ "zh-Hant": "輸出主機還需 Fontconfig。編碼前檢查指定字型是否實際存在,並涵蓋全部字幕字元;OCR 另外比較渲染後的底部文字與說明時間軸。"
+ },
+ "6170c5ceb07c357e": {
+ "en": "Resize and move the saved window with real pointer drags, then verify its original frame.",
+ "ko": "실제 드래그로 저장한 창의 크기와 위치를 되돌린 뒤 원래 영역인지 확인해요.",
+ "ja": "実際のドラッグで保存した大きさと位置へ戻し、元の枠を確認します。",
+ "zh-Hans": "通过实际指针拖移恢复窗口大小和位置,再验证原始边界。",
+ "zh-Hant": "透過實際游標拖移還原視窗大小與位置,再驗證原始邊界。"
+ },
+ "fb461e66ef7990e2": {
+ "en": "Original MOV files contain the full captured desktop. Exports keep that image at 1920×1200, or 1920×600 for two screens. Narration appears over the lower part of the image, with a chapter label at the upper left and actual keystrokes at the upper right. Translucent backgrounds keep text readable over light and dark apps. The colors come from the website palette; changing presentation needs no new capture.",
+ "context": "DemoLab/README.md / prose",
+ "ko": "원본 MOV에는 촬영한 데스크톱 전체가 담겨요. 내보낼 때는 1920×1200을 유지하고, 두 화면 영상은 1920×600으로 만들어요. 자막은 영상 아래쪽에, 장면 이름은 왼쪽 위에, 실제 키 입력은 오른쪽 위에 겹쳐 표시해요. 반투명 배경으로 밝거나 어두운 앱 위에서도 읽기 쉽게 했어요. 색상은 웹사이트 팔레트를 사용하므로 표현을 바꿀 때 다시 촬영할 필요는 없어요.",
+ "ja": "元の MOV には、撮影したデスクトップ全体が含まれます。書き出しは 1920×1200、2 画面の場合は 1920×600 です。説明字幕は映像の下部、章名は左上、実際のキー入力は右上に重ねます。半透明の背景により、明るいアプリでも暗いアプリでも読みやすくなります。色はウェブサイトの配色を使うため、表示の変更に再撮影は必要ありません。",
+ "zh-Hans": "原始 MOV 包含完整的桌面画面。导出保留 1920×1200 分辨率,双屏视频为 1920×600。说明字幕叠加在画面下方,章节名称位于左上角,实际按键位于右上角。半透明背景确保文字在深浅色应用上都清晰可读。颜色来自网站配色,调整呈现方式无需重新录制。",
+ "zh-Hant": "原始 MOV 包含完整的桌面畫面。匯出保留 1920×1200 解析度,雙螢幕影片為 1920×600。說明字幕疊加在畫面下方,章節名稱位於左上角,實際按鍵位於右上角。半透明背景確保文字在深淺色 App 上都清楚可讀。顏色來自網站配色,調整呈現方式不必重新錄製。"
+ },
+ "ce234b3b7e29f58c": {
+ "en": "Caption edits may reuse an original only when every recorded action, typed input, assertion and delay is unchanged. The exporter verifies the frozen scene hash and compares those actions before replacing narration on the original timeline. Original and edited scenes and timelines are both included in the evidence.",
+ "context": "DemoLab/README.md / prose",
+ "ko": "자막만 고칠 때는 촬영한 동작, 입력 내용, 검증 조건, 대기 시간이 모두 같아야 원본을 재사용할 수 있어요. 내보내기 도구는 촬영 당시 고정한 장면 파일의 해시와 동작을 검증한 뒤 원래 타임라인의 자막을 교체해요. 증거 자료에는 원본과 수정본의 장면 파일 및 타임라인을 모두 포함해요.",
+ "ja": "字幕の編集で元映像を再利用できるのは、撮影した操作、入力内容、検証条件、待機時間がすべて同じ場合だけです。書き出しツールは撮影時に固定したシーンのハッシュと操作を検証し、元のタイムライン上の説明文を差し替えます。検証資料には、編集前後のシーンとタイムラインを両方含めます。",
+ "zh-Hans": "仅修改字幕时,只有录制的操作、输入内容、断言和等待时间均未改变,才可复用原始视频。导出工具会验证录制时冻结的场景哈希并比较操作,然后替换原时间线上的说明文字。证据资料同时保留原始和编辑后的场景及时间线。",
+ "zh-Hant": "只修改字幕時,必須確保錄製的操作、輸入內容、驗證條件及等待時間皆未變更,才能重用原始影片。匯出工具會驗證錄製時固定的場景雜湊並比較操作,再替換原時間軸上的說明文字。驗證資料同時保留原始及編輯後的場景與時間軸。"
+ },
+ "26c1ad698ff09501": {
+ "en": "A failed take stays available for diagnosis but cannot enter the exporter. Exports require a verified capture contract, clean narration, less than 1% dropped frames, timely opening captions and matching movie/timeline duration. The output must fit its time/size budget, use H.264/yuv420p, and survive a complete FFmpeg decode. {0} places the MP4 header before the media payload.",
+ "context": "DemoLab/README.md / prose",
+ "ko": "실패한 촬영본은 진단용으로 보관하지만 내보내기에는 사용할 수 없어요. 촬영 조건 검증, 정상 자막, 1% 미만의 프레임 손실, 제때 나오는 시작 자막, 영상과 타임라인 길이 일치가 필요해요. 결과물은 시간·용량 제한을 지키고 H.264/yuv420p를 사용하며 FFmpeg 전체 디코딩을 통과해야 해요. {0}는 MP4 헤더를 영상 데이터 앞에 배치해요.",
+ "ja": "失敗したテイクは診断用に残しますが、書き出しには使えません。撮影条件の検証、正常な字幕、1% 未満のフレーム欠落、遅れのない冒頭字幕、映像とタイムラインの長さの一致が必要です。出力は時間・容量の上限を守り、H.264/yuv420p を使用し、FFmpeg で全編をデコードできる必要があります。{0} は MP4 ヘッダーを映像データより前に配置します。",
+ "zh-Hans": "失败的录制会保留用于诊断,但不能导出。导出要求拍摄条件验证通过、字幕正常、丢帧率低于 1%、开场字幕及时出现,并且视频与时间线时长一致。输出必须符合时长和大小限制,使用 H.264/yuv420p,并通过 FFmpeg 完整解码。{0} 会将 MP4 文件头放在媒体数据之前。",
+ "zh-Hant": "失敗的錄製會保留供診斷使用,但不能匯出。匯出要求拍攝條件驗證通過、字幕正常、掉幀率低於 1%、開場字幕及時出現,且影片與時間軸長度一致。輸出必須符合長度及容量限制,使用 H.264/yuv420p,並通過 FFmpeg 完整解碼。{0} 會將 MP4 標頭放在媒體資料之前。"
+ },
+ "22fbbd9ad192a552": {
+ "en": "The export bundle includes a playback gallery, posters, sampled evidence frames, source/delivery hashes and all sidecars. Automated acceptance does not replace watching the action: in particular, shared-window mirroring and focus targets still need visible verification. Website players use native controls and {0}; playback is deliberate and only one video plays at a time. Every collection exposes its thumbnail playlist, count and previous/next buttons. Each film links to its related configuration keys. Arrow keys, Home/End and per-film links work without opening a disclosure.",
+ "context": "DemoLab/README.md / prose",
+ "ko": "내보내기 묶음에는 재생 갤러리, 포스터, 추출한 검증 프레임, 원본·결과물 해시와 모든 부가 파일이 들어 있어요. 자동 검증이 실제 동작 확인을 대신하지는 않아요. 특히 공용 창 미러링과 포커스 대상은 화면으로 확인해야 해요. 웹 플레이어는 기본 컨트롤과 {0}를 사용하며, 직접 재생하고 한 번에 영상 하나만 재생돼요. 각 모음에는 썸네일 목록, 개수, 이전·다음 버튼이 있어요. 영상마다 관련 설정 키로 가는 링크도 제공해요. 방향키, Home/End, 개별 영상 링크는 별도 펼치기 없이 사용할 수 있어요.",
+ "ja": "書き出しには再生ギャラリー、ポスター、検証用フレーム、元映像と出力のハッシュ、すべての付随ファイルが含まれます。自動検証だけでは実際の動作確認を代替できません。特に共有ウインドウのミラーリングとフォーカス先は目視確認が必要です。ウェブプレイヤーは標準コントロールと {0} を使い、手動で再生し、同時に再生する動画は一つです。各動画集にはサムネイル一覧、件数、前後のボタンがあります。各動画から関連する設定キーにも移動できます。矢印キー、Home/End、個別動画へのリンクは、一覧を別途展開せずに使えます。",
+ "zh-Hans": "导出包包含播放图库、海报、抽样验证帧、原始与输出文件的哈希及所有附属文件。自动验收不能替代观看实际操作,尤其是共享窗口镜像和焦点目标仍需目视确认。网页播放器使用原生控件和 {0},由用户主动播放,且一次只播放一个视频。每个视频集提供缩略图列表、数量和前后按钮。每个视频还链接到相关配置键。方向键、Home/End 和单独视频链接无需展开额外面板即可使用。",
+ "zh-Hant": "匯出套件包含播放圖庫、海報、抽樣驗證畫格、原始與輸出檔案的雜湊,以及所有附屬檔案。自動驗收不能取代觀看實際操作,尤其是共用視窗鏡像及焦點目標仍須目視確認。網頁播放器使用原生控制項和 {0},由使用者主動播放,且一次只播放一部影片。每個影片集提供縮圖清單、數量及前後按鈕。每部影片也連結至相關設定鍵。方向鍵、Home/End 及個別影片連結,不必展開額外面板即可使用。"
+ },
+ "7343fd55cf6f97fc": {
+ "en": "The hotplug scene records the main screen continuously. Once the second virtual display is connected, a separate recorder captures it until just before removal. Both streams use their actual first-frame clocks. The compositor places them side by side and leaves the second region blank outside its captured interval. It never duplicates the main screen or keeps a frozen screen after disconnection. The capture manifest retains both source hashes and the secondary interval.",
+ "context": "DemoLab/docs/MULTI-DISPLAY.md / prose",
+ "ko": "화면 연결 데모는 주 화면을 계속 녹화해요. 두 번째 가상 화면이 연결되면 별도 녹화기가 해당 화면을 녹화하고, 연결 해제 직전에 종료해요. 두 영상은 각각의 실제 첫 프레임 시각으로 맞춰요. 합성할 때 나란히 배치하고 보조 화면을 촬영하지 않은 구간은 비워둬요. 주 화면을 복제하거나 연결 해제 뒤 정지 화면을 남기지 않아요. 촬영 명세에는 두 원본의 해시와 보조 화면 촬영 구간을 보관해요.",
+ "ja": "画面接続のシーンでは、メイン画面を連続して録画します。2 台目の仮想ディスプレイが接続されると、別のレコーダーがその画面を録画し、切断の直前に停止します。両映像は実際の最初のフレーム時刻で同期します。合成時は左右に並べ、2 台目を録画していない区間は空白にします。メイン画面の複製や、切断後の静止画表示は行いません。撮影マニフェストには両方の元映像のハッシュと、2 台目の録画区間を保存します。",
+ "zh-Hans": "显示器连接场景会持续录制主屏幕。第二个虚拟显示器连接后,由独立录制器拍摄该屏幕,并在移除前停止。两路视频按各自实际的首帧时间同步。合成器将它们并排显示,未录制副屏的时间段留空。它不会复制主屏,也不会在断开后保留冻结画面。录制清单保留两个源文件的哈希及副屏录制区间。",
+ "zh-Hant": "顯示器連接場景會持續錄製主螢幕。第二個虛擬顯示器連接後,由獨立錄製器拍攝該螢幕,並在移除前停止。兩路影片依各自實際的首幀時間同步。合成器將它們並排顯示,未錄製副螢幕的時段留白。它不會複製主螢幕,也不會在中斷連接後保留凍結畫面。錄製清單保留兩個來源檔案的雜湊及副螢幕錄製區間。"
+ },
+ "5edeb5d972918c02": {
+ "en": "Open the native context menu for a control.",
+ "context": "DemoLab/docs/SCENES.md / table",
+ "ko": "컨트롤의 기본 컨텍스트 메뉴를 열어요.",
+ "ja": "コントロールの標準コンテキストメニューを開きます。",
+ "zh-Hans": "打开控件的原生上下文菜单。",
+ "zh-Hant": "開啟控制項的原生快顯選單。"
+ },
+ "c1c19eb9ec096618": {
+ "en": "Require every source window to be left, right, above or below the target windows.",
+ "context": "DemoLab/docs/SCENES.md / table",
+ "ko": "모든 대상 앱 창이 비교할 창들의 왼쪽·오른쪽·위·아래 중 지정한 위치에 있는지 확인해요.",
+ "ja": "対象アプリのすべてのウインドウが、比較先の左・右・上・下の指定位置にあるか確認します。",
+ "zh-Hans": "验证源应用的所有窗口都位于目标窗口的左侧、右侧、上方或下方。",
+ "zh-Hant": "驗證來源 App 的所有視窗都位於目標視窗的左側、右側、上方或下方。"
+ },
+ "d5e21b9784090aad": {
+ "en": "Verify the profile count through the real CLI.",
+ "context": "DemoLab/docs/SCENES.md / table",
+ "ko": "실제 CLI로 프로필 개수를 확인해요.",
+ "ja": "実際の CLI でプロファイル数を確認します。",
+ "zh-Hans": "通过实际 CLI 验证配置方案数量。",
+ "zh-Hant": "透過實際 CLI 驗證設定組合數量。"
+ },
+ "c181e5aaa86247de": {
+ "en": "Verify that a copied app belongs to the target workspace.",
+ "context": "DemoLab/docs/SCENES.md / table",
+ "ko": "복사한 앱이 대상 작업 공간에 포함됐는지 확인해요.",
+ "ja": "コピーしたアプリが対象ワークスペースに含まれるか確認します。",
+ "zh-Hans": "验证复制的应用已分配给目标工作区。",
+ "zh-Hant": "驗證複製的 App 已指派給目標工作空間。"
+ },
+ "34131e57c530f9f9": {
+ "en": "Grow or shrink the focused tile at its parent split, following either left/right or top/bottom orientation",
+ "context": "docs/CONFIGURATION.md / table",
+ "ko": "선택한 창의 상위 분할 방향에 따라 좌우 또는 위아래로 영역을 늘이거나 줄임",
+ "ja": "選択中のウインドウを含む分割の向きに合わせ、左右または上下に領域を拡大・縮小",
+ "zh-Hans": "根据父级分割的左右或上下方向,扩大或缩小当前选中窗口的区域",
+ "zh-Hant": "依上層分割的左右或上下方向,擴大或縮小目前選取視窗的區域"
+ },
+ "a8bb97c81a56aba1": {
+ "en": "The export host also needs Fontconfig. Before encoding, the lab verifies the requested font family and coverage of every caption character. The OCR audit separately compares overlaid narration with the narration timeline.",
+ "context": "DemoLab/README.md / prose",
+ "ko": "내보내기를 실행할 Mac에는 Fontconfig도 필요해요. 인코딩 전에 지정한 글꼴과 모든 자막 글자 지원 여부를 확인해요. OCR 검증은 영상 위에 표시된 자막을 타임라인과 별도로 비교해요.",
+ "ja": "書き出し側の Mac には Fontconfig も必要です。エンコード前に指定したフォントと、すべての字幕文字が表示できるかを確認します。OCR 検証では、映像に重ねた字幕をタイムラインと照合します。",
+ "zh-Hans": "导出主机还需要 Fontconfig。编码前会验证指定字体及其对全部字幕字符的支持。OCR 审核会另行比较画面上的字幕和说明时间线。",
+ "zh-Hant": "匯出主機也需要 Fontconfig。編碼前會驗證指定字型及其對全部字幕字元的支援。OCR 檢查會另外比對畫面上的字幕與說明時間軸。"
+ },
+ "8cdff6c996115030": {
+ "en": "{0} identifies missing or rejected takes for each locale. It never treats an English capture as proof of a translated UI. A failed scene stays available for diagnosis; another language can continue independently.",
+ "context": "DemoLab/README.md / prose",
+ "ko": "{0}는 언어별로 없거나 검증에 실패한 촬영본을 찾아요. 영어 촬영본으로 번역된 UI를 검증했다고 간주하지 않아요. 실패한 장면은 진단용으로 보관하고 다른 언어는 독립적으로 진행할 수 있어요.",
+ "ja": "{0} は言語ごとに、未撮影または検証に失敗したテイクを見つけます。英語の映像を翻訳済み UI の検証とは見なしません。失敗したシーンは診断用に残し、ほかの言語は独立して進められます。",
+ "zh-Hans": "{0} 会找出各语言缺失或未通过验证的录制。它不会将英语视频视为已验证翻译 UI 的证据。失败场景会保留用于诊断,其他语言可以独立继续。",
+ "zh-Hant": "{0} 會找出各語言缺少或未通過驗證的錄製。它不會將英文影片視為已驗證翻譯 UI 的證據。失敗場景會保留供診斷使用,其他語言可以獨立繼續。"
+ },
+ "2f6ed82aaefe3123": {
+ "en": "The publication inventory contains 27 scenarios: a complete workflow and 26 focused demonstrations. Every supported locale has its own app language, fixture content, typed input, captions and capture assertions. Accepted takes are selected by locale, recorded actions and capture quality, then exported separately.",
+ "context": "DemoLab/docs/COVERAGE.md / prose",
+ "ko": "공개 목록에는 전체 작업 흐름 1개와 개별 기능 데모 26개, 총 27개 장면이 있어요. 지원 언어마다 앱 UI, 데모 콘텐츠, 입력, 자막, 촬영 검증 조건이 따로 있어요. 언어, 촬영한 동작, 촬영 품질로 검증된 원본을 골라 각각 내보내요.",
+ "ja": "公開一覧は、一連の作業を示す 1 本と個別機能のデモ 26 本、計 27 シーンです。各言語に専用のアプリ表示、デモ内容、入力、字幕、撮影時の検証条件があります。言語、撮影した操作、画質をもとにテイクを選び、個別に書き出します。",
+ "zh-Hans": "发布清单包含 27 个场景:1 个完整工作流程和 26 个功能演示。每种语言都有对应的应用语言、演示内容、输入、字幕和拍摄断言。通过验证的录制按语言、拍摄操作和质量筛选,再分别导出。",
+ "zh-Hant": "發布清單包含 27 個場景:1 個完整工作流程及 26 個功能示範。每種語言都有對應的 App 語言、示範內容、輸入、字幕及拍攝驗證條件。通過驗證的錄製依語言、拍攝操作及品質篩選,再分別匯出。"
+ },
+ "6f83784f9d53f2d3": {
+ "en": "Profile switching, GUI profile duplication, selective workspace copying, two-screen chains, cross-display focus, automatic profile activation on connect/disconnect.",
+ "context": "DemoLab/docs/COVERAGE.md / table",
+ "ko": "프로필 전환, GUI 프로필 복제, 작업 공간 선택 복사, 두 화면 체인, 화면 간 포커스, 연결·해제에 따른 자동 프로필 전환.",
+ "ja": "プロファイル切り替え、GUI での複製、ワークスペースの選択コピー、2 画面のチェーン、画面間フォーカス、接続・切断に応じた自動切り替え。",
+ "zh-Hans": "配置方案切换、GUI 复制配置方案、选择性复制工作区、双屏工作区链、跨屏焦点、连接和断开时自动切换配置方案。",
+ "zh-Hant": "設定組合切換、GUI 複製設定組合、選擇性複製工作空間、雙螢幕工作空間鏈、跨螢幕焦點、連接與中斷時自動切換設定組合。"
+ },
+ "2755ac6b541fa208": {
+ "en": "Insert/close, swap, split orientation, resize/balance, drag, workspace zoom, directional focus, MFF/FFM, held/quick window switcher, native visual layout editor.",
+ "context": "DemoLab/docs/COVERAGE.md / table",
+ "ko": "창 추가·닫기, 위치 교체, 분할 방향, 크기 조절·균등 배치, 드래그, 작업 공간 확대, 방향별 포커스, MFF/FFM, 길게·짧게 누르는 창 전환기, 기본 UI 레이아웃 편집기.",
+ "ja": "ウインドウの追加・終了、入れ替え、分割方向、サイズ調整・均等配置、ドラッグ、拡大、方向指定フォーカス、MFF/FFM、長押し・短押しの切り替え、ネイティブの配置エディタ。",
+ "zh-Hans": "新增和关闭窗口、交换位置、分割方向、调整大小和均衡、拖动、工作区放大、方向焦点、MFF/FFM、长按和轻按窗口切换器、原生可视布局编辑器。",
+ "zh-Hant": "新增與關閉視窗、交換位置、分割方向、調整大小與平衡、拖曳、工作空間放大、方向焦點、MFF/FFM、長按與輕按視窗切換器、原生視覺化排列編輯器。"
+ },
+ "32e7deae9f2f8d0b": {
+ "en": "Every accepted take must pass its real action/state/layout assertions, match the current localized capture actions and timing, and stay below the 1% frame-drop threshold. Text-only revisions preserve the source scene hash and add the edited scene hash. Exports check the whole MP4, H.264/yuv420p encoding, editorial duration/size budgets, first-caption timing and the shared movie/timeline clock.",
+ "context": "DemoLab/docs/COVERAGE.md / prose",
+ "ko": "검증된 촬영본은 실제 동작·상태·배치 검증을 통과하고, 현재 언어별 촬영 동작과 타이밍이 일치하며, 프레임 손실이 1% 미만이어야 해요. 자막만 수정하면 원본 장면 해시를 보관하고 수정본 해시를 추가해요. 내보낼 때 MP4 전체, H.264/yuv420p, 길이·용량 제한, 첫 자막 시점, 영상과 타임라인의 공통 시계를 확인해요.",
+ "ja": "採用するテイクは、実際の操作・状態・配置の検証を通過し、現行の言語別シーンの操作とタイミングに一致し、フレーム欠落率が 1% 未満である必要があります。文面だけの修正では元シーンのハッシュを残し、編集後のハッシュを追加します。書き出し時に MP4 全編、H.264/yuv420p、長さ・容量の上限、冒頭字幕の時刻、映像とタイムラインの共通時計を確認します。",
+ "zh-Hans": "验收的录制必须通过实际操作、状态和布局断言,与当前语言的拍摄操作及时间一致,且丢帧率低于 1%。仅修改文字时会保留原场景哈希并新增编辑后的场景哈希。导出会检查完整 MP4、H.264/yuv420p、时长和大小限制、首条字幕时间,以及视频与时间线的共同时间基准。",
+ "zh-Hant": "驗收的錄製必須通過實際操作、狀態及排列驗證,與目前語言的拍攝操作及時間一致,且掉幀率低於 1%。只修改文字時會保留原場景雜湊,並新增編輯後的場景雜湊。匯出會檢查完整 MP4、H.264/yuv420p、長度及容量限制、首條字幕時間,以及影片與時間軸的共同時間基準。"
+ },
+ "7b77f2fd4c0e4572": {
+ "en": "Host requirements: Swift 6.2+, {0}, {1} with libass/libx264, {2} and {3}. Run host commands from the repository root. The guest needs Xcode Command Line Tools. Source sync carries the compiled automation executable, so the guest does not download packages. The recorder remains an independent SwiftPM package outside Tatami's Tuist graph.",
+ "ko": "호스트에는 Swift 6.2 이상, {0}, libass/libx264를 지원하는 {1}, {2}, {3}이 필요해요. 호스트 명령은 저장소 루트에서 실행해요. 게스트에는 Xcode Command Line Tools가 필요해요. 소스를 동기화할 때 빌드된 자동화 실행 파일을 함께 전달하므로 게스트에서 패키지를 다운로드하지 않아요. 녹화기는 Tatami의 Tuist 그래프와 분리된 독립 SwiftPM 패키지로 유지돼요.",
+ "ja": "ホストには Swift 6.2 以降、{0}、libass/libx264 に対応した {1}、{2}、{3} が必要です。ホストのコマンドはリポジトリのルートで実行します。ゲストには Xcode Command Line Tools が必要です。ソースの同期時にビルド済みの自動化実行ファイルも転送するため、ゲストでパッケージをダウンロードする必要はありません。レコーダーは Tatami の Tuist グラフから独立した SwiftPM パッケージです。",
+ "zh-Hans": "主机需要 Swift 6.2 或更新版本、{0}、支持 libass/libx264 的 {1}、{2} 和 {3}。主机命令在仓库根目录运行。虚拟机需要 Xcode Command Line Tools。同步源代码时会一并传入编译好的自动化程序,因此虚拟机无需下载依赖包。录制器仍是独立的 SwiftPM 包,不加入 Tatami 的 Tuist 构建图。",
+ "zh-Hant": "主機需要 Swift 6.2 或更新版本、{0}、支援 libass/libx264 的 {1}、{2} 和 {3}。主機指令請在儲存庫根目錄執行。虛擬機需要 Xcode Command Line Tools。同步原始碼時會一併傳入編譯好的自動化程式,因此虛擬機不需要下載套件。錄製工具仍是獨立的 SwiftPM 套件,不加入 Tatami 的 Tuist 建置圖。"
+ },
+ "5669c96a0fc1e286": {
+ "en": "Automation uses {0}, ArgumentParser, SwiftSoup, Hummingbird, {1}, {2} and Swift Crypto. Foundation handles JSON and property lists. Capture acceptance, localization units and editorial budgets remain Tatami-specific rules. {3} pins the dependency graph.",
+ "ko": "자동화에는 {0}, ArgumentParser, SwiftSoup, Hummingbird, {1}, {2}, Swift Crypto를 사용해요. JSON과 속성 목록은 Foundation으로 처리해요. 녹화 승인 조건, 번역 단위, 영상 길이·용량 제한은 Tatami 고유의 규칙으로 유지해요. {3}에서 의존성 버전을 고정해요.",
+ "ja": "自動化には {0}、ArgumentParser、SwiftSoup、Hummingbird、{1}、{2}、Swift Crypto を使います。JSON とプロパティリストは Foundation で処理します。録画の合格条件、翻訳単位、動画の時間・容量の上限は Tatami 固有のルールとして管理します。依存関係のバージョンは {3} で固定します。",
+ "zh-Hans": "自动化使用 {0}、ArgumentParser、SwiftSoup、Hummingbird、{1}、{2} 和 Swift Crypto。JSON 和属性列表由 Foundation 处理。录制验收条件、翻译单元和视频时长及大小限制仍由 Tatami 自身的规则决定。{3} 固定依赖版本。",
+ "zh-Hant": "自動化使用 {0}、ArgumentParser、SwiftSoup、Hummingbird、{1}、{2} 和 Swift Crypto。JSON 與屬性列表由 Foundation 處理。錄製驗收條件、翻譯單元,以及影片長度與大小限制仍由 Tatami 的專屬規則決定。{3} 固定相依套件版本。"
+ },
+ "a5d911eac3784dd3": {
+ "en": "For a local website preview that outlives the terminal command, run {0} from the repository root.",
+ "ko": "터미널 명령이 끝난 뒤에도 로컬 웹사이트 미리보기를 유지하려면 저장소 루트에서 {0}을 실행해요.",
+ "ja": "ターミナルのコマンド終了後もローカルサイトのプレビューを続けるには、リポジトリのルートで {0} を実行します。",
+ "zh-Hans": "如需在终端命令结束后继续运行本地网站预览,请在仓库根目录运行 {0}。",
+ "zh-Hant": "若要在終端機指令結束後繼續執行本機網站預覽,請在儲存庫根目錄執行 {0}。"
+ },
+ "4b98135e841b4558": {
+ "en": "The host needs Apple silicon, Swift 6.2+ and Tart with {0} support. The guest needs the Tart agent, Command Line Tools and a copy of Tatami. Run host commands from the repository root; guest commands use {1}.",
+ "ko": "호스트에는 Apple Silicon, Swift 6.2 이상, {0}을 지원하는 Tart가 필요해요. 게스트에는 Tart 에이전트, Command Line Tools, Tatami가 필요해요. 호스트 명령은 저장소 루트에서, 게스트 명령은 {1}에서 실행해요.",
+ "ja": "ホストには Apple シリコン、Swift 6.2 以降、{0} に対応した Tart が必要です。ゲストには Tart エージェント、Command Line Tools、Tatami が必要です。ホストのコマンドはリポジトリのルート、ゲストのコマンドは {1} で実行します。",
+ "zh-Hans": "主机需要 Apple 芯片、Swift 6.2 或更新版本,以及支持 {0} 的 Tart。虚拟机需要 Tart 代理、Command Line Tools 和 Tatami。主机命令在仓库根目录运行,虚拟机命令在 {1} 中运行。",
+ "zh-Hant": "主機需要 Apple 晶片、Swift 6.2 或更新版本,以及支援 {0} 的 Tart。虛擬機需要 Tart 代理程式、Command Line Tools 和 Tatami。主機指令請在儲存庫根目錄執行,虛擬機指令則在 {1} 中執行。"
+ },
+ "204cea8b77797a7d": {
+ "en": "Place the Tatami build in the lab share before provisioning:",
+ "ko": "프로비저닝하기 전에 사용할 Tatami 빌드를 Demo Lab 공유 폴더에 복사해요.",
+ "ja": "プロビジョニングの前に、使う Tatami のビルドを Demo Lab の共有フォルダへコピーします。",
+ "zh-Hans": "配置虚拟机之前,先将要使用的 Tatami 构建复制到 Demo Lab 共享文件夹。",
+ "zh-Hant": "設定虛擬機之前,先將要使用的 Tatami 版本複製到 Demo Lab 共用資料夾。"
+ },
+ "fa8da33aff1da070": {
+ "en": "**Automatic BSP layout:** Insert new windows beside the current insertion target or focused tile. Use the shallowest tile when neither is available.",
+ "ko": "**자동 BSP 배치:** 새 창을 현재 삽입 지점이나 포커스된 타일 옆에 배치해요. 둘 다 없으면 트리에서 가장 얕은 타일을 사용해요.",
+ "ja": "**自動 BSP 配置:** 新しいウインドウは、現在の挿入先またはフォーカス中のタイルの隣に配置します。どちらもなければ、ツリーで最も浅いタイルを使います。",
+ "zh-Hans": "**自动 BSP 布局:** 将新窗口放在当前插入目标或焦点窗口所在区域的旁边。两者都不可用时,使用树中层级最浅的区域。",
+ "zh-Hant": "**自動 BSP 配置:** 將新視窗放在目前插入目標或取得焦點的區塊旁。兩者都不可用時,使用樹狀結構中最淺層的區塊。"
+ },
+ "99857bfc0d0c159e": {
+ "en": "**Keyboard operations:** With the recommended bindings, use {0} with {1}, {2}, {3}, or {4} to focus, arrow keys to swap, and {5} or {6} to grow or shrink the focused tile.",
+ "ko": "**키보드 조작:** 권장 단축키에서는 {0}과 {1}, {2}, {3}, {4}로 포커스를 옮기고, 화살표 키로 창 위치를 바꾸며, {5} 또는 {6}으로 포커스된 타일을 키우거나 줄여요.",
+ "ja": "**キーボード操作:** 推奨設定では、{0} と {1}、{2}、{3}、{4} でフォーカスを移動し、矢印キーでウインドウを入れ替え、{5} または {6} でフォーカス中のタイルを拡大・縮小します。",
+ "zh-Hans": "**键盘操作:** 使用推荐快捷键时,按住 {0},配合 {1}、{2}、{3} 或 {4} 移动焦点,配合方向键交换窗口,配合 {5} 或 {6} 放大或缩小焦点窗口所在的区域。",
+ "zh-Hant": "**鍵盤操作:** 使用建議快速鍵時,按住 {0},搭配 {1}、{2}、{3} 或 {4} 移動焦點,搭配方向鍵交換視窗,搭配 {5} 或 {6} 放大或縮小取得焦點的區塊。"
+ },
+ "add4ca695d67c690": {
+ "en": "Use Xcode 26 or later with a Swift 6.2 or later toolchain. The app runs on macOS 14 or later; its runtime requirement is separate from the tools needed to build it.",
+ "ko": "Xcode 26 이상과 Swift 6.2 이상의 툴체인을 사용해요. 앱은 macOS 14 이상에서 실행되며, 실행 환경과 빌드 도구의 요구사항은 별개예요.",
+ "ja": "Xcode 26 以降と Swift 6.2 以降のツールチェーンを使ってください。アプリは macOS 14 以降で動作します。実行環境とビルドツールの要件は別です。",
+ "zh-Hans": "请使用 Xcode 26 或更新版本,以及 Swift 6.2 或更新版本的工具链。应用可在 macOS 14 或更新版本上运行;运行环境要求与构建工具要求不同。",
+ "zh-Hant": "請使用 Xcode 26 或更新版本,以及 Swift 6.2 或更新版本的工具鏈。App 可在 macOS 14 或更新版本執行;執行環境與建置工具的需求不同。"
+ },
+ "565c83066cbf2ed4": {
+ "en": "A scene requires {0}, {1}, and {2}. Optional fields are {3}, {4}, {5}, {6}, and {7}. {8} executes before recording; {9} are the visible story. Their actions drive the installed Tatami and native fixture apps.",
+ "ko": "장면에는 {0}, {1}, {2}가 필요해요. {3}, {4}, {5}, {6}, {7}은 선택 필드예요. {8}은 녹화 전에 실행되고, {9}는 영상에 보이는 과정을 담아요. 각 동작은 설치된 Tatami와 네이티브 데모 앱을 직접 조작해요.",
+ "ja": "シーンには {0}、{1}、{2} が必要です。{3}、{4}、{5}、{6}、{7} は任意のフィールドです。{8} は録画前に実行され、{9} が映像に映る操作を定義します。各操作は、インストール済みの Tatami とネイティブのデモアプリを実際に動かします。",
+ "zh-Hans": "场景必须包含 {0}、{1} 和 {2}。可选字段包括 {3}、{4}、{5}、{6} 和 {7}。{8} 在录制前执行,{9} 定义视频中展示的过程。各项操作会实际控制已安装的 Tatami 和原生演示应用。",
+ "zh-Hant": "場景必須包含 {0}、{1} 和 {2}。選填欄位包括 {3}、{4}、{5}、{6} 和 {7}。{8} 在錄製前執行,{9} 定義影片中呈現的過程。各項操作會實際控制已安裝的 Tatami 與原生示範 App。"
+ },
+ "9b2ada4fffe0b28c": {
+ "en": "Set {0} to {1} for a hotplug scene that records the main display continuously and captures the second display only while it is connected.",
+ "ko": "주 화면을 계속 녹화하면서 두 번째 화면은 연결된 동안만 촬영하는 핫플러그 장면에서는 {0}을 {1}로 설정해요.",
+ "ja": "メイン画面を録画し続け、2 台目の画面は接続中だけ録画するホットプラグのシーンでは、{0} を {1} に設定します。",
+ "zh-Hans": "对于持续录制主屏幕、仅在第二块屏幕连接期间录制该屏幕的热插拔场景,请将 {0} 设为 {1}。",
+ "zh-Hant": "若場景會持續錄製主螢幕,並只在第二個螢幕連接期間錄製該螢幕,請將 {0} 設為 {1}。"
+ },
+ "a877808f1aafb12a": {
+ "en": "Size and position the native Tatami settings window before its controls are used.",
+ "ko": "컨트롤을 조작하기 전에 Tatami 설정 창의 크기와 위치를 맞춰요.",
+ "ja": "コントロールを操作する前に、Tatami のネイティブ設定ウインドウのサイズと位置を整えます。",
+ "zh-Hans": "操作控件前,调整 Tatami 原生设置窗口的大小和位置。",
+ "zh-Hant": "操作控制項前,調整 Tatami 原生設定視窗的大小與位置。"
+ },
+ "983ca98ad633be2d": {
+ "en": "Require the visible layout to differ from the named saved checkpoint.",
+ "ko": "현재 보이는 배치가 이름으로 지정한 저장 지점과 달라졌는지 확인해요.",
+ "ja": "現在表示されている配置が、指定した名前の保存済みチェックポイントと異なることを確認します。",
+ "zh-Hans": "确认当前可见布局与指定名称的已保存检查点不同。",
+ "zh-Hant": "確認目前顯示的配置與指定名稱的已儲存檢查點不同。"
+ },
+ "d03dbebdd6f32483": {
+ "en": "{0} defaults to {1}: text is recorded in editable ASS and JSON sidecars. Exports place captions over the lower part of the captured image, with the chapter at the upper left and actual keycasts at the upper right. Translucent backgrounds keep text readable, but it can cover app content; keep important controls clear of those areas. {2} uses a live rehearsal panel, which is separate from the final export design.",
+ "ko": "{0}의 기본값은 {1}이며, 텍스트는 편집 가능한 ASS와 JSON 부가 파일에 기록해요. 내보낸 영상은 화면 아래쪽에 자막을, 왼쪽 위에 챕터를, 오른쪽 위에 실제 키 입력을 겹쳐 보여줘요. 반투명 배경으로 가독성을 높이지만 앱 내용을 가릴 수 있으므로 중요한 컨트롤은 해당 영역을 피해서 배치해요. {2}는 실시간 리허설 패널을 사용하며, 최종 영상의 표시 방식과는 달라요.",
+ "ja": "{0} の既定値は {1} で、テキストは編集可能な ASS と JSON のサイドカーファイルに記録します。書き出した映像では、画面下部に字幕、左上にチャプター、右上に実際のキー入力を重ねます。半透明の背景で読みやすくしていますが、アプリの内容に重なるため、重要なコントロールはその領域を避けて配置してください。{2} はライブのリハーサルパネルを使い、最終映像とは表示方法が異なります。",
+ "zh-Hans": "{0} 默认为 {1},文字保存在可编辑的 ASS 和 JSON 附属文件中。导出的视频会在画面下方叠加字幕、左上角显示章节、右上角显示实际按键。半透明背景提高了可读性,但仍可能遮挡应用内容,因此重要控件应避开这些区域。{2} 使用实时排练面板,其呈现方式与最终视频不同。",
+ "zh-Hant": "{0} 預設為 {1},文字會儲存在可編輯的 ASS 與 JSON 附屬檔案中。匯出的影片會在畫面下方疊加字幕、左上角顯示章節、右上角顯示實際按鍵。半透明背景提高了可讀性,但仍可能遮住 App 內容,因此重要控制項應避開這些區域。{2} 使用即時排練面板,呈現方式與最終影片不同。"
+ },
+ "b1a74651529b32af": {
+ "en": "Tatami's app dependency versions and revisions come from {0}.",
+ "ko": "Tatami 앱 의존성의 버전과 리비전은 {0}을 기준으로 해요.",
+ "ja": "Tatami アプリの依存関係のバージョンとリビジョンは、{0} に基づいています。",
+ "zh-Hans": "Tatami 应用依赖项的版本和修订号以 {0} 为准。",
+ "zh-Hant": "Tatami App 相依套件的版本與修訂版本以 {0} 為準。"
+ },
+ "a4f4a10cfde85e94": {
+ "en": "Read [the complete app license and copyright notices]({0}) in their original language. This page translates the explanatory overview only.",
+ "ko": "[앱의 전체 라이선스 및 저작권 고지]({0})는 원문으로 제공해요. 이 페이지는 개요 설명만 번역해요.",
+ "ja": "[アプリのライセンスと著作権表示の全文]({0})は原文でご確認ください。このページでは概要の説明のみを翻訳しています。",
+ "zh-Hans": "请阅读原文形式的[应用完整许可证和版权声明]({0})。本页仅翻译概述说明。",
+ "zh-Hant": "請閱讀原文形式的[App 完整授權條款與著作權聲明]({0})。本頁僅翻譯概述說明。"
+ },
+ "14144cb7ccd165ca": {
+ "en": "Development automation uses a separate set of packages pinned in {0}. Read [the development tool notices]({1}) in their original language.",
+ "ko": "개발 자동화는 {0}에 고정된 별도의 패키지 집합을 사용해요. [개발 도구 의존성 고지]({1})는 원문으로 제공해요.",
+ "ja": "開発用の自動化は、{0} で固定された別のパッケージ群を使います。[開発ツールの依存関係の表示]({1})は原文でご確認ください。",
+ "zh-Hans": "开发自动化使用另一组依赖包,其版本固定在 {0} 中。请阅读原文形式的[开发工具依赖声明]({1})。",
+ "zh-Hant": "開發自動化使用另一組套件,其版本固定在 {0} 中。請閱讀原文形式的[開發工具相依套件聲明]({1})。"
+ }
+}
diff --git a/Localization/ThirdPartyNotice.md b/Localization/ThirdPartyNotice.md
new file mode 100644
index 0000000..a9b922b
--- /dev/null
+++ b/Localization/ThirdPartyNotice.md
@@ -0,0 +1,7 @@
+# Third-party notices
+
+Tatami's app dependency versions and revisions come from `Tuist/Package.resolved`.
+
+Read [the complete app license and copyright notices](THIRD_PARTY_NOTICES.md) in their original language. This page translates the explanatory overview only.
+
+Development automation uses a separate set of packages pinned in `Tools/Package.resolved`. Read [the development tool notices](Tools/THIRD_PARTY_NOTICES.md) in their original language.
diff --git a/Localization/Web.json b/Localization/Web.json
new file mode 100644
index 0000000..bda797f
--- /dev/null
+++ b/Localization/Web.json
@@ -0,0 +1,851 @@
+{
+ "1377264ba3b0a717": {
+ "en": "Workspaces",
+ "ko": "작업 공간",
+ "ja": "ワークスペース",
+ "zh-Hans": "工作区",
+ "zh-Hant": "工作空間"
+ },
+ "535e52e4a2616ebe": {
+ "en": "Profiles",
+ "ko": "프로필",
+ "ja": "プロファイル",
+ "zh-Hans": "配置方案",
+ "zh-Hant": "設定組合"
+ },
+ "2edfb168b38b6272": {
+ "en": "Borrow",
+ "ko": "빌려오기",
+ "ja": "借りる",
+ "zh-Hans": "借用",
+ "zh-Hant": "借用"
+ },
+ "3fe02a548e905a06": {
+ "en": "Tiling",
+ "ko": "타일링",
+ "ja": "タイル表示",
+ "zh-Hans": "平铺",
+ "zh-Hant": "並排"
+ },
+ "25b01bd53e3c5f26": {
+ "en": "Always on Top",
+ "ko": "항상 위",
+ "ja": "常に手前",
+ "zh-Hans": "置顶",
+ "zh-Hant": "置頂"
+ },
+ "b50c780ec8f2aa52": {
+ "en": "Guided Setup",
+ "ko": "가이드 설정",
+ "ja": "ガイド設定",
+ "zh-Hans": "引导设置",
+ "zh-Hant": "引導設定"
+ },
+ "e21f935f11d7e966": {
+ "en": "Copy",
+ "ko": "복사",
+ "ja": "コピー",
+ "zh-Hans": "复制",
+ "zh-Hant": "拷貝"
+ },
+ "06d496fb1b96f603": {
+ "en": "__VERSION__",
+ "ko": "__VERSION__",
+ "ja": "__VERSION__",
+ "zh-Hans": "__VERSION__",
+ "zh-Hant": "__VERSION__"
+ },
+ "10c78a38a2006860": {
+ "en": "Make room for the whole workflow.",
+ "ko": "모든 작업에 알맞은 자리를 만들어요.",
+ "ja": "仕事の流れに、必要な場所を。",
+ "zh-Hans": "为整个工作流程腾出空间。",
+ "zh-Hant": "為整個工作流程騰出空間。"
+ },
+ "71bf90935fc231d2": {
+ "en": "Reference",
+ "ko": "참고 자료",
+ "ja": "参考資料",
+ "zh-Hans": "参考资料",
+ "zh-Hant": "參考資料"
+ },
+ "6de82c482cedab95": {
+ "en": "Command Line",
+ "ko": "명령줄",
+ "ja": "コマンドライン",
+ "zh-Hans": "命令行",
+ "zh-Hant": "命令列"
+ },
+ "c50ca3efacdd8990": {
+ "en": "Tatami: macOS workspace manager with BSP window tiling",
+ "ko": "Tatami: BSP 타일링을 갖춘 macOS 작업 공간 관리자",
+ "ja": "Tatami:BSP タイル表示を備えた macOS 用ワークスペースマネージャ",
+ "zh-Hans": "Tatami:支持 BSP 窗口平铺的 macOS 工作区管理器",
+ "zh-Hant": "Tatami:支援 BSP 視窗並排的 macOS 工作空間管理工具"
+ },
+ "6185894424b71dd4": {
+ "en": "Tatami is a native macOS workspace manager with BSP window tiling. Group apps into virtual workspaces, switch instantly, and restore each layout automatically.",
+ "ko": "Tatami는 BSP 창 타일링을 갖춘 macOS 작업 공간 관리자예요. 앱을 작업별로 모으고, 빠르게 전환하고, 쓰던 배치를 자동으로 복원해요.",
+ "ja": "Tatami は BSP タイル表示を備えた macOS 用ワークスペースマネージャです。アプリを作業ごとにまとめ、すぐに切り替え、配置も自動で復元します。",
+ "zh-Hans": "Tatami 是支持 BSP 窗口平铺的原生 macOS 工作区管理器。按任务组织应用,快速切换,并自动恢复各自的布局。",
+ "zh-Hant": "Tatami 是支援 BSP 視窗並排的原生 macOS 工作空間管理工具。依工作整理 App,快速切換,並自動還原各自的排列。"
+ },
+ "dfc1e6d16ba5e2e5": {
+ "en": "Toggle navigation",
+ "ko": "탐색 메뉴 열고 닫기",
+ "ja": "ナビゲーションを開く/閉じる",
+ "zh-Hans": "打开或关闭导航",
+ "zh-Hant": "開啟或關閉導覽"
+ },
+ "99af6606ff9d6cd5": {
+ "en": "Menu",
+ "ko": "메뉴",
+ "ja": "メニュー",
+ "zh-Hans": "菜单",
+ "zh-Hant": "選單"
+ },
+ "b332c3492d5eb10a": {
+ "en": "Configuration",
+ "ko": "설정 안내",
+ "ja": "設定ガイド",
+ "zh-Hans": "配置指南",
+ "zh-Hant": "設定指南"
+ },
+ "1a6f7cae06cc1fc9": {
+ "en": "Releases",
+ "ko": "릴리스",
+ "ja": "リリース",
+ "zh-Hans": "版本发布",
+ "zh-Hant": "版本發布"
+ },
+ "1ba07bcac12f39fc": {
+ "en": "Tatami app icon",
+ "ko": "Tatami 앱 아이콘",
+ "ja": "Tatami のアプリアイコン",
+ "zh-Hans": "Tatami 应用图标",
+ "zh-Hant": "Tatami App 圖像"
+ },
+ "57363a4fddb9d784": {
+ "en": "A macOS workspace manager,{0}with BSP window tiling.",
+ "ko": "작업 공간을 관리하고,{0}BSP로 창을 정리하는 macOS 앱이에요.",
+ "ja": "macOS のワークスペースを管理。{0}BSP でウインドウも整えます。",
+ "zh-Hans": "macOS 工作区管理器,{0}用 BSP 自动平铺窗口。",
+ "zh-Hant": "macOS 工作空間管理工具,{0}用 BSP 自動並排視窗。"
+ },
+ "5910c059eabab457": {
+ "en": "Download for macOS",
+ "ko": "macOS용 다운로드",
+ "ja": "macOS 版をダウンロード",
+ "zh-Hans": "下载 macOS 版",
+ "zh-Hant": "下載 macOS 版"
+ },
+ "908bcf850f90b6ba": {
+ "en": "View on GitHub ›",
+ "ko": "GitHub에서 보기 ›",
+ "ja": "GitHub で見る ›",
+ "zh-Hans": "在 GitHub 上查看 ›",
+ "zh-Hant": "在 GitHub 上查看 ›"
+ },
+ "8d525e5f158b9afe": {
+ "en": "Copied",
+ "ko": "복사했어요",
+ "ja": "コピーしました",
+ "zh-Hans": "已复制",
+ "zh-Hant": "已拷貝"
+ },
+ "7af785b85cf4730e": {
+ "en": "Select and copy the command",
+ "ko": "명령을 선택해서 복사해주세요",
+ "ja": "コマンドを選択してコピーしてください",
+ "zh-Hans": "请选择并复制命令",
+ "zh-Hant": "請選取並拷貝指令"
+ },
+ "d14ebe3837d29550": {
+ "en": "Version {0} · Requires macOS 14 · Free & open source",
+ "ko": "버전 {0} · macOS 14 이상 · 무료 오픈 소스",
+ "ja": "バージョン {0} · macOS 14 以降 · 無料のオープンソース",
+ "zh-Hans": "版本 {0} · 需要 macOS 14 或更新版本 · 免费开源",
+ "zh-Hant": "版本 {0} · 需要 macOS 14 或更新版本 · 免費開源"
+ },
+ "5eda2c99f4cf14a8": {
+ "en": "Available in English, 한국어, 日本語, 简体中文, and 繁體中文.",
+ "ko": "영어, 한국어, 일본어, 중국어 간체와 대만 번체를 지원해요.",
+ "ja": "英語、韓国語、日本語、中国語の簡体字・繁体字(台湾)に対応しています。",
+ "zh-Hans": "支持英语、韩语、日语、简体中文和繁体中文(台湾)。",
+ "zh-Hant": "支援英文、韓文、日文、簡體中文與繁體中文(台灣)。"
+ },
+ "26ceae0deed7525b": {
+ "en": "See it in action",
+ "ko": "실제 사용 모습",
+ "ja": "実際の使い方",
+ "zh-Hans": "看看实际操作",
+ "zh-Hant": "看看實際操作"
+ },
+ "ed6b6d0a3725de24": {
+ "en": "Design, write, review, borrow, and automate. Follow one piece of work through a setup that changes with you.",
+ "ko": "디자인하고, 쓰고, 검토하고, 빌려오고, 자동화해요. 작업에 맞춰 바뀌는 환경에서 하나의 일을 끝까지 이어가보세요.",
+ "ja": "デザイン、執筆、レビュー、借りる操作、自動化。仕事に合わせて変わる環境で、一つの作業を最後まで進めます。",
+ "zh-Hans": "设计、写作、评审、借用与自动化。跟随一项工作,看看环境如何随任务改变。",
+ "zh-Hant": "設計、撰寫、審閱、借用與自動化。跟著一項工作,看看環境如何隨任務改變。"
+ },
+ "d66808b110e020b7": {
+ "en": "Tatami overview: design, write, review, borrow, automate, and adapt to a second display",
+ "ko": "Tatami 종합 데모: 디자인, 글쓰기, 검토, 빌려오기, 자동화, 두 번째 화면 연결",
+ "ja": "Tatami の紹介:デザイン、執筆、レビュー、借りる操作、自動化、2 台目の画面への対応",
+ "zh-Hans": "Tatami 概览:设计、写作、评审、借用、自动化,以及连接第二个显示器",
+ "zh-Hant": "Tatami 概覽:設計、撰寫、審閱、借用、自動化,以及連接第二部顯示器"
+ },
+ "97d314a0d1916231": {
+ "en": "Real Tatami window management, with demo apps and sample content. Shortcuts shown are the demo’s configured bindings.",
+ "ko": "실제 Tatami로 창을 관리해요. 앱과 콘텐츠는 데모용이며, 표시한 단축키는 이 데모에서 설정한 키예요.",
+ "ja": "実際の Tatami でウインドウを管理しています。アプリと内容はデモ用で、表示するキーはこのデモの設定です。",
+ "zh-Hans": "窗口由实际的 Tatami 管理,应用和内容为演示素材。画面中的快捷键采用本次演示的配置。",
+ "zh-Hant": "視窗由實際的 Tatami 管理,App 和內容為示範素材。畫面中的快速鍵採用本次示範的設定。"
+ },
+ "b3352d1dfbbe4502": {
+ "en": "Virtual workspaces",
+ "ko": "가상 작업 공간",
+ "ja": "仮想ワークスペース",
+ "zh-Hans": "虚拟工作区",
+ "zh-Hant": "虛擬工作空間"
+ },
+ "c7d15ac3e85325bf": {
+ "en": "A space for every task.",
+ "ko": "작업마다 알맞은 공간을 만들어요.",
+ "ja": "作業ごとに、使いやすい場所を。",
+ "zh-Hans": "每项任务,都有合适的空间。",
+ "zh-Hant": "每項工作,都有合適的空間。"
+ },
+ "519c37c6d50375c9": {
+ "en": "Group your apps into workspaces and jump between them in a keystroke. Each one brings exactly the windows you assigned it, on the display you want.",
+ "ko": "필요한 앱을 작업 공간에 모으고 단축키로 오가세요. 배정한 창이 원하는 화면에 함께 나타나요.",
+ "ja": "必要なアプリをワークスペースにまとめ、キー一つで切り替えます。割り当てたウインドウが、使いたい画面にそろいます。",
+ "zh-Hans": "把应用按任务分组,用一个快捷键切换。每个工作区都会在指定显示器上显示你分配的窗口。",
+ "zh-Hant": "依工作將 App 分組,用一個快速鍵切換。每個工作空間都會在指定顯示器上顯示你指派的視窗。"
+ },
+ "154785dfe762aab9": {
+ "en": "Switch your way",
+ "ko": "편한 방식으로 전환",
+ "ja": "使いやすい方法で切り替える",
+ "zh-Hans": "按习惯切换",
+ "zh-Hant": "依習慣切換"
+ },
+ "0b47c14cbd3b33f3": {
+ "en": "Use hotkeys or bind every three- and four-finger swipe direction to a shortcut action. Recent navigation can stay on one display or follow a global history.",
+ "ko": "단축키를 쓰거나 세 손가락·네 손가락 쓸기에 동작을 연결하세요. 최근 작업 공간은 한 화면 안에서만 찾거나 모든 화면을 함께 탐색할 수 있어요.",
+ "ja": "ショートカットや 3 本指・4 本指のスワイプに操作を割り当てられます。直前の作業への移動は、一つの画面内でも全画面共通でも使えます。",
+ "zh-Hans": "使用快捷键,或为三指、四指滑动的各个方向绑定操作。最近工作区可以只在当前显示器内查找,也可以共享所有显示器的历史。",
+ "zh-Hant": "使用快速鍵,或為三指、四指滑動的各個方向綁定操作。最近的工作空間可以只在目前顯示器內尋找,也可以共用所有顯示器的記錄。"
+ },
+ "a92ae51acb65bada": {
+ "en": "Per-display workspaces",
+ "ko": "화면별 작업 공간",
+ "ja": "画面ごとのワークスペース",
+ "zh-Hans": "每个显示器的工作区",
+ "zh-Hant": "每部顯示器的工作空間"
+ },
+ "5642a56e89203746": {
+ "en": "Pin a workspace to a monitor or let a dynamic workspace follow the pointer. Link an ordered set into a workspace chain to place available companions across connected displays while focus stays with the workspace you chose.",
+ "ko": "작업 공간을 특정 화면에 고정하거나 포인터가 있는 화면에 열 수 있어요. 여러 작업 공간을 체인으로 묶으면 연결된 화면에 함께 배치되고, 포커스는 선택한 작업에 남아요.",
+ "ja": "ワークスペースを画面に固定したり、ポインタのある画面に開いたりできます。チェーンにまとめると複数画面へ一緒に配置され、選んだ作業にフォーカスが残ります。",
+ "zh-Hans": "把工作区固定到显示器,或让动态工作区跟随指针。按顺序组成工作区链,在已连接的显示器上一起放置,同时保留所选工作区的焦点。",
+ "zh-Hant": "把工作空間固定到顯示器,或讓動態工作空間跟隨游標。依序組成工作空間鏈,在已連接的顯示器上一起放置,同時保留所選工作空間的焦點。"
+ },
+ "d8e54fce2e176849": {
+ "en": "Auto-open & shared apps",
+ "ko": "자동 열기와 공용 앱",
+ "ja": "自動で開く・共有アプリ",
+ "zh-Hans": "自动打开与共享应用",
+ "zh-Hant": "自動開啟與共用 App"
+ },
+ "190397df17135f42": {
+ "en": "Assigned apps launch when their workspace activates and reopen on re-entry. Shared apps join every workspace.",
+ "ko": "작업 공간에 들어가면 배정한 앱이 열려요. 닫았던 창도 다시 들어가면 열어줘요. 공용 앱은 모든 작업 공간에 함께해요.",
+ "ja": "ワークスペースを開くと、割り当てたアプリが起動します。閉じたウインドウも入り直すと開きます。共有アプリはどの作業でも使えます。",
+ "zh-Hans": "进入工作区时自动启动分配的应用,重新进入时打开之前关闭的窗口。共享应用会加入每个工作区。",
+ "zh-Hant": "進入工作空間時自動啟動指派的 App,重新進入時開啟先前關閉的視窗。共用 App 會加入每個工作空間。"
+ },
+ "a4c6160636e46dd7": {
+ "en": "Switch your whole setup at once.",
+ "ko": "작업 환경 전체를 한 번에 바꿔요.",
+ "ja": "作業環境を、まとめて切り替えます。",
+ "zh-Hans": "一次切换整个工作环境。",
+ "zh-Hant": "一次切換整個工作環境。"
+ },
+ "5b3da0f65315778e": {
+ "en": "Group workspaces into profiles, such as one for the laptop and one for the desk, then switch the entire set with a keystroke. Every display re-tiles for the new profile.",
+ "ko": "노트북용, 책상용처럼 작업 공간을 프로필로 묶어보세요. 단축키 하나로 전체 구성이 바뀌고 각 화면의 창도 다시 정리돼요.",
+ "ja": "ノートブック用、デスク用など、ワークスペースをプロファイルにまとめられます。キー一つで構成を切り替え、各画面のウインドウも整えます。",
+ "zh-Hans": "把工作区组成配置方案,例如笔记本用一套、办公桌用一套。一个快捷键即可切换,各显示器也会重新平铺窗口。",
+ "zh-Hant": "把工作空間組成設定組合,例如筆電用一套、辦公桌用一套。一個快速鍵即可切換,各顯示器也會重新並排視窗。"
+ },
+ "1eb74491ffca66c9": {
+ "en": "A setup for every situation",
+ "ko": "상황마다 다른 구성",
+ "ja": "場面に合った構成",
+ "zh-Hans": "适合不同场景的配置",
+ "zh-Hant": "適合不同情境的設定"
+ },
+ "ca7c27cc2fb9d22f": {
+ "en": "Each profile has its own workspaces, app assignments, and shortcuts. Switch by hotkey or from the menu bar, then return to the setup that fits your connected displays after relaunch.",
+ "ko": "프로필마다 작업 공간, 앱 배정, 단축키를 따로 저장해요. 단축키나 메뉴 막대에서 전환하고, 다시 실행해도 연결된 화면에 맞는 구성으로 돌아올 수 있어요.",
+ "ja": "プロファイルごとにワークスペース、アプリ、ショートカットを保存します。キーやメニューバーで切り替え、再起動後も接続中の画面に合う構成に戻れます。",
+ "zh-Hans": "每个配置方案分别保存工作区、应用分配和快捷键。通过快捷键或菜单栏切换,重新启动后也能恢复适合已连接显示器的配置。",
+ "zh-Hant": "每個設定組合分別儲存工作空間、App 指派與快速鍵。透過快速鍵或選單列切換,重新啟動後也能還原適合已連接顯示器的設定。"
+ },
+ "cd65b647f23aa922": {
+ "en": "Auto-switch by display",
+ "ko": "화면에 맞춰 자동 전환",
+ "ja": "画面に合わせて自動で切り替える",
+ "zh-Hans": "按显示器自动切换",
+ "zh-Hant": "依顯示器自動切換"
+ },
+ "e79b52920242502b": {
+ "en": "Activate a profile automatically by monitor count or by specific displays being connected or unplugged. Overlapping rules get flagged.",
+ "ko": "화면 수나 특정 화면의 연결 여부에 따라 프로필을 자동으로 켜요. 조건이 겹치면 미리 알려줘요.",
+ "ja": "画面の数や、特定の画面の接続・取り外しに合わせて切り替えます。条件が重なる場合はお知らせします。",
+ "zh-Hans": "根据显示器数量,或指定显示器的连接、断开状态自动激活配置方案。规则重叠时会提示。",
+ "zh-Hant": "根據顯示器數量,或指定顯示器的連接、中斷狀態自動啟用設定組合。規則重疊時會提示。"
+ },
+ "9cd7cce80c60ab65": {
+ "en": "Start from an existing setup",
+ "ko": "기존 구성에서 시작",
+ "ja": "今ある構成から始める",
+ "zh-Hans": "从现有配置开始",
+ "zh-Hant": "從現有設定開始"
+ },
+ "ec935612f34bb623": {
+ "en": "Copy selected workspaces and settings from another profile, or duplicate one as a starting point. Review what will be brought over before applying.",
+ "ko": "다른 프로필에서 필요한 작업 공간과 설정을 골라 복사하거나 프로필을 복제하세요. 적용하기 전에 가져올 내용을 확인할 수 있어요.",
+ "ja": "ほかのプロファイルから必要な作業や設定をコピーしたり、複製したりできます。適用前に、取り込む内容を確認できます。",
+ "zh-Hans": "从其他配置方案选择工作区和设置进行复制,或直接复制整个方案作为起点。应用前可以检查导入内容。",
+ "zh-Hant": "從其他設定組合選擇工作空間與設定進行拷貝,或直接複製整個組合作為起點。套用前可以檢查帶入的內容。"
+ },
+ "c99b73d04cb51614": {
+ "en": "Workspace composition",
+ "ko": "작업 공간 함께 쓰기",
+ "ja": "ワークスペースを組み合わせる",
+ "zh-Hans": "组合工作区",
+ "zh-Hant": "組合工作空間"
+ },
+ "5d424c01edb64a77": {
+ "en": "Two workspaces, side by side.",
+ "ko": "두 작업 공간을 나란히 써요.",
+ "ja": "二つのワークスペースを、並べて使う。",
+ "zh-Hans": "两个工作区,并排使用。",
+ "zh-Hant": "兩個工作空間,並排使用。"
+ },
+ "22ad22a2bf4eda9b": {
+ "en": "Need one workspace's window while you work in another? Borrow it beside the current one, docked to a screen edge, with each block keeping its own layout.",
+ "ko": "다른 작업 공간의 창이 필요할 땐 옆으로 빌려오세요. 화면 가장자리에 나란히 놓고, 각 공간의 배치는 따로 유지해요.",
+ "ja": "別の作業にあるウインドウが必要なら、横に借りてきましょう。画面の端に並べても、それぞれの配置は保たれます。",
+ "zh-Hans": "需要另一个工作区的窗口时,就把它借到当前工作区旁边。靠屏幕边缘放置,各自保留独立布局。",
+ "zh-Hant": "需要另一個工作空間的視窗時,就把它借到目前工作空間旁邊。靠螢幕邊緣放置,各自保留獨立排列。"
+ },
+ "80c842aa3c4e021d": {
+ "en": "Docked & tiled",
+ "ko": "나란히 두고 따로 정리",
+ "ja": "並べても、それぞれに整える",
+ "zh-Hans": "并排放置,独立平铺",
+ "zh-Hant": "並排放置,獨立整理"
+ },
+ "6011a24088fa6ca9": {
+ "en": "Pull a workspace to the top, bottom, left, or right. The two blocks tile beside each other with separate BSP layouts, and windows stay on their own side.",
+ "ko": "위·아래·왼쪽·오른쪽으로 작업 공간을 가져올 수 있어요. 두 영역은 각자 BSP 배치를 쓰고, 창은 자기 영역 안에 머물러요.",
+ "ja": "上下左右にワークスペースを呼び出せます。二つの領域は別々に BSP 配置され、ウインドウは自分の領域内に収まります。",
+ "zh-Hans": "把工作区放到上、下、左或右侧。两个区域各自使用 BSP 布局,窗口留在所属区域内。",
+ "zh-Hant": "把工作空間放到上、下、左或右側。兩個區域各自使用 BSP 排列,視窗留在所屬區域內。"
+ },
+ "d7cbdfcc23057375": {
+ "en": "Changes stay in sync",
+ "ko": "작업 내용은 그대로 유지",
+ "ja": "変更はそのまま残る",
+ "zh-Hans": "更改持续保留",
+ "zh-Hant": "變更持續保留"
+ },
+ "5cae9467f28b840c": {
+ "en": "The borrowed block {0}is{1} the real workspace, so changes stay with it. Directional focus and MFF cross the seam, while host and borrowed tiled windows share one switching order. Activate it to switch over fully, or borrow it again to return it.",
+ "ko": "빌려온 영역은 {0}실제 작업 공간{1}이에요. 작업한 내용도 그대로 남아요. 방향키 포커스와 포인터 이동은 경계를 넘나들고, 두 영역의 창을 함께 전환할 수 있어요. 해당 공간을 활성화하면 완전히 전환하고, 다시 빌려오면 돌려보내요.",
+ "ja": "借りた領域は{0}実際のワークスペース{1}なので、変更も残ります。方向によるフォーカスと MFF は境界を越え、両側のタイルを一緒に切り替えられます。開くと完全に移動し、もう一度借りると返せます。",
+ "zh-Hans": "借来的区域就是{0}实际工作区{1},更改会保留。方向焦点和鼠标跟随焦点可以跨越边界,两侧平铺窗口也共享切换顺序。激活它即可完整切换,再次借用即可归还。",
+ "zh-Hant": "借來的區域就是{0}實際工作空間{1},變更會保留。方向焦點與游標跟隨焦點可以跨越邊界,兩側並排視窗也共用切換順序。啟用它即可完整切換,再次借用即可歸還。"
+ },
+ "694d89283fa5b499": {
+ "en": "A key and a direction",
+ "ko": "키 하나와 방향 선택",
+ "ja": "キーと方向で選ぶ",
+ "zh-Hans": "一个按键,一个方向",
+ "zh-Hant": "一個按鍵,一個方向"
+ },
+ "30f094fb6e9879d5": {
+ "en": "Hold the borrow modifier with a workspace's key, then press a direction ({0} {1} {2} {3} or arrows). You can also set a default edge and size. Scratchpad workspaces are borrow-only.",
+ "ko": "빌려오기 보조키와 작업 공간 키를 누른 뒤 방향({0} {1} {2} {3} 또는 화살표)을 골라요. 기본 방향과 크기를 정할 수도 있어요. 임시 공간은 빌려오기로만 열려요.",
+ "ja": "借りる修飾キーとワークスペースのキーを押して、方向({0} {1} {2} {3} または矢印)を選びます。既定の位置と大きさも設定できます。一時スペースは借りる操作で開きます。",
+ "zh-Hans": "按住借用修饰键并按下工作区按键,再选择方向({0} {1} {2} {3} 或方向键)。也可以设置默认方向和大小。暂存区只能通过借用打开。",
+ "zh-Hant": "按住借用輔助鍵並按下工作空間按鍵,再選擇方向({0} {1} {2} {3} 或方向鍵)。也可以設定預設方向與大小。暫存區只能透過借用開啟。"
+ },
+ "a4a516b0860ab946": {
+ "en": "Window tiling",
+ "ko": "창 타일링",
+ "ja": "ウインドウのタイル表示",
+ "zh-Hans": "窗口平铺",
+ "zh-Hant": "視窗並排"
+ },
+ "496f5a4bfdecd013": {
+ "en": "Tiled automatically, controlled precisely.",
+ "ko": "자동으로 정리하고, 원하는 대로 조절해요.",
+ "ja": "自動で整えて、細かく調整できます。",
+ "zh-Hans": "自动整理,也能精细调整。",
+ "zh-Hant": "自動整理,也能細緻調整。"
+ },
+ "aadde0b6a3f79330": {
+ "en": "A binary-space-partitioning engine tiles each workspace's windows as you open them. It is fully native and needs no shell scripting.",
+ "ko": "창을 열면 BSP 엔진이 각 작업 공간 안에서 창을 나눠 배치해요. macOS에 맞게 만들었고, 셸 스크립트 없이 사용할 수 있어요.",
+ "ja": "ウインドウを開くと、BSP エンジンが作業ごとにタイル表示します。macOS 向けのネイティブ実装で、シェルスクリプトは不要です。",
+ "zh-Hans": "打开窗口时,二叉空间分割引擎会在各工作区内自动平铺。采用原生实现,无需编写 shell 脚本。",
+ "zh-Hant": "開啟視窗時,二元空間分割引擎會在各工作空間內自動並排。採用原生實作,不必撰寫 shell 指令稿。"
+ },
+ "35acb2db3571b0de": {
+ "en": "Automatic BSP",
+ "ko": "BSP 자동 배치",
+ "ja": "BSP による自動配置",
+ "zh-Hans": "BSP 自动布局",
+ "zh-Hant": "BSP 自動排列"
+ },
+ "f2cefce3a6fed4c2": {
+ "en": "Control it from the keyboard",
+ "ko": "키보드로 세밀하게 조절",
+ "ja": "キーボードで細かく操作",
+ "zh-Hans": "用键盘精细控制",
+ "zh-Hant": "用鍵盤細緻控制"
+ },
+ "cf6399266cc4921a": {
+ "en": "Move focus with {0} {1} {2} {3}, swap windows with the arrow keys, and grow or shrink the focused split with {4} and {5}. Hold the window-switch shortcut for a compact, state-aware switcher scoped to the display under the pointer.",
+ "ko": "{0} {1} {2} {3}으로 포커스를 옮기고, 화살표로 창을 교체하고, {4}와 {5}로 영역을 늘이거나 줄여요. 창 전환 단축키를 길게 누르면 포인터가 있는 화면의 창과 상태를 한눈에 볼 수 있어요.",
+ "ja": "{0} {1} {2} {3} でフォーカスを移し、矢印で入れ替え、{4} と {5} で領域を広げたり縮めたりします。切り替えキーを長押しすると、ポインタのある画面のウインドウと状態を一覧できます。",
+ "zh-Hans": "用 {0} {1} {2} {3} 移动焦点,方向键交换窗口,{4} 和 {5} 调整分割区域大小。按住窗口切换快捷键,即可查看指针所在显示器上的窗口及其状态。",
+ "zh-Hant": "用 {0} {1} {2} {3} 移動焦點,方向鍵交換視窗,{4} 和 {5} 調整分割區域大小。按住視窗切換快速鍵,即可查看游標所在顯示器上的視窗及其狀態。"
+ },
+ "cf8a11afa7754d63": {
+ "en": "Drag to rearrange",
+ "ko": "드래그해서 배치",
+ "ja": "ドラッグして並べる",
+ "zh-Hans": "拖移调整布局",
+ "zh-Hant": "拖移調整排列"
+ },
+ "f5484aa9135f458e": {
+ "en": "Drag a window onto another and a live preview shows the swap or insertion before you drop. Resize an edge by hand and the layout remembers the ratio.",
+ "ko": "창을 다른 창 위로 옮기면 놓기 전에 교체·삽입 위치를 보여줘요. 가장자리를 직접 조절해도 비율이 저장돼요.",
+ "ja": "別のウインドウへドラッグすると、放す前に入れ替えや挿入位置が見えます。端を手で調整しても比率を覚えます。",
+ "zh-Hans": "把窗口拖到另一个窗口上,松开前即可预览交换或插入位置。手动调整边缘时,布局也会记住比例。",
+ "zh-Hant": "把視窗拖到另一個視窗上,放開前即可預覽交換或插入位置。手動調整邊緣時,排列也會記住比例。"
+ },
+ "028cb0b0e39fac2a": {
+ "en": "Keep important windows above the layout.",
+ "ko": "중요한 창은 항상 위에 두세요.",
+ "ja": "大切なウインドウは、いつも手前に。",
+ "zh-Hans": "重要窗口,始终可见。",
+ "zh-Hant": "重要視窗,隨時可見。"
+ },
+ "25d06ccdf3684bbf": {
+ "en": "Some windows should remain visible, including a video call, a simulator, or a music player. Choose Always on Top and Tatami keeps them above the layout.",
+ "ko": "영상 통화, 시뮬레이터, 음악 재생 창처럼 계속 보고 싶은 창이 있나요? 항상 위를 선택하면 다른 창 위에서 볼 수 있어요.",
+ "ja": "ビデオ通話、シミュレータ、音楽プレイヤーなど、見続けたいウインドウは「常に手前」で表示できます。",
+ "zh-Hans": "视频通话、模拟器或音乐播放器,有些窗口需要一直可见。选择置顶,Tatami 就会让它保持在其他窗口上方。",
+ "zh-Hant": "視訊通話、模擬器或音樂播放器,有些視窗需要一直可見。選擇置頂,Tatami 就會讓它保持在其他視窗上方。"
+ },
+ "6f2d06ebaf8a483d": {
+ "en": "Per-workspace or shared",
+ "ko": "한 작업 공간에서도, 모든 공간에서도",
+ "ja": "一つの作業でも、すべての作業でも",
+ "zh-Hans": "当前工作区或全部共享",
+ "zh-Hant": "目前工作空間或全部共用"
+ },
+ "de398c3c9889db47": {
+ "en": "Keep an app on top in one workspace or add it to Shared Apps to keep it on top everywhere. Use one setting in the app or one shortcut anywhere.",
+ "ko": "한 작업 공간에서만 앱을 항상 위에 두거나 공용 앱에 추가해 어디서나 볼 수 있어요. 설정 하나나 단축키로 바꿀 수 있어요.",
+ "ja": "一つのワークスペースだけで手前に置いたり、共有アプリにしてどこでも表示したりできます。設定やショートカットで切り替えられます。",
+ "zh-Hans": "只在一个工作区置顶应用,或把它加入共享应用,在所有工作区置顶。通过设置或快捷键即可切换。",
+ "zh-Hant": "只在一個工作空間置頂 App,或把它加入共用 App,在所有工作空間置頂。透過設定或快速鍵即可切換。"
+ },
+ "a024f0c4188be2c9": {
+ "en": "The real window, on demand",
+ "ko": "조작할 땐 실제 창으로",
+ "ja": "操作するときは実際のウインドウへ",
+ "zh-Hans": "需要操作时,使用实际窗口",
+ "zh-Hant": "需要操作時,使用實際視窗"
+ },
+ "675a5c1cf3cb4fb1": {
+ "en": "Reach for an always-on-top window and Tatami hands you the actual window. Type, drag, and resize as usual. Focused windows stack by recency.",
+ "ko": "항상 위에 둔 창을 조작하면 실제 창으로 이어져요. 평소처럼 입력하고, 옮기고, 크기를 바꿀 수 있어요. 최근 포커스한 창부터 위에 놓여요.",
+ "ja": "手前のウインドウに触れると、実際のウインドウに切り替わります。入力、ドラッグ、サイズ変更はいつもどおり。最近選んだ順に重なります。",
+ "zh-Hans": "操作置顶窗口时,Tatami 会交给你实际窗口。照常输入、拖移和调整大小,窗口按最近获得焦点的顺序堆叠。",
+ "zh-Hant": "操作置頂視窗時,Tatami 會交給你實際視窗。照常輸入、拖移與調整大小,視窗依最近取得焦點的順序堆疊。"
+ },
+ "bb32a1d2bc9dbc5c": {
+ "en": "System protection stays on",
+ "ko": "시스템 보호는 그대로",
+ "ja": "システムの保護はそのまま",
+ "zh-Hans": "保留系统保护",
+ "zh-Hant": "保留系統保護"
+ },
+ "b5dabf09652cba28": {
+ "en": "Tatami uses ScreenCaptureKit mirrors with Screen Recording permission instead of changing protected system settings. System Integrity Protection stays on.",
+ "ko": "보호된 시스템 설정을 바꾸지 않고, 화면 기록 권한으로 ScreenCaptureKit 미러를 사용해요. 시스템 무결성 보호는 켜둔 채로 쓸 수 있어요.",
+ "ja": "保護された設定を変えず、画面収録の許可を使って ScreenCaptureKit で表示します。システム整合性保護は有効なままで使えます。",
+ "zh-Hans": "Tatami 通过屏幕录制权限使用 ScreenCaptureKit 镜像,无需修改受保护的系统设置,系统完整性保护可以保持开启。",
+ "zh-Hant": "Tatami 透過螢幕錄製權限使用 ScreenCaptureKit 鏡像,不必修改受保護的系統設定,系統完整性保護可以維持開啟。"
+ },
+ "efae8d49def91967": {
+ "en": "Learn Tatami by using it.",
+ "ko": "Tatami를 직접 쓰면서 익혀요.",
+ "ja": "使いながら、Tatami を覚えよう。",
+ "zh-Hans": "实际操作,学会 Tatami。",
+ "zh-Hant": "實際操作,學會 Tatami。"
+ },
+ "99efff7151ab9c32": {
+ "en": "First launch turns this Mac's app metadata and displays into a draft, then teaches every major feature in order on a safe virtual display. Nothing moves and no configuration is written until you apply.",
+ "ko": "처음 실행하면 이 Mac의 앱 정보와 화면 구성으로 초안을 만들어요. 안전한 가상 화면에서 주요 기능을 순서대로 익히고, 적용하기 전까지 실제 창과 설정은 그대로 유지해요.",
+ "ja": "初回起動では、この Mac のアプリ情報と画面から下書きを作ります。安全な仮想画面で機能を順番に学び、適用するまで実際のウインドウや設定は変わりません。",
+ "zh-Hans": "首次启动时,根据这台 Mac 的应用信息和显示器生成草稿,再在安全的虚拟桌面中依次学习主要功能。应用前不会移动实际窗口或写入配置。",
+ "zh-Hant": "首次啟動時,依這部 Mac 的 App 資訊與顯示器產生草稿,再在安全的虛擬桌面中依序學習主要功能。套用前不會移動實際視窗或寫入設定。"
+ },
+ "3df59e4a41aa1971": {
+ "en": "Built around your setup",
+ "ko": "내 환경에서 시작",
+ "ja": "自分の環境から始める",
+ "zh-Hans": "从你的环境开始",
+ "zh-Hant": "從你的環境開始"
+ },
+ "e7cc4d4230488e97": {
+ "en": "Shape repeatable workspaces, assign the apps you already use, and carry those choices through switching, tiling, Borrow, window handling, focus, and window-switching lessons.",
+ "ko": "반복해서 쓰는 작업 공간을 만들고 익숙한 앱을 배정해보세요. 같은 구성을 이어서 쓰며 전환, 타일링, 빌려오기, 창 표시, 포커스와 창 전환을 배워요.",
+ "ja": "繰り返し使うワークスペースを作り、いつものアプリを割り当てます。その構成を使いながら、切り替え、タイル表示、借りる操作、表示方法、フォーカスを学べます。",
+ "zh-Hans": "按经常进行的任务组织工作区,分配熟悉的应用,再沿用这些选择学习切换、平铺、借用、窗口显示、焦点和窗口切换。",
+ "zh-Hant": "依經常進行的工作組織工作空間,指派熟悉的 App,再沿用這些選擇學習切換、並排、借用、視窗顯示、焦點與視窗切換。"
+ },
+ "ba8f4e2f6458113a": {
+ "en": "Practice on a virtual display",
+ "ko": "가상 화면에서 연습",
+ "ja": "仮想画面で練習",
+ "zh-Hans": "在虚拟桌面中练习",
+ "zh-Hant": "在虛擬桌面中練習"
+ },
+ "f0be7aef89d5c536": {
+ "en": "Use the real shortcuts and trackpad gestures you configured. Each later lesson keeps the earlier commands active, without touching your real windows.",
+ "ko": "설정한 실제 단축키와 트랙패드 제스처를 사용해요. 다음 단계에서도 앞서 배운 동작을 계속 쓸 수 있고, 실제 창은 움직이지 않아요.",
+ "ja": "設定したショートカットやトラックパッドのジェスチャを使えます。次のレッスンでも前の操作を使え、実際のウインドウには触れません。",
+ "zh-Hans": "使用已配置的实际快捷键和触控板手势。后续课程中,先前学过的操作仍然有效,不会影响实际窗口。",
+ "zh-Hant": "使用已設定的實際快速鍵與觸控式軌跡板手勢。後續課程中,先前學過的操作仍然有效,不會影響實際視窗。"
+ },
+ "e08c897c41b092bc": {
+ "en": "Bring your preferred AI",
+ "ko": "좋아하는 AI와 구성",
+ "ja": "好みの AI で構成を考える",
+ "zh-Hans": "使用喜欢的 AI 规划",
+ "zh-Hant": "使用喜歡的 AI 規劃"
+ },
+ "6532f6437087ec1e": {
+ "en": "Review a workspace proposal from ChatGPT, Claude, Gemini, another AI, or on-device Apple Intelligence on supported Macs. Tatami shares app metadata and display geometry, never screen contents.",
+ "ko": "ChatGPT, Claude, Gemini 등 원하는 AI가 제안한 작업 공간을 검토하세요. 지원하는 Mac에서는 기기의 Apple Intelligence도 쓸 수 있어요. 앱 정보와 화면 크기만 공유하고 화면 내용은 보내지 않아요.",
+ "ja": "ChatGPT、Claude、Gemini など、好みの AI の提案を確認できます。対応する Mac では Apple Intelligence も使えます。共有するのはアプリ情報と画面の形だけで、画面の内容は送りません。",
+ "zh-Hans": "检查 ChatGPT、Claude、Gemini 等 AI 提出的工作区建议;支持的 Mac 也可使用设备端 Apple Intelligence。只共享应用信息和显示器几何数据,不共享屏幕内容。",
+ "zh-Hant": "檢查 ChatGPT、Claude、Gemini 等 AI 提出的工作空間建議;支援的 Mac 也可使用裝置端 Apple Intelligence。只分享 App 資訊與顯示器幾何資料,不分享螢幕內容。"
+ },
+ "d9d820769716101b": {
+ "en": "For power users",
+ "ko": "더 깊이 활용하기",
+ "ja": "もっと使いこなす",
+ "zh-Hans": "进阶使用",
+ "zh-Hant": "進階使用"
+ },
+ "188cd3b0f37b4829": {
+ "en": "Make your routine a command.",
+ "ko": "반복하는 일을 명령 하나로 묶어요.",
+ "ja": "いつもの手順を、一つのコマンドに。",
+ "zh-Hans": "把日常步骤变成一个命令。",
+ "zh-Hant": "把日常步驟變成一個指令。"
+ },
+ "8f22403dd9c89492": {
+ "en": "Prepare a workspace with a script, connect another tool through hooks, or change your settings in a plain text file.",
+ "ko": "스크립트로 작업 공간을 준비하고, 훅으로 다른 도구를 연결하고, 텍스트 파일에서 설정을 바꿔보세요.",
+ "ja": "スクリプトで作業環境を用意し、フックでほかのツールにつなぎ、テキストファイルで設定を変えられます。",
+ "zh-Hans": "用脚本准备工作区,通过钩子连接其他工具,或直接编辑文本配置文件。",
+ "zh-Hant": "用指令稿準備工作空間,透過掛鉤連接其他工具,或直接編輯文字設定檔。"
+ },
+ "26f06c313ca674bb": {
+ "en": "Start with a command",
+ "ko": "명령으로 시작",
+ "ja": "コマンドで始める",
+ "zh-Hans": "从命令开始",
+ "zh-Hant": "從指令開始"
+ },
+ "04cfc284b2fd0d35": {
+ "en": "Activate workspaces, arrange windows, and inspect your setup from a script.",
+ "ko": "스크립트에서 작업 공간을 켜고, 창을 정리하고, 구성을 확인해요.",
+ "ja": "スクリプトからワークスペースを開き、ウインドウを整え、構成を確認できます。",
+ "zh-Hans": "从脚本激活工作区、排列窗口并检查配置。",
+ "zh-Hant": "從指令稿啟用工作空間、排列視窗並檢查設定。"
+ },
+ "de8a9db102e98dcb": {
+ "en": "Explore the CLI ›",
+ "ko": "CLI 살펴보기 ›",
+ "ja": "CLI を見る ›",
+ "zh-Hans": "了解 CLI ›",
+ "zh-Hant": "瞭解 CLI ›"
+ },
+ "f7970f23e75a0dd2": {
+ "en": "Connect your tools",
+ "ko": "도구 연결",
+ "ja": "ツールをつなぐ",
+ "zh-Hans": "连接工具",
+ "zh-Hant": "連接工具"
+ },
+ "f4aeb836cd27ebab": {
+ "en": "Let another tool react when your workspace changes or an action produces feedback.",
+ "ko": "작업 공간이 바뀌거나 화면 알림이 생기면 다른 도구도 반응하게 만들어요.",
+ "ja": "ワークスペースの切り替えや操作フィードバックに、ほかのツールを連動できます。",
+ "zh-Hans": "让其他工具响应工作区变化或操作反馈。",
+ "zh-Hant": "讓其他工具回應工作空間變化或操作回饋。"
+ },
+ "89b966fd241729d2": {
+ "en": "Explore hooks ›",
+ "ko": "훅 살펴보기 ›",
+ "ja": "フックを見る ›",
+ "zh-Hans": "了解钩子 ›",
+ "zh-Hant": "瞭解掛鉤 ›"
+ },
+ "4414ca652f1f99fe": {
+ "en": "Keep settings in a file",
+ "ko": "파일로 설정 관리",
+ "ja": "設定をファイルで管理",
+ "zh-Hans": "用文件管理配置",
+ "zh-Hant": "用檔案管理設定"
+ },
+ "d048c5552a5704a0": {
+ "en": "Edit plain TOML and see supported settings update without restarting Tatami.",
+ "ko": "TOML을 직접 수정하면 지원하는 설정이 Tatami를 다시 실행하지 않고 반영돼요.",
+ "ja": "TOML を編集すると、対応する設定は Tatami を再起動せずに反映されます。",
+ "zh-Hans": "直接编辑 TOML,支持的设置无需重启 Tatami 即可生效。",
+ "zh-Hant": "直接編輯 TOML,支援的設定不必重新啟動 Tatami 即可生效。"
+ },
+ "bd32f1575950135d": {
+ "en": "Read the configuration guide ›",
+ "ko": "설정 안내 읽기 ›",
+ "ja": "設定ガイドを読む ›",
+ "zh-Hans": "阅读配置指南 ›",
+ "zh-Hant": "閱讀設定指南 ›"
+ },
+ "c3c6a1379d19f5e3": {
+ "en": "Get Tatami.",
+ "ko": "Tatami를 시작해보세요.",
+ "ja": "Tatami を始めよう。",
+ "zh-Hans": "开始使用 Tatami。",
+ "zh-Hant": "開始使用 Tatami。"
+ },
+ "1b24bbc734869d66": {
+ "en": "Free, open source, and lives in your menu bar.",
+ "ko": "무료 오픈 소스 앱이에요. 메뉴 막대에서 편하게 쓸 수 있어요.",
+ "ja": "無料のオープンソース。メニューバーから使えます。",
+ "zh-Hans": "免费开源,随时从菜单栏使用。",
+ "zh-Hant": "免費開源,隨時從選單列使用。"
+ },
+ "bcd96ee792813746": {
+ "en": "Release notes ›",
+ "ko": "릴리스 노트 ›",
+ "ja": "リリースノート ›",
+ "zh-Hans": "版本说明 ›",
+ "zh-Hant": "版本說明 ›"
+ },
+ "c011d6097bfbc8e9": {
+ "en": "License",
+ "ko": "라이선스",
+ "ja": "ライセンス",
+ "zh-Hans": "许可证",
+ "zh-Hant": "授權條款"
+ },
+ "e87b87d1b729140e": {
+ "en": "Project notices",
+ "ko": "프로젝트 고지",
+ "ja": "プロジェクトの告知",
+ "zh-Hans": "项目声明",
+ "zh-Hant": "專案聲明"
+ },
+ "8fadbc8de2733658": {
+ "en": "Third-party notices",
+ "ko": "외부 라이브러리 고지",
+ "ja": "外部ライブラリの告知",
+ "zh-Hans": "第三方声明",
+ "zh-Hant": "第三方聲明"
+ },
+ "60b9881fa40e6d6a": {
+ "en": "Tatami is open source under the AGPL-3.0-only license and uses a native BSP window engine. Not affiliated with Apple Inc.",
+ "ko": "Tatami는 AGPL-3.0-only 라이선스의 오픈 소스이며 macOS용 BSP 창 배치 엔진을 사용해요. Apple Inc.와는 관련이 없어요.",
+ "ja": "Tatami は AGPL-3.0-only のオープンソースで、ネイティブの BSP エンジンを使っています。Apple Inc. とは関係ありません。",
+ "zh-Hans": "Tatami 采用 AGPL-3.0-only 许可证开源,使用原生 BSP 窗口引擎,与 Apple Inc. 无关联。",
+ "zh-Hant": "Tatami 採用 AGPL-3.0-only 授權開源,使用原生 BSP 視窗引擎,與 Apple Inc. 無關聯。"
+ },
+ "dde83db7774f47c4": {
+ "en": "Apps by {0}: {1} · {2} · {3}",
+ "ko": "{0}의 앱: {1} · {2} · {3}",
+ "ja": "{0} のアプリ:{1} · {2} · {3}",
+ "zh-Hans": "{0} 的应用:{1} · {2} · {3}",
+ "zh-Hant": "{0} 的 App:{1} · {2} · {3}"
+ },
+ "f7ea16e20c9a211b": {
+ "en": "Release Notes: Tatami",
+ "ko": "릴리스 노트: Tatami",
+ "ja": "リリースノート:Tatami",
+ "zh-Hans": "版本说明:Tatami",
+ "zh-Hant": "版本說明:Tatami"
+ },
+ "acf5f53c2c508df1": {
+ "en": "Tatami release notes and version history.",
+ "ko": "Tatami 릴리스 노트와 버전 기록이에요.",
+ "ja": "Tatami のリリースノートとバージョン履歴です。",
+ "zh-Hans": "Tatami 版本说明和更新历史。",
+ "zh-Hant": "Tatami 版本說明與更新記錄。"
+ },
+ "1254829bd9996472": {
+ "en": "Release notes",
+ "ko": "릴리스 노트",
+ "ja": "リリースノート",
+ "zh-Hans": "版本说明",
+ "zh-Hant": "版本說明"
+ },
+ "135e912e89054ffa": {
+ "en": "What's new",
+ "ko": "새로운 기능",
+ "ja": "新機能",
+ "zh-Hans": "新功能",
+ "zh-Hant": "新功能"
+ },
+ "52d04d944b64da3b": {
+ "en": "Every version of Tatami, newest first. Release notes are shown in their original language.",
+ "ko": "최신 버전부터 확인할 수 있어요. 과거 릴리스 노트는 작성된 언어의 원문으로 보여줘요.",
+ "ja": "新しいバージョンから順に紹介します。過去のリリースノートは原文で表示します。",
+ "zh-Hans": "按最新版本优先排列。版本说明保留发布时的原文。",
+ "zh-Hant": "依最新版本優先排列。版本說明保留發布時的原文。"
+ },
+ "95104354cf021bb8": {
+ "en": "Could not load the changelog. Read the original releases on",
+ "ko": "변경 기록을 불러오지 못했어요. 다음 링크에서 원문을 확인해주세요.",
+ "ja": "変更履歴を読み込めませんでした。次のリンクで原文を確認してください。",
+ "zh-Hans": "无法加载更新记录,请通过以下链接查看原文。",
+ "zh-Hant": "無法載入更新記錄,請透過以下連結查看原文。"
+ },
+ "d767e73566b2627e": {
+ "en": "No notes.",
+ "ko": "작성된 내용이 없어요.",
+ "ja": "記載された内容はありません。",
+ "zh-Hans": "暂无说明。",
+ "zh-Hant": "尚無說明。"
+ },
+ "eea11a93e678c44b": {
+ "en": "Loading releases…",
+ "ko": "릴리스 정보를 불러오고 있어요…",
+ "ja": "リリース情報を読み込んでいます…",
+ "zh-Hans": "正在加载版本信息…",
+ "zh-Hant": "正在載入版本資訊…"
+ },
+ "3b8c94fdc381d392": {
+ "en": "All releases",
+ "ko": "모든 릴리스",
+ "ja": "すべてのリリース",
+ "zh-Hans": "全部版本",
+ "zh-Hant": "所有版本"
+ },
+ "97170b76c91fc465": {
+ "en": "Rendered from {0} on the {1} branch as the single source of truth. Not affiliated with Apple Inc.",
+ "ko": "{1} 브랜치의 {0} 원문을 보여줘요. Apple Inc.와는 관련이 없어요.",
+ "ja": "{1} ブランチの {0} を原文として表示します。Apple Inc. とは関係ありません。",
+ "zh-Hans": "内容来自 {1} 分支中的 {0} 原文,与 Apple Inc. 无关联。",
+ "zh-Hant": "內容來自 {1} 分支中的 {0} 原文,與 Apple Inc. 無關聯。"
+ },
+ "838c58eabfc67f80": {
+ "en": "Configuration: Tatami",
+ "ko": "설정 안내: Tatami",
+ "ja": "設定ガイド:Tatami",
+ "zh-Hans": "配置指南:Tatami",
+ "zh-Hant": "設定指南:Tatami"
+ },
+ "388df8f071b9dfde": {
+ "en": "Tatami configuration reference: every config.toml key, its default, and the shortcut syntax.",
+ "ko": "Tatami의 config.toml 항목과 기본값, 단축키 문법을 설명해요.",
+ "ja": "Tatami の config.toml の各項目、既定値、ショートカットの書き方を説明します。",
+ "zh-Hans": "Tatami 配置参考:config.toml 中的各个键、默认值和快捷键语法。",
+ "zh-Hant": "Tatami 設定參考:config.toml 中的各個鍵、預設值與快速鍵語法。"
+ },
+ "eac7d70478a3ca2a": {
+ "en": "Could not load this guide. Read the source document on",
+ "ko": "안내를 불러오지 못했어요. 다음 링크에서 원문을 확인해주세요.",
+ "ja": "ガイドを読み込めませんでした。次のリンクで元の文書を確認してください。",
+ "zh-Hans": "无法加载本指南,请通过以下链接查看源文档。",
+ "zh-Hant": "無法載入本指南,請透過以下連結查看來源文件。"
+ },
+ "bc8fd86dcffdd94e": {
+ "en": "Loading configuration…",
+ "ko": "설정 안내를 불러오고 있어요…",
+ "ja": "設定ガイドを読み込んでいます…",
+ "zh-Hans": "正在加载配置指南…",
+ "zh-Hant": "正在載入設定指南…"
+ },
+ "6605efe3f2ad8fa7": {
+ "en": "Source document",
+ "ko": "원본 문서",
+ "ja": "元の文書",
+ "zh-Hans": "源文档",
+ "zh-Hant": "來源文件"
+ },
+ "1f6909f54b71eaa5": {
+ "en": "This page uses the documentation included with this version of the site. Not affiliated with Apple Inc.",
+ "ko": "이 사이트 버전에 포함된 문서를 보여줘요. Apple Inc.와는 관련이 없어요.",
+ "ja": "このサイトのバージョンに含まれる文書を表示しています。Apple Inc. とは関係ありません。",
+ "zh-Hans": "本页显示随此版本网站提供的文档,与 Apple Inc. 无关联。",
+ "zh-Hant": "本頁顯示隨此版本網站提供的文件,與 Apple Inc. 無關聯。"
+ },
+ "537cf5cb4dcfcd92": {
+ "en": "Command Line: Tatami",
+ "ko": "커맨드라인: Tatami",
+ "ja": "コマンドライン:Tatami",
+ "zh-Hans": "命令行:Tatami",
+ "zh-Hant": "命令列:Tatami"
+ },
+ "a7dee0a499d488d6": {
+ "en": "Tatami CLI reference: profile and workspace management, gesture-equivalent domain commands, JSON output, and scripting.",
+ "ko": "Tatami CLI의 프로필·작업 공간 관리, 제스처와 같은 동작 명령, JSON 출력과 스크립트 사용법을 설명해요.",
+ "ja": "Tatami CLI のプロファイル・ワークスペース管理、ジェスチャに相当する操作、JSON 出力、スクリプトでの使い方を説明します。",
+ "zh-Hans": "Tatami CLI 参考:配置方案和工作区管理、对应手势的领域命令、JSON 输出与脚本使用。",
+ "zh-Hant": "Tatami CLI 參考:設定組合與工作空間管理、對應手勢的領域指令、JSON 輸出與指令稿使用。"
+ },
+ "2515a897e2f98a21": {
+ "en": "Loading CLI reference…",
+ "ko": "CLI 안내를 불러오고 있어요…",
+ "ja": "CLI ガイドを読み込んでいます…",
+ "zh-Hans": "正在加载 CLI 参考…",
+ "zh-Hant": "正在載入 CLI 參考…"
+ },
+ "a4fe65264ef7dbb3": {
+ "en": "Language",
+ "ko": "언어",
+ "ja": "言語",
+ "zh-Hans": "语言",
+ "zh-Hant": "語言"
+ },
+ "70f689a42c724165": {
+ "en": "Tatami makes room for new windows by splitting a tile, with configurable inner and outer gaps. Each workspace keeps its layout and ratios across relaunches and system sleep.",
+ "context": "web/index.html",
+ "ko": "새 창이 열리면 영역을 나눠 자리를 만들어요. 창 사이와 화면 바깥 여백도 정할 수 있어요. 다시 실행하거나 잠자기에서 깨어나도 작업 공간별 배치와 비율을 기억해요.",
+ "ja": "新しいウインドウが開くと、領域を分割して配置します。ウインドウ間と画面の外側の余白も設定できます。再起動やスリープからの復帰後も、ワークスペースごとの配置と比率を保ちます。",
+ "zh-Hans": "新窗口打开时,Tatami 会分割平铺区域,为它腾出空间。窗口之间和屏幕边缘的间距均可设置。重新启动或从睡眠中唤醒后,各工作区仍保留自己的布局和比例。",
+ "zh-Hant": "新視窗開啟時,Tatami 會分割並排區域,為它騰出空間。視窗之間與螢幕邊緣的間距皆可設定。重新啟動或從睡眠中喚醒後,各工作空間仍保留自己的排列與比例。"
+ },
+ "50b00b910c64521d": {
+ "en": "Related settings",
+ "context": "demo settings label",
+ "ko": "관련 설정",
+ "ja": "関連する設定",
+ "zh-Hans": "相关设置",
+ "zh-Hant": "相關設定"
+ }
+}
diff --git a/NOTICE.ja.md b/NOTICE.ja.md
new file mode 100644
index 0000000..b9c6550
--- /dev/null
+++ b/NOTICE.ja.md
@@ -0,0 +1,49 @@
+
+[English](NOTICE.md) · [한국어](NOTICE.ko.md) · [日本語](NOTICE.ja.md) · [简体中文](NOTICE.zh-Hans.md) · [繁體中文](NOTICE.zh-Hant.md)
+
+
+
+# プロジェクトの告知
+
+Tatami はフォークではない独立プロジェクトです。次の先行成果を参考にしています。
+
+- 仮想ワークスペースの切り替えは Wojciech Kulik の [FlashSpace](GPL-3.0)に着想を得ました。ソースを複製せず、独自の構成で実装しています。
+- タイルのモデルと移動、均等化、回転、反転、境界によるサイズ変更は、MIT の koekeishiya による [yabai] を基にしています。該当する著作権表示を以下に原文で掲載します。
+
+Tatami は [AGPL-3.0-only](LICENSE) で提供します。FlashSpace、yabai、その作者との提携や承認などの関係はありません。
+
+依存パッケージのライセンスと著作権の原文は [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) に掲載しています。
+
+
+
+
+## yabai(MIT ライセンス)
+
+```
+MIT License
+
+Copyright (c) 2019 Åsmund Vikane
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
+
+
diff --git a/NOTICE.ko.md b/NOTICE.ko.md
new file mode 100644
index 0000000..aa9b3e3
--- /dev/null
+++ b/NOTICE.ko.md
@@ -0,0 +1,49 @@
+
+[English](NOTICE.md) · [한국어](NOTICE.ko.md) · [日本語](NOTICE.ja.md) · [简体中文](NOTICE.zh-Hans.md) · [繁體中文](NOTICE.zh-Hant.md)
+
+
+
+# 프로젝트 고지
+
+Tatami는 포크가 아닌 독립 프로젝트예요. 다음 선행 작업을 참고했어요.
+
+- 가상 작업 공간 전환 개념은 GPL-3.0으로 제공되는 Wojciech Kulik의 [FlashSpace]에서 영감을 받았어요. 소스를 복사하지 않고 Tatami의 구조에서 동작을 다시 구현했어요.
+- 창 타일링 모델과 이동·균등 배치·회전·뒤집기·경계 기반 크기 조절은 MIT 라이선스인 koekeishiya의 [yabai]를 바탕으로 했어요. 해당 저작권 고지를 아래에 원문으로 실었어요.
+
+Tatami는 [AGPL-3.0-only](LICENSE)로 제공해요. FlashSpace, yabai 또는 저자와 제휴·보증 등의 관계는 없어요.
+
+패키지 의존성의 라이선스와 저작권 원문은 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 실었어요.
+
+
+
+
+## yabai(MIT 라이선스)
+
+```
+MIT License
+
+Copyright (c) 2019 Åsmund Vikane
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
+
+
diff --git a/NOTICE.md b/NOTICE.md
index a52dc82..35595e0 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -1,3 +1,7 @@
+
+[English](NOTICE.md) · [한국어](NOTICE.ko.md) · [日本語](NOTICE.ja.md) · [简体中文](NOTICE.zh-Hans.md) · [繁體中文](NOTICE.zh-Hant.md)
+
+
# Notice
Tatami is an independent project, not a fork. Its design draws on prior work:
diff --git a/NOTICE.zh-Hans.md b/NOTICE.zh-Hans.md
new file mode 100644
index 0000000..ed6781a
--- /dev/null
+++ b/NOTICE.zh-Hans.md
@@ -0,0 +1,49 @@
+
+[English](NOTICE.md) · [한국어](NOTICE.ko.md) · [日本語](NOTICE.ja.md) · [简体中文](NOTICE.zh-Hans.md) · [繁體中文](NOTICE.zh-Hant.md)
+
+
+
+# 项目声明
+
+Tatami 是独立项目,并非分支,其设计参考了以下工作:
+
+- 虚拟工作区切换概念受到 Wojciech Kulik 的 [FlashSpace](GPL-3.0)启发。Tatami 未复制其源码,而是在自身架构中重新实现相似行为。
+- 窗口平铺模型及 BSP 的移动、均衡、旋转、镜像、边界调整操作改编自 koekeishiya 的 MIT 许可项目 [yabai]。相关版权声明原文列于下方。
+
+Tatami 采用 [AGPL-3.0-only](LICENSE) 许可证,与 FlashSpace、yabai 及其作者不存在关联、背书或其他联系。
+
+依赖包的许可证与版权声明原文见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+
+
+
+
+## yabai(MIT 许可证)
+
+```
+MIT License
+
+Copyright (c) 2019 Åsmund Vikane
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
+
+
diff --git a/NOTICE.zh-Hant.md b/NOTICE.zh-Hant.md
new file mode 100644
index 0000000..6d0f063
--- /dev/null
+++ b/NOTICE.zh-Hant.md
@@ -0,0 +1,49 @@
+
+[English](NOTICE.md) · [한국어](NOTICE.ko.md) · [日本語](NOTICE.ja.md) · [简体中文](NOTICE.zh-Hans.md) · [繁體中文](NOTICE.zh-Hant.md)
+
+
+
+# 專案聲明
+
+Tatami 是獨立專案,並非分支,其設計參考了以下成果:
+
+- 虛擬工作空間切換概念受到 Wojciech Kulik 的 [FlashSpace](GPL-3.0)啟發。Tatami 未拷貝其原始碼,而是在自身架構中重新實作相似行為。
+- 視窗並排模型及 BSP 的移動、平均排列、旋轉、鏡像、邊界調整操作改編自 koekeishiya 的 MIT 授權專案 [yabai]。相關著作權聲明原文列於下方。
+
+Tatami 採用 [AGPL-3.0-only](LICENSE) 授權,與 FlashSpace、yabai 及其作者不存在關聯、背書或其他聯繫。
+
+相依套件的授權與著作權聲明原文見 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+
+
+
+
+## yabai(MIT 授權條款)
+
+```
+MIT License
+
+Copyright (c) 2019 Åsmund Vikane
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
+
+
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 0000000..1135596
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,219 @@
+
+[English](README.md) · [한국어](README.ko.md) · [日本語](README.ja.md) · [简体中文](README.zh-Hans.md) · [繁體中文](README.zh-Hant.md)
+
+
+
+# Tatami
+
+[](https://github.com/PangMo5/Tatami/releases/latest) [](https://github.com/PangMo5/Tatami/releases)  [](LICENSE)
+
+BSP タイル表示を備えた macOS 用ワークスペースマネージャです。
+
+Tatami はアプリを仮想ワークスペースにまとめ、キーや設定したトラックパッドのジェスチャで切り替えます。BSP エンジンがウインドウを自動で整えるので、SIP の変更やシェルスクリプトは不要です。
+
+
+## Tatami の使い方を見る
+
+[](https://pangmo5.dev/Tatami/ja/#demo)
+
+**[一連の作業を見る](https://pangmo5.dev/Tatami/ja/#demo)**。デザイン、執筆、レビュー、次の作業を借りる操作、集中環境の自動化、2 台目の画面への対応まで紹介します。実際の Tatami を使い、アプリと内容はデモ用です。表示するキーはこのデモの設定です。
+
+
+設定とガイドのスクリーンショット
+
+
+
+
+
+元のサイズ:ガイド設定 · ワークスペース · 借りる 以前のバージョンで撮影したため、一部の表記は現在と異なります。
+
+
+
+
+
+## 主な機能
+
+
+### ワークスペース
+[](https://pangmo5.dev/Tatami/ja/#demo-workspaces)
+
+
+- **仮想ワークスペース:** 作業ごとに必要なアプリを割り当ててまとめます。
+- **使いやすく切り替え:** ショートカット、トラックパッドのジェスチャ、直前のワークスペースへの移動を使えます。
+- **ジェスチャ設定:** 3 本指・4 本指の各方向に操作を割り当てられます。特定のプロファイルやワークスペースへの移動も選べます。
+- **ワークスペースごとに一つのキー:** 切り替え・割り当て・借りる操作の修飾キーと**ワークスペースキー**を組み合わせます。直前・次・前への移動にも使え、操作別の上書きもできます。
+- **切り替え方を選択:** 端での循環、空の作業をスキップ、アプリのフォーカスへの追従を設定できます。
+- **[自動で開くデモ](https://pangmo5.dev/Tatami/ja/#demo-autoopen):** 作業を開くと割り当てたアプリが起動し、閉じたウインドウも入り直すと開きます。
+- **画面ごとの作業:** 特定の画面に固定するか、ポインタのある画面に開けます。再起動後も各画面の現在・直前の作業を記憶し、次・前・直前への移動範囲を個別に設定できます。
+- **ワークスペースチェーン:** プロファイル内の作業を順に連結します。どのメンバーを選んでも、優先順・接続中の画面固定・動的配置に従って仲間を復元し、選んだ作業にフォーカスを残します。
+- **画面をまたぐ操作:** 別の画面へフォーカスを移したり、選んだアプリを別の作業へ送ったりできます。
+- **共有アプリ:** すべてのワークスペースで使うアプリを追加できます。
+
+
+### ウインドウのタイル表示(BSP)
+[](https://pangmo5.dev/Tatami/ja/#demo-tiling)
+
+
+- **自動 BSP 配置:** 新しいウインドウは、現在の挿入先またはフォーカス中のタイルの隣に配置します。どちらもなければ、ツリーで最も浅いタイルを使います。
+- **キーボード操作:** 推奨設定では、`ctrl + alt` と `h`、`j`、`k`、`l` でフォーカスを移動し、矢印キーでウインドウを入れ替え、`=` または `-` でフォーカス中のタイルを拡大・縮小します。
+- **ウインドウを選択:** 短く押すとすぐ切り替わり、修飾キーを長く押すと一覧が開きます。ポインタの画面内で動作し、共有アプリも含められます。状態を見ながらキーやマウスで選べます。
+- **ズームと分割:** 一つのウインドウを作業全体に広げたり、分割方向を切り替えたりできます。
+- **配置の変換:** 回転、反転、均等配置が使えます。
+- **ドラッグ編集:** プレビューを見ながら入れ替えや挿入ができます。端を手で調整した比率も配置に反映されます。
+- **配置を記憶:** 作業の切り替え、再起動、スリープ後も、ツリーと比率を保ちます。
+- **余白の設定:** 内側と外側の余白を選べます。
+
+
+### プロファイル
+
+[プロファイルと 2 画面の動画を見る](https://pangmo5.dev/Tatami/ja/#profiles)。
+
+- **独立したプロファイル:** 作業をまとめ、構成全体を切り替えます。作業、アプリの割り当て、ショートカットを個別に持ちます。
+- **すばやい切り替え:** キーやメニューバーで切り替えます。全画面を新しい構成に整え、再起動後も適切なプロファイルに戻ります。
+- **画面に合わせて有効化:** 画面数や特定の画面の接続・取り外しで切り替えます。同じ優先度で条件が重なると警告します。
+- **プロファイルのアイコン:** サイドバー、メニューバー、切り替え時に表示する SF Symbol を選べます。
+- **構成を再利用:** **コピー元**と**複製**は共通のプレビューで確認できます。変更前に作業、アプリ、設定、保存済み配置を含めるか選べます。
+
+
+### 借りる:二つの作業を組み合わせる
+[](https://pangmo5.dev/Tatami/ja/#demo-borrow)
+
+
+- **並べて使う:** 別の作業を画面の端に借りて、両方を独立して配置します。ウインドウは境界を越えません。
+- **実際の作業をそのまま:** 借りた領域は実際の作業なので、編集内容も元の場所に残ります。
+- **方向を選択:** 借りる修飾キーと作業のキーを押してから、`h`、`j`、`k`、`l` または矢印を使います。全体・作業別の既定位置と大きさも設定できます。
+- **境界を越えるフォーカスと切り替え:** 方向操作と MFF で両領域を行き来できます。返すまでは両側のタイルが同じ切り替え順に入り、有効化すると完全に移動、再度借りると通常は返却します。`esc` で配置を取り消せます。
+- **所属を表示:** 借りたウインドウに、元のワークスペースのアイコンを表示します。
+- **一時スペース:** 借りるための作業です。通常の切り替えや単独の有効化には入らず、呼び出すとアプリが開きます。
+
+
+### 常に手前
+
+[作業が変わっても同じ状態ウインドウを見る](https://pangmo5.dev/Tatami/ja/#demo-shared)。
+
+- **作業別・共有:** 一つの作業だけで手前に置くか、共有アプリにしてどこでも表示できます。
+- **SIP 変更不要:** ScreenCaptureKit のミラーを手前に表示し、操作時に実際のウインドウへ渡します。
+- **わかりやすい重なり順:** 手前のウインドウは最近選んだ順に重なります。画面収録の許可が必要です。
+- **そのまま:** 位置と大きさを保ちながら、自動起動、フォーカス、マウス追従、切り替えに参加します。ミラーや画面収録の許可は不要です。
+
+
+### フォーカスとカーソル
+
+[キーとポインタに合わせたフォーカス移動を見る](https://pangmo5.dev/Tatami/ja/#demo-focus)。
+
+- **二つの方式:** FFM はポインタの下にキーボードフォーカスを移し、MFF は Tatami が選んだウインドウへポインタを移します。手前・共有の手前・そのままのウインドウにも対応します。
+- **閉じた後のフォーカス:** 残った中で最近使ったウインドウへ戻ります。
+- **カーソル操作:** ワークスペースの切り替え中にカーソルを隠せます。
+
+
+### 画面と設定
+
+[実際の CLI、フック、設定の即時反映を見る](https://pangmo5.dev/Tatami/ja/#automation)。
+
+- **5 言語:** macOS のアプリ言語に合わせ、英語・韓国語・日本語・簡体字・繁体字(台湾)で使えます。
+- **メニューバーの設定:** 現在の作業のアイコン・名前と、必要に応じてプロファイルの情報を表示できます。
+- **操作フィードバック:** 作業、プロファイル、手前表示、割り当て、配置、借りる操作の結果を対象の画面に短く表示します。9 か所・3 サイズから選び、同じ多言語の内容を HUD フックでほかの場所へ送れます。
+- **作業のアイコン:** ワークスペースごとに SF Symbol を選べます。
+- **ネイティブの設定:** SwiftUI の設定画面で操作できます。
+- **skhd 形式:** `ctrl + alt - h` のように書けます。
+- **TOML 設定:** `~/.config/tatami/config.toml` を直接編集できます。XDG と即時反映に対応します。
+- **フック編集:** **設定 → フック**で状態・操作フィードバックのフックを追加、編集、削除、有効・無効化できます。実行ファイル、引数、環境、作業フォルダ、時間制限も指定できます。
+- **CLI 自動化:** `tatami workspace activate ` や `tatami workspace list` などのコマンドをスクリプトで使えます。
+- **自動更新:** Sparkle で新しいリリースを受け取れます。
+
+
+### 使いながら設定
+
+[ショートカットの練習と提案の確認を見る](https://pangmo5.dev/Tatami/ja/#guided-setup)。
+
+- **使いながら学ぶ:** 初回起動時に安全な仮想画面で、作業、切り替え・ジェスチャ、BSP、借りる・一時スペース、手前・そのまま、MFF・FFM、アプリ・ウインドウ切り替えを順に学べます。
+- **この Mac から開始:** 実行中アプリの情報と接続画面から下書きを作ります。一般的な分類ではなく繰り返す作業に合わせ、画面の内容は取得しません。
+- **任意の AI 提案:** ChatGPT、Claude、Gemini などや、対応 Mac の Apple Intelligence の作業案を確認できます。適用するまでは提案のままです。
+- **続けて練習:** 実際のキーやジェスチャでプレビューを操作し、前のレッスンの操作も続けて使えます。
+- **まず下書き:** **設定を適用**するまで実際のウインドウや `config.toml` は変えません。**設定 → 一般**からいつでも再開できます。
+
+
+## 必要な環境
+
+- macOS 14.0 以降
+- アクセシビリティの許可(システム設定 → プライバシーとセキュリティ → アクセシビリティ)
+- 「常に手前」を使う場合だけ画面収録の許可が必要です。ScreenCaptureKit でウインドウを表示します(システム設定 → プライバシーとセキュリティ → 画面収録)。
+
+
+## インストール
+
+
+### Homebrew
+
+```sh
+brew install --cask pangmo5/tap/tatami
+```
+
+[最新リリース](https://github.com/PangMo5/Tatami/releases/latest)から署名・公証済みの `.dmg` を入手することもできます。各リリースには、その版に対応するソースもあります。
+
+
+### ソースからビルド
+
+Xcode 26 以降と Swift 6.2 以降のツールチェーンを使ってください。アプリは macOS 14 以降で動作します。実行環境とビルドツールの要件は別です。
+
+```sh
+brew install tuist # or: mise install
+tuist install && tuist generate --no-open
+open Tatami.xcworkspace
+```
+
+
+## 設定と自動化
+
+
+### 設定ガイド
+
+設定は `~/.config/tatami/config.toml` に保存され、`[settings.layout]`、`[settings.focus]`、`[settings.gestures]`、`[settings.shortcuts]` などのテーブルに分かれます。作業、アプリ、共有アプリも同じファイルです。フックは**設定 → フック**か `[[hooks]]` で管理します。GUI は `command` をシェル文字列にせず、実行ファイルと引数を分けて扱い、変更を即時反映します。
+
+全項目、既定値、ショートカットの書き方は [docs/CONFIGURATION.md](docs/ja/CONFIGURATION.md) を参照してください。
+
+会議の操作パネルや作業を切り替えたときのピクチャインピクチャなど、アプリ別の動作は[トラブルシューティング](docs/ja/TROUBLESHOOTING.md)を参照してください。
+
+
+### コマンドライン
+
+アプリ内に `tatami` CLI を同梱しています。**設定 → 一般 → コマンドライン → インストール**で `tatami` を `/usr/local/bin` にリンクします。パスワードの入力は 1 回です。インストール後は次のように使います。
+
+```sh
+tatami workspace list
+tatami workspace activate "Browser"
+tatami profile activate "Dual"
+tatami window focus left
+tatami layout balance
+```
+
+CLI はプロファイル・作業の管理、フックの確認、安定した JSON 出力に加え、ジェスチャと同じフォーカス、配置、アプリ、タイル表示、切り替え、借りる操作を分類して提供します。Tatami の起動が必要です。
+
+[CLI の完全なガイド](docs/ja/CLI.md)、または [pangmo5.dev/Tatami](https://pangmo5.dev/Tatami/ja/cli.html) の Web 版を参照してください。
+
+
+## 技術構成
+
+- **Tuist:** プロジェクト生成
+- **The Composable Architecture(TCA):** アプリの構成
+- **swift-sharing:** 機能間の状態共有
+- **swift-collections:** タイル処理の高速経路で使う順序付き集合、辞書、deque
+- **swift-toml:** 設定の保存
+- **swift-subprocess:** キャンセル・時間制限付きのフック実行
+- **swift-yyjson:** 配置保存と CLI 通信の高速 JSON
+- **Magnet:** Carbon ベースのグローバルショートカット
+- **SFSafeSymbols:** 型安全な SF Symbol カタログ
+- **Sparkle:** アプリの更新
+
+
+## 参考にしたプロジェクト
+
+Tatami は Wojciech Kulik の [FlashSpace](仮想作業の切り替え)と koekeishiya の [yabai](タイル表示のモデル)を参考にしています。帰属は [NOTICE.md](NOTICE.ja.md)、依存ライセンスは [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) を参照してください。
+
+
+## ライセンス
+
+[AGPL-3.0-only](LICENSE)。
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
diff --git a/README.ko.md b/README.ko.md
new file mode 100644
index 0000000..2d2a0bb
--- /dev/null
+++ b/README.ko.md
@@ -0,0 +1,219 @@
+
+[English](README.md) · [한국어](README.ko.md) · [日本語](README.ja.md) · [简体中文](README.zh-Hans.md) · [繁體中文](README.zh-Hant.md)
+
+
+
+# Tatami
+
+[](https://github.com/PangMo5/Tatami/releases/latest) [](https://github.com/PangMo5/Tatami/releases)  [](LICENSE)
+
+BSP 창 타일링을 갖춘 macOS 작업 공간 관리자예요.
+
+Tatami는 앱을 가상 작업 공간에 모으고, 단축키나 설정한 트랙패드 제스처로 전환해요. BSP 엔진이 창을 자동으로 정리해줘요. SIP 설정을 바꾸거나 셸 스크립트를 작성할 필요가 없어요.
+
+
+## Tatami 사용 모습
+
+[](https://pangmo5.dev/Tatami/ko/#demo)
+
+**[전체 작업 흐름 보기](https://pangmo5.dev/Tatami/ko/#demo)**. 디자인, 글쓰기, 검토, 다음 할 일 빌려오기, 집중 환경 자동화, 두 번째 화면 연결까지 이어져요. 실제 Tatami로 창을 관리하며 앱과 내용은 데모용이에요. 표시한 단축키는 이 데모에서 설정한 키예요.
+
+
+설정과 시작 안내 스크린샷
+
+
+
+
+
+원본 크기: 가이드 설정 · 작업 공간 · 빌려오기 이전 버전에서 촬영했어요. 일부 문구는 현재 버전과 달라요.
+
+
+
+
+
+## 주요 기능
+
+
+### 작업 공간
+[](https://pangmo5.dev/Tatami/ko/#demo-workspaces)
+
+
+- **가상 작업 공간:** 작업 공간마다 필요한 앱을 배정해서 모아요.
+- **편한 방식으로 전환:** 단축키, 트랙패드 제스처, 최근 작업 공간 이동을 사용할 수 있어요.
+- **제스처 설정:** 세 손가락·네 손가락 쓸기의 모든 방향에 동작을 연결해요. 특정 프로필이나 작업 공간으로 가는 명령도 연결할 수 있어요.
+- **작업 공간마다 키 하나:** 전환·배정·빌려오기 보조키와 **작업 공간 키**를 함께 눌러요. 같은 키 체계로 최근·다음·이전 작업 공간에도 이동할 수 있고, 각 동작에 별도 단축키를 지정할 수도 있어요.
+- **전환 방식 선택:** 끝에서 처음으로 돌아가기, 빈 공간 건너뛰기, 앱 포커스 따라가기를 설정할 수 있어요.
+- **[자동 열기 데모](https://pangmo5.dev/Tatami/ko/#demo-autoopen):** 작업 공간에 들어가면 배정한 앱이 열려요. 창을 닫아도 다시 들어가면 열어줘요.
+- **화면별 작업 공간:** 특정 화면에 고정하거나 마우스가 있는 화면에 열 수 있어요. 각 화면은 재실행 후에도 활성·최근 작업 공간을 기억해요. 다음·이전·최근 이동의 범위는 각각 현재 화면이나 전체 화면으로 정할 수 있어요.
+- **작업 공간 체인:** 프로필 안의 작업 공간을 순서대로 연결해요. 어느 구성원을 선택해도 체인 우선순위, 연결된 화면 고정, 동적 배치에 따라 함께 놓여요. 포커스는 선택한 작업에 남아요.
+- **화면 간 조작:** 다른 화면으로 포커스를 옮기거나 포커스한 앱을 다른 작업 공간에 보낼 수 있어요.
+- **공용 앱:** 모든 작업 공간에서 함께 쓸 앱을 추가해요.
+
+
+### 창 타일링(BSP)
+[](https://pangmo5.dev/Tatami/ko/#demo-tiling)
+
+
+- **자동 BSP 배치:** 새 창을 현재 삽입 지점이나 포커스된 타일 옆에 배치해요. 둘 다 없으면 트리에서 가장 얕은 타일을 사용해요.
+- **키보드 조작:** 권장 단축키에서는 `ctrl + alt`과 `h`, `j`, `k`, `l`로 포커스를 옮기고, 화살표 키로 창 위치를 바꾸며, `=` 또는 `-`으로 포커스된 타일을 키우거나 줄여요.
+- **창 선택:** 전환 단축키를 짧게 누르면 바로 전환하고, 보조키를 길게 누르면 앱·창 목록이 나타나요. 포인터가 있는 화면 안에서 동작하고 공용 앱도 포함할 수 있어요. 각 창의 상태를 보며 키보드나 마우스로 정확히 고를 수 있어요.
+- **확대와 분할:** 창 하나를 작업 공간 크기로 펼치거나 나누는 방향을 바꿔요.
+- **배치 변환:** 회전·뒤집기·균등 배치를 사용할 수 있어요.
+- **드래그 편집:** 미리보기를 보며 창을 교체하거나 다른 자리에 넣어요. 가장자리를 직접 조절한 비율도 배치에 반영돼요.
+- **배치 기억:** 작업 공간 전환, 앱 재실행, 시스템 잠자기 후에도 배치 트리와 비율이 남아요.
+- **여백 설정:** 창 사이와 바깥 여백을 정할 수 있어요.
+
+
+### 프로필
+
+[프로필과 두 화면 영상 모음 보기](https://pangmo5.dev/Tatami/ko/#profiles).
+
+- **독립 프로필:** 작업 공간을 묶어 전체 구성을 한 번에 바꿔요. 프로필마다 작업 공간·앱 배정·단축키를 따로 저장해요.
+- **빠른 프로필 전환:** 단축키나 메뉴 막대에서 전환해요. 모든 화면을 새 구성으로 정리하고 재실행 후에도 알맞은 프로필로 돌아와요.
+- **화면에 맞춰 활성화:** 화면 수나 특정 화면의 연결·해제에 따라 자동 전환해요. 같은 우선순위의 규칙이 겹치면 알려줘요.
+- **프로필 아이콘:** 사이드바·메뉴 막대·전환 알림에 표시할 SF Symbol을 정해요.
+- **기존 구성 재사용:** **가져오기**와 **복제**에서 같은 미리보기를 사용해요. 변경 전에 작업 공간·앱·설정·저장한 배치를 골라 포함하거나 제외할 수 있어요.
+
+
+### 빌려오기: 두 작업 공간 함께 쓰기
+[](https://pangmo5.dev/Tatami/ko/#demo-borrow)
+
+
+- **나란히 사용:** 다른 작업 공간을 화면 가장자리로 빌려와요. 두 영역은 따로 타일링되며 창은 경계를 넘지 않아요.
+- **실제 작업 그대로:** 빌려온 영역은 실제 작업 공간이에요. 편집한 내용은 원래 공간에도 남아요.
+- **방향 선택:** 빌려오기 보조키와 작업 공간 키를 누른 뒤 `h`, `j`, `k`, `l` 또는 화살표를 눌러요. 전체 또는 작업 공간별 기본 위치와 크기도 설정할 수 있어요.
+- **경계를 넘는 포커스와 전환:** 방향 포커스와 포인터 따라가기가 두 영역을 오가요. 돌려보내기 전까지 두 영역의 타일링 창을 함께 전환해요. 빌려온 공간을 활성화하면 완전히 전환하고 다시 빌리면 기본적으로 돌려보내요. `esc`로 위치 선택을 취소해요.
+- **소속 표시:** 빌려온 창에는 해당 작업 공간의 아이콘이 보여요.
+- **임시 공간:** 빌려오기로만 쓰는 공간이에요. 일반 전환이나 단독 활성화에는 포함되지 않고, 꺼낼 때 필요한 앱이 열려요.
+
+
+### 항상 위
+
+[여러 작업 공간 위에서 같은 상태 창 보기](https://pangmo5.dev/Tatami/ko/#demo-shared).
+
+- **작업 공간별 또는 공용:** 한 공간에서만 앱을 항상 위에 두거나 공용 앱으로 추가해 어디서나 볼 수 있어요.
+- **SIP 변경 불필요:** ScreenCaptureKit 미러를 항상 위에 두고, 조작할 때 실제 창으로 연결해요.
+- **예측 가능한 순서:** 항상 위에 둔 여러 창은 최근 포커스한 순서로 쌓여요. 화면 기록 권한이 필요해요.
+- **그대로 두기:** 창의 위치와 크기를 유지하면서 자동 열기·포커스·마우스에 따른 포커스·창 전환에 참여해요. 미러나 화면 기록 권한 없이 사용할 수 있어요.
+
+
+### 포커스와 커서
+
+[키보드와 포인터에 따른 포커스 이동 보기](https://pangmo5.dev/Tatami/ko/#demo-focus).
+
+- **두 가지 포커스 방식:** 마우스 따라가기는 포인터 아래 창에 키보드 포커스를 줘요. 포커스 따라가기는 Tatami가 창을 바꿀 때 포인터를 옮겨요. 항상 위·공용 항상 위·그대로 두기 창에서도 동작해요.
+- **닫은 뒤 포커스:** 남은 창 중 가장 최근에 쓰던 창으로 돌아가요.
+- **커서 제어:** 작업 공간을 전환하는 동안 커서를 숨길 수 있어요.
+
+
+### 화면과 설정
+
+[실제 CLI·훅·설정 바로 적용 보기](https://pangmo5.dev/Tatami/ko/#automation).
+
+- **다섯 언어:** macOS 앱 언어 설정에 따라 영어·한국어·일본어·중국어 간체·대만 번체로 쓸 수 있어요.
+- **메뉴 막대 설정:** 활성 작업 공간의 아이콘·이름과 필요한 경우 프로필의 아이콘·이름을 표시해요.
+- **화면 알림:** 작업 공간·프로필·항상 위·앱 배정·배치·빌려오기 결과를 해당 화면에서 짧은 애니메이션으로 알려줘요. 아홉 위치와 세 크기를 고르거나, 훅으로 같은 다국어 알림을 다른 도구에 전달할 수 있어요.
+- **작업 공간 아이콘:** 작업 공간마다 SF Symbol을 고를 수 있어요.
+- **네이티브 설정:** SwiftUI로 만든 설정 화면을 사용해요.
+- **skhd 형식 단축키:** 예를 들어 `ctrl + alt - h`처럼 써요.
+- **TOML 설정:** `~/.config/tatami/config.toml`을 직접 수정할 수 있어요. XDG 경로와 설정 바로 적용을 지원해요.
+- **훅 편집기:** **설정 → 훅**에서 수명 주기·화면 알림 훅을 추가·수정·삭제하거나 켜고 끌 수 있어요. 실행 파일, 인자, 환경 변수, 작업 폴더, 제한 시간도 지정해요.
+- **CLI 자동화:** `tatami workspace activate `, `tatami workspace list` 같은 명령을 스크립트에서 사용할 수 있어요.
+- **자동 업데이트:** Sparkle로 새 릴리스를 받아요.
+
+
+### 시작 안내
+
+[단축키 연습과 제안 검토 보기](https://pangmo5.dev/Tatami/ko/#guided-setup).
+
+- **직접 배우기:** 처음 실행하면 안전한 가상 화면에서 작업 공간, 전환·제스처, BSP 타일링, 빌려오기·임시 공간, 항상 위·그대로 두기, 포커스·포인터 따라가기, 앱·창 전환을 차례로 익혀요.
+- **이 Mac에서 시작:** 실행 중인 앱 정보와 연결된 화면을 바탕으로 초안을 만들어요. 일반적인 분류보다 반복하는 작업에 맞춰 구성하고 화면 내용은 수집하지 않아요.
+- **선택할 수 있는 AI 구성:** ChatGPT, Claude, Gemini 등 원하는 AI나 지원하는 Mac의 Apple Intelligence가 제안한 구성을 검토해요. 적용하기 전까지는 제안으로만 남아요.
+- **이어서 연습:** 실제 단축키와 제스처로 미리보기를 조작해요. 다음 단계에서도 앞서 익힌 명령을 계속 쓸 수 있어요.
+- **초안 먼저:** **설정 적용** 전에는 실제 창을 움직이거나 `config.toml`에 쓰지 않아요. **설정 → 일반**에서 언제든 다시 열 수 있어요.
+
+
+## 필요한 환경
+
+- macOS 14.0 이상
+- 손쉬운 사용 권한(시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용)
+- 항상 위 기능을 사용할 때만 화면 기록 권한이 필요해요. 이 기능은 ScreenCaptureKit으로 창을 캡처해 보여줘요(시스템 설정 → 개인정보 보호 및 보안 → 화면 기록).
+
+
+## 설치
+
+
+### Homebrew
+
+```sh
+brew install --cask pangmo5/tap/tatami
+```
+
+[최신 릴리스](https://github.com/PangMo5/Tatami/releases/latest)에서 서명·공증된 `.dmg`을 내려받을 수도 있어요. 각 릴리스에는 해당 버전의 정확한 소스 코드도 연결되어 있어요.
+
+
+### 소스에서 빌드
+
+Xcode 26 이상과 Swift 6.2 이상의 툴체인을 사용해요. 앱은 macOS 14 이상에서 실행되며, 실행 환경과 빌드 도구의 요구사항은 별개예요.
+
+```sh
+brew install tuist # or: mise install
+tuist install && tuist generate --no-open
+open Tatami.xcworkspace
+```
+
+
+## 설정과 자동화
+
+
+### 설정 안내
+
+설정은 `~/.config/tatami/config.toml`에 저장돼요. `[settings.layout]`, `[settings.focus]`, `[settings.gestures]`, `[settings.shortcuts]` 같은 표로 나뉘며 작업 공간·앱 배정·공용 앱도 같은 파일에 있어요. 훅은 **설정 → 훅**이나 파일의 `[[hooks]]`에서 관리해요. GUI는 `command`을 셸 문자열로 합치지 않고 실행 파일과 각 인자를 따로 다뤄요. 앱이나 파일에서 수정하면 바로 반영돼요.
+
+모든 항목과 기본값, 단축키 문법은 [docs/CONFIGURATION.md](docs/ko/CONFIGURATION.md)에서 확인해요.
+
+회의 조작 창이나 작업 공간 전환 중 화면 속 화면처럼 앱마다 다른 동작은 [문제 해결](docs/ko/TROUBLESHOOTING.md)에서 확인해요.
+
+
+### 커맨드라인
+
+앱 번들 안에 `tatami` CLI가 들어 있어요. **설정 → 일반 → 명령줄 → 설치**에서 설치하세요. `tatami`을 `/usr/local/bin`에 심볼릭 링크로 연결하며 암호를 한 번 요청해요. 설치 후에는 다음처럼 사용해요.
+
+```sh
+tatami workspace list
+tatami workspace activate "Browser"
+tatami profile activate "Dual"
+tatami window focus left
+tatami layout balance
+```
+
+CLI는 프로필·작업 공간 관리, 훅 조회, 안정된 JSON 출력과 제스처로 실행할 수 있는 포커스·배치·앱·타일링·전환·빌려오기 명령을 분야별로 제공해요. Tatami가 실행 중이어야 해요.
+
+[전체 CLI 안내](docs/ko/CLI.md)를 읽거나 [pangmo5.dev/Tatami](https://pangmo5.dev/Tatami/ko/cli.html)에서 웹으로 확인할 수 있어요.
+
+
+## 기술 구성
+
+- **Tuist:** 프로젝트 생성
+- **The Composable Architecture(TCA):** 앱 구조
+- **swift-sharing:** 기능 간 상태 공유
+- **swift-collections:** 타일링 핵심 경로의 순서 있는 집합·사전·데크
+- **swift-toml:** 설정 저장
+- **swift-subprocess:** 취소 가능하고 실행 시간이 제한된 훅
+- **swift-yyjson:** 배치 저장소와 CLI 통신의 빠른 JSON 처리
+- **Magnet:** Carbon 기반 전역 단축키
+- **SFSafeSymbols:** 타입으로 검증하는 SF Symbol 목록
+- **Sparkle:** 앱 업데이트
+
+
+## 도움을 준 프로젝트
+
+Tatami는 Wojciech Kulik의 [FlashSpace]에서 가상 작업 공간 전환 개념을, koekeishiya의 [yabai]에서 창 타일링 방식을 참고했어요. 출처는 [NOTICE.md](NOTICE.ko.md), 의존성 라이선스는 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에서 확인할 수 있어요.
+
+
+## 라이선스
+
+[AGPL-3.0-only](LICENSE).
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
diff --git a/README.md b/README.md
index 683d798..0f01d3a 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+[English](README.md) · [한국어](README.ko.md) · [日本語](README.ja.md) · [简体中文](README.zh-Hans.md) · [繁體中文](README.zh-Hant.md)
+
+
# Tatami
[](https://github.com/PangMo5/Tatami/releases/latest)
@@ -12,7 +16,17 @@ keystroke or a configurable trackpad gesture, and tiles their windows
automatically with a binary space partitioning (BSP) engine. No SIP changes and no shell
scripting required.
-## Screenshots
+## See Tatami in action
+
+[](https://pangmo5.dev/Tatami#demo)
+
+**[Watch the complete workflow](https://pangmo5.dev/Tatami#demo)** — design, write,
+review, borrow a follow-up, automate a focus session, and adapt to a second display.
+Real Tatami window management; demo apps and sample content. The displayed
+shortcuts are this demo's configured bindings.
+
+
+Settings and Guided Setup screenshots
@@ -24,37 +38,32 @@ scripting required.
Borrow
Captured on an earlier version. Some labels have changed since these screenshots.
-## Demo
-
-
-
-
-
-
-▶ Watch the demo
-
-Recorded on an early version. It will be re-recorded for a later release.
+
## Features
### Workspaces
+[](https://pangmo5.dev/Tatami#demo-workspaces)
+
- **Virtual workspaces:** Group apps with per-workspace assignments.
- **Flexible switching:** Use a hotkey, trackpad gesture, or recent-workspace action.
- **Configurable gestures:** Bind every direction of a three- or four-finger swipe to any shortcut action, including profile- and workspace-specific commands.
- **One key per workspace:** Hold the switch, assign, or borrow modifier with a **key equivalent**. The same keys also drive recent, next, and previous targets, while any action can take an explicit override.
- **Optional switching behaviors:** Enable loop-around, skip-empty, or follow-app-focus.
-- **Auto-open:** Launch assigned apps when a workspace activates and reopen them on re-entry if their window was closed.
+- **[Auto-open demo](https://pangmo5.dev/Tatami#demo-autoopen):** Launch assigned apps when a workspace activates and reopen them on re-entry if their window was closed.
- **Per-display workspaces:** Pin a workspace to a display or have dynamic workspaces open under the mouse. Each display keeps its own active and recent workspace across relaunches, while next/previous and recent navigation can independently stay local or span every display.
- **Workspace chains:** Link an ordered set of workspaces inside a profile. Switching to any member restores available companions in chain priority, honoring connected display pins and Chain Dynamic placement while focus stays with the workspace you chose.
- **Cross-display control:** Jump focus between displays or move the focused app to another workspace.
- **Shared apps:** Add apps that should join every workspace.
### Window tiling (BSP)
+[](https://pangmo5.dev/Tatami#demo-tiling)
-- **Automatic BSP layout:** Insert new windows at the shallowest tile.
-- **Keyboard operations:** Focus, swap, and resize directionally with vim-like `h`, `j`, `k`, and `l` keys.
+
+- **Automatic BSP layout:** Insert new windows beside the current insertion target or focused tile. Use the shallowest tile when neither is available.
+- **Keyboard operations:** With the recommended bindings, use `ctrl + alt` with `h`, `j`, `k`, or `l` to focus, arrow keys to swap, and `=` or `-` to grow or shrink the focused tile.
- **Interactive window switching:** Tap the window-switch shortcut for an immediate switch, or hold its modifier for a compact app / window switcher. It stays scoped to the display under the pointer, can include Shared Apps, shows each window's current state, and lets you choose the exact target with the keyboard or pointer.
- **Zoom and splits:** Fill the workspace with one window or toggle split orientation.
- **Tree transforms:** Rotate, mirror, and balance the layout.
@@ -64,6 +73,8 @@ Captured on an earlier version. Some labels have changed since these screenshots
### Profiles
+[Watch the profile and two-display collection](https://pangmo5.dev/Tatami#profiles).
+
- **Independent profiles:** Group workspaces and switch the whole set at once. Each profile keeps its own workspaces, app assignments, and shortcuts.
- **Fast profile switching:** Switch by hotkey or from the menu bar. Every display re-tiles for the new profile, and Tatami returns to the right profile after relaunch.
- **Display-aware activation:** Auto-activate a profile by monitor count or by specific displays being connected or disconnected. Tatami warns when rules overlap at the same priority.
@@ -71,6 +82,8 @@ Captured on an earlier version. Some labels have changed since these screenshots
- **Reuse an existing setup:** **Copy from** and **Duplicate** share one preview where you keep or skip each workspace, app, setting, and saved layout before anything changes.
### Borrow: compose two workspaces
+[](https://pangmo5.dev/Tatami#demo-borrow)
+
- **Side-by-side composition:** Pull another workspace beside the current one, dock it to any screen edge, and tile both blocks independently. Windows cannot cross the boundary.
- **Live and bidirectional:** The borrowed block is the real workspace, so edits persist back to it.
@@ -81,6 +94,8 @@ Captured on an earlier version. Some labels have changed since these screenshots
### Always on Top
+[Watch: one shared monitor above different workspaces](https://pangmo5.dev/Tatami#demo-shared).
+
- **Per-workspace or shared:** Keep an app on top in one workspace or add it to Shared Apps to keep it on top everywhere.
- **No SIP changes:** Tatami uses always-on-top ScreenCaptureKit mirrors, then hands you the real window when you interact with it.
- **Predictable stacking:** Multiple always-on-top windows stack by focus recency. This requires Screen Recording permission.
@@ -88,12 +103,16 @@ Captured on an earlier version. Some labels have changed since these screenshots
### Focus & cursor
+[Watch focus follow the keyboard and pointer](https://pangmo5.dev/Tatami#demo-focus).
+
- **Two explicit focus models:** Focus-follows-mouse gives the window under the pointer keyboard focus. Mouse-follows-focus moves the pointer after Tatami changes windows, including switching to Always on Top, Shared Always on Top, or Leave As Is windows.
- **Close-window refocus:** Return to the most recently used remaining window.
- **Cursor control:** Optionally hide the cursor during a workspace switch.
### Interface & config
+[Watch real CLI commands, hooks and live configuration](https://pangmo5.dev/Tatami#automation).
+
- **Five interface languages:** Use Tatami in English, Korean, Japanese, Simplified Chinese, or Traditional Chinese, following your macOS app-language preference.
- **Customizable menu bar:** Show the active workspace icon or name and, when relevant, the active profile icon or name.
- **Adaptive on-screen feedback:** Compact spring feedback confirms workspace, profile, Always on Top, membership, layout, and Borrow actions on the display they affect. Choose one of nine positions and three sizes, or mirror the same localized payload to another surface with a HUD hook.
@@ -107,6 +126,8 @@ Captured on an earlier version. Some labels have changed since these screenshots
### Guided setup
+[Watch shortcut practice and proposal review](https://pangmo5.dev/Tatami#guided-setup).
+
- **Learn by doing:** First launch walks through Workspaces, switching and gestures, BSP tiling, Borrow and scratchpads, Always on Top / Leave As Is, MFF / FFM, and app / window switching in a safe virtual display.
- **Built from this Mac:** Start from running-app metadata and connected-display geometry, then organize apps around repeatable work rather than generic categories. No screen contents are captured.
- **Optional AI planning:** Review a task-oriented proposal from ChatGPT, Claude, Gemini, another AI, or the on-device Apple Intelligence model on supported Macs. AI output remains a proposal until you apply it.
@@ -136,6 +157,9 @@ release also links its exact corresponding source archive.
### Build from source
+Use Xcode 26 or later with a Swift 6.2 or later toolchain. The app runs on macOS
+14 or later; its runtime requirement is separate from the tools needed to build it.
+
```sh
brew install tuist # or: mise install
tuist install && tuist generate --no-open
diff --git a/README.zh-Hans.md b/README.zh-Hans.md
new file mode 100644
index 0000000..8c8ab3e
--- /dev/null
+++ b/README.zh-Hans.md
@@ -0,0 +1,219 @@
+
+[English](README.md) · [한국어](README.ko.md) · [日本語](README.ja.md) · [简体中文](README.zh-Hans.md) · [繁體中文](README.zh-Hant.md)
+
+
+
+# Tatami
+
+[](https://github.com/PangMo5/Tatami/releases/latest) [](https://github.com/PangMo5/Tatami/releases)  [](LICENSE)
+
+支持 BSP 窗口平铺的 macOS 工作区管理器。
+
+Tatami 把应用组织到虚拟工作区,通过快捷键或自定义触控板手势切换,并用二叉空间分割(BSP)引擎自动平铺窗口。无需修改 SIP,也无需编写 shell 脚本。
+
+
+## 看看 Tatami 的实际操作
+
+[](https://pangmo5.dev/Tatami/zh-Hans/#demo)
+
+**[观看完整工作流程](https://pangmo5.dev/Tatami/zh-Hans/#demo)**:设计、写作、评审、借用待办事项、自动准备专注环境,再连接第二个显示器。窗口由实际的 Tatami 管理,应用和内容为演示素材。快捷键采用本次演示的配置。
+
+
+设置与设置向导截图
+
+
+
+
+
+原图:引导设置 · 工作区 · 借用 截图来自较早版本,部分标签已更新。
+
+
+
+
+
+## 主要功能
+
+
+### 工作区
+[](https://pangmo5.dev/Tatami/zh-Hans/#demo-workspaces)
+
+
+- **虚拟工作区:** 按工作区分配和组织应用。
+- **灵活切换:** 使用快捷键、触控板手势或最近工作区操作。
+- **自定义手势:** 为三指、四指滑动的各个方向绑定快捷键操作,也可以指定某个配置方案或工作区。
+- **每个工作区一个按键:** 将切换、分配或借用修饰键与**工作区按键**组合使用。同一套按键也用于最近、下一个和上一个目标,每项操作还可以单独覆盖。
+- **可选切换行为:** 启用循环、跳过空工作区或跟随应用焦点。
+- **[自动打开演示](https://pangmo5.dev/Tatami/zh-Hans/#demo-autoopen):** 激活工作区时启动分配的应用,重新进入时打开之前关闭的窗口。
+- **每个显示器独立管理:** 固定工作区到显示器,或在指针所在屏幕打开动态工作区。各显示器在重启后仍保留当前和最近工作区;前后切换与最近切换可分别限定为当前屏幕或所有屏幕。
+- **工作区链:** 在配置方案内按顺序连接工作区。切换到任一成员时,按链优先级恢复可用伙伴,遵守已连接显示器的固定位置和链动态分配,焦点仍留在所选工作区。
+- **跨显示器控制:** 在显示器间移动焦点,或把聚焦的应用移到其他工作区。
+- **共享应用:** 添加应出现在每个工作区的应用。
+
+
+### 窗口平铺(BSP)
+[](https://pangmo5.dev/Tatami/zh-Hans/#demo-tiling)
+
+
+- **自动 BSP 布局:** 将新窗口放在当前插入目标或焦点窗口所在区域的旁边。两者都不可用时,使用树中层级最浅的区域。
+- **键盘操作:** 使用推荐快捷键时,按住 `ctrl + alt`,配合 `h`、`j`、`k` 或 `l` 移动焦点,配合方向键交换窗口,配合 `=` 或 `-` 放大或缩小焦点窗口所在的区域。
+- **交互式窗口切换:** 轻按立即切换,按住修饰键显示紧凑的应用与窗口列表。范围限定为指针所在显示器,可包含共享应用;可查看状态并用键盘或指针选择准确目标。
+- **放大与分割:** 让一个窗口铺满工作区,或切换分割方向。
+- **布局树变换:** 旋转、镜像和均衡布局。
+- **拖移编辑:** 通过实时预览交换或重新插入窗口,手动调整边缘也会同步到布局树。
+- **持久布局:** 切换工作区、重新启动和系统睡眠后,保留布局树及比例。
+- **可调间距:** 设置内部和外部间距。
+
+
+### 配置方案
+
+[观看配置方案与双屏演示](https://pangmo5.dev/Tatami/zh-Hans/#profiles)。
+
+- **独立配置方案:** 分组管理工作区,一次切换整套环境。每个方案分别保存工作区、应用分配和快捷键。
+- **快速切换方案:** 通过快捷键或菜单栏切换,所有显示器重新平铺,重启后返回合适的配置方案。
+- **按显示器激活:** 根据数量或指定显示器的连接、断开状态自动切换。同优先级规则重叠时会警告。
+- **方案图标:** 选择显示在侧边栏、菜单栏和切换反馈中的 SF Symbol。
+- **复用现有配置:** **复制自**和**复制**共用预览,在更改前选择保留或跳过工作区、应用、设置和保存的布局。
+
+
+### 借用:组合两个工作区
+[](https://pangmo5.dev/Tatami/zh-Hans/#demo-borrow)
+
+
+- **并排组合:** 把另一个工作区借到屏幕任一边缘,两个区域独立平铺,窗口不会越界。
+- **实时双向:** 借来的区域就是实际工作区,编辑会保留下来。
+- **方向放置:** 按借用修饰键和工作区按键,再用 `h`、`j`、`k`、`l` 或方向键。也可设置全局或每工作区的默认方向和大小。
+- **跨边界焦点和切换:** 方向焦点与鼠标跟随焦点可跨区域移动;归还前,两侧平铺窗口共享切换顺序。激活借来的工作区会完整切换,再次借用默认归还,`esc` 取消放置。
+- **显示归属:** 借来的窗口显示所属工作区图标。
+- **暂存区:** 仅供借用,不参与普通切换,也不会单独激活,调出时自动打开应用。
+
+
+### 置顶
+
+[观看跨工作区的共享状态窗口](https://pangmo5.dev/Tatami/zh-Hans/#demo-shared)。
+
+- **单独或共享:** 在一个工作区置顶应用,或加入共享应用后在所有工作区置顶。
+- **无需修改 SIP:** 使用置顶的 ScreenCaptureKit 镜像,交互时切换到实际窗口。
+- **可预测的堆叠:** 多个置顶窗口按最近焦点顺序堆叠,需要屏幕录制权限。
+- **保持原样:** 保留位置和大小,同时参与自动打开、焦点、焦点跟随鼠标和窗口切换,无需镜像或屏幕录制权限。
+
+
+### 焦点与指针
+
+[观看焦点跟随键盘和指针](https://pangmo5.dev/Tatami/zh-Hans/#demo-focus)。
+
+- **两种明确的焦点模式:** 焦点跟随鼠标让指针下方的窗口获得键盘焦点;鼠标跟随焦点在 Tatami 切换窗口后移动指针,包含置顶、共享置顶和保持原样窗口。
+- **关闭后恢复焦点:** 返回剩余窗口中最近使用的窗口。
+- **指针控制:** 可在切换工作区时隐藏指针。
+
+
+### 界面与配置
+
+[观看实际 CLI、钩子和实时配置](https://pangmo5.dev/Tatami/zh-Hans/#automation)。
+
+- **五种界面语言:** 跟随 macOS 应用语言设置,支持英语、韩语、日语、简体中文和繁体中文(台湾)。
+- **自定义菜单栏:** 显示当前工作区的图标或名称,并可显示活动配置方案的图标或名称。
+- **自适应操作反馈:** 在相关显示器上以简洁的弹性动画确认工作区、配置方案、置顶、成员关系、布局和借用操作。可选九个位置、三种大小,或通过 HUD 钩子把相同的本地化数据传给其他界面。
+- **工作区图标:** 为每个工作区选择 SF Symbol。
+- **原生设置:** 在 SwiftUI 界面中配置 Tatami。
+- **skhd 风格快捷键:** 例如 `ctrl + alt - h`。
+- **纯文本 TOML:** 编辑 `~/.config/tatami/config.toml`,支持 XDG 和实时重载。
+- **原生钩子编辑器:** 在**设置 → 钩子**中添加、编辑、删除、启用或禁用生命周期和操作反馈钩子,配置可执行文件、argv、环境变量、工作目录和超时。
+- **可脚本化 CLI:** 使用 `tatami workspace activate `、`tatami workspace list` 等领域命令。
+- **自动更新:** 通过 Sparkle 获取新版本。
+
+
+### 设置向导
+
+[观看快捷键练习和建议检查](https://pangmo5.dev/Tatami/zh-Hans/#guided-setup)。
+
+- **边用边学:** 首次启动时,在安全的虚拟桌面依次学习工作区、切换与手势、BSP 平铺、借用与暂存区、置顶与保持原样、MFF/FFM 和应用窗口切换。
+- **基于这台 Mac:** 使用运行中应用的信息和已连接显示器的几何数据,按重复进行的任务组织应用,而非套用通用分类。不捕获屏幕内容。
+- **可选 AI 规划:** 检查 ChatGPT、Claude、Gemini、其他 AI 或支持的 Mac 上设备端 Apple Intelligence 提出的任务配置。应用前始终只是建议。
+- **连续练习界面:** 实际快捷键和触控板手势控制预览,之前学过的命令在后续课程中仍可使用。
+- **先用草稿:** 点击**应用设置**前,不移动实际窗口或写入 `config.toml`。可随时从**设置 → 通用**重新打开。
+
+
+## 系统要求
+
+- macOS 14.0 或更新版本
+- 辅助功能权限(系统设置 → 隐私与安全性 → 辅助功能)
+- 仅使用置顶时需要屏幕录制权限,置顶镜像来自 ScreenCaptureKit 捕获(系统设置 → 隐私与安全性 → 屏幕录制)。
+
+
+## 安装
+
+
+### Homebrew
+
+```sh
+brew install --cask pangmo5/tap/tatami
+```
+
+也可从[最新版本](https://github.com/PangMo5/Tatami/releases/latest)下载已签名并公证的 `.dmg`。每个版本都链接到对应的准确源代码归档。
+
+
+### 从源代码构建
+
+请使用 Xcode 26 或更新版本,以及 Swift 6.2 或更新版本的工具链。应用可在 macOS 14 或更新版本上运行;运行环境要求与构建工具要求不同。
+
+```sh
+brew install tuist # or: mise install
+tuist install && tuist generate --no-open
+open Tatami.xcworkspace
+```
+
+
+## 配置与自动化
+
+
+### 配置指南
+
+设置保存在 `~/.config/tatami/config.toml`,分为 `[settings.layout]`、`[settings.focus]`、`[settings.gestures]`、`[settings.shortcuts]` 等表。工作区、应用分配和共享应用也在同一文件中。生命周期钩子可在**设置 → 钩子**或 `[[hooks]]` 中管理。GUI 分别保存可执行文件和各 argv,不把 `command` 当作 shell 字符串。应用内或手动修改都会实时生效。
+
+完整键、默认值和快捷键语法见 [docs/CONFIGURATION.md](docs/zh-Hans/CONFIGURATION.md)。
+
+关于各应用的窗口行为,包括浮动会议控件和跨工作区的画中画,请参阅[故障排查](docs/zh-Hans/TROUBLESHOOTING.md)。
+
+
+### 命令行
+
+应用内置 `tatami` CLI。从**设置 → 通用 → 命令行 → 安装**进行安装,把 `tatami` 符号链接到 `/usr/local/bin`,会请求一次密码。之后可执行:
+
+```sh
+tatami workspace list
+tatami workspace activate "Browser"
+tatami profile activate "Dual"
+tatami window focus left
+tatami layout balance
+```
+
+CLI 按领域提供配置方案与工作区管理、钩子查询、稳定 JSON 输出,以及与手势相同的焦点、布局、应用、平铺、循环切换和借用操作。Tatami 必须正在运行。
+
+阅读[完整 CLI 参考](docs/zh-Hans/CLI.md),或在 [pangmo5.dev/Tatami](https://pangmo5.dev/Tatami/zh-Hans/cli.html) 查看网页版本。
+
+
+## 技术栈
+
+- **Tuist:** 项目生成
+- **The Composable Architecture(TCA):** 应用架构
+- **swift-sharing:** 跨功能状态共享
+- **swift-collections:** 平铺关键路径中的有序集合、字典和双端队列
+- **swift-toml:** 配置持久化
+- **swift-subprocess:** 可取消且有时间边界的钩子执行
+- **swift-yyjson:** 布局存储和 CLI 协议中的快速 JSON
+- **Magnet:** 基于 Carbon 的全局快捷键
+- **SFSafeSymbols:** 类型安全的 SF Symbol 目录
+- **Sparkle:** 应用更新
+
+
+## 致谢
+
+Tatami 受到 Wojciech Kulik 的 [FlashSpace](虚拟工作区切换概念)和 koekeishiya 的 [yabai](窗口平铺模型)启发。署名见 [NOTICE.md](NOTICE.zh-Hans.md),依赖许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+
+
+## 许可证
+
+[AGPL-3.0-only](LICENSE)。
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
diff --git a/README.zh-Hant.md b/README.zh-Hant.md
new file mode 100644
index 0000000..9f25d55
--- /dev/null
+++ b/README.zh-Hant.md
@@ -0,0 +1,219 @@
+
+[English](README.md) · [한국어](README.ko.md) · [日本語](README.ja.md) · [简体中文](README.zh-Hans.md) · [繁體中文](README.zh-Hant.md)
+
+
+
+# Tatami
+
+[](https://github.com/PangMo5/Tatami/releases/latest) [](https://github.com/PangMo5/Tatami/releases)  [](LICENSE)
+
+支援 BSP 視窗並排的 macOS 工作空間管理工具。
+
+Tatami 把 App 整理到虛擬工作空間,透過快速鍵或自訂觸控式軌跡板手勢切換,並用二元空間分割(BSP)引擎自動並排視窗。不必修改 SIP,也不必撰寫 shell 指令稿。
+
+
+## 看看 Tatami 的實際操作
+
+[](https://pangmo5.dev/Tatami/zh-Hant/#demo)
+
+**[觀看完整工作流程](https://pangmo5.dev/Tatami/zh-Hant/#demo)**:設計、撰寫、審閱、借用待辦事項、自動準備專注環境,再連接第二部顯示器。視窗由實際的 Tatami 管理,App 和內容為示範素材。快速鍵採用本次示範的設定。
+
+
+設定與設定導覽截圖
+
+
+
+
+
+原圖:引導設定 · 工作空間 · 借用 截圖來自較早版本,部分標籤已更新。
+
+
+
+
+
+## 主要功能
+
+
+### 工作空間
+[](https://pangmo5.dev/Tatami/zh-Hant/#demo-workspaces)
+
+
+- **虛擬工作空間:** 依工作空間指派和整理 App。
+- **彈性切換:** 使用快速鍵、觸控式軌跡板手勢或最近工作空間操作。
+- **自訂手勢:** 為三指、四指滑動的各個方向綁定快速鍵操作,也可以指定某個設定組合或工作空間。
+- **每個工作空間一個按鍵:** 將切換、指派或借用輔助鍵與**工作空間按鍵**組合使用。同一套按鍵也用於最近、下一個和上一個目標,每項操作還可以個別覆寫。
+- **可選切換行為:** 啟用循環、略過空工作空間或跟隨 App 焦點。
+- **[自動開啟示範](https://pangmo5.dev/Tatami/zh-Hant/#demo-autoopen):** 啟用工作空間時啟動指派的 App,重新進入時開啟先前關閉的視窗。
+- **每部顯示器獨立管理:** 固定工作空間到顯示器,或在游標所在螢幕開啟動態工作空間。各顯示器在重新啟動後仍保留目前與最近工作空間;前後切換與最近切換可分別限定為目前螢幕或所有螢幕。
+- **工作空間鏈:** 在設定組合內依序連接工作空間。切換到任一成員時,依鏈優先順序還原可用夥伴,遵守已連接顯示器的固定位置與鏈動態分配,焦點仍留在所選工作空間。
+- **跨顯示器控制:** 在顯示器間移動焦點,或把聚焦的 App 移到其他工作空間。
+- **共用 App:** 新增應出現在每個工作空間的 App。
+
+
+### 視窗並排(BSP)
+[](https://pangmo5.dev/Tatami/zh-Hant/#demo-tiling)
+
+
+- **自動 BSP 配置:** 將新視窗放在目前插入目標或取得焦點的區塊旁。兩者都不可用時,使用樹狀結構中最淺層的區塊。
+- **鍵盤操作:** 使用建議快速鍵時,按住 `ctrl + alt`,搭配 `h`、`j`、`k` 或 `l` 移動焦點,搭配方向鍵交換視窗,搭配 `=` 或 `-` 放大或縮小取得焦點的區塊。
+- **互動式視窗切換:** 輕按立即切換,按住輔助鍵顯示精簡的 App 與視窗清單。範圍限定為游標所在顯示器,可包含共用 App;可查看狀態並用鍵盤或游標選擇確切目標。
+- **放大與分割:** 讓一個視窗鋪滿工作空間,或切換分割方向。
+- **排列樹變換:** 旋轉、鏡像與平均排列。
+- **拖移編輯:** 透過即時預覽交換或重新插入視窗,手動調整邊緣也會同步到排列樹。
+- **保留排列:** 切換工作空間、重新啟動與系統睡眠後,保留排列樹及比例。
+- **可調間距:** 設定內部與外部間距。
+
+
+### 設定組合
+
+[觀看設定組合與雙螢幕示範](https://pangmo5.dev/Tatami/zh-Hant/#profiles)。
+
+- **獨立設定組合:** 分組管理工作空間,一次切換整套環境。每個組合分別儲存工作空間、App 指派與快速鍵。
+- **快速切換組合:** 透過快速鍵或選單列切換,所有顯示器重新並排,重新啟動後返回合適的設定組合。
+- **依顯示器啟用:** 根據數量或指定顯示器的連接、中斷狀態自動切換。同優先順序規則重疊時會警告。
+- **組合圖像:** 選擇顯示在側邊欄、選單列與切換回饋中的 SF Symbol。
+- **重複使用設定:** **拷貝自**與**複製**共用預覽,在變更前選擇保留或略過工作空間、App、設定與儲存的排列。
+
+
+### 借用:組合兩個工作空間
+[](https://pangmo5.dev/Tatami/zh-Hant/#demo-borrow)
+
+
+- **並排組合:** 把另一個工作空間借到螢幕任一邊緣,兩個區域獨立並排,視窗不會越界。
+- **即時雙向:** 借來的區域就是實際工作空間,編輯會保留下來。
+- **方向放置:** 按借用輔助鍵與工作空間按鍵,再用 `h`、`j`、`k`、`l` 或方向鍵。也可設定全域或每個工作空間的預設方向與大小。
+- **跨邊界焦點與切換:** 方向焦點與游標跟隨焦點可跨區域移動;歸還前,兩側並排視窗共用切換順序。啟用借來的工作空間會完整切換,再次借用預設歸還,`esc` 取消放置。
+- **顯示歸屬:** 借來的視窗顯示所屬工作空間圖像。
+- **暫存區:** 僅供借用,不參與一般切換,也不會單獨啟用,叫出時自動開啟 App。
+
+
+### 置頂
+
+[觀看跨工作空間的共用狀態視窗](https://pangmo5.dev/Tatami/zh-Hant/#demo-shared)。
+
+- **個別或共用:** 在一個工作空間置頂 App,或加入共用 App 後在所有工作空間置頂。
+- **不必修改 SIP:** 使用置頂的 ScreenCaptureKit 鏡像,互動時切換到實際視窗。
+- **可預期的堆疊:** 多個置頂視窗依最近焦點順序堆疊,需要螢幕錄製權限。
+- **維持原狀:** 保留位置和大小,同時參與自動開啟、焦點、焦點跟隨游標與視窗切換,不必使用鏡像或螢幕錄製權限。
+
+
+### 焦點與游標
+
+[觀看焦點跟隨鍵盤和游標](https://pangmo5.dev/Tatami/zh-Hant/#demo-focus)。
+
+- **兩種明確的焦點模式:** 焦點跟隨游標讓游標下方的視窗取得鍵盤焦點;游標跟隨焦點在 Tatami 切換視窗後移動游標,包含置頂、共用置頂與維持原狀視窗。
+- **關閉後還原焦點:** 返回剩餘視窗中最近使用的視窗。
+- **游標控制:** 可在切換工作空間時隱藏游標。
+
+
+### 介面與設定
+
+[觀看實際 CLI、掛鉤與即時設定](https://pangmo5.dev/Tatami/zh-Hant/#automation)。
+
+- **五種介面語言:** 跟隨 macOS App 語言設定,支援英文、韓文、日文、簡體中文與繁體中文(台灣)。
+- **自訂選單列:** 顯示目前工作空間的圖像或名稱,並可顯示作用中設定組合的圖像或名稱。
+- **自適應操作回饋:** 在相關顯示器上以精簡的彈性動畫確認工作空間、設定組合、置頂、成員關係、排列與借用操作。可選九個位置、三種大小,或透過 HUD 掛鉤把相同的在地化資料傳給其他介面。
+- **工作空間圖像:** 為每個工作空間選擇 SF Symbol。
+- **原生設定:** 在 SwiftUI 介面中設定 Tatami。
+- **skhd 風格快速鍵:** 例如 `ctrl + alt - h`。
+- **純文字 TOML:** 編輯 `~/.config/tatami/config.toml`,支援 XDG 與即時重新載入。
+- **原生掛鉤編輯器:** 在**設定 → Hook**中新增、編輯、刪除、啟用或停用生命週期與操作回饋掛鉤,設定執行檔、argv、環境變數、工作目錄與逾時。
+- **可指令稿化 CLI:** 使用 `tatami workspace activate `、`tatami workspace list` 等領域指令。
+- **自動更新:** 透過 Sparkle 取得新版本。
+
+
+### 設定導覽
+
+[觀看快速鍵練習與建議檢查](https://pangmo5.dev/Tatami/zh-Hant/#guided-setup)。
+
+- **邊用邊學:** 首次啟動時,在安全的虛擬桌面依序學習工作空間、切換與手勢、BSP 並排、借用與暫存區、置頂與維持原狀、MFF/FFM 和 App 視窗切換。
+- **基於這部 Mac:** 使用執行中 App 的資訊與已連接顯示器的幾何資料,依重複進行的工作組織 App,而非套用一般分類。不擷取螢幕內容。
+- **可選 AI 規劃:** 檢查 ChatGPT、Claude、Gemini、其他 AI 或支援的 Mac 上裝置端 Apple Intelligence 提出的工作設定。套用前始終只是建議。
+- **連續練習介面:** 實際快速鍵和觸控式軌跡板手勢控制預覽,之前學過的指令在後續課程中仍可使用。
+- **先用草稿:** 點按**套用設定**前,不移動實際視窗或寫入 `config.toml`。可隨時從**設定 → 一般**重新開啟。
+
+
+## 系統需求
+
+- macOS 14.0 或更新版本
+- 輔助使用權限(系統設定 → 隱私權與安全性 → 輔助使用)
+- 僅使用置頂時需要螢幕錄製權限,置頂鏡像來自 ScreenCaptureKit 擷取(系統設定 → 隱私權與安全性 → 螢幕錄製)。
+
+
+## 安裝
+
+
+### Homebrew
+
+```sh
+brew install --cask pangmo5/tap/tatami
+```
+
+也可從[最新版本](https://github.com/PangMo5/Tatami/releases/latest)下載已簽名並公證的 `.dmg`。每個版本都連結到對應的確切原始碼封存檔。
+
+
+### 從原始碼建置
+
+請使用 Xcode 26 或更新版本,以及 Swift 6.2 或更新版本的工具鏈。App 可在 macOS 14 或更新版本執行;執行環境與建置工具的需求不同。
+
+```sh
+brew install tuist # or: mise install
+tuist install && tuist generate --no-open
+open Tatami.xcworkspace
+```
+
+
+## 設定與自動化
+
+
+### 設定指南
+
+設定儲存在 `~/.config/tatami/config.toml`,分為 `[settings.layout]`、`[settings.focus]`、`[settings.gestures]`、`[settings.shortcuts]` 等表。工作空間、App 指派與共用 App 也在同一檔案中。生命週期掛鉤可在**設定 → Hook**或 `[[hooks]]` 中管理。GUI 分別儲存執行檔和各 argv,不把 `command` 當作 shell 字串。App 內或手動修改都會即時生效。
+
+完整鍵、預設值與快速鍵語法請見 [docs/CONFIGURATION.md](docs/zh-Hant/CONFIGURATION.md)。
+
+關於各 App 的視窗行為,包含浮動會議控制項與跨工作空間的子母畫面,請參閱[疑難排解](docs/zh-Hant/TROUBLESHOOTING.md)。
+
+
+### 命令列
+
+App 內建 `tatami` CLI。從**設定 → 一般 → 命令列 → 安裝**進行安裝,把 `tatami` 符號連結到 `/usr/local/bin`,會要求一次密碼。之後可執行:
+
+```sh
+tatami workspace list
+tatami workspace activate "Browser"
+tatami profile activate "Dual"
+tatami window focus left
+tatami layout balance
+```
+
+CLI 依領域提供設定組合與工作空間管理、掛鉤查詢、穩定 JSON 輸出,以及與手勢相同的焦點、排列、App、並排、循環切換與借用操作。Tatami 必須正在執行。
+
+閱讀[完整 CLI 參考](docs/zh-Hant/CLI.md),或在 [pangmo5.dev/Tatami](https://pangmo5.dev/Tatami/zh-Hant/cli.html) 查看網頁版本。
+
+
+## 技術架構
+
+- **Tuist:** 專案產生
+- **The Composable Architecture(TCA):** App 架構
+- **swift-sharing:** 跨功能狀態共用
+- **swift-collections:** 並排關鍵路徑中的有序集合、字典與雙端佇列
+- **swift-toml:** 設定持久化
+- **swift-subprocess:** 可取消且有時間限制的掛鉤執行
+- **swift-yyjson:** 排列儲存與 CLI 協定中的快速 JSON
+- **Magnet:** 以 Carbon 為基礎的全域快速鍵
+- **SFSafeSymbols:** 型別安全的 SF Symbol 目錄
+- **Sparkle:** App 更新
+
+
+## 致謝
+
+Tatami 受到 Wojciech Kulik 的 [FlashSpace](虛擬工作空間切換概念)與 koekeishiya 的 [yabai](視窗並排模型)啟發。歸屬說明見 [NOTICE.md](NOTICE.zh-Hant.md),相依套件授權見 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+
+
+## 授權條款
+
+[AGPL-3.0-only](LICENSE)。
+
+[FlashSpace]: https://github.com/wojciech-kulik/FlashSpace
+[yabai]: https://github.com/koekeishiya/yabai
diff --git a/REUSE.toml b/REUSE.toml
index 6d00925..4244bbe 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -7,14 +7,19 @@ path = [
".mise.toml",
".swiftformat",
"CHANGELOG.md",
+ "DemoLab/**",
"Makefile",
"NOTICE.md",
"README.md",
+ "README.*.md",
+ "NOTICE.*.md",
+ "THIRD_PARTY_NOTICES.*.md",
+ "Localization/**",
+ "Tools/**",
"Resources/**",
"Tatami/Resources/**",
"Tatami/Tatami.entitlements",
"THIRD_PARTY_NOTICES.md",
- "demo/**",
"docs/**",
"web/**",
]
@@ -23,7 +28,7 @@ SPDX-FileCopyrightText = "2026 PangMo5 and contributors"
SPDX-License-Identifier = "AGPL-3.0-only"
[[annotations]]
-path = "Tuist/Package.resolved"
+path = ["Tuist/Package.resolved", "Tools/Package.resolved"]
precedence = "override"
SPDX-FileCopyrightText = "NONE"
SPDX-License-Identifier = "CC0-1.0"
diff --git a/Resources/Marketing/screenshots/build-overview.sh b/Resources/Marketing/screenshots/build-overview.sh
deleted file mode 100755
index 91f8f9b..0000000
--- a/Resources/Marketing/screenshots/build-overview.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-# Rebuilds overview.png, the README's screenshot cascade: the three captures
-# stacked with an overlap so each one renders larger than a side-by-side row
-# allows. Guided Setup sits in front, then workspace settings, then borrow at
-# the back. Transparent background and soft shadows, so it reads on GitHub's
-# light and dark themes.
-#
-# Requires ImageMagick 7 (`brew install imagemagick`). Run after replacing any
-# of the source captures:
-#
-# ./Resources/Marketing/screenshots/build-overview.sh
-set -euo pipefail
-cd "$(dirname "$0")"
-
-H=1000 # card height for the full-screen captures
-R=24 # corner radius
-YSTEP=620 # vertical cascade step: how much of each card stays uncovered
-XOFF=110 # horizontal drift per step
-
-TMP=$(mktemp -d)
-trap 'rm -rf "$TMP"' EXIT
-
-# Rounded corners + a drop shadow for an opaque full-screen capture.
-card() {
- local src=$1 out=$2 w
- magick "$src" -resize "x${H}" -depth 8 -strip "$TMP/flat.png"
- w=$(magick identify -format %w "$TMP/flat.png")
- magick -size "${w}x${H}" xc:black -fill white \
- -draw "roundrectangle 0,0,$((w - 1)),$((H - 1)),$R,$R" -alpha off "$TMP/mask.png"
- magick "$TMP/flat.png" "$TMP/mask.png" -alpha off -compose CopyOpacity -composite "$TMP/round.png"
- magick "$TMP/round.png" \( +clone -background black -shadow 45x26+0+14 \) \
- +swap -background none -layers merge +repage "$out"
-}
-
-card workspaces.png "$TMP/ws.png"
-card borrow.png "$TMP/bw.png"
-
-card_w=$(magick identify -format %w "$TMP/ws.png")
-card_h=$(magick identify -format %h "$TMP/ws.png")
-
-# Guided Setup is already a transparent window capture with its own shadow, so
-# it only needs trimming and scaling to the card width.
-magick guided-setup.png -trim +repage -resize "${card_w}x" -depth 8 -strip "$TMP/guide.png"
-guide_h=$(magick identify -format %h "$TMP/guide.png")
-
-# Composited back to front, so Guided Setup ends up on top and fully visible
-# while the two desktop captures recede behind it.
-magick -size "$((card_w + XOFF * 2 + 60))x$((YSTEP * 2 + card_h + guide_h + 60))" xc:none \
- "$TMP/bw.png" -geometry "+0+0" -composite \
- "$TMP/ws.png" -geometry "+${XOFF}+${YSTEP}" -composite \
- "$TMP/guide.png" -geometry "+$((XOFF * 2))+$((YSTEP * 2))" -composite \
- -trim +repage -bordercolor none -border 20 \
- -define png:compression-level=9 overview.png
-
-magick identify -format "Built %f %wx%h %B bytes\n" overview.png
diff --git a/THIRD_PARTY_NOTICES.ja.md b/THIRD_PARTY_NOTICES.ja.md
new file mode 100644
index 0000000..d331040
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.ja.md
@@ -0,0 +1,12 @@
+
+[English](THIRD_PARTY_NOTICES.md) · [한국어](THIRD_PARTY_NOTICES.ko.md) · [日本語](THIRD_PARTY_NOTICES.ja.md) · [简体中文](THIRD_PARTY_NOTICES.zh-Hans.md) · [繁體中文](THIRD_PARTY_NOTICES.zh-Hant.md)
+
+
+
+# 外部ライブラリの告知
+
+Tatami アプリの依存関係のバージョンとリビジョンは、`Tuist/Package.resolved` に基づいています。
+
+[アプリのライセンスと著作権表示の全文](THIRD_PARTY_NOTICES.md)は原文でご確認ください。このページでは概要の説明のみを翻訳しています。
+
+開発用の自動化は、`Tools/Package.resolved` で固定された別のパッケージ群を使います。[開発ツールの依存関係の表示](Tools/THIRD_PARTY_NOTICES.md)は原文でご確認ください。
diff --git a/THIRD_PARTY_NOTICES.ko.md b/THIRD_PARTY_NOTICES.ko.md
new file mode 100644
index 0000000..de2ce2c
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.ko.md
@@ -0,0 +1,12 @@
+
+[English](THIRD_PARTY_NOTICES.md) · [한국어](THIRD_PARTY_NOTICES.ko.md) · [日本語](THIRD_PARTY_NOTICES.ja.md) · [简体中文](THIRD_PARTY_NOTICES.zh-Hans.md) · [繁體中文](THIRD_PARTY_NOTICES.zh-Hant.md)
+
+
+
+# 외부 라이브러리 고지
+
+Tatami 앱 의존성의 버전과 리비전은 `Tuist/Package.resolved`을 기준으로 해요.
+
+[앱의 전체 라이선스 및 저작권 고지](THIRD_PARTY_NOTICES.md)는 원문으로 제공해요. 이 페이지는 개요 설명만 번역해요.
+
+개발 자동화는 `Tools/Package.resolved`에 고정된 별도의 패키지 집합을 사용해요. [개발 도구 의존성 고지](Tools/THIRD_PARTY_NOTICES.md)는 원문으로 제공해요.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 3d6bf0d..985b9c6 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,8 +1,16 @@
+
+[English](THIRD_PARTY_NOTICES.md) · [한국어](THIRD_PARTY_NOTICES.ko.md) · [日本語](THIRD_PARTY_NOTICES.ja.md) · [简体中文](THIRD_PARTY_NOTICES.zh-Hans.md) · [繁體中文](THIRD_PARTY_NOTICES.zh-Hant.md)
+
+
# Third-Party Notices
-Tatami incorporates or is built with the dependencies listed below. Versions and
-revisions are taken from `Tuist/Package.resolved`. Each license notice is
-reproduced verbatim from that resolved checkout.
+The distributed Tatami app incorporates or is built with the dependencies listed
+below. Versions and revisions are taken from `Tuist/Package.resolved`. Each
+license notice is reproduced verbatim from that resolved checkout.
+
+Development automation has a separate dependency graph. See the
+[development tool notices](Tools/THIRD_PARTY_NOTICES.md) for the packages pinned
+in `Tools/Package.resolved`. Those packages are not added to the app's Tuist graph.
diff --git a/THIRD_PARTY_NOTICES.zh-Hans.md b/THIRD_PARTY_NOTICES.zh-Hans.md
new file mode 100644
index 0000000..d26739c
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.zh-Hans.md
@@ -0,0 +1,12 @@
+
+[English](THIRD_PARTY_NOTICES.md) · [한국어](THIRD_PARTY_NOTICES.ko.md) · [日本語](THIRD_PARTY_NOTICES.ja.md) · [简体中文](THIRD_PARTY_NOTICES.zh-Hans.md) · [繁體中文](THIRD_PARTY_NOTICES.zh-Hant.md)
+
+
+
+# 第三方声明
+
+Tatami 应用依赖项的版本和修订号以 `Tuist/Package.resolved` 为准。
+
+请阅读原文形式的[应用完整许可证和版权声明](THIRD_PARTY_NOTICES.md)。本页仅翻译概述说明。
+
+开发自动化使用另一组依赖包,其版本固定在 `Tools/Package.resolved` 中。请阅读原文形式的[开发工具依赖声明](Tools/THIRD_PARTY_NOTICES.md)。
diff --git a/THIRD_PARTY_NOTICES.zh-Hant.md b/THIRD_PARTY_NOTICES.zh-Hant.md
new file mode 100644
index 0000000..0bbc559
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.zh-Hant.md
@@ -0,0 +1,12 @@
+
+[English](THIRD_PARTY_NOTICES.md) · [한국어](THIRD_PARTY_NOTICES.ko.md) · [日本語](THIRD_PARTY_NOTICES.ja.md) · [简体中文](THIRD_PARTY_NOTICES.zh-Hans.md) · [繁體中文](THIRD_PARTY_NOTICES.zh-Hant.md)
+
+
+
+# 第三方聲明
+
+Tatami App 相依套件的版本與修訂版本以 `Tuist/Package.resolved` 為準。
+
+請閱讀原文形式的[App 完整授權條款與著作權聲明](THIRD_PARTY_NOTICES.md)。本頁僅翻譯概述說明。
+
+開發自動化使用另一組套件,其版本固定在 `Tools/Package.resolved` 中。請閱讀原文形式的[開發工具相依套件聲明](Tools/THIRD_PARTY_NOTICES.md)。
diff --git a/TatamiKit/Sources/AppFeature.swift b/TatamiKit/Sources/AppFeature.swift
index d8b37b3..31ffce2 100644
--- a/TatamiKit/Sources/AppFeature.swift
+++ b/TatamiKit/Sources/AppFeature.swift
@@ -254,8 +254,8 @@ public struct AppFeature {
await send(.settingsChanged(settings))
}
},
- // Reading `updater` starts Sparkle's background schedule; keep
- // its auto-check preference + interval in sync with settings.
+ // Apply the saved preferences before Sparkle starts its schedule,
+ // then keep them synchronized with live settings changes.
.run { [updater, sharedConfig] _ in
for await general in Perceptions({ sharedConfig.wrappedValue.settings.general }) {
await updater.configure(
@@ -1092,10 +1092,10 @@ public struct AppFeature {
return .send(.activation(.membershipEdit(.toggleShared)))
case .resizeGrow:
- return .send(.activation(.bspResize(direction: .east, delta: 0.05)))
+ return .send(.activation(.bspResizeFocused(delta: 0.05)))
case .resizeShrink:
- return .send(.activation(.bspResize(direction: .east, delta: -0.05)))
+ return .send(.activation(.bspResizeFocused(delta: -0.05)))
case .swapLeft:
return .send(.activation(.bspSwap(.west)))
diff --git a/TatamiKit/Sources/Dependencies/UpdaterClient.swift b/TatamiKit/Sources/Dependencies/UpdaterClient.swift
index f126e37..9fd29cf 100644
--- a/TatamiKit/Sources/Dependencies/UpdaterClient.swift
+++ b/TatamiKit/Sources/Dependencies/UpdaterClient.swift
@@ -7,8 +7,7 @@ import DependenciesMacros
import Sparkle
/// Wraps Sparkle's updater so reducers/views can drive software updates
-/// without touching Sparkle directly. Reading the live dependency starts
-/// Sparkle's background check schedule.
+/// without touching Sparkle directly. Configure the schedule before starting it.
@DependencyClient
struct UpdaterClient: Sendable {
/// Triggers a user-initiated update check.
@@ -23,13 +22,14 @@ struct UpdaterClient: Sendable {
extension UpdaterClient: DependencyKey {
static let liveValue: UpdaterClient = MainActor.assumeIsolated {
let controller = SPUStandardUpdaterController(
- startingUpdater: true,
+ startingUpdater: false,
updaterDelegate: nil,
userDriverDelegate: nil
)
let updater = controller.updater
return UpdaterClient(
checkForUpdates: {
+ controller.startUpdater()
// Sparkle's window can end up behind another app without any error, so
// record the activation state the check starts from. That is what
// distinguishes "Sparkle refused" from "the alert opened out of sight".
@@ -45,6 +45,7 @@ extension UpdaterClient: DependencyKey {
configure: { automaticallyChecks, interval in
updater.automaticallyChecksForUpdates = automaticallyChecks
updater.updateCheckInterval = interval
+ controller.startUpdater()
}
)
}
diff --git a/TatamiKit/Sources/Features/Onboarding/OnboardingFeature.swift b/TatamiKit/Sources/Features/Onboarding/OnboardingFeature.swift
index 038d5fb..7a89cf3 100644
--- a/TatamiKit/Sources/Features/Onboarding/OnboardingFeature.swift
+++ b/TatamiKit/Sources/Features/Onboarding/OnboardingFeature.swift
@@ -2198,7 +2198,7 @@ public struct OnboardingFeature {
state.demoActionResult = String(localized: "Flipped the focused tile's parent split")
case .resize(let delta):
- let updated = tree.resizing(window: selected, direction: .east, delta: delta)
+ let updated = tree.resizing(window: selected, delta: delta)
guard updated != tree else {
state.demoActionResult = String(localized: "No vertical split owns the focused tile")
return true
diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift
index acd4c3b..d89a536 100644
--- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift
+++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift
@@ -2353,6 +2353,8 @@ extension WorkspaceActivationFeature {
)
))
let sessionTree = state.tilingTrees[workspace.id]
+ let pendingRestoration = state.pendingLayoutRestorations[workspace.id]
+ let autoOpeningBundleIDs = Set(workspace.apps.filter { $0.autoOpen && $0.layout == .tiled }.map(\.bundleIdentifier))
let sharedTiledBundleIds = Set(
state.config.sharedApps.filter { $0.layout == .tiled }.map(\.bundleIdentifier)
)
@@ -2605,8 +2607,12 @@ extension WorkspaceActivationFeature {
if !isPaused {
// Layouts always persist now. Restore the saved template whenever
// there's no in-memory tree yet (fresh launch / first activation).
- let persistedSnapshot: LayoutSnapshot? =
- sessionTree == nil ? await store.load(workspaceId) : nil
+ let persistedSnapshot: LayoutSnapshot?
+ if let pendingRestoration {
+ persistedSnapshot = pendingRestoration
+ } else {
+ persistedSnapshot = sessionTree == nil ? await store.load(workspaceId) : nil
+ }
guard !Task.isCancelled else { return }
// Cache-first discovery: a warm `WindowKeyCache` entry costs zero
// AX round trips. AX scans block on each target app's run loop
@@ -2711,6 +2717,16 @@ extension WorkspaceActivationFeature {
// Do not publish or persist a superseded workspace's layout snapshot.
guard !Task.isCancelled else { return }
await send(.tilingTreeUpdated(workspaceId: workspaceId, tree: tree))
+ // Wait for the first window of apps this activation opens, not
+ // for every old occurrence (an app may reopen fewer windows).
+ let missingLayoutApps = Set(persistedSnapshot?.tree.windows.map(\.bundleId) ?? [])
+ .intersection(autoOpeningBundleIDs)
+ .subtracting(keys.map(\.bundleId))
+ let stillRestoring = missingLayoutApps.isEmpty ? nil : persistedSnapshot
+ await send(.persistedLayoutRestorationUpdated(
+ workspaceId: workspaceId,
+ snapshot: stillRestoring,
+ ))
if persistedSnapshot != nil, zoomed.isEmpty {
await send(.persistedFullscreenZoomRestored(
workspaceId: workspaceId,
@@ -2719,7 +2735,7 @@ extension WorkspaceActivationFeature {
))
}
guard !Task.isCancelled else { return }
- if let tree {
+ if let tree, stillRestoring == nil {
let slots = slotAssignment(tree.windows)
await store.save(
workspaceId,
diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift
index 7c19885..c73ce8b 100644
--- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift
+++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift
@@ -112,6 +112,7 @@ extension WorkspaceActivationFeature {
if abs(newFrame.maxY - expected.maxY) > tolerance { adjust(.south, edge: newFrame.maxY) }
guard newTree != tree else { return .none }
+ state.pendingLayoutRestorations[workspaceId] = nil
state.tilingTrees[workspaceId] = newTree
return .merge(
@@ -212,6 +213,7 @@ extension WorkspaceActivationFeature {
}
state.tilingTrees[workspaceId] = newTree
let zoomed = state.fullscreenZoomed[workspaceId] ?? []
+ state.pendingLayoutRestorations[workspaceId] = nil
return .merge(
flushPointerDrivenLayout(workspaceId: workspaceId, state: &state),
diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift
index 8adea88..bde8fa9 100644
--- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift
+++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift
@@ -477,6 +477,7 @@ extension WorkspaceActivationFeature {
windowSnapshot.invalidateWindowIDs(invalidatedWindowIDs)
var pruned: BSPNode? = tree
for key in gone { pruned = pruned?.removing(key) }
+ state.pendingLayoutRestorations[workspaceId] = nil
let balanced = axis == .none ? pruned : pruned?.balanced(axis: axis)
state.tilingTrees[workspaceId] = balanced
let newWindows = Set(balanced?.windows ?? [])
@@ -681,6 +682,7 @@ extension WorkspaceActivationFeature {
let lostEveryIdentity = Set(current.windows).isDisjoint(with: suspendedWindows)
let recovered: BSPNode
if lostEveryIdentity {
+ state.pendingLayoutRestorations[workspaceId] = nil
let (_, workArea) = tilingContext(for: workspaceId, state: state)
recovered = current.balancedForCommand(
autoBalance: settings.layout.autoBalance,
@@ -1026,6 +1028,30 @@ extension WorkspaceActivationFeature {
}
}
+ // Restore late-arriving slots into the saved shape. A closed window is
+ // new user intent and ends the startup restore.
+ let currentKeys = tree?.windows ?? []
+ let removedDuringSync = Set(replacementBaseline?.windows ?? [])
+ .subtracting(currentKeys)
+ if !removedDuringSync.isEmpty {
+ state.pendingLayoutRestorations[workspaceId] = nil
+ } else if let restoration = state.pendingLayoutRestorations[workspaceId],
+ let restored = BSPNode.hydrate(template: restoration.tree, keys: currentKeys) {
+ tree = Self.mergeTree(
+ existing: restored,
+ target: currentKeys,
+ focused: { focused },
+ insertionPoint: insertionPointKey,
+ workArea: workArea,
+ settings: settings,
+ )
+ let expectedApps = Set(workspace.apps.filter { $0.autoOpen && $0.layout == .tiled }.map(\.bundleIdentifier))
+ .intersection(restoration.tree.windows.map(\.bundleId))
+ if expectedApps.isSubset(of: Set(currentKeys.map(\.bundleId))) {
+ state.pendingLayoutRestorations[workspaceId] = nil
+ }
+ }
+
// Auto-balance belongs to logical insert/remove transitions. A no-op sync
// must preserve user-resized ratios, and a WindowServer identity swap is
// still the same logical slot. Compare against the replacement-normalized
@@ -1267,7 +1293,7 @@ extension WorkspaceActivationFeature {
postLayoutFocusEffect,
)
}
- let persistence = state.isRecoveringSystemLayout
+ let persistence = state.isRecoveringSystemLayout || state.pendingLayoutRestorations[workspaceId] != nil
? Effect.none
: persist(
final,
diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift
index 742bd68..862bfeb 100644
--- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift
+++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift
@@ -315,6 +315,9 @@ public struct WorkspaceActivationFeature {
/// startup discovery. The first later sync that sees the matching live
/// occurrences promotes them into `fullscreenZoomed`.
public var unresolvedFullscreenZoomSlots = [Workspace.ID: Set]()
+ /// Keep the saved shape while auto-opened windows are still arriving.
+ /// A partial discovery must not replace a complete persisted layout.
+ public var pendingLayoutRestorations = [Workspace.ID: LayoutSnapshot]()
/// Active composition per display — a host workspace plus borrowed
/// blocks. Absent → that display shows its host alone (default behavior).
@@ -1196,6 +1199,7 @@ public struct WorkspaceActivationFeature {
case windowCycleHUDInteraction(WindowSwitcherInteraction)
case bspSwap(BSPDirection)
case bspResize(direction: BSPDirection, delta: CGFloat)
+ case bspResizeFocused(delta: CGFloat)
case bspToggleOrientation
/// Tatami's fullscreen-zoom: multi-window, takes the window out of
/// the tree's layout and renders it at the work area.
@@ -1205,6 +1209,7 @@ public struct WorkspaceActivationFeature {
/// GUI layout-preview edit for the *active* workspace: apply a structural
/// op to its live tree, re-tile on screen, and persist per memory setting.
case layoutEdited(workspaceId: Workspace.ID, op: LayoutEditOp)
+ case persistedLayoutRestorationUpdated(workspaceId: Workspace.ID, snapshot: LayoutSnapshot?)
/// A GUI edit changed an *inactive* workspace's saved layout — drop its
/// resident in-memory tree/zoom so the next activation rebuilds from the
/// edited snapshot rather than the stale session state.
@@ -1390,6 +1395,7 @@ public struct WorkspaceActivationFeature {
public enum BSPOp: Sendable, Hashable {
case swap(BSPDirection)
case resize(BSPDirection, delta: CGFloat)
+ case resizeFocused(delta: CGFloat)
case toggleOrientation
case toggleZoomFullscreen
/// Follow the configured auto-balance axes, or rebuild the canonical
@@ -4017,6 +4023,11 @@ public struct WorkspaceActivationFeature {
.bspOpResolved(windowKey: key, op: .resize(direction, delta: delta))
}
+ case .bspResizeFocused(let delta):
+ return resolveFocusedWindowKey { key in
+ .bspOpResolved(windowKey: key, op: .resizeFocused(delta: delta))
+ }
+
case .bspToggleOrientation:
return resolveFocusedWindowKey { key in
.bspOpResolved(windowKey: key, op: .toggleOrientation)
@@ -4046,6 +4057,7 @@ public struct WorkspaceActivationFeature {
else { return .none }
let newTree = tree.applying(op)
guard newTree != tree else { return .none }
+ state.pendingLayoutRestorations[workspaceId] = nil
state.tilingTrees[workspaceId] = newTree
let zoomed = state.fullscreenZoomed[workspaceId] ?? []
return .merge(
@@ -4071,9 +4083,14 @@ public struct WorkspaceActivationFeature {
state.tilingTrees[workspaceId] = nil
state.fullscreenZoomed[workspaceId] = nil
state.unresolvedFullscreenZoomSlots[workspaceId] = nil
+ state.pendingLayoutRestorations[workspaceId] = nil
state.insertionPoint[workspaceId] = nil
return .none
+ case .persistedLayoutRestorationUpdated(let workspaceId, let snapshot):
+ state.pendingLayoutRestorations[workspaceId] = snapshot
+ return .none
+
case .persistedFullscreenZoomRestored(
let workspaceId,
let keys,
@@ -4921,6 +4938,7 @@ public struct WorkspaceActivationFeature {
.bspFocus,
.bspSwap,
.bspResize,
+ .bspResizeFocused,
.bspToggleOrientation,
.bspToggleZoomFullscreen,
.bspBalance,
@@ -5422,6 +5440,9 @@ public struct WorkspaceActivationFeature {
"\(String(describing: op)) \(windowKey.bundleId)#\(windowKey.windowID)",
)
+ // An explicit edit owns the current layout, including during startup.
+ state.pendingLayoutRestorations[workspaceId] = nil
+
let settings = state.config.settings
// The block's geometry: a composition sub-rect when composed, else the
// workspace's full work area. (Display is re-derived in `flushLayout`.)
@@ -5460,6 +5481,9 @@ public struct WorkspaceActivationFeature {
// edge, which made grow/shrink a no-op for edge windows.)
tree = tree.resizing(window: windowKey, direction: direction, delta: delta)
+ case .resizeFocused(let delta):
+ tree = tree.resizing(window: windowKey, delta: delta)
+
case .toggleOrientation:
tree = tree.togglingSplit(at: windowKey)
// Flipping the split axis rebuilds the focused window's frame from a
diff --git a/TatamiKit/Sources/Tiling/BSPTree.swift b/TatamiKit/Sources/Tiling/BSPTree.swift
index 6c9733b..11bc625 100644
--- a/TatamiKit/Sources/Tiling/BSPTree.swift
+++ b/TatamiKit/Sources/Tiling/BSPTree.swift
@@ -666,6 +666,15 @@ extension BSPNode {
// MARK: - Resize / fence
extension BSPNode {
+ /// Grow or shrink the focused tile at its parent split, following the
+ /// layout's orientation rather than assuming a left/right arrangement.
+ public func resizing(window: WindowID, delta: CGFloat) -> BSPNode {
+ guard let path = pathTo(window: window), !path.isEmpty,
+ case .branch(let parent) = subtree(at: Array(path.dropLast()))
+ else { return self }
+ return resizing(window: window, axis: parent.split, delta: delta)
+ }
+
/// Adjust the ratio at the nearest ancestor of `window` whose split
/// axis matches `axis`. Positive `delta` always means "grow the
/// focused window": the sign is flipped when the focused leaf sits on
diff --git a/TatamiTests/Sources/BSPTreeTests.swift b/TatamiTests/Sources/BSPTreeTests.swift
index 6393455..0f776bc 100644
--- a/TatamiTests/Sources/BSPTreeTests.swift
+++ b/TatamiTests/Sources/BSPTreeTests.swift
@@ -152,6 +152,38 @@ struct BSPTreeTests {
#expect(frames[2]?.width == 700)
}
+ @Test(arguments: [BSPSplitAxis.horizontal, .vertical], [1, 2])
+ func `focused resize follows its parent split on either side`(
+ axis: BSPSplitAxis, window: Int
+ ) {
+ let display = CGRect(x: 0, y: 0, width: 1000, height: 600)
+ let tree = BSPNode.branch(BSPBranch(split: axis, ratio: 0.5, left: .leaf(1), right: .leaf(2)))
+ let grown = tree.resizing(window: window, delta: 0.2)
+ let frame = grown.frames(in: display, gap: 0)[window]
+ if axis == .horizontal {
+ #expect(frame?.height == 420)
+ #expect(frame?.width == 1000)
+ } else {
+ #expect(frame?.width == 700)
+ #expect(frame?.height == 600)
+ }
+ #expect(grown.resizing(window: window, delta: -0.2).frames(in: display, gap: 0) == tree.frames(in: display, gap: 0))
+ }
+
+ @Test
+ func `focused resize adjusts nested parent without widening its ancestor`() {
+ let tree = BSPNode.branch(BSPBranch(
+ split: .vertical, ratio: 0.5, left: .leaf(1),
+ right: .branch(BSPBranch(split: .horizontal, ratio: 0.5, left: .leaf(2), right: .leaf(3)))
+ ))
+ let display = CGRect(x: 0, y: 0, width: 1000, height: 600)
+ let frames = tree.resizing(window: 3, delta: 0.2).frames(in: display, gap: 0)
+ #expect(frames[1] == CGRect(x: 0, y: 0, width: 500, height: 600))
+ #expect(frames[3] == CGRect(x: 500, y: 180, width: 500, height: 420))
+ #expect(BSPNode.leaf(1).resizing(window: 1, delta: 0.2) == .leaf(1))
+ #expect(tree.resizing(window: 99, delta: 0.2) == tree)
+ }
+
@Test
func `path to finds the leaf`() throws {
let display = CGRect(x: 0, y: 0, width: 1000, height: 600)
diff --git a/TatamiTests/Sources/WorkspaceActivationTests.swift b/TatamiTests/Sources/WorkspaceActivationTests.swift
index d1287b5..68e2192 100644
--- a/TatamiTests/Sources/WorkspaceActivationTests.swift
+++ b/TatamiTests/Sources/WorkspaceActivationTests.swift
@@ -4402,6 +4402,84 @@ struct WorkspaceActivationFeatureTests {
#expect(discovered.value.count(where: { $0 == "app.shared" }) == 1)
}
+ @Test(arguments: [false, true])
+ func `saved layout survives partial activation until late windows arrive`(startsEmpty: Bool) async {
+ let first = WindowKey(pid: 1, windowID: 101, bundleId: "app.first")
+ let late = WindowKey(pid: 2, windowID: 202, bundleId: "app.late")
+ let workspace = Workspace(name: "Saved", apps: [first, late].map {
+ AppAssignment(bundleIdentifier: $0.bundleId, name: $0.bundleId, autoOpen: true)
+ })
+ let expected = BSPNode.branch(BSPBranch(
+ split: .horizontal, ratio: 0.35, left: .leaf(late), right: .leaf(first)
+ ))
+ let slots = slotAssignment(expected.windows)
+ let saved = LayoutSnapshot(tree: expected.mapWindows { slots[$0]! })
+ let writes = LockIsolated<[LayoutSnapshot]>([])
+ let workArea = CGRect(x: 0, y: 0, width: 1000, height: 800)
+ let state = Self.makeState(workspaces: [workspace]) {
+ $0.focusedDisplay = Self.display
+ $0.$config.withLock {
+ $0.settings.layout.autoBalance = .none
+ $0.settings.layout.gapInner = 0
+ $0.settings.layout.gapOuter = 0
+ }
+ }
+ let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: {
+ $0.continuousClock = TestClock()
+ $0.displays.workArea = { _ in workArea }
+ $0.floatingOverlay.retainOnly = { _ in }
+ $0.floatingOverlay.setFloating = { _ in }
+ $0.windowSnapshot.cachedKeys = { bundles, _ in
+ !startsEmpty && bundles.contains(first.bundleId) ? [first] : []
+ }
+ $0.layoutStore.load = { _ in saved }
+ $0.layoutStore.save = { _, snapshot in writes.withValue { $0.append(snapshot) } }
+ }
+ store.exhaustivity = .off
+ await store.send(.activate(workspaceId: workspace.id, setFocus: false))
+ await store.receive {
+ guard case .activationCompleted = $0 else { return false }
+ return true
+ }
+ await store.finish()
+ #expect(store.state.pendingLayoutRestorations[workspace.id] == saved)
+ #expect(writes.value.isEmpty)
+ if startsEmpty {
+ await store.send(.syncAppWindowsResolved(
+ bundleId: first.bundleId, resizableKeys: [first], onScreenFrames: [first.windowID: workArea]
+ ))
+ await store.finish()
+ #expect(writes.value.isEmpty)
+ }
+ await store.send(.syncAppWindowsResolved(
+ bundleId: late.bundleId, resizableKeys: [late],
+ onScreenFrames: [first.windowID: workArea, late.windowID: workArea]
+ ))
+ await store.finish()
+ #expect(store.state.tilingTrees[workspace.id] == expected)
+ #expect(store.state.pendingLayoutRestorations[workspace.id] == nil)
+ #expect(writes.value.last == saved)
+ }
+
+ @Test
+ func `explicit layout edit cancels a pending startup shape`() async {
+ let first = WindowKey(pid: 1, windowID: 101, bundleId: "app.first")
+ let second = WindowKey(pid: 2, windowID: 202, bundleId: "app.second")
+ let workspace = Workspace(name: "Work")
+ let initial = BSPNode.branch(BSPBranch(split: .horizontal, ratio: 0.5, left: .leaf(first), right: .leaf(second)))
+ let state = Self.makeState(workspaces: [workspace]) {
+ $0.activeWorkspacesByDisplay[Self.display] = workspace.id
+ $0.tilingTrees[workspace.id] = initial
+ $0.pendingLayoutRestorations[workspace.id] = LayoutSnapshot(tree: .leaf(SlotID(bundleId: "app.late", occurrence: 0)))
+ }
+ let store = TestStore(initialState: state) { WorkspaceActivationFeature() }
+ store.exhaustivity = .off
+ await store.send(.bspOpResolved(windowKey: second, op: .resizeFocused(delta: 0.2)))
+ await store.finish()
+ #expect(store.state.pendingLayoutRestorations[workspace.id] == nil)
+ #expect(store.state.tilingTrees[workspace.id] == initial.resizing(window: second, delta: 0.2))
+ }
+
@Test(arguments: AutoBalanceMode.allCases)
func `fresh activation initializes a missing layout from auto balance`(
mode: AutoBalanceMode
diff --git a/Tools/Package.resolved b/Tools/Package.resolved
new file mode 100644
index 0000000..d1d407d
--- /dev/null
+++ b/Tools/Package.resolved
@@ -0,0 +1,267 @@
+{
+ "originHash" : "f19c04ea1edb0a7dd35ddd48ff92861d00bb2878ac435f73c3f86ee4e18f7d5c",
+ "pins" : [
+ {
+ "identity" : "async-http-client",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swift-server/async-http-client.git",
+ "state" : {
+ "revision" : "f95c908967e98c68c5ce3fd61a7974e7e869e303",
+ "version" : "1.36.1"
+ }
+ },
+ {
+ "identity" : "hummingbird",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/hummingbird-project/hummingbird.git",
+ "state" : {
+ "revision" : "55bc9025a4825ee2a234b1f82b51b87be6ef74e4",
+ "version" : "2.26.0"
+ }
+ },
+ {
+ "identity" : "swift-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-algorithms.git",
+ "state" : {
+ "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
+ "version" : "1.2.1"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
+ "version" : "1.8.2"
+ }
+ },
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f",
+ "version" : "1.7.2"
+ }
+ },
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms.git",
+ "state" : {
+ "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c",
+ "version" : "1.1.5"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
+ "version" : "1.3.1"
+ }
+ },
+ {
+ "identity" : "swift-certificates",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-certificates.git",
+ "state" : {
+ "revision" : "c8aece90ea05f9866bd392a5bf13b5cae56c0e03",
+ "version" : "1.20.0"
+ }
+ },
+ {
+ "identity" : "swift-cmark",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-cmark.git",
+ "state" : {
+ "revision" : "924936d0427cb25a61169739a7660230bffa6ea6",
+ "version" : "0.8.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
+ "version" : "1.6.0"
+ }
+ },
+ {
+ "identity" : "swift-configuration",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-configuration.git",
+ "state" : {
+ "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
+ "version" : "3.15.1"
+ }
+ },
+ {
+ "identity" : "swift-distributed-tracing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-distributed-tracing.git",
+ "state" : {
+ "revision" : "dc4030184203ffafbb2ec614352487235d747fe0",
+ "version" : "1.4.1"
+ }
+ },
+ {
+ "identity" : "swift-http-structured-headers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-structured-headers.git",
+ "state" : {
+ "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47",
+ "version" : "1.7.0"
+ }
+ },
+ {
+ "identity" : "swift-http-types",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-types.git",
+ "state" : {
+ "revision" : "6c8ca8297332ea2780c45e1bce9d7fd2a8b51e1d",
+ "version" : "1.7.0"
+ }
+ },
+ {
+ "identity" : "swift-log",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-log.git",
+ "state" : {
+ "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222",
+ "version" : "1.15.0"
+ }
+ },
+ {
+ "identity" : "swift-markdown",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-markdown.git",
+ "state" : {
+ "revision" : "3c6f9523da3a1ec2fd829673e472d95b8097a3b8",
+ "version" : "0.8.0"
+ }
+ },
+ {
+ "identity" : "swift-metrics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-metrics.git",
+ "state" : {
+ "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4",
+ "version" : "2.11.0"
+ }
+ },
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "a931f2c1de8dd49381ce3bf2e279d033f68d8865",
+ "version" : "2.102.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-extras.git",
+ "state" : {
+ "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced",
+ "version" : "1.35.1"
+ }
+ },
+ {
+ "identity" : "swift-nio-http2",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-http2.git",
+ "state" : {
+ "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69",
+ "version" : "1.46.0"
+ }
+ },
+ {
+ "identity" : "swift-nio-ssl",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-ssl.git",
+ "state" : {
+ "revision" : "03827c1a9fdb2b6b00a4e93ede8861520263af8c",
+ "version" : "2.37.4"
+ }
+ },
+ {
+ "identity" : "swift-nio-transport-services",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio-transport-services.git",
+ "state" : {
+ "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5",
+ "version" : "1.28.0"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-service-context",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-service-context.git",
+ "state" : {
+ "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-service-lifecycle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swift-server/swift-service-lifecycle.git",
+ "state" : {
+ "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e",
+ "version" : "2.12.0"
+ }
+ },
+ {
+ "identity" : "swift-subprocess",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-subprocess.git",
+ "state" : {
+ "revision" : "b3937ab85dd32f6e9435914599c1519074769c1a",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
+ "version" : "1.8.1"
+ }
+ },
+ {
+ "identity" : "swiftsoup",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/scinfu/SwiftSoup.git",
+ "state" : {
+ "revision" : "18b80329749eca5ea29fc50211dca5c7eff5bfec",
+ "version" : "2.13.9"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Tools/Package.swift b/Tools/Package.swift
new file mode 100644
index 0000000..d67e0ef
--- /dev/null
+++ b/Tools/Package.swift
@@ -0,0 +1,41 @@
+// swift-tools-version: 6.2
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+import PackageDescription
+
+/// Automation dependencies stay outside the product and the offline Demo Lab graph.
+let package = Package(
+ name: "TatamiTools",
+ platforms: [.macOS(.v14)],
+ products: [.executable(name: "tatami-tools", targets: ["tatami-tools"])],
+ dependencies: [
+ .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "1.0.0"),
+ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.8.2"),
+ .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
+ .package(url: "https://github.com/apple/swift-system.git", from: "1.5.0"),
+ .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.9"),
+ .package(url: "https://github.com/swiftlang/swift-markdown.git", .upToNextMinor(from: "0.8.0")),
+ .package(url: "https://github.com/swiftlang/swift-cmark.git", .upToNextMinor(from: "0.8.0")),
+ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.26.0"),
+ ],
+ targets: [
+ .target(name: "TatamiToolsKit", dependencies: [
+ .product(name: "Subprocess", package: "swift-subprocess"),
+ .product(name: "ArgumentParser", package: "swift-argument-parser"),
+ .product(name: "Crypto", package: "swift-crypto"),
+ .product(name: "SystemPackage", package: "swift-system"),
+ .product(name: "SwiftSoup", package: "SwiftSoup"),
+ .product(name: "Markdown", package: "swift-markdown"),
+ .product(name: "cmark-gfm", package: "swift-cmark"),
+ .product(name: "Hummingbird", package: "hummingbird"),
+ ]),
+ .executableTarget(
+ name: "tatami-tools",
+ dependencies: ["TatamiToolsKit", .product(name: "ArgumentParser", package: "swift-argument-parser")],
+ ),
+ .testTarget(name: "TatamiToolsTests", dependencies: [
+ "TatamiToolsKit",
+ .product(name: "HummingbirdTesting", package: "hummingbird"),
+ ]),
+ ],
+)
diff --git a/Tools/Sources/TatamiToolsKit/Automation.swift b/Tools/Sources/TatamiToolsKit/Automation.swift
new file mode 100644
index 0000000..d25edc9
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/Automation.swift
@@ -0,0 +1,144 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+// MARK: - FixtureAutomation
+
+enum FixtureAutomation {
+ static func focusSession() async throws {
+ let cli = ProcessInfo.processInfo.environment["TATAMI_CLI"] ?? "tatami"
+ for arguments in [["workspace", "activate", "Design"], ["layout", "balance"], ["workspace", "borrow", "from", "Notes"]] {
+ try await runProcess([cli] + arguments)
+ }
+ print("\nFocus-session commands submitted.")
+ }
+
+ static func setSpacing(_ gap: Int) throws {
+ try require((4...40).contains(gap), "gap must be 4...40")
+ guard let path = ProcessInfo.processInfo.environment["DEMO_CONFIG"] else { throw ToolError("DEMO_CONFIG is required") }
+ let file = URL(fileURLWithPath: path)
+ let source = try file.text()
+ let pattern = #"(?m)^(\s*gapInner\s*=\s*)\d+\s*$"#
+ try require(matches(pattern, source).count == 1, "Expected exactly one gapInner setting")
+ // A targeted source edit preserves all TOML comments and unrelated formatting.
+ try file.write(replacing(pattern, in: source) { $0[1] + String(gap) })
+ print("config.toml: gapInner = \(gap)\nTatami picks up the change through its normal live reload.")
+ }
+}
+
+// MARK: - MarketingBuilder
+
+struct MarketingBuilder {
+ let workspace: Workspace
+
+ func overview() async throws {
+ let source = workspace.root.at("Resources/Marketing/screenshots")
+ let scratch = fm.temporaryDirectory.at("tatami-overview-" + UUID().uuidString)
+ try scratch.makeDirectory()
+ defer { try? fm.removeItem(at: scratch) }
+ func magick(_ arguments: [String], capture: Bool = false) async throws -> String {
+ try await runProcess(
+ ["magick"] + arguments,
+ capture: capture,
+ )
+ }
+ func dimension(_ file: URL, _ field: String) async throws -> Int {
+ let raw = trim(try await magick(["identify", "-format", field, file.path], capture: true))
+ guard let value = Int(raw), value > 0 else { throw ToolError("Invalid image dimensions") }
+ return value
+ }
+ for (name, output) in [("workspaces", "ws"), ("borrow", "bw")] {
+ _ = try await magick([
+ source.at(name + ".png").path,
+ "-resize",
+ "x1000",
+ "-depth",
+ "8",
+ "-strip",
+ scratch.at("flat.png").path,
+ ])
+ let width = try await dimension(scratch.at("flat.png"), "%w")
+ _ = try await magick([
+ "-size",
+ "\(width)x1000",
+ "xc:black",
+ "-fill",
+ "white",
+ "-draw",
+ "roundrectangle 0,0,\(width - 1),999,24,24",
+ "-alpha",
+ "off",
+ scratch.at("mask.png").path,
+ ])
+ _ = try await magick([
+ scratch.at("flat.png").path,
+ scratch.at("mask.png").path,
+ "-alpha",
+ "off",
+ "-compose",
+ "CopyOpacity",
+ "-composite",
+ scratch.at("round.png").path,
+ ])
+ _ = try await magick([
+ scratch.at("round.png").path,
+ "(",
+ "+clone",
+ "-background",
+ "black",
+ "-shadow",
+ "45x26+0+14",
+ ")",
+ "+swap",
+ "-background",
+ "none",
+ "-layers",
+ "merge",
+ "+repage",
+ scratch.at(output + ".png").path,
+ ])
+ }
+ let width = try await dimension(scratch.at("ws.png"), "%w")
+ let height = try await dimension(scratch.at("ws.png"), "%h")
+ _ = try await magick([
+ source.at("guided-setup.png").path,
+ "-trim",
+ "+repage",
+ "-resize",
+ "\(width)x",
+ "-depth",
+ "8",
+ "-strip",
+ scratch.at("guide.png").path,
+ ])
+ let guideHeight = try await dimension(scratch.at("guide.png"), "%h")
+ _ = try await magick([
+ "-size",
+ "\(width + 280)x\(1240 + height + guideHeight + 60)",
+ "xc:none",
+ scratch.at("bw.png").path,
+ "-geometry",
+ "+0+0",
+ "-composite",
+ scratch.at("ws.png").path,
+ "-geometry",
+ "+110+620",
+ "-composite",
+ scratch.at("guide.png").path,
+ "-geometry",
+ "+220+1240",
+ "-composite",
+ "-trim",
+ "+repage",
+ "-bordercolor",
+ "none",
+ "-border",
+ "20",
+ "-define",
+ "png:compression-level=9",
+ source.at("overview.png").path,
+ ])
+ _ = try await magick(["identify", "-format", "Built %f %wx%h %B bytes\n", source.at("overview.png").path])
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/BundleBuilder.swift b/Tools/Sources/TatamiToolsKit/BundleBuilder.swift
new file mode 100644
index 0000000..bb70432
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/BundleBuilder.swift
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+struct BundleBuilder: Sendable {
+ let workspace: Workspace
+
+ func build(output: URL?) async throws {
+ #if os(macOS)
+ let out = output ?? ProcessInfo.processInfo.environment["DEMOLAB_OUT"].map { URL(fileURLWithPath: $0) } ?? workspace.lab
+ .at(".build/DemoLab")
+ let identity = ProcessInfo.processInfo.environment["CODESIGN_IDENTITY"] ?? "-"
+ try out.makeDirectory()
+ try await runProcess(["swift", "build", "--package-path", workspace.lab.path, "-c", "release"])
+ let bin = URL(fileURLWithPath: trim(try await runProcess(
+ ["swift", "build", "--package-path", workspace.lab.path, "-c", "release", "--show-bin-path"],
+ capture: true,
+ )))
+ let catalog = try JSON.parse(await runProcess([bin.at("democtl").path, "catalog"], capture: true)).array
+ try require(!catalog.isEmpty, "Demo app catalog is empty")
+ try SceneLocalizer(workspace: workspace).compileStrings(output: out.at("Localization"))
+ let scratch = workspace.lab.at(".build/bundle-icons-" + UUID().uuidString)
+ try scratch.makeDirectory()
+ defer { try? fm.removeItem(at: scratch) }
+ try await runProcess([bin.at("demoicon").path, "--all", "--out", scratch.at("iconsets").path])
+ for file in try scratch.at("iconsets").children() where file.pathExtension == "iconset" {
+ try await runProcess(["iconutil", "-c", "icns", "-o", scratch.at(file.stem + ".icns").path, file.path])
+ }
+ for spec in catalog {
+ let name = spec["name"].str
+ let bundle = out.at(name + ".app")
+ try require(fm.isExecutableFile(atPath: bin.at(name).path), "Missing executable: \(name)")
+ if bundle.exists { try fm.removeItem(at: bundle) }
+ let resources = bundle.at("Contents/Resources")
+ try resources.makeDirectory()
+ try copy(bin.at(name), bundle.at("Contents/MacOS/" + name))
+ for localization in try out.at("Localization").children() where localization.pathExtension == "lproj" { try copy(
+ localization,
+ resources.at(localization.lastPathComponent),
+ ) }
+ var plist: [String: Any] = [
+ "CFBundleDevelopmentRegion": "en",
+ "CFBundleName": name,
+ "CFBundleDisplayName": name,
+ "CFBundleExecutable": name,
+ "CFBundleIdentifier": spec["bundleIdentifier"].str,
+ "CFBundlePackageType": "APPL",
+ "CFBundleShortVersionString": "1.0",
+ "CFBundleVersion": "1",
+ "LSMinimumSystemVersion": "14.0",
+ "LSApplicationCategoryType": spec["categoryType"].str,
+ "NSPrincipalClass": "NSApplication",
+ "NSHighResolutionCapable": true,
+ "NSSupportsAutomaticTermination": false,
+ "NSSupportsSuddenTermination": false,
+ ]
+ if spec["agent"].boolean { plist["LSUIElement"] = true }
+ if let usage = spec["captureUsageDescription"].string { plist["NSScreenCaptureUsageDescription"] = usage }
+ let icon = scratch.at(name + ".icns")
+ if icon.exists { try copy(icon, resources.at(icon.lastPathComponent))
+ plist["CFBundleIconFile"] = name
+ } else { try require(spec["agent"].boolean, "Missing icon for \(name)") }
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try data.write(to: bundle.at("Contents/Info.plist"), options: .atomic)
+ try await runProcess(["codesign", "--force", "--sign", identity, "--timestamp=none", bundle.path])
+ try await runProcess([
+ "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
+ "-f",
+ bundle.path,
+ ])
+ print("Bundled \(name): \(spec["bundleIdentifier"].str)")
+ }
+ for tool in ["democtl", "demokey", "demohook"] { try copy(bin.at(tool), out.at("bin/" + tool)) }
+ try await buildVirtualDisplay()
+ let tools = workspace.lab.at(".build/tools/tatami-tools")
+ let current = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
+ if current != tools.resolvingSymlinksInPath() { try copy(current, tools) }
+ print("Bundles: \(out.path). Signed with \(identity). Ad-hoc rebuilds require renewed Screen Recording consent.")
+ #else
+ throw ToolError("Demo app bundling requires macOS")
+ #endif
+ }
+
+ func buildVirtualDisplay() async throws {
+ #if os(macOS)
+ let output = workspace.lab.at(".build/tools/demodisplay")
+ try output.deletingLastPathComponent().makeDirectory()
+ try await runProcess([
+ "clang",
+ "-fobjc-arc",
+ "-framework",
+ "Cocoa",
+ "-framework",
+ "CoreGraphics",
+ workspace.lab.at("Sources/demodisplay/main.m").path,
+ "-o",
+ output.path,
+ ])
+ #else
+ throw ToolError("Virtual displays require macOS")
+ #endif
+ }
+
+ func control(_ arguments: [String]) async throws {
+ let binary = workspace.lab.at(".build/release/democtl")
+ if ProcessInfo.processInfo.environment["DEMOLAB_NO_BUILD"] != "1" {
+ try await runProcess(["swift", "build", "--package-path", workspace.lab.path, "-c", "release", "--product", "democtl"])
+ }
+ try require(fm.isExecutableFile(atPath: binary.path), "Missing democtl; build the Demo Lab first")
+ try await runProcess([binary.path] + arguments, cwd: workspace.lab, environment: ["DEMOLAB_ROOT": workspace.lab.path])
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/Core.swift b/Tools/Sources/TatamiToolsKit/Core.swift
new file mode 100644
index 0000000..a0aa873
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/Core.swift
@@ -0,0 +1,221 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+import Subprocess
+#if canImport(System)
+import System
+#else
+import SystemPackage
+#endif
+#if canImport(CryptoKit)
+import CryptoKit
+#else
+import Crypto
+#endif
+
+// MARK: - ToolError
+
+struct ToolError: Error, CustomStringConvertible {
+ init(_ message: String) {
+ description = message
+ }
+
+ let description: String
+}
+
+func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
+ if try !condition() { throw ToolError(message) }
+}
+
+let locales = ["en", "ko", "ja", "zh-Hans", "zh-Hant"]
+let languageNames = ["en": "English", "ko": "한국어", "ja": "日本語", "zh-Hans": "简体中文", "zh-Hant": "繁體中文"]
+var fm: FileManager {
+ FileManager()
+}
+
+extension URL {
+ var exists: Bool {
+ fm.fileExists(atPath: path)
+ }
+
+ var stem: String {
+ deletingPathExtension().lastPathComponent
+ }
+
+ func at(_ path: String) -> URL {
+ appendingPathComponent(path)
+ }
+
+ func replacingExtension(_ suffix: String) -> URL {
+ deletingPathExtension().appendingPathExtension(suffix)
+ }
+
+ func text() throws -> String {
+ try String(contentsOf: self, encoding: .utf8)
+ }
+
+ func makeDirectory() throws {
+ try fm.createDirectory(at: self, withIntermediateDirectories: true)
+ }
+
+ func write(_ text: String) throws {
+ try deletingLastPathComponent().makeDirectory()
+ try Data(text.utf8).write(to: self, options: .atomic)
+ }
+
+ func children() throws -> [URL] {
+ try fm.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).sorted { $0.lastPathComponent < $1.lastPathComponent }
+ }
+}
+
+func copy(_ source: URL, _ target: URL) throws {
+ try require(source.exists, "Copy source is missing: \(source.path)")
+ try require(
+ source.resolvingSymlinksInPath() != target.resolvingSymlinksInPath(),
+ "Copy source and destination are the same: \(source.path)",
+ )
+ try target.deletingLastPathComponent().makeDirectory()
+ if target.exists { try fm.removeItem(at: target) }
+ try fm.copyItem(at: source, to: target)
+}
+
+func sha(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+}
+
+func sha(_ file: URL) throws -> String {
+ let stream = try FileHandle(forReadingFrom: file)
+ defer { try? stream.close() }
+ var hash = SHA256()
+ while let data = try stream.read(upToCount: 1_048_576), !data.isEmpty { hash.update(data: data) }
+ return hash.finalize().map { String(format: "%02x", $0) }.joined()
+}
+
+func textKey(_ text: String) -> String {
+ String(sha(Data(text.utf8)).prefix(16))
+}
+
+func matches(_ pattern: String, _ text: String) -> [[String]] {
+ let regex = try! NSRegularExpression(pattern: pattern)
+ return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).map { match in
+ (0.. String) rethrows -> String {
+ let regex = try! NSRegularExpression(pattern: pattern)
+ var result = ""
+ var cursor = text.startIndex
+ for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) {
+ let groups = (0.. String {
+ value.trimmingCharacters(in: .whitespacesAndNewlines)
+}
+
+func fullMatch(_ pattern: String, _ text: String) -> Bool {
+ !matches("^(?:" + pattern + ")$", text).isEmpty
+}
+
+func lines(_ text: String) -> [String] {
+ var result = text.components(separatedBy: .newlines)
+ if result.last == "" { result.removeLast() }
+ return result
+}
+
+func relativePath(_ target: URL, from directory: URL) -> String {
+ let a = target.standardizedFileURL.pathComponents
+ let b = directory.standardizedFileURL.pathComponents
+ let common = zip(a, b).prefix { $0 == $1 }.count
+ return (Array(repeating: "..", count: b.count - common) + a.dropFirst(common)).joined(separator: "/")
+}
+
+/// The package owns pipe draining, cancellation, signal handling and child reaping.
+@discardableResult
+func runProcess(
+ _ arguments: [String],
+ cwd: URL? = nil,
+ environment: [String: String] = [:],
+ capture: Bool = false,
+) async throws -> String {
+ try require(!arguments.isEmpty, "Missing executable")
+ let executable: Executable = arguments[0].contains("/") ? .path(FilePath(arguments[0])) : .name(arguments[0])
+ let argv = Subprocess.Arguments(Array(arguments.dropFirst()))
+ let overrides = Dictionary(uniqueKeysWithValues: environment.map { (
+ Subprocess.Environment.Key(stringLiteral: $0.key),
+ Optional($0.value),
+ ) })
+ if capture {
+ let result = try await Subprocess.run(
+ executable,
+ arguments: argv,
+ environment: .inherit.updating(overrides),
+ workingDirectory: cwd.map { FilePath($0.path) },
+ output: .string(limit: 16 * 1024 * 1024),
+ error: .currentStandardError,
+ )
+ try require(result.terminationStatus == .exited(0), "\(arguments[0]): \(result.terminationStatus)")
+ return result.standardOutput
+ }
+ let result = try await Subprocess.run(
+ executable,
+ arguments: argv,
+ environment: .inherit.updating(overrides),
+ workingDirectory: cwd.map { FilePath($0.path) },
+ output: .currentStandardOutput,
+ error: .currentStandardError,
+ )
+ try require(result.terminationStatus == .exited(0), "\(arguments[0]): \(result.terminationStatus)")
+ return ""
+}
+
+// MARK: - Workspace
+
+struct Workspace: Sendable {
+ let root: URL
+ var labOverride: URL? = nil
+
+ var lab: URL {
+ labOverride ?? root.at("DemoLab")
+ }
+
+ var tools: URL {
+ root.at("Tools")
+ }
+
+ var assets: [JSON] {
+ get throws {
+ let file = lab.at("publication.json")
+ let contract = try JSONDecoder().decode(PublicationContract.self, from: Data(contentsOf: file))
+ try contract.validate()
+ return try JSON.read(file)["assets"].array
+ }
+ }
+
+ static func discover(_ explicit: String? = nil) throws -> Workspace {
+ var path = URL(fileURLWithPath: explicit ?? ProcessInfo.processInfo.environment["TATAMI_ROOT"] ?? fm.currentDirectoryPath)
+ .standardizedFileURL
+ while true {
+ if path.at("DemoLab/publication.json").exists { return Workspace(root: path) }
+ if path.at("publication.json").exists, path.at("Sources/DemoAppKit").exists {
+ return Workspace(root: path.deletingLastPathComponent(), labOverride: path)
+ }
+ let parent = path.deletingLastPathComponent()
+ if parent == path { throw ToolError("Run inside the Tatami checkout, or pass --root PATH") }
+ path = parent
+ }
+ }
+
+ func scene(_ asset: JSON) -> URL {
+ let locale = asset["locale"].string ?? "en"
+ return lab.at("scenes").at(locale == "en" ? "" : locale).at(asset["scene"].str + ".json")
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/DependencyNotices.swift b/Tools/Sources/TatamiToolsKit/DependencyNotices.swift
new file mode 100644
index 0000000..8bc0678
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/DependencyNotices.swift
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+struct DependencyNotices {
+ let workspace: Workspace
+
+ func rendered() async throws -> String {
+ let pins = try JSON.read(workspace.tools.at("Package.resolved"))["pins"].array
+ try require(!pins.isEmpty, "Tools/Package.resolved has no packages")
+ let checkouts = try workspace.tools.at(".build/checkouts").children()
+ var output = """
+ # Development Tool Dependency Notices
+
+ This file covers the complete dependency graph pinned in `Tools/Package.resolved`, including build and test dependencies. It is separate from the dependencies of the distributed Tatami app, listed in [the app notices](../THIRD_PARTY_NOTICES.md).
+
+ Generated by `swift run --package-path Tools tatami-tools build-tool-notices`. License and notice files are reproduced from the exact pinned Git revisions, including component notices inside those packages. Keep these original legal texts in their original language.
+
+
+
+ """
+ for pin in pins.sorted(by: { $0["identity"].str < $1["identity"].str }) {
+ let identity = pin["identity"].str
+ let revision = pin["state"]["revision"].str
+ guard let checkout = checkouts.first(where: { $0.lastPathComponent.lowercased() == identity.lowercased() }) else {
+ throw ToolError("Missing checkout for \(identity); run swift package --package-path Tools resolve")
+ }
+ let files = try await runProcess(["git", "-C", checkout.path, "ls-tree", "-r", "--name-only", revision], capture: true)
+ let notices = lines(files).filter { path in
+ fullMatch(#"(?i)(?:LICEN[CS]E|COPYING|NOTICE)(?:[._-].*)?"#, URL(fileURLWithPath: path).lastPathComponent)
+ }.sorted()
+ try require(!notices.isEmpty, "No license or notice files in \(identity) at \(revision)")
+ var source = pin["location"].str
+ if source.hasSuffix(".git") { source.removeLast(4) }
+ output += "## \(identity) \(pin["state"]["version"].string ?? revision)\n\nSource: \(source)\n\nRevision: `\(revision)`\n\n"
+ for path in notices {
+ let contents = try await runProcess(["git", "-C", checkout.path, "show", "\(revision):\(path)"], capture: true)
+ let fence = String(repeating: "~", count: max(4, matches("~+", contents).map { $0[0].count + 1 }.max() ?? 4))
+ output += "### `\(path)`\n\n\(fence)text\n\(contents)\(contents.hasSuffix("\n") ? "" : "\n")\(fence)\n\n"
+ }
+ }
+ return output + "\n"
+ }
+
+ func build(check: Bool) async throws {
+ let text = try await rendered()
+ let file = workspace.tools.at("THIRD_PARTY_NOTICES.md")
+ if check { try require(try file.text() == text, "Development tool notices have drifted from Package.resolved") }
+ else { try file.write(text) }
+ print("\(check ? "Verified" : "Generated") development tool dependency notices")
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/Documents.swift b/Tools/Sources/TatamiToolsKit/Documents.swift
new file mode 100644
index 0000000..dd485e2
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/Documents.swift
@@ -0,0 +1,138 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+struct DocumentBuilder {
+ static let navigationPattern = #"(?s)\n?.*?\n?"#
+
+ let workspace: Workspace
+
+ var documents: [String] {
+ get throws {
+ try ["README.md"] + workspace.root.at("docs").children().filter { $0.pathExtension == "md" }
+ .map { "docs/" + $0.lastPathComponent }
+ + ["DemoLab/README.md"] + workspace.lab.at("docs").children().filter { $0.pathExtension == "md" }
+ .map { "DemoLab/docs/" + $0.lastPathComponent }
+ + ["NOTICE.md", "THIRD_PARTY_NOTICES.md"]
+ }
+ }
+
+ func destination(_ source: String, _ locale: String) -> URL {
+ let file = workspace.root.at(source)
+ if locale == "en" { return file }
+ if !source.contains("/") { return file.deletingLastPathComponent().at(file.stem + "." + locale + "." + file.pathExtension) }
+ return file.deletingLastPathComponent().at(locale).at(file.lastPathComponent)
+ }
+
+ func languageLinks(_ source: String, _ locale: String) -> String {
+ let parent = destination(source, locale).deletingLastPathComponent()
+ return "\n" + locales.map { "[" + languageNames[$0]! + "](" + relativePath(
+ destination(source, $0),
+ from: parent,
+ ) + ")" }.joined(separator: " · ") + "\n\n\n"
+ }
+
+ func headings(_ source: String) -> [String] {
+ var result = [String]()
+ var used = [String: Int]()
+ let sourceLines = lines(source)
+ for lineNumber in MarkdownStructure(source).headingLines {
+ let line = sourceLines[lineNumber]
+ if let heading = matches(#"^#{1,6}\s+(.+)"#, line).first {
+ let label = replacing("<[^>]+>", in: heading[1]) { _ in "" }
+ let slug = trim(replacing(#"[^\w\s-]"#, in: label.lowercased()) { _ in "" }).replacingOccurrences(of: " ", with: "-")
+ let count = used[slug, default: 0]
+ used[slug] = count + 1
+ result.append(slug + (count > 0 ? "-\(count)" : ""))
+ }
+ }
+ return result
+ }
+
+ func anchorHeadings(_ source: String, _ anchors: [String]) throws -> String {
+ var result = [String]()
+ var index = 0
+ let headingLines = Set(MarkdownStructure(source).headingLines)
+ for (lineNumber, line) in lines(source).enumerated() {
+ if headingLines.contains(lineNumber), !matches(#"^#{1,6}\s"#, line).isEmpty {
+ try require(index < anchors.count, "Changed documentation heading count")
+ result.append(" ")
+ index += 1
+ }
+ result.append(line)
+ }
+ try require(index == anchors.count, "Changed documentation heading count")
+ return result.joined(separator: "\n") + "\n"
+ }
+
+ func rewriteLinks(_ text: String, _ source: String, _ locale: String) throws -> String {
+ let current = destination(source, locale)
+ let known = try Set(documents)
+ func url(_ value: String) -> String {
+ guard var parts = URLComponents(string: value) else { return value }
+ if parts.host == "pangmo5.dev" && (parts.path == "/Tatami" || parts.path.hasPrefix("/Tatami/")) {
+ let tail = String(parts.path.dropFirst("/Tatami".count)).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ parts.path = "/Tatami/" + locale + "/" + tail
+ return parts.string ?? value
+ }
+ if parts.scheme != nil || parts.host != nil || parts.path.isEmpty { return value }
+ let absolute = workspace.root.at(source).deletingLastPathComponent().at(parts.path).standardizedFileURL
+ guard absolute.path.hasPrefix(workspace.root.path + "/") else { return value }
+ let relative = relativePath(absolute, from: workspace.root)
+ var localized = known.contains(relative) && relative != "THIRD_PARTY_NOTICES.md" ? destination(relative, locale) : absolute
+ if
+ absolute.deletingLastPathComponent() == workspace.root.at("web"),
+ absolute.pathExtension == "jpg" { localized = workspace.root.at("web/media").at(locale).at(absolute.lastPathComponent) }
+ // Preserve the original query and fragment; code fences never enter this path.
+ let suffix = (value.contains("?") ? "?" + (parts.percentEncodedQuery ?? "") : "") + (value.contains("#")
+ ? "#" + (parts.percentEncodedFragment ?? "")
+ : "")
+ return relativePath(localized, from: current.deletingLastPathComponent()) + suffix
+ }
+ var result = [String]()
+ let codeLines = MarkdownStructure(text).codeLines
+ for (lineNumber, original) in lines(text).enumerated() {
+ var line = original
+ if codeLines.contains(lineNumber) { result.append(line)
+ continue
+ }
+ line = replacing(#"(?<=\]\()([^\s)]+)(?=\))"#, in: line) { url($0[0]) }
+ line = replacing(#"\b(href|src)="([^"]+)""#, in: line) { $0[1] + "=\"" + url($0[2]) + "\"" }
+ line = replacing(#"^(\[[^]]+\]:\s*)(\S+)"#, in: line) { $0[1] + url($0[2]) }
+ result.append(line)
+ }
+ return result.joined(separator: "\n") + "\n"
+ }
+
+ func outputs(selected: [String] = []) throws -> [(URL, String)] {
+ let values = try JSON.read(workspace.root.at("Localization/Docs.json"))
+ var outputs = [(URL, String)]()
+ for source in try documents where selected.isEmpty || selected.contains(source) {
+ let text = replacing(Self.navigationPattern, in: try workspace.root.at(source).text()) { _ in "" }
+ .drop(while: { $0 == "\n" })
+ let localizedSource = try source == "THIRD_PARTY_NOTICES.md"
+ ? workspace.root.at("Localization/ThirdPartyNotice.md").text()
+ : String(text)
+ for locale in locales.dropFirst() {
+ var translated = try markdownUnits(localizedSource, TextCatalog(values: values, locale: locale), source)
+ translated = try anchorHeadings(translated, headings(localizedSource))
+ outputs.append((
+ destination(source, locale),
+ try languageLinks(source, locale) + rewriteLinks(translated, source, locale),
+ ))
+ }
+ outputs.append((destination(source, "en"), languageLinks(source, "en") + text))
+ }
+ return outputs
+ }
+
+ func build(selected: [String] = [], check: Bool = false) throws {
+ let outputs = try outputs(selected: selected)
+ for (file, text) in outputs {
+ if check { try require(try file.text() == text, "Generated document drift: \(file.path)") }
+ else { try file.write(text) }
+ }
+ print("\(check ? "Verified" : "Generated") \(outputs.count) localized documents")
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/FilmTheme.swift b/Tools/Sources/TatamiToolsKit/FilmTheme.swift
new file mode 100644
index 0000000..4a773ec
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/FilmTheme.swift
@@ -0,0 +1,132 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+struct FilmPresentation {
+
+ // MARK: Lifecycle
+
+ init(workspace: Workspace) throws {
+ let blocks = try matches(#":root\s*\{([^}]+)\}"#, workspace.root.at("web/style.css").text())
+ try require(blocks.count >= 2, "Website dark palette is missing")
+ var tokens = [String: String]()
+ for block in blocks
+ .prefix(2) { for token in matches(#"--([\w-]+)\s*:\s*([^;]+);"#, block[1]) { tokens[token[1]] = token[2] } }
+ func color(_ key: String) throws -> String {
+ guard let value = tokens[key] else { throw ToolError("Missing CSS color: \(key)") }
+ var raw = trim(value).trimmingCharacters(in: CharacterSet(charactersIn: "#"))
+ if raw.count == 3 { raw = raw.map { "\($0)\($0)" }.joined() }
+ try require(fullMatch("[0-9a-fA-F]{6}", raw), "\(key) is not a hex color")
+ return raw.uppercased()
+ }
+ colors = try [
+ "background": color("bg-gray"),
+ "text": color("text"),
+ "secondary": color("text-secondary"),
+ "accent": color("accent"),
+ ]
+ }
+
+ // MARK: Internal
+
+ static let fonts = [
+ "en": "Helvetica Neue",
+ "ko": "Apple SD Gothic Neo",
+ "ja": "Hiragino Sans",
+ "zh-Hans": "Heiti SC",
+ "zh-Hant": "Heiti TC",
+ ]
+
+ let colors: [String: String]
+
+ var json: JSON {
+ .object(["background", "text", "secondary", "accent"].map { ($0, .string(colors[$0]!)) })
+ }
+
+ static func bgr(_ rgb: String) -> String {
+ let chars = Array(rgb)
+ return String(chars[4...5] + chars[2...3] + chars[0...1])
+ }
+
+ static func requireFont(_ locale: String, texts: [String], resolved: String? = nil) async throws {
+ guard let expected = fonts[locale] else { throw ToolError("Unsupported caption locale: \(locale)") }
+ let fontOutput: String =
+ if let resolved { resolved } else { try await runProcess(
+ ["fc-match", "-f", "%{family}\n%{charset}\n", expected],
+ capture: true,
+ ) }
+ let result = lines(fontOutput)
+ func normalize(_ value: String) -> String {
+ value.lowercased().filter { !$0.isWhitespace }
+ }
+ try require(
+ result.count >= 2 && result[0].components(separatedBy: ",").map(normalize).contains(normalize(expected)),
+ "Required caption font is unavailable: \(expected); resolved \(result.first ?? "nothing")",
+ )
+ let ranges = try result[1].split(whereSeparator: \.isWhitespace).map { entry -> ClosedRange in
+ let bounds = entry.split(separator: "-")
+ guard
+ let low = UInt32(bounds[0], radix: 16), let high = UInt32(bounds.last!, radix: 16),
+ low <= high
+ else { throw ToolError("Invalid font character range: \(entry)") }
+ return low...high
+ }
+ let missing = Set(texts.flatMap(\.unicodeScalars).filter { scalar in
+ !CharacterSet.whitespacesAndNewlines.contains(scalar) && !ranges.contains(where: { $0.contains(scalar.value) })
+ })
+ try require(
+ missing.isEmpty,
+ "\(expected) lacks caption glyphs: \(missing.sorted { $0.value < $1.value }.map(String.init).joined())",
+ )
+ }
+
+ func restyle(_ ass: String, locale: String = "en", dual: Bool = false) throws -> String {
+ guard let font = Self.fonts[locale] else { throw ToolError("Unsupported caption locale: \(locale)") }
+ var output = [String]()
+ for original in lines(ass) {
+ var line = original
+ if line.hasPrefix("Style: ") {
+ var fields = String(line.dropFirst(7)).components(separatedBy: ",")
+ try require(fields.count >= 23, "Incomplete ASS style")
+ if fields[0] != "Keys" { fields[1] = font }
+ if ["Caption", "Chapter", "Keys"].contains(fields[0]) {
+ let color = colors[fields[0] == "Caption" ? "text" : "accent"]!
+ fields[3] = "&H00" + Self.bgr(color)
+ fields[4] = fields[3]
+ fields[5] = "&H40141414"
+ fields[6] = fields[5]
+ fields.replaceSubrange(15..<18, with: ["3", "10", "0"])
+ switch fields[0] {
+ case "Caption": fields[2] = dual ? "36" : "42"
+ fields.replaceSubrange(18..<22, with: ["2", "110", "110", dual ? "52" : "96"])
+
+ case "Chapter": fields[2] = dual ? "22" : "26"
+ fields.replaceSubrange(18..<22, with: ["7", "32", "700", dual ? "36" : "54"])
+
+ default: fields[2] = dual ? "34" : "38"
+ fields.replaceSubrange(18..<22, with: ["9", "1300", "32", dual ? "36" : "54"])
+ }
+ line = "Style: " + fields.joined(separator: ",")
+ }
+ }
+ if
+ line
+ .contains(",Caption,,")
+ { line = replacing(#"\\1c&H[0-9A-Fa-f]+&"#, in: line) { _ in
+ #"\1c&H"# + Self.bgr(colors["secondary"]!) + "&"
+ } }
+ output.append(line)
+ }
+ return output.joined(separator: "\n") + "\n"
+ }
+
+ func writeSwift(workspace: Workspace) throws {
+ var text = "// Generated by tatami-tools video-theme from web/style.css.\n// SPDX-FileCopyrightText: 2026 PangMo5 and contributors\n// SPDX-License-Identifier: AGPL-3.0-only\nenum FilmTheme {\n"
+ text += " static let background = \"\(colors["background"]!)\"\n"
+ for key in ["text", "secondary", "accent"] { text += " static let \(key) = \"&H00\(Self.bgr(colors[key]!))\"\n" }
+ text += "}\n"
+ try workspace.lab.at("Sources/DemoCtlKit/FilmTheme.swift").write(text)
+ }
+
+}
diff --git a/Tools/Sources/TatamiToolsKit/GuestSetup.swift b/Tools/Sources/TatamiToolsKit/GuestSetup.swift
new file mode 100644
index 0000000..14e78cd
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/GuestSetup.swift
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+extension VMWorkflow {
+ func provision() async throws {
+ try await sync(build: true)
+ let home = trim(try await guest(["/usr/bin/printenv", "HOME"], capture: true))
+ let destination = URL(fileURLWithPath: home).at(environment["GUEST_DIR"] ?? "DemoLab")
+ let forwarded = ["TATAMI_APP", "TATAMI_DMG"].compactMap { key in environment[key].map { key + "=" + $0 } }
+ _ = try await guest(["/usr/bin/env"] + forwarded + [
+ destination.at(".build/tools/tatami-tools").path,
+ "vm-setup",
+ "--root",
+ destination.path,
+ ])
+ }
+}
+
+extension VMGuest {
+ static func setup(workspace: Workspace) async throws {
+ try await requireGuest()
+ let installed = URL(fileURLWithPath: "/Applications/Tatami.app")
+ let environment = ProcessInfo.processInfo.environment
+ if installed.exists {
+ let plist = try PropertyListSerialization.propertyList(
+ from: Data(contentsOf: installed.at("Contents/Info.plist")),
+ format: nil,
+ ) as? [String: Any]
+ print("Tatami already installed: \(plist?["CFBundleShortVersionString"] as? String ?? "unknown")")
+ } else if let app = environment["TATAMI_APP"], !app.isEmpty {
+ try require(URL(fileURLWithPath: app).exists, "TATAMI_APP does not exist")
+ try await runProcess(["ditto", app, installed.path])
+ } else if let dmg = environment["TATAMI_DMG"], !dmg.isEmpty {
+ try require(URL(fileURLWithPath: dmg).exists, "TATAMI_DMG does not exist")
+ let mount = fm.temporaryDirectory.at("tatami-dmg-" + UUID().uuidString)
+ try mount.makeDirectory()
+ defer { try? fm.removeItem(at: mount) }
+ try await runProcess(["hdiutil", "attach", dmg, "-nobrowse", "-quiet", "-mountpoint", mount.path])
+ do { try await runProcess(["ditto", mount.at("Tatami.app").path, installed.path]) }
+ catch { _ = try? await runProcess(["hdiutil", "detach", mount.path, "-quiet"])
+ throw error
+ }
+ try await runProcess(["hdiutil", "detach", mount.path, "-quiet"])
+ } else {
+ throw ToolError(
+ "Tatami is not installed. Provide TATAMI_APP or TATAMI_DMG as a guest-visible path; provisioning does not download the app."
+ )
+ }
+ try await desktopDefaults()
+ do { try await TakeLibrary(workspace: workspace).control(["doctor"]) }
+ catch {
+ print(
+ "Doctor reported blockers: \(error). Grant the documented Accessibility and Screen Recording permissions in the guest GUI before recording."
+ )
+ }
+ print(
+ "Provisioning complete. Dismiss first-run notifications, grant the permissions in DemoLab/docs/PERMISSIONS.md, then snapshot the VM."
+ )
+ }
+
+ static func desktopDefaults() async throws {
+ try await requireGuest()
+ func attempt(_ label: String, _ command: [String]) async {
+ do { _ = try await runProcess(command, capture: true)
+ print("ok: \(label)")
+ } catch { print("skip: \(label): \(error)") }
+ }
+ let dock: [(String, String, String)] = [
+ ("autohide", "-bool", "true"),
+ ("autohide-delay", "-float", "1000"),
+ ("autohide-time-modifier", "-float", "0"),
+ ("show-recents", "-bool", "false"),
+ ("launchanim", "-bool", "false"),
+ ("mru-spaces", "-bool", "false"),
+ ("workspaces-auto-swoosh", "-bool", "false"),
+ ]
+ for (key, type, value) in dock { await attempt(key, ["defaults", "write", "com.apple.dock", key, type, value]) }
+ for corner in ["tl", "tr", "bl", "br"] { await attempt(
+ "disable hot corner \(corner)",
+ ["defaults", "write", "com.apple.dock", "wvous-\(corner)-corner", "-int", "0"],
+ ) }
+ await attempt("restart Dock", ["killall", "Dock"])
+ let settings: [(String, String, String, String)] = [
+ ("com.apple.finder", "CreateDesktop", "-bool", "false"),
+ ("com.apple.WindowManager", "EnableStandardClickToShowDesktop", "-bool", "false"),
+ ("com.apple.WindowManager", "GloballyEnabled", "-bool", "false"),
+ ("NSGlobalDomain", "NSAutomaticWindowAnimationsEnabled", "-bool", "false"),
+ ("NSGlobalDomain", "NSWindowResizeTime", "-float", "0.001"),
+ ("NSGlobalDomain", "AppleShowScrollBars", "-string", "Always"),
+ ("NSGlobalDomain", "NSQuitAlwaysKeepsWindows", "-bool", "false"),
+ ("com.apple.menuextra.clock", "ShowSeconds", "-bool", "false"),
+ ("com.apple.menuextra.clock", "ShowDate", "-int", "2"),
+ ]
+ for (domain, key, type, value) in settings { await attempt(key, ["defaults", "write", domain, key, type, value]) }
+ for owner in ["Finder", "WindowManager", "SystemUIServer"] { await attempt("restart \(owner)", ["killall", owner]) }
+ if (try? await runProcess(["pgrep", "-f", "caffeinate -dimsu"], capture: true)) == nil {
+ // launchd owns the persistent assertion after this setup command exits.
+ await attempt(
+ "start caffeinate",
+ ["launchctl", "submit", "-l", "dev.PangMo5.DemoLab.caffeinate", "--", "/usr/bin/caffeinate", "-dimsu"],
+ )
+ }
+ await attempt("no screen saver", ["defaults", "-currentHost", "write", "com.apple.screensaver", "idleTime", "-int", "0"])
+ if (try? await runProcess(["sudo", "-n", "true"], capture: true)) != nil {
+ await attempt("no display/system sleep", ["sudo", "-n", "pmset", "-a", "displaysleep", "0", "sleep", "0", "disksleep", "0"])
+ for key in ["AutomaticCheckEnabled", "AutomaticDownload", "AutomaticallyInstallMacOSUpdates"] { await attempt(
+ key,
+ ["sudo", "-n", "defaults", "write", "/Library/Preferences/com.apple.SoftwareUpdate", key, "-bool", "false"],
+ ) }
+ await attempt(
+ "no App Store updates",
+ ["sudo", "-n", "defaults", "write", "/Library/Preferences/com.apple.commerce", "AutoUpdate", "-bool", "false"],
+ )
+ await attempt("unschedule updates", ["sudo", "-n", "softwareupdate", "--schedule", "off"])
+ await attempt("disable indexing", ["sudo", "-n", "mdutil", "-a", "-i", "off"])
+ } else { print("skip: system sleep, software updates and indexing need sudo") }
+ print(
+ "Set Focus, wallpaper and screen-capture permissions in System Settings, then snapshot. Log out and back in for desktop settings to settle."
+ )
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/JSON.swift b/Tools/Sources/TatamiToolsKit/JSON.swift
new file mode 100644
index 0000000..bfd43ab
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/JSON.swift
@@ -0,0 +1,162 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+
+/// Dynamic manifest fields use Foundation Codable; no custom JSON grammar or writer.
+indirect enum JSON: Sendable, Equatable, Codable {
+ case object([(String, JSON)])
+ case array([JSON])
+ case string(String)
+ case number(String)
+ case bool(Bool)
+ case null
+
+ // MARK: Lifecycle
+
+ init(from decoder: any Decoder) throws {
+ let value = try decoder.singleValueContainer()
+ if value.decodeNil() { self = .null }
+ else if let bool = try? value.decode(Bool.self) { self = .bool(bool) }
+ else if let text = try? value.decode(String.self) { self = .string(text) }
+ else if let number = try? value.decode(Int64.self) { self = .number(String(number)) }
+ else if let number = try? value.decode(Double.self) { self = .number(String(number)) }
+ else if let array = try? value.decode([JSON].self) { self = .array(array) }
+ else {
+ let object = try value.decode([String: JSON].self)
+ self = .object(object.sorted { $0.key < $1.key }.map { ($0.key, $0.value) })
+ }
+ }
+
+ // MARK: Internal
+
+ var object: [(String, JSON)] {
+ if case .object(let value) = self { return value }
+ return []
+ }
+
+ var array: [JSON] {
+ if case .array(let value) = self { return value }
+ return []
+ }
+
+ var string: String? {
+ if case .string(let value) = self { return value }
+ return nil
+ }
+
+ var str: String {
+ string ?? ""
+ }
+
+ var double: Double {
+ if case .number(let value) = self { return Double(value) ?? .nan }
+ return Double(str) ?? .nan
+ }
+
+ var int: Int {
+ double.isFinite ? Int(double) : 0
+ }
+
+ var boolean: Bool {
+ self == .bool(true)
+ }
+
+ var isNull: Bool {
+ self == .null
+ }
+
+ static func ==(lhs: JSON, rhs: JSON) -> Bool {
+ switch (lhs, rhs) {
+ case (.object(let a), .object(let b)):
+ a.count == b.count && a.allSatisfy { key, value in b.first { $0.0 == key }?.1 == value }
+ case (.array(let a), .array(let b)): a == b
+ case (.string(let a), .string(let b)): a == b
+ case (.number(let a), .number(let b)): Double(a) == Double(b)
+ case (.bool(let a), .bool(let b)): a == b
+ case (.null, .null): true
+ default: false
+ }
+ }
+
+ static func integer(_ value: Int) -> JSON {
+ .number(String(value))
+ }
+
+ static func decimal(_ value: Double) -> JSON {
+ .number(String(value))
+ }
+
+ static func read(_ file: URL) throws -> JSON {
+ try parse(file.text())
+ }
+
+ static func parse(_ source: String) throws -> JSON {
+ try JSONDecoder().decode(JSON.self, from: Data(source.utf8))
+ }
+
+ static func quote(_ text: String) -> String {
+ // Encoding a String cannot fail; withoutEscapingSlashes matches .strings syntax.
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.withoutEscapingSlashes]
+ return String(decoding: try! encoder.encode(text), as: UTF8.self)
+ }
+
+ subscript(_ key: String) -> JSON {
+ get { if case .object(let entries) = self { return entries.first { $0.0 == key }?.1 ?? .null }
+ return .null
+ }
+ set {
+ var entries = object
+ if let index = entries.firstIndex(where: { $0.0 == key }) { entries[index].1 = newValue }
+ else { entries.append((key, newValue)) }
+ self = .object(entries)
+ }
+ }
+
+ subscript(_ index: Int) -> JSON {
+ get { array[index] }
+ set { var items = array
+ items[index] = newValue
+ self = .array(items)
+ }
+ }
+
+ mutating func remove(_ key: String) {
+ self = .object(object.filter { $0.0 != key })
+ }
+
+ func merging(_ entries: [(String, JSON)]) -> JSON {
+ var value = self
+ for (key, item) in entries { value[key] = item }
+ return value
+ }
+
+ func rendered() throws -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ return String(decoding: try encoder.encode(self), as: UTF8.self)
+ }
+
+ func write(_ file: URL) throws {
+ // Existing scene bytes are referenced by accepted captures. Equivalent data
+ // is already current, so changing serializer formatting must not invalidate it.
+ if file.exists, try Self.read(file) == self { return }
+ try file.write(rendered() + "\n")
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var value = encoder.singleValueContainer()
+ switch self {
+ case .null: try value.encodeNil()
+ case .bool(let bool): try value.encode(bool)
+ case .string(let text): try value.encode(text)
+ case .array(let array): try value.encode(array)
+ case .object(let object): try value.encode(Dictionary(uniqueKeysWithValues: object))
+ case .number(let number):
+ if let integer = Int64(number) { try value.encode(integer) }
+ else if let decimal = Double(number), decimal.isFinite { try value.encode(decimal) }
+ else { throw ToolError("Invalid JSON number: \(number)") }
+ }
+ }
+}
diff --git a/Tools/Sources/TatamiToolsKit/Localization.swift b/Tools/Sources/TatamiToolsKit/Localization.swift
new file mode 100644
index 0000000..95c707e
--- /dev/null
+++ b/Tools/Sources/TatamiToolsKit/Localization.swift
@@ -0,0 +1,414 @@
+// SPDX-FileCopyrightText: 2026 PangMo5 and contributors
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import Foundation
+import SwiftSoup
+
+// MARK: - TextCatalog
+
+final class TextCatalog {
+
+ // MARK: Lifecycle
+
+ init(values: JSON = .object([]), locale: String = "en") {
+ self.values = values
+ self.locale = locale
+ }
+
+ // MARK: Internal
+
+ let values: JSON
+ let locale: String
+ var observed = [String: (english: String, context: String)]()
+
+ func text(_ source: String, _ context: String) throws -> String {
+ let normalized = trim(replacing(#"\s+"#, in: source) { _ in " " })
+ let remainder = trim(replacing(#"\{\d+\}"#, in: normalized) { _ in "" })
+ let identity: Set = [
+ "Tatami",
+ "GitHub",
+ "CLI",
+ "TOML",
+ "BSP",
+ "JSON",
+ "PangMo5",
+ "SwiftyCrow",
+ "Amado",
+ "macOS",
+ "UUID",
+ "UUID[]",
+ "UUID[]?",
+ "double?",
+ "table?",
+ "string[]?",
+ "bool",
+ "string",
+ "string[]",
+ "string?",
+ "int",
+ "double",
+ "table",
+ "table[]",
+ "en",
+ "ko",
+ "ja",
+ "zh-Hans",
+ "zh-Hant",
+ "true",
+ "false",
+ "null",
+ "Bool",
+ "String",
+ "Int",
+ "Float",
+ ]
+ if
+ normalized
+ .isEmpty || fullMatch(#"\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]"#, normalized) || matches("[A-Za-z]", remainder)
+ .isEmpty || identity.contains(remainder) { return source }
+ let key = textKey(normalized)
+ if observed[key] == nil { observed[key] = (normalized, context) }
+ if locale == "en" { return normalized }
+ let translated = values[key][locale].str
+ try require(!translated.isEmpty, "Missing \(locale): \(key) / \(normalized)")
+ try require(
+ matches(#"\{\d+\}"#, normalized).map { $0[0] }.sorted() == matches(#"\{\d+\}"#, translated).map { $0[0] }.sorted(),
+ "Changed placeholders: \(key) / \(locale)",
+ )
+ return translated
+ }
+
+}
+
+func htmlEscape(_ source: String, quotes: Bool = true) -> String {
+ var text = source.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<").replacingOccurrences(
+ of: ">",
+ with: ">",
+ )
+ if quotes { text = text.replacingOccurrences(of: "\"", with: """).replacingOccurrences(of: "'", with: "'") }
+ return text
+}
+
+let voidTags: Set = [
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "param",
+ "source",
+ "track",
+ "wbr",
+]
+
+// MARK: - HTMLNode
+
+final class HTMLNode {
+
+ // MARK: Lifecycle
+
+ init(_ tag: String, _ attributes: [(String, String?)] = [], _ children: [HTMLChild] = []) {
+ self.tag = tag
+ self.attributes = attributes
+ self.children = children
+ }
+
+ // MARK: Internal
+
+ let tag: String
+ var attributes: [(String, String?)]
+ var children: [HTMLChild]
+
+ subscript(_ attribute: String) -> String? {
+ get { attributes.first { $0.0 == attribute }?.1 }
+ set {
+ if let index = attributes.firstIndex(where: { $0.0 == attribute }) { attributes[index].1 = newValue }
+ else { attributes.append((attribute, newValue)) }
+ }
+ }
+
+ func has(_ attribute: String) -> Bool {
+ attributes.contains { $0.0 == attribute }
+ }
+
+ func render() -> String {
+ let content = children.map { $0.render() }.joined()
+ switch tag {
+ case "#root": return content
+ case "#markup": return children.first?.text ?? ""
+ case "#comment": return ""
+ case "#doctype": return ""
+ default: break
+ }
+ let attrs = attributes.map { " " + $0.0 + ($0.1.map { "=\"" + htmlEscape($0) + "\"" } ?? "") }.joined()
+ let opening = "<" + tag + attrs + ">"
+ if voidTags.contains(tag) { return opening }
+ return opening + (["script", "style"].contains(tag) ? children.compactMap(\.text).joined() : content) + "" + tag + ">"
+ }
+
+ func walk(_ visitor: (HTMLNode) throws -> Void) rethrows {
+ try visitor(self)
+ for child in children { if let node = child.node { try node.walk(visitor) } }
+ }
+
+}
+
+// MARK: - HTMLChild
+
+enum HTMLChild {
+ case text(String)
+ case node(HTMLNode)
+
+ var text: String? {
+ if case .text(let value) = self { return value }
+ return nil
+ }
+
+ var node: HTMLNode? {
+ if case .node(let value) = self { return value }
+ return nil
+ }
+
+ func render() -> String {
+ switch self
+ { case .text(let value): htmlEscape(value, quotes: false)
+ case .node(let value): value.render() }
+ }
+}
+
+/// SwiftSoup handles HTML tokenization, entities and the browser-compatible tree.
+/// The small translation tree keeps movable prose placeholders separate from markup.
+func translationTree(_ source: String) throws -> HTMLNode {
+ func convert(_ node: SwiftSoup.Node) throws -> HTMLChild {
+ if let node = node as? SwiftSoup.TextNode { return .text(node.getWholeText()) }
+ if let node = node as? SwiftSoup.DataNode { return .text(node.getWholeData()) }
+ if let node = node as? SwiftSoup.Comment { return .node(HTMLNode("#comment", [], [.text(node.getData())])) }
+ if node is SwiftSoup.DocumentType { return .node(HTMLNode("#markup", [], [.text(try node.outerHtml())])) }
+ let booleanAttributes: Set = [
+ "controls",
+ "muted",
+ "playsinline",
+ "defer",
+ "selected",
+ "disabled",
+ "hidden",
+ "autoplay",
+ "loop",
+ "checked",
+ "multiple",
+ "required",
+ "open",
+ ]
+ let attributes = node.getAttributes()?.asList().map { attribute -> (String, String?) in
+ let key = attribute.getKey()
+ let value = attribute.getValue()
+ return (key, booleanAttributes.contains(key) && value.isEmpty ? nil : value)
+ } ?? []
+ return .node(HTMLNode(node.nodeName(), attributes, try node.getChildNodes().map(convert)))
+ }
+ let nodes: [SwiftSoup.Node] =
+ if !matches("(?i)<(?:!doctype|html)\\b", source).isEmpty {
+ try SwiftSoup.parse(source).getChildNodes()
+ } else {
+ try SwiftSoup.parseBodyFragment(source).body()?.getChildNodes() ?? []
+ }
+ return HTMLNode("#root", [], try nodes.map(convert))
+}
+
+func translateHTML(_ source: String, _ catalog: TextCatalog, _ context: String) throws -> HTMLNode {
+ let root = try translationTree(source)
+ let blocks: Set = ["div", "section", "nav", "aside", "article", "main", "footer", "header", "ul", "ol", "li", "video", "pre"]
+ let translatable: Set = [
+ "p",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "title",
+ "a",
+ "button",
+ "summary",
+ "label",
+ "span",
+ "option",
+ "sub",
+ "small",
+ ]
+ func visit(_ node: HTMLNode) throws {
+ if ["script", "style", "pre", "#comment", "#doctype"].contains(node.tag) { return }
+ for attribute in [
+ "aria-label",
+ "title",
+ "alt",
+ "placeholder",
+ "data-play-hint",
+ "data-copied-label",
+ "data-copy-error",
+ "data-load-error",
+ "data-no-notes",
+ ] {
+ if let value = node[attribute], !value.isEmpty { node[attribute] = try catalog.text(value, context + " / " + attribute) }
+ }
+ if node.tag == "meta" && node["name"] == "description", let content = node["content"] { node["content"] = try catalog.text(
+ content,
+ context + " / meta",
+ ) }
+ for child in node.children { if let child = child.node { try visit(child) } }
+ let eligible = translatable.contains(node.tag) || (node.tag == "div" && node.children.allSatisfy { $0.text != nil })
+ if !eligible || node.children.contains(where: { $0.node.map { blocks.contains($0.tag) } ?? false }) { return }
+ var protected = [HTMLChild]()
+ var source = ""
+ func protect(_ child: HTMLChild) {
+ source += "{\(protected.count)}"
+ protected.append(child)
+ }
+ for child in node.children {
+ if
+ let element = child.node, ["em", "strong", "b", "i"].contains(element.tag),
+ element.children.allSatisfy({ $0.text != nil })
+ {
+ protect(.node(HTMLNode("#markup", [], [.text("<" + element.tag + ">")])))
+ source += element.children.compactMap(\.text).joined()
+ protect(.node(HTMLNode("#markup", [], [.text("" + element.tag + ">")])))
+ } else if child.node != nil { protect(child) }
+ else { source += child.text! }
+ }
+ let translated = try catalog.text(source, context + " / " + node.tag)
+ var children = [HTMLChild]()
+ var cursor = translated.startIndex
+ let regex = try! NSRegularExpression(pattern: #"\{(\d+)\}"#)
+ for match in regex.matches(in: translated, range: NSRange(translated.startIndex..., in: translated)) {
+ let range = Range(match.range, in: translated)!
+ if cursor < range.lowerBound { children.append(.text(String(translated[cursor.. String {
+ var protected = [String]()
+ let text = replacing(#"<[^>]+>|(`+)(.+?)\1|(?<=\]\()([^\s)]+)(?=\))|https?://[^\s<>]+"#, in: source) { match in
+ protected.append(match[0])
+ return "{\(protected.count - 1)}"
+ }
+ return try replacing(#"\{(\d+)\}"#, in: catalog.text(text, context)) { match in
+ let index = Int(match[1])!
+ try require(index < protected.count, "Invalid inline placeholder")
+ return protected[index]
+ }
+}
+
+func markdownUnits(_ source: String, _ catalog: TextCatalog, _ context: String) throws -> String {
+ let input = lines(source)
+ var result = [String]()
+ var index = 0
+ var fence: Character?
+ var referenceMatrix = false
+ while index < input.count {
+ let line = input[index]
+ if trim(line).isEmpty { referenceMatrix = false }
+ if context.hasSuffix("LOCALIZATION.md") && line.hasPrefix("| Concept |") { referenceMatrix = true }
+ if let marker = matches(#"^\s*(`{3,}|~{3,})"#, line).first {
+ if fence == nil { fence = marker[1].first } else if fence == marker[1].first { fence = nil }
+ result.append(line)
+ index += 1
+ continue
+ }
+ if
+ fence != nil || trim(line).isEmpty || line.hasPrefix(".*?"#,
+ #"(?s)\s*.*? "#,
+ #"(?s)\s*"#,
+ ] { text = replacing(pattern, in: text) { _ in "" } }
+ for (section, assets) in groups {
+ let regex = try NSRegularExpression(pattern: #"]*\bid=""# + NSRegularExpression
+ .escapedPattern(for: section) + #""[^>]*>"#)
+ guard
+ let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let range = Range(
+ match.range,
+ in: text,
+ ),
+ let end = text.range(of: " ", range: range.upperBound...*?"#).firstMatch(
+ in: region,
+ range: NSRange(region.startIndex..., in: region),
+ )
+ {
+ let offset = region.distance(from: region.startIndex, to: Range(paragraph.range, in: region)!.upperBound)
+ position = text.index(range.upperBound, offsetBy: offset)
+ } else {
+ guard let div = text.range(of: "", options: .backwards, range: range.upperBound..",
+ with: "\n