From 7627feb0cb9b300bc8413b07fdd8b2d13348ab43 Mon Sep 17 00:00:00 2001 From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:34:47 -0700 Subject: [PATCH] feat(apps): standardize connector brand artwork --- doc/connections/CONNECTOR-ICONS.md | 57 + doc/connections/CONNECTOR-PLAYBOOK.md | 2 + packages/shared/src/app-definitions.test.ts | 14 +- .../shared/src/app-definitions/agentmail.json | 2 +- .../shared/src/app-definitions/airtable.json | 3 +- .../shared/src/app-definitions/asana.json | 3 +- .../shared/src/app-definitions/beehiiv.json | 3 +- .../shared/src/app-definitions/bitly.json | 3 +- packages/shared/src/app-definitions/box.json | 3 +- .../shared/src/app-definitions/candid.json | 3 +- .../src/app-definitions/clickhouse.json | 3 +- .../src/app-definitions/cloudflare.json | 3 +- packages/shared/src/app-definitions/coda.json | 3 +- .../shared/src/app-definitions/discord.json | 3 +- .../shared/src/app-definitions/egnyte.json | 3 +- .../shared/src/app-definitions/embat.json | 3 +- .../shared/src/app-definitions/gmail.json | 3 +- .../src/app-definitions/google-calendar.json | 3 +- .../src/app-definitions/google-chat.json | 3 +- .../src/app-definitions/google-docs.json | 3 +- .../src/app-definitions/google-drive.json | 3 +- .../src/app-definitions/google-people.json | 3 +- .../src/app-definitions/google-sheets.json | 3 +- .../src/app-definitions/google-slides.json | 3 +- .../google-workspace-search.json | 3 +- .../src/app-definitions/hugging-face.json | 3 +- .../src/app-definitions/imessage-photon.json | 3 +- packages/shared/src/app-definitions/jira.json | 2 +- .../shared/src/app-definitions/kernel.json | 3 +- .../shared/src/app-definitions/linear.json | 3 +- .../src/app-definitions/local-falcon.json | 3 +- .../shared/src/app-definitions/manufact.json | 3 +- packages/shared/src/app-definitions/mem0.json | 3 +- .../src/app-definitions/microsoft-teams.json | 3 +- packages/shared/src/app-definitions/miro.json | 2 +- .../shared/src/app-definitions/mixpanel.json | 3 +- .../shared/src/app-definitions/netlify.json | 3 +- .../shared/src/app-definitions/notion.json | 2 +- .../shared/src/app-definitions/oreilly.json | 3 +- .../shared/src/app-definitions/pagerduty.json | 3 +- .../shared/src/app-definitions/postman.json | 3 +- .../shared/src/app-definitions/shopify.json | 3 +- .../shared/src/app-definitions/slack.json | 3 +- .../shared/src/app-definitions/stripe.json | 3 +- .../shared/src/app-definitions/supabase.json | 3 +- .../shared/src/app-definitions/telegram.json | 3 +- .../shared/src/app-definitions/ticktick.json | 3 +- .../shared/src/app-definitions/todoist.json | 3 +- .../shared/src/app-definitions/webflow.json | 3 +- packages/shared/src/app-definitions/wix.json | 3 +- packages/shared/src/app-definitions/xero.json | 3 +- .../shared/src/app-definitions/zapier.json | 3 +- scripts/app-brand-validation.mjs | 60 + scripts/app-brand-validation.test.mjs | 35 + scripts/check-app-brand-assets.mjs | 8 + scripts/import-app-brand-assets.mjs | 28 + scripts/ingest-app-definitions.mjs | 22 +- ui/public/brands/apps/agentmail.svg | 31 +- ui/public/brands/apps/airtable.svg | 2 +- ui/public/brands/apps/asana.svg | 13 +- ui/public/brands/apps/clickhouse-dark.svg | 1 + ui/public/brands/apps/clickhouse.svg | 2 +- ui/public/brands/apps/cloudflare.svg | 2 +- ui/public/brands/apps/cloudinary.svg | 4 +- ui/public/brands/apps/discord-dark.svg | 1 + ui/public/brands/apps/discord.svg | 5 +- ui/public/brands/apps/github-dark.svg | 11 +- ui/public/brands/apps/github.svg | 11 +- ui/public/brands/apps/gmail.svg | 2 +- ui/public/brands/apps/google-calendar.svg | 2 +- ui/public/brands/apps/google-chat.svg | 2 +- ui/public/brands/apps/google-docs.svg | 2 +- ui/public/brands/apps/google-drive.svg | 2 +- ui/public/brands/apps/google-people.svg | 2 +- ui/public/brands/apps/google-sheets.svg | 2 +- ui/public/brands/apps/google-slides.svg | 2 +- .../brands/apps/google-workspace-search.svg | 2 +- ui/public/brands/apps/hugging-face.svg | 9 +- ui/public/brands/apps/jam-dev-dark.svg | 1 + ui/public/brands/apps/jam-dev.svg | 1 + ui/public/brands/apps/jira.svg | 5 +- ui/public/brands/apps/linear-dark.svg | 5 + ui/public/brands/apps/linear.svg | 6 +- ui/public/brands/apps/manifest.json | 2152 ++++++++++++++++- ui/public/brands/apps/manufact-dark.svg | 6 + ui/public/brands/apps/manufact.svg | 25 +- ui/public/brands/apps/microsoft-teams.svg | 88 +- ui/public/brands/apps/miro.svg | 5 +- ui/public/brands/apps/mixpanel-dark.svg | 1 + ui/public/brands/apps/netlify-dark.svg | 1 + ui/public/brands/apps/netlify.svg | 17 +- ui/public/brands/apps/notion.svg | 2 +- ui/public/brands/apps/pagerduty.svg | 5 +- ui/public/brands/apps/posthog.svg | 2 +- ui/public/brands/apps/postman.svg | 2 +- ui/public/brands/apps/shopify.svg | 27 +- ui/public/brands/apps/slack.svg | 17 + ui/public/brands/apps/stripe.svg | 12 +- ui/public/brands/apps/supabase.svg | 16 +- ui/public/brands/apps/telegram.svg | 14 +- ui/public/brands/apps/todoist.svg | 2 +- ui/public/brands/apps/wix-dark.svg | 12 + ui/public/brands/apps/wix.svg | 13 +- ui/public/brands/apps/zapier.svg | 2 +- ui/src/lib/app-brand-assets.test.ts | 23 + ui/src/lib/app-brand-assets.ts | 20 +- .../pages/apps/AppLogo.brand-assets.test.tsx | 24 + ui/src/pages/apps/AppLogo.tsx | 4 +- .../stories/AppLogoRegistry.stories.tsx | 38 + 109 files changed, 2753 insertions(+), 274 deletions(-) create mode 100644 doc/connections/CONNECTOR-ICONS.md create mode 100644 scripts/app-brand-validation.mjs create mode 100644 scripts/app-brand-validation.test.mjs create mode 100644 scripts/check-app-brand-assets.mjs create mode 100644 scripts/import-app-brand-assets.mjs create mode 100644 ui/public/brands/apps/clickhouse-dark.svg create mode 100644 ui/public/brands/apps/discord-dark.svg create mode 100644 ui/public/brands/apps/jam-dev-dark.svg create mode 100644 ui/public/brands/apps/jam-dev.svg create mode 100644 ui/public/brands/apps/linear-dark.svg create mode 100644 ui/public/brands/apps/manufact-dark.svg create mode 100644 ui/public/brands/apps/mixpanel-dark.svg create mode 100644 ui/public/brands/apps/netlify-dark.svg create mode 100644 ui/public/brands/apps/slack.svg create mode 100644 ui/public/brands/apps/wix-dark.svg create mode 100644 ui/storybook/stories/AppLogoRegistry.stories.tsx diff --git a/doc/connections/CONNECTOR-ICONS.md b/doc/connections/CONNECTOR-ICONS.md new file mode 100644 index 0000000000..a6b89eabaa --- /dev/null +++ b/doc/connections/CONNECTOR-ICONS.md @@ -0,0 +1,57 @@ +# Connector icons + +The durable rule is **fixed gray Paperclip frame + authentic vendor artwork + explicit theme variants + optical fit + provenance**. Use the shared `AppLogo` and local registry for every connector surface. Brand-library membership does not enable an app, grant access, or change catalog visibility. + +## Add or update artwork + +1. Find the stable app slug in `ui/public/brands/apps/manifest.json`. Preserve it and the product name. Add explicit name aliases only when unambiguous; owner-qualified connection names should pass `brandKey` from their definition. Do not guess a provider from a substring. +2. Select an authentic compact vendor mark. Keep the original file and exact URL. Record `sourceClass`, source bundle-relative `sourceFile`, `sourceUrl`, acquisition date and SHA-256 for **each** theme. Distinguish vendor-sourced files, supplied artwork, board-selected exports, retained upstream files and Simple Icons derivatives. A Paper export URL establishes which file was selected; it does not independently establish vendor authorship. Historical `officialSourceUrl` is a vendor reference, not verification of a replacement file. +3. Declare `localAsset`, `darkAsset` and `themeMode` (`same` or `pair`). Same artwork explicitly points both fields at one file. A resolved local mark owns both themes; a remote dark URL cannot override it. Preserve authentic black/white companions. Export complete vendor theme groups unchanged when an original uses a theme media query, then let the product theme select the file. Do not CSS-invert, recolor, trace, stretch, crop arbitrarily, or wrap a raster in SVG. +4. Keep the existing `bg-muted rounded-lg` outer frame, caller width/height, alignment and caller border. Native vendor tiles, gradients, white internal details and aspect ratios stay inside it. Notion's white body belongs to its glyph. No transparent, full-bleed or protective outer-surface overrides. `opticalFit: standard` currently means the existing contained image with `p-1.5`; record any unresolved small/weak mark in `selection`, then assess it at actual size. A new optical fit needs an explicit token-backed inner-image rule and visual evidence, without changing the frame. +5. Run the importer against an extracted, reviewed source bundle. It validates the entire batch and hashes before writing. Dry run is the default; `--apply` copies exact selected bytes. Do not overwrite unrelated registry rows when importing a newer source catalog. + +```sh +node scripts/import-app-brand-assets.mjs --source-dir /path/to/extracted-bundle +node scripts/import-app-brand-assets.mjs --source-dir /path/to/extracted-bundle --apply +node scripts/check-app-brand-assets.mjs +node --test scripts/app-brand-validation.test.mjs +node scripts/ingest-app-definitions.mjs --branding-only +node scripts/ingest-app-definitions.mjs --branding-only --check +pnpm exec vitest run ui/src/lib/app-brand-assets.test.ts ui/src/pages/apps/AppLogo.test.tsx ui/src/pages/apps/AppLogo.brand-assets.test.tsx packages/shared/src/app-definitions.test.ts +pnpm check:token-gates +pnpm --filter @paperclipai/ui typecheck +pnpm --filter @paperclipai/ui build +``` + +`--branding-only` regenerates only branding in existing app definitions, without requiring the external connector capture corpus or adding app definitions. The normal full ingest uses the same manifest mapping. Generic MCP/API-key definitions keep their generic icons. Neither generator mode interprets a brand-only registry row as permission to activate a connector. + +The structural validator rejects missing files/hashes/provenance, incomplete pairs, ambiguous aliases, external SVG resources, scripting, event handlers, animation, theme media queries and raster wrappers. It is a conservative rejection check, not a general SVG sanitizer or proof of optical quality. Genuine PNG files remain supported. Intrinsic width/height are valid when a vendor file has no viewBox; do not fabricate geometry just to satisfy tooling. + +## Visual proof + +Use Storybook `Apps/Canonical icon registry/Light` and `/Dark` for the **synthetic test fixtures** at 24/28/32/36/44/48px. The first row group is Gmail, Calendar, Slack and Stripe. Verify all images load without third-party requests, retain native details and remain recognizable. Compare computed wrapper width, height, radius, fill and border with the pre-change checkout in both themes. Preserve decorative `alt=""` next to readable names and image-error fallback. + +Then use the task's managed, isolated, quarantined, seeded runtime to inspect Discover, connected apps, detail/setup, approval cards, sidebar and agent-access references. Only represent supported runtime data as real product evidence. Do not run provider actions for icon QA. Verify `/api/health`, root/apps responses, populated company data, seed-manifest completion, isolated config/database/ports and OS listener PID/cwd. A healthy response alone does not establish the checkout identity. Upload screenshots and the evidence/exception report to the task. Report missing surfaces or measurements explicitly; synthetic stories do not substitute for real caller verification. + +## September 11 selection exceptions + +The manifest's `selection` and per-theme `provenance` are the complete ledger. The approved bundle hash is `8297f781858b44cc43edd7824725e5c700542adc9a9f563610d5b42675d54485`. + +- Cloudinary and PostHog retain upstream white dark marks; Netlify retains its teal dark mark. Mixpanel retains purple light and selects the supplied white dark mark. +- Context7, Kernel, Box, Egnyte, O'Reilly, Xero, beehiiv, Candid and Local Falcon retain upstream sources while sourcing remains deferred. Resend and Sentry retain their baseline pairs until clear-space/optical sizing is verified. Genuine rasters are intentional interim files. +- Manufact uses the complete existing vendor groups exported separately; AgentMail uses the board's supplied compact SVG in both themes, with original black/white artwork and added viewBox only. AgentMail provenance is not vendor-verified. +- Google People and Workspace Search deliberately share Google G artwork as parent-brand fallbacks while retaining separate IDs and names. Image-byte uniqueness is not an invariant. +- Jam.dev is brand-only in this baseline. No Jam app definition or connector capability is created. + +## Onboarding pressure test + +These are workflow examples, not connector implementations or claims that new artwork has been sourced. + +| Candidate | Apply the guide | Evidence needed before accepting art | +| --- | --- | --- | +| Granola | Check for an existing stable identity first; source a compact authentic symbol and explicit theme choices. | Exact source bytes/URL, provenance class and small-size render on both gray frames; retain a source gap if unavailable. | +| Circleback | Check normalized names and aliases for collisions before adding a brand-only row. Avoid substituting a generic circular arrow. | File hashes, native aspect ratio, contrast-safe theme pair or proven same-artwork choice. | +| Paper.design | Keep the product name distinct from Paperclip; choose an explicit stable slug and alias only if needed. | Provenance for the actual mark, internal white details retained, no protective frame override. | +| Figma | First check existing registry/definition coverage; keep multicolor vendor artwork intact. Brand sourcing does not install a Figma plugin or enable connector tools. | Exact source record, genuine vector/PNG validation, both theme references, generator idempotence and caller screenshots. | + +Each example can stop with a recorded source gap while other ready artwork proceeds. No additional approval workflow is introduced by this reference. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index ca77a2600b..cf46f04316 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 diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 37cbc57045..0496580c49 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 complete local branding provenance for every store-visible provider", () => { const uiPublic = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../ui/public", @@ -718,14 +718,10 @@ describe("AppDefinition catalog", () => { }>; }; 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)), @@ -736,7 +732,7 @@ describe("AppDefinition catalog", () => { expect(provenance.localAsset).toBe(app.branding.logoUrl); expect(provenance.darkAsset).toBe(app.branding.darkLogoUrl); expect(provenance.darkVariantRequired).toBe( - Boolean(provenance.darkAsset), + provenance.darkAsset !== provenance.localAsset, ); expect(new URL(provenance.officialSourceUrl).protocol).toBe("https:"); expect(new URL(provenance.upstreamAssetUrl).protocol).toBe("https:"); @@ -749,7 +745,7 @@ describe("AppDefinition catalog", () => { expect(asset.readUInt32BE(20)).toBeGreaterThanOrEqual(128); } else { const svg = asset.toString("utf8"); - expect(svg).toMatch(/^\s*)? createHash("sha256").update(bytes).digest("hex"); +export const normalizeBrandKey = (value) => 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, ...row.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); + } + if (!["same", "pair"].includes(row.themeMode) || !row.localAsset || !row.darkAsset + || (row.themeMode === "same") !== (row.localAsset === row.darkAsset) + || row.darkVariantRequired !== (row.themeMode === "pair")) throw new Error(`${row.slug}: incomplete or inconsistent theme pair`); + if (row.opticalFit !== "standard" || !row.selection?.note || !row.selection?.opticalReview) throw new Error(`${row.slug}: missing optical fit or selection evidence`); + for (const [theme, asset] of [["light", row.localAsset], ["dark", row.darkAsset]]) { + if (!assetPathPattern.test(asset) || !asset.endsWith(`.${row.assetType}`)) throw new Error(`${row.slug}: invalid asset path`); + const provenance = row.provenance?.[theme]; + if (!provenance || !["board-selected", "user-supplied", "vendor-theme-export", "simple-icons-derivative", "retained-upstream", "vendor-sourced"].includes(provenance.sourceClass) + || !provenance.sourceFile || !provenance.sourceUrl || !provenance.acquiredAt || !/^[a-f0-9]{64}$/.test(provenance.sha256)) throw new Error(`${row.slug}/${theme}: missing provenance`); + if (new URL(provenance.sourceUrl).protocol !== "https:" || provenance.sourceFile.startsWith("/") + || provenance.sourceFile.split(/[\\/]/).includes("..")) throw new Error(`${row.slug}/${theme}: invalid provenance source`); + const bytes = readAsset(asset, provenance); + if (sha256(bytes) !== provenance.sha256) throw new Error(`${row.slug}/${theme}: source hash mismatch`); + validateArtwork(bytes, 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 0000000000..6a26b51e82 --- /dev/null +++ b/scripts/app-brand-validation.test.mjs @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateArtwork, validateManifest, sha256 } 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", + themeMode: "same", darkVariantRequired: false, assetType: "svg", opticalFit: "standard", + selection: { note: "Supplied artwork", opticalReview: "Pending" }, + provenance: Object.fromEntries(["light", "dark"].map((theme) => [theme, { sourceClass: "user-supplied", sourceFile: "example.svg", sourceUrl: "https://example.com/icon.svg", sha256: sha256(svg), acquiredAt: "2026-09-11" }])), +}); +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 ['