Files
paperclip/scripts/check-token-gates.mjs
DottaandPaperclip d2665ff6b4 fix(ui): align the mobile task chat composer with the thread (#11296)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task thread is where people read work and guide agents.
> - The mobile composer should use the same content width as the thread.
> - The composer kept the desktop 80% width on mobile, so its edges did
not align with the thread.
> - Long assignee-aware placeholder text could also clip inside the
mobile editor.
> - Some extracted style tokens used legacy HSL wrappers around complete
semantic colors, which made those declarations invalid.
> - This pull request makes the composer full width on mobile, preserves
the narrower desktop layout, wraps the placeholder, and repairs the
invalid color compositions.
> - The benefit is a stable mobile composer that aligns with the task
thread and keeps its intended visual styles.

## Linked Issues or Issue Description

Related work: Refs #11263.

**What happened?**

At mobile widths, the task chat composer used the same 80% width as the
desktop composer. Its horizontal edges did not align with the full task
thread. A long assignee-aware placeholder could clip on one line. The
composer's extracted shadow also used a legacy `hsl(var(...))` wrapper
around complete semantic color values, so the browser could reject the
declaration.

**Expected behavior**

The composer must match the task thread width on mobile. It must stay
narrower on larger screens. Long placeholder text must wrap inside the
editor. Semantic color tokens must form valid shadows and gradients.

**Steps to reproduce**

1. Open a task with the chat-style thread on a mobile viewport.
2. Compare the composer edges with the task thread edges.
3. Select an assignee whose placeholder text wraps to two lines.
4. Inspect the computed composer shadow and the extracted semantic color
styles.

**Paperclip version or commit**

The change is based on `dc6fcd1ff1` from `master`.

**Deployment mode**

Local build from source. The behavior also applies to packaged web
builds.

## What Changed

- Made the task chat composer full width below the medium breakpoint and
kept the 80% desktop width.
- Matched the composer dock padding to the task thread padding.
- Allowed long composer placeholders to wrap and reserved enough mobile
editor height for two lines.
- Replaced invalid legacy HSL wrappers around full semantic colors in
extracted shadows, gradients, and approval styles.
- Added a token gate that prevents legacy `hsl(var(--token))` wrappers
from returning.
- Added focused regression tests for responsive width, padding,
placeholder wrapping, mobile height, and semantic shadow validity.

## Verification

- `pnpm check:token-gates` — all four gates pass.
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/TaskChatThread.test.tsx
src/components/task-chat/TaskChatComposer.test.tsx
src/components/task-chat/TaskChatComposerStyles.test.ts` — 37 tests
pass.
- `pnpm --filter @paperclipai/ui typecheck` — passes.
- `pnpm --filter @paperclipai/ui build` — passes. The build prints
existing CSS optimizer and bundle-size warnings.

## Risks

- Low risk. The width change is limited to the mobile breakpoint. The
desktop 80% layout remains in place.
- The semantic token fixes can affect shadows and gradients that were
previously invalid. The new gate prevents the invalid wrapper pattern
from returning.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with `gpt-5.6-sol`. The context-window size is not
exposed in this environment. The model used reasoning, repository tools,
code execution, and GitHub tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-14 12:53:11 -04:00

342 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* check-token-gates.mjs
*
* Phase 2 (extraction) DONE-WHEN gate check for the design-token-extraction
* run (branch design/token-extraction; see DESIGN.md, GOAL-PROMPT.md,
* TOKEN-AUDIT.md). Scans `ui/src/components/**` and `ui/src/pages/**`
* (excluding `ui/src/lib|context|plugins`, which are explicitly out of
* scope for this run per TOKEN-AUDIT.md's Batch 4 log) for three gates:
*
* Gate 1 — zero hardcoded COLOR LITERALS: hex colors (#fff, #ffffff,
* #ffffffff) and rgb()/rgba()/hsl()/hsla()/oklch() value literals
* (i.e. NOT a var() reference, and not merely referencing a CSS
* variable inside one of those functions, e.g. hsl(var(--primary)) is
* fine — only a literal numeric color argument fails the gate).
*
* Gate 2 — zero VALUE-BEARING arbitrary Tailwind bracket utilities:
* bracket contents (`utility-[...]`) that carry a rendered CSS value
* (digits with CSS units, bare numbers, color literals, or CSS value
* functions like calc()/min()/max()/clamp()/var()/linear-gradient()/
* cubic-bezier()/rgba()/env()). This is checked on the UTILITY
* position, i.e. `word-[...]` where `word` is not itself a selector/
* variant keyword.
*
* SELECTOR/VARIANT BRACKETS ARE EXCLUDED BY DEFINITION, not by
* omission: `data-[...]`, `group-data-[...]`, `has-[...]`,
* `group-has-data-[...]`, `aria-[...]`, `supports-[...]`, and
* `max-[...]`/`min-[...]` used as a BREAKPOINT VARIANT PREFIX (i.e.
* immediately followed by `:`, such as `max-[480px]:hidden`) are CSS
* SELECTOR CONDITIONS or responsive variant prefixes, not visual
* values applied to a property — they describe WHEN a rule applies,
* not WHAT value it sets. A variant's bracket cannot reference a CSS
* custom property (Tailwind resolves variants at build time, before
* any `var()` could be evaluated), so there is nothing to tokenize;
* tokenizing would require changing Tailwind's own variant syntax,
* which is out of scope. These are recognized structurally: a
* bracket immediately followed by `:` (not part of a class string's
* trailing utility) is a variant, not a utility value.
*
* True exceptions that DO carry a value but cannot be tokenized are
* ALLOWLISTED, not silently excluded (see ALLOWLIST parsing below):
* `max-[480px]`/`min-[420px]` breakpoint variants (variant position
* cannot reference a var), and `rounded-[inherit]` (a CSS-wide
* keyword, not a literal value, cannot come from a custom property).
*
* Gate 3 — zero raw FONT-SIZE declarations: `text-[Npx]`/`text-[N.Nrem]`
* Tailwind arbitrary font-size utilities (a subset of gate 2, checked
* explicitly since font-size is its own DESIGN.md-named category) and
* `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:
* * allow <repo-relative-path> — <reason>
* A violation at a path is suppressed if the path CONTAINS (substring
* match) any allowlisted path. This intentionally allowlists the whole
* file for simplicity/reviewability, matching how Batches 1-3 allowlisted
* entire sites' surrounding functional code rather than individual
* characters.
*
* Exit code: 0 if all three gates are clean (prints a per-gate summary).
* Exit code: 1 if any gate has violations (lists them, grouped by gate).
*
* Usage: node scripts/check-token-gates.mjs
*/
import { readFileSync, readdirSync } from "node:fs";
import { resolve, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..");
const UI_SRC = resolve(REPO_ROOT, "ui/src");
const SCAN_DIRS = ["components", "pages"];
const CSS_PATH = resolve(UI_SRC, "index.css");
// ── Allowlist parsing ────────────────────────────────────────────────────
// Reads the machine-readable "* allow <path> — <reason>" lines from the
// ALLOWLIST block in ui/src/index.css. Tolerant of either em-dash (—) or
// a plain hyphen-minus as the path/reason separator, and of the historical
// per-batch prose blocks NOT being in this format (they are not parsed;
// only lines starting with "* allow " are).
function loadAllowlist(cssPath) {
const css = readFileSync(cssPath, "utf8");
const entries = [];
const lineRe = /^\s*\*\s*allow\s+(\S+)\s+(?:—|-{1,2})\s*(.*)$/;
for (const rawLine of css.split("\n")) {
const m = rawLine.match(lineRe);
if (m) {
entries.push({ path: m[1], reason: m[2].trim() });
}
}
return entries;
}
function isAllowlisted(relPath, allowlist) {
return allowlist.some((entry) => relPath.includes(entry.path));
}
// ── File walking ─────────────────────────────────────────────────────────
function walk(dir, out) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) walk(p, out);
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p);
}
}
function listFiles() {
const files = [];
for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files);
files.sort();
return files;
}
// ── Gate 1: color literals ───────────────────────────────────────────────
// Hex colors: #abc, #aabbcc, #aabbccdd — word-boundary guarded so it
// doesn't match inside identifiers, and NOT preceded by another hex digit
// (avoids over-matching truncated substrings of longer non-color tokens,
// though `#` itself is a strong enough anchor in practice).
// A genuine CSS hex color is never glued directly to an identifier
// character (letter/digit/underscore) or `/` immediately before the `#` —
// that shape is an issue/PR reference like "acme/web#241" or "acme/web#12"
// (Batch 1's codemod header documented this exact false-positive risk for
// its own hex-literal sweep; the same guard applies here). A real color
// literal is preceded by a delimiter (quote, colon, paren, comma,
// whitespace, backtick, template `${`) or sits at the start of the string.
const HEX_COLOR_RE = /(?<![a-zA-Z0-9_/])#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
// rgb()/rgba()/hsl()/hsla()/oklch() with a LITERAL first argument (a digit,
// a `.` decimal, or a `%` — i.e. not `var(` or `calc(` immediately inside).
// `hsl(var(--x)/0.16)` must NOT match (var() reference); `rgba(0,0,0,0.5)`
// MUST match (literal numeric channels).
const COLOR_FN_LITERAL_RE = /\b(?:rgb|rgba|hsl|hsla|oklch)\(\s*(?!var\()[0-9.%-]/g;
function findColorLiteralIssues(content) {
const issues = [];
for (const m of content.matchAll(HEX_COLOR_RE)) {
issues.push({ index: m.index, snippet: m[0] });
}
for (const m of content.matchAll(COLOR_FN_LITERAL_RE)) {
issues.push({ index: m.index, snippet: m[0] });
}
return issues;
}
// ── Gate 2: value-bearing arbitrary bracket utilities ───────────────────
// Matches `word-[content]` (optionally prefixed by `!`, and optionally
// preceded by a Tailwind variant chain like `sm:` / `dark:` / `hover:` /
// `data-[state=open]:` etc. — the regex only needs to find the utility's
// OWN bracket, not parse the whole variant chain, since VARIANT_KEYWORDS
// below excludes variant-shaped words directly at the match site).
//
// A bracket is a VARIANT (excluded by definition, see header) if:
// (a) the word immediately before `-[` is one of the known variant
// keywords (data, group-data, has, group-has-data, aria, supports,
// group-aria, peer-data, peer-aria, in, not), OR
// (b) the bracket is immediately followed by `:` (a breakpoint-style
// variant prefix, e.g. `max-[480px]:hidden` — the `:` right after
/// `]` is the structural signal that this bracket is a CONDITION,
// not a value).
const BRACKET_RE = /(!?)([a-zA-Z][a-zA-Z0-9-]*)-\[([^\[\]]*)\]/g;
const VARIANT_WORD_RE =
/(?:^|[\s"'`{])(?:group-|peer-)?(?:data|has|aria|supports|in|not)(?:-[a-zA-Z0-9]+)*$/;
// A bracket carries a VALUE (not just a keyword/selector fragment) if its
// content looks like: a number (optionally with a CSS unit or %), a CSS
// color literal (# hex or a color function), OR a known CSS value function
// call (calc/min/max/clamp/var/env/linear-gradient/radial-gradient/
// conic-gradient/cubic-bezier/rgba/rgb/hsl/hsla/oklch). Pure CSS KEYWORDS
// (e.g. `inherit`, `auto`, `pointer`) do NOT match and are not gated here
// (they're a separate, allowlisted concern — see `rounded-[inherit]`).
const VALUE_UNIT_RE = /^-?[0-9.]+(?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|s|ms|fr)?$/;
const VALUE_FUNC_RE =
/^(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/;
const HEX_ONLY_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function bracketCarriesValue(raw) {
const trimmed = raw.trim();
if (VALUE_UNIT_RE.test(trimmed)) return true;
if (HEX_ONLY_RE.test(trimmed)) return true;
if (VALUE_FUNC_RE.test(trimmed)) return true;
// A bracket containing an embedded CSS value function anywhere (e.g. a
// grid track list `56px_56px_24px_minmax(0,1fr)` that doesn't itself
// start with one of the above, or `translate-y-[-50%]`-style negative
// percentages already covered by VALUE_UNIT_RE) also counts.
if (/[0-9](?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|fr)\b/.test(trimmed)) return true;
if (/\b(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/.test(trimmed)) return true;
if (HEX_COLOR_RE.test(trimmed)) return true;
return false;
}
function findArbitraryBracketIssues(content) {
const issues = [];
for (const m of content.matchAll(BRACKET_RE)) {
const [full, , word, raw] = m;
const matchEnd = m.index + full.length;
const followedByColon = content[matchEnd] === ":";
if (followedByColon) continue; // breakpoint/arbitrary-variant prefix, not a utility value
// Reject if `word` itself IS (or ends in) a variant keyword shape, e.g.
// a match that accidentally captured "...data" as the utility name for
// some malformed/edge case. In practice BRACKET_RE's utility-name
// capture group only ever contains real utility names (data-[...] etc.
// are matched with `word` = "data", "group-data", "has", etc.).
const precedingContext = content.slice(Math.max(0, m.index - 1), m.index + word.length + 1);
if (VARIANT_WORD_RE.test(precedingContext)) continue;
if (/^(?:data|has|aria|supports|group-data|group-has-data|group-aria|peer-data|peer-aria|group-has-data-slot|in|not)$/.test(word)) {
continue;
}
if (!raw.includes("[") && bracketCarriesValue(raw)) {
issues.push({ index: m.index, snippet: `${word}-[${raw}]` });
}
}
return issues;
}
// ── Gate 3: raw font-size declarations ──────────────────────────────────
const FONT_SIZE_CLASS_RE = /\btext-\[(?:[0-9.]+(?:px|rem|em)|[0-9.]+\/[0-9.]+)\]/g;
// A raw literal font-size value: starts with a digit (px/rem/em number) —
// EXCLUDES `fontSize: "var(--text-micro)"`-style token references, which start
// with `var(` and are the desired post-extraction form, not a violation.
const FONT_SIZE_INLINE_RE = /\bfontSize\s*:\s*["'][0-9][^"']*["']/g;
const FONT_SIZE_CSS_PROP_RE = /(?<!-)\bfont-size\s*:\s*["'`][0-9][^"'`]*["'`]/g;
function findFontSizeIssues(content) {
const issues = [];
for (const m of content.matchAll(FONT_SIZE_CLASS_RE)) {
issues.push({ index: m.index, snippet: m[0] });
}
for (const m of content.matchAll(FONT_SIZE_INLINE_RE)) {
issues.push({ index: m.index, snippet: m[0] });
}
for (const m of content.matchAll(FONT_SIZE_CSS_PROP_RE)) {
issues.push({ index: m.index, snippet: m[0] });
}
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;
}
function main() {
const allowlist = loadAllowlist(CSS_PATH);
const files = listFiles();
const violations = { gate1: [], gate2: [], gate3: [], gate4: [] };
let allowlistedSkips = 0;
for (const filePath of files) {
const content = readFileSync(filePath, "utf8");
const relPathPosix = relPathToPosix(filePath);
const allowed = isAllowlisted(relPathPosix, allowlist);
const g1 = findColorLiteralIssues(content);
const g2 = findArbitraryBracketIssues(content);
const g3 = findFontSizeIssues(content);
if (allowed) {
allowlistedSkips += g1.length + g2.length + g3.length;
continue;
}
for (const issue of g1) {
violations.gate1.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
}
for (const issue of g2) {
violations.gate2.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
}
for (const issue of g3) {
violations.gate3.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
}
}
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}`);
console.log(` Allowlist entries loaded: ${allowlist.length}`);
console.log(` Allowlisted issues skipped: ${allowlistedSkips}`);
console.log("");
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");
for (const [gateName, list] of Object.entries(violations)) {
if (list.length === 0) continue;
console.log(`── ${gateName} ──`);
for (const v of list) {
console.log(` ${v.file}:${v.line} ${v.snippet}`);
}
console.log("");
}
process.exitCode = 1;
return;
}
console.log("\nAll gates clean.");
process.exitCode = 0;
}
// Windows path separators never appear in this repo's CI, but keep relative
// paths POSIX-style for allowlist substring matching regardless of platform.
function relPathToPosix(filePath) {
return ("ui/src/" + relative(UI_SRC, filePath)).split("\\").join("/");
}
main();