refactor(vishot-*,scenarios-*): better structure, better api, and markup components

This commit is contained in:
Neko Ayaka
2026-04-15 04:51:00 +08:00
parent 6d68cfbd92
commit f7a9a7bf76
149 changed files with 2761 additions and 528 deletions
+1
View File
@@ -315,6 +315,7 @@ words:
- Unlisten
- unocss
- unplugin
- unref
- unspeech
- upterm
- userversion
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 42 KiB

@@ -8,43 +8,67 @@ This package is the tamagotchi browser composition layer. It contains:
- the Vite/Vue scene app
- the scene composition components and capture roots
- file-based scene routing from `src/scenes/**` via `unplugin-vue-router`
- the `capture` script that exports final browser captures through `@proj-airi/vishot-runner-browser`
It expects raw business screenshots to exist in `artifacts/raw` before final export runs.
Current docs capture target route:
- `/docs/setup-and-use` -> `src/scenes/docs/setup-and-use/index.vue`
- `/docs/setup-and-use/intro-chat` -> `src/scenes/docs/setup-and-use/intro-chat.vue`
- `/docs/setup-and-use/intro-websocket` -> `src/scenes/docs/setup-and-use/intro-websocket.vue`
- `/docs/setup-and-use/main-window` -> `src/scenes/docs/setup-and-use/main-window.vue`
- `/docs/setup-and-use/settings` -> `src/scenes/docs/setup-and-use/settings.vue`
## Workflow
1. Build the Electron app.
2. Capture raw screenshots into this package's `artifacts/raw` directory.
3. Run this package's `capture` script to export the composed scene roots into `artifacts/final`.
1. Run Electron capture first and populate `artifacts/raw` (see electron package README).
2. Run browser capture and output final composed files to docs assets.
## Agent Quickstart
From repo root, run:
```bash
pnpm -F @proj-airi/stage-tamagotchi build
pnpm -F @proj-airi/vishot-runner-electron capture -- ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ../../packages/scenarios-stage-tamagotchi-browser/artifacts/raw
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture
pnpm -F @proj-airi/vishot-runner-electron capture ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket/index.ts --output-dir ../../packages/scenarios-stage-tamagotchi-browser/artifacts/raw --format avif
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --format avif --route '/docs/setup-and-use/intro-chat' --output-dir ../../docs/content/en/docs/manual/tamagotchi/setup-and-use/assets --settle-ms 800
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --format avif --route '/docs/setup-and-use/intro-websocket' --output-dir ../../docs/content/en/docs/manual/tamagotchi/setup-and-use/assets --settle-ms 800
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --format avif --route '/docs/setup-and-use/main-window' --output-dir ../../docs/content/en/docs/manual/tamagotchi/setup-and-use/assets --settle-ms 800
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --format avif --route '/docs/setup-and-use/settings' --output-dir ../../docs/content/en/docs/manual/tamagotchi/setup-and-use/assets --settle-ms 800
```
Expected raw inputs:
Expected results:
- `artifacts/raw/00-stage-tamagotchi.png`
- `artifacts/raw/04-websocket-settings.png`
- Raw: `27` files under `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`
- Final docs assets: `manual-*.avif` and `intro-chat-window.avif` under `docs/content/en/docs/manual/tamagotchi/setup-and-use/assets`
Current composed outputs:
## Capture Flags
- `artifacts/final/intro-chat-window.png`
- `artifacts/final/intro-websocket-settings.png`
The browser capture script supports:
Optional AVIF export:
- `--format <png|avif>` (default: `png`)
- `--route </path>` (default: `/docs/setup-and-use`)
- `--output-dir <path>` (default: `artifacts/final`)
- `--settle-ms <number>` (default: `500`) extra delay after scene ready before screenshot
Examples:
```bash
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture -- --format avif
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --format avif --settle-ms 800
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --route /docs/setup-and-use --settle-ms 800
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --route /docs/setup-and-use/settings --settle-ms 800
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture --format avif --route /docs/setup-and-use --output-dir ../../docs/content/en/docs/manual/tamagotchi/setup-and-use/assets --settle-ms 800
```
The browser capture flow still starts from PNG because that is the Playwright screenshot format. The package's `capture` script can opt into an AVIF post-processing step through Vishot's `imageTransformers` hook.
The browser capture flow starts from Playwright PNG screenshots, then optionally transforms to AVIF via Vishot `imageTransformers`.
## Notes
- The current scene app renders `src/scenes/intro-manual-scene.vue`.
- This package can host multiple scene pages. Add Vue SFC routes under `src/scenes/**` (for example `src/scenes/docs/setup-and-use/index.vue`).
- The default capture route is `/docs/setup-and-use`. Override it with `--route /your/path`.
- Captures are produced only for roots mounted by the exact route you pass. For full setup manual assets, run captures for `/docs/setup-and-use/intro-chat`, `/docs/setup-and-use/intro-websocket`, `/docs/setup-and-use/main-window`, and `/docs/setup-and-use/settings`.
- Final export depends on the browser scene reaching the `__SCENARIO_CAPTURE_READY__` flag after its raw images load.
- If raw capture is missing or stale, final export will fail or render outdated assets.
- If you enable AVIF output, the final artifact filenames switch from `.png` to `.avif` because the transformer replaces the emitted PNG files after capture.
- Paths in these `pnpm -F` examples are resolved from the filtered package working directory.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -26,15 +26,20 @@
"@napi-rs/image": "^1.12.0",
"@proj-airi/vishot-runner-browser": "workspace:^",
"@proj-airi/vishot-runtime": "workspace:^",
"@vueuse/core": "catalog:",
"date-fns": "catalog:",
"vue": "catalog:"
"reka-ui": "^2.9.5",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"@iconify-json/line-md": "catalog:",
"@iconify-json/material-symbols": "catalog:",
"@iconify-json/mdi": "catalog:",
"@iconify-json/ph": "catalog:",
"@unocss/reset": "^66.6.8",
"@vitejs/plugin-vue": "^6.0.5",
"unplugin-vue-router": "^0.19.2",
"vue-tsc": "^3.2.6"
}
}
@@ -8,14 +8,42 @@ import { argv, cwd } from 'node:process'
import { Transformer } from '@napi-rs/image'
import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser'
const DEFAULT_AVIF_MAX_WIDTH = 1200
const DEFAULT_AVIF_QUALITY = 35
const DEFAULT_AVIF_SPEED = 6
const sceneAppRoot = path.resolve(cwd())
const outputDir = path.resolve(sceneAppRoot, 'artifacts', 'final')
const formatFlagIndex = argv.findIndex(arg => arg === '--format')
const routeFlagIndex = argv.findIndex(arg => arg === '--route')
const outputDirFlagIndex = argv.findIndex(arg => arg === '--output-dir')
const settleMsFlagIndex = argv.findIndex(arg => arg === '--settle-ms')
const avifMaxWidthFlagIndex = argv.findIndex(arg => arg === '--avif-max-width')
const avifQualityFlagIndex = argv.findIndex(arg => arg === '--avif-quality')
const avifSpeedFlagIndex = argv.findIndex(arg => arg === '--avif-speed')
const requestedFormat = formatFlagIndex >= 0 ? argv[formatFlagIndex + 1] : 'png'
const routePath = routeFlagIndex >= 0 ? argv[routeFlagIndex + 1] : '/docs/setup-and-use'
const settleMs = settleMsFlagIndex >= 0 ? Number(argv[settleMsFlagIndex + 1]) : 500
const avifMaxWidth = avifMaxWidthFlagIndex >= 0 ? Number(argv[avifMaxWidthFlagIndex + 1]) : DEFAULT_AVIF_MAX_WIDTH
const avifQuality = avifQualityFlagIndex >= 0 ? Number(argv[avifQualityFlagIndex + 1]) : DEFAULT_AVIF_QUALITY
const avifSpeed = avifSpeedFlagIndex >= 0 ? Number(argv[avifSpeedFlagIndex + 1]) : DEFAULT_AVIF_SPEED
const outputDir = outputDirFlagIndex >= 0
? path.resolve(sceneAppRoot, argv[outputDirFlagIndex + 1])
: path.resolve(sceneAppRoot, 'artifacts', 'final')
const avifTransformer: ArtifactTransformer = async (artifact) => {
const derivedFilePath = artifact.filePath.replace(/\.png$/i, '.avif')
const avifBuffer = await new Transformer(await readFile(artifact.filePath)).avif()
const transformer = new Transformer(await readFile(artifact.filePath))
const metadata = await transformer.metadata()
if (metadata.width > avifMaxWidth) {
const scale = avifMaxWidth / metadata.width
transformer.resize(avifMaxWidth, Math.round(metadata.height * scale))
}
const avifBuffer = await transformer.avif({
quality: avifQuality,
speed: avifSpeed,
})
await writeFile(derivedFilePath, avifBuffer)
await rm(artifact.filePath, { force: true })
@@ -31,13 +59,54 @@ if (!['png', 'avif'].includes(requestedFormat)) {
throw new Error(`Unsupported capture format "${requestedFormat}". Expected "png" or "avif".`)
}
if (!routePath?.startsWith('/')) {
throw new Error(`Unsupported route path "${routePath}". Route paths must start with "/".`)
}
if (outputDirFlagIndex >= 0 && !argv[outputDirFlagIndex + 1]) {
throw new Error('Missing value for --output-dir.')
}
if (settleMsFlagIndex >= 0 && !argv[settleMsFlagIndex + 1]) {
throw new Error('Missing value for --settle-ms.')
}
if (avifMaxWidthFlagIndex >= 0 && !argv[avifMaxWidthFlagIndex + 1]) {
throw new Error('Missing value for --avif-max-width.')
}
if (avifQualityFlagIndex >= 0 && !argv[avifQualityFlagIndex + 1]) {
throw new Error('Missing value for --avif-quality.')
}
if (avifSpeedFlagIndex >= 0 && !argv[avifSpeedFlagIndex + 1]) {
throw new Error('Missing value for --avif-speed.')
}
if (!Number.isFinite(settleMs) || settleMs < 0) {
throw new Error(`Unsupported settle delay "${argv[settleMsFlagIndex + 1] ?? settleMs}". Expected a number >= 0.`)
}
if (!Number.isFinite(avifMaxWidth) || avifMaxWidth < 1) {
throw new Error(`Unsupported AVIF max width "${argv[avifMaxWidthFlagIndex + 1] ?? avifMaxWidth}". Expected a number >= 1.`)
}
if (!Number.isFinite(avifQuality) || avifQuality < 0 || avifQuality > 100) {
throw new Error(`Unsupported AVIF quality "${argv[avifQualityFlagIndex + 1] ?? avifQuality}". Expected a number between 0 and 100.`)
}
if (!Number.isFinite(avifSpeed) || avifSpeed < 1 || avifSpeed > 10) {
throw new Error(`Unsupported AVIF speed "${argv[avifSpeedFlagIndex + 1] ?? avifSpeed}". Expected a number between 1 and 10.`)
}
await captureBrowserRoots({
imageTransformers: requestedFormat === 'avif'
? [avifTransformer]
: undefined,
sceneAppRoot,
routePath: '/',
routePath,
outputDir,
settleMs,
viewport: {
width: 1920,
height: 1080,
@@ -1,9 +1,17 @@
<script setup lang="ts">
import IntroManualScene from './scenes/intro-manual-scene.vue'
import { ScreenNavigator, ScreenRouterProvider } from '@proj-airi/vishot-runtime/vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const sceneNavigatorEnabled = computed(() => route.meta.sceneNavigator !== false)
</script>
<template>
<IntroManualScene />
<ScreenRouterProvider>
<RouterView />
<ScreenNavigator v-if="sceneNavigatorEnabled" />
</ScreenRouterProvider>
</template>
<style>
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { createApp, h } from 'vue'
import PlatformRoot from './PlatformRoot.vue'
vi.mock('./ui', () => ({
Appearance: {
template: '<div data-test-appearance />',
},
Dock: {
template: '<div data-test-dock><slot name="dock" /></div>',
},
MenuBar: {
template: '<div data-test-menu-bar />',
},
}))
describe('platformRoot', () => {
it('renders wallpaper behind the windows layer', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp({
render: () => h(PlatformRoot, null, {
windows: () => h('div', { 'data-test-window': 'true' }),
}),
})
app.mount(host)
const wallpaper = host.querySelector<HTMLElement>('[data-test-appearance]')
const windowNode = host.querySelector<HTMLElement>('[data-test-window="true"]')
expect(wallpaper).not.toBeNull()
expect(windowNode).not.toBeNull()
expect(Boolean(wallpaper && windowNode && (wallpaper.compareDocumentPosition(windowNode) & Node.DOCUMENT_POSITION_FOLLOWING))).toBe(true)
app.unmount()
host.remove()
})
})
@@ -9,9 +9,11 @@ import { Appearance, Dock, MenuBar } from './ui'
const props = withDefaults(defineProps<{
aspectRatio?: string | number
dockSize?: number
uiScale?: number
}>(), {
aspectRatio: '16:9',
dockSize: 1.5,
uiScale: 1,
})
const aspectRatio = computed(() => {
@@ -25,18 +27,19 @@ const aspectRatio = computed(() => {
return props.aspectRatio.split(':').map(Number).reduce((a, b) => a / b)
})
const platformRoot = ref<HTMLElement | null>(null)
const platformSurface = ref<HTMLElement | null>(null)
const dockRoot = ref<HTMLElement | null>(null)
const normalizedUiScale = computed(() => props.uiScale > 0 ? props.uiScale : 1)
provide(injectPlatformLayout, {
dock: dockRoot,
root: readonly(platformRoot) as Readonly<Ref<HTMLElement | null>>,
root: readonly(platformSurface) as Readonly<Ref<HTMLElement | null>>,
uiScale: normalizedUiScale,
})
</script>
<template>
<div
ref="platformRoot"
:class="[
'relative overflow-hidden',
'font-macos',
@@ -46,16 +49,27 @@ provide(injectPlatformLayout, {
}"
>
<div
ref="platformSurface"
:class="[
'relative z-999',
'w-full h-full',
]"
:style="{
width: `${100 / normalizedUiScale}%`,
height: `${100 / normalizedUiScale}%`,
transform: `scale(${normalizedUiScale})`,
transformOrigin: 'top left',
}"
>
<Appearance />
<slot name="windows" />
<MenuBar />
<Dock :size="props.dockSize">
<template #dock>
<slot name="dock" />
</template>
</Dock>
</div>
<Appearance />
<MenuBar />
<Dock :size="props.dockSize" />
</div>
</template>
@@ -3,6 +3,7 @@ import type { InjectionKey, Ref } from 'vue'
export interface PlatformLayoutContext {
dock: Ref<HTMLElement | null>
root: Readonly<Ref<HTMLElement | null>>
uiScale: Readonly<Ref<number>>
}
export const injectPlatformLayout: InjectionKey<PlatformLayoutContext> = Symbol('vishot:platforms:macos-26:layout')
@@ -0,0 +1,153 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, readonly, ref } from 'vue'
import WindowRoot from './WindowRoot.vue'
import { injectPlatformLayout } from '../../constants'
function createRect(left: number, top: number, width: number, height: number): DOMRect {
return {
bottom: top + height,
height,
left,
right: left + width,
toJSON: () => ({}),
top,
width,
x: left,
y: top,
} as DOMRect
}
describe('windowRoot', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('re-anchors to workarea when dock is registered after mount', async () => {
vi.stubGlobal('requestAnimationFrame', ((callback: FrameRequestCallback) => {
callback(0)
return 1
}) as typeof requestAnimationFrame)
vi.stubGlobal('cancelAnimationFrame', (() => {}) as typeof cancelAnimationFrame)
const host = document.createElement('div')
document.body.appendChild(host)
const platformRef = ref<HTMLElement | null>(null)
const dockRef = ref<HTMLElement | null>(null)
const uiScaleRef = ref(1)
const app = createApp({
setup() {
return () => h('div', { ref: platformRef }, [
h(WindowRoot, {
anchorBounds: 'workarea',
anchorTo: 'bottom-right',
frame: false,
hasShadow: false,
style: {
bottom: '0px',
right: '0px',
},
}, () => h('div')),
])
},
provide: {
[injectPlatformLayout as symbol]: {
dock: dockRef,
root: readonly(platformRef),
uiScale: readonly(uiScaleRef),
},
},
})
app.mount(host)
await nextTick()
const platformEl = platformRef.value
const windowEl = host.querySelector<HTMLElement>('.absolute.flex.flex-col.rounded-2xl.overflow-hidden')
expect(platformEl).not.toBeNull()
expect(windowEl).not.toBeNull()
vi.spyOn(platformEl!, 'getBoundingClientRect').mockReturnValue(createRect(0, 0, 1000, 600))
vi.spyOn(windowEl!, 'getBoundingClientRect').mockReturnValue(createRect(900, 500, 100, 100))
const dockEl = document.createElement('div')
vi.spyOn(dockEl, 'getBoundingClientRect').mockReturnValue(createRect(920, 200, 70, 200))
dockRef.value = dockEl
await nextTick()
await nextTick()
expect(windowEl!.style.right).toBe('80px')
app.unmount()
host.remove()
})
it('uses measured workarea bounds when uiScale is applied so dock does not overlap anchored window', async () => {
vi.stubGlobal('requestAnimationFrame', ((callback: FrameRequestCallback) => {
callback(0)
return 1
}) as typeof requestAnimationFrame)
vi.stubGlobal('cancelAnimationFrame', (() => {}) as typeof cancelAnimationFrame)
const host = document.createElement('div')
document.body.appendChild(host)
const platformRef = ref<HTMLElement | null>(null)
const dockRef = ref<HTMLElement | null>(null)
const uiScaleRef = ref(1.25)
const app = createApp({
setup() {
return () => h('div', { ref: platformRef }, [
h(WindowRoot, {
anchorBounds: 'workarea',
anchorTo: 'bottom-right',
frame: false,
hasShadow: false,
style: {
bottom: '0px',
right: '0px',
},
}, () => h('div')),
])
},
provide: {
[injectPlatformLayout as symbol]: {
dock: dockRef,
root: readonly(platformRef),
uiScale: readonly(uiScaleRef),
},
},
})
app.mount(host)
await nextTick()
const platformEl = platformRef.value
const windowEl = host.querySelector<HTMLElement>('.absolute.flex.flex-col.rounded-2xl.overflow-hidden')
expect(platformEl).not.toBeNull()
expect(windowEl).not.toBeNull()
vi.spyOn(platformEl!, 'getBoundingClientRect').mockReturnValue(createRect(0, 0, 1646, 926))
vi.spyOn(windowEl!, 'getBoundingClientRect').mockReturnValue(createRect(1239, 383, 407, 543))
const dockEl = document.createElement('div')
vi.spyOn(dockEl, 'getBoundingClientRect').mockReturnValue(createRect(1571, 323, 66, 280))
dockRef.value = dockEl
await nextTick()
await nextTick()
expect(windowEl!.style.right).toBe('75px')
app.unmount()
host.remove()
})
})
@@ -1,11 +1,15 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { CSSProperties, StyleValue } from 'vue'
import { inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useAttrs, watch } from 'vue'
import { injectPlatformLayout } from '../../constants'
import { computeElementAnchorStyle, createContainerAnchorStyle, createWorkAreaRect } from './window-anchor'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<{
title?: string
focus?: boolean
@@ -26,8 +30,14 @@ const props = withDefaults(defineProps<{
})
const platformLayout = inject(injectPlatformLayout, null)
const attrs = useAttrs()
const windowRoot = ref<HTMLElement | null>(null)
const anchorStyle = shallowRef<CSSProperties | undefined>()
const incomingStyle = computed<StyleValue | undefined>(() => attrs.style as StyleValue | undefined)
const forwardedAttrs = computed(() => {
const { style: _style, ...rest } = attrs
return rest
})
let resizeObserver: ResizeObserver | null = null
let animationFrameId: number | null = null
@@ -66,9 +76,13 @@ function updateAnchorStyle() {
const platformRect = currentPlatformRoot?.getBoundingClientRect()
// When anchoring against the container, we can optionally shrink the usable
// bounds to the platform work area so windows avoid overlapping the dock.
const dockRect = platformLayout?.dock.value
? platformLayout.dock.value.getBoundingClientRect()
: null
const workAreaRect = platformRect
? createWorkAreaRect({
dockRect: props.anchorBounds === 'workarea' ? platformLayout?.dock.value?.getBoundingClientRect() ?? null : null,
dockRect: props.anchorBounds === 'workarea' ? dockRect : null,
platformRect,
})
: undefined
@@ -131,7 +145,14 @@ onMounted(async () => {
refreshResizeObserver()
})
watch(() => [props.anchorBounds, props.anchorTo, props.anchorEl], async () => {
watch(() => [
props.anchorBounds,
props.anchorTo,
props.anchorEl,
platformLayout?.uiScale.value,
platformLayout?.dock.value,
platformLayout?.root.value,
], async () => {
await nextTick()
queueAnchorUpdate()
refreshResizeObserver()
@@ -146,13 +167,14 @@ onBeforeUnmount(() => {
<template>
<div
ref="windowRoot"
v-bind="forwardedAttrs"
:class="[
'absolute',
'flex flex-col',
'rounded-2xl overflow-hidden',
props.hasShadow ? 'shadow-xl' : '',
]"
:style="anchorStyle"
:style="[incomingStyle, anchorStyle]"
>
<div
v-if="!!props.frame"
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { computeElementAnchorStyle, createContainerAnchorStyle, createWorkAreaRect } from './window-anchor'
import { computeElementAnchorStyle, createContainerAnchorStyle, createWorkAreaRect, normalizeRectForScale } from './window-anchor'
describe('createContainerAnchorStyle', () => {
it('anchors a window to the platform top-right corner', () => {
@@ -82,3 +82,19 @@ describe('computeElementAnchorStyle', () => {
})
})
})
describe('normalizeRectForScale', () => {
it('converts measured rect values into logical coordinates for scaled platform UI', () => {
expect(normalizeRectForScale({
left: 960,
top: 240,
width: 640,
height: 360,
}, 2)).toEqual({
left: 480,
top: 120,
width: 320,
height: 180,
})
})
})
@@ -7,6 +7,20 @@ type RectSize = Pick<DOMRect, 'width' | 'height'>
type RectLike = RectPosition & RectSize
type RelativeRect = RectLike
export function normalizeRectForScale<T extends RectLike>(rect: T, uiScale: number): T {
if (uiScale <= 0 || uiScale === 1) {
return rect
}
return {
...rect,
left: rect.left / uiScale,
top: rect.top / uiScale,
width: rect.width / uiScale,
height: rect.height / uiScale,
}
}
function toRelativeRect(rect: RectLike, platformRect: RectPosition): RelativeRect {
// All anchor math is done in the platform's own coordinate system so callers
// can mix measured DOM rects with the logical scene layout consistently.
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createApp, h } from 'vue'
import Appearance from './appearance.vue'
describe('appearance', () => {
it('does not pin wallpaper above window content via z-index utility classes', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp({
render: () => h(Appearance),
})
app.mount(host)
const wallpaper = host.querySelector<HTMLImageElement>('img')
expect(wallpaper).not.toBeNull()
expect(wallpaper?.className.includes('z-1')).toBe(false)
app.unmount()
host.remove()
})
})
@@ -6,7 +6,6 @@ import wallpaper from '../../../../../assets/wallpaper-2025-08-12.jpg'
<img
:class="[
'absolute left-0 top-0 h-full w-full',
'z-1',
]"
:src="wallpaper"
>
@@ -1,8 +1,17 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import 'uno.css'
createApp(App).mount('#app')
const router = createRouter({
routes,
history: createWebHistory(),
})
createApp(App)
.use(router)
.mount('#app')
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ScenarioCanvas, ScreenMarupsProvider, ScreenRouterCaptureRoot } from '@proj-airi/vishot-runtime/vue'
import Icon from '../../../../components/icon.vue'
import { PlatformRoot } from '../../../../components/platforms/macos-26'
import { Application } from '../../../../components/platforms/macos-26/containers/dock'
interface SceneCaptureLayoutProps {
height?: number
name: string
platformUiScale?: number
scaleMultiplier?: number
width?: number
}
withDefaults(defineProps<SceneCaptureLayoutProps>(), {
height: 1080,
platformUiScale: 1,
scaleMultiplier: 1,
width: 1920,
})
</script>
<template>
<ScreenRouterCaptureRoot :name="name">
<ScenarioCanvas :width="width" :height="height" :scale-multiplier="scaleMultiplier">
<ScreenMarupsProvider :width="width" :height="height">
<PlatformRoot :dock-size="1.5" :ui-scale="platformUiScale">
<template #windows>
<slot name="windows" />
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
<template #overlay>
<slot name="overlay" />
</template>
</ScreenMarupsProvider>
</ScenarioCanvas>
</ScreenRouterCaptureRoot>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useSceneReady } from '@proj-airi/vishot-runtime/vue'
import stageShot from '../../../../artifacts/raw/00-stage-tamagotchi.avif'
import websocketSettingsShot from '../../../../artifacts/raw/03-websocket-settings.avif'
import chatWindowShot from '../../../../artifacts/raw/04-chat-window.avif'
import SceneCaptureLayout from './components/scene-capture-layout.vue'
import { WindowRoot } from '../../../components/platforms/macos-26/containers/window'
useSceneReady([
stageShot,
websocketSettingsShot,
chatWindowShot,
])
</script>
<route lang="yaml">
meta:
title: Setup And Use
</route>
<template>
<SceneCaptureLayout name="manual-chat-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '72px', transform: 'translateX(-50%)' }">
<img :src="chatWindowShot" :class="['w-150']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="intro-chat-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot
:style="{ right: '0px', bottom: '0px' }"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" :class="['w-95']">
</WindowRoot>
<WindowRoot :style="{ left: '480px', top: '120px' }">
<img :src="websocketSettingsShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useSceneReady } from '@proj-airi/vishot-runtime/vue'
import stageShot from '../../../../artifacts/raw/00-stage-tamagotchi.avif'
import websocketSettingsShot from '../../../../artifacts/raw/03-websocket-settings.avif'
import chatWindowShot from '../../../../artifacts/raw/04-chat-window.avif'
import SceneCaptureLayout from './components/scene-capture-layout.vue'
import { WindowRoot } from '../../../components/platforms/macos-26/containers/window'
useSceneReady([
stageShot,
websocketSettingsShot,
chatWindowShot,
])
</script>
<route lang="yaml">
meta:
title: Setup And Use Intro Chat
</route>
<template>
<SceneCaptureLayout name="manual-chat-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '72px', transform: 'translateX(-50%)' }">
<img :src="chatWindowShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="intro-chat-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot
:style="{ right: '0px', bottom: '0px' }"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" :class="['w-95']">
</WindowRoot>
<WindowRoot :style="{ left: '480px', top: '120px' }">
<img :src="websocketSettingsShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ScreenHighlightRegion, useSceneReady } from '@proj-airi/vishot-runtime/vue'
import stageShot from '../../../../artifacts/raw/00-stage-tamagotchi.avif'
import websocketSettingsShot from '../../../../artifacts/raw/03-websocket-settings.avif'
import SceneCaptureLayout from './components/scene-capture-layout.vue'
import { WindowRoot } from '../../../components/platforms/macos-26/containers/window'
useSceneReady([
stageShot,
websocketSettingsShot,
])
</script>
<route lang="yaml">
meta:
title: Setup And Use Intro Websocket
</route>
<template>
<SceneCaptureLayout name="intro-chat-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot
:style="{ right: '0px', bottom: '0px' }"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" :class="['w-95']">
</WindowRoot>
<WindowRoot :style="{ left: '480px', top: '120px' }">
<div :class="['relative']">
<img :src="websocketSettingsShot" :class="['w-120']">
<div
:class="[
'absolute inset-0',
'pointer-events-none',
]"
>
<ScreenHighlightRegion
:class="[
'absolute left-2.5 top-22',
'w-[calc(100%-(16px*2))] h-20',
'translate-x-[8px]',
]"
:inset="3"
border-color="border-yellow-500 dark:border-yellow-400"
/>
<ScreenHighlightRegion
:class="[
'absolute left-2.5 top-78',
'w-[calc(100%-(16px*2))] h-26',
'translate-x-[8px]',
]"
:inset="3"
border-color="border-cyan-400 dark:border-cyan-300"
/>
</div>
</div>
</WindowRoot>
</template>
</SceneCaptureLayout>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useSceneReady } from '@proj-airi/vishot-runtime/vue'
import stageShot from '../../../../artifacts/raw/00-stage-tamagotchi.avif'
import controlsIslandShot from '../../../../artifacts/raw/01-controls-island-expanded.avif'
import SceneCaptureLayout from './components/scene-capture-layout.vue'
import { WindowRoot } from '../../../components/platforms/macos-26/containers/window'
useSceneReady([
stageShot,
controlsIslandShot,
])
</script>
<route lang="yaml">
meta:
title: Setup And Use Main Window
</route>
<template>
<SceneCaptureLayout name="manual-main-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot
:style="{ right: '0px', bottom: '0px' }"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-controls-island-expanded" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot
:style="{ right: '0px', bottom: '0px' }"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="controlsIslandShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
</template>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { useSceneReady } from '@proj-airi/vishot-runtime/vue'
import settingsShot from '../../../../artifacts/raw/02-settings-window.avif'
import websocketSettingsShot from '../../../../artifacts/raw/03-websocket-settings.avif'
import airiCardShot from '../../../../artifacts/raw/05-airi-card.avif'
import providersShot from '../../../../artifacts/raw/06-providers.avif'
import dataShot from '../../../../artifacts/raw/07-data.avif'
import systemGeneralShot from '../../../../artifacts/raw/08-system-general.avif'
import systemColorSchemeShot from '../../../../artifacts/raw/09-system-color-scheme.avif'
import modelsShot from '../../../../artifacts/raw/10-models.avif'
import modulesShot from '../../../../artifacts/raw/11-modules.avif'
import hearingShot from '../../../../artifacts/raw/12-hearing.avif'
import systemDeveloperShot from '../../../../artifacts/raw/13-system-developer.avif'
import consciousnessShot from '../../../../artifacts/raw/14-consciousness.avif'
import speechShot from '../../../../artifacts/raw/15-speech.avif'
import visionShot from '../../../../artifacts/raw/16-vision.avif'
import useWindowMouseShot from '../../../../artifacts/raw/17-devtools-use-window-mouse.avif'
import displaysShot from '../../../../artifacts/raw/18-devtools-displays.avif'
import widgetsCallingShot from '../../../../artifacts/raw/19-devtools-widgets-calling.avif'
import contextFlowShot from '../../../../artifacts/raw/20-devtools-context-flow.avif'
import relativeMouseShot from '../../../../artifacts/raw/21-devtools-relative-mouse.avif'
import beatSyncShot from '../../../../artifacts/raw/22-devtools-beat-sync.avif'
import websocketInspectorShot from '../../../../artifacts/raw/23-devtools-websocket-inspector.avif'
import pluginHostShot from '../../../../artifacts/raw/24-devtools-plugin-host.avif'
import screenCaptureShot from '../../../../artifacts/raw/25-devtools-screen-capture.avif'
import visionCaptureShot from '../../../../artifacts/raw/26-devtools-vision-capture.avif'
import SceneCaptureLayout from './components/scene-capture-layout.vue'
import { WindowRoot } from '../../../components/platforms/macos-26/containers/window'
useSceneReady([
settingsShot,
websocketSettingsShot,
airiCardShot,
providersShot,
dataShot,
systemGeneralShot,
systemColorSchemeShot,
modelsShot,
modulesShot,
hearingShot,
systemDeveloperShot,
consciousnessShot,
speechShot,
visionShot,
useWindowMouseShot,
displaysShot,
widgetsCallingShot,
contextFlowShot,
relativeMouseShot,
beatSyncShot,
websocketInspectorShot,
pluginHostShot,
screenCaptureShot,
visionCaptureShot,
])
</script>
<route lang="yaml">
meta:
title: Setup And Use Settings
</route>
<template>
<SceneCaptureLayout name="manual-settings-window" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="settingsShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-websocket-settings" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="websocketSettingsShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-airi-card" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="airiCardShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-providers" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="providersShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-data-settings" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="dataShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-system-general" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="systemGeneralShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-system-color-scheme" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="systemColorSchemeShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-models" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="modelsShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-modules" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="modulesShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-hearing" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="hearingShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-system-developer" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="systemDeveloperShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-consciousness" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="consciousnessShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-speech" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="speechShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-vision" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="visionShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-use-window-mouse" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="useWindowMouseShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-displays" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="displaysShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-widgets-calling" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="widgetsCallingShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-context-flow" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="contextFlowShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-relative-mouse" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="relativeMouseShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-beat-sync" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="beatSyncShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-websocket-inspector" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="websocketInspectorShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-plugin-host" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="pluginHostShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-screen-capture" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="screenCaptureShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
<SceneCaptureLayout name="manual-devtools-vision-capture" :platform-ui-scale="1.25">
<template #windows>
<WindowRoot :style="{ left: '50%', top: '84px', transform: 'translateX(-50%)' }">
<img :src="visionCaptureShot" :class="['w-120']">
</WindowRoot>
</template>
</SceneCaptureLayout>
</template>
@@ -1,262 +0,0 @@
<script setup lang="ts">
import { markScenarioReady, resetScenarioReady } from '@proj-airi/vishot-runtime'
import { ScenarioCanvas, ScenarioCaptureRoot } from '@proj-airi/vishot-runtime/vue'
import { onMounted } from 'vue'
import stageShot from '../../artifacts/raw/00-stage-tamagotchi.avif'
import controlsIslandShot from '../../artifacts/raw/01-controls-island-expanded.avif'
import settingsShot from '../../artifacts/raw/02-settings-window.avif'
import websocketSettingsShot from '../../artifacts/raw/03-websocket-settings.avif'
import chatWindowShot from '../../artifacts/raw/04-chat-window.avif'
import airiCardShot from '../../artifacts/raw/05-airi-card.avif'
import providersShot from '../../artifacts/raw/06-providers.avif'
import dataShot from '../../artifacts/raw/07-data.avif'
import systemGeneralShot from '../../artifacts/raw/08-system-general.avif'
import systemColorSchemeShot from '../../artifacts/raw/09-system-color-scheme.avif'
import modelsShot from '../../artifacts/raw/10-models.avif'
import modulesShot from '../../artifacts/raw/11-modules.avif'
import hearingShot from '../../artifacts/raw/12-hearing.avif'
import systemDeveloperShot from '../../artifacts/raw/13-system-developer.avif'
import consciousnessShot from '../../artifacts/raw/14-consciousness.avif'
import speechShot from '../../artifacts/raw/15-speech.avif'
import visionShot from '../../artifacts/raw/16-vision.avif'
import useWindowMouseShot from '../../artifacts/raw/17-devtools-use-window-mouse.avif'
import displaysShot from '../../artifacts/raw/18-devtools-displays.avif'
import widgetsCallingShot from '../../artifacts/raw/19-devtools-widgets-calling.avif'
import contextFlowShot from '../../artifacts/raw/20-devtools-context-flow.avif'
import relativeMouseShot from '../../artifacts/raw/21-devtools-relative-mouse.avif'
import beatSyncShot from '../../artifacts/raw/22-devtools-beat-sync.avif'
import websocketInspectorShot from '../../artifacts/raw/23-devtools-websocket-inspector.avif'
import pluginHostShot from '../../artifacts/raw/24-devtools-plugin-host.avif'
import screenCaptureShot from '../../artifacts/raw/25-devtools-screen-capture.avif'
import visionCaptureShot from '../../artifacts/raw/26-devtools-vision-capture.avif'
import Icon from '../components/icon.vue'
import { PlatformRoot } from '../components/platforms/macos-26'
import { Application } from '../components/platforms/macos-26/containers/dock'
import { WindowRoot } from '../components/platforms/macos-26/containers/window'
/**
* These coordinates are expressed in the logical `1920x1080` canvas provided by
* `ScenarioCanvas`, not in the browser's live viewport.
*
* That is why the windows keep their relative placement when the viewport size
* changes: the browser scales the entire fixed scene surface after layout rather
* than reinterpreting each translate against a resized responsive container.
*/
const stageWindowStyle = {
right: '0px',
bottom: '0px',
}
const websocketWindowStyle = {
left: '480px',
top: '120px',
}
const mainOnlyWindowStyle = {
right: '0px',
bottom: '0px',
}
const settingsOnlyWindowStyle = {
left: '50%',
top: '84px',
transform: 'translateX(-50%)',
}
const chatWindowStyle = {
left: '50%',
top: '72px',
transform: 'translateX(-50%)',
}
const settingsCaptureRoots = [
{ name: 'manual-settings-window', src: settingsShot, imageClass: 'w-130' },
{ name: 'manual-websocket-settings', src: websocketSettingsShot, imageClass: 'w-130' },
{ name: 'manual-airi-card', src: airiCardShot, imageClass: 'w-140' },
{ name: 'manual-providers', src: providersShot, imageClass: 'w-140' },
{ name: 'manual-data-settings', src: dataShot, imageClass: 'w-140' },
{ name: 'manual-system-general', src: systemGeneralShot, imageClass: 'w-140' },
{ name: 'manual-system-color-scheme', src: systemColorSchemeShot, imageClass: 'w-140' },
{ name: 'manual-models', src: modelsShot, imageClass: 'w-150' },
{ name: 'manual-modules', src: modulesShot, imageClass: 'w-140' },
{ name: 'manual-hearing', src: hearingShot, imageClass: 'w-145' },
{ name: 'manual-system-developer', src: systemDeveloperShot, imageClass: 'w-145' },
{ name: 'manual-consciousness', src: consciousnessShot, imageClass: 'w-145' },
{ name: 'manual-speech', src: speechShot, imageClass: 'w-150' },
{ name: 'manual-vision', src: visionShot, imageClass: 'w-145' },
{ name: 'manual-devtools-use-window-mouse', src: useWindowMouseShot, imageClass: 'w-140' },
{ name: 'manual-devtools-displays', src: displaysShot, imageClass: 'w-150' },
{ name: 'manual-devtools-widgets-calling', src: widgetsCallingShot, imageClass: 'w-150' },
{ name: 'manual-devtools-context-flow', src: contextFlowShot, imageClass: 'w-155' },
{ name: 'manual-devtools-relative-mouse', src: relativeMouseShot, imageClass: 'w-150' },
{ name: 'manual-devtools-beat-sync', src: beatSyncShot, imageClass: 'w-155' },
{ name: 'manual-devtools-websocket-inspector', src: websocketInspectorShot, imageClass: 'w-155' },
{ name: 'manual-devtools-plugin-host', src: pluginHostShot, imageClass: 'w-155' },
{ name: 'manual-devtools-screen-capture', src: screenCaptureShot, imageClass: 'w-155' },
{ name: 'manual-devtools-vision-capture', src: visionCaptureShot, imageClass: 'w-155' },
] as const
async function waitForImageSource(source: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(), { once: true })
image.addEventListener('error', () => reject(new Error(`Scenario image failed to load: ${source}`)), { once: true })
image.src = source
})
}
onMounted(async () => {
resetScenarioReady()
try {
await Promise.all([
document.fonts.ready,
waitForImageSource(stageShot),
waitForImageSource(controlsIslandShot),
waitForImageSource(settingsShot),
waitForImageSource(websocketSettingsShot),
waitForImageSource(chatWindowShot),
waitForImageSource(airiCardShot),
waitForImageSource(providersShot),
waitForImageSource(dataShot),
waitForImageSource(systemGeneralShot),
waitForImageSource(systemColorSchemeShot),
waitForImageSource(modelsShot),
waitForImageSource(modulesShot),
waitForImageSource(hearingShot),
waitForImageSource(systemDeveloperShot),
waitForImageSource(consciousnessShot),
waitForImageSource(speechShot),
waitForImageSource(visionShot),
waitForImageSource(useWindowMouseShot),
waitForImageSource(displaysShot),
waitForImageSource(widgetsCallingShot),
waitForImageSource(contextFlowShot),
waitForImageSource(relativeMouseShot),
waitForImageSource(beatSyncShot),
waitForImageSource(websocketInspectorShot),
waitForImageSource(pluginHostShot),
waitForImageSource(screenCaptureShot),
waitForImageSource(visionCaptureShot),
])
markScenarioReady()
}
catch (error) {
console.error(error)
}
})
</script>
<template>
<ScenarioCaptureRoot name="manual-main-window">
<ScenarioCanvas :width="1920" :height="1080">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot
:style="mainOnlyWindowStyle"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" class="w-95">
</WindowRoot>
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
</ScenarioCanvas>
</ScenarioCaptureRoot>
<ScenarioCaptureRoot name="manual-controls-island-expanded">
<ScenarioCanvas :width="1920" :height="1080">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot
:style="mainOnlyWindowStyle"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="controlsIslandShot" class="w-95">
</WindowRoot>
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
</ScenarioCanvas>
</ScenarioCaptureRoot>
<ScenarioCaptureRoot
v-for="capture in settingsCaptureRoots"
:key="capture.name"
:name="capture.name"
>
<ScenarioCanvas :width="1920" :height="1080">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot :style="settingsOnlyWindowStyle">
<img :src="capture.src" :class="capture.imageClass">
</WindowRoot>
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
</ScenarioCanvas>
</ScenarioCaptureRoot>
<ScenarioCaptureRoot name="manual-chat-window">
<ScenarioCanvas :width="1920" :height="1080">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot :style="chatWindowStyle">
<img :src="chatWindowShot" class="w-150">
</WindowRoot>
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
</ScenarioCanvas>
</ScenarioCaptureRoot>
<ScenarioCaptureRoot name="intro-chat-window">
<ScenarioCanvas :width="1920" :height="1080">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot
:style="stageWindowStyle"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" class="w-95">
</WindowRoot>
<WindowRoot :style="websocketWindowStyle">
<img :src="websocketSettingsShot" class="w-120">
</WindowRoot>
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
</ScenarioCanvas>
</ScenarioCaptureRoot>
</template>
@@ -11,7 +11,8 @@
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": [
"vite/client"
"vite/client",
"unplugin-vue-router/client"
],
"allowJs": true,
"strict": true,
@@ -1,10 +1,23 @@
import { resolve } from 'node:path'
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
import VueRouter from 'unplugin-vue-router/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
VueRouter({
extensions: ['.vue'],
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
importMode: 'async',
routesFolder: [
resolve(import.meta.dirname, 'src', 'scenes'),
],
exclude: ['**/components/**'],
}),
Vue(),
// https://github.com/antfu/unocss
@@ -14,17 +14,27 @@ It does not launch Electron itself and it does not own browser-scene composition
## Workflow
1. Build the Electron app.
2. Run the Electron runner against a section-based scenario entrypoint under `src/scenarios/demo-controls-settings-chat-websocket/index.ts`.
3. Write the scenario's raw working outputs into the scenario-local working directory.
4. Publish the final docs screenshots directly into `docs/content/en/docs/manual/tamagotchi/setup-and-use/assets`.
5. Skip any separate final staging directory for this docs workflow.
This package is step 1 of the docs screenshot pipeline.
1. Build `@proj-airi/stage-tamagotchi`.
2. Run this scenario through `@proj-airi/vishot-runner-electron`.
3. Write raw outputs to `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`.
4. Then run the browser package capture (step 2, documented in that package README).
## Agent Quickstart
From repo root, run:
```bash
pnpm -F @proj-airi/stage-tamagotchi build
pnpm -F @proj-airi/vishot-runner-electron capture --format avif -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket/index.ts --output-dir /tmp/tamagotchi-docs-capture
pnpm -F @proj-airi/vishot-runner-electron capture ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket/index.ts --output-dir ../../packages/scenarios-stage-tamagotchi-browser/artifacts/raw --format avif
```
Expected result:
- `27` raw files in `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`
- names like `00-stage-tamagotchi.avif` ... `26-devtools-vision-capture.avif`
## Scenario Authoring
```ts
@@ -48,10 +58,16 @@ export default defineScenario({
## Scenario Layout
The docs workflow is organized as one section-based scenario module under `src/scenarios/demo-controls-settings-chat-websocket/`. The top-level `index.ts` orchestrates section manifests and writes working outputs into the scenario-local raw directory, so the capture flow stays close to the scenario being authored.
The docs workflow is organized as one section-based scenario module under `src/scenarios/demo-controls-settings-chat-websocket/`. The top-level `index.ts` orchestrates section manifests.
Important:
- `--output-dir` for the runner should point to `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`.
- This package does not publish docs assets directly; it only prepares raw assets for browser-scene composition.
## Notes
- Raw scenario modules live under `src/scenarios`.
- Scenario entrypoints should point at `index.ts` when the workflow is organized as a section folder.
- Keep this package focused on Electron capture flows for docs screenshots.
- Paths in these `pnpm -F` examples are resolved from the filtered package working directory.

Some files were not shown because too many files have changed in this diff Show More