sh-ui-cli 0.34.0 → 0.38.0
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/data/changelog/versions.json +19 -0
- package/data/registry/react/components/date-picker/index.tsx +21 -4
- package/data/registry/react/components/date-picker/styles.css +12 -12
- package/package.json +1 -1
- package/src/api.d.ts +24 -0
- package/src/api.js +1 -0
- package/src/create/generator.js +77 -2
- package/src/create/index.mjs +4 -1
- package/src/create/theme/decode.js +106 -3
- package/src/create/theme/inject.js +317 -33
- package/src/create/theme/presets.js +147 -0
- package/src/mcp.mjs +3 -1
- package/templates/flutter-standalone/lib/sh_ui/foundation/sh_ui_tokens.dart +43 -2
- package/templates/nextjs-standalone/src/shared/styles/tokens.css +21 -0
- package/templates/ui-app-template/src/styles/tokens.css +21 -0
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.38.0",
|
|
7
|
+
"date": "2026-04-29",
|
|
8
|
+
"title": "테마 시스템 풀스택 — 5 프리셋 + 풀 토큰 편집기 + 스캐폴드 threading + CSS↔Flutter 변환기",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**프리셋 5종 (CLI + 플레이그라운드)** — neutral · slate · rose · emerald · violet. `sh-ui create --theme rose` 처럼 짧게 받거나 playground 에서 만든 base64 그대로. 플레이그라운드 토큰 편집기 위쪽 '프리셋' 행 클릭 한 번으로 light/dark/radius 동시 적용. sh-ui-cli/api 가 단일 소스",
|
|
12
|
+
"**쉬운 / 고급 토글** — 쉬운 모드는 베이스 톤 4(neutral/slate/warm/cool) + 포인트 컬러 1개 픽커(primary-foreground/primary-hover 자동 파생). 고급 모드는 8 카테고리 collapsible 섹션 — 색(15) · 크기 · 테두리 · 그림자 · 타이포 · 여백 · 폰트굵기 · 모션. 모든 슬라이더는 sh-ui Slider 사용",
|
|
13
|
+
"**그라데이션 빌더 + 슬롯 3개** (primary/surface/overlay) — 각도 슬라이더 + 2 컬러스톱 + 위치 슬라이더 + 라이브 프리뷰. CSS 변수 `--gradient-*` 로 캔버스 흘러감",
|
|
14
|
+
"**스캐폴드 threading 풀스택** — 색 15 + radius + 9 옵셔널 카테고리 (spacing 11 / typography 8 / weights 4 / controls 3 / borders 2 / durations 3 / shadows 4 / eases 2 / gradients 3). theme JSON 에 키가 있으면 tokens.css / sh_ui_tokens.dart 의 마커 섹션 교체, 없으면 템플릿 디폴트 유지. 디폴트와 다른 카테고리만 base64 에 포함돼 페이로드 최소화",
|
|
15
|
+
"**CSS↔Flutter 변환기** — shadow `0 1px 2px rgba(0,0,0,0.08)` → `BoxShadow(offset, blurRadius, spreadRadius, color: Color(0x14000000))` (alpha = 0.08 × 255). ease `cubic-bezier(0.4,0,0.2,1)` → `Cubic(0.4,0,0.2,1)` (named easing 도 정확한 4-인자로). gradient `linear-gradient(135deg, ...)` → `LinearGradient(begin: Alignment(-0.707,-0.707), end: Alignment(0.707,0.707), colors, stops)`. CSS deg → Flutter Alignment (0deg=topCenter, 시계방향)",
|
|
16
|
+
"**확장 색 직접 편집** — background-inverse / foreground-inverse / foreground-subtle 3개를 사용자가 직접 잡음. 기존 Dart 자동 inverse 거울 / 고정 foregroundSubtle 디폴트 폐기. 5 프리셋 light/dark 모두 15키 풀세트",
|
|
17
|
+
"**Flutter 템플릿** — ShUiGradientTokens 클래스 신설 + ShUiTheme 에 gradient 필드 통합. tokens.css / sh_ui_tokens.dart 에 카테고리별 마커 9쌍 (theme-space/text/weight/shadow/duration/ease/control/border-width/gradient)",
|
|
18
|
+
"**DatePicker 회귀 픽스 2개** — (1) container prop 누락 → 추가 (Select·Combobox·Popover 와 동일 패턴). 다크 모드를 page subtree 에만 입힌 환경에서 캘린더 popover 가 토큰 스코프 벗어나 흰 배경으로 뜨던 문제 해결. (2) 선택일 색을 --foreground 에서 --primary / --primary-foreground 로 전환 — rose/emerald 같은 primary 변경 프리셋이 캘린더에도 일관되게 반영. **호환성 주의**: primary 를 안 바꾸고 쓰던 사용자는 선택일 색이 검정 → 자기 primary 색으로 변경됨",
|
|
19
|
+
"**MCP** sh_ui_create_project 의 theme 파라미터가 프리셋 이름 수용 — IDE 에이전트가 `theme: \"slate\"` 한 줄로 호출. 짧은 오타는 base64 디코드 에러 대신 '알 수 없는 테마 프리셋: 지원 목록 …' 친절 안내",
|
|
20
|
+
"**테스트 104** (전 68 → +36). 옵셔널 카테고리 검증, 빌더 출력 형식, 파서 (rgba alpha / hex 8자리 / spread / multi-shadow / named ease / Alignment 각도 0·45·90·135·180), 잘못된 형식 throw, end-to-end Next + Flutter 스캐폴드 주입. localStorage 마이그레이션 (v0.34 까지 저장된 데이터는 새 키 디폴트 보강)"
|
|
21
|
+
],
|
|
22
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.38.0"
|
|
23
|
+
},
|
|
5
24
|
{
|
|
6
25
|
"version": "0.34.0",
|
|
7
26
|
"date": "2026-04-29",
|
|
@@ -329,6 +329,11 @@ export interface DatePickerProps {
|
|
|
329
329
|
* @default true
|
|
330
330
|
*/
|
|
331
331
|
closeOnSelect?: boolean;
|
|
332
|
+
/**
|
|
333
|
+
* Portal이 마운트될 DOM 노드. 토큰 스코프(다크 모드 등) 안에 popover를 띄우려면 해당 컨테이너 ref 전달.
|
|
334
|
+
* @default document.body
|
|
335
|
+
*/
|
|
336
|
+
container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
332
337
|
/**
|
|
333
338
|
* compound 모드. 미지정 시 기본 레이아웃(Trigger + Content + Calendar)이 자동 렌더된다.
|
|
334
339
|
* 직접 조립하려면 `DatePickerTrigger`/`DatePickerContent`/`DatePickerCalendar`/`DatePickerFooter`를 자식으로 넘긴다.
|
|
@@ -353,6 +358,7 @@ export function DatePicker({
|
|
|
353
358
|
"aria-invalid": ariaInvalid,
|
|
354
359
|
className,
|
|
355
360
|
closeOnSelect = true,
|
|
361
|
+
container,
|
|
356
362
|
children,
|
|
357
363
|
}: DatePickerProps) {
|
|
358
364
|
const isControlled = value !== undefined;
|
|
@@ -417,7 +423,7 @@ export function DatePicker({
|
|
|
417
423
|
{children ?? (
|
|
418
424
|
<>
|
|
419
425
|
<DatePickerTrigger className={className} />
|
|
420
|
-
<DatePickerContent>
|
|
426
|
+
<DatePickerContent container={container}>
|
|
421
427
|
<DatePickerCalendar />
|
|
422
428
|
</DatePickerContent>
|
|
423
429
|
</>
|
|
@@ -525,19 +531,24 @@ export interface DatePickerContentProps
|
|
|
525
531
|
* @default "start"
|
|
526
532
|
*/
|
|
527
533
|
align?: React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>["align"];
|
|
534
|
+
/**
|
|
535
|
+
* Portal이 마운트될 DOM 노드. 토큰 스코프(다크 모드 등) 안에 popover를 띄우려면 해당 컨테이너 ref 전달.
|
|
536
|
+
* @default document.body
|
|
537
|
+
*/
|
|
538
|
+
container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
528
539
|
}
|
|
529
540
|
|
|
530
541
|
/** 캘린더 popover 본문. portal로 마운트되며 `disabled`/`readOnly`이면 렌더되지 않는다. */
|
|
531
542
|
export const DatePickerContent = React.forwardRef<HTMLDivElement, DatePickerContentProps>(
|
|
532
543
|
function DatePickerContent(
|
|
533
|
-
{ className, children, sideOffset = 4, side = "bottom", align = "start", ...props },
|
|
544
|
+
{ className, children, sideOffset = 4, side = "bottom", align = "start", container, ...props },
|
|
534
545
|
ref,
|
|
535
546
|
) {
|
|
536
547
|
const ctx = useDatePickerContext("DatePickerContent");
|
|
537
548
|
if (ctx.disabled || ctx.readOnly) return null;
|
|
538
549
|
|
|
539
550
|
return (
|
|
540
|
-
<BasePopover.Portal>
|
|
551
|
+
<BasePopover.Portal container={container}>
|
|
541
552
|
<BasePopover.Positioner
|
|
542
553
|
className="sh-ui-date-picker__positioner"
|
|
543
554
|
sideOffset={sideOffset}
|
|
@@ -639,6 +650,11 @@ export interface DateRangePickerProps {
|
|
|
639
650
|
/** invalid 상태. */
|
|
640
651
|
"aria-invalid"?: boolean | "true";
|
|
641
652
|
className?: string;
|
|
653
|
+
/**
|
|
654
|
+
* Portal이 마운트될 DOM 노드. 토큰 스코프(다크 모드 등) 안에 popover를 띄우려면 해당 컨테이너 ref 전달.
|
|
655
|
+
* @default document.body
|
|
656
|
+
*/
|
|
657
|
+
container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
642
658
|
}
|
|
643
659
|
|
|
644
660
|
/**
|
|
@@ -659,6 +675,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
659
675
|
readOnly,
|
|
660
676
|
"aria-invalid": ariaInvalid,
|
|
661
677
|
className,
|
|
678
|
+
container,
|
|
662
679
|
},
|
|
663
680
|
ref,
|
|
664
681
|
) {
|
|
@@ -724,7 +741,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
724
741
|
</BasePopover.Trigger>
|
|
725
742
|
|
|
726
743
|
{!disabled && !readOnly && (
|
|
727
|
-
<BasePopover.Portal>
|
|
744
|
+
<BasePopover.Portal container={container}>
|
|
728
745
|
<BasePopover.Positioner
|
|
729
746
|
className="sh-ui-date-picker__positioner"
|
|
730
747
|
sideOffset={4}
|
|
@@ -207,37 +207,37 @@
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
.sh-ui-calendar__day--selected {
|
|
210
|
-
background: var(--
|
|
211
|
-
color: var(--
|
|
210
|
+
background: var(--primary);
|
|
211
|
+
color: var(--primary-foreground);
|
|
212
212
|
font-weight: var(--weight-semibold);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
.sh-ui-calendar__day--selected:hover:not(:disabled) {
|
|
216
|
-
background: var(--
|
|
217
|
-
|
|
216
|
+
background: var(--primary-hover);
|
|
217
|
+
color: var(--primary-foreground);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/* ── Range ── */
|
|
221
221
|
|
|
222
222
|
.sh-ui-calendar__day--in-range {
|
|
223
|
-
background: color-mix(in srgb, var(--
|
|
223
|
+
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
|
224
224
|
border-radius: 0;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
.sh-ui-calendar__day--in-range:hover:not(:disabled) {
|
|
228
|
-
background: color-mix(in srgb, var(--
|
|
228
|
+
background: color-mix(in srgb, var(--primary) 22%, transparent);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
.sh-ui-calendar__day--range-start {
|
|
232
|
-
background: var(--
|
|
233
|
-
color: var(--
|
|
232
|
+
background: var(--primary);
|
|
233
|
+
color: var(--primary-foreground);
|
|
234
234
|
font-weight: var(--weight-semibold);
|
|
235
235
|
border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
.sh-ui-calendar__day--range-end {
|
|
239
|
-
background: var(--
|
|
240
|
-
color: var(--
|
|
239
|
+
background: var(--primary);
|
|
240
|
+
color: var(--primary-foreground);
|
|
241
241
|
font-weight: var(--weight-semibold);
|
|
242
242
|
border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
|
|
243
243
|
}
|
|
@@ -248,8 +248,8 @@
|
|
|
248
248
|
|
|
249
249
|
.sh-ui-calendar__day--range-start:hover:not(:disabled),
|
|
250
250
|
.sh-ui-calendar__day--range-end:hover:not(:disabled) {
|
|
251
|
-
background: var(--
|
|
252
|
-
|
|
251
|
+
background: var(--primary-hover);
|
|
252
|
+
color: var(--primary-foreground);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
/* ── Hint (range picker) ── */
|
package/package.json
CHANGED
package/src/api.d.ts
CHANGED
|
@@ -40,3 +40,27 @@ export type PluginManifest = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
export const allPlugins: readonly PluginManifest[];
|
|
43
|
+
|
|
44
|
+
/* ─────── 테마 프리셋 ─────── */
|
|
45
|
+
|
|
46
|
+
export type ThemePresetName = 'neutral' | 'slate' | 'rose' | 'emerald' | 'violet';
|
|
47
|
+
|
|
48
|
+
/** decode.js 의 TOKEN_KEYS — light/dark 양쪽이 가져야 하는 키 12개. */
|
|
49
|
+
export type ThemeTokenKey =
|
|
50
|
+
| 'background' | 'background-subtle' | 'background-muted'
|
|
51
|
+
| 'foreground' | 'foreground-muted'
|
|
52
|
+
| 'border' | 'border-strong'
|
|
53
|
+
| 'primary' | 'primary-foreground' | 'primary-hover'
|
|
54
|
+
| 'danger' | 'danger-foreground';
|
|
55
|
+
|
|
56
|
+
export interface ThemePreset {
|
|
57
|
+
/** UI 에 표시할 사람이 읽는 라벨. */
|
|
58
|
+
label: string;
|
|
59
|
+
light: Record<ThemeTokenKey, string>;
|
|
60
|
+
dark: Record<ThemeTokenKey, string>;
|
|
61
|
+
/** rem 단위 (0~1.5). */
|
|
62
|
+
radius: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const THEME_PRESETS: Record<ThemePresetName, ThemePreset>;
|
|
66
|
+
export const THEME_PRESET_NAMES: readonly ThemePresetName[];
|
package/src/api.js
CHANGED
package/src/create/generator.js
CHANGED
|
@@ -4,13 +4,32 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { getPluginChoices, getPluginsByNames } from './plugins/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { resolveTheme } from './theme/decode.js';
|
|
8
|
+
import { THEME_PRESETS, getThemePreset } from './theme/presets.js';
|
|
8
9
|
import {
|
|
9
10
|
replaceSection,
|
|
10
11
|
buildCssColorsBlock,
|
|
11
12
|
buildCssRadiusBlock,
|
|
13
|
+
buildCssSpacingBlock,
|
|
14
|
+
buildCssTypographyBlock,
|
|
15
|
+
buildCssWeightsBlock,
|
|
16
|
+
buildCssControlsBlock,
|
|
17
|
+
buildCssBordersBlock,
|
|
18
|
+
buildCssDurationsBlock,
|
|
19
|
+
buildCssShadowsBlock,
|
|
20
|
+
buildCssEasesBlock,
|
|
21
|
+
buildCssGradientsBlock,
|
|
12
22
|
buildDartColorsBlock,
|
|
13
23
|
buildDartRadiusBlock,
|
|
24
|
+
buildDartSpacingBlock,
|
|
25
|
+
buildDartTypographyBlock,
|
|
26
|
+
buildDartWeightsBlock,
|
|
27
|
+
buildDartControlsBlock,
|
|
28
|
+
buildDartBordersBlock,
|
|
29
|
+
buildDartDurationsBlock,
|
|
30
|
+
buildDartShadowsBlock,
|
|
31
|
+
buildDartEasesBlock,
|
|
32
|
+
buildDartGradientsBlock,
|
|
14
33
|
} from './theme/inject.js';
|
|
15
34
|
import { getTemplatesRoot } from '../paths.mjs';
|
|
16
35
|
|
|
@@ -52,7 +71,25 @@ export async function createProject(options = {}) {
|
|
|
52
71
|
],
|
|
53
72
|
});
|
|
54
73
|
|
|
55
|
-
|
|
74
|
+
let theme = null;
|
|
75
|
+
if (options.theme) {
|
|
76
|
+
theme = resolveTheme(options.theme);
|
|
77
|
+
} else if (process.stdin.isTTY && !options.yes) {
|
|
78
|
+
// --yes 는 "선택 옵션은 기본값으로" 의미. 테마는 옵션이므로 prompt 우회.
|
|
79
|
+
const NONE = '__none__';
|
|
80
|
+
const choice = await select({
|
|
81
|
+
message: '테마:',
|
|
82
|
+
default: NONE,
|
|
83
|
+
choices: [
|
|
84
|
+
{ name: '기본 (테마 없음 — sh-ui 기본 토큰 그대로)', value: NONE },
|
|
85
|
+
...Object.entries(THEME_PRESETS).map(([name, preset]) => ({
|
|
86
|
+
name: preset.label,
|
|
87
|
+
value: name,
|
|
88
|
+
})),
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
if (choice !== NONE) theme = getThemePreset(choice);
|
|
92
|
+
}
|
|
56
93
|
|
|
57
94
|
// dry-run 은 tmpdir 에 그대로 생성한 뒤 파일 목록 출력 + 정리.
|
|
58
95
|
// 사용자 cwd 를 건드리지 않으면서 실제 generation 흐름을 그대로 검증한다.
|
|
@@ -601,6 +638,34 @@ async function applyTransforms(targetDir, plugins) {
|
|
|
601
638
|
// ─── Theme 주입 ───
|
|
602
639
|
|
|
603
640
|
/** 여러 후보 경로 중 존재하는 첫 tokens.css 에 theme 주입 */
|
|
641
|
+
/**
|
|
642
|
+
* 옵셔널 카테고리 → CSS 마커 / 빌더 매핑.
|
|
643
|
+
* theme 에 카테고리 키가 있을 때만 해당 마커 섹션 교체.
|
|
644
|
+
*/
|
|
645
|
+
const OPTIONAL_CSS_INJECTORS = [
|
|
646
|
+
['spacing', 'theme-space', buildCssSpacingBlock],
|
|
647
|
+
['typography', 'theme-text', buildCssTypographyBlock],
|
|
648
|
+
['weights', 'theme-weight', buildCssWeightsBlock],
|
|
649
|
+
['controls', 'theme-control', buildCssControlsBlock],
|
|
650
|
+
['borders', 'theme-border-width', buildCssBordersBlock],
|
|
651
|
+
['durations', 'theme-duration', buildCssDurationsBlock],
|
|
652
|
+
['shadows', 'theme-shadow', buildCssShadowsBlock],
|
|
653
|
+
['eases', 'theme-ease', buildCssEasesBlock],
|
|
654
|
+
['gradients', 'theme-gradient', buildCssGradientsBlock],
|
|
655
|
+
];
|
|
656
|
+
|
|
657
|
+
const OPTIONAL_DART_INJECTORS = [
|
|
658
|
+
['spacing', 'theme-space', buildDartSpacingBlock],
|
|
659
|
+
['typography', 'theme-text', buildDartTypographyBlock],
|
|
660
|
+
['weights', 'theme-weight', buildDartWeightsBlock],
|
|
661
|
+
['controls', 'theme-control', buildDartControlsBlock],
|
|
662
|
+
['borders', 'theme-border-width', buildDartBordersBlock],
|
|
663
|
+
['durations', 'theme-duration', buildDartDurationsBlock],
|
|
664
|
+
['shadows', 'theme-shadow', buildDartShadowsBlock],
|
|
665
|
+
['eases', 'theme-ease', buildDartEasesBlock],
|
|
666
|
+
['gradients', 'theme-gradient', buildDartGradientsBlock],
|
|
667
|
+
];
|
|
668
|
+
|
|
604
669
|
async function injectCssTheme(projectDir, theme) {
|
|
605
670
|
if (!theme) return;
|
|
606
671
|
const candidates = [
|
|
@@ -613,6 +678,11 @@ async function injectCssTheme(projectDir, theme) {
|
|
|
613
678
|
let css = await fs.readFile(abs, 'utf-8');
|
|
614
679
|
css = replaceSection(css, 'theme-colors', '/*', '*/', buildCssColorsBlock(theme));
|
|
615
680
|
css = replaceSection(css, 'theme-radius', '/*', '*/', buildCssRadiusBlock(theme));
|
|
681
|
+
for (const [key, marker, builder] of OPTIONAL_CSS_INJECTORS) {
|
|
682
|
+
if (theme[key]) {
|
|
683
|
+
css = replaceSection(css, marker, '/*', '*/', builder(theme[key]));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
616
686
|
await fs.writeFile(abs, css);
|
|
617
687
|
return;
|
|
618
688
|
}
|
|
@@ -629,5 +699,10 @@ async function injectDartTheme(projectDir, theme) {
|
|
|
629
699
|
let dart = await fs.readFile(abs, 'utf-8');
|
|
630
700
|
dart = replaceSection(dart, 'theme-colors', '//', '', buildDartColorsBlock(theme));
|
|
631
701
|
dart = replaceSection(dart, 'theme-radius', '//', '', buildDartRadiusBlock(theme));
|
|
702
|
+
for (const [key, marker, builder] of OPTIONAL_DART_INJECTORS) {
|
|
703
|
+
if (theme[key]) {
|
|
704
|
+
dart = replaceSection(dart, marker, '//', '', builder(theme[key]));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
632
707
|
await fs.writeFile(abs, dart);
|
|
633
708
|
}
|
package/src/create/index.mjs
CHANGED
|
@@ -4,9 +4,11 @@ import { parseArgs } from './cli-args.js';
|
|
|
4
4
|
import { createProject, addApp, addComponent } from './generator.js';
|
|
5
5
|
import { allPlugins } from './plugins/index.js';
|
|
6
6
|
import { CREATE_PLATFORMS, CREATE_STRUCTURES } from '../constants.js';
|
|
7
|
+
import { THEME_PRESET_NAMES } from './theme/presets.js';
|
|
7
8
|
|
|
8
9
|
const PLUGIN_NAMES = allPlugins.map((p) => p.name);
|
|
9
10
|
const PLUGINS_LIST = PLUGIN_NAMES.join(', ');
|
|
11
|
+
const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join('|');
|
|
10
12
|
|
|
11
13
|
export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next.js / Flutter)
|
|
12
14
|
|
|
@@ -19,7 +21,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
19
21
|
--platform <${CREATE_PLATFORMS.join('|')}> 타겟 플랫폼
|
|
20
22
|
--structure <${CREATE_STRUCTURES.join('|')}> Next.js 프로젝트 구조 (next 일 때)
|
|
21
23
|
--plugins <a,b> 플러그인 (${PLUGINS_LIST}). 미지정/"" → 없음
|
|
22
|
-
--theme <base64>
|
|
24
|
+
--theme <preset|base64> 프리셋 이름(${THEME_PRESETS_LIST}) 또는 playground base64. 선택
|
|
23
25
|
--yes 디렉토리 덮어쓰기 + 모노레포 기본값 자동 채택
|
|
24
26
|
--dry-run 파일을 쓰지 않고 작성될 파일 목록만 출력
|
|
25
27
|
-h, --help 이 도움말
|
|
@@ -30,6 +32,7 @@ export const HELP_TEXT = `sh-ui create — sh-ui 프로젝트 스캐폴드 (Next
|
|
|
30
32
|
예 (비대화형 / 에이전트 / CI):
|
|
31
33
|
sh-ui create my-app --platform next --structure standalone --yes
|
|
32
34
|
sh-ui create my-app --platform next --structure monorepo --plugins ${PLUGIN_NAMES.slice(0, 3).join(',')} --yes
|
|
35
|
+
sh-ui create my-app --platform next --structure standalone --theme rose --yes
|
|
33
36
|
sh-ui create my-app --platform flutter --yes
|
|
34
37
|
|
|
35
38
|
비대화형 환경(TTY 없음)에서는 누락된 필수 인자가 있으면 prompt 대신 에러로 종료한다.
|
|
@@ -1,11 +1,81 @@
|
|
|
1
|
+
import { getThemePreset, THEME_PRESET_NAMES } from './presets.js';
|
|
2
|
+
|
|
1
3
|
const TOKEN_KEYS = [
|
|
2
|
-
'background', 'background-subtle', 'background-muted',
|
|
3
|
-
'foreground', 'foreground-muted',
|
|
4
|
+
'background', 'background-subtle', 'background-muted', 'background-inverse',
|
|
5
|
+
'foreground', 'foreground-muted', 'foreground-subtle', 'foreground-inverse',
|
|
4
6
|
'border', 'border-strong',
|
|
5
7
|
'primary', 'primary-foreground', 'primary-hover',
|
|
6
8
|
'danger', 'danger-foreground',
|
|
7
9
|
];
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* 옵셔널 카테고리 검증 — 모두 Record<string, number>.
|
|
13
|
+
* 카테고리 누락은 OK (스캐폴드 시 해당 블록은 디폴트 유지). 형식만 위배되면 에러.
|
|
14
|
+
*
|
|
15
|
+
* 각 카테고리의 expectedKeys 가 정확히 일치해야 함 — 나머지/누락 키 모두 거부.
|
|
16
|
+
*/
|
|
17
|
+
const SCALAR_CATEGORIES = {
|
|
18
|
+
spacing: ['0', '1', '2', '3', '4', '5', '6', '8', '10', '12', '16'],
|
|
19
|
+
typography: ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl'],
|
|
20
|
+
weights: ['regular', 'medium', 'semibold', 'bold'],
|
|
21
|
+
controls: ['sm', 'md', 'lg'],
|
|
22
|
+
borders: ['width', 'widthStrong'],
|
|
23
|
+
durations: ['fast', 'base', 'slow'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Phase 3 — 문자열 카테고리. 형식 자체 검증은 inject 빌더에서 (CSS 그대로 통과 + Dart 변환 시 throw). */
|
|
27
|
+
const STRING_CATEGORIES = {
|
|
28
|
+
shadows: ['sm', 'md', 'lg', 'xl'],
|
|
29
|
+
eases: ['standard', 'emphasized'],
|
|
30
|
+
gradients: ['primary', 'surface', 'overlay'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const validateStringCategory = (name, expectedKeys, value) => {
|
|
34
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
35
|
+
throw new Error(`theme 디코드 실패: ${name} 가 객체가 아님`);
|
|
36
|
+
}
|
|
37
|
+
for (const key of expectedKeys) {
|
|
38
|
+
if (!(key in value)) {
|
|
39
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 누락`);
|
|
40
|
+
}
|
|
41
|
+
const v = value[key];
|
|
42
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
43
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 가 빈 문자열 또는 비-문자열 (받은 값: ${JSON.stringify(v)})`);
|
|
44
|
+
}
|
|
45
|
+
if (v.length > 512) {
|
|
46
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 가 너무 김 (>512자)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const key of Object.keys(value)) {
|
|
50
|
+
if (!expectedKeys.includes(key)) {
|
|
51
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 는 알 수 없는 키`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const validateScalarCategory = (name, expectedKeys, value) => {
|
|
57
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
58
|
+
throw new Error(`theme 디코드 실패: ${name} 가 객체가 아님`);
|
|
59
|
+
}
|
|
60
|
+
for (const key of expectedKeys) {
|
|
61
|
+
if (!(key in value)) {
|
|
62
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 누락`);
|
|
63
|
+
}
|
|
64
|
+
const v = value[key];
|
|
65
|
+
if (typeof v !== 'number' || Number.isNaN(v) || !Number.isFinite(v)) {
|
|
66
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 가 유한 숫자가 아님 (받은 값: ${JSON.stringify(v)})`);
|
|
67
|
+
}
|
|
68
|
+
if (v < 0) {
|
|
69
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 가 음수 (받은 값: ${v})`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const key of Object.keys(value)) {
|
|
73
|
+
if (!expectedKeys.includes(key)) {
|
|
74
|
+
throw new Error(`theme 디코드 실패: ${name}.${key} 는 알 수 없는 키`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
9
79
|
const HEX_REGEX = /^#[0-9A-Fa-f]{6}$/;
|
|
10
80
|
const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
11
81
|
const MAX_THEME_BYTES = 10 * 1024; // 정상 테마 ~860 바이트. 10KB 면 10× 여유.
|
|
@@ -60,7 +130,40 @@ export const decodeTheme = (b64) => {
|
|
|
60
130
|
if (parsed.radius < 0 || parsed.radius > 1.5) {
|
|
61
131
|
throw new Error(`theme 디코드 실패: radius 가 허용 범위(0~1.5)를 벗어남 (${parsed.radius})`);
|
|
62
132
|
}
|
|
133
|
+
// 옵셔널 카테고리 — 누락은 OK, 들어 있으면 형식 검증.
|
|
134
|
+
for (const [category, expectedKeys] of Object.entries(SCALAR_CATEGORIES)) {
|
|
135
|
+
if (category in parsed) {
|
|
136
|
+
validateScalarCategory(category, expectedKeys, parsed[category]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const [category, expectedKeys] of Object.entries(STRING_CATEGORIES)) {
|
|
140
|
+
if (category in parsed) {
|
|
141
|
+
validateStringCategory(category, expectedKeys, parsed[category]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
63
144
|
return parsed;
|
|
64
145
|
};
|
|
65
146
|
|
|
66
|
-
|
|
147
|
+
/**
|
|
148
|
+
* --theme 입력 1개 슬롯에 프리셋 이름과 base64 둘 다 받기 위한 리졸버.
|
|
149
|
+
* 프리셋 이름이면 미리 정의된 ThemeConfig 를, 그 외엔 base64 로 간주해 decodeTheme 위임.
|
|
150
|
+
* THEME_PRESET_NAMES 와 base64 알파벳은 둘 다 [a-z]+ 와 충돌하지만 프리셋 사전 매칭이 우선이라
|
|
151
|
+
* 'rose' 같이 우연히 base64 로도 파싱 가능한 짧은 토큰은 프리셋으로 해석된다 (의도).
|
|
152
|
+
*/
|
|
153
|
+
export const resolveTheme = (input) => {
|
|
154
|
+
if (typeof input !== 'string') {
|
|
155
|
+
throw new Error(`theme 입력 실패: 문자열이 아님`);
|
|
156
|
+
}
|
|
157
|
+
const preset = getThemePreset(input);
|
|
158
|
+
if (preset) return preset;
|
|
159
|
+
// 정상 base64 테마는 200자 이상. 그보다 짧으면 프리셋 오타로 보고 친절한 에러를.
|
|
160
|
+
if (input.length < 50) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`알 수 없는 테마 프리셋: '${input}'. 지원: ${THEME_PRESET_NAMES.join(', ')} ` +
|
|
163
|
+
`(또는 playground 에서 생성한 base64).`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return decodeTheme(input);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export { TOKEN_KEYS, THEME_PRESET_NAMES };
|
|
@@ -56,40 +56,32 @@ const toDartColor = (hex) => `Color(0xFF${hex.replace('#', '').toUpperCase()})`;
|
|
|
56
56
|
* inverse — 반대 모드의 편집값
|
|
57
57
|
* default — playground 가 노출하지 않음, 고정 기본값 사용
|
|
58
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* Dart ShUiColorTokens 필드 매핑. v0.37.0 부터 background-inverse / foreground-inverse /
|
|
61
|
+
* foreground-subtle 도 사용자가 직접 잡으므로 모두 self 룩업.
|
|
62
|
+
*/
|
|
59
63
|
const DART_FIELD_SOURCES = [
|
|
60
|
-
{ field: 'background',
|
|
61
|
-
{ field: 'backgroundSubtle',
|
|
62
|
-
{ field: 'backgroundMuted',
|
|
63
|
-
{ field: 'backgroundInverse',
|
|
64
|
-
{ field: 'foreground',
|
|
65
|
-
{ field: 'foregroundMuted',
|
|
66
|
-
{ field: 'foregroundSubtle',
|
|
67
|
-
{ field: 'foregroundInverse',
|
|
68
|
-
{ field: 'border',
|
|
69
|
-
{ field: 'borderStrong',
|
|
70
|
-
{ field: 'primary',
|
|
71
|
-
{ field: 'primaryForeground',
|
|
72
|
-
{ field: 'primaryHover',
|
|
73
|
-
{ field: 'danger',
|
|
74
|
-
{ field: 'dangerForeground',
|
|
64
|
+
{ field: 'background', key: 'background' },
|
|
65
|
+
{ field: 'backgroundSubtle', key: 'background-subtle' },
|
|
66
|
+
{ field: 'backgroundMuted', key: 'background-muted' },
|
|
67
|
+
{ field: 'backgroundInverse', key: 'background-inverse' },
|
|
68
|
+
{ field: 'foreground', key: 'foreground' },
|
|
69
|
+
{ field: 'foregroundMuted', key: 'foreground-muted' },
|
|
70
|
+
{ field: 'foregroundSubtle', key: 'foreground-subtle' },
|
|
71
|
+
{ field: 'foregroundInverse', key: 'foreground-inverse' },
|
|
72
|
+
{ field: 'border', key: 'border' },
|
|
73
|
+
{ field: 'borderStrong', key: 'border-strong' },
|
|
74
|
+
{ field: 'primary', key: 'primary' },
|
|
75
|
+
{ field: 'primaryForeground', key: 'primary-foreground' },
|
|
76
|
+
{ field: 'primaryHover', key: 'primary-hover' },
|
|
77
|
+
{ field: 'danger', key: 'danger' },
|
|
78
|
+
{ field: 'dangerForeground', key: 'danger-foreground' },
|
|
75
79
|
];
|
|
76
80
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const buildDartStaticConst = (mode, self, opposite) => {
|
|
83
|
-
const lines = DART_FIELD_SOURCES.map(({ field, source }) => {
|
|
84
|
-
switch (source.kind) {
|
|
85
|
-
case 'self':
|
|
86
|
-
return ` ${field}: ${toDartColor(self[source.key])},`;
|
|
87
|
-
case 'inverse':
|
|
88
|
-
return ` ${field}: ${toDartColor(opposite[source.key])},`;
|
|
89
|
-
case 'default':
|
|
90
|
-
return ` ${field}: Color(${DART_DEFAULTS[mode][field]}),`;
|
|
91
|
-
}
|
|
92
|
-
}).join('\n');
|
|
81
|
+
const buildDartStaticConst = (mode, self) => {
|
|
82
|
+
const lines = DART_FIELD_SOURCES.map(({ field, key }) =>
|
|
83
|
+
` ${field}: ${toDartColor(self[key])},`,
|
|
84
|
+
).join('\n');
|
|
93
85
|
return [
|
|
94
86
|
` static const ${mode} = ShUiColorTokens(`,
|
|
95
87
|
lines,
|
|
@@ -99,9 +91,9 @@ const buildDartStaticConst = (mode, self, opposite) => {
|
|
|
99
91
|
|
|
100
92
|
export const buildDartColorsBlock = (theme) => {
|
|
101
93
|
return [
|
|
102
|
-
buildDartStaticConst('light', theme.light
|
|
94
|
+
buildDartStaticConst('light', theme.light),
|
|
103
95
|
'',
|
|
104
|
-
buildDartStaticConst('dark', theme.dark
|
|
96
|
+
buildDartStaticConst('dark', theme.dark),
|
|
105
97
|
].join('\n');
|
|
106
98
|
};
|
|
107
99
|
|
|
@@ -109,3 +101,295 @@ export const buildDartRadiusBlock = (theme) => {
|
|
|
109
101
|
const px = (theme.radius * 16).toFixed(1);
|
|
110
102
|
return ` defaultRadius: ${px},`;
|
|
111
103
|
};
|
|
104
|
+
|
|
105
|
+
// ─── 옵셔널 카테고리 빌더 (Phase 2) ───
|
|
106
|
+
//
|
|
107
|
+
// 모두 마커 사이에 들어갈 본문만 만든다. CSS 는 변수 선언 N줄, Dart 는 필드 N줄.
|
|
108
|
+
|
|
109
|
+
/* --- spacing --- */
|
|
110
|
+
|
|
111
|
+
const SPACING_KEYS = ['0', '1', '2', '3', '4', '5', '6', '8', '10', '12', '16'];
|
|
112
|
+
|
|
113
|
+
export const buildCssSpacingBlock = (spacing) =>
|
|
114
|
+
SPACING_KEYS.map((k) => ` --space-${k}: ${spacing[k]}px;`).join('\n');
|
|
115
|
+
|
|
116
|
+
export const buildDartSpacingBlock = (spacing) =>
|
|
117
|
+
SPACING_KEYS.map((k) => ` s${k}: ${spacing[k].toFixed(1)},`).join('\n');
|
|
118
|
+
|
|
119
|
+
/* --- typography --- */
|
|
120
|
+
|
|
121
|
+
const TYPOGRAPHY_KEYS = ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl'];
|
|
122
|
+
|
|
123
|
+
export const buildCssTypographyBlock = (typography) =>
|
|
124
|
+
TYPOGRAPHY_KEYS.map((k) => ` --text-${k}: ${typography[k]}px;`).join('\n');
|
|
125
|
+
|
|
126
|
+
// Dart 클래스는 'xl2'/'xl3'/'xl4' 명명 규칙 사용 (숫자 prefix 회피).
|
|
127
|
+
const DART_TEXT_FIELD_NAMES = {
|
|
128
|
+
xs: 'xs', sm: 'sm', base: 'base', lg: 'lg', xl: 'xl',
|
|
129
|
+
'2xl': 'xl2', '3xl': 'xl3', '4xl': 'xl4',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const buildDartTypographyBlock = (typography) =>
|
|
133
|
+
TYPOGRAPHY_KEYS
|
|
134
|
+
.map((k) => ` ${DART_TEXT_FIELD_NAMES[k]}: ${typography[k].toFixed(1)},`)
|
|
135
|
+
.join('\n');
|
|
136
|
+
|
|
137
|
+
/* --- weights --- */
|
|
138
|
+
|
|
139
|
+
const WEIGHT_KEYS = ['regular', 'medium', 'semibold', 'bold'];
|
|
140
|
+
|
|
141
|
+
export const buildCssWeightsBlock = (weights) =>
|
|
142
|
+
WEIGHT_KEYS.map((k) => ` --weight-${k}: ${weights[k]};`).join('\n');
|
|
143
|
+
|
|
144
|
+
export const buildDartWeightsBlock = (weights) =>
|
|
145
|
+
WEIGHT_KEYS.map((k) => ` ${k}: FontWeight.w${weights[k]},`).join('\n');
|
|
146
|
+
|
|
147
|
+
/* --- controls --- */
|
|
148
|
+
|
|
149
|
+
const CONTROL_KEYS = ['sm', 'md', 'lg'];
|
|
150
|
+
|
|
151
|
+
export const buildCssControlsBlock = (controls) =>
|
|
152
|
+
CONTROL_KEYS.map((k) => ` --control-${k}: ${controls[k]}px;`).join('\n');
|
|
153
|
+
|
|
154
|
+
export const buildDartControlsBlock = (controls) =>
|
|
155
|
+
CONTROL_KEYS.map((k) => ` ${k}: ${controls[k].toFixed(1)},`).join('\n');
|
|
156
|
+
|
|
157
|
+
/* --- borders --- */
|
|
158
|
+
|
|
159
|
+
export const buildCssBordersBlock = (borders) => [
|
|
160
|
+
` --border-width: ${borders.width}px;`,
|
|
161
|
+
` --border-width-strong: ${borders.widthStrong}px;`,
|
|
162
|
+
].join('\n');
|
|
163
|
+
|
|
164
|
+
export const buildDartBordersBlock = (borders) => [
|
|
165
|
+
` normal: ${borders.width.toFixed(1)},`,
|
|
166
|
+
` strong: ${borders.widthStrong.toFixed(1)},`,
|
|
167
|
+
].join('\n');
|
|
168
|
+
|
|
169
|
+
/* --- durations --- */
|
|
170
|
+
|
|
171
|
+
const DURATION_KEYS = ['fast', 'base', 'slow'];
|
|
172
|
+
|
|
173
|
+
export const buildCssDurationsBlock = (durations) =>
|
|
174
|
+
DURATION_KEYS.map((k) => ` --duration-${k}: ${durations[k]}ms;`).join('\n');
|
|
175
|
+
|
|
176
|
+
export const buildDartDurationsBlock = (durations) =>
|
|
177
|
+
DURATION_KEYS
|
|
178
|
+
.map((k) => ` ${k}: Duration(milliseconds: ${Math.round(durations[k])}),`)
|
|
179
|
+
.join('\n');
|
|
180
|
+
|
|
181
|
+
// ─── Phase 3 — string 카테고리: shadow / ease / gradient ───
|
|
182
|
+
//
|
|
183
|
+
// CSS 측은 사용자가 입력한 문자열을 거의 그대로 흘려보내고, Dart 측은 파서를 거쳐
|
|
184
|
+
// BoxShadow / Cubic / LinearGradient 로 변환. 파서는 형식 위배 시 throw.
|
|
185
|
+
|
|
186
|
+
/* --- 공통 유틸 --- */
|
|
187
|
+
|
|
188
|
+
/** 괄호 깊이 0 인 위치에서만 콤마로 split. rgba(0,0,0,0.5) 같은 것을 보호. */
|
|
189
|
+
const splitTopLevelByComma = (str) => {
|
|
190
|
+
const parts = [];
|
|
191
|
+
let depth = 0, start = 0;
|
|
192
|
+
for (let i = 0; i < str.length; i++) {
|
|
193
|
+
const c = str[i];
|
|
194
|
+
if (c === '(') depth++;
|
|
195
|
+
else if (c === ')') depth--;
|
|
196
|
+
else if (c === ',' && depth === 0) {
|
|
197
|
+
parts.push(str.slice(start, i).trim());
|
|
198
|
+
start = i + 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
parts.push(str.slice(start).trim());
|
|
202
|
+
return parts.filter(Boolean);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** 공백 split — 단 괄호 안은 그룹 유지. */
|
|
206
|
+
const tokenizeSpaceAware = (str) => {
|
|
207
|
+
const tokens = [];
|
|
208
|
+
let depth = 0, start = 0;
|
|
209
|
+
const flush = (end) => {
|
|
210
|
+
const t = str.slice(start, end).trim();
|
|
211
|
+
if (t) tokens.push(t);
|
|
212
|
+
};
|
|
213
|
+
for (let i = 0; i < str.length; i++) {
|
|
214
|
+
const c = str[i];
|
|
215
|
+
if (c === '(') depth++;
|
|
216
|
+
else if (c === ')') depth--;
|
|
217
|
+
else if (/\s/.test(c) && depth === 0) {
|
|
218
|
+
flush(i);
|
|
219
|
+
start = i + 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
flush(str.length);
|
|
223
|
+
return tokens;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const cssLengthToPx = (s, ctx) => {
|
|
227
|
+
const m = s.match(/^(-?[\d.]+)(?:px)?$/);
|
|
228
|
+
if (!m) throw new Error(`${ctx}: 길이 단위 오류 — '${s}'`);
|
|
229
|
+
return parseFloat(m[1]);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** CSS color → { r, g, b, a } (a ∈ 0..255). #RRGGBB / #RRGGBBAA / rgb() / rgba() 지원. */
|
|
233
|
+
const parseCssColor = (s, ctx) => {
|
|
234
|
+
if (s.startsWith('#')) {
|
|
235
|
+
const hex = s.slice(1);
|
|
236
|
+
if (hex.length === 6) {
|
|
237
|
+
return {
|
|
238
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
239
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
240
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
241
|
+
a: 0xff,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (hex.length === 8) {
|
|
245
|
+
return {
|
|
246
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
247
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
248
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
249
|
+
a: parseInt(hex.slice(6, 8), 16),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
throw new Error(`${ctx}: hex 색은 #RRGGBB 또는 #RRGGBBAA 만 지원 — '${s}'`);
|
|
253
|
+
}
|
|
254
|
+
const m = s.match(/^rgba?\(\s*([^)]+)\)$/);
|
|
255
|
+
if (m) {
|
|
256
|
+
const parts = m[1].split(',').map((p) => p.trim());
|
|
257
|
+
if (parts.length < 3 || parts.length > 4) {
|
|
258
|
+
throw new Error(`${ctx}: rgb/rgba 인자 수 오류 — '${s}'`);
|
|
259
|
+
}
|
|
260
|
+
const r = parseInt(parts[0], 10);
|
|
261
|
+
const g = parseInt(parts[1], 10);
|
|
262
|
+
const b = parseInt(parts[2], 10);
|
|
263
|
+
const a = parts[3] !== undefined ? Math.round(parseFloat(parts[3]) * 255) : 0xff;
|
|
264
|
+
if ([r, g, b, a].some((n) => Number.isNaN(n))) {
|
|
265
|
+
throw new Error(`${ctx}: rgb/rgba 숫자 파싱 실패 — '${s}'`);
|
|
266
|
+
}
|
|
267
|
+
return { r, g, b, a };
|
|
268
|
+
}
|
|
269
|
+
throw new Error(`${ctx}: 색 형식 미지원 (#hex / rgb() / rgba() 만) — '${s}'`);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const colorToARGBHex = ({ a, r, g, b }) => {
|
|
273
|
+
const v = (((a & 0xff) << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)) >>> 0;
|
|
274
|
+
return v.toString(16).toUpperCase().padStart(8, '0');
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/* --- shadow --- */
|
|
278
|
+
|
|
279
|
+
const SHADOW_KEYS = ['sm', 'md', 'lg', 'xl'];
|
|
280
|
+
|
|
281
|
+
export const buildCssShadowsBlock = (shadows) =>
|
|
282
|
+
SHADOW_KEYS.map((k) => ` --shadow-${k}: ${shadows[k]};`).join('\n');
|
|
283
|
+
|
|
284
|
+
const parseSingleBoxShadow = (s) => {
|
|
285
|
+
const tokens = tokenizeSpaceAware(s);
|
|
286
|
+
if (tokens.length < 4 || tokens.length > 5) {
|
|
287
|
+
throw new Error(`shadow: 토큰 수 4~5 (offset-x offset-y blur [spread] color) — '${s}'`);
|
|
288
|
+
}
|
|
289
|
+
const offsetX = cssLengthToPx(tokens[0], 'shadow.offset-x');
|
|
290
|
+
const offsetY = cssLengthToPx(tokens[1], 'shadow.offset-y');
|
|
291
|
+
const blur = cssLengthToPx(tokens[2], 'shadow.blur');
|
|
292
|
+
let spread = 0;
|
|
293
|
+
let colorStr;
|
|
294
|
+
if (tokens.length === 4) {
|
|
295
|
+
colorStr = tokens[3];
|
|
296
|
+
} else {
|
|
297
|
+
spread = cssLengthToPx(tokens[3], 'shadow.spread');
|
|
298
|
+
colorStr = tokens[4];
|
|
299
|
+
}
|
|
300
|
+
const color = parseCssColor(colorStr, 'shadow.color');
|
|
301
|
+
return { offsetX, offsetY, blur, spread, color };
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export const cssShadowToDartList = (cssValue) => {
|
|
305
|
+
const items = splitTopLevelByComma(cssValue).map(parseSingleBoxShadow);
|
|
306
|
+
const dartItems = items.map((it) => {
|
|
307
|
+
const hex = colorToARGBHex(it.color);
|
|
308
|
+
return `BoxShadow(offset: Offset(${it.offsetX.toFixed(1)}, ${it.offsetY.toFixed(1)}), blurRadius: ${it.blur.toFixed(1)}, spreadRadius: ${it.spread.toFixed(1)}, color: Color(0x${hex}))`;
|
|
309
|
+
});
|
|
310
|
+
return `<BoxShadow>[${dartItems.join(', ')}]`;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
export const buildDartShadowsBlock = (shadows) =>
|
|
314
|
+
SHADOW_KEYS.map((k) => ` ${k}: ${cssShadowToDartList(shadows[k])},`).join('\n');
|
|
315
|
+
|
|
316
|
+
/* --- ease --- */
|
|
317
|
+
|
|
318
|
+
const EASE_KEYS = ['standard', 'emphasized'];
|
|
319
|
+
|
|
320
|
+
const NAMED_EASES = {
|
|
321
|
+
linear: [0, 0, 1, 1],
|
|
322
|
+
ease: [0.25, 0.1, 0.25, 1],
|
|
323
|
+
'ease-in': [0.42, 0, 1, 1],
|
|
324
|
+
'ease-out': [0, 0, 0.58, 1],
|
|
325
|
+
'ease-in-out': [0.42, 0, 0.58, 1],
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export const buildCssEasesBlock = (eases) =>
|
|
329
|
+
EASE_KEYS.map((k) => ` --ease-${k}: ${eases[k]};`).join('\n');
|
|
330
|
+
|
|
331
|
+
export const cssEaseToDartCubic = (cssValue) => {
|
|
332
|
+
const trimmed = cssValue.trim();
|
|
333
|
+
if (NAMED_EASES[trimmed]) {
|
|
334
|
+
const [x1, y1, x2, y2] = NAMED_EASES[trimmed];
|
|
335
|
+
return `Cubic(${x1}, ${y1}, ${x2}, ${y2})`;
|
|
336
|
+
}
|
|
337
|
+
const m = trimmed.match(/^cubic-bezier\(([^)]+)\)$/);
|
|
338
|
+
if (!m) throw new Error(`ease: cubic-bezier(...) 또는 named easing 만 — '${cssValue}'`);
|
|
339
|
+
const nums = m[1].split(',').map((s) => parseFloat(s.trim()));
|
|
340
|
+
if (nums.length !== 4 || nums.some((n) => Number.isNaN(n))) {
|
|
341
|
+
throw new Error(`ease: cubic-bezier 인자 4개 숫자 필요 — '${cssValue}'`);
|
|
342
|
+
}
|
|
343
|
+
return `Cubic(${nums.join(', ')})`;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export const buildDartEasesBlock = (eases) =>
|
|
347
|
+
EASE_KEYS.map((k) => ` ${k}: ${cssEaseToDartCubic(eases[k])},`).join('\n');
|
|
348
|
+
|
|
349
|
+
/* --- gradient --- */
|
|
350
|
+
|
|
351
|
+
const GRADIENT_KEYS = ['primary', 'surface', 'overlay'];
|
|
352
|
+
|
|
353
|
+
export const buildCssGradientsBlock = (gradients) =>
|
|
354
|
+
GRADIENT_KEYS.map((k) => ` --gradient-${k}: ${gradients[k]};`).join('\n');
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* CSS gradient 의 deg → Flutter Alignment(begin, end).
|
|
358
|
+
* CSS 0deg=위쪽, 시계방향. Flutter Alignment 의 +y 는 아래.
|
|
359
|
+
*/
|
|
360
|
+
const angleToBeginEnd = (deg) => {
|
|
361
|
+
const rad = (deg * Math.PI) / 180;
|
|
362
|
+
const ex = Math.sin(rad);
|
|
363
|
+
const ey = -Math.cos(rad);
|
|
364
|
+
const round3 = (v) => parseFloat(v.toFixed(3));
|
|
365
|
+
return {
|
|
366
|
+
begin: [round3(-ex), round3(-ey)],
|
|
367
|
+
end: [round3(ex), round3(ey)],
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
export const cssGradientToDartLinear = (cssValue) => {
|
|
372
|
+
const trimmed = cssValue.trim();
|
|
373
|
+
const m = trimmed.match(/^linear-gradient\(([\s\S]+)\)$/);
|
|
374
|
+
if (!m) throw new Error(`gradient: linear-gradient(...) 만 — '${cssValue}'`);
|
|
375
|
+
const parts = splitTopLevelByComma(m[1]);
|
|
376
|
+
if (parts.length < 3) throw new Error(`gradient: 인자 부족 (각도 + 스톱 ≥ 2) — '${cssValue}'`);
|
|
377
|
+
const angleM = parts[0].match(/^(-?[\d.]+)deg$/);
|
|
378
|
+
if (!angleM) throw new Error(`gradient: 첫 인자가 <angle>deg 아님 — '${parts[0]}'`);
|
|
379
|
+
const angle = parseFloat(angleM[1]);
|
|
380
|
+
const stops = parts.slice(1).map((s) => {
|
|
381
|
+
const tokens = tokenizeSpaceAware(s);
|
|
382
|
+
if (tokens.length !== 2) throw new Error(`gradient: 스톱 형식 '<color> <pos>%' — '${s}'`);
|
|
383
|
+
const color = parseCssColor(tokens[0], 'gradient.stop.color');
|
|
384
|
+
const pm = tokens[1].match(/^([\d.]+)%$/);
|
|
385
|
+
if (!pm) throw new Error(`gradient: 스톱 위치 % 형식 — '${tokens[1]}'`);
|
|
386
|
+
return { color, position: parseFloat(pm[1]) };
|
|
387
|
+
});
|
|
388
|
+
const { begin, end } = angleToBeginEnd(angle);
|
|
389
|
+
const colorList = stops.map((st) => `Color(0x${colorToARGBHex(st.color)})`).join(', ');
|
|
390
|
+
const stopList = stops.map((st) => (st.position / 100).toFixed(2)).join(', ');
|
|
391
|
+
return `LinearGradient(begin: Alignment(${begin[0]}, ${begin[1]}), end: Alignment(${end[0]}, ${end[1]}), colors: <Color>[${colorList}], stops: <double>[${stopList}])`;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export const buildDartGradientsBlock = (gradients) =>
|
|
395
|
+
GRADIENT_KEYS.map((k) => ` ${k}: ${cssGradientToDartLinear(gradients[k])},`).join('\n');
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// 프로젝트 스캐폴드용 테마 프리셋. 사용자가 --theme <name> 또는 대화형 프롬프트에서
|
|
2
|
+
// 고르면 generator 가 tokens.css / sh_ui_tokens.dart 에 그대로 주입한다.
|
|
3
|
+
//
|
|
4
|
+
// 색은 shadcn/ui · Tailwind 팔레트를 참고해 light/dark 모두 충분한 명도 대비를 갖도록 잡았다.
|
|
5
|
+
// radius 만 살짝 변주해 프리셋 별 인상 차이를 더했다 (slate=0.375 sharp, rose=0.75 round, …).
|
|
6
|
+
//
|
|
7
|
+
// 새 프리셋을 추가하면 cli-args.js 의 VALID_THEME_PRESETS 에도 자동 반영된다 (이 파일에서 derive).
|
|
8
|
+
|
|
9
|
+
const NEUTRAL_LIGHT = {
|
|
10
|
+
'background': '#FFFFFF',
|
|
11
|
+
'background-subtle': '#FAFAFA',
|
|
12
|
+
'background-muted': '#F5F5F5',
|
|
13
|
+
'background-inverse': '#0A0A0A',
|
|
14
|
+
'foreground': '#0A0A0A',
|
|
15
|
+
'foreground-muted': '#525252',
|
|
16
|
+
'foreground-subtle': '#A3A3A3',
|
|
17
|
+
'foreground-inverse': '#FFFFFF',
|
|
18
|
+
'border': '#E5E5E5',
|
|
19
|
+
'border-strong': '#D4D4D4',
|
|
20
|
+
'primary': '#171717',
|
|
21
|
+
'primary-foreground': '#FAFAFA',
|
|
22
|
+
'primary-hover': '#262626',
|
|
23
|
+
'danger': '#DC2626',
|
|
24
|
+
'danger-foreground': '#FFFFFF',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const NEUTRAL_DARK = {
|
|
28
|
+
'background': '#0A0A0A',
|
|
29
|
+
'background-subtle': '#171717',
|
|
30
|
+
'background-muted': '#262626',
|
|
31
|
+
'background-inverse': '#FFFFFF',
|
|
32
|
+
'foreground': '#FAFAFA',
|
|
33
|
+
'foreground-muted': '#A3A3A3',
|
|
34
|
+
'foreground-subtle': '#737373',
|
|
35
|
+
'foreground-inverse': '#0A0A0A',
|
|
36
|
+
'border': '#262626',
|
|
37
|
+
'border-strong': '#404040',
|
|
38
|
+
'primary': '#FAFAFA',
|
|
39
|
+
'primary-foreground': '#171717',
|
|
40
|
+
'primary-hover': '#E5E5E5',
|
|
41
|
+
'danger': '#DC2626',
|
|
42
|
+
'danger-foreground': '#FFFFFF',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const THEME_PRESETS = {
|
|
46
|
+
neutral: {
|
|
47
|
+
label: '뉴트럴 — 흑백 강조 (sh-ui 기본)',
|
|
48
|
+
light: NEUTRAL_LIGHT,
|
|
49
|
+
dark: NEUTRAL_DARK,
|
|
50
|
+
radius: 0.5,
|
|
51
|
+
},
|
|
52
|
+
slate: {
|
|
53
|
+
label: '슬레이트 — 차분한 슬레이트 + 인디고',
|
|
54
|
+
light: {
|
|
55
|
+
'background': '#FFFFFF',
|
|
56
|
+
'background-subtle': '#F8FAFC',
|
|
57
|
+
'background-muted': '#F1F5F9',
|
|
58
|
+
'background-inverse': '#0F172A',
|
|
59
|
+
'foreground': '#0F172A',
|
|
60
|
+
'foreground-muted': '#475569',
|
|
61
|
+
'foreground-subtle': '#94A3B8',
|
|
62
|
+
'foreground-inverse': '#F1F5F9',
|
|
63
|
+
'border': '#E2E8F0',
|
|
64
|
+
'border-strong': '#CBD5E1',
|
|
65
|
+
'primary': '#4F46E5',
|
|
66
|
+
'primary-foreground': '#FFFFFF',
|
|
67
|
+
'primary-hover': '#4338CA',
|
|
68
|
+
'danger': '#DC2626',
|
|
69
|
+
'danger-foreground': '#FFFFFF',
|
|
70
|
+
},
|
|
71
|
+
dark: {
|
|
72
|
+
'background': '#0F172A',
|
|
73
|
+
'background-subtle': '#1E293B',
|
|
74
|
+
'background-muted': '#334155',
|
|
75
|
+
'background-inverse': '#FFFFFF',
|
|
76
|
+
'foreground': '#F1F5F9',
|
|
77
|
+
'foreground-muted': '#94A3B8',
|
|
78
|
+
'foreground-subtle': '#64748B',
|
|
79
|
+
'foreground-inverse': '#0F172A',
|
|
80
|
+
'border': '#334155',
|
|
81
|
+
'border-strong': '#475569',
|
|
82
|
+
'primary': '#818CF8',
|
|
83
|
+
'primary-foreground': '#1E1B4B',
|
|
84
|
+
'primary-hover': '#A5B4FC',
|
|
85
|
+
'danger': '#F87171',
|
|
86
|
+
'danger-foreground': '#450A0A',
|
|
87
|
+
},
|
|
88
|
+
radius: 0.375,
|
|
89
|
+
},
|
|
90
|
+
rose: {
|
|
91
|
+
label: '로즈 — 핑크 강조 + 둥근 모서리',
|
|
92
|
+
light: {
|
|
93
|
+
...NEUTRAL_LIGHT,
|
|
94
|
+
'primary': '#E11D48',
|
|
95
|
+
'primary-foreground': '#FFF1F2',
|
|
96
|
+
'primary-hover': '#BE123C',
|
|
97
|
+
},
|
|
98
|
+
dark: {
|
|
99
|
+
...NEUTRAL_DARK,
|
|
100
|
+
'primary': '#FB7185',
|
|
101
|
+
'primary-foreground': '#4C0519',
|
|
102
|
+
'primary-hover': '#FDA4AF',
|
|
103
|
+
},
|
|
104
|
+
radius: 0.75,
|
|
105
|
+
},
|
|
106
|
+
emerald: {
|
|
107
|
+
label: '에메랄드 — 그린 강조',
|
|
108
|
+
light: {
|
|
109
|
+
...NEUTRAL_LIGHT,
|
|
110
|
+
'primary': '#059669',
|
|
111
|
+
'primary-foreground': '#ECFDF5',
|
|
112
|
+
'primary-hover': '#047857',
|
|
113
|
+
},
|
|
114
|
+
dark: {
|
|
115
|
+
...NEUTRAL_DARK,
|
|
116
|
+
'primary': '#34D399',
|
|
117
|
+
'primary-foreground': '#022C22',
|
|
118
|
+
'primary-hover': '#6EE7B7',
|
|
119
|
+
},
|
|
120
|
+
radius: 0.5,
|
|
121
|
+
},
|
|
122
|
+
violet: {
|
|
123
|
+
label: '바이올렛 — 퍼플 강조 + 살짝 라운드',
|
|
124
|
+
light: {
|
|
125
|
+
...NEUTRAL_LIGHT,
|
|
126
|
+
'primary': '#7C3AED',
|
|
127
|
+
'primary-foreground': '#F5F3FF',
|
|
128
|
+
'primary-hover': '#6D28D9',
|
|
129
|
+
},
|
|
130
|
+
dark: {
|
|
131
|
+
...NEUTRAL_DARK,
|
|
132
|
+
'primary': '#A78BFA',
|
|
133
|
+
'primary-foreground': '#1E1B4B',
|
|
134
|
+
'primary-hover': '#C4B5FD',
|
|
135
|
+
},
|
|
136
|
+
radius: 0.625,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS);
|
|
141
|
+
|
|
142
|
+
/** 프리셋 객체에서 inject 가 기대하는 ThemeConfig 형태(light/dark/radius)만 추출 */
|
|
143
|
+
export const getThemePreset = (name) => {
|
|
144
|
+
const preset = THEME_PRESETS[name];
|
|
145
|
+
if (!preset) return null;
|
|
146
|
+
return { light: preset.light, dark: preset.dark, radius: preset.radius };
|
|
147
|
+
};
|
package/src/mcp.mjs
CHANGED
|
@@ -38,12 +38,14 @@ import {
|
|
|
38
38
|
THEME_MODES,
|
|
39
39
|
} from "./constants.js";
|
|
40
40
|
import { allPlugins } from "./create/plugins/index.js";
|
|
41
|
+
import { THEME_PRESET_NAMES } from "./create/theme/presets.js";
|
|
41
42
|
|
|
42
43
|
const PLATFORMS = INIT_PLATFORMS;
|
|
43
44
|
const BASES = THEME_BASES;
|
|
44
45
|
const RADII = THEME_RADII;
|
|
45
46
|
const MODES = THEME_MODES;
|
|
46
47
|
const PLUGIN_NAMES = allPlugins.map((p) => p.name);
|
|
48
|
+
const THEME_PRESETS_LIST = THEME_PRESET_NAMES.join(", ");
|
|
47
49
|
|
|
48
50
|
const INIT_DESCRIPTIONS = {
|
|
49
51
|
platform: {
|
|
@@ -178,7 +180,7 @@ export async function startMcpServer() {
|
|
|
178
180
|
plugins: z.array(z.enum(PLUGIN_NAMES)).optional()
|
|
179
181
|
.describe(`Next.js 플러그인 (${PLUGIN_NAMES.join(', ')}). 미지정시 빈 배열`),
|
|
180
182
|
theme: z.string().optional()
|
|
181
|
-
.describe(
|
|
183
|
+
.describe(`프리셋 이름 (${THEME_PRESETS_LIST}) 또는 playground 에서 생성한 base64 (선택)`),
|
|
182
184
|
cwd: z.string().optional()
|
|
183
185
|
.describe("부모 디렉토리. 기본 process.cwd()"),
|
|
184
186
|
force: z.boolean().optional()
|
|
@@ -122,6 +122,7 @@ class ShUiSpacingTokens {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
static const tokens = ShUiSpacingTokens(
|
|
125
|
+
// sh-ui:theme-space-start
|
|
125
126
|
s0: 0.0,
|
|
126
127
|
s1: 4.0,
|
|
127
128
|
s2: 8.0,
|
|
@@ -133,6 +134,7 @@ class ShUiSpacingTokens {
|
|
|
133
134
|
s10: 40.0,
|
|
134
135
|
s12: 48.0,
|
|
135
136
|
s16: 64.0,
|
|
137
|
+
// sh-ui:theme-space-end
|
|
136
138
|
);
|
|
137
139
|
}
|
|
138
140
|
|
|
@@ -159,6 +161,7 @@ class ShUiTextTokens {
|
|
|
159
161
|
});
|
|
160
162
|
|
|
161
163
|
static const tokens = ShUiTextTokens(
|
|
164
|
+
// sh-ui:theme-text-start
|
|
162
165
|
xs: 12.0,
|
|
163
166
|
sm: 14.0,
|
|
164
167
|
base: 16.0,
|
|
@@ -167,6 +170,7 @@ class ShUiTextTokens {
|
|
|
167
170
|
xl2: 24.0,
|
|
168
171
|
xl3: 30.0,
|
|
169
172
|
xl4: 36.0,
|
|
173
|
+
// sh-ui:theme-text-end
|
|
170
174
|
);
|
|
171
175
|
}
|
|
172
176
|
|
|
@@ -185,10 +189,12 @@ class ShUiWeightTokens {
|
|
|
185
189
|
});
|
|
186
190
|
|
|
187
191
|
static const tokens = ShUiWeightTokens(
|
|
192
|
+
// sh-ui:theme-weight-start
|
|
188
193
|
regular: FontWeight.w400,
|
|
189
194
|
medium: FontWeight.w500,
|
|
190
195
|
semibold: FontWeight.w600,
|
|
191
196
|
bold: FontWeight.w700,
|
|
197
|
+
// sh-ui:theme-weight-end
|
|
192
198
|
);
|
|
193
199
|
}
|
|
194
200
|
|
|
@@ -207,10 +213,12 @@ class ShUiShadowTokens {
|
|
|
207
213
|
});
|
|
208
214
|
|
|
209
215
|
static const tokens = ShUiShadowTokens(
|
|
216
|
+
// sh-ui:theme-shadow-start
|
|
210
217
|
sm: <BoxShadow>[BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0, color: Color(0x14000000))],
|
|
211
218
|
md: <BoxShadow>[BoxShadow(offset: Offset(0.0, 4.0), blurRadius: 12.0, spreadRadius: 0.0, color: Color(0x1F000000))],
|
|
212
219
|
lg: <BoxShadow>[BoxShadow(offset: Offset(0.0, 8.0), blurRadius: 24.0, spreadRadius: 0.0, color: Color(0x26000000))],
|
|
213
220
|
xl: <BoxShadow>[BoxShadow(offset: Offset(0.0, 16.0), blurRadius: 48.0, spreadRadius: 0.0, color: Color(0x2E000000))],
|
|
221
|
+
// sh-ui:theme-shadow-end
|
|
214
222
|
);
|
|
215
223
|
}
|
|
216
224
|
|
|
@@ -227,9 +235,11 @@ class ShUiDurationTokens {
|
|
|
227
235
|
});
|
|
228
236
|
|
|
229
237
|
static const tokens = ShUiDurationTokens(
|
|
238
|
+
// sh-ui:theme-duration-start
|
|
230
239
|
fast: Duration(milliseconds: 120),
|
|
231
240
|
base: Duration(milliseconds: 160),
|
|
232
241
|
slow: Duration(milliseconds: 200),
|
|
242
|
+
// sh-ui:theme-duration-end
|
|
233
243
|
);
|
|
234
244
|
}
|
|
235
245
|
|
|
@@ -244,8 +254,10 @@ class ShUiEaseTokens {
|
|
|
244
254
|
});
|
|
245
255
|
|
|
246
256
|
static const tokens = ShUiEaseTokens(
|
|
257
|
+
// sh-ui:theme-ease-start
|
|
247
258
|
standard: Cubic(0.4, 0, 0.2, 1),
|
|
248
259
|
emphasized: Cubic(0.2, 0, 0, 1),
|
|
260
|
+
// sh-ui:theme-ease-end
|
|
249
261
|
);
|
|
250
262
|
}
|
|
251
263
|
|
|
@@ -262,9 +274,11 @@ class ShUiControlTokens {
|
|
|
262
274
|
});
|
|
263
275
|
|
|
264
276
|
static const tokens = ShUiControlTokens(
|
|
277
|
+
// sh-ui:theme-control-start
|
|
265
278
|
sm: 32.0,
|
|
266
279
|
md: 40.0,
|
|
267
280
|
lg: 48.0,
|
|
281
|
+
// sh-ui:theme-control-end
|
|
268
282
|
);
|
|
269
283
|
}
|
|
270
284
|
|
|
@@ -279,8 +293,10 @@ class ShUiBorderWidthTokens {
|
|
|
279
293
|
});
|
|
280
294
|
|
|
281
295
|
static const tokens = ShUiBorderWidthTokens(
|
|
296
|
+
// sh-ui:theme-border-width-start
|
|
282
297
|
normal: 1.0,
|
|
283
298
|
strong: 2.0,
|
|
299
|
+
// sh-ui:theme-border-width-end
|
|
284
300
|
);
|
|
285
301
|
}
|
|
286
302
|
|
|
@@ -297,6 +313,27 @@ class ShUiOpacityTokens {
|
|
|
297
313
|
);
|
|
298
314
|
}
|
|
299
315
|
|
|
316
|
+
@immutable
|
|
317
|
+
class ShUiGradientTokens {
|
|
318
|
+
final LinearGradient primary;
|
|
319
|
+
final LinearGradient surface;
|
|
320
|
+
final LinearGradient overlay;
|
|
321
|
+
|
|
322
|
+
const ShUiGradientTokens({
|
|
323
|
+
required this.primary,
|
|
324
|
+
required this.surface,
|
|
325
|
+
required this.overlay,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
static const tokens = ShUiGradientTokens(
|
|
329
|
+
// sh-ui:theme-gradient-start
|
|
330
|
+
primary: LinearGradient(begin: Alignment(-0.707, 0.707), end: Alignment(0.707, -0.707), colors: <Color>[Color(0xFF171717), Color(0xFF525252)], stops: <double>[0.00, 1.00]),
|
|
331
|
+
surface: LinearGradient(begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: <Color>[Color(0xFFFFFFFF), Color(0xFFF5F5F5)], stops: <double>[0.00, 1.00]),
|
|
332
|
+
overlay: LinearGradient(begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: <Color>[Color(0xFF000000), Color(0xFF1F1F1F)], stops: <double>[0.00, 1.00]),
|
|
333
|
+
// sh-ui:theme-gradient-end
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
300
337
|
@immutable
|
|
301
338
|
class ShUiBreakpointTokens {
|
|
302
339
|
final double sm;
|
|
@@ -330,6 +367,7 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
|
|
|
330
367
|
final ShUiEaseTokens ease;
|
|
331
368
|
final ShUiControlTokens control;
|
|
332
369
|
final ShUiBorderWidthTokens borderWidth;
|
|
370
|
+
final ShUiGradientTokens gradient;
|
|
333
371
|
final ShUiOpacityTokens opacity;
|
|
334
372
|
final ShUiBreakpointTokens breakpoint;
|
|
335
373
|
|
|
@@ -344,6 +382,7 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
|
|
|
344
382
|
required this.ease,
|
|
345
383
|
required this.control,
|
|
346
384
|
required this.borderWidth,
|
|
385
|
+
required this.gradient,
|
|
347
386
|
required this.opacity,
|
|
348
387
|
required this.breakpoint,
|
|
349
388
|
});
|
|
@@ -359,6 +398,7 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
|
|
|
359
398
|
ease: ShUiEaseTokens.tokens,
|
|
360
399
|
control: ShUiControlTokens.tokens,
|
|
361
400
|
borderWidth: ShUiBorderWidthTokens.tokens,
|
|
401
|
+
gradient: ShUiGradientTokens.tokens,
|
|
362
402
|
opacity: ShUiOpacityTokens.tokens,
|
|
363
403
|
breakpoint: ShUiBreakpointTokens.tokens,
|
|
364
404
|
);
|
|
@@ -373,13 +413,14 @@ class ShUiTheme extends ThemeExtension<ShUiTheme> {
|
|
|
373
413
|
ease: ShUiEaseTokens.tokens,
|
|
374
414
|
control: ShUiControlTokens.tokens,
|
|
375
415
|
borderWidth: ShUiBorderWidthTokens.tokens,
|
|
416
|
+
gradient: ShUiGradientTokens.tokens,
|
|
376
417
|
opacity: ShUiOpacityTokens.tokens,
|
|
377
418
|
breakpoint: ShUiBreakpointTokens.tokens,
|
|
378
419
|
);
|
|
379
420
|
|
|
380
421
|
@override
|
|
381
|
-
ShUiTheme copyWith({ShUiColorTokens? colors, ShUiRadiusTokens? radius, ShUiSpacingTokens? spacing, ShUiTextTokens? text, ShUiWeightTokens? weight, ShUiShadowTokens? shadow, ShUiDurationTokens? duration, ShUiEaseTokens? ease, ShUiControlTokens? control, ShUiBorderWidthTokens? borderWidth, ShUiOpacityTokens? opacity, ShUiBreakpointTokens? breakpoint}) =>
|
|
382
|
-
ShUiTheme(colors: colors ?? this.colors, radius: radius ?? this.radius, spacing: spacing ?? this.spacing, text: text ?? this.text, weight: weight ?? this.weight, shadow: shadow ?? this.shadow, duration: duration ?? this.duration, ease: ease ?? this.ease, control: control ?? this.control, borderWidth: borderWidth ?? this.borderWidth, opacity: opacity ?? this.opacity, breakpoint: breakpoint ?? this.breakpoint);
|
|
422
|
+
ShUiTheme copyWith({ShUiColorTokens? colors, ShUiRadiusTokens? radius, ShUiSpacingTokens? spacing, ShUiTextTokens? text, ShUiWeightTokens? weight, ShUiShadowTokens? shadow, ShUiDurationTokens? duration, ShUiEaseTokens? ease, ShUiControlTokens? control, ShUiBorderWidthTokens? borderWidth, ShUiGradientTokens? gradient, ShUiOpacityTokens? opacity, ShUiBreakpointTokens? breakpoint}) =>
|
|
423
|
+
ShUiTheme(colors: colors ?? this.colors, radius: radius ?? this.radius, spacing: spacing ?? this.spacing, text: text ?? this.text, weight: weight ?? this.weight, shadow: shadow ?? this.shadow, duration: duration ?? this.duration, ease: ease ?? this.ease, control: control ?? this.control, borderWidth: borderWidth ?? this.borderWidth, gradient: gradient ?? this.gradient, opacity: opacity ?? this.opacity, breakpoint: breakpoint ?? this.breakpoint);
|
|
383
424
|
|
|
384
425
|
@override
|
|
385
426
|
ShUiTheme lerp(ThemeExtension<ShUiTheme>? other, double t) {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
/* sh-ui:theme-radius-start */
|
|
43
43
|
--radius: 0.5rem;
|
|
44
44
|
/* sh-ui:theme-radius-end */
|
|
45
|
+
/* sh-ui:theme-space-start */
|
|
45
46
|
--space-0: 0px;
|
|
46
47
|
--space-1: 4px;
|
|
47
48
|
--space-2: 8px;
|
|
@@ -53,6 +54,8 @@
|
|
|
53
54
|
--space-10: 40px;
|
|
54
55
|
--space-12: 48px;
|
|
55
56
|
--space-16: 64px;
|
|
57
|
+
/* sh-ui:theme-space-end */
|
|
58
|
+
/* sh-ui:theme-text-start */
|
|
56
59
|
--text-xs: 12px;
|
|
57
60
|
--text-sm: 14px;
|
|
58
61
|
--text-base: 16px;
|
|
@@ -61,24 +64,42 @@
|
|
|
61
64
|
--text-2xl: 24px;
|
|
62
65
|
--text-3xl: 30px;
|
|
63
66
|
--text-4xl: 36px;
|
|
67
|
+
/* sh-ui:theme-text-end */
|
|
68
|
+
/* sh-ui:theme-weight-start */
|
|
64
69
|
--weight-regular: 400;
|
|
65
70
|
--weight-medium: 500;
|
|
66
71
|
--weight-semibold: 600;
|
|
67
72
|
--weight-bold: 700;
|
|
73
|
+
/* sh-ui:theme-weight-end */
|
|
74
|
+
/* sh-ui:theme-shadow-start */
|
|
68
75
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
69
76
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
70
77
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
71
78
|
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.18);
|
|
79
|
+
/* sh-ui:theme-shadow-end */
|
|
80
|
+
/* sh-ui:theme-duration-start */
|
|
72
81
|
--duration-fast: 120ms;
|
|
73
82
|
--duration-base: 160ms;
|
|
74
83
|
--duration-slow: 200ms;
|
|
84
|
+
/* sh-ui:theme-duration-end */
|
|
85
|
+
/* sh-ui:theme-ease-start */
|
|
75
86
|
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
76
87
|
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
|
|
88
|
+
/* sh-ui:theme-ease-end */
|
|
89
|
+
/* sh-ui:theme-control-start */
|
|
77
90
|
--control-sm: 32px;
|
|
78
91
|
--control-md: 40px;
|
|
79
92
|
--control-lg: 48px;
|
|
93
|
+
/* sh-ui:theme-control-end */
|
|
94
|
+
/* sh-ui:theme-border-width-start */
|
|
80
95
|
--border-width: 1px;
|
|
81
96
|
--border-width-strong: 2px;
|
|
97
|
+
/* sh-ui:theme-border-width-end */
|
|
98
|
+
/* sh-ui:theme-gradient-start */
|
|
99
|
+
--gradient-primary: linear-gradient(135deg, #171717 0%, #525252 100%);
|
|
100
|
+
--gradient-surface: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
|
|
101
|
+
--gradient-overlay: linear-gradient(180deg, #000000 0%, #1F1F1F 100%);
|
|
102
|
+
/* sh-ui:theme-gradient-end */
|
|
82
103
|
--opacity-disabled: 0.5;
|
|
83
104
|
--z-base: 0;
|
|
84
105
|
--z-sticky: 100;
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
/* sh-ui:theme-radius-start */
|
|
43
43
|
--radius: 0.5rem;
|
|
44
44
|
/* sh-ui:theme-radius-end */
|
|
45
|
+
/* sh-ui:theme-space-start */
|
|
45
46
|
--space-0: 0px;
|
|
46
47
|
--space-1: 4px;
|
|
47
48
|
--space-2: 8px;
|
|
@@ -53,6 +54,8 @@
|
|
|
53
54
|
--space-10: 40px;
|
|
54
55
|
--space-12: 48px;
|
|
55
56
|
--space-16: 64px;
|
|
57
|
+
/* sh-ui:theme-space-end */
|
|
58
|
+
/* sh-ui:theme-text-start */
|
|
56
59
|
--text-xs: 12px;
|
|
57
60
|
--text-sm: 14px;
|
|
58
61
|
--text-base: 16px;
|
|
@@ -61,24 +64,42 @@
|
|
|
61
64
|
--text-2xl: 24px;
|
|
62
65
|
--text-3xl: 30px;
|
|
63
66
|
--text-4xl: 36px;
|
|
67
|
+
/* sh-ui:theme-text-end */
|
|
68
|
+
/* sh-ui:theme-weight-start */
|
|
64
69
|
--weight-regular: 400;
|
|
65
70
|
--weight-medium: 500;
|
|
66
71
|
--weight-semibold: 600;
|
|
67
72
|
--weight-bold: 700;
|
|
73
|
+
/* sh-ui:theme-weight-end */
|
|
74
|
+
/* sh-ui:theme-shadow-start */
|
|
68
75
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
69
76
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
70
77
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
71
78
|
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.18);
|
|
79
|
+
/* sh-ui:theme-shadow-end */
|
|
80
|
+
/* sh-ui:theme-duration-start */
|
|
72
81
|
--duration-fast: 120ms;
|
|
73
82
|
--duration-base: 160ms;
|
|
74
83
|
--duration-slow: 200ms;
|
|
84
|
+
/* sh-ui:theme-duration-end */
|
|
85
|
+
/* sh-ui:theme-ease-start */
|
|
75
86
|
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
76
87
|
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
|
|
88
|
+
/* sh-ui:theme-ease-end */
|
|
89
|
+
/* sh-ui:theme-control-start */
|
|
77
90
|
--control-sm: 32px;
|
|
78
91
|
--control-md: 40px;
|
|
79
92
|
--control-lg: 48px;
|
|
93
|
+
/* sh-ui:theme-control-end */
|
|
94
|
+
/* sh-ui:theme-border-width-start */
|
|
80
95
|
--border-width: 1px;
|
|
81
96
|
--border-width-strong: 2px;
|
|
97
|
+
/* sh-ui:theme-border-width-end */
|
|
98
|
+
/* sh-ui:theme-gradient-start */
|
|
99
|
+
--gradient-primary: linear-gradient(135deg, #171717 0%, #525252 100%);
|
|
100
|
+
--gradient-surface: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
|
|
101
|
+
--gradient-overlay: linear-gradient(180deg, #000000 0%, #1F1F1F 100%);
|
|
102
|
+
/* sh-ui:theme-gradient-end */
|
|
82
103
|
--opacity-disabled: 0.5;
|
|
83
104
|
--z-base: 0;
|
|
84
105
|
--z-sticky: 100;
|