diff --git a/cspell.config.yaml b/cspell.config.yaml
index c932c5364..fe70a8eb7 100644
--- a/cspell.config.yaml
+++ b/cspell.config.yaml
@@ -305,6 +305,7 @@ words:
- valibot
- vaul
- velin
+ - vishot
- VITE
- vitepress
- vllm
diff --git a/package.json b/package.json
index eb86a78e9..7f7f006a5 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"lint": "moeru-lint .",
"lint:fix": "moeru-lint --fix .",
"lint:swift": "pnpm -rF @proj-airi/stage-pocket run lint:swift",
- "capture:tamagotchi": "pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture --output-dir ./artifacts/manual-run",
+ "capture:tamagotchi": "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",
"to-avif": "tsx docs/scripts/avif.ts",
"sponsors:generate": "sponsorkit --output-dir docs/content/public/assets/sponsors",
"typecheck": "pnpm -rF=\"./packages/*\" -F=\"./apps/*\" -F=\"./docs\" --parallel typecheck",
diff --git a/packages/devtool-capture-stage-tamagotchi/src/index.ts b/packages/devtool-capture-stage-tamagotchi/src/index.ts
deleted file mode 100644
index 3e34061dc..000000000
--- a/packages/devtool-capture-stage-tamagotchi/src/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export { defineScenario } from './runtime/define-scenario'
-export type {
- CaptureOptions,
- ControlsIslandApi,
- DialogsApi,
- DrawersApi,
- ScenarioContext,
- SettingsWindowApi,
- StageTamagotchiScenario,
- StageWindowsApi,
-} from './runtime/types'
diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts b/packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts
deleted file mode 100644
index cf44c1a35..000000000
--- a/packages/devtool-capture-stage-tamagotchi/src/runtime/define-scenario.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { StageTamagotchiScenario } from './types'
-
-export function defineScenario(scenario: StageTamagotchiScenario): StageTamagotchiScenario {
- return scenario
-}
diff --git a/packages/scenarios-stage-tamagotchi-browser/README.md b/packages/scenarios-stage-tamagotchi-browser/README.md
new file mode 100644
index 000000000..f8855bc16
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/README.md
@@ -0,0 +1,41 @@
+# Scenarios - Stage Tamagotchi Browser
+
+Compose final browser-scene exports for stage-tamagotchi from raw Electron screenshots.
+
+## Purpose
+
+This package is the tamagotchi browser composition layer. It contains:
+
+- the Vite/Vue scene app
+- the scene composition components and capture roots
+- the `capture` script that exports final PNGs through `@proj-airi/vishot-runner-browser`
+
+It expects raw business screenshots to exist in `artifacts/raw` before final export runs.
+
+## 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`.
+
+```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
+```
+
+Expected raw inputs:
+
+- `artifacts/raw/02-chat-window.png`
+- `artifacts/raw/03-websocket-settings.png`
+
+Current composed outputs:
+
+- `artifacts/final/intro-chat-window.png`
+- `artifacts/final/intro-websocket-settings.png`
+
+## Notes
+
+- The current scene app renders `src/scenes/intro-manual-scene.vue`.
+- 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.
diff --git a/packages/scenarios-stage-tamagotchi-browser/index.html b/packages/scenarios-stage-tamagotchi-browser/index.html
new file mode 100644
index 000000000..9c27d85a0
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Stage Tamagotchi Scenes
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/package.json b/packages/scenarios-stage-tamagotchi-browser/package.json
new file mode 100644
index 000000000..a2b1ea2a8
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@proj-airi/scenarios-stage-tamagotchi-browser",
+ "type": "module",
+ "version": "0.9.0-beta.0",
+ "private": true,
+ "description": "Vite/Vue scene app for stage tamagotchi browser captures",
+ "author": {
+ "name": "Moeru AI Project AIRI Team",
+ "email": "airi@moeru.ai",
+ "url": "https://github.com/moeru-ai"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/moeru-ai/airi.git",
+ "directory": "packages/scenarios-stage-tamagotchi-browser"
+ },
+ "scripts": {
+ "capture": "tsx scripts/capture.ts",
+ "build": "vite build",
+ "dev": "vite",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@proj-airi/vishot-runner-browser": "workspace:^",
+ "@proj-airi/vishot-runtime": "workspace:^",
+ "date-fns": "catalog:",
+ "vue": "catalog:"
+ },
+ "devDependencies": {
+ "@iconify-json/line-md": "catalog:",
+ "@iconify-json/material-symbols": "catalog:",
+ "@iconify-json/mdi": "catalog:",
+ "@types/node": "^24.12.0",
+ "@unocss/reset": "^66.6.7",
+ "@vitejs/plugin-vue": "^6.0.5",
+ "typescript": "^5.9.3",
+ "vite": "catalog:",
+ "vue-tsc": "^3.2.6"
+ }
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts b/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts
new file mode 100644
index 000000000..ebf5567a4
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts
@@ -0,0 +1,19 @@
+import path from 'node:path'
+
+import { cwd } from 'node:process'
+
+import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser'
+
+const sceneAppRoot = path.resolve(cwd())
+const outputDir = path.resolve(sceneAppRoot, 'artifacts', 'final')
+
+await captureBrowserRoots({
+ sceneAppRoot,
+ routePath: '/',
+ outputDir,
+ viewport: {
+ width: 1920,
+ height: 1080,
+ deviceScaleFactor: 2,
+ },
+})
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/App.vue b/packages/scenarios-stage-tamagotchi-browser/src/App.vue
new file mode 100644
index 000000000..ffe71b591
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/App.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/assets/icon.png b/packages/scenarios-stage-tamagotchi-browser/src/assets/icon.png
new file mode 100644
index 000000000..3323573ca
Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/src/assets/icon.png differ
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/assets/wallpaper-2025-08-12.jpg b/packages/scenarios-stage-tamagotchi-browser/src/assets/wallpaper-2025-08-12.jpg
new file mode 100644
index 000000000..9ac5fea5d
Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/src/assets/wallpaper-2025-08-12.jpg differ
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/icon.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/icon.vue
new file mode 100644
index 000000000..a2d529390
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/icon.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/PlatformRoot.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/PlatformRoot.vue
new file mode 100644
index 000000000..5b689dd13
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/PlatformRoot.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/Application.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/Application.vue
new file mode 100644
index 000000000..8d3111cfc
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/Application.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+ {{ props.notificationCount }}
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/Divider.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/Divider.vue
new file mode 100644
index 000000000..28b39efd4
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/Divider.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/DockRoot.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/DockRoot.vue
new file mode 100644
index 000000000..3394c05fd
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/DockRoot.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/constants.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/constants.ts
new file mode 100644
index 000000000..0d0c0a63f
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/constants.ts
@@ -0,0 +1,3 @@
+import type { InjectionKey } from 'vue'
+
+export const injectDockSize: InjectionKey = Symbol('vishot:platforms:macos-26:dock:size')
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/index.ts
new file mode 100644
index 000000000..4ff7979a8
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/dock/index.ts
@@ -0,0 +1,3 @@
+export { default as Application } from './Application.vue'
+export { default as DockDivider } from './Divider.vue'
+export { default as DockRoot } from './DockRoot.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/window/WindowRoot.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/window/WindowRoot.vue
new file mode 100644
index 000000000..b997a755a
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/window/WindowRoot.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+ {{ props.title }}
+
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/window/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/window/index.ts
new file mode 100644
index 000000000..50e3b8411
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/containers/window/index.ts
@@ -0,0 +1 @@
+export { default as WindowRoot } from './WindowRoot.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/index.ts
new file mode 100644
index 000000000..6872b66f4
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/index.ts
@@ -0,0 +1 @@
+export * from './liquid-glass'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/components/composite-parts.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/components/composite-parts.vue
new file mode 100644
index 000000000..6679ea51a
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/components/composite-parts.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/components/filter.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/components/filter.vue
new file mode 100644
index 000000000..a44fd6cce
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/components/filter.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/image-data-to-url.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/image-data-to-url.ts
new file mode 100644
index 000000000..0ef09f445
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/image-data-to-url.ts
@@ -0,0 +1,17 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/helpers/image-data-to-url.ts}
+ */
+export function imageDataToUrl(
+ imageData: ImageData,
+ width?: number,
+ height?: number,
+ x = 0,
+ y = 0,
+): string {
+ const canvas = document.createElement('canvas')
+ canvas.width = width ?? imageData.width
+ canvas.height = height ?? imageData.height
+ const ctx = canvas.getContext('2d')!
+ ctx.putImageData(imageData, -x, -y)
+ return canvas.toDataURL()
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/split-imagedata-to-parts.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/split-imagedata-to-parts.ts
new file mode 100644
index 000000000..1b754ebad
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/split-imagedata-to-parts.ts
@@ -0,0 +1,113 @@
+/* eslint-disable jsdoc/check-param-names */
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/helpers/split-imagedata-to-parts.ts}
+ */
+import { imageDataToUrl } from './image-data-to-url'
+
+// Each part is a Base64-encoded PNG image
+export interface Parts {
+ topLeft: string
+ top: string
+ topRight: string
+ left: string
+ center: string
+ right: string
+ bottomLeft: string
+ bottom: string
+ bottomRight: string
+}
+
+/**
+ * Splits an ImageData into 8 parts and returns them as Base64-encoded PNG images.
+ * The parts can then be rendered and composited together to form a scalable image with correct corners and edges.
+ *
+ * @param props - The properties for splitting the image data.
+ * @returns The 8 parts of the image as Base64-encoded PNG images.
+ */
+export function splitImageDataToParts(props: {
+ imageData: ImageData
+ cornerWidth: number
+ pixelRatio: number
+}): Parts {
+ const { imageData } = props
+ const cornerWidth = props.cornerWidth * props.pixelRatio
+ // This is to always keep width and height odd, so we can always extract a center part to stretch
+ const lateralPartSize = 1 * props.pixelRatio
+
+ if (imageData.width !== cornerWidth * 2 + lateralPartSize) {
+ throw new Error('ImageData width is too small for the given corner width')
+ }
+ if (imageData.height !== imageData.width) {
+ throw new Error('ImageData should be square')
+ }
+
+ const topLeft = imageDataToUrl(imageData, cornerWidth, cornerWidth, 0, 0)
+ const top = imageDataToUrl(
+ imageData,
+ lateralPartSize,
+ cornerWidth,
+ cornerWidth,
+ 0,
+ )
+ const topRight = imageDataToUrl(
+ imageData,
+ cornerWidth,
+ cornerWidth,
+ cornerWidth + lateralPartSize,
+ 0,
+ )
+ const left = imageDataToUrl(
+ imageData,
+ cornerWidth,
+ lateralPartSize,
+ 0,
+ cornerWidth,
+ )
+ const center = imageDataToUrl(
+ imageData,
+ lateralPartSize,
+ lateralPartSize,
+ cornerWidth,
+ cornerWidth,
+ )
+ const right = imageDataToUrl(
+ imageData,
+ cornerWidth,
+ lateralPartSize,
+ cornerWidth + lateralPartSize,
+ cornerWidth,
+ )
+ const bottomLeft = imageDataToUrl(
+ imageData,
+ cornerWidth,
+ cornerWidth,
+ 0,
+ cornerWidth + lateralPartSize,
+ )
+ const bottom = imageDataToUrl(
+ imageData,
+ lateralPartSize,
+ cornerWidth,
+ cornerWidth,
+ cornerWidth + lateralPartSize,
+ )
+ const bottomRight = imageDataToUrl(
+ imageData,
+ cornerWidth,
+ cornerWidth,
+ cornerWidth + lateralPartSize,
+ cornerWidth + lateralPartSize,
+ )
+
+ return {
+ topLeft,
+ top,
+ topRight,
+ left,
+ center,
+ right,
+ bottomLeft,
+ bottom,
+ bottomRight,
+ }
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/surface-equations.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/surface-equations.ts
new file mode 100644
index 000000000..a9e31772a
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/helpers/surface-equations.ts
@@ -0,0 +1,18 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/helpers/surface-equations.ts}
+ */
+
+export type SurfaceFnDef = (x: number) => number
+
+export const convexCircle: SurfaceFnDef = x => Math.sqrt(1 - (1 - x) ** 2)
+
+export const convex: SurfaceFnDef = x => (1 - (1 - x) ** 4) ** (1 / 4)
+
+export const concave: SurfaceFnDef = x => 1 - convexCircle(x)
+
+export const lip: SurfaceFnDef = (x) => {
+ const cvx = convex(x * 2)
+ const ccv = concave(x) + 0.1
+ const smootherstep = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3
+ return cvx * (1 - smootherstep) + ccv * smootherstep
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/hoc/refractive.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/hoc/refractive.vue
new file mode 100644
index 000000000..cf11bd70a
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/hoc/refractive.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/index.ts
new file mode 100644
index 000000000..59a3aba39
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/index.ts
@@ -0,0 +1 @@
+export { default as Refractive } from './hoc/refractive.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/calculate-circle-map.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/calculate-circle-map.ts
new file mode 100644
index 000000000..f39880016
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/calculate-circle-map.ts
@@ -0,0 +1,96 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/calculate-circle-map.ts}
+ */
+
+import type { ProcessPixelFunction } from './process-pixel.type'
+
+/**
+ * Generates a circular (or rounded rectangle) map and allows processing of each pixel.
+ * Used by Diplacement and Specular maps.
+ */
+export function calculateCircleMap(props: {
+ width: number
+ height: number
+ radius: number
+ fillColor: number
+ /** Restricts pixel processing to a certain distance from the border. */
+ maximumDistanceToBorder?: number
+ processPixel: ProcessPixelFunction
+}) {
+ const { fillColor, processPixel, maximumDistanceToBorder } = props
+
+ const width = Math.round(props.width)
+ const height = Math.round(props.height)
+ const imageData = new ImageData(width, height)
+
+ // Fill buffer with base color
+ new Uint32Array(imageData.data.buffer).fill(fillColor)
+
+ const radius = Math.min(props.radius, width / 2, height / 2)
+
+ const widthBetweenRadiuses = width - radius * 2
+ const heightBetweenRadiuses = height - radius * 2
+
+ const radiusSquared = radius ** 2
+ const radiusPlusOneSquared = (radius + 1) ** 2
+ const radiusMinusBezelSquared = maximumDistanceToBorder
+ ? (radius - maximumDistanceToBorder) ** 2
+ : 0
+
+ for (let y1 = 0; y1 < height; y1++) {
+ for (let x1 = 0; x1 < width; x1++) {
+ const idx = (y1 * width + x1) * 4
+
+ const isOnLeftSide = x1 < radius
+ const isOnRightSide = x1 >= width - radius
+ const isOnTopSide = y1 < radius
+ const isOnBottomSide = y1 >= height - radius
+
+ // Virtual x value
+ // When not on sides, value is 0 to stretch circle into rounded rectangle.
+ const x = isOnLeftSide
+ ? x1 - radius
+ : isOnRightSide
+ ? x1 - radius - widthBetweenRadiuses
+ : 0
+
+ // Virtual y value
+ // When not on sides, value is 0 to stretch circle into rounded rectangle.
+ const y = isOnTopSide
+ ? y1 - radius
+ : isOnBottomSide
+ ? y1 - radius - heightBetweenRadiuses
+ : 0
+
+ const distanceToCenterSquared = x * x + y * y
+
+ const isInBezel
+ = distanceToCenterSquared <= radiusPlusOneSquared
+ && distanceToCenterSquared >= radiusMinusBezelSquared
+
+ // Process pixels that are in bezel or near bezel edge for anti-aliasing
+ if (isInBezel) {
+ const distanceFromCenter = Math.sqrt(distanceToCenterSquared)
+ const distanceFromBorder = radius - distanceFromCenter
+ const distanceFromBorderRatio = distanceFromBorder / radius
+ const angle = Math.atan2(y, x)
+ // H-5525: Fix antialiasing calculation
+ const opacity
+ = distanceToCenterSquared > radiusSquared ? 1 - distanceFromBorder : 1
+
+ processPixel(
+ x,
+ y,
+ imageData.data,
+ idx,
+ distanceFromCenter,
+ distanceFromBorder,
+ distanceFromBorderRatio,
+ angle,
+ opacity,
+ )
+ }
+ }
+ }
+ return imageData
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/calculate-rounded-square-map.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/calculate-rounded-square-map.ts
new file mode 100644
index 000000000..c65955dbb
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/calculate-rounded-square-map.ts
@@ -0,0 +1,177 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/calculate-circle-map.ts}
+ */
+
+import type { ProcessPixelFunction } from './process-pixel.type'
+
+import { calculateCircleMap } from './calculate-circle-map'
+
+/**
+ * Calculates the intersection point on the border of the rounded square,
+ * given by projecting a line from corner center through point (x, y), to the border.
+ *
+ * See this notebook: https://observablehq.com/d/879ebd7e070ed87c
+ */
+function calculateBorderIntersection(
+ radius: number,
+ cornerWidth: number,
+ x: number,
+ y: number,
+) {
+ const angleStart = Math.atan2(cornerWidth - radius, cornerWidth)
+ const angleEnd = Math.atan2(cornerWidth, cornerWidth - radius)
+ const aperture = angleEnd - angleStart
+
+ const pointAngleInSquare = Math.atan2(Math.abs(y), Math.abs(x))
+
+ if (pointAngleInSquare <= angleStart || pointAngleInSquare >= angleEnd) {
+ // Outside the cone
+ if (Math.abs(y) > Math.abs(x)) {
+ return [
+ Math.abs(x / y) * cornerWidth * Math.sign(x),
+ cornerWidth * Math.sign(y),
+ ] as const
+ }
+ else {
+ return [
+ cornerWidth * Math.sign(x),
+ Math.abs(y / x) * cornerWidth * Math.sign(y),
+ ] as const
+ }
+ }
+ else {
+ // Inside the cone
+ const pointAngleInCone
+ = (pointAngleInSquare - angleStart) / (aperture / (Math.PI / 2))
+
+ const intersectionX = Math.cos(pointAngleInCone)
+ const intersectionY = Math.sin(pointAngleInCone)
+
+ return [
+ (cornerWidth - radius + intersectionX * radius) * Math.sign(x),
+ (cornerWidth - radius + intersectionY * radius) * Math.sign(y),
+ ] as const
+ }
+}
+
+/**
+ * Generates a circular (or rounded rectangle) map and allows processing of each pixel.
+ * Used by Diplacement and Specular maps.
+ */
+export function calculateRoundedSquareMap(props: {
+ width: number
+ height: number
+ radius: number
+ fillColor: number
+ /** Restricts pixel processing to a certain distance from the border. */
+ maximumDistanceToBorder?: number
+ processPixel: ProcessPixelFunction
+}) {
+ if (
+ props.maximumDistanceToBorder === undefined
+ || props.maximumDistanceToBorder <= props.radius
+ ) {
+ return calculateCircleMap(props)
+ }
+
+ const { fillColor, processPixel } = props
+
+ const width = Math.round(props.width)
+ const height = Math.round(props.height)
+ const imageData = new ImageData(width, height)
+
+ // Fill buffer with base color
+ new Uint32Array(imageData.data.buffer).fill(fillColor)
+
+ const radius = Math.min(props.radius, width / 2, height / 2)
+ const cornerWidth = Math.max(
+ radius,
+ Math.min(props.maximumDistanceToBorder, width / 2, height / 2),
+ )
+
+ const widthBetweenCorners = width - cornerWidth * 2
+ const heightBetweenCorners = height - cornerWidth * 2
+
+ // Calculate radiusPlusOne to antialias the border (1 pixel outside)
+ const radiusPlusOne = radius + 1
+ const radiusPlusOneSquared = radiusPlusOne ** 2
+
+ // Iterate over every pixel in the buffer
+ for (let bufferY = 0; bufferY < height; bufferY++) {
+ for (let bufferX = 0; bufferX < width; bufferX++) {
+ const idx = (bufferY * width + bufferX) * 4
+
+ const isOnLeftSide = bufferX < cornerWidth
+ const isOnRightSide = bufferX >= width - cornerWidth
+ const isOnTopSide = bufferY < cornerWidth
+ const isOnBottomSide = bufferY >= height - cornerWidth
+
+ // Virtual x value
+ // When not on sides, value is 0 to stretch circle into rounded rectangle.
+ const x = isOnLeftSide
+ ? bufferX - cornerWidth
+ : isOnRightSide
+ ? bufferX - cornerWidth - widthBetweenCorners
+ : 0
+
+ // Virtual y value
+ // When not on sides, value is 0 to stretch circle into rounded rectangle.
+ const y = isOnTopSide
+ ? bufferY - cornerWidth
+ : isOnBottomSide
+ ? bufferY - cornerWidth - heightBetweenCorners
+ : 0
+
+ // Find the intersection point on the border of the rounded square
+ const [intersectionX, intersectionY] = calculateBorderIntersection(
+ radius,
+ cornerWidth,
+ x,
+ y,
+ )
+
+ const distanceToCenterSquared = x * x + y * y
+ const distanceToBorderSquared
+ = (intersectionX - x) ** 2 + (intersectionY - y) ** 2
+
+ // If the pixel is within corner square containing the quarter circle
+ const isInRadiusSquare
+ = Math.abs(x) > cornerWidth - radius
+ && Math.abs(y) > cornerWidth - radius
+
+ // If the pixel is within corner square, but outside the quarter circle
+ const isOutsideRadius
+ = isInRadiusSquare
+ && (Math.abs(x) - (cornerWidth - radius)) ** 2
+ + (Math.abs(y) - (cornerWidth - radius)) ** 2
+ >= radiusPlusOneSquared
+
+ const isInRoundedSquare = !isInRadiusSquare || !isOutsideRadius
+
+ // Process pixels that are in rounded square or near bezel edge for anti-aliasing
+ if (isInRoundedSquare) {
+ const distanceFromCenter = Math.sqrt(distanceToCenterSquared)
+ const distanceFromBorder = Math.sqrt(distanceToBorderSquared)
+ const distanceFromBorderRatio
+ = distanceFromBorder / (distanceFromCenter + distanceFromBorder)
+ const angle = Math.atan2(y, x)
+ // H-5525: Fix antialiasing calculation
+ const opacity = isOutsideRadius ? 1 - distanceFromBorder : 1
+
+ processPixel(
+ x,
+ y,
+ imageData.data,
+ idx,
+ distanceFromCenter,
+ distanceFromBorder,
+ distanceFromBorderRatio,
+ angle,
+ opacity,
+ )
+ }
+ }
+ }
+
+ return imageData
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/displacement-map.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/displacement-map.ts
new file mode 100644
index 000000000..2a3af04c3
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/displacement-map.ts
@@ -0,0 +1,112 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/displacement-map.ts}
+ */
+
+import { calculateRoundedSquareMap } from './calculate-rounded-square-map'
+
+export function calculateDisplacementMapRadius(
+ glassThickness: number = 200,
+ bezelWidth: number = 50,
+ bezelHeightFn: (x: number) => number = x => x,
+ refractiveIndex: number = 1.5,
+ samples: number = 128,
+): number[] {
+ // Pre-calculate the distance the ray will be deviated
+ // given the distance to border (ratio of bezel)
+ // and height of the glass
+ const eta = 1 / refractiveIndex
+
+ // Simplified refraction, which only handles fully vertical incident ray [0, 1]
+ function refract(normalX: number, normalY: number): [number, number] | null {
+ const dot = normalY
+ const k = 1 - eta * eta * (1 - dot * dot)
+ if (k < 0) {
+ // Total internal reflection
+ return null
+ }
+ const kSqrt = Math.sqrt(k)
+ return [
+ -(eta * dot + kSqrt) * normalX,
+ eta - (eta * dot + kSqrt) * normalY,
+ ] as const
+ }
+
+ return Array.from({ length: samples }, (_, i) => {
+ const x = i / samples
+ const y = bezelHeightFn(x)
+
+ // Calculate derivative in x
+ const dx = x < 1 ? 0.0001 : -0.0001
+ const y2 = bezelHeightFn(x + dx)
+ const derivative = (y2 - y) / dx
+ const magnitude = Math.sqrt(derivative * derivative + 1)
+ const normal = [-derivative / magnitude, -1 / magnitude] as const
+ const refracted = refract(normal[0], normal[1])
+
+ if (!refracted) {
+ return 0
+ }
+ else {
+ const remainingHeightOnBezel = y * bezelWidth
+ const remainingHeight = remainingHeightOnBezel + glassThickness
+
+ // Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom of glass)
+ return refracted[0] * (remainingHeight / refracted[1])
+ }
+ })
+}
+
+export function calculateDisplacementMap(props: {
+ width: number
+ height: number
+ radius: number
+ bezelWidth: number
+ maximumDisplacement: number
+ precomputedDisplacementMap: number[]
+ pixelRatio: number
+}) {
+ const { pixelRatio, maximumDisplacement, precomputedDisplacementMap } = props
+
+ const width = Math.round(props.width * pixelRatio)
+ const height = Math.round(props.height * pixelRatio)
+
+ const radius = Math.min(props.radius * pixelRatio, width / 2, height / 2)
+ const bezel = Math.min(props.bezelWidth * pixelRatio, width / 2, height / 2)
+
+ return calculateRoundedSquareMap({
+ width,
+ height,
+ radius,
+ maximumDistanceToBorder: bezel,
+ fillColor: 0xFF008080,
+ processPixel(
+ _x,
+ _y,
+ buffer,
+ offset,
+ _distanceFromCenter,
+ distanceFromBorder,
+ distanceFromBorderRatio,
+ angle,
+ opacity,
+ ) {
+ // Viewed from top
+ const cos = Math.cos(angle)
+ const sin = Math.sin(angle)
+
+ const ratio
+ = bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel
+
+ const bezelIndex = Math.round(ratio * precomputedDisplacementMap.length)
+ const distance = precomputedDisplacementMap[bezelIndex] ?? 0
+
+ const dX = (-cos * distance) / maximumDisplacement
+ const dY = (-sin * distance) / maximumDisplacement
+
+ buffer[offset] = 128 + dX * 127 * opacity // R
+ buffer[offset + 1] = 128 + dY * 127 * opacity // G
+ buffer[offset + 2] = 0 // B
+ buffer[offset + 3] = 255 // A
+ },
+ })
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/process-pixel.type.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/process-pixel.type.ts
new file mode 100644
index 000000000..62a4148a8
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/process-pixel.type.ts
@@ -0,0 +1,18 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/process-pixel.type.ts}
+ */
+
+export type ProcessPixelFunction = (
+ x: number,
+ y: number,
+ buffer: Uint8ClampedArray,
+ offset: number,
+ distanceFromCenter: number,
+ distanceFromBorder: number,
+ distanceFromBorderRatio: number,
+ /**
+ * Angle from center to pixel in radians.
+ */
+ angle: number,
+ opacity: number,
+) => void
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/specular.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/specular.ts
new file mode 100644
index 000000000..988fa6e2b
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/graphics/liquid-glass/maps/specular.ts
@@ -0,0 +1,70 @@
+/**
+ * @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/specular.ts}
+ */
+
+import { calculateCircleMap } from './calculate-circle-map'
+
+const NEAR_EDGE_DISTANCE = 20
+
+export function calculateSpecularImage(props: {
+ width: number
+ height: number
+ radius: number
+ specularAngle: number
+ pixelRatio: number
+}) {
+ const { pixelRatio, specularAngle } = props
+
+ // Calculate real dimensions using pixel ratio
+ const width = Math.round(props.width * pixelRatio)
+ const height = Math.round(props.height * pixelRatio)
+ const radius = Math.min(props.radius * pixelRatio, width / 2, height / 2)
+
+ // Vector along which we should see specular
+ const specular_vector = [
+ Math.cos(specularAngle),
+ Math.sin(specularAngle),
+ ] as const
+
+ return calculateCircleMap({
+ width,
+ height,
+ fillColor: 0x00000000,
+ radius,
+ maximumDistanceToBorder: NEAR_EDGE_DISTANCE * pixelRatio,
+ processPixel(
+ x,
+ y,
+ buffer,
+ offset,
+ distanceFromCenter,
+ _distanceFromBorder,
+ _distanceFromBorderRatio,
+ _angle,
+ opacity,
+ ) {
+ const distanceFromSide = radius - distanceFromCenter
+
+ // Viewed from top
+ const cos = x / distanceFromCenter
+ const sin = -y / distanceFromCenter
+
+ // Dot product of orientation
+ const dotProduct = Math.abs(
+ cos * specular_vector[0] + sin * specular_vector[1],
+ )
+
+ const coefficient
+ = dotProduct
+ * Math.sqrt(1 - (1 - distanceFromSide / (1 * pixelRatio)) ** 2)
+
+ const color = 255 * coefficient
+ const finalOpacity = color * coefficient * opacity
+
+ buffer[offset] = color // R
+ buffer[offset + 1] = color // G
+ buffer[offset + 2] = color // B
+ buffer[offset + 3] = finalOpacity // A
+ },
+ })
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/Apps.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/Apps.vue
new file mode 100644
index 000000000..a3945da26
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/Apps.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/Finder.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/Finder.vue
new file mode 100644
index 000000000..f399d46d8
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/Finder.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/TrashFull.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/TrashFull.vue
new file mode 100644
index 000000000..b56f6209f
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/TrashFull.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/apps.png b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/apps.png
new file mode 100644
index 000000000..b03238622
Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/apps.png differ
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/finder.png b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/finder.png
new file mode 100644
index 000000000..4139286de
Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/finder.png differ
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/trash-full.png b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/trash-full.png
new file mode 100644
index 000000000..ba6424b6a
Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/assets/trash-full.png differ
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/index.ts
new file mode 100644
index 000000000..7ead5ee84
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/applications/index.ts
@@ -0,0 +1,3 @@
+export { default as Apps } from './Apps.vue'
+export { default as Finder } from './Finder.vue'
+export { default as TrashFull } from './TrashFull.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Apple.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Apple.vue
new file mode 100644
index 000000000..f2db9c81a
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Apple.vue
@@ -0,0 +1,9 @@
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Switch2.0.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Switch2.0.vue
new file mode 100644
index 000000000..15345523c
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Switch2.0.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Wifi.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Wifi.vue
new file mode 100644
index 000000000..2b53de722
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/Wifi.vue
@@ -0,0 +1,13 @@
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/index.ts
new file mode 100644
index 000000000..39172e2ee
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/sf-symbol-like/index.ts
@@ -0,0 +1,3 @@
+export { default as Apple } from './Apple.vue'
+export { default as Switch2_0 } from './Switch2.0.vue'
+export { default as Wifi } from './Wifi.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/Folder.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/Folder.vue
new file mode 100644
index 000000000..49e5319d5
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/Folder.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/assets/folder-common.png b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/assets/folder-common.png
new file mode 100644
index 000000000..0b83c8c0f
Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/assets/folder-common.png differ
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/index.ts
new file mode 100644
index 000000000..d0317450a
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/icons/system/index.ts
@@ -0,0 +1 @@
+export { default as Folder } from './Folder.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/index.ts
new file mode 100644
index 000000000..ba831cbaf
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/index.ts
@@ -0,0 +1 @@
+export { default as PlatformRoot } from './PlatformRoot.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/texts/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/texts/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/display/appearance.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/display/appearance.vue
new file mode 100644
index 000000000..1e542d95c
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/display/appearance.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/dock/dock.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/dock/dock.vue
new file mode 100644
index 000000000..67f2b2321
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/dock/dock.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/index.ts
new file mode 100644
index 000000000..a8731e4dd
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/index.ts
@@ -0,0 +1,3 @@
+export { default as Appearance } from './display/appearance.vue'
+export { default as Dock } from './dock/dock.vue'
+export { default as MenuBar } from './menu-bar/menu-bar.vue'
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/menu-bar/menu-bar.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/menu-bar/menu-bar.vue
new file mode 100644
index 000000000..93ff3ad0d
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/macos-26/ui/menu-bar/menu-bar.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+ AIRI
+
+
+ File
+
+
+ Edit
+
+
+ Selection
+
+
+ View
+
+
+ Window
+
+
+ Help
+
+
+
+
+
+ Fri Apr 3 22:04:05
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/windows-11/PlatformRoot.vue b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/windows-11/PlatformRoot.vue
new file mode 100644
index 000000000..96e2f9e08
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/windows-11/PlatformRoot.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/windows-11/index.ts b/packages/scenarios-stage-tamagotchi-browser/src/components/platforms/windows-11/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/main.ts b/packages/scenarios-stage-tamagotchi-browser/src/main.ts
new file mode 100644
index 000000000..024420928
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/main.ts
@@ -0,0 +1,8 @@
+import { createApp } from 'vue'
+
+import App from './App.vue'
+
+import '@unocss/reset/tailwind.css'
+import 'uno.css'
+
+createApp(App).mount('#app')
diff --git a/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue b/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue
new file mode 100644
index 000000000..7c65ed116
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/scenarios-stage-tamagotchi-browser/tsconfig.json b/packages/scenarios-stage-tamagotchi-browser/tsconfig.json
new file mode 100644
index 000000000..cf6ab9bb4
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/tsconfig.json
@@ -0,0 +1,38 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "jsx": "preserve",
+ "lib": [
+ "DOM",
+ "ESNext",
+ "DOM.AsyncIterable"
+ ],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "types": [
+ "vite/client"
+ ],
+ "allowJs": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.d.ts",
+ "src/**/*.vue",
+ "vite.config.ts"
+ ],
+ "exclude": [
+ "dist",
+ "node_modules"
+ ]
+}
diff --git a/packages/scenarios-stage-tamagotchi-browser/uno.config.ts b/packages/scenarios-stage-tamagotchi-browser/uno.config.ts
new file mode 100644
index 000000000..df19debe1
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/uno.config.ts
@@ -0,0 +1,20 @@
+import { mergeConfigs, presetWebFonts } from 'unocss'
+
+import { presetWebFontsFonts, sharedUnoConfig } from '../../uno.config'
+
+export default mergeConfigs([
+ sharedUnoConfig(),
+ {
+ presets: [
+ presetWebFonts({
+ fonts: {
+ ...presetWebFontsFonts('fontsource'),
+ },
+ timeouts: {
+ warning: 5000,
+ failure: 10000,
+ },
+ }),
+ ],
+ },
+])
diff --git a/packages/scenarios-stage-tamagotchi-browser/vite.config.ts b/packages/scenarios-stage-tamagotchi-browser/vite.config.ts
new file mode 100644
index 000000000..396d6b530
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-browser/vite.config.ts
@@ -0,0 +1,14 @@
+import Vue from '@vitejs/plugin-vue'
+import Unocss from 'unocss/vite'
+
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [
+ Vue(),
+
+ // https://github.com/antfu/unocss
+ // see uno.config.ts for config
+ Unocss(),
+ ],
+})
diff --git a/packages/scenarios-stage-tamagotchi-electron/README.md b/packages/scenarios-stage-tamagotchi-electron/README.md
new file mode 100644
index 000000000..b45e03271
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-electron/README.md
@@ -0,0 +1,52 @@
+# Scenarios - Stage Tamagotchi Electron
+
+Own the raw Electron capture scenarios for stage-tamagotchi.
+
+## Purpose
+
+This package owns the product-specific scenario definitions only. It depends on `@proj-airi/vishot-runner-electron` for:
+
+- the `defineScenario()` helper
+- the capture context surface
+- Electron window and screenshot helpers exposed by the runner package
+
+It does not launch Electron itself and it does not compose final browser-scene exports.
+
+## Workflow
+
+1. Build the Electron app.
+2. Run the runner package against one of the scenario modules in this package.
+3. Write raw screenshots into `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`.
+4. Let the browser scene package consume those raw screenshots for final composition.
+
+```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
+```
+
+## Scenario Authoring
+
+```ts
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
+
+export default defineScenario({
+ id: 'settings-connection',
+ async run({ capture, stageWindows, controlsIsland, settingsWindow }) {
+ const mainWindow = await stageWindows.waitFor('main')
+
+ await controlsIsland.expand(mainWindow.page)
+ const settings = await controlsIsland.openSettings(mainWindow.page)
+ const page = await settingsWindow.goToConnection(settings.page)
+ await page.waitForTimeout(1000)
+
+ await page.getByText('WebSocket Server Address').waitFor({ state: 'visible' })
+ await capture('connection-settings', page)
+ },
+})
+```
+
+## Notes
+
+- Raw scenario modules live under `src/scenarios`.
+- Scenario modules are consumed by the runner package, not by the browser composition package.
+- The package stays focused on business capture flows and avoids browser-scene composition concerns.
diff --git a/packages/scenarios-stage-tamagotchi-electron/package.json b/packages/scenarios-stage-tamagotchi-electron/package.json
new file mode 100644
index 000000000..e554d9aa2
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-electron/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@proj-airi/scenarios-stage-tamagotchi-electron",
+ "type": "module",
+ "version": "0.9.0-beta.0",
+ "private": true,
+ "description": "Product-owned Electron scenarios for stage-tamagotchi raw screenshot capture",
+ "author": {
+ "name": "Moeru AI Project AIRI Team",
+ "email": "airi@moeru.ai",
+ "url": "https://github.com/moeru-ai"
+ },
+ "license": "MIT",
+ "exports": "./src/index.ts",
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@proj-airi/vishot-runner-electron": "workspace:^"
+ },
+ "devDependencies": {
+ "@types/node": "^24.12.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/packages/scenarios-stage-tamagotchi-electron/src/index.ts b/packages/scenarios-stage-tamagotchi-electron/src/index.ts
new file mode 100644
index 000000000..10286d365
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-electron/src/index.ts
@@ -0,0 +1,4 @@
+export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket'
+export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces'
+export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog'
+export { default as settingsConnectionScenario } from './scenarios/settings-connection'
diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts
similarity index 61%
rename from packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts
rename to packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts
index c7a00b41e..55a3af0d0 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-controls-settings-chat-websocket.ts
+++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts
@@ -1,31 +1,28 @@
/* eslint-disable e18e/prefer-static-regex */
-import { defineScenario } from '../runtime/define-scenario'
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'demo-controls-settings-chat-websocket',
async run({ capture, controlsIsland, settingsWindow, stageWindows }) {
const mainWindow = await stageWindows.waitFor('main')
+ await mainWindow.page.waitForTimeout(2000)
+ await capture('00-stage-tamagotchi', mainWindow.page)
await controlsIsland.expand(mainWindow.page)
await mainWindow.page.waitForTimeout(300)
-
- await capture('00-controls-island-expanded', mainWindow.page)
+ await capture('01-controls-island-expanded', mainWindow.page)
const settingsWindowSnapshot = await controlsIsland.openSettings(mainWindow.page)
await settingsWindowSnapshot.page.getByText(/connection|websocket|router/i).first().waitFor({ state: 'visible' })
await settingsWindowSnapshot.page.waitForTimeout(300)
- await capture('01-settings-window', settingsWindowSnapshot.page)
+ await capture('02-settings-window', settingsWindowSnapshot.page)
await mainWindow.page.bringToFront()
await controlsIsland.expand(mainWindow.page)
- const chatWindowSnapshot = await controlsIsland.openChat(mainWindow.page)
- await chatWindowSnapshot.page.waitForLoadState('domcontentloaded')
- await chatWindowSnapshot.page.waitForTimeout(300)
- await capture('02-chat-window', chatWindowSnapshot.page)
const websocketSettingsPage = await settingsWindow.goToConnection(settingsWindowSnapshot.page)
await websocketSettingsPage.getByText('WebSocket Server Address').waitFor({ state: 'visible' })
- await websocketSettingsPage.waitForTimeout(300)
- await capture('03-websocket-settings', websocketSettingsPage)
+ await websocketSettingsPage.waitForTimeout(750)
+ await capture('04-websocket-settings', websocketSettingsPage)
},
})
diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-dismiss-surfaces.ts
similarity index 82%
rename from packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts
rename to packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-dismiss-surfaces.ts
index c3db44748..6c9ccfad2 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-dismiss-surfaces.ts
+++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-dismiss-surfaces.ts
@@ -1,4 +1,4 @@
-import { defineScenario } from '../runtime/define-scenario'
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'demo-dismiss-surfaces',
diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts
similarity index 87%
rename from packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts
rename to packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts
index 927c907a9..e1dff0923 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/scenarios/demo-hearing-dialog.ts
+++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts
@@ -1,4 +1,4 @@
-import { defineScenario } from '../runtime/define-scenario'
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'demo-hearing-dialog',
diff --git a/packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts
similarity index 89%
rename from packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts
rename to packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts
index 6b4d40f90..34d75809a 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/scenarios/settings-connection.ts
+++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts
@@ -1,4 +1,4 @@
-import { defineScenario } from '../runtime/define-scenario'
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'settings-connection',
diff --git a/packages/scenarios-stage-tamagotchi-electron/tsconfig.json b/packages/scenarios-stage-tamagotchi-electron/tsconfig.json
new file mode 100644
index 000000000..1af564659
--- /dev/null
+++ b/packages/scenarios-stage-tamagotchi-electron/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}
diff --git a/packages/vishot-runner-browser/README.md b/packages/vishot-runner-browser/README.md
new file mode 100644
index 000000000..f6f571a6a
--- /dev/null
+++ b/packages/vishot-runner-browser/README.md
@@ -0,0 +1,45 @@
+# Vishot Runner Browser
+
+Browser-side capture tooling for Vishot scene rendering entrypoints.
+
+## Purpose
+
+This package is the browser capture engine used by scene packages such as `@proj-airi/scenarios-stage-tamagotchi-browser`. It provides:
+
+- the package export surface in `src/index.ts`
+- the `captureBrowserRoots()` programmatic API
+- the `capture` CLI entry in `src/cli/capture.ts`
+- Vite dev-server startup for scene packages
+- Playwright-driven export of each `data-scenario-capture-root` element as its own PNG
+
+## Usage
+
+```bash
+pnpm -F @proj-airi/vishot-runner-browser capture -- ../scenarios-stage-tamagotchi-browser --output-dir ../scenarios-stage-tamagotchi-browser/artifacts/final
+pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture
+```
+
+The browser capture package can run directly against a scene package root, or indirectly through the consumer package's own `capture` script. In both cases it starts the scene package's Vite app, opens it in Chromium, waits for the frontend ready signal, and exports each capture root into `packages/scenarios-stage-tamagotchi-browser/artifacts/final`.
+
+Programmatic usage from a scene package looks like this:
+
+```ts
+import path from 'node:path'
+
+import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser'
+
+const sceneAppRoot = path.resolve(process.cwd())
+
+await captureBrowserRoots({
+ sceneAppRoot,
+ routePath: '/',
+ outputDir: path.resolve(sceneAppRoot, 'artifacts', 'final'),
+})
+```
+
+## Notes
+
+- `captureBrowserRoots()` accepts either `sceneAppRoot` or `baseUrl`.
+- Scene packages mark readiness through `window.__SCENARIO_CAPTURE_READY__`.
+- The CLI treats `` as the scene package root and captures the default `/` route.
+- The parser accepts repeated `--root` flags for named capture roots.
diff --git a/packages/vishot-runner-browser/package.json b/packages/vishot-runner-browser/package.json
new file mode 100644
index 000000000..f87d1382d
--- /dev/null
+++ b/packages/vishot-runner-browser/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@proj-airi/vishot-runner-browser",
+ "type": "module",
+ "version": "0.9.0-beta.4",
+ "private": true,
+ "description": "Browser capture runner for Vishot scenes",
+ "author": {
+ "name": "Moeru AI Project AIRI Team",
+ "email": "airi@moeru.ai",
+ "url": "https://github.com/moeru-ai"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/moeru-ai/airi.git",
+ "directory": "packages/vishot-runner-browser"
+ },
+ "exports": "./src/index.ts",
+ "scripts": {
+ "capture": "tsx src/cli/capture.ts",
+ "test:run": "vitest run",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@moeru/std": "catalog:",
+ "@types/node": "^24.12.0",
+ "meow": "catalog:",
+ "playwright": "^1.56.1",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3",
+ "vite": "catalog:",
+ "vitest": "catalog:vitest"
+ }
+}
diff --git a/packages/vishot-runner-browser/src/cli/capture.test.ts b/packages/vishot-runner-browser/src/cli/capture.test.ts
new file mode 100644
index 000000000..4423aeed7
--- /dev/null
+++ b/packages/vishot-runner-browser/src/cli/capture.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+
+import { parseCaptureBrowserCliArguments } from './capture'
+
+describe('parseCaptureBrowserCliArguments', () => {
+ it('accepts a render entry and output dir', () => {
+ expect(parseCaptureBrowserCliArguments([
+ 'src/scenes/intro.ts',
+ '--output-dir',
+ './artifacts/browser-run',
+ ])).toEqual({
+ renderEntry: 'src/scenes/intro.ts',
+ outputDir: './artifacts/browser-run',
+ rootNames: [],
+ })
+ })
+
+ it('accepts the -o alias and repeated --root flags', () => {
+ expect(parseCaptureBrowserCliArguments([
+ 'src/scenes/intro.ts',
+ '-o',
+ './artifacts/browser-run',
+ '--root',
+ 'intro-desktop',
+ '--root',
+ 'intro-settings',
+ ])).toEqual({
+ renderEntry: 'src/scenes/intro.ts',
+ outputDir: './artifacts/browser-run',
+ rootNames: ['intro-desktop', 'intro-settings'],
+ })
+ })
+
+ it('rejects missing output dir', () => {
+ expect(() => parseCaptureBrowserCliArguments([
+ 'src/scenes/intro.ts',
+ ])).toThrow('Usage: vishot-runner-browser --output-dir ')
+ })
+
+ it('rejects extra positional arguments', () => {
+ expect(() => parseCaptureBrowserCliArguments([
+ 'src/scenes/intro.ts',
+ 'src/scenes/demo.ts',
+ '--output-dir',
+ './artifacts/browser-run',
+ ])).toThrow('Usage: vishot-runner-browser --output-dir ')
+ })
+})
diff --git a/packages/vishot-runner-browser/src/cli/capture.ts b/packages/vishot-runner-browser/src/cli/capture.ts
new file mode 100644
index 000000000..4b2575bac
--- /dev/null
+++ b/packages/vishot-runner-browser/src/cli/capture.ts
@@ -0,0 +1,88 @@
+import type { CaptureBrowserCliArguments } from '../runtime/types'
+
+import path from 'node:path'
+import process from 'node:process'
+
+import { fileURLToPath } from 'node:url'
+
+import meow from 'meow'
+
+import { errorMessageFrom } from '@moeru/std'
+
+import { captureBrowserRoots } from '../runtime/capture'
+
+const captureBrowserHelpText = `
+ Capture browser-rendered scenes and export named roots.
+
+ Usage
+ $ vishot-runner-browser --output-dir
+
+ Options
+ --output-dir, -o Directory to write browser capture output into
+ --root Named capture root to export; may be repeated
+`
+
+const captureBrowserUsageMessage = 'Usage: vishot-runner-browser --output-dir '
+
+function normalizeCliArgv(argv: string[]): string[] {
+ return argv[0] === '--' ? argv.slice(1) : argv
+}
+
+function normalizeRootNames(rootNames: string | string[] | undefined): string[] {
+ if (typeof rootNames === 'string') {
+ return [rootNames]
+ }
+
+ return rootNames ?? []
+}
+
+export function parseCaptureBrowserCliArguments(argv: string[]): CaptureBrowserCliArguments {
+ const cli = meow(captureBrowserHelpText, {
+ argv: normalizeCliArgv(argv),
+ importMeta: import.meta,
+ flags: {
+ outputDir: {
+ shortFlag: 'o',
+ type: 'string',
+ },
+ root: {
+ isMultiple: true,
+ type: 'string',
+ },
+ },
+ })
+
+ if (cli.input.length !== 1
+ || typeof cli.flags.outputDir !== 'string'
+ || cli.flags.outputDir.length === 0) {
+ throw new Error(captureBrowserUsageMessage)
+ }
+
+ return {
+ renderEntry: cli.input[0],
+ outputDir: cli.flags.outputDir,
+ rootNames: normalizeRootNames(cli.flags.root),
+ }
+}
+
+function isDirectExecution(): boolean {
+ return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+}
+
+async function main(): Promise {
+ const { outputDir, renderEntry, rootNames } = parseCaptureBrowserCliArguments(process.argv.slice(2))
+
+ await captureBrowserRoots({
+ sceneAppRoot: path.resolve(process.cwd(), renderEntry),
+ routePath: '/',
+ outputDir: path.resolve(process.cwd(), outputDir),
+ rootNames,
+ })
+}
+
+if (isDirectExecution()) {
+ void main().catch((error) => {
+ console.error(errorMessageFrom(error) ?? captureBrowserUsageMessage)
+ process.exitCode = 1
+ })
+}
diff --git a/packages/vishot-runner-browser/src/index.ts b/packages/vishot-runner-browser/src/index.ts
new file mode 100644
index 000000000..c39481f54
--- /dev/null
+++ b/packages/vishot-runner-browser/src/index.ts
@@ -0,0 +1,6 @@
+export { parseCaptureBrowserCliArguments } from './cli/capture'
+export { captureBrowserRoots } from './runtime/capture'
+export { assertUniqueCaptureFilePaths, captureFilePath, sanitizeOutputName } from './runtime/files'
+export { captureRootSelector } from './runtime/selectors'
+export type { CaptureBrowserCliArguments } from './runtime/types'
+export type { BrowserCaptureRequest, CapturedRootArtifact } from './runtime/types'
diff --git a/packages/vishot-runner-browser/src/runtime/capture.test.ts b/packages/vishot-runner-browser/src/runtime/capture.test.ts
new file mode 100644
index 000000000..8f4abd63f
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/capture.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+
+import { assertUniqueCaptureFilePaths, sanitizeOutputName } from './files'
+import { captureRootSelector } from './selectors'
+
+describe('sanitizeOutputName', () => {
+ it('normalizes invalid filename characters', () => {
+ expect(sanitizeOutputName('Intro Desktop / Main')).toBe('intro-desktop-main')
+ })
+})
+
+describe('assertUniqueCaptureFilePaths', () => {
+ it('allows unique sanitized output names', () => {
+ expect(() => assertUniqueCaptureFilePaths([
+ 'intro-desktop',
+ 'intro-settings',
+ ])).not.toThrow()
+ })
+
+ it('rejects colliding sanitized output names', () => {
+ expect(() => assertUniqueCaptureFilePaths([
+ 'Foo Bar',
+ 'foo/bar',
+ ])).toThrow('both resolve to "foo-bar.png"')
+ })
+})
+
+describe('captureRootSelector', () => {
+ it('builds the root selector for a named capture root', () => {
+ expect(captureRootSelector('intro-desktop')).toBe('[data-scenario-capture-root="intro-desktop"]')
+ })
+})
diff --git a/packages/vishot-runner-browser/src/runtime/capture.ts b/packages/vishot-runner-browser/src/runtime/capture.ts
new file mode 100644
index 000000000..a405afad5
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/capture.ts
@@ -0,0 +1,122 @@
+import type { Page } from 'playwright'
+
+import type { BrowserCaptureRequest, CapturedRootArtifact } from './types'
+
+import path from 'node:path'
+
+import { mkdir } from 'node:fs/promises'
+
+import { chromium } from 'playwright'
+
+import { assertUniqueCaptureFilePaths, captureFilePath } from './files'
+import { captureRootSelector } from './selectors'
+import { startSceneViteServer } from './vite-server'
+
+const defaultViewport = {
+ width: 1600,
+ height: 1200,
+ deviceScaleFactor: 2,
+} as const
+
+function getScenarioCaptureRoots(page: Page): Promise {
+ return page.locator('[data-scenario-capture-root]').evaluateAll((elements) => {
+ const rootNames = elements
+ .map(element => element.getAttribute('data-scenario-capture-root') ?? '')
+ .filter((name): name is string => name.length > 0)
+
+ return [...new Set(rootNames)]
+ })
+}
+
+async function waitForScenarioReady(page: Page): Promise {
+ await page.waitForFunction(() => {
+ return (window as typeof window & { __SCENARIO_CAPTURE_READY__?: boolean }).__SCENARIO_CAPTURE_READY__ === true
+ })
+}
+
+async function captureRoot(page: Page, outputDir: string, rootName: string): Promise {
+ const filePath = captureFilePath(outputDir, rootName)
+ const locator = page.locator(captureRootSelector(rootName))
+
+ await locator.waitFor({ state: 'visible' })
+ await locator.screenshot({
+ animations: 'disabled',
+ path: filePath,
+ })
+
+ return {
+ rootName,
+ filePath,
+ }
+}
+
+async function resolveBaseUrl(request: BrowserCaptureRequest): Promise<{ baseUrl: string, closeServer?: () => Promise }> {
+ if (request.baseUrl) {
+ return { baseUrl: request.baseUrl }
+ }
+
+ if (request.sceneAppRoot) {
+ const server = await startSceneViteServer(request.sceneAppRoot)
+
+ return {
+ baseUrl: server.baseUrl,
+ closeServer: server.close,
+ }
+ }
+
+ throw new Error('Browser capture requires either "baseUrl" or "sceneAppRoot"')
+}
+
+export async function captureBrowserRoots(request: BrowserCaptureRequest): Promise {
+ const { baseUrl, closeServer } = await resolveBaseUrl(request)
+ let browser: Awaited> | undefined
+
+ try {
+ browser = await chromium.launch()
+ const context = await browser.newContext({
+ viewport: {
+ width: request.viewport?.width ?? defaultViewport.width,
+ height: request.viewport?.height ?? defaultViewport.height,
+ },
+ deviceScaleFactor: request.viewport?.deviceScaleFactor ?? defaultViewport.deviceScaleFactor,
+ })
+
+ try {
+ const page = await context.newPage()
+ const targetUrl = new URL(request.routePath, baseUrl).href
+
+ await page.goto(targetUrl)
+ await waitForScenarioReady(page)
+ await mkdir(path.resolve(request.outputDir), { recursive: true })
+
+ const rootNames = request.rootNames?.length
+ ? request.rootNames
+ : await getScenarioCaptureRoots(page)
+
+ assertUniqueCaptureFilePaths(rootNames)
+
+ const artifacts: CapturedRootArtifact[] = []
+
+ for (const rootName of rootNames) {
+ artifacts.push(await captureRoot(page, request.outputDir, rootName))
+ }
+
+ return artifacts
+ }
+ finally {
+ await context.close()
+ }
+ }
+ finally {
+ try {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ finally {
+ if (closeServer) {
+ await closeServer()
+ }
+ }
+ }
+}
diff --git a/packages/vishot-runner-browser/src/runtime/files.ts b/packages/vishot-runner-browser/src/runtime/files.ts
new file mode 100644
index 000000000..1ee539683
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/files.ts
@@ -0,0 +1,35 @@
+import path from 'node:path'
+
+const nonFilenameCharactersPattern = /[^a-z0-9-_]+/g
+const edgeDashPattern = /^-+|-+$/g
+
+export function sanitizeOutputName(name: string): string {
+ const sanitized = name
+ .trim()
+ .toLowerCase()
+ .replace(nonFilenameCharactersPattern, '-')
+ .replace(edgeDashPattern, '')
+
+ return sanitized.length > 0 ? sanitized : 'capture'
+}
+
+export function captureFilePath(outputDir: string, rootName: string): string {
+ return path.resolve(outputDir, `${sanitizeOutputName(rootName)}.png`)
+}
+
+export function assertUniqueCaptureFilePaths(rootNames: string[]): void {
+ const seenFilePaths = new Map()
+
+ for (const rootName of rootNames) {
+ const sanitizedName = sanitizeOutputName(rootName)
+ const previousRootName = seenFilePaths.get(sanitizedName)
+
+ if (previousRootName) {
+ throw new Error(
+ `Capture roots "${previousRootName}" and "${rootName}" both resolve to "${sanitizedName}.png". Root names must map to unique output files.`,
+ )
+ }
+
+ seenFilePaths.set(sanitizedName, rootName)
+ }
+}
diff --git a/packages/vishot-runner-browser/src/runtime/integration.test.ts b/packages/vishot-runner-browser/src/runtime/integration.test.ts
new file mode 100644
index 000000000..c93bcf561
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/integration.test.ts
@@ -0,0 +1,202 @@
+import path from 'node:path'
+
+import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+
+interface FakeLocator {
+ evaluateAll: (callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) => Promise
+ waitFor: (opts: { state: 'visible' }) => Promise
+ screenshot: (opts: { animations: 'disabled', path: string }) => Promise
+}
+
+interface FakePage {
+ goto: (url: string) => Promise
+ waitForFunction: (predicate: () => boolean) => Promise
+ locator: (selector: string) => FakeLocator
+}
+
+function createFixturePage(html: string): FakePage {
+ const readyState = {
+ ready: false,
+ }
+
+ function getRootNames(): string[] {
+ return Array.from(html.matchAll(/data-scenario-capture-root="([^"]+)"/g), match => match[1])
+ }
+
+ function hasRoot(selector: string): boolean {
+ const match = selector.match(/^\[data-scenario-capture-root="([^"]+)"\]$/)
+
+ if (!match) {
+ return false
+ }
+
+ return getRootNames().includes(match[1])
+ }
+
+ return {
+ async goto(url: string) {
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error(`Failed to load scene fixture: ${response.status} ${response.statusText}`)
+ }
+
+ html = await response.text()
+ readyState.ready = /__SCENARIO_CAPTURE_READY__\s*=\s*true/.test(html)
+ },
+ async waitForFunction(predicate: () => boolean) {
+ const startedAt = Date.now()
+
+ while (Date.now() - startedAt <= 5000) {
+ const previousWindow = globalThis.window
+
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ __SCENARIO_CAPTURE_READY__: readyState.ready,
+ },
+ })
+
+ try {
+ if (predicate()) {
+ return
+ }
+ }
+ finally {
+ if (previousWindow === undefined) {
+ Reflect.deleteProperty(globalThis, 'window')
+ }
+ else {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: previousWindow,
+ })
+ }
+ }
+
+ if (Date.now() - startedAt > 5000) {
+ throw new Error('Timed out waiting for scene readiness')
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+
+ throw new Error('Timed out waiting for scene readiness')
+ },
+ locator(selector: string): FakeLocator {
+ return {
+ async evaluateAll(callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) {
+ const elements = getRootNames().map(name => ({
+ getAttribute(nameToGet: string) {
+ return nameToGet === 'data-scenario-capture-root' ? name : null
+ },
+ }))
+
+ return callback(elements)
+ },
+ async waitFor(opts: { state: 'visible' }) {
+ if (opts.state !== 'visible' || !hasRoot(selector)) {
+ throw new Error(`Selector not visible: ${selector}`)
+ }
+ },
+ async screenshot(opts: { animations: 'disabled', path: string }) {
+ await writeFile(opts.path, `fake screenshot for ${selector}\n`)
+ },
+ }
+ },
+ }
+}
+
+let captureBrowserRoots: typeof import('./capture').captureBrowserRoots
+
+vi.mock('playwright', () => {
+ return {
+ chromium: {
+ launch: vi.fn(async () => {
+ let page: FakePage | undefined
+
+ return {
+ newContext: async () => ({
+ newPage: async () => {
+ page = createFixturePage('')
+ return page
+ },
+ close: async () => {},
+ }),
+ close: async () => {},
+ }
+ }),
+ },
+ }
+})
+
+beforeAll(async () => {
+ ;({ captureBrowserRoots } = await import('./capture'))
+})
+
+async function createViteSceneFixture(): Promise {
+ const rootDir = await mkdtemp(path.join(process.cwd(), '.tmp-scene-'))
+
+ await mkdir(path.join(rootDir, 'artifacts'), { recursive: true })
+
+ await writeFile(
+ path.join(rootDir, 'index.html'),
+ `
+
+
+
+
+ Scene fixture
+
+
+
+
+
+ Chat window
+ Capture root fixture.
+
+
+ WebSocket settings
+ Capture root fixture.
+
+
+
+
+`,
+ )
+
+ await writeFile(
+ path.join(rootDir, 'vite.config.ts'),
+ `import { defineConfig } from 'vite'
+
+export default defineConfig({
+ publicDir: 'artifacts',
+})
+`,
+ )
+
+ return rootDir
+}
+
+describe('captureBrowserRoots', () => {
+ it('captures all roots from a vite-served scene app', async () => {
+ const sceneAppRoot = await createViteSceneFixture()
+ const outputDir = path.join(sceneAppRoot, 'artifacts', 'final-test')
+
+ const artifacts = await captureBrowserRoots({
+ sceneAppRoot,
+ routePath: '/',
+ outputDir,
+ })
+
+ expect(artifacts.map(item => item.rootName)).toEqual([
+ 'intro-chat-window',
+ 'intro-websocket-settings',
+ ])
+ expect(artifacts.every(item => item.filePath.startsWith(outputDir))).toBe(true)
+ }, 120000)
+})
diff --git a/packages/vishot-runner-browser/src/runtime/selectors.ts b/packages/vishot-runner-browser/src/runtime/selectors.ts
new file mode 100644
index 000000000..cf2167045
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/selectors.ts
@@ -0,0 +1,3 @@
+export function captureRootSelector(rootName: string): string {
+ return `[data-scenario-capture-root=${JSON.stringify(rootName)}]`
+}
diff --git a/packages/vishot-runner-browser/src/runtime/types.ts b/packages/vishot-runner-browser/src/runtime/types.ts
new file mode 100644
index 000000000..a504745dd
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/types.ts
@@ -0,0 +1,23 @@
+export interface CaptureBrowserCliArguments {
+ renderEntry: string
+ outputDir: string
+ rootNames: string[]
+}
+
+export interface BrowserCaptureRequest {
+ sceneAppRoot?: string
+ baseUrl?: string
+ routePath: string
+ outputDir: string
+ rootNames?: string[]
+ viewport?: {
+ width: number
+ height: number
+ deviceScaleFactor?: number
+ }
+}
+
+export interface CapturedRootArtifact {
+ rootName: string
+ filePath: string
+}
diff --git a/packages/vishot-runner-browser/src/runtime/vite-server.ts b/packages/vishot-runner-browser/src/runtime/vite-server.ts
new file mode 100644
index 000000000..8e5e893af
--- /dev/null
+++ b/packages/vishot-runner-browser/src/runtime/vite-server.ts
@@ -0,0 +1,48 @@
+import type { AddressInfo } from 'node:net'
+
+import type { ViteDevServer } from 'vite'
+
+import { createServer } from 'vite'
+
+export interface ManagedViteServer {
+ baseUrl: string
+ close: () => Promise
+}
+
+function resolveViteServerUrl(server: ViteDevServer, sceneAppRoot: string): string {
+ const url = server.resolvedUrls?.local?.[0] ?? server.resolvedUrls?.network?.[0]
+
+ if (url) {
+ return url
+ }
+
+ const address = server.httpServer?.address()
+
+ if (!address || typeof address === 'string') {
+ throw new Error(`Unable to determine Vite dev server address for ${sceneAppRoot}`)
+ }
+
+ const { address: host, port } = address as AddressInfo
+
+ return `http://${host}:${port}`
+}
+
+export async function startSceneViteServer(sceneAppRoot: string): Promise {
+ const server = await createServer({
+ root: sceneAppRoot,
+ server: {
+ host: '127.0.0.1',
+ port: 41731,
+ strictPort: false,
+ },
+ })
+
+ await server.listen()
+
+ return {
+ baseUrl: resolveViteServerUrl(server, sceneAppRoot),
+ close: async () => {
+ await server.close()
+ },
+ }
+}
diff --git a/packages/vishot-runner-browser/tsconfig.json b/packages/vishot-runner-browser/tsconfig.json
new file mode 100644
index 000000000..97068867e
--- /dev/null
+++ b/packages/vishot-runner-browser/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": [
+ "node"
+ ],
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/**/*.ts",
+ "vitest.config.ts"
+ ]
+}
diff --git a/packages/devtool-capture-stage-tamagotchi/vitest.config.ts b/packages/vishot-runner-browser/vitest.config.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/vitest.config.ts
rename to packages/vishot-runner-browser/vitest.config.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/README.md b/packages/vishot-runner-electron/README.md
similarity index 52%
rename from packages/devtool-capture-stage-tamagotchi/README.md
rename to packages/vishot-runner-electron/README.md
index bc1724746..df71f03fe 100644
--- a/packages/devtool-capture-stage-tamagotchi/README.md
+++ b/packages/vishot-runner-electron/README.md
@@ -1,62 +1,57 @@
-# DevTool - Capture Stage Tamagotchi
+# Vishot Runner - Electron
-Capture screenshots from the built `stage-tamagotchi` Electron app with TypeScript scenarios.
+Capture raw screenshots from the built `stage-tamagotchi` Electron app with TypeScript scenarios.
## Purpose
-This package now provides three things:
+This package is the Electron capture runner. It provides:
- a runtime surface in `src/index.ts`
- the `capture` CLI in `src/cli/capture.ts`
- the `defineScenario()` authoring helper for scenario modules
+- reusable helpers for the controls island, settings window, dialogs, drawers, and stage windows
-Legacy POC-only files are gone from the package surface. The package is now focused on capture/runtime behavior rather than Playwright test-runner scaffolding or docs generation.
+This package stops at raw business screenshots. It does not own the scenario modules themselves; those live in `@proj-airi/scenarios-stage-tamagotchi-electron`.
-## Usage
+## Workflow
```bash
pnpm -F @proj-airi/stage-tamagotchi build
-pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run
+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
```
-## Demo Walkthrough
+This writes the raw inputs consumed by the browser scene package:
-This package also ships a demo scenario that captures these states in order:
+- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-controls-island-expanded.png`
+- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-settings-window.png`
+- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-chat-window.png`
+- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/03-websocket-settings.png`
-- `00-controls-island-expanded`
-- `01-settings-window`
-- `02-chat-window`
-- `03-websocket-settings`
-
-Run it from the repo root:
+Then export the composed browser assets:
```bash
-pnpm -F @proj-airi/stage-tamagotchi build
-pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ./artifacts/demo-run
+pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture
```
-Expected files:
+In this environment, the raw capture command worked end-to-end after running outside the sandbox because `tsx` pipe creation was denied inside the sandbox (`listen EPERM` on the temporary `tsx` IPC pipe).
-- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/00-controls-island-expanded.png`
-- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/01-settings-window.png`
-- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/02-chat-window.png`
-- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/03-websocket-settings.png`
+## Additional Examples
To verify the controls-island hearing button specifically:
```bash
pnpm -F @proj-airi/stage-tamagotchi build
-pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo
+pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo
```
Expected file:
-- `packages/devtool-capture-stage-tamagotchi/artifacts/hearing-demo/hearing-dialog.png`
+- `packages/vishot-runner-electron/artifacts/hearing-demo/hearing-dialog.png`
## Scenario Authoring
```ts
-import { defineScenario } from '@proj-airi/devtool-capture-stage-tamagotchi'
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'settings-connection',
@@ -85,7 +80,7 @@ For surfaces built with `DialogRoot` or `DrawerRoot`, the runtime now exposes:
Example:
```ts
-import { defineScenario } from '@proj-airi/devtool-capture-stage-tamagotchi'
+import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'dismiss-helpers',
@@ -122,5 +117,6 @@ It does not open the settings window from the main window for you. The intended
## Notes
-- Importing `@proj-airi/devtool-capture-stage-tamagotchi` now resolves to `src/index.ts` via the package export surface.
+- Importing `@proj-airi/vishot-runner-electron` resolves to `src/index.ts` via the package export surface.
- The package is no longer a Playwright test suite package.
+- Final composed exports live in `packages/scenarios-stage-tamagotchi-browser/artifacts/final`, not this package.
diff --git a/packages/devtool-capture-stage-tamagotchi/package.json b/packages/vishot-runner-electron/package.json
similarity index 79%
rename from packages/devtool-capture-stage-tamagotchi/package.json
rename to packages/vishot-runner-electron/package.json
index e2aefc9aa..e0442c121 100644
--- a/packages/devtool-capture-stage-tamagotchi/package.json
+++ b/packages/vishot-runner-electron/package.json
@@ -1,9 +1,9 @@
{
- "name": "@proj-airi/devtool-capture-stage-tamagotchi",
+ "name": "@proj-airi/vishot-runner-electron",
"type": "module",
"version": "0.9.0-beta.3",
"private": true,
- "description": "Playwright-driven Electron capture tooling for stage-tamagotchi",
+ "description": "Playwright-driven Electron capture runner for stage-tamagotchi",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
diff --git a/packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts b/packages/vishot-runner-electron/src/cli/capture.test.ts
similarity index 61%
rename from packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts
rename to packages/vishot-runner-electron/src/cli/capture.test.ts
index 7c0012766..ea58e9c2e 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/cli/capture.test.ts
+++ b/packages/vishot-runner-electron/src/cli/capture.test.ts
@@ -5,32 +5,32 @@ import { parseCaptureCliArguments } from './capture'
describe('parseCaptureCliArguments', () => {
it('accepts a scenario path with --output-dir', () => {
expect(parseCaptureCliArguments([
- 'src/scenarios/settings-connection.ts',
+ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
'--output-dir',
'./artifacts/manual-run',
])).toEqual({
- scenarioPath: 'src/scenarios/settings-connection.ts',
+ scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
outputDir: './artifacts/manual-run',
})
})
it('accepts the -o alias', () => {
expect(parseCaptureCliArguments([
- 'src/scenarios/settings-connection.ts',
+ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
'-o',
'./artifacts/manual-run',
])).toEqual({
- scenarioPath: 'src/scenarios/settings-connection.ts',
+ scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
outputDir: './artifacts/manual-run',
})
})
it('accepts --output-dir=value', () => {
expect(parseCaptureCliArguments([
- 'src/scenarios/settings-connection.ts',
+ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
'--output-dir=./artifacts/manual-run',
])).toEqual({
- scenarioPath: 'src/scenarios/settings-connection.ts',
+ scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
outputDir: './artifacts/manual-run',
})
})
@@ -44,14 +44,14 @@ describe('parseCaptureCliArguments', () => {
it('rejects missing output directory', () => {
expect(() => parseCaptureCliArguments([
- 'src/scenarios/settings-connection.ts',
+ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
])).toThrow('Usage: capture --output-dir ')
})
it('rejects extra positional arguments', () => {
expect(() => parseCaptureCliArguments([
- 'src/scenarios/settings-connection.ts',
- 'src/scenarios/demo-hearing-dialog.ts',
+ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
+ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts',
'--output-dir',
'./artifacts/manual-run',
])).toThrow('Usage: capture --output-dir ')
diff --git a/packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts b/packages/vishot-runner-electron/src/cli/capture.ts
similarity index 90%
rename from packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts
rename to packages/vishot-runner-electron/src/cli/capture.ts
index 64dca9acf..ccd8ebe21 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/cli/capture.ts
+++ b/packages/vishot-runner-electron/src/cli/capture.ts
@@ -28,8 +28,8 @@ const captureHelpText = `
--output-dir, -o Directory to write PNG screenshots into
Examples
- $ capture src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run
- $ capture src/scenarios/settings-connection.ts -o ./artifacts/manual-run
+ $ capture packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run
+ $ capture packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts -o ./artifacts/manual-run
`
const captureUsageMessage = 'Usage: capture --output-dir '
diff --git a/packages/vishot-runner-electron/src/index.ts b/packages/vishot-runner-electron/src/index.ts
new file mode 100644
index 000000000..c47b38286
--- /dev/null
+++ b/packages/vishot-runner-electron/src/index.ts
@@ -0,0 +1,17 @@
+export { capturePage } from './runtime/capture'
+export { createScenarioContext } from './runtime/context'
+export { defineScenario } from './runtime/define-scenario'
+export { loadScenarioModule } from './runtime/load-scenario'
+export type { LoadedScenarioModule } from './runtime/load-scenario'
+export type {
+ CaptureOptions,
+ ControlsIslandApi,
+ DialogsApi,
+ DrawersApi,
+ ElectronScenario,
+ ScenarioContext,
+ SettingsWindowApi,
+ StageWindowsApi,
+} from './runtime/types'
+export { resolveElectronAppInfo } from './utils/app-path'
+export type { ElectronAppInfo } from './utils/app-path'
diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/capture.ts b/packages/vishot-runner-electron/src/runtime/capture.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/runtime/capture.ts
rename to packages/vishot-runner-electron/src/runtime/capture.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/context.ts b/packages/vishot-runner-electron/src/runtime/context.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/runtime/context.ts
rename to packages/vishot-runner-electron/src/runtime/context.ts
diff --git a/packages/vishot-runner-electron/src/runtime/define-scenario.ts b/packages/vishot-runner-electron/src/runtime/define-scenario.ts
new file mode 100644
index 000000000..b9cd05dc2
--- /dev/null
+++ b/packages/vishot-runner-electron/src/runtime/define-scenario.ts
@@ -0,0 +1,5 @@
+import type { ElectronScenario } from './types'
+
+export function defineScenario(scenario: ElectronScenario): ElectronScenario {
+ return scenario
+}
diff --git a/packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts b/packages/vishot-runner-electron/src/runtime/load-scenario.ts
similarity index 81%
rename from packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts
rename to packages/vishot-runner-electron/src/runtime/load-scenario.ts
index 45e94d841..9d6a9518b 100644
--- a/packages/devtool-capture-stage-tamagotchi/src/runtime/load-scenario.ts
+++ b/packages/vishot-runner-electron/src/runtime/load-scenario.ts
@@ -1,4 +1,4 @@
-import type { StageTamagotchiScenario } from './types'
+import type { ElectronScenario } from './types'
import process from 'node:process'
@@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url'
export interface LoadedScenarioModule {
modulePath: string
- scenario: StageTamagotchiScenario
+ scenario: ElectronScenario
}
function getErrorMessage(error: unknown): string {
@@ -19,12 +19,12 @@ function getErrorMessage(error: unknown): string {
return String(error)
}
-function isStageTamagotchiScenario(value: unknown): value is StageTamagotchiScenario {
+function isElectronScenario(value: unknown): value is ElectronScenario {
if (typeof value !== 'object' || value === null) {
return false
}
- const scenario = value as Partial
+ const scenario = value as Partial
return typeof scenario.id === 'string' && typeof scenario.run === 'function'
}
@@ -44,7 +44,7 @@ export async function loadScenarioModule(scenarioPath: string): Promise Promise
}
diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/app-path.ts b/packages/vishot-runner-electron/src/utils/app-path.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/utils/app-path.ts
rename to packages/vishot-runner-electron/src/utils/app-path.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/overlays.ts b/packages/vishot-runner-electron/src/utils/overlays.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/utils/overlays.ts
rename to packages/vishot-runner-electron/src/utils/overlays.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/selectors.ts b/packages/vishot-runner-electron/src/utils/selectors.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/utils/selectors.ts
rename to packages/vishot-runner-electron/src/utils/selectors.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/settings.ts b/packages/vishot-runner-electron/src/utils/settings.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/utils/settings.ts
rename to packages/vishot-runner-electron/src/utils/settings.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/src/utils/windows.ts b/packages/vishot-runner-electron/src/utils/windows.ts
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/src/utils/windows.ts
rename to packages/vishot-runner-electron/src/utils/windows.ts
diff --git a/packages/devtool-capture-stage-tamagotchi/tsconfig.json b/packages/vishot-runner-electron/tsconfig.json
similarity index 100%
rename from packages/devtool-capture-stage-tamagotchi/tsconfig.json
rename to packages/vishot-runner-electron/tsconfig.json
diff --git a/packages/vishot-runner-electron/vitest.config.ts b/packages/vishot-runner-electron/vitest.config.ts
new file mode 100644
index 000000000..647f39364
--- /dev/null
+++ b/packages/vishot-runner-electron/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ include: ['src/**/*.test.ts'],
+ },
+})
diff --git a/packages/vishot-runtime/README.md b/packages/vishot-runtime/README.md
new file mode 100644
index 000000000..4c97b7ee3
--- /dev/null
+++ b/packages/vishot-runtime/README.md
@@ -0,0 +1,27 @@
+# Vishot Runtime
+
+Shared runtime contracts and Vue bindings for Vishot scene capture.
+
+## Purpose
+
+This package owns the browser-side runtime surface used by scene packages. It provides:
+
+- readiness helpers via `markScenarioReady()` and `resetScenarioReady()`
+- the shared `ScenarioCaptureRootProps` type
+- framework-specific bindings through subpath exports such as `@proj-airi/vishot-runtime/vue`
+
+## Usage
+
+```ts
+import { markScenarioReady, resetScenarioReady } from '@proj-airi/vishot-runtime'
+import { ScenarioCanvas, ScenarioCaptureRoot } from '@proj-airi/vishot-runtime/vue'
+```
+
+Keep framework-agnostic contracts at the package root and import Vue SFC bindings from `./vue`.
+
+```ts
+import {
+ markScenarioReady,
+ resetScenarioReady,
+} from '@proj-airi/vishot-runtime'
+```
diff --git a/packages/vishot-runtime/package.json b/packages/vishot-runtime/package.json
new file mode 100644
index 000000000..8e37a9d28
--- /dev/null
+++ b/packages/vishot-runtime/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@proj-airi/vishot-runtime",
+ "type": "module",
+ "version": "0.9.0-beta.4",
+ "private": true,
+ "description": "Runtime capture contracts and Vue bindings for Vishot",
+ "author": {
+ "name": "Moeru AI Project AIRI Team",
+ "email": "airi@moeru.ai",
+ "url": "https://github.com/moeru-ai"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/moeru-ai/airi.git",
+ "directory": "packages/vishot-runtime"
+ },
+ "exports": {
+ ".": "./src/index.ts",
+ "./vue": "./src/vue/index.ts",
+ "./components/*": "./src/components/*.vue",
+ "./runtime/*": "./src/runtime/*.ts",
+ "./*": "./src/*.ts"
+ },
+ "scripts": {
+ "build": "echo \"No build step required\"",
+ "test:run": "vitest run",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "vue": "catalog:"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.5",
+ "typescript": "^5.9.3",
+ "vite": "catalog:",
+ "vitest": "catalog:vitest",
+ "vue-tsc": "^3.2.6"
+ }
+}
diff --git a/packages/vishot-runtime/src/__tests__/exports.test.ts b/packages/vishot-runtime/src/__tests__/exports.test.ts
new file mode 100644
index 000000000..49def272a
--- /dev/null
+++ b/packages/vishot-runtime/src/__tests__/exports.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+
+import * as runtimeModule from '../index'
+
+describe('vishot runtime exports', () => {
+ it('keeps framework-agnostic helpers on the root export only', () => {
+ expect(runtimeModule.markScenarioReady).toBeTypeOf('function')
+ expect(runtimeModule.resetScenarioReady).toBeTypeOf('function')
+ expect('ScenarioCanvas' in runtimeModule).toBe(false)
+ expect('ScenarioCaptureRoot' in runtimeModule).toBe(false)
+ })
+
+ it('exposes vue-specific bindings from the vue subpath', async () => {
+ const vueModule = await import('../vue')
+
+ expect(vueModule.ScenarioCanvas).toBeTruthy()
+ expect(vueModule.ScenarioCaptureRoot).toBeTruthy()
+ })
+})
diff --git a/packages/vishot-runtime/src/__tests__/ready.test.ts b/packages/vishot-runtime/src/__tests__/ready.test.ts
new file mode 100644
index 000000000..579d630c9
--- /dev/null
+++ b/packages/vishot-runtime/src/__tests__/ready.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { markScenarioReady, resetScenarioReady } from '../runtime/ready'
+
+describe('scenario ready helpers', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('marks the browser runtime as ready', () => {
+ const dispatchEvent = vi.fn()
+
+ vi.stubGlobal('window', {
+ __SCENARIO_CAPTURE_READY__: undefined,
+ dispatchEvent,
+ } as unknown as Window)
+
+ resetScenarioReady()
+ markScenarioReady()
+
+ expect(window.__SCENARIO_CAPTURE_READY__).toBe(true)
+ expect(dispatchEvent).toHaveBeenCalledTimes(1)
+ expect(dispatchEvent.mock.calls[0]?.[0]).toMatchObject({
+ type: 'scenario-capture:ready',
+ })
+ })
+})
diff --git a/packages/vishot-runtime/src/components/scenario-canvas.vue b/packages/vishot-runtime/src/components/scenario-canvas.vue
new file mode 100644
index 000000000..fefbae9fe
--- /dev/null
+++ b/packages/vishot-runtime/src/components/scenario-canvas.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/packages/vishot-runtime/src/components/scenario-capture-root.vue b/packages/vishot-runtime/src/components/scenario-capture-root.vue
new file mode 100644
index 000000000..aceab60f3
--- /dev/null
+++ b/packages/vishot-runtime/src/components/scenario-capture-root.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/packages/vishot-runtime/src/env.d.ts b/packages/vishot-runtime/src/env.d.ts
new file mode 100644
index 000000000..ab333dd6f
--- /dev/null
+++ b/packages/vishot-runtime/src/env.d.ts
@@ -0,0 +1,8 @@
+///
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+
+ const component: DefineComponent, Record, unknown>
+ export default component
+}
diff --git a/packages/vishot-runtime/src/index.ts b/packages/vishot-runtime/src/index.ts
new file mode 100644
index 000000000..815c7f15f
--- /dev/null
+++ b/packages/vishot-runtime/src/index.ts
@@ -0,0 +1,2 @@
+export { markScenarioReady, resetScenarioReady } from './runtime/ready'
+export type { ScenarioCaptureRootProps } from './runtime/types'
diff --git a/packages/vishot-runtime/src/runtime/ready.ts b/packages/vishot-runtime/src/runtime/ready.ts
new file mode 100644
index 000000000..4cc81342a
--- /dev/null
+++ b/packages/vishot-runtime/src/runtime/ready.ts
@@ -0,0 +1,20 @@
+const scenarioCaptureReadyEventType = 'scenario-capture:ready'
+
+type ScenarioReadyWindow = Window & {
+ __SCENARIO_CAPTURE_READY__?: boolean
+}
+
+export function resetScenarioReady(): void {
+ window.__SCENARIO_CAPTURE_READY__ = false
+}
+
+export function markScenarioReady(): void {
+ const scenarioReadyWindow = window as ScenarioReadyWindow
+
+ scenarioReadyWindow.__SCENARIO_CAPTURE_READY__ = true
+ scenarioReadyWindow.dispatchEvent(
+ typeof Event === 'function'
+ ? new Event(scenarioCaptureReadyEventType)
+ : ({ type: scenarioCaptureReadyEventType } as Event),
+ )
+}
diff --git a/packages/vishot-runtime/src/runtime/types.ts b/packages/vishot-runtime/src/runtime/types.ts
new file mode 100644
index 000000000..7e9c28fd6
--- /dev/null
+++ b/packages/vishot-runtime/src/runtime/types.ts
@@ -0,0 +1,10 @@
+declare global {
+ interface Window {
+ __SCENARIO_CAPTURE_READY__?: boolean
+ }
+}
+
+export interface ScenarioCaptureRootProps {
+ name: string
+ padding?: string
+}
diff --git a/packages/vishot-runtime/src/vue/index.ts b/packages/vishot-runtime/src/vue/index.ts
new file mode 100644
index 000000000..edee96f18
--- /dev/null
+++ b/packages/vishot-runtime/src/vue/index.ts
@@ -0,0 +1,2 @@
+export { default as ScenarioCanvas } from '../components/scenario-canvas.vue'
+export { default as ScenarioCaptureRoot } from '../components/scenario-capture-root.vue'
diff --git a/packages/vishot-runtime/tsconfig.json b/packages/vishot-runtime/tsconfig.json
new file mode 100644
index 000000000..226fbfe79
--- /dev/null
+++ b/packages/vishot-runtime/tsconfig.json
@@ -0,0 +1,39 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "jsx": "preserve",
+ "lib": [
+ "DOM",
+ "ESNext",
+ "DOM.AsyncIterable"
+ ],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "types": [
+ "vitest",
+ "vite/client"
+ ],
+ "allowJs": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "noUnusedLocals": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.d.ts",
+ "src/**/*.vue",
+ "vite.config.ts",
+ "vitest.config.ts"
+ ],
+ "exclude": [
+ "dist",
+ "node_modules"
+ ]
+}
diff --git a/packages/vishot-runtime/vite.config.ts b/packages/vishot-runtime/vite.config.ts
new file mode 100644
index 000000000..a72c03b58
--- /dev/null
+++ b/packages/vishot-runtime/vite.config.ts
@@ -0,0 +1,4 @@
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+})
diff --git a/packages/vishot-runtime/vitest.config.ts b/packages/vishot-runtime/vitest.config.ts
new file mode 100644
index 000000000..f1cd83bc3
--- /dev/null
+++ b/packages/vishot-runtime/vitest.config.ts
@@ -0,0 +1,11 @@
+import Vue from '@vitejs/plugin-vue'
+
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ root: import.meta.dirname,
+ plugins: [Vue()],
+ test: {
+ include: ['src/**/*.test.ts'],
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bb943f11c..cf6fb397f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -45,9 +45,18 @@ catalogs:
'@hono/node-ws':
specifier: ^1.3.0
version: 1.3.0
+ '@iconify-json/line-md':
+ specifier: ^1.2.16
+ version: 1.2.16
'@iconify-json/logos':
specifier: ^1.2.10
version: 1.2.10
+ '@iconify-json/material-symbols':
+ specifier: ^1.2.64
+ version: 1.2.64
+ '@iconify-json/mdi':
+ specifier: ^1.2.3
+ version: 1.2.3
'@iconify-json/tabler':
specifier: ^1.2.32
version: 1.2.32
@@ -168,6 +177,9 @@ catalogs:
d3:
specifier: 7.9.0
version: 7.9.0
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
@@ -2318,27 +2330,6 @@ importers:
specifier: ^1.0.2
version: 1.0.2
- packages/devtool-capture-stage-tamagotchi:
- devDependencies:
- '@moeru/std':
- specifier: 'catalog:'
- version: 0.1.0-beta.17
- '@types/node':
- specifier: ^24.12.0
- version: 24.12.0
- meow:
- specifier: 'catalog:'
- version: 14.1.0
- playwright:
- specifier: ^1.56.1
- version: 1.59.0
- tsx:
- specifier: ^4.21.0
- version: 4.21.0
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
packages/electron-eventa:
dependencies:
'@moeru/eventa':
@@ -2522,6 +2513,62 @@ importers:
specifier: ^5.28.0
version: 5.28.0
+ packages/scenarios-stage-tamagotchi-browser:
+ dependencies:
+ '@proj-airi/vishot-runner-browser':
+ specifier: workspace:^
+ version: link:../vishot-runner-browser
+ '@proj-airi/vishot-runtime':
+ specifier: workspace:^
+ version: link:../vishot-runtime
+ date-fns:
+ specifier: 'catalog:'
+ version: 4.1.0
+ vue:
+ specifier: 'catalog:'
+ version: 3.5.30(typescript@5.9.3)
+ devDependencies:
+ '@iconify-json/line-md':
+ specifier: 'catalog:'
+ version: 1.2.16
+ '@iconify-json/material-symbols':
+ specifier: 'catalog:'
+ version: 1.2.64
+ '@iconify-json/mdi':
+ specifier: 'catalog:'
+ version: 1.2.3
+ '@types/node':
+ specifier: ^24.12.0
+ version: 24.12.0
+ '@unocss/reset':
+ specifier: ^66.6.7
+ version: 66.6.7
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.5
+ version: 6.0.5(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.30(typescript@5.9.3))
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: 'catalog:'
+ version: 8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vue-tsc:
+ specifier: ^3.2.6
+ version: 3.2.6(typescript@5.9.3)
+
+ packages/scenarios-stage-tamagotchi-electron:
+ dependencies:
+ '@proj-airi/vishot-runner-electron':
+ specifier: workspace:^
+ version: link:../vishot-runner-electron
+ devDependencies:
+ '@types/node':
+ specifier: ^24.12.0
+ version: 24.12.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
packages/server-runtime:
dependencies:
'@guiiai/logg':
@@ -3499,6 +3546,76 @@ importers:
specifier: '>=66'
version: 66.6.7
+ packages/vishot-runner-browser:
+ devDependencies:
+ '@moeru/std':
+ specifier: 'catalog:'
+ version: 0.1.0-beta.17
+ '@types/node':
+ specifier: ^24.12.0
+ version: 24.12.0
+ meow:
+ specifier: 'catalog:'
+ version: 14.1.0
+ playwright:
+ specifier: ^1.56.1
+ version: 1.59.0
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: 'catalog:'
+ version: 8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vitest:
+ specifier: catalog:vitest
+ version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+
+ packages/vishot-runner-electron:
+ devDependencies:
+ '@moeru/std':
+ specifier: 'catalog:'
+ version: 0.1.0-beta.17
+ '@types/node':
+ specifier: ^24.12.0
+ version: 24.12.0
+ meow:
+ specifier: 'catalog:'
+ version: 14.1.0
+ playwright:
+ specifier: ^1.56.1
+ version: 1.59.0
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+ packages/vishot-runtime:
+ dependencies:
+ vue:
+ specifier: 'catalog:'
+ version: 3.5.30(typescript@5.9.3)
+ devDependencies:
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.5
+ version: 6.0.5(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.30(typescript@5.9.3))
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: 'catalog:'
+ version: 8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vitest:
+ specifier: catalog:vitest
+ version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ vue-tsc:
+ specifier: ^3.2.6
+ version: 3.2.6(typescript@5.9.3)
+
packages/vite-plugin-warpdrive:
dependencies:
rolldown:
@@ -5798,12 +5915,21 @@ packages:
'@iconify-json/eos-icons@1.2.4':
resolution: {integrity: sha512-gp2BCSF3+aRgMqE7LRAVxT5nXJLOtfcvGnOYwJz4gc4zxcEqAaOByxFVLZej15jXPRJmUv2TSzMNrQ2gDtw4VA==}
+ '@iconify-json/line-md@1.2.16':
+ resolution: {integrity: sha512-esHPUQUp30NyqJWa7MNu6ExdG8z/aEFC5r56rVyRGhtJ7acOme6YtYPnDOgVu6p+kk24rM8602xkFuBC6g6x+w==}
+
'@iconify-json/logos@1.2.10':
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
'@iconify-json/lucide@1.2.98':
resolution: {integrity: sha512-Lx2464W8Tty/QEnZ2UPb73nPdML/HpGCj0J0w37jP3/jx3l4fniZBjDxe1TgHiIL5XW9QO3vlx53ZQZ5JsNpzQ==}
+ '@iconify-json/material-symbols@1.2.64':
+ resolution: {integrity: sha512-80iGXJ/rsCE2zbEBWrLeOcyBNRD7v15EyaflUolgiAtaevSpTLBXlz+M53Bs2K/RkV/I4tOMMxyXVjQwKhTA9w==}
+
+ '@iconify-json/mdi@1.2.3':
+ resolution: {integrity: sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg==}
+
'@iconify-json/mingcute@1.2.7':
resolution: {integrity: sha512-bp4z6F53KAQp99aSL7qPCHL7HWUNGMNKIhfo/dx9pwVHiad/WusUbTOFXEZfpA44syJPzSOxa3O6Pwiq7qZz1g==}
@@ -19813,6 +19939,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
+ '@iconify-json/line-md@1.2.16':
+ dependencies:
+ '@iconify/types': 2.0.0
+
'@iconify-json/logos@1.2.10':
dependencies:
'@iconify/types': 2.0.0
@@ -19821,6 +19951,14 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
+ '@iconify-json/material-symbols@1.2.64':
+ dependencies:
+ '@iconify/types': 2.0.0
+
+ '@iconify-json/mdi@1.2.3':
+ dependencies:
+ '@iconify/types': 2.0.0
+
'@iconify-json/mingcute@1.2.7':
dependencies:
'@iconify/types': 2.0.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index ce5b1e08a..aaf822574 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -40,7 +40,10 @@ catalog:
'@guiiai/logg': ^1.2.11
'@histoire/plugin-vue': 1.0.0-beta.1
'@hono/node-ws': ^1.3.0
+ '@iconify-json/line-md': ^1.2.16
'@iconify-json/logos': ^1.2.10
+ '@iconify-json/material-symbols': ^1.2.64
+ '@iconify-json/mdi': ^1.2.3
'@iconify-json/tabler': ^1.2.32
'@intlify/core': ^11.3.0
'@modelcontextprotocol/sdk': ^1.27.1
@@ -81,6 +84,7 @@ catalog:
capacitor-native-settings: ^8.1.0
crossws: ^0.4.4
d3: 7.9.0
+ date-fns: ^4.1.0
drizzle-kit: ^0.31.10
drizzle-orm: ^0.45.1
drizzle-valibot: ^0.4.2
diff --git a/vitest.config.ts b/vitest.config.ts
index 08bf0c4f5..d6b13bb9e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -8,11 +8,13 @@ export default defineConfig({
'apps/stage-tamagotchi',
'packages/audio-pipelines-transcribe',
'packages/cap-vite',
+ 'packages/vishot-runner-browser',
'packages/plugin-sdk',
'packages/server-runtime',
'packages/server-sdk',
'packages/stage-shared',
'packages/stage-ui',
+ 'packages/vishot-runtime',
'packages/vite-plugin-warpdrive',
],
},