kintex/src/frontend/src/screens/design/BoothDesignStudioPage.tsx
zio 0f91dcf4ae feat(ai): ReRoomAI image-to-image booth photo restyling (M5 pipeline reuse)
Reference photo upload (magic-byte check, 10MB, server-named files) +
reroom render job on the existing RenderJob queue/callback/WS pipeline.
Worker gains R1 reroom mode: 4-part prompt with fixed preserve-lock
(architecture + camera perspective) and style dictionary (6 styles,
JSON single source); NL instruction injects into replace-part only.
Booth Design Studio gets a photo-mockup section with client 1024px
downscale, style cards and Before/After slider (watermark enforced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:25:23 +09:00

795 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ApiRequestError } from '../../api/client';
import { designApi, renderApi } from '../../api/endpoints';
import { subscribeRenderJob } from '../../api/websocket';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { AiImage } from '../../components/ui/AiImage';
import { CompareSlider } from '../../components/ui/CompareSlider';
import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types';
import { AFTER_PLACEHOLDER, AFTER_SAMPLE, BEFORE_EMPTY_HALL, MOCK_SHOT_IMG } from './placeholders';
import {
REROOM_STYLES,
downscaleImage,
reroomApi,
MAX_UPLOAD_BYTES,
} from './reroomApi';
import './studio.css';
/*
* SCR-06 부스 설계 스튜디오 (M3 스펙 + M5 렌더). Stitch booth_design_studio 이식.
* - 좌: 스펙 아코디언 폼 → precheck(완성 API)로 사전검증. 중앙: 4샷 그리드(S1~S4) + Before/After(S5).
* - "예상 사진 생성" → render POST(완성 큐잉) → WebSocket /topic/render/{jobId} 로 타일 교체.
*/
const SHOTS = [
{ preset: 'S1', label: 'S1 부스 정면' },
{ preset: 'S2', label: 'S2 야간 점등' },
{ preset: 'S3', label: 'S3 통로 뷰' },
{ preset: 'S4', label: 'S4 부스 내부' },
] as const;
const DEFAULT_SPEC: DesignSpec = {
boothType: 'independent',
industry: '로봇/제조',
budget: 30000000,
zones: [
{ type: 'demo', ratioPercent: 40 },
{ type: 'consult', ratioPercent: 30 },
{ type: 'reception', ratioPercent: 20 },
{ type: 'storage', ratioPercent: 10 },
],
wallHeightM: 4.2,
signageText: '한빛로보틱스',
rigging: { use: true, heightM: 7.0 },
mezzanineAreaRatio: 0.3,
materials: [{ part: 'wall', finish: 'matte_white', fireRetardant: true }],
lightingMode: 'night',
usesDesignatedLightingOnly: true,
};
type Section = 'basic' | 'zones' | 'structure' | 'material' | 'lighting';
export function BoothDesignStudioPage() {
const { eventId = '', boothId = '' } = useParams();
const navigate = useNavigate();
const [spec, setSpec] = useState<DesignSpec>(DEFAULT_SPEC);
const [open, setOpen] = useState<Record<Section, boolean>>({
basic: true,
zones: false,
structure: true,
material: false,
lighting: false,
});
const [report, setReport] = useState<ComplianceReport | null>(null);
const [prechecking, setPrechecking] = useState(false);
const [jobs, setJobs] = useState<Record<string, RenderJobDto>>({}); // preset → job
const [generating, setGenerating] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmAgree, setConfirmAgree] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
// ── 사진 시안(ReRoom, image-to-image) — 소유자 지시 13.2 ──
const fileInputRef = useRef<HTMLInputElement>(null);
const [refPreview, setRefPreview] = useState<string | null>(null); // 로컬 objectURL(Before)
const [refId, setRefId] = useState<string | null>(null); // 서버 참조 이미지 id
const [refUploading, setRefUploading] = useState(false);
const [reroomStyle, setReroomStyle] = useState<string>('modern');
const [reroomInstruction, setReroomInstruction] = useState('');
const [reroomJob, setReroomJob] = useState<RenderJobDto | null>(null);
const [reroomBusy, setReroomBusy] = useState(false);
const [reroomError, setReroomError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
// 초기 설계안 로드(501이면 degraded 기본 스펙 유지).
useEffect(() => {
let alive = true;
designApi
.get(eventId, boothId)
.then((d) => alive && setSpec(d.spec))
.catch(() => {
/* 501/미존재 → 기본 스펙 시연 */
});
return () => {
alive = false;
};
}, [eventId, boothId]);
// 발행된 렌더잡 WebSocket 구독.
useEffect(() => {
const ids = Object.values(jobs)
.map((j) => j.jobId)
.filter(Boolean);
if (ids.length === 0) return;
const unsubs = ids.map((id) =>
subscribeRenderJob(id, (job) => {
setJobs((prev) => ({ ...prev, [job.shotPreset]: job }));
}),
);
return () => unsubs.forEach((u) => u());
}, [jobs]);
// 사진 시안 잡: WebSocket 구독 + 폴백 폴링(완료/실패까지).
useEffect(() => {
const jobId = reroomJob?.jobId;
if (!jobId || reroomJob?.status === 'DONE' || reroomJob?.status === 'FAILED') return;
const unsub = subscribeRenderJob(jobId, (job) => setReroomJob(job));
const timer = window.setInterval(async () => {
try {
setReroomJob(await reroomApi.status(eventId, jobId));
} catch {
/* 폴백 폴링 실패는 무시(WebSocket 우선) */
}
}, 4000);
return () => {
unsub();
window.clearInterval(timer);
};
}, [reroomJob?.jobId, reroomJob?.status, eventId]);
// 언마운트 시 미리보기 objectURL 정리.
useEffect(() => {
return () => {
if (refPreview) URL.revokeObjectURL(refPreview);
};
}, [refPreview]);
// 참조 이미지 선택 → 1024px 다운스케일 → 즉시 업로드(referenceId 확보).
const handleRefFile = useCallback(
async (file: File | undefined | null) => {
if (!file) return;
setReroomError(null);
if (!file.type.startsWith('image/')) {
setReroomError('이미지 파일만 업로드할 수 있습니다.');
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setReroomError('참조 이미지는 10MB 이하만 업로드할 수 있습니다.');
return;
}
setRefUploading(true);
setReroomJob(null);
setRefId(null);
try {
const blob = await downscaleImage(file);
setRefPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
const res = await reroomApi.uploadReference(eventId, boothId, blob);
setRefId(res.referenceId);
} catch (err) {
setReroomError(
err instanceof ApiRequestError ? err.message : '참조 이미지 업로드에 실패했습니다.',
);
} finally {
setRefUploading(false);
}
},
[eventId, boothId],
);
const generateReroom = useCallback(async () => {
if (!refId) {
setReroomError('먼저 빈 부스/공간 사진을 업로드하세요.');
return;
}
setReroomBusy(true);
setReroomError(null);
try {
const job = await reroomApi.render(eventId, boothId, {
referenceId: refId,
style: reroomStyle,
instruction: reroomInstruction.trim() || undefined,
});
setReroomJob(job);
} catch (err) {
if (err instanceof ApiRequestError && err.code === 'RENDER_QUOTA_EXCEEDED') {
setReroomError('행사 이미지 생성 쿼터가 소진되었습니다.');
} else {
setReroomError(err instanceof ApiRequestError ? err.message : '시안 생성 중 오류가 발생했습니다.');
}
} finally {
setReroomBusy(false);
}
}, [eventId, boothId, refId, reroomStyle, reroomInstruction]);
const runPrecheck = useCallback(async () => {
setPrechecking(true);
setNotice(null);
try {
const r = await designApi.precheck(eventId, boothId, spec);
setReport(r);
} catch (err) {
setNotice(
err instanceof ApiRequestError ? err.message : '사전 검증 중 오류가 발생했습니다.',
);
} finally {
setPrechecking(false);
}
}, [eventId, boothId, spec]);
const buildScene = useCallback(
(): RenderJobRequest['scene'] => ({
hall: { id: 'H7', dims_m: [126, 90], ceiling_m: 12 },
booth: { id: boothId || 'A-102', size_m: [6, 3], type: spec.boothType },
design: { signage: { text: spec.signageText } },
lighting: { mode: spec.lightingMode },
}),
[boothId, spec.boothType, spec.signageText, spec.lightingMode],
);
const generateShots = useCallback(async () => {
setGenerating(true);
setNotice(null);
const scene = buildScene();
// S1·S2를 우선 발행(S3·S4는 온디맨드 타일에서 개별 생성).
for (const preset of ['S1', 'S2'] as const) {
try {
const job = await renderApi.create(eventId, boothId, { shotPreset: preset, scene });
setJobs((prev) => ({ ...prev, [preset]: job }));
} catch (err) {
if (err instanceof ApiRequestError && err.code === 'RENDER_QUOTA_EXCEEDED') {
setNotice('행사 이미지 생성 쿼터가 소진되었습니다.');
break;
}
if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') {
setNotice('렌더 이력 저장이 준비 중입니다(큐잉만 동작).');
} else {
setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.');
}
}
}
setGenerating(false);
// 병행: precheck 갱신
void runPrecheck();
}, [eventId, boothId, buildScene, runPrecheck]);
const generateSingle = useCallback(
async (preset: string) => {
setNotice(null);
try {
const job = await renderApi.create(eventId, boothId, {
shotPreset: preset,
scene: buildScene(),
});
setJobs((prev) => ({ ...prev, [preset]: job }));
} catch (err) {
setNotice(err instanceof ApiRequestError ? err.message : '생성 중 오류가 발생했습니다.');
}
},
[eventId, boothId, buildScene],
);
const s1 = jobs.S1;
const afterReal = s1?.status === 'DONE' && s1.imageUrl ? s1.imageUrl : null;
const afterSrc = afterReal ?? AFTER_SAMPLE;
const passCount = report?.passCount ?? 0;
const warnCount = report?.warnCount ?? 0;
const blockCount = report?.blockCount ?? 0;
const submittable = report?.submittable ?? false;
return (
<div className="kx-studio">
{/* 상단 컨텍스트 바 */}
<header className="kx-studio__head">
<div className="kx-studio__ctx">
<button
type="button"
className="kx-studio__back"
aria-label="뒤로"
onClick={() => navigate(-1)}
>
</button>
<div>
<h2> {boothId || 'A-102'} · 6×3m</h2>
<span className="kx-studio__ctx-sub"> 5m</span>
</div>
<span className="kx-studio__ctx-div" aria-hidden="true" />
<select
className="kx-studio__ver-select"
aria-label="설계 버전"
defaultValue="v2"
>
<option value="v2"> v2</option>
<option value="v1"> v1</option>
</select>
</div>
<Button variant="ai" leadingIcon="✦" onClick={generateShots} disabled={generating}>
{generating ? '생성 요청 중…' : '예상 사진 생성'}
</Button>
</header>
<div className="kx-studio__body">
{/* 좌: 스펙 아코디언 */}
<aside className="kx-studio__spec" aria-label="설계 스펙">
<Accordion
title="기본 정보"
open={open.basic}
onToggle={() => setOpen((o) => ({ ...o, basic: !o.basic }))}
>
<Field label="업종">
<input
value={spec.industry}
onChange={(e) => setSpec((s) => ({ ...s, industry: e.target.value }))}
/>
</Field>
<Field label="예산 (₩)">
<input
type="number"
className="tnum"
value={spec.budget}
onChange={(e) => setSpec((s) => ({ ...s, budget: Number(e.target.value) }))}
/>
</Field>
</Accordion>
<Accordion
title="공간 구성"
open={open.zones}
onToggle={() => setOpen((o) => ({ ...o, zones: !o.zones }))}
>
{spec.zones.map((z, i) => (
<Field key={z.type} label={ZONE_LABEL[z.type] ?? z.type}>
<div className="kx-studio__ratio">
<input
type="range"
min={0}
max={100}
value={z.ratioPercent}
aria-label={`${ZONE_LABEL[z.type] ?? z.type} 비율`}
onChange={(e) =>
setSpec((s) => ({
...s,
zones: s.zones.map((x, j) =>
j === i ? { ...x, ratioPercent: Number(e.target.value) } : x,
),
}))
}
/>
<span className="tnum">{z.ratioPercent}%</span>
</div>
</Field>
))}
</Accordion>
<Accordion
title="구조"
open={open.structure}
onToggle={() => setOpen((o) => ({ ...o, structure: !o.structure }))}
>
<Field label={`벽체 높이 (${spec.wallHeightM}m · 최대 5m)`}>
<input
type="range"
min={2}
max={6}
step={0.1}
value={spec.wallHeightM}
aria-label="벽체 높이"
onChange={(e) => setSpec((s) => ({ ...s, wallHeightM: Number(e.target.value) }))}
/>
{spec.wallHeightM > 5 && (
<p className="kx-studio__inline-warn"> 5m </p>
)}
</Field>
<Field label="간판 문구">
<input
value={spec.signageText}
onChange={(e) => setSpec((s) => ({ ...s, signageText: e.target.value }))}
/>
</Field>
<label className="kx-studio__toggle">
<input
type="checkbox"
checked={spec.rigging.use}
onChange={(e) =>
setSpec((s) => ({ ...s, rigging: { ...s.rigging, use: e.target.checked } }))
}
/>
/
</label>
{spec.rigging.use && (
<p className="kx-studio__inline-warn">
6.5~8.5m: 구조계산서 D-7
</p>
)}
</Accordion>
<Accordion
title="자재"
open={open.material}
onToggle={() => setOpen((o) => ({ ...o, material: !o.material }))}
>
<Field label="벽체 마감">
<select
value={spec.materials[0]?.finish}
onChange={(e) =>
setSpec((s) => ({
...s,
materials: [{ ...s.materials[0], finish: e.target.value }],
}))
}
>
<option value="matte_white"> </option>
<option value="wood"></option>
<option value="metal"></option>
</select>
</Field>
<label className="kx-studio__toggle kx-studio__toggle--req">
<input
type="checkbox"
checked={spec.materials[0]?.fireRetardant ?? false}
onChange={(e) =>
setSpec((s) => ({
...s,
materials: [{ ...s.materials[0], fireRetardant: e.target.checked }],
}))
}
/>
()
</label>
</Accordion>
<Accordion
title="조명"
open={open.lighting}
onToggle={() => setOpen((o) => ({ ...o, lighting: !o.lighting }))}
>
<div className="kx-studio__seg" role="radiogroup" aria-label="조명 모드">
{(['day', 'night'] as const).map((m) => (
<button
key={m}
role="radio"
aria-checked={spec.lightingMode === m}
className={spec.lightingMode === m ? 'is-active' : ''}
onClick={() => setSpec((s) => ({ ...s, lightingMode: m }))}
>
{m === 'day' ? '주간' : '야간'}
</button>
))}
</div>
</Accordion>
</aside>
{/* 중앙: 4샷 그리드 + Before/After */}
<section className="kx-studio__hero">
<div className="kx-studio__grid">
{SHOTS.map((shot) => {
const job = jobs[shot.preset];
return (
<div className="kx-studio__tile" key={shot.preset}>
{job ? (
<AiImage
imageUrl={job.imageUrl}
status={job.status}
watermarkText={job.watermarkText}
notice={job.notice}
shotLabel={shot.label}
versionTag="설계 v2"
onRetry={() => generateSingle(shot.preset)}
alt={`${shot.label} 예상 이미지`}
/>
) : MOCK_SHOT_IMG[shot.preset] ? (
<AiImage
imageUrl={MOCK_SHOT_IMG[shot.preset]}
status="DONE"
watermarkText="AI 생성 예상 이미지"
notice="AI 예시 이미지 — 실제 부스 렌더는 ‘이 뷰 생성’으로 요청하세요. 시공 기준은 도면입니다."
shotLabel={shot.label}
versionTag="설계 v2"
sample
onGenerate={() => generateSingle(shot.preset)}
alt={`${shot.label} 예시 이미지`}
/>
) : (
<div className="kx-studio__tile-empty">
<div className="kx-studio__tile-icon" aria-hidden="true"></div>
<span className="kx-studio__tile-label">{shot.label}</span>
<button onClick={() => generateSingle(shot.preset)}> </button>
</div>
)}
</div>
);
})}
</div>
{/* Before/After 슬라이더 (S5) */}
<div className="kx-studio__compare">
<div className="kx-studio__compare-head">
<AiLabel>S5 Before / After</AiLabel>
{!afterReal && <span className="kx-studio__compare-note"> · S1 </span>}
</div>
<CompareSlider
beforeSrc={BEFORE_EMPTY_HALL}
afterSrc={afterSrc || AFTER_PLACEHOLDER}
beforeLabel="시공 전"
afterLabel="시공 후"
afterOverlay={
<>
<div className="kx-studio__wm" aria-hidden="true">
{s1?.watermarkText ?? 'AI 생성 예상 이미지'}
</div>
<span className="kx-studio__after-ai">
<AiLabel>{afterReal ? 'AI 생성' : '예시'}</AiLabel>
</span>
</>
}
/>
<p className="kx-studio__compare-notice">
{s1?.notice ??
'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'}
</p>
</div>
</section>
</div>
{/* 사진 시안(AI) — ReRoomAI 방식 image-to-image (소유자 지시 13.2) */}
<section className="kx-reroom" aria-label="사진 시안 (AI)">
<div className="kx-reroom__head">
<AiLabel> (AI)</AiLabel>
<p className="kx-reroom__sub">
· · .
, .
</p>
</div>
<div className="kx-reroom__body">
{/* 01 업로드 */}
<div className="kx-reroom__col">
<span className="kx-reroom__step">01 </span>
<div
className={`kx-reroom__drop${dragOver ? ' is-over' : ''}${
refPreview ? ' has-img' : ''
}`}
role="button"
tabIndex={0}
aria-label="참조 이미지 업로드"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void handleRefFile(e.dataTransfer.files?.[0]);
}}
>
{refPreview ? (
<img src={refPreview} alt="참조 이미지 미리보기" className="kx-reroom__preview" />
) : (
<span className="kx-reroom__drop-hint">
<span className="kx-reroom__drop-plus" aria-hidden="true">
</span>
<small>JPG · PNG · WebP · 10MB </small>
</span>
)}
{refUploading && (
<span className="kx-reroom__uploading" aria-live="polite">
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => void handleRefFile(e.target.files?.[0])}
/>
</div>
{/* 02 스타일 + 03 지시 + 생성 */}
<div className="kx-reroom__col kx-reroom__col--wide">
<span className="kx-reroom__step">02 </span>
<div className="kx-reroom__styles" role="radiogroup" aria-label="시안 스타일">
{REROOM_STYLES.map((s) => (
<button
key={s.id}
type="button"
role="radio"
aria-checked={reroomStyle === s.id}
className={`kx-reroom__style${reroomStyle === s.id ? ' is-active' : ''}`}
onClick={() => setReroomStyle(s.id)}
>
<span className="kx-reroom__swatch" aria-hidden="true">
{s.swatch.map((c, i) => (
<i key={i} style={{ background: c }} />
))}
</span>
{s.label}
</button>
))}
</div>
<span className="kx-reroom__step">03 ()</span>
<textarea
className="kx-reroom__instruction"
rows={2}
maxLength={500}
placeholder="예: 파란색 브랜드 월과 로봇 데모 존을 강조해줘"
value={reroomInstruction}
onChange={(e) => setReroomInstruction(e.target.value)}
/>
<div className="kx-reroom__actions">
<Button
variant="ai"
leadingIcon="✦"
onClick={generateReroom}
disabled={!refId || refUploading || reroomBusy}
>
{reroomBusy ? '시안 생성 요청 중…' : '사진 시안 생성'}
</Button>
{reroomError && (
<p className="kx-reroom__err" role="status">
{reroomError}
</p>
)}
</div>
</div>
</div>
{/* 결과 Before/After */}
{reroomJob && (
<div className="kx-reroom__result">
{reroomJob.status === 'DONE' && reroomJob.imageUrl && refPreview ? (
<CompareSlider
beforeSrc={refPreview}
afterSrc={reroomJob.imageUrl}
beforeLabel="업로드(빈 공간)"
afterLabel="AI 시안"
afterOverlay={
<>
<div className="kx-studio__wm" aria-hidden="true">
{reroomJob.watermarkText}
</div>
<span className="kx-studio__after-ai">
<AiLabel>AI </AiLabel>
</span>
</>
}
/>
) : (
<AiImage
imageUrl={reroomJob.imageUrl}
status={reroomJob.status}
watermarkText={reroomJob.watermarkText}
notice={reroomJob.notice}
shotLabel="사진 시안"
onRetry={generateReroom}
alt="사진 시안 예상 이미지"
/>
)}
<p className="kx-reroom__notice">{reroomJob.notice}</p>
</div>
)}
</section>
{/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */}
<footer className="kx-studio__footer">
<div className="kx-studio__precheck">
<Button variant="ghost" onClick={runPrecheck} disabled={prechecking}>
{prechecking ? '검증 중…' : '규정 사전검증'}
</Button>
{report ? (
<div className="kx-studio__chips tnum">
<span className="kx-chip kx-chip--pass"> {passCount}</span>
{warnCount > 0 && <span className="kx-chip kx-chip--warn"> {warnCount}</span>}
{blockCount > 0 && <span className="kx-chip kx-chip--block"> {blockCount}</span>}
{report.violations.some((v) => v.requiresDocument) && (
<span className="kx-chip kx-chip--doc"> D-7 </span>
)}
</div>
) : (
<span className="kx-studio__precheck-hint"> </span>
)}
</div>
<Button disabled={!!report && !submittable} onClick={() => setConfirmOpen(true)}>
</Button>
</footer>
{notice && (
<p className="kx-studio__banner" role="status">
{notice}
</p>
)}
{/* 컨펌 모달 — "시공 기준은 도면입니다" 동의 필수 */}
{confirmOpen && (
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div className="kx-modal__backdrop" onClick={() => setConfirmOpen(false)} />
<div className="kx-studio__confirm">
<h3 id="confirm-title"> </h3>
<p>
. AI
.
</p>
<label className="kx-studio__toggle kx-studio__toggle--req">
<input
type="checkbox"
checked={confirmAgree}
onChange={(e) => setConfirmAgree(e.target.checked)}
/>
()
</label>
<div className="kx-modal__actions">
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
</Button>
<Button
disabled={!confirmAgree}
onClick={() => {
setConfirmOpen(false);
setNotice('컨펌 요청이 전송되었습니다.');
}}
>
</Button>
</div>
</div>
</div>
)}
</div>
);
}
const ZONE_LABEL: Record<string, string> = {
demo: '시연존',
consult: '상담존',
reception: '안내데스크',
storage: '창고',
};
function Accordion({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<section className="kx-acc">
<button
className="kx-acc__head"
aria-expanded={open}
onClick={onToggle}
type="button"
>
<span>{title}</span>
<span className="kx-acc__chevron" aria-hidden="true">
{open ? '▾' : '▸'}
</span>
</button>
{open && <div className="kx-acc__body">{children}</div>}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="kx-field-block">
<span className="kx-field-block__label">{label}</span>
{children}
</label>
);
}