fix(storybook): package avatar PNGs for static branch previews
Build finite avatar presets with the API renderer and resolve preview images under each build path. Verify published PNG bytes and density dimensions before considering deployment successful. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ad735fbb94
commit
4e488f9f42
|
|
@ -24,7 +24,10 @@ for (const name of ['index.html', 'iframe.html', 'index.json']) {
|
|||
throw new Error(`Missing Storybook output: ${name}`);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify(destination, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify({ ...destination,
|
||||
...(fs.existsSync(path.join(source, 'agent-avatar-images/manifest.json'))
|
||||
? { avatarManifest: 'agent-avatar-images/manifest.json' } : {}),
|
||||
}, null, 2) + '\n');
|
||||
const aws = (args) => execFileSync('aws', args, { stdio: 'inherit' });
|
||||
// Complete a unique build before changing the branch's entry point. No deletion
|
||||
// permissions, shared root writes or mixed-version branch assets are needed.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,40 @@
|
|||
const { branchIndex } = require('./storybook-destination.cjs');
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
async function verifyAvatarImages({ buildUrl, fetch = globalThis.fetch }) {
|
||||
const response = await fetch(new URL('agent-avatar-images/manifest.json', buildUrl), { signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) throw new Error(`Avatar manifest returned HTTP ${response.status}.`);
|
||||
const manifest = await response.json();
|
||||
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.images) || !manifest.images.length || manifest.images.length > 10000) {
|
||||
throw new Error('Invalid avatar image manifest.');
|
||||
}
|
||||
const paths = new Set();
|
||||
for (const image of manifest.images) {
|
||||
if (!/^agent-avatar-images\/[a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+\.png$/.test(image.path)
|
||||
|| !/^[a-f0-9]{64}$/.test(image.sha256) || !Number.isInteger(image.pixels)
|
||||
|| image.pixels < 16 || image.pixels > 1024 || paths.has(image.path)) {
|
||||
throw new Error('Invalid avatar image entry.');
|
||||
}
|
||||
paths.add(image.path);
|
||||
}
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: 8 }, async () => {
|
||||
while (next < manifest.images.length) {
|
||||
const image = manifest.images[next++];
|
||||
const result = await fetch(new URL(image.path, buildUrl), { signal: AbortSignal.timeout(15000) });
|
||||
if (!result.ok || !result.headers.get('content-type')?.startsWith('image/png')) {
|
||||
throw new Error(`Avatar ${image.path} returned HTTP ${result.status} or a non-PNG content type.`);
|
||||
}
|
||||
const png = Buffer.from(await result.arrayBuffer());
|
||||
if (png.length < 24 || png.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
|
||||
|| png.readUInt32BE(16) !== image.pixels || png.readUInt32BE(20) !== image.pixels
|
||||
|| createHash('sha256').update(png).digest('hex') !== image.sha256) {
|
||||
throw new Error(`Avatar ${image.path} has incorrect PNG bytes or dimensions.`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
console.log(`Verified ${manifest.images.length} public avatar PNGs.`);
|
||||
}
|
||||
|
||||
async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch,
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) {
|
||||
|
|
@ -9,8 +45,10 @@ async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fe
|
|||
fetch(branchUrl, { signal: AbortSignal.timeout(15000) }),
|
||||
]);
|
||||
if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`);
|
||||
if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.');
|
||||
const build = await metadata.json();
|
||||
if (build.sha !== sha) throw new Error('Public build has the wrong source commit.');
|
||||
if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.');
|
||||
if (build.avatarManifest) await verifyAvatarImages({ buildUrl, fetch });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === attempts) throw error;
|
||||
|
|
@ -19,7 +57,7 @@ async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fe
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyStorybook };
|
||||
module.exports = { verifyStorybook, verifyAvatarImages };
|
||||
if (require.main === module) {
|
||||
verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL,
|
||||
sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
|
|
|
|||
|
|
@ -1370,7 +1370,8 @@ See [execution GitHub identity](execution-github-identity.md) for the operation-
|
|||
|
||||
See [agent-personas.md](agent-personas.md) for the dynamic avatar endpoint, cache,
|
||||
and character stories. Set `PAPERCLIP_STORYBOOK_API_URL` to your isolated
|
||||
Paperclip API URL when running Storybook. Avatar PNGs are generated on demand.
|
||||
Paperclip API URL when running dev Storybook. Published Storybook builds automatically
|
||||
package avatar PNGs using the API renderer; static hosting needs no API proxy.
|
||||
|
||||
### Investigating polling load
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,16 @@ the TS palette data; `--check` detects drift. This does not generate images.
|
|||
|
||||
Storybook: **Agents / Personas**. Run with
|
||||
`PAPERCLIP_STORYBOOK_API_URL=http://localhost:<isolated-port> pnpm storybook`.
|
||||
Static Storybook hosting must proxy `/api/agent-avatars` to an instance.
|
||||
`pnpm build-storybook` automatically packages all finite avatar presets (17
|
||||
palettes plus muted gray, nine poses, eleven logical sizes, both densities).
|
||||
The build uses the same bounded Node worker pool, SVG renderer, and Sharp pipeline
|
||||
as the API. Storybook-only URL resolution points to relative PNG paths under the
|
||||
published build, including branch-prefixed deployments. Production Paperclip
|
||||
continues to use its on-demand API; no image generation runs during agent creation.
|
||||
The generated files are build output, never committed. A manifest records image
|
||||
hashes and pixel dimensions; deployment verification fetches every image and checks
|
||||
its content type, PNG signature, dimensions, and hash. Dev Storybook still uses the
|
||||
API proxy for cold-cache and regeneration testing.
|
||||
|
||||
Focused checks:
|
||||
|
||||
|
|
@ -85,7 +94,7 @@ Use fixed poses/times for screenshots and Linux for authoritative visual
|
|||
baselines. Verify cold and warm URLs, reduced motion, reconnect, and saved
|
||||
appearance after refresh alongside normal typecheck/test/build checks.
|
||||
|
||||
Linux visual/performance checks (against the built Storybook with the API proxy):
|
||||
Linux visual/performance checks (against the self-contained built Storybook):
|
||||
|
||||
```sh
|
||||
pnpm exec playwright test --config tests/storybook-visual/agent-personas.config.ts
|
||||
|
|
|
|||
|
|
@ -215,3 +215,43 @@ test('public verification rejects a permanently stale branch URL or wrong source
|
|||
wrong === 'branch' ? /does not point to this build/ : /wrong source commit/);
|
||||
}
|
||||
});
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { verifyAvatarImages } from '../../.github/scripts/verify-storybook.cjs';
|
||||
function avatarVerificationFixture(change = {}) {
|
||||
const png = Buffer.alloc(24);
|
||||
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
|
||||
png.writeUInt32BE(48, 16); png.writeUInt32BE(48, 20);
|
||||
const entry = { path: 'agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png', pixels: 48,
|
||||
sha256: createHash('sha256').update(png).digest('hex'), ...change.entry };
|
||||
const requested = [];
|
||||
return { requested, fetch: async (url) => {
|
||||
requested.push(String(url));
|
||||
if (String(url).endsWith('manifest.json')) return Response.json({ schemaVersion: 1, images: [entry] });
|
||||
return new Response(change.body ?? png, { status: change.status ?? 200,
|
||||
headers: { 'content-type': change.type ?? 'image/png' } });
|
||||
} };
|
||||
}
|
||||
test('public avatar verification checks relative PNG paths, bytes and density dimensions', async () => {
|
||||
const fixture = avatarVerificationFixture();
|
||||
const d = storybookDestination(input);
|
||||
await verifyAvatarImages({ buildUrl: d.buildUrl, fetch: fixture.fetch });
|
||||
assert.ok(fixture.requested.every(url => url.startsWith(d.buildUrl.replace('index.html', ''))));
|
||||
assert.equal(fixture.requested.length, 2);
|
||||
});
|
||||
test('public avatar verification rejects missing, HTML, corrupt or wrong-density images', async () => {
|
||||
for (const change of [{ status: 403 }, { type: 'text/html' }, { body: 'broken PNG' },
|
||||
{ entry: { pixels: 24 } }, { entry: { sha256: '0'.repeat(64) } },
|
||||
{ entry: { path: '../../api/agent-avatars/portrait.png' } }]) {
|
||||
await assert.rejects(verifyAvatarImages({ buildUrl: storybookDestination(input).buildUrl,
|
||||
fetch: avatarVerificationFixture(change).fetch }), /Avatar|avatar/);
|
||||
}
|
||||
});
|
||||
test('deployment metadata opts into avatar verification and fails on missing images', async () => {
|
||||
const d = storybookDestination(input);
|
||||
await assert.rejects(verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha, attempts: 1,
|
||||
fetch: async url => String(url).endsWith('deployment.json')
|
||||
? Response.json({ sha: d.sha, avatarManifest: 'agent-avatar-images/manifest.json' })
|
||||
: String(url) === d.url ? new Response(branchIndex(d.buildUrl)) : new Response('', { status: 403 }),
|
||||
}), /Avatar manifest returned HTTP 403/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export function storybookAgentAvatarAssets(): {
|
||||
name: string;
|
||||
apply: "build";
|
||||
generateBundle(this: { emitFile(asset: { type: "asset"; fileName: string; source: string | Uint8Array }): unknown }): Promise<void>;
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { createRequire } from "node:module";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Build-only: reuse the API worker and finite preset contract, never a browser renderer. */
|
||||
export function storybookAgentAvatarAssets() {
|
||||
return {
|
||||
name: "storybook-agent-avatar-assets",
|
||||
apply: "build",
|
||||
async generateBundle() {
|
||||
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
|
||||
const { tsImport } = await import(serverRequire.resolve("tsx/esm/api"));
|
||||
const { createAgentAvatarPool } = await tsImport("../server/src/services/agent-avatar-pool.ts", import.meta.url);
|
||||
const { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } =
|
||||
await tsImport("../packages/shared/src/agent-appearance.ts", import.meta.url);
|
||||
const { agentAvatarUrl } = await tsImport("../ui/storybook/fixtures/agent-avatar-url.ts", import.meta.url);
|
||||
const requests = [false, true].flatMap(muted =>
|
||||
(muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette =>
|
||||
CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size =>
|
||||
[1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose, muted }))))));
|
||||
const pool = createAgentAvatarPool(2);
|
||||
const images = [];
|
||||
let next = 0;
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 2 }, async () => {
|
||||
while (next < requests.length) {
|
||||
const request = requests[next++];
|
||||
const source = await pool.render(request);
|
||||
const { appearance, size, scale, pose, muted } = request;
|
||||
const fileName = agentAvatarUrl(appearance, size, scale, pose, muted).slice(2);
|
||||
this.emitFile({ type: "asset", fileName, source });
|
||||
images.push({ path: fileName, sha256: createHash("sha256").update(source).digest("hex"), pixels: size * scale });
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
await pool.close();
|
||||
}
|
||||
images.sort((a, b) => a.path.localeCompare(b.path));
|
||||
this.emitFile({ type: "asset", fileName: "agent-avatar-images/manifest.json",
|
||||
source: JSON.stringify({ schemaVersion: 1, images }) });
|
||||
console.log(`Packaged ${images.length} agent avatar PNGs using the API renderer.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ test("500 avatars load images without WebGL, live modules or avatar frame loops"
|
|||
test("image failures and slow cold responses preserve dimensions", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route("**/api/agent-avatars/**", async route => { await held; await route.abort(); });
|
||||
await page.route("**/agent-avatar-images/**", async route => { await held; await route.abort(); });
|
||||
await story(page, "cache-miss-loading");
|
||||
const image = page.locator("#storybook-root img");
|
||||
const before = await image.boundingBox();
|
||||
|
|
@ -117,7 +117,7 @@ for (const id of fullPages) {
|
|||
await page.goto(`/iframe.html?id=agents-personas-full-pages--${id}&viewMode=story&globals=theme:dark`);
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
await expect(page.locator("main")).not.toContainText("This page hit an error");
|
||||
await expect(page.locator('img[src*="/api/agent-avatars/"]').first()).toBeAttached();
|
||||
await expect(page.locator('img[src*="/agent-avatar-images/"]').first()).toBeAttached();
|
||||
if (id === "company-dashboard") await expect(page.getByText("Live now", { exact: true })).toBeVisible();
|
||||
await imagesLoaded(page);
|
||||
await expect(page.locator("canvas")).toHaveCount(0);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { useState } from "react";
|
||||
import { agentAvatarUrl, resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared";
|
||||
import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { deriveInitials } from "./Identity";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { resolveAgentAppearance } from "@paperclipai/shared";
|
||||
/**
|
||||
* Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
// Storybook replaces this module at build time with its packaged-image resolver.
|
||||
export { agentAvatarUrl } from "@paperclipai/shared";
|
||||
|
|
@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { mergeConfig } from "vite";
|
||||
import { storybookAgentAvatarAssets } from "../../../scripts/storybook-agent-avatar-assets.mjs";
|
||||
|
||||
const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -17,9 +18,9 @@ const config: StorybookConfig = {
|
|||
docs: {
|
||||
autodocs: true,
|
||||
},
|
||||
viteFinal: async (baseConfig) =>
|
||||
viteFinal: async (baseConfig, { configType }) =>
|
||||
mergeConfig(baseConfig, {
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [tailwindcss(), storybookAgentAvatarAssets()],
|
||||
server: { proxy: { "/api/agent-avatars": { target: process.env.PAPERCLIP_STORYBOOK_API_URL ?? "http://localhost:3100", changeOrigin: true } } },
|
||||
optimizeDeps: { include: ["motion/react", "react", "react-dom"] },
|
||||
resolve: {
|
||||
|
|
@ -30,6 +31,9 @@ const config: StorybookConfig = {
|
|||
// The app's own dev server hoists one React and never hit this.
|
||||
dedupe: ["react", "react-dom"],
|
||||
alias: {
|
||||
...(configType === "PRODUCTION" ? {
|
||||
"@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"),
|
||||
} : {}),
|
||||
"@": path.resolve(storybookConfigDir, "../../src"),
|
||||
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"),
|
||||
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { AGENT_AVATAR_SIZES, AGENT_PALETTE_IDS, CHARACTER_STATES, appearanceForPalette } from "@paperclipai/shared";
|
||||
import { agentAvatarUrl as productionUrl } from "../../src/lib/agent-avatar-url";
|
||||
import { agentAvatarUrl } from "./agent-avatar-url";
|
||||
|
||||
describe("packaged Storybook avatar URLs", () => {
|
||||
it("keeps production on the public API and resolves preview assets under a branch prefix", () => {
|
||||
const appearance = appearanceForPalette("bubblegum-sky");
|
||||
expect(productionUrl(appearance, 24, 2)).toBe("/api/agent-avatars/cap-v1/bubblegum-sky/rest.png?size=24&scale=2");
|
||||
expect(new URL(agentAvatarUrl(appearance, 24, 2), "https://example.com/branches/personas/builds/123/iframe.html").href)
|
||||
.toBe("https://example.com/branches/personas/builds/123/agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png");
|
||||
expect(agentAvatarUrl(appearance)).toBe("./agent-avatar-images/cap-v1/bubblegum-sky/rest-512-1.png");
|
||||
});
|
||||
|
||||
it("gives every finite preset a distinct filename, retaining logical size separately from density", () => {
|
||||
const paths = new Set<string>();
|
||||
for (const palette of AGENT_PALETTE_IDS) for (const pose of CHARACTER_STATES)
|
||||
for (const size of AGENT_AVATAR_SIZES) for (const scale of [1, 2] as const) {
|
||||
paths.add(agentAvatarUrl(appearanceForPalette(palette), size, scale, pose));
|
||||
paths.add(agentAvatarUrl(appearanceForPalette(palette), size, scale, pose, true));
|
||||
}
|
||||
expect(paths.size).toBe((AGENT_PALETTE_IDS.length + 1) * CHARACTER_STATES.length * AGENT_AVATAR_SIZES.length * 2);
|
||||
expect(agentAvatarUrl(appearanceForPalette("arctic-blue"), 24, 2))
|
||||
.not.toBe(agentAvatarUrl(appearanceForPalette("arctic-blue"), 48, 1));
|
||||
expect([...paths].every(path => !path.includes("?") && path.startsWith("./agent-avatar-images/cap-v1/"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { agentAvatarUrl as apiAvatarUrl } from "@paperclipai/shared";
|
||||
|
||||
/** Relative to iframe.html, including when a preview is hosted under a branch prefix. */
|
||||
export const agentAvatarUrl: typeof apiAvatarUrl = (...args) => {
|
||||
const url = new URL(apiAvatarUrl(...args), "https://storybook.invalid");
|
||||
const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, "");
|
||||
return `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}.png`;
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -12,7 +13,7 @@ import { Dashboard } from "@/pages/Dashboard";
|
|||
import { NewAgent } from "@/pages/NewAgent";
|
||||
import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../fixtures/paperclipData";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { expect, waitFor } from "storybook/test";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
|
@ -14,7 +14,7 @@ const meta = {
|
|||
title: "Agents/Personas",
|
||||
component: AgentCharacter,
|
||||
args: { appearance, size: 256, state: "listening", label: "Chief of Staff" },
|
||||
parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Run Paperclip locally, or set PAPERCLIP_STORYBOOK_API_URL to an isolated API when starting Storybook. Static Storybook hosting must route /api/agent-avatars to Paperclip. No images are baked into Storybook." } } },
|
||||
parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Development Storybook uses PAPERCLIP_STORYBOOK_API_URL. Published Storybook packages PNGs from the same API renderer automatically during its build, including every preset and both densities; no running API is required." } } },
|
||||
argTypes: {
|
||||
state: { control: "select", options: CHARACTER_STATES },
|
||||
size: { control: "select", options: AGENT_AVATAR_SIZES },
|
||||
|
|
|
|||
Loading…
Reference in New Issue