Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import axios from 'axios'
import { ApiProblem, type ProblemDetails } from './problem'
export const api = axios.create({ baseURL: '/api', timeout: 15_000 })
api.interceptors.request.use(config => {
const user = import.meta.env.VITE_DEV_AUTH_USER as string | undefined
const role = import.meta.env.VITE_DEV_AUTH_ROLE as string | undefined
if (import.meta.env.DEV && user && role) {
config.headers['X-KArtSell-User'] = user
config.headers['X-KArtSell-Role'] = role
}
return config
})
api.interceptors.response.use(
response => response,
error => {
const data = error.response?.data as ProblemDetails | undefined
if (data?.status) throw new ApiProblem(data)
throw error
}
)
+16
View File
@@ -0,0 +1,16 @@
export interface ProblemDetails {
type?: string
title: string
status: number
detail?: string
traceId?: string
errors?: Record<string, string[]>
}
export class ApiProblem extends Error {
constructor(public readonly problem: ProblemDetails) {
super(problem.title)
}
get status(): number { return this.problem.status }
}
@@ -0,0 +1,11 @@
<script setup lang="ts">
const props = defineProps<{
allowed: boolean
deniedMessage?: string
}>()
</script>
<template>
<slot v-if="props.allowed" />
<p v-else role="alert">{{ props.deniedMessage ?? '이 기능을 사용할 권한이 없습니다.' }}</p>
</template>
@@ -0,0 +1,11 @@
export interface IdempotentCommand<T> {
readonly idempotencyKey: string
readonly request: T
}
/**
* Create once per user intent. Retries must reuse the returned envelope rather than call this again.
*/
export function createIdempotentCommand<T>(request: T): IdempotentCommand<T> {
return Object.freeze({ idempotencyKey: crypto.randomUUID(), request })
}
@@ -0,0 +1,12 @@
import { z } from 'zod'
export const versionSetSchema = z.object({
datasetId: z.string().min(1).max(128),
dataHash: z.string().min(1).max(128),
modelVersion: z.string().min(1).max(128),
configVersion: z.string().min(1).max(128),
codeSha: z.string().min(1).max(128),
contractVersion: z.string().min(1).max(64)
})
export type VersionSet = z.infer<typeof versionSetSchema>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { StandardScreenState } from '../ui/contracts/screenContract'
import PageLayout from '../ui/layouts/PageLayout.vue'
import FormPageLayout from '../ui/layouts/FormPageLayout.vue'
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
withDefaults(defineProps<{ title: string; subtitle?: string; state?: StandardScreenState; dirty?: boolean; readonly?: boolean; asOf?: string; version?: string }>(), { state: 'READY', dirty: false, readonly: false })
const emit = defineEmits<{ retry: []; submit: []; cancel: [] }>()
</script>
<template>
<PageLayout :title="title" :subtitle="subtitle" :status="readonly ? 'READONLY' : state" :as-of="asOf" :version="version">
<StandardScreenBoundary :state="readonly ? 'READONLY' : (dirty ? 'DIRTY' : state)" :stale-at="asOf" @retry="emit('retry')">
<FormPageLayout @submit="emit('submit')">
<slot />
<template v-if="$slots.aside" #preview><slot name="aside" /></template>
</FormPageLayout>
</StandardScreenBoundary>
<template #footer><slot name="actions" /></template>
</PageLayout>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { UiGridColumn } from '../ui/adapter/contracts'
import type { StandardScreenState } from '../ui/contracts/screenContract'
import PageLayout from '../ui/layouts/PageLayout.vue'
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
import KsDataGrid from '../ui/components/KsDataGrid.vue'
import KsPaginator from '../ui/components/KsPaginator.vue'
withDefaults(defineProps<{
title: string
subtitle?: string
state?: StandardScreenState
rows: unknown[]
columns: UiGridColumn[]
page: number
pageSize: number
total: number
asOf?: string
version?: string
warning?: string
}>(), { state: 'READY', rows: () => [] })
const emit = defineEmits<{ retry: []; rowSelected: [row: unknown]; pageChange: [value: { page: number; pageSize: number }] }>()
</script>
<template>
<PageLayout :title="title" :subtitle="subtitle" :status="state" :as-of="asOf" :version="version">
<template #actions><slot name="actions" /></template>
<template #summary><slot name="summary" /></template>
<template #filters><slot name="filters" /></template>
<StandardScreenBoundary :state="state" :warning="warning" :stale-at="asOf" @retry="emit('retry')">
<KsDataGrid :rows="rows" :columns="columns" @row-selected="emit('rowSelected', $event)" />
<KsPaginator :page="page" :page-size="pageSize" :total="total" @page-change="emit('pageChange', $event)" />
</StandardScreenBoundary>
<template v-if="$slots.detail" #aside><slot name="detail" /></template>
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
</PageLayout>
</template>
+36
View File
@@ -0,0 +1,36 @@
import type { ZodType } from 'zod'
import type { UiGridColumn, UiGridFilter, UiGridSort } from '../ui/adapter/contracts'
export type CrudPermission = 'read' | 'create' | 'update' | 'delete' | 'export' | 'review' | 'publish'
export interface CrudListQuery {
page: number
pageSize: number
search?: string
sorts: UiGridSort[]
filters: UiGridFilter[]
}
export interface CrudPageResult<T> {
items: T[]
total: number
asOf: string
projectionVersion: string
watermark?: string
stale: boolean
}
export interface CrudMutationContext {
idempotencyKey: string
ifMatch?: string
reason?: string
correlationId?: string
}
export interface CrudResourceContract<TItem, TForm> {
resourceCode: string
routeBase: string
queryKey: readonly string[]
columns: UiGridColumn[]
formSchema: ZodType<TForm>
permissions: Partial<Record<CrudPermission, string>>
defaultQuery: CrudListQuery
parseListResponse(payload: unknown): CrudPageResult<TItem>
parseDetailResponse(payload: unknown): TItem
}
+36
View File
@@ -0,0 +1,36 @@
import type { CrudListQuery } from './contracts'
const positiveInt = (value: string | null, fallback: number): number => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
const sorts = params.getAll('sort').flatMap(value => {
const [field, direction] = value.split(':')
return field && (direction === 'asc' || direction === 'desc') ? [{ field, direction }] : []
})
const filters = params.getAll('filter').flatMap(value => {
const first = value.indexOf(':')
const second = value.indexOf(':', first + 1)
if (first <= 0 || second <= first) return []
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
})
return {
page: positiveInt(params.get('page'), fallback.page),
pageSize: positiveInt(params.get('pageSize'), fallback.pageSize),
search: params.get('search')?.trim() || undefined,
sorts: sorts.length ? sorts : fallback.sorts,
filters: filters.length ? filters : fallback.filters
}
}
export function encodeCrudQuery(query: CrudListQuery): URLSearchParams {
const params = new URLSearchParams()
params.set('page', String(query.page))
params.set('pageSize', String(query.pageSize))
if (query.search) params.set('search', query.search)
for (const sort of query.sorts) params.append('sort', `${sort.field}:${sort.direction}`)
for (const filter of query.filters) params.append('filter', `${filter.field}:${filter.operator}:${String(filter.value ?? '')}`)
return params
}
@@ -0,0 +1,22 @@
import type { ZodType } from 'zod'
import type { UiGridColumn } from '../ui/adapter/contracts'
export type CrudConcurrencyMode = 'NONE' | 'ETAG_IF_MATCH'
export type CrudIdempotencyMode = 'NONE' | 'IDEMPOTENCY_KEY'
export interface CrudResourceDefinition<TQuery, TRow, TForm> {
resourceId: string
querySchemaVersion: string
responseSchemaVersion: string
querySchema: ZodType<TQuery>
rowSchema: ZodType<TRow>
formSchema: ZodType<TForm>
columns: readonly UiGridColumn[]
sensitiveFields: readonly string[]
permissionPolicy: string
concurrencyMode: CrudConcurrencyMode
idempotencyMode: CrudIdempotencyMode
}
export function assertCrudResourceDefinition(definition: CrudResourceDefinition<unknown, unknown, unknown>): void {
if (!definition.resourceId.trim()) throw new Error('resourceId is required')
const fields = new Set(definition.columns.map(x => x.field))
for (const sensitive of definition.sensitiveFields) if (!fields.has(sensitive)) throw new Error(`Sensitive field '${sensitive}' has no grid column contract`)
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { decodeCrudQuery, encodeCrudQuery } from '../queryCodec'
const fallback = { page: 1, pageSize: 20, sorts: [], filters: [] }
describe('CRUD URL codec', () => {
it('round-trips paging, sort, filter and search without hidden Pinia state', () => {
const source = { page: 3, pageSize: 50, search: '005930', sorts: [{ field: 'asOf', direction: 'desc' as const }], filters: [{ field: 'status', operator: 'eq', value: 'WARN' }] }
expect(decodeCrudQuery(encodeCrudQuery(source), fallback)).toEqual(source)
})
it('fails closed to approved defaults for invalid paging', () => {
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
})
})
@@ -0,0 +1,24 @@
import { computed, ref } from 'vue'
import type { CrudListQuery } from './contracts'
export function useCrudListState(initial: CrudListQuery) {
const query = ref<CrudListQuery>({ ...initial, sorts: [...initial.sorts], filters: [...initial.filters] })
const selectedId = ref<string | number | null>(null)
const dirty = ref(false)
function replace(next: CrudListQuery): void { query.value = { ...next, sorts: [...next.sorts], filters: [...next.filters] } }
function setPage(page: number, pageSize = query.value.pageSize): void { query.value = { ...query.value, page, pageSize } }
function setSearch(search?: string): void { query.value = { ...query.value, page: 1, search: search?.trim() || undefined } }
function reset(): void { replace(initial); selectedId.value = null; dirty.value = false }
return {
query,
selectedId,
dirty,
offset: computed(() => (query.value.page - 1) * query.value.pageSize),
replace,
setPage,
setSearch,
reset
}
}
@@ -0,0 +1,21 @@
import { ref } from 'vue'
import { createIdempotencyKey } from '../commands/idempotency'
export interface OptimisticCommandRequest<T> { payload: T; etag?: string }
export interface OptimisticCommandResponse<TResult> { data: TResult; etag?: string; correlationId?: string }
export function useOptimisticCommand<TPayload, TResult>(execute: (request: OptimisticCommandRequest<TPayload>, headers: Record<string,string>) => Promise<OptimisticCommandResponse<TResult>>) {
const pending = ref(false); const conflict = ref(false); const lastCorrelationId = ref<string>()
async function run(request: OptimisticCommandRequest<TPayload>): Promise<OptimisticCommandResponse<TResult>> {
if (pending.value) throw new Error('Command is already in progress')
pending.value=true; conflict.value=false
try {
const headers: Record<string,string> = { 'Idempotency-Key': createIdempotencyKey() }
if (request.etag) headers['If-Match']=request.etag
const response=await execute(request,headers); lastCorrelationId.value=response.correlationId; return response
} catch (error: unknown) {
const status=(error as { response?: { status?: number } })?.response?.status
if (status===409 || status===412) conflict.value=true
throw error
} finally { pending.value=false }
}
return { pending, conflict, lastCorrelationId, run }
}
@@ -0,0 +1,18 @@
export function formatCurrency(value: number | null | undefined, currency: string, locale = 'ko-KR'): string {
if (value == null || Number.isNaN(value)) return '—'
return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(value)
}
export function formatPercent(value: number | null | undefined, digits = 2, locale = 'ko-KR'): string {
if (value == null || Number.isNaN(value)) return '—'
return new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value)
}
export function formatQuantity(value: number | null | undefined, digits = 4, locale = 'ko-KR'): string {
if (value == null || Number.isNaN(value)) return '—'
return new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value)
}
export function formatAsOf(value: string | Date | null | undefined, locale = 'ko-KR'): string {
if (!value) return '—'
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul' }).format(date)
}
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
import { formatCurrency, formatPercent, formatQuantity } from '../financial'
describe('financial formatters', () => {
it('renders missing values as an explicit em dash', () => { expect(formatCurrency(null, 'KRW')).toBe('—'); expect(formatPercent(undefined)).toBe('—') })
it('keeps percentage inputs in decimal-return units', () => { expect(formatPercent(0.125, 1)).toContain('12.5') })
it('uses bounded quantity precision', () => { expect(formatQuantity(1.234567, 2)).toContain('1.23') })
})
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
asOf: string
staleAfterMinutes: number
}>()
const ageMinutes = computed(() => Math.max(0, (Date.now() - new Date(props.asOf).getTime()) / 60_000))
const stale = computed(() => ageMinutes.value > props.staleAfterMinutes)
</script>
<template>
<span :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'">
{{ stale ? 'STALE' : 'FRESH' }} · {{ new Date(props.asOf).toLocaleString() }}
</span>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { UiGridColumn } from './adapter/contracts'
import { KsDataGrid } from './components'
withDefaults(defineProps<{
rows: unknown[]
columns: UiGridColumn[]
loading?: boolean
emptyMessage?: string
}>(), { loading: false, emptyMessage: '표시할 데이터가 없습니다.' })
</script>
<template>
<section aria-label="데이터 " :aria-busy="loading">
<p v-if="loading">데이터를 불러오는 중입니다.</p>
<p v-else-if="rows.length === 0">{{ emptyMessage }}</p>
<KsDataGrid v-else :rows="rows" :columns="columns" height="30rem" />
</section>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { VersionSet } from '../contracts/versionSet'
const props = defineProps<{
value: VersionSet
compact?: boolean
}>()
</script>
<template>
<dl class="version-set" :data-compact="props.compact ? 'true' : 'false'">
<dt>Dataset</dt><dd>{{ props.value.datasetId }}</dd>
<dt>Data hash</dt><dd><code>{{ props.value.dataHash }}</code></dd>
<dt>Model</dt><dd>{{ props.value.modelVersion }}</dd>
<dt>Config</dt><dd>{{ props.value.configVersion }}</dd>
<dt>Code</dt><dd><code>{{ props.value.codeSha }}</code></dd>
<dt>Contract</dt><dd>{{ props.value.contractVersion }}</dd>
</dl>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import KsButton from './components/KsButton.vue'
import KsInlineMessage from './components/KsInlineMessage.vue'
const props = defineProps<{
loading: boolean
processing?: boolean
dirty?: boolean
error?: Error | null
empty?: boolean
partial?: boolean
staleAt?: string | null
warning?: string | null
unauthorized?: boolean
forbidden?: boolean
conflict?: boolean
expired?: boolean
readonly?: boolean
correlationId?: string
}>()
const emit = defineEmits<{ retry: [] }>()
</script>
<template>
<section :aria-busy="props.loading || props.processing">
<KsInlineMessage v-if="props.loading" severity="info" message="불러오는 중입니다." />
<KsInlineMessage v-else-if="props.unauthorized" severity="warning" title="로그인 필요" message="로그인 후 다시 시도하세요." />
<KsInlineMessage v-else-if="props.forbidden" severity="danger" title="권한 없음" message="이 작업을 수행할 권한이 없습니다." />
<KsInlineMessage v-else-if="props.conflict" severity="warning" title="변경 충돌" message="다른 사용자가 먼저 변경했습니다. 최신 버전을 확인하세요." />
<KsInlineMessage v-else-if="props.expired" severity="warning" title="유효기간 만료" message="만료된 증거 또는 제안은 실행·공개할 수 없습니다." />
<div v-else-if="props.error" role="alert" class="ks-state-error">
<KsInlineMessage severity="danger" title="요청 실패" :message="props.error.message" />
<KsButton label="같은 요청 다시 시도" severity="secondary" @click="emit('retry')" />
<small v-if="correlationId">Correlation: {{ correlationId }}</small>
</div>
<KsInlineMessage v-else-if="props.empty" severity="info" message="표시할 데이터가 없습니다." />
<template v-else>
<KsInlineMessage v-if="props.partial" severity="warning" message="일부 데이터만 표시하고 있습니다. 완전성 경고를 확인하세요." />
<KsInlineMessage v-if="props.warning" severity="warning" :message="props.warning" />
<KsInlineMessage v-if="props.readonly" severity="info" message="읽기 전용 상태입니다." />
<KsInlineMessage v-if="props.dirty" severity="warning" message="저장되지 않은 변경사항이 있습니다." />
<KsInlineMessage v-if="props.processing" severity="info" message="처리 중입니다. 중복 제출하지 마세요." />
<slot />
</template>
<small v-if="props.staleAt">데이터 기준시각: {{ props.staleAt }}</small>
</section>
</template>
<style scoped>.ks-state-error{display:grid;gap:var(--ks-space-2);justify-items:start}</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps<{
open: boolean
currentVersion?: string
}>()
const emit = defineEmits<{ close: []; reload: [] }>()
</script>
<template>
<dialog :open="props.open" aria-labelledby="version-conflict-title">
<h2 id="version-conflict-title">버전 충돌</h2>
<p>다른 사용자가 먼저 변경했습니다. 최신 데이터를 다시 불러온 재검토하세요.</p>
<p v-if="props.currentVersion">현재 버전: {{ props.currentVersion }}</p>
<button type="button" @click="emit('reload')">최신 버전 불러오기</button>
<button type="button" @click="emit('close')">닫기</button>
</dialog>
</template>
@@ -0,0 +1,49 @@
import type { Component } from 'vue'
import type { UiAdapter, UiAdapterCapability } from './contracts'
export interface UiAdapterCompatibilityIssue {
code: 'CONTRACT_VERSION' | 'MISSING_CAPABILITY' | 'MISSING_COMPONENT' | 'PRODUCTION_INELIGIBLE'
severity: 'ERROR' | 'WARNING'
detail: string
}
export interface UiAdapterCompatibilityReport {
adapterId: string
contractVersion: string
compatible: boolean
issues: readonly UiAdapterCompatibilityIssue[]
}
const componentByCapability: Readonly<Record<UiAdapterCapability, keyof UiAdapter['components']>> = {
'button': 'Button', 'text-field': 'TextField', 'text-area': 'TextArea', 'select': 'Select',
'multi-select': 'MultiSelect', 'checkbox': 'Checkbox', 'date-field': 'DateField',
'number-field': 'NumberField', 'dialog': 'Dialog', 'status-tag': 'StatusTag',
'inline-message': 'InlineMessage', 'paginator': 'Paginator', 'tabs': 'Tabs', 'data-grid': 'DataGrid'
}
export function evaluateUiAdapterCompatibility(
adapter: UiAdapter,
requiredCapabilities: readonly UiAdapterCapability[],
requireProductionEligible = false
): UiAdapterCompatibilityReport {
const issues: UiAdapterCompatibilityIssue[] = []
if (adapter.descriptor.contractVersion !== '4.0') {
issues.push({ code: 'CONTRACT_VERSION', severity: 'ERROR', detail: `Expected 4.0, got ${adapter.descriptor.contractVersion}` })
}
for (const capability of requiredCapabilities) {
if (!adapter.descriptor.capabilities.has(capability)) {
issues.push({ code: 'MISSING_CAPABILITY', severity: 'ERROR', detail: capability })
continue
}
const component = adapter.components[componentByCapability[capability]] as Component | undefined
if (!component) issues.push({ code: 'MISSING_COMPONENT', severity: 'ERROR', detail: capability })
}
if (requireProductionEligible && !adapter.descriptor.productionEligible) {
issues.push({ code: 'PRODUCTION_INELIGIBLE', severity: 'ERROR', detail: adapter.descriptor.id })
}
return { adapterId: adapter.descriptor.id, contractVersion: adapter.descriptor.contractVersion, compatible: !issues.some(x => x.severity === 'ERROR'), issues }
}
export function assertUiAdapterCompatibility(report: UiAdapterCompatibilityReport): void {
if (!report.compatible) throw new Error(`UI adapter ${report.adapterId} is incompatible: ${report.issues.map(x => `${x.code}:${x.detail}`).join(', ')}`)
}
+110
View File
@@ -0,0 +1,110 @@
import type { Component, InjectionKey } from 'vue'
export type UiSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
export type UiButtonType = 'button' | 'submit' | 'reset'
export type UiAdapterId = 'primevue-aggrid' | 'native-accessible'
export type UiAdapterContractVersion = '4.0'
export type UiAdapterCapability =
| 'button'
| 'text-field'
| 'text-area'
| 'select'
| 'multi-select'
| 'checkbox'
| 'date-field'
| 'number-field'
| 'dialog'
| 'status-tag'
| 'inline-message'
| 'paginator'
| 'tabs'
| 'data-grid'
export interface UiSelectOption {
label: string
value: string | number | boolean | null
disabled?: boolean
}
export interface UiTabItem {
id: string
label: string
disabled?: boolean
badge?: string | number
}
export type UiGridSortDirection = 'asc' | 'desc'
export interface UiGridSort { field: string; direction: UiGridSortDirection }
export interface UiGridFilter { field: string; operator: string; value: unknown }
export interface UiGridQuery {
page: number
pageSize: number
sorts: UiGridSort[]
filters: UiGridFilter[]
search?: string
}
export interface UiGridColumn {
field: string
header: string
width?: number
minWidth?: number
sortable?: boolean
filterable?: boolean
sensitive?: boolean
formatter?: (value: unknown, row: unknown) => string
}
export interface UiAdapterDescriptor {
readonly id: UiAdapterId
readonly version: string
readonly contractVersion: UiAdapterContractVersion
readonly vendor: string
readonly capabilities: ReadonlySet<UiAdapterCapability>
readonly productionEligible: boolean
readonly accessibilityBaseline: 'WCAG_2_2_AA_TARGET'
}
export interface UiAdapter {
readonly descriptor: UiAdapterDescriptor
readonly components: {
Button: Component
TextField: Component
TextArea: Component
Select: Component
MultiSelect: Component
Checkbox: Component
DateField: Component
NumberField: Component
Dialog: Component
StatusTag: Component
InlineMessage: Component
Paginator: Component
Tabs: Component
DataGrid: Component
}
}
export const requiredUiAdapterCapabilities: readonly UiAdapterCapability[] = Object.freeze([
'button', 'text-field', 'text-area', 'select', 'multi-select', 'checkbox', 'date-field',
'number-field', 'dialog', 'status-tag', 'inline-message', 'paginator', 'tabs', 'data-grid'
])
export function assertUiAdapterContract(adapter: UiAdapter): void {
if (adapter.descriptor.contractVersion !== '4.0') {
throw new Error(`Unsupported UI adapter contract: ${adapter.descriptor.contractVersion}`)
}
const missing = requiredUiAdapterCapabilities.filter(x => !adapter.descriptor.capabilities.has(x))
if (missing.length > 0) {
throw new Error(`UI adapter ${adapter.descriptor.id} is missing capabilities: ${missing.join(', ')}`)
}
const componentNames = [
'Button', 'TextField', 'TextArea', 'Select', 'MultiSelect', 'Checkbox', 'DateField',
'NumberField', 'Dialog', 'StatusTag', 'InlineMessage', 'Paginator', 'Tabs', 'DataGrid'
] as const
for (const name of componentNames) {
if (!adapter.components[name]) throw new Error(`UI adapter ${adapter.descriptor.id} has no component for ${name}`)
}
}
export const uiAdapterKey: InjectionKey<UiAdapter> = Symbol('KArtSellUiAdapterV4')
@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { UiButtonType, UiSeverity } from '../contracts'
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), { severity: 'primary', type: 'button', disabled: false, loading: false })
const emit = defineEmits<{ activate: [event: MouseEvent] }>()
</script>
<template><button class="ks-native-button" :class="`is-${severity}`" :type="type" :disabled="disabled || loading" @click="emit('activate', $event)"><span v-if="loading" aria-hidden="true"></span><slot>{{ label }}</slot></button></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
defineProps<{ modelValue: boolean; inputId?: string; disabled?: boolean; invalid?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; blur: [event: FocusEvent] }>()
</script>
<template><input :id="inputId" class="ks-native-checkbox" type="checkbox" :checked="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" @change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)" @blur="emit('blur', $event)" /></template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { UiGridColumn } from '../contracts'
withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
const emit = defineEmits<{ 'row-selected': [row: unknown] }>()
function value(row: unknown, field: string): unknown { return typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[field] : undefined }
</script>
<template><div class="ks-native-grid" :style="{ maxHeight: height }" :aria-busy="loading"><p v-if="loading" role="status">불러오는 중입니다.</p><table><thead><tr><th v-for="column in columns" :key="column.field" scope="col" :style="{ width: column.width ? `${column.width}px` : undefined, minWidth: column.minWidth ? `${column.minWidth}px` : undefined }">{{ column.header }}</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="index" tabindex="0" @click="emit('row-selected', row)" @keydown.enter="emit('row-selected', row)"><td v-for="column in columns" :key="column.field">{{ column.formatter ? column.formatter(value(row, column.field), row) : value(row, column.field) }}</td></tr><tr v-if="!loading && rows.length === 0"><td :colspan="columns.length">조회 결과가 없습니다.</td></tr></tbody></table></div></template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
defineProps<{ modelValue: string | Date | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: Date; max?: Date }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
function toDateValue(value: string | Date | null): string { if (!value) return ''; if (value instanceof Date) return value.toISOString().slice(0, 10); return value.slice(0, 10) }
function boundary(value?: Date): string | undefined { return value?.toISOString().slice(0, 10) }
</script>
<template><input :id="inputId" class="ks-native-input" type="date" :value="toDateValue(modelValue)" :disabled="disabled" :aria-invalid="invalid || undefined" :min="boundary(min)" :max="boundary(max)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)" @blur="emit('blur', $event)" /></template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
const props = defineProps<{ visible: boolean; title?: string; modal?: boolean; closeOnEscape?: boolean }>()
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
const element = ref<HTMLDialogElement | null>(null)
watch(() => props.visible, async visible => { await nextTick(); const dialog = element.value; if (!dialog) return; if (visible && !dialog.open) props.modal === false ? dialog.show() : dialog.showModal(); if (!visible && dialog.open) dialog.close() }, { immediate: true })
function close(): void { emit('update:visible', false) }
</script>
<template><dialog ref="element" class="ks-native-dialog" @close="close" @cancel="close"><header><h2>{{ title }}</h2><button type="button" aria-label="닫기" @click="close">×</button></header><section><slot /></section><footer><slot name="footer" /></footer></dialog></template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { UiSeverity } from '../contracts'
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
const emit = defineEmits<{ dismiss: [] }>()
</script>
<template><div class="ks-inline-message" :data-severity="severity" :role="severity === 'danger' ? 'alert' : 'status'"><strong v-if="title">{{ title }}</strong><span>{{ message }}</span><button v-if="dismissible" type="button" aria-label="메시지 닫기" @click="emit('dismiss')">×</button></div></template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { UiSelectOption } from '../contracts'
const props = withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
function update(event: Event): void {
const selected = Array.from((event.target as HTMLSelectElement).selectedOptions).map(x => {
const option = props.options[Number(x.value)]
return option?.value ?? null
})
emit('update:modelValue', selected)
}
</script>
<template>
<label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span>
<select multiple :disabled="disabled" :required="required" @change="update">
<option v-for="(option, index) in options" :key="`${index}:${option.label}`" :value="index" :disabled="option.disabled" :selected="modelValue.includes(option.value)">{{ option.label }}</option>
</select>
</label>
</template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
defineProps<{ modelValue: number | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
function parse(raw: string): number | null { if (raw.trim() === '') return null; const value = Number(raw); return Number.isFinite(value) ? value : null }
</script>
<template><input :id="inputId" class="ks-native-input" type="number" :value="modelValue ?? ''" :disabled="disabled" :aria-invalid="invalid || undefined" :min="min" :max="max" :step="maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1" @input="emit('update:modelValue', parse(($event.target as HTMLInputElement).value))" @blur="emit('blur', $event)" /></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
const pageCount = () => Math.max(1, Math.ceil(props.total / props.pageSize))
function move(page: number): void { emit('pageChange', { page: Math.min(Math.max(1, page), pageCount()), pageSize: props.pageSize }) }
function size(event: Event): void { emit('pageChange', { page: 1, pageSize: Number((event.target as HTMLSelectElement).value) }) }
</script>
<template><nav class="ks-paginator" aria-label="목록 페이지"><button type="button" :disabled="disabled || page <= 1" @click="move(page - 1)">이전</button><span>{{ page }} / {{ pageCount() }} · {{ total }}</span><button type="button" :disabled="disabled || page >= pageCount()" @click="move(page + 1)">다음</button><label>페이지 크기 <select :value="pageSize" :disabled="disabled" @change="size"><option v-for="item in pageSizes" :key="item" :value="item">{{ item }}</option></select></label></nav></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { UiSelectOption } from '../contracts'
const props = defineProps<{ modelValue: unknown; inputId?: string; options: UiSelectOption[]; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
function encode(value: UiSelectOption['value']): string { return JSON.stringify(value) }
function decode(raw: string): unknown { const option = props.options.find(x => encode(x.value) === raw); return option?.value ?? null }
</script>
<template><select :id="inputId" class="ks-native-input" :value="encode(modelValue as UiSelectOption['value'])" :disabled="disabled" :aria-invalid="invalid || undefined" @change="emit('update:modelValue', decode(($event.target as HTMLSelectElement).value))" @blur="emit('blur', $event)"><option v-if="placeholder" value="" disabled>{{ placeholder }}</option><option v-for="option in options" :key="encode(option.value)" :value="encode(option.value)" :disabled="option.disabled">{{ option.label }}</option></select></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import type { UiSeverity } from '../contracts'
withDefaults(defineProps<{ value: string; severity?: UiSeverity }>(), { severity: 'info' })
</script>
<template><span class="ks-native-tag" :class="`is-${severity}`">{{ value }}</span></template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { UiTabItem } from '../contracts'
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; rows?: number; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
</script>
<template><textarea :id="inputId" class="ks-native-input" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :rows="rows ?? 4" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)" @blur="emit('blur', $event)" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
</script>
<template><input :id="inputId" class="ks-native-input" type="text" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @blur="emit('blur', $event)" /></template>
@@ -0,0 +1,25 @@
import type { UiAdapter, UiAdapterCapability } from '../contracts'
import Button from './NativeButtonAdapter.vue'
import TextField from './NativeTextFieldAdapter.vue'
import TextArea from './NativeTextAreaAdapter.vue'
import Select from './NativeSelectAdapter.vue'
import MultiSelect from './NativeMultiSelectAdapter.vue'
import Checkbox from './NativeCheckboxAdapter.vue'
import DateField from './NativeDateFieldAdapter.vue'
import NumberField from './NativeNumberFieldAdapter.vue'
import Dialog from './NativeDialogAdapter.vue'
import StatusTag from './NativeStatusTagAdapter.vue'
import InlineMessage from './NativeInlineMessageAdapter.vue'
import Paginator from './NativePaginatorAdapter.vue'
import Tabs from './NativeTabsAdapter.vue'
import DataGrid from './NativeDataGridAdapter.vue'
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
'dialog','status-tag','inline-message','paginator','tabs','data-grid'
])
export const nativeUiAdapter: UiAdapter = Object.freeze({
descriptor: Object.freeze({ id: 'native-accessible', version: '2.0.0', contractVersion: '4.0', vendor: 'HTML platform primitives', capabilities, productionEligible: false, accessibilityBaseline: 'WCAG_2_2_AA_TARGET' }),
components: Object.freeze({ Button, TextField, TextArea, Select, MultiSelect, Checkbox, DateField, NumberField, Dialog, StatusTag, InlineMessage, Paginator, Tabs, DataGrid })
})
@@ -0,0 +1,10 @@
import type { App } from 'vue'
import type { UiProvider } from '../../provider/UiProvider'
import { installUiAdapter } from '../useUiAdapter'
import { nativeUiAdapter } from './index'
import './native.css'
export const nativeUiProvider: UiProvider = {
id: 'native-accessible',
install(app: App): void { installUiAdapter(app, nativeUiAdapter) }
}
@@ -0,0 +1,2 @@
.ks-native-button,.ks-native-input,.ks-native-dialog{font:inherit}.ks-native-button{min-height:2.5rem;padding:.5rem .9rem;border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-sm);background:#fff;cursor:pointer}.ks-native-button.is-primary{background:var(--ks-color-primary-700);border-color:var(--ks-color-primary-700);color:#fff}.ks-native-button:disabled{opacity:.55;cursor:not-allowed}.ks-native-input{width:100%;min-height:2.5rem;padding:.45rem .65rem;border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-sm);background:#fff}.ks-native-input[aria-invalid=true]{border-color:var(--ks-color-danger-600)}.ks-native-checkbox{width:1.15rem;height:1.15rem}.ks-native-dialog{width:min(42rem,calc(100vw - 2rem));border:0;border-radius:var(--ks-radius-md);box-shadow:0 1rem 3rem rgb(15 23 42 / 25%)}.ks-native-dialog::backdrop{background:rgb(15 23 42 / 55%)}.ks-native-dialog header{display:flex;justify-content:space-between;align-items:center}.ks-native-dialog footer{display:flex;justify-content:flex-end;gap:var(--ks-space-2)}.ks-native-tag{display:inline-flex;padding:.2rem .55rem;border-radius:999px;background:var(--ks-color-neutral-100)}.ks-native-tag.is-warning{background:#fef3c7}.ks-native-tag.is-danger{background:#fee2e2}.ks-native-tag.is-success{background:#dcfce7}.ks-native-grid{overflow:auto;border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.ks-native-grid table{width:100%;border-collapse:collapse}.ks-native-grid th,.ks-native-grid td{padding:.65rem;border-bottom:1px solid var(--ks-color-neutral-200);text-align:left}.ks-native-grid tbody tr:focus{outline:2px solid var(--ks-color-primary-700);outline-offset:-2px}
.ks-inline-message{display:flex;gap:.5rem;align-items:flex-start;padding:.75rem;border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm);background:#fff}.ks-inline-message[data-severity="danger"]{border-color:#b91c1c}.ks-inline-message[data-severity="warning"]{border-color:#b45309}.ks-paginator,.ks-tabs{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.ks-tabs [aria-selected="true"]{font-weight:700;border-bottom:2px solid currentColor}
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import {
AllCommunityModule,
ModuleRegistry,
themeQuartz,
type ColDef,
type RowClickedEvent
} from 'ag-grid-community'
import type { UiGridColumn } from '../contracts'
ModuleRegistry.registerModules([AllCommunityModule])
const props = withDefaults(defineProps<{
rows: unknown[]
columns: UiGridColumn[]
loading?: boolean
height?: string
rowSelection?: 'single' | 'multiple' | 'none'
}>(), { loading: false, height: '32rem', rowSelection: 'single' })
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
const columnDefs = computed<ColDef[]>(() => props.columns.map(column => ({
field: column.field,
headerName: column.header,
width: column.width,
minWidth: column.minWidth ?? 120,
sortable: column.sortable ?? true,
filter: column.filterable ?? true,
valueFormatter: column.formatter
? params => column.formatter?.(params.value, params.data) ?? ''
: undefined
})))
const rowSelectionOptions = computed(() => {
if (props.rowSelection === 'none') return undefined
return props.rowSelection === 'multiple'
? ({ mode: 'multiRow' } as const)
: ({ mode: 'singleRow' } as const)
})
function onRowClicked(event: RowClickedEvent): void {
if (event.data) emit('rowSelected', event.data)
}
</script>
<template>
<div class="ks-grid" :style="{ height }" :aria-busy="loading">
<AgGridVue
style="height: 100%; width: 100%"
:theme="themeQuartz"
:row-data="rows"
:column-defs="columnDefs"
:row-selection="rowSelectionOptions"
:loading="loading"
@row-clicked="onRowClicked"
/>
</div>
</template>
<style scoped>
.ks-grid {
min-height: 12rem;
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-md);
overflow: hidden;
background: #fff;
}
</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { UiButtonType, UiSeverity } from '../contracts'
withDefaults(defineProps<{
label?: string
severity?: UiSeverity
type?: UiButtonType
disabled?: boolean
loading?: boolean
}>(), { severity: 'primary', type: 'button', disabled: false, loading: false })
defineEmits<{ activate: [event: MouseEvent] }>()
</script>
<template>
<Button
class="ks-button"
:label="label"
:severity="severity"
:type="type"
:disabled="disabled"
:loading="loading"
@click="$emit('activate', $event)"
>
<slot />
</Button>
</template>
<style scoped>
.ks-button { min-height: var(--ks-control-height); padding: 0 var(--ks-space-4); border: 0; border-radius: var(--ks-radius-sm); background: var(--ks-color-action); color: #fff; font-weight: 650; cursor: pointer; }
.ks-button:hover:not(:disabled) { background: var(--ks-color-action-hover); }
.ks-button:disabled { opacity: .55; cursor: not-allowed; }
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
defineProps<{ modelValue: boolean; inputId?: string; disabled?: boolean }>()
defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<Checkbox
class="ks-checkbox"
:input-id="inputId"
:model-value="modelValue"
binary
:disabled="disabled"
@update:model-value="$emit('update:modelValue', Boolean($event))"
/>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import DatePicker from 'primevue/datepicker'
defineProps<{ modelValue: string | Date | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: Date; max?: Date }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
</script>
<template>
<DatePicker
:input-id="inputId"
:model-value="modelValue"
:disabled="disabled"
:invalid="invalid"
:min-date="min"
:max-date="max"
date-format="yy-mm-dd"
show-icon
@update:model-value="emit('update:modelValue', $event)"
@blur="emit('blur', $event)"
/>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
defineEmits<{ 'update:visible': [value: boolean] }>()
</script>
<template>
<Dialog
class="ks-dialog"
:visible="visible"
:header="title"
:modal="modal ?? true"
:closable="closable ?? true"
@update:visible="$emit('update:visible', $event)"
>
<slot />
<template #footer><slot name="footer" /></template>
</Dialog>
</template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import Message from 'primevue/message'
import type { UiSeverity } from '../contracts'
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
const emit = defineEmits<{ dismiss: [] }>()
const map = { primary: 'info', secondary: 'secondary', success: 'success', info: 'info', warning: 'warn', danger: 'error' } as const
</script>
<template><Message :severity="map[severity]" :closable="dismissible" @close="emit('dismiss')"><strong v-if="title">{{ title }} </strong>{{ message }}</Message></template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import type { UiSelectOption } from '../contracts'
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
</script>
<template><label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span><MultiSelect :model-value="modelValue" :options="options" option-label="label" option-value="value" option-disabled="disabled" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /></label></template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
defineProps<{ modelValue: number | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
</script>
<template>
<InputNumber
:input-id="inputId"
:model-value="modelValue"
:disabled="disabled"
:invalid="invalid"
:min="min"
:max="max"
:min-fraction-digits="minFractionDigits"
:max-fraction-digits="maxFractionDigits"
@update:model-value="emit('update:modelValue', $event)"
@blur="emit('blur', $event)"
/>
</template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import Paginator from 'primevue/paginator'
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
</script>
<template><Paginator :first="(page - 1) * pageSize" :rows="pageSize" :total-records="total" :rows-per-page-options="pageSizes" :disabled="disabled" @page="emit('pageChange', { page: $event.page + 1, pageSize: $event.rows })" /></template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import Select from 'primevue/select'
import type { UiSelectOption } from '../contracts'
defineProps<{ modelValue: unknown; inputId?: string; options: UiSelectOption[]; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
</script>
<template>
<Select
class="ks-select"
:input-id="inputId"
:model-value="modelValue"
:options="options"
option-label="label"
option-value="value"
option-disabled="disabled"
:disabled="disabled"
:invalid="invalid"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', $event)"
@blur="$emit('blur', $event)"
/>
</template>
<style scoped>
.ks-select { width: 100%; min-height: var(--ks-control-height); border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); background: #fff; }
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import Tag from 'primevue/tag'
import type { UiSeverity } from '../contracts'
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
</script>
<template>
<Tag class="ks-status-tag" :severity="severity ?? 'info'">
<span v-if="iconLabel" aria-hidden="true">{{ iconLabel }}</span>
<span>{{ value }}</span>
</Tag>
</template>
<style scoped>
.ks-status-tag { display: inline-flex; align-items: center; gap: var(--ks-space-1); border-radius: 999px; padding: var(--ks-space-1) var(--ks-space-2); border: 1px solid currentColor; font-size: var(--ks-font-caption); line-height: var(--ks-line-caption); }
</style>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { UiTabItem } from '../contracts'
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import Textarea from 'primevue/textarea'
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; rows?: number; placeholder?: string }>()
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
</script>
<template>
<Textarea
class="ks-textarea"
:id="inputId"
:model-value="modelValue"
:disabled="disabled"
:invalid="invalid"
:rows="rows ?? 4"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', String($event ?? ''))"
@blur="$emit('blur', $event)"
/>
</template>
<style scoped>
.ks-textarea { width: 100%; border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); padding: var(--ks-space-3); background: #fff; color: var(--ks-color-neutral-950); resize: vertical; }
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
</script>
<template>
<InputText
class="ks-input"
:id="inputId"
:model-value="modelValue"
:disabled="disabled"
:invalid="invalid"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', String($event ?? ''))"
@blur="$emit('blur', $event)"
/>
</template>
<style scoped>
.ks-input { width: 100%; min-height: var(--ks-control-height); border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-3); background: #fff; color: var(--ks-color-neutral-950); }
.ks-input[aria-invalid='true'] { border-color: var(--ks-color-danger); }
</style>
@@ -0,0 +1,13 @@
/* PrimeVue v4 is intentionally installed in unstyled mode. Vendor DOM classes stay in this adapter boundary. */
.p-dialog-mask { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: var(--ks-space-4); background: rgb(15 23 42 / 48%); }
.p-dialog.ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
.p-dialog.ks-dialog .p-dialog-header, .p-dialog.ks-dialog .p-dialog-content, .p-dialog.ks-dialog .p-dialog-footer { padding: var(--ks-space-4); }
.p-dialog.ks-dialog .p-dialog-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--ks-color-neutral-200); font-weight: 700; }
.p-dialog.ks-dialog .p-dialog-footer { display: flex; justify-content: flex-end; gap: var(--ks-space-2); border-top: 1px solid var(--ks-color-neutral-200); }
.p-select-overlay { z-index: 1100; min-width: 12rem; overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-sm); background: #fff; box-shadow: var(--ks-shadow-md); }
.p-select-list { margin: 0; padding: var(--ks-space-1); list-style: none; }
.p-select-option { padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); cursor: pointer; }
.p-select-option.p-focus, .p-select-option:hover { background: var(--ks-color-neutral-100); }
.p-checkbox.ks-checkbox { display: inline-grid; width: 1.25rem; height: 1.25rem; place-items: center; border: 1px solid var(--ks-color-neutral-400); border-radius: .25rem; background: #fff; }
.p-checkbox.ks-checkbox.p-checked { border-color: var(--ks-color-action); background: var(--ks-color-action); color: #fff; }
.ks-tabs{display:flex;gap:.5rem;flex-wrap:wrap}.ks-tabs [aria-selected="true"]{font-weight:700;border-bottom:2px solid currentColor}
@@ -0,0 +1,25 @@
import type { UiAdapter, UiAdapterCapability } from '../contracts'
import Button from './PrimeButtonAdapter.vue'
import TextField from './PrimeTextFieldAdapter.vue'
import TextArea from './PrimeTextAreaAdapter.vue'
import Select from './PrimeSelectAdapter.vue'
import MultiSelect from './PrimeMultiSelectAdapter.vue'
import Checkbox from './PrimeCheckboxAdapter.vue'
import DateField from './PrimeDateFieldAdapter.vue'
import NumberField from './PrimeNumberFieldAdapter.vue'
import Dialog from './PrimeDialogAdapter.vue'
import StatusTag from './PrimeStatusTagAdapter.vue'
import InlineMessage from './PrimeInlineMessageAdapter.vue'
import Paginator from './PrimePaginatorAdapter.vue'
import Tabs from './PrimeTabsAdapter.vue'
import DataGrid from './AgGridAdapter.vue'
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
'dialog','status-tag','inline-message','paginator','tabs','data-grid'
])
export const primeVueUiAdapter: UiAdapter = Object.freeze({
descriptor: Object.freeze({ id: 'primevue-aggrid', version: '4.x+34.x', contractVersion: '4.0', vendor: 'PrimeVue + AG Grid Community', capabilities, productionEligible: true, accessibilityBaseline: 'WCAG_2_2_AA_TARGET' }),
components: Object.freeze({ Button, TextField, TextArea, Select, MultiSelect, Checkbox, DateField, NumberField, Dialog, StatusTag, InlineMessage, Paginator, Tabs, DataGrid })
})
@@ -0,0 +1,18 @@
import type { App } from 'vue'
import PrimeVue from 'primevue/config'
import type { UiProvider } from '../../provider/UiProvider'
import { installUiAdapter } from '../useUiAdapter'
import { primeVueUiAdapter } from './index'
import './adapter.css'
export const primeVueUiProvider: UiProvider = {
id: 'primevue-aggrid',
install(app: App): void {
app.use(PrimeVue, { unstyled: true })
installUiAdapter(app, primeVueUiAdapter)
}
}
export function installPrimeVueAdapter(app: App): void {
primeVueUiProvider.install(app)
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { assertUiAdapterContract, requiredUiAdapterCapabilities } from '../contracts'
import { nativeUiAdapter } from '../native'
import { primeVueUiAdapter } from '../primevue'
describe.each([nativeUiAdapter, primeVueUiAdapter])('UI adapter $descriptor.id', adapter => {
it('implements the complete v4 normalized contract', () => {
expect(() => assertUiAdapterContract(adapter)).not.toThrow()
expect(adapter.descriptor.contractVersion).toBe('4.0')
expect(adapter.descriptor.capabilities.size).toBe(requiredUiAdapterCapabilities.length)
expect(adapter.descriptor.accessibilityBaseline).toBe('WCAG_2_2_AA_TARGET')
})
})
@@ -0,0 +1,17 @@
import type { App } from 'vue'
import { inject } from 'vue'
import type { UiAdapter } from './contracts'
import { assertUiAdapterContract, uiAdapterKey } from './contracts'
export function installUiAdapter(app: App, adapter: UiAdapter): void {
assertUiAdapterContract(adapter)
app.provide(uiAdapterKey, adapter)
}
export function useUiAdapter(): UiAdapter {
const adapter = inject(uiAdapterKey)
if (!adapter) {
throw new Error('UI adapter is not installed. Install a validated provider during app bootstrap.')
}
return adapter
}
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
const props = defineProps<{ label: string; inputId?: string; required?: boolean; error?: string; help?: string }>()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`)
const messageId = computed(() => props.error || props.help ? `${resolvedId.value}-message` : undefined)
</script>
<template>
<div class="ks-field-shell" :data-invalid="Boolean(error) || undefined">
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
<slot :input-id="resolvedId" :described-by="messageId" :invalid="Boolean(error)" />
<small v-if="error || help" :id="messageId" :class="{ 'ks-danger-text': error }" :role="error ? 'alert' : undefined">{{ error ?? help }}</small>
</div>
</template>
<style scoped>
.ks-field-shell{display:grid;gap:var(--ks-space-1)}
label{font-weight:650} small{color:var(--ks-color-neutral-600)}
</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { UiButtonType, UiSeverity } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), {
severity: 'primary', type: 'button', disabled: false, loading: false
})
const emit = defineEmits<{ click: [event: MouseEvent] }>()
const adapter = useUiAdapter()
</script>
<template>
<component :is="adapter.components.Button" v-bind="$props" @activate="emit('click', $event)"><slot /></component>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
const props = defineProps<{ modelValue: boolean; label: string; inputId?: string; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const adapter = useUiAdapter()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-check-${generatedId}`)
</script>
<template><label class="ks-check" :for="resolvedId"><component :is="adapter.components.Checkbox" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /><span>{{ label }}</span></label></template>
<style scoped>.ks-check { display: inline-flex; align-items: center; gap: var(--ks-space-2); cursor: pointer; }</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import KsButton from './KsButton.vue'
export interface CommandBarAction { id: string; label: string; severity?: 'primary'|'secondary'|'success'|'info'|'warning'|'danger'; disabled?: boolean; busy?: boolean }
defineProps<{ actions: readonly CommandBarAction[]; ariaLabel?: string }>()
const emit = defineEmits<{ execute: [actionId: string] }>()
</script>
<template><nav class="ks-command-bar" :aria-label="ariaLabel ?? 'Page actions'"><KsButton v-for="action in actions" :key="action.id" :label="action.label" :severity="action.severity" :disabled="action.disabled" :loading="action.busy" @click="emit('execute', action.id)" /></nav></template>
<style scoped>.ks-command-bar{display:flex;gap:var(--ks-space-2);flex-wrap:wrap;justify-content:flex-end}</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { VersionSet } from '../../contracts/versionSet'
import EvidenceVersionSet from '../EvidenceVersionSet.vue'
const props = defineProps<{
title: string; asOf: string; datasetId: string; dataHash: string; modelVersion: string; configVersion: string;
codeSha: string; contractVersion: string; projectionVersion?: string; watermark?: string;
stale?: boolean; correlationId?: string
}>()
const versionSet = computed<VersionSet>(() => ({
datasetId: props.datasetId,
dataHash: props.dataHash,
modelVersion: props.modelVersion,
configVersion: props.configVersion,
codeSha: props.codeSha,
contractVersion: props.contractVersion
}))
</script>
<template>
<header class="ks-data-context" :data-stale="stale || undefined">
<div><h1>{{ title }}</h1><p>As-of {{ asOf }} <strong v-if="stale">STALE</strong></p></div>
<EvidenceVersionSet :value="versionSet" compact />
<dl v-if="projectionVersion || watermark || correlationId">
<template v-if="projectionVersion"><dt>Projection</dt><dd>{{ projectionVersion }}</dd></template>
<template v-if="watermark"><dt>Watermark</dt><dd>{{ watermark }}</dd></template>
<template v-if="correlationId"><dt>Correlation</dt><dd>{{ correlationId }}</dd></template>
</dl>
</header>
</template>
<style scoped>
.ks-data-context{display:grid;gap:var(--ks-space-3);padding:var(--ks-space-4);border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-md)}
.ks-data-context[data-stale=true]{border-color:var(--ks-color-warning-500)} h1,p{margin:0} dl{display:flex;gap:var(--ks-space-3);margin:0;flex-wrap:wrap} dt{font-weight:700} dd{margin:0}
</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { UiGridColumn } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.DataGrid" v-bind="$props" @row-selected="emit('rowSelected', $event)" /></template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
const props = defineProps<{ modelValue: string | Date | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: Date; max?: Date }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-date-${generatedId}`)
</script>
<template><div class="ks-field"><label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label><component :is="adapter.components.DateField" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :min="min" :max="max" :aria-describedby="error || help ? `${resolvedId}-message` : undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /><small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small></div></template>
<style scoped>.ks-field{display:grid;gap:var(--ks-space-1)}label{font-weight:650}small{color:var(--ks-color-neutral-600)}</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.Dialog" v-bind="$props" @update:visible="emit('update:visible', $event)"><slot /><template #footer><slot name="footer" /></template></component></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { UiSeverity } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
const emit = defineEmits<{ dismiss: [] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.InlineMessage" v-bind="$props" @dismiss="emit('dismiss')" /></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { UiSelectOption } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.MultiSelect" v-bind="$props" @update:model-value="emit('update:modelValue', $event)" /></template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
const props = defineProps<{ modelValue: number | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-number-${generatedId}`)
</script>
<template><div class="ks-field"><label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label><component :is="adapter.components.NumberField" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="error || help ? `${resolvedId}-message` : undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /><small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small></div></template>
<style scoped>.ks-field{display:grid;gap:var(--ks-space-1)}label{font-weight:650}small{color:var(--ks-color-neutral-600)}</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.Paginator" v-bind="$props" @page-change="emit('pageChange', $event)" /></template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import type { UiSelectOption } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
const props = defineProps<{ modelValue: unknown; label: string; options: UiSelectOption[]; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-select-${generatedId}`)
</script>
<template>
<div class="ks-field">
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
<component :is="adapter.components.Select" :input-id="resolvedId" :model-value="modelValue" :options="options" :disabled="disabled" :invalid="Boolean(error)" :placeholder="placeholder" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
<small v-if="error || help" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
</div>
</template>
<style scoped>.ks-field { display: grid; gap: var(--ks-space-1); } label { font-weight: 650; } small { color: var(--ks-color-neutral-600); }</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { UiSeverity } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.StatusTag" v-bind="$props" /></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { UiTabItem } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.Tabs" v-bind="$props" @update:model-value="emit('update:modelValue', $event)"><template #default="slotProps"><slot :active-id="slotProps.activeId" /></template></component></template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; rows?: number; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-area-${generatedId}`)
</script>
<template>
<div class="ks-field">
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
<component :is="adapter.components.TextArea" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :rows="rows" :placeholder="placeholder" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
<small v-if="error || help" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
</div>
</template>
<style scoped>.ks-field { display: grid; gap: var(--ks-space-1); } label { font-weight: 650; } small { color: var(--ks-color-neutral-600); }</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
const generatedId = useId()
const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`)
</script>
<template>
<div class="ks-field">
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
<component
:is="adapter.components.TextField"
:input-id="resolvedId"
:model-value="modelValue"
:disabled="disabled"
:invalid="Boolean(error)"
:placeholder="placeholder"
:aria-describedby="error || help ? `${resolvedId}-message` : undefined"
@update:model-value="emit('update:modelValue', $event)"
@blur="emit('blur', $event)"
/>
<small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
</div>
</template>
<style scoped>
.ks-field { display: grid; gap: var(--ks-space-1); }
label { font-weight: 650; }
small { color: var(--ks-color-neutral-600); }
</style>
@@ -0,0 +1,17 @@
export { default as KsButton } from './KsButton.vue'
export { default as KsTextField } from './KsTextField.vue'
export { default as KsTextArea } from './KsTextArea.vue'
export { default as KsSelect } from './KsSelect.vue'
export { default as KsMultiSelect } from './KsMultiSelect.vue'
export { default as KsCheckbox } from './KsCheckbox.vue'
export { default as KsDateField } from './KsDateField.vue'
export { default as KsNumberField } from './KsNumberField.vue'
export { default as KsDialog } from './KsDialog.vue'
export { default as KsStatusTag } from './KsStatusTag.vue'
export { default as KsInlineMessage } from './KsInlineMessage.vue'
export { default as KsPaginator } from './KsPaginator.vue'
export { default as KsTabs } from './KsTabs.vue'
export { default as KsDataGrid } from './KsDataGrid.vue'
export { default as FieldShell } from './FieldShell.vue'
export { default as KsDataContextHeader } from './KsDataContextHeader.vue'
export { default as KsCommandBar } from './KsCommandBar.vue'
@@ -0,0 +1,30 @@
export type StandardScreenState =
| 'READY' | 'LOADING' | 'EMPTY' | 'WARN' | 'ERROR'
| 'UNAUTHORIZED' | 'FORBIDDEN' | 'PARTIAL' | 'DIRTY'
| 'CONFLICT' | 'EXPIRED' | 'READONLY' | 'PROCESSING'
export interface ScreenEvidenceContext {
asOf?: string
version?: string
datasetId?: string
modelVersion?: string
configVersion?: string
codeSha?: string
contractVersion?: string
evidenceHash?: string
projectionVersion?: string
watermark?: string
stale?: boolean
correlationId?: string
permission?: string
capabilityStatus?: string
}
export interface StandardScreenProps {
title: string
subtitle?: string
state?: StandardScreenState
evidence?: ScreenEvidenceContext
warning?: string
error?: Error | null
}
@@ -0,0 +1,11 @@
<script setup lang="ts">
export type StandardUiState = 'LOADING' | 'EMPTY' | 'WARN' | 'ERROR' | 'EXPIRED' | 'UNAUTHORIZED' | 'READONLY' | 'DIRTY' | 'CONFLICT' | 'PROCESSING' | 'PARTIAL' | 'READY'
withDefaults(defineProps<{ state: StandardUiState; title?: string; message?: string; traceId?: string; retryable?: boolean }>(), { state: 'READY', retryable: false })
defineEmits<{ retry: [] }>()
</script>
<template>
<div v-if="state !== 'READY'" class="ks-state" :data-state="state" role="status" :aria-busy="state === 'LOADING' || state === 'PROCESSING'">
<strong>{{ title ?? state }}</strong><p v-if="message">{{ message }}</p><small v-if="traceId">Trace: {{ traceId }}</small><button v-if="retryable" type="button" @click="$emit('retry')">다시 시도</button><slot />
</div><slot v-else name="ready" />
</template>
<style scoped>.ks-state { padding: var(--ks-space-4); border: 1px solid var(--ks-color-neutral-300); border-left-width: .35rem; border-radius: var(--ks-radius-sm); background: #fff; }.ks-state[data-state='WARN'],.ks-state[data-state='PARTIAL']{border-left-color:var(--ks-color-warning)}.ks-state[data-state='ERROR'],.ks-state[data-state='CONFLICT']{border-left-color:var(--ks-color-danger)}.ks-state p{margin:var(--ks-space-1) 0}.ks-state small{display:block;color:var(--ks-color-neutral-600)}</style>
+7
View File
@@ -0,0 +1,7 @@
export * from './components'
export * from './layouts'
export * from './screen-types'
export { default as QueryStateBoundary } from './QueryStateBoundary.vue'
export { default as EvidenceVersionSet } from './EvidenceVersionSet.vue'
export { default as DataGridShell } from './DataGridShell.vue'
export { default as VersionConflictDialog } from './VersionConflictDialog.vue'
@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{ productName?: string; environment?: string; automationStatus?: string }>()
</script>
<template>
<div class="ks-shell">
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
<header class="ks-shell__header">
<div><strong>{{ productName ?? 'K-ArtSell Aegis' }}</strong><small>{{ environment ?? 'IMPLEMENTATION_TEMPLATE' }}</small></div>
<div class="ks-shell__boundary" role="status">{{ automationStatus ?? '투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF' }}</div>
<slot name="header-actions" />
</header>
<aside class="ks-shell__nav" aria-label="주요 메뉴"><slot name="navigation" /></aside>
<main id="ks-main" class="ks-shell__main" tabindex="-1"><slot /></main>
<footer class="ks-shell__footer"><slot name="footer">RESEARCH_CANDIDATE_NOT_PRODUCTION</slot></footer>
</div>
</template>
<style scoped>
.ks-shell { min-height: 100vh; display: grid; grid-template-columns: 16rem minmax(0, 1fr); grid-template-rows: auto 1fr auto; grid-template-areas: 'header header' 'nav main' 'footer footer'; }
.ks-shell__header { grid-area: header; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-4); padding: var(--ks-space-3) var(--ks-space-6); color: #fff; background: var(--ks-color-neutral-950); }
.ks-shell__header > div:first-child { display: grid; } .ks-shell__header small { color: #cbd5e1; }
.ks-shell__boundary { padding: var(--ks-space-2) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; }
.ks-shell__nav { grid-area: nav; padding: var(--ks-space-4); border-right: 1px solid var(--ks-color-neutral-200); background: #fff; }
.ks-shell__main { grid-area: main; min-width: 0; padding: var(--ks-space-6); }
.ks-shell__footer { grid-area: footer; padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-neutral-200); background: #fff; color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: #fff; } .ks-skip:focus { top: var(--ks-space-2); }
@media (max-width: 900px) { .ks-shell { grid-template-columns: 1fr; grid-template-areas: 'header' 'nav' 'main' 'footer'; } .ks-shell__header { align-items: flex-start; flex-direction: column; } .ks-shell__nav { border-right: 0; border-bottom: 1px solid var(--ks-color-neutral-200); } }
</style>
@@ -0,0 +1,3 @@
<script setup lang="ts">withDefaults(defineProps<{ asideWidth?: string; dense?: boolean }>(), { asideWidth: '24rem', dense: false })</script>
<template><section class="ks-crud-workspace" :class="{ dense }" :style="{ '--ks-crud-aside': asideWidth }"><header v-if="$slots.toolbar"><slot name="toolbar" /></header><div class="ks-crud-workspace__body" :class="{ 'has-aside': $slots.aside }"><main><slot /></main><aside v-if="$slots.aside"><slot name="aside" /></aside></div><footer v-if="$slots.actions"><slot name="actions" /></footer></section></template>
<style scoped>.ks-crud-workspace{display:grid;gap:var(--ks-space-3)}.ks-crud-workspace__body{display:grid;gap:var(--ks-space-4);min-width:0}.ks-crud-workspace__body.has-aside{grid-template-columns:minmax(0,1fr) var(--ks-crud-aside)}main,aside{min-width:0}.dense{gap:var(--ks-space-2)}@media(max-width:1100px){.ks-crud-workspace__body.has-aside{grid-template-columns:1fr}}</style>
@@ -0,0 +1,2 @@
<template><div class="ks-dashboard"><section class="ks-dashboard__kpis"><slot name="kpis" /></section><section class="ks-dashboard__primary ks-card"><slot name="primary" /></section><section class="ks-dashboard__secondary ks-card"><slot name="secondary" /></section><section class="ks-dashboard__alerts ks-card"><slot name="alerts" /></section></div></template>
<style scoped>.ks-dashboard { display: grid; grid-template-columns: 2fr 1fr; gap: var(--ks-space-4); }.ks-dashboard__kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }.ks-dashboard__primary,.ks-dashboard__secondary,.ks-dashboard__alerts { padding: var(--ks-space-4); }.ks-dashboard__alerts { grid-column: 1 / -1; }@media (max-width: 900px) { .ks-dashboard { grid-template-columns: 1fr; } }</style>
@@ -0,0 +1,8 @@
<template>
<div class="ks-form-layout">
<form class="ks-form-layout__form ks-card" @submit.prevent="$emit('submit')"><slot /></form>
<aside v-if="$slots.preview" class="ks-form-layout__preview ks-card"><slot name="preview" /></aside>
</div>
</template>
<script setup lang="ts">defineEmits<{ submit: [] }>()</script>
<style scoped>.ks-form-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem); gap: var(--ks-space-4); align-items: start; } .ks-form-layout__form,.ks-form-layout__preview { padding: var(--ks-space-4); } @media (max-width: 950px) { .ks-form-layout { grid-template-columns: 1fr; } }</style>
@@ -0,0 +1,3 @@
<script setup lang="ts">defineProps<{ title: string; runStatus?: string; watermark?: string; owner?: string }>()</script>
<template><section class="ks-ops-console"><header><div><h1>{{ title }}</h1><p>상태 {{ runStatus ?? '-' }} · Watermark {{ watermark ?? '-' }} · Owner {{ owner ?? '-' }}</p></div><slot name="actions" /></header><div class="ks-ops-console__grid"><aside><slot name="runs" /></aside><main><slot /></main></div><footer v-if="$slots.footer"><slot name="footer" /></footer></section></template>
<style scoped>.ks-ops-console{display:grid;gap:var(--ks-space-4)}header{display:flex;justify-content:space-between;gap:var(--ks-space-3)}h1{margin:0}.ks-ops-console__grid{display:grid;grid-template-columns:minmax(18rem,28rem) minmax(0,1fr);gap:var(--ks-space-4)}main,aside{min-width:0}@media(max-width:1000px){.ks-ops-console__grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string; version?: string; asideWidth?: string }>()
</script>
<template>
<section class="ks-page" :style="{ '--ks-aside-width': asideWidth ?? '22rem' }">
<header class="ks-page__header">
<div><h1>{{ title }}</h1><p v-if="subtitle">{{ subtitle }}</p><div class="ks-page__meta"><span v-if="status">상태: {{ status }}</span><span v-if="asOf">As-of: {{ asOf }}</span><span v-if="version">Version: {{ version }}</span></div></div>
<div class="ks-page__actions"><slot name="actions" /></div>
</header>
<div v-if="$slots.summary" class="ks-page__summary"><slot name="summary" /></div>
<div v-if="$slots.filters" class="ks-page__filters ks-card"><slot name="filters" /></div>
<div class="ks-page__workspace" :class="{ 'has-aside': $slots.aside }">
<div class="ks-page__content"><slot /></div>
<aside v-if="$slots.aside" class="ks-page__aside"><slot name="aside" /></aside>
</div>
<footer v-if="$slots.footer" class="ks-page__footer"><slot name="footer" /></footer>
</section>
</template>
<style scoped>
.ks-page { max-width: var(--ks-content-max); margin: 0 auto; display: grid; gap: var(--ks-space-4); }
.ks-page__header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--ks-space-4); }
h1 { margin: 0; font-size: var(--ks-font-page); line-height: var(--ks-line-page); } p { margin: var(--ks-space-1) 0 0; color: var(--ks-color-neutral-600); }
.ks-page__meta { display: flex; gap: var(--ks-space-3); flex-wrap: wrap; margin-top: var(--ks-space-2); color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
.ks-page__summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }
.ks-page__filters { padding: var(--ks-space-3); }
.ks-page__workspace { min-width: 0; display: grid; gap: var(--ks-space-4); }
.ks-page__workspace.has-aside { grid-template-columns: minmax(0, 1fr) var(--ks-aside-width); }
.ks-page__content, .ks-page__aside { min-width: 0; }
.ks-page__footer { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: flex-end; gap: var(--ks-space-2); padding: var(--ks-space-3); border: 1px solid var(--ks-color-neutral-200); background: rgb(255 255 255 / 96%); }
@media (max-width: 1100px) { .ks-page__workspace.has-aside { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,12 @@
<template>
<div class="ks-review-workbench">
<section class="ks-review-workbench__queue ks-card" aria-label="검토 대기열"><slot name="queue" /></section>
<section class="ks-review-workbench__detail ks-card" aria-label="결정 상세"><slot name="detail" /></section>
<aside class="ks-review-workbench__decision ks-card" aria-label="검토 결정"><slot name="decision" /></aside>
</div>
</template>
<style scoped>
.ks-review-workbench { display: grid; grid-template-columns: minmax(18rem, 28rem) minmax(24rem, 1fr) minmax(18rem, 24rem); gap: var(--ks-space-3); align-items: start; }
.ks-review-workbench > * { min-height: 30rem; padding: var(--ks-space-4); }
@media (max-width: 1200px) { .ks-review-workbench { grid-template-columns: 1fr; } }
</style>
+7
View File
@@ -0,0 +1,7 @@
export { default as AppShellLayout } from './AppShellLayout.vue'
export { default as PageLayout } from './PageLayout.vue'
export { default as DashboardLayout } from './DashboardLayout.vue'
export { default as FormPageLayout } from './FormPageLayout.vue'
export { default as ReviewWorkbenchLayout } from './ReviewWorkbenchLayout.vue'
export { default as CrudWorkspaceLayout } from './CrudWorkspaceLayout.vue'
export { default as OperationsConsoleLayout } from './OperationsConsoleLayout.vue'
@@ -0,0 +1,7 @@
import type { App } from 'vue'
import type { UiAdapterId } from '../adapter/contracts'
export interface UiProvider {
readonly id: UiAdapterId
install(app: App): void
}
+2
View File
@@ -0,0 +1,2 @@
export type { UiProvider } from './UiProvider'
export { resolveUiProvider } from './resolveUiProvider'
@@ -0,0 +1,12 @@
import type { UiProvider } from './UiProvider'
import { nativeUiProvider } from '../adapter/native/installNativeAdapter'
import { primeVueUiProvider } from '../adapter/primevue/installPrimeVueAdapter'
export type UiProviderName = 'primevue' | 'native'
export function resolveUiProvider(name: string | undefined): UiProvider {
const normalized = (name ?? 'primevue').trim().toLowerCase()
if (normalized === 'primevue') return primeVueUiProvider
if (normalized === 'native') return nativeUiProvider
throw new Error(`Unsupported VITE_UI_ADAPTER '${name}'. Allowed values: primevue, native.`)
}
@@ -0,0 +1,3 @@
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; defineProps<{ title: string; status?: string; asOf?: string }>()</script>
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><template #summary><slot name="run-summary" /></template><section class="ks-stack"><div class="ks-card ks-section"><slot name="timeline" /></div><div class="ks-card ks-section"><slot name="records" /></div><div class="ks-card ks-section"><slot name="reprocess" /></div></section><template #aside><slot name="runbook" /></template></PageLayout></template>
<style scoped>.ks-section { padding: var(--ks-space-4); }</style>
@@ -0,0 +1,2 @@
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string; version?: string }>()</script>
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><template #summary><slot name="summary" /></template><slot /><template #aside><slot name="side-panel" /></template><template #footer><slot name="footer" /></template></PageLayout></template>
@@ -0,0 +1,2 @@
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; import FormPageLayout from '../layouts/FormPageLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; version?: string }>(); defineEmits<{ submit: [] }>()</script>
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><FormPageLayout @submit="$emit('submit')"><slot /><template #preview><slot name="preview" /></template></FormPageLayout><template #footer><slot name="footer" /></template></PageLayout></template>
@@ -0,0 +1,2 @@
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string }>()</script>
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><template #summary><slot name="summary" /></template><template #filters><slot name="filters" /></template><slot /><template #aside><slot name="detail" /></template><template #footer><slot name="footer" /></template></PageLayout></template>
@@ -0,0 +1,2 @@
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; import ReviewWorkbenchLayout from '../layouts/ReviewWorkbenchLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string }>()</script>
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><ReviewWorkbenchLayout><template #queue><slot name="queue" /></template><template #detail><slot name="detail" /></template><template #decision><slot name="decision" /></template></ReviewWorkbenchLayout></PageLayout></template>
@@ -0,0 +1,30 @@
export type ScreenTemplateId = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09' | 'T10'
export interface ScreenTemplateDefinition {
id: ScreenTemplateId
name: string
component: string
intendedUse: string
mandatoryStates: readonly string[]
mandatoryEvidence: readonly string[]
antiPatterns: readonly string[]
}
export const screenTemplateCatalogue: readonly ScreenTemplateDefinition[] = Object.freeze([
{ id: 'T01', name: '검색·목록형 CRUD', component: 'SearchListCrudPage', intendedUse: '상품·고객·권한·데이터 Run·추천 검토함', mandatoryStates: ['LOADING','EMPTY','WARN','ERROR','UNAUTHORIZED','PARTIAL'], mandatoryEvidence: ['filter-url','permission','export-auth'], antiPatterns: ['client-side-all-data','unbounded-export'] },
{ id: 'T02', name: '상세 조회형', component: 'DetailReadPage', intendedUse: '투자제안·상품·포트폴리오·백테스트 결과', mandatoryStates: ['LOADING','WARN','EXPIRED','READONLY','UNAUTHORIZED'], mandatoryEvidence: ['as-of','version-set','audit'], antiPatterns: ['mutable-evidence','hidden-version'] },
{ id: 'T03', name: '등록·편집 Form', component: 'EditFormPage', intendedUse: '고객·IPS·비용표·권한·설정', mandatoryStates: ['DIRTY','CONFLICT','PROCESSING','ERROR'], mandatoryEvidence: ['zod','if-match','idempotency-key'], antiPatterns: ['silent-overwrite','pinia-form-cache'] },
{ id: 'T04', name: 'Master-Detail', component: 'MasterDetailCrudPage', intendedUse: '고객-IPS·추천-항목·Watch-Stage·대사 Run-Break', mandatoryStates: ['LOADING','EMPTY','DIRTY','PARTIAL','CONFLICT'], mandatoryEvidence: ['route-selection','unsaved-guard','version'], antiPatterns: ['selection-only-local','detail-n-plus-one'] },
{ id: 'T05', name: '검토·승인 Workbench', component: 'ApprovalWorkbenchPage', intendedUse: '추천·모델·정정·대사 maker-checker', mandatoryStates: ['WARN','EXPIRED','CONFLICT','PROCESSING','READONLY'], mandatoryEvidence: ['maker-checker','reason','warning-ack'], antiPatterns: ['self-approval','approval-without-evidence'] },
{ id: 'T06', name: '단계 Wizard', component: 'StepWizardPage', intendedUse: '고객 온보딩·IPS·리밸런싱·Backfill', mandatoryStates: ['DIRTY','ERROR','PROCESSING','READONLY'], mandatoryEvidence: ['resume','branch','impact-revalidation'], antiPatterns: ['single-huge-form','skip-validation'] },
{ id: 'T07', name: 'Dashboard·Scorecard', component: 'ScorecardDashboardPage', intendedUse: '고객 대시보드·일평가·운영 SLO', mandatoryStates: ['LOADING','EMPTY','WARN','PARTIAL'], mandatoryEvidence: ['metric-definition','sample-size','table-alternative'], antiPatterns: ['chart-only','metric-definition-hidden'] },
{ id: 'T08', name: 'Batch·데이터 운영', component: 'BatchOperationsPageV2', intendedUse: '수집·Feature·추천·평가·Backfill', mandatoryStates: ['PROCESSING','WARN','ERROR','PARTIAL','READONLY'], mandatoryEvidence: ['job-run','watermark','replay-scope'], antiPatterns: ['blind-retry','overwrite-reprocess'] },
{ id: 'T09', name: '대사·예외 처리', component: 'ReconciliationExceptionPage', intendedUse: 'KIS/원장 대사·데이터 격리·DQ 예외', mandatoryStates: ['WARN','CONFLICT','PROCESSING','READONLY'], mandatoryEvidence: ['before-after','correction','audit'], antiPatterns: ['direct-db-fix','delete-break'] },
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN','EXPIRED','READONLY','CONFLICT'], mandatoryEvidence: ['same-dataset-cost','gate-pack','rollback'], antiPatterns: ['auto-promotion','different-cohort-comparison'] }
])
export function getScreenTemplate(id: ScreenTemplateId): ScreenTemplateDefinition {
const template = screenTemplateCatalogue.find(x => x.id === id)
if (!template) throw new Error(`Unknown screen template: ${id}`)
return template
}
@@ -0,0 +1,7 @@
export { default as CrudListPage } from './CrudListPage.vue'
export { default as CrudDetailPage } from './CrudDetailPage.vue'
export { default as CrudFormPage } from './CrudFormPage.vue'
export { default as CrudReviewPage } from './CrudReviewPage.vue'
export { default as BatchOperationsPage } from './BatchOperationsPage.vue'
export * from './v2'
export * from './catalogue'
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { getScreenTemplate, screenTemplateCatalogue } from '../catalogue'
describe('screen template catalogue', () => {
it('defines all ten screen contracts without duplicate IDs or components', () => {
expect(screenTemplateCatalogue).toHaveLength(10)
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(10)
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(10)
})
it('requires evidence and anti-pattern declarations for every template', () => {
for (const template of screenTemplateCatalogue) {
expect(template.mandatoryEvidence.length).toBeGreaterThan(0)
expect(template.mandatoryStates.length).toBeGreaterThan(0)
expect(template.antiPatterns.length).toBeGreaterThan(0)
}
})
it('resolves a template by stable ID', () => {
expect(getScreenTemplate('T05').component).toBe('ApprovalWorkbenchPage')
})
})
@@ -0,0 +1,2 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import ReviewWorkbenchLayout from '../../layouts/ReviewWorkbenchLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><ReviewWorkbenchLayout><template #queue><slot name="queue"/></template><template #detail><slot name="detail"/></template><template #decision><slot name="decision"/></template></ReviewWorkbenchLayout></StandardScreenBoundary></PageLayout></template>
@@ -0,0 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><template #summary><slot name="runSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="timeline"/></div><div class="ks-card ks-section"><slot name="records"/></div><div class="ks-card ks-section"><slot name="reprocess"/></div></section></StandardScreenBoundary><template #aside><slot name="runbook"/></template></PageLayout></template>
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>

Some files were not shown because too many files have changed in this diff Show More