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 @@ + + + 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 @@ + + + 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 @@ + + + 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', ], },