diff --git a/FORK.md b/FORK.md index 7afaf0edf0d..1186d5a9099 100644 --- a/FORK.md +++ b/FORK.md @@ -40,6 +40,10 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Provider sessions use a shared launch environment pipeline instead of ad hoc environment assembly. +### Preview Iframes + +- Preview annotations and MCP automation include nested iframes in the same browser surface. + ### Base Path And Remote URLs - The fork includes base-path handling for served web assets and remote URL normalization. diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index 5b6b73c8ba7..22277b5a85b 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a476..b2a4cbc1bd9 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -2,5 +2,6 @@ export const START_PICK_CHANNEL = "preview:start-pick"; export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +export const ANNOTATION_SELECTION_CLAIMED_CHANNEL = "preview:annotation-selection-claimed"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d0360ef72c..0558fa14b92 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,6 +5,8 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import * as NodeCrypto from "node:crypto"; + import type { DesktopPreviewAnnotationTheme, DesktopPreviewPointerEvent, @@ -34,6 +36,7 @@ import { nativeImage, shell, webContents, + webFrameMain, } from "electron"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -55,6 +58,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_SELECTION_CLAIMED_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, @@ -99,6 +103,7 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const MAX_PREVIEW_TITLE_LENGTH = 512; const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; @@ -124,6 +129,28 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { fontMono: "ui-monospace, monospace", }; +const sendToPreviewFrames = ( + wc: Electron.WebContents, + channel: string, + ...args: ReadonlyArray +): void => { + for (const frame of wc.mainFrame.framesInSubtree) { + if (!frame.isDestroyed()) frame.send(channel, ...args); + } +}; + +const startPickInPreviewFrames = ( + wc: Electron.WebContents, + theme: DesktopPreviewAnnotationTheme, + sessionId: string, +): void => { + for (const frame of wc.mainFrame.framesInSubtree) { + if (!frame.isDestroyed()) { + frame.send(START_PICK_CHANNEL, theme, sessionId, String(frame.frameTreeNodeId)); + } + } +}; + const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); @@ -296,6 +323,7 @@ interface ManagedListeners { interface PickSession { readonly cancel: Effect.Effect; + readonly sessionId: string; } interface BrowserControlSession { @@ -997,6 +1025,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); + const previewFrames = ( + wc: Electron.WebContents, + frameIndex?: number, + ): ReadonlyArray<{ readonly frame: Electron.WebFrameMain; readonly frameIndex: number }> => { + const frames = wc.mainFrame.framesInSubtree + .map((frame, index) => ({ frame, frameIndex: index })) + .filter(({ frame }) => !frame.isDestroyed()); + return frameIndex === undefined + ? frames + : frames.filter((candidate) => candidate.frameIndex === frameIndex); + }; + + const evaluateInFrame = Effect.fn("PreviewManager.evaluateInFrame")(function* ( + tabId: string, + wc: Electron.WebContents, + frame: Electron.WebFrameMain, + operation: string, + expression: string, + ) { + return (yield* attemptPromise({ operation, tabId, webContentsId: wc.id }, () => + frame.executeJavaScript(expression, true), + )) as A; + }); + const automationLocator = (input: { readonly selector?: string | undefined; readonly locator?: string | undefined; @@ -1042,6 +1094,99 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* evaluateWithDebugger(tabId, send, expression, true); }); + const ensurePlaywrightInjectedInFrame = Effect.fn( + "PreviewManager.ensurePlaywrightInjectedInFrame", + )(function* (tabId: string, wc: Electron.WebContents, frame: Electron.WebFrameMain) { + const installed = yield* evaluateInFrame( + tabId, + wc, + frame, + "ensurePlaywrightInjectedInFrame.check", + "Boolean(globalThis.__t3PlaywrightInjected)", + ); + if (installed) return; + const expression = yield* playwrightInstallExpression.pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "ensurePlaywrightInjectedInFrame", + tabId, + webContentsId: wc.id, + cause, + }), + ), + ); + yield* evaluateInFrame(tabId, wc, frame, "ensurePlaywrightInjectedInFrame.install", expression); + }); + + const requireAutomationFrame = Effect.fn("PreviewManager.requireAutomationFrame")(function* ( + tabId: string, + wc: Electron.WebContents, + frameIndex = 0, + ) { + const target = previewFrames(wc, frameIndex)[0]; + if (target) return target; + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "frame", + tabId, + selectorKind: "focused-element", + }); + }); + + const findAutomationTargetFrame = Effect.fn("PreviewManager.findAutomationTargetFrame")( + function* ( + tabId: string, + wc: Electron.WebContents, + operation: string, + input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + readonly frameIndex?: number | undefined; + }, + ) { + const locator = automationLocator(input)!; + const locatorJson = yield* encodeJson( + { operation: `${operation}.encodeLocator`, tabId, webContentsId: wc.id }, + locator, + ); + for (const target of previewFrames(wc, input.frameIndex)) { + yield* ensurePlaywrightInjectedInFrame(tabId, wc, target.frame); + const result = yield* evaluateInFrame< + { found: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + tabId, + wc, + target.frame, + `${operation}.findTarget`, + `(() => { + try { + const injected = globalThis.__t3PlaywrightInjected; + const element = injected.querySelector(injected.parseSelector(${locatorJson}), document, true); + return element ? { found: true } : { notFound: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + ); + if ("invalidSelector" in result) { + return yield* new PreviewAutomationInvalidSelectorError({ + operation, + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); + } + if ("found" in result) return target; + } + return yield* new PreviewAutomationTargetNotFoundError({ + operation, + tabId, + ...automationSelectorDiagnostics(input), + }); + }, + ); + const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( tabId: string, ) { @@ -1073,7 +1218,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { const url = wc.getURL(); - const title = wc.getTitle(); + const title = wc.getTitle().slice(0, MAX_PREVIEW_TITLE_LENGTH); if (url === "" || url === "about:blank") return { kind: "Idle" }; if (wc.isLoading()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; @@ -1178,7 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: { kind: "LoadFailed", url: validatedUrl || wc.getURL(), - title: wc.getTitle(), + title: wc.getTitle().slice(0, MAX_PREVIEW_TITLE_LENGTH), code, description, }, @@ -1357,7 +1502,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); yield* update(tabId, { zoomFactor }); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => - wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + sendToPreviewFrames(wc, ANNOTATION_THEME_CHANNEL, annotationTheme), ); return; } @@ -1422,7 +1567,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const { state: registered, pendingUrl } = registration.value; yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => - wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + sendToPreviewFrames(wc, ANNOTATION_THEME_CHANNEL, annotationTheme), ); const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; if ( @@ -1549,7 +1694,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: tab.tabId, webContentsId: tab.webContentsId, }, - () => wc.send(ANNOTATION_THEME_CHANNEL, theme), + () => sendToPreviewFrames(wc, ANNOTATION_THEME_CHANNEL, theme), ).pipe(Effect.ignore); }, { discard: true }, @@ -1560,13 +1705,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); + const sessionId = NodeCrypto.randomUUID(); return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.ipc.removeListener(ANNOTATION_SELECTION_CLAIMED_CHANNEL, onSelectionClaimed); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); + wc.off("did-frame-finish-load", onFrameLoaded); }).pipe(Effect.ignore); yield* Ref.update(pickSessionsRef, (sessions) => replaceMap(sessions, (copy) => { @@ -1580,6 +1728,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const active = (yield* Ref.get(pickSessionsRef)).get(tabId); if (!active || active.cancel !== cancel) return; yield* cleanup(); + if (payload === null && !wc.isDestroyed()) { + yield* attempt( + { operation: "pickElement.cancelFrames", tabId, webContentsId: wc.id }, + () => sendToPreviewFrames(wc, CANCEL_PICK_CHANNEL), + ).pipe(Effect.ignore); + } resume(Effect.succeed(payload)); }); const settle = (payload: PreviewAnnotationPayload | null) => { @@ -1598,7 +1752,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, webContentsId: activeWc.id, }, - () => activeWc.send(CANCEL_PICK_CHANNEL), + () => sendToPreviewFrames(activeWc, CANCEL_PICK_CHANNEL), ).pipe(Effect.ignore); } } @@ -1622,13 +1776,34 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, () => { - if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + if (!wc.isDestroyed()) { + sendToPreviewFrames(wc, ANNOTATION_CAPTURED_CHANNEL); + } }, ).pipe(Effect.ignore), ), ), ); }; + const onSelectionClaimed = ( + _event: Electron.IpcMainEvent, + claimedSessionId: string, + keepFrameId: string, + keepTargetIds: ReadonlyArray, + ): void => { + if (claimedSessionId !== sessionId) return; + runFork( + attempt({ operation: "pickElement.claimSelection", tabId, webContentsId: wc.id }, () => + sendToPreviewFrames( + wc, + ANNOTATION_SELECTION_CLAIMED_CHANNEL, + sessionId, + keepFrameId, + keepTargetIds, + ), + ).pipe(Effect.ignore), + ); + }; const onDestroyed = () => settle(null); const onNavigated = ( _event: Electron.Event, @@ -1638,19 +1813,50 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) => { if (isMainFrame) settle(null); }; + const onFrameLoaded = ( + _event: Electron.Event, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ): void => { + if (isMainFrame) return; + const frame = webFrameMain.fromId(frameProcessId, frameRoutingId); + if (!frame) return; + runFork( + Ref.get(pickSessionsRef).pipe( + Effect.flatMap((sessions) => { + if (sessions.get(tabId)?.sessionId !== sessionId || frame.isDestroyed()) { + return Effect.void; + } + return attempt( + { operation: "pickElement.startFrame", tabId, webContentsId: wc.id }, + () => + frame.send( + START_PICK_CHANNEL, + annotationTheme, + sessionId, + String(frame.frameTreeNodeId), + ), + ).pipe(Effect.ignore); + }), + ), + ); + }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, { cancel, sessionId }); + }), + ); yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.ipc.on(ANNOTATION_SELECTION_CLAIMED_CHANNEL, onSelectionClaimed); wc.once("destroyed", onDestroyed); - wc.once("did-start-navigation", onNavigated); + wc.on("did-start-navigation", onNavigated); + wc.on("did-frame-finish-load", onFrameLoaded); if (!wc.isFocused()) wc.focus(); - wc.send(START_PICK_CHANNEL, annotationTheme); + startPickInPreviewFrames(wc, annotationTheme, sessionId); }); - yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.set(tabId, { cancel }); - }), - ); }); runFork( registerPickElement().pipe( @@ -1853,68 +2059,96 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function concurrency: 2, discard: true, }); - const page = yield* evaluateWithDebugger<{ + const snapshotExpression = `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const buildParts = (current, parts = []) => { + if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { + return parts; + } + const parent = current.parentElement; + const siblings = parent + ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) + : []; + const base = current.tagName.toLowerCase(); + const part = siblings.length > 1 + ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" + : base; + return buildParts(parent, [part, ...parts]); + }; + return buildParts(element).join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements + }; + })()`; + type FramePage = { url: string; title: string; loading: boolean; visibleText: string; - interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; - }>( + interactiveElements: ReadonlyArray< + Omit + >; + }; + const frames = previewFrames(wc); + const mainFrame = yield* requireAutomationFrame(tabId, wc); + const mainPage = yield* evaluateInFrame( tabId, - send, - `(() => { - const selectorFor = (element) => { - if (element.id) return "#" + CSS.escape(element.id); - for (const attribute of ["data-testid", "name"]) { - const value = element.getAttribute(attribute); - if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; - } - const buildParts = (current, parts = []) => { - if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { - return parts; - } - const parent = current.parentElement; - const siblings = parent - ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) - : []; - const base = current.tagName.toLowerCase(); - const part = siblings.length > 1 - ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" - : base; - return buildParts(parent, [part, ...parts]); - }; - return buildParts(element).join(" > "); - }; - const visible = (element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; - }; - const elements = Array.from(document.querySelectorAll( - "a[href],button,input,textarea,select,[role],[tabindex]" - )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { - const rect = element.getBoundingClientRect(); - return { - tag: element.tagName.toLowerCase(), - role: element.getAttribute("role"), - name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", - selector: selectorFor(element), - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height - }; - }); - return { - url: location.href, - title: document.title, - loading: document.readyState !== "complete", - visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), - interactiveElements: elements - }; - })()`, - true, + wc, + mainFrame.frame, + "automationSnapshot.mainFrame", + snapshotExpression, + ); + const childPages = yield* Effect.forEach( + frames.slice(1), + (target) => + evaluateInFrame( + tabId, + wc, + target.frame, + "automationSnapshot.childFrame", + snapshotExpression, + ).pipe( + Effect.option, + Effect.map((page) => ({ target, page })), + ), + { concurrency: 4 }, ); + const pages = [ + { target: mainFrame, page: mainPage }, + ...childPages.flatMap(({ target, page }) => + Option.isSome(page) ? [{ target, page: page.value }] : [], + ), + ]; const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), attemptPromise( @@ -1936,7 +2170,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const size = image.getSize(); const browserDiagnostics = diagnostics.get(wc.id); return { - ...page, + url: mainPage.url, + title: mainPage.title, + loading: pages.some(({ page }) => page.loading), + frames: frames.map(({ frame, frameIndex }) => ({ + index: frameIndex, + url: frame.url, + name: frame.name, + })), + visibleText: pages + .map( + ({ target, page }) => `[frame ${target.frameIndex}: ${page.url}]\n${page.visibleText}`, + ) + .join("\n\n") + .slice(0, MAX_VISIBLE_TEXT_LENGTH), + interactiveElements: pages + .flatMap(({ target, page }) => + page.interactiveElements.map((element) => ({ + ...element, + frameIndex: target.frameIndex, + })), + ) + .slice(0, MAX_INTERACTIVE_ELEMENTS), accessibilityTree: accessibility, consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], @@ -2090,17 +2345,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "click", (send) => - performAutomationClick(tabId, input, send), + Effect.gen(function* () { + const locator = automationLocator(input); + if (!locator) return yield* performAutomationClick(tabId, input, send); + const target = yield* findAutomationTargetFrame(tabId, wc, "click", input); + if (target.frame === wc.mainFrame) { + return yield* performAutomationClick(tabId, input, send); + } + const locatorJson = yield* encodeJson( + { operation: "automationClick.encodeFrameLocator", tabId, webContentsId: wc.id }, + locator, + ); + const result = yield* evaluateInFrame< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + tabId, + wc, + target.frame, + "automationClick.frame", + `(() => { + try { + const injected = globalThis.__t3PlaywrightInjected; + const element = injected.querySelector(injected.parseSelector(${locatorJson}), document, true); + if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; + element.scrollIntoView({ block: "center", inline: "center" }); + element.focus({ preventScroll: true }); + element.click(); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + ); + if ("invalidSelector" in result) { + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); + } + if ("notFound" in result) { + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + }); + } + }), ); }); const typeIntoAutomationTarget = Effect.fn("PreviewManager.typeIntoAutomationTarget")(function* ( tabId: string, + wc: Electron.WebContents, send: SendCommand, input: PreviewAutomationTypeInput, ) { const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(tabId, send); + const target = locator + ? yield* findAutomationTargetFrame(tabId, wc, "type", input) + : yield* requireAutomationFrame(tabId, wc, input.frameIndex); const locatorJson = locator ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) : null; @@ -2108,73 +2417,78 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { operation: "automationType.encodeText", tabId }, input.text, ); - const result = yield* evaluateWithDebugger< - | { ok: true } - | { invalidSelector: true; message: string } - | { notEditable: true } - | { notFound: true } - >( - tabId, - send, - `(() => { - try { - const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; - if (!element) return { notFound: true }; - const textControl = - element instanceof HTMLTextAreaElement || - (element instanceof HTMLInputElement && - !new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]).has(element.type)); - const editable = textControl || element.isContentEditable; - if (!editable || element.disabled || element.readOnly) return { notEditable: true }; - element.focus(); - if (document.activeElement !== element) return { notEditable: true }; - const clear = ${input.clear ?? false}; - if (clear) { + const expression = `(() => { + try { + const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; + if (!element) return { notFound: true }; + const textControl = + element instanceof HTMLTextAreaElement || + (element instanceof HTMLInputElement && + !new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]).has(element.type)); + const editable = textControl || element.isContentEditable; + if (!editable || element.disabled || element.readOnly) return { notEditable: true }; + element.focus(); + if (document.activeElement !== element) return { notEditable: true }; + const clear = ${input.clear ?? false}; + if (clear) { + if (textControl) { + element.select(); + } else { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + const text = ${textJson}; + let inserted = true; + if (text.length > 0) { + inserted = document.execCommand("insertText", false, text); + } else if (clear) { + document.execCommand("delete", false); + const cleared = textControl + ? element.value.length === 0 + : (element.textContent ?? "").length === 0; + if (!cleared) { if (textControl) { - element.select(); + const prototype = element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (valueSetter) valueSetter.call(element, ""); + else element.value = ""; } else { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = document.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } - } - const text = ${textJson}; - let inserted = true; - if (text.length > 0) { - inserted = document.execCommand("insertText", false, text); - } else if (clear) { - document.execCommand("delete", false); - const cleared = textControl - ? element.value.length === 0 - : (element.textContent ?? "").length === 0; - if (!cleared) { - if (textControl) { - const prototype = element instanceof HTMLTextAreaElement - ? HTMLTextAreaElement.prototype - : HTMLInputElement.prototype; - const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; - if (valueSetter) valueSetter.call(element, ""); - else element.value = ""; - } else { - element.replaceChildren(); - } - element.dispatchEvent(new InputEvent("input", { - bubbles: true, - inputType: "deleteContentBackward", - })); + element.replaceChildren(); } + element.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "deleteContentBackward", + })); } - if (!inserted) return { notEditable: true }; - element.dispatchEvent(new Event("change", { bubbles: true })); - return { ok: true }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; } - })()`, - true, - ); + if (!inserted) return { notEditable: true }; + element.dispatchEvent(new Event("change", { bubbles: true })); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`; + type TypeResult = + | { ok: true } + | { invalidSelector: true; message: string } + | { notEditable: true } + | { notFound: true }; + const result = + target.frame === wc.mainFrame + ? yield* evaluateWithDebugger(tabId, send, expression, true) + : yield* evaluateInFrame( + tabId, + wc, + target.frame, + "automationType.frame", + expression, + ); if ("invalidSelector" in result) { return yield* new PreviewAutomationInvalidSelectorError({ operation: "type", @@ -2201,13 +2515,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( tabId: string, + wc: Electron.WebContents, input: PreviewAutomationTypeInput, send: SendCommand, ) { // CDP Input.insertText silently drops text until Electron has activated a hidden // guest WebContents with a pointer event. Editing in the page runtime keeps // background automation deterministic without stealing foreground app focus. - yield* typeIntoAutomationTarget(tabId, send, input); + yield* typeIntoAutomationTarget(tabId, wc, send, input); }); const automationType = Effect.fn("PreviewManager.automationType")(function* ( @@ -2216,7 +2531,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "type", (send) => - performAutomationType(tabId, input, send), + performAutomationType(tabId, wc, input, send), ); }); @@ -2228,6 +2543,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function sendCleanup: SendCommand, ) { yield* prepareAutomationInput(send, false); + if (input.frameIndex !== undefined) { + const target = yield* requireAutomationFrame(tabId, wc, input.frameIndex); + yield* evaluateInFrame( + tabId, + wc, + target.frame, + "automationPress.focusFrame", + "window.focus(); document.activeElement?.focus(); null", + ); + } const keySequence = makePreviewAutomationKeySequence(input, { isMac: hostPlatform === "darwin", }); @@ -2284,32 +2609,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* ( tabId: string, + wc: Electron.WebContents, input: PreviewAutomationScrollInput, send: SendCommand, ) { yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(tabId, send); + const target = locator + ? yield* findAutomationTargetFrame(tabId, wc, "scroll", input) + : yield* requireAutomationFrame(tabId, wc, input.frameIndex); const locatorJson = locator ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator) : null; - const result = yield* evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } - >( - tabId, - send, - `(() => { - try { - const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; - if (!target) return { notFound: true }; - target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); - return { ok: true }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); + const expression = `(() => { + try { + const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`; + type ScrollResult = + | { ok: true } + | { invalidSelector: true; message: string } + | { notFound: true }; + const result = + target.frame === wc.mainFrame + ? yield* evaluateWithDebugger(tabId, send, expression, true) + : yield* evaluateInFrame( + tabId, + wc, + target.frame, + "automationScroll.frame", + expression, + ); if ("invalidSelector" in result) { return yield* new PreviewAutomationInvalidSelectorError({ operation: "scroll", @@ -2334,20 +2669,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "scroll", (send) => - performAutomationScroll(tabId, input, send), + performAutomationScroll(tabId, wc, input, send), ); }); const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( - function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) { + function* ( + tabId: string, + wc: Electron.WebContents, + input: PreviewAutomationEvaluateInput, + send: SendCommand, + ) { yield* send("Runtime.enable"); - const value = yield* evaluateWithDebugger( - tabId, - send, - input.expression, - input.returnByValue ?? true, - input.awaitPromise ?? true, - ); + const target = yield* requireAutomationFrame(tabId, wc, input.frameIndex); + const value = + target.frame === wc.mainFrame + ? yield* evaluateWithDebugger( + tabId, + send, + input.expression, + input.returnByValue ?? true, + input.awaitPromise ?? true, + ) + : yield* evaluateInFrame( + tabId, + wc, + target.frame, + "automationEvaluate.frame", + input.expression, + ); const serialized = yield* encodeJson( { operation: "automationEvaluate.encodeResult", tabId }, value, @@ -2370,19 +2720,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); return yield* withControlSession(tabId, wc, "evaluate", (send) => - performAutomationEvaluate(tabId, input, send), + performAutomationEvaluate(tabId, wc, input, send), ); }); const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( tabId: string, + wc: Electron.WebContents, input: PreviewAutomationWaitForInput, send: SendCommand, ) { const timeoutMs = input.timeoutMs ?? 15_000; yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(tabId, send); const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ locator ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator) @@ -2394,39 +2744,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes) : Effect.succeed(null), ]); + const expression = `(() => { + try { + const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; + const textMatched = ${textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true"}; + const urlMatched = ${urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true"}; + return { matched: selectorMatched && textMatched && urlMatched }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`; + if (input.frameIndex !== undefined) { + yield* requireAutomationFrame(tabId, wc, input.frameIndex); + } const deadline = (yield* currentMillis) + timeoutMs; while ((yield* currentMillis) <= deadline) { - const result = yield* evaluateWithDebugger< - { matched: boolean } | { invalidSelector: true; message: string } - >( - tabId, - send, - `(() => { - try { - const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; - const textMatched = ${ - textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true" - }; - const urlMatched = ${ - urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true" - }; - return { matched: selectorMatched && textMatched && urlMatched }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in result) { - return yield* new PreviewAutomationInvalidSelectorError({ - operation: "waitFor", - tabId, - ...automationSelectorDiagnostics(input), - reasonLength: result.message.length, - cause: result, - }); + for (const target of previewFrames(wc, input.frameIndex)) { + if (locator) yield* ensurePlaywrightInjectedInFrame(tabId, wc, target.frame); + const result = + target.frame === wc.mainFrame + ? yield* evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >(tabId, send, expression, true) + : yield* evaluateInFrame< + { matched: boolean } | { invalidSelector: true; message: string } + >(tabId, wc, target.frame, "automationWaitFor.frame", expression); + if ("invalidSelector" in result) { + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "waitFor", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); + } + if (result.matched) return; } - if (result.matched) return; yield* Effect.sleep(100); } return yield* new PreviewAutomationTimeoutError({ @@ -2441,7 +2794,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "waitFor", (send) => - performAutomationWaitFor(tabId, input, send), + performAutomationWaitFor(tabId, wc, input, send), ); }); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 2654b898102..51997cd0c93 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -16,6 +16,7 @@ import type { import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_SELECTION_CLAIMED_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, @@ -29,17 +30,136 @@ const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; +const FRAME_BRIDGE_SOURCE = "t3code-preview-annotation"; +const isMainFrame = process.isMainFrame; type AnnotationTool = "select" | "marquee" | "draw" | "erase"; +interface AnnotationComputedStyles { + readonly fontFamily: string; + readonly fontSize: string; + readonly fontWeight: string; + readonly lineHeight: string; + readonly color: string; + readonly backgroundColor: string; + readonly opacity: string; + readonly borderRadius: string; + readonly borderColor: string; + readonly borderWidth: string; + readonly padding: string; + readonly margin: string; + readonly gap: string; +} + +interface FrameElementTarget { + readonly id: string; + readonly element: PickedElementPayload; + readonly rect: PreviewAnnotationRect; + readonly computedStyles: AnnotationComputedStyles; +} + +interface FrameAnnotationState { + readonly pageUrl: string; + readonly pageTitle: string | null; + readonly elements: ReadonlyArray; + readonly regions: ReadonlyArray; + readonly strokes: ReadonlyArray; + readonly styleChanges: ReadonlyArray; +} + +interface FrameStateMessage { + readonly source: typeof FRAME_BRIDGE_SOURCE; + readonly kind: "state"; + readonly sessionId: string; + readonly frameId: string; + readonly viewport: { readonly width: number; readonly height: number }; + readonly state: FrameAnnotationState; +} + +type FrameCommand = + | { readonly kind: "tool"; readonly tool: AnnotationTool } + | { + readonly kind: "marquee-select"; + readonly operationId: string; + readonly rect: PreviewAnnotationRect; + readonly additive: boolean; + } + | { readonly kind: "style"; readonly property: string; readonly value: string }; +type FrameMarqueeSelectionCommand = Extract; + +interface FrameCommandMessage { + readonly source: typeof FRAME_BRIDGE_SOURCE; + readonly kind: "command"; + readonly sessionId: string; + readonly command: FrameCommand; +} + +interface FrameToolRequestMessage { + readonly source: typeof FRAME_BRIDGE_SOURCE; + readonly kind: "tool-request"; + readonly sessionId: string; + readonly tool: AnnotationTool; +} + +interface FrameDrawGestureMessage { + readonly source: typeof FRAME_BRIDGE_SOURCE; + readonly kind: "draw-gesture"; + readonly sessionId: string; + readonly phase: "start" | "move" | "end" | "cancel"; + readonly pointerId: number; + readonly viewport: { readonly width: number; readonly height: number }; + readonly point: PreviewAnnotationPoint; +} + +interface FrameMarqueeGestureMessage { + readonly source: typeof FRAME_BRIDGE_SOURCE; + readonly kind: "marquee-gesture"; + readonly sessionId: string; + readonly phase: "start" | "move" | "end" | "cancel"; + readonly pointerId: number; + readonly viewport: { readonly width: number; readonly height: number }; + readonly point: PreviewAnnotationPoint; + readonly additive: boolean; +} + +interface FrameMarqueeSelectionMessage { + readonly source: typeof FRAME_BRIDGE_SOURCE; + readonly kind: "marquee-selection"; + readonly sessionId: string; + readonly operationId: string; +} + +type FrameBridgeMessage = + | FrameStateMessage + | FrameCommandMessage + | FrameToolRequestMessage + | FrameDrawGestureMessage + | FrameMarqueeGestureMessage + | FrameMarqueeSelectionMessage; + interface SelectedElement { id: string; element: Element; outline: HTMLDivElement; label: HTMLDivElement; baselineStyles: Map; + capture: Promise; } +type AnnotationOrigin = + | { + readonly kind: "element"; + readonly anchor: Element; + readonly anchorRect: PreviewAnnotationRect; + readonly scrollX: number; + readonly scrollY: number; + } + | { + readonly kind: "viewport"; + readonly scrollX: number; + readonly scrollY: number; + }; + interface AnnotationSession { teardown: (notifyMain: boolean) => void; applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; @@ -124,17 +244,106 @@ const normalizeRect = ( height: Math.abs(endY - startY), }); +const intersectRects = ( + rect: PreviewAnnotationRect, + clip: PreviewAnnotationRect, +): PreviewAnnotationRect => { + const x = Math.max(rect.x, clip.x); + const y = Math.max(rect.y, clip.y); + const right = Math.min(rect.x + rect.width, clip.x + clip.width); + const bottom = Math.min(rect.y + rect.height, clip.y + clip.height); + return { + x, + y, + width: Math.max(0, right - x), + height: Math.max(0, bottom - y), + }; +}; + +const clipToViewport = (rect: PreviewAnnotationRect): PreviewAnnotationRect => + intersectRects(rect, { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }); + +const snapshotComputedStyles = (element: Element): AnnotationComputedStyles => { + const computed = getComputedStyle(element); + return { + fontFamily: computed.fontFamily, + fontSize: computed.fontSize, + fontWeight: computed.fontWeight, + lineHeight: computed.lineHeight, + color: computed.color, + backgroundColor: computed.backgroundColor, + opacity: computed.opacity, + borderRadius: computed.borderRadius, + borderColor: computed.borderColor, + borderWidth: computed.borderWidth, + padding: computed.padding, + margin: computed.margin, + gap: computed.gap, + }; +}; + +const frameOwnerElements = (): ReadonlyArray => + Array.from(document.querySelectorAll("iframe,frame")); + +const projectFrameState = ( + message: FrameStateMessage, + owner: HTMLIFrameElement | HTMLFrameElement, +): FrameStateMessage => { + const ownerRect = owner.getBoundingClientRect(); + const borderScaleX = owner.offsetWidth > 0 ? ownerRect.width / owner.offsetWidth : 1; + const borderScaleY = owner.offsetHeight > 0 ? ownerRect.height / owner.offsetHeight : 1; + const contentRect = { + x: ownerRect.left + owner.clientLeft * borderScaleX, + y: ownerRect.top + owner.clientTop * borderScaleY, + width: owner.clientWidth * borderScaleX, + height: owner.clientHeight * borderScaleY, + }; + const scaleX = message.viewport.width > 0 ? contentRect.width / message.viewport.width : 1; + const scaleY = message.viewport.height > 0 ? contentRect.height / message.viewport.height : 1; + const visibleFrame = clipToViewport(contentRect); + const mapPoint = (point: PreviewAnnotationPoint): PreviewAnnotationPoint => ({ + x: contentRect.x + point.x * scaleX, + y: contentRect.y + point.y * scaleY, + }); + const mapRect = (rect: PreviewAnnotationRect): PreviewAnnotationRect => + intersectRects( + { + x: contentRect.x + rect.x * scaleX, + y: contentRect.y + rect.y * scaleY, + width: rect.width * scaleX, + height: rect.height * scaleY, + }, + visibleFrame, + ); + return { + ...message, + viewport: { width: window.innerWidth, height: window.innerHeight }, + state: { + ...message.state, + elements: message.state.elements.map((target) => ({ ...target, rect: mapRect(target.rect) })), + regions: message.state.regions.map((region) => ({ ...region, rect: mapRect(region.rect) })), + strokes: message.state.strokes.map((stroke) => ({ + ...stroke, + points: stroke.points.map(mapPoint), + bounds: mapRect(stroke.bounds), + width: stroke.width * Math.min(scaleX, scaleY), + })), + }, + }; +}; + const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; function unionRects( rects: ReadonlyArray, padding = 20, ): PreviewAnnotationRect | null { - if (rects.length === 0) return null; - const left = Math.min(...rects.map((rect) => rect.x)); - const top = Math.min(...rects.map((rect) => rect.y)); - const right = Math.max(...rects.map((rect) => rect.x + rect.width)); - const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const visibleRects = rects.filter((rect) => rect.width > 0 && rect.height > 0); + if (visibleRects.length === 0) return null; + const left = Math.min(...visibleRects.map((rect) => rect.x)); + const top = Math.min(...visibleRects.map((rect) => rect.y)); + const right = Math.max(...visibleRects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...visibleRects.map((rect) => rect.y + rect.height)); const x = Math.max(0, left - padding); const y = Math.max(0, top - padding); const maxWidth = Math.max(1, window.innerWidth - x); @@ -156,6 +365,8 @@ function pickFromPoint(clientX: number, clientY: number): Element | null { if (!(candidate instanceof Element)) continue; if (isAnnotationNode(candidate)) continue; if (candidate === document.documentElement || candidate === document.body) continue; + if (candidate instanceof HTMLIFrameElement || candidate instanceof HTMLFrameElement) + return null; return candidate; } return null; @@ -194,6 +405,10 @@ function createBox(color: string, fill: string): HTMLDivElement { } function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + if (rect.width <= 0 || rect.height <= 0) { + node.style.display = "none"; + return; + } node.style.display = "block"; node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; node.style.width = `${rect.width}px`; @@ -215,17 +430,22 @@ function createLabel(): HTMLDivElement { return label; } -function updateSelectedVisual(target: SelectedElement): void { +function updateSelectedVisual(target: SelectedElement, measuredRect?: PreviewAnnotationRect): void { if (!target.element.isConnected) { target.outline.style.display = "none"; target.label.style.display = "none"; return; } - const rect = target.element.getBoundingClientRect(); - positionBox(target.outline, rectFromDomRect(rect)); + const rect = + measuredRect ?? clipToViewport(rectFromDomRect(target.element.getBoundingClientRect())); + positionBox(target.outline, rect); + if (rect.width <= 0 || rect.height <= 0) { + target.label.style.display = "none"; + return; + } target.label.textContent = describeRawElement(target.element); target.label.style.display = "block"; - target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; + target.label.style.transform = `translate(${Math.max(4, rect.x)}px, ${Math.max(4, rect.y - 22)}px)`; } function toStackFrame(frame: { @@ -350,7 +570,7 @@ function strokeBounds( return { x: left, y: top, width: right - left, height: bottom - top }; } -function startAnnotation(): void { +function startAnnotation(sessionId: string, frameId: string): void { activeSession?.teardown(false); let finished = false; const host = document.createElement("div"); @@ -380,7 +600,6 @@ function startAnnotation(): void { svg.setAttribute(OVERLAY_ATTRIBUTE, ""); svg.setAttribute("width", "100%"); svg.setAttribute("height", "100%"); - svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); root.appendChild(svg); @@ -390,14 +609,14 @@ function startAnnotation(): void { toolbar.className = "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); - root.appendChild(toolbar); + if (isMainFrame) root.appendChild(toolbar); const editor = document.createElement("div"); editor.setAttribute(OVERLAY_ATTRIBUTE, ""); editor.className = "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); - root.appendChild(editor); + if (isMainFrame) root.appendChild(editor); const composerRow = document.createElement("div"); composerRow.className = "flex items-start gap-2 p-2"; @@ -441,16 +660,42 @@ function startAnnotation(): void { const regions: PreviewAnnotationRegionTarget[] = []; const strokes: PreviewAnnotationStrokeTarget[] = []; const styleChanges = new Map(); + const remoteFrames = new Map(); + const childFrameStates = new Map< + string, + { readonly sourceWindow: WindowProxy; readonly message: FrameStateMessage } + >(); + const regionOrigins = new Map< + string, + { readonly origin: AnnotationOrigin; readonly node: HTMLDivElement } + >(); + const strokeOrigins = new Map< + string, + { readonly origin: AnnotationOrigin; readonly path: SVGPathElement } + >(); const toolButtons = new Map(); + const pendingMarqueeRegions = new Map(); let tool: AnnotationTool = "select"; - let dragStart: PreviewAnnotationPoint | null = null; - let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let hoveredElement: Element | null = null; + let activeMarquee: { + pointerId: number; + start: PreviewAnnotationPoint; + additive: boolean; + } | null = null; + let activeStroke: { + pointerId: number; + target: PreviewAnnotationStrokeTarget; + path: SVGPathElement; + } | null = null; let pendingCapture = false; let editorExpanded = false; let editorWasShown = false; let editorPosition: { left: number; top: number } | null = null; let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; let editorLayoutFrame: number | null = null; + let repaintFrame: number | null = null; + let publishFrame: number | null = null; + let relayFrame: number | null = null; const resizeComment = (): void => { const maxHeight = 96; @@ -462,15 +707,184 @@ function startAnnotation(): void { }; comment.addEventListener("input", resizeComment); - const updateStatus = (): void => { - const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; - editor.style.display = hasTargets ? "flex" : "none"; + const captureAnnotationOrigin = (anchor: Element | null): AnnotationOrigin => + anchor + ? { + kind: "element", + anchor, + anchorRect: rectFromDomRect(anchor.getBoundingClientRect()), + scrollX: window.scrollX, + scrollY: window.scrollY, + } + : { + kind: "viewport", + scrollX: window.scrollX, + scrollY: window.scrollY, + }; + + const annotationOffset = (origin: AnnotationOrigin): PreviewAnnotationPoint => { + if (origin.kind === "element" && origin.anchor.isConnected) { + const rect = origin.anchor.getBoundingClientRect(); + return { + x: rect.left - origin.anchorRect.x, + y: rect.top - origin.anchorRect.y, + }; + } + return { + x: -(window.scrollX - origin.scrollX), + y: -(window.scrollY - origin.scrollY), + }; + }; + + const currentRegionRect = (region: PreviewAnnotationRegionTarget): PreviewAnnotationRect => { + const visual = regionOrigins.get(region.id); + if (!visual) return region.rect; + const offset = annotationOffset(visual.origin); + return { + ...region.rect, + x: region.rect.x + offset.x, + y: region.rect.y + offset.y, + }; + }; + + const currentStroke = (stroke: PreviewAnnotationStrokeTarget): PreviewAnnotationStrokeTarget => { + const visual = strokeOrigins.get(stroke.id); + if (!visual) return stroke; + const offset = annotationOffset(visual.origin); + return { + ...stroke, + points: stroke.points.map((point) => ({ + x: point.x + offset.x, + y: point.y + offset.y, + })), + bounds: { + ...stroke.bounds, + x: stroke.bounds.x + offset.x, + y: stroke.bounds.y + offset.y, + }, + }; + }; + + let publishRevision = 0; + const publishState = (): void => { + if (isMainFrame || finished) return; + const revision = ++publishRevision; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await target.capture; + if (!element || !target.element.isConnected) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: clipToViewport(rectFromDomRect(target.element.getBoundingClientRect())), + computedStyles: snapshotComputedStyles(target.element), + } satisfies FrameElementTarget; + }), + ).then((captured) => { + if (finished || revision !== publishRevision) return; + const message: FrameStateMessage = { + source: FRAME_BRIDGE_SOURCE, + kind: "state", + sessionId, + frameId, + viewport: { width: window.innerWidth, height: window.innerHeight }, + state: { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + elements: captured.filter((target) => target !== null), + regions: regions.map((region) => ({ + ...region, + rect: clipToViewport(currentRegionRect(region)), + })), + strokes: strokes.map((stroke) => { + const current = currentStroke(stroke); + return { ...current, bounds: clipToViewport(current.bounds) }; + }), + styleChanges: Array.from(styleChanges.values()), + }, + }; + window.parent.postMessage(message, "*"); + }); + }; + + const queuePublishState = (): void => { + if (isMainFrame || finished || publishFrame !== null) return; + publishFrame = window.requestAnimationFrame(() => { + publishFrame = null; + publishState(); + }); + }; + + const broadcastFrameCommand = (command: FrameCommand): void => { + for (const owner of frameOwnerElements()) { + if (!owner.contentWindow) continue; + let childCommand = command; + if (command.kind === "marquee-select") { + const ownerRect = owner.getBoundingClientRect(); + const scaleX = owner.offsetWidth > 0 ? ownerRect.width / owner.offsetWidth : 1; + const scaleY = owner.offsetHeight > 0 ? ownerRect.height / owner.offsetHeight : 1; + const contentX = ownerRect.left + owner.clientLeft * scaleX; + const contentY = ownerRect.top + owner.clientTop * scaleY; + childCommand = { + ...command, + rect: { + x: (command.rect.x - contentX) / scaleX, + y: (command.rect.y - contentY) / scaleY, + width: command.rect.width / scaleX, + height: command.rect.height / scaleY, + }, + }; + } + owner.contentWindow.postMessage( + { + source: FRAME_BRIDGE_SOURCE, + kind: "command", + sessionId, + command: childCommand, + } satisfies FrameCommandMessage, + "*", + ); + } + }; + + const remoteTargets = (): ReadonlyArray => + Array.from(remoteFrames.values()).flatMap((state) => state.elements); + + const remoteTargetRects = (): ReadonlyArray => + Array.from(remoteFrames.values()).flatMap((state) => [ + ...state.elements.map((target) => target.rect), + ...state.regions.map((region) => region.rect), + ...state.strokes.map((stroke) => stroke.bounds), + ]); + + const updateStatus = (measuredLocalRects?: ReadonlyArray): void => { + const hasTargets = + selected.size > 0 || + regions.length > 0 || + strokes.length > 0 || + remoteTargetRects().length > 0; + const hasVisibleTarget = + unionRects([ + ...(measuredLocalRects ?? [ + ...Array.from(selected.values(), (target) => + clipToViewport(rectFromDomRect(target.element.getBoundingClientRect())), + ), + ...regions.map((region) => clipToViewport(currentRegionRect(region))), + ...strokes.map((stroke) => clipToViewport(currentStroke(stroke).bounds)), + ]), + ...remoteTargetRects(), + ]) !== null; + editor.style.display = isMainFrame && hasTargets && hasVisibleTarget ? "flex" : "none"; submit.disabled = !hasTargets; submit.style.opacity = hasTargets ? "1" : "0.45"; adjust.disabled = !hasTargets; - stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + stylePanel.style.display = + editorExpanded && (selected.size > 0 || remoteTargets().length > 0) ? "grid" : "none"; queueEditorLayout(); - if (hasTargets && !editorWasShown) { + if (isMainFrame && hasVisibleTarget && !editorWasShown) { editorWasShown = true; window.setTimeout(() => comment.focus({ preventScroll: true }), 0); } @@ -483,8 +897,18 @@ function startAnnotation(): void { button.classList.toggle("text-primary", active); button.classList.toggle("text-foreground", !active); } - if (tool !== "select") hoverOutline.style.display = "none"; - if (tool !== "marquee") marqueeBox.style.display = "none"; + if (tool !== "select") { + hoveredElement = null; + hoverOutline.style.display = "none"; + } + if (tool !== "marquee") { + marqueeBox.style.display = "none"; + activeMarquee = null; + } + if (tool !== "draw" && activeStroke) { + activeStroke.path.remove(); + activeStroke = null; + } document.documentElement.setAttribute("data-t3code-annotation-tool", tool); }; @@ -502,16 +926,42 @@ function startAnnotation(): void { if (change.targetId === target.id) styleChanges.delete(key); } updateStatus(); + queuePublishState(); }; - const addSelected = (element: Element): void => { - if (selected.has(element)) return; + const clearTargetsExcept = (keepFrameId: string, keepTargetIds: ReadonlyArray): void => { + const keep = frameId === keepFrameId ? new Set(keepTargetIds) : new Set(); + for (const target of Array.from(selected.values())) { + if (!keep.has(target.id)) removeSelected(target); + } + for (let index = regions.length - 1; index >= 0; index -= 1) { + const region = regions[index]; + if (!region || keep.has(region.id)) continue; + regions.splice(index, 1); + regionOrigins.get(region.id)?.node.remove(); + regionOrigins.delete(region.id); + } + for (let index = strokes.length - 1; index >= 0; index -= 1) { + const stroke = strokes[index]; + if (!stroke || keep.has(stroke.id)) continue; + strokes.splice(index, 1); + strokeOrigins.get(stroke.id)?.path.remove(); + strokeOrigins.delete(stroke.id); + } + updateStatus(); + queuePublishState(); + }; + + const addSelected = (element: Element): SelectedElement => { + const existing = selected.get(element); + if (existing) return existing; const target: SelectedElement = { - id: nextId("element"), + id: `${frameId}:${nextId("element")}`, element, outline: createBox(PRIMARY, PRIMARY_FILL), label: createLabel(), baselineStyles: new Map(), + capture: captureElement(element), }; selected.set(element, target); root.append(target.outline, target.label); @@ -521,6 +971,8 @@ function startAnnotation(): void { stylePanel.style.display = "grid"; syncStyleControls(); } + queuePublishState(); + return target; }; const toggleSelected = (element: Element, additive: boolean): void => { @@ -532,7 +984,11 @@ function startAnnotation(): void { if (!additive) { for (const target of Array.from(selected.values())) removeSelected(target); } - addSelected(element); + const target = addSelected(element); + if (!additive) { + clearTargetsExcept(frameId, [target.id]); + ipcRenderer.send(ANNOTATION_SELECTION_CLAIMED_CHANNEL, sessionId, frameId, [target.id]); + } }; const setStyleForSelected = (property: string, value: string): void => { @@ -556,6 +1012,8 @@ function startAnnotation(): void { }); updateSelectedVisual(target); } + queuePublishState(); + if (isMainFrame) broadcastFrameCommand({ kind: "style", property, value }); }; const textSection = createStyleSection(); @@ -755,10 +1213,11 @@ function startAnnotation(): void { const gap = addSpacingField("Gap", "gap", "0px"); const syncStyleControls = (): void => { - const first = selected.values().next().value as SelectedElement | undefined; - if (!first) return; - const computed = getComputedStyle(first.element); - const rect = first.element.getBoundingClientRect(); + const local = selected.values().next().value as SelectedElement | undefined; + const remote = remoteTargets()[0]; + if (!local && !remote) return; + const computed = local ? snapshotComputedStyles(local.element) : remote!.computedStyles; + const rect = local ? local.element.getBoundingClientRect() : remote!.rect; aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; widthInput.value = String(Math.round(rect.width)); heightInput.value = String(Math.round(rect.height)); @@ -793,11 +1252,323 @@ function startAnnotation(): void { button.addEventListener("click", () => { tool = candidate; refreshToolButtons(); + broadcastFrameCommand({ kind: "tool", tool }); }); toolButtons.set(candidate, button); toolbar.appendChild(button); } + const applyDrawGesture = (message: FrameDrawGestureMessage): void => { + if (!isMainFrame) return; + if (message.phase === "start") { + activeStroke?.path.remove(); + const stroke: PreviewAnnotationStrokeTarget = { + id: `${frameId}:${nextId("stroke")}`, + color: annotationTheme?.primary ?? "#2563eb", + width: 4, + points: [message.point], + bounds: { x: message.point.x, y: message.point.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { pointerId: message.pointerId, target: stroke, path }; + return; + } + if (!activeStroke || activeStroke.pointerId !== message.pointerId) return; + if (message.phase === "cancel") { + activeStroke.path.remove(); + activeStroke = null; + return; + } + const lastPoint = activeStroke.target.points.at(-1); + if (!lastPoint || lastPoint.x !== message.point.x || lastPoint.y !== message.point.y) { + activeStroke.target.points = [...activeStroke.target.points, message.point]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } + if (message.phase === "move") return; + if (activeStroke.target.points.length > 1) { + strokes.push(activeStroke.target); + strokeOrigins.set(activeStroke.target.id, { + origin: captureAnnotationOrigin(null), + path: activeStroke.path, + }); + } else activeStroke.path.remove(); + activeStroke = null; + updateStatus(); + }; + + const sendDrawGesture = (phase: FrameDrawGestureMessage["phase"], event: PointerEvent): void => { + const message = { + source: FRAME_BRIDGE_SOURCE, + kind: "draw-gesture", + sessionId, + phase, + pointerId: event.pointerId, + viewport: { width: window.innerWidth, height: window.innerHeight }, + point: { x: event.clientX, y: event.clientY }, + } satisfies FrameDrawGestureMessage; + if (isMainFrame) applyDrawGesture(message); + else window.parent.postMessage(message, "*"); + }; + + const applyMarqueeGesture = (message: FrameMarqueeGestureMessage): void => { + if (!isMainFrame) return; + if (message.phase === "start") { + activeMarquee = { + pointerId: message.pointerId, + start: message.point, + additive: message.additive, + }; + return; + } + if (!activeMarquee || activeMarquee.pointerId !== message.pointerId) return; + if (message.phase === "cancel") { + marqueeBox.style.display = "none"; + activeMarquee = null; + return; + } + const rect = normalizeRect( + activeMarquee.start.x, + activeMarquee.start.y, + message.point.x, + message.point.y, + ); + if (message.phase === "move") { + positionBox(marqueeBox, rect); + return; + } + marqueeBox.style.display = "none"; + const additive = activeMarquee.additive; + activeMarquee = null; + if (!isUsableRect(rect)) return; + const command = { + kind: "marquee-select", + operationId: `${frameId}:${nextId("marquee")}`, + rect, + additive, + } satisfies FrameMarqueeSelectionCommand; + const found = applyMarqueeSelection(command); + if (found.length === 0) { + const region: PreviewAnnotationRegionTarget = { + id: `${frameId}:${nextId("region")}`, + rect, + }; + regions.push(region); + const regionBox = createBox(PRIMARY, "color-mix(in srgb, var(--t3-primary) 6%, transparent)"); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + regionOrigins.set(region.id, { + origin: captureAnnotationOrigin(null), + node: regionBox, + }); + pendingMarqueeRegions.set(command.operationId, region.id); + } + broadcastFrameCommand(command); + updateStatus(); + }; + + const sendMarqueeGesture = ( + phase: FrameMarqueeGestureMessage["phase"], + event: PointerEvent, + ): void => { + const message = { + source: FRAME_BRIDGE_SOURCE, + kind: "marquee-gesture", + sessionId, + phase, + pointerId: event.pointerId, + viewport: { width: window.innerWidth, height: window.innerHeight }, + point: { x: event.clientX, y: event.clientY }, + additive: event.shiftKey, + } satisfies FrameMarqueeGestureMessage; + if (isMainFrame) applyMarqueeGesture(message); + else window.parent.postMessage(message, "*"); + }; + + const applyFrameCommand = (command: FrameCommand): void => { + if (command.kind === "tool") { + tool = command.tool; + refreshToolButtons(); + return; + } + if (command.kind === "marquee-select") { + applyMarqueeSelection(command); + return; + } + setStyleForSelected(command.property, command.value); + }; + + const onSelectionClaimed = ( + _event: Electron.IpcRendererEvent, + claimedSessionId: string, + keepFrameId: string, + keepTargetIds: ReadonlyArray, + ): void => { + if (claimedSessionId !== sessionId) return; + clearTargetsExcept(keepFrameId, keepTargetIds); + }; + + const relayChildState = ( + entry: { + readonly sourceWindow: WindowProxy; + readonly message: FrameStateMessage; + }, + refreshMain = true, + ): void => { + const owner = frameOwnerElements().find( + (candidate) => candidate.contentWindow === entry.sourceWindow, + ); + const message = owner + ? projectFrameState(entry.message, owner) + : { + ...entry.message, + viewport: { width: window.innerWidth, height: window.innerHeight }, + state: { + ...entry.message.state, + elements: [], + regions: [], + strokes: [], + styleChanges: [], + }, + }; + if (!isMainFrame) { + window.parent.postMessage(message, "*"); + return; + } + if ( + message.state.elements.length === 0 && + message.state.regions.length === 0 && + message.state.strokes.length === 0 + ) { + remoteFrames.delete(message.frameId); + } else { + remoteFrames.set(message.frameId, message.state); + } + if (refreshMain) { + updateStatus(); + if (editorExpanded) syncStyleControls(); + } + }; + + const relayChildStates = (refreshMain = true): void => { + for (const entry of childFrameStates.values()) relayChildState(entry, false); + if (isMainFrame && refreshMain) { + updateStatus(); + if (editorExpanded) syncStyleControls(); + } + }; + + const queueRelayChildStates = (): void => { + if (finished || childFrameStates.size === 0 || relayFrame !== null) return; + relayFrame = window.requestAnimationFrame(() => { + relayFrame = null; + relayChildStates(); + }); + }; + + const onFrameBridgeMessage = (event: MessageEvent): void => { + const message = event.data; + if (!message || message.source !== FRAME_BRIDGE_SOURCE || message.sessionId !== sessionId) { + return; + } + if (message.kind === "state") { + const owner = frameOwnerElements().find( + (candidate) => candidate.contentWindow === event.source, + ); + if (!owner?.contentWindow) return; + const entry = { sourceWindow: owner.contentWindow, message }; + childFrameStates.set(message.frameId, entry); + relayChildState(entry); + return; + } + if (message.kind === "marquee-selection") { + const fromChild = frameOwnerElements().some( + (candidate) => candidate.contentWindow === event.source, + ); + if (!fromChild) return; + if (!isMainFrame) { + window.parent.postMessage(message, "*"); + return; + } + const regionId = pendingMarqueeRegions.get(message.operationId); + if (!regionId) return; + pendingMarqueeRegions.delete(message.operationId); + const regionIndex = regions.findIndex((region) => region.id === regionId); + if (regionIndex < 0) return; + regions.splice(regionIndex, 1); + regionOrigins.get(regionId)?.node.remove(); + regionOrigins.delete(regionId); + updateStatus(); + return; + } + if (message.kind === "draw-gesture" || message.kind === "marquee-gesture") { + const owner = frameOwnerElements().find( + (candidate) => candidate.contentWindow === event.source, + ); + if (!owner?.contentWindow) return; + const ownerRect = owner.getBoundingClientRect(); + const borderScaleX = owner.offsetWidth > 0 ? ownerRect.width / owner.offsetWidth : 1; + const borderScaleY = owner.offsetHeight > 0 ? ownerRect.height / owner.offsetHeight : 1; + const contentX = ownerRect.left + owner.clientLeft * borderScaleX; + const contentY = ownerRect.top + owner.clientTop * borderScaleY; + const contentWidth = owner.clientWidth * borderScaleX; + const contentHeight = owner.clientHeight * borderScaleY; + const projected = { + ...message, + viewport: { width: window.innerWidth, height: window.innerHeight }, + point: { + x: + contentX + + message.point.x * + (message.viewport.width > 0 ? contentWidth / message.viewport.width : 1), + y: + contentY + + message.point.y * + (message.viewport.height > 0 ? contentHeight / message.viewport.height : 1), + }, + } satisfies FrameDrawGestureMessage | FrameMarqueeGestureMessage; + if (!isMainFrame) { + window.parent.postMessage(projected, "*"); + return; + } + if (projected.kind === "draw-gesture") applyDrawGesture(projected); + else applyMarqueeGesture(projected); + return; + } + if (message.kind === "tool-request") { + if (isMainFrame) { + applyFrameCommand({ kind: "tool", tool: message.tool }); + broadcastFrameCommand({ kind: "tool", tool: message.tool }); + } else { + window.parent.postMessage(message, "*"); + } + return; + } + if (event.source === window.parent && !isMainFrame) { + applyFrameCommand(message.command); + broadcastFrameCommand(message.command); + return; + } + if (isMainFrame) { + applyFrameCommand(message.command); + broadcastFrameCommand(message.command); + } else { + window.parent.postMessage(message, "*"); + } + }; + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { const margin = 8; const rect = editor.getBoundingClientRect(); @@ -826,10 +1597,11 @@ function startAnnotation(): void { unionRects( [ ...Array.from(selected.values(), (target) => - rectFromDomRect(target.element.getBoundingClientRect()), + clipToViewport(rectFromDomRect(target.element.getBoundingClientRect())), ), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), + ...regions.map((region) => clipToViewport(currentRegionRect(region))), + ...strokes.map((stroke) => clipToViewport(currentStroke(stroke).bounds)), + ...remoteTargetRects(), ], 0, ); @@ -863,6 +1635,7 @@ function startAnnotation(): void { }; function queueEditorLayout(): void { + if (!isMainFrame) return; if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); editorLayoutFrame = window.requestAnimationFrame(() => { editorLayoutFrame = null; @@ -873,17 +1646,17 @@ function startAnnotation(): void { } adjust.addEventListener("click", () => { - if (selected.size === 0) return; + if (selected.size === 0 && remoteTargets().length === 0) return; if (!editorExpanded) { const rect = editor.getBoundingClientRect(); editorExpanded = true; editorPosition = { left: rect.left, top: rect.top }; - stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + stylePanel.style.display = selected.size > 0 || remoteTargets().length > 0 ? "grid" : "none"; dragHandle.style.display = "block"; adjust.setAttribute("aria-expanded", "true"); adjust.title = "Collapse annotation editor"; adjust.setAttribute("aria-label", "Collapse annotation editor"); - if (selected.size > 0) syncStyleControls(); + syncStyleControls(); } else { editorExpanded = false; editorPosition = null; @@ -935,8 +1708,60 @@ function startAnnotation(): void { dragHandle.addEventListener("pointercancel", onEditorPointerUp); const repaint = (): void => { - for (const target of selected.values()) updateSelectedVisual(target); - queueEditorLayout(); + if (relayFrame !== null) window.cancelAnimationFrame(relayFrame); + relayFrame = null; + relayChildStates(false); + + const hoverRect = + tool === "select" && hoveredElement?.isConnected + ? clipToViewport(rectFromDomRect(hoveredElement.getBoundingClientRect())) + : null; + const selectedVisuals = Array.from(selected.values(), (target) => ({ + target, + rect: target.element.isConnected + ? clipToViewport(rectFromDomRect(target.element.getBoundingClientRect())) + : null, + })); + const regionVisuals = regions.flatMap((region) => { + const visual = regionOrigins.get(region.id); + return visual ? [{ visual, rect: clipToViewport(currentRegionRect(region)) }] : []; + }); + const strokeVisuals = strokes.flatMap((stroke) => { + const visual = strokeOrigins.get(stroke.id); + if (!visual) return []; + const current = currentStroke(stroke); + return [{ visual, current }]; + }); + + if (hoverRect) positionBox(hoverOutline, hoverRect); + else hoverOutline.style.display = "none"; + for (const { target, rect } of selectedVisuals) { + if (rect) updateSelectedVisual(target, rect); + else updateSelectedVisual(target); + } + for (const { visual, rect } of regionVisuals) positionBox(visual.node, rect); + for (const { visual, current } of strokeVisuals) { + visual.path.setAttribute("d", pathFromPoints(current.points)); + } + + updateStatus([ + ...selectedVisuals.flatMap(({ rect }) => (rect ? [rect] : [])), + ...regionVisuals.map(({ rect }) => rect), + ...strokeVisuals.map(({ current }) => clipToViewport(current.bounds)), + ]); + if (!isMainFrame) { + if (publishFrame !== null) window.cancelAnimationFrame(publishFrame); + publishFrame = null; + publishState(); + } + }; + + const queueRepaint = (): void => { + if (finished || repaintFrame !== null) return; + repaintFrame = window.requestAnimationFrame(() => { + repaintFrame = null; + repaint(); + }); }; const removeTargetAtPoint = (x: number, y: number): boolean => { @@ -947,38 +1772,50 @@ function startAnnotation(): void { return true; } } - const regionIndex = regions.findIndex( - (region) => - x >= region.rect.x && - x <= region.rect.x + region.rect.width && - y >= region.rect.y && - y <= region.rect.y + region.rect.height, - ); + const regionIndex = regions.findIndex((region) => { + const rect = currentRegionRect(region); + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; + }); if (regionIndex >= 0) { const [removed] = regions.splice(regionIndex, 1); - root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + if (removed) { + regionOrigins.get(removed.id)?.node.remove(); + regionOrigins.delete(removed.id); + } updateStatus(); + queuePublishState(); return true; } - const strokeIndex = strokes.findIndex( - (stroke) => - x >= stroke.bounds.x && - x <= stroke.bounds.x + stroke.bounds.width && - y >= stroke.bounds.y && - y <= stroke.bounds.y + stroke.bounds.height, - ); + const strokeIndex = strokes.findIndex((stroke) => { + const bounds = currentStroke(stroke).bounds; + return ( + x >= bounds.x && + x <= bounds.x + bounds.width && + y >= bounds.y && + y <= bounds.y + bounds.height + ); + }); if (strokeIndex >= 0) { const [removed] = strokes.splice(strokeIndex, 1); - svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + if (removed) { + strokeOrigins.get(removed.id)?.path.remove(); + strokeOrigins.delete(removed.id); + } updateStatus(); + queuePublishState(); return true; } return false; }; - const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + function selectElementsInRect(rect: PreviewAnnotationRect): ReadonlyArray { const candidates = Array.from(document.querySelectorAll("body *")) - .filter((element) => !isAnnotationNode(element)) + .filter( + (element) => + !isAnnotationNode(element) && + !(element instanceof HTMLIFrameElement) && + !(element instanceof HTMLFrameElement), + ) .map((element) => ({ element, rect: element.getBoundingClientRect() })) .filter(({ rect: candidate }) => { if (candidate.width < 2 || candidate.height < 2) return false; @@ -1007,44 +1844,58 @@ function startAnnotation(): void { (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, ) .slice(0, MAX_MARQUEE_ELEMENTS); - for (const candidate of candidates) addSelected(candidate.element); - return candidates.length; - }; + return candidates.map((candidate) => addSelected(candidate.element)); + } + + function applyMarqueeSelection( + command: FrameMarqueeSelectionCommand, + ): ReadonlyArray { + if (!command.additive) { + clearTargetsExcept(frameId, []); + if (isMainFrame) pendingMarqueeRegions.clear(); + } + const found = selectElementsInRect(command.rect); + if (found.length > 0 && !isMainFrame) { + window.parent.postMessage( + { + source: FRAME_BRIDGE_SOURCE, + kind: "marquee-selection", + sessionId, + operationId: command.operationId, + } satisfies FrameMarqueeSelectionMessage, + "*", + ); + } + return found; + } const clearHoverOutline = (): void => { + hoveredElement = null; hoverOutline.style.display = "none"; }; const onPointerMove = (event: PointerEvent): void => { + if (tool === "draw") { + sendDrawGesture("move", event); + return; + } + if (tool === "marquee") { + sendMarqueeGesture("move", event); + return; + } if (isAnnotationNode(event.target as Element)) { clearHoverOutline(); return; } - if (tool === "select" && dragStart === null) { + if (tool === "select") { const target = pickFromPoint(event.clientX, event.clientY); - if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); - else clearHoverOutline(); + if (target) { + hoveredElement = target; + positionBox(hoverOutline, clipToViewport(rectFromDomRect(target.getBoundingClientRect()))); + } else clearHoverOutline(); return; } clearHoverOutline(); - if (tool === "marquee" && dragStart) { - positionBox( - marqueeBox, - normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), - ); - return; - } - if (tool === "draw" && activeStroke) { - activeStroke.target.points = [ - ...activeStroke.target.points, - { x: event.clientX, y: event.clientY }, - ]; - activeStroke.target.bounds = strokeBounds( - activeStroke.target.points, - activeStroke.target.width, - ); - activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); - } }; const onPointerDown = (event: PointerEvent): void => { @@ -1060,56 +1911,33 @@ function startAnnotation(): void { removeTargetAtPoint(event.clientX, event.clientY); return; } - dragStart = { x: event.clientX, y: event.clientY }; if (tool === "draw") { - const stroke: PreviewAnnotationStrokeTarget = { - id: nextId("stroke"), - color: annotationTheme?.primary ?? "#2563eb", - width: 4, - points: [dragStart], - bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, - }; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute(OVERLAY_ATTRIBUTE, ""); - path.setAttribute("data-stroke-id", stroke.id); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", stroke.color); - path.setAttribute("stroke-width", String(stroke.width)); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - svg.appendChild(path); - activeStroke = { target: stroke, path }; + sendDrawGesture("start", event); + return; } + sendMarqueeGesture("start", event); }; const onPointerUp = (event: PointerEvent): void => { - if (!dragStart) return; - event.preventDefault(); - event.stopPropagation(); + if (tool === "draw") { + event.preventDefault(); + event.stopPropagation(); + sendDrawGesture("end", event); + return; + } if (tool === "marquee") { - const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); - marqueeBox.style.display = "none"; - if (isUsableRect(rect)) { - const found = selectElementsInRect(rect); - if (found === 0) { - const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; - regions.push(region); - const regionBox = createBox( - PRIMARY, - "color-mix(in srgb, var(--t3-primary) 6%, transparent)", - ); - regionBox.setAttribute("data-region-id", region.id); - positionBox(regionBox, rect); - root.appendChild(regionBox); - } - } - } else if (tool === "draw" && activeStroke) { - if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); - else activeStroke.path.remove(); - activeStroke = null; + event.preventDefault(); + event.stopPropagation(); + sendMarqueeGesture("end", event); } - dragStart = null; - updateStatus(); + }; + + const onPointerCancel = (event: PointerEvent): void => { + if (tool === "draw") { + sendDrawGesture("cancel", event); + return; + } + if (tool === "marquee") sendMarqueeGesture("cancel", event); }; const onClick = (event: MouseEvent): void => { @@ -1119,7 +1947,13 @@ function startAnnotation(): void { }; const onPointerOut = (event: PointerEvent): void => { - if (event.relatedTarget === null) clearHoverOutline(); + if ( + event.relatedTarget === null || + event.relatedTarget instanceof HTMLIFrameElement || + event.relatedTarget instanceof HTMLFrameElement + ) { + clearHoverOutline(); + } }; const onWindowBlur = (): void => { @@ -1137,26 +1971,53 @@ function startAnnotation(): void { } }; + const frameObserver = new MutationObserver(queueRelayChildStates); + const teardown = (notifyMain: boolean): void => { if (finished) return; + if (!isMainFrame) { + const message: FrameStateMessage = { + source: FRAME_BRIDGE_SOURCE, + kind: "state", + sessionId, + frameId, + viewport: { width: window.innerWidth, height: window.innerHeight }, + state: { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + elements: [], + regions: [], + strokes: [], + styleChanges: [], + }, + }; + window.parent.postMessage(message, "*"); + } finished = true; restoreStyles(); window.removeEventListener("pointermove", onPointerMove, true); window.removeEventListener("pointerdown", onPointerDown, true); window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointercancel", onPointerCancel, true); window.removeEventListener("pointerout", onPointerOut, true); window.removeEventListener("click", onClick, true); window.removeEventListener("blur", onWindowBlur); window.removeEventListener("keydown", onKeyDown, true); - window.removeEventListener("scroll", repaint, true); - window.removeEventListener("resize", repaint); + window.removeEventListener("scroll", queueRepaint, true); + window.removeEventListener("resize", queueRepaint); + window.removeEventListener("message", onFrameBridgeMessage); + frameObserver.disconnect(); dragHandle.removeEventListener("pointerdown", onEditorPointerDown); dragHandle.removeEventListener("pointermove", onEditorPointerMove); dragHandle.removeEventListener("pointerup", onEditorPointerUp); dragHandle.removeEventListener("pointercancel", onEditorPointerUp); if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + if (repaintFrame !== null) window.cancelAnimationFrame(repaintFrame); + if (publishFrame !== null) window.cancelAnimationFrame(publishFrame); + if (relayFrame !== null) window.cancelAnimationFrame(relayFrame); ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + ipcRenderer.off(ANNOTATION_SELECTION_CLAIMED_CHANNEL, onSelectionClaimed); document.documentElement.removeAttribute("data-t3code-annotation-tool"); cursorStyle.remove(); host.remove(); @@ -1180,17 +2041,39 @@ function startAnnotation(): void { else if (event.key === "e") tool = "erase"; else return; refreshToolButtons(); + if (isMainFrame) broadcastFrameCommand({ kind: "tool", tool }); + else { + window.parent.postMessage( + { + source: FRAME_BRIDGE_SOURCE, + kind: "tool-request", + sessionId, + tool, + } satisfies FrameToolRequestMessage, + "*", + ); + } }; submit.addEventListener("click", () => { - if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + const remoteStates = Array.from(remoteFrames.values()); + if ( + pendingCapture || + (selected.size === 0 && + regions.length === 0 && + strokes.length === 0 && + remoteStates.every( + (state) => + state.elements.length === 0 && state.regions.length === 0 && state.strokes.length === 0, + )) + ) return; pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; void Promise.all( Array.from(selected.values()).map(async (target) => { - const element = await captureElement(target.element); + const element = await target.capture; if (!element) return null; for (const change of styleChanges.values()) { if (change.targetId === target.id) change.selector = element.selector; @@ -1198,20 +2081,42 @@ function startAnnotation(): void { return { id: target.id, element, - rect: rectFromDomRect(target.element.getBoundingClientRect()), + rect: clipToViewport(rectFromDomRect(target.element.getBoundingClientRect())), }; }), ).then((captured) => { - const elements = captured.filter((target) => target !== null); + const elements = [ + ...captured.filter((target) => target !== null), + ...remoteStates.flatMap((state) => + state.elements.map(({ computedStyles: _, ...target }) => target), + ), + ]; + const currentRegions = [ + ...regions.map((region) => ({ + ...region, + rect: clipToViewport(currentRegionRect(region)), + })), + ...remoteStates.flatMap((state) => state.regions), + ]; + const currentStrokes = [ + ...strokes.map((stroke) => { + const current = currentStroke(stroke); + return { ...current, bounds: clipToViewport(current.bounds) }; + }), + ...remoteStates.flatMap((state) => state.strokes), + ]; const annotation: PreviewAnnotationPayload = { id: nextId("annotation"), pageUrl: location.href, pageTitle: document.title?.trim() || null, comment: comment.value.trim(), elements, - regions: [...regions], - strokes: [...strokes], - styleChanges: Array.from(styleChanges.values()), + regions: currentRegions, + strokes: currentStrokes, + styleChanges: [ + ...styleChanges.values(), + ...remoteStates.flatMap((state) => state.styleChanges), + ], screenshot: null, createdAt: new Date().toISOString(), }; @@ -1220,8 +2125,8 @@ function startAnnotation(): void { hoverOutline.style.display = "none"; const screenshotRect = unionRects([ ...elements.map((target) => target.rect), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), + ...currentRegions.map((region) => region.rect), + ...currentStrokes.map((stroke) => stroke.bounds), ]); ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); }); @@ -1235,14 +2140,18 @@ function startAnnotation(): void { window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointercancel", onPointerCancel, { capture: true, passive: false }); window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); window.addEventListener("click", onClick, { capture: true, passive: false }); window.addEventListener("blur", onWindowBlur); window.addEventListener("keydown", onKeyDown, { capture: true }); - window.addEventListener("scroll", repaint, { capture: true, passive: true }); - window.addEventListener("resize", repaint, { passive: true }); + window.addEventListener("scroll", queueRepaint, { capture: true, passive: true }); + window.addEventListener("resize", queueRepaint, { passive: true }); + window.addEventListener("message", onFrameBridgeMessage); + frameObserver.observe(document.documentElement, { childList: true, subtree: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + ipcRenderer.on(ANNOTATION_SELECTION_CLAIMED_CHANNEL, onSelectionClaimed); document.documentElement.appendChild(host); refreshToolButtons(); updateStatus(); @@ -1250,12 +2159,21 @@ function startAnnotation(): void { teardown, applyTheme: (theme) => applyAnnotationTheme(host, theme), }; + queuePublishState(); } -ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { - if (theme) annotationTheme = theme; - startAnnotation(); -}); +ipcRenderer.on( + START_PICK_CHANNEL, + ( + _event, + theme: DesktopPreviewAnnotationTheme | undefined, + sessionId: string, + frameId: string, + ) => { + if (theme) annotationTheme = theme; + startAnnotation(sessionId, frameId); + }, +); ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { annotationTheme = theme; activeSession?.applyTheme(theme); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c808c77e51c..e8c074036c6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -286,7 +286,7 @@ export const make = Effect.gen(function* () { } webPreferences.sandbox = true; webPreferences.nodeIntegration = false; - webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.nodeIntegrationInSubFrames = true; webPreferences.contextIsolation = false; }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index c729fc20ece..42d36f5c07c 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -89,7 +89,7 @@ export const PreviewResizeTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.", + "Inspect a page before interacting. Returns every frame with its frameIndex, page text, semantic elements, diagnostics, action history, and a PNG screenshot.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationSnapshot, failure: PreviewAutomationError, @@ -100,7 +100,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( export const PreviewClickTool = browserTool( Tool.make("preview_click", { description: - "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", + "Click exactly one target. Locator/selector targets are searched across every frame unless frameIndex is supplied; x and y target the main viewport.", parameters: PreviewAutomationClickInput, success: Schema.Null, failure: PreviewAutomationError, @@ -111,7 +111,7 @@ export const PreviewClickTool = browserTool( export const PreviewTypeTool = browserTool( Tool.make("preview_type", { description: - "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", + "Insert literal text into one input, searching every frame unless frameIndex is supplied. Prefer a Playwright locator; set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, success: Schema.Null, failure: PreviewAutomationError, @@ -122,7 +122,7 @@ export const PreviewTypeTool = browserTool( export const PreviewPressTool = browserTool( Tool.make("preview_press", { description: - "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", + "Press one keyboard key in the active frame, or focus frameIndex first. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", parameters: PreviewAutomationPressInput, success: Schema.Null, failure: PreviewAutomationError, @@ -133,7 +133,7 @@ export const PreviewPressTool = browserTool( export const PreviewScrollTool = safeBrowserTool( Tool.make("preview_scroll", { description: - "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", + "Scroll a frame viewport or container. Locator/selector targets are searched across every frame; frameIndex targets a specific frame.", parameters: PreviewAutomationScrollInput, success: Schema.Null, failure: PreviewAutomationError, @@ -144,7 +144,7 @@ export const PreviewScrollTool = safeBrowserTool( export const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.", + "Evaluate JavaScript in frameIndex (main frame 0 by default). Returns a serializable result up to 64 KB; the expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, success: Schema.Unknown, failure: PreviewAutomationError, @@ -155,7 +155,7 @@ export const PreviewEvaluateTool = browserTool( export const PreviewWaitForTool = readonlyBrowserTool( Tool.make("preview_wait_for", { description: - "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", + "Wait until one frame satisfies every supplied locator, selector, text, and URL condition; frameIndex restricts the search.", parameters: PreviewAutomationWaitForInput, success: Schema.Null, failure: PreviewAutomationError, diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index f1723dd93aa..33f5025198c 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -103,9 +103,9 @@ export async function previewAnnotationScreenshotFile( annotation: PreviewAnnotationPayload, ): Promise { if (!annotation.screenshot) return null; - const response = await fetch(annotation.screenshot.dataUrl); - const blob = await response.blob(); - return new File([blob], `preview-annotation-${annotation.id}.png`, { - type: blob.type || "image/png", + const payload = annotation.screenshot.dataUrl.slice("data:image/png;base64,".length); + const bytes = Uint8Array.from(atob(payload), (character) => character.charCodeAt(0)); + return new File([bytes], `preview-annotation-${annotation.id}.png`, { + type: "image/png", }); } diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 6431dd0dcfd..d7bd5f4afc4 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -62,6 +62,15 @@ const PreviewAutomationTabTargetFields = { }), }; +const PreviewAutomationFrameTargetFields = { + frameIndex: Schema.optional( + Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ + description: + "Frame index from preview_snapshot. Omit to search every frame for selector-based actions; other actions default to the main frame (index 0).", + }), + ), +}; + export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; @@ -266,6 +275,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({ export const PreviewAutomationClickInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, + ...PreviewAutomationFrameTargetFields, selector: Schema.optional(LegacySelector).annotate({ description: "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", @@ -294,6 +304,9 @@ export const PreviewAutomationClickInput = Schema.Struct({ const hasY = input.y !== undefined; if (hasX !== hasY) return "Coordinates require both x and y."; const coordinateModes = hasX && hasY ? 1 : 0; + if (coordinateModes === 1 && input.frameIndex !== undefined) { + return "Coordinate clicks do not accept frameIndex."; + } return selectorModes + coordinateModes === 1 || "Provide exactly one click target."; }), ) @@ -305,6 +318,7 @@ export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Typ export const PreviewAutomationTypeInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, + ...PreviewAutomationFrameTargetFields, text: Schema.String.annotate({ description: "Literal text to insert." }), selector: Schema.optional(LegacySelector).annotate({ description: "Legacy CSS selector for the input. Prefer locator.", @@ -335,6 +349,7 @@ export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; export const PreviewAutomationPressInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, + ...PreviewAutomationFrameTargetFields, key: Schema.String.check(Schema.isTrimmed()) .check( Schema.isNonEmpty({ @@ -356,6 +371,7 @@ export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Typ export const PreviewAutomationScrollInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, + ...PreviewAutomationFrameTargetFields, deltaX: Schema.optional( Schema.Finite.annotate({ description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.", @@ -391,17 +407,18 @@ export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.T export const PreviewAutomationEvaluateInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, + ...PreviewAutomationFrameTargetFields, expression: Schema.String.check(Schema.isTrimmed()) .check( Schema.isNonEmpty({ description: - "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", + "JavaScript expression evaluated in the selected frame, defaulting to the main frame, for example document.title or (() => ({href: location.href}))().", }), ) .check(Schema.isMaxLength(64_000)) .annotateKey({ description: - "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", + "JavaScript expression evaluated in the selected frame, defaulting to the main frame, for example document.title or (() => ({href: location.href}))().", }), awaitPromise: Schema.optional( Schema.Boolean.annotate({ description: "Await a returned Promise. Defaults to true." }), @@ -420,6 +437,7 @@ export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInp export const PreviewAutomationWaitForInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, + ...PreviewAutomationFrameTargetFields, selector: Schema.optional(LegacySelector).annotate({ description: "Legacy CSS selector that must match an element. Prefer locator.", }), @@ -462,6 +480,7 @@ export const PreviewAutomationWaitForInput = Schema.Struct({ export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; export const PreviewAutomationElement = Schema.Struct({ + frameIndex: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), tag: Schema.String, role: Schema.NullOr(Schema.String), name: Schema.String, @@ -473,6 +492,13 @@ export const PreviewAutomationElement = Schema.Struct({ }); export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; +export const PreviewAutomationFrame = Schema.Struct({ + index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + url: Schema.String, + name: Schema.String, +}); +export type PreviewAutomationFrame = typeof PreviewAutomationFrame.Type; + export const PreviewAutomationConsoleEntry = Schema.Struct({ level: Schema.String, text: Schema.String, @@ -505,6 +531,7 @@ export const PreviewAutomationSnapshot = Schema.Struct({ url: Schema.String, title: Schema.String, loading: Schema.Boolean, + frames: Schema.Array(PreviewAutomationFrame), visibleText: Schema.String, interactiveElements: Schema.Array(PreviewAutomationElement), accessibilityTree: Schema.Unknown,