diff --git a/.changeset/sharp-logos-arrive.md b/.changeset/sharp-logos-arrive.md new file mode 100644 index 0000000..79c6e04 --- /dev/null +++ b/.changeset/sharp-logos-arrive.md @@ -0,0 +1,24 @@ +--- +"@elide/brand": minor +"@elide/ui": minor +--- + +Ship the Elide logo as a catalog component. + +New `@elide/brand` package holding the logo geometry and every rendition +generated from it — 8 artworks (square/full × gradient/mono/blend, with light +and dark cuts where the variant is theme-dependent) as scalable SVG plus +png/webp/avif at 1x/2x/3x. + +`ElideLogo` in `@elide/ui` now covers light/dark, square/full form, and +vector/raster in one component. Vector is the default and inlines the SVG, so +there is no request and no asset to serve; `mono` and `blend` draw in +`currentColor` and invert with the theme for free. Raster mode reads from +`@elide/brand/assets` under `assetBase` (default `/brand`). + +**Breaking:** `` with no props now renders the official full +lockup. It previously rendered the nav's badge-plus-wordmark lockup, which is +different artwork. That lockup still ships as `AppNav`'s default, so navs are +unaffected; anywhere else, pass `form="square"` for the badge alone, or compose +`ElideMark` + `ElideWordmark` to reproduce the old output exactly. `markOnly` is +still accepted as a deprecated alias for `form="square"`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 555eb62..a0606b9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,6 +23,8 @@ ├─────────────────────────────────────────────────────────────┤ │ @elide/icons Lucide (react re-exports + raw SVG) │ framework-agnostic ├─────────────────────────────────────────────────────────────┤ +│ @elide/brand logo geometry · svg/png/webp/avif renditions │ framework-agnostic +├─────────────────────────────────────────────────────────────┤ │ @elide/tokens CSS vars · Tailwind theme · typed JS object │ framework-agnostic └─────────────────────────────────────────────────────────────┘ ``` @@ -108,6 +110,7 @@ components/ ├─ COMPONENTS.md ├─ packages/ │ ├─ tokens/ # @elide/tokens (done) +│ ├─ brand/ # @elide/brand (done) │ ├─ ui/ # @elide/ui (exemplars landed) │ └─ icons/ # @elide/icons (planned) └─ apps/ diff --git a/COMPONENTS.md b/COMPONENTS.md index 0921323..45fcf56 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -57,6 +57,7 @@ radii, type. See the package README. | `CommandPalette` | registry+ | built on `Command`; groups: pages·actions; recent | ⌘K overlay | | `MobileNav` | **done** | top bar + `Sheet` drawer + bottom "Search or ask AI" bar | mobile screens | | `ThemeProvider` / `useTheme` | **done** | class/`data-theme` on ``, persisted | dark/light toggle | +| `ElideLogo` | **done** | form: square·full; variant: gradient·mono·blend; format: svg·png·webp·avif; `linked`, `height`, `decorative`, `assetBase` | brand mark; artwork from `@elide/brand` | ## Tier 3 — Marketing widgets (from elide.dev) diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index a1b202b..e6c1135 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -16,6 +16,10 @@ const config: StorybookConfig = { name: "@storybook/react-vite", options: {}, }, + // ElideLogo's raster modes read from @elide/brand/assets under `assetBase`, + // which defaults to /brand. Serving it here is what a consuming app does when + // it copies the package's assets into its public directory. + staticDirs: [{ from: "../../../packages/brand/assets", to: "/brand" }], core: { disableTelemetry: true }, async viteFinal(cfg) { const { default: tailwindcss } = await import("@tailwindcss/vite"); diff --git a/bun.lock b/bun.lock index 8771b1b..1d5cd08 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,19 @@ "vitest": "^4.1.10", }, }, + "packages/brand": { + "name": "@elide/brand", + "version": "0.1.0", + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.8.0", + "globals": "^17.9.0", + "sharp": "^0.34.5", + "typescript": "^6.0.3", + "typescript-eslint": "^8.66.0", + "vitest": "^4.1.10", + }, + }, "packages/tokens": { "name": "@elide/tokens", "version": "0.1.0", @@ -59,6 +72,7 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.7.0", + "@elide/brand": "workspace:*", "@elide/tokens": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -207,6 +221,8 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@elide/brand": ["@elide/brand@workspace:packages/brand"], + "@elide/storybook": ["@elide/storybook@workspace:apps/storybook"], "@elide/tokens": ["@elide/tokens@workspace:packages/tokens"], @@ -215,7 +231,7 @@ "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], @@ -313,6 +329,56 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], @@ -671,7 +737,7 @@ "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], @@ -681,7 +747,7 @@ "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], - "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], @@ -739,7 +805,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001802", "", {}, "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw=="], - "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -843,7 +909,7 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -1171,6 +1237,8 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1359,6 +1427,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -1367,6 +1437,8 @@ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1389,16 +1461,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - - "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "ast-v8-to-istanbul/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1429,6 +1491,10 @@ "storybook/@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "storybook/ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="], "tsup/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], @@ -1437,10 +1503,6 @@ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], - - "vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - "vitest/vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], "@codecov/vite-plugin/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1483,12 +1545,16 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], - "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + "tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsup/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -1541,8 +1607,6 @@ "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "vitest/vite/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1607,6 +1671,8 @@ "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "vitest/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], diff --git a/docs/superpowers/plans/2026-08-05-elide-logo.md b/docs/superpowers/plans/2026-08-05-elide-logo.md new file mode 100644 index 0000000..b47c191 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-elide-logo.md @@ -0,0 +1,268 @@ +# ElideLogo Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Elide's logo as a catalog component covering light/dark, square/full form, and vector/raster (svg/png/webp/avif) modes, built from the official `creatives-v2` artwork. + +**Architecture:** A new framework-agnostic `@elide/brand` package holds the four extracted path strings as the single source of truth (`src/paths.ts`), a typed asset manifest (`src/manifest.ts`), and a build script that emits 8 standalone SVGs plus 72 raster renditions into `assets/`. `@elide/ui`'s rewritten `ElideLogo` inlines the vector by default and falls back to `` for raster formats, resolving light/dark through a CSS class swap rather than JavaScript. + +**Tech Stack:** TypeScript, React 19, Tailwind v4, tsup, vitest + @testing-library/react (jsdom), Storybook 10, bun workspaces + turbo, sharp (asset generation only). + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-08-05-elide-logo-design.md`. Every requirement there applies. +- Relative imports only — no `@/` path alias. +- Colors come from `@elide/tokens` CSS variables or Tailwind utilities that resolve to them. The two brand hexes `#662d91` and `#e7008c` are the sole exception: they are artwork, and they are already literal in `tokens.css` as `--eld-brand-purple` / `--eld-brand-magenta`. +- No new runtime dependencies. `sharp` is a `packages/brand` **devDependency** used only by the asset script. +- Geometry is copied verbatim from `creatives-v2`. Never retrace, round, or hand-edit a `d` string. +- Square artwork viewBox: `0 0 102.700292 114.027098`. Full artwork viewBox: `0 0 272.122583 90.385516`. +- Raster 1x sizes: square `104×115`, full `273×92`. Scales `@1x`, `@2x`, `@3x`. +- Generated assets are committed to git. +- Theme is a `.dark` class on ``. Never key logo theming off `prefers-color-scheme`. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `packages/brand/package.json` | Package manifest; `build` runs the asset script | +| `packages/brand/tsconfig.json` | Mirrors `packages/tokens/tsconfig.json` | +| `packages/brand/vitest.config.ts` | Node-environment unit tests for the manifest | +| `packages/brand/src/paths.ts` | The 4 verbatim `d` strings, viewBoxes, aspect ratios, gradient stops | +| `packages/brand/src/manifest.ts` | Typed form × variant × theme × format × scale matrix + `assetPath()` | +| `packages/brand/src/manifest.test.ts` | Manifest resolution tests | +| `packages/brand/src/index.ts` | Public surface | +| `packages/brand/scripts/build-assets.mjs` | `paths.ts` → 8 SVGs → png/webp/avif @1–3x | +| `packages/brand/scripts/verify-fidelity.mjs` | Diffs regenerated rasters against the delivered PNGs | +| `packages/brand/assets/**` | Generated, committed | +| `packages/ui/src/components/elide-logo.tsx` | The component (rewritten) | +| `packages/ui/src/components/elide-logo.test.tsx` | Unit tests (rewritten) | +| `packages/ui/src/stories/ElideLogo.stories.tsx` | Catalog stories | +| `packages/ui/src/components/app-nav.tsx` | `defaultLogo` becomes an explicit legacy composition | +| `packages/ui/src/index.ts` | Export the new types | +| `.changeset/*.md` | Minor bump for `@elide/ui`, initial release for `@elide/brand` | + +--- + +### Task 1: `@elide/brand` scaffold + geometry + +**Files:** +- Create: `packages/brand/package.json`, `packages/brand/tsconfig.json`, `packages/brand/src/paths.ts`, `packages/brand/src/index.ts` + +**Interfaces:** +- Produces: `SQUARE_VIEWBOX`, `FULL_VIEWBOX`, `SQUARE_ASPECT`, `FULL_ASPECT`, `BRAND_PURPLE`, `BRAND_MAGENTA`, and `paths = { squareBody, squareRibbon, fullGlyph, fullWordmark }`. + +- [ ] **Step 1: Extract the four path strings verbatim** + +Source them with a script, never by hand: + +```bash +python3 -c " +import re, json +def d(f): return re.findall(r']*?d=\"([^\"]+)\"', open(f).read()) +sq = d('$HOME/txfer/creatives-v2/elide-block-black.svg') +fu = d('$HOME/txfer/creatives-v2/elide-mark-black.svg') +print(json.dumps({'squareBody': sq[0], 'squareRibbon': sq[1], 'fullGlyph': fu[0], 'fullWordmark': fu[1]})) +" +``` + +Expected lengths: `squareBody` 429, `squareRibbon` 2741, `fullGlyph` 2732, `fullWordmark` 3429. + +- [ ] **Step 2: Write `src/paths.ts`** with those strings and the constants above, documenting the `creatives-v2` provenance of each. + +- [ ] **Step 3: Scaffold the package** — `package.json` (`@elide/brand`, `type: module`, exports `.` and `./assets/*`, `files: ["dist","src","assets"]`, build script `tsc -p tsconfig.json && node scripts/build-assets.mjs`), `tsconfig.json` copied from `packages/tokens`, `src/index.ts` re-exporting `paths.ts`. + +- [ ] **Step 4: Verify it compiles** + +Run: `bun install && bun run --filter @elide/brand build` +Expected: PASS (the asset script does not exist yet — add it in Task 2 and rerun; for this step run `bunx tsc -p packages/brand/tsconfig.json` alone). + +- [ ] **Step 5: Commit** — `feat(brand): extract Elide logo geometry from creatives-v2` + +--- + +### Task 2: Asset generation + fidelity gate + +**Files:** +- Create: `packages/brand/scripts/build-assets.mjs`, `packages/brand/scripts/verify-fidelity.mjs` +- Create (generated): `packages/brand/assets/{svg,png,webp,avif}/**` + +**Interfaces:** +- Consumes: `paths.ts` from Task 1. +- Produces: the 8 named artworks on disk under the naming scheme `elide-
-[-][@x].`. + +- [ ] **Step 1: Add `sharp` as a devDependency** — `bun add -D --cwd packages/brand sharp`. If it fails to install or render, fall back to ImageMagick per the spec's Risk section. + +- [ ] **Step 2: Write `build-assets.mjs`** emitting the 8 SVGs from `paths.ts`: + +| Artwork | Composition | +| --- | --- | +| `elide-square-gradient` | `squareBody` filled with the vertical gradient (`#662d91` at y=114.027098 → `#e7008c` at y=0); `squareRibbon` filled `#ffffff` | +| `elide-square-mono-light` | single path `squareBody + " " + squareRibbon`, `fill-rule="evenodd"`, `#000000` | +| `elide-square-mono-dark` | same, `#ffffff` | +| `elide-full-gradient` | `fullGlyph` with a vertical gradient over its bbox; `fullWordmark` with a horizontal gradient `x1=92.919817 → x2=272.344807` | +| `elide-full-mono-light` | both paths `#000000` | +| `elide-full-mono-dark` | both paths `#ffffff` | +| `elide-full-blend-light` | `fullGlyph` gradient; `fullWordmark` `#000000` | +| `elide-full-blend-dark` | `fullGlyph` gradient; `fullWordmark` `#ffffff` | + +Then rasterize each to png/webp/avif at 1x/2x/3x off the base sizes. + +- [ ] **Step 3: Write `verify-fidelity.mjs`** — render `elide-square-mono-light`, `elide-square-gradient`, and `elide-full-mono-light` at the delivered PNGs' exact dimensions and compare pixel-by-pixel against `~/txfer/creatives-v2/elide-block-black.png`, `elide-block-gradient.png`, and `elide-mark-black.png`. Report mean absolute error per channel. + +- [ ] **Step 4: Run the fidelity gate** + +Run: `node packages/brand/scripts/verify-fidelity.mjs` +Expected: mean absolute error within antialiasing noise on all three. A structural mismatch means the `evenodd` composition or a gradient geometry is wrong — fix before continuing. This gate is what justifies the whole rebuild-from-paths approach. + +- [ ] **Step 5: Generate and commit** — `node packages/brand/scripts/build-assets.mjs`, then commit the script and `assets/`. + +--- + +### Task 3: Typed asset manifest + +**Files:** +- Create: `packages/brand/src/manifest.ts`, `packages/brand/src/manifest.test.ts`, `packages/brand/vitest.config.ts` +- Modify: `packages/brand/src/index.ts` + +**Interfaces:** +- Produces: + ```ts + type BrandForm = "square" | "full"; + type BrandVariant = "gradient" | "mono" | "blend"; + type BrandFormat = "svg" | "png" | "webp" | "avif"; + type BrandTheme = "light" | "dark"; + function isThemed(form: BrandForm, variant: BrandVariant): boolean; + function resolveVariant(form: BrandForm, variant: BrandVariant): BrandVariant; + function assetPath(o: { form; variant; format; theme?; scale?; base? }): string; + const BASE_SIZE: Record; + ``` + `resolveVariant` collapses `blend` → `gradient` on `square`. `isThemed` is false for `gradient`. + +- [ ] **Step 1: Write the failing tests** + +```ts +import { describe, it, expect } from "vitest"; +import { assetPath, isThemed, resolveVariant, BASE_SIZE } from "./manifest"; + +describe("manifest", () => { + it("collapses blend to gradient on the square form", () => { + expect(resolveVariant("square", "blend")).toBe("gradient"); + expect(resolveVariant("full", "blend")).toBe("blend"); + }); + + it("treats gradient as theme-agnostic", () => { + expect(isThemed("full", "gradient")).toBe(false); + expect(isThemed("full", "mono")).toBe(true); + expect(isThemed("full", "blend")).toBe(true); + expect(isThemed("square", "blend")).toBe(false); // resolves to gradient + }); + + it("omits the theme segment for gradient and the scale suffix at 1x svg", () => { + expect(assetPath({ form: "full", variant: "gradient", format: "svg" })) + .toBe("/brand/svg/elide-full-gradient.svg"); + }); + + it("includes theme and scale for raster themed artwork", () => { + expect(assetPath({ form: "full", variant: "blend", format: "webp", theme: "dark", scale: 2 })) + .toBe("/brand/webp/elide-full-blend-dark@2x.webp"); + }); + + it("honors a custom base", () => { + expect(assetPath({ form: "square", variant: "mono", format: "avif", theme: "light", base: "/static/art" })) + .toBe("/static/art/avif/elide-square-mono-light.avif"); + }); + + it("exposes 1x base sizes matching the delivered artwork", () => { + expect(BASE_SIZE.square).toEqual({ width: 104, height: 115 }); + expect(BASE_SIZE.full).toEqual({ width: 273, height: 92 }); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `bun run --filter @elide/brand test` +Expected: FAIL — `./manifest` does not exist. + +- [ ] **Step 3: Implement `manifest.ts`** to satisfy exactly those cases; SVG never takes a scale suffix. + +- [ ] **Step 4: Run to verify it passes** — Expected: 6 passing. + +- [ ] **Step 5: Commit** — `feat(brand): typed asset manifest` + +--- + +### Task 4: Rewrite `ElideLogo` + +**Files:** +- Modify: `packages/ui/src/components/elide-logo.tsx`, `packages/ui/src/components/app-nav.tsx`, `packages/ui/src/index.ts`, `packages/ui/package.json` +- Test: `packages/ui/src/components/elide-logo.test.tsx` + +**Interfaces:** +- Consumes: `@elide/brand` — `paths`, `assetPath`, `resolveVariant`, `isThemed`, `BASE_SIZE`, viewBox and aspect constants. +- Produces: `ElideLogo`, `ElideLogoProps`, `ElideLogoForm`, `ElideLogoVariant`, `ElideLogoFormat`; `ElideMark` and `ElideWordmark` unchanged in output. + +- [ ] **Step 1: Add the workspace dependency** — `"@elide/brand": "workspace:*"` in `packages/ui/package.json`, then `bun install`. + +- [ ] **Step 2: Write the failing tests** covering, at minimum: + - default render is the full lockup, one inline ``, accessible name `Elide` + - `form="square"` renders the square viewBox + - `variant="mono"` uses `currentColor` and emits no `linearGradient` + - `variant="blend"` on `full` emits a gradient glyph and a `currentColor` wordmark + - `variant="blend"` on `square` renders identically to `variant="gradient"` + - two instances get distinct gradient ids + - `decorative` sets `aria-hidden` and removes the accessible name + - `format="png" | "webp" | "avif"` renders `` with the right extension in `src`, a `srcSet` carrying `2x` and `3x`, and intrinsic `width`/`height` + - themed variants render a light/dark `` pair (`dark:hidden`, `hidden dark:block`); `gradient` renders one `` + - `assetBase` overrides the URL prefix + - `linked` renders `` pointing at the `.svg` + - `height` sets the height and derives the width from the aspect ratio + - `markOnly` still yields the square form + - `ElideMark` / `ElideWordmark` keep their current output and unique-id behavior + +- [ ] **Step 3: Run to verify they fail** + +Run: `bun run --filter @elide/ui test -- elide-logo` +Expected: FAIL. + +- [ ] **Step 4: Implement the component** per the spec's API block. Inline vector for `format="svg"` unless `linked`; `` otherwise; gradient ids from `React.useId()`; default classes `h-6 w-auto`; wrapper carries `text-foreground` so `mono`/`blend` invert with the theme. + +- [ ] **Step 5: Keep `AppNav` visually unchanged** — replace `const defaultLogo = ` with the explicit `ElideMark` + `ElideWordmark` span currently living in `elide-logo.tsx` (gap `9px`, mark `h-[23px]`, wordmark `h-[14px]` and `aria-hidden`). + +- [ ] **Step 6: Run the full UI suite** + +Run: `bun run --filter @elide/ui test` +Expected: PASS, including the untouched `app-nav.test.tsx`. + +- [ ] **Step 7: Commit** — `feat(ui): ElideLogo with form, variant and format axes` + +--- + +### Task 5: Stories, docs, changeset, verification + +**Files:** +- Create: `packages/ui/src/stories/ElideLogo.stories.tsx`, `.changeset/.md` +- Modify: `COMPONENTS.md`, `ARCHITECTURE.md`, `packages/brand/README.md` + +- [ ] **Step 1: Write the stories** — one per `form × variant`, plus format, sizing, `decorative`, and an on-dark-surface story. Chromatic captures each in both themes automatically. + +- [ ] **Step 2: Document** — add `ElideLogo` to the `COMPONENTS.md` Tier-2 table, add `@elide/brand` to the `ARCHITECTURE.md` layer diagram and repo layout, and write `packages/brand/README.md` covering the artwork matrix, the asset naming scheme, how to regenerate, and how consumers serve `assets/`. + +- [ ] **Step 3: Add a changeset** — minor for `@elide/ui` (noting the breaking change to ``'s default render), initial minor for `@elide/brand`. + +- [ ] **Step 4: Verify the whole repo** + +Run: `bun run lint && bun run test && bun run build` +Expected: all PASS. Confirm coverage has not regressed below the catalog's 97% statement bar. + +- [ ] **Step 5: Commit** — `docs: ElideLogo stories, brand README, changeset` + +--- + +## Self-Review + +**Spec coverage.** Source mapping → Task 1. Vector source of truth and the mono `evenodd` knockout → Tasks 1–2. Package layout → Tasks 1–3. Artwork matrix and raster ladder → Task 2. Manifest → Task 3. Component API, sizing, gradient ids, light/dark class swap → Task 4. Back-compat and `AppNav` → Task 4 Steps 5–6. Testing → Tasks 2–5. Risk (sharp vs ImageMagick) → Task 2 Step 1. Out-of-scope items are absent from every task, as intended. + +**Type consistency.** `resolveVariant`, `isThemed`, `assetPath`, `BASE_SIZE` are defined in Task 3 and consumed under those exact names in Task 4. `paths.{squareBody,squareRibbon,fullGlyph,fullWordmark}` is defined in Task 1 and used under those names in Tasks 2 and 4. diff --git a/docs/superpowers/specs/2026-08-05-elide-logo-design.md b/docs/superpowers/specs/2026-08-05-elide-logo-design.md new file mode 100644 index 0000000..50ab2b5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-elide-logo-design.md @@ -0,0 +1,263 @@ +# Design: `ElideLogo` — the brand mark as a catalog component + +**Date:** 2026-08-05 +**Status:** Approved +**Branch / delivery:** `feat/elide-logo` → PR into `main`. + +## Goal + +Ship Elide's logo as a first-class component in the catalog, built from the +official artwork in `~/txfer/creatives-v2`. It must cover three axes the brand +actually needs: + +- **light / dark** — the mark adapts to the active theme, +- **square / full form** — the hexagon badge alone, or the full lockup, +- **vector / raster mode** — inlined SVG by default, with `png` / `webp` / `avif` + available for contexts that can't take inline SVG (email, OG images, native + shells, ``-only CMS fields). + +SVG is the preferred and default mode. The raster formats are a supported +fallback, not the primary path. + +## Source artwork + +`~/txfer/creatives-v2` ships five artworks, each as `svg` / `png` / `webp` / +`jpg` / `pdf` at `@1x`–`@4x`: + +| File stem | Maps to | Notes | +| --- | --- | --- | +| `elide-block-black` | `form="square"` `variant="mono"` | Pure vector: 2 paths (hexagon body + knockout ribbon) | +| `elide-block-gradient` | `form="square"` `variant="gradient"` | Pure vector, same 2 paths, gradient body | +| `elide-mark-black` | `form="full"` `variant="mono"` | Pure vector: 2 paths (glyph ribbon + wordmark) | +| `elide-mark-gradient` | `form="full"` `variant="gradient"` | **Glyph is an embedded base64 PNG**, wordmark is vector | +| `elide-mark-blend` | `form="full"` `variant="blend"` | **Glyph is an embedded base64 PNG**, wordmark is vector | + +Geometry: square is `102.700292 × 114.027098` (≈0.9007:1), full is +`272.122583 × 90.385516` (≈3.0107:1). + +The two gradient/blend lockups are not genuinely vector — the glyph is a raster +image. Because `elide-mark-black.svg` and `elide-block-black.svg` *are* clean +two-path vectors, every variant is **rebuilt** from those four `d` strings by +re-filling them. Nothing is traced, redrawn, or approximated; the geometry is +byte-for-byte the delivered paths. + +No AVIF exists in the source set, so the AVIF renditions are generated. + +## Vocabulary + +The prop vocabulary follows the request (`square` / `full`), not the source +filenames (`block` / `mark`). The mapping is recorded in `paths.ts` so the +provenance stays traceable. + +- `form`: `"square"` — the hexagon badge. `"full"` — glyph + wordmark lockup. +- `variant`: `"gradient"` — brand gradient. `"mono"` — one color, `currentColor`. + `"blend"` — gradient glyph + mono wordmark. +- `blend` only differs from `gradient` on the `full` form. On `square` it + resolves to `gradient`, which is what "blend" means there (gradient body, + knockout ribbon). This is an alias, not an error. + +`form="square"` names the *role* (compact badge), not a 1:1 aspect — the +hexagon is 0.9:1 and is rendered tight-cropped, faithful to the artwork. A +padded 1:1 favicon/app-icon set is deliberately out of scope; it can be added +later without changing this API. + +## Package layout + +Artwork lives in a new **`@elide/brand`** package, below the framework line +described in `ARCHITECTURE.md` — it is framework-agnostic (raw geometry, raw +SVG, raw raster), so Astro, native, and Figma consumers can reach it without +pulling in React. + +``` +packages/brand/ +├─ package.json # @elide/brand +├─ tsconfig.json +├─ vitest.config.ts +├─ src/ +│ ├─ paths.ts # hand-authored geometry — SINGLE SOURCE OF TRUTH +│ ├─ manifest.ts # typed matrix + assetPath() +│ ├─ manifest.test.ts +│ └─ index.ts +├─ scripts/ +│ └─ build-assets.mjs # paths.ts → SVG files → png/webp/avif +└─ assets/ # generated, committed + ├─ svg/ + ├─ png/ + ├─ webp/ + └─ avif/ +``` + +`paths.ts` holds the `d` strings, viewBoxes, and gradient stops. Both the +inlined React vector and the standalone asset files derive from it, so the two +representations cannot drift. + +### Artwork matrix + +Eight distinct artworks. `gradient` is theme-agnostic and therefore a single +file; `mono` and `blend` have a light and a dark cut. + +| Artwork | Body | Ribbon / wordmark | +| --- | --- | --- | +| `elide-square-gradient` | brand gradient hexagon | `#ffffff` ribbon | +| `elide-square-mono-light` | `#000000` hexagon | transparent knockout | +| `elide-square-mono-dark` | `#ffffff` hexagon | transparent knockout | +| `elide-full-gradient` | gradient glyph | gradient wordmark | +| `elide-full-mono-light` | `#000000` glyph | `#000000` wordmark | +| `elide-full-mono-dark` | `#ffffff` glyph | `#ffffff` wordmark | +| `elide-full-blend-light` | gradient glyph | `#000000` wordmark | +| `elide-full-blend-dark` | gradient glyph | `#ffffff` wordmark | + +Raster renditions: `png` / `webp` / `avif` at `@1x` / `@2x` / `@3x`. 1x equals +the artwork's natural pixel size — square `104×115`, full `273×92` — matching +the delivered PNGs exactly so regenerated output is directly comparable to the +source. That is 8 SVG + 72 raster files, all committed, so CI never needs a +native image toolchain. + +Naming: `assets//elide--[-][@x].`, e.g. +`assets/webp/elide-full-blend-dark@2x.webp`. + +### The mono knockout + +The delivered `elide-block-black.svg` paints the inner ribbon opaque `#ffffff`. +For `variant="mono"` this is replaced by **true transparency** — the hexagon +body and the ribbon are emitted as one path with `fill-rule="evenodd"`, so the +ribbon punches through and the enclosed sub-regions return to the body color. +On a white surface the result is pixel-identical to the source; on any other +surface it is correct rather than a white smear. `variant="gradient"` keeps the +opaque white ribbon exactly as delivered, because that is the official +rendering. + +The `full` glyph path is already an outline authored for `fill-rule="nonzero"` +and is used unmodified. + +## Component + +`packages/ui/src/components/elide-logo.tsx`. + +```tsx +export type ElideLogoForm = "square" | "full"; +export type ElideLogoVariant = "gradient" | "mono" | "blend"; +export type ElideLogoFormat = "svg" | "png" | "webp" | "avif"; + +export interface ElideLogoProps { + form?: ElideLogoForm; // default "full" + variant?: ElideLogoVariant; // default "gradient" + format?: ElideLogoFormat; // default "svg" — inlined, zero requests + linked?: boolean; // format="svg" as , not inlined + height?: number; // px; width derived from the artwork aspect + title?: string; // accessible name, default "Elide" + decorative?: boolean; // aria-hidden + alt="" + assetBase?: string; // raster/linked URL prefix, default "/brand" + className?: string; + /** @deprecated use form="square" */ + markOnly?: boolean; +} +``` + +**Vector vs raster.** `format="svg"` inlines the SVG element — no network +request, `currentColor`-aware, SSR-safe. The raster formats render ``. +`linked` is the escape hatch for callers who want the `.svg` as an external, +cacheable file rather than path data in their HTML. + +**Sizing.** With `height`, the element gets an explicit height and a width +derived from the artwork's aspect ratio. Without it the element falls back to +`h-6 w-auto`, overridable through `className`. Either way `` elements +always carry intrinsic `width`/`height` attributes so the aspect ratio is known +before load and nothing shifts. + +**Gradient ids** are per-instance via `React.useId()`, so multiple logos on a +page never collide — the existing `ElideMark` already does this and its test is +kept. + +## Light / dark + +**Vector mode needs no assets and no JavaScript.** `mono` fills with +`currentColor` (the wrapper defaults to `text-foreground`), so it inverts with +the theme automatically. `blend` pairs a gradient glyph with a `currentColor` +wordmark. `gradient` is theme-agnostic. + +**Raster mode uses a CSS class swap.** For `mono` and `blend`, both the light +and dark `` are rendered and toggled with `dark:hidden` / +`hidden dark:block`. `gradient` renders a single ``. + +This was chosen over the alternatives deliberately: + +- It needs no JavaScript, is SSR-safe, and cannot flash. +- It works without a `ThemeProvider` ancestor (`useTheme` throws without one). +- It honors a *manual* theme override. Theming in this repo is a `.dark` class + on ``, so a `` + `prefers-color-scheme` approach would show + the wrong logo to anyone whose site theme disagrees with their OS. + +The accepted cost: a browser may prefetch both images for the two +theme-dependent variants. This affects `mono` and `blend` only, the files are +single-digit kilobytes, and vector — which has no such cost — is the default. + +## Back-compat and `AppNav` + +`ElideLogo`'s current contract is `{ markOnly?: boolean }` rendering a hexagon + +wordmark lockup. The new component supersedes it: + +- `ElideMark` keeps its exact current output, re-expressed on the new square + paths (identical geometry, so the render is unchanged). +- `ElideWordmark` is unchanged. Note it is a *different* wordmark cut + (`179.42 × 42.36`) than the one inside the `full` lockup; it is retained + because `AppNav` depends on it. +- `markOnly` is retained as a deprecated alias for `form="square"`. +- `AppNav`'s brand lockup moves into `app-nav.tsx` as an explicit + `ElideMark` + `ElideWordmark` composition. **The nav renders exactly as it + does today.** + +`` with no props now means the official full lockup rather than +the old nav lockup. That is a breaking change to the default in a `0.1.0` +package; it ships with a changeset. Switching the nav to the official artwork +is a one-line opt-in for consumers: + +```tsx +} … /> +``` + +## Testing + +**Reconstruction fidelity.** The build script rasterizes the rebuilt SVGs and +diffs them against the delivered `creatives-v2` PNGs. This is the proof that +re-filling the extracted paths reproduces the official artwork rather than +approximating it, and it is the gate on the whole vector approach. + +**Unit (`packages/ui`, jsdom, vitest):** the `form × variant × format` matrix; +`blend` aliasing to `gradient` on `square`; unique gradient ids across +instances; accessible naming and `decorative`; the light/dark `` pair for +theme-dependent variants and the single `` for `gradient`; `assetBase` +override; intrinsic `width`/`height` on every ``; `height` deriving width +from the aspect ratio; the deprecated `markOnly` alias; `ElideMark` / +`ElideWordmark` back-compat; `AppNav`'s unchanged brand lockup. + +**Unit (`packages/brand`):** `assetPath()` resolution across the matrix, +including the theme-agnostic `gradient` case and scale suffixing. + +**Storybook:** a story per form × variant, plus format and sizing stories. +Chromatic captures every story in both themes, which is the visual regression +gate on light/dark. + +Coverage stays at the catalog's current bar (97% statements). + +## Risk + +Rasterization needs an SVG renderer. `rsvg-convert` and Inkscape are not +installed on this machine. The plan is `sharp` as a `packages/brand` +devDependency (libvips/librsvg, and it writes png/webp/avif in one pipeline). +If `sharp` cannot render these paths faithfully, the fallback is ImageMagick — +installed here, with AVIF and WebP write support — recoloring the delivered +PNGs, with the `full-blend-dark` cut composited from the gradient glyph and a +recolored wordmark. The fidelity diff decides which path is taken. + +Note that the standalone SVG files must carry literal colors: `currentColor` +renders as black in librsvg and `var(--background)` is not resolvable outside a +document. This is why `mono` and `blend` are emitted as separate light/dark +files while the inlined React vector uses `currentColor`. + +## Out of scope + +- A padded 1:1 favicon / app-icon set. +- Replacing `AppNav`'s lockup with the official `full` artwork. +- The `jpg` and `pdf` renditions in the source set. +- Retiring the legacy `ElideWordmark` cut. diff --git a/packages/brand/README.md b/packages/brand/README.md new file mode 100644 index 0000000..a1750f3 --- /dev/null +++ b/packages/brand/README.md @@ -0,0 +1,98 @@ +# @elide/brand + +Elide's brand artwork: the logo geometry, and every rendition generated from it. + +Framework-agnostic by design — this package sits below the React line in +[ARCHITECTURE.md](../../ARCHITECTURE.md), so the docs site, the marketing site, +a native shell, or a build script can all consume it. React consumers should +reach for [``](../ui/src/components/elide-logo.tsx) in `@elide/ui` +instead, which is built on top of this. + +## The artwork + +Two forms and three variants: + +- **`square`** — the hexagon badge. viewBox `0 0 102.700292 114.027098`. +- **`full`** — the glyph + wordmark lockup. viewBox `0 0 272.122583 90.385516`. + +- **`gradient`** — the brand gradient, purple `#662d91` to magenta `#e7008c`. +- **`mono`** — a single color. +- **`blend`** — a gradient glyph beside a mono wordmark. Only distinct on the + `full` form; on `square` it resolves to `gradient`, which is what blend means + there. + +`gradient` reads on any surface and ships as one file. `mono` and `blend` carry +a color that has to invert with the theme, so each ships a light and a dark cut. +That makes eight artworks: + +| | gradient | mono | blend | +| --- | --- | --- | --- | +| `square` | `elide-square-gradient` | `elide-square-mono-{light,dark}` | → gradient | +| `full` | `elide-full-gradient` | `elide-full-mono-{light,dark}` | `elide-full-blend-{light,dark}` | + +## Assets + +``` +assets/svg/elide-full-blend-dark.svg resolution-independent +assets/png/elide-full-blend-dark.png 1x — no suffix +assets/png/elide-full-blend-dark@2x.png +assets/webp/… @1x, @2x, @3x +assets/avif/… +``` + +1x is the artwork's natural pixel size — `square` is 104×115, `full` is 273×92. + +Encoder choices were measured against a lossless reference render, not assumed: + +| Format | Setting | Why | +| --- | --- | --- | +| `png` | lossless | The universal fallback. Correctness over bytes. | +| `webp` | lossless | Lossless *and* smaller than PNG on this artwork (10.8K vs 12.7K on the gradient square @2x). Nothing to trade. | +| `avif` | quality 90 | Lossless AVIF on line art is counterproductive — over double the PNG. At q90 the error is mae 0.32 / worst 11 on the gradient square and 0.00 on mono, while cutting gradient artwork to 9.6K. | + +### Serving them + +Copy `assets/` into the app's public directory and point `assetBase` at it — +`` is the default. In Storybook this is done +with `staticDirs`; see `apps/storybook/.storybook/main.ts`. + +## Regenerating + +Only needed when the artwork itself changes. `assets/` is committed, so normal +builds and CI never run this. + +```bash +bun run --filter @elide/brand build:assets # regenerate all 80 files +bun run --filter @elide/brand verify:assets # diff against the source artwork +``` + +Both need `sharp`, which is a devDependency of this package only. + +## How the geometry got here + +`src/paths.ts` holds four path strings copied verbatim out of `creatives-v2`: +the hexagon body and knockout ribbon from `elide-block-black.svg`, and the glyph +and wordmark from `elide-mark-black.svg`. Everything else — every variant, every +format, every scale — is generated from those four strings, so the +representations cannot drift. + +The delivered `elide-mark-gradient.svg` and `elide-mark-blend.svg` are *not* +usable as vector sources: they embed their glyph as a base64 PNG. Those variants +are rebuilt here by re-filling the paths above, which keeps them genuinely +vector while leaving the geometry byte-identical to the originals. + +`verify-fidelity.mjs` is the proof. It compares against the delivered **SVGs** +rather than the delivered PNGs, because the PNG exports are canvas-rounded +(102.700292 wide is exported as 104px), so diffing those would measure the +exporter's padding instead of the geometry. Against the SVGs the gradient square +and the full mono are pixel-identical (mae 0.000); the mono square differs by +0.007, which is antialiasing where our `evenodd` hole meets the original's +opaque white overlay. + +### One deliberate change + +For `mono`, the ribbon is punched through to transparency — body and ribbon are +emitted as a single `fill-rule="evenodd"` path — where the delivered artwork +paints it opaque white. Over white the two are identical; over anything else, +transparency is correct and a white smear is not. `gradient` keeps the opaque +white ribbon, because that is the official rendering of the mark. diff --git a/packages/brand/assets/avif/elide-full-blend-dark.avif b/packages/brand/assets/avif/elide-full-blend-dark.avif new file mode 100644 index 0000000..89379ae Binary files /dev/null and b/packages/brand/assets/avif/elide-full-blend-dark.avif differ diff --git a/packages/brand/assets/avif/elide-full-blend-dark@2x.avif b/packages/brand/assets/avif/elide-full-blend-dark@2x.avif new file mode 100644 index 0000000..c9ad1e9 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-blend-dark@2x.avif differ diff --git a/packages/brand/assets/avif/elide-full-blend-dark@3x.avif b/packages/brand/assets/avif/elide-full-blend-dark@3x.avif new file mode 100644 index 0000000..51fafa6 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-blend-dark@3x.avif differ diff --git a/packages/brand/assets/avif/elide-full-blend-light.avif b/packages/brand/assets/avif/elide-full-blend-light.avif new file mode 100644 index 0000000..06e350f Binary files /dev/null and b/packages/brand/assets/avif/elide-full-blend-light.avif differ diff --git a/packages/brand/assets/avif/elide-full-blend-light@2x.avif b/packages/brand/assets/avif/elide-full-blend-light@2x.avif new file mode 100644 index 0000000..87c2ded Binary files /dev/null and b/packages/brand/assets/avif/elide-full-blend-light@2x.avif differ diff --git a/packages/brand/assets/avif/elide-full-blend-light@3x.avif b/packages/brand/assets/avif/elide-full-blend-light@3x.avif new file mode 100644 index 0000000..bf321f8 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-blend-light@3x.avif differ diff --git a/packages/brand/assets/avif/elide-full-gradient.avif b/packages/brand/assets/avif/elide-full-gradient.avif new file mode 100644 index 0000000..2b9bdc4 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-gradient.avif differ diff --git a/packages/brand/assets/avif/elide-full-gradient@2x.avif b/packages/brand/assets/avif/elide-full-gradient@2x.avif new file mode 100644 index 0000000..2b4e436 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-gradient@2x.avif differ diff --git a/packages/brand/assets/avif/elide-full-gradient@3x.avif b/packages/brand/assets/avif/elide-full-gradient@3x.avif new file mode 100644 index 0000000..ab2fcb8 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-gradient@3x.avif differ diff --git a/packages/brand/assets/avif/elide-full-mono-dark.avif b/packages/brand/assets/avif/elide-full-mono-dark.avif new file mode 100644 index 0000000..b53bfcb Binary files /dev/null and b/packages/brand/assets/avif/elide-full-mono-dark.avif differ diff --git a/packages/brand/assets/avif/elide-full-mono-dark@2x.avif b/packages/brand/assets/avif/elide-full-mono-dark@2x.avif new file mode 100644 index 0000000..3d2774c Binary files /dev/null and b/packages/brand/assets/avif/elide-full-mono-dark@2x.avif differ diff --git a/packages/brand/assets/avif/elide-full-mono-dark@3x.avif b/packages/brand/assets/avif/elide-full-mono-dark@3x.avif new file mode 100644 index 0000000..cf0592a Binary files /dev/null and b/packages/brand/assets/avif/elide-full-mono-dark@3x.avif differ diff --git a/packages/brand/assets/avif/elide-full-mono-light.avif b/packages/brand/assets/avif/elide-full-mono-light.avif new file mode 100644 index 0000000..21b826d Binary files /dev/null and b/packages/brand/assets/avif/elide-full-mono-light.avif differ diff --git a/packages/brand/assets/avif/elide-full-mono-light@2x.avif b/packages/brand/assets/avif/elide-full-mono-light@2x.avif new file mode 100644 index 0000000..82d312f Binary files /dev/null and b/packages/brand/assets/avif/elide-full-mono-light@2x.avif differ diff --git a/packages/brand/assets/avif/elide-full-mono-light@3x.avif b/packages/brand/assets/avif/elide-full-mono-light@3x.avif new file mode 100644 index 0000000..2e40347 Binary files /dev/null and b/packages/brand/assets/avif/elide-full-mono-light@3x.avif differ diff --git a/packages/brand/assets/avif/elide-square-gradient.avif b/packages/brand/assets/avif/elide-square-gradient.avif new file mode 100644 index 0000000..9cfe160 Binary files /dev/null and b/packages/brand/assets/avif/elide-square-gradient.avif differ diff --git a/packages/brand/assets/avif/elide-square-gradient@2x.avif b/packages/brand/assets/avif/elide-square-gradient@2x.avif new file mode 100644 index 0000000..c33be13 Binary files /dev/null and b/packages/brand/assets/avif/elide-square-gradient@2x.avif differ diff --git a/packages/brand/assets/avif/elide-square-gradient@3x.avif b/packages/brand/assets/avif/elide-square-gradient@3x.avif new file mode 100644 index 0000000..7103a3d Binary files /dev/null and b/packages/brand/assets/avif/elide-square-gradient@3x.avif differ diff --git a/packages/brand/assets/avif/elide-square-mono-dark.avif b/packages/brand/assets/avif/elide-square-mono-dark.avif new file mode 100644 index 0000000..ba91829 Binary files /dev/null and b/packages/brand/assets/avif/elide-square-mono-dark.avif differ diff --git a/packages/brand/assets/avif/elide-square-mono-dark@2x.avif b/packages/brand/assets/avif/elide-square-mono-dark@2x.avif new file mode 100644 index 0000000..1187cbc Binary files /dev/null and b/packages/brand/assets/avif/elide-square-mono-dark@2x.avif differ diff --git a/packages/brand/assets/avif/elide-square-mono-dark@3x.avif b/packages/brand/assets/avif/elide-square-mono-dark@3x.avif new file mode 100644 index 0000000..b8871ef Binary files /dev/null and b/packages/brand/assets/avif/elide-square-mono-dark@3x.avif differ diff --git a/packages/brand/assets/avif/elide-square-mono-light.avif b/packages/brand/assets/avif/elide-square-mono-light.avif new file mode 100644 index 0000000..c658d64 Binary files /dev/null and b/packages/brand/assets/avif/elide-square-mono-light.avif differ diff --git a/packages/brand/assets/avif/elide-square-mono-light@2x.avif b/packages/brand/assets/avif/elide-square-mono-light@2x.avif new file mode 100644 index 0000000..4065280 Binary files /dev/null and b/packages/brand/assets/avif/elide-square-mono-light@2x.avif differ diff --git a/packages/brand/assets/avif/elide-square-mono-light@3x.avif b/packages/brand/assets/avif/elide-square-mono-light@3x.avif new file mode 100644 index 0000000..d934161 Binary files /dev/null and b/packages/brand/assets/avif/elide-square-mono-light@3x.avif differ diff --git a/packages/brand/assets/png/elide-full-blend-dark.png b/packages/brand/assets/png/elide-full-blend-dark.png new file mode 100644 index 0000000..e2bbf97 Binary files /dev/null and b/packages/brand/assets/png/elide-full-blend-dark.png differ diff --git a/packages/brand/assets/png/elide-full-blend-dark@2x.png b/packages/brand/assets/png/elide-full-blend-dark@2x.png new file mode 100644 index 0000000..22372c8 Binary files /dev/null and b/packages/brand/assets/png/elide-full-blend-dark@2x.png differ diff --git a/packages/brand/assets/png/elide-full-blend-dark@3x.png b/packages/brand/assets/png/elide-full-blend-dark@3x.png new file mode 100644 index 0000000..7a5e108 Binary files /dev/null and b/packages/brand/assets/png/elide-full-blend-dark@3x.png differ diff --git a/packages/brand/assets/png/elide-full-blend-light.png b/packages/brand/assets/png/elide-full-blend-light.png new file mode 100644 index 0000000..92d7a25 Binary files /dev/null and b/packages/brand/assets/png/elide-full-blend-light.png differ diff --git a/packages/brand/assets/png/elide-full-blend-light@2x.png b/packages/brand/assets/png/elide-full-blend-light@2x.png new file mode 100644 index 0000000..8c4e45b Binary files /dev/null and b/packages/brand/assets/png/elide-full-blend-light@2x.png differ diff --git a/packages/brand/assets/png/elide-full-blend-light@3x.png b/packages/brand/assets/png/elide-full-blend-light@3x.png new file mode 100644 index 0000000..adeb3d1 Binary files /dev/null and b/packages/brand/assets/png/elide-full-blend-light@3x.png differ diff --git a/packages/brand/assets/png/elide-full-gradient.png b/packages/brand/assets/png/elide-full-gradient.png new file mode 100644 index 0000000..60c258d Binary files /dev/null and b/packages/brand/assets/png/elide-full-gradient.png differ diff --git a/packages/brand/assets/png/elide-full-gradient@2x.png b/packages/brand/assets/png/elide-full-gradient@2x.png new file mode 100644 index 0000000..6153083 Binary files /dev/null and b/packages/brand/assets/png/elide-full-gradient@2x.png differ diff --git a/packages/brand/assets/png/elide-full-gradient@3x.png b/packages/brand/assets/png/elide-full-gradient@3x.png new file mode 100644 index 0000000..2e89b07 Binary files /dev/null and b/packages/brand/assets/png/elide-full-gradient@3x.png differ diff --git a/packages/brand/assets/png/elide-full-mono-dark.png b/packages/brand/assets/png/elide-full-mono-dark.png new file mode 100644 index 0000000..ec283a1 Binary files /dev/null and b/packages/brand/assets/png/elide-full-mono-dark.png differ diff --git a/packages/brand/assets/png/elide-full-mono-dark@2x.png b/packages/brand/assets/png/elide-full-mono-dark@2x.png new file mode 100644 index 0000000..a2e52f0 Binary files /dev/null and b/packages/brand/assets/png/elide-full-mono-dark@2x.png differ diff --git a/packages/brand/assets/png/elide-full-mono-dark@3x.png b/packages/brand/assets/png/elide-full-mono-dark@3x.png new file mode 100644 index 0000000..d454d60 Binary files /dev/null and b/packages/brand/assets/png/elide-full-mono-dark@3x.png differ diff --git a/packages/brand/assets/png/elide-full-mono-light.png b/packages/brand/assets/png/elide-full-mono-light.png new file mode 100644 index 0000000..feaa9b5 Binary files /dev/null and b/packages/brand/assets/png/elide-full-mono-light.png differ diff --git a/packages/brand/assets/png/elide-full-mono-light@2x.png b/packages/brand/assets/png/elide-full-mono-light@2x.png new file mode 100644 index 0000000..fb229d9 Binary files /dev/null and b/packages/brand/assets/png/elide-full-mono-light@2x.png differ diff --git a/packages/brand/assets/png/elide-full-mono-light@3x.png b/packages/brand/assets/png/elide-full-mono-light@3x.png new file mode 100644 index 0000000..e21e6cd Binary files /dev/null and b/packages/brand/assets/png/elide-full-mono-light@3x.png differ diff --git a/packages/brand/assets/png/elide-square-gradient.png b/packages/brand/assets/png/elide-square-gradient.png new file mode 100644 index 0000000..2ff56a0 Binary files /dev/null and b/packages/brand/assets/png/elide-square-gradient.png differ diff --git a/packages/brand/assets/png/elide-square-gradient@2x.png b/packages/brand/assets/png/elide-square-gradient@2x.png new file mode 100644 index 0000000..3d7ff42 Binary files /dev/null and b/packages/brand/assets/png/elide-square-gradient@2x.png differ diff --git a/packages/brand/assets/png/elide-square-gradient@3x.png b/packages/brand/assets/png/elide-square-gradient@3x.png new file mode 100644 index 0000000..bd66812 Binary files /dev/null and b/packages/brand/assets/png/elide-square-gradient@3x.png differ diff --git a/packages/brand/assets/png/elide-square-mono-dark.png b/packages/brand/assets/png/elide-square-mono-dark.png new file mode 100644 index 0000000..b401598 Binary files /dev/null and b/packages/brand/assets/png/elide-square-mono-dark.png differ diff --git a/packages/brand/assets/png/elide-square-mono-dark@2x.png b/packages/brand/assets/png/elide-square-mono-dark@2x.png new file mode 100644 index 0000000..77ed3be Binary files /dev/null and b/packages/brand/assets/png/elide-square-mono-dark@2x.png differ diff --git a/packages/brand/assets/png/elide-square-mono-dark@3x.png b/packages/brand/assets/png/elide-square-mono-dark@3x.png new file mode 100644 index 0000000..5ae498e Binary files /dev/null and b/packages/brand/assets/png/elide-square-mono-dark@3x.png differ diff --git a/packages/brand/assets/png/elide-square-mono-light.png b/packages/brand/assets/png/elide-square-mono-light.png new file mode 100644 index 0000000..5f174b4 Binary files /dev/null and b/packages/brand/assets/png/elide-square-mono-light.png differ diff --git a/packages/brand/assets/png/elide-square-mono-light@2x.png b/packages/brand/assets/png/elide-square-mono-light@2x.png new file mode 100644 index 0000000..8f03e1d Binary files /dev/null and b/packages/brand/assets/png/elide-square-mono-light@2x.png differ diff --git a/packages/brand/assets/png/elide-square-mono-light@3x.png b/packages/brand/assets/png/elide-square-mono-light@3x.png new file mode 100644 index 0000000..ff66505 Binary files /dev/null and b/packages/brand/assets/png/elide-square-mono-light@3x.png differ diff --git a/packages/brand/assets/svg/elide-full-blend-dark.svg b/packages/brand/assets/svg/elide-full-blend-dark.svg new file mode 100644 index 0000000..ae203ee --- /dev/null +++ b/packages/brand/assets/svg/elide-full-blend-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-full-blend-light.svg b/packages/brand/assets/svg/elide-full-blend-light.svg new file mode 100644 index 0000000..c2584e9 --- /dev/null +++ b/packages/brand/assets/svg/elide-full-blend-light.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-full-gradient.svg b/packages/brand/assets/svg/elide-full-gradient.svg new file mode 100644 index 0000000..671429f --- /dev/null +++ b/packages/brand/assets/svg/elide-full-gradient.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-full-mono-dark.svg b/packages/brand/assets/svg/elide-full-mono-dark.svg new file mode 100644 index 0000000..13e94c1 --- /dev/null +++ b/packages/brand/assets/svg/elide-full-mono-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-full-mono-light.svg b/packages/brand/assets/svg/elide-full-mono-light.svg new file mode 100644 index 0000000..bd704a7 --- /dev/null +++ b/packages/brand/assets/svg/elide-full-mono-light.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-square-gradient.svg b/packages/brand/assets/svg/elide-square-gradient.svg new file mode 100644 index 0000000..bc8c52b --- /dev/null +++ b/packages/brand/assets/svg/elide-square-gradient.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-square-mono-dark.svg b/packages/brand/assets/svg/elide-square-mono-dark.svg new file mode 100644 index 0000000..adf7a32 --- /dev/null +++ b/packages/brand/assets/svg/elide-square-mono-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/svg/elide-square-mono-light.svg b/packages/brand/assets/svg/elide-square-mono-light.svg new file mode 100644 index 0000000..4ceba6e --- /dev/null +++ b/packages/brand/assets/svg/elide-square-mono-light.svg @@ -0,0 +1 @@ + diff --git a/packages/brand/assets/webp/elide-full-blend-dark.webp b/packages/brand/assets/webp/elide-full-blend-dark.webp new file mode 100644 index 0000000..ec595e4 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-blend-dark.webp differ diff --git a/packages/brand/assets/webp/elide-full-blend-dark@2x.webp b/packages/brand/assets/webp/elide-full-blend-dark@2x.webp new file mode 100644 index 0000000..fb528b2 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-blend-dark@2x.webp differ diff --git a/packages/brand/assets/webp/elide-full-blend-dark@3x.webp b/packages/brand/assets/webp/elide-full-blend-dark@3x.webp new file mode 100644 index 0000000..d127cda Binary files /dev/null and b/packages/brand/assets/webp/elide-full-blend-dark@3x.webp differ diff --git a/packages/brand/assets/webp/elide-full-blend-light.webp b/packages/brand/assets/webp/elide-full-blend-light.webp new file mode 100644 index 0000000..acf5f75 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-blend-light.webp differ diff --git a/packages/brand/assets/webp/elide-full-blend-light@2x.webp b/packages/brand/assets/webp/elide-full-blend-light@2x.webp new file mode 100644 index 0000000..b8ca34d Binary files /dev/null and b/packages/brand/assets/webp/elide-full-blend-light@2x.webp differ diff --git a/packages/brand/assets/webp/elide-full-blend-light@3x.webp b/packages/brand/assets/webp/elide-full-blend-light@3x.webp new file mode 100644 index 0000000..8ad594b Binary files /dev/null and b/packages/brand/assets/webp/elide-full-blend-light@3x.webp differ diff --git a/packages/brand/assets/webp/elide-full-gradient.webp b/packages/brand/assets/webp/elide-full-gradient.webp new file mode 100644 index 0000000..db8c073 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-gradient.webp differ diff --git a/packages/brand/assets/webp/elide-full-gradient@2x.webp b/packages/brand/assets/webp/elide-full-gradient@2x.webp new file mode 100644 index 0000000..b5c2613 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-gradient@2x.webp differ diff --git a/packages/brand/assets/webp/elide-full-gradient@3x.webp b/packages/brand/assets/webp/elide-full-gradient@3x.webp new file mode 100644 index 0000000..913ef8c Binary files /dev/null and b/packages/brand/assets/webp/elide-full-gradient@3x.webp differ diff --git a/packages/brand/assets/webp/elide-full-mono-dark.webp b/packages/brand/assets/webp/elide-full-mono-dark.webp new file mode 100644 index 0000000..ec3330e Binary files /dev/null and b/packages/brand/assets/webp/elide-full-mono-dark.webp differ diff --git a/packages/brand/assets/webp/elide-full-mono-dark@2x.webp b/packages/brand/assets/webp/elide-full-mono-dark@2x.webp new file mode 100644 index 0000000..bb1a5a5 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-mono-dark@2x.webp differ diff --git a/packages/brand/assets/webp/elide-full-mono-dark@3x.webp b/packages/brand/assets/webp/elide-full-mono-dark@3x.webp new file mode 100644 index 0000000..866bdb2 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-mono-dark@3x.webp differ diff --git a/packages/brand/assets/webp/elide-full-mono-light.webp b/packages/brand/assets/webp/elide-full-mono-light.webp new file mode 100644 index 0000000..52466f2 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-mono-light.webp differ diff --git a/packages/brand/assets/webp/elide-full-mono-light@2x.webp b/packages/brand/assets/webp/elide-full-mono-light@2x.webp new file mode 100644 index 0000000..84fd307 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-mono-light@2x.webp differ diff --git a/packages/brand/assets/webp/elide-full-mono-light@3x.webp b/packages/brand/assets/webp/elide-full-mono-light@3x.webp new file mode 100644 index 0000000..45723a4 Binary files /dev/null and b/packages/brand/assets/webp/elide-full-mono-light@3x.webp differ diff --git a/packages/brand/assets/webp/elide-square-gradient.webp b/packages/brand/assets/webp/elide-square-gradient.webp new file mode 100644 index 0000000..d90dbfa Binary files /dev/null and b/packages/brand/assets/webp/elide-square-gradient.webp differ diff --git a/packages/brand/assets/webp/elide-square-gradient@2x.webp b/packages/brand/assets/webp/elide-square-gradient@2x.webp new file mode 100644 index 0000000..dec6d86 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-gradient@2x.webp differ diff --git a/packages/brand/assets/webp/elide-square-gradient@3x.webp b/packages/brand/assets/webp/elide-square-gradient@3x.webp new file mode 100644 index 0000000..b1aba62 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-gradient@3x.webp differ diff --git a/packages/brand/assets/webp/elide-square-mono-dark.webp b/packages/brand/assets/webp/elide-square-mono-dark.webp new file mode 100644 index 0000000..8eb74d8 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-mono-dark.webp differ diff --git a/packages/brand/assets/webp/elide-square-mono-dark@2x.webp b/packages/brand/assets/webp/elide-square-mono-dark@2x.webp new file mode 100644 index 0000000..8ede7e7 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-mono-dark@2x.webp differ diff --git a/packages/brand/assets/webp/elide-square-mono-dark@3x.webp b/packages/brand/assets/webp/elide-square-mono-dark@3x.webp new file mode 100644 index 0000000..8f36a89 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-mono-dark@3x.webp differ diff --git a/packages/brand/assets/webp/elide-square-mono-light.webp b/packages/brand/assets/webp/elide-square-mono-light.webp new file mode 100644 index 0000000..226900f Binary files /dev/null and b/packages/brand/assets/webp/elide-square-mono-light.webp differ diff --git a/packages/brand/assets/webp/elide-square-mono-light@2x.webp b/packages/brand/assets/webp/elide-square-mono-light@2x.webp new file mode 100644 index 0000000..98f3a53 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-mono-light@2x.webp differ diff --git a/packages/brand/assets/webp/elide-square-mono-light@3x.webp b/packages/brand/assets/webp/elide-square-mono-light@3x.webp new file mode 100644 index 0000000..7825289 Binary files /dev/null and b/packages/brand/assets/webp/elide-square-mono-light@3x.webp differ diff --git a/packages/brand/eslint.config.js b/packages/brand/eslint.config.js new file mode 100644 index 0000000..52cf470 --- /dev/null +++ b/packages/brand/eslint.config.js @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +/** + * Flat config (ESLint 10), mirroring packages/ui. Type-aware linting is left + * off intentionally — `tsc` is the type gate; this pass catches lint-level + * issues only. + * + * Two environments here: `src` is environment-neutral (it must run in a + * browser, in Node, and in a build script), while `scripts` is Node-only + * tooling that never ships. + */ +export default tseslint.config( + { ignores: ["dist/**", "assets/**"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + }, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { ...globals.node }, + }, + }, +); diff --git a/packages/brand/package.json b/packages/brand/package.json new file mode 100644 index 0000000..00f1178 --- /dev/null +++ b/packages/brand/package.json @@ -0,0 +1,36 @@ +{ + "name": "@elide/brand", + "version": "0.1.0", + "description": "Elide's brand artwork \u2014 logo geometry, generated SVG, and png/webp/avif renditions. No framework dependency; safe to consume from any repo.", + "license": "UNLICENSED", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./assets/*": "./assets/*" + }, + "files": [ + "dist", + "src", + "assets" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "build:assets": "bun scripts/build-assets.mjs", + "dev": "tsc -p tsconfig.json --watch", + "lint": "eslint src scripts", + "test": "vitest run", + "verify:assets": "bun scripts/verify-fidelity.mjs" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.8.0", + "globals": "^17.9.0", + "sharp": "^0.34.5", + "typescript": "^6.0.3", + "typescript-eslint": "^8.66.0", + "vitest": "^4.1.10" + } +} diff --git a/packages/brand/scripts/artworks.mjs b/packages/brand/scripts/artworks.mjs new file mode 100644 index 0000000..dc7549d --- /dev/null +++ b/packages/brand/scripts/artworks.mjs @@ -0,0 +1,122 @@ +/** + * Composes each artwork's SVG markup from the shared geometry. + * + * Shared by build-assets.mjs (which writes the files) and verify-fidelity.mjs + * (which diffs them against the delivered originals), so the thing that gets + * verified is exactly the thing that gets shipped. + * + * These files carry literal colors on purpose. librsvg renders `currentColor` + * as black and cannot resolve `var(--background)`, so a standalone file has to + * commit to a color — which is why mono and blend each ship a light and a dark + * cut. The inlined React vector in @elide/ui uses `currentColor` instead and + * needs no such split. + */ +import { + paths, + GRADIENTS, + BRAND_PURPLE, + BRAND_MAGENTA, + SQUARE_VIEWBOX, + FULL_VIEWBOX, +} from "../src/paths.ts"; +import { BASE_SIZE } from "../src/manifest.ts"; + +const VIEWBOX = { square: SQUARE_VIEWBOX, full: FULL_VIEWBOX }; + +function gradient(id, { x1, y1, x2, y2 }) { + return ( + `` + + `` + + `` + + `` + ); +} + +/** + * Body of each artwork: the `` it needs and the shapes that draw it. + * + * The mono cuts merge body and ribbon into one `fill-rule="evenodd"` path so + * the ribbon punches clean through to whatever is behind it. The delivered + * artwork paints that ribbon opaque white, which is identical on a white page + * and wrong on every other surface. The gradient cut keeps the opaque white + * ribbon, because that is the official rendering of the mark. + */ +const COMPOSE = { + "elide-square-gradient": () => ({ + form: "square", + defs: gradient("g", GRADIENTS.squareBody), + shapes: + `` + + ``, + }), + "elide-square-mono-light": () => monoSquare("#000000"), + "elide-square-mono-dark": () => monoSquare("#ffffff"), + "elide-full-gradient": () => ({ + form: "full", + defs: gradient("g", GRADIENTS.fullGlyph) + gradient("w", GRADIENTS.fullWordmark), + shapes: + `` + + ``, + }), + "elide-full-mono-light": () => monoFull("#000000"), + "elide-full-mono-dark": () => monoFull("#ffffff"), + "elide-full-blend-light": () => blendFull("#000000"), + "elide-full-blend-dark": () => blendFull("#ffffff"), +}; + +function monoSquare(color) { + return { + form: "square", + defs: "", + shapes: + ``, + }; +} + +function monoFull(color) { + return { + form: "full", + defs: "", + shapes: + `` + + ``, + }; +} + +function blendFull(wordmarkColor) { + return { + form: "full", + defs: gradient("g", GRADIENTS.fullGlyph), + shapes: + `` + + ``, + }; +} + +/** + * Render an artwork to SVG markup. + * + * With `size`, the root gets explicit width/height so librsvg rasterizes + * natively at that resolution instead of rendering small and upscaling. The + * files written to `assets/svg/` omit it and stay resolution-independent. + */ +export function renderSvg(name, size) { + const compose = COMPOSE[name]; + if (!compose) throw new Error(`unknown artwork: ${name}`); + const { form, defs, shapes } = compose(); + const dims = size ? ` width="${size.width}" height="${size.height}"` : ""; + return ( + `` + + (defs ? `${defs}` : "") + + shapes + + `` + ); +} + +/** Pixel size of an artwork at a given density. */ +export function sizeAt(form, scale) { + const { width, height } = BASE_SIZE[form]; + return { width: width * scale, height: height * scale }; +} diff --git a/packages/brand/scripts/build-assets.mjs b/packages/brand/scripts/build-assets.mjs new file mode 100644 index 0000000..ae08c91 --- /dev/null +++ b/packages/brand/scripts/build-assets.mjs @@ -0,0 +1,78 @@ +/** + * Generates every shipped brand asset from the geometry in `src/paths.ts`. + * + * Output lands in `assets/` and is committed, so consuming repos and CI never + * need a native image toolchain — only this script does, and only when the + * artwork changes. + * + * assets/svg/elide-full-blend-dark.svg (resolution-independent) + * assets/png/elide-full-blend-dark@2x.png (1x has no suffix) + * assets/webp/... + * assets/avif/... + * + * Run: bun scripts/build-assets.mjs + */ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { ARTWORKS, SCALES } from "../src/manifest.ts"; +import { renderSvg, sizeAt } from "./artworks.mjs"; + +const ASSETS = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets"); + +/** + * Encoder settings, chosen by measuring size against a lossless reference + * render rather than by reputation. Each format has a distinct job: + * + * - png — the universal fallback, so it stays lossless. Largest of the three. + * - webp — lossless *and* smaller than PNG on this artwork (10.8K vs 12.7K on + * the gradient square @2x, 4.5K vs 8.5K on the full mono), so there is + * no reason to trade away exactness here. + * - avif — lossless AVIF is badly counterproductive on line art (28K, over + * double the PNG), so this is the one lossy encoder. At q90 the error + * against the lossless render is mae 0.32 / worst 11 on the gradient + * square and 0.00 on mono — imperceptible — while cutting the gradient + * artwork to 9.6K. + * + * `effort` is maxed because these files are generated once and served many times. + */ +const ENCODERS = { + png: (img) => img.png({ compressionLevel: 9 }), + webp: (img) => img.webp({ lossless: true, effort: 6 }), + avif: (img) => img.avif({ quality: 90, effort: 9 }), +}; + +async function main() { + await rm(ASSETS, { recursive: true, force: true }); + for (const dir of ["svg", ...Object.keys(ENCODERS)]) { + await mkdir(path.join(ASSETS, dir), { recursive: true }); + } + + let count = 0; + + for (const { name, form } of ARTWORKS) { + // The .svg on disk carries a viewBox and no fixed dimensions, so it scales + // to whatever box a consumer puts it in. + await writeFile(path.join(ASSETS, "svg", `${name}.svg`), `${renderSvg(name)}\n`); + count++; + + for (const scale of SCALES) { + const size = sizeAt(form, scale); + // Give the SVG explicit pixel dimensions so librsvg rasterizes natively + // at the target resolution rather than rendering small and upscaling. + const svg = Buffer.from(renderSvg(name, size)); + const suffix = scale === 1 ? "" : `@${scale}x`; + + for (const [format, encode] of Object.entries(ENCODERS)) { + const file = path.join(ASSETS, format, `${name}${suffix}.${format}`); + await encode(sharp(svg)).toFile(file); + count++; + } + } + } + + console.log(`Wrote ${count} files to ${path.relative(process.cwd(), ASSETS)}/`); +} + +await main(); diff --git a/packages/brand/scripts/verify-fidelity.mjs b/packages/brand/scripts/verify-fidelity.mjs new file mode 100644 index 0000000..52d0a24 --- /dev/null +++ b/packages/brand/scripts/verify-fidelity.mjs @@ -0,0 +1,118 @@ +/** + * Fidelity gate for the rebuilt artwork. + * + * The gradient and blend lockups shipped in creatives-v2 embed their glyph as a + * base64 PNG, so this package rebuilds every variant by re-filling the paths + * extracted from the two all-vector files. This script is the proof that the + * rebuild reproduces the official artwork rather than approximating it. + * + * It compares against the delivered **SVGs**, not the delivered PNGs. Both + * sides then share a viewBox and a renderer, so the diff measures only what + * this package changed — the evenodd merge and the re-filled gradients. The + * PNG exports are canvas-rounded (102.700292 wide exported as 104px, 90.385516 + * tall as 92px), so diffing against those would measure the exporter's padding + * instead of our geometry. They are still checked, as informational output. + * + * Both sides are flattened onto white before comparing: our mono cut punches + * the ribbon through to transparency where the original paints it opaque white, + * which is identical over white and isolates the geometry. + * + * Run: bun scripts/verify-fidelity.mjs [--creatives ] + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import sharp from "sharp"; +import { renderSvg } from "./artworks.mjs"; + +const argIndex = process.argv.indexOf("--creatives"); +const CREATIVES = + argIndex !== -1 ? process.argv[argIndex + 1] : path.join(homedir(), "txfer", "creatives-v2"); + +/** + * `gate: true` comparisons share a viewBox with their reference, so they must + * match within antialiasing noise. + * + * The rest are informational. The gradient/blend SVGs draw their glyph from an + * embedded PNG and use a slightly different viewBox, and the PNG references + * carry export padding — a small delta there is resampling, not a fault in our + * geometry. A *large* delta would still mean a gradient runs the wrong way, so + * the numbers are printed rather than dropped. + */ +const COMPARISONS = [ + { name: "elide-square-mono-light", reference: "elide-block-black.svg", gate: true }, + { name: "elide-square-gradient", reference: "elide-block-gradient.svg", gate: true }, + { name: "elide-full-mono-light", reference: "elide-mark-black.svg", gate: true }, + { name: "elide-full-gradient", reference: "elide-mark-gradient.svg", gate: false }, + { name: "elide-full-blend-light", reference: "elide-mark-blend.svg", gate: false }, + { name: "elide-square-mono-light", reference: "elide-block-black.png", gate: false }, + { name: "elide-full-mono-light", reference: "elide-mark-black.png", gate: false }, +]; + +/** Height every comparison is normalized to before diffing. */ +const COMPARE_HEIGHT = 512; + +/** Mean absolute per-channel difference, 0-255, over an opaque RGB composite. */ +async function compare(name, referenceFile) { + const refPath = path.join(CREATIVES, referenceFile); + const meta = await sharp(refPath).metadata(); + const width = Math.round((meta.width / meta.height) * COMPARE_HEIGHT); + const size = { width, height: COMPARE_HEIGHT }; + + // Both sides go through an identical pipeline, and ours is emitted with only + // a viewBox — exactly like the reference — so neither gets a sharpness or + // resampling advantage that would show up as a difference in geometry. + const onWhite = (input) => + sharp(input, { density: 600 }) + .resize(size.width, size.height, { fit: "fill" }) + .flatten({ background: "#ffffff" }) + .raw() + .toBuffer(); + + const [ours, theirs] = await Promise.all([ + onWhite(Buffer.from(renderSvg(name))), + onWhite(refPath), + ]); + + let total = 0; + let worst = 0; + for (let i = 0; i < ours.length; i++) { + const diff = Math.abs(ours[i] - theirs[i]); + total += diff; + if (diff > worst) worst = diff; + } + return { mae: total / ours.length, worst, size }; +} + +/** + * The gated comparisons currently score 0.000 (gradient square, full mono — + * pixel-identical) and 0.007 (mono square, where our evenodd hole antialiases + * marginally differently from the original's opaque white overlay). The limit + * is set just above that noise floor: anything larger is a real divergence, + * not a rounding artifact. + */ +const MAE_LIMIT = 0.5; + +if (!existsSync(CREATIVES)) { + console.error(`Source artwork not found at ${CREATIVES}`); + console.error("Pass --creatives to point at the creatives-v2 directory."); + process.exit(2); +} + +let failed = false; +for (const { name, reference, gate } of COMPARISONS) { + const { mae, worst, size } = await compare(name, reference); + const ok = !gate || mae <= MAE_LIMIT; + if (!ok) failed = true; + console.log( + `${gate ? (ok ? "PASS" : "FAIL") : "info"} ${name} vs ${reference} ` + + `(${size.width}x${size.height}) mae=${mae.toFixed(3)} worst=${worst}`, + ); +} + +if (failed) { + console.error(`\nA gated comparison exceeded mae ${MAE_LIMIT}.`); + console.error("The rebuilt geometry does not match the delivered artwork — fix before shipping."); + process.exit(1); +} +console.log("\nRebuilt artwork matches the delivered originals."); diff --git a/packages/brand/src/index.ts b/packages/brand/src/index.ts new file mode 100644 index 0000000..80e0cbb --- /dev/null +++ b/packages/brand/src/index.ts @@ -0,0 +1,2 @@ +export * from "./paths.js"; +export * from "./manifest.js"; diff --git a/packages/brand/src/manifest.test.ts b/packages/brand/src/manifest.test.ts new file mode 100644 index 0000000..31e54b2 --- /dev/null +++ b/packages/brand/src/manifest.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { + assetPath, + isThemed, + resolveVariant, + BASE_SIZE, + DEFAULT_ASSET_BASE, + artworkName, + ARTWORKS, +} from "./manifest.js"; + +describe("resolveVariant", () => { + it("collapses blend to gradient on the square form", () => { + // The square artwork has no separate blend cut — gradient body plus a + // knockout ribbon is what "blend" means there. + expect(resolveVariant("square", "blend")).toBe("gradient"); + }); + + it("leaves blend alone on the full form", () => { + expect(resolveVariant("full", "blend")).toBe("blend"); + }); + + it("passes through the other variants unchanged", () => { + expect(resolveVariant("square", "mono")).toBe("mono"); + expect(resolveVariant("full", "gradient")).toBe("gradient"); + }); +}); + +describe("isThemed", () => { + it("treats gradient as theme-agnostic", () => { + expect(isThemed("full", "gradient")).toBe(false); + expect(isThemed("square", "gradient")).toBe(false); + }); + + it("treats mono and blend as theme-dependent", () => { + expect(isThemed("full", "mono")).toBe(true); + expect(isThemed("square", "mono")).toBe(true); + expect(isThemed("full", "blend")).toBe(true); + }); + + it("follows the blend-to-gradient collapse on square", () => { + expect(isThemed("square", "blend")).toBe(false); + }); +}); + +describe("artworkName", () => { + it("omits the theme segment for gradient", () => { + expect(artworkName("full", "gradient")).toBe("elide-full-gradient"); + }); + + it("includes the theme segment for themed artwork", () => { + expect(artworkName("full", "blend", "dark")).toBe("elide-full-blend-dark"); + expect(artworkName("square", "mono", "light")).toBe("elide-square-mono-light"); + }); + + it("defaults themed artwork to the light cut", () => { + expect(artworkName("square", "mono")).toBe("elide-square-mono-light"); + }); + + it("enumerates exactly the eight artworks the build script emits", () => { + expect(ARTWORKS).toHaveLength(8); + expect(ARTWORKS.map((a) => a.name)).toEqual([ + "elide-square-gradient", + "elide-square-mono-light", + "elide-square-mono-dark", + "elide-full-gradient", + "elide-full-mono-light", + "elide-full-mono-dark", + "elide-full-blend-light", + "elide-full-blend-dark", + ]); + }); +}); + +describe("assetPath", () => { + it("builds an svg path with no scale suffix", () => { + expect(assetPath({ form: "full", variant: "gradient", format: "svg" })).toBe( + "/brand/svg/elide-full-gradient.svg", + ); + }); + + it("ignores a scale on svg, which is resolution-independent", () => { + expect(assetPath({ form: "full", variant: "gradient", format: "svg", scale: 3 })).toBe( + "/brand/svg/elide-full-gradient.svg", + ); + }); + + it("omits the @1x suffix on raster", () => { + expect(assetPath({ form: "square", variant: "gradient", format: "png" })).toBe( + "/brand/png/elide-square-gradient.png", + ); + }); + + it("includes theme and scale for themed raster artwork", () => { + expect( + assetPath({ form: "full", variant: "blend", format: "webp", theme: "dark", scale: 2 }), + ).toBe("/brand/webp/elide-full-blend-dark@2x.webp"); + }); + + it("honors a custom base", () => { + expect( + assetPath({ + form: "square", + variant: "mono", + format: "avif", + theme: "light", + base: "/static/art", + }), + ).toBe("/static/art/avif/elide-square-mono-light.avif"); + }); + + it("strips a trailing slash from the base", () => { + expect( + assetPath({ form: "full", variant: "gradient", format: "png", base: "/assets/" }), + ).toBe("/assets/png/elide-full-gradient.png"); + }); + + it("supports an absolute base", () => { + expect( + assetPath({ form: "full", variant: "gradient", format: "png", base: "https://cdn.elide.dev/b" }), + ).toBe("https://cdn.elide.dev/b/png/elide-full-gradient.png"); + }); + + it("resolves blend on square to the gradient artwork", () => { + expect(assetPath({ form: "square", variant: "blend", format: "png", theme: "dark" })).toBe( + "/brand/png/elide-square-gradient.png", + ); + }); + + it("defaults to /brand", () => { + expect(DEFAULT_ASSET_BASE).toBe("/brand"); + }); +}); + +describe("BASE_SIZE", () => { + it("matches the delivered artwork's natural pixel size", () => { + expect(BASE_SIZE.square).toEqual({ width: 104, height: 115 }); + expect(BASE_SIZE.full).toEqual({ width: 273, height: 92 }); + }); +}); diff --git a/packages/brand/src/manifest.ts b/packages/brand/src/manifest.ts new file mode 100644 index 0000000..b58c863 --- /dev/null +++ b/packages/brand/src/manifest.ts @@ -0,0 +1,127 @@ +/** + * The brand asset matrix: which artworks exist, what they're called on disk, + * and how to build a URL for one. + * + * Kept free of any framework or filesystem dependency so the docs site, the + * marketing site, a native shell, or a build script can all resolve the same + * asset names. `scripts/build-assets.mjs` generates exactly the artworks that + * `ARTWORKS` enumerates, so this module and the contents of `assets/` cannot + * drift apart. + */ + +/** `square` is the hexagon badge; `full` is the glyph + wordmark lockup. */ +export type BrandForm = "square" | "full"; + +/** + * `gradient` is the brand gradient, `mono` a single color, `blend` a gradient + * glyph beside a mono wordmark. `blend` is only distinct on the `full` form. + */ +export type BrandVariant = "gradient" | "mono" | "blend"; + +export type BrandFormat = "svg" | "png" | "webp" | "avif"; + +export type BrandTheme = "light" | "dark"; + +/** Density ladder generated for every raster format. */ +export const SCALES = [1, 2, 3] as const; +export type BrandScale = (typeof SCALES)[number]; + +/** + * 1x pixel size per form, matching the natural size of the delivered artwork + * (`elide-block-*.png` is 104x115, `elide-mark-*.png` is 273x92) so regenerated + * output can be diffed against the originals. + */ +export const BASE_SIZE: Record = { + square: { width: 104, height: 115 }, + full: { width: 273, height: 92 }, +}; + +/** Where `assets/` is expected to be served from, unless a caller says otherwise. */ +export const DEFAULT_ASSET_BASE = "/brand"; + +/** + * Collapse a variant to the one that actually has artwork for the given form. + * + * The square artwork has no separate blend cut: a gradient body with a knockout + * ribbon already *is* the blend of gradient and mono there. Treated as an alias + * rather than an error so `variant` can be set once and used with either form. + */ +export function resolveVariant(form: BrandForm, variant: BrandVariant): BrandVariant { + return form === "square" && variant === "blend" ? "gradient" : variant; +} + +/** + * Whether this artwork has separate light and dark cuts. + * + * The gradient reads on any surface and ships as one file; mono and blend carry + * a color that has to invert with the theme. + */ +export function isThemed(form: BrandForm, variant: BrandVariant): boolean { + return resolveVariant(form, variant) !== "gradient"; +} + +/** + * File stem for an artwork, e.g. `elide-full-blend-dark`. Theme-agnostic + * artwork drops the trailing segment. + */ +export function artworkName( + form: BrandForm, + variant: BrandVariant, + theme: BrandTheme = "light", +): string { + const resolved = resolveVariant(form, variant); + const suffix = isThemed(form, resolved) ? `-${theme}` : ""; + return `elide-${form}-${resolved}${suffix}`; +} + +export interface BrandArtwork { + name: string; + form: BrandForm; + variant: BrandVariant; + /** Absent when the artwork is theme-agnostic. */ + theme?: BrandTheme; +} + +/** Every artwork the build script emits, in generation order. */ +export const ARTWORKS: readonly BrandArtwork[] = [ + { name: "elide-square-gradient", form: "square", variant: "gradient" }, + { name: "elide-square-mono-light", form: "square", variant: "mono", theme: "light" }, + { name: "elide-square-mono-dark", form: "square", variant: "mono", theme: "dark" }, + { name: "elide-full-gradient", form: "full", variant: "gradient" }, + { name: "elide-full-mono-light", form: "full", variant: "mono", theme: "light" }, + { name: "elide-full-mono-dark", form: "full", variant: "mono", theme: "dark" }, + { name: "elide-full-blend-light", form: "full", variant: "blend", theme: "light" }, + { name: "elide-full-blend-dark", form: "full", variant: "blend", theme: "dark" }, +]; + +export interface AssetPathOptions { + form: BrandForm; + variant: BrandVariant; + format: BrandFormat; + /** Ignored for theme-agnostic artwork. Defaults to the light cut. */ + theme?: BrandTheme; + /** Ignored for `svg`, which is resolution-independent. Defaults to 1x. */ + scale?: BrandScale; + /** URL prefix that `assets/` is served under. */ + base?: string; +} + +/** + * Build the URL for one asset, e.g. + * `/brand/webp/elide-full-blend-dark@2x.webp`. + * + * SVG never takes a density suffix, and neither does 1x — so the common case + * stays a clean, cacheable name. + */ +export function assetPath({ + form, + variant, + format, + theme = "light", + scale = 1, + base = DEFAULT_ASSET_BASE, +}: AssetPathOptions): string { + const name = artworkName(form, variant, theme); + const density = format !== "svg" && scale !== 1 ? `@${scale}x` : ""; + return `${base.replace(/\/+$/, "")}/${format}/${name}${density}.${format}`; +} diff --git a/packages/brand/src/paths.ts b/packages/brand/src/paths.ts new file mode 100644 index 0000000..6548128 --- /dev/null +++ b/packages/brand/src/paths.ts @@ -0,0 +1,63 @@ +/** + * Elide brand geometry — the single source of truth for every rendering of the + * logo, vector or raster. + * + * The four path strings below are copied verbatim out of the official artwork + * in `creatives-v2`; they are never retraced, rounded, or hand-edited: + * + * - `squareBody` + `squareRibbon` <- elide-block-black.svg (paths 1 and 2) + * - `fullGlyph` + `fullWordmark` <- elide-mark-black.svg (paths 1 and 2) + * + * The delivered `elide-mark-gradient.svg` and `elide-mark-blend.svg` embed the + * glyph as a base64 PNG rather than a path, so the gradient and blend artworks + * are rebuilt here by re-filling these same paths. That keeps every variant + * genuinely vector while staying byte-identical in geometry to the originals. + * + * Naming note: the source files call the two forms `block` and `mark`; the API + * calls them `square` and `full`. + */ + +/** viewBox of the square (hexagon badge) artwork — aspect ~0.9007:1. */ +export const SQUARE_VIEWBOX = "0 0 102.700292 114.027098"; +/** viewBox of the full (glyph + wordmark) lockup — aspect ~3.0107:1. */ +export const FULL_VIEWBOX = "0 0 272.122583 90.385516"; + +/** Width / height of each form, for deriving one dimension from the other. */ +export const SQUARE_ASPECT = 102.700292 / 114.027098; +export const FULL_ASPECT = 272.122583 / 90.385516; + +/** Brand gradient endpoints. Mirrors --eld-brand-purple / --eld-brand-magenta. */ +export const BRAND_PURPLE = "#662d91"; +export const BRAND_MAGENTA = "#e7008c"; + +/** + * Gradient geometry, in each artwork's own user space. + * + * The square body and the full glyph both run purple at the bottom to magenta + * at the top. The wordmark runs purple at its left edge to magenta at its + * right, using the exact coordinates from elide-mark-gradient.svg. + */ +export const GRADIENTS = { + squareBody: { x1: 51.350146, y1: 114.027098, x2: 51.350146, y2: 0 }, + fullGlyph: { x1: 40.360287, y1: 90.385516, x2: 40.360287, y2: 0 }, + fullWordmark: { x1: 92.919817, y1: 43.338834, x2: 272.344807, y2: 43.338834 }, +} as const; + +/** + * The paths themselves. + * + * `squareRibbon` and `fullGlyph` are the same interlocking-layers shape; the + * square form sits inside the hexagon body, the full form stands alone. + */ +export const paths = { + squareBody: + "M102.700292,78.149657v-42.272216c0-5.266571-2.809682-10.133083-7.370666-12.766369L58.720812,1.974964c-4.560984-2.633285-10.180348-2.633285-14.741333,0L7.370666,23.111072c-4.560984,2.633285-7.370666,7.499798-7.370666,12.766369v42.272216c0,5.266571,2.809682,10.133083,7.370666,12.766369l36.608813,21.136108c4.560984,2.633285,10.180348,2.633285,14.741333,0l36.608813-21.136108c4.560984-2.633285,7.370666-7.499798,7.370666-12.766369Z", + squareRibbon: + "M51.350146,102.206307c-1.573011,0-3.146023-.405097-4.549025-1.21508l-31.262343-18.049408c-2.805898-1.619965-4.548919-4.639011-4.548919-7.878942v-36.098815c0-3.239931,1.743021-6.258976,4.548813-7.878942l31.262555-18.049196c2.805898-1.620177,6.291834-1.620177,9.097944,0l31.262343,18.049196c2.805898,1.619965,4.548919,4.639011,4.548919,7.878942,0,3.240143-1.743127,6.2594-4.549131,7.879154l-1.984256,1.14555,1.984468,1.145762c2.805898,1.619965,4.548919,4.639011,4.548919,7.878942s-1.743021,6.258976-4.548813,7.878942l-1.984574,1.145762,1.984468,1.145762c2.805898,1.619965,4.548919,4.639011,4.548919,7.878942s-1.743021,6.258976-4.548813,7.878942l-31.262555,18.049408c-1.402896.809983-2.975907,1.21508-4.548919,1.21508ZM51.350146,18.330171c-.44707,0-.894139.115106-1.292877.345318l-31.262449,18.049196c-.797476.460425-1.292877,1.318527-1.292877,2.239376v36.098815c0,.920849.495402,1.778952,1.292877,2.239376l31.262449,18.049408c.797476.460425,1.788279.460425,2.585755,0l31.262449-18.049408c.797476-.460425,1.292877-1.318527,1.292877-2.239376s-.495402-1.778952-1.292877-2.239376l-5.24051-3.025617-22.765898,13.143935c-2.80611,1.619965-6.292046,1.619965-9.097944,0l-15.636948-9.027884c-2.848824-1.644767-4.549343-4.590679-4.548919-7.880214.00053-3.289535,1.70179-6.235023,4.551039-7.878942l1.982984-1.144278-1.92257-1.109937c-2.844055-1.642012-4.544573-4.582836-4.548919-7.866859-.004452-3.284023,1.688224-6.229511,4.527933-7.878942l15.583635-9.052473c2.809926-1.632048,6.304129-1.636924,9.118718-.011871l22.756889,13.138636,5.24051-3.025617c.797582-.460425,1.292877-1.318527,1.292877-2.239376s-.495402-1.778952-1.292877-2.239376l-31.262449-18.049196c-.398738-.230212-.845808-.345318-1.292877-.345318ZM39.66242,60.770848l-5.24157,3.024345c-1.167278.673466-1.293407,1.790399-1.293513,2.239376,0,.448978.125811,1.56591,1.292983,2.2398l15.636948,9.027884c.797476.460425,1.788279.460425,2.585755,0l19.509857-11.26408-9.119248-5.264994-7.134568,4.119232c-2.80611,1.620177-6.292046,1.620177-9.097944,0l-7.138701-4.121563ZM69.545715,57.013469l9.119248,5.264994,5.24051-3.025617c.797476-.460425,1.292877-1.318527,1.292877-2.239376s-.495402-1.778952-1.292877-2.239376l-5.24051-3.025617-9.119248,5.264994ZM46.176516,57.012198l3.880752,2.240648c.797476.460425,1.788279.460425,2.585755,0l3.878526-2.239376-3.87948-2.2398c-.797264-.460213-1.787855-.460001-2.585119-.000424l-3.880434,2.238952ZM51.349086,47.919235c1.573223,0,3.145917.404885,4.548919,1.214868l7.135627,4.119656,9.119248-5.264994-19.500954-11.258781c-.799808-.461273-1.792837-.460425-2.591796.003392l-15.583423,9.052262c-1.16325.675798-1.287472,1.791247-1.286836,2.239376s.127825,1.563366,1.292877,2.235985l5.180625,2.991064,7.139019-4.119232c1.402154-.809135,2.974635-1.213596,4.546693-1.213596Z", + fullGlyph: + "M40.360287,90.385516c-1.573011,0-3.146023-.405097-4.549025-1.21508l-31.262343-18.049408c-2.805898-1.619965-4.548919-4.639011-4.548919-7.878942V27.143271c0-3.239931,1.743021-6.258976,4.548813-7.878942L35.811368,1.215133c2.805898-1.620177,6.291834-1.620177,9.097944,0l31.262343,18.049196c2.805898,1.619965,4.548919,4.639011,4.548919,7.878942,0,3.240143-1.743127,6.2594-4.549131,7.879154l-1.984256,1.14555,1.984468,1.145762c2.805898,1.619965,4.548919,4.639011,4.548919,7.878942s-1.743021,6.258976-4.548813,7.878942l-1.984574,1.145762,1.984468,1.145762c2.805898,1.619965,4.548919,4.639011,4.548919,7.878942s-1.743021,6.258976-4.548813,7.878942l-31.262555,18.049408c-1.402896.809983-2.975907,1.21508-4.548919,1.21508ZM40.360287,6.50938c-.44707,0-.894139.115106-1.292877.345318L7.80496,24.903894c-.797476.460425-1.292877,1.318527-1.292877,2.239376v36.098815c0,.920849.495402,1.778952,1.292877,2.239376l31.262449,18.049408c.797476.460425,1.788279.460425,2.585755,0l31.262449-18.049408c.797476-.460425,1.292877-1.318527,1.292877-2.239376s-.495402-1.778952-1.292877-2.239376l-5.24051-3.025617-22.765898,13.143935c-2.80611,1.619965-6.292046,1.619965-9.097944,0l-15.636948-9.027884c-2.848824-1.644767-4.549343-4.590679-4.548919-7.880214.00053-3.289535,1.70179-6.235023,4.551039-7.878942l1.982984-1.144278-1.92257-1.109937c-2.844055-1.642012-4.544573-4.582836-4.548919-7.866859-.004452-3.284023,1.688224-6.229511,4.527933-7.878942l15.583635-9.052473c2.809926-1.632048,6.304129-1.636924,9.118718-.011871l22.756889,13.138636,5.24051-3.025617c.797582-.460425,1.292877-1.318527,1.292877-2.239376s-.495402-1.778952-1.292877-2.239376L41.653164,6.854699c-.398738-.230212-.845808-.345318-1.292877-.345318ZM28.672561,48.950057l-5.24157,3.024345c-1.167278.673466-1.293407,1.790399-1.293513,2.239376,0,.448978.125811,1.56591,1.292983,2.2398l15.636948,9.027884c.797476.460425,1.788279.460425,2.585755,0l19.509857-11.26408-9.119248-5.264994-7.134568,4.119232c-2.80611,1.620177-6.292046,1.620177-9.097944,0l-7.138701-4.121563ZM58.555856,45.192678l9.119248,5.264994,5.24051-3.025617c.797476-.460425,1.292877-1.318527,1.292877-2.239376s-.495402-1.778952-1.292877-2.239376l-5.24051-3.025617-9.119248,5.264994ZM35.186658,45.191407l3.880752,2.240648c.797476.460425,1.788279.460425,2.585755,0l3.878526-2.239376-3.87948-2.2398c-.797264-.460213-1.787855-.460001-2.585119-.000424l-3.880434,2.238952ZM40.359227,36.098445c1.573223,0,3.145917.404885,4.548919,1.214868l7.135627,4.119656,9.119248-5.264994-19.500954-11.258781c-.799808-.461273-1.792837-.460425-2.591796.003392l-15.583423,9.052262c-1.16325.675798-1.287472,1.791247-1.286836,2.239376s.127825,1.563366,1.292877,2.235985l5.180625,2.991064,7.139019-4.119232c1.402154-.809135,2.974635-1.213596,4.546693-1.213596Z", + fullWordmark: + "M265.461276,44.868391c0,1.045832-.715569,1.59627-1.59627,1.59627h-17.668651c-.912218,0-1.651717-.739499-1.651717-1.651717v-1.540823c0-.935744.715569-1.816445,1.59627-1.816445h17.724098c.880701,0,1.59627.770613,1.59627,1.816445v1.59627ZM272.121573,40.299758c0-3.027408-2.47697-5.339247-5.339247-5.339247h-23.393607c-2.862277,0-5.339247,1.981576-5.339247,5.339247v18.880017c.055044,3.192539,2.366883,5.284203,5.339247,5.284203h25.76049c1.045832,0,2.972364-1.045832,2.972364-2.91732v-.770613c0-1.321051-1.486182-2.807233-2.972364-2.807233h-23.008301c-.877948,0-1.59627-.718321-1.59627-1.59627v-2.641698c0-.912218.739499-1.651717,1.651717-1.651717h20.585971c3.192539,0,5.394291-2.256795,5.339247-5.174115v-6.605254ZM120.25578,44.868391c0,1.045832-.715569,1.59627-1.59627,1.59627h-17.668651c-.912218,0-1.651717-.739499-1.651717-1.651717v-1.540823c0-.935744.715569-1.816445,1.59627-1.816445h17.724098c.880701,0,1.59627.770613,1.59627,1.816445v1.59627ZM126.916078,40.299758c0-3.027408-2.47697-5.339247-5.339247-5.339247h-23.393607c-2.862277,0-5.339247,1.981576-5.339247,5.339247v18.880017c.055044,3.192539,2.366883,5.284203,5.339247,5.284203h25.76049c1.045832,0,2.972364-1.045832,2.972364-2.91732v-.770613c0-1.321051-1.486182-2.807233-2.972364-2.807233h-23.008301c-.877948,0-1.59627-.718321-1.59627-1.59627v-2.641698c0-.912218.739499-1.651717,1.651717-1.651717h20.585971c3.192539,0,5.394291-2.256795,5.339247-5.174115v-6.605254ZM225.333956,57.968811h-17.613607c-.968771,0-1.761401-.79263-1.761401-1.761401v-12.935289c0-.935744.715569-1.816445,1.59627-1.816445h17.778738c.912218,0,1.651717.739499,1.651717,1.651717v13.209701c0,.912218-.739499,1.651717-1.651717,1.651717ZM230.673606,22.13531h-.880701c-2.03662,0-2.807233,1.486182-2.807233,2.807233v8.366252c0,.912218-.739499,1.651717-1.651717,1.651717h-20.585971c-3.082452,0-5.284203,2.311839-5.284203,5.339247v18.880017c.055044,3.247583,2.311839,5.284203,5.284203,5.284203h23.448651c2.807233,0,5.284203-2.532014,5.284203-5.284203V24.942543c0-1.321051-.880701-2.807233-2.807233-2.807233ZM150.530263,57.968811c-.912218,0-1.651717-.739499-1.651717-1.651717v-31.374552c0-1.321051-.605482-2.807233-2.807233-2.807233h-13.430683c-1.59627,0-2.752189,1.486182-2.752189,2.807233v.880701c0,1.321051,1.155919,2.807233,2.752189,2.807233h8.091033c.912218,0,1.651717.739499,1.651717,1.651717v26.034902c0,.912218-.739499,1.651717-1.651717,1.651717h-8.091033c-1.59627,0-2.752189,1.486182-2.752189,2.807233v.880701c0,1.486182.990788,2.807233,2.752189,2.807233h25.980665c1.59627,0,2.752189-1.155919,2.752189-2.807233v-.880701c0-1.321051-.990788-2.807233-2.752189-2.807233h-8.091033ZM180.858984,29.235957c1.651313,0,3.082452-1.376095,3.082452-3.137496v-.880701c0-1.761401-1.431138-3.082452-3.082452-3.082452h-.880701c-1.651313,0-3.082452,1.321051-3.082452,3.082452v.880701c0,1.761401,1.431138,3.137496,3.082452,3.137496h.880701ZM183.666217,37.767744c0-1.155919-.605482-2.807233-2.807233-2.807233h-13.430683c-1.59627,0-2.807233.935744-2.807233,2.807233v.880701c0,1.761401,1.210963,2.807233,2.807233,2.807233h8.091033c.912218,0,1.651717.739499,1.651717,1.651717v13.209701c0,.912218-.739499,1.651717-1.651717,1.651717h-8.091033c-1.155919,0-2.807233,1.210963-2.807233,2.807233v.880701c0,1.651313,1.651313,2.807233,2.807233,2.807233h25.980665c1.59627,0,2.807233-1.321051,2.807233-2.807233v-.880701c0-1.321051-1.210963-2.807233-2.807233-2.807233h-8.091033c-.912218,0-1.651717-.739499-1.651717-1.651717v-18.549351Z", +} as const; + +export type BrandPathName = keyof typeof paths; diff --git a/packages/brand/tsconfig.json b/packages/brand/tsconfig.json new file mode 100644 index 0000000..e72b46e --- /dev/null +++ b/packages/brand/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/brand/vitest.config.ts b/packages/brand/vitest.config.ts new file mode 100644 index 0000000..f0077ea --- /dev/null +++ b/packages/brand/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// Pure data/resolution tests — no DOM, no framework. Matches the package's +// framework-agnostic position below the React line. +export default defineConfig({ + test: { + name: "brand", + environment: "node", + globals: true, + include: ["src/**/*.test.ts"], + reporters: ["default", ["junit", { outputFile: "./test-report.junit.xml" }]], + }, +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index d40958a..519b653 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@base-ui/react": "^1.7.0", + "@elide/brand": "workspace:*", "@elide/tokens": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/ui/src/components/app-nav.test.tsx b/packages/ui/src/components/app-nav.test.tsx index 00f47b8..0a512db 100644 --- a/packages/ui/src/components/app-nav.test.tsx +++ b/packages/ui/src/components/app-nav.test.tsx @@ -38,4 +38,30 @@ describe("AppNav", () => { await user.click(screen.getByRole("button", { name: "Search" })); expect(onSearchClick).toHaveBeenCalledTimes(1); }); + + it("renders the badge-plus-wordmark brand lockup, not the official full lockup", () => { + // The nav's lockup is deliberately different artwork from + // . Guards against it being silently swapped. + const { container } = render(); + const svgs = Array.from(container.querySelectorAll("svg[viewBox]")); + const viewBoxes = svgs.map((s) => s.getAttribute("viewBox")); + expect(viewBoxes).toContain("0 0 102.700292 114.027098"); // the filled badge + expect(viewBoxes).toContain("0 0 179.42 42.36"); // the wide wordmark cut + expect(viewBoxes).not.toContain("0 0 272.122583 90.385516"); // the full lockup + }); + + it("announces the brand once — the wordmark beside the mark is decorative", () => { + render(); + expect(screen.getAllByRole("img", { name: "Elide" })).toHaveLength(1); + }); + + it("uses a caller-supplied logo instead of the default lockup", () => { + const { container } = render( + Custom} />, + ); + expect(screen.getByTestId("custom-logo")).toBeInTheDocument(); + expect( + container.querySelector('svg[viewBox="0 0 102.700292 114.027098"]'), + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/components/app-nav.tsx b/packages/ui/src/components/app-nav.tsx index 2ca67fe..389898f 100644 --- a/packages/ui/src/components/app-nav.tsx +++ b/packages/ui/src/components/app-nav.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { ChevronDown, Globe, History, Search, Sparkles, Sun } from "lucide-react"; import { cn } from "../lib/utils"; import { Button } from "./button"; -import { ElideLogo } from "./elide-logo"; +import { ElideMark, ElideWordmark } from "./elide-logo"; import { useMessages } from "../i18n/context"; /** @@ -32,7 +32,24 @@ export interface AppNavProps extends React.ComponentProps<"nav"> { onLocaleClick?: () => void; } -const defaultLogo = ; +/** + * The nav's brand lockup: the hexagon badge beside the standalone wordmark. + * + * This is composed here rather than reaching for `` + * because the two are different artwork. The official full lockup pairs an + * *outlined* glyph with a narrower wordmark; the nav has always used the filled + * badge and the wider wordmark cut, and that is what ships on every docs page. + * To adopt the official lockup instead, pass `logo={}`. + */ +const defaultLogo = ( + + {/* The mark carries the accessible name, so the wordmark beside it is + decorative — otherwise the brand would be announced twice. */} + + + +); export function AppNav({ links, diff --git a/packages/ui/src/components/elide-logo.test.tsx b/packages/ui/src/components/elide-logo.test.tsx index b140fd0..0551958 100644 --- a/packages/ui/src/components/elide-logo.test.tsx +++ b/packages/ui/src/components/elide-logo.test.tsx @@ -1,43 +1,273 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { ElideLogo, ElideMark } from "./elide-logo"; +import { ElideLogo, ElideMark, ElideWordmark } from "./elide-logo"; -describe("ElideLogo", () => { - it("renders the mark and the wordmark by default, with the wordmark hidden", () => { +/** The single rendered in vector mode. */ +function svg(container: HTMLElement): SVGSVGElement { + const el = container.querySelector("svg"); + if (!el) throw new Error("expected an inline "); + return el; +} + +const SQUARE_VIEWBOX = "0 0 102.700292 114.027098"; +const FULL_VIEWBOX = "0 0 272.122583 90.385516"; + +describe("ElideLogo — form", () => { + it("renders the full lockup by default", () => { const { container } = render(); - const svgs = container.querySelectorAll("svg"); - expect(svgs).toHaveLength(2); - // The wordmark is decorative alongside the mark. - const hidden = container.querySelector("svg[aria-hidden]"); - expect(hidden).toBeInTheDocument(); + expect(svg(container)).toHaveAttribute("viewBox", FULL_VIEWBOX); + expect(container.querySelectorAll("path")).toHaveLength(2); + }); + + it("renders the square badge on form=square", () => { + const { container } = render(); + expect(svg(container)).toHaveAttribute("viewBox", SQUARE_VIEWBOX); + }); + + it("treats the deprecated markOnly prop as form=square", () => { + const { container } = render(); + expect(svg(container)).toHaveAttribute("viewBox", SQUARE_VIEWBOX); + }); + + it("lets an explicit form win over markOnly", () => { + const { container } = render(); + expect(svg(container)).toHaveAttribute("viewBox", FULL_VIEWBOX); + }); +}); + +describe("ElideLogo — variant", () => { + it("fills the gradient variant from a gradient, not currentColor", () => { + const { container } = render(); + expect(container.querySelectorAll("linearGradient").length).toBeGreaterThan(0); + const fills = Array.from(container.querySelectorAll("path")).map((p) => p.getAttribute("fill")); + expect(fills.every((f) => f?.startsWith("url(#"))).toBe(true); + }); + + it("fills the mono variant with currentColor and defines no gradient", () => { + const { container } = render(); + expect(container.querySelector("linearGradient")).not.toBeInTheDocument(); + const fills = Array.from(container.querySelectorAll("path")).map((p) => p.getAttribute("fill")); + expect(fills).toEqual(["currentColor", "currentColor"]); + }); + + it("punches the square mono ribbon through with evenodd rather than painting it white", () => { + const { container } = render(); + const paths = container.querySelectorAll("path"); + expect(paths).toHaveLength(1); + expect(paths[0]).toHaveAttribute("fill-rule", "evenodd"); + expect(paths[0].getAttribute("fill")).toBe("currentColor"); + }); + + it("keeps the opaque white ribbon on the square gradient, as the artwork specifies", () => { + const { container } = render(); + const fills = Array.from(container.querySelectorAll("path")).map((p) => p.getAttribute("fill")); + expect(fills[1]).toBe("#ffffff"); + }); + + it("gives blend a gradient glyph and a currentColor wordmark", () => { + const { container } = render(); + const fills = Array.from(container.querySelectorAll("path")).map((p) => p.getAttribute("fill")); + expect(fills[0]).toMatch(/^url\(#/); + expect(fills[1]).toBe("currentColor"); }); - it("exposes a single accessible image named Elide", () => { + it("renders square blend identically to square gradient — the square has no separate blend cut", () => { + const blend = render(); + const gradient = render(); + const strip = (c: HTMLElement) => c.innerHTML.replace(/(id|fill)="[^"]*"/g, ""); + expect(strip(blend.container)).toBe(strip(gradient.container)); + }); + + it("gives each instance a distinct gradient id so multiple logos never collide", () => { + const { container } = render( + <> + + + , + ); + const ids = Array.from(container.querySelectorAll("linearGradient")).map((g) => g.id); + expect(ids.length).toBeGreaterThan(1); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("points every gradient fill at a gradient that exists in the same document", () => { + const { container } = render(); + for (const path of container.querySelectorAll("path")) { + const id = path.getAttribute("fill")?.match(/^url\(#(.+)\)$/)?.[1]; + expect(id).toBeTruthy(); + expect(container.querySelector(`linearGradient[id="${CSS.escape(id!)}"]`)).toBeInTheDocument(); + } + }); +}); + +describe("ElideLogo — accessibility", () => { + it("exposes a single image named Elide", () => { render(); - // The wordmark's aria-hidden removes it from the tree, so only the mark is named. expect(screen.getByRole("img", { name: "Elide" })).toBeInTheDocument(); }); - it("renders only the mark when markOnly is set", () => { - const { container } = render(); - const svgs = container.querySelectorAll("svg"); - expect(svgs).toHaveLength(1); - expect(container.querySelector("svg[aria-hidden]")).not.toBeInTheDocument(); + it("accepts a custom accessible name", () => { + render(); + expect(screen.getByRole("img", { name: "Elide home" })).toBeInTheDocument(); + }); + + it("hides the logo from assistive tech when decorative", () => { + const { container } = render(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(svg(container)).toHaveAttribute("aria-hidden"); + }); + + it("gives raster output an empty alt when decorative", () => { + const { container } = render(); + for (const img of container.querySelectorAll("img")) { + expect(img).toHaveAttribute("alt", ""); + } + }); +}); + +describe("ElideLogo — raster mode", () => { + it.each(["png", "webp", "avif"] as const)("renders an for format=%s", (format) => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeInTheDocument(); + const img = container.querySelector("img"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", `/brand/${format}/elide-full-gradient.${format}`); + }); + + it("offers 2x and 3x in srcSet", () => { + const { container } = render(); + const srcSet = container.querySelector("img")?.getAttribute("srcset") ?? ""; + expect(srcSet).toContain("elide-full-gradient@2x.webp 2x"); + expect(srcSet).toContain("elide-full-gradient@3x.webp 3x"); + }); + + it("sets intrinsic width and height so the box is known before the image loads", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img).toHaveAttribute("width", "104"); + expect(img).toHaveAttribute("height", "115"); + }); + + it("honors a custom assetBase", () => { + const { container } = render(); + expect(container.querySelector("img")).toHaveAttribute( + "src", + "https://cdn.elide.dev/brand/png/elide-full-gradient.png", + ); + }); + + it("links the .svg file instead of inlining it when linked is set", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeInTheDocument(); + expect(container.querySelector("img")).toHaveAttribute( + "src", + "/brand/svg/elide-full-gradient.svg", + ); + }); + + it("omits srcSet for linked svg, which needs no density ladder", () => { + const { container } = render(); + expect(container.querySelector("img")).not.toHaveAttribute("srcset"); + }); + + it("ignores linked in vector mode's raster formats — format wins", () => { + const { container } = render(); + expect(container.querySelector("img")).toHaveAttribute( + "src", + "/brand/png/elide-full-gradient.png", + ); + }); +}); + +describe("ElideLogo — raster light/dark", () => { + it("renders one image for the theme-agnostic gradient", () => { + const { container } = render(); + expect(container.querySelectorAll("img")).toHaveLength(1); + }); + + it("renders a light/dark pair for mono, toggled by the .dark class", () => { + const { container } = render(); + const imgs = Array.from(container.querySelectorAll("img")); + expect(imgs).toHaveLength(2); + expect(imgs[0]).toHaveAttribute("src", "/brand/png/elide-full-mono-light.png"); + expect(imgs[0].className).toContain("dark:hidden"); + expect(imgs[1]).toHaveAttribute("src", "/brand/png/elide-full-mono-dark.png"); + expect(imgs[1].className).toContain("hidden"); + expect(imgs[1].className).toContain("dark:block"); + }); + + it("renders a light/dark pair for blend", () => { + const { container } = render(); + const srcs = Array.from(container.querySelectorAll("img")).map((i) => i.getAttribute("src")); + expect(srcs).toEqual([ + "/brand/avif/elide-full-blend-light.avif", + "/brand/avif/elide-full-blend-dark.avif", + ]); + }); + + it("renders one image for square blend, which resolves to the gradient artwork", () => { + const { container } = render(); + const imgs = container.querySelectorAll("img"); + expect(imgs).toHaveLength(1); + expect(imgs[0]).toHaveAttribute("src", "/brand/png/elide-square-gradient.png"); + }); + + it("names only one image of the pair so the logo is announced once", () => { + render(); + expect(screen.getAllByRole("img", { name: "Elide" })).toHaveLength(1); + }); +}); + +describe("ElideLogo — sizing", () => { + it("derives width from the artwork aspect ratio when height is given", () => { + const { container } = render(); + const el = svg(container); + // 102.700292 / 114.027098 * 100 + expect(el).toHaveStyle({ height: "100px", width: "90.07px" }); + }); + + it("derives the intrinsic raster box from height too", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img).toHaveAttribute("height", "46"); + expect(img).toHaveAttribute("width", "138"); + }); + + it("falls back to a utility class when height is omitted", () => { + const { container } = render(); + expect(svg(container).className.baseVal).toContain("h-6"); + }); + + it("lets className override the default size", () => { + const { container } = render(); + expect(svg(container).className.baseVal).toContain("h-10"); + expect(svg(container).className.baseVal).not.toContain("h-6"); + }); +}); + +describe("back-compat", () => { + it("ElideMark still renders the gradient square at its original size", () => { + const { container } = render(); + const el = svg(container); + expect(el).toHaveAttribute("viewBox", SQUARE_VIEWBOX); + expect(el.className.baseVal).toContain("h-[23px]"); expect(screen.getByRole("img", { name: "Elide" })).toBeInTheDocument(); }); - it("gives each mark on a page a distinct gradient id", () => { + it("ElideMark still gives each instance a distinct gradient id", () => { const { container } = render( <> , ); - const gradients = container.querySelectorAll("linearGradient"); - expect(gradients).toHaveLength(2); - const [a, b] = Array.from(gradients).map((g) => g.getAttribute("id")); - expect(a).toBeTruthy(); - expect(b).toBeTruthy(); - expect(a).not.toBe(b); + const ids = Array.from(container.querySelectorAll("linearGradient")).map((g) => g.id); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + }); + + it("ElideWordmark still draws in currentColor", () => { + const { container } = render(); + expect(container.querySelector("path")).toHaveAttribute("fill", "currentColor"); }); }); diff --git a/packages/ui/src/components/elide-logo.tsx b/packages/ui/src/components/elide-logo.tsx index 16bff0f..11e6100 100644 --- a/packages/ui/src/components/elide-logo.tsx +++ b/packages/ui/src/components/elide-logo.tsx @@ -1,43 +1,341 @@ import * as React from "react"; +import { + paths, + GRADIENTS, + BRAND_PURPLE, + BRAND_MAGENTA, + SQUARE_VIEWBOX, + FULL_VIEWBOX, + SQUARE_ASPECT, + FULL_ASPECT, + assetPath, + isThemed, + resolveVariant, + BASE_SIZE, + SCALES, + DEFAULT_ASSET_BASE, + type BrandForm, + type BrandVariant, + type BrandFormat, + type BrandTheme, +} from "@elide/brand"; import { cn } from "../lib/utils"; /** - * Elide brand marks, inlined as SVG so they render everywhere without an - * external asset request (works in SSR, Storybook, and Chromatic capture). + * ElideLogo — the brand mark, across the three axes the brand actually needs: + * light/dark, square/full form, and vector/raster. * - * - `ElideMark` — the gradient hexagon glyph. - * - `ElideWordmark` — the "elide" wordmark, drawn in `currentColor`. - * - `ElideLogo` — the nav lockup (mark + wordmark), used as AppNav's default logo. + * Vector is the default and inlines the SVG, so there is no network request and + * no asset to serve. `mono` and `blend` draw in `currentColor`, which means + * light/dark costs nothing: the mark inverts with the surrounding text color. + * + * The raster formats exist for contexts that can't take inline SVG — email, OG + * images, native shells, ``-only CMS fields. They read files from + * `@elide/brand/assets`, which the consuming app serves under `assetBase`. + */ + +export type ElideLogoForm = BrandForm; +export type ElideLogoVariant = BrandVariant; +export type ElideLogoFormat = BrandFormat; + +const VIEWBOX: Record = { + square: SQUARE_VIEWBOX, + full: FULL_VIEWBOX, +}; + +const ASPECT: Record = { + square: SQUARE_ASPECT, + full: FULL_ASPECT, +}; + +export interface ElideLogoProps extends Omit, "title"> { + /** `square` is the hexagon badge; `full` is the glyph + wordmark lockup. */ + form?: ElideLogoForm; + /** + * `gradient` is the brand gradient, `mono` a single color that follows the + * text color, `blend` a gradient glyph beside a mono wordmark. `blend` only + * differs from `gradient` on the `full` form. + */ + variant?: ElideLogoVariant; + /** `svg` inlines the vector. The raster formats render an ``. */ + format?: ElideLogoFormat; + /** Link the `.svg` as an external file rather than inlining its path data. */ + linked?: boolean; + /** Height in px. Width follows from the artwork's aspect ratio. */ + height?: number; + /** Accessible name. */ + title?: string; + /** Hide from assistive tech — for when an adjacent label already names it. */ + decorative?: boolean; + /** URL prefix that `@elide/brand/assets` is served under. */ + assetBase?: string; + /** @deprecated Use `form="square"`. */ + markOnly?: boolean; +} + +interface Shape { + key: string; + d: string; + fill: string; + fillRule?: "evenodd"; +} + +interface Gradient { + id: string; + x1: number; + y1: number; + x2: number; + y2: number; +} + +/** + * Shapes for one artwork, in the artwork's own user space. + * + * The square mono cut merges body and ribbon into a single `evenodd` path so + * the ribbon punches clean through to whatever is behind it. The delivered + * artwork paints that ribbon opaque white — identical on a white page, wrong on + * every other surface. The gradient cut keeps the opaque white ribbon, because + * that is the official rendering of the mark. + */ +function shapesFor( + form: ElideLogoForm, + variant: ElideLogoVariant, + gid: string, +): { gradients: Gradient[]; shapes: Shape[] } { + const url = (suffix: string) => `url(#${gid}-${suffix})`; + + if (form === "square") { + if (variant === "mono") { + return { + gradients: [], + shapes: [ + { + key: "body", + d: `${paths.squareBody} ${paths.squareRibbon}`, + fill: "currentColor", + fillRule: "evenodd" as const, + }, + ], + }; + } + return { + gradients: [{ id: `${gid}-body`, ...GRADIENTS.squareBody }], + shapes: [ + { key: "body", d: paths.squareBody, fill: url("body") }, + { key: "ribbon", d: paths.squareRibbon, fill: "#ffffff" }, + ], + }; + } + + const glyphGradient = variant !== "mono"; + const wordmarkGradient = variant === "gradient"; + + return { + gradients: [ + ...(glyphGradient ? [{ id: `${gid}-glyph`, ...GRADIENTS.fullGlyph }] : []), + ...(wordmarkGradient ? [{ id: `${gid}-word`, ...GRADIENTS.fullWordmark }] : []), + ], + shapes: [ + { key: "glyph", d: paths.fullGlyph, fill: glyphGradient ? url("glyph") : "currentColor" }, + { + key: "wordmark", + d: paths.fullWordmark, + fill: wordmarkGradient ? url("word") : "currentColor", + }, + ], + }; +} + +/** Height/width pair for a given rendered height, rounded to hundredths of a px. */ +function box(form: ElideLogoForm, height: number) { + return { height, width: Math.round(height * ASPECT[form] * 100) / 100 }; +} + +export function ElideLogo({ + form, + variant = "gradient", + format = "svg", + linked = false, + height, + title = "Elide", + decorative = false, + assetBase = DEFAULT_ASSET_BASE, + markOnly, + className, + style, + ...props +}: ElideLogoProps) { + // Unique per instance so several logos on a page never share a gradient id. + const gid = React.useId().replace(/:/g, ""); + + const resolvedForm: ElideLogoForm = form ?? (markOnly ? "square" : "full"); + const resolvedVariant = resolveVariant(resolvedForm, variant); + const { gradients, shapes } = shapesFor(resolvedForm, resolvedVariant, gid); + + const sized = height !== undefined ? box(resolvedForm, height) : undefined; + const a11y = decorative + ? ({ "aria-hidden": true } as const) + : ({ role: "img", "aria-label": title } as const); + + if (format !== "svg" || linked) { + return ( + + ); + } + + return ( + )} + > + {gradients.length > 0 && ( + + {gradients.map(({ id, x1, y1, x2, y2 }) => ( + + + + + ))} + + )} + {shapes.map(({ key, d, fill, fillRule }) => ( + + ))} + + ); +} + +interface RasterLogoProps extends React.HTMLAttributes { + form: ElideLogoForm; + variant: ElideLogoVariant; + format: ElideLogoFormat; + assetBase: string; + sized?: { width: number; height: number }; + title: string; + decorative: boolean; +} + +/** + * Raster (and linked-SVG) rendering. + * + * Theme-dependent artwork renders both cuts and toggles them with `dark:` + * utilities rather than reading the theme in JavaScript. That needs no + * provider, cannot flash, survives SSR, and — unlike a `` keyed on + * `prefers-color-scheme` — respects a site theme that disagrees with the OS, + * which matters because theming here is a `.dark` class on ``. + * + * The cost is that a browser may fetch both files. It applies only to `mono` + * and `blend`, the files are single-digit kilobytes, and vector — which has no + * such cost — is the default. + */ +function RasterLogo({ + form, + variant, + format, + assetBase, + sized, + title, + decorative, + className, + style, + ...props +}: RasterLogoProps) { + const themed = isThemed(form, variant); + const intrinsic = sized ?? BASE_SIZE[form]; + + const image = (theme: BrandTheme, themeClass?: string) => ( + s !== 1) + .map((s) => `${assetPath({ form, variant, format, theme, scale: s, base: assetBase })} ${s}x`) + .join(", ") + } + // Always present so the box is known before the image loads. + width={Math.round(intrinsic.width)} + height={Math.round(intrinsic.height)} + // Only the light cut carries the name — the pair is one logo, and the + // hidden one would otherwise announce a duplicate. + alt={decorative || theme === "dark" ? "" : title} + aria-hidden={decorative || theme === "dark" ? true : undefined} + className={cn(sized ? undefined : "h-6 w-auto", themeClass, className)} + style={sized ? { ...sized, ...style } : style} + {...(props as React.ImgHTMLAttributes)} + /> + ); + + if (!themed) return image("light"); + return ( + <> + {image("light", "dark:hidden")} + {image("dark", "hidden dark:block")} + + ); +} + +/** + * ElideMark — the gradient hexagon badge at the nav's size. + * + * Retained at its original signature and output for `AppNav` and for anything + * already importing it; new code should prefer ``. */ export function ElideMark({ className, ...props }: React.SVGProps) { - // Unique gradient id per instance so multiple marks on a page don't collide. const gid = React.useId(); return ( - - - + + + - - + + ); } +/** + * ElideWordmark — the standalone "elide" wordmark, drawn in `currentColor`. + * + * This is a different cut from the wordmark inside the `full` lockup (a wider + * setting, on its own 179.42x42.36 canvas). It is kept because `AppNav`'s + * lockup is built from it; `` is the official lockup. + */ export function ElideWordmark({ className, ...props }: React.SVGProps) { return ( ); } - -export interface ElideLogoProps extends React.HTMLAttributes { - /** Hide the wordmark, showing only the hexagon glyph. */ - markOnly?: boolean; -} - -export function ElideLogo({ markOnly, className, ...props }: ElideLogoProps) { - return ( - - {/* The mark keeps its role="img" aria-label="Elide" so the lockup is - announced once; the wordmark is decorative alongside it. */} - - {markOnly ? null : } - - ); -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e987faa..17348e6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -56,7 +56,10 @@ export { // --- Tier-2 docs composites (cycle 2, ported from the Elide Docs mockup) --- export { AppNav, type AppNavProps, type NavLink } from "./components/app-nav"; -export { ElideLogo, ElideMark, ElideWordmark, type ElideLogoProps } from "./components/elide-logo"; +export { + ElideLogo, ElideMark, ElideWordmark, + type ElideLogoProps, type ElideLogoForm, type ElideLogoVariant, type ElideLogoFormat, +} from "./components/elide-logo"; export { SectionTabs, type SectionTabsProps, type SectionTabItem, type SectionTabsVersion, } from "./components/section-tabs"; diff --git a/packages/ui/src/stories/ElideLogo.stories.tsx b/packages/ui/src/stories/ElideLogo.stories.tsx new file mode 100644 index 0000000..0a9888c --- /dev/null +++ b/packages/ui/src/stories/ElideLogo.stories.tsx @@ -0,0 +1,218 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { ElideLogo } from "../components/elide-logo"; +import { AppNav } from "../components/app-nav"; + +const meta = { + title: "Brand/ElideLogo", + component: ElideLogo, + parameters: { layout: "centered" }, + argTypes: { + form: { control: "inline-radio", options: ["full", "square"] }, + variant: { control: "inline-radio", options: ["gradient", "mono", "blend"] }, + format: { control: "inline-radio", options: ["svg", "png", "webp", "avif"] }, + height: { control: { type: "range", min: 16, max: 160, step: 2 } }, + linked: { control: "boolean" }, + decorative: { control: "boolean" }, + }, + args: { form: "full", variant: "gradient", format: "svg", height: 48 }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Grid cell with a caption, so a story reads as a labeled matrix. */ +function Cell({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{children}
+ + {label} + +
+ ); +} + +const row: React.CSSProperties = { + display: "flex", + alignItems: "flex-end", + gap: 40, + flexWrap: "wrap", + justifyContent: "center", +}; + +export const Playground: Story = {}; + +/** + * The two forms across all three variants. `blend` is only distinct on the full + * lockup — on the square it resolves to `gradient`, which is what blend means + * there. Toggle the theme in the toolbar: `mono` and `blend` follow the text + * color, so they invert with no asset swap. + */ +export const Matrix: Story = { + render: () => ( +
+
+ + + + + + + + + +
+
+ + + + + + + + + +
+
+ ), +}; + +/** + * The square mono ribbon is punched through rather than painted white, so the + * surface behind it shows through. These sit on tinted panels to make that + * visible — on a plain background it is indistinguishable from the original. + */ +export const MonoOnSurfaces: Story = { + render: () => ( +
+ {[ + { bg: "var(--eld-magenta-500, #e7008c)", fg: "#ffffff", label: "on brand" }, + { bg: "var(--muted)", fg: "var(--foreground)", label: "on muted" }, + { bg: "var(--card)", fg: "var(--foreground)", label: "on card" }, + ].map(({ bg, fg, label }) => ( + +
+ +
+
+ ))} +
+ ), +}; + +/** + * Vector versus raster. All four render the same artwork; `svg` inlines it with + * no request, the rest load from `@elide/brand/assets` at `assetBase` + * (`/brand` by default, served here by Storybook's staticDirs). + */ +export const Formats: Story = { + render: () => ( +
+ {(["svg", "png", "webp", "avif"] as const).map((format) => ( + + + + ))} + + + +
+ ), +}; + +/** + * Raster `mono` and `blend` ship a light and a dark cut, swapped by the `.dark` + * class rather than by JavaScript. Flip the theme in the toolbar — the wordmark + * inverts while the gradient glyph stays put. + */ +export const RasterThemeSwap: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), + /** + * Asserts the swap in real CSS, which is the only place it can be checked: + * jsdom sees the `dark:hidden` / `hidden dark:block` class names but never + * evaluates them, so a unit test passes even when the variant is misconfigured + * and both themes show the light cut. That exact bug shipped once — Tailwind + * v4's default `dark:` is a `prefers-color-scheme` media query, so it ignored + * the `.dark` class until `styles.css` bound it with `@custom-variant`. + */ + play: async ({ canvasElement }) => { + const root = document.documentElement; + const wasDark = root.classList.contains("dark"); + // Queried by selector, not by role: the dark cut carries alt="" so the pair + // is announced once, which also makes it presentational and invisible to + // getAllByRole("img"). + const shown = (src: string) => { + const img = canvasElement.querySelector(`img[src$="${src}"]`); + if (!img) throw new Error(`no for ${src}`); + return getComputedStyle(img).display !== "none"; + }; + + try { + root.classList.remove("dark"); + await expect(shown("elide-full-blend-light.webp")).toBe(true); + await expect(shown("elide-full-blend-dark.webp")).toBe(false); + + root.classList.add("dark"); + await expect(shown("elide-full-blend-light.webp")).toBe(false); + await expect(shown("elide-full-blend-dark.webp")).toBe(true); + + // The theme-agnostic gradient stays visible either way. + await expect(shown("elide-full-gradient.webp")).toBe(true); + } finally { + root.classList.toggle("dark", wasDark); + } + }, +}; + +/** Width always follows the artwork's aspect ratio; nothing is ever stretched. */ +export const Sizes: Story = { + render: () => ( +
+ {[16, 24, 32, 48, 72, 112].map((height) => ( + + + + ))} +
+ ), +}; + +/** + * The nav keeps its own lockup — the filled badge beside the wider wordmark + * cut — which is different artwork from ``. Pass + * `logo` to adopt the official lockup instead. + */ +export const InAppNav: Story = { + parameters: { layout: "fullscreen" }, + render: () => ( +
+ + } + /> +
+ ), +}; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index f20ca62..dc6cac4 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -11,6 +11,16 @@ * (animate-in, fade-in-0, zoom-in-95, slide-in-from-*). */ @import "tw-animate-css"; +/* Bind the `dark:` variant to the `.dark` class on , which is how + * ThemeProvider (and the Storybook decorator) applies the theme. + * + * Tailwind v4's default `dark:` is `@media (prefers-color-scheme: dark)`, so + * without this every `dark:` utility in the catalog silently follows the OS + * instead of the site theme — a user who toggles to light while their OS is + * dark gets dark-mode styling. The token layer was unaffected (it keys off + * `.dark` directly), which is why this stayed hidden. */ +@custom-variant dark (&:where(.dark, .dark *)); + /* Tailwind v4 auto-detection roots at this file's location. When consumed via * the `node_modules/@elide/ui` symlink (Storybook, and any downstream app), that * tree is gitignored, so auto-detection finds nothing and no component utilities