mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: make all buttons brand orange and lint against white ones
This commit is contained in:
1 parent
b691d4f7cb
commit
28cb1bc554
4 files changed
+197
-5
No files matched your search
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
|
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-button-colors.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
"type-check": "npm run build:sdk && tsc -b --force",
|
"type-check": "npm run build:sdk && tsc -b --force",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Buttons are never white.
|
||||||
|
*
|
||||||
|
* Every Button variant is the brand orange with a different background
|
||||||
|
* opacity (or red for destructive, or transparent for ghost). Fails when a
|
||||||
|
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
|
||||||
|
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
|
||||||
|
* in its className.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const NEUTRAL =
|
||||||
|
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
|
||||||
|
|
||||||
|
/** The opening tag text of each <Button ...> in a source file. */
|
||||||
|
function buttonTags(source) {
|
||||||
|
const tags = [];
|
||||||
|
const re = /<Button(?=[\s/>])/g;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(source))) {
|
||||||
|
let depth = 0;
|
||||||
|
let quote = "";
|
||||||
|
let i = match.index + match[0].length;
|
||||||
|
for (; i < source.length; i++) {
|
||||||
|
const c = source[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === "\\") i++;
|
||||||
|
else if (c === quote) quote = "";
|
||||||
|
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
||||||
|
else if (c === "{") depth++;
|
||||||
|
else if (c === "}") depth--;
|
||||||
|
else if (c === ">" && depth === 0) break;
|
||||||
|
}
|
||||||
|
tags.push(source.slice(match.index, i + 1));
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variant strings from the cva block of button.tsx. */
|
||||||
|
function variantLines(source) {
|
||||||
|
const start = source.indexOf("variant: {");
|
||||||
|
const end = source.indexOf("size: {");
|
||||||
|
if (start < 0 || end < 0) return [];
|
||||||
|
return source
|
||||||
|
.slice(start, end)
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, out) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
|
||||||
|
continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full, out);
|
||||||
|
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems found under the given roots. */
|
||||||
|
function scan(roots, buttonFile) {
|
||||||
|
const problems = [];
|
||||||
|
if (buttonFile && fs.existsSync(buttonFile)) {
|
||||||
|
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
|
||||||
|
if (NEUTRAL.test(line)) {
|
||||||
|
problems.push(
|
||||||
|
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = [];
|
||||||
|
for (const root of roots) walk(root, files);
|
||||||
|
for (const file of files) {
|
||||||
|
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
|
||||||
|
if (NEUTRAL.test(tag)) {
|
||||||
|
problems.push(
|
||||||
|
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scan, buttonTags, variantLines };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const core = path.resolve(__dirname, "..");
|
||||||
|
const plugins = path.resolve(core, "..", "Plugins");
|
||||||
|
const roots = [path.join(core, "src", "ui")];
|
||||||
|
if (fs.existsSync(plugins)) {
|
||||||
|
for (const entry of fs.readdirSync(plugins)) {
|
||||||
|
roots.push(path.join(plugins, entry, "src"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const problems = scan(
|
||||||
|
roots,
|
||||||
|
path.join(core, "src", "ui", "components", "button.tsx"),
|
||||||
|
);
|
||||||
|
if (problems.length) {
|
||||||
|
console.error("Buttons must be the brand orange, never white or neutral:");
|
||||||
|
for (const p of problems) console.error(" " + p);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("ok button colors");
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
|
||||||
|
scan: (roots: string[], buttonFile?: string) => string[];
|
||||||
|
buttonTags: (source: string) => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let root: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(files: Record<string, string>): string {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
|
||||||
|
for (const [file, content] of Object.entries(files)) {
|
||||||
|
const full = path.join(root, file);
|
||||||
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||||
|
fs.writeFileSync(full, content);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buttonTags", () => {
|
||||||
|
it("reads a tag with arrow functions inside", () => {
|
||||||
|
const tags = buttonTags(
|
||||||
|
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
|
||||||
|
);
|
||||||
|
expect(tags).toHaveLength(1);
|
||||||
|
expect(tags[0]).toContain('className="x"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("check-button-colors", () => {
|
||||||
|
it("accepts orange buttons and hover fills", () => {
|
||||||
|
const dir = fixture({
|
||||||
|
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
|
||||||
|
});
|
||||||
|
expect(scan([dir])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a white or neutral fill on a Button", () => {
|
||||||
|
for (const cls of [
|
||||||
|
"bg-primary",
|
||||||
|
"bg-white",
|
||||||
|
"bg-foreground text-background",
|
||||||
|
"dark:bg-secondary",
|
||||||
|
"bg-muted",
|
||||||
|
"text-primary-foreground",
|
||||||
|
]) {
|
||||||
|
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
|
||||||
|
expect(scan([dir]), cls).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a neutral fill in a variant", () => {
|
||||||
|
const dir = fixture({
|
||||||
|
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
|
||||||
|
});
|
||||||
|
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes on the real button", () => {
|
||||||
|
const button = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"ui",
|
||||||
|
"components",
|
||||||
|
"button.tsx",
|
||||||
|
);
|
||||||
|
expect(scan([], button)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,16 +10,17 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default:
|
||||||
|
"border-accent-brand/40 bg-accent-brand/15 text-accent-brand hover:bg-accent-brand/25 aria-expanded:bg-accent-brand/25",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-accent-brand/40 bg-transparent text-accent-brand hover:bg-accent-brand/10 aria-expanded:bg-accent-brand/10",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"border-accent-brand/20 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 aria-expanded:bg-accent-brand/20",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-accent-brand underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in new issue
Block a user