@ihyeon/flow-qa 0.0.0-stage → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ko.md +73 -0
- package/README.md +72 -2
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/dist/bin/flow-qa.mjs +3 -0
- package/dist/examples/demo/server.js +31 -0
- package/dist/src/cli.js +285 -0
- package/dist/src/compiler.js +62 -0
- package/dist/src/engine.js +246 -0
- package/dist/src/i18n.js +98 -0
- package/dist/src/planner.js +112 -0
- package/dist/src/report.js +315 -0
- package/dist/src/schema.js +71 -0
- package/dist/src/ui/entry.js +109 -0
- package/dist/src/ui/evidence.js +85 -0
- package/dist/src/ui/public/app.js +360 -0
- package/dist/src/ui/public/display.js +56 -0
- package/dist/src/ui/public/fonts/dm-sans.woff2 +0 -0
- package/dist/src/ui/public/fonts/noto-kr-subset.woff2 +0 -0
- package/dist/src/ui/public/i18n.js +290 -0
- package/dist/src/ui/public/index.html +117 -0
- package/dist/src/ui/public/style.css +200 -0
- package/dist/src/ui/server.js +785 -0
- package/dist/src/workspace.js +88 -0
- package/docs/architecture.md +38 -0
- package/docs/releases.md +54 -0
- package/docs/ui.md +71 -0
- package/licenses/DM-Sans-LICENSE.txt +93 -0
- package/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
- package/node_modules/@hyeonse/design-system/.github/workflows/ci.yml +21 -0
- package/node_modules/@hyeonse/design-system/.npmignore +5 -0
- package/node_modules/@hyeonse/design-system/AGENTS.md +10 -0
- package/node_modules/@hyeonse/design-system/LICENSE +21 -0
- package/node_modules/@hyeonse/design-system/README.md +67 -0
- package/node_modules/@hyeonse/design-system/THIRD_PARTY_NOTICES.md +5 -0
- package/node_modules/@hyeonse/design-system/dist/components.css +105 -0
- package/node_modules/@hyeonse/design-system/dist/preferences.d.ts +21 -0
- package/node_modules/@hyeonse/design-system/dist/preferences.js +69 -0
- package/node_modules/@hyeonse/design-system/dist/react.d.ts +21 -0
- package/node_modules/@hyeonse/design-system/dist/react.js +100 -0
- package/node_modules/@hyeonse/design-system/dist/tokens.css +75 -0
- package/node_modules/@hyeonse/design-system/docs/api.md +33 -0
- package/node_modules/@hyeonse/design-system/docs/assets/fonts/dm-sans.woff2 +0 -0
- package/node_modules/@hyeonse/design-system/docs/assets/fonts/noto-kr-subset.woff2 +0 -0
- package/node_modules/@hyeonse/design-system/docs/audit.md +29 -0
- package/node_modules/@hyeonse/design-system/docs/foundations.md +30 -0
- package/node_modules/@hyeonse/design-system/docs/verification.md +13 -0
- package/node_modules/@hyeonse/design-system/index.html +51 -0
- package/node_modules/@hyeonse/design-system/licenses/DM-Sans-LICENSE.txt +93 -0
- package/node_modules/@hyeonse/design-system/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
- package/node_modules/@hyeonse/design-system/licenses/arla6ka-MIT.txt +21 -0
- package/node_modules/@hyeonse/design-system/package.json +35 -0
- package/node_modules/@hyeonse/design-system/scripts/audit-tokens.mjs +48 -0
- package/node_modules/@hyeonse/design-system/scripts/build.mjs +12 -0
- package/node_modules/@hyeonse/design-system/showcase.css +75 -0
- package/node_modules/@hyeonse/design-system/showcase.mjs +63 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/SKILL.md +18 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/api.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/build.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/foundations.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/mapping.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/migration.md +5 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/review-docs.md +5 -0
- package/node_modules/@hyeonse/design-system/src/components.css +105 -0
- package/node_modules/@hyeonse/design-system/src/preferences.ts +87 -0
- package/node_modules/@hyeonse/design-system/src/react.tsx +161 -0
- package/node_modules/@hyeonse/design-system/src/tokens.css +75 -0
- package/node_modules/@hyeonse/design-system/tests/audit.test.mjs +30 -0
- package/node_modules/@hyeonse/design-system/tests/preferences.test.mjs +86 -0
- package/node_modules/@hyeonse/design-system/tsconfig.json +16 -0
- package/package.json +73 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type ThemePreference = 'system' | 'light' | 'dark';
|
|
2
|
+
export type AccentPreference = 'cobalt' | 'forest' | 'coral';
|
|
3
|
+
export type DensityPreference = 'comfortable' | 'compact';
|
|
4
|
+
export type CornersPreference = 'crisp' | 'soft';
|
|
5
|
+
export type MotionPreference = 'full' | 'reduced';
|
|
6
|
+
|
|
7
|
+
export type Preferences = {
|
|
8
|
+
theme: ThemePreference;
|
|
9
|
+
accent: AccentPreference;
|
|
10
|
+
density: DensityPreference;
|
|
11
|
+
corners: CornersPreference;
|
|
12
|
+
motion: MotionPreference;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const preferencesKey = 'hyeonse-site-preferences';
|
|
16
|
+
export const defaultPreferences: Preferences = {
|
|
17
|
+
theme: 'system',
|
|
18
|
+
accent: 'cobalt',
|
|
19
|
+
density: 'comfortable',
|
|
20
|
+
corners: 'crisp',
|
|
21
|
+
motion: 'full',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const options = {
|
|
25
|
+
theme: ['system', 'light', 'dark'],
|
|
26
|
+
accent: ['cobalt', 'forest', 'coral'],
|
|
27
|
+
density: ['comfortable', 'compact'],
|
|
28
|
+
corners: ['crisp', 'soft'],
|
|
29
|
+
motion: ['full', 'reduced'],
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export function validatePreferences(value: unknown): Preferences {
|
|
33
|
+
const input = value && typeof value === 'object' && !Array.isArray(value)
|
|
34
|
+
? value as Record<string, unknown>
|
|
35
|
+
: {};
|
|
36
|
+
return {
|
|
37
|
+
theme: options.theme.includes(input.theme as ThemePreference) ? input.theme as ThemePreference : defaultPreferences.theme,
|
|
38
|
+
accent: options.accent.includes(input.accent as AccentPreference) ? input.accent as AccentPreference : defaultPreferences.accent,
|
|
39
|
+
density: options.density.includes(input.density as DensityPreference) ? input.density as DensityPreference : defaultPreferences.density,
|
|
40
|
+
corners: options.corners.includes(input.corners as CornersPreference) ? input.corners as CornersPreference : defaultPreferences.corners,
|
|
41
|
+
motion: options.motion.includes(input.motion as MotionPreference) ? input.motion as MotionPreference : defaultPreferences.motion,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parsePreferences(raw: string | null): Preferences {
|
|
46
|
+
if (!raw) return { ...defaultPreferences };
|
|
47
|
+
try { return validatePreferences(JSON.parse(raw)); }
|
|
48
|
+
catch { return { ...defaultPreferences }; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function serializePreferences(value: Preferences): string {
|
|
52
|
+
return JSON.stringify(validatePreferences(value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolvedTheme(theme: ThemePreference, systemDark: boolean): 'light' | 'dark' {
|
|
56
|
+
return theme === 'system' ? (systemDark ? 'dark' : 'light') : theme;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function effectiveMotion(motion: MotionPreference, systemReduced: boolean): MotionPreference {
|
|
60
|
+
return systemReduced ? 'reduced' : motion;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Root layout inserts this in <head> before CSS paints and suppresses hydration warnings on <html>.
|
|
64
|
+
export function createPreferenceScript(storageKey = preferencesKey): string {
|
|
65
|
+
// A JSON string literal is used inside an inline script; escape HTML syntax too.
|
|
66
|
+
const safeKey = JSON.stringify(storageKey).replace(/[<>&\u2028\u2029]/g, character =>
|
|
67
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
68
|
+
return `(function(){
|
|
69
|
+
var defaults={theme:'system',accent:'cobalt',density:'comfortable',corners:'crisp',motion:'full'};
|
|
70
|
+
var allowed={theme:['system','light','dark'],accent:['cobalt','forest','coral'],density:['comfortable','compact'],corners:['crisp','soft'],motion:['full','reduced']};
|
|
71
|
+
var stored={};
|
|
72
|
+
try { var value=JSON.parse(localStorage.getItem(${safeKey})||'{}'); if(value&&typeof value==='object'&&!Array.isArray(value))stored=value; } catch(e) {}
|
|
73
|
+
var p={};
|
|
74
|
+
for(var key in defaults)p[key]=allowed[key].includes(stored[key])?stored[key]:defaults[key];
|
|
75
|
+
var dark=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
76
|
+
var reduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
77
|
+
var root=document.documentElement;
|
|
78
|
+
root.dataset.theme=p.theme==='system'?(dark?'dark':'light'):p.theme;
|
|
79
|
+
root.dataset.themePreference=p.theme;
|
|
80
|
+
root.dataset.accent=p.accent;
|
|
81
|
+
root.dataset.density=p.density;
|
|
82
|
+
root.dataset.corners=p.corners;
|
|
83
|
+
root.dataset.motion=reduced?'reduced':p.motion;
|
|
84
|
+
})();`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const preferenceScript = createPreferenceScript();
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
defaultPreferences, effectiveMotion, parsePreferences, preferencesKey,
|
|
7
|
+
resolvedTheme, serializePreferences, validatePreferences,
|
|
8
|
+
} from './preferences.js';
|
|
9
|
+
import type { Preferences, ThemePreference } from './preferences.js';
|
|
10
|
+
export type Locale = 'ko' | 'en';
|
|
11
|
+
const defaultEventName = 'hyeonse:preferences';
|
|
12
|
+
|
|
13
|
+
type PreferenceContext = {
|
|
14
|
+
preferences: Preferences;
|
|
15
|
+
setPreference: <K extends keyof Preferences>(key: K, value: Preferences[K]) => void;
|
|
16
|
+
resetPreferences: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const Context = createContext<PreferenceContext | null>(null);
|
|
20
|
+
|
|
21
|
+
function applyPreferences(preferences: Preferences, eventName: string) {
|
|
22
|
+
const root = document.documentElement;
|
|
23
|
+
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
24
|
+
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
25
|
+
root.dataset.theme = resolvedTheme(preferences.theme, dark);
|
|
26
|
+
root.dataset.themePreference = preferences.theme;
|
|
27
|
+
root.dataset.accent = preferences.accent;
|
|
28
|
+
root.dataset.density = preferences.density;
|
|
29
|
+
root.dataset.corners = preferences.corners;
|
|
30
|
+
root.dataset.motion = effectiveMotion(preferences.motion, reduced);
|
|
31
|
+
window.dispatchEvent(new CustomEvent(eventName, { detail: preferences }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPreferences(storageKey: string): Preferences {
|
|
35
|
+
try { return parsePreferences(window.localStorage.getItem(storageKey)); }
|
|
36
|
+
catch { return { ...defaultPreferences }; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function PreferencesProvider({ children, storageKey = preferencesKey, eventName = defaultEventName }: {
|
|
40
|
+
children: ReactNode; storageKey?: string; eventName?: string;
|
|
41
|
+
}) {
|
|
42
|
+
const [preferences, setPreferences] = useState<Preferences>(defaultPreferences);
|
|
43
|
+
const preferencesRef = useRef<Preferences>(defaultPreferences);
|
|
44
|
+
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
const stored = readPreferences(storageKey);
|
|
47
|
+
preferencesRef.current = stored;
|
|
48
|
+
setPreferences(stored);
|
|
49
|
+
applyPreferences(stored, eventName);
|
|
50
|
+
|
|
51
|
+
const themeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
52
|
+
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
53
|
+
const onSystemChange = () => applyPreferences(preferencesRef.current, eventName);
|
|
54
|
+
const onStorage = (event: StorageEvent) => {
|
|
55
|
+
if (event.key !== storageKey && event.key !== null) return;
|
|
56
|
+
const next = readPreferences(storageKey);
|
|
57
|
+
preferencesRef.current = next;
|
|
58
|
+
setPreferences(next);
|
|
59
|
+
applyPreferences(next, eventName);
|
|
60
|
+
};
|
|
61
|
+
themeQuery.addEventListener('change', onSystemChange);
|
|
62
|
+
motionQuery.addEventListener('change', onSystemChange);
|
|
63
|
+
window.addEventListener('storage', onStorage);
|
|
64
|
+
return () => {
|
|
65
|
+
themeQuery.removeEventListener('change', onSystemChange);
|
|
66
|
+
motionQuery.removeEventListener('change', onSystemChange);
|
|
67
|
+
window.removeEventListener('storage', onStorage);
|
|
68
|
+
};
|
|
69
|
+
}, [storageKey, eventName]);
|
|
70
|
+
|
|
71
|
+
const save = useCallback((next: Preferences) => {
|
|
72
|
+
const safe = validatePreferences(next);
|
|
73
|
+
preferencesRef.current = safe;
|
|
74
|
+
setPreferences(safe);
|
|
75
|
+
applyPreferences(safe, eventName);
|
|
76
|
+
try { window.localStorage.setItem(storageKey, serializePreferences(safe)); }
|
|
77
|
+
catch { /* Settings still work for this tab when storage is unavailable. */ }
|
|
78
|
+
}, [storageKey, eventName]);
|
|
79
|
+
|
|
80
|
+
const setPreference = useCallback(<K extends keyof Preferences>(key: K, value: Preferences[K]) => {
|
|
81
|
+
save({ ...preferencesRef.current, [key]: value });
|
|
82
|
+
}, [save]);
|
|
83
|
+
|
|
84
|
+
const resetPreferences = useCallback(() => save({ ...defaultPreferences }), [save]);
|
|
85
|
+
|
|
86
|
+
return <Context.Provider value={{ preferences, setPreference, resetPreferences }}>{children}</Context.Provider>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function usePreferences() {
|
|
90
|
+
const context = useContext(Context);
|
|
91
|
+
if (!context) throw new Error('PreferencesProvider가 필요합니다.');
|
|
92
|
+
return context;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const themeOptions: Record<Locale, { value: ThemePreference; label: string }[]> = {
|
|
96
|
+
ko: [{ value: 'system', label: '기기 설정' }, { value: 'light', label: '밝게' }, { value: 'dark', label: '어둡게' }],
|
|
97
|
+
en: [{ value: 'system', label: 'System' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function ThemeToggle({ locale = 'ko' }: { locale?: Locale }) {
|
|
101
|
+
const { preferences, setPreference } = usePreferences();
|
|
102
|
+
const options = themeOptions[locale];
|
|
103
|
+
const current = options.find(option => option.value === preferences.theme)!;
|
|
104
|
+
const next = options[(options.findIndex(option => option.value === preferences.theme) + 1) % options.length]!;
|
|
105
|
+
return <button
|
|
106
|
+
type="button"
|
|
107
|
+
className={'hds-themeToggle'}
|
|
108
|
+
onClick={() => setPreference('theme', next.value)}
|
|
109
|
+
aria-label={locale === 'ko' ? `현재 테마 ${current.label}. ${next.label} 모드로 변경` : `Current theme: ${current.label}. Switch to ${next.label} mode`}
|
|
110
|
+
title={locale === 'ko' ? `현재 ${current.label} · 다음 ${next.label}` : `Current: ${current.label} · Next: ${next.label}`}
|
|
111
|
+
>
|
|
112
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
|
113
|
+
{preferences.theme === 'system' && <><rect x="3" y="4" width="18" height="13" rx="1" /><path d="M8 21h8M12 17v4" /></>}
|
|
114
|
+
{preferences.theme === 'light' && <><circle cx="12" cy="12" r="4" /><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4m0-14.2-1.4 1.4M6.3 17.7l-1.4 1.4" /></>}
|
|
115
|
+
{preferences.theme === 'dark' && <path d="M20.3 15.7A8.5 8.5 0 0 1 8.3 3.7a8.5 8.5 0 1 0 12 12Z" />}
|
|
116
|
+
</svg>
|
|
117
|
+
<span aria-hidden="true">{current.label}</span>
|
|
118
|
+
</button>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type Choice<K extends keyof Preferences> = { value: Preferences[K]; label: string };
|
|
122
|
+
|
|
123
|
+
function RadioGroup<K extends 'theme' | 'density' | 'corners' | 'motion'>({
|
|
124
|
+
field, legend, choices, name,
|
|
125
|
+
}: { field: K; legend: string; choices: Choice<K>[]; name: string }) {
|
|
126
|
+
const { preferences, setPreference } = usePreferences();
|
|
127
|
+
return <fieldset className={'hds-group'}>
|
|
128
|
+
<legend>{legend}</legend>
|
|
129
|
+
<div className={'hds-options'}>
|
|
130
|
+
{choices.map(choice => <label className={'hds-radioOption'} key={choice.value}>
|
|
131
|
+
<input type="radio" name={`${name}-${field}`} value={choice.value} checked={preferences[field] === choice.value} onChange={() => setPreference(field, choice.value)} />
|
|
132
|
+
<span>{choice.label}</span>
|
|
133
|
+
</label>)}
|
|
134
|
+
</div>
|
|
135
|
+
</fieldset>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function PreferencesPanel({ locale = 'ko' }: { locale?: Locale }) {
|
|
139
|
+
const { preferences, setPreference, resetPreferences } = usePreferences();
|
|
140
|
+
const panelId = useId();
|
|
141
|
+
const en = locale === 'en';
|
|
142
|
+
const accentOptions = en
|
|
143
|
+
? [{ value: 'cobalt', label: 'Cobalt' }, { value: 'forest', label: 'Forest' }, { value: 'coral', label: 'Coral' }] as const
|
|
144
|
+
: [{ value: 'cobalt', label: '코발트' }, { value: 'forest', label: '포레스트' }, { value: 'coral', label: '코랄' }] as const;
|
|
145
|
+
return <section className={'hds-panel'} aria-labelledby={`${panelId}-title`}>
|
|
146
|
+
<div className={'hds-heading'}><div><span className={'hds-kicker'}>DISPLAY / CONTROL</span><h2 id={`${panelId}-title`}>{en ? 'Display settings' : '화면 설정'}</h2></div><p>{en ? 'Adjust the display for comfortable reading.' : '읽기 편한 화면으로 바꿔 보세요.'}</p></div>
|
|
147
|
+
<div className={'hds-groups'}>
|
|
148
|
+
<RadioGroup field="theme" legend={en ? 'Theme' : '테마'} choices={themeOptions[locale]} name={panelId} />
|
|
149
|
+
<fieldset className={'hds-group'}>
|
|
150
|
+
<legend>{en ? 'Accent color' : '강조 색상'}</legend>
|
|
151
|
+
<div className={'hds-accents'}>
|
|
152
|
+
{accentOptions.map(({ value, label }) => <button type="button" key={value} className={`${'hds-accentButton'} ${`hds-${value}`}`} aria-pressed={preferences.accent === value} aria-label={en ? `${label} accent color${preferences.accent === value ? ', selected' : ''}` : `${label} 강조 색상${preferences.accent === value ? ', 선택됨' : ''}`} onClick={() => setPreference('accent', value)}><span className={'hds-swatch'} aria-hidden="true">{preferences.accent === value ? '✓' : ''}</span><span>{label}</span></button>)}
|
|
153
|
+
</div>
|
|
154
|
+
</fieldset>
|
|
155
|
+
<RadioGroup field="density" legend={en ? 'Spacing' : '간격'} choices={en ? [{ value: 'comfortable', label: 'Default' }, { value: 'compact', label: 'Compact' }] : [{ value: 'comfortable', label: '기본' }, { value: 'compact', label: '촘촘하게' }]} name={panelId} />
|
|
156
|
+
<RadioGroup field="corners" legend={en ? 'Corners' : '모서리'} choices={en ? [{ value: 'crisp', label: 'Sharp' }, { value: 'soft', label: 'Rounded' }] : [{ value: 'crisp', label: '각지게' }, { value: 'soft', label: '부드럽게' }]} name={panelId} />
|
|
157
|
+
<RadioGroup field="motion" legend={en ? 'Motion' : '움직임'} choices={en ? [{ value: 'full', label: 'Default' }, { value: 'reduced', label: 'Reduced' }] : [{ value: 'full', label: '기본' }, { value: 'reduced', label: '줄이기' }]} name={panelId} />
|
|
158
|
+
</div>
|
|
159
|
+
<div className={'hds-footer'}><p role="status" aria-live="polite">{en ? 'Current settings: ' : '현재 설정: '}{themeOptions[locale].find(option => option.value === preferences.theme)?.label}, {accentOptions.find(option => option.value === preferences.accent)?.label}, {en ? (preferences.density === 'compact' ? 'compact spacing' : 'default spacing') : (preferences.density === 'compact' ? '촘촘한 간격' : '기본 간격')}, {en ? (preferences.corners === 'soft' ? 'rounded corners' : 'sharp corners') : (preferences.corners === 'soft' ? '부드러운 모서리' : '각진 모서리')}, {en ? (preferences.motion === 'reduced' ? 'reduced motion' : 'default motion') : (preferences.motion === 'reduced' ? '움직임 줄이기' : '기본 움직임')}</p><button type="button" onClick={resetPreferences}>{en ? 'Reset to defaults' : '기본값으로 되돌리기'}</button></div>
|
|
160
|
+
</section>;
|
|
161
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
--ink: #101114;
|
|
4
|
+
--paper: #ffffff;
|
|
5
|
+
--muted: #5a606b;
|
|
6
|
+
--line: #d8dbe3;
|
|
7
|
+
--surface: #f3f5f8;
|
|
8
|
+
--surface-raised: #ffffff;
|
|
9
|
+
--accent-solid: #204cfc;
|
|
10
|
+
--accent-text: #204cfc;
|
|
11
|
+
--palette-cobalt: #204cfc;
|
|
12
|
+
--palette-forest: #087451;
|
|
13
|
+
--palette-coral: #b93d20;
|
|
14
|
+
--on-accent: #ffffff;
|
|
15
|
+
--blue: var(--accent-text);
|
|
16
|
+
--about-surface: var(--accent-solid);
|
|
17
|
+
--about-ink: #ffffff;
|
|
18
|
+
--about-muted: #dce3ff;
|
|
19
|
+
--inverse-surface: #101114;
|
|
20
|
+
--inverse-ink: #ffffff;
|
|
21
|
+
--radius-card: 0px;
|
|
22
|
+
--radius-control: 2px;
|
|
23
|
+
--space-factor: 1;
|
|
24
|
+
--font-sans: 'DM Sans', 'Noto Sans KR', sans-serif;
|
|
25
|
+
--gutter: clamp(22px, 4.1vw, 72px);
|
|
26
|
+
--control-min-size: 44px;
|
|
27
|
+
--text-control: 12px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media (max-width: 700px) {
|
|
31
|
+
:root { --gutter: 22px; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
:root[data-accent='forest'] { --accent-solid: #087451; --accent-text: #087451; --about-muted: #d4f5e8; }
|
|
35
|
+
:root[data-accent='coral'] { --accent-solid: #b93d20; --accent-text: #b93d20; --about-muted: #ffe1d9; }
|
|
36
|
+
:root[data-density='compact'] { --space-factor: .8; }
|
|
37
|
+
:root[data-corners='soft'] { --radius-card: 20px; --radius-control: 12px; }
|
|
38
|
+
|
|
39
|
+
:root[data-theme='dark'] {
|
|
40
|
+
color-scheme: dark;
|
|
41
|
+
--ink: #f2f4f8;
|
|
42
|
+
--paper: #111318;
|
|
43
|
+
--muted: #b2b8c4;
|
|
44
|
+
--line: #444a56;
|
|
45
|
+
--surface: #1c2028;
|
|
46
|
+
--surface-raised: #282d37;
|
|
47
|
+
--accent-text: #9aafff;
|
|
48
|
+
--about-surface: #1b2540;
|
|
49
|
+
--about-ink: #f5f7ff;
|
|
50
|
+
--about-muted: #c1c9de;
|
|
51
|
+
--inverse-surface: #f2f4f8;
|
|
52
|
+
--inverse-ink: #101114;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
:root[data-theme='dark'][data-accent='forest'] { --accent-text: #68dfb1; --about-surface: #173029; --about-muted: #b7e1d1; }
|
|
56
|
+
:root[data-theme='dark'][data-accent='coral'] { --accent-text: #ffa58e; --about-surface: #35231f; --about-muted: #e7c1b8; }
|
|
57
|
+
|
|
58
|
+
/* CSS alone follows the OS when scripting is disabled and no resolved theme is set. */
|
|
59
|
+
@media (prefers-color-scheme: dark) {
|
|
60
|
+
:root:not([data-theme]) {
|
|
61
|
+
color-scheme: dark;
|
|
62
|
+
--ink: #f2f4f8;
|
|
63
|
+
--paper: #111318;
|
|
64
|
+
--muted: #b2b8c4;
|
|
65
|
+
--line: #444a56;
|
|
66
|
+
--surface: #1c2028;
|
|
67
|
+
--surface-raised: #282d37;
|
|
68
|
+
--accent-text: #9aafff;
|
|
69
|
+
--about-surface: #1b2540;
|
|
70
|
+
--about-ink: #f5f7ff;
|
|
71
|
+
--about-muted: #c1c9de;
|
|
72
|
+
--inverse-surface: #f2f4f8;
|
|
73
|
+
--inverse-ink: #101114;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {join} from 'node:path';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
import {spawnSync} from 'node:child_process';
|
|
8
|
+
const cli=fileURLToPath(new URL('../scripts/audit-tokens.mjs',import.meta.url));
|
|
9
|
+
test('CSS audit catches new drift and bounds material exceptions by count',async()=>{
|
|
10
|
+
const root=await mkdtemp(join(tmpdir(),'hds-audit-'));
|
|
11
|
+
try{
|
|
12
|
+
await mkdir(join(root,'src'));
|
|
13
|
+
const config={paths:['src']};
|
|
14
|
+
const writeConfig=()=>writeFile(join(root,'design-system.audit.json'),JSON.stringify(config));
|
|
15
|
+
const css=join(root,'src','sample.css');
|
|
16
|
+
const run=()=>spawnSync(process.execPath,[cli,root],{encoding:'utf8'});
|
|
17
|
+
await writeConfig();
|
|
18
|
+
await writeFile(css,'.card {color:var(--ink);background:var(--paper)} /* #abcdef is a note */');
|
|
19
|
+
assert.equal(run().status,0);
|
|
20
|
+
await writeFile(css,'.card {color:#ff00ff}');
|
|
21
|
+
assert.equal(run().status,1);assert.match(run().stderr,/raw color/);
|
|
22
|
+
config.exceptions={'src/sample.css':{reason:'Illustration material',colors:{'#ff00ff':1}}};
|
|
23
|
+
await writeConfig();assert.equal(run().status,0);
|
|
24
|
+
await writeFile(css,'.card {color:#ff00ff; border-color:#ff00ff}');assert.equal(run().status,1);
|
|
25
|
+
await writeFile(css,':root {--ink:var(--accent-text)}');assert.match(run().stderr,/shared token --ink/);
|
|
26
|
+
await writeFile(css,'.card {color:var(--ink)}');
|
|
27
|
+
await writeFile(join(root,'src','new-file.css'),'.new {background:rgb(0 0 0)}');
|
|
28
|
+
assert.match(run().stderr,/new-file\.css: raw color/);
|
|
29
|
+
}finally{await rm(root,{recursive:true,force:true});}
|
|
30
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
6
|
+
import {
|
|
7
|
+
createPreferenceScript, defaultPreferences, effectiveMotion, parsePreferences,
|
|
8
|
+
preferenceScript, preferencesKey, resolvedTheme, serializePreferences, validatePreferences,
|
|
9
|
+
} from '../dist/preferences.js';
|
|
10
|
+
import { PreferencesPanel, PreferencesProvider, ThemeToggle } from '../dist/react.js';
|
|
11
|
+
|
|
12
|
+
test('malformed and untrusted preferences fall back field by field', () => {
|
|
13
|
+
assert.deepEqual(parsePreferences('{bad'), defaultPreferences);
|
|
14
|
+
assert.deepEqual(parsePreferences('[]'), defaultPreferences);
|
|
15
|
+
assert.deepEqual(validatePreferences({ theme: 'dark', accent: 'unknown', motion: 'reduced' }), {
|
|
16
|
+
...defaultPreferences, theme: 'dark', motion: 'reduced',
|
|
17
|
+
});
|
|
18
|
+
assert.deepEqual(JSON.parse(serializePreferences({ ...defaultPreferences, theme: 'dark', accent: 'bad' })), {
|
|
19
|
+
...defaultPreferences, theme: 'dark',
|
|
20
|
+
});
|
|
21
|
+
assert.equal(resolvedTheme('system', true), 'dark');
|
|
22
|
+
assert.equal(resolvedTheme('system', false), 'light');
|
|
23
|
+
assert.equal(resolvedTheme('light', true), 'light');
|
|
24
|
+
assert.equal(effectiveMotion('full', true), 'reduced');
|
|
25
|
+
assert.equal(effectiveMotion('full', false), 'full');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function runInitialScript({ key = preferencesKey, stored, dark = false, reduced = false, storageThrows = false } = {}) {
|
|
29
|
+
const dataset = {};
|
|
30
|
+
const requestedKeys = [];
|
|
31
|
+
const context = {
|
|
32
|
+
document: { documentElement: { dataset } },
|
|
33
|
+
localStorage: { getItem(requested) {
|
|
34
|
+
requestedKeys.push(requested);
|
|
35
|
+
if (storageThrows) throw new Error('blocked');
|
|
36
|
+
return stored ?? null;
|
|
37
|
+
} },
|
|
38
|
+
window: { matchMedia(query) {
|
|
39
|
+
return { matches: query.includes('color-scheme') ? dark : reduced };
|
|
40
|
+
} },
|
|
41
|
+
};
|
|
42
|
+
vm.runInNewContext(createPreferenceScript(key), context);
|
|
43
|
+
return { dataset, requestedKeys };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('initial script uses the same defaults, validation and OS overrides', () => {
|
|
47
|
+
assert.equal(preferenceScript, createPreferenceScript());
|
|
48
|
+
const stored = JSON.stringify({ theme: 'system', accent: 'forest', density: 'invalid', corners: 'soft', motion: 'full' });
|
|
49
|
+
assert.deepEqual(runInitialScript({ stored, dark: true, reduced: true }).dataset, {
|
|
50
|
+
theme: 'dark', themePreference: 'system', accent: 'forest', density: 'comfortable', corners: 'soft', motion: 'reduced',
|
|
51
|
+
});
|
|
52
|
+
assert.deepEqual(runInitialScript({ stored: '{bad', dark: true }).dataset, {
|
|
53
|
+
theme: 'dark', themePreference: 'system', accent: 'cobalt', density: 'comfortable', corners: 'crisp', motion: 'full',
|
|
54
|
+
});
|
|
55
|
+
assert.deepEqual(runInitialScript({ storageThrows: true }).dataset, {
|
|
56
|
+
theme: 'light', themePreference: 'system', accent: 'cobalt', density: 'comfortable', corners: 'crisp', motion: 'full',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('custom storage key is honored and safe inside inline HTML', () => {
|
|
61
|
+
const key = '</script><script>alert(1)</script>\u2028';
|
|
62
|
+
const script = createPreferenceScript(key);
|
|
63
|
+
assert.equal(script.includes('</script>'), false);
|
|
64
|
+
assert.equal(script.includes('<script>'), false);
|
|
65
|
+
assert.equal(script.includes('\u2028'), false);
|
|
66
|
+
assert.deepEqual(runInitialScript({ key }).requestedKeys, [key]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('SSR retains localized semantics and isolates multiple panels', () => {
|
|
70
|
+
const html = renderToStaticMarkup(React.createElement(PreferencesProvider, null,
|
|
71
|
+
React.createElement(React.Fragment, null,
|
|
72
|
+
React.createElement(ThemeToggle, { locale: 'en' }),
|
|
73
|
+
React.createElement(PreferencesPanel, { locale: 'ko' }),
|
|
74
|
+
React.createElement(PreferencesPanel, { locale: 'en' }),
|
|
75
|
+
)));
|
|
76
|
+
assert.match(html, /Current theme: System\. Switch to Light mode/);
|
|
77
|
+
assert.match(html, /화면 설정/);
|
|
78
|
+
assert.match(html, /Display settings/);
|
|
79
|
+
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map(match => match[1]);
|
|
80
|
+
assert.equal(ids.length, 2);
|
|
81
|
+
assert.notEqual(ids[0], ids[1]);
|
|
82
|
+
assert.ok(ids.every(id => html.includes(`aria-labelledby="${id}"`)));
|
|
83
|
+
const themeNames = [...html.matchAll(/name="([^"]+-theme)"/g)].map(match => match[1]);
|
|
84
|
+
assert.equal(new Set(themeNames).size, 2);
|
|
85
|
+
assert.match(html, /hds-panel/);
|
|
86
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["DOM", "ES2020"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmitOnError": true,
|
|
12
|
+
"rootDir": "src",
|
|
13
|
+
"outDir": "dist"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ihyeon/flow-qa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "Hyeonse Im",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hyeonse-swing/flow-qa.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/hyeonse-swing/flow-qa#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/hyeonse-swing/flow-qa/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"qa",
|
|
16
|
+
"e2e",
|
|
17
|
+
"testing",
|
|
18
|
+
"cli",
|
|
19
|
+
"ai",
|
|
20
|
+
"local-first"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"tag": "alpha",
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"licenses",
|
|
30
|
+
"docs/*.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"THIRD_PARTY_NOTICES.md",
|
|
33
|
+
"README.ko.md"
|
|
34
|
+
],
|
|
35
|
+
"bundleDependencies": [
|
|
36
|
+
"@hyeonse/design-system"
|
|
37
|
+
],
|
|
38
|
+
"description": "Turn user flows into repeatable tests and evidence-backed QA reports.",
|
|
39
|
+
"type": "module",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=24"
|
|
42
|
+
},
|
|
43
|
+
"bin": {
|
|
44
|
+
"flow-qa": "./dist/bin/flow-qa.mjs"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"qa": "node bin/flow-qa.mjs",
|
|
48
|
+
"ui": "node src/ui/entry.ts",
|
|
49
|
+
"ui:demo": "node src/ui/entry.ts --demo",
|
|
50
|
+
"test": "node --test tests/*.test.ts",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"build": "node scripts/build.mjs",
|
|
53
|
+
"prepack": "npm run build",
|
|
54
|
+
"check:package": "npm run build && node scripts/check-package.mjs",
|
|
55
|
+
"smoke:package": "node scripts/package-smoke.mjs",
|
|
56
|
+
"check": "npm run typecheck && npm test && npm run check:ui",
|
|
57
|
+
"check:ui": "node --check src/ui/public/app.js && node --check src/ui/public/display.js && node --check src/ui/public/i18n.js && hds-audit .",
|
|
58
|
+
"demo:serve": "node examples/demo/server.ts",
|
|
59
|
+
"demo:smoke": "node scripts/demo-smoke.ts",
|
|
60
|
+
"smoke:ui": "node scripts/ui-i18n-smoke.mjs"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@ai-sdk/openai-compatible": "3.0.53",
|
|
64
|
+
"@e2e-dev/web": "0.11.1",
|
|
65
|
+
"@hyeonse/design-system": "https://codeload.github.com/hyeonse-swing/hyeonse-design-system/tar.gz/38978c6a86f422fc0e63e919f600bc2dd3451e5c",
|
|
66
|
+
"ai": "7.0.107",
|
|
67
|
+
"e2e": "0.16.0",
|
|
68
|
+
"playwright": "1.63.0",
|
|
69
|
+
"zod": "4.6.1"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@types/node": "^24.0.0",
|
|
73
|
+
"typescript": "^5.9.3"
|
|
74
|
+
}
|
|
75
|
+
}
|