{"_id":"@aimee.shop/blocks","_rev":"5-c0de7ce55ec6993919ed8245559588a1","name":"@aimee.shop/blocks","dist-tags":{"latest":"0.0.2"},"versions":{"0.5.2":{"name":"@aimee.shop/blocks","version":"0.5.2","keywords":["aimee","ziftr","blocks","cms","react","components","storefront"],"author":{"name":"Ziftr"},"license":"UNLICENSED","_id":"@aimee.shop/blocks@0.5.2","maintainers":[{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"}],"homepage":"https://gitlab.com/ziftr1/zs2-blocks#readme","bugs":{"url":"https://gitlab.com/ziftr1/zs2-blocks/issues"},"dist":{"shasum":"4ad1e2a95c3a03f86962ad2b75f3d0aa42274d20","tarball":"https://registry.npmjs.org/@aimee.shop/blocks/-/blocks-0.5.2.tgz","fileCount":105,"integrity":"sha512-Olf009L6w+DdGWJYK6XZCfSkHhX2pBZQP63oWwfgS09ofJGihyslETv/2rMgymDQRi8Ft7jcEuIzNwnNiHgxcw==","signatures":[{"sig":"MEUCIQDOlpzMcXjZuG22biFHd8ilRrycBEsKaTCPgRIxJUFPXgIgLE2wF8ZNXnpwv/6yGX/FMu8u8n/wUPRIKQ98b/X4jCU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":248015},"main":"dist/index.js","type":"module","types":"dist/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./styles.css":"./dist/styles.css"},"gitHead":"a8b546834b55708560e7506b121a403528a5de19","scripts":{"lint":"eslint \"src/**/*.{ts,tsx}\" --fix","test":"vitest run","build":"tsup && npm run build:css","format":"prettier --write \"src/**/*.{ts,tsx,css}\"","status":"npm run lint && npm run typecheck && npm run test && npm run build && npm run test:cjs-sanitizer","test:cov":"vitest run --coverage","build:css":"cat src/styles/variables.css src/styles/blocks.css > dist/styles.css","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build","test:cjs-sanitizer":"node scripts/assert-cjs-sanitizer.cjs"},"_npmUser":{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"},"repository":{"url":"git+https://gitlab.com/ziftr1/zs2-blocks.git","type":"git"},"_npmVersion":"11.17.0","description":"React component library for rendering Aimee CMS block content on storefronts","directories":{},"sideEffects":["*.css"],"_nodeVersion":"24.19.0","dependencies":{"sanitize-html":"2.17.0"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","jsdom":"^29.1.1","react":"^19.2.6","eslint":"^10.4.0","vitest":"^4.1.6","globals":"^17.6.0","prettier":"^3.4.2","react-dom":"^19.2.6","@eslint/js":"^10.0.1","typescript":"^5.9.3","@types/react":"^19.2.15","@types/react-dom":"^19.0.0","typescript-eslint":"^8.59.4","@vitest/coverage-v8":"^4.1.6","@types/sanitize-html":"^2.16.1","@testing-library/react":"^16.1.0","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.2.1","@testing-library/jest-dom":"^6.6.3","@testing-library/user-event":"^14.6.1"},"peerDependencies":{"react":">=18.0.0","react-dom":">=18.0.0","@ziftr.ai/sdk":">=0.1.0"},"peerDependenciesMeta":{"@ziftr.ai/sdk":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/blocks_0.5.2_1788894890694_0.1621444615683978","host":"s3://npm-registry-packages-npm-production"},"deprecated":"Old-brand SDK peer. Use @aimee.shop/blocks 0.0.2 or later."},"0.0.1":{"name":"@aimee.shop/blocks","version":"0.0.1","keywords":["aimee","ziftr","blocks","cms","react","components","storefront"],"author":{"name":"Ziftr"},"license":"UNLICENSED","_id":"@aimee.shop/blocks@0.0.1","maintainers":[{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"}],"homepage":"https://gitlab.com/ziftr1/zs2-blocks#readme","bugs":{"url":"https://gitlab.com/ziftr1/zs2-blocks/issues"},"dist":{"shasum":"97fe57f2859f1641a1ccafbecb4866d7e5ddf3e9","tarball":"https://registry.npmjs.org/@aimee.shop/blocks/-/blocks-0.0.1.tgz","fileCount":105,"integrity":"sha512-1OKxYNJvTk3HqJmfD4d20L4kQGwx7NesIu4QePK5Zhu6oRkMdS/ayK/FjNzD/hXS3Io9QuKuBwuOGgmXp/748Q==","signatures":[{"sig":"MEUCIFZR49HkGwMItXdZg6d9svqz5v8Gh9bmiC/QOn/72x9FAiEA0hmIKa0y2SWS6bG4tCFEIINU53sDpe01lMSd/iJuQ2c=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":248015},"main":"dist/index.js","type":"module","types":"dist/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./styles.css":"./dist/styles.css"},"gitHead":"c7476cba42ad8d221638b0be625e3f8301029dbc","scripts":{"lint":"eslint \"src/**/*.{ts,tsx}\" --fix","test":"vitest run","build":"tsup && npm run build:css","format":"prettier --write \"src/**/*.{ts,tsx,css}\"","status":"npm run lint && npm run typecheck && npm run test && npm run build && npm run test:cjs-sanitizer","test:cov":"vitest run --coverage","build:css":"cat src/styles/variables.css src/styles/blocks.css > dist/styles.css","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build","test:cjs-sanitizer":"node scripts/assert-cjs-sanitizer.cjs"},"_npmUser":{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"},"deprecated":"Old-brand SDK peer. Use @aimee.shop/blocks 0.0.2 or later.","repository":{"url":"git+https://gitlab.com/ziftr1/zs2-blocks.git","type":"git"},"_npmVersion":"11.17.0","description":"React component library for rendering Aimee CMS block content on storefronts","directories":{},"sideEffects":["*.css"],"_nodeVersion":"24.19.0","dependencies":{"sanitize-html":"2.17.0"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","jsdom":"^29.1.1","react":"^19.2.6","eslint":"^10.4.0","vitest":"^4.1.6","globals":"^17.6.0","prettier":"^3.4.2","react-dom":"^19.2.6","@eslint/js":"^10.0.1","typescript":"^5.9.3","@types/react":"^19.2.15","@types/react-dom":"^19.0.0","typescript-eslint":"^8.59.4","@vitest/coverage-v8":"^4.1.6","@types/sanitize-html":"^2.16.1","@testing-library/react":"^16.1.0","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.2.1","@testing-library/jest-dom":"^6.6.3","@testing-library/user-event":"^14.6.1"},"peerDependencies":{"react":">=18.0.0","react-dom":">=18.0.0","@ziftr.ai/sdk":">=0.1.0"},"peerDependenciesMeta":{"@ziftr.ai/sdk":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/blocks_0.0.1_1788967376360_0.18638224193846842","host":"s3://npm-registry-packages-npm-production"}},"0.0.2":{"name":"@aimee.shop/blocks","version":"0.0.2","keywords":["aimee","aimee","blocks","cms","react","components","storefront"],"author":{"name":"Aimee"},"license":"UNLICENSED","_id":"@aimee.shop/blocks@0.0.2","maintainers":[{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"}],"homepage":"https://gitlab.com/aimee1/blocks#readme","bugs":{"url":"https://gitlab.com/aimee1/blocks/issues"},"dist":{"shasum":"3e4c1965ef9314f9ad33a8216ab9caad1c2ac0d7","tarball":"https://registry.npmjs.org/@aimee.shop/blocks/-/blocks-0.0.2.tgz","fileCount":117,"integrity":"sha512-aolJFkeJERWkhEN103PGl3bqIsvAipBt2275S1p+PAQQ4C7NAlncgu5h7vtU6UmO8L8omoQB7FRptDZs5NqnxQ==","signatures":[{"sig":"MEUCICFi4aHn5i29anfnYBw6aCTh0jdGk8lzA2zMKmvIcNqJAiEAoTEZFv8V+GdualsCkvwQ3L03nDtuZEOG1+iOvwzKac0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCICwjVXOdSf0ZDzOyAmSev4iRh9PUKBuz2MuRGsUsSdRbAiEAjQyp1cNcjADwDltNtGjYmMZbGePyTO37toPTy0h59WU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":378323},"main":"dist/index.js","type":"module","types":"dist/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./styles.css":"./dist/styles.css"},"gitHead":"5a862215a6794281fa00d6324244f2a458d38b05","scripts":{"lint":"eslint \"src/**/*.{ts,tsx}\" \".storybook/**/*.{ts,tsx}\" --fix","test":"vitest run","build":"tsup && npm run build:css","format":"prettier --write \"src/**/*.{ts,tsx,css}\" \".storybook/**/*.{ts,tsx}\"","status":"npm run lint && npm run typecheck && npm run test && npm run build && npm run test:cjs-sanitizer","test:cov":"vitest run --coverage","build:css":"cat src/styles/variables.css src/styles/blocks.css > dist/styles.css","storybook":"storybook dev -p 6006","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build","build-storybook":"storybook build","test:cjs-sanitizer":"node scripts/assert-cjs-sanitizer.cjs"},"_npmUser":{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"},"repository":{"url":"git+https://gitlab.com/aimee1/blocks.git","type":"git"},"_npmVersion":"11.17.0","description":"React component library for rendering Aimee CMS block content on storefronts","directories":{},"sideEffects":["*.css"],"_nodeVersion":"24.19.0","dependencies":{"sanitize-html":"2.17.0"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"8.2.2","jsdom":"^29.1.1","react":"^19.2.6","eslint":"^10.4.0","vitest":"^4.1.6","globals":"^17.6.0","prettier":"^3.4.2","react-dom":"^19.2.6","storybook":"10.6.0","@eslint/js":"^10.0.1","typescript":"^5.9.3","@types/react":"^19.2.15","@types/react-dom":"^19.0.0","typescript-eslint":"^8.59.4","@vitest/coverage-v8":"^4.1.6","@types/sanitize-html":"^2.16.1","@storybook/react-vite":"10.6.0","@testing-library/react":"^16.1.0","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.2.1","@testing-library/jest-dom":"^6.6.3","@testing-library/user-event":"^14.6.1"},"peerDependencies":{"react":">=18.0.0","react-dom":">=18.0.0","@aimee.shop/sdk":">=0.0.1"},"peerDependenciesMeta":{"@aimee.shop/sdk":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/blocks_0.0.2_1789431604898_0.7433130527912264","host":"s3://npm-registry-packages-npm-production"}}},"time":{"created":"2026-09-08T19:14:50.493Z","modified":"2026-09-15T00:26:25.945Z","0.5.2":"2026-09-08T19:14:50.865Z","0.0.1":"2026-09-09T15:22:56.488Z","0.0.2":"2026-09-15T00:20:04.992Z"},"bugs":{"url":"https://gitlab.com/aimee1/blocks/issues"},"author":{"name":"Aimee"},"license":"UNLICENSED","homepage":"https://gitlab.com/aimee1/blocks#readme","keywords":["aimee","aimee","blocks","cms","react","components","storefront"],"repository":{"url":"git+https://gitlab.com/aimee1/blocks.git","type":"git"},"description":"React component library for rendering Aimee CMS block content on storefronts","maintainers":[{"name":"tom.ziftr","email":"tom.schofield@ziftr.com"}],"readme":"# @aimee.shop/blocks\n\nReact component library for rendering Aimee CMS block content on storefronts.\n\nThis package ships 24 read-only block renderers (including composition types). It is a\nconvenience layer over raw SDK data -- storefronts can also render blocks manually.\n\n## Install\n\n```bash\nnpm install @aimee.shop/blocks\n```\n\nPeer dependencies: `react >= 18`, `react-dom >= 18`. `@aimee.shop/sdk` is an optional\npeer dependency -- this package never imports it; all data arrives via props.\n\n## Catalog\n\nA Storybook catalog (dev-only, not published) renders every `BLOCK_DEFS` type through\n`BlockRenderer` with fixture content:\n\n```bash\nnpm run storybook\nnpm run build-storybook\n```\n\nTheme tokens (`--zb-heading-color`, `--zb-cta-bg`, `--zb-font-family`) are toolbar\nglobals. Viewports: mobile 375 and desktop 1280. The catalog does not call a live\nassistant.\n\n## Usage\n\nImport the stylesheet once (for example in a Next.js root layout):\n\n```ts\nimport '@aimee.shop/blocks/styles.css';\n```\n\nFetch content with the SDK and pass blocks to the renderer:\n\n```tsx\nimport { BlockRenderer } from '@aimee.shop/blocks';\n\n// In an async Server Component\nconst entry = await sdk.cms.public.getContent('blog', slug);\n\nreturn <BlockRenderer blocks={entry.blocks} />;\n```\n\nBlock data shape (mirrors the CMS API):\n\n```ts\ninterface Block {\n  id: string;\n  type: string; // one of 24 known types; unknown types render as JSON via CustomBlock\n  sortOrder: number;\n  content: Record<string, unknown>;\n  settings?: Record<string, unknown>;\n}\n```\n\n## Block types\n\ntext, heading, image, gallery, video, embed, cta, product_card, product_grid, quote,\ncode, divider, spacer, accordion, tabs, custom, form_embed, hero, promo_banner,\ncard_grid, comparison_table, video_lightbox, columns, container, chat_widget.\n\nContent for each type is validated at render time by exported parsers\n(`parseHeadingContent`, `parseGalleryContent`, ...). Invalid or empty content renders\nnothing; recoverable fields get defaults (heading level 2, grid columns 3, etc.).\nUnknown block types fall back to `CustomBlock`, which renders the raw content as JSON.\n\n### Composition (columns / container)\n\n`columns` and `container` hold nested `Block[]` inside their content JSON. `BlockRenderer`\nrecurses into children (same parse/dispatch path, products and overrides flow down).\nLimits:\n\n- max nesting depth 3 (`MAX_BLOCK_DEPTH`); deeper composition blocks degrade to `CustomBlock`\n- only `columns` / `container` hold children\n- `columns.length` capped at 6\n- column `width` is `'auto'` or a positive relative weight (CSS `fr`)\n\n### Notes on specific types\n\n- `hero`: headline (h1 by default, demotable to h2 via `headlineLevel`), optional\n  subheadline, background image and/or video (video wins, image becomes the poster\n  and the `prefers-reduced-motion` fallback), contrast overlay\n  (`overlayOpacity`, 0-1), up to 2 CTAs, `height` small|medium|large|full.\n- `promo_banner`: text with optional link. First block type with typed settings:\n  `dismissible`, `startsAt`/`endsAt` (ISO 8601 schedule window), `storageKey`\n  (localStorage key for dismissal; falls back to the block id). Pages cached by\n  ISR/static rendering re-evaluate the window client-side at hydration, but keep\n  revalidation intervals shorter than your window edges.\n- `card_grid`: cards with title, plain-text body, image/icon, optional link\n  (single anchor, CSS-expanded hit area). Optional `linkText` makes that\n  anchor a visible underlined CTA under the body; optional `showTitle: false`\n  hides the heading (title stays required and is the image alt fallback).\n- `comparison_table`: semantic table with column/row headers, optional caption and\n  `highlightColumn` (0-based), horizontal scroll wrapper that is keyboard focusable.\n- `video`: YouTube and Vimeo page URLs are rewritten to their embed form.\n  Allowlisted hosted players (`videos.sproutvideo.com`) and `provider: 'hosted'`\n  pass the `http(s)` URL through as the iframe src. Unrecognized URLs render a\n  visible error and `console.warn` -- never a silent link. Raw media files are\n  not hosted players; use `hero` background video or `embed` for those.\n- `video_lightbox`: thumbnail button opening a modal embed (same URL rules as\n  `video`) with autoplay. Unrecognized URLs keep the thumbnail and show the\n  same visible error.\n- `columns`: multi-column layout; each column has `width` and nested `blocks`.\n- `container`: grouped section with nested `blocks` and optional `style`\n  default|emphasis|accent. Presentation fields (all optional): `backgroundColor`\n  / `textColor` (`#RGB` or `#RRGGBB`), `backgroundImage` / `mobileSrc` (http(s),\n  `mobileSrc` via `<picture>`), `paddingY` none|sm|md|lg, `minHeight` none|md|lg.\n  Typed settings: `align` start|center|end, `width` contained|full, `cssClass`,\n  `htmlId`. `width=full` bleeds the outer surface and constrains\n  `.zb-container__inner`.\n\n## Block settings\n\n`settings` mirrors `content`: raw `Record<string, unknown>` narrowed by runtime\nparsers, never cast. Block types with typed settings appear in `BlockSettingsMap`\n(`promo_banner` -> `PromoBannerBlockSettings`, `container` -> `ContainerBlockSettings`);\nfor every other type the raw settings record passes through unchanged. Settings may\ncarry extra keys (for example admin variant/personalization metadata); parsers read\nwhat they know and ignore the rest. Components rendered through `BlockRenderer` also\nreceive `blockId` -- the CMS block id -- for per-block client state such as promo\nbanner dismissal.\n\n## Server and client components\n\nComponents are React Server Component compatible by default. Exactly six blocks are\nclient components (`'use client'`): AccordionBlock, TabsBlock, GalleryBlock,\nPromoBannerBlock, VideoLightboxBlock, ChatWidgetBlock. Composition blocks (`columns`, `container`) are\nserver components. No renderer is async and none fetches data -- everything renders\nfrom props.\n\n## Product blocks and the products map\n\n`product_card` and `product_grid` content contains only product IDs. The CMS API does\nnot resolve products; the storefront fetches them and passes a map:\n\n```tsx\nconst products = {\n  [productId]: { id, name, slug, price, compareAtPrice, currency, images },\n};\n\n<BlockRenderer blocks={entry.blocks} products={products} />;\n```\n\nWhen a product is missing from the map (or no map is passed), the card renders a\nvisible \"Product unavailable\" placeholder instead of disappearing silently.\n\n## Chat widget and the assistant session\n\n`chat_widget` renders the shopper assistant (ai-toolkit `POST /v1/ai/shopper/chat`)\nas a floating drawer (`mode: \"drawer\"`, launcher button bottom-right or `settings.position:\n\"left\"`) or an inline panel (`mode: \"inline\"`). Content: `title`, `greeting`, `placeholder`,\n`launcherLabel`, `suggestions` (max 4 quick prompts). Settings: `position`, `pageType`.\n\nThe session is storefront data, not CMS content, so it is passed to `BlockRenderer` as\n`assistant` (the same way `products` is):\n\n```tsx\n<BlockRenderer\n  blocks={entry.blocks}\n  assistant={{\n    endpoint: 'https://ai-dev.aimee.shop',\n    publishableKey: process.env.NEXT_PUBLIC_AIMEE_PUBLISHABLE_KEY,\n    customerToken: session?.accessToken, // sent as X-Customer-Token\n    cartId: cart?.id,\n    page: { pageType: 'product', productId: product.id },\n    onCartUpdate: (cart) => adoptCart(cart.cartId),\n    onCheckout: (url) => router.push(url), // default: window.location.assign(url)\n  }}\n/>\n```\n\nThe widget makes no SDK calls. Every commerce action goes through the assistant; the\nstorefront only bootstraps the session (key, cart id, customer token) and adopts the\ncart id announced on `onCartUpdate` (a guest cart is created server-side on the first\nwrite). Without `assistant`, an inline block shows \"Chat is unavailable\" and a drawer\nrenders nothing.\n\nStream payloads render as cards: product search results and details, the cart\n(`cart_update` also replaces the running cart), orders, and the `checkout_handoff`\nlink to the hosted checkout URL. Gate refusals (`status: \"held\"`), tool errors, and\n`warning` events show as a muted notice line. The assistant only ever streams text and these events; the\nwidget round-trips the text history plus `conversationId` (`X-Session-Id`).\n\nFor a custom UI, `streamShopperChat`, `parseSseChunk`, `readShopperEvent`,\n`shopperChatReducer`, and the card components are exported.\n\n## Overriding block components\n\n```tsx\nimport type { BlockComponentProps, HeadingBlockContent } from '@aimee.shop/blocks';\n\nfunction MyHeading({ content }: BlockComponentProps<HeadingBlockContent>) {\n  return <h2 className=\"my-heading\">{content.text}</h2>;\n}\n\n<BlockRenderer blocks={blocks} overrides={{ heading: MyHeading }} />;\n```\n\nOverrides are typed per block type: the `heading` override receives parsed\n`HeadingBlockContent`, not raw JSON.\n\n## Theming\n\nAll styling uses CSS custom properties with the `--zb-` prefix (internal; not\nrenamed). Package defaults follow Aimee (Blue CTA `#0066F7`, Ink headings\n`#050A1C`). Load Quicksand yourself if you want the heading stack to render;\nthis package only sets the family name. Merchant `themeSettings` overrides stay\nthe same. Defaults ship inside `@layer aimee-blocks`, so **unlayered** rules in\nyour stylesheet always win -- no `:root:root` or `!important` needed:\n\n```css\n:root {\n  --zb-font-family: 'Inter', sans-serif;\n  --zb-font-heading: 'Playfair Display', serif;\n  --zb-heading-color: #111;\n  --zb-cta-bg: #0066f7;\n  --zb-cta-color: #fff;\n  --zb-spacing-md: 2rem;\n  --zb-border-radius: 0.25rem;\n  /* Gradient text: set BOTH tokens or text becomes invisible */\n  --zb-headline-gradient: linear-gradient(90deg, #0066f7, #fb74c6);\n  --zb-headline-text-fill: transparent;\n}\n```\n\nFull token list:\n\n- Typography: `--zb-font-family`, `--zb-font-heading`, `--zb-font-size-base`,\n  `--zb-line-height-base`\n- Headline gradient: `--zb-headline-gradient`, `--zb-headline-text-fill` (pair;\n  default no-op)\n- Colors: `--zb-heading-color`, `--zb-text-color`, `--zb-link-color`,\n  `--zb-border-color`, `--zb-bg-muted`\n- CTA: `--zb-cta-bg`, `--zb-cta-color`, `--zb-cta-hover-bg`, `--zb-cta-hover-color`,\n  `--zb-cta-border-color`, `--zb-cta-radius`, `--zb-cta-padding`,\n  `--zb-cta-font-weight`, `--zb-cta-text-transform`\n- Hero CTA (defaults to matching `--zb-cta-*`): `--zb-hero-cta-bg`,\n  `--zb-hero-cta-color`, `--zb-hero-cta-hover-bg`, `--zb-hero-cta-hover-color`,\n  `--zb-hero-cta-border-color`, `--zb-hero-cta-radius`, `--zb-hero-cta-padding`,\n  `--zb-hero-cta-font-weight`, `--zb-hero-cta-text-transform`\n- Spacing: `--zb-spacing-xs`, `--zb-spacing-sm`, `--zb-spacing-md`, `--zb-spacing-lg`,\n  `--zb-spacing-xl`\n- Layout: `--zb-max-width`, `--zb-border-radius`\n- Code: `--zb-code-bg`, `--zb-code-border`, `--zb-code-font`\n- Quote: `--zb-quote-border-color`, `--zb-quote-bg`\n- Overlay: `--zb-overlay-bg`\n- Product card: `--zb-product-card-shadow`, `--zb-product-card-hover-shadow`\n- Hero: `--zb-hero-min-height-small|medium|large`, `--zb-hero-overlay-color`,\n  `--zb-hero-text-color`, `--zb-hero-bg`\n- Promo banner: `--zb-promo-banner-bg`, `--zb-promo-banner-color`\n- Card grid: `--zb-card-grid-bg`, `--zb-card-grid-shadow`, `--zb-card-grid-hover-shadow`,\n  `--zb-card-grid-title-size` (1.125rem), `--zb-card-grid-body-size` (0.9375rem)\n- Comparison table: `--zb-table-header-bg`, `--zb-table-highlight-bg`\n- Video lightbox: `--zb-play-button-bg`, `--zb-play-button-color`\n\nHero content also supports optional `eyebrow` (plain text above the headline).\n\nAll classes are BEM with the `zb-` namespace (`.zb-heading`, `.zb-product-card__price`).\n\n## Security\n\nHTML content (TextBlock, EmbedBlock) is sanitized with sanitize-html before\nrendering (CJS-safe on Vercel Node; no jsdom). EmbedBlock allows iframes through sanitization; VideoBlock, FormEmbedBlock,\nVideoLightboxBlock, and hero background media only render http(s) URLs. Card grid,\ncomparison table, hero, and promo banner content is plain text -- no new HTML\nsanitization surface.\n","readmeFilename":"README.md"}