
Modular Design
- 1 installs
- Updated June 12, 2026
- byumin/2.0-modular
modular-design is a Claude skill that generates branded interfaces and assets for the Modular FastAPI + React assessment app using its design guidelines and admin UI kit.
About
This skill provides design guidelines, color palettes, type, fonts, and an admin UI kit for building branded interfaces for the Modular (인싸이트 2.0) FastAPI plus React assessment-operations web app. It defines two separate palettes for admin and test-taker surfaces that must not mix. A developer or designer uses it to produce production UI or throwaway prototypes on-brand.
- Generates branded interfaces and assets for the Modular (인싸이트 2.0) FastAPI + React assessment app
- Two non-mixing palettes: admin HSL Tailwind tokens and assessment hex teal (#175e63)
- Ships an admin UI kit with Button/Input/Select/Badge/Card/Checkbox primitives and Tabler icons
Modular Design by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,609 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
modular-design capabilities & compatibility
- Capabilities
- ui design · frontend
- Use cases
- ui design · web design · frontend
- Pricing
- Free
What modular-design says it does
Contains essential design guidelines, colors, type, fonts, and admin UI kit components.
The Modular system has **two parallel palettes that never mix**:
The admin UI kit at `ui_kits/admin/` is the reference implementation
npx skills add https://github.com/byumin/2.0-modular --skill modular-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | June 12, 2026 |
| Repository | byumin/2.0-modular ↗ |
What it does
Design on-brand admin or assessment UI for the Modular FastAPI + React app using its palettes and UI kit.
Who is it for?
producing on-brand Modular admin or assessment screens and prototypes
Skip if: unrelated projects or mixing the admin and assessment palettes
When should I use this skill?
building or designing admin or assessment UI for the Modular app
What you get
Produces on-brand Modular admin or assessment UI as HTML artifacts or production code.
- branded HTML artifacts or production UI code
By the numbers
- 2 parallel palettes that never mix
Files
Read the README.md file within this skill, and explore the other available files (colors_and_type.css, ui_kits/admin/*, preview/*).
The Modular system has two parallel palettes that never mix:
- Admin (HSL Tailwind tokens — primary blue
hsl(215 70% 35%)) - Assessment (hex teal palette —
#175e63)
For admin UI work (검사 운영, 내담자 관리, etc.) use Tailwind theme tokens. For 수검자 (test-taker) UI use the assessment hex palette. Never combine them on one screen.
If creating visual artifacts (slides, mocks, throwaway prototypes), copy assets out and create static HTML files for the user to view. The admin UI kit at ui_kits/admin/ is the reference implementation — primitives.jsx covers Button/Input/Select/Badge/Card/Checkbox + Tabler icons.
If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. The source-of-truth design doc in the codebase is 2.0-modular/DESIGN.md.
If the user invokes this skill without other guidance, ask them what they want to build/design, ask some questions (admin or assessment surface? new screen or modify existing?), and act as an expert designer who outputs HTML artifacts or production code, depending on the need.
/* ============================================================
Modular Design System — Tokens
Source of truth: 2.0-modular/frontend/src/index.css + DESIGN.md
Two parallel palettes: Admin (HSL Tailwind tokens) + Assessment (hex teal).
They never mix in a single screen.
============================================================ */
/* Pretendard — primary Korean/Latin UI font */
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
:root {
/* ---- Admin: core surfaces ------------------------------------ */
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 84% 4.9%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
/* ---- Admin: brand & interaction ------------------------------ */
--color-primary: hsl(215 70% 35%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-ring: hsl(215 70% 35%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
/* ---- Admin: neutrals & lines --------------------------------- */
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
/* ---- Admin: sidebar ------------------------------------------ */
--color-sidebar: hsl(0 0% 98%);
--color-sidebar-foreground: hsl(222.2 84% 4.9%);
--color-sidebar-border: hsl(214.3 31.8% 91.4%);
--color-sidebar-accent: hsl(210 40% 93%);
--color-sidebar-accent-foreground: hsl(222.2 47.4% 11.2%);
/* ---- Admin: status (Tailwind palette) ------------------------ */
--color-success-bg: hsl(141 78.9% 85.1%); /* green-100 */
--color-success-fg: hsl(142 76% 26%); /* green-700 */
--color-warning-bg: hsl(48 96% 89%); /* yellow-100 */
--color-warning-fg: hsl(35 91% 33%); /* yellow-700 */
/* ---- Assessment: teal palette (hard-coded, hex) -------------- */
--color-assess-deep: #175e63;
--color-assess-deep-hover: #124b4f;
--color-assess-text: #161d1b;
--color-assess-text-2: #5f6f73;
--color-assess-text-3: #8a9a96;
--color-assess-panel-1: #f3f5f4;
--color-assess-panel-2: #f5f7fa;
--color-assess-panel-3: #fafbfc;
--color-assess-border-1: #dfe5e3;
--color-assess-border-2: #e2e8f0;
--color-assess-border-3: #e8ebee;
--color-assess-cyan: #5ce1e6;
/* ---- Radius -------------------------------------------------- */
--radius: 0.75rem; /* 12px — base */
--radius-md: 0.375rem; /* 6px — buttons, badges, inputs */
--radius-lg: 0.5rem; /* 8px — tool panels */
--radius-xl: 0.75rem; /* 12px — cards */
/* ---- Spacing scale (8pt with 4 + 12 inserts) ---------------- */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
/* ---- Layout -------------------------------------------------- */
--header-height: 3.5rem;
/* ---- Shadows (very restrained) ------------------------------- */
--shadow-xs: 0 1px 0 0 rgb(0 0 0 / 0.04);
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.05);
--shadow-assess: 0 1px 2px rgba(23, 94, 99, 0.04), 0 8px 24px -12px rgba(23, 94, 99, 0.18);
/* ---- Type ---------------------------------------------------- */
--font-sans: 'Pretendard', 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
--font-size-base: 14px;
--line-height-base: 1.5;
/* Type scale tokens */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-4xl: 36px;
}
/* ============================================================
Base + semantic typography
============================================================ */
html, body {
margin: 0;
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* { box-sizing: border-box; }
/* Page title — admin pages */
.t-page-title {
font-size: var(--text-xl);
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
}
/* Modal title */
.t-modal-title {
font-size: var(--text-lg);
font-weight: 600;
line-height: 1.4;
}
/* Section title (modal sub-section, card heading) */
.t-section-title {
font-size: var(--text-sm);
font-weight: 600;
}
/* Label (form labels, list item key text) */
.t-label {
font-size: var(--text-sm);
font-weight: 500;
}
/* Body (default — table cell, paragraph) */
.t-body {
font-size: var(--text-sm);
font-weight: 400;
}
/* Hint (subtitle, helper text, meta) */
.t-hint {
font-size: var(--text-xs);
font-weight: 400;
color: var(--color-muted-foreground);
}
/* Badge micro */
.t-badge {
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0;
}
/* Assessment display title — only on profile/intro */
.t-assess-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-assess-text);
letter-spacing: -0.01em;
}
@media (min-width: 768px) {
.t-assess-title { font-size: var(--text-4xl); }
}
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Brand</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 22px; display:flex; gap: 24px; align-items: center; }
.logo { display:flex; align-items:center; gap: 10px; padding: 14px 18px; border:1px solid var(--color-border); border-radius: 10px; background: #fff; }
.logo svg { color: var(--color-primary); }
.logo .text { display:flex; flex-direction:column; }
.logo .word { font-size: 16px; font-weight: 600; line-height: 1.1; }
.logo .sub { font-size: 11px; color: var(--color-muted-foreground); }
.dark { background: hsl(222.2 84% 4.9%); border-color: hsl(222.2 84% 4.9%); }
.dark .word { color: #fff; }
.dark .sub { color: hsl(215.4 16.3% 70%); }
.dark svg { color: #fff; }
.note { font-size: 11px; color: var(--color-muted-foreground); align-self: flex-end; max-width: 240px;}
</style></head><body>
<div class="logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"/><path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8"/><path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5"/><path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0"/><path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5"/><path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10"/></svg>
<div class="text"><span class="word">Modular Admin</span><span class="sub">검사 운영 콘솔</span></div>
</div>
<div class="logo dark">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"/><path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8"/><path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5"/><path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0"/><path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5"/><path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10"/></svg>
<div class="text"><span class="word">Modular Admin</span><span class="sub">검사 운영 콘솔</span></div>
</div>
<p class="note">코드베이스에 자체 워드마크 없음 — IconBrain (Tabler) + "Modular Admin" 텍스트가 현재의 브랜드 표식.</p>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Buttons</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 22px; display:flex; flex-direction:column; gap: 14px; }
.row { display:flex; gap: 10px; align-items:center; flex-wrap:wrap;}
.label { font-size:11px; font-weight:600; color:var(--color-muted-foreground); width:80px; text-transform:uppercase; letter-spacing:0.04em; }
button { font-family: inherit; }
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; padding:0 16px; height:36px; border-radius:6px; font-size:14px; font-weight:500; border:1px solid transparent; cursor:pointer; }
.btn-default { background: hsl(215 70% 35%); color: hsl(210 40% 98%); }
.btn-default:hover { background: hsl(215 70% 31%); }
.btn-outline { background: #fff; border-color: var(--color-border); color: var(--color-foreground); box-shadow: 0 1px 0 0 rgb(0 0 0 / .04); }
.btn-outline:hover { background: var(--color-accent); }
.btn-secondary { background: var(--color-secondary); color: hsl(222.2 47.4% 11.2%); }
.btn-ghost { background: transparent; color: var(--color-foreground); }
.btn-ghost:hover { background: var(--color-accent); }
.btn-destructive { background: hsl(0 84.2% 60.2%); color:#fff; }
.btn-link { background:none; color: hsl(215 70% 35%); padding: 0; height:auto; text-decoration: underline; text-underline-offset: 4px; }
.btn-sm { height:32px; padding:0 12px; font-size:12px; }
.btn-lg { height:40px; padding:0 24px; }
.btn-icon { width:36px; height:36px; padding:0; }
</style></head><body>
<div class="row"><span class="label">Variants</span>
<button class="btn btn-default">검사 생성</button>
<button class="btn btn-outline">URL</button>
<button class="btn btn-secondary">상세</button>
<button class="btn btn-ghost">닫기</button>
<button class="btn btn-destructive">삭제</button>
<button class="btn btn-link">자세히 보기</button>
</div>
<div class="row"><span class="label">Sizes</span>
<button class="btn btn-default btn-sm">sm</button>
<button class="btn btn-default">default</button>
<button class="btn btn-default btn-lg">lg</button>
<button class="btn btn-outline btn-icon" aria-label="삭제"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 13a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-13M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></svg></button>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Admin colors</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 20px; }
.grid { display:grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.swatch { border:1px solid var(--color-border); border-radius: 8px; overflow:hidden; }
.chip { height: 56px; }
.meta { padding: 8px 10px; display:flex; flex-direction:column; gap:2px; }
.name { font-size:12px; font-weight:600; }
.val { font-size:11px; color:var(--color-muted-foreground); font-family:ui-monospace,monospace; }
</style></head><body>
<div class="grid">
<div class="swatch"><div class="chip" style="background:hsl(215 70% 35%)"></div><div class="meta"><span class="name">primary</span><span class="val">hsl(215 70% 35%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(222.2 84% 4.9%)"></div><div class="meta"><span class="name">foreground</span><span class="val">hsl(222.2 84% 4.9%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(210 40% 96.1%)"></div><div class="meta"><span class="name">muted / secondary</span><span class="val">hsl(210 40% 96.1%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(215.4 16.3% 46.9%)"></div><div class="meta"><span class="name">muted-fg</span><span class="val">hsl(215.4 16.3% 46.9%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(214.3 31.8% 91.4%)"></div><div class="meta"><span class="name">border / input</span><span class="val">hsl(214.3 31.8% 91.4%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(0 0% 98%)"></div><div class="meta"><span class="name">sidebar bg</span><span class="val">hsl(0 0% 98%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(210 40% 93%)"></div><div class="meta"><span class="name">sidebar accent</span><span class="val">hsl(210 40% 93%)</span></div></div>
<div class="swatch"><div class="chip" style="background:hsl(0 84.2% 60.2%)"></div><div class="meta"><span class="name">destructive</span><span class="val">hsl(0 84.2% 60.2%)</span></div></div>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Assessment colors</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 20px; }
.grid { display:grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.swatch { border:1px solid var(--color-assess-border-2); border-radius: 8px; overflow:hidden; }
.chip { height: 56px; }
.meta { padding: 8px 10px; display:flex; flex-direction:column; gap:2px; }
.name { font-size:12px; font-weight:600; color:var(--color-assess-text); }
.val { font-size:11px; color:var(--color-assess-text-2); font-family:ui-monospace,monospace; }
</style></head><body>
<div class="grid">
<div class="swatch"><div class="chip" style="background:#175e63"></div><div class="meta"><span class="name">deep teal</span><span class="val">#175e63</span></div></div>
<div class="swatch"><div class="chip" style="background:#124b4f"></div><div class="meta"><span class="name">deep teal hover</span><span class="val">#124b4f</span></div></div>
<div class="swatch"><div class="chip" style="background:#5ce1e6"></div><div class="meta"><span class="name">accent cyan</span><span class="val">#5ce1e6</span></div></div>
<div class="swatch"><div class="chip" style="background:#161d1b"></div><div class="meta"><span class="name">text</span><span class="val">#161d1b</span></div></div>
<div class="swatch"><div class="chip" style="background:#5f6f73"></div><div class="meta"><span class="name">text 2</span><span class="val">#5f6f73</span></div></div>
<div class="swatch"><div class="chip" style="background:#8a9a96"></div><div class="meta"><span class="name">text 3</span><span class="val">#8a9a96</span></div></div>
<div class="swatch"><div class="chip" style="background:#f3f5f4"></div><div class="meta"><span class="name">panel 1</span><span class="val">#f3f5f4</span></div></div>
<div class="swatch"><div class="chip" style="background:#dfe5e3"></div><div class="meta"><span class="name">border 1</span><span class="val">#dfe5e3</span></div></div>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Status colors</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 20px; display:flex; gap:14px; align-items:flex-start; flex-wrap:wrap; }
.stack { display:flex; flex-direction:column; gap:8px; min-width: 150px; }
.label { font-size:11px; font-weight:600; color:var(--color-muted-foreground); }
.badge { display:inline-flex; align-items:center; padding:2px 10px; font-size:12px; font-weight:600; border-radius:6px; align-self:flex-start; }
.b-success { background:hsl(141 78.9% 85.1%); color:hsl(142 76% 26%); }
.b-warning { background:hsl(48 96% 89%); color:hsl(35 91% 33%); }
.b-destruct{ background:hsl(0 84.2% 60.2%); color:#fff; }
.b-default { background:hsl(215 70% 35%); color:hsl(210 40% 98%); }
.b-secondary{background:hsl(210 40% 96.1%); color:hsl(222.2 47.4% 11.2%); }
.b-outline { border:1px solid hsl(214.3 31.8% 91.4%); color:hsl(222.2 84% 4.9%);}
</style></head><body>
<div class="stack"><span class="label">Default</span><span class="badge b-default">운영중</span></div>
<div class="stack"><span class="label">Secondary</span><span class="badge b-secondary">중지</span></div>
<div class="stack"><span class="label">Outline</span><span class="badge b-outline">초안</span></div>
<div class="stack"><span class="label">Success</span><span class="badge b-success">실시완료</span></div>
<div class="stack"><span class="label">Warning</span><span class="badge b-warning">미실시</span></div>
<div class="stack"><span class="label">Destructive</span><span class="badge b-destruct">삭제</span></div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Form controls</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 22px; display:grid; grid-template-columns: 1fr 1fr; gap: 18px 28px; }
.field { display:flex; flex-direction:column; gap:6px; }
.label { font-size: 14px; font-weight: 500; }
.hint { font-size: 12px; color: var(--color-muted-foreground); }
.input, .select { height: 36px; padding: 0 12px; border-radius: 6px; border: 1px solid var(--color-input); font-size: 14px; background: #fff; box-shadow: 0 1px 0 0 rgb(0 0 0 / 0.04); font-family: inherit; }
.input:focus, .select:focus { outline: 2px solid hsl(215 70% 35% / 0.5); outline-offset: 0; }
.check { display:flex; align-items:center; gap:8px; font-size:14px; }
textarea.input { height: 64px; resize: none; padding-top: 8px; line-height: 1.5; }
</style></head><body>
<div class="field">
<label class="label" for="a">검사 이름</label>
<input id="a" class="input" placeholder="예: STS 파일럿-초등용" />
<span class="hint">최대 120자</span>
</div>
<div class="field">
<label class="label" for="b">내담자 등록 방식</label>
<select id="b" class="select"><option>사전 등록 필수</option><option>검사 진행 시 자동 생성 허용</option></select>
<span class="hint">자동 생성 허용은 검사 링크에서 등록을 진행합니다.</span>
</div>
<div class="field" style="grid-column: span 2;">
<label class="label" for="c">세션 안내 문구</label>
<textarea id="c" class="input" placeholder="세션 시작 전 보여줄 검사 안내 문구"></textarea>
</div>
<label class="check"><input type="checkbox" checked> 수검 전 개인정보 수집·이용 동의 받기</label>
<label class="check"><input type="checkbox"> 필수 입력</label>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Iconography</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 18px; }
.grid { display:grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.it { display:flex; flex-direction:column; align-items:center; gap:4px; padding:10px 4px; border:1px solid var(--color-border); border-radius:8px;}
.it svg { color: var(--color-foreground); }
.it span { font-size: 10px; color: var(--color-muted-foreground); font-family: ui-monospace, monospace;}
.note { font-size:11px; color: var(--color-muted-foreground); margin-top: 12px; }
</style></head><body>
<div class="grid">
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"/><path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8"/></svg><span>Brain</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v8H4zM14 4h6v4h-6zM14 12h6v8h-6zM4 16h6v4H4z"/></svg><span>Dashboard</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="2"/><path d="M9 7h6M9 11h6M9 15h4"/></svg><span>Clipboard</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/></svg><span>Users</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"/><path d="m20 20-3-3"/></svg><span>Search</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7a5 5 0 0 1 0-10h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg><span>Link</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 13a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-13"/></svg><span>Trash</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg><span>Plus</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg><span>ChevR</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg><span>ChevD</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg><span>Settings</span></div>
<div class="it"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg><span>Logout</span></div>
</div>
<div class="note">@tabler/icons-react · 2px stroke · 24×24. CDN으로 사용; 새 아이콘 추가 시 같은 stroke·grid 유지.</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>List row</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 18px; }
.card { border:1px solid var(--color-border); border-radius:12px; background:#fff; box-shadow: 0 1px 2px 0 rgb(0 0 0 / .05); overflow:hidden; }
.head { display:grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 152px; gap: 8px; height: 40px; padding: 0 16px; border-bottom: 2px solid var(--color-border); align-items:center; background: var(--color-muted); font-size: 12px; font-weight: 500; }
.head span { text-align: center; }
.row { display:grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 152px; gap: 8px; padding: 12px 16px; align-items:center; border-bottom: 1px solid var(--color-border); }
.row:hover { background: hsl(210 40% 96.1% / 0.4); }
.row span { text-align: center; font-size: 14px; }
.name-col { display:flex; flex-direction:column; align-items:center; gap: 1px; }
.name { font-weight: 500; }
.meta { font-size: 12px; color: var(--color-muted-foreground); }
.badge { display:inline-flex; padding: 2px 8px; font-size: 11px; font-weight: 600; border-radius: 6px; }
.b-success { background: hsl(141 78.9% 85.1%); color: hsl(142 76% 26%); }
.b-secondary { background: var(--color-secondary); color: hsl(222.2 47.4% 11.2%); }
.actions { display:flex; gap:4px; justify-content: center; }
.btn { display:inline-flex; align-items:center; gap:4px; height: 28px; padding: 0 10px; font-size: 12px; border:1px solid var(--color-border); background: #fff; border-radius:6px; cursor:pointer; font-family:inherit;}
.btn-ghost { border-color: transparent; background: transparent; color: var(--color-muted-foreground);}
.btn:hover { background: var(--color-accent); }
</style></head><body>
<div class="card">
<div class="head"><span>검사명</span><span>기반 검사</span><span>척도 수</span><span>배정 / 실시</span><span>진행 상태</span><span></span></div>
<div class="row">
<div class="name-col"><span class="name">STS 파일럿-초등용</span><span class="meta">2026-04-22 생성</span></div>
<span class="meta">STS</span>
<span>척도 12개</span>
<span>34 / 28</span>
<span style="display:flex;justify-content:center;gap:4px"><span class="badge b-success">운영중</span><span class="badge b-secondary">진행</span></span>
<span class="actions"><button class="btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2M15 7h2a5 5 0 0 1 0 10h-2M8 12h8"/></svg>URL</button><button class="btn btn-ghost">상세</button></span>
</div>
<div class="row" style="border-bottom:none">
<div class="name-col"><span class="name">MMPI-2 성인용 표준</span><span class="meta">2026-04-15 생성</span></div>
<span class="meta">MMPI-2</span>
<span>척도 24개</span>
<span>120 / 95</span>
<span style="display:flex;justify-content:center;gap:4px"><span class="badge b-success">운영중</span><span class="badge b-secondary">진행</span></span>
<span class="actions"><button class="btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2M15 7h2a5 5 0 0 1 0 10h-2M8 12h8"/></svg>URL</button><button class="btn btn-ghost">상세</button></span>
</div>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Radius & shadow</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 24px; display:grid; grid-template-columns: repeat(4, 1fr); gap: 16px; background: hsl(210 40% 98%); }
.box { background:#fff; border:1px solid var(--color-border); padding: 14px; display:flex; flex-direction:column; gap: 6px; min-height: 84px; }
.r-md { border-radius: 6px; }
.r-lg { border-radius: 8px; }
.r-xl { border-radius: 12px; }
.s-xs { box-shadow: 0 1px 0 0 rgb(0 0 0 / 0.04); }
.s-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.06); }
.s-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0 / .10), 0 4px 6px -4px rgb(0 0 0 / .05); }
.name { font-size:12px; font-weight:600; }
.meta { font-size:11px; color:var(--color-muted-foreground); font-family:ui-monospace,monospace; }
</style></head><body>
<div class="box r-md s-xs"><span class="name">rounded-md</span><span class="meta">6px · shadow-xs</span><span class="meta">Button, Input</span></div>
<div class="box r-lg s-sm"><span class="name">rounded-lg</span><span class="meta">8px · shadow-sm</span><span class="meta">Tool panel, Modal</span></div>
<div class="box r-xl s-sm"><span class="name">rounded-xl</span><span class="meta">12px · shadow-sm</span><span class="meta">Card (default)</span></div>
<div class="box r-lg s-lg"><span class="name">rounded-lg</span><span class="meta">8px · shadow-lg</span><span class="meta">Modal panel</span></div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Sidebar</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 0; margin: 0; background: hsl(210 40% 98%); height: 100vh; }
.sb { width: 240px; height: 100%; background: hsl(0 0% 98%); border-right: 1px solid var(--color-border); display:flex; flex-direction:column; }
.sb-header { padding: 12px 16px; border-bottom: 1px solid var(--color-border); display:flex; align-items:center; gap: 8px; }
.sb-header svg { color: var(--color-primary); }
.sb-header span { font-size:14px; font-weight:600; }
.sb-group { padding: 8px 6px; display:flex; flex-direction:column; gap:1px; }
.sb-label { font-size:11px; color: var(--color-muted-foreground); padding: 8px 12px 4px; }
.sb-item { display:flex; align-items:center; gap:8px; padding: 7px 10px; border-radius: 6px; font-size: 14px; color: var(--color-foreground); cursor:pointer; position: relative; }
.sb-item:hover { background: var(--color-sidebar-accent); }
.sb-item.active { background: hsl(210 40% 93%); font-weight: 500; }
.sb-item svg { color: var(--color-muted-foreground); }
.sb-item.active svg { color: var(--color-foreground); }
.badge { margin-left:auto; background: hsl(0 84.2% 60.2%); color:#fff; font-size: 10px; font-weight:700; min-width:18px; height:18px; border-radius:9px; display:flex; align-items:center; justify-content:center; padding: 0 5px; line-height:1; }
</style></head><body>
<aside class="sb">
<div class="sb-header">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"/><path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8"/><path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5"/><path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0"/><path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5"/><path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10"/></svg>
<span>Modular Admin</span>
</div>
<div class="sb-group">
<span class="sb-label">메뉴</span>
<div class="sb-item"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v8H4zM14 4h6v4h-6zM14 12h6v8h-6zM4 16h6v4H4z"/></svg>대시보드</div>
<div class="sb-item active"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="2"/><path d="M9 7h6M9 11h6M9 15h4"/></svg>검사 운영</div>
<div class="sb-item"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><circle cx="17" cy="7" r="2"/></svg>내담자 관리</div>
<div class="sb-item"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"/><path d="m20 20-3-3"/></svg>동일인 검토<span class="badge">7</span></div>
</div>
</aside>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Spacing</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 22px; }
.row { display:grid; grid-template-columns: 70px 1fr 90px; gap: 12px; align-items:center; padding: 6px 0; }
.name { font-size:12px; font-weight:600; }
.bar { height: 14px; background: var(--color-primary); border-radius: 3px; }
.use { font-size:11px; color:var(--color-muted-foreground); font-family:ui-monospace,monospace; }
</style></head><body>
<div class="row"><span class="name">4 px</span><div class="bar" style="width:4px"></div><span class="use">gap-1, p-1</span></div>
<div class="row"><span class="name">8 px</span><div class="bar" style="width:8px"></div><span class="use">gap-2, p-2</span></div>
<div class="row"><span class="name">12 px</span><div class="bar" style="width:12px"></div><span class="use">gap-3, p-3</span></div>
<div class="row"><span class="name">16 px</span><div class="bar" style="width:16px"></div><span class="use">gap-4, p-4</span></div>
<div class="row"><span class="name">24 px</span><div class="bar" style="width:24px"></div><span class="use">gap-6, p-6</span></div>
<div class="row"><span class="name">32 px</span><div class="bar" style="width:32px"></div><span class="use">gap-8, p-8</span></div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Summary tiles</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 18px; background: #fff; }
.panel { border:1px solid var(--color-border); border-radius: 8px; padding: 16px; max-width: 540px; }
.h { font-size: 14px; font-weight: 600; margin: 0 0 12px; }
.grid { display:grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.tile { background: hsl(210 40% 96.1% / 0.6); border-radius: 6px; padding: 12px; }
.tile p { font-size: 12px; color: var(--color-muted-foreground); margin: 0 0 4px; }
.tile strong { font-size: 14px; }
.warn { color: hsl(35 91% 33%); }
</style></head><body>
<div class="panel">
<h4 class="h">구성 요약</h4>
<div class="grid">
<div class="tile"><p>검사군</p><strong>3개</strong></div>
<div class="tile"><p>선택 척도</p><strong>18개</strong></div>
<div class="tile"><p>포함 실시구간</p><strong>5개</strong></div>
<div class="tile"><p>제외 예정</p><strong class="warn">1개</strong></div>
<div class="tile"><p>예상 문항</p><strong>284개</strong></div>
<div class="tile"><p>세션</p><strong>2개</strong></div>
</div>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Tree control</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 18px; background: hsl(210 40% 98%); }
.panel { background:#fff; border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; max-width: 460px; }
.group { border:1px solid var(--color-border); border-radius: 6px; margin-bottom: 8px; }
.group:last-child { margin-bottom: 0; }
.ghead { display:flex; align-items:center; justify-content:space-between; padding: 6px 10px; }
.ghead label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight:500; }
.toggle { font-size: 11px; color: var(--color-muted-foreground); background:none; border:none; cursor:pointer; }
.gbody { border-top: 1px solid var(--color-border); padding: 8px; background: hsl(210 40% 96.1% / 0.3); }
.cond { background: #fff; border:1px solid var(--color-border); border-radius: 5px; padding: 6px 8px; margin-bottom: 6px; }
.cond:last-child { margin-bottom:0;}
.cond-h { display:flex; justify-content:space-between; align-items:center; font-size:12px; font-weight:500; padding-bottom: 4px; border-bottom: 1px solid var(--color-border); margin-bottom: 6px; }
.cond-h span:last-child { font-size: 11px; color: var(--color-muted-foreground); font-weight: 400; }
.leaf { display:flex; align-items:flex-start; gap: 8px; padding: 4px 6px; font-size: 13px; }
.leaf input { margin-top: 2px; }
.leaf-meta { font-size: 11px; color: var(--color-muted-foreground); display:block; }
</style></head><body>
<div class="panel">
<div class="group">
<div class="ghead">
<label><input type="checkbox" checked> <span>STS</span></label>
<button class="toggle">접기</button>
</div>
<div class="gbody">
<div class="cond">
<div class="cond-h"><span>초등 4-6학년</span><span>32문항</span></div>
<label class="leaf"><input type="checkbox" checked><span>외현화 척도 <span class="leaf-meta">12문항</span></span></label>
<label class="leaf"><input type="checkbox" checked><span>내재화 척도 <span class="leaf-meta">10문항</span></span></label>
<label class="leaf"><input type="checkbox"><span>또래 관계 <span class="leaf-meta">10문항</span></span></label>
</div>
<div class="cond">
<div class="cond-h"><span>중학생</span><span>40문항</span></div>
<label class="leaf"><input type="checkbox"><span>학교 적응 <span class="leaf-meta">15문항</span></span></label>
<label class="leaf"><input type="checkbox"><span>가족 관계 <span class="leaf-meta">12문항</span></span></label>
</div>
</div>
</div>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Assessment type</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 24px; background: var(--color-assess-panel-3); }
.stack { display:flex; flex-direction:column; gap: 8px; }
.meta { font-size: 11px; color: var(--color-assess-text-2); font-family: ui-monospace, monospace; letter-spacing:0.02em; text-transform: uppercase;}
h1 { font-size: 36px; font-weight: 700; color: var(--color-assess-text); margin: 0; letter-spacing:-0.01em; }
h2 { font-size: 24px; font-weight: 700; color: var(--color-assess-text); margin: 0; }
p { font-size: 14px; color: var(--color-assess-text-2); margin: 0; }
</style></head><body>
<div class="stack">
<span class="meta">Display · 36 / 700</span>
<h1>검사를 시작합니다</h1>
<span class="meta" style="margin-top:6px">Heading · 24 / 700</span>
<h2>인적사항을 입력해주세요</h2>
<span class="meta" style="margin-top:6px">Body · 14 / 400</span>
<p>입력하신 정보는 검사 결과 분석에만 사용됩니다.</p>
</div>
</body></html>
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Type scale</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body { padding: 24px; color: var(--color-foreground); }
.row { display:grid; grid-template-columns: 110px 1fr 90px; gap: 14px; align-items: baseline; padding: 7px 0; border-bottom: 1px solid var(--color-border); }
.row:last-child { border-bottom: none; }
.role { font-size:11px; font-weight:600; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: 0.04em; }
.meta { font-size:11px; color: var(--color-muted-foreground); font-family: ui-monospace,monospace; text-align:right; }
</style></head><body>
<div class="row"><span class="role">Page</span><span style="font-size:20px;font-weight:600">검사 관리</span><span class="meta">20 / 600</span></div>
<div class="row"><span class="role">Modal</span><span style="font-size:18px;font-weight:600">검사 생성</span><span class="meta">18 / 600</span></div>
<div class="row"><span class="role">Section</span><span style="font-size:14px;font-weight:600">기본 정보</span><span class="meta">14 / 600</span></div>
<div class="row"><span class="role">Label</span><span style="font-size:14px;font-weight:500">검사 이름</span><span class="meta">14 / 500</span></div>
<div class="row"><span class="role">Body</span><span style="font-size:14px;font-weight:400">맞춤형 검사를 생성하고 관리합니다</span><span class="meta">14 / 400</span></div>
<div class="row"><span class="role">Hint</span><span style="font-size:12px;color:var(--color-muted-foreground)">동의서 내용은 설정 메뉴에서 관리합니다.</span><span class="meta">12 / 400</span></div>
<div class="row"><span class="role">Badge</span><span style="font-size:12px;font-weight:600">실시완료</span><span class="meta">12 / 600</span></div>
</body></html>
Modular Design System
인싸이트 2.0 모듈러 — 검사 운영 웹 앱의 디자인 시스템
What is this?
Modular는 FastAPI 백엔드 + React SPA로 구성된 검사 운영 웹 애플리케이션입니다. 두 개의 명확하게 구분된 사용자 흐름으로 구성됩니다:
1. 관리자 화면 (Admin) — 조용하고 실무적인 SaaS 운영 도구. 정보 밀도가 높고, 표/카드/필터/액션 버튼의 정렬이 명확. blue primary (hsl(215 70% 35%)) + neutral 토큰 사용. 2. 수검자 화면 (Assessment) — 검사 응답에 집중시키는 단순한 플로우. 부드럽고 몰입감 있는 teal 계열 (`#175e63`) 으로 차별화.
이 두 갈래가 같은 앱 안에 공존하기 때문에, 운영 화면에 마케팅 hero 장식 / decorative blob / gradient를 가져오지 않는 것이 디자인 시스템의 핵심 규칙입니다.
Sources
- Codebase:
2.0-modular/(mounted via Import) —frontend/가 React SPA frontend/src/index.css— Tailwind v4@theme토큰frontend/src/components/ui/*— shadcn 스타일 primitives (Button, Card, Input, Badge, Sidebar)frontend/src/components/layout/AppSidebar.tsx— 관리자 네비게이션frontend/src/pages/TestManagement.tsx— 검사 생성 모달 (현재 복잡해진 화면 — 이 시스템의 핵심 redesign 대상)frontend/src/pages/assessment/*— 수검자 단계형 플로우- Design SoT:
2.0-modular/DESIGN.md(이 시스템 정의의 원본 — 552줄) - Repo: Byumin/2.0-modular @ harness-engineering branch
Index
| File | What |
|---|---|
README.md | 이 문서 — 컨텍스트, 가이드, manifest |
colors_and_type.css | CSS 변수 — admin/assessment 양 팔레트 + 타입 스케일 |
SKILL.md | 에이전트가 이 시스템으로 작업할 때 읽는 진입점 |
assets/ | 로고/아이콘 (현재는 Tabler Icons CDN 사용) |
preview/ | Design System 탭의 카드들 (HTML preview cards) |
ui_kits/admin/ | 관리자 화면 UI kit + 검사 생성 모달 redesign |
Content Fundamentals
Voice & Tone
- 언어: 한국어 위주 (UI 라벨, 버튼, 안내문). 영문은 코드/식별자 (test_id, STS, MMPI 같은 검사명) 에서만 노출.
- 존댓말: 관리자 화면도 수검자 화면도 모두 정중한 존댓말 (
~합니다,~해주세요). 명령조 (~하라,~해) 는 사용 안 함. - 간결함: 한 문장에 하나의 행동만. 페이지 제목 → 한 줄 설명문 패턴.
- 예: "검사 관리" → "맞춤형 검사를 생성하고 관리합니다"
- 예: "검사 생성" → "검사군, 척도, 추가 인적사항을 선택해 커스텀 검사를 생성합니다."
- 2인칭/1인칭: "당신/저희" 같은 대명사 거의 안 씀. 행위 중심 — 무인칭으로 작성.
- Emoji: 사용하지 않음. 상태/카운트는 badge + 숫자.
- Casing: 영문 검사 코드는 대문자 (
STS,MMPI-2,K-WISC). 한글은 normal.
Examples (real strings from codebase)
# Page titles
"검사 관리"
"내담자 관리"
"동일인 검토"
# Subtitles
"맞춤형 검사를 생성하고 관리합니다"
"검사군, 척도, 추가 인적사항을 선택해 커스텀 검사를 생성합니다."
# Section labels (modal)
"기본 정보"
"1. 검사 선택"
"2. 세션 구성"
"3. 척도 선택"
"4. 추가 인적사항"
"구성 요약"
# Inline hints
"자동 생성 허용은 검사 링크에서 내담자 등록과 배정을 진행합니다."
"동의서 내용은 설정 메뉴에서 관리합니다."
"먼저 검사 선택에서 검사 항목을 체크해주세요."
# Empty states
"등록된 검사가 없습니다."
"추가 인적사항이 없습니다."
"조건에 맞는 실시 현황이 없습니다."
# Buttons
"검사 생성" # primary
"닫기" # outline
"생성" # primary submit
"생성 중..." # loading state
"항목 삭제" # outline destructive variant
"옵션 추가"
# Errors
"검사 생성에 실패했습니다."
# Step modal — "단계별 번호 + 한국어 동사형 제목" 패턴이 일관됨.Modal Numbering Convention
검사 생성 모달은 단계가 많아도 사용자가 스캔 가능하도록 번호 + 짧은 명사형 제목 으로 섹션을 구분합니다:
기본 정보 → 1. 검사 선택 → 2. 세션 구성 → 3. 척도 선택 → 4. 추가 인적사항 → 구성 요약
번호 없는 섹션 (기본 정보, 구성 요약) 은 메타/요약 영역. 1~4번은 사용자가 차례대로 결정해야 하는 단계.
Visual Foundations
Colors
Admin palette — Tailwind v4 @theme 토큰 기반. HSL.
- Primary blue:
hsl(215 70% 35%)— 주요 액션, 현재 선택, focus ring 모두 동일 - Foreground:
hsl(222.2 84% 4.9%)— 거의 검정에 가까운 짙은 네이비 - Muted:
hsl(210 40% 96.1%)— 표 header, secondary surface - Muted foreground:
hsl(215.4 16.3% 46.9%)— 보조 텍스트 - Border:
hsl(214.3 31.8% 91.4%) - Destructive:
hsl(0 84.2% 60.2%) - Sidebar:
hsl(0 0% 98%)(배경),hsl(210 40% 93%)(active/hover) - Status: success는
bg-green-100 text-green-700, warning은bg-yellow-100 text-yellow-700(Tailwind palette 직접 사용)
Assessment palette — 별도 hard-coded teal 시스템.
- Deep teal:
#175e63— 주요 CTA - Deep teal hover:
#124b4f - Dark text:
#161d1b - Secondary text:
#5f6f73,#8a9a96 - Light panels:
#f3f5f4,#f5f7fa,#fafbfc - Borders:
#dfe5e3,#e2e8f0,#e8ebee - Accent cyan:
#5ce1e6
두 팔레트는 절대 한 화면에서 섞이지 않습니다. Admin 화면에서 teal 사용 금지.
Typography
font-family: 'Pretendard', 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
font-size: 14px; /* base — 관리자 밀도 우선 */
line-height: 1.5;| Role | Size | Weight |
|---|---|---|
| Page title | 20px (text-xl) | 600 |
| Modal title | 18px (text-lg) | 600 |
| Section title | 14px (text-sm) | 600 |
| Label | 14px (text-sm) | 500 |
| Body | 14px (text-sm) | 400 |
| Hint | 12px (text-xs) | 400~500 |
| Badge micro | 10~12px | 600~700 |
| Assessment title | 24~36px | 700 |
Letter spacing 기본 0. tracking-wider/tracking-widest는 micro label에서만 제한적 사용. 본문은 카드/섹션 안에서 짧게 끊습니다.
Spacing
8pt 그리드의 변형. Tailwind 토큰 그대로:
- 4 / 8 / 12 / 16 / 24 / 32 px (=
gap-1~gap-8) - 카드 default padding:
px-6 py-6또는p-4 - 관리자 list/grid는
gap-3~gap-6범위에서 정렬감 우선 - modal 2-column에서도 각 section의 상단선이 맞아야 함
Surfaces & Elevation
Cards
flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm- radius:
rounded-xl(default) /rounded-lg(좁은 tool panel) - shadow: 거의 없음 (
shadow-sm,shadow-xs). 강한 box-shadow 추가 안 함. - 카드 안 카드 중첩은 반복 item / modal section / tree panel 같이 의미가 있을 때만
Borders
- Section 분리는 shadow보다 border + spacing
- list row는 border-bottom 또는 muted hover
- 대부분
border(1px,hsl(214.3 31.8% 91.4%)) — 변형 없음
Modals
- overlay:
bg-black/40(assessment intro / warning은bg-black/50) - centered panel —
rounded-lg border bg-card shadow-lg - 길어지면
max-h-full overflow-auto, 푸터 액션은 우측 정렬 - 검사 생성 모달은
max-w-5xl까지 넓게, 2-column grid (lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)])
Hover & Press States
- Primary 버튼 hover:
bg-primary/90(10% darker via opacity) - Outline / secondary hover:
bg-accent text-accent-foreground(= muted neutral) - Ghost hover:
bg-accent - 표 row hover:
hover:bg-muted/40— 매우 옅게 - Press 상태 별도 정의 없음 (CSS
active:거의 미사용) - 드래그 시:
cursor-grab→active:cursor-grabbing, drop zone 강조는border-primary/40 bg-primary/5
Focus
- 모든 interactive:
focus-visible:ring-2 focus-visible:ring-ring/50 - ring 색상은 primary와 동일 (
hsl(215 70% 35%)) - 색상만으로 상태 전달 금지 — 항상 label/icon과 함께
Motion
- 관리자 화면: decorative blob/orb 사용 금지. transition은
transition-colors,transition-all정도만. - 수검자 intro: deep teal blob 3개 (
assessment-intro-blob-*) —cubic-bezier(0.07,0.8,0.16,1), 22~30s 무한 alternate - 수검자 step transition: 180~220ms, opacity + translateY + blur,
cubic-bezier(0.22, 1, 0.36, 1) prefers-reduced-motion: reduce시 모든 blob / step transition 무력화
Borders & Radii
--radius: 0.75rem(12px)- 실제로는
rounded-md(6px),rounded-lg(8px),rounded-xl(12px)을 가장 많이 사용 - Badge:
rounded-md - Input/Button:
rounded-md - Card:
rounded-xl
Imagery & Iconography vibe
- 사진/이미지 거의 없음 — 정보 밀도 중심 SaaS
- 수검자 intro 배경: 추상 teal blur blob 3개 + 연한 cyan/yellow/red 그라데이션
- 색감 분위기: 차분한 cool tone (admin), 깊이있는 teal + cyan accent (assessment)
- 그레인 / 텍스처 / 패턴 사용 안 함
Iconography
- Icon library:
@tabler/icons-react— line-style, 1.5~2px stroke, 24×24 base, 인터페이스 전체에서 단일 system - 코드베이스에 자체 SVG/이미지 아이콘 없음 (
frontend/src/assets/에 Vite 기본react.svg만 존재) - 자주 쓰이는 아이콘:
IconBrain— 브랜드 / 앱 identity (사이드바)IconDashboard/IconClipboardList/IconUsers/IconUserSearch/IconSettings/IconLogout— 메인 메뉴IconLink— URL 복사/생성IconTrash— 삭제IconPlus— 생성/추가IconSearch— 검색IconChevronDown/IconChevronRight— tree expand/collapse- CDN 사용: 이 디자인 시스템에서는 Tabler Icons를 https://unpkg.com/@tabler/icons-react@latest/dist/esm/* 또는 SVG sprite로 부릅니다. 별도 import 없음.
- Emoji / unicode 아이콘 사용 안 함
- 같은 의미의 action에 다른 icon 섞지 않음
🚩 Substitution flag: 이 디자인 시스템 카드에서는 인라인 SVG로 Tabler 모양을 흉내냅니다. 실제 production 작업에서는 @tabler/icons-react 를 그대로 사용하세요.UI Kits
| Product | Path | Status |
|---|---|---|
| Admin (관리자) | ui_kits/admin/ | 검사 관리 페이지 + 검사 생성 모달 redesign (개선된 UX) |
수검자 (Assessment) UI kit은 이번 사이클에서 제외했습니다 — 사용자 요청은 검사 생성 모달 (admin) 의 UX 개선이 명시적 목표.
Caveats / Open questions
- 폰트 파일 미포함:
Pretendard/Noto Sans KR는 보통 시스템 폰트 또는 CDN으로 로드. 이 시스템은 Pretendard CDN (https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css) 을 참조합니다. 자체 호스팅이 필요하면 폰트 파일을 별도 제공해주세요. - 로고 미포함: 코드베이스에 Modular 자체 로고가 없습니다. 사이드바는
IconBrain(Tabler) + 텍스트 "Modular Admin"이 브랜드 표식입니다. 별도 워드마크/심볼을 만드시려면 의뢰해주세요.
/* AppShell — sidebar + content layout matching AppSidebar.tsx */
const { useState: useStateShell } = React;
function AppSidebar({ active = "tests", pendingReviewCount = 7 }) {
const items = [
{ key: "dashboard", icon: IconDashboard, label: "대시보드" },
{ key: "tests", icon: IconClipboard, label: "검사 운영" },
{ key: "clients", icon: IconUsers, label: "내담자 관리" },
{ key: "review", icon: IconUserSearch, label: "동일인 검토", badge: pendingReviewCount },
];
return (
<aside className="flex h-full w-[240px] shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-sidebar)]">
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-3">
<span className="text-[var(--color-primary)]"><IconBrain size={22} /></span>
<span className="text-base font-semibold">Modular Admin</span>
</div>
<nav className="flex flex-1 flex-col gap-2 p-2">
<span className="px-3 pt-1 pb-1 text-[11px] font-medium text-[var(--color-muted-foreground)]">메뉴</span>
<ul className="flex flex-col gap-0.5">
{items.map(({ key, icon: I, label, badge }) => (
<li key={key}>
<button className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
active === key
? "bg-[var(--color-sidebar-accent)] font-medium text-[var(--color-foreground)]"
: "text-[var(--color-foreground)] hover:bg-[var(--color-sidebar-accent)]"
)}>
<I size={16} />
<span className="flex-1 text-left">{label}</span>
{badge ? (
<span className="ml-auto flex size-5 items-center justify-center rounded-full bg-[var(--color-destructive)] text-[10px] font-bold leading-none text-white">
{badge > 99 ? "99+" : badge}
</span>
) : null}
</button>
</li>
))}
</ul>
<div className="mt-2 h-px bg-[var(--color-border)]" />
<ul className="flex flex-col gap-0.5">
<li><button className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm hover:bg-[var(--color-sidebar-accent)]"><IconSettings size={16} /><span>설정</span></button></li>
</ul>
</nav>
<div className="border-t border-[var(--color-border)] p-2">
<button className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-destructive)]">
<IconLogout size={16} /><span>로그아웃</span>
</button>
</div>
</aside>
);
}
function PageHeader({ title, subtitle, action }) {
return (
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold leading-tight">{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--color-muted-foreground)]">{subtitle}</p>}
</div>
{action}
</div>
);
}
Object.assign(window, { AppSidebar, PageHeader });
/**
* CreateTestModal — 검사 생성 모달 redesign (v3 · polish)
*
* Source of truth: Byumin/2.0-modular @ harness-engineering
* - frontend/src/pages/TestManagement.tsx (lines 1022-1390)
*
* v3 polish (additive on top of v2):
* 1. Step rail: stepper에 connector line + check fill animation, hover 상태
* 2. Step content: subtle slide-in 트랜지션 (key 변경 시 fade+translate)
* 3. Footer: progress bar (5단계 / 현재 단계) + 미니 카운터 (검사 N · 세션 N · 척도 N · 항목 N)
* 4. Step 2 (검사 카드): 선택 시 ring + check 배지, hover translateY
* 5. Step 3 (드래그): drop zone idle/hot/active 3-state — hot은 dashed primary,
* drag 중인 zone에 직접 hover하면 solid primary + 살짝 scale up
* 6. Step 3 칩: drag handle 아이콘, 배정된 칩은 같은 색으로 그룹핑
* 7. Step 4 (척도 트리): tri-state checkbox (전체/일부/없음) + 검사별 진행 바
* 8. Empty state: 일관된 dashed border + IconAlert
*
* 변경 없음: 단계 순서, 상태 shape, 드래그 핸들러 시그니처, 컬러 토큰
*/
const STEPS = [
{ id: 1, key: "basic", title: "기본 정보", hint: "이름과 운영 옵션" },
{ id: 2, key: "tests", title: "검사 선택", hint: "포함할 검사군" },
{ id: 3, key: "sessions", title: "세션 구성", hint: "검사를 세션에 배정" },
{ id: 4, key: "scales", title: "척도 선택", hint: "사용할 척도 트리" },
{ id: 5, key: "profile", title: "추가 인적사항", hint: "URL에서 받을 항목" },
];
function CreateTestModal({ open, onClose }) {
const [step, setStep] = React.useState(1);
const [name, setName] = React.useState("");
const [intake, setIntake] = React.useState("pre_registered_only");
const [consent, setConsent] = React.useState(true);
const [selectedTests, setSelectedTests] = React.useState(["STS", "MMPI-2"]);
const [sessionsDraft, setSessionsDraft] = React.useState([
{ local_id: "session_1", title: "1차 세션", description: "표준화된 검사 안내를 확인한 뒤 응답을 시작합니다." },
{ local_id: "session_2", title: "2차 세션", description: "" },
]);
const [testSessionMap, setTestSessionMap] = React.useState({ STS: "session_1", "MMPI-2": "session_2" });
const [draggingTestId, setDraggingTestId] = React.useState(null);
// scale selection — store as Set of "TEST::CODE" keys for tri-state math
const [selectedScaleKeys, setSelectedScaleKeys] = React.useState(() => {
const s = new Set();
["EXT", "INT", "PEER"].forEach((c) => s.add(`STS::${c}`));
["L", "F", "K", "Hs", "D", "Hy"].forEach((c) => s.add(`MMPI-2::${c}`));
return s;
});
const [profileFields, setProfileFields] = React.useState([
{ id: "p1", label: "학교명", type: "short_text", required: true },
]);
if (!open) return null;
const handleTestDragStart = (event, testId) => {
setDraggingTestId(testId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", testId);
};
const handleSessionDrop = (event, sessionId) => {
event.preventDefault();
const testId = event.dataTransfer.getData("text/plain") || draggingTestId;
if (!testId || !selectedTests.includes(testId)) return;
setTestSessionMap((prev) => ({ ...prev, [testId]: sessionId }));
setDraggingTestId(null);
};
const goNext = () => setStep((s) => Math.min(STEPS.length, s + 1));
const goPrev = () => setStep((s) => Math.max(1, s - 1));
const isLast = step === STEPS.length;
// Mini counters for the footer
const counters = {
tests: selectedTests.length,
sessions: sessionsDraft.length,
scales: selectedScaleKeys.size,
profile: profileFields.length,
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-6 backdrop-blur-sm" role="dialog" aria-modal="true">
<style>{`
@keyframes step-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.step-anim { animation: step-in .22s cubic-bezier(.2,.7,.2,1); }
@keyframes pulse-soft { 0%,100% { transform: scale(1); } 50% { transform: scale(1.04); } }
`}</style>
<section className="flex max-h-full w-full max-w-5xl flex-col rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] shadow-2xl overflow-hidden">
{/* Header */}
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
<div>
<h3 className="text-lg font-semibold leading-tight">검사 생성</h3>
<p className="mt-0.5 text-xs text-[var(--color-muted-foreground)]">
{STEPS[step - 1].title} · 단계 {step}/{STEPS.length}
</p>
</div>
<button onClick={onClose} aria-label="닫기" className="flex size-8 items-center justify-center rounded-md text-[var(--color-muted-foreground)] transition-colors hover:bg-[var(--color-accent)] hover:text-[var(--color-foreground)]">
<IconX size={16} />
</button>
</header>
{/* Body */}
<div className="grid min-h-0 flex-1 grid-cols-[240px_1fr] overflow-hidden">
{/* Left rail — stepper with connector line */}
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[hsl(210_40%_98%)] p-5 overflow-auto">
<p className="px-1 pb-3 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]">단계</p>
<ol className="relative flex flex-col gap-1">
{/* connector line */}
<span aria-hidden className="absolute left-[18px] top-3 bottom-3 w-px bg-[var(--color-border)]" />
{STEPS.map((s) => {
const done = s.id < step;
const active = s.id === step;
return (
<li key={s.key} className="relative">
<button
onClick={() => setStep(s.id)}
className={cn(
"group flex w-full items-start gap-3 rounded-md px-2 py-2 text-left transition-all duration-150",
active && "bg-white border border-[var(--color-border)] shadow-[0_1px_2px_0_rgb(0_0_0/0.04)]",
!active && "hover:bg-white/70"
)}
>
<span className={cn(
"relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold transition-all duration-200",
done && "bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[0_0_0_3px_hsl(210_40%_98%)]",
active && "bg-[var(--color-foreground)] text-white shadow-[0_0_0_3px_hsl(210_40%_98%),0_0_0_4px_hsl(215_70%_35%/0.2)]",
!done && !active && "bg-white border border-[var(--color-border)] text-[var(--color-muted-foreground)] shadow-[0_0_0_3px_hsl(210_40%_98%)] group-hover:border-[var(--color-foreground)]/30"
)}>
{done ? <IconCheck size={13} /> : s.id}
</span>
<span className="min-w-0 pt-0.5">
<span className={cn("block text-sm leading-tight", active ? "font-semibold" : "font-medium")}>{s.title}</span>
<span className="mt-0.5 block text-[11px] text-[var(--color-muted-foreground)]">{s.hint}</span>
</span>
</button>
</li>
);
})}
</ol>
</aside>
{/* Step content with fade transition (keyed by step) */}
<div key={step} className="step-anim min-h-0 overflow-auto p-6">
{step === 1 && <StepBasic name={name} setName={setName} intake={intake} setIntake={setIntake} consent={consent} setConsent={setConsent} />}
{step === 2 && <StepTests selected={selectedTests} setSelected={setSelectedTests} testSessionMap={testSessionMap} setTestSessionMap={setTestSessionMap} sessionsDraft={sessionsDraft} />}
{step === 3 && (
<StepSessions
selectedTests={selectedTests}
sessionsDraft={sessionsDraft}
setSessionsDraft={setSessionsDraft}
testSessionMap={testSessionMap}
setTestSessionMap={setTestSessionMap}
draggingTestId={draggingTestId}
setDraggingTestId={setDraggingTestId}
onTestDragStart={handleTestDragStart}
onSessionDrop={handleSessionDrop}
/>
)}
{step === 4 && <StepScales selectedTests={selectedTests} selectedScaleKeys={selectedScaleKeys} setSelectedScaleKeys={setSelectedScaleKeys} />}
{step === 5 && <StepProfile profileFields={profileFields} setProfileFields={setProfileFields} />}
</div>
</div>
{/* Footer — progress bar + counters + buttons */}
<footer className="border-t border-[var(--color-border)]">
{/* progress bar */}
<div className="h-1 w-full bg-[var(--color-muted)]">
<div
className="h-full bg-[var(--color-primary)] transition-all duration-300 ease-out"
style={{ width: `${(step / STEPS.length) * 100}%` }}
/>
</div>
<div className="flex items-center justify-between gap-4 px-6 py-3">
<div className="flex min-w-0 items-center gap-3 text-[11px] text-[var(--color-muted-foreground)]">
<Counter label="검사" value={counters.tests} />
<span className="text-[var(--color-border)]">·</span>
<Counter label="세션" value={counters.sessions} />
<span className="text-[var(--color-border)]">·</span>
<Counter label="척도" value={counters.scales} />
<span className="text-[var(--color-border)]">·</span>
<Counter label="인적사항" value={counters.profile} />
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>닫기</Button>
{step > 1 && <Button variant="outline" onClick={goPrev}><IconChevronLeft size={14} /> 이전</Button>}
{!isLast && <Button onClick={goNext}>다음 <IconChevronRight size={14} /></Button>}
{isLast && <Button onClick={onClose}>생성</Button>}
</div>
</div>
</footer>
</section>
</div>
);
}
function Counter({ label, value }) {
return (
<span className="flex items-center gap-1">
<span className="text-[var(--color-muted-foreground)]">{label}</span>
<span className={cn(
"inline-flex min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
value > 0 ? "bg-[var(--color-foreground)] text-white" : "bg-[var(--color-muted)] text-[var(--color-muted-foreground)]"
)}>
{value}
</span>
</span>
);
}
/* ===== Step 1 ===== */
function StepBasic({ name, setName, intake, setIntake, consent, setConsent }) {
return (
<div className="flex max-w-xl flex-col gap-5">
<SectionHeader title="기본 정보" hint="검사 이름과 운영 방식을 정합니다." />
<Field label="검사 이름" hint="목록에서 식별할 이름. 최대 120자.">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="예: STS 파일럿-초등용" maxLength={120} />
</Field>
<Field label="내담자 등록 방식" hint="자동 생성 허용은 검사 링크에서 등록과 배정을 진행합니다.">
<Select value={intake} onChange={(e) => setIntake(e.target.value)}>
<option value="pre_registered_only">사전 등록 필수</option>
<option value="auto_create">검사 진행 시 자동 생성 허용</option>
</Select>
</Field>
<Field label="개인정보동의" hint="동의서 내용은 설정 메뉴에서 관리합니다.">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />
수검 전 개인정보 수집·이용 동의 받기
</label>
</Field>
</div>
);
}
/* ===== Step 2 ===== */
const CATALOG = [
{ id: "STS", name: "정서·사회성 검사", items: "초등 4-6, 중학생", count: 32 },
{ id: "MMPI-2", name: "다면적 인성검사", items: "성인 표준", count: 567 },
{ id: "K-WISC", name: "아동 지능검사", items: "4-6학년", count: 180 },
{ id: "CES-D", name: "우울척도", items: "단축형", count: 20 },
{ id: "STAI", name: "상태특성 불안검사", items: "청소년", count: 40 },
];
function StepTests({ selected, setSelected, testSessionMap, setTestSessionMap, sessionsDraft }) {
const toggle = (id) => {
setSelected((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
setTestSessionMap((prevMap) => {
const m = { ...prevMap };
if (next.includes(id)) m[id] = m[id] || sessionsDraft[0]?.local_id || "session_1";
else delete m[id];
return m;
});
return next;
});
};
return (
<div className="flex flex-col gap-4">
<SectionHeader title="검사 선택" hint="생성할 커스텀 검사에 포함할 검사군을 고릅니다. 여러 개 선택 가능." />
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{CATALOG.map((t) => {
const on = selected.includes(t.id);
return (
<button
key={t.id}
onClick={() => toggle(t.id)}
className={cn(
"group relative flex items-start gap-3 rounded-lg border p-3 text-left transition-all duration-150",
on
? "border-[var(--color-primary)] bg-[hsl(215_70%_35%/0.04)] shadow-[0_0_0_3px_hsl(215_70%_35%/0.12)]"
: "border-[var(--color-border)] bg-white hover:border-[var(--color-foreground)]/20 hover:bg-[var(--color-accent)] hover:-translate-y-px"
)}
>
{on && (
<span className="absolute right-2.5 top-2.5 flex size-5 items-center justify-center rounded-full bg-[var(--color-primary)] text-white shadow-sm">
<IconCheck size={12} />
</span>
)}
<span className={cn(
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border transition-colors",
on ? "border-[var(--color-primary)] bg-[var(--color-primary)] text-white" : "border-[var(--color-input)] bg-white"
)}>
{on && <IconCheck size={12} />}
</span>
<span className="flex min-w-0 flex-col gap-0.5 pr-6">
<span className="flex items-center gap-2">
<span className="rounded bg-[var(--color-muted)] px-1.5 py-0.5 font-mono text-[11px] font-semibold tracking-wide">{t.id}</span>
<span className="text-sm font-medium">{t.name}</span>
</span>
<span className="text-xs text-[var(--color-muted-foreground)]">실시구간: {t.items} · 총 {t.count}문항</span>
</span>
</button>
);
})}
</div>
</div>
);
}
/* ===== Step 3 — 세션 구성 (드래그 앤 드롭) =====
* Each session has a stable color hue, used for both the chip and the drop zone accent
*/
const SESSION_PALETTE = [
{ ring: "hsl(215 70% 35%)", bg: "hsl(215 70% 35% / 0.06)" }, // primary blue
{ ring: "hsl(160 55% 36%)", bg: "hsl(160 55% 36% / 0.06)" }, // teal-green
{ ring: "hsl(280 50% 45%)", bg: "hsl(280 50% 45% / 0.06)" }, // purple
{ ring: "hsl(35 91% 43%)", bg: "hsl(35 91% 43% / 0.06)" }, // amber
{ ring: "hsl(340 70% 45%)", bg: "hsl(340 70% 45% / 0.06)" }, // rose
];
function StepSessions({ selectedTests, sessionsDraft, setSessionsDraft, testSessionMap, setTestSessionMap, draggingTestId, setDraggingTestId, onTestDragStart, onSessionDrop }) {
const [hotZone, setHotZone] = React.useState(null);
const addSession = () => {
setSessionsDraft((prev) => [
...prev,
{
local_id: `session_${Date.now()}_${prev.length + 1}`,
title: `${prev.length + 1}차 세션`,
description: "이 세션의 검사 안내를 확인한 뒤 응답을 시작합니다.",
},
]);
};
const updateSession = (id, patch) =>
setSessionsDraft((prev) => prev.map((s) => (s.local_id === id ? { ...s, ...patch } : s)));
const removeSession = (id) => {
if (sessionsDraft.length <= 1) return;
const fallback = sessionsDraft.find((s) => s.local_id !== id)?.local_id || "session_1";
setSessionsDraft((prev) => prev.filter((s) => s.local_id !== id));
setTestSessionMap((prev) => {
const next = { ...prev };
Object.keys(next).forEach((tid) => { if (next[tid] === id) next[tid] = fallback; });
return next;
});
};
const sessionColor = (idx) => SESSION_PALETTE[idx % SESSION_PALETTE.length];
if (selectedTests.length === 0) {
return <Empty title="세션 구성" hint="이전 단계에서 검사를 먼저 선택해주세요." body="선택된 검사가 없습니다." />;
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionHeader title="세션 구성" hint="검사 칩을 세션 카드로 드래그해 배정합니다." />
<Button variant="outline" size="sm" onClick={addSession}><IconPlus size={12} /> 세션</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[240px_1fr]">
{/* Draggable test chips — colored by their assigned session */}
<aside className="flex flex-col gap-2 self-start rounded-md border border-[var(--color-border)] bg-[hsl(210_40%_98%)] p-3">
<div className="flex items-baseline justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]">선택한 검사</p>
<span className="text-[11px] text-[var(--color-muted-foreground)]">{selectedTests.length}개</span>
</div>
<p className="text-[11px] leading-relaxed text-[var(--color-muted-foreground)]">아래 칩을 우측 세션 카드로 드래그해서 배정하세요.</p>
<div className="mt-1 flex flex-wrap gap-1.5">
{selectedTests.map((testId) => {
const sIdx = sessionsDraft.findIndex((s) => s.local_id === testSessionMap[testId]);
const c = sessionColor(sIdx);
const beingDragged = draggingTestId === testId;
return (
<button
key={testId}
type="button"
draggable
onDragStart={(e) => onTestDragStart(e, testId)}
onDragEnd={() => setDraggingTestId(null)}
title={`${sessionsDraft[sIdx]?.title || "미배정"}에 배정됨`}
style={{ borderColor: c.ring, background: c.bg }}
className={cn(
"group flex cursor-grab items-center gap-1.5 rounded-md border px-2 py-1.5 font-mono text-[11px] font-semibold shadow-sm transition-transform duration-150 active:cursor-grabbing active:scale-95",
beingDragged && "opacity-30"
)}
>
<IconGrip size={11} className="text-[var(--color-muted-foreground)] group-hover:text-[var(--color-foreground)]" />
{testId}
</button>
);
})}
</div>
</aside>
{/* Session drop zones */}
<div className="flex flex-col gap-3">
{sessionsDraft.map((session, index) => {
const sessionTestIds = selectedTests.filter((t) => testSessionMap[t] === session.local_id);
const c = sessionColor(index);
const isDragHot = !!draggingTestId;
const isHover = hotZone === session.local_id && isDragHot;
return (
<div
key={session.local_id}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (hotZone !== session.local_id) setHotZone(session.local_id);
}}
onDragLeave={() => setHotZone((z) => (z === session.local_id ? null : z))}
onDrop={(e) => { onSessionDrop(e, session.local_id); setHotZone(null); }}
style={isHover ? { borderColor: c.ring, background: c.bg, transform: "scale(1.005)" } : undefined}
className={cn(
"rounded-lg border bg-white p-3 transition-all duration-150",
!isHover && isDragHot && "border-dashed",
!isHover && "border-[var(--color-border)]"
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full" style={{ background: c.ring }} aria-hidden />
<span className="text-xs font-semibold text-[var(--color-muted-foreground)]">세션 {index + 1}</span>
</div>
{sessionsDraft.length > 1 && (
<button onClick={() => removeSession(session.local_id)} className="text-xs text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-destructive)]">삭제</button>
)}
</div>
<div className="mt-2 grid gap-2">
<Input value={session.title} onChange={(e) => updateSession(session.local_id, { title: e.target.value })} placeholder="세션 이름" maxLength={80} />
<Textarea rows={2} value={session.description} onChange={(e) => updateSession(session.local_id, { description: e.target.value })} placeholder="세션 시작 전 보여줄 검사 안내 문구" maxLength={500} />
</div>
<div
className={cn(
"mt-2 flex min-h-9 flex-wrap items-center gap-1.5 rounded border-2 border-dashed p-1.5 transition-colors",
isHover ? "border-transparent" : "border-[var(--color-border)] bg-[hsl(210_40%_96.1%/0.4)]"
)}
style={isHover ? { borderColor: c.ring } : undefined}
>
{sessionTestIds.length === 0 && (
<span className="px-1 text-[11px] text-[var(--color-muted-foreground)]">
{isHover ? "여기에 놓아주세요" : "검사 칩을 여기로 드래그"}
</span>
)}
{sessionTestIds.map((tid) => (
<span
key={tid}
style={{ borderColor: c.ring, background: c.bg }}
className="rounded border px-2 py-0.5 font-mono text-[11px] font-semibold"
>
{tid}
</span>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ===== Step 4 — 척도 선택 (tri-state) ===== */
const SCALE_TREE = {
STS: [
{ cond: "초등 4-6학년", count: 32, scales: [
{ code: "EXT", name: "외현화 척도", items: 12 },
{ code: "INT", name: "내재화 척도", items: 10 },
{ code: "PEER", name: "또래 관계", items: 10 },
]},
{ cond: "중학생", count: 40, scales: [
{ code: "SCH", name: "학교 적응", items: 15 },
{ code: "FAM", name: "가족 관계", items: 12 },
{ code: "SLF", name: "자아 개념", items: 13 },
]},
],
"MMPI-2": [
{ cond: "성인 표준", count: 567, scales: [
{ code: "L", name: "L (부인)", items: 15 },
{ code: "F", name: "F (비전형)", items: 60 },
{ code: "K", name: "K (방어성)", items: 30 },
{ code: "Hs", name: "Hs (건강염려증)", items: 32 },
{ code: "D", name: "D (우울)", items: 57 },
{ code: "Hy", name: "Hy (히스테리)", items: 60 },
]},
],
"K-WISC": [{ cond: "4-6학년", count: 180, scales: [{ code: "VC", name: "언어이해", items: 30 },{ code: "PR", name: "지각추론", items: 35 }] }],
"CES-D": [{ cond: "단축형", count: 20, scales: [{ code: "CESD", name: "전체 척도", items: 20 }] }],
"STAI": [{ cond: "청소년", count: 40, scales: [{ code: "S", name: "상태 불안", items: 20 },{ code: "T", name: "특성 불안", items: 20 }] }],
};
function StepScales({ selectedTests, selectedScaleKeys, setSelectedScaleKeys }) {
const [expanded, setExpanded] = React.useState(() => new Set(selectedTests));
const toggleExpand = (id) => setExpanded((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const allScalesOf = (testId) => (SCALE_TREE[testId] || []).flatMap((c) => c.scales.map((s) => `${testId}::${s.code}`));
const countOf = (testId) => allScalesOf(testId).filter((k) => selectedScaleKeys.has(k)).length;
const totalOf = (testId) => allScalesOf(testId).length;
const toggleScale = (key) => {
setSelectedScaleKeys((prev) => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n; });
};
const setAllForTest = (testId, on) => {
setSelectedScaleKeys((prev) => {
const n = new Set(prev);
allScalesOf(testId).forEach((k) => (on ? n.add(k) : n.delete(k)));
return n;
});
};
if (selectedTests.length === 0) {
return <Empty title="척도 선택" hint="이전 단계에서 검사를 먼저 선택해주세요." body="선택된 검사가 없습니다." />;
}
return (
<div className="flex flex-col gap-4">
<SectionHeader title="척도 선택" hint="검사 선택 후 펼쳐지는 트리에서 사용할 척도를 고릅니다." />
<div className="flex flex-col gap-2">
{selectedTests.map((testId) => {
const tree = SCALE_TREE[testId] || [];
const isOpen = expanded.has(testId);
const sel = countOf(testId);
const tot = totalOf(testId);
const triState = sel === 0 ? "off" : sel === tot ? "on" : "indeterminate";
const pct = tot ? (sel / tot) * 100 : 0;
return (
<div key={testId} className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-white">
<div className="flex items-center gap-2 px-3 py-2.5">
<TriCheckbox
state={triState}
onChange={() => setAllForTest(testId, triState !== "on")}
aria-label={`${testId} 전체 선택`}
/>
<button onClick={() => toggleExpand(testId)} className="flex flex-1 items-center justify-between gap-2 text-left">
<span className="flex items-center gap-2">
<span className="text-[var(--color-muted-foreground)]">{isOpen ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}</span>
<span className="rounded bg-[var(--color-muted)] px-1.5 py-0.5 font-mono text-[11px] font-semibold">{testId}</span>
<span className="text-xs text-[var(--color-muted-foreground)]">실시구간 {tree.length}개 · 척도 {tot}개</span>
</span>
<span className="flex items-center gap-2">
<span className="hidden h-1 w-20 rounded-full bg-[var(--color-muted)] sm:block">
<span className="block h-full rounded-full bg-[var(--color-primary)] transition-all duration-300" style={{ width: `${pct}%` }} />
</span>
<span className="tabular-nums text-xs font-medium">{sel}<span className="text-[var(--color-muted-foreground)]">/{tot}</span></span>
</span>
</button>
</div>
{isOpen && (
<div className="flex flex-col gap-2 border-t border-[var(--color-border)] bg-[hsl(210_40%_96.1%/0.4)] p-2">
{tree.map((cn_) => (
<div key={cn_.cond} className="rounded border border-[var(--color-border)] bg-white">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-2.5 py-1.5">
<span className="text-xs font-medium">{cn_.cond}</span>
<span className="text-[11px] text-[var(--color-muted-foreground)]">{cn_.count}문항</span>
</div>
<div className="flex flex-col">
{cn_.scales.map((sc) => {
const k = `${testId}::${sc.code}`;
const on = selectedScaleKeys.has(k);
return (
<label key={sc.code} className={cn(
"flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-sm transition-colors",
on ? "bg-[hsl(215_70%_35%/0.04)]" : "hover:bg-[var(--color-accent)]"
)}>
<Checkbox checked={on} onChange={() => toggleScale(k)} />
<span className="font-mono text-[11px] text-[var(--color-muted-foreground)]">{sc.code}</span>
<span>{sc.name}</span>
<span className="ml-auto text-[11px] text-[var(--color-muted-foreground)]">{sc.items}문항</span>
</label>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/* ===== Step 5 ===== */
function StepProfile({ profileFields, setProfileFields }) {
const addField = () =>
setProfileFields((prev) => [...prev, { id: `p${Date.now()}`, label: "", type: "short_text", required: false }]);
const updateField = (id, p) =>
setProfileFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
const removeField = (id) =>
setProfileFields((prev) => prev.filter((f) => f.id !== id));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionHeader title="추가 인적사항" hint="검사 URL에서 받을 추가 정보 항목을 정의합니다." />
<Button variant="outline" size="sm" onClick={addField}><IconPlus size={12} /> 항목 추가</Button>
</div>
<div className="flex flex-col gap-2">
{profileFields.length === 0 && (
<div className="rounded-lg border-2 border-dashed border-[var(--color-border)] bg-[hsl(210_40%_98%)] p-8 text-center">
<p className="text-sm font-medium text-[var(--color-foreground)]">추가 인적사항이 없습니다</p>
<p className="mt-1 text-xs text-[var(--color-muted-foreground)]">필요하면 위 + 버튼으로 항목을 추가하세요.</p>
</div>
)}
{profileFields.map((f) => (
<div key={f.id} className="rounded-lg border border-[var(--color-border)] bg-white p-3 transition-shadow hover:shadow-[0_1px_2px_0_rgb(0_0_0/0.04)]">
<div className="grid gap-2 md:grid-cols-2">
<Input value={f.label} onChange={(e) => updateField(f.id, { label: e.target.value })} placeholder="항목명 (예: 학교명)" maxLength={60} />
<Select value={f.type} onChange={(e) => updateField(f.id, { type: e.target.value })}>
<option value="short_text">짧은 텍스트</option>
<option value="long_text">긴 텍스트</option>
<option value="number">숫자</option>
<option value="date">날짜</option>
<option value="select">단일 선택</option>
<option value="multi_select">다중 선택</option>
<option value="phone">전화번호</option>
<option value="email">이메일</option>
</Select>
</div>
<div className="mt-2 flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox checked={f.required} onChange={(e) => updateField(f.id, { required: e.target.checked })} />
필수 입력
</label>
<button onClick={() => removeField(f.id)} className="text-xs text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-destructive)]">항목 삭제</button>
</div>
</div>
))}
</div>
</div>
);
}
/* ===== Bits ===== */
function SectionHeader({ title, hint }) {
return (
<div>
<h4 className="text-base font-semibold leading-tight">{title}</h4>
{hint && <p className="mt-1 text-xs text-[var(--color-muted-foreground)]">{hint}</p>}
</div>
);
}
function Field({ label, hint, children }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--color-muted-foreground)]">{hint}</p>}
</div>
);
}
function Empty({ title, hint, body }) {
return (
<div className="flex flex-col gap-4">
<SectionHeader title={title} hint={hint} />
<div className="rounded-lg border-2 border-dashed border-[var(--color-border)] bg-[hsl(210_40%_98%)] p-10 text-center">
<p className="text-sm font-medium text-[var(--color-foreground)]">{body}</p>
<p className="mt-1 text-xs text-[var(--color-muted-foreground)]">왼쪽 단계 표시기에서 이전 단계로 이동할 수 있습니다.</p>
</div>
</div>
);
}
function TriCheckbox({ state, onChange }) {
// state: 'on' | 'off' | 'indeterminate'
const ref = React.useRef(null);
React.useEffect(() => {
if (ref.current) ref.current.indeterminate = state === "indeterminate";
}, [state]);
return (
<input
ref={ref}
type="checkbox"
checked={state === "on"}
onChange={onChange}
className="size-4 cursor-pointer accent-[var(--color-primary)]"
/>
);
}
/* Icon used for the drag handle (mini SVG — keeps the file self-contained) */
function IconGrip({ size = 12, className }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden>
<circle cx="9" cy="6" r="1" />
<circle cx="15" cy="6" r="1" />
<circle cx="9" cy="12" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="9" cy="18" r="1" />
<circle cx="15" cy="18" r="1" />
</svg>
);
}
Object.assign(window, { CreateTestModal });
/**
* CreateTestModal — 검사 생성 모달 redesign (v2)
*
* Source of truth: Byumin/2.0-modular @ harness-engineering
* - frontend/src/pages/TestManagement.tsx (lines 1022-1390)
* - 현재 production 라벨: 1.검사 선택 / 2.세션 구성 / 3.척도 선택 + 기본 정보 + 추가 인적사항
* - 6 섹션 dense 2-column grid → 단계별 wizard 로 분해
*
* v2 changes (per user feedback m0017):
* - 단계 구성: 기본 → 검사 선택 → 세션 → 척도 → 추가 인적사항 (5 steps)
* - 좌측 rail에서 "구성 요약" 제거 (단계 표시기만 유지)
* - 드래그 앤 드롭 유지 — step 3 (세션) 화면이 좌(검사 칩)/우(세션 drop zones) split.
* production handlers 그대로: handleTestDragStart / handleSessionDrop / draggingTestId
*
* Visual rules from DESIGN.md preserved:
* - max-w-5xl rounded-lg modal, bg-black/40 overlay
* - Tailwind theme tokens (admin palette — primary blue, NOT teal)
* - SectionHeader: text-sm font-semibold + text-xs muted hint
* - Existing Button variants only
*/
const STEPS = [
{ id: 1, key: "basic", title: "기본 정보", hint: "이름과 운영 옵션" },
{ id: 2, key: "tests", title: "검사 선택", hint: "포함할 검사군" },
{ id: 3, key: "sessions", title: "세션 구성", hint: "검사를 세션에 배정" },
{ id: 4, key: "scales", title: "척도 선택", hint: "사용할 척도 트리" },
{ id: 5, key: "profile", title: "추가 인적사항", hint: "URL에서 받을 항목" },
];
function CreateTestModal({ open, onClose }) {
const [step, setStep] = React.useState(1);
// ----- Form state — mirrors production state shape -----
const [name, setName] = React.useState("");
const [intake, setIntake] = React.useState("pre_registered_only");
const [consent, setConsent] = React.useState(true);
const [selectedTests, setSelectedTests] = React.useState(["STS", "MMPI-2"]);
// sessionsDraft + testSessionMap (production naming)
const [sessionsDraft, setSessionsDraft] = React.useState([
{ local_id: "session_1", title: "세션 1", description: "표준화된 검사 안내를 확인한 뒤 응답을 시작합니다." },
{ local_id: "session_2", title: "세션 2", description: "" },
]);
const [testSessionMap, setTestSessionMap] = React.useState({ STS: "session_1", "MMPI-2": "session_2" });
const [draggingTestId, setDraggingTestId] = React.useState(null);
// scale selection (mocked counts for the demo)
const [selectedScales, setSelectedScales] = React.useState({ STS: 3, "MMPI-2": 8 });
// profile fields
const [profileFields, setProfileFields] = React.useState([
{ id: "p1", label: "학교명", type: "short_text", required: true },
]);
if (!open) return null;
// ----- Drag handlers (verbatim from production) -----
const handleTestDragStart = (event, testId) => {
setDraggingTestId(testId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", testId);
};
const handleSessionDrop = (event, sessionId) => {
event.preventDefault();
const testId = event.dataTransfer.getData("text/plain") || draggingTestId;
if (!testId || !selectedTests.includes(testId)) return;
setTestSessionMap((prev) => ({ ...prev, [testId]: sessionId }));
setDraggingTestId(null);
};
const goNext = () => setStep((s) => Math.min(STEPS.length, s + 1));
const goPrev = () => setStep((s) => Math.max(1, s - 1));
const isLast = step === STEPS.length;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 py-6" role="dialog" aria-modal="true">
<section className="flex max-h-full w-full max-w-5xl flex-col rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] shadow-lg overflow-hidden">
{/* Header */}
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
<div>
<h3 className="text-lg font-semibold leading-tight">검사 생성</h3>
<p className="mt-0.5 text-xs text-[var(--color-muted-foreground)]">
{STEPS[step - 1].title} · 단계 {step}/{STEPS.length}
</p>
</div>
<button onClick={onClose} aria-label="닫기" className="flex size-8 items-center justify-center rounded-md text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-foreground)]">
<IconX size={16} />
</button>
</header>
{/* Body */}
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr] overflow-hidden">
{/* Left rail — steps only (no summary, per user request) */}
<aside className="flex flex-col gap-2 border-r border-[var(--color-border)] bg-[hsl(210_40%_98%)] p-4 overflow-auto">
<p className="px-1 pb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--color-muted-foreground)]">단계</p>
<ol className="flex flex-col gap-1">
{STEPS.map((s) => {
const done = s.id < step;
const active = s.id === step;
return (
<li key={s.key}>
<button
onClick={() => setStep(s.id)}
className={cn(
"group flex w-full items-start gap-3 rounded-md px-2.5 py-2 text-left transition-colors",
active && "bg-white shadow-[0_1px_0_0_rgb(0_0_0/0.04)] border border-[var(--color-border)]",
!active && "hover:bg-white/70"
)}
>
<span className={cn(
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold",
done && "bg-[var(--color-primary)] text-[var(--color-primary-foreground)]",
active && "bg-[var(--color-foreground)] text-white",
!done && !active && "bg-white border border-[var(--color-border)] text-[var(--color-muted-foreground)]"
)}>
{done ? <IconCheck size={12} /> : s.id}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium leading-tight">{s.title}</span>
<span className="mt-0.5 block text-[11px] text-[var(--color-muted-foreground)]">{s.hint}</span>
</span>
</button>
</li>
);
})}
</ol>
</aside>
{/* Step content */}
<div className="min-h-0 overflow-auto p-6">
{step === 1 && <StepBasic name={name} setName={setName} intake={intake} setIntake={setIntake} consent={consent} setConsent={setConsent} />}
{step === 2 && <StepTests selected={selectedTests} setSelected={setSelectedTests} testSessionMap={testSessionMap} setTestSessionMap={setTestSessionMap} sessionsDraft={sessionsDraft} />}
{step === 3 && (
<StepSessions
selectedTests={selectedTests}
sessionsDraft={sessionsDraft}
setSessionsDraft={setSessionsDraft}
testSessionMap={testSessionMap}
setTestSessionMap={setTestSessionMap}
draggingTestId={draggingTestId}
setDraggingTestId={setDraggingTestId}
onTestDragStart={handleTestDragStart}
onSessionDrop={handleSessionDrop}
/>
)}
{step === 4 && <StepScales selectedTests={selectedTests} selectedScales={selectedScales} setSelectedScales={setSelectedScales} />}
{step === 5 && <StepProfile profileFields={profileFields} setProfileFields={setProfileFields} />}
</div>
</div>
{/* Footer */}
<footer className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3">
<span className="text-xs text-[var(--color-muted-foreground)]">
{step === 1 && "검사 이름과 운영 옵션을 먼저 확인해주세요."}
{step === 2 && "선택한 검사군이 다음 단계의 세션과 척도 트리에 펼쳐집니다."}
{step === 3 && "검사 칩을 세션 카드로 드래그하여 배정합니다."}
{step === 4 && "선택한 척도가 없는 실시구간은 자동으로 제외됩니다."}
{step === 5 && "검사 URL에서 받을 추가 정보 항목을 정의합니다."}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>닫기</Button>
{step > 1 && <Button variant="outline" onClick={goPrev}><IconChevronLeft size={14} /> 이전</Button>}
{!isLast && <Button onClick={goNext}>다음 <IconChevronRight size={14} /></Button>}
{isLast && <Button onClick={onClose}>생성</Button>}
</div>
</footer>
</section>
</div>
);
}
/* ===== Step 1: 기본 정보 ===== */
function StepBasic({ name, setName, intake, setIntake, consent, setConsent }) {
return (
<div className="flex max-w-xl flex-col gap-5">
<SectionHeader title="기본 정보" hint="검사 이름과 운영 방식을 정합니다." />
<Field label="검사 이름" hint="목록에서 식별할 이름. 최대 120자.">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="예: STS 파일럿-초등용" maxLength={120} />
</Field>
<Field label="내담자 등록 방식" hint="자동 생성 허용은 검사 링크에서 등록과 배정을 진행합니다.">
<Select value={intake} onChange={(e) => setIntake(e.target.value)}>
<option value="pre_registered_only">사전 등록 필수</option>
<option value="auto_create">검사 진행 시 자동 생성 허용</option>
</Select>
</Field>
<Field label="개인정보동의" hint="동의서 내용은 설정 메뉴에서 관리합니다.">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />
수검 전 개인정보 수집·이용 동의 받기
</label>
</Field>
</div>
);
}
/* ===== Step 2: 검사 선택 ===== */
const CATALOG = [
{ id: "STS", name: "정서·사회성 검사", items: "초등 4-6, 중학생", count: 32 },
{ id: "MMPI-2", name: "다면적 인성검사", items: "성인 표준", count: 567 },
{ id: "K-WISC", name: "아동 지능검사", items: "4-6학년", count: 180 },
{ id: "CES-D", name: "우울척도", items: "단축형", count: 20 },
{ id: "STAI", name: "상태특성 불안검사", items: "청소년", count: 40 },
];
function StepTests({ selected, setSelected, testSessionMap, setTestSessionMap, sessionsDraft }) {
const toggle = (id) => {
setSelected((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
// mirror production: when adding, default to first session; when removing, drop mapping
setTestSessionMap((prevMap) => {
const m = { ...prevMap };
if (next.includes(id)) m[id] = m[id] || sessionsDraft[0]?.local_id || "session_1";
else delete m[id];
return m;
});
return next;
});
};
return (
<div className="flex flex-col gap-4">
<SectionHeader title="검사 선택" hint="생성할 커스텀 검사에 포함할 검사군을 고릅니다. 여러 개 선택 가능." />
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{CATALOG.map((t) => {
const on = selected.includes(t.id);
return (
<button
key={t.id}
onClick={() => toggle(t.id)}
className={cn(
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors",
on ? "border-[var(--color-primary)] bg-[hsl(215_70%_35%/0.04)]" : "border-[var(--color-border)] bg-white hover:bg-[var(--color-accent)]"
)}
>
<span className={cn(
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border",
on ? "border-[var(--color-primary)] bg-[var(--color-primary)] text-white" : "border-[var(--color-input)] bg-white"
)}>
{on && <IconCheck size={12} />}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-2">
<span className="font-mono text-xs font-semibold">{t.id}</span>
<span className="text-sm font-medium">{t.name}</span>
</span>
<span className="text-xs text-[var(--color-muted-foreground)]">실시구간: {t.items} · 총 {t.count}문항</span>
</span>
</button>
);
})}
</div>
</div>
);
}
/* ===== Step 3: 세션 구성 (드래그 앤 드롭 유지) =====
* Layout: left = "선택한 검사" 칩 list (draggable), right = session cards (drop zones)
*/
function StepSessions({ selectedTests, sessionsDraft, setSessionsDraft, testSessionMap, setTestSessionMap, draggingTestId, setDraggingTestId, onTestDragStart, onSessionDrop }) {
const addSession = () => {
setSessionsDraft((prev) => [
...prev,
{
local_id: `session_${Date.now()}_${prev.length + 1}`,
title: `세션 ${prev.length + 1}`,
description: "이 세션의 검사 안내를 확인한 뒤 응답을 시작합니다.",
},
]);
};
const updateSession = (id, patch) =>
setSessionsDraft((prev) => prev.map((s) => (s.local_id === id ? { ...s, ...patch } : s)));
const removeSession = (id) => {
if (sessionsDraft.length <= 1) return;
const fallback = sessionsDraft.find((s) => s.local_id !== id)?.local_id || "session_1";
setSessionsDraft((prev) => prev.filter((s) => s.local_id !== id));
setTestSessionMap((prev) => {
const next = { ...prev };
Object.keys(next).forEach((tid) => { if (next[tid] === id) next[tid] = fallback; });
return next;
});
};
if (selectedTests.length === 0) {
return (
<div className="flex flex-col gap-4">
<SectionHeader title="세션 구성" hint="이전 단계에서 검사를 먼저 선택해주세요." />
<div className="rounded-md border border-dashed border-[var(--color-border)] bg-[var(--color-muted)] p-6 text-center text-sm text-[var(--color-muted-foreground)]">
선택된 검사가 없습니다.
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionHeader title="세션 구성" hint="검사 칩을 세션 카드로 드래그해 배정합니다." />
<Button variant="outline" size="sm" onClick={addSession}><IconPlus size={12} /> 세션</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
{/* Draggable test chips */}
<aside className="flex flex-col gap-2 self-start rounded-md border border-[var(--color-border)] bg-[hsl(210_40%_98%)] p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-[var(--color-muted-foreground)]">선택한 검사</p>
<p className="text-[11px] text-[var(--color-muted-foreground)]">아래 칩을 드래그</p>
<div className="mt-1 flex flex-wrap gap-1.5">
{selectedTests.map((testId) => {
const session = sessionsDraft.find((s) => s.local_id === testSessionMap[testId]);
return (
<button
key={testId}
type="button"
draggable
onDragStart={(e) => onTestDragStart(e, testId)}
onDragEnd={() => setDraggingTestId(null)}
title={`${session?.title || "세션 1"}에 배정됨`}
className={cn(
"cursor-grab rounded-md border bg-white px-2.5 py-1.5 font-mono text-[11px] font-semibold shadow-sm transition-colors hover:border-[var(--color-primary)]/40 active:cursor-grabbing",
draggingTestId === testId && "opacity-50"
)}
>
⋮⋮ {testId}
</button>
);
})}
</div>
</aside>
{/* Session drop zones */}
<div className="flex flex-col gap-2">
{sessionsDraft.map((session, index) => {
const sessionTestIds = selectedTests.filter((t) => testSessionMap[t] === session.local_id);
const dragHot = !!draggingTestId;
return (
<div
key={session.local_id}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }}
onDrop={(e) => onSessionDrop(e, session.local_id)}
className={cn(
"rounded-md border bg-white p-3 transition-colors",
dragHot ? "border-[var(--color-primary)]/40 bg-[hsl(215_70%_35%/0.04)]" : "border-[var(--color-border)]"
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[var(--color-muted-foreground)]">세션 {index + 1}</span>
{sessionsDraft.length > 1 && (
<button onClick={() => removeSession(session.local_id)} className="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-destructive)]">삭제</button>
)}
</div>
<div className="mt-2 grid gap-2">
<Input value={session.title} onChange={(e) => updateSession(session.local_id, { title: e.target.value })} placeholder="세션 이름" maxLength={80} />
<Textarea rows={2} value={session.description} onChange={(e) => updateSession(session.local_id, { description: e.target.value })} placeholder="세션 시작 전 보여줄 검사 안내 문구" maxLength={500} />
</div>
<div className="mt-2 flex min-h-7 flex-wrap items-center gap-1.5 rounded border border-dashed border-[var(--color-border)] bg-[hsl(210_40%_96.1%/0.4)] p-1.5">
{sessionTestIds.length === 0 && (
<span className="px-1 text-[11px] text-[var(--color-muted-foreground)]">검사 칩을 여기로 드래그</span>
)}
{sessionTestIds.map((tid) => (
<span key={tid} className="rounded bg-[var(--color-muted)] px-2 py-0.5 font-mono text-[11px] font-semibold">{tid}</span>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ===== Step 4: 척도 선택 ===== */
const SCALE_TREE = {
STS: [
{ cond: "초등 4-6학년", count: 32, scales: [
{ code: "EXT", name: "외현화 척도", items: 12 },
{ code: "INT", name: "내재화 척도", items: 10 },
{ code: "PEER", name: "또래 관계", items: 10 },
]},
{ cond: "중학생", count: 40, scales: [
{ code: "SCH", name: "학교 적응", items: 15 },
{ code: "FAM", name: "가족 관계", items: 12 },
{ code: "SLF", name: "자아 개념", items: 13 },
]},
],
"MMPI-2": [
{ cond: "성인 표준", count: 567, scales: [
{ code: "L", name: "L (부인)", items: 15 },
{ code: "F", name: "F (비전형)", items: 60 },
{ code: "K", name: "K (방어성)", items: 30 },
{ code: "Hs", name: "Hs (건강염려증)", items: 32 },
{ code: "D", name: "D (우울)", items: 57 },
{ code: "Hy", name: "Hy (히스테리)", items: 60 },
]},
],
"K-WISC": [{ cond: "4-6학년", count: 180, scales: [{ code: "VC", name: "언어이해", items: 30 },{ code: "PR", name: "지각추론", items: 35 }] }],
"CES-D": [{ cond: "단축형", count: 20, scales: [{ code: "CESD", name: "전체 척도", items: 20 }] }],
"STAI": [{ cond: "청소년", count: 40, scales: [{ code: "S", name: "상태 불안", items: 20 },{ code: "T", name: "특성 불안", items: 20 }] }],
};
function StepScales({ selectedTests, selectedScales }) {
const [expanded, setExpanded] = React.useState(() => new Set(selectedTests));
const toggle = (id) => setExpanded((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
if (selectedTests.length === 0) {
return (
<div className="flex flex-col gap-4">
<SectionHeader title="척도 선택" hint="이전 단계에서 검사를 먼저 선택해주세요." />
<div className="rounded-md border border-dashed border-[var(--color-border)] bg-[var(--color-muted)] p-6 text-center text-sm text-[var(--color-muted-foreground)]">
선택된 검사가 없습니다.
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<SectionHeader title="척도 선택" hint="검사 선택 후 펼쳐지는 트리에서 사용할 척도를 고릅니다." />
<div className="flex flex-col gap-2">
{selectedTests.map((testId) => {
const tree = SCALE_TREE[testId] || [];
const open = expanded.has(testId);
return (
<div key={testId} className="rounded-md border border-[var(--color-border)] bg-white">
<button onClick={() => toggle(testId)} className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left">
<span className="flex items-center gap-2">
<span>{open ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}</span>
<span className="font-mono text-xs font-semibold">{testId}</span>
<span className="text-xs text-[var(--color-muted-foreground)]">· {tree.reduce((a, b) => a + b.scales.length, 0)}개 척도 · 실시구간 {tree.length}개</span>
</span>
<span className="text-xs text-[var(--color-muted-foreground)]">선택 {selectedScales[testId] || 0}</span>
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-[var(--color-border)] bg-[hsl(210_40%_96.1%/0.4)] p-2">
{tree.map((cn_) => (
<div key={cn_.cond} className="rounded border border-[var(--color-border)] bg-white">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-2.5 py-1.5">
<span className="text-xs font-medium">{cn_.cond}</span>
<span className="text-[11px] text-[var(--color-muted-foreground)]">{cn_.count}문항</span>
</div>
<div className="flex flex-col">
{cn_.scales.map((sc) => (
<label key={sc.code} className="flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-sm hover:bg-[var(--color-accent)]">
<Checkbox defaultChecked={Math.random() > 0.4} />
<span className="font-mono text-[11px] text-[var(--color-muted-foreground)]">{sc.code}</span>
<span>{sc.name}</span>
<span className="ml-auto text-[11px] text-[var(--color-muted-foreground)]">{sc.items}문항</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/* ===== Step 5: 추가 인적사항 ===== */
function StepProfile({ profileFields, setProfileFields }) {
const addField = () =>
setProfileFields((prev) => [...prev, { id: `p${Date.now()}`, label: "", type: "short_text", required: false }]);
const updateField = (id, p) =>
setProfileFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
const removeField = (id) =>
setProfileFields((prev) => prev.filter((f) => f.id !== id));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionHeader title="추가 인적사항" hint="검사 URL에서 받을 추가 정보 항목을 정의합니다." />
<Button variant="outline" size="sm" onClick={addField}><IconPlus size={12} /> 항목 추가</Button>
</div>
<div className="flex flex-col gap-2">
{profileFields.length === 0 && (
<p className="rounded-md border border-dashed border-[var(--color-border)] bg-[var(--color-muted)] p-6 text-center text-xs text-[var(--color-muted-foreground)]">
추가 인적사항이 없습니다.
</p>
)}
{profileFields.map((f) => (
<div key={f.id} className="rounded-md border border-[var(--color-border)] bg-white p-3">
<div className="grid gap-2 md:grid-cols-2">
<Input value={f.label} onChange={(e) => updateField(f.id, { label: e.target.value })} placeholder="항목명 (예: 학교명)" maxLength={60} />
<Select value={f.type} onChange={(e) => updateField(f.id, { type: e.target.value })}>
<option value="short_text">짧은 텍스트</option>
<option value="long_text">긴 텍스트</option>
<option value="number">숫자</option>
<option value="date">날짜</option>
<option value="select">단일 선택</option>
<option value="multi_select">다중 선택</option>
<option value="phone">전화번호</option>
<option value="email">이메일</option>
</Select>
</div>
<div className="mt-2 flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox checked={f.required} onChange={(e) => updateField(f.id, { required: e.target.checked })} />
필수 입력
</label>
<button onClick={() => removeField(f.id)} className="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-destructive)]">항목 삭제</button>
</div>
</div>
))}
</div>
</div>
);
}
/* ===== Bits ===== */
function SectionHeader({ title, hint }) {
return (
<div>
<h4 className="text-sm font-semibold leading-tight">{title}</h4>
{hint && <p className="mt-0.5 text-xs text-[var(--color-muted-foreground)]">{hint}</p>}
</div>
);
}
function Field({ label, hint, children }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--color-muted-foreground)]">{hint}</p>}
</div>
);
}
Object.assign(window, { CreateTestModal });
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>Modular Admin · UI Kit</title>
<link rel="stylesheet" href="../../colors_and_type.css" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<style>body{font-family:'Pretendard','Noto Sans KR','Apple SD Gothic Neo','Malgun Gothic',sans-serif;}</style>
</head>
<body class="h-screen w-screen overflow-hidden bg-[hsl(210_40%_98%)]">
<div id="root" class="h-full w-full"></div>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="AppShell.jsx"></script>
<script type="text/babel" src="TestManagementPage.jsx"></script>
<script type="text/babel" src="CreateTestModal.jsx"></script>
<script type="text/babel">
function App() {
const [open, setOpen] = React.useState(true);
return (
<div className="flex h-full w-full">
<AppSidebar active="tests" />
<main className="flex min-w-0 flex-1 flex-col bg-white">
<TestManagementPage onCreate={() => setOpen(true)} />
</main>
<CreateTestModal open={open} onClose={() => setOpen(false)} />
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>
/**
* Modular Admin — UI primitives
* Pixel-faithful recreations of the shadcn-style components in
* 2.0-modular/frontend/src/components/ui/*
*/
const { useState, useEffect, useRef, useMemo } = React;
/* ---- cn() ---- */
const cn = (...xs) => xs.filter(Boolean).join(" ");
/* ---- Tabler-style line icons (inline SVG, 2px stroke) ---- */
const Icon = ({ d, size = 16, ...rest }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...rest}>
{Array.isArray(d) ? d.map((p, i) => <path key={i} d={p} />) : <path d={d} />}
</svg>
);
const IconBrain = (p) => <svg {...p} width={p.size||22} height={p.size||22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"/><path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8"/><path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5"/><path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0"/><path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5"/><path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10"/></svg>;
const IconDashboard = (p) => <Icon size={p.size||16} d={["M4 4h6v8H4z","M14 4h6v4h-6z","M14 12h6v8h-6z","M4 16h6v4H4z"]} />;
const IconClipboard = (p) => <Icon size={p.size||16} d={["M6 3h12v18H6z","M9 7h6","M9 11h6","M9 15h4"]} />;
const IconUsers = (p) => <Icon size={p.size||16} d={["M9 7a4 4 0 1 0 0 8 4 4 0 0 0 0 -8","M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2","M17 5a2 2 0 1 1 0 4","M21 21v-2a4 4 0 0 0 -3 -3.9"]} />;
const IconUserSearch = (p) => <Icon size={p.size||16} d={["M11 11a4 4 0 1 0 0 -8 4 4 0 0 0 0 8","M3 21v-2a4 4 0 0 1 4 -4h2","M16.5 17.5a3 3 0 1 0 0 -6 3 3 0 0 0 0 6","M21 21l-1.5 -1.5"]} />;
const IconSettings = (p) => <Icon size={p.size||16} d={["M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0 -6","M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1 -2.8 2.8l-.1 -.1a1.7 1.7 0 0 0 -1.8 -.3 1.7 1.7 0 0 0 -1 1.5V21a2 2 0 1 1 -4 0v-.1a1.7 1.7 0 0 0 -1 -1.5 1.7 1.7 0 0 0 -1.8 .3l-.1 .1a2 2 0 1 1 -2.8 -2.8l.1 -.1a1.7 1.7 0 0 0 .3 -1.8 1.7 1.7 0 0 0 -1.5 -1H3a2 2 0 1 1 0 -4h.1a1.7 1.7 0 0 0 1.5 -1 1.7 1.7 0 0 0 -.3 -1.8l-.1 -.1a2 2 0 1 1 2.8 -2.8l.1 .1a1.7 1.7 0 0 0 1.8 .3h.1a1.7 1.7 0 0 0 1 -1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8 -.3l.1 -.1a2 2 0 1 1 2.8 2.8l-.1 .1a1.7 1.7 0 0 0 -.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0 -1.5 1Z"]} />;
const IconLogout = (p) => <Icon size={p.size||16} d={["M9 21H5a2 2 0 0 1 -2 -2V5a2 2 0 0 1 2 -2h4","M16 17l5 -5 -5 -5","M21 12H9"]} />;
const IconPlus = (p) => <Icon size={p.size||14} d={["M12 5v14","M5 12h14"]} />;
const IconSearch = (p) => <Icon size={p.size||16} d={["M11 17a6 6 0 1 0 0 -12 6 6 0 0 0 0 12","M21 21l-4 -4"]} />;
const IconLink = (p) => <Icon size={p.size||14} d={["M9 17H7a5 5 0 0 1 0 -10h2","M15 7h2a5 5 0 0 1 0 10h-2","M8 12h8"]} />;
const IconTrash = (p) => <Icon size={p.size||14} d={["M4 7h16","M10 11v6","M14 11v6","M5 7l1 13a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -13","M9 7V4a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"]} />;
const IconChevronDown = (p) => <Icon size={p.size||14} d="M6 9l6 6 6 -6" />;
const IconChevronRight = (p) => <Icon size={p.size||14} d="M9 6l6 6 -6 6" />;
const IconChevronLeft = (p) => <Icon size={p.size||14} d="M15 6l-6 6 6 6" />;
const IconCheck = (p) => <Icon size={p.size||14} d="M5 12l5 5 9 -9" />;
const IconX = (p) => <Icon size={p.size||14} d={["M18 6 6 18","M6 6l12 12"]} />;
const IconGrip = (p) => <Icon size={p.size||14} d={["M9 5h.01","M9 12h.01","M9 19h.01","M15 5h.01","M15 12h.01","M15 19h.01"]} />;
const IconAlertCircle = (p) => <Icon size={p.size||14} d={["M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0 -18","M12 8v4","M12 16h.01"]} />;
const IconInfoCircle = (p) => <Icon size={p.size||14} d={["M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0 -18","M12 8h.01","M11 12h1v4h1"]} />;
/* ---- Button (matches buttonVariants) ---- */
function Button({ variant = "default", size = "default", className = "", children, ...rest }) {
const base = "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[hsl(215_70%_35%/0.5)] disabled:pointer-events-none disabled:opacity-50";
const variants = {
default: "bg-[var(--color-primary)] text-[var(--color-primary-foreground)] hover:bg-[hsl(215_70%_31%)]",
destructive: "bg-[var(--color-destructive)] text-white hover:bg-[hsl(0_84.2%_55%)]",
outline: "border border-[var(--color-border)] bg-white shadow-[0_1px_0_0_rgb(0_0_0/0.04)] hover:bg-[var(--color-accent)]",
secondary: "bg-[var(--color-secondary)] text-[hsl(222.2_47.4%_11.2%)] hover:bg-[hsl(210_40%_92%)]",
ghost: "hover:bg-[var(--color-accent)] text-[var(--color-foreground)]",
link: "text-[var(--color-primary)] underline-offset-4 hover:underline px-0",
};
const sizes = {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs rounded-md",
lg: "h-10 px-6 rounded-md",
icon: "size-9",
};
return <button className={cn(base, variants[variant], sizes[size], className)} {...rest}>{children}</button>;
}
/* ---- Input ---- */
function Input({ className = "", ...rest }) {
return <input className={cn(
"flex h-9 w-full rounded-md border border-[var(--color-input)] bg-white px-3 py-1 text-sm shadow-[0_1px_0_0_rgb(0_0_0/0.04)] transition-colors placeholder:text-[var(--color-muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(215_70%_35%/0.5)] disabled:cursor-not-allowed disabled:opacity-50",
className
)} {...rest} />;
}
/* ---- Select (native styled) ---- */
function Select({ className = "", children, ...rest }) {
return <select className={cn("h-9 rounded-md border border-[var(--color-input)] bg-white px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(215_70%_35%/0.5)]", className)} {...rest}>{children}</select>;
}
/* ---- Textarea ---- */
function Textarea({ className = "", ...rest }) {
return <textarea className={cn(
"w-full resize-none rounded-md border border-[var(--color-input)] bg-white px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(215_70%_35%/0.5)]",
className
)} {...rest} />;
}
/* ---- Badge ---- */
function Badge({ variant = "default", className = "", children }) {
const variants = {
default: "border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)]",
secondary: "border-transparent bg-[var(--color-secondary)] text-[hsl(222.2_47.4%_11.2%)]",
destructive: "border-transparent bg-[var(--color-destructive)] text-white",
outline: "border border-[var(--color-border)] text-[var(--color-foreground)] bg-white",
success: "border-transparent bg-[hsl(141_78.9%_85.1%)] text-[hsl(142_76%_26%)]",
warning: "border-transparent bg-[hsl(48_96%_89%)] text-[hsl(35_91%_33%)]",
};
return <span className={cn("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors", variants[variant], className)}>{children}</span>;
}
/* ---- Card ---- */
function Card({ className = "", children, ...rest }) {
return <div className={cn("flex flex-col gap-6 rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] py-6 text-[var(--color-card-foreground)] shadow-sm", className)} {...rest}>{children}</div>;
}
function CardHeader({ className = "", children }) {
return <div className={cn("grid auto-rows-min gap-2 px-6", className)}>{children}</div>;
}
function CardContent({ className = "", children }) {
return <div className={cn("px-6", className)}>{children}</div>;
}
/* ---- Checkbox (custom-styled native) ---- */
function Checkbox({ className = "", ...rest }) {
return <input type="checkbox" className={cn(
"size-3.5 rounded border-[var(--color-input)] accent-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[hsl(215_70%_35%/0.4)]",
className
)} {...rest} />;
}
Object.assign(window, {
cn, Icon, Button, Input, Select, Textarea, Badge, Card, CardHeader, CardContent, Checkbox,
IconBrain, IconDashboard, IconClipboard, IconUsers, IconUserSearch, IconSettings, IconLogout,
IconPlus, IconSearch, IconLink, IconTrash, IconChevronDown, IconChevronRight, IconChevronLeft,
IconCheck, IconX, IconGrip, IconAlertCircle, IconInfoCircle,
});
http://localhost:5120/assessment/custom/pj4TjH1o6Mhy1uVq09G9s9CnaytZlrrn