diff --git a/prek.toml b/prek.toml
index ea06e70..2e3f8b1 100644
--- a/prek.toml
+++ b/prek.toml
@@ -68,7 +68,7 @@ id = "cws-tiles"
name = "render chrome web store tiles from brand svgs"
entry = "npm run cws"
language = "system"
-files = '^(brand/(mark|sticker|wordmark)\.svg|scripts/(render-cws|render-icons|rasterize)\.mjs|brand/cws/.+\.png)$'
+files = '^(brand/(mark|sticker|wordmark)\.svg|scripts/(render-cws|render-icons|rasterize|compose)\.mjs|brand/cws/.+\.png)$'
pass_filenames = false
[[repos.hooks]]
diff --git a/scripts/compose.mjs b/scripts/compose.mjs
new file mode 100644
index 0000000..5d93acf
--- /dev/null
+++ b/scripts/compose.mjs
@@ -0,0 +1,46 @@
+// Shared SVG composition helpers for the brand render scripts. Compositions
+// are assembled at render time by nesting the canonical brand SVGs into a
+// paper-colored canvas, so derived art can never drift from the logo files.
+
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+export const PAPER = '#f4f3ee'
+
+/** Inline a brand SVG as a nested '
+ )
+}
diff --git a/scripts/render-cws.mjs b/scripts/render-cws.mjs
index a93b82f..5da318e 100644
--- a/scripts/render-cws.mjs
+++ b/scripts/render-cws.mjs
@@ -1,9 +1,9 @@
// Renders the Chrome Web Store listing images from the brand SVGs. The
-// compositions are assembled here at render time by nesting brand/mark.svg,
-// brand/sticker.svg, and the generated brand/wordmark.svg into a
-// paper-colored canvas, so the store art can never drift from the canonical
-// logo files. Listing copy lives in the store listing itself, not in art;
-// the only text here is the wordmark.
+// compositions are assembled in scripts/compose.mjs terms by nesting
+// brand/mark.svg, brand/sticker.svg, and the generated brand/wordmark.svg
+// into a paper-colored canvas, so the store art can never drift from the
+// canonical logo files. Listing copy lives in the store listing itself, not
+// in art; the only text here is the wordmark.
//
// Run npm run wordmark first when brand sources change (the prek hooks
// order this correctly): the marquee nests the generated wordmark file.
@@ -11,44 +11,15 @@
// Usage: node scripts/render-cws.mjs (also: npm run cws)
// Set CHROME to point at a specific Chrome/Chromium binary.
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
+import { compose, markY, nest, nestWordmark } from './compose.mjs'
import { findChrome, pngSize } from './render-icons.mjs'
import { rasterize } from './rasterize.mjs'
-const PAPER = '#f4f3ee'
-
-/** Inline a brand SVG as a nested '
- )
-}
-
function main() {
const chrome = findChrome()
const work = mkdtempSync(join(tmpdir(), 'substandard-cws-'))
diff --git a/src/lib/compose.test.ts b/src/lib/compose.test.ts
new file mode 100644
index 0000000..10ab8af
--- /dev/null
+++ b/src/lib/compose.test.ts
@@ -0,0 +1,46 @@
+// The composition helpers' logic that runs without Chrome: geometry and SVG
+// nesting against the real brand files.
+import { describe, expect, it } from 'vitest'
+
+// @ts-expect-error plain-node script, no type declarations
+import { compose, markY, nest, nestWordmark } from '../../scripts/compose.mjs'
+
+describe('nest', () => {
+ it('positions the brand svg and keeps it a single nested element', () => {
+ const svg = nest('brand/mark.svg', 10, 20, 64)
+ expect(svg).toContain('