feat(vishot-*,scenarios-*): new packages, and playwright high level abstraction

Co-authored-by-agent: Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
This commit is contained in:
Neko Ayaka
2026-04-04 01:37:57 +08:00
parent 1fccad5fc7
commit c9dd2cac74
116 changed files with 3025 additions and 96 deletions
+1
View File
@@ -305,6 +305,7 @@ words:
- valibot
- vaul
- velin
- vishot
- VITE
- vitepress
- vllm
+1 -1
View File
@@ -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",
@@ -1,11 +0,0 @@
export { defineScenario } from './runtime/define-scenario'
export type {
CaptureOptions,
ControlsIslandApi,
DialogsApi,
DrawersApi,
ScenarioContext,
SettingsWindowApi,
StageTamagotchiScenario,
StageWindowsApi,
} from './runtime/types'
@@ -1,5 +0,0 @@
import type { StageTamagotchiScenario } from './types'
export function defineScenario(scenario: StageTamagotchiScenario): StageTamagotchiScenario {
return scenario
}
@@ -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.
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stage Tamagotchi Scenes</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -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"
}
}
@@ -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,
},
})
@@ -0,0 +1,21 @@
<script setup lang="ts">
import IntroManualScene from './scenes/intro-manual-scene.vue'
</script>
<template>
<IntroManualScene />
</template>
<style>
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
#app {
overflow: hidden;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

