Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: slide layouts/themes, transitions, database views (#66, #68, #45)' (#143) from feat/layouts-transitions-views into main

scott fce9f562 b69df5cf

+1545
+277
src/sheets/database-views.ts
··· 1 + /** 2 + * Database Views — kanban, gallery, calendar view models for sheet data. 3 + * 4 + * Pure logic module: grouping, card extraction, date bucketing. 5 + * DOM rendering handled by the sheets UI layer. 6 + */ 7 + 8 + export type ViewType = 'table' | 'kanban' | 'gallery' | 'calendar'; 9 + 10 + export interface ViewConfig { 11 + type: ViewType; 12 + /** Column index used for grouping (kanban) or date (calendar) */ 13 + groupByColumn: number; 14 + /** Columns to display as card fields */ 15 + displayColumns: number[]; 16 + /** Column used as card title */ 17 + titleColumn: number; 18 + /** Sort order for cards within groups */ 19 + sortColumn: number; 20 + sortAscending: boolean; 21 + } 22 + 23 + export interface CardData { 24 + rowIndex: number; 25 + title: string; 26 + fields: Array<{ columnIndex: number; value: string }>; 27 + } 28 + 29 + export interface KanbanColumn { 30 + groupValue: string; 31 + cards: CardData[]; 32 + } 33 + 34 + export interface CalendarEvent { 35 + rowIndex: number; 36 + title: string; 37 + date: string; // ISO date string YYYY-MM-DD 38 + fields: Array<{ columnIndex: number; value: string }>; 39 + } 40 + 41 + export interface CalendarMonth { 42 + year: number; 43 + month: number; // 0-11 44 + events: CalendarEvent[]; 45 + } 46 + 47 + /** 48 + * Create a default view config. 49 + */ 50 + export function createViewConfig( 51 + type: ViewType, 52 + groupByColumn = 0, 53 + titleColumn = 0, 54 + ): ViewConfig { 55 + return { 56 + type, 57 + groupByColumn, 58 + displayColumns: [], 59 + titleColumn, 60 + sortColumn: 0, 61 + sortAscending: true, 62 + }; 63 + } 64 + 65 + /** 66 + * Extract card data from a row. 67 + */ 68 + export function extractCard( 69 + rowIndex: number, 70 + getCellValue: (row: number, col: number) => string, 71 + config: ViewConfig, 72 + ): CardData { 73 + const title = getCellValue(rowIndex, config.titleColumn); 74 + const fields = config.displayColumns.map(col => ({ 75 + columnIndex: col, 76 + value: getCellValue(rowIndex, col), 77 + })); 78 + return { rowIndex, title, fields }; 79 + } 80 + 81 + /** 82 + * Group rows into kanban columns by a grouping column's value. 83 + */ 84 + export function buildKanbanColumns( 85 + rowIndices: number[], 86 + getCellValue: (row: number, col: number) => string, 87 + config: ViewConfig, 88 + ): KanbanColumn[] { 89 + const groups = new Map<string, CardData[]>(); 90 + 91 + for (const row of rowIndices) { 92 + const groupValue = getCellValue(row, config.groupByColumn) || '(empty)'; 93 + const card = extractCard(row, getCellValue, config); 94 + const existing = groups.get(groupValue) || []; 95 + existing.push(card); 96 + groups.set(groupValue, existing); 97 + } 98 + 99 + // Sort cards within each group 100 + const columns: KanbanColumn[] = []; 101 + for (const [groupValue, cards] of groups) { 102 + const sorted = sortCards(cards, getCellValue, config); 103 + columns.push({ groupValue, cards: sorted }); 104 + } 105 + 106 + return columns; 107 + } 108 + 109 + /** 110 + * Build gallery cards (all rows, no grouping). 111 + */ 112 + export function buildGalleryCards( 113 + rowIndices: number[], 114 + getCellValue: (row: number, col: number) => string, 115 + config: ViewConfig, 116 + ): CardData[] { 117 + const cards = rowIndices.map(row => extractCard(row, getCellValue, config)); 118 + return sortCards(cards, getCellValue, config); 119 + } 120 + 121 + /** 122 + * Build calendar events from rows that have date values. 123 + */ 124 + export function buildCalendarEvents( 125 + rowIndices: number[], 126 + getCellValue: (row: number, col: number) => string, 127 + config: ViewConfig, 128 + ): CalendarEvent[] { 129 + const events: CalendarEvent[] = []; 130 + 131 + for (const row of rowIndices) { 132 + const dateStr = getCellValue(row, config.groupByColumn); 133 + const parsed = parseCalendarDate(dateStr); 134 + if (!parsed) continue; 135 + 136 + events.push({ 137 + rowIndex: row, 138 + title: getCellValue(row, config.titleColumn), 139 + date: parsed, 140 + fields: config.displayColumns.map(col => ({ 141 + columnIndex: col, 142 + value: getCellValue(row, col), 143 + })), 144 + }); 145 + } 146 + 147 + return events.sort((a, b) => a.date.localeCompare(b.date)); 148 + } 149 + 150 + /** 151 + * Group calendar events by month. 152 + */ 153 + export function groupEventsByMonth(events: CalendarEvent[]): CalendarMonth[] { 154 + const months = new Map<string, CalendarMonth>(); 155 + 156 + for (const event of events) { 157 + const [yearStr, monthStr] = event.date.split('-'); 158 + const year = parseInt(yearStr, 10); 159 + const month = parseInt(monthStr, 10) - 1; // 0-indexed 160 + const key = `${year}-${month}`; 161 + 162 + if (!months.has(key)) { 163 + months.set(key, { year, month, events: [] }); 164 + } 165 + months.get(key)!.events.push(event); 166 + } 167 + 168 + return Array.from(months.values()).sort( 169 + (a, b) => a.year - b.year || a.month - b.month, 170 + ); 171 + } 172 + 173 + /** 174 + * Get events for a specific date. 175 + */ 176 + export function eventsForDate(events: CalendarEvent[], date: string): CalendarEvent[] { 177 + return events.filter(e => e.date === date); 178 + } 179 + 180 + /** 181 + * Move a card in kanban (change its group value). 182 + * Returns the new value that should be set in the grouping cell. 183 + */ 184 + export function moveCardToColumn( 185 + _card: CardData, 186 + targetGroup: string, 187 + ): string { 188 + return targetGroup === '(empty)' ? '' : targetGroup; 189 + } 190 + 191 + /** 192 + * Get distinct group values from data. 193 + */ 194 + export function getDistinctGroups( 195 + rowIndices: number[], 196 + getCellValue: (row: number, col: number) => string, 197 + groupColumn: number, 198 + ): string[] { 199 + const seen = new Set<string>(); 200 + for (const row of rowIndices) { 201 + const val = getCellValue(row, groupColumn) || '(empty)'; 202 + seen.add(val); 203 + } 204 + return Array.from(seen); 205 + } 206 + 207 + /** 208 + * Filter rows by a specific group value. 209 + */ 210 + export function filterByGroup( 211 + rowIndices: number[], 212 + getCellValue: (row: number, col: number) => string, 213 + groupColumn: number, 214 + groupValue: string, 215 + ): number[] { 216 + return rowIndices.filter(row => { 217 + const val = getCellValue(row, groupColumn) || '(empty)'; 218 + return val === groupValue; 219 + }); 220 + } 221 + 222 + /** 223 + * Count cards per group. 224 + */ 225 + export function countByGroup(columns: KanbanColumn[]): Map<string, number> { 226 + const counts = new Map<string, number>(); 227 + for (const col of columns) { 228 + counts.set(col.groupValue, col.cards.length); 229 + } 230 + return counts; 231 + } 232 + 233 + /** 234 + * Parse a date string to ISO YYYY-MM-DD format. 235 + */ 236 + export function parseCalendarDate(value: string): string | null { 237 + if (!value) return null; 238 + // Try ISO format first 239 + const isoMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/); 240 + if (isoMatch) return `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`; 241 + 242 + // Try MM/DD/YYYY 243 + const usMatch = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); 244 + if (usMatch) { 245 + const m = usMatch[1].padStart(2, '0'); 246 + const d = usMatch[2].padStart(2, '0'); 247 + return `${usMatch[3]}-${m}-${d}`; 248 + } 249 + 250 + return null; 251 + } 252 + 253 + /** 254 + * Get all valid view types. 255 + */ 256 + export function getViewTypes(): Array<{ type: ViewType; label: string }> { 257 + return [ 258 + { type: 'table', label: 'Table' }, 259 + { type: 'kanban', label: 'Kanban' }, 260 + { type: 'gallery', label: 'Gallery' }, 261 + { type: 'calendar', label: 'Calendar' }, 262 + ]; 263 + } 264 + 265 + /** Sort cards based on config */ 266 + function sortCards( 267 + cards: CardData[], 268 + getCellValue: (row: number, col: number) => string, 269 + config: ViewConfig, 270 + ): CardData[] { 271 + return [...cards].sort((a, b) => { 272 + const aVal = getCellValue(a.rowIndex, config.sortColumn); 273 + const bVal = getCellValue(b.rowIndex, config.sortColumn); 274 + const cmp = aVal.localeCompare(bVal, undefined, { numeric: true }); 275 + return config.sortAscending ? cmp : -cmp; 276 + }); 277 + }
+289
src/slides/layouts-themes.ts
··· 1 + /** 2 + * Slide Layouts & Themes — pre-built layouts and color/font theme system. 3 + * 4 + * Pure logic module: layout definitions, theme palettes, applying themes. 5 + * DOM rendering handled by the slides UI layer. 6 + */ 7 + 8 + export type LayoutType = 'blank' | 'title' | 'section' | 'twoColumn' | 'titleContent' | 'imageLeft' | 'imageRight'; 9 + 10 + export interface LayoutRegion { 11 + name: string; 12 + x: number; 13 + y: number; 14 + width: number; 15 + height: number; 16 + /** Content hint for the region */ 17 + role: 'title' | 'subtitle' | 'body' | 'image' | 'content'; 18 + } 19 + 20 + export interface SlideLayout { 21 + type: LayoutType; 22 + label: string; 23 + regions: LayoutRegion[]; 24 + } 25 + 26 + export interface ThemePalette { 27 + primary: string; 28 + secondary: string; 29 + background: string; 30 + surface: string; 31 + text: string; 32 + accent: string; 33 + } 34 + 35 + export interface ThemeFonts { 36 + heading: string; 37 + body: string; 38 + } 39 + 40 + export interface Theme { 41 + id: string; 42 + name: string; 43 + palette: ThemePalette; 44 + fonts: ThemeFonts; 45 + } 46 + 47 + export interface ThemedDeck { 48 + themeId: string; 49 + /** Layout type per slide (index = slide index) */ 50 + layouts: LayoutType[]; 51 + } 52 + 53 + /** Slide dimensions (matching canvas-engine) */ 54 + const W = 960; 55 + const H = 540; 56 + const PAD = 40; 57 + 58 + /** 59 + * Get the built-in layout definitions. 60 + */ 61 + export function getLayouts(): SlideLayout[] { 62 + return [ 63 + { 64 + type: 'blank', 65 + label: 'Blank', 66 + regions: [], 67 + }, 68 + { 69 + type: 'title', 70 + label: 'Title Slide', 71 + regions: [ 72 + { name: 'title', x: PAD, y: H * 0.3, width: W - PAD * 2, height: 80, role: 'title' }, 73 + { name: 'subtitle', x: PAD, y: H * 0.3 + 100, width: W - PAD * 2, height: 50, role: 'subtitle' }, 74 + ], 75 + }, 76 + { 77 + type: 'section', 78 + label: 'Section Header', 79 + regions: [ 80 + { name: 'title', x: PAD, y: H * 0.4, width: W - PAD * 2, height: 80, role: 'title' }, 81 + ], 82 + }, 83 + { 84 + type: 'twoColumn', 85 + label: 'Two Column', 86 + regions: [ 87 + { name: 'title', x: PAD, y: PAD, width: W - PAD * 2, height: 60, role: 'title' }, 88 + { name: 'left', x: PAD, y: 120, width: (W - PAD * 3) / 2, height: H - 160, role: 'content' }, 89 + { name: 'right', x: PAD + (W - PAD * 3) / 2 + PAD, y: 120, width: (W - PAD * 3) / 2, height: H - 160, role: 'content' }, 90 + ], 91 + }, 92 + { 93 + type: 'titleContent', 94 + label: 'Title + Content', 95 + regions: [ 96 + { name: 'title', x: PAD, y: PAD, width: W - PAD * 2, height: 60, role: 'title' }, 97 + { name: 'body', x: PAD, y: 120, width: W - PAD * 2, height: H - 160, role: 'body' }, 98 + ], 99 + }, 100 + { 101 + type: 'imageLeft', 102 + label: 'Image Left', 103 + regions: [ 104 + { name: 'image', x: PAD, y: PAD, width: (W - PAD * 3) / 2, height: H - PAD * 2, role: 'image' }, 105 + { name: 'content', x: PAD + (W - PAD * 3) / 2 + PAD, y: PAD, width: (W - PAD * 3) / 2, height: H - PAD * 2, role: 'content' }, 106 + ], 107 + }, 108 + { 109 + type: 'imageRight', 110 + label: 'Image Right', 111 + regions: [ 112 + { name: 'content', x: PAD, y: PAD, width: (W - PAD * 3) / 2, height: H - PAD * 2, role: 'content' }, 113 + { name: 'image', x: PAD + (W - PAD * 3) / 2 + PAD, y: PAD, width: (W - PAD * 3) / 2, height: H - PAD * 2, role: 'image' }, 114 + ], 115 + }, 116 + ]; 117 + } 118 + 119 + /** 120 + * Get a specific layout by type. 121 + */ 122 + export function getLayout(type: LayoutType): SlideLayout | undefined { 123 + return getLayouts().find(l => l.type === type); 124 + } 125 + 126 + /** 127 + * Get the built-in themes. 128 + */ 129 + export function getThemes(): Theme[] { 130 + return [ 131 + { 132 + id: 'light', 133 + name: 'Light', 134 + palette: { 135 + primary: '#1a1a2e', 136 + secondary: '#16213e', 137 + background: '#ffffff', 138 + surface: '#f5f5f5', 139 + text: '#1a1a2e', 140 + accent: '#0f3460', 141 + }, 142 + fonts: { heading: 'Inter, sans-serif', body: 'Inter, sans-serif' }, 143 + }, 144 + { 145 + id: 'dark', 146 + name: 'Dark', 147 + palette: { 148 + primary: '#e0e0e0', 149 + secondary: '#b0b0b0', 150 + background: '#1a1a2e', 151 + surface: '#16213e', 152 + text: '#e0e0e0', 153 + accent: '#e94560', 154 + }, 155 + fonts: { heading: 'Inter, sans-serif', body: 'Inter, sans-serif' }, 156 + }, 157 + { 158 + id: 'ocean', 159 + name: 'Ocean', 160 + palette: { 161 + primary: '#0077b6', 162 + secondary: '#00b4d8', 163 + background: '#caf0f8', 164 + surface: '#ade8f4', 165 + text: '#03045e', 166 + accent: '#0077b6', 167 + }, 168 + fonts: { heading: 'Georgia, serif', body: 'Inter, sans-serif' }, 169 + }, 170 + { 171 + id: 'forest', 172 + name: 'Forest', 173 + palette: { 174 + primary: '#2d6a4f', 175 + secondary: '#40916c', 176 + background: '#d8f3dc', 177 + surface: '#b7e4c7', 178 + text: '#1b4332', 179 + accent: '#52b788', 180 + }, 181 + fonts: { heading: 'Georgia, serif', body: 'Inter, sans-serif' }, 182 + }, 183 + { 184 + id: 'sunset', 185 + name: 'Sunset', 186 + palette: { 187 + primary: '#e76f51', 188 + secondary: '#f4a261', 189 + background: '#fefae0', 190 + surface: '#faedcd', 191 + text: '#264653', 192 + accent: '#e76f51', 193 + }, 194 + fonts: { heading: 'Georgia, serif', body: 'Inter, sans-serif' }, 195 + }, 196 + ]; 197 + } 198 + 199 + /** 200 + * Get a specific theme by ID. 201 + */ 202 + export function getTheme(id: string): Theme | undefined { 203 + return getThemes().find(t => t.id === id); 204 + } 205 + 206 + /** 207 + * Create initial themed deck state. 208 + */ 209 + export function createThemedDeck(slideCount: number, themeId = 'light'): ThemedDeck { 210 + return { 211 + themeId, 212 + layouts: Array.from({ length: slideCount }, (_, i) => i === 0 ? 'title' : 'titleContent'), 213 + }; 214 + } 215 + 216 + /** 217 + * Set the layout for a specific slide. 218 + */ 219 + export function setSlideLayout( 220 + deck: ThemedDeck, 221 + slideIndex: number, 222 + layout: LayoutType, 223 + ): ThemedDeck { 224 + if (slideIndex < 0 || slideIndex >= deck.layouts.length) return deck; 225 + const layouts = [...deck.layouts]; 226 + layouts[slideIndex] = layout; 227 + return { ...deck, layouts }; 228 + } 229 + 230 + /** 231 + * Change the theme for the entire deck. 232 + */ 233 + export function setDeckTheme(deck: ThemedDeck, themeId: string): ThemedDeck { 234 + return { ...deck, themeId }; 235 + } 236 + 237 + /** 238 + * Add a slide layout entry (when a slide is added to the deck). 239 + */ 240 + export function addSlideLayout( 241 + deck: ThemedDeck, 242 + index?: number, 243 + layout: LayoutType = 'titleContent', 244 + ): ThemedDeck { 245 + const layouts = [...deck.layouts]; 246 + const pos = index ?? layouts.length; 247 + layouts.splice(pos, 0, layout); 248 + return { ...deck, layouts }; 249 + } 250 + 251 + /** 252 + * Remove a slide layout entry. 253 + */ 254 + export function removeSlideLayout(deck: ThemedDeck, index: number): ThemedDeck { 255 + if (deck.layouts.length <= 1) return deck; 256 + const layouts = deck.layouts.filter((_, i) => i !== index); 257 + return { ...deck, layouts }; 258 + } 259 + 260 + /** 261 + * Generate CSS variables from a theme. 262 + */ 263 + export function themeToCSS(theme: Theme): Record<string, string> { 264 + return { 265 + '--slide-primary': theme.palette.primary, 266 + '--slide-secondary': theme.palette.secondary, 267 + '--slide-bg': theme.palette.background, 268 + '--slide-surface': theme.palette.surface, 269 + '--slide-text': theme.palette.text, 270 + '--slide-accent': theme.palette.accent, 271 + '--slide-font-heading': theme.fonts.heading, 272 + '--slide-font-body': theme.fonts.body, 273 + }; 274 + } 275 + 276 + /** 277 + * Check if a layout type is valid. 278 + */ 279 + export function isValidLayout(type: string): type is LayoutType { 280 + const valid: string[] = ['blank', 'title', 'section', 'twoColumn', 'titleContent', 'imageLeft', 'imageRight']; 281 + return valid.includes(type); 282 + } 283 + 284 + /** 285 + * Get layout region count. 286 + */ 287 + export function regionCount(type: LayoutType): number { 288 + return getLayout(type)?.regions.length ?? 0; 289 + }
+205
src/slides/transitions.ts
··· 1 + /** 2 + * Slide Transitions — CSS-based transition effects between slides. 3 + * 4 + * Pure logic module: transition definitions, CSS generation, timing. 5 + * DOM animation handled by the slides UI layer. 6 + */ 7 + 8 + export type TransitionType = 'none' | 'fade' | 'slideLeft' | 'slideRight' | 'slideUp' | 'slideDown' | 'zoom' | 'zoomOut'; 9 + export type EasingType = 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out'; 10 + 11 + export interface TransitionConfig { 12 + type: TransitionType; 13 + /** Duration in milliseconds */ 14 + duration: number; 15 + easing: EasingType; 16 + } 17 + 18 + export interface SlideTransitions { 19 + /** Default transition for the deck */ 20 + defaultTransition: TransitionConfig; 21 + /** Per-slide overrides (slide index → config) */ 22 + overrides: Map<number, TransitionConfig>; 23 + } 24 + 25 + /** 26 + * Create a default transition config. 27 + */ 28 + export function createTransition( 29 + type: TransitionType = 'fade', 30 + duration = 300, 31 + easing: EasingType = 'ease-in-out', 32 + ): TransitionConfig { 33 + return { type, duration: Math.max(0, Math.min(duration, 5000)), easing }; 34 + } 35 + 36 + /** 37 + * Create the transitions state for a deck. 38 + */ 39 + export function createSlideTransitions( 40 + defaultType: TransitionType = 'fade', 41 + duration = 300, 42 + ): SlideTransitions { 43 + return { 44 + defaultTransition: createTransition(defaultType, duration), 45 + overrides: new Map(), 46 + }; 47 + } 48 + 49 + /** 50 + * Set the default transition for the deck. 51 + */ 52 + export function setDefaultTransition( 53 + state: SlideTransitions, 54 + config: TransitionConfig, 55 + ): SlideTransitions { 56 + return { ...state, defaultTransition: config }; 57 + } 58 + 59 + /** 60 + * Set a transition for a specific slide. 61 + */ 62 + export function setSlideTransition( 63 + state: SlideTransitions, 64 + slideIndex: number, 65 + config: TransitionConfig, 66 + ): SlideTransitions { 67 + const overrides = new Map(state.overrides); 68 + overrides.set(slideIndex, config); 69 + return { ...state, overrides }; 70 + } 71 + 72 + /** 73 + * Remove a per-slide override (reverts to default). 74 + */ 75 + export function removeSlideTransition( 76 + state: SlideTransitions, 77 + slideIndex: number, 78 + ): SlideTransitions { 79 + const overrides = new Map(state.overrides); 80 + overrides.delete(slideIndex); 81 + return { ...state, overrides }; 82 + } 83 + 84 + /** 85 + * Get the effective transition for a slide. 86 + */ 87 + export function getTransitionForSlide( 88 + state: SlideTransitions, 89 + slideIndex: number, 90 + ): TransitionConfig { 91 + return state.overrides.get(slideIndex) ?? state.defaultTransition; 92 + } 93 + 94 + /** 95 + * Generate CSS keyframe name for a transition type. 96 + */ 97 + export function keyframeName(type: TransitionType, direction: 'enter' | 'exit'): string { 98 + if (type === 'none') return ''; 99 + return `slide-${type}-${direction}`; 100 + } 101 + 102 + /** 103 + * Generate the CSS @keyframes for a transition. 104 + */ 105 + export function generateKeyframes(type: TransitionType): { enter: string; exit: string } { 106 + switch (type) { 107 + case 'none': 108 + return { enter: '', exit: '' }; 109 + 110 + case 'fade': 111 + return { 112 + enter: `@keyframes slide-fade-enter { from { opacity: 0; } to { opacity: 1; } }`, 113 + exit: `@keyframes slide-fade-exit { from { opacity: 1; } to { opacity: 0; } }`, 114 + }; 115 + 116 + case 'slideLeft': 117 + return { 118 + enter: `@keyframes slide-slideLeft-enter { from { transform: translateX(100%); } to { transform: translateX(0); } }`, 119 + exit: `@keyframes slide-slideLeft-exit { from { transform: translateX(0); } to { transform: translateX(-100%); } }`, 120 + }; 121 + 122 + case 'slideRight': 123 + return { 124 + enter: `@keyframes slide-slideRight-enter { from { transform: translateX(-100%); } to { transform: translateX(0); } }`, 125 + exit: `@keyframes slide-slideRight-exit { from { transform: translateX(0); } to { transform: translateX(100%); } }`, 126 + }; 127 + 128 + case 'slideUp': 129 + return { 130 + enter: `@keyframes slide-slideUp-enter { from { transform: translateY(100%); } to { transform: translateY(0); } }`, 131 + exit: `@keyframes slide-slideUp-exit { from { transform: translateY(0); } to { transform: translateY(-100%); } }`, 132 + }; 133 + 134 + case 'slideDown': 135 + return { 136 + enter: `@keyframes slide-slideDown-enter { from { transform: translateY(-100%); } to { transform: translateY(0); } }`, 137 + exit: `@keyframes slide-slideDown-exit { from { transform: translateY(0); } to { transform: translateY(100%); } }`, 138 + }; 139 + 140 + case 'zoom': 141 + return { 142 + enter: `@keyframes slide-zoom-enter { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }`, 143 + exit: `@keyframes slide-zoom-exit { from { transform: scale(1); opacity: 1; } to { transform: scale(1.5); opacity: 0; } }`, 144 + }; 145 + 146 + case 'zoomOut': 147 + return { 148 + enter: `@keyframes slide-zoomOut-enter { from { transform: scale(1.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }`, 149 + exit: `@keyframes slide-zoomOut-exit { from { transform: scale(1); opacity: 1; } to { transform: scale(0.5); opacity: 0; } }`, 150 + }; 151 + } 152 + } 153 + 154 + /** 155 + * Generate inline animation CSS for applying a transition. 156 + */ 157 + export function transitionCSS(config: TransitionConfig, direction: 'enter' | 'exit'): string { 158 + if (config.type === 'none') return ''; 159 + const name = keyframeName(config.type, direction); 160 + return `animation: ${name} ${config.duration}ms ${config.easing} both`; 161 + } 162 + 163 + /** 164 + * Get all available transition types. 165 + */ 166 + export function getTransitionTypes(): Array<{ type: TransitionType; label: string }> { 167 + return [ 168 + { type: 'none', label: 'None' }, 169 + { type: 'fade', label: 'Fade' }, 170 + { type: 'slideLeft', label: 'Slide Left' }, 171 + { type: 'slideRight', label: 'Slide Right' }, 172 + { type: 'slideUp', label: 'Slide Up' }, 173 + { type: 'slideDown', label: 'Slide Down' }, 174 + { type: 'zoom', label: 'Zoom In' }, 175 + { type: 'zoomOut', label: 'Zoom Out' }, 176 + ]; 177 + } 178 + 179 + /** 180 + * Check if a transition type is valid. 181 + */ 182 + export function isValidTransition(type: string): type is TransitionType { 183 + const valid: string[] = ['none', 'fade', 'slideLeft', 'slideRight', 'slideUp', 'slideDown', 'zoom', 'zoomOut']; 184 + return valid.includes(type); 185 + } 186 + 187 + /** 188 + * Generate all CSS needed for transitions in a deck. 189 + */ 190 + export function generateDeckCSS(state: SlideTransitions): string { 191 + const types = new Set<TransitionType>(); 192 + types.add(state.defaultTransition.type); 193 + for (const config of state.overrides.values()) { 194 + types.add(config.type); 195 + } 196 + 197 + const blocks: string[] = []; 198 + for (const type of types) { 199 + const kf = generateKeyframes(type); 200 + if (kf.enter) blocks.push(kf.enter); 201 + if (kf.exit) blocks.push(kf.exit); 202 + } 203 + 204 + return blocks.join('\n'); 205 + }
+301
tests/database-views.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createViewConfig, 4 + extractCard, 5 + buildKanbanColumns, 6 + buildGalleryCards, 7 + buildCalendarEvents, 8 + groupEventsByMonth, 9 + eventsForDate, 10 + moveCardToColumn, 11 + getDistinctGroups, 12 + filterByGroup, 13 + countByGroup, 14 + parseCalendarDate, 15 + getViewTypes, 16 + } from '../src/sheets/database-views'; 17 + 18 + // Helper: create a mock getCellValue from a 2D array 19 + function mockGrid(data: string[][]) { 20 + return (row: number, col: number) => data[row]?.[col] ?? ''; 21 + } 22 + 23 + describe('database-views', () => { 24 + describe('createViewConfig', () => { 25 + it('creates default config', () => { 26 + const config = createViewConfig('kanban'); 27 + expect(config.type).toBe('kanban'); 28 + expect(config.groupByColumn).toBe(0); 29 + expect(config.titleColumn).toBe(0); 30 + expect(config.displayColumns).toEqual([]); 31 + expect(config.sortAscending).toBe(true); 32 + }); 33 + 34 + it('accepts custom columns', () => { 35 + const config = createViewConfig('calendar', 2, 1); 36 + expect(config.groupByColumn).toBe(2); 37 + expect(config.titleColumn).toBe(1); 38 + }); 39 + }); 40 + 41 + describe('extractCard', () => { 42 + it('extracts card data from a row', () => { 43 + const grid = mockGrid([ 44 + ['Task A', 'Done', 'Alice'], 45 + ]); 46 + const config = createViewConfig('kanban', 1, 0); 47 + config.displayColumns = [1, 2]; 48 + const card = extractCard(0, grid, config); 49 + expect(card.rowIndex).toBe(0); 50 + expect(card.title).toBe('Task A'); 51 + expect(card.fields).toHaveLength(2); 52 + expect(card.fields[0].value).toBe('Done'); 53 + expect(card.fields[1].value).toBe('Alice'); 54 + }); 55 + }); 56 + 57 + describe('buildKanbanColumns', () => { 58 + const data = [ 59 + ['Task A', 'Todo'], 60 + ['Task B', 'Done'], 61 + ['Task C', 'Todo'], 62 + ['Task D', 'In Progress'], 63 + ]; 64 + const grid = mockGrid(data); 65 + const config = createViewConfig('kanban', 1, 0); 66 + 67 + it('groups rows by column value', () => { 68 + const columns = buildKanbanColumns([0, 1, 2, 3], grid, config); 69 + const groups = columns.map(c => c.groupValue); 70 + expect(groups).toContain('Todo'); 71 + expect(groups).toContain('Done'); 72 + expect(groups).toContain('In Progress'); 73 + }); 74 + 75 + it('has correct card count per group', () => { 76 + const columns = buildKanbanColumns([0, 1, 2, 3], grid, config); 77 + const todo = columns.find(c => c.groupValue === 'Todo')!; 78 + expect(todo.cards).toHaveLength(2); 79 + const done = columns.find(c => c.groupValue === 'Done')!; 80 + expect(done.cards).toHaveLength(1); 81 + }); 82 + 83 + it('uses (empty) for blank values', () => { 84 + const dataWithEmpty = [['Task', ''], ['Task2', 'Done']]; 85 + const g = mockGrid(dataWithEmpty); 86 + const columns = buildKanbanColumns([0, 1], g, config); 87 + expect(columns.some(c => c.groupValue === '(empty)')).toBe(true); 88 + }); 89 + 90 + it('sorts cards within groups', () => { 91 + const sortedData = [ 92 + ['Charlie', 'Team A'], 93 + ['Alice', 'Team A'], 94 + ['Bob', 'Team A'], 95 + ]; 96 + const g = mockGrid(sortedData); 97 + const cfg = createViewConfig('kanban', 1, 0); 98 + const columns = buildKanbanColumns([0, 1, 2], g, cfg); 99 + const teamA = columns.find(c => c.groupValue === 'Team A')!; 100 + expect(teamA.cards[0].title).toBe('Alice'); 101 + expect(teamA.cards[1].title).toBe('Bob'); 102 + expect(teamA.cards[2].title).toBe('Charlie'); 103 + }); 104 + }); 105 + 106 + describe('buildGalleryCards', () => { 107 + it('builds cards for all rows without grouping', () => { 108 + const data = [['Card A'], ['Card B'], ['Card C']]; 109 + const grid = mockGrid(data); 110 + const config = createViewConfig('gallery', 0, 0); 111 + const cards = buildGalleryCards([0, 1, 2], grid, config); 112 + expect(cards).toHaveLength(3); 113 + }); 114 + 115 + it('sorts cards', () => { 116 + const data = [['Zebra'], ['Apple'], ['Mango']]; 117 + const grid = mockGrid(data); 118 + const config = createViewConfig('gallery', 0, 0); 119 + const cards = buildGalleryCards([0, 1, 2], grid, config); 120 + expect(cards[0].title).toBe('Apple'); 121 + expect(cards[2].title).toBe('Zebra'); 122 + }); 123 + }); 124 + 125 + describe('buildCalendarEvents', () => { 126 + it('extracts events with valid dates', () => { 127 + const data = [ 128 + ['Meeting', '2026-03-20'], 129 + ['Lunch', '2026-03-21'], 130 + ['Invalid', 'not-a-date'], 131 + ]; 132 + const grid = mockGrid(data); 133 + const config = createViewConfig('calendar', 1, 0); 134 + const events = buildCalendarEvents([0, 1, 2], grid, config); 135 + expect(events).toHaveLength(2); 136 + }); 137 + 138 + it('sorts events by date', () => { 139 + const data = [ 140 + ['Later', '2026-04-01'], 141 + ['Earlier', '2026-03-01'], 142 + ]; 143 + const grid = mockGrid(data); 144 + const config = createViewConfig('calendar', 1, 0); 145 + const events = buildCalendarEvents([0, 1], grid, config); 146 + expect(events[0].title).toBe('Earlier'); 147 + expect(events[1].title).toBe('Later'); 148 + }); 149 + 150 + it('includes display fields', () => { 151 + const data = [['Meeting', '2026-03-20', 'Room A']]; 152 + const grid = mockGrid(data); 153 + const config = createViewConfig('calendar', 1, 0); 154 + config.displayColumns = [2]; 155 + const events = buildCalendarEvents([0], grid, config); 156 + expect(events[0].fields[0].value).toBe('Room A'); 157 + }); 158 + }); 159 + 160 + describe('groupEventsByMonth', () => { 161 + it('groups events by year-month', () => { 162 + const events = [ 163 + { rowIndex: 0, title: 'A', date: '2026-03-15', fields: [] }, 164 + { rowIndex: 1, title: 'B', date: '2026-03-20', fields: [] }, 165 + { rowIndex: 2, title: 'C', date: '2026-04-05', fields: [] }, 166 + ]; 167 + const months = groupEventsByMonth(events); 168 + expect(months).toHaveLength(2); 169 + expect(months[0].year).toBe(2026); 170 + expect(months[0].month).toBe(2); // 0-indexed, March = 2 171 + expect(months[0].events).toHaveLength(2); 172 + expect(months[1].month).toBe(3); // April = 3 173 + }); 174 + 175 + it('sorts by year then month', () => { 176 + const events = [ 177 + { rowIndex: 0, title: 'A', date: '2027-01-01', fields: [] }, 178 + { rowIndex: 1, title: 'B', date: '2026-12-01', fields: [] }, 179 + ]; 180 + const months = groupEventsByMonth(events); 181 + expect(months[0].year).toBe(2026); 182 + expect(months[1].year).toBe(2027); 183 + }); 184 + }); 185 + 186 + describe('eventsForDate', () => { 187 + it('filters events by exact date', () => { 188 + const events = [ 189 + { rowIndex: 0, title: 'A', date: '2026-03-20', fields: [] }, 190 + { rowIndex: 1, title: 'B', date: '2026-03-21', fields: [] }, 191 + { rowIndex: 2, title: 'C', date: '2026-03-20', fields: [] }, 192 + ]; 193 + const result = eventsForDate(events, '2026-03-20'); 194 + expect(result).toHaveLength(2); 195 + }); 196 + 197 + it('returns empty for no matches', () => { 198 + expect(eventsForDate([], '2026-01-01')).toHaveLength(0); 199 + }); 200 + }); 201 + 202 + describe('moveCardToColumn', () => { 203 + it('returns the target group value', () => { 204 + const card = { rowIndex: 0, title: 'Task', fields: [] }; 205 + expect(moveCardToColumn(card, 'Done')).toBe('Done'); 206 + }); 207 + 208 + it('returns empty string for (empty) group', () => { 209 + const card = { rowIndex: 0, title: 'Task', fields: [] }; 210 + expect(moveCardToColumn(card, '(empty)')).toBe(''); 211 + }); 212 + }); 213 + 214 + describe('getDistinctGroups', () => { 215 + it('returns distinct values', () => { 216 + const data = [['A'], ['B'], ['A'], ['C']]; 217 + const grid = mockGrid(data); 218 + const groups = getDistinctGroups([0, 1, 2, 3], grid, 0); 219 + expect(groups).toHaveLength(3); 220 + expect(groups).toContain('A'); 221 + expect(groups).toContain('B'); 222 + expect(groups).toContain('C'); 223 + }); 224 + 225 + it('uses (empty) for blank cells', () => { 226 + const data = [['A'], ['']]; 227 + const grid = mockGrid(data); 228 + const groups = getDistinctGroups([0, 1], grid, 0); 229 + expect(groups).toContain('(empty)'); 230 + }); 231 + }); 232 + 233 + describe('filterByGroup', () => { 234 + it('filters rows by group value', () => { 235 + const data = [['A'], ['B'], ['A']]; 236 + const grid = mockGrid(data); 237 + const rows = filterByGroup([0, 1, 2], grid, 0, 'A'); 238 + expect(rows).toEqual([0, 2]); 239 + }); 240 + 241 + it('filters by (empty)', () => { 242 + const data = [['A'], ['']]; 243 + const grid = mockGrid(data); 244 + const rows = filterByGroup([0, 1], grid, 0, '(empty)'); 245 + expect(rows).toEqual([1]); 246 + }); 247 + }); 248 + 249 + describe('countByGroup', () => { 250 + it('counts cards per kanban column', () => { 251 + const columns = [ 252 + { groupValue: 'Todo', cards: [{ rowIndex: 0, title: 'A', fields: [] }, { rowIndex: 1, title: 'B', fields: [] }] }, 253 + { groupValue: 'Done', cards: [{ rowIndex: 2, title: 'C', fields: [] }] }, 254 + ]; 255 + const counts = countByGroup(columns); 256 + expect(counts.get('Todo')).toBe(2); 257 + expect(counts.get('Done')).toBe(1); 258 + }); 259 + }); 260 + 261 + describe('parseCalendarDate', () => { 262 + it('parses ISO dates', () => { 263 + expect(parseCalendarDate('2026-03-20')).toBe('2026-03-20'); 264 + }); 265 + 266 + it('parses ISO datetime (ignores time)', () => { 267 + expect(parseCalendarDate('2026-03-20T10:30:00')).toBe('2026-03-20'); 268 + }); 269 + 270 + it('parses US date format', () => { 271 + expect(parseCalendarDate('3/20/2026')).toBe('2026-03-20'); 272 + }); 273 + 274 + it('pads single-digit month/day in US format', () => { 275 + expect(parseCalendarDate('1/5/2026')).toBe('2026-01-05'); 276 + }); 277 + 278 + it('returns null for empty string', () => { 279 + expect(parseCalendarDate('')).toBeNull(); 280 + }); 281 + 282 + it('returns null for non-date strings', () => { 283 + expect(parseCalendarDate('hello')).toBeNull(); 284 + expect(parseCalendarDate('abc-de-fg')).toBeNull(); 285 + }); 286 + }); 287 + 288 + describe('getViewTypes', () => { 289 + it('returns 4 view types', () => { 290 + expect(getViewTypes()).toHaveLength(4); 291 + }); 292 + 293 + it('includes all expected types', () => { 294 + const types = getViewTypes().map(v => v.type); 295 + expect(types).toContain('table'); 296 + expect(types).toContain('kanban'); 297 + expect(types).toContain('gallery'); 298 + expect(types).toContain('calendar'); 299 + }); 300 + }); 301 + });
+241
tests/layouts-themes.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + getLayouts, 4 + getLayout, 5 + getThemes, 6 + getTheme, 7 + createThemedDeck, 8 + setSlideLayout, 9 + setDeckTheme, 10 + addSlideLayout, 11 + removeSlideLayout, 12 + themeToCSS, 13 + isValidLayout, 14 + regionCount, 15 + } from '../src/slides/layouts-themes'; 16 + 17 + describe('layouts-themes', () => { 18 + describe('getLayouts', () => { 19 + it('returns 7 built-in layouts', () => { 20 + expect(getLayouts()).toHaveLength(7); 21 + }); 22 + 23 + it('includes all expected types', () => { 24 + const types = getLayouts().map(l => l.type); 25 + expect(types).toContain('blank'); 26 + expect(types).toContain('title'); 27 + expect(types).toContain('section'); 28 + expect(types).toContain('twoColumn'); 29 + expect(types).toContain('titleContent'); 30 + expect(types).toContain('imageLeft'); 31 + expect(types).toContain('imageRight'); 32 + }); 33 + 34 + it('blank layout has no regions', () => { 35 + const blank = getLayouts().find(l => l.type === 'blank')!; 36 + expect(blank.regions).toHaveLength(0); 37 + }); 38 + 39 + it('title layout has title and subtitle regions', () => { 40 + const title = getLayouts().find(l => l.type === 'title')!; 41 + expect(title.regions).toHaveLength(2); 42 + expect(title.regions[0].role).toBe('title'); 43 + expect(title.regions[1].role).toBe('subtitle'); 44 + }); 45 + 46 + it('twoColumn layout has 3 regions', () => { 47 + const tc = getLayouts().find(l => l.type === 'twoColumn')!; 48 + expect(tc.regions).toHaveLength(3); 49 + }); 50 + 51 + it('all regions have positive dimensions', () => { 52 + for (const layout of getLayouts()) { 53 + for (const region of layout.regions) { 54 + expect(region.width).toBeGreaterThan(0); 55 + expect(region.height).toBeGreaterThan(0); 56 + expect(region.x).toBeGreaterThanOrEqual(0); 57 + expect(region.y).toBeGreaterThanOrEqual(0); 58 + } 59 + } 60 + }); 61 + }); 62 + 63 + describe('getLayout', () => { 64 + it('returns a specific layout', () => { 65 + const layout = getLayout('title'); 66 + expect(layout).toBeDefined(); 67 + expect(layout!.type).toBe('title'); 68 + }); 69 + 70 + it('returns undefined for unknown type', () => { 71 + expect(getLayout('nonexistent' as any)).toBeUndefined(); 72 + }); 73 + }); 74 + 75 + describe('getThemes', () => { 76 + it('returns 5 built-in themes', () => { 77 + expect(getThemes()).toHaveLength(5); 78 + }); 79 + 80 + it('includes light and dark themes', () => { 81 + const ids = getThemes().map(t => t.id); 82 + expect(ids).toContain('light'); 83 + expect(ids).toContain('dark'); 84 + }); 85 + 86 + it('all themes have required palette properties', () => { 87 + for (const theme of getThemes()) { 88 + expect(theme.palette.primary).toBeDefined(); 89 + expect(theme.palette.secondary).toBeDefined(); 90 + expect(theme.palette.background).toBeDefined(); 91 + expect(theme.palette.surface).toBeDefined(); 92 + expect(theme.palette.text).toBeDefined(); 93 + expect(theme.palette.accent).toBeDefined(); 94 + } 95 + }); 96 + 97 + it('all themes have font definitions', () => { 98 + for (const theme of getThemes()) { 99 + expect(theme.fonts.heading).toBeTruthy(); 100 + expect(theme.fonts.body).toBeTruthy(); 101 + } 102 + }); 103 + }); 104 + 105 + describe('getTheme', () => { 106 + it('returns a specific theme', () => { 107 + const theme = getTheme('ocean'); 108 + expect(theme).toBeDefined(); 109 + expect(theme!.name).toBe('Ocean'); 110 + }); 111 + 112 + it('returns undefined for unknown ID', () => { 113 + expect(getTheme('nonexistent')).toBeUndefined(); 114 + }); 115 + }); 116 + 117 + describe('createThemedDeck', () => { 118 + it('creates a deck with default light theme', () => { 119 + const deck = createThemedDeck(5); 120 + expect(deck.themeId).toBe('light'); 121 + expect(deck.layouts).toHaveLength(5); 122 + }); 123 + 124 + it('first slide is title layout, rest are titleContent', () => { 125 + const deck = createThemedDeck(3); 126 + expect(deck.layouts[0]).toBe('title'); 127 + expect(deck.layouts[1]).toBe('titleContent'); 128 + expect(deck.layouts[2]).toBe('titleContent'); 129 + }); 130 + 131 + it('accepts custom theme ID', () => { 132 + const deck = createThemedDeck(1, 'dark'); 133 + expect(deck.themeId).toBe('dark'); 134 + }); 135 + }); 136 + 137 + describe('setSlideLayout', () => { 138 + it('changes a slide layout', () => { 139 + const deck = createThemedDeck(3); 140 + const updated = setSlideLayout(deck, 1, 'twoColumn'); 141 + expect(updated.layouts[1]).toBe('twoColumn'); 142 + }); 143 + 144 + it('does not mutate original', () => { 145 + const deck = createThemedDeck(3); 146 + setSlideLayout(deck, 1, 'blank'); 147 + expect(deck.layouts[1]).toBe('titleContent'); 148 + }); 149 + 150 + it('returns unchanged for out-of-bounds index', () => { 151 + const deck = createThemedDeck(3); 152 + expect(setSlideLayout(deck, -1, 'blank')).toBe(deck); 153 + expect(setSlideLayout(deck, 10, 'blank')).toBe(deck); 154 + }); 155 + }); 156 + 157 + describe('setDeckTheme', () => { 158 + it('changes the theme ID', () => { 159 + const deck = createThemedDeck(1); 160 + const updated = setDeckTheme(deck, 'forest'); 161 + expect(updated.themeId).toBe('forest'); 162 + }); 163 + }); 164 + 165 + describe('addSlideLayout', () => { 166 + it('adds a layout at the end by default', () => { 167 + const deck = createThemedDeck(2); 168 + const updated = addSlideLayout(deck); 169 + expect(updated.layouts).toHaveLength(3); 170 + expect(updated.layouts[2]).toBe('titleContent'); 171 + }); 172 + 173 + it('adds at specific position', () => { 174 + const deck = createThemedDeck(2); 175 + const updated = addSlideLayout(deck, 1, 'section'); 176 + expect(updated.layouts).toHaveLength(3); 177 + expect(updated.layouts[1]).toBe('section'); 178 + }); 179 + }); 180 + 181 + describe('removeSlideLayout', () => { 182 + it('removes a layout by index', () => { 183 + const deck = createThemedDeck(3); 184 + const updated = removeSlideLayout(deck, 1); 185 + expect(updated.layouts).toHaveLength(2); 186 + }); 187 + 188 + it('does not remove the last layout', () => { 189 + const deck = createThemedDeck(1); 190 + const updated = removeSlideLayout(deck, 0); 191 + expect(updated.layouts).toHaveLength(1); 192 + }); 193 + }); 194 + 195 + describe('themeToCSS', () => { 196 + it('generates CSS custom properties', () => { 197 + const theme = getTheme('light')!; 198 + const css = themeToCSS(theme); 199 + expect(css['--slide-bg']).toBe('#ffffff'); 200 + expect(css['--slide-text']).toBe('#1a1a2e'); 201 + expect(css['--slide-font-heading']).toBe('Inter, sans-serif'); 202 + }); 203 + 204 + it('includes all 8 CSS variables', () => { 205 + const theme = getTheme('dark')!; 206 + const css = themeToCSS(theme); 207 + expect(Object.keys(css)).toHaveLength(8); 208 + }); 209 + }); 210 + 211 + describe('isValidLayout', () => { 212 + it('returns true for valid layout types', () => { 213 + expect(isValidLayout('blank')).toBe(true); 214 + expect(isValidLayout('title')).toBe(true); 215 + expect(isValidLayout('twoColumn')).toBe(true); 216 + }); 217 + 218 + it('returns false for invalid types', () => { 219 + expect(isValidLayout('unknown')).toBe(false); 220 + expect(isValidLayout('')).toBe(false); 221 + }); 222 + }); 223 + 224 + describe('regionCount', () => { 225 + it('returns 0 for blank', () => { 226 + expect(regionCount('blank')).toBe(0); 227 + }); 228 + 229 + it('returns 2 for title', () => { 230 + expect(regionCount('title')).toBe(2); 231 + }); 232 + 233 + it('returns 3 for twoColumn', () => { 234 + expect(regionCount('twoColumn')).toBe(3); 235 + }); 236 + 237 + it('returns 2 for titleContent', () => { 238 + expect(regionCount('titleContent')).toBe(2); 239 + }); 240 + }); 241 + });
+232
tests/transitions.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createTransition, 4 + createSlideTransitions, 5 + setDefaultTransition, 6 + setSlideTransition, 7 + removeSlideTransition, 8 + getTransitionForSlide, 9 + keyframeName, 10 + generateKeyframes, 11 + transitionCSS, 12 + getTransitionTypes, 13 + isValidTransition, 14 + generateDeckCSS, 15 + } from '../src/slides/transitions'; 16 + 17 + describe('transitions', () => { 18 + describe('createTransition', () => { 19 + it('creates default fade transition', () => { 20 + const t = createTransition(); 21 + expect(t.type).toBe('fade'); 22 + expect(t.duration).toBe(300); 23 + expect(t.easing).toBe('ease-in-out'); 24 + }); 25 + 26 + it('accepts custom values', () => { 27 + const t = createTransition('zoom', 500, 'ease'); 28 + expect(t.type).toBe('zoom'); 29 + expect(t.duration).toBe(500); 30 + expect(t.easing).toBe('ease'); 31 + }); 32 + 33 + it('clamps duration to 0-5000', () => { 34 + expect(createTransition('fade', -100).duration).toBe(0); 35 + expect(createTransition('fade', 10000).duration).toBe(5000); 36 + }); 37 + }); 38 + 39 + describe('createSlideTransitions', () => { 40 + it('creates state with default transition', () => { 41 + const state = createSlideTransitions(); 42 + expect(state.defaultTransition.type).toBe('fade'); 43 + expect(state.overrides.size).toBe(0); 44 + }); 45 + 46 + it('accepts custom default type and duration', () => { 47 + const state = createSlideTransitions('slideLeft', 500); 48 + expect(state.defaultTransition.type).toBe('slideLeft'); 49 + expect(state.defaultTransition.duration).toBe(500); 50 + }); 51 + }); 52 + 53 + describe('setDefaultTransition', () => { 54 + it('changes the default transition', () => { 55 + const state = createSlideTransitions(); 56 + const updated = setDefaultTransition(state, createTransition('zoom', 400)); 57 + expect(updated.defaultTransition.type).toBe('zoom'); 58 + expect(updated.defaultTransition.duration).toBe(400); 59 + }); 60 + }); 61 + 62 + describe('setSlideTransition / removeSlideTransition', () => { 63 + it('sets a per-slide override', () => { 64 + const state = createSlideTransitions(); 65 + const updated = setSlideTransition(state, 3, createTransition('slideLeft')); 66 + expect(updated.overrides.get(3)!.type).toBe('slideLeft'); 67 + }); 68 + 69 + it('removes a per-slide override', () => { 70 + let state = createSlideTransitions(); 71 + state = setSlideTransition(state, 3, createTransition('slideLeft')); 72 + const updated = removeSlideTransition(state, 3); 73 + expect(updated.overrides.has(3)).toBe(false); 74 + }); 75 + 76 + it('does not mutate original', () => { 77 + const state = createSlideTransitions(); 78 + setSlideTransition(state, 0, createTransition('zoom')); 79 + expect(state.overrides.size).toBe(0); 80 + }); 81 + }); 82 + 83 + describe('getTransitionForSlide', () => { 84 + it('returns default when no override', () => { 85 + const state = createSlideTransitions('fade', 300); 86 + expect(getTransitionForSlide(state, 5).type).toBe('fade'); 87 + }); 88 + 89 + it('returns override when set', () => { 90 + let state = createSlideTransitions(); 91 + state = setSlideTransition(state, 2, createTransition('zoom')); 92 + expect(getTransitionForSlide(state, 2).type).toBe('zoom'); 93 + expect(getTransitionForSlide(state, 3).type).toBe('fade'); 94 + }); 95 + }); 96 + 97 + describe('keyframeName', () => { 98 + it('generates enter/exit names', () => { 99 + expect(keyframeName('fade', 'enter')).toBe('slide-fade-enter'); 100 + expect(keyframeName('fade', 'exit')).toBe('slide-fade-exit'); 101 + expect(keyframeName('slideLeft', 'enter')).toBe('slide-slideLeft-enter'); 102 + }); 103 + 104 + it('returns empty string for none', () => { 105 + expect(keyframeName('none', 'enter')).toBe(''); 106 + expect(keyframeName('none', 'exit')).toBe(''); 107 + }); 108 + }); 109 + 110 + describe('generateKeyframes', () => { 111 + it('generates empty strings for none', () => { 112 + const kf = generateKeyframes('none'); 113 + expect(kf.enter).toBe(''); 114 + expect(kf.exit).toBe(''); 115 + }); 116 + 117 + it('generates opacity keyframes for fade', () => { 118 + const kf = generateKeyframes('fade'); 119 + expect(kf.enter).toContain('opacity'); 120 + expect(kf.exit).toContain('opacity'); 121 + }); 122 + 123 + it('generates translateX keyframes for slideLeft', () => { 124 + const kf = generateKeyframes('slideLeft'); 125 + expect(kf.enter).toContain('translateX(100%)'); 126 + expect(kf.exit).toContain('translateX(-100%)'); 127 + }); 128 + 129 + it('generates translateX keyframes for slideRight', () => { 130 + const kf = generateKeyframes('slideRight'); 131 + expect(kf.enter).toContain('translateX(-100%)'); 132 + expect(kf.exit).toContain('translateX(100%)'); 133 + }); 134 + 135 + it('generates translateY for slideUp', () => { 136 + const kf = generateKeyframes('slideUp'); 137 + expect(kf.enter).toContain('translateY(100%)'); 138 + expect(kf.exit).toContain('translateY(-100%)'); 139 + }); 140 + 141 + it('generates translateY for slideDown', () => { 142 + const kf = generateKeyframes('slideDown'); 143 + expect(kf.enter).toContain('translateY(-100%)'); 144 + expect(kf.exit).toContain('translateY(100%)'); 145 + }); 146 + 147 + it('generates scale keyframes for zoom', () => { 148 + const kf = generateKeyframes('zoom'); 149 + expect(kf.enter).toContain('scale(0.5)'); 150 + expect(kf.exit).toContain('scale(1.5)'); 151 + }); 152 + 153 + it('generates scale keyframes for zoomOut', () => { 154 + const kf = generateKeyframes('zoomOut'); 155 + expect(kf.enter).toContain('scale(1.5)'); 156 + expect(kf.exit).toContain('scale(0.5)'); 157 + }); 158 + }); 159 + 160 + describe('transitionCSS', () => { 161 + it('generates animation CSS', () => { 162 + const config = createTransition('fade', 300, 'ease-in-out'); 163 + const css = transitionCSS(config, 'enter'); 164 + expect(css).toBe('animation: slide-fade-enter 300ms ease-in-out both'); 165 + }); 166 + 167 + it('returns empty string for none', () => { 168 + const config = createTransition('none'); 169 + expect(transitionCSS(config, 'enter')).toBe(''); 170 + }); 171 + 172 + it('uses correct direction', () => { 173 + const config = createTransition('slideLeft', 500, 'ease'); 174 + expect(transitionCSS(config, 'exit')).toContain('slide-slideLeft-exit'); 175 + }); 176 + }); 177 + 178 + describe('getTransitionTypes', () => { 179 + it('returns 8 transition types', () => { 180 + expect(getTransitionTypes()).toHaveLength(8); 181 + }); 182 + 183 + it('includes none and fade', () => { 184 + const types = getTransitionTypes().map(t => t.type); 185 + expect(types).toContain('none'); 186 + expect(types).toContain('fade'); 187 + }); 188 + 189 + it('all entries have labels', () => { 190 + for (const t of getTransitionTypes()) { 191 + expect(t.label).toBeTruthy(); 192 + } 193 + }); 194 + }); 195 + 196 + describe('isValidTransition', () => { 197 + it('returns true for valid types', () => { 198 + expect(isValidTransition('fade')).toBe(true); 199 + expect(isValidTransition('none')).toBe(true); 200 + expect(isValidTransition('zoom')).toBe(true); 201 + }); 202 + 203 + it('returns false for invalid types', () => { 204 + expect(isValidTransition('spin')).toBe(false); 205 + expect(isValidTransition('')).toBe(false); 206 + }); 207 + }); 208 + 209 + describe('generateDeckCSS', () => { 210 + it('generates CSS for used transitions only', () => { 211 + let state = createSlideTransitions('fade'); 212 + state = setSlideTransition(state, 0, createTransition('zoom')); 213 + const css = generateDeckCSS(state); 214 + expect(css).toContain('slide-fade-enter'); 215 + expect(css).toContain('slide-zoom-enter'); 216 + expect(css).not.toContain('slideLeft'); 217 + }); 218 + 219 + it('generates empty for none-only deck', () => { 220 + const state = createSlideTransitions('none'); 221 + expect(generateDeckCSS(state)).toBe(''); 222 + }); 223 + 224 + it('deduplicates transitions', () => { 225 + let state = createSlideTransitions('fade'); 226 + state = setSlideTransition(state, 0, createTransition('fade', 500)); 227 + const css = generateDeckCSS(state); 228 + const fadeCount = (css.match(/slide-fade-enter/g) || []).length; 229 + expect(fadeCount).toBe(1); // only one set of keyframes 230 + }); 231 + }); 232 + });