diff --git a/.changeset/design-layout-audit.md b/.changeset/design-layout-audit.md new file mode 100644 index 000000000000..bbfb1ff160a3 --- /dev/null +++ b/.changeset/design-layout-audit.md @@ -0,0 +1,8 @@ +--- +"@reddb-io/redcode": minor +"@reddb-io/redcode-core": minor +--- + +Design mode: a passive layout audit with an inbox the person triages + +The prototype now audits its own layout after fonts, geometry and finite animations settle: text clipped by its container, controls cut off or outside the viewport, text off-screen, a page that scrolls sideways, text covered by an opaque sibling. Findings survive only if two samples agree, and every pass reports its own completeness. They land in a "Layout issues" inbox on the review page — badge, drawer, select, queue, dismiss, reveal — and nothing in it reaches the agent until the person queues it, when it becomes one ordinary note. A warning is cleared only by a complete pass on a newer revision that no longer finds it; a failed pass, a different viewport or a reload in flight never clears anything, and a dismissal lasts one revision. Every frame load is named by a token so a pass from a replaced frame is discarded. The page holds the prototype behind a short curtain until its first pass (`experimental.design.gate`, `gate_timeout`, or `?gate=0` for one tab), asks the server whether the document can be served when the frame stays silent, and the one report that does wake the agent unasked is a prototype that cannot be shown at all (``). Viewport classes can be narrowed with `experimental.design.viewports`; a class left out has its warnings marked obsolete rather than resolved. diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 31e24189f4d5..46b33850cce2 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -241,6 +241,17 @@ export const Info = Schema.Struct({ }), }), ).annotate({ description: "Limits on images attached to design-review notes." }), + viewports: Schema.optional(Schema.Array(Schema.Literals(["mobile", "compact", "desktop"]))).annotate({ + description: + "Viewport classes the browser's layout audit reports on (default: all three). A class left out is never re-checked, and its warnings are marked obsolete.", + }), + gate: Schema.optional(Schema.Boolean).annotate({ + description: + "Hold the prototype behind a curtain until its first layout pass, so a person never sees a half-laid-out page (default: true; ?gate=0 on the review URL disables it for one tab).", + }), + gate_timeout: Schema.optional(PositiveInt).annotate({ + description: "Milliseconds the gate may hold the prototype before revealing it anyway (default: 12000, at most 60000).", + }), }), ).annotate({ description: "Design mode: the review surface and its stores." }), subtask_concurrency: Schema.optional(PositiveInt).annotate({ diff --git a/packages/redcode/src/design/client/artifact.ts b/packages/redcode/src/design/client/artifact.ts index 297c149c636e..ef3bcd0068ef 100644 --- a/packages/redcode/src/design/client/artifact.ts +++ b/packages/redcode/src/design/client/artifact.ts @@ -15,6 +15,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type * as Helpers from "./helpers" +import type { artifactAudit } from "./audit" export interface ArtifactConfig { /** The revision this document was served for; every message carries it back. */ @@ -27,6 +28,9 @@ export type HelperTable = { readonly [K in keyof typeof Helpers as (typeof Helpers)[K] extends (...args: any[]) => any ? K : never]: (typeof Helpers)[K] +} & { + /** The layout audit, declared beside the helpers; it takes the table itself. */ + readonly artifactAudit: typeof artifactAudit } export function artifactMain(config: ArtifactConfig, h: HelperTable) { @@ -862,6 +866,11 @@ export function artifactMain(config: ArtifactConfig, h: HelperTable) { case "restoreReviewState": restoreReviewState(payload.state) return + case "requestLayoutDiagnostics": + // The shell wants fresh evidence (it opened the inbox, or a pass was lost to a load + // race): run again and publish even if nothing changed. + audit.schedule(true) + return case "attachmentResult": // Only from the shell, and only for this document: a result for a chip of the previous // document must not mark a new chip ready with the wrong image. @@ -894,6 +903,33 @@ export function artifactMain(config: ArtifactConfig, h: HelperTable) { scheduleReviewStateReport() }) + // --- the passive layout audit, and the one fatal path ----------------------------------------- + const audit = h.artifactAudit({ h, post, isUi, selector, load: config.load }) + // A local subresource the prototype declares but the server cannot serve makes the review + // unusable rather than merely flawed, so it bypasses the passive inbox. Only same-origin + // references count: a remote host failing is the viewer's network, not the prototype's defect. + window.addEventListener( + "error", + (event) => { + const el = event.target as any + if (!(el instanceof Element) || isUi(el)) return + const tag = String(el.tagName || "").toLowerCase() + if (!["img", "script", "link", "source", "video", "audio", "iframe"].includes(tag)) return + const raw = String(el.getAttribute("src") || el.getAttribute("href") || "") + if (!raw) return + let resolved: URL + try { + resolved = new URL(raw, document.baseURI) + } catch { + return + } + if (resolved.origin !== window.location.origin) return + post("artifactAssetFailure", { detail: "<" + tag + "> could not load " + resolved.pathname }) + }, + true, + ) + audit.start() + const icon = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null setAnnotationMode(true) post("ready", { diff --git a/packages/redcode/src/design/client/audit.ts b/packages/redcode/src/design/client/audit.ts new file mode 100644 index 000000000000..325c219d9fff --- /dev/null +++ b/packages/redcode/src/design/client/audit.ts @@ -0,0 +1,769 @@ +/** + * The passive layout audit, run inside the prototype. + * + * Shipped as the text of `artifactAudit` (see `sdk.ts`), so it must be self-contained: it reads + * its helpers and its outlet off the `deps` parameter and touches nothing else in module scope. + * + * It measures what the browser actually laid out — text fragments, control boxes, the page's + * scroll width — after fonts, geometry and finite animations settle, samples twice and keeps only + * what both samples agree on, and reports the result to the shell as a diagnostic pass. It never + * decides anything: a pass is evidence the inbox weighs, and an incomplete pass says so about + * itself. Every suppression here (diagrams, visually-hidden text, intentional truncation, real + * scrollers, elements in motion) is a way of staying silent rather than being wrong. + * + * Ported from lavish-axi's artifact SDK with the same timings and thresholds. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { HelperTable } from "./artifact" + +export interface AuditDeps { + readonly h: HelperTable + /** Send a message to the shell; the SDK stamps the load on it. */ + readonly post: (type: string, payload?: Record) => void + /** Our own UI, which is never audited. */ + readonly isUi: (el: any) => boolean + readonly selector: (el: any) => string + /** The revision this document was served for, echoed on every pass. */ + readonly load: number +} + +export function artifactAudit(deps: AuditDeps) { + const h = deps.h + const SETTLE_MS = 180 + const MAX_WAIT_MS = 2000 + const ANIMATION_MAX_WAIT_MS = 4000 + const STABLE_SAMPLE_MS = 120 + const MAX_ELEMENTS = 800 + + let run = 0 + let timer = 0 + let passSequence = 0 + let publishRequested = false + let lastSignature = "" + + type Finding = { + selector: string + kind: string + axis: "horizontal" | "vertical" + overflowPx: number + viewportWidth: number + severity: "error" + } + type Rect = { left: number; right: number; top: number; bottom: number; width?: number; height?: number } + + const toPx = (value: unknown) => { + const parsed = Number.parseFloat(String(value || "0")) + return Number.isFinite(parsed) ? parsed : 0 + } + const rounded = (value: number) => Math.round(Math.max(0, value) * 10) / 10 + const elementText = (el: any) => + String((el && (el.innerText || el.textContent)) || "") + .trim() + .replace(/\s+/g, " ") + const directText = (el: any) => + Array.from((el && el.childNodes) || []) + .filter((node: any) => node.nodeType === 3) + .map((node: any) => String(node.textContent || "")) + .join(" ") + .trim() + .replace(/\s+/g, " ") + + const isRequiredControl = (el: any) => { + if ( + !el || + !el.matches || + !el.matches("button,input,select,textarea,a[href],summary,[data-redcode-action],[data-lavish-action],[role]") + ) + return false + if (el.matches("input[type='hidden'],[disabled],[aria-disabled='true']")) return false + if (!el.hasAttribute("role")) return true + return ["button", "link", "checkbox", "radio", "switch", "textbox", "combobox"].includes( + String(el.getAttribute("role") || "").toLowerCase(), + ) + } + const isSemanticTextBoundary = (el: any) => + !!( + el && + el.matches && + el.matches( + "p,h1,h2,h3,h4,h5,h6,button,label,a[href],li,dt,dd,th,td,legend,figcaption,summary,[role='button'],[role='link'],[role='alert'],[role='status']", + ) + ) + const hasSemanticTextBoundaryAncestor = (el: any) => { + let node = el && el.parentElement + while (node && node !== document.body && node !== document.documentElement) { + if (isSemanticTextBoundary(node)) return true + node = node.parentElement + } + return false + } + const auditedText = (el: any) => (isSemanticTextBoundary(el) ? elementText(el) : directText(el)) + const rectArea = (rect: DOMRect) => Math.max(0, rect.width) * Math.max(0, rect.height) + + const isVisible = (el: any, rect: DOMRect = el.getBoundingClientRect()) => { + if (!el || deps.isUi(el) || rect.width <= 0 || rect.height <= 0) return false + let node = el + while (node && node.nodeType === 1) { + const style = getComputedStyle(node) + const opacity = Number.parseFloat(style.opacity || "1") + if ( + style.display === "none" || + style.visibility === "hidden" || + (style as any).contentVisibility === "hidden" || + (Number.isFinite(opacity) && opacity <= 0.01) + ) + return false + node = node.parentElement + } + return true + } + const isHorizontalScroller = (el: any) => { + if (!el || el === document.body || el === document.documentElement) return false + const overflowX = getComputedStyle(el).overflowX + return overflowX === "auto" || overflowX === "scroll" + } + const isVerticalScroller = (el: any) => { + if (!el || el === document.body || el === document.documentElement) return false + const overflowY = getComputedStyle(el).overflowY + return overflowY === "auto" || overflowY === "scroll" + } + const hasHorizontalScrollerAncestor = (el: any) => { + let node = el + while (node && node.nodeType === 1 && node !== document.body && node !== document.documentElement) { + if (isHorizontalScroller(node)) return true + node = node.parentElement + } + return false + } + const hasReachableVerticalScrollerAncestor = (el: any) => { + let node = el && el.parentElement + while (node && node !== document.body && node !== document.documentElement) { + if (isVerticalScroller(node)) { + const rect = node.getBoundingClientRect() + if (rect.bottom > 0 && rect.top < (window.innerHeight || 0)) return true + } + node = node.parentElement + } + return false + } + const rootVerticalScrollLocked = () => + [document.documentElement, document.body] + .filter(Boolean) + .map((node) => getComputedStyle(node).overflowY) + .some((value) => value === "hidden" || value === "clip") + const paddingBox = (el: any): Rect => { + const rect = el.getBoundingClientRect() + const style = getComputedStyle(el) + return { + left: rect.left + toPx(style.borderLeftWidth), + right: rect.right - toPx(style.borderRightWidth), + top: rect.top + toPx(style.borderTopWidth), + bottom: rect.bottom - toPx(style.borderBottomWidth), + } + } + const textNodes = (el: any) => { + const descendants = isSemanticTextBoundary(el) + const nodes: any[] = [] + const pending: any[] = Array.from((el && el.childNodes) || []) + while (pending.length > 0) { + const node = pending.shift() + if (!node) continue + if (node.nodeType === 3) { + if (String(node.textContent || "").trim()) nodes.push(node) + } else if (descendants && node.nodeType === 1) { + pending.unshift(...Array.from(node.childNodes || [])) + } + } + return nodes + } + const textFragments = (el: any): DOMRect[] => { + const fragments: DOMRect[] = [] + for (const node of textNodes(el)) { + const range = document.createRange() + range.selectNodeContents(node) + fragments.push(...Array.from(range.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0)) + if (range.detach) range.detach() + } + return fragments + } + const isTruncation = (style: CSSStyleDeclaration) => + style.textOverflow === "ellipsis" || Number.parseInt((style as any).webkitLineClamp || "0", 10) > 0 + const hasVisualMask = (style: CSSStyleDeclaration) => { + const maskImage = String((style as any).maskImage || (style as any).webkitMaskImage || "none").toLowerCase() + const clipPath = String(style.clipPath || "none").toLowerCase() + return (maskImage !== "none" && maskImage !== "") || (clipPath !== "none" && clipPath !== "") + } + const isRoundedOverflowMask = (style: CSSStyleDeclaration) => { + const clips = + style.overflowX === "hidden" || + style.overflowX === "clip" || + style.overflowY === "hidden" || + style.overflowY === "clip" + if (!clips) return false + return [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomRightRadius, + style.borderBottomLeftRadius, + ].some((value) => toPx(value) > 0) + } + const isDiagram = (el: any) => + !!(el && el.closest && el.closest(".mermaid,svg,[data-redcode-mermaid],[data-lavish-mermaid],[data-redcode-ui],[data-lavish-ui]")) + const hasVisualMaskAncestor = (el: any) => { + let node = el + while (node && node.nodeType === 1) { + const style = getComputedStyle(node) + if (hasVisualMask(style) || isRoundedOverflowMask(style)) return true + node = node.parentElement + } + return false + } + const clippingBoundaries = (el: any) => { + const boundaries: { el: any; box: Rect; axes: ("horizontal" | "vertical")[] }[] = [] + let node = el && el.parentElement + while (node && node !== document.body && node !== document.documentElement) { + const style = getComputedStyle(node) + const axes: ("horizontal" | "vertical")[] = [] + if (style.overflowX === "hidden" || style.overflowX === "clip") axes.push("horizontal") + if (style.overflowY === "hidden" || style.overflowY === "clip") axes.push("vertical") + if (axes.length > 0 && !hasVisualMask(style) && !isRoundedOverflowMask(style)) { + boundaries.push({ el: node, box: paddingBox(node), axes }) + } + node = node.parentElement + } + return boundaries + } + const isStandardVisuallyHidden = (style: CSSStyleDeclaration, rect: DOMRect) => { + const positioned = style.position === "absolute" || style.position === "fixed" + const clipped = style.overflowX === "hidden" || style.overflowX === "clip" + const legacyClip = String(style.clip || "").toLowerCase() + const clipPath = String(style.clipPath || "").toLowerCase() + const hasClip = legacyClip !== "auto" || (clipPath !== "none" && clipPath !== "") + return positioned && clipped && rect.width <= 2 && rect.height <= 2 && (style.whiteSpace === "nowrap" || hasClip) + } + const hasVisuallyHiddenAncestor = (el: any) => { + let node = el + while (node && node.nodeType === 1) { + if (isStandardVisuallyHidden(getComputedStyle(node), node.getBoundingClientRect())) return true + node = node.parentElement + } + return false + } + const isExcluded = (el: any) => isDiagram(el) || hasVisualMaskAncestor(el) || hasVisuallyHiddenAncestor(el) + const collectElements = () => + Array.from((document.body && document.body.querySelectorAll("*")) || []) + .filter((el) => el instanceof Element && !deps.isUi(el)) + .slice(0, MAX_ELEMENTS) + + const push = (findings: Finding[], seen: Set, finding: Partial) => { + if (finding.severity !== "error") return + const sel = finding.selector || "" + const axis: "horizontal" | "vertical" = finding.axis === "vertical" ? "vertical" : "horizontal" + const key = finding.kind + ":" + sel + ":" + axis + if (seen.has(key)) return + seen.add(key) + findings.push({ + selector: sel, + kind: String(finding.kind || "layout-failure"), + axis, + overflowPx: rounded(Number(finding.overflowPx) || 0), + viewportWidth: Math.round(Number(finding.viewportWidth) || window.innerWidth || 0), + severity: "error", + }) + } + + // --- animations: what is moving is not audited, and finite motion is waited for ---------------- + const animationTarget = (animation: Animation): Element | null => { + const target = (animation.effect as any) && (animation.effect as any).target + if (target instanceof Element) return target + return target && target.element instanceof Element ? target.element : null + } + const activeAnimations = () => { + if (typeof document.getAnimations !== "function") return [] + return document + .getAnimations() + .filter((animation) => ["running", "pending"].includes(String(animation.playState))) + .filter((animation) => !deps.isUi(animationTarget(animation))) + } + const activeAnimationTargets = () => activeAnimations().map(animationTarget).filter(Boolean) as Element[] + const inMotion = (el: any, targets: Element[]) => + targets.some((target) => target === el || target.contains(el) || el.contains(target)) + + // --- the rules -------------------------------------------------------------------------------- + const auditTextOverflow = ( + el: any, + viewportWidth: number, + findings: Finding[], + seen: Set, + targets: Element[], + failedRoots: any[], + ) => { + if (el === document.body || el === document.documentElement) return + if (isExcluded(el)) return + if (!auditedText(el)) return + if (!isSemanticTextBoundary(el) && hasSemanticTextBoundaryAncestor(el)) return + if (failedRoots.some((root) => root.contains(el))) return + if (inMotion(el, targets)) return + const rect = el.getBoundingClientRect() + if (!isVisible(el, rect)) return + const style = getComputedStyle(el) + const fragments = textFragments(el) + let severe = h.classifySevereTextOverflow({ + fragments, + box: paddingBox(el), + overflowX: style.overflowX, + overflowY: style.overflowY, + isTruncated: isTruncation(style), + isVisuallyHidden: false, + }) + let failureRoot = el + for (const boundary of clippingBoundaries(el)) { + const ancestorFailure = h.classifySevereTextOverflow({ + fragments, + box: boundary.box, + overflowX: boundary.axes.includes("horizontal") ? "hidden" : "auto", + overflowY: boundary.axes.includes("vertical") ? "hidden" : "auto", + isTruncated: isTruncation(style), + isVisuallyHidden: false, + }) + if (ancestorFailure && (!severe || ancestorFailure.overflowPx > severe.overflowPx)) { + severe = ancestorFailure + failureRoot = boundary.el + } + } + if (!severe) return + failedRoots.push(failureRoot) + push(findings, seen, { + selector: deps.selector(failureRoot), + kind: severe.kind, + axis: severe.axis, + overflowPx: severe.overflowPx, + viewportWidth, + severity: "error", + }) + } + + const escapesViewport = (rect: Rect | DOMRect, viewportWidth: number, minOutsidePx: number) => + h.classifyMaterialRectEscape({ + rect, + boundary: { left: 0, right: viewportWidth, top: 0, bottom: window.innerHeight || 0 }, + axes: ["horizontal"], + minOutsidePx, + }) + + const hasMaterialViewportEscape = (el: any, viewportWidth: number, targets: Element[]) => { + if (hasHorizontalScrollerAncestor(el)) return false + if (inMotion(el, targets)) return false + if (isExcluded(el)) return false + if (!isSemanticTextBoundary(el) && hasSemanticTextBoundaryAncestor(el)) return false + const rect = el.getBoundingClientRect() + if (!isVisible(el, rect)) return false + const style = getComputedStyle(el) + const positioned = style.position === "absolute" || style.position === "fixed" || style.position === "sticky" + if (positioned && !isRequiredControl(el)) return false + if (isRequiredControl(el)) { + const escape = escapesViewport(rect, viewportWidth, 4) + return !!escape && escape.side === "end" + } + if (!auditedText(el)) return false + const materialPx = Math.max(24, viewportWidth * 0.05) + return textFragments(el).some((fragment) => { + const escape = escapesViewport(fragment, viewportWidth, materialPx) + return !!escape && escape.side === "end" + }) + } + + const auditUnreachableLeftText = ( + el: any, + viewportWidth: number, + findings: Finding[], + seen: Set, + targets: Element[], + ) => { + if (hasHorizontalScrollerAncestor(el)) return + if (inMotion(el, targets)) return + if (isExcluded(el)) return + if (!isSemanticTextBoundary(el) && hasSemanticTextBoundaryAncestor(el)) return + if (!auditedText(el)) return + const rect = el.getBoundingClientRect() + if (!isVisible(el, rect)) return + const style = getComputedStyle(el) + if (["absolute", "fixed", "sticky"].includes(style.position) && !isRequiredControl(el)) return + const materialPx = Math.max(24, viewportWidth * 0.05) + let escape: ReturnType = null + for (const fragment of textFragments(el)) { + const candidate = escapesViewport(fragment, viewportWidth, materialPx) + if (candidate && candidate.side === "start" && (!escape || candidate.overflowPx > escape.overflowPx)) + escape = candidate + } + if (!escape) return + push(findings, seen, { + selector: deps.selector(el), + kind: "viewport-unreachable-content", + axis: "horizontal", + overflowPx: escape.overflowPx, + viewportWidth, + severity: "error", + }) + } + + const auditControlBounds = ( + el: any, + viewportWidth: number, + findings: Finding[], + seen: Set, + targets: Element[], + failedRoots: any[], + ) => { + if (!isRequiredControl(el) || isExcluded(el)) return + if (inMotion(el, targets)) return + const rect = el.getBoundingClientRect() + if (!isVisible(el, rect)) return + + let clipped: { boundary: { el: any; box: Rect; axes: ("horizontal" | "vertical")[] }; escape: any } | null = + null + for (const boundary of clippingBoundaries(el)) { + const escape = h.classifyMaterialRectEscape({ rect, boundary: boundary.box, axes: boundary.axes }) + if (escape && (!clipped || escape.overflowPx > clipped.escape.overflowPx)) clipped = { boundary, escape } + } + if (clipped && !failedRoots.some((root) => root === clipped!.boundary.el || root.contains(clipped!.boundary.el))) { + failedRoots.push(clipped.boundary.el) + push(findings, seen, { + selector: deps.selector(clipped.boundary.el), + kind: "clipped-control", + axis: clipped.escape.axis, + overflowPx: clipped.escape.overflowPx, + viewportWidth, + severity: "error", + }) + } + + const horizontal = hasHorizontalScrollerAncestor(el) ? null : escapesViewport(rect, viewportWidth, 4) + if (horizontal && horizontal.side === "start") { + push(findings, seen, { + selector: deps.selector(el), + kind: "viewport-unreachable-control", + axis: "horizontal", + overflowPx: horizontal.overflowPx, + viewportWidth, + severity: "error", + }) + } + + const style = getComputedStyle(el) + const fixedToViewport = style.position === "fixed" || style.position === "sticky" + const lockedToViewport = rootVerticalScrollLocked() && !hasReachableVerticalScrollerAncestor(el) + const scrollY = Number(window.scrollY || window.pageYOffset || 0) + const verticalRect = + fixedToViewport || lockedToViewport + ? rect + : { top: rect.top + scrollY, bottom: rect.bottom + scrollY, height: rect.height } + const verticalBoundary = + fixedToViewport || lockedToViewport + ? { top: 0, bottom: window.innerHeight || 0 } + : { top: 0, bottom: document.documentElement.scrollHeight } + const vertical = h.classifyMaterialRectEscape({ rect: verticalRect, boundary: verticalBoundary, axes: ["vertical"] }) + if (vertical) { + push(findings, seen, { + selector: deps.selector(el), + kind: "viewport-unreachable-control", + axis: "vertical", + overflowPx: vertical.overflowPx, + viewportWidth, + severity: "error", + }) + } + } + + const backgroundIsOpaque = (el: any) => { + const style = getComputedStyle(el) + if (Number.parseFloat(style.opacity || "1") < 0.95) return false + const color = String(style.backgroundColor || "") + .trim() + .toLowerCase() + if (!color || color === "transparent") return false + const rgba = color.match(/^rgba?\(([^)]+)\)$/) + if (!rgba) return false + const parts = rgba[1]!.split(/[\s,/]+/).filter(Boolean) + if (parts.length < 4) return true + const alpha = Number(parts[3]) + return Number.isFinite(alpha) && alpha >= 0.95 + } + const effectiveOpacityTo = (node: any, stopParent: any) => { + let opacity = 1 + let current = node + while (current && current !== stopParent) { + const value = Number.parseFloat(getComputedStyle(current).opacity || "1") + if (Number.isFinite(value)) opacity *= value + current = current.parentElement + } + return opacity + } + const opaqueSiblingBlocker = (el: any, point: { x: number; y: number }, targets: Element[]) => { + const top = document.elementFromPoint(point.x, point.y) + if (!(top instanceof Element) || top === el || el.contains(top) || top.contains(el) || deps.isUi(top)) return null + const ancestors: any[] = [] + let targetNode = el + while (targetNode && targetNode !== document.body && targetNode !== document.documentElement) { + ancestors.push(targetNode) + targetNode = targetNode.parentElement + } + let node: any = top + let foundOpaqueSurface = false + while (node && node !== document.body && node !== document.documentElement) { + if (inMotion(node, targets)) return null + if (backgroundIsOpaque(node)) foundOpaqueSurface = true + const siblingOf = ancestors.find((target) => target.parentElement === node.parentElement) + if (siblingOf && foundOpaqueSurface && effectiveOpacityTo(top, node.parentElement) >= 0.95) return node + node = node.parentElement + } + return null + } + const samplePoints = (fragment: DOMRect) => { + const ratios = [0.2, 0.5, 0.8] + return ratios.flatMap((xr) => + ratios.map((yr) => ({ x: fragment.left + fragment.width * xr, y: fragment.top + fragment.height * yr })), + ) + } + const auditTextOcclusion = ( + elements: Element[], + viewportWidth: number, + findings: Finding[], + seen: Set, + targets: Element[], + ) => { + const candidates = elements + .filter((el) => !isExcluded(el)) + .filter((el) => { + const text = auditedText(el) + return text.length >= 8 || (text.length > 0 && isRequiredControl(el)) + }) + .filter((el) => isSemanticTextBoundary(el) || !hasSemanticTextBoundaryAncestor(el)) + .filter((el) => isVisible(el)) + .filter((el) => getComputedStyle(el).position === "static") + .filter((el) => !inMotion(el, targets)) + .slice(0, 200) + const failedRoots: Element[] = [] + for (const el of candidates) { + if (failedRoots.some((root) => root.contains(el))) continue + const blockers = new Map() + let totalSamples = 0 + for (const fragment of textFragments(el)) { + if (rectArea(fragment) < 16) continue + for (const point of samplePoints(fragment)) { + if (point.x < 0 || point.y < 0 || point.x > viewportWidth || point.y > window.innerHeight) continue + totalSamples += 1 + const blocker = opaqueSiblingBlocker(el, point, targets) + if (blocker) blockers.set(blocker, (blockers.get(blocker) || 0) + 1) + } + } + const occludedSamples = Math.max(0, ...blockers.values()) + if (!h.isNearTotalOcclusion({ occludedSamples, totalSamples })) continue + failedRoots.push(el) + push(findings, seen, { + selector: deps.selector(el), + kind: "overlapping-text", + axis: "horizontal", + overflowPx: 0, + viewportWidth, + severity: "error", + }) + } + } + + const auditLayout = (): Finding[] => { + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0 + const findings: Finding[] = [] + const seen = new Set() + const elements = collectElements() + const targets = activeAnimationTargets() + const pageOverflowPx = document.documentElement.scrollWidth - viewportWidth + const escaped = elements.some((el) => hasMaterialViewportEscape(el, viewportWidth, targets)) + if (h.isMaterialPageOverflow({ overflowPx: pageOverflowPx, viewportWidth, hasEscapedContent: escaped })) { + push(findings, seen, { + selector: "html", + kind: "page-horizontal-overflow", + axis: "horizontal", + overflowPx: pageOverflowPx, + viewportWidth, + severity: "error", + }) + } + const failedClippingRoots: any[] = [] + for (const el of elements) auditControlBounds(el, viewportWidth, findings, seen, targets, failedClippingRoots) + for (const el of elements) auditUnreachableLeftText(el, viewportWidth, findings, seen, targets) + for (const el of elements) auditTextOverflow(el, viewportWidth, findings, seen, targets, failedClippingRoots) + auditTextOcclusion(elements, viewportWidth, findings, seen, targets) + return findings + } + + // --- waiting for the page to settle ----------------------------------------------------------- + const fontsReady = (): Promise => { + try { + if (document.fonts && document.fonts.ready) return document.fonts.ready.catch(() => undefined) + } catch { + // The ResizeObserver settle below is still a safety net. + } + return Promise.resolve() + } + const frames = (count: number) => + new Promise((resolve) => { + const step = (remaining: number) => { + if (remaining <= 0) { + resolve() + return + } + const next = () => step(remaining - 1) + if (window.requestAnimationFrame) window.requestAnimationFrame(next) + else window.setTimeout(next, 16) + } + step(count) + }) + const resizeSettle = () => + new Promise((resolve) => { + let observer: ResizeObserver | null = null + let settleTimer = 0 + let maxTimer = 0 + let done = false + const finish = () => { + if (done) return + done = true + if (settleTimer) window.clearTimeout(settleTimer) + if (maxTimer) window.clearTimeout(maxTimer) + if (observer) observer.disconnect() + resolve() + } + const scheduleFinish = () => { + if (settleTimer) window.clearTimeout(settleTimer) + settleTimer = window.setTimeout(finish, SETTLE_MS) + } + if (typeof ResizeObserver !== "undefined") { + observer = new ResizeObserver(scheduleFinish) + const observed = [ + document.documentElement, + document.body, + ...Array.from((document.body && document.body.querySelectorAll("*")) || []), + ] + .filter(Boolean) + .slice(0, MAX_ELEMENTS) + for (const el of observed) observer.observe(el) + } + scheduleFinish() + maxTimer = window.setTimeout(finish, MAX_WAIT_MS) + }) + const hydrationQuiescence = () => + new Promise((resolve) => { + if (typeof MutationObserver === "undefined" || !document.documentElement) { + resolve(false) + return + } + let settleTimer = 0 + let maxTimer = 0 + let done = false + const observer = new MutationObserver(() => scheduleFinish()) + const finish = (quiescent: boolean) => { + if (done) return + done = true + if (settleTimer) window.clearTimeout(settleTimer) + if (maxTimer) window.clearTimeout(maxTimer) + observer.disconnect() + resolve(quiescent) + } + const scheduleFinish = () => { + if (settleTimer) window.clearTimeout(settleTimer) + settleTimer = window.setTimeout(() => finish(true), SETTLE_MS) + } + observer.observe(document.documentElement, { attributes: true, characterData: true, childList: true, subtree: true }) + scheduleFinish() + maxTimer = window.setTimeout(() => finish(false), MAX_WAIT_MS) + }) + // Infinite animations may keep running: the audit reports stable findings unrelated to their + // targets. Completeness waits only for finite animations, and reschedules if they outlast it. + const finiteAnimationsSettle = async () => { + const finite = activeAnimations().filter((animation) => { + const timing = animation.effect && animation.effect.getComputedTiming ? animation.effect.getComputedTiming() : null + return Number.isFinite(Number(timing && timing.endTime)) + }) + if (finite.length === 0) return true + let settled = false + await Promise.race([ + Promise.all(finite.map((animation) => animation.finished.catch(() => undefined))).then(() => { + settled = true + }), + new Promise((resolve) => window.setTimeout(resolve, ANIMATION_MAX_WAIT_MS)), + ]) + if (!settled) { + for (const animation of finite) { + animation.finished.then( + () => schedule(), + () => schedule(), + ) + } + } + return settled + } + + // A pass reports its own completeness. An incomplete pass is uncertainty, never evidence that a + // previously detected failure is gone: the inbox preserves prior warnings as unverified. + const publish = (findings: Finding[], complete: boolean, targetPresenceComplete = false) => { + const severe = findings.filter((finding) => finding && finding.severity === "error") + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0 + const signature = JSON.stringify({ complete, targetPresenceComplete, viewportWidth, severe }) + if (!publishRequested && signature === lastSignature) return + publishRequested = false + lastSignature = signature + deps.post("layoutDiagnostics", { + complete, + artifact_revision: deps.load, + artifact_pass_sequence: ++passSequence, + target_presence_complete: targetPresenceComplete === true, + viewport_width: viewportWidth, + findings: severe, + }) + } + + const runAudit = async (id: number) => { + await fontsReady() + await resizeSettle() + const animationsSettled = await finiteAnimationsSettle() + await frames(2) + if (id !== run) return + const first = auditLayout() + await new Promise((resolve) => window.setTimeout(resolve, STABLE_SAMPLE_MS)) + await frames(2) + if (id !== run) return + const second = auditLayout() + const quiescent = await hydrationQuiescence() + if (id !== run) return + const final = quiescent ? auditLayout() : second + const targetPresenceComplete = document.readyState === "complete" && quiescent + publish( + h.findStableLayoutFindings(quiescent ? second : first, final) as Finding[], + animationsSettled && targetPresenceComplete, + targetPresenceComplete, + ) + } + + const schedule = (requested = false) => { + if (requested) publishRequested = true + if (timer) window.clearTimeout(timer) + const id = ++run + timer = window.setTimeout(() => { + runAudit(id).catch(() => { + if (id === run) publish([], false) + }) + }, 50) + } + + const start = () => { + schedule() + window.addEventListener("load", () => schedule(), { once: true }) + window.addEventListener("resize", () => schedule(), { passive: true }) + window.addEventListener("animationend", () => schedule(), { passive: true }) + window.addEventListener("transitionend", () => schedule(), { passive: true }) + } + + return { start, schedule } +} diff --git a/packages/redcode/src/design/client/helpers.ts b/packages/redcode/src/design/client/helpers.ts index 7ae29c0b14d5..39ead33a1e46 100644 --- a/packages/redcode/src/design/client/helpers.ts +++ b/packages/redcode/src/design/client/helpers.ts @@ -527,6 +527,179 @@ export function deriveAttachmentNoticeState( return "" } +// --- the layout audit's pure classifiers --------------------------------------------------------- +// The audit measures in the document (see `audit.ts`); these decide what a measurement means, so +// the thresholds are testable without a browser. Ported from lavish-axi with the same numbers. + +export interface AuditRect { + readonly left?: number + readonly right?: number + readonly top?: number + readonly bottom?: number + readonly width?: number + readonly height?: number +} + +export interface TextOverflowFinding { + readonly axis: "horizontal" | "vertical" + readonly kind: "clipped-text" + readonly overflowPx: number +} + +/** + * Severe text overflow: a line whose centre lies outside its clipping box, or whose clipped share + * is at least a fifth. Glyph ink outside the line box, tiny excursions, explicit truncation and + * standard accessibility hiding are the author's intent and stay silent. + */ +export function classifySevereTextOverflow(input: { + readonly fragments: readonly AuditRect[] | null | undefined + readonly box: AuditRect | null | undefined + readonly overflowX: string + readonly overflowY: string + readonly isTruncated?: boolean + readonly isVisuallyHidden?: boolean + readonly minOutsideRatio?: number + readonly epsilon?: number +}): TextOverflowFinding | null { + const minOutsideRatio = input.minOutsideRatio === undefined ? 0.2 : input.minOutsideRatio + const epsilon = input.epsilon === undefined ? 1 : input.epsilon + function overflowOf(fragment: AuditRect, boundary: AuditRect, axis: "horizontal" | "vertical") { + const start = Number(axis === "horizontal" ? fragment.left : fragment.top) + const end = Number(axis === "horizontal" ? fragment.right : fragment.bottom) + const boxStart = Number(axis === "horizontal" ? boundary.left : boundary.top) + const boxEnd = Number(axis === "horizontal" ? boundary.right : boundary.bottom) + const explicitSize = Number(axis === "horizontal" ? fragment.width : fragment.height) + const size = Number.isFinite(explicitSize) ? Math.max(0, explicitSize) : Math.max(0, end - start) + if (![start, end, boxStart, boxEnd, size].every(Number.isFinite) || size <= 0) { + return { overflowPx: 0, outsideRatio: 0, centerOutside: false } + } + const before = Math.max(0, boxStart - start) + const after = Math.max(0, end - boxEnd) + const center = start + size / 2 + return { + overflowPx: Math.max(before, after), + outsideRatio: Math.min(1, (before + after) / size), + centerOutside: center < boxStart || center > boxEnd, + } + } + const fragments = input.fragments + if (input.isTruncated || input.isVisuallyHidden || !input.box || !Array.isArray(fragments) || fragments.length === 0) + return null + const clipsX = input.overflowX === "hidden" || input.overflowX === "clip" + const clipsY = input.overflowY === "hidden" || input.overflowY === "clip" + const spillsY = input.overflowY === "visible" + const scrollsX = input.overflowX === "auto" || input.overflowX === "scroll" + const scrollsY = input.overflowY === "auto" || input.overflowY === "scroll" + let strongest: TextOverflowFinding | null = null + for (const fragment of fragments) { + const horizontal = overflowOf(fragment, input.box, "horizontal") + const vertical = overflowOf(fragment, input.box, "vertical") + const severeX = + clipsX && + !scrollsX && + horizontal.overflowPx > epsilon && + (horizontal.centerOutside || horizontal.outsideRatio >= minOutsideRatio) + const severeY = (clipsY || spillsY) && !scrollsY && vertical.overflowPx > epsilon && vertical.centerOutside + const candidates: (TextOverflowFinding | null)[] = [ + severeX ? { axis: "horizontal", kind: "clipped-text", overflowPx: horizontal.overflowPx } : null, + severeY ? { axis: "vertical", kind: "clipped-text", overflowPx: vertical.overflowPx } : null, + ] + for (const candidate of candidates) { + if (candidate && (!strongest || candidate.overflowPx > strongest.overflowPx)) strongest = candidate + } + } + return strongest +} + +export interface RectEscape { + readonly axis: "horizontal" | "vertical" + readonly side: "start" | "end" + readonly overflowPx: number +} + +/** A box that materially leaves a boundary: at least 4px, and either its centre or a fifth of it. */ +export function classifyMaterialRectEscape(input: { + readonly rect: AuditRect | null | undefined + readonly boundary: AuditRect | null | undefined + readonly axes?: readonly ("horizontal" | "vertical")[] + readonly minOutsidePx?: number + readonly minOutsideRatio?: number +}): RectEscape | null { + const axes = input.axes || ["horizontal", "vertical"] + const minOutsidePx = input.minOutsidePx === undefined ? 4 : input.minOutsidePx + const minOutsideRatio = input.minOutsideRatio === undefined ? 0.2 : input.minOutsideRatio + let strongest: RectEscape | null = null + for (const axis of axes) { + const rect = input.rect || {} + const boundary = input.boundary || {} + const start = Number(axis === "horizontal" ? rect.left : rect.top) + const end = Number(axis === "horizontal" ? rect.right : rect.bottom) + const boundaryStart = Number(axis === "horizontal" ? boundary.left : boundary.top) + const boundaryEnd = Number(axis === "horizontal" ? boundary.right : boundary.bottom) + const explicitSize = Number(axis === "horizontal" ? rect.width : rect.height) + const size = Number.isFinite(explicitSize) ? Math.max(0, explicitSize) : Math.max(0, end - start) + if (![start, end, boundaryStart, boundaryEnd, size].every(Number.isFinite) || size <= 0) continue + const before = Math.max(0, boundaryStart - start) + const after = Math.max(0, end - boundaryEnd) + const outsidePx = Math.max(before, after) + const outsideRatio = Math.min(1, (before + after) / size) + const center = start + size / 2 + const centerOutside = center < boundaryStart || center > boundaryEnd + if (outsidePx < minOutsidePx || (!centerOutside && outsideRatio < minOutsideRatio)) continue + const candidate: RectEscape = { axis, side: before >= after ? "start" : "end", overflowPx: outsidePx } + if (!strongest || candidate.overflowPx > strongest.overflowPx) strongest = candidate + } + return strongest +} + +/** + * Tiny document deltas are cosmetic. A page failure is reportable only when meaningful content + * materially escapes the usable viewport; the caller establishes that from visible element bounds. + */ +export function isMaterialPageOverflow(input: { + readonly overflowPx: unknown + readonly viewportWidth: unknown + readonly hasEscapedContent: unknown +}): boolean { + const overflow = Number(input.overflowPx) + const width = Number(input.viewportWidth) + const materialThreshold = Math.max(24, Number.isFinite(width) ? width * 0.05 : 24) + return Boolean(input.hasEscapedContent) && Number.isFinite(overflow) && overflow >= materialThreshold +} + +export interface LayoutFinding { + readonly selector: string + readonly kind: string + readonly axis?: string + readonly overflowPx?: number + readonly viewportWidth?: number + readonly severity: string +} + +/** Only what two samples agree on: a finding seen once is motion, not layout. */ +export function findStableLayoutFindings( + first: readonly LayoutFinding[] | null | undefined, + second: readonly LayoutFinding[] | null | undefined, +): LayoutFinding[] { + const key = (finding: LayoutFinding) => finding.kind + ":" + finding.selector + ":" + (finding.axis || "") + const firstKeys = new Set((Array.isArray(first) ? first : []).filter((f) => f && f.severity === "error").map(key)) + return (Array.isArray(second) ? second : []).filter((f) => f && f.severity === "error" && firstKeys.has(key(f))) +} + +/** Covered text: enough sample points, and nine in ten of them under an opaque sibling. */ +export function isNearTotalOcclusion(input: { + readonly occludedSamples: unknown + readonly totalSamples: unknown + readonly minSamples?: number + readonly minRatio?: number +}): boolean { + const minSamples = input.minSamples === undefined ? 5 : input.minSamples + const minRatio = input.minRatio === undefined ? 0.9 : input.minRatio + const occluded = Number(input.occludedSamples) + const total = Number(input.totalSamples) + return Number.isFinite(occluded) && Number.isFinite(total) && total >= minSamples && occluded / total >= minRatio +} + /** Every helper above, in the order the bundle declares them. */ export const HELPERS = [ isModeToggleHotkeyEvent, @@ -559,4 +732,9 @@ export const HELPERS = [ planClipboardPaste, isTrustedAttachmentResult, deriveAttachmentNoticeState, + classifySevereTextOverflow, + classifyMaterialRectEscape, + isMaterialPageOverflow, + findStableLayoutFindings, + isNearTotalOcclusion, ] as const diff --git a/packages/redcode/src/design/feedback.ts b/packages/redcode/src/design/feedback.ts index 8e0018dfc833..ed02e530ef2d 100644 --- a/packages/redcode/src/design/feedback.ts +++ b/packages/redcode/src/design/feedback.ts @@ -12,6 +12,8 @@ * commands. */ +import { DesignLayoutWarnings } from "./layout-warnings" + /** Long enough for a real remark, short enough that a page cannot flood a turn. */ export const LIMITS = { items: 50, @@ -65,7 +67,10 @@ export interface MermaidNodeTarget { readonly selector: string } -export type Target = TextRangeTarget | TableCellTarget | MermaidNodeTarget +/** A batch of layout warnings the person queued for repair, as the inbox described them. */ +export type LayoutWarningsTarget = DesignLayoutWarnings.PromptTarget + +export type Target = TextRangeTarget | TableCellTarget | MermaidNodeTarget | LayoutWarningsTarget export interface Annotation { /** Where in the prototype, as a CSS path. */ @@ -184,6 +189,10 @@ export function target(raw: unknown): Target | undefined { label: clamp(r.label, LIMITS.elementText), selector: clamp(r.selector, LIMITS.selector), } + case "layout-warnings": { + const normalized = DesignLayoutWarnings.normalizeTarget(r) + return normalized.warnings.length ? normalized : undefined + } default: return undefined } @@ -246,10 +255,48 @@ export function where(item: Annotation): string { const ids = [t.diagramId ? `#${t.diagramId}` : "", t.nodeId ? `#${t.nodeId}` : ""].filter(Boolean).join(" ") return `node ${t.label ? `"${t.label}"` : ""}${ids ? ` ${ids}` : ""}`.replace(/\s+/g, " ").trim() } + if (t?.type === "layout-warnings") return `layout issues: ${t.warnings.length} queued for repair` if (item.tag === "message") return "" return item.label || item.selector || "" } +/** The ids of every layout warning a batch carries: what the inbox marks as requested once it is delivered. */ +export function queuedWarningIDs(annotations: readonly Annotation[]): string[] { + const ids = new Set() + for (const item of annotations) { + if (item.target?.type !== "layout-warnings") continue + for (const warning of item.target.warnings) if (warning.id) ids.add(warning.id) + } + return [...ids] +} + +export const FAILURE_KINDS = ["artifact-unavailable", "artifact-asset-unavailable"] as const + +/** + * The one report that reaches the agent unasked: the prototype could not be shown, so there is no + * review to have. Rendered like feedback — fenced, capped — because its details come from a page. + */ +export function renderFailures( + failures: readonly { kind: string; detail: string }[], + context: { prototype: string; revision: number }, +): string { + const head = `` + const lines = failures.slice(0, 20).map((failure, index) => { + const what = + failure.kind === "artifact-unavailable" + ? "The prototype's document could not be served" + : "A local asset the prototype declares could not be served" + return `${index + 1}. ${what}: ${clamp(failure.detail, 300)}` + }) + return [ + head, + ...lines, + "", + "The person has nothing to review until this is fixed. Fix the file or the reference, then call design_preview again.", + "", + ].join("\n") +} + export function render(annotations: readonly Annotation[], context: Context): string { const viewport = context.viewport ? ` viewport="${context.viewport.width}x${context.viewport.height}"` : "" const ended = context.ended ? ` ended="${context.ended}"` : "" diff --git a/packages/redcode/src/design/layout-warnings.ts b/packages/redcode/src/design/layout-warnings.ts new file mode 100644 index 000000000000..201ace157eb8 --- /dev/null +++ b/packages/redcode/src/design/layout-warnings.ts @@ -0,0 +1,706 @@ +/** + * The passive layout-warning inbox. + * + * Detection is passive: a browser diagnostic pass never wakes the agent and never triggers a + * repair. Findings land here as durable records the person triages from the review page, and only + * an explicit "Queue selected fixes" turns them into an ordinary queued note. + * + * Every rule here is a lifecycle rule, and the lifecycle is deliberately conservative: a warning is + * only ever cleared by positive evidence — a newer prototype revision plus a complete diagnostic + * pass for the same viewport class that no longer detects it. Absence of evidence (a failed pass, a + * different viewport, a reload in flight, a closed drawer, a delivered note) never clears anything. + * + * Ported from lavish-axi's `layout-warnings.js` (MIT), with the same thresholds. + */ + +import { createHash } from "node:crypto" + +export const STATUSES = [ + "open", + "queued", + "recurring", + "unverified", + "reopened", + "resolved", + "dismissed", + "obsolete", +] as const +export type Status = (typeof STATUSES)[number] + +/** Statuses that still count as unresolved work in the badge. */ +export const ACTIVE_STATUSES: readonly Status[] = ["open", "queued", "recurring", "unverified", "reopened"] + +export const VIEWPORT_CLASSES = ["mobile", "compact", "desktop"] as const +export type ViewportClass = (typeof VIEWPORT_CLASSES)[number] + +export const RULES = [ + "page-horizontal-overflow", + "clipped-text", + "clipped-control", + "viewport-unreachable-control", + "viewport-unreachable-content", + "overlapping-text", +] as const + +export const MAX_HISTORY = 20 +export const MAX_SERIALIZED_HISTORY = 10 +export const MAX_STORED = 200 +export const MAX_PER_PROMPT = 50 + +export type Axis = "horizontal" | "vertical" + +/** What the browser reports: one severe finding of one pass. */ +export interface Finding { + readonly rule: string + readonly selector: string + readonly axis: Axis + readonly overflowPx: number + readonly viewportWidth: number +} + +export interface HistoryEntry { + readonly at: string + readonly revision: number + readonly event: string + readonly note?: string +} + +/** The stored record. Snake case on purpose: it is the wire shape the page renders from. */ +export interface Warning { + readonly id: string + readonly fingerprint: string + readonly rule: string + readonly severity: "error" + readonly status: Status + readonly selector: string + readonly component: string + readonly axis: Axis + readonly overflow_px: number + readonly viewport_class: ViewportClass + readonly viewport_width: number + readonly first_seen_at: string + readonly first_seen_revision: number + readonly last_seen_at: string + readonly last_seen_revision: number + readonly observation_count: number + readonly queued_revision: number + readonly queued_at: string + readonly queue_attempts: number + readonly dismissed_revision: number + readonly dismissed_at?: string + readonly resolved_at?: string + readonly resolved_revision?: number + readonly obsolete_reason?: string + readonly obsolete_at?: string + readonly history: readonly HistoryEntry[] +} + +/** One completed (or failed) browser pass, as the route hands it in. */ +export interface Pass { + readonly complete?: boolean + readonly targetPresenceComplete?: boolean + readonly viewportWidth?: number + readonly revision?: number + readonly at?: string + readonly findings?: readonly unknown[] +} + +/** What the page renders: the record plus every display string, computed here. */ +export interface Serialized { + readonly id: string + readonly fingerprint: string + readonly rule: string + readonly severity: "error" + readonly status: Status + readonly status_label: string + readonly title: string + readonly explanation: string + readonly selector: string + readonly component: string + readonly axis: Axis + readonly overflow_px: number + readonly viewport_class: ViewportClass + readonly viewport_label: string + readonly viewport_width: number + readonly first_seen_at: string + readonly last_seen_at: string + readonly last_seen_revision: number + readonly queued_at: string + readonly queue_attempts: number + readonly active: boolean + readonly selectable: boolean + readonly outstanding: boolean + readonly obsolete_reason?: string + readonly history: readonly HistoryEntry[] +} + +/** The structured target a queued batch carries, beside the words. */ +export interface PromptTarget { + readonly type: "layout-warnings" + readonly artifact_revision?: number + readonly warnings: readonly { + readonly id: string + readonly rule: string + readonly selector: string + readonly component: string + readonly axis: Axis + readonly overflow_px: number + readonly viewport_class: string + readonly viewport_width: number + readonly status: string + readonly last_seen_at: string + }[] +} + +export function viewportClassFor(viewportWidth: unknown): ViewportClass { + const width = finite(viewportWidth) + if (width <= 640) return "mobile" + if (width <= 1024) return "compact" + return "desktop" +} + +export function viewportClassLabel(viewportClass: string): string { + if (viewportClass === "mobile") return "Mobile" + if (viewportClass === "compact") return "Tablet / compact" + return "Desktop" +} + +/** + * Stable identity: the rule, the normalized target, and the viewport class. Magnitude is + * deliberately excluded so a finding that gets worse (or slightly better) updates the one record + * instead of inflating the count with a near-duplicate. + */ +export function fingerprint(input: { rule: string; target: string; viewportClass: string }): string { + const payload = `${text(input.rule)}|${text(input.target)}|${text(input.viewportClass)}` + return createHash("sha256").update(payload).digest("hex").slice(0, 16) +} + +/** The human-readable component for a CSS path: the most specific id, then the first class, then the tag. */ +export function componentIdentity(selector: string): string { + const last = text(selector).split(">").pop()?.trim() || "" + if (!last) return "" + const id = last.match(/#([A-Za-z0-9_-]+)/) + if (id) return `#${id[1]}` + const className = last.match(/\.([A-Za-z0-9_-]+)/) + if (className) return `.${className[1]}` + const tag = last.match(/^([A-Za-z][A-Za-z0-9-]*)/) + return tag ? tag[1]! : "" +} + +interface Described { + readonly axis?: string + readonly overflowPx: number + readonly viewportWidth: number +} + +const DESCRIPTIONS: Record string }> = { + "page-horizontal-overflow": { + title: "Page scrolls sideways", + explain: (w) => + `The page is ${px(w.overflowPx)} wider than the ${px(w.viewportWidth)} viewport, so content sits off-screen.`, + }, + "clipped-text": { + title: "Text cut off by its container", + explain: (w) => `Rendered text crosses its container's ${edge(w.axis)} edge by ${px(w.overflowPx)} and is hidden.`, + }, + "clipped-control": { + title: "Control cut off by its container", + explain: (w) => + `A required control crosses its container's ${edge(w.axis)} edge by ${px(w.overflowPx)}, so part of it cannot be used.`, + }, + "viewport-unreachable-control": { + title: "Control outside the viewport", + explain: (w) => + `A required control sits ${px(w.overflowPx)} outside the ${edge(w.axis)} edge of the viewport and cannot be reached.`, + }, + "viewport-unreachable-content": { + title: "Text outside the viewport", + explain: (w) => + `Rendered text sits ${px(w.overflowPx)} outside the ${edge(w.axis)} edge of the viewport and cannot be read.`, + }, + "overlapping-text": { + title: "Text covered by another element", + explain: () => "An opaque sibling covers nearly all of this text, so it cannot be read.", + }, +} + +/** Accepts the stored record (snake_case) or a raw finding (camelCase): one set of strings for both. */ +export function describe(warning: Record | undefined): { title: string; explanation: string } { + const w = warning ?? {} + const normalized: Described = { + ...(typeof w.axis === "string" ? { axis: w.axis } : {}), + overflowPx: finite(w.overflow_px ?? w.overflowPx), + viewportWidth: finite(w.viewport_width ?? w.viewportWidth), + } + const description = DESCRIPTIONS[String(w.rule ?? w.kind ?? "")] + if (!description) { + return { + title: "Layout failure", + explanation: `The browser proved a severe layout failure on this element${normalized.overflowPx ? ` (${px(normalized.overflowPx)})` : ""}.`, + } + } + return { title: description.title, explanation: description.explain(normalized) } +} + +const STATUS_LABELS: Record = { + open: "Open", + queued: "Queued for fix", + recurring: "Still present", + unverified: "Unverified", + reopened: "Returned", + resolved: "Resolved", + dismissed: "Dismissed", + obsolete: "Obsolete", +} + +export function statusLabel(status: string): string { + return STATUS_LABELS[status as Status] || "Open" +} + +export function isActive(warning: { readonly status?: string } | undefined): boolean { + return ACTIVE_STATUSES.includes(String(warning?.status || "") as Status) +} + +export function active(warnings: readonly Warning[]): Warning[] { + return warnings.filter(isActive) +} + +export function activeCount(warnings: readonly Warning[]): number { + return active(warnings).length +} + +/** + * A repair request is outstanding while a queued warning has not been re-checked against a newer + * revision. `recurring` means the newer pass still found it, so asking again is legitimate; `queued` + * and a `queued` warning knocked to `unverified` are not. `queued_at`, not `queued_revision`, marks + * "a repair was requested": a revision of 0 is a legitimate value. + */ +export function hasOutstandingRepairRequest(warning: Warning | undefined): boolean { + if (!warning) return false + if (warning.status === "queued") return true + return warning.status === "unverified" && Boolean(warning.queued_at) +} + +export function isSelectable(warning: Warning): boolean { + return isActive(warning) && !hasOutstandingRepairRequest(warning) +} + +/** Fold one completed (or failed) browser pass into the stored records. */ +export function applyDiagnosticPass(warnings: unknown, pass: Pass): { warnings: Warning[]; changed: boolean } { + const previous = normalizeStored(warnings) + const at = String(pass.at || new Date().toISOString()) + const revision = Math.max(0, Math.trunc(finite(pass.revision))) + const viewportWidth = finite(pass.viewportWidth) + const viewportClass = viewportClassFor(viewportWidth) + const complete = pass.complete !== false + const targetPresenceComplete = pass.targetPresenceComplete === true + const observations = new Map() + for (const finding of normalizeFindings(pass.findings, viewportWidth)) { + const key = fingerprint({ rule: finding.rule, target: finding.selector, viewportClass }) + if (!observations.has(key)) observations.set(key, finding) + } + + const next = previous.map((warning) => { + // A pass for one viewport class is silent about every other class: a desktop pass can never + // clear a phone-specific warning. + if (warning.viewport_class !== viewportClass) return warning + const observation = observations.get(warning.fingerprint) + if (observation) { + observations.delete(warning.fingerprint) + return recordDetection(warning, observation, { at, revision, viewportWidth }) + } + // Absence is only evidence when the pass actually completed. + if (!complete || !targetPresenceComplete) return recordUnverified(warning, { at, revision }) + // Temporary absence within the same revision is not proof of repair. + if (revision <= finite(warning.last_seen_revision)) return warning + if (!isActive(warning) && warning.status !== "dismissed") return warning + return recordResolved(warning, { at, revision }) + }) + + for (const [key, observation] of observations) { + next.push(createWarning(key, observation, { at, revision, viewportClass, viewportWidth })) + } + + const pruned = prune(next) + return { warnings: pruned, changed: !same(previous, pruned) } +} + +/** Mark warnings as queued for repair. They stay unresolved and counted. */ +export function queue( + warnings: unknown, + ids: readonly unknown[], + options: { revision?: number; at?: string } = {}, +): { warnings: Warning[]; queued: Warning[]; changed: boolean } { + const revision = options.revision ?? 0 + const at = options.at ?? new Date().toISOString() + const wanted = new Set(ids.slice(0, MAX_PER_PROMPT).map((id) => String(id))) + const queued: Warning[] = [] + const next = normalizeStored(warnings).map((warning) => { + if (!wanted.has(warning.id) || !isSelectable(warning)) return warning + const updated = withHistory( + { + ...warning, + status: "queued", + queued_revision: Math.max(0, Math.trunc(finite(revision))), + queued_at: at, + queue_attempts: finite(warning.queue_attempts) + 1, + }, + { at, revision, event: "queued" }, + ) + queued.push(updated) + return updated + }) + return { warnings: next, queued, changed: queued.length > 0 } +} + +/** Dismiss a warning for the current revision only. */ +export function dismiss( + warnings: unknown, + id: unknown, + options: { revision?: number; at?: string } = {}, +): { warnings: Warning[]; changed: boolean } { + const revision = options.revision ?? 0 + const at = options.at ?? new Date().toISOString() + const target = String(id || "") + let changed = false + const next = normalizeStored(warnings).map((warning) => { + if (warning.id !== target || !isSelectable(warning)) return warning + changed = true + return withHistory( + { + ...warning, + status: "dismissed", + dismissed_revision: Math.max(0, Math.trunc(finite(revision))), + dismissed_at: at, + }, + { at, revision, event: "dismissed", note: "dismissed for this prototype revision" }, + ) + }) + return { warnings: next, changed } +} + +/** + * A viewport class that leaves the configured diagnostic set can never be re-checked, so its + * warnings are marked obsolete with a reason rather than silently reading as fixed. + */ +export function markObsoleteViewports( + warnings: unknown, + viewportClasses: readonly string[] = VIEWPORT_CLASSES, + options: { revision?: number; at?: string } = {}, +): { warnings: Warning[]; changed: boolean } { + const revision = options.revision ?? 0 + const at = options.at ?? new Date().toISOString() + const configured = new Set(viewportClasses.map(String)) + let changed = false + const next = normalizeStored(warnings).map((warning) => { + if (configured.has(warning.viewport_class) || !isActive(warning)) return warning + changed = true + const reason = `the ${warning.viewport_class} viewport is no longer in the configured diagnostic set, so this warning can no longer be re-checked` + return withHistory( + { ...warning, status: "obsolete", obsolete_reason: reason, obsolete_at: at }, + { at, revision, event: "obsolete", note: reason }, + ) + }) + return { warnings: next, changed } +} + +/** The page renders only what the server hands it, so every display string is computed here. */ +export function serialize(warning: Warning): Serialized { + const { title, explanation } = describe(warning as unknown as Record) + return { + id: warning.id, + fingerprint: warning.fingerprint, + rule: warning.rule, + severity: warning.severity, + status: warning.status, + status_label: statusLabel(warning.status), + title, + explanation, + selector: warning.selector, + component: warning.component, + axis: warning.axis, + overflow_px: warning.overflow_px, + viewport_class: warning.viewport_class, + viewport_label: viewportClassLabel(warning.viewport_class), + viewport_width: warning.viewport_width, + first_seen_at: warning.first_seen_at, + last_seen_at: warning.last_seen_at, + last_seen_revision: warning.last_seen_revision, + queued_at: warning.queued_at || "", + queue_attempts: warning.queue_attempts || 0, + active: isActive(warning), + selectable: isSelectable(warning), + outstanding: hasOutstandingRepairRequest(warning), + ...(warning.obsolete_reason ? { obsolete_reason: warning.obsolete_reason } : {}), + history: (warning.history || []).slice(-MAX_SERIALIZED_HISTORY), + } +} + +export function serializeAll(warnings: unknown): Serialized[] { + return normalizeStored(warnings).map(serialize) +} + +/** The agent-facing payload for one queued batch. Bounded so a runaway pass can never blow up a turn. */ +export function promptPayload(warnings: unknown): { prompt: string; text: string; target: PromptTarget } { + const selected = normalizeStored(warnings).slice(0, MAX_PER_PROMPT) + const lines = selected.map((warning, index) => { + const { title, explanation } = describe(warning as unknown as Record) + return `${index + 1}. [${warning.id}] ${title} - ${explanation} Target: ${warning.selector || "(page)"}. Viewport: ${viewportClassLabel(warning.viewport_class)} (${px(warning.viewport_width)}). Status: ${statusLabel(warning.status)}.` + }) + const count = selected.length + const prompt = + `Fix ${count === 1 ? "this layout issue" : `these ${count} layout issues`} the browser detected in this prototype:\n` + + `${lines.join("\n")}\n\n` + + "Apply every listed fix in one pass before saving so the review refreshes once. " + + "A queued layout issue is a repair request, not a resolved issue: it is only marked resolved after a newer prototype revision and a complete diagnostic pass for the same viewport no longer detects it." + const target: PromptTarget = { + type: "layout-warnings", + artifact_revision: Math.max(0, Math.trunc(finite(selected[0]?.queued_revision))), + warnings: selected.map((warning) => ({ + id: warning.id, + rule: warning.rule, + selector: warning.selector, + component: warning.component, + axis: warning.axis, + overflow_px: warning.overflow_px, + viewport_class: warning.viewport_class, + viewport_width: warning.viewport_width, + status: warning.status, + last_seen_at: warning.last_seen_at, + })), + } + return { + prompt, + text: count === 1 ? "Layout issue: 1 selected" : `Layout issues: ${count} selected`, + target, + } +} + +/** Target normalization for the queued batch, mirroring the other structured targets. */ +export function normalizeTarget(target: unknown): PromptTarget { + const t = (target && typeof target === "object" ? target : {}) as Record + const warnings = Array.isArray(t.warnings) ? t.warnings : [] + const normalized: PromptTarget = { + type: "layout-warnings", + warnings: warnings.slice(0, MAX_PER_PROMPT).map((raw) => { + const w = (raw && typeof raw === "object" ? raw : {}) as Record + return { + id: text(w.id).slice(0, 64), + rule: text(w.rule).slice(0, 64), + selector: text(w.selector).slice(0, 300), + component: text(w.component).slice(0, 120), + axis: w.axis === "vertical" ? "vertical" : "horizontal", + overflow_px: finite(w.overflow_px), + viewport_class: text(w.viewport_class).slice(0, 16), + viewport_width: finite(w.viewport_width), + status: text(w.status).slice(0, 16), + last_seen_at: text(w.last_seen_at).slice(0, 40), + } + }), + } + if (Object.hasOwn(t, "artifact_revision")) { + return { ...normalized, artifact_revision: Math.max(0, Math.trunc(finite(t.artifact_revision))) } + } + return normalized +} + +/** The viewport classes a review audits, from config; nonsense falls back to every class. */ +export function resolveViewportClasses(configured: readonly unknown[] | undefined): ViewportClass[] { + if (!configured || !Array.isArray(configured)) return [...VIEWPORT_CLASSES] + const kept = configured + .map((value) => String(value).trim().toLowerCase()) + .filter((value): value is ViewportClass => (VIEWPORT_CLASSES as readonly string[]).includes(value)) + return kept.length ? [...new Set(kept)] : [...VIEWPORT_CLASSES] +} + +/** Records with an id are records; anything else (a legacy shape, a stray value) is dropped. */ +export function normalizeStored(warnings: unknown): Warning[] { + if (!Array.isArray(warnings)) return [] + return warnings.filter( + (warning): warning is Warning => + !!warning && typeof warning === "object" && !Array.isArray(warning) && !!(warning as Warning).id, + ) +} + +// --------------------------------------------------------------------------------------------- +// internals +// --------------------------------------------------------------------------------------------- + +function createWarning( + key: string, + observation: Finding, + input: { at: string; revision: number; viewportClass: ViewportClass; viewportWidth: number }, +): Warning { + return withHistory( + { + id: key, + fingerprint: key, + rule: observation.rule, + severity: "error", + status: "open", + selector: observation.selector, + component: componentIdentity(observation.selector), + axis: observation.axis, + overflow_px: observation.overflowPx, + viewport_class: input.viewportClass, + viewport_width: input.viewportWidth, + first_seen_at: input.at, + first_seen_revision: input.revision, + last_seen_at: input.at, + last_seen_revision: input.revision, + observation_count: 1, + queued_revision: 0, + queued_at: "", + queue_attempts: 0, + dismissed_revision: 0, + history: [], + }, + { at: input.at, revision: input.revision, event: "detected" }, + ) +} + +function recordDetection( + warning: Warning, + observation: Finding, + input: { at: string; revision: number; viewportWidth: number }, +): Warning { + const status = detectedStatus(warning, input.revision) + // Re-observing the identical finding on the revision that already recorded it changes nothing. + // Returning the record untouched keeps repeat passes from rewriting state. + const unchanged = + status === warning.status && + input.revision <= finite(warning.last_seen_revision) && + warning.selector === observation.selector && + warning.axis === observation.axis && + finite(warning.overflow_px) === observation.overflowPx && + finite(warning.viewport_width) === input.viewportWidth + if (unchanged) return warning + + const updated: Warning = { + ...warning, + rule: observation.rule, + selector: observation.selector, + component: componentIdentity(observation.selector), + axis: observation.axis, + overflow_px: observation.overflowPx, + viewport_width: input.viewportWidth, + last_seen_at: input.at, + last_seen_revision: Math.max(finite(warning.last_seen_revision), input.revision), + observation_count: finite(warning.observation_count) + 1, + status, + } + if (status === warning.status) return updated + const note = + status === "recurring" + ? "still present after a newer prototype revision" + : status === "reopened" + ? "detected again after being resolved" + : "" + return withHistory(updated, { at: input.at, revision: input.revision, event: status, note }) +} + +function detectedStatus(warning: Warning, revision: number): Status { + if (warning.status === "dismissed" && revision <= finite(warning.dismissed_revision)) return "dismissed" + if (warning.queued_at) return revision > finite(warning.queued_revision) ? "recurring" : "queued" + if (warning.status === "resolved") return "reopened" + if (warning.status === "reopened") return "reopened" + return "open" +} + +function recordUnverified(warning: Warning, input: { at: string; revision: number }): Warning { + if (!isActive(warning) || warning.status === "unverified") return warning + return withHistory( + { ...warning, status: "unverified" }, + { + at: input.at, + revision: input.revision, + event: "unverified", + note: "a diagnostic pass failed or was incomplete, so this warning was preserved rather than cleared", + }, + ) +} + +function recordResolved(warning: Warning, input: { at: string; revision: number }): Warning { + return withHistory( + { + ...warning, + status: "resolved", + resolved_at: input.at, + resolved_revision: input.revision, + queued_revision: 0, + queued_at: "", + }, + { + at: input.at, + revision: input.revision, + event: "resolved", + note: "absent from a complete pass on a newer prototype revision", + }, + ) +} + +function withHistory(warning: Warning, entry: { at: string; revision: number; event: string; note?: string }): Warning { + const history: HistoryEntry[] = [ + ...(Array.isArray(warning.history) ? warning.history : []), + { + at: String(entry.at), + revision: Math.max(0, Math.trunc(finite(entry.revision))), + event: String(entry.event), + ...(entry.note ? { note: String(entry.note).slice(0, 200) } : {}), + }, + ] + return { ...warning, history: history.slice(-MAX_HISTORY) } +} + +/** Keep every unresolved record; trim the closed tail so history stays bounded. */ +function prune(warnings: Warning[]): Warning[] { + if (warnings.length <= MAX_STORED) return warnings + const live = warnings.filter(isActive) + const closed = warnings.filter((warning) => !isActive(warning)) + const room = Math.max(0, MAX_STORED - live.length) + const keptClosed = new Set(closed.slice(-room)) + return warnings.filter((warning) => isActive(warning) || keptClosed.has(warning)) +} + +function normalizeFindings(findings: readonly unknown[] | undefined, viewportWidth: number): Finding[] { + if (!Array.isArray(findings)) return [] + return findings + .filter( + (finding): finding is Record => + !!finding && + typeof finding === "object" && + !Array.isArray(finding) && + String((finding as Record).severity || "").toLowerCase() === "error", + ) + .slice(0, MAX_STORED) + .map((finding) => ({ + rule: text(finding.kind ?? finding.rule ?? "layout-failure").slice(0, 64), + selector: text(finding.selector).slice(0, 300), + axis: finding.axis === "vertical" ? ("vertical" as const) : ("horizontal" as const), + overflowPx: Math.round(finite(finding.overflowPx ?? finding.overflow_px)), + viewportWidth: Math.round(finite(finding.viewportWidth ?? finding.viewport_width) || viewportWidth), + })) +} + +function same(previous: readonly Warning[], next: readonly Warning[]): boolean { + return JSON.stringify(previous) === JSON.stringify(next) +} + +function text(value: unknown): string { + return value === null || value === undefined ? "" : String(value).replace(/\s+/g, " ").trim() +} + +function finite(value: unknown): number { + const number = Number(value) + return Number.isFinite(number) ? number : 0 +} + +function px(value: unknown): string { + return `${Math.round(finite(value))}px` +} + +function edge(axis: string | undefined): string { + return axis === "vertical" ? "bottom" : "right" +} + +export * as DesignLayoutWarnings from "./layout-warnings" diff --git a/packages/redcode/src/design/registry.ts b/packages/redcode/src/design/registry.ts index f8eb6b245698..5ed2d0d3b607 100644 --- a/packages/redcode/src/design/registry.ts +++ b/packages/redcode/src/design/registry.ts @@ -12,6 +12,7 @@ import { promises as nodeFs } from "node:fs" import path from "path" import type { SessionID } from "@/session/schema" import { DesignAttachments } from "./attachments" +import { DesignLayoutWarnings } from "./layout-warnings" import { DesignManifest } from "./manifest" import { DesignState } from "./state" import { DesignWatch } from "./watch" @@ -56,6 +57,43 @@ export interface Prototype { readonly chat: readonly DesignState.ChatEntry[] /** Images handed to the agent, and when. */ readonly delivered: readonly DesignState.Delivered[] + /** The passive layout inbox: what the browser proved, and what the person did about it. */ + readonly warnings: readonly DesignLayoutWarnings.Warning[] +} + +/** + * One load of the prototype into one shell's frame. The token ties a diagnostic pass to the + * document that ran it: a pass from a frame that has since been replaced is stale, and a stale + * pass is discarded rather than allowed to clear or create a warning for a page nobody is seeing. + */ +export interface Load { + readonly token: string + readonly revision: number + readonly sequence: number + /** The highest pass this load has reported; a lower or equal one is a replay. */ + lastPass: number + /** Failures already reported for this load, so a page that keeps failing wakes the agent once. */ + readonly failures: Set +} + +export type LoadBegun = { readonly revision: number; readonly token: string; readonly stale?: "out-of-order" } + +/** What a review page was configured to do about layout. */ +export interface Settings { + readonly viewports: readonly DesignLayoutWarnings.ViewportClass[] + readonly gate: boolean + readonly gateTimeoutMs: number +} + +export const GATE_TIMEOUT_MS = 12_000 +export const GATE_TIMEOUT_MAX_MS = 60_000 +/** Frames one prototype remembers loads for; a shell that keeps reloading forgets its oldest. */ +export const MAX_LOADS = 8 +export const MAX_FAILURES = 20 +export const FAILURE_KINDS = ["artifact-unavailable", "artifact-asset-unavailable"] as const +export interface Failure { + readonly kind: (typeof FAILURE_KINDS)[number] + readonly detail: string } /** What a mounted shell is told as it happens. */ @@ -65,6 +103,7 @@ export type LiveEvent = | { readonly type: "chat-sync"; readonly chat: readonly DesignState.ChatEntry[] } | { readonly type: "presence"; readonly state: "working" | "waiting" } | { readonly type: "ended"; readonly by: DesignState.EndedBy } + | { readonly type: "layout-warnings"; readonly warnings: readonly DesignLayoutWarnings.Serialized[] } export interface Interface { readonly register: (input: { sessionID: SessionID; root: string; name: string }) => Effect.Effect @@ -96,6 +135,46 @@ export interface Interface { readonly attachments: () => Effect.Effect /** One writer at a time in the attachment store: admission, write and sweep are one critical section. */ readonly exclusive: (effect: Effect.Effect) => Effect.Effect + /** The layout audit's settings, from config. */ + readonly settings: () => Effect.Effect + /** A shell is about to load the frame: mint the token that ties that document's passes to it. */ + readonly beginLoad: (id: string, input: { client: string; sequence: number }) => Effect.Effect + /** Is this the token of a load still current for this prototype? */ + readonly verifyLoad: (id: string, token: string) => Effect.Effect + /** Fold one browser pass into the inbox. Never wakes the agent; a stale pass changes nothing. */ + readonly diagnostics: ( + id: string, + payload: Record, + ) => Effect.Effect<{ stale: boolean; changed: boolean; warnings: DesignLayoutWarnings.Serialized[] } | undefined> + /** The person selected warnings to fix: the note they would send, without committing anything yet. */ + readonly prepareWarnings: ( + id: string, + ids: readonly unknown[], + revision: number | undefined, + ) => Effect.Effect< + | { conflict: true; revision: number } + | { + conflict?: undefined + queued: DesignLayoutWarnings.Warning[] + prompt: ReturnType | null + warnings: DesignLayoutWarnings.Serialized[] + } + | undefined + > + /** The note with those warnings was delivered: they are now repair requests. */ + readonly commitWarnings: (id: string, ids: readonly unknown[]) => Effect.Effect + readonly dismissWarning: ( + id: string, + warningID: unknown, + ) => Effect.Effect<{ changed: boolean; warnings: DesignLayoutWarnings.Serialized[] } | undefined> + /** + * The prototype could not be shown: the document itself, or a local asset it declares. Deduped + * per load; what comes back is only what is new, and the route wakes the agent with it. + */ + readonly failures: ( + id: string, + payload: Record, + ) => Effect.Effect<{ stale: boolean; fresh: Failure[] } | undefined> } export class Service extends Context.Service()("@redcode/DesignRegistry") {} @@ -164,6 +243,8 @@ const layer = Layer.effect( readonly listeners: Map readonly pollers: Map> readonly baselines: Map + /** Per prototype, per shell (its own random id): the load whose passes are current. */ + readonly loads: Map> } const persist = (item: Prototype) => @@ -178,6 +259,7 @@ const layer = Layer.effect( ...(item.ended ? { ended: item.ended } : {}), chat: item.chat, ...(item.delivered.length ? { delivered: item.delivered } : {}), + ...(item.warnings.length ? { warnings: item.warnings } : {}), } yield* writeAtomic(DesignState.file(item.root), DesignState.serialize(state)) const raw = yield* fs.readFileStringSafe(DesignState.INDEX).pipe(Effect.orElseSucceed(() => undefined)) @@ -221,6 +303,7 @@ const layer = Layer.effect( ...(parsed.ended ? { ended: parsed.ended } : {}), chat: parsed.chat, delivered: parsed.delivered ?? [], + warnings: parsed.warnings ?? [], } return item }).pipe(Effect.catchCause(() => Effect.succeed(undefined))) @@ -229,6 +312,17 @@ const layer = Layer.effect( return DesignAttachments.resolveConfig((yield* config.get()).experimental?.design?.attachments) }) + const settings = Effect.fn("DesignRegistry.settings")(function* () { + const design = (yield* config.get()).experimental?.design + const timeout = design?.gate_timeout + return { + viewports: DesignLayoutWarnings.resolveViewportClasses(design?.viewports), + gate: design?.gate !== false, + gateTimeoutMs: + typeof timeout === "number" && timeout > 0 ? Math.min(timeout, GATE_TIMEOUT_MAX_MS) : GATE_TIMEOUT_MS, + } satisfies Settings + }) + /** Everything the index knows, with the sidecars' delivered lists: nothing referenced is swept. */ const referenced = Effect.fn("DesignRegistry.referenced")(function* () { const out = new Set() @@ -269,6 +363,7 @@ const layer = Layer.effect( listeners: new Map(), pollers: new Map(), baselines: new Map(), + loads: new Map(), } const hub = (id: string) => Effect.gen(function* () { @@ -391,6 +486,7 @@ const layer = Layer.effect( ...(existing?.ended ? { ended: existing.ended } : {}), chat: existing?.chat ?? [], delivered: existing?.delivered ?? [], + warnings: existing?.warnings ?? [], } st.data.set(id, next) yield* persist(next) @@ -447,8 +543,11 @@ const layer = Layer.effect( continue } if (now === baseline) continue - // Let a burst of saves settle before the shell reloads once for all of them. - yield* Effect.sleep(DesignWatch.DEBOUNCE_MS) + // Let a burst of saves settle before the shell reloads once for all of them. Wider while + // a batch of layout fixes is outstanding: the agent is touching several places for one + // reload, and every extra reload is a pass that cannot yet resolve anything. + const outstanding = item.warnings.some(DesignLayoutWarnings.hasOutstandingRepairRequest) + yield* Effect.sleep(outstanding ? DesignWatch.BATCH_DEBOUNCE_MS : DesignWatch.DEBOUNCE_MS) yield* bump(id) } }).pipe(Effect.catchCause(() => Effect.void)) @@ -512,6 +611,164 @@ const layer = Layer.effect( const exclusive = (effect: Effect.Effect) => lock.withPermits(1)(effect) + // --- the passive layout inbox --------------------------------------------------------------- + + const beginLoad = Effect.fn("DesignRegistry.beginLoad")(function* ( + id: string, + input: { client: string; sequence: number }, + ) { + const { st } = yield* InstanceState.get(state) + const item = yield* lookup(id) + if (!item) return undefined + const byClient = st.loads.get(id) ?? new Map() + st.loads.set(id, byClient) + const client = String(input.client || "").slice(0, 64) || "anon" + const sequence = Number.isSafeInteger(input.sequence) && input.sequence > 0 ? input.sequence : 0 + const existing = byClient.get(client) + // A begin that arrives after a later one from the same shell lost a race; the later load + // is the one showing, and it keeps its token. + if (existing && sequence > 0 && existing.sequence > sequence) { + return { revision: existing.revision, token: existing.token, stale: "out-of-order" as const } + } + const token = randomBytes(24).toString("base64url") + byClient.delete(client) + byClient.set(client, { token, revision: item.revision, sequence, lastPass: 0, failures: new Set() }) + while (byClient.size > MAX_LOADS) byClient.delete(byClient.keys().next().value!) + return { revision: item.revision, token } + }) + + const findLoad = (st: State, id: string, token: unknown): Load | undefined => { + const key = String(token || "") + if (!key) return undefined + for (const load of st.loads.get(id)?.values() ?? []) if (load.token === key) return load + return undefined + } + + const verifyLoad = Effect.fn("DesignRegistry.verifyLoad")(function* (id: string, token: string) { + const { st } = yield* InstanceState.get(state) + const item = yield* lookup(id) + const load = findLoad(st, id, token) + return !!item && !!load && load.revision === item.revision + }) + + const publishWarnings = (id: string, warnings: readonly DesignLayoutWarnings.Warning[]) => + Effect.gen(function* () { + const { publish } = yield* InstanceState.get(state) + yield* publish(id, { type: "layout-warnings", warnings: DesignLayoutWarnings.serializeAll(warnings) }) + }) + + const diagnostics = Effect.fn("DesignRegistry.diagnostics")(function* ( + id: string, + payload: Record, + ) { + const { st } = yield* InstanceState.get(state) + const item = yield* lookup(id) + if (!item) return undefined + const stale = { stale: true, changed: false, warnings: DesignLayoutWarnings.serializeAll(item.warnings) } + const load = findLoad(st, id, payload.artifact_load_token) + const revision = Number(payload.artifact_revision) + const sequence = Number(payload.artifact_pass_sequence) + if ( + !load || + !Number.isInteger(revision) || + revision !== load.revision || + !Number.isInteger(sequence) || + sequence <= load.lastPass + ) + return stale + load.lastPass = sequence + const cfg = yield* settings() + const at = new Date().toISOString() + const viewportWidth = Number(payload.viewport_width) || 0 + // A class that is not audited is not evidence either way; its findings are not recorded + // only to be marked obsolete a line later. + const audited = cfg.viewports.includes(DesignLayoutWarnings.viewportClassFor(viewportWidth)) + const pass = audited + ? DesignLayoutWarnings.applyDiagnosticPass(item.warnings, { + complete: payload.complete !== false, + targetPresenceComplete: payload.target_presence_complete === true, + viewportWidth, + findings: Array.isArray(payload.findings) ? payload.findings : [], + revision: load.revision, + at, + }) + : { warnings: [...item.warnings], changed: false } + const obsolete = DesignLayoutWarnings.markObsoleteViewports(pass.warnings, cfg.viewports, { + at, + revision: load.revision, + }) + const changed = pass.changed || obsolete.changed + if (!changed) return { stale: false, changed: false, warnings: DesignLayoutWarnings.serializeAll(item.warnings) } + const next = yield* update(id, (current) => ({ ...current, warnings: obsolete.warnings })) + const warnings = next?.warnings ?? obsolete.warnings + yield* publishWarnings(id, warnings) + return { stale: false, changed: true, warnings: DesignLayoutWarnings.serializeAll(warnings) } + }) + + const prepareWarnings = Effect.fn("DesignRegistry.prepareWarnings")(function* ( + id: string, + ids: readonly unknown[], + revision: number | undefined, + ) { + const item = yield* lookup(id) + if (!item) return undefined + // The person chose from a list drawn for one revision. If the prototype moved meanwhile, + // their choice may no longer mean what they think: say so, and let the page redraw. + if (revision !== undefined && revision !== item.revision) return { conflict: true as const, revision: item.revision } + const result = DesignLayoutWarnings.queue(item.warnings, ids, { revision: item.revision }) + return { + queued: result.queued, + prompt: result.queued.length ? DesignLayoutWarnings.promptPayload(result.queued) : null, + warnings: DesignLayoutWarnings.serializeAll(item.warnings), + } + }) + + const commitWarnings = Effect.fn("DesignRegistry.commitWarnings")(function* (id: string, ids: readonly unknown[]) { + if (ids.length === 0) return + const item = yield* lookup(id) + if (!item) return + const result = DesignLayoutWarnings.queue(item.warnings, ids, { revision: item.revision }) + if (!result.changed) return + const next = yield* update(id, (current) => ({ ...current, warnings: result.warnings })) + yield* publishWarnings(id, next?.warnings ?? result.warnings) + }) + + const dismissWarning = Effect.fn("DesignRegistry.dismissWarning")(function* (id: string, warningID: unknown) { + const item = yield* lookup(id) + if (!item) return undefined + const result = DesignLayoutWarnings.dismiss(item.warnings, warningID, { revision: item.revision }) + if (!result.changed) return { changed: false, warnings: DesignLayoutWarnings.serializeAll(item.warnings) } + const next = yield* update(id, (current) => ({ ...current, warnings: result.warnings })) + const warnings = next?.warnings ?? result.warnings + yield* publishWarnings(id, warnings) + return { changed: true, warnings: DesignLayoutWarnings.serializeAll(warnings) } + }) + + const failures = Effect.fn("DesignRegistry.failures")(function* (id: string, payload: Record) { + const { st } = yield* InstanceState.get(state) + const item = yield* lookup(id) + if (!item) return undefined + const load = findLoad(st, id, payload.artifact_load_token) + const revision = Number(payload.artifact_revision) + if (!load || !Number.isInteger(revision) || revision !== load.revision) return { stale: true, fresh: [] } + const raw = Array.isArray(payload.failures) ? payload.failures : [] + const fresh: Failure[] = [] + for (const entry of raw.slice(0, MAX_FAILURES)) { + if (!entry || typeof entry !== "object") continue + const kind = String((entry as Record).kind || "") + if (!(FAILURE_KINDS as readonly string[]).includes(kind)) continue + const detail = String((entry as Record).detail || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 300) + const key = `${kind}|${detail}` + if (load.failures.has(key)) continue + load.failures.add(key) + fresh.push({ kind: kind as Failure["kind"], detail }) + } + return { stale: false, fresh } + }) + return Service.of({ register, get, @@ -527,6 +784,14 @@ const layer = Layer.effect( referenced, attachments, exclusive, + settings, + beginLoad, + verifyLoad, + diagnostics, + prepareWarnings, + commitWarnings, + dismissWarning, + failures, }) }), ) diff --git a/packages/redcode/src/design/route-path.ts b/packages/redcode/src/design/route-path.ts index c247cadaf8b4..bc2ba385cffb 100644 --- a/packages/redcode/src/design/route-path.ts +++ b/packages/redcode/src/design/route-path.ts @@ -18,6 +18,14 @@ export type Target = | { readonly kind: "attachment"; readonly id: string; readonly aid?: string } /** A design asset we ship for prototypes that have no network: tailwind, daisyui, mermaid. */ | { readonly kind: "vendor"; readonly name: string } + /** A shell is about to load the prototype into its frame, and wants a token for that load. */ + | { readonly kind: "load"; readonly id: string } + /** One browser pass of the passive layout audit. Never wakes the agent. */ + | { readonly kind: "diagnostics"; readonly id: string } + /** The layout inbox: read it, prepare a batch of fixes, or dismiss one warning. */ + | { readonly kind: "warnings"; readonly id: string; readonly action?: "queue" | "dismiss" } + /** The prototype could not be shown at all. The one report that does wake the agent. */ + | { readonly kind: "failures"; readonly id: string } const ID = /^[A-Za-z0-9_-]{1,64}$/ @@ -38,6 +46,12 @@ export function parse(pathname: string): Target | undefined { if (tail === "/events") return { kind: "events", id } if (tail === "/end") return { kind: "end", id } if (tail === "/attachments") return { kind: "attachment", id } + if (tail === "/loads/begin") return { kind: "load", id } + if (tail === "/layout-diagnostics") return { kind: "diagnostics", id } + if (tail === "/layout-warnings") return { kind: "warnings", id } + if (tail === "/layout-warnings/queue") return { kind: "warnings", id, action: "queue" } + if (tail === "/layout-warnings/dismiss") return { kind: "warnings", id, action: "dismiss" } + if (tail === "/artifact-failures") return { kind: "failures", id } const attachment = /^\/attachments\/([0-9a-f]{64}\.(?:png|jpg|webp))$/.exec(tail) if (attachment) return { kind: "attachment", id, aid: attachment[1]! } // The `/files/` prefix is what makes relative asset paths inside a prototype resolve back into diff --git a/packages/redcode/src/design/sdk.ts b/packages/redcode/src/design/sdk.ts index 30c38c5b1852..63f825f76d10 100644 --- a/packages/redcode/src/design/sdk.ts +++ b/packages/redcode/src/design/sdk.ts @@ -12,14 +12,18 @@ */ import { artifactMain, type ArtifactConfig } from "./client/artifact" +import { artifactAudit } from "./client/audit" import { HELPERS } from "./client/helpers" /** A readable name for an element, short enough to sit in a transcript line. */ export const LABEL_PARTS = 3 export function sdkScript(config: ArtifactConfig = { load: 0 }) { - const declarations = HELPERS.map((fn) => fn.toString()).join("\n") - const table = "{ " + HELPERS.map((fn) => fn.name + ": " + fn.name).join(", ") + " }" + // The audit is one more declaration in the same scope: bigger than a helper, but bundled the + // same way, so it is typechecked and tested here and cannot drift from what the route serves. + const parts = [...HELPERS, artifactAudit] + const declarations = parts.map((fn) => fn.toString()).join("\n") + const table = "{ " + parts.map((fn) => fn.name + ": " + fn.name).join(", ") + " }" return `(() => { ${declarations} ;(${artifactMain.toString()})(${JSON.stringify(config)}, ${table}) diff --git a/packages/redcode/src/design/shell.ts b/packages/redcode/src/design/shell.ts index 573c742c10a1..ecc4bc2b7d1e 100644 --- a/packages/redcode/src/design/shell.ts +++ b/packages/redcode/src/design/shell.ts @@ -28,6 +28,10 @@ export interface ShellInput { readonly embed?: boolean /** The image limits the server enforces; the page refuses early and says why. */ readonly attachments?: { readonly maxCount: number; readonly maxBytes: number; readonly accepted: readonly string[] } + /** Hold the prototype behind a curtain until its first layout pass; off for an ended review. */ + readonly gate?: boolean + /** How long the curtain may hold before the prototype is shown anyway. */ + readonly gateTimeoutMs?: number } /** Everything the prototype may say; anything else is ignored rather than interpreted. */ @@ -44,6 +48,8 @@ export const ARTIFACT_MESSAGES = [ "reviewDraftUnrestorable", "uploadAttachment", "mode", + "layoutDiagnostics", + "artifactAssetFailure", ] as const /** A page may upload this many images a minute, this many bytes in its lifetime, this many at once. */ @@ -58,6 +64,12 @@ export const MOBILE_SHEET_MEDIA = "(max-width: 860px)" export const SHEET_DRAG_THRESHOLD_PX = 48 /** A send still unacknowledged after this long says so. */ export const SEND_ACKNOWLEDGEMENT_WARNING_MS = 10_000 +/** A frame that has said nothing after this long is asked whether it can be served at all. */ +export const ARTIFACT_SILENCE_PROBE_MS = 8_000 +/** A frame that has still said nothing after this long gets a card with a way out. */ +export const ARTIFACT_BOOT_FAILSAFE_MS = 15_000 +/** The curtain's default hold, when the server sends none. */ +export const GATE_TIMEOUT_MS = 12_000 const escape = (value: string) => value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) @@ -92,6 +104,8 @@ export function shellHTML(input: ShellInput) { maxBytes: 10 * 1024 * 1024, accepted: ["image/png", "image/jpeg", "image/webp"], }, + gate: input.gate !== false && !input.ended, + gateTimeoutMs: input.gateTimeoutMs && input.gateTimeoutMs > 0 ? input.gateTimeoutMs : GATE_TIMEOUT_MS, }).replace(/ @@ -112,13 +126,44 @@ export function shellHTML(input: ShellInput) { header strong { font-weight: 600; white-space: nowrap } header .status { opacity: .6; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis } main { display: grid; grid-template-columns: minmax(0, 1fr) min(360px, 34vw); min-height: 0; position: relative } - iframe { border: 0; width: 100%; height: 100%; background: #fff } + .stage { position: relative; min-width: 0; min-height: 0 } + iframe { border: 0; width: 100%; height: 100%; background: #fff; display: block } button { font: inherit; padding: .4rem .75rem; border-radius: .375rem; border: 1px solid var(--edge); background: transparent; color: inherit; cursor: pointer } button[disabled] { opacity: .5; cursor: default } button.mode { padding: .25rem .6rem } button.mode[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent) } button.more { padding: .25rem .5rem; font-weight: 700 } + button.issues { padding: .25rem .6rem; display: inline-flex; align-items: center; gap: .4rem; border-color: #d0432b; color: #d0432b } + button.issues[aria-expanded="true"] { background: color-mix(in oklab, #d0432b 14%, transparent) } + .badge { display: inline-block; min-width: 1.4em; padding: 0 .35em; border-radius: 999px; background: #d0432b; color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 1.5 } + .drawer { position: absolute; top: .5rem; right: .5rem; z-index: 45; width: min(440px, calc(100% - 1rem)); max-height: calc(100% - 1rem); + display: grid; grid-template-rows: auto minmax(0, 1fr) auto; background: Canvas; color: CanvasText; + border: 1px solid var(--edge); border-radius: .6rem; box-shadow: 0 12px 40px rgba(0,0,0,.25) } + .drawer[hidden] { display: none } + .drawer-head, .drawer-foot { display: flex; align-items: center; gap: .6rem; padding: .5rem .75rem; border-bottom: 1px solid var(--edge) } + .drawer-foot { border-bottom: 0; border-top: 1px solid var(--edge); justify-content: flex-end } + .drawer-head label { display: inline-flex; align-items: center; gap: .35rem } + .drawer-head .sum { flex: 1; min-width: 0; font-size: 12px; opacity: .7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis } + .drawer-head .close { border: 0; padding: .1rem .4rem; font-size: 16px; line-height: 1 } + .drawer-foot .sel { flex: 1; font-size: 12px; opacity: .7 } + .warnings { overflow: auto; display: flex; flex-direction: column } + .warnings .empty { padding: 1rem .75rem; opacity: .7 } + .warning { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .5rem; padding: .6rem .75rem; border-bottom: 1px solid var(--edge) } + .warning.outstanding { opacity: .75 } + .warning input { margin-top: .2rem } + .warning .title { font-weight: 600 } + .warning .explain { margin: .15rem 0 .3rem; opacity: .85 } + .warning .meta { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .3rem } + .warning .chip { display: inline-block; padding: 0 .45rem; border-radius: 999px; background: var(--soft); font-size: 11px; border: 0 } + .warning .chip.sev { background: color-mix(in oklab, #d0432b 18%, transparent); color: #d0432b; font-weight: 600 } + .warning .chip.st-queued, .warning .chip.st-recurring, .warning .chip.st-unverified { background: color-mix(in oklab, var(--accent) 30%, transparent) } + .warning code { display: block; font-size: 11px; opacity: .7; word-break: break-all; margin-bottom: .3rem } + .warning .acts { display: flex; gap: .4rem } + .warning .acts button { padding: .15rem .5rem; font-size: 12px } + .overlay.gate { z-index: 48; background: color-mix(in oklab, Canvas 92%, transparent) } + .overlay .row { display: flex; gap: .5rem; justify-content: center; margin-top: .75rem } + .overlay .row .secondary { opacity: .75 } .menu { position: absolute; right: .5rem; top: calc(var(--bar-h) + .25rem); z-index: 40; min-width: 240px; background: Canvas; color: CanvasText; border: 1px solid var(--edge); border-radius: .5rem; box-shadow: 0 12px 40px rgba(0,0,0,.25); padding: .25rem; display: none } @@ -215,6 +260,7 @@ export function shellHTML(input: ShellInput) {
${escape(input.name)} click an element in the prototype to annotate it +
@@ -230,7 +276,22 @@ export function shellHTML(input: ShellInput) { model-written and must stay at an opaque origin, unable to read this page's token. The serving route repeats the restriction in a header, so it holds even when this page is bypassed and the prototype URL is opened directly. --> - +
+ +

Checking layout…

Waiting for fonts and final geometry before showing the prototype.

+ +