Skip to content

Commit 1966599

Browse files
SOIVclaude
andcommitted
feat(controls): P0/P0.5 Control 전 항목 구현 및 라이트/다크 테마 시스템 구축
**packages/controls:** - P0: Button, Input, Select, Checkbox/CheckboxGroup, RadioGroup, Switch, Modal, FormField, Alert, Progress/StepProgress - P0.5: Textarea, PasswordInput(strength), OtpInput, SearchInput(debounce), Spinner(blocking/inline), ToastProvider/useToast, EmptyState, Skeleton - controls.css: fs- 접두사, CSS 변수 기반 라이트/다크 공통 스타일 - package.json: ESM 전환, react peerDep, exports.styles 추가 - CONTROL_DESCRIPTORS: P0/P0.5 ready: true 반영 **apps/web/src/styles/global.css:** - 라이트 모드(:root 기본값) + 다크 모드([data-theme="dark"] / prefers-color-scheme) 분리 - --ok-subtle / --warn-subtle / --err-subtle / --info-subtle 토큰 추가 - 기존 유틸 클래스 하드코딩 색상을 CSS 변수로 교체 **apps/web:** - main.tsx: controls.css import, ThemeSetting 타입 + applyTheme/loadTheme 헬퍼, 앱 초기화 시 FOUC 방지 테마 적용, App 컴포넌트에 theme 상태 추가 - SettingsView: theme prop 수신 → 테마 변경 시 data-theme + localStorage 저장 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 82d4186 commit 1966599

27 files changed

Lines changed: 2141 additions & 90 deletions

apps/web/src/main.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
33

44
import "./styles/global.css";
55
import "./styles/login.css";
6+
import "@fieldstack/controls/styles";
67

78
import { AppShell, type RouteKey } from "./components/AppShell";
89
import { AdminPinModal } from "./components/AdminPinModal";
@@ -46,6 +47,30 @@ function getRouteFromHash(rawHash: string): RouteKey {
4647
return (valid as string[]).includes(hash) ? (hash as RouteKey) : "login";
4748
}
4849

