feat(design): kobalte ui toolkit with design guide
This commit is contained in:
parent
c78338fc11
commit
8da76464b0
36 changed files with 4015 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,6 +7,7 @@ node_modules/
|
||||||
|
|
||||||
# Distribution files
|
# Distribution files
|
||||||
dist/
|
dist/
|
||||||
|
client/storybook-static/
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
data/*.db
|
data/*.db
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,12 @@ pnpm run bench # 벤치마크 실행
|
||||||
|
|
||||||
## Detailed Docs
|
## Detailed Docs
|
||||||
|
|
||||||
- [docs/server.md](docs/server.md) — 서버 구조, 서비스, 모델, 의존성
|
클라이언트 중심
|
||||||
- [docs/client.md](docs/client.md) — 클라이언트 구조, 라우트, 컴포넌트
|
- [docs/client.md](docs/client.md) — 클라이언트 구조, 라우트, 컴포넌트
|
||||||
|
- [docs/frontend-design.md](docs/frontend-design.md) — 프론트엔드 디자인 가이드
|
||||||
|
|
||||||
|
서버 중심
|
||||||
|
- [docs/server.md](docs/server.md) — 서버 구조, 서비스, 모델, 의존성
|
||||||
- [docs/database.md](docs/database.md) — DB 테이블 스키마 전체
|
- [docs/database.md](docs/database.md) — DB 테이블 스키마 전체
|
||||||
- [docs/api.md](docs/api.md) — API 엔드포인트 레퍼런스
|
- [docs/api.md](docs/api.md) — API 엔드포인트 레퍼런스
|
||||||
- [docs/scripts.md](docs/scripts.md) — Script Engine 사용법, 타입, 예제
|
- [docs/scripts.md](docs/scripts.md) — Script Engine 사용법, 타입, 예제
|
||||||
|
|
|
||||||
16
client/.storybook/main.ts
Normal file
16
client/.storybook/main.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export default {
|
||||||
|
framework: {
|
||||||
|
name: 'storybook-solidjs-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-docs',
|
||||||
|
'@storybook/addon-a11y',
|
||||||
|
'@storybook/addon-vitest',
|
||||||
|
],
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
};
|
||||||
69
client/.storybook/preview.tsx
Normal file
69
client/.storybook/preview.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import '../src/ui/styles.css';
|
||||||
|
|
||||||
|
const withWorkbenchGlobals = (Story: () => unknown, context: { globals: { theme?: string; density?: string } }) => {
|
||||||
|
const theme = context.globals.theme ?? 'system';
|
||||||
|
const density = context.globals.density ?? 'dense';
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
document.documentElement.dataset.density = density;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sb-workbench">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decorators = [withWorkbenchGlobals];
|
||||||
|
|
||||||
|
export const globalTypes = {
|
||||||
|
theme: {
|
||||||
|
name: 'Theme',
|
||||||
|
defaultValue: 'system',
|
||||||
|
toolbar: {
|
||||||
|
icon: 'circlehollow',
|
||||||
|
items: [
|
||||||
|
{ value: 'system', title: 'System' },
|
||||||
|
{ value: 'light', title: 'Light' },
|
||||||
|
{ value: 'dark', title: 'Dark' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
density: {
|
||||||
|
name: 'Density',
|
||||||
|
defaultValue: 'dense',
|
||||||
|
toolbar: {
|
||||||
|
icon: 'sidebaralt',
|
||||||
|
items: [
|
||||||
|
{ value: 'dense', title: 'Dense' },
|
||||||
|
{ value: 'regular', title: 'Regular' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
controls: {
|
||||||
|
expanded: true,
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
disable: true,
|
||||||
|
},
|
||||||
|
a11y: {
|
||||||
|
test: 'todo',
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
viewports: {
|
||||||
|
narrowConsole: {
|
||||||
|
name: 'Narrow Console',
|
||||||
|
styles: { width: '960px', height: '900px' },
|
||||||
|
},
|
||||||
|
wideConsole: {
|
||||||
|
name: 'Wide Console',
|
||||||
|
styles: { width: '1440px', height: '900px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultViewport: 'wideConsole',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -7,20 +7,33 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "vite preview",
|
"start": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@kobalte/core": "^0.13.11",
|
||||||
"@solidjs/router": "^0.15.4",
|
"@solidjs/router": "^0.15.4",
|
||||||
"solid-js": "^1.9.11",
|
"solid-js": "^1.9.11",
|
||||||
"solid-monaco": "^0.3.0"
|
"solid-monaco": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@storybook/addon-a11y": "^10.3.3",
|
||||||
|
"@storybook/addon-docs": "^10.3.3",
|
||||||
|
"@storybook/addon-links": "^10.3.3",
|
||||||
|
"@storybook/addon-vitest": "^10.3.3",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
|
"@vitest/browser": "^4.1.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.1",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
|
"storybook": "^10.3.3",
|
||||||
|
"storybook-solidjs-vite": "^10.0.11",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-solid": "^2.11.10"
|
"vite-plugin-solid": "^2.11.10",
|
||||||
|
"vitest": "^4.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { render } from 'solid-js/web';
|
import { render } from 'solid-js/web';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import './ui/styles.css';
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
|
|
||||||
|
|
|
||||||
18
client/src/ui/index.ts
Normal file
18
client/src/ui/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export * from './tokens';
|
||||||
|
export * from './primitives/Alert';
|
||||||
|
export * from './primitives/Button';
|
||||||
|
export * from './primitives/Checkbox';
|
||||||
|
export * from './primitives/Dialog';
|
||||||
|
export * from './primitives/DropdownMenu';
|
||||||
|
export * from './primitives/Popover';
|
||||||
|
export * from './primitives/Select';
|
||||||
|
export * from './primitives/Switch';
|
||||||
|
export * from './primitives/Tabs';
|
||||||
|
export * from './primitives/TextField';
|
||||||
|
export * from './primitives/Toast';
|
||||||
|
export * from './primitives/Tooltip';
|
||||||
|
export * from './patterns/CommandBar';
|
||||||
|
export * from './patterns/DataGrid';
|
||||||
|
export * from './patterns/FieldRow';
|
||||||
|
export * from './patterns/LogConsole';
|
||||||
|
export * from './patterns/StatusBadge';
|
||||||
3
client/src/ui/lib/cn.ts
Normal file
3
client/src/ui/lib/cn.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function cn(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
14
client/src/ui/patterns/CommandBar.tsx
Normal file
14
client/src/ui/patterns/CommandBar.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { JSX, ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
export function CommandBar(props: ParentProps<{ class?: string }>) {
|
||||||
|
return <div class={cn('ui-command-bar', props.class)}>{props.children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandBarGroup(props: ParentProps<{ class?: string }>) {
|
||||||
|
return <div class={cn('ui-command-bar__group', props.class)}>{props.children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandBarHint(props: { children: JSX.Element; class?: string }) {
|
||||||
|
return <span class={cn('ui-command-bar__hint', props.class)}>{props.children}</span>;
|
||||||
|
}
|
||||||
205
client/src/ui/patterns/DataGrid.tsx
Normal file
205
client/src/ui/patterns/DataGrid.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { For, Match, Show, Switch } from 'solid-js';
|
||||||
|
import type { JSX } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
export type DataMode = 'paged' | 'infinite';
|
||||||
|
export type DataDensity = 'dense' | 'regular';
|
||||||
|
|
||||||
|
export interface DataGridColumn<T> {
|
||||||
|
id: string;
|
||||||
|
header: string;
|
||||||
|
class?: string;
|
||||||
|
width?: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
truncate?: boolean;
|
||||||
|
mono?: boolean;
|
||||||
|
cell: (row: T) => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridPagination {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridProps<T> {
|
||||||
|
rows: T[];
|
||||||
|
columns: DataGridColumn<T>[];
|
||||||
|
getRowKey: (row: T) => string | number;
|
||||||
|
mode?: DataMode;
|
||||||
|
density?: DataDensity;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
emptyMessage?: string;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
pagination?: DataGridPagination;
|
||||||
|
rowActions?: (row: T) => JSX.Element;
|
||||||
|
renderExpanded?: (row: T) => JSX.Element;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
selectedKeys?: Set<string | number>;
|
||||||
|
onToggleRowSelection?: (row: T, nextSelected: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGrid<T>(props: DataGridProps<T>) {
|
||||||
|
const pageCount = () => {
|
||||||
|
if (!props.pagination) return 1;
|
||||||
|
return Math.max(1, Math.ceil(props.pagination.total / props.pagination.pageSize));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={cn('ui-data-grid', props.density === 'regular' && 'ui-data-grid--regular')}>
|
||||||
|
<div class="ui-data-grid__shell">
|
||||||
|
<table class="ui-data-grid__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Show when={props.onToggleRowSelection}>
|
||||||
|
<th style={{ width: '36px' }}>Sel</th>
|
||||||
|
</Show>
|
||||||
|
<For each={props.columns}>
|
||||||
|
{(column) => (
|
||||||
|
<th
|
||||||
|
class={column.class}
|
||||||
|
style={{
|
||||||
|
width: column.width,
|
||||||
|
'text-align': column.align ?? 'left',
|
||||||
|
position: props.stickyHeader === false ? 'static' : 'sticky',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={props.rowActions}>
|
||||||
|
<th style={{ width: '1%' }}>Actions</th>
|
||||||
|
</Show>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.loading}>
|
||||||
|
<tr>
|
||||||
|
<td class="ui-data-grid__status" colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
|
||||||
|
Loading rows...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.error}>
|
||||||
|
<tr>
|
||||||
|
<td class="ui-data-grid__status" colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
|
||||||
|
{props.error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.rows.length === 0}>
|
||||||
|
<tr>
|
||||||
|
<td class="ui-data-grid__status" colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
|
||||||
|
{props.emptyMessage ?? 'No rows to display.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.rows.length > 0}>
|
||||||
|
<For each={props.rows}>
|
||||||
|
{(row) => {
|
||||||
|
const key = () => props.getRowKey(row);
|
||||||
|
const isSelected = () => props.selectedKeys?.has(key()) ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
class={cn('ui-data-grid__row', props.onRowClick && 'ui-data-grid__row--clickable')}
|
||||||
|
onClick={() => props.onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
<Show when={props.onToggleRowSelection}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onChange={(event) => props.onToggleRowSelection?.(row, event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
<For each={props.columns}>
|
||||||
|
{(column) => (
|
||||||
|
<td
|
||||||
|
class={cn(
|
||||||
|
column.class,
|
||||||
|
column.truncate && 'ui-data-grid__truncate',
|
||||||
|
column.mono && 'ui-data-grid__cell--mono',
|
||||||
|
)}
|
||||||
|
style={{ 'text-align': column.align ?? 'left' }}
|
||||||
|
title={column.truncate ? String(props.getRowKey(row)) : undefined}
|
||||||
|
>
|
||||||
|
{column.cell(row)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={props.rowActions}>
|
||||||
|
<td>{props.rowActions?.(row)}</td>
|
||||||
|
</Show>
|
||||||
|
</tr>
|
||||||
|
<Show when={props.renderExpanded}>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
|
||||||
|
{props.renderExpanded?.(row)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.pagination && props.mode !== 'infinite'}>
|
||||||
|
<div class="ui-pagination">
|
||||||
|
<div class="ui-pagination__cluster">
|
||||||
|
<span>
|
||||||
|
Page {props.pagination!.page} / {pageCount()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{props.pagination!.total} rows, {props.pagination!.pageSize} per page
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-pagination__cluster">
|
||||||
|
<Show when={props.pagination!.onPageSizeChange && props.pagination!.pageSizeOptions?.length}>
|
||||||
|
<label class="ui-cluster">
|
||||||
|
<span>Page size</span>
|
||||||
|
<select
|
||||||
|
class="ui-input"
|
||||||
|
value={String(props.pagination!.pageSize)}
|
||||||
|
onChange={(event) => props.pagination!.onPageSizeChange?.(Number(event.currentTarget.value))}
|
||||||
|
>
|
||||||
|
<For each={props.pagination!.pageSizeOptions}>
|
||||||
|
{(option) => <option value={option}>{option}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
class="ui-pagination__button"
|
||||||
|
disabled={props.pagination!.page <= 1}
|
||||||
|
onClick={() => props.pagination!.onPageChange(Math.max(1, props.pagination!.page - 1))}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ui-pagination__button"
|
||||||
|
disabled={props.pagination!.page >= pageCount()}
|
||||||
|
onClick={() => props.pagination!.onPageChange(Math.min(pageCount(), props.pagination!.page + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
client/src/ui/patterns/FieldRow.tsx
Normal file
6
client/src/ui/patterns/FieldRow.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
export function FieldRow(props: ParentProps<{ class?: string }>) {
|
||||||
|
return <div class={cn('ui-field-row', props.class)}>{props.children}</div>;
|
||||||
|
}
|
||||||
140
client/src/ui/patterns/LogConsole.tsx
Normal file
140
client/src/ui/patterns/LogConsole.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
|
||||||
|
import { Button } from '../primitives/Button';
|
||||||
|
import { StatusBadge, type StatusTone } from './StatusBadge';
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string | number;
|
||||||
|
timestamp?: string;
|
||||||
|
level?: LogLevel;
|
||||||
|
message: string;
|
||||||
|
context?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogConsoleProps {
|
||||||
|
entries: LogEntry[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
emptyMessage?: string;
|
||||||
|
follow?: boolean;
|
||||||
|
wrapLines?: boolean;
|
||||||
|
showTimestamp?: boolean;
|
||||||
|
showLevel?: boolean;
|
||||||
|
levelFilter?: LogLevel[];
|
||||||
|
onLevelFilterChange?: (levels: LogLevel[]) => void;
|
||||||
|
onCopyLine?: (entry: LogEntry) => void;
|
||||||
|
onCopyAll?: () => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelTones: Record<LogLevel, StatusTone> = {
|
||||||
|
debug: 'neutral',
|
||||||
|
info: 'info',
|
||||||
|
warn: 'warning',
|
||||||
|
error: 'danger',
|
||||||
|
success: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const allLevels: LogLevel[] = ['debug', 'info', 'warn', 'error', 'success'];
|
||||||
|
|
||||||
|
export function LogConsole(props: LogConsoleProps) {
|
||||||
|
let surfaceRef: HTMLDivElement | undefined;
|
||||||
|
const [internalLevels, setInternalLevels] = createSignal<LogLevel[]>(props.levelFilter ?? allLevels);
|
||||||
|
|
||||||
|
const activeLevels = createMemo(() => props.levelFilter ?? internalLevels());
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.follow && surfaceRef) {
|
||||||
|
surfaceRef.scrollTop = surfaceRef.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleEntries = createMemo(() => props.entries.filter((entry) => !entry.level || activeLevels().includes(entry.level)));
|
||||||
|
|
||||||
|
const copyText = async (text: string) => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLevel = (level: LogLevel) => {
|
||||||
|
const next = activeLevels().includes(level)
|
||||||
|
? activeLevels().filter((item) => item !== level)
|
||||||
|
: [...activeLevels(), level];
|
||||||
|
|
||||||
|
if (props.onLevelFilterChange) {
|
||||||
|
props.onLevelFilterChange(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInternalLevels(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-log-console">
|
||||||
|
<div class="ui-log-console__toolbar">
|
||||||
|
<div class="ui-cluster">
|
||||||
|
<For each={allLevels}>
|
||||||
|
{(level) => (
|
||||||
|
<button class="ui-pagination__button" onClick={() => toggleLevel(level)}>
|
||||||
|
<StatusBadge tone={levelTones[level]}>{level}</StatusBadge>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="ui-cluster">
|
||||||
|
<Show when={props.onClear}>
|
||||||
|
<Button onClick={props.onClear}>Clear</Button>
|
||||||
|
</Show>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
props.onCopyAll?.();
|
||||||
|
await copyText(visibleEntries().map((entry) => entry.message).join('\n'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-log-console__surface" ref={surfaceRef}>
|
||||||
|
<Show when={props.loading}>
|
||||||
|
<div>Loading logs...</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.loading && props.error}>
|
||||||
|
<div>{props.error}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.loading && !props.error && visibleEntries().length === 0}>
|
||||||
|
<div>{props.emptyMessage ?? 'No log entries.'}</div>
|
||||||
|
</Show>
|
||||||
|
<For each={visibleEntries()}>
|
||||||
|
{(entry, index) => (
|
||||||
|
<div class="ui-log-console__line" tabindex={0}>
|
||||||
|
<span class="ui-log-console__line-number">{String(index() + 1).padStart(3, '0')}</span>
|
||||||
|
<Show when={props.showTimestamp !== false}>
|
||||||
|
<span class="ui-log-console__timestamp">{entry.timestamp ?? '--:--:--'}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.showLevel !== false}>
|
||||||
|
<StatusBadge tone={entry.level ? levelTones[entry.level] : 'neutral'}>{entry.level ?? 'info'}</StatusBadge>
|
||||||
|
</Show>
|
||||||
|
<div class={props.wrapLines === false ? 'ui-log-console__message ui-log-console__message--nowrap' : 'ui-log-console__message'}>
|
||||||
|
<Show when={entry.context}>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>{entry.context} </span>
|
||||||
|
</Show>
|
||||||
|
{entry.message}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ui-log-console__copy ui-log-console__copy-button"
|
||||||
|
onClick={async () => {
|
||||||
|
props.onCopyLine?.(entry);
|
||||||
|
await copyText(entry.message);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
client/src/ui/patterns/StatusBadge.tsx
Normal file
13
client/src/ui/patterns/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
export type StatusTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
tone?: StatusTone;
|
||||||
|
children: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge(props: StatusBadgeProps) {
|
||||||
|
return <span class={cn('ui-badge', `ui-badge--${props.tone ?? 'neutral'}`, props.class)}>{props.children}</span>;
|
||||||
|
}
|
||||||
21
client/src/ui/primitives/Alert.tsx
Normal file
21
client/src/ui/primitives/Alert.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { JSX, ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type AlertTone = 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
interface AlertProps extends ParentProps {
|
||||||
|
title?: string;
|
||||||
|
tone?: AlertTone;
|
||||||
|
class?: string;
|
||||||
|
actions?: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Alert(props: AlertProps) {
|
||||||
|
return (
|
||||||
|
<div class={cn('ui-alert', `ui-alert--${props.tone ?? 'info'}`, props.class)} role="alert">
|
||||||
|
{props.title && <strong>{props.title}</strong>}
|
||||||
|
<div>{props.children}</div>
|
||||||
|
{props.actions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
client/src/ui/primitives/Button.tsx
Normal file
30
client/src/ui/primitives/Button.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { JSX, ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type ButtonVariant = 'neutral' | 'primary' | 'danger';
|
||||||
|
|
||||||
|
interface ButtonProps extends ParentProps {
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
class?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button(props: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={props.type ?? 'button'}
|
||||||
|
class={cn(
|
||||||
|
'ui-button',
|
||||||
|
props.variant === 'primary' && 'ui-button--primary',
|
||||||
|
props.variant === 'danger' && 'ui-button--danger',
|
||||||
|
props.class,
|
||||||
|
)}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
client/src/ui/primitives/Checkbox.tsx
Normal file
33
client/src/ui/primitives/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import * as KCheckbox from '@kobalte/core/checkbox';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
interface CheckboxProps {
|
||||||
|
checked?: boolean;
|
||||||
|
defaultChecked?: boolean;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox(props: CheckboxProps) {
|
||||||
|
return (
|
||||||
|
<KCheckbox.Root
|
||||||
|
class={cn('ui-checkbox', props.class)}
|
||||||
|
checked={props.checked}
|
||||||
|
defaultChecked={props.defaultChecked}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={props.onChange}
|
||||||
|
>
|
||||||
|
<KCheckbox.Input />
|
||||||
|
<KCheckbox.Control class="ui-checkbox__control">
|
||||||
|
<KCheckbox.Indicator class="ui-checkbox__indicator">✓</KCheckbox.Indicator>
|
||||||
|
</KCheckbox.Control>
|
||||||
|
<span>
|
||||||
|
<KCheckbox.Label>{props.label}</KCheckbox.Label>
|
||||||
|
{props.description && <KCheckbox.Description class="ui-field__description">{props.description}</KCheckbox.Description>}
|
||||||
|
</span>
|
||||||
|
</KCheckbox.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
client/src/ui/primitives/Dialog.tsx
Normal file
32
client/src/ui/primitives/Dialog.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import * as KDialog from '@kobalte/core/dialog';
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
||||||
|
|
||||||
|
export const Dialog = {
|
||||||
|
Root: (props: WrapperProps) => <KDialog.Root {...(props as KDialog.DialogRootProps)}>{props.children}</KDialog.Root>,
|
||||||
|
Trigger: (props: WrapperProps) => (
|
||||||
|
<KDialog.Trigger {...(props as KDialog.DialogTriggerProps)} class={cn('ui-button', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDialog.Trigger>
|
||||||
|
),
|
||||||
|
Portal: (props: WrapperProps) => <KDialog.Portal>{props.children}</KDialog.Portal>,
|
||||||
|
Overlay: (props: WrapperProps) => <KDialog.Overlay {...(props as KDialog.DialogOverlayProps)} class={cn('ui-dialog__overlay', props.class)} />,
|
||||||
|
Content: (props: WrapperProps) => (
|
||||||
|
<KDialog.Content {...(props as KDialog.DialogContentProps)} class={cn('ui-dialog__content', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDialog.Content>
|
||||||
|
),
|
||||||
|
Title: (props: WrapperProps) => <KDialog.Title {...(props as KDialog.DialogTitleProps)}>{props.children}</KDialog.Title>,
|
||||||
|
Description: (props: WrapperProps) => (
|
||||||
|
<KDialog.Description {...(props as KDialog.DialogDescriptionProps)} class={cn('ui-subtitle', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDialog.Description>
|
||||||
|
),
|
||||||
|
CloseButton: (props: WrapperProps) => (
|
||||||
|
<KDialog.CloseButton {...(props as KDialog.DialogCloseButtonProps)} class={cn('ui-button', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDialog.CloseButton>
|
||||||
|
),
|
||||||
|
};
|
||||||
28
client/src/ui/primitives/DropdownMenu.tsx
Normal file
28
client/src/ui/primitives/DropdownMenu.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import * as KDropdownMenu from '@kobalte/core/dropdown-menu';
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
||||||
|
|
||||||
|
export const DropdownMenu = {
|
||||||
|
Root: (props: WrapperProps) => <KDropdownMenu.Root {...(props as KDropdownMenu.DropdownMenuRootProps)}>{props.children}</KDropdownMenu.Root>,
|
||||||
|
Trigger: (props: WrapperProps) => (
|
||||||
|
<KDropdownMenu.Trigger {...(props as KDropdownMenu.DropdownMenuTriggerProps)} class={cn('ui-button', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDropdownMenu.Trigger>
|
||||||
|
),
|
||||||
|
Portal: (props: WrapperProps) => <KDropdownMenu.Portal>{props.children}</KDropdownMenu.Portal>,
|
||||||
|
Content: (props: WrapperProps) => (
|
||||||
|
<KDropdownMenu.Content {...(props as KDropdownMenu.DropdownMenuContentProps)} class={cn('ui-dropdown__content', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDropdownMenu.Content>
|
||||||
|
),
|
||||||
|
Item: (props: WrapperProps) => (
|
||||||
|
<KDropdownMenu.Item {...(props as KDropdownMenu.DropdownMenuItemProps)} class={cn('ui-dropdown__item', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KDropdownMenu.Item>
|
||||||
|
),
|
||||||
|
Separator: (props: WrapperProps) => (
|
||||||
|
<KDropdownMenu.Separator {...(props as KDropdownMenu.DropdownMenuSeparatorProps)} class={cn('ui-dropdown__separator', props.class)} />
|
||||||
|
),
|
||||||
|
};
|
||||||
31
client/src/ui/primitives/Popover.tsx
Normal file
31
client/src/ui/primitives/Popover.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import * as KPopover from '@kobalte/core/popover';
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
||||||
|
|
||||||
|
export const Popover = {
|
||||||
|
Root: (props: WrapperProps) => <KPopover.Root {...(props as KPopover.PopoverRootProps)}>{props.children}</KPopover.Root>,
|
||||||
|
Trigger: (props: WrapperProps) => (
|
||||||
|
<KPopover.Trigger {...(props as KPopover.PopoverTriggerProps)} class={cn('ui-button', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KPopover.Trigger>
|
||||||
|
),
|
||||||
|
Portal: (props: WrapperProps) => <KPopover.Portal>{props.children}</KPopover.Portal>,
|
||||||
|
Content: (props: WrapperProps) => (
|
||||||
|
<KPopover.Content {...(props as KPopover.PopoverContentProps)} class={cn('ui-popover__content', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KPopover.Content>
|
||||||
|
),
|
||||||
|
Title: (props: WrapperProps) => <KPopover.Title {...(props as KPopover.PopoverTitleProps)}>{props.children}</KPopover.Title>,
|
||||||
|
Description: (props: WrapperProps) => (
|
||||||
|
<KPopover.Description {...(props as KPopover.PopoverDescriptionProps)} class={cn('ui-subtitle', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KPopover.Description>
|
||||||
|
),
|
||||||
|
CloseButton: (props: WrapperProps) => (
|
||||||
|
<KPopover.CloseButton {...(props as KPopover.PopoverCloseButtonProps)} class={cn('ui-button', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KPopover.CloseButton>
|
||||||
|
),
|
||||||
|
};
|
||||||
55
client/src/ui/primitives/Select.tsx
Normal file
55
client/src/ui/primitives/Select.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import * as KSelect from '@kobalte/core/select';
|
||||||
|
import { Show, createMemo } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
class?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select(props: SelectProps) {
|
||||||
|
const selected = createMemo(() => props.options.find((option) => option.value === props.value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KSelect.Root<SelectOption>
|
||||||
|
class={cn('ui-select', props.class)}
|
||||||
|
options={props.options}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
value={selected()}
|
||||||
|
placeholder={props.placeholder ?? 'Select'}
|
||||||
|
onChange={(option) => props.onChange?.(option?.value ?? '')}
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<KSelect.Item item={itemProps.item} class="ui-select__item">
|
||||||
|
<KSelect.ItemLabel>{itemProps.item.rawValue.label}</KSelect.ItemLabel>
|
||||||
|
<KSelect.ItemIndicator>✓</KSelect.ItemIndicator>
|
||||||
|
</KSelect.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Show when={props.label}>
|
||||||
|
<KSelect.Label class="ui-field__label">{props.label}</KSelect.Label>
|
||||||
|
</Show>
|
||||||
|
<KSelect.Trigger class="ui-select__trigger">
|
||||||
|
<KSelect.Value<SelectOption> class="ui-select__value">
|
||||||
|
{(state) => state.selectedOption()?.label ?? props.placeholder ?? 'Select'}
|
||||||
|
</KSelect.Value>
|
||||||
|
<KSelect.Icon>▾</KSelect.Icon>
|
||||||
|
</KSelect.Trigger>
|
||||||
|
<KSelect.Portal>
|
||||||
|
<KSelect.Content class="ui-select__content">
|
||||||
|
<KSelect.Listbox />
|
||||||
|
</KSelect.Content>
|
||||||
|
</KSelect.Portal>
|
||||||
|
</KSelect.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
client/src/ui/primitives/Switch.tsx
Normal file
33
client/src/ui/primitives/Switch.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import * as KSwitch from '@kobalte/core/switch';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
interface SwitchProps {
|
||||||
|
checked?: boolean;
|
||||||
|
defaultChecked?: boolean;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Switch(props: SwitchProps) {
|
||||||
|
return (
|
||||||
|
<KSwitch.Root
|
||||||
|
class={cn('ui-switch', props.class)}
|
||||||
|
checked={props.checked}
|
||||||
|
defaultChecked={props.defaultChecked}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={props.onChange}
|
||||||
|
>
|
||||||
|
<KSwitch.Input />
|
||||||
|
<KSwitch.Control class="ui-switch__control">
|
||||||
|
<KSwitch.Thumb class="ui-switch__thumb" />
|
||||||
|
</KSwitch.Control>
|
||||||
|
<span>
|
||||||
|
<KSwitch.Label>{props.label}</KSwitch.Label>
|
||||||
|
{props.description && <KSwitch.Description class="ui-field__description">{props.description}</KSwitch.Description>}
|
||||||
|
</span>
|
||||||
|
</KSwitch.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
client/src/ui/primitives/Tabs.tsx
Normal file
20
client/src/ui/primitives/Tabs.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import * as KTabs from '@kobalte/core/tabs';
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
||||||
|
|
||||||
|
export const Tabs = {
|
||||||
|
Root: (props: WrapperProps) => <KTabs.Root {...(props as unknown as KTabs.TabsRootProps)} class={cn('ui-tabs', props.class)}>{props.children}</KTabs.Root>,
|
||||||
|
List: (props: WrapperProps) => <KTabs.List {...(props as unknown as KTabs.TabsListProps)} class={cn('ui-tabs__list', props.class)}>{props.children}</KTabs.List>,
|
||||||
|
Trigger: (props: WrapperProps) => (
|
||||||
|
<KTabs.Trigger {...(props as unknown as KTabs.TabsTriggerProps)} class={cn('ui-tabs__trigger', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KTabs.Trigger>
|
||||||
|
),
|
||||||
|
Content: (props: WrapperProps) => (
|
||||||
|
<KTabs.Content {...(props as unknown as KTabs.TabsContentProps)} class={cn('ui-tabs__content', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KTabs.Content>
|
||||||
|
),
|
||||||
|
};
|
||||||
44
client/src/ui/primitives/TextField.tsx
Normal file
44
client/src/ui/primitives/TextField.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import * as KTextField from '@kobalte/core/text-field';
|
||||||
|
import type { JSX, ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
interface TextFieldProps extends ParentProps {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
description?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
class?: string;
|
||||||
|
onInput?: JSX.EventHandlerUnion<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField(props: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<KTextField.Root class={cn('ui-field', props.class)} validationState={props.errorMessage ? 'invalid' : 'valid'}>
|
||||||
|
<KTextField.Label class="ui-field__label">{props.label}</KTextField.Label>
|
||||||
|
<div class="ui-field__control-row">
|
||||||
|
<div class="ui-field__control-fill">
|
||||||
|
{props.multiline ? (
|
||||||
|
<KTextField.TextArea
|
||||||
|
class="ui-textarea"
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
onInput={props.onInput as JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<KTextField.Input
|
||||||
|
class="ui-input"
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
onInput={props.onInput as JSX.EventHandlerUnion<HTMLInputElement, InputEvent>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
{props.description && <KTextField.Description class="ui-field__description">{props.description}</KTextField.Description>}
|
||||||
|
{props.errorMessage && <KTextField.ErrorMessage class="ui-field__error">{props.errorMessage}</KTextField.ErrorMessage>}
|
||||||
|
</KTextField.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
client/src/ui/primitives/Toast.tsx
Normal file
35
client/src/ui/primitives/Toast.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import * as KToast from '@kobalte/core/toast';
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
||||||
|
|
||||||
|
export const Toast = {
|
||||||
|
Region: (props: WrapperProps) => (
|
||||||
|
<KToast.Region {...(props as KToast.ToastRegionProps)} class={cn('ui-toast-region', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KToast.Region>
|
||||||
|
),
|
||||||
|
List: (props: WrapperProps) => (
|
||||||
|
<KToast.List {...(props as KToast.ToastListProps)} class={cn('ui-toast-list', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KToast.List>
|
||||||
|
),
|
||||||
|
Root: (props: WrapperProps) => (
|
||||||
|
<KToast.Root {...(props as unknown as KToast.ToastRootProps)} class={cn('ui-toast', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KToast.Root>
|
||||||
|
),
|
||||||
|
Title: (props: WrapperProps) => <KToast.Title {...(props as KToast.ToastTitleProps)}>{props.children}</KToast.Title>,
|
||||||
|
Description: (props: WrapperProps) => (
|
||||||
|
<KToast.Description {...(props as KToast.ToastDescriptionProps)} class={cn('ui-subtitle', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KToast.Description>
|
||||||
|
),
|
||||||
|
CloseButton: (props: WrapperProps) => (
|
||||||
|
<KToast.CloseButton {...(props as KToast.ToastCloseButtonProps)} class={cn('ui-button', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KToast.CloseButton>
|
||||||
|
),
|
||||||
|
toaster: KToast.toaster,
|
||||||
|
};
|
||||||
17
client/src/ui/primitives/Tooltip.tsx
Normal file
17
client/src/ui/primitives/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import * as KTooltip from '@kobalte/core/tooltip';
|
||||||
|
import type { ParentProps } from 'solid-js';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
|
||||||
|
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
||||||
|
|
||||||
|
export const Tooltip = {
|
||||||
|
Root: (props: WrapperProps) => <KTooltip.Root openDelay={150} {...(props as KTooltip.TooltipRootProps)}>{props.children}</KTooltip.Root>,
|
||||||
|
Trigger: (props: WrapperProps) => <KTooltip.Trigger {...(props as KTooltip.TooltipTriggerProps)} class={props.class}>{props.children}</KTooltip.Trigger>,
|
||||||
|
Portal: (props: WrapperProps) => <KTooltip.Portal>{props.children}</KTooltip.Portal>,
|
||||||
|
Content: (props: WrapperProps) => (
|
||||||
|
<KTooltip.Content {...(props as KTooltip.TooltipContentProps)} class={cn('ui-tooltip__content', props.class)}>
|
||||||
|
{props.children}
|
||||||
|
</KTooltip.Content>
|
||||||
|
),
|
||||||
|
Arrow: (props: WrapperProps) => <KTooltip.Arrow {...(props as KTooltip.TooltipArrowProps)} />,
|
||||||
|
};
|
||||||
141
client/src/ui/stories/data-grid.stories.tsx
Normal file
141
client/src/ui/stories/data-grid.stories.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
import { Button, CommandBar, CommandBarGroup, CommandBarHint, DataGrid, StatusBadge, type DataGridColumn } from '../index';
|
||||||
|
|
||||||
|
type GridRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
backend: string;
|
||||||
|
apiKey: string;
|
||||||
|
status: 'Active' | 'Inactive';
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: GridRow[] = Array.from({ length: 120 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name: `ops-user-${String(index + 1).padStart(3, '0')}`,
|
||||||
|
backend: index % 2 === 0 ? 'OpenAI Primary' : 'Anthropic Failover',
|
||||||
|
apiKey: `sk-router-${String(index + 1).padStart(3, '0')}-abcdefghijklmnopqrstuvwx`,
|
||||||
|
status: index % 3 === 0 ? 'Inactive' : 'Active',
|
||||||
|
updatedAt: `2026-03-${String((index % 28) + 1).padStart(2, '0')} 12:${String(index % 60).padStart(2, '0')}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columns: DataGridColumn<GridRow>[] = [
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
width: '72px',
|
||||||
|
mono: true,
|
||||||
|
cell: (row) => row.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: (row) => row.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'backend',
|
||||||
|
header: 'Backend',
|
||||||
|
cell: (row) => row.backend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
header: 'API Key',
|
||||||
|
truncate: true,
|
||||||
|
mono: true,
|
||||||
|
cell: (row) => row.apiKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: (row) => <StatusBadge tone={row.status === 'Active' ? 'success' : 'danger'}>{row.status}</StatusBadge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updatedAt',
|
||||||
|
header: 'Updated',
|
||||||
|
mono: true,
|
||||||
|
cell: (row) => row.updatedAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'UI/Patterns/DataGrid',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Paged = {
|
||||||
|
render: () => {
|
||||||
|
const [page, setPage] = createSignal(1);
|
||||||
|
const [pageSize, setPageSize] = createSignal(10);
|
||||||
|
const [selectedKeys, setSelectedKeys] = createSignal(new Set<string | number>([1, 3]));
|
||||||
|
|
||||||
|
const pagedRows = () => {
|
||||||
|
const start = (page() - 1) * pageSize();
|
||||||
|
return rows.slice(start, start + pageSize());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-workbench ui-stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="ui-title">DataGrid</h1>
|
||||||
|
<p class="ui-subtitle">Pagination-first dense table for users, backends, analytics, and scripts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommandBar>
|
||||||
|
<CommandBarGroup>
|
||||||
|
<Button variant="primary">Add User</Button>
|
||||||
|
<Button>Refresh</Button>
|
||||||
|
</CommandBarGroup>
|
||||||
|
<CommandBarGroup>
|
||||||
|
<CommandBarHint>
|
||||||
|
Search <span class="ui-kbd">/</span>
|
||||||
|
</CommandBarHint>
|
||||||
|
</CommandBarGroup>
|
||||||
|
</CommandBar>
|
||||||
|
|
||||||
|
<DataGrid
|
||||||
|
rows={pagedRows()}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
stickyHeader
|
||||||
|
selectedKeys={selectedKeys()}
|
||||||
|
onToggleRowSelection={(row, nextSelected) => {
|
||||||
|
const next = new Set(selectedKeys());
|
||||||
|
if (nextSelected) next.add(row.id);
|
||||||
|
else next.delete(row.id);
|
||||||
|
setSelectedKeys(next);
|
||||||
|
}}
|
||||||
|
rowActions={(row) => (
|
||||||
|
<div class="ui-cluster">
|
||||||
|
<Button>Edit</Button>
|
||||||
|
<Button variant="danger">Disable</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
pagination={{
|
||||||
|
page: page(),
|
||||||
|
pageSize: pageSize(),
|
||||||
|
total: rows.length,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onPageSizeChange: (nextSize) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(nextSize);
|
||||||
|
},
|
||||||
|
pageSizeOptions: [10, 20, 50],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const States = {
|
||||||
|
render: () => (
|
||||||
|
<div class="ui-workbench ui-stack">
|
||||||
|
<div class="ui-stack">
|
||||||
|
<h1 class="ui-title">Grid States</h1>
|
||||||
|
<DataGrid rows={[]} columns={columns} getRowKey={(row) => row.id} loading emptyMessage="No data." />
|
||||||
|
<DataGrid rows={[]} columns={columns} getRowKey={(row) => row.id} error="Failed to fetch rows from analytics database." />
|
||||||
|
<DataGrid rows={[]} columns={columns} getRowKey={(row) => row.id} emptyMessage="No matching rows for this filter set." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
97
client/src/ui/stories/log-console.stories.tsx
Normal file
97
client/src/ui/stories/log-console.stories.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
import { Button, LogConsole, type LogEntry } from '../index';
|
||||||
|
|
||||||
|
const entries: LogEntry[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
timestamp: '12:01:11',
|
||||||
|
level: 'info',
|
||||||
|
context: '[router]',
|
||||||
|
message: 'Accepted request for user #14 and routed to OpenAI Primary.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
timestamp: '12:01:12',
|
||||||
|
level: 'debug',
|
||||||
|
context: '[script:onRequest]',
|
||||||
|
message: 'Injected X-Custom-Header and normalized model alias.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
timestamp: '12:01:13',
|
||||||
|
level: 'warn',
|
||||||
|
context: '[analytics]',
|
||||||
|
message: 'Response time exceeded 1200ms threshold for backend #2.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
timestamp: '12:01:14',
|
||||||
|
level: 'error',
|
||||||
|
context: '[backend]',
|
||||||
|
message:
|
||||||
|
'POST /v1/chat/completions returned 502 from upstream. body={"error":{"message":"gateway timeout","type":"upstream_error"}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
timestamp: '12:01:15',
|
||||||
|
level: 'success',
|
||||||
|
context: '[script:test]',
|
||||||
|
message: 'Sample script test completed without runtime errors.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'UI/Patterns/LogConsole',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
render: () => {
|
||||||
|
const [follow, setFollow] = createSignal(true);
|
||||||
|
const [wrapLines, setWrapLines] = createSignal(true);
|
||||||
|
const [localEntries, setLocalEntries] = createSignal(entries);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-workbench ui-stack">
|
||||||
|
<div class="ui-cluster">
|
||||||
|
<Button onClick={() => setFollow((value) => !value)}>{follow() ? 'Follow: on' : 'Follow: off'}</Button>
|
||||||
|
<Button onClick={() => setWrapLines((value) => !value)}>{wrapLines() ? 'Wrap: on' : 'Wrap: off'}</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setLocalEntries((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: current.length + 1,
|
||||||
|
timestamp: '12:02:00',
|
||||||
|
level: 'info',
|
||||||
|
context: '[tail]',
|
||||||
|
message: `Appended log line ${current.length + 1} for follow-mode verification.`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Append line
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LogConsole
|
||||||
|
entries={localEntries()}
|
||||||
|
follow={follow()}
|
||||||
|
wrapLines={wrapLines()}
|
||||||
|
emptyMessage="No logs yet."
|
||||||
|
onClear={() => setLocalEntries([])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const States = {
|
||||||
|
render: () => (
|
||||||
|
<div class="ui-workbench ui-stack">
|
||||||
|
<LogConsole entries={[]} loading />
|
||||||
|
<LogConsole entries={[]} error="Failed to fetch script test logs." />
|
||||||
|
<LogConsole entries={[]} emptyMessage="No console output yet." />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
159
client/src/ui/stories/primitives.stories.tsx
Normal file
159
client/src/ui/stories/primitives.stories.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CommandBar,
|
||||||
|
CommandBarGroup,
|
||||||
|
CommandBarHint,
|
||||||
|
Dialog,
|
||||||
|
DropdownMenu,
|
||||||
|
FieldRow,
|
||||||
|
Popover,
|
||||||
|
Select,
|
||||||
|
StatusBadge,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
} from '../index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'UI/Primitives',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
render: () => {
|
||||||
|
const [dialogOpen, setDialogOpen] = createSignal(false);
|
||||||
|
const [selected, setSelected] = createSignal('analytics');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-workbench ui-stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="ui-title">Kobalte Wrapper Workbench</h1>
|
||||||
|
<p class="ui-subtitle">Compact primitives for the router admin console.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommandBar>
|
||||||
|
<CommandBarGroup>
|
||||||
|
<Button variant="primary">Create Backend</Button>
|
||||||
|
<Button>Refresh</Button>
|
||||||
|
<StatusBadge tone="success">Active</StatusBadge>
|
||||||
|
</CommandBarGroup>
|
||||||
|
<CommandBarGroup>
|
||||||
|
<CommandBarHint>
|
||||||
|
<span class="ui-kbd">Ctrl</span> + <span class="ui-kbd">N</span>
|
||||||
|
</CommandBarHint>
|
||||||
|
</CommandBarGroup>
|
||||||
|
</CommandBar>
|
||||||
|
|
||||||
|
<div class="ui-panel">
|
||||||
|
<div class="ui-panel__header">
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: '0 0 4px 0' }}>Controls</h2>
|
||||||
|
<p class="ui-subtitle">Dense inputs and Kobalte primitives with project styling.</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Item>Edit</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>Duplicate</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item>Delete</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
<div class="ui-panel__body ui-stack">
|
||||||
|
<TextField label="Backend Name" value="OpenAI Primary" description="Shown in routing and analytics views.">
|
||||||
|
<Button variant="primary">Save</Button>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<FieldRow>
|
||||||
|
<Select
|
||||||
|
label="Primary Route"
|
||||||
|
value={selected()}
|
||||||
|
onChange={setSelected}
|
||||||
|
options={[
|
||||||
|
{ value: 'analytics', label: 'Analytics' },
|
||||||
|
{ value: 'users', label: 'Users' },
|
||||||
|
{ value: 'scripts', label: 'Scripts' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger class="ui-button">Hover hint</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content>Long values should still stay readable in dense layouts.</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
<div class="ui-cluster">
|
||||||
|
<Checkbox label="Active only" defaultChecked />
|
||||||
|
<Switch label="Auto refresh" defaultChecked />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Root defaultValue="request">
|
||||||
|
<Tabs.List aria-label="Editor tabs">
|
||||||
|
<Tabs.Trigger value="request">Request</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="response">Response</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="test">Test</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="request">Request transform settings and headers.</Tabs.Content>
|
||||||
|
<Tabs.Content value="response">Response inspection and fallback rules.</Tabs.Content>
|
||||||
|
<Tabs.Content value="test">Console output, sample payloads, and validation feedback.</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
|
||||||
|
<div class="ui-cluster">
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger>Show Meta</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content>
|
||||||
|
<Popover.Title>Backend metadata</Popover.Title>
|
||||||
|
<Popover.Description>Compact metadata clusters live in popovers when space is tight.</Popover.Description>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>Open Dialog</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert tone="warning" title="Migration note">
|
||||||
|
Wrapper components should replace direct primitive usage before route-level refactors begin.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Dialog.Root open={dialogOpen()} onOpenChange={setDialogOpen}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<Dialog.Content>
|
||||||
|
<div class="ui-dialog__header">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title>Compact Dialog</Dialog.Title>
|
||||||
|
<Dialog.Description>Dense forms should still remain keyboard-friendly.</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-dialog__body ui-stack">
|
||||||
|
<TextField label="User Name" value="ops-admin" />
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
multiline
|
||||||
|
value="This dialog mirrors the future add/edit user workflow in a more compact form."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ui-dialog__footer">
|
||||||
|
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => setDialogOpen(false)}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
825
client/src/ui/styles.css
Normal file
825
client/src/ui/styles.css
Normal file
|
|
@ -0,0 +1,825 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--font-ui: "Pretendard", "Noto Sans KR", system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Consolas", monospace;
|
||||||
|
|
||||||
|
--text-1: 11px;
|
||||||
|
--text-2: 12px;
|
||||||
|
--text-3: 13px;
|
||||||
|
--text-4: 14px;
|
||||||
|
--text-5: 16px;
|
||||||
|
--text-6: 18px;
|
||||||
|
|
||||||
|
--space-1: 2px;
|
||||||
|
--space-2: 4px;
|
||||||
|
--space-3: 6px;
|
||||||
|
--space-4: 8px;
|
||||||
|
--space-5: 12px;
|
||||||
|
--space-6: 16px;
|
||||||
|
--space-7: 20px;
|
||||||
|
--space-8: 24px;
|
||||||
|
|
||||||
|
--radius-1: 2px;
|
||||||
|
--radius-2: 4px;
|
||||||
|
--radius-3: 6px;
|
||||||
|
|
||||||
|
--control-height-dense: 30px;
|
||||||
|
--control-height-regular: 36px;
|
||||||
|
--row-height-dense: 34px;
|
||||||
|
--row-height-regular: 40px;
|
||||||
|
|
||||||
|
--color-bg: #f3f4f6;
|
||||||
|
--color-bg-elevated: #ffffff;
|
||||||
|
--color-bg-panel: #fbfbfc;
|
||||||
|
--color-bg-inset: #e8eaee;
|
||||||
|
--color-bg-hover: #eef2f8;
|
||||||
|
|
||||||
|
--color-text: #10131a;
|
||||||
|
--color-text-muted: #5a6472;
|
||||||
|
--color-text-soft: #778092;
|
||||||
|
|
||||||
|
--color-border: #cad1da;
|
||||||
|
--color-border-strong: #98a2b3;
|
||||||
|
--color-border-danger: #cb6f6f;
|
||||||
|
|
||||||
|
--color-accent: #2357d8;
|
||||||
|
--color-accent-strong: #173ea4;
|
||||||
|
--color-accent-soft: #dbe7ff;
|
||||||
|
--color-focus: #2c6bff;
|
||||||
|
--color-success: #1f7a45;
|
||||||
|
--color-success-soft: #daf1e1;
|
||||||
|
--color-warning: #946200;
|
||||||
|
--color-warning-soft: #fff0c2;
|
||||||
|
--color-danger: #b42318;
|
||||||
|
--color-danger-soft: #ffdeda;
|
||||||
|
--color-info-soft: #deebff;
|
||||||
|
|
||||||
|
--shadow-panel: 0 1px 2px rgb(16 24 40 / 0.06);
|
||||||
|
--shadow-dialog: 0 12px 32px rgb(15 23 42 / 0.18);
|
||||||
|
--focus-ring: 0 0 0 2px var(--color-bg-elevated), 0 0 0 4px var(--color-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #111317;
|
||||||
|
--color-bg-elevated: #181b20;
|
||||||
|
--color-bg-panel: #1d2127;
|
||||||
|
--color-bg-inset: #0e1014;
|
||||||
|
--color-bg-hover: #242a33;
|
||||||
|
|
||||||
|
--color-text: #f2f4f8;
|
||||||
|
--color-text-muted: #b5bcc9;
|
||||||
|
--color-text-soft: #929cad;
|
||||||
|
|
||||||
|
--color-border: #353d48;
|
||||||
|
--color-border-strong: #5f6b7a;
|
||||||
|
--color-border-danger: #8c3b3b;
|
||||||
|
|
||||||
|
--color-accent: #79a6ff;
|
||||||
|
--color-accent-strong: #a9c5ff;
|
||||||
|
--color-accent-soft: #162848;
|
||||||
|
--color-focus: #8cb4ff;
|
||||||
|
--color-success: #72d39a;
|
||||||
|
--color-success-soft: #173323;
|
||||||
|
--color-warning: #e7c062;
|
||||||
|
--color-warning-soft: #3f3010;
|
||||||
|
--color-danger: #ff8a80;
|
||||||
|
--color-danger-soft: #431d1f;
|
||||||
|
--color-info-soft: #142840;
|
||||||
|
|
||||||
|
--shadow-panel: 0 1px 2px rgb(0 0 0 / 0.35);
|
||||||
|
--shadow-dialog: 0 18px 44px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-bg: #111317;
|
||||||
|
--color-bg-elevated: #181b20;
|
||||||
|
--color-bg-panel: #1d2127;
|
||||||
|
--color-bg-inset: #0e1014;
|
||||||
|
--color-bg-hover: #242a33;
|
||||||
|
--color-text: #f2f4f8;
|
||||||
|
--color-text-muted: #b5bcc9;
|
||||||
|
--color-text-soft: #929cad;
|
||||||
|
--color-border: #353d48;
|
||||||
|
--color-border-strong: #5f6b7a;
|
||||||
|
--color-border-danger: #8c3b3b;
|
||||||
|
--color-accent: #79a6ff;
|
||||||
|
--color-accent-strong: #a9c5ff;
|
||||||
|
--color-accent-soft: #162848;
|
||||||
|
--color-focus: #8cb4ff;
|
||||||
|
--color-success: #72d39a;
|
||||||
|
--color-success-soft: #173323;
|
||||||
|
--color-warning: #e7c062;
|
||||||
|
--color-warning-soft: #3f3010;
|
||||||
|
--color-danger: #ff8a80;
|
||||||
|
--color-danger-soft: #431d1f;
|
||||||
|
--color-info-soft: #142840;
|
||||||
|
--shadow-panel: 0 1px 2px rgb(0 0 0 / 0.35);
|
||||||
|
--shadow-dialog: 0 18px 44px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-density="regular"] {
|
||||||
|
--control-height-dense: var(--control-height-regular);
|
||||||
|
--row-height-dense: var(--row-height-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-3);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-workbench,
|
||||||
|
.ui-workbench {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--space-6);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 0.02), transparent 180px),
|
||||||
|
var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-cluster {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-panel {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-panel__body {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-6);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: var(--control-height-dense);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:focus-visible,
|
||||||
|
.ui-input:focus-visible,
|
||||||
|
.ui-textarea:focus-visible,
|
||||||
|
.ui-select__trigger:focus-visible,
|
||||||
|
.ui-tabs__trigger:focus-visible,
|
||||||
|
.ui-pagination__button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button--danger {
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
border-color: var(--color-border-danger);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field__control-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field__control-fill {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field__label {
|
||||||
|
font-size: var(--text-2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field__hint,
|
||||||
|
.ui-field__description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field__error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input,
|
||||||
|
.ui-textarea,
|
||||||
|
.ui-select__trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--control-height-dense);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-textarea {
|
||||||
|
min-height: 88px;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-command-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-command-bar__group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-command-bar__hint {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-1);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge--neutral {
|
||||||
|
background: var(--color-bg-inset);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge--success {
|
||||||
|
background: var(--color-success-soft);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge--warning {
|
||||||
|
background: var(--color-warning-soft);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge--danger {
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge--info {
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
font: 600 var(--text-1) / 1 var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs__list {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs__trigger[data-selected] {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs__content {
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__overlay,
|
||||||
|
.ui-popover__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(10 14 20 / 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__content,
|
||||||
|
.ui-popover__content,
|
||||||
|
.ui-dropdown__content,
|
||||||
|
.ui-tooltip__content,
|
||||||
|
.ui-select__content {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__content {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__header,
|
||||||
|
.ui-dialog__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__header {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__body {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dialog__footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tooltip__content,
|
||||||
|
.ui-dropdown__content,
|
||||||
|
.ui-popover__content,
|
||||||
|
.ui-select__content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown__item,
|
||||||
|
.ui-select__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown__item[data-highlighted],
|
||||||
|
.ui-select__item[data-highlighted] {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown__separator {
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-checkbox,
|
||||||
|
.ui-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-checkbox__control,
|
||||||
|
.ui-switch__control {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-checkbox__control {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-checkbox__indicator {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-switch__control {
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-switch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-text-soft);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-switch__control[data-checked] {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-switch__control[data-checked] .ui-switch__thumb {
|
||||||
|
transform: translateX(16px);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-select {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-select__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-select__value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert--info {
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert--success {
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert--warning {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert--danger {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-toast-region {
|
||||||
|
position: fixed;
|
||||||
|
right: var(--space-6);
|
||||||
|
bottom: var(--space-6);
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-toast-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-toast {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
box-shadow: var(--shadow-dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__shell {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__table th,
|
||||||
|
.ui-data-grid__table td {
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
height: var(--row-height-dense);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-1);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__row:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__row--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__cell--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__truncate {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-data-grid__status {
|
||||||
|
padding: var(--space-6);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pagination__cluster {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pagination__button {
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pagination__button[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__surface {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 420px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-inset);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-1);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__line {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1; /* 부모의 5열 전체를 차지 */
|
||||||
|
grid-template-columns: subgrid; /* 부모의 열 트랙을 그대로 상속 */
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__line:hover {
|
||||||
|
background: rgb(255 255 255 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__line-number,
|
||||||
|
.ui-log-console__timestamp {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__message {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__message--nowrap {
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__copy {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__copy-button {
|
||||||
|
min-width: auto;
|
||||||
|
min-height: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__copy-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-log-console__line:hover .ui-log-console__copy,
|
||||||
|
.ui-log-console__line:focus-within .ui-log-console__copy {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
5
client/src/ui/tokens.ts
Normal file
5
client/src/ui/tokens.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const densityOptions = ['dense', 'regular'] as const;
|
||||||
|
export type Density = (typeof densityOptions)[number];
|
||||||
|
|
||||||
|
export const themeOptions = ['system', 'light', 'dark'] as const;
|
||||||
|
export type ThemeMode = (typeof themeOptions)[number];
|
||||||
|
|
@ -18,6 +18,6 @@
|
||||||
"~/*": ["src/*"]
|
"~/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", ".storybook", "vitest.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
client/vitest.config.ts
Normal file
11
client/vitest.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import solidPlugin from 'vite-plugin-solid';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [solidPlugin()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
47
docs/client-ui-kit.md
Normal file
47
docs/client-ui-kit.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Client UI Kit Workbench
|
||||||
|
|
||||||
|
`client`의 새 UI kit은 실제 라우트와 분리된 공통 레이어로 구성한다. 목적은 기존 인라인 스타일을 바로 교체하는 것이 아니라, 먼저 Storybook에서 조밀한 운영 콘솔 스타일을 검토하고 안정화한 뒤 앱으로 이식하는 것이다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `client/src/ui/styles.css`
|
||||||
|
- 전역 semantic token, density, light/dark theme, 공통 primitive class
|
||||||
|
- `client/src/ui/primitives`
|
||||||
|
- `@kobalte/core` wrapper와 공통 스타일 계약
|
||||||
|
- `client/src/ui/patterns`
|
||||||
|
- `DataGrid`, `LogConsole`, `CommandBar`, `FieldRow`, `StatusBadge`
|
||||||
|
- `client/src/ui/stories`
|
||||||
|
- primitive와 pattern 검토용 Storybook stories
|
||||||
|
- `client/.storybook`
|
||||||
|
- Storybook workbench 설정
|
||||||
|
|
||||||
|
## 1차 시범 적용 대상
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
- `DataGrid`
|
||||||
|
- compact dialog
|
||||||
|
- `CommandBar`
|
||||||
|
- `StatusBadge`
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
- `Tabs`
|
||||||
|
- `Select`
|
||||||
|
- dialog
|
||||||
|
- `LogConsole`
|
||||||
|
- dense metadata cluster
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter client storybook
|
||||||
|
pnpm --filter client build-storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
## 원칙
|
||||||
|
|
||||||
|
- 앱 코드에서 `@kobalte/core` primitive를 직접 사용하지 않는다.
|
||||||
|
- 새 스타일은 Storybook에서 먼저 검토하고, 이후 라우트에 연결한다.
|
||||||
|
- 대용량 표는 pagination 우선을 기본으로 한다.
|
||||||
|
- 로그 콘솔은 1차에서 읽기 중심으로 유지하고, streaming append는 후속 확장으로 둔다.
|
||||||
709
docs/frontend-design.md
Normal file
709
docs/frontend-design.md
Normal file
|
|
@ -0,0 +1,709 @@
|
||||||
|
# Frontend Design Guide
|
||||||
|
|
||||||
|
Kyush LLM Router client의 새 UI 기준 문서. 이 문서는 기존 인라인 스타일 기반 화면을 점진적으로 `@kobalte/core` 중심의 조밀한 운영 콘솔 UI로 전환하기 위한 공통 기준이다.
|
||||||
|
|
||||||
|
## 1. 목표와 디자인 원칙
|
||||||
|
|
||||||
|
### Compact Operational Console
|
||||||
|
|
||||||
|
이 프로젝트의 어드민 UI는 "넓고 여유 있는 마케팅 화면"이 아니라 "운영자가 빠르게 읽고 조작하는 콘솔"을 목표로 한다.
|
||||||
|
|
||||||
|
- 작은 화면에서도 주요 지표와 조작 수단이 한 번에 보여야 한다.
|
||||||
|
- 카드와 패널은 시각적으로 분리되되, 공간을 많이 차지하면 안 된다.
|
||||||
|
- 중요 정보는 크기와 대비로 구분하고, 덜 중요한 정보는 작은 텍스트와 밀도 높은 보조 레이블로 정리한다.
|
||||||
|
- 좁은 화면, 긴 ID, 긴 URL, API 키, 긴 스크립트 이름처럼 overflow가 자주 발생하는 데이터를 기본 시나리오로 간주한다.
|
||||||
|
- 사용자가 예상하는 관리자 UI 행동을 지원해야 한다.
|
||||||
|
- 탭 전환
|
||||||
|
- 키보드 포커스 이동
|
||||||
|
- 단축키 힌트 노출
|
||||||
|
- destructive action 확인
|
||||||
|
- 로딩/빈 상태/오류 상태 표현
|
||||||
|
|
||||||
|
### 미학적 방향
|
||||||
|
|
||||||
|
루트의 `frontend-design.md`를 참고하되, 본 프로젝트는 과한 장식 대신 다음 성격을 유지한다.
|
||||||
|
|
||||||
|
- 2000년대 운영 도구에 가까운 정보 밀도
|
||||||
|
- 얇은 border, 작은 radius, 촘촘한 행 높이
|
||||||
|
- 대형 hero 대신 요약 스트립, 표, 패널, 탭, 유틸리티 바 중심 구성
|
||||||
|
- 장식은 배경 질감보다 타이포그래피와 정렬 정확도로 해결
|
||||||
|
|
||||||
|
## 2. 토큰 시스템
|
||||||
|
|
||||||
|
모든 신규 화면은 하드코딩된 색상/여백/반경 대신 semantic token을 사용한다.
|
||||||
|
|
||||||
|
### 2.1 색상 토큰
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--color-bg: #f3f4f6;
|
||||||
|
--color-bg-elevated: #ffffff;
|
||||||
|
--color-bg-panel: #fbfbfc;
|
||||||
|
--color-bg-inset: #e8eaee;
|
||||||
|
|
||||||
|
--color-text: #111318;
|
||||||
|
--color-text-muted: #5b6472;
|
||||||
|
--color-text-soft: #737b88;
|
||||||
|
|
||||||
|
--color-border: #c8cdd6;
|
||||||
|
--color-border-strong: #98a2b3;
|
||||||
|
--color-border-danger: #d56b6b;
|
||||||
|
|
||||||
|
--color-accent: #2357d8;
|
||||||
|
--color-accent-strong: #173ea4;
|
||||||
|
--color-accent-soft: #dbe7ff;
|
||||||
|
--color-focus: #2c6bff;
|
||||||
|
|
||||||
|
--color-success: #1f7a45;
|
||||||
|
--color-success-soft: #d8f2df;
|
||||||
|
--color-warning: #946200;
|
||||||
|
--color-warning-soft: #fff0c2;
|
||||||
|
--color-danger: #b42318;
|
||||||
|
--color-danger-soft: #fddfdb;
|
||||||
|
|
||||||
|
--color-overlay: rgb(10 14 20 / 0.52);
|
||||||
|
--shadow-panel: 0 1px 2px rgb(16 24 40 / 0.06);
|
||||||
|
--shadow-dialog: 0 12px 32px rgb(15 23 42 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #111317;
|
||||||
|
--color-bg-elevated: #181b20;
|
||||||
|
--color-bg-panel: #1d2127;
|
||||||
|
--color-bg-inset: #0d0f13;
|
||||||
|
|
||||||
|
--color-text: #f2f4f8;
|
||||||
|
--color-text-muted: #b4bcc9;
|
||||||
|
--color-text-soft: #9099a8;
|
||||||
|
|
||||||
|
--color-border: #343b46;
|
||||||
|
--color-border-strong: #5f6b7a;
|
||||||
|
--color-border-danger: #8c3b3b;
|
||||||
|
|
||||||
|
--color-accent: #79a6ff;
|
||||||
|
--color-accent-strong: #a9c5ff;
|
||||||
|
--color-accent-soft: #162848;
|
||||||
|
--color-focus: #8cb4ff;
|
||||||
|
|
||||||
|
--color-success: #72d39a;
|
||||||
|
--color-success-soft: #173323;
|
||||||
|
--color-warning: #e7c062;
|
||||||
|
--color-warning-soft: #3f3010;
|
||||||
|
--color-danger: #ff8a80;
|
||||||
|
--color-danger-soft: #431d1f;
|
||||||
|
|
||||||
|
--color-overlay: rgb(0 0 0 / 0.64);
|
||||||
|
--shadow-panel: 0 1px 2px rgb(0 0 0 / 0.35);
|
||||||
|
--shadow-dialog: 0 18px 44px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- `--color-bg`는 페이지 바탕, `--color-bg-elevated`는 카드/모달, `--color-bg-panel`은 표/사이드 패널, `--color-bg-inset`은 코드/보조 영역에 사용한다.
|
||||||
|
- 상태 색은 항상 soft 배경과 짝으로 사용한다.
|
||||||
|
- 포커스는 테마와 무관하게 항상 충분히 눈에 띄어야 한다.
|
||||||
|
- 이후 수동 테마 전환을 추가할 수 있도록 `[data-theme="light"]`, `[data-theme="dark"]` 오버라이드를 같은 이름의 토큰으로 덮어쓸 수 있게 설계한다.
|
||||||
|
|
||||||
|
### 2.2 간격 토큰
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--space-1: 2px;
|
||||||
|
--space-2: 4px;
|
||||||
|
--space-3: 6px;
|
||||||
|
--space-4: 8px;
|
||||||
|
--space-5: 12px;
|
||||||
|
--space-6: 16px;
|
||||||
|
--space-7: 20px;
|
||||||
|
--space-8: 24px;
|
||||||
|
--space-9: 32px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 기본 조밀 레이아웃은 `4px`, `6px`, `8px`, `12px`를 중심으로 조합한다.
|
||||||
|
- 대형 카드 패딩처럼 넓은 여백을 기본값으로 두지 않는다.
|
||||||
|
- 리스트 행, 폼 필드, 버튼 사이 간격은 먼저 `--space-3` 또는 `--space-4`를 검토한다.
|
||||||
|
|
||||||
|
### 2.3 반경, 선, 그림자
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--radius-1: 2px;
|
||||||
|
--radius-2: 4px;
|
||||||
|
--radius-3: 6px;
|
||||||
|
|
||||||
|
--line-1: 1px;
|
||||||
|
--line-2: 1.5px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 기본 radius는 `2px` 또는 `4px`.
|
||||||
|
- 데이터 표, 툴바, 패널은 가능한 한 `2px` 또는 `4px`을 유지한다.
|
||||||
|
- 큰 모달도 `6px`을 넘기지 않는다.
|
||||||
|
- 구분은 그림자보다 border를 우선 사용한다.
|
||||||
|
|
||||||
|
### 2.4 타이포그래피
|
||||||
|
|
||||||
|
작은 크기에서도 시인성이 높은 조합을 사용한다. 기본 방향:
|
||||||
|
|
||||||
|
- UI 본문/레이블: `Pretendard`, `Noto Sans KR`, `system-ui`, `sans-serif`
|
||||||
|
- 숫자/ID/URL/토큰: `JetBrains Mono`, `Consolas`, `monospace`
|
||||||
|
- 작은 보조 정보 전용: 기본 본문과 동일 계열이되 자간과 대비를 조정해 촘촘하게 배치
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--font-ui: "Pretendard", "Noto Sans KR", system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Consolas", monospace;
|
||||||
|
--font-micro: "Pretendard", "Noto Sans KR", system-ui, sans-serif;
|
||||||
|
|
||||||
|
--text-1: 11px;
|
||||||
|
--text-2: 12px;
|
||||||
|
--text-3: 13px;
|
||||||
|
--text-4: 14px;
|
||||||
|
--text-5: 16px;
|
||||||
|
--text-6: 18px;
|
||||||
|
--text-7: 22px;
|
||||||
|
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-ui: 1.35;
|
||||||
|
--leading-copy: 1.5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 기본 UI 텍스트는 `13px` 또는 `14px`.
|
||||||
|
- 테이블/메타데이터/상태 텍스트는 `11px` 또는 `12px`.
|
||||||
|
- 페이지 제목만 `18px` 이상을 허용한다.
|
||||||
|
- 부가 정보는 작은 텍스트로 배치하되, 대비가 부족하면 안 된다.
|
||||||
|
- 숫자 정렬, API 키 prefix, URL, script type 등은 monospace 또는 tabular 숫자를 우선 고려한다.
|
||||||
|
|
||||||
|
### 2.5 포커스와 레이어
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--focus-ring: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-focus);
|
||||||
|
--z-header: 20;
|
||||||
|
--z-sticky: 30;
|
||||||
|
--z-popover: 40;
|
||||||
|
--z-dialog: 50;
|
||||||
|
--z-toast: 60;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 포커스는 hover보다 우선한다.
|
||||||
|
- sticky header, dropdown, dialog가 겹칠 때 레이어 순서를 문서화된 값으로만 관리한다.
|
||||||
|
|
||||||
|
## 3. 레이아웃 기준
|
||||||
|
|
||||||
|
### 3.1 페이지 셸
|
||||||
|
|
||||||
|
- 좌측 내비게이션은 "크고 넓은 앱 셸"이 아니라 조밀한 도구 패널이어야 한다.
|
||||||
|
- 본문은 최대 너비 고정형이 아니라 데이터 양에 따라 유연하게 확장한다.
|
||||||
|
- 기본 레이아웃은 다음 3층 구조를 사용한다.
|
||||||
|
- 글로벌 셸: 내비게이션, 전체 배경, 테마/세션 정보
|
||||||
|
- 페이지 헤더: 제목, 설명, 주요 액션, 보조 단축키 힌트
|
||||||
|
- 본문 영역: 요약 스트립 + 툴바 + 표/패널
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="nav-rail">...</aside>
|
||||||
|
<div class="workspace">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Scripts</h1>
|
||||||
|
<p>요청/응답 스크립트 정책을 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button>새 스크립트</button>
|
||||||
|
<kbd>Ctrl</kbd><kbd>N</kbd>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="summary-strip">...</section>
|
||||||
|
<section class="content-panel">...</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-rail {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 요약 스트립
|
||||||
|
|
||||||
|
- Dashboard, Analytics의 상단 수치 카드는 큰 박스 3개보다 "짧은 수평 스트립"을 우선한다.
|
||||||
|
- 각 셀은 제목, 핵심 수치, 작은 보조 수치 1개까지 허용한다.
|
||||||
|
- 카드형 UI가 필요해도 패딩을 크게 주지 않는다.
|
||||||
|
|
||||||
|
### 3.3 분할 패널
|
||||||
|
|
||||||
|
Scripts 화면처럼 목록과 편집기가 함께 필요한 경우 split panel을 기본 패턴으로 삼는다.
|
||||||
|
|
||||||
|
- 좌측: 목록/필터/타깃 메타
|
||||||
|
- 우측: 편집기/세부설정/테스트 결과
|
||||||
|
- 좁은 화면에서는 세로 스택으로 전환한다.
|
||||||
|
|
||||||
|
## 4. 컴포넌트 기준 (`@kobalte/core`)
|
||||||
|
|
||||||
|
`@kobalte/core`는 headless primitive를 제공하므로, 스타일은 이 문서의 토큰과 패턴에 맞춰 직접 입힌다.
|
||||||
|
|
||||||
|
### 4.1 Tabs
|
||||||
|
|
||||||
|
용도:
|
||||||
|
|
||||||
|
- Dashboard/Analytics의 다중 데이터 묶음
|
||||||
|
- Settings 성격의 세부 항목 전환
|
||||||
|
- Scripts 편집기의 코드/대상/테스트 결과 전환
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 탭 헤더 높이는 낮게 유지한다.
|
||||||
|
- 활성 탭은 색상+하단선 또는 inset 배경으로 표시한다.
|
||||||
|
- 탭 라벨 오른쪽에 작은 count/badge를 둘 수 있다.
|
||||||
|
- 탭은 반드시 키보드 화살표 이동을 지원해야 한다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as Tabs from "@kobalte/core/tabs";
|
||||||
|
|
||||||
|
<Tabs.Root class="tabs" defaultValue="request">
|
||||||
|
<Tabs.List class="tabs__list" aria-label="Script sections">
|
||||||
|
<Tabs.Trigger class="tabs__trigger" value="request">Request</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger class="tabs__trigger" value="response">Response</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger class="tabs__trigger" value="test">Test</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content class="tabs__panel" value="request">...</Tabs.Content>
|
||||||
|
<Tabs.Content class="tabs__panel" value="response">...</Tabs.Content>
|
||||||
|
<Tabs.Content class="tabs__panel" value="test">...</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Dialog
|
||||||
|
|
||||||
|
용도:
|
||||||
|
|
||||||
|
- Add/Edit User
|
||||||
|
- Add/Edit Backend
|
||||||
|
- Grant Permission
|
||||||
|
- Script test result 요약
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 단순 입력 모달은 `480px` 내외.
|
||||||
|
- 복합 편집 모달은 `720px` 또는 `min(920px, 92vw)`.
|
||||||
|
- 헤더, 본문, 푸터를 구분하고 푸터 액션은 오른쪽 정렬.
|
||||||
|
- 모달 내부에서도 overflow를 통제한다.
|
||||||
|
- 포커스 트랩, ESC 닫기, 초기 포커스 대상 지정이 필요하다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as Dialog from "@kobalte/core/dialog";
|
||||||
|
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger class="button-primary">Add User</Dialog.Trigger>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
|
<Dialog.Content class="dialog-content">
|
||||||
|
<Dialog.Title>User 생성</Dialog.Title>
|
||||||
|
<Dialog.Description>필수 필드만 먼저 입력합니다.</Dialog.Description>
|
||||||
|
<form class="dense-form">...</form>
|
||||||
|
<div class="dialog-footer">...</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Dropdown Menu / Popover / Tooltip
|
||||||
|
|
||||||
|
- `DropdownMenu`: 행 단위 액션을 압축할 때 사용
|
||||||
|
- `Popover`: 행 인라인 세부 정보나 추가 메타 노출
|
||||||
|
- `Tooltip`: 잘린 텍스트, 단축키 설명, 위험 액션 보조 설명
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 기본 액션이 2개 이하일 때는 버튼을 직접 노출한다.
|
||||||
|
- 액션이 3개 이상이면 overflow menu를 검토한다.
|
||||||
|
- tooltip은 필수 정보의 유일한 전달 수단이 되면 안 된다.
|
||||||
|
- 잘린 API 키, URL, 스크립트 대상명은 hover/focus 시 tooltip 또는 popover로 전체 값을 볼 수 있어야 한다.
|
||||||
|
|
||||||
|
### 4.4 Select / Checkbox / Switch
|
||||||
|
|
||||||
|
- 기존 native `select`는 단계적으로 `@kobalte/core` 기반 커스텀 select로 전환한다.
|
||||||
|
- 다만 밀도와 접근성을 해치지 않도록 input 높이를 낮게 유지한다.
|
||||||
|
- `Checkbox`는 행 높이를 키우지 않는 compact label과 함께 사용한다.
|
||||||
|
- `Switch`는 즉시 반영되는 on/off 토글에만 사용하고, 저장이 필요한 폼 값에는 checkbox를 우선한다.
|
||||||
|
|
||||||
|
### 4.5 Toast / Alert
|
||||||
|
|
||||||
|
- 성공 알림은 짧게 자동 사라짐.
|
||||||
|
- 실패 알림은 원인과 재시도 방향을 포함한 alert 또는 inline error로 보여준다.
|
||||||
|
- destructive action 이후에는 toast만 띄우고 끝내지 말고, 화면 상태도 즉시 갱신해야 한다.
|
||||||
|
|
||||||
|
## 5. 데이터 표시 패턴
|
||||||
|
|
||||||
|
### 5.1 Dense Table
|
||||||
|
|
||||||
|
Users, Backends, Permissions, Scripts, Dashboard Recent Requests, Analytics 표에 공통 적용한다.
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 기본 행 높이는 작게 유지하되 클릭/포커스 가능한 최소 영역은 확보한다.
|
||||||
|
- 헤더는 sticky 가능하게 설계한다.
|
||||||
|
- 테이블 바깥 래퍼가 horizontal scroll을 담당한다.
|
||||||
|
- 긴 셀은 아래 규칙 중 하나를 반드시 선택한다.
|
||||||
|
- 한 줄 truncate + tooltip
|
||||||
|
- 줄바꿈 허용
|
||||||
|
- 열 최소폭 보장 후 스크롤
|
||||||
|
- 상태 열, 숫자 열, 액션 열은 가능한 좁게 유지한다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div class="table-shell">
|
||||||
|
<table class="dense-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="col-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-mono">12</td>
|
||||||
|
<td>ops-admin</td>
|
||||||
|
<td class="cell-truncate" title="sk-router-very-long-value">
|
||||||
|
sk-router-very-long-value
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge--success">Active</span></td>
|
||||||
|
<td class="cell-actions">...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.table-shell {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table th,
|
||||||
|
.dense-table td {
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-truncate {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 상태 배지
|
||||||
|
|
||||||
|
- 상태 배지는 채도보다 대비를 우선한다.
|
||||||
|
- 높이는 낮고 텍스트는 짧아야 한다.
|
||||||
|
- `Active`, `Inactive`, `Error`, `Draft`, `Assigned` 같은 한 단어 중심으로 유지한다.
|
||||||
|
|
||||||
|
### 5.3 메타데이터 클러스터
|
||||||
|
|
||||||
|
주요 정보 주변의 작은 텍스트 묶음은 다음 형태를 기본으로 한다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div class="meta-cluster">
|
||||||
|
<span class="meta-key">Model</span>
|
||||||
|
<span class="meta-value">gpt-4.1-mini</span>
|
||||||
|
<span class="meta-key">Backend</span>
|
||||||
|
<span class="meta-value">OpenAI Main</span>
|
||||||
|
<span class="meta-key">Updated</span>
|
||||||
|
<span class="meta-value">5m ago</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 중요한 제목 아래 또는 표 셀 내부에 배치한다.
|
||||||
|
- 작은 텍스트지만 키/값 구분은 분명해야 한다.
|
||||||
|
- 텍스트 크기를 줄이더라도 줄 간격과 대비는 유지한다.
|
||||||
|
|
||||||
|
### 5.4 명령/필터 바
|
||||||
|
|
||||||
|
표 위에는 큰 hero 대신 compact command bar를 둔다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div class="command-bar">
|
||||||
|
<div class="command-bar__group">
|
||||||
|
<input placeholder="Search users" />
|
||||||
|
<button>Active only</button>
|
||||||
|
<button>Backend: All</button>
|
||||||
|
</div>
|
||||||
|
<div class="command-bar__group">
|
||||||
|
<span class="hint">/ 검색</span>
|
||||||
|
<button class="button-primary">Add User</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 왼쪽은 필터, 오른쪽은 주요 액션과 단축키 힌트.
|
||||||
|
- 필터는 1행 유지가 어렵다면 wrap을 허용하되, 버튼 간격은 계속 조밀해야 한다.
|
||||||
|
|
||||||
|
## 6. 폼과 편집 패턴
|
||||||
|
|
||||||
|
### 6.1 Dense Form
|
||||||
|
|
||||||
|
- 라벨과 필드는 세로 배치하되 간격을 작게 유지한다.
|
||||||
|
- 필수 표시, 설명, 검증 메시지는 한 시야 안에 모인다.
|
||||||
|
- 긴 텍스트/URL/API Key 필드는 monospace 적용을 검토한다.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.dense-form {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea,
|
||||||
|
.field button,
|
||||||
|
.field [role="combobox"] {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Overflow-safe Field Layout
|
||||||
|
|
||||||
|
Backend URL, API Key, Script target 조합처럼 값이 긴 경우는 다음 규칙을 적용한다.
|
||||||
|
|
||||||
|
- 단일 행 필드가 너무 길면 전체 width를 먹지 않도록 `minmax(0, 1fr)`를 사용한다.
|
||||||
|
- prefix/suffix 버튼이 붙은 입력은 grid로 나눈다.
|
||||||
|
- 편집 전에는 truncate, 편집 상태에서는 full value를 보여준다.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Script Editor 영역
|
||||||
|
|
||||||
|
Scripts는 일반 CRUD보다 복합도가 높으므로 별도 규칙을 둔다.
|
||||||
|
|
||||||
|
- 상단: script 이름, 타입, 대상 선택
|
||||||
|
- 중단: 코드/설명/테스트 탭
|
||||||
|
- 하단: 활성 상태, 테스트 결과, 저장 액션
|
||||||
|
- 편집기 주변에는 작은 메타 정보와 단축키 힌트를 같이 둔다.
|
||||||
|
- Monaco 같은 코드 편집기는 주변 패널 밀도와 맞추기 위해 외곽 패딩을 최소화한다.
|
||||||
|
|
||||||
|
## 7. 상호작용 기준
|
||||||
|
|
||||||
|
### 7.1 키보드와 단축키
|
||||||
|
|
||||||
|
- 모든 주요 화면은 최소 1개 이상의 빠른 조작 단축키를 문서화할 수 있어야 한다.
|
||||||
|
- 단축키 표시는 버튼 오른쪽, command bar, 또는 panel header 보조영역에 둔다.
|
||||||
|
- 예시:
|
||||||
|
- `/`: 검색 포커스
|
||||||
|
- `Ctrl+N`: 새 항목 생성
|
||||||
|
- `Ctrl+S`: 스크립트 저장
|
||||||
|
- `Esc`: 모달 닫기
|
||||||
|
|
||||||
|
`<kbd>` 스타일 예시:
|
||||||
|
|
||||||
|
```css
|
||||||
|
kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
font: 500 var(--text-1) / 1 var(--font-mono);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 상태 전이
|
||||||
|
|
||||||
|
- hover는 보조 신호, active/focus는 주 신호로 취급한다.
|
||||||
|
- destructive 버튼은 빨간색을 남용하지 말고 실제 위험 액션에만 사용한다.
|
||||||
|
- 성공/실패 상태는 색과 문구를 함께 제공한다.
|
||||||
|
|
||||||
|
### 7.3 로딩 / 빈 상태 / 오류 상태
|
||||||
|
|
||||||
|
- 로딩: spinner만 두지 말고 영역 skeleton 또는 `Loading users...` 같은 맥락 문구를 제공한다.
|
||||||
|
- 빈 상태: "없음"만 보여주지 말고 다음 행동을 안내한다.
|
||||||
|
- 오류 상태: 실패 이유와 재시도 액션을 함께 제공한다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<section class="panel-state">
|
||||||
|
<h3>등록된 backend가 없습니다</h3>
|
||||||
|
<p>라우팅을 시작하려면 첫 backend를 추가하세요.</p>
|
||||||
|
<button class="button-primary">Add Backend</button>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 확인 대화상자
|
||||||
|
|
||||||
|
- 삭제, 권한 회수, API 키 재생성은 확인 대화상자를 거친다.
|
||||||
|
- 대화상자는 대상 이름과 영향을 명시한다.
|
||||||
|
- 가능하면 브라우저 기본 `confirm()` 대신 `@kobalte/core` dialog 기반으로 통일한다.
|
||||||
|
|
||||||
|
## 8. 화면별 매핑 기준
|
||||||
|
|
||||||
|
이 문서는 실제 현재 라우트와 연결되어야 한다.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- 상단 대형 카드 대신 summary strip
|
||||||
|
- Recent Requests는 dense table + sticky header
|
||||||
|
- 새로고침은 우측 상단 action cluster
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
- 검색 + 상태 필터 + Add User command bar
|
||||||
|
- API Key 셀은 truncate + copy action + tooltip
|
||||||
|
- Edit/Add는 compact dialog
|
||||||
|
|
||||||
|
### Backends
|
||||||
|
|
||||||
|
- Base URL은 monospace + overflow 처리
|
||||||
|
- Active 상태는 badge
|
||||||
|
- Add/Edit dialog는 URL 입력과 API Key 입력을 overflow-safe form으로 구성
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
- user/backend 조합은 dense table
|
||||||
|
- Add Permission은 compact dialog 또는 inline panel
|
||||||
|
- revoke는 명시적 destructive action
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
|
||||||
|
- 여러 표를 나란히 둘 때 card보다 panel grid
|
||||||
|
- 요청/사용량/백엔드 메트릭은 탭 또는 split panel 후보
|
||||||
|
- 숫자는 정렬과 단위 표기를 일관되게 유지
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
- 이 화면이 새 디자인 시스템의 기준 화면이 된다.
|
||||||
|
- 목록, 타입 badge, 대상 메타, 테스트 결과, 편집기 탭 패턴을 모두 포함한다.
|
||||||
|
- 긴 코드 편집 영역과 조밀한 설정 패널의 공존을 우선 해결한다.
|
||||||
|
|
||||||
|
## 9. 구현 가이드라인
|
||||||
|
|
||||||
|
### 해야 하는 것
|
||||||
|
|
||||||
|
- semantic token을 먼저 만들고 화면에 적용한다.
|
||||||
|
- 인라인 스타일을 점진적으로 공통 class와 토큰 기반 스타일로 치환한다.
|
||||||
|
- `@kobalte/core` primitive로 상호작용 로직을 통일한다.
|
||||||
|
- 모바일보다는 "좁은 데스크톱 폭" 대응을 우선 설계하되, 최소한의 반응형 스택 전환을 제공한다.
|
||||||
|
- overflow 정책을 컴포넌트마다 명시한다.
|
||||||
|
|
||||||
|
### 피해야 하는 것
|
||||||
|
|
||||||
|
- 큰 radius와 과한 drop shadow
|
||||||
|
- 여백으로만 계층을 표현하는 느슨한 레이아웃
|
||||||
|
- 보라색 중심의 전형적인 AI 스타일
|
||||||
|
- 툴팁에만 의존하는 핵심 정보
|
||||||
|
- 페이지별로 제각각인 색상/버튼/테이블 스타일
|
||||||
|
|
||||||
|
## 10. 최소 수용 기준
|
||||||
|
|
||||||
|
새로 만드는 모든 client 화면 또는 리팩터링되는 기존 화면은 다음을 만족해야 한다.
|
||||||
|
|
||||||
|
- light/dark 모두에서 읽기 가능하다.
|
||||||
|
- 좁은 폭에서 긴 텍스트가 레이아웃을 깨지 않는다.
|
||||||
|
- 키보드 포커스와 탭 이동이 보인다.
|
||||||
|
- 주요 액션과 destructive 액션이 시각적으로 구분된다.
|
||||||
|
- 표, 모달, 필터 바, 상태 배지 중 해당 화면에 필요한 공통 패턴을 재사용한다.
|
||||||
|
- 페이지 로직과 무관한 스타일 상수는 semantic token으로 대체한다.
|
||||||
|
|
||||||
|
## 11. 첫 적용 우선순위
|
||||||
|
|
||||||
|
가이드 문서 자체는 구현 순서를 강제하지 않지만, 실제 적용 시에는 다음 순서가 가장 안전하다.
|
||||||
|
|
||||||
|
1. 토큰과 전역 테마 레이어
|
||||||
|
2. 레이아웃 셸과 command bar
|
||||||
|
3. dense table / badge / button / input 공통 스타일
|
||||||
|
4. `@kobalte/core` dialog, tabs, tooltip, select
|
||||||
|
5. Scripts 화면과 Users 화면 시범 전환
|
||||||
|
|
||||||
|
이 순서는 이후 마이그레이션 작업의 기본 기준으로 사용한다.
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"start": "pnpm --parallel start",
|
"start": "pnpm --parallel start",
|
||||||
"test": "pnpm -r --filter server test",
|
"test": "pnpm -r --filter server test",
|
||||||
"bench": "pnpm -r --filter server run bench"
|
"bench": "pnpm -r --filter server run bench",
|
||||||
|
"storybook": "pnpm -r --filter client storybook"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"llm",
|
"llm",
|
||||||
|
|
|
||||||
1135
pnpm-lock.yaml
generated
1135
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue