diff --git a/scripts/check-token-gates.mjs b/scripts/check-token-gates.mjs index 26722d70d3..c9ea0b6b7a 100644 --- a/scripts/check-token-gates.mjs +++ b/scripts/check-token-gates.mjs @@ -49,6 +49,11 @@ * `fontSize: "..."` / `font-size:` string-literal declarations in * inline styles or css-in-js. * + * Gate 4 — zero legacy hsl(var(--token)) wrappers in the token layer. + * Semantic colors are complete color values (currently OKLCH), not bare + * HSL channels. Wrapping one in hsl() creates an invalid declaration and + * can void an entire composed box-shadow. + * * The ALLOWLIST is parsed from the machine-readable block in * ui/src/index.css (search for "── ALLOWLIST" below it), one entry per * line in the form: @@ -240,6 +245,17 @@ function findFontSizeIssues(content) { return issues; } +// Semantic color custom properties hold complete color values. Legacy +// Tailwind-v3-era hsl(var(--token) / alpha) composition is therefore invalid. +const LEGACY_HSL_VAR_WRAPPER_RE = /\bhsla?\(\s*var\(--[^)]+\)[^)]*\)/g; + +function findLegacyHslVarWrapperIssues(content) { + return Array.from(content.matchAll(LEGACY_HSL_VAR_WRAPPER_RE), (match) => ({ + index: match.index, + snippet: match[0], + })); +} + function lineNumberAt(content, index) { return content.slice(0, index).split("\n").length; } @@ -248,7 +264,7 @@ function main() { const allowlist = loadAllowlist(CSS_PATH); const files = listFiles(); - const violations = { gate1: [], gate2: [], gate3: [] }; + const violations = { gate1: [], gate2: [], gate3: [], gate4: [] }; let allowlistedSkips = 0; for (const filePath of files) { @@ -277,7 +293,16 @@ function main() { } } - const totalViolations = violations.gate1.length + violations.gate2.length + violations.gate3.length; + const tokenLayer = readFileSync(CSS_PATH, "utf8"); + for (const issue of findLegacyHslVarWrapperIssues(tokenLayer)) { + violations.gate4.push({ + file: relPathToPosix(CSS_PATH), + line: lineNumberAt(tokenLayer, issue.index), + snippet: issue.snippet, + }); + } + + const totalViolations = Object.values(violations).reduce((total, gate) => total + gate.length, 0); console.log("check-token-gates summary"); console.log(` Files scanned: ${files.length}`); @@ -287,6 +312,7 @@ function main() { console.log(` Gate 1 (color literals): ${violations.gate1.length === 0 ? "CLEAN" : `${violations.gate1.length} violation(s)`}`); console.log(` Gate 2 (arbitrary bracket vals): ${violations.gate2.length === 0 ? "CLEAN" : `${violations.gate2.length} violation(s)`}`); console.log(` Gate 3 (raw font-size): ${violations.gate3.length === 0 ? "CLEAN" : `${violations.gate3.length} violation(s)`}`); + console.log(` Gate 4 (legacy hsl(var())): ${violations.gate4.length === 0 ? "CLEAN" : `${violations.gate4.length} violation(s)`}`); if (totalViolations > 0) { console.log("\nViolations:\n"); diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 2976c504e4..34be3e9125 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -54,6 +54,19 @@ function render(ui: ReactElement) { } describe("TaskChatThread draft pass-through", () => { + it("keeps the composer dock aligned with the thread's horizontal padding", () => { + render( + {}} + />, + ); + + const dock = container.querySelector('[data-testid="task-chat-composer-dock"]'); + expect(dock?.classList).toContain("px-4"); + expect(dock?.classList).not.toContain("px-1"); + }); + it("forwards draftKey so the composer restores a task's saved draft", () => { localStorage.setItem("task-chat-draft:issue-1", "half-written thought"); @@ -71,15 +84,15 @@ describe("TaskChatThread draft pass-through", () => { }); describe("TaskChatThread composer alignment (PAP-498)", () => { - it("keeps the composer dock at 80% of the thread width", () => { + it("matches the thread width on mobile and stays narrower on larger screens", () => { render( {}} />); const dock = container .querySelector('[data-testid="mock-editor"]') ?.closest("div.sticky") as HTMLElement | null; - expect(dock?.className).toContain("w-(--pct-80)"); - expect(dock?.className).not.toContain("w-full"); + expect(dock?.className).toContain("w-full"); + expect(dock?.className).toContain("md:w-(--pct-80)"); }); }); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 30e60a025a..942d0ffbdd 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -610,6 +610,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { {showComposer ? (
{composerAccessory} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index e82fa34f0b..dd4b0a0448 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -43,10 +43,12 @@ vi.mock("@mdxeditor/editor", async () => { markdown, onChange, readOnly, + contentEditableClassName, }: { markdown: string; onChange?: (value: string) => void; readOnly?: boolean; + contentEditableClassName?: string; }, forwardedRef: React.ForwardedRef, ) { @@ -84,6 +86,7 @@ vi.mock("@mdxeditor/editor", async () => {
{ @@ -255,6 +258,18 @@ describe("TaskChatComposer", () => { expect(composer?.className).not.toContain("p-2"); }); + it("scopes the wrapping placeholder override to the task-chat composer", () => { + render(); + + expect(container.firstElementChild?.classList).toContain("paperclip-task-chat-composer"); + }); + + it("reserves enough mobile editor height for a wrapped two-line placeholder", () => { + render(); + + expect(editable().dataset.contentClassName).toContain("min-h-(--sz-72px)"); + }); + it("submits the trimmed body on Cmd+Enter and clears the draft", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 72ffd24261..4062b1ced6 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -371,7 +371,7 @@ export function TaskChatComposer({ return (
{ // Shift+Tab cycles the pending mode; captured on the wrapper so it @@ -400,7 +400,7 @@ export function TaskChatComposer({ className={cn(disabled && "opacity-60")} contentClassName={ mobile - ? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide" + ? "max-h-(--sz-28dvh) min-h-(--sz-72px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide" : "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide" } /> diff --git a/ui/src/components/task-chat/TaskChatComposerStyles.test.ts b/ui/src/components/task-chat/TaskChatComposerStyles.test.ts new file mode 100644 index 0000000000..ce79a25ad2 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatComposerStyles.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const stylesheet = readFileSync(new URL("../../index.css", import.meta.url), "utf8"); + +function cssBlock(selector: string): string { + const start = stylesheet.indexOf(`${selector} {`); + expect(start, `Missing CSS selector: ${selector}`).toBeGreaterThanOrEqual(0); + + const bodyStart = stylesheet.indexOf("{", start); + const bodyEnd = stylesheet.indexOf("\n}", bodyStart); + expect(bodyEnd, `Missing CSS block end: ${selector}`).toBeGreaterThan(bodyStart); + + return stylesheet.slice(bodyStart + 1, bodyEnd); +} + +function customPropertyValue(name: string): string { + const match = stylesheet.match(new RegExp(`^\\s*${name}:\\s*([^;]+);`, "m")); + expect(match, `Missing CSS custom property: ${name}`).not.toBeNull(); + return match?.[1].trim() ?? ""; +} + +describe("task-chat composer styles", () => { + it("wraps long placeholders within the composer instead of clipping them", () => { + const block = cssBlock( + '.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"]', + ); + + expect(block).toContain("display: block"); + expect(block).toContain("width: 100%"); + expect(block).toContain("overflow-wrap: anywhere"); + expect(block).toContain("white-space: normal"); + }); + + it("keeps the composer's combined shadow valid with full-color semantic tokens", () => { + const shadow = customPropertyValue("--shadow-extract-7"); + + expect(shadow).toContain("color-mix(in oklab, var(--primary) 16%, transparent)"); + expect(shadow).not.toContain("hsl(var("); + }); +}); diff --git a/ui/src/index.css b/ui/src/index.css index b2db57da1b..f8792195e3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -596,8 +596,8 @@ to { opacity: 1; transform: none; } } @keyframes tc-approval-pulse { - 0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.0); } - 50% { box-shadow: 0 0 0 3px hsl(var(--primary) / 0.18); } + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent); } + 50% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 18%, transparent); } } @keyframes tc-cursor-blink { 0%, 45% { opacity: 1; } @@ -703,7 +703,7 @@ /* The pill keyframes carry the X-centering; with animation:none restore it. */ .tc-scroll-pill-in, .tc-scroll-pill-out { transform: translate(-50%, 0); } - .tc-approval { animation: none; box-shadow: 0 0 0 2px hsl(var(--primary) / 0.18); } + .tc-approval { animation: none; box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 18%, transparent); } .tc-cursor { animation: none; opacity: 1; } } @@ -1233,6 +1233,16 @@ color: var(--muted-foreground); } +/* MDXEditor defaults placeholders to a single clipped line. The task-chat + composer uses longer, assignee-aware copy, so keep it inside the editor by + giving the absolutely positioned placeholder a wrapping inline size. */ +.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"] { + display: block; + width: 100%; + overflow-wrap: anywhere; + white-space: normal; +} + .paperclip-mdxeditor-content { font-size: inherit; line-height: inherit; @@ -2222,12 +2232,12 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --shadow-extract-4: 0 -12px 28px rgba(15,23,42,0.08); /* Extracted from ui/src/components/ChatComposer.test.tsx (shadow-[0_-12px_28px_rgba(15,23,42,0.08)]). */ --shadow-extract-5: 0 -12px 28px rgba(0,0,0,0.28); /* Extracted from ui/src/components/ChatComposer.test.tsx (shadow-[0_-12px_28px_rgba(0,0,0,0.28)]). */ --shadow-extract-6: 0 0 0 1px var(--color-background); /* Extracted from ui/src/components/DocumentAnnotationLayer.tsx (shadow-[0_0_0_1px_var(--color-background)]). */ - --shadow-extract-7: 0 -12px 28px rgba(15,23,42,0.08),0 0 0 1px hsl(var(--primary)/0.16); /* Extracted from ui/src/components/IssueChatThread.tsx (shadow-[0_-12px_28px_rgba(15,23,42,0.08),0_0_0_1px_hsl(var(--primary)/0.16)]). */ + --shadow-extract-7: 0 -12px 28px rgba(15,23,42,0.08),0 0 0 1px color-mix(in oklab, var(--primary) 16%, transparent); /* Extracted from ui/src/components/IssueChatThread.tsx; corrected for full-color semantic tokens. */ --shadow-extract-8: 0 1px 0 rgba(15,23,42,0.02); /* Extracted from ui/src/components/IssueRecoveryActionCard.tsx (shadow-[0_1px_0_rgba(15,23,42,0.02)]). */ --shadow-extract-9: 0 18px 42px rgba(15,23,42,0.06); /* Extracted from ui/src/components/IssueThreadInteractionCard.tsx (shadow-[0_18px_42px_rgba(15,23,42,0.06)]). */ - --shadow-extract-10: 0 1px 0 1px hsl(var(--border)); /* Extracted from ui/src/components/KeyboardShortcutsCheatsheet.tsx (shadow-[0_1px_0_1px_hsl(var(--border))]). */ + --shadow-extract-10: 0 1px 0 1px var(--border); /* Extracted from ui/src/components/KeyboardShortcutsCheatsheet.tsx; corrected for full-color semantic tokens. */ --shadow-extract-11: 0 18px 50px rgba(37,99,235,0.08); /* LiveRunWidget.tsx glow. Gallery feedback r2: liveness glow recolored cyan->status blue (value edit, site unchanged; originally extracted as rgba(6,182,212,0.08)). */ - --shadow-extract-12: 0 0 0 2px hsl(var(--background)); /* Extracted from ui/src/components/SidebarNavItem.tsx (shadow-[0_0_0_2px_hsl(var(--background))]). */ + --shadow-extract-12: 0 0 0 2px var(--background); /* Extracted from ui/src/components/SidebarNavItem.tsx; corrected for full-color semantic tokens. */ --shadow-extract-13: 0 0 0 3px rgba(245,158,11,0.18); /* Extracted from ui/src/components/environment-variables-editor/index.tsx (shadow-[0_0_0_3px_rgba(245,158,11,0.18)]). */ --shadow-extract-14: 0 0 12px rgba(37,99,235,0.08); /* AgentDetail.tsx live-card glow. Gallery feedback r2: liveness glow recolored cyan->status blue (value edit, site unchanged; originally extracted as rgba(6,182,212,0.08)). */ --shadow-extract-18: 0 18px 44px -28px rgba(0,0,0,0.45); /* Extracted from ui/src/pages/ProfileSettings.tsx (shadow-[0_18px_44px_-28px_rgba(0,0,0,0.45)]). */ @@ -2376,8 +2386,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --code-highlight-bg-resolved: var(--paperclip-code-highlight-bg); /* Extracted from ui/src/components/FileViewerSheet.tsx; fallback dropped in A5 now that --paperclip-code-highlight-bg is a real token (was var(--paperclip-code-highlight-bg, rgba(250,204,21,0.12))). */ --code-gutter-fg-resolved: var(--paperclip-code-gutter-fg, var(--muted-foreground)); /* Extracted from ui/src/components/FileViewerSheet.tsx (text-[var(--paperclip-code-gutter-fg,theme(colors.muted.foreground))]). */ --code-highlight-border-resolved: var(--paperclip-code-highlight-border); /* Extracted from ui/src/components/FileViewerSheet.tsx; fallback dropped in A5 now that --paperclip-code-highlight-border is a real token (was var(--paperclip-code-highlight-border, rgb(234,179,8))). */ - --gradient-extract-25: linear-gradient(135deg,hsl(var(--primary)) 0%,hsl(var(--accent)) 55%,hsl(var(--muted)) 100%); /* Extracted from ui/src/components/SidebarAccountMenu.tsx (bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]). */ - --gradient-extract-26: linear-gradient(135deg,hsl(var(--primary)) 0%,hsl(var(--accent)) 58%,color-mix(in oklab,hsl(var(--background)) 76%,white 24%) 100%); /* Extracted from ui/src/pages/ProfileSettings.tsx (bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_58%,color-mix(in_oklab,hsl(var(--background))_76%,white_24%)_100%)]). */ + --gradient-extract-25: linear-gradient(135deg,var(--primary) 0%,var(--accent) 55%,var(--muted) 100%); /* Extracted from ui/src/components/SidebarAccountMenu.tsx; corrected for full-color semantic tokens. */ + --gradient-extract-26: linear-gradient(135deg,var(--primary) 0%,var(--accent) 58%,color-mix(in oklab,var(--background) 76%,white 24%) 100%); /* Extracted from ui/src/pages/ProfileSettings.tsx; corrected for full-color semantic tokens. */ } /* ── ALLOWLIST (Phase 2, design/token-extraction) ──────────────────────────