Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ jobs:
- name: Install
run: npm ci

- name: Unit tests
run: npm test

- name: Lint
run: npm run lint

- name: Build
run: npm run build

- name: Install browser for layout gates
run: npx playwright install --with-deps chromium

- name: Cross-viewport layout gates
run: npm run test:e2e
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"build": "npm run sitemap && tsc -b && vite build",
"lint": "oxlint src",
"test": "node --experimental-strip-types --test tests/*.test.mjs",
"test:e2e": "playwright test",
"preview": "vite preview",
"import:bsr": "node scripts/bsr/import-bsr.mjs && node scripts/bsr/fill-quota.mjs",
"fill:quota": "node scripts/bsr/fill-quota.mjs",
Expand All @@ -26,6 +27,7 @@
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
Expand Down
22 changes: 22 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'

export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 8_000 },
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4175',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4175',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
url: 'http://127.0.0.1:4175',
},
})
122 changes: 82 additions & 40 deletions src/components/AdaptiveContentBalloons.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,30 @@
import { useMemo, type CSSProperties } from 'react'
import type { BalloonPlan, BalloonSlot, EditorialType } from '../lib/balloonPlan'
import { Leaf, Lightbulb, Sparkles } from 'lucide-react'
import { useMemo } from 'react'
import type { BalloonPlan, EditorialType } from '../lib/balloonPlan'
import { contentBalloonCopy } from '../lib/contentBalloonContent'
import type { ContentBalloonDeck } from '../hooks/useAdaptiveContentBalloons'

const LABELS: Record<EditorialType, string> = {
did_you_know: 'Did you know?',
fun_fact: 'Fun fact',
care_tip: 'Care tip',
design_note: 'Design note',
material_myth: 'Material myth',
nature_note: 'Nature note',
care_tip: 'Care note',
design_note: 'Design detail',
material_myth: 'Material check',
nature_note: 'From the grove',
culture_craft: 'Craft & culture',
}

function slotStyle(slot: BalloonSlot): CSSProperties {
if (slot.size === 'responsive') {
return { minHeight: slot.minHeight, overflow: 'hidden', width: '100%' }
}
const [width, height] = slot.size.split('x').map(Number)
return { height, marginInline: 'auto', maxWidth: '100%', overflow: 'hidden', width }
}

function sizeFamily(size: BalloonSlot['size']) {
if (size === 'responsive') return 'fluid'
if (size === '728x90') return 'banner'
if (size === '160x600') return 'rail'
if (size === '320x100') return 'strip'
return 'card'
}

type AdaptiveContentBalloonProps = {
anchor: string
className?: string
deck: ContentBalloonDeck
plan: BalloonPlan
}