50+
// ─── Theme ────────────────────────────────────────────────────
51+
type ThemeSetting = "light" | "dark" | "system";
52+
53+
function applyTheme(setting: ThemeSetting) {
54+
const root = document.documentElement;
55+
if (setting === "system") {
56+
root.removeAttribute("data-theme");
57+
} else {
58+
root.setAttribute("data-theme", setting);
59+
}
60+
try { localStorage.setItem("fs_theme", setting); } catch { /* ignore */ }
61+
}
62+
63+
function loadTheme(): ThemeSetting {
64+
try {
65+
const saved = localStorage.getItem("fs_theme");
66+
if (saved === "light" || saved === "dark" || saved === "system") return saved;
67+
} catch { /* ignore */ }
68+
return "system";
69+
}
70+
71+
// 초기 테마 적용 (React 렌더 전에 FOUC 방지)
72+
applyTheme(loadTheme());
73+
4974
// ─── Session Storage Keys ─────────────────────────────────────
5075
const SS = {
5176
auth: "fs_auth",
@@ -57,6 +82,13 @@ const SS = {
5782

5883
// ─── App Root ─────────────────────────────────────────────────
5984
function App({ installMode }: { installMode: InstallMode }) {
85+
const [theme, setTheme] = useState<ThemeSetting>(loadTheme);
86+
87+
const handleThemeChange = (next: ThemeSetting) => {
88+
setTheme(next);
89+
applyTheme(next);
90+
};
91+
6092
const [isAuthenticated, setIsAuthenticated] = useState(
6193
() => sessionStorage.getItem(SS.auth) === "true",
6294
);
@@ -272,6 +304,8 @@ function App({ installMode }: { installMode: InstallMode }) {
272304
{isSettingsOpen && (
273305
<SettingsView
274306
isAdmin={isAdmin}
307+
theme={theme}
308+
onThemeChange={handleThemeChange}
275309
onClose={() => setIsSettingsOpen(false)}
276310
onToggleAdmin={() => {
277311
setIsAdmin((prev) => {

apps/web/src/styles/global.css

Lines changed: 116 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,122 @@
1-
/* ─── Design Tokens ──────────────────────────────────────────── */
1+
/* ─── Design Tokens: Light Mode (default) ────────────────────── */
22
:root {
3-
color-scheme: dark;
3+
color-scheme: light dark;
44

55
/* Backgrounds */
6+
--bg: #f4f4f9;
7+
--bg-surface: #ffffff;
8+
--bg-elevated: #ededf6;
9+
--bg-hover: #e4e4f0;
10+
11+
/* Borders */
12+
--border: #d0d0e4;
13+
--border-subtle: #e4e4f2;
14+
15+
/* Text */
16+
--text: #0e0e18;
17+
--text-muted: #525278;
18+
--text-faint: #9898b8;
19+
20+
/* Accent (indigo — darker for light bg) */
21+
--accent: #5252e0;
22+
--accent-hover: #3e3ece;
23+
--accent-subtle: rgba(82, 82, 224, 0.08);
24+
--accent-border: rgba(82, 82, 224, 0.20);
25+
26+
/* Status */
27+
--ok: #16a34a;
28+
--warn: #b45309;
29+
--err: #b91c1c;
30+
--info: #1d4ed8;
31+
--ok-subtle: rgba(22, 163, 74, 0.10);
32+
--warn-subtle:rgba(180, 83, 9, 0.10);
33+
--err-subtle: rgba(185, 28, 28, 0.10);
34+
--info-subtle:rgba(29, 78, 216, 0.10);
35+
36+
/* Aliases */
37+
--primary: var(--accent);
38+
--primary-strong: var(--accent-hover);
39+
--focus-ring: rgba(82, 82, 224, 0.20);
40+
--surface: var(--bg-elevated);
41+
--muted: var(--text-muted);
42+
43+
/* Layout */
44+
--sidebar-width: 220px;
45+
--content-max: 900px;
46+
}
47+
48+
/* ─── Design Tokens: Dark Mode ───────────────────────────────── */
49+
/* OS preference (no explicit theme set) */
50+
@media (prefers-color-scheme: dark) {
51+
:root:not([data-theme="light"]) {
52+
color-scheme: dark;
53+
54+
--bg: #0e0e14;
55+
--bg-surface: #13131b;
56+
--bg-elevated: #1a1a25;
57+
--bg-hover: #20202e;
58+
59+
--border: #2a2a3c;
60+
--border-subtle: #1d1d2a;
61+
62+
--text: #e0e0ec;
63+
--text-muted: #7878a0;
64+
--text-faint: #44445c;
65+
66+
--accent: #7c7cf0;
67+
--accent-hover: #9191f5;
68+
--accent-subtle: rgba(124, 124, 240, 0.12);
69+
--accent-border: rgba(124, 124, 240, 0.20);
70+
71+
--ok: #4ade80;
72+
--warn: #fbbf24;
73+
--err: #f87171;
74+
--info: #60a5fa;
75+
--ok-subtle: rgba(74, 222, 128, 0.12);
76+
--warn-subtle:rgba(251, 191, 36, 0.12);
77+
--err-subtle: rgba(248, 113, 113, 0.12);
78+
--info-subtle:rgba(96, 165, 250, 0.12);
79+
80+
--focus-ring: rgba(124, 124, 240, 0.28);
81+
}
82+
}
83+
84+
/* Explicit dark theme */
85+
[data-theme="dark"] {
86+
color-scheme: dark;
87+
688
--bg: #0e0e14;
789
--bg-surface: #13131b;
890
--bg-elevated: #1a1a25;
991
--bg-hover: #20202e;
1092

11-
/* Borders */
1293
--border: #2a2a3c;
1394
--border-subtle: #1d1d2a;
1495

15-
/* Text */
1696
--text: #e0e0ec;
1797
--text-muted: #7878a0;
1898
--text-faint: #44445c;
1999

20-
/* Accent (indigo) */
21100
--accent: #7c7cf0;
22101
--accent-hover: #9191f5;
23102
--accent-subtle: rgba(124, 124, 240, 0.12);
103+
--accent-border: rgba(124, 124, 240, 0.20);
104+
105+
--ok: #4ade80;
106+
--warn: #fbbf24;
107+
--err: #f87171;
108+
--info: #60a5fa;
109+
--ok-subtle: rgba(74, 222, 128, 0.12);
110+
--warn-subtle:rgba(251, 191, 36, 0.12);
111+
--err-subtle: rgba(248, 113, 113, 0.12);
112+
--info-subtle:rgba(96, 165, 250, 0.12);
113+
114+
--focus-ring: rgba(124, 124, 240, 0.28);
115+
}
24116

25-
/* Status */
26-
--ok: #4ade80;
27-
--warn: #fbbf24;
28-
--err: #f87171;
29-
--info: #60a5fa;
30-
31-
/* Aliases for backwards compat */
32-
--primary: var(--accent);
33-
--primary-strong: var(--accent-hover);
34-
--focus-ring: rgba(124, 124, 240, 0.28);
35-
--surface: var(--bg-elevated);
36-
--muted: var(--text-muted);
37-
38-
/* Layout */
39-
--sidebar-width: 220px;
40-
--content-max: 900px;
117+
/* Explicit light theme */
118+
[data-theme="light"] {
119+
color-scheme: light;
41120
}
42121

43122
/* ─── Reset ──────────────────────────────────────────────────── */
@@ -94,15 +173,15 @@ body {
94173
}
95174

96175
.badge-danger {
97-
background: rgba(248, 113, 113, 0.14);
98-
color: #f87171;
99-
border: 1px solid rgba(248, 113, 113, 0.2);
176+
background: var(--err-subtle);
177+
color: var(--err);
178+
border: 1px solid color-mix(in srgb, var(--err) 20%, transparent);
100179
}
101180

102181
.badge-soft {
103182
background: var(--accent-subtle);
104183
color: var(--accent-hover);
105-
border: 1px solid rgba(124, 124, 240, 0.2);
184+
border: 1px solid var(--accent-border);
106185
}
107186

108187
.chip {
@@ -114,10 +193,10 @@ body {
114193
font-weight: 700;
115194
}
116195

117-
.chip-ready { background: rgba(74, 222, 128, 0.12); color: #4ade80; }
118-
.chip-loading { background: rgba(96, 165, 250, 0.12); color: #60a5fa; }
119-
.chip-empty { background: rgba(251, 191, 36, 0.12); color: #fbbf24; }
120-
.chip-error { background: rgba(248, 113, 113, 0.12); color: #f87171; }
196+
.chip-ready { background: var(--ok-subtle); color: var(--ok); }
197+
.chip-loading { background: var(--info-subtle); color: var(--info); }
198+
.chip-empty { background: var(--warn-subtle); color: var(--warn); }
199+
.chip-error { background: var(--err-subtle); color: var(--err); }
121200

122201
/* ─── Form Controls ──────────────────────────────────────────── */
123202
.field {
@@ -157,6 +236,7 @@ body {
157236

158237
.select option {
159238
background: var(--bg-elevated);
239+
color: var(--text);
160240
}
161241

162242
/* ─── Buttons ────────────────────────────────────────────────── */
@@ -176,7 +256,6 @@ body {
176256
.button:hover {
177257
background: var(--bg-hover);
178258
color: var(--text);
179-
border-color: var(--border);
180259
}
181260

182261
.button-primary {
@@ -191,13 +270,13 @@ body {
191270
}
192271

193272
.button-danger {
194-
background: rgba(248, 113, 113, 0.1);
273+
background: var(--err-subtle);
195274
color: var(--err);
196-
border-color: rgba(248, 113, 113, 0.2);
275+
border-color: color-mix(in srgb, var(--err) 20%, transparent);
197276
}
198277

199278
.button-danger:hover {
200-
background: rgba(248, 113, 113, 0.18);
279+
background: color-mix(in srgb, var(--err) 18%, transparent);
201280
}
202281

203282
.button-block {
@@ -242,10 +321,10 @@ body {
242321
color: var(--text-muted);
243322
}
244323

245-
.status-ready { border-color: rgba(74, 222, 128, 0.25); }
246-
.status-loading { border-color: rgba(96, 165, 250, 0.25); }
247-
.status-empty { border-color: rgba(251, 191, 36, 0.25); }
248-
.status-error { border-color: rgba(248, 113, 113, 0.25); }
324+
.status-ready { border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
325+
.status-loading { border-color: color-mix(in srgb, var(--info) 25%, transparent); }
326+
.status-empty { border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
327+
.status-error { border-color: color-mix(in srgb, var(--err) 25%, transparent); }
249328

250329
/* ─── Panel ──────────────────────────────────────────────────── */
251330
.panel {

apps/web/src/views/SettingsView.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ import { useEffect, useRef, useState } from "react";
22

33
import "../styles/settings.css";
44

5+
type ThemeSetting = "light" | "dark" | "system";
6+
57
interface SettingsViewProps {
68
isAdmin: boolean;
9+
theme: ThemeSetting;
10+
onThemeChange: (theme: ThemeSetting) => void;
711
onToggleAdmin: () => void;
812
onClose: () => void;
913
onSaved: () => void;
1014
}
1115

12-
export function SettingsView({ isAdmin, onToggleAdmin, onClose, onSaved }: SettingsViewProps) {
16+
export function SettingsView({ isAdmin, theme, onThemeChange, onToggleAdmin, onClose, onSaved }: SettingsViewProps) {
1317
const [displayName, setDisplayName] = useState("");
1418
const [language, setLanguage] = useState("ko");
15-
const [theme, setTheme] = useState("light");
1619
const dialogRef = useRef<HTMLDivElement>(null);
1720

1821
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -114,7 +117,7 @@ export function SettingsView({ isAdmin, onToggleAdmin, onClose, onSaved }: Setti
114117
<select
115118
className="select"
116119
value={theme}
117-
onChange={(e) => setTheme(e.target.value)}
120+
onChange={(e) => onThemeChange(e.target.value as ThemeSetting)}
118121
>
119122
<option value="light">라이트</option>
120123
<option value="dark">다크</option>

docs/v2_FINANCIAL-LEDGER/roadmap/01-development-plan.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,11 @@ Control 전체 목록과 상태 관리는 별도 문서에서 관리:
144144
- [x] Control 우선순위 분류 (P0: Core 필수 / P0.5: 반복 사용 / P1/P2: 요청 기반)
145145
- [x] 신규 Control 추가 정책 확정 (요청 -> RFC/이슈 -> 디자인/접근성 검토 -> 릴리스)
146146

147-
**packages/ui 실제 구현 (미착수):**
148-
- [ ] P0 Control 구현 — Button / Input / Select / Checkbox / Radio / Switch / Modal / Form Field / Alert / Progress
149-
- [ ] P0.5 Control 구현 — Textarea / Password Input / OTP Input / Search Input / Spinner / Toast / Empty State / Skeleton
147+
**packages/controls 실제 구현:**
148+
- [x] P0 Control 구현 — Button / Input / Select / Checkbox / Radio / Switch / Modal / Form Field / Alert / Progress
149+
- [x] P0.5 Control 구현 — Textarea / Password Input / OTP Input / Search Input / Spinner / Toast / EmptyState / Skeleton
150+
- [x] `global.css` 토큰 라이트/다크 분리 (`[data-theme]` + `prefers-color-scheme`)
151+
- [x] `controls.css` 작성 (fs- 접두사, CSS 변수 기반, 라이트/다크 공통)
150152
- [ ] Control 접근성 기준 체크 (focus ring, 명도 대비, aria role/label, tab 순서)
151153
- [ ] `apps/web` View에서 `@fieldstack/controls` Control로 교체 검증
152154

@@ -225,7 +227,7 @@ Control 전체 목록과 상태 관리는 별도 문서에서 관리:
225227
- ✅ Phase 2 모듈 UI를 붙일 수 있는 라우팅/레이아웃 기반 확보
226228

227229
### Phase 2 진입 게이트 (권장)
228-
- [ ] Control 패키지 MVP 완료 (P0/P0.5 규격 확정됨`packages/ui` 실제 구현 및 `ready: true` 반영 필요)
230+
- [x] Control 패키지 MVP 완료 (P0/P0.5 구현 완료`packages/controls` 반영 + `ready: true`)
229231
- [ ] Auth/Install/Home/Settings/Admin 흐름에서 공통 Control 재사용 검증
230232
- [ ] 접근성/반응형/상태 처리(Loading/Empty/Error/Unauthorized) 기준 통과
231233
- [ ] 핵심 E2E 통과 (설치 -> 로그인 -> 홈 -> 설정/관리자)
@@ -240,6 +242,7 @@ Control 전체 목록과 상태 관리는 별도 문서에서 관리:
240242
| 2026-02-27 | Web 진입점을 React + TypeScript + Vite(`main.tsx`) 기준으로 전환 완료. 개발 실행 모드에 `dev:bypass` 추가. bypass 정책을 "설치만 스킵, 인증은 로그인부터"로 확정 |
241243
| 2026-04-12 | 1차 UI/UX 전면 개편. 다크 모드 디자인 토큰 시스템 구축 및 고정 220px 좌측 사이드바 레이아웃으로 재설계. AppShell A/B/C/D 변형 폐기 후 단일 Shell로 통합. 로그인/홈/설정/관리자 CSS 전체를 다크 토큰 기반으로 전환 |
242244
| 2026-04-12 | 임시 비밀번호 첫 로그인 강제 변경 화면(ChangePasswordView) 구현. 관리자 역할(isAdmin)과 PIN 인증(isPinVerified) 상태 분리 — 역할 보유자도 Admin 페이지 진입 시 PIN 재인증 필요. 비관리자 Admin 진입점 사이드바에서 숨김. Marketplace 사이드바 진입점 추가(Phase 3 플레이스홀더). @fieldstack/core ESM 빌드 전환 |
245+
| 2026-04-13 | P0/P0.5 Control 전 항목 `packages/controls`에 React 컴포넌트 구현 완료 (`ready: true`). `controls.css` 작성(fs- 접두사). `global.css` 토큰을 라이트 기본값 + 다크 오버라이드(`[data-theme]`/`prefers-color-scheme`) 구조로 재설계. Settings 테마 셀렉터 실제 동작 연결 (localStorage + data-theme 적용). |
243246

244247
---
245248

packages/controls/package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,27 @@
22
"name": "@fieldstack/controls",
33
"private": true,
44
"version": "0.0.0",
5+
"type": "module",
56
"main": "dist/index.js",
67
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"import": "./dist/index.js",
11+
"types": "./dist/index.d.ts"
12+
},
13+
"./styles": "./src/styles/controls.css"
14+
},
715
"scripts": {
816
"build": "tsc",
917
"test": "vitest run --passWithNoTests",
1018
"typecheck": "tsc --noEmit"
1119
},
12-
"dependencies": {
13-
"@fieldstack/core": "workspace:*"
20+
"peerDependencies": {
21+
"react": ">=18",
22+
"react-dom": ">=18"
1423
},
1524
"devDependencies": {
25+
"@types/react": "^19.2.14",
1626
"vitest": "^2.1.9"
1727
}
1828
}

0 commit comments

Comments
 (0)