diff --git a/doc/connections/CONNECTOR-ICONS.md b/doc/connections/CONNECTOR-ICONS.md new file mode 100644 index 00000000000..3648ebbed73 --- /dev/null +++ b/doc/connections/CONNECTOR-ICONS.md @@ -0,0 +1,29 @@ +# Connector icons + +Use the shared `AppLogo` component and bundled artwork in `ui/public/brands/apps`. +Keep the existing gray rounded frame, caller size and border, and contained image +padding. Preserve vendor shapes and colors; use a separate dark asset only when +the light artwork is unsuitable on the dark frame. Do not invert or stretch marks. + +The public manifest contains only identity, catalog visibility, artwork paths, +and optional aliases. Omit `darkAsset` when both themes use the same file. Keep +matching logo paths in the app definition. Brand-library membership does not +enable a connector. Google People and Workspace Search share the Google mark. + +Use reviewed vendor or supplied source files. Keep source research and review +records outside the browser-served manifest. Never add credentials or private +review links to public assets. Render SVGs as images, not inline HTML. The +structural safety check rejects common active SVG features; it is not a general +sanitizer for untrusted uploads. + +Before submitting artwork, run: + +```sh +node scripts/check-app-brand-assets.mjs +node --test scripts/app-brand-validation.test.mjs +pnpm exec vitest run ui/src/lib/app-brand-assets.test.ts ui/src/pages/apps/AppLogo.brand-assets.test.tsx packages/shared/src/app-definitions.test.ts +``` + +Run the structural artwork check locally. Review the Storybook canonical icon registry +in light and dark themes at 24–48px, then inspect affected product surfaces. Check +contrast, optical size, native details, and the existing image-error fallback. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index ca77a2600b2..6bd8a9de77c 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -5,6 +5,8 @@ and shipping Paperclip app connections. Status: canonical end-to-end authoring guide for Apps v2 catalog connections. +For connector artwork, follow [Connector icons](./CONNECTOR-ICONS.md): fixed gray Paperclip frames, authentic vendor artwork, explicit theme variants, optical fit and exact provenance. Brand-library additions do not activate connectors. Use the shared registry/resolver and branding generator; do not introduce per-screen logos or outer-surface overrides. + This runbook is the repeatable, agent-executable procedure for adding a vendor to the Apps catalog as data, not as a plugin. It follows the accepted connections framework in [PAP-13211](/PAP/issues/PAP-13211), the first-30 @@ -87,7 +89,7 @@ scripts/ingest-app-definitions.mjs # human-authored definition so packages/shared/src/app-definitions/.json # generated definition packages/shared/src/app-definitions.generated.ts # generated registry ui/public/brands/apps/.svg # official, sanitized mark -ui/public/brands/apps/manifest.json # branding provenance +ui/public/brands/apps/manifest.json # runtime branding paths packages/shared/src/app-definitions.test.ts # manifest/provider assertions ``` @@ -571,15 +573,15 @@ only a runtime image-failure fallback. external executable content, or unsafe references. 5. Save assets under `ui/public/brands/apps/`. Add a `-dark` variant only when the normal mark loses contrast in dark mode. -6. Add the provider to `ui/public/brands/apps/manifest.json` with slug, local - asset, optional dark asset, official source URL, exact upstream asset URL, - asset type, visibility, and dark-variant requirement. +6. Add the provider to `ui/public/brands/apps/manifest.json` with slug, name, + local asset, optional dark asset, visibility, and optional aliases. Keep source + URLs and verification notes in the review record, outside the public manifest. 7. Let the ingestion script derive `branding.logoUrl` and `darkLogoUrl` from the - provenance manifest. + runtime manifest. The manifest test decodes PNG headers, requires at least 128 by 128 pixels, sanity-checks SVG markup, verifies files exist, and requires store-visible -definitions and visible provenance entries to match exactly. +definitions and visible manifest entries to match exactly. ### Phase 5: Author the definition at the durable source diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 37cbc570459..2f70bd73e57 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -698,7 +698,7 @@ describe("AppDefinition catalog", () => { expect(storeSlugs.has(slug), slug).toBe(false); } }); - it("ships complete local branding provenance for all 46 store-visible providers", () => { + it("ships matching local artwork for every store-visible provider", () => { const uiPublic = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../ui/public", @@ -711,21 +711,13 @@ describe("AppDefinition catalog", () => { catalogVisible: boolean; localAsset: string; darkAsset?: string; - officialSourceUrl: string; - upstreamAssetUrl: string; - assetType: "svg" | "png"; - darkVariantRequired: boolean; }>; }; const visible = manifest.providers.filter((entry) => entry.catalogVisible); - expect(visible).toHaveLength(46); + expect(visible).toHaveLength(APP_STORE_DEFINITIONS.length); expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty( "size", - 46, - ); - expect(new Set(visible.map((entry) => entry.localAsset))).toHaveProperty( - "size", - 46, + visible.length, ); expect(new Set(APP_STORE_DEFINITIONS.map((entry) => entry.slug))).toEqual( new Set(visible.map((entry) => entry.slug)), @@ -735,21 +727,16 @@ describe("AppDefinition catalog", () => { expect(provenance).toBeTruthy(); expect(provenance.localAsset).toBe(app.branding.logoUrl); expect(provenance.darkAsset).toBe(app.branding.darkLogoUrl); - expect(provenance.darkVariantRequired).toBe( - Boolean(provenance.darkAsset), - ); - expect(new URL(provenance.officialSourceUrl).protocol).toBe("https:"); - expect(new URL(provenance.upstreamAssetUrl).protocol).toBe("https:"); expect(provenance.localAsset).toMatch(/^\/brands\/apps\/.+\.(svg|png)$/); expect(provenance.localAsset).not.toContain("google.com/s2/favicons"); const asset = fs.readFileSync(path.join(uiPublic, provenance.localAsset)); - if (provenance.assetType === "png") { + if (provenance.localAsset.endsWith(".png")) { expect(asset.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); expect(asset.readUInt32BE(16)).toBeGreaterThanOrEqual(128); expect(asset.readUInt32BE(20)).toBeGreaterThanOrEqual(128); } else { const svg = asset.toString("utf8"); - expect(svg).toMatch(/^\s*)? value.trim().toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + +// Conservative structural rejection, not a general-purpose SVG sanitizer. +// Original accepted artwork is copied byte-for-byte, never rewritten here. +export function validateArtwork(bytes, filename) { + if (filename.endsWith(".png")) { + if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) === 0 || bytes.readUInt32BE(20) === 0 + || bytes.toString("ascii", bytes.length - 8, bytes.length - 4) !== "IEND") { + throw new Error(`${filename}: invalid PNG signature`); + } + return; + } + const svg = bytes.toString("utf8"); + if (!filename.endsWith(".svg") || (!/]*\bviewBox\s*=\s*["'][^"']+["']/i.test(svg) && !(/]*\bwidth\s*=\s*["'][0-9.]+(?:px)?["']/i.test(svg) && /]*\bheight\s*=\s*["'][0-9.]+(?:px)?["']/i.test(svg)))) { + throw new Error(`${filename}: SVG requires a viewBox or intrinsic width and height`); + } + if (/ typeof alias !== "string" || !alias.trim())) throw new Error(`${row.slug}: invalid aliases`); + for (const name of [row.slug, row.provider, ...aliases]) { + const key = normalizeBrandKey(name); + if (keys.has(key) && keys.get(key) !== row.slug) throw new Error(`${row.slug}: ambiguous brand alias ${name}`); + keys.set(key, row.slug); + } + for (const asset of new Set([row.localAsset, ...(row.darkAsset === undefined ? [] : [row.darkAsset])])) { + if (typeof asset !== "string" || !assetPathPattern.test(asset)) throw new Error(`${row.slug}: invalid asset path`); + validateArtwork(readAsset(asset), asset); + } + } + return manifest.providers.length; +} diff --git a/scripts/app-brand-validation.test.mjs b/scripts/app-brand-validation.test.mjs new file mode 100644 index 00000000000..df7a427776b --- /dev/null +++ b/scripts/app-brand-validation.test.mjs @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateArtwork, validateManifest } from "./app-brand-validation.mjs"; + +const svg = Buffer.from(''); +const record = () => ({ + slug: "example", provider: "Example", aliases: [], localAsset: "/brands/apps/example.svg", darkAsset: "/brands/apps/example.svg", +}); +const check = (row, read = () => svg) => validateManifest({ schemaVersion: 1, providers: [row] }, read); + +test("accepts authentic vector artwork, native gradients and actual PNG bytes", () => { + assert.equal(check(record()), 1); + validateArtwork(Buffer.from(''), "gradient.svg"); + validateArtwork(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"), "native.png"); + assert.throws(() => validateArtwork(Buffer.from("89504e470d0a1a0a", "hex"), "truncated.png"), /PNG/); + assert.throws(() => validateArtwork(svg, "fake.png"), /PNG/); +}); +for (const payload of ['