/**
* Host-native rendering is the safety boundary: Conbal selects the fact while
* iBamboo owns markup, typography, spacing, breakpoints, and accessibility.
*/
export function AdaptiveContentBalloon({
anchor,
className = '',
Expand All @@ -45,27 +36,78 @@ export function AdaptiveContentBalloon({
[anchor, plan.slots],
)
const item = deck[anchor]
if (!slot || !item || !item.editorial_type) return null
const family = sizeFamily(slot.size)
const layout = slot.layout || 'inline'
const copy = useMemo(() => item ? contentBalloonCopy(item) : null, [item])
if (!slot || !item || !item.editorial_type || !copy) return null

const label = LABELS[item.editorial_type]
const shared = {
'data-balloon-anchor': anchor,
'data-balloon-budget': slot.budget,
'data-balloon-role': slot.role,
'data-balloon-section': slot.section,
'data-content-balloon': item.slug,
'data-editorial-type': item.editorial_type,
}

if (slot.role === 'section-break') {
return (
<section
{...shared}
aria-label={slot.ariaLabel}
className={`content-balloon overflow-hidden rounded-2xl border border-bamboo/20 bg-[linear-gradient(118deg,#f6f2e8_0%,#f6f2e8_66%,#e4edd9_66%,#e4edd9_100%)] ${className}`.trim()}
>
<div className="grid gap-5 px-5 py-6 sm:grid-cols-[minmax(13rem,0.8fr)_minmax(16rem,1.2fr)] sm:items-center sm:px-8">
<div>
<p className="mb-2 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.16em] text-bamboo">
<Sparkles aria-hidden="true" className="size-3.5" /> {label}
</p>
<h3 className="font-display text-2xl font-semibold leading-tight text-ink sm:text-3xl">
{copy.headline}
</h3>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-ink-soft sm:text-base">
{copy.body}
</p>
</div>
</section>
)
}

if (slot.role === 'aside-note') {
return (
<section
{...shared}
aria-label={slot.ariaLabel}
className={`content-balloon rounded-2xl border border-line bg-card p-5 shadow-[0_16px_40px_-34px_rgba(18,26,18,0.45)] sm:p-6 ${className}`.trim()}
>
<p className="mb-3 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.16em] text-bamboo">
<Leaf aria-hidden="true" className="size-3.5" /> {label}
</p>
<h3 className="font-display text-xl font-semibold leading-tight text-ink">
{copy.headline}
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-soft">{copy.body}</p>
</section>
)
}

return (
<aside
<section
{...shared}
aria-label={slot.ariaLabel}
className={`content-balloon content-balloon--${family} content-balloon--layout-${layout} ${className}`.trim()}
data-content-balloon={item.slug}
data-editorial-type={item.editorial_type}
data-layout={layout}
data-size={slot.size}
data-size-family={family}
className={`content-balloon border-y border-bamboo/20 bg-bamboo/[0.045] px-1 py-5 sm:px-5 sm:py-6 ${className}`.trim()}
>
<p className="mb-2 text-[10px] font-bold uppercase tracking-[0.16em] text-bamboo">
{LABELS[item.editorial_type]}
</p>
<div
style={slotStyle(slot)}
dangerouslySetInnerHTML={{ __html: `<style>${item.css || ''}</style>${item.html}` }}
/>
</aside>
<div className="grid gap-3 sm:grid-cols-[minmax(12rem,0.7fr)_minmax(16rem,1.3fr)] sm:items-center sm:gap-8">
<div>
<p className="mb-1.5 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.16em] text-bamboo">
<Lightbulb aria-hidden="true" className="size-3.5" /> {label}
</p>
<h3 className="font-display text-xl font-semibold leading-tight text-ink sm:text-2xl">
{copy.headline}
</h3>
</div>
<p className="text-sm leading-relaxed text-ink-soft">{copy.body}</p>
</div>
</section>
)
}
17 changes: 7 additions & 10 deletions src/components/ProductEnrichment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ function FaqItem({ q, a }: { q: string; a: string }) {
}

export function ProductEnrichmentSections({
editorialNote,
enrichment,
guideNote,
reviewNote,
}: {
editorialNote?: ReactNode
enrichment: ProductEnrichment
guideNote?: ReactNode
reviewNote?: ReactNode
}) {
const { reviewSnapshot: r, blog, faq, setupTips, researchNotes } = enrichment

Expand Down Expand Up @@ -147,14 +149,7 @@ export function ProductEnrichmentSections({
</div>
</section>

{editorialNote ? (
<section
aria-label="Bamboo field note"
className="rounded-3xl border border-line bg-card p-5 shadow-[0_18px_50px_-38px_rgba(18,26,18,0.4)] sm:p-7"
>
{editorialNote}
</section>
) : null}
{reviewNote}

<section aria-labelledby="item-blog-heading">
<div className="mb-6">
Expand Down Expand Up @@ -182,6 +177,8 @@ export function ProductEnrichmentSections({
</article>
</section>

{guideNote}

{setupTips && setupTips.length > 0 ? (
<section aria-labelledby="setup-tips-heading">
<div className="flex items-center gap-2 mb-5">
Expand Down
Loading
Loading