Skip to content

Commit 08157f0

Browse files
authored
Merge pull request #13910 from gitbutlerapp/storybook-updates
Storybook updates
2 parents e3540e7 + 497dd4e commit 08157f0

80 files changed

Lines changed: 489 additions & 9 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.oxlintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "./node_modules/oxlint/configuration_schema.json",
3-
"ignorePatterns": ["**/*", "!apps/lite/**/*"],
3+
"ignorePatterns": ["**/*", "!apps/lite/**/*", "apps/lite/scripts/**"],
44
"options": {
55
"typeAware": true,
66
"denyWarnings": true,

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,8 @@ packages/but-sdk/**/*.md
2525
# TanStack Router's generated types for file-based routing
2626
apps/lite/ui/src/routeTree.gen.ts
2727

28+
# Auto-generated icon names union type
29+
apps/lite/ui/src/components/iconNames.ts
30+
2831
# Apple Icon Composer generated file
2932
apps/lite/resources/icons/macos/icon.icon/icon.json

apps/lite/.storybook/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defineMain } from "@storybook/react-vite/node";
33
export default defineMain({
44
stories: ["../ui/src/**/*.stories.tsx"],
55
framework: "@storybook/react-vite",
6-
addons: ["@storybook/addon-designs", "@storybook/addon-docs"],
6+
addons: ["@storybook/addon-designs"],
77
typescript: {
88
// Better props inference.
99
reactDocgen: "react-docgen-typescript",

apps/lite/.storybook/preview.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
1-
import docs from "@storybook/addon-docs";
1+
import type { Decorator } from "@storybook/react-vite";
22
import { definePreview } from "@storybook/react-vite";
33

4+
import "../ui/src/global.css";
5+
import "./storybook-styles.css";
6+
7+
const themeDecorator: Decorator = (Story, context) => {
8+
const globals = context.globals as Record<string, string>;
9+
const theme = globals["theme"] ?? "light";
10+
document.documentElement.classList.toggle("dark", theme === "dark");
11+
document.documentElement.classList.toggle("light", theme !== "dark");
12+
return Story();
13+
};
14+
415
export default definePreview({
5-
// Designs addon is missing as per:
6-
// https://github.com/storybookjs/addon-designs/issues/277
7-
addons: [docs()],
8-
tags: ["autodocs"],
16+
addons: [],
17+
initialGlobals: {
18+
theme: "light",
19+
} as never,
20+
globalTypes: {
21+
theme: {
22+
name: "Theme",
23+
description: "Toggle between light and dark theme",
24+
toolbar: {
25+
icon: "contrast",
26+
items: [
27+
{ value: "light", title: "Light mode", icon: "sun" },
28+
{ value: "dark", title: "Dark mode", icon: "moon" },
29+
],
30+
showName: false,
31+
dynamicTitle: true,
32+
},
33+
},
34+
} as never,
35+
decorators: [themeDecorator] as never,
936
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
body {
2+
background-color: var(--bg-1);
3+
color: var(--text-1);
4+
}

apps/lite/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"bundle": "pnpm build && electron-builder",
2828
"bundle-local": "CHANNEL=${CHANNEL:-} pnpm -F @gitbutler/but-sdk build && CHANNEL=${CHANNEL:-} pnpm bundle -c.mac.entitlements=resources/entitlements.mac.local.plist -c.mac.entitlementsInherit=resources/entitlements.mac.local.plist",
2929
"bundle-local-nightly": "CHANNEL=nightly pnpm -F @gitbutler/but-sdk build:release && CHANNEL=nightly pnpm bundle -c.mac.entitlements=resources/entitlements.mac.local.plist -c.mac.entitlementsInherit=resources/entitlements.mac.local.plist",
30-
"demos": "storybook dev --no-open -p 6007"
30+
"demos": "storybook dev --no-open -p 6007",
31+
"optimize-icons": "node scripts/optimize-icons.mjs"
3132
},
3233
"build": {
3334
"appId": "com.gitbutler.lite",
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Icon optimization script.
5+
*
6+
* - Normalizes width/height to "100%"
7+
* - Replaces color values in fill/stroke with "currentColor"
8+
* - Minifies SVG markup
9+
* - Regenerates iconNames.ts
10+
*/
11+
12+
import fs from "node:fs";
13+
import path from "node:path";
14+
import { fileURLToPath } from "node:url";
15+
16+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
17+
const ICONS_DIR = path.resolve(__dirname, "../ui/src/components/icons");
18+
const ICON_NAMES_FILE = path.resolve(__dirname, "../ui/src/components/iconNames.ts");
19+
20+
/**
21+
* @param {string} message
22+
*/
23+
function writeStdout(message) {
24+
process.stdout.write(`${message}\n`);
25+
}
26+
27+
/**
28+
* @param {string} message
29+
*/
30+
function writeStderr(message) {
31+
process.stderr.write(`${message}\n`);
32+
}
33+
34+
// Colors to preserve as-is (not replaced with currentColor)
35+
const PRESERVE_VALUES = new Set(["none", "currentColor", "currentcolor"]);
36+
37+
/**
38+
* Replace width/height on the root <svg> element with 100%.
39+
* @param {string} svg
40+
* @returns {string}
41+
*/
42+
function normalizeSize(svg) {
43+
return svg.replace(
44+
/<svg([^>]*)>/,
45+
(/** @type {string} */ _match, /** @type {string} */ attrs) => {
46+
let updated = attrs;
47+
updated = updated.replace(/\bwidth="[^"]*"/, 'width="100%"');
48+
updated = updated.replace(/\bheight="[^"]*"/, 'height="100%"');
49+
// Add if missing
50+
if (!/\bwidth=/.test(updated)) updated += ' width="100%"';
51+
if (!/\bheight=/.test(updated)) updated += ' height="100%"';
52+
return `<svg${updated}>`;
53+
},
54+
);
55+
}
56+
57+
/**
58+
* Replace fill="..." and stroke="..." color values with "currentColor".
59+
* Preserves "none" and already-correct "currentColor".
60+
* @param {string} svg
61+
* @returns {string}
62+
*/
63+
function replaceColors(svg) {
64+
return svg.replace(
65+
/\b(fill|stroke)="([^"]*)"/g,
66+
(/** @type {string} */ _match, /** @type {string} */ attr, /** @type {string} */ value) => {
67+
const trimmed = value.trim();
68+
if (PRESERVE_VALUES.has(trimmed.toLowerCase())) return `${attr}="${trimmed}"`;
69+
// If it looks like a color (hex, rgb, hsl, named color, or a typo like "curentColor")
70+
if (
71+
trimmed.startsWith("#") ||
72+
/^rgb/i.test(trimmed) ||
73+
/^hsl/i.test(trimmed) ||
74+
/^[a-zA-Z]+$/.test(trimmed)
75+
)
76+
return `${attr}="currentColor"`;
77+
return `${attr}="${trimmed}"`;
78+
},
79+
);
80+
}
81+
82+
/**
83+
* Add vector-effect="non-scaling-stroke" to all shape/path elements
84+
* that don't already have it.
85+
* @param {string} svg
86+
* @returns {string}
87+
*/
88+
function addNonScalingStroke(svg) {
89+
const targets = /(<(?:path|circle|ellipse|line|polyline|polygon|rect)\b)([^>]*?)(\/?>)/g;
90+
return svg.replace(
91+
targets,
92+
(
93+
/** @type {string} */ _match,
94+
/** @type {string} */ open,
95+
/** @type {string} */ attrs,
96+
/** @type {string} */ close,
97+
) => {
98+
if (/vector-effect/.test(attrs)) return _match;
99+
return `${open}${attrs} vector-effect="non-scaling-stroke"${close}`;
100+
},
101+
);
102+
}
103+
104+
/**
105+
* Minify SVG by removing unnecessary whitespace, newlines, and comments.
106+
* @param {string} svg
107+
* @returns {string}
108+
*/
109+
function minify(svg) {
110+
return (
111+
svg
112+
// Remove XML comments
113+
.replace(/<!--[\s\S]*?-->/g, "")
114+
// Collapse newlines and runs of whitespace into a single space
115+
.replace(/\s+/g, " ")
116+
// Remove space between tags
117+
.replace(/>\s+</g, "><")
118+
// Remove space before self-closing
119+
.replace(/\s\/>/g, "/>")
120+
// Remove space after opening <
121+
.replace(/<\s+/g, "<")
122+
// Trim leading/trailing whitespace
123+
.trim()
124+
);
125+
}
126+
127+
/**
128+
* @param {string} svg
129+
* @returns {string}
130+
*/
131+
function optimizeSvg(svg) {
132+
let result = svg;
133+
result = normalizeSize(result);
134+
result = replaceColors(result);
135+
result = addNonScalingStroke(result);
136+
result = minify(result);
137+
return result;
138+
}
139+
140+
/**
141+
* @param {string[]} names
142+
* @returns {string}
143+
*/
144+
function generateIconNamesFile(names) {
145+
const sorted = [...names].sort((a, b) => a.localeCompare(b));
146+
const union = sorted.length > 0 ? sorted.map((n) => `"${n}"`).join(" | ") : "never";
147+
return `// This file is auto-generated by apps/lite/scripts/optimize-icons.mjs.\n// Do not edit this file manually.\n\nexport type IconName = ${union};\n`;
148+
}
149+
150+
/**
151+
* @param {string} content
152+
* @returns {Set<string>}
153+
*/
154+
function extractIconNamesFromGeneratedFile(content) {
155+
const match = content.match(/export\s+type\s+IconName\s*=\s*([\s\S]*?);/);
156+
if (!match) return new Set();
157+
158+
const unionBody = match[1].trim();
159+
if (unionBody === "never" || unionBody === "") return new Set();
160+
161+
const names = [...unionBody.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
162+
return new Set(names);
163+
}
164+
165+
/**
166+
* @param {string[]} iconNames
167+
* @returns {{added: string[], removed: string[]}}
168+
*/
169+
function diffGeneratedIconNames(iconNames) {
170+
if (!fs.existsSync(ICON_NAMES_FILE))
171+
return { added: [...iconNames].sort((a, b) => a.localeCompare(b)), removed: [] };
172+
173+
const prevTypes = fs.readFileSync(ICON_NAMES_FILE, "utf-8");
174+
const existingNames = extractIconNamesFromGeneratedFile(prevTypes);
175+
const nextNames = new Set(iconNames);
176+
177+
const added = [...nextNames].filter((name) => !existingNames.has(name));
178+
const removed = [...existingNames].filter((name) => !nextNames.has(name));
179+
180+
added.sort((a, b) => a.localeCompare(b));
181+
removed.sort((a, b) => a.localeCompare(b));
182+
183+
return { added, removed };
184+
}
185+
186+
// ── Main ───────────────────────────────────────────────────────────────
187+
188+
if (!fs.existsSync(ICONS_DIR)) {
189+
writeStderr(`Icons directory not found: ${ICONS_DIR}`);
190+
process.exit(1);
191+
}
192+
193+
const files = fs.readdirSync(ICONS_DIR).filter((f) => f.endsWith(".svg"));
194+
195+
let updated = 0;
196+
let unchanged = 0;
197+
const iconNames = [];
198+
199+
for (const file of files) {
200+
const filePath = path.join(ICONS_DIR, file);
201+
const name = path.basename(file, ".svg");
202+
iconNames.push(name);
203+
204+
const original = fs.readFileSync(filePath, "utf-8");
205+
const optimized = optimizeSvg(original);
206+
207+
if (optimized !== original) {
208+
fs.writeFileSync(filePath, optimized, "utf-8");
209+
updated++;
210+
writeStdout(` ✓ optimized: ${file}`);
211+
} else unchanged++;
212+
}
213+
214+
// Regenerate iconNames.ts
215+
const { added, removed } = diffGeneratedIconNames(iconNames);
216+
const prevTypes = fs.existsSync(ICON_NAMES_FILE) ? fs.readFileSync(ICON_NAMES_FILE, "utf-8") : "";
217+
const nextTypes = generateIconNamesFile(iconNames);
218+
219+
let typesStatus;
220+
if (prevTypes === nextTypes) typesStatus = "unchanged";
221+
else typesStatus = "updated";
222+
223+
if (typesStatus === "updated") fs.writeFileSync(ICON_NAMES_FILE, nextTypes, "utf-8");
224+
225+
// Stats
226+
writeStdout("");
227+
writeStdout("--- Icon optimization complete ---");
228+
writeStdout(` Total icons : ${files.length}`);
229+
writeStdout(` Optimized : ${updated}`);
230+
writeStdout(` Unchanged : ${unchanged}`);
231+
writeStdout(` iconNames.ts: ${typesStatus}`);
232+
233+
if (added.length > 0 || removed.length > 0) {
234+
if (added.length > 0) writeStdout(` Added names : ${added.join(", ")}`);
235+
if (removed.length > 0) writeStdout(` Removed names: ${removed.join(", ")}`);
236+
}
237+
238+
if (files.length === 0) writeStdout(" Note : No SVG files found in the icons directory.");
239+
writeStdout("");
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import preview from "#storybook/preview";
2+
3+
import { Icon } from "./Icon.tsx";
4+
import type { IconName } from "./iconNames";
5+
6+
const iconNames = Object.keys(
7+
import.meta.glob("./icons/*.svg", {
8+
eager: true,
9+
query: "?raw",
10+
import: "default",
11+
}),
12+
)
13+
.map((path) => path.replace(/^.*\/(.+)\.svg$/, "$1"))
14+
.sort((a, b) => a.localeCompare(b)) as Array<IconName>;
15+
16+
const meta = preview.meta({
17+
component: Icon,
18+
argTypes: {
19+
size: {
20+
control: { type: "range", min: 8, max: 128, step: 4 },
21+
},
22+
},
23+
});
24+
25+
export const AllIcons = meta.story({
26+
args: {
27+
name: "commit",
28+
size: 16,
29+
} as never,
30+
render: ((args: { size: number }) => (
31+
<div
32+
style={{
33+
display: "grid",
34+
gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))",
35+
gap: 16,
36+
}}
37+
>
38+
{iconNames.map((name: IconName) => (
39+
<div
40+
key={name}
41+
style={{
42+
display: "flex",
43+
flexDirection: "column",
44+
// alignItems: "center",
45+
lineHeight: 1.3,
46+
gap: 12,
47+
padding: 16,
48+
}}
49+
>
50+
<Icon name={name} size={args.size} />
51+
<span style={{ fontSize: 11, opacity: 0.5 }}>{name}</span>
52+
</div>
53+
))}
54+
</div>
55+
)) as never,
56+
});

0 commit comments

Comments
 (0)