@@ -0,0 +1,7 @@
<script setup lang="ts">
import AppIcon from '../assets/icon.png'
</script>
<template>
<img :src="AppIcon" alt="App Icon">
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Appearance, Dock, MenuBar } from './ui'
const props = withDefaults(defineProps<{
aspectRatio?: string | number
dockSize?: number
}>(), {
aspectRatio: '16:9',
dockSize: 1.5,
})
const aspectRatio = computed(() => {
if (!props.aspectRatio) {
return 16 / 9
}
if (typeof props.aspectRatio === 'number') {
return props.aspectRatio
}
return props.aspectRatio.split(':').map(Number).reduce((a, b) => a / b)
})
</script>
<template>
<div
:class="[
'relative overflow-hidden',
'font-macos',
]"
:style="{
aspectRatio,
}"
>
<div
:class="[
'relative z-999',
'w-full h-full',
]"
>
<slot name="windows" />
</div>
<Appearance />
<MenuBar />
<Dock :size="props.dockSize" />
</div>
</template>
<style scoped>
.font-macos {
font-family: 'Inter', 'Helvetica', 'Arial', sans-serif;
}
</style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { StyleValue } from 'vue'
import { inject, toRef } from 'vue'
import { injectDockSize } from './constants'
const props = withDefaults(defineProps<{
class?: string | string[]
style?: StyleValue
baseSize?: number
running?: boolean
notificationCount?: number
}>(), {})
const dockSize = toRef(() => inject(injectDockSize, 1))
</script>
<template>
<div
:class="[
...props.class ? typeof props.class === 'string' ? [props.class] : props.class : [],
'relative',
]"
:style="{
...props.style ? typeof props.style === 'object' ? props.style : {} : {},
width: `${props.baseSize ? props.baseSize * dockSize : 32 * dockSize}px`,
height: `${props.baseSize ? props.baseSize * dockSize : 32 * dockSize}px`,
}"
>
<slot />
<div
v-if="props.running"
:class="[
'absolute top-1/2 right--0.8 translate-y--1/2',
'rounded-full',
'bg-white/75',
]"
:style="{
width: `${3.2 * dockSize}px`,
height: `${3.2 * dockSize}px`,
}"
/>
<div
v-if="props.notificationCount"
:class="[
'absolute top-0 right-[calc(-50%+5%)] translate-x-[-100%] translate-y-[calc(-50%+16%)]',
'rounded-full',
'bg-red-500',
]"
:style="{
width: `${12 * dockSize}px`,
height: `${12 * dockSize}px`,
}"
>
{{ props.notificationCount }}
</div>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
</script>
<template>
<div
:class="[
'mt-2 mb-1',
'w-[100%] h-0.25',
'bg-white/50',
'rounded-full',
]"
/>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { provide } from 'vue'
import { injectDockSize } from './constants'
const props = withDefaults(defineProps<{
size?: number
}>(), {
size: 1,
})
provide(injectDockSize, props.size)
</script>
<template>
<slot />
</template>
@@ -0,0 +1,3 @@
import type { InjectionKey } from 'vue'
export const injectDockSize: InjectionKey<number> = Symbol('vishot:platforms:macos-26:dock:size')
@@ -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'
@@ -0,0 +1,58 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
title?: string
focus?: boolean
frame?: boolean
transparent?: boolean
titleBarStyle?: 'default' | 'hidden' | 'hiddenInset' | 'customButtonsOnHover'
hasShadow?: boolean
alignTo?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'center'
}>(), {
focus: true,
frame: true,
transparent: false,
titleBarStyle: 'default',
hasShadow: true,
})
</script>
<template>
<div
:class="[
'absolute',
'flex flex-col',
'rounded-2xl overflow-hidden',
props.hasShadow ? 'shadow-xl' : '',
]"
>
<div
v-if="!!props.frame"
:class="[
'flex gap-2 px-2.5 py-2',
'bg-white',
]"
>
<div
:class="[
'flex gap-2',
]"
>
<div :class="['bg-[#FF5C5F] dark:bg-[#FF5C5F]', 'size-3.5', 'rounded-full']" />
<div :class="['bg-[#FAC800] dark:bg-[#FAC800]', 'size-3.5', 'rounded-full']" />
<div :class="['bg-[#34C759] dark:bg-[#34C759]', 'size-3.5', 'rounded-full']" />
</div>
<div v-if="props.title">
<span>{{ props.title }}</span>
</div>
</div>
<div
v-if="!!props.frame"
:class="[
'bg-[#E6E6E6] h-0.25 w-full',
]"
/>
<div>
<slot />
</div>
</div>
</template>
@@ -0,0 +1 @@
export { default as WindowRoot } from './WindowRoot.vue'
@@ -0,0 +1 @@
export * from './liquid-glass'
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue'
import { splitImageDataToParts } from '../helpers/split-imagedata-to-parts'
interface CompositePartsProps {
imageData: ImageData
cornerWidth: number
pixelRatio: number
width: number
height: number
result: string
hideTop?: boolean
hideBottom?: boolean
hideLeft?: boolean
hideRight?: boolean
}
defineOptions({
name: 'CompositeParts',
})
const props = defineProps<CompositePartsProps>()
const parts = computed(() => splitImageDataToParts({
imageData: props.imageData,
cornerWidth: props.cornerWidth,
pixelRatio: props.pixelRatio,
}))
const widthMinusCorner = computed(() => props.width - props.cornerWidth)
const heightMinusCorner = computed(() => props.height - props.cornerWidth)
const compositePartNames = computed(() =>
[
!props.hideTop && 'top',
!props.hideLeft && 'left',
!props.hideRight && 'right',
!props.hideBottom && 'bottom',
!props.hideTop && !props.hideLeft && 'topLeft',
!props.hideTop && !props.hideRight && 'topRight',
!props.hideBottom && !props.hideLeft && 'bottomLeft',
!props.hideBottom && !props.hideRight && 'bottomRight',
].filter((partName): partName is string => typeof partName === 'string'),
)
</script>
<template>
<feImage
:href="parts.topLeft"
:x="0"
:y="0"
:width="cornerWidth"
:height="cornerWidth"
:result="`${result}_topLeft`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.top"
:x="0"
:y="0"
:width="width"
:height="cornerWidth"
:result="`${result}_top`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.topRight"
:x="widthMinusCorner"
:y="0"
:width="cornerWidth"
:height="cornerWidth"
:result="`${result}_topRight`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.left"
:x="0"
:y="0"
:width="cornerWidth"
:height="height"
:result="`${result}_left`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.right"
:x="widthMinusCorner"
:y="0"
:width="cornerWidth"
:height="height"
:result="`${result}_right`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.bottomLeft"
:x="0"
:y="heightMinusCorner"
:width="cornerWidth"
:height="cornerWidth"
:result="`${result}_bottomLeft`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.bottom"
:x="0"
:y="heightMinusCorner"
:width="width"
:height="cornerWidth"
:result="`${result}_bottom`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.bottomRight"
:x="widthMinusCorner"
:y="heightMinusCorner"
:width="cornerWidth"
:height="cornerWidth"
:result="`${result}_bottomRight`"
preserveAspectRatio="none"
/>
<feImage
:href="parts.center"
:x="0"
:y="0"
:width="width"
:height="height"
:result="`${result}_base`"
preserveAspectRatio="none"
/>
<feComposite
v-for="(partName, index) in compositePartNames"
:key="partName"
operator="over"
:in="`${result}_${partName}`"
:in2="index === 0 ? `${result}_base` : `${result}_composite_${index}`"
:result="index === compositePartNames.length - 1 ? result : `${result}_composite_${index}`"
/>
</template>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue'
import CompositeParts from './composite-parts.vue'
import {
calculateDisplacementMap,
calculateDisplacementMapRadius,
} from '../maps/displacement-map'
import { calculateSpecularImage } from '../maps/specular'
interface FilterProps {
id: string
scaleRatio: number
blur: number
width: number
height: number
radius: number
glassThickness: number
bezelWidth: number
refractiveIndex: number
specularOpacity: number
specularAngle: number
bezelHeightFn: (x: number) => number
pixelRatio: number
hideTop?: boolean
hideBottom?: boolean
hideLeft?: boolean
hideRight?: boolean
}
defineOptions({
name: 'LiquidGlassFilter',
})
const props = defineProps<FilterProps>()
const cornerWidth = computed(() => Math.max(props.radius, props.bezelWidth))
const imageSide = computed(() => cornerWidth.value * 2 + 1)
const displacementRadiusMap = computed(() => calculateDisplacementMapRadius(
props.glassThickness,
props.bezelWidth,
props.bezelHeightFn,
props.refractiveIndex,
))
const maximumDisplacement = computed(() =>
Math.max(...displacementRadiusMap.value.map(Math.abs)),
)
const displacementMap = computed(() => calculateDisplacementMap({
width: imageSide.value,
height: imageSide.value,
radius: props.radius,
bezelWidth: props.bezelWidth,
precomputedDisplacementMap: displacementRadiusMap.value,
maximumDisplacement: maximumDisplacement.value,
pixelRatio: props.pixelRatio,
}))
const specularMap = computed(() => calculateSpecularImage({
width: imageSide.value,
height: imageSide.value,
radius: props.radius,
specularAngle: props.specularAngle,
pixelRatio: props.pixelRatio,
}))
const scale = computed(() => maximumDisplacement.value * props.scaleRatio)
</script>
<template>
<svg color-interpolation-filters="sRGB" :style="{ display: 'none' }">
<defs>
<filter :id="id">
<feGaussianBlur
in="SourceGraphic"
:stdDeviation="blur"
result="blurred_source"
/>
<CompositeParts
:image-data="displacementMap"
:width="width"
:height="height"
:corner-width="cornerWidth"
:pixel-ratio="pixelRatio"
result="displacement_map"
:hide-top="hideTop"
:hide-bottom="hideBottom"
:hide-left="hideLeft"
:hide-right="hideRight"
/>
<CompositeParts
:image-data="specularMap"
:width="width"
:height="height"
:corner-width="cornerWidth"
:pixel-ratio="pixelRatio"
result="specular_map"
:hide-top="hideTop"
:hide-bottom="hideBottom"
:hide-left="hideLeft"
:hide-right="hideRight"
/>
<feDisplacementMap
in="blurred_source"
in2="displacement_map"
:scale="scale"
xChannelSelector="R"
yChannelSelector="G"
result="displaced_source"
/>
<feColorMatrix
in="specular_map"
type="luminanceToAlpha"
result="specular_alpha"
/>
<feComponentTransfer in="specular_alpha" result="specular_with_opacity">
<feFuncA type="linear" :slope="specularOpacity" />
</feComponentTransfer>
<feFlood flood-color="white" result="white_layer" />
<feComposite
in="white_layer"
in2="specular_with_opacity"
operator="in"
result="masked_specular"
/>
<feComposite
in="masked_specular"
in2="displaced_source"
operator="over"
/>
</filter>
</defs>
</svg>
</template>
@@ -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()
}
@@ -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,
}
}
@@ -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
}
@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { Component, StyleValue } from 'vue'
import type { SurfaceFnDef } from '../helpers/surface-equations'
import { computed, onBeforeUnmount, onMounted, shallowRef, useAttrs, useId } from 'vue'
import Filter from '../components/filter.vue'
import { convex } from '../helpers/surface-equations'
interface RefractionOptions {
radius: number
blur?: number
glassThickness?: number
bezelWidth?: number
refractiveIndex?: number
specularOpacity?: number
specularAngle?: number
bezelHeightFn?: SurfaceFnDef
}
interface RefractiveProps {
as?: string | Component
refraction: RefractionOptions
}
defineOptions({
name: 'Refractive',
inheritAttrs: false,
})
const props = withDefaults(defineProps<RefractiveProps>(), {
as: 'div',
})
defineSlots<{
default?: () => unknown
}>()
const attrs = useAttrs()
const filterId = useId()
const elementRef = shallowRef<HTMLElement | null>(null)
const width = shallowRef(0)
const height = shallowRef(0)
let resizeObserver: ResizeObserver | null = null
const forwardedAttrs = computed(() => {
const { style: _style, ...rest } = attrs
return rest
})
const mergedStyle = computed<StyleValue>(() => [
attrs.style as StyleValue,
{
backdropFilter: `url(#${filterId})`,
borderRadius: `${props.refraction.radius}px`,
},
])
const resolvedRefraction = computed(() => ({
blur: props.refraction.blur ?? 0,
glassThickness: props.refraction.glassThickness ?? 70,
bezelWidth: props.refraction.bezelWidth ?? 0,
refractiveIndex: props.refraction.refractiveIndex ?? 1.5,
specularOpacity: props.refraction.specularOpacity ?? 0,
specularAngle: props.refraction.specularAngle ?? 0,
bezelHeightFn: props.refraction.bezelHeightFn ?? convex,
}))
function updateSize(entry: ResizeObserverEntry) {
const borderBox = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: entry.borderBoxSize
if (borderBox) {
width.value = borderBox.inlineSize
height.value = borderBox.blockSize
return
}
width.value = entry.contentRect.width
height.value = entry.contentRect.height
}
onMounted(() => {
if (!elementRef.value) {
return
}
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
updateSize(entry)
}
})
resizeObserver.observe(elementRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<Filter
:id="filterId"
:scale-ratio="1"
:pixel-ratio="6"
:width="width"
:height="height"
:radius="refraction.radius"
:blur="resolvedRefraction.blur"
:glass-thickness="resolvedRefraction.glassThickness"
:bezel-width="resolvedRefraction.bezelWidth"
:refractive-index="resolvedRefraction.refractiveIndex"
:specular-opacity="resolvedRefraction.specularOpacity"
:specular-angle="resolvedRefraction.specularAngle"
:bezel-height-fn="resolvedRefraction.bezelHeightFn"
/>
<component
:is="as"
ref="elementRef"
v-bind="forwardedAttrs"
:style="mergedStyle"
>
<slot />
</component>
</template>
@@ -0,0 +1 @@
export { default as Refractive } from './hoc/refractive.vue'
@@ -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
}
@@ -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
}
@@ -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
},
})
}
@@ -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<ArrayBufferLike>,
offset: number,
distanceFromCenter: number,
distanceFromBorder: number,
distanceFromBorderRatio: number,
/**
* Angle from center to pixel in radians.
*/
angle: number,
opacity: number,
) => void
@@ -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
},
})
}
@@ -0,0 +1,10 @@
<script setup lang="ts">
/**
* /System/Applications/Apps.app/Contents/Resources
*/
import AppsIcon from './assets/apps.png'
</script>
<template>
<img :src="AppsIcon" alt="Apps Icon">
</template>
@@ -0,0 +1,10 @@
<script setup lang="ts">
/**
* /System/Library/CoreServices/Finder.app/Contents/Resources
*/
import FinderIcon from './assets/finder.png'
</script>
<template>
<img :src="FinderIcon" alt="Finder Icon">
</template>
@@ -0,0 +1,10 @@
<script setup lang="ts">
/**
* /System/Library/CoreServices/Dock.app/Contents/Resources
*/
import TrashFullIcon from './assets/trash-full.png'
</script>
<template>
<img :src="TrashFullIcon" alt="Trash Full Icon">
</template>
@@ -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'
@@ -0,0 +1,9 @@
<template>
<div
:class="[
'inline-flex items-center',
]"
>
<div i-mdi:apple />
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
</script>
<template>
<div
:class="[
'inline-flex flex-col justify-center items-center',
'relative',
'size-4 m-1 translate-y--0.8',
]"
>
<div
:class="[
'absolute order-1',
'text-base scale-75',
'i-mdi:toggle-switch',
]"
/>
<div
:class="[
'translate-y-1.75 absolute order-2',
'text-base scale-67',
'i-mdi:toggle-switch-off-outline',
]"
/>
</div>
</template>
@@ -0,0 +1,13 @@
<template>
<div
:class="[
'inline-flex flex-col justify-center items-center',
]"
>
<div
:class="[
'i-material-symbols:android-wifi-3-bar',
]"
/>
</div>
</template>
@@ -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'
@@ -0,0 +1,7 @@
<script setup lang="ts">
import FolderIcon from './assets/folder-common.png'
</script>
<template>
<img :src="FolderIcon" alt="Folder Icon">
</template>
@@ -0,0 +1 @@
export { default as Folder } from './Folder.vue'
@@ -0,0 +1 @@
export { default as PlatformRoot } from './PlatformRoot.vue'
@@ -0,0 +1,13 @@
<script setup lang="ts">
import wallpaper from '../../../../../assets/wallpaper-2025-08-12.jpg'
</script>
<template>
<img
:class="[
'absolute left-0 top-0 h-full w-full',
'z-1',
]"
:src="wallpaper"
>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { Application, DockDivider, DockRoot } from '../../containers/dock'
import { Refractive } from '../../graphics'
import { Apps, Finder, TrashFull } from '../../icons/applications'
import { Folder } from '../../icons/system'
const props = withDefaults(defineProps<{
size?: number
}>(), {
size: 2,
})
</script>
<template>
<div class="absolute right-2 top-1/2 z-1000 translate-y--1/2">
<DockRoot :size="props.size">
<Refractive
:refraction="{
radius: 16 * props.size * 0.75,
blur: 6,
glassThickness: 70,
bezelWidth: 8,
refractiveIndex: 1.5,
specularOpacity: 0.25,
specularAngle: Math.PI / 4,
}"
:style="{
background: 'rgb(255 255 255 / 0.12)',
border: '1px solid rgb(255 255 255 / 0.1)',
}"
:class="[
'p-1.5',
'flex flex-col justify-center items-center',
]"
>
<Application running>
<Finder />
</Application>
<Application>
<Apps />
</Application>
<slot name="dock" />
<DockDivider />
<Application :base-size="28">
<Folder />
</Application>
<Application>
<TrashFull />
</Application>
</Refractive>
</DockRoot>
</div>
</template>
@@ -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'
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { Apple, Switch2_0, Wifi } from '../../icons/sf-symbol-like'
</script>
<template>
<div
:class="[
'absolute top-0 left-0',
'z-1000',
'w-full py-1 px-5',
'flex items-center',
'bg-white/40 dark:bg-black/40',
]"
>
<div class="w-full flex items-center gap-4">
<Apple />
<div :class="['text-xs', 'font-600']">
<span>AIRI</span>
</div>
<div :class="['text-xs']">
<span>File</span>
</div>
<div :class="['text-xs']">
<span>Edit</span>
</div>
<div :class="['text-xs']">
<span>Selection</span>
</div>
<div :class="['text-xs']">
<span>View</span>
</div>
<div :class="['text-xs']">
<span>Window</span>
</div>
<div :class="['text-xs']">
<span>Help</span>
</div>
<div class="w-full flex-1" />
<Wifi />
<Switch2_0 />
<div :class="['text-xs']">
<span>Fri Apr 3 22:04:05</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
</script>
<template>
<div />
</template>
@@ -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')
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { markScenarioReady, resetScenarioReady } from '@proj-airi/vishot-runtime'
import { ScenarioCanvas, ScenarioCaptureRoot } from '@proj-airi/vishot-runtime/vue'
import { onMounted } from 'vue'
import stageShot from '../../artifacts/raw/00-stage-tamagotchi.png'
import websocketSettingsShot from '../../artifacts/raw/04-websocket-settings.png'
import Icon from '../components/icon.vue'
import { PlatformRoot } from '../components/platforms/macos-26'
import { Application } from '../components/platforms/macos-26/containers/dock'
import { WindowRoot } from '../components/platforms/macos-26/containers/window'
async function waitForImageSource(source: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(), { once: true })
image.addEventListener('error', () => reject(new Error(`Scenario image failed to load: ${source}`)), { once: true })
image.src = source
})
}
onMounted(async () => {
resetScenarioReady()
try {
await Promise.all([
document.fonts.ready,
waitForImageSource(stageShot),
waitForImageSource(websocketSettingsShot),
])
markScenarioReady()
}
catch (error) {
console.error(error)
}
})
</script>
<template>
<ScenarioCanvas>
<ScenarioCaptureRoot name="intro-chat-window">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot class="translate-x-300 translate-y-100" :frame="false" :has-shadow="false">
<img :src="stageShot" class="w-95">
</WindowRoot>
<WindowRoot class="translate-x-120 translate-y-30">
<img :src="websocketSettingsShot" class="w-120">
</WindowRoot>
</template>
<template #dock>
<Application running>
<Icon />
</Application>
</template>
</PlatformRoot>
</ScenarioCaptureRoot>
</ScenarioCanvas>
</template>
@@ -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"
]
}
@@ -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,
},
}),
],
},
])
@@ -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(),
],
})
@@ -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.
@@ -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"
}
}
@@ -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'
@@ -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)
},
})
@@ -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',
@@ -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',
@@ -1,4 +1,4 @@
import { defineScenario } from '../runtime/define-scenario'
import { defineScenario } from '@proj-airi/vishot-runner-electron'
export default defineScenario({
id: 'settings-connection',
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}
+45
View File
@@ -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 `<render-entry>` as the scene package root and captures the default `/` route.
- The parser accepts repeated `--root` flags for named capture roots.
@@ -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"
}
}
@@ -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 <render-entry> --output-dir <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 <render-entry> --output-dir <dir>')
})
})
@@ -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 <render-entry> --output-dir <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 <render-entry> --output-dir <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<void> {
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
})
}
@@ -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'
@@ -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"]')
})
})
@@ -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<string[]> {
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<void> {
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<CapturedRootArtifact> {
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<void> }> {
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<CapturedRootArtifact[]> {
const { baseUrl, closeServer } = await resolveBaseUrl(request)
let browser: Awaited<ReturnType<typeof chromium.launch>> | 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()
}
}
}
}
@@ -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<string, string>()
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)
}
}
@@ -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: <T>(callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) => Promise<T>
waitFor: (opts: { state: 'visible' }) => Promise<void>
screenshot: (opts: { animations: 'disabled', path: string }) => Promise<void>
}
interface FakePage {
goto: (url: string) => Promise<void>
waitForFunction: (predicate: () => boolean) => Promise<void>
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<T>(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<string> {
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'),
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scene fixture</title>
</head>
<body>
<script>
window.__SCENARIO_CAPTURE_READY__ = true
</script>
<main style="display: grid; gap: 24px; padding: 24px; font-family: sans-serif;">
<section data-scenario-capture-root="intro-chat-window" style="width: 480px; min-height: 160px; padding: 24px; border: 2px solid #334155; border-radius: 20px; background: #0f172a; color: white;">
<h1 style="margin: 0 0 12px;">Chat window</h1>
<p style="margin: 0;">Capture root fixture.</p>
</section>
<section data-scenario-capture-root="intro-websocket-settings" style="width: 480px; min-height: 160px; padding: 24px; border: 2px solid #334155; border-radius: 20px; background: #111827; color: white;">
<h1 style="margin: 0 0 12px;">WebSocket settings</h1>
<p style="margin: 0;">Capture root fixture.</p>
</section>
</main>
</body>
</html>
`,
)
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)
})
@@ -0,0 +1,3 @@
export function captureRootSelector(rootName: string): string {
return `[data-scenario-capture-root=${JSON.stringify(rootName)}]`
}
@@ -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
}
@@ -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<void>
}
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<ManagedViteServer> {
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()
},
}
}
@@ -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"
]
}
@@ -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.
@@ -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",
@@ -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 <scenario.ts> --output-dir <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 <scenario.ts> --output-dir <dir>')
@@ -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 <scenario.ts> --output-dir <dir>'
@@ -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'
@@ -0,0 +1,5 @@
import type { ElectronScenario } from './types'
export function defineScenario(scenario: ElectronScenario): ElectronScenario {
return scenario
}
@@ -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<StageTamagotchiScenario>
const scenario = value as Partial<ElectronScenario>
return typeof scenario.id === 'string' && typeof scenario.run === 'function'
}
@@ -44,7 +44,7 @@ export async function loadScenarioModule(scenarioPath: string): Promise<LoadedSc
throw new Error(`Failed to load scenario module at ${modulePath}: ${getErrorMessage(error)}`)
}
if (!isStageTamagotchiScenario(moduleNamespace.default)) {
if (!isElectronScenario(moduleNamespace.default)) {
const exportedKeys = Object.keys(moduleNamespace).join(', ') || '(none)'
throw new Error(
`Scenario module at ${modulePath} must export a default scenario object with id and run(ctx). Exported keys: ${exportedKeys}`,
@@ -42,7 +42,7 @@ export interface ScenarioContext {
drawers: DrawersApi
}
export interface StageTamagotchiScenario {
export interface ElectronScenario {
id: string
run: (context: ScenarioContext) => Promise<void>
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+27
View File
@@ -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'
```

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