feat(fe): standardize loading UI using shimmer SkeletonLoader across QueryStateBoundary and AGENTS.md

This commit is contained in:
2026-08-15 21:44:45 +09:00
parent dff10d305a
commit c320318207
4 changed files with 65 additions and 51 deletions
+3 -1
View File
@@ -98,7 +98,7 @@ All work in this repository MUST follow `docs/CURRENT/WBS_EXECUTION_PROCEDURES.m
1. **Page Action Toolbar (Top-Right `.ks-page__actions`)**: Dedicated exclusively to **Primary Processing Actions** (e.g., `▶ 배치 실행`, `⚡ 리밸런싱 실행`, `📤 데이터 수집`) and **Global Page Operations** (e.g., ` 신규 등록`). Secondary actions are styled as outline/ghost. 1. **Page Action Toolbar (Top-Right `.ks-page__actions`)**: Dedicated exclusively to **Primary Processing Actions** (e.g., `▶ 배치 실행`, `⚡ 리밸런싱 실행`, `📤 데이터 수집`) and **Global Page Operations** (e.g., ` 신규 등록`). Secondary actions are styled as outline/ghost.
2. **Grid Row & Item Context Actions (Table Row Actions)**: Dedicated to **Single-Row CRUD & Processing** (e.g., `✏️ 수정`, `🗑️ 삭제`, `🔍 상세보기`, `▶ 재처리`). Placed in a pinned right column or explicit context menu; never placed in page top toolbar. 2. **Grid Row & Item Context Actions (Table Row Actions)**: Dedicated to **Single-Row CRUD & Processing** (e.g., `✏️ 수정`, `🗑️ 삭제`, `🔍 상세보기`, `▶ 재처리`). Placed in a pinned right column or explicit context menu; never placed in page top toolbar.
3. **Multi-Selection Batch Toolbar (Grid Top/Bottom Selection Bar)**: Activated conditionally upon multi-row selection for **Bulk Actions** (e.g., `선택 일괄 승인(3)`, `선택 일괄 삭제`). 3. **Multi-Selection Batch Toolbar (Grid Top/Bottom Selection Bar)**: Activated conditionally upon multi-row selection for **Bulk Actions** (e.g., `선택 일괄 승인(3)`, `선택 일괄 삭제`).
4. **Form & Dialog Footer Toolbar (Form Bottom Action Bar)**: Dedicated to **Form Submission & Dialog Operations** (Right-aligned: `[취소 (Secondary)] [저장/확인 (Primary)]`). - **[CRITICAL IRON RULE] Standardized Loading Skeleton Rule**: ALL screen-level and section-level data loading MUST render animated `SkeletonLoader` (shimmer mode) matching the expected layout (e.g. `skeletonType="table"` for grids, `skeletonType="card"` for forms/summaries) through `QueryStateBoundary`/`StandardScreenBoundary`. Static text ("불러오는 중...") or empty screen placeholders during loading states are STRICTLY PROHIBITED.
## v16.0 Gitea API & CI/CD Automation ## v16.0 Gitea API & CI/CD Automation
@@ -359,12 +359,14 @@ Every task — code change, refactor, new feature, tooling, infrastructure — m
- [ ] **Debt:** 새로운 debt 만들지 않는가? 기존 debt 감축하는가? - [ ] **Debt:** 새로운 debt 만들지 않는가? 기존 debt 감축하는가?
- [ ] **Viewport Fit (UI):** 대시보드를 제외한 모든 업무 화면이 페이지 스크롤 없이 초기 로딩 시 100% 한눈에 들어오는가? - [ ] **Viewport Fit (UI):** 대시보드를 제외한 모든 업무 화면이 페이지 스크롤 없이 초기 로딩 시 100% 한눈에 들어오는가?
- [ ] **Button Standard (UI):** 상단 툴바(배치/등록), 행별 작업(수정/상세), 다중선택(일괄), 폼 푸터(취소/저장) 버튼 배치가 규칙 매트릭스를 따르는가? - [ ] **Button Standard (UI):** 상단 툴바(배치/등록), 행별 작업(수정/상세), 다중선택(일괄), 폼 푸터(취소/저장) 버튼 배치가 규칙 매트릭스를 따르는가?
- [ ] **Skeleton Loading (UI):** 로딩 상태 시 텍스트 대신 레이아웃에 반응하는 shimmer 스켈레톤(SkeletonLoader)이 제대로 노출되는가?
### Anti-Patterns (금지) ### Anti-Patterns (금지)
- ❌ "일단 만들고 나중에 리팩터" → Feature 초기부터 정공법 - ❌ "일단 만들고 나중에 리팩터" → Feature 초기부터 정공법
- ❌ "페이지에 창 스크롤바가 생기게 방치" → 대시보드 제외 모든 업무 화면은 Viewport-Fit Zero-Scroll 필수 - ❌ "페이지에 창 스크롤바가 생기게 방치" → 대시보드 제외 모든 업무 화면은 Viewport-Fit Zero-Scroll 필수
- ❌ "버튼 위치 난잡 배치" → 상단 우측(페이지/배치), 행 내부(개별 CRUD), 선택바(일괄), 폼 푸터(저장/취소) 표준 무시 금지 - ❌ "버튼 위치 난잡 배치" → 상단 우측(페이지/배치), 행 내부(개별 CRUD), 선택바(일괄), 폼 푸터(저장/취소) 표준 무시 금지
- ❌ "로딩 시 '불러오는 중...' 텍스트 방치" → 반드시 레이아웃 맞춤형 애니메이션 스켈레톤(SkeletonLoader) 적용 필수
- ❌ "혹시 필요할까봐 추상화" → Necessity-driven만 - ❌ "혹시 필요할까봐 추상화" → Necessity-driven만
- ❌ SELECT * / Generic Repository → Explicit columns, explicit logic - ❌ SELECT * / Generic Repository → Explicit columns, explicit logic
- ❌ "이건 작은 변경이라 테스트 스킵" → 모든 경로 characterize - ❌ "이건 작은 변경이라 테스트 스킵" → 모든 경로 characterize
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import KsButton from './components/KsButton.vue' import KsButton from './components/KsButton.vue'
import KsInlineMessage from './components/KsInlineMessage.vue' import KsInlineMessage from './components/KsInlineMessage.vue'
import SkeletonLoader from './components/SkeletonLoader.vue'
const props = defineProps<{ const props = defineProps<{
loading: boolean loading: boolean
processing?: boolean processing?: boolean
@@ -16,12 +17,14 @@ const props = defineProps<{
expired?: boolean expired?: boolean
readonly?: boolean readonly?: boolean
correlationId?: string correlationId?: string
skeletonType?: 'text' | 'card' | 'avatar' | 'table' | 'list'
skeletonRows?: number
}>() }>()
const emit = defineEmits<{ retry: [] }>() const emit = defineEmits<{ retry: [] }>()
</script> </script>
<template> <template>
<section class="ks-query-boundary" :aria-busy="props.loading || props.processing"> <section class="ks-query-boundary" :aria-busy="props.loading || props.processing">
<KsInlineMessage v-if="props.loading" severity="info" message="불러오는 중입니다." /> <SkeletonLoader v-if="props.loading" :type="props.skeletonType ?? 'table'" :rows="props.skeletonRows ?? 6" />
<KsInlineMessage v-else-if="props.unauthorized" severity="warning" title="로그인 필요" 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.forbidden" severity="danger" title="권한 없음" message="이 작업을 수행할 권한이 없습니다." />
<KsInlineMessage v-else-if="props.conflict" severity="warning" title="변경 충돌" message="다른 사용자가 먼저 변경했습니다. 최신 버전을 확인하세요." /> <KsInlineMessage v-else-if="props.conflict" severity="warning" title="변경 충돌" message="다른 사용자가 먼저 변경했습니다. 최신 버전을 확인하세요." />
@@ -40,10 +40,11 @@ defineProps<Props>()
<!-- Table skeleton --> <!-- Table skeleton -->
<template v-else-if="type === 'table'"> <template v-else-if="type === 'table'">
<div class="skeleton-table"> <div class="skeleton-table">
<div v-for="i in (rows || 5)" :key="i" class="skeleton-table-row"> <div class="skeleton-table-header">
<div class="skeleton-cell"></div> <div v-for="j in 6" :key="j" class="skeleton-header-cell"></div>
<div class="skeleton-cell"></div> </div>
<div class="skeleton-cell"></div> <div v-for="i in (rows || 6)" :key="i" class="skeleton-table-row">
<div v-for="j in 6" :key="j" class="skeleton-cell"></div>
</div> </div>
</div> </div>
</template> </template>
@@ -63,83 +64,89 @@ defineProps<Props>()
<style scoped> <style scoped>
.skeleton-loader { .skeleton-loader {
animation: pulse var(--transition-slow); width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: var(--ks-space-2);
} }
@keyframes pulse { .skeleton-line, .skeleton-cell, .skeleton-header-cell, .skeleton-avatar {
0% { background: linear-gradient(90deg, var(--ks-color-neutral-100) 25%, var(--ks-color-neutral-200) 37%, var(--ks-color-neutral-100) 63%);
opacity: 1; background-size: 400% 100%;
} animation: skeleton-shimmer 1.4s ease infinite;
50% { border-radius: var(--ks-radius-sm);
opacity: 0.6;
}
100% {
opacity: 1;
}
} }
.skeleton-line { @keyframes skeleton-shimmer {
background: var(--color-background-secondary); 0% { background-position: 100% 50%; }
border-radius: var(--border-radius-base); 100% { background-position: 0 50%; }
display: block;
margin-bottom: var(--spacing-2);
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
background: var(--color-background-secondary);
flex-shrink: 0;
} }
.skeleton-card { .skeleton-card {
padding: var(--spacing-4); padding: var(--ks-space-3);
background: var(--color-background-primary); background: #fff;
border: var(--border-width-1) solid var(--color-border-secondary); border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--border-radius-lg); border-radius: var(--ks-radius-md);
} }
.skeleton-header { .skeleton-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--spacing-3); margin-bottom: var(--ks-space-2);
}
.skeleton-body {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
} }
.skeleton-table { .skeleton-table {
width: 100%; width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-md);
padding: 6px;
background: #fff;
}
.skeleton-table-header {
display: grid;
grid-template-columns: 80px 2fr 2fr 1fr 1fr 1.5fr;
gap: 8px;
height: 30px;
align-items: center;
border-bottom: 1px solid var(--ks-color-neutral-200);
padding-bottom: 4px;
}
.skeleton-header-cell {
height: 20px;
border-radius: var(--ks-radius-sm);
} }
.skeleton-table-row { .skeleton-table-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: 80px 2fr 2fr 1fr 1fr 1.5fr;
gap: var(--spacing-2); gap: 8px;
margin-bottom: var(--spacing-3); height: 28px;
align-items: center;
} }
.skeleton-cell { .skeleton-cell {
height: 20px; height: 18px;
background: var(--color-background-secondary); border-radius: var(--ks-radius-sm);
border-radius: var(--border-radius-base);
} }
.skeleton-list-item { .skeleton-list-item {
display: flex; display: flex;
gap: var(--spacing-3); gap: var(--ks-space-3);
margin-bottom: var(--spacing-3); margin-bottom: var(--ks-space-2);
} }
.skeleton-list-content { .skeleton-list-content {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-2); gap: var(--ks-space-2);
} }
</style> </style>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { StandardScreenState } from '../../contracts/screenContract' import type { StandardScreenState } from '../../contracts/screenContract'
import QueryStateBoundary from '../../QueryStateBoundary.vue' import QueryStateBoundary from '../../QueryStateBoundary.vue'
withDefaults(defineProps<{ state?: StandardScreenState; warning?: string; error?: Error | null; staleAt?: string; correlationId?: string }>(), { state: 'READY' }) withDefaults(defineProps<{ state?: StandardScreenState; warning?: string; error?: Error | null; staleAt?: string; correlationId?: string; skeletonType?: 'text' | 'card' | 'avatar' | 'table' | 'list'; skeletonRows?: number }>(), { state: 'READY' })
const emit = defineEmits<{ retry: [] }>() const emit = defineEmits<{ retry: [] }>()
</script> </script>
<template> <template>
@@ -20,6 +20,8 @@ const emit = defineEmits<{ retry: [] }>()
:error="state === 'ERROR' ? (error ?? new Error('화면 상태 오류')) : undefined" :error="state === 'ERROR' ? (error ?? new Error('화면 상태 오류')) : undefined"
:stale-at="staleAt" :stale-at="staleAt"
:correlation-id="correlationId" :correlation-id="correlationId"
:skeleton-type="skeletonType"
:skeleton-rows="skeletonRows"
@retry="emit('retry')" @retry="emit('retry')"
><slot /></QueryStateBoundary> ><slot /></QueryStateBoundary>
</template> </template>