authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 21:33:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 21:33:03-07:00
log10882bea739a98f11b942c20c6570eb6126dd010
tree258a6a03b0defb5961935b3ed30a5da0f53270cf
parent0e512db34d787b21b827f07c6a62fba7fb00369e
signature Signed by PGP key B5690EEEBB952194

chore(web): no-restyle fails lint, and the ceiling gate goes (#13210)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 4 insertions(+), 98 deletions(-)

.github/workflows/ci.yml-3
...@@ -52,9 +52,6 @@ jobs:...@@ -52,9 +52,6 @@ jobs:
52 - name: Check52 - name: Check
53 run: vp check53 run: vp check
5454
55 - name: Check className overrides on components/ui do not grow
56 run: vp run lint:restyle-ceiling
57
58 - name: Typecheck55 - name: Typecheck
59 run: vpr typecheck56 run: vpr typecheck
6057
AGENTS.md+1-1
...@@ -158,7 +158,7 @@ Full glossary with file links: `docs/internals/glossary.md`...@@ -158,7 +158,7 @@ Full glossary with file links: `docs/internals/glossary.md`
158## Taste158## Taste
159159
160- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb.160- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb.
161- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits, add a variant to the component, not classes at the call site. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` reports violations and CI caps their count.161- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations.
162- Inferred types over annotations. `any` is the enemy.162- Inferred types over annotations. `any` is the enemy.
163- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior.163- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior.
164- Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays.164- Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays.
package.json-1
...@@ -31,7 +31,6 @@...@@ -31,7 +31,6 @@
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "lint:restyle-ceiling": "node scripts/lint-restyle-ceiling.ts",
35 "test": "vp run -r test",34 "test": "vp run -r test",
36 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",35 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
37 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",36 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
scripts/lint-restyle-ceiling.test.ts deleted-24
...@@ -1,24 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { countRestyleFindings, evaluateCeiling } from "./lint-restyle-ceiling.ts";
4
5describe("lint-restyle-ceiling", () => {
6 it("counts only no-restyle diagnostics", () => {
7 const count = countRestyleFindings({
8 diagnostics: [
9 { code: "shadcn(no-restyle)" },
10 { code: "react(refs)" },
11 { code: "shadcn(no-restyle)" },
12 ],
13 });
14 assert.strictEqual(count, 2);
15 });
16
17 it("fails above the ceiling and passes at or below it", () => {
18 assert.isFalse(evaluateCeiling(11, 10).ok);
19 assert.isTrue(evaluateCeiling(10, 10).ok);
20 const below = evaluateCeiling(7, 10);
21 assert.isTrue(below.ok);
22 assert.include(below.message, "Lower RESTYLE_CEILING");
23 });
24});
scripts/lint-restyle-ceiling.ts deleted-66
...@@ -1,66 +0,0 @@
1#!/usr/bin/env node
2// @effect-diagnostics nodeBuiltinImport:off - one-shot CI gate over a child process.
3import * as NodeChildProcess from "node:child_process";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6
7// `shadcn/no-restyle` stays a warning while the existing className overrides on
8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the
9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you
10// migrate a file, and delete this script when the rule becomes an error.
11export const RESTYLE_CEILING = 0;
12
13const RULE = "shadcn(no-restyle)";
14
15const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
16
17interface LintReport {
18 readonly diagnostics: ReadonlyArray<{ readonly code: string }>;
19}
20
21export function countRestyleFindings(report: LintReport): number {
22 return report.diagnostics.filter((diagnostic) => diagnostic.code === RULE).length;
23}
24
25export function evaluateCeiling(
26 count: number,
27 ceiling: number,
28): { readonly ok: boolean; readonly message: string } {
29 if (count > ceiling) {
30 return {
31 ok: false,
32 message:
33 `${RULE}: ${count} findings exceed the ceiling of ${ceiling}. ` +
34 "Use a variant or size on the components/ui export instead of a className override " +
35 "(run `vp lint apps/web/src` for the list).",
36 };
37 }
38 const slack = ceiling - count;
39 return {
40 ok: true,
41 message:
42 slack === 0
43 ? `${RULE}: ${count} findings, at the ceiling.`
44 : `${RULE}: ${count} findings, ${slack} below the ceiling of ${ceiling}. Lower RESTYLE_CEILING in scripts/lint-restyle-ceiling.ts to ${count}.`,
45 };
46}
47
48function main() {
49 const result = NodeChildProcess.spawnSync("vp", ["lint", "--format", "json", "apps/web/src"], {
50 cwd: repoRoot,
51 encoding: "utf8",
52 maxBuffer: 256 * 1024 * 1024,
53 });
54 if (result.error) throw result.error;
55 const report = JSON.parse(result.stdout) as LintReport;
56 const verdict = evaluateCeiling(countRestyleFindings(report), RESTYLE_CEILING);
57 process.stdout.write(`${verdict.message}\n`);
58 process.exitCode = verdict.ok ? 0 : 1;
59}
60
61if (
62 process.argv[1] !== undefined &&
63 NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url)
64) {
65 main();
66}
vite.config.ts+3-3
...@@ -200,13 +200,13 @@ export default defineConfig({...@@ -200,13 +200,13 @@ export default defineConfig({
200 {200 {
201 // components/ui exports own their look. App code picks a variant or size instead201 // components/ui exports own their look. App code picks a variant or size instead
202 // of restyling with className; layout classes (width, flex, margin, position) stay202 // of restyling with className; layout classes (width, flex, margin, position) stay
203 // allowed because placement belongs to the parent. Warn-only until the existing203 // allowed because placement belongs to the parent. components/ui is for generic
204 // overrides are migrated to variants; the ceiling below stops the count growing.204 // primitives: a look that belongs to one feature stays in that feature's component.
205 files: ["apps/web/src/**"],205 files: ["apps/web/src/**"],
206 excludeFiles: ["apps/web/src/components/ui/**"],206 excludeFiles: ["apps/web/src/components/ui/**"],
207 rules: {207 rules: {
208 "shadcn/no-restyle": [208 "shadcn/no-restyle": [
209 "warn",209 "error",
210 {210 {
211 allow: ["layout"],211 allow: ["layout"],
212 contracts: [212 contracts: [