Skip to content

Commit da6a343

Browse files
docs(mentor): add Heph mentor avatar PNG brand assets (#1682)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 85b3e3d commit da6a343

6 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
Repository tooling only: adds a Storybook capture story and an `export:brand-assets` script that
5+
render the Heph mentor avatar into versioned brand PNGs under `docs/static/img/brand/`. Nothing in
6+
the shipped application changes, so operators and users see no difference.
19.8 KB
Loading
23.4 KB
Loading

webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"chromatic:ci": "chromatic --only-changed",
2626
"chromatic:dry-run": "chromatic --dry-run",
2727
"test:e2e": "playwright test",
28+
"export:brand-assets": "node scripts/export-brand-assets.ts",
2829
"export:readme-assets": "node scripts/export-readme-assets.ts",
2930
"storybook:dev": "storybook dev -p 6006",
3031
"generate:api": "openapi-ts"
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { spawn } from "node:child_process";
2+
import { mkdir, readFile } from "node:fs/promises";
3+
import { resolve } from "node:path";
4+
import { inflateSync } from "node:zlib";
5+
6+
import { type Browser, chromium } from "playwright";
7+
8+
const scriptDirectory = import.meta.dirname;
9+
const webappDirectory = resolve(scriptDirectory, "..");
10+
const outputDirectory = resolve(webappDirectory, "../docs/static/img/brand");
11+
const port = 6107;
12+
const storybookUrl = `http://127.0.0.1:${port}`;
13+
const storyId = "components-mentor-mentoricon--brand-export";
14+
15+
interface CaptureConfig {
16+
/** Output filename inside docs/static/img/brand/. */
17+
fileName: string;
18+
selector: string;
19+
/** Whether to capture with a transparent backdrop instead of the tile's own background. */
20+
transparent: boolean;
21+
}
22+
23+
// Each tile is 512 CSS pixels; deviceScaleFactor 2 yields the 1024x1024 brand asset.
24+
const expectedTileSize = 512;
25+
const expectedPixelSize = 1024;
26+
27+
const captureConfigs: CaptureConfig[] = [
28+
{
29+
fileName: "heph-avatar-1024.png",
30+
selector: '[data-brand-export="heph-avatar"]',
31+
transparent: false,
32+
},
33+
{
34+
fileName: "heph-avatar-1024-transparent.png",
35+
selector: '[data-brand-export="heph-avatar-transparent"]',
36+
transparent: true,
37+
},
38+
];
39+
40+
function indexContains(index: unknown): boolean {
41+
if (!index || typeof index !== "object" || !("entries" in index)) return false;
42+
const { entries } = index;
43+
return Boolean(entries && typeof entries === "object" && storyId in entries);
44+
}
45+
46+
async function waitForStorybook(): Promise<void> {
47+
for (let attempt = 0; attempt < 120; attempt += 1) {
48+
const response = await fetch(`${storybookUrl}/index.json`).catch(() => undefined);
49+
if (response?.ok) {
50+
if (!indexContains(await response.json())) {
51+
throw new Error(`Story ${storyId} is not in the Storybook index. Was it renamed?`);
52+
}
53+
return;
54+
}
55+
await new Promise((resolveDelay) => {
56+
setTimeout(resolveDelay, 500);
57+
});
58+
}
59+
throw new Error("Storybook did not start within 60 seconds.");
60+
}
61+
62+
/**
63+
* Asserted after render; a mismatch fails the export instead of committing a wrong image.
64+
* Beyond the dimensions, the solid export must be opaque RGB (Slack rejects icons that rely on
65+
* transparency) and the transparent export must actually carry an alpha channel with an empty
66+
* corner, so a silently ignored `omitBackground` cannot slip through.
67+
*/
68+
async function assertPng(path: string, transparent: boolean): Promise<void> {
69+
const png = await readFile(path);
70+
const width = png.readUInt32BE(16);
71+
const height = png.readUInt32BE(20);
72+
if (width !== expectedPixelSize || height !== expectedPixelSize) {
73+
throw new Error(`${path} is ${width}x${height}px; expected ${expectedPixelSize}px square.`);
74+
}
75+
const colorType = png.readUInt8(25);
76+
if (!transparent) {
77+
if (colorType !== 2) {
78+
throw new Error(`${path} has PNG color type ${colorType}; the solid export must be RGB (2).`);
79+
}
80+
return;
81+
}
82+
if (colorType !== 6) {
83+
throw new Error(
84+
`${path} has PNG color type ${colorType}; the transparent export must be RGBA (6).`,
85+
);
86+
}
87+
const idat: Buffer[] = [];
88+
for (let offset = 8; offset + 8 <= png.length;) {
89+
const chunkLength = png.readUInt32BE(offset);
90+
const chunkType = png.toString("latin1", offset + 4, offset + 8);
91+
if (chunkType === "IDAT") idat.push(png.subarray(offset + 8, offset + 8 + chunkLength));
92+
if (chunkType === "IEND") break;
93+
offset += chunkLength + 12;
94+
}
95+
// Every PNG filter reads the first pixel's neighbours as zero, so its RGBA bytes appear
96+
// verbatim right after row 0's filter byte and the corner alpha is byte 4.
97+
const cornerAlpha = inflateSync(Buffer.concat(idat)).readUInt8(4);
98+
if (cornerAlpha !== 0) {
99+
throw new Error(`${path} has corner alpha ${cornerAlpha}; the transparent export needs 0.`);
100+
}
101+
}
102+
103+
async function capture(browser: Browser, config: CaptureConfig): Promise<void> {
104+
const page = await browser.newPage({
105+
viewport: { width: 1400, height: 700 },
106+
deviceScaleFactor: 2,
107+
colorScheme: "light",
108+
reducedMotion: "reduce",
109+
});
110+
111+
try {
112+
const globals = encodeURIComponent("theme:light");
113+
await page.goto(`${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=${globals}`);
114+
await page.waitForLoadState("networkidle");
115+
await page.addStyleTag({
116+
content: `*,*::before,*::after{animation:none!important;transition:none!important}${
117+
config.transparent ? "html,body{background:transparent!important}" : ""
118+
}`,
119+
});
120+
121+
const exportSurface = page.locator(config.selector);
122+
await exportSurface.waitFor({ state: "visible" });
123+
const bounds = await exportSurface.boundingBox();
124+
if (!bounds || Math.round(bounds.width) !== expectedTileSize) {
125+
throw new Error(
126+
`${config.fileName} export width was ${bounds?.width ?? "missing"}px; expected ${expectedTileSize}px.`,
127+
);
128+
}
129+
130+
const outputPath = resolve(outputDirectory, config.fileName);
131+
await exportSurface.screenshot({ path: outputPath, omitBackground: config.transparent });
132+
await assertPng(outputPath, config.transparent);
133+
process.stdout.write(`Exported ${outputPath}\n`);
134+
} finally {
135+
await page.close();
136+
}
137+
}
138+
139+
// The brand directory also holds hand-drawn SVG marks, so it is never wiped — the
140+
// export only overwrites the PNGs it owns.
141+
await mkdir(outputDirectory, { recursive: true });
142+
const storybook = spawn(
143+
"pnpm",
144+
["run", "storybook:dev", "--port", String(port), "--ci", "--host", "127.0.0.1"],
145+
{
146+
cwd: webappDirectory,
147+
stdio: "ignore",
148+
},
149+
);
150+
151+
try {
152+
await waitForStorybook();
153+
const browser = await chromium.launch();
154+
try {
155+
for (const config of captureConfigs) await capture(browser, config);
156+
} finally {
157+
await browser.close();
158+
}
159+
} finally {
160+
storybook.kill("SIGTERM");
161+
}

webapp/src/components/mentor/MentorIcon.stories.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,34 @@ export const MultipleIcons: Story = {
7777
},
7878
};
7979

80+
export const BrandExport: Story = {
81+
parameters: {
82+
chromatic: { disableSnapshot: true },
83+
docs: {
84+
description: {
85+
story:
86+
"Capture surfaces for `pnpm --filter webapp run export:brand-assets`, which screenshots them into `docs/static/img/brand/`. Colors are literal white and black so the export matches the hammer marks and never follows the Storybook theme.",
87+
},
88+
},
89+
},
90+
render: () => (
91+
<div className="flex gap-8">
92+
<div
93+
data-brand-export="heph-avatar"
94+
className="flex size-[512px] items-center justify-center bg-white text-black"
95+
>
96+
<MentorIcon size={400} pad={2} animated={false} />
97+
</div>
98+
<div
99+
data-brand-export="heph-avatar-transparent"
100+
className="flex size-[512px] items-center justify-center bg-transparent text-black"
101+
>
102+
<MentorIcon size={400} pad={2} animated={false} />
103+
</div>
104+
</div>
105+
),
106+
};
107+
80108
export const AccessibilityPreference: Story = {
81109
render: () => (
82110
<div className="space-y-4">

0 commit comments

Comments
 (0)