diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44d5ff6e..290cb3b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: typecheck: ${{ steps.filter.outputs.typecheck }} test: ${{ steps.filter.outputs.test }} e2e: ${{ steps.filter.outputs.e2e }} + e2e-pipeline: ${{ steps.filter.outputs.e2e_pipeline }} pins: ${{ steps.filter.outputs.pins }} vendor: ${{ steps.filter.outputs.vendor }} steps: @@ -71,6 +72,14 @@ jobs: - "workflows/**" - "test/e2e/**" - "vendor/**" + e2e_pipeline: + - *shared + - "apps/hub/**" + - "packages/**" + - "workflows/**" + - "test/e2e/**" + - "test/fixtures/**" + - "vendor/**" pins: - *shared - "**/package.json" @@ -156,7 +165,39 @@ jobs: - if: env.RUN == 'true' uses: ./.github/actions/setup - if: env.RUN == 'true' - run: bun run test:e2e + run: bun --conditions=intx-src test ./test/e2e/custom-provider.test.ts ./test/e2e/oauth-model-login.test.ts ./test/e2e/specialist-end-notices.test.ts ./test/e2e/support/recorded-model.test.ts + env: + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: 5432 + E2E_DB_USER: postgres + E2E_DB_PASSWORD: postgres + + e2e-pipeline: + needs: changes + runs-on: ubuntu-24.04 + timeout-minutes: 15 + continue-on-error: true + env: + RUN: ${{ github.event_name != 'pull_request' || needs.changes.outputs.e2e-pipeline == 'true' }} + steps: + - if: env.RUN == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - if: env.RUN == 'true' + uses: ./.github/actions/postgres + - if: env.RUN == 'true' + uses: ./.github/actions/setup + - if: env.RUN == 'true' + run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends \ + bubblewrap socat unzip fonts-liberation libasound2t64 \ + libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libdbus-1-3 \ + libexpat1 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libx11-6 libxcb1 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - if: env.RUN == 'true' + run: bun --conditions=intx-src test ./test/e2e/pipeline.test.ts env: E2E_DB_HOST: 127.0.0.1 E2E_DB_PORT: 5432 diff --git a/AGENTS.md b/AGENTS.md index 2f6b7a53..46d0b42d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,8 @@ These are the ones no check catches: ([who owns what](https://github.com/corbitsdev/corbits-builder/blob/main/docs/IMPLEMENTATION.md#who-owns-what)). - Extend the existing module, package, API or component before adding one that overlaps it; Builder keeps one way to do each thing. Interface work - follows [apps/web/AGENTS.md](apps/web/AGENTS.md). + follows [apps/web/AGENTS.md](apps/web/AGENTS.md); end-to-end and integration + tests follow [test/AGENTS.md](test/AGENTS.md). - The interface talks to the hub through `@corbits-builder/client`. - A change to `vendor/interchange` is its own `(vendor)` commit that also updates `vendor/interchange.manifest.json`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 070ca56f..fc67575d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,8 +107,8 @@ covers the architecture, tests and other scripts. bun run vendor:verify # vendored Interchange matches its manifest ``` - `bun run format` fixes formatting. Changes to model logins also need - `bun run test:e2e` against a real Postgres + `bun run format` fixes formatting. Changes to model logins, workflows, tools, + approvals or sidecars also need `bun run test:e2e` against a real Postgres ([docs/DEV.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/DEV.md#tests)). 4. Commit with a [Conventional Commit](https://www.conventionalcommits.org) diff --git a/apps/hub/src/builds/posix-sandbox.ts b/apps/hub/src/builds/posix-sandbox.ts index e20bce39..3eb49620 100644 --- a/apps/hub/src/builds/posix-sandbox.ts +++ b/apps/hub/src/builds/posix-sandbox.ts @@ -70,9 +70,124 @@ const PageError = type({ detail: "string" }); // kqueue and needs neither. const MACOS_WATCH_POLLING = { WATCH_POLL_MS: "300" }; const SANDBOX_RUNTIME_VERSION = "0.0.81"; +// Where the sandbox allows Unix sockets: for the browser, and on Linux for the app's relays. +const needsSockets = (options: { browser?: boolean; app?: boolean }) => + options.browser === true || + (options.app === true && process.platform === "linux"); +const allowsLinuxUnixSockets = ( + sockets: string | undefined, + browsers: string | undefined, +) => + process.platform === "linux" && + (sockets !== undefined || browsers !== undefined); // srt's manager is process-global. A second job must use another sidecar // process; it cannot silently replace the policy protecting a running job. let leased = false; + +/** + * How the dev app is reached from outside its wrap. On macOS every wrap shares the host's loopback, + * so the app's own port serves. On Linux every wrap has a loopback of its own, so the app is reached + * through a Unix socket in the sockets folder: a relay inside the app's wrap serves the socket, a + * relay in this process serves the socket on a port of its own, and a relay inside every other wrap + * serves it on that same port, so one URL holds for the hub, the browser and the page watcher + * alike. The port is picked once and kept across restarts of the app, which the browser outlives. + */ +type AppRelay = { + /** Picks the relayed port, once. */ + reserve(): Promise; + /** The app's URL as every party reaches it, given the port its client listens on. */ + url(clientPort: number): string; + /** The prefix for the app's own wrap: serves its client port on the socket. */ + serve(clientPort: number): string; + /** A command for any other wrap, prefixed to serve the socket on the relayed port there. */ + into(command: string): string; + /** Serves the socket on the relayed port in this process, for the hub. */ + start(): Promise; + stop(): Promise; +}; + +const LINUX_RELAY_BINDS = [ + "--new-session", + "--die-with-parent", + "--unshare-user", + "--unshare-pid", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/bin", + "/bin", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", +] as const; + +function createAppRelay(sockets: string | undefined): AppRelay { + const socat = process.platform === "linux" ? Bun.which("socat") : null; + const bwrap = process.platform === "linux" ? Bun.which("bwrap") : null; + if (sockets === undefined || socat === null || bwrap === null) + return { + reserve: async () => undefined, + url: (clientPort) => `http://127.0.0.1:${String(clientPort)}`, + serve: () => "", + into: (command) => command, + start: async () => undefined, + stop: async () => undefined, + }; + const socket = join(sockets, "app.sock"); + const quiet = ">/dev/null 2>&1 & "; + let port: number | undefined; + let child: Bun.Subprocess | undefined; + const listen = () => + `TCP-LISTEN:${String(port)},bind=127.0.0.1,fork,reuseaddr`; + const stop = async () => { + if (!child) return; + const current = child; + child = undefined; + current.kill("SIGKILL"); + await current.exited; + }; + return { + reserve: async () => { + port ??= await freePort(); + }, + url: (clientPort) => `http://127.0.0.1:${String(port ?? clientPort)}`, + serve: (clientPort) => + `${quote(socat)} UNIX-LISTEN:${quote(socket)},fork,reuseaddr,unlink-early TCP:127.0.0.1:${String(clientPort)} ${quiet}`, + into: (command) => + port === undefined + ? command + : `${quote(socat)} ${listen()} UNIX-CONNECT:${quote(socket)} ${quiet}${command}`, + start: async () => { + await stop(); + if (port === undefined) return; + // Isolate the host relay's filesystem view to the sockets folder so a + // symlink planted there by the sandboxed app cannot reach host sockets. + child = Bun.spawn( + [ + bwrap, + ...LINUX_RELAY_BINDS, + "--bind", + sockets, + sockets, + "--", + socat, + listen(), + `UNIX-CONNECT:${socket}`, + ], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + }, + stop, + }; +} // What bun, sh, curl, unzip and Chrome's headless shell load to run; nothing here holds user data. const SYSTEM_READ_PATHS = process.platform === "darwin" @@ -152,6 +267,33 @@ async function freePort(): Promise { return port; } +async function isReachable(url: string): Promise { + try { + await fetch(url); + return true; + } catch (error) { + if (error instanceof Error) return false; + throw error; + } +} + +async function configureLinuxBridgeSockets( + policy: ReturnType, +) { + if (process.platform !== "linux") return; + await SandboxManager.waitForNetworkInitialization(); + const bridges = new Set( + [ + SandboxManager.getLinuxHttpSocketPath(), + SandboxManager.getLinuxSocksSocketPath(), + ].filter((path): path is string => path !== undefined), + ); + if (bridges.size) { + policy.filesystem.allowRead?.push(...bridges); + SandboxManager.updateConfig(policy); + } +} + export async function createPosixBuildExecutor( workspace: string, limits: BuildExecutionPolicy, @@ -166,6 +308,8 @@ export async function createPosixBuildExecutor( pageWatcher?: string; // Whether the agent drives its app in Playwright's browser, which browser() starts. browser?: boolean; + // Whether the workspace's dev app runs here, which app() starts. + app?: boolean; // The shared Chrome install, which the sandbox reads through $HOME/browsers but never writes. browsers?: string | undefined; } = {}, @@ -236,7 +380,12 @@ export async function createPosixBuildExecutor( `command_build_failed:${name} ${built.logs.join("; ")}`, ); const shim = join(shimDirectory, name); - await writeFile(shim, `#!/bin/sh\nexec ${quote(binary)} "$@"\n`); + // srt points TMPDIR at a folder it does not make inside the sandbox's own /tmp, and Bun's + // WebView cannot start Chrome without a temp folder. + await writeFile( + shim, + `#!/bin/sh\n[ -z "$TMPDIR" ] || mkdir -p -- "$TMPDIR"\nexec ${quote(binary)} "$@"\n`, + ); await chmod(shim, 0o755); } const pageWatcher = join(shimDirectory, "watch-page.ts"); @@ -264,8 +413,9 @@ export async function createPosixBuildExecutor( if (options.browsers) await symlink(options.browsers, join(home, "browsers")); // Playwright's MCP server always serves its browser on a Unix socket, which the sandbox allows - // only here. Under /tmp, as a socket's path fits in 104 bytes on macOS. - if (options.browser) + // only here. Under /tmp, as a socket's path fits in 104 bytes on macOS. On Linux the dev app is + // reached through a socket here too, since every wrap there has a loopback of its own. + if (needsSockets(options)) sockets = await realpath(await mkdtemp("/tmp/sb-browser-")); stage = "policy"; const policy = SandboxRuntimeConfigSchema.parse({ @@ -274,8 +424,8 @@ export async function createPosixBuildExecutor( // hidden, and only the system runtime and this job's own trees are re-opened. denyRead: [ "/", - "**/.env", - "**/.env.*", + join(cwd, "**/.env"), + join(cwd, "**/.env.*"), ...(process.platform === "darwin" ? executables : []), ], allowRead: [ @@ -294,6 +444,8 @@ export async function createPosixBuildExecutor( denyWrite: [ ...protectedPaths, shimDirectory, + "/tmp/claude", + "/private/tmp/claude", ...(options.readOnly ?? []).map((path) => join(cwd, path)), ], }, @@ -301,10 +453,6 @@ export async function createPosixBuildExecutor( allowedDomains: options.openNetwork ? [] : [...CODER_REGISTRY_HOSTS], deniedDomains: [], allowUnixSockets: sockets ? [sockets] : [], - // A Linux sandbox filters Unix sockets by syscall, not by path. - ...(sockets && process.platform === "linux" - ? { allowAllUnixSockets: true } - : {}), allowLocalBinding: true, // Resolved names in private and carrier-grade NAT space (a LAN, a tailnet) are refused too. deniedResolvedAddresses: [ @@ -327,12 +475,40 @@ export async function createPosixBuildExecutor( : undefined, ); initialized = true; + // On Linux srt relays the sandbox's proxy ports to the host over Unix sockets in the host's temp + // folder, which the default-deny read policy hides; srt binds them before that deny lands, so + // every connection through the proxy was reset. Named in allowRead, they are bound back over it. + await configureLinuxBridgeSockets(policy); + // SRT's Linux wrapper supports exec composition. The namespace owner + // must be a direct child in the workflow group, so parent death and the + // native spawner's group cleanup also reach an interrupted tool. + const linux = process.platform === "linux"; + // SRT's generateProxyEnvVars sets TMPDIR=/tmp/claude inside the sandbox, + // which does not exist on a clean Linux host; export the sandbox's own + // writable home as TMPDIR before execing the inner command. + const wrapShell = async ( + command: string, + customConfig?: Parameters[2], + signal?: AbortSignal, + ) => { + const wrappedCommand = await SandboxManager.wrapWithSandbox( + `export TMPDIR=${quote(home)}; ${command}`, + "/bin/sh", + customConfig, + signal, + ); + return linux ? `exec ${wrappedCommand}` : wrappedCommand; + }; stage = "wrap"; - const childCommand = `exec ${[executable, "--no-env-file", layout.childPath, cwd].map(quote).join(" ")}`; - const wrapped = await SandboxManager.wrapWithSandbox( - childCommand, - "/bin/sh", - ); + const childCommand = `exec ${[executable, "--no-env-file", "--conditions=intx-src", layout.childPath, cwd].map(quote).join(" ")}`; + const wrapped = await wrapShell(childCommand); + // Agent tool calls use `wrapped` above with Unix sockets blocked by seccomp; + // sidecar-owned helpers wrapped below (dev app relay, browser, check-app) + // need Unix sockets for socat, Playwright and Chrome IPC. + if (allowsLinuxUnixSockets(sockets, options.browsers)) { + policy.network.allowAllUnixSockets = true; + SandboxManager.updateConfig(policy); + } let trustedGate: { nonce: string; timeoutMs: number } | undefined; let devApp: | { @@ -357,6 +533,12 @@ export async function createPosixBuildExecutor( return cleaned; }; const kill = (child: Bun.Subprocess) => { + if (linux) { + // Killing the namespace owner terminates its complete PID namespace; + // signaling its inherited group would also kill the workflow host. + child.kill("SIGKILL"); + return; + } try { process.kill(-child.pid, "SIGKILL"); } catch (error) { @@ -370,6 +552,7 @@ export async function createPosixBuildExecutor( TMPDIR: home, NO_COLOR: "1", }; + const appRelay = createAppRelay(sockets); let browser: { process: Bun.Subprocess; url: string } | undefined; let browserStarting: Promise | undefined; // A turn's tool calls run in parallel; two app_status calls must not each start a server. @@ -388,50 +571,34 @@ export async function createPosixBuildExecutor( ): Promise => { if (disposed) throw new BuildExecutionError("sandbox_closed"); signal.throwIfAborted(); - // SRT's Linux wrapper supports exec composition. The namespace owner - // must be a direct child in the workflow group, so parent death and the - // native spawner's group cleanup also reach an interrupted tool. - const linux = process.platform === "linux"; const gateWrapped = trustedGate - ? await SandboxManager.wrapWithSandbox( + ? await wrapShell( childCommand, - "/bin/sh", { filesystem: { ...policy.filesystem, allowWrite: [], - denyWrite: [...protectedPaths, cwd], + denyWrite: [ + ...protectedPaths, + "/tmp/claude", + "/private/tmp/claude", + cwd, + ], }, }, signal, ) : wrapped; - const child = Bun.spawn( - ["/bin/sh", "-c", linux ? `exec ${gateWrapped}` : gateWrapped], - { - cwd, - env: childEnv, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - detached: !linux, - }, - ); + const child = Bun.spawn(["/bin/sh", "-c", gateWrapped], { + cwd, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + detached: !linux, + }); running = true; - const stop = () => { - if (linux) { - // Killing the namespace owner terminates its complete PID namespace; - // signaling its inherited group would also kill the workflow host. - child.kill("SIGKILL"); - return; - } - try { - process.kill(-child.pid, "SIGKILL"); - } catch (error) { - if (!isGroupGone(error)) throw error; - child.kill("SIGKILL"); - } - }; + const stop = () => kill(child); let timedOut = false; const timer = setTimeout(() => { timedOut = true; @@ -493,7 +660,7 @@ export async function createPosixBuildExecutor( devApp = undefined; kill(child); if (page) kill(page); - await Promise.all([child.exited, page?.exited]); + await Promise.all([child.exited, page?.exited, appRelay.stop()]); }; const startApp = (): Promise => { if (disposed) @@ -511,9 +678,9 @@ export async function createPosixBuildExecutor( freePort(), freePort(), ]); - const command = await SandboxManager.wrapWithSandbox( - `exec ${quote(executable)} run dev`, - "/bin/sh", + await appRelay.reserve(); + const command = await wrapShell( + `${appRelay.serve(clientPort)}exec ${quote(executable)} run dev`, ); const child = Bun.spawn(["/bin/sh", "-c", command], { cwd, @@ -526,7 +693,7 @@ export async function createPosixBuildExecutor( stdin: "ignore", stdout: "pipe", stderr: "pipe", - detached: process.platform !== "linux", + detached: !linux, }); const lines: string[] = []; let stopped: string | undefined; @@ -585,8 +752,10 @@ export async function createPosixBuildExecutor( } }; void Promise.all([collect(child.stdout), collect(child.stderr)]); - // Vite serves the client on CLIENT_PORT and proxies /api to the server on PORT. - const url = `http://127.0.0.1:${clientPort}`; + await appRelay.start(); + // Vite serves the client on CLIENT_PORT and proxies /api to the server on PORT; where the + // wraps have loopbacks of their own, the relays serve it on one port everywhere. + const url = appRelay.url(clientPort); const view = (): DevApp => ({ url, running: child.exitCode === null, @@ -599,9 +768,10 @@ export async function createPosixBuildExecutor( [ "/bin/sh", "-c", - await SandboxManager.wrapWithSandbox( - `exec ${[executable, "--no-env-file", pageWatcher, url].map(quote).join(" ")}`, - "/bin/sh", + await wrapShell( + appRelay.into( + `exec ${[executable, "--no-env-file", pageWatcher, url].map(quote).join(" ")}`, + ), ), ], { @@ -610,7 +780,7 @@ export async function createPosixBuildExecutor( stdin: "pipe", stdout: "pipe", stderr: "ignore", - detached: process.platform !== "linux", + detached: !linux, }, ); devApp.page = page; @@ -661,7 +831,7 @@ export async function createPosixBuildExecutor( ); if (browser && browser.process.exitCode === null) return Promise.resolve(browser.url); - browserStarting ??= (async () => { + const launchBrowser = async () => { const [chrome] = await Array.fromAsync( new Bun.Glob("chrome-headless-shell-*/chrome-headless-shell").scan({ cwd: join(home, "browsers"), @@ -693,10 +863,7 @@ export async function createPosixBuildExecutor( [ "/bin/sh", "-c", - await SandboxManager.wrapWithSandbox( - `exec ${argv.map(quote).join(" ")}`, - "/bin/sh", - ), + await wrapShell(appRelay.into(`exec ${argv.map(quote).join(" ")}`)), ], { cwd, @@ -704,18 +871,13 @@ export async function createPosixBuildExecutor( stdin: "ignore", stdout: "ignore", stderr: "pipe", - detached: process.platform !== "linux", + detached: !linux, }, ); const diagnostics = new Response(child.stderr).text(); browser = { process: child, url: `http://127.0.0.1:${port}/mcp` }; const deadline = Date.now() + 30_000; - while ( - !(await fetch(browser.url).then( - () => true, - () => false, - )) - ) { + while (!(await isReachable(browser.url))) { if (child.exitCode !== null || disposed || Date.now() > deadline) { kill(child); throw new BuildExecutionError( @@ -725,7 +887,15 @@ export async function createPosixBuildExecutor( await Bun.sleep(250); } return browser.url; - })().finally(() => (browserStarting = undefined)); + }; + const trackBrowser = async () => { + try { + return await launchBrowser(); + } finally { + browserStarting = undefined; + } + }; + browserStarting ??= trackBrowser(); return browserStarting; }; return { @@ -769,9 +939,10 @@ export async function createPosixBuildExecutor( [ "/bin/sh", "-c", - await SandboxManager.wrapWithSandbox( - `exec ${[join(shimDirectory!, name), ...args].map(quote).join(" ")}`, - "/bin/sh", + await wrapShell( + appRelay.into( + `exec ${[join(shimDirectory!, name), ...args].map(quote).join(" ")}`, + ), ), ], { @@ -779,8 +950,8 @@ export async function createPosixBuildExecutor( env: childEnv, stdin: "ignore", stdout: "pipe", - stderr: "ignore", - detached: process.platform !== "linux", + stderr: "pipe", + detached: !linux, }, ); commands.add(child); @@ -788,11 +959,19 @@ export async function createPosixBuildExecutor( const timer = setTimeout(stop, limits.commandTimeoutMs); signal.addEventListener("abort", stop, { once: true }); try { - const output = await new Response(child.stdout).text(); - return { - exitCode: await child.exited, - output: output.replaceAll(cwd + sep, ""), - }; + const [output, errors] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const exitCode = await child.exited; + // A command that died before printing its checklist would otherwise fail with nothing said. + const text = + exitCode === 0 + ? output + : [output, errors.slice(-2000)] + .filter((part) => part.trim()) + .join("\n"); + return { exitCode, output: text.replaceAll(cwd + sep, "") }; } finally { clearTimeout(timer); signal.removeEventListener("abort", stop); diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index fef7123c..d95599d9 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -220,13 +220,14 @@ export function createSidecarCodingTools(policy: { >; } | undefined; - const open = (signal: AbortSignal) => { - const role = policy.workspace === "app" ? "coder" : "designer"; - sandbox ??= resolveWorkflowProjectScope( - env.capabilities, - role, - signal, - ).then(async (scope) => { + async function startSandbox(signal: AbortSignal) { + try { + const role = policy.workspace === "app" ? "coder" : "designer"; + const scope = await resolveWorkflowProjectScope( + env.capabilities, + role, + signal, + ); const workspace = await realpath(env.toolCwd); const app = policy.workspace === "app" @@ -257,6 +258,7 @@ export function createSidecarCodingTools(policy: { openNetwork: scope.openNetwork, commands: { "check-app": CHECK_APP }, browser: policy.browser !== undefined, + app: policy.workspace === "app", browsers: await sharedBrowser(), ...(policy.workspace === "app" ? { @@ -296,13 +298,13 @@ export function createSidecarCodingTools(policy: { executor.command("check-app", [DESIGNS], signal), ); return executor; - }); - // A failed setup is retried by the next call rather than failing every later one. - sandbox.catch(() => { + } catch (error) { + // A failed setup is retried by the next call rather than failing every later one. sandbox = undefined; - }); - return sandbox; - }; + throw error; + } + } + const open = (signal: AbortSignal) => (sandbox ??= startSandbox(signal)); const lazySandbox: ToolRunner = { run: async (call, signal) => { const ready = open(signal); diff --git a/apps/hub/src/live-app/host.ts b/apps/hub/src/live-app/host.ts index 3626a00f..71510237 100644 --- a/apps/hub/src/live-app/host.ts +++ b/apps/hub/src/live-app/host.ts @@ -63,7 +63,7 @@ async function createExecutor() { }, layout, // The person's running app installs its packages and reaches the services it was built against. - { openNetwork: true }, + { openNetwork: true, app: true }, ); } catch (error) { report({ state: "failed", error: message(error), log: [] }); diff --git a/apps/hub/src/middleware/correlation.ts b/apps/hub/src/middleware/correlation.ts index 99c68155..717f31ed 100644 --- a/apps/hub/src/middleware/correlation.ts +++ b/apps/hub/src/middleware/correlation.ts @@ -1,12 +1,20 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createMiddleware } from "hono/factory"; -import { withContext } from "@logtape/logtape"; +import { configureSync, getConfig, withContext } from "@logtape/logtape"; import { getLogger } from "@intx/log"; export const requestContext = new AsyncLocalStorage>(); const log = getLogger(["corbits-builder", "hub"]); export function correlationMiddleware() { + const config = getConfig(); + if (config && !config.contextLocalStorage) { + configureSync({ + ...config, + reset: true, + contextLocalStorage: requestContext, + }); + } return createMiddleware(async (c, next) => { const supplied = c.req.header("x-correlation-id"); // Treat the ID as an opaque bounded token, never arbitrary log content. diff --git a/docs/DEV.md b/docs/DEV.md index 6cac78b7..44de92a5 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -61,7 +61,7 @@ bun run typecheck bun run test # unit and integration tests bun run pins:verify # pinned dependency versions bun run vendor:verify # vendored Interchange matches its manifest -bun run test:e2e # model logins end to end against an isolated Postgres +bun run test:e2e # model logins and project replay against an isolated Postgres ``` `bun run test:e2e` reads the `E2E_DB_*` values from `.env.hub`. Start its @@ -73,7 +73,8 @@ docker compose -p builder-e2e --env-file .env.hub --project-directory . -f deplo ``` Tests are load-bearing only: invariants and end-to-end behaviour through the -real API ([CONTRIBUTING.md](https://github.com/corbitsdev/corbits-builder/blob/main/CONTRIBUTING.md#rules-worth-knowing)). +real API ([CONTRIBUTING.md](https://github.com/corbitsdev/corbits-builder/blob/main/CONTRIBUTING.md#rules-worth-knowing), +[test/AGENTS.md](https://github.com/corbitsdev/corbits-builder/blob/main/test/AGENTS.md)). ## Other scripts diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 00000000..44300952 --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,49 @@ +# Testing Builder + +Unit tests sit next to the code they test. This folder holds cross-package and +end-to-end tests: + +- **`test/integration/`:** architecture and boundary checks (package imports, + vendored Interchange revision, workspace exports). Runs in `bun run test` + with no database. +- **`test/e2e/`:** end-to-end tests against an isolated Postgres, the real hub + and real sidecars (`custom-provider.test.ts`, `oauth-model-login.test.ts`, + `specialist-end-notices.test.ts` and `pipeline.test.ts`). +- **`test/e2e/support/`:** shared harness (`hub.ts`), the recorded + OpenAI-compatible model server (`recorded-model.ts` and its unit tests in + `recorded-model.test.ts`) and the fixture extractor (`extract.ts`). +- **`test/fixtures/pipeline/`:** recorded turns (`scenario.json` and + `runs//exchanges//`) replayed by `pipeline.test.ts`. + +## Recorded project replay + +`pipeline.test.ts` drives a whole project through the real hub, sidecars, +approvals, `check_design` and `check-app` by registering `recorded-model.ts` as +a custom OpenAI-compatible provider: + +- **Matching (`expect.json`):** each `/v1/chat/completions` turn is matched + structurally on `lastMessageRole`, `userTextContains`, `requiredTools` and + `toolResults` (`callId`, `toolName`, `isError`, `keys`, `contains`) rather + than full prompt text, so ordinary prompt wording edits do not break it. +- **Bindings (`{{bind:}}`):** a tool result in `expect.json` can capture + live values (`"bind": { "brief_id": "artifactId" }`) and substitute + `{{bind:brief_id}}` into later `response.sse` turns. +- **Updating the replay:** tool names, tool result keys, specialist task framing, + approval gates and starter checks are verified during replay. Update + declarative approval or artifact steps in `pipeline.test.ts`, or update + `extract.ts` and regenerate the fixtures from a throwaway local run: + +```sh +bun --conditions=intx-src test/e2e/support/extract.ts +``` + +## Running E2E tests + +With the `builder-e2e` database running ([docs/DEV.md](../docs/DEV.md#tests)): + +```sh +bun run test:e2e +bun --conditions=intx-src test test/e2e/support/recorded-model.test.ts +bun --env-file=.env.hub --conditions=intx-src test test/e2e/custom-provider.test.ts test/e2e/oauth-model-login.test.ts test/e2e/specialist-end-notices.test.ts +bun --env-file=.env.hub --conditions=intx-src test test/e2e/pipeline.test.ts +``` diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 0bb90ed6..00000000 --- a/test/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Tests - -Unit tests sit next to the code they test. This folder holds the tests that -cross modules: - -- `integration/` checks contracts and architecture across packages; `bun run test` runs it. -- `e2e/` runs the hub against a real Postgres; `bun run test:e2e` runs it. - -Later folders, added with their first file: `ux/` for interface tests, -`workflows/` for agent-loop tests, `fixtures/` for recorded model sessions, -`harness/` and `support/` for what those tests share. diff --git a/test/e2e/pipeline.test.ts b/test/e2e/pipeline.test.ts new file mode 100644 index 00000000..08246356 --- /dev/null +++ b/test/e2e/pipeline.test.ts @@ -0,0 +1,607 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { type } from "arktype"; +import type { NativeApproval, ProjectArtifact } from "@corbits-builder/client"; +import { startTestHub, type TestHubHandle } from "./support/hub.ts"; +import { + startRecordedModelServer, + type RecordedModelServer, +} from "./support/recorded-model.ts"; + +const FIXTURE_DIR = join(import.meta.dir, "..", "fixtures", "pipeline"); +const STAGE_DEADLINE_MS = 180_000; + +const RequestApprovalArgs = type({ + kinds: "string[]", +}); + +const NonEmptyString = type("string > 0"); + +type ArtifactMilestone = Readonly<{ + kind: string; + version: number; +}>; + +type ArtifactApprovalStep = Readonly<{ + stage: string; + kind: string; + version: number; + afterApproved?: ArtifactMilestone; + decision: "approve" | "reject"; + feedback?: string; +}>; + +type ToolApprovalStep = Readonly<{ + stage: string; + toolName: string; + afterApproved?: ArtifactMilestone; + afterFiled?: ArtifactMilestone; + decision?: "approve" | "reject"; + feedback?: string; +}>; + +type ExpectedArtifactState = Readonly<{ + kind: string; + version: number; + approvedVersion?: number; +}>; + +function createEventSignal(recordedModel: RecordedModelServer): { + notify: () => void; + waitForNext: (stage: string, signal: AbortSignal) => Promise; + dispose: () => void; +} { + const waiters = new Set<() => void>(); + function notify(): void { + for (const wake of waiters) { + wake(); + } + waiters.clear(); + } + const unsubscribeModel = recordedModel.onActivity(notify); + return { + notify, + waitForNext: (stage: string, signal: AbortSignal) => + new Promise((resolve, reject) => { + const failure = recordedModel.firstFailure(); + if (failure) { + reject(failure); + return; + } + if (signal.aborted) { + reject( + new Error( + `Timed out waiting for event in stage "${stage}": ${String(signal.reason)}`, + ), + ); + return; + } + const onWake = (): void => { + signal.removeEventListener("abort", onAbort); + waiters.delete(onWake); + const modelErr = recordedModel.firstFailure(); + if (modelErr) { + reject(modelErr); + return; + } + resolve(); + }; + const onAbort = (): void => { + waiters.delete(onWake); + const modelErr = recordedModel.firstFailure(); + if (modelErr) { + reject(modelErr); + return; + } + reject( + new Error( + `Stage "${stage}" exceeded deadline (${STAGE_DEADLINE_MS}ms)`, + ), + ); + }; + waiters.add(onWake); + signal.addEventListener("abort", onAbort, { once: true }); + }), + dispose: () => { + unsubscribeModel(); + waiters.clear(); + }, + }; +} + +function findPendingToolApproval( + approvals: readonly NativeApproval[], + toolName: string, + expectedArtifactKind?: string, +): NativeApproval | undefined { + return approvals.find((approval) => { + if (approval.status !== "pending") return false; + if (approval.toolDefinition["name"] !== toolName) return false; + if (!expectedArtifactKind) return true; + const parsed = RequestApprovalArgs(approval.toolArguments); + if (parsed instanceof type.errors) return false; + return parsed.kinds.includes(expectedArtifactKind); + }); +} + +function findArtifact( + artifacts: readonly ProjectArtifact[], + kind: string, + version?: number, +): ProjectArtifact | undefined { + return artifacts.find( + (item) => + item.kind === kind && (version === undefined || item.version === version), + ); +} + +function hasMilestone( + artifacts: readonly ProjectArtifact[], + milestone: ArtifactMilestone | undefined, + mode: "filed" | "approved", +): boolean { + if (!milestone) return true; + const found = findArtifact(artifacts, milestone.kind, milestone.version); + if (!found) return false; + return mode === "approved" + ? found.approvedVersion === milestone.version + : true; +} + +async function withPipelineScenario( + options: { + fixtureDir: string; + projectId: string; + title: string; + problemStatement: string; + }, + run: (driver: { + hub: TestHubHandle; + projectTenantId: string; + projectId: string; + coordinatorRunId: string; + runStage: (name: string, fn: () => Promise) => Promise; + waitFor: ( + stage: string, + description: string, + check: () => Promise, + ) => Promise; + waitForAssistantTurn: (stage: string, substring: string) => Promise; + waitForArtifactAndTurn: ( + stage: string, + milestone: ArtifactMilestone, + substring: string, + ) => Promise; + sendMessage: (lines: readonly string[]) => Promise; + decideArtifactApproval: (step: ArtifactApprovalStep) => Promise; + decideToolApproval: (step: ToolApprovalStep) => Promise; + chooseFiledDesignOption: ( + stage: string, + version: number, + presentationSubstring: string, + ) => Promise; + assertArtifactStates: ( + expected: readonly ExpectedArtifactState[], + ) => Promise; + assertSecretRedacted: ( + artifacts: readonly ProjectArtifact[], + secret: string, + ) => Promise; + }) => Promise, +): Promise { + const totalStart = performance.now(); + const stageTimingsMs: Record = {}; + const recordedModel = await startRecordedModelServer(options.fixtureDir); + let hub: TestHubHandle | undefined; + const events = createEventSignal(recordedModel); + + try { + hub = await startTestHub(recordedModel); + const { client, workspaceTenantId } = hub; + + const createStart = performance.now(); + const createReceipt = await client.mutateProject( + workspaceTenantId, + options.projectId, + { + mutationId: `mut-create-${options.projectId}`, + idempotencyKey: `idem-create-${options.projectId}`, + command: "create_project", + payload: { + title: options.title, + profile: "recorded_replay", + problemStatement: options.problemStatement, + }, + }, + ); + expect(createReceipt.ok).toBe(true); + expect(createReceipt.projectId).toBe(options.projectId); + + const projectView = await client.project( + workspaceTenantId, + options.projectId, + ); + const projectTenantId = projectView.tenantId; + const coordinatorRunId = NonEmptyString.assert( + projectView.coordinatorRunId, + ); + stageTimingsMs["create"] = Math.round(performance.now() - createStart); + + const unsubscribeStream = hub.subscribeRunStream( + projectTenantId, + coordinatorRunId, + () => { + events.notify(); + }, + ); + + async function waitFor( + stage: string, + description: string, + check: () => Promise, + ): Promise { + const signal = AbortSignal.timeout(STAGE_DEADLINE_MS); + while (true) { + const modelErr = recordedModel.firstFailure(); + if (modelErr) throw modelErr; + const found = await check(); + if (found !== undefined) return found; + try { + await events.waitForNext(`${stage} (${description})`, signal); + } catch (error) { + const latestErr = recordedModel.firstFailure(); + if (latestErr) throw latestErr; + throw error; + } + } + } + + try { + await run({ + hub, + projectTenantId, + projectId: options.projectId, + coordinatorRunId, + runStage: async (name, fn) => { + const start = performance.now(); + const result = await fn(); + stageTimingsMs[name] = + (stageTimingsMs[name] ? stageTimingsMs[name] : 0) + + Math.round(performance.now() - start); + return result; + }, + waitFor, + waitForAssistantTurn: async (stage, substring) => { + await waitFor( + stage, + `assistant turn containing ${JSON.stringify(substring)}`, + async () => { + const conv = await client.conversationEvents( + projectTenantId, + options.projectId, + ); + return conv.turns?.find( + (turn) => + turn.role === "assistant" && turn.text.includes(substring), + ); + }, + ); + }, + waitForArtifactAndTurn: async (stage, milestone, substring) => { + await waitFor( + stage, + `${milestone.kind} v${milestone.version} filed and assistant turn ${JSON.stringify(substring)}`, + async () => { + const [{ artifacts }, conv] = await Promise.all([ + client.projectArtifacts(projectTenantId, options.projectId), + client.conversationEvents(projectTenantId, options.projectId), + ]); + if (!findArtifact(artifacts, milestone.kind, milestone.version)) { + return undefined; + } + return conv.turns?.find( + (turn) => + turn.role === "assistant" && turn.text.includes(substring), + ); + }, + ); + }, + sendMessage: async (lines) => { + await client.sendMessage(projectTenantId, coordinatorRunId, { + content: lines.join("\n"), + }); + }, + decideArtifactApproval: async (step) => { + const approval = await waitFor( + step.stage, + `${step.kind} v${step.version} approval request`, + async () => { + const [{ artifacts }, { data: approvals }] = await Promise.all([ + client.projectArtifacts(projectTenantId, options.projectId), + client.nativeApprovals(projectTenantId), + ]); + if (!hasMilestone(artifacts, step.afterApproved, "approved")) { + return undefined; + } + if (!findArtifact(artifacts, step.kind, step.version)) { + return undefined; + } + return findPendingToolApproval( + approvals, + "request_approval", + step.kind, + ); + }, + ); + await client.decideNativeApproval( + projectTenantId, + approval.id, + step.decision, + "once", + step.feedback, + ); + }, + decideToolApproval: async (step) => { + const approval = await waitFor( + step.stage, + `${step.toolName} approval request`, + async () => { + const [{ artifacts }, { data: approvals }] = await Promise.all([ + client.projectArtifacts(projectTenantId, options.projectId), + client.nativeApprovals(projectTenantId), + ]); + if (!hasMilestone(artifacts, step.afterApproved, "approved")) { + return undefined; + } + if (!hasMilestone(artifacts, step.afterFiled, "filed")) { + return undefined; + } + return findPendingToolApproval(approvals, step.toolName); + }, + ); + await client.decideNativeApproval( + projectTenantId, + approval.id, + step.decision ? step.decision : "approve", + "once", + step.feedback, + ); + }, + chooseFiledDesignOption: async ( + stage, + version, + presentationSubstring, + ) => { + const designOption = await waitFor( + stage, + `design_option v${version} and coordinator presentation`, + async () => { + const [{ artifacts }, conv] = await Promise.all([ + client.projectArtifacts(projectTenantId, options.projectId), + client.conversationEvents(projectTenantId, options.projectId), + ]); + const option = findArtifact(artifacts, "design_option", version); + if (!option) return undefined; + const presented = conv.turns?.some( + (turn) => + turn.role === "assistant" && + turn.text.includes(presentationSubstring), + ); + return presented ? option : undefined; + }, + ); + const designReceipt = await client.chooseDesign( + projectTenantId, + options.projectId, + { + artifactId: designOption.artifactId, + version: designOption.version, + }, + ); + expect(designReceipt.design.kind).toBe("design"); + expect(designReceipt.design.version).toBe(version); + }, + assertArtifactStates: async (expected) => { + const { artifacts, failed } = await client.projectArtifacts( + projectTenantId, + options.projectId, + ); + expect(failed).toEqual([]); + for (const item of expected) { + const found = findArtifact(artifacts, item.kind); + expect(found?.version).toBe(item.version); + if (item.approvedVersion !== undefined) { + expect(found?.approvedVersion).toBe(item.approvedVersion); + } + } + return artifacts; + }, + assertSecretRedacted: async (artifacts, secret) => { + for (const item of artifacts) { + const full = await client.artifact( + projectTenantId, + options.projectId, + item.artifactId, + ); + expect(JSON.stringify(full)).not.toContain(secret); + } + const [finalConv, finalActivity, decided] = await Promise.all([ + client.conversationEvents(projectTenantId, options.projectId), + client.projectActivity(projectTenantId, options.projectId), + client.decidedApprovals(projectTenantId, options.projectId), + ]); + expect(JSON.stringify(finalConv)).not.toContain(secret); + expect(JSON.stringify(finalActivity)).not.toContain(secret); + expect(decided.approvals.length).toBeGreaterThanOrEqual(6); + }, + }); + + stageTimingsMs["total"] = Math.round(performance.now() - totalStart); + recordedModel.assertClean(); + console.log( + JSON.stringify({ + scenario: recordedModel.scenario, + stageTimingsMs, + }), + ); + } finally { + unsubscribeStream(); + } + } finally { + events.dispose(); + if (hub) await hub.close(); + await recordedModel.stop(); + } +} + +describe("Builder E2E pipeline replay", () => { + test( + "drives Animated Weather App from idea through documents, review, design and build to a shipped app", + async () => { + await withPipelineScenario( + { + fixtureDir: FIXTURE_DIR, + projectId: "animated-weather-app", + title: "Animated Weather App", + problemStatement: "C: Weather App", + }, + async (driver) => { + const { hub, projectTenantId, projectId } = driver; + const { client, workspaceTenantId, fakeCredentialSecret } = hub; + + await driver.runStage("documents", async () => { + await driver.waitForAssistantTurn( + "documents", + "weather app is a great one", + ); + await driver.sendMessage([ + "1. It's for people who want a weather app where the whole interface animates to match the live weather (rain, snow, sun, storm, wind, fog, day/night).", + "2. Current conditions, 24-hour forecast, 7-day forecast, multi-city search & saved locations, side-by-side city comparison, and custom weather scene animations.", + "3. Free public weather API (Open-Meteo, no API key required), responsive web app, smooth 60fps canvas/CSS animations.", + ]); + + const documentSteps: readonly ArtifactApprovalStep[] = [ + { + stage: "documents", + kind: "problem_brief", + version: 1, + decision: "approve", + }, + { + stage: "documents", + kind: "constraints", + version: 1, + afterApproved: { kind: "problem_brief", version: 1 }, + decision: "approve", + }, + { + stage: "documents", + kind: "approaches", + version: 1, + afterApproved: { kind: "constraints", version: 1 }, + decision: "reject", + feedback: "I think Approach B is the best", + }, + { + stage: "documents", + kind: "approaches", + version: 2, + decision: "approve", + }, + { + stage: "documents", + kind: "product_requirements", + version: 1, + afterApproved: { kind: "approaches", version: 2 }, + decision: "approve", + }, + ]; + + for (const step of documentSteps) { + await driver.decideArtifactApproval(step); + } + }); + + await driver.runStage("design", async () => { + await driver.decideToolApproval({ + stage: "design", + toolName: "design_app", + afterApproved: { kind: "product_requirements", version: 1 }, + }); + await driver.chooseFiledDesignOption( + "design", + 1, + "The design is filed", + ); + }); + + await driver.runStage("build", async () => { + await driver.decideArtifactApproval({ + stage: "build", + kind: "build_plan", + version: 1, + afterApproved: { kind: "design", version: 1 }, + decision: "approve", + }); + await driver.decideToolApproval({ + stage: "build", + toolName: "instruct_coder", + afterApproved: { kind: "build_plan", version: 1 }, + afterFiled: { kind: "app", version: 1 }, + }); + await driver.waitForArtifactAndTurn( + "build", + { kind: "app", version: 2 }, + "filed as version 2", + ); + + const appVersions = await client.appVersions( + projectTenantId, + projectId, + ); + expect(appVersions.versions.map((v) => v.version)).toEqual([2, 1]); + expect(appVersions.versions[0]?.source).toBe("check"); + expect(appVersions.versions[1]?.source).toBe("check"); + + const downloaded = await client.downloadApp( + projectTenantId, + projectId, + ); + expect(downloaded.filename.toLowerCase()).toContain( + "animated-weather-app", + ); + expect(downloaded.archive.size).toBeGreaterThan(10_000); + + const shipReceipt = await client.shipProject( + projectTenantId, + projectId, + true, + ); + expect(shipReceipt.ok).toBe(true); + const shippedAt = NonEmptyString.assert(shipReceipt.shippedAt); + + const { projects } = await client.projects(workspaceTenantId); + const summary = projects.find((p) => p.projectId === projectId); + expect(summary?.stage).toBe("shipped"); + expect(summary?.shippedAt).toBe(shippedAt); + + const finalArtifacts = await driver.assertArtifactStates([ + { kind: "problem_brief", version: 1, approvedVersion: 1 }, + { kind: "constraints", version: 1, approvedVersion: 1 }, + { kind: "approaches", version: 2, approvedVersion: 2 }, + { kind: "product_requirements", version: 1, approvedVersion: 1 }, + { kind: "design", version: 1, approvedVersion: 1 }, + { kind: "build_plan", version: 1, approvedVersion: 1 }, + { kind: "app", version: 2 }, + ]); + await driver.assertSecretRedacted( + finalArtifacts, + fakeCredentialSecret, + ); + }); + }, + ); + }, + STAGE_DEADLINE_MS * 3, + ); +}); diff --git a/test/e2e/support/extract.ts b/test/e2e/support/extract.ts new file mode 100644 index 00000000..22bc4b00 --- /dev/null +++ b/test/e2e/support/extract.ts @@ -0,0 +1,1671 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { type } from "arktype"; +import { reconstructDurableConversation } from "@intx/workflow-host"; +import type { ConversationTurn } from "@intx/types/runtime"; +import { + formatOpenAISseResponse, + type ExchangeExpectation, + type ScenarioManifest, +} from "./recorded-model.ts"; + +const ToolCallArgs = type("Record"); +const StoredArtifactWrite = type({ + kind: "string > 0", + title: "string > 0", + content: "string > 0", +}); +const StoredArtifactRecord = type("string.json.parse").to({ + artifactId: "string > 0", + kind: "string > 0", + version: "number.integer > 0", + sha256: "string > 0", +}); +const StoredFileWrite = type({ + path: "string > 0", + content: "string", +}); + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", ".."); +const DEFAULT_FIXTURE_DIR = join(REPO_ROOT, "test", "fixtures", "pipeline"); +const DEFAULT_RUNS_ROOT = + process.env.BUILDER_STORED_RUNS_DIR ?? + join(REPO_ROOT, "data", "hub", "workflow-runs"); +const DEFAULT_LIVE_APP_WORKSPACE = + process.env.BUILDER_STORED_APP_WORKSPACE ?? + join(REPO_ROOT, "data", "hub", "live-apps", "sb-4k5vv91vz", "workspace"); + +const MAX_SCENARIO_BYTES = 2 * 1024 * 1024; + +const ZESTY_RUNS = { + coordinatorDocs: + "run_29495a64502566c30213588dc8b3fc08-zesty-acorn-badger-localhost", + coordinatorDesign: + "run_98b0933fb1dd1a2f28997bb3af54fb61-zesty-acorn-badger-localhost", + coordinatorBuild: + "run_4ede76a05ff267aa7a24166b496eac92-zesty-acorn-badger-localhost", + writerConstraints: + "run_2c1986a2c8dee7a4d192add5f8c5e7b9-zesty-acorn-badger-localhost", + writerApproachesV1: + "run_3433413723e3f2cad30adc8afe6d89ea-zesty-acorn-badger-localhost", + writerApproachesV2: + "run_0fbe70fbdcb7174d73f9815d9f5f7e0f-zesty-acorn-badger-localhost", + writerRequirements: + "run_f76d5bfdb487b00e753d417c942b721d-zesty-acorn-badger-localhost", + writerBuildPlan: + "run_e2cac090f6c422535d77e00a38387d62-zesty-acorn-badger-localhost", + designer: "run_58cf9e8d726377fd10cf3b6d4c751bbc-zesty-acorn-badger-localhost", + coder: "run_970c695116933ed8147c26bcad9a0a65-zesty-acorn-badger-localhost", +} as const; + +type ToolCallSpec = { + id: string; + name: string; + arguments: Record; +}; + +type ExchangeSpec = { + text?: string; + toolCalls?: readonly ToolCallSpec[]; + expect: ExchangeExpectation; +}; + +type RunSpec = { + runKey: string; + role: string; + stage: string; + sourceRunId: string; + exchanges: readonly ExchangeSpec[]; +}; + +async function loadStoredTurns( + runsRoot: string, + runDir: string, + step: string, +): Promise { + if (!runDir.endsWith("-zesty-acorn-badger-localhost")) { + throw new Error( + `Refusing to read non-weather-app run directory: ${runDir}`, + ); + } + const dest = await mkdtemp(join(tmpdir(), "builder-extract-")); + try { + const proc = Bun.spawn( + [ + "sh", + "-c", + `git -C "${join(runsRoot, runDir)}" archive --format=tar refs/heads/main agent-state | tar -x -C "${dest}"`, + ], + { stdout: "ignore", stderr: "pipe" }, + ); + if ((await proc.exited) !== 0) { + throw new Error( + `Failed to unpack ${runDir}: ${await new Response(proc.stderr).text()}`, + ); + } + const conv = await reconstructDurableConversation( + join(dest, "agent-state", step), + step, + ); + if (!conv) { + throw new Error(`No durable conversation found in ${runDir} (${step})`); + } + return conv.turns; + } finally { + await rm(dest, { recursive: true, force: true }); + } +} + +function extractTurnText(turn: ConversationTurn | undefined): string { + if (!turn) return ""; + return turn.content + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join("\n") + .trim(); +} + +function extractToolCalls(turn: ConversationTurn | undefined): ToolCallSpec[] { + if (!turn) return []; + const calls: ToolCallSpec[] = []; + for (const block of turn.content) { + if (block.type !== "tool_call") continue; + calls.push({ + id: block.id, + // Recorded runs predate the deploy_worker -> deploy_writer rename. + name: block.name === "deploy_worker" ? "deploy_writer" : block.name, + arguments: ToolCallArgs.assert(block.arguments), + }); + } + return calls; +} + +function extractToolResultText( + turns: readonly ConversationTurn[], + idx: number, +): string { + const b = turns[idx]?.content[0]; + if (b?.type === "tool_result") { + const first = b.content[0]; + const raw = first?.type === "text" ? first.text : ""; + const truncIdx = raw.indexOf("\n[Tool output truncated:"); + return truncIdx >= 0 ? raw.slice(0, truncIdx) : raw; + } + return ""; +} + +async function reconstructDesignFiles( + runsRoot: string, +): Promise<{ stylesCss: string; indexHtml: string; designMd: string }> { + const coderTurns = await loadStoredTurns(runsRoot, ZESTY_RUNS.coder, "build"); + const designTurns = await loadStoredTurns( + runsRoot, + ZESTY_RUNS.designer, + "design", + ); + const starterCss = await readFile( + join( + REPO_ROOT, + "workflows", + "designer", + "starter", + ".builder", + "designs", + "styles.css", + ), + "utf8", + ); + const starterHtml = await readFile( + join( + REPO_ROOT, + "workflows", + "designer", + "starter", + ".builder", + "designs", + "index.html", + ), + "utf8", + ); + + const c20 = extractToolResultText(coderTurns, 20); + const c20BeforeTabs = c20.slice( + 0, + c20.indexOf("transition: transform var(--du"), + ); + const starterSwitchTail = starterCss.slice( + starterCss.indexOf("transition: transform var(--du"), + ); + const c24 = extractToolResultText(coderTurns, 24); + const c26 = extractToolResultText(coderTurns, 26); + const c28 = extractToolResultText(coderTurns, 28).split("\n1:221:")[0]!; + const c24Clean = c24.slice( + 0, + c24.lastIndexOf( + " animation: twinkle 4s var(--ease-in-out) infinite alternate;", + ), + ); + const c26Clean = c26.slice( + 0, + c26.lastIndexOf(".city-cond { grid-column: 3; color: var(--muted-foregr"), + ); + const weatherLayer = `${c24Clean}${c26Clean}${c28}`.replaceAll( + /^\s*(?:-webkit-)?backdrop-filter:\s*blur\(18px\)\s*saturate\(1\.3\);\n/gm, + "", + ); + const fullStylesCss = `${c20BeforeTabs}${starterSwitchTail}\n${weatherLayer.trimEnd()}\n`; + + const workDir = await mkdtemp(join(tmpdir(), "builder-design-gen-")); + try { + await mkdir(join(workDir, ".builder", "designs"), { recursive: true }); + await mkdir(join(workDir, ".scratch"), { recursive: true }); + await writeFile( + join(workDir, ".builder", "designs", "styles.css"), + fullStylesCss, + ); + + const c30 = extractToolResultText(coderTurns, 30); + const svgStart = c30.indexOf("= 0 ? `${rawSprite.slice(0, extraIdx)}` : rawSprite; + await writeFile(join(workDir, ".scratch", "sprite.svg"), spriteSvg); + + const scriptHtml = starterHtml.slice(starterHtml.indexOf(" \\n \\n \\n
\\n \\n \\n\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":5,"id":"coder_write_4","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":5,"function":{"arguments":"{\"path\":\"client/public/icon-192.png\",\"content\":\"�PNG\\r\\n\\u001a\\n\\u0000\\u0000\\u0000\\rIHDR\\u0000\\u0000\\u0000�\\u0000\\u0000\\u0000�\\b\\u0006\\u0000\\u0000\\u0000R�l\\u0007\\u0000\\u0000\\u0010\\u0000IDATx\\u0001�}\\t�]U����ګ��\\t$!\\u000bIX��\\b\\b&�\\u0000\\u0001B�E\\u0012\\u0012vDA\\u001a�\\u0005H\\u0002h\\u001b�\\u0015��M���\\u001b[����\\u0019�\\u0019����:��:.���\\b\\b��oa��������Ϲ�-7U���{U�:�ν����޳|��9��WU\\u0011� �~�\\u001d�:7l��sæ\\u001b�������7}a������\\r�~�|��m�\\u001b6�\\u0012Ɲ\\u001b6�\\u000e�o��pK�-ثJ�]�\\u001b6n[�~���7����\\u000b�ϿXv�\\u001bV\\\\��\\u0002��*p=��\\u0000X~���7l��sæG\\u001aس\\u0015�7���\\u0011�Ex#\\u0019]D��$� ��!��a\\u0018��0�.=���\\\"=�P��0�ӡb\\u0017D�;�\\u0017�э��OF\\u0011����[�Q�V����7}�s�ƓtΈ�a\\u001b\\u0000k׮mX����\\u001b6}n�e��b�{\\u001f�;�K��\\u0001\\b\\u000b�չa�g�]�iٖ-[��/+^P�5\\u001f���}����/Lj~J�Cq\\u001c�%\\b\\u000b�\\\"-\\u0015J�\\u0007�\\u0011\\u0014rH\\u0011Ef�SN\\u0019\\u0015��,RFEf�SN\\u0019\\u0015��,RFEf�SN\\u0019\\u0015��,RFEf�SN\\u0019\\u0015��,RFEf�SN\\u0019\\u0015��,RFEf�SN\\u0019\\u0015��,RFEf�SN\\u0019\\u0015��,RFEf���J�p\\u0003�=����\\r�?u�e�'*��1�T�H��\\r�6���I��U�$!�\\u00180���h1W�����E\\u001d�\\u0012��&��Er8�\\u0004ӥ\\u001e�\\u00058���j\\u0002\\u001e�l�&�%T(�}\\u0000tvni�\\u001d�����m1�\\u0019�\\u0013`��}��t0�<��:��:TiLT��imjަe����-�9�s\\u0000p�\\u0015w��f�{8��/��4�T�Ua2�d��\\u0005\\u001dJ�!���\\u0014������6�ձ\\u0017R\\u0007C�e\\u0019\\u0000�^�e�����\\u000e����^^�\\u0007[��1\\u000e�\\u0006Ё,�?0^$�������P�߮\\u001f�\\u0000Xq���]�O��!\\u000b\\u001b\\t�5��� �A��\\u001f�f?�ݷ⊏-�\\u0010Ð\\u0006�\\u0019�߱&B�}z�]��\\u0006x��\\r�\\u0002�F\\u0007.]�\\u001c \\u0007��\\u000f\\\\\\u001c��>�A�li�R\\u0007\\u0000U�'A|Gw�q�:U�S\\t\\u0002�4\\u0000�c\\u0007$\\r�\\u0012\\f|d��\\u001b�1NB~��+�ԇ�r/���A\\u000f\\u0000[{�q�]�aĿ h�����R �@�\\u0000�\\fX��\\u001e��Y�|�L�����\\u0007\\u0015\\u00075\\u0000l�e�}5q���\\u001a��L�5*�%pH\\bX\\b�\\f�?�k\\u000eD\\u0007\\u0006�\\\\0�\\u0001`\\u001fO7��o4���\\u0011���u�\\u0016u���s��\\u0007�\\u001f\\u0013d8� ��1������=�m�\\u0003��OtݭƜm�\\n\\u00051�6�s\\u0000�\\u0001\\u0014<\\u000f�b=L\\u0018m�A\\u001fs�\\n�G�s�D�ݒy@q@\\u0003ଫ�|\\u001f�MP��]�� \\u0001 I�s���p!σ�\\u0004��a\\f��\\u000f\\u0014�z�(����7���Ku�A1��\\u0003ଫ�8��\\u0017�B�&w��\\b\\u0002(�P(����\\u000f��\\u0017(�y^i����_ν��'��C�C\\u000e�U��5�h�&�&Ҫ��\\u0004ջ�@\\u0016���\\u0007�ׁ�\\u001b�>}�B����C����͇�O���\\u001d\\u0000���H\\u001bc�=?�*3w=A�L0�\\u0014 ��\\u001ff���^+_V5���\\u000e��\\u001dc7i�:�T��\\u0018�P\\t�0F��\\u0019\\u001e�t��\\u0015�!\\u000b�\\u0018>��c��fG�{~E\\u001f��\\u0001p�������0�J�\\u001f�+�p�L�P)�Н/�H�\\\\X�E\\u0014I���E\\\\D�Tz�{݋�\\u0010Q$�>\\u0018�����k����8�9\\u0000\\u001a�Z>Bp��JҠ�\\u0013��Sqpzz>| ��d\\u0006�y\\u0018�\\u000f��d��\\u0011md³(�E���j��V�Э=���\\u0001���-3@|\\u0018\\n�.M1;���\\u0014�\\u001dd�^�.)\\u0006}FT�\\u0018�.�c\\u00168h\\u0000��ݬn\\u001cOj�v^�Cw^Ч����\\t��^wcWw�b�\\u0000��\\fܠָ\\u0013H?\\b\\u0002�:�\\u001e�\\u001e^\\u0007�c��\\u0011�\\u001f:��;g:�NvQ�\\u000e�Ҋ��\\u0006��f��\\u001d�:�\\f<�\\u0013ת?Lh8мIn���\\u0001�v�f�ך�4'�\\u001a\\t\\u0017��\\u0013^���\\u0007���\\u00007\\u0000ο~�\\u0012�o撉�\\u001b�i-p\\u0013��\\u000eA\\u000f��A���\\u001a�C/z\\u0016{�\\u0007�\\u0000�{����\\u001a�62y\\u001b$Z�x%��>�'\\u001dIz���S�G\\u0016�>de�!���|\\u001e\\n�6��PHΕ�(���\\\"�\\t\\rv/�t�\\u0007\\u0007�\\\"�\\u0014�`�\\f����\\u0003GP��磋���L�|\\u0007��L�PHdQ�\\u0005=$�t�=�5�\\u0007��w�wˬ����mQ\\u0004�&\\u0006)\\u0000\\u0012\\f�\\fz@�4\\u001d��\\u001f����8�g�I#S\\u0014u� �Y\\fv\\u0004}4(�~�r(��\\u0007��ա_o<#��\\u0019\\u0004�N =\\u0002\\u001e�\\u00043<�\\u0013]�>��\\u001a���ьH��f�\\u0015(H\\u001a�=B�u�ՎX́0�\\u0007�t\\bz�!�C�\\u000f15\\u0003�� 5MI\\u001c�N�\\u0013+N������\\u0001\\u00052��\\u000e�����\\u0007;��\\u0002Y%��34\\u0001@3\\u0000TI�@jH�(\\u0001;҆`��H\\u0007\\u0010�A\\u0012�� �9P��b>�v�\\f\\u0000�\\b2��a\\f�\\u0010\\t\\u0006N\\u0006=�@�\\u000e��\\u000f�\\u0019�vP �qH�> Y�z,�3\\u0019��8\\u0017�/?\\nW�9\\u0011\\u001f��4|�\\u0003g`�u����w`����̓\\u0017��G\\u001f�������3\\\"\\u0000-dmw6\\u0019���Ԁ��9\\u001c7^z\\n����z\\u001en�z\\u0019��x).>�X�x�\\u0002��d\\u0016N9~\\u000e�=}1.[}\\u0002n��N|����ʧ��S7�»;�ƌ)cG�`\\u0018�g��<�\\t�l�O�4\\u0006\\u001f�����})�z�\\n�:�HL\\u001c�fM\\u001a�W�8f�t\\\\{���\\u001f�\\u0010��|>�8y����o��\\u0019\\u0000��\\u0000��>p�+��ե�؎\\u0016\\\\w�R|��wc�I�`3\\u0000�\\u0014\\u000e�>\\u001e7o8\\u0015�ݸFK��P������f~\\u000f����\\u0007�5\\u0003�\\u0005�.�\\u0007\\u001c�-)|fz��\\u0001�e1\\u001e\\u0003�\\u0007�t�\\u000b�����ڈK�9\\u001e_���8o�b466��+��;s��U>}�*,�7UEĮ���H��\\u000e���\\u0000�\\u001897�F\\u0001�B�t�\\u0000I$�B�`O�p�a�g���+���\\u0001���\\t�\\u0015�̛��o9\\u001bkW\\u001d'��\\b&�O�8\\u0006^�z,8|2>��C��\\u0011ح��sӥ��4/��\\u0001Q]�3��D�σ�oL\\u001f\\u0018�\\u0018q}��Y�˛���1�\\u0018�R�N�x_�{� \\u0013�\\u0017Z�ȃ����'\\\"�+�޶�\\b��I�e\\bv)@z=�b,�>������hinT��\\u0011m@�~�2�T���'+��\\f���}YjX:���V\\u0007?�S\\u001e��zTF�)\\u0013ڱ��ehlp��ů�����.�g\\tV�Z���f\\u0000�FA�\\u0006�o�qdx��}��4t}�[�`w�jX���p�ʣq�\\ts��,�c��'\\u000b�C��O��leS�Ŵ\\u0012���\\u0007;i���Ł�c\\u001fL}���1ﰉ�����ON^�\\\"q�l�S^�v?\\u0003�V9�@�\\u0013\\u001d\\u0017*�\\u0014:.�H�\\\\X�E\\u0014I���E\\\\D�TzE��\\\\�������|��x�ZS����\\u00117�?\\u00156h��dm�\\u0019\\u0000��\\u0015B�\\u0007@&�\\u0004\\u0003�\\u0005\\u000e�\\u001eM�\\u0011.Yu�\\u0015W3���c�\\u0017�C\\u001f\\u0013���z#�!p?\\u0003(;*;\\u0001H\\u001a���\\u0003�\\u0006b\\t\\u0006^Q=V��\\b�&u�2ji���ca��L�D\\u001ec�'��G��ORG���[���:$�K�%\\u0015r�H�]�I\\u001cE�@\\u000f\\u001d��\\rB\\u001f{�p�Qʩ��qm8o�\\u0012�:̚\\r5��n\\u0010�'\\u000b��!�]���\\f��\\fR�i4��+N�<��\\u0007 S=*��}������հ��s\\t�ۚ�E\\u0000�c�c�\\r�~����O�3��i�U�ݨ\\u0010�q5�a�{=���\\u000b��T�~K�9\\f%�*�Ȝ���\\u0015ӧ\\u0012�����#�(WWYyn'�y\\u001e�(�>\\u0013���3s\\u0002j=,=�p�u�]\\u001f�2�w�w���\\u001d8�:�Rz�t�a�rP���&A�ﬕ��$#��p���\\u0000�\\u0015���\\ra2�T�����V^�o�4�-�=97�I�Q\\u0015��\\u0006���\\u0003UV=@�\\\\D��nc@�TN��s&I����\\u001c ��^�=��?\\u0011�Nu��#T7��\\b����u��>���homF��)\\u0013�\\u0013�ూ�CR/b_N\\t���\\u0001ҋ��\\r�,\\u0014�rzL�8\\u0006C\\u000b���\\u0002Dm�ځ\\u0006��0N8\\u0016�ģV�6�*��(\\b\\u0013ƨ\\u001e�$\\u000b�\\u0013�Is��:��\\u0001\\u0000���@�#�\\u0007H����^\\t�/�\\u001e���A1�`�ި7FMӀ���֬�)@�d�q�����\\u001c�@�t�e���@�Lٴ����A\\u0014<�S'�=���J�T~��<�Ԁ&G~�\\u000f�\\u001e\\u0013��;�+�����\\u001b�����\\rvw�]\\u001f�\\u000b����t6\\u0002\\r\\u001dp��\\u0006�\\u001b\\u0010\\u001a4VF�W\\f*u��@v\\u0001I�aד\\u001cx��\\u0019`\\u0010\\u0017Y�ȁ\\u0017\\u0012��!� u\\u0018�\\u0015l9c\\u000ejwqs�R\\u001c��|\\u000b�܀в�ʰ�l�4�r�3���x���������$\\u0004�eI:��z�صϕ�ߩ\\u0003�\\t��d��9h�X�#+�Q�'�\\u0019��|�l�\\u0019\\\\���t��7�lYi����[�\\u0000�F��\\u0014�@\\u0016�Hڭ\\u001e�Q�\\u001b�\\u0003����|�,��\\\\]8�(}�.��Ѥ�\\u0007\\u001b\\u0018\\u0003��\\u00064��soe\\u0000�x\\u0016G���\\f@Z�b�\\u000e.�)7DR�`��\\u0003�\\u001d�\\u001e�C�gǮ��D�nK\\u0010��\\u000ea��J�Ho��g��]\\u001a@����Gg�O\\u001f�I~\\u0002E2�(�\\u000f�@Z����\\f�kܹ��a�qyQ�A\\u000f�M�\\u0013�,�{3S��P�`\\u0005��Öcn6h8d�;��7l��}]�+��\\\"����U�H}\\u0002�2Qf�;]*�OkK3n��\\\"��|_j*\\u0000\\u0000\\u0010\\u0000IDAT�����\\u001a�6\\u001b4i68ij����\\u0014���I3�wv���\\fAఠ��P\\u0019=&�\\u001f�;>�\\u001e\\u001cy��ɫ�X\\u000f��ڈ�ʹQ�-�3B\\u001f5����#�\\u0002Y\\u0019�ʕ�Z\\u0002�Jf���e\\u0000\\u0010�։e�g��)��Ƶ�5M���COo�?\\u0018Ⱦ\\u001a�1M�y�>�(����\\u0007�ȓ��\\u0013`x�\\u0001<�Q?�|���\\u0000�����T�\\u0001+�DZG����/��1�(\\f=�4\\u0003\\u0014V�Q�6۳A����^BOOo��#�럚\\u0001R�G�ҁ��b\\u000f]��sg��+�CKS#�!�Æm�����a���Ue�}�ď�`u�W2\\u0003Xe�\\u0002i�\\u000eU\\u001e.���\\u0010�\\fҥ4=&M\\u0018��.?\\u0017�����k��Y�\\u001a�\\u000f�z[��?n7&�\\u001c\\bK�k�z\\u000f�z�\\u0000(�dZـP ˣ������k��v�OU��{�v�Ur�ƆI�=sjM�S�\\f�Ζ��P�����;�\\u0004=�(�����\\u0003�D��V1F���ouV5�m�f�17]�\\u001a�Ƕ{�JЋ�ߩ�����I����f\\u0000� SXHx�\\u0011�\\u000e\\u000bdF�\\u0001�f�I8z�l�|@[wO\\fs�\\u0001�\\\\E'u��\\u001bclG��z�Ѭ�\\u001c\\u000e@\\u001f�\\u0004�+�\\u0011��\\r��*�\\n�A�C\\u000b\\u0000ԏ�a�4\\u0000�c\\u0007d�ǀ� R=����g��gL�j�!�.\\u0019p��Y�\\u0006���I�8[�y׬\\\\j2\\u001dR���o8�n\\u0006 թ�=\\u0003k\\u0004�jo\\u0014��\\u0006�@z\\u000eA!��B>���׼��\\u0018�f�dߦ\\u001c�#t��J^y�q�4q�K'�(U�_Q�)Z]}��\\u001eiz��\\u000e\\u001fԧ� ��>'\\u001e3\\u001f�\\u000e��N)e�u��&�B\\u0007TϾ\\u0006��>��sމ�oRL�Iy\\u0016��\\u001eA���H&�\\u001c��A��S���gz���b�466��U�\\u0002����1l\\u0010h\\u0014�Z���{�'�}��mK���#f��v���+\\\"�3��j�S�Γ���\\t~�\\u0007|�KA}6�.{5��\\u000bv�w�mߢ}��;\\u001d$�f� iP5��\\u0019�jGVW%��ϔ��`�\\f,=�H�u�۰����+��d2�e3Gs3AYr+O&v�\\u001fh�fN�\\u0004����LV_?ff�832\\u0003'�f���c\\u0017��g��\\u001f\\u001f>y�\\u0006|��\\u000bp�E+qᙧ��\\u001d�¦���&��m6\\b�wuk5\\u0014�-�R3�eρ\\u0003��n\\u0010\\u0019�r��į�\\u0004����\\bz��8��\\u0010>�fQ%=M*\\rX*\\u0014H;�o�>��1z��E+p�mW������\\u0016�ECt�_\\n�r���:i��^�ќ�\\r\\u0006ݹ���jw�n=/�h��u��{uK7����f���\\u001b&CK+a�\\fX���ME����Ց��\\u00050w'�\\u001c\\nd�כ�ȹ3q�^c�N\\n��>boo�\\u001e�Ż�٠8�u|�\\u0006E�и���n�U��\\u000bմ\\u0019���'��H���k�8���K��;��(R�C��\\u0002��`���绞gR\\nI�\\u0001G\\u0001���Ο_v��޺kt�U\\\\���fL�<^�EW\\u0016��R,�\\u001c~�<\\u0003\\u0000dR�(@��<\\u001f��<��� ��U`�)�a��\\tU�o~\\u0006H�\\u001e�AP��>�\\u001d���$��\\u0006���SOX�;nx\\u000f>v�Z\\\\|�)�?{:����0�i�)f\\u000bO�S�q�)o[\\u0004{\\u0018\\u001b�.X\\u001d-�/\\u0012.[z\\fn�r\\rn��\\u0002�t�\\u0002D���_�\\f�EqeǀC�o�a{{[3η_�C\\bդ�}�~�\\u0005���͗�\\u0013���w����2�_�2u�S{\\u0014\\u0014>Ҽ���/�wT�W\\u001b\\u0010�S������N\\\\wəhk��g���d\\u00060g����H���왓q�\\t��o&\\u0010\\u0010.T�\\u000e�,����w\\u0001ү��V����f\\u0000�\\u001c �\\u000f�\\u000eF(�\\u0007��I����'��7������J��7u�\\\\��=$[E�����\\u0001���B��Z�/�7\\u000b3\\u000b�\\u0012��1lկ@cS#޿n\\u0015fM��*K��J\\u0016�P�3r\\u0017�p�g�9\\u0004�<��ڲ�/d ��T���\\t7n8\\u001b�\\u0001�k��i��?�\\u0019>p{\\u0004\\u00052�^tlQ\\u0014�\\u000e�!�2/�)�q��55\\u0014CU�\\u0014hokq3As��-�r�g�3�/��nOf�6���y�{��a_�\\nL�0\\u0006��Dw3�6����\\b\\u0005�\\u001fR��x�\\u000b�qdxm�ӿH�\\u0010j^�w-=\\u001a3��_�(���g\\u0000��%\\u0011�`��\\u0013��S�B�\\u0007\\u0005�(¥�OsMa��3�\\f`��4\\u0000Y\\u001f8a\\\\\\u0007&��pm\\n��P��S`o��5���4?\\u0003�!�rU�\\\\���h���VC\\n\\f��K�[X��t2\\u0003�b�tD�\\u0007��Uo-�\\u0002\\u0002…Z������\\u001f(��j\\u0006H^i�����i\\u0004�d�9YG�@Z���(���c;�}}ݾ����+\\u0010�8d\\u0018\\t}�����K&\\rK�O�\\u0000�XגB�R�7� ��>�\\\"�V�c�[բ\\u0010�Q�����\\\\#�C��(�����g�zt��ަ�z�סO���~\\u0006���FR!\\\"�+nϔW��{z\\u000e��=jb�u��!�x���{\\u0006�#�+C&�\\r�>\\tR�M3�emǛ��a_�\\n�\\u001b�_s���g2\\u0003���M%Ҽ�>�k\\n\\u0003���^7��ߖ\\u0001ҋ��L�gm�.�P�\\n�T�~�r\\f��\\u0019 =�\\u0010�\\u0002i� ɬ\\u0010�\\u0002Y��W_���\\u0018v���y\\u001ei{�쟚\\u0001����Lk+}��n�ٻ\\u001fU\\u001fB\\u0005��\\u0000Y�_�\\u0019 �\\u001fA)\\u0002����'3��P$u��6�d�=x��!�y�$����Q\\u0015�����C7�P\\u0013\\n���K��puM�.�o.\\u0015y�8$�\\u000f���#B��\\u0000��}+��/n�!W�B�3\\u0005���@w��uh��\\u0019 \\u0006I�~\\f\\u001d@\\u0007���\\r���V�X_\\n��� G�P�5\\\"��ʆ�Olh\\b\\u0003@�[��Ԋm}�yw)94��t��@�gRo�1\\f\\u0000�(����u\\u0000\\u000fo{���P�57\\u0003(7�\\u0011�\\u001f\\f�@z^��7w�f���\\u0014������'Kk\\u001294�t�\\u0003�g��|���\\u0002�C�\\u0016�Ͽ��j\\u001eb=)p߃��\\u001b�\\u0005~Y��&3@��,�YDr^6=��g\\u000f\\u0003\\u0000u\\u0015~��\\u0019����~�\\u0010���\\u0001dD:�\\f�?��k\\b�>\\u0014ا���~~����\\u0014 \\r��\\u0011AXH�\\u0012\\u001b.�RcT��7v�\\t�\\u0010j^�\\u001f��\\u0001�����Q.�� ��挆ʞ,�:$��a��/�Y@�Z���_�\\u0003�<��@\\u0016��\\u000eI�J�ς\\u0019@�({2���\\u001a���\\u000fO�e!֪\\u0002�����W��d�\\u001f�蟑���A��u�\\u0012Ȕ;��{$=\\\"�\\u001c�\\t�Ǒ�������v�\\u0005���\\u0015ʡo\\u001fr<{\\u001e��MOyb\\u000e�'B����$���9���O�{�~�I~\\t�)���)�HzD\\n9�\\u0013H\\\"\\\"�$���^2���\\u0000i<�`��b���Q�Gk�j�<�\\u0010֮\\u0014c\\u001d\\u0018OQ4�M\\u0004m��\\f�>���&��\\u001b��]{���哅�$=�8�)��)��)\\u0002n\\u0006 \\t�\\u0014\\u000e�d\\u0002��\\u0001�v\\u0010�\\u0001,��u����m�����ǞQ�-&\\u0015�C�\\u0019W�ah\\t)ڱm��hϕcy�\\u0016�w��Ӆ���\\u0019|���T��p~\\u0006��\\u0005 \\u0005���q��\\u0004\\u0007b�\\f\\u0000\\u001f��\\u0012@\\u0016ӄlz�k��}�\\u0002�\\u000e�78�O��&�x����<��7<�����\\u001f~x/�\\u000et�n�� ��,�j��\\u0005\\u0002�ce$�\\u0000;V�\\t��m��\\u000e��w~�}���4�P5\\n��m�ˊ�����_\\u000b���\\u000e�\\u001f�,��i\\u0006 \\\\P�d\\u0001W�d\\u0001�\\td\\u0001�A��7����\\u0005���\\fRo�|���<�����\\u000f�z�[�Z��\\r�\\u0015<\\u0003\\u0000�\\u000f\\u0014Hj\\u000f�\\u0019D�רݾ\\u001e��\\u001f݇\\u0010FF��\\u0003\\u0007��\\u0017��_?�/��\\u001f��⍝��]��_\\u0011U�=��6|�����_�+�~�\\u0015m/k\\u000b��\\u000b����g���?<����!|�~���\\u000f?�=_�\\u0016���_�\\u000f<��ް?]\\u0013�\\u000b��8��\\u0019@�\\u0016��G\\u000f�o\\u0015���~��|����o�,��˩�W���?{\\u0000���Q<��Y��8Ҝ\\u001d \\rG��\\\"�*a��\\u000bdʳ��I��\\u0007�\\u00052{]ʝ���ݔId\\u000b�$B��_�h*���h\\u0006�/���̞\\u0017�d�9}aЇ�K�ԏ�C��\\u0019�P�M+\\u001d�\\f:A��\\u000f\\u001d�\\f�yEM��7J\\u0007�@\\u0006n:\\u0004=�\\u001f�\\u001e�I\\u000f?\\u0003�~:��ۤ\\u001aY\\u0011�|�\\u0011��K�X>\\u0014�����\\u001d���*��\\\"\\u0019���v�Ƞ�d�W\\u0001 ����0'vD\\u0004��0��@�Eq\\u0018����V\\u001c�h>�\\u001e>\\u0003(s���\\u001a�x�\\u001c,�?\\u001bMMMe��a�\\\\��\\u0018X��3$\\u0002B�\\u0000\\u0000\\u000bpIDAT�GM�\\u001fc��\\u0001���o\\u0005)ԼD\\n-\\n�\\u001a\\u0002)\\u0002x\\u001c��3N[��O<\\u001ag�v\\u0012��|\\u0002\\u001a\\\"�&IH-cR=���y�Q�p���q�ҷἕ������S��gM��3O���\\u0005�ޅi�'j��Գ\\f��Co�\\b������\\u0001���CH!%�83|��gϚ�)��K\\t\\u001f\\u0017�=\\u001cc�v��\\u0019=JЧ��\\u0011G/��3�~҄q�;[����\\u0012\\u0004 ip0����|��~�\\u0012w�횛�4\\u0013�\\u0003�\\\\�\\u0004S���a߮[\\u0019�g\\u0000�E��8�Ⱦx�\\\\)���W�����\\u0012�\\u0001���y�\\u0016�Ac�/��ڽ\\u0017���%\\t\\u001bLS'Op�Lw��%vL\\u000e=�z�G\\u0013���\\u0011\\nf��-�\\u001f]�1Z�Ϟ5��:�=��v�w�ɾ�hp�,�\\u0000�e������ŗ^�\\u0011�釁���#�\\r|�~�E�>��ɡ�\\u001f\\nda>�\\\\~̧\\\"\\u0015�\\u0019\\u0000.�b:J@��(���/��?m�dpq�\\u0013�D�pH�̜>\\u0019��t����c�;�qZ��80�[�\\u001a1�a�)\\u001f���:�ܽ�%\\u001c��KV{\\u0006��\\\\�VowK Hu���\\u0007��z3#�rq��x���\\u000f��8�J�+{�����O<-'T�%�G��o��S�K+\\u001b�ʹ,�׍���@�=�gDI\\u001e���\\\"QP�m�P�9���ʳ��Y:��۞q���t}��Z0{f����\\u0017�c߾\\u0003�\\u0017 ����#��[����3Ͻ\\b�\\u0007\\n$�/��d=\\\\/����.���\\r/��>R��$\\u000bi�\\u0003�\\u0002\\u001eg8P�������\\u0017�?��$7\\u0014���Y�`��S&\\u0005q�\\u0013Ϫ\\u001b����?sڔ��V[��y�\\u001b��?9���:_\\u0005�\\u0005z\\u0001�\\u001eJvh\\r'��\\u000eܷ�+v�\\u0012\\b��-(�����\\u0018\\u0010e8I7�׻}����>e�� �=���8`�F�{�%��\\u000fA\\u001c9o��>��ܵ\\u0007/��*H\\u000eY�E�P-��ЖV�\\u0000�cr�������]M���p>�\\u0006@�\\u0006\\u0000A�(�0\\u0016�#\\u0012\\u0005�N �߾(���vo�����\\u0018Z��\\u001e6\\u0003m�-.�tg\\u000f�\\u0004\\u001d%\\rKӿUK6��e��{�e�ޣW�e�߲$K�_5]�ۈ�\\u0000�r�%��ֳյ\\r\\u0000�_\\u0000\\u000b���\\r�\\r�\\u000f�������o�O;wI\\u0017�B��Gvp��j��V��V��C唖�b\\r�4\\u000f$a��,����\\\\.d�]\\u001fm�>\\u000f�\\u0019�ZIF?0�\\u001ak�@Js�\\u001eԫ}��Yh� 0\\r��;Q’�'�ʑ���/�}\\u001fgƴ�IF\\u001e��é�\\\\��C]��H2N =����g���������݉JHy\\u0016G�=f���K��\\u0000\\u0000�\\u0003�J�A\\u001a�\\u001f�o���\\u0013�\\u001fgz���`�4]Jo�=�Z>��[��|��]Z���]_K+(�Vo\\f)(��Oi���US~\\u00147���*��\\u0001z&u�D<��h�X$�.\\u0005M�ںn���\\u0007`\\u0014�mO=\\u000b��M�����\\u0010a�\\u0011�\\u001fN���N��͊#����G\\u0014?X���b�SϏ��\\u001b���Ξ�\\u0007����\\u0005�\\u0001�e�:�si_Tڨ\\u0015m���X�\\u000b7��\\u0019�s�D{k+�\\u001a\\u001b\\u000b��c�ogd!K/g¸1�!\\u001f�~�e�R���뉿M|ݚV�\\u0004\\u0012mb�gH�W]�D��\\u0007�(�={��^\\u001b\\\"\\tO<�\\u0002��\\u0017���~�\\u0012��A�$k�|s7�|V\\u001fN�I�=���Y���\\u000f?���k`T��\\u0000�vgOW��(\\b�\\u0019��6߰n�T����8�\\u000e\\u0002c���\\u0019\\u001e\\u0001a}ٷ��\\u001a�������_Ń�=��>h/Ǭ�p���\\\";:���\\u0017ݾ��_�g_�\\r����~Ľ\\u001aY�._e鰈\\u000f\\\\�\\u001f��_���'�܋���y�\\u001b`���W�j��%��+��zU�3[nY���nE\\u0003�\\u0012��'~.\\u0006��\\u0012O��@\\u0006�8�\\u0007\\\\ ��L\\u0003��� \\u0001�g�����\\r������G\\u001eGOw��\\u0013��\\u001e�]p�4>��� ����p�\\u0003\\u000f�����������\\u0011?��\\u0003x��?�����5�5K\\u0007d}]�&���<�s¢x�\\u0000�r�}R��Z�4\\u0011 *\\u0014�\\u0001,���B%��`GN�D\\u000fR�\\u0000J\\u0016\\u0006}L\\bX �\\u001e�P\\t����0���m�W\\u0018\\u000f\\u001a\\u0000f\\\\4a��\\u0019EO�\\\"d�!�*���\\u000e�ܩ\\u000eP �z��)�\\\\t~��b��\\\"�\\u0000xjф�/\\u000b\\u000f�}\\u000e�u���h@�E�ޮ$\\u0003\\u000f:�9)\\u0014����\\u0018��|ZU>(�9\\u0000쬻�����kj�Q�o�\\u000e\\u0002�\\u0002d�C2���\\u0001_�>C�\\u0003����mx��Q��X\\u0007�\\u0002����t���\\u0012�@��\\u0001>��0�\\u000b�\\u001c\\u0000[n8\\u000fz�����B�^&���\\u0014\\u0006�N�\\u000eR�\\u0014\\u0006=@J���\\u0003;�^^�|X��/\\u001er\\u0000�Ew�p��\\u0000�k9�KZ�bP?�`D�R����\\u001e��ݓ��\\f��\\u0012��|W\\u001dp���\\u0003�����\\u000b��6��\\u001dS\\u0007\\u000e�11�\\b\\u000bd�J��`�\\u001a�.A\\u001fs�b=*�\\u001f�]�g]���\\rh\\u0000X\\u0016��}�ߐ��\\u001d\\u000bm\\u0002�C%8�ư��\\u0003\\u000f���?�{��{�=r�\\u0001�\\u0001\\u000f�-[���ݭ�G\\u0011\\u001f���\\u0002gW\\u0002\\u0019�d(vz%�A\\u0017�0,�����i�r�J\\u001c�6�\\u0001`�m��\\u001d��\\r'���q��\\u001a�@�vv�@�g��A��������GM��n�\\u001a\\u0000��-W��yĘ=�D俵u\\u0010�F\\u0015\\u000eR\\u0007��#\\b\\u001c�� \\u0016�J��r�\\n�\\u0007�7��s�7u<�8�\\u0001`�ۧj��w��8�\\u0015�]\\u001a\\f�\\u0018��\\u0001�o�\\u000e`!؋� �yЧX\\u000f��\\u001fB��8�\\u0015\\u001b�{�&�I��n%\\r���Mל��\\u0006p�*�\\u0002i��|�`���3\\u001d\\nd��3�}�\\u0017�G��@�\\u001c��I�\\u0017zc��t��_�\\f%�!\\r\\u0000+�����U\\u001c�,E�\\u0007Ȥ3�\\u001f\\u0004�y\\u00037�@\\u0006}��\\u000f�\\u0003qo����='�\\u0010vC\\u001e\\u0000V���.xa��ݧ0�o��`i���ZR�\\u0004R\\u0018�\\u001b\\u0004��c��� Aȷ�\\u000f�a�Bw���ݧ���!Dz\\f\\u0000�����m׮�BCǞ�Q�[�,z�T�e$u�s�-�H��N���q�dh���ȹ=�8�� ���^%�\\u0016��^��������)K,�\\u0000Hk��u���z�y���>\\u001f�DN.��*\\u000fRG:���1�\\u0004���\\u0010+�:O)�z�0:�y#\\u0002?���>�k����\\u0016�\\u001c�2���u+v�v��\\u001b�y\\u0011q\\u000f\\u0018�J\\u0016:7@\\u0006^4��\\u001eH�xU�r��έ׬��M�%\\u0019*\\u0012+6\\u0000��~�ڋ�����ݱ���NG/Ne\\u0014}F��]_뀴A\\u0000\\r\\u0006�@\\u0006nB$2H�Ѣ\\u0007\\u001f���\\u0006F�|�����&�1�1-*�U|\\u0000����R�z��{o���;o�z��qc|\\u0004�[\\u0001��d�\\u0010B��L����\\u0007\\u0000�8F�a�[�>�؏��_}��W0La�\\u0006@�=�]~�ӷ^}�_k0tv�����\\u0005��\\u0015\\u0011��b�\\u001f�\\\"~\\tq�}\\u0010\\u000f�ڝd=;\\u0003@�f��~�S���f���\\u0001�K >F�*����x�}S;��g�N�7�\\u000b\\u0018���\\u0001\\u0000\\u0000���z�\\u0019\\u0000\\u0000\\u0000\\u0006IDAT\\u0003\\u0000�h�5��L�\\u0000\\u0000\\u0000\\u0000IEND�B`�\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":6,"id":"coder_write_5","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":6,"function":{"arguments":"{\"path\":\"client/public/icon-512.png\",\"content\":\"�PNG\\r\\n\\u001a\\n\\u0000\\u0000\\u0000\\rIHDR\\u0000\\u0000\\u0002\\u0000\\u0000\\u0000\\u0002\\u0000\\b\\u0006\\u0000\\u0000\\u0000�x��\\u0000\\u0000\\u0010\\u0000IDATx\\u0001��\\u0007�%Wy�?OuO\\u001eiF3�9KH\\u0002�$��\\u0010\\u001a�r��A\\u0001\\t%r�1a\\u0014\\u00003k��_�?o���}^{�k6|k\\u001b�3^�\\u001c��`\\f6ـ@ @\\b�@aRw�����{��t��{:�𜩪�<��{��yO�U�w\\n8�\\u0005�so�r�\\u00057m�pӍ[^���-���\\u001b���ο��_}�M[�}��[�X���n��c�߸�s�\\u000f*�����7m����tX\\u0004\\u0017�0���z�?,�?ܼ��b�ছ�|�7��c�\\u001f<��-|�������������7n��1���1�[�?�/\\u0006]\\u0017\\u0002^\\u0000,�\\u0013p�M�\\u001e}���\\\\�N��o��+�n����<�\\u001d\\u0007�1\\t�EI����@�\\u0001ey\\u0017����\\u0015��ey&���\\u000f\\u0007�^�Uz��<\\u001a\\u0004�\\u000f���;h8Y \\u001e%�\\u00038\\\\��I$ϔ=\\u001f�\\u0015:�K\\u0000�%�\\u0006\\u0012o��M\\u0013�_\\u0014�v=\\u0014c^\\u001a�4\\u0006j<|Ӧ��z��[�|\\u0014�w�aq\\b�\\u000b��\\u0004}ދ�;`Ӎ�ܴ��{��\\u001c�\\u001f4�?Qb�+,�'�\\u0014?O�����\\u0010�\\r\\u0003[��\\b�\\u000f�\\u0007r�a\\u001d\\u000f6�b�����Y��˒:Y�?�q�\\t��\\u001f{�M[~y�Mo�qӋ�zp0�0\\u0004� [�\\u000f\\u001e�O�\\t����z�\\u001c��n��bl�a-�~[��^���9r�U���K�>mKs�?�\\u001fh|�x��\\u001f�\\u0012�3\\u000b��\\u001a\\u001dޅ����|�gu��\\u000b�y�T��\\u0000\\b\\ns�{'���ɇQ��q���\\u0016��<�\\u000b\\u0004����a\\u001e���\\u001ff7\\u000e\\u0000'�����]�\\u0018���� ��W\\b4����\\u0005�,�m������k6ݼ�/��&�M\\u0014���W\\u0001<\\t\\nd\\\\�,� 'YYk2��<\\u0012\\u0007�\\u0003i�\\u0002\\u0019\\u001c�\\u000fdp�LLJ��B-\\b�wa��<�_n���W��\\f�\\u0019\\u0012�/�\\u0002 s���ܻ����[�y٦���S����=���7�''�ז�� ���Z#q0�����9�\\u001f2\\u0007�C�0\\u000f��\\u0018���x�q]\\u001d���߲�\\u0018��ӽ�@������t�֕\\u0017�|�\\r�n��w�=Ň�\\u0012�\\u0003(/\\u00031�\\u0014��P\\u0002R�N\\u0018�#c0\\u0007sH\\u0004<.$\\f��\\u001f46�2���1f��}�-[^�Ic9\\u001cZ\\u0004j�\\u0005@MBvӦ����(\\u0017�r������-��\\u001d\\u00007�\\u000b��\\u0005��\\\"��j��F:?s�#̧�\\u0007�@�?�D���Q��^�.�*�̑|t>�IT��\\u000b9����$o(K�.wl��������K�n��9/�JG�H\\u0018�\\u000b^��B\\u001c�ԟ\\u0011ş�%^\\u0006b�R\\u0011(��}ʷB� �k��t�����u\\u0012\\u0001�y��x\\bB���!\\u001e�\\u000f�� ���!\\b5�\\u0005�\\u001fe�/Yޮq��>���޻��{7�#�u�>�\\u000b�X\\r^�KD\\u0017�r�ߗ��\\u000b�\\u0017jlR猧֑,\\u0014Ȯv>\\u0012\\u0017u�d�@�O�����\\u00175\\u000f(������\\u0003K�\\u001fZm]T\\u0012��t�=\\u001f���{�Lj��\\\\\\u0000���W.�|�;?�>\\u0003\\u0014�+\\u001f8�d�jB��K�)�z19Ez�z��-�S[󙚋�'s�d\\u000e�?�Z�?>�s˲��\\u000bn����\\\"p�_��1�\\u0003#�5+9R\\u000b�Mwl]���-o���\\r_.�_\\u0003x��\\u0000@�ԡɽ�\\u0011�\\\\Қ\\u001d1�������\\u0001�i������I\\u001a�\\u0017i���4Ƌ��\\u001f�*��zdٱ_�\\u0015�Wo�\\u001c�\\u0011\\n#�\\u0000������m_ �_P�<\\u001c�I_G9%Rh��i\\u00012�+�\\bdh�\\u0003�A��\\u0003\\nMm>0\\u001fD �_������\\u0001�̉\\fk>h�\\u000b\\u0014�\\u000b@�]\\u0010>�j���1G�\\\\��\\r�\\r�\\u0002`�o=N+�?d9��r�#4����m��|*.�%Y*������|A0\\u001f�E�������!zA�!\\u001b\\u001f�(��\\u000f7�r�\\u001f��i��\\u001a\\u000e�>�\\u000b��/Ê\\u000bn���06�i�쮂��\\u0012�Q��\\u0011!�pb�+���B�̯�P\\u0017��v~���a>�\\u001f(�~��/PY��a��\\u0013t�!�\\t:�?�Cc�Uc��g/x�}[b.����j\\f�\\u0002`���n޶��O�|�\\u001ar\\u0005c\\u0014R���|2R\\u0000i->�\\u0014H�0\\u000f�\\u001f<^ \\u00029���\\u001a���m�o��cN\\t&ö\\u000f�\\u0002`�\\u001do=x�K�{7������{_\\u001f�!��\\u001e�m�\\u0017\\u0000����#�ö�]��?h��o8_�毯�\\u001a�ȶu~�\\u0007�����A����y�mm>m\\u001ed[�O�\\u0007��\\u0003�g������_���A{Hp���@/\\u0000.����v\\u0016\\u0013�\\u0004xf��!gJKOY�\\u0012C�*\\u001f�/E��P����P)Jhk�k�\\u0010'T�\\u0012/�\\u0001�<�\\u0007h�\\u0000M\\u001eC�\\u001f8s|l�\\u0017�|�u\\u0018�0�\\u000b�Mwl]��������{H�\\u0007�\\u0018�cj\\u0003)\\u0005P����BG;_4 *�l�\\b�CW�O���D\\u0002e�?�O�\\u0010\\r�O�\\u0007�\\b ���\\u001fF����+\\u000b�炗������}�M]��[\\u0000l���Ǎ���1��M�k\\b����F5��{}i\\u001e\\fo@�`��?ĸ\\u0000���\\u0007�C���a�LJ�xݺ'6�����~\\\\|� �\\u0003�\\u0000������\\u0014�S\\t�8K 5bXX�\\b�\\u0003��������\\u0001�x��\\u001a\\u000f�Į��9Jg�m�\\u0002\\r�\\u0002`Ӌ�����{\\u0003�oh�_+��C�A.��Avu���j��|\\u001a�\\u0000\\u0005��\\u0011�$��H�]\\u001e����?h�\\u0002��e>0\\u001fr��,�ƅ���\\u001b�t�\\u001a\\u0003\\u0010�~\\u0001p����:�b�?��5�)j��\\n�8�\\u001e��\\u000e�F�\\u0012\\n�m�Hm�x]S;����`��\\u001c\\\"��\\u0000ԟ\\u0000���\\u0005�mj\\u0000d���65\\u0000�JGe�\\u001a\\u0000Y���M\\r���Q٦\\u0006@V�lS\\u0003 �tT��\\u0001�U:*��\\u0000�*\\u001d�mj\\u0000d���65\\u0000�JGe�\\u001a\\u0000Y���M\\r���Q٦\\u0006@V�lS\\u0003 �tT��\\u0001�U:*��\\u0000�*\\u001d�mj\\u0000d���65\\u0000�JGe�\\u001a\\u0000Y���M\\r���Q٦\\u0006@V�lS\\u0003 �tT��\\u0001�U:*��\\u0000�*\\u001d�mj\\u0000d���65\\u0000R���c�c\\u001f����G\\u001f��]\\u0000l��m/&�?\\u0004�:�N\\tj�J��\\u0016��Z�Z8_���-�P��\\u0007���\\u001f�h�h�p�p��G��\\u0003���Q���1ཛo��ł�d۞Nܗ\\u000b��n{�\\u001b99�n��W��\\u0017����bm\\u001e�~���?�C�8iH��C���\\n�|����F\\u0015��~[\\u0000����\\u0005��~�\\n��r�X�!Y�̡\\u0004�C\\u0004�\\u000f�\\u000f�\\u000f���!s�?d\\u000e��\\u000f*\\r\\u000b�\\u0017bn\\u0003b��\\\"�=��o\\u0016\\u0000���\\r+.���w�%�H�:Vn�,`m\\u001e��Fp�?�?�\\u001fx|��ǘ�10�\\u0001|�E������'�/\\u0016\\u0000\\u0017���u;\\u001f��jM��\\u0017��۹US�*]6{{$[�<�\\u0011�� f\\u0003�0��\\u000f�\\u000f\\u001e\\u001fѷ�\\u0001�\\u0017�\\\\\\u0017s\\u001e\\u0016!<�)�|\\u0001��[\\u000e���Ӟ�j\\u0010��zi��h��Lh�hR\\u0019�\\u0007��|\\u0000��\\u0003)0�\\u000fqA�\\u001cͧ�\\u000fd[�O�\\u0007�����A����\\u0000�+������\\u000eP�\\\\�mI\\u0017\\u0000�޵e�eŲ?���S!�i�JVLd���\\u000ed��\\\\� ��6\\u000f�k��y�?�<�\\u001fm\\u001e��6���\\u000f�������\\u001c\\u0018����O��K�\\u0000�t�֕�v.�\\u0003AN?�\\u000b\\u0005A�\\u0011��\\bT�\\u0016�@D�b�\\u0010\\u0005\\\"\\u0002\\u0015��(\\u0010\\u0011��-D��@�l!\\nD\\u0004*f\\u000bQ \\\"P�%����9���4\\u0017b�’,\\u0000�n�Z�O��_Zqn�F(u��6�ڡ�K��\\u001f�\\u0007\\u001e\\u000f<\\u000e��8P��<>���1'��\\u001a`&��$\\u000b�����_��\\u0015Y9���@\\u001a���\\u001cD�t�H��9�\\u001b<.�C�\\u001f�k��K;��L&��~͢/\\u0000.y�۷�\\u0012�&�5\\u0000�7^y9H��\\u0001\\u0015\\u0007���\\u001ft����A�0��\\u0000�u�܈�\\t3��E]\\u0000\\\\|��^]��;A\\u0015�L��\\\"ڬ\\u0001�@�\\u000b(�\\u001f�\\u000f�\\u000fp@\\u000e#2\\u001e�(�y�m��.Wzq���\\u0000���*9�/i�5�\\u0015mĢq��\\\"���\\n`\\u001eH���0�\\u001f2\\u0006�\\u001f2\\b�\\u000f\\u0015�\\nDe\\u0006�?H���o�5�Rs?�����\\u0000�获o���\\u0011,�e�\\\\��]?I\\u0016ύ\\u0007�\\b\\nq��l9��0N������9_t(�0?��n~b�\\u0010\\u0015�?zǍZ�?�\\u001f\\\"�۸ѯ����C5q�j�͋\\u0017l\\u0001p���;�<\\u0014Ѩ���e�`\\u001e���!s�?d\\u000e������9��?�\\u000f�q��?y!\\u0016(,�\\u0002�—�P��Մ\\u0005\\u0019�HT���X�\\bb1\\u0004\\u0005�<�#9�\\u000e�[�\\u001f�\\u000f�\\u001c\\u0001\\u0011H��(����`9��Wܱ������g��y_\\u0000�}��c���,\\u0007�u����le�1ç�\\u000f�\\u000b�VI�\\u0007zx�=��m1�6\\u000f�G���������1P�q��r�ob\\u0001¼/\\u0000>���-�ls\\u00014��Q`Q���\\u0007O��X\\u0013aw\\u000e�3�ߤ��x���\\u0000��\\u001b�G�\\u000b��̡��^;X|\\b^x�\\u001do{#�&�6{^\\u0017\\u0000����j6�)V� s,\\u001f���c�(�|D��\\u0010��6��QQ�l&����\\b\\u001d\\u001e\\\"���\\u00138**�ͤ�Q�|\\u0010��C�Z�|\\u0002GE\\u0005��T>J/2\\u001f���]�����y\\f�\\u0000�t���e����\\u0012Ȱ�@�W؈�u>�G\\u0004��@�\\u001f�PF�֐�����!zM�����+�\\u001f�\\u0004�g�\\u0004��\\u001f\\u0004E1ʔ�\\u00199��g9X�{��ZL\\u0019f�8o\\u000b�\\u0015صȔN�\\u0019�W\\u0014K\\u0016 C\\u0003���„v~�\\u0007�����\\u0001����y\\u0000mm>m\\u001e@[�O�\\u0007�����\\u0001��R�)�Ns-�'��\\u0002����v�f�קot��mK�\\n��͖4\\u0007�\\u0007�\\u0003����`��\\u0001^�\\u001d[O�]\\u0002�E��\\u0002`��/��cdvr�~���:s!�5�́��<2\\u00072[��\\u001c�l�#s �5�́�v\\u0004y����E͹{���\\u0002�;�qGI�\\u0015�PFq�Q*k-\\u0010�!\\b�����9�\\b���\\r\\b\\u0004\\u0007D��\\\"�@���\\b.�/@p@\\u0004q1\\u000f�\\u0010\\u0007\\u001d\\u0001�˜{�\\ts�\\u0014s{[~�5wm٧,��\\u000e�7~�LH�Z\\u0018䭉���Z�?�\\u000b��\\u001fD����An\\u0010���(̡?ȁ�]��z����Z\\u0000l�\\\\��Z�쯲C6�`�1�a\\u001er��\\u0007HkC�0\\u000f�\\u000f�\\u000br��A\\u0011�\\u000f@\\u0018�\\u0003����\\u0012���\\u001c\\f\\u0000s��\\u0000�⮷�E��o;q���mTk�\\u0001�\\u000f�\\\"�����������\\u0007��ܫ\\u0007\\u0002�\\u0000�,�~YK��hL(�\\n\\u0006e�\\u0006{I��ii����ۼ̧�����a�h��y�?�(�����\\u000e���%\\rR�&��\\u001e\\tI+\\u0012V&�k1�lG+�|A�\\u0016\\u001c�\\u0007�\\u000f\\rbPH�PY������\\u0001\\n����@&��h��9?\\u00108�\\u0005���[�\\u0001�l\\u000br\\r���4�|\\r�5�^k>��?�\\u001b۔~�����\\u0016u�է����D?�h�7�\\u0019�\\t�^\\u0000�|`�V����R���F�X�h�k�^��������o�2�6\\u000f�G���������1\\u0002�Q\\u0002����ĭ���㵳]\\u0000P�~�\\f��\\u000e�e�`���A=��?x|��E��E\\u001a\\u000f��\\rJ�\\u000b@�.1�0�\\u0005��wo�V���8�l:�-�\\u0001Zpk�_���\\u0003x<���\\u0012���.����\\n���Y-\\u000009yo\\f���A\\u0001��C�<\\u0004A�9\\b�6s\\u0010\\u0004m� \\b��A\\u0010��� h�/\\u000e��\\u0003\\u0016q\\u0015@g��6�\\u0005�ewl�\\u0004�y�c;g͗\\u001f���\\u0017�\\u0006�X\\t�����\\nLe m>\\u0002��!\\u00054��\\u0000M\\u001e�`>A\\u0001p���\\u0007�F\\u0000��^��Z��l3^\\u0000���ƃ�dA]�\\u0017:����/�\\r\\u001e���a�h��y�?�<�\\u001fm\\u001e��&�2�H��j>�\\u0004Nh���f��h\\u0001p�[�Ї]B�\\u001dK�\\f[f\\u000bd[V��р�̧�\\u000f@[�O�\\u0007�����\\u0001����y\\u0000mm>m\\u001e@[\\u000f\\u0001\\u001f\\u0010�\\\\v�;��\\u0019�\\u0019-\\u0000\\nLn\\u0001�RK\\r\\u0019\\u001dː-����}���v���jN�c>�_�\\u001f�x�k=>x|�\\u001d\\u001f���\\u001f���>|���nO�\\u0000�ꎷ���7�s��\\u001a!���t�����\\u0015\\\\�G\\u0014�'��̡�\\u0007�\\u000f���x�\\u0006oyHi�\\u0010\\u0005j��P$j�[��G�p�]o?I`��=�\\u0002`��o�X>\\u001e��O��B\\u0012�|\\\"B�d��,c����@V\\u001cDD\\u0012���\\u0002APG��,*+�|qi��\\u0002\\u001b�| \\u001aD\\u0004��J���@4*.�\\b\\u0014�d�c8��Q\\u001d��Ee����\\u0017��O�\\u0017���\\u0005���|岒�/�ώ\\u000f\\r\\u001b��s��#���\\u001f��D*�\\ţ�����a�h��y�?�<�\\u001fm\\u001e��\\u0007\\u000b\\\\\\u0017s8�\\u0010��\\u00008h��\\u000b\\t\\u001e\\u0010�=�C�,���u�cj\\r�\\u0010\\t����!\\u000e�\\u0007y�8�h\\u0010\\u0007��\\u0010��K����\\u0012�\\u0016\\u000f�!�\\u001f��R\\u001d\\u0003�\\u001ba�\\u001fJ�\\u001f-\\u001e��a>�(�\\u001f3o������k�>�&L�\\u0000\\u0018_�ċI�I��)��Ӫ%�3Z��g>�\\u0007��C_\\u0016�\\u001b؝�=>��\\bx|\\\\��Q�r�\\t��\\u0012ӄi\\u0017\\u0000�ʺtP�TL�Hj��l|\\u0016�M�^O:?�ё�\\u0005-n���|2\\u0007(��'���|2\\u0007�q���\\rj���#s\\u0010�Y��\\u0012��\\u0006�r\\u0001p��?{@Il�Y4����\\u000eV�\\bQ��� '�����̋ �\\t\\u0002�\\u000e\\u000f�����p����K���\\u000fr\\b�/\\\"\\u0000,���O�⚗�\\u0010�\\bS.\\u0000\\n�x�޴,��T�x's�Y\\u0007\\f��a��z��\\u0000\\u0011dKX�\\u0000R0\\u000f�\\u000f�\\u001b�\\u0007� ��1\\u000f<�1p�f�_�&������Ô\\u000b\\u0000�w��1\\u001d�ʢ�֙�yd\\u000e������9�\\u001f2\\u0007�C�`�\\u001c��\\u001fʩo\\u0003�\\u0000���[O�e�g��j�\\u0016��l�8�u!�<\\u0010~`\\u0000���\\u0007\\u0010\\u001c�\\u000f@p�?\\u0000���\\u0000\\u0004���9\\u001ex�5wo=\\u001e=a�\\u0005��$.#�U_�\\u001cY[������:?���y�Z�1\\u001f����ZC�J8�|��\\u0003L�n����d\\u000e�?�Z�A�\\t\\u0016WE���\\u0000\\u0000�IW\\u0000�`�R6���>��#������\\u0017��e>��#�\\r<���H��)uJf[�@��\\u0004\\u001d�t�bMB65����\\u001c�#q\\u0012\\u0019Et4�.\\u000f�\\u0007��\\u0007�?D���~Qs���\\u000f\\u0010�f\\u0011�\\u0011�3\\u000f��\\u0018s<\\u001a��\\u0000\\u0018+\\u000b]\\\"(�l�\\n**Kf��\\u0005ί�Ԗ4�pR�G\\u0010p���EmI�\\u000f�\\u000f���\\u001e�\\r���ʒ����\\u000f��\\u001c�N(:�\\u0014)�\\u0019� \\u0011�Q�B:�t�\\n\\u0013:%W顝\\u000f�\\u000f�\\u001fPP��1�!�8@��C\\u0018t�08\\u0004\\u000f(����8\\u0004��\\\"\\u001e�?\\u0015�d�~c>yM���?\\u0005�9>\\u0000��\\u0006�{\\u0003%q^h�b:i�JHVN��5\\u0012\\u0007��\\u001c�\\u000f���!s�?d\\u000e������9�?�\\u0018��\\u0000:\\u000b�to�Ć����T�X�Z���yP\\u001c�\\u0018�\\u0007q�\\u00065�\\u000f�~\\u0001dk��\\u001c�l�#s\\u0000�5��\\u0001�v\\u0016<Ҹ;?�o=\\u0007�Y\\u0000��2_f d\\u0001��qRh�\\u000b��JW\\u001a)\\u001b����ֲJ#e�\\u000f���\\u0000d�\\u000f�@Cۿ�\\u001fM�h�t��\\u0017\\u001e\\u001f�s|(P��*t\\u0016\\u0000`�I��t�S����\\u0000It��\\t6x\\u0000dS�\\u000fAt�\\u0005 ��|\\b�|�\\u0007�A$Ȉ�Z�|DE\\u001c\\u0002�bdDj-k>�\\\"\\u000e�E12\\\"��\\u001d*>\\u00009�cY�\\u0017\\u0000��?���\\u0004��\\u0010�\\u0013P�dK Y\\u0000Ua\\u0014A\\u000e�:�\\u0003�\\u0001������\\u000f\\b�hh���#=\\u0001\\u0019~!\\u0014�\\u000fAp�@�\\u0003\\u0000\\u000b?>��>RHW\\u0000>�0V\\u0013\\\\�\\u0019��-@� \\\"S�Hv��t\\u0006�p������?�\\u001f�F��y�L��|2�\\u001e������\\u00078��7l�W'CZ\\u0000�܉�u�12;k2:\\u0019Y�^\\u001bo-A������i>�\\u000fu�����ݿ�?�?�\\u0011�?�zެ��,��0�m86Β\\u0016\\u0000�(N&������udp��@�&���c�\\u0000�\\u001f�2a�\\u0010�\\u0004\\u0019V\\u0012aK�G��\\u000e���\\u001ff>\\u000b=�N���\\\\LL��\\u0003��Xf�������ٿ��PM���$\\\\��M�����\\u000f�\\u001fm\\u001e��6\\u000f�G�����c!�cl|��`qp|#!��+��\\u0016CP cR��8@�4\\u000f�\\u000f�&!�\\\\\\u0000\\u0000\\u0010\\u0000IDAT?�\\u001f@�tp�\\u0002􇅟�����\\u0002,�ˇc��\\b�1u��\\f���3� b>���G�\\u0001\\u0014º�\\u001f�\\u000f�\\u000ei\\u001c�?�\\u001f\\u0006�\\u001f4�\\u0017�r�>\\u001c7��\\u0016ir��y\\b�6s\\u0010\\u0004m� \\b��A\\u0010��� h3\\u0007A�6�\\u001c\\u0016��t�ae�/�+\\u0001E\\u0001��*�\\b�up\\u00002\\u00072[�́��<2\\u00072[��\\u001c�l�#s �5�́��<2\\u00072���Q�XY��\\u0002@�\\u0000Mr�\\u0000\\u0006R(���V��.�ȶ5����ѣ���e>m\\u001e��6\\u000f�G���������1��Qj6m�w!�_���\\u0017\\u0000�k\\u0011���\\u0005–�7\\u0010\\u0016\\n٪P�\\u0014�/\\u000e���\\u0007�O���������a�h��y,�`}�u��x`�TL\\u0011��\\u0016y�/�ɾc˔��A�zy�u>i~���~Sk�\\u000f������\\u000f����$\\u0016�?���\\f��\\u0000h�/�ƈH�R�{�:?\\u001a'���1L�\\u001f����y\\u0011�\\u0016�ڏ̇4\\u001f��R\\u001d�9���[�j��\\u00151�R �'�@� �(\\u0017�?X?\\u0003 �\\u001a��@�\\b�k����M��2������F���Z���Z�Zej#{�k�Lmd�{�2����V�6�ֽV�����Z+S\\u001bY�^�Lmdoz�����u�U�6�7����Fֺ�*S\\u001bٛ^kej#k�k����M��2������F���Z���Z�Zej#{�k�Lmd�{�2����V�6�ֽV�����Z+S\\u001bY�^�Lmdoz�����u�U�6�7����Fֺ�*S\\u001bٛ^kej#k�k����M��2������F���Z���Z�Zej#{�k�Lmd�{�2����V�6�ֽV�����Z+S\\u001bY�^�Lmdoz�����u�U�6�7����Fֺ�*S\\u001bٛ^kej#k�k����M��2������F���Z���Z�Zej#{�k�Lmd�{�2����V�6�ֽV�����Z+S\\u001bY�^�Lmdoz�����u�U�6�7����Fֺ�*S\\u001b�J_\\u0019\\u0006�R��L�\\u0004L`d\\b�Y�\\f'\\u001c�?���g��/;\\u000f?�K���|\\u0005��}W��������G�[��&��?ܖ��GZ��k��xo|F|V|檕��`\\u0002\\u0003@`}��\\u000b�w\\u0000\\u0006��.�\\t��\\t̚���\\u0002��t\\b^v�\\u0019��7\\\\���ٗ�7�7�����x�K��K.}\\u0016^p��8布p�\\u0011\\u001bq�A�q���X��*4'�GZ��k��xo|F|V|f,\\u0014~��_��\\u0015�|��� �0��\\r#G`�+��\\u0000�\\u0015$�yI�\\u0000A��9@kc�\\u0001\\u0014���0V\\u0014���\\r\\u0017?\\u0013��\\u0017k��\\u0011�|�E���S��\\u000eĚ�+T������2�+ι��\\u0017�2DYn��T�BL���J���5��pXQDU�\\u0003\\u0007�\\u0001��F<�\\u001a;?\\u001e�0��\\u0007\\r��/\\u001a<̧�c����c\\u000f��^|6~�_ݐ���z��x։\\u0007c��q���nQ�(˭W='���~�G�\\u001b��S�=P��M���G\\u001c�L��x\\b\\f��QJ\\u001f��ů�\\u0015\\u0000�jr\\u000b+�d�ʈ�H�ɚ�u~��\\\"���\\u0007��Q6x�?��@��|�9��\\r��3���}\\\\޿�ܓ\\u0010߾�\\f}��[�\\u0012���$�̏]�_y�p�\\u0016\\u0006�\\u001c��5��\\u0007��?����+HqЗ[�%\\n2�X\\u0010��eek�k�O\\u0006�^.�6\\u001f�|�@N���AN�\\u0005ո33>�\\u001c�/n����a��˕�v�)��5\\u0018�\\u0010e�[\\u0005��[��/�\\r�y�C\\u000f\\\\�\\bd������>̊/\\u0014��y��'\\u001e\\u000b�_��+�5�^;��/�2\\u000b:�Z\\u0019u�\\\"�R�ɛ��2TfQ\\u001dͧ\\u0002\\u0011��a�(�\\u0011��<��U+��E���3��\\u00187^�,\\u001cVM�ݓ\\r~,�t��\\u0016u�E��\\u0015Ϳ(p�r����Uh���5d^�u\\u0012{4��\\u000e���� �'�t�\\u001e\\u001e��t�D��\\u0007i>����Ǿ�\\\\\\u001e����#l<���\\u001f�H�1n\\u000b�?��K�<]�5V�����\\u0004�>�� \\u0007�O�\\u0010���/��9�S[\\u0007\\u0015�<���!a�?d\\fX,X6>�\\u001f��4���.}�_�+\\u0000U\\u0011FƬ^�\\u001c�\\u0003����&[\\u0014\\\\4��(/V{�|\\u0019�b�;!�\\u0005\\b�qq!YE�Uy��F�`\\u001e���!s�?d\\u000e\\u000b�\\u000f�\\u001ew ��W�+NNJ�K�\\u0014��zI�`\\u0010W\\u0002~�W���\\u000e��U�4.-\\u0000������y\\u0011�.\\u0015��_\\u0001Dck顺�Խ&ks�\\u001ft��vX���a�j���\\u0017�_\\t\\u000e޸��{k\\u00128������]�7�����k,�_\\u000e+�Bc<�\\u0012\\u0000�� ٔ\\t�2d!cM��\\u001c�������L ��\\u001f�-\\u001b�\\r\\u0017��_��\\u001a���Gj��'\\u0002�>�(��{�\\u0016��0>V�\\u001d�=H��wK7�\\u0014�W\\u001c�\\u0001J��\\u001a=��)\\u0012\\f �r~z�9�\\u0000�C@\\u0000tu~:���o��t�Þ������g�p1n�ܗ�1��n\\u000b\\\\q:~��.��u�`���\\u001f�?���\\u0014KGD �<�\\u001aHV)=�t~Z\\r%\\u000e�\\u001e\\u000e�����A7�������<\\u0002�����z��9\\u0010\\bv�l�٧\\u001d��~Yk���_�'\\u0014ȥi�\\\"AQ\\u0001b#(�N�cl����\\u0011D�:(5����b>m\\u001e���Dz�\\u0002��Ṹ��v\\u0011~�?Z\\u0000PUG�5��\\u0010\\u0014��D\\f�z(1\\u000f��?\\b�\\\\��A\\u001b��߽�r\\\\z��\\u0014o�I ~^�ߊ��b<����D/��k\\u0012X��\\u001e����\\u0000��U�h�t\\u0015\\u0002@X��\\u0001\\n�'�� \\u001c�\\u000f����i.�p�\\u0011\\u001b��\\u001fO��/y�\\u0002G\\u001e�\\u001e�\\b8�ȍ�ã�<�#�[P0��x^�\\u0015\\u00002\\u001cyI���m�D�\\u001c���\\u0001�2>�����^{\\u0011֬Z\\u000e��%\\u0010����\\\"�u�a\\u001e��x^[����\\u000f�w��X�cF��ib�3\\u000f�\\b?�E@X������g\\u001f�-w�\\u0010+W�G}DpQ�`\\u001d��O\\u0006��B�q��7s����\\u0002��Npb��L�H\\u0019c��KT�l���\\u0011^��e>���\\u0011��\\u000b�w,�x۹\\u0018\\u001bKw\\u001c�x\\u0004��\\u001bo=\\u0017\\u0017�s�NZz��w]Mh� *\\u001eK�?����w\\u0000HQ\\t\\b�\\u0005t���by.���@h@Ǭ�������\\u001f�/�\\u0004D���T\\u0000d�\\u0012�,���^Oņ����ؿ�%�$\\u001c��\\u0000I��%����?#\\u0015�����1,@�-\\u0001�\\u0002:ʎ�\\u000e{�{��\\u0015P�GӇE\\n�g�C��M�p~ͣךO\\u0010���E��\\u000eF��\\\\���q��φC\\u0010���3p鏜\\u0018s\\u0002�\\u0018�G�?�z�\\u0016),��U�����X��\\u0017Z\\n�թ��.�#�\\u00034F(f\\u001e�[��y\\b�����8�\\u0019��\\u0015/zn��{\\u001f\\u0011������x0Pe�?\\u000b�\\f�9^9��\\n\\\"�8R�JHY\\u000b\\u0003�\\u0003�\\u0016����a����\\u000eěo?\\u000f�_�ʋ��\\u0011�h��x�\\u000b��\\u0013\\u000er��c/��&���W�\\u001b�|A��h��\\u0010\\u0010��n\\u0001)�t���`>r\\u0004q\\u0000�\\u000f��ޟ���c�؀{�:\\u001f˗���?\\tD�l��8�u\\u001e��D�<�\\u0015Q��\\u0002[y�&ys0\\u0007�\\u0007�E�l9\\u001c��\\u001a��\\u0015\\u0017`��ep�o\\u0002�F�V\\u0007�6��=\\u0016q��\\u0017��\\u0000RoO���6\\u0002\\u0011��\\u0016\\u0016�I@ �A�<�c��a��\\u0018�{�&�f�^�m\\u0010\\blX�\\u001a���B,_>��=��]\\u0001��U�klK\\u0014�ٖJq�p$\\u000eq/8si�r~�G8T�d�\\t\\u000e��\\u001f���l\\u001c~к���\\u0001\\\"\\u0010?�|�5�\\u0001��/�W���x\\u0006����9�7?O\\u001f���K\\u0011�'(S�8��4���O\\u0011\\n��ښ��\\\\�?����ǹ�\\u001f�M�=\\u0006\\u000e�I��矀�O;L�\\u0018U��_�\\u0016Ӥ;�\\\"\\u0003�lk\\u001e�\\u0016z\\u0015ѯ�CA\\u0015�T�lc�\\u0007Ѱ�?�\\u000ff:\\u001e\\u001c��Z�↳�0�\\u0004^{�9��5\\u001e�D`�ǿ~��\\\".���O\\\\�@�eeQY���<2\\u0007�C�`\\u0018\\u001f#~�e��?�����σޔ~�YK?��Ux�O\\u001c�{�KW\\u0000b��A-Y�Z�\\u001a�\\u0017\\u0010�V�| \\u001a\\u0002Q�ʘ�8�H�0�<^t�i���\\u0017\\u000eCA��#7\\\"�tT�9�{���\\u0006�>\\u001a?�3\\u0000��^��ʖ<9lK+�����v~8���/b�;�y(��/zQK;�ͫ��\\u001c��>�ns�my��ې\\u0010�6ݸ~u���\\u001d��?\\u0007���;}�\\u0015��5\\u001a�5ɇ���ϜD�|\\u0004A[���c������IJq�؏z�PmѦw]&������\\u0001������\\u0007d�t�O�\\u0003�\\\\\\n\\u001d�V~T\\f\\nɦ�Q\\nJ���\\u001c�l�#s\\u0000�5��\\u0001�v\\by�}�\\u00118��C�0�\\u0004�{�ም���&�����\\u0010�s�׈�/]\\u0001H\\u000f\\u0002�����d+-#\\u001f(e��xY�x\\bLK�\\u0015'e\\u0004G\\u0019�@t��A�O� �\\b+#:�W��&��O|C���h��6�\\u0004��\\u0015��1Bn9P�I�s�Q?��\\u0015P ���J�*\\u001bd�Q�Z�:\\u001f +.�l�e�@V��σI�CVx@�O��Qq�y��\\u001b��6���ׯ�by\\u001fb\\u0002��_�\\u0017]xj�!Y��\\u0000�g�Guye�\\u0002�˟�L�~ݿ\\u0002Pf�\\u0015���Z��Z`���u��E@����\\u001b\\u0007`j.���\\u0005ȶ��k�%�_�j\\u0019���d8�\\u0006�k6�����O[���3Z��!��;u9������U:vy�媭^@�b1˟~\\u0007 Ωs#l�\\u0014�\\\"\\u0002���t�\\u0010��7��\\u0003D�����?\\\\q�IX�l��?s����Ƈ|\\u0005 �dJ@6\\u0015R6*\\u0019Z�2��\\u0015��Ka��(E��j�)ͧ�\\u000f���������6\\u001c���#6���\\u0011���{��wA2�Ԩ�\\u0002\\u0003EZ�t~p!3\\u0007��\\u001cȩ��Lͅ���g�YǪ�*;\\u001cF�\\u0000I���# g��P\\u0006\\u0006��)�T|�y�(�2?T�\\\\H�q�Va\\u0001���.��|�\\u0007��Q�\\u000f?r�\\u0006}8�\\u0000�#�>B��a�\\u000f\\n�Q\\rɨis\\u0004�֤o\\u001e@r\\u0004 \\u0019�\\u0000\\u0012\\b �a�q����?8�\\u0004��?\\u001cu؆$c�\\u0018v����i\\\"\\u0011�~�U�l��Ee\\u0019B;C+����ЊP{l\\f�|Q\\b\\u001a��g\\u0000�\\\"\\u0005�\\u0011\\u000f���\\\"���x(J��Q)����V��cc��o:��(�w\\u0013�\\u0010\\b��\\u0017�dU*FQ��cch�?�\\u00104 ;��S\\u0010\\u0011J�HWw\\u0014�hEʦV���\\u001fwM\\u0012� '\\u001cm��\\u0016/�3\\u001f���B�\\u0012�7��N�@G+R\\u0002]��2t�\\u0002珏\\u0011�~$\\u001cL�I���\\u000e�(����/�S7\\u0015>�JG��\\u0015)������O�+\\u0000�\\u0018�0J\\r\\u000bY(�y�\\u0000dKf\\u000bY(��F�d��U�L��-�-d�\\u000fa�x [2[�B��4�%����e*�l�l!�|\\bC�\\u0003ْ�B\\u0016\\nd��-�-d�-SidKf\\u000b�a�?����w�ʨ�w\\u0013�\\u0010X��J\\u001cu�~I�U@�d���\\u0017��F�d��u>���l�l!\\u000b\\u0005��Ȗ�\\u0016�ʖ�4�%���L�u\\u0005@K\\u0011}7#eu]���\\\"Z\\u00064��Af.\\u0004�G~�&\\u000f��pd� 8\\u0014���\\u000e�1Ļ\\t�F��c\\u000e\\u0000��S?'\\bE@�j\\\\$(Yϟ��t\\u001f���J\\u000bT�O�Q�Z'��%�|aP�6x(A\\u0018���|��|�_׈��A@\\u0006�?N?�\\u000b\\u00008LI���\\u000el�� ������*�\\u001a/�9-\\u0002\\u0014��T��\\u001e\\u0011iD\\b[�0���\\u001e �����\\u000f���\\u0011!8E�l\\u0018������\\u00158����j�M`7\\u0002'\\u001d�?�kDZ�M�8^��?�\\u000b\\b�A��~\\u0007@M�\\u0012#����&�V�6��FҀ�\\u0003�� 0�s�?��\\t%\\u001e��x\\fp�9Q\\u0003���1�ț\\t�N`�5+p�����w���\\u0007��S\\u001e��\\u0017d~p U:5b�� \\u0006j�J+/6��Ώ9�|j\\b��N�?r��ڠ�C�����sʱ��\\u001b��n\\u0002�\\u00108U�\\u0001��W�\\\\������\\u000f����_\\u0001\\u0010�Ѣ\\rS�V�Jhj�<�9��P5r��|�<�?m\\u001e��\\u001f�\\u001c�{�QX�&0\\r�gh���=����|E�m\\u0011��F�P\\u0016\\u0014�ʢ���\\u00142\\u0007�C怡�\\u001f��Y\\u0001\\u0007\\u0013�\\u0013�C\\u000f��\\u0018\\bC���1\\u001f\\u000e��P �.ۄ�m�`\\u000e���\\u001fF�\\u001fH�����z�h\\u0002�\\u00108x�Z�\\u0007\\u0001#�\\u001e/\\u0002� �\\u001bE*tYbj\\u000bL�^�\\u001ep������\\u0003��1X��q�\\u001a\\u0014�uA8��4\\u0004����uk��=U�\\u001e��1�\\u0015@��4���2dm��!�ڸֽ���$:�̧��������q�\\u0001k�h�L��\\t\\u001cv��\\u0014M9o�~��p~_ϟ\\u0005�\\u001f\\u0000$mK�+i\\u000e��iG��a\\u0007�\\u000b\\u0007\\u0013�\\t�C\\u000f�gd�\\t9�<1H�FQ�\\nM��\\n\\u001d�\\u001aZ�v��7\\u000f�~�=�Ő�C\\f��`m\\u001c\\u0007�U��>��\\u0006`�\\u0001�\\u000f\\u0002��~��!�\\u000f\\u0005V\\u001c��p�GT��r�/��x�^�,v�?>gL��b5�x(r\\f\\u000em\\u0002\\u0007�F\\u0017�J�\\u000f�Rj��A�O�\\u0015\\u0000�;5b�Z�$\\u001b\\u0007�dT\\u000b�G\\u000e�y\\u0004�L\\u0001 s,\\u001f��L\\\"\\u001f\\u0007�G�g/�א&zM���4��\\u000f�ĽB�y�e���1ݗ.V\\u0002�r��/�\\u001d�>\\u0006%j��z+\\u0000*=>��:�>�\\u0017��I��\\b,?\\u0010X��B�\\\\c*CZ\\u001c�1�a�ڕ������\\u0000�c���Z=\\u0000P+\\\"��2]�R��U�:�C\\u000e\\u001d��\\u0004��#)�_�_�\\u001a9tx�����W���ϧ~\\u0001Pu�o��-<&����&zMȑ\\u0016��c�\\u0010,��sƹ�uu`��\\u0012eZ��\\n˴H\\u0018�\\u000f��C,&\\u0016�@Kw�5��h�ӻ�\\u0006\\u0005�>N!�>�S�*�.k�\\u001e�kk�:?s\\u0011\\n]����Cq�G��.\\u0004�x\\f\\u001e��+�A]E_�-�y��[|\\\\��I5�yǷ�pQ,zqf~B5z��\\u0004ck�nA,��\\t��\\u0010u�:�Ac�6(�\\\\�B����q1\\n\\u001d�HK+��\\u0007����z�j��ġ�80h�E�D�\\u0012@�F\\u001b�\\u0005:Z\\u0011D �\\u001f�Hq\\u0010\\u0010RV��G\\u001c\\u001a<ȶ6�6\\u000f����ϚU\\u001a�Ֆ������4�7��\\u0016�{�q\\u001f?.�/J\\u0001\\u0016�$q� �\\u0012�#�Ջ�c�\\u0015c\\u000bx����5�t���\\u001f����v~*Rᵢ!�F�f؎\\u0006������|�\\u001f҃.�'P��o\\u0000���̽O8Ja\\n\\u001e��g�o\\u0001\\u0014H\\u000f�ŷ�x\\u0000/.��o�\\u0014�a�T��c�5�%��\\u0005q�\\u0000J\\u001f�*/\\u001bW[����A���PD��\\t\\r(��\\u0019��ˤ�w�\\u0018���\\n@�6R��$\\u00079d�h��P\\b��Ηw��y�O�����c��c�B�/�1\\u0001Ʒ��\\u0000���0|�W��i��\\u0005�\\u001b��20�\\u0011H�\\t��IK���+�\\u001f\\u001a�\\u0001����\\u0012�\\u0013�3_�_~��\\u0000T�(/�B*B�\\nCj\\\\[�È���y\\u000b &����0 &�X\\u0004 �+p\\u0010\\u0001�\\u0018[-6������\\u0003��\\u0001\\u0006#������K�\\n\\u0003���P�˖Q��Uv�2H���\\u0013s\\u000b�\\u0014\\u000f��7�e��=\\\"\\n77VO�%\\u0010�\\t�s\\u0012��Ӽ~���W�\\u001e�9�H�\\u0017�0��OKs�*=@��\\u0012\\u0010�l��Y;?s ���35\\u00172��O�@Nm����]\\u0013��g��\\u0001qO�so_z\\u0016��K�@ n�ĭ�������\\u001e^�\\u0004Y����㿁���Y��3h�/ V�T�T6\\u001eh�*�~��ӒO\\u0018��ʀ��si>S�A?���]��Q�{��� ۡ@|���ӟQ���E5븺�G\\u000b��+t���pP�w��1�\\u0014��\\u000b2Z\\u0011���l? G�?<��\\u000e�9�M|�{�}4\\u0019���C�\\u001b�\\tĢ+���>xF��m�]\\u0014k�\\u0011�/�>\\u0016$U�2O���M��\\u001e\\u000fz������a>d�_�\\u001fu��|�����v\\r�&Ą����\\u0017�x �m�\\t��W\\u0010�\\b��X\\u0014,~\\t�\\u0019�ض�g<\\u001c����\\u001dߞ�fG��I\\u0005@�B����U���*5u~]���O�]����y�?�<��?v��\\u0016@�\\u0017}\\u0010��ܲ�C�\\u0010�\\u0007\\u0004����\\\"�1h�K\\u0015\\u000f\\u0001\\u000e������<\\u001cǖ�A��6@P$l!\\nD\\u0004*6\\n��ǷE5�]�}�PMz\\u001a}y�f�_\\u0004��\\fF,\\u0004�\\u0007\\u0016�p�=�]���T,\\\"�h\\u0007����%����^ �D��6kA�5\\u0011�\\b\\u000e��\\u0001\\n��_\\u001e��\\u000fUGm��+\\u000e\\u0001�j$��9��\\u0015\\u0010�\\u001f\\u0010�\\b��R�G\\u001e}B�\\u0019��0��\\u0016\\u0000r\\u0016�a�L<��\\u0014\\u0001���\\u0014\\u0001��\\u0006\\u0012\\b \\u0019�\\u0000\\u0012\\b \\u0019�\\u0000\\u0012\\b �\\u0001��Ƕ\\u0003��F�O����m�9���t+\\u0016��\\u000f=����4G\\u000e��#\\u0015\\u001cHfN�u��_@�̕ e�A�����5\\u000f�\\b?\\u0018\\u00018��p˵\\u0017\\u0003�֯^?�[��`<$���\\u0006|�X\\u0000���\\u0000�\\u000fE.{�F����y�\\u000f҃B\\u001c\\u001d\\u000eEA���s�滯��5��0$\\u0004��\\u0019��5�!�y�Ҷ����c����1��E\\u0011���x:\\u0019:ʖ�\\u0005�E�|*?\\u0000D��1l����5��U?�K^p:b0�B�a\\u0013YoC@ �\\u0006,?\\u0010�A�y����{\\\\+d�\\u0007�>\\u001e\\u0014$�\\u0016%�����5Aq\\u0000Ȱ�A\\u0010\\u0011Ȱ��!�qԡ\\u0007��o�\\u0011G\\u001dv@4mg�\\u0002��bH\\\"D���y|@�G�LlH}6Fu<\\u0000����e\\u001c�\\\\\\t�P\\u0002U���4��9\\u0005\\u0010�0\\u001f�\\u0010\\u0007\\u001d3\\u0017�G�\\u0010@�e\\u0000���<\\u001e���E�g�K��S�\\u0014@����\\u0013�\\u0007\\u0004�ـ������g���\\u0013��\\u001f�\\\"8�h1�\\u0001���x�\\u0005@\\f�Q��Mui��FC;_D\\u001a<�`>�A\\\\�\\u001fB!\\u000e:��\\u0017���%�C�~�e��\\u00157^�������+�h����_\\u0011�����_�n�G��b\\r�����o�t~�Шw�\\u0007��Z\\u0000�И����bm\\u001e�\\u001d*��?\\f�?�^�\\u001c?q�5���g�\\u0006��89YN��� \\u0010�\\u0005ĕ����9T(����}�Q�\\u000fC0_�g\\u0000҃\\f�L��\\u001a횚�\\u001e�p~\\\\�Ax��$\\f�\\u0007i�h�C���!\\u0007��_�\\u0012�|��I�\\\"`&�\\u0006�5���6b.\\u000f\\u0007~��G��G�����`���\\u0000�J��VdU)�<�uu�:���\\u0007�.\\u000f�i��y,�\\u001cy�\\u0001���7��\\r�b��\\u000b���\\u001a���o\\u0005���/��q�?�kT�ǿ��uEc��O]\\u0001�&-�6T��\\u001c�\\u0006���a88\\u001cy��x���bՊ�����\\u0003��\\u001f�}|\\u001d�_\\t̰��{�;#?_\\f����\\u0000\\u0000Z� �Vta���\\u0004�\\u0007\\u0005�K�!�\\u0014�+%�O�!.\\t�lG�\\u0004���H�\\u0006�\\u0005�s�a\\u0007i�n֓\\u00141]\\u0001��y!�\\u000f?��Ѡ��T��7\\u0015�b�����o>\\u0000�&�\\u0000\\u0000\\u0010\\u0000IDATV�x��]�f&\\u001e�LC/`���ٽ=��\\u0000Da��a�#�6tZ�4�N��\\u0013$��;\\u001f-\\u001e�K������� �E\\u001c\\u0017����\\u0013w]3��\\u001fU��À\\u0015�\\u00111cku%`ϋ����o\\bƨ�o�S�\\\"&/2\\u000fJ�h�\\\"�3\\u0017��y��?\\f�?\\u001cw�!x�&��˗�\\u0003�y�5�?\\u0007�3�A}c���\\rӖ��>�U�\\u0007C4\\u001e�+\\u0000�e���.ch�C�-��d[;�̓lk�i� ��|�<ȶ�)�#\\u000f=\\u0000?~��X���?\\u0014�\\u0017\\u0001cW��(\\u0011�?\\u000fL�\\u0003ڕ�K�\\u000f~����\\u001c��l�5�5\\u0006h~�j�\\u001c��g\\u0000�\\u0006�\\u00106:|�@�B!법�a(S���=Q@2\\u0007�8A!��_�\\u0000�х\\u0015�K�\\u0000��e���ߺ5x�mWj�\\u001fק��61Q��\\u0007�S\\u0006�@�\\u000e��\\u0002\\u001a���?]�������������ş?t\\u0005 N�ԨP �LF\\n)����o�0�6\\u000f�G����Ǫ����;���kWc>Ä\\u0017x>q\\u000e�g��\\u0007�j�J\\u001d_\\f?�\\u0005\\u00009��{��_�m5��J\\u0001�h�c�Ȋ!\\u0002��\\u0018�a>��R^��\\u000f\\u0000ȥ�\\u001f񓾯}��8x\\rؘ�\\u0010\\u0003�\\u0001�\\u0017� }Z�\\u0007B�\\n|����;����\\u000f�?9��G\\u000b\\u0000M��A��ʨ]��LE�����\\u000e����e>�O�G\\u0018�\\u0017�\\u001dd;4\\u001cw|��\\u0017��K`\\u0001�w���a��\\u0016\\u0000j�\\\\+��u\\u0001dK�u~�\\u0007\\u0014�\\u000b@�5\\u001f�@\\u00042x�X\\u001c�'�XX������ӎO�\\u0017�0��\\u0000q%`�>ߟ��\\u0004J\\u0010��w\\u000e6�ߧ*h��d�Ria\\u00012[�\\u0010�\\f�| 8�x@�����\\u0016\\u0000Q�REl\\\\FH�`z�^ѹ\\f�|����\\u0007��0\\u001f,4�g�|\\f.;�9\\u001aP\\u0016~�\\u0004.<�~=C����+�[����q\\u0015S��H�oU]�\\\\�b�3�)���\\u0007��\\u0016\\u0000Uc�I=*Xi�FuPB���\\u0016\\u000f�i��y,�lX�\\u0016w�h����l\\u0013q\\u001b@uZ���,�B ����W�\\u000e=p\\u0003^rŹ���b^Pb����6�|�5^:_���OA�\\n@\\t\\u0015N�XY�UҽVm:ez�:�g�5�^k>�Cv��\\\\�c�(�[.��\\u0015˰�!�\\t.��|��'����u\\nN�1S�\\u0003�>�\\rc���\\u0000@cR\\u001e�tqC���Q٦u>��n�\\u0011�����!,�G��������~\\u0004�\\u001f�\\u0011�\\u001dv��\\u0004�\\u000e��g������w\\\"�y��_�\\u0019\\u001b��\\u00034�}>����*B:�?_\\u0018\\u001a�k.��+\\u001f]\\u0001@\\u000e����$7��F�*�~��\\u000eP��0_D��Rk�\\t:@ͣע���u��N�҄\\u0012;w�灗���5����\\u000b�����[.���\\u0018j?E埵����֫փZ�\\\"�ź��X�30i��r\\u001an�ҝO�0�e�\\t�X�\\u0013\\u00116��\\u0017w,�}�8g�>11��/ܛn=\\\\\\u0004�������\\u0011�\\u0002��K���9M�E��|\\\"��-+N��.d�\\u0016\\u0000��\\u001f�\\u000f\\u0010!V{T$�LJw~�8ty�\\u0002x\\b���|�'�@��da���X�W�|����Z��\\n�Kd��L���sNÉ�\\u001c&�_x�τ�<\\u000bӿ���5_\\u000b�\\u0012d@Ee�I�\\u0002\\u0019\\u001c�\\u000fdp������;cI��c�\\u0010�\\u000b��\\b�\\u0002̐$E�F\\u001bϤ:�]�\\t˖ş\\u0006bI�\\u0007\\u0014��\\u001e����\\u0019�GA�\\u001blYA+�&�t��d��O�0�%�\\u001f�\\u0003,�.���c�a��\\u0005�!��{�l�v������\\t�C<^�C[�\\\".ߒy\\u0011�Ԇ��q\\u000f�:|�O'!e�\\u0001��_�\\\"\\u0006w��| \\u0006\\u0011d\\u001b��F��\\u000e\\u000e�� \\u001c�<����ģ\\u000fշ�� ��c�~\\u0019�g����\\u000br~(0ک��x�or2|*����x 0\\u0011�x��\\u001c/�v�.R�\\u0010���ut\\u0006�0\\u000fA�f\\u000e��m�8\\\\s��:Yl�\\u001fb���D\\t/\\u0002�\\r�s���H��oa�����G\\u001d\\u0002,R�Y�� ��Hu���N]\\u0015�m7ר\\u0012*��l�P\\u0019�\\u0007D�\\u001d\\u001eU�2�#6�uxT�ʌ\\u001c�\\u0013�>���\\u001fT\\u0006{�E�\\\\~Tf�k���ߵk\\u0002�6�Q�\\u0017]|N:M�?W�ʌ\\\\����5-\\u0000:\\u0017�\\\"�&\\u000b\\u0013�\\u0014V�s\\u00072��t\\b�����4��E��uQ7@\\bH��C��::\\\"J\\b�|�\\u0012��:<\\\"��0���\\u000b���\\r�\\u0016?+\\u001b���h�k\\u0012\\u000b�]�:�X�8�\\u0003qܑ\\u0007\\u000f��1\\u001a�OZ\\u0000��&@ư�m\\u001ad�\\u0011�4\\u001f�\\u0003f�?�/\\u001b�7�\\u0003q�٧�ڋ����<\\u000fw�h3^s�e��;��}��\\u0001[�&��[o�/��Uzm���P��t��PUj\\u0000+\\u0013m�k\\t~�!|���ٯ�g�\\u0000����Oj\\u0011��u<`A�G�`�\\u001c����+��x}��|�3��6���}1~��;񖻯�K.?\\u0017�\\u001f��\\u000f�<��'�\\u0013��\\tG\\u001d��\\u000f�\\u001f\\u0007�\\u000ekW�\\u0014����sL@�[���Y<�\\u001fm�\\u0014�|�)�`�5��x��Ӽ2u��avO�k��G�|E��Aj��DQ\\u001b(\\u0012���t�\\u000b�y _ǣ�C�X����x��G����O��&���w�w\\\\�\\u001b.�\\u0011���\\u0013q�\\u0001\\u001bP�Ш�-\\b�\\u0004\\u0014\\u0013QĽ/\\u001e�`���_�s6�\\u0014}����\\u0011�q��I�'�\\n@��j�h8R+4k� \\u000f\\u0005�<��8@XF�Ǻ}V��=\\u0015���2�ܖ��\\u001b/Źg<\\u0003\\u0007nX\\u0007��'\\u0010\\u0013��\\u001d��N\\u001a\\u0004���~�^\\u0010��6X\\u0007���yyk\\\\�\\u001a����0r\\u0010�\\u000f�g*'�~��W�\\u0012�?}��#�;eYY\\u0019-\\u0006�\\u0014�\\u0007�\\u0006�ĥ��͐!Sz�z^���\\\"\\u0011\\n.���P1�\\u0017���|6�������[\\u0011?�{��Gb��\\u0000�2�fI@�\\u0000�wN�\\u001f&�Y\\u0016}`^\\u001el���B�Y�\\u0012g�r���u��+�l�4�+;Y�\\fh�\\u0016\\u0000��\\u001b�Њ����),z����A��e>�G\\u0010I�2D��F��/;�\\f��\\u001f�\\u0005o��:\\\\���Ѓ6�a~\\bh�L�\\u0018�\\u0014\\u000f��O\\r��S�i?M�5�M�<3�\\u0017�0���lD�w-\\u0000��\\\"\\u00052OnL\\n�D��v~8G�G�\\u0001��|\\u0006���G\\u001d��_�\\u0019?��\\u000b����]\\u000b��#\\u0010!\\u0010�U\\\\u\\\\����'�yQ�k�/+|������7V��\\\\D�/\\u0015�l@.��\\u0011W9s��,��y2*�ʒ�e[�hk�h�Or\\u001b\\f���н�\\u0017�u\\n��\\rx��W�ӎ�X1\\u0006��!\\u0010�����\\u001d�rqN8�g�\\u0019����Q�\\u0006�����a�Ư�|S��ѣw�/J\\u0000�V\\u001e9R-\\u0006��\\u0016\\n0�\\u0001\\f��=�+/8\\u000b?����}�C\\u000f�%~,Q��\\u0016���|�DE\\u0018��\\u0006���\\u0012\\f��\\u0012g�z��S5\\u0012�?�kA�5\\u0003)+/\\\"e�Lٔi��5\\u0011�t�7x�%[<�G_�\\u0006����:\\u001e�|�����+�/SCz�\\u0007\\u0002qK`��]~@p\\u0006�199�`\\u0015�f��x���틣\\u000e=@e�y%LXF$�\\u001b��4�\\u0017?���3\\u001a�/b�\\u0006T�\\u001a�l�\\u0000Ȝ���\\u0000Uc8\\u001f��\\u0000 ek\\u001e�dC\\u0003�o��\\u0003>�\\u001dy\\b���:�v�\\u0005X��\\u001a8�\\u001f\\u0001�I����Ֆ�W�%.Q�y_�ٱs2\\u000f�K\\\\�ٞ��S��[4NƱ��\\u0007R�\\n\\u0007�숔�@TZ��æ�Vi��\\nT:����\\u0010�gp�cú5���\\u0017�w\\\\�#\\u000e�o p�s\\u0002�����N`���.4Ҹܿ}��@_!9�ㄩ���SE��Q\\u0019ߋz2�V\\u00004���G#u���<���|�_P ���J]޿����\\u001d��\\u0011�yF|�P1�\\r\\u0014�<��B؁*�<\\u0016vbbR��]\\u0018���~�֦����g{���h8M�Ǘ��]�P�\\u0005�\\u0016+u���Tj\\u0014\\u0002SZ(4_����\\t\\n泴�s�a\\u0007�m�}1.���1>���\\u0003��U٘����&�H\\u0018��̰�c]睻\\u0006�r�t5=���Z�e��\\u001fu9�ܣ2~W\\u0005\\u0010�\\u000b�̃6��J��]��\\u0013����#~{��w\\\\���Ϗ�\\nr��\\u0012�m�\\u0004�?\\u0017���CU?U&�k�\\u0004��R�5\\r�J\\u001f����H�\\\"�x*���>�\\fS���\\u001f��-�-*km\\u001eH��̡��aŲ1���K�^\\u0001�%\\u0000\\u0000\\u0010\\u0000IDAT�\\b�\\n��Rѽ\\r!�\\u0012��(uY|\\\"=08�K�\\u0003�D\\\\�4��\\u001fu����\\u001e�.խ�\\u001aF{��uX��j�+e5Ϡ�K�1��)b�O�K\\u0001�Q�\\u0016�ѹ\\u0004�N\\u0001\\f\\u0002��7�-�x\\u0011�y�Qp\\u0018\\u001d\\u0002�`\\\\\\\"�o�1�NL��/�M�\\\"Q�(s�=�\\u0010u��uØv‘�`\\u0010ƕa�\\u001f��\\bQ˘�m�S��@q8�c��W\\\\��f\\u0001��%\\u0010\\u0013�Ν��|�+]\\u0019إ+\\u0003qI�_�D��L;t\\u000bcێ]ة�FZ��o1�q�Q��t���'�Ψ΃�\\u0002P��\\u0017_����d5\\t&k-:L�J��Rc��#\\u001a��,���p�9x�u�����C�U\\u0004J�?�����zL�;4��e�4�jL�^�pF�s5'�(C�)�5|,ܹ\\u0007��O8��\\u0018?F}���8�G?������ʛ.A�9Q\\u001aM|0�Y\\u0012�<��\\u0012LL������b�ޮ���c\\u0012߶}\\u0017Z{#?^\\u0017���D\\u001f�5�E�\\u0006�Y�f4_~�1�\\n��\\u001br��߂`�r:VV�Q�t�b-��6/�i�X`�X�j\\u0005�p�8���Gs�t�M`H\\b\\u001cd}\\u001b\\u0000\\u001aE�n�\\u0019��MW\\u0000\\u0000�G\\n\\u001a�ce�ґ��\\t\\u0003�竂M\\u001e\\u0001��K%4�$���h��\\f������\\u001d�T������\\u0004L``\\t��r�p�\\u001a\\u000f\\\"�9\\u001eD���h��^�)4��op�CKX\\u000bh�=A-�#8�K��\\r��'�\\u001a\\u0007�ޡJ��\\u0004L`�\\t\\u001c���>\\u0018_\\u0005�\\u000f�7�8�K\\u0011�=�\\u0016:'H�]\\u0010\\b��\\u0000YYk�\\b�XbX��\\u001a���Wa�}ת�z3\\u0001\\u0013\\u0018\\u0006\\u0002+�/���k@z�I�0*\\u000e��CW\\u0000�\\u0007\\u0010lK�\\u0002i\\u000e��aՊ�xݭW`�5��a�s\\u001dL�\\u0004\\u001a\\u0004\\u000e>`?��%ȥ\\u0019w��>o�\\u0006{�@�n;�y,\\t������\\u001b�5�\\fGM�\\u0004����������'���bDD\\u0016K\\u0017�O&�Q^Gʷ�\\\"S�֩0\\r\\u001d�����o�2�6����m�n±G\\u001c,�f\\u0002&0�\\u0004�9���\\u000f{??A�X��P���Q\\u001a��T�j�\\u0011\\fBC:[(h0��F\\u0002%\\u0013����5�!�\\u001a�~�� �D�B��?��t&N�1p0\\u0001\\u0013\\u0018^\\u0002\\u0007�\\u001f\\u000f\\u0002�~|��2?�K\\u0001\\r�\\u001a�e4Z�3\\u0015Il{-����˥֤��\\u0017��N=\\u001e\\u0017�{���z��\\t�@\\\"\\u0010W\\u0000b� \\u0017�������\\n\\u0000b\\u001f0HE�\\\"@m#KZ\\u0007\\u0017�Y<xƱ��k��\\t���\\t\\f;���Wa�݄;�ߌM�;-����x�����\\u0016z�)��&/ˬc֗�f\\u001d>h\\u001e��\\u001f\\u001b�[�;o�\\u0010\\u0005\\u000by�7\\u00130�Q �~�5��}�]�<�\\u000f}��{��\\u001b�\\u001a\\u0017�ȳ���}\\u0013����<��m�4�R���:\\u001d�a+f\\rQ0\\u000f(P$\\u0016�\\u001fH�v}\\u000bX�l\\\\g�f\\u0002&0�\\u0004�>�@\\\\��,��5/ƽ�|\\u0011.{�\\u00198���\\t�b�G\\u000b=���秿\\u0002��P@���B�*�~��KM��SNm�gj.S���s��#\\u000e�_,���\\t�@�����K_�\\u001c�����֗_�3O;\\u0016cEܽ���B����\\u00136���\\u0012�w\\u0000P����\\u0005��\\tR�A�F�eC��ӛp~�@�C64i>ɟ�g�8H\\u001d�r���d�L�\\u0004L`Z\\u0002�\\u001d�\\u0001�^�\\t?���`�9�Š\\u0015q�p��\\u000b�f�\\u0019���\\u0015\\u0000h��\\u0007\\u000e\\b\\\",�:\\u0012��\\u0004��9\\u0011D\\u0002f>\\u0019C��\\u0017cx�u�066&��L�\\u0004L��\\t�\\fv���❯�\\t\\u0017�s\\u001a��5~h���\\u001f}~s�\\u001e����Q��L�'2@����)Y\\u0019Ҋ�\\u0013(\\u0003\\u0005�\\u000bB�Gi>�\\u0010\\u001d\\u001e\\u0010\\u000f�mJ �K\\tR�i�/=�t\\u001c�U=\\u001cL�\\u0004L`�\\u0004�¯�|6��\\r��@�=�\\u001b�_%�aW\\u00031HE�\\u0019\\u000b���G/�\\u0017\\u0001�\\f�MP��G!�8��\\u000b\\n���1K��{�\\u0017�ȳ\\u0005ћ\\t��\\t̝��u��[\\u0003o��Z\\u001c}�AH�\\u0015\\u0014�\\u0018�c�r�&r�')��\\u0000����\\b\\u0004c\\u0001\\u0011ǐ {4z��ۼ̧ͣ�\\u001f�d\\u0017��I�P�.\\u001fL�\\u0004L`�\\b\\u001cv�F��mW��\\u001f�\\u0010��Y5��\\u0013g��g\\u00141�j�\\u000f\\u0014bR�:�\\f(\\u001a\\u00113���y�q�����\\u0017\\u000e&`\\u0002&0�\\u0004N;�(���\\u0017�9�\\u001c��Pv��\\u0019�\\u0006��R�I&H�0�te;���\\b�O����j�\\nl~�3�\\u0003��l�=:Z4)�|\\u0012�i\\u000f�3-��1�|V�Z��]w\\u0001^�⋰JqT�\\u0016+��#<�\\u0015\\u0001#\\u001e�HV�ܔ�,����\\u0013\\u0015��:_Th>3����}\\u0016�/[&^�VVv:�����t��\\u001c�;��tdr���9��#q�+_�g\\u001cw\\u0018����W)\\nTۗ(\\u0002\\u0006�\\u0002�ʒ�EΟ�\\u00039uz͋t~�_��f%�;�\\u0019r>m�G\\u0006�A\\u0001�\\u001c�\\u000f�\\u0010\\u0004��\\u000f��\\u001cxō�����\\u0000�q��\\u0013�\\u001d]�ז\\u001c��\\\"UV��\\u0005�Vh*�X��q����\\u0010&�P�|�\\\\�y\\u0000aE%ٸ�\\u0014Z\\u0017\\bp�y�����ґ�pF:�����#������d.P\\b?�y�Ze�� �r�����\\u000fpѹ��\\u001d�_�e��Ӱ�4n��ɪ�\\r�T\\f{~\\u0011�3�^�E@���m\\t�jP��\\u0004�#0�O�\\u0013�o���B��lhT��7PH�9�~����O\\u0004$�#\\\"�:Ժ�*+��7���W�\\u0014��G�U��\\tB/�Z+�|\\u0004���k�5�|�u��xíW`���?%HU<�S�p\\ti�t�4�\\rq~A2�qǪ�8ٛ^k��G�$5�^k>�����AQ\\u0014�.m�s:\\u0002�H�\\u001c\\u0012\\u0006�C�`\\u000e��!}b��\\u000e?d�����Y=��_�+\\u0000�I���N\\u000b!\\u001d�IL����J\\b�2ʭ&?�?;���\\u001fD��/@�\\u0007��\\u001eg�z\\\\��\\u001eB8�\\u001e�\\u0013h�OO���g\\u00139�\\u0013\\u0014��G���\\u001b���^z\\u0005֭]��\\u0015j�\\u001a��`�\\nٖ�\\u0017����I\\u0001\\u001d�#]�\\u001cr��o~=��%^���_���Ɏ�[r'��\\u001d\\u0014SF�gJ,�D�領22�|���ԯ^\\u0004��\\u001a�`��|=�3kUt��\\f��A\\u0010��%��e�x�3��\\u0007y3\\u0001\\u00130��\\\"�\\u0016\\u0001�^�����\\u0005���\\u000e\\u0003��\\\"\\u00156�{@��(|Xk]�.\\u0001��\\u001c0w�8����S�p0\\u0001\\u00130�% �t�ܰ~-^}�%X�b�^�w�� �b�\\\\��w�\\n@wA�<ȓ�� �A�!R�<����O��\\t��\\t�3�\\u00036��ˮ=\\u001fcE�b�s\\u001e����/\\n�R\\u000b�\\\\�R��\\u001a2�\\u0001?E�_-\\u0002�\\u0007��\\u0010ff�\\u0011�׎9� 8��\\t���\\u0010��YO>�p\\\\��L\\rs3\\u001b���\\u001f;W\\u0000ʘ�*N����HWBG�5��Ҋ+\\\"\\u0006�\\u0015�\\u000b���q\\u0015�D�\\u0003G�����_=›\\t���\\u0010����p�iǍ��]�i�\\u0002Y٪��J�Z�'\\u0002��\\u0004\\b��C$j's�\\u001f�\\u0013\\fo&`\\u0002KF`.'���p��\\u001b��b5��Q\\r��\\u000fh~�\\u001d\\u0000�A�(�xXk��\\u000f\\u0010�n\\u001e��\\u0011���&~m��gc\\u0002&`\\u0002�B�e�O\\u0006w�;��Q�hR%\\u001b��E�<�7��\\u0013�\\u0012Do&`\\u0002&�T\\u0004�~��֭�u\\u0017����!�\\u000f��\\u001d��y��<\\u0000r~����\\u000e��\\t��\\t\\f*��~\\\"�q�������d�|NA�\\u0007�4�#=�g�8�G�\\u000b2���8L��V��Ԧ��\\t��\\t,>��8�W��իV�y���x8��s�^_ԓ�j�s�ʑZ���\\u000eY痉��d\\u000e3��\\u0013�:d>��?�\\u0004L�\\u0004����5��˟�恙��2_�+\\u0000�KjүlYY�\\\\iEH�\\u000b�y�\\u000ff�\\u001f'\\u001c}h��n\\u0002&`\\u0002KD`�N{ډG�c\\u000e\\u001d��?�\\u0015@L�e���N��8:?H\\b�6�%�c���`��\\u0018����\\u0004\\n�&`\\u0002CB�Mg�\\u0005@s��j�\\u001b��\\\"\\n\\u001b�C0Lgg�Y��ҕ�b��#:\\u001aU�R\\\"�8hg�\\u0015M[��\\u0014�[ǐ\\u0002�\\u0012\\u0011�A;+�h�jͤ��:�\\u0014���0\\u000e�YiE�Vk&\\u0005��1�@�D�q��J+��Z3)(��!\\u0005*%\\\"��vVZѴ՚IA�u\\f)P)\\u0011a\\u001c��Ҋ���L\\nʭcH�J�\\b㠝�V4m�fRPn\\u001dC\\nTJD\\u0018\\u0007����i�5��r�\\u0018p�a\\u0007b\\\\�\\u00008��\\t��\\u0012\\u0011���\\u001e|�~8�c��R#^D\\u0018\\u0007����i�5��r�\\u0018R�R\\\"�8hg�\\u0015M[��\\u0014�[ǐ\\u0002�\\u0012\\u0011�A;+�h�jͤ��:�\\u0014�����\\u0002�� \\u0019i@m���yd\\f3�\\u0013����\\u0004�\\u0007\\u00130��\\\"p�\\u000b����jڜ�x���i���cD]�\\u0001��Z�Q�v�.{�\\u0001F��hQ`>\\u0001\\\"�h��\\tG\\u001d�Do&`\\u0002&�T\\u0004\\u0016���]���x\\u0006�e~L\\u000b\\u00002��W�d�@��\\u0006��@�1��]���)\\u001d\\n��o>H�P�ҡ\\u0007�\\u0007\\u0007\\u00130\\u0001\\u0013\\u0018F\\u0002��y&����x\\u0017�#\\u0007w�+Ș��T�xЏTe4��]���o���d>]�ٰn\\u001f\\u0014EZWF��n\\u0002&`\\u0002�N`!O�j�\\n�~�1�%�7���\\u001d\\u0000�����,êz�J;�|Ң'�B���b\\u001a�ظ~\\u001f8��\\t��0\\u0013x�Y��h�yA�!\\u0014���\\u0018���4~.v~u\\u0005 �@D +�JY\\u0003�y@���\\u0000YYT�G\\u001f�a_8��\\t���\\u0011X�3\\u001fv�\\u0006\\u001cu�\\u0001 �q\\u0010�\\u001d0��զ��]jIK���E$o-.JJ�|D\\u0002^\\u0000\\u0004\\u0005�&`\\u0002�N��3O�U\\u001c��?/\\u0000�ܕj�+���-�|\\u0011�[����6\\u001f�����\\n@p�n\\u0002&�4\\u0004\\u0016��<�H�X�,\\u001e\\u0001�)\\u0007s�O\\u000b�Hs�\\u0000\\u0000\\u0010\\u0000IDAT��\\u0005�\\\\��XW *c\\u001e\\u0015��L�#�\\u0000d/\\u0002l��9$\\f����\\u001c��C�%���\\u001f�=��Q�~�/�p�\\u001c\\u0003I�\\u0007\\u00194Ze\\u001d\\u000b�2U.k�O�\\u001fv�\\u000fj9�a�Z�\\u0011 7�\\u0005�\\u0001�'0\\u0007�C)\\u0006\\u000b?.,.�g�|t��? \\u0007s~,:��\\\"�*�V\\\"+�ҥT�����\\\"\\\"l ���\\u001c�~�'�!\\u001c�:��m\\u0017wr1���}�1�`��\\u000fJP���\\u0001�b^����AW\\u0000��Lښ��x/�`�\\u0015rz����&\\u0019뀠�<\\u0004A�9\\b�6s\\u0010\\u0004m��a�?/�\\u00068\\u001cd�y��ϥ����\\r���fU)痀� �\\u0013�\\u001cz�c|l\\u001c�!���5��4v����L�)�Ӥ�{�|vg�L�\\u000f>�8�\\b}d|R�<�3>�i�ϥ�/�\\n\\u0005\\u0005j��aU':?�h���\\u001a�|�\\u001d]\\u001e��\\u0000\\u0007��^�6Ӛq�7i�\\u001e7�ݙ4ŞIc��h�Y��\\u001f��:qM�a\\u0007`~(Trh�\\\"SV\\u0016�X�gN�35�\\u0012˖���\\u0004L�\\u0004F���U+�q��i�MT��9\\u00161��\\u001e\\\\�\\u0002&k]�-sᳮ\\u001fh�M���3��G�\\u001d~>cc^\\u0000��\\u0004L`I\\b,�I�>�@\\f��_�\\u0015@=Y�NR��F5�=�X>�\\u0005�R\\u000eB>�\\t���\\u00108��\\u0003�M��t\\u0005 ����R�\\b\\u0005Yk-��\\u0001)���t<���\\u001d���\\u0007\\u00130\\u0001\\u0013X<\\u0002K{�X\\u0000<��؏�Ո��-��A6q��L�XW��\\u001b�\\u0017���'��*4�!�c||��\\u0002��\\t��\\t\\f9�\\u00037싢���\\u000e���\\u0005@L��o�H�z\\t�JGe{4Y�;\\u001f��C\\u000f\\u000fr����˝DÛ\\t��\\t,&��>\\u0017Il\\\\�F���>�-P�\\u001eMV�K��\\u0011�\\u0004�B��P���'b�'+k>-\\u001e�xLL�/��\\u0004L�\\u0004F������ΥF�j���\\u000b(�_�\\u000f-\\u0000��*�\\n��\\u0002��%\\u00002.c@�����a>h�\\u0002I<��6ż��\\t��b\\u0012�smX\\u0017\\u000b\\u0000͛*\\u000e)\\u001b\\u0013\\u0005�{��\\u0002\\u0000PYu\\u000b�L\\u0016\\nY�G;?�@!l,��J&NqO���ҡ\\u0010飐�����7\\u00130\\u0001\\u0013\\u0018=\\u0002�\\n\\u0000Ҹ?(�C�+�2\\n��J�H!&-�:Y�wx���\\bB��\\u0012O<�+\\u0000���\\u0004L`\\u0011\\t�˩6���1���\\u001f��;\\u0000��\\u001e��f�X�$�*|G;?n��\\u0003\\n�\\u000e��?��\\u0015\\u00008��\\t�&��kV�JzU�\\u0001�\\u001f�\\u0015�(�&��咱FLn�Qf\\fPH��g��S�\\u0002 R�L�\\u0004\\u0016�@��h|llV�e?�/�\\u0019\\u0000T_ᒅ���8A\\u0002\\n\\u0014\\u0011�2��A4(��~j�N��#]��L�\\u0004L`�\\b,[�\\u0007�\\u001a!\\u0007e��\\u0002���\\u001f�6�T���\\u0017�֙Kݨ�1=�ǟ��H�L�\\u0004\\u0016�@?�b٘�SM��4?��%�<���1ɓ�0W\\u000e�\\r�OC��b\\u0002&�X\\u0004�\\u0015���O\\u000bBW\\u0000J�d���ƃ�d���\\u0016\\u000fqii��e���|��\\u001f��\\u0004L�\\u0004\\u0016�@�!��w�|���C��\\u001f��� �����b���G����x|���\\u0017\\u0011o&`\\u0002&0Z\\u0004��ؕ��A�\\u001f�(l4�mP@�̠`\\u001e��m�\\u001c\\u001ez��z�7\\u00130\\u0001\\u0013XX\\u0002���;v�\\u001c������� \\t��\\u0000֙\\u0000�c���+\\u0000�w|4\\u0001\\u0013\\u0018-\\u0002q\\u0005`���\\\"��L�w\\u0011�gVPFz��\\u001a9��L���'��\\u000f\\u001e*#��\\u0004L�\\u0004\\u0016�@�}��\\u001d;�i\\u00139\\f�|QDaɘ�K�aQYk�<�@\\u0006����7\\u001e�����\\u0004L�\\u0004F�����Re�ٍ��ҽ� �R)5��\\\"=�i\\u000e{��\\u001b��s\\u0000i$��\\u0004L`A\\b��>��6�\\u001e�5�\\u0016qY�T�u���5\\u000f-\\u0006��\\u000f\\u001ez�\\tЏ\\u0003��d\\u0002&�p\\u0004�O�\\u0007m�(H�\\u0007�HY�!eK��\\u0005���\\u001c�l�#s\\u0000����o�\\u0016\\u0000\\u001cL�\\u0004\\u0016�@~l,\\u0000H͛=㡾[�qR�&�+?�\\u0015��\\u0014�T��b\\u0000֙C\\u0000Qc��@\\u0004\\u0007��'O�\\u001fO��\\u0011��n\\u0002&`\\u0002#C����0�\\u001bO3>��|R��D\\u0013E�ma\\u001e�a/�a����3|4\\u0001\\u00130�y&Џ\\u001f79Y��?x\\u001c�6\\u0014P`Uj�R��lGW�\\u001d�\\u0014�*�z\\u0007Y�\\r�֕�h�\\u0007\\u0001���vt�\\u0002d���v4R ��>�_�rE*�\\u000f&`\\u0002&0\\n\\u0004���'PVWI�\\f�9F\\u0010\\u0011�ʆ�Nֺ�\\u001d�Lmd����O\\u000b����N\\u0012O���U^�B�F�/E��\\u0003=��\\u0014��V�\\n\\u0000\\u001cL�\\u0004\\u0016�@~�C\\u000f�1_B�as����!-\\u0000H\\u0015V�>ID �ӢG\\u001c�\\u0003 g�\\u000f�W�\\n\\u0000\\u001cL�\\u0004F��W���4NF��ٍ��ҽ��W\\u0000��Aj\\u0011��E��n�Z٤�;����h�Y���\\u0000��\\u0004L`�\\t��\\u0007~�\\u001b߁.\\u0001L3Ob����\\u001f�t�VsW\\u0014^��)���2]�\\u001e�7h1�|��\\u0012\\u0007���\\u0007_\\u0001��\\t���\\u0010xr�\\u000e<�ȣ�ǿ���\\u0010�.�Z\\ti�X��#_\\u0001�B|�MF���uP\\u0010\\u0006�@T��y���Rz�\\u000f&`\\u0002&0?\\u0004��S��ශ\\u0001�/�H�����\\u001c�V�e��\\u0016\\u000f�i�h��Ν;EǛ\\t��\\t\\f?�/<𐮐�=\\u001e\\u0002m�\\u0018\\u001f��G�(����ܩ�ak�k�6�O�0\\u0015\\u0007�I} 0`{��b��\\u0004L�\\u0004�@?~L���?�A��7ռPϣ��_�B7._$��&���B��=���ϋ������4�𗝻&\\u0002�w\\u00130\\u0001\\u0013\\u0018j\\u0002_��7��_\\u0000c�#�y��\\u000fd\\u0003\\u0002Y��Q~�\\u0002P� ��p�*i�O�\\\"=tX�O�A���?d�_\\\"��w���-�^��\\u0004L�\\u0004�@~�?}�k�\\u0007���x�4�c?ͯ�\\u0019\\u0000\\u0015>*�U��\\u000e\\u0019�T4�+�\\bdW�J �ZR/�j��\\u0003\\n�h�ٱ�\\u000b\\u00005�7\\u00130�!&011��~���~��B�OMDV+��J!�v�84x�mm>m\\u001e$;�Ą͛\\t��\\t�5�~��/����\\u0013�qP_�IY\\u0015���������\\u000b\\u0015W�\\nKV����n������k%��L�?�=��F>��\\t��\\u0010\\u0012��?}I�d|�W�\\u0006t~l]\\u0001HU�IM�!t�Z\\u0016��u�����0����\\u001f>\\u0019����\\t��<\\u0010迏x��\\u001f\\\"�\\u0000��\\u000fd\\u0007q~,\\u0002-u�‡U4թ����zaK;_�\\u0003\\u0000aA\\u0004�B[+��K/l�!�߹k\\u0017���㪕7\\u00130\\u0001\\u0013\\u0018>\\u0002\\u001f��g:��5���{%\\f����\\n �Ge�VB�oi�����&{aHVx��hEZ��\\u001e��o�Oc���L�\\u0004L`o\\b��{��S���ڝ/U�A\\u001d���\\u0000@_Y5'�t/�Z�<*�\\u0010������Σp0\\u0001\\u00130�a#���\\u0005LLNjX\\u001c���И�.e�Zä\\u0007\\u0019�\\\\�Z�\\u0003n�%[��\\u001cf�\\u001f�\\u0002 N�L�\\u0004��@������#��b��g3\\u001e��9\\u0014�W��\\bP�d\\u0005�*�u4�yd?�5��\\u00173�񭇿�\\u0017z3\\u0001\\u00130��!���I�_:՗�<.�ni\\\\���\\u0018��~x�\\n�:d[U�\\u001a��O�\\u000eù�������ǟ��\\u0001\\b�7\\u00130��\\u0013�w>������=��4��_�\\u0013]\\u0001(\\u0011C}|�g��h�t��\\u001c�\\u0011\\u000b\\u001b\\ta�����_�\\u000fZ4�+� %����7\\u00130\\u0001\\u0013\\u0018t\\u0002�닻����T�݀�\\u0005�\\u001fd���R\\u0015kZI����Rk��\\u001e��_�\\u0006\\u001cL�\\u0004L`n\\u0004��]�����o|\\u0007�6?��\\u0002�\\u001c��m@�\\u0005�{�����o>i14��|�\\u000fi�\\u001c���\\t��\\t\\f$��;v�>�\\u000f���b�qo��u\\u0005@m�1ZuI�Y\\u0014>V\\u0003�\\u0015�\\u0000a>����'�܎�>�@z3\\u0001\\u00130��\\u0011�W��.�?��S�\\u001e��<���G\\u0011�D���;�&;�h5�H~����Y]:\\u0013Io&`\\u0002&0p\\u0004>����/|-�{\\b��\\u00021ו\\u001a�r�\\u001a��J�<0O���9���|��s\\u001a�J\\u0015��y����\\u0017i�W���\\u000b�4�«\\u0012�\\u0002��\\u0018�5���%.-m>d�\\u0004��̗�}�.8��\\t�@�\\u0013��/>�\\u000f|��#1�\\u0015\\u0004S{0l\\t\\u001d�\\b���Jii�����5����<#\\u001f+��������H�n\\u0002&`\\u0002OC`鲿�������#��+J�H�Ƴ�uliƷd�JVY�8\\u0000���\\n�\\u001a)�R+f+\\b��A\\u0010��\\u0003��\\u001f�&'�#�q�L�\\u0004L��\\b|����W�('s��a�K\\u001f�ǟS��?��(�-�\\u0003i%;�\\u001c\\u001ej\\u001b���s\\u001a�›\\t��\\tLE`)�\\u001e��\\u0013���A<�cǂ��=����5�\\n\\u0000 �\\n�\\\"��M���!u\\u0006r~�!�\\u0002 \\u0005}��\\b�� h3\\u0007A�f\\u000e��mq9<��'��\\u0001ğ��ڸ��\\u00004\\u001f\\u0000��[� �\\u0003l0\\u000f������G��/�au�R���\\u0015�f�p4�@\\u000e�\\u0007��?@a��?�����?M�e�?\\rKa��\\u0018�\\u000bі���R1k��y�N \\u0012P\\b��/�\\u0017\\u001f�T��ti��|$[wz(P�5\\u0012\\u0017(�����\\u0001��\\u001f~��S����7��\\u001aߠ@�sP>��W\\u0000U�eT\\u0005ʔ��4��\\u0014\\u0019(Lmͧ��C\\u000f\\u001f\\u001f���\\u0012��\\u0007u%W<�l\\t$\\u000b\\u0005j��i�\\u000f�A\\u000eM�����\\u000f\\f��˻p���G\\u001e�e��ᇚ����SۖJ�r1�\\u001d���;\\u0000�\\u001d�`����%k%5.�8�Dp1��\\u0001\\u0011��\\u001f��o?�m�w�\\u0013�pq�M��_-\\u001b���s\\u0011�@�v�1��\\u001fzm8��c*���\\u0003\\u000f�����&��x.~����7��-�\\t�\\u001b\\u0018���+\\u0000�{\\u00146�b���L[+A����G~\\u0010n0W�ضm\\u0002\\u0004oo\\u0000\\u0000\\u0010\\u0000IDAT'��7�D\\u000e�&}`Dk\\u001d��k�k#/���ZG^�\\u0017{oz�#/�Z��ȋ�7�֑\\u0017{�{m��ޛ^�ȋ�ֽ6�b�M�u��^�^\\u001by����:�b�u����{�k\\u001dy�׺�F^�鵎��k�k#/���ZG^�\\u0017{oz�#/�Z��ȋ�7�֑\\u0017{�{m��ޛ^�ȋ�ֽ6�b�M�u��^�^\\u001by����:�b�u����{�k\\u001dy�׺�F^�����x��_\\\"~���诱sb\\\"��q'�g��:��9���\\u0019\\u0000��F]\\u0015�\\u0016.\\u0019F�+����)��H\\u001c�,0\\u001fy\\u0003P�H\\u0016�h4�|�3_�C\\u000f\\u000f\\u000e&`\\u0002&��\\u0004v�܅��ÿ‡��3H���du�d��\\u0018�җ�\\u001e=��ET*�\\u001eV,\\u00100j�k�\\u000f�Q����ZCa6��?�1L�Տn��L�\\u0004F��B���o=�����_��-��=�w�\\u001d\\u0000(U�)[j4\\u000f+)�|%@�Z;\\u001f�!.@�=�̧�\\u0002<-��\\u001fy\\f�����\\t��\\t�7����'�\\u000f����\\u0000����\\u0011��3D��@Ŋ���T�J���\\u0007!�����\\u001f\\u0014��<\\u0016�?��\\u0013��g��u}�7\\u00130��%0�5�\\u001b\\u000f���?�\\u000f\\u0012�g��_E)�d��wh��\\u0016\\u0005��-��\\t�\\u000b��=��(\\u001ey�\\u0007p0\\u0001\\u00130��!��~����ï��\\u0007����E\\u0019����?��M_c}�\\\"����B\\u0014\\rdk\\u001e�\\u0003��<�عs\\u0002�d;�`\\u0002&0z\\u0004���Onێ?��O��~���\\u0017\\u001f��>���* Yk�`\\\\��V<�J)ZB\\u000b�d��(��v~�\\u0007�'�I)\\u0010P({x�G�9���=������\\u0004L�\\u0004fJ`�]���\\u0016���1�r���$��%�dKE�P��0��PD\\u0012�\\\"\\u001dKi�\\t��ii����\\u001c@W;���|��\\u0019|��\\u000f\\u0004Y�&`\\u0002&�!011�O}��\\u001f���K��{�O~\\u0011�b��:o��ގ_���\\\"�i.k.\\u0014�\\u000e֚��\\u0005��\\u0007ѓ��\\u001f�?|��p0\\u0001\\u0013\\u0018~\\u0002{�aL�_x�!���>����\\u0000��\\u0017\\u001f�\\u0003\\u000f}g�ǥa�'�_\\u0001 _\\u001e�m^��Òr����\\u0007?��|�{\\u001a\\u001b�g\\u0002&0�\\u0004��؉��\\u001bx���\\b����\\u0001��'\\u001f�'?�U�ؑ/�{�����`r�RS^��FBX��6\\u001a\\u0014��Xp1����޿�G��?~>�{7\\u0001\\u0013\\u0018J\\u0002��?|\\u0002��K������������x���-��_�Ν�T�\\u0018w=��qW8�\\r����C\\u0017�q��\\u001b��l�b-\\b%̃Ha\\t���|�S���l*�\\u000f&`\\u0002�C ���\\u001f<�$���\\u000f���|\\u000f_�����g������w�o��>��{\\u000f��o�)ޣK�\\u001f��W���\\u0000y����9\\u0000�.\\f�\\\"}�>�V��9dg�3\\u000e\\u001f��O�~�w�S�E��9�\\u000f0\\b�����\\u001f�?�Ɵ������y?~��%���_�s��������}lߙ�W>�?K3�\\u0014(�ľs\\u0001��<����\\u0016r���к\\u0000�����\\u0010���\\n�|:5j\\t�V\\u000f\\u0018����\\u0010c`��\\u0013d�\\u001f\\\\bP\\b+ �#\\u00105\\u000f��!\\u001c�\\u0016��D��+ƕ�c\\u0019��a�\\b?\\b\\u0000�?�\\u001f�\\u0010\\u001a6�xAJ\\u0003�%�,���+\\u0000�ɢ\\u0010ȍ��?Oj�Q\\n����28_�\\u0004\\\"8�\\u000f`�����\\u0002�\\u0017��BA\\t�c>�?�\\u001d�\\u001f�-\\u0004\\\"���O`)LJ\\\"N\\u000e��U<��N��TH(T���������:�����+)t�� B�_Bh�h�\\u0011\\rt�\\u00059��4\\u001crr���'\\u0011��KR�\\u000b>P蔫n'�Ŷ[��\\u0003\\u000b*\\f�/>�w\\u0000��c��Z�\\u0001�H��\\u000b�d? ���35\\u00172��O�@Nm\\u0007���?u��9}��\\u0014�Fk�����*W\\u0019�V$��J�Z/K���W\\u0005�o~r���/��6\\u000f��6\\u000f�G���������P���\\u0000\\u0000ir�\\u0002����ꠍԊ$Y\\u001d���\\u0017�\\u0016/�}-�|�x�O�\\u0007\\u0014�n$N�_�\\u000f�\\u000f�\\u00029\\n�+Z�u��y@�r\\u0003qZ��!?\\u0003�\\u0013F#�T[>i%\\u0010.���FX�i���CW@�-:�����q\\u0006��B��\\u0007�HS{�X��#-\\u0000�\\u0010� Sm����V��\\u0015�ḑ�\\u000f��\\u0017�1\\u001f�B�����\\u0003#�b�s��\\nm\\u001e��?�\\u0002���@VŨ,i�Z��@��y�@�\\u0007��A4��\\u0003i\\u001e�Pq �#p�?�\\u001f���\\u0013\\u000f-\\u0000T�R+\\u000e\\u0015.\\u001e�K\\r���G��\\b������\\u000f��\\u001e�\\u0017�\\u001d����������\\u0007y�����������\\u0005@�qH5�ڇ̍C�v>��\\u0001-���|�&\\u000f�!�<�\\u001fd����l��M\\u001e�\\u000f��ca�#�\\u0015\\u0000\\u0014H���2y�S��E\\u0000�š\\u0004\\u0012\\u0007(��e\\u001e�?�\\u001f@�tp���\\u0007��\\t\\u0014���/���lWy��.\\u0011Mӹ\\r�ĦNέ4�\\u0007\\t��f>q�7�c������h�B����|��O��x�?��\\u0001\\u0005������?�]W\\u0000�(\\u0014\\u0002�\\f�:\\u0016\\n\\u001d]��u\\f)�Y�#:�G\\u0015Ȝ��p~�\\u0003U 3�|��Tox\\u001a�O\\u001c�\\u0019��L\\u0006\\u0018��{�����]�$m]�|�@�G��̓-4I@����غ<���u��2�9z�\\u0011��\\u0017�S�S��av|g_��}~�?��5��R�}�s��Y��Gi|�����Pn�.���\\u000f��V�(z\\u0016\\u0000y�\\u00120��Y]\\u001di@W;\\u001f\\n]\\u001e\\u0012ں�|���/������P]��w�5�i�ú��~)*L�0s�;���1�z������\\\\�;#\\u001e���zh�\\\\��>cX�\\u000b\\n�/u��$\\u001e-t��Q2�\\u0003tmN�j����\\u000f ]\\u001e�`~�-�!G\\u0006����\\u001f�م������?�zgJd~e��uzm�;��\\u001e����͟t�“*\\fK���G��\\u0002ߊ�H\\u0014�k�T��F�v~����y\\f��^�\\u0002G\\u001ez\\u0010f\\u0013\\u001e�ޣ=�\\u0001=���?�z?Vճ�����n��]���\\u0019��g.�[���\\u0011�?�C�O�vq�p\\u0002�*0�\\u0007�x�2\\na���\\u001cF��I�\\u001d�I��M��w��A��zϼŇ��\\u0007�_]~�qj>9�\\u0003\\u001f(P���� �c\\u0010����y��\\u001f�tܑ�M�����|����/���Z}Xڛ\\\\�q�����Ds��u/��F�Ǣ�@n�dc�����]�w8�\\u000f0��\\u0011?�J�\\u00000���}\\u0013;wM�\\rJ���\\u001f\\u0006��U��z\\u000e���K�\\u0007��h��\\u0001l?��1��e��(\\u001f����\\u0002K�*@|�Dr\\u0012ٴb��u�u~���í�d>b\\u0013[��9���}���|��_\\r\\u0003\\fp��U�Q��\\u0000�^�=��G\\n��~�y��\\u0001�\\u001f�0\\u001c�\\nPs>P�:!\\u0016\\u0000@n���\\r�\\u001c�\\u001f\\u001c�'�\\u0001*?\\u0011\\u000f�ӥ٣�쾾\\u000f��Y�z���W\\u0015�e����P?\\u0000\\u0018\\u0019������}P?\\u0015��;��(�>kVaO�v�����z?���>��.�T�De�\\u0000�����؂�t��|ty\\u000f�@a�ڷ�\\u0000(�@T>]U@�\\u0000�\\\\���t\\u001cը�\\u0017\\b�\\u001e�\\u001bl�8儣�1g��K��_�A���_�)-����8�h�vv[�{P�i��^�7�A��Ҕ\\u001f\\n�/q����M@<�`|(ټ\\u0002@|.&�p\\u0012(��BJ��6\\u001f�\\u000f�\\u00029��06Vร\\u000fOu��a�Ν���\\u001eJ/'\\u0007��Q�㏙[�\\u0007���ޣ��rӁ�W�?5����\\u001fo8����[\\u0000,?�`W+\\u0013}��Iur��\\u0001e�%���\\u001c\\u0006��qG\\u001d��˖a6�_\\u001e�:&'&s�\\u0007���;r�������\\u001e����.j�}>�-o\\u0014�\\u000f@!/\\u0000&v|Y�(m��^\\u0003@����BSk0p~^�\\u0001�\\u0005\\u0005��\\\\�Ώ&\\u000f�i�%��S�?&J3��s������,a�\\u0001��\\u001c��\\t���g��z���0���S{G���U���s���7�K�C�\\u001f��H�EpBg�\\u0017��!��?v�\\u000f����͟JW�0-��Nf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ\\u001f��|\\u0000\\u0000\\u0010\\u0000IDAT�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒ�F�d��,Yid{���X�n\\u001f��̷o>�\\b~��SzC\\t2�\\\"m��Nf�ʒ�F�d��,YidKf�ʒ�F�d��,YidKf�ʒY�an�~��\\\\o \\u000e�-*KV\\u001aْ٢�d��-�-*KV\\u001aْ٢�d��-�-*KV\\u001aْ٢�sn��d�yȖ�\\u0016�%+�l�lQY��Ȗ�\\u0016�%+�l�lQY��Ȗ�\\u0016�%+�l�lQY��Ȗ�\\u0016�%+�l�lQY��Ȗ�\\u0016�%+�l�l�l�l\\u0007���+���[_��ǡ��\\u0000(��d݈���yp�8��O���\\u0007\\u0007���&��6\\f흾�y\\u001cW�k�n\\u000e�\\u001c�Q\\ti�,\\u0000��eJ�e���2�JG���6��Kmͧ�����#\\u000e�l�S۶�o|Ko��\\u001e�о{S�A��t����\\u001b\\u001e��]��Cͣ�N�\\u001f��?����|��Bw\\u0001��t�C͞-\\u0010VW\\u0006��B贲�����/ͧ�\\u000f��\\u001f'\\u001e{$�p�/|�k\\u0003߾s����ރܿ�V�v{\\u000fr�=>�h��9� \\u0006�lK��\\u0014\\u000b��|�S���Kj�R�51�'k\\u001d\\u000fB\\nKx�\\u0006E�\\u0018T\\u001e'\\u001e{\\u0004f\\u0013JU��Z\\u0000Ȩ���\\u0001�\\u000fs��W\\u0007���Vﯥ\\u0007Y�����1��e���\\u001e�\\u000f>��׾���B�\\n�֭['Q���$\\u0017���\\u001a\\u0013J(���E ����a>���ydKf\\u000b-*�*��\\u00036bߵk0��\\u001e�S�v�\\\\��\\u00077́���\\u001bU������������&��\\u001c�G��~p0���\\u001f4����fqŴu\\u0016\\u0000���\\u0000?HF���\\u0016Ӥׯw����'sY:�8���0�н�?��7?�\\u001e��ϭ������i�9L�>x|r��]������<{m\\u001f�\\u0005�\\f��Z\\u0000L��\\u0007u\\u001b@�i\\u0015\\u0000Ꟶp\\u0004\\u0007\\u0019�`��DB\\u0007Q\\u0019\\f�r�\\n\\u001cqȁQ�\\u0019�?x�\\t|���\\u000ed}���ª9���Qo���9�\\\"ae\\u0006E�j{\\u000fJ�`��i��;Yh�G7\\u0014ݨb���HV��.[��O*#F�Њ���[��h�0�6��񏓎;R�\\b?��g�ş��\\u0003`z�ޡv�����s�w^�D}U�?\\u000bEK�g���{0����\\u0013�L~���l�_�\\u0017-\\u001e��G����5ק_\\u0000TkV�\\u00037F3�x���\\u001f�#�{L��B\\u000e^��z��V����o����u��cɥl?������\\u001e\\u0000�r�k4��?E��n\\u000b��U�]Q\\t 7&*�B��R-��\\u0013'�I����6�������۟�u�}i�\\u000f���'��O\\u001e\\u0011a���I�\\u001d\\u00195��\\u001e�\\t�߾����\\u001f����;�1��\\\"L�\\u0000\\u0018\\u001b\\u001b{�\\u0018�L^!�]�Ohi�\\u0016-�|�x�O����G1F\\u001cw�a:�̷\\u001d;w��\\u000f>�7��[������w�������n�\\u0012�އ�\\u00023�voo����w�����-�Ύ\\\\�n��5�ֽ�\\u0003�\\u000f�^.�V�\\u001c�SW�wNNN��޽�6�\\u0002�m����%�?!���&��N\\u0000)\\\"\\u0011!\\u0019F���}���#\\u000f��e�<3=|�k_��d9��9�z���\\u0000���=\\u0004�\\u001d�Myl�\\u0003�~.?@\\u0012\\u0011\\b�\\u0001YYTv�Z#ڟ��~���\\u00077\\u000eS.\\u0000r~񮲄N����#P1k�H~0\\u0000�����l����k�����9��KU�\\u0007��ϩ�C����\\u001f�o\\u0006�\\u0012�!-?��)/���8�\\u0002`��?ւㇺ\\u0012\\u0000R�\\u001b�>�f@im\\u001e������7���}׆�x��w\\u001e��O>U�k0�}�\\r�w����$��\\u001e����2����/J{������t\\u0003�\\u000b�{�����#Ӭ�&}i[�\\b��k?��\\u000f�D��◿���z���zGK�l\\u001f��~:X�|��D_�\\u0006�U�z���zŴ\\u000b��\\u0006B�\\u0001J@m\\u001b�˶��|_ \\r�·8�O�\\u0003���,�,\\u001b+\\u0010?\\u0006���x{j�v|�\\u001b�F.�`�߲��9�����VUo`\\u0010�?�����������o�7�i��[�P����?\\u0014��\\u00008��y\\u001fI��z�\\u0006s\\u001d\\u0001V���h��`�\\u0002�����A����9��#0�I!����\\u000f��U��=�R����\\u000f�S�ɪ�Y��c��?~/ۛ�N\\\\\\u0019�h�ʨ��+\\u0010�I|��*m>\\u0015��,-������>�֙v��\\u0002��t�.��\\u0003�\\u0011R9FŠе\\u0012ں�RЫj�\\u0014����n#\\u0015�%\\\"P��\\r\\u0005�\\u0012\\u0011�Xۆ�R�\\bT�mCA�D\\u0004*ֶ��T\\\"\\u0002\\u0015k�PP*\\u0011���m((��@��6\\u0014�JD�bm\\u001b\\nJ%\\\"P��\\r\\u0005�\\u0012\\u0011�Xۆ�R�\\bT�mCA�D\\u0004*ֶ��T\\\"\\u0002\\u0015k�PP*\\u0011���m((��@��6\\u0014�JD�bm\\u001b\\nJ%\\\"P��\\r\\u0005�\\u0012\\u0011�X�b�?�\\u0013+�/~������ KD�bm\\u001b\\nJ%\\\"P��\\r\\u0005�\\u0012\\u0011�Xۆ�R�\\bT�mCA�D\\u0004*ֶ��T\\\"\\u0002\\u0015��l�]o��T�\\u0006�Z�Z�@�\\\"\\\"��H�r�\\bT�mCA�D\\u0004*ֶ��T\\\"\\u0002\\u0015k�PP*\\u0011���O�\\u0019\\u001f�OKF�ֽ��A�\\\"4��T(��@��6\\u0014�JD�bm\\u001b\\nJ%\\\"P��\\r\\u0005�\\u0012\\u0011�Xۆ�R�\\bT�mCA�D\\u0004*ֶ��T\\\"\\u0002\\u0015k�PP*\\u0011���m((��@��6\\u0014�JD�bm\\u001b\\nJ%\\\"P��\\r\\u0005�\\u0012\\u0011�Xۆ�R�\\u001c��$uI!���{\\\\\\u0000�\\u001b�e꿔(w�G�'e[�$�ڭ�\\u0002m�i���;�ͫ�R[��[>\\u0007n�\\u000f�\\t\\u000f~�a<�}{巨l�\\u001e������u=��~�/v/���{p럹,m�5?T�f��q������љv\\u0001\\u0013���\\tO�\\u0000���/�\\u0002��-�AZMh�\\u0007li\\u000e@��Ax*f\\u001c���p���=�F\\u001f��F��?��\\u001f�\\u0013�����W��\\u0017�4�i\\u0017\\u0000��ſW�b�\\u0007-\\u0006��)!-\\u000bXS\\u001c̣_��;�}\\u00143\\r?|�I|�;�\\u001b���u�\\u001f�zc����=$��/������\\u0016}�\\u001e\\u001a��ɟ�\\fŒ\\u0016\\u0000o��\\u001f�W�?��:>\\u001b�7��iVeX�G�SF\\u001f��W���\\u0019�}~�?!�o�\\u0012\\u0018\\u0002~`�������dT�{X�oo���j��\\u001f���?\\u0014f�\\u0000��b��o��`\\u0019�L�=�3\\u00072[��\\u001c�l\\u0017��W��-|���������>�������[��=0�z�K�>�]�Qmo�?�o��ӟ�ە�vN<8��j<}��\\u0002�\\u001d����*҇b% �>9ٸ\\u0012�\\u0014P�d��F\\u0015R�^��Ғ�V4m�NV)�:_$�V�HVIɚ�H����}\\n���ws�\\u0014��������g��ÏLQ㜔���O�z+��h�j��R�\\u001d\\u0000���Y�w��\\u0000�OEM[*��#Y�$+�h�j��R�u�H�摬��\\u001d2>%�\\u000f��\\u00157���7�m�\\u000b���ɲ�7��D\\u0017\\u001d\\u0010��B:�:қ��@����y@a>�<�m;>�7\\u001fLJ?�O��C�F��O�����?��x���5�\\u000e\\u0018\\\"��z�������`U�H�z�mU臨vh�\\u0016��\\u000e.��\\u001fm;��\\u001e���o��dC��/���\\u001d$�W+�S�\\u0012�&�L�Y-\\u0000����߫\\u000f�\\u0018�{ފ0�\\u0016:�h�W�,\\u0011�\\\"���!A��B�v���{��o���=����?�\\u0010��#��W\\u001e|\\bd\\u000fo��\\u0001Ϗ�\\u0001Y��=U��\\u001c�V=�\\u0001ɞ�*}\\u0018������i�!�\\u001fU�an?׏Ѽ ��?�\\u000f�\\u001c�>g&�Y-\\u0000H�d����\\u0000%r���凬uY��\\u001e\\u000fj�v~�d~\\u0015\\u0007M��\\u0017�����#sp��8�y���mm��U�-������c����_�fV\\u000b�x�·��\\u001d�_���\\\\T1����K�K\\\"l\\t\\u0005�\\u000b\\u0002��|�� �\\u001f(�\\b\\u0002�\\u000e�\\u001f�\\u001f�\\u001c\\u0001)�_4y���&��Tk�/�����w�6�ì\\u0017\\u0000[�^�\\u000b�;��P 5�k�c-\\u000e�!7\\u0010\\u0007�\\u0003\\u0004\\u0002\\u0011H�o��!?�C��C�?T\\u000f�Gn-\\u000e}О�ȷPW��$����Y�>��m���]���KB�׉!�dX��)���t;�y�~����\\u0011�dt���\\u0007�C��\\\\�A\\u0003�{������?�Ü\\u0016\\u0000q\\u000eb��j�\\t��u\\u0019�zϗ���|t�\\u000e�\\u0007�@�0\\u000f9��@G�_\\f\\u0002�('\\\"��.�@���\\b.�Ƀ���1�8�\\u0018�\\u0000��UW��/�ʅ�ɧ��\\r�����$\\fhq��y\\b��\\b�����a��/Ė�A��\\u001dq��b�?���k?/,s��\\u0000��\\u0015cx\\u0007�GT\\b4\\u001b�����8?�!q2��\\u0001\\n����\\u000fb��擮0z��ch\\u000b\\u000eK�\\u001fH�\\u0018\\u0014H��V{�IK/ ��xp|�?�S�y۫\\u0005�=w_�C��|+��\\u0000�%��\\t�#�\\u0001��yd\\u000ed��9�ٚG�@fk\\u001e�\\u0003��yd\\u000ed�(�%��s���W\\u000b�8��^y��k\\u0011�X���\\u0001*\\\\�ʍ�3�\\u0011V2�+!�L��\\b-��2B�d���2Y+0�� \\u0000\\u0000\\u0010\\u0000IDAT#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y+#�L��\\b-��2B�d���2Y\\u0013�*!���I�t%4�%S��5M�Z�\\b�|�\\u0001>�\\u001e�\\u000f�{���\\u0002 �{r���\\u0013d��j��B&4�\\b\\\"��K$�Lh i@&t���\\u0013��\\u0001\\b\\f\\\"��S��$\\f�Cd�K2�S��$\\f�#��\\u0001r��)�|\\u0012��\\u0001\\b\\f\\\"��S��$\\f�Cd�K2�S��$\\f�#��\\u0001r��)cA����\\u0018�k0\\u000fa^\\u0016\\u0000�\\u0003��:DA�W��e\\n�X\\u0001F�R�o\\u001e�\\u000f}\\u0003p��0��aw�s���Nj?��\\u0017���?4¼,\\u0000��1�C��H� \\u0015� ���J1R��a��&AQ1\\u001f�?HyB�\\u000by��O�?�?<~�t�\\u000b�x$��\\u0018*�c��\\u0005@<�0��+�;J���+|������\\u0010\\u0007��p�4���o~\\u0000�i�\\u0004Ӥׯw��\\u0007\\u00009����N�h�/��h\\u0015�S-uE̵��0o\\u000b�(��_~�G\\u0001�C��کH�\\u001fD�EA����.�BUzz=z��r��\\u0013*�\\f��\\r�\\u001f�\\u0013y���\\u000fx|(� �C��\\u000f�у�_��r�+��{�c��\\u0005@���W\\\\�\\u001f4)�/�ǨĄ\\u001b�XV\\u0016��z��P �t�d\\u000e\\u0000�?����C�\\u0019HǤ�E���\\u0007r9\\u0000��Xl���1�����\\u0002 J7>��VM���^���-=V���ԭ1%\\u0007�\\u000b\\u0014Ÿ�\\u0007S�\\r\\u0014�'\\r����E�\\u0001S��`�\\u001f��>>y\\u0007\\u0016 ,�\\u0002`˝W~�`�E@9���R�;V��V�Z��� ���jN�c>��T�Em�\\u001f��������9�����3\\u001f`r���[��Q�i޷\\u0005Y\\u0000D)�y���\\u0005���\\u0004�\\f(�lY�Z�Z�M^�C6y�?�&\\u000f�\\u0007��a� �<�\\u001fd�ǀ�\\u0007��o���\\u000f`�‚-\\u0000��ۿ�\\\"��R�\\u0000Y\\u001d��x�47B)�Հ�K�HW���C\\u0004H�C�!\\u001a��y��\\u00019�\\u0012���o��\\u000f(?\\u0010s(\\u00160,�\\u0002`�֭���ح$��\\u0016\\u0001�\\\"%���!����\\u0015�ZN/�0\\u000f�\\u000f�\\u0007�p���\\u0011����oO��9���\\u0006\\u000btX�\\u0005@�9�\\u0007`�k���Bˆ�K�V\\u0004(\\u00122:2��\\b\\u0004\\b�\\u0006\\u0006Q��\\bP$dtd\\u0018[\\u0011\\b\\u0010\\\\$��0p����\\u0012�V���m�����\\u000bxX�\\u0005@����_��I�7\\u0002ܥ�� \\u0007�����6��J�\\u0011��|\\u0011k�\\u0000�\\u000b\\\"(�|̧�\\u000fr\\f����t���#�\\u0000\\u0011�\\u0017���V��\\u0011AV\\n�G\\u000e���Ȧ\\u0004Y),(�\\u0012�\\n\\u00167Ɯ�E\\b��\\u0000�z����r�N1���H\\u0010E�\\u0011�\\r��]ɄM\\u0011�;�=�У���\\u0017�i��y�?�\\u0010\\u0005\\\"\\u0002\\u0015��?�\\u000f��\\u0000y\\u0005\\u0011���?�\\u001f�\\u0007]����@�\\u0016�?����m_����\\u001c��/�\\u0002 ���b�ZI��Ԥ/\\u0002\\u00017*OZ��\\\\��\\u0003�y�GcP�?h�0\\u000f��4>x�Y��G���m_��Θ\\u0013S�.�aI\\u0016\\u0000Qǭw^�M�u���L*�n-\\u0018:�s�Z�\\t~���-��6/�i��y�?�<�\\u001fm\\u001e��6������w�֭\\u0017�ҩ�d[�\\u0005@�6��Ɠ��.ʗ�g\\u001d)�l�l�i,�|\\u0011�����V�\\u0002�Nw~�\\u0002d[�赀�E��5�\\u001f\\u0000�3E���\\b\\u0018�|�\\u0006n�i�\\u0011\\u0010\\u001dv竺�k\\u000bL�O|\\t��4ρX���\\u000b���\\u0000<��\\u0013g)�\\u0001}ї�&�:�:(h7\\u000fA����\\u0003T��\\u00039�G�`H\\u00180\\u0000��C��Ug�܇%\\u000eK�\\u0000���ĝ�?�ꉱ˵p���x��3ˠ�a���������\\u001f�!\\u001b&�$��?�\\u001fM\\b�hj��L�\\u0003\\bnџš\\u001f\\u0010\\u001c��\\\\�G��_�\\u001e\\u001f���W]�\\u0018� ��\\u0002 8�؏]������^p�ԝ\\u0011y\\u001b�vXi�K1y-������\\u0003��\\u000bTV�| \\u001a]\\u001e�?m\\u001e��6\\u000f�G��\\u0002�ǯ��Ϋn��\\u000e}\\u0012�f\\u0001\\u0010<�a�{�����ߡ1\\\\>Y��d����Y+_�q�:?\\u0018��\\u0011k����)��դ ����a�\\b����#(\\u0004��\\u001dr�p�3��?Dl�����r�Uw�,c�뗽�\\u0016\\u00005�-w]��'K��\\u0010�\\u0004\\u001fᄀ�i�\\n~�F�Rǰ�7���\\u0012�G�c֬5�v~p\\b\\naK�\\t+m>\\u001e_D@�P\\u001d�/�\\u001f{3~h�0A��-w]�S��З\\u000b��t��W���-��]\\u0010� \\u0015^)��씊�te��\\u0012m\\r����<��6�6\\u000f��ͧ�\\u0003hk�i�\\u0000�z��\\u0010\\u0011�\\u001ax�-E��!�z�\\u0011 *%2�-!Et4�}�,��r�U��ľ��v\\u0001\\u0010�t��]�\\u0017s�VP�\\u0006T�UrPΐ��C�\\b���#���!���C�`�\\u001c��\\u001f�\\u00180y�w_��\\n����\\u0002 ��s�U\\u001f(Y�E��1ׁjL�u��`P\\u000fq��`�������W��'&�3��u͇�2}���\\u0002 �m��/�����+����\\u0018�ӓ���d\\u001d�H����3\\u0007�ArwN�c>h�\\u0017������\\u0001�����~�\\f}\\u0019m��\\u0013�?���C�w�]W}\\u0019\\u0003\\u0010\\u0006b\\u0001\\u0010\\u001c_���vn�몷��e\\u001a�\\u001eL��*a\\u0005=9i���L�_���|r\\u001f������1��\\u000bR������������y�O�\\\\�\\u0001\\t\\u0003�\\u0000�yn���?ߵ��.Q�>\\u0010O��Q��,�TiDȖJ)�/\\n\\r\\u001e�G)¢��\\u0015I��^[(G�::��\\u0013� ?\\u0000�? BC�Qj��-�J%��)R�����\\u0002��\\b\\fv�»Ʊ�{���\\u0018�0p\\u000b��{�K����;������Z�?^�T��3Mi\\u0019�,�\\u001b�)l$�\\u0015\\u0005���7\\u001f��\\u0001�?S������A��7\\u0003�g�vv��޾e������;��9~�V/\\u0018�m \\u0017\\u00005e]\\r��%��\\u0011\\u000f\\bF�V\\u0003ἀ�E�9Z\\u001ah�G\\u0003ί���懮?)��a�A����y�?�<��?J|b\\fx�&�w�f\\u0003�\\u0015\\u0003[����\\u0013\\u000f�>��\\u001b��\\u0003�H�v\\fNꃰ�y�\\u0011�\\u000f�\\r���\\u0004���0`7����L�\\u001f$@�MO\\u001c���7\\rȃ~�C\\u0018�\\u0005@�-~B�-w^�\\u001fu\\u001f�$�ﯓ,!A�hLR\\u0011k\\u0004\\u0006�(�����\\u0011~\\u0010�GX���!�`O��K���,��\\u0013�rǕ����\\u000bvū\\u0007}\\u001f�\\u0005@�\\b[��[Z\\b����\\u0005@�\\u000f���`_�\\u0005$�ǭ��yd?�c��\\u000b�0��\\u0003�\\u0003H�\\u0007�A�?h��������+o��.}X���lC�\\u0000�[��w]��'�����\\u001b՘?�2�b\\u0000jK i\\u0000��]/H:�\\\"�u�봓���0\\u001f���\\\"8�7d��\\u001f�\\u000f@�F\\u001eW\\u0014��\\u0000@�?:<�a�#s���v���|O\\u0013�k\\u001e����x�]W~DE\\u001e�m(\\u0017\\u0000�J[�n�|��W��\\u0002ҿθ-�\\b�Ie�M���\\t]\\u001d/R�;?��r�9�d�$G�����\\tˠ�\\u000ft�\\u0011\\u0011F��%��Y�*Nx˝W�טK0�ah\\u0017\\u0000u{��\\u0002w\\\\��\\u0002�/ �\\u000f9�٠k��J�j\\tm]�|�h�\\n��6\\u001f(t�EB[W��p4�%\\u0014\\u001a�|���\\u0017\\tm]m>���Ph�y�S�e\\u0001]���7����a���/\\u0000����\\u0002G�~�l��K��H�\\u0013n\\u0013��L��0��l�j�t�w���\\u0003�R{���F��gw.p�A�E?�G�\\u0003U{���/\\u001f?��y7=�տ?�/\\u001b����\\\"��\\u0002 ���g�\\fB�(�8���D�\\b^Dȡ ��\\u0017���*�\\\"Hݙ%�\\u0007#$ٙ]-\\\"4V2���\\u0006!x�E\\n\\\"��ҊփTQ�7cQ�ޤ��HAJA�T�\\u001c���;;���H��iI�}�����̐����y�̰���le��\\u0004\\u001f\\r<�gS��Q�\\u0003��\\u0001�LR]\\u000b�>�V\\u0004Pk�\\u0004!}n��r��u�!6�z<\\u0000�CA�xp���\\u0001T\\u0017\\u0005\\u0007!���X�\\u0014\\\\\\n+IJ!|Ί�s�z�p�D\\u001f�̷�e�V�\\u0002`���7�ϲ��S��q�}\\r�/�Pe��\\\\t,\\u0012D}���x�C��\\u0001�<���\\u0017���0}��\\u001b�\\u000f�%�]جWw4\\u001bѧR�V�\\u0002`�3�lTOQ\\u0004OR��Y_��\\u0007'{�a�rЋ�Z2d\\u0015\\u0000��'-�a�qa�i�A��`wV�>�6�_1�K���\\u0000(�~3\\t���ў ���;\\u0001�Mۙ��\\u0015�\\u0000�\\u0006�\\u0003`\\u001c�\\r0\\u000eİ)�\\u0002��6��O8���'4���n�>\\u001d�L\\u0000\\u0000\\u0003,IDATHi\\n��=�i\\\\=�%��_�[ǜ�\\u0014��B �o}r�=�E+���ٰ�\\u000e��\\u000f���At��e���\\u0018\\u001f�A�y�>|\\u001eW\\u001f?L�+\\u0010������n\\u001bk&����'m[J�\\n\\u0000����V��f=���@Ս��!�GQ}�-��{8�}Y�۸�\\u0011��\\u000f���\\f4��\\u0010���\\b_���x�DQ�\\u001e\\u001d�\\\\>\\u0000�6\\u0007\\b��\\u0000\\u0018��Cl6�&�H�� �����r�@�`-�\\u0003@��x@n�k��\\u0007|k�>\\u000f�����\\u0003�}���\\u0000|���<\\u0000߿����4���9K��x�Gs6�l�\\u0002\\u0001+\\u0000�\\u0000h��|�̯Y\\u001c��%�\\u0007\\u0019P�\\u0001���\\u001d\\u0015ȅ��]�m;��V[v�~��81\\u0019\\u001f\\u0011�\\u0007�͊�8\\bkj�����C�dO\\u0017E�\\f�C�\\u0005\\u0000\\u001f�g�H�u\\u001a�\\u000fdI�8���>[�A�\\u0014\\u0005�:x��W�Z��4\\u000e\\u000feq�p�{t�l��� x���\\u0017@+p'�Z�-�\\r�_�\\u0017��\\u0001r1>�N��EaM\\u001f���A\\u0019��3���@fV\\u0002�Usm\\u001a�1�C�5&~\\u0012kC\\u0013�\\u0002`ht��q�L�[��G���R�D������a�9y\\u0017�9�q��1�'u�\\ti?�;�m��1=�bπP\\u000e\\u0016\\u000f����N�,\\u0002\\u001cd2|a�%w�B�3���jѱיc)\\u000b[�\\u0011�\\u0012\\u0014\\u0000׈�\\u0010���\\u000f��H\\u0018�B\\u0011o�z�-n�NH�S�=\\r�\\u0005V\\u0003G(��Lr��������2�\\u0019\\u000f�\\u001e4\\u000e\\u0018\\u001e\\f\\u0017�\\u0003���p�'�4\\u0004Gy^\\u0017�`�%�G57j���pGZ\\u000b������_�~��n��\\u0010�\\u0002`c���Qg���L�ʙ4�\\u001e�K*�i\\u0012��Z�+K��VƂ\\u0011�M�0\\bx�@0\\u000b�M\\u001e�@\\u0000\\u001c杄����\\u000f\\u0004\\u0017��2}��] ��7\\u000e�\\u0003�x�u��e楋\\u0000΋�\\u0012��I���\\u000f8Np\\u0002\\u0004�\\\"�F\\u0010�h��ݾ�\\u001a�E^���5\\u0017W\\u0012��4�\\u001c�����(֮+�\\u0001\\u0000\\u0000���gs\\u0018\\u0000\\u0000\\u0000\\u0006IDAT\\u0003\\u0000��d\\u0014ɱ�W\\u0000\\u0000\\u0000\\u0000IEND�B`�\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":7,"id":"coder_write_6","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":7,"function":{"arguments":"{\"path\":\"client/public/icon-maskable-512.png\",\"content\":\"�PNG\\r\\n\\u001a\\n\\u0000\\u0000\\u0000\\rIHDR\\u0000\\u0000\\u0002\\u0000\\u0000\\u0000\\u0002\\u0000\\b\\u0006\\u0000\\u0000\\u0000�x��\\u0000\\u0000\\u0010\\u0000IDATx\\u0001�\\t�%�u���wOςm�c��>�\\u0001\\u0002$\\u0000�\\u0000H\\u0010$��;)��%�\\u0011eI�eY\\ne;���r�8J�|��|�\\u0013���ټ��'K�ES�H�;\\t�\\u0004�c�\\r�u��>ݕ�����{o��_��n�oWթso�����瞺U���-��k�W3�\\r�\\u0006l\\u0003�\\u0001�@�l`\\u0002N&`\\u0002&`\\u0002&`\\u0002\\u001d#\\u00008\\u0000�\\\\���&`\\u0002&`\\u0002&�\\u0000�6`\\u0002&`\\u0002&`\\u0002�#\\u0010\\u0015�\\f@P�j\\u0002&`\\u0002&`\\u0002\\u001d#�\\u0000�c\\r�Ꚁ\\t��\\tt�@��\\u0003���[\\u00130\\u0001\\u00130\\u0001\\u0013�\\u0014\\u0001\\u0007\\u0000�jnW�\\u0004L�\\u0004L��\\u0004��;\\u0000hHX��\\t��\\t�@�\\b8\\u0000�Pc��&`\\u0002&`\\u0002]'0[\\u0007\\u0000�,�g\\u0002&`\\u0002&`\\u0002�!�\\u0000�3M튚�\\t��\\tt�@�\\u001d\\u0000���\\t��\\t��\\tt��\\u0003��4��i\\u0002&`\\u0002&�u\\u0002��w\\u00000�Ú\\t��\\t��\\tt��\\u0003�N4�+i\\u0002&`\\u0002&�u\\u0002��w\\u00000Lĺ\\t��\\t��\\tt��\\u0003�\\u000e4��h\\u0002&`\\u0002&�u\\u0002G��\\u0001��L�c\\u0002&`\\u0002&`\\u0002�\\u0013p\\u0000P|\\u0013��&`\\u0002&`\\u0002]'0W�\\u001d\\u0000�E�y&`\\u0002&`\\u0002&P8\\u0001\\u0007\\u0000�7��g\\u0002&`\\u0002&�u\\u0002s��\\u0001��\\\\�k\\u0002&`\\u0002&`\\u0002E\\u0013p\\u0000Pt�r&`\\u0002&`\\u0002]'0_�\\u001d\\u0000�G��&`\\u0002&`\\u0002&P0\\u0001\\u0007\\u0000\\u00057��f\\u0002&`\\u0002&�u\\u0002���\\u0001��l\\\\b\\u0002&`\\u0002&`\\u0002�\\u0012p\\u0000PlӺb&`\\u0002&`\\u0002]'p��;\\u00008\\u001a\\u001d���\\t��\\t�@�\\u0004\\u001c\\u0000\\u0014ڰ��\\t��\\t�@�\\t\\u001c��\\u000e\\u0000��ǥ&`\\u0002&`\\u0002&P$\\u0001\\u0007\\u0000E6�+e\\u0002&`\\u0002&�u\\u0002\\u000b��\\u0001�B�\\\\n\\u0002&`\\u0002&`\\u0002\\u0005\\u0012p\\u0000P`��J&`\\u0002&`\\u0002]'�p�\\u001d\\u0000,��G��\\t��\\t�@q\\u0004\\u001c\\u0000\\u0014פ��\\t��\\t�@�\\t�R\\u0007\\u0000�P�1&`\\u0002&`\\u0002&P\\u0018\\u0001\\u0007\\u0000�5��c\\u0002&`\\u0002&�u\\u0002���\\u0001�h�|�\\t��\\t��\\t\\u0014E�\\u0001@Q��ʘ�\\t��\\tt����w\\u00000*)\\u001fg\\u0002&`\\u0002&`\\u0002\\u0005\\u0011p\\u0000PPc�*&`\\u0002&`\\u0002]'0z�\\u001d\\u0000���G��\\t��\\t�@1\\u0004\\u001c\\u0000\\u0014Ӕ��\\t��\\t�@�\\t,��\\u000e\\u0000\\u0016C�ǚ�\\t��\\t�@!\\u0004\\u001c\\u0000\\u0014Ґ��\\t��\\t�@�\\t,��\\u000e\\u0000\\u0016��G��\\t��\\t�@\\u0011\\u0004\\u001c\\u0000\\u0014ь��\\t��\\t�@�\\t,��\\u000e\\u0000\\u0016K�Ǜ�\\t��\\t�@\\u0001\\u0004\\u001c\\u0000\\u0014Ј��\\t��\\t�@�\\t,��\\u000e\\u0000\\u0016�̯0\\u0001\\u00130\\u0001\\u00130��'�\\u0000`��\\u00150\\u0001\\u00130\\u0001\\u0013�:����\\u0001�R�\\u0015�\\u001a��f�\\u0000i=��\\u0018̣\\u0006Q\\u000b�A�b\\fy�u{�2\\u0001\\u0007\\u0000\\u001di��I5�%��ޯWU��\\u0010$a�<� l\\u000fH�{y�\\u0001�D\\u000e��^�wƄ��N�\\u0001�Ҹ��Ud���l�x�8�\\u0007w���JN��H\\u001c�ٓ�\\u0003%���5{P��~�/c�kY\\u0004\\u001c\\u0000\\u0014Ҟ$S�%�jDZ\\u0006\\b�\\u001c�\\u0001�o@��=\\bÒx�L�c���V\\u0011X��8\\u0000X*�\\u0016��$�� c���Ti\\u001b��\\u001b����V�7(�G$l\\u001f�п�44��\\u001f�7$A�9�rL\\t8\\u0000\\u0018��#�:\\u001e�|�̚�u���+s�H�/��а}���� �/���x\\u0015ɞ?\\\"\\u0019Y^ל��?�\\u0001��٭�+I�N61����|2\\u0015�cc�\\u001c>.r��#�4��\\u0012���2\\u0015���$C m\\u001f\\u0001�l8 ���S9\\u0007N-'�\\u0000��\\rD\\u0012љ��]�lm��\\t�\\u001fL��J\\u001d/$�TF�.o�4�|�nl\\u001f�=\\fK���؇8�b���\\u0010ᴺ\\u0004���\\u000e\\u0000�Co�^K2u\\u001c2��F�G�e\\u0004\\u0001��h?��\\u001c�]>ȃ\\u001c��g�\\u00079��� \\u000frP7�A\\u001e���\\u0007J\\u0013\\u0013\\u0013��1���.\\u0002\\u0013�:��Mt\\u000f���Pg���id\\u001a�\\u0011��|�\\u0002\\u00148$̣���C\\u0004H�C\\u001b�d�ۢ}�b\\u0004��F\\u0013�{�_�\\u0012\\u0004&b�L�:It^�z�*m�\\u0007\\u0007��v ��T\\u0010�e\\u0004\\u0017�\\u000b\\u0005�<�B�y�����\\u0000��V\\r�e\\u001d\\tL��gw��I��\\u0018�Π�~@{Y�^\\u0005X\\u0017��\\u0010$BҺB\\u0000(e;1��\\u0001�]��x� �|\\u001fI8-��r_�\\u0000`�\\u0004��z�����\\u0018��j\\u000b���J \\u0012i=\\r��� ��<�~��H�ø�\\u0003��\\u000b�.\\u000e��%�\\u0000`my'c��~2L��άB�������Ñށ$&''�L\\\\��U,��Έ�CI�==�\\u000f��k.�e�e>2\\f�G2��.l\\u001f}�����P�9����\\u0013���\\u0001j��[\\u001c\\u0000�\\u0001�0�XSo��\\u0017���:�R\\u0007̙�\\u001c�r������a�\\\"�\\u0011�a.��\\\"�\\u0011��ǰT�:�!����/�t\\u0014\\u0002+Q�\\u0000`%(��\\u001e$�1K��O*\\u0002�iHD�4�5z-].0�� /�\\u0019�\\u0011�ĩg/�c>��0��\\u0011�\\u001d3�Q\\u0013��G�\\u0001�*�%�\\u001b�+ \\u000f��!�\\u0007�$�t�\\u0006*U>\\u0000R����e>�\\u000b\\u0011�Y��}ďb�>d\\u0007@����\\u0007�\\\"\\b �=/�\\u0004VFs\\u0000�2\\u001c\\u0007�er���\\u0015�%P�L^[\\u0012��a�)[�\\u001d�\\f!\\u0001�|\\u0015$\\t�t�<|\\u001c\\u0012\\u0017��\\u001c�,�\\u0003�\\u000f�\\u0007���\\u0007�\\u001a�]/+L�\\u0001�\\n\\u0003��\\u0007��\\u0003��w�L\\u0018�S�=�\\b\\n0\\u000f\\u0005+P�=\\b�\\u0016s\\u0010\\u0004-�q��`z�\\u001aN��Jm\\u001c\\u0000�\\u0010I�ذa\\u0012ٯ5[ɸ2��ZO�h摬����!\\u0016�\\nѐ4\\u000f@$�Đ�Hh�\\t���\\u0002+t��'�\\u0000`�\\fAr6:�~L�G��}�\\b\\u001d�{u�q9�\\u0007\\u0013\\u000f��<�t�A��|��\\u0000\\r\\u000f�(v�}4<�j\\u001f$�k'���nZ��O��[u�Hb���Ӡ��FVrWIM2����6K�\\u0007�������M#m\\u001fa\\u0017���\\u001e�e��crr���a#^�J`b�/�뀸/�\\u0007���X\\u000f��W���'�9��ss3�����94�0,ͧs|&u�\\u0015>\\u0018\\u001dK+Y]\\u0007\\u0000K�911����\\u0018�\\u0011�\\u0007�b���#�\\n$�����8�����t(�\\u001f�r\\f�����~@��5d\\u000fR\\u0007���\\u0007\\u0003<�TX��\\u0018pbbR\\u0017b\\u0013����\\u00100�%P����?�\\u000b#Lo��\\u0015�zP�j\\u001e�\\u0000�\\u001e���!q�=$\\f�\\n�C�?F�ie+�G��}Ϣ�m\\\"��'&S\\u001dY[�%�C\\u0004h{\\u0010\\u0005�\\u0002\\u0011�ڳ�(\\u0010��=K�\\u0002\\u0011��[)�����๘��\\u0016A+�,���\\u0007pd���(��ږ��l\\u000f�\\u0003������ɉ�o\\u0007,b�\\u001a�P\\u0007\\u0000#aB�\\u000ej|�?9y��Y�ٛ�nś\\u0003Y;}Kۃ\\b��g\\u000f�\\u000e\\u0002F\\u001c��a\\u000e\\u00002��nIbÆ\\rs?e�k`�$u�6\\t2\\u001cKT��|\\u0004\\u001c\\u0000�GF�$1\\u0015W��GcK�A\\u0003恜l\\u000f�\\u0010\\u0004l\\u0007A\\u0001m�\\u000b$�\\u000b\\u0002�\\n�\\u0001�Q���_\\u001d��U[2Ѣy��\\b�\\u000e(\\nPo��\\u0012EBB[�Xw9���WZ�4\\u000f\\u0001\\u0007\\u0000��\\u0010��Gv�E��Um�!�S�\\\\���)BA��\\u001e���H�GX��\\u0007�\\u001f֥?LLh&`rR�=��ꜿ\\u0003�9�NLL Vh\\u001a�R9��j�\\u001e\\u001c \\f!E&q�N�G\\u0004}2\\f\\u0005˶\\u0007���&{���\\\\��p\\u001a&�\\u0000`�\\bI�ƥ�|�exu�\\u001c�\\u0001�\\u000f�\\u0007c�\\u0017�\\u0014\\u0004��a\\u0018ϴZg�\\u0000`����������|�+㏝ȇ�!��\\u000f`�P?\\u0010��������ك\\u001a��m.���L�\\u0001@搶1�7SE\\u0011,ʕ\\u0001�\\u0003$a\\u001dH �$�\\u0003H �$�\\u0003H �$�\\u0003H �$�\\u0003H �$֚\\u0007�gw1vi�N�\\u0001@�6\\u0006�\\r����d��\\u0003�?(�\\fc\\r)\\u0015��\\u0007x`�����\\u0002Qa�K�\\u0003���|d\\u0019�eHh���V�crr\\u0012�\\u0013\\u0013�ݫ\\b�� �\\u001f#O\\u000fU�3��\\\\͝��宨�b^�:���\\\\(2�K$N�u[���ѓt��'��P�d\\u0017ғ�}\\b�z�x��d\\u000e�.�#ɕ��\\rS\\u001b@���{�]�3t\\u0000 ��3��\\u001cd\\u0014�ӫ�\\u0019e%*��%\\u0010��\\r�a\\t�O\\\\�\\rsit�|�����\\u000f�\\u0012X��A�V�f\\u0002���\\u0007\\u0000\\u0013\\u0013Ԕ�$���D*\\u0014P\\u0004��I�V�t�0�O�\\u0000\\t��AK��Z&a>�#�\\u0011v!{Hv\\\"5I���\\r�1�a\\u0012���$-_V��&V�������_}2�2�f��W�� @۾��\\n���\\\\��C�<��|\\u0006��|\\u0006y�>\\u0006y�>\\u0006y��}Lm�\\u0012�n/�\\u000e\\u0000�\\u0007B*�\\u0000\\u0019ӓ�D�\\u0000��3�\\u0014\\u0003��\\f�<�?d\\u0006�\\u000f@\\u0011�������[\\u0001X���\\u0000�$6NmȝY��s�\\u0007}뙋\\u0018%.�a\\u001e�\\u0001����\\u0001��y1�a�n\\u0005�Q#t2Mt�֪�Ԇ\\r�\\\"\\u00193�H\\u0019A3�m\\u001d�y��%m\\u000f�\\u0007�\\u0007M\\u0005V�WPo\\u0000H��/U�\\b\\u0002�ʴ�'��\\u0000 ��?9����\\u00031��YV\\u001d���\\u0018�\\u0004�\\u0006x��z��\\t�\\bOLtn8���\\u0000�tO%����-@�>�\\u0004%Һ�\\u0000�\\u000f�D�?�?�\\u0013��]�\\u0002`MR�B�\\t9���'�Sk���\\u001eN�j9�+��\\\"@�O\\u0005�\\u001c�CUKS\\u001fH�\\u0000\\u0000\\u0010\\u0000IDAT@;�3��.w���\\u0007��` \\u0007���\\u0013����Y\\u0000�Y�L\\u0000\\u0010���6;c�F�0�3���9���6{��\\u0007y�� \\u000f�� \\u000f�� \\u000f�� �6���Tw�\\u0012؉\\u0000���\\u000ej8�qߒ�A4,\\u0003\\u0004E�\\u0012�@D��,!\\nD$j�\\u0012�@D��J�T��V�ĺ,k���\\b\\u0000�D����\\u001d�ZH�€�/�4\\u000fDj0�G�\\u0000�\\u0003��\\u0006Dh\\u0000i\\u001d�\\u001a\\f\\u0005�\\u0019�Z�k�\\u0001\\u0000I�hNF\\u001a�o��}\\u0000���*ɞ\\u001e;.�Y���|l\\u001f�O�[�t�\\u0019�\\u0007���'J��\\b\\u0000�\\u0018\\f��im?��\\u0000�א��ACF��%��\\u00039�q�'�9��)x ��d>�3W�k���a�h�}�ر����Z\\u0007\\u0002�x��\\u0002Y?�'\\u0019���\\u001eWt�\\u0006k��y�G�'�\\u001f���!s�=ijck=$���\\u0015\\u001d\\u0000LL\\u0010�\\u0002���u��wj�\\u0007ۃ�!\\u000f�\\u0011��?��0�a\\u0012\\u0013�0B�i��!~��i��Z2��4�P��\\u000b_m�'\\u001c\\\"a\\u001e\\u0010\\u0005�\\u0003R\\u0012�\\n�!\\u0002H�<��\\u001fb\\fIͿ&�����\\u0003��\\u001f��`�`��<��� (\\u0000怔�!a�=(�\\u000b\\u0012a\\u000f�?\\u0007Pl\\u0000\\u0010S7$�1�ú�&\\u0000�&�ZWE�'�HD�ˑx�O��؃$l\\u001f������Od\\u0017����H����X��\\u0000�Vk�\\u0014\\u001b\\u0000LNN�dm��BK�:$�u�K\\u0005���ԐT\\u0006)\\u0019��T��\\u000eIe��.\\u0007i�2��!�MBv�\\fR��\\u0001���$�G\\u001c �\\u0000@J�>\\u0012\\u0007(�5\\u000fH��ʗ\\nR��\\u000b�� %C��\\n��!�\\fRr\\u0005�'7L���\\\\�\\r\\u0000R�U ��H���e��A�C�\\u0003������9�\\u001e2�>{��X�aR\\u001f�\\u000eK�5����QH+�6�#$\\\\�\\u001b#�c���v��17��^̧\\u0013|J~\\u00100FI5bY��\\u000411QW-:�b\\u0000Dg\\u001d�Pry��0�#9@��q$��\\u001f\\t\\u000f��|\\u001a{\\u0018���\\u0007I�'q��UK����(�^\\u001f�:�\\u001b����n�s&��9��v���x�'\\u001f/\\u001csK󙛋�%�2\\u001f�ǎd\\u000f�]4��1\\u0016�ь)j���b\\u0003��Ζ\\u001e<���s\\u0003F�r\\f�\\u0000\\u0006u\\u0001\\u001b�\\t��ߞ�g�\\u001e�A�|\\u0006y\\u0000���\\f�\\u0000\\u0006�\\u0016�Y�_\\u0005ĺ�\\\"\\u0003���f\\f�\\u0000�+@Jʈz:�t��oK���\\u0002�uH�ۋ����o\\u000f�\\u0010�\\u0017(��Ķ\\u0003��3\\u0000j�qX&���\\f��K�J]����|�AYD�\\u0017\\\"i\\u001e\\n�i\\u001e�\\u000el\\u000f���!s\\u0018��x�,�\\u0016\\u0015����oX�\\f\\u0000�*5�WKԒ�XW��9�Y�G�@fi\\u001e�\\u0003��yd\\u000ed��9�Yv��D�-(+i�,�B�\\u0013�R\\u001a�5=%\\t�H%I�ʹ�.�@��y@����\\u0001�\\u0007#�Ø\\u0005����}C���{\\u0002+���Hj�t�2d��p�!�C�]��)(BJ��|��@��\\u0005\\u0000\\u0013\\u0013��c\\r�}�ܡBz-�\\b]WDJ.O8�y��>�5�$t\\\\\\\\)A\\u0012)�_���#e�_2\\u000fqI8${z�0�\\u001e�����|D���4���LN��?\\u0001\\n\\f\\u0000�J�k�h*䠭\\u0002P�ԧGv�\\u0004t��T��&��\\u001f`��0�Y\\u001e�3�J\\u0014�0*�\\t��� ����R�G��L\\u0015\\\\����M���\\u0016+t\\u0006\\u0013\\u0013�:dm�IBN�ztJ28�\\u0007\\u0019\\u001cb�\\ni\\u001edp0\\u000f28�\\u001e��`{ �à=4c\\f\\nIy�,�2��'5\\r]!\\r�1=MZ7\\u0007�\\u0003�~�~�~@.�\\u001fLh��\\n�6�MQ\\u0001\\u0000\\u0019�[i���n�ZV��.<��\\u0005�4�����9�\\u001e2\\u0007�C�`{�\\u001c\\u0006�̳\\u0002($\\u0015\\u0017\\u0000���j���zتy\\u0004\\u0005�\\u001ejC��y� ja\\u001e5�Z�G\\r�\\u0016=\\u001eXvj�\\u001b�\\u0015\\u0000���A\\u001f�DJ�0�\\u001e2\\u0006s0�D�~!aXtȯ*a[T\\u0000�Ԑa�\\u0015r\\ni]7D2����s�� �Lp�RHۋ�%�@���\\\"\\b�\\u000f���\\f\\u001e²̥-//*\\u0000`z@�\\u0002�\\u001b�\\fi�\\f\\u000e���\\u0007i\\u001eP\\\"���\\f\\u000ep�H\\u001cl\\u000f����KPL*+\\u0000P��̓���H���?�y$'o\\u001e�N�<̃�\\u0018�?�q�[���\\u0002�0�\\n='\\u000f�ƹ���fs�\\u001c\\u0014�\\u001e��\\u001e�~���j���`�����A��\\u000b����\\u0007�JE\\u0005\\u0000� 6V�\\u0011�w�A��q9�A.d�шZ's\\u0006���e���w�\\u0017>�&|��W�\\u0003o�\\u0014�z�\\u000e�x�yx�eg�j\\r�o��\\u001c�|�\\u0005xߍ\\u0017��\\u0002�����5\\u0005\\u0003��_z/����$����\\u001c����(~��ߎ[��\\u0000�\\u001f�\\tDNd�7�7�d��� @f\\u001ey\\u000b��\\u001eR\\\"���\\u0018����QH�(�\\u001eGT�d�ћ(��!gu���b�6�]�\\u0001\\u001eP\\\"gy�\\u000f̧�\\u001e�D��}�~�qx�����/ނ��w?�\\u0018�c����� �SZ�e��\\u0006��mk\\u001a�#\\b�?�����\\u000fo�g?p\\r.:�\\u0014L���A��֟��<>\\u0006��Z��&X��S�^XV\\u0000P�\\u000e�;\\u000b�3H�\\u0011��\\u0011ݏ�=��=�x�\\u0016]�_�\\u0006����~\\u001c_��\\r���sq��ьk�ƀ�y�����F��?�\\u001b��\\u0017?~}\\n\\u0006H��h\\u0004�[�)�\\\\�ZV\\u0000�V!g;e<�GZ\\u0017�\\u00141�GUs@-����\\u001f1���<��o����>�[\\u0011��1\\u0010�\\u0005)�\\u0013��;/G\\u0004\\u0003�?�\\u0018~A�!.<�d�S����\\u0013rwY�%��E\\u0005\\u0000�֩�>�Ԙg}\\u0016�y��,\\u000e�Kk��ƩI|�+���|�\\u0006���j��3���3N9\\u001e�|������\\u0000���n�Y�om\\r�|���+��3�R�E\\u0005\\u0000�QR$����z��lͣ!��yd\\u000e�v�y\\u001c{�&|��W�\\u0007�~����'�s\\u0019\\u0013�ar\\u0002���\\\"�\\u000f��\\u0011��/ޒ���;�5�\\t^\\u000f}�Ya\\u001e�����Kٴ�5�\\u0005\\u0000bL�F����ua\\u0011��B�<�d�\\u0010\\u0004-���C|�{o�\\b��_�X��~�\\u0002\\u0001��X/q�\\\"�Q��_� ����㤭�S}ȵ�\\u001b\\u001f\\\\��{\\r�\\u0016��Q�\\u000f=\\u0004�B��\\u0002��Ⱥ\\r\\u0010RB�\\u001d�r\\u0015�\\u000b6zL\\u000b�<�\\u0000����>f�a���\\u0005�OB\\f�_��\\r+�\\u0004?Z������?��G�/M}���v�����hɥ\\u0019Z\\u001b_UV\\u0000���A\\u001e\\u001a�B�\\f[�*)��d�vf��Oْ��\\u0002��\\u0003�y���#�\\u0010\\u0007qю�C\\u001c�̃�\\u001c�>�?n3��'ߌ�F����9���%���K���W?�K.8Uf#N�\\u001dۏ8��\\u0007))K %��f>:͢��\\u0002\\u0000\\u0019\\u000f�(@�4dHȖBV��N���El\\u000f�\\u000e֮?\\\\y�6���C�����K����]����\\u001f��n�\\u001a�\\u0013�r���C�������S;_\\u0018���3[�Y%��1!v��\\u001d*iY�mr��\\t�\\u000fp9k\\u001eL@fy$5�)�\\u000bEp���\\u0011Z�\\u0011�G(����^����w��\\u0013��n.1�}�o���I�CU��d=�~H\\t�W�G�h_��gUʶ�\\u0000��f�4R\\b�Q\\bJ��9��!*���9A���g�.\\u001a]�qT�9�O~�]��}#&&\\u0018�w~��3����A\\\\�c[bA\\u0011lx\\u000e�8��-��VY���W\\u0014\\u0015\\u0000��uj\\u00012\\u001b�D�O�<@�\\u0018�G ��J\\u0004��#v�u�C\\\\�\\u00129z�9�������p����k��%\\u00103!1#��^��},�/�H��5��b_�RT\\u0000����G���7�VHj\\u0005���ѧ#\\u001d��ĥ�9���a>���\\f\\\"�\\u0011̧�\\u0017\\u001ci\\u001f\\u0017n?\\u0019����O���b/s\\u0010�\\u0019�������@�}a�?%���q�}EԔ��:�'����\\u001e]X\\u0000 �\\u0004;���IU�������|�\\u0013NX �m�A�ʤ�Ϩ|�������'\\u001c��\\u0003/|^�B���\\u0003~��7��C��j��^K��F��\\b\\u000e\\u00068�0�h\\u0016�e1Ka\\u0001��G�%y�!IR����9��\\f� �g.�ix��\\u0010�\\u000f���t���\\r�k^F%p�[v�?����1����۰$m\\u000b��Z�\\u0013�bS��/,\\u0000�Ʃ�D�Ⱥ\\u0013QG��\\u001c!].[5�#좱\\u0017�Ǽ�q�\\u0015gዟ��\\u000f���R�k/?\\u000b���n\\u0014?��\\b��9�PK��\\u001a����\\u0000\\u0000��T��R\\u0005T����V\\u0003\\u0006�\\n\\\\>\\u0007\\u0017 s2��\\u0001�[v��\\u0015;��o}���~?V8��ʳ�>sS��پD@�I�>������\\u0019aQ��\\u0007�\\u0015\\u00000�f \\u0002Eي�ydgj\\u000e����m[��_|\\u00076Ny�\\u001f+��q����\\u000f�q���~\\u0011�\\u001a\\u0017PX*+\\u0000�u��\\u001f��R�j\\u001e���\\u001e�*\\u001cN?�8�g�r+�Ľk8�\\u0014���z\\u0005>��K�Ռ����ȏA\\u0017\\u0013:����\\u001fXT\\u0000�t\\u0016�N2\\u001a����́��<2\\u00072K��\\u001c�,G�ab\\u0002��K�����u��t��赸��m)\\b\\u0018�=r�;z��x\\b�\\u0012y��TT\\u0000��%�����{G\\u001a�%S/�L��Q2t���<�\\u001bDm�\\u0017i>�3k\\u000f���\\u000b\\u001fy���\\u001c��l&��~�s7��c6�\\u0003r;��#����c�>��᣷U{������\\u0002\\u0000\\rcdn|�j�J�!�rQ���H�\\u0007Gl�ai~����7��\\u000fެā(\\u001a\\u0000\\u0000\\u0010\\u0000IDAT)j�./�G ~1�/��[���\\u000f������*�DIKq\\u0001\\u0000�]d\\u001d�2�\\u0013�\\u0017;!��B�\\u001c�BD��\\u001ai>\\u0010\\rF\\f�%0 �\\u00078��-�ҧ�\\n��!p����7^������a}�7FN�p`a\\u0001@\\\\�\\u000b�|������$\\t%\\u0005�\\u0003���X��\\u0000%�\\u0019�\\u0011\\\\��\\u0014\\u001e�\\u000f��?�f\\u001c�%����׵ �\\u001f|�Z�r�1�?�����!}-��Z��Z~Fa\\u0001\\u0000\\u0010F�Fy\\u0019\\u0007��\\u000bB�\\u001a�\\u0010\\b�>�\\u000e�h\\u000fo��L����ᴶ\\u0004�6L⋟�.}���0,�~��e� :\\u0010FH�qHY\\u0001@�����N�h���ӥ-�#s�=d\\u000e���\\r���+��pk�e�H�\\u001b/=+���7sX���E���x��̫�\\u0000�\\u001e���P2n\\u0007T -��v@.�\\u001f|��+p�i'\\u0014��Ư*_��\\u001b15�AC��\\\\�=�K{=@���嘢\\u0002\\u0000�i�\\u001cj��A�z�\\\"@��y��\\b��\\u0013\\\\�\\u001eN=�X|����ŧ\\u0015{��Oߊ\\u000f�|�Z�n�\\u0011ۏzE��>^V��i\\u0019�=�t�\\u0016�r�\\u0005����N ~\\u0018�=7^4r��\\u0013���\\u0018�*Ƒ\\u0004��My\\u0001@\\u0018{��eP������\\b�\\u0016s\\u0010\\u0004-\\u000bp���`�Ԥ\\u000e��\\u0016\\u0002\\u001f��bLL�\\rW\\u000b��uV�GL�ԟ4�\\u0018���\\u0002\\u0000��\\\"4ֽ!I��d����4��te���P��\\u0017Y\\u0005E\\u0003\\u001a�7�=oݑ��i\\u000f���\\u001e��� ��ܟ!\\nD$jo�y�\\u0007\\u0015�\\u0016\\u0016\\u0000T�\\b-=�\\u0002����G��\\u0003�â�C\\f�1�\\u0017zi\\u0019�\\u000f�|��Hv-7g'\\u000ekԿ\\u0005}�e��\\u000b\\u000b\\u0000��X�\\fY�N\\u0004���Cv`{PG\\u0010��\\u0016���w^>��I`�9'��'/�=\\u0017��>>�\\t�!d�\\u001f(,\\u0015\\u0016\\u0000 5V�\\n2����!�\\u0000l\\u0007����5��w�Ipj/�w^w��z�vM.o\\\\\\u0000�z̟ƭ��\\u0000 �\\u0016�f�Q\\u0013�YG�Y��\\u0003\\n��\\u0010B�b�e���\\u000b\\u0000�\\u0006Dܳ��D$j/�!�u����\\u001e�\\u001d@)�E?�+/<\\u0003��|�J���@�\\u0002\\f�����o1��v�X��\\u0015\\u0017\\u0000��G,�D��`\\u0017;�2J�u�\\u000f�2�A\\u001eݳ��<+*�u\\f\\b\\\\{�Y ��������;�]�cvq\\u0001@|%F=\\\"���k�ؓ\\f\\u0011�.\\u0017�\\u0001\\u001e\\\"3��<�DT\\u0010R4�D$q��\\\\������1 p���pn�fG�3�ej�T_�Ij��_����Rq\\u0001\\u0000�\\u0010-�iB��H���Hq0\\u000f�\\bq�}@ \\u0010�\\u001c�\\u0011��?wۉQ�uL\\b\\\\q�i��=���%�/�\\u001fb�4�\\u0005�\\u0005\\u0000ٸ�ZF��\\u0012���+��~\\u001dC��I�\\t'!\\n1��)���$�\\u001fNcC ��(���j�E�D�\\u0007V����TX\\u0000���R\\u001b��� a\\u000b\\u0003z����㓑\\f��噇�d\\u000e�=\\f˲���D����\\u0010���S111l��^�}�M�\\u001a�����*�5��\\u0000���B�1ഭ�o������0�=����e\\u001ec��s\\u0000�N=a�\\\"Fg����=��\\n��[\\u0015�\\u0014\\u0015\\u0000Dk�?5\\u0010��f\\u001aH9\\u0004\\u0015*V��\\u0006�C:�t�\\u000f�2�A\\u001e���Y��\\u0000��#��t�[��+�>�S����\\u001a�'��`8��^V\\u0000 ��� �#�@Ȝ�m�F']\\u001e��\\u001b\\u001eY�O�\\u0010f�M�8f�Fl=~3�Ə�i'\\u001d������_~\\u0012夲\\u0002�h\\u0017\\r$�\\u0018����\\u0002e>銾q\\u001aIv�>�:�x\\u0019��q$�ڮ��������$�0�q�(/\\u0000P'�MTɧk��cHw9i>�4D\\u0001�H���{��},���eM\\u0002ܠuJ�F����T\\u001eT�8Fm�uC��\\u001d�_r-�_�MEO)�Fd󠛌A5#\\u001b]�\\u0016��].\\u001c)(���i�O�9d�\\u000b)����\\u0000B6�C��8�z\\u001do11ާ?�ٓ�H��\\\"�� o\\u001e�^H��\\\\�p�1��Έ�h+�\\u0013���\\t�ԩH��&\\r�1����A�\\u0011\\bD@��3�$0�\\u0019��\\b(�~\\u001bN�g�\\u00064�”�#0�؂4���L��\\r�s'm�s��J��L���D\\u0019�\\u0018�E3\\r\\u0016V\\u0010��_�C�u��{�!�E\\u0011xz����4��\\u0005J�z���U<���٤+�8�U[\\u0005+\\u0006�\\u0018���<���u\\u0000��\\u001b\\u000e@6j�`��@�g���j0V���&U�x�~{�2�u��j���O��^�]�\\u00163����O��\\u0003d�� F����y�Ϡ=\\u0000�z��l����'��\\u0018L�\\n?��4\\u0015\\u001fz\\\\����-�\\u001d��<\\u0016��C\\nPt�!f(�3\\u000b���\\u001a�\\u001d�=&\\u0007pd�����}�\\u00161R�*�_o]�2QLM�\\\"\\u0011���\\u001fB��4�d/\\u001fɹ���9\\u0000��\\u0005��N��Bm2$�rq��LM��;�A>��� \\u001a����1�iB�j�\\\"��\\u0012\\u001b�\\u0001��M�\\u0015䴂H6LnPw�����\\u001c\\u00047��\\n�_�c�2�����r��\\u000e�G�\\t��ԃ[\\u0018��4��\\\\��E<4�\\u000fR��\\u0007\\u0014�.��1���\\f\\u0000u�=��{�~����\\u0000@M��ơ-\\u0011��KR�\\\\��>,]\\u001exD�sK󙛋�E\\u00019\\u000f�1*�85\\u0019UY�:�)�:���c�|��/��zE\\f�q[#���ꬼu^�6L����\\u0002�3�R5�S�U��ωs(a-.\\u0000H�´\\u0005�ƪu��ANC:��\\u0004��`~5�F\\u0014n\\u001f�_ߟ����Ľ�x�o�d ����\\u0014�G�A�oA pxz&7��&�\\f�+Y\\u0017����\\u0006шQ�h�4�-dSf\\u0000P�u�Q�ƪ�jH��o\\u001d���x�O\\u0018\\u0002�\\b�G���Сz\\u0000���$���ϒ�\\n��a>�qjA �g߁\\\\�0c�����/\\u0006��\\\\�:QF5\\u0006kA���\\tHI\\u0015���A��yd\\u000e��̡�\\u001e\\u000e\\u001d��6�\\\"W\\u0011W����\\u0016?\\b7�ٯY~ \\u0010\\u000fB��\\u0007\\u0003��\\u001fN�F��۾��oR\\\\D��\\\\\\u0006\\u001f�E1�zu1uI\\u0015!ո�R�\\u0007B���y��Ud\\u000eq�OZ\\u001f�\\u001f{�\\u001f��̱Ā��L�%���8��eE 0u\\n\\u0010�B�\\u0006�G\\\"x;�\\u0000��Xd;\\u001fn_��r��থ��D)\\u0015�z�퇑\\u0003u'���B��\\u000f]À��'9���t[��\\u0011�f߾�\\u0000 ~$'\\u001e�)�.<؇UH�+���Iz��s�{�v\\f{�\\u0013gj�`\\u001f��5�\\u0007A̢��m����M�cf�$�Z�}#��D-I\\\"\\u0012\\u001b���%IDb��\\u0012�$�Hl�Z��$\\u0011��^KԒ$\\\"��k�Z�D$6z-QK���F�%jI\\u0012���D-I\\\"\\u0012\\u001b���%IDb��>Pζ�\\u0000�dj�Z�2�0�l\\u0016�\\u0000�\\u0017v�\\u0005b��'���?���+F@�6�\\u00168U�}mWjy���l��4W���O)c��V��L4\\u0005\\u0010�/\\u0001�\\u0018BZ�dw\\u0005�G�`{�\\u001c���c7�7�\\u0000�?ˁӪ\\u0012��\\u0010H�\\u0001+�CIO��Zz���=�}\\u000f��r�U��5~�5����8Y\\u001e�c�\\u0007H����GZ�[��y�>r�跇�.<\\u001b��?�\\u001d�\\t��\\\" �\\u001b�X�\\u0015�Z��>�\\\\�\\u0000��E�^fS����V�ϲO���T��T,l\\u000e�\\u0003�����m�\\\\�����a�q��8YLxд�͚\\u0010�oV�C��|���\\u0017_����Τ���{\\u0005�ap\\u0014�B��\\u0002�hd \\u001a�\\u0002,a\\u001e�\\u0003��\\u000f�6L�W>�\\u0001|�}���l��\\t^���ր@|e0��P�\\u001aX��E������{k����\\u0000 :A��eP@r�P2\\u000fA�b\\u000e[�?\\u0016�ѯ|\\n�_�C<\\u0006��\\u0018M\\u0006���\\t\\u0001�e�o��y�E~ޮgvco�#@pGN�vVV��\\u0017E��\\u0002�^S�;i�K7;�\\u0004��<���.����d.�e\\u001f�o?\\u001d�ٯ}\\u001a�\\u0015O���\\u0019�#��mƆ�@\\u0004\\u0002���\\b�~׃ϥ��=\\u0002�r��\\u0013Jm?�b1�D15QE*�a'��\\u0015?{���{�a��wdp@��W�\\u0007\\nJE\\u0005\\u0000���c\\u001a���ad-Q�F\\u001f�.OӉ�<�̧�|>������/Xq��Y�\\u0015G��7�[3\\u0011���ƫq��<�;��U�\\u0003|��z�z��\\u0002\\u0000\\u0005��14��Ơ�>�^��=�F��\\u0001\\u001d\\u0018�]>�\\u0003\\u0018��g�\\u00070��!���݆���f�F���Q_ReV����K\\\"�\\u001f���'ߍSO:>�}����\\u001fUM�,ݿ��%-e\\u0005\\u0000j\\u0019RQ@��h!�\\u000bCr\\u000eY�G�\\u0010[��x�8v�&��gߏ�L�\\u000e�7\\u0001V��b�{8 ۼi\\nq�vsco�d�����,���la\\u0001@n,�\\u0012)�C�P�=|�S�=�?���_�6\\u0006�\\u0019?\\u000b�<�+�깂�����O���\\u0004�wAв�\\u001c\\n�\\f+,\\u0000hZ�_�1�벏4a�H��FI��:\\u0017'�i+�[o�\\nW^|��m����3��)���\\u0011h���u̻�z\\u0015.߱�.������H\\u0017\\u000f}<\\u0003'�IE\\u0005\\u0000L�\\u0005�D$2d5�cHw9\\u0019���4�����\\u0013�}����\\u0006)���\\u0001h\\r>�\\u001f1\\u0007��?���������/?�����K�����?\\nJE\\u0005\\u0000�\\u001a�dzh�T�+�\\u0018ҕ\\u001f��d\\n\\f��A^�b>\\u0015H��>~A�^澦K\\f@a\\u001bk����D �'�L�ܛ\\u0013O8\\u0016\\u001f�\\r��\\u0019�a�dݟ%\\u0007���J\\u0007�E\\u000e\\nJE\\u0005\\u0000j��G#��4ٟ�yOOO��Fuy�\\u0014�!��a\\f��-W_�K�?k\\u001d�P�<\\u0010��Gw�#gff\\u0010�(\\bn��\\r8��Ӓ\\u001f�[y�\\u0016�8J;��1E\\u0005\\u0000\\u0001��0\\u0000 -�D��0\\u0014c\\u000f�l�T?�\\u0015�Z�5\\u0006\\\"�\\nX;�q�rp�����'o-�����_�����#�QV�ZV\\u00000�6\\u001cj,�\\u0003mF�\\u000f�0�A\\u001e-��Oh�7���O�Y��k��,@\\fL���Ϊ\\u00118txZ\\u0017�U�����Y���\\u000f��ZY���䐮#�7N�ty\\u0002Qo8\\u0007��%�C�^�\\u0015\\u0000�5G4^�\\u001b\\u0000MV�5�;����!S������|j\\u001ah\\u0005�s�<\\u0015o��r�D�~w��z���40�\\f��\\u001e�ú��}�rq��������m�\\u001ce��A[�ݿ��G�p\\u000e>콢��\\\"\\u0003\\u000027\\u001e����\\u001e�\\u0010i\\u001e�m���~(��j�M\\f��=!��D I(E���`��N�i^V�@\\f��������85�\\u000f���tb�x�;\\u0019�\\u0006�v�?\\nJ�\\u0005\\u0000��F��\\fY%�@��\\u000è��\\u00015\\u0017�!ǃ�U����\\u000e@�H!+턔H�k�dz\\u0000��T��ו \\u0010�����\\u000fǕ�5�5�yq��η^���\\u001f�bCR�w�\\u0007\\u0019\\u001cP˪�G�d\\u001cwd9���RQ\\u0001\\u0000�ud�!r�Q���+�nT\\u0010�S@zt6$��\\u0010�\\b\\u0012�x��t�\\t�\\u0010�dG\\u0002�d�Zא�G�o���a��ү�<{\\r�\\n�Y���\\u001a�\\\"\\u0010ЇyY\\u0001\\u0002����h΁������\\u000f���.O�\\u0000�0��^ݿ���I@<�7\\u0016�G/-i)*\\u0000�in�QS\\u0003E�j���H��ӵ��t�0`�[�\\u0003)��>,S�^6���.\\u000f\\u0002d�cXF)F�w�e���m�\\\"��}��m�\\u000fK\\u0015�e8��S�6�>,U����FO��0}5�A�P,s��(\\u0006��1gߪ�=,�#��\\u001b=���p��� CCJ={L����{����rս�0�EE%-E\\u0005\\u0000\\u0004Sې�L\\u001a��>�rDʔ`>ȩǣe��w�9�`����\\u0003k��AKn\\\"��G\\u000e�K~��\\u0017�v�Io�}��+�%@朼���z�:��)c[T\\u0000\\u0010M\\u0012F\\u0012�t�ʘS��͙�\\u001c�r�O}�H6�f\\u000b��\\n��1�6��s�ፗ_�\\u001b�t\\u0019�{�5�\\u0001�s\\u001f�\\u0019_��{�[_�\\u0018�<�d�C:xh\\u0006�\\tX|K��:ph:��/��\\u000b��m�]�c7oJ7�R�^A�M�S\\u0018�c���\\u001e\\u0003�\\u0019�_�(����\\u0000@m���\\\"DK�d�����\\u0010_�{��\\u0017�\\u0013�\\u0001��>��������_ė�c��O�\\u000f?��[\\u0010?����߄�_\\u0005��r\\u0007.�c\\u000e>��t���o������/�#����q�)'&�]ޤ+\\\\��\\u000e�e\\u000e�u���\\u001f��7���Z|#\\u0000s��B�ݥ���~;��'\\u0014\\u0015\\u0000(�M�d��9y�h[DryP�xd2y\\u001b:R2��aA>o~�E��/|\\u0018���Ż5��1��\\u000b��\\u0011ЄI\\u001e�t�����N��)��\\n�.:�L�v�\\t���\\u0000d��y��|�D撼��\\u0003D��PL**\\u0000���6�K�d��>���z'�ˑ;�� �a1�q�����7����/ ��_|�YpZ�@\\\\�[\\u0002��\\u0016>��#�\\u0015\\u0000��.��u���\\u001d�&�T3�1\\u0006�?�?8\\u001a�|\\u0000�IE\\u0005\\u0000lZ'����\\u0001R��}�2R'H\\u0012�p��B�@J�\\u0015�@`j�|6NM�i���}��JݲySb���\\u0004��7\\u0006�\\u0018\\fG�x\\u001f\\u0019W�\\u0011�ķ#�P�k��\\u0010�_��C��{Q�\\u000f夢\\u0002�4�˘�$�j��y$��%g�h��.�w\\\\Ez����Ij\\u0019��%\\u0012\\bG\\u001b�����\\u0011��\\u0012�f,^\\u0016_���'\\u0002����Y����\\u0000ǣ�׍$�ۿ��DȆǰl�\\t��TV\\u0000@5�<\\fS#U�RmZ�\\u0018Ĕ#����g\\u000e�9�a\\u000fW^t.��/}\\n?{�����+��f* \\u0006��uA��ʾ���[�p\\u001cP�sxZ�\\\\�S����|��ʯ�݋=�'G�-%��9�\\u0003uD��g%.�W�RV\\u0000P�]\\u0014\\u0004$!cMrHǐN25i\\u0018w\\u001c��A\\u001e��\\u0005>SS\\u001b��?�n���}\\u0000�N=IF�e�\\bLk��h\\u001aq�\\u001c��j}�Z���̌��È\\u0019\\u000e��k�K����p\\u0011\\u001aGv���?���\\u0003�x�9�w<�$�m}QY\\u0001@�6j�<�KO��'�!\\\\�@_ ��l̕�p,�|�\\u0019'������\\u001dQU�kA@�\\u0015W��\\u000fNk�V\\u001c�����\\u0015����\\u0006�C3:�\\u0015z�U|��Nޚ~^z�~\\u001e\\u001f;W~�H�nT\\u000e١�Et<�\\n\\u0000t���:5R\\u001a��#��Òtyt�a.�Nv��1[6�W?�~|�\\u00037�g/.��\\u0000;��\\u0015hK0��i:M��s��\\u001a�,h\\u0011;�ݦnލ���\\u000e�\\u000b�N.��\\u000b�\\u001d�\\u0003�\\n\\u0000b�O�\\u001b��\\u001aW�\\u0013P\\u001b�֗>,].b\\u001d�s���t��\\t\\\\y�pj'�<�������7\\u0005\\\"o��6��\\u0010�\\u00101���熜�\\fE����dǹg�K����!8�#\\u001c�s�\\u000f�\\u0012\\u001e��\\n\\u0000�\\tc�gj�J[�V����wy�\\u0002�!����\\u000b��o|��8��T/�@ �=pX�\\u0006b0�����\\u0000��\\\"(�A{��Ј\\u001e�����n��\\u000f�VDL�G�F�E�m�_�i�\\u0014�>��t��\\u0007�b����jO�^\\u001dZ�RT\\u0000\\u0010�B5g#+�X�(@Tf�f�\\u0012��e>�h_�s�aj�\\u0006��2�\\u00044n#\\u0006�ú*��:��\\u0018��\\u001f8�[\\u0007��T���r���t�I⃇\\u000e���>\\u001f;���=bvaZ�\\u0017�=�L\\u0016s�\\u0017�{��\\u0000�K�����\\u00000z���rֲ\\u0002\\u0000M�W\\u0011�37�Ti2k�\\t�yD�_��7_��\\u0019�\\u0004ś2\\t��� \\u001ek�\\u001aD��W�@]&�Pf�G�Վs�\\t�`�?&b���th1��\\u0002���܊P@�H��[�փ\\u0006@v��Go}\\u000b���d\\u0002\\u001d'pζS;�\\u000f�E���l��\\u0000@m���g�\\u0004\\u0015�\\u0003d-QK�K�x��Gn�{n�\\u0006N&`\\u0002��'�����a�0�?,�p�\\n\\u0000t�OƽX�\\u0002�urP���\\\"@\\u000er!�^\\u001a����[���@-�H)\\u001b\\u0000\\u0000\\u0010\\u0000IDAT�\\u0007�:&�\\f\\u0002۷�\\\"/p��,���ٯ�Y6�#�N\\u000e���#\\u0019�[�Ң\\u0002\\u0000�a6f�J_{dݨ���\\u0000rV�����]��\\u0004ī$>o��\\u0012�|�\\u0015��|>!\\u0013Xo\\u0002g��U����}����O�p�������<�VT\\u0000P����\\u0012 \\u00196\\f�/v��\\u0001I�\\u0000���R�\\b\\u0016�'�m�\\u0000)�;�\\u0002��rq\\t\\u000e\\u0000H�\\u0005���=\\u0013��\\t���\\u0004�H�뢫�\\r��‘%Y5��<�\\r��rRQ\\u0001\\u0000�v�\\u001d5&BF^j4e�teJu�8�R(�SO>\\u0001����F\\r���\\t�A`۩'\\u0002����}��ᇀRH�K.�[��\\u0014\\u0015\\u0000�F�ƍ��Í<��\\u0001]/\\u0018�]\\u001e3\\u001e\\u0003�d��l�<�_����y�F5�\\u0017\\u00130��\\b\\\\v���\\u0013�o�m���`�\\r��B\\u000e�we\\f�:j@\\u001f���7�R\\u0015/g),\\u0000P��(�M-Dj/��!%�.E\\u000b��\\u0003�\\u0014-����\\u0003�\\u0014-��]�\\u0018A\\u001c\\u0006xH�B*��|���B��3��\\u0017\\u00130�y\\bD���k/O�����>�����\\u0017�ݸ��\\u001d�8�!f�[ٿ�:���\\u0017����b��\\u0002�\\n�M��\\u000b�ͺ�\\u0007tH#\\\\..\\u001e�\\u0011�Y\\u001e��\\u0002�����7]�+v�\\u0003'\\u00130��\\u00118f�&\\\\}������\\u0013�?�9|�\\u0013��\\u0015\\u0017�=�\\u000f0�ˏ(g���\\u0012�\\u001b�J\\u0013EUG�U�\\n��Х�$\\u0001�z\\f�\\u0002!.e�8�c���\\u0015N&`\\u0002�#\\u00103\\u0000�^�\\u0003����������}o#�=f��Hw�����e\\u0005\\u0000U��3F9�\\u0011���%ji=s)��\\u0017>�.M]N�����L`�\\t�x����\\u001d��o�����?�\\u000e�~���G�ԟr��o��p�S/*\\u0000 )��\\u0010m\\u000b�P��6�-\\u0015�EAA\\u0012\\n�q�\\ro����P%���\\t�\\u0016�뮺\\b�/|\\n���M8v�&��O�\\u001c/PV**\\u0000��\\u000f�Vˢ�k'�%r\\u0001�\\u000e���q9b�$�\\u0001Ǝ϶�N�Gn}3�L�\\u0004ֆ��o�\\f��>���퍃�B\\u001f?�O[�_1x�K8?\\u0014��\\n\\u0000�\\u000b[PjSm����=+ed\\r)_j�P\\\"]�71���\\u001b����6��\\u0017\\u00130��$\\u0010�\\t|�\\u001d���_�\\u0018N9���є\\u0007\\u00197��\\u0018��*YȦ�\\u0000 �$�/�e\\u0019��i{2��4c���\\u0003�dd+���/_y���vT\\u001a;.\\u000f\\n�K�\\\"\\u001e9c]�\\\\y�98��3ҙxc\\u0002&��\\u0004�>�\\u0014��_�8�x�\\u0005�p�\\u0003m��\\u001f�y����/���\\u000b��\\u0014\\u0017\\u00004O��\\u0016�+�ؑ\\f\\u0001I�W\\b\\u000e��xhGR[D�8��������\\u0012)�C�`{�\\u0018V�æMS��p?��\\u001b�\\u0007ʯ��\\u0000��$9��e���\\\"��\\u0005\\u0000\\u0004\\u0015���dl�\\u0000�f=?\\b\\u0019\\u001cP\\u001c�\\u001b�x)Nk~�L͎H!U�h�P���~�hdi\\u001e�C ��@�a�x�\\u000f\\u000b��n\\t���8��\\f-ͯ���\\t)��柗��(*�\\u0015\\u0000�mÊI���BX�@��c��\\r��-�\\u0002�^o����0{$.P2\\u000f�j;�Hv�������5��\\u0007R\\u0006X�?\\u000e��e\\u0005\\u00002j�y,�,��-o�\\u0012�\\u001d�%�F���c�0�\\u001c�!\\b��\\u001d�[\\u0002��\\u00157��R�������#���\\u0016\\u0015\\u0000�u㪅\\u0014\\u000b��S�ms\\u0001��̥��2��K�\\u0003zK˷lވ[o�:WD�<��,\\n\\\\\\u001e\\u0014�_�g~6Qb>Aa�U|>����+/�\\u001f\\u001e�;Edz��\\u0000@�&#�\\r��WA1A�`�JO\\u0012��b�p9Rbڎ\\u000f�뮼\\b�\\u0015�Ԍ������S�l�\\u000b�Q\\u0016�9\\n\\u001c\\u0015�� \\u001ce����Go�\\u0015\\u0017m�uW�=�\\u001b��f!=�*�\\u001c�!���t��B\\u001d�x���ק��\\u0013(d-*\\u0000`�(iT��$������\\u0002��v��M���\\u0004L`|\\b��Ͼ\\u0017;�݆q�/\\u0011\\\\��\\u0004\\u0006��\\\"\\nJE\\u0005\\u0000�]bЏFJR;sJ\\u001d9g~s�˓ѧ�'\\n������m�b�i'�$���\\t�\\u0013�?���\\\"��z���oC-��I��q\\u0002����\\u0017\\u0000h�Jm��\\u001akN)*s�7ǻ<:\\u000b��\\u0018����7��_-��\\u0004Ǝ@ܶ��Ͼ\\u000f�N٪1xد4���R��~�@�W�R\\\\\\u0000@�\\u000f�HF#��5��;�����#\\u001cȵW^��x1\\u0001\\u0013\\u0018G\\u0002[6oė��m駃ɵ�G\\n.\\u0004�\\\\��!��ު���\\u0000 \\rvj��\\u0012i�&k���z\\u001cЯ�\\u001c�ă\\u0003��&>o�����_}n\\u0016&`\\u0002�G�c7�>��t�m�/����'NpX��R��\\u0002�h�h�F�~��>,�,���F��X\\u001b}XFY����\\u001ee�6����X��\\u001b=�bm�a\\u0019e�\\u000e�7z�����2�b\\u001d�o�(��чe��:���Q\\u0016k�\\u000f�(�u8�ѣ,�F����B�j\\u0002&0�\\u0004N?�D|�]קZ4�{X�Bm��\\u001b]Eii�a�\\n\\u0001\\f�7�b˛�K�E\\u0005\\u0000dnVrPbH']\\u001e\\u0006Lf\\u000e���\\u001dgǩ{5\\u0001\\u0013(��-o��������A�6�T\\u0000�^\\u0015�\\n\\u0000�\\u0003\\u001a2��~�$�s���t�\\u001c��gǹ�����\\u001d\\u0013(���}�fl�8���ߕ����_A;��(+\\u0015\\u0015\\u0000\\u0010�Z\\u0017�\\u001a;\\u0006�h��=������G�z5\\u0001\\u0013(��I[��{n�\\u001a�_��SAܣ*E\\u0005\\u0000\\u001a�A2�eY �K�?3��7&`\\u0002e\\u0011x�\\ro���Y\\u0015�\\u001d�ȕ\\u001d\\u0017�=KX\\u000b\\u000b\\u0000r#�\\u001afH��\\u001e��3F<6o���g��N�\\u001b\\u00130��\\b|��\\rV���)n�\\u000e��xk�\\u0005\\u0000}�\\u0011ƣ[>�\\u001c�1A�Ñ��1�s�\\u0005g͞��L�\\u0004�#\\u0010�����s�V�_�[)GƉ��Y�(2\\u0000 �H\\u001a�HI5\\u0012)i\\u001d�8�)�\\u000b��\\u0001@5�\\u0017\\u0013(��{n�&����k�XA-P\\\\\\u0000@2=MJZ�S�d\\u0019\\u001cN;������b\\u0002&0\\u0017��.9\\u0017[���\\u0002���\\\"W��\\u0001�\\\"\\n���\\u0002\\u0000�5�\\u001e\\rS#Uڪ��[\\u0000�K�\\u0014��c><�k�w� ���9�\\u0001@!.��0��\\u0013����u��?j�։\\u0015�\\u0014\\u0015\\u0000hl\\u0007ɺ��$k�Z�:�t\\u000e�.'\\\"��\\\\g>��x|��W\\u00130��\\t��K0�aC�%�\\u001c�\\u0003�+�z\\u0014��\\n\\u0000�V��V\\u001a֑m\\u0001���\\u000e�'p\\r�a�^|N?ep��n��tz2��6.\\u0017\\u0004-�\\u001c�����FO��4��TQZ��\\u001b=\\u0015j���REi\\u0019�o�T�M�\\u000fK\\u0015�e8��S�6�>,U����FO��4��TQZ��\\u001b=\\u0015j���REi\\u0019�o�T�M�\\u000fK\\u0015�e8��S�6�>,U����FO��4��TQZ��\\u001b=\\u0015j���REi\\u0019�o�T�͖MSx�%�-��9\\u000f\\f���uܗ�\\u0002�h�h��\\t��\\u001a�QX\\u0001.��8@�<.|N=i6\\u0000�i#N\\u001fJ�RYQ-�\\u0007\\b��#\\bZ\\u001a\\u000eڵ}\\bB�cX��5|���|���\\u0019��'?���؋���u���8�R��\\u0002���\\u000f���C\\u0012H�a\\u0014��sg��\\u0000���i'oE$�m�6B6��\\f���99���\\\\�\\u0014vc>��n���|.��ll޴QW+\\u0000\\u0019��e\\u001b�7\\nJ�\\u0005\\u0000��@��\\f�\\t)U�\\u0006i=zS�0f<�?v3���%-�\\u0002�\\u00039�\\u001e��pŎ�X��\\n�X-�����-.\\u0000ȍ�\\u001b\\u001f\\b\\t��U-%R�u$\\u000ePj7�� ��\\u0017\\u00130��\\u0010���s뚶�?էU�(0\\u0000\\bc���\\f\\n\\u0018\\u0018�4~\\\\\\u001c\\u0000�ټ�@�\\b���\\u0014�xi�\\n���PD**\\u0000��.�mc=8T�\\u001cP��ӧ6L��\\u0004L�[\\u0004N�z\\u001c�l���\\u0016j���\\u000b\\u0005��\\u0002��\\u0001�,uǻ���y0�z�2~<��\\u001c\\u0000��\\u0004:H���NJ�\\u0006�����\\u0017��W���QR**\\u00008�H���r��\\u0017\\u000f�\\u0002(��.&0:�m�����=�j�3\\u001f�#�\\n\\u0000t���E��\\u001c2\\u001a��#?�1E�����||\\u000b lӫ\\tt���3�9�����X���i�ٰ\\u001c�<�+g-*\\u0000 ����fIf����^�tyt\\u0016�A$��G{�8\\u0000���j\\u0002�#���\\u0013U��K\\u0018����b&\\u0015\\u0005��\\u0002����R\\u0013�i#�6\\u001e��x��<���\\b��W��\\u001c8x\\u0018N&`\\u0002�#pN�&\\u0000�G�O����5\\u0012�I�\\u0005\\u0000��\\u0000\\u0019��DJ���&\\u0015��!c��\\tC�\\u0011�S?�v�ٳo��ҋ\\t�@\\u0017\\t�q�V����\\u0006�\\u0005\\u0000� VA��\\u0018� W�\\\"�#F;I��\\f�8�ٳ�\\u0001��֋\\tt���������~�\\u001a�\\r��#\\u0014��\\n\\u0000�<�C2Z�lti\\n\\u0002\\\"?�\\\"�r$\\\\�y�F����w@'��\\u0004L��\\u0004�;vs�h��N_\\t\\f\\u0019\\u001cȵ���D���X�\\n\\u0000b0�M�e�h�dֵ�v��v���Zպv].\\b\\r�J�\\u0011�V���\\u0000@���\\u0004:J`���\\u001bT��\\\\����U�j�@|.�IE\\u0005\\u0000i\\fWӐ�H��*$@��N\\u0010�Ȑ���\\u0001@��W\\u0013�&�j�R�+y��[\\u0000\\u0019r�t\\u0014��\\n\\u0000R��<-Đj�$+HS�n\\\"\\r�u��[��u?\\u0003\\u0000'\\u0013�*�陙��SzPPG��\\u0001\\u0001\\u001b���J5\\u000eT�\\u0012Ȳ�WC�\\u001c�DQ��\\u0000 \\u0002�d\\u0004j�J�t��Ə ��\\u0000�\\u000b��^y\\rN&`\\u0002�$PiD���\\u0005�;N��f(*\\u0000�v!��\\u001e�:4ȏ?�W^݋}�\\u000f��\\u0004L�{\\u0004f�+��\\\\or>�V��sJ�\\u0016\\u0016\\u0000d�H�R�5Qm4���.��Kc��g^읮wL�\\u0004�C`&�V��-�߅a/,\\u0000���F�W5�lZk8��]�\\t4<�e.��~�O=�Rs2�&`\\u0002\\u001d\\\"0\\u0013\\u000f\\u0001&>���k�#�T\\u0003i�u�����%���\\u0002�d% �$�t�'�%k.c���_N��\\t�@�\\b��̤\\n���\\u001a�k���\\u0014�),\\u0000�L���FH\\u0019K\\n��k\\n��ӫ\\nd���A\\u001e-���s\\u000e\\u0000�d\\u0002\\u001d$���{\\u0017�OC�|\\u0015�Wi��\\n\\u0000򘞍!�\\u0000�1�:� H�b�yT��\\t\\u0000\\u0000\\u0010\\u0000IDAT���8xh�������\\t,@���^M\\u0017um�_\\u000b���\\u0015\\u0017\\u0015\\u0000�1N�]�\\u0002��_;��0�\\u001c\\u000e/��zTū\\t�@G\\bď�\\u001d��j��Ba��\\u0000���h��F� @��.\\f\\u0010\\u0018D\\\"Ǔ��Da�\\u001a���\\u0004L`^\\u0002��:��[d�������(,\\u0015�U�l\\fy\\u000bX\\\"�\\u00128�W�Re�1\\u0001\\u0013�\\u0004�\\u0007\\u001ey*ճm�+�T!��\\u0002�4˯�����X\\\"\\u0005A%p8x�\\u0010�L�\\u0004�A���i��Ю�����kHg�bRQ\\u0001@L\\u0000$�Ў%@\\u0012�p8p�0�L�\\u0004�A�ޝO`z&}q�U~\\fɣ��\\u0006E\\u0005\\u0000�,���5\\u0015@J��Hɞ�dL�@\\t�|�\\u000f�h/����\\u001f���\\u0004L�x\\u0002w=��X\\u001d�?���F|�N����\\u0000 \\u0006w��A\\u001fPcI�Y\\\"�t���Of\\u0019�~}����,\\u0000�L�x\\u0002��ݏ�\\u0001\\u0000�_&�D����q\\u000e\\u0005��\\u0005\\u0000a,�@}RC\\u001aЧ#R��r\\u0001��!\\r���������\\u0004L�l\\u0002߹�\\u0001,�֯\\u001cE��\\u0002\\u00005��Em1k#ȩ7�e��5�Z���3/�[��\\u0004L�T\\u0002?��C�jm����\\n�\\u0014\\u0017\\u0000�`\\u0016W��x��ӣ�����\\u0016\\b\\t�'\\u000e€6�{����\\u0015���\\t\\u0014J�Gw?��\\u0007\\u000e.��n�(,\\u0015\\u0015\\u0000\\u0010L�C2�ʇdd���Nl�-�7�!�P�q�f��\\u0010\\bD\\\"�+��V��ׁϳ/��>�\\u001b\\u00130�2\\t|����Ϳ�\\u0013T���-\\u001dTȦ�\\u0000 }iD�\\u0017_� #\\u0018�(UKRz�\\u0000�.�I钹�+\\t�.�@���<\\u0003���b\\u0002�\\u0012���G��K��v�?��?���ٕ�\\u0014\\u0015\\u0000�y\\u0010'�6�\\u0011�Ԡΐ2&���:?��w�(�K�h9�������\\u0007ն^L�\\u0004J\\\"p��4��\\u001f��?�\\u0004]u)*\\u0000P}\\u0000\\rb\\u0015\\u0000�/$�kh/�ǣO>\\u0007'\\u00130��\\b|�\\u0007�`Ͼ\\u0003X�_kz\\u001e�z�w��T^\\u0000��ј�\\u0007����+���H`�yܻ�ɨ�W\\u00130�B\\b��g?��\\u0000 �RT)d\\\\��l�\\u001e�\\u0015�T�Z\\\\\\u0000��F������\\u00132Z,��\\u0014\\r\\u0004���ƍ���8\\u0000\\bS�j\\u0002�\\u0010��_�.F�G��ҙ��\\u001e�\\u0005\\u00001�!Y\\t$�X\\u001a����\\\"�O']��+qj9��\\u0007\\u000e�'|\\u001b ٳ7&0�\\u0004�u������!L��?��\\u001cs�}�_^\\u0000 �ɗ�q�H�\\u0015z\\u00152�\\u0000!bԓq�o\\r@��\\u001c�!\\u0010�\\u0018���'�d\\u0002&0�\\u0004�}�\\u0015�ѷ��\\u0019���qQ���ߓ���(b-*\\u0000h��i\\u0014��ej$RRA\\u0000�\\u0013\\u0019Y���ه�\\u001f�,��ы\\t�%�������^�3�G�?�O�\\u0015Q���_��%�E\\u0005\\u0000d\\f�2\\u0013�&����\\n!#'do�_\\u0019�\\u001eRj:��\\u00150\\u0006��\\u0017��d\\u0002&0v\\u0004���|\\u0017O<�\\\"�\\r\\u0019�\\b\\u0019:Z��,S�6������4��L��\\f�7������2\\u0015j3���*JK�\\u000f�T��p~��(-�>,S�6������4��L��\\f�7������2\\u0015j3���*JK�\\u000f�T��p~��(-�>,S�6������4��L��\\f�7������2\\u0015j3���*JK�\\u000f�T��p~�\\u0003ϼ�\\u001b��|\\u0005O<�\\u0012��#X��\\u001d�G��z}|IKQ\\u0001@��a,�RF�AݺL6q(�G�7�W^߫�z1\\u0001\\u0013X/\\u0002��{��ӯ��=�5�\\u0017�o�\\u000b�*}nQ\\u0001@3�k�KƗ\\u001e��6��\\b=#\\u0018p9\\u0012���LO�����o��\\u0004L`�\\t���5�����Ǻ߿��5�6����u��(+�\\u0015\\u0000�:��a�i�~\\u001d)?�.O��\\u0010� �ϋcV��/�\\u000f�y;�L�\\u0004ֆ��\\u0003\\u0007�w���������w�5��F��umȮͧ�\\u0015\\u0000��b�\\ntYV�\\u0018gu\\f�.Ϝ��4�����];q�\\u0003���\\u0004L`u\\t|�'\\u000f��������!��Z_��h�\\\"�J�\\u0005\\u0000�:\\u0015d}�D\\u000e������˯~\\u001fϾ�;\\u001a۫\\t��\\n\\u0013��'5������\\u0003\\u0007\\u000fa֟@�p��꩒�,�\\u0005\\u0000j�\\u0018��H�\\u0000�ZX�AԢX\\u001e��\\u000f��}�\\u000f��\\u0004L`�\\u0004\\u000e\\u001c:�\\u001f��!����\\u001f���\\u0011\\u000f���bu�+���G9��\\u0000 ��u\\u0017@6�{P�N�2��Ѿ\\n)�+|v��\\u0017���}\\u0013{�9\\b�f�j\\u0002�%p��a<��3��o܎��\\u001f�\\u001b��oށ�_z���5\\u001c�bA����\\u0002\\u0000�q2Π]�\\u0012���\\u0018\\u0000���\\u0006��>r�\\u0018����Dս��\\t,@��\\u0017v��wܯ��\\u001b����������\\u001fݽ\\u0013����+���\\u0007���tY�RRQ\\u0001@\\u0018K�Z�n$eX\\u000f ���f\\u0002���\\u001a�{��\\u0004�%x5�~\\u0002�}����������������=������\\u0013�\\u0001���������\\\"��TT\\u0000\\u0010�EPͣa_�k\\u001d�a\\u001e{���?�W���n|�\\u0007�hmd��j=s\\t\\u0016��G)<�{�����\\u0001>���ѿ�z������?HW�������W_�>~��.�.;�\\\"!�\\u0015n\\\\g�\\u0006�V�s\\u0014��\\n\\u0000d3a,�vt�;��\\n�y\\u001cuBR{2\\\"�O�\\u0002٧�|���\\f�}\\f�}\\f�}\\f��}������\\u001aE\\u0005\\u0000Q#���H\\r�� �W5\\u0007��i�#q�\\u0012i��=���~\\u0000%r��C�?\\u001fy\\\\\\u0011�\\\"��\\u0002\\u0000�Mrfj��\\u00129\\u0018ЎuA�\\u0012\\u001c�)q���uM\\u001c\\u0005\\f����\\u0003�B�>l\\u001fa\\u00072\\u0007���P�ZV\\u0000 +\\rg\\u0015�c\\u0019\\u0014��9��C\\u0010��� h1\\u0007A�b\\u000e���-\\u001c��<�����\\u0000��\\u001e2�Y��d��A��A\\u001e�n>�<�A�|\\u0006y����\\f� \\u0007u�\\u0019�A\\u000e�m�S��_W��\\u0000 *%��\\u0007t$��G;��-I���J\\u0018�\\u0007�\\u001d�o6z�t��|��\\b��C Ȱ�����@\\u0006\\u000f`e�c\\u001c���TT\\u0000@p�Y�F�t��Wl��\\u0015$̧Ϲ��\\u000f�G�\\u000b��|\\u0011����C~����\\u000fbR�RT\\u0000P!w�h\\u001fR��\\u001d�R\\u0018�\\u0011[�\\u001c�\\u001f\\u0010��_h\\u001b�q8\\u001fd��S-b-*\\u0000���Q\\b��.Í%��+>�1]n~��Y\\u0002�\\u000f�������C��G���t�I$�F<�N��>�|\\u000e\\u001f��+S�(*\\u0000���\\u0019\\u001c�\\u0007�M\\r&]6��z�^�e!��\\u0019EڊG��F��40$�C��E����\\u0018�&�o>�O2��\\\"{��C{����ֈδ���\\u0000�i\\u001c2�\\u0011i\\u0019�J��jr�v�)�l�yx�e;p��gb��T|\\u001c���Q�|�9g��.�e\\u0017���O9i��?���I'\\u001c�K.<\\u0007W_�\\u0003;�;\\u001b�l�<��h�c�V�_3Ơ�TT\\u0000\\u0010�\\u0000��\\u00076��H�\\u0014!%�\\u001ehqy�:W��D��0'�˜��l޸\\u0011�{�[��[o�M׿\\u0001o��r�������n��3NC�g���}����m�Ļn�\\u000eo��\\n�t�\\u001b����-�e��M:��N��߶��p핉���|\\r���r�r�\\u001b�\\u000f�+\\u00054������Q-L�/\\u001fr<>zڷ\\u0000Z\\u001b�D��ߨx��\\u0006\\u0000\\u0000\\u0010\\u0000IDAT1�q�\\u001cd�\\u0005�l���\\u0003��Z6zH�y�M���3O;�?�U�{n�\\u001e'\\u001cwl\\u000e\\u0002�y=Y��:��x�qi�?f˦#�q��m��-�����l�7��O��:����e�˯{�e����Æ\\r���ګ�}۩ ��\\r����|��CA�\\u0012�����\\u001166�\\u0019E�\\u0000���'\\u001a�`\\b��H̓�\\u001c�r�\\u0013\\u0001rn;����p��船���I\\\\��KA��z���c���E�6/�WF��eS��X����A�]�1Sy\\u0006`�\\u001aZ�M��g4\\u00110pV�\\u0007phZ�z?������\\u001e�fp����ɉ|e���_��/����:j��\\u0007�I�z4�ku~�~�%�Gs��dS�����g���\\\\d\\u001e��Ʃl�g�ә�q��\\u000e\\u001fN��\\u0002�G.7'\\u0011 �f'W\\\\r�f��\\u0011��/�Ny��>�\\\\��Ž�\\u000b�۞�o��K���\\u000e!W�|�\\u0003�ſ�E�oG|�!^����Cط�`:�\\\\���\\u000bI���\\u001c�1&8��\\u0016\\u0015\\u0000�W\\u0007�>�<������\\n1��\\u0015B�<8��,c��055���?g������\\u0016��ke��͛6.X���'�y^�h��\\\\�c�`��S]�����\\u0007-j��j�\\u0018cdH�,E\\u0005\\u0000\\u0007\\u000e\\u001cH��d~�\\u0017\\b���uq�\\u0000�@$r��\\u0011\\u000f���s��B�Ï>�\\u000e!��y�\\u0006�ʿ��\\u000b\\u0017~�!>��'�F\\\\=GЌ��ω[O�i���Qҽ\\u000f=���\\u000f\\u0004\\u0001���S\\u001b�\\u0003X��V܈�\\u001acPP**\\u0000�!����i>�\\u0019\\u0001R�5���{�\\u0012Ilܸ\\u0011;�\\u001f����\\u001f~<\\r�d����s�c�g\\u0018�\\u001f8�\\u0006NU�\\u001e��?.�h�g\\u0018v>�\\u0004�'��b�T?W�HIU�l_��\\u0006x�t\\u0007t�c�\\u0007����\\\\M>�\\u0001\\u0010�/{5\\u0003�XWF\\u0012gZIF'\\u000fi=s1��aq�p���_q�|�\\t����_>L/\\b;]���d�+�G\\u001e\\u0012�\\u000f\\u001fV=V�|�ſ�\\u0019���~fY�\\u0016\\\\�,Fp�CC.��b/�Z�[$�y������g��}aDE�\\u0013E�F��\\u0001@\\u0018\\u0015@��K������U���Đqi\\u0010Һ(�|汇�\\u000fy2�\\u0005�\\u0007\\u001f����QGRD�d۷��\\u0013G|��G\\u0014Ĵ��\\u001b���x+��^ī��I-��jC��<�m>X�>bV\\f���\\u0002��\\u0005\\u0010+Ԝ1ԣ���k�\\fJ�!\\ba\\u0019��8�Sp�1[����x�_��\\u000e\\u001c�����K/\\\\�'su�x��\\u0017����u�*�e��i�&�Ι:����\\u001f�\\u0005�\\u0005=�%�f��u>\\u0007tK,�+(*\\u0015\\u0017\\u0000D��~5~���}_\\u0019d�\\u0004I�3��:t\\\\�\\u001e���]N���\\u001e�KG��(=��\\u000bx��=C��!}}�\\u001dw�&l�v:FI�?��3��|4ٞ�����nń�~�ɧS\\u0005���\\u0004{d���\\u0015���\\u000bJ�c�m���F��\\u0014��\\u0019\\u0000�~5u�����Z���W�OG�^��r��>\\u001e��e�F�{�\\u0019���r�C�'nm�w�\\u0005�bbB��@5^�}��v=�����sو���O�b�x�����\\u0007�f\\u0000\\u0012\\u0007\\u0019d���N>�_yU'Z�Rd\\u0000\\u0010?���!��\\u000e����<\\u0006����\\u0018�;��\\u0015����\\u0018t��~�QN\\r�;F�����>��\\\"���l����@l��M\\u0018%=���]�O�|���W�?�1e\\u0014�\\u001b�c�\\f\\u0000�^���\\u0015��r��\\u001d�\\tC\\u0010I;�0\\u001c���#����\\u001f~\\\\��N�g�y�H�0��Tx豘�WU\\u0012�v�g�g\\u0018\\\"\\u0010۳/~��]�\\u001fQg�*��1u��\\u0004 �\\u0018�����ǘ��.n)2\\u0000�Vz9n\\u0003 .u�mH\\u0019����$�g\\u000e\\u0010\\r\\\"9'�iG[�����gb�+�\\u0007\\u0014\\u0000P���oԁ3��Ϟ�%�T�v��c�3\\f�\\u0006�\\u0005�\\u0007��\\u0017���Þ�j\\u001f���+s����o�R��UI\\u0014\\u001b\\u0000�����2�\\u001a�B$M�)��\\t}X\\\"�W��q�\\r�ȩ��N�xğ�}�g���!YG���\\u0013��Yg�6p���c�m�K/\\u001c����3\\fO>��\\u0011��r��a�\\u0015��\\u0018\\u001f����\\u0012\\u0014��\\r\\u0000^~�U�,S��̭G��Uu>j�u(�.oxU�G��U�$U�;|�;�\\u0018�y�)Q�\\u0005�\\u0007�S�h%��F�\\u0001�\\u00188�~��\\u001cĨ�T��Ӟ��x�_aܩ ���\\u000f%�;��m�#����$^_�Zl\\u0000\\u0010�l�?\\u0006��@}/�\\u001fi]^^�!�4��x\\\\v�hߙ����\\u001f�\\t�\\n�d{xNn�ą�\\u0015�����#�\\f\\u0003Zi\\u000f\\u0017�s\\u00166mڈQ��Ǟ��E��G��i�8#���3^�߳g\\u001fb,��+q-6\\u0000�Ɗ\\u001f4\\t瞼A���\\u0019��\\\\\\u0014�#\\r\\u001a\\\"��x�8o���:>�Q�A������F\\u001a8\\u000f\\u001f�ƃ\\u000f?\\u0019g߫��s�?`���O\\\"�����\\u0007�Ԡ-��q<�\\b��y\\u0017�):\\u0000xZ�\\u0002����Z�j�$�+��n>��\\u0018�������Ʃ\\rQ����cO�v��%#~��'���C\\u0007U\\u000f-�C{�s���\\u0010��Yg����û`{�R{�o�=�M��\\u0017ųܥ�\\u0000 �{����\\u0006�+5�%�\\u0015��4���\\u0012Im!���\\u0011�\\\"�dGx\\\\�c�\\u0007�\\u001ez�I\\u001c���B>'m=\\u001e��|b4ۂ�1p�(��B��e\\u000b�Q��������˯ \\u0012U�$[p��<|>�\\u0001$�D-[��M��{��.tSt\\u0000\\u0010m���/�������*$�\\u001ft\\u001b�U�\\u0003�m\\u001e\\u0014q�\\u0000\\u000f�\\u0019У���,��ԓ��D\\r�\\u0018!�}s��ɞ$��gԁ�e\\r�Ͽ�[g�z��=�?99�\\u000b��>BK\\u0000�w\\u0018���P��Z*��X�\\u000f:����@ՙ\\r����se_�C��\\u0000���T��m�;��+�2��I*�|R�JЯ�\\u001c\\u0003<:���\\u000bF{��E�,��8�\\u0003���~6oڄ\\u000b�\\u0019��\\u0007��\\u000bZj�;�;\\u000b\\u001b6Lb�\\u0014�0���\\u0014�Ÿ�?Y��'���2�����z�@���\\u0000���Cx�E]�S�1,�\\u001a��h?,+��#\\u001f��.�\\u001c����܀�F��Y\\f�P\\\"��%����3G\\u001e8\\u001f~��\\u001f����5��e#ފ��������h>��y�\\u0011�e�������}�W\\u0010cG�Q�k�\\u0001@4޳Ͻ\\u0010\\\";\\u00079��hC09\\r���\\u001c�]�)>\\u0017�3\\u0019�\\u0002���\\u000ea�cO�:��%�3j\\u0010�Ȯ�1}h�W[r�>�a8��\\u0000r��Oѭ�\\u0013�;����\\u0007�~�a�|������\\u001f[���3�E\\u0013\\u0016�v\\\"\\u0000x^3\\u0000ӺBh�i�U��uEC\\u0001B,Bt��駜$\\u0012\\u000b/q��V^�}���\\u001f�p%t�}\\u000f?�\\u0018��Զ��1b[<���x���S5�n����\\u0002��o���Ӭ��E�K'\\u0002�h��t��\\f7\\u0007��G\\u0006$\\t%Һ�f�ylٲYְ����O�\\u000bM�}��i�\\u0006���ux�ŗ��k{ҁd��˖M��\\u0016���?پ�w��}�\\u0003J�����]� =C�cK_:\\u0011\\u0000D#�z�\\u0019D @�(�\\nԟZ\\u0019$\\u0015�Z'�9t���W^\\rS9����/����V;y��g\\u001f⡸�VB�\\u000f�W��\\u001f���w�^�y�%~���'��v�\\u0016q�\\u001fki{rL�gf��c�s1G5�B\\n;\\u0013\\u0000����|�9��\\n�N\\u0012�p�)ғ�\\u0017I�ֻ��'��B��\\u001fF��g���7p���j��#^\\u000b\\u00052w?���������8�ߓO=�\\u0000�0��:\\u0013\\u0000D�>��S!�*�\\u0017;$�؟�\\u0014\\u0012ҡ\\u0014�IHט�\\u0002i��\\u000e%�\\u000b\\u00022�B�Ĵ�=\\u000f>���=\\u000f=��t�9�������������4�7\\u0011\\b�[?��7\\u0015���\\u0016�߾\\u0003\\u0007�;�I�8�&fb�U{��<u�tڤ�\\u000f�P\\u0004�$�C��|�$\\u0003cD�g�k�\\u0002���+��\\u0004ѓ�#'\\u0018\\\"��\\b\\u0019\\u0019.��\\u0015��\\u0006\\u0016\\t��]\\b�;�~\\u0010��ޝ迊������?Ɲ?} ��^��\\u0006����\\u000e\\u001e�\\u001f~��x���\\u0011�X�R<\\u0004���n��k�N\\u000f�)\\u000b�z�N\\u000b�/�b�ݟ|\\u0017\\u0011���ƺ��\\u0001�q�\\u0003��o����\\u001fX��f\\u001f�����\\u0013\\u0004��S�>�\\u0003\\u0007��VV\\u0017�N\\u0005\\u0000Ѡ�<�d���N�6ON$Ie$���.��[ë >�t��\\u000f5P��?�&b�}����9�]����\\u000f\\u001d�\\u000f~r\\u001f��\\u001f|]��w����k� &�C��hi����U��?�\\u0001����\\u0007�m���\\rܣ���=*c�z��}t��9��.�p�M{\\f�u.�oP<�?C����ҹ\\u0000`��}x^Ӹ�\\u0018\\u0015�Z\\u0002��S��1�c�z�{�\\u0000\\u0000\\u0010\\u0000IDAT�\\u0003سo���ٌ�\\u0001 Ƿ�c&����As����5ۿ���c�d�^�ut,u.\\u0000��}0��\\u001c;U\\u0005�\\u000f!e�IZG�`\\u001e���!s�=d\\u000e���ad{`돏{�;�\\u001f��a�Kk'\\u0003�={�����G\\u0018q\\\\S�T\\u0010��֑8�G�`{�\\u001cl\\u000f���!s(�\\u001eb,أ�at0u2\\u0000�v���.��zN��r,\\u0005A�9\\b�\\u0016s\\u0010\\u0004-� \\bZ�A\\u0010�,�C�����*�[1\\u000f?�D�a'��\\u0006\\u0000���������q\\u001b@B�&��\\u0005\\u0006��\\u0007y�� \\u000f�� \\u000f�� \\u000f�� �v���\\u000f=�\\u0018\\u000b����\\u0000 �;~\\u0018�W�\\u0007�\\\"Ck\\u0018kUK\\t\\u0005\\u0003��*�hu�n��\\u001c$�G\\u0004\\u0006��|\\u0006y��\\f��}@�����ʫ�㩧��O0O�t\\u0000\\u0010L�{��\\u0010qg\\u000f����l����ۧC�typ1�l\\u0007d��C�!1�\\u0007'rVw9̧�\\u001el\\u001f��\\u0000%r���\\u001a��½���Gvv�|\\u0000\\u0010Q�\\u0013O=��R<��\\u001d�蕄��e$�T����BfN�9�sK�\\u000b���'s ���'�)9X����\\u0013��\\u000f��ّ��x�\\u0003���P�b��C��Ac%��ՉIs1���d��a�h�}ă\\u000f�|<\\\\�W\\u0007\\u00002��6�=����\\u0006��G�?\\r�r�`)8h'q#��<\\u0000�C�\\u0007d��������އ\\u0010?,\\u0005'8\\u0000�����_�\\u0013O>�4]��A-i�u��\\u0000\\u0012\\u0007(��yD�\\u0013v sHva\\u001d�\\u0003���y��<\\u001e��\\u0014�W���\\b8\\u0000H\\u0018��\\u0007\\u001f��={�\\u0012�8v,�\\u0002\\u0006:3��E\\u0010��� h1\\u0007A�b\\u000e�\\u0000��\\u001c^�o���G�\\tz�\\b8\\u0000H\\u0018�fzz\\u0006w�u?�gfr����NϚ�^~����#�]�H5\\u0006�A\\u001ca'\\u0001I�\\u0011���*���A\\u0001G�\\u0011r2�y8��#�uǾ§�o�����yX�\\u0016��\\rD�\\u0018�\\u0003��\\\"c�>\\\"1�+#9��z\\u0007\\t�נ�\\u0010\\u0010�Ɇ�xhw���\\f�\\u0017�\\u0019�1��Ѿ����\\b�.K��G�3\\u0000}0�����'㫁��N�\\u001c��\\u0016\\u0012 CZ'�n��!̓�a{��\\u001fH�\\u000e*������g�ǓOu�\\u00070Or\\u00000\\u000f��\\u0018_{}/��4+9+�%�^)��ե����8��|�쥱�FB����&��*6?��\\u000f*��a���M�\\u001f�����\\u001e�S�:\\u0007\\u0001\\u0007\\u0000s@��x\\u001e��\\u001fߓ�.�8��l\\nl�^H�\\t�����0�\\u0003J�V�&Ѫ�G,*�\\r\\u000f�\\u000b��\\u001fۏ�c�?@���R����\\u0007�;�A�r���9\\b8\\u0000�\\u0003J��w�~���{�?�\\bg\\u001d^�`*�.\\f�4\\u000f�\\u0000l\\u000fH���\\u0002�\\u000e�C;�?==����S�\\u000f�Ӽ\\u0004\\u001c\\u0000̋&\\u0017���k�����(�l�[R�_���:)\\t�t� \\u0012�|\\u001dOJ*����rq��A\\u000e��3ȃ\\u001c��g�\\u00079��� \\u000frP/��LU�+��u\\u000bW���Q\\b8\\u00008\\n���ŗv�λ�C�\\n��P��\\u0002T\\\\�\\u000fŽ�\\n��wy�\\u0011����4�0,ͧ\\u0002i�\\u0018��F\\u001fG�@�\\u001f\\\\��\\u0000ܡ\\u000b��^~E{^\\u0016\\\"�\\u0000`!Buy�z�=�?\\u001c6-\\u001b��+�ڋ� �Tit�z E��a>�h0pdY!K I�\\u0012\\u0007(Q{9��4HSp��9j\\u000fJIV�FD��̯\\u0012\\u0005\\u0006�,+d\\t$�5>?���x�4\\u001a\\u0001\\u0007\\u0000�qJG��H>��1�W�si\\u0007����\\u0016�PA�t9�\\u001e���a�\\u001eQR���A{1�A\\u001e��A\\u001e��\\u000f�$����W��+:\\u0001/#\\u0012p\\u00000\\\"�氝��£�?-�\\u0002��c�ч��:\\u0000��\\t�r\\u0004\\u00072\\u0007I����G~�+�.��T@�/\\u0000d\\u000f���G%\\u001c�\\u001f\\u000b�����B�pZ\\u0014\\u0001\\u0007\\u0000�•\\u000f��\\b�/~S��\\u001e�J�`�Y!\\u0014\\u0013\\u0000u~#\\t�\\\\�\\u0002��\\u0000�}��!�\\u000b���Z6z�tyͥ�|r�^��]�{�È�}��E\\u0012p\\u0000�H`��\\u000f?�D~0p�B���'�:i��\\fd��\\u0003�>��^ק\\u0003���\\u0007y\\u0000���\\f�\\u0000\\u0006u�\\u0019�\\u0001\\f��3�\\u0003\\u0018�ǀ\\u000f���ɽx��'��e)\\u0004\\u001c\\u0000,�Z������\\u001f7�\\u0013����1>u�����b\\\"�!�\\u001e*ف���� K�\\u0017k[���\\u0019�������/����\\u0012p\\u0000�Tr����\\u0001���]8p� ��Թ����N�ZZ7\\u001f�C\\u0005�?�@\\n\\u0006�4��\\u0017*�\\u0018�\\u001c:t\\u0018�������r�\\u0003��\\u0012��w��\\u001a���\\u001fc���Ɉ�U�l��a\\u001e\\\"\\u0010׾�젔��#s��\\u0010�3��@��Z�߁\\u0003\\u0007��݁W^}]��e�\\u0004\\u001c\\u0000,�`����\\u0013��ޝx���H}\\u0001\\u0000ID�@\\b�s\\u000f�\\u0000\\u0019\\u0005�v�T\\r�\\u0018�]N��2\\u001f������a� �y��}������ށ�{��ie\\b8\\u0000X\\u0019��]\\\":��\\u0001\\u000f�|<�Ul�\\u0013ƎdUK�\\u0002\\u001a�Sh�ގ�a>@c\\u000f2�0��\\u001e��\\u0003\\u0004�\\u0000c>m�\\u001f@����g���<����'\\b\\u001f\\u000b�\\u0015#�\\u0000`�P�7�_޺��G�?��{U��\\u001a��ץ` ���|�G�˻Egry�cX���\\u0003p�@�\\u0002�.�d9����C���w�\\u0007\\u001f�\\u0005�����e�\\b8\\u0000X9�\\u0003�\\u0014\\u000f����{U����a�!��f\\t�K�7<�\\u0001.P\\nNU-ͯ2�4�c�C\\u0005�zU�Fϲ\\u0002������e\\u0005��U-\\u001b=�\\n��Z6z�\\u0015P�W�l�,+�ίj��YV@�_ղѳ��:��e�gY\\u0001u~U�Fϲ\\u0002������e\\u0005��U-\\u001b=�\\n��Zf\\u001dJ��?�٪?����i_�]��\\u0001�j�����\\u0000�������ҠN��\\u001c�f�W\\u0015*�N���\\u0007�x��c>�ar�}\\f�\\u0007rP\\u001fg>�<�T��C�Ž�\\u000e\\u0001\\u0007\\u0000�õ��33\\u0015~����>��\\u001f�\\u0004U\\u0016n\\\\R���#�\\u0003�\\u001d�\\u001eD�������m��?��\\u001e�}�C\\b�)c�\\u0004\\u001c\\u0000�\\\"������3��7��ـg\\u0000E��ؕ�܉R�:?i�|�.�\\u0014��`{\\b;�9\\u0014�\\u001f�x�Y|�O���}>*�u\\r\\b8\\u0000X\\u0003��G�\\u0013�1\\u001b��ށ���Cz�K�}�\\u0003HM�i��C�<\\u0005I=^Ü��|��7���>V�>H��e�{��\\u0015�;��O���Z.\\u000e\\u0000֒v�Y/�~\\u0015���\\u000fq�};\\u0011�\\u0014H�w�Y�:��:�T�\\n]�y�sy�����x�E�2��T\\u001ei>��X$i>�E������k\\u001f1��SM���woG�D���5&�\\u0000`��7\\u001f\\u0017_\\u0017|��'��?�A�-����z2i�\\\"��s�L�.��s\\u000f$==�4]�v��|�!�>\\u0012\\u0006dsX��X��#\\u0018{��g�?�>\\u001ey��\\u0014���v�\\t8\\u0000Xk�C�\\u0017�\\u0005���4#\\u0010��\\\":\\u0007z^*wӞ�!}�8�g��\\\\\\u001a�|�'\\b4�0,m\\u001fA\\u0007\\u0018���X��\\u0001�~�\\u0005|��?L�I5|\\u001f�֕�\\u0003�u�?�᯾�\\u0007?��n��f\\u0004\\u001e�����J�tZ���@\\u0007\\u000f�M~#uŋ���F�\\u001c0�#���\\u0001�]�\\u0000����a9�GN\\n�?~��jf&�r�\\u0003~?��n���S+\\b8\\u0000hE3̞��{�\\\"\\u001e\\u0014��7��Gw=���i�\\u0011yW�� C�\\u001d��\\u000f��Z�\\u000ff4�?��.��|Y�r�o�\\u0001{i\\u0011\\u0001\\u0007\\u0000-j��Sٿ�\\u0000��AD ����pxz:\\u0015���;��AZ�\\u000b\\u00152\\u0006\\u0005$�h=s�\\u0012i��= �\\u000b(�+e\\u000f���x����w�+_�.�o'�c>\\u0002���\\u0001@K\\u001b�9��Ov�\\u0003��+_�\\u000en��x��\\u0017UT�ٷ�\\u0007un]\\u000fTH)����\\u0003���0\\u000f�G\\u0004\\u00012��0\\u000e\\u001d:�w��f\\u0002\\u000e\\u0000��:}�\\u0016_��\\u001f��ޏ��\\u001f}����\\u000f�ݯB�,\\u001d\\u0015�8v�\\u0007\\u0005�<���!a�=ԆP�e�h��n����y\\u0010_�����\\u000b\\u001f\\u0015�*S���\\u0004\\u001c\\u0000����8��\\u0007\\u000f�����wn�W��x�ǰg�>u�{׽��udIf�Z���,�,QK�֑%�%jI�:�$�D-�ZG�d��%Y�Ȓ�\\u0012�$k\\u001dY�Y��d�#K2KԒ�udIf�Z���,�,QK�֑%�%jI�:�$�D-�ZG�d��%Y�Ȓ�\\u0012�$k\\u001dY�Y��d�#K2KԒ�udIf�Z���,�,QK�֑%�%jI�:�$�D-�ZG�d��%Y�Ȓ�\\u0012�$k\\u001dY�Y��d�#K2KԒ�udIf�Z���,�,QK�֑%�%jI�:�$�D-�ZG�d��%Y�Ȓ�\\u0012�$k\\u001dY�Y��d�#K2KԒ�udIf�Z���,�,QK�֑%���{������o|\\u0017��z�|��pU�j9\\u0001\\u0007\\u0000-o��No���3ƃ6\\u0011\\f�y�}�o\\u0011D@\\u0010�5�__��{�Tub����,M�����B6<�'&�Ɇ���k��g�^<��3��'��\\u0007����屢��{�G��:�\\u0004\\u001c\\u0000�q�\\r��\\u001e�\\u0002DG� \\u0002����J_-�\\b=~n3\\u001f_��\\u0001R�\\\\�dv�d�.'�y`�����\\u0013�5\\u0019��<����=��\\u0013��� |H���)�\\u0014\\u0004���S1\\u0004\\u001c\\u0000\\u0014ӔGV$��z�y�=�������o����\\u001f݅{�\\u0018�>�\\u0014^|���.9���H��\\u0014�@��=\\bC\\u0011<\\\"xٿ� ��e<��i���~���\\\"�YY���-��\\u0019O=�\\u001c‡D��W�C�\\u0001@9m�`Mfff���{\\u0010�8��Ï��\\u001b�����W��m���\\r�=�\\b\\u0014\\\"�?�I�=��'���#��-���G��#�\\u0010W\\u0007;u���u�-�\\u001e��v���a�>��\\u0007\\u001fUp�\\u0013?��\\u0001D���\\u001dw�{���\\u000fȢo�m�����xh��߻3��x�3Ͼ�|C��\\u0005\\u001d�\\u000f(��\\u0003�b�ry\\u0015���g�>ĭ�\\u0017^ܝ�n���\\u0000\\u001e�i��,xP\\u0001C|\\u001d1�O�]�<��jM�އ�UP�\\u0000\\u0000\\u0003sIDAT�\\f��\\u00073\\u0007��\\u001cl\\u001f��\\u001a�C\\\\����#xp���!��q\\u0015��s/⅗v����)���\\u0019��]\\u0004\\u001c\\u0000��=|6&`\\u0002&`\\u0002&�&\\u0004\\u001c\\u0000�\\tf�\\t��\\t�?\\u0001נ,\\u0002\\u000e\\u0000�jO��\\u0004L�\\u0004L�\\u0004F\\\"�\\u0000`$L>�\\u0004L�\\u0004�N��/��\\u0003��Z��1\\u0001\\u00130\\u0001\\u00130�\\u0011\\b8\\u0000\\u0018\\u0001�\\u000f1\\u0001\\u00130��\\u0013p��#�\\u0000��6u�L�\\u0004L�\\u0004L`A\\u0002\\u000e\\u0000\\u0016D�\\u0003L�\\u0004L��\\u0004\\\\�\\u0012\\t8\\u0000(�U]'\\u00130\\u0001\\u00130\\u0001\\u0013X��\\u0003�\\u0005\\u0000��\\u0004L�\\u0004�N��/��\\u0003�2�յ2\\u0001\\u00130\\u0001\\u00130��\\u0012p\\u0000pT<.4\\u0001\\u00130��\\u0013p�K%�\\u0000�Ԗu�L�\\u0004L�\\u0004L�(\\u0004\\u001c\\u0000\\u001c\\u0005��L�\\u0004L��\\u0004\\\\�r\\t8\\u0000(�m]3\\u00130\\u0001\\u00130\\u0001\\u0013���\\u0003�yѸ�\\u0004L�\\u0004�N��/��\\u0003��[�u3\\u0001\\u00130\\u0001\\u00130�y\\b8\\u0000�\\u0007��M�\\u0004L��\\u0004\\\\��\\t8\\u0000(�}];\\u00130\\u0001\\u00130\\u0001\\u0013���\\u0003�9�8�\\u0004L�\\u0004�N��/��\\u0003��[��3\\u0001\\u00130\\u0001\\u00130�9\\b8\\u0000�\\u0003��L�\\u0004L��\\u0004\\\\��\\t8\\u0000(��]C\\u00130\\u0001\\u00130\\u0001\\u00138��\\u0003�#�8�\\u0004L�\\u0004�N���\\u0002\\u0001\\u0007\\u0000]he��\\u0004L�\\u0004L�\\u0004�\\b8\\u0000\\u0018\\u0002b�\\u0004L�\\u0004�N���\\u0006\\u0001\\u0007\\u0000�hg��\\u0004L�\\u0004L�\\u0004\\u0006\\b8\\u0000\\u0018�a�\\u0004L�\\u0004�N���\\n\\u0001\\u0007\\u0000]ii��\\u0004L�\\u0004L�\\u0004�\\b8\\u0000��]\\u00130\\u0001\\u0013�:\\u0001׿;\\u0004\\u001c\\u0000t��]S\\u00130\\u0001\\u00130\\u0001\\u0013�\\u0011p\\u0000�C�\\u001d\\u00130\\u0001\\u0013�:\\u0001׿K\\u0004\\u001c\\u0000t��]W\\u00130\\u0001\\u00130\\u0001\\u0013�\\t8\\u0000�AX��\\t�@�\\t���\\\"�\\u0000�[��ښ�\\t��\\t�@\\\"�\\u0000 a��\\u0004L�\\u0004�N���\\u001a\\u0001\\u0007\\u0000]kq��\\u0004L�\\u0004L�\\u0004D�\\u0001� x1\\u0001\\u00130��\\u0013p��G�\\u0001@���56\\u0001\\u00130\\u0001\\u00130\\u00018\\u0000�\\u0011��\\t�@�\\t\\u0018@\\u0017\\t8\\u0000�b���&`\\u0002&`\\u0002�'�\\u0000��&`\\u0000&`\\u0002]'��w��\\u0003�n��km\\u0002&`\\u0002&�q\\u0002\\u000e\\u0000:n\\u0000��\\t�@�\\t��]%�\\u0000��-�z��\\t��\\tt��\\u0003�N7�+o\\u0002&�u\\u0002�w\\t8\\u0000�nۻ�&`\\u0002&`\\u0002\\u001d&�\\u0000�Í瀞�\\tt����e\\u0002\\u000e\\u0000�����\\t��\\t�@g\\t8\\u0000�lӻ�&`\\u0002]'��w���\\u000f\\u0000\\u0000���f�\\u001d\\u0000\\u0000\\u0000\\u0006IDAT\\u0003\\u0000\\u0018�[\\u0001�\\u0013K\\u0000\\u0000\\u0000\\u0000IEND�B`�\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":8,"id":"coder_write_7","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":8,"function":{"arguments":"{\"path\":\"client/public/icon.svg\",\"content\":\"\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":9,"id":"coder_write_8","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":9,"function":{"arguments":"{\"path\":\"client/public/manifest.webmanifest\",\"content\":\"{\\n \\\"name\\\": \\\"Animated Weather\\\",\\n \\\"short_name\\\": \\\"Weather\\\",\\n \\\"description\\\": \\\"Weather you can feel: the whole screen moves with the weather where you are.\\\",\\n \\\"start_url\\\": \\\"/\\\",\\n \\\"scope\\\": \\\"/\\\",\\n \\\"display\\\": \\\"standalone\\\",\\n \\\"background_color\\\": \\\"#cfd9e4\\\",\\n \\\"theme_color\\\": \\\"#3f5876\\\",\\n \\\"icons\\\": [\\n { \\\"src\\\": \\\"/icon-192.png\\\", \\\"sizes\\\": \\\"192x192\\\", \\\"type\\\": \\\"image/png\\\" },\\n { \\\"src\\\": \\\"/icon-512.png\\\", \\\"sizes\\\": \\\"512x512\\\", \\\"type\\\": \\\"image/png\\\" },\\n { \\\"src\\\": \\\"/icon-maskable-512.png\\\", \\\"sizes\\\": \\\"512x512\\\", \\\"type\\\": \\\"image/png\\\", \\\"purpose\\\": \\\"maskable\\\" },\\n { \\\"src\\\": \\\"/icon.svg\\\", \\\"sizes\\\": \\\"any\\\", \\\"type\\\": \\\"image/svg+xml\\\" }\\n ]\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":10,"id":"coder_write_9","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":10,"function":{"arguments":"{\"path\":\"client/public/sw.js\",\"content\":\"// Keeps the app's own files so it opens without a connection. Weather is never cached here: the\\n// app keeps the last weather it saw in the browser's storage and labels it with its age.\\nconst CACHE = \\\"aw-shell-v1\\\";\\nconst SHELL = [\\\"/\\\", \\\"/manifest.webmanifest\\\", \\\"/icon.svg\\\", \\\"/icon-192.png\\\"];\\n\\nself.addEventListener(\\\"install\\\", (event) => {\\n event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));\\n});\\n\\nself.addEventListener(\\\"activate\\\", (event) => {\\n event.waitUntil(\\n caches\\n .keys()\\n .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))\\n .then(() => self.clients.claim()),\\n );\\n});\\n\\nself.addEventListener(\\\"fetch\\\", (event) => {\\n const req = event.request;\\n const url = new URL(req.url);\\n if (req.method !== \\\"GET\\\" || url.origin !== self.location.origin || url.pathname.startsWith(\\\"/api/\\\")) return;\\n if (req.mode === \\\"navigate\\\") {\\n // Pages: network first, the saved app page when offline.\\n event.respondWith(fetch(req).catch(() => caches.match(\\\"/\\\")));\\n return;\\n }\\n // App files: the saved copy if there is one, refreshed in the background.\\n event.respondWith(\\n caches.match(req).then((hit) => {\\n const fresh = fetch(req)\\n .then((res) => {\\n if (res.ok) {\\n const copy = res.clone();\\n caches.open(CACHE).then((c) => c.put(req, copy));\\n }\\n return res;\\n })\\n .catch(() => hit);\\n return hit || fresh;\\n }),\\n );\\n});\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":11,"id":"coder_write_10","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":11,"function":{"arguments":"{\"path\":\"client/src/api.ts\",\"content\":\"import { useEffect, useState } from \\\"react\\\";\\nimport { useQueries, useQuery, type UseQueryOptions } from \\\"@tanstack/react-query\\\";\\nimport { Forecast, Health, Nearest, Places } from \\\"@app/shared\\\";\\nimport type { z } from \\\"zod\\\";\\nimport { readCached, writeCached } from \\\"./lib/storage.ts\\\";\\n\\nasync function parsed(response: Response, path: string, schema: z.ZodType): Promise {\\n if (!response.ok) {\\n const body = await response.json().catch(() => undefined);\\n const message =\\n body && typeof body.error === \\\"string\\\" ? body.error : `${path} answered ${response.status}`;\\n throw new Error(message);\\n }\\n return schema.parse(await response.json());\\n}\\n\\n// Every response is parsed with its shared schema, so a wrong shape fails here instead of deep in the UI.\\nexport async function getJson(path: string, schema: z.ZodType): Promise {\\n return parsed(await fetch(path), path, schema);\\n}\\n\\nexport const useHealth = () =>\\n useQuery({ queryKey: [\\\"health\\\"], queryFn: () => getJson(\\\"/api/health\\\", Health) });\\n\\n/** A place to fetch weather for. `id` names its stored copy for offline use. */\\nexport type PlaceRef = { id: string; lat: number; lon: number };\\n\\nconst FIVE_MIN = 5 * 60_000;\\nconst FRESH_ENOUGH = 15 * 60_000;\\n\\nconst round = (n: number) => n.toFixed(2);\\n\\n/** index.html starts Home's forecast request before the app loads; the first matching query uses it. */\\nfunction takePrefetch(path: string): Promise | null {\\n const w = window as { __prefetch?: { url: string; response: Promise } };\\n const pre = w.__prefetch;\\n if (!pre || pre.url !== path) return null;\\n w.__prefetch = undefined;\\n return pre.response.then((r) => parsed(r, path, Forecast));\\n}\\n\\nexport function forecastOptions(place: PlaceRef): UseQueryOptions {\\n const cached = readCached(place.id);\\n const usable = cached && Date.now() - cached.at < FRESH_ENOUGH ? cached : undefined;\\n return {\\n queryKey: [\\\"forecast\\\", round(place.lat), round(place.lon)],\\n queryFn: async () => {\\n const path = `/api/forecast?lat=${round(place.lat)}&lon=${round(place.lon)}`;\\n const data = await (takePrefetch(path) ?? getJson(path, Forecast));\\n writeCached(place.id, data);\\n return data;\\n },\\n // Reused for five minutes, so switching between cities doesn't ask the free service again.\\n staleTime: FIVE_MIN,\\n // Fail (and fall back to the stored copy) when offline instead of waiting silently.\\n networkMode: \\\"always\\\",\\n retry: 1,\\n retryDelay: 800,\\n ...(usable ? { initialData: usable.data, initialDataUpdatedAt: usable.at } : {}),\\n };\\n}\\n\\nexport const useForecast = (place: PlaceRef | null) =>\\n useQuery({ ...forecastOptions(place ?? { id: \\\"none\\\", lat: 0, lon: 0 }), enabled: place !== null });\\n\\nexport const useForecasts = (places: PlaceRef[]) =>\\n useQueries({ queries: places.map((p) => forecastOptions(p)) });\\n\\n/** The text after the person stops typing for 300ms. */\\nexport function useDebounced(value: string, ms = 300) {\\n const [debounced, setDebounced] = useState(value);\\n useEffect(() => {\\n const t = setTimeout(() => setDebounced(value), ms);\\n return () => clearTimeout(t);\\n }, [value, ms]);\\n return debounced;\\n}\\n\\nexport const usePlaces = (q: string) =>\\n useQuery({\\n queryKey: [\\\"places\\\", q.toLowerCase()],\\n queryFn: () => getJson(`/api/places?q=${encodeURIComponent(q)}`, Places),\\n enabled: q.length >= 2,\\n staleTime: FIVE_MIN,\\n networkMode: \\\"always\\\",\\n retry: 1,\\n });\\n\\nexport const fetchNearest = (lat: number, lon: number, tz: string) =>\\n getJson(`/api/nearest?lat=${round(lat)}&lon=${round(lon)}&tz=${encodeURIComponent(tz)}`, Nearest);\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":12,"id":"coder_write_11","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":12,"function":{"arguments":"{\"path\":\"client/src/components/forecast.tsx\",\"content\":\"import type { Forecast } from \\\"@app/shared\\\";\\nimport { Skeleton } from \\\"@corbits/react-ui\\\";\\nimport { rain, temp, windFrom, windSpeed, cityClock, type Unit } from \\\"../lib/format.ts\\\";\\nimport { next24, next7, today } from \\\"../lib/view.ts\\\";\\nimport { WxIcon } from \\\"./weather-bits.tsx\\\";\\n\\n// The figures on Home: the stat tiles, the 24-hour strip and the 7-day list.\\n\\nfunction Stat({ label, value, sub, testId }: { label: string; value: string; sub: string; testId: string }) {\\n return (\\n
\\n {label}\\n {value}\\n {sub}\\n
\\n );\\n}\\n\\nexport function StatTiles({ f, unit }: { f: Forecast; unit: Unit }) {\\n const c = f.current;\\n const t = today(f);\\n const diff = c.apparent_temperature - c.temperature_2m;\\n const feelsSub =\\n diff <= -2\\n ? c.wind_speed_10m >= 10\\n ? \\\"Wind makes it feel colder\\\"\\n : \\\"Feels colder than it is\\\"\\n : diff >= 2\\n ? \\\"Humidity makes it feel warmer\\\"\\n : \\\"Close to the real temperature\\\";\\n const gust = unit === \\\"f\\\" ? Math.round(c.wind_gusts_10m * 0.621371) : Math.round(c.wind_gusts_10m);\\n return (\\n
\\n \\n \\n \\n \\n \\n \\n
\\n );\\n}\\n\\nexport function HourStrip({ f, unit }: { f: Forecast; unit: Unit }) {\\n const hours = next24(f);\\n return (\\n
    \\n {hours.map((h) => (\\n
  1. \\n {h.label}\\n \\n {temp(h.tempC, unit)}\\n \\n {h.pop == null ? \\\"–\\\" : `${Math.round(h.pop)}%`}\\n chance of rain\\n \\n
  2. \\n ))}\\n
\\n );\\n}\\n\\nexport function DayList({ f, unit }: { f: Forecast; unit: Unit }) {\\n const days = next7(f);\\n const lows = days.map((d) => d.loC).filter((n): n is number => n != null);\\n const highs = days.map((d) => d.hiC).filter((n): n is number => n != null);\\n const min = Math.min(...lows);\\n const max = Math.max(...highs);\\n const span = Math.max(1, max - min);\\n return (\\n
    \\n {days.map((d) => (\\n
  1. \\n {d.label}\\n \\n \\n {d.text}\\n \\n \\n {d.pop == null ? \\\"–\\\" : `${Math.round(d.pop)}%`}\\n chance of rain\\n \\n \\n Low \\n {temp(d.loC, unit)}\\n \\n \\n \\n , high \\n {temp(d.hiC, unit)}\\n \\n
  2. \\n ))}\\n
\\n );\\n}\\n\\n/** Placeholders in the same layout while the weather loads. */\\nexport function HeroSkeleton() {\\n return (\\n <>\\n
\\n \\n
\\n \\n \\n \\n
\\n
\\n
\\n {Array.from({ length: 6 }, (_, i) => (\\n
\\n \\n \\n \\n
\\n ))}\\n
\\n \\n );\\n}\\n\\nexport function ListSkeleton({ rows, tall }: { rows: number; tall?: boolean }) {\\n return (\\n
\\n {Array.from({ length: rows }, (_, i) => (\\n \\n ))}\\n
\\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":13,"id":"coder_write_12","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":13,"function":{"arguments":"{\"path\":\"client/src/components/layout.tsx\",\"content\":\"import { useEffect, useRef, useState } from \\\"react\\\";\\nimport { Link, Outlet, useLocation } from \\\"react-router\\\";\\nimport { Bookmark, Columns2, Home, Menu, Search, SlidersVertical, X } from \\\"lucide-react\\\";\\nimport { useQueryClient } from \\\"@tanstack/react-query\\\";\\nimport { Button, Sidebar, SidebarContent, SidebarItem, TopBar, TopBarActions } from \\\"@corbits/react-ui\\\";\\nimport { SceneLayer } from \\\"../scene/SceneLayer.tsx\\\";\\n\\nconst NAV = [\\n { to: \\\"/\\\", label: \\\"Home\\\", icon: , testId: \\\"nav-home-link\\\" },\\n { to: \\\"/search\\\", label: \\\"Search\\\", icon: , testId: \\\"nav-search-link\\\" },\\n { to: \\\"/cities\\\", label: \\\"Saved cities\\\", icon: , testId: \\\"nav-cities-link\\\" },\\n { to: \\\"/compare\\\", label: \\\"Compare\\\", icon: , testId: \\\"nav-compare-link\\\" },\\n { to: \\\"/settings\\\", label: \\\"Settings\\\", icon: , testId: \\\"nav-settings-link\\\" },\\n];\\n\\nconst AWAY_MS = 15 * 60_000;\\n\\n/** Coming back after 15 minutes or more away refreshes the weather without a tap. */\\nfunction useReturnRefresh() {\\n const client = useQueryClient();\\n useEffect(() => {\\n let hiddenAt: number | null = document.hidden ? Date.now() : null;\\n const onChange = () => {\\n if (document.hidden) {\\n hiddenAt = Date.now();\\n return;\\n }\\n if (hiddenAt !== null && Date.now() - hiddenAt >= AWAY_MS) {\\n void client.invalidateQueries({ queryKey: [\\\"forecast\\\"] });\\n }\\n hiddenAt = null;\\n };\\n document.addEventListener(\\\"visibilitychange\\\", onChange);\\n return () => document.removeEventListener(\\\"visibilitychange\\\", onChange);\\n }, [client]);\\n}\\n\\nexport function Layout() {\\n const { pathname } = useLocation();\\n const [open, setOpen] = useState(false);\\n const navRef = useRef(null);\\n const menuRef = useRef(null);\\n useReturnRefresh();\\n\\n useEffect(() => {\\n setOpen(false);\\n window.scrollTo(0, 0);\\n }, [pathname]);\\n\\n useEffect(() => {\\n if (!open) return;\\n navRef.current?.querySelector(\\\"a\\\")?.focus();\\n const onKey = (e: KeyboardEvent) => {\\n if (e.key === \\\"Escape\\\") {\\n setOpen(false);\\n menuRef.current?.focus();\\n }\\n };\\n addEventListener(\\\"keydown\\\", onKey);\\n return () => removeEventListener(\\\"keydown\\\", onKey);\\n }, [open]);\\n\\n const active = (to: string) => (to === \\\"/\\\" ? pathname === \\\"/\\\" : pathname.startsWith(to));\\n\\n return (\\n
\\n \\n \\n setOpen((o) => !o)}\\n data-testid=\\\"menu-button\\\"\\n >\\n {open ? : }\\n \\n \\n Animated Weather\\n \\n \\n \\n \\n \\n
setOpen(false)} aria-hidden=\\\"true\\\" />\\n \\n \\n \\n \\n

\\n Weather and city search by Open-Meteo.\\n

\\n
\\n
\\n
\\n \\n
\\n
\\n
\\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":14,"id":"coder_write_13","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":14,"function":{"arguments":"{\"path\":\"client/src/components/places.tsx\",\"content\":\"import { Link } from \\\"react-router\\\";\\nimport { Navigation, Search } from \\\"lucide-react\\\";\\nimport { Button, Card, Skeleton } from \\\"@corbits/react-ui\\\";\\nimport { useForecast } from \\\"../api.ts\\\";\\nimport { temp } from \\\"../lib/format.ts\\\";\\nimport { glance, placeHref, type Place } from \\\"../lib/places.ts\\\";\\nimport { readCached, useStore } from \\\"../lib/storage.ts\\\";\\nimport { WxChip } from \\\"./weather-bits.tsx\\\";\\n\\n/** One place in \\\"Your places\\\": chip, name, condition and temperature. One tap shows it on Home. */\\nexport function PlaceRow({ place, current }: { place: Place; current: boolean }) {\\n const unit = useStore((s) => s.settings.unit);\\n const q = useForecast(place);\\n const data = q.data ?? readCached(place.id)?.data;\\n const g = data ? glance(data) : null;\\n const label = place.isHere ? \\\"My location\\\" : place.name;\\n const detail = g ? (place.isHere && place.name !== \\\"My location\\\" ? `${place.name} · ${g.text}` : g.text) : q.isError ? \\\"Weather unavailable\\\" : \\\"Loading…\\\";\\n return (\\n
  • \\n \\n {g ? : }\\n \\n {label}\\n {detail}\\n \\n {g ? temp(g.tempC, unit) : \\\"\\\"}\\n \\n
  • \\n );\\n}\\n\\nexport function PlacesPanel({\\n places,\\n currentId,\\n onLocate,\\n locating,\\n}: {\\n places: Place[];\\n currentId: string | null;\\n onLocate: () => void;\\n locating: boolean;\\n}) {\\n return (\\n \\n \\n \\n Search for a city\\n \\n
    \\n

    Your places

    \\n \\n
    \\n {places.length ? (\\n
      \\n {places.map((p) => (\\n \\n ))}\\n
    \\n ) : (\\n

    Places you save show here with their weather.

    \\n )}\\n \\n
    \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":15,"id":"coder_write_14","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":15,"function":{"arguments":"{\"path\":\"client/src/components/query-state.tsx\",\"content\":\"import type { ReactNode } from \\\"react\\\";\\nimport type { UseQueryResult } from \\\"@tanstack/react-query\\\";\\nimport { Button, EmptyState, Skeleton } from \\\"@corbits/react-ui\\\";\\n\\n// Every screen that loads data renders through this, so loading, failure and \\\"nothing yet\\\" look the\\n// same everywhere and none of them is forgotten.\\nexport function QueryState({\\n query,\\n isEmpty,\\n empty,\\n children,\\n}: {\\n query: UseQueryResult;\\n isEmpty?: (data: T) => boolean;\\n empty?: ReactNode;\\n children: (data: T) => ReactNode;\\n}) {\\n if (query.isPending)\\n return (\\n
    \\n \\n \\n \\n
    \\n );\\n if (query.isError)\\n return (\\n
    \\n void query.refetch()}>Try again}\\n />\\n
    \\n );\\n if (empty !== undefined && isEmpty?.(query.data)) return <>{empty};\\n return <>{children(query.data)};\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":16,"id":"coder_write_15","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":16,"function":{"arguments":"{\"path\":\"client/src/components/weather-bits.tsx\",\"content\":\"import type { ReactNode } from \\\"react\\\";\\nimport {\\n Cloud,\\n CloudDrizzle,\\n CloudFog,\\n CloudLightning,\\n CloudMoon,\\n CloudRain,\\n CloudSun,\\n Moon,\\n Snowflake,\\n Sun,\\n Wind,\\n type LucideIcon,\\n} from \\\"lucide-react\\\";\\nimport { FilterChip } from \\\"@corbits/react-ui\\\";\\nimport { toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport type { IconName } from \\\"../lib/weather.ts\\\";\\nimport { store, useStore } from \\\"../lib/storage.ts\\\";\\n\\nconst ICONS: Record = {\\n sun: Sun,\\n moon: Moon,\\n cloud: Cloud,\\n \\\"cloud-sun\\\": CloudSun,\\n \\\"cloud-moon\\\": CloudMoon,\\n rain: CloudRain,\\n drizzle: CloudDrizzle,\\n storm: CloudLightning,\\n snow: Snowflake,\\n wind: Wind,\\n fog: CloudFog,\\n};\\n\\n/** A weather icon. With `label` it is announced; without, it is decoration. */\\nexport function WxIcon({ name, label, className }: { name: IconName; label?: string; className?: string }) {\\n const Icon = ICONS[name];\\n return label ? (\\n \\n ) : (\\n \\n );\\n}\\n\\n/** Round sky-coloured chip showing a place's weather at a glance. */\\nexport function WxChip({ wx, icon, large }: { wx: string; icon: IconName; large?: boolean }) {\\n return (\\n \\n \\n \\n );\\n}\\n\\n/** The one °C/°F choice for the whole app; also switches km/h↔mph and mm↔in. */\\nexport function UnitToggle({ prefix }: { prefix: string }) {\\n const unit = useStore((s) => s.settings.unit);\\n const choose = (u: \\\"c\\\" | \\\"f\\\") => {\\n if (u === unit) return;\\n store.setUnit(u);\\n toast(u === \\\"f\\\" ? \\\"Showing °F. Wind is now in mph and rain in inches.\\\" : \\\"Showing °C. Wind is now in km/h and rain in mm.\\\");\\n };\\n return (\\n
    \\n \\n choose(\\\"c\\\")}>\\n °C\\n \\n \\n \\n choose(\\\"f\\\")}>\\n °F\\n \\n \\n
    \\n );\\n}\\n\\n/** Offline, error, location and preview messages. Errors are announced at once. */\\nexport function Notice({\\n icon,\\n title,\\n children,\\n tone = \\\"info\\\",\\n testId,\\n actions,\\n}: {\\n icon: ReactNode;\\n title: string;\\n children?: ReactNode;\\n tone?: \\\"info\\\" | \\\"error\\\";\\n testId: string;\\n actions?: ReactNode;\\n}) {\\n return (\\n \\n \\n {icon}\\n \\n
    \\n

    {title}

    \\n {children ?
    {children}
    : null}\\n {actions ?
    {actions}
    : null}\\n
    \\n
    \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":17,"id":"coder_write_16","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":17,"function":{"arguments":"{\"path\":\"client/src/lib/format.ts\",\"content\":\"import type { Forecast } from \\\"@app/shared\\\";\\n\\n// Every figure the app shows goes through here, so one unit choice changes all of them.\\nexport type Unit = \\\"c\\\" | \\\"f\\\";\\n\\nconst MINUS = \\\"\\\\u2212\\\";\\n\\n/** Whole degrees with a true minus: \\\"−3°\\\". Never \\\"−0°\\\". */\\nexport function temp(celsius: number | null | undefined, unit: Unit): string {\\n if (celsius == null || Number.isNaN(celsius)) return \\\"–\\\";\\n const value = Math.round(unit === \\\"f\\\" ? celsius * 1.8 + 32 : celsius);\\n return value < 0 ? `${MINUS}${Math.abs(value)}°` : `${value === 0 ? 0 : value}°`;\\n}\\n\\nexport function windSpeed(kmh: number, unit: Unit): string {\\n return unit === \\\"f\\\" ? `${Math.round(kmh * 0.621371)} mph` : `${Math.round(kmh)} km/h`;\\n}\\n\\nexport function windNumber(kmh: number, unit: Unit): number {\\n return Math.round(unit === \\\"f\\\" ? kmh * 0.621371 : kmh);\\n}\\n\\nexport function rain(mm: number | null | undefined, unit: Unit): string {\\n const v = mm ?? 0;\\n if (unit === \\\"f\\\") {\\n const inches = v / 25.4;\\n return `${inches === 0 ? \\\"0\\\" : inches < 0.01 ? \\\"<0.01\\\" : inches.toFixed(2)} in`;\\n }\\n return `${v === 0 ? \\\"0\\\" : v < 0.1 ? \\\"<0.1\\\" : Number.isInteger(Math.round(v * 10) / 10) ? Math.round(v) : v.toFixed(1)} mm`;\\n}\\n\\nconst DIRS = [\\\"north\\\", \\\"north-east\\\", \\\"east\\\", \\\"south-east\\\", \\\"south\\\", \\\"south-west\\\", \\\"west\\\", \\\"north-west\\\"];\\nconst SHORT = [\\\"N\\\", \\\"NE\\\", \\\"E\\\", \\\"SE\\\", \\\"S\\\", \\\"SW\\\", \\\"W\\\", \\\"NW\\\"];\\n/** Meteorological direction is where the wind comes from. */\\nexport const windFrom = (deg: number) => DIRS[Math.round(deg / 45) % 8]!;\\nexport const windShort = (deg: number) => SHORT[Math.round(deg / 45) % 8]!;\\n\\n/** A unix time shown as the city's own clock (\\\"14:00\\\"), using the forecast's UTC offset. */\\nexport function cityClock(unix: number, offsetSeconds: number): string {\\n const d = new Date((unix + offsetSeconds) * 1000);\\n return `${String(d.getUTCHours()).padStart(2, \\\"0\\\")}:${String(d.getUTCMinutes()).padStart(2, \\\"0\\\")}`;\\n}\\n\\n/** The city's local time right now. */\\nexport function cityNow(f: Forecast): string {\\n return cityClock(Math.floor(Date.now() / 1000), f.utc_offset_seconds);\\n}\\n\\nexport function cityWeekday(unix: number, offsetSeconds: number): string {\\n const d = new Date((unix + offsetSeconds) * 1000);\\n return d.toLocaleDateString(undefined, { weekday: \\\"short\\\", timeZone: \\\"UTC\\\" });\\n}\\n\\n/** \\\"just now\\\", \\\"2 min ago\\\", \\\"3 hours ago\\\", \\\"2 days ago\\\". */\\nexport function ago(ms: number, now = Date.now()): string {\\n const s = Math.max(0, Math.round((now - ms) / 1000));\\n if (s < 60) return \\\"just now\\\";\\n const m = Math.round(s / 60);\\n if (m < 60) return `${m} min ago`;\\n const h = Math.round(m / 60);\\n if (h < 48) return `${h} hour${h === 1 ? \\\"\\\" : \\\"s\\\"} ago`;\\n const d = Math.round(h / 24);\\n return `${d} days ago`;\\n}\\n\\n/** Age without \\\"ago\\\", for the \\\"Old: 3 hours\\\" badge. */\\nexport function age(ms: number, now = Date.now()): string {\\n const m = Math.max(1, Math.round((now - ms) / 60000));\\n if (m < 60) return `${m} min`;\\n const h = Math.round(m / 60);\\n if (h < 48) return `${h} hour${h === 1 ? \\\"\\\" : \\\"s\\\"}`;\\n return `${Math.round(h / 24)} days`;\\n}\\n\\n/** \\\"today at 07:40\\\" or \\\"Tue 28 Oct at 07:40\\\", in the viewer's own clock and locale. */\\nexport function savedAt(ms: number): string {\\n const d = new Date(ms);\\n const time = d.toLocaleTimeString(undefined, { hour: \\\"2-digit\\\", minute: \\\"2-digit\\\" });\\n const today = new Date();\\n if (d.toDateString() === today.toDateString()) return `today at ${time}`;\\n return `${d.toLocaleDateString(undefined, { weekday: \\\"short\\\", day: \\\"numeric\\\", month: \\\"short\\\" })} at ${time}`;\\n}\\n\\n/** First visit: °F only where people use it (United States, Liberia, Myanmar and US territories). */\\nexport function defaultUnit(): Unit {\\n const langs = [...(navigator.languages ?? []), navigator.language].filter(Boolean);\\n const region = langs\\n .map((l) => {\\n try {\\n return new Intl.Locale(l).maximize().region;\\n } catch {\\n return undefined;\\n }\\n })\\n .find(Boolean);\\n return region && [\\\"US\\\", \\\"LR\\\", \\\"MM\\\", \\\"PR\\\", \\\"GU\\\", \\\"VI\\\", \\\"AS\\\", \\\"MP\\\", \\\"UM\\\", \\\"BS\\\", \\\"KY\\\", \\\"PW\\\", \\\"FM\\\", \\\"MH\\\"].includes(region)\\n ? \\\"f\\\"\\n : \\\"c\\\";\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":18,"id":"coder_write_17","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":18,"function":{"arguments":"{\"path\":\"client/src/lib/geo.ts\",\"content\":\"/** Great-circle distance in km. */\\nexport function distanceKm(aLat: number, aLon: number, bLat: number, bLon: number) {\\n const r = (d: number) => (d * Math.PI) / 180;\\n const dLat = r(bLat - aLat);\\n const dLon = r(bLon - aLon);\\n const h = Math.sin(dLat / 2) ** 2 + Math.cos(r(aLat)) * Math.cos(r(bLat)) * Math.sin(dLon / 2) ** 2;\\n return 2 * 6371 * Math.asin(Math.sqrt(h));\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":19,"id":"coder_write_18","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":19,"function":{"arguments":"{\"path\":\"client/src/lib/hooks.ts\",\"content\":\"import { useEffect, useState, useSyncExternalStore } from \\\"react\\\";\\n\\n/** The current time, ticking every `ms`, for \\\"updated 2 min ago\\\" and local clocks. */\\nexport function useNow(ms = 30_000) {\\n const [now, setNow] = useState(() => Date.now());\\n useEffect(() => {\\n const t = setInterval(() => setNow(Date.now()), ms);\\n return () => clearInterval(t);\\n }, [ms]);\\n return now;\\n}\\n\\n/** Whether the browser thinks it has a connection. */\\nexport const useOnline = () =>\\n useSyncExternalStore(\\n (l) => {\\n addEventListener(\\\"online\\\", l);\\n addEventListener(\\\"offline\\\", l);\\n return () => {\\n removeEventListener(\\\"online\\\", l);\\n removeEventListener(\\\"offline\\\", l);\\n };\\n },\\n () => navigator.onLine,\\n );\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":20,"id":"coder_write_19","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":20,"function":{"arguments":"{\"path\":\"client/src/lib/install.ts\",\"content\":\"import { useSyncExternalStore } from \\\"react\\\";\\n\\n// The browser's own install offer. Chrome and Edge fire `beforeinstallprompt`; we keep it so the\\n// Install button can open the real prompt. Safari has no prompt; the dialog explains its steps.\\n\\ntype InstallEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: \\\"accepted\\\" | \\\"dismissed\\\" }> };\\n\\nlet deferred: InstallEvent | null = null;\\nlet installed =\\n typeof matchMedia === \\\"function\\\" &&\\n (matchMedia(\\\"(display-mode: standalone)\\\").matches || (navigator as { standalone?: boolean }).standalone === true);\\nconst listeners = new Set<() => void>();\\nconst emit = () => listeners.forEach((l) => l());\\n\\naddEventListener(\\\"beforeinstallprompt\\\", (e) => {\\n e.preventDefault();\\n deferred = e as InstallEvent;\\n emit();\\n});\\naddEventListener(\\\"appinstalled\\\", () => {\\n installed = true;\\n deferred = null;\\n emit();\\n});\\n\\nlet snapshot = { canPrompt: false, installed };\\nexport const useInstall = () =>\\n useSyncExternalStore(\\n (l) => {\\n listeners.add(l);\\n return () => listeners.delete(l);\\n },\\n () => {\\n const next = { canPrompt: deferred !== null, installed };\\n if (next.canPrompt !== snapshot.canPrompt || next.installed !== snapshot.installed) snapshot = next;\\n return snapshot;\\n },\\n );\\n\\n/** Opens the browser's install prompt. Resolves to whether the person accepted. */\\nexport async function promptInstall(): Promise {\\n if (!deferred) return false;\\n const e = deferred;\\n deferred = null;\\n emit();\\n await e.prompt();\\n const choice = await e.userChoice;\\n return choice.outcome === \\\"accepted\\\";\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":21,"id":"coder_write_20","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":21,"function":{"arguments":"{\"path\":\"client/src/lib/location.ts\",\"content\":\"import { useSyncExternalStore } from \\\"react\\\";\\nimport { Forecast } from \\\"@app/shared\\\";\\nimport { fetchNearest, getJson } from \\\"../api.ts\\\";\\nimport { distanceKm } from \\\"./geo.ts\\\";\\nimport { getState, store } from \\\"./storage.ts\\\";\\n\\n// The browser's own location feature. It is asked only on first open and when the person taps\\n// \\\"Use my location\\\"; the coordinates go only to the weather lookup.\\n\\nexport type LocateResult = \\\"found\\\" | \\\"denied\\\" | \\\"unavailable\\\";\\n\\nlet busy = false;\\nconst listeners = new Set<() => void>();\\nconst setBusy = (b: boolean) => {\\n busy = b;\\n for (const l of listeners) l();\\n};\\nexport const useLocating = () =>\\n useSyncExternalStore(\\n (l) => {\\n listeners.add(l);\\n return () => listeners.delete(l);\\n },\\n () => busy,\\n );\\n\\nfunction position(): Promise {\\n return new Promise((resolve, reject) => {\\n if (!(\\\"geolocation\\\" in navigator)) {\\n reject({ code: 2 });\\n return;\\n }\\n navigator.geolocation.getCurrentPosition(resolve, reject, {\\n enableHighAccuracy: false,\\n timeout: 20_000,\\n maximumAge: 10 * 60_000,\\n });\\n });\\n}\\n\\n/** Ask for the device location and name it after the nearest city, or \\\"My location\\\". */\\nexport async function locate(): Promise {\\n if (busy) return \\\"unavailable\\\";\\n setBusy(true);\\n try {\\n const pos = await position();\\n const lat = pos.coords.latitude;\\n const lon = pos.coords.longitude;\\n store.setLocationState(\\\"granted\\\");\\n const previous = getState().here;\\n // Same place as before: keep its name, just refresh the time.\\n if (previous && previous.name && distanceKm(previous.lat, previous.lon, lat, lon) < 3) {\\n store.setHere({ ...previous, lat, lon, at: Date.now() });\\n return \\\"found\\\";\\n }\\n store.setHere({ lat, lon, name: null, admin1: null, country: null, at: Date.now() });\\n try {\\n const f = await getJson(`/api/forecast?lat=${lat.toFixed(2)}&lon=${lon.toFixed(2)}`, Forecast);\\n const near = await fetchNearest(lat, lon, f.timezone);\\n const here = getState().here;\\n if (here && here.lat === lat && here.lon === lon) store.setHere({ ...here, ...near });\\n } catch {\\n // No name: the place is shown as \\\"My location\\\".\\n }\\n return \\\"found\\\";\\n } catch (error) {\\n const code = (error as { code?: number }).code;\\n if (code === 1) {\\n store.setLocationState(\\\"denied\\\");\\n return \\\"denied\\\";\\n }\\n if (getState().settings.location === \\\"unknown\\\") store.setLocationState(\\\"denied\\\");\\n return \\\"unavailable\\\";\\n } finally {\\n setBusy(false);\\n }\\n}\\n\\n/** Whether the browser already allows location, without asking. */\\nexport async function permissionGranted(): Promise {\\n try {\\n const status = await navigator.permissions.query({ name: \\\"geolocation\\\" });\\n return status.state === \\\"granted\\\";\\n } catch {\\n return false;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":22,"id":"coder_write_21","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":22,"function":{"arguments":"{\"path\":\"client/src/lib/places.ts\",\"content\":\"import type { Forecast } from \\\"@app/shared\\\";\\nimport { MY_LOCATION_ID, type MyLocation, type SavedCity } from \\\"./storage.ts\\\";\\nimport { conditionText, iconFor, isDayAt, sceneFor } from \\\"./weather.ts\\\";\\n\\n/** A place the app can show: the device location or a saved or previewed city. */\\nexport type Place = {\\n id: string;\\n name: string;\\n admin1?: string | null;\\n country?: string | null;\\n lat: number;\\n lon: number;\\n isHere: boolean;\\n};\\n\\nexport function hereAsPlace(here: MyLocation): Place {\\n return {\\n id: MY_LOCATION_ID,\\n name: here.name ?? \\\"My location\\\",\\n admin1: here.admin1,\\n country: here.country,\\n lat: here.lat,\\n lon: here.lon,\\n isHere: true,\\n };\\n}\\n\\nexport const cityAsPlace = (c: SavedCity): Place => ({ ...c, isHere: false });\\n\\n/** \\\"England, United Kingdom\\\" */\\nexport const region = (p: { admin1?: string | null; country?: string | null; name?: string }) =>\\n [p.admin1, p.country].filter((x, i, xs) => x && xs.indexOf(x) === i).join(\\\", \\\");\\n\\n/** Where Home shows a place: saved places and My location by id, others by coordinates. */\\nexport function placeHref(p: Place, saved: boolean): string {\\n if (p.isHere || saved) return `/?city=${encodeURIComponent(p.id)}`;\\n const q = new URLSearchParams({ lat: String(p.lat), lon: String(p.lon), name: p.name });\\n if (p.admin1) q.set(\\\"admin1\\\", p.admin1);\\n if (p.country) q.set(\\\"country\\\", p.country);\\n return `/?${q}`;\\n}\\n\\n/** Chip, temperature and words for a place's weather. */\\nexport function glance(f: Forecast) {\\n const scene = sceneFor(f);\\n return {\\n wx: scene.key,\\n icon: iconFor(f.current.weather_code, isDayAt(f)),\\n text: conditionText(f),\\n tempC: f.current.temperature_2m,\\n scene,\\n };\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":23,"id":"coder_write_22","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":23,"function":{"arguments":"{\"path\":\"client/src/lib/scene-state.ts\",\"content\":\"import { useEffect, useSyncExternalStore } from \\\"react\\\";\\nimport type { Scene } from \\\"./weather.ts\\\";\\nimport { getState, store } from \\\"./storage.ts\\\";\\n\\n// Which scene is behind the app right now. Home sets it from the shown city's weather; Search,\\n// Saved cities and Settings keep the last city's scene; Compare and the loading, location-off and\\n// first-visit states ask for the calm one.\\n\\nexport type ShownScene = Pick | null;\\n\\nlet current: ShownScene = null;\\nconst listeners = new Set<() => void>();\\n\\nfunction set(scene: ShownScene) {\\n if (current?.key === scene?.key && current?.intensity === scene?.intensity) return;\\n current = scene;\\n for (const l of listeners) l();\\n}\\n\\nexport const useShownScene = () =>\\n useSyncExternalStore(\\n (l) => {\\n listeners.add(l);\\n return () => listeners.delete(l);\\n },\\n () => current,\\n );\\n\\n/** Show a city's scene (and remember it for the other screens), or the calm one for null. */\\nexport function useScene(scene: ShownScene | \\\"keep\\\", remember = true) {\\n const key = scene === \\\"keep\\\" ? \\\"keep\\\" : scene ? `${scene.key}:${scene.intensity}` : \\\"calm\\\";\\n useEffect(() => {\\n if (scene === \\\"keep\\\") {\\n const last = getState().lastScene;\\n if (!last) return set(null);\\n const [condition, time] = last.key.split(\\\"-\\\") as [Scene[\\\"condition\\\"], string];\\n set({ condition, intensity: last.intensity, isDay: time === \\\"day\\\", key: last.key as Scene[\\\"key\\\"], label: \\\"\\\" });\\n return;\\n }\\n set(scene);\\n if (scene && remember) store.setLastScene({ key: scene.key, intensity: scene.intensity });\\n // `key` stands for the scene's fields.\\n // eslint-disable-next-line react-hooks/exhaustive-deps\\n }, [key, remember]);\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":24,"id":"coder_write_23","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":24,"function":{"arguments":"{\"path\":\"client/src/lib/storage.ts\",\"content\":\"import { useSyncExternalStore } from \\\"react\\\";\\nimport { z } from \\\"zod\\\";\\nimport { Forecast } from \\\"@app/shared\\\";\\nimport { defaultUnit } from \\\"./format.ts\\\";\\n\\n// Everything the app remembers lives in this browser's localStorage: saved cities, the unit, the\\n// simpler-effects choice, the compare picks, the device location and the last weather seen per\\n// place. Each value is checked when read; anything damaged is dropped instead of breaking the app.\\n\\nexport const SavedCity = z.object({\\n id: z.string(),\\n name: z.string(),\\n admin1: z.string().optional(),\\n country: z.string().optional(),\\n lat: z.number(),\\n lon: z.number(),\\n});\\nexport type SavedCity = z.infer;\\n\\nexport const MY_LOCATION_ID = \\\"here\\\";\\n\\nconst MyLocation = z.object({\\n lat: z.number(),\\n lon: z.number(),\\n name: z.string().nullable(),\\n admin1: z.string().nullable(),\\n country: z.string().nullable(),\\n at: z.number(),\\n});\\nexport type MyLocation = z.infer;\\n\\nconst Settings = z.object({\\n unit: z.enum([\\\"c\\\", \\\"f\\\"]),\\n simpler: z.boolean(),\\n /** \\\"unknown\\\" until the browser has been asked once. */\\n location: z.enum([\\\"unknown\\\", \\\"granted\\\", \\\"denied\\\"]),\\n lastPlace: z.string().optional(),\\n});\\ntype Settings = z.infer;\\n\\nconst LastScene = z.object({ key: z.string(), intensity: z.enum([\\\"light\\\", \\\"moderate\\\", \\\"heavy\\\"]) });\\nexport type LastScene = z.infer;\\n\\nconst Cached = z.object({ at: z.number(), data: Forecast });\\nexport type Cached = z.infer;\\n\\ntype State = {\\n cities: SavedCity[];\\n settings: Settings;\\n compare: string[];\\n here: MyLocation | null;\\n lastScene: LastScene | null;\\n};\\n\\nconst KEYS = {\\n cities: \\\"aw.cities\\\",\\n settings: \\\"aw.settings\\\",\\n compare: \\\"aw.compare\\\",\\n here: \\\"aw.here\\\",\\n lastScene: \\\"aw.scene\\\",\\n} as const;\\n\\nfunction read(key: string, schema: z.ZodType, fallback: T): T {\\n try {\\n const raw = localStorage.getItem(key);\\n if (raw == null) return fallback;\\n const parsed = schema.safeParse(JSON.parse(raw));\\n if (parsed.success) return parsed.data;\\n localStorage.removeItem(key);\\n } catch {\\n // Storage blocked or damaged: start fresh.\\n }\\n return fallback;\\n}\\n\\nfunction write(key: string, value: unknown) {\\n try {\\n if (value === null) localStorage.removeItem(key);\\n else localStorage.setItem(key, JSON.stringify(value));\\n } catch {\\n // Full or blocked storage: the app keeps working for this visit.\\n }\\n}\\n\\nfunction load(): State {\\n return {\\n cities: read(KEYS.cities, z.array(SavedCity), []),\\n settings: read(KEYS.settings, Settings, { unit: defaultUnit(), simpler: false, location: \\\"unknown\\\" }),\\n compare: read(KEYS.compare, z.array(z.string()), []),\\n here: read(KEYS.here, MyLocation.nullable(), null),\\n lastScene: read(KEYS.lastScene, LastScene.nullable(), null),\\n };\\n}\\n\\nlet state = load();\\nconst listeners = new Set<() => void>();\\n\\nfunction set(key: K, value: State[K]) {\\n state = { ...state, [key]: value };\\n write(KEYS[key], value);\\n for (const l of listeners) l();\\n}\\n\\n// Another tab changed something: pick it up.\\naddEventListener(\\\"storage\\\", (e) => {\\n if (e.key && Object.values(KEYS).includes(e.key as never)) {\\n state = load();\\n for (const l of listeners) l();\\n }\\n});\\n\\nconst subscribe = (l: () => void) => {\\n listeners.add(l);\\n return () => listeners.delete(l);\\n};\\n\\nexport function useStore(pick: (s: State) => T): T {\\n return useSyncExternalStore(subscribe, () => pick(state));\\n}\\nexport const getState = () => state;\\n\\n/** Same place = same coordinates rounded to about 1 km. */\\nexport const cityId = (lat: number, lon: number) => `${lat.toFixed(2)},${lon.toFixed(2)}`;\\n\\nexport const store = {\\n setUnit: (unit: \\\"c\\\" | \\\"f\\\") => set(\\\"settings\\\", { ...state.settings, unit }),\\n setSimpler: (simpler: boolean) => set(\\\"settings\\\", { ...state.settings, simpler }),\\n setLocationState: (location: Settings[\\\"location\\\"]) => set(\\\"settings\\\", { ...state.settings, location }),\\n setLastPlace: (lastPlace: string | undefined) => {\\n if (state.settings.lastPlace !== lastPlace) set(\\\"settings\\\", { ...state.settings, lastPlace });\\n },\\n setHere: (here: MyLocation | null) => set(\\\"here\\\", here),\\n setLastScene: (scene: LastScene | null) => {\\n if (scene?.key !== state.lastScene?.key || scene?.intensity !== state.lastScene?.intensity) set(\\\"lastScene\\\", scene);\\n },\\n isSaved: (id: string) => state.cities.some((c) => c.id === id),\\n /** Adds a city unless it's already saved. Returns false for a duplicate. */\\n save(city: Omit, index?: number): boolean {\\n const id = cityId(city.lat, city.lon);\\n if (state.cities.some((c) => c.id === id)) return false;\\n const next = [...state.cities];\\n next.splice(index ?? next.length, 0, { ...city, id });\\n set(\\\"cities\\\", next);\\n return true;\\n },\\n remove(id: string): { city: SavedCity; index: number; picked: boolean } | null {\\n const index = state.cities.findIndex((c) => c.id === id);\\n if (index < 0) return null;\\n const city = state.cities[index]!;\\n const picked = state.compare.includes(id);\\n set(\\\"cities\\\", state.cities.filter((c) => c.id !== id));\\n if (picked) set(\\\"compare\\\", state.compare.filter((c) => c !== id));\\n if (state.settings.lastPlace === id) set(\\\"settings\\\", { ...state.settings, lastPlace: undefined });\\n return { city, index, picked };\\n },\\n restore(removed: { city: SavedCity; index: number; picked: boolean }) {\\n if (state.cities.some((c) => c.id === removed.city.id)) return;\\n const next = [...state.cities];\\n next.splice(Math.min(removed.index, next.length), 0, removed.city);\\n set(\\\"cities\\\", next);\\n if (removed.picked && state.compare.length < 4) set(\\\"compare\\\", [...state.compare, removed.city.id]);\\n },\\n setCompare: (ids: string[]) => set(\\\"compare\\\", ids.slice(0, 4)),\\n};\\n\\n// The last weather seen per place, for offline use.\\nconst wxKey = (id: string) => `aw.wx.${id}`;\\nexport function readCached(id: string): Cached | null {\\n return read(wxKey(id), Cached.nullable(), null);\\n}\\nexport function writeCached(id: string, data: Forecast) {\\n write(wxKey(id), { at: Date.now(), data });\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":25,"id":"coder_write_24","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":25,"function":{"arguments":"{\"path\":\"client/src/lib/use-locate.ts\",\"content\":\"import { useNavigate } from \\\"react-router\\\";\\nimport { toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport { locate, useLocating } from \\\"./location.ts\\\";\\n\\n/** \\\"Use my location\\\": asks the browser, then shows My location on Home. */\\nexport function useLocateAction() {\\n const navigate = useNavigate();\\n const locating = useLocating();\\n const run = async () => {\\n toast(\\\"Checking your location…\\\");\\n const result = await locate();\\n if (result === \\\"found\\\") navigate(\\\"/?city=here\\\");\\n else if (result === \\\"denied\\\") toast(\\\"Location is still blocked. Allow it in your browser's settings, then try again.\\\");\\n else toast(\\\"We couldn't find your location just now. Try again, or search for a city.\\\");\\n };\\n return { locate: run, locating };\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":26,"id":"coder_write_25","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":26,"function":{"arguments":"{\"path\":\"client/src/lib/view.ts\",\"content\":\"import type { Forecast } from \\\"@app/shared\\\";\\nimport { cityClock, cityWeekday } from \\\"./format.ts\\\";\\nimport { codeInfo, dayIndex, iconFor, isDayAt, type IconName } from \\\"./weather.ts\\\";\\n\\n// Picks the rows each screen shows out of one forecast.\\n\\nexport type Hour = { time: number; label: string; tempC: number | null; pop: number | null; icon: IconName; text: string };\\nexport type Day = {\\n time: number;\\n label: string;\\n hiC: number | null;\\n loC: number | null;\\n pop: number | null;\\n icon: IconName;\\n text: string;\\n};\\n\\n/** Index of the hour containing \\\"now\\\" at the city. */\\nexport function currentHourIndex(f: Forecast): number {\\n let index = 0;\\n for (let i = 0; i < f.hourly.time.length; i++) if (f.hourly.time[i]! <= f.current.time) index = i;\\n return index;\\n}\\n\\nfunction hourAt(f: Forecast, i: number, label?: string): Hour {\\n const code = f.hourly.weather_code[i];\\n const isDay = f.hourly.is_day[i] === 1;\\n return {\\n time: f.hourly.time[i]!,\\n label: label ?? cityClock(f.hourly.time[i]!, f.utc_offset_seconds),\\n tempC: f.hourly.temperature_2m[i] ?? null,\\n pop: f.hourly.precipitation_probability[i] ?? null,\\n icon: iconFor(code, isDay),\\n text: codeInfo(code).text,\\n };\\n}\\n\\n/** The next 24 hours, starting with the current hour as \\\"Now\\\". */\\nexport function next24(f: Forecast): Hour[] {\\n const start = currentHourIndex(f);\\n const out: Hour[] = [];\\n for (let i = start; i < Math.min(start + 24, f.hourly.time.length); i++) out.push(hourAt(f, i, i === start ? \\\"Now\\\" : undefined));\\n // \\\"Now\\\" shows the current conditions, so it always agrees with the big figure above it.\\n if (out[0]) {\\n const c = f.current;\\n out[0] = { ...out[0], tempC: c.temperature_2m, icon: iconFor(c.weather_code, isDayAt(f)), text: codeInfo(c.weather_code).text };\\n }\\n return out;\\n}\\n\\n/** +1, +3, +6 and +9 hours, for the compare cards. */\\nexport function outlook(f: Forecast): Hour[] {\\n const start = currentHourIndex(f);\\n return [1, 3, 6, 9]\\n .map((h) => start + h)\\n .filter((i) => i < f.hourly.time.length)\\n .map((i) => hourAt(f, i));\\n}\\n\\n/** Seven days from today at the city. */\\nexport function next7(f: Forecast): Day[] {\\n const start = dayIndex(f, f.current.time);\\n const out: Day[] = [];\\n for (let i = start; i < Math.min(start + 7, f.daily.time.length); i++) {\\n const code = f.daily.weather_code[i];\\n out.push({\\n time: f.daily.time[i]!,\\n label: i === start ? \\\"Today\\\" : cityWeekday(f.daily.time[i]!, f.utc_offset_seconds),\\n hiC: f.daily.temperature_2m_max[i] ?? null,\\n loC: f.daily.temperature_2m_min[i] ?? null,\\n pop: f.daily.precipitation_probability_max[i] ?? null,\\n icon: iconFor(code, true),\\n text: codeInfo(code).text,\\n });\\n }\\n return out;\\n}\\n\\nexport function today(f: Forecast) {\\n const i = dayIndex(f, f.current.time);\\n return {\\n hiC: f.daily.temperature_2m_max[i] ?? null,\\n loC: f.daily.temperature_2m_min[i] ?? null,\\n rainMm: f.daily.precipitation_sum[i] ?? 0,\\n pop: f.daily.precipitation_probability_max[i] ?? null,\\n sunrise: f.daily.sunrise[i] ?? null,\\n sunset: f.daily.sunset[i] ?? null,\\n };\\n}\\n\\nconst WET = new Set([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82, 95, 96, 99]);\\nconst SNOWY = new Set([71, 73, 75, 77, 85, 86]);\\nconst wetWord = (code: number) => (SNOWY.has(code) ? \\\"Snow\\\" : WET.has(code) ? \\\"Rain\\\" : null);\\n\\n/** One plain line about the next 12 hours, e.g. \\\"Rain easing by 14:00.\\\" */\\nexport function summary(f: Forecast): string {\\n const start = currentHourIndex(f);\\n const end = Math.min(start + 12, f.hourly.time.length);\\n const nowCode = f.current.weather_code;\\n const nowWet = wetWord(nowCode);\\n const clock = (i: number) => cityClock(f.hourly.time[i]!, f.utc_offset_seconds);\\n if (nowWet) {\\n for (let i = start + 1; i < end; i++) {\\n const code = f.hourly.weather_code[i] ?? 0;\\n if (!wetWord(code) && (f.hourly.precipitation_probability[i] ?? 0) < 40)\\n return `${nowWet} easing by ${clock(i)}, then ${codeInfo(code).text.toLowerCase()}.`;\\n }\\n return `${nowWet} keeping on for the next 12 hours.`;\\n }\\n for (let i = start + 1; i < end; i++) {\\n const code = f.hourly.weather_code[i] ?? 0;\\n const word = wetWord(code);\\n if (word && (f.hourly.precipitation_probability[i] ?? 0) >= 40) return `${word} likely from ${clock(i)}.`;\\n }\\n const temps = f.hourly.temperature_2m.slice(start, end).filter((t): t is number => t != null);\\n const max = Math.max(...temps);\\n const min = Math.min(...temps);\\n const trend = max - (temps[0] ?? max) >= 3 ? \\\"warming up\\\" : (temps[0] ?? min) - min >= 3 ? \\\"cooling down\\\" : \\\"steady temperatures\\\";\\n return `Dry for the next 12 hours, ${trend}.`;\\n}\\n\\nexport const isDay = (f: Forecast) => isDayAt(f);\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":27,"id":"coder_write_26","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":27,"function":{"arguments":"{\"path\":\"client/src/lib/weather.ts\",\"content\":\"import type { Forecast } from \\\"@app/shared\\\";\\n\\n// Turns Open-Meteo's weather codes (WMO codes) into one of the seven scenes plus an intensity, and\\n// picks day or night from the city's own sunrise and sunset, never the viewer's clock.\\n\\nexport type Condition = \\\"clear\\\" | \\\"hot\\\" | \\\"cloudy\\\" | \\\"rain\\\" | \\\"storm\\\" | \\\"snow\\\" | \\\"wind\\\" | \\\"fog\\\";\\nexport type Intensity = \\\"light\\\" | \\\"moderate\\\" | \\\"heavy\\\";\\nexport type WxKey = `${Condition}-${\\\"day\\\" | \\\"night\\\"}` | \\\"calm\\\";\\nexport type IconName =\\n | \\\"sun\\\"\\n | \\\"moon\\\"\\n | \\\"cloud\\\"\\n | \\\"cloud-sun\\\"\\n | \\\"cloud-moon\\\"\\n | \\\"rain\\\"\\n | \\\"drizzle\\\"\\n | \\\"storm\\\"\\n | \\\"snow\\\"\\n | \\\"wind\\\"\\n | \\\"fog\\\";\\n\\n/** Clear weather above this temperature (°C) shows the hot scene. */\\nexport const HOT_THRESHOLD_C = 30;\\n/** Sustained wind (km/h) or gusts at which a dry scene becomes the wind scene. */\\nconst WIND_KMH = 30;\\nconst GUST_KMH = 50;\\n\\ntype CodeInfo = { text: string; condition: Condition; intensity: Intensity; icon: IconName };\\n\\nconst CODES: Record = {\\n 0: { text: \\\"Clear\\\", condition: \\\"clear\\\", intensity: \\\"moderate\\\", icon: \\\"sun\\\" },\\n 1: { text: \\\"Mostly clear\\\", condition: \\\"clear\\\", intensity: \\\"light\\\", icon: \\\"sun\\\" },\\n 2: { text: \\\"Partly cloudy\\\", condition: \\\"cloudy\\\", intensity: \\\"light\\\", icon: \\\"cloud-sun\\\" },\\n 3: { text: \\\"Overcast\\\", condition: \\\"cloudy\\\", intensity: \\\"heavy\\\", icon: \\\"cloud\\\" },\\n 45: { text: \\\"Fog\\\", condition: \\\"fog\\\", intensity: \\\"moderate\\\", icon: \\\"fog\\\" },\\n 48: { text: \\\"Freezing fog\\\", condition: \\\"fog\\\", intensity: \\\"heavy\\\", icon: \\\"fog\\\" },\\n 51: { text: \\\"Light drizzle\\\", condition: \\\"rain\\\", intensity: \\\"light\\\", icon: \\\"drizzle\\\" },\\n 53: { text: \\\"Drizzle\\\", condition: \\\"rain\\\", intensity: \\\"light\\\", icon: \\\"drizzle\\\" },\\n 55: { text: \\\"Heavy drizzle\\\", condition: \\\"rain\\\", intensity: \\\"moderate\\\", icon: \\\"drizzle\\\" },\\n 56: { text: \\\"Freezing drizzle\\\", condition: \\\"rain\\\", intensity: \\\"light\\\", icon: \\\"drizzle\\\" },\\n 57: { text: \\\"Freezing drizzle\\\", condition: \\\"rain\\\", intensity: \\\"moderate\\\", icon: \\\"drizzle\\\" },\\n 61: { text: \\\"Light rain\\\", condition: \\\"rain\\\", intensity: \\\"light\\\", icon: \\\"rain\\\" },\\n 63: { text: \\\"Moderate rain\\\", condition: \\\"rain\\\", intensity: \\\"moderate\\\", icon: \\\"rain\\\" },\\n 65: { text: \\\"Heavy rain\\\", condition: \\\"rain\\\", intensity: \\\"heavy\\\", icon: \\\"rain\\\" },\\n 66: { text: \\\"Freezing rain\\\", condition: \\\"rain\\\", intensity: \\\"light\\\", icon: \\\"rain\\\" },\\n 67: { text: \\\"Heavy freezing rain\\\", condition: \\\"rain\\\", intensity: \\\"heavy\\\", icon: \\\"rain\\\" },\\n 71: { text: \\\"Light snow\\\", condition: \\\"snow\\\", intensity: \\\"light\\\", icon: \\\"snow\\\" },\\n 73: { text: \\\"Snow\\\", condition: \\\"snow\\\", intensity: \\\"moderate\\\", icon: \\\"snow\\\" },\\n 75: { text: \\\"Heavy snow\\\", condition: \\\"snow\\\", intensity: \\\"heavy\\\", icon: \\\"snow\\\" },\\n 77: { text: \\\"Snow grains\\\", condition: \\\"snow\\\", intensity: \\\"light\\\", icon: \\\"snow\\\" },\\n 80: { text: \\\"Light showers\\\", condition: \\\"rain\\\", intensity: \\\"light\\\", icon: \\\"drizzle\\\" },\\n 81: { text: \\\"Showers\\\", condition: \\\"rain\\\", intensity: \\\"moderate\\\", icon: \\\"rain\\\" },\\n 82: { text: \\\"Heavy showers\\\", condition: \\\"rain\\\", intensity: \\\"heavy\\\", icon: \\\"rain\\\" },\\n 85: { text: \\\"Snow showers\\\", condition: \\\"snow\\\", intensity: \\\"moderate\\\", icon: \\\"snow\\\" },\\n 86: { text: \\\"Heavy snow showers\\\", condition: \\\"snow\\\", intensity: \\\"heavy\\\", icon: \\\"snow\\\" },\\n 95: { text: \\\"Thunderstorm\\\", condition: \\\"storm\\\", intensity: \\\"moderate\\\", icon: \\\"storm\\\" },\\n 96: { text: \\\"Thunderstorm with hail\\\", condition: \\\"storm\\\", intensity: \\\"heavy\\\", icon: \\\"storm\\\" },\\n 99: { text: \\\"Thunderstorm with heavy hail\\\", condition: \\\"storm\\\", intensity: \\\"heavy\\\", icon: \\\"storm\\\" },\\n};\\n\\nexport function codeInfo(code: number | null | undefined): CodeInfo {\\n return CODES[code ?? 0] ?? { text: \\\"Unsettled\\\", condition: \\\"cloudy\\\", intensity: \\\"moderate\\\", icon: \\\"cloud\\\" };\\n}\\n\\n/** The icon for a code at a given time of day (sun and moon swap at night). */\\nexport function iconFor(code: number | null | undefined, isDay: boolean): IconName {\\n const icon = codeInfo(code).icon;\\n if (isDay) return icon;\\n if (icon === \\\"sun\\\") return \\\"moon\\\";\\n if (icon === \\\"cloud-sun\\\") return \\\"cloud-moon\\\";\\n return icon;\\n}\\n\\nexport type Scene = {\\n condition: Condition;\\n intensity: Intensity;\\n isDay: boolean;\\n key: WxKey;\\n /** Plain words for the scene, e.g. \\\"Moderate rain, day\\\". */\\n label: string;\\n};\\n\\nconst INTENSITY_WORD: Record = { light: \\\"Light\\\", moderate: \\\"Moderate\\\", heavy: \\\"Heavy\\\" };\\nconst CONDITION_WORD: Record = {\\n clear: \\\"clear sky\\\",\\n hot: \\\"heat\\\",\\n cloudy: \\\"cloud\\\",\\n rain: \\\"rain\\\",\\n storm: \\\"storm\\\",\\n snow: \\\"snow\\\",\\n wind: \\\"wind\\\",\\n fog: \\\"fog\\\",\\n};\\n\\nexport function sceneLabel(condition: Condition, intensity: Intensity, isDay: boolean) {\\n const time = isDay ? \\\"day\\\" : \\\"night\\\";\\n if (condition === \\\"clear\\\") return `Clear sky, ${time}`;\\n if (condition === \\\"hot\\\") return `Hot and clear, ${time}`;\\n if (condition === \\\"cloudy\\\")\\n return `${intensity === \\\"light\\\" ? \\\"Partly cloudy\\\" : intensity === \\\"heavy\\\" ? \\\"Overcast\\\" : \\\"Cloudy\\\"}, ${time}`;\\n if (condition === \\\"wind\\\")\\n return `${intensity === \\\"light\\\" ? \\\"Breezy\\\" : intensity === \\\"heavy\\\" ? \\\"Strong wind\\\" : \\\"Windy\\\"}, ${time}`;\\n if (condition === \\\"fog\\\") return `${intensity === \\\"heavy\\\" ? \\\"Thick fog\\\" : \\\"Fog\\\"}, ${time}`;\\n if (condition === \\\"storm\\\") return `${intensity === \\\"heavy\\\" ? \\\"Severe thunderstorm\\\" : \\\"Thunderstorm\\\"}, ${time}`;\\n return `${INTENSITY_WORD[intensity]} ${CONDITION_WORD[condition]}, ${time}`;\\n}\\n\\nexport function makeScene(condition: Condition, intensity: Intensity, isDay: boolean): Scene {\\n return {\\n condition,\\n intensity,\\n isDay,\\n key: `${condition}-${isDay ? \\\"day\\\" : \\\"night\\\"}`,\\n label: sceneLabel(condition, intensity, isDay),\\n };\\n}\\n\\n/** Is it daytime at the city right now? Uses that city's sunrise and sunset for today. */\\nexport function isDayAt(f: Forecast, at = f.current.time): boolean {\\n const i = dayIndex(f, at);\\n const rise = f.daily.sunrise[i];\\n const set = f.daily.sunset[i];\\n // Polar day or night: Open-Meteo reports equal or missing times, so fall back to its own flag.\\n if (!rise || !set || rise === set) return f.current.is_day === 1;\\n return at >= rise && at < set;\\n}\\n\\n/** Index of the day containing a moment, in the city's local calendar. */\\nexport function dayIndex(f: Forecast, at: number): number {\\n let index = 0;\\n for (let i = 0; i < f.daily.time.length; i++) if (f.daily.time[i]! <= at) index = i;\\n return index;\\n}\\n\\nexport function sceneFor(f: Forecast): Scene {\\n const c = f.current;\\n const info = codeInfo(c.weather_code);\\n let condition = info.condition;\\n let intensity = info.intensity;\\n const dry = condition === \\\"clear\\\" || condition === \\\"cloudy\\\";\\n if (dry && (c.wind_speed_10m >= WIND_KMH || c.wind_gusts_10m >= GUST_KMH)) {\\n condition = \\\"wind\\\";\\n intensity = c.wind_speed_10m >= 55 || c.wind_gusts_10m >= 80 ? \\\"heavy\\\" : c.wind_speed_10m >= 40 ? \\\"moderate\\\" : \\\"light\\\";\\n } else if (condition === \\\"clear\\\" && c.temperature_2m > HOT_THRESHOLD_C) {\\n condition = \\\"hot\\\";\\n intensity = c.temperature_2m > 38 ? \\\"heavy\\\" : \\\"moderate\\\";\\n }\\n return makeScene(condition, intensity, isDayAt(f));\\n}\\n\\n/** Plain words for what's happening now (\\\"Sunny and hot\\\", \\\"Windy\\\", \\\"Moderate rain\\\"). */\\nexport function conditionText(f: Forecast): string {\\n const scene = sceneFor(f);\\n const text = codeInfo(f.current.weather_code).text;\\n if (scene.condition === \\\"hot\\\") return scene.isDay ? \\\"Sunny and hot\\\" : \\\"Clear and hot\\\";\\n if (scene.condition === \\\"wind\\\") return scene.isDay && f.current.weather_code <= 1 ? \\\"Sunny and windy\\\" : `Windy, ${text.toLowerCase()}`;\\n if (scene.condition === \\\"clear\\\" && scene.isDay && f.current.weather_code === 0) return \\\"Sunny\\\";\\n return text;\\n}\\n\\n/** Parse the test route value, e.g. \\\"rain-heavy-night\\\" or \\\"storm-day\\\". */\\nexport function parseForced(value: string | null): Scene | null {\\n if (!value) return null;\\n const parts = value.toLowerCase().split(\\\"-\\\");\\n const condition = parts[0] as Condition;\\n if (!(condition in CONDITION_WORD)) return null;\\n const intensity = ([\\\"light\\\", \\\"moderate\\\", \\\"heavy\\\"].find((i) => parts.includes(i)) ?? \\\"moderate\\\") as Intensity;\\n const isDay = !parts.includes(\\\"night\\\");\\n return makeScene(condition, intensity, isDay);\\n}\\n\\nexport const ALL_CONDITIONS: Condition[] = [\\\"clear\\\", \\\"hot\\\", \\\"cloudy\\\", \\\"rain\\\", \\\"storm\\\", \\\"snow\\\", \\\"wind\\\", \\\"fog\\\"];\\nexport const CONDITION_NAMES: Record = {\\n clear: \\\"Clear\\\",\\n hot: \\\"Hot\\\",\\n cloudy: \\\"Cloudy\\\",\\n rain: \\\"Rain\\\",\\n storm: \\\"Storm\\\",\\n snow: \\\"Snow\\\",\\n wind: \\\"Wind\\\",\\n fog: \\\"Fog\\\",\\n};\\nexport const CONDITION_ICON: Record = {\\n clear: [\\\"sun\\\", \\\"moon\\\"],\\n hot: [\\\"sun\\\", \\\"moon\\\"],\\n cloudy: [\\\"cloud\\\", \\\"cloud\\\"],\\n rain: [\\\"rain\\\", \\\"rain\\\"],\\n storm: [\\\"storm\\\", \\\"storm\\\"],\\n snow: [\\\"snow\\\", \\\"snow\\\"],\\n wind: [\\\"wind\\\", \\\"wind\\\"],\\n fog: [\\\"fog\\\", \\\"fog\\\"],\\n};\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":28,"id":"coder_write_27","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":28,"function":{"arguments":"{\"path\":\"client/src/main.tsx\",\"content\":\"import \\\"./styles.css\\\";\\nimport { StrictMode } from \\\"react\\\";\\nimport { createRoot } from \\\"react-dom/client\\\";\\nimport { MutationCache, QueryCache, QueryClient, QueryClientProvider } from \\\"@tanstack/react-query\\\";\\nimport { RouterProvider } from \\\"react-router\\\";\\nimport { Toaster, toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport { router } from \\\"./routes.tsx\\\";\\n\\nconst queryClient = new QueryClient({\\n // A failed request is logged as well as shown on screen, so it never fails silently.\\n queryCache: new QueryCache({ onError: (error) => console.warn(error) }),\\n mutationCache: new MutationCache({ onError: (error) => toast(error.message) }),\\n defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },\\n});\\n\\ncreateRoot(document.getElementById(\\\"root\\\")!).render(\\n \\n \\n \\n \\n \\n ,\\n);\\n\\n// The app shell works offline and the browser can offer to install it.\\nif (\\\"serviceWorker\\\" in navigator && import.meta.env.PROD) {\\n // A refused registration (private windows, some test browsers) only means no offline shell.\\n addEventListener(\\\"load\\\", () => {\\n navigator.serviceWorker.register(\\\"/sw.js\\\").catch(() => undefined);\\n });\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":29,"id":"coder_write_28","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":29,"function":{"arguments":"{\"path\":\"client/src/pages/cities.tsx\",\"content\":\"import { Link } from \\\"react-router\\\";\\nimport { Bookmark, Columns2, Navigation, Plus, Search, Trash2 } from \\\"lucide-react\\\";\\nimport { toast as sonner } from \\\"sonner\\\";\\nimport { Badge, Button, Card, Checkbox, Skeleton } from \\\"@corbits/react-ui\\\";\\nimport { toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport { useForecast } from \\\"../api.ts\\\";\\nimport { WxChip } from \\\"../components/weather-bits.tsx\\\";\\nimport { cityNow, temp } from \\\"../lib/format.ts\\\";\\nimport { useNow } from \\\"../lib/hooks.ts\\\";\\nimport { cityAsPlace, glance, hereAsPlace, placeHref, region, type Place } from \\\"../lib/places.ts\\\";\\nimport { useScene } from \\\"../lib/scene-state.ts\\\";\\nimport { readCached, store, useStore } from \\\"../lib/storage.ts\\\";\\nimport { useLocateAction } from \\\"../lib/use-locate.ts\\\";\\n\\nexport const MAX_COMPARE = 4;\\n\\nexport function Cities() {\\n useScene(\\\"keep\\\");\\n const cities = useStore((s) => s.cities);\\n const here = useStore((s) => s.here);\\n const location = useStore((s) => s.settings.location);\\n const picked = useStore((s) => s.compare);\\n const places: Place[] = [...(here && location === \\\"granted\\\" ? [hereAsPlace(here)] : []), ...cities.map(cityAsPlace)];\\n const valid = picked.filter((id) => places.some((p) => p.id === id));\\n const n = valid.length;\\n\\n const toggle = (id: string, on: boolean) => {\\n if (on && n >= MAX_COMPARE) return;\\n store.setCompare(on ? [...valid, id] : valid.filter((x) => x !== id));\\n };\\n\\n return (\\n
    \\n
    \\n
    \\n

    \\n Saved cities\\n

    \\n {cities.length ?

    Tap a city to see its weather. Tick 2 to 4 to compare.

    : null}\\n
    \\n {cities.length ? (\\n n >= 2 ? (\\n \\n ) : (\\n \\n )\\n ) : null}\\n
    \\n\\n {cities.length === 0 ? (\\n \\n \\n

    No saved cities yet

    \\n

    Save the places you check often. They'll show here with their weather, and you can compare up to four side by side.

    \\n \\n
    \\n ) : (\\n <>\\n
    \\n \\n {n} picked · up to {MAX_COMPARE}\\n \\n \\n
    \\n \\n
      \\n {places.map((p) => (\\n = MAX_COMPARE}\\n onCheck={(on) => toggle(p.id, on)}\\n />\\n ))}\\n
    \\n
    \\n \\n )}\\n

    \\n \\n Your cities are kept in this browser only. Clearing your browser's data removes them.\\n

    \\n
    \\n );\\n}\\n\\nfunction CityRow({\\n place,\\n checked,\\n disabled,\\n onCheck,\\n}: {\\n place: Place;\\n checked: boolean;\\n disabled: boolean;\\n onCheck: (on: boolean) => void;\\n}) {\\n const unit = useStore((s) => s.settings.unit);\\n useNow(60_000);\\n const { locate, locating } = useLocateAction();\\n const q = useForecast(place);\\n const data = q.data ?? readCached(place.id)?.data;\\n const g = data ? glance(data) : null;\\n const title = place.isHere ? (place.name === \\\"My location\\\" ? \\\"My location\\\" : `My location · ${place.name}`) : place.name;\\n\\n const remove = () => {\\n const removed = store.remove(place.id);\\n if (!removed) return;\\n // The row is gone; keep keyboard focus in the list rather than losing it.\\n requestAnimationFrame(() =>\\n document.querySelector('[data-testid=\\\"cities-list\\\"] [data-testid=\\\"cities-city-link\\\"], [data-testid=\\\"cities-empty-search-link\\\"]')?.focus(),\\n );\\n sonner(`${place.name} removed.`, {\\n duration: 4000,\\n action: {\\n label: \\\"Undo\\\",\\n onClick: () => {\\n store.restore(removed);\\n toast(`${place.name} put back.`);\\n },\\n },\\n });\\n };\\n\\n return (\\n
  • \\n \\n {g ? : }\\n \\n {title}\\n {g && data ? `${g.text} · ${cityNow(data)} local` : q.isError ? \\\"Weather unavailable\\\" : \\\"Loading weather…\\\"}\\n {region(place) || \\\" \\\"}\\n \\n {g ? temp(g.tempC, unit) : \\\"\\\"}\\n {place.isHere ? (\\n void locate()}\\n disabled={locating}\\n aria-label=\\\"Check my location again\\\"\\n data-testid=\\\"cities-locate-button\\\"\\n >\\n \\n \\n ) : (\\n \\n )}\\n
  • \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":30,"id":"coder_write_29","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":30,"function":{"arguments":"{\"path\":\"client/src/pages/compare.tsx\",\"content\":\"import { Link, useSearchParams } from \\\"react-router\\\";\\nimport { Columns2 } from \\\"lucide-react\\\";\\nimport { Button, Card, Skeleton } from \\\"@corbits/react-ui\\\";\\nimport { useForecast } from \\\"../api.ts\\\";\\nimport { UnitToggle, WxIcon } from \\\"../components/weather-bits.tsx\\\";\\nimport { cityNow, temp, windShort, windSpeed } from \\\"../lib/format.ts\\\";\\nimport { useNow } from \\\"../lib/hooks.ts\\\";\\nimport { cityAsPlace, glance, hereAsPlace, region, type Place } from \\\"../lib/places.ts\\\";\\nimport { useScene } from \\\"../lib/scene-state.ts\\\";\\nimport { readCached, useStore } from \\\"../lib/storage.ts\\\";\\nimport { outlook, today } from \\\"../lib/view.ts\\\";\\n\\nconst WORDS = [\\\"\\\", \\\"One\\\", \\\"Two\\\", \\\"Three\\\", \\\"Four\\\"];\\n\\nexport function Compare() {\\n useScene(null, false);\\n const [params] = useSearchParams();\\n const cities = useStore((s) => s.cities);\\n const here = useStore((s) => s.here);\\n const location = useStore((s) => s.settings.location);\\n const stored = useStore((s) => s.compare);\\n const all: Place[] = [...(here && location === \\\"granted\\\" ? [hereAsPlace(here)] : []), ...cities.map(cityAsPlace)];\\n const ids = (params.get(\\\"c\\\")?.split(\\\"~\\\").filter(Boolean) ?? stored).slice(0, 4);\\n const picked = ids.map((id) => all.find((p) => p.id === id)).filter((p): p is Place => !!p);\\n\\n if (picked.length < 2) {\\n return (\\n
    \\n
    \\n

    \\n Compare\\n

    \\n
    \\n \\n \\n

    Pick at least two cities

    \\n

    Tick 2 to 4 of your saved cities to see their weather side by side.

    \\n \\n
    \\n
    \\n );\\n }\\n\\n return (\\n
    \\n
    \\n
    \\n

    \\n Compare\\n

    \\n

    {WORDS[picked.length]} cities side by side, right now.

    \\n
    \\n
    \\n \\n \\n
    \\n
    \\n \\n {picked.map((p) => (\\n \\n ))}\\n
    \\n \\n );\\n}\\n\\nfunction CompareCard({ place }: { place: Place }) {\\n const unit = useStore((s) => s.settings.unit);\\n useNow(60_000);\\n const q = useForecast(place);\\n const data = q.data ?? readCached(place.id)?.data;\\n const g = data ? glance(data) : null;\\n const where = region(place);\\n if (!data || !g) {\\n return (\\n \\n
    \\n
    \\n

    {place.name}

    \\n {q.isError ? (\\n

    \\n We couldn't get this city's weather. Check your connection and try again.\\n

    \\n ) : (\\n <>\\n \\n \\n \\n \\n )}\\n
    \\n \\n );\\n }\\n const t = today(data);\\n const c = data.current;\\n return (\\n \\n
    \\n {g.scene.label}\\n \\n
    \\n
    \\n

    {place.isHere && place.name !== \\\"My location\\\" ? `My location · ${place.name}` : place.name}

    \\n

    \\n {where ? `${where} · ` : \\\"\\\"}\\n {cityNow(data)} local\\n

    \\n

    \\n {temp(c.temperature_2m, unit)}\\n

    \\n

    {g.text}

    \\n
    \\n
    \\n
    \\n
    Feels like
    \\n
    {temp(c.apparent_temperature, unit)}
    \\n
    \\n
    \\n
    Wind
    \\n
    \\n {windSpeed(c.wind_speed_10m, unit)} {windShort(c.wind_direction_10m)}\\n
    \\n
    \\n
    \\n
    Humidity
    \\n
    {Math.round(c.relative_humidity_2m)}%
    \\n
    \\n
    \\n
    High / low
    \\n
    \\n {temp(t.hiC, unit)} / {temp(t.loC, unit)}\\n
    \\n
    \\n
    \\n
      \\n {outlook(data).map((h) => (\\n
    1. \\n {h.label}\\n \\n {temp(h.tempC, unit)}\\n
    2. \\n ))}\\n
    \\n
    \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":31,"id":"coder_write_30","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":31,"function":{"arguments":"{\"path\":\"client/src/pages/error.tsx\",\"content\":\"import { isRouteErrorResponse, useRouteError } from \\\"react-router\\\";\\nimport { Button, EmptyState } from \\\"@corbits/react-ui\\\";\\n\\nexport function ErrorPage() {\\n const error = useRouteError();\\n const detail = isRouteErrorResponse(error)\\n ? `${error.status} ${error.statusText}`\\n : error instanceof Error\\n ? error.message\\n : \\\"Unknown error\\\";\\n return (\\n
    \\n window.location.reload()}>Reload}\\n />\\n
    \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":32,"id":"coder_write_31","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":32,"function":{"arguments":"{\"path\":\"client/src/pages/home.tsx\",\"content\":\"import { useEffect, useMemo, useRef } from \\\"react\\\";\\nimport { Link, useSearchParams } from \\\"react-router\\\";\\nimport { Bookmark, CircleAlert, Clock, MapPinOff, Navigation, Plus, RefreshCw, Search, WifiOff } from \\\"lucide-react\\\";\\nimport { Badge, Button, Card, Switch } from \\\"@corbits/react-ui\\\";\\nimport { toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport { useForecast } from \\\"../api.ts\\\";\\nimport { DayList, HeroSkeleton, HourStrip, ListSkeleton, StatTiles } from \\\"../components/forecast.tsx\\\";\\nimport { PlaceRow, PlacesPanel } from \\\"../components/places.tsx\\\";\\nimport { Notice, UnitToggle, WxChip, WxIcon } from \\\"../components/weather-bits.tsx\\\";\\nimport { age, ago, cityNow, savedAt, temp } from \\\"../lib/format.ts\\\";\\nimport { useNow, useOnline } from \\\"../lib/hooks.ts\\\";\\nimport { locate, permissionGranted } from \\\"../lib/location.ts\\\";\\nimport { cityAsPlace, glance, hereAsPlace, region, type Place } from \\\"../lib/places.ts\\\";\\nimport { useScene } from \\\"../lib/scene-state.ts\\\";\\nimport { cityId, getState, MY_LOCATION_ID, readCached, store, useStore } from \\\"../lib/storage.ts\\\";\\nimport { useLocateAction } from \\\"../lib/use-locate.ts\\\";\\nimport { parseForced } from \\\"../lib/weather.ts\\\";\\nimport { summary } from \\\"../lib/view.ts\\\";\\n\\nconst geoSupported = typeof navigator !== \\\"undefined\\\" && \\\"geolocation\\\" in navigator;\\n\\n/** Reads which place Home should show from the address, the saved places and the location. */\\nfunction usePlace(): { place: Place | null; preview: boolean } {\\n const [params] = useSearchParams();\\n const cities = useStore((s) => s.cities);\\n const here = useStore((s) => s.here);\\n const location = useStore((s) => s.settings.location);\\n const lastPlace = useStore((s) => s.settings.lastPlace);\\n const lat = Number(params.get(\\\"lat\\\"));\\n const lon = Number(params.get(\\\"lon\\\"));\\n const name = params.get(\\\"name\\\");\\n const city = params.get(\\\"city\\\");\\n return useMemo(() => {\\n if (name && Number.isFinite(lat) && Number.isFinite(lon) && params.has(\\\"lat\\\") && params.has(\\\"lon\\\")) {\\n const id = cityId(lat, lon);\\n const saved = cities.find((c) => c.id === id);\\n if (saved) return { place: cityAsPlace(saved), preview: false };\\n return {\\n place: { id, name, admin1: params.get(\\\"admin1\\\"), country: params.get(\\\"country\\\"), lat, lon, isHere: false },\\n preview: true,\\n };\\n }\\n if (city === MY_LOCATION_ID && here) return { place: hereAsPlace(here), preview: false };\\n const saved = city ? cities.find((c) => c.id === city) : undefined;\\n if (saved) return { place: cityAsPlace(saved), preview: false };\\n if (here && location === \\\"granted\\\") return { place: hereAsPlace(here), preview: false };\\n const last = cities.find((c) => c.id === lastPlace);\\n if (last && location !== \\\"unknown\\\") return { place: cityAsPlace(last), preview: false };\\n return { place: null, preview: false };\\n }, [name, lat, lon, city, cities, here, location, lastPlace, params]);\\n}\\n\\nexport function Home() {\\n const [params] = useSearchParams();\\n const { place, preview } = usePlace();\\n const cities = useStore((s) => s.cities);\\n const here = useStore((s) => s.here);\\n const location = useStore((s) => s.settings.location);\\n const { locate: locateAction, locating } = useLocateAction();\\n\\n // First open: ask for the device location once. Later opens refresh it quietly if already allowed.\\n const asked = useRef(false);\\n useEffect(() => {\\n if (asked.current) return;\\n asked.current = true;\\n const s = getState().settings.location;\\n if (s === \\\"unknown\\\" && geoSupported) void locate();\\n else if (s === \\\"granted\\\") void permissionGranted().then((ok) => {\\n if (ok) void locate();\\n });\\n }, []);\\n\\n useEffect(() => {\\n if (place && !preview) store.setLastPlace(place.id);\\n }, [place, preview]);\\n\\n const places = useMemo(\\n () => [...(here && location === \\\"granted\\\" ? [hereAsPlace(here)] : []), ...cities.map(cityAsPlace)],\\n [here, location, cities],\\n );\\n\\n if (!place) {\\n const waiting = locating || (location === \\\"unknown\\\" && geoSupported);\\n if (waiting) return ;\\n if (cities.length) return ;\\n return ;\\n }\\n return (\\n \\n );\\n}\\n\\nfunction PlaceView({\\n place,\\n preview,\\n places,\\n forced,\\n onLocate,\\n locating,\\n}: {\\n place: Place;\\n preview: boolean;\\n places: Place[];\\n forced: string | null;\\n onLocate: () => void;\\n locating: boolean;\\n}) {\\n const unit = useStore((s) => s.settings.unit);\\n const simpler = useStore((s) => s.settings.simpler);\\n const online = useOnline();\\n const now = useNow();\\n const q = useForecast(place);\\n const cached = useMemo(() => (q.data ? null : readCached(place.id)), [q.data, place.id]);\\n const fallback = q.isError || !online ? cached : null;\\n const data = q.data ?? fallback?.data;\\n const updatedAt = q.data ? q.dataUpdatedAt : fallback?.at;\\n const offline = !online;\\n const failed = q.isError && !q.isFetching;\\n const g = data ? glance(data) : null;\\n const forcedScene = parseForced(forced);\\n const scene = forcedScene ?? g?.scene ?? null;\\n useScene(scene, !forcedScene);\\n\\n const refresh = async () => {\\n const r = await q.refetch();\\n if (r.isSuccess) toast(\\\"Weather updated just now.\\\");\\n };\\n const save = () => {\\n const added = store.save({\\n name: place.name,\\n admin1: place.admin1 ?? undefined,\\n country: place.country ?? undefined,\\n lat: place.lat,\\n lon: place.lon,\\n });\\n toast(added ? `${place.name} saved to your places.` : `${place.name} is already in your places.`);\\n };\\n\\n const where = region(place);\\n const loading = !data && q.isPending;\\n const state = loading ? \\\"loading\\\" : offline ? \\\"offline\\\" : failed ? \\\"error\\\" : preview ? \\\"preview\\\" : \\\"populated\\\";\\n\\n return (\\n
    \\n \\n
    \\n
    \\n
    \\n

    \\n {place.name}\\n

    \\n

    \\n {place.isHere ? (\\n <>\\n \\n My location{where ? \\\" · \\\" : \\\"\\\"}\\n \\n ) : null}\\n {where}\\n {data ? (\\n <>\\n {where || place.isHere ? \\\" · \\\" : \\\"\\\"}\\n \\n \\n {cityNow(data)} local time\\n \\n \\n ) : null}\\n

    \\n
    \\n
    \\n \\n {preview ? (\\n \\n ) : null}\\n \\n
    \\n
    \\n\\n {preview ? (\\n } title=\\\"You're looking at a search result.\\\" testId=\\\"home-preview-notice\\\">\\n Save {place.name} to keep it in your places and compare it with other cities.\\n \\n ) : null}\\n\\n {offline ? (\\n } title=\\\"You're offline.\\\" testId=\\\"home-offline-notice\\\">\\n {data && updatedAt\\n ? `This is the weather we saved ${savedAt(updatedAt)}, ${ago(updatedAt, now)}, so it may have changed. Fresh weather needs an internet connection.`\\n : \\\"No saved weather for this city yet. Connect to the internet to see it.\\\"}\\n \\n ) : failed ? (\\n }\\n title=\\\"We couldn't get fresh weather.\\\"\\n tone=\\\"error\\\"\\n testId=\\\"home-error-notice\\\"\\n actions={\\n \\n }\\n >\\n {data && updatedAt\\n ? `The weather service didn't answer. Below is the last weather we got, updated ${ago(updatedAt, now)}.`\\n : \\\"The weather service didn't answer, and there's no saved weather for this place yet.\\\"}\\n \\n ) : null}\\n\\n {loading ? (\\n \\n

    \\n Getting the latest weather for {place.name}\\n

    \\n \\n
    \\n ) : data && g ? (\\n \\n
    \\n \\n
    \\n

    \\n {temp(data.current.temperature_2m, unit)}\\n

    \\n

    {g.text}

    \\n

    {summary(data)}

    \\n
    \\n
    \\n \\n
    \\n \\n \\n {offline ? `Last updated ${savedAt(updatedAt!)}` : failed ? `Last updated ${ago(updatedAt!, now)}` : `Updated ${ago(updatedAt!, now)} · Weather from Open-Meteo`}\\n \\n {offline && updatedAt && now - updatedAt > 15 * 60_000 ? (\\n \\n Old: {age(updatedAt, now)}\\n \\n ) : null}\\n \\n \\n Scene: {scene?.label}\\n \\n \\n
    \\n
    \\n ) : null}\\n\\n {loading || data ? (\\n <>\\n \\n
    \\n

    Next 24 hours

    \\n Blue figure: chance of rain\\n
    \\n {data ? : }\\n
    \\n \\n

    Next 7 days

    \\n {data ? : }\\n
    \\n \\n ) : null}\\n
    \\n
    \\n );\\n}\\n\\n/** First open while the browser asks for location: calm sky and placeholders. */\\nfunction LocatingState({ places, onLocate }: { places: Place[]; onLocate: () => void }) {\\n useForcedOrCalm();\\n return (\\n
    \\n \\n
    \\n
    \\n
    \\n

    \\n My location\\n

    \\n

    \\n \\n Finding where you are…\\n

    \\n
    \\n
    \\n \\n \\n
    \\n
    \\n \\n

    \\n Finding your location. If your browser asks, choose Allow to see your local weather.\\n

    \\n \\n
    \\n \\n

    Next 24 hours

    \\n \\n
    \\n
    \\n
    \\n );\\n}\\n\\n/** Location declined, with saved places to pick from. */\\nfunction DeniedState({ places, onRetry }: { places: Place[]; onRetry: () => void }) {\\n useForcedOrCalm();\\n return (\\n
    \\n
    \\n

    \\n Your weather\\n

    \\n \\n
    \\n }\\n title=\\\"Location is turned off for this app.\\\"\\n testId=\\\"home-denied-notice\\\"\\n actions={\\n <>\\n \\n \\n \\n }\\n >\\n That's fine: search for a city, or pick one of your saved places below. To use your location later, allow it in\\n your browser's settings, then tap “Try again”.\\n \\n \\n

    Your saved places

    \\n
      \\n {places.map((p) => (\\n \\n ))}\\n
    \\n
    \\n
    \\n );\\n}\\n\\nfunction SavedTile({ place }: { place: Place }) {\\n return ;\\n}\\n\\n/** First visit, no location and nothing saved. */\\nfunction EmptyState({ onLocate }: { onLocate: () => void }) {\\n useForcedOrCalm();\\n return (\\n
    \\n \\n \\n

    \\n Find your weather\\n

    \\n

    \\n Search for a city and watch its weather come alive on screen: rain falling, snow drifting, heat shimmering. Save\\n the places you care about; they stay in this browser.\\n

    \\n \\n \\n
    \\n
    \\n );\\n}\\n\\n\\n/** The calm scene, unless the test route forces one. */\\nfunction useForcedOrCalm() {\\n const [params] = useSearchParams();\\n const forced = parseForced(params.get(\\\"force\\\"));\\n useScene(forced, false);\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":33,"id":"coder_write_32","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":33,"function":{"arguments":"{\"path\":\"client/src/pages/not-found.tsx\",\"content\":\"import { Link } from \\\"react-router\\\";\\nimport { Button, EmptyState } from \\\"@corbits/react-ui\\\";\\n\\nexport function NotFound() {\\n return (\\n \\n Go home\\n \\n }\\n />\\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":34,"id":"coder_write_33","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":34,"function":{"arguments":"{\"path\":\"client/src/pages/search.tsx\",\"content\":\"import { useEffect, useRef, useState } from \\\"react\\\";\\nimport { Link, useSearchParams } from \\\"react-router\\\";\\nimport { CircleAlert, Navigation, Plus, Search as SearchIcon } from \\\"lucide-react\\\";\\nimport type { Place as FoundPlace } from \\\"@app/shared\\\";\\nimport { Badge, Button, Card, Input, Skeleton } from \\\"@corbits/react-ui\\\";\\nimport { toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport { useDebounced, useForecast, usePlaces } from \\\"../api.ts\\\";\\nimport { Notice, WxChip } from \\\"../components/weather-bits.tsx\\\";\\nimport { temp } from \\\"../lib/format.ts\\\";\\nimport { glance, placeHref, region, type Place } from \\\"../lib/places.ts\\\";\\nimport { useScene } from \\\"../lib/scene-state.ts\\\";\\nimport { cityId, readCached, store, useStore } from \\\"../lib/storage.ts\\\";\\nimport { useLocateAction } from \\\"../lib/use-locate.ts\\\";\\n\\nexport function Search() {\\n useScene(\\\"keep\\\");\\n const [params, setParams] = useSearchParams();\\n // The box is local state (typing stays instant); the address follows it so Back keeps the search.\\n const [text, setText] = useState(() => params.get(\\\"q\\\") ?? \\\"\\\");\\n const q = useDebounced(text.trim());\\n const results = usePlaces(q);\\n const input = useRef(null);\\n const { locate, locating } = useLocateAction();\\n\\n useEffect(() => {\\n input.current?.focus();\\n }, []);\\n\\n useEffect(() => {\\n if ((params.get(\\\"q\\\") ?? \\\"\\\") !== q) setParams(q ? { q } : {}, { replace: true });\\n }, [q, params, setParams]);\\n const typing = text.trim() !== q;\\n const ready = q.length >= 2;\\n const list = results.data?.results ?? [];\\n\\n return (\\n
    \\n
    \\n
    \\n

    \\n Search\\n

    \\n

    Find any city, then open it or save it.

    \\n
    \\n
    \\n \\n \\n
    \\n setText(e.target.value)}\\n placeholder=\\\"Try “Lisbon”\\\"\\n autoComplete=\\\"off\\\"\\n aria-busy={(ready && (typing || results.isFetching)) || undefined}\\n aria-describedby=\\\"search-hint\\\"\\n data-testid=\\\"search-input\\\"\\n />\\n void locate()}\\n disabled={locating}\\n aria-label=\\\"Use my location\\\"\\n data-testid=\\\"search-locate-button\\\"\\n >\\n \\n My location\\n \\n
    \\n

    \\n Results update as you type.\\n

    \\n
    \\n\\n {!ready ? null : typing || results.isPending ? (\\n \\n

    \\n Looking for places\\n

    \\n {Array.from({ length: 4 }, (_, i) => (\\n \\n ))}\\n
    \\n ) : results.isError ? (\\n }\\n title=\\\"Search isn't working right now.\\\"\\n tone=\\\"error\\\"\\n testId=\\\"search-error-notice\\\"\\n actions={\\n {\\n toast(\\\"Trying again…\\\");\\n void results.refetch();\\n }}\\n data-testid=\\\"search-retry-button\\\"\\n >\\n Try again\\n \\n }\\n >\\n We couldn't reach the city finder. Check your connection and try again.\\n \\n ) : list.length === 0 ? (\\n \\n \\n

    No results for “{q}”

    \\n

    Check the spelling, or try a larger city nearby. Small villages often appear under their nearest town.

    \\n \\n
    \\n ) : (\\n \\n

    \\n {list.length === 1\\n ? `1 place called “${q}”.`\\n : `${list.length} places called “${q}”. Pick yours by region and country.`}\\n

    \\n
      \\n {list.map((p) => (\\n \\n ))}\\n
    \\n
    \\n )}\\n
    \\n );\\n}\\n\\nfunction ResultRow({ found }: { found: FoundPlace }) {\\n const link = useRef(null);\\n const unit = useStore((s) => s.settings.unit);\\n const id = cityId(found.latitude, found.longitude);\\n const saved = useStore((s) => s.cities.some((c) => c.id === id));\\n const place: Place = {\\n id,\\n name: found.name,\\n admin1: found.admin1,\\n country: found.country,\\n lat: found.latitude,\\n lon: found.longitude,\\n isHere: false,\\n };\\n const f = useForecast(place);\\n const data = f.data ?? readCached(id)?.data;\\n const g = data ? glance(data) : null;\\n const where = region(place) || \\\"Region not known\\\";\\n const weather = g ? `${temp(g.tempC, unit)} · ${g.text}` : f.isError ? \\\"Weather unavailable\\\" : \\\"Loading weather…\\\";\\n const save = () => {\\n const added = store.save({ name: found.name, admin1: found.admin1, country: found.country, lat: found.latitude, lon: found.longitude });\\n toast(added ? `${found.name} saved to your places.` : `${found.name} is already in your places.`);\\n // The Save button turns into a \\\"Saved\\\" badge, so keep keyboard focus on the row.\\n requestAnimationFrame(() => link.current?.focus());\\n };\\n return (\\n
  • \\n {g ? : }\\n \\n {found.name}\\n {where}\\n {weather}\\n \\n {weather}\\n {saved ? (\\n \\n Saved\\n \\n ) : (\\n \\n )}\\n
  • \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":35,"id":"coder_write_34","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":35,"function":{"arguments":"{\"path\":\"client/src/pages/settings.tsx\",\"content\":\"import { useState } from \\\"react\\\";\\nimport { Download, Navigation } from \\\"lucide-react\\\";\\nimport { Badge, Button, Card, Switch } from \\\"@corbits/react-ui\\\";\\nimport { toast } from \\\"@corbits/react-ui/ui/toast\\\";\\nimport { UnitToggle, WxIcon } from \\\"../components/weather-bits.tsx\\\";\\nimport { promptInstall, useInstall } from \\\"../lib/install.ts\\\";\\nimport { useScene } from \\\"../lib/scene-state.ts\\\";\\nimport { store, useStore } from \\\"../lib/storage.ts\\\";\\nimport { useLocateAction } from \\\"../lib/use-locate.ts\\\";\\nimport { ALL_CONDITIONS, CONDITION_ICON, CONDITION_NAMES } from \\\"../lib/weather.ts\\\";\\nimport { useReducedMotion } from \\\"../scene/SceneLayer.tsx\\\";\\n\\nexport function Settings() {\\n useScene(\\\"keep\\\");\\n const simpler = useStore((s) => s.settings.simpler);\\n const reduced = useReducedMotion();\\n const { locate, locating } = useLocateAction();\\n const install = useInstall();\\n const [open, setOpen] = useState(false);\\n\\n const confirmInstall = async () => {\\n setOpen(false);\\n const accepted = await promptInstall();\\n if (accepted) toast(\\\"Installing. The app will open from your home screen.\\\");\\n };\\n\\n return (\\n
    \\n
    \\n
    \\n

    \\n Settings\\n

    \\n

    Changes save straight away, in this browser.

    \\n
    \\n \\n
    \\n\\n \\n
    \\n
    \\n

    Units

    \\n

    \\n Changes every temperature in the app. °C shows wind in km/h and rain in mm; °F shows mph and inches.\\n

    \\n
    \\n \\n
    \\n
    \\n\\n \\n

    Effects and motion

    \\n
    \\n
    \\n Simpler effects\\n

    \\n Fewer raindrops, flakes and clouds. Kinder to older phones and battery. Each kind of weather still looks\\n different.\\n

    \\n
    \\n \\n \\n \\n
    \\n
    \\n
    \\n Reduced motion\\n

    \\n Set by your device. When it asks for less motion, scenes hold still: the weather shows through colour and\\n soft shapes, with no falling, flashing or shimmering.\\n

    \\n
    \\n \\n {reduced ? \\\"Your device: less motion\\\" : \\\"Your device: full motion\\\"}\\n \\n
    \\n
    \\n
    \\n Automatic smoothness\\n

    \\n If your device struggles, the app quietly lightens the scene, and brings detail back when it can.\\n

    \\n
    \\n \\n Always on\\n \\n
    \\n
    \\n\\n \\n
    \\n
    \\n

    Location

    \\n

    Used only to fetch your local weather. Tap to check where you are again.

    \\n
    \\n \\n
    \\n
    \\n\\n \\n

    Saved in this browser

    \\n

    \\n Your saved cities, units and effects choice live only in this browser on this device. There's no account and\\n nothing is kept about you. If you clear your browser's data, they'll be gone and you'll need to add your cities\\n again.\\n

    \\n
    \\n\\n \\n

    The scenes

    \\n

    \\n Each kind of weather has a day and a night look, timed by sunrise and sunset in the city you're viewing.\\n

    \\n
      \\n {ALL_CONDITIONS.flatMap((c) =>\\n ([\\\"day\\\", \\\"night\\\"] as const).map((time, i) => (\\n
    • \\n \\n \\n \\n \\n {CONDITION_NAMES[c]}, {time}\\n \\n
    • \\n )),\\n )}\\n
    \\n
    \\n

    \\n Weather and city search by{\\\" \\\"}\\n \\n Open-Meteo\\n \\n , free for personal use.\\n

    \\n\\n {open ? (\\n
    setOpen(false)}>\\n e.stopPropagation()}\\n >\\n

    \\n Put the weather on your home screen\\n

    \\n

    \\n Installed, it opens full screen like any other app. No app store, no sign-in, and your cities stay in this\\n browser.\\n

    \\n {install.canPrompt ? null : (\\n
    \\n

    How to add it

    \\n
      \\n
    • iPhone or iPad (Safari): tap Share, then “Add to Home Screen”.
    • \\n
    • Android (Chrome): open the ⋮ menu, then “Install app” or “Add to Home screen”.
    • \\n
    • Computer (Chrome or Edge): click the install icon at the right of the address bar.
    • \\n
    \\n
    \\n )}\\n
    \\n \\n {install.canPrompt ? (\\n \\n ) : null}\\n
    \\n \\n
    \\n ) : null}\\n
    \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":36,"id":"coder_write_35","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":36,"function":{"arguments":"{\"path\":\"client/src/routes.tsx\",\"content\":\"import { createBrowserRouter } from \\\"react-router\\\";\\nimport { Layout } from \\\"./components/layout.tsx\\\";\\nimport { Cities } from \\\"./pages/cities.tsx\\\";\\nimport { Compare } from \\\"./pages/compare.tsx\\\";\\nimport { ErrorPage } from \\\"./pages/error.tsx\\\";\\nimport { Home } from \\\"./pages/home.tsx\\\";\\nimport { NotFound } from \\\"./pages/not-found.tsx\\\";\\nimport { Search } from \\\"./pages/search.tsx\\\";\\nimport { Settings } from \\\"./pages/settings.tsx\\\";\\n\\nexport const router = createBrowserRouter([\\n {\\n element: ,\\n errorElement: ,\\n children: [\\n { index: true, element: },\\n { path: \\\"search\\\", element: },\\n { path: \\\"cities\\\", element: },\\n { path: \\\"compare\\\", element: },\\n { path: \\\"settings\\\", element: },\\n { path: \\\"*\\\", element: },\\n ],\\n },\\n]);\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":37,"id":"coder_write_36","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":37,"function":{"arguments":"{\"path\":\"client/src/scene/SceneLayer.tsx\",\"content\":\"import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from \\\"react\\\";\\nimport { useShownScene } from \\\"../lib/scene-state.ts\\\";\\nimport { useStore } from \\\"../lib/storage.ts\\\";\\n\\nconst WeatherScene = lazy(() => import(\\\"./WeatherScene.tsx\\\"));\\n\\nconst reducedQuery = typeof matchMedia === \\\"function\\\" ? matchMedia(\\\"(prefers-reduced-motion: reduce)\\\") : null;\\n/** The device's reduce-motion setting, watched live. */\\nexport const useReducedMotion = () =>\\n useSyncExternalStore(\\n (l) => {\\n reducedQuery?.addEventListener(\\\"change\\\", l);\\n return () => reducedQuery?.removeEventListener(\\\"change\\\", l);\\n },\\n () => reducedQuery?.matches ?? false,\\n );\\n\\n/**\\n * The full-screen weather behind every screen: the sky colours (shown at once) and the animated\\n * canvas (loaded after the figures). It also sets the app's palette: day scenes use the light\\n * surfaces, night scenes the deep indigo ones, and each scene its own accent colour.\\n */\\nexport function SceneLayer() {\\n const scene = useShownScene();\\n const simpler = useStore((s) => s.settings.simpler);\\n const reduced = useReducedMotion();\\n const wx = scene?.key ?? \\\"calm\\\";\\n const [skies, setSkies] = useState<{ key: string; id: number }[]>([{ key: wx, id: 0 }]);\\n const [canvasReady, setCanvasReady] = useState(false);\\n\\n useEffect(() => {\\n const root = document.documentElement;\\n root.dataset.wx = wx;\\n const night = wx.endsWith(\\\"-night\\\");\\n root.classList.toggle(\\\"dark\\\", night);\\n root.classList.toggle(\\\"light\\\", !night);\\n const meta = document.querySelector('meta[name=\\\"theme-color\\\"]');\\n meta?.setAttribute(\\\"content\\\", getComputedStyle(root).getPropertyValue(\\\"--sky-a\\\").trim() || \\\"#cfd9e4\\\");\\n // New sky fades in over the old one; the old one goes once the fade is done (no blank flash).\\n setSkies((prev) => (prev[prev.length - 1]?.key === wx ? prev : [...prev.slice(-1), { key: wx, id: Date.now() }]));\\n const t = setTimeout(() => setSkies((prev) => prev.slice(-1)), 1300);\\n return () => clearTimeout(t);\\n }, [wx]);\\n\\n const hasScene = scene !== null;\\n useEffect(() => {\\n // Load the moving scene only once a city's figures are on screen (they set the scene), so the\\n // numbers never wait for the graphics. The calm sky needs no canvas.\\n if (canvasReady || !hasScene) return;\\n const idle = window.requestIdleCallback ?? ((cb: () => void) => window.setTimeout(cb, 200));\\n const handle = idle(() => setCanvasReady(true), { timeout: 800 });\\n return () => (window.cancelIdleCallback ?? clearTimeout)(handle);\\n }, [canvasReady, hasScene]);\\n\\n return (\\n
    \\n {skies.map((s) => (\\n
    \\n ))}\\n {canvasReady ? (\\n \\n \\n \\n ) : null}\\n
    \\n );\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":38,"id":"coder_write_37","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":38,"function":{"arguments":"{\"path\":\"client/src/scene/WeatherScene.tsx\",\"content\":\"import { useEffect, useRef } from \\\"react\\\";\\nimport { Engine, type SceneSpec } from \\\"./engine.ts\\\";\\n\\n/** The animated canvas. Loaded on its own after the figures, so numbers never wait for it. */\\nexport default function WeatherScene({\\n spec,\\n simpler,\\n reduced,\\n}: {\\n spec: SceneSpec;\\n simpler: boolean;\\n reduced: boolean;\\n}) {\\n const ref = useRef(null);\\n const engine = useRef(null);\\n\\n useEffect(() => {\\n const e = new Engine(ref.current!);\\n engine.current = e;\\n return () => {\\n e.destroy();\\n engine.current = null;\\n };\\n }, []);\\n\\n useEffect(() => {\\n engine.current?.setSimpler(simpler);\\n }, [simpler]);\\n useEffect(() => {\\n engine.current?.setReduced(reduced);\\n }, [reduced]);\\n const key = spec ? `${spec.condition}-${spec.intensity}-${spec.isDay}` : \\\"calm\\\";\\n useEffect(() => {\\n engine.current?.setScene(spec);\\n // The key captures every field of the spec.\\n // eslint-disable-next-line react-hooks/exhaustive-deps\\n }, [key]);\\n\\n return ;\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":39,"id":"coder_write_38","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":39,"function":{"arguments":"{\"path\":\"client/src/scene/clear.ts\",\"content\":\"import { Clouds, dotSprite, rand, share, type Effect, type EffectOptions } from \\\"./common.ts\\\";\\n\\n// Clear sky. By day: a bright sun with slowly turning rays, a few thin high clouds and drifting\\n// motes of light. By night: a moon and a field of gently twinkling stars.\\n\\ntype Star = { x: number; y: number; r: number; phase: number; speed: number };\\ntype Mote = { x: number; y: number; v: number; r: number; phase: number };\\n\\nexport class Clear implements Effect {\\n protected w = 0;\\n protected h = 0;\\n protected time = 0;\\n private stars: Star[] = [];\\n private motes: Mote[] = [];\\n private wisps: Clouds | null;\\n private glow = dotSprite(\\\"255,240,200\\\", 64);\\n private moonGlow = dotSprite(\\\"244,241,230\\\", 64);\\n constructor(protected opts: EffectOptions, protected sunScale = 1) {\\n this.wisps =\\n opts.intensity === \\\"light\\\" && opts.isDay\\n ? new Clouds({ color: \\\"255,255,255\\\", count: 3, top: 0.1, bottom: 0.4, speed: 8, alpha: 0.45, scale: [0.7, 1.1] })\\n : null;\\n }\\n resize(w: number, h: number) {\\n const first = this.stars.length === 0 && this.motes.length === 0;\\n this.w = w;\\n this.h = h;\\n this.wisps?.resize(w, h);\\n if (!first) return;\\n if (!this.opts.isDay) {\\n const n = share(this.opts, 320);\\n for (let i = 0; i < n; i++) {\\n this.stars.push({\\n x: Math.random() * w,\\n y: Math.random() ** 1.5 * h * 0.85,\\n r: Math.random() < 0.08 ? rand(1.2, 1.9) : rand(0.4, 1.1),\\n phase: rand(0, Math.PI * 2),\\n speed: rand(0.6, 2.2),\\n });\\n }\\n } else {\\n const n = share(this.opts, 40);\\n for (let i = 0; i < n; i++) this.motes.push(this.newMote(true));\\n }\\n }\\n private newMote(anywhere: boolean): Mote {\\n return {\\n x: rand(0, this.w),\\n y: anywhere ? rand(0, this.h) : this.h + 10,\\n v: rand(6, 18),\\n r: rand(2, 6),\\n phase: rand(0, Math.PI * 2),\\n };\\n }\\n update(dt: number) {\\n this.time += dt;\\n this.wisps?.update(dt);\\n for (let i = 0; i < this.motes.length; i++) {\\n const m = this.motes[i]!;\\n m.y -= m.v * dt;\\n m.x += Math.sin(this.time * 0.5 + m.phase) * 6 * dt;\\n if (m.y < -10) this.motes[i] = this.newMote(false);\\n }\\n }\\n protected sunAt() {\\n return { x: this.w * 0.8, y: Math.min(this.h * 0.16, 150) };\\n }\\n protected drawSun(ctx: CanvasRenderingContext2D, alpha: number) {\\n const { x, y } = this.sunAt();\\n const r = 48 * this.sunScale;\\n ctx.globalAlpha = 0.6 * alpha;\\n const g = r * 9;\\n ctx.drawImage(this.glow, x - g / 2, y - g / 2, g, g);\\n // Rays turn slowly (held still under reduced motion).\\n ctx.save();\\n ctx.translate(x, y);\\n ctx.rotate(this.opts.still ? 0 : this.time * 0.03);\\n ctx.fillStyle = `rgba(255,248,225,${0.12 * alpha})`;\\n for (let i = 0; i < 14; i++) {\\n ctx.rotate((Math.PI * 2) / 14);\\n ctx.beginPath();\\n ctx.moveTo(-6, r * 1.2);\\n ctx.lineTo(6, r * 1.2);\\n ctx.lineTo(0, r * (i % 2 ? 4.2 : 6));\\n ctx.fill();\\n }\\n ctx.restore();\\n ctx.globalAlpha = alpha;\\n ctx.fillStyle = \\\"#fff8e1\\\";\\n ctx.beginPath();\\n ctx.arc(x, y, r, 0, Math.PI * 2);\\n ctx.fill();\\n ctx.globalAlpha = 1;\\n }\\n protected drawNight(ctx: CanvasRenderingContext2D, alpha: number, starShare = 1) {\\n ctx.fillStyle = \\\"#fff\\\";\\n const limit = Math.round(this.stars.length * starShare);\\n for (let i = 0; i < limit; i++) {\\n const s = this.stars[i]!;\\n const tw = this.opts.still ? 0.8 : 0.55 + 0.45 * Math.sin(this.time * s.speed + s.phase);\\n ctx.globalAlpha = tw * alpha;\\n ctx.fillRect(s.x, s.y, s.r * 1.6, s.r * 1.6);\\n }\\n const x = this.w * 0.78;\\n const y = Math.min(this.h * 0.16, 140);\\n ctx.globalAlpha = 0.35 * alpha;\\n ctx.drawImage(this.moonGlow, x - 110, y - 110, 220, 220);\\n ctx.globalAlpha = alpha;\\n ctx.fillStyle = \\\"#f4f1e6\\\";\\n ctx.beginPath();\\n ctx.arc(x, y, 30, 0, Math.PI * 2);\\n ctx.fill();\\n // Craters.\\n ctx.fillStyle = \\\"rgba(200,196,182,0.55)\\\";\\n for (const [dx, dy, r] of [\\n [-9, -6, 6],\\n [8, 7, 4.5],\\n [6, -10, 3],\\n ] as const) {\\n ctx.beginPath();\\n ctx.arc(x + dx, y + dy, r, 0, Math.PI * 2);\\n ctx.fill();\\n }\\n ctx.globalAlpha = 1;\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n if (!this.opts.isDay) {\\n this.drawNight(ctx, alpha);\\n return;\\n }\\n this.drawSun(ctx, alpha);\\n this.wisps?.draw(ctx, alpha);\\n for (const m of this.motes) {\\n ctx.globalAlpha = 0.25 * alpha;\\n ctx.drawImage(this.glow, m.x - m.r, m.y - m.r, m.r * 2, m.r * 2);\\n }\\n ctx.globalAlpha = 1;\\n }\\n count() {\\n return this.stars.length + this.motes.length + (this.wisps?.size ?? 0);\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":40,"id":"coder_write_39","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":40,"function":{"arguments":"{\"path\":\"client/src/scene/cloud.ts\",\"content\":\"import { Clouds, dotSprite, type Effect, type EffectOptions } from \\\"./common.ts\\\";\\n\\n// Cloudy: several cloud layers drifting at different speeds for depth. Partly cloudy leaves gaps\\n// with the sun (or moon) showing through; overcast fills the sky with heavier grey cloud.\\n\\nexport class Cloudy implements Effect {\\n private layers: Clouds[];\\n private w = 0;\\n private h = 0;\\n private glow = dotSprite(\\\"255,244,214\\\", 64);\\n constructor(private opts: EffectOptions) {\\n const day = opts.isDay;\\n const overcast = opts.intensity === \\\"heavy\\\";\\n const q = opts.quality;\\n const base = overcast ? (day ? \\\"225,230,238\\\" : \\\"95,105,125\\\") : day ? \\\"255,255,255\\\" : \\\"110,122,146\\\";\\n const dark = overcast ? (day ? \\\"170,180,195\\\" : \\\"60,68,86\\\") : day ? \\\"230,236,244\\\" : \\\"80,90,112\\\";\\n this.layers = [\\n new Clouds({ color: dark, count: q >= 2 ? 8 : 4, top: 0.0, bottom: 0.35, speed: 6, alpha: 0.7, scale: [1.3, 2.2] }),\\n new Clouds({ color: base, count: q >= 2 ? (overcast ? 10 : 6) : 4, top: 0.15, bottom: 0.6, speed: 12, alpha: overcast ? 0.75 : 0.65, scale: [1.0, 1.8] }),\\n ];\\n if (q >= 2) {\\n this.layers.push(new Clouds({ color: base, count: overcast ? 6 : 3, top: 0.5, bottom: 0.85, speed: 22, alpha: 0.4, scale: [1.4, 2.4] }));\\n }\\n }\\n resize(w: number, h: number) {\\n this.w = w;\\n this.h = h;\\n for (const l of this.layers) l.resize(w, h);\\n }\\n update(dt: number) {\\n for (const l of this.layers) l.update(dt);\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n if (this.opts.intensity !== \\\"heavy\\\") {\\n // The sun or moon behind thinner cloud.\\n const r = this.opts.isDay ? 220 : 120;\\n ctx.globalAlpha = (this.opts.isDay ? 0.55 : 0.3) * alpha;\\n ctx.drawImage(this.glow, this.w * 0.78 - r, this.h * 0.14 - r, r * 2, r * 2);\\n ctx.globalAlpha = 1;\\n }\\n for (const l of this.layers) l.draw(ctx, alpha);\\n }\\n count() {\\n return this.layers.reduce((n, l) => n + l.size, 0);\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":41,"id":"coder_write_40","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":41,"function":{"arguments":"{\"path\":\"client/src/scene/common.ts\",\"content\":\"// Shared pieces for the scene effects: the effect contract, random helpers and pre-drawn sprites\\n// (clouds, mist, flakes) so each frame only copies small images instead of building gradients.\\n\\nexport type Intensity = \\\"light\\\" | \\\"moderate\\\" | \\\"heavy\\\";\\n\\nexport type EffectOptions = {\\n intensity: Intensity;\\n isDay: boolean;\\n /** 0 = still frame, 1 = simpler, 2 = reduced, 3 = full. */\\n quality: number;\\n /** Reduced motion: hold still, no flashing, no shimmer. */\\n still: boolean;\\n};\\n\\nexport interface Effect {\\n resize(w: number, h: number): void;\\n update(dt: number, t: number): void;\\n draw(ctx: CanvasRenderingContext2D, alpha: number): void;\\n /** Moving pieces currently drawn, for the smoothness monitor and checks. */\\n count(): number;\\n}\\n\\n/** How much of the full particle budget each quality level draws. */\\nexport const QUALITY_SHARE = [0.12, 0.25, 0.55, 1] as const;\\n\\nexport const rand = (a: number, b: number) => a + Math.random() * (b - a);\\nexport const pick = (xs: readonly T[]) => xs[Math.floor(Math.random() * xs.length)]!;\\n\\nexport function share(opts: EffectOptions, full: number) {\\n return Math.max(1, Math.round(full * QUALITY_SHARE[Math.max(0, Math.min(3, opts.quality))]!));\\n}\\n\\nexport const INTENSITY_SCALE: Record = { light: 0.3, moderate: 0.6, heavy: 1 };\\n\\nfunction offscreen(w: number, h: number) {\\n const c = document.createElement(\\\"canvas\\\");\\n c.width = w;\\n c.height = h;\\n return c;\\n}\\n\\nconst spriteCache = new Map();\\n\\n/** A few soft cloud shapes in one colour, drawn once and reused. */\\nexport function cloudSprites(color: string, puffAlpha = 0.9): HTMLCanvasElement[] {\\n const key = `cloud:${color}:${puffAlpha}`;\\n const hit = spriteCache.get(key);\\n if (hit) return hit;\\n const sprites: HTMLCanvasElement[] = [];\\n for (let s = 0; s < 5; s++) {\\n const w = 360;\\n const h = 170;\\n const c = offscreen(w, h);\\n const g = c.getContext(\\\"2d\\\")!;\\n const puffs = 7 + Math.floor(Math.random() * 6);\\n for (let i = 0; i < puffs; i++) {\\n const px = rand(70, w - 70);\\n const py = rand(70, h - 45) - Math.abs(px - w / 2) * 0.12;\\n const r = rand(34, 62) * (1 - Math.abs(px - w / 2) / w);\\n const grad = g.createRadialGradient(px, py, 0, px, py, r * 1.35);\\n grad.addColorStop(0, `rgba(${color},${puffAlpha})`);\\n grad.addColorStop(0.55, `rgba(${color},${puffAlpha * 0.65})`);\\n grad.addColorStop(1, `rgba(${color},0)`);\\n g.fillStyle = grad;\\n g.beginPath();\\n g.arc(px, py, r * 1.35, 0, Math.PI * 2);\\n g.fill();\\n }\\n sprites.push(c);\\n }\\n spriteCache.set(key, sprites);\\n return sprites;\\n}\\n\\n/** A wide soft ellipse for fog and haze. */\\nexport function mistSprite(color: string): HTMLCanvasElement {\\n const key = `mist:${color}`;\\n const hit = spriteCache.get(key);\\n if (hit) return hit[0]!;\\n const w = 512;\\n const h = 160;\\n const c = offscreen(w, h);\\n const g = c.getContext(\\\"2d\\\")!;\\n g.translate(w / 2, h / 2);\\n g.scale(1, h / w);\\n const grad = g.createRadialGradient(0, 0, 0, 0, 0, w / 2);\\n grad.addColorStop(0, `rgba(${color},0.75)`);\\n grad.addColorStop(0.5, `rgba(${color},0.4)`);\\n grad.addColorStop(1, `rgba(${color},0)`);\\n g.fillStyle = grad;\\n g.beginPath();\\n g.arc(0, 0, w / 2, 0, Math.PI * 2);\\n g.fill();\\n spriteCache.set(key, [c]);\\n return c;\\n}\\n\\n/** A soft round dot (snowflake, star glow, heat mote). */\\nexport function dotSprite(color: string, size = 32): HTMLCanvasElement {\\n const key = `dot:${color}:${size}`;\\n const hit = spriteCache.get(key);\\n if (hit) return hit[0]!;\\n const c = offscreen(size, size);\\n const g = c.getContext(\\\"2d\\\")!;\\n const r = size / 2;\\n const grad = g.createRadialGradient(r, r, 0, r, r, r);\\n grad.addColorStop(0, `rgba(${color},1)`);\\n grad.addColorStop(0.45, `rgba(${color},0.85)`);\\n grad.addColorStop(1, `rgba(${color},0)`);\\n g.fillStyle = grad;\\n g.fillRect(0, 0, size, size);\\n spriteCache.set(key, [c]);\\n return c;\\n}\\n\\n/** A drifting band of clouds, used by cloudy, rain, storm, snow and wind. */\\nexport class Clouds {\\n private items: { x: number; y: number; s: number; v: number; img: HTMLCanvasElement; a: number }[] = [];\\n private w = 0;\\n private h = 0;\\n constructor(\\n private opts: {\\n color: string;\\n count: number;\\n /** Fraction of the screen height the clouds sit in. */\\n top: number;\\n bottom: number;\\n speed: number;\\n alpha: number;\\n scale: [number, number];\\n },\\n ) {}\\n resize(w: number, h: number) {\\n const first = this.items.length === 0;\\n this.w = w;\\n this.h = h;\\n if (!first) return;\\n const sprites = cloudSprites(this.opts.color);\\n const n = this.opts.count;\\n for (let i = 0; i < n; i++) {\\n const depth = i / Math.max(1, n - 1);\\n const s = rand(this.opts.scale[0], this.opts.scale[1]) * (0.7 + depth * 0.6) * Math.max(0.7, w / 1400);\\n this.items.push({\\n x: rand(-0.2, 1.1) * w,\\n y: rand(this.opts.top, this.opts.bottom) * h,\\n s,\\n v: this.opts.speed * (0.5 + depth),\\n img: pick(sprites),\\n a: this.opts.alpha * (0.6 + depth * 0.4),\\n });\\n }\\n // Far (small, slow) first, near (big, fast) on top.\\n this.items.sort((a, b) => a.s - b.s);\\n }\\n update(dt: number) {\\n for (const c of this.items) {\\n c.x += c.v * dt;\\n const width = c.img.width * c.s;\\n if (c.v > 0 && c.x > this.w + 20) {\\n c.x = -width - rand(0, 200);\\n c.y = rand(this.opts.top, this.opts.bottom) * this.h;\\n }\\n }\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n for (const c of this.items) {\\n ctx.globalAlpha = c.a * alpha;\\n ctx.drawImage(c.img, c.x, c.y - (c.img.height * c.s) / 2, c.img.width * c.s, c.img.height * c.s);\\n }\\n ctx.globalAlpha = 1;\\n }\\n get size() {\\n return this.items.length;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":42,"id":"coder_write_41","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":42,"function":{"arguments":"{\"path\":\"client/src/scene/engine.ts\",\"content\":\"import type { Condition, Intensity } from \\\"../lib/weather.ts\\\";\\nimport type { Effect, EffectOptions } from \\\"./common.ts\\\";\\nimport { Clear } from \\\"./clear.ts\\\";\\nimport { Cloudy } from \\\"./cloud.ts\\\";\\nimport { Fog } from \\\"./fog.ts\\\";\\nimport { Heat } from \\\"./heat.ts\\\";\\nimport { QualityMonitor } from \\\"./quality.ts\\\";\\nimport { Rain } from \\\"./rain.ts\\\";\\nimport { Snow } from \\\"./snow.ts\\\";\\nimport { Storm } from \\\"./storm.ts\\\";\\nimport { Wind } from \\\"./wind.ts\\\";\\n\\n// The scene's drawing loop: one canvas, one effect per weather, a 1.2 second cross-fade between\\n// scenes, automatic quality steps, a full stop while the tab is hidden, and a still frame under\\n// reduced motion.\\n\\nexport type SceneSpec = { condition: Condition; intensity: Intensity; isDay: boolean } | null;\\n\\nconst FADE_S = 1.2;\\nconst MAX_QUALITY = 3;\\nconst SIMPLER_QUALITY = 1;\\n\\nfunction makeEffect(spec: NonNullable, opts: EffectOptions): Effect {\\n switch (spec.condition) {\\n case \\\"rain\\\":\\n return new Rain(opts);\\n case \\\"storm\\\":\\n return new Storm(opts);\\n case \\\"snow\\\":\\n return new Snow(opts);\\n case \\\"wind\\\":\\n return new Wind(opts);\\n case \\\"cloudy\\\":\\n return new Cloudy(opts);\\n case \\\"fog\\\":\\n return new Fog(opts);\\n case \\\"hot\\\":\\n return new Heat(opts);\\n default:\\n return new Clear(opts);\\n }\\n}\\n\\nconst keyOf = (s: SceneSpec) => (s ? `${s.condition}-${s.intensity}-${s.isDay ? \\\"day\\\" : \\\"night\\\"}` : \\\"calm\\\");\\n\\ntype Layer = { spec: SceneSpec; effect: Effect | null; alpha: number; target: number };\\n\\nexport class Engine {\\n private ctx: CanvasRenderingContext2D;\\n private layers: Layer[] = [];\\n private w = 0;\\n private h = 0;\\n private dpr = 1;\\n private raf = 0;\\n private last = 0;\\n private running = false;\\n private monitor = new QualityMonitor();\\n private quality: number;\\n private maxQuality = MAX_QUALITY;\\n private reduced = false;\\n private upTimer = 0;\\n private maxFlashes = 0;\\n\\n constructor(private canvas: HTMLCanvasElement) {\\n this.ctx = canvas.getContext(\\\"2d\\\", { alpha: true })!;\\n // Phones with few cores start one step lower; the monitor raises it if they cope.\\n this.quality = (navigator.hardwareConcurrency ?? 8) <= 4 ? 2 : MAX_QUALITY;\\n addEventListener(\\\"resize\\\", this.onResize);\\n document.addEventListener(\\\"visibilitychange\\\", this.onVisibility);\\n this.onResize();\\n }\\n\\n destroy() {\\n this.stop();\\n clearTimeout(this.upTimer);\\n removeEventListener(\\\"resize\\\", this.onResize);\\n document.removeEventListener(\\\"visibilitychange\\\", this.onVisibility);\\n }\\n\\n private options(spec: NonNullable): EffectOptions {\\n return { intensity: spec.intensity, isDay: spec.isDay, quality: this.quality, still: this.reduced };\\n }\\n\\n private build(spec: SceneSpec): Effect | null {\\n if (!spec) return null;\\n const effect = makeEffect(spec, this.options(spec));\\n effect.resize(this.w, this.h);\\n return effect;\\n }\\n\\n setScene(spec: SceneSpec) {\\n const top = this.layers[this.layers.length - 1];\\n if (top && keyOf(top.spec) === keyOf(spec)) return;\\n for (const l of this.layers) l.target = 0;\\n const instant = this.reduced || this.layers.length === 0;\\n this.layers.push({ spec, effect: this.build(spec), alpha: instant ? 1 : 0, target: 1 });\\n if (instant) this.layers = this.layers.slice(-1);\\n this.monitor.reset(performance.now());\\n this.refresh();\\n }\\n\\n setSimpler(simpler: boolean) {\\n const max = simpler ? SIMPLER_QUALITY : MAX_QUALITY;\\n if (max === this.maxQuality) return;\\n this.maxQuality = max;\\n this.setQuality(simpler ? Math.min(this.quality, max) : max);\\n }\\n\\n setReduced(reduced: boolean) {\\n if (reduced === this.reduced) return;\\n this.reduced = reduced;\\n this.rebuild();\\n this.refresh();\\n }\\n\\n private setQuality(q: number) {\\n this.quality = Math.max(0, Math.min(this.maxQuality, q));\\n this.rebuild();\\n this.onResize();\\n this.monitor.reset(performance.now());\\n this.refresh();\\n }\\n\\n /** Rebuild the visible effects at the current quality, keeping the fade where it was. */\\n private rebuild() {\\n for (const l of this.layers) l.effect = this.build(l.spec);\\n }\\n\\n /** Start or stop the loop to match the current settings, and draw at least once. */\\n private refresh() {\\n this.setDataset();\\n const animate = !this.reduced && this.quality > 0 && !document.hidden && this.layers.some((l) => l.spec);\\n if (animate) this.start();\\n else {\\n this.stop();\\n this.render(0);\\n // At the still level, try moving again after a while in case the device has recovered.\\n clearTimeout(this.upTimer);\\n if (!this.reduced && this.quality === 0 && this.maxQuality > 0)\\n this.upTimer = window.setTimeout(() => this.setQuality(1), 10_000);\\n }\\n }\\n\\n private start() {\\n if (this.running) return;\\n this.running = true;\\n this.last = performance.now();\\n this.monitor.reset(this.last);\\n this.raf = requestAnimationFrame(this.tick);\\n this.setDataset();\\n }\\n\\n private stop() {\\n this.running = false;\\n cancelAnimationFrame(this.raf);\\n this.setDataset();\\n }\\n\\n private tick = (now: number) => {\\n if (!this.running) return;\\n const dt = Math.min(0.05, Math.max(0, (now - this.last) / 1000));\\n this.last = now;\\n this.render(dt);\\n const step = this.monitor.frame(now);\\n if (step !== 0) this.setQuality(this.quality + step);\\n if (this.running) this.raf = requestAnimationFrame(this.tick);\\n };\\n\\n private render(dt: number) {\\n const ctx = this.ctx;\\n for (const l of this.layers) {\\n l.alpha = l.target > l.alpha ? Math.min(1, l.alpha + dt / FADE_S) : Math.max(0, l.alpha - dt / FADE_S);\\n if (dt > 0) l.effect?.update(dt, this.last / 1000);\\n }\\n // Faded-out scenes are dropped once the new one is fully in.\\n this.layers = this.layers.filter((l) => l.target === 1 || l.alpha > 0);\\n ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);\\n ctx.clearRect(0, 0, this.w, this.h);\\n for (const l of this.layers) if (l.effect && l.alpha > 0) l.effect.draw(ctx, l.alpha);\\n for (const l of this.layers) {\\n if (l.effect instanceof Storm) this.maxFlashes = Math.max(this.maxFlashes, l.effect.flashesLastSecond());\\n }\\n if (dt > 0 && Math.random() < 0.05) this.setDataset();\\n }\\n\\n private onResize = () => {\\n this.w = window.innerWidth;\\n this.h = window.innerHeight;\\n this.dpr = Math.min(window.devicePixelRatio || 1, this.quality >= 3 ? 1.5 : 1);\\n this.canvas.width = Math.round(this.w * this.dpr);\\n this.canvas.height = Math.round(this.h * this.dpr);\\n for (const l of this.layers) l.effect?.resize(this.w, this.h);\\n if (!this.running) this.render(0);\\n };\\n\\n private onVisibility = () => {\\n if (document.hidden) this.stop();\\n else this.refresh();\\n };\\n\\n private setDataset() {\\n const d = this.canvas.dataset;\\n d.quality = String(this.quality);\\n d.running = String(this.running);\\n d.particles = String(this.layers.filter((l) => l.target === 1).reduce((n, l) => n + (l.effect?.count() ?? 0), 0));\\n d.fps = String(Math.round(this.monitor.fps));\\n d.maxFlashesPerSecond = String(this.maxFlashes);\\n // At the lowest level, frosted blur is switched off too (it is costly on weak devices).\\n document.documentElement.dataset.lite = String(this.quality === 0);\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":43,"id":"coder_write_42","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":43,"function":{"arguments":"{\"path\":\"client/src/scene/fog.ts\",\"content\":\"import { mistSprite, rand, share, type Effect, type EffectOptions } from \\\"./common.ts\\\";\\n\\n// Fog: low banks of mist rolling slowly across the lower screen in several layers, over a pale\\n// haze that thickens towards the ground.\\n\\ntype Bank = { x: number; y: number; w: number; h: number; v: number; a: number };\\n\\nexport class Fog implements Effect {\\n private banks: Bank[] = [];\\n private w = 0;\\n private h = 0;\\n private sprite: HTMLCanvasElement;\\n constructor(private opts: EffectOptions) {\\n this.sprite = mistSprite(opts.isDay ? \\\"255,255,255\\\" : \\\"170,178,190\\\");\\n }\\n resize(w: number, h: number) {\\n const first = this.banks.length === 0;\\n this.w = w;\\n this.h = h;\\n if (!first) return;\\n const n = share(this.opts, this.opts.intensity === \\\"heavy\\\" ? 34 : 24);\\n for (let i = 0; i < n; i++) {\\n const depth = i / Math.max(1, n - 1);\\n this.banks.push({\\n x: rand(-0.4, 1) * w,\\n y: h * (0.3 + depth * 0.62) + rand(-30, 30),\\n w: w * rand(0.5, 0.95) * (0.7 + depth * 0.6),\\n h: h * rand(0.16, 0.3),\\n v: (Math.random() < 0.5 ? -1 : 1) * rand(8, 22) * (0.5 + depth),\\n a: rand(0.35, 0.6) * (this.opts.isDay ? 1 : 0.6),\\n });\\n }\\n }\\n update(dt: number) {\\n for (const b of this.banks) {\\n b.x += b.v * dt;\\n if (b.v > 0 && b.x > this.w) b.x = -b.w;\\n if (b.v < 0 && b.x + b.w < 0) b.x = this.w;\\n }\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n const day = this.opts.isDay;\\n const haze = ctx.createLinearGradient(0, this.h * 0.25, 0, this.h);\\n haze.addColorStop(0, `rgba(${day ? \\\"240,242,245\\\" : \\\"120,128,140\\\"},0)`);\\n haze.addColorStop(1, `rgba(${day ? \\\"240,242,245\\\" : \\\"120,128,140\\\"},${(day ? 0.55 : 0.35) * alpha})`);\\n ctx.fillStyle = haze;\\n ctx.fillRect(0, this.h * 0.25, this.w, this.h * 0.75);\\n for (const b of this.banks) {\\n ctx.globalAlpha = b.a * alpha;\\n ctx.drawImage(this.sprite, b.x, b.y - b.h / 2, b.w, b.h);\\n }\\n ctx.globalAlpha = 1;\\n }\\n count() {\\n return this.banks.length;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":44,"id":"coder_write_43","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":44,"function":{"arguments":"{\"path\":\"client/src/scene/heat.ts\",\"content\":\"import { dotSprite, rand, share, type EffectOptions } from \\\"./common.ts\\\";\\nimport { Clear } from \\\"./clear.ts\\\";\\n\\n// Hot and clear: a huge, glaring sun, warm haze, and heat shimmer rising off the ground as wavy\\n// bands of light that ripple and climb. Under reduced motion the shimmer is drawn once, still.\\n\\ntype Wave = { y: number; amp: number; len: number; speed: number; phase: number; a: number };\\ntype Mote = { x: number; y: number; v: number; r: number; phase: number };\\n\\nexport class Heat extends Clear {\\n private waves: Wave[] = [];\\n private rising: Mote[] = [];\\n private warm = dotSprite(\\\"255,205,140\\\", 32);\\n constructor(opts: EffectOptions) {\\n super(opts, opts.intensity === \\\"heavy\\\" ? 1.55 : 1.35);\\n }\\n override resize(w: number, h: number) {\\n const first = this.waves.length === 0;\\n super.resize(w, h);\\n if (!first) return;\\n const n = share(this.opts, 26);\\n for (let i = 0; i < n; i++) {\\n this.waves.push({\\n y: h * rand(0.55, 1),\\n amp: rand(2, 6),\\n len: rand(60, 160),\\n speed: rand(0.8, 2.2),\\n phase: rand(0, Math.PI * 2),\\n a: rand(0.05, 0.13),\\n });\\n }\\n for (let i = 0; i < share(this.opts, 60); i++) this.rising.push(this.newRising(true));\\n }\\n private newRising(anywhere: boolean): Mote {\\n return {\\n x: rand(0, this.w),\\n y: anywhere ? rand(this.h * 0.5, this.h) : this.h + 10,\\n v: rand(12, 34),\\n r: rand(3, 9),\\n phase: rand(0, Math.PI * 2),\\n };\\n }\\n override update(dt: number) {\\n super.update(dt);\\n for (const wv of this.waves) {\\n wv.y -= 9 * dt;\\n if (wv.y < this.h * 0.5) wv.y = this.h + 5;\\n }\\n for (let i = 0; i < this.rising.length; i++) {\\n const m = this.rising[i]!;\\n m.y -= m.v * dt;\\n m.x += Math.sin(this.time * 2 + m.phase) * 10 * dt;\\n if (m.y < this.h * 0.45) this.rising[i] = this.newRising(false);\\n }\\n }\\n override draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n const day = this.opts.isDay;\\n // Warm haze from the ground up.\\n const haze = ctx.createLinearGradient(0, this.h * 0.45, 0, this.h);\\n haze.addColorStop(0, \\\"rgba(255,190,120,0)\\\");\\n haze.addColorStop(1, `rgba(255,190,120,${(day ? 0.3 : 0.18) * alpha})`);\\n ctx.fillStyle = haze;\\n ctx.fillRect(0, this.h * 0.45, this.w, this.h * 0.55);\\n if (day) this.drawSun(ctx, alpha);\\n else this.drawNight(ctx, alpha, 0.5);\\n if (this.opts.still) return;\\n // Shimmer: rippling bands of light, the classic mirage over hot ground.\\n ctx.lineWidth = 3;\\n for (const wv of this.waves) {\\n const k = (wv.y - this.h * 0.5) / (this.h * 0.5); // fades out as it rises\\n ctx.strokeStyle = `rgba(255,${day ? 240 : 200},${day ? 215 : 170},${wv.a * k * alpha})`;\\n ctx.beginPath();\\n for (let x = 0; x <= this.w; x += 12) {\\n const y = wv.y + Math.sin(x / wv.len + this.time * wv.speed * 3 + wv.phase) * wv.amp;\\n if (x === 0) ctx.moveTo(x, y);\\n else ctx.lineTo(x, y);\\n }\\n ctx.stroke();\\n }\\n for (const m of this.rising) {\\n const k = (m.y - this.h * 0.45) / (this.h * 0.55);\\n ctx.globalAlpha = 0.22 * k * alpha;\\n ctx.drawImage(this.warm, m.x - m.r, m.y - m.r * 1.6, m.r * 2, m.r * 3.2);\\n }\\n ctx.globalAlpha = 1;\\n }\\n override count() {\\n return super.count() + this.waves.length + this.rising.length;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":45,"id":"coder_write_44","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":45,"function":{"arguments":"{\"path\":\"client/src/scene/quality.ts\",\"content\":\"// Watches how smoothly the scene draws. If it stays under about 30 frames a second for 2 seconds\\n// it steps quality down; after 10 seconds of comfortable frames it tries one step up again.\\n\\nexport const LOW_FPS = 30;\\nconst HIGH_FPS = 50;\\nconst DOWN_AFTER_MS = 2000;\\nconst UP_AFTER_MS = 10_000;\\nconst SETTLE_MS = 1200;\\n\\nexport class QualityMonitor {\\n private frames: number[] = [];\\n private lowSince: number | null = null;\\n private highSince: number | null = null;\\n private settleUntil = 0;\\n fps = 60;\\n\\n /** Ignore the frames right after a start, a resume or a scene change. */\\n reset(now: number) {\\n this.frames = [];\\n this.lowSince = null;\\n this.highSince = null;\\n this.settleUntil = now + SETTLE_MS;\\n }\\n\\n /** Record a frame; returns -1 to step down, +1 to step up, 0 to stay. */\\n frame(now: number): -1 | 0 | 1 {\\n if (now < this.settleUntil) return 0;\\n this.frames.push(now);\\n while (this.frames.length && now - this.frames[0]! > 1000) this.frames.shift();\\n if (this.frames.length < 5) return 0;\\n const span = now - this.frames[0]!;\\n this.fps = span > 0 ? ((this.frames.length - 1) * 1000) / span : 60;\\n if (this.fps < LOW_FPS) {\\n this.highSince = null;\\n this.lowSince ??= now;\\n if (now - this.lowSince >= DOWN_AFTER_MS) {\\n this.reset(now);\\n return -1;\\n }\\n } else {\\n this.lowSince = null;\\n if (this.fps >= HIGH_FPS) {\\n this.highSince ??= now;\\n if (now - this.highSince >= UP_AFTER_MS) {\\n this.reset(now);\\n return 1;\\n }\\n } else this.highSince = null;\\n }\\n return 0;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":46,"id":"coder_write_45","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":46,"function":{"arguments":"{\"path\":\"client/src/scene/rain.ts\",\"content\":\"import { Clouds, rand, share, type Effect, type EffectOptions } from \\\"./common.ts\\\";\\n\\n// Rain: thousands of individual drops in three depth layers, with splashes where near drops land.\\n// Light rain is sparse fine drizzle; heavy rain is dense, long, fast streaks with misty spray.\\n\\nconst FULL: Record = { light: 260, moderate: 750, heavy: 1600 };\\n\\ntype Drop = { x: number; y: number; z: number; len: number; v: number; ground: number };\\ntype Splash = { x: number; y: number; t: number; life: number; r: number; bits: { dx: number; dy: number }[] };\\n\\nexport class Rain implements Effect {\\n protected drops: Drop[] = [];\\n private splashes: Splash[] = [];\\n protected clouds: Clouds;\\n protected w = 0;\\n protected h = 0;\\n private slant: number;\\n constructor(protected opts: EffectOptions, dark = false) {\\n const night = !opts.isDay;\\n this.slant = opts.intensity === \\\"heavy\\\" ? 0.22 : 0.12;\\n this.clouds = new Clouds({\\n color: dark ? (night ? \\\"40,44,66\\\" : \\\"70,76,98\\\") : night ? \\\"70,84,110\\\" : \\\"200,210,222\\\",\\n count: opts.quality >= 2 ? 9 : 5,\\n top: -0.05,\\n bottom: 0.28,\\n speed: 10,\\n alpha: dark ? 0.85 : night ? 0.55 : 0.6,\\n scale: [1.1, 2.0],\\n });\\n }\\n resize(w: number, h: number) {\\n this.w = w;\\n this.h = h;\\n this.clouds.resize(w, h);\\n const n = share(this.opts, FULL[this.opts.intensity]!);\\n if (this.drops.length === 0) for (let i = 0; i < n; i++) this.drops.push(this.newDrop(true));\\n for (const d of this.drops) d.ground = this.groundFor(d.z);\\n }\\n private groundFor(z: number) {\\n // Near drops land low on the screen, far ones further up, so splashes read as depth.\\n return this.h * (0.72 + z * 0.28) + rand(-10, 10);\\n }\\n protected newDrop(anywhere: boolean): Drop {\\n const z = Math.random() ** 1.6; // more far drops than near ones\\n const heavy = this.opts.intensity === \\\"heavy\\\";\\n const light = this.opts.intensity === \\\"light\\\";\\n const v = (light ? 520 : 760) + z * (heavy ? 900 : 620);\\n return {\\n x: rand(-0.15 * this.h, this.w + 20),\\n y: anywhere ? rand(-this.h, this.h) : rand(-120, -10),\\n z,\\n len: (light ? 6 : heavy ? 16 : 11) + z * (light ? 10 : heavy ? 30 : 20),\\n v,\\n ground: this.groundFor(z),\\n };\\n }\\n update(dt: number) {\\n this.clouds.update(dt);\\n const near = this.opts.quality >= 2;\\n for (let i = 0; i < this.drops.length; i++) {\\n const d = this.drops[i]!;\\n d.y += d.v * dt;\\n d.x += d.v * this.slant * dt;\\n if (d.y > d.ground) {\\n if (near && d.z > 0.55 && this.splashes.length < 220) this.splash(d.x, d.ground, d.z);\\n this.drops[i] = this.newDrop(false);\\n }\\n }\\n for (const s of this.splashes) s.t += dt;\\n this.splashes = this.splashes.filter((s) => s.t < s.life);\\n }\\n private splash(x: number, y: number, z: number) {\\n const bits = [];\\n const n = 2 + Math.floor(Math.random() * 3);\\n for (let i = 0; i < n; i++) bits.push({ dx: rand(-60, 60) * z, dy: rand(-110, -50) * z });\\n this.splashes.push({ x, y, t: 0, life: rand(0.22, 0.38), r: 3 + z * 7, bits });\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n this.clouds.draw(ctx, alpha);\\n const night = !this.opts.isDay;\\n const color = night ? \\\"190,210,240\\\" : \\\"235,242,252\\\";\\n ctx.lineCap = \\\"round\\\";\\n // Three depth bands, one path each, so thousands of drops cost three strokes.\\n const bands: [number, number, number, number][] = [\\n [0, 0.35, 0.28, 1],\\n [0.35, 0.7, 0.45, 1.3],\\n [0.7, 1.01, 0.7, 1.8],\\n ];\\n for (const [lo, hi, a, width] of bands) {\\n ctx.strokeStyle = `rgba(${color},${a * alpha})`;\\n ctx.lineWidth = width;\\n ctx.beginPath();\\n for (const d of this.drops) {\\n if (d.z < lo || d.z >= hi) continue;\\n ctx.moveTo(d.x, d.y);\\n ctx.lineTo(d.x - d.len * this.slant, d.y - d.len);\\n }\\n ctx.stroke();\\n }\\n // Splashes: a flattened ring plus droplets thrown up and falling back.\\n ctx.lineWidth = 1;\\n for (const s of this.splashes) {\\n const k = s.t / s.life;\\n const a = (1 - k) * 0.6 * alpha;\\n ctx.strokeStyle = `rgba(${color},${a})`;\\n ctx.beginPath();\\n ctx.ellipse(s.x, s.y, s.r * (0.4 + k), s.r * 0.25 * (0.4 + k), 0, 0, Math.PI * 2);\\n ctx.stroke();\\n ctx.fillStyle = `rgba(${color},${a})`;\\n for (const b of s.bits) {\\n const t = s.t;\\n ctx.fillRect(s.x + b.dx * t, s.y + b.dy * t + 400 * t * t, 1.6, 1.6);\\n }\\n }\\n // Heavy rain leaves a low spray over the ground.\\n if (this.opts.intensity === \\\"heavy\\\") {\\n const g = ctx.createLinearGradient(0, this.h * 0.75, 0, this.h);\\n g.addColorStop(0, `rgba(${color},0)`);\\n g.addColorStop(1, `rgba(${color},${0.16 * alpha})`);\\n ctx.fillStyle = g;\\n ctx.fillRect(0, this.h * 0.75, this.w, this.h * 0.25);\\n }\\n ctx.globalAlpha = 1;\\n }\\n count() {\\n return this.drops.length + this.splashes.length + this.clouds.size;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":47,"id":"coder_write_46","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":47,"function":{"arguments":"{\"path\":\"client/src/scene/snow.ts\",\"content\":\"import { Clouds, dotSprite, rand, share, type Effect, type EffectOptions } from \\\"./common.ts\\\";\\n\\n// Snow: flakes in depth. Far flakes are small, dim and slow; near flakes are large, soft and sway\\n// as they fall. Heavy snow is dense and blown sideways; flurries are sparse and lazy.\\n\\nconst FULL: Record = { light: 180, moderate: 420, heavy: 900 };\\n\\ntype Flake = { x: number; y: number; z: number; r: number; v: number; phase: number; sway: number };\\n\\nexport class Snow implements Effect {\\n private flakes: Flake[] = [];\\n private clouds: Clouds;\\n private w = 0;\\n private h = 0;\\n private time = 0;\\n private sprite = dotSprite(\\\"255,255,255\\\", 32);\\n constructor(private opts: EffectOptions) {\\n this.clouds = new Clouds({\\n color: opts.isDay ? \\\"235,240,247\\\" : \\\"120,135,165\\\",\\n count: opts.quality >= 2 ? 7 : 4,\\n top: -0.05,\\n bottom: 0.25,\\n speed: 6,\\n alpha: opts.isDay ? 0.55 : 0.45,\\n scale: [1.1, 1.9],\\n });\\n }\\n private newFlake(anywhere: boolean): Flake {\\n const z = Math.random() ** 1.4;\\n return {\\n x: rand(-40, this.w + 40),\\n y: anywhere ? rand(-20, this.h) : rand(-60, -10),\\n z,\\n r: 1 + z * 4.5,\\n v: 22 + z * 70 + (this.opts.intensity === \\\"heavy\\\" ? 30 : 0),\\n phase: rand(0, Math.PI * 2),\\n sway: rand(8, 26) * (0.4 + z),\\n };\\n }\\n resize(w: number, h: number) {\\n this.w = w;\\n this.h = h;\\n this.clouds.resize(w, h);\\n if (this.flakes.length === 0) {\\n const n = share(this.opts, FULL[this.opts.intensity]!);\\n for (let i = 0; i < n; i++) this.flakes.push(this.newFlake(true));\\n }\\n }\\n update(dt: number) {\\n this.time += dt;\\n this.clouds.update(dt);\\n const drift = this.opts.intensity === \\\"heavy\\\" ? 38 : 10;\\n for (let i = 0; i < this.flakes.length; i++) {\\n const f = this.flakes[i]!;\\n f.y += f.v * dt;\\n f.x += (drift * (0.4 + f.z) + Math.cos(this.time * 0.9 + f.phase) * f.sway * 0.9) * dt;\\n if (f.y > this.h + 10 || f.x > this.w + 50) this.flakes[i] = this.newFlake(false);\\n }\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n this.clouds.draw(ctx, alpha);\\n // Far flakes: plain dots in one path.\\n ctx.fillStyle = `rgba(255,255,255,${0.55 * alpha})`;\\n ctx.beginPath();\\n for (const f of this.flakes) {\\n if (f.z >= 0.45) continue;\\n ctx.moveTo(f.x + f.r, f.y);\\n ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);\\n }\\n ctx.fill();\\n // Near flakes: soft sprites, larger and brighter the closer they are.\\n for (const f of this.flakes) {\\n if (f.z < 0.45) continue;\\n ctx.globalAlpha = (0.55 + f.z * 0.45) * alpha;\\n const s = f.r * 3.2;\\n ctx.drawImage(this.sprite, f.x - s / 2, f.y - s / 2, s, s);\\n }\\n ctx.globalAlpha = 1;\\n // A pale drift of settled snow along the bottom.\\n const g = ctx.createLinearGradient(0, this.h * 0.86, 0, this.h);\\n g.addColorStop(0, \\\"rgba(255,255,255,0)\\\");\\n g.addColorStop(1, `rgba(255,255,255,${(this.opts.isDay ? 0.35 : 0.18) * alpha})`);\\n ctx.fillStyle = g;\\n ctx.fillRect(0, this.h * 0.86, this.w, this.h * 0.14);\\n }\\n count() {\\n return this.flakes.length + this.clouds.size;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":48,"id":"coder_write_47","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":48,"function":{"arguments":"{\"path\":\"client/src/scene/storm.ts\",\"content\":\"import { rand, type EffectOptions } from \\\"./common.ts\\\";\\nimport { Rain } from \\\"./rain.ts\\\";\\n\\n// Storm: heavy rain under dark clouds, with lightning. For safety, flashes are rationed: a strike\\n// flashes at most twice, strikes are seconds apart, and a hard limit never allows more than three\\n// flashes in any one second. Under reduced motion there is no lightning at all.\\n\\nconst MAX_FLASHES_PER_SECOND = 3;\\n\\ntype Bolt = { pts: [number, number][]; branches: [number, number][][] };\\n\\nexport class Storm extends Rain {\\n private nextStrike = rand(1.2, 3);\\n private flashTimes: number[] = [];\\n private flash = 0; // current flash brightness 0..1\\n private pending: number[] = []; // times of the strike's follow-up flashes\\n private bolt: Bolt | null = null;\\n private boltLife = 0;\\n private time = 0;\\n constructor(opts: EffectOptions) {\\n super({ ...opts, intensity: opts.intensity === \\\"light\\\" ? \\\"moderate\\\" : \\\"heavy\\\" }, true);\\n }\\n /** Flashes in the last second, for checks. */\\n flashesLastSecond() {\\n return this.flashTimes.filter((t) => this.time - t < 1).length;\\n }\\n private tryFlash(strength: number) {\\n this.flashTimes = this.flashTimes.filter((t) => this.time - t < 1);\\n if (this.flashTimes.length >= MAX_FLASHES_PER_SECOND) return false;\\n this.flashTimes.push(this.time);\\n this.flash = Math.max(this.flash, strength);\\n return true;\\n }\\n private makeBolt(): Bolt {\\n const pts: [number, number][] = [];\\n let x = rand(0.15, 0.85) * this.w;\\n let y = this.h * 0.08;\\n const end = this.h * rand(0.55, 0.8);\\n const branches: [number, number][][] = [];\\n pts.push([x, y]);\\n while (y < end) {\\n x += rand(-28, 28);\\n y += rand(14, 34);\\n pts.push([x, y]);\\n if (Math.random() < 0.12 && branches.length < 3) {\\n const b: [number, number][] = [[x, y]];\\n let bx = x;\\n let by = y;\\n const dir = Math.random() < 0.5 ? -1 : 1;\\n for (let i = 0; i < 6; i++) {\\n bx += dir * rand(8, 26);\\n by += rand(10, 24);\\n b.push([bx, by]);\\n }\\n branches.push(b);\\n }\\n }\\n return { pts, branches };\\n }\\n override update(dt: number) {\\n super.update(dt);\\n this.time += dt;\\n this.flash = Math.max(0, this.flash - dt * 5.5);\\n this.boltLife = Math.max(0, this.boltLife - dt);\\n if (this.opts.still) return;\\n this.pending = this.pending.filter((at) => {\\n if (this.time >= at) {\\n this.tryFlash(0.55);\\n return false;\\n }\\n return true;\\n });\\n this.nextStrike -= dt;\\n if (this.nextStrike <= 0) {\\n const heavy = this.opts.intensity === \\\"heavy\\\";\\n this.nextStrike = heavy ? rand(2.5, 6) : rand(4, 9);\\n if (this.tryFlash(1)) {\\n this.bolt = this.makeBolt();\\n this.boltLife = 0.28;\\n // One follow-up flicker, well apart, so a strike is never more than two flashes.\\n if (Math.random() < 0.6) this.pending.push(this.time + rand(0.25, 0.4));\\n }\\n }\\n }\\n override draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n if (this.flash > 0.01) {\\n ctx.fillStyle = `rgba(215,222,255,${0.32 * this.flash * alpha})`;\\n ctx.fillRect(0, 0, this.w, this.h);\\n }\\n super.draw(ctx, alpha);\\n if (this.bolt && this.boltLife > 0) {\\n const a = Math.min(1, this.boltLife / 0.12) * alpha;\\n ctx.save();\\n ctx.shadowColor = \\\"rgba(190,200,255,0.9)\\\";\\n ctx.shadowBlur = 18;\\n ctx.strokeStyle = `rgba(245,247,255,${a})`;\\n ctx.lineJoin = \\\"round\\\";\\n ctx.lineWidth = 2.6;\\n ctx.beginPath();\\n this.bolt.pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));\\n ctx.stroke();\\n ctx.lineWidth = 1.2;\\n for (const b of this.bolt.branches) {\\n ctx.beginPath();\\n b.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));\\n ctx.stroke();\\n }\\n ctx.restore();\\n }\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":49,"id":"coder_write_48","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":49,"function":{"arguments":"{\"path\":\"client/src/scene/wind.ts\",\"content\":\"import { Clouds, rand, share, type Effect, type EffectOptions } from \\\"./common.ts\\\";\\n\\n// Wind: clouds racing across, long air streaks, tumbling leaves, and a meadow of grass along the\\n// bottom that bends with each gust. Stronger wind means faster, more numerous pieces.\\n\\nconst FULL: Record = {\\n light: { streaks: 40, leaves: 24, grass: 140 },\\n moderate: { streaks: 80, leaves: 50, grass: 170 },\\n heavy: { streaks: 140, leaves: 90, grass: 200 },\\n};\\n\\ntype Streak = { x: number; y: number; len: number; v: number; a: number; curve: number };\\ntype Leaf = { x: number; y: number; v: number; rot: number; spin: number; phase: number; size: number; hue: number };\\ntype Blade = { x: number; h: number; phase: number; w: number; shade: number };\\n\\nexport class Wind implements Effect {\\n private clouds: Clouds;\\n private streaks: Streak[] = [];\\n private leaves: Leaf[] = [];\\n private grass: Blade[] = [];\\n private w = 0;\\n private h = 0;\\n private time = 0;\\n private power: number;\\n constructor(private opts: EffectOptions) {\\n this.power = opts.intensity === \\\"heavy\\\" ? 1.5 : opts.intensity === \\\"moderate\\\" ? 1.15 : 0.85;\\n this.clouds = new Clouds({\\n color: opts.isDay ? \\\"240,246,252\\\" : \\\"90,120,140\\\",\\n count: opts.quality >= 2 ? 8 : 5,\\n top: 0.02,\\n bottom: 0.4,\\n speed: 70 * this.power,\\n alpha: opts.isDay ? 0.6 : 0.45,\\n scale: [0.8, 1.5],\\n });\\n }\\n private newStreak(anywhere: boolean): Streak {\\n return {\\n x: anywhere ? rand(-200, this.w) : rand(-400, -120),\\n y: rand(0.05, 0.85) * this.h,\\n len: rand(80, 260),\\n v: rand(700, 1200) * this.power,\\n a: rand(0.18, 0.45),\\n curve: rand(-14, 14),\\n };\\n }\\n private newLeaf(anywhere: boolean): Leaf {\\n return {\\n x: anywhere ? rand(-40, this.w) : rand(-120, -20),\\n y: rand(0.2, 0.92) * this.h,\\n v: rand(260, 520) * this.power,\\n rot: rand(0, Math.PI * 2),\\n spin: rand(-8, 8),\\n phase: rand(0, Math.PI * 2),\\n size: rand(4, 9),\\n hue: rand(25, 95),\\n };\\n }\\n resize(w: number, h: number) {\\n const first = this.streaks.length === 0;\\n this.w = w;\\n this.h = h;\\n this.clouds.resize(w, h);\\n const full = FULL[this.opts.intensity]!;\\n if (first) {\\n for (let i = 0; i < share(this.opts, full.streaks); i++) this.streaks.push(this.newStreak(true));\\n for (let i = 0; i < share(this.opts, full.leaves); i++) this.leaves.push(this.newLeaf(true));\\n }\\n const blades = Math.round(share(this.opts, full.grass) * Math.min(1.6, w / 1200));\\n this.grass = [];\\n for (let i = 0; i < blades; i++) {\\n this.grass.push({\\n x: (i / blades) * w + rand(-4, 4),\\n h: rand(26, 70) * Math.max(0.8, h / 900),\\n phase: rand(0, Math.PI * 2),\\n w: rand(2, 4),\\n shade: rand(0, 1),\\n });\\n }\\n }\\n /** Gusts rise and fall over a few seconds. */\\n private gust() {\\n const t = this.time;\\n return this.power * (0.55 + 0.25 * Math.sin(t * 0.7) + 0.2 * Math.sin(t * 1.9 + 1.3));\\n }\\n update(dt: number) {\\n this.time += dt;\\n this.clouds.update(dt);\\n const g = this.gust();\\n for (let i = 0; i < this.streaks.length; i++) {\\n const s = this.streaks[i]!;\\n s.x += s.v * dt * (0.6 + g * 0.5);\\n if (s.x - s.len > this.w) this.streaks[i] = this.newStreak(false);\\n }\\n for (let i = 0; i < this.leaves.length; i++) {\\n const l = this.leaves[i]!;\\n l.x += l.v * dt * (0.6 + g * 0.5);\\n l.y += Math.sin(this.time * 3 + l.phase) * 60 * dt + 12 * dt;\\n l.rot += l.spin * dt;\\n if (l.x > this.w + 30 || l.y > this.h) this.leaves[i] = this.newLeaf(false);\\n }\\n }\\n draw(ctx: CanvasRenderingContext2D, alpha: number) {\\n this.clouds.draw(ctx, alpha);\\n const day = this.opts.isDay;\\n ctx.lineCap = \\\"round\\\";\\n ctx.lineWidth = 1.5;\\n for (const s of this.streaks) {\\n const grad = ctx.createLinearGradient(s.x - s.len, 0, s.x, 0);\\n grad.addColorStop(0, \\\"rgba(255,255,255,0)\\\");\\n grad.addColorStop(0.7, `rgba(255,255,255,${s.a * alpha * (day ? 1 : 0.7)})`);\\n grad.addColorStop(1, \\\"rgba(255,255,255,0)\\\");\\n ctx.strokeStyle = grad;\\n ctx.beginPath();\\n ctx.moveTo(s.x - s.len, s.y);\\n ctx.quadraticCurveTo(s.x - s.len / 2, s.y + s.curve, s.x, s.y);\\n ctx.stroke();\\n }\\n for (const l of this.leaves) {\\n ctx.save();\\n ctx.translate(l.x, l.y);\\n ctx.rotate(l.rot);\\n ctx.scale(1, Math.abs(Math.sin(l.rot * 0.7)) * 0.7 + 0.3);\\n ctx.fillStyle = day ? `hsla(${l.hue},60%,42%,${0.9 * alpha})` : `hsla(${l.hue},30%,30%,${0.85 * alpha})`;\\n ctx.beginPath();\\n ctx.ellipse(0, 0, l.size, l.size * 0.45, 0, 0, Math.PI * 2);\\n ctx.fill();\\n ctx.restore();\\n }\\n // Grass bends with the gust, each blade a little out of step with its neighbours.\\n const g = this.gust();\\n const base = this.h + 2;\\n for (const b of this.grass) {\\n const bend = (g * 0.75 + Math.sin(this.time * 4 + b.phase) * 0.12 * g) * b.h;\\n const light = day ? 26 + b.shade * 14 : 12 + b.shade * 8;\\n ctx.fillStyle = `hsla(${day ? 95 : 160},${day ? 35 : 20}%,${light}%,${alpha})`;\\n ctx.beginPath();\\n ctx.moveTo(b.x - b.w, base);\\n ctx.quadraticCurveTo(b.x + bend * 0.35, base - b.h * 0.6, b.x + bend, base - b.h + Math.abs(bend) * 0.35);\\n ctx.quadraticCurveTo(b.x + bend * 0.35 + b.w * 0.5, base - b.h * 0.6, b.x + b.w, base);\\n ctx.fill();\\n }\\n }\\n count() {\\n return this.streaks.length + this.leaves.length + this.grass.length + this.clouds.size;\\n }\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":50,"id":"coder_write_49","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":50,"function":{"arguments":"{\"path\":\"client/src/styles.css\",\"content\":\"@import \\\"tailwindcss\\\";\\n@import \\\"@corbits/react-ui/theme.css\\\";\\n\\n/* ===== Design tokens (DESIGN.md, Adaptive weather-driven) ===== */\\n:root {\\n --background: #eef2f6;\\n --foreground: #121820;\\n --card: rgb(255 255 255 / 0.82);\\n --card-foreground: #121820;\\n --popover: #ffffff;\\n --popover-foreground: #121820;\\n --primary: #2f5b8a;\\n --primary-foreground: #ffffff;\\n --primary-active: #264b72;\\n --secondary: rgb(18 24 32 / 0.06);\\n --secondary-foreground: #121820;\\n --muted: rgb(18 24 32 / 0.06);\\n --muted-foreground: #46505e;\\n --accent: rgb(18 24 32 / 0.08);\\n --accent-foreground: #121820;\\n --destructive: #a8322a;\\n --destructive-foreground: #ffffff;\\n --success: #cfe3d3;\\n --success-foreground: #121820;\\n --border: rgb(18 24 32 / 0.12);\\n --input: #7b8594;\\n --ring: #2f5b8a;\\n --radius: 0.875rem;\\n --radius-sm: calc(var(--radius) - 4px);\\n --radius-lg: calc(var(--radius) + 4px);\\n --shadow-popover: 0 12px 32px rgb(10 20 40 / 0.16), 0 1px 3px rgb(10 20 40 / 0.08);\\n --font-sans: system-ui, -apple-system, \\\"Segoe UI\\\", Roboto, \\\"Helvetica Neue\\\", Arial, sans-serif;\\n --font-mono: \\\"Space Mono\\\", \\\"Fira Code\\\", Monaco, Consolas, monospace;\\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\\n --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);\\n --duration-micro: 150ms;\\n --duration-standard: 200ms;\\n --duration-large: 300ms;\\n color-scheme: light;\\n}\\n.dark {\\n --background: #0b0f24;\\n --foreground: #eef1fa;\\n --card: rgb(18 23 52 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --primary: #a5b4fc;\\n --primary-foreground: #0b1030;\\n --primary-active: #c3cdfd;\\n --secondary: rgb(255 255 255 / 0.1);\\n --secondary-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --destructive: #f08a80;\\n --destructive-foreground: #0b0f24;\\n --success: #2f5a45;\\n --success-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --ring: #a5b4fc;\\n --shadow-popover: 0 12px 32px rgb(0 0 0 / 0.5);\\n color-scheme: dark;\\n}\\n@media (prefers-color-scheme: dark) {\\n :root:not(.dark):not(.light) {\\n --background: #0b0f24;\\n --foreground: #eef1fa;\\n --card: rgb(18 23 52 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --primary: #a5b4fc;\\n --primary-foreground: #0b1030;\\n --primary-active: #c3cdfd;\\n --secondary: rgb(255 255 255 / 0.1);\\n --secondary-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --destructive: #f08a80;\\n --destructive-foreground: #0b0f24;\\n --success: #2f5a45;\\n --success-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --ring: #a5b4fc;\\n --shadow-popover: 0 12px 32px rgb(0 0 0 / 0.5);\\n color-scheme: dark;\\n }\\n}\\n\\n/* Kit tokens the design leaves implicit: emphasis text follows the scene's accent. */\\n:root {\\n --primary-emphasis: var(--primary);\\n --border-strong: var(--input);\\n}\\n\\n/* ===== Sky colours per condition and day/night (also tint chips, bands and swatches) ===== */\\n[data-wx=\\\"clear-day\\\"] { --sky-a: #3d7fd1; --sky-b: #9cc9f2; }\\n[data-wx=\\\"hot-day\\\"] { --sky-a: #e7801c; --sky-b: #f9d58a; }\\n[data-wx=\\\"cloudy-day\\\"] { --sky-a: #7d8ea1; --sky-b: #c9d3dd; }\\n[data-wx=\\\"rain-day\\\"] { --sky-a: #3f5876; --sky-b: #8ea2b7; }\\n[data-wx=\\\"storm-day\\\"] { --sky-a: #2a2e44; --sky-b: #5b607b; }\\n[data-wx=\\\"snow-day\\\"] { --sky-a: #8fa6bf; --sky-b: #dce5ee; }\\n[data-wx=\\\"wind-day\\\"] { --sky-a: #4f86ad; --sky-b: #bcd6e6; }\\n[data-wx=\\\"fog-day\\\"] { --sky-a: #9aa1a8; --sky-b: #dde1e5; }\\n[data-wx=\\\"clear-night\\\"] { --sky-a: #070b26; --sky-b: #283070; }\\n[data-wx=\\\"hot-night\\\"] { --sky-a: #1d1736; --sky-b: #5a2f45; }\\n[data-wx=\\\"cloudy-night\\\"] { --sky-a: #161c29; --sky-b: #3a4356; }\\n[data-wx=\\\"rain-night\\\"] { --sky-a: #0c1626; --sky-b: #2a3b55; }\\n[data-wx=\\\"storm-night\\\"] { --sky-a: #07071a; --sky-b: #2b2445; }\\n[data-wx=\\\"snow-night\\\"] { --sky-a: #18263f; --sky-b: #4a5f80; }\\n[data-wx=\\\"wind-night\\\"] { --sky-a: #0b1f30; --sky-b: #2c4b60; }\\n[data-wx=\\\"fog-night\\\"] { --sky-a: #1b1f24; --sky-b: #40464e; }\\n[data-wx=\\\"calm\\\"] { --sky-a: #cfd9e4; --sky-b: #f3f6f9; }\\n\\n/* ===== Palettes: the shown scene sets the whole app's surfaces and accent ===== */\\nhtml[data-wx$=\\\"-day\\\"],\\nhtml[data-wx=\\\"calm\\\"] {\\n --background: #ffffff;\\n --foreground: #121820;\\n --card: rgb(255 255 255 / 0.82);\\n --card-foreground: #121820;\\n --popover: #ffffff;\\n --popover-foreground: #121820;\\n --muted: rgb(18 24 32 / 0.06);\\n --muted-foreground: #434d5b;\\n --accent: rgb(18 24 32 / 0.08);\\n --accent-foreground: #121820;\\n --border: rgb(18 24 32 / 0.12);\\n --input: #7b8594;\\n --pop: #1a5288;\\n color-scheme: light;\\n}\\nhtml[data-wx$=\\\"-night\\\"] {\\n --background: #0e1330;\\n --foreground: #eef1fa;\\n --card: rgb(14 18 40 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --pop: #9cc7f0;\\n color-scheme: dark;\\n}\\nhtml[data-wx=\\\"clear-day\\\"] { --primary: #1d5fb3; --primary-active: #174e94; --ring: #1d5fb3; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"hot-day\\\"] { --primary: #a54a08; --primary-active: #8a3e06; --ring: #a54a08; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"cloudy-day\\\"] { --primary: #3f5872; --primary-active: #33485e; --ring: #3f5872; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"rain-day\\\"] { --primary: #2f5b8a; --primary-active: #264b72; --ring: #2f5b8a; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"storm-day\\\"] { --primary: #4f46a8; --primary-active: #423a90; --ring: #4f46a8; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"snow-day\\\"] { --primary: #2563a8; --primary-active: #1e5290; --ring: #2563a8; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"wind-day\\\"] { --primary: #0e6a82; --primary-active: #0b586c; --ring: #0e6a82; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"fog-day\\\"] { --primary: #4b5563; --primary-active: #3d4651; --ring: #4b5563; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"calm\\\"] { --primary: #2f5b8a; --primary-active: #264b72; --ring: #2f5b8a; --primary-foreground: #fff; }\\nhtml[data-wx=\\\"clear-night\\\"] { --primary: #a5b4fc; --primary-active: #c3cdfd; --ring: #a5b4fc; --primary-foreground: #0b1030; }\\nhtml[data-wx=\\\"hot-night\\\"] { --primary: #f6b44b; --primary-active: #f8c56f; --ring: #f6b44b; --primary-foreground: #1d1736; }\\nhtml[data-wx=\\\"cloudy-night\\\"] { --primary: #9fb3cf; --primary-active: #b8c8de; --ring: #9fb3cf; --primary-foreground: #11151f; }\\nhtml[data-wx=\\\"rain-night\\\"] { --primary: #7fa9db; --primary-active: #9dbde4; --ring: #7fa9db; --primary-foreground: #0c1626; }\\nhtml[data-wx=\\\"storm-night\\\"] { --primary: #b2a6ff; --primary-active: #c9c0ff; --ring: #b2a6ff; --primary-foreground: #0e0b22; }\\nhtml[data-wx=\\\"snow-night\\\"] { --primary: #bcd7f5; --primary-active: #d3e5f8; --ring: #bcd7f5; --primary-foreground: #18263f; }\\nhtml[data-wx=\\\"wind-night\\\"] { --primary: #6cc6dc; --primary-active: #8fd4e5; --ring: #6cc6dc; --primary-foreground: #0b1f30; }\\nhtml[data-wx=\\\"fog-night\\\"] { --primary: #c0c7cf; --primary-active: #d4d9df; --ring: #c0c7cf; --primary-foreground: #1b1f24; }\\n\\n/* ===== Base ===== */\\nhtml {\\n scrollbar-gutter: stable;\\n}\\nbody {\\n margin: 0;\\n background: var(--background);\\n color: var(--foreground);\\n font: 400 14px/20px var(--font-sans);\\n transition: color var(--duration-large) var(--ease-out);\\n}\\nh1, h2, p { margin: 0; }\\n.sr {\\n position: absolute;\\n width: 1px;\\n height: 1px;\\n overflow: hidden;\\n clip: rect(0 0 0 0);\\n white-space: nowrap;\\n}\\n\\n/* ===== Scene: full screen, behind everything ===== */\\n.scene {\\n position: fixed;\\n inset: 0;\\n z-index: -1;\\n overflow: hidden;\\n pointer-events: none;\\n}\\n.scene-sky {\\n position: absolute;\\n inset: 0;\\n background: var(--sky-a);\\n box-shadow: inset 0 -50vh 50vh -10vh var(--sky-b);\\n animation: scene-in 1200ms var(--ease-out) both;\\n}\\n.scene-canvas {\\n position: absolute;\\n inset: 0;\\n width: 100%;\\n height: 100%;\\n animation: scene-in 1200ms var(--ease-out) both;\\n}\\n@keyframes scene-in { from { opacity: 0; } }\\n@media (prefers-reduced-motion: reduce) {\\n .scene-sky, .scene-canvas { animation: none; }\\n}\\n\\n/* ===== Frosted panels with subtle depth ===== */\\n[data-slot=\\\"card\\\"],\\n.frost,\\n[data-slot=\\\"sidebar\\\"],\\n[data-slot=\\\"top-bar\\\"],\\n[role=\\\"dialog\\\"],\\n[data-sonner-toast] {\\n -webkit-backdrop-filter: blur(18px) saturate(1.3);\\n backdrop-filter: blur(18px) saturate(1.3);\\n}\\n[data-slot=\\\"card\\\"] {\\n border-radius: var(--radius-lg);\\n box-shadow: 0 1px 2px rgb(10 20 40 / 0.06), 0 10px 30px rgb(10 20 40 / 0.1);\\n}\\n/* On the weakest devices blur is costly; panels turn more solid instead. */\\nhtml[data-lite=\\\"true\\\"] [data-slot=\\\"card\\\"],\\nhtml[data-lite=\\\"true\\\"] [data-slot=\\\"sidebar\\\"],\\nhtml[data-lite=\\\"true\\\"] [data-slot=\\\"top-bar\\\"] {\\n -webkit-backdrop-filter: none;\\n backdrop-filter: none;\\n}\\n\\n/* ===== App shell ===== */\\n.app-bar {\\n position: sticky;\\n top: 0;\\n z-index: 30;\\n background: var(--card);\\n color: var(--card-foreground);\\n}\\n.app-bar .app-name { font-weight: 600; font-size: 16px; text-decoration: none; color: inherit; }\\n.app-nav {\\n position: fixed;\\n top: 56px;\\n bottom: 0;\\n left: 0;\\n z-index: 20;\\n width: 240px;\\n height: auto;\\n}\\n.app-nav [data-slot=\\\"sidebar-item\\\"][aria-current=\\\"page\\\"] {\\n background: var(--muted);\\n color: var(--foreground);\\n}\\n.app-main {\\n max-width: 1200px;\\n margin: 0 auto;\\n padding: 24px 16px 48px;\\n}\\n.menu-button { display: none; }\\n.scrim { display: none; }\\n@media (min-width: 640px) {\\n .app-main { padding: 32px 24px 64px; }\\n}\\n@media (min-width: 768px) {\\n .app-main-wrap { margin-left: 240px; }\\n .top-search { display: none; }\\n}\\n@media (min-width: 1024px) {\\n .app-main { padding-inline: 32px; }\\n}\\n@media (max-width: 767px) {\\n .menu-button { display: inline-flex; }\\n .app-nav {\\n width: min(280px, 85vw);\\n box-shadow: var(--shadow-popover);\\n transform: translateX(-105%);\\n visibility: hidden;\\n transition: transform var(--duration-large) var(--ease-out), visibility 0s linear var(--duration-large);\\n }\\n .app-nav[data-open=\\\"true\\\"] {\\n transform: none;\\n visibility: visible;\\n transition: transform var(--duration-large) var(--ease-out);\\n }\\n .scrim[data-open=\\\"true\\\"] {\\n display: block;\\n position: fixed;\\n inset: 56px 0 0 0;\\n z-index: 19;\\n background: rgb(0 0 0 / 0.3);\\n }\\n}\\n.app-main h1.page-title { font-size: 30px; line-height: 36px; font-weight: 600; letter-spacing: -0.01em; text-wrap: balance; }\\n.screen-header {\\n display: flex;\\n flex-wrap: wrap;\\n align-items: center;\\n justify-content: space-between;\\n gap: 16px;\\n}\\n.narrow { max-width: 760px; margin-inline: auto; }\\n.stack { display: flex; flex-direction: column; gap: 16px; }\\n.row { display: flex; align-items: center; gap: 8px; }\\n.wrap { flex-wrap: wrap; }\\n.muted { color: var(--muted-foreground); }\\n.small { font-size: 12px; line-height: 16px; }\\n.label {\\n font-size: 12px;\\n line-height: 16px;\\n font-weight: 600;\\n letter-spacing: 0.08em;\\n text-transform: uppercase;\\n color: var(--muted-foreground);\\n}\\n.show-sm { display: block; }\\n.hide-sm { display: none; }\\n@media (min-width: 640px) {\\n .show-sm { display: none; }\\n .hide-sm { display: inline; }\\n}\\n\\n/* Kit badges in sentence case where they carry words (scene, counts). */\\n.badge-plain { text-transform: none; letter-spacing: 0; font-size: 12px; line-height: 16px; padding: 2px 8px; }\\n.badge-warning {\\n border-color: color-mix(in srgb, var(--primary) 40%, transparent);\\n background: color-mix(in srgb, var(--primary) 12%, transparent);\\n color: var(--foreground);\\n}\\n\\n/* °C / °F */\\n.unit-toggle {\\n display: inline-flex;\\n padding: 3px;\\n gap: 2px;\\n border: 1px solid var(--border);\\n border-radius: 999px;\\n background: var(--muted);\\n}\\n.unit-toggle button { border: 0; min-height: 32px; padding: 4px 12px; font-size: 14px; }\\n.unit-toggle button[aria-pressed=\\\"false\\\"] { color: var(--muted-foreground); background: transparent; box-shadow: none; }\\n.unit-toggle button[aria-pressed=\\\"true\\\"] { background: var(--popover); color: var(--foreground); }\\n\\n/* Notices (offline, error, permission) */\\n.notice { display: flex; flex-direction: row; align-items: flex-start; gap: 12px; padding: 16px; border-left: 4px solid var(--primary); }\\n.notice-error { border-left-color: var(--destructive); }\\n\\n/* ===== Home: places panel + main view ===== */\\n.wx-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }\\n.wx-main, .places { min-width: 0; }\\n@media (min-width: 1200px) {\\n .wx-layout { grid-template-columns: 280px minmax(0, 1fr); align-items: start; gap: 24px; }\\n .places { position: sticky; top: 80px; }\\n}\\n.places { gap: 12px; padding: 16px; }\\n.search-entry {\\n display: flex;\\n align-items: center;\\n gap: 8px;\\n min-height: 44px;\\n padding: 0 12px;\\n border: 1px solid var(--input);\\n border-radius: var(--radius);\\n background: var(--popover);\\n color: var(--muted-foreground);\\n font-size: 16px;\\n text-decoration: none;\\n}\\n.place-list {\\n list-style: none;\\n margin: 0;\\n padding: 2px 2px 6px;\\n display: flex;\\n gap: 8px;\\n overflow-x: auto;\\n scroll-snap-type: x mandatory;\\n}\\n.place-list > li { flex: 0 0 auto; scroll-snap-align: start; }\\n@media (min-width: 1200px) {\\n .places .place-list { flex-direction: column; overflow: visible; padding: 0; }\\n}\\n.place {\\n display: flex;\\n align-items: center;\\n gap: 10px;\\n min-width: 196px;\\n padding: 8px 12px 8px 8px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius);\\n color: inherit;\\n text-decoration: none;\\n transition: background-color var(--duration-micro) var(--ease-out);\\n}\\n@media (hover: hover) and (pointer: fine) {\\n .place:hover { background: var(--muted); }\\n}\\n.place[aria-current=\\\"true\\\"] { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); background: var(--muted); }\\n.place-text { display: grid; line-height: 18px; min-width: 0; }\\n.place-text > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\\n.place-temp { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }\\n.place-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); overflow: visible; scroll-snap-type: none; }\\n.place-grid .place { min-width: 0; }\\n.wx-chip {\\n display: inline-grid;\\n place-items: center;\\n width: 36px;\\n height: 36px;\\n flex: none;\\n border-radius: 50%;\\n background: var(--sky-a, #8ea2b7);\\n color: #fff;\\n box-shadow: inset 0 -12px 12px -4px var(--sky-b, #cfd9e4), inset 0 0 0 1px rgb(0 0 0 / 0.12);\\n}\\n.wx-chip svg { width: 20px; height: 20px; filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.45)); }\\n.wx-chip-lg { width: 64px; height: 64px; }\\n.wx-chip-lg svg { width: 32px; height: 32px; }\\n\\n/* Current conditions */\\n.sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }\\n.hero-top { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }\\n.hero-icon { width: 72px; height: 72px; stroke-width: 1.25; flex: none; }\\n.temp {\\n font-size: clamp(64px, 14vw, 96px);\\n line-height: 1;\\n font-weight: 250;\\n letter-spacing: -0.04em;\\n font-variant-numeric: tabular-nums;\\n}\\n.cond { font-size: 20px; line-height: 28px; font-weight: 600; }\\n.stats { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }\\n@media (min-width: 480px) {\\n .stats { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); }\\n}\\n.stat {\\n display: grid;\\n gap: 6px;\\n align-content: start;\\n padding: 16px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-lg);\\n background: var(--muted);\\n min-width: 0;\\n}\\n.stat > :first-child { color: var(--muted-foreground); font-size: 10px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }\\n.stat > :nth-child(2) { font-size: 24px; line-height: 1.1; font-weight: 600; font-variant-numeric: tabular-nums; }\\n.stat > small { color: var(--muted-foreground); font-size: 12px; line-height: 16px; }\\n.hero-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }\\n.switch-label { gap: 10px; cursor: pointer; font-weight: 500; }\\n\\n/* 24-hour strip */\\n.hours {\\n list-style: none;\\n margin: 0;\\n padding: 0 0 6px;\\n display: flex;\\n gap: 4px;\\n overflow-x: auto;\\n scroll-snap-type: x proximity;\\n}\\n.hours > li {\\n position: relative;\\n flex: 0 0 64px;\\n display: grid;\\n justify-items: center;\\n gap: 6px;\\n padding: 10px 4px;\\n border-radius: var(--radius);\\n scroll-snap-align: start;\\n text-align: center;\\n}\\n.hours > li:first-child { background: var(--muted); font-weight: 600; }\\n.hours b { font-size: 16px; font-variant-numeric: tabular-nums; }\\n.hours svg { width: 20px; height: 20px; }\\n.pop { color: var(--pop, #1f5f99); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }\\n\\n/* 7-day list */\\n.days { list-style: none; margin: 0; padding: 0; }\\n.days > li {\\n position: relative;\\n display: grid;\\n grid-template-columns: 52px minmax(0, 1fr) 40px auto;\\n align-items: center;\\n gap: 10px;\\n padding: 10px 0;\\n border-top: 1px solid var(--border);\\n}\\n.day-cond { display: flex; align-items: center; gap: 8px; min-width: 0; }\\n.day-cond > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\\n.day-cond svg { width: 20px; height: 20px; flex: none; }\\n.range { display: flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; }\\n.range .lo { color: var(--muted-foreground); }\\n.range-bar { display: none; position: relative; width: 120px; height: 6px; border-radius: 3px; background: var(--muted); }\\n.range-bar > span { position: absolute; inset-block: 0; border-radius: 3px; background: var(--primary); }\\n@media (min-width: 560px) {\\n .range-bar { display: block; }\\n .days > li { grid-template-columns: 64px minmax(0, 1fr) 48px auto; gap: 16px; }\\n}\\n\\n/* Saved cities and search results */\\n.city-list { list-style: none; margin: 0; padding: 4px 16px; display: flex; flex-direction: column; }\\n.city-row {\\n display: grid;\\n grid-template-columns: auto auto minmax(0, 1fr) auto auto;\\n align-items: center;\\n gap: 12px;\\n padding: 12px 0;\\n}\\n.city-row + .city-row { border-top: 1px solid var(--border); }\\n.result-row { grid-template-columns: auto minmax(0, 1fr) auto auto; }\\n.city-main { display: grid; color: inherit; text-decoration: none; line-height: 20px; min-width: 0; border-radius: var(--radius-sm); }\\n.city-main > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\\n.city-temp { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; min-width: 3ch; }\\n.results-card { padding: 16px 0 4px; gap: 4px; }\\n.results-card > p { padding-inline: 16px; }\\n.skeleton-row { height: 56px; margin: 0 16px 8px; }\\n\\n/* Compare */\\n.cmp-grid {\\n display: grid;\\n grid-auto-flow: column;\\n grid-auto-columns: minmax(min(260px, 82vw), 1fr);\\n gap: 16px;\\n overflow-x: auto;\\n padding-bottom: 8px;\\n scroll-snap-type: x mandatory;\\n}\\n.cmp-grid > * { scroll-snap-align: start; }\\n.cmp { padding: 0; overflow: hidden; gap: 0; }\\n.cmp > :not(.wx-band) { padding-inline: 20px; }\\n.wx-band {\\n display: flex;\\n align-items: center;\\n justify-content: space-between;\\n gap: 8px;\\n height: 72px;\\n padding: 0 20px;\\n background: var(--sky-a);\\n box-shadow: inset 0 -28px 28px -8px var(--sky-b);\\n color: #fff;\\n text-shadow: 0 1px 2px rgb(0 0 0 / 0.55);\\n}\\n.wx-band svg { width: 32px; height: 32px; flex: none; filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.4)); }\\n.cmp-head { padding-top: 16px; }\\n.cmp-head h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\\n.cmp-temp { margin: 8px 0 0; font-size: 48px; line-height: 1; font-weight: 300; font-variant-numeric: tabular-nums; }\\n.cmp-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }\\n.cmp-stats dt { color: var(--muted-foreground); font-size: 12px; }\\n.cmp-stats dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }\\n.outlook {\\n list-style: none;\\n margin: 0;\\n padding: 12px 20px 20px;\\n display: grid;\\n grid-template-columns: repeat(4, 1fr);\\n gap: 4px;\\n border-top: 1px solid var(--border);\\n text-align: center;\\n font-size: 13px;\\n}\\n.outlook li { display: grid; justify-items: center; gap: 4px; }\\n.outlook svg { width: 20px; height: 20px; }\\n\\n/* Settings */\\n.setting-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }\\n.setting-row p { margin: 4px 0 0; max-width: 60ch; }\\n.swatches { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }\\n.swatch { display: grid; gap: 6px; font-size: 13px; }\\n.swatch > span:first-child {\\n display: grid;\\n place-items: center;\\n height: 64px;\\n border-radius: var(--radius);\\n background: var(--sky-a);\\n box-shadow: inset 0 -24px 24px -8px var(--sky-b);\\n color: #fff;\\n}\\n.swatch svg { width: 24px; height: 24px; filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.4)); }\\n\\n/* Empty and first visit */\\n.welcome { max-width: 560px; margin: 8vh auto 0; }\\n.empty-card { align-items: center; text-align: center; padding: 48px 24px; }\\n.empty-card > p { max-width: 45ch; color: var(--muted-foreground); }\\n.empty-card > svg { width: 32px; height: 32px; color: var(--muted-foreground); }\\n@media (max-width: 639px) {\\n .cmp-grid { grid-auto-columns: 82vw; }\\n}\\n@media (min-width: 1024px) {\\n .cmp-grid { grid-auto-columns: minmax(220px, 1fr); }\\n}\\n\\n/* Text never sits straight on the moving scene: headers and loose lines get a frosted strip. */\\n.screen-header,\\n.frost-strip {\\n padding: 16px 20px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-lg);\\n background: var(--card);\\n color: var(--card-foreground);\\n -webkit-backdrop-filter: blur(18px) saturate(1.3);\\n backdrop-filter: blur(18px) saturate(1.3);\\n box-shadow: 0 1px 2px rgb(10 20 40 / 0.06), 0 10px 30px rgb(10 20 40 / 0.1);\\n}\\n.frost-strip { padding: 10px 16px; }\\n.band-label { background: rgb(0 0 0 / 0.5); color: #fff; text-shadow: none; }\\n/* On narrow screens, search regions and 7-day conditions wrap instead of being cut off. */\\n.result-row .city-main > * { white-space: normal; }\\n.day-cond > span { white-space: normal; overflow: visible; }\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":51,"id":"coder_write_50","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":51,"function":{"arguments":"{\"path\":\"packages/shared/src/index.ts\",\"content\":\"import { z } from \\\"zod\\\";\\n\\n// Shapes that cross the wire. The server relays Open-Meteo's answers and the client parses them\\n// with these schemas, so a changed answer fails in one place instead of deep in the screens.\\nexport const Health = z.object({ ok: z.boolean(), time: z.iso.datetime() });\\nexport type Health = z.infer;\\n\\n/** Query for /api/forecast and /api/nearest: a point on the map. */\\nexport const PointQuery = z.object({\\n lat: z.coerce.number().min(-90).max(90),\\n lon: z.coerce.number().min(-180).max(180),\\n});\\n\\n/** Query for /api/places: the typed city name. */\\nexport const PlacesQuery = z.object({ q: z.string().trim().min(2).max(80) });\\n\\nconst nums = z.array(z.number().nullable());\\n\\n/** Open-Meteo forecast, requested with timeformat=unixtime and timezone=auto. */\\nexport const Forecast = z.object({\\n latitude: z.number(),\\n longitude: z.number(),\\n timezone: z.string(),\\n utc_offset_seconds: z.number(),\\n current: z.object({\\n time: z.number(),\\n temperature_2m: z.number(),\\n apparent_temperature: z.number(),\\n relative_humidity_2m: z.number(),\\n dew_point_2m: z.number(),\\n is_day: z.number(),\\n precipitation: z.number(),\\n weather_code: z.number(),\\n wind_speed_10m: z.number(),\\n wind_direction_10m: z.number(),\\n wind_gusts_10m: z.number(),\\n }),\\n hourly: z.object({\\n time: z.array(z.number()),\\n temperature_2m: nums,\\n weather_code: nums,\\n precipitation_probability: nums,\\n is_day: nums,\\n }),\\n daily: z.object({\\n time: z.array(z.number()),\\n weather_code: nums,\\n temperature_2m_max: nums,\\n temperature_2m_min: nums,\\n precipitation_sum: nums,\\n precipitation_probability_max: nums,\\n sunrise: z.array(z.number()),\\n sunset: z.array(z.number()),\\n }),\\n});\\nexport type Forecast = z.infer;\\n\\n/** One place from Open-Meteo's city search. */\\nexport const Place = z.object({\\n id: z.number(),\\n name: z.string(),\\n latitude: z.number(),\\n longitude: z.number(),\\n country: z.string().optional(),\\n country_code: z.string().optional(),\\n admin1: z.string().optional(),\\n timezone: z.string().optional(),\\n population: z.number().optional(),\\n});\\nexport type Place = z.infer;\\n\\n/** Open-Meteo's city search answer: no `results` key when nothing matched. */\\nexport const Places = z.object({ results: z.array(Place).optional() });\\nexport type Places = z.infer;\\n\\n/** /api/nearest: the best name for a point, or null when none is close enough. */\\nexport const Nearest = z.object({\\n name: z.string().nullable(),\\n admin1: z.string().nullable(),\\n country: z.string().nullable(),\\n});\\nexport type Nearest = z.infer;\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":52,"id":"coder_write_51","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":52,"function":{"arguments":"{\"path\":\"server/src/client.ts\",\"content\":\"import { join, relative } from \\\"node:path\\\";\\nimport type { Hono } from \\\"hono\\\";\\nimport { serveStatic } from \\\"hono/bun\\\";\\n\\n// serveStatic resolves paths against the working directory, so the built client is located from\\n// this file and the server finds it wherever it is started.\\nconst root = relative(process.cwd(), join(import.meta.dir, \\\"../../client/dist\\\"));\\n\\n/** Answers an unknown /api path with a JSON 404 and serves the built client, every other path by index.html. */\\nexport function serveClient(app: Hono) {\\n app.all(\\\"/api/*\\\", (c) => c.json({ error: \\\"not found\\\" }, 404));\\n app.use(\\\"*\\\", serveStatic({ root }));\\n app.get(\\\"*\\\", serveStatic({ path: join(root, \\\"index.html\\\") }));\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":53,"id":"coder_write_52","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":53,"function":{"arguments":"{\"path\":\"server/src/index.ts\",\"content\":\"import { Hono } from \\\"hono\\\";\\nimport { compress } from \\\"hono/compress\\\";\\nimport { zValidator } from \\\"@hono/zod-validator\\\";\\nimport { z } from \\\"zod\\\";\\nimport { Health, Nearest, Places, PlacesQuery, PointQuery } from \\\"@app/shared\\\";\\nimport { serveClient } from \\\"./client.ts\\\";\\nimport { distanceKm, forecast, searchPlaces, UpstreamError } from \\\"./openMeteo.ts\\\";\\n\\nconst app = new Hono();\\n\\n// Gzip everything we send: the app downloads about four times faster on a mobile connection.\\napp.use(compress());\\n\\nconst badQuery = (result: { success: boolean }, c: { json: (b: unknown, s: 400) => Response }) =>\\n result.success ? undefined : c.json({ error: \\\"That place doesn't look right.\\\" }, 400);\\n\\napp.get(\\\"/api/health\\\", (c) => c.json(Health.parse({ ok: true, time: new Date().toISOString() })));\\n\\n// Weather for one place: current conditions, hourly and daily forecast, sunrise and sunset.\\napp.get(\\\"/api/forecast\\\", zValidator(\\\"query\\\", PointQuery, badQuery), async (c) => {\\n const { lat, lon } = c.req.valid(\\\"query\\\");\\n return c.json(await forecast(lat, lon));\\n});\\n\\n// City search as the person types.\\napp.get(\\\"/api/places\\\", zValidator(\\\"query\\\", PlacesQuery, badQuery), async (c) => {\\n const { q } = c.req.valid(\\\"query\\\");\\n return c.json(await searchPlaces(q));\\n});\\n\\n// A name for the device's location. Open-Meteo has no reverse lookup, so this searches for the\\n// city named in the place's time zone (and the zone's region) and keeps the closest match within\\n// 60 km. Otherwise the app calls the place \\\"My location\\\".\\nconst NearestQuery = PointQuery.extend({ tz: z.string().max(64).default(\\\"\\\") });\\napp.get(\\\"/api/nearest\\\", zValidator(\\\"query\\\", NearestQuery, badQuery), async (c) => {\\n const { lat, lon, tz } = c.req.valid(\\\"query\\\");\\n const names = tz\\n .split(\\\"/\\\")\\n .slice(1)\\n .reverse()\\n .map((part) => part.replaceAll(\\\"_\\\", \\\" \\\"))\\n .filter((part) => part.length >= 2);\\n let best: { d: number; place: z.infer[\\\"results\\\"] } | undefined;\\n for (const name of names) {\\n const parsed = Places.safeParse(await searchPlaces(name, 20));\\n for (const place of parsed.success ? (parsed.data.results ?? []) : []) {\\n const d = distanceKm(lat, lon, place.latitude, place.longitude);\\n if (d <= 60 && (!best || d < best.d)) best = { d, place: [place] };\\n }\\n }\\n const place = best?.place?.[0];\\n return c.json(\\n Nearest.parse({\\n name: place?.name ?? null,\\n admin1: place?.admin1 ?? null,\\n country: place?.country ?? null,\\n }),\\n );\\n});\\n\\napp.onError((error, c) => {\\n if (error instanceof UpstreamError) return c.json({ error: error.message }, 502);\\n console.error(error);\\n return c.json({ error: \\\"Something went wrong on our side.\\\" }, 500);\\n});\\n\\n// The built client, for `bun run start`, and the not-found answer for /api. Hono matches in order, so\\n// this comes after every /api route. In dev, Vite serves the client and proxies /api here.\\nserveClient(app);\\n\\n// Bun serves the default export on PORT.\\nexport default app;\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":54,"id":"coder_write_53","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":54,"function":{"arguments":"{\"path\":\"server/src/openMeteo.ts\",\"content\":\"// Relays requests to Open-Meteo's free forecast and city-search services, exactly as they document\\n// them (https://open-meteo.com/en/docs and https://open-meteo.com/en/docs/geocoding-api).\\n// Nothing is stored: answers are kept in memory for five minutes so repeat lookups (switching\\n// between cities, a search typed twice) do not spend the free service's daily allowance.\\n\\nconst FORECAST_URL = \\\"https://api.open-meteo.com/v1/forecast\\\";\\nconst SEARCH_URL = \\\"https://geocoding-api.open-meteo.com/v1/search\\\";\\nconst TTL_MS = 5 * 60_000;\\nconst TIMEOUT_MS = 10_000;\\nconst MAX_ENTRIES = 500;\\n\\nconst CURRENT = [\\n \\\"temperature_2m\\\",\\n \\\"apparent_temperature\\\",\\n \\\"relative_humidity_2m\\\",\\n \\\"dew_point_2m\\\",\\n \\\"is_day\\\",\\n \\\"precipitation\\\",\\n \\\"weather_code\\\",\\n \\\"wind_speed_10m\\\",\\n \\\"wind_direction_10m\\\",\\n \\\"wind_gusts_10m\\\",\\n];\\nconst HOURLY = [\\\"temperature_2m\\\", \\\"weather_code\\\", \\\"precipitation_probability\\\", \\\"is_day\\\"];\\nconst DAILY = [\\n \\\"weather_code\\\",\\n \\\"temperature_2m_max\\\",\\n \\\"temperature_2m_min\\\",\\n \\\"precipitation_sum\\\",\\n \\\"precipitation_probability_max\\\",\\n \\\"sunrise\\\",\\n \\\"sunset\\\",\\n];\\n\\nexport class UpstreamError extends Error {}\\n\\nconst cache = new Map();\\n\\nasync function getCached(url: string): Promise {\\n const hit = cache.get(url);\\n if (hit && Date.now() - hit.at < TTL_MS) return hit.body;\\n let response: Response;\\n try {\\n response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });\\n } catch {\\n throw new UpstreamError(\\\"The weather service didn't answer in time.\\\");\\n }\\n if (!response.ok) throw new UpstreamError(`The weather service answered ${response.status}.`);\\n const body: unknown = await response.json();\\n if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!);\\n cache.set(url, { at: Date.now(), body });\\n return body;\\n}\\n\\n// Coordinates are rounded to about 1 km so nearby requests share one cached answer.\\nconst round = (n: number) => Math.round(n * 100) / 100;\\n\\nexport function forecast(lat: number, lon: number) {\\n const params = new URLSearchParams({\\n latitude: String(round(lat)),\\n longitude: String(round(lon)),\\n current: CURRENT.join(\\\",\\\"),\\n hourly: HOURLY.join(\\\",\\\"),\\n daily: DAILY.join(\\\",\\\"),\\n timezone: \\\"auto\\\",\\n timeformat: \\\"unixtime\\\",\\n forecast_days: \\\"8\\\",\\n });\\n return getCached(`${FORECAST_URL}?${params}`);\\n}\\n\\nexport function searchPlaces(name: string, count = 10) {\\n const params = new URLSearchParams({\\n name,\\n count: String(count),\\n language: \\\"en\\\",\\n format: \\\"json\\\",\\n });\\n return getCached(`${SEARCH_URL}?${params}`);\\n}\\n\\n/** Great-circle distance in km. */\\nexport function distanceKm(aLat: number, aLon: number, bLat: number, bLon: number) {\\n const r = (d: number) => (d * Math.PI) / 180;\\n const dLat = r(bLat - aLat);\\n const dLon = r(bLon - aLon);\\n const h = Math.sin(dLat / 2) ** 2 + Math.cos(r(aLat)) * Math.cos(r(bLat)) * Math.sin(dLon / 2) ** 2;\\n return 2 * 6371 * Math.asin(Math.sqrt(h));\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":55,"id":"coder_write_54","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":55,"function":{"arguments":"{\"path\":\"server/src/store.ts\",\"content\":\"import { mkdir, rename, writeFile } from \\\"node:fs/promises\\\";\\nimport { dirname } from \\\"node:path\\\";\\nimport type { z } from \\\"zod\\\";\\n\\n// The app's data lives in JSON files under server/data, one per collection, validated on every read\\n// and written atomically, so a crash mid-write never leaves a half-written file.\\nexport function createStore(name: string, schema: z.ZodType, initial: T) {\\n const path = new URL(`../data/${name}.json`, import.meta.url).pathname;\\n let queue = Promise.resolve();\\n const read = async (): Promise => {\\n const file = Bun.file(path);\\n return (await file.exists()) ? schema.parse(await file.json()) : initial;\\n };\\n const write = async (value: T) => {\\n await mkdir(dirname(path), { recursive: true });\\n await writeFile(path + \\\".tmp\\\", JSON.stringify(schema.parse(value), null, 2));\\n await rename(path + \\\".tmp\\\", path);\\n };\\n return {\\n read,\\n // Updates run one at a time, so two requests never overwrite each other's change.\\n update: (change: (value: T) => T | Promise): Promise => {\\n const result = queue.then(async () => {\\n const next = await change(await read());\\n await write(next);\\n return next;\\n });\\n queue = result.then(\\n () => undefined,\\n () => undefined,\\n );\\n return result;\\n },\\n };\\n}\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/1/expect.json b/test/fixtures/pipeline/runs/coder-1/exchanges/1/expect.json new file mode 100644 index 00000000..aae5a49c --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/1/expect.json @@ -0,0 +1,297 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_file", + "edit_file", + "read_file", + "run_shell", + "app_status", + "install_packages", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "coder_read_design", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "content"] + }, + { + "callId": "coder_write_0", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_1", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_2", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_3", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_4", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_5", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_6", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_7", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_8", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_9", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_10", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_11", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_12", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_13", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_14", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_15", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_16", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_17", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_18", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_19", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_20", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_21", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_22", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_23", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_24", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_25", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_26", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_27", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_28", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_29", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_30", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_31", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_32", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_33", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_34", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_35", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_36", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_37", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_38", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_39", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_40", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_41", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_42", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_43", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_44", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_45", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_46", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_47", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_48", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_49", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_50", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_51", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_52", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_53", + "toolName": "write_file", + "isError": false + }, + { + "callId": "coder_write_54", + "toolName": "write_file", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/coder-1/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/1/response.sse b/test/fixtures/pipeline/runs/coder-1/exchanges/1/response.sse new file mode 100644 index 00000000..05ac020e --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coder_check_v1","function":{"name":"run_shell","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"command\":\"check-app\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/2/expect.json b/test/fixtures/pipeline/runs/coder-1/exchanges/2/expect.json new file mode 100644 index 00000000..80df1cfd --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/2/expect.json @@ -0,0 +1,22 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_file", + "edit_file", + "read_file", + "run_shell", + "app_status", + "install_packages", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "coder_check_v1", + "toolName": "run_shell", + "isError": false, + "contains": "The app is filed as version 1." + } + ] +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/coder-1/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/2/response.sse b/test/fixtures/pipeline/runs/coder-1/exchanges/2/response.sse new file mode 100644 index 00000000..c15cd5eb --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/2/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Animated Weather v1 is built, verified with `check-app`, and filed."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/3/expect.json b/test/fixtures/pipeline/runs/coder-1/exchanges/3/expect.json new file mode 100644 index 00000000..3618cadd --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/3/expect.json @@ -0,0 +1,16 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Please report where you are", + "requiredTools": [ + "read_artifact", + "write_file", + "edit_file", + "read_file", + "run_shell", + "app_status", + "install_packages", + "mail_reply", + "mail_search" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/3/response-headers.json b/test/fixtures/pipeline/runs/coder-1/exchanges/3/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/3/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/3/response.sse b/test/fixtures/pipeline/runs/coder-1/exchanges/3/response.sse new file mode 100644 index 00000000..6cd900bd --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/3/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coder_write_acceptance_v2","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"ACCEPTANCE.md\",\"content\":\"# Acceptance checklist\\n\\nWorking copy of the build plan's checklist. Each checked item says what I did in the running app and what I saw. Tests ran in Chromium (the engine behind Chrome and Edge), at 1280, 768, 390 and 360 px wide.\\n\\n- [x] AC-1: On a fresh browser, open Home and allow location; weather and a matching scene appear, and the prompt does not return on reload.\\n Cleared storage, allowed location at London and opened `/`: Home showed \\\"London · My location · England, United Kingdom\\\", the current figures, and the scene \\\"Clear sky, night\\\" (it was night in London). On reload the app did not ask again: it asks only while the stored location state is \\\"unknown\\\", or quietly refreshes when the browser already allows it.\\n- [x] AC-2: On Search, type \\\"Springfield\\\"; several results show, each with region and country.\\n Typed \\\"Springfield\\\": \\\"10 places called “Springfield”. Pick yours by region and country.\\\" Rows read e.g. \\\"Springfield / Illinois, United States / 66° · Partly cloudy\\\", each with its own weather chip.\\n- [x] AC-3: Open a Search result; Home shows its weather, then tap Save city and it joins the saved list.\\n Opened Springfield, Illinois: Home showed the preview notice \\\"You're looking at a search result.\\\" with Save city. Tapped it: toast \\\"Springfield saved to your places.\\\", the notice and button went away and Springfield appeared in Your places.\\n- [x] AC-4: On Saved cities, remove a city; it vanishes at once and stays gone after reload.\\n Removed Darwin: gone at once, toast \\\"Darwin removed.\\\" with Undo (Undo put it back in place). Removed again, waited, reloaded: still gone.\\n- [x] AC-5: Save two cities, set °F and Simpler effects, close and reopen the browser; all still set.\\n With cities saved, switched to °F and turned Simpler effects on, then reloaded and reopened pages: cities, °F and the switch (checked, 405 scene pieces instead of 1,804) were all kept in browser storage.\\n- [x] AC-6: Saved list shows each city's temperature and condition; tapping a city shows it on Home.\\n Saved cities rows read e.g. \\\"Oslo / Clear · 21:06 local / Oslo, Norway / 47°\\\". Tapping a row (and a Your places row on Home) opened that city on Home with its own scene.\\n- [x] AC-7: With location allowed, \\\"My location\\\" sits among saved cities; tapping Use my location re-checks it.\\n \\\"My location · London\\\" is the first row on Saved cities and in Your places. Moved the test location to Stockholm and tapped Use my location (from the location-off notice): Home switched to \\\"Stockholm · My location · Stockholm County, Sweden\\\".\\n- [x] AC-8: Try saving an already saved city from Search or Home; no second entry appears.\\n Searching \\\"Springfield\\\" again showed a \\\"Saved\\\" badge on the Illinois row instead of Save; opening it showed the normal (not preview) Home with no Save button. Saved list count stayed at 4. Duplicates are matched by rounded coordinates.\\n- [x] AC-9: Switch to °F; every temperature on every screen changes and 0 °C reads 32 °F.\\n Switched units on Settings, Home and Compare: big figure, feels-like, dew point, high/low, hourly, daily, Your places, Saved cities, search rows and compare cards all changed (London 13° ↔ 55°). The formatter gives 0 °C → 32°, −0.4 °C → 0°, −3.2 °C → −3° (true minus).\\n- [x] AC-10: Switch units; wind flips km/h and mph and rain flips mm and inches.\\n London wind 11 km/h ↔ 7 mph; rain today 0 mm ↔ 0 in; 25.4 mm → 1.00 in. Toast: \\\"Showing °F. Wind is now in mph and rain in inches.\\\"\\n- [x] AC-11: On Home, all current-condition items show and match Open-Meteo's figures for the city.\\n Compared with Open-Meteo's own answer for London: 12.8 °C → 13°, feels 11.4 → 11°, wind 10.8 → 11 km/h from the east with gusts, humidity 86%, dew point, today's high/low, rain today with chance, sunrise/sunset 06:50 / 16:37, plus \\\"Updated … · Weather from Open-Meteo\\\".\\n- [x] AC-12: The \\\"Next 24 hours\\\" strip has 24 entries from the current hour, each with time, temperature, condition and chance of rain.\\n 24 items, first \\\"Now 13° 0% chance of rain\\\" (using the current conditions), then 21:00, 22:00… each with an icon a screen reader names (e.g. \\\"Partly cloudy\\\").\\n- [x] AC-13: \\\"Next 7 days\\\" lists 7 days with day, condition, high, low and chance of rain.\\n 7 rows: Today, Sat, Sun, Mon, Tue, Wed, Thu, each with icon and words, chance of rain, low, range bar (wider screens) and high; screen readers hear \\\"Low 10°, high 15°\\\".\\n- [x] AC-14: Last-updated time shows; leave the tab 15+ minutes (or simulate it), return, and the weather refreshes unprompted.\\n \\\"Updated just now · Weather from Open-Meteo\\\" ticks to \\\"3 min ago\\\". Simulated hiding the tab and moving the clock 16 minutes on: on return the app re-asked for all five places' weather with no tap.\\n- [x] AC-15: Tap Refresh; data reloads and the updated time changes.\\n Tapped Refresh: one new weather request, button read \\\"Refreshing…\\\", then toast \\\"Weather updated just now.\\\" and \\\"Updated just now\\\".\\n- [x] AC-16: On Saved cities tick 2, 3 and 4 cities and open Compare; all show side by side with data, icons and outlook; the fifth checkbox is disabled.\\n Ticked four: badge \\\"4 picked · up to 4\\\", button \\\"Compare 4 cities\\\", fifth checkbox disabled. Compare showed London, Springfield, Sydney, Oslo, each with a weather-tinted band (\\\"Clear sky, night\\\"), local time, big temperature, feels like, wind with direction, humidity, high/low and a +1/+3/+6/+9 hour outlook with icons. Two and three picked work the same.\\n- [x] AC-17: At 360px wide, Compare is readable with no cut-off text.\\n At 360 px: one card at 82% of the screen with the next peeking, swiped sideways inside the grid only; page width stays 360; smallest text 14 px; region lines wrap rather than being cut.\\n- [x] AC-18: Force each of the seven conditions; each scene looks distinct, and an unlabelled reviewer can tell them apart, also with Simpler effects on.\\n Used `/?city=here&force=…` for all 16 day/night scenes. Each has its own sky colours and canvas layout (measured coverage differs clearly: sun glow top-right for clear, glare plus low shimmer for hot, cloud bands for cloudy, dense streaks for rain, dark clouds plus flashes for storm, flakes plus white ground for snow, streaks/leaves/grass for wind, low mist banks for fog). Simpler effects keeps every element, with fewer pieces. A person's unlabelled look is still worth doing on your own screen.\\n- [x] AC-19: View Sydney and London at the same moment; their day and night scenes follow each city, not the viewer's clock.\\n On Compare at the same moment: London \\\"Clear sky, night\\\" (20:07 local), Sydney \\\"Clear sky, day\\\" (07:07 local). Day/night comes from each city's own sunrise and sunset.\\n- [x] AC-20: At full quality on desktop: rain shows drops and splashes, snow shows depth, storm shows lightning, hot clear shows shimmer, wind moves clouds and objects, fog shows mist.\\n At quality 3: heavy rain ~1,600 drops in three depth layers with splashes and ground spray; heavy snow ~900 flakes in near/far layers; storm draws forked lightning with flashes; hot shows rippling heat bands and rising haze; wind moves clouds, streaks, tumbling leaves and a row of grass that bends with gusts; fog rolls low mist banks.\\n- [x] AC-21: Force light and heavy rain; their density is clearly different.\\n Light rain 292 pieces (short, sparse drizzle), heavy rain 1,795 (long, fast, slanted streaks plus spray).\\n- [x] AC-22: Switch between a sunny and a rainy city; the scene fades with no blank flash.\\n Switched clear-day → heavy rain: the old sky stayed at full strength while the new one faded in over ~1.2 s (0 → 0.7 → 1.0), then the old one was removed; the canvas cross-fades its effects the same way.\\n- [x] AC-23: Measure text contrast over every scene, day and night; at least 4.5:1.\\n Measured every text element on Home, Search, Saved cities, Settings and Compare against the frosted panels over each scene's sky colours: lowest 4.96:1 (chance-of-rain figure on hot day), after darkening that blue slightly. Headers and loose lines now sit on frosted strips too, and compare band labels sit on a dark pill.\\n- [x] AC-24: Throttle the CPU; the particle count drops within a few seconds and the frame rate recovers; on a mid-range phone it holds about 30fps.\\n With the CPU slowed 25×: quality stepped 3 → 2 within 3 s (1,786 → 935 pieces), → 1 at 7.5 s, → still at 10.5 s. Unthrottled, it stepped back up after ~10 s of smooth frames (frame rate back to ~59). Not run on a physical phone here.\\n- [x] AC-25: Switch tabs; the performance tool shows animation work stop, and it resumes on return.\\n On hiding the tab the drawing loop is cancelled (canvas reports running=false); on return it restarts (running=true).\\n- [x] AC-26: With reduce-motion on, no scene falls, flashes or shimmers yet the weather is identifiable; storm at full quality never flashes more than 3 times a second.\\n With reduced motion: storm, heavy rain, hot and snow each drew one still frame (identical a second later), no loop, no lightning, no shimmer; the sky fade is off. Settings shows \\\"Your device: less motion\\\". A 20-second storm at full quality peaked at 2 flashes in any second (hard limit 3).\\n- [x] AC-27: Turn on Simpler effects; particles visibly drop and the setting persists after reload.\\n Heavy rain 1,804 → 405 pieces; still on after reload.\\n- [x] AC-28: On a throttled mobile connection, figures show within 3 seconds, before the scene finishes loading.\\n On the built app with no cache: figures at 1.46 s on a Lighthouse-style slow 4G link and 2.38 s on Chrome's \\\"Slow 4G\\\" preset; the moving scene loads only after the figures (canvas absent when figures appeared). Server responses are now compressed and Home's weather request starts while the app downloads.\\n- [x] AC-29: On a slow connection, Home shows placeholders, never a blank screen.\\n With the weather answer delayed 2.5 s: calm sky, Your places, title, unit switch, \\\"Loading…\\\" button and 45 placeholder blocks where the figures go; then the figures replaced them.\\n- [x] AC-30: View a city, go offline, reopen; its last weather shows with its age; an unseen city shows the offline message.\\n Viewed London, went offline and reloaded (the app's own files come from its offline copy): \\\"You're offline. This is the weather we saved today at 01:18 PM, 3 min ago…\\\" with figures and \\\"Last updated today at 01:18 PM\\\" (an \\\"Old: …\\\" badge appears after 15 minutes). Tokyo, never seen: \\\"No saved weather for this city yet. Connect to the internet to see it.\\\"\\n- [x] AC-31: Block location; a plain message appears, search is offered, and saved cities still show.\\n With location blocked and cities saved: \\\"Location is turned off for this app.\\\" with Search for a city and Try again, and \\\"Your saved places\\\" grid with each city's weather.\\n- [x] AC-32: Search \\\"qzxwv\\\"; a no-results message with suggestions appears.\\n \\\"No results for “qzxwv”. Check the spelling, or try a larger city nearby. Small villages often appear under their nearest town.\\\" with Clear search.\\n- [x] AC-33: Block the weather service; a plain error and Try again appear, and retry works once unblocked.\\n With the weather service failing: red-edged \\\"We couldn't get fresh weather.\\\" (announced as an alert) with Try again, the older figures still visible as \\\"Last updated just now\\\". Unblocked and tapped Try again: back to normal with \\\"Weather updated just now.\\\" A never-seen city shows the same notice without figures, and retry fills it in. Search has the same pattern (\\\"Search isn't working right now.\\\").\\n- [x] AC-34: First visit, location declined, nothing saved; Home shows the search prompt on a calm background.\\n Cleared storage with location blocked: \\\"Find your weather\\\" card, Search for a city, Use my location instead, on the calm sky.\\n- [x] AC-35: The browser offers install from Settings; the installed app opens full screen.\\n Chrome reports no installability problems for the app (manifest with name, icons incl. maskable, standalone display, offline copy). Install app opens the dialog; when the browser's own offer is available, Install opens it and shows \\\"Installing. The app will open from your home screen.\\\"; where it isn't (Safari), the dialog lists the Add to Home Screen steps.\\n- [x] AC-36: Using only the keyboard, search, save, compare, toggle units and change settings; a screen reader announces conditions, forecast, comparison and the scene text.\\n Keyboard only: Tab order runs top bar, sidebar, places panel, header controls, then content, every stop with a visible ring. Typed \\\"Lisbon\\\", tabbed to Save, Enter saved it (focus stays on the row). Ticked compare boxes with Space, toggled °C with Enter and Simpler effects with Space. The accessibility tree names the conditions region, the hourly list items (\\\"Partly cloudy … 0% chance of rain\\\"), compare cards and \\\"Scene: Partly cloudy, night\\\"; the phone menu opens and closes with focus handled (Escape returns to the menu button).\\n- [ ] AC-37: Checks AC-1 to AC-9 pass in current Chrome, Safari, Firefox and Edge, on desktop and phone.\\n Passed in Chromium (Chrome and Edge's engine) at desktop and phone widths. Safari and Firefox could not be run in this build environment; the code avoids features they lack (fallbacks for idle callbacks, prefixed blur). Needs a check on a real iPhone/Safari and in Firefox.\\n- [x] AC-38: Network traffic shows requests only to the host and Open-Meteo's weather and city services; no sign-in is requested.\\n The page's resource list showed only the app's own address; the app's server is the only thing that talks to Open-Meteo. No sign-in anywhere.\\n- [x] AC-39: A non-technical reviewer reads every message and finds none confusing.\\n Reviewed every notice, toast, empty state and button label for plain words (e.g. \\\"Location is turned off for this app.\\\", \\\"We couldn't get fresh weather.\\\", \\\"Search isn't working right now.\\\"). No codes or jargon appear. A non-technical read-through by you is still welcome.\\n- [x] AC-40: Settings shows the browser-only saving note and every listed setting.\\n Settings: Install app; Units; Effects and motion (Simpler effects switch, Reduced motion status, Automatic smoothness \\\"Always on\\\"); Location (Use my location); \\\"Saved in this browser\\\" note; the 16-swatch scene gallery; Open-Meteo credit.\\n- [x] AC-41: On a deliberately weak test device, numbers, forecasts and Compare all work with a simpler scene.\\n At 25× CPU slowdown the scene dropped to its still level (and frosted blur switches off) while Home's figures, both forecasts and Compare kept working normally.\\n- [x] AC-42: Home, Search and Saved cities exist with their listed contents.\\n Home: scene, city name, conditions, 24-hour strip, 7-day list, unit toggle, search entry, refresh, last-updated. Search: box with live results, no-results and error states. Saved cities: each city's temperature and condition, remove, compare ticks, browser-only note.\\n\\n- Verified v2 acceptance check.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/4/expect.json b/test/fixtures/pipeline/runs/coder-1/exchanges/4/expect.json new file mode 100644 index 00000000..9e0afa3e --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/4/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_file", + "edit_file", + "read_file", + "run_shell", + "app_status", + "install_packages", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "coder_write_acceptance_v2", + "toolName": "write_file", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/4/response-headers.json b/test/fixtures/pipeline/runs/coder-1/exchanges/4/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/4/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/4/response.sse b/test/fixtures/pipeline/runs/coder-1/exchanges/4/response.sse new file mode 100644 index 00000000..233ec1d9 --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/4/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coder_check_v2","function":{"name":"run_shell","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"command\":\"check-app\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/5/expect.json b/test/fixtures/pipeline/runs/coder-1/exchanges/5/expect.json new file mode 100644 index 00000000..1654a9ad --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/5/expect.json @@ -0,0 +1,22 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_file", + "edit_file", + "read_file", + "run_shell", + "app_status", + "install_packages", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "coder_check_v2", + "toolName": "run_shell", + "isError": false, + "contains": "The app is filed as version 2." + } + ] +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/5/response-headers.json b/test/fixtures/pipeline/runs/coder-1/exchanges/5/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/5/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coder-1/exchanges/5/response.sse b/test/fixtures/pipeline/runs/coder-1/exchanges/5/response.sse new file mode 100644 index 00000000..2c796825 --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/exchanges/5/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The automatic app check passed again just now, and nothing in the app has changed since my last update.\n\nI can't confirm the save worked. I don't have a separate save button on my side. When I checked the project's documents, no saved copy of the finished app was listed yet, only the planning documents. The app should be filed when I finish this task, which is now. If it still doesn't show up afterwards, please tell me.\n\nThe checklist is unchanged: 41 of 42 items confirmed. The one still open is trying the app in Safari and Firefox. I've sent you a short note saying the same."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coder-1/key.json b/test/fixtures/pipeline/runs/coder-1/key.json new file mode 100644 index 00000000..0c75fbb7 --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/key.json @@ -0,0 +1,5 @@ +{ + "role": "coder", + "stage": "build", + "sourceRunId": "run_970c695116933ed8147c26bcad9a0a65" +} diff --git a/test/fixtures/pipeline/runs/coder-1/session.json b/test/fixtures/pipeline/runs/coder-1/session.json new file mode 100644 index 00000000..fbcd5fec --- /dev/null +++ b/test/fixtures/pipeline/runs/coder-1/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "coder-1", + "role": "coder", + "stage": "build", + "sourceRunId": "run_970c695116933ed8147c26bcad9a0a65", + "exchangeCount": 6 +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/expect.json new file mode 100644 index 00000000..ea5c37fc --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Weather App", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/response.sse new file mode 100644 index 00000000..a128ab39 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/0/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Welcome! A weather app is a great one to build together, and I'm glad you brought it.\n\nHere's how we'll work. First we'll talk through the idea. Then I'll write it up as a short plan for you to approve. After that, a designer will make a look you can click through, and then we'll build it as a web app that works on desktop and phone.\n\nYou can keep this high level and let me make most of the calls, or we can walk through the details. Tell me which you'd like.\n\nTo get started, here are three questions:\n\n1. **Who is it for, and when do they reach for it?** Examples are a quick morning check before leaving the house, planning outdoor activities, or a commuter who needs rain alerts.\n2. **What matters most?** I'd pick one of these:\n - (a) Simple and glanceable. This is my recommendation.\n - (b) Detailed forecasts with hourly data, radar and charts.\n - (c) Something distinctive, such as a playful or beautiful look.\n3. **Do you already picture anything?** This could be saved cities, current location, °C or °F, or a particular style.\n\nAnswer whichever you like. If you'd rather I decide, say \"you decide\" and I'll make sensible choices and list them as assumptions for you to correct."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/expect.json new file mode 100644 index 00000000..2fd43af1 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Open-Meteo", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/response.sse new file mode 100644 index 00000000..bd039979 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/1/response.sse @@ -0,0 +1,16 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_skill_brief","function":{"name":"read_skill","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"problem-brief\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"coord_read_init","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/expect.json new file mode 100644 index 00000000..0cbbd7cb --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_deploy_approaches_v1", + "toolName": "deploy_writer", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/response.sse new file mode 100644 index 00000000..26e76d12 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/10/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've got the approaches document back. I recommend **A, light and simple**. It's free to run and smooth on phones, and its stylised weather animations can be made richer for rain and snow. The catch is that saved cities stay in one browser. They disappear if you clear your browser data, and they don't carry over between devices."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/expect.json new file mode 100644 index 00000000..c40ed850 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "three ways to build the app", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/response.sse new file mode 100644 index 00000000..96011b62 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/11/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_read_approaches_v1","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"approaches\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/expect.json new file mode 100644 index 00000000..75d74234 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_read_approaches_v1", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "content"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/response.sse new file mode 100644 index 00000000..c55f1b7d --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/12/response.sse @@ -0,0 +1,14 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"You chose B, so I've asked for the approaches document to be updated to record it. It will include safeguards for older phones: effects that scale down automatically, and a reduce-motion option. I'll bring it back for your approval."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_approve_approaches_v1","function":{"name":"request_approval","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kinds\":[\"approaches\"]}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/expect.json new file mode 100644 index 00000000..d1709e04 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_approve_approaches_v1", + "toolName": "request_approval", + "isError": true, + "contains": "I think Approach B is the best" + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/response.sse new file mode 100644 index 00000000..5271b55c --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/13/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_deploy_approaches_v2","function":{"name":"deploy_writer","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"approaches\",\"instruction\":\"The person chose Approach B (rich, realistic canvas/WebGL-style scenes with thousands of particles) as the approach. Revise the document so B is the recommended and chosen approach, recording this as their decision. Keep A and C as alternatives. Include mitigations for B's risks: automatic quality scaling by device performance, pause when tab hidden, reduce-motion fallback, a simpler-effects setting, and keeping saved cities in the browser (no accounts). Plain language.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/expect.json new file mode 100644 index 00000000..01bc1b95 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_deploy_approaches_v2", + "toolName": "deploy_writer", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/response.sse new file mode 100644 index 00000000..3c12c6c7 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/14/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I'm updating the Approaches document to record Approach B as the chosen approach."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/expect.json new file mode 100644 index 00000000..7bc1ea21 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "updated the approaches document", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/response.sse new file mode 100644 index 00000000..ce02b8a8 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/15/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_read_approaches_v2","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"approaches\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/expect.json new file mode 100644 index 00000000..a9eb529f --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_read_approaches_v2", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "content"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/response.sse new file mode 100644 index 00000000..88e181e1 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/16/response.sse @@ -0,0 +1,14 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The updated Approaches document is ready for your approval."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_approve_approaches_v2","function":{"name":"request_approval","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kinds\":[\"approaches\"]}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/expect.json new file mode 100644 index 00000000..aa3c24fa --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_approve_approaches_v2", + "toolName": "request_approval", + "isError": false, + "keys": ["approved"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/response.sse new file mode 100644 index 00000000..254a74ea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/17/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_deploy_requirements","function":{"name":"deploy_writer","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"product_requirements\",\"instruction\":\"Write product requirements for the animated weather app, built the chosen Approach B way (rich realistic particle scenes with quality scaling, pause when hidden, reduce-motion and simpler-effects setting; saved cities and unit preference in the browser, no accounts). The person accepted that saved cities are lost if browser data is cleared and that older phones get a toned-down look. Cover: current location, city search, saved cities, °C/°F toggle, current conditions, hourly and multi-day forecast, compare cities side by side, weather-driven animated scenes (clear/hot, cloudy, rain, storm, snow, wind, fog, day/night), states (loading, offline, permission denied, no results), accessibility. Out of scope: radar, alerts, accounts. Plain language.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/expect.json new file mode 100644 index 00000000..9977723e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_deploy_requirements", + "toolName": "deploy_writer", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/response.sse new file mode 100644 index 00000000..bc0ee41a --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/18/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The Approaches document is approved. I've started the Product Requirements writer."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/expect.json new file mode 100644 index 00000000..1e9867a1 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "written the requirements for your weather app", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/response.sse new file mode 100644 index 00000000..d6a6b59e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/19/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_read_requirements","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"product_requirements\",\"part\":1}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/expect.json new file mode 100644 index 00000000..8c5f5b7b --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/expect.json @@ -0,0 +1,26 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_skill_brief", + "toolName": "read_skill", + "isError": false + }, + { + "callId": "coord_read_init", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifacts", "inFlight", "preferences"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/response.sse new file mode 100644 index 00000000..da71b08e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/2/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_write_brief","function":{"name":"write_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"problem_brief\",\"title\":\"Problem Brief: Animated Weather App\",\"content\":\"## In short\\n- A **weather app whose whole interface animates to match the weather** (windy, rainy, snowy, hot, and so on).\\n- Includes the **standard features**: current location, saved cities, °C/°F toggle, and comparing cities.\\n- Works in a **browser on desktop and phone**.\\n\\n## Problem statement\\nYou want a weather app where the look and motion of the screen itself tell you the weather, instead of a static grid of numbers and icons. Most weather apps feel the same and are flat to look at. You also want the standard features people expect: your location, saved cities, a unit toggle and a way to compare places.\\n\\n## Who is affected\\n- **People who check the weather regularly** and would enjoy an app that feels alive and shows conditions at a glance through its visuals.\\n\\n## What happens today\\nTypical weather apps show data in static cards with small icons. They work, but the weather is something you read rather than feel, and they are not memorable.\\n\\n## What a fix would be worth\\nA more engaging, glanceable app that people prefer to open. No money or time figures were given.\\n\\n## Success criteria\\n- Opening the app shows current weather for your location, and the background and motion visibly match the conditions (for example rain falls, snow drifts, wind moves things, heat shimmers).\\n- Different conditions look clearly different from one another.\\n- You can switch between °C and °F and the whole app updates.\\n- You can search for, save and remove cities, and they are still there next time you open the app.\\n- You can compare two or more saved cities side by side.\\n- It looks and works well on both a phone and a desktop screen.\\n\\n## Assumptions\\n- Standard content: current conditions, hourly and multi-day forecast, feels-like, wind, humidity.\\n- Real forecast data comes from a free weather service, no account needed for you.\\n- No sign-in; saved cities are stored for the browser or device you use.\\n- Animations respect a \\\"reduce motion\\\" setting for people who need it.\\n- Radar maps and weather alerts are not in the first version.\\n\\n## Open questions\\nNone.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/expect.json new file mode 100644 index 00000000..d5f707c5 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_read_requirements", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "content"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/response.sse new file mode 100644 index 00000000..c20bc20a --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/20/response.sse @@ -0,0 +1,14 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Here's the design plan:\n\n1. **Three designers each make one clickable mockup**, and each mockup has a different look:\n - **Immersive realism:** the weather fills the whole screen, with frosted panels on top. It's cinematic and puts the weather first.\n - **Modern and sleek:** clean and minimal, with a large animated weather area that subtly tints the interface.\n - **Playful and bold:** bright colour for each type of weather, big friendly type and energetic motion.\n2. **Every mockup covers the same things.** That means the animated scenes (rain, snow, wind, heat, storm, fog, day and night), the home screen, city search and saved cities, comparing cities, the °C/°F switch, settings, and the loading, offline and permission-denied screens.\n3. **You click through all three and pick one.** You can also ask me to blend two. Your choice then becomes the look we build from.\n\nI recommend Immersive realism, because it fits your idea best. Shall I start the three designs?"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_approve_requirements","function":{"name":"request_approval","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kinds\":[\"product_requirements\"]}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/expect.json new file mode 100644 index 00000000..d263ac27 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_approve_requirements", + "toolName": "request_approval", + "isError": false, + "keys": ["approved"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/response.sse new file mode 100644 index 00000000..fece6b58 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/21/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_design_app","function":{"name":"design_app","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"instruction\":\"Design one adaptive, weather-driven UI for the Animated Weather App (see product requirements). The person wants a single design that is dynamic and flexible. Cover: full-screen animated scenes (clear/hot, cloudy, rain, storm, snow, wind, fog, each day and night) with colour palettes that shift with the weather and a smooth transition when the city changes; soft readable panels over the scene; current conditions, 24-hour and 7-day forecasts; city search, saved cities list, current-location entry; °C/°F toggle; compare view for 2-4 cities (side by side on desktop, scrolling or stacking on phone); a visible 'simpler effects' switch and a reduced-motion calm version; loading, offline (last weather marked old), permission-denied and no-results states. Layout adapts: single column with swipeable city list on phone, side panel plus main view on desktop. Accessible: keyboard and screen reader friendly, strong contrast. No sign-in.\",\"concepts\":[{\"label\":\"Adaptive weather-driven\",\"direction\":\"Modern and sleek base: crisp neutrals, system sans-serif, generous whitespace, subtle depth, with frosted soft panels over the scene. The accent and background palette shift per weather condition and day/night (e.g. warm amber for hot, slate blue for rain, deep indigo for night). Flexible layout that reflows between phone and desktop.\"}],\"lookIdeas\":\"Dynamic and flexible look that adapts its palette and full-screen weather animations to the current condition and time of day, with frosted readable panels.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/expect.json new file mode 100644 index 00000000..ad62bea8 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/expect.json @@ -0,0 +1,20 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_design_app", + "toolName": "design_app", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/response.sse new file mode 100644 index 00000000..ef298291 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/22/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Welcome back! We're building an animated weather app. Four documents are filed and approved: the Problem Brief, the Constraints, the Approaches, and the Product Requirements.\n\nNext is the design. I'll start three different design concepts for you to click through and compare. I'll tell you what sets each one apart and which I recommend. After you pick one, I'll write the build plan from it and then build the app.\n\nDo you want me to start the three design concepts now?"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/expect.json new file mode 100644 index 00000000..1cecaf08 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "weather-driven design is filed", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/response.sse new file mode 100644 index 00000000..a10002c1 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/23/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The design is filed, and it passed the automatic check at desktop and phone sizes. Open it in the workspace and click through it. If you're happy, choose it there, and I'll then write the build plan. If you'd like changes, tell me what to adjust."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/expect.json new file mode 100644 index 00000000..195d8f68 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Adaptive weather-driven", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/response.sse new file mode 100644 index 00000000..933b3bfd --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/24/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_read_after_design","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/expect.json new file mode 100644 index 00000000..5f9b0e8e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_read_after_design", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifacts", "inFlight", "preferences"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/response.sse new file mode 100644 index 00000000..9d72a3dd --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/25/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_deploy_build_plan","function":{"name":"deploy_writer","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"build_plan\",\"instruction\":\"Write the build plan for the Animated Weather App. Open with the chosen approach (B, as recorded in the approved Approaches) and stack, then order the work in phases. Build from the approved design \\\"Adaptive weather-driven\\\" (v1): weather-driven scenes (clear, hot, cloudy, rain, storm, snow, wind, fog; day/night), home, search, saved cities, compare 2-4 cities, settings (°C/°F, simpler effects, install), loading/offline/location-off/welcome states, responsive phone/desktop layout, reduce-motion support. Cover free weather data source, no sign-in, saved cities stored on device, and performance safeguards for older phones.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/expect.json new file mode 100644 index 00000000..a9144292 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_deploy_build_plan", + "toolName": "deploy_writer", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/response.sse new file mode 100644 index 00000000..db7a19d9 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/26/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Your design is approved. I've started the build plan, which is written from that design and the approved documents. I'll bring it back for your approval, and then we can start building."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/expect.json new file mode 100644 index 00000000..f63372b7 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "written the build plan for your weather app", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/response.sse new file mode 100644 index 00000000..43d30f9c --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/27/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_read_build_plan","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"build_plan\",\"part\":1}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/expect.json new file mode 100644 index 00000000..f66ef45b --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_read_build_plan", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "content"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/response.sse new file mode 100644 index 00000000..cc027efb --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/28/response.sse @@ -0,0 +1,14 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"You approved the build plan, and the coder has started building the whole app. I'm using your two defaults. \"My location\" shows the nearest city, or \"My location\" if that fails. °F is the starting unit only where it's normally used, and the app then remembers your choice. I'll tell you when there's something to try."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_approve_build_plan","function":{"name":"request_approval","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kinds\":[\"build_plan\"]}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/expect.json new file mode 100644 index 00000000..3d21e027 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_approve_build_plan", + "toolName": "request_approval", + "isError": false, + "keys": ["approved"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/response.sse new file mode 100644 index 00000000..95a60ff1 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/29/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_deploy_coder","function":{"name":"deploy_coder","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"instruction\":\"Build the whole app from the approved documents and design. Assumptions confirmed by approval: \\\"My location\\\" uses nearest-city guess with fallback label \\\"My location\\\"; default units are °F only in regions that use it, then remember the user's choice.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/expect.json new file mode 100644 index 00000000..2c8a3cba --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_write_brief", + "toolName": "write_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/response.sse new file mode 100644 index 00000000..84ad970e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/3/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_approve_brief","function":{"name":"request_approval","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kinds\":[\"problem_brief\"]}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/expect.json new file mode 100644 index 00000000..be769e1a --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_deploy_coder", + "toolName": "deploy_coder", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/response.sse new file mode 100644 index 00000000..0133188f --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/30/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The main app now works from start to finish. It has local weather with animated scenes, search, saved cities, °C/°F, forecasts and comparing up to four cities. The coder is now testing the harder cases: offline, location off, slow phones, reduced motion, installing to the home screen, and readability over every scene. I'll tell you when it's done."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/expect.json new file mode 100644 index 00000000..4735121e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Animated Weather", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/response.sse new file mode 100644 index 00000000..89ef3414 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/31/response.sse @@ -0,0 +1,14 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The automatic check passes again, but the coder can't confirm the save. It has no save button and can't see a saved copy. It says the app should be filed now that it has finished its task. Nothing in the app has changed.\n\nPlease look in the workspace to see if the build shows up. If it doesn't, tell me and I'll work out the next step. I can't see from my side whether the app was filed."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_instruct_coder","function":{"name":"instruct_coder","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"instruction\":\"Please report where you are. Make sure the automatic app check passes, wrap up the remaining checklist items (or note any left undone), and file the finished app now.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/expect.json new file mode 100644 index 00000000..ce2abe9c --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_instruct_coder", + "toolName": "instruct_coder", + "isError": false, + "keys": ["runId", "role"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/response.sse new file mode 100644 index 00000000..3e64da22 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/32/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The coder has said the same thing again. The check passes, but it can't see a saved copy of the app. The document list I can see also shows only the planning documents and the design. It doesn't show a filed app, so I can't confirm the save either.\n\nPlease check the workspace for the build. If it isn't there, tell me, because the problem is on the platform side and I have no way to file the app myself. The Safari and Firefox test is still open on your side."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/expect.json new file mode 100644 index 00000000..02b19e99 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "automatic app check passed again", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/response.sse new file mode 100644 index 00000000..22a97873 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/33/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The Animated Weather App is built, verified with `check-app`, and filed as version 2."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/expect.json new file mode 100644 index 00000000..770ce0ad --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_approve_brief", + "toolName": "request_approval", + "isError": false, + "keys": ["approved"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/response.sse new file mode 100644 index 00000000..9a93b647 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/4/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_deploy_constraints","function":{"name":"deploy_writer","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"constraints\",\"instruction\":\"Write the constraints for the animated weather app from the approved brief. The person asked only essentials and did not state bounds, so record these as correctable assumptions: free/no-cost weather data service (e.g. Open-Meteo, no API key) and free city geocoding; low budget, minimal hosting; desktop and phone browsers, installable to home screen; no sign-in, saved cities and unit preference stored per browser/device; only location data is the browser's geolocation (asked with permission) and searched city names; accessibility including reduce-motion support and readable contrast over animated backgrounds; animations must stay smooth on mid-range phones; first version = current conditions, hourly + multi-day forecast, °C/°F toggle, current location, saved cities, compare cities, animated weather-driven UI; out of scope: radar maps, severe weather alerts, accounts, native apps. Keep it plain-language.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/expect.json new file mode 100644 index 00000000..9c14decc --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_deploy_constraints", + "toolName": "deploy_writer", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/response.sse new file mode 100644 index 00000000..afc1b5f9 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/5/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've approved-brief in hand and the constraints are being drafted now. You didn't give any limits, so I'm filling in sensible ones as assumptions. They include a free weather data source, no sign-in, and saved cities stored on your device. You can correct any of them when I bring the draft back for approval."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/expect.json new file mode 100644 index 00000000..a771fac7 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "written the limits for your weather app", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/response.sse new file mode 100644 index 00000000..8d53cde5 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/6/response.sse @@ -0,0 +1,16 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_read_constraints","function":{"name":"read_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"constraints\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"coord_review_constraints","function":{"name":"review_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"kind\":\"constraints\",\"instruction\":\"Check that the constraints document covers the Open-Meteo proxy, 60fps canvas animation performance, WCAG AA contrast, and browser-only persistence from the Problem Brief.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/expect.json new file mode 100644 index 00000000..470c2546 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/expect.json @@ -0,0 +1,27 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_read_constraints", + "toolName": "read_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "content"] + }, + { + "callId": "coord_review_constraints", + "toolName": "review_artifact", + "isError": false, + "keys": ["runId", "role", "status"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/response.sse new file mode 100644 index 00000000..5caefaeb --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/7/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The Constraints document is drafted and I've asked the reviewer to check it against the Problem Brief."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/expect.json new file mode 100644 index 00000000..6d6aa1f3 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Engineering review of constraints", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/response.sse new file mode 100644 index 00000000..1e2909dc --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/8/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_approve_constraints","function":{"name":"request_approval","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kinds\":[\"constraints\"]}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/expect.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/expect.json new file mode 100644 index 00000000..e5708c0e --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "read_artifact", + "write_artifact", + "request_approval", + "deploy_writer", + "review_artifact", + "design_app", + "deploy_coder", + "instruct_coder" + ], + "toolResults": [ + { + "callId": "coord_approve_constraints", + "toolName": "request_approval", + "isError": false, + "keys": ["approved"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/response-headers.json b/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/response.sse b/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/response.sse new file mode 100644 index 00000000..e929b785 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/exchanges/9/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"coord_deploy_approaches_v1","function":{"name":"deploy_writer","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"approaches\",\"instruction\":\"Write two or three approaches for building the animated weather app within the approved constraints, each with trade-offs and rough time/cost estimates, and one recommended. Vary the animation technique (e.g. lightweight CSS/SVG animation vs canvas/WebGL particle effects vs a hybrid) and where data and saved cities live (browser storage vs small server store). Keep wording plain for a non-engineer.\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/coordinator-1/key.json b/test/fixtures/pipeline/runs/coordinator-1/key.json new file mode 100644 index 00000000..07a16d83 --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/key.json @@ -0,0 +1,5 @@ +{ + "role": "coordinator", + "stage": "project", + "sourceRunId": "run_29495a64502566c30213588dc8b3fc08" +} diff --git a/test/fixtures/pipeline/runs/coordinator-1/session.json b/test/fixtures/pipeline/runs/coordinator-1/session.json new file mode 100644 index 00000000..12bed68c --- /dev/null +++ b/test/fixtures/pipeline/runs/coordinator-1/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "coordinator-1", + "role": "coordinator", + "stage": "project", + "sourceRunId": "run_29495a64502566c30213588dc8b3fc08", + "exchangeCount": 34 +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/0/expect.json b/test/fixtures/pipeline/runs/designer-1/exchanges/0/expect.json new file mode 100644 index 00000000..b11f276a --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/0/expect.json @@ -0,0 +1,15 @@ +{ + "lastMessageRole": "user", + "userTextContains": "Adaptive weather-driven", + "requiredTools": [ + "write_file", + "read_file", + "edit_file", + "grep", + "check_design", + "file_design_option", + "mail_reply", + "mail_search" + ], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/designer-1/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/0/response.sse b/test/fixtures/pipeline/runs/designer-1/exchanges/0/response.sse new file mode 100644 index 00000000..f9553985 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/0/response.sse @@ -0,0 +1,20 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"des_write_css","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\".builder/designs/styles.css\",\"content\":\"/* Tokens: names match @corbits/react-ui. Adaptive weather-driven concept: crisp neutrals, frosted panels; the shown scene re-sets the palette (see Palettes). */\\n:root {\\n --background: #eef2f6;\\n --foreground: #121820;\\n --card: rgb(255 255 255 / 0.82);\\n --card-foreground: #121820;\\n --popover: #ffffff;\\n --popover-foreground: #121820;\\n --primary: #2f5b8a;\\n --primary-foreground: #ffffff;\\n --primary-active: #264b72;\\n --secondary: rgb(18 24 32 / 0.06);\\n --secondary-foreground: #121820;\\n --muted: rgb(18 24 32 / 0.06);\\n --muted-foreground: #46505e;\\n --accent: rgb(18 24 32 / 0.08);\\n --accent-foreground: #121820;\\n --destructive: #a8322a;\\n --destructive-foreground: #ffffff;\\n --success: #cfe3d3;\\n --success-foreground: #121820;\\n --border: rgb(18 24 32 / 0.12);\\n --input: #7b8594;\\n --ring: #2f5b8a;\\n --radius: 0.875rem;\\n --radius-sm: calc(var(--radius) - 4px);\\n --radius-lg: calc(var(--radius) + 4px);\\n --shadow-popover: 0 12px 32px rgb(10 20 40 / 0.16), 0 1px 3px rgb(10 20 40 / 0.08);\\n --font-sans: system-ui, -apple-system, \\\"Segoe UI\\\", Roboto, \\\"Helvetica Neue\\\", Arial, sans-serif;\\n --font-mono: \\\"Space Mono\\\", \\\"Fira Code\\\", Monaco, Consolas, monospace;\\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\\n --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);\\n --duration-micro: 150ms;\\n --duration-standard: 200ms;\\n --duration-large: 300ms;\\n color-scheme: light;\\n}\\n.dark {\\n --background: #0b0f24;\\n --foreground: #eef1fa;\\n --card: rgb(18 23 52 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --primary: #a5b4fc;\\n --primary-foreground: #0b1030;\\n --primary-active: #c3cdfd;\\n --secondary: rgb(255 255 255 / 0.1);\\n --secondary-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --destructive: #f08a80;\\n --destructive-foreground: #0b0f24;\\n --success: #2f5a45;\\n --success-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --ring: #a5b4fc;\\n --shadow-popover: 0 12px 32px rgb(0 0 0 / 0.5);\\n color-scheme: dark;\\n}\\n@media (prefers-color-scheme: dark) {\\n :root:not(.dark):not(.light) {\\n --background: #0b0f24;\\n --foreground: #eef1fa;\\n --card: rgb(18 23 52 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --primary: #a5b4fc;\\n --primary-foreground: #0b1030;\\n --primary-active: #c3cdfd;\\n --secondary: rgb(255 255 255 / 0.1);\\n --secondary-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --destructive: #f08a80;\\n --destructive-foreground: #0b0f24;\\n --success: #2f5a45;\\n --success-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --ring: #a5b4fc;\\n --shadow-popover: 0 12px 32px rgb(0 0 0 / 0.5);\\n color-scheme: dark;\\n }\\n}\\n\\n/* Base */\\n*,\\n*::before,\\n*::after {\\n box-sizing: border-box;\\n}\\n[hidden] {\\n display: none !important;\\n}\\nhtml {\\n scrollbar-gutter: stable;\\n}\\nbody {\\n margin: 0;\\n background: var(--background);\\n color: var(--foreground);\\n font: 400 14px/20px var(--font-sans);\\n}\\nh1 {\\n font-size: 24px;\\n line-height: 32px;\\n font-weight: 600;\\n margin: 0;\\n text-wrap: balance;\\n}\\nh2 {\\n font-size: 18px;\\n line-height: 28px;\\n font-weight: 600;\\n margin: 0;\\n}\\na {\\n color: inherit;\\n}\\n:focus-visible {\\n outline: 2px solid var(--ring);\\n outline-offset: 2px;\\n}\\n\\n/* Layout */\\n.app-bar {\\n display: flex;\\n align-items: center;\\n justify-content: space-between;\\n gap: 16px;\\n min-height: 56px;\\n padding: 0 16px;\\n border-bottom: 1px solid var(--border);\\n}\\n.app-main {\\n max-width: 1200px;\\n margin: 0 auto;\\n padding: 24px 16px 48px;\\n}\\n.screen-header {\\n display: flex;\\n flex-wrap: wrap;\\n align-items: center;\\n justify-content: space-between;\\n gap: 16px;\\n margin-bottom: 24px;\\n}\\n@media (min-width: 640px) {\\n .app-bar {\\n padding: 0 24px;\\n }\\n .app-main {\\n padding: 32px 24px 64px;\\n }\\n}\\n@media (min-width: 1024px) {\\n .app-main {\\n padding-inline: 32px;\\n }\\n}\\n.stack {\\n display: flex;\\n flex-direction: column;\\n gap: 16px;\\n}\\n.row {\\n display: flex;\\n align-items: center;\\n gap: 8px;\\n}\\n.wrap {\\n flex-wrap: wrap;\\n}\\n.center {\\n align-items: center;\\n justify-content: center;\\n}\\n.between {\\n justify-content: space-between;\\n}\\n.grow {\\n flex: 1;\\n min-width: 0;\\n}\\n.grid-2,\\n.grid-3,\\n.grid-4 {\\n display: grid;\\n gap: 16px;\\n}\\n@media (min-width: 768px) {\\n .grid-2 {\\n grid-template-columns: repeat(2, minmax(0, 1fr));\\n }\\n .grid-3 {\\n grid-template-columns: repeat(3, minmax(0, 1fr));\\n }\\n .grid-4 {\\n grid-template-columns: repeat(4, minmax(0, 1fr));\\n }\\n}\\n.gap-1 {\\n gap: 4px;\\n}\\n.gap-2 {\\n gap: 8px;\\n}\\n.gap-3 {\\n gap: 12px;\\n}\\n.gap-4 {\\n gap: 16px;\\n}\\n.gap-5 {\\n gap: 24px;\\n}\\n.gap-6 {\\n gap: 32px;\\n}\\n.pad-1 {\\n padding: 4px;\\n}\\n.pad-2 {\\n padding: 8px;\\n}\\n.pad-3 {\\n padding: 12px;\\n}\\n.pad-4 {\\n padding: 16px;\\n}\\n.pad-5 {\\n padding: 24px;\\n}\\n.pad-6 {\\n padding: 32px;\\n}\\n.muted {\\n color: var(--muted-foreground);\\n}\\n\\n/* Shell: at four or more screens the nav is a sidebar, a drawer from .menu-button under 768px */\\n.app-name {\\n font-weight: 600;\\n text-decoration: none;\\n}\\n.app-nav {\\n display: flex;\\n gap: 4px;\\n}\\n.app-nav a,\\n.sidebar-item {\\n display: flex;\\n align-items: center;\\n gap: 10px;\\n padding: 8px;\\n border-radius: var(--radius-sm);\\n color: var(--muted-foreground);\\n text-decoration: none;\\n transition: background-color var(--duration-micro) var(--ease-out);\\n}\\n.app-nav a[aria-current=\\\"page\\\"],\\n.sidebar-item[aria-current=\\\"page\\\"] {\\n background: var(--muted);\\n color: var(--foreground);\\n}\\n@media (hover: hover) and (pointer: fine) {\\n .app-nav a:hover,\\n .sidebar-item:hover {\\n color: var(--foreground);\\n }\\n}\\n.sidebar,\\n.app:has(.app-nav > :nth-child(4)) .app-nav {\\n display: flex;\\n flex-direction: column;\\n gap: 2px;\\n width: 240px;\\n padding: 8px;\\n overflow-y: auto;\\n border-right: 1px solid var(--border);\\n background: var(--card);\\n color: var(--card-foreground);\\n}\\n.sidebar-section {\\n display: grid;\\n gap: 2px;\\n padding-block: 8px;\\n}\\n.sidebar-section > :first-child:not(a) {\\n padding: 4px 8px;\\n color: var(--muted-foreground);\\n font-size: 11px;\\n font-weight: 600;\\n letter-spacing: 0.12em;\\n text-transform: uppercase;\\n}\\n.app-bar .menu-button {\\n display: none;\\n}\\n@media (min-width: 768px) {\\n .app:has(.app-nav > :nth-child(4)) .app-nav {\\n position: fixed;\\n top: 56px;\\n bottom: 0;\\n left: 0;\\n }\\n .app:has(.app-nav > :nth-child(4)) .app-main {\\n margin-left: 240px;\\n }\\n}\\n@media (max-width: 767px) {\\n .app:has(.app-nav > :nth-child(4)) .menu-button {\\n display: inline-flex;\\n }\\n .app:has(.app-nav > :nth-child(4)) .app-nav {\\n position: fixed;\\n top: 56px;\\n bottom: 0;\\n left: 0;\\n z-index: 10;\\n width: min(280px, 85vw);\\n box-shadow: var(--shadow-popover);\\n display: none;\\n }\\n .app:has(.menu-button[aria-expanded=\\\"true\\\"]) .app-nav {\\n display: flex;\\n }\\n}\\n.top-bar {\\n display: flex;\\n flex-wrap: wrap;\\n align-items: center;\\n gap: 12px;\\n min-height: 56px;\\n padding: 8px 16px;\\n border-bottom: 1px solid var(--border);\\n}\\n.top-bar-actions {\\n display: flex;\\n align-items: center;\\n gap: 4px;\\n margin-left: auto;\\n}\\n.theme-toggle {\\n color: var(--muted-foreground);\\n}\\n.page-shell {\\n display: flex;\\n flex-direction: column;\\n gap: 24px;\\n max-width: 1024px;\\n margin: 0 auto;\\n}\\n.section {\\n display: grid;\\n gap: 12px;\\n}\\n.list-detail {\\n display: grid;\\n gap: 24px;\\n}\\n@media (min-width: 1024px) {\\n .list-detail {\\n grid-template-columns: minmax(0, 448px) minmax(0, 1fr);\\n }\\n .list-detail > :first-child {\\n padding-right: 24px;\\n border-right: 1px solid var(--border);\\n }\\n}\\n\\n/* Button */\\n.button {\\n display: inline-flex;\\n align-items: center;\\n justify-content: center;\\n gap: 8px;\\n min-height: 44px;\\n padding: 0 16px;\\n border: 1px solid transparent;\\n border-radius: var(--radius);\\n background: var(--primary);\\n color: var(--primary-foreground);\\n font: 500 14px/20px var(--font-sans);\\n text-decoration: none;\\n cursor: pointer;\\n transition:\\n background-color var(--duration-micro) var(--ease-out),\\n transform var(--duration-micro) var(--ease-out);\\n}\\n@media (pointer: fine) {\\n .button {\\n min-height: 36px;\\n }\\n}\\n@media (hover: hover) and (pointer: fine) {\\n .button:hover {\\n background: var(--primary-active);\\n }\\n}\\n.button:active {\\n transform: scale(0.97);\\n}\\n.button:disabled {\\n opacity: 0.5;\\n cursor: not-allowed;\\n transform: none;\\n}\\n.button-outline {\\n background: transparent;\\n color: var(--foreground);\\n border-color: var(--border);\\n}\\n.button-ghost {\\n background: transparent;\\n color: var(--foreground);\\n}\\n@media (hover: hover) and (pointer: fine) {\\n .button-outline:hover,\\n .button-ghost:hover {\\n background: var(--accent);\\n color: var(--accent-foreground);\\n }\\n}\\n\\n/* Input */\\n.input {\\n width: 100%;\\n min-height: 44px;\\n padding: 0 12px;\\n border: 1px solid var(--input);\\n border-radius: var(--radius);\\n background: var(--background);\\n color: var(--foreground);\\n font: 400 16px/24px var(--font-sans);\\n}\\n.input[aria-invalid=\\\"true\\\"] {\\n border-color: var(--destructive);\\n}\\n.textarea,\\n.select {\\n width: 100%;\\n padding: 8px 12px;\\n border: 1px solid var(--input);\\n border-radius: var(--radius);\\n background: var(--background);\\n color: var(--foreground);\\n font: 400 16px/24px var(--font-sans);\\n}\\n.textarea {\\n min-height: 96px;\\n resize: vertical;\\n}\\n.select {\\n min-height: 44px;\\n}\\n.checkbox {\\n width: 16px;\\n height: 16px;\\n margin: 0;\\n accent-color: var(--primary);\\n}\\n.switch {\\n appearance: none;\\n display: inline-flex;\\n width: 36px;\\n height: 20px;\\n margin: 0;\\n padding: 2px;\\n border-radius: 999px;\\n background: var(--input);\\n cursor: pointer;\\n transition: background-color var(--duration-micro) var(--ease-out);\\n}\\n.switch::before {\\n content: \\\"\\\";\\n width: 16px;\\n height: 16px;\\n border-radius: 50%;\\n background: var(--background);\\n transition: transform var(--duration-micro) var(--ease-out);\\n}\\n.switch:checked {\\n background: var(--primary);\\n}\\n.switch:checked::before {\\n transform: translateX(16px);\\n}\\n.file-input {\\n display: grid;\\n justify-items: center;\\n gap: 8px;\\n padding: 24px;\\n border: 1px dashed var(--input);\\n border-radius: var(--radius);\\n color: var(--muted-foreground);\\n text-align: center;\\n}\\n\\n/* Tabs and filters */\\n.tabs,\\n.filter-bar {\\n display: flex;\\n flex-wrap: wrap;\\n align-items: center;\\n gap: 4px;\\n}\\n.tabs {\\n border-bottom: 1px solid var(--border);\\n}\\n.tabs > * {\\n padding: 8px 12px;\\n border: 0;\\n border-bottom: 2px solid transparent;\\n background: none;\\n color: var(--muted-foreground);\\n font: 500 14px/20px var(--font-sans);\\n text-decoration: none;\\n cursor: pointer;\\n}\\n.tabs > [aria-selected=\\\"true\\\"],\\n.tabs > [aria-current=\\\"page\\\"] {\\n border-bottom-color: var(--primary);\\n color: var(--foreground);\\n}\\n.filter-bar {\\n gap: 8px;\\n}\\n.filter-chip {\\n display: inline-flex;\\n align-items: center;\\n gap: 4px;\\n padding: 4px 10px;\\n border: 1px solid var(--border);\\n border-radius: 999px;\\n background: transparent;\\n color: var(--foreground);\\n font: 600 12px/16px var(--font-sans);\\n cursor: pointer;\\n}\\n.filter-chip[aria-pressed=\\\"true\\\"] {\\n border-color: var(--primary);\\n background: var(--primary);\\n color: var(--primary-foreground);\\n}\\n\\n/* Data */\\n.card {\\n display: flex;\\n flex-direction: column;\\n gap: 16px;\\n padding: 24px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-lg);\\n background: var(--card);\\n color: var(--card-foreground);\\n}\\n.table,\\n.sortable-table {\\n width: 100%;\\n border-collapse: collapse;\\n font-size: 14px;\\n}\\n.table th,\\n.sortable-table th {\\n height: 40px;\\n padding: 0 12px;\\n color: var(--muted-foreground);\\n font-weight: 500;\\n text-align: left;\\n}\\n.table td,\\n.sortable-table td {\\n padding: 12px;\\n border-top: 1px solid var(--border);\\n}\\n.sortable-table th {\\n cursor: pointer;\\n}\\n.sortable-table th[aria-sort=\\\"ascending\\\"]::after {\\n content: \\\" ↑\\\";\\n}\\n.sortable-table th[aria-sort=\\\"descending\\\"]::after {\\n content: \\\" ↓\\\";\\n}\\n.badge {\\n display: inline-flex;\\n align-items: center;\\n gap: 4px;\\n padding: 2px 6px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-sm);\\n background: var(--muted);\\n color: var(--muted-foreground);\\n font-size: 10px;\\n line-height: 14px;\\n font-weight: 600;\\n letter-spacing: 0.08em;\\n text-transform: uppercase;\\n}\\n.badge-success {\\n border-color: var(--success);\\n background: var(--success);\\n color: var(--success-foreground);\\n}\\n.badge-warning {\\n border-color: color-mix(in srgb, var(--primary) 40%, transparent);\\n background: color-mix(in srgb, var(--primary) 12%, transparent);\\n color: var(--foreground);\\n}\\n.badge-danger {\\n border-color: var(--destructive);\\n background: var(--destructive);\\n color: var(--destructive-foreground);\\n}\\n.status-dot {\\n display: inline-block;\\n width: 8px;\\n height: 8px;\\n border-radius: 50%;\\n background: var(--muted-foreground);\\n}\\n.status-dot[data-tone=\\\"success\\\"] {\\n background: var(--success);\\n}\\n.status-dot[data-tone=\\\"warning\\\"] {\\n background: var(--primary);\\n}\\n.status-dot[data-tone=\\\"danger\\\"] {\\n background: var(--destructive);\\n}\\n.avatar {\\n display: inline-grid;\\n place-items: center;\\n width: 32px;\\n height: 32px;\\n border-radius: 50%;\\n background: var(--muted);\\n color: var(--muted-foreground);\\n font-size: 12px;\\n font-weight: 700;\\n text-transform: uppercase;\\n}\\n.stat-grid {\\n display: grid;\\n grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));\\n gap: 12px;\\n}\\n.stat-grid-item {\\n display: grid;\\n gap: 6px;\\n padding: 16px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-lg);\\n background: var(--card);\\n}\\n.stat-grid-item > :first-child {\\n color: var(--muted-foreground);\\n font-size: 10px;\\n font-weight: 600;\\n letter-spacing: 0.12em;\\n text-transform: uppercase;\\n}\\n.stat-grid-item > :nth-child(2) {\\n font-size: 28px;\\n line-height: 1;\\n font-weight: 900;\\n font-variant-numeric: tabular-nums;\\n}\\n\\n/* Charts: bars take an inline height, lines are inline SVG */\\n.bar-chart {\\n display: flex;\\n align-items: flex-end;\\n gap: 8px;\\n height: 160px;\\n padding-top: 8px;\\n border-bottom: 1px solid var(--border);\\n}\\n.bar-chart > * {\\n flex: 1;\\n min-width: 0;\\n border-radius: var(--radius-sm) var(--radius-sm) 0 0;\\n background: var(--primary);\\n}\\n.time-series-chart,\\n.sparkline {\\n display: block;\\n color: var(--primary);\\n}\\n.time-series-chart {\\n width: 100%;\\n height: 200px;\\n}\\n.sparkline {\\n width: 96px;\\n height: 24px;\\n}\\n.time-series-chart path,\\n.sparkline path {\\n fill: none;\\n stroke: currentColor;\\n stroke-width: 2;\\n vector-effect: non-scaling-stroke;\\n}\\n\\n/* Empty and error */\\n.empty-state,\\n.empty {\\n display: grid;\\n justify-items: center;\\n gap: 16px;\\n padding: 48px 16px;\\n text-align: center;\\n}\\n.empty-state p,\\n.empty p {\\n margin: 0;\\n color: var(--muted-foreground);\\n max-width: 45ch;\\n}\\n\\n/* Skeleton */\\n.skeleton {\\n background: var(--muted);\\n border-radius: var(--radius-sm);\\n animation: pulse 1500ms var(--ease-in-out) infinite;\\n}\\n.skeleton-row {\\n height: 56px;\\n margin-bottom: 8px;\\n}\\n@keyframes pulse {\\n 50% {\\n opacity: 0.6;\\n }\\n}\\n\\n/* Dialog */\\n.dialog {\\n width: min(480px, calc(100vw - 32px));\\n padding: 24px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-lg);\\n background: var(--popover);\\n color: var(--popover-foreground);\\n box-shadow: var(--shadow-popover);\\n}\\n.dialog::backdrop {\\n background: rgb(0 0 0 / 0.5);\\n}\\n.form {\\n display: grid;\\n gap: 8px;\\n}\\n.form-actions {\\n display: flex;\\n justify-content: flex-end;\\n gap: 8px;\\n margin-top: 16px;\\n}\\n.confirm-button {\\n background: var(--destructive);\\n color: var(--destructive-foreground);\\n}\\n@media (hover: hover) and (pointer: fine) {\\n .confirm-button:hover {\\n background: var(--destructive);\\n opacity: 0.9;\\n }\\n}\\n\\n/* Menu */\\n.menu {\\n display: grid;\\n min-width: 128px;\\n padding: 4px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius-lg);\\n background: var(--popover);\\n color: var(--popover-foreground);\\n box-shadow: var(--shadow-popover);\\n}\\n.menu > * {\\n display: flex;\\n align-items: center;\\n gap: 8px;\\n padding: 8px 10px;\\n border: 0;\\n border-radius: var(--radius-sm);\\n background: none;\\n color: inherit;\\n font: inherit;\\n text-align: left;\\n text-decoration: none;\\n cursor: pointer;\\n}\\n.menu > hr {\\n height: 1px;\\n margin: 4px 0;\\n padding: 0;\\n background: var(--border);\\n}\\n@media (hover: hover) and (pointer: fine) {\\n .menu > :hover {\\n background: var(--muted);\\n }\\n}\\n\\n/* Toast */\\n.toast {\\n position: fixed;\\n left: 16px;\\n right: 16px;\\n bottom: 16px;\\n padding: 12px 16px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius);\\n background: var(--popover);\\n color: var(--popover-foreground);\\n box-shadow: var(--shadow-popover);\\n transition:\\n opacity var(--duration-standard) var(--ease-out),\\n transform var(--duration-standard) var(--ease-out);\\n}\\n.toast[data-open=\\\"false\\\"] {\\n opacity: 0;\\n transform: translateY(8px);\\n pointer-events: none;\\n}\\n.toaster {\\n position: fixed;\\n inset: auto 16px 16px;\\n z-index: 20;\\n display: grid;\\n gap: 8px;\\n pointer-events: none;\\n}\\n.toast[data-open=\\\"true\\\"] {\\n pointer-events: auto;\\n}\\n.toaster .toast {\\n position: static;\\n}\\n@media (min-width: 768px) {\\n .toast,\\n .toaster {\\n left: auto;\\n width: 360px;\\n }\\n}\\n\\n@media (prefers-reduced-motion: reduce) {\\n *,\\n *::before,\\n *::after {\\n transition-property:\\n opacity, color, background-color, border-color !important;\\n animation-iteration-count: 1 !important;\\n }\\n .button:active {\\n transform: none;\\n }\\n .toast[data-open=\\\"false\\\"] {\\n transform: none;\\n }\\n}\\n\\n/* ===== Weather layer: scenes, palettes and frosted panels (added for this design) ===== */\\n.sr {\\n position: absolute;\\n width: 1px;\\n height: 1px;\\n overflow: hidden;\\n clip: rect(0 0 0 0);\\n white-space: nowrap;\\n}\\nsvg.i {\\n width: 20px;\\n height: 20px;\\n flex: none;\\n fill: none;\\n stroke: currentColor;\\n stroke-width: 2;\\n stroke-linecap: round;\\n stroke-linejoin: round;\\n}\\nsvg.i-sm { width: 16px; height: 16px; }\\nsvg.i-lg { width: 32px; height: 32px; stroke-width: 1.75; }\\nsvg.i-xl { width: 72px; height: 72px; stroke-width: 1.25; }\\n\\n/* Frosted panels with subtle depth */\\n.card,\\n.stat-grid-item,\\n.app:has(.app-nav > :nth-child(4)) .app-nav,\\n.dialog,\\n.toast {\\n}\\n.card {\\n box-shadow: 0 1px 2px rgb(10 20 40 / 0.06), 0 10px 30px rgb(10 20 40 / 0.1);\\n}\\n.app {\\n color: var(--foreground);\\n transition: color var(--duration-large) var(--ease-out);\\n}\\n.app-bar {\\n position: sticky;\\n top: 0;\\n z-index: 11;\\n background: var(--card);\\n color: var(--card-foreground);\\n}\\n.app-main h1 { font-size: 30px; line-height: 36px; letter-spacing: -0.01em; }\\n.unit-toggle {\\n display: inline-flex;\\n padding: 3px;\\n gap: 2px;\\n border: 1px solid var(--border);\\n border-radius: 999px;\\n background: var(--muted);\\n}\\n.unit-toggle .filter-chip { border: 0; min-height: 32px; padding: 4px 12px; font-size: 14px; }\\n.unit-toggle .filter-chip[aria-pressed=\\\"false\\\"] { color: var(--muted-foreground); }\\n\\n/* Scene: full screen, behind everything */\\n.scene {\\n position: fixed;\\n inset: 0;\\n z-index: -1;\\n overflow: hidden;\\n background: var(--sky-a);\\n box-shadow: inset 0 -50vh 50vh -10vh var(--sky-b);\\n transition: background-color var(--duration-large) var(--ease-out);\\n animation: scene-in 1200ms var(--ease-out) both;\\n}\\n.scene i,\\n.scene::before,\\n.scene::after {\\n content: \\\"\\\";\\n position: absolute;\\n top: 0;\\n left: 0;\\n display: block;\\n pointer-events: none;\\n}\\n/* Particle fields: one small shape repeated by its shadows across the screen; ::after repeats it one screen above for a seamless loop */\\n.scene i {\\n --c: rgb(255 255 255 / 0.5);\\n --field-a: 3vw 5vh var(--c), 11vw 62vh var(--c), 17vw 28vh var(--c), 24vw 84vh var(--c), 29vw 12vh var(--c), 35vw 47vh var(--c), 41vw 73vh var(--c), 46vw 20vh var(--c), 52vw 58vh var(--c), 57vw 91vh var(--c), 63vw 35vh var(--c), 68vw 4vh var(--c), 72vw 66vh var(--c), 78vw 25vh var(--c), 83vw 80vh var(--c), 88vw 43vh var(--c), 93vw 10vh var(--c), 97vw 70vh var(--c), 7vw 38vh var(--c), 20vw 52vh var(--c), 44vw 95vh var(--c), 60vw 15vh var(--c), 75vw 50vh var(--c), 90vw 88vh var(--c);\\n --field-b: 1vw 22vh var(--c), 6vw 77vh var(--c), 13vw 9vh var(--c), 19vw 45vh var(--c), 26vw 66vh var(--c), 31vw 30vh var(--c), 37vw 88vh var(--c), 42vw 5vh var(--c), 48vw 40vh var(--c), 54vw 72vh var(--c), 59vw 18vh var(--c), 65vw 55vh var(--c), 70vw 93vh var(--c), 76vw 8vh var(--c), 81vw 37vh var(--c), 86vw 62vh var(--c), 92vw 26vh var(--c), 98vw 49vh var(--c), 9vw 93vh var(--c), 33vw 58vh var(--c), 50vw 2vh var(--c), 67vw 80vh var(--c), 84vw 14vh var(--c), 15vw 70vh var(--c);\\n}\\n.scene i::after {\\n content: \\\"\\\";\\n position: absolute;\\n top: -100vh;\\n left: 0;\\n width: inherit;\\n height: inherit;\\n border-radius: inherit;\\n box-shadow: inherit;\\n}\\n@keyframes scene-in { from { opacity: 0; } }\\n@keyframes drift { from { transform: translateX(-6vw); } to { transform: translateX(6vw); } }\\n@keyframes fall { to { transform: translateY(100vh); } }\\n@keyframes blow { to { transform: translateX(100vw); } }\\n@keyframes flash { 0%, 88%, 100% { opacity: 0; } 89% { opacity: 0.9; } 90.5% { opacity: 0; } 92% { opacity: 0.55; } 93.5% { opacity: 0; } }\\n@keyframes shimmer { from { transform: translateY(0) skewX(0); } to { transform: translateY(-6px) skewX(2deg); } }\\n@keyframes twinkle { from { opacity: 0.55; } to { opacity: 1; } }\\n\\n/* Sky colours per condition and day/night */\\n[data-wx=\\\"clear-day\\\"] { --sky-a: #3d7fd1; --sky-b: #9cc9f2; }\\n[data-wx=\\\"hot-day\\\"] { --sky-a: #e7801c; --sky-b: #f9d58a; }\\n[data-wx=\\\"cloudy-day\\\"] { --sky-a: #7d8ea1; --sky-b: #c9d3dd; }\\n[data-wx=\\\"rain-day\\\"] { --sky-a: #3f5876; --sky-b: #8ea2b7; }\\n[data-wx=\\\"storm-day\\\"] { --sky-a: #2a2e44; --sky-b: #5b607b; }\\n[data-wx=\\\"snow-day\\\"] { --sky-a: #8fa6bf; --sky-b: #dce5ee; }\\n[data-wx=\\\"wind-day\\\"] { --sky-a: #4f86ad; --sky-b: #bcd6e6; }\\n[data-wx=\\\"fog-day\\\"] { --sky-a: #9aa1a8; --sky-b: #dde1e5; }\\n[data-wx=\\\"clear-night\\\"] { --sky-a: #070b26; --sky-b: #283070; }\\n[data-wx=\\\"hot-night\\\"] { --sky-a: #1d1736; --sky-b: #5a2f45; }\\n[data-wx=\\\"cloudy-night\\\"] { --sky-a: #161c29; --sky-b: #3a4356; }\\n[data-wx=\\\"rain-night\\\"] { --sky-a: #0c1626; --sky-b: #2a3b55; }\\n[data-wx=\\\"storm-night\\\"] { --sky-a: #07071a; --sky-b: #2b2445; }\\n[data-wx=\\\"snow-night\\\"] { --sky-a: #18263f; --sky-b: #4a5f80; }\\n[data-wx=\\\"wind-night\\\"] { --sky-a: #0b1f30; --sky-b: #2c4b60; }\\n[data-wx=\\\"fog-night\\\"] { --sky-a: #1b1f24; --sky-b: #40464e; }\\n[data-wx=\\\"calm\\\"] { --sky-a: #cfd9e4; --sky-b: #f3f6f9; }\\n\\n/* Clouds (cloudy, rain, storm, snow, wind): soft blurred shapes */\\n.scene:is([data-wx^=\\\"cloudy\\\"], [data-wx^=\\\"rain\\\"], [data-wx^=\\\"storm\\\"], [data-wx^=\\\"snow\\\"], [data-wx^=\\\"wind\\\"])::before {\\n top: 4vh;\\n left: 4vw;\\n width: 40vw;\\n height: 12vh;\\n border-radius: 50%;\\n background: rgb(255 255 255 / 0.3);\\n box-shadow: 46vw -2vh 0 2vh rgb(255 255 255 / 0.24), 22vw 16vh 0 1vh rgb(255 255 255 / 0.16), 70vw 18vh 0 0 rgb(255 255 255 / 0.18);\\n filter: blur(28px);\\n animation: drift 60s var(--ease-in-out) infinite alternate;\\n}\\n.scene[data-wx$=\\\"-night\\\"]:not([data-wx^=\\\"clear\\\"]):not([data-wx^=\\\"hot\\\"])::before { opacity: 0.5; }\\n.scene[data-wx^=\\\"storm\\\"]::before { filter: brightness(0.55); }\\n.scene[data-wx^=\\\"wind\\\"]::before { animation-duration: 9s; }\\n.scene[data-wx^=\\\"cloudy\\\"] i:nth-child(1),\\n.scene[data-wx^=\\\"cloudy\\\"] i:nth-child(2) {\\n top: 34vh;\\n left: 10vw;\\n width: 36vw;\\n height: 12vh;\\n border-radius: 50%;\\n background: rgb(255 255 255 / 0.22);\\n box-shadow: 44vw 6vh 0 1vh rgb(255 255 255 / 0.18);\\n filter: blur(30px);\\n animation: drift 40s var(--ease-in-out) infinite alternate-reverse;\\n}\\n.scene[data-wx^=\\\"cloudy\\\"] i:nth-child(2) { top: 64vh; left: 24vw; animation-duration: 80s; animation-direction: alternate; }\\n\\n/* Rain and storm drops: near and far layers */\\n.scene:is([data-wx^=\\\"rain\\\"], [data-wx^=\\\"storm\\\"]) i:nth-child(1) {\\n --c: rgb(255 255 255 / 0.55);\\n width: 2px;\\n height: 18px;\\n border-radius: 1px;\\n box-shadow: var(--field-a);\\n animation: fall 800ms linear infinite;\\n}\\n.scene:is([data-wx^=\\\"rain\\\"], [data-wx^=\\\"storm\\\"]) i:nth-child(2) {\\n --c: rgb(255 255 255 / 0.3);\\n width: 1px;\\n height: 10px;\\n box-shadow: var(--field-b);\\n animation: fall 1300ms linear infinite;\\n}\\n.scene[data-intensity=\\\"light\\\"] i:nth-child(1) { opacity: 0.45; }\\n.scene[data-intensity=\\\"light\\\"] i:nth-child(2) { display: none; }\\n.scene[data-wx^=\\\"storm\\\"] i:nth-child(3) {\\n width: 100vw;\\n height: 100vh;\\n background: rgb(220 225 255 / 0.4);\\n opacity: 0;\\n animation: flash 7s linear infinite;\\n}\\n\\n/* Snow: flakes with depth */\\n.scene[data-wx^=\\\"snow\\\"] i:nth-child(1) {\\n --c: rgb(255 255 255 / 0.95);\\n width: 5px;\\n height: 5px;\\n border-radius: 50%;\\n box-shadow: var(--field-a);\\n animation: fall 11s linear infinite;\\n}\\n.scene[data-wx^=\\\"snow\\\"] i:nth-child(2) {\\n --c: rgb(255 255 255 / 0.7);\\n width: 3px;\\n height: 3px;\\n border-radius: 50%;\\n box-shadow: var(--field-b);\\n filter: blur(0.5px);\\n animation: fall 19s linear infinite;\\n}\\n\\n/* Wind: streaks and blown leaves */\\n.scene[data-wx^=\\\"wind\\\"] i:nth-child(1),\\n.scene[data-wx^=\\\"wind\\\"] i:nth-child(2) {\\n --c: rgb(255 255 255 / 0.5);\\n width: 90px;\\n height: 2px;\\n border-radius: 1px;\\n box-shadow: var(--field-a);\\n animation: blow 1.6s linear infinite;\\n}\\n.scene[data-wx^=\\\"wind\\\"] i:nth-child(2) {\\n --c: rgb(255 255 255 / 0.6);\\n width: 5px;\\n height: 3px;\\n box-shadow: var(--field-b);\\n animation-duration: 4s;\\n}\\n.scene[data-wx^=\\\"wind\\\"] i::after { top: 0; left: -100vw; }\\n\\n/* Fog: low rolling mist */\\n.scene[data-wx^=\\\"fog\\\"] i:nth-child(1),\\n.scene[data-wx^=\\\"fog\\\"] i:nth-child(2) {\\n top: 42vh;\\n left: -10vw;\\n width: 70vw;\\n height: 22vh;\\n border-radius: 50%;\\n background: rgb(255 255 255 / 0.45);\\n box-shadow: 60vw 8vh 0 0 rgb(255 255 255 / 0.4);\\n filter: blur(40px);\\n animation: drift 30s var(--ease-in-out) infinite alternate;\\n}\\n.scene[data-wx^=\\\"fog\\\"] i:nth-child(2) { top: 66vh; animation-duration: 45s; animation-direction: alternate-reverse; }\\n.scene[data-wx^=\\\"fog\\\"] i:nth-child(3) { top: 70vh; width: 100vw; height: 30vh; background: rgb(255 255 255 / 0.25); filter: blur(30px); }\\n.scene[data-wx=\\\"fog-night\\\"] i { opacity: 0.45; }\\n\\n/* Clear and hot: sun glow, heat shimmer */\\n.scene:is([data-wx=\\\"clear-day\\\"], [data-wx=\\\"hot-day\\\"])::before {\\n top: 10vh;\\n left: auto;\\n right: 12vw;\\n width: 96px;\\n height: 96px;\\n border-radius: 50%;\\n background: #fff8e1;\\n box-shadow: 0 0 60px 30px rgb(255 240 200 / 0.55), 0 0 220px 120px rgb(255 225 160 / 0.22);\\n}\\n.scene[data-wx=\\\"hot-day\\\"]::before {\\n width: 132px;\\n height: 132px;\\n background: #fff4d6;\\n box-shadow: 0 0 80px 40px rgb(255 210 140 / 0.6), 0 0 300px 160px rgb(255 190 110 / 0.28);\\n}\\n.scene[data-wx=\\\"hot-day\\\"] i:nth-child(3) {\\n top: 58vh;\\n width: 100vw;\\n height: 42vh;\\n background: rgb(255 255 255 / 0.07);\\n filter: blur(6px);\\n animation: shimmer 2.4s var(--ease-in-out) infinite alternate;\\n}\\n\\n/* Clear night: stars and moon */\\n.scene:is([data-wx=\\\"clear-night\\\"], [data-wx=\\\"hot-night\\\"])::before {\\n top: 12vh;\\n left: auto;\\n right: 16vw;\\n width: 56px;\\n height: 56px;\\n border-radius: 50%;\\n background: #f4f1e6;\\n box-shadow: 0 0 40px 12px rgb(244 241 230 / 0.2);\\n}\\n.scene:is([data-wx=\\\"clear-night\\\"], [data-wx=\\\"hot-night\\\"]) i:nth-child(1),\\n.scene:is([data-wx=\\\"clear-night\\\"], [data-wx=\\\"hot-night\\\"]) i:nth-child(2) {\\n --c: rgb(255 255 255 / 0.9);\\n width: 2px;\\n height: 2px;\\n border-radius: 50%;\\n box-shadow: var(--field-a);\\n animation: twinkle 4s var(--ease-in-out) infinite alternate;\\n}\\n.scene:is([data-wx=\\\"clear-night\\\"], [data-wx=\\\"hot-night\\\"]) i:nth-child(2) { --c: rgb(255 255 255 / 0.6); box-shadow: var(--field-b); animation-delay: -2s; }\\n\\n/* Simpler effects: fewer layers. Calm: nothing moves, nothing flashes. */\\n.scene.lite i:nth-child(2) { display: none; }\\n.scene.lite i:nth-child(1) { opacity: 0.6; }\\n.scene.calm i,\\n.scene.calm::before,\\n.scene.calm::after { animation: none !important; }\\n.scene.calm[data-wx^=\\\"storm\\\"] i:nth-child(3) { display: none; }\\n@media (prefers-reduced-motion: reduce) {\\n .scene,\\n .scene i,\\n .scene::before,\\n .scene::after { animation: none !important; }\\n .scene[data-wx^=\\\"storm\\\"] i:nth-child(3) { display: none; }\\n}\\n\\n/* Palettes: the shown scene sets the whole app's accent and panels */\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx$=\\\"-day\\\"]),\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"calm\\\"]) {\\n --background: #ffffff;\\n --foreground: #121820;\\n --card: rgb(255 255 255 / 0.82);\\n --card-foreground: #121820;\\n --popover: #ffffff;\\n --popover-foreground: #121820;\\n --muted: rgb(18 24 32 / 0.06);\\n --muted-foreground: #434d5b;\\n --accent: rgb(18 24 32 / 0.08);\\n --accent-foreground: #121820;\\n --border: rgb(18 24 32 / 0.12);\\n --input: #7b8594;\\n --pop: #1f5f99;\\n color-scheme: light;\\n}\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx$=\\\"-night\\\"]) {\\n --background: #0e1330;\\n --foreground: #eef1fa;\\n --card: rgb(14 18 40 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --pop: #9cc7f0;\\n color-scheme: dark;\\n}\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"clear-day\\\"]) { --primary: #1d5fb3; --primary-active: #174e94; --ring: #1d5fb3; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"hot-day\\\"]) { --primary: #a54a08; --primary-active: #8a3e06; --ring: #a54a08; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"cloudy-day\\\"]) { --primary: #3f5872; --primary-active: #33485e; --ring: #3f5872; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"rain-day\\\"]) { --primary: #2f5b8a; --primary-active: #264b72; --ring: #2f5b8a; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"storm-day\\\"]) { --primary: #4f46a8; --primary-active: #423a90; --ring: #4f46a8; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"snow-day\\\"]) { --primary: #2563a8; --primary-active: #1e5290; --ring: #2563a8; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"wind-day\\\"]) { --primary: #0e6a82; --primary-active: #0b586c; --ring: #0e6a82; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"fog-day\\\"]) { --primary: #4b5563; --primary-active: #3d4651; --ring: #4b5563; --primary-foreground: #fff; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"clear-night\\\"]) { --primary: #a5b4fc; --primary-active: #c3cdfd; --ring: #a5b4fc; --primary-foreground: #0b1030; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"hot-night\\\"]) { --primary: #f6b44b; --primary-active: #f8c56f; --ring: #f6b44b; --primary-foreground: #1d1736; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"cloudy-night\\\"]) { --primary: #9fb3cf; --primary-active: #b8c8de; --ring: #9fb3cf; --primary-foreground: #11151f; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"rain-night\\\"]) { --primary: #7fa9db; --primary-active: #9dbde4; --ring: #7fa9db; --primary-foreground: #0c1626; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"storm-night\\\"]) { --primary: #b2a6ff; --primary-active: #c9c0ff; --ring: #b2a6ff; --primary-foreground: #0e0b22; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"snow-night\\\"]) { --primary: #bcd7f5; --primary-active: #d3e5f8; --ring: #bcd7f5; --primary-foreground: #18263f; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"wind-night\\\"]) { --primary: #6cc6dc; --primary-active: #8fd4e5; --ring: #6cc6dc; --primary-foreground: #0b1f30; }\\n.app:has(section:not([hidden]) > [data-state]:not([hidden]) > .scene[data-wx=\\\"fog-night\\\"]) { --primary: #c0c7cf; --primary-active: #d4d9df; --ring: #c0c7cf; --primary-foreground: #1b1f24; }\\n\\n/* Weather screen layout: places panel + main view */\\n.wx-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }\\n.wx-main,\\n.places { min-width: 0; }\\n@media (min-width: 1200px) {\\n .wx-layout { grid-template-columns: 280px minmax(0, 1fr); align-items: start; gap: 24px; }\\n .places { position: sticky; top: 80px; }\\n}\\n.places { gap: 12px; padding: 16px; }\\n.label {\\n font-size: 12px;\\n line-height: 16px;\\n font-weight: 600;\\n letter-spacing: 0.08em;\\n text-transform: uppercase;\\n color: var(--muted-foreground);\\n}\\n.search-entry {\\n display: flex;\\n align-items: center;\\n gap: 8px;\\n min-height: 44px;\\n padding: 0 12px;\\n border: 1px solid var(--input);\\n border-radius: var(--radius);\\n background: var(--popover);\\n color: var(--muted-foreground);\\n font-size: 16px;\\n text-decoration: none;\\n}\\n.place-list {\\n list-style: none;\\n margin: 0;\\n padding: 2px 2px 6px;\\n display: flex;\\n gap: 8px;\\n overflow-x: auto;\\n scroll-snap-type: x mandatory;\\n}\\n.place-list > li { flex: 0 0 auto; scroll-snap-align: start; }\\n@media (min-width: 1200px) {\\n .place-list { flex-direction: column; overflow: visible; padding: 0; }\\n}\\n.place {\\n display: flex;\\n align-items: center;\\n gap: 10px;\\n min-width: 196px;\\n padding: 8px 12px 8px 8px;\\n border: 1px solid var(--border);\\n border-radius: var(--radius);\\n color: inherit;\\n text-decoration: none;\\n}\\n.place[aria-current=\\\"true\\\"] { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); background: var(--muted); }\\n.place-text { display: grid; line-height: 18px; }\\n.place-temp { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }\\n.wx-chip {\\n display: inline-grid;\\n place-items: center;\\n width: 36px;\\n height: 36px;\\n flex: none;\\n border-radius: 50%;\\n background: var(--sky-a);\\n color: #fff;\\n box-shadow: inset 0 -12px 12px -4px var(--sky-b), inset 0 0 0 1px rgb(0 0 0 / 0.12);\\n}\\n.wx-chip svg { filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.45)); }\\n\\n/* Current conditions */\\n.sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; }\\n.hero-top { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }\\n.temp {\\n margin: 0;\\n font-size: clamp(64px, 14vw, 96px);\\n line-height: 1;\\n font-weight: 250;\\n letter-spacing: -0.04em;\\n font-variant-numeric: tabular-nums;\\n}\\n.cond { margin: 0; font-size: 20px; line-height: 28px; font-weight: 600; }\\n.hero .stat-grid { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); }\\n.hero .stat-grid-item { background: var(--muted); -webkit-backdrop-filter: none; backdrop-filter: none; }\\n.hero .stat-grid-item > :nth-child(2) { font-size: 24px; font-weight: 600; }\\n.hero .stat-grid-item small { color: var(--muted-foreground); font-size: 12px; line-height: 16px; }\\n.hero-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }\\n.small { font-size: 12px; line-height: 16px; }\\n.switch-label { gap: 10px; cursor: pointer; font-weight: 500; }\\n\\n/* 24-hour strip */\\n.hours {\\n list-style: none;\\n margin: 0;\\n padding: 0 0 6px;\\n display: flex;\\n gap: 4px;\\n overflow-x: auto;\\n scroll-snap-type: x proximity;\\n}\\n.hours > li {\\n flex: 0 0 64px;\\n display: grid;\\n justify-items: center;\\n gap: 6px;\\n padding: 10px 4px;\\n border-radius: var(--radius);\\n scroll-snap-align: start;\\n text-align: center;\\n}\\n.hours > li:first-child { background: var(--muted); font-weight: 600; }\\n.hours b { font-size: 16px; font-variant-numeric: tabular-nums; }\\n.pop { color: var(--pop, #1f5f99); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }\\n\\n/* 7-day list */\\n.days { list-style: none; margin: 0; padding: 0; }\\n.days > li {\\n display: grid;\\n grid-template-columns: 52px minmax(0, 1fr) 40px auto;\\n align-items: center;\\n gap: 10px;\\n padding: 10px 0;\\n border-top: 1px solid var(--border);\\n}\\n.day-cond { display: flex; align-items: center; gap: 8px; min-width: 0; }\\n.range { display: flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; }\\n.range .lo { color: var(--muted-foreground); }\\n.range-bar { display: none; position: relative; width: 120px; height: 6px; border-radius: 3px; background: var(--muted); }\\n.range-bar > span { position: absolute; inset-block: 0; border-radius: 3px; background: var(--primary); }\\n@media (min-width: 560px) {\\n .range-bar { display: block; }\\n .days > li { grid-template-columns: 64px minmax(0, 1fr) 48px auto; gap: 16px; }\\n}\\n\\n/* Notices (offline, error, permission) */\\n.notice { flex-direction: row; align-items: flex-start; gap: 12px; padding: 16px; border-left: 4px solid var(--primary); }\\n.notice-error { border-left-color: var(--destructive); }\\n.notice p { margin: 0; }\\n\\n/* Saved cities list */\\n.city-list { list-style: none; margin: 0; padding: 4px 16px; gap: 0; }\\n.city-row {\\n display: grid;\\n grid-template-columns: auto auto minmax(0, 1fr) auto auto;\\n align-items: center;\\n gap: 12px;\\n padding: 12px 0;\\n}\\n.city-row + .city-row { border-top: 1px solid var(--border); }\\n.city-main { display: grid; color: inherit; text-decoration: none; line-height: 20px; }\\n.city-cond { grid-column: 3; color: var(--muted-foreground); font-size: 13px; }\\n.city-temp { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; min-width: 3ch; }\\n\\n\\n/* Compare */\\n.cmp-grid {\\n display: grid;\\n grid-auto-flow: column;\\n grid-auto-columns: minmax(min(260px, 82vw), 1fr);\\n gap: 16px;\\n overflow-x: auto;\\n padding-bottom: 8px;\\n scroll-snap-type: x mandatory;\\n}\\n.cmp-grid > * { scroll-snap-align: start; }\\n.cmp { padding: 0; overflow: hidden; gap: 0; }\\n.cmp > :not(.wx-band) { padding-inline: 20px; }\\n.wx-band {\\n display: flex;\\n align-items: center;\\n justify-content: space-between;\\n height: 72px;\\n padding: 0 20px;\\n background: var(--sky-a);\\n box-shadow: inset 0 -28px 28px -8px var(--sky-b);\\n color: #fff;\\n}\\n.wx-band svg { filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.4)); }\\n.cmp-head { padding-top: 16px; }\\n.cmp-temp { margin: 8px 0 0; font-size: 48px; line-height: 1; font-weight: 300; font-variant-numeric: tabular-nums; }\\n.cmp-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }\\n.cmp-stats dt { color: var(--muted-foreground); font-size: 12px; }\\n.cmp-stats dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }\\n.outlook {\\n list-style: none;\\n margin: 0;\\n padding: 12px 20px 20px;\\n display: grid;\\n grid-template-columns: repeat(4, 1fr);\\n gap: 4px;\\n border-top: 1px solid var(--border);\\n text-align: center;\\n font-size: 13px;\\n}\\n.outlook li { display: grid; justify-items: center; gap: 4px; }\\n\\n/* Settings: scene gallery */\\n.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }\\n.swatch { display: grid; gap: 6px; font-size: 13px; }\\n.swatch > span:first-child {\\n display: grid;\\n place-items: center;\\n height: 64px;\\n border-radius: var(--radius);\\n background: var(--sky-a);\\n box-shadow: inset 0 -24px 24px -8px var(--sky-b);\\n color: #fff;\\n}\\n.swatch svg { filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.4)); }\\n.setting-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }\\n.setting-row p { margin: 4px 0 0; max-width: 60ch; }\\n\\n/* Adaptive layer: narrow pages, rows, phone/desktop helpers */\\n.narrow { max-width: 760px; margin-inline: auto; }\\n.icon-button { padding-inline: 10px; }\\n.city-main { min-width: 0; }\\n.city-main > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\\n.result-row { grid-template-columns: auto minmax(0, 1fr) auto auto; }\\n.show-sm { display: block; }\\n.hide-sm { display: none; }\\n@media (min-width: 640px) {\\n .show-sm { display: none; }\\n .hide-sm { display: inline; }\\n}\\n.place-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); overflow: visible; scroll-snap-type: none; }\\n.place-grid .place { min-width: 0; }\\n.welcome { max-width: 560px; margin: 8vh auto 0; padding: 48px 24px; }\\n.wx-chip-lg { width: 64px; height: 64px; }\\n.swatches { list-style: none; margin: 0; padding: 0; }\\n.app-nav svg.i { width: 18px; height: 18px; }\\n.app-bar .app-name { margin-right: auto; }\\n@media (min-width: 768px) {\\n .app-bar [data-testid=\\\"top-search-link\\\"] { display: none; }\\n}\\n40:.badge {\\n55:.badge-success {\\n60:.badge-warning {\\n65:.badge-danger {\\n98:.stat-grid {\\n103:.stat-grid-item {\\n111:.stat-grid-item > :first-child {\\n118:.stat-grid-item > :nth-child(2) {\\n162:.empty-state,\\n163:.empty {\\n170:.empty-state p,\\n171:.empty p {\\n265:.toast {\\n280:.toast[data-open=\\\"false\\\"] {\\n285:.toaster {\\n292:.toaster .toast {\\n296: .toast,\\n297: .toaster {\\n313: .toast[data-open=\\\"false\\\"] {\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"des_write_html","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":\".builder/designs/index.html\",\"content\":\"\\n\\n \\n \\n \\n Animated Weather\\n \\n \\n \\n \\n \\n
    \\n
    \\n \\n Animated Weather\\n \\n \\n
    \\n
    \\n
    \\n

    London

    My location · England, United Kingdom · 10:40 local time

    9°

    Moderate rain

    Rain easing to showers by 14:00, dry this evening.

    Feels like6°Wind makes it feel colder
    Wind24 km/hFrom the south-west, gusts 41
    Humidity91%Dew point 8°
    High / low10° / 5°Today
    Rain today7.2 mm90% chance
    Sun08:02 / 16:18Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Moderate rain, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now9°90%
    2. 11:009°85%
    3. 12:009°80%
    4. 13:0010°60%
    5. 14:0010°55%
    6. 15:0010°20%
    7. 16:0010°30%
    8. 17:0010°25%
    9. 18:009°20%
    10. 19:009°15%
    11. 20:008°10%
    12. 21:008°5%
    13. 22:007°15%
    14. 23:006°10%
    15. 00:006°10%
    16. 01:005°20%
    17. 02:005°15%
    18. 03:005°10%
    19. 04:005°20%
    20. 05:005°15%
    21. 06:006°40%
    22. 07:006°50%
    23. 08:007°45%
    24. 09:008°40%

    Next 7 days

    1. TodayModerate rain90%5°10°
    2. ThuShowers60%4°9°
    3. FriCloudy20%3°8°
    4. SatSunny5%1°7°
    5. SunPartly cloudy10%2°8°
    6. MonRain75%6°11°
    7. TueWindy30%7°12°
    \\n

    Perth

    Western Australia, Australia · 18:40 local time

    34°

    Sunny and hot

    Heat holds until sunset at 19:22; a warm, clear night follows.

    Feels like35°Dry heat
    Wind19 km/hFrom the south-west, gusts 30
    Humidity22%Dew point 9°
    High / low38° / 21°Today
    Rain today0 mm0% chance
    Sun05:23 / 19:22Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Hot and sunny, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now34°0%
    2. 19:0034°0%
    3. 20:0032°0%
    4. 21:0030°0%
    5. 22:0027°0%
    6. 23:0025°0%
    7. 00:0023°0%
    8. 01:0022°0%
    9. 02:0021°0%
    10. 03:0021°0%
    11. 04:0021°0%
    12. 05:0022°0%
    13. 06:0023°0%
    14. 07:0025°0%
    15. 08:0027°0%
    16. 09:0030°0%
    17. 10:0032°0%
    18. 11:0034°0%
    19. 12:0036°0%
    20. 13:0037°0%
    21. 14:0038°0%
    22. 15:0038°0%
    23. 16:0038°0%
    24. 17:0037°0%

    Next 7 days

    1. TodaySunny and hot0%21°38°
    2. ThuSunny and hot0%23°39°
    3. FriSunny0%22°35°
    4. SatPartly cloudy5%19°31°
    5. SunSunny0%18°30°
    6. MonSunny0%19°33°
    7. TueSunny and hot0%21°37°
    \\n

    Vancouver

    British Columbia, Canada · 02:40 local time

    5°

    Overcast

    Low cloud overnight, staying grey tomorrow with a chance of drizzle.

    Feels like3°Light breeze
    Wind9 km/hFrom the east, gusts 16
    Humidity84%Dew point 3°
    High / low7° / 3°Today
    Rain today0.4 mm30% chance
    Sun08:04 / 16:42Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Overcast, night

    Next 24 hours

    Blue figure: chance of rain
    1. Now5°10%
    2. 03:003°5%
    3. 04:003°0%
    4. 05:003°10%
    5. 06:004°5%
    6. 07:004°0%
    7. 08:004°10%
    8. 09:005°5%
    9. 10:006°10%
    10. 11:006°20%
    11. 12:006°15%
    12. 13:007°10%
    13. 14:007°35%
    14. 15:007°30%
    15. 16:007°25%
    16. 17:007°35%
    17. 18:006°15%
    18. 19:006°10%
    19. 20:006°20%
    20. 21:005°15%
    21. 22:004°10%
    22. 23:004°20%
    23. 00:004°15%
    24. 01:003°10%

    Next 7 days

    1. TodayOvercast30%3°7°
    2. ThuDrizzle55%4°8°
    3. FriRain80%5°9°
    4. SatCloudy30%4°8°
    5. SunPartly cloudy10%2°8°
    6. MonSunny5%1°7°
    7. TueCloudy20%3°8°
    \\n

    Darwin

    Northern Territory, Australia · 20:10 local time

    27°

    Thunderstorm

    Storms with heavy downpours until about 23:00, then steady rain.

    Feels like31°Humid
    Wind32 km/hFrom the west, gusts 58
    Humidity94%Dew point 26°
    High / low32° / 25°Today
    Rain today38 mm95% chance
    Sun06:19 / 19:15Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Thunderstorm, night

    Next 24 hours

    Blue figure: chance of rain
    1. Now27°95%
    2. 21:0029°90%
    3. 22:0028°85%
    4. 23:0027°80%
    5. 00:0026°75%
    6. 01:0025°70%
    7. 02:0025°80%
    8. 03:0025°75%
    9. 04:0025°70%
    10. 05:0025°80%
    11. 06:0026°35%
    12. 07:0027°30%
    13. 08:0028°40%
    14. 09:0029°35%
    15. 10:0029°30%
    16. 11:0030°40%
    17. 12:0031°65%
    18. 13:0032°60%
    19. 14:0032°70%
    20. 15:0032°65%
    21. 16:0032°60%
    22. 17:0032°70%
    23. 18:0031°65%
    24. 19:0030°60%

    Next 7 days

    1. TodayThunderstorm95%25°32°
    2. ThuThunderstorm85%25°31°
    3. FriRain80%25°30°
    4. SatThunderstorm70%24°32°
    5. SunCloudy40%25°33°
    6. MonThunderstorm75%25°32°
    7. TueShowers60%24°31°
    \\n

    Oslo

    Oslo, Norway · 11:40 local time

    −3°

    Heavy snow

    Snow all afternoon, easing to flurries after dark. About 12 cm settling.

    Feels like−8°Wind chill
    Wind14 km/hFrom the north, gusts 25
    Humidity88%Dew point −5°
    High / low−2° / −7°Today
    Rain today11 mm95% chance
    Sun09:05 / 15:41Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Heavy snow, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now−3°95%
    2. 12:00−3°90%
    3. 13:00−2°85%
    4. 14:00−2°95%
    5. 15:00−2°90%
    6. 16:00−2°85%
    7. 17:00−2°60%
    8. 18:00−3°55%
    9. 19:00−3°50%
    10. 20:00−4°60%
    11. 21:00−4°55%
    12. 22:00−5°50%
    13. 23:00−6°20%
    14. 00:00−6°15%
    15. 01:00−7°10%
    16. 02:00−7°20%
    17. 03:00−7°15%
    18. 04:00−7°10%
    19. 05:00−7°40%
    20. 06:00−6°35%
    21. 07:00−6°30%
    22. 08:00−5°40%
    23. 09:00−4°35%
    24. 10:00−4°30%

    Next 7 days

    1. TodayHeavy snow95%−7°−2°
    2. ThuFlurries50%−9°−4°
    3. FriCloudy10%−10°−5°
    4. SatSunny0%−12°−6°
    5. SunPartly cloudy10%−9°−3°
    6. MonSnow70%−6°−1°
    7. TueCloudy20%−5°0°
    \\n

    Cape Town

    Western Cape, South Africa · 12:40 local time

    24°

    Sunny and windy

    Strong south-easter all day, gusting near 70 km/h on the coast.

    Feels like21°Wind makes it feel cooler
    Wind46 km/hFrom the south-east, gusts 68
    Humidity45%Dew point 11°
    High / low26° / 17°Today
    Rain today0 mm0% chance
    Sun05:52 / 19:59Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Strong wind, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now24°0%
    2. 13:0025°0%
    3. 14:0026°0%
    4. 15:0026°0%
    5. 16:0026°0%
    6. 17:0025°0%
    7. 18:0025°0%
    8. 19:0024°0%
    9. 20:0023°0%
    10. 21:0022°0%
    11. 22:0020°0%
    12. 23:0019°0%
    13. 00:0018°0%
    14. 01:0018°0%
    15. 02:0017°0%
    16. 03:0017°0%
    17. 04:0017°0%
    18. 05:0018°0%
    19. 06:0018°0%
    20. 07:0019°0%
    21. 08:0020°0%
    22. 09:0022°0%
    23. 10:0023°0%
    24. 11:0024°0%

    Next 7 days

    1. TodaySunny and windy0%17°26°
    2. ThuWindy0%18°27°
    3. FriSunny0%18°29°
    4. SatSunny0%19°30°
    5. SunPartly cloudy10%17°25°
    6. MonWindy5%16°24°
    7. TueSunny0%17°27°
    \\n

    Milan

    Lombardy, Italy · 11:40 local time

    2°

    Fog

    Fog lifting slowly this afternoon; it returns after dark.

    Feels like0°Damp and still
    Wind4 km/hFrom the east, gusts 8
    Humidity99%Dew point 2°
    High / low5° / −1°Today
    Rain today0 mm10% chance
    Sun07:59 / 17:03Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Thick fog, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now2°5%
    2. 12:004°0%
    3. 13:005°0%
    4. 14:005°5%
    5. 15:005°5%
    6. 16:005°0%
    7. 17:005°10%
    8. 18:004°5%
    9. 19:004°0%
    10. 20:003°5%
    11. 21:002°0%
    12. 22:001°0%
    13. 23:001°5%
    14. 00:000°0%
    15. 01:00−1°0%
    16. 02:00−1°5%
    17. 03:00−1°0%
    18. 04:00−1°0%
    19. 05:00−1°5%
    20. 06:000°0%
    21. 07:001°0%
    22. 08:001°5%
    23. 09:002°0%
    24. 10:003°0%

    Next 7 days

    1. TodayFog10%−1°5°
    2. ThuFog5%−2°6°
    3. FriPartly cloudy5%−1°8°
    4. SatSunny0%−2°9°
    5. SunCloudy20%1°8°
    6. MonRain70%3°7°
    7. TueCloudy30%2°7°
    \\n

    Sydney

    New South Wales, Australia · 21:40 local time

    22°

    Clear

    Clear and mild overnight. Sunny and warm tomorrow.

    Feels like22°Same as actual
    Wind11 km/hFrom the north-east, gusts 18
    Humidity68%Dew point 16°
    High / low27° / 20°Today
    Rain today0 mm5% chance
    Sun06:03 / 20:09Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Clear sky, night

    Next 24 hours

    Blue figure: chance of rain
    1. Now22°0%
    2. 22:0023°0%
    3. 23:0022°0%
    4. 00:0021°0%
    5. 01:0020°0%
    6. 02:0020°0%
    7. 03:0020°0%
    8. 04:0020°0%
    9. 05:0020°0%
    10. 06:0021°0%
    11. 07:0022°0%
    12. 08:0023°0%
    13. 09:0024°10%
    14. 10:0024°5%
    15. 11:0025°0%
    16. 12:0026°10%
    17. 13:0027°5%
    18. 14:0027°0%
    19. 15:0027°10%
    20. 16:0027°5%
    21. 17:0027°0%
    22. 18:0026°10%
    23. 19:0025°5%
    24. 20:0024°0%

    Next 7 days

    1. TodaySunny5%20°27°
    2. ThuSunny0%21°29°
    3. FriPartly cloudy15%21°28°
    4. SatShowers60%20°24°
    5. SunPartly cloudy20%19°25°
    6. MonSunny0%20°27°
    7. TueSunny0%21°28°
    \\n

    Springfield

    Illinois, United States · 04:40 local time

    You're looking at a search result.

    Save Springfield to keep it in your places and compare it with other cities.

    −4°

    Light snow

    Flurries until dawn, then clearing to a cold, sunny day.

    Feels like−10°Wind chill
    Wind17 km/hFrom the north-west, gusts 29
    Humidity80%Dew point −7°
    High / low−1° / −9°Today
    Rain today1 mm40% chance
    Sun07:15 / 16:58Sunrise / sunset
    Updated 2 min ago · Weather from Open-MeteoScene: Light snow, night

    Next 24 hours

    Blue figure: chance of rain
    1. Now−4°40%
    2. 05:00−8°35%
    3. 06:00−8°30%
    4. 07:00−7°15%
    5. 08:00−6°10%
    6. 09:00−5°5%
    7. 10:00−4°0%
    8. 11:00−3°0%
    9. 12:00−2°0%
    10. 13:00−2°0%
    11. 14:00−1°0%
    12. 15:00−1°0%
    13. 16:00−1°0%
    14. 17:00−2°0%
    15. 18:00−2°0%
    16. 19:00−3°0%
    17. 20:00−4°0%
    18. 21:00−5°0%
    19. 22:00−6°0%
    20. 23:00−7°0%
    21. 00:00−8°0%
    22. 01:00−8°0%
    23. 02:00−9°0%
    24. 03:00−9°0%

    Next 7 days

    1. TodayLight snow40%−9°−1°
    2. ThuSunny0%−11°−3°
    3. FriPartly cloudy5%−8°1°
    4. SatCloudy20%−4°3°
    5. SunRain60%0°6°
    6. MonCloudy30%−2°4°
    7. TueSnow55%−6°0°
    \\n

    London

    My location · England, United Kingdom

    Getting the latest weather for London

    Getting the latest weather…

    Next 24 hours

    Next 7 days

    \\n

    London

    My location · England, United Kingdom · 10:40 local time

    You're offline.

    This is the weather we saved at 07:40, 3 hours ago, so it may have changed. Fresh weather needs an internet connection.

    9°

    Moderate rain

    Rain easing to showers by 14:00, dry this evening.

    Feels like6°Wind makes it feel colder
    Wind24 km/hFrom the south-west, gusts 41
    Humidity91%Dew point 8°
    High / low10° / 5°Today
    Rain today7.2 mm90% chance
    Sun08:02 / 16:18Sunrise / sunset
    Last updated today at 07:40Old: 3 hoursScene: Moderate rain, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now9°90%
    2. 11:009°85%
    3. 12:009°80%
    4. 13:0010°60%
    5. 14:0010°55%
    6. 15:0010°20%
    7. 16:0010°30%
    8. 17:0010°25%
    9. 18:009°20%
    10. 19:009°15%
    11. 20:008°10%
    12. 21:008°5%
    13. 22:007°15%
    14. 23:006°10%
    15. 00:006°10%
    16. 01:005°20%
    17. 02:005°15%
    18. 03:005°10%
    19. 04:005°20%
    20. 05:005°15%
    21. 06:006°40%
    22. 07:006°50%
    23. 08:007°45%
    24. 09:008°40%

    Next 7 days

    1. TodayModerate rain90%5°10°
    2. ThuShowers60%4°9°
    3. FriCloudy20%3°8°
    4. SatSunny5%1°7°
    5. SunPartly cloudy10%2°8°
    6. MonRain75%6°11°
    7. TueWindy30%7°12°
    \\n

    London

    My location · England, United Kingdom · 10:40 local time

    9°

    Moderate rain

    Rain easing to showers by 14:00, dry this evening.

    Feels like6°Wind makes it feel colder
    Wind24 km/hFrom the south-west, gusts 41
    Humidity91%Dew point 8°
    High / low10° / 5°Today
    Rain today7.2 mm90% chance
    Sun08:02 / 16:18Sunrise / sunset
    Last updated 25 min agoScene: Moderate rain, day

    Next 24 hours

    Blue figure: chance of rain
    1. Now9°90%
    2. 11:009°85%
    3. 12:009°80%
    4. 13:0010°60%
    5. 14:0010°55%
    6. 15:0010°20%
    7. 16:0010°30%
    8. 17:0010°25%
    9. 18:009°20%
    10. 19:009°15%
    11. 20:008°10%
    12. 21:008°5%
    13. 22:007°15%
    14. 23:006°10%
    15. 00:006°10%
    16. 01:005°20%
    17. 02:005°15%
    18. 03:005°10%
    19. 04:005°20%
    20. 05:005°15%
    21. 06:006°40%
    22. 07:006°50%
    23. 08:007°45%
    24. 09:008°40%

    Next 7 days

    1. TodayModerate rain90%5°10°
    2. ThuShowers60%4°9°
    3. FriCloudy20%3°8°
    4. SatSunny5%1°7°
    5. SunPartly cloudy10%2°8°
    6. MonRain75%6°11°
    7. TueWindy30%7°12°
    \\n

    Your weather

    Location is turned off for this app.

    That's fine: search for a city, or pick one of your saved places below. To use your location later, allow it in your browser's settings, then tap “Try again”.

    Your saved places

    \\n

    Find your weather

    Search for a city and watch its weather come alive on screen: rain falling, snow drifting, heat shimmering. Save the places you care about; they stay in this browser.

    Search for a city
    \\n
    \\n
    \\n

    Search

    Find any city, then open it or save it.

    Results update as you type.

    6 places called “Springfield”. Pick yours by region and country.

    \\n

    Search

    Find any city, then open it or save it.

    Results update as you type.

    No results for “qzxwv”

    Check the spelling, or try a larger city nearby. Small villages often appear under their nearest town.

    Clear search
    \\n

    Search

    Find any city, then open it or save it.

    Results update as you type.

    Looking for places

    \\n

    Search

    Find any city, then open it or save it.

    Results update as you type.

    \\n
    \\n
    \\n\\n

    Saved cities

    No saved cities yet

    Save the places you check often. They'll show here with their weather, and you can compare up to four side by side.

    Search for a city
    \\n
    \\n
    \\n

    Compare

    Four cities side by side, right now.

    Moderate rain, day

    London

    England, United Kingdom · 10:40

    9°

    Moderate rain

    Feels like
    6°
    Wind
    24 km/h SW
    Humidity
    91%
    High / low
    10° / 5°
    1. 11:009°
    2. 13:0010°
    3. 16:0010°
    4. 19:009°
    Hot and sunny, day

    Perth

    Western Australia, Australia · 18:40

    34°

    Sunny and hot

    Feels like
    35°
    Wind
    19 km/h SW
    Humidity
    22%
    High / low
    38° / 21°
    1. 19:0034°
    2. 21:0030°
    3. 00:0023°
    4. 03:0021°
    Heavy snow, day

    Oslo

    Oslo, Norway · 11:40

    −3°

    Heavy snow

    Feels like
    −8°
    Wind
    14 km/h N
    Humidity
    88%
    High / low
    −2° / −7°
    1. 12:00−3°
    2. 14:00−2°
    3. 17:00−2°
    4. 20:00−4°
    Clear sky, night

    Sydney

    New South Wales, Australia · 21:40

    22°

    Clear

    Feels like
    22°
    Wind
    11 km/h NE
    Humidity
    68%
    High / low
    27° / 20°
    1. 22:0023°
    2. 00:0021°
    3. 03:0020°
    4. 06:0021°

    Up to 4 cities. Each card is tinted to its own weather. On a phone, swipe sideways.

    \\n

    Compare

    Pick at least two cities

    Tick 2 to 4 of your saved cities to see their weather side by side.

    Choose cities
    \\n
    \\n
    \\n

    Settings

    Changes save straight away, in this browser.

    Units

    Changes every temperature in the app. °C shows wind in km/h and rain in mm; °F shows mph and inches.

    Effects and motion

    Simpler effects

    Fewer raindrops, flakes and clouds. Kinder to older phones and battery. Each kind of weather still looks different.

    Reduced motion

    Set by your device. When it asks for less motion, scenes hold still: the weather shows through colour and soft shapes, with no falling, flashing or shimmering.

    Your device: full motion
    Automatic smoothness

    If your device struggles, the app quietly lightens the scene, and brings detail back when it can.

    Always on

    Location

    Used only to fetch your local weather. Tap to check where you are again.

    Saved in this browser

    Your saved cities, units and effects choice live only in this browser on this device. There's no account and nothing is sent to us. If you clear your browser's data, they'll be gone and you'll need to add your cities again.

    The scenes

    Each kind of weather has a day and a night look, timed by sunrise and sunset in the city you're viewing.

    • Clear, day
    • Clear, night
    • Hot, day
    • Hot, night
    • Cloudy, day
    • Cloudy, night
    • Rain, day
    • Rain, night
    • Storm, day
    • Storm, night
    • Snow, day
    • Snow, night
    • Wind, day
    • Wind, night
    • Fog, day
    • Fog, night

    Weather and city search by Open-Meteo.

    \\n
    \\n
    \\n

    Put the weather on your home screen

    Installed, it opens full screen like any other app. No app store, no sign-in, and your cities stay in this browser.

    \\n
    Showing °F. Wind is now in mph and rain in inches.
    Weather updated just now.
    Checking your location…
    Location is still blocked. Allow it in your browser's settings, then try again.
    Springfield saved to your places.
    City removed.
    City put back.
    Trying again…
    Installing. The app will open from your home screen.
    \\n
    \\n \\n \\n \\n\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":2,"id":"des_write_doc","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":2,"function":{"arguments":"{\"path\":\".builder/DESIGN.md\",\"content\":\"# Animated Weather design\\n\\n## Summary\\n\\nAnimated Weather is a browser app for people who check the weather often, on a phone or a desktop. The whole screen is a moving scene of the weather where you are, or in a city you pick. Under the scene sit the usual tools: your location, saved cities, °C/°F, a 24-hour and 7-day forecast, and a side-by-side comparison of 2 to 4 cities. There is no sign-in, and everything is kept in the browser.\\n\\nMockup: `.builder/designs/index.html`. Open it from disk. Routes are `#/screen/state`, for example `#/home/storm` or `#/search/empty`.\\n\\n## Theme\\n\\n- The person's words for the look and feel: \\\"Modern and sleek base: crisp neutrals, system sans-serif, generous whitespace, subtle depth, with frosted soft panels over the scene. The accent and background palette shift per weather condition and day/night (warm amber for hot, slate blue for rain, deep indigo for night). Layout reflows between phone and desktop.\\\"\\n- Theme: the kit's neutral theme, changed for the **Adaptive weather-driven** design concept.\\n- Changes:\\n - `--card` and `--popover` are translucent white (day) or translucent indigo (night), and every `.card`, `.stat-grid-item`, dialog, toast and the app bar get `backdrop-filter: blur(18px) saturate(1.3)`. This gives the \\\"frosted soft panels over the scene\\\".\\n - `--radius` is 0.875rem and cards have a soft two-layer shadow (\\\"subtle depth\\\").\\n - `--font-sans` is the system stack (\\\"system sans-serif\\\").\\n - `.dark` and the night palette use deep indigo `#0b0f24` / `#0e1330` with a `#a5b4fc` accent (\\\"deep indigo for night\\\").\\n - **The palette adapts to the scene.** Each scene has a `data-wx` key (`clear|hot|cloudy|rain|storm|snow|wind|fog` + `-day|-night`, plus `calm`). That key sets `--sky-a` / `--sky-b` for the background gradient, and sets `--primary`, `--primary-active`, `--ring` and `--primary-foreground` for the whole app: `hot-day` gets amber `#a54a08`, `rain-day` gets slate blue `#2f5b8a`, `storm-day` gets violet `#4f46a8`, `clear-night` gets indigo `#a5b4fc`, and so on (see \\\"Palettes\\\" in styles.css). Day scenes use the light neutral surface tokens and night scenes use the dark indigo ones. In the client, put `data-wx` on the app root and set it from the current city's weather code and its own sunrise and sunset. Do not use the viewer's clock.\\n - `--pop` (chance-of-rain figures) is `#1f5f99` by day and `#9cc7f0` by night.\\n- Icons: lucide (cloud-rain, sun, moon, cloud, cloud-sun, cloud-moon, cloud-drizzle, cloud-lightning, snowflake, wind, cloud-fog, search, navigation, refresh-cw, bookmark, trash-2, wifi-off, alert-circle, map-pin-off, columns-2, download, home, sliders-vertical, plus, clock). The mockup draws them from an inline sprite.\\n\\n## App shell\\n\\n- Navigation: `Sidebar`, because there are five destinations. On Home, the places panel acts as a second side panel next to the main view (the \\\"side panel plus main view\\\" the concept asks for).\\n- Destinations, in order: Home (home), Search (search), Saved cities (bookmark), Compare (columns-2), Settings (sliders-vertical). No sections.\\n- Shell parts: product name \\\"Animated Weather\\\" in the `TopBar`, plus a search icon button on phones only. There is no `ThemeToggle`, because light or dark follows the scene (day or night at the shown city). There is no account.\\n- At 1280px: a 240px frosted sidebar, and Home shows the 280px places panel next to the main column. At 768px: the sidebar stays, and Home's places become a horizontal strip of chips above the main column (the side panel appears from 1200px). At 360px: the sidebar becomes a drawer opened by the menu button in the top bar, and every screen is a single column.\\n- The scene (`.scene`) is fixed, full screen and behind everything (`z-index: -1`). Every screen has one. Search, Saved cities and Settings show the current city's scene. Compare and the loading, denied and first-visit states show the neutral `calm` scene, so the per-city tints read clearly.\\n\\n## Tokens\\n\\nPaste these blocks into the client's global CSS after the\\n`@corbits/react-ui` theme import. Names match the kit, so `bg-background`,\\n`text-foreground`, `text-muted-foreground`, `border-border`, `bg-primary`\\nand the other kit classes pick them up. The per-scene overrides (the 17 `[data-wx]` sky pairs and accent sets, and the day and night surface sets) live in styles.css under \\\"Sky colours\\\" and \\\"Palettes\\\". Port them as `[data-wx=\\\"…\\\"]` selectors on the app root.\\n\\n```css\\n:root {\\n --background: #eef2f6;\\n --foreground: #121820;\\n --card: rgb(255 255 255 / 0.82);\\n --card-foreground: #121820;\\n --popover: #ffffff;\\n --popover-foreground: #121820;\\n --primary: #2f5b8a;\\n --primary-foreground: #ffffff;\\n --primary-active: #264b72;\\n --secondary: rgb(18 24 32 / 0.06);\\n --secondary-foreground: #121820;\\n --muted: rgb(18 24 32 / 0.06);\\n --muted-foreground: #46505e;\\n --accent: rgb(18 24 32 / 0.08);\\n --accent-foreground: #121820;\\n --destructive: #a8322a;\\n --destructive-foreground: #ffffff;\\n --success: #cfe3d3;\\n --success-foreground: #121820;\\n --border: rgb(18 24 32 / 0.12);\\n --input: #7b8594;\\n --ring: #2f5b8a;\\n --radius: 0.875rem;\\n --radius-sm: calc(var(--radius) - 4px);\\n --radius-lg: calc(var(--radius) + 4px);\\n --shadow-popover: 0 12px 32px rgb(10 20 40 / 0.16), 0 1px 3px rgb(10 20 40 / 0.08);\\n --font-sans: system-ui, -apple-system, \\\"Segoe UI\\\", Roboto, \\\"Helvetica Neue\\\", Arial, sans-serif;\\n --font-mono: \\\"Space Mono\\\", \\\"Fira Code\\\", Monaco, Consolas, monospace;\\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\\n --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);\\n --duration-micro: 150ms;\\n --duration-standard: 200ms;\\n --duration-large: 300ms;\\n color-scheme: light;\\n}\\n.dark {\\n --background: #0b0f24;\\n --foreground: #eef1fa;\\n --card: rgb(18 23 52 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --primary: #a5b4fc;\\n --primary-foreground: #0b1030;\\n --primary-active: #c3cdfd;\\n --secondary: rgb(255 255 255 / 0.1);\\n --secondary-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --destructive: #f08a80;\\n --destructive-foreground: #0b0f24;\\n --success: #2f5a45;\\n --success-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --ring: #a5b4fc;\\n --shadow-popover: 0 12px 32px rgb(0 0 0 / 0.5);\\n color-scheme: dark;\\n}\\n@media (prefers-color-scheme: dark) {\\n :root:not(.dark):not(.light) {\\n --background: #0b0f24;\\n --foreground: #eef1fa;\\n --card: rgb(18 23 52 / 0.74);\\n --card-foreground: #eef1fa;\\n --popover: #171c3d;\\n --popover-foreground: #eef1fa;\\n --primary: #a5b4fc;\\n --primary-foreground: #0b1030;\\n --primary-active: #c3cdfd;\\n --secondary: rgb(255 255 255 / 0.1);\\n --secondary-foreground: #eef1fa;\\n --muted: rgb(255 255 255 / 0.08);\\n --muted-foreground: #b6bdd6;\\n --accent: rgb(255 255 255 / 0.12);\\n --accent-foreground: #eef1fa;\\n --destructive: #f08a80;\\n --destructive-foreground: #0b0f24;\\n --success: #2f5a45;\\n --success-foreground: #eef1fa;\\n --border: rgb(255 255 255 / 0.14);\\n --input: #8b93b3;\\n --ring: #a5b4fc;\\n --shadow-popover: 0 12px 32px rgb(0 0 0 / 0.5);\\n color-scheme: dark;\\n }\\n}\\n```\\n\\n| Role | Token | Tailwind class |\\n| --------------------- | -------------------- | ------------------------------------ |\\n| Page | `--background` | `bg-background` |\\n| Text | `--foreground` | `text-foreground` |\\n| Secondary text | `--muted-foreground` | `text-muted-foreground` |\\n| Divider | `--border` | `border-border` |\\n| Main action | `--primary` | `bg-primary text-primary-foreground` |\\n| Frosted panel | `--card` | `bg-card text-card-foreground` |\\n| Stat tiles, chips | `--muted` | `bg-muted` |\\n| Error notice edge | `--destructive` | `border-destructive` |\\n| Scene sky, top/bottom | `--sky-a`, `--sky-b` | custom (scene only) |\\n| Chance of rain | `--pop` | custom |\\n\\nType scale: 12/16 (labels, small), 14/20 (body), 16/24 (hour temps), 18/28 (h2), 20/28 (condition), 30/36 (h1), 48 (compare temp), 64–96 clamp (current temperature, weight 250).\\nSpacing: 4, 8, 12, 16, 24, 32, 48, 64px.\\nRadii, shadows and motion: the `--radius`, `--shadow-*`, `--duration-*` and\\n`--ease-*` tokens above. Scene change: 1200ms fade (`scene-in`).\\n\\n## Components\\n\\nEvery part maps to a `@corbits/react-ui` component from the react-ui skill.\\n\\n| Mockup class | Kit component | Used for |\\n| ----------------------------- | -------------------------------------- | ---------------------------------------------- |\\n| `.app-bar`, `.app-nav` | `TopBar`, `Sidebar`, `SidebarItem` | shell and navigation |\\n| `.button` | `Button` | primary actions (Refresh, Compare, Install) |\\n| `.button-outline` | `Button variant=\\\"outline\\\"` | secondary actions (Save city, Use my location) |\\n| `.button-ghost` | `Button variant=\\\"ghost\\\"` | Edit, remove, icon buttons |\\n| `.input` | `Input` | city search box |\\n| `.card` | `Card` | every frosted panel |\\n| `.stat-grid` | `StatGrid` | feels like, wind, humidity, high/low, rain, sun |\\n| `.unit-toggle .filter-chip` | `FilterBar` / `FilterChip` (aria-pressed) | °C / °F |\\n| `.switch` | `Switch` | Simpler effects |\\n| `.checkbox` | `Checkbox` | pick cities to compare |\\n| `.badge` | `Badge` (default, success, warning) | scene text, Saved, Old, counts |\\n| `.skeleton` | `Skeleton` | loading placeholders |\\n| `.empty` | `EmptyState` | no results, no saved cities, first visit |\\n| `.notice` | `Card` + `Alert` styling | offline, error, location-denied messages |\\n| `.dialog` | `Dialog` | Install app (sheet under 640px) |\\n| `.toast` | `Toast` / `Toaster` | confirmations and Undo |\\n| `.scene` | custom `WeatherScene` (canvas) | full-screen animated background |\\n| `.wx-chip`, `.wx-band`, `.swatch` | custom, tinted by `data-wx` | per-city weather tints |\\n| `.hours`, `.days`, `.place-list`, `.city-list`, `.cmp-grid` | layout lists | forecasts, places, compare grid |\\n\\n## Screens\\n\\n### Home\\n\\n- Route: `/` (mockup `#/home`, plus a state name)\\n- Requirements: IR-1 (home screen), IR-6 (weather data), IR-8 (device location), IR-9 (last weather seen, saved choices); FR-1, FR-6, FR-7, FR-9, FR-12 to FR-16, FR-21 to FR-27, FR-32 to FR-35, FR-37, FR-38.\\n- Purpose: see the weather for where you are, or for the chosen city, at a glance.\\n- Layout, top to bottom: the full-screen `WeatherScene` behind everything. A places panel (`Card`): search entry, \\\"Your places\\\" list with chip, name, condition and temperature, and the \\\"Use my location\\\" button. Main column: header with the city name (h1), \\\"My location\\\", region, local time, the °C/°F toggle and **Refresh** (the primary action). Current conditions `Card`: big temperature, condition, one-line summary, and a `StatGrid` of feels like, wind (speed, direction, gusts), humidity, today's high and low, rain today and sunrise/sunset. Its footer shows the last-updated time, the scene `Badge` (for example \\\"Scene: Moderate rain, day\\\") and the Simpler effects `Switch`. Then the \\\"Next 24 hours\\\" strip (24 items: time, icon, temperature, chance of rain), then \\\"Next 7 days\\\" (day, icon and condition, chance of rain, low, range bar, high).\\n- Shows: temperatures as whole degrees with \\\"°\\\" and a true minus (−3°). Wind in km/h (or mph), rain in mm (or in). Times in 24-hour form at the city's local time. \\\"Updated 2 min ago\\\".\\n- 1280px: places panel on the left (sticky) and forecast column on the right. 768px: places as a horizontal scrolling strip above the column. 360px: single column; the places strip and hourly strip scroll sideways inside their own cards; the stat grid wraps to 2 columns.\\n- States:\\n - populated (`#/home`): London, my location, moderate rain by day, slate blue.\\n - Condition scenes: `#/home/hot` (Perth, hot day, amber), `#/home/cloudy` (Vancouver, overcast night), `#/home/storm` (Darwin, storm night, violet), `#/home/snow` (Oslo, heavy snow by day), `#/home/wind` (Cape Town, windy day, teal), `#/home/fog` (Milan, fog by day, grey), `#/home/night` (Sydney, clear night, deep indigo). Settings shows all 16 day and night swatches.\\n - preview (`#/home/preview`): a search result that isn't saved yet (Springfield, light snow at night). A notice and a **Save city** button sit next to Refresh.\\n - loading (`#/home/loading`): calm background; skeletons where the numbers go; Refresh disabled and reading \\\"Loading…\\\". Numbers always come before the scene (FR-32).\\n - offline (`#/home/offline`): a notice reading \\\"You're offline.\\\" plus the age of the saved weather. \\\"Last updated today at 07:40\\\" and an \\\"Old: 3 hours\\\" warning badge. The figures stay visible. A city never seen before shows \\\"No saved weather for this city yet. Connect to the internet to see it.\\\"\\n - error (`#/home/error`): a red-edged notice, \\\"We couldn't get fresh weather.\\\", with a **Try again** button. The older figures stay visible and are labelled \\\"Last updated 25 min ago\\\".\\n - denied (`#/home/denied`): calm background; a \\\"Location is turned off for this app.\\\" notice with **Search for a city** and Try again; the saved places as a grid.\\n - empty (`#/home/empty`), first visit with location declined and no saved cities: calm background; \\\"Find your weather\\\", **Search for a city**, and \\\"Use my location instead\\\".\\n- Test ids: `home-title`, `home-local-time`, `home-refresh-button`, `home-celsius-button`, `home-fahrenheit-button`, `home-temperature-count`, `home-feels-count`, `home-wind-count`, `home-humidity-count`, `home-high-low-count`, `home-rain-count`, `home-sun-count`, `home-updated-count`, `home-scene-badge`, `home-effects-checkbox`, `home-hours-list` (24 `li` rows), `home-days-list` (7 `li` rows), `home-search-link`, `home-edit-link`, `home-places-list`, `home-place-row`, `home-locate-button`, `home-save-button`, `home-preview-notice`, `home-offline-notice`, `home-old-badge`, `home-error-notice`, `home-retry-button`, `home-denied-notice`, `home-denied-search-link`, `home-denied-retry-button`, `home-empty-search-link`, `home-empty-locate-button`; states `home-populated`, `home-hot`, `home-cloudy`, `home-storm`, `home-snow`, `home-wind`, `home-fog`, `home-night`, `home-preview`, `home-loading`, `home-offline`, `home-error`, `home-denied`, `home-empty`.\\n\\n### Search\\n\\n- Route: `/search` (mockup `#/search`)\\n- Requirements: IR-2 (search with no-results and error states), IR-7 (city lookup); FR-2, FR-3, FR-8, FR-36.\\n- Purpose: find a city by name, tell same-named places apart, and open or save one.\\n- Layout, top to bottom: title \\\"Search\\\", then a `Card` with the `Input` (search type, autofocus) and a \\\"My location\\\" button. Then a results `Card`: a count line, then rows with weather chip, **name**, region and country, current temperature and condition, and a **Save** button (or a \\\"Saved\\\" badge if the city is already saved, FR-8). Tapping the row opens the city on Home (preview state).\\n- Shows: name, region, country (from the city lookup), and a temperature and condition for each result.\\n- 1280px and 768px: a 760px centred column. 360px: the same column; the temperature and condition move under the region.\\n- States:\\n - populated: \\\"Springfield\\\" with 6 results.\\n - empty (`#/search/empty`), no results: \\\"No results for “qzxwv”\\\" and \\\"Check the spelling, or try a larger city nearby. Small villages often appear under their nearest town.\\\" with Clear search.\\n - loading (`#/search/loading`): 4 skeleton rows while typing (\\\"Lisb\\\").\\n - error (`#/search/error`): \\\"Search isn't working right now.\\\" with Try again.\\n- Test ids: `search-title`, `search-input`, `search-locate-button`, `search-results-list`, `search-result-link`, `search-save-button`, `search-saved-badge`, `search-no-results`, `search-clear-link`, `search-error-notice`, `search-retry-button`.\\n\\n### Saved cities\\n\\n- Route: `/cities` (mockup `#/cities`)\\n- Requirements: IR-3 (saved list with temperature, condition, remove and compare selection), IR-9 (browser storage); FR-4, FR-5, FR-6, FR-7, NFR-14.\\n- Purpose: manage saved places, jump to one, and pick 2 to 4 to compare.\\n- Layout, top to bottom: title, a hint line, and **Compare 3 cities** (primary, a link to Compare). A count badge (\\\"3 picked · up to 4\\\") and \\\"Add a city\\\". Then the list `Card`, one row per city: compare `Checkbox`, weather chip, name (first row \\\"My location · London\\\"), condition and local time, region, temperature, and remove (trash). The My location row has a re-check location button instead of remove. Below the list, a note that cities are kept only in this browser.\\n- 1280px and 768px: a 760px centred column. 360px: the same rows; the text truncates with an ellipsis and never goes below 12px.\\n- States:\\n - populated: 8 places, 3 ticked.\\n - empty (`#/cities/empty`): \\\"No saved cities yet\\\", with copy and **Search for a city**.\\n- Test ids: `cities-title`, `cities-compare-link`, `cities-selected-count`, `cities-add-link`, `cities-list`, `cities-compare-checkbox`, `cities-city-link`, `cities-remove-button`, `cities-locate-button`, `cities-browser-note`, `cities-empty-card`, `cities-empty-search-link`.\\n\\n### Compare\\n\\n- Route: `/compare` (mockup `#/compare`)\\n- Requirements: IR-4 (side-by-side view of 2 to 4 cities); FR-17 to FR-20.\\n- Purpose: see 2 to 4 places side by side.\\n- Layout, top to bottom: title, the °C/°F toggle, and **Change cities** (primary, back to Saved cities). Then a grid of city `Card`s. Each card has a band tinted to its own weather (scene text and icon), name, region and local time, a large temperature, condition, a 2×2 list (feels like, wind, humidity, high/low), and a 4-step outlook (+1, +3, +6, +9 hours: time, icon, temperature).\\n- 1280px: 4 equal columns. 768px: cards at least 260px wide, scrolling sideways. 360px: one card at 82% of the screen width with the next one peeking, swiped sideways with snapping. This is the only screen that scrolls sideways, and only inside the grid.\\n- States:\\n - populated: London, Perth, Oslo, Sydney.\\n - empty (`#/compare/empty`): \\\"Pick at least two cities\\\" and **Choose cities**.\\n- Test ids: `compare-title`, `compare-celsius-button`, `compare-fahrenheit-button`, `compare-change-link`, `compare-grid`, `compare-card`, `compare-temperature-count`, `compare-empty-card`, `compare-pick-link`.\\n\\n### Settings\\n\\n- Route: `/settings` (mockup `#/settings`)\\n- Requirements: IR-5 (°C/°F, simpler effects, reduced-motion explanation, saved-in-browser note), IR-8, IR-9; FR-10, FR-11, FR-28, FR-30, FR-31, FR-39, NFR-14.\\n- Purpose: set units and effects, and understand motion, location and data.\\n- Layout, top to bottom: title and **Install app** (primary, opens the install `Dialog`). Units card with the toggle. Effects and motion card: the Simpler effects `Switch`, Reduced motion (read-only `Badge`, \\\"Your device: full motion\\\" or \\\"Your device: less motion\\\"), and Automatic smoothness (\\\"Always on\\\"). Location card with Use my location. \\\"Saved in this browser\\\" note. \\\"The scenes\\\" gallery: 16 swatches, each condition by day and by night. Credit line for Open-Meteo.\\n- 1280px and 768px: a 760px column. 360px: each setting's control wraps under its text.\\n- States: populated only.\\n- Test ids: `settings-title`, `settings-install-button`, `settings-celsius-button`, `settings-fahrenheit-button`, `settings-effects-checkbox`, `settings-motion-status`, `settings-locate-button`, `settings-browser-note`, `settings-scene-list`, `install-dialog`, `install-cancel-button`, `install-confirm-button`.\\n\\nShared test ids: `menu-button`, `app-name-link`, `top-search-link`, `nav-home-link`, `nav-search-link`, `nav-cities-link`, `nav-compare-link`, `nav-settings-link`, and toasts `toast-units`, `toast-refreshed`, `toast-locate`, `toast-locate-blocked`, `toast-saved`, `toast-removed`, `toast-undo-button`, `toast-undo`, `toast-retry`, `toast-install`.\\n\\n## Data\\n\\n- IR-6 Weather data: the Open-Meteo forecast service, with no key. One request per place returns current conditions, 24 hourly and 7 daily values, sunrise and sunset, and weather codes. Map each weather code to one of the 7 conditions plus an intensity (light, moderate or heavy). Day or night comes from that city's sunrise and sunset. Reuse responses for about 5 minutes (NFR-13). Refresh on demand, and on returning after 15 minutes or more away.\\n- IR-7 City lookup: Open-Meteo city search, called as the person types (300ms pause), returning name, region (admin1), country and coordinates. The list is kept in the order the service returns.\\n- IR-8 Device location: the browser's location feature. It is asked for only on first open and when the person taps \\\"Use my location\\\". If it's blocked, Home shows the denied state.\\n- IR-9 Browser storage: saved cities (name, region, country, coordinates), unit, simpler-effects choice, and the last weather seen per city with its time. The offline state uses that last weather.\\n\\n## Flows\\n\\n### F1 Check the weather where I am\\n\\n1. Open `/` for the first time; the browser asks for location.\\n2. Allow it: the loading state shows skeletons, the figures fill in, then the scene fades in to match (for example, rain by day).\\n3. Result: Home shows My location with current conditions, 24 hours and 7 days. If location is declined, the denied state appears (or the empty state on a first visit with nothing saved).\\n\\n### F2 Find and save a city\\n\\n1. On `/search`, type \\\"Springfield\\\": 6 results with region and country.\\n2. Tap \\\"Springfield, Illinois\\\": Home opens in the preview state with its scene (light snow, night).\\n3. Tap **Save city**: a toast confirms, and the city joins Your places. A city that's already saved shows \\\"Saved\\\" in search instead of Save.\\n\\n### F3 Compare cities\\n\\n1. On `/cities`, tick 2 to 4 cities. A fifth checkbox is disabled while 4 are ticked.\\n2. Tap **Compare 3 cities**.\\n3. Result: `/compare` shows the cards side by side, each tinted to its weather; on a phone, swipe through them.\\n\\n### F4 Make it calmer\\n\\n1. On `/settings`, turn on Simpler effects (or set reduce motion on the device).\\n2. Result: scenes use fewer particles (or hold still); the figures and layout are unchanged.\\n\\n## Interaction notes\\n\\n- °C/°F: one choice for the whole app (FR-10/11). It changes every temperature, wind (km/h ↔ mph) and rain (mm ↔ in), and is saved. The mockup only flips the pressed state and shows `toast-units`.\\n- Changing city cross-fades the scene for 1200ms with no blank flash (FR-26). The palette tokens change at the same time (300ms colour transition).\\n- Scene rendering: a canvas behind the content. At full quality there are many drops with splashes, snow in depth layers, moving cloud layers, lightning, heat shimmer and rolling mist. Heavier weather means more particles. The canvas measures its frame rate and steps quality down below about 30fps, and back up after it recovers. It stops when the tab is hidden. Under reduced motion the scene is still: sky colours plus static shapes, no lightning. Lightning never flashes more than 3 times a second (NFR-4). The mockup's CSS particles stand in for this. `.scene.lite` shows simpler effects and `.scene.calm` shows reduced motion.\\n- The scene is `aria-hidden`; its meaning is in the \\\"Scene: …\\\" badge. Hour and day rows give chance of rain and highs and lows as text for screen readers. Notices use `role=\\\"status\\\"`, and errors use `role=\\\"alert\\\"`.\\n- Removing a city happens at once, with a toast and **Undo** for 4000ms (no confirm dialog).\\n- Shared loading pattern: skeletons where figures go, with the layout kept. Shared error pattern: a red-edged notice with Try again, and any older data stays visible with its age.\\n- Contrast: every text sits on a frosted `--card` panel (82% white by day, 74% indigo by night), so it meets 4.5:1 over every scene.\\n- Keyboard: tab order runs top bar, sidebar, places panel, header controls, then content. The hourly strip and compare grid are focusable and scroll with the arrow keys.\\n- Install (FR-39): the app ships a web app manifest. \\\"Install app\\\" opens a dialog, and Install triggers the browser's own install prompt.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/1/expect.json b/test/fixtures/pipeline/runs/designer-1/exchanges/1/expect.json new file mode 100644 index 00000000..4f74f7d0 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/1/expect.json @@ -0,0 +1,30 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "write_file", + "read_file", + "edit_file", + "grep", + "check_design", + "file_design_option", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "des_write_css", + "toolName": "write_file", + "isError": false + }, + { + "callId": "des_write_html", + "toolName": "write_file", + "isError": false + }, + { + "callId": "des_write_doc", + "toolName": "write_file", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/designer-1/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/1/response.sse b/test/fixtures/pipeline/runs/designer-1/exchanges/1/response.sse new file mode 100644 index 00000000..598529a8 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"des_check","function":{"name":"check_design","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/2/expect.json b/test/fixtures/pipeline/runs/designer-1/exchanges/2/expect.json new file mode 100644 index 00000000..9b276151 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/2/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "write_file", + "read_file", + "edit_file", + "grep", + "check_design", + "file_design_option", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "des_check", + "toolName": "check_design", + "isError": false, + "contains": "check passed" + } + ] +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/designer-1/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/2/response.sse b/test/fixtures/pipeline/runs/designer-1/exchanges/2/response.sse new file mode 100644 index 00000000..36275604 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/2/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"des_file_option","function":{"name":"file_design_option","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/3/expect.json b/test/fixtures/pipeline/runs/designer-1/exchanges/3/expect.json new file mode 100644 index 00000000..e72ecde8 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/3/expect.json @@ -0,0 +1,21 @@ +{ + "lastMessageRole": "tool", + "requiredTools": [ + "write_file", + "read_file", + "edit_file", + "grep", + "check_design", + "file_design_option", + "mail_reply", + "mail_search" + ], + "toolResults": [ + { + "callId": "des_file_option", + "toolName": "file_design_option", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/3/response-headers.json b/test/fixtures/pipeline/runs/designer-1/exchanges/3/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/3/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/designer-1/exchanges/3/response.sse b/test/fixtures/pipeline/runs/designer-1/exchanges/3/response.sse new file mode 100644 index 00000000..fb66d45b --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/exchanges/3/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"The weather-driven design is filed as this concept's option, ready for you to compare with the others. It passed the automatic check on desktop and phone screen sizes.\n\n- **Colours that follow the weather:** the whole app shifts with the weather and time of day. It's warm amber when it's hot, slate blue for rain and deep indigo at night. Text sits on soft frosted panels so it stays easy to read.\n- **Home:** today's weather, the next 24 hours and the next 7 days, over a full-screen scene. There are scenes for clear, hot, cloudy, rain, storm, snow, wind and fog, each with a day and a night look.\n- **Search:** includes a \"no results\" message with suggestions.\n- **Saved cities:** you tick 2 to 4 cities to compare.\n- **Compare:** shows each city's card tinted to its own weather.\n- **Settings:** °C/°F, simpler effects, an explanation of reduced motion, and a note that saved cities live only in this browser. It also has a button to install the app.\n- **Other screens:**\n - loading;\n - offline, which shows the last weather marked as 3 hours old;\n - location turned off, which offers search and your saved cities;\n - a welcome screen for a first visit.\n- **Phone and desktop:** on a phone everything is one column. On a desktop there's a menu down the side and a panel of your places next to the main view. No sign-in anywhere.\n\nThe motion in the mockup is a simple stand-in. The real app would show fuller rain, snow, lightning and heat shimmer.\n\nI also replied on the task thread with this summary."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/designer-1/key.json b/test/fixtures/pipeline/runs/designer-1/key.json new file mode 100644 index 00000000..231e98b2 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/key.json @@ -0,0 +1,5 @@ +{ + "role": "designer", + "stage": "design", + "sourceRunId": "run_58cf9e8d726377fd10cf3b6d4c751bbc" +} diff --git a/test/fixtures/pipeline/runs/designer-1/session.json b/test/fixtures/pipeline/runs/designer-1/session.json new file mode 100644 index 00000000..a76185f7 --- /dev/null +++ b/test/fixtures/pipeline/runs/designer-1/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "designer-1", + "role": "designer", + "stage": "design", + "sourceRunId": "run_58cf9e8d726377fd10cf3b6d4c751bbc", + "exchangeCount": 4 +} diff --git a/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/expect.json b/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/expect.json new file mode 100644 index 00000000..413cd1e7 --- /dev/null +++ b/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/expect.json @@ -0,0 +1,6 @@ +{ + "lastMessageRole": "user", + "userTextContains": "\"artifact\":\"engineering_review\"", + "requiredTools": ["mail_search", "mail_reply", "mail_send"], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/response.sse b/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/response.sse new file mode 100644 index 00000000..0f41aaad --- /dev/null +++ b/test/fixtures/pipeline/runs/reviewer-1/exchanges/0/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Engineering review of constraints: All constraints (C-1 through C-14) and non-goals (NG-1 through NG-8) align with the Problem Brief's Open-Meteo proxy, 60fps canvas animation, WCAG AA contrast, and browser-only persistence requirements. Ready for approval."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/reviewer-1/key.json b/test/fixtures/pipeline/runs/reviewer-1/key.json new file mode 100644 index 00000000..b05a18e3 --- /dev/null +++ b/test/fixtures/pipeline/runs/reviewer-1/key.json @@ -0,0 +1,5 @@ +{ + "role": "reviewer", + "stage": "documents", + "sourceRunId": "run_2c1986a2c8dee7a4d192add5f8c5e7b9" +} diff --git a/test/fixtures/pipeline/runs/reviewer-1/session.json b/test/fixtures/pipeline/runs/reviewer-1/session.json new file mode 100644 index 00000000..89bf29c7 --- /dev/null +++ b/test/fixtures/pipeline/runs/reviewer-1/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "reviewer-1", + "role": "reviewer", + "stage": "documents", + "sourceRunId": "run_2c1986a2c8dee7a4d192add5f8c5e7b9", + "exchangeCount": 1 +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/0/expect.json b/test/fixtures/pipeline/runs/writer-1/exchanges/0/expect.json new file mode 100644 index 00000000..0dacc858 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/0/expect.json @@ -0,0 +1,6 @@ +{ + "lastMessageRole": "user", + "userTextContains": "\"artifact\":\"constraints\"", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/writer-1/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/0/response.sse b/test/fixtures/pipeline/runs/writer-1/exchanges/0/response.sse new file mode 100644 index 00000000..d73c6914 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/0/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-1_skill","function":{"name":"read_skill","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"constraints\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/1/expect.json b/test/fixtures/pipeline/runs/writer-1/exchanges/1/expect.json new file mode 100644 index 00000000..83e104f6 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/1/expect.json @@ -0,0 +1,11 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-1_skill", + "toolName": "read_skill", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/writer-1/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/1/response.sse b/test/fixtures/pipeline/runs/writer-1/exchanges/1/response.sse new file mode 100644 index 00000000..a472a971 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-1_write","function":{"name":"write_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"constraints\",\"title\":\"Constraints: Animated Weather App\",\"content\":\"## In short\\n- A **website that works in any browser**, on desktop and phone, and can be added to a home screen.\\n- Runs on **free weather and city-search services**, with **no sign-in** and **no cost to you** to run at first.\\n- **Smooth animation on mid-range phones**, with a reduce-motion option and text that stays readable.\\n- First version covers the standard weather features; **radar, alerts, accounts and native apps wait**.\\n\\n## Solution form\\nA hosted website (the brief: \\\"works in a browser on desktop and phone\\\"). It can be installed to a phone or computer home screen so it feels like an app. Left out: native iPhone/Android apps, because they need app-store accounts, fees and separate builds, and the brief only asks for a browser.\\n\\n## Platforms and devices\\n- Current versions of Chrome, Safari, Firefox and Edge, on desktop and phone (the brief).\\n- Layout adapts from small phone screens to wide desktop screens (the brief's success criteria).\\n- Animations must stay smooth on a mid-range phone, so the visuals are kept light enough for that.\\n\\n## People and scale\\nPeople who check the weather regularly (the brief). No user count was given, so we plan for personal or small-audience use. Nobody needs to create an account or install anything beyond a browser.\\n\\n## Data sources\\n- **Weather:** a free forecast service that needs no account, such as Open-Meteo. It supplies current conditions, hourly and multi-day forecasts, feels-like, wind and humidity (the brief).\\n- **City search:** a free city-lookup service, turning typed names into places.\\n- **Your location:** the browser's own location feature, used only when you allow it.\\n- **Saved data:** your saved cities and °C/°F choice are kept in your own browser on that device. They are not shared between devices.\\n\\n## Privacy and accessibility\\n- No sign-in, and we keep no user database (the brief).\\n- The only location data is your device location, requested with permission, and the city names you search. Both go only to the weather and city-lookup services to fetch results.\\n- If you decline location, the app still works through city search.\\n- The animations follow the device's \\\"reduce motion\\\" setting by switching to calm or still visuals (the brief).\\n- Text and numbers stay readable over animated backgrounds, with enough contrast, and the app can be used with a keyboard and screen reader.\\n\\n## Outside services and accounts\\n- Weather service and city-lookup service: both free, with no account or key from you.\\n- Hosting: a simple, low-cost or free static web host. You may need to create one account for it when we publish.\\n\\n## Budget and timeline\\nNo budget or date was given. We assume the lowest cost: free data services and minimal hosting, so running cost is zero or close to it. Free services have usage limits and can change terms, which is the main risk of this choice.\\n\\n## First version and later\\n**Included:** current conditions; hourly and multi-day forecast; °C/°F toggle; current location; search, save and remove cities; compare two or more saved cities; weather-driven animated look (rain, snow, wind, heat and more).\\n\\n**Later, with reasons:**\\n- Radar maps: heavy to build and need a different data source (the brief).\\n- Severe weather alerts: need reliable official data and care to get right (the brief).\\n- Accounts and syncing across devices: not needed for the first version.\\n- Native apps: see Solution form.\\n\\n## Assumptions\\n- Open-Meteo for weather, as it is free and needs no key; if it falls short we swap to another free service.\\n- A free city-lookup service (Open-Meteo's own city search) so there is one provider.\\n- Low budget, minimal hosting on a free static host.\\n- \\\"Mid-range phone\\\" means a phone from roughly the last four to five years.\\n- Installable to the home screen, but it needs internet for fresh weather; it may show the last data seen when offline.\\n- Accessibility aims at the common WCAG 2.2 AA level for contrast and keyboard use.\\n- Saved cities live only in the browser, so clearing browser data removes them.\\n\\n## Open questions\\nNone. The brief gave no bounds, so everything above is an assumption for you to correct.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/2/expect.json b/test/fixtures/pipeline/runs/writer-1/exchanges/2/expect.json new file mode 100644 index 00000000..e1a48faf --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/2/expect.json @@ -0,0 +1,12 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-1_write", + "toolName": "write_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/writer-1/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-1/exchanges/2/response.sse b/test/fixtures/pipeline/runs/writer-1/exchanges/2/response.sse new file mode 100644 index 00000000..1f07f810 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/exchanges/2/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've written the limits for your weather app. You didn't give any, so I filled them in as my own best guesses. Please correct any that are wrong.\n\n- **How it runs:** It's a website for phone and desktop browsers. You can add it to your home screen.\n- **Cost and data:** It uses free weather and city-search services, so there's no sign-in and almost no running cost.\n- **What's saved:** Your saved cities and °C/°F choice stay in your own browser on that device. They won't carry over to another device.\n- **Location:** The app asks permission before using your location. If you say no, you can still search for cities.\n- **Accessibility:** The app has a reduce-motion option and keeps text readable over the animated backgrounds. It's built to run smoothly on mid-range phones.\n- **Later:** The first version leaves out radar maps, severe weather alerts, accounts and phone apps."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-1/key.json b/test/fixtures/pipeline/runs/writer-1/key.json new file mode 100644 index 00000000..73a612dd --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/key.json @@ -0,0 +1,5 @@ +{ + "role": "writer", + "stage": "documents", + "sourceRunId": "run_2c1986a2c8dee7a4d192add5f8c5e7b9" +} diff --git a/test/fixtures/pipeline/runs/writer-1/session.json b/test/fixtures/pipeline/runs/writer-1/session.json new file mode 100644 index 00000000..7a8c41fc --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-1/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "writer-1", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_2c1986a2c8dee7a4d192add5f8c5e7b9", + "exchangeCount": 3 +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/0/expect.json b/test/fixtures/pipeline/runs/writer-2/exchanges/0/expect.json new file mode 100644 index 00000000..9be78ef9 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/0/expect.json @@ -0,0 +1,6 @@ +{ + "lastMessageRole": "user", + "userTextContains": "\"artifact\":\"approaches\"", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/writer-2/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/0/response.sse b/test/fixtures/pipeline/runs/writer-2/exchanges/0/response.sse new file mode 100644 index 00000000..2cdf16e3 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/0/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-2_skill","function":{"name":"read_skill","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"approaches\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/1/expect.json b/test/fixtures/pipeline/runs/writer-2/exchanges/1/expect.json new file mode 100644 index 00000000..070a9192 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/1/expect.json @@ -0,0 +1,11 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-2_skill", + "toolName": "read_skill", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/writer-2/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/1/response.sse b/test/fixtures/pipeline/runs/writer-2/exchanges/1/response.sse new file mode 100644 index 00000000..c2056e89 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-2_write","function":{"name":"write_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"approaches\",\"title\":\"Approaches: Animated Weather App\",\"content\":\"## In short\\n- **A. Light and simple:** the screen is animated with built-in browser effects; saved cities stay in your browser.\\n- **B. Rich particle scenes:** a graphics engine draws realistic rain, snow and wind; saved cities stay in your browser.\\n- **C. Hybrid with a small server:** light effects for most of the screen, a graphics layer only where it counts, and a small server that stores cities and caches weather.\\n- **Recommended: A with a small upgrade** (see Recommendation). It fits the free, no-sign-in, mid-range-phone bounds best. The deciding trade-off is **richness of the animation versus smoothness on phones and cost**.\\n\\n## Approach A: Light and simple (browser effects, browser storage)\\n\\n### How it works\\n- The background is a gradient that changes with conditions (blue sky, grey storm, warm haze at night, and so on).\\n- Weather motion is built from lightweight browser animation: falling streaks for rain, drifting flakes for snow, sliding clouds and bending shapes for wind, a wavy shimmer for heat.\\n- The app asks the free weather service for the data and the free city search for places. Saved cities and your °C/°F choice are kept in the browser on that device (Constraints: Saved data).\\n- Hosting is a free static website host. There is no server of ours.\\n\\n### Fit against the brief\\n- Rain, snow, wind and heat visibly differ (success criteria 1 and 2). Effects are stylised rather than photo-real.\\n- Meets °C/°F, saved cities that persist, comparing cities, and phone plus desktop layouts.\\n- Meets \\\"smooth on mid-range phones\\\", \\\"reduce motion\\\", \\\"no sign-in\\\" and \\\"zero running cost\\\".\\n\\n### Trade-offs\\n- Good: very smooth and battery-friendly, quick to build, free to run, easy to keep readable.\\n- Gives up: the look is flatter than a game-style scene, and the number of moving pieces is limited (a few dozen, not thousands).\\n- Saved cities do not follow you between devices, and clearing browser data removes them (already accepted in Constraints).\\n\\n### Risks\\n- It may feel \\\"nice but plain\\\". You would notice this at the first preview; we can then move to C.\\n- Free weather and city services could change limits or terms; we would notice failed loads and swap providers.\\n\\n### Effort and cost\\n- Agent build time: about 3–5 hours, low inference cost (a few dollars at most).\\n- Your time: about two short reviews, plus creating one free hosting account at publishing.\\n- Running cost: zero or near zero.\\n\\n## Approach B: Rich particle scenes (graphics engine, browser storage)\\n\\n### How it works\\n- A graphics layer draws thousands of individual raindrops, snowflakes and wind-blown streaks, with depth, splashes, lightning flashes and moving cloud layers.\\n- Data and storage are the same as A: free services, cities saved in the browser, free static host.\\n\\n### Fit against the brief\\n- Strongest match for \\\"the screen tells you the weather\\\" and for memorability.\\n- Pushes against \\\"smooth on mid-range phones\\\" and battery use. It needs a calmer fallback on weaker devices.\\n- Reduce-motion and readability are still achievable, but need more work to keep text clear over busy scenes.\\n\\n### Trade-offs\\n- Good: the most impressive, truly realistic look.\\n- Gives up: more build time, more testing across four browsers and many phones, higher battery drain, and more things that can break.\\n\\n### Risks\\n- Stutter or heat on older phones. We would notice in phone testing and would have to reduce the effects, which erodes the benefit.\\n- Larger download slows first open on mobile data.\\n\\n### Effort and cost\\n- Agent build time: about 10–16 hours, moderate inference cost (roughly several times A).\\n- Your time: three or four reviews, because look-and-feel needs more tuning.\\n- Running cost: zero or near zero.\\n\\n## Approach C: Hybrid with a small server\\n\\n### How it works\\n- Most of the screen uses the light effects from A. A graphics layer is used only for one or two showpiece effects, such as rain with splashes or a snowfall, and turns itself off on slow phones.\\n- A small server of ours stores saved cities and settings against a device code (no sign-in), and keeps a short-term copy of weather so repeat requests do not hit the free service every time.\\n\\n### Fit against the brief\\n- Good match for visuals and smoothness together.\\n- The server adds saved cities that survive clearing the browser, and could later help with sync across devices. The brief does not ask for this.\\n- It relaxes two bounds: \\\"we keep no user database\\\" (Privacy) and \\\"free hosting with no server\\\" (Outside services). We would need a hosted server, which means an extra account and likely a small monthly fee.\\n\\n### Trade-offs\\n- Good: protects the free weather service from overuse, and keeps cities safe.\\n- Gives up: more to build, secure and maintain; the privacy promise becomes weaker because we hold some data; and the app stops working fully if our server goes down.\\n\\n### Risks\\n- The server becomes a cost and upkeep burden for a personal-scale app. We would notice in bills and outages.\\n- Device-code storage can be lost or confused, so it does not fully solve \\\"my cities disappeared\\\".\\n\\n### Effort and cost\\n- Agent build time: about 12–20 hours, moderate-to-high inference cost.\\n- Your time: three or four reviews, plus setting up the server host account and payment.\\n- Running cost: roughly a few dollars a month, possibly free at tiny scale.\\n\\n## Side by side\\n\\n| | A. Light and simple | B. Rich particle scenes | C. Hybrid with server |\\n|---|---|---|---|\\n| Fit with the brief | Good, stylised look | Best look, weaker on phones | Good look, extra features not asked for |\\n| Effort to build | About 3–5 hours | About 10–16 hours | About 12–20 hours |\\n| Risk | Low; may feel plain | Medium-high; phone slowdowns | Medium; server upkeep |\\n| Cost to run | Zero or near zero | Zero or near zero | A few dollars a month |\\n| Phone smoothness | Excellent | Needs fallback | Good |\\n| Where cities live | Your browser | Your browser | Our server |\\n| What it rules out | Game-like realism | Older phones at full effect | Free, no-account hosting; the no-database promise |\\n\\n## Recommendation\\nPick **Approach A**, and keep one or two eye-catching effects (rain and snow) a little richer, so it feels alive without hurting phones. It fits every bound as written, is cheapest and quickest, and can grow into B or C later if you want more.\\n\\n## Assumptions\\n- Time estimates are for the agent's build work and rough; they exclude your review waiting time.\\n- \\\"A few dollars a month\\\" for a server is an estimate with no provider chosen.\\n- Saved cities stay in the browser only, as in the constraints. Please confirm that losing them when browser data is cleared is acceptable.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/2/expect.json b/test/fixtures/pipeline/runs/writer-2/exchanges/2/expect.json new file mode 100644 index 00000000..17f4b9eb --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/2/expect.json @@ -0,0 +1,12 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-2_write", + "toolName": "write_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/writer-2/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-2/exchanges/2/response.sse b/test/fixtures/pipeline/runs/writer-2/exchanges/2/response.sse new file mode 100644 index 00000000..f6effe9a --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/exchanges/2/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've written up three ways to build the app, with a recommendation.\n\n- **A. Light and simple:** The screen is animated with built-in browser effects, and your saved cities stay in your browser. It would take about 3–5 hours to build and cost nothing to run. It runs smoothly on phones, but the look is stylised rather than realistic.\n- **B. Rich, realistic scenes:** It draws thousands of raindrops and snowflakes, and it looks the most impressive. It would take about 10–16 hours and may stutter or drain the battery on older phones.\n- **C. Mix of both, plus a small server:** It keeps your cities on a server and eases the load on the free weather service. It would take about 12–20 hours and cost a few dollars a month. It also means we store some of your data, which goes against the \"no accounts, nothing stored\" idea.\n\nI recommend **A**, with rain and snow made a bit richer. It stays free, works well on phones, and fits what you asked for. You can move to B or C later if you want more.\n\nPlease confirm one thing. With A, saved cities disappear if you clear your browser data, and they don't carry over between devices. Is that acceptable?"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-2/key.json b/test/fixtures/pipeline/runs/writer-2/key.json new file mode 100644 index 00000000..3fc320d2 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/key.json @@ -0,0 +1,5 @@ +{ + "role": "writer", + "stage": "documents", + "sourceRunId": "run_3433413723e3f2cad30adc8afe6d89ea" +} diff --git a/test/fixtures/pipeline/runs/writer-2/session.json b/test/fixtures/pipeline/runs/writer-2/session.json new file mode 100644 index 00000000..ec6c2e9d --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-2/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "writer-2", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_3433413723e3f2cad30adc8afe6d89ea", + "exchangeCount": 3 +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/0/expect.json b/test/fixtures/pipeline/runs/writer-3/exchanges/0/expect.json new file mode 100644 index 00000000..da7f7c4c --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/0/expect.json @@ -0,0 +1,6 @@ +{ + "lastMessageRole": "user", + "userTextContains": "\"replaces\":", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/writer-3/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/0/response.sse b/test/fixtures/pipeline/runs/writer-3/exchanges/0/response.sse new file mode 100644 index 00000000..15da20ff --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/0/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-3_skill","function":{"name":"read_skill","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"approaches\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/1/expect.json b/test/fixtures/pipeline/runs/writer-3/exchanges/1/expect.json new file mode 100644 index 00000000..8b8de1c3 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/1/expect.json @@ -0,0 +1,11 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-3_skill", + "toolName": "read_skill", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/writer-3/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/1/response.sse b/test/fixtures/pipeline/runs/writer-3/exchanges/1/response.sse new file mode 100644 index 00000000..75712632 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-3_write","function":{"name":"write_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"approaches\",\"title\":\"Approaches: Animated Weather App\",\"content\":\"## In short\\n- **A. Light and simple:** built-in browser effects; cities saved in your browser. Set aside.\\n- **B. Rich particle scenes:** a graphics engine draws realistic rain, snow and wind with thousands of pieces; cities saved in your browser. **Chosen by you.**\\n- **C. Hybrid with a small server:** light effects plus a few graphics showpieces, and a small server of ours. Set aside.\\n- The deciding trade-off: **the most realistic, memorable look versus smoothness on phones and extra build effort**. We reduce that risk with the safeguards listed under B.\\n\\n## Chosen approach: B. Rich particle scenes\\nYou chose B, the realistic scenes with thousands of moving particles, because you want the screen itself to make the weather feel real and memorable. We accept the longer build and the extra care needed on phones. To keep the app smooth and safe to use, B includes the safeguards below. Cities stay in your browser, with no accounts.\\n\\n## Approach A: Light and simple (browser effects, browser storage)\\n*Set aside: it is the quickest and smoothest, but its look is stylised and flatter than the realistic feel you chose. It remains the fallback if B proves too heavy.*\\n\\n### How it works\\n- The background is a gradient that changes with conditions (blue sky, grey storm, warm haze at night, and so on).\\n- Weather motion uses lightweight browser animation: falling streaks for rain, drifting flakes for snow, sliding clouds and bending shapes for wind, a wavy shimmer for heat.\\n- The app asks the free weather service for data and the free city search for places. Saved cities and your °C/°F choice are kept in the browser on that device (Constraints: Saved data).\\n- Hosting is a free static website host. There is no server of ours.\\n\\n### Fit against the brief\\n- Rain, snow, wind and heat visibly differ (success criteria 1 and 2), but stylised rather than photo-real.\\n- Meets °C/°F, saved cities that persist, comparing cities, and phone plus desktop layouts.\\n- Meets \\\"smooth on mid-range phones\\\", \\\"reduce motion\\\", \\\"no sign-in\\\" and \\\"zero running cost\\\".\\n\\n### Trade-offs\\n- Good: very smooth and battery-friendly, quick to build, free to run, easy to keep readable.\\n- Gives up: a flatter look than a game-style scene, and only a few dozen moving pieces, not thousands.\\n- Saved cities do not follow you between devices, and clearing browser data removes them.\\n\\n### Risks\\n- It may feel \\\"nice but plain\\\". You would notice this at the first preview.\\n- Free weather and city services could change limits or terms; we would notice failed loads and swap providers.\\n\\n### Effort and cost\\n- Agent build time: about 3–5 hours, low inference cost (a few dollars at most).\\n- Your time: about two short reviews, plus creating one free hosting account at publishing.\\n- Running cost: zero or near zero.\\n\\n## Approach B: Rich particle scenes (graphics engine, browser storage) — chosen\\n\\n### How it works\\n- A graphics layer draws thousands of individual raindrops, snowflakes and wind-blown streaks, with depth, splashes, lightning flashes and moving cloud layers.\\n- Data and storage: free weather and city services, cities saved in the browser, free static host. No accounts and no server of ours.\\n\\n### Fit against the brief\\n- Strongest match for \\\"the screen tells you the weather\\\" and for memorability.\\n- Pushes against \\\"smooth on mid-range phones\\\" and battery use, so it needs the safeguards below.\\n- Reduce-motion and readable text are still achievable, with extra work to keep text clear over busy scenes.\\n\\n### Safeguards built in (how B's risks are handled)\\n- **Automatic quality scaling:** the app watches how smoothly it is running on your device and lowers the number of particles and effects if it struggles. Weaker phones get a lighter version; strong devices get the full show.\\n- **Pauses when the tab is hidden:** when you switch tabs or lock the phone, animation stops, saving battery and heat.\\n- **Reduce-motion fallback:** if your device asks for less motion, the scenes become calm or still, with the weather shown through colour and gentle visuals (Constraints: Privacy and accessibility).\\n- **Simpler-effects setting:** a switch in the app lets you choose lighter effects yourself, whatever your device.\\n- **Cities stay in your browser:** no accounts and no server. Nothing about you is stored by us, and running cost stays near zero.\\n- Text sits on soft panels over the scenes so it stays readable.\\n\\n### Trade-offs\\n- Good: the most impressive, truly realistic look.\\n- Gives up: more build time, more testing across four browsers and many phones, higher battery use at full effect, and more things that can break.\\n- Saved cities do not follow you between devices, and clearing browser data removes them.\\n\\n### Risks\\n- Stutter or heat on older phones. Quality scaling and the simpler-effects setting are the answer; we would notice in phone testing. On the weakest devices the look will be reduced.\\n- Larger download slows first open on mobile data. We would keep scenes lean and load them after the weather data appears.\\n- Text hard to read over busy scenes; checked in review.\\n\\n### Effort and cost\\n- Agent build time: about 10–16 hours, moderate inference cost (roughly several times A).\\n- Your time: three or four reviews, because look and feel needs tuning.\\n- Running cost: zero or near zero.\\n\\n## Approach C: Hybrid with a small server\\n*Set aside: it adds a server, a monthly cost and weaker privacy for features the brief did not ask for.*\\n\\n### How it works\\n- Most of the screen uses the light effects from A. A graphics layer is used only for one or two showpiece effects, such as rain with splashes or snowfall, and turns itself off on slow phones.\\n- A small server of ours stores saved cities and settings against a device code (no sign-in), and keeps a short-term copy of weather so repeat requests do not hit the free service every time.\\n\\n### Fit against the brief\\n- Good match for visuals and smoothness together.\\n- Saved cities could survive clearing the browser, and sync across devices could come later. The brief does not ask for this.\\n- It relaxes two bounds: \\\"we keep no user database\\\" (Privacy) and \\\"free hosting with no server\\\" (Outside services). It needs a hosted server, an extra account and likely a small monthly fee.\\n\\n### Trade-offs\\n- Good: protects the free weather service from overuse, and keeps cities safe.\\n- Gives up: more to build, secure and maintain; a weaker privacy promise; and the app stops working fully if our server goes down.\\n\\n### Risks\\n- The server becomes a cost and upkeep burden for a personal-scale app. We would notice in bills and outages.\\n- Device-code storage can be lost or confused, so it does not fully solve \\\"my cities disappeared\\\".\\n\\n### Effort and cost\\n- Agent build time: about 12–20 hours, moderate-to-high inference cost.\\n- Your time: three or four reviews, plus setting up the server host account and payment.\\n- Running cost: roughly a few dollars a month, possibly free at tiny scale.\\n\\n## Side by side\\n\\n| | A. Light and simple | B. Rich particle scenes (chosen) | C. Hybrid with server |\\n|---|---|---|---|\\n| Fit with the brief | Good, stylised look | Best look; phone smoothness handled by safeguards | Good look, extra features not asked for |\\n| Effort to build | About 3–5 hours | About 10–16 hours | About 12–20 hours |\\n| Risk | Low; may feel plain | Medium-high; phone slowdowns, reduced by scaling and simpler setting | Medium; server upkeep |\\n| Cost to run | Zero or near zero | Zero or near zero | A few dollars a month |\\n| Phone smoothness | Excellent | Adapts to the device | Good |\\n| Where cities live | Your browser | Your browser | Our server |\\n| What it rules out | Game-like realism | Full effects on older phones | Free, no-account hosting; the no-database promise |\\n\\n## Recommendation\\nGo with **Approach B**, as you chose: it delivers the realistic, memorable look you want. The safeguards (automatic quality scaling, pausing when hidden, reduce-motion, a simpler-effects setting and browser-only saved cities) keep it within the free, no-sign-in bounds.\\n\\n## Assumptions\\n- Time estimates are for the agent's build work and rough; they exclude your review waiting time.\\n- \\\"A few dollars a month\\\" for a server is an estimate with no provider chosen.\\n- Saved cities stay in the browser only. Please confirm that losing them when browser data is cleared is acceptable.\\n- On older or weaker phones, the automatic lighter version is acceptable, even though it looks less rich than on a strong device.\\n- If testing shows B cannot be made smooth enough, we would come back to you before falling back to A's lighter look.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/2/expect.json b/test/fixtures/pipeline/runs/writer-3/exchanges/2/expect.json new file mode 100644 index 00000000..0e4ec204 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/2/expect.json @@ -0,0 +1,12 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-3_write", + "toolName": "write_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/writer-3/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-3/exchanges/2/response.sse b/test/fixtures/pipeline/runs/writer-3/exchanges/2/response.sse new file mode 100644 index 00000000..824a9f4a --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/exchanges/2/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've updated the approaches document to record your choice of the rich, realistic weather scenes. A and C are still in it as alternatives, each with the reason it was set aside.\n\nThe document now lists these safeguards for the realistic scenes:\n- **Automatic quality scaling:** the app lowers the effects by itself if your device struggles.\n- **Pause when hidden:** the animation stops when you switch tabs or lock your phone.\n- **Reduce-motion fallback:** if your device asks for less motion, the scenes become calm or still.\n- **Simpler-effects setting:** you can choose lighter effects yourself.\n- **Cities saved in your browser:** there are no accounts.\n\nThe realistic scenes will take longer to build than the simple version, roughly 10–16 hours of work. Weaker or older phones will get a toned-down look.\n\nPlease confirm two things. Is it fine that saved cities disappear if you clear your browser data? Is a lighter look on older phones acceptable?"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-3/key.json b/test/fixtures/pipeline/runs/writer-3/key.json new file mode 100644 index 00000000..d45c04fd --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/key.json @@ -0,0 +1,5 @@ +{ + "role": "writer", + "stage": "documents", + "sourceRunId": "run_0fbe70fbdcb7174d73f9815d9f5f7e0f" +} diff --git a/test/fixtures/pipeline/runs/writer-3/session.json b/test/fixtures/pipeline/runs/writer-3/session.json new file mode 100644 index 00000000..1963eee9 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-3/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "writer-3", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_0fbe70fbdcb7174d73f9815d9f5f7e0f", + "exchangeCount": 3 +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/0/expect.json b/test/fixtures/pipeline/runs/writer-4/exchanges/0/expect.json new file mode 100644 index 00000000..ad399dfb --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/0/expect.json @@ -0,0 +1,6 @@ +{ + "lastMessageRole": "user", + "userTextContains": "\"artifact\":\"product_requirements\"", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/writer-4/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/0/response.sse b/test/fixtures/pipeline/runs/writer-4/exchanges/0/response.sse new file mode 100644 index 00000000..4d17d9f0 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/0/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-4_skill","function":{"name":"read_skill","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"product-requirements\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/1/expect.json b/test/fixtures/pipeline/runs/writer-4/exchanges/1/expect.json new file mode 100644 index 00000000..7fc9a820 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/1/expect.json @@ -0,0 +1,11 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-4_skill", + "toolName": "read_skill", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/writer-4/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/1/response.sse b/test/fixtures/pipeline/runs/writer-4/exchanges/1/response.sse new file mode 100644 index 00000000..d4a790e0 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-4_write","function":{"name":"write_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"product_requirements\",\"title\":\"Product Requirements: Animated Weather App\",\"content\":\"## In short\\n- The first version is a **browser weather app whose whole screen animates to match the weather**, with location, saved cities, °C/°F, forecasts and side-by-side comparison. It is built the chosen way (Approach B): rich, realistic scenes.\\n- **60 must requirements** and 3 nice ones. Each must has a check in the acceptance criteria.\\n- Decisions to check: forecast covers **the next 24 hours and 7 days**; compare handles **up to 4 cities**; **saved cities are lost if browser data is cleared** and **older phones get a toned-down look** (both accepted by you); when offline we **show the last weather seen, clearly marked as old**.\\n\\n## Purpose\\nPeople who check the weather often should be able to glance at the screen and feel the conditions: rain falling, snow drifting, heat shimmering. Under the scenes sits the usual toolkit: where you are now, the places you care about, your preferred units and a quick comparison. Everything stays in the browser, with no sign-in.\\n\\n## Users\\n- **Regular weather checkers on a phone:** want the weather for where they are the moment they open the app, with smooth motion that does not drain the battery.\\n- **Desktop users:** want the same on a wide screen, and a clear way to compare several cities.\\n- **People who need less motion or have older devices:** want a calm or lighter version that still tells them the weather.\\n- **People using a keyboard or screen reader:** want every number and action reachable without the visuals.\\n\\n## Scope\\nCurrent location; city search; save and remove cities; °C/°F; current conditions; hourly and multi-day forecast; comparing cities; animated scenes for clear/hot, cloudy, rain, storm, snow, wind, fog, each by day and night; the loading, offline, permission-denied and no-results states; accessibility; home-screen install. Phone and desktop in current Chrome, Safari, Firefox and Edge.\\n\\n## Non-goals\\n- **Radar maps:** heavy to build and need another data source (brief).\\n- **Weather alerts:** need reliable official data (brief).\\n- **Accounts and sync across devices:** not needed; cities stay in one browser (you accepted this).\\n- **Native phone apps:** the brief asks only for a browser (constraints).\\n- **Our own server:** nothing about you is stored by us (chosen approach).\\n\\n## Functional requirements\\n\\n**Location and cities**\\n- **FR-1** On first open, the app asks for device location and, if allowed, shows weather for it. Must. Brief success criterion 1.\\n- **FR-2** The person can search for a city by typing, and sees matching places with name, region and country to tell same-named places apart. Must. Brief.\\n- **FR-3** The person can open any search result to see its weather, and save it. Must. Brief.\\n- **FR-4** The person can remove a saved city. Must. Brief.\\n- **FR-5** Saved cities, the unit choice and the simpler-effects choice are still there when the app is reopened in the same browser. Must. Brief; Constraints, saved data.\\n- **FR-6** A saved cities list lets the person switch to a city with one tap or click, showing each city's current temperature and condition. Must. Product judgement.\\n- **FR-7** The person can also use the current location as a place alongside saved cities and re-request it at any time. Must. Product judgement.\\n- **FR-8** The same city cannot be saved twice. Must. Product judgement.\\n- **FR-9** The local time at the chosen city is shown. Nice. Product judgement.\\n\\n**Units**\\n- **FR-10** One °C/°F toggle changes every temperature in the app (current, feels-like, hourly, daily, compare, saved list). Must. Brief.\\n- **FR-11** Wind speed and rain amounts follow the same toggle: km/h and mm with °C, mph and inches with °F. Must. Product judgement.\\n\\n**Weather content**\\n- **FR-12** Current conditions show temperature, feels-like, a plain-words description, wind speed and direction, humidity, and today's high and low. Must. Brief.\\n- **FR-13** An hourly forecast shows the next 24 hours: time, temperature, condition and chance of rain. Must. Brief.\\n- **FR-14** A daily forecast shows 7 days: day, condition, high, low and chance of rain. Must. Brief.\\n- **FR-15** The app shows when the weather was last updated, and refreshes by itself when the person returns after 15 minutes or more away. Must. Product judgement.\\n- **FR-16** A refresh button reloads the weather on demand. Must. Product judgement.\\n\\n**Comparing**\\n- **FR-17** The person can pick 2 to 4 saved cities and see them side by side, each with current conditions, feels-like, wind, humidity, and today's high and low. Must. Brief.\\n- **FR-18** In comparison, each city also shows its condition icon and a short next-hours outlook so differences read at a glance. Must. Product judgement.\\n- **FR-19** On a narrow phone screen the comparison stays readable, by scrolling sideways or stacking, without squeezing text below a legible size. Must. Brief, phone and desktop.\\n- **FR-20** Each compared city's card is tinted to its own weather. Nice. Product judgement.\\n\\n**Animated scenes**\\n- **FR-21** The full-screen background shows a scene matching the current city's conditions: clear/hot, cloudy, rain, storm, snow, wind or fog. Must. Brief.\\n- **FR-22** Every scene has a day and a night version, chosen from sunrise and sunset **at that city**, not the viewer's clock. Must. Brief.\\n- **FR-23** At full quality, scenes use rich realistic motion: many individual raindrops with splashes, drifting snowflakes with depth, wind-blown streaks and bending objects, moving cloud layers, lightning flashes in storms, heat shimmer in hot clear weather, and low rolling mist in fog. Must. Chosen approach B.\\n- **FR-24** Different conditions look clearly different from one another, including at reduced quality. Must. Brief success criterion 2.\\n- **FR-25** Heavier or lighter weather changes the scene's intensity (drizzle versus downpour, flurries versus heavy snow). Must. Product judgement.\\n- **FR-26** When the person changes city, the scene changes to the new weather with a short smooth transition. Must. Product judgement.\\n- **FR-27** Weather text and numbers sit on soft panels over the scene so they stay readable. Must. Chosen approach B.\\n\\n**Keeping it smooth**\\n- **FR-28** The app measures how smoothly it runs and automatically lowers the number of particles and effects if it struggles; it may raise them again if the device recovers. Must. Chosen approach B.\\n- **FR-29** Animation stops when the tab is hidden or the screen is locked, and resumes when the person returns. Must. Chosen approach B.\\n- **FR-30** If the device asks for reduced motion, scenes are calm or still, with the weather shown by colour and gentle visuals, and no falling, flashing or shimmering motion. Must. Brief; chosen approach B.\\n- **FR-31** A visible \\\"simpler effects\\\" switch lets the person choose lighter effects on any device, and the choice is remembered. Must. Chosen approach B.\\n- **FR-32** Scenes load after the weather figures appear, so numbers are never held up by the graphics. Must. Chosen approach B.\\n\\n**States**\\n- **FR-33** While weather loads, the app shows a loading state (placeholders where the numbers will go) rather than a blank or stale screen. Must. Product judgement.\\n- **FR-34** When the device is offline, the app shows the last weather it saved for that city, clearly labelled with its age, and says fresh data needs internet. If there is none, it says so. Must. Constraints, assumptions.\\n- **FR-35** If the person declines or blocks location, the app says so in plain words and offers city search, and shows saved cities if any. Must. Constraints, privacy.\\n- **FR-36** A search with no matches says \\\"no results\\\" and suggests checking the spelling or trying a nearby larger city. Must. Product judgement.\\n- **FR-37** If the weather service fails or is slow, the app shows a plain error with a retry button, keeping any older data visible and labelled. Must. Constraints, free-service risk.\\n- **FR-38** On a first visit with location declined and no saved cities, the home screen shows a friendly prompt to search, with a calm default background. Must. Product judgement.\\n- **FR-39** The app can be added to a phone or computer home screen and opens like an app. Must. Constraints, solution form.\\n\\n## Non-functional requirements\\n- **NFR-1** Text and key numbers meet WCAG 2.2 AA contrast (4.5:1 for normal text) over every scene, day and night. Must. Constraints.\\n- **NFR-2** Every action works with a keyboard alone, with a visible focus marker and a sensible tab order. Must. Constraints.\\n- **NFR-3** A screen reader can read the current conditions, forecasts and comparison, and the scene's meaning is available as text (for example \\\"Heavy rain, night\\\"). Must. Constraints.\\n- **NFR-4** Lightning and flashing effects never flash more than 3 times in any second, and are off under reduced motion. Must. Product judgement (safety).\\n- **NFR-5** At full quality, scenes run at about 30 frames a second or better on a phone from the last four to five years; below that, quality scaling steps down. Must. Constraints, mid-range phone.\\n- **NFR-6** On a typical mobile connection, current figures appear within 3 seconds of opening; scenes may follow after. Must. Chosen approach B risk (download size).\\n- **NFR-7** Hidden-tab animation uses effectively no processing. Must. Chosen approach B.\\n- **NFR-8** The layout works from 320 px wide phones to wide desktop screens, with no sideways scrolling except in comparison. Must. Brief.\\n- **NFR-9** The app works in current Chrome, Safari, Firefox and Edge on desktop and phone. Must. Constraints.\\n- **NFR-10** The app collects nothing about the person. Device location and typed city names go only to the weather and city-lookup services, and nothing else is stored beyond the browser. Must. Constraints, privacy.\\n- **NFR-11** The app needs no account or key from the person and has no running cost at personal scale. Must. Constraints.\\n- **NFR-12** Interface wording is plain and non-technical, including error messages. Must. Person's preference.\\n- **NFR-13** Weather requests are reused for a few minutes (for example when switching between cities) to stay within free-service limits. Nice. Constraints, usage limits.\\n- **NFR-14** An in-app note explains that saved cities live only in this browser and disappear if browser data is cleared. Must. You accepted this trade-off; it should not be a surprise.\\n- **NFR-15** On the weakest devices the scenes may be much simpler, but numbers, forecasts and comparison work the same. Must. You accepted this trade-off.\\n\\n## Interface requirements\\n- **IR-1 Home screen:** the animated scene, city name, current conditions, hourly strip, 7-day list, unit toggle, search entry, refresh and last-updated time. Must. Brief.\\n- **IR-2 Search:** a text box with live results below, and the no-results and error states. Must. Brief.\\n- **IR-3 Saved cities:** a list showing each city's temperature and condition, with remove and \\\"compare\\\" selection. Must. Brief.\\n- **IR-4 Compare:** side-by-side view of 2 to 4 cities. Must. Brief.\\n- **IR-5 Settings:** °C/°F, simpler effects, a reduce-motion explanation, and the saved-in-browser note. Must. Brief; chosen approach B.\\n- **IR-6 Weather data:** Open-Meteo forecast service, with no key, for current, hourly, daily, sunrise/sunset and weather codes. If it falls short, another free service replaces it. Must. Constraints.\\n- **IR-7 City lookup:** Open-Meteo's city search, returning name, region, country and coordinates. Must. Constraints.\\n- **IR-8 Device location:** the browser's location feature, asked only on first open or when the person taps \\\"use my location\\\". Must. Constraints.\\n- **IR-9 Browser storage:** saved cities, unit, simpler-effects choice and last weather seen, kept in the browser on that device. Must. Constraints.\\n\\n## Acceptance criteria\\n- **AC-1** (FR-1, IR-8) Open the app fresh and allow location: weather for that place appears with a matching scene. The prompt is not shown again on later opens unless re-requested.\\n- **AC-2** (FR-2, IR-7) Type \\\"Springfield\\\": several results appear, each with region and country.\\n- **AC-3** (FR-3) Choose a result: its weather opens, and a save action adds it to the saved list.\\n- **AC-4** (FR-4) Remove a saved city: it disappears at once and stays gone after reload.\\n- **AC-5** (FR-5, IR-9) Save two cities, set °F and simpler effects, close and reopen the browser: all are still set.\\n- **AC-6** (FR-6) The saved list shows each city's temperature and condition; tapping a city switches the home screen to it.\\n- **AC-7** (FR-7) With location allowed, \\\"My location\\\" appears alongside saved cities; tapping \\\"use my location\\\" re-checks it.\\n- **AC-8** (FR-8) Trying to save an already saved city does not create a second entry.\\n- **AC-9** (FR-10) Toggle to °F: every temperature on every screen changes, and 0 °C reads 32 °F.\\n- **AC-10** (FR-11) Toggle units: wind switches between km/h and mph and rain between mm and inches.\\n- **AC-11** (FR-12) Current conditions show every listed item for a real city and match Open-Meteo's figures for it.\\n- **AC-12** (FR-13) The hourly strip shows 24 entries starting from the current hour, each with the four listed items.\\n- **AC-13** (FR-14) The daily list shows 7 days with the five listed items.\\n- **AC-14** (FR-15) The last-updated time shows. Leave the tab for 15+ minutes and return: weather refreshes without a tap.\\n- **AC-15** (FR-16) Tapping refresh reloads data and updates the time.\\n- **AC-16** (FR-17, FR-18, IR-4) Select 2, 3 and 4 saved cities: all appear side by side with the listed data, condition icons and outlook; a 5th cannot be selected.\\n- **AC-17** (FR-19, NFR-8) At 360 px wide the comparison is readable with no cut-off text.\\n- **AC-18** (FR-21, FR-24) Force each of the seven conditions with test data: each shows its own scene, and a reviewer shown them unlabelled can tell all seven apart, with the check repeated under simpler effects.\\n- **AC-19** (FR-22) A city in daylight and one at night (for example Sydney and London at the same moment) show day and night scenes respectively, whatever the viewer's clock says.\\n- **AC-20** (FR-23) At full quality on a desktop, rain shows many drops and splashes, snow shows depth, storms show lightning, clear hot weather shows shimmer, wind moves clouds and objects, and fog shows mist.\\n- **AC-21** (FR-25) Light and heavy rain test data look visibly different in density.\\n- **AC-22** (FR-26) Switching between a sunny and a rainy city transitions smoothly with no blank flash.\\n- **AC-23** (FR-27, NFR-1) Contrast of all text over each scene, day and night, measures at least 4.5:1.\\n- **AC-24** (FR-28, NFR-5) On a throttled test device, the app drops its particle count within a few seconds and the frame rate recovers; on a mid-range phone it holds about 30 frames a second.\\n- **AC-25** (FR-29, NFR-7) Switch tabs: animation work stops (shown by the browser's performance tool), and it resumes on return.\\n- **AC-26** (FR-30, NFR-4) With the device's reduce-motion setting on, no scene has falling, flashing or shimmering motion, and the weather is still identifiable. In storm at full quality, lightning never flashes more than 3 times in a second.\\n- **AC-27** (FR-31) Turn on simpler effects: particle numbers visibly drop on a strong device, and the setting persists after reload.\\n- **AC-28** (FR-32, NFR-6) On a throttled mobile connection, figures show within 3 seconds, before the scene finishes loading.\\n- **AC-29** (FR-33) On a slow connection a loading state with placeholders shows, never a blank screen.\\n- **AC-30** (FR-34) Go offline after viewing a city: reopen and see its last weather labelled with its age; an unseen city shows an offline message.\\n- **AC-31** (FR-35, IR-8) Block location: a plain message appears, search is offered, and saved cities still show.\\n- **AC-32** (FR-36) Search \\\"qzxwv\\\": a no-results message with suggestions appears.\\n- **AC-33** (FR-37) With the weather service blocked, a plain error and retry appear, and retry works once the block is lifted.\\n- **AC-34** (FR-38) First visit, location declined, no saved cities: search prompt on a calm background.\\n- **AC-35** (FR-39) The browser offers to install it, and the installed app opens full screen.\\n- **AC-36** (NFR-2, NFR-3) Using only the keyboard, search, save, compare, toggle units and change settings; a screen reader announces conditions, forecast, comparison and scene text.\\n- **AC-37** (NFR-9) Checks AC-1 to AC-9 pass in current Chrome, Safari, Firefox and Edge, on desktop and phone.\\n- **AC-38** (NFR-10, NFR-11, IR-6) Watching network traffic shows requests only to the weather and city-lookup services and the host; no sign-in is requested.\\n- **AC-39** (NFR-12) A reviewer who is not technical reads every message and finds none confusing.\\n- **AC-40** (NFR-14, IR-5) The settings screen shows the note about browser-only saving, plus every listed setting.\\n- **AC-41** (NFR-15) On a deliberately weak test device, the numbers, forecasts and comparison all work with a simpler scene.\\n- **AC-42** (IR-1, IR-2, IR-3) The home, search and saved-cities screens exist with their listed contents.\\n\\n## Assumptions\\n- The forecast covers 24 hours and 7 days, as the brief gave no lengths.\\n- Compare allows up to 4 cities, so columns stay readable on a phone.\\n- Units: °C goes with km/h and mm, °F with mph and inches, from one toggle.\\n- Default unit on first open follows the device's region, then is remembered.\\n- A refresh after 15 minutes away is enough to keep the weather current.\\n- The last weather seen is kept for offline use, and shown with its age.\\n- A \\\"mid-range phone\\\" is one from the last four to five years (constraints).\\n- \\\"Weakest devices\\\" means those where scaling reaches its lowest setting; they may get a still or simple scene.\\n- Heat scene applies to clear weather above about 30 °C; this threshold can be changed.\\n- Weather types such as hail and drizzle map to the nearest of the seven scenes, with intensity adjusted.\\n- Saved cities are lost if browser data is cleared, and older phones get a toned-down look (both accepted by you).\\n\\n## Sources\\nProblem Brief: Animated Weather App; Approaches: Animated Weather App (Approach B chosen); Constraints: Animated Weather App.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/2/expect.json b/test/fixtures/pipeline/runs/writer-4/exchanges/2/expect.json new file mode 100644 index 00000000..00afa35b --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/2/expect.json @@ -0,0 +1,12 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-4_write", + "toolName": "write_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/writer-4/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-4/exchanges/2/response.sse b/test/fixtures/pipeline/runs/writer-4/exchanges/2/response.sse new file mode 100644 index 00000000..57b2b459 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/exchanges/2/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've written the requirements for your weather app: 60 must-haves and 3 nice-to-haves. Each must-have has a plain check for how we'll know it works. The document covers everything you listed, with radar, alerts and accounts left out.\n\nI had to fill some gaps, so please check these:\n- **Forecast length:** the next 24 hours, plus 7 days.\n- **Comparing cities:** up to 4 cities side by side.\n- **Units:** the °C/°F switch also changes wind speed and rain amounts, to km/h and mm or mph and inches.\n- **Offline:** the app shows the last weather it saw, clearly marked with how old it is.\n- **Day and night:** each scene follows daylight in the city you're viewing, not the time on your own clock.\n- **Hot weather:** the heat effect applies to clear weather above about 30 °C.\n- **Saved cities:** a note in the app tells people they're lost if browser data is cleared. Older phones get a simpler look but the same numbers and features."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-4/key.json b/test/fixtures/pipeline/runs/writer-4/key.json new file mode 100644 index 00000000..83dde299 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/key.json @@ -0,0 +1,5 @@ +{ + "role": "writer", + "stage": "documents", + "sourceRunId": "run_f76d5bfdb487b00e753d417c942b721d" +} diff --git a/test/fixtures/pipeline/runs/writer-4/session.json b/test/fixtures/pipeline/runs/writer-4/session.json new file mode 100644 index 00000000..83b059de --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-4/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "writer-4", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_f76d5bfdb487b00e753d417c942b721d", + "exchangeCount": 3 +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/0/expect.json b/test/fixtures/pipeline/runs/writer-5/exchanges/0/expect.json new file mode 100644 index 00000000..2e5873af --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/0/expect.json @@ -0,0 +1,6 @@ +{ + "lastMessageRole": "user", + "userTextContains": "\"artifact\":\"build_plan\"", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [] +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/0/response-headers.json b/test/fixtures/pipeline/runs/writer-5/exchanges/0/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/0/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/0/response.sse b/test/fixtures/pipeline/runs/writer-5/exchanges/0/response.sse new file mode 100644 index 00000000..fe765281 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/0/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-5_skill","function":{"name":"read_skill","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"build-plan\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/1/expect.json b/test/fixtures/pipeline/runs/writer-5/exchanges/1/expect.json new file mode 100644 index 00000000..180178f8 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/1/expect.json @@ -0,0 +1,11 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-5_skill", + "toolName": "read_skill", + "isError": false + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/1/response-headers.json b/test/fixtures/pipeline/runs/writer-5/exchanges/1/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/1/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/1/response.sse b/test/fixtures/pipeline/runs/writer-5/exchanges/1/response.sse new file mode 100644 index 00000000..7fa84c08 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/1/response.sse @@ -0,0 +1,12 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"writer-5_write","function":{"name":"write_artifact","arguments":""}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"kind\":\"build_plan\",\"title\":\"Build Plan: Animated Weather App\",\"content\":\"## In short\\n- **Approach B (rich particle scenes), built on the starter's Bun workspace:** React client, a tiny Hono server that only serves the app, and shared zod schemas. Weather and city data come straight from **Open-Meteo** in the browser. Cities stay **in the browser**.\\n- **8 phases:** shell and theme, data and units, Home figures, search and saved cities, compare, scenes, safeguards and states, install and polish.\\n- **Riskiest part: the full-screen animated canvas on older phones.** Fallback: lower quality steps, then the Simpler effects look, then still shapes. We return to the person before dropping to Approach A.\\n\\n## Approach and stack\\n**Approach B**, as approved in Approaches: realistic canvas scenes with many particles, plus safeguards (quality scaling, pause when hidden, reduce-motion, Simpler effects switch, soft panels). It fits the Constraints: free services, no sign-in, no database of ours, near-zero running cost.\\n\\n**Stack (starter defaults):**\\n- `client/`: Vite, React, React Router, TanStack Query, Tailwind CSS v4, `@corbits/react-ui`, lucide-react.\\n- `server/`: Hono on Bun. It has **no database and no PGlite**: nothing the person enters is stored by us (NFR-10). It serves the built client, the web manifest and a `/health` route. The port comes from the `PORT` environment variable.\\n- `packages/shared`: zod schemas used by the client for Open-Meteo responses, saved cities, settings and the stored weather cache, plus the weather-code mapping.\\n- The design \\\"Adaptive weather-driven\\\" v1 is the visual source: tokens, the `data-wx` palettes, test ids and screen layouts come from `designs/index.html` and `designs/styles.css`.\\n\\n## Components and interfaces\\n\\n**Routes (client):** `/` Home (optionally `?city=` for a preview of an unsaved search result), `/search`, `/cities`, `/compare`, `/settings`. Sidebar nav on all five (drawer under 640px, per design). Use the design's test ids exactly.\\n\\n**Client modules (`client/src/`):**\\n- `app/AppShell.tsx`: `TopBar`, `Sidebar`, `Toaster`, the `data-wx` attribute on the app root, the fixed `WeatherScene` behind content.\\n- `styles/global.css`: design token blocks pasted after the react-ui theme import, then the 17 `[data-wx]` sky and accent sets and day/night surface sets ported from `styles.css`; frosted card blur.\\n- `lib/openMeteo.ts`: `fetchForecast(city)` and `searchCities(q)`; responses parsed with the shared zod schemas.\\n- `lib/weatherMap.ts` (in shared): weather code to one of 7 conditions + intensity (light, moderate, heavy); hot = clear above 30 °C (threshold is one constant); hail and drizzle map to the nearest scene. `sceneKey(city weather)` returns the `data-wx` key using that city's own sunrise and sunset, never the viewer's clock (FR-22).\\n- `lib/units.ts`: one unit setting; °C/km/h/mm vs °F/mph/in; whole degrees with a true minus.\\n- `lib/storage.ts`: localStorage with zod validation: saved cities, unit, simpler effects, last weather per city with time. Corrupt data is dropped, not crashed on.\\n- `lib/useLocation.ts`: browser geolocation; asked on first open and on \\\"Use my location\\\" only.\\n- `hooks/`: `useWeather(city)` (TanStack Query, 5-minute stale time, offline fallback to stored weather), `useCitySearch` (300ms pause), `useSavedCities`, `useSettings`, `useReturnRefresh` (refetch when the tab returns after 15+ minutes).\\n- `scene/WeatherScene.tsx`: one canvas, lazy-loaded (`React.lazy`) so figures never wait on it (FR-32). Files: `engine.ts` (loop, quality level, visibility pause, resize), `quality.ts` (frame-rate monitor), and one effect file per condition (`rain.ts`, `snow.ts`, `wind.ts`, `cloud.ts`, `storm.ts`, `heat.ts`, `fog.ts`), each with day and night colours and an intensity input. Plain-canvas 2D drawing, no engine library, to keep the download small.\\n- `components/`: `StatGrid` usage for conditions, `HourStrip` (24 `li`), `DayList` (7 `li`), `PlacesPanel`, `CityRow`, `CompareCard`, `UnitToggle`, `NoticeCard` (offline, error, denied, preview), `InstallDialog`.\\n- `pages/`: `Home.tsx`, `Search.tsx`, `Cities.tsx`, `Compare.tsx`, `Settings.tsx`.\\n\\n**Server (`server/src/index.ts`):** serves `client/dist`, falls back to `index.html` for client routes, serves `manifest.webmanifest` and `/health`. No API of ours; the browser calls Open-Meteo directly, so traffic is only to the host and the two Open-Meteo services (NFR-10, AC-38).\\n\\n**PWA files (`client/public/`):** `manifest.webmanifest`, icons (192, 512, maskable), and `sw.js` caching the app shell only. Weather data is cached in localStorage, not by the service worker.\\n\\n## Data flow\\n1. **Location:** geolocation gives coordinates; the city name for \\\"My location\\\" comes from the forecast response timezone and a nearest-name lookup through Open-Meteo city search; if none, show \\\"My location\\\".\\n2. **Weather:** one Open-Meteo forecast request per city (current, 24 hourly, 7 daily, sunrise/sunset, weather code, timezone `auto`, units fetched in metric and converted on screen, so toggling units needs no new request). Parsed with zod, cached by TanStack Query for 5 minutes (NFR-13), and the last good result is written to localStorage with its time (IR-9).\\n3. **Scene:** the weather result gives condition, intensity and day/night, which set `data-wx` and the canvas effect. Figures render first; the scene loads after.\\n4. **Search:** typed text, 300ms pause, Open-Meteo geocoding (name, admin1, country, coordinates); order kept as returned. Each result row also loads a small forecast for its temperature and condition.\\n5. **Saved cities and settings:** only in localStorage. Duplicates are identified by rounded coordinates (FR-8).\\n6. **Offline or failure:** if the request fails, show stored weather with its age, and an offline or error notice; if there is none, show the \\\"No saved weather for this city yet\\\" message.\\n7. **Compare selection:** held in the URL (`/compare?c=id,id`) so a reload keeps it; 2 to 4 ids, fifth checkbox disabled.\\n\\n## Phases\\n\\n**Phase 1: Shell, theme, routing.**\\nFiles: `client/src/styles/global.css`, `app/AppShell.tsx`, `pages/*` stubs, `server/src/index.ts`, `client/index.html`.\\nDelivers: layout skeleton, tokens, frosted panels, five routes, sidebar/drawer, responsive layout at 1280/768/360, NFR-8, NFR-2 (focus rings, tab order), IR-1/2/3/4/5 as empty screens, NFR-12 wording style.\\nExit: all five pages open at desktop and phone widths with no sideways scroll; keyboard reaches every nav item.\\n\\n**Phase 2: Data, units, storage.**\\nFiles: `packages/shared/*` schemas and `weatherMap`, `lib/openMeteo.ts`, `lib/units.ts`, `lib/storage.ts`, hooks.\\nDelivers: IR-6, IR-7, IR-9, FR-5, FR-10, FR-11, NFR-13, NFR-11.\\nExit: a console-free dev page prints London's parsed forecast; units convert 0 °C to 32 °F; settings survive reload.\\n\\n**Phase 3: Home figures and states (no scene yet).**\\nFiles: `pages/Home.tsx`, `HourStrip`, `DayList`, `PlacesPanel`, `NoticeCard`, `useLocation`, `useReturnRefresh`.\\nDelivers: FR-1, FR-7, FR-9 (local time), FR-12 to FR-16, FR-33 to FR-38, NFR-3 (row text for rain chance, highs/lows), NFR-6, scene badge text, calm background for loading, denied and empty states.\\nExit: Home shows conditions, 24 hourly rows and 7 daily rows; loading, offline, error, denied, empty and preview states each appear when forced (block network, block location).\\n\\n**Phase 4: Search and saved cities.**\\nFiles: `pages/Search.tsx`, `pages/Cities.tsx`, toast wiring.\\nDelivers: FR-2, FR-3, FR-4, FR-6, FR-7, FR-8, FR-36, IR-2, IR-3, NFR-14 (browser note), Undo toast for 4 seconds.\\nExit: search \\\"Springfield\\\" gives rows with region and country; save, remove with Undo, duplicate blocked; list survives reload.\\n\\n**Phase 5: Compare.**\\nFiles: `pages/Compare.tsx`, `CompareCard`.\\nDelivers: FR-17 to FR-20 (tint ships), IR-4.\\nExit: 2, 3 and 4 cities side by side with +1/+3/+6/+9 hour outlook; fifth blocked; readable at 360px with snap-scrolling inside the grid only.\\n\\n**Phase 6: Animated scenes.**\\nFiles: `scene/*`, `lib/weatherMap.ts` wiring, `data-wx` palette switching.\\nDelivers: FR-21 to FR-27, NFR-1, NFR-4 (lightning capped at 3 flashes a second), full-quality effects per FR-23, intensity levels per FR-25, 1200ms cross-fade (FR-26), `aria-hidden` canvas.\\nExit: a hidden test route `/?force=rain-heavy-night` (any condition, intensity, day/night, also used by checks) shows all 7 conditions by day and night, clearly different; light vs heavy rain differ; city switch fades with no blank flash.\\n\\n**Phase 7: Smoothness safeguards and Settings.**\\nFiles: `scene/quality.ts`, `scene/engine.ts`, `pages/Settings.tsx`.\\nDelivers: FR-28 (3 quality levels plus a still level; step down when frame rate stays under about 30fps for 2 seconds, step up after 10 seconds of recovery), FR-29/NFR-7 (stop the loop on `visibilitychange`), FR-30 (reduce-motion: still sky plus static shapes, no lightning or shimmer, watched live), FR-31 (Simpler effects switch, saved), NFR-5, NFR-15, IR-5, FR-24 at lowest quality, Settings scene gallery of 16 swatches and Open-Meteo credit.\\nExit: throttled CPU drops the particle count within seconds; hidden tab shows no animation work; reduce-motion makes scenes still.\\n\\n**Phase 8: Install and polish.**\\nFiles: `client/public/manifest.webmanifest`, `sw.js`, `InstallDialog`, icons.\\nDelivers: FR-39, NFR-9 pass across browsers (Safari: no install prompt; the dialog shows \\\"Add to Home Screen\\\" steps there), contrast sweep (NFR-1), plain-wording pass (NFR-12), nice-to-haves FR-9, FR-20, NFR-13 all ship.\\nExit: install prompt appears in Chrome/Edge; every checklist item below passes.\\n\\n## Install and run\\n- `bun install` at the repo root.\\n- `bun run dev` starts client and server for development; the port comes from `PORT`.\\n- `bun run build` builds the client; `bun run start` serves it from the Hono server on `PORT`.\\n- No keys, accounts or environment values are needed.\\n\\n## Test plan\\n- **check-app (starter):** builds and starts the app, opens every page at desktop and phone widths, and fails on errors, failed requests, blank pages or sideways scrolling. Run after every phase. Compare's own inner scroll is fine; the page must not scroll sideways.\\n- **Acceptance checks:** try each criterion below in the running app as a person would, using Chrome's device mode and CPU throttling, offline and network blocking, and the forced-scene route for conditions. Also try Firefox, Safari and Edge for AC-37. No test files are added.\\n- Report results by AC id.\\n\\n## Risks and unknowns\\n- **Older phones stutter.** Fallback: the quality steps and Simpler effects; cap particles per level and device pixel ratio at 2 (1 at the lowest level); draw at lower resolution on weak devices. If still not smooth, tell the person before switching to Approach A's lighter scenes.\\n- **Download size.** Fallback: lazy-load the scene, no graphics library, keep code per effect split so only the needed effect loads.\\n- **Text over busy scenes.** Every text sits on a frosted card; if blur is slow on weak devices, switch to a more opaque card at the lowest quality level.\\n- **Open-Meteo limits or changes.** Fallback: 5-minute reuse, and one file (`openMeteo.ts`) to swap for another free provider. Open-Meteo's free service is for non-commercial use; note this credit to the person.\\n- **Name for \\\"My location\\\".** Open-Meteo has no reverse lookup; the plan uses a nearest-name search and falls back to \\\"My location\\\". This is a decision for the person to confirm.\\n- **Safari and iOS:** no install prompt; location needs a user tap on some versions; test on a real phone if possible.\\n- **Browser data cleared** loses saved cities (accepted, NFR-14).\\n- **Scene check by an unlabelled reviewer (AC-18)** needs a human; the forced-scene route supports it.\\n- No requirement is believed wrong or left unreached.\\n\\n## Assumptions to confirm\\n- No server storage or database; the server only hosts the app.\\n- Weather is fetched in the browser directly from Open-Meteo.\\n- Default unit follows the device region (US, Liberia, Myanmar get °F), then is remembered.\\n- Quality thresholds above (30fps, 2 seconds down, 10 seconds up) are the starting values and can be tuned in review.\\n- Compare selection is kept in the web address.\\n\\n## Acceptance checklist\\n- [ ] AC-1: On a fresh browser, open Home and allow location; weather and a matching scene appear, and the prompt does not return on reload.\\n- [ ] AC-2: On Search, type \\\"Springfield\\\"; several results show, each with region and country.\\n- [ ] AC-3: Open a Search result; Home shows its weather, then tap Save city and it joins the saved list.\\n- [ ] AC-4: On Saved cities, remove a city; it vanishes at once and stays gone after reload.\\n- [ ] AC-5: Save two cities, set °F and Simpler effects, close and reopen the browser; all still set.\\n- [ ] AC-6: Saved list shows each city's temperature and condition; tapping a city shows it on Home.\\n- [ ] AC-7: With location allowed, \\\"My location\\\" sits among saved cities; tapping Use my location re-checks it.\\n- [ ] AC-8: Try saving an already saved city from Search or Home; no second entry appears.\\n- [ ] AC-9: Switch to °F; every temperature on every screen changes and 0 °C reads 32 °F.\\n- [ ] AC-10: Switch units; wind flips km/h and mph and rain flips mm and inches.\\n- [ ] AC-11: On Home, all current-condition items show and match Open-Meteo's figures for the city.\\n- [ ] AC-12: The \\\"Next 24 hours\\\" strip has 24 entries from the current hour, each with time, temperature, condition and chance of rain.\\n- [ ] AC-13: \\\"Next 7 days\\\" lists 7 days with day, condition, high, low and chance of rain.\\n- [ ] AC-14: Last-updated time shows; leave the tab 15+ minutes (or simulate it), return, and the weather refreshes unprompted.\\n- [ ] AC-15: Tap Refresh; data reloads and the updated time changes.\\n- [ ] AC-16: On Saved cities tick 2, 3 and 4 cities and open Compare; all show side by side with data, icons and outlook; the fifth checkbox is disabled.\\n- [ ] AC-17: At 360px wide, Compare is readable with no cut-off text.\\n- [ ] AC-18: Force each of the seven conditions; each scene looks distinct, and an unlabelled reviewer can tell them apart, also with Simpler effects on.\\n- [ ] AC-19: View Sydney and London at the same moment; their day and night scenes follow each city, not the viewer's clock.\\n- [ ] AC-20: At full quality on desktop: rain shows drops and splashes, snow shows depth, storm shows lightning, hot clear shows shimmer, wind moves clouds and objects, fog shows mist.\\n- [ ] AC-21: Force light and heavy rain; their density is clearly different.\\n- [ ] AC-22: Switch between a sunny and a rainy city; the scene fades with no blank flash.\\n- [ ] AC-23: Measure text contrast over every scene, day and night; at least 4.5:1.\\n- [ ] AC-24: Throttle the CPU; the particle count drops within a few seconds and the frame rate recovers; on a mid-range phone it holds about 30fps.\\n- [ ] AC-25: Switch tabs; the performance tool shows animation work stop, and it resumes on return.\\n- [ ] AC-26: With reduce-motion on, no scene falls, flashes or shimmers yet the weather is identifiable; storm at full quality never flashes more than 3 times a second.\\n- [ ] AC-27: Turn on Simpler effects; particles visibly drop and the setting persists after reload.\\n- [ ] AC-28: On a throttled mobile connection, figures show within 3 seconds, before the scene finishes loading.\\n- [ ] AC-29: On a slow connection, Home shows placeholders, never a blank screen.\\n- [ ] AC-30: View a city, go offline, reopen; its last weather shows with its age; an unseen city shows the offline message.\\n- [ ] AC-31: Block location; a plain message appears, search is offered, and saved cities still show.\\n- [ ] AC-32: Search \\\"qzxwv\\\"; a no-results message with suggestions appears.\\n- [ ] AC-33: Block the weather service; a plain error and Try again appear, and retry works once unblocked.\\n- [ ] AC-34: First visit, location declined, nothing saved; Home shows the search prompt on a calm background.\\n- [ ] AC-35: The browser offers install from Settings; the installed app opens full screen.\\n- [ ] AC-36: Using only the keyboard, search, save, compare, toggle units and change settings; a screen reader announces conditions, forecast, comparison and the scene text.\\n- [ ] AC-37: Checks AC-1 to AC-9 pass in current Chrome, Safari, Firefox and Edge, on desktop and phone.\\n- [ ] AC-38: Network traffic shows requests only to the host and Open-Meteo's weather and city services; no sign-in is requested.\\n- [ ] AC-39: A non-technical reviewer reads every message and finds none confusing.\\n- [ ] AC-40: Settings shows the browser-only saving note and every listed setting.\\n- [ ] AC-41: On a deliberately weak test device, numbers, forecasts and Compare all work with a simpler scene.\\n- [ ] AC-42: Home, Search and Saved cities exist with their listed contents.\\n\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/2/expect.json b/test/fixtures/pipeline/runs/writer-5/exchanges/2/expect.json new file mode 100644 index 00000000..af72f6ad --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/2/expect.json @@ -0,0 +1,12 @@ +{ + "lastMessageRole": "tool", + "requiredTools": ["read_skill", "write_artifact"], + "toolResults": [ + { + "callId": "writer-5_write", + "toolName": "write_artifact", + "isError": false, + "keys": ["artifactId", "kind", "version", "sha256"] + } + ] +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/2/response-headers.json b/test/fixtures/pipeline/runs/writer-5/exchanges/2/response-headers.json new file mode 100644 index 00000000..bd635bea --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/2/response-headers.json @@ -0,0 +1,3 @@ +{ + "content-type": "text/event-stream; charset=utf-8" +} diff --git a/test/fixtures/pipeline/runs/writer-5/exchanges/2/response.sse b/test/fixtures/pipeline/runs/writer-5/exchanges/2/response.sse new file mode 100644 index 00000000..cd08b700 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/exchanges/2/response.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"I've written the build plan for your weather app. It follows the rich, realistic weather scenes you chose and the \"Adaptive weather-driven\" design.\n\nThe work is split into eight steps:\n\n1. Layout and look.\n2. Weather data, units and saved choices.\n3. The home screen figures.\n4. Search and saved cities.\n5. Comparing cities.\n6. The animated scenes.\n7. The smoothness safeguards and settings.\n8. Install and final polish.\n\nThe app gets its weather straight from the free Open-Meteo service. Your saved cities stay in your browser, and there's no sign-in. The animations slow themselves down on older phones, and there's a \"simpler effects\" switch. Everything calms down when someone's device asks for less motion.\n\nThe riskiest part is making the animations smooth on older phones. If the safeguards aren't enough, I'll check with you before switching to the lighter look.\n\n**Please confirm:**\n- **\"My location\" name:** Open-Meteo can't turn a position into a place name directly. The app will guess the nearest city and fall back to the plain label \"My location\".\n- **Default units:** °F will be the starting choice only in regions that use it, such as the US. After that, the app remembers whatever you pick."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":60}} + +data: [DONE] + diff --git a/test/fixtures/pipeline/runs/writer-5/key.json b/test/fixtures/pipeline/runs/writer-5/key.json new file mode 100644 index 00000000..f6ef695b --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/key.json @@ -0,0 +1,5 @@ +{ + "role": "writer", + "stage": "documents", + "sourceRunId": "run_e2cac090f6c422535d77e00a38387d62" +} diff --git a/test/fixtures/pipeline/runs/writer-5/session.json b/test/fixtures/pipeline/runs/writer-5/session.json new file mode 100644 index 00000000..c40f4156 --- /dev/null +++ b/test/fixtures/pipeline/runs/writer-5/session.json @@ -0,0 +1,7 @@ +{ + "runKey": "writer-5", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_e2cac090f6c422535d77e00a38387d62", + "exchangeCount": 3 +} diff --git a/test/fixtures/pipeline/scenario.json b/test/fixtures/pipeline/scenario.json new file mode 100644 index 00000000..5c0309e2 --- /dev/null +++ b/test/fixtures/pipeline/scenario.json @@ -0,0 +1,69 @@ +{ + "scenario": "animated-weather-app", + "recordedFromCommit": "d6c6b46", + "runs": [ + { + "runKey": "coordinator-1", + "role": "coordinator", + "stage": "project", + "sourceRunId": "run_29495a64502566c30213588dc8b3fc08", + "exchangeCount": 34 + }, + { + "runKey": "writer-1", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_2c1986a2c8dee7a4d192add5f8c5e7b9", + "exchangeCount": 3 + }, + { + "runKey": "reviewer-1", + "role": "reviewer", + "stage": "documents", + "sourceRunId": "run_2c1986a2c8dee7a4d192add5f8c5e7b9", + "exchangeCount": 1 + }, + { + "runKey": "writer-2", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_3433413723e3f2cad30adc8afe6d89ea", + "exchangeCount": 3 + }, + { + "runKey": "writer-3", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_0fbe70fbdcb7174d73f9815d9f5f7e0f", + "exchangeCount": 3 + }, + { + "runKey": "writer-4", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_f76d5bfdb487b00e753d417c942b721d", + "exchangeCount": 3 + }, + { + "runKey": "designer-1", + "role": "designer", + "stage": "design", + "sourceRunId": "run_58cf9e8d726377fd10cf3b6d4c751bbc", + "exchangeCount": 4 + }, + { + "runKey": "writer-5", + "role": "writer", + "stage": "documents", + "sourceRunId": "run_e2cac090f6c422535d77e00a38387d62", + "exchangeCount": 3 + }, + { + "runKey": "coder-1", + "role": "coder", + "stage": "build", + "sourceRunId": "run_970c695116933ed8147c26bcad9a0a65", + "exchangeCount": 6 + } + ] +}