style(design): FINDING-011 — 타이포 스케일 정수 6단으로 통일 (본문 14px) [subagent]

작은 앱에 0.5px 간격 10종(10.5~22)이 혼재 — 12 vs 12.5 는 위계를
만들지 못하고 half-pixel 은 zoom/서브픽셀에서 반올림이 갈린다.
{11,12,13,14,17,22} 정수 6단으로 정규화(순서 보존 매핑), 본문
13.5→14px(FINDING-004). 빌드·렌더 검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-13 22:58:14 +09:00
parent 29e54ad838
commit 645a0362b0
8 changed files with 31 additions and 31 deletions

View File

@ -25,10 +25,10 @@ export default function Layout() {
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span>
<div>
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
<div className="text-[10.5px] font-medium text-ink-400">인터넷 최저가 검색</div>
<div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div>
</div>
</div>
<div className="flex items-center gap-2 px-2 text-[11.5px] font-semibold text-ink-500 md:hidden">
<div className="flex items-center gap-2 px-2 text-[12px] font-semibold text-ink-500 md:hidden">
<span className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
{up ? "연결됨" : "연결 안 됨"}
</div>
@ -52,7 +52,7 @@ export default function Layout() {
))}
</ul>
</nav>
<div className="mt-8 hidden items-center gap-2 px-3 text-[11.5px] font-semibold text-ink-500 md:flex">
<div className="mt-8 hidden items-center gap-2 px-3 text-[12px] font-semibold text-ink-500 md:flex">
<span
className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`}
aria-hidden

View File

@ -32,7 +32,7 @@ export function StatTile({ label, value, sub, tone = "default" }: {
}[tone];
return (
<div className="rounded-xl border border-line-200 bg-surface px-4 py-3">
<div className="text-[11.5px] font-semibold text-ink-500">{label}</div>
<div className="text-[12px] font-semibold text-ink-500">{label}</div>
<div className={`mt-0.5 text-[22px] font-bold leading-tight ${toneCls}`}>{value}</div>
{sub && <div className="mt-0.5 text-[11px] text-ink-400">{sub}</div>}
</div>
@ -57,17 +57,17 @@ export function StatusBadge({ status }: { status: JobStatusName }) {
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="py-8 text-center text-[12.5px] text-ink-400">{children}</p>;
return <p className="py-8 text-center text-[13px] text-ink-400">{children}</p>;
}
export function ErrorNote({ error }: { error: unknown }) {
return (
<p role="alert" className="rounded-lg bg-dead-50 px-3 py-2 text-[12.5px] font-medium text-dead-600">
<p role="alert" className="rounded-lg bg-dead-50 px-3 py-2 text-[13px] font-medium text-dead-600">
{error instanceof Error ? error.message : "요청에 실패했습니다"}
</p>
);
}
export function Loading() {
return <p className="py-8 text-center text-[12.5px] text-ink-400" aria-busy="true">불러오는 중…</p>;
return <p className="py-8 text-center text-[13px] text-ink-400" aria-busy="true">불러오는 중…</p>;
}

View File

@ -50,7 +50,7 @@
}
body {
@apply bg-page text-ink-900 font-sans text-[13.5px] antialiased;
@apply bg-page text-ink-900 font-sans text-[14px] antialiased;
}
/* 테이블 숫자 열 정렬 */
@ -61,5 +61,5 @@ body {
/* recharts 기본 폰트 */
.recharts-wrapper,
.recharts-tooltip-wrapper {
font-size: 11.5px;
font-size: 12px;
}

View File

@ -65,11 +65,11 @@ export default function Crawl() {
{ip.data && (
blockCount === 0 ? (
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[12.5px] font-semibold text-ok-600">
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[13px] font-semibold text-ok-600">
✓ 기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다
</p>
) : (
<p role="alert" className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[12.5px] font-semibold text-dead-600">
<p role="alert" className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[13px] font-semibold text-dead-600">
⚠ 예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다.
요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget)
</p>
@ -212,7 +212,7 @@ export default function Crawl() {
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
<td className="py-1.5">
<span className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold" style={{ color: r.color }}>
<span className="inline-flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: r.color }}>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
{r.label}
</span>

View File

@ -50,12 +50,12 @@ export default function Dashboard() {
{ops.isError && <ErrorNote error={ops.error} />}
{alerts.map((a) => (
<p key={a.key} role="alert"
className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[12.5px] font-semibold text-dead-600">
className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[13px] font-semibold text-dead-600">
⚠ {a.msg}
</p>
))}
{d && alerts.length === 0 && (
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[12.5px] font-semibold text-ok-600">
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[13px] font-semibold text-ok-600">
✓ 모든 지표가 임계 안에 있습니다
</p>
)}

View File

@ -52,7 +52,7 @@ export default function Jobs() {
</div>
<form className="ml-auto flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); setPage(0); }}>
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드·상품명 검색"
className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[12.5px] outline-none focus:border-primary-600" />
className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form>
</div>
@ -64,7 +64,7 @@ export default function Jobs() {
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-[12.5px]">
<table className="w-full text-left text-[13px]">
<thead>
<tr className="border-b border-line-200 text-[11px] text-ink-400">
<th className="pb-1.5 pr-3 font-semibold">상품</th>
@ -87,7 +87,7 @@ export default function Jobs() {
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
<td className="tnum py-2 text-[11.5px] text-ink-500">{dateShort(j.created_at)}</td>
<td className="tnum py-2 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
</tr>
))}
</tbody>
@ -128,8 +128,8 @@ function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => v
const out = detail.data?.output as Record<string, any> | undefined;
return (
<Card title="상세" hint={<button onClick={onClose} className="font-semibold text-ink-400 hover:text-ink-700">닫기</button>}>
<dl className="space-y-2 text-[12.5px]">
<Row k="작업 ID"><span className="tnum break-all text-[11.5px]">{job.job_id}</span></Row>
<dl className="space-y-2 text-[13px]">
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row>
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row>
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row>
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>}
@ -146,7 +146,7 @@ function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => v
{job.status === "DEAD" && (
<div className="mt-4 border-t border-line-100 pt-3">
<button onClick={() => requeue.mutate()} disabled={requeue.isPending}
className="w-full rounded-lg bg-primary-600 px-3 py-2 text-[12.5px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
className="w-full rounded-lg bg-primary-600 px-3 py-2 text-[13px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
</button>
{requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>}
@ -156,8 +156,8 @@ function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => v
{out && (
<details className="mt-3">
<summary className="cursor-pointer text-[11.5px] font-semibold text-ink-400">원본 결과(JSON)</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[10.5px] leading-relaxed">{JSON.stringify(out, null, 2)}</pre>
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400">원본 결과(JSON)</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[11px] leading-relaxed">{JSON.stringify(out, null, 2)}</pre>
</details>
)}
</Card>
@ -167,7 +167,7 @@ function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => v
function Row({ k, children }: { k: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<dt className="w-16 shrink-0 text-[11.5px] font-semibold text-ink-400">{k}</dt>
<dt className="w-16 shrink-0 text-[12px] font-semibold text-ink-400">{k}</dt>
<dd className="min-w-0 flex-1">{children}</dd>
</div>
);

View File

@ -33,7 +33,7 @@ export default function Products() {
<form className="flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); }}>
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드 검색"
className="w-64 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[12.5px] outline-none focus:border-primary-600" />
className="w-64 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form>
@ -49,8 +49,8 @@ export default function Products() {
<button onClick={() => setSelected(p)}
className={`w-full px-1 py-2 text-left hover:bg-surface-2 ${selected?.product_code === p.product_code ? "bg-primary-50/60" : ""}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[12.5px] font-semibold">{p.display_name || p.product_code}</span>
<span className="tnum shrink-0 text-[12.5px] font-bold">{won(p.final_lowest)}</span>
<span className="truncate text-[13px] font-semibold">{p.display_name || p.product_code}</span>
<span className="tnum shrink-0 text-[13px] font-bold">{won(p.final_lowest)}</span>
</div>
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
<span>{p.product_code} · 검색 {p.searches}회</span>
@ -96,7 +96,7 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
title={<>가격 추이 — {product.display_name || product.product_code}</>}
hint={
<button onClick={() => research.mutate()} disabled={research.isPending}
className="rounded-md bg-primary-600 px-2.5 py-1 text-[11.5px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
className="rounded-md bg-primary-600 px-2.5 py-1 text-[12px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{research.isPending ? "요청 중…" : "지금 다시 검색"}
</button>
}>
@ -160,7 +160,7 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
</Card>
<Card title="검증 링크 (최근 검색)">
<ul className="space-y-2 text-[12.5px]">
<ul className="space-y-2 text-[13px]">
{latest.naver_url && (
<li>
<a href={latest.naver_url} target="_blank" rel="noreferrer" className="font-semibold text-primary-600 hover:underline">

View File

@ -26,7 +26,7 @@ export default function Settings() {
<label htmlFor="base" className="mb-1 block text-[12px] font-semibold text-ink-700">API 주소</label>
<input id="base" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)}
placeholder="비우면 현재 주소 기준 (개발: vite 프록시 → :9600)"
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[12.5px] outline-none focus:border-primary-600" />
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
<p className="mt-1 text-[11px] text-ink-400">예: https://lps.example.com — dev 서버를 직접 보려면 해당 주소 입력</p>
</div>
<div>
@ -34,10 +34,10 @@ export default function Settings() {
<input id="key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)}
placeholder="개발(개방 모드)은 비워둠 — prod 만 필요"
autoComplete="off"
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[12.5px] outline-none focus:border-primary-600" />
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
<p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p>
</div>
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-[12.5px] font-semibold text-white hover:bg-primary-700">
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-[13px] font-semibold text-white hover:bg-primary-700">
저장
</button>
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}