Skip to content

Commit 3261d10

Browse files
committed
Classify tool input sizing intents (#325)
1 parent 3908529 commit 3261d10

34 files changed

Lines changed: 1038 additions & 411 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Input intent taxonomy
2+
3+
Tool input sizing uses five stable intents:
4+
5+
| Intent | Use |
6+
| --- | --- |
7+
| `scalar` | Numbers, colors, flags, enum-like controls, and other compact values. |
8+
| `shortText` | URLs, names, expressions, and short free-form text. |
9+
| `payload` | User-authored JSON, CSV, tokens, logs, code, and multiline data. |
10+
| `workbench` | Dense editor or split-pane work areas. |
11+
| `generatedOutput` | Read-only generated text or code with copy/export actions. |
12+
13+
`Input`, `Textarea`, Monaco wrappers, and `TextOutputPanel` emit `data-input-intent` at runtime. The create-tool scaffold declares a payload input, a workbench container, and generated output explicitly.
14+
15+
`tests/guards/input-intent-taxonomy.test.ts` parses every TSX file under `src` to inventory shared controls plus raw `input`, `textarea`, and `select` elements. Representative routes must declare an explicit intent. Remaining hardcoded height tokens are frozen by a stable file/tag/token hash, so a new unclassified size fails CI without depending on source line numbers.

scripts/e2e/run-playwright-smoke.js

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ const AXE_REVIEW_ROUTES = [
4747
"/en/trust-center",
4848
"/en/install-app",
4949
];
50+
const INPUT_INTENT_AUDIT_ROUTES = [
51+
{ route: "/en/qr-code-generator", requiredIntents: ["shortText", "scalar"] },
52+
{ route: "/en/base64-encode-decode", requiredIntents: ["payload", "generatedOutput"] },
53+
{ route: "/en/regex-tester", requiredIntents: ["scalar", "shortText", "payload"] },
54+
{ route: "/en/json-formatter", requiredIntents: ["workbench", "payload"] },
55+
{ route: "/en/csv-json-converter", requiredIntents: ["workbench", "payload", "generatedOutput"] },
56+
{ route: "/en/jwt-decoder", requiredIntents: ["workbench", "payload", "generatedOutput"] },
57+
{ route: "/en/pipeline-builder", requiredIntents: ["workbench", "shortText", "payload", "generatedOutput"] },
58+
];
59+
const INPUT_INTENT_AUDIT_VIEWPORTS = [
60+
{ width: 390, height: 844, mobile: true },
61+
{ width: 1280, height: 900, mobile: false },
62+
];
5063
const ALL_TOOLS_FILTER_INTERACTION_BUDGET_MS = 2000;
5164
const ALL_TOOLS_MOBILE_SCROLL_BUDGET_MS = 3500;
5265
const ALL_TOOLS_MOBILE_MAX_FRAME_DELTA_MS = 500;
@@ -194,6 +207,7 @@ function parseArgs(argv) {
194207
includePwa: false,
195208
firstLoadOnly: false,
196209
writeFirstLoadArtifacts: false,
210+
inputIntentsOnly: false,
197211
};
198212

199213
for (const arg of argv) {
@@ -217,6 +231,11 @@ function parseArgs(argv) {
217231
continue;
218232
}
219233

234+
if (arg === "--input-intents-only") {
235+
args.inputIntentsOnly = true;
236+
continue;
237+
}
238+
220239
if (arg.startsWith("--port=")) {
221240
const parsed = Number(arg.slice("--port=".length));
222241
if (Number.isFinite(parsed) && parsed > 0) {
@@ -1139,6 +1158,93 @@ async function assertMobileReviewMatrix(browser, baseUrl) {
11391158
}
11401159
}
11411160

1161+
async function assertInputIntentSizingMatrix(browser, baseUrl) {
1162+
for (const viewport of INPUT_INTENT_AUDIT_VIEWPORTS) {
1163+
const context = await browser.newContext({
1164+
serviceWorkers: "block",
1165+
viewport: { width: viewport.width, height: viewport.height },
1166+
isMobile: viewport.mobile,
1167+
});
1168+
1169+
try {
1170+
for (const audit of INPUT_INTENT_AUDIT_ROUTES) {
1171+
const page = await context.newPage();
1172+
const routeLabel = `${audit.route} input intent ${viewport.width}x${viewport.height}`;
1173+
const runtime = createRuntimeObserver(page, routeLabel, baseUrl);
1174+
1175+
try {
1176+
await page.goto(`${baseUrl}${audit.route}`, { waitUntil: "domcontentloaded" });
1177+
await page.waitForSelector("main", { timeout: 15_000 });
1178+
await page.locator("[data-input-intent]").first().waitFor({ state: "attached", timeout: 15_000 });
1179+
1180+
const measurements = await page.evaluate(({ mobile }) => {
1181+
const isVisible = (element) => {
1182+
const style = window.getComputedStyle(element);
1183+
const rect = element.getBoundingClientRect();
1184+
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
1185+
};
1186+
const nodes = Array.from(document.querySelectorAll("[data-input-intent]")).filter(isVisible);
1187+
const counts = {};
1188+
const issues = [];
1189+
1190+
for (const element of nodes) {
1191+
const intent = element.getAttribute("data-input-intent");
1192+
if (!intent) continue;
1193+
counts[intent] = (counts[intent] || 0) + 1;
1194+
1195+
const rect = element.getBoundingClientRect();
1196+
const tag = element.tagName.toLowerCase();
1197+
const field = ["input", "textarea", "select"].includes(tag);
1198+
if (intent === "scalar" && field) {
1199+
const target = element.closest("label") || element;
1200+
const targetRect = target.getBoundingClientRect();
1201+
const minimum = mobile ? 44 : 36;
1202+
if (targetRect.height + 0.5 < minimum || targetRect.width + 0.5 < minimum) {
1203+
issues.push(`${intent} ${tag}: ${Math.round(targetRect.width)}x${Math.round(targetRect.height)}`);
1204+
}
1205+
}
1206+
if (intent === "shortText" && tag === "input" && rect.height + 0.5 < (mobile ? 44 : 44)) {
1207+
issues.push(`${intent} input height: ${Math.round(rect.height)}px`);
1208+
}
1209+
if (intent === "shortText" && tag === "textarea") {
1210+
const style = window.getComputedStyle(element);
1211+
const resize = style.resize;
1212+
if (rect.height + 0.5 < 144) issues.push(`${intent} textarea height: ${Math.round(rect.height)}px (min-height ${style.minHeight}; ${element.className})`);
1213+
if (!resize.includes("vertical") && resize !== "both") issues.push(`${intent} textarea resize: ${resize}`);
1214+
}
1215+
if (["payload", "generatedOutput"].includes(intent) && ["textarea", "pre"].includes(tag) && rect.height + 0.5 < 256) {
1216+
issues.push(`${intent} ${tag} height: ${Math.round(rect.height)}px`);
1217+
}
1218+
}
1219+
1220+
return { counts, issues };
1221+
}, { mobile: viewport.mobile });
1222+
1223+
for (const intent of audit.requiredIntents) {
1224+
if (!measurements.counts[intent]) {
1225+
throw new Error(`${routeLabel} did not render required ${intent} input intent.`);
1226+
}
1227+
}
1228+
if (measurements.issues.length > 0) {
1229+
throw new Error(`${routeLabel} input sizing failed:\n- ${measurements.issues.join("\n- ")}`);
1230+
}
1231+
1232+
await assertNoHorizontalOverflow(page, routeLabel);
1233+
const screenshot = await page.screenshot({ animations: "disabled", fullPage: false });
1234+
if (screenshot.byteLength < 5_000) {
1235+
throw new Error(`${routeLabel} sizing screenshot was unexpectedly blank (${screenshot.byteLength} bytes).`);
1236+
}
1237+
runtime.assertClean();
1238+
} finally {
1239+
await page.close();
1240+
}
1241+
}
1242+
} finally {
1243+
await context.close();
1244+
}
1245+
}
1246+
}
1247+
11421248
async function assertAxeSeriousCriticalMatrix(browser, baseUrl) {
11431249
const context = await browser.newContext({ serviceWorkers: "block" });
11441250

@@ -2046,6 +2152,9 @@ async function runSmoke(baseUrl, { writeFirstLoadArtifacts = false } = {}) {
20462152
await assertMobileReviewMatrix(browser, baseUrl);
20472153
console.log("[playwright-smoke] PASS mobile review matrix: no overflow or touch-target regressions");
20482154

2155+
await assertInputIntentSizingMatrix(browser, baseUrl);
2156+
console.log("[playwright-smoke] PASS input intent sizing matrix: mobile/desktop heights, touch targets, overflow, and screenshots");
2157+
20492158
await assertAxeSeriousCriticalMatrix(browser, baseUrl);
20502159
console.log("[playwright-smoke] PASS accessibility: no serious or critical axe violations on representative pages");
20512160
} finally {
@@ -2073,11 +2182,22 @@ async function runPwaSmoke(baseUrl) {
20732182
}
20742183
}
20752184

2185+
async function runInputIntentSmoke(baseUrl) {
2186+
const browser = await chromium.launch({ headless: true });
2187+
try {
2188+
await assertInputIntentSizingMatrix(browser, baseUrl);
2189+
console.log("[playwright-smoke] PASS input intent sizing matrix: mobile/desktop heights, touch targets, overflow, and screenshots");
2190+
} finally {
2191+
await browser.close();
2192+
}
2193+
}
2194+
20762195
async function main() {
20772196
const {
20782197
baseUrl,
20792198
firstLoadOnly,
20802199
includePwa,
2200+
inputIntentsOnly,
20812201
port,
20822202
skipServer,
20832203
writeFirstLoadArtifacts,
@@ -2091,15 +2211,19 @@ async function main() {
20912211
console.log(`[playwright-smoke] Static server ready at ${baseUrl}`);
20922212
}
20932213

2094-
if (firstLoadOnly) {
2214+
if (inputIntentsOnly) {
2215+
await runInputIntentSmoke(baseUrl);
2216+
} else if (firstLoadOnly) {
20952217
await runFirstLoadAudit(baseUrl, { writeArtifacts: writeFirstLoadArtifacts });
20962218
} else {
20972219
await runSmoke(baseUrl, { writeFirstLoadArtifacts });
20982220
}
2099-
if (includePwa && !firstLoadOnly) {
2221+
if (includePwa && !firstLoadOnly && !inputIntentsOnly) {
21002222
await runPwaSmoke(baseUrl);
21012223
}
2102-
console.log("[playwright-smoke] PASS: critical routes render and navigate correctly");
2224+
if (!firstLoadOnly && !inputIntentsOnly) {
2225+
console.log("[playwright-smoke] PASS: critical routes render and navigate correctly");
2226+
}
21032227
} catch (error) {
21042228
console.error("[playwright-smoke] FAILED");
21052229
if (serverHandle) {

scripts/scaffolding/create-tool.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,20 +333,21 @@ export function ${componentName}Page() {
333333
</Button>
334334
</div>
335335
336-
<div className="grid gap-4 lg:grid-cols-2">
336+
<div data-input-intent="workbench" className="grid gap-4 lg:grid-cols-2">
337337
<div className="space-y-2">
338338
<label className="text-sm font-medium text-muted-foreground">{inputLabel}</label>
339339
<Textarea
340+
intent="payload"
340341
value={input}
341342
onChange={(event) => setInput(event.target.value)}
342343
placeholder={inputLabel}
343-
className="min-h-[360px] font-mono"
344+
className="font-mono"
344345
/>
345346
</div>
346347
347348
<div className="space-y-2">
348349
<label className="text-sm font-medium text-muted-foreground">{outputLabel}</label>
349-
<Textarea value={output} readOnly className="min-h-[360px] font-mono" />
350+
<Textarea intent="generatedOutput" value={output} readOnly className="font-mono" />
350351
</div>
351352
</div>
352353
</ToolPageContainer>

src/app/globals.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,19 @@
154154
min-height: 44px !important;
155155
min-width: 44px !important;
156156
}
157+
158+
textarea[data-input-intent="shortText"] {
159+
min-height: 9rem !important;
160+
}
161+
162+
textarea[data-input-intent="payload"],
163+
textarea[data-input-intent="generatedOutput"] {
164+
min-height: 16rem !important;
165+
}
166+
167+
textarea[data-input-intent="workbench"] {
168+
min-height: 22rem !important;
169+
}
157170
}
158171
}
159172

src/components/ui/input-intent.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export const INPUT_INTENTS = [
2+
"scalar",
3+
"shortText",
4+
"payload",
5+
"workbench",
6+
"generatedOutput",
7+
] as const
8+
9+
export type InputIntent = (typeof INPUT_INTENTS)[number]
10+
11+
type InputIntentControl = "input" | "textarea" | "editor" | "output"
12+
13+
export const INPUT_INTENT_CLASS_NAMES: Record<InputIntent, Record<InputIntentControl, string>> = {
14+
scalar: {
15+
input: "h-11 lg:h-9",
16+
textarea: "min-h-11 resize-y",
17+
editor: "min-h-44",
18+
output: "min-h-44",
19+
},
20+
shortText: {
21+
input: "min-h-11",
22+
textarea: "min-h-36 resize-y",
23+
editor: "min-h-56",
24+
output: "min-h-56",
25+
},
26+
payload: {
27+
input: "min-h-11",
28+
textarea: "min-h-64 resize-y",
29+
editor: "min-h-80",
30+
output: "min-h-80",
31+
},
32+
workbench: {
33+
input: "min-h-11",
34+
textarea: "min-h-[22rem] resize-y lg:resize-none",
35+
editor: "min-h-[22rem]",
36+
output: "min-h-[22rem]",
37+
},
38+
generatedOutput: {
39+
input: "min-h-11",
40+
textarea: "min-h-64 resize-y",
41+
editor: "min-h-80",
42+
output: "min-h-64",
43+
},
44+
}
45+
46+
export function inputIntentClassName(intent: InputIntent, control: InputIntentControl) {
47+
return INPUT_INTENT_CLASS_NAMES[intent][control]
48+
}

src/components/ui/input.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import * as React from "react"
22

33
import { cn } from "@/core/utils/utils"
4+
import { inputIntentClassName, type InputIntent } from "@/components/ui/input-intent"
5+
6+
export type InputProps = React.ComponentProps<"input"> & {
7+
intent?: InputIntent
8+
}
9+
10+
function Input({ className, intent, type, ...props }: InputProps) {
11+
const resolvedIntent = intent ?? "shortText"
412

5-
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
613
return (
714
<input
815
type={type}
916
data-slot="input"
17+
data-input-intent={resolvedIntent}
1018
className={cn(
11-
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm lg:h-9",
19+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
1220
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
1321
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
22+
intent ? inputIntentClassName(intent, "input") : "h-11 lg:h-9",
1423
className
1524
)}
1625
{...props}

src/components/ui/textarea.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import * as React from "react"
22

33
import { cn } from "@/core/utils/utils"
4+
import { inputIntentClassName, type InputIntent } from "@/components/ui/input-intent"
5+
6+
export type TextareaProps = React.ComponentProps<"textarea"> & {
7+
intent?: InputIntent
8+
}
9+
10+
function Textarea({ className, intent, ...props }: TextareaProps) {
11+
const resolvedIntent = intent ?? "payload"
412

5-
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
613
return (
714
<textarea
815
data-slot="textarea"
16+
data-input-intent={resolvedIntent}
917
className={cn(
10-
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
18+
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
19+
intent ? inputIntentClassName(intent, "textarea") : "field-sizing-content min-h-16",
1120
className
1221
)}
1322
{...props}

0 commit comments

Comments
 (0)