From de8a98921efba0156d8fd6364cc156592f7c8730 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:08:06 -0600 Subject: [PATCH 01/11] feat(clients): brand-colored activity rows for connector apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool calls from known apps (Gmail, Google Calendar, Google Maps, Uber, Lyft, GitHub, Slack, Spotify, DoorDash) now show the app's colored monogram and a plain sentence ("Searching Gmail · invoices from Acme") instead of the raw MCP server and tool name, on web and mobile. Grouped rows name the apps ("Used Gmail, Uber, and Google Maps"). Matching is by MCP server name, so it works for every provider, and it undoes Hermes's editor__ rename. No logos are bundled. --- .../src/features/threads/thread-work-log.tsx | 35 +++- .../components/chat/TimelineWorkEntryRow.tsx | 54 +++-- packages/client-runtime/package.json | 4 + .../client-runtime/src/appConnectors.test.ts | 83 ++++++++ packages/client-runtime/src/appConnectors.ts | 189 ++++++++++++++++++ .../src/work-log/presentation.test.ts | 76 +++++++ .../src/work-log/presentation.ts | 23 ++- 7 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 packages/client-runtime/src/appConnectors.test.ts create mode 100644 packages/client-runtime/src/appConnectors.ts diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 71944a6fa054f..71ebd769c9df4 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -79,7 +79,9 @@ import { shouldFollowThreadWorkGroupAppend, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; +import type { AppBrand } from "@t3tools/client-runtime/app-connectors"; import { + resolveWorkEntryAppActivity, resolveWorkEntryToolPresentation, toolGroupAction, type ToolGroupSummaryKind, @@ -200,6 +202,24 @@ export function ThreadDisclosureChevron(props: { ); } +/** Brand monogram for tools from a known app (Gmail, Uber, Maps, ...); no logos are bundled. */ +function AppActivityMark({ brand }: { readonly brand: AppBrand }) { + return ( + + 1 ? 7 : 10, lineHeight: 12 }} + > + {brand.mark} + + + ); +} + function ShimmerWorkContent(props: { readonly textClassName?: string; readonly compact?: boolean; @@ -1007,12 +1027,17 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); - const previewText = row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry); + const appActivity = row.groupStandIn ? null : resolveWorkEntryAppActivity(row.workEntry); + const appLabel = appActivity + ? [appActivity.label, appActivity.detail].filter(Boolean).join(" · ") + : null; + const previewText = row.groupStandIn?.label ?? appLabel ?? workEntryRowLabel(row.workEntry); const answerPreview = row.workEntry.questionAnswer ? getQuestionAnswerPreview(row.workEntry.questionAnswer) : null; const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); - const displayText = row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry, expanded); + const displayText = + row.groupStandIn?.label ?? appLabel ?? workEntryRowLabel(row.workEntry, expanded); const isSystemNotice = row.projectedItem.item.type === "system_notice"; const isUsageLimit = row.projectedItem.item.type === "error" && @@ -1058,7 +1083,7 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} > - {row.live && !expanded ? ( + {row.live && !expanded && !appActivity ? ( - {toolIcon ? ( + {appActivity ? ( + + ) : toolIcon ? ( - - + appActivity ? ( + + ) : ( + + + + ) } label={
@@ -561,6 +571,24 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ); } +/** Brand monogram for tools from a known app (Gmail, Uber, Maps, ...); no logos are bundled. */ +function AppActivityMark({ brand }: { brand: AppBrand }) { + return ( + 1 ? 7 : 10, + }} + > + {brand.mark} + + ); +} + function QuestionAnswerHistory({ answer, }: { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ecb9bedad2c2a..9c8ec46ce97f6 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -514,6 +514,10 @@ "./openui-charts": { "types": "./src/openUiCharts.ts", "default": "./src/openUiCharts.ts" + }, + "./app-connectors": { + "types": "./src/appConnectors.ts", + "default": "./src/appConnectors.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/appConnectors.test.ts b/packages/client-runtime/src/appConnectors.test.ts new file mode 100644 index 0000000000000..5a67715665290 --- /dev/null +++ b/packages/client-runtime/src/appConnectors.test.ts @@ -0,0 +1,83 @@ +import * as NodeAssert from "node:assert/strict"; +import { it as test } from "vite-plus/test"; + +import { + brandForServer, + normalizeServerName, + resolveAppActivity, + splitToolName, +} from "./appConnectors.ts"; + +test("server names resolve through harness renames", () => { + NodeAssert.equal(normalizeServerName("editor_gmail_3f2a9c0d"), "gmail"); + NodeAssert.equal(normalizeServerName("mcp__google-maps"), "googlemaps"); + NodeAssert.equal(brandForServer("editor_google_maps_0a1b2c3d")?.id, "google-maps"); + NodeAssert.equal(brandForServer("Uber")?.name, "Uber"); + NodeAssert.equal(brandForServer("t3-code"), null); + NodeAssert.equal(brandForServer("github_enterprise_tools"), null); +}); + +test("tool names split for T3 items and ACP titles", () => { + NodeAssert.deepEqual(splitToolName("gmail.search_threads"), { + server: "gmail", + tool: "search_threads", + }); + NodeAssert.deepEqual(splitToolName("mcp__editor_uber_0a1b2c3d__request_ride"), { + server: "editor_uber_0a1b2c3d", + tool: "request_ride", + }); + NodeAssert.equal(splitToolName("terminal"), null); +}); + +test("activity wording follows the tool and its lifecycle", () => { + const search = resolveAppActivity( + "gmail.search_threads", + { query: "invoices from Acme" }, + "inProgress", + )!; + NodeAssert.equal(search.label, "Searching Gmail"); + NodeAssert.equal(search.detail, "invoices from Acme"); + NodeAssert.equal(search.phase, "running"); + NodeAssert.equal( + resolveAppActivity("gmail.search_threads", {}, "completed")!.label, + "Searched Gmail", + ); + NodeAssert.equal( + resolveAppActivity("uber.estimate_rides", { dropoff: "SFO" }, "completed")!.label, + "Checked Uber prices", + ); + NodeAssert.equal( + resolveAppActivity("editor_uber_0a1b2c3d.request_ride", { dropoff: "SFO" }, "inProgress")! + .label, + "Requesting an Uber", + ); + NodeAssert.equal( + resolveAppActivity("google_maps.directions", { destination: "Ferry Building" }, "completed")! + .detail, + "Ferry Building", + ); + NodeAssert.equal(resolveAppActivity("lyft.unknown_tool", {}, "inProgress")!.label, "Using Lyft"); + NodeAssert.equal( + resolveAppActivity("gmail.search_threads", {}, "failed")!.label, + "Searching Gmail didn't finish", + ); + NodeAssert.equal(resolveAppActivity("terminal", {}, "completed"), null); + NodeAssert.equal(resolveAppActivity("t3-code.html_render", {}, "completed"), null); +}); + +test("only a finished merge plays the merged transition", () => { + const merging = resolveAppActivity( + "github.merge_pull_request", + { pull_number: 128 }, + "inProgress", + )!; + NodeAssert.equal(merging.label, "Merging the pull request"); + NodeAssert.equal(merging.detail, "#128"); + NodeAssert.equal(merging.merged, false); + NodeAssert.equal( + resolveAppActivity("github.merge_pull_request", { pull_number: 128 }, "completed")!.merged, + true, + ); + NodeAssert.equal(resolveAppActivity("github.merge_pull_request", {}, "failed")!.merged, false); + NodeAssert.equal(resolveAppActivity("github.search_issues", {}, "completed")!.merged, false); +}); diff --git a/packages/client-runtime/src/appConnectors.ts b/packages/client-runtime/src/appConnectors.ts new file mode 100644 index 0000000000000..5d8ed0f1789ac --- /dev/null +++ b/packages/client-runtime/src/appConnectors.ts @@ -0,0 +1,189 @@ +/** + * Brands for connector tools: activity rows read "Searching Gmail · invoices" with the app's color, + * matched by MCP server name so every provider gets it. The OpenUI app cards share these brands. + * Names and colors only identify the service; no logos are bundled (cards draw a monogram). + */ + +export type AppBrandId = + | "gmail" + | "google-calendar" + | "google-maps" + | "uber" + | "lyft" + | "github" + | "slack" + | "spotify" + | "doordash"; + +export interface AppBrand { + readonly id: AppBrandId; + readonly name: string; + /** Monogram drawn in place of a logo. */ + readonly mark: string; + /** Card accent: stripe, monogram background, primary button. */ + readonly color: string; + /** Text and icon color drawn on `color`. */ + readonly onColor: string; +} + +export const APP_BRANDS: Readonly> = { + gmail: { id: "gmail", name: "Gmail", mark: "M", color: "#EA4335", onColor: "#FFFFFF" }, + "google-calendar": { + id: "google-calendar", + name: "Google Calendar", + mark: "31", + color: "#1A73E8", + onColor: "#FFFFFF", + }, + "google-maps": { + id: "google-maps", + name: "Google Maps", + mark: "◆", + color: "#188038", + onColor: "#FFFFFF", + }, + uber: { id: "uber", name: "Uber", mark: "U", color: "#000000", onColor: "#FFFFFF" }, + lyft: { id: "lyft", name: "Lyft", mark: "L", color: "#FF00BF", onColor: "#FFFFFF" }, + github: { id: "github", name: "GitHub", mark: "GH", color: "#24292F", onColor: "#FFFFFF" }, + slack: { id: "slack", name: "Slack", mark: "#", color: "#4A154B", onColor: "#FFFFFF" }, + spotify: { id: "spotify", name: "Spotify", mark: "♪", color: "#1DB954", onColor: "#000000" }, + doordash: { id: "doordash", name: "DoorDash", mark: "D", color: "#FF3008", onColor: "#FFFFFF" }, +}; + +/** Server-name aliases after normalization (lowercase, no separators). */ +const SERVER_ALIASES: ReadonlyArray]> = [ + ["gmail", ["gmail", "googlegmail", "gwsgmail", "googlemail"]], + ["google-calendar", ["googlecalendar", "gcal", "calendar", "gwscalendar"]], + ["google-maps", ["googlemaps", "maps", "gmaps"]], + ["uber", ["uber", "uberrides"]], + ["lyft", ["lyft"]], + ["github", ["github", "gh"]], + ["slack", ["slack"]], + ["spotify", ["spotify"]], + ["doordash", ["doordash"]], +]; + +/** + * Normalize an MCP server name, undoing harness renames: Hermes registers editor-supplied + * servers as `editor__<8 hex>`, and ACP titles may carry an `mcp__` prefix. + */ +export function normalizeServerName(server: string): string { + return server + .replace(/^mcp__/, "") + .toLowerCase() + .replace(/[\s-]+/g, "_") + .replace(/^editor_(.+?)_[0-9a-f]{8}$/, "$1") + .replace(/[^a-z0-9]/g, ""); +} + +export function brandForServer(server: string): AppBrand | null { + const key = normalizeServerName(server); + for (const [id, aliases] of SERVER_ALIASES) if (aliases.includes(key)) return APP_BRANDS[id]; + return null; +} + +/** Split a T3 tool name (`server.tool`) or an ACP title (`mcp__server__tool`). */ +export function splitToolName( + toolName: string, +): { readonly server: string; readonly tool: string } | null { + const acp = /^mcp__(.+?)__(.+)$/.exec(toolName); + if (acp) return { server: acp[1]!, tool: acp[2]! }; + const dot = toolName.indexOf("."); + return dot > 0 && dot < toolName.length - 1 + ? { server: toolName.slice(0, dot), tool: toolName.slice(dot + 1) } + : null; +} + +export type AppActivityPhase = "running" | "done" | "failed"; + +export interface AppActivity { + readonly brand: AppBrand; + /** Sentence-case action, tense matched to the phase: "Searching Gmail" / "Searched Gmail". */ + readonly label: string; + /** The most useful argument, e.g. the search query or the destination. */ + readonly detail: string | null; + readonly phase: AppActivityPhase; + /** A finished merge: clients play the one-shot merged transition. */ + readonly merged: boolean; +} + +type Verb = readonly [pattern: RegExp, running: string, done: string]; + +/** First match wins; `{app}` is the brand name. */ +const VERBS: ReadonlyArray = [ + [/merge/, "Merging the pull request", "Merged the pull request"], + [/(estimate|price|quote|fare)/, "Checking {app} prices", "Checked {app} prices"], + [ + /(request|book|order|reserve)_?ride|^ride_request|request_?trip/, + "Requesting an {app}", + "Requested an {app}", + ], + [/(order|checkout|place_order)/, "Placing an order on {app}", "Placed an order on {app}"], + [/(status|track|eta)/, "Checking the {app} status", "Checked the {app} status"], + [/(direction|route|navigate|travel_?time)/, "Getting directions", "Got directions"], + [/(send|reply|draft|compose)/, "Writing in {app}", "Wrote in {app}"], + [/(create|add|schedule|insert)_?(event|meeting)/, "Adding to {app}", "Added to {app}"], + [/(search|find|query|list|lookup|places)/, "Searching {app}", "Searched {app}"], + [/(read|get|fetch|open|view)/, "Reading from {app}", "Read from {app}"], + [/(play|queue)/, "Playing on {app}", "Played on {app}"], +]; + +const DETAIL_KEYS = [ + "query", + "q", + "search", + "destination", + "dropoff", + "to", + "title", + "subject", + "text", + "address", + "name", +]; + +function detailFrom(input: unknown): string | null { + if (input === null || typeof input !== "object" || Array.isArray(input)) return null; + const record = input as Record; + for (const key of DETAIL_KEYS) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim().slice(0, 120); + } + const number = record.number ?? record.pull_number ?? record.pr; + return typeof number === "number" && Number.isSafeInteger(number) ? `#${number}` : null; +} + +/** + * Presentation for one tool call, or null when the server is not a known brand. + * `status` takes T3 tool lifecycle values (`inProgress`, `completed`, `failed`, ...). + */ +export function resolveAppActivity( + toolName: string | undefined, + input: unknown, + status: string | undefined, +): AppActivity | null { + if (!toolName) return null; + const parts = splitToolName(toolName); + const brand = parts ? brandForServer(parts.server) : null; + if (!parts || !brand) return null; + const phase: AppActivityPhase = + status === "failed" || status === "declined" + ? "failed" + : status === "completed" + ? "done" + : "running"; + const tool = parts.tool.toLowerCase(); + const verb = VERBS.find(([pattern]) => pattern.test(tool)); + const [running, done] = verb ? [verb[1], verb[2]] : ["Using {app}", "Used {app}"]; + const named = (template: string) => template.split("{app}").join(brand.name); + return { + brand, + label: + phase === "failed" + ? `${named(running)} didn't finish` + : named(phase === "running" ? running : done), + detail: detailFrom(input), + phase, + merged: phase === "done" && /merge/.test(tool), + }; +} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 28caf074e4fc0..662d9b6909784 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -4,7 +4,9 @@ import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/con import * as DateTime from "effect/DateTime"; import { + resolveWorkEntryAppActivity, resolveWorkEntryToolPresentation, + summarizeToolGroup, toolItemForDisplay, type WorkLogPresentationEntry, workEntryIndicatesToolFailure, @@ -189,3 +191,77 @@ describe("resolveWorkEntryToolPresentation", () => { ).toBeNull(); }); }); + +describe("resolveWorkEntryAppActivity", () => { + it.each([ + [{ label: "mcp__editor_gmail_3f2a9c0d__search_threads" }, "Searching Gmail"], + [ + { + label: "Gmail search", + toolData: { server: "gmail", tool: "search_threads", arguments: { query: "invoices" } }, + }, + "Searching Gmail", + ], + [ + { label: "uber.request_ride", toolLifecycleStatus: "completed" as const }, + "Requested an Uber", + ], + ])("brands connector tools across providers: %o", (fields, label) => { + expect(resolveWorkEntryAppActivity({ ...fields })?.label).toBe(label); + }); + + it("reads the detail from structured arguments and leaves other tools alone", () => { + expect( + resolveWorkEntryAppActivity({ + label: "Gmail search", + toolData: { server: "gmail", tool: "search_threads", arguments: { query: "invoices" } }, + })?.detail, + ).toBe("invoices"); + expect(resolveWorkEntryAppActivity({ label: "T3-code.preview_click" })).toBeNull(); + expect(resolveWorkEntryAppActivity({ label: "Ran command" })).toBeNull(); + }); + + it("reads Hermes connector calls, whose tool name carries the detail after a colon", () => { + const activity = resolveWorkEntryAppActivity({ + label: "search messages", + structuredPayload: { + type: "dynamic_tool", + toolName: "editor_gmail_63ea3519.search_messages: invoices from Acme", + input: { query: "invoices from Acme" }, + } as never, + toolLifecycleStatus: "completed", + }); + expect(activity && [activity.brand.name, activity.label, activity.detail]).toEqual([ + "Gmail", + "Searched Gmail", + "invoices from Acme", + ]); + }); +}); + +describe("summarizeToolGroup", () => { + const sourced = (key: string, name: string) => + ({ + id: key, + createdAt: "2026-11-03T00:00:00.000Z", + label: name, + tone: "tool", + itemType: "dynamic_tool", + toolSource: { key: `mcp:${key}`, name, kind: "integration" }, + }) as unknown as WorkLogPresentationEntry; + + it("names connector apps by brand instead of their renamed MCP servers", () => { + const summary = summarizeToolGroup([ + sourced("editor_gmail_63ea3519", "editor gmail 63ea3519"), + sourced("editor_uber_46f286a1", "editor uber 46f286a1"), + sourced("editor_google_maps_0e4b678a", "editor google maps 0e4b678a"), + ]).summary; + expect(summary).toBe("Used Gmail, Uber, and Google Maps"); + }); + + it("keeps the integration wording for servers that are not known apps", () => { + expect(summarizeToolGroup([sourced("linear", "Linear")]).summary).toBe( + "Used Linear integration", + ); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 1d884f0b54eb9..61752bad3ffd6 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -15,6 +15,7 @@ import { type T3McpToolDefinition, type T3McpToolSummaryAction, } from "@t3tools/shared/t3McpToolPresentation"; +import { brandForServer, resolveAppActivity } from "@t3tools/client-runtime/app-connectors"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -242,6 +243,20 @@ export function resolveWorkEntryToolPresentation( ); } +/** Brand-colored activity for tools from known apps (Gmail, Uber, Maps, ...), or null. */ +export function resolveWorkEntryAppActivity( + entry: Pick< + WorkLogPresentationEntry, + "label" | "toolTitle" | "toolData" | "toolLifecycleStatus" | "structuredPayload" + >, +) { + return resolveAppActivity( + workEntryToolName(entry), + workEntryToolInput(entry), + entry.toolLifecycleStatus, + ); +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -571,14 +586,18 @@ export function summarizeToolGroup(entries: ReadonlyArray label); if (sources.size > 0) { const sourceValues = [...sources.values()]; - const sourceNames = [...new Set(sourceValues.map((source) => source.name))]; + const brands = sourceValues.map((source) => brandForServer(source.name)); + const sourceNames = [ + ...new Set(sourceValues.map((source, index) => brands[index]?.name ?? source.name)), + ]; const formattedNames = sourceNames.length < 2 ? sourceNames[0]! : sourceNames.length === 2 ? sourceNames.join(" and ") : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; - const allIntegrations = sourceValues.every((source) => source.kind === "integration"); + const allIntegrations = + !brands.every(Boolean) && sourceValues.every((source) => source.kind === "integration"); labels.unshift( `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`, ); From 6d811b44604d6135fa236be9e558500887e675cc Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:08:15 -0600 Subject: [PATCH 02/11] feat(clients): OpenUI app cards for rides, mail, routes and pull requests Chart boards (openui-v2) can now hold four read-only app cards drawn in their service's colors: RideOptions (Uber/Lyft, cheapest tagged), MailList (Gmail), Route (Google Maps) and PullRequest (GitHub progress track, conflict count and check chips). Taps stay local to the card; nothing is booked, sent or merged. A merged pull request plays its fill once on insertion and respects reduced motion. The shared inspector rejects incoherent cards (a conflict state without conflicts, a ready PR with failing checks, duplicate ride options) the same way it rejects bad charts, on web and Android. --- apps/mobile/src/native/OpenUiAppCards.tsx | 522 ++++++++++++++++++ apps/mobile/src/native/OpenUiCharts.tsx | 6 +- .../src/components/chat/OpenUiAppCards.tsx | 421 ++++++++++++++ apps/web/src/components/chat/OpenUiCharts.tsx | 7 +- experiments/openui-lab/app-cards.test.ts | 100 ++++ experiments/openui-lab/run-smoke.mjs | 8 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/openUiAppCards.test.ts | 73 +++ packages/client-runtime/src/openUiAppCards.ts | 165 ++++++ packages/client-runtime/src/openUiCharts.ts | 11 +- 10 files changed, 1310 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/native/OpenUiAppCards.tsx create mode 100644 apps/web/src/components/chat/OpenUiAppCards.tsx create mode 100644 experiments/openui-lab/app-cards.test.ts create mode 100644 packages/client-runtime/src/openUiAppCards.test.ts create mode 100644 packages/client-runtime/src/openUiAppCards.ts diff --git a/apps/mobile/src/native/OpenUiAppCards.tsx b/apps/mobile/src/native/OpenUiAppCards.tsx new file mode 100644 index 0000000000000..ba252041bd1fc --- /dev/null +++ b/apps/mobile/src/native/OpenUiAppCards.tsx @@ -0,0 +1,522 @@ +import { defineComponent } from "@openuidev/react-lang"; +import { memo, useState, type ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import Animated, { ReduceMotion, ZoomIn, withDelay, withTiming } from "react-native-reanimated"; +import { z } from "zod/v4"; + +import { APP_BRANDS, type AppBrand } from "@t3tools/client-runtime/app-connectors"; +import { + APP_CARD_DESCRIPTIONS as DESCRIPTIONS, + APP_CARD_LIMITS as LIMITS, + CHECK_STATUS_SYMBOL, + PULL_REQUEST_STATE_COLORS, + cheapestRide, + checkCounts, + pullRequestSteps, + rideReadout, +} from "@t3tools/client-runtime/openui-app-cards"; + +import { AppText } from "../components/ui/AppText"; + +const text = z.string().max(LIMITS.text); +const name = z.string().min(1).max(LIMITS.text); +const minutes = z.number().finite().min(0).max(LIMITS.minutes); + +export const rideOptionsProps = z.object({ + app: z.enum(["uber", "lyft"]), + pickup: name, + dropoff: name, + options: z + .array( + z.object({ + name, + minutes, + price: z.string().min(1).max(LIMITS.price), + seats: z.number().int().min(1).max(20), + }), + ) + .min(1) + .max(LIMITS.rideOptions), +}); +export const mailListProps = z.object({ + query: text, + messages: z + .array( + z.object({ + from: name, + subject: text, + snippet: z.string().max(LIMITS.snippet), + time: text, + unread: z.boolean(), + }), + ) + .max(LIMITS.messages), +}); +export const routeProps = z.object({ + origin: name, + destination: name, + mode: z.enum(["drive", "walk", "transit", "bike"]), + minutes, + distance: text, + steps: z.array(name).max(LIMITS.steps), +}); +export const pullRequestProps = z.object({ + number: z.number().int().min(1), + title: name, + state: z.enum(["open", "conflict", "ready", "merged", "closed"]), + head: name, + base: name, + checks: z + .array(z.object({ name, status: z.enum(["pass", "fail", "running", "skip"]), detail: text })) + .max(LIMITS.checks), + conflicts: z.number().int().min(0).max(10_000), +}); + +/** Brand header shared by every card: monogram, service name, one line of context. */ +function CardShell({ + brand, + subtitle, + accessibilityLabel, + children, +}: { + brand: AppBrand; + subtitle: string; + accessibilityLabel: string; + children: ReactNode; +}) { + return ( + + + + + 1 ? 11 : 15, + fontWeight: "700", + }} + > + {brand.mark} + + + + {brand.name} + + {subtitle} + + + + {children} + + ); +} + +function Tag({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +const RideOptionsCard = memo(function RideOptionsCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS[props.app]; + const [selected, setSelected] = useState(null); + const best = cheapestRide(props.options); + return ( + + {props.options.map((option, index) => { + const active = selected === index; + return ( + setSelected(index)} + className={active ? "rounded-lg bg-subtle" : "rounded-lg"} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 10, + padding: 10, + borderWidth: active ? 2 : 1, + borderColor: active + ? brand.color === "#000000" + ? "#888888" + : brand.color + : "rgba(127,127,127,0.25)", + }} + > + + + {option.name} + {option.name === best ? : null} + + {`${option.minutes} min away · ${option.seats} seats`} + + {option.price} + + ); + })} + + {selected === null + ? `Prices from ${brand.name}. Booking happens in the ${brand.name} app.` + : rideReadout(props.options[selected]!)} + + + ); +}); + +const MailListCard = memo(function MailListCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.gmail; + const [open, setOpen] = useState(null); + const unread = props.messages.filter((message) => message.unread).length; + return ( + + {props.messages.length === 0 ? ( + No messages matched. + ) : null} + {props.messages.map((message, index) => ( + setOpen(open === index ? null : index)} + style={{ flexDirection: "row", gap: 8, paddingVertical: 6 }} + > + + {message.unread ? ( + + ) : null} + + + + + {message.from} + + {message.time} + + + {message.subject} + + + {message.snippet} + + + + ))} + + ); +}); + +const MODE_LABEL = { + drive: "Driving", + walk: "Walking", + transit: "Transit", + bike: "Cycling", +} as const; + +const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { + const brand = APP_BRANDS["google-maps"]; + const [step, setStep] = useState(null); + return ( + + + {`${props.minutes} min`} + {props.distance} + + {props.steps.map((entry, index) => ( + setStep(index)} + style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }} + > + + + + {index + 1} + + + {index < props.steps.length - 1 ? ( + + ) : null} + + + {entry} + + + ))} + + ); +}); + +/** The last step of a merged track grows in once when the card mounts; reduced motion skips it. */ +function finishMerge() { + "worklet"; + return { + initialValues: { transform: [{ scaleX: 0.75 }] }, + animations: { + transform: [ + { + scaleX: withDelay( + 150, + withTiming(1, { duration: 650, reduceMotion: ReduceMotion.System }), + ), + }, + ], + }, + }; +} + +function StepTrack({ + fraction, + color, + animate, +}: { + fraction: number; + color: string; + animate: boolean; +}) { + return ( + + + + ); +} + +const MERGED_PILL_ENTERING = ZoomIn.delay(700).duration(220).reduceMotion(ReduceMotion.System); + +function MergedPill({ color, animate }: { color: string; animate: boolean }) { + return ( + + Merged + + ); +} + +const STATE_LABEL = { + open: "Open", + conflict: "Conflicts", + ready: "Ready to merge", + merged: "Merged", + closed: "Closed", +} as const; + +const PullRequestCard = memo(function PullRequestCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.github; + const stateColor = PULL_REQUEST_STATE_COLORS[props.state]; + const steps = pullRequestSteps(props.state); + const reached = steps.filter((entry) => entry.reached).length; + const counts = checkCounts(props.checks); + const [check, setCheck] = useState(null); + const merged = props.state === "merged"; + return ( + + + + {props.title} + + {merged ? ( + + ) : ( + + )} + + + + {steps.map((entry) => ( + + {entry.name} + + ))} + + {props.state === "conflict" ? ( + {`${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} with ${props.base}`} + ) : null} + {props.checks.length ? ( + + {`Checks · ${counts.pass} passed${counts.fail ? ` · ${counts.fail} failed` : ""}${counts.running ? ` · ${counts.running} running` : ""}${counts.skip ? ` · ${counts.skip} skipped` : ""}`} + + {props.checks.map((entry, index) => { + const color = + entry.status === "pass" + ? "#1A7F37" + : entry.status === "fail" + ? "#CF222E" + : entry.status === "running" + ? "#9A6700" + : "#6E7781"; + return ( + setCheck(check === index ? null : index)} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 4, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 4, + borderWidth: 1, + borderColor: check === index ? color : "rgba(127,127,127,0.3)", + }} + > + + {CHECK_STATUS_SYMBOL[entry.status]} + + {entry.name} + + ); + })} + + {check !== null && props.checks[check]?.detail ? ( + {`${props.checks[check]!.name}: ${props.checks[check]!.detail}`} + ) : null} + + ) : null} + + ); +}); + +export const RideOptions = defineComponent({ + name: "RideOptions", + description: DESCRIPTIONS.RideOptions, + props: rideOptionsProps, + component: RideOptionsCard, +}); +export const MailList = defineComponent({ + name: "MailList", + description: DESCRIPTIONS.MailList, + props: mailListProps, + component: MailListCard, +}); +export const Route = defineComponent({ + name: "Route", + description: DESCRIPTIONS.Route, + props: routeProps, + component: RouteCard, +}); +export const PullRequest = defineComponent({ + name: "PullRequest", + description: DESCRIPTIONS.PullRequest, + props: pullRequestProps, + component: PullRequestCard, +}); + +export const appCardSchemas = { + RideOptions: (value: unknown) => rideOptionsProps.safeParse(value), + MailList: (value: unknown) => mailListProps.safeParse(value), + Route: (value: unknown) => routeProps.safeParse(value), + PullRequest: (value: unknown) => pullRequestProps.safeParse(value), +}; diff --git a/apps/mobile/src/native/OpenUiCharts.tsx b/apps/mobile/src/native/OpenUiCharts.tsx index 89e8da8243822..c6024c62a7e7e 100644 --- a/apps/mobile/src/native/OpenUiCharts.tsx +++ b/apps/mobile/src/native/OpenUiCharts.tsx @@ -27,6 +27,7 @@ import { } from "@t3tools/client-runtime/openui-charts"; import { AppText } from "../components/ui/AppText"; +import { MailList, PullRequest, RideOptions, Route, appCardSchemas } from "./OpenUiAppCards"; // Catalog v2: the shared definition in client-runtime bound to this app's zod and OpenUI. const title = z.string().max(LIMITS.title); @@ -274,7 +275,7 @@ const Board = defineComponent({ props: z.object({ title, summary: z.string().max(LIMITS.summary), - panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref])).min(1).max(LIMITS.panels), + panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref, RideOptions.ref, MailList.ref, Route.ref, PullRequest.ref])).min(1).max(LIMITS.panels), }), component: function NativeBoard({ props, renderNode }) { return ( @@ -289,12 +290,13 @@ const Board = defineComponent({ }, }); -const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap], root: "Board" }); +const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap, RideOptions, MailList, Route, PullRequest], root: "Board" }); const panelSchemas = { Treemap: (value: unknown) => treemapProps.safeParse(value), BarChart: (value: unknown) => barChartProps.safeParse(value), StackedArea: (value: unknown) => stackedAreaProps.safeParse(value), Heatmap: (value: unknown) => heatmapProps.safeParse(value), + ...appCardSchemas, }; export function inspectOpenUiChart(code: string) { diff --git a/apps/web/src/components/chat/OpenUiAppCards.tsx b/apps/web/src/components/chat/OpenUiAppCards.tsx new file mode 100644 index 0000000000000..38121205a5248 --- /dev/null +++ b/apps/web/src/components/chat/OpenUiAppCards.tsx @@ -0,0 +1,421 @@ +import { defineComponent } from "@openuidev/react-lang"; +import { memo, useState, type ReactNode } from "react"; +import { z } from "zod/v4"; + +import { APP_BRANDS, type AppBrand } from "@t3tools/client-runtime/app-connectors"; +import { + APP_CARD_DESCRIPTIONS as DESCRIPTIONS, + APP_CARD_LIMITS as LIMITS, + CHECK_STATUS_SYMBOL, + PULL_REQUEST_STATE_COLORS, + cheapestRide, + checkCounts, + pullRequestSteps, + rideReadout, +} from "@t3tools/client-runtime/openui-app-cards"; + +const text = z.string().max(LIMITS.text); +const name = z.string().min(1).max(LIMITS.text); +const minutes = z.number().finite().min(0).max(LIMITS.minutes); + +export const rideOptionsProps = z.object({ + app: z.enum(["uber", "lyft"]), + pickup: name, + dropoff: name, + options: z + .array( + z.object({ + name, + minutes, + price: z.string().min(1).max(LIMITS.price), + seats: z.number().int().min(1).max(20), + }), + ) + .min(1) + .max(LIMITS.rideOptions), +}); +export const mailListProps = z.object({ + query: text, + messages: z + .array( + z.object({ + from: name, + subject: text, + snippet: z.string().max(LIMITS.snippet), + time: text, + unread: z.boolean(), + }), + ) + .max(LIMITS.messages), +}); +export const routeProps = z.object({ + origin: name, + destination: name, + mode: z.enum(["drive", "walk", "transit", "bike"]), + minutes, + distance: text, + steps: z.array(name).max(LIMITS.steps), +}); +export const pullRequestProps = z.object({ + number: z.number().int().min(1), + title: name, + state: z.enum(["open", "conflict", "ready", "merged", "closed"]), + head: name, + base: name, + checks: z + .array(z.object({ name, status: z.enum(["pass", "fail", "running", "skip"]), detail: text })) + .max(LIMITS.checks), + conflicts: z.number().int().min(0).max(10_000), +}); + +function CardShell({ + brand, + subtitle, + label, + children, +}: { + brand: AppBrand; + subtitle: string; + label: string; + children: ReactNode; +}) { + return ( +
+
+
+ 1 ? 11 : 15, + }} + > + {brand.mark} + +
+

{brand.name}

+

{subtitle}

+
+
+
{children}
+
+ ); +} + +function Tag({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +const RideOptionsCard = memo(function RideOptionsCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS[props.app]; + const [selected, setSelected] = useState(null); + const best = cheapestRide(props.options); + const ring = brand.color === "#000000" ? "#888888" : brand.color; + return ( + + {props.options.map((option, index) => ( + + ))} +

+ {selected === null + ? `Prices from ${brand.name}. Booking happens in the ${brand.name} app.` + : rideReadout(props.options[selected]!)} +

+
+ ); +}); + +const MailListCard = memo(function MailListCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.gmail; + const [open, setOpen] = useState(null); + const unread = props.messages.filter((message) => message.unread).length; + return ( + + {props.messages.length === 0 ? ( +

No messages matched.

+ ) : null} +
    + {props.messages.map((message, index) => ( +
  • + +
  • + ))} +
+
+ ); +}); + +const MODE_LABEL = { + drive: "Driving", + walk: "Walking", + transit: "Transit", + bike: "Cycling", +} as const; + +const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { + const brand = APP_BRANDS["google-maps"]; + const [step, setStep] = useState(null); + return ( + +

+ {`${props.minutes} min`} + {props.distance} +

+
    + {props.steps.map((entry, index) => ( +
  1. + +
  2. + ))} +
+
+ ); +}); + +const STATE_LABEL = { + open: "Open", + conflict: "Conflicts", + ready: "Ready to merge", + merged: "Merged", + closed: "Closed", +} as const; + +const PullRequestCard = memo(function PullRequestCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.github; + const stateColor = PULL_REQUEST_STATE_COLORS[props.state]; + const steps = pullRequestSteps(props.state); + const reached = steps.filter((entry) => entry.reached).length; + const counts = checkCounts(props.checks); + const merged = props.state === "merged"; + const [check, setCheck] = useState(null); + return ( + +
+

{props.title}

+ {merged ? ( + // A merged pull request plays its fill once on insertion (@starting-style); reduced motion skips it. + + Merged + + ) : ( + + )} +
+
+
+
+
+ {steps.map((entry) => ( + + {entry.name} + + ))} +
+ {props.state === "conflict" ? ( +

{`${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} with ${props.base}`}

+ ) : null} + {props.checks.length ? ( +
+

{`Checks · ${counts.pass} passed${counts.fail ? ` · ${counts.fail} failed` : ""}${counts.running ? ` · ${counts.running} running` : ""}${counts.skip ? ` · ${counts.skip} skipped` : ""}`}

+
+ {props.checks.map((entry, index) => { + const color = + entry.status === "pass" + ? "#1A7F37" + : entry.status === "fail" + ? "#CF222E" + : entry.status === "running" + ? "#9A6700" + : "#6E7781"; + return ( + + ); + })} +
+ {check !== null && props.checks[check]?.detail ? ( +

{`${props.checks[check]!.name}: ${props.checks[check]!.detail}`}

+ ) : null} +
+ ) : null} + + ); +}); + +export const RideOptions = defineComponent({ + name: "RideOptions", + description: DESCRIPTIONS.RideOptions, + props: rideOptionsProps, + component: RideOptionsCard, +}); +export const MailList = defineComponent({ + name: "MailList", + description: DESCRIPTIONS.MailList, + props: mailListProps, + component: MailListCard, +}); +export const Route = defineComponent({ + name: "Route", + description: DESCRIPTIONS.Route, + props: routeProps, + component: RouteCard, +}); +export const PullRequest = defineComponent({ + name: "PullRequest", + description: DESCRIPTIONS.PullRequest, + props: pullRequestProps, + component: PullRequestCard, +}); + +export const appCardSchemas = { + RideOptions: (value: unknown) => rideOptionsProps.safeParse(value), + MailList: (value: unknown) => mailListProps.safeParse(value), + Route: (value: unknown) => routeProps.safeParse(value), + PullRequest: (value: unknown) => pullRequestProps.safeParse(value), +}; diff --git a/apps/web/src/components/chat/OpenUiCharts.tsx b/apps/web/src/components/chat/OpenUiCharts.tsx index 0437b5783bfd9..f7697b4e20af3 100644 --- a/apps/web/src/components/chat/OpenUiCharts.tsx +++ b/apps/web/src/components/chat/OpenUiCharts.tsx @@ -27,6 +27,8 @@ import { treemapReadout, } from "@t3tools/client-runtime/openui-charts"; +import { MailList, PullRequest, RideOptions, Route, appCardSchemas } from "./OpenUiAppCards"; + // Catalog v2: the shared definition in client-runtime bound to this app's zod and OpenUI. const title = z.string().max(LIMITS.title); const label = z.string().min(1).max(LIMITS.label); @@ -220,7 +222,7 @@ const Board = defineComponent({ props: z.object({ title, summary: z.string().max(LIMITS.summary), - panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref])).min(1).max(LIMITS.panels), + panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref, RideOptions.ref, MailList.ref, Route.ref, PullRequest.ref])).min(1).max(LIMITS.panels), }), component: ({ props, renderNode }) => (
@@ -233,12 +235,13 @@ const Board = defineComponent({ ), }); -const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap], root: "Board" }); +const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap, RideOptions, MailList, Route, PullRequest], root: "Board" }); const panelSchemas = { Treemap: (value: unknown) => treemapProps.safeParse(value), BarChart: (value: unknown) => barChartProps.safeParse(value), StackedArea: (value: unknown) => stackedAreaProps.safeParse(value), Heatmap: (value: unknown) => heatmapProps.safeParse(value), + ...appCardSchemas, }; export const openUiChartPrompt = library.prompt(OPENUI_CHART_PROMPT); diff --git a/experiments/openui-lab/app-cards.test.ts b/experiments/openui-lab/app-cards.test.ts new file mode 100644 index 0000000000000..a3feea0afeffe --- /dev/null +++ b/experiments/openui-lab/app-cards.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + OpenUiCharts, + inspectOpenUiChart, + openUiChartPrompt, +} from "../../apps/web/src/components/chat/OpenUiCharts"; + +const render = (code: string) => renderToStaticMarkup(createElement(OpenUiCharts, { code })); + +const ride = `root = Board("Ride to SFO", "UberX is the cheapest option.", [rides]) +rides = RideOptions("uber", "Ferry Building", "SFO Terminal 2", [{name: "UberX", minutes: 4, price: "$31.20", seats: 4}, {name: "Comfort", minutes: 6, price: "$38.90", seats: 4}, {name: "UberXL", minutes: 9, price: "$52.10", seats: 6}])`; +const mail = `root = Board("Invoices", "Two unread invoices from Acme.", [mail]) +mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice #4512", snippet: "Your October invoice is ready.", time: "9:41 AM", unread: true}, {from: "Acme Billing", subject: "Invoice #4498", snippet: "September invoice attached.", time: "Oct 2", unread: false}])`; +const route = `root = Board("To the Ferry Building", "About 18 minutes by car.", [route]) +route = Route("Home", "Ferry Building", "drive", 18, "6.2 mi", ["Head north on Valencia St", "Turn right onto Market St", "Arrive at Ferry Building"])`; +const pr = ( + state: string, + conflicts: number, + checks = '[{name: "Tests", status: "pass", detail: "vitest · 42 passed"}, {name: "Lint", status: "pass", detail: ""}, {name: "Types", status: "pass", detail: ""}]', +) => + `root = Board("PR #128", "Status of the auth PR.", [pr])\npr = PullRequest(128, "Refresh sessions in the auth middleware", "${state}", "feat/session-refresh", "main", ${checks}, ${conflicts})`; + +test("app cards parse, render their brand and supplied data", () => { + const cases: Array<[string, RegExp[]]> = [ + [ + ride, + [ + /Uber/, + /#000000/, + /Ferry Building → SFO Terminal 2/, + /Best price/, + /\$52\.10/, + /Booking happens in the Uber app/, + ], + ], + [ + mail, + [ + /Gmail/, + /#EA4335/, + /“invoices from Acme” · 2 results · 1 unread/, + /Invoice #4512/, + /aria-label="Unread"/, + ], + ], + [ + route, + [ + /Google Maps/, + /18 min/, + /6\.2 mi/, + /Driving · Home → Ferry Building/, + /Turn right onto Market St/, + ], + ], + [ + pr("ready", 0), + [/GitHub/, /#128 · feat\/session-refresh → main/, /Ready to merge/, /Checks · 3 passed/], + ], + [pr("conflict", 3), [/3 conflicts with main/]], + [pr("merged", 0), [/>Merged { + for (const code of [ + pr("conflict", 0), + pr("merged", 2), + pr("ready", 0, '[{name: "Tests", status: "running", detail: ""}]'), + ride.replace('"uber"', '"bolt"'), + ride.replace("[{name", '[{name: "UberX", minutes: 1, price: "$1", seats: 4}, {name'), + route.replace('"drive"', '"teleport"'), + ]) + assert.equal(inspectOpenUiChart(code).kind, "invalid", code.slice(-90)); +}); + +test("app cards mix with charts on one board", () => { + const code = `root = Board("Trip", "Ride and route.", [rides, route, spend]) +rides = RideOptions("lyft", "Home", "Airport", [{name: "Lyft", minutes: 3, price: "$28", seats: 4}]) +route = Route("Home", "Airport", "transit", 52, "14 mi", ["Walk to 16th St BART"]) +spend = BarChart("Ride spend", "USD", [{label: "Uber", value: 120}, {label: "Lyft", value: 80}])`; + assert.deepEqual(inspectOpenUiChart(code), { kind: "ready" }); + const html = render(code); + assert.match(html, /#FF00BF/); + assert.match(html, /Transit · Home → Airport/); + assert.match(html, /Ride spend/); +}); diff --git a/experiments/openui-lab/run-smoke.mjs b/experiments/openui-lab/run-smoke.mjs index e6edf05d18962..e7b395c20f902 100644 --- a/experiments/openui-lab/run-smoke.mjs +++ b/experiments/openui-lab/run-smoke.mjs @@ -14,6 +14,10 @@ const sources = { "OpenUiReport.tsx": "apps/web/src/components/chat/OpenUiReport.tsx", "OpenUiCharts.tsx": "apps/web/src/components/chat/OpenUiCharts.tsx", "openUiCharts.ts": "packages/client-runtime/src/openUiCharts.ts", + "OpenUiAppCards.tsx": "apps/web/src/components/chat/OpenUiAppCards.tsx", + "openUiAppCards.ts": "packages/client-runtime/src/openUiAppCards.ts", + "appConnectors.ts": "packages/client-runtime/src/appConnectors.ts", + "app-cards.test.ts": "experiments/openui-lab/app-cards.test.ts", "report.test.ts": "experiments/openui-lab/report.test.ts", "charts.test.ts": "experiments/openui-lab/charts.test.ts", "source-demo-fences.md": "experiments/openui-lab/source-demo-fences.md", @@ -22,6 +26,8 @@ const rewrites = [ ["../../apps/web/src/components/chat/OpenUiReport", "./OpenUiReport"], ["../../apps/web/src/components/chat/OpenUiCharts", "./OpenUiCharts"], ["@t3tools/client-runtime/openui-charts", "./openUiCharts"], + ["@t3tools/client-runtime/openui-app-cards", "./openUiAppCards"], + ["@t3tools/client-runtime/app-connectors", "./appConnectors"], ]; for (const [name, path] of Object.entries(sources)) { if (name.endsWith(".md")) { @@ -43,7 +49,7 @@ function run(command, args) { return { status: result.status, log }; } const install = run("npm", ["install", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"]); -const result = install.status === 0 ? run(process.execPath, ["--import", "tsx", "--test", "report.test.ts", "charts.test.ts"]) : install; +const result = install.status === 0 ? run(process.execPath, ["--import", "tsx", "--test", "report.test.ts", "charts.test.ts", "app-cards.test.ts"]) : install; writeFileSync(join(out, "smoke.log"), result.log); const hash = (path) => createHash("sha256").update(readFileSync(join(root, path))).digest("hex"); const sourceSha256 = Object.fromEntries(Object.values(sources).map((path) => [path, hash(path)])); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 9c8ec46ce97f6..2b4c187e3fc12 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -518,6 +518,10 @@ "./app-connectors": { "types": "./src/appConnectors.ts", "default": "./src/appConnectors.ts" + }, + "./openui-app-cards": { + "types": "./src/openUiAppCards.ts", + "default": "./src/openUiAppCards.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/openUiAppCards.test.ts b/packages/client-runtime/src/openUiAppCards.test.ts new file mode 100644 index 0000000000000..e50f9bb13b3ae --- /dev/null +++ b/packages/client-runtime/src/openUiAppCards.test.ts @@ -0,0 +1,73 @@ +import * as NodeAssert from "node:assert/strict"; +import { it as test } from "vite-plus/test"; + +import { + appCardProblem, + cheapestRide, + checkCounts, + pullRequestSteps, + rideReadout, + type PullRequestCheck, +} from "./openUiAppCards.ts"; +import { chartPanelProblem } from "./openUiCharts.ts"; + +const options = [ + { name: "UberX", minutes: 4, price: "$31.20", seats: 4 }, + { name: "Comfort", minutes: 6, price: "$38.90", seats: 4 }, + { name: "UberXL", minutes: 9, price: "$52.10", seats: 6 }, +]; + +test("ride options read out and tag the cheapest", () => { + NodeAssert.equal(rideReadout(options[0]!), "UberX · pickup in 4 min · $31.20 · 4 seats"); + NodeAssert.equal(cheapestRide(options), "UberX"); + NodeAssert.equal( + cheapestRide([{ name: "Wait & Save", minutes: 12, price: "Price on request", seats: 4 }]), + null, + ); + NodeAssert.match( + appCardProblem({ + typeName: "RideOptions", + props: { app: "uber", options: [...options, options[0]!] }, + })!, + /same option twice/, + ); +}); + +test("pull request states must agree with checks and conflicts", () => { + const checks: ReadonlyArray = [ + { name: "Tests", status: "pass", detail: "42 passed" }, + { name: "Lint", status: "pass", detail: "" }, + ]; + const pr = ( + state: "open" | "conflict" | "ready" | "merged" | "closed", + conflicts: number, + list = checks, + ) => ({ typeName: "PullRequest" as const, props: { state, checks: list, conflicts } }); + NodeAssert.equal(appCardProblem(pr("ready", 0)), null); + NodeAssert.equal(appCardProblem(pr("conflict", 3)), null); + NodeAssert.match(appCardProblem(pr("conflict", 0))!, /conflict count/); + NodeAssert.match(appCardProblem(pr("merged", 2))!, /cannot have conflicts/); + NodeAssert.match( + appCardProblem(pr("ready", 0, [{ name: "Tests", status: "running", detail: "" }]))!, + /every check/, + ); + NodeAssert.equal( + chartPanelProblem(pr("merged", 0)), + null, + "the chart board inspector delegates to app cards", + ); + NodeAssert.deepEqual(checkCounts([...checks, { name: "Types", status: "fail", detail: "" }]), { + pass: 2, + fail: 1, + running: 0, + skip: 0, + }); + NodeAssert.deepEqual( + pullRequestSteps("ready").map((step) => step.reached), + [true, true, true, false], + ); + NodeAssert.deepEqual( + pullRequestSteps("merged").map((step) => step.reached), + [true, true, true, true], + ); +}); diff --git a/packages/client-runtime/src/openUiAppCards.ts b/packages/client-runtime/src/openUiAppCards.ts new file mode 100644 index 0000000000000..7d8c10a1e306d --- /dev/null +++ b/packages/client-runtime/src/openUiAppCards.ts @@ -0,0 +1,165 @@ +/** + * App cards for the `openui-v2` catalog: rides, mail, routes and pull requests in their app's colors. + * Read-only like the charts: taps stay local to the card and nothing is booked, sent or merged. + * Design credits: docs/user/openui-reports.md. + */ +import { APP_BRANDS, type AppBrand } from "./appConnectors.ts"; + +export const APP_CARD_TYPES = ["RideOptions", "MailList", "Route", "PullRequest"] as const; +export type AppCardType = (typeof APP_CARD_TYPES)[number]; + +export const APP_CARD_LIMITS = { + text: 200, + snippet: 400, + price: 24, + rideOptions: 8, + messages: 20, + steps: 20, + checks: 12, + minutes: 100_000, +} as const; + +export const APP_CARD_DESCRIPTIONS: Readonly> = { + RideOptions: + 'Ride choices a ride app returned for one trip: app is "uber" or "lyft"; each option has a name, pickup minutes, a price string and seats.', + MailList: + "Messages a mail search returned: the query, then each message's sender, subject, snippet, time text and unread flag.", + Route: + "Directions a maps app returned: origin, destination, mode (drive, walk, transit or bike), minutes, a distance string, then the steps in order.", + PullRequest: + "One pull request: number, title, state (open, conflict, ready, merged or closed), head and base branches, its checks (pass, fail, running or skip) and the conflict count.", +}; + +export const APP_CARD_PROMPT_RULES = [ + "App cards only show what a connector tool returned in this turn. Copy values exactly; never invent prices, times, messages or check results.", + "PullRequest state conflict needs a conflict count above 0; ready means every check passed or was skipped; merged and closed have no conflicts.", +] as const; + +export const APP_CARD_EXAMPLE = + 'root = Board("Ride to SFO", "UberX is the cheapest option.", [rides])\nrides = RideOptions("uber", "Ferry Building", "SFO Terminal 2", [{name: "UberX", minutes: 4, price: "$31.20", seats: 4}, {name: "Comfort", minutes: 6, price: "$38.90", seats: 4}])'; + +export interface RideOption { + readonly name: string; + readonly minutes: number; + readonly price: string; + readonly seats: number; +} +export interface MailMessage { + readonly from: string; + readonly subject: string; + readonly snippet: string; + readonly time: string; + readonly unread: boolean; +} +export type CheckStatus = "pass" | "fail" | "running" | "skip"; +export interface PullRequestCheck { + readonly name: string; + readonly status: CheckStatus; + readonly detail: string; +} +export type PullRequestState = "open" | "conflict" | "ready" | "merged" | "closed"; + +export type AppCardPanel = + | { + readonly typeName: "RideOptions"; + readonly props: { + readonly app: "uber" | "lyft"; + readonly options: ReadonlyArray; + }; + } + | { + readonly typeName: "MailList"; + readonly props: { readonly messages: ReadonlyArray }; + } + | { readonly typeName: "Route"; readonly props: { readonly steps: ReadonlyArray } } + | { + readonly typeName: "PullRequest"; + readonly props: { + readonly state: PullRequestState; + readonly checks: ReadonlyArray; + readonly conflicts: number; + }; + }; + +export function isAppCardType(value: unknown): value is AppCardType { + return typeof value === "string" && (APP_CARD_TYPES as ReadonlyArray).includes(value); +} + +/** Cross-field rules the schemas cannot express. */ +export function appCardProblem(panel: AppCardPanel): string | null { + switch (panel.typeName) { + case "RideOptions": { + const names = panel.props.options.map((option) => option.name); + return new Set(names).size === names.length + ? null + : "RideOptions lists the same option twice."; + } + case "MailList": + case "Route": + return null; + case "PullRequest": { + const { state, checks, conflicts } = panel.props; + if (state === "conflict" && conflicts < 1) + return "PullRequest in conflict needs a conflict count."; + if (state !== "conflict" && state !== "open" && conflicts > 0) + return `PullRequest ${state} cannot have conflicts.`; + if ( + state === "ready" && + checks.some((check) => check.status === "fail" || check.status === "running") + ) + return "PullRequest ready needs every check passed or skipped."; + return null; + } + } +} + +export function rideBrand(app: "uber" | "lyft"): AppBrand { + return APP_BRANDS[app]; +} + +export function rideReadout(option: RideOption) { + return `${option.name} · pickup in ${option.minutes} min · ${option.price} · ${option.seats} seats`; +} + +/** The cheapest option by the numeric part of its price, for the "Best price" tag. */ +export function cheapestRide(options: ReadonlyArray): string | null { + let best: { name: string; value: number } | null = null; + for (const option of options) { + const value = Number(option.price.replace(/[^0-9.]/g, "")); + if ( + Number.isFinite(value) && + option.price.match(/[0-9]/) && + (best === null || value < best.value) + ) + best = { name: option.name, value }; + } + return best?.name ?? null; +} + +export function checkCounts(checks: ReadonlyArray) { + const counts = { pass: 0, fail: 0, running: 0, skip: 0 }; + for (const check of checks) counts[check.status] += 1; + return counts; +} + +/** Progress steps the card draws, in order; `reached` marks how far this pull request got. */ +export function pullRequestSteps(state: PullRequestState) { + const order = ["Opened", "Checks", "Ready", "Merged"] as const; + const reached = state === "merged" ? 4 : state === "ready" ? 3 : state === "closed" ? 1 : 2; + return order.map((name, index) => ({ name, reached: index < reached })); +} + +export const PULL_REQUEST_STATE_COLORS: Readonly> = { + open: "#1A7F37", + conflict: "#BC4C00", + ready: "#1A7F37", + merged: "#8250DF", + closed: "#CF222E", +}; + +export const CHECK_STATUS_SYMBOL: Readonly> = { + pass: "✓", + fail: "✕", + running: "●", + skip: "–", +}; diff --git a/packages/client-runtime/src/openUiCharts.ts b/packages/client-runtime/src/openUiCharts.ts index 8947ad9128124..e34fba8bd33cf 100644 --- a/packages/client-runtime/src/openUiCharts.ts +++ b/packages/client-runtime/src/openUiCharts.ts @@ -7,6 +7,8 @@ * zod and OpenUI copies, which keeps duplicate React/zod instances out of either bundle. */ +import { APP_CARD_EXAMPLE, APP_CARD_PROMPT_RULES, appCardProblem, isAppCardType, type AppCardPanel } from "./openUiAppCards.ts"; + export const OPENUI_CHART_FENCE = "openui-v2"; /** Bounds every client applies to the schemas. */ @@ -43,9 +45,11 @@ export const OPENUI_CHART_PROMPT = { "Use each component's arguments in declared order.", "StackedArea series need one value per category; Heatmap counts need one row per row label and one count per column label.", "After the fenced block include a normal Markdown summary for clients without the renderer.", + ...APP_CARD_PROMPT_RULES, ], examples: [ 'root = Board("Churn", "Login is the hottest file.", [map, top])\nmap = Treemap("Size and churn", "LOC", "commits", [{path: "src/login.ts", size: 80, color: 12}, {path: "src/ui.ts", size: 120, color: 4}])\ntop = BarChart("Most commits", "commits", [{label: "login.ts", value: 12}, {label: "ui.ts", value: 4}])', + APP_CARD_EXAMPLE, ], }; @@ -88,7 +92,7 @@ export function inspectParsedChartBoard( } for (const panel of panels) { const typeName = isRecord(panel) ? panel.typeName : undefined; - if (typeName !== "Treemap" && typeName !== "BarChart" && typeName !== "StackedArea" && typeName !== "Heatmap") { + if (typeName !== "Treemap" && typeName !== "BarChart" && typeName !== "StackedArea" && typeName !== "Heatmap" && !isAppCardType(typeName)) { return { kind: "invalid", reason: "Chart board contains an unknown panel." }; } const props = panelSchemas[typeName](isRecord(panel) ? panel.props : undefined); @@ -351,7 +355,8 @@ export type ChartPanel = | { readonly typeName: "Treemap"; readonly props: { readonly rows: ReadonlyArray } } | { readonly typeName: "BarChart"; readonly props: { readonly bars: ReadonlyArray<{ readonly label: string; readonly value: number }> } } | { readonly typeName: "StackedArea"; readonly props: { readonly categories: ReadonlyArray; readonly series: ReadonlyArray } } - | { readonly typeName: "Heatmap"; readonly props: { readonly rows: ReadonlyArray; readonly columns: ReadonlyArray; readonly counts: ReadonlyArray> } }; + | { readonly typeName: "Heatmap"; readonly props: { readonly rows: ReadonlyArray; readonly columns: ReadonlyArray; readonly counts: ReadonlyArray> } } + | AppCardPanel; /** Cross-field rules the schemas cannot express; returns a reader-facing reason or null. */ export function chartPanelProblem(panel: ChartPanel): string | null { @@ -382,5 +387,7 @@ export function chartPanelProblem(panel: ChartPanel): string | null { const index = counts.findIndex((row) => row.length !== columns.length); return index >= 0 ? `Heatmap row "${rows[index]}" needs ${columns.length} counts.` : null; } + default: + return appCardProblem(panel); } } From 413b1128fb916b1321d3f6473220e338b5265c65 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:08:15 -0600 Subject: [PATCH 03/11] test(openui-lab): demo connectors for dogfooding app cards demo-connectors.mjs serves canned Gmail, Uber, Google Maps and GitHub tools over MCP stdio (nothing is fetched, booked or merged). acp-with-connectors.mjs wraps any ACP agent and adds them to each session, so a sandbox provider instance gets the tools without editing the agent's own config. --- .../dogfood/acp-with-connectors.mjs | 55 ++++++ .../openui-lab/dogfood/demo-connectors.mjs | 181 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 experiments/openui-lab/dogfood/acp-with-connectors.mjs create mode 100644 experiments/openui-lab/dogfood/demo-connectors.mjs diff --git a/experiments/openui-lab/dogfood/acp-with-connectors.mjs b/experiments/openui-lab/dogfood/acp-with-connectors.mjs new file mode 100644 index 0000000000000..6119ecdd4687a --- /dev/null +++ b/experiments/openui-lab/dogfood/acp-with-connectors.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Dogfood ACP proxy: `node acp-with-connectors.mjs `. Adds the demo connectors +// to every session so a sandbox provider gets Gmail/Uber/Maps/GitHub without touching agent config. +// OPENUI_CATALOG_FILE, when set, is appended to each prompt in place of catalog instructions +// T3 does not inject yet. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +import * as NodeURL from "node:url"; + +const [command, ...args] = process.argv.slice(2); +if (!command) { + process.stderr.write("usage: acp-with-connectors.mjs \n"); + process.exit(2); +} + +const connectorPath = NodeURL.fileURLToPath(new URL("./demo-connectors.mjs", import.meta.url)); +const servers = ["gmail", "uber", "google-maps", "github"].map((name) => ({ + name, + command: process.execPath, + args: [connectorPath, name], + env: [], +})); +const catalog = process.env.OPENUI_CATALOG_FILE + ? `The openui-v2 catalog for this reply is complete below; do not look for it elsewhere.\n\n${NodeFS.readFileSync(process.env.OPENUI_CATALOG_FILE, "utf8")}` + : null; +const SESSION_METHODS = new Set(["session/new", "session/load", "session/resume", "session/fork"]); + +const child = NodeChildProcess.spawn(command, args, { stdio: ["pipe", "inherit", "inherit"] }); +child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal)); + +NodeReadline.createInterface({ input: process.stdin }) + .on("line", (line) => { + let out = line; + try { + const message = JSON.parse(line); + if (SESSION_METHODS.has(message.method) && message.params) { + const existing = (message.params.mcpServers ?? []).filter( + (server) => !servers.some((demo) => demo.name === server.name), + ); + message.params.mcpServers = [...existing, ...servers]; + out = JSON.stringify(message); + } else if ( + catalog && + message.method === "session/prompt" && + Array.isArray(message.params?.prompt) + ) { + message.params.prompt = [...message.params.prompt, { type: "text", text: catalog }]; + out = JSON.stringify(message); + } + } catch {} + child.stdin.write(`${out}\n`); + }) + .on("close", () => child.stdin.end()); diff --git a/experiments/openui-lab/dogfood/demo-connectors.mjs b/experiments/openui-lab/dogfood/demo-connectors.mjs new file mode 100644 index 0000000000000..025c4905f10a5 --- /dev/null +++ b/experiments/openui-lab/dogfood/demo-connectors.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// Demo MCP connectors for dogfooding app cards: `node demo-connectors.mjs gmail|uber|google-maps|github`. +// Speaks MCP over stdio with no dependencies. Every result is canned demo data and says so; +// nothing is fetched, booked, sent or merged. +import * as NodeReadline from "node:readline"; + +const DEMO = "Demo data from the T3 Code OpenUI lab, not a real account."; + +const CONNECTORS = { + gmail: { + search_messages: { + description: "Search the user's Gmail. Returns matching messages.", + input: { query: { type: "string", description: "Gmail search query" } }, + run: ({ query }) => ({ + query, + messages: [ + { + from: "Acme Billing", + subject: "Invoice #4512 for October", + snippet: "Your October invoice for $1,240.00 is ready. Payment is due Nov 15.", + time: "9:41 AM", + unread: true, + }, + { + from: "Acme Billing", + subject: "Invoice #4498 for September", + snippet: "September invoice attached. Thanks for your business.", + time: "Oct 2", + unread: false, + }, + { + from: "Priya at Acme", + subject: "Re: billing contact change", + snippet: "Updated the billing contact to finance@ as requested.", + time: "Sep 28", + unread: false, + }, + ], + }), + }, + }, + uber: { + get_price_estimates: { + description: "Uber ride options and prices for a trip.", + input: { pickup: { type: "string" }, dropoff: { type: "string" } }, + run: ({ pickup, dropoff }) => ({ + pickup, + dropoff, + options: [ + { name: "UberX", pickup_minutes: 4, price: "$31.20", seats: 4 }, + { name: "Comfort", pickup_minutes: 6, price: "$38.90", seats: 4 }, + { name: "UberXL", pickup_minutes: 9, price: "$52.10", seats: 6 }, + ], + }), + }, + request_ride: { + description: "Request an Uber ride. Demo only: never books.", + input: { + product: { type: "string" }, + pickup: { type: "string" }, + dropoff: { type: "string" }, + }, + run: () => ({ + booked: false, + reason: "Demo connector: booking is disabled. Open the Uber app to book.", + }), + }, + }, + "google-maps": { + get_directions: { + description: "Directions between two places.", + input: { + origin: { type: "string" }, + destination: { type: "string" }, + mode: { type: "string", description: "drive, walk, transit or bike" }, + }, + run: ({ origin, destination, mode = "drive" }) => ({ + origin, + destination, + mode, + minutes: 18, + distance: "6.2 mi", + steps: [ + "Head north on Valencia St", + "Turn right onto Market St", + "Continue onto The Embarcadero", + "Arrive at the Ferry Building", + ], + }), + }, + }, + github: { + get_pull_request: { + description: "A pull request with its checks and merge state.", + input: { number: { type: "number" } }, + run: ({ number = 128 }) => ({ + number, + title: "Refresh sessions in the auth middleware", + head: "feat/session-refresh", + base: "main", + mergeable_state: "dirty", + conflicts: 3, + checks: [ + { name: "Tests", status: "pass", detail: "vitest · 42 passed" }, + { name: "Lint", status: "pass", detail: "" }, + { name: "Types", status: "running", detail: "tsgo" }, + ], + }), + }, + merge_pull_request: { + description: "Merge a pull request. Demo only: never merges.", + input: { number: { type: "number" } }, + run: ({ number }) => ({ + merged: false, + number, + reason: "Demo connector: merging is disabled.", + }), + }, + }, +}; + +const name = process.argv[2]; +const tools = CONNECTORS[name]; +if (!tools) { + process.stderr.write(`usage: demo-connectors.mjs ${Object.keys(CONNECTORS).join("|")}\n`); + process.exit(2); +} + +const send = (message) => + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + +NodeReadline.createInterface({ input: process.stdin }).on("line", (line) => { + let request; + try { + request = JSON.parse(line); + } catch { + return; + } + const { id, method, params } = request; + if (id === undefined) return; + if (method === "initialize") { + send({ + id, + result: { + protocolVersion: params?.protocolVersion ?? "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name, version: "0.0.1" }, + }, + }); + } else if (method === "tools/list") { + send({ + id, + result: { + tools: Object.entries(tools).map(([tool, spec]) => ({ + name: tool, + description: `${spec.description} ${DEMO}`, + inputSchema: { type: "object", properties: spec.input }, + })), + }, + }); + } else if (method === "tools/call") { + const spec = tools[params?.name]; + if (!spec) send({ id, error: { code: -32602, message: `Unknown tool ${params?.name}` } }); + else + send({ + id, + result: { + content: [ + { + type: "text", + text: JSON.stringify({ note: DEMO, ...spec.run(params.arguments ?? {}) }), + }, + ], + }, + }); + } else if (method === "ping") { + send({ id, result: {} }); + } else { + send({ id, error: { code: -32601, message: `Method not found: ${method}` } }); + } +}); From f266a74bc70e9d8b80e037e73db3192ff7767edd Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:08:15 -0600 Subject: [PATCH 04/11] docs(user): app cards, brand activity rows and design credits --- docs/user/openui-reports.md | 39 +++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/user/openui-reports.md b/docs/user/openui-reports.md index 398ce792a7876..e324ebe838130 100644 --- a/docs/user/openui-reports.md +++ b/docs/user/openui-reports.md @@ -23,8 +23,34 @@ block is complete; **Show source** still returns to the program. see its exact count. Grids wider than 12 columns, such as hours of the day, show compact cells and label every few columns; the counts stay one tap away. -Charts keep their selections locally; tapping never sends a message or fetches -anything. Scrolling a long conversation can reset a selection. +Chart boards can also hold app cards, drawn in the colors of the service the +data came from: + +- **RideOptions** (Uber or Lyft): pickup and drop-off, then each ride option + with its pickup time, price and seats. The cheapest option is tagged. Tap an + option to see it in full; booking still happens in the ride app. +- **MailList** (Gmail): the search, then each message's sender, subject, time + and snippet, with unread messages marked. Tap a message to show its whole + snippet. +- **Route** (Google Maps): travel time, distance and mode, then the steps. Tap a + step to highlight it. +- **PullRequest** (GitHub): number, title, branches, a progress track (opened, + checks, ready, merged), the conflict count and check chips. Tap a check to + see its detail. A merged pull request fills its track once when it appears. + +Charts and cards keep their selections locally; tapping never sends a message, +books, sends or merges anything. Scrolling a long conversation can reset a +selection. + +When an agent uses a tool from a known app, its activity row shows that app's +colored mark and a plain sentence, such as "Searching Gmail · invoices from +Acme" or "Checked Uber prices", instead of the generic tool name. This works for +any provider; the app is recognized from the tool's server name (Gmail, Google +Calendar, Google Maps, Uber, Lyft, GitHub, Slack, Spotify and DoorDash). Expand +the row as usual to see the full call. + +Brand names and colors only identify the service. T3 Code bundles no logos and +is not affiliated with these companies. Reports cannot call tools, run queries or mutations, maintain generated state, or dispatch actions. Invalid or incomplete reports display a notice; the @@ -45,3 +71,12 @@ does not replace the standard T3 Code app or receive its over-the-air updates. A replay of a saved conversation demonstrates rendering and navigation, not model generation speed. Generation-speed claims require live measurements with the same facts, model, prompt overhead, and completion criteria. + +## Credits + +- Reports and chart boards use [OpenUI](https://github.com/thesysdev/openui) + (`@openuidev/react-lang`, `@openuidev/lang-core`). +- The PullRequest card's progress track, conflict count and check chips follow + [@pitus_'s "Generative UI #2 – Merge conflicts"](https://x.com/pitus_/status/2111519352503873866). +- The idea of brand-colored app cards in place of a bare "thinking" row comes + from Kevin Rajan ([@kvnloo](https://github.com/kvnloo)). From 0273aefe51996b1581de04ed39242e640c45ab0a Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:03:34 -0600 Subject: [PATCH 05/11] fix(clients): use the app sentence in live and single-call work labels Dogfooding showed the running turn header and single-call rows still read "Get pull request": they resolve their labels separately from the row. All three label paths on web and mobile now use the shared app activity label first, with the live status taking precedence. --- .../src/features/threads/thread-work-log.tsx | 5 ++--- apps/mobile/src/lib/threadActivity.ts | 10 ++++++++++ .../components/chat/MessagesTimeline.logic.ts | 7 +++++++ .../components/chat/TimelineWorkEntryRow.tsx | 3 ++- .../src/work-log/presentation.test.ts | 18 ++++++++++++++++++ .../src/work-log/presentation.ts | 11 +++++++++++ 6 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 71ebd769c9df4..3fff6e94fed1c 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -85,6 +85,7 @@ import { resolveWorkEntryToolPresentation, toolGroupAction, type ToolGroupSummaryKind, + workEntryAppActivityLabel, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; @@ -1028,9 +1029,7 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const appActivity = row.groupStandIn ? null : resolveWorkEntryAppActivity(row.workEntry); - const appLabel = appActivity - ? [appActivity.label, appActivity.detail].filter(Boolean).join(" · ") - : null; + const appLabel = appActivity ? workEntryAppActivityLabel(row.workEntry) : null; const previewText = row.groupStandIn?.label ?? appLabel ?? workEntryRowLabel(row.workEntry); const answerPreview = row.workEntry.questionAnswer ? getQuestionAnswerPreview(row.workEntry.questionAnswer) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index bd7af44b2fa07..7d7c99502dff6 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -24,6 +24,7 @@ import { liveActivityToolStatus, toolGroupAction, resolveWorkEntryToolPresentation, + workEntryAppActivityLabel, summarizeToolGroup, toolGroupSummaryKind, type ToolGroupSummaryKind, @@ -256,6 +257,8 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa if (entry.itemType === "fork") return entry.label; if (expanded && entry.itemType === "reasoning") return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought"; + const appLabel = workEntryAppActivityLabel(entry); + if (appLabel) return appLabel; const presentation = resolveWorkEntryToolPresentation(entry); if (presentation) return presentation.displayName; if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command)); @@ -856,6 +859,11 @@ function singleToolCallLabel(activity: ThreadFeedActivity, expanded: boolean): s return expanded ? "Thought" : compactWorkEntryText(activity.workEntry.detail ?? "") || "Thought"; + const appLabel = workEntryAppActivityLabel( + activity.workEntry, + activity.workEntry.toolLifecycleStatus ?? "completed", + ); + if (appLabel) return appLabel; const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed"); if (presentation) return presentation.displayName; const command = activity.workEntry.command?.trim(); @@ -1877,6 +1885,8 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo (status === "inProgress" ? "Thinking" : "Thought") ); } + const appLabel = workEntryAppActivityLabel(activity.workEntry, status); + if (appLabel) return appLabel; const presentation = resolveWorkEntryToolPresentation({ ...activity.workEntry, toolLifecycleStatus: status, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c26bcf51a718a..af15dd3f7d61c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -22,6 +22,7 @@ import { liveActivityToolStatus, normalizeCompactToolLabel, resolveWorkEntryToolPresentation, + workEntryAppActivityLabel, summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, @@ -95,6 +96,8 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { function singleToolCallLabel(entry: WorkLogEntry): string { if (entry.itemType === "reasoning") return entry.detail?.trim().replace(/\s+/g, " ") || "Thought"; + const appLabel = workEntryAppActivityLabel(entry, entry.toolLifecycleStatus ?? "completed"); + if (appLabel) return appLabel; const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); if (toolPresentation) return toolPresentation.displayName; const item = entry.structuredPayload; @@ -136,6 +139,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string const thought = entry.detail?.trim().replace(/\s+/g, " "); return thought || entry.label; } + const appLabel = workEntryAppActivityLabel(entry); + if (appLabel) return appLabel; const toolPresentation = resolveWorkEntryToolPresentation(entry); if (toolPresentation) return toolPresentation.displayName; if (entry.command?.trim()) return commandDisplayText(entry.command); @@ -242,6 +247,8 @@ export function liveWorkEntryLabel( (status === "inProgress" ? "Thinking" : "Thought") ); } + const appLabel = workEntryAppActivityLabel(entry, status); + if (appLabel) return appLabel; const toolPresentation = resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: status, diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 7016e8c7ccd8e..01952c46bfeec 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -18,6 +18,7 @@ import { resolveViewedImageAsset, resolveWorkEntryAppActivity, toolGroupAction, + workEntryAppActivityLabel, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { @@ -291,7 +292,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? "Thinking" : "Thought" : appActivity - ? [appActivity.label, appActivity.detail].filter(Boolean).join(" · ") + ? workEntryAppActivityLabel(workEntry)! : questionHeading || (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); const answerPreview = workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer) diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 662d9b6909784..d230342f0e9f8 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -7,6 +7,7 @@ import { resolveWorkEntryAppActivity, resolveWorkEntryToolPresentation, summarizeToolGroup, + workEntryAppActivityLabel, toolItemForDisplay, type WorkLogPresentationEntry, workEntryIndicatesToolFailure, @@ -239,6 +240,23 @@ describe("resolveWorkEntryAppActivity", () => { }); }); +describe("workEntryAppActivityLabel", () => { + it("joins the sentence and detail, with the live status taking precedence", () => { + const entry = { + label: "get pull request", + structuredPayload: { + type: "dynamic_tool", + toolName: "editor_github_2f7f86e1.get_pull_request", + input: { number: 128 }, + } as never, + toolLifecycleStatus: "completed" as const, + }; + expect(workEntryAppActivityLabel(entry)).toBe("Read from GitHub · #128"); + expect(workEntryAppActivityLabel(entry, "inProgress")).toBe("Reading from GitHub · #128"); + expect(workEntryAppActivityLabel({ label: "Ran command" })).toBeNull(); + }); +}); + describe("summarizeToolGroup", () => { const sourced = (key: string, name: string) => ({ diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 61752bad3ffd6..9e95c3dd10ae3 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -257,6 +257,17 @@ export function resolveWorkEntryAppActivity( ); } +/** "Searching Gmail · invoices" for known apps, or null; `status` overrides the entry's own. */ +export function workEntryAppActivityLabel( + entry: Parameters[0], + status?: string, +): string | null { + const activity = resolveWorkEntryAppActivity( + status === undefined ? entry : { ...entry, toolLifecycleStatus: status as never }, + ); + return activity ? [activity.label, activity.detail].filter(Boolean).join(" · ") : null; +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) From 279225a9951457f1129b37fc25bd21e65a8fa110 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:04:25 -0600 Subject: [PATCH 06/11] fix(web): no list markers inside mail and route cards Inside chat Markdown the cards' lists inherited bullets and numbers, so the Route card showed "3. (3)" and mail rows had stray dots. --- apps/web/src/components/chat/OpenUiAppCards.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/OpenUiAppCards.tsx b/apps/web/src/components/chat/OpenUiAppCards.tsx index 38121205a5248..5ec6505972f27 100644 --- a/apps/web/src/components/chat/OpenUiAppCards.tsx +++ b/apps/web/src/components/chat/OpenUiAppCards.tsx @@ -179,9 +179,12 @@ const MailListCard = memo(function MailListCard({ {props.messages.length === 0 ? (

No messages matched.

) : null} -
    +
      {props.messages.map((message, index) => ( -
    • +
@@ -423,6 +437,7 @@ function LiveActivityContent({ label, iconName, toolIcon, + brand, failed = false, announceFailure = false, active = false, @@ -431,6 +446,7 @@ function LiveActivityContent({ label: ReactNode; iconName: WorkEntryIconName | undefined; toolIcon?: ToolActivityIcon | undefined; + brand?: AppBrand | undefined; failed?: boolean; announceFailure?: boolean; active?: boolean; @@ -442,7 +458,9 @@ function LiveActivityContent({ return ( + ) : iconName ? ( From edbbc7dadbdc074c0f951ab8c4f2a6d0d9f08a03 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:17:38 -0600 Subject: [PATCH 08/11] feat(web): richer app cards with commit graph, app links and selection Cards now read like the apps they come from: ride options with a selected row and a Request button that opens the ride app, mail rows with avatars and attachments plus Open in Gmail, a step-by-step route with Open in Maps, and a pull request card with a small commit graph and conflict marks after @pitus_'s merge-conflicts card. New fields are optional, so existing boards still parse; Android accepts them and shows the simpler layout. --- apps/mobile/src/native/OpenUiAppCards.tsx | 24 +- .../chat/OpenUiAppCards.browser.test.tsx | 47 ++ .../src/components/chat/OpenUiAppCards.tsx | 715 +++++++++++++----- docs/user/openui-reports.md | 31 +- .../client-runtime/src/openUiAppCards.test.ts | 6 + packages/client-runtime/src/openUiAppCards.ts | 42 +- 6 files changed, 664 insertions(+), 201 deletions(-) create mode 100644 apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx diff --git a/apps/mobile/src/native/OpenUiAppCards.tsx b/apps/mobile/src/native/OpenUiAppCards.tsx index ba252041bd1fc..cf3d9e0a28541 100644 --- a/apps/mobile/src/native/OpenUiAppCards.tsx +++ b/apps/mobile/src/native/OpenUiAppCards.tsx @@ -33,6 +33,7 @@ export const rideOptionsProps = z.object({ minutes, price: z.string().min(1).max(LIMITS.price), seats: z.number().int().min(1).max(20), + arrival: text.optional(), }), ) .min(1) @@ -48,6 +49,7 @@ export const mailListProps = z.object({ snippet: z.string().max(LIMITS.snippet), time: text, unread: z.boolean(), + attachment: text.optional(), }), ) .max(LIMITS.messages), @@ -70,6 +72,21 @@ export const pullRequestProps = z.object({ .array(z.object({ name, status: z.enum(["pass", "fail", "running", "skip"]), detail: text })) .max(LIMITS.checks), conflicts: z.number().int().min(0).max(10_000), + // Accepted for parity with web, which draws the commit graph; this card shows checks only. + commits: z + .array( + z.object({ + title: name, + sha: z.string().min(4).max(12), + author: name, + age: text, + lane: z.enum(["base", "head", "side"]), + branch: text.optional(), + conflict: z.boolean().optional(), + }), + ) + .max(LIMITS.commits) + .optional(), }); /** Brand header shared by every card: monogram, service name, one line of context. */ @@ -180,7 +197,7 @@ const RideOptionsCard = memo(function RideOptionsCard({ {option.name} {option.name === best ? : null}
- {`${option.minutes} min away · ${option.seats} seats`} + {`${option.minutes} min away · ${option.seats} seats${option.arrival ? ` · ${option.arrival}` : ""}`} {option.price} @@ -250,6 +267,11 @@ const MailListCard = memo(function MailListCard({ > {message.snippet} + {message.attachment ? ( + + {`📎 ${message.attachment}`} + + ) : null} ))} diff --git a/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx new file mode 100644 index 0000000000000..309d8c5a62e82 --- /dev/null +++ b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; +import { render } from "../../../test/render"; +import { OpenUiCharts } from "./OpenUiCharts"; + +// One board with every app card, filled the way a connector turn would fill it. +const BOARD = [ + 'root = Board("Your apps", "Demo data.", [mail, ride, route, pr])', + 'mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice INV-2041 for October", snippet: "Your invoice of $4,280.00 is due Nov 15.", time: "Oct 31", unread: true, attachment: "INV-2041.pdf"}, {from: "Dana Ruiz", subject: "Re: Updated pricing for Q4", snippet: "Thanks for confirming.", time: "Oct 24", unread: false}])', + 'ride = RideOptions("uber", "2240 Valencia St", "SFO", [{name: "UberX", minutes: 4, price: "$38.40", seats: 4, arrival: "6:42 PM"}, {name: "Comfort", minutes: 6, price: "$46.10", seats: 4}])', + 'route = Route("20th St", "SFO Terminal 2", "drive", 24, "14.2 mi", ["Head east on 20th St", "Turn right onto Valencia St", "Merge onto US-101 S"])', + 'pr = PullRequest(128, "Add model picker presets", "conflict", "feat/model-picker", "main", [{name: "Tests", status: "pass", detail: "412 passed"}, {name: "Lint", status: "running", detail: ""}], 2, [{title: "Wire presets into the composer", sha: "a41f9c2", author: "kevin", age: "2h", lane: "head", branch: "feat/model-picker"}, {title: "Rename picker tokens", sha: "c90e1b7", author: "julius", age: "5h", lane: "base", branch: "main", conflict: true}, {title: "Bump effect", sha: "77d0aa1", author: "theo", age: "1d", lane: "base"}])', +].join("\n"); + +describe("OpenUI app cards", () => { + it("renders every card and keeps taps local to the card", async () => { + await render( +
+ +
, + ); + + await expect.element(page.getByRole("button", { name: /PDF INV-2041\.pdf$/ })).toBeVisible(); + await expect.element(page.getByRole("link", { name: "Request UberX · $38.40" })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: /^Comfort/ })); + await expect + .element(page.getByRole("link", { name: "Request Comfort · $46.10" })) + .toBeVisible(); + + await expect + .element(page.getByRole("status").filter({ hasText: "Head east on 20th St" })) + .toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Next step" })); + await expect + .element(page.getByRole("status").filter({ hasText: "Turn right onto Valencia St" })) + .toBeVisible(); + + await expect.element(page.getByText("Rename picker tokens")).toBeVisible(); + await expect.element(page.getByText("2 conflicts with main")).toBeVisible(); + if (import.meta.env.VITE_APP_CARDS_SHOT) { + await page.viewport(720, 1500); + await page.getByRole("region", { name: "Your apps" }).screenshot({ path: "app-cards.png" }); + } + }); +}); diff --git a/apps/web/src/components/chat/OpenUiAppCards.tsx b/apps/web/src/components/chat/OpenUiAppCards.tsx index 5ec6505972f27..87c77569e3e01 100644 --- a/apps/web/src/components/chat/OpenUiAppCards.tsx +++ b/apps/web/src/components/chat/OpenUiAppCards.tsx @@ -1,16 +1,17 @@ import { defineComponent } from "@openuidev/react-lang"; -import { memo, useState, type ReactNode } from "react"; +import { memo, useState, type CSSProperties, type ReactNode } from "react"; import { z } from "zod/v4"; import { APP_BRANDS, type AppBrand } from "@t3tools/client-runtime/app-connectors"; import { APP_CARD_DESCRIPTIONS as DESCRIPTIONS, APP_CARD_LIMITS as LIMITS, - CHECK_STATUS_SYMBOL, + APP_CARD_PAINT as PAINT, PULL_REQUEST_STATE_COLORS, + avatarColor, cheapestRide, checkCounts, - pullRequestSteps, + commitLaneX, rideReadout, } from "@t3tools/client-runtime/openui-app-cards"; @@ -29,6 +30,7 @@ export const rideOptionsProps = z.object({ minutes, price: z.string().min(1).max(LIMITS.price), seats: z.number().int().min(1).max(20), + arrival: text.optional(), }), ) .min(1) @@ -44,6 +46,7 @@ export const mailListProps = z.object({ snippet: z.string().max(LIMITS.snippet), time: text, unread: z.boolean(), + attachment: text.optional(), }), ) .max(LIMITS.messages), @@ -66,54 +69,128 @@ export const pullRequestProps = z.object({ .array(z.object({ name, status: z.enum(["pass", "fail", "running", "skip"]), detail: text })) .max(LIMITS.checks), conflicts: z.number().int().min(0).max(10_000), + commits: z + .array( + z.object({ + title: name, + sha: z.string().min(4).max(12), + author: name, + age: text, + lane: z.enum(["base", "head", "side"]), + branch: text.optional(), + conflict: z.boolean().optional(), + }), + ) + .max(LIMITS.commits) + .optional(), }); +// The brand's monogram tile; T3 Code bundles no logos (docs/user/openui-reports.md). +function BrandLogo({ brand }: { brand: AppBrand }) { + return ( + 1 ? 10 : 13, + }} + > + {brand.mark} + + ); +} + function CardShell({ brand, + title, subtitle, + meta, label, children, }: { brand: AppBrand; + title: string; subtitle: string; + meta?: ReactNode; label: string; children: ReactNode; }) { return ( -
-
-
- 1 ? 11 : 15, - }} - > - {brand.mark} +
+
+ + -
-

{brand.name}

-

{subtitle}

-
+

{title}

+

{subtitle}

+ {meta ?
{meta}
: null}
-
{children}
+ {children}
); } -function Tag({ label, color }: { label: string; color: string }) { +function CardFoot({ note, action }: { note: string; action?: ReactNode }) { return ( - + {note} + {action} + + ); +} + +function OutLink({ + href, + children, + style, +}: { + href: string; + children: ReactNode; + style?: CSSProperties; +}) { + return ( + - {label} - + {children} + + ); +} + +function CarGlyph({ tone }: { tone: string }) { + return ( + + + + + + + ); } +const CAR_TONES = [ + "#e9e9e9", + "#3a3f47", + "#f4f4f4", + "#151515", + "#c9ced6", + "#6b7280", + "#e5e7eb", + "#111827", +]; +const RIDE_LINKS = { uber: "https://m.uber.com/", lyft: "https://ride.lyft.com/" } as const; const RideOptionsCard = memo(function RideOptionsCard({ props, @@ -121,43 +198,75 @@ const RideOptionsCard = memo(function RideOptionsCard({ props: z.infer; }) { const brand = APP_BRANDS[props.app]; - const [selected, setSelected] = useState(null); const best = cheapestRide(props.options); - const ring = brand.color === "#000000" ? "#888888" : brand.color; + const [selected, setSelected] = useState(() => + Math.max( + 0, + props.options.findIndex((option) => option.name === best), + ), + ); + const chosen = props.options[selected] ?? props.options[0]!; return ( - {props.options.map((option, index) => ( - + ))} +
+ ); }); @@ -169,48 +278,61 @@ const MailListCard = memo(function MailListCard({ }) { const brand = APP_BRANDS.gmail; const [open, setOpen] = useState(null); - const unread = props.messages.filter((message) => message.unread).length; return ( {props.messages.length === 0 ? ( -

No messages matched.

+

No messages matched.

) : null} -
    +
      {props.messages.map((message, index) => (
    • ))}
    + + Open in Gmail + + } + /> ); }); @@ -239,56 +386,196 @@ const MODE_LABEL = { bike: "Cycling", } as const; +/** A schematic of the route that fills as the steps advance. Not a map: no geometry is known. */ +function RouteStrip({ steps, active }: { steps: number; active: number }) { + const n = Math.max(1, steps); + return ( + + + + + + + + ); +} + const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { const brand = APP_BRANDS["google-maps"]; - const [step, setStep] = useState(null); + const [step, setStep] = useState(0); + const total = props.steps.length; + const mapsMode = { drive: "driving", walk: "walking", transit: "transit", bike: "bicycling" }[ + props.mode + ]; return ( -

    - {`${props.minutes} min`} - {props.distance} -

    -
      - {props.steps.map((entry, index) => ( -
    1. +
      +

      + {`${props.minutes} min`} + {props.distance} +

      + +
      + {total ? ( +
      + + {step + 1} + +

      + {props.steps[step]} +

      + -
    2. - ))} -
    + {`${step + 1} / ${total}`} + + +
+ ) : null} + + Open in Maps + + } + /> ); }); +// After @pitus_'s "Generative UI #2 - Merge conflicts": a commit graph with lanes, conflicting rows +// marked on the base lane, checks as chips, and the state said once in the footer. const STATE_LABEL = { open: "Open", - conflict: "Conflicts", - ready: "Ready to merge", + conflict: "Open", + ready: "Open", merged: "Merged", closed: "Closed", } as const; +const LANE_COLOR = { base: "#bdbdbd", head: "#3d7be0", side: "#22c39b" } as const; +const LANE_TAG = { + base: "bg-muted text-muted-foreground", + head: "bg-[#edf3fe] text-[#2a5bc8] dark:bg-[#3d7be0]/15 dark:text-[#8db4f5]", + side: "bg-[#e9f8f2] text-[#0f8a68] dark:bg-[#22c39b]/15 dark:text-[#5fd9b6]", +} as const; +const ROW = 32; + +function CommitGraph({ + commits, +}: { + commits: NonNullable["commits"]>; +}) { + const height = commits.length * ROW; + const ys = commits.map((_, index) => index * ROW + ROW / 2); + const lastOf = (lane: "base" | "head" | "side") => commits.map((c) => c.lane).lastIndexOf(lane); + const firstOf = (lane: "base" | "head" | "side") => commits.findIndex((c) => c.lane === lane); + const baseLast = lastOf("base"); + const segments: ReactNode[] = []; + for (const lane of ["base", "head", "side"] as const) { + const first = firstOf(lane); + const last = lastOf(lane); + if (first < 0) continue; + const x = commitLaneX(lane); + segments.push( + , + ); + if (lane !== "base" && baseLast > last) { + const y1 = ys[last]!; + const y2 = ys[baseLast]!; + segments.push( + , + ); + } + } + return ( + + {segments} + {commits.map((commit, index) => ( + + ))} + + ); +} const PullRequestCard = memo(function PullRequestCard({ props, @@ -297,97 +584,155 @@ const PullRequestCard = memo(function PullRequestCard({ }) { const brand = APP_BRANDS.github; const stateColor = PULL_REQUEST_STATE_COLORS[props.state]; - const steps = pullRequestSteps(props.state); - const reached = steps.filter((entry) => entry.reached).length; const counts = checkCounts(props.checks); - const merged = props.state === "merged"; const [check, setCheck] = useState(null); + const commits = props.commits ?? []; + const merged = props.state === "merged"; + const foot = + props.state === "conflict" + ? `${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} with ${props.base}` + : props.state === "ready" + ? "Ready to merge" + : merged + ? `Merged into ${props.base}` + : props.state === "closed" + ? "Closed without merging" + : counts.running + ? `Checks running · ${counts.pass} of ${props.checks.length} passed` + : `${props.head} → ${props.base}`; return ( - -
-

{props.title}

- {merged ? ( - // A merged pull request plays its fill once on insertion (@starting-style); reduced motion skips it. - - Merged - - ) : ( - - )} -
-
-
-
-
- {steps.map((entry) => ( - - {entry.name} - - ))} -
- {props.state === "conflict" ? ( -

+ +

+ {props.title}{" "} + {`#${props.number}`} +

+ {`${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} with ${props.base}`}

- ) : null} - {props.checks.length ? ( -
-

{`Checks · ${counts.pass} passed${counts.fail ? ` · ${counts.fail} failed` : ""}${counts.running ? ` · ${counts.running} running` : ""}${counts.skip ? ` · ${counts.skip} skipped` : ""}`}

-
- {props.checks.map((entry, index) => { - const color = - entry.status === "pass" - ? "#1A7F37" - : entry.status === "fail" - ? "#CF222E" - : entry.status === "running" - ? "#9A6700" - : "#6E7781"; - return ( - - ); - })} -
- {check !== null && props.checks[check]?.detail ? ( -

{`${props.checks[check]!.name}: ${props.checks[check]!.detail}`}

- ) : null} + {commit.branch} + + ) : null} + + {commit.sha.slice(0, 6)} + {`${commit.author} · ${commit.age}`} + + + ))} + + ) : ( +

{`${props.head} → ${props.base}`}

+ )} + {props.checks.length ? ( +
+ {props.checks.map((entry, index) => { + const color = + entry.status === "pass" + ? "#1A7F37" + : entry.status === "fail" + ? "#CF222E" + : entry.status === "running" + ? "#9A6700" + : "#6E7781"; + return ( + + ); + })}
) : null} - + {check !== null && props.checks[check]?.detail ? ( +

+ {`${props.checks[check]!.name}: ${props.checks[check]!.detail}`} +

+ ) : null} + {merged ? "GitHub" : "Open on GitHub"} + } + /> + ); }); diff --git a/docs/user/openui-reports.md b/docs/user/openui-reports.md index e324ebe838130..a20d3023e22e1 100644 --- a/docs/user/openui-reports.md +++ b/docs/user/openui-reports.md @@ -27,19 +27,26 @@ Chart boards can also hold app cards, drawn in the colors of the service the data came from: - **RideOptions** (Uber or Lyft): pickup and drop-off, then each ride option - with its pickup time, price and seats. The cheapest option is tagged. Tap an - option to see it in full; booking still happens in the ride app. -- **MailList** (Gmail): the search, then each message's sender, subject, time - and snippet, with unread messages marked. Tap a message to show its whole - snippet. -- **Route** (Google Maps): travel time, distance and mode, then the steps. Tap a - step to highlight it. -- **PullRequest** (GitHub): number, title, branches, a progress track (opened, - checks, ready, merged), the conflict count and check chips. Tap a check to - see its detail. A merged pull request fills its track once when it appears. + with its pickup time, price, seats and arrival time. The cheapest option is + tagged and selected. Tap another option to select it; the button opens the + ride app, where booking happens. +- **MailList** (Gmail): the search, then each message's sender, subject, time, + snippet and attachment, with unread messages in bold. Tap a message to show + its whole snippet; "Open in Gmail" runs the same search in Gmail. +- **Route** (Google Maps): travel time, distance and mode, a route line, then + one step at a time. Step through with the arrows; "Open in Maps" opens the + same directions in Google Maps. +- **PullRequest** (GitHub): number, title, state, recent commits on a small + commit graph with conflicting commits marked, the checks, and one line that + says where the pull request stands (for example, "2 conflicts with main"). + Tap a check to see its detail. + +In the Android experiment the cards show the same facts in a simpler layout; +the commit graph, route line and app links are on web and desktop only. Charts and cards keep their selections locally; tapping never sends a message, -books, sends or merges anything. Scrolling a long conversation can reset a +books, sends or merges anything. Links open the app itself, where any action +still needs you. Scrolling a long conversation can reset a selection. When an agent uses a tool from a known app, its activity row shows that app's @@ -76,7 +83,7 @@ the same facts, model, prompt overhead, and completion criteria. - Reports and chart boards use [OpenUI](https://github.com/thesysdev/openui) (`@openuidev/react-lang`, `@openuidev/lang-core`). -- The PullRequest card's progress track, conflict count and check chips follow +- The PullRequest card's commit graph, conflict marks and check chips follow [@pitus_'s "Generative UI #2 – Merge conflicts"](https://x.com/pitus_/status/2111519352503873866). - The idea of brand-colored app cards in place of a bare "thinking" row comes from Kevin Rajan ([@kvnloo](https://github.com/kvnloo)). diff --git a/packages/client-runtime/src/openUiAppCards.test.ts b/packages/client-runtime/src/openUiAppCards.test.ts index e50f9bb13b3ae..58fe619e22b50 100644 --- a/packages/client-runtime/src/openUiAppCards.test.ts +++ b/packages/client-runtime/src/openUiAppCards.test.ts @@ -3,6 +3,7 @@ import { it as test } from "vite-plus/test"; import { appCardProblem, + avatarColor, cheapestRide, checkCounts, pullRequestSteps, @@ -71,3 +72,8 @@ test("pull request states must agree with checks and conflicts", () => { [true, true, true, true], ); }); + +test("a sender keeps one avatar color regardless of case or spacing", () => { + NodeAssert.equal(avatarColor("Acme Billing"), avatarColor(" acme billing ")); + NodeAssert.match(avatarColor("Dana Ruiz"), /^#[0-9a-f]{6}$/); +}); diff --git a/packages/client-runtime/src/openUiAppCards.ts b/packages/client-runtime/src/openUiAppCards.ts index 7d8c10a1e306d..80e3421222ffe 100644 --- a/packages/client-runtime/src/openUiAppCards.ts +++ b/packages/client-runtime/src/openUiAppCards.ts @@ -16,18 +16,19 @@ export const APP_CARD_LIMITS = { messages: 20, steps: 20, checks: 12, + commits: 12, minutes: 100_000, } as const; export const APP_CARD_DESCRIPTIONS: Readonly> = { RideOptions: - 'Ride choices a ride app returned for one trip: app is "uber" or "lyft"; each option has a name, pickup minutes, a price string and seats.', + 'Ride choices a ride app returned for one trip: app is "uber" or "lyft"; each option has a name, pickup minutes, a price string, seats and optionally an arrival time text.', MailList: - "Messages a mail search returned: the query, then each message's sender, subject, snippet, time text and unread flag.", + "Messages a mail search returned: the query, then each message's sender, subject, snippet, time text, unread flag and optionally one attachment file name.", Route: "Directions a maps app returned: origin, destination, mode (drive, walk, transit or bike), minutes, a distance string, then the steps in order.", PullRequest: - "One pull request: number, title, state (open, conflict, ready, merged or closed), head and base branches, its checks (pass, fail, running or skip) and the conflict count.", + "One pull request: number, title, state (open, conflict, ready, merged or closed), head and base branches, its checks (pass, fail, running or skip), the conflict count, and optionally its recent commits newest first (title, short sha, author, age text, lane base, head or side, an optional branch label on a lane's tip, and conflict true on base commits that conflict).", }; export const APP_CARD_PROMPT_RULES = [ @@ -163,3 +164,38 @@ export const CHECK_STATUS_SYMBOL: Readonly> = { running: "●", skip: "–", }; + +/** x of a commit-graph lane, in px from the card's left edge. */ +export function commitLaneX(lane: "base" | "head" | "side") { + return lane === "head" ? 34 : lane === "side" ? 46 : 22; +} + +const AVATAR_COLORS = [ + "#5b6cf0", + "#0f9d73", + "#e37400", + "#c2410c", + "#7c3aed", + "#0284c7", + "#be185d", + "#4d7c0f", +]; + +/** A stable avatar color for a sender, so one sender keeps one color across cards. */ +export function avatarColor(name: string) { + let hash = 0; + for (const char of name.trim().toLowerCase()) hash = (hash * 31 + char.codePointAt(0)!) >>> 0; + return AVATAR_COLORS[hash % AVATAR_COLORS.length]!; +} + +/** Fixed colors the cards draw: the attachment chip, the route, the cheapest tag and the car glyph. */ +export const APP_CARD_PAINT = { + googleRed: "#ea4335", + routeBlue: "#1a73e8", + routeCasing: "#cfe0fb", + cheapest: "#1a7f37", + shadow: "rgba(0,0,0,.10)", + glass: "#cfd8e3", + tyre: "#1d1d1d", + white: "#ffffff", +} as const; From 3cf6d0c655d22e249398fb7ea36198bf73b4e63d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:42:15 -0600 Subject: [PATCH 09/11] feat(openui): app cards in each app's own design system, with real routes App cards now copy each service's design system instead of sharing T3's palette: Primer for GitHub, Base Web for Uber and Lyft, Material 3 for Gmail and Google Maps, each in its own typeface and in light and dark. - Route draws the path the maps tool returned, projected in Web Mercator onto the tool's basemap, so the line follows the road; no path, no map. - PullRequest shows checks as a timeline when the tool reports timing, opens a failing check's detail, and ends with GitHub's merge-box line. - RideOptions shows vehicle pictures and a sliding selection. - Cards reflow at phone width (container queries) instead of clipping. - Android renders the same cards natively (react-native-svg, embedded fonts). - Demo connectors return a real OSRM route, timed checks and commits. --- apps/mobile/app.config.ts | 33 +- apps/mobile/assets/app-cards/car-black.webp | Bin 0 -> 10452 bytes apps/mobile/assets/app-cards/car-comfort.webp | Bin 0 -> 10758 bytes apps/mobile/assets/app-cards/car-x.webp | Bin 0 -> 14638 bytes apps/mobile/assets/app-cards/car-xl.webp | Bin 0 -> 15202 bytes apps/mobile/package.json | 7 +- apps/mobile/src/native/OpenUiAppCards.tsx | 1316 ++++++++++++----- apps/web/package.json | 3 + apps/web/src/assets/app-cards/car-black.webp | Bin 0 -> 10452 bytes .../web/src/assets/app-cards/car-comfort.webp | Bin 0 -> 10758 bytes apps/web/src/assets/app-cards/car-x.webp | Bin 0 -> 14638 bytes apps/web/src/assets/app-cards/car-xl.webp | Bin 0 -> 15202 bytes .../chat/OpenUiAppCards.browser.test.tsx | 143 +- .../src/components/chat/OpenUiAppCards.tsx | 1004 ++++++++----- apps/web/src/index.css | 50 + apps/web/test/fixtures/ferry-sfo-map.png | Bin 0 -> 33056 bytes docs/user/openui-reports.md | 40 +- experiments/openui-lab/app-cards.test.ts | 46 +- .../openui-lab/dogfood/demo-connectors.mjs | 105 +- experiments/openui-lab/run-smoke.mjs | 83 +- packages/client-runtime/package.json | 8 + .../client-runtime/src/appCardThemes.test.ts | 41 + packages/client-runtime/src/appCardThemes.ts | 229 +++ packages/client-runtime/src/octicons.ts | 42 + .../client-runtime/src/openUiAppCards.test.ts | 31 +- packages/client-runtime/src/openUiAppCards.ts | 85 +- pnpm-lock.yaml | 48 + 27 files changed, 2505 insertions(+), 809 deletions(-) create mode 100644 apps/mobile/assets/app-cards/car-black.webp create mode 100644 apps/mobile/assets/app-cards/car-comfort.webp create mode 100644 apps/mobile/assets/app-cards/car-x.webp create mode 100644 apps/mobile/assets/app-cards/car-xl.webp create mode 100644 apps/web/src/assets/app-cards/car-black.webp create mode 100644 apps/web/src/assets/app-cards/car-comfort.webp create mode 100644 apps/web/src/assets/app-cards/car-x.webp create mode 100644 apps/web/src/assets/app-cards/car-xl.webp create mode 100644 apps/web/test/fixtures/ferry-sfo-map.png create mode 100644 packages/client-runtime/src/appCardThemes.test.ts create mode 100644 packages/client-runtime/src/appCardThemes.ts create mode 100644 packages/client-runtime/src/octicons.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 71ffcd14a19a6..94b3b8e5e7cbf 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -124,6 +124,23 @@ const dmSansFonts = { bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf", } as const; +// OpenUI app cards draw each app in its own typeface (Google Sans, Inter Tight, Mona Sans; all OFL). +const appCardFonts = [ + ["GoogleSans-Regular", "google-sans/400Regular/GoogleSans_400Regular.ttf", 400], + ["GoogleSans-Medium", "google-sans/500Medium/GoogleSans_500Medium.ttf", 500], + ["GoogleSans-Bold", "google-sans/700Bold/GoogleSans_700Bold.ttf", 700], + ["InterTight-Regular", "inter-tight/400Regular/InterTight_400Regular.ttf", 400], + ["InterTight-SemiBold", "inter-tight/600SemiBold/InterTight_600SemiBold.ttf", 600], + ["InterTight-Bold", "inter-tight/700Bold/InterTight_700Bold.ttf", 700], + ["MonaSans-Regular", "mona-sans/400Regular/MonaSans_400Regular.ttf", 400], + ["MonaSans-SemiBold", "mona-sans/600SemiBold/MonaSans_600SemiBold.ttf", 600], + ["MonaSans-Bold", "mona-sans/700Bold/MonaSans_700Bold.ttf", 700], +].map(([fontFamily, file, weight]) => ({ + fontFamily: fontFamily as string, + path: `@expo-google-fonts/${file as string}`, + weight: weight as number, +})); + const widgetsPlugin: NonNullable[number] = [ "expo-widgets", { @@ -284,7 +301,10 @@ const config: ExpoConfig = { }, android: { icon: variant.assets.appIcon, - package: process.env.T3_OPENUI_LAB_BUILD === "1" ? "com.t3tools.t3code.openui" : variant.androidPackage, + package: + process.env.T3_OPENUI_LAB_BUILD === "1" + ? "com.t3tools.t3code.openui" + : variant.androidPackage, ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE } : {}), @@ -311,7 +331,12 @@ const config: ExpoConfig = { "expo-font", { ios: { - fonts: [dmSansFonts.regular, dmSansFonts.medium, dmSansFonts.bold], + fonts: [ + dmSansFonts.regular, + dmSansFonts.medium, + dmSansFonts.bold, + ...appCardFonts.map((font) => font.path), + ], }, android: { fonts: [ @@ -327,6 +352,10 @@ const config: ExpoConfig = { fontFamily: "DMSans-Bold", fontDefinitions: [{ path: dmSansFonts.bold, weight: 700 }], }, + ...appCardFonts.map(({ fontFamily, path, weight }) => ({ + fontFamily, + fontDefinitions: [{ path, weight }], + })), ], }, }, diff --git a/apps/mobile/assets/app-cards/car-black.webp b/apps/mobile/assets/app-cards/car-black.webp new file mode 100644 index 0000000000000000000000000000000000000000..13e802381512a48003d99cfc6844bfba4703f81c GIT binary patch literal 10452 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 E0PgD@qyPW_ literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/app-cards/car-x.webp b/apps/mobile/assets/app-cards/car-x.webp new file mode 100644 index 0000000000000000000000000000000000000000..085ec04c23b9677385f7d4294aac91f5418bfe79 GIT binary patch literal 14638 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 knG%V20Jb7%s{jB1 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/app-cards/car-xl.webp b/apps/mobile/assets/app-cards/car-xl.webp new file mode 100644 index 0000000000000000000000000000000000000000..1bc155ad68e151f279a019a01bd29a5ad090abdd GIT binary patch literal 15202 zcmV-oJDtQ*Nk&FmI{*MzMM6+kP&il$0000G0002L003_Q06|PpNIfM000I9eBuMc8 zcxyzY?h_6pNevqxC}55L`!Co;HAM7(0&D|xOCTg>SACK>hl-}Ex-)awdw`3Fb2uBG}xi%M%3V3p*RK-B=P=n1b>&Mh$*%&xWVUFF)e_HF*s%|K#*KY zvLtRb110HZ>;@PN+O`oihd=A>{wYMn1k77ds`*$!S02jYtSezuNoA&j{wiowG^>(s zxv$}{t58s}sz$oBVMK8abZSkMI-rE)dCk+@_Pjkuk}cb|B}pPW_Py`_-~9$a4T1q- z7+B9>GQ-9YtM^oG+-C&{5itRtY}-~O*|x3T$Cy(@O$|0PGc(8NoD<}8!Y2WzoGdWq zC8e_Rd;pw zGzN{eZF~1++qP}nw*4A=uURyYk;)jc&4F#$J7@`k`<8-|QRwuuY^ zZ1|FGgJaOUmMIcM8W_X^U;#7XpnxB7tdR*0_ss8Q%Q}yh1k5l1M1=uFU`qhW1SCQ&4FX8Msv$Xn0#fg%I6hOHz_S)&PKDX1Z?23JIHz zZ1_+^RS~9^bjfyaaWlx;d9G%|Y-k8Y`ecdEujF@dUlo3 zhin3w+cUkT+?rOFK<3Jnz}?HcXL~K$nh$R7<91*)nOvj)C2z-;vl7aH7jwre0O`^_1*vgQ7V?_bKB-6UC(8`F2pnT|rMy@C9S+uZ?U^cJ=Ik^7cI}U8 z;j7GzUzI@z6CmJc6ZNt;Wlyt_@BrdBc1AH4oZ0ug{p^qa2wr)Xt1I20^wjm<%wZ%_ zT@=kL4c3<1oy@$H%dLLJcU}L*o3+FHkBKl@beyfFwcaMp))Co&-O_ZuC>7K6H@ZOg zm;O2Z(gFy|%(j73kVSSaaM^>)DtpFk*;C+MmO(^<9AXHC#C!FJ_nT|yvn;1J_qL?! z#nmvH3lSJbuhmlMky3)e#`NCs%FmtP{cn27a8THvPL9)Jyfwlcdvnfldh>*yRLUtT z6!uK*yT88v|5|GZAO$c}WwP8bZUcE60oPu5$DU`gy_7ZKk)Z&yC5G^G|05sQC!d)e zxIw}9Z)zF5j8~9TP|}A|y&&fRSzTvI7^W-VIDBH&5wSeF=l)*MZEO8xbNSDMSc=8L z;zsk}>~f>OuyI)*H|x1_M`{z%@%cDY(t2^Ey!>89Wa`Mr4KEOu?CoyDtxIf&M@QqY zFaTNe&hN%Q%++euR*g|MvovQh{dH^Y<<8jNk#46k+>4wE5r>>< z2$dwo;MQoN7Y{Bh-Dt0XvVp3m8!08s9mqG>xE1US+$Lr?Wh|j~ZbUi{N--OQiAb2& zeC-#T6U~DPcdBE=l{*Pre>abe2)$Hk0)PMm)Cve&TY_d60Fs+mPAx6Owla~gR)fgY z>Iw8t%`fwljU}&YiET})wlltoz-p!A22S%G-&2Ma0D~<7AaI4poXh|$&D6!zuB^0A zFoOUPPGx|Y04B>s1nghARjRd)TtLl?BqC+!Pslp6xiL=M9j|fO!ZVY;#IBsL!J16i z@wb;@1X&OWJKBoxICJ@Oc?b)E9m&k1YOJ3O@|dg`q+%D>)=iz!xJ_XjOA03sdBUdD z4{HrXG`0s?62JuHSt5CDDmV`-KJ&o~7jK#fJDK^$Kc5$kpZh`-du^< zN)l-e8y??=@Bnh}L0Tsl2Sz}I%{<7hBc&WP#JPOa|H@mHi-LC)r4N2K)NlFu1S3ec z)2ME4L^fDw#xf&h02^$^A;OdlxcdVT00vgtrd9z6#91=ko9%h$TjHJIIRP}tP#8>f zLM@dLE5ZuWh2;Wcd|X|&tbHWgNCZ2GfU8z_6ovp0dg~c1AmUt!8+os0Z|aV-zdYYj z$>GJaWVS+^bW=*8g;LWk4;wS@*gFQvxtasxxLoc969DGEoE~$rvuWS;_VVrS5@;s&Mu+ zpWbx!u1i82ozI!Tr!$OqygRL+$z9k&E1MZ}DznYlUSl(p9j_(W=|o4VT;e)_=Lda# z)`dNv3%1T}SfbnZfm=m$a|7ti!4k%naF%0E6^Dei`FdaPG_vkkva3>!OX2)2omm(p zqSSFM$8C!*AK$YDd?0Z4Nso-bw~S>-zrJi@mB0Q-@djHjskKmBhSW)XUb`z z-dvYrxd;Q-Xrda&ht2EbBX%{cGNeoson{l(wL<8)=SREr!0i{n_dnSO@ z3*_IsP9ichfGolTgF1-ZDIzLpaCIq`=M>0{)54d%hztsU60eh zD?o;`+zf2f6@tn*ozGt(zU9R~{1+|=B8WF_h@C8f#FjUIxBA9+lq*Ce@Ix-PPXY-5 zIt3xV0MspR+uF*c?R49noB7Ied9+q-)QVMc?;pKfyJRMH9gt&Dx5*46VXtld!=8UM zLTeC~|9x_MyL%FAwxo#Ur689n%-n~HF!NE7`xXUwEdcspP`+_|@pE?e;eT26EdaO{ zV1PiDgEd9S8cXblsD7gEtkx0vjNw!9+rD8!@{OPj_H-`;Q zWQIX71XzGASq6DQPz{1YZFuK)DtzZBzB0Y?&g;>pC$FisJ{C`_1t`W?a-AS>Wghio z6Olh!R+Q&yxYUVOSJno}^aH;;Qgnmj_&5jzOPd7%NB}q0aD7hFB9b<)Co)c?5UwMj z1RIOk*?%j8B_&8&Az0XrU)mp&q0QXKF%2mMTae@&2+AXks!LWnu3vgHT7TMYx0|-8 zZ}PGFn{KZ8=;iU|>P;IjJ&s~R$_y$tN1L^xjMg?b7T4R0OPyG&Lb)s_-u$=4RB8DZ zEs4Dgtv~j~ zPrg-_t=0+4DII|!MY$=|Mk_vq5#>tB6VeV|d39dY_APqm)VhyXZyz;1Vh)|g zT-ks0*r9Rm&tAGbGq<6Yl!{3@3c^zRtNV`@HAg1X;<9o5-5@JxzGuy0-SrAgQm4ck zgF`guT5Gk@mbK0;lk~h`vi$Of(=4hnvAR}r3f)RE+4UV(2#j48+oB5*a)0-s{Z2YG zZe^tE*+{3Wi-RDF!$3J%E(Ww+PgZt899-;996VGL(c0|X;`|`DzVEq8NkMEm%dH4r z`|96yq_onW>E~i(vIT}Pzr4$Kn0%RmEo;jr&-23qZ(hZoSP@pcsq* zO$)9k_f-0eHh=E%1;;Bj%jFUGNY_zBL@Sl@d@s>y zZegv}))?vH@RS{ERLf+!;JcKE;nm`|ML+Yj*mYUx{{408F9bm&{!JsMN|AtU1(ac* zIe4_iY3@jhqRd*RsxlCY3}mthwkdrdPLpL?q1v9ijvUIbOEf#7ZSv-2*2 zkG^|8?38*-ec@u?yco|yPtm9T0m3CA2SyZm{nOun+tdmV2PV(0QcB6RR4~F404Ky$ z9dZsg$dE80p1$73O?HyX@q9_JCP&_ls=Fq}xVOAI3~jklE*51Lhk24l>;2JUsdr>v zQ1SGjW%9;76a>B_mFxI}mmR~OJSnQzoh-}J+~(Fww$bFD=fPy@JDv_!{Cw0#ZYlPI z`mwnc8H^VkU?Udapa0<>|0#O3+zW-{N~IWtz+)B!kf2mr#gc9$!HfX8zYWh0lTJYj zn`y1B?NpE4Q|ZrM@93ae3Or?_FpTp|TNYMm;n7Oj+dx>WiN;I1Yh9z`?%MiV7*D)S zY&D6IhznM6r^P0GLIh(*raDa#)6-xHnAUddhW#)z;)}S%vmJhnBD2*if~KtCMNh z(39XHB5d76mHOI!knp4V&eGrf=i{fh$|T0!FF$&@>^=JAzs^Ym$mK_bPO>#RvO0Zq zrAfw(>dJ~>q*^*yD&OjLqg?4G!C{-d=bpMGWkXA{)*=BTO~_;j2)IEw3<)5ihV7nK zj_Z1^upnb~ZsK8Q`U^kzlx|jior>6i?sN$RC`9VRuMyqO^(z;yt@NE%Gx*=9x6@3) z;kV!Y%>j7kkEYJBfqc9Vu{zJdeL0#y`jN8zgEOj9n;L7>M^m_L1g?+qn(ET$Mg&Ra zc>-i>Z3(R+P|TcG3@{u@EDoiwq$17|W7!H4LIj1@*FUN6lchb4_&1&jSXhDzBn(8x zj1!Ob1?Zk$W3n|OQ1enQdInt>rg#7NyFcC8c;H&XG6uAn)@%(MC`o!Y6|2=ZIqi`a zmm4^wm}DH7*L%MEzrUospznL7Bq3afDD|baOeq0CmF=(#&TQQ-e;R>~tWrwQZofRW zzaF&4rR16p#W*%U{uR?X!3bmmVzF3eQOef(E}AuCve1IkF9FgDsT#|sTC;~!4#=Nl z3p`xw^dhY-MZ?#;>8)>iTVXh}GDT!Hd|?0&z=!=0x;=W+um8Z2k#|viJRO z`@3pYG8=L)s?1BRZEnjF+1-5ri%^Y`anp|8?xbsuytH0OhO!|1AQSgEvg%^pzT+c5 zLM)&CayL;fQLIosg_xJwTbz*R5|tyt5~K$BFplG<3=TFU^209ykH zJ7#qEPoGP8ZM8}YU;=W%W{nBR&7r%}gkgFd#~IjZH-D!3>;bN0f1o6RQ$7xW`IZ zEy+3dGpDW$%o=ZFvq0Q8GXxmJAG?COJ8`6ujE#=pY75J*UU1;4*DjM(<4`2^Fw;*Rs^<9I&)^js=47z zmA;g*;glHW`04u^89>5}e(;t5{Eqz(>de~fl@TU{al??$wiW9J5_dBt=f_7orV%g! zNG?9Mc4m&&dd0G<$#Xoe?GA=no>?8yp~od?jRNY&VQ|Xxa{7Q{`YY8}+Zhx5!qxg=V8QR`xLH%`bTM0-N0E zjN9M(v884ccdogVRvXS&97`{!V8ibI=6BV#U;y3$n3VtcNA)^e3);pR8u=T~zHGEs z_8sEfYUVU--&#`5isa1ZCa(V!4aRXd!x9i@gh)g^Db`bU4im)=$Ky}0z1fNH7rg;? zYVIxxfbgP2AMB?r1rdzRZ8BJ0E)+ah0>qBUUWdhfxYrDSdg13outv(0S}Hg_aT7>J zAN%Az?^Z`i^g*;UkcQ0%WN9qa8l*T6H#(iw)%9LbZq=1ToEcp{lvZ%RHrHJu-5!cT zn;?~SaPl~kc>Sr14PJ6<^~w94ihomw7q2G!gXb+_03djbW>R*fMABJaUS92naTG^R zQ1{BNA=do^*qFF$BX*rIyC()11Oi}*uvy$076?z~!HjagdD?mQjK;mE@|9^{mA~zD@E2>nz^~!5A!t7a#qEnS4&s5kWO)&9o+4tw*_uEnE@scwzLKg0ERZ~ z+$QeI4&YTP9N&%6+70484+Wnr?#2$@w^#@QS~`EL9c8&HdP0)03m1clUc2BjCna}S zziY}O!f`ZjB5Y22@y60}f2iGH>Ln90W2592`$zpQy8zG&*^S4Vk1c($>d04$`w%4W?-X(~%iNp2w<#7FEmIy#7 zDYR=}+VnwU)5Y5ZcWValj*Cr=krX`;AcG(M^D`tIAa?+0!T>;w(E^yQkwmz*#Y5B8 z@8DD$$|@0-?wYtS8|HyPV0!sK{^`p6${;bqA+m?hN}X%vj*K1Hx95!>pcMfSjyC|W zMb0d!jcu;Y{LgF8=ly=B*O|88weV8L&33FqW%1$)NpF}}j=Tky7=$h9HdDZ-wrq+! z1%TLwcpR{F1pzPwIv37dzS)fpNg)JTtviR?d1d@>|MqYG?k|4h*JX=C+Nvi28(l#B zyCS%r*WdKNx5oChY9$=mm$|pyZEr1zkO0bR^@2c2w}v}p!s_d09EOlmC>Vupa&BmG z7xAVpa;ZS*KK9hL4Qqwe#^hS7LS?%3(#h6jB><}+C+nu4g)I>Pi5kxoi@KtRW3#%VlUZFe`;!a-(P6w1ZG%f9%4T>~Jqgr~$>6SpP&#&vTE-W7mH=IrtA zE8IA-rOD|%dk;27eQY)$5afTEMgZ8YHXUiyCoh&l^b@_#WaYJI-uu8HmriL>yc!?? z2PZBq8g|#0XXn~OVn}idQaXX~zjfCUjHM_QWTtRC`KMk1x^Sdz5b%wgldl+Uj!)k^ z+6u7QGKfI9!!S1-lCwRl@*ix9fl0JDLo(jDwfBIvR)~I^rH+o+2UrJY3IeSV$cScH z=bW$4U%8bQtQ7(vB3t{GBl2-+UejM4#dg(!%45_OLi66&?5?=jY#9V1+*O0&K0~En ztI8n}@@)AulI8r3B(Qw}ASB^{1|a4R5RvNum^nY|NNaVLLrN+cxCYI6vDu{I^0|C~09Xcy1fECYWOV>G`%P+O9g^HU=b!p5sV1kHwh+LBhh+v)HgiS!#+Y2Q z6`27#1Z=si#lh}200(gJOl!6QzziaRogNbn;Gch&TU(KmSr&rVAN=gbi(H8vZ_N7Y z&}zd*=P~ECcLV@HM6Lp0e95%M-bSnn|01pl07$%$j(`>a{#B;QVTLS^%Aa?jUtnek z(2fER)OwqYv1z80?##*;9%MzCce2syvkT*5>oLRw@zHD7SU1B9$&@MyuxNibQUxSn z44-@H_fIonO9r47uJIuNqX(t1aBtu+G?ho_Ex1@I@fR zmyS5Zn4d>q!jLq_#p|u8l6h{UI#H_oxKG^^14PM&xf(Bj=337kDW`Fqkyk(TP2fPm zGoY=&HR!z?+-U_bBKL`RYcj^YnoA%OnW)vvv5*4##JNthUnqxPUEiSL;`(BQfHI7B zPn(U5LhLFQEWN<>{a}9uOl(D7!=0BANAiIfEErE3tlro^3aogIW!FoRH1pafo`SYbUP(7EB%EflrO+g#3FE5se>;#=)2(iaw(j>8cJyZ%hcS`0i0Fdsu zcfo>55H<_oF-$Z2(s3{q9iZTanHLa)fjxNx7O+f2xI+OD`Wz`}nU^*#(Hqs~Sw=^_ znOA?M6>J2+%p|rTj{)T%!1m|97W4f{0*u(QmtOuG{p=RUlP(rviMU93&ZABTkwlsg zA18xt3R)xa;B!B5?AwjR9SVUvZH2&V3#2(vHuq6R#OnT)Q~k@cs~7so4!`+x<|B`= z+(ZokN!H6sFG!Fm?xlR{H?Gp&?w&;f(3-%t@GpM}JLPolPy+ziM(vE5x5Equ5n(eA zPY=$|&z%b&bsU-`y}xkmuW=;jiU^FE^xm1%mAnfu_pI{E<-Sz{48} zfSE0C-hwTG2wR$S;f4827t>i|tRNTwB`Bot7e@Ydm+k@+m>AE!f=~6vbJ0uzCW@71 z7|+@l0yOV=|HA<=kWrhrv*q$Som*Z!8(v9=8Km$8u_j8V08oJaQulYhAdi6EXjpk; zeJr#=9l0WqIEW$ac=*Z<|DNw0)%9~IyC4QpeKHVlw7 z7`haOjFIaf|7+gGG-vh#_1AOvGYtfwsj(mxxBhIj&iU(~cWwx6ve($-G zHi56o<#MSgiEM6?G&f)Y2{E^29~vmw{6|f909y1vQ-6h}hp#syDzs7r3SiohX6^aX z3h#q>c&6EC{>&^}X0`+%Ul=Dr7D5QZ-BpG*ECnSQjvrjVpDBFsAhN^@;IJZyd$MVWIhsPUVW+d?UwG6R{!>e(>WSC3e6FHD{H_G^xU zAQlw{OHoLt;P{17y;%tgWt+!2FrPeJI3osO>zI~4MZ;lSFZ5KudGk<=P=qAaXxBt% zL389IxVx6V|EZP=e8+PgpXjMltxyh%CEsxrb|w?Rbk4b#c@7K^HnYysX?i;M)>2mL z9^w?mZLCA73S^fF$r@$QtkJQNyTbI)LoMrjL9tM(mr&WWb=;n}q6e`YahZvhKl6Sf zJg|tZt&Ru7C=O$Dqd7@Z2uDfGDk>=)B?x!w zHiR9>SVcpDbtZj0p8|1c(j-fxXc!HXG|O_(0Q163q?K!Gm6<9!LrCCqcRxPAwI)Uz z6<^_BpMxhL-x$Mf8Mh^GvqSTwJTt?`>UjQ_{j>fsOtUmjk}S!xTxXUl)hrw$0!g9( zHx-64XdFO#DUw7|1^d0<`iC!Go&rEC#EX2jbAN^DZip-DJ*M65E_grghl&>wCU# z>v!iXlC^LsW$?>Nqu~NTS~=~FkwM!M50p92``yw+7O;}CLXgi%WJ@3@;f*)mlJ?cm zMKTt%VX#363k!ndjhz7yAo?`z=4F!PwU$yzWre87J}z1Lfgy_n3M;njQBwn&sa%QM}Azcp3d-;5&|^s(Zmb>b`hzcOpa6aIhRp zn`zd0nkGqFBw8s(D7~1&OR`f2Gc!PsV*MS-*gju!vm?IExT5lS`eE(rV~@;O_RBJT z|Lc8M^4rfJfBM7ymLGro`T2bAbBP!J-`DJIX>H!`<_IipKiw{UZtAV6FLR47^*OTl zKR3GewXbh2d&^!2>zf^`Vk{?H!X9ACGbP7==Z3h~y|wb5p3>S^zP)hg<;;)!@gsMa z-+uo5e4^Hob;29{_TRpJKerZ{nLW@Iz4y7d2qm&d%amHE2W|HqWn0%LdoQD~?b6A3 zI0*itbQs2rl*omQ)waXPB$*uF5B25z=FIIr^l0G3GC&E$CVMUK^;D)=m!kI+BC} zCXvrJ^tIi35R!UGQqN!pvm;a16Nwbq#VjipTF|;zZ|;e1BN<^eg9Y5%&bM2b>R28tRtwD;I1zZKG}q0;_XF!#1fX`6t^c`P)bthFfEiK z63LYWC<#dXzF0Q&dCZl_#wJ_9;5vignzcE6B}_d!_fC9G!V=w>qpl}ph}k$nC?eDV zW5~!ed#RX-Ob9>_Qqm9r6*7oqN(A4Qv-Q^D1eG_8?hRn21SZMG=z?xpIjC%#F#6x0 zJX3@SZ1nOfLLjc9dDBfnAa7v|AWI7YnGhieCIzV^g@hT9P|6IH4A6&-Fx|+8LuUfy z;$Ui7XlR(enE?P+P&gp=6#xLxO8}h#D!>4606s|`j6|ZLp%iL%;3x!yw18_^A_tWp z41XoypX5J8{%`wV?XUN~PMvS&FY`aazqs=w{(pr(2mj3cxcCVCm-(;x&&^M8f0pi1 z*#FXh()ASf&+7l}KfZqM|DE_@`@cH=(0|MK0sevgYyJO~Z;1Dy`vHGL{>}d{{Aasw z_CNUl(toD?x%XT82l-$7pXdGp|CfJY|CRn9_qY8I&hN9|-A`r@*U$WJVwqzC5vi7* zl9#{?SF@9kq1K@;Z$+%*Yg%fVeg<%EDn}1~+&*mA*GMhp;_ZheA3>n|Vg=Fk(eIe+ z1blw~_z_&3TkO@T0n+vB@Y~LIOP334XCZGdT(xc6~Aix70AoAGcf#9^_K#DWXA{(SY=MQ?Qms~t4o{_z-KSy z6*4;&R!Z}cG_av!^xj#hjD+0Dfky;hqS9Mn+WHuX1^JCV->X9%r}vlPCf*Yht%Wk^ zK1U7Z?i*|J-n(BmX7>)gP99fH6(Htl4Sx0qXR=I?R<|n+s5Z5(R+K>eN-omaRTQfCXcBXzfVz9i2hj$HWO)0h% zM7A3s*n}+Gbo)Y$5?ELw9q%y7oLrtLC4xWnbJY}Jp`+m%Rd${y4Hvdc1~)ir%|iQH z-Y`8<_O;D+;5IIRL={_ChCgQA()}_oj^m%&uiPkoZB%o5RdOhAzw0&L5EMJBs1OeG zt9V-52LiOtsAa$Ga?yNx965N%$vq?!=$PYtkbt2j4@wp0)w(z(s(1KX(c zX~oXFkpFF@`os+XDBDEwb*h)hKe8XU7nxsuj7u zb8@lfP_UAzV&seM9Zh+|WxyIn-_>81Gg33D5kv`}#l7S4&qn)yNXH%+GwrdH z)w>H18)Rn#^lhF1@AmmLZ8=Sab3YE*J2*6-yHe3piz3EPdt@UQYU=KzPG?Xucv)R_ z!ELKwe;t75*IslLWe>Ld*(BC$DNC-oJE~aHQm(R>(#9o20aDuMK2eyi=igIpYDRac zBKs1Sz5&QEhcm1^Sah$i9IXMkK9^G(b^Q33QJ&Ww0VSwU?jWI2Rzmrm-$QE0FB)#kaK5u^Rip$lW1q?@&MYOlOA>so2wpGjt_km-C} zhuH5;FR;Ie2c&(6o9thXJv15PK4O$n&TEM6E3Om$^GL%$_|&bJT^m7dO|m_?gR^k- zcqMmuKfRvb)=Zb33L_^0c45t++=%0E+h$=b95XjR;zVzlWiT8qx`OEQb)z7tJ%K9N z6PG(`Js3cz;WHDRoNXG}0O zlj_^BKS%I^aO1$D{It}}Dz>=nSV&j#sQfewZ1NtK=mz9;TnO{>Ik<=W&a1ddAg#u? z3=sVTsVq(BZ2FMrl;$Jf$4ma)fc{+d9^fz6*BV})LOJ}pG9HzT2Om!CI_CT0N`zdK z{$l&y%7`;y1p*)P+3dfJoWKkSMrlQEmk#9UaAuQvdo70XXl7sOoSj!rdfnJ5OWhcg za=Vp0UDZUUS7z!%ufG>rlO^o8!i>=GSIylE6TzG;yN;5!l|29vO1so}zEn_pZx>jV zeD4!Dh({QBdO9`)GeUN(fb?w4J<(C)6Td~4+Ewu(b~tg%%rY0;UiH@w^39o4VBkkZ znhLM_=s{g;mn|}6qydmX!{LL-=P)UFyA1uFA2@77n2IDlxWZ>vEB?7jP(5#Wu zzp@T;HaS5WPdLoFGn~TgmX)KnH_YHwXpVXzK>Y8uZ0;4$##ncG7s`&{+eTU&2jx_N zTv%9wL(o}V5<3;3hRkDV0WRvacOOGsT_D4L$84BHa-hPm@3{7b8d7qW&2n1{4%;dm zh(&wzvnv8T?lX}u5J;G7M7M-v@wL?h^_rp!Z^H@=j{;()_Xwtc58mT62H_Y-;wAlf z$kGyAab|8BQd(jS>w03F=gphFP^gA*!;+-t*va9_A#i9O$a;|S*34tEu++ zbIFD=>P{es(ggQ1YmMXTyyPHcjq5m~kXijG(Y2{x=ij5mwTrBO8K(EPf^kb(g1`o^Aip5qd-V1liI?*rTWI2e&4ncmOg61Z5 zdGMn7snzA`(OmtM9AzV$0Vi6|$ra`s42@cg=PLp-Ro8$AQX?I_IC78S#6y|9P3mNe ze*-n;dzfD9Tw7i1dK1n8zE`n;j!JG<2aCuN=r$=_xVhK6*D>)^GpPIUEB$xmVK!dv{dwsbFltC0ji3AYdeCo|TYh!#6qowKVXYrZW% zqb_JB0%**GSic=%N9lXOwb~B@T8{B}dnR$wg5%5j!zVme2VM^zM=RZqXWPTr}l+~(>h?XxlCwoO4d4GDz@YFH({jB$#7s-N) zR>Xhthvh3>avhJp;@hcT)NW1Xb7R)&1luwavJ}Vvcb)`!_|4g-T$GjDcsyesXdCO; zMR+$ds@=mU6vyh?-!$`3nFywZwRU7XDw0s>8$GlW9MtV9Lqok{?=jjl@Rr22-04B3RLvNLl`-(b7d+I=VFB|Af!+Md+-Z+)B-dG;l*$U zhN$wwJitCy0w^m_;o}H!p@L?Gbt8nw*6t^iCad$Y;vwXS2!dhYDPsUFm)r^c=R=0b zPKz(NW;NRlZm8L%2{dD{B|G~h2N3=~+lgvcfXGSv@9kdMvL2JKqrtPC^#<%H+TZ{r zFXBraTOZZ+y_U)YI8c!8o~+|Uv{JiwkcBiaOeoTIG}&f@`S6lG<{gLz-RdMtz)Lo$ z`JAE04aKJcU-7@8IEoVUi(r|uO)bALIv;yJV{skM!vwoyN1J`9D-vi;nO}GQ3RX-uEfdclf+o&B5 zcN~9eJdycRQtvm(ZI8KOl1j@7(sy^^zexUrv(Oway$<__iWl(Sx6?w_2$o%5R7woL(VawbhxcU(GzR-}9CngTG*cg2>2S8yrJmpA^n zajz8p6ApDFiT6XBZuW8wm8gRXj4~WzZ>!4+2G^XDM5YFr36N+sl+yj&1p9pm7B=7adYcgMMW_A`Tgp_akEGeYjcLfm zTq&Eaa}>tI6$Wr>Mn&RUoX~i0U5RN6A@XHByVNl_^%10YuRDDbWolIE0SNMaHv2-3iK;`|CEn*P?DKCIxkwvY+X{W zoEQqHr=XA>K0(093W>Wh6KhwYHQ-^4!6_cI8Gb{<|NCEWf3#h(ejEa@ zk(#$=NMG~5sU!%$>7vkSC!wE`9rl*!)dC#4s8K|cdCUB{DywXU*0{F(QAdCMn=w;J zJdU;E)%o&pJKI(xpJ%brvi;hi zbW_&gjwP8rZ&d0p0|?BgE)jUtQG)?5Or-+Tt&Y>lOI{=eU2|zW@)uz$UKbOmLzbU7 zx`OnSvR!VuC5}n9ZyAn`@Nh}Ey#c5;mP^XxNc8V6wy=_U@#ZlnN29z*q1^LfQSk~m znM0d^;z`mTHPAFD@SCmwR8L&>B4g|9U7+Faov`j95O!(MQbz4>8wTt?;CXrrZUHg1 z&nIrU$r->_@Wxg58GoQFlk?pp8s#h$T#|cKYQ=3yFUjI0jqtwv9R)a!k;W!1A1ewo z;J^hxfack-@jPFt?(|SjTzH5+56|I!4bDR1{8& zr2)#YsS%U-;GK{BxSTH&`AAV?4bO|4KgxcE0m<(`NzuGR8o+5*IV6=LHEbfI*TAZM zkGLfSvg?A%6p>6$##Ss*rr4?8bAF<5JTi~0J&N?uFd4b08Rs#7q33r~F!1mJ!1qfK zMmL$gUQK_%w;BvkdR2xR_g89Nb>;S_O6ouj8{He2t;C;v&@hT`UlugJ#Bm9XJ04n2 z*LGhpSQH|La8m!a(KKbv=j*e(vm z3cqiq+oV1OI>+;oNp{}?`OHJx;&1UrUm3W#4~mB9Mkkh*6LXa?K-K)VXy3Z7-Vfx%<< z1}|E)r%3vrwek~|#MN7Nrr%|-QQKUO{mNLct7tFkd|eBEIJC?6_O5MWD0XH3%8!~X zb()knTd)B=OfQqf(#W6Z;F!5!JM?`c6@=a7w>bk7NEF|FQBUHkWxMXx7q6><^_=ez zn_ZjD9BEK;huugQ@+nLL36?jydp|t6M+Dq7b=iXL*ZrrOz1m@7VN)N|i7T%4(@ww$ za`wFudHI+AMSS3XAcU4)V48+jo}@lkP8Yi6u7y^$tN3E=F3-;$Xso6387fv(h@o!3 z7g(0JtFvd5-We~UZ_)pu)u>b!tvm*J6?#1PK-Cy?4O0UgqEMdOtJQtoIQD9GxOh=k zLLp$05|SbRLfvowKM>dacGO{`PEsuWo7BNup0c%Sb+Lo4vh#I1)7%~|+G$`sV)`g6 z5FZVZwsZE6gp(bqJG!*S#~Prv{wLRMK)i&agdY}$0}WLGi0YS?av4awYE~rCb*< zWM*lEQ31Z>g9$*ZFV_Hq)ejCCv_#FuO+GU_ooe?r*4Tu3veEnI zZflNiVV)`qe2=F+*B{Z_M%2>JYnC zzfKJiMV#%6G%`f8ZjacseLnfJ?)(lzXFwIXX!nIJwi@}RS}Ckr1`B_)=?jQj8rePj zIjpP7gSq$qyZPr~NZQ39A8r=)3IfJKjSI8mr9M#W6dl{9cSGCpznl=98%`E{udk*B z1Gns)y(x6M?EV~|Fulb^)RvwC!q?2LRgyTt7Gmx!`2FoeS-{K?XmG zH!%i%T+O~k?=LiSM8-g0+CWDgo#`Wg`|n^`HSHrGM=gXHZBS}-5j139EeT?#1=VZ% z`u|llvZr8I+!-zd!!hon+aWM!(3PFV&7g0fwEK^&tk_$Q;y*gZ2^aA^CeCcfsoPTa zgHI2Xop3GD*iM>1>&RpW86>?Kk=<2S<8k%(T~yrBe7O zp{73UeFwlwoJKkBi^#)ol> = { + "google-sans": { + regular: "GoogleSans-Regular", + medium: "GoogleSans-Medium", + bold: "GoogleSans-Bold", + }, + "inter-tight": { + regular: "InterTight-Regular", + medium: "InterTight-SemiBold", + bold: "InterTight-Bold", + }, + "mona-sans": { regular: "MonaSans-Regular", medium: "MonaSans-SemiBold", bold: "MonaSans-Bold" }, +}; +const MONO = "monospace"; + +type Weight = "regular" | "medium" | "bold"; + +function useCardTheme(app: AppBrandId) { + return appCardTheme(app, useColorScheme() === "dark" ? "dark" : "light"); +} + +/** Text in the card's app font and colour. */ +function T({ + theme, + weight = "regular", + size = 14, + color, + style, + lines, + children, +}: { + theme: AppCardTheme; + weight?: Weight; + size?: number; + color?: string; + style?: TextStyle; + lines?: number; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +const ENTER = FadeInDown.duration(260).reduceMotion(ReduceMotion.System); + +function AppCard({ + theme, + label, children, }: { - brand: AppBrand; - subtitle: string; - accessibilityLabel: string; + theme: AppCardTheme; + label: string; children: ReactNode; }) { + return ( + + {children} + + ); +} + +// The brand's monogram tile; T3 Code bundles no logos (docs/user/openui-reports.md). +function BrandTile({ brand }: { brand: AppBrand }) { return ( - - - - 1 ? 11 : 15, - fontWeight: "700", - }} - > - {brand.mark} - - - - {brand.name} - - {subtitle} - - - - {children} + 1 ? 10 : 13, + fontWeight: "700", + }} + > + {brand.mark} + ); } -function Tag({ label, color }: { label: string; color: string }) { +function Octicon({ icon, color, size = 16 }: { icon: OcticonName; color: string; size?: number }) { + return ( + + {OCTICONS[icon].map((d) => ( + + ))} + + ); +} + +function PillButton({ + theme, + label, + url, + tone, +}: { + theme: AppCardTheme; + label: string; + url: string; + tone: "primary" | "container" | "outline"; +}) { + const fill = + tone === "primary" ? theme.primary : tone === "container" ? theme.container : theme.surfaceAlt; + const ink = + tone === "primary" + ? theme.onPrimary + : tone === "container" + ? theme.onContainer + : theme.onSurface; + return ( + void Linking.openURL(url)} + style={({ pressed }) => ({ + height: 34, + paddingHorizontal: 14, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: fill, + borderWidth: tone === "outline" ? 1 : 0, + borderColor: theme.outline, + opacity: pressed ? 0.8 : 1, + })} + > + + {label} + + + ); +} + +function Footer({ theme, children }: { theme: AppCardTheme; children: ReactNode }) { return ( - {label} + {children} ); } +const CARS: Record = { + x: require("../../assets/app-cards/car-x.webp"), + comfort: require("../../assets/app-cards/car-comfort.webp"), + xl: require("../../assets/app-cards/car-xl.webp"), + black: require("../../assets/app-cards/car-black.webp"), +}; +const RIDE_LINKS = { uber: "https://m.uber.com/", lyft: "https://ride.lyft.com/" } as const; + +/** Uber's product picker: vehicle renders, a grey selected row with a black outline, one black button. */ const RideOptionsCard = memo(function RideOptionsCard({ props, }: { props: z.infer; }) { const brand = APP_BRANDS[props.app]; - const [selected, setSelected] = useState(null); + const theme = useCardTheme(props.app); const best = cheapestRide(props.options); + const [selected, setSelected] = useState(() => + Math.max( + 0, + props.options.findIndex((option) => option.name === best), + ), + ); + const chosen = props.options[selected] ?? props.options[0]!; return ( - - {props.options.map((option, index) => { - const active = selected === index; - return ( - setSelected(index)} - className={active ? "rounded-lg bg-subtle" : "rounded-lg"} - style={{ - flexDirection: "row", - alignItems: "center", - gap: 10, - padding: 10, - borderWidth: active ? 2 : 1, - borderColor: active - ? brand.color === "#000000" - ? "#888888" - : brand.color - : "rgba(127,127,127,0.25)", - }} - > - - - {option.name} - {option.name === best ? : null} + + + + + {`Ride to ${props.dropoff}`} + + + {`from ${props.pickup}`} + + + + + {props.options.map((option, index) => { + const active = selected === index; + return ( + setSelected(index)} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 10, + height: 64, + paddingRight: 12, + borderRadius: 12, + borderWidth: 2, + borderColor: active ? theme.onSurface : "transparent", + backgroundColor: active ? theme.surfaceAlt : "transparent", + }} + > + + + + + {option.name} + + {`👤 ${option.seats}`} + {option.name === best ? ( + + Cheapest + + ) : null} + + + {`${option.arrival ? `${option.arrival} · ` : ""}${option.minutes} min away`} + - {`${option.minutes} min away · ${option.seats} seats${option.arrival ? ` · ${option.arrival}` : ""}`} - - {option.price} - - ); - })} - - {selected === null - ? `Prices from ${brand.name}. Booking happens in the ${brand.name} app.` - : rideReadout(props.options[selected]!)} - - + + {option.price} + + + ); + })} + + + void Linking.openURL(RIDE_LINKS[props.app])} + style={({ pressed }) => ({ + height: 48, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.primary, + opacity: pressed ? 0.85 : 1, + })} + > + + {`Choose ${chosen.name}`} + + + + {`Opens ${brand.name} to confirm. Nothing is booked from here.`} + + + ); }); +/** Gmail's list: avatars, bold unread rows, read rows on the tinted surface, attachment chips. */ const MailListCard = memo(function MailListCard({ props, }: { props: z.infer; }) { const brand = APP_BRANDS.gmail; + const theme = useCardTheme("gmail"); const [open, setOpen] = useState(null); - const unread = props.messages.filter((message) => message.unread).length; return ( - + + + + + + {props.query} + + + {`${props.messages.length} result${props.messages.length === 1 ? "" : "s"}`} + + + {props.messages.length === 0 ? ( - No messages matched. - ) : null} - {props.messages.map((message, index) => ( - setOpen(open === index ? null : index)} - style={{ flexDirection: "row", gap: 8, paddingVertical: 6 }} + - - {message.unread ? ( - - ) : null} - - - - + ) : null} + {props.messages.map((message, index) => { + const isOpen = open === index; + const weight = message.unread ? "bold" : "regular"; + const ink = isOpen ? theme.onContainer : theme.onSurface; + return ( + setOpen(isOpen ? null : index)} + style={{ + flexDirection: "row", + gap: 12, + paddingHorizontal: 14, + paddingVertical: 10, + borderTopWidth: 1, + borderTopColor: theme.outline, + backgroundColor: isOpen + ? theme.container + : message.unread + ? theme.surface + : theme.surfaceAlt, + }} + > + + + {message.from.trim().charAt(0).toUpperCase() || "?"} + + + + + + {message.from} + + + {message.time} + + + + {message.subject} + + - {message.from} - - {message.time} + {message.snippet} + + {message.attachment ? ( + + + {message.attachment.split(".").pop()?.toUpperCase().slice(0, 4) ?? "FILE"} + + + {message.attachment} + + + ) : null} - - {message.subject} - - - {message.snippet} - - {message.attachment ? ( - - {`📎 ${message.attachment}`} - - ) : null} - - - ))} - + + ); + })} +
+ +
+ ); }); @@ -285,229 +599,561 @@ const MODE_LABEL = { transit: "Transit", bike: "Cycling", } as const; +const MAPS_MODE = { + drive: "driving", + walk: "walking", + transit: "transit", + bike: "bicycling", +} as const; +const MAP_W = 400; +const MAP_H = 200; + +/** The tool's route over the tool's basemap, in Google's map colours. Only real geometry. */ +function RouteMap({ + path, + map, + scheme, +}: { + path: ReadonlyArray; + map: z.infer["map"]; + scheme: "light" | "dark"; +}) { + const paint = MAP_PAINT[scheme]; + const { points, d } = projectPath(path, MAP_W, MAP_H, 22, map?.bounds); + const start = points[0]!; + const end = points.at(-1)!; + return ( + + + {map ? ( + + ) : null} + + + + + + + + + + ); +} +/** Google Maps' directions panel: travel time in traffic green, the route, then one step at a time. */ const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { const brand = APP_BRANDS["google-maps"]; - const [step, setStep] = useState(null); + const scheme = useColorScheme() === "dark" ? "dark" : "light"; + const theme = appCardTheme("google-maps", scheme); + const [step, setStep] = useState(0); + const total = props.steps.length; + const query = `origin=${encodeURIComponent(props.origin)}&destination=${encodeURIComponent(props.destination)}&travelmode=${MAPS_MODE[props.mode]}`; return ( - - - {`${props.minutes} min`} - {props.distance} + + + + + {`${props.origin} → ${props.destination}`} + - {props.steps.map((entry, index) => ( - setStep(index)} - style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }} + + - - - - {index + 1} - - - {index < props.steps.length - 1 ? ( - - ) : null} + + {MODE_LABEL[props.mode]} + + + {`${props.minutes} min`} + + {`${props.distance}${props.via ? ` · via ${props.via}` : ""}`} + + + {props.path ? : null} + {total ? ( + + + + {step + 1} + - - {entry} - - - ))} - + + {props.steps[step]} + + setStep(step - 1)} + hitSlop={6} + style={{ opacity: step === 0 ? 0.35 : 1, padding: 4 }} + > + + ‹ + + + {`${step + 1} / ${total}`} + setStep(step + 1)} + hitSlop={6} + style={{ opacity: step === total - 1 ? 0.35 : 1, padding: 4 }} + > + + › + + + + ) : null} +
+ + +
+ ); }); -/** The last step of a merged track grows in once when the card mounts; reduced motion skips it. */ -function finishMerge() { - "worklet"; - return { - initialValues: { transform: [{ scaleX: 0.75 }] }, - animations: { - transform: [ - { - scaleX: withDelay( - 150, - withTiming(1, { duration: 650, reduceMotion: ReduceMotion.System }), - ), - }, - ], - }, - }; +const STATE = { + open: { label: "Open", icon: "git-pull-request" }, + conflict: { label: "Open", icon: "git-pull-request" }, + ready: { label: "Open", icon: "git-pull-request" }, + merged: { label: "Merged", icon: "git-merge" }, + closed: { label: "Closed", icon: "git-pull-request-closed" }, +} as const satisfies Record; +const STATUS_ICON = { + pass: "check-circle-fill", + fail: "x-circle-fill", + running: "dot-fill", + skip: "skip", +} as const satisfies Record; +const ROW = 34; + +function statusTone(theme: AppCardTheme, status: "pass" | "fail" | "running" | "skip") { + return status === "pass" + ? theme.success + : status === "fail" + ? theme.danger + : status === "running" + ? theme.warning + : theme.muted; } -function StepTrack({ - fraction, - color, - animate, +function CommitGraph({ + theme, + commits, }: { - fraction: number; - color: string; - animate: boolean; + theme: AppCardTheme; + commits: NonNullable["commits"]>; }) { + const laneColor = { base: theme.muted, head: theme.accent, side: theme.success } as const; + const height = commits.length * ROW; + const ys = commits.map((_, index) => index * ROW + ROW / 2); + const lanes = commits.map((commit) => commit.lane); + const baseLast = lanes.lastIndexOf("base"); + const paths: ReactNode[] = []; + for (const lane of ["base", "head", "side"] as const) { + const first = lanes.indexOf(lane); + const last = lanes.lastIndexOf(lane); + if (first < 0) continue; + const x = commitLaneX(lane); + paths.push( + , + ); + if (lane !== "base" && baseLast > last) { + const y1 = ys[last]!; + const y2 = ys[baseLast]!; + const bx = commitLaneX("base"); + paths.push( + , + ); + } + } return ( - - - - ); -} - -const MERGED_PILL_ENTERING = ZoomIn.delay(700).duration(220).reduceMotion(ReduceMotion.System); - -function MergedPill({ color, animate }: { color: string; animate: boolean }) { - return ( - - Merged - + {paths} + {commits.map((commit, index) => ( + + ))} + ); } -const STATE_LABEL = { - open: "Open", - conflict: "Conflicts", - ready: "Ready to merge", - merged: "Merged", - closed: "Closed", -} as const; - +/** GitHub's pull request in Primer: state label, commit graph, checks as a timeline, merge box line. */ const PullRequestCard = memo(function PullRequestCard({ props, }: { props: z.infer; }) { const brand = APP_BRANDS.github; - const stateColor = PULL_REQUEST_STATE_COLORS[props.state]; - const steps = pullRequestSteps(props.state); - const reached = steps.filter((entry) => entry.reached).length; + const theme = useCardTheme("github"); + const state = STATE[props.state]; + const stateTone = + props.state === "merged" ? theme.done : props.state === "closed" ? theme.danger : theme.success; const counts = checkCounts(props.checks); - const [check, setCheck] = useState(null); - const merged = props.state === "merged"; - return ( - (() => { + const failed = props.checks.findIndex((check) => check.status === "fail" && check.detail); + return failed < 0 ? null : failed; + }); + const commits = props.commits ?? []; + const plural = props.conflicts === 1 ? "" : "s"; + const foot = + props.state === "conflict" + ? { + icon: "alert", + tone: theme.warning, + title: `This branch has ${props.conflicts} conflict${plural} that must be resolved`, + sub: `Conflicts with ${props.base}`, + } + : props.state === "ready" + ? { + icon: "check", + tone: theme.success, + title: "All checks have passed", + sub: "No conflicts with the base branch", + } + : props.state === "merged" + ? { + icon: "git-merge", + tone: theme.done, + title: "Pull request successfully merged and closed", + sub: `Merged into ${props.base}`, + } + : props.state === "closed" + ? { + icon: "git-pull-request-closed", + tone: theme.danger, + title: "Closed with unmerged commits", + sub: `${props.head} was not merged`, + } + : counts.fail + ? { + icon: "x-circle-fill", + tone: theme.danger, + title: "Some checks were not successful", + sub: `${counts.fail} failing, ${counts.pass} successful`, + } + : { + icon: "dot-fill", + tone: theme.warning, + title: "Some checks haven't completed yet", + sub: `${counts.running} in progress, ${counts.pass} successful`, + }; + const chip = (label: string) => ( + - - - {props.title} - - {merged ? ( - - ) : ( - - )} - - - - {steps.map((entry) => ( - - {entry.name} - - ))} + {label} + + ); + return ( + + + + + + {props.title} + {` #${props.number}`} + + + + + + {state.label} + + + {chip(props.head)} + + → + + {chip(props.base)} + + - {props.state === "conflict" ? ( - {`${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} with ${props.base}`} + {commits.length ? ( + + {commits.map((commit) => ( + + + {commit.title} + + + {commit.sha.slice(0, 7)} + + + ))} + + ) : null} {props.checks.length ? ( - - {`Checks · ${counts.pass} passed${counts.fail ? ` · ${counts.fail} failed` : ""}${counts.running ? ` · ${counts.running} running` : ""}${counts.skip ? ` · ${counts.skip} skipped` : ""}`} - - {props.checks.map((entry, index) => { - const color = - entry.status === "pass" - ? "#1A7F37" - : entry.status === "fail" - ? "#CF222E" - : entry.status === "running" - ? "#9A6700" - : "#6E7781"; - return ( + + {props.checks.map((check, index) => { + const tone = statusTone(theme, check.status); + const span = timeline?.spans[index]; + return ( + setCheck(check === index ? null : index)} + accessibilityLabel={`${check.name}: ${check.status}`} + accessibilityState={{ expanded: open === index, disabled: !check.detail }} + disabled={!check.detail} + onPress={() => setOpen(open === index ? null : index)} style={{ flexDirection: "row", alignItems: "center", - gap: 4, - borderRadius: 999, - paddingHorizontal: 8, - paddingVertical: 4, - borderWidth: 1, - borderColor: check === index ? color : "rgba(127,127,127,0.3)", + gap: 8, + paddingHorizontal: 14, + paddingVertical: 6, }} > - - {CHECK_STATUS_SYMBOL[entry.status]} - - {entry.name} + + + {check.name} + + + {span ? ( + + ) : null} + + + {check.seconds !== undefined ? formatSeconds(check.seconds) : ""} + - ); - })} - - {check !== null && props.checks[check]?.detail ? ( - {`${props.checks[check]!.name}: ${props.checks[check]!.detail}`} - ) : null} + {check.detail && open === index ? ( + + {check.detail} + + ) : null} + + ); + })} ) : null} - + + + + + + + {foot.title} + + + {foot.sub} + + + + + ); }); diff --git a/apps/web/package.json b/apps/web/package.json index b1a2f4c25ad5f..198c5354439d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", + "@fontsource-variable/google-sans": "^5.3.2", "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/inter-tight": "^5.3.0", + "@fontsource-variable/mona-sans": "^5.3.0", "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@openuidev/lang-core": "1.0.2", diff --git a/apps/web/src/assets/app-cards/car-black.webp b/apps/web/src/assets/app-cards/car-black.webp new file mode 100644 index 0000000000000000000000000000000000000000..13e802381512a48003d99cfc6844bfba4703f81c GIT binary patch literal 10452 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 E0PgD@qyPW_ literal 0 HcmV?d00001 diff --git a/apps/web/src/assets/app-cards/car-x.webp b/apps/web/src/assets/app-cards/car-x.webp new file mode 100644 index 0000000000000000000000000000000000000000..085ec04c23b9677385f7d4294aac91f5418bfe79 GIT binary patch literal 14638 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 knG%V20Jb7%s{jB1 literal 0 HcmV?d00001 diff --git a/apps/web/src/assets/app-cards/car-xl.webp b/apps/web/src/assets/app-cards/car-xl.webp new file mode 100644 index 0000000000000000000000000000000000000000..1bc155ad68e151f279a019a01bd29a5ad090abdd GIT binary patch literal 15202 zcmV-oJDtQ*Nk&FmI{*MzMM6+kP&il$0000G0002L003_Q06|PpNIfM000I9eBuMc8 zcxyzY?h_6pNevqxC}55L`!Co;HAM7(0&D|xOCTg>SACK>hl-}Ex-)awdw`3Fb2uBG}xi%M%3V3p*RK-B=P=n1b>&Mh$*%&xWVUFF)e_HF*s%|K#*KY zvLtRb110HZ>;@PN+O`oihd=A>{wYMn1k77ds`*$!S02jYtSezuNoA&j{wiowG^>(s zxv$}{t58s}sz$oBVMK8abZSkMI-rE)dCk+@_Pjkuk}cb|B}pPW_Py`_-~9$a4T1q- z7+B9>GQ-9YtM^oG+-C&{5itRtY}-~O*|x3T$Cy(@O$|0PGc(8NoD<}8!Y2WzoGdWq zC8e_Rd;pw zGzN{eZF~1++qP}nw*4A=uURyYk;)jc&4F#$J7@`k`<8-|QRwuuY^ zZ1|FGgJaOUmMIcM8W_X^U;#7XpnxB7tdR*0_ss8Q%Q}yh1k5l1M1=uFU`qhW1SCQ&4FX8Msv$Xn0#fg%I6hOHz_S)&PKDX1Z?23JIHz zZ1_+^RS~9^bjfyaaWlx;d9G%|Y-k8Y`ecdEujF@dUlo3 zhin3w+cUkT+?rOFK<3Jnz}?HcXL~K$nh$R7<91*)nOvj)C2z-;vl7aH7jwre0O`^_1*vgQ7V?_bKB-6UC(8`F2pnT|rMy@C9S+uZ?U^cJ=Ik^7cI}U8 z;j7GzUzI@z6CmJc6ZNt;Wlyt_@BrdBc1AH4oZ0ug{p^qa2wr)Xt1I20^wjm<%wZ%_ zT@=kL4c3<1oy@$H%dLLJcU}L*o3+FHkBKl@beyfFwcaMp))Co&-O_ZuC>7K6H@ZOg zm;O2Z(gFy|%(j73kVSSaaM^>)DtpFk*;C+MmO(^<9AXHC#C!FJ_nT|yvn;1J_qL?! z#nmvH3lSJbuhmlMky3)e#`NCs%FmtP{cn27a8THvPL9)Jyfwlcdvnfldh>*yRLUtT z6!uK*yT88v|5|GZAO$c}WwP8bZUcE60oPu5$DU`gy_7ZKk)Z&yC5G^G|05sQC!d)e zxIw}9Z)zF5j8~9TP|}A|y&&fRSzTvI7^W-VIDBH&5wSeF=l)*MZEO8xbNSDMSc=8L z;zsk}>~f>OuyI)*H|x1_M`{z%@%cDY(t2^Ey!>89Wa`Mr4KEOu?CoyDtxIf&M@QqY zFaTNe&hN%Q%++euR*g|MvovQh{dH^Y<<8jNk#46k+>4wE5r>>< z2$dwo;MQoN7Y{Bh-Dt0XvVp3m8!08s9mqG>xE1US+$Lr?Wh|j~ZbUi{N--OQiAb2& zeC-#T6U~DPcdBE=l{*Pre>abe2)$Hk0)PMm)Cve&TY_d60Fs+mPAx6Owla~gR)fgY z>Iw8t%`fwljU}&YiET})wlltoz-p!A22S%G-&2Ma0D~<7AaI4poXh|$&D6!zuB^0A zFoOUPPGx|Y04B>s1nghARjRd)TtLl?BqC+!Pslp6xiL=M9j|fO!ZVY;#IBsL!J16i z@wb;@1X&OWJKBoxICJ@Oc?b)E9m&k1YOJ3O@|dg`q+%D>)=iz!xJ_XjOA03sdBUdD z4{HrXG`0s?62JuHSt5CDDmV`-KJ&o~7jK#fJDK^$Kc5$kpZh`-du^< zN)l-e8y??=@Bnh}L0Tsl2Sz}I%{<7hBc&WP#JPOa|H@mHi-LC)r4N2K)NlFu1S3ec z)2ME4L^fDw#xf&h02^$^A;OdlxcdVT00vgtrd9z6#91=ko9%h$TjHJIIRP}tP#8>f zLM@dLE5ZuWh2;Wcd|X|&tbHWgNCZ2GfU8z_6ovp0dg~c1AmUt!8+os0Z|aV-zdYYj z$>GJaWVS+^bW=*8g;LWk4;wS@*gFQvxtasxxLoc969DGEoE~$rvuWS;_VVrS5@;s&Mu+ zpWbx!u1i82ozI!Tr!$OqygRL+$z9k&E1MZ}DznYlUSl(p9j_(W=|o4VT;e)_=Lda# z)`dNv3%1T}SfbnZfm=m$a|7ti!4k%naF%0E6^Dei`FdaPG_vkkva3>!OX2)2omm(p zqSSFM$8C!*AK$YDd?0Z4Nso-bw~S>-zrJi@mB0Q-@djHjskKmBhSW)XUb`z z-dvYrxd;Q-Xrda&ht2EbBX%{cGNeoson{l(wL<8)=SREr!0i{n_dnSO@ z3*_IsP9ichfGolTgF1-ZDIzLpaCIq`=M>0{)54d%hztsU60eh zD?o;`+zf2f6@tn*ozGt(zU9R~{1+|=B8WF_h@C8f#FjUIxBA9+lq*Ce@Ix-PPXY-5 zIt3xV0MspR+uF*c?R49noB7Ied9+q-)QVMc?;pKfyJRMH9gt&Dx5*46VXtld!=8UM zLTeC~|9x_MyL%FAwxo#Ur689n%-n~HF!NE7`xXUwEdcspP`+_|@pE?e;eT26EdaO{ zV1PiDgEd9S8cXblsD7gEtkx0vjNw!9+rD8!@{OPj_H-`;Q zWQIX71XzGASq6DQPz{1YZFuK)DtzZBzB0Y?&g;>pC$FisJ{C`_1t`W?a-AS>Wghio z6Olh!R+Q&yxYUVOSJno}^aH;;Qgnmj_&5jzOPd7%NB}q0aD7hFB9b<)Co)c?5UwMj z1RIOk*?%j8B_&8&Az0XrU)mp&q0QXKF%2mMTae@&2+AXks!LWnu3vgHT7TMYx0|-8 zZ}PGFn{KZ8=;iU|>P;IjJ&s~R$_y$tN1L^xjMg?b7T4R0OPyG&Lb)s_-u$=4RB8DZ zEs4Dgtv~j~ zPrg-_t=0+4DII|!MY$=|Mk_vq5#>tB6VeV|d39dY_APqm)VhyXZyz;1Vh)|g zT-ks0*r9Rm&tAGbGq<6Yl!{3@3c^zRtNV`@HAg1X;<9o5-5@JxzGuy0-SrAgQm4ck zgF`guT5Gk@mbK0;lk~h`vi$Of(=4hnvAR}r3f)RE+4UV(2#j48+oB5*a)0-s{Z2YG zZe^tE*+{3Wi-RDF!$3J%E(Ww+PgZt899-;996VGL(c0|X;`|`DzVEq8NkMEm%dH4r z`|96yq_onW>E~i(vIT}Pzr4$Kn0%RmEo;jr&-23qZ(hZoSP@pcsq* zO$)9k_f-0eHh=E%1;;Bj%jFUGNY_zBL@Sl@d@s>y zZegv}))?vH@RS{ERLf+!;JcKE;nm`|ML+Yj*mYUx{{408F9bm&{!JsMN|AtU1(ac* zIe4_iY3@jhqRd*RsxlCY3}mthwkdrdPLpL?q1v9ijvUIbOEf#7ZSv-2*2 zkG^|8?38*-ec@u?yco|yPtm9T0m3CA2SyZm{nOun+tdmV2PV(0QcB6RR4~F404Ky$ z9dZsg$dE80p1$73O?HyX@q9_JCP&_ls=Fq}xVOAI3~jklE*51Lhk24l>;2JUsdr>v zQ1SGjW%9;76a>B_mFxI}mmR~OJSnQzoh-}J+~(Fww$bFD=fPy@JDv_!{Cw0#ZYlPI z`mwnc8H^VkU?Udapa0<>|0#O3+zW-{N~IWtz+)B!kf2mr#gc9$!HfX8zYWh0lTJYj zn`y1B?NpE4Q|ZrM@93ae3Or?_FpTp|TNYMm;n7Oj+dx>WiN;I1Yh9z`?%MiV7*D)S zY&D6IhznM6r^P0GLIh(*raDa#)6-xHnAUddhW#)z;)}S%vmJhnBD2*if~KtCMNh z(39XHB5d76mHOI!knp4V&eGrf=i{fh$|T0!FF$&@>^=JAzs^Ym$mK_bPO>#RvO0Zq zrAfw(>dJ~>q*^*yD&OjLqg?4G!C{-d=bpMGWkXA{)*=BTO~_;j2)IEw3<)5ihV7nK zj_Z1^upnb~ZsK8Q`U^kzlx|jior>6i?sN$RC`9VRuMyqO^(z;yt@NE%Gx*=9x6@3) z;kV!Y%>j7kkEYJBfqc9Vu{zJdeL0#y`jN8zgEOj9n;L7>M^m_L1g?+qn(ET$Mg&Ra zc>-i>Z3(R+P|TcG3@{u@EDoiwq$17|W7!H4LIj1@*FUN6lchb4_&1&jSXhDzBn(8x zj1!Ob1?Zk$W3n|OQ1enQdInt>rg#7NyFcC8c;H&XG6uAn)@%(MC`o!Y6|2=ZIqi`a zmm4^wm}DH7*L%MEzrUospznL7Bq3afDD|baOeq0CmF=(#&TQQ-e;R>~tWrwQZofRW zzaF&4rR16p#W*%U{uR?X!3bmmVzF3eQOef(E}AuCve1IkF9FgDsT#|sTC;~!4#=Nl z3p`xw^dhY-MZ?#;>8)>iTVXh}GDT!Hd|?0&z=!=0x;=W+um8Z2k#|viJRO z`@3pYG8=L)s?1BRZEnjF+1-5ri%^Y`anp|8?xbsuytH0OhO!|1AQSgEvg%^pzT+c5 zLM)&CayL;fQLIosg_xJwTbz*R5|tyt5~K$BFplG<3=TFU^209ykH zJ7#qEPoGP8ZM8}YU;=W%W{nBR&7r%}gkgFd#~IjZH-D!3>;bN0f1o6RQ$7xW`IZ zEy+3dGpDW$%o=ZFvq0Q8GXxmJAG?COJ8`6ujE#=pY75J*UU1;4*DjM(<4`2^Fw;*Rs^<9I&)^js=47z zmA;g*;glHW`04u^89>5}e(;t5{Eqz(>de~fl@TU{al??$wiW9J5_dBt=f_7orV%g! zNG?9Mc4m&&dd0G<$#Xoe?GA=no>?8yp~od?jRNY&VQ|Xxa{7Q{`YY8}+Zhx5!qxg=V8QR`xLH%`bTM0-N0E zjN9M(v884ccdogVRvXS&97`{!V8ibI=6BV#U;y3$n3VtcNA)^e3);pR8u=T~zHGEs z_8sEfYUVU--&#`5isa1ZCa(V!4aRXd!x9i@gh)g^Db`bU4im)=$Ky}0z1fNH7rg;? zYVIxxfbgP2AMB?r1rdzRZ8BJ0E)+ah0>qBUUWdhfxYrDSdg13outv(0S}Hg_aT7>J zAN%Az?^Z`i^g*;UkcQ0%WN9qa8l*T6H#(iw)%9LbZq=1ToEcp{lvZ%RHrHJu-5!cT zn;?~SaPl~kc>Sr14PJ6<^~w94ihomw7q2G!gXb+_03djbW>R*fMABJaUS92naTG^R zQ1{BNA=do^*qFF$BX*rIyC()11Oi}*uvy$076?z~!HjagdD?mQjK;mE@|9^{mA~zD@E2>nz^~!5A!t7a#qEnS4&s5kWO)&9o+4tw*_uEnE@scwzLKg0ERZ~ z+$QeI4&YTP9N&%6+70484+Wnr?#2$@w^#@QS~`EL9c8&HdP0)03m1clUc2BjCna}S zziY}O!f`ZjB5Y22@y60}f2iGH>Ln90W2592`$zpQy8zG&*^S4Vk1c($>d04$`w%4W?-X(~%iNp2w<#7FEmIy#7 zDYR=}+VnwU)5Y5ZcWValj*Cr=krX`;AcG(M^D`tIAa?+0!T>;w(E^yQkwmz*#Y5B8 z@8DD$$|@0-?wYtS8|HyPV0!sK{^`p6${;bqA+m?hN}X%vj*K1Hx95!>pcMfSjyC|W zMb0d!jcu;Y{LgF8=ly=B*O|88weV8L&33FqW%1$)NpF}}j=Tky7=$h9HdDZ-wrq+! z1%TLwcpR{F1pzPwIv37dzS)fpNg)JTtviR?d1d@>|MqYG?k|4h*JX=C+Nvi28(l#B zyCS%r*WdKNx5oChY9$=mm$|pyZEr1zkO0bR^@2c2w}v}p!s_d09EOlmC>Vupa&BmG z7xAVpa;ZS*KK9hL4Qqwe#^hS7LS?%3(#h6jB><}+C+nu4g)I>Pi5kxoi@KtRW3#%VlUZFe`;!a-(P6w1ZG%f9%4T>~Jqgr~$>6SpP&#&vTE-W7mH=IrtA zE8IA-rOD|%dk;27eQY)$5afTEMgZ8YHXUiyCoh&l^b@_#WaYJI-uu8HmriL>yc!?? z2PZBq8g|#0XXn~OVn}idQaXX~zjfCUjHM_QWTtRC`KMk1x^Sdz5b%wgldl+Uj!)k^ z+6u7QGKfI9!!S1-lCwRl@*ix9fl0JDLo(jDwfBIvR)~I^rH+o+2UrJY3IeSV$cScH z=bW$4U%8bQtQ7(vB3t{GBl2-+UejM4#dg(!%45_OLi66&?5?=jY#9V1+*O0&K0~En ztI8n}@@)AulI8r3B(Qw}ASB^{1|a4R5RvNum^nY|NNaVLLrN+cxCYI6vDu{I^0|C~09Xcy1fECYWOV>G`%P+O9g^HU=b!p5sV1kHwh+LBhh+v)HgiS!#+Y2Q z6`27#1Z=si#lh}200(gJOl!6QzziaRogNbn;Gch&TU(KmSr&rVAN=gbi(H8vZ_N7Y z&}zd*=P~ECcLV@HM6Lp0e95%M-bSnn|01pl07$%$j(`>a{#B;QVTLS^%Aa?jUtnek z(2fER)OwqYv1z80?##*;9%MzCce2syvkT*5>oLRw@zHD7SU1B9$&@MyuxNibQUxSn z44-@H_fIonO9r47uJIuNqX(t1aBtu+G?ho_Ex1@I@fR zmyS5Zn4d>q!jLq_#p|u8l6h{UI#H_oxKG^^14PM&xf(Bj=337kDW`Fqkyk(TP2fPm zGoY=&HR!z?+-U_bBKL`RYcj^YnoA%OnW)vvv5*4##JNthUnqxPUEiSL;`(BQfHI7B zPn(U5LhLFQEWN<>{a}9uOl(D7!=0BANAiIfEErE3tlro^3aogIW!FoRH1pafo`SYbUP(7EB%EflrO+g#3FE5se>;#=)2(iaw(j>8cJyZ%hcS`0i0Fdsu zcfo>55H<_oF-$Z2(s3{q9iZTanHLa)fjxNx7O+f2xI+OD`Wz`}nU^*#(Hqs~Sw=^_ znOA?M6>J2+%p|rTj{)T%!1m|97W4f{0*u(QmtOuG{p=RUlP(rviMU93&ZABTkwlsg zA18xt3R)xa;B!B5?AwjR9SVUvZH2&V3#2(vHuq6R#OnT)Q~k@cs~7so4!`+x<|B`= z+(ZokN!H6sFG!Fm?xlR{H?Gp&?w&;f(3-%t@GpM}JLPolPy+ziM(vE5x5Equ5n(eA zPY=$|&z%b&bsU-`y}xkmuW=;jiU^FE^xm1%mAnfu_pI{E<-Sz{48} zfSE0C-hwTG2wR$S;f4827t>i|tRNTwB`Bot7e@Ydm+k@+m>AE!f=~6vbJ0uzCW@71 z7|+@l0yOV=|HA<=kWrhrv*q$Som*Z!8(v9=8Km$8u_j8V08oJaQulYhAdi6EXjpk; zeJr#=9l0WqIEW$ac=*Z<|DNw0)%9~IyC4QpeKHVlw7 z7`haOjFIaf|7+gGG-vh#_1AOvGYtfwsj(mxxBhIj&iU(~cWwx6ve($-G zHi56o<#MSgiEM6?G&f)Y2{E^29~vmw{6|f909y1vQ-6h}hp#syDzs7r3SiohX6^aX z3h#q>c&6EC{>&^}X0`+%Ul=Dr7D5QZ-BpG*ECnSQjvrjVpDBFsAhN^@;IJZyd$MVWIhsPUVW+d?UwG6R{!>e(>WSC3e6FHD{H_G^xU zAQlw{OHoLt;P{17y;%tgWt+!2FrPeJI3osO>zI~4MZ;lSFZ5KudGk<=P=qAaXxBt% zL389IxVx6V|EZP=e8+PgpXjMltxyh%CEsxrb|w?Rbk4b#c@7K^HnYysX?i;M)>2mL z9^w?mZLCA73S^fF$r@$QtkJQNyTbI)LoMrjL9tM(mr&WWb=;n}q6e`YahZvhKl6Sf zJg|tZt&Ru7C=O$Dqd7@Z2uDfGDk>=)B?x!w zHiR9>SVcpDbtZj0p8|1c(j-fxXc!HXG|O_(0Q163q?K!Gm6<9!LrCCqcRxPAwI)Uz z6<^_BpMxhL-x$Mf8Mh^GvqSTwJTt?`>UjQ_{j>fsOtUmjk}S!xTxXUl)hrw$0!g9( zHx-64XdFO#DUw7|1^d0<`iC!Go&rEC#EX2jbAN^DZip-DJ*M65E_grghl&>wCU# z>v!iXlC^LsW$?>Nqu~NTS~=~FkwM!M50p92``yw+7O;}CLXgi%WJ@3@;f*)mlJ?cm zMKTt%VX#363k!ndjhz7yAo?`z=4F!PwU$yzWre87J}z1Lfgy_n3M;njQBwn&sa%QM}Azcp3d-;5&|^s(Zmb>b`hzcOpa6aIhRp zn`zd0nkGqFBw8s(D7~1&OR`f2Gc!PsV*MS-*gju!vm?IExT5lS`eE(rV~@;O_RBJT z|Lc8M^4rfJfBM7ymLGro`T2bAbBP!J-`DJIX>H!`<_IipKiw{UZtAV6FLR47^*OTl zKR3GewXbh2d&^!2>zf^`Vk{?H!X9ACGbP7==Z3h~y|wb5p3>S^zP)hg<;;)!@gsMa z-+uo5e4^Hob;29{_TRpJKerZ{nLW@Iz4y7d2qm&d%amHE2W|HqWn0%LdoQD~?b6A3 zI0*itbQs2rl*omQ)waXPB$*uF5B25z=FIIr^l0G3GC&E$CVMUK^;D)=m!kI+BC} zCXvrJ^tIi35R!UGQqN!pvm;a16Nwbq#VjipTF|;zZ|;e1BN<^eg9Y5%&bM2b>R28tRtwD;I1zZKG}q0;_XF!#1fX`6t^c`P)bthFfEiK z63LYWC<#dXzF0Q&dCZl_#wJ_9;5vignzcE6B}_d!_fC9G!V=w>qpl}ph}k$nC?eDV zW5~!ed#RX-Ob9>_Qqm9r6*7oqN(A4Qv-Q^D1eG_8?hRn21SZMG=z?xpIjC%#F#6x0 zJX3@SZ1nOfLLjc9dDBfnAa7v|AWI7YnGhieCIzV^g@hT9P|6IH4A6&-Fx|+8LuUfy z;$Ui7XlR(enE?P+P&gp=6#xLxO8}h#D!>4606s|`j6|ZLp%iL%;3x!yw18_^A_tWp z41XoypX5J8{%`wV?XUN~PMvS&FY`aazqs=w{(pr(2mj3cxcCVCm-(;x&&^M8f0pi1 z*#FXh()ASf&+7l}KfZqM|DE_@`@cH=(0|MK0sevgYyJO~Z;1Dy`vHGL{>}d{{Aasw z_CNUl(toD?x%XT82l-$7pXdGp|CfJY|CRn9_qY8I&hN9|-A`r@*U$WJVwqzC5vi7* zl9#{?SF@9kq1K@;Z$+%*Yg%fVeg<%EDn}1~+&*mA*GMhp;_ZheA3>n|Vg=Fk(eIe+ z1blw~_z_&3TkO@T0n+vB@Y~LIOP334XCZGdT(xc6~Aix70AoAGcf#9^_K#DWXA{(SY=MQ?Qms~t4o{_z-KSy z6*4;&R!Z}cG_av!^xj#hjD+0Dfky;hqS9Mn+WHuX1^JCV->X9%r}vlPCf*Yht%Wk^ zK1U7Z?i*|J-n(BmX7>)gP99fH6(Htl4Sx0qXR=I?R<|n+s5Z5(R+K>eN-omaRTQfCXcBXzfVz9i2hj$HWO)0h% zM7A3s*n}+Gbo)Y$5?ELw9q%y7oLrtLC4xWnbJY}Jp`+m%Rd${y4Hvdc1~)ir%|iQH z-Y`8<_O;D+;5IIRL={_ChCgQA()}_oj^m%&uiPkoZB%o5RdOhAzw0&L5EMJBs1OeG zt9V-52LiOtsAa$Ga?yNx965N%$vq?!=$PYtkbt2j4@wp0)w(z(s(1KX(c zX~oXFkpFF@`os+XDBDEwb*h)hKe8XU7nxsuj7u zb8@lfP_UAzV&seM9Zh+|WxyIn-_>81Gg33D5kv`}#l7S4&qn)yNXH%+GwrdH z)w>H18)Rn#^lhF1@AmmLZ8=Sab3YE*J2*6-yHe3piz3EPdt@UQYU=KzPG?Xucv)R_ z!ELKwe;t75*IslLWe>Ld*(BC$DNC-oJE~aHQm(R>(#9o20aDuMK2eyi=igIpYDRac zBKs1Sz5&QEhcm1^Sah$i9IXMkK9^G(b^Q33QJ&Ww0VSwU?jWI2Rzmrm-$QE0FB)#kaK5u^Rip$lW1q?@&MYOlOA>so2wpGjt_km-C} zhuH5;FR;Ie2c&(6o9thXJv15PK4O$n&TEM6E3Om$^GL%$_|&bJT^m7dO|m_?gR^k- zcqMmuKfRvb)=Zb33L_^0c45t++=%0E+h$=b95XjR;zVzlWiT8qx`OEQb)z7tJ%K9N z6PG(`Js3cz;WHDRoNXG}0O zlj_^BKS%I^aO1$D{It}}Dz>=nSV&j#sQfewZ1NtK=mz9;TnO{>Ik<=W&a1ddAg#u? z3=sVTsVq(BZ2FMrl;$Jf$4ma)fc{+d9^fz6*BV})LOJ}pG9HzT2Om!CI_CT0N`zdK z{$l&y%7`;y1p*)P+3dfJoWKkSMrlQEmk#9UaAuQvdo70XXl7sOoSj!rdfnJ5OWhcg za=Vp0UDZUUS7z!%ufG>rlO^o8!i>=GSIylE6TzG;yN;5!l|29vO1so}zEn_pZx>jV zeD4!Dh({QBdO9`)GeUN(fb?w4J<(C)6Td~4+Ewu(b~tg%%rY0;UiH@w^39o4VBkkZ znhLM_=s{g;mn|}6qydmX!{LL-=P)UFyA1uFA2@77n2IDlxWZ>vEB?7jP(5#Wu zzp@T;HaS5WPdLoFGn~TgmX)KnH_YHwXpVXzK>Y8uZ0;4$##ncG7s`&{+eTU&2jx_N zTv%9wL(o}V5<3;3hRkDV0WRvacOOGsT_D4L$84BHa-hPm@3{7b8d7qW&2n1{4%;dm zh(&wzvnv8T?lX}u5J;G7M7M-v@wL?h^_rp!Z^H@=j{;()_Xwtc58mT62H_Y-;wAlf z$kGyAab|8BQd(jS>w03F=gphFP^gA*!;+-t*va9_A#i9O$a;|S*34tEu++ zbIFD=>P{es(ggQ1YmMXTyyPHcjq5m~kXijG(Y2{x=ij5mwTrBO8K(EPf^kb(g1`o^Aip5qd-V1liI?*rTWI2e&4ncmOg61Z5 zdGMn7snzA`(OmtM9AzV$0Vi6|$ra`s42@cg=PLp-Ro8$AQX?I_IC78S#6y|9P3mNe ze*-n;dzfD9Tw7i1dK1n8zE`n;j!JG<2aCuN=r$=_xVhK6*D>)^GpPIUEB$xmVK!dv{dwsbFltC0ji3AYdeCo|TYh!#6qowKVXYrZW% zqb_JB0%**GSic=%N9lXOwb~B@T8{B}dnR$wg5%5j!zVme2VM^zM=RZqXWPTr}l+~(>h?XxlCwoO4d4GDz@YFH({jB$#7s-N) zR>Xhthvh3>avhJp;@hcT)NW1Xb7R)&1luwavJ}Vvcb)`!_|4g-T$GjDcsyesXdCO; zMR+$ds@=mU6vyh?-!$`3nFywZwRU7XDw0s>8$GlW9MtV9Lqok{?=jjl@Rr22-04B3RLvNLl`-(b7d+I=VFB|Af!+Md+-Z+)B-dG;l*$U zhN$wwJitCy0w^m_;o}H!p@L?Gbt8nw*6t^iCad$Y;vwXS2!dhYDPsUFm)r^c=R=0b zPKz(NW;NRlZm8L%2{dD{B|G~h2N3=~+lgvcfXGSv@9kdMvL2JKqrtPC^#<%H+TZ{r zFXBraTOZZ+y_U)YI8c!8o~+|Uv{JiwkcBiaOeoTIG}&f@`S6lG<{gLz-RdMtz)Lo$ z`JAE04aKJcU-7@8IEoVUi(r|uO)bALIv;yJV{skM!vwoyN1J`9D-vi;nO}GQ3RX-uEfdclf+o&B5 zcN~9eJdycRQtvm(ZI8KOl1j@7(sy^^zexUrv(Oway$<__iWl(Sx6?w_2$o%5R7woL(VawbhxcU(GzR-}9CngTG*cg2>2S8yrJmpA^n zajz8p6ApDFiT6XBZuW8wm8gRXj4~WzZ>!4+2G^XDM5YFr36N+sl+yj&1p9pm7B=7adYcgMMW_A`Tgp_akEGeYjcLfm zTq&Eaa}>tI6$Wr>Mn&RUoX~i0U5RN6A@XHByVNl_^%10YuRDDbWolIE0SNMaHv2-3iK;`|CEn*P?DKCIxkwvY+X{W zoEQqHr=XA>K0(093W>Wh6KhwYHQ-^4!6_cI8Gb{<|NCEWf3#h(ejEa@ zk(#$=NMG~5sU!%$>7vkSC!wE`9rl*!)dC#4s8K|cdCUB{DywXU*0{F(QAdCMn=w;J zJdU;E)%o&pJKI(xpJ%brvi;hi zbW_&gjwP8rZ&d0p0|?BgE)jUtQG)?5Or-+Tt&Y>lOI{=eU2|zW@)uz$UKbOmLzbU7 zx`OnSvR!VuC5}n9ZyAn`@Nh}Ey#c5;mP^XxNc8V6wy=_U@#ZlnN29z*q1^LfQSk~m znM0d^;z`mTHPAFD@SCmwR8L&>B4g|9U7+Faov`j95O!(MQbz4>8wTt?;CXrrZUHg1 z&nIrU$r->_@Wxg58GoQFlk?pp8s#h$T#|cKYQ=3yFUjI0jqtwv9R)a!k;W!1A1ewo z;J^hxfack-@jPFt?(|SjTzH5+56|I!4bDR1{8& zr2)#YsS%U-;GK{BxSTH&`AAV?4bO|4KgxcE0m<(`NzuGR8o+5*IV6=LHEbfI*TAZM zkGLfSvg?A%6p>6$##Ss*rr4?8bAF<5JTi~0J&N?uFd4b08Rs#7q33r~F!1mJ!1qfK zMmL$gUQK_%w;BvkdR2xR_g89Nb>;S_O6ouj8{He2t;C;v&@hT`UlugJ#Bm9XJ04n2 z*LGhpSQH|La8m!a(KKbv=j*e(vm z3cqiq+oV1OI>+;oNp{}?`OHJx;&1UrUm3W#4~mB9Mkkh*6LXa?K-K)VXy3Z7-Vfx%<< z1}|E)r%3vrwek~|#MN7Nrr%|-QQKUO{mNLct7tFkd|eBEIJC?6_O5MWD0XH3%8!~X zb()knTd)B=OfQqf(#W6Z;F!5!JM?`c6@=a7w>bk7NEF|FQBUHkWxMXx7q6><^_=ez zn_ZjD9BEK;huugQ@+nLL36?jydp|t6M+Dq7b=iXL*ZrrOz1m@7VN)N|i7T%4(@ww$ za`wFudHI+AMSS3XAcU4)V48+jo}@lkP8Yi6u7y^$tN3E=F3-;$Xso6387fv(h@o!3 z7g(0JtFvd5-We~UZ_)pu)u>b!tvm*J6?#1PK-Cy?4O0UgqEMdOtJQtoIQD9GxOh=k zLLp$05|SbRLfvowKM>dacGO{`PEsuWo7BNup0c%Sb+Lo4vh#I1)7%~|+G$`sV)`g6 z5FZVZwsZE6gp(bqJG!*S#~Prv{wLRMK)i&agdY}$0}WLGi0YS?av4awYE~rCb*< zWM*lEQ31Z>g9$*ZFV_Hq)ejCCv_#FuO+GU_ooe?r*4Tu3veEnI zZflNiVV)`qe2=F+*B{Z_M%2>JYnC zzfKJiMV#%6G%`f8ZjacseLnfJ?)(lzXFwIXX!nIJwi@}RS}Ckr1`B_)=?jQj8rePj zIjpP7gSq$qyZPr~NZQ39A8r=)3IfJKjSI8mr9M#W6dl{9cSGCpznl=98%`E{udk*B z1Gns)y(x6M?EV~|Fulb^)RvwC!q?2LRgyTt7Gmx!`2FoeS-{K?XmG zH!%i%T+O~k?=LiSM8-g0+CWDgo#`Wg`|n^`HSHrGM=gXHZBS}-5j139EeT?#1=VZ% z`u|llvZr8I+!-zd!!hon+aWM!(3PFV&7g0fwEK^&tk_$Q;y*gZ2^aA^CeCcfsoPTa zgHI2Xop3GD*iM>1>&RpW86>?Kk=<2S<8k%(T~yrBe7O zp{73UeFwlwoJKkBi^#)ol { + await Promise.all(document.getAnimations().map((animation) => animation.finished)); + await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined))); +}; + describe("OpenUI app cards", () => { it("renders every card and keeps taps local to the card", async () => { await render( @@ -22,26 +71,92 @@ describe("OpenUI app cards", () => {
, ); - await expect.element(page.getByRole("button", { name: /PDF INV-2041\.pdf$/ })).toBeVisible(); - await expect.element(page.getByRole("link", { name: "Request UberX · $38.40" })).toBeVisible(); + await expect.element(page.getByText("INV-2041.pdf", { exact: false })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: /^Dana Ruiz/ })); + await expect + .element(page.getByRole("button", { name: /^Dana Ruiz/ })) + .toHaveAttribute("aria-expanded", "true"); + + await expect.element(page.getByRole("link", { name: "Choose UberX" })).toBeVisible(); await userEvent.click(page.getByRole("button", { name: /^Comfort/ })); + await expect.element(page.getByRole("link", { name: "Choose Comfort" })).toBeVisible(); + + const map = page.getByRole("region", { name: /^Directions from Ferry Building/ }); + // The route is drawn from the tool's geometry: one point per coordinate it returned. + const routePath = map + .element() + .querySelector('svg[viewBox="0 0 400 200"] path')! + .getAttribute("d")!; + expect(routePath.split("L")).toHaveLength(36); + await expect.element(page.getByText("22 min")).toBeVisible(); + await expect.element(page.getByText(/via I-280 S and US-101 S/)).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Next step" })); await expect - .element(page.getByRole("link", { name: "Request Comfort · $46.10" })) + .element(page.getByRole("status").filter({ hasText: "Turn left onto Mission St" })) .toBeVisible(); + await expect.element(page.getByText("Rename picker tokens")).toBeVisible(); await expect - .element(page.getByRole("status").filter({ hasText: "Head east on 20th St" })) + .element(page.getByText("This branch has 2 conflicts that must be resolved")) .toBeVisible(); - await userEvent.click(page.getByRole("button", { name: "Next step" })); + // The failing check opens with its log line. await expect - .element(page.getByRole("status").filter({ hasText: "Turn right onto Valencia St" })) + .element(page.getByRole("status").filter({ hasText: "src/picker.ts:14 no-unused-vars" })) .toBeVisible(); + await expect.element(page.getByText("38s")).toBeVisible(); - await expect.element(page.getByText("Rename picker tokens")).toBeVisible(); - await expect.element(page.getByText("2 conflicts with main")).toBeVisible(); - if (import.meta.env.VITE_APP_CARDS_SHOT) { - await page.viewport(720, 1500); - await page.getByRole("region", { name: "Your apps" }).screenshot({ path: "app-cards.png" }); + if (shot) { + await page.viewport(720, 2000); + await settle(); + await page + .getByRole("region", { name: "Your apps" }) + .screenshot({ path: "app-cards-light.png" }); + } + }); + + it("fits a phone-width thread without overflow", async () => { + await page.viewport(390, 900); + await render( +
+ +
, + ); + await expect.element(page.getByRole("link", { name: "Choose UberX" })).toBeVisible(); + for (const card of document.querySelectorAll("[data-app-card]")) { + expect(card.scrollWidth, card.dataset.appCard).toBeLessThanOrEqual(card.clientWidth + 1); + } + if (shot) { + await page.viewport(390, 2400); + await settle(); + await page + .getByRole("region", { name: "Your apps" }) + .last() + .screenshot({ path: "app-cards-phone.png" }); + } + }); + + it("switches each card to its app's dark theme", async () => { + setAppearanceMode("dark"); + try { + await page.viewport(720, 900); + await render( +
+ +
, + ); + const pr = page.getByRole("region", { name: "Pull request 128, conflict" }).last(); + // Primer dark canvas. + await expect.element(pr).toHaveStyle({ backgroundColor: "rgb(13, 17, 23)" }); + if (shot) { + await page.viewport(720, 2000); + await settle(); + await page + .getByRole("region", { name: "Your apps" }) + .last() + .screenshot({ path: "app-cards-dark.png" }); + } + } finally { + setAppearanceMode("light"); } }); }); diff --git a/apps/web/src/components/chat/OpenUiAppCards.tsx b/apps/web/src/components/chat/OpenUiAppCards.tsx index 87c77569e3e01..c75095bca15d5 100644 --- a/apps/web/src/components/chat/OpenUiAppCards.tsx +++ b/apps/web/src/components/chat/OpenUiAppCards.tsx @@ -1,23 +1,50 @@ +import "@fontsource-variable/google-sans"; +import "@fontsource-variable/inter-tight"; +import "@fontsource-variable/mona-sans"; + import { defineComponent } from "@openuidev/react-lang"; import { memo, useState, type CSSProperties, type ReactNode } from "react"; import { z } from "zod/v4"; -import { APP_BRANDS, type AppBrand } from "@t3tools/client-runtime/app-connectors"; +import { APP_BRANDS, type AppBrand, type AppBrandId } from "@t3tools/client-runtime/app-connectors"; +import { + MAP_PAINT, + appCardTheme, + projectPath, + type AppCardFont, + type AppCardTheme, +} from "@t3tools/client-runtime/app-card-themes"; +import { OCTICONS, type OcticonName } from "@t3tools/client-runtime/octicons"; import { APP_CARD_DESCRIPTIONS as DESCRIPTIONS, APP_CARD_LIMITS as LIMITS, - APP_CARD_PAINT as PAINT, - PULL_REQUEST_STATE_COLORS, avatarColor, + carClass, cheapestRide, checkCounts, + checkTimeline, commitLaneX, + formatSeconds, rideReadout, + type CarClass, } from "@t3tools/client-runtime/openui-app-cards"; +import carBlack from "~/assets/app-cards/car-black.webp"; +import carComfort from "~/assets/app-cards/car-comfort.webp"; +import carX from "~/assets/app-cards/car-x.webp"; +import carXl from "~/assets/app-cards/car-xl.webp"; +import { useRenderedAppearance } from "~/hooks/useTheme"; + const text = z.string().max(LIMITS.text); const name = z.string().min(1).max(LIMITS.text); const minutes = z.number().finite().min(0).max(LIMITS.minutes); +const latitude = z.number().finite().min(-90).max(90); +const longitude = z.number().finite().min(-180).max(180); +const seconds = z + .number() + .finite() + .min(0) + .max(LIMITS.minutes * 60); export const rideOptionsProps = z.object({ app: z.enum(["uber", "lyft"]), @@ -58,6 +85,18 @@ export const routeProps = z.object({ minutes, distance: text, steps: z.array(name).max(LIMITS.steps), + via: text.optional(), + path: z + .array(z.tuple([latitude, longitude])) + .min(2) + .max(LIMITS.pathPoints) + .optional(), + map: z + .object({ + image: z.url({ protocol: /^https?$/ }).max(LIMITS.url), + bounds: z.tuple([latitude, longitude, latitude, longitude]), + }) + .optional(), }); export const pullRequestProps = z.object({ number: z.number().int().min(1), @@ -66,7 +105,15 @@ export const pullRequestProps = z.object({ head: name, base: name, checks: z - .array(z.object({ name, status: z.enum(["pass", "fail", "running", "skip"]), detail: text })) + .array( + z.object({ + name, + status: z.enum(["pass", "fail", "running", "skip"]), + detail: text, + started: seconds.optional(), + seconds: seconds.optional(), + }), + ) .max(LIMITS.checks), conflicts: z.number().int().min(0).max(10_000), commits: z @@ -85,12 +132,49 @@ export const pullRequestProps = z.object({ .optional(), }); +const FONT_STACK: Record = { + "google-sans": '"Google Sans Variable", "Google Sans", Roboto, Arial, sans-serif', + "inter-tight": '"Inter Tight Variable", "Inter Variable", "Helvetica Neue", Arial, sans-serif', + "mona-sans": + '"Mona Sans Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", sans-serif', +}; +const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; + +/** Theme tokens as CSS variables, so every element below reads var(--ac-…). */ +function themeVars(theme: AppCardTheme): CSSProperties { + return { + "--ac-surface": theme.surface, + "--ac-surface-alt": theme.surfaceAlt, + "--ac-on-surface": theme.onSurface, + "--ac-muted": theme.muted, + "--ac-outline": theme.outline, + "--ac-primary": theme.primary, + "--ac-on-primary": theme.onPrimary, + "--ac-container": theme.container, + "--ac-on-container": theme.onContainer, + "--ac-accent": theme.accent, + "--ac-success": theme.success, + "--ac-warning": theme.warning, + "--ac-warning-surface": theme.warningSurface, + "--ac-danger": theme.danger, + "--ac-done": theme.done, + "--ac-radius": `${theme.radius}px`, + fontFamily: FONT_STACK[theme.font], + color: theme.onSurface, + backgroundColor: theme.surface, + } as CSSProperties; +} + +function useCardTheme(app: AppBrandId) { + return appCardTheme(app, useRenderedAppearance() === "dark" ? "dark" : "light"); +} + // The brand's monogram tile; T3 Code bundles no logos (docs/user/openui-reports.md). -function BrandLogo({ brand }: { brand: AppBrand }) { +function BrandTile({ brand }: { brand: AppBrand }) { return ( -
- - - -

{title}

-

{subtitle}

- {meta ?
{meta}
: null} -
{children} ); } -function CardFoot({ note, action }: { note: string; action?: ReactNode }) { +function Octicon({ + name: icon, + className, + style, +}: { + name: OcticonName; + className?: string; + style?: CSSProperties; +}) { return ( -
- {note} - {action} -
+ + {OCTICONS[icon].map((d) => ( + + ))} + ); } -function OutLink({ +function ExternalButton({ href, children, - style, + tone, }: { href: string; children: ReactNode; - style?: CSSProperties; + tone: "primary" | "container" | "outline"; }) { + const style: CSSProperties = + tone === "primary" + ? { backgroundColor: "var(--ac-primary)", color: "var(--ac-on-primary)" } + : tone === "container" + ? { backgroundColor: "var(--ac-container)", color: "var(--ac-on-container)" } + : { + borderColor: "var(--ac-outline)", + color: "var(--ac-on-surface)", + backgroundColor: "var(--ac-surface-alt)", + }; return ( {children} @@ -166,32 +268,11 @@ function OutLink({ ); } -function CarGlyph({ tone }: { tone: string }) { - return ( - - - - - - - - ); -} -const CAR_TONES = [ - "#e9e9e9", - "#3a3f47", - "#f4f4f4", - "#151515", - "#c9ced6", - "#6b7280", - "#e5e7eb", - "#111827", -]; +const CARS: Record = { x: carX, comfort: carComfort, xl: carXl, black: carBlack }; const RIDE_LINKS = { uber: "https://m.uber.com/", lyft: "https://ride.lyft.com/" } as const; +const ROW_HEIGHT = 64; +/** Uber's product picker: vehicle renders, a grey selected row with a black outline that slides, one black button. */ const RideOptionsCard = memo(function RideOptionsCard({ props, }: { @@ -207,14 +288,33 @@ const RideOptionsCard = memo(function RideOptionsCard({ ); const chosen = props.options[selected] ?? props.options[0]!; return ( - -
+
+ +
+

{`Ride to ${props.dropoff}`}

+

+ {`from ${props.pickup}`} +

+
+ + {`Prices from ${brand.name}`} + +
+
+
{props.options.map((option, index) => ( ))}
-
+
- {`Request ${chosen.name} · ${chosen.price}`} + {`Choose ${chosen.name}`} -

+

{`Opens ${brand.name} to confirm. Nothing is booked from here.`}

- + ); }); +function initials(from: string) { + return from.trim().charAt(0).toUpperCase() || "?"; +} + +/** Gmail's list: avatars, bold unread rows, read rows on the tinted surface, attachment chips, a hover lift. */ const MailListCard = memo(function MailListCard({ props, }: { @@ -279,103 +388,124 @@ const MailListCard = memo(function MailListCard({ const brand = APP_BRANDS.gmail; const [open, setOpen] = useState(null); return ( - + +
+ +
+ + + + {props.query} + + {`${props.messages.length} result${props.messages.length === 1 ? "" : "s"}`} + +
+
{props.messages.length === 0 ? ( -

No messages matched.

+

+ No messages matched. +

) : null}
    - {props.messages.map((message, index) => ( -
  • - -
  • - ))} + + {message.subject} + + + {message.snippet} + + {message.attachment ? ( + + + {message.attachment.split(".").pop()?.toUpperCase().slice(0, 4) ?? "FILE"} + + {message.attachment} + + ) : null} + + + + ); + })}
- - Open in Gmail - - } - /> -
+
+ + Read-only. Nothing is sent from here. + + + + Open in Gmail + +
+ ); }); @@ -385,84 +515,152 @@ const MODE_LABEL = { transit: "Transit", bike: "Cycling", } as const; +const MODE_ICON = { + drive: + "M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16m11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5M5 11l1.5-4.5h11L19 11z", + walk: "M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6z", + transit: + "M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4M7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17m3.5-7H6V6h5zm2 0V6h5v4zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5", + bike: "M15.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M5 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5m0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5m5.8-10 2.4-2.4.8.8c1.3 1.3 3 2.1 5.1 2.1V9c-1.5 0-2.7-.6-3.6-1.5l-1.9-1.9c-.5-.4-1-.6-1.6-.6s-1.1.2-1.4.6L7.8 8.4c-.4.4-.6.9-.6 1.4 0 .6.2 1.1.6 1.4L11 14v5h2v-6.2zM19 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5m0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5", +} as const; +const MAPS_MODE = { + drive: "driving", + walk: "walking", + transit: "transit", + bike: "bicycling", +} as const; + +const MAP_W = 400; +const MAP_H = 200; -/** A schematic of the route that fills as the steps advance. Not a map: no geometry is known. */ -function RouteStrip({ steps, active }: { steps: number; active: number }) { - const n = Math.max(1, steps); +/** + * The route the maps tool returned, drawn in Google's map colours over the tool's static basemap when + * it sent one. Only real geometry: no path, no map. + */ +function RouteMap({ + path, + map, +}: { + path: ReadonlyArray; + map: z.infer["map"]; +}) { + const scheme = useRenderedAppearance() === "dark" ? "dark" : "light"; + const paint = MAP_PAINT[scheme]; + const { points, d } = projectPath(path, MAP_W, MAP_H, 22, map?.bounds); + const start = points[0]!; + const end = points.at(-1)!; return ( - + + {map ? ( + + ) : null} - - - + + + + ); } +/** Google Maps' directions panel: the travel time in traffic green, the route, then one step at a time. */ const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { const brand = APP_BRANDS["google-maps"]; const [step, setStep] = useState(0); const total = props.steps.length; - const mapsMode = { drive: "driving", walk: "walking", transit: "transit", bike: "bicycling" }[ - props.mode - ]; + const query = `origin=${encodeURIComponent(props.origin)}&destination=${encodeURIComponent(props.destination)}&travelmode=${MAPS_MODE[props.mode]}`; return ( - -
-

- {`${props.minutes} min`} - {props.distance} + +

+ +

+ {props.origin} + {" → "} + {props.destination}

- +
+
+ + + + + {MODE_LABEL[props.mode]} + + {`${props.minutes} min`} + + {`${props.distance}${props.via ? ` · via ${props.via}` : ""}`} +
+ {props.path ? : null} {total ? ( -
+
{step + 1}

{props.steps[step]}

- + @@ -472,45 +670,45 @@ const RouteCard = memo(function RouteCard({ props }: { props: z.infer setStep(step + 1)} - className="grid size-7 place-items-center rounded-full border border-border bg-card disabled:opacity-40" + className="grid size-7 place-items-center rounded-full border disabled:opacity-40" + style={{ borderColor: "var(--ac-outline)", backgroundColor: "var(--ac-surface)" }} > ›
) : null} - - Open in Maps - - } - /> - +
+ Route from Google Maps. + + + Open in Maps + + + Start + +
+ ); }); -// After @pitus_'s "Generative UI #2 - Merge conflicts": a commit graph with lanes, conflicting rows -// marked on the base lane, checks as chips, and the state said once in the footer. -const STATE_LABEL = { - open: "Open", - conflict: "Open", - ready: "Open", - merged: "Merged", - closed: "Closed", -} as const; -const LANE_COLOR = { base: "#bdbdbd", head: "#3d7be0", side: "#22c39b" } as const; -const LANE_TAG = { - base: "bg-muted text-muted-foreground", - head: "bg-[#edf3fe] text-[#2a5bc8] dark:bg-[#3d7be0]/15 dark:text-[#8db4f5]", - side: "bg-[#e9f8f2] text-[#0f8a68] dark:bg-[#22c39b]/15 dark:text-[#5fd9b6]", +const STATE = { + open: { label: "Open", icon: "git-pull-request", tone: "--ac-success" }, + conflict: { label: "Open", icon: "git-pull-request", tone: "--ac-success" }, + ready: { label: "Open", icon: "git-pull-request", tone: "--ac-success" }, + merged: { label: "Merged", icon: "git-merge", tone: "--ac-done" }, + closed: { label: "Closed", icon: "git-pull-request-closed", tone: "--ac-danger" }, +} as const satisfies Record; +const LANE_COLOR = { + base: "var(--ac-muted)", + head: "var(--ac-accent)", + side: "var(--ac-success)", } as const; const ROW = 32; @@ -535,8 +733,8 @@ function CommitGraph({ key={`${lane}-line`} d={`M${x} ${ys[first]} V${ys[last]}`} stroke={LANE_COLOR[lane]} - strokeOpacity={0.5} - strokeWidth={1.5} + strokeOpacity={0.6} + strokeWidth={2} fill="none" />, ); @@ -548,8 +746,8 @@ function CommitGraph({ key={`${lane}-fork`} d={`M${x} ${y1} C${x} ${(y1 + y2) / 2} ${commitLaneX("base")} ${(y1 + y2) / 2} ${commitLaneX("base")} ${y2}`} stroke={LANE_COLOR[lane]} - strokeOpacity={0.5} - strokeWidth={1.5} + strokeOpacity={0.6} + strokeWidth={2} fill="none" />, ); @@ -558,7 +756,7 @@ function CommitGraph({ return ( ))} ); } +const STATUS_ICON = { + pass: { icon: "check-circle-fill", tone: "var(--ac-success)" }, + fail: { icon: "x-circle-fill", tone: "var(--ac-danger)" }, + running: { icon: "dot-fill", tone: "var(--ac-warning)" }, + skip: { icon: "skip", tone: "var(--ac-muted)" }, +} as const satisfies Record; + +/** GitHub's pull request, in Primer: state label, commits on a graph, checks as a timeline, the merge box line. */ const PullRequestCard = memo(function PullRequestCard({ props, }: { props: z.infer; }) { const brand = APP_BRANDS.github; - const stateColor = PULL_REQUEST_STATE_COLORS[props.state]; + const state = STATE[props.state]; const counts = checkCounts(props.checks); - const [check, setCheck] = useState(null); + const timeline = checkTimeline(props.checks); + const [open, setOpen] = useState(() => { + const failed = props.checks.findIndex((check) => check.status === "fail" && check.detail); + return failed < 0 ? null : failed; + }); const commits = props.commits ?? []; const merged = props.state === "merged"; const foot = props.state === "conflict" - ? `${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} with ${props.base}` + ? { + icon: "alert" as const, + tone: "var(--ac-warning)", + title: `This branch has ${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} that must be resolved`, + sub: `Conflicts with ${props.base}`, + } : props.state === "ready" - ? "Ready to merge" + ? { + icon: "check" as const, + tone: "var(--ac-success)", + title: "All checks have passed", + sub: "This branch has no conflicts with the base branch", + } : merged - ? `Merged into ${props.base}` + ? { + icon: "git-merge" as const, + tone: "var(--ac-done)", + title: "Pull request successfully merged and closed", + sub: `Merged into ${props.base}`, + } : props.state === "closed" - ? "Closed without merging" - : counts.running - ? `Checks running · ${counts.pass} of ${props.checks.length} passed` - : `${props.head} → ${props.base}`; + ? { + icon: "git-pull-request-closed" as const, + tone: "var(--ac-danger)", + title: "Closed with unmerged commits", + sub: `${props.head} was not merged`, + } + : counts.fail + ? { + icon: "x-circle-fill" as const, + tone: "var(--ac-danger)", + title: "Some checks were not successful", + sub: `${counts.fail} failing, ${counts.pass} successful`, + } + : { + icon: "dot-fill" as const, + tone: "var(--ac-warning)", + title: "Some checks haven't completed yet", + sub: `${counts.running} in progress, ${counts.pass} successful`, + }; return ( -
-
- -

- {props.title}{" "} - {`#${props.number}`} -

- - - {STATE_LABEL[props.state]} - + +
+ +
+

+ {props.title}{" "} + {`#${props.number}`} +

+

+ + + {state.label} + + + {props.head} + + → + + {props.base} + +

+
{commits.length ? ( -
    +
      {commits.map((commit, index) => (
    1. {commit.title} {commit.branch ? ( {commit.branch} ) : null} - - {commit.sha.slice(0, 6)} - {`${commit.author} · ${commit.age}`} + + + {commit.sha.slice(0, 7)} + + {`${commit.author} · ${commit.age}`}
    2. ))}
    - ) : ( -

    {`${props.head} → ${props.base}`}

    - )} + ) : null} {props.checks.length ? ( -
    - {props.checks.map((entry, index) => { - const color = - entry.status === "pass" - ? "#1A7F37" - : entry.status === "fail" - ? "#CF222E" - : entry.status === "running" - ? "#9A6700" - : "#6E7781"; +
    + {props.checks.map((check, index) => { + const status = STATUS_ICON[check.status]; + const span = timeline?.spans[index]; + const expandable = Boolean(check.detail); return ( - + + {check.seconds !== undefined ? formatSeconds(check.seconds) : ""} + + + {expandable && open === index ? ( +

    + {check.detail} +

    + ) : null} +
    ); })}
    ) : null} - {check !== null && props.checks[check]?.detail ? ( -

    - {`${props.checks[check]!.name}: ${props.checks[check]!.detail}`} -

    - ) : null} - {merged ? "GitHub" : "Open on GitHub"} - } - /> -
+
+ + + + + {foot.title} + + {foot.sub} + + + + Open on GitHub + +
+ ); }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8acc312321b8f..50a11fe1dd87a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2545,3 +2545,53 @@ code { outline: 2px solid var(--ring); outline-offset: -2px; } + +/* App cards: type scale, easing and elevation copied from each app's design system. Colours come + from appCardThemes.ts as --ac-* variables on the card root. */ +@utility ac-t8 { + font-size: 8px; + line-height: 16px; +} +@utility ac-t11 { + font-size: 11px; + line-height: 16px; +} +@utility ac-t13 { + font-size: 13px; + line-height: 18px; +} +@utility ac-t15 { + font-size: 15px; + line-height: 21px; +} +@utility ac-t17 { + font-size: 17px; + line-height: 22px; + letter-spacing: -0.02em; +} +@utility ac-tight { + letter-spacing: -0.01em; +} +/* Material 3 emphasized easing. */ +@utility ac-ease-emphasized { + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} +/* Gmail's row hover lift. */ +@utility ac-lift { + &:hover { + z-index: 1; + box-shadow: + 0 1px 2px 0 rgb(60 64 67 / 0.3), + 0 1px 3px 1px rgb(60 64 67 / 0.15); + } +} +/* The maps route draws itself once along its real geometry. */ +@utility ac-route-draw { + stroke-dashoffset: 0; + @media (prefers-reduced-motion: no-preference) { + transition: stroke-dashoffset 1s cubic-bezier(0.65, 0, 0.35, 1); + @starting-style { + stroke-dashoffset: 1; + } + } +} diff --git a/apps/web/test/fixtures/ferry-sfo-map.png b/apps/web/test/fixtures/ferry-sfo-map.png new file mode 100644 index 0000000000000000000000000000000000000000..6605251594f1ba9d95ef1e735534ea0afc6e2452 GIT binary patch literal 33056 zcmV)^K!CrAP)*_{r>&^{rvp=`~3X-`}_L(`}z9%`T6g(<4>+R|4?C9z1=jiI@=<4O?>gDF>=$&=Huh%FI@3rOfvg7fxklOCn+1%9E+|$?F z($?C|)Yzxg;lISjuCTC*qN!+tie_eJa`O40004ixNkl6?hi)!uky3s%|SPgg=-&R`Hi=f`aY|o0{B{~D20#*Zj+C>9>A*;5b z`(vSiSO5sR{#!!mZI^a`FJR(X{q=vr(YY&e|39+Vc%KP?3{Thi#qZG@Gk4k_)a!2X zOQ4FkeoVl>Lnq~O2UxH8Z7wE01Mbm!HQ>gt^h|SVgjk~YP0xzoquS7ayx5fvhl{%S zfICpFiq|?&mjM!_0C}(VkU&>I{3e9>y|qBi6hJPAYa`@xwuOF2E7wlDRs*E(UFTQF z+dk3M{vZIip{oXtYqms^`l^)GnPy1Zc+;`GsWJWrLFPP zYUrA~C_vs3AWgCZ|JQA<_OiT^hhD2U>cFSkR~}{HZh;3I^;&iR_^iNHis{%lMia2w zn^D)Ld3=xrmVY*0`Q*W=CW^q8Y8YrV>INq?i~sxrw>;TpC1yap2s|+$;AHUD7V#m) z6QTB9O!2onr-qlfRyPhLeD~PQTknq-e5{(+MFH}TYvAgU^T|>WIx9l;T27j1VulVD0M#+%+g|Uz23JciI?Fb&ttLknS1<3mX z#3zKM`xnRmTPtd-Z11P`IM`$J~SM@1TZUH z#g0zv=Ex*Iw(;wG4`!ZEB9Ogctv~3?nSk-XOKJtg$FjtPqWtH@3VgZU>oqg*z>gIb zf67@KnuL5r+T3*wKxzxyyJMHjhPr`B;9{8tak-kLw9-|TboVxj4+;>S)q*v!EqMbJ zrqgB`8FCNXyXO;fM!#@p07bch_xmy}RO$rXpo<;YYtB@|I;g!^cxp-!(EZ; zDgMBAuUk(0RW72xdnYW~MA?9-ovcQ6PZx&Gd$)9D?lXGVZ#fk~aP}>xi`yb@lno7_OezOoyA#Wlo5gH0s*O|{jUa~*QcPU0#0O7&pNH`q|H_;C zGB!rDU|GH#k;TWdW%(pmhUtcmQtUn}oj}utNY5KwRm^C!_|@TiF9pa?TT+_lZp&h3 zB^I+VFyjl3&DdG93y<1(8wT_)vL}PJfK15qG`~XEtFvm;KM)eq&TRbVKl9(EY>a8x z)ZcE2?J7}|<6=4n#CY?1x=r%h|MfcYwRf*T0`%ejCyGd6?jF6qH!xGaHXC-sR9;A0qEnj&(AdQGdDzunJuE6f5!lxg1T(z14;r+BrS z?FpUi8VoqTED?K~6d*rmDXAIJt^p$0?)3hCCdJ=pVjSX2=G)WzX*(DFs`HAD`=YTg z68U5|OyS~`w@|LPbviP}2MK9A4;%P1f7JF?Y>nc5bm7mi@BVMmfTlFhIpg9D9S;lq zxn7+kA~#jDe8c6tX*p_k|8}K&(9KeS{ERg|P#vS#kx*8Cqn-P19^S!jS*|AD-S^sO zVVluG3z{n+G~8afkf3q?yP?W+jCY%G7 zu?Nyu-SA&Jf1q7uMVfN=67l4KOBc}1N~Hyd_PsJ)p_4XaNCE-L$lKm0l|=!fJy~5k z0GV~~d*!5aMCagdB`akif>q>7Rc`gLHfCB zVt0sDpI)=v?DX#M+W7?9vf=iuy>Jj%I~AYepI^c}KuF{2s?ortb#FjpvD`s9*4tUU zUOxVQnqL`_fDFf!931{T3Xq>vC0qc=J@_`=>J9KTyLeLZbzHRAk7jw$&~m~ zE)~TIWTS%mLwgN|;)>{_U%vpmchBSw@ukjGbsMy$+jh;Lxn1sXZWT|r(14aSPwRV?j?KKAAJBP< zy0sT;db(ocCrPr`KBEA6=hfGgdOIrt0s;}mMzKY+IDN(|hO-cE3U7=U->Rb|cue&rF;RwMX zUZDW_DKr!5lx$-j#PWRT6ePKv3E)rN-L;Dul}7-C8&G$JT@X8LquT~Ci+wkq<6t(2 zStzKFZ~f~r6xYDWb?XfzjZjDEZEOCJP&>Q#I}OSkm%msA%AKyRkcq#?;L;WBE#y80B$b_Xj;`IhqjnDll4Ff z4cUkH_?xfaVn?FAo+brV7PoS;c^3D3BPnPpo%~wcwgCb+Uh8dH3qop%@gcmqREGj& z^1huQuK~!4T9#MeXjr$ysRGC4(^8HojgXLf&~HJHKD^VPJ?x*q0U{c@fwkEc|DH(Z zDw9re@Nn3z?3#bS{fqtmivlQwB-F;%-t8K8^i41n--?k-9R(p?8&G9nkIaD>&z%<)_{8PRhqNKGJvIJDm+gp6iiH^@+2jS*vs z(r~MPJqCsN*7^DA>FL3N3ot9i2%p2r>bQcA^G)dA;9Nx>?eJvQtT+s;vdekIwxaRS z;t&ioV%-E`oG5a~ZVHh1O!5MNpu^w!CV=2H*7h`Rg3HY#Co6nA#tGh?O)?Z;kC9V- zWxG);N}871%5dE-NZx7@Q6AZ)wG41T)D24epz%;9O}Y)`U_%17v`y5TxL*_LV2vOJ z$onOE0YFd@(AnJpKy=IT3-WC~?9s<=)h+0Sho5&D7BO;eDE~Vqz3I`c>v-CexQ({^ zG=A+a2QyOEVq!T!jJI;Q*Of}Du{$FUW3fzx<5eQPK?;y}qM5+(0bTT+w*ds#3QPgS z72{DAASIsI50b*>!_U1GV?g*F-2Axi_eCAXCFy3BVgP?FbL ziLEUtO}Nh~LF&y-8(MP#b9U~0N}2aYfC6Ol9y~?VtAK95`)vSeqBK@FW=bD7#D7Kf z*szIowB|>kxR< z_k(rVg+l@I9z_t5MH0ut9WjI+Daj^xj51<43h~bfKDSNhT>)q6;i}3D@ODbl|3@HBoGv9#*#(&n7`t~Vn?rt42r~B zG57izu{PqT#AlLRgcwwMtJH#Q;#ObkY2Ub4wSz1YiNwicE`_qSsP!)3N`~1tVO8W5 z{^8>mj&Si|=T;#je_Gabk=as!frz5H-HMpp+Hc^|Q)_Z|l2Q^`S4y#qk|Ak6P*XTr zG>f$neCQ2fN_EzsqZ1716sK_Z^WH4i!q-&rSu-COACoJLgyKjfJ@#As_1eF~i7UU* zXK77z5JLV+a(*}&;uQ^BUa2+LtxGz=oP%86wYRAp76k~5&VT&S9a46OQ~ceNH(q3% z#_E|U6z=BvW_>@zO9+X6V0;WQP0>mC$Nx1hMxxp&+gogSvEtqBtR(mAuRGfllGvrQ z*Y+6Io4Bk(b*NFL0C^ARN?d2nP4VB((CNviSJ@5_ipDH9zz%PcHpT4^o4^tc2u{7S zQW9Y_MB!XkNE@z!!9YJ8l!hg^H55HM&W5@0%%IDF)C&wNhTe zyXVrQ8uk=pNn-bHpWbZ6UP>W={l+ZsCjy8j4hL8<6*yR?^|#yUdxJEVR!NNqe#u|i zZh}l|+wO(?ANPuqDtd0g$_>V_tmH}Yg3ydJdP)rW6_>2m&_9(rZN=N91x{z~k6qPx zyH731Tz8sW;mK&^g@~+kg1_*9a%8zyJHLBA6 zH+;CgD=v1KAD5TAS;4x2Dm>p+0M1$zx;dXT_lIcfb!k7yCWs`<*9=!7Qm?(V{zNv< zRoHZFcMCfjy%lPjyCRV(rclKFmix#68AO)=WHRoD>letji~(zALFadyKQ`YWqDpzF zW#+ux^sdVn*|Sul7vH>2ujP}-T^+by36QC0{fin(Ws7_{m-IW;shsvI&pYo{Z=&6- z>jS%3qnwe8MQHPi#>j_RPLtPSSXSib?5%(DbbIQ_E&uZ;(zzlSpjei4dOZ{%Z)z1> zQSB2wDy-MHrWV;TkV~xO;@+=%v1!_1@c2F%jNommRdoEfx9>&nM9JH-8}8JFPS6Kkut*p^}HQi zH%rLlryU=&N~mfL8KN$Yl*v@v=(f0!%}Tts_IjUK29WqPL;+&9-1|ytv}SCjsG>J0 z?sK0nlE{`h?#yr)x>ahCkRYf&I5B-IB}g+6y}WJ$^_QPDVn{T;zP?64xrNp7r0#Dy z0MF9;UJFL#OFr{z(Ym|nve9rtiUMTvT9&RkfFxD|q*^O*m2y6pJ=xWV#$OJ9k}dPi zq;)y#(T_FLCF_$EpjJqF_|amy!}H-}d^qd06j6viK>Gj84u`9aNWAjea3S37`Im_5 zPk9OuQwC)H@Z1H*s}{4yV+%sDbgok7GSP2ejFjE|$(#tDOhU9@sp;;r83-=z;OVn& z0_J+NH-is#F|Yr5zUl*jygcFL8ovY}kMl#FA1oM=ZQk6J^ZnWaJtfw?=Z!H^-iVuJ zq16x9(hg3*F!V)YiBu}f7C7KYRm$a3F_(&bGvXTAeL&u)nFr$SiZ?5q>CQk<2c9G~ z^H;m$O2LdJ>u-H1s!8i<{(L@002$p}{}@8LTBtu51BXgP!x%g${>I>kEfFB%;{$b~ zVIUAAVnYXlt!T>43g|I_JsDBU*S}-ace6qiAe#q>FC0rGQ`sz!+Lp>7#hy(kW075_ zonii3o;>;LPH?4cvR0GJxBEfRb{oVBbW4&eDan3Vdj;@M=wKI5KUOmEaQ-wu8bE;b z5C6IP=kN?ndazW4qobp%tE2v8aDG1f=kR)RbM(*b8~~($e$=@-QiHtZD&s~?3x^55 zNzRO$RW(p3iE(W(F;cjnPJ7)>TPY78?NWfOx}^uWoSjG}6H%Sot;ZKV20u$@^2Krm zEUuJ4OD7W%pVw<{N;=-u4lhs>Z2}2u&*H&XuamCZ)Pi3e+$TfXr6{H=vM{nf)TsBd zPMg{MbpF^90dju$Jb#%#?^qsRp6B!VBlyS5(an+g_4(-f2>x(>cs`qhZ)TSoX^b@H zVutTyBqd?S%?hFf;JzVM-WQ6+^Ibt!vb?7NS+PXEL$boL>0~^f$mGjqzRYHlsZ2Is z%_%t7YwFwMWuyv0Sd?^(7NwpLxAOW*{u;2VuYZY$|a#Op-#9S8&5HW?7%vUn1K>Eve zC=&6zo%S!5H!Z6(nkrN(K%0F<78y{vK=%Wt3Ek_9VX~5BhovkksNfcE-|fe$ft-W= z-f2cQUGVg%xq!<)my!+!vz##$57B-DZgk4vNJ zyhl_+Tu#q&o2?^8aVCobWR<8u&iO_lD0{1&Zm&O zS*)YS7x5)(fQb5)_`Co*7Tz*;v;56fZdT27K`S&ZZi>#(+((?a2{Ow+t8 z)|KoL+3ZV0j4w9lH?Nq^&17>$4#X-vhr)(-_=x5|7C`z}^M6|X)<3|{60z`ncyy%&$m6_ocmx0fa$PN8P7Y`D^P@wJ1sRKe z6d4Io7TIZWtk`Yw%gmF&LP~dky~d6Z%Z6y7&?G?O&1R)ktnlShHXehuJ;v_b4T*5b z0QHUiI%viT%IE11o;0x*0sDje7q9#wU<|q;Jfp~$ET-%W)(~B(UNV=W&j|MSFP*A z+0)bG`Q_#LBlz-oc>d4R6DWwN0W$fw?ss+l%6SH!d=}exN51IO?bhFOQGmRI+l+Qw z`c>jDcds75<_!cDAo9eLNuTQ;#oe03BYQ7bTXw}x(WJyxa(t{D@8lXZDT_Yepe3&B zVZYV7Y(c(SPW*N(J~)Ao-i#+Vm*9u3))2f1JknAFWC(;5q=`^r0Y=ivic_L#rgSWZ z*F^#HE+!;_WNu#}Q|3acS7v>Hh)wKu1PwG7{W!tLx_UDZ*q%IuFE$IgJZD8YQp#VB zKm7HD!WPAKf6sp}_FdgwX-f3H2oXP*#i?#UtFlYNZ^>DgadY!U&| zNaxFyVw%iFXPQ_jUL_vq5u_Znt<&|onnJ-UH-lsvtKJRh4C_sLmyt4l_T?gHr*U%OGC)v(bTZRV7-*UM{Hda~ci;H(VDOZ-^m%SVyM@^#6) zvV=JP@_$5DGj=uX*kFm$1!thOS|8TW8pD)A`xV&}G3ah~RVPWfH$8JDd6Lz0a(B}N z1<3mcA)=#XU8klRX?E{~ySv^ruG8cM5>(>UC@g5{i>1XVJR9G&t)gy`Z{#V(1*v7U zlgufeG8;DFL?&B_JoCd!|BPWxvom>RpJ$<`Zs6bIg`zK4l0>weLV^P1-2nm{*vhNN z7RZ^`B}EKL`9Yl3y=|X60b(1(fS!_ zG;xO&ii{Q_@iXa)Ig6}moojc!MgcN;rTm`;Ac3lmSn@t7?^SCZ?JgHC7FXk{>|8;H z52x~F4z#3)z}Q;cNsC0Jq;SZG=a0Zzz3+~E9Kc5~6GCS128kAZT!0{HiENH7^36`? z3andk4b2-7S5fGwW#FB{RH0YmH=WN4Tu^|#T7tZ6fCr$Eue1-18mE=DvdxshsG)Uc z43956u}o2fM$BPz!jI~NRT>4-xT;tOTMZ#9OOr=uRUpunhQD;lh1e#e>yLVIhndBr zfw4JuvR6a%M#PouO|wd~ZO^LSFxiZm9L+@m@|t@H8Sk7P9&Z1;y;T{8`d;wyaaK~@ zo-Q0yR3}fE!PV1)2JqNq(Zx*~DOa$!BXd^d0L_LMG@6pCH_Nl;>V{qlkk<;x7Ige1QCWGYa-8;_ zP{R+nC(%^yEVg4$3UD4hoaYPy0-rE{(WqZ}6S)%4Ms$4yA^T97JYq+bo%jRTLiR@)&+FL-Iq*59AJ4Lft@0w`IHuMm3 z`=9EcweI}+AG>b`NDy(MR;^vlW>=y^wPoLtEXe%%`}gl9sSnHk%@+kC?%CrV$6gRD z#ISSN?T&4`>pQdUO8ZP?w!~Uw9tEbhyFj%X3_>R(16+&q>km!Hx=kAAw|yOx5V=Gr zf4D8EM6UeZb~7&54Wkqw?^x_j>jFfxs*m$&6?gX?Odn@wfqHF!y1zerobJ^KK%N3s zar?%;+m0U}x3;&tkF#Ujwqv`y9e8}~xwl*{CzAo+ZEdBxjMMArJjycgj#Y7!uifh)*AXQ3zc7_5DtL$~XBuTB60hQjo| zfyjB%bQB^IXi`R#ZzR7jq=_N!G`}Uf^ZE2LLjk?Sa_ju}T24x9jJQ&FlalPab>M#XQX&;CmoR(9MM0FLQx$0_*fVpIX49k7}Zp*k?$zY7$e= zeihQPqA6En@H<}e@okgni`sJLqV>T!V(rXGf6P6J=VE+<_*oe)L;> zaLzv+LyNu_0NZ`P`du_0HPLWjfV@0|H3I;7ayp$38v_%6AQle@NS4=$y@k?L-OyBvY6QZE5~1WDJ+328k}q zeL)n<>frai@WU&KQx)wDOplQyGg2IaVw=V)Bz$MmT!aE-qxT8sd7Ddps|H-bnwXD? zJ_*nj|O1OR@R5V9aC2Ly=K?R=a+ zGa#mSh{eM$M33!x?gV&AwTZ`#_Cl9Xkp6eqbYe2r?|n91#iVk$nP?p;dzwOkJUt2DK|Iee;9Y04Cl@d!Pr~eFKDE2t`+)!+8$tl$lQe+t;5X&g zZ-4biw-T+>QCJzR!{O>7(rD?{^s4zvlCDGMr}R2+W;yMATKgN{NlL=0Fn}85b=3W-vK~MAi)ddcwa!7X70~SY~98QN* zERMW59gO(H7Xj4Ry&x%ehDClzQ&vd8`I;g$EnMplxc;MCmM})XHCILJ(1hekFjm$! zf+i{_e-S_HC+RwT0)S-J>1T2g6m27F>i-zV89)ANx4X++xx#Ka>@Y0}rW_{TPQ#kPZiEXGE?M#|>^Ai+ihRFmVl zT=)(8g~isNaR;u_#8!tp598GXC8uGf?xJd?D_cKT*Wt5R5HkY}Y>w^i9d9^|Zcmdd z7IL{vI(3ptL_?l$@N#W~rTpsJc5cIepa-`10=0U8MUll9HYWbuiJl0awYQB%=8#7u z#a=Os1?ntf&sG~d;P40e{-1IWuHyLQwo=Mvp+g!YWa%tVh7QGZ8Z(e7`Z(tIX z!@1)X;c%8O0cVLX1FZ!>u1ksfygs!zi~XM7YHr+u!t!yE&tZ}U!00_v8)cxyhc64u8 zc8!<(t!@~N|Lv27wDwl$$Aiuw#bZ6B3I$t-;s_vvKj&TyNp0PEHC_r3SDWNkIyrq^ zKepuj8GhC$00!yR|~E`>n7GVsWwv!{4GnN ze9Kk%g2&uAs^gAUY*9QpW3s;cH+*>B{7JWC*nvAgbh{iNiU}f9BN6>NPdc-*MHFMxP@gR@9h#gmgrOV zWhcve7uV0S1C6~9m-!;;I)o@dUbVum;98PmQ`>r@rSi}p`YT`6$);2>Kmq|B;aEIW z$6}qzK}#0Xv;0cU?aM#sW(3OsCf+2BmQrQeY840)zpdCgP(XdV1JMb!5%y<-Q=dLQ-bW_4p&umv#yplX1;~2m)|!<H7J|d` z5KowPo-F627UUJFMX#&2PNgu}Bn=_^uAs@hR4A`t7p_`+!G+4X^=?nicefHUR<-pf zRnc)yHfc`yeE^C_T}kr&s}dm53N|gNq9X1KRT4HgjX~BggsfFck0URqD-z(UCY>yH zca0c*&%0PT7iy|9B{*i0_$c6d{YhulEuJOiy&9QhVtWKhPfsbT$lG!Fkx^gU)EZL_ zDM0W#U$2yoS+;F40I13q=PEPoiQCcys`m z3q(c-u2|8k!&=NHaQJcTa?ysV$(t*MYn9SH$n9;40YFgENpjsc%!MVObIrbj3`CmH z@9UV0LfWyv!oFSCpB1IUmXIA1h>Xl+AT#n*9lj_hur%QZ1<2%0mBRH(>4*cwX{&zT zo6(Jd6@hb`t(8~YfEWhE9U*JA@#OF=)pXweb^o&UXNa7PNYhlPXhM(lJeL2sJc*G$dO+VmbkcAlL596$?Eu$(swCoQcHlP+bH_5X~usF$i>dy+( z1h5sdUR8>Z2I+4vgs1?r2j>xLvsf}fx;t7y&=uq@SVqr^Imm!s5aQvjW)We*A&9LD zD=XOuA=RIrpPwG||2Tjgud*QHQ;pW59cV2kW1HqAesX|Zs!Qo!J%D%v0irA2S*N8i zmMbILLj^)l&(I&IR>9(1G0&CT4B=pbnph5IGSdkW87;8RPrbjbi}j=RFIeJVQI%H> z0>s-)$4|i@c-ik?-wcK$IjKcb+5>!3B-Uc0>x#n*TJ%tWY^)HXE~Qt4!N8JyQ#))D zAdyVDoOYu<{Z|E=AwAk~EVIgB6cgZPr{Xekb#?>ED<_HHKL#0J1{<|twH6Enf?%pv zFc_?gXOS1BdTmj*QbI6 z$wlO7Vpj}7yupC(Gsy@N2CB?^z2QhCc9Kjc`J7_Jl)@9^9wxBD9=!wwbkTxbibrh! zcLd1byt=O&#mN~8N4(I?pTuIZ#7XjUI2>WCe~R3%@j202#G39lAzXM>cNztVaUsOL zj(#=nB=1$4g1^*L^VIeUR?9QZO1V_b=hl-Jf%n(EdXPn32OtXvUXW0^?MT)pLX_mJ<(!MNE@Z}l*ye9m zOCPm@##N+j_KUvX$w2Ys>2_%V>n4LV|~| zwNH$kXH?g)SCAGgrpW~)3Zzz~wdV$^!+=ci!M4cFeT8#au%?!AwCXCU-!5Q?+^`0hqzn4v?8w08!6a)__2k9;9GWO zGKV#;NBvWD{>ToS#9Fi@iWDFlbBWg*1&sg_Z^~qmEXQ-1s8dQvIym((Fhm8$R>tkE zD$YE_#7b%-{wRq#dcb7lx>5k5jO&e?u0Q%zwqf;6+meltDG8%iL+)f#Pl&ekC_pBg zYnfW7l#ZfqyufLxr+G}n3Ui{!JGLg?J5p$Y z8U;Dy>f;B`^^R(<-;)uaYZt|NIbbM9*gVT7sgowBNnw%pmSGbVAg=;QeSh6Kx6&BT zVp3h5uqOZ3rr_OB^8s5appT+R+Ekdu_BUCS*HVsve6O2~JjU;f_}rRGn75($8xB5p zCTm=MosZ=^&Q!hwV;h5XL`IyZKEM&4bUb)w*pOQVZ5BNAcVs7%rvQ2HQaW<69N2{C zH24VV4pfDKh@O=yE0Vm5)!ULn%`Q0-yCiRxgNWgF)l9!&qftE-+bUZ9!ACEr4{!c` zMVaE{ySPMwN*2~)Bf!ZT5(iekqJ+n=tD(zV_ba25-~VBovdZMs>(rtK*jbVymIl||D@6?c{@*00?3w7ukNYw@vTB*kjY!S z(pA9&{=vR%0lIEtK`=gS{Ayk5y3$RSQdsEq={O{-D^FAmLW|Us*pap2^J0AR7fkYc zRfg}p|8Jww`1d#I3K{9~3V?2F&^S6tL4cId1SuGcH!o%KK~>YCDPGwbK9_tLAHE75 ze=vD_SGw|kUDUjU1p*4(b+7|xVcao=_cy~=R}xbpi9~zn^C^rk1R&*)+ZvL0x{E1~ zq_p@=XPAPh`!oD~8bU7hqLA96rB%2|QV;?nb{PEaUNt0u-~&w64?Kgh3@@599m_uOW2`~mtkR*@Zp7l06PxivN)RBZmH`K!WLQoXoLNwk%s~NWBhQqsBUQyOep#77!3dFUbcPXi6&QT{d`Flv>%k49OytOnZNy56I^+X^2f~ zpIE%xQhmhkeOWGb?4yC8j1JZP^tY3DU%R4y3;}}U>>?CC=|I~q+h1QE<@|_BQX5nT zV0@TWxuRHDUNg4$w{4Q1~DQe4#aY5r=W$$9jIVEVG zn=j+ik3oK~wVIUWRu$cSCc5$M+<(jC zko|xvI*ce1y*M`&SG9KL;Q3MTi82>XR;A_pmOt@A*8<+ zvljsb6PYI?x?~flpTE9<9>)*X~nx$m62^qbT zP?(SebdRIx=VfxlSpLUmT@)Z20Z1_<7w?wy-C+^Lyfi@WzL)-D79I(q9dgBRa~z|j zZ5Qnl&jdHad+YjM{K5In?@|s~pe=d>ODXSpLO3jo*4~=UOL+{K1GjMSOiOS)xt_Tq z?I_$#o)-t`d4uGX8=lekvcjQR$2NJ-Z<&t#qf;pL`qaYQQ#H}QEEfg!p>ghP~1$r?ak1s?^-CIAwuzyLj+G34~t zRTgC1oM#*A_MB$XVU13^cmrhd3Fwpi8X`GWk~h#;?w+(plkwo=XMTqZINu8{CG?g~ zu+6{$qpg^Qfe7oymnXrg^MT1O1;{1-s!3Pcc`!r>n1b-BH&o?d?zatLMca_ z^N5K?#0Pt&m`b9O(F(P8GZY|i2S^%5z*{{;m-AwFDUf^%7{nlP6S;LyudJfx{vVDk zVx>x{b2pn!d%ZhB=yjU;R7~=vYmEl@cpj(n&(f+S4YTI_z2894dQdeJpO;dZ@A860 z+E@i!im%12D+A?D{GE)HDcou@N<}dWkXI{XDF!w-LElleS_dVns=r34aBf|IBodGz zwe;@xR88wKByr`)R-_)QkXMiOE#_}SIA6xxtB6L?^qTka^y6oKZ&vmf`o1qL@fwEy z_De${aD_>-9$PuCNCd58JqnPw0wf8ya!MA+y;$hR(EvnL7TIqs9a7)TDF!7q1=MUF zs;r=Jp@gk#iX;0T22%=Vesk*|h9BR{>aR-nX1?pK{IM^ewGuZ~??QJ4^Z+78%ig8JnN2CxXmR(Z=@i7ElQ-Fyz~c7NL_wLwT%T0rAOH58 z6Hn#4{wk%Cg!-w>btHHspAjNji_AMXS)U6a9_YU$8~OmL>lF&KA>+X~XopK`%1m>b z0Fr0$uq*pb-T)u|sIOB6w_-PE;*|k)Gw#Q~1()vSZJCMh#w*X|7!5p{5=%NoZdZ6p z0rG}1g`}&dtd9>xKcKH?dI*J&ZzcFX z{NdI#*e_U+Q7*hbXxX?oAieKMg>};`W25eck{ciXc5=dP)n6*>d3-@*WD?Dw#Ml z>yD9qEL0^dC~h*ypshG{4}JrQtiD2?Z>2trp&3c}IB5=Wyrk-`Wl63#(W3x)H9(@7 zr6GuX;}Y_$NN)M{yT=j4td0{gL?%wpDbjxnJ}r+yfRcgMiZtyCPF7eUzt)fh-hU@43;cwKgW^TpU1$=PSoSaY}fH^Xav1M=f{Xi~pkU+UeL($qx~M07OjL&MT5%CbkA z#EpzV)!knwK8jl{EUdl>2`&z|S=msC5C5&1ThZRLAc-hX?%WsIkoAUIIIWGDD)$~< zqH+_;>%JdO2W@E`71!^km38BP#qK=LD*@smvm~OO2o_8Z>E(KNHW{dY9)Mg+MGB^~ z3LpyE!{r%Q>|vF0$6FEdM%bupLA95-an=1jjXMYY27LAv(Z?o|s=lK0;ozb+($y}4 z5qj%4>AxKuyk>ufaq#ZtVKYxQ zrlV{9pE2)Cp2pVGdRWLiQzaU78m2CP6CI#eA_SX}l00%A)TTUTL|gPKkCVh5VKq!+ z6d;rJr?5$S$hsyZxVU41Imj=&dIgeHaTI}Hlz>$}TSad$!tH;CDKn7!!X$1ew3$k% z1hK%x9L=Agb+T@Ln>#Q%CsrY|W|ZJLDZwW7G88!x4=oReY=}>p?ah<)e43*Gd0j3( zgr;}(mIFkVCUGuhP|HBF7L$<*Y#z+P!%I2OD!7~&scM1O1U=&5Lq!zccmKwH=`!I2 zbk&w##kykCisErE*87=a<(ludm(@_p(`>pQ7e>q8$ z!7QgU!t!aX++Zy!O>7AvB*KT1T%J&Uvte3EpS*5Xx{K6e-B-&1QoYzK;smb!&MS(^ zL4I98%ZXwO=t*}mB5$QPAg@ucHdb|LNo;K}8l6l#Mje9z^xDSxP@nwQ`VA{mX{ncX zbuufGFPBR>x8%s?)haV36fJI*ld=j>!9xKuc`exl`1^1#78ducBCH1A-tEY`($x!n z(S}YGlc@g29n;Qc%uM{T7>Bg+>DLjNB`ClL8D0oTBD+$%(W^|KjwZh|LMp3TCr`L- zWuvW^*GAvm!lajQ$wdM325BtRc5Ck}0|;1g9Dw(tVl}(2*Oh*9kDd~r?nvX>*rN7O ztQrGEMqL^YHZBw|$TX#3g9s4?5ty}eLBzV7cko-@iQ%b4XFp!LJ=3gGaLmpY7EKPW zhma&-hG#t#Aa5xZdXTC@I#X*b1Bk1P_H0I5asssVdJQofwiaS^Kz=A^JSxG)+M;Jw z3kCy$z`o)GJy>?YIg@S6-k#hvg+H(8mN=On`o4|mYJ#}obr`rl^pXykI8ceyXHJT#ZlDb)X7nVZ6mabR#7ks&z zo;8{gj)aa#mlWgIM}N$%7`LQQJ!5#{Czbe!mJRm44>#ia@*JHowGnm2ziott=v6|* ztKV&YZvc>9Nu_mVs2+z087Q2yaix4l`1IUR0D}T#1L9cNbeFX4Op8PkG<^Gh(e}@j(?H@)C95dO zD--j(1qcyh4IoCO7~PBRa-ysry(8e%S6vN|!5?@(ZVuQbyc0xvaPqi2hbQ_JqswZ2 zshT(i$m9*uCgj3Pif|YqjYT)gFGuOVc$zJ@yWMUN%sQVcMjP$&np>*~MPSrj21Wb2 ztb(Xe)I|fnJaVitIP#u;+Gt zG4z0aR6(f+NaV@4F@@NVeLC;H0wDcA^o9&iPmFmd$+V&Z*tr2!pzD7diz;RmAd?NV zO`w8$CaTbZ)&&SAp-;0y)@m?q&|vm(gsl`aUg<^gPQ&nil1;G7hwd@<7JRxrBL{0V z7#cN8jYoK`$hO`rWjcy)Fn;Hfp~UW84Ao0gDt-G3{u+Z}U8lc|0%XH!ERIaZQE|!_ zu1yiusd^oo!8A^0S!F7gtrUnJxvgpF5%6CS3mAxGb7$&VIubh)07P$}4_b#{wQh8G zaQ_gk<&ulz^Yg=H!A%RjNBeI z35A9L2^meRwUg-F$U69P9QL)o6!Q9sdNlIN0mV6@Jz3{{KM)kf=4u2WwZ(&eaQN`@ z^74FpsJnanw=d#)UJj7K;mgCa!3iz+5zkjg!}lzzd-8^%daRjACbL)4$B%=CX?Y5e zjis@W`dCV%T(uw>TTU&V=A7nZID|0{7=4924(NBX6}_n+O=v(0@F(?|q&OfAPOK8D ziAXaU4>gGt9I0ci8yTwf2ylONaDH~l^~DE)_*2e59Bz=PNQo6+D}{K)|R3u-Zr z!6QG0hYzhjc(i{l+L0mn3O-_Z1HZt}coyH~`Q^3`oH6iP@Otr?_{-+{a5Kg&BC-BZ zaRLl#x+C)_KsK7jf-Y7psp(5v2&1RYWwXwE8PXfIRM9`mNdb(PtzF4epgAd`1!n&PDKwKBsB z4rSIK{Xy1OJ4j;Td8*UAY35I|B*ie+NRdnXfchwL$_miFu-LS8u(u`&>CZa+<*}K@EI6@7wC<>Je&io zBR=Q(Xm}1EX#p4Oumzp5!}Ck{oWpbYj8>ljq<;yX)H*`%06yz*I6MUJFy5?m?=h*W zS3=7G(o#`(>Ol&ScSvLH!ZdG69TWiax!bD_CIkgIY-QnY+M6PR3A-NaA;v4RzDzX# z_gRfNWfj#>bT%geM4JumUtae6*Z(O4^8VG!%k5!nFlk+$9|CY39RYvn5bj_M5Ex-U zzdV6&T9=21PXGh}Acp`gm**|8#}9!8L3{r22>cubEQjYu;GeC-bMUkg0>~|J<6h3M z`@m?3&%AuO>Vr?d+Hl_aCLF2gw}_|(DNfY@2~dE%LmCTJ(lgP8`5ldmn*OkKOH2@Z z(_w`oU%nLmqHxb5g-UW)77CPjsxhp4)*YKmkX}BEXZqk;!~xj-;{F|g z;Qags0Yp0TZw4@@1%3jsaejRxq5|OOdf0EFZ*KuYT9+@km%|_8BdyWp?Mv$i0tkGU zqw9hA4i7IPjE>GR2sc|Y^ys&U9H?F7#xmw71<2d)gPU-%bXxlZHPr=|0TPCK;XWSv zO5ZRkD5zysEtzucOUpT5XfzVAwWp{S?rqi}%F<$(M2{Qm0iM)$G*xX>#0~|mL0KX%NFc2-q)ytC@haC2gULL?3y+bSG=u{5Xl@XpQi#@C_Yp$UH*+?Y> z0fb!DFCrC4u@k0Qmm}}>xb0?^_q+#GxnDsw!)=lpBqm}vYP_P;o;@=wYYad zHmd~#2`xaxbQXN@QbY^b-U@&KIP`y@7zAXdfcphptcTW*{*f4p+_Vr{P>>E$2>xq{ zUjbUcIvTx?bg$eZB41^-GxzV(K4}V&O{im`7$h5AhK?l1W$k+T-TXSDmWwWOtW=Dv z*<4C7UZV9oGX;?6hbyr2pTOR|6bpJw00IJ&hx1#di**FTaPaDLh?U!xm=Oc-bKAPT zf(GgK1z~^$=`c_c6JH?60m-h1%lDG*jV!i^s0%Lt)qk#!_2~d2CVF8mYB#soN{h3G zv3wE$zplGB7yA8FA8myN2UOR;%9MpgM#|=0zeKWfDFVi ziD};c;nAVQyb|+OH<*hB0Rrh*H%IbwhGIhM5=8iiFwc7k=vZPNcr>{dpLBjR5dVGj zL&3!oKja#}!$2&8oR8lx%jnUzi2TqT3{|~6mRJ8Yq5xTc{UDRrG;Pe{b+3C;T;Bhe zZt2`=G{5#$3#7I3yNlxvu=rjRIs- z(k7mg*0+IKsf$L3(*_8l@@}(I;masI*koTc61DMXb!wB}9zN>TJ9-cckE8dU_Ni_W zSwQ3Xnn;=rNQ=+Dn4_lvnQV%VRfgFjUL`agDl}EDm6yme^!(C%&MynoqY^BeeWjoV zn)sHDQN_tIvf{o(u1GK`Q= zJ-1Sxn*!t=*d{*cW7&vKMxvy0t-i##BB)JFF~{&uZn8?8XcWtkE-aJe($rrINmv06xKI!xXxt>?=ZcwBkvow+vE(rX$gb|xL&qlv2t>#+xEbD634u#L1;~2 zE@BS41dv@t3<4wSNXDhS9WiKzn$5J-5Vfrk$?|0QBA@q@mJn!;?8!)(k;R=`j3Ka~AIxH*h+0!xOh=)zi7Qc&X?8TgDA|geioF&?noN5Y^%^QxyoZLNAUK zizL{O@PLmZ0mhOpNNCZhYQZ#COfB0?MopbPPym>?j|Zox?~Ibq2oRVUM0wjSFqO!t zf)VS;GF4E#$ELO*{8v>a+{c!%QDSn%2&Wj0#PRS42{z_fgvl3PH;omh05JiO*8Asq zKO;b(-x`7rRCL)edwiHD%Q&ICtD;S1<7n=eq93Y4MBm+Im;p>2aju5w+zS54d$aj`QO1ti$I2Bb*<=lX3}_wXy`pp&C49>*xi)7- zQV5Aaf2`mURY1i`VX~Qo7GRrO7)qQgkg7;5Clc#1`!K4VZtrW7DI!S~!7GZ%CgmYT zz-oXH>#<*@0NHeu4!tHve#y1*W08?w7!3pCphoGwYnMx*vIfKZ^km}VR)soHwivNn zoJDp~!68qwzMNi^o}xj>YQ|P;{No%?c-f}_*>IE&<8E7N*~P)_OYLMbMtq??pmB{k zyq3R8x}6}qnMx@g&vHJ}J6LSy^~ShzB&Ssf=|ny|6dGJpS8?Su@(;WZeb>>=8fS6e0n^v&i@WUZ` zH42c;M(IfAFdtnFkXRdn2_N*KrWl30nWa>0kYXz zx|c)!t!{2tznU>6SuEA?XEyKKWm z{&AF z`}VdA|8G-K2oG}sU(3g8ITafP$l3|5Qwoqx1|ecKn^hDd7`SXsMZWbBu+Q%~*cq4y zW@g(CpJBH-GmgPEfRzfvdNUDv3|V?`K$mzb63P_c21rZ z!aP3q`9-Z4MM;D}gqml3PM=DQd16GzjCpG`5@@2LrIizNS$8P6 z=A{7n^(N%g0_28Vfs9`R5N8fR!scYYKD|H}>lql7>0I2vTi{#x8WA822t}C3xx-HYSzusr1ilPChpU$Y#=5wW?bhK4>ox zfNVRM=NDUSK4Tn@2q1~4m)YD6BbCQ_2?QZd2ZI5!Z7m4EqGYk)OAj-4_H9J~n@TazI@ZFGvA{I-v&vea|lWkKs!vi~17@kanrqVyJ9KCZ&0kS!Oz#1JIFUXX--R}_w zcu@}9bjI4ggZjv^WoiR?T%JXE(ORNLb)1*159FV!eDhrY}{ zbhTorJ!MJ(vYAt`e@+4N+I23*|UO4>Ww$QXU1d!Y7 ze*d!78qlr&dW@V>fV`zrST=I8?xZoBJS%sPBThOW+B`sTtd3`@W6{$NdayK9>iSq7!K#oOZOhWR z91_dgt-KpTh?*T3Jtep(KqecKDyIN>6+r6IlCF={JX7lN+61YZ=P4suH-CBey$}*3 z#w*;R0Ga$^(#dBANDT!cP8}aBB&Y@5({06ob0p{+;oi;3B5Lw{iUQlzBP7Yuq#hyhZEjCX6(`32CC$nKs6gY_K#UpPvF0OX>_Sag;hF--EfKiuL~3~ECoq$BTOLIiBZ9D?hdZfpvWU&&#U0Mbb7 z;KMJZ)HQWJE?>4?)GU(DY#JXO@~d=Hu1mVHDL^K_uzDl`2AZ^kr<(!Sx!6hve zPJUHi$fpGepg=bWKqM~;=2x3q1__o}Jj?3k&rh=7ARb+Y#B`d^@0L#I9lNVu(O+d!<_Ej|@9(SBQlB13YJudyc? zNkk<&BUP=wBri(=vXM19pa5B`OQ3pD!+o^4{?Kb8EEJBBRPRLtV8kaN^1EAN*J26y zJSrC2qG^shUg=`NXs0U4#MQk@hH0ty7fBMx5-R7{=akL+s|)Iu&)m!J4g}Si+*lWN zdN36+_TGn4d?Sme+1T%l^+)XatQmk@Tu9>;_G>a)Ta)UP8UT` z5#wY)tA&$aQBSu}MKPFS<#B+_WpHFs!N-1mKZNTbmgK_b4OL_ft9Z%yD(^Ssnq(uW zR^xT~W?@-hsKYI^DV|=7Rgr%$=>C5DI-me~rAsOxx}tsRDk6iC$l%-)aDd{tzP;#1 z`8al$MAr#pFDp>1`vcW_z23lSGsQZd$z}-r65ES7tJiAPTKxk2zZ#@=j1uiDiy>4|i#54P%BoTu= zL4GMfCkXk5v4U19XD7_$! zAlQ-$>dh=kRZYS6wKJiNQyiv_&vnrxBv4y>IL|yi%2PcZjEmi7Y)+l)ZfDRU{k)dk z)bER^-Ca2)+Tgim5yj1S!G0!h4M`^~u8Mk()%WbpSTn>Afu{=J3Jk;|O10IIv z#rE`4vSl8bZKwFm?QI)e4g!#TK3^gZR>G@(f)jkT+hQqZ+aW%(?YeB8gT|@xHTZH( zho`?`jan2Srq>u`sP4k59FkCn#ctLIx$Kh5bLS;bNPe3?+MEsm4Muo+esnl&(?`Z- z|NgkudwF?!v^+n}o}Z?Sh!6%?0H?UYw;!K?=jC7?Q;*M2-)$HmjKkUfl7I9(J==ZY z2{Z77rwDigYjfFM;OmT;%^0Tp^!(HXZrbDX^W)*sKj2^gv?xM;UY!2PEr?H(WcAN3 z8X}F*15L<|lu?QZGOOhICBcAGo_U-FCi-7-OfG{?&(=7lX zR=e{B1|m-1^ZdEXIJU)T96XDeKQngQ{Lzj8^7!~Tv&`m?0CP_93D58eHs^c>u;;OX zd>8=8}2OVjzN|YX>t05V6^r_9L)0VE;eaT?_&bc)<%W9snRV3&Sxz z{DepQHuLg`UcW7VNxi&0Iw4eCf&StAsWrKLoF5M9Hvd_f-cJpXQ8A~IQtT-@KClUq zyP?8@U+PxCEj|K zGz@-IG#4rm;*QU!?7R(Jqn9TUAlo3^5CLL~IWa(-DPRF?4yOa4;yVaMkc*`TNb?&4 zNKTkXz^j!2d10JRn+OZW#(-p)_-d!qz4P*PK5RX`98&uGAM^4o2I?hdHvi6)=5rAPAYQ-#vE&om%pTrOxj=kMk=Ks6SEB-k-;L z|KtF{F4nz@L{){=ab!Z=d6xzuHdrT|CY=ef3^-$)Ht{R4AolOzaR3lI1jrXY#E3tb zKkjXTuP={_0O1fo;su-Z1OyNpBbE`LM1ai159^@^o_7wP#4ks*YWVY_^v?~D|1b@) zz^eF%4TTBe#HJ0>9vgr*#F+t}Sy*r~p#)>w76~OMVy0=^#$?2F)Gq2Mu~+QOb|J7A ztk>M3lnpUU06>7(gs3KVoH^bt?lGVwh!#wFfk@qviQorP$KVCqHhbpuuyu2E^v@y1 z$Ipz?KQ}=7IEw|^Kvbmkn%J!FgFLCQ*cT$T=7xl*G(AM{VJ0FV9wPoCVjym3kpNsY z>vGoWVq?1(JpnQoF?|vInGBGLqlfrT$J>4mfa1vowBc15Kj}wIS5^7Lg>`8;7q3c%mfq15FaOF8da+#XD zQ+t$P@+%U(pPXuv=9g_!*lNpa9VwrL5Vz`OfeRT6={OnMwr3!`xXVc}GdF^@N~?n# z%gs=Lm<31~bFl=yYvX}Yx<*978*usV5+r;`ssN(srv2Nht)gfJQL>hWap=p|DGfm; zze48ybJN|uT{k9~=!Vf5g8>J=I~=Jym0EJ{y22D76S8Y8mgUP!!*a|FIy|Kj2nEP5 z2-2~lqDGgR3`7&daYk>*TIilM0xCDRGWOd=CWJGVaVQh9QAgLreWK0izo?e}xdC#H zH+nIrJFr7EFW+>3)Xb4l(MA_G6Vr~4Hp*gI3enZ~i55S8QK|5gTM#_;7e{ zKOT*SgTW0=VNnb6OR^a_M$hV7feIS2=38z;;vJ)Fi>C3!4YRmqTuv!30Aw(vl*A8` z=l$FO=_%FX78a6^+bu3PA(pApbgAQ_X0nZndpe!PY{osQ>YPN-BBZH9n^RF|{5NONb_5oj?;{<)iibG84jd z4ZN%n2N9Mth|>vEQv2}~9}`Me{1^a1Dh!UE(bYlI*S4C6ALT4M-B5(t|%&Z=aNd&E~d5fN=+?WudHh% zSmdPX$LUjC$B$}xi8r)6YVtv9=@cN#d-_t)Ojs)+MZO&Dngxiht71h7@ygW$u<-p(qC%H?sQ0L|0OlIZ1WV$Y(GdS6NSonSfN%*n;RW zT~28weexl+e?Iq|^e*>CYU852t722Cdti*QPqMj373o3OOIERr5X-B2u>@Z%TJ{ASY2fIkv z$J$ydz2oDdm*h8WFz*nNM5kUpEXo?0kOF>PIU6l>g=uek-|?$ltl?#!vKA-5v{?Ab z0n+uG5pRp5AyA6Y!A8FQY>^xkIIVMvC(-1#}bhH>;S=C>1XV!3+3Uz;d(izABc2u z_f-sXgB2YlsV0fR^RU+Fi^tJX}4iz=(($qE48et!-$dcT!EC77LRrvVt^`&}i)xcCr~d@nG`NxF(++Ane(C00ClI{1!&Yf>YF%COa34 zIf96Ig}rOrEoK%^1>GkfZHmrk2MAF&yfd$q6ylL>_`*kxriNI1Bskx$6sBFL5(P}M zGwt%(uoFDjTjiy$CdzuCVGF3fc0kw$Z=<_VdW&7g}Q8`3!=PJuM!NAyLQg6 zaii=SzDe6nEKA$BWMoe{E|Gzbe&=4(%Nf#k(0HnA#5oOM(j*&JF>I9jtNN< zo;)i91bJ=;BZ--P?#7a&bkQuEU6#kyZ7c zL`h3XMxH}bD#a9yAOkGn%xCPZ-ED)r(?44eeC;*{k zRlutj$5KdWAAkS;9TI=fuSYkR=Mq2c^jcXO8PTx)E$;Pce@MxS6BA?utW6c}tOXEn zU_ZT}+8aRwcWhr5@WBeGiv*hx(LMzBG}Xm$TQVTu#l`CAaGl;jsgn_m+7B+NNqK+e zAIoQ#XC+({!L{xxZL3v#<2)<}#`&(GE}O>$UL_)DQ|XZ2{ZG@4e)1i!j85ap`OS6M z74DCqhd`r}_m4t8F+j#{kxNAe1(x&c0tCxtg^RfFBMbj&|67zJRUpLPRbsIr5J(cg$${2Cr51N$}_}PDSmrvf(5<&s8q9ee2Z0kW!2I0(Bk9U>?Ku+XS2;^U&B(}-J# zuqlQhp(IH>sxKw7^V7Y*Fl5-~;>f8l3KJqp|M-dG532&m_llYZpO=43(anXJA|#+!2@!=$B1MHLV!o*KhB~Aul1(p4Sz8q)_zr0t z!Saro?~AJxQ|Ex;`iFLm&@}J+t&y0-`XMGVSi-^WieRYgcv*EFF^4KNEP0%Rvc@aL zh=%))txKY=R^k~-`SAj;XJLQ zPkw^(kA-@m3f)kwA6H#ONZqhph)hUHXN+xv#Oe1Tb%7BUNpi7;M6Z%k?9&THl#8Z# zCqF~^=hFgYtSX~U?J9Y!{Y57$XlO$GsP?B*^d^nBNnk?U)p`xnuTm*>wUG3rSkh%x z5VI5_d79##{0!wE3+>s5EJD-(W$#H5xS3)I?g4@RnYXm~qZn>BvD}4R-kO{Wf9mLlJ&X8tan+WPGhof|n6x@r$sJZ~zD;xfM^ktMMfaU?YUmFDp2`qcX2>Yn3xca zczZUcO7-F#frZi6uS=8+$|8Q6>ZJgg{EQinECNU`{3?97e1Cs|*{a2ANC%VIS@nGA zUDQjG#Wbxlp`}a}sQ?dKE|n_@R2I>z9Ofc)e&Op}Y~-4bXclY9HPX!BJbQ&YmYFal zgv;b#HOh3Tw~)bJnZ)Q-%u4id5%C#WG|CfmNH?r%hmu*nTS~7?lD(E$zD4{(X9ba1^i#eWUyDLy9dtbOQXuL0 zzup-gptFv2aXY@Ig!RHI!!YCAD@gBRpo4k9iJXdUk|Cu+UtSuao=obK;AM!= zNW|o+_%I`lpEiL4WU_`bYa1Tdw(08u;^Sc$5?@dwyf<5ZsXG2d6CGN0>#SY_^shjlOroA?`s20Pi8*O#VA_)qS zrLGmXrM{@P8IlC205KDS_yi;wnq%Jr5Eti`gtGQ(q{k{sn244*3c!ORHy9>Rj!_r_ z1NGRQp=hZXgTxeA@E9~y9niwMk!uyzUeoRSp~;>p3J|dS1WcLg#@+-F9}-Rs6`<-# z9g(-XG3lnTq}im76O36@BuQo!w5^5Ztf>2>5=NE7l$Y8NovhVeMmzoPf9dJNh)k8B z0GX`nmB|X&z41<6Z(Fevlrl-DARQPMiCch7O@nr^Ukoo4(Hv;7JpA4xN z2J5KBs*P*p)?~9D2-d_-tcEPd z%0&wz8rQ13;96}JXAh&bx(Y3yyqV13lXOX%#W&&u2$~r-Ao3kUYNqPMFHpD z%ZA!0h$;``7J0y-t*2D)RX9_5#UQrfrC07bEK!|a$+fzb6C-3fw62wDM(?KvNV`jF zNA_uX1Nd;Ak0<{rX^SkLug1)b+jhBcMD#i=e3R~Ix#yDu6@>v&nTgS*pY$9i=mL0+K!;H6l zSb%s}tNI(zO%6--!muDyuxuUV^!XH#cx17KP=v@an1s-$uGMXCp_XJfbgc|%-4r0? z;NVDh7dZTF*T&WXxa3VSGvx(gH-|H~83`bDJtqqdbA##B!u>Pz7xgxrtmR%w1+Ta3P_!j1Fa|u@ur&2IJs)4G0%X<;o z4Y86K5>m?9Z822@5;Bl78TG@LpSUna!F_)t`nED+Nr|Uq#4)B zq>p9!jIAI!qC@qyS9WcBiMx2*5$lCvP99waD(LPdFT(XgFDD`-tw?5ByKQ1aGPG2_ z=vuYCTGz^J(zTi>1}Ra1OjM;)0UyyjLTbb2!;29zglda81eq>3A8;HG>S?$5xm_i_ z7>+LNl`xlts$gkdtK=J8E0aE!kN)fe@szf}_%? zp=J_bbwnHD>lNgerv=?v%j}3VMR0ijpBo79R{gxpI$h|5LXZfJ)R{{2L`I`xagq&oF{5PLjxhK)PeHj0wdFh`JAP?Sm zwjdx(=o1q{jy|4zTEGw>#5^SP$fGI(wX7ts+8thr8{@tHtjawbbgl6EK>?yVlW?0~ zJN145k%ILHLW{I?_!Tb+)QR9p8nv}sE3slmQxUUIStYOj4|lB=m3wSC;bG$eSf2t! zm0EIOnYeevhfC^TdXR{@9c~33OLo4}r~qQ)302i0Z4_&#{V5QltK1XctZOwfBW&{N z0P=u~6NR$tCs>flXeKd6y32*>YKl@xHl?b6w3{=T-pdjSnRoE1lzVnI?pjS~C0z#} zm?rDLcfITC%LpOuPu8Bg@)D>SXS50mzIzg=k1FZC5()Wq^l6lP-o>>tCEM_41LV4| zIb|O@q%skHUx0X}G}B0s5uzSH6=S*I!2doO9Y=1`DCcc&TCFXzxU+2zSdhWwpFE4)|>dWV0!a?cJw zdw5rN&GHclmLGrgf`5C9{Oi9NZeQ*eTt8u*V=FSeCkXk{hMK7+^*D?s}RlN z#a8;gi=h0s{r>Crc2T* { [ ride, [ - /Uber/, - /#000000/, - /Ferry Building → SFO Terminal 2/, - /Best price/, + /Uber ride options/, + /--ac-primary:#000000/, + /Ride to SFO Terminal 2/, + /Cheapest/, /\$52\.10/, - /Booking happens in the Uber app/, + /Choose UberX/, + /Nothing is booked from here/, ], ], [ mail, [ - /Gmail/, + /Gmail results/, /#EA4335/, - /“invoices from Acme” · 2 results · 1 unread/, + /invoices from Acme/, + /2 results/, /Invoice #4512/, - /aria-label="Unread"/, + /aria-label="Unread"|font-bold/, ], ], - [ - route, - [ - /Google Maps/, - /18 min/, - /6\.2 mi/, - /Driving · Home → Ferry Building/, - /Turn right onto Market St/, - ], - ], - [ - pr("ready", 0), - [/GitHub/, /#128 · feat\/session-refresh → main/, /Ready to merge/, /Checks · 3 passed/], - ], - [pr("conflict", 3), [/3 conflicts with main/]], - [pr("merged", 0), [/>MergedMerged]*href="([^"]*)"/g)) + assert.match(href, /^https:\/\//, "cards only link out to the app over https"); } assert.equal(render(ride).match(/aria-pressed/g)?.length, 3); assert.match(openUiChartPrompt, /RideOptions\(app: "uber" \| "lyft"/); @@ -94,7 +86,7 @@ route = Route("Home", "Airport", "transit", 52, "14 mi", ["Walk to 16th St BART" spend = BarChart("Ride spend", "USD", [{label: "Uber", value: 120}, {label: "Lyft", value: 80}])`; assert.deepEqual(inspectOpenUiChart(code), { kind: "ready" }); const html = render(code); - assert.match(html, /#FF00BF/); - assert.match(html, /Transit · Home → Airport/); + assert.match(html, /--ac-primary:#ff00bf/); + assert.match(html, /Transit/); assert.match(html, /Ride spend/); }); diff --git a/experiments/openui-lab/dogfood/demo-connectors.mjs b/experiments/openui-lab/dogfood/demo-connectors.mjs index 025c4905f10a5..24531f1ad052a 100644 --- a/experiments/openui-lab/dogfood/demo-connectors.mjs +++ b/experiments/openui-lab/dogfood/demo-connectors.mjs @@ -4,6 +4,45 @@ // nothing is fetched, booked, sent or merged. import * as NodeReadline from "node:readline"; +const ROUTE_FERRY_SFO = [ + [37.79384, -122.39395], + [37.79325, -122.39321], + [37.79372, -122.39261], + [37.79271, -122.39147], + [37.79115, -122.39061], + [37.78924, -122.38841], + [37.78778, -122.38799], + [37.78085, -122.38865], + [37.7737, -122.39757], + [37.77275, -122.3981], + [37.76886, -122.39738], + [37.76591, -122.39412], + [37.76399, -122.393], + [37.75164, -122.39166], + [37.74557, -122.39467], + [37.73888, -122.40064], + [37.73572, -122.40444], + [37.73383, -122.40507], + [37.71634, -122.39893], + [37.7114, -122.39581], + [37.67773, -122.38851], + [37.6742, -122.38972], + [37.67028, -122.39245], + [37.66498, -122.39762], + [37.6597, -122.40477], + [37.65578, -122.4068], + [37.65292, -122.40712], + [37.64071, -122.40597], + [37.6356, -122.40409], + [37.62656, -122.40252], + [37.61442, -122.39796], + [37.6138, -122.39644], + [37.61536, -122.38856], + [37.61475, -122.38581], + [37.6151, -122.38486], + [37.6165, -122.38392], +]; + const DEMO = "Demo data from the T3 Code OpenUI lab, not a real account."; const CONNECTORS = { @@ -20,6 +59,7 @@ const CONNECTORS = { snippet: "Your October invoice for $1,240.00 is ready. Payment is due Nov 15.", time: "9:41 AM", unread: true, + attachment: "INV-4512.pdf", }, { from: "Acme Billing", @@ -78,14 +118,27 @@ const CONNECTORS = { origin, destination, mode, - minutes: 18, - distance: "6.2 mi", + minutes: 22, + distance: "14.5 mi", + via: "I-280 S and US-101 S", steps: [ - "Head north on Valencia St", - "Turn right onto Market St", - "Continue onto The Embarcadero", - "Arrive at the Ferry Building", + "Head south on Steuart St", + "Turn left onto Mission St", + "Turn right onto The Embarcadero", + "Continue onto King St", + "Merge onto I-280 S", + "Take the exit onto US-101 S", + "Keep right onto US-101 Spur toward the airport", + "Turn right onto Departures", ], + // The real Ferry Building to SFO driving route (OSRM over OpenStreetMap, ODbL), simplified. + path: ROUTE_FERRY_SFO, + // A static basemap of the same area in Google's map colours, from OpenStreetMap (ODbL). + map: { + image: + "https://raw.githubusercontent.com/kvnloo/t3code/media/openui-app-cards/maps/ferry-sfo-map.png", + bounds: [37.597565, -122.664055, 37.810018, -122.127003], + }, }), }, }, @@ -101,9 +154,43 @@ const CONNECTORS = { mergeable_state: "dirty", conflicts: 3, checks: [ - { name: "Tests", status: "pass", detail: "vitest · 42 passed" }, - { name: "Lint", status: "pass", detail: "" }, - { name: "Types", status: "running", detail: "tsgo" }, + { name: "Install", status: "pass", detail: "", started: 0, seconds: 11 }, + { name: "Lint", status: "pass", detail: "", started: 11, seconds: 9 }, + { name: "Tests", status: "pass", detail: "vitest · 42 passed", started: 11, seconds: 38 }, + { name: "Types", status: "running", detail: "tsgo", started: 20, seconds: 24 }, + ], + commits: [ + { + title: "Refresh tokens before expiry", + sha: "a41f9c2", + author: "kevin", + age: "2h", + lane: "head", + branch: "feat/session-refresh", + }, + { + title: "Add refresh test for clock skew", + sha: "9b3e0d4", + author: "kevin", + age: "3h", + lane: "head", + }, + { + title: "Rewrite session middleware", + sha: "c90e1b7", + author: "julius", + age: "5h", + lane: "base", + branch: "main", + conflict: true, + }, + { + title: "Bump effect to 4.0.0-beta.31", + sha: "77d0aa1", + author: "theo", + age: "1d", + lane: "base", + }, ], }), }, diff --git a/experiments/openui-lab/run-smoke.mjs b/experiments/openui-lab/run-smoke.mjs index e7b395c20f902..9559630e75b0b 100644 --- a/experiments/openui-lab/run-smoke.mjs +++ b/experiments/openui-lab/run-smoke.mjs @@ -17,6 +17,8 @@ const sources = { "OpenUiAppCards.tsx": "apps/web/src/components/chat/OpenUiAppCards.tsx", "openUiAppCards.ts": "packages/client-runtime/src/openUiAppCards.ts", "appConnectors.ts": "packages/client-runtime/src/appConnectors.ts", + "appCardThemes.ts": "packages/client-runtime/src/appCardThemes.ts", + "octicons.ts": "packages/client-runtime/src/octicons.ts", "app-cards.test.ts": "experiments/openui-lab/app-cards.test.ts", "report.test.ts": "experiments/openui-lab/report.test.ts", "charts.test.ts": "experiments/openui-lab/charts.test.ts", @@ -28,7 +30,16 @@ const rewrites = [ ["@t3tools/client-runtime/openui-charts", "./openUiCharts"], ["@t3tools/client-runtime/openui-app-cards", "./openUiAppCards"], ["@t3tools/client-runtime/app-connectors", "./appConnectors"], + ["@t3tools/client-runtime/app-card-themes", "./appCardThemes"], + ["@t3tools/client-runtime/octicons", "./octicons"], + ["~/hooks/useTheme", "./useTheme"], ]; +// SSR in plain Node has no bundler: fonts are dropped, images become their file names, the theme is light. +const stubs = { + "useTheme.ts": 'export const useRenderedAppearance = () => "light";\n', +}; +const bundlerOnly = /^import "@fontsource[^"]*";\n/gm; +const assetImport = /^import (\w+) from "~\/assets\/[^"]*\/([^"/]+)";$/gm; for (const [name, path] of Object.entries(sources)) { if (name.endsWith(".md")) { copyFileSync(join(root, path), join(scratch, name)); @@ -36,22 +47,76 @@ for (const [name, path] of Object.entries(sources)) { } let text = readFileSync(join(root, path), "utf8"); for (const [from, to] of rewrites) text = text.replaceAll(`"${from}"`, `"${to}"`); + text = text.replace(bundlerOnly, "").replace(assetImport, 'const $1 = "$2";'); writeFileSync(join(scratch, name), text); } -writeFileSync(join(scratch, "tsconfig.json"), JSON.stringify({ compilerOptions: { jsx: "react-jsx" } })); -const dependencies = { "@openuidev/react-lang": "1.0.2", "@openuidev/lang-core": "1.0.2", zod: "4.1.12", react: "19.2.0", "react-dom": "19.2.0", tsx: "4.20.6" }; -writeFileSync(join(scratch, "package.json"), JSON.stringify({ private: true, type: "module", dependencies })); +for (const [name, text] of Object.entries(stubs)) writeFileSync(join(scratch, name), text); +writeFileSync( + join(scratch, "tsconfig.json"), + JSON.stringify({ compilerOptions: { jsx: "react-jsx" } }), +); +const dependencies = { + "@openuidev/react-lang": "1.0.2", + "@openuidev/lang-core": "1.0.2", + zod: "4.1.12", + react: "19.2.0", + "react-dom": "19.2.0", + tsx: "4.20.6", +}; +writeFileSync( + join(scratch, "package.json"), + JSON.stringify({ private: true, type: "module", dependencies }), +); const env = { ...process.env, OPENUI_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }; function run(command, args) { - const result = spawnSync(command, args, { cwd: scratch, env, encoding: "utf8", timeout: 120_000 }); - const log = (result.stdout ?? "") + (result.stderr ?? "") + (result.error ? String(result.error) : ""); + const result = spawnSync(command, args, { + cwd: scratch, + env, + encoding: "utf8", + timeout: 120_000, + }); + const log = + (result.stdout ?? "") + (result.stderr ?? "") + (result.error ? String(result.error) : ""); process.stdout.write(log); return { status: result.status, log }; } -const install = run("npm", ["install", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"]); -const result = install.status === 0 ? run(process.execPath, ["--import", "tsx", "--test", "report.test.ts", "charts.test.ts", "app-cards.test.ts"]) : install; +const install = run("npm", [ + "install", + "--ignore-scripts", + "--legacy-peer-deps", + "--no-audit", + "--no-fund", +]); +const result = + install.status === 0 + ? run(process.execPath, [ + "--import", + "tsx", + "--test", + "report.test.ts", + "charts.test.ts", + "app-cards.test.ts", + ]) + : install; writeFileSync(join(out, "smoke.log"), result.log); -const hash = (path) => createHash("sha256").update(readFileSync(join(root, path))).digest("hex"); +const hash = (path) => + createHash("sha256") + .update(readFileSync(join(root, path))) + .digest("hex"); const sourceSha256 = Object.fromEntries(Object.values(sources).map((path) => [path, hash(path)])); -writeFileSync(join(out, "smoke-receipt.json"), JSON.stringify({ scope: "isolated stock parser + React SSR, not integrated T3 or browser", status: result.status, node: process.version, dependencies, sourceSha256, scratch }, null, 2)); +writeFileSync( + join(out, "smoke-receipt.json"), + JSON.stringify( + { + scope: "isolated stock parser + React SSR, not integrated T3 or browser", + status: result.status, + node: process.version, + dependencies, + sourceSha256, + scratch, + }, + null, + 2, + ), +); process.exitCode = result.status ?? 1; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 2b4c187e3fc12..3c7f768bcc295 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -522,6 +522,14 @@ "./openui-app-cards": { "types": "./src/openUiAppCards.ts", "default": "./src/openUiAppCards.ts" + }, + "./app-card-themes": { + "types": "./src/appCardThemes.ts", + "default": "./src/appCardThemes.ts" + }, + "./octicons": { + "types": "./src/octicons.ts", + "default": "./src/octicons.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/appCardThemes.test.ts b/packages/client-runtime/src/appCardThemes.test.ts new file mode 100644 index 0000000000000..b34b4254b8e03 --- /dev/null +++ b/packages/client-runtime/src/appCardThemes.test.ts @@ -0,0 +1,41 @@ +import * as NodeAssert from "node:assert/strict"; +import { it as test } from "vite-plus/test"; + +import { appCardTheme, projectPath } from "./appCardThemes.ts"; + +test("each app gets its own design system in both schemes", () => { + NodeAssert.equal(appCardTheme("github", "light").done, "#8250df"); + NodeAssert.equal(appCardTheme("github", "dark").surface, "#0d1117"); + NodeAssert.equal(appCardTheme("uber", "light").primary, "#000000"); + NodeAssert.equal(appCardTheme("gmail", "dark").font, "google-sans"); + NodeAssert.equal(appCardTheme("spotify", "light").font, "google-sans"); +}); + +test("a route path keeps its shape and fits the box", () => { + // A north-south route is taller than wide, so it fills the height and is centred horizontally. + const { points, d } = projectPath( + [ + [37.79, -122.39], + [37.7, -122.4], + [37.62, -122.38], + ], + 300, + 200, + 20, + ); + const ys = points.map(([, y]) => y); + NodeAssert.equal(Math.min(...ys), 20); + NodeAssert.equal(Math.max(...ys), 180); + for (const [x] of points) NodeAssert.ok(x > 100 && x < 200); + NodeAssert.match(d, /^M[\d.]+ [\d.]+ L/); + NodeAssert.deepEqual(projectPath([], 10, 10).points, []); +}); + +test("with basemap bounds, a point lands where the image drew it", () => { + // Bounds and pixel from the Ferry Building to SFO basemap the demo maps connector returns. + const bounds = [37.597565, -122.664055, 37.810018, -122.127003] as const; + const { points } = projectPath([[37.7944, -122.394]], 400, 200, 16, bounds); + const [x, y] = points[0]!; + NodeAssert.ok(Math.abs(x - 201.2) < 1, `x ${x}`); + NodeAssert.ok(Math.abs(y - 14.7) < 1, `y ${y}`); +}); diff --git a/packages/client-runtime/src/appCardThemes.ts b/packages/client-runtime/src/appCardThemes.ts new file mode 100644 index 0000000000000..be0ee171f8c78 --- /dev/null +++ b/packages/client-runtime/src/appCardThemes.ts @@ -0,0 +1,229 @@ +/** + * App card themes copied from each app's design system: GitHub from Primer, Uber from Base Web (Inter + * Tight stands in for the private Uber Move), Gmail and Maps from Google's Material 3. Web maps a theme + * to CSS variables; mobile reads it directly. + */ +import type { AppBrandId } from "./appConnectors.ts"; + +export type AppCardFont = "google-sans" | "inter-tight" | "mona-sans"; + +export interface AppCardTheme { + readonly font: AppCardFont; + /** Card container. */ + readonly surface: string; + /** Inset areas: read mail rows, selected rows, check lists. */ + readonly surfaceAlt: string; + readonly onSurface: string; + readonly muted: string; + readonly outline: string; + /** Primary action fill and its text. */ + readonly primary: string; + readonly onPrimary: string; + /** Tonal action / selected fill and its text. */ + readonly container: string; + readonly onContainer: string; + /** Links and focus. */ + readonly accent: string; + readonly success: string; + readonly warning: string; + readonly warningSurface: string; + readonly danger: string; + readonly done: string; + readonly radius: number; +} + +type ThemePair = { readonly light: AppCardTheme; readonly dark: AppCardTheme }; + +const GOOGLE: ThemePair = { + light: { + font: "google-sans", + surface: "#ffffff", + surfaceAlt: "#f2f6fc", + onSurface: "#1f1f1f", + muted: "#444746", + outline: "#c4c7c5", + primary: "#0b57d0", + onPrimary: "#ffffff", + container: "#c2e7ff", + onContainer: "#001d35", + accent: "#0b57d0", + success: "#188038", + warning: "#b06000", + warningSurface: "#fef7e0", + danger: "#b3261e", + done: "#0b57d0", + radius: 16, + }, + dark: { + font: "google-sans", + surface: "#1e1f20", + surfaceAlt: "#282a2c", + onSurface: "#e3e3e3", + muted: "#c4c7c5", + outline: "#444746", + primary: "#a8c7fa", + onPrimary: "#062e6f", + container: "#004a77", + onContainer: "#c2e7ff", + accent: "#a8c7fa", + success: "#6dd58c", + warning: "#fdd663", + warningSurface: "#3d3000", + danger: "#f2b8b5", + done: "#a8c7fa", + radius: 16, + }, +}; + +const BASE: ThemePair = { + light: { + font: "inter-tight", + surface: "#ffffff", + surfaceAlt: "#f3f3f3", + onSurface: "#000000", + muted: "#5e5e5e", + outline: "#e8e8e8", + primary: "#000000", + onPrimary: "#ffffff", + container: "#f3f3f3", + onContainer: "#000000", + accent: "#276ef1", + success: "#0e8345", + warning: "#9f6402", + warningSurface: "#fdf2dc", + danger: "#de1135", + done: "#000000", + radius: 12, + }, + dark: { + font: "inter-tight", + surface: "#161616", + surfaceAlt: "#292929", + onSurface: "#dedede", + muted: "#ababab", + outline: "#383838", + primary: "#dedede", + onPrimary: "#000000", + container: "#292929", + onContainer: "#dedede", + accent: "#5e8bdb", + success: "#5c9d70", + warning: "#f6bc2f", + warningSurface: "#3a2d0b", + danger: "#de5b5d", + done: "#dedede", + radius: 12, + }, +}; + +const PRIMER: ThemePair = { + light: { + font: "mona-sans", + surface: "#ffffff", + surfaceAlt: "#f6f8fa", + onSurface: "#1f2328", + muted: "#59636e", + outline: "#d1d9e0", + primary: "#1f883d", + onPrimary: "#ffffff", + container: "#ddf4ff", + onContainer: "#0969da", + accent: "#0969da", + success: "#1a7f37", + warning: "#9a6700", + warningSurface: "#fff8c5", + danger: "#d1242f", + done: "#8250df", + radius: 12, + }, + dark: { + font: "mona-sans", + surface: "#0d1117", + surfaceAlt: "#151b23", + onSurface: "#f0f6fc", + muted: "#9198a1", + outline: "#3d444d", + primary: "#238636", + onPrimary: "#ffffff", + container: "#388bfd1a", + onContainer: "#4493f8", + accent: "#4493f8", + success: "#3fb950", + warning: "#d29922", + warningSurface: "#bb800926", + danger: "#f85149", + done: "#ab7df8", + radius: 12, + }, +}; + +const LYFT: ThemePair = { + light: { ...BASE.light, primary: "#ff00bf", onPrimary: "#ffffff", accent: "#ff00bf" }, + dark: { ...BASE.dark, primary: "#ff00bf", onPrimary: "#ffffff", accent: "#ff5fd5" }, +}; + +export const APP_CARD_THEMES: Readonly>> = { + gmail: GOOGLE, + "google-maps": GOOGLE, + "google-calendar": GOOGLE, + uber: BASE, + lyft: LYFT, + github: PRIMER, +}; + +export function appCardTheme(app: AppBrandId, scheme: "light" | "dark"): AppCardTheme { + return (APP_CARD_THEMES[app] ?? GOOGLE)[scheme]; +} + +/** Google Maps' map palette (measured on google.com/maps): land, water, park, route and its casing. */ +export const MAP_PAINT = { + light: { + land: "#f7f7f7", + route: "#0b57d0", + routeCasing: "#0842a0", + ink: "#1f1f1f", + startFill: "#ffffff", + imageFilter: "none", + pin: "#ea4335", + pinCore: "#a50e0e", + }, + dark: { + land: "#1f2835", + route: "#8ab4f8", + routeCasing: "#4285f4", + ink: "#e3e3e3", + startFill: "#1f2835", + imageFilter: "invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.6)", + pin: "#ea4335", + pinCore: "#a50e0e", + }, +} as const; + +/** + * A [lat, lon] path projected into a w x h box (Web Mercator). Fits the path itself, padded, or the + * [south, west, north, east] bounds of a basemap image drawn edge to edge in the same box. + */ +export function projectPath( + path: ReadonlyArray, + w: number, + h: number, + pad = 16, + bounds?: readonly [number, number, number, number], +) { + if (path.length === 0) return { points: [] as Array<[number, number]>, d: "" }; + const merc = (lat: number) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + const xs = path.map(([, lon]) => (lon * Math.PI) / 180); + const ys = path.map(([lat]) => merc(lat)); + const [x0, x1, y0, y1] = bounds + ? [(bounds[1] * Math.PI) / 180, (bounds[3] * Math.PI) / 180, merc(bounds[0]), merc(bounds[2])] + : [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const inset = bounds ? 0 : pad; + const span = Math.max((x1 - x0) / (w - inset * 2), (y1 - y0) / (h - inset * 2)) || 1; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const points = xs.map((x, i): [number, number] => [ + Math.round(((x - cx) / span + w / 2) * 10) / 10, + Math.round(((cy - ys[i]!) / span + h / 2) * 10) / 10, + ]); + return { points, d: "M" + points.map(([x, y]) => `${x} ${y}`).join(" L") }; +} diff --git a/packages/client-runtime/src/octicons.ts b/packages/client-runtime/src/octicons.ts new file mode 100644 index 0000000000000..7e4742dd86599 --- /dev/null +++ b/packages/client-runtime/src/octicons.ts @@ -0,0 +1,42 @@ +/** + * Primer Octicons used by the GitHub app card: 16px viewBox path data, one or more paths per icon. + * From @primer/octicons 19.42.0, MIT License, Copyright (c) GitHub Inc. + */ +export const OCTICONS = { + "git-pull-request": [ + "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z", + ], + "git-pull-request-closed": [ + "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z", + ], + "git-pull-request-draft": [ + "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z", + ], + "git-merge": [ + "M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z", + ], + "git-commit": [ + "M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z", + ], + check: [ + "M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z", + ], + "check-circle-fill": [ + "M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z", + ], + "x-circle-fill": [ + "M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z", + ], + skip: [ + "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z", + ], + "dot-fill": ["M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"], + alert: [ + "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + ], + clock: [ + "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z", + ], +} as const satisfies Record>; + +export type OcticonName = keyof typeof OCTICONS; diff --git a/packages/client-runtime/src/openUiAppCards.test.ts b/packages/client-runtime/src/openUiAppCards.test.ts index 58fe619e22b50..bde74e817a995 100644 --- a/packages/client-runtime/src/openUiAppCards.test.ts +++ b/packages/client-runtime/src/openUiAppCards.test.ts @@ -4,9 +4,11 @@ import { it as test } from "vite-plus/test"; import { appCardProblem, avatarColor, + carClass, + checkTimeline, + formatSeconds, cheapestRide, checkCounts, - pullRequestSteps, rideReadout, type PullRequestCheck, } from "./openUiAppCards.ts"; @@ -63,17 +65,28 @@ test("pull request states must agree with checks and conflicts", () => { running: 0, skip: 0, }); - NodeAssert.deepEqual( - pullRequestSteps("ready").map((step) => step.reached), - [true, true, true, false], - ); - NodeAssert.deepEqual( - pullRequestSteps("merged").map((step) => step.reached), - [true, true, true, true], - ); }); test("a sender keeps one avatar color regardless of case or spacing", () => { NodeAssert.equal(avatarColor("Acme Billing"), avatarColor(" acme billing ")); NodeAssert.match(avatarColor("Dana Ruiz"), /^#[0-9a-f]{6}$/); }); + +test("ride names map to a vehicle glyph", () => { + NodeAssert.deepEqual( + ["UberX", "Comfort", "UberXL", "Black", "Black SUV", "Lyft XL", "Wait & Save"].map(carClass), + ["x", "comfort", "xl", "black", "xl", "xl", "x"], + ); +}); + +test("checks with timing become gantt spans; any untimed check falls back", () => { + const timeline = checkTimeline([ + { started: 0, seconds: 11 }, + { started: 41, seconds: 33 }, + ]); + NodeAssert.equal(timeline?.total, 74); + NodeAssert.deepEqual(timeline?.spans[1], { start: 41 / 74, length: 33 / 74 }); + NodeAssert.equal(checkTimeline([{ started: 0, seconds: 3 }, {}]), null); + NodeAssert.equal(formatSeconds(64), "1m 4s"); + NodeAssert.equal(formatSeconds(38), "38s"); +}); diff --git a/packages/client-runtime/src/openUiAppCards.ts b/packages/client-runtime/src/openUiAppCards.ts index 80e3421222ffe..654a3f11a9f8f 100644 --- a/packages/client-runtime/src/openUiAppCards.ts +++ b/packages/client-runtime/src/openUiAppCards.ts @@ -17,6 +17,8 @@ export const APP_CARD_LIMITS = { steps: 20, checks: 12, commits: 12, + pathPoints: 600, + url: 2_000, minutes: 100_000, } as const; @@ -26,9 +28,9 @@ export const APP_CARD_DESCRIPTIONS: Readonly> = { MailList: "Messages a mail search returned: the query, then each message's sender, subject, snippet, time text, unread flag and optionally one attachment file name.", Route: - "Directions a maps app returned: origin, destination, mode (drive, walk, transit or bike), minutes, a distance string, then the steps in order.", + "Directions a maps app returned: origin, destination, mode (drive, walk, transit or bike), minutes, a distance string, then the steps in order. Optionally the road it takes (via), the route geometry the tool returned as [lat, lon] points (path) and the static basemap it returned as {image, bounds: [south, west, north, east]} (map); never invent a path or a map.", PullRequest: - "One pull request: number, title, state (open, conflict, ready, merged or closed), head and base branches, its checks (pass, fail, running or skip), the conflict count, and optionally its recent commits newest first (title, short sha, author, age text, lane base, head or side, an optional branch label on a lane's tip, and conflict true on base commits that conflict).", + "One pull request: number, title, state (open, conflict, ready, merged or closed), head and base branches, its checks (pass, fail, running or skip, optionally with the second each started and how many seconds it ran), the conflict count, and optionally its recent commits newest first (title, short sha, author, age text, lane base, head or side, an optional branch label on a lane's tip, and conflict true on base commits that conflict).", }; export const APP_CARD_PROMPT_RULES = [ @@ -143,28 +145,6 @@ export function checkCounts(checks: ReadonlyArray) { return counts; } -/** Progress steps the card draws, in order; `reached` marks how far this pull request got. */ -export function pullRequestSteps(state: PullRequestState) { - const order = ["Opened", "Checks", "Ready", "Merged"] as const; - const reached = state === "merged" ? 4 : state === "ready" ? 3 : state === "closed" ? 1 : 2; - return order.map((name, index) => ({ name, reached: index < reached })); -} - -export const PULL_REQUEST_STATE_COLORS: Readonly> = { - open: "#1A7F37", - conflict: "#BC4C00", - ready: "#1A7F37", - merged: "#8250DF", - closed: "#CF222E", -}; - -export const CHECK_STATUS_SYMBOL: Readonly> = { - pass: "✓", - fail: "✕", - running: "●", - skip: "–", -}; - /** x of a commit-graph lane, in px from the card's left edge. */ export function commitLaneX(lane: "base" | "head" | "side") { return lane === "head" ? 34 : lane === "side" ? 46 : 22; @@ -188,14 +168,49 @@ export function avatarColor(name: string) { return AVATAR_COLORS[hash % AVATAR_COLORS.length]!; } -/** Fixed colors the cards draw: the attachment chip, the route, the cheapest tag and the car glyph. */ -export const APP_CARD_PAINT = { - googleRed: "#ea4335", - routeBlue: "#1a73e8", - routeCasing: "#cfe0fb", - cheapest: "#1a7f37", - shadow: "rgba(0,0,0,.10)", - glass: "#cfd8e3", - tyre: "#1d1d1d", - white: "#ffffff", -} as const; +export type CarClass = "x" | "comfort" | "xl" | "black"; + +/** Which vehicle glyph a ride option gets, from its product name. */ +export function carClass(name: string): CarClass { + const n = name.toLowerCase(); + if (/\b(xl|xxl|suv|van|6|7)\b|xl$/.test(n)) return "xl"; + if (/black|lux|premier|select/.test(n)) return "black"; + if (/comfort|priority|extra/.test(n)) return "comfort"; + return "x"; +} + +export interface CheckSpan { + readonly start: number; + readonly length: number; +} + +/** + * Gantt bars for checks that report timing: each bar's start and length as fractions of the run. + * Returns null when any check lacks timing, so the card falls back to chips. + */ +export function checkTimeline( + checks: ReadonlyArray<{ + readonly started?: number | undefined; + readonly seconds?: number | undefined; + }>, +): { readonly total: number; readonly spans: ReadonlyArray } | null { + if ( + checks.length === 0 || + checks.some((check) => check.started === undefined || check.seconds === undefined) + ) + return null; + const total = Math.max(1, ...checks.map((check) => check.started! + check.seconds!)); + return { + total, + spans: checks.map((check) => ({ + start: check.started! / total, + length: check.seconds! / total, + })), + }; +} + +/** "1m 4s", "38s". */ +export function formatSeconds(seconds: number) { + const s = Math.round(seconds); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4462481e14d9..420ea72ae404f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,15 @@ importers: '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 + '@expo-google-fonts/google-sans': + specifier: ^0.4.4 + version: 0.4.4 + '@expo-google-fonts/inter-tight': + specifier: ^0.4.2 + version: 0.4.2 + '@expo-google-fonts/mona-sans': + specifier: ^0.4.1 + version: 0.4.1 '@expo/metro-runtime': specifier: ~58.0.14 version: 58.0.14(@expo/log-box@58.0.12)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -1045,9 +1054,18 @@ importers: '@effect/atom-react': specifier: 4.0.5 version: 4.0.5(effect@4.0.5)(react@19.3.0)(scheduler@0.28.0) + '@fontsource-variable/google-sans': + specifier: ^5.3.2 + version: 5.3.2 '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 + '@fontsource-variable/inter-tight': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/mona-sans': + specifier: ^5.3.0 + version: 5.3.0 '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -4043,9 +4061,18 @@ packages: '@expo-google-fonts/dm-sans@0.4.2': resolution: {integrity: sha512-e/fLH5aCJzkEenz7axvEn8N7IJAKhUwNjIxS75h9j+Ip1M9S7d22M19gJN3A7QW4egVkBJRyQh1hQFhngQn9yA==} + '@expo-google-fonts/google-sans@0.4.4': + resolution: {integrity: sha512-6lx67hqW5HDZEHu4tY0DBr3OlTrCjrbMLITQMeykX6GeXVAtjSC34ShqjkFHELAQD0cmykfGK+GzHWjjo3TRDQ==} + + '@expo-google-fonts/inter-tight@0.4.2': + resolution: {integrity: sha512-/b4YLh8v145nMfrtw0FF2EB8eRhw/SVt/LdhzN1+Faz/uSFvZkF7EtxfipV8iacDSb1PUqyssHd0tZjlzhhf2g==} + '@expo-google-fonts/material-symbols@0.4.38': resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} + '@expo-google-fonts/mona-sans@0.4.1': + resolution: {integrity: sha512-lwr4o4HWTj9oiBEPotlTX6VarveV9WJAOBPn3k3A98sQaQ3jFGI3Xld8zDp1/ZswAR4cmhxGMJLDqHilh1mojA==} + '@expo/cli@58.1.11': resolution: {integrity: sha512-wkFTSLXXYqLkRT5oy4DnLuTOtbFIu3qTiXHSHkFSqUBTHiE92y3bOIwM3UN7Y1ys18qVJge8lXjezaEfsHmijQ==} hasBin: true @@ -4332,9 +4359,18 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/google-sans@5.3.2': + resolution: {integrity: sha512-fN+fO2Ffm+n2G5K+UK4ynP5WrWT+M/dbBs9HZaYcgAT5evhefJJH2MlGj8yMz+8slQmhjWAqPJyJ1sELkUM4Uw==} + + '@fontsource-variable/inter-tight@5.3.0': + resolution: {integrity: sha512-ZhZZ29sNZ15P1K+cUt13GeBRYON0akgmNiftshUeDLos7X7zGcRgPzt434879Xhq1QbwlW3M/apBMk7jWabwvA==} + '@fontsource-variable/inter@5.3.0': resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@fontsource-variable/mona-sans@5.3.0': + resolution: {integrity: sha512-PkxRB3KI4z9Gjpk7vVqgO3GMSyQuOIIj4TaRDbvbcDj8SzWwI38jvLTQxJM/cTRorhcT7h8qjcllR+qjRtM/Fg==} + '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -14014,8 +14050,14 @@ snapshots: '@expo-google-fonts/dm-sans@0.4.2': {} + '@expo-google-fonts/google-sans@0.4.4': {} + + '@expo-google-fonts/inter-tight@0.4.2': {} + '@expo-google-fonts/material-symbols@0.4.38': {} + '@expo-google-fonts/mona-sans@0.4.1': {} + '@expo/cli@58.1.11(5e998cea2b9eedfd9ad81915214fc418)': dependencies: 2g: 1.0.1(@tsc-rs/typescript@0.8.0) @@ -14493,8 +14535,14 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/google-sans@5.3.2': {} + + '@fontsource-variable/inter-tight@5.3.0': {} + '@fontsource-variable/inter@5.3.0': {} + '@fontsource-variable/mona-sans@5.3.0': {} + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 From 54e9bb966983114997ce0634a9e5a83216ff1359 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:35:52 -0600 Subject: [PATCH 10/11] feat(openui): boards fill in card by card, plus a replay scenario and in-app film - A streaming openui-v2 board now renders its complete statements as they arrive, so cards appear one by one instead of showing source until the fence closes (web and desktop; Android still renders once complete). - Replay (dev) gains an "App cards" scenario: four demo connector calls, then the board, so the real server, adapter and clients can be checked for free. - test/appCardsFilm.renders.tsx shoots the launch film inside the real app (AppRoot on the fake environment), one PNG per frame on a virtual clock. - Ride card hides its price note at phone width; Android dims the basemap in dark mode. --- apps/mobile/src/native/OpenUiAppCards.tsx | 7 +- apps/server/scripts/dev-replay-agent.ts | 2 + .../dev-replay/app_cards_transcript.ndjson | 13 + .../chat/MarkdownOpenUiCodeBlock.tsx | 8 +- .../chat/OpenUiAppCards.browser.test.tsx | 23 +- .../src/components/chat/OpenUiAppCards.tsx | 5 +- apps/web/src/components/chat/OpenUiCharts.tsx | 13 +- apps/web/test/appCardsFilm.renders.tsx | 393 ++++++++++++++++++ apps/web/test/fakeEnvironment.ts | 36 +- docs/operations/development.md | 6 + docs/user/openui-reports.md | 5 +- .../client-runtime/src/openUiAppCards.test.ts | 19 +- packages/client-runtime/src/openUiCharts.ts | 19 + 13 files changed, 532 insertions(+), 17 deletions(-) create mode 100644 apps/server/scripts/dev-replay/app_cards_transcript.ndjson create mode 100644 apps/web/test/appCardsFilm.renders.tsx diff --git a/apps/mobile/src/native/OpenUiAppCards.tsx b/apps/mobile/src/native/OpenUiAppCards.tsx index 89d98ad8d9f00..10ca4bb21997b 100644 --- a/apps/mobile/src/native/OpenUiAppCards.tsx +++ b/apps/mobile/src/native/OpenUiAppCards.tsx @@ -3,7 +3,7 @@ import { Image } from "expo-image"; import { memo, useState, type ReactNode } from "react"; import { Linking, Pressable, Text, View, useColorScheme, type TextStyle } from "react-native"; import Animated, { FadeInDown, ReduceMotion } from "react-native-reanimated"; -import Svg, { Circle, G, Path, Image as SvgImage } from "react-native-svg"; +import Svg, { Circle, G, Path, Rect, Image as SvgImage } from "react-native-svg"; import { z } from "zod/v4"; import { APP_BRANDS, type AppBrand, type AppBrandId } from "@t3tools/client-runtime/app-connectors"; @@ -631,9 +631,12 @@ function RouteMap({ width={MAP_W} height={MAP_H} preserveAspectRatio="none" - opacity={scheme === "dark" ? 0.55 : 1} /> ) : null} + {/* Basemaps come light; dark mode dims them under Google's night land colour. */} + {map && scheme === "dark" ? ( + + ) : null} "}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Four apps to check: Gmail for the invoice, Uber for prices, Maps for the route, GitHub for the PR. They are independent, so I will call them together and answer with one board."}}}}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-gmail","kind":"other","title":"mcp__gmail__search_messages","status":"in_progress","rawInput":{"query":"invoices from Acme"}}}}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-uber","kind":"other","title":"mcp__uber__get_price_estimates","status":"in_progress","rawInput":{"pickup":"Ferry Building","dropoff":"SFO Terminal 2"}}}},"afterMs":350} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-maps","kind":"other","title":"mcp__google-maps__get_directions","status":"in_progress","rawInput":{"origin":"Ferry Building","destination":"SFO Terminal 2","mode":"drive"}}}},"afterMs":350} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-github","kind":"other","title":"mcp__github__get_pull_request","status":"in_progress","rawInput":{"number":128}}}},"afterMs":350} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-gmail","status":"completed","content":[{"type":"content","content":{"type":"text","text":"2 messages from Acme Billing and Dana Ruiz (demo data)."}}]}}},"afterMs":500} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-uber","status":"completed","content":[{"type":"content","content":{"type":"text","text":"UberX $38.40, Comfort $46.10, UberXL $61.75 (demo data)."}}]}}},"afterMs":750} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-maps","status":"completed","content":[{"type":"content","content":{"type":"text","text":"22 min, 14.5 mi via I-280 S and US-101 S (demo data)."}}]}}},"afterMs":1000} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-github","status":"completed","content":[{"type":"content","content":{"type":"text","text":"PR #128: 2 conflicts with main, Lint failing (demo data)."}}]}}},"afterMs":1250} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Here's Thursday at a glance, from your connected apps.\n\n```openui-v2\nroot = Board(\"Thursday, before SFO\", \"From Gmail, Uber, Google Maps and GitHub. Demo data.\", [mail, ride, route, pr])\nmail = MailList(\"invoices from Acme\", [{from: \"Acme Billing\", subject: \"Invoice INV-2041 for October\", snippet: \"Your invoice of $4,280.00 is due Nov 15. Pay by card or ACH from the billing portal.\", time: \"Oct 31\", unread: true, attachment: \"INV-2041.pdf\"}, {from: \"Dana Ruiz\", subject: \"Re: Updated pricing for Q4\", snippet: \"Thanks for confirming. I have updated the order form.\", time: \"Oct 24\", unread: false}])\nride = RideOptions(\"uber\", \"Ferry Building\", \"SFO Terminal 2\", [{name: \"UberX\", minutes: 4, price: \"$38.40\", seats: 4, arrival: \"6:42 PM\"}, {name: \"Comfort\", minutes: 6, price: \"$46.10\", seats: 4, arrival: \"6:45 PM\"}, {name: \"UberXL\", minutes: 9, price: \"$61.75\", seats: 6, arrival: \"6:49 PM\"}])\nroute = Route(\"Ferry Building\", \"SFO Terminal 2\", \"drive\", 22, \"14.5 mi\", [\"Head south on Steuart St\", \"Turn left onto Mission St\", \"Turn right onto The Embarcadero\", \"Merge onto I-280 S\", \"Take US-101 S toward the airport\", \"Turn right onto Departures\"], \"I-280 S and US-101 S\", [[37.79376,-122.39407],[37.79324,-122.3934],[37.79251,-122.39433],[37.79246,-122.39228],[37.78788,-122.38787],[37.78263,-122.38804],[37.77976,-122.38962],[37.77737,-122.39212],[37.7759,-122.39525],[37.77356,-122.39816],[37.77129,-122.40078],[37.76716,-122.40196],[37.76224,-122.40219],[37.75564,-122.40312],[37.74932,-122.40347],[37.74476,-122.4039],[37.73961,-122.40442],[37.73529,-122.40594],[37.73186,-122.40592],[37.72818,-122.40382],[37.72402,-122.40047],[37.71913,-122.39819],[37.71246,-122.39591],[37.70594,-122.39391],[37.69766,-122.39276],[37.68965,-122.39327],[37.6828,-122.39578],[37.67455,-122.3984],[37.66447,-122.40065],[37.65586,-122.40054],[37.64764,-122.40214],[37.64144,-122.40354],[37.63487,-122.40011],[37.62957,-122.39552],[37.62291,-122.39129],[37.6165,-122.383]], {image: \"https://raw.githubusercontent.com/kvnloo/t3code/media/openui-app-cards/maps/ferry-sfo-map.png\", bounds: [37.597565, -122.664055, 37.810018, -122.127003]})\npr = PullRequest(128, \"Add model picker presets\", \"conflict\", \"feat/model-picker\", \"main\", [{name: \"Install\", status: \"pass\", detail: \"\", started: 0, seconds: 11}, {name: \"Tests\", status: \"pass\", detail: \"412 passed\", started: 11, seconds: 38}, {name: \"Lint\", status: \"fail\", detail: \"src/picker.ts:14 'preset' is defined but never used\", started: 11, seconds: 9}, {name: \"Types\", status: \"running\", detail: \"\", started: 20, seconds: 24}], 2, [{title: \"Wire presets into the composer\", sha: \"a41f9c2\", author: \"kevin\", age: \"2h\", lane: \"head\", branch: \"feat/model-picker\"}, {title: \"Add preset tests\", sha: \"9b3e0d4\", author: \"kevin\", age: \"3h\", lane: \"head\"}, {title: \"Rename picker tokens\", sha: \"c90e1b7\", author: \"julius\", age: \"5h\", lane: \"base\", branch: \"main\", conflict: true}, {title: \"Bump effect\", sha: \"77d0aa1\", author: \"theo\", age: \"1d\", lane: \"base\"}])\n```\n\nThe Acme invoice is **$4,280, due Nov 15**. UberX is cheapest at $38.40 and the drive is 22 min via I-280. PR #128 can't merge yet: 2 conflicts with `main`, and Lint is failing."}}}}} +{"type":"emit_inbound","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}} diff --git a/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx b/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx index 1d9efd7abb7c6..177af7d019701 100644 --- a/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx @@ -5,11 +5,11 @@ import { Button } from "../ui/button"; const Report = lazy(() => import("./OpenUiReport").then((module) => ({ default: module.OpenUiReport }))); const Charts = lazy(() => import("./OpenUiCharts").then((module) => ({ default: module.OpenUiCharts }))); -/** The versioned fence stays in the normal transcript; v1 reports render on request, v2 boards once complete. */ +/** The versioned fence stays in the normal transcript; v1 reports render on request, v2 boards as they stream. */ export function MarkdownOpenUiCodeBlock({ code, version, isStreaming }: { code: string; version: 1 | 2; isStreaming: boolean }) { - // Chart boards are the answer itself, so they render once the fence completes unless the reader chose source. + // Chart boards are the answer itself, so they render card by card while streaming unless the reader chose source. const [choice, setChoice] = useState(null); - const showReport = choice ?? (version === 2 && !isStreaming); + const showReport = choice ?? version === 2; const Renderer = version === 2 ? Charts : Report; const noun = version === 2 ? "chart" : "report"; const source =
{code}
; @@ -24,7 +24,7 @@ export function MarkdownOpenUiCodeBlock({ code, version, isStreaming }: { code: {showReport ? (

{version === 2 ? "Chart" : "Report"} unavailable. Original source:

{source}}> Loading {noun} renderer…

}> - +
) : source} diff --git a/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx index 5604b7054964e..771bba80bf7a2 100644 --- a/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx +++ b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -159,4 +159,25 @@ describe("OpenUI app cards", () => { setAppearanceMode("light"); } }); + + it("fills a streaming board in card by card", async () => { + const lines = BOARD.split("\n"); + // Root, mail and half of the ride statement have arrived. + const partial = `${lines.slice(0, 2).join("\n")}\n${lines[2]!.slice(0, 40)}`; + const screen = await render( +
+ +
, + ); + await expect.element(screen.getByText("INV-2041.pdf", { exact: false })).toBeVisible(); + expect(screen.container.querySelectorAll("[data-app-card]")).toHaveLength(1); + await screen.rerender( +
+ +
, + ); + await vi.waitFor(() => + expect(screen.container.querySelectorAll("[data-app-card]")).toHaveLength(4), + ); + }); }); diff --git a/apps/web/src/components/chat/OpenUiAppCards.tsx b/apps/web/src/components/chat/OpenUiAppCards.tsx index c75095bca15d5..9cc862b56a15f 100644 --- a/apps/web/src/components/chat/OpenUiAppCards.tsx +++ b/apps/web/src/components/chat/OpenUiAppCards.tsx @@ -300,7 +300,10 @@ const RideOptionsCard = memo(function RideOptionsCard({ {`from ${props.pickup}`}

- + {`Prices from ${brand.name}`} diff --git a/apps/web/src/components/chat/OpenUiCharts.tsx b/apps/web/src/components/chat/OpenUiCharts.tsx index f7697b4e20af3..9d38a2d902153 100644 --- a/apps/web/src/components/chat/OpenUiCharts.tsx +++ b/apps/web/src/components/chat/OpenUiCharts.tsx @@ -23,6 +23,7 @@ import { shade, stackSeries, stackedReadout, + streamingChartBoard, tailPath, treemapReadout, } from "@t3tools/client-runtime/openui-charts"; @@ -255,9 +256,13 @@ export function inspectOpenUiChart(code: string) { } } -export function OpenUiCharts({ code }: { code: string }) { - const status = useMemo(() => inspectOpenUiChart(code), [code]); - if (status.kind !== "ready") return

{status.reason}

; +export function OpenUiCharts({ code, isStreaming = false }: { code: string; isStreaming?: boolean }) { + const shown = isStreaming ? streamingChartBoard(code) : code; + const status = shown === null ? null : inspectOpenUiChart(shown); + if (status === null || status.kind !== "ready") { + const reason = isStreaming || status === null ? "Receiving chart board…" : status.reason; + return

{reason}

; + } // No tool provider, actions or state callbacks: generated content has no action authority. - return ; + return ; } diff --git a/apps/web/test/appCardsFilm.renders.tsx b/apps/web/test/appCardsFilm.renders.tsx new file mode 100644 index 0000000000000..bba1340d69149 --- /dev/null +++ b/apps/web/test/appCardsFilm.renders.tsx @@ -0,0 +1,393 @@ +// Shoots the app cards film in the real app (AppRoot on fakeEnvironment.ts) with demo data, one +// PNG per frame, every animation pinned to a virtual clock. Skipped unless asked for: +// VITE_APP_CARDS_FILM= vp test run --project renders test/appCardsFilm.renders.tsx +import { createMemoryHistory } from "@tanstack/react-router"; +import type { RunId, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +import { AppRoot } from "~/AppRoot"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { setAppearanceMode } from "~/hooks/useTheme"; +import { getRouter } from "~/router"; +import ferrySfoMap from "./fixtures/ferry-sfo-map.png"; +import { + ENVIRONMENT_ID, + assistantItem, + emitThreadEvent, + emptyThread, + installFakeEnvironment, + isThreadSubscribed, + runningRun, + toolItem, + userItem, + type FakeDriver, +} from "./fakeEnvironment"; +import { act, fakeTimers, render } from "./render"; + +vi.mock("../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("./fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +const OUT = import.meta.env.VITE_APP_CARDS_FILM as string | undefined; +const FPS = Number(import.meta.env.VITE_APP_CARDS_FILM_FPS ?? 60); +// Resumes a killed run: frames before this one are replayed but not saved again. +const FROM = Number(import.meta.env.VITE_APP_CARDS_FILM_FROM ?? 0); +// Laid out at 1280x720 and drawn at 2x, so the frames stay sharp when scaled to 1080p. +const WIDTH = 1280; +const HEIGHT = 720; +const ZOOM = 2; + +const TRIP = emptyThread("thread-trip", "Thursday before SFO"); +const THREADS = [ + TRIP, + emptyThread("thread-picker", "Model picker presets"), + emptyThread("thread-session", "Refresh sessions in auth middleware"), + emptyThread("thread-flaky", "Fix flaky composer tests"), + emptyThread("thread-ws", "Trim websocket snapshot size"), +]; +installFakeEnvironment(THREADS, { projectTitle: "t3code", environmentLabel: "MacBook Pro" }); +__setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", +}); + +const PROMPT = + "Thursday I fly out of SFO. Find the Acme invoice I still owe, price a ride from the Ferry Building, show me the route, and tell me if PR #128 can merge before I leave."; + +// The real Ferry Building to SFO driving route (OSRM over OpenStreetMap), as the demo maps +// connector returns it, with the basemap it sends. +const FERRY_SFO = + "[[37.79376,-122.39407],[37.79324,-122.3934],[37.79251,-122.39433],[37.79246,-122.39228],[37.78788,-122.38787],[37.78263,-122.38804],[37.77976,-122.38962],[37.77737,-122.39212],[37.7759,-122.39525],[37.77356,-122.39816],[37.77129,-122.40078],[37.76716,-122.40196],[37.76224,-122.40219],[37.75564,-122.40312],[37.74932,-122.40347],[37.74476,-122.4039],[37.73961,-122.40442],[37.73529,-122.40594],[37.73186,-122.40592],[37.72818,-122.40382],[37.72402,-122.40047],[37.71913,-122.39819],[37.71246,-122.39591],[37.70594,-122.39391],[37.69766,-122.39276],[37.68965,-122.39327],[37.6828,-122.39578],[37.67455,-122.3984],[37.66447,-122.40065],[37.65586,-122.40054],[37.64764,-122.40214],[37.64144,-122.40354],[37.63487,-122.40011],[37.62957,-122.39552],[37.62291,-122.39129],[37.6165,-122.383]]"; +const MAP = `{image: "${new URL(ferrySfoMap, location.href).href}", bounds: [37.597565, -122.664055, 37.810018, -122.127003]}`; + +const PROGRAM = [ + 'root = Board("Thursday, before SFO", "From Gmail, Uber, Google Maps and GitHub. Demo data.", [mail, ride, route, pr])', + 'mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice INV-2041 for October", snippet: "Your invoice of $4,280.00 is due Nov 15. Pay by card or ACH from the billing portal.", time: "Oct 31", unread: true, attachment: "INV-2041.pdf"}, {from: "Dana Ruiz", subject: "Re: Updated pricing for Q4", snippet: "Thanks for confirming. I have updated the order form.", time: "Oct 24", unread: false}])', + 'ride = RideOptions("uber", "Ferry Building", "SFO Terminal 2", [{name: "UberX", minutes: 4, price: "$38.40", seats: 4, arrival: "6:42 PM"}, {name: "Comfort", minutes: 6, price: "$46.10", seats: 4, arrival: "6:45 PM"}, {name: "UberXL", minutes: 9, price: "$61.75", seats: 6, arrival: "6:49 PM"}])', + `route = Route("Ferry Building", "SFO Terminal 2", "drive", 22, "14.5 mi", ["Head south on Steuart St", "Turn left onto Mission St", "Turn right onto The Embarcadero", "Merge onto I-280 S", "Take US-101 S toward the airport", "Turn right onto Departures"], "I-280 S and US-101 S", ${FERRY_SFO}, ${MAP})`, + 'pr = PullRequest(128, "Add model picker presets", "conflict", "feat/model-picker", "main", [{name: "Install", status: "pass", detail: "", started: 0, seconds: 11}, {name: "Tests", status: "pass", detail: "412 passed", started: 11, seconds: 38}, {name: "Lint", status: "fail", detail: "src/picker.ts:14 \'preset\' is defined but never used", started: 11, seconds: 9}, {name: "Types", status: "running", detail: "", started: 20, seconds: 24}], 2, [{title: "Wire presets into the composer", sha: "a41f9c2", author: "kevin", age: "2h", lane: "head", branch: "feat/model-picker"}, {title: "Add preset tests", sha: "9b3e0d4", author: "kevin", age: "3h", lane: "head"}, {title: "Rename picker tokens", sha: "c90e1b7", author: "julius", age: "5h", lane: "base", branch: "main", conflict: true}, {title: "Bump effect", sha: "77d0aa1", author: "theo", age: "1d", lane: "base"}])', +].join("\n"); +const INTRO = "Here's Thursday at a glance, from your connected apps.\n\n```openui-v2\n"; +const OUTRO = + "\n```\n\nThe Acme invoice is **$4,280, due Nov 15**. UberX is cheapest at $38.40 and the drive is 22 min via I-280. PR #128 can't merge yet: 2 conflicts with `main`, and Lint is failing."; +const ANSWER = INTRO + PROGRAM + OUTRO; + +const TOOLS = [ + ["tool-gmail", "mcp__gmail__search_messages", { query: "invoices from Acme" }], + [ + "tool-uber", + "mcp__uber__get_price_estimates", + { pickup: "Ferry Building", dropoff: "SFO Terminal 2" }, + ], + [ + "tool-maps", + "mcp__google-maps__get_directions", + { origin: "Ferry Building", destination: "SFO Terminal 2", mode: "drive" }, + ], + ["tool-github", "mcp__github__get_pull_request", { number: 128 }], +] as const; + +const firstSeen = new WeakMap(); +/** Pauses every animation and transition and puts it where it would be at `t` seconds. */ +function pinAnimations(t: number) { + for (const animation of document.getAnimations()) { + if (!firstSeen.has(animation)) firstSeen.set(animation, t); + animation.pause(); + animation.currentTime = (t - firstSeen.get(animation)!) * 1000; + } +} + +const ease = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2); +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); + +// A cursor drawn over the app, since Playwright's pointer is invisible in screenshots. +const cursor = document.createElement("div"); +cursor.style.cssText = + "position:fixed;left:0;top:0;width:40px;height:40px;z-index:2147483647;pointer-events:none;transform:translate(-100px,-100px);filter:drop-shadow(0 1px 2px rgb(0 0 0/.35))"; +cursor.innerHTML = + ''; +let cursorAt: readonly [number, number] = [WIDTH * 2 * 0.62, HEIGHT * 2 * 0.9]; + +function centerOf(element: Element): readonly [number, number] { + const box = element.getBoundingClientRect(); + return [box.left + box.width / 2, box.top + box.height / 2]; +} + +function scroller(): HTMLElement { + const anchor = + document.querySelector('[aria-label="Thursday, before SFO"]') ?? document.querySelector("main"); + let node = anchor?.parentElement ?? null; + while ( + node && + !( + node.scrollHeight > node.clientHeight + 4 && + /auto|scroll/.test(getComputedStyle(node).overflowY) + ) + ) { + node = node.parentElement; + } + if (!node) throw new Error("no thread scroller"); + return node; +} + +it.skipIf(!OUT)( + "app cards film", + async () => { + await page.viewport(WIDTH * ZOOM, HEIGHT * ZOOM); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${TRIP.id}`] }), + ); + console.log("film: loading router"); + // Discovery sometimes stalls the first load; AppRoot finishes it once mounted. + await Promise.race([router.load(), new Promise((resolve) => setTimeout(resolve, 8000))]); + console.log("film: router loaded"); + const container = document.createElement("div"); + container.style.cssText = `height: ${HEIGHT}px; width: ${WIDTH}px; zoom: ${ZOOM};`; + // Full-height views size to the viewport, which is ZOOM times the laid-out height here. + const fit = document.createElement("style"); + fit.textContent = `.h-dvh, .h-svh { height: ${HEIGHT}px !important; } @media (min-width: 768px) { .md\\:h-dvh { height: ${HEIGHT}px !important; } }`; + document.head.append(fit); + await using _app = await render(, { container, router: false }); + document.body.append(cursor); + await vi.waitFor(() => expect(isThreadSubscribed(TRIP.id)).toBe(true), { timeout: 30_000 }); + await document.fonts.ready; + // Date is faked only once connected: discovery retries on a Date-based backoff. It starts + // just before the fixture runs' timestamps, so "Working for" counts from zero. + const start = new Date("2026-03-17T19:12:24.000Z").getTime(); + using _clock = fakeTimers({ toFake: ["Date"], now: start }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const run = runningRun(TRIP.id, "trip-run", 1, "trip-user"); + const runId = run.id as RunId; + const editor = page.getByTestId("composer-editor"); + let frame = 0; + let typed = 0; + let streamed = 0; + const toolsStarted = new Set(); + const toolsDone = new Set(); + let sent = false; + let finished = false; + let scrollFrom: number | null = null; + const done = new Set(); + const once = async (key: string, at: number, t: number, action: () => Promise) => { + if (t >= at && !done.has(key)) { + done.add(key); + await action(); + } + }; + const emit = (event: Parameters[1]) => + act(() => emitThreadEvent(TRIP.id as ThreadId, event)); + + /** Glides the cursor to `target` over [from, to] seconds. */ + const glide = ( + t: number, + from: number, + to: number, + start: readonly [number, number], + target: readonly [number, number], + ) => { + if (t < from || t > to + 1 / FPS) return; + const k = ease(clamp01((t - from) / (to - from))); + cursorAt = [start[0] + (target[0] - start[0]) * k, start[1] + (target[1] - start[1]) * k]; + }; + const targets = new Map(); + const aim = (key: string, find: () => Element | null) => { + if (!targets.has(key)) { + const element = find(); + if (element) targets.set(key, centerOf(element)); + } + return targets.get(key); + }; + const scrollTo = (t: number, from: number, to: number, target: () => number) => { + if (t < from || t > to + 1 / FPS) return; + const node = scroller(); + if (scrollFrom === null || t - from < 1 / FPS / 2) scrollFrom = node.scrollTop; + node.scrollTop = + scrollFrom + (target() - scrollFrom) * ease(clamp01((t - from) / (to - from))); + if (t >= to) scrollFrom = null; + }; + const cardTop = (label: RegExp) => { + const node = scroller(); + const card = [...document.querySelectorAll("[data-app-card]")].find((element) => + label.test(element.getAttribute("aria-label") ?? ""), + ); + if (!card) return node.scrollTop; + return ( + node.scrollTop + + (card.getBoundingClientRect().top - node.getBoundingClientRect().top) / ZOOM - + 24 + ); + }; + + const DURATION = 46; + for (let t = 0; t <= DURATION; t += 1 / FPS, frame++) { + vi.setSystemTime(start + t * 1000); + // 0.6-4.6 s: type the prompt into the real composer. + await once("focus", 0.3, t, () => userEvent.click(editor)); + if (t >= 0.6 && t < 4.6) { + const want = Math.round(PROMPT.length * ease(clamp01((t - 0.6) / 3.8))); + if (want > typed) { + await userEvent.keyboard(PROMPT.slice(typed, want).replace(/[{[]/g, (c) => c + c)); + typed = want; + } + } + glide( + t, + 4.6, + 5.2, + cursorAt, + aim("send", () => + document.querySelector('button[aria-label^="Send"], button[type="submit"]'), + ) ?? cursorAt, + ); + // 5.3 s: send. The fake server never answers a send, so the composer is cleared by hand + // and the turn arrives as the server would deliver it. + if (t >= 5.3 && !sent) { + sent = true; + await userEvent.keyboard("{Control>}a{/Control}{Backspace}"); + await emit({ type: "run.created", payload: run }); + await emit({ + type: "turn-item.updated", + payload: userItem(TRIP.id, runId, "trip-user", 0, PROMPT), + }); + } + // 6.0-9.6 s: four connector calls, overlapping. + for (const [index, [id, name, input]] of TOOLS.entries()) { + const start = 6.0 + index * 0.45; + const end = start + 1.5 + index * 0.25; + if (t >= start && !toolsStarted.has(id)) { + toolsStarted.add(id); + await emit({ + type: "turn-item.updated", + payload: toolItem(TRIP.id, runId, id, 1 + index, name, input, true), + }); + } + if (t >= end && !toolsDone.has(id)) { + toolsDone.add(id); + await emit({ + type: "turn-item.updated", + payload: toolItem(TRIP.id, runId, id, 1 + index, name, input, false), + }); + } + } + // 10-19 s: the answer streams; the board fills in card by card. + if (t >= 10 && streamed < ANSWER.length) { + const want = Math.min(ANSWER.length, Math.round((t - 10) * 520)); + if (want > streamed) { + streamed = want; + await emit({ + type: "turn-item.updated", + payload: assistantItem( + TRIP.id, + runId, + "trip-answer", + 5, + ANSWER.slice(0, streamed), + streamed < ANSWER.length, + ), + }); + } + } + if (streamed >= ANSWER.length && !finished) { + finished = true; + await emit({ + type: "run.updated", + payload: { ...run, status: "completed", completedAt: run.startedAt }, + }); + } + // 20-46 s: read the board, tapping as a person would. + scrollTo(t, 19.6, 21.0, () => cardTop(/^Gmail results/)); + glide( + t, + 21.2, + 22.0, + cursorAt, + aim( + "mail", + () => [...document.querySelectorAll("[data-app-card=gmail] button")].at(-1) ?? null, + ) ?? cursorAt, + ); + await once("mail", 22.1, t, () => + userEvent.click(page.getByRole("button", { name: /^Dana Ruiz/ })), + ); + scrollTo(t, 23.2, 24.6, () => cardTop(/ride options/)); + glide( + t, + 24.8, + 25.6, + cursorAt, + aim("comfort", () => + document.querySelector('[data-app-card=uber] button[aria-label^="Comfort"]'), + ) ?? cursorAt, + ); + await once("comfort", 25.7, t, () => + userEvent.click(page.getByRole("button", { name: /^Comfort/ })), + ); + scrollTo(t, 27.0, 28.4, () => cardTop(/^Directions/)); + for (const [n, at] of [ + [0, 29.6], + [1, 30.6], + [2, 31.6], + ] as const) { + glide( + t, + at - 0.9, + at - 0.1, + cursorAt, + aim("next", () => document.querySelector('[aria-label="Next step"]')) ?? cursorAt, + ); + await once(`next${n}`, at, t, () => + userEvent.click(page.getByRole("button", { name: "Next step" })), + ); + } + scrollTo(t, 32.6, 34.0, () => cardTop(/^Pull request/)); + glide( + t, + 34.2, + 35.0, + cursorAt, + aim( + "tests", + () => + [...document.querySelectorAll("[data-app-card=github] button")].find((b) => + b.textContent?.startsWith("Tests"), + ) ?? null, + ) ?? cursorAt, + ); + await once("tests", 35.1, t, () => + userEvent.click(page.getByRole("button", { name: /^Tests/ })), + ); + // 37.5 s: dark mode, the way Settings or the theme shortcut switches it. + await once("dark", 37.5, t, async () => { + await act(() => void setAppearanceMode("dark")); + }); + scrollTo(t, 39.0, 41.5, () => cardTop(/^Gmail results/) - 40); + scrollTo(t, 42.0, 45.0, () => cardTop(/^Pull request/)); + + // Let React commit, then pin animations to this frame's time and save it. + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + cursor.style.transform = `translate(${cursorAt[0] - 3}px, ${cursorAt[1] - 2}px)`; + pinAnimations(t); + if (frame % 20 === 0) console.log(`film: frame ${frame} t=${t.toFixed(2)}`); + if (frame >= FROM) + await page.screenshot({ path: `${OUT}/${String(frame).padStart(5, "0")}.png`, save: true }); + } + expect(finished).toBe(true); + setAppearanceMode("light"); + }, + 3_600_000, +); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 26e2ee9fa33a3..0faa84566887c 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -101,7 +101,7 @@ const serverConfig: ServerConfig = { keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }; -const project: OrchestrationProjectShell = { +let project: OrchestrationProjectShell = { id: PROJECT_ID, title: "render", workspaceRoot: "/workspace/render", @@ -408,8 +408,16 @@ const encodedDescriptor = Schema.encodeSync(ExecutionEnvironmentDescriptor)( ); /** Serves the environment's HTTP API and these threads until disposed. */ -export function installFakeEnvironment(fixtures: ReadonlyArray): Disposable { +export function installFakeEnvironment( + fixtures: ReadonlyArray, + { + projectTitle, + environmentLabel, + }: { readonly projectTitle?: string; readonly environmentLabel?: string } = {}, +): Disposable { threads = fixtures; + if (projectTitle) project = { ...project, title: projectTitle }; + if (environmentLabel) descriptor.label = environmentLabel; globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); if (url.origin !== window.location.origin) return realFetch(input, init); @@ -459,6 +467,30 @@ export async function emitThreadEvent( ); } +/** A tool call from an MCP server, as providers report it (`mcp__server__tool`). */ +export function toolItem( + threadId: ThreadId, + runId: RunId, + id: string, + ordinal: number, + toolName: string, + input: unknown, + running: boolean, +) { + return { + ...base(threadId, runId, id, ordinal), + type: "dynamic_tool", + toolName, + input, + ...(running ? { status: "running" as const, completedAt: null } : {}), + } satisfies OrchestrationV2TurnItem; +} + +/** An empty thread: the user has not sent anything yet. */ +export function emptyThread(name: string, title: string): FakeThread { + return { id: ThreadId.make(name), title, runs: [], items: [] }; +} + export function runningRun(threadId: ThreadId, id: string, ordinal: number, userMessageId: string) { return { ...run(threadId, RunId.make(id), ordinal, userMessageId), diff --git a/docs/operations/development.md b/docs/operations/development.md index 3307dbc22b969..3acba2a89bc73 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -86,6 +86,12 @@ To add a scenario, point a `SCENARIOS` entry at a generic ACP replay fixture The agent replays session updates, permission requests, and questions. It drops session setup and vendor frames, so Grok and fx recordings lose their vendor-only behavior. +The `App cards` scenario plays four demo connector calls (Gmail, Uber, Google Maps, GitHub) and an +OpenUI board of app cards, which is the quickest way to check the cards on web, desktop and a paired +phone. The same turn, shot frame by frame inside the app, is the app cards film: +`VITE_APP_CARDS_FILM= vp test run --project renders test/appCardsFilm.renders.tsx` +in `apps/web`. + ### Sharing and remote debugging `vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL diff --git a/docs/user/openui-reports.md b/docs/user/openui-reports.md index c939e69243ead..abc8f36a04196 100644 --- a/docs/user/openui-reports.md +++ b/docs/user/openui-reports.md @@ -46,8 +46,9 @@ appearance: request stands, such as "This branch has 2 conflicts that must be resolved". A failing check opens with its detail; tap any check to see its detail. -The Android experiment shows the same cards natively, with the same themes, -commit graph, route and app links. +On web and desktop a board fills in card by card while the agent is still +writing it. The Android experiment shows the same cards natively, with the same +themes, commit graph, route and app links, once the board is complete. Charts and cards keep their selections locally; tapping never sends a message, books, sends or merges anything. Links open the app itself, where any action diff --git a/packages/client-runtime/src/openUiAppCards.test.ts b/packages/client-runtime/src/openUiAppCards.test.ts index bde74e817a995..f9a4f10f9b5fb 100644 --- a/packages/client-runtime/src/openUiAppCards.test.ts +++ b/packages/client-runtime/src/openUiAppCards.test.ts @@ -12,7 +12,7 @@ import { rideReadout, type PullRequestCheck, } from "./openUiAppCards.ts"; -import { chartPanelProblem } from "./openUiCharts.ts"; +import { chartPanelProblem, streamingChartBoard } from "./openUiCharts.ts"; const options = [ { name: "UberX", minutes: 4, price: "$31.20", seats: 4 }, @@ -90,3 +90,20 @@ test("checks with timing become gantt spans; any untimed check falls back", () = NodeAssert.equal(formatSeconds(64), "1m 4s"); NodeAssert.equal(formatSeconds(38), "38s"); }); + +test("a streaming board shows the panels whose statements are complete", () => { + const root = 'root = Board("Trip", "Demo.", [mail, ride, route])\n'; + const mail = 'mail = MailList("q", [])\n'; + NodeAssert.equal(streamingChartBoard(root), null); + NodeAssert.equal( + streamingChartBoard(root + mail + 'ride = RideOptions("ub'), + root.replace("[mail, ride, route]", "[mail]") + mail.trimEnd(), + ); + NodeAssert.equal(streamingChartBoard('root = Board("Trip", "Demo.", [mail'), null); + NodeAssert.equal(streamingChartBoard(mail), null); + // An open fence arrives with a newline after the partial line. + NodeAssert.equal( + streamingChartBoard(`${root}${mail}ride = Ride\n`), + root.replace("[mail, ride, route]", "[mail]") + mail.trimEnd(), + ); +}); diff --git a/packages/client-runtime/src/openUiCharts.ts b/packages/client-runtime/src/openUiCharts.ts index e34fba8bd33cf..f690f0a7281dd 100644 --- a/packages/client-runtime/src/openUiCharts.ts +++ b/packages/client-runtime/src/openUiCharts.ts @@ -76,6 +76,25 @@ export type OpenUiChartStatus = const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; /** Decide whether a parsed board may render: read-only, complete, schema-valid and coherent. */ +/** + * What a streaming board can show so far: its complete statements, with the root's panel list cut + * to the panels already defined, so cards appear one by one. Null until the root and one panel + * are complete. Only for a fence that is still streaming; a finished fence is shown whole or not. + */ +export function streamingChartBoard(code: string): string | null { + // The last line is still arriving; Markdown hands an open fence over with a newline appended. + const body = code.replace(/\n$/, ""); + const lines = body.slice(0, body.lastIndexOf("\n") + 1).split("\n").filter((line) => line.trim()); + const defined = new Set(lines.map((line) => /^\s*([A-Za-z_]\w*)\s*=/.exec(line)?.[1])); + const rootIndex = lines.findIndex((line) => /^\s*root\s*=/.test(line)); + const panels = rootIndex < 0 ? null : /\[([^[\]]*)\]\)\s*$/.exec(lines[rootIndex]!); + if (!panels) return null; + const kept = panels[1]!.split(",").map((name) => name.trim()).filter((name) => defined.has(name)); + if (kept.length === 0) return null; + lines[rootIndex] = `${lines[rootIndex]!.slice(0, panels.index)}[${kept.join(", ")}])`; + return lines.join("\n"); +} + export function inspectParsedChartBoard( parsed: ParsedChartProgram, panelSchemas: Readonly>, From aa0284ae0a87756906dea2b7bdd9253bb3a88fb8 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:45:44 -0600 Subject: [PATCH 11/11] test(web): preview the app cards in other harnesses' tokens - test/harnessKits.renders.tsx mounts the demo board inside each kit's colours, fonts and radii (mapped onto T3's semantic variables) at 412 and 760 px, light and dark, and fails if a phone-width thread scrolls sideways. Kits and their provenance live outside the repo. - The demo board moves to test/fixtures/appCardsBoard.ts so the browser test and the preview share it. - The app cards film starts its minute clock at the fixtures' time, so the sidebar no longer reads "230d". --- .../chat/OpenUiAppCards.browser.test.tsx | 49 +------- apps/web/test/appCardsFilm.renders.tsx | 25 +++-- apps/web/test/fixtures/appCardsBoard.ts | 51 +++++++++ apps/web/test/harnessKits.renders.tsx | 105 ++++++++++++++++++ 4 files changed, 173 insertions(+), 57 deletions(-) create mode 100644 apps/web/test/fixtures/appCardsBoard.ts create mode 100644 apps/web/test/harnessKits.renders.tsx diff --git a/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx index 771bba80bf7a2..06f69be0921df 100644 --- a/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx +++ b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx @@ -2,59 +2,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import ferrySfoMap from "../../../test/fixtures/ferry-sfo-map.png"; +import { APP_CARDS_BOARD as BOARD } from "../../../test/fixtures/appCardsBoard"; import { setAppearanceMode } from "~/hooks/useTheme"; import { render } from "../../../test/render"; import { OpenUiCharts } from "./OpenUiCharts"; // The real Ferry Building to SFO driving route (OSRM over OpenStreetMap), as a maps tool returns it. -const FERRY_SFO = JSON.stringify([ - [37.79384, -122.39395], - [37.79325, -122.39321], - [37.79372, -122.39261], - [37.79271, -122.39147], - [37.79115, -122.39061], - [37.78924, -122.38841], - [37.78778, -122.38799], - [37.78085, -122.38865], - [37.7737, -122.39757], - [37.77275, -122.3981], - [37.76886, -122.39738], - [37.76591, -122.39412], - [37.76399, -122.393], - [37.75164, -122.39166], - [37.74557, -122.39467], - [37.73888, -122.40064], - [37.73572, -122.40444], - [37.73383, -122.40507], - [37.71634, -122.39893], - [37.7114, -122.39581], - [37.67773, -122.38851], - [37.6742, -122.38972], - [37.67028, -122.39245], - [37.66498, -122.39762], - [37.6597, -122.40477], - [37.65578, -122.4068], - [37.65292, -122.40712], - [37.64071, -122.40597], - [37.6356, -122.40409], - [37.62656, -122.40252], - [37.61442, -122.39796], - [37.6138, -122.39644], - [37.61536, -122.38856], - [37.61475, -122.38581], - [37.6151, -122.38486], - [37.6165, -122.38392], -]); - -// One board with every app card, filled the way a connector turn would fill it. -const BOARD = [ - 'root = Board("Your apps", "Demo data.", [mail, ride, route, pr])', - 'mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice INV-2041 for October", snippet: "Your invoice of $4,280.00 is due Nov 15.", time: "Oct 31", unread: true, attachment: "INV-2041.pdf"}, {from: "Dana Ruiz", subject: "Re: Updated pricing for Q4", snippet: "Thanks for confirming.", time: "Oct 24", unread: false}])', - 'ride = RideOptions("uber", "Ferry Building", "SFO", [{name: "UberX", minutes: 4, price: "$38.40", seats: 4, arrival: "6:42 PM"}, {name: "Comfort", minutes: 6, price: "$46.10", seats: 4}, {name: "UberXL", minutes: 9, price: "$61.75", seats: 6}])', - `route = Route("Ferry Building", "SFO Terminal 2", "drive", 22, "14.5 mi", ["Head south on Steuart St", "Turn left onto Mission St", "Turn right onto The Embarcadero"], "I-280 S and US-101 S", ${FERRY_SFO}, {image: "${new URL(ferrySfoMap, location.href).href}", bounds: [37.597565, -122.664055, 37.810018, -122.127003]})`, - 'pr = PullRequest(128, "Add model picker presets", "conflict", "feat/model-picker", "main", [{name: "Tests", status: "pass", detail: "412 passed", started: 11, seconds: 38}, {name: "Lint", status: "fail", detail: "src/picker.ts:14 no-unused-vars", started: 11, seconds: 9}, {name: "Types", status: "running", detail: "", started: 20, seconds: 24}], 2, [{title: "Wire presets into the composer", sha: "a41f9c2", author: "kevin", age: "2h", lane: "head", branch: "feat/model-picker"}, {title: "Rename picker tokens", sha: "c90e1b7", author: "julius", age: "5h", lane: "base", branch: "main", conflict: true}, {title: "Bump effect", sha: "77d0aa1", author: "theo", age: "1d", lane: "base"}])', -].join("\n"); const shot = import.meta.env.VITE_APP_CARDS_SHOT; // Entry transitions and the basemap finish before a screenshot. diff --git a/apps/web/test/appCardsFilm.renders.tsx b/apps/web/test/appCardsFilm.renders.tsx index bba1340d69149..b309ad17856c8 100644 --- a/apps/web/test/appCardsFilm.renders.tsx +++ b/apps/web/test/appCardsFilm.renders.tsx @@ -46,10 +46,10 @@ const OUT = import.meta.env.VITE_APP_CARDS_FILM as string | undefined; const FPS = Number(import.meta.env.VITE_APP_CARDS_FILM_FPS ?? 60); // Resumes a killed run: frames before this one are replayed but not saved again. const FROM = Number(import.meta.env.VITE_APP_CARDS_FILM_FROM ?? 0); -// Laid out at 1280x720 and drawn at 2x, so the frames stay sharp when scaled to 1080p. +// Laid out at 1280x720 and drawn at 1.5x: 1080p frames with laptop-sized type. const WIDTH = 1280; const HEIGHT = 720; -const ZOOM = 2; +const ZOOM = 1.5; const TRIP = emptyThread("thread-trip", "Thursday before SFO"); const THREADS = [ @@ -117,10 +117,10 @@ const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); // A cursor drawn over the app, since Playwright's pointer is invisible in screenshots. const cursor = document.createElement("div"); cursor.style.cssText = - "position:fixed;left:0;top:0;width:40px;height:40px;z-index:2147483647;pointer-events:none;transform:translate(-100px,-100px);filter:drop-shadow(0 1px 2px rgb(0 0 0/.35))"; + "position:fixed;left:0;top:0;width:30px;height:30px;z-index:2147483647;pointer-events:none;transform:translate(-100px,-100px);filter:drop-shadow(0 1px 2px rgb(0 0 0/.35))"; cursor.innerHTML = - ''; -let cursorAt: readonly [number, number] = [WIDTH * 2 * 0.62, HEIGHT * 2 * 0.9]; + ''; +let cursorAt: readonly [number, number] = [WIDTH * ZOOM * 0.62, HEIGHT * ZOOM * 0.9]; function centerOf(element: Element): readonly [number, number] { const box = element.getBoundingClientRect(); @@ -148,6 +148,10 @@ it.skipIf(!OUT)( "app cards film", async () => { await page.viewport(WIDTH * ZOOM, HEIGHT * ZOOM); + const start = new Date("2026-03-17T19:12:24.000Z").getTime(); + // Until connected, Date runs at real speed from just before the fixtures' timestamps, so the + // minute clock behind sidebar ages ("2m") starts there and discovery's backoff still elapses. + vi.useFakeTimers({ toFake: ["Date"], now: start - 90_000, shouldAdvanceTime: true }); const router = getRouter( createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${TRIP.id}`] }), ); @@ -165,9 +169,9 @@ it.skipIf(!OUT)( document.body.append(cursor); await vi.waitFor(() => expect(isThreadSubscribed(TRIP.id)).toBe(true), { timeout: 30_000 }); await document.fonts.ready; - // Date is faked only once connected: discovery retries on a Date-based backoff. It starts - // just before the fixture runs' timestamps, so "Working for" counts from zero. - const start = new Date("2026-03-17T19:12:24.000Z").getTime(); + // From here Date only moves with the virtual clock. It starts just before the fixture runs' + // timestamps, so "Working for" counts from zero. + vi.useRealTimers(); using _clock = fakeTimers({ toFake: ["Date"], now: start }); await new Promise((resolve) => setTimeout(resolve, 1500)); @@ -379,7 +383,10 @@ it.skipIf(!OUT)( scrollTo(t, 42.0, 45.0, () => cardTop(/^Pull request/)); // Let React commit, then pin animations to this frame's time and save it. - await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + // Frames being replayed only need React to commit, not a paint. + await new Promise((resolve) => + frame >= FROM ? requestAnimationFrame(() => resolve(null)) : setTimeout(resolve, 0), + ); cursor.style.transform = `translate(${cursorAt[0] - 3}px, ${cursorAt[1] - 2}px)`; pinAnimations(t); if (frame % 20 === 0) console.log(`film: frame ${frame} t=${t.toFixed(2)}`); diff --git a/apps/web/test/fixtures/appCardsBoard.ts b/apps/web/test/fixtures/appCardsBoard.ts new file mode 100644 index 0000000000000..f23b286f5d0de --- /dev/null +++ b/apps/web/test/fixtures/appCardsBoard.ts @@ -0,0 +1,51 @@ +// The demo app-card board: the real Ferry Building to SFO route (OSRM over OpenStreetMap) and its +// basemap, the way the demo connectors return them. Shared by the browser test and preview renders. +import ferrySfoMap from "./ferry-sfo-map.png"; + +export const FERRY_SFO = JSON.stringify([ + [37.79384, -122.39395], + [37.79325, -122.39321], + [37.79372, -122.39261], + [37.79271, -122.39147], + [37.79115, -122.39061], + [37.78924, -122.38841], + [37.78778, -122.38799], + [37.78085, -122.38865], + [37.7737, -122.39757], + [37.77275, -122.3981], + [37.76886, -122.39738], + [37.76591, -122.39412], + [37.76399, -122.393], + [37.75164, -122.39166], + [37.74557, -122.39467], + [37.73888, -122.40064], + [37.73572, -122.40444], + [37.73383, -122.40507], + [37.71634, -122.39893], + [37.7114, -122.39581], + [37.67773, -122.38851], + [37.6742, -122.38972], + [37.67028, -122.39245], + [37.66498, -122.39762], + [37.6597, -122.40477], + [37.65578, -122.4068], + [37.65292, -122.40712], + [37.64071, -122.40597], + [37.6356, -122.40409], + [37.62656, -122.40252], + [37.61442, -122.39796], + [37.6138, -122.39644], + [37.61536, -122.38856], + [37.61475, -122.38581], + [37.6151, -122.38486], + [37.6165, -122.38392], +]); + +// One board with every app card, filled the way a connector turn would fill it. +export const APP_CARDS_BOARD = [ + 'root = Board("Your apps", "Demo data.", [mail, ride, route, pr])', + 'mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice INV-2041 for October", snippet: "Your invoice of $4,280.00 is due Nov 15.", time: "Oct 31", unread: true, attachment: "INV-2041.pdf"}, {from: "Dana Ruiz", subject: "Re: Updated pricing for Q4", snippet: "Thanks for confirming.", time: "Oct 24", unread: false}])', + 'ride = RideOptions("uber", "Ferry Building", "SFO", [{name: "UberX", minutes: 4, price: "$38.40", seats: 4, arrival: "6:42 PM"}, {name: "Comfort", minutes: 6, price: "$46.10", seats: 4}, {name: "UberXL", minutes: 9, price: "$61.75", seats: 6}])', + `route = Route("Ferry Building", "SFO Terminal 2", "drive", 22, "14.5 mi", ["Head south on Steuart St", "Turn left onto Mission St", "Turn right onto The Embarcadero"], "I-280 S and US-101 S", ${FERRY_SFO}, {image: "${new URL(ferrySfoMap, location.href).href}", bounds: [37.597565, -122.664055, 37.810018, -122.127003]})`, + 'pr = PullRequest(128, "Add model picker presets", "conflict", "feat/model-picker", "main", [{name: "Tests", status: "pass", detail: "412 passed", started: 11, seconds: 38}, {name: "Lint", status: "fail", detail: "src/picker.ts:14 no-unused-vars", started: 11, seconds: 9}, {name: "Types", status: "running", detail: "", started: 20, seconds: 24}], 2, [{title: "Wire presets into the composer", sha: "a41f9c2", author: "kevin", age: "2h", lane: "head", branch: "feat/model-picker"}, {title: "Rename picker tokens", sha: "c90e1b7", author: "julius", age: "5h", lane: "base", branch: "main", conflict: true}, {title: "Bump effect", sha: "77d0aa1", author: "theo", age: "1d", lane: "base"}])', +].join("\n"); diff --git a/apps/web/test/harnessKits.renders.tsx b/apps/web/test/harnessKits.renders.tsx new file mode 100644 index 0000000000000..34bc36236fa7b --- /dev/null +++ b/apps/web/test/harnessKits.renders.tsx @@ -0,0 +1,105 @@ +// App cards in other harnesses' tokens: each kit is mapped onto T3 Code's semantic variables around a +// real board; every card keeps its own app's design system. Skipped unless asked for: +// VITE_HARNESS_KITS="$(cat kits.json)" VITE_HARNESS_OUT= \ +// vp test run --project renders test/harnessKits.renders.tsx +import { expect, it } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import "~/index.css"; +import { OpenUiCharts } from "~/components/chat/OpenUiCharts"; +import { setAppearanceMode } from "~/hooks/useTheme"; +import { APP_CARDS_BOARD } from "./fixtures/appCardsBoard"; +import { act, render } from "./render"; + +interface Kit { + readonly id: string; + readonly name: string; + readonly source: string; + readonly fonts: { readonly ui?: string | null; readonly mono?: string | null }; + readonly radius: { readonly card?: string | null; readonly bubble?: string | null }; + readonly light: Record; + readonly dark: Record; +} + +const KITS: readonly Kit[] = JSON.parse(import.meta.env.VITE_HARNESS_KITS ?? "[]"); +const OUT = import.meta.env.VITE_HARNESS_OUT; +const PROMPT = + "Find the Acme invoice, price a ride from the Ferry Building to SFO, and check PR #128."; + +// Kit role -> the T3 Code variables that draw that role. +const ROLE_VARS: Record = { + canvas: ["--background"], + surface: ["--card", "--popover", "--surface-raised"], + text: ["--foreground", "--card-foreground", "--popover-foreground"], + muted: ["--muted-foreground"], + border: ["--border", "--input"], + accent: ["--primary", "--ring"], + userBubble: ["--message-surface"], + userText: ["--message-foreground"], + codeBg: ["--code-background"], +}; + +function kitStyle(kit: Kit, scheme: "light" | "dark") { + const vars: Record = {}; + for (const [role, names] of Object.entries(ROLE_VARS)) { + const value = kit[scheme][role]; + if (value) for (const name of names) vars[name] = value; + } + if (kit.fonts.ui) vars["--font-sans"] = kit.fonts.ui; + if (kit.fonts.mono) vars["--font-mono"] = kit.fonts.mono; + if (kit.radius.card) vars["--radius"] = kit.radius.card; + return vars; +} + +function Shell(props: { kit: Kit; scheme: "light" | "dark"; width: number }) { + const { kit, scheme, width } = props; + return ( +
+
+ {`${kit.name} tokens · ${scheme} · preview, not the real ${kit.name} UI`} +
+
+ {PROMPT} +
+
Here's Thursday at a glance, from your connected apps.
+ +
+ ); +} + +const settle = async () => { + await Promise.all(document.getAnimations().map((animation) => animation.finished)); + await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined))); +}; + +it.skipIf(!OUT || KITS.length === 0)( + "renders the app cards in every harness kit", + async () => { + for (const scheme of ["light", "dark"] as const) { + await act(() => void setAppearanceMode(scheme)); + for (const width of [412, 760]) { + await page.viewport(width + 40, 2600); + for (const kit of KITS) { + await using _shell = await render(); + await expect.element(page.getByText("INV-2041.pdf", { exact: false })).toBeVisible(); + await settle(); + const shell = document.querySelector(`[data-harness="${kit.id}"]`)!; + // Nothing in a phone-width thread may scroll sideways. + expect(shell.scrollWidth).toBeLessThanOrEqual(width + 1); + await page + .elementLocator(shell) + .screenshot({ path: `${OUT}/${kit.id}-${scheme}-${width}.png`, save: true }); + } + } + } + await act(() => void setAppearanceMode("light")); + }, + 600_000, +);