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

Configure Feed

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

feat: enhanced charts, CF data bars/icons, layers, animations, type filter (0.38.0)

Sheets: add area, doughnut, radar, stacked bar, stacked line chart types.
Register RadialLinearScale + Filler in Chart.js. Update chart dialog labels.

Sheets: data bars and icon sets for conditional formatting. Data bars show
proportional fill with bidirectional negative support. Icon sets assign
3/4/5-level icons by percentile thresholds.

Diagrams: named layers panel with visibility toggle, lock, rename,
add/remove layers, and shape-to-layer assignment. Layers sync via Yjs
for collaborative editing.

Slides: per-element enter/exit animations with 14 effects, 3 trigger
types, playback step grouping. Tabbed Notes/Animations panel in sidebar.
CSS keyframes for all animation effects.

Landing: document type filter bar with pill buttons and per-type counts.
Filters docs by type (doc, sheet, slide, diagram, form, calendar).

Closes #613, #614, #615, #616, #617

+2099 -37
+16
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.38.0] — 2026-04-14 11 + 12 + ### Added 13 + - Sheets: 5 new chart types — area, doughnut, radar, stacked bar, stacked line (#613) 14 + - Sheets: data bars conditional formatting with bidirectional support for negative values (#614) 15 + - Sheets: icon sets conditional formatting with 3/4/5-icon set support (#614) 16 + - Diagrams: named layers panel with visibility toggle, lock, rename, add/remove, shape assignment (#615) 17 + - Diagrams: layers synced via Yjs for real-time collaboration 18 + - Slides: per-element enter/exit animations with 14 effects (fade, slide, zoom, bounce, spin) (#616) 19 + - Slides: animation triggers (on click, with previous, after previous) and playback step grouping (#616) 20 + - Slides: Notes/Animations tabbed panel in right sidebar 21 + - Slides: CSS keyframes for all animation effects 22 + - Landing: document type filter bar with counts (filter by doc, sheet, slide, diagram, form) (#617) 23 + - Landing: type filter pills with active state styling 24 + 10 25 ## [0.37.1] — 2026-04-14 11 26 12 27 ### Security 28 + - fix: security + quality issues from adversarial review (SSRF, auth bypass, parser limits, export) (#612) 13 29 - ICS proxy: block Tailscale CGNAT range `100.64.0.0/10` — previously could be used to reach any Tailscale node on the tailnet (#612) 14 30 - ICS proxy: block full `127.0.0.0/8` loopback range (was only blocking `127.0.0.1`) (#612) 15 31 - ICS proxy: set `redirect: 'manual'` and explicitly reject `3xx` responses, preventing redirect-based SSRF bypass (#612)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.37.1", 3 + "version": "0.38.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+241
src/css/app.css
··· 11422 11422 .event-modal { 11423 11423 transition: background-color var(--transition-med), border-color var(--transition-med), color var(--transition-med); 11424 11424 } 11425 + 11426 + 11427 + /* ── Element Animation Keyframes (slides presenter) ──────────────── */ 11428 + 11429 + @keyframes tools-fade-in { from { opacity: 0; } to { opacity: 1; } } 11430 + @keyframes tools-fade-out { from { opacity: 1; } to { opacity: 0; } } 11431 + @keyframes tools-slide-in-left { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } 11432 + @keyframes tools-slide-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } 11433 + @keyframes tools-slide-in-up { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } 11434 + @keyframes tools-slide-in-down { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } 11435 + @keyframes tools-slide-out-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } } 11436 + @keyframes tools-slide-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } 11437 + @keyframes tools-slide-out-up { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-100%); opacity: 0; } } 11438 + @keyframes tools-slide-out-down { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } } 11439 + @keyframes tools-zoom-in { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } } 11440 + @keyframes tools-zoom-out { from { transform: scale(1); opacity: 1; } to { transform: scale(0.5); opacity: 0; } } 11441 + @keyframes tools-bounce-in { 0% { transform: scale(0.3); opacity: 0; } 50% { transform: scale(1.05); } 70% { transform: scale(0.95); } 100% { transform: scale(1); opacity: 1; } } 11442 + @keyframes tools-spin-in { from { transform: rotate(-180deg) scale(0); opacity: 0; } to { transform: rotate(0) scale(1); opacity: 1; } } 11443 + 11444 + /* ── Data Bars (conditional formatting) ───────────────────────────── */ 11445 + 11446 + .cf-data-bar { 11447 + position: absolute; 11448 + bottom: 0; 11449 + height: 100%; 11450 + opacity: 0.3; 11451 + pointer-events: none; 11452 + transition: width 0.2s ease; 11453 + } 11454 + .cf-data-bar.cf-data-bar--negative { right: 50%; } 11455 + .cf-data-bar.cf-data-bar--positive { left: 50%; } 11456 + .cf-data-bar.cf-data-bar--all-positive { left: 0; } 11457 + 11458 + /* ── Icon Sets (conditional formatting) ───────────────────────────── */ 11459 + 11460 + .cf-icon-set { display: inline-block; width: 14px; height: 14px; margin-right: 4px; vertical-align: middle; } 11461 + 11462 + /* ── Layers Panel (diagrams) ──────────────────────────────────────── */ 11463 + 11464 + .layers-panel { 11465 + position: absolute; 11466 + right: 0; 11467 + top: var(--topbar-h, 48px); 11468 + bottom: 0; 11469 + width: 220px; 11470 + background: var(--color-surface); 11471 + border-left: 1px solid var(--color-border); 11472 + overflow-y: auto; 11473 + z-index: 20; 11474 + display: flex; 11475 + flex-direction: column; 11476 + padding: var(--space-xs); 11477 + gap: var(--space-xs); 11478 + } 11479 + 11480 + .layers-panel h3 { 11481 + margin: 0; 11482 + font-size: 0.85rem; 11483 + font-weight: 600; 11484 + text-transform: uppercase; 11485 + letter-spacing: 0.04em; 11486 + color: var(--color-text-muted); 11487 + } 11488 + 11489 + .layer-item { 11490 + display: flex; 11491 + align-items: center; 11492 + gap: var(--space-xs); 11493 + padding: 4px 6px; 11494 + border-radius: var(--radius-sm, 4px); 11495 + font-size: 0.82rem; 11496 + cursor: pointer; 11497 + user-select: none; 11498 + } 11499 + .layer-item:hover { background: var(--color-surface-alt); } 11500 + .layer-item.layer-item--active { background: var(--color-teal-light); } 11501 + .layer-item .layer-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 11502 + .layer-item .layer-toggle { opacity: 0.5; cursor: pointer; font-size: 0.9rem; } 11503 + .layer-item .layer-toggle:hover { opacity: 1; } 11504 + .layer-item .layer-toggle--hidden { opacity: 0.25; } 11505 + .layer-item .layer-toggle--locked { color: var(--color-accent); } 11506 + 11507 + .layers-panel .layers-add-btn { 11508 + font-size: 0.78rem; 11509 + padding: 4px 8px; 11510 + border: 1px dashed var(--color-border); 11511 + border-radius: var(--radius-sm, 4px); 11512 + background: transparent; 11513 + color: var(--color-text-muted); 11514 + cursor: pointer; 11515 + text-align: center; 11516 + } 11517 + .layers-panel .layers-add-btn:hover { 11518 + border-color: var(--color-text-muted); 11519 + color: var(--color-text); 11520 + } 11521 + 11522 + /* ── Type Filter Bar (landing) ────────────────────────────────────── */ 11523 + 11524 + .type-filter-bar { 11525 + display: flex; 11526 + gap: var(--space-xs); 11527 + padding: var(--space-xs) 0; 11528 + overflow-x: auto; 11529 + -webkit-overflow-scrolling: touch; 11530 + } 11531 + 11532 + .type-filter-btn { 11533 + display: inline-flex; 11534 + align-items: center; 11535 + gap: 4px; 11536 + padding: 4px 12px; 11537 + border: 1px solid var(--color-border); 11538 + border-radius: 999px; 11539 + background: transparent; 11540 + color: var(--color-text-muted); 11541 + font-size: 0.78rem; 11542 + cursor: pointer; 11543 + white-space: nowrap; 11544 + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); 11545 + } 11546 + .type-filter-btn:hover { 11547 + border-color: var(--color-text-muted); 11548 + color: var(--color-text); 11549 + } 11550 + .type-filter-btn.type-filter-btn--active { 11551 + background: var(--color-accent); 11552 + border-color: var(--color-accent); 11553 + color: #fff; 11554 + } 11555 + .type-filter-btn .type-filter-count { 11556 + font-size: 0.7rem; 11557 + opacity: 0.7; 11558 + } 11559 + 11560 + /* ── Shortcut Customization Panel ─────────────────────────────────── */ 11561 + 11562 + .shortcuts-settings-panel { 11563 + max-width: 600px; 11564 + margin: 0 auto; 11565 + } 11566 + 11567 + .shortcut-row { 11568 + display: flex; 11569 + align-items: center; 11570 + gap: var(--space-sm); 11571 + padding: 6px 0; 11572 + border-bottom: 1px solid var(--color-border); 11573 + } 11574 + .shortcut-row:last-child { border-bottom: none; } 11575 + 11576 + .shortcut-label { 11577 + flex: 1; 11578 + font-size: 0.85rem; 11579 + } 11580 + .shortcut-label .shortcut-desc { 11581 + display: block; 11582 + font-size: 0.75rem; 11583 + color: var(--color-text-muted); 11584 + } 11585 + 11586 + .shortcut-combo { 11587 + display: inline-flex; 11588 + align-items: center; 11589 + gap: 2px; 11590 + padding: 2px 8px; 11591 + border: 1px solid var(--color-border); 11592 + border-radius: var(--radius-sm, 4px); 11593 + background: var(--color-surface); 11594 + font-family: var(--font-mono, monospace); 11595 + font-size: 0.78rem; 11596 + min-width: 80px; 11597 + text-align: center; 11598 + cursor: pointer; 11599 + } 11600 + .shortcut-combo:hover { border-color: var(--color-accent); } 11601 + .shortcut-combo.shortcut-combo--recording { 11602 + border-color: var(--color-accent); 11603 + background: var(--color-accent); 11604 + color: #fff; 11605 + } 11606 + .shortcut-combo--conflict { border-color: #c33; color: #c33; } 11607 + 11608 + .shortcut-reset-btn { 11609 + font-size: 0.72rem; 11610 + padding: 2px 6px; 11611 + border: none; 11612 + background: transparent; 11613 + color: var(--color-text-muted); 11614 + cursor: pointer; 11615 + } 11616 + .shortcut-reset-btn:hover { color: var(--color-accent); } 11617 + 11618 + /* ── Slides Panel Tabs ────────────────────────────────────────── */ 11619 + 11620 + .slides-panel-tabs { 11621 + display: flex; 11622 + gap: 0; 11623 + border-bottom: 1px solid var(--color-border); 11624 + margin-bottom: var(--space-xs); 11625 + } 11626 + .slides-panel-tab { 11627 + flex: 1; 11628 + padding: 6px 0; 11629 + border: none; 11630 + background: transparent; 11631 + font-size: 0.78rem; 11632 + font-weight: 500; 11633 + color: var(--color-text-muted); 11634 + cursor: pointer; 11635 + border-bottom: 2px solid transparent; 11636 + transition: color var(--transition-fast), border-color var(--transition-fast); 11637 + } 11638 + .slides-panel-tab:hover { color: var(--color-text); } 11639 + .slides-panel-tab--active { 11640 + color: var(--color-accent); 11641 + border-bottom-color: var(--color-accent); 11642 + } 11643 + 11644 + .slides-anim-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; max-height: 200px; } 11645 + .anim-row { 11646 + display: flex; 11647 + align-items: center; 11648 + gap: 4px; 11649 + padding: 4px 6px; 11650 + border-radius: var(--radius-sm, 4px); 11651 + background: var(--color-surface); 11652 + font-size: 0.8rem; 11653 + } 11654 + .anim-row:hover { background: var(--color-surface-alt); } 11655 + .anim-effect { font-weight: 500; } 11656 + .anim-remove { margin-left: auto; opacity: 0.5; } 11657 + .anim-remove:hover { opacity: 1; color: var(--color-accent); } 11658 + .slides-anim-controls { 11659 + display: flex; 11660 + gap: 4px; 11661 + margin-top: var(--space-xs); 11662 + flex-wrap: wrap; 11663 + } 11664 + .slides-anim-controls select { font-size: 0.78rem; flex: 1; min-width: 80px; } 11665 + .slides-anim-hint { font-size: 0.78rem; color: var(--color-text-muted); margin: var(--space-xs) 0; }
+8
src/diagrams/index.html
··· 43 43 <input type="file" id="svg-import-input" accept=".svg,image/svg+xml" style="display:none"> 44 44 <button class="btn-icon btn-sm" id="btn-export-svg" title="Export SVG">Export SVG</button> 45 45 <button class="btn-icon btn-sm" id="btn-export-png" title="Export PNG">PNG</button> 46 + <button class="btn-icon btn-sm" id="btn-toggle-layers" title="Layers Panel">Layers</button> 46 47 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 47 48 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 48 49 </button> ··· 128 129 <rect class="diagrams-grid" width="100%" height="100%" fill="url(#grid-pattern)"/> 129 130 <g class="diagrams-layer" id="diagram-layer"></g> 130 131 </svg> 132 + </div> 133 + 134 + <!-- Layers panel (right sidebar, toggleable) --> 135 + <div class="layers-panel" id="layers-panel" style="display:none"> 136 + <h3>Layers</h3> 137 + <div id="layers-list"></div> 138 + <button class="layers-add-btn" id="btn-add-layer">+ Add Layer</button> 131 139 </div> 132 140 133 141 <!-- Unified style & properties panel (right sidebar) -->
+187
src/diagrams/layers.ts
··· 1 + /** 2 + * Named layers for the diagrams editor. 3 + * 4 + * Layers are an organizational abstraction on top of z-ordering. 5 + * Each shape can be assigned to a layer. Layers can be hidden, 6 + * locked, or reordered. Shapes without a layer are on the default layer. 7 + */ 8 + 9 + import type { Shape, WhiteboardState } from './whiteboard-types.js'; 10 + 11 + export interface Layer { 12 + id: string; 13 + name: string; 14 + visible: boolean; 15 + locked: boolean; 16 + /** Lower = further back. Default layer is 0. */ 17 + order: number; 18 + } 19 + 20 + export interface LayerState { 21 + layers: Layer[]; 22 + /** Shape ID → Layer ID mapping */ 23 + assignments: Map<string, string>; 24 + } 25 + 26 + let _layerCounter = 0; 27 + 28 + export const DEFAULT_LAYER_ID = 'layer-default'; 29 + 30 + export function createLayerState(): LayerState { 31 + return { 32 + layers: [{ 33 + id: DEFAULT_LAYER_ID, 34 + name: 'Default', 35 + visible: true, 36 + locked: false, 37 + order: 0, 38 + }], 39 + assignments: new Map(), 40 + }; 41 + } 42 + 43 + export function addLayer(state: LayerState, name: string): LayerState { 44 + const maxOrder = state.layers.reduce((m, l) => Math.max(m, l.order), 0); 45 + const layer: Layer = { 46 + id: `layer-${Date.now()}-${++_layerCounter}`, 47 + name, 48 + visible: true, 49 + locked: false, 50 + order: maxOrder + 1, 51 + }; 52 + return { ...state, layers: [...state.layers, layer] }; 53 + } 54 + 55 + export function removeLayer(state: LayerState, layerId: string): LayerState { 56 + if (layerId === DEFAULT_LAYER_ID) return state; // can't remove default 57 + const layers = state.layers.filter(l => l.id !== layerId); 58 + // Move orphaned shapes to default layer 59 + const assignments = new Map(state.assignments); 60 + for (const [shapeId, lid] of assignments) { 61 + if (lid === layerId) assignments.set(shapeId, DEFAULT_LAYER_ID); 62 + } 63 + return { layers, assignments }; 64 + } 65 + 66 + export function renameLayer(state: LayerState, layerId: string, name: string): LayerState { 67 + const layers = state.layers.map(l => 68 + l.id === layerId ? { ...l, name } : l, 69 + ); 70 + return { ...state, layers }; 71 + } 72 + 73 + export function toggleLayerVisibility(state: LayerState, layerId: string): LayerState { 74 + const layers = state.layers.map(l => 75 + l.id === layerId ? { ...l, visible: !l.visible } : l, 76 + ); 77 + return { ...state, layers }; 78 + } 79 + 80 + export function toggleLayerLock(state: LayerState, layerId: string): LayerState { 81 + const layers = state.layers.map(l => 82 + l.id === layerId ? { ...l, locked: !l.locked } : l, 83 + ); 84 + return { ...state, layers }; 85 + } 86 + 87 + export function reorderLayer(state: LayerState, layerId: string, newOrder: number): LayerState { 88 + const layers = state.layers.map(l => 89 + l.id === layerId ? { ...l, order: newOrder } : l, 90 + ); 91 + return { ...state, layers }; 92 + } 93 + 94 + export function moveLayerUp(state: LayerState, layerId: string): LayerState { 95 + const sorted = [...state.layers].sort((a, b) => a.order - b.order); 96 + const idx = sorted.findIndex(l => l.id === layerId); 97 + if (idx < 0 || idx >= sorted.length - 1) return state; 98 + const currentLayer = sorted[idx]!; 99 + const aboveLayer = sorted[idx + 1]!; 100 + const layers = state.layers.map(l => { 101 + if (l.id === currentLayer.id) return { ...l, order: aboveLayer.order }; 102 + if (l.id === aboveLayer.id) return { ...l, order: currentLayer.order }; 103 + return l; 104 + }); 105 + return { ...state, layers }; 106 + } 107 + 108 + export function moveLayerDown(state: LayerState, layerId: string): LayerState { 109 + const sorted = [...state.layers].sort((a, b) => a.order - b.order); 110 + const idx = sorted.findIndex(l => l.id === layerId); 111 + if (idx <= 0) return state; 112 + const currentLayer = sorted[idx]!; 113 + const belowLayer = sorted[idx - 1]!; 114 + const layers = state.layers.map(l => { 115 + if (l.id === currentLayer.id) return { ...l, order: belowLayer.order }; 116 + if (l.id === belowLayer.id) return { ...l, order: currentLayer.order }; 117 + return l; 118 + }); 119 + return { ...state, layers }; 120 + } 121 + 122 + export function assignShapeToLayer(state: LayerState, shapeId: string, layerId: string): LayerState { 123 + const assignments = new Map(state.assignments); 124 + if (layerId === DEFAULT_LAYER_ID) { 125 + assignments.delete(shapeId); 126 + } else { 127 + assignments.set(shapeId, layerId); 128 + } 129 + return { ...state, assignments }; 130 + } 131 + 132 + export function getShapeLayer(state: LayerState, shapeId: string): string { 133 + return state.assignments.get(shapeId) || DEFAULT_LAYER_ID; 134 + } 135 + 136 + export function getLayerById(state: LayerState, layerId: string): Layer | undefined { 137 + return state.layers.find(l => l.id === layerId); 138 + } 139 + 140 + export function isShapeVisible(state: LayerState, shapeId: string): boolean { 141 + const layerId = getShapeLayer(state, shapeId); 142 + const layer = getLayerById(state, layerId); 143 + return layer ? layer.visible : true; 144 + } 145 + 146 + export function isShapeLocked(state: LayerState, shapeId: string): boolean { 147 + const layerId = getShapeLayer(state, shapeId); 148 + const layer = getLayerById(state, layerId); 149 + return layer ? layer.locked : false; 150 + } 151 + 152 + /** 153 + * Get shapes on a specific layer, sorted by z-order (Map insertion order). 154 + */ 155 + export function shapesOnLayer( 156 + layerState: LayerState, 157 + boardState: WhiteboardState, 158 + layerId: string, 159 + ): Shape[] { 160 + const result: Shape[] = []; 161 + for (const shape of boardState.shapes.values()) { 162 + const shapeLayer = getShapeLayer(layerState, shape.id); 163 + if (shapeLayer === layerId) result.push(shape); 164 + } 165 + return result; 166 + } 167 + 168 + /** 169 + * Get all visible shapes from the board, filtered by layer visibility. 170 + */ 171 + export function visibleShapes( 172 + layerState: LayerState, 173 + boardState: WhiteboardState, 174 + ): Shape[] { 175 + const result: Shape[] = []; 176 + for (const shape of boardState.shapes.values()) { 177 + if (isShapeVisible(layerState, shape.id)) result.push(shape); 178 + } 179 + return result; 180 + } 181 + 182 + /** 183 + * Get layers sorted by order (back to front). 184 + */ 185 + export function sortedLayers(state: LayerState): Layer[] { 186 + return [...state.layers].sort((a, b) => a.order - b.order); 187 + }
+104
src/diagrams/main.ts
··· 16 16 import type { 17 17 WhiteboardState, Shape, Arrow, Point, 18 18 } from './whiteboard.js'; 19 + import { 20 + createLayerState, addLayer, removeLayer, renameLayer, 21 + toggleLayerVisibility, toggleLayerLock, assignShapeToLayer, 22 + getShapeLayer, isShapeVisible, isShapeLocked, sortedLayers, 23 + DEFAULT_LAYER_ID, 24 + } from './layers.js'; 25 + import type { LayerState } from './layers.js'; 19 26 import History from './history.js'; 20 27 21 28 // Extracted modules ··· 67 74 let wb: WhiteboardState = createWhiteboard(); 68 75 let activeTool: string = 'select'; 69 76 const history = new History(); 77 + let layerState: LayerState = createLayerState(); 70 78 71 79 // Selection (multi-select) 72 80 let selectedShapeIds: Set<string> = new Set(); ··· 148 156 yBoard.set('panY', wb.panY); 149 157 yBoard.set('zoom', wb.zoom); 150 158 yBoard.set('snapToGrid', wb.snapToGrid); 159 + yBoard.set('layers', JSON.stringify({ 160 + layers: layerState.layers, 161 + assignments: Object.fromEntries(layerState.assignments), 162 + })); 151 163 } 152 164 153 165 function loadFromYjs() { ··· 165 177 if (yBoard.has('zoom')) wb = { ...wb, zoom: yBoard.get('zoom') as number }; 166 178 if (yBoard.has('panX')) wb = { ...wb, panX: yBoard.get('panX') as number, panY: yBoard.get('panY') as number }; 167 179 if (yBoard.has('snapToGrid')) wb = { ...wb, snapToGrid: yBoard.get('snapToGrid') as boolean }; 180 + if (yBoard.has('layers')) { 181 + try { 182 + const parsed = JSON.parse(yBoard.get('layers') as string); 183 + layerState = { 184 + layers: parsed.layers || layerState.layers, 185 + assignments: new Map(Object.entries(parsed.assignments || {})), 186 + }; 187 + } catch { /* keep defaults */ } 188 + } 168 189 } catch { /* defaults */ } 169 190 } 170 191 ··· 386 407 { id: 'zoom-fit', label: 'Zoom to Fit', category: 'action', icon: '\u21f2', action: () => { document.getElementById('btn-zoom-fit')?.click(); } }, 387 408 { id: 'delete', label: 'Delete Selected', category: 'action', icon: '\u2715', shortcut: 'Del', action: () => { document.getElementById('btn-delete')?.click(); } }, 388 409 ], 410 + }); 411 + 412 + // --- Layers Panel --- 413 + const layersPanel = $('layers-panel'); 414 + const layersList = $('layers-list'); 415 + const btnAddLayer = $('btn-add-layer'); 416 + const btnToggleLayers = $('btn-toggle-layers'); 417 + 418 + function renderLayersPanel() { 419 + if (layersPanel.style.display === 'none') return; 420 + const sorted = sortedLayers(layerState); 421 + let html = ''; 422 + for (let i = sorted.length - 1; i >= 0; i--) { 423 + const l = sorted[i]; 424 + const activeLayer = selectedShapeIds.size === 1 425 + ? getShapeLayer(layerState, [...selectedShapeIds][0]) 426 + : null; 427 + const isActive = l.id === activeLayer; 428 + html += `<div class="layer-item${isActive ? ' layer-item--active' : ''}" data-layer-id="${l.id}">`; 429 + html += `<span class="layer-toggle${l.visible ? '' : ' layer-toggle--hidden'}" data-action="visibility" title="${l.visible ? 'Hide' : 'Show'}">${l.visible ? '&#9673;' : '&#9675;'}</span>`; 430 + html += `<span class="layer-toggle${l.locked ? ' layer-toggle--locked' : ''}" data-action="lock" title="${l.locked ? 'Unlock' : 'Lock'}">${l.locked ? '&#128274;' : '&#128275;'}</span>`; 431 + html += `<span class="layer-name" data-action="select">${l.name}</span>`; 432 + if (l.id !== DEFAULT_LAYER_ID) { 433 + html += `<span class="layer-toggle" data-action="remove" title="Delete layer">&times;</span>`; 434 + } 435 + html += '</div>'; 436 + } 437 + layersList.innerHTML = html; 438 + } 439 + 440 + btnToggleLayers.addEventListener('click', () => { 441 + const visible = layersPanel.style.display === 'none'; 442 + layersPanel.style.display = visible ? '' : 'none'; 443 + if (visible) renderLayersPanel(); 444 + }); 445 + 446 + btnAddLayer.addEventListener('click', () => { 447 + const name = prompt('Layer name:'); 448 + if (!name || !name.trim()) return; 449 + layerState = addLayer(layerState, name.trim()); 450 + syncToYjs(); 451 + renderLayersPanel(); 452 + }); 453 + 454 + layersList.addEventListener('click', (e) => { 455 + const target = e.target as HTMLElement; 456 + const action = target.dataset.action; 457 + const item = target.closest('.layer-item') as HTMLElement | null; 458 + if (!item || !action) return; 459 + const layerId = item.dataset.layerId!; 460 + 461 + switch (action) { 462 + case 'visibility': 463 + layerState = toggleLayerVisibility(layerState, layerId); 464 + syncToYjs(); 465 + render(); 466 + renderLayersPanel(); 467 + break; 468 + case 'lock': 469 + layerState = toggleLayerLock(layerState, layerId); 470 + syncToYjs(); 471 + renderLayersPanel(); 472 + break; 473 + case 'remove': 474 + if (confirm('Delete this layer? Shapes will move to Default.')) { 475 + layerState = removeLayer(layerState, layerId); 476 + syncToYjs(); 477 + render(); 478 + renderLayersPanel(); 479 + } 480 + break; 481 + case 'select': 482 + // Assign selected shapes to this layer 483 + if (selectedShapeIds.size > 0) { 484 + pushHistory(); 485 + for (const sid of selectedShapeIds) { 486 + layerState = assignShapeToLayer(layerState, sid, layerId); 487 + } 488 + syncToYjs(); 489 + renderLayersPanel(); 490 + } 491 + break; 492 + } 389 493 }); 390 494 391 495 init();
+15
src/landing-events.ts
··· 68 68 setSearchQuery: (q: string) => void; 69 69 getActiveTagFilter: () => string | null; 70 70 setActiveTagFilter: (t: string | null) => void; 71 + getActiveTypeFilter: () => string | null; 72 + setActiveTypeFilter: (t: string | null) => void; 71 73 getTrashExpanded: () => boolean; 72 74 setTrashExpanded: (v: boolean) => void; 73 75 getViewMode: () => 'list' | 'grid'; ··· 134 136 if (btn) { 135 137 const tag = btn.dataset.tag || null; 136 138 deps.setActiveTagFilter(tag || null); 139 + deps.renderDocuments(); 140 + } 141 + }); 142 + } 143 + 144 + // --- Type filter bar (delegated on parent since barEl may not exist yet) --- 145 + const typeParent = deps.docListEl.parentElement; 146 + if (typeParent) { 147 + typeParent.addEventListener('click', (e) => { 148 + const btn = (e.target as HTMLElement).closest('.type-filter-btn') as HTMLElement | null; 149 + if (btn) { 150 + const type = btn.dataset.type || null; 151 + deps.setActiveTypeFilter(type === 'all' ? null : type); 137 152 deps.renderDocuments(); 138 153 } 139 154 });
+34
src/landing-render.ts
··· 10 10 sortDocuments, 11 11 starredIdsSet, 12 12 filterBySearch, 13 + filterByType, 14 + countByType, 15 + DOC_TYPES, 16 + DOC_TYPE_LABELS, 13 17 getDocsInFolder, 14 18 buildBreadcrumbs, 15 19 getRecentDocs, ··· 51 55 getCurrentFolderId: () => string | null; 52 56 getSearchQuery: () => string; 53 57 getActiveTagFilter: () => string | null; 58 + getActiveTypeFilter: () => string | null; 54 59 getCurrentSort: () => string; 55 60 getViewMode: () => 'list' | 'grid'; 56 61 getTrashExpanded: () => boolean; ··· 147 152 tagBarEl.innerHTML = html; 148 153 } 149 154 155 + // ── Type Filter Bar ───────────────────────────────────────── 156 + 157 + export function renderTypeFilter(deps: RenderDeps, docs: DocumentMeta[]): void { 158 + let barEl = document.getElementById('type-filter-bar'); 159 + if (!barEl) { 160 + barEl = document.createElement('div'); 161 + barEl.id = 'type-filter-bar'; 162 + barEl.className = 'type-filter-bar'; 163 + const parent = deps.docListEl.parentElement; 164 + if (parent) parent.insertBefore(barEl, deps.docListEl); 165 + } 166 + const activeTypeFilter = deps.getActiveTypeFilter(); 167 + const counts = countByType(docs); 168 + let html = `<button class="type-filter-btn${!activeTypeFilter ? ' type-filter-btn--active' : ''}" data-type="all">All</button>`; 169 + for (const type of DOC_TYPES) { 170 + if (counts[type] === 0) continue; // skip types with no docs 171 + const isActive = activeTypeFilter === type; 172 + html += `<button class="type-filter-btn${isActive ? ' type-filter-btn--active' : ''}" data-type="${type}">${DOC_TYPE_LABELS[type]} <span class="type-filter-count">${counts[type]}</span></button>`; 173 + } 174 + barEl.innerHTML = html; 175 + } 176 + 150 177 // ── Breadcrumbs ────────────────────────────────────────────── 151 178 152 179 export function renderBreadcrumbs(deps: RenderDeps): void { ··· 285 312 const currentFolderId = deps.getCurrentFolderId(); 286 313 const searchQuery = deps.getSearchQuery(); 287 314 const activeTagFilter = deps.getActiveTagFilter(); 315 + const activeTypeFilter = deps.getActiveTypeFilter(); 288 316 const currentSort = deps.getCurrentSort(); 289 317 const viewMode = deps.getViewMode(); 290 318 ··· 308 336 visibleDocs = filterByTag(visibleDocs, activeTagFilter) as DocumentMeta[]; 309 337 } 310 338 339 + // Apply type filter 340 + visibleDocs = filterByType(visibleDocs, activeTypeFilter); 341 + 311 342 // Render tag filter bar 312 343 renderTagFilter(deps, active); 344 + 345 + // Render type filter bar 346 + renderTypeFilter(deps, active); 313 347 314 348 // Apply sort 315 349 const sorted = sortDocuments(visibleDocs, currentSort, starSet);
+36
src/landing-utils.ts
··· 246 246 } 247 247 248 248 // ============================================================ 249 + // Type Filter 250 + // ============================================================ 251 + 252 + /** All available document types for filtering. */ 253 + export const DOC_TYPES = ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar'] as const; 254 + 255 + export const DOC_TYPE_LABELS: Record<string, string> = { 256 + doc: 'Documents', 257 + sheet: 'Spreadsheets', 258 + form: 'Forms', 259 + slide: 'Presentations', 260 + diagram: 'Diagrams', 261 + calendar: 'Calendar', 262 + }; 263 + 264 + /** 265 + * Filter documents by type. If typeFilter is null/undefined or 'all', returns all docs. 266 + */ 267 + export function filterByType(docs: DocumentMeta[], typeFilter: string | null | undefined): DocumentMeta[] { 268 + if (!typeFilter || typeFilter === 'all') return docs; 269 + return docs.filter(d => d.type === typeFilter); 270 + } 271 + 272 + /** 273 + * Count documents by type. 274 + */ 275 + export function countByType(docs: DocumentMeta[]): Record<string, number> { 276 + const counts: Record<string, number> = {}; 277 + for (const type of DOC_TYPES) counts[type] = 0; 278 + for (const doc of docs) { 279 + counts[doc.type] = (counts[doc.type] || 0) + 1; 280 + } 281 + return counts; 282 + } 283 + 284 + // ============================================================ 249 285 // Search 250 286 // ============================================================ 251 287
+4
src/landing.ts
··· 79 79 let recentIds: string[] = JSON.parse(localStorage.getItem('tools-recent') || '[]'); 80 80 let searchQuery = ''; 81 81 let activeTagFilter: string | null = null; 82 + let activeTypeFilter: string | null = null; 82 83 let trashExpanded = false; 83 84 let viewMode: 'list' | 'grid' = (localStorage.getItem('tools-view-mode') as 'list' | 'grid') || 'list'; 84 85 let moveDocId: string | null = null; ··· 108 109 getCurrentFolderId: () => currentFolderId, 109 110 getSearchQuery: () => searchQuery, 110 111 getActiveTagFilter: () => activeTagFilter, 112 + getActiveTypeFilter: () => activeTypeFilter, 111 113 getCurrentSort: () => currentSort, 112 114 getViewMode: () => viewMode, 113 115 getTrashExpanded: () => trashExpanded, ··· 148 150 setSearchQuery: (q) => { searchQuery = q; }, 149 151 getActiveTagFilter: () => activeTagFilter, 150 152 setActiveTagFilter: (t) => { activeTagFilter = t; }, 153 + getActiveTypeFilter: () => activeTypeFilter, 154 + setActiveTypeFilter: (t: string | null) => { activeTypeFilter = t; }, 151 155 getTrashExpanded: () => trashExpanded, 152 156 setTrashExpanded: (v) => { trashExpanded = v; }, 153 157 getViewMode: () => viewMode,
+20 -1
src/sheets/charts-ui.ts
··· 32 32 mod.Chart.register( 33 33 mod.CategoryScale, mod.LinearScale, mod.PointElement, 34 34 mod.LineElement, mod.BarElement, mod.ArcElement, 35 + mod.RadialLinearScale, mod.Filler, 35 36 mod.Title, mod.Tooltip, mod.Legend 36 37 ); 37 38 ChartJS = mod.Chart; 38 39 return ChartJS; 40 + } 41 + 42 + // ── Chart Type Labels ────────────────────────────────────── 43 + 44 + const CHART_TYPE_LABELS: Record<string, string> = { 45 + bar: 'Bar', 46 + line: 'Line', 47 + pie: 'Pie', 48 + scatter: 'Scatter', 49 + area: 'Area', 50 + doughnut: 'Doughnut', 51 + radar: 'Radar', 52 + stackedBar: 'Stacked Bar', 53 + stackedLine: 'Stacked Line', 54 + }; 55 + 56 + function chartTypeLabel(type: string): string { 57 + return CHART_TYPE_LABELS[type] || type.charAt(0).toUpperCase() + type.slice(1); 39 58 } 40 59 41 60 // ── Chart State ───────────────────────────────────────────── ··· 63 82 <h3>${isEdit ? 'Edit' : 'Insert'} Chart</h3> 64 83 <label>Chart Type</label> 65 84 <select id="chart-type"> 66 - ${CHART_TYPES.map(t => `<option value="${t}" ${t === cfg.type ? 'selected' : ''}>${t.charAt(0).toUpperCase() + t.slice(1)}</option>`).join('')} 85 + ${CHART_TYPES.map(t => `<option value="${t}" ${t === cfg.type ? 'selected' : ''}>${chartTypeLabel(t)}</option>`).join('')} 67 86 </select> 68 87 <label>Data Range (e.g. A1:D10)</label> 69 88 <input id="chart-range" value="${cfg.range}" placeholder="A1:D10">
+72 -23
src/sheets/charts.ts
··· 9 9 import type { ChartConfig, ChartValidationResult, DataRange, TransformedChartData, ChartDataset, CellValue } from './types.js'; 10 10 11 11 // ---- Supported chart types ---- 12 - export const CHART_TYPES: readonly string[] = ['bar', 'line', 'pie', 'scatter']; 12 + export const CHART_TYPES: readonly string[] = ['bar', 'line', 'pie', 'scatter', 'area', 'doughnut', 'radar', 'stackedBar', 'stackedLine']; 13 13 14 14 // ---- Configuration validation ---- 15 15 ··· 125 125 }; 126 126 } 127 127 128 + // Doughnut: same as pie (first data column only) 129 + if (config.type === 'doughnut') { 130 + const labels = dataRows.map((row: (CellValue | '')[]) => row[0]); 131 + const data = dataRows.map((row: (CellValue | '')[]) => row[1]); 132 + return { 133 + labels, 134 + datasets: [{ 135 + label: headerRow ? String(headerRow[1]) : 'Series 1', 136 + data, 137 + }], 138 + }; 139 + } 140 + 128 141 // Scatter chart: first column is X, second column is Y 129 142 if (config.type === 'scatter') { 130 143 const points = dataRows.map((row: (CellValue | '')[]) => ({ ··· 184 197 text: string; 185 198 } 186 199 200 + interface ChartJsScaleAxis { 201 + title?: ChartJsScaleTitle; 202 + stacked?: boolean; 203 + } 204 + 187 205 interface ChartJsScale { 188 - x: { title: ChartJsScaleTitle }; 189 - y: { title: ChartJsScaleTitle }; 206 + x: ChartJsScaleAxis; 207 + y: ChartJsScaleAxis; 190 208 } 191 209 192 210 interface ChartJsConfig { ··· 212 230 export function buildChartJsConfig(chartConfig: ChartConfig, transformedData: TransformedChartData): ChartJsConfig { 213 231 const { type, title, xAxisLabel, yAxisLabel } = chartConfig; 214 232 233 + // Map our type names to Chart.js type names 234 + const isArea = type === 'area'; 235 + const isStackedBar = type === 'stackedBar'; 236 + const isStackedLine = type === 'stackedLine'; 237 + const isDoughnut = type === 'doughnut'; 238 + const isRadar = type === 'radar'; 239 + const isPielike = type === 'pie' || isDoughnut; 240 + const isLineLike = type === 'line' || isArea || isStackedLine; 241 + 242 + let chartJsType: string; 243 + if (isArea || isStackedLine) chartJsType = 'line'; 244 + else if (isStackedBar) chartJsType = 'bar'; 245 + else if (isDoughnut) chartJsType = 'doughnut'; 246 + else if (isRadar) chartJsType = 'radar'; 247 + else chartJsType = type; 248 + 215 249 // Assign colors to datasets 216 250 const datasets = transformedData.datasets.map((ds, i) => ({ 217 251 ...ds, 218 252 backgroundColor: CHART_COLORS[i % CHART_COLORS.length], 219 253 borderColor: CHART_COLORS[i % CHART_COLORS.length].replace('0.8', '1'), 220 - borderWidth: type === 'line' ? 2 : 1, 221 - fill: false, 222 - tension: type === 'line' ? 0.1 : undefined, 254 + borderWidth: isLineLike ? 2 : 1, 255 + fill: isArea || isStackedLine, 256 + tension: isLineLike ? 0.1 : undefined, 223 257 pointRadius: type === 'scatter' ? 4 : undefined, 224 258 })); 225 259 226 - // Pie charts get a multi-color single dataset 227 - if (type === 'pie' && datasets.length === 1) { 260 + // Pie/doughnut charts get a multi-color single dataset 261 + if (isPielike && datasets.length === 1) { 228 262 datasets[0].backgroundColor = transformedData.labels.map( 229 263 (_: CellValue | '' | number, i: number) => CHART_COLORS[i % CHART_COLORS.length] 230 264 ); 231 265 datasets[0].borderColor = 'rgba(255,255,255,0.8)'; 232 266 } 233 267 268 + // Radar datasets get semi-transparent fill 269 + if (isRadar) { 270 + for (const ds of datasets) { 271 + ds.fill = true; 272 + ds.backgroundColor = (ds.backgroundColor as string).replace('0.8', '0.2'); 273 + ds.borderWidth = 2; 274 + } 275 + } 276 + 234 277 const config: ChartJsConfig = { 235 - type: type === 'scatter' ? 'scatter' : type, 278 + type: chartJsType, 236 279 data: { 237 280 labels: transformedData.labels, 238 281 datasets, ··· 246 289 text: title || '', 247 290 }, 248 291 legend: { 249 - display: datasets.length > 1 || type === 'pie', 292 + display: datasets.length > 1 || isPielike, 250 293 }, 251 294 }, 252 295 }, 253 296 }; 254 297 255 - // Axis labels for non-pie charts 256 - if (type !== 'pie') { 257 - config.options.scales = { 258 - x: { 259 - title: { 260 - display: !!xAxisLabel, 261 - text: xAxisLabel || '', 262 - }, 298 + // No cartesian scales for pie, doughnut, or radar 299 + if (!isPielike && !isRadar) { 300 + const xScale: ChartJsScaleAxis = { 301 + title: { 302 + display: !!xAxisLabel, 303 + text: xAxisLabel || '', 263 304 }, 264 - y: { 265 - title: { 266 - display: !!yAxisLabel, 267 - text: yAxisLabel || '', 268 - }, 305 + }; 306 + const yScale: ChartJsScaleAxis = { 307 + title: { 308 + display: !!yAxisLabel, 309 + text: yAxisLabel || '', 269 310 }, 270 311 }; 312 + 313 + // Stacked charts 314 + if (isStackedBar || isStackedLine) { 315 + xScale.stacked = true; 316 + yScale.stacked = true; 317 + } 318 + 319 + config.options.scales = { x: xScale, y: yScale }; 271 320 } 272 321 273 322 return config;
+122 -3
src/sheets/conditional-format.ts
··· 17 17 * Multiple rules per sheet, evaluated in order (first match wins). 18 18 */ 19 19 20 - import type { CfRule, CfStyleResult } from './types.js'; 20 + import type { CfRule, CfStyleResult, DataBarResult, IconSetResult } from './types.js'; 21 21 22 22 /** 23 23 * Evaluate a single conditional formatting rule against a cell value. ··· 197 197 198 198 if (nums.length === 0) return result; 199 199 200 - let min = nums[0].val; 201 - let max = nums[0].val; 200 + let min = nums[0]!.val; 201 + let max = nums[0]!.val; 202 202 for (const { val } of nums) { 203 203 if (val < min) min = val; 204 204 if (val > max) max = val; ··· 222 222 case 'textContains': return 'Text contains "' + (rule.value ?? '') + '"'; 223 223 case 'isEmpty': return 'Is empty'; 224 224 case 'isNotEmpty': return 'Is not empty'; 225 + case 'dataBar': return 'Data bar'; 226 + case 'iconSet': return 'Icon set'; 225 227 default: return rule.type; 226 228 } 227 229 } 230 + 231 + // ============================================================ 232 + // Data Bars 233 + // ============================================================ 234 + 235 + const DEFAULT_BAR_COLOR = '#4472c4'; 236 + 237 + /** 238 + * Compute data bar widths for a range of cells. 239 + * Each bar's width is proportional to the cell's value relative to the range. 240 + * Negative values produce bars in the opposite direction. 241 + */ 242 + export function computeDataBars( 243 + cellValues: Map<string, unknown>, 244 + rule: CfRule, 245 + ): Map<string, DataBarResult> { 246 + const result = new Map<string, DataBarResult>(); 247 + const nums: { id: string; val: number }[] = []; 248 + 249 + for (const [id, v] of cellValues) { 250 + const n = toNumber(v); 251 + if (n !== null) nums.push({ id, val: n }); 252 + } 253 + 254 + if (nums.length === 0) return result; 255 + 256 + const barColor = rule.barColor || DEFAULT_BAR_COLOR; 257 + 258 + let min = nums[0]!.val; 259 + let max = nums[0]!.val; 260 + for (const { val } of nums) { 261 + if (val < min) min = val; 262 + if (val > max) max = val; 263 + } 264 + 265 + // All equal → 100% bars 266 + if (min === max) { 267 + for (const { id } of nums) { 268 + result.set(id, { barWidthPct: 100, barColor, negative: min < 0 }); 269 + } 270 + return result; 271 + } 272 + 273 + const hasNegative = min < 0; 274 + 275 + for (const { id, val } of nums) { 276 + let barWidthPct: number; 277 + let negative = false; 278 + 279 + if (hasNegative) { 280 + // Bidirectional: bar width relative to the full range span 281 + const span = max - min; 282 + barWidthPct = (Math.abs(val) / span) * 100; 283 + negative = val < 0; 284 + } else { 285 + // All positive: simple percentage of max 286 + barWidthPct = (val / max) * 100; 287 + } 288 + 289 + result.set(id, { barWidthPct, barColor, negative }); 290 + } 291 + 292 + return result; 293 + } 294 + 295 + // ============================================================ 296 + // Icon Sets 297 + // ============================================================ 298 + 299 + /** Number of icons per named icon set. */ 300 + function iconCount(name: string): number { 301 + if (name.endsWith('5')) return 5; 302 + if (name.endsWith('4')) return 4; 303 + return 3; 304 + } 305 + 306 + /** 307 + * Compute icon set assignments for a range of cells. 308 + * Values are split into equal percentile bands; higher values get higher icon indices. 309 + */ 310 + export function computeIconSets( 311 + cellValues: Map<string, unknown>, 312 + rule: CfRule, 313 + ): Map<string, IconSetResult> { 314 + const result = new Map<string, IconSetResult>(); 315 + const setName = rule.iconSetName || 'traffic3'; 316 + const count = iconCount(setName); 317 + const nums: { id: string; val: number }[] = []; 318 + 319 + for (const [id, v] of cellValues) { 320 + const n = toNumber(v); 321 + if (n !== null) nums.push({ id, val: n }); 322 + } 323 + 324 + if (nums.length === 0) return result; 325 + 326 + let min = nums[0]!.val; 327 + let max = nums[0]!.val; 328 + for (const { val } of nums) { 329 + if (val < min) min = val; 330 + if (val > max) max = val; 331 + } 332 + 333 + for (const { id, val } of nums) { 334 + let iconIndex: number; 335 + if (min === max) { 336 + // All equal → assign the highest icon 337 + iconIndex = count - 1; 338 + } else { 339 + const pct = (val - min) / (max - min); // 0..1 340 + iconIndex = Math.min(count - 1, Math.floor(pct * count)); 341 + } 342 + result.set(id, { iconIndex, iconSetName: setName }); 343 + } 344 + 345 + return result; 346 + }
+19 -2
src/sheets/types.ts
··· 140 140 141 141 // --- Chart Types --- 142 142 143 - export type ChartType = 'bar' | 'line' | 'pie' | 'scatter'; 143 + export type ChartType = 'bar' | 'line' | 'pie' | 'scatter' | 'area' | 'doughnut' | 'radar' | 'stackedBar' | 'stackedLine'; 144 144 145 145 export interface ChartConfig { 146 146 type: ChartType; ··· 197 197 | 'textContains' 198 198 | 'isEmpty' 199 199 | 'isNotEmpty' 200 - | 'colorScale'; 200 + | 'colorScale' 201 + | 'dataBar' 202 + | 'iconSet'; 201 203 202 204 export interface CfRule { 203 205 type: CfRuleType; ··· 212 214 midColor?: string; 213 215 /** Color scale: high-end color (default: #63be7b green) */ 214 216 maxColor?: string; 217 + /** Data bar: fill color (default: #4472c4 blue) */ 218 + barColor?: string; 219 + /** Icon set: which icon set to use (default: traffic3) */ 220 + iconSetName?: string; 221 + } 222 + 223 + export interface DataBarResult { 224 + barWidthPct: number; 225 + barColor: string; 226 + negative: boolean; 227 + } 228 + 229 + export interface IconSetResult { 230 + iconIndex: number; 231 + iconSetName: string; 215 232 } 216 233 217 234 export interface CfStyleResult {
+230
src/slides/element-animations.ts
··· 1 + /** 2 + * Per-element animations for slides. 3 + * 4 + * Each element on a slide can have enter (appear) and exit (disappear) animations. 5 + * Animations are ordered and played sequentially or on-click during presentation. 6 + * 7 + * Pure logic module — no DOM. The presenter UI reads this state to apply CSS animations. 8 + */ 9 + 10 + export type AnimationEffect = 11 + | 'none' 12 + | 'fadeIn' 13 + | 'fadeOut' 14 + | 'slideInLeft' 15 + | 'slideInRight' 16 + | 'slideInUp' 17 + | 'slideInDown' 18 + | 'slideOutLeft' 19 + | 'slideOutRight' 20 + | 'slideOutUp' 21 + | 'slideOutDown' 22 + | 'zoomIn' 23 + | 'zoomOut' 24 + | 'bounceIn' 25 + | 'spinIn'; 26 + 27 + export type AnimationTrigger = 'onClick' | 'withPrevious' | 'afterPrevious'; 28 + 29 + export interface ElementAnimation { 30 + id: string; 31 + /** The slide element this animation targets */ 32 + elementId: string; 33 + /** Which effect to play */ 34 + effect: AnimationEffect; 35 + /** When to trigger this animation */ 36 + trigger: AnimationTrigger; 37 + /** Duration in milliseconds */ 38 + duration: number; 39 + /** Delay before starting (ms), used with afterPrevious */ 40 + delay: number; 41 + /** Sequence order within the slide (lower = plays first) */ 42 + order: number; 43 + } 44 + 45 + export interface SlideAnimations { 46 + /** Slide index → ordered list of animations */ 47 + animations: Map<number, ElementAnimation[]>; 48 + } 49 + 50 + let _animCounter = 0; 51 + 52 + export const ENTER_EFFECTS: AnimationEffect[] = [ 53 + 'fadeIn', 'slideInLeft', 'slideInRight', 'slideInUp', 'slideInDown', 54 + 'zoomIn', 'bounceIn', 'spinIn', 55 + ]; 56 + 57 + export const EXIT_EFFECTS: AnimationEffect[] = [ 58 + 'fadeOut', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'slideOutDown', 'zoomOut', 59 + ]; 60 + 61 + export const ALL_EFFECTS: AnimationEffect[] = ['none', ...ENTER_EFFECTS, ...EXIT_EFFECTS]; 62 + 63 + export function createSlideAnimations(): SlideAnimations { 64 + return { animations: new Map() }; 65 + } 66 + 67 + export function getAnimationsForSlide(state: SlideAnimations, slideIndex: number): ElementAnimation[] { 68 + return state.animations.get(slideIndex) || []; 69 + } 70 + 71 + export function addAnimation( 72 + state: SlideAnimations, 73 + slideIndex: number, 74 + elementId: string, 75 + effect: AnimationEffect, 76 + trigger: AnimationTrigger = 'onClick', 77 + duration = 500, 78 + delay = 0, 79 + ): SlideAnimations { 80 + const existing = getAnimationsForSlide(state, slideIndex); 81 + const maxOrder = existing.reduce((m, a) => Math.max(m, a.order), -1); 82 + const anim: ElementAnimation = { 83 + id: `anim-${Date.now()}-${++_animCounter}`, 84 + elementId, 85 + effect, 86 + trigger, 87 + duration, 88 + delay, 89 + order: maxOrder + 1, 90 + }; 91 + const animations = new Map(state.animations); 92 + animations.set(slideIndex, [...existing, anim]); 93 + return { animations }; 94 + } 95 + 96 + export function removeAnimation(state: SlideAnimations, slideIndex: number, animationId: string): SlideAnimations { 97 + const existing = getAnimationsForSlide(state, slideIndex); 98 + const filtered = existing.filter(a => a.id !== animationId); 99 + const animations = new Map(state.animations); 100 + if (filtered.length === 0) { 101 + animations.delete(slideIndex); 102 + } else { 103 + animations.set(slideIndex, filtered); 104 + } 105 + return { animations }; 106 + } 107 + 108 + export function updateAnimation( 109 + state: SlideAnimations, 110 + slideIndex: number, 111 + animationId: string, 112 + updates: Partial<Pick<ElementAnimation, 'effect' | 'trigger' | 'duration' | 'delay'>>, 113 + ): SlideAnimations { 114 + const existing = getAnimationsForSlide(state, slideIndex); 115 + const updated = existing.map(a => 116 + a.id === animationId ? { ...a, ...updates } : a, 117 + ); 118 + const animations = new Map(state.animations); 119 + animations.set(slideIndex, updated); 120 + return { animations }; 121 + } 122 + 123 + export function reorderAnimation( 124 + state: SlideAnimations, 125 + slideIndex: number, 126 + animationId: string, 127 + newOrder: number, 128 + ): SlideAnimations { 129 + const existing = getAnimationsForSlide(state, slideIndex); 130 + const anim = existing.find(a => a.id === animationId); 131 + if (!anim) return state; 132 + 133 + const oldOrder = anim.order; 134 + const reordered = existing.map(a => { 135 + if (a.id === animationId) return { ...a, order: newOrder }; 136 + // Shift other items to make room 137 + if (newOrder < oldOrder && a.order >= newOrder && a.order < oldOrder) { 138 + return { ...a, order: a.order + 1 }; 139 + } 140 + if (newOrder > oldOrder && a.order > oldOrder && a.order <= newOrder) { 141 + return { ...a, order: a.order - 1 }; 142 + } 143 + return a; 144 + }); 145 + 146 + const animations = new Map(state.animations); 147 + animations.set(slideIndex, reordered); 148 + return { animations }; 149 + } 150 + 151 + /** 152 + * Get animations for a slide in playback order. 153 + * Groups by trigger: onClick items create new steps, withPrevious/afterPrevious 154 + * attach to the preceding step. 155 + */ 156 + export function getPlaybackSteps(state: SlideAnimations, slideIndex: number): ElementAnimation[][] { 157 + const anims = getAnimationsForSlide(state, slideIndex); 158 + if (anims.length === 0) return []; 159 + 160 + const sorted = [...anims].sort((a, b) => a.order - b.order); 161 + const steps: ElementAnimation[][] = []; 162 + 163 + for (const anim of sorted) { 164 + if (anim.trigger === 'onClick' || steps.length === 0) { 165 + steps.push([anim]); 166 + } else { 167 + steps[steps.length - 1]!.push(anim); 168 + } 169 + } 170 + 171 + return steps; 172 + } 173 + 174 + /** 175 + * Get the CSS keyframes name for an effect. 176 + */ 177 + export function effectToCssKeyframes(effect: AnimationEffect): string { 178 + const keyframesMap: Record<AnimationEffect, string> = { 179 + none: '', 180 + fadeIn: 'tools-fade-in', 181 + fadeOut: 'tools-fade-out', 182 + slideInLeft: 'tools-slide-in-left', 183 + slideInRight: 'tools-slide-in-right', 184 + slideInUp: 'tools-slide-in-up', 185 + slideInDown: 'tools-slide-in-down', 186 + slideOutLeft: 'tools-slide-out-left', 187 + slideOutRight: 'tools-slide-out-right', 188 + slideOutUp: 'tools-slide-out-up', 189 + slideOutDown: 'tools-slide-out-down', 190 + zoomIn: 'tools-zoom-in', 191 + zoomOut: 'tools-zoom-out', 192 + bounceIn: 'tools-bounce-in', 193 + spinIn: 'tools-spin-in', 194 + }; 195 + return keyframesMap[effect] || ''; 196 + } 197 + 198 + /** 199 + * Generate CSS animation property value for an animation. 200 + */ 201 + export function animationCss(anim: ElementAnimation): string { 202 + const keyframes = effectToCssKeyframes(anim.effect); 203 + if (!keyframes) return ''; 204 + const easing = anim.effect.includes('bounce') ? 'cubic-bezier(0.36, 0.07, 0.19, 0.97)' : 'ease'; 205 + return `${keyframes} ${anim.duration}ms ${easing} ${anim.delay}ms both`; 206 + } 207 + 208 + /** 209 + * Human-readable effect label. 210 + */ 211 + export function effectLabel(effect: AnimationEffect): string { 212 + const labels: Record<AnimationEffect, string> = { 213 + none: 'None', 214 + fadeIn: 'Fade In', 215 + fadeOut: 'Fade Out', 216 + slideInLeft: 'Slide In Left', 217 + slideInRight: 'Slide In Right', 218 + slideInUp: 'Slide In Up', 219 + slideInDown: 'Slide In Down', 220 + slideOutLeft: 'Slide Out Left', 221 + slideOutRight: 'Slide Out Right', 222 + slideOutUp: 'Slide Out Up', 223 + slideOutDown: 'Slide Out Down', 224 + zoomIn: 'Zoom In', 225 + zoomOut: 'Zoom Out', 226 + bounceIn: 'Bounce In', 227 + spinIn: 'Spin In', 228 + }; 229 + return labels[effect] || effect; 230 + }
+114
src/slides/event-handlers.ts
··· 13 13 import { createTransition, setDefaultTransition } from './transitions.js'; 14 14 import { setNotes, nextSlide as presenterNext, prevSlide as presenterPrev } from './presenter-mode.js'; 15 15 import { enterPresenter, exitPresenter, renderPresenter } from './presenter-ui.js'; 16 + import { 17 + addAnimation, removeAnimation, 18 + getAnimationsForSlide, effectLabel, ENTER_EFFECTS, EXIT_EFFECTS, 19 + } from './element-animations.js'; 20 + import type { AnimationEffect, AnimationTrigger } from './element-animations.js'; 16 21 import type { DOMRefs, AppActions } from './types.js'; 17 22 18 23 const $ = (id: string) => document.getElementById(id)!; ··· 30 35 setupTouchHandlers(refs, actions); 31 36 setupKeyboardShortcuts(refs, actions); 32 37 setupTitleEditing(refs, actions); 38 + setupAnimationsPanel(actions); 39 + setupPanelTabs(); 33 40 } 34 41 35 42 // --- Toolbar buttons --- ··· 275 282 }); 276 283 }); 277 284 } 285 + 286 + // --- Panel tabs (Notes / Animations) --- 287 + 288 + function setupPanelTabs(): void { 289 + const tabNotes = document.getElementById('tab-notes'); 290 + const tabAnims = document.getElementById('tab-animations'); 291 + const contentNotes = document.getElementById('tab-content-notes'); 292 + const contentAnims = document.getElementById('tab-content-animations'); 293 + if (!tabNotes || !tabAnims || !contentNotes || !contentAnims) return; 294 + 295 + tabNotes.addEventListener('click', () => { 296 + tabNotes.classList.add('slides-panel-tab--active'); 297 + tabAnims.classList.remove('slides-panel-tab--active'); 298 + contentNotes.style.display = ''; 299 + contentAnims.style.display = 'none'; 300 + }); 301 + tabAnims.addEventListener('click', () => { 302 + tabAnims.classList.add('slides-panel-tab--active'); 303 + tabNotes.classList.remove('slides-panel-tab--active'); 304 + contentAnims.style.display = ''; 305 + contentNotes.style.display = 'none'; 306 + }); 307 + } 308 + 309 + // --- Animations panel --- 310 + 311 + function setupAnimationsPanel(actions: AppActions): void { 312 + const animList = document.getElementById('anim-list'); 313 + const animControls = document.getElementById('anim-controls'); 314 + const animHint = document.getElementById('anim-hint'); 315 + const effectSelect = document.getElementById('anim-effect-select') as HTMLSelectElement | null; 316 + const triggerSelect = document.getElementById('anim-trigger-select') as HTMLSelectElement | null; 317 + const btnAddAnim = document.getElementById('btn-add-anim'); 318 + if (!animList || !animControls || !animHint || !effectSelect || !triggerSelect || !btnAddAnim) return; 319 + 320 + // Populate effect dropdown 321 + const allEffects = [...ENTER_EFFECTS, ...EXIT_EFFECTS]; 322 + for (const effect of allEffects) { 323 + const opt = document.createElement('option'); 324 + opt.value = effect; 325 + opt.textContent = effectLabel(effect); 326 + effectSelect.appendChild(opt); 327 + } 328 + 329 + function renderAnimList() { 330 + const s = actions.getState(); 331 + const slideIdx = s.deck.currentSlide; 332 + const anims = getAnimationsForSlide(s.animations, slideIdx); 333 + const hasSelection = !!s.selectedElementId; 334 + 335 + animControls.style.display = hasSelection ? '' : 'none'; 336 + animHint.style.display = hasSelection ? 'none' : ''; 337 + 338 + if (anims.length === 0) { 339 + animList.innerHTML = '<p style="font-size:0.8rem;color:var(--color-text-muted);">No animations on this slide</p>'; 340 + return; 341 + } 342 + 343 + let html = ''; 344 + for (const anim of anims.sort((a, b) => a.order - b.order)) { 345 + const slide = currentSlide(s.deck); 346 + const el = slide.elements.find(e => e.id === anim.elementId); 347 + const elName = el ? (el.content.slice(0, 20) || el.type) : '(deleted)'; 348 + html += `<div class="anim-row" data-anim-id="${anim.id}">`; 349 + html += `<span class="anim-effect">${effectLabel(anim.effect)}</span>`; 350 + html += `<span class="anim-target" style="font-size:0.75rem;color:var(--color-text-muted);margin-left:4px;">${elName}</span>`; 351 + html += `<button class="btn-icon btn-sm anim-remove" data-anim-id="${anim.id}" title="Remove">&times;</button>`; 352 + html += '</div>'; 353 + } 354 + animList.innerHTML = html; 355 + } 356 + 357 + // Add animation to selected element 358 + btnAddAnim.addEventListener('click', () => { 359 + const s = actions.getState(); 360 + if (!s.selectedElementId) return; 361 + const effect = effectSelect.value as AnimationEffect; 362 + const trigger = triggerSelect.value as AnimationTrigger; 363 + actions.setState({ 364 + animations: addAnimation(s.animations, s.deck.currentSlide, s.selectedElementId, effect, trigger), 365 + }); 366 + actions.syncDeckToYjs(); 367 + renderAnimList(); 368 + }); 369 + 370 + // Remove animation 371 + animList.addEventListener('click', (e) => { 372 + const btn = (e.target as HTMLElement).closest('.anim-remove') as HTMLElement | null; 373 + if (!btn) return; 374 + const animId = btn.dataset.animId; 375 + if (!animId) return; 376 + const s = actions.getState(); 377 + actions.setState({ 378 + animations: removeAnimation(s.animations, s.deck.currentSlide, animId), 379 + }); 380 + actions.syncDeckToYjs(); 381 + renderAnimList(); 382 + }); 383 + 384 + // Re-render on canvas interaction (element selection changes) 385 + const observer = new MutationObserver(() => renderAnimList()); 386 + const slideCanvas = document.getElementById('slide-canvas'); 387 + if (slideCanvas) observer.observe(slideCanvas, { childList: true, subtree: true }); 388 + 389 + // Also re-render after tab switch 390 + document.getElementById('tab-animations')?.addEventListener('click', () => renderAnimList()); 391 + }
+21 -3
src/slides/index.html
··· 70 70 </div> 71 71 </div> 72 72 73 - <!-- Right: Notes panel --> 73 + <!-- Right: Notes & Animations panel --> 74 74 <div class="slides-notes-panel" id="notes-panel"> 75 - <h3 class="slides-notes-title">Speaker Notes</h3> 76 - <textarea class="slides-notes-input" id="notes-input" placeholder="Add speaker notes..."></textarea> 75 + <div class="slides-panel-tabs"> 76 + <button class="slides-panel-tab slides-panel-tab--active" id="tab-notes" data-tab="notes">Notes</button> 77 + <button class="slides-panel-tab" id="tab-animations" data-tab="animations">Animations</button> 78 + </div> 79 + <div class="slides-tab-content" id="tab-content-notes"> 80 + <textarea class="slides-notes-input" id="notes-input" placeholder="Add speaker notes..."></textarea> 81 + </div> 82 + <div class="slides-tab-content" id="tab-content-animations" style="display:none"> 83 + <div class="slides-anim-list" id="anim-list"></div> 84 + <div class="slides-anim-controls" id="anim-controls" style="display:none"> 85 + <select id="anim-effect-select"></select> 86 + <select id="anim-trigger-select"> 87 + <option value="onClick">On Click</option> 88 + <option value="withPrevious">With Previous</option> 89 + <option value="afterPrevious">After Previous</option> 90 + </select> 91 + <button class="btn-secondary btn-sm" id="btn-add-anim">+ Add</button> 92 + </div> 93 + <p class="slides-anim-hint" id="anim-hint">Select an element to add animations</p> 94 + </div> 77 95 </div> 78 96 </main> 79 97
+17
src/slides/main.ts
··· 20 20 import type { SlideTransitions } from './transitions.js'; 21 21 import { createPresenterState } from './presenter-mode.js'; 22 22 import type { PresenterState } from './presenter-mode.js'; 23 + import { createSlideAnimations } from './element-animations.js'; 24 + import type { SlideAnimations } from './element-animations.js'; 23 25 import { createCommandPalette } from '../command-palette.js'; 24 26 25 27 import type { AppState, DOMRefs, AppActions } from './types.js'; ··· 51 53 themedDeck: createThemedDeck(1), 52 54 transitions: createSlideTransitions(), 53 55 presenter: createPresenterState(1), 56 + animations: createSlideAnimations(), 54 57 selectedElementId: null, 55 58 isDragging: false, 56 59 dragStartX: 0, ··· 70 73 yDeck.set('currentSlide', state.deck.currentSlide); 71 74 yDeck.set('themed', JSON.stringify(state.themedDeck)); 72 75 yDeck.set('transitions', JSON.stringify(state.transitions)); 76 + yDeck.set('animations', JSON.stringify({ 77 + animations: Object.fromEntries( 78 + [...state.animations.animations].map(([k, v]) => [k, v]) 79 + ), 80 + })); 73 81 } 74 82 75 83 function loadDeckFromYjs() { ··· 85 93 if (transJson) { 86 94 const parsed = JSON.parse(transJson); 87 95 state.transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 96 + } 97 + const animJson = yDeck.get('animations') as string; 98 + if (animJson) { 99 + const parsed = JSON.parse(animJson); 100 + state.animations = { 101 + animations: new Map(Object.entries(parsed.animations || {}).map( 102 + ([k, v]) => [Number(k), v as any[]] 103 + )), 104 + }; 88 105 } 89 106 } catch { /* use defaults */ } 90 107 }
+2
src/slides/types.ts
··· 9 9 import type { ThemedDeck } from './layouts-themes.js'; 10 10 import type { SlideTransitions } from './transitions.js'; 11 11 import type { PresenterState } from './presenter-mode.js'; 12 + import type { SlideAnimations } from './element-animations.js'; 12 13 13 14 /** Mutable application state — single source of truth in main.ts. */ 14 15 export interface AppState { ··· 16 17 themedDeck: ThemedDeck; 17 18 transitions: SlideTransitions; 18 19 presenter: PresenterState; 20 + animations: SlideAnimations; 19 21 selectedElementId: string | null; 20 22 isDragging: boolean; 21 23 dragStartX: number;
+121 -4
tests/charts.test.ts
··· 4 4 parseDataRange, 5 5 extractChartData, 6 6 transformChartData, 7 + buildChartJsConfig, 7 8 CHART_TYPES, 8 9 } from '../src/sheets/charts.js'; 9 10 ··· 23 24 24 25 // ---- Chart types constant ---- 25 26 describe('CHART_TYPES', () => { 26 - it('exports exactly the four supported chart types', () => { 27 - expect(CHART_TYPES).toEqual(['bar', 'line', 'pie', 'scatter']); 27 + it('exports all supported chart types including new ones', () => { 28 + expect(CHART_TYPES).toEqual(['bar', 'line', 'pie', 'scatter', 'area', 'doughnut', 'radar', 'stackedBar', 'stackedLine']); 28 29 }); 29 30 }); 30 31 ··· 44 45 expect(result.errors).toEqual([]); 45 46 }); 46 47 48 + it('accepts all new chart types', () => { 49 + for (const type of ['area', 'doughnut', 'radar', 'stackedBar', 'stackedLine']) { 50 + const result = validateChartConfig({ ...validConfig, type }); 51 + expect(result.valid).toBe(true); 52 + } 53 + }); 54 + 47 55 it('rejects an unknown chart type', () => { 48 - const result = validateChartConfig({ ...validConfig, type: 'radar' }); 56 + const result = validateChartConfig({ ...validConfig, type: 'bubble' }); 49 57 expect(result.valid).toBe(false); 50 - expect(result.errors).toContain('Invalid chart type: radar'); 58 + expect(result.errors).toContain('Invalid chart type: bubble'); 51 59 }); 52 60 53 61 it('rejects a missing chart type', () => { ··· 261 269 expect(result.labels).toEqual([1, 2, 3]); 262 270 expect(result.datasets).toHaveLength(1); 263 271 expect(result.datasets[0].data).toEqual([10, 20, 30]); 272 + }); 273 + 274 + // ---- New chart type transformations ---- 275 + 276 + it('transforms area chart data same as line', () => { 277 + const rawData = [ 278 + ['Month', 'Revenue'], 279 + ['Jan', 100], 280 + ['Feb', 200], 281 + ['Mar', 300], 282 + ]; 283 + const result = transformChartData(rawData, { type: 'area' }); 284 + expect(result.labels).toEqual(['Jan', 'Feb', 'Mar']); 285 + expect(result.datasets).toHaveLength(1); 286 + expect(result.datasets[0].data).toEqual([100, 200, 300]); 287 + }); 288 + 289 + it('transforms doughnut chart data like pie (first data column only)', () => { 290 + const rawData = [ 291 + ['Category', 'Value'], 292 + ['A', 40], 293 + ['B', 30], 294 + ['C', 20], 295 + ]; 296 + const result = transformChartData(rawData, { type: 'doughnut' }); 297 + expect(result.labels).toEqual(['A', 'B', 'C']); 298 + expect(result.datasets).toHaveLength(1); 299 + expect(result.datasets[0].data).toEqual([40, 30, 20]); 300 + }); 301 + 302 + it('transforms radar chart data with multi-axis labels', () => { 303 + const rawData = [ 304 + ['Axis', 'Team A', 'Team B'], 305 + ['Speed', 80, 60], 306 + ['Power', 90, 70], 307 + ['Agility', 70, 85], 308 + ]; 309 + const result = transformChartData(rawData, { type: 'radar' }); 310 + expect(result.labels).toEqual(['Speed', 'Power', 'Agility']); 311 + expect(result.datasets).toHaveLength(2); 312 + expect(result.datasets[0].label).toBe('Team A'); 313 + expect(result.datasets[0].data).toEqual([80, 90, 70]); 314 + expect(result.datasets[1].label).toBe('Team B'); 315 + expect(result.datasets[1].data).toEqual([60, 70, 85]); 316 + }); 317 + 318 + it('transforms stackedBar data same as bar', () => { 319 + const rawData = [ 320 + ['Q', 'Product A', 'Product B'], 321 + ['Q1', 100, 50], 322 + ['Q2', 150, 75], 323 + ]; 324 + const result = transformChartData(rawData, { type: 'stackedBar' }); 325 + expect(result.labels).toEqual(['Q1', 'Q2']); 326 + expect(result.datasets).toHaveLength(2); 327 + expect(result.datasets[0].data).toEqual([100, 150]); 328 + expect(result.datasets[1].data).toEqual([50, 75]); 329 + }); 330 + 331 + it('transforms stackedLine data same as line', () => { 332 + const rawData = [ 333 + ['Month', 'A', 'B'], 334 + ['Jan', 10, 20], 335 + ['Feb', 30, 40], 336 + ]; 337 + const result = transformChartData(rawData, { type: 'stackedLine' }); 338 + expect(result.labels).toEqual(['Jan', 'Feb']); 339 + expect(result.datasets).toHaveLength(2); 340 + }); 341 + }); 342 + 343 + // ---- buildChartJsConfig for new types ---- 344 + describe('buildChartJsConfig — new types', () => { 345 + const sampleData = { 346 + labels: ['A', 'B', 'C'], 347 + datasets: [{ label: 'S1', data: [10, 20, 30] }], 348 + }; 349 + 350 + it('area chart maps to line type with fill enabled', () => { 351 + const cfg = buildChartJsConfig({ type: 'area', range: 'A1:B3' }, sampleData); 352 + expect(cfg.type).toBe('line'); 353 + expect(cfg.data.datasets[0].fill).toBe(true); 354 + }); 355 + 356 + it('doughnut chart maps to doughnut type with multi-color', () => { 357 + const cfg = buildChartJsConfig({ type: 'doughnut', range: 'A1:B3' }, sampleData); 358 + expect(cfg.type).toBe('doughnut'); 359 + expect(Array.isArray(cfg.data.datasets[0].backgroundColor)).toBe(true); 360 + }); 361 + 362 + it('radar chart maps to radar type with no scales', () => { 363 + const cfg = buildChartJsConfig({ type: 'radar', range: 'A1:B3' }, sampleData); 364 + expect(cfg.type).toBe('radar'); 365 + expect(cfg.options.scales).toBeUndefined(); 366 + }); 367 + 368 + it('stackedBar maps to bar type with stacked scales', () => { 369 + const cfg = buildChartJsConfig({ type: 'stackedBar', range: 'A1:B3' }, sampleData); 370 + expect(cfg.type).toBe('bar'); 371 + expect(cfg.options.scales.x.stacked).toBe(true); 372 + expect(cfg.options.scales.y.stacked).toBe(true); 373 + }); 374 + 375 + it('stackedLine maps to line type with stacked scales and fill', () => { 376 + const cfg = buildChartJsConfig({ type: 'stackedLine', range: 'A1:B3' }, sampleData); 377 + expect(cfg.type).toBe('line'); 378 + expect(cfg.options.scales.x.stacked).toBe(true); 379 + expect(cfg.options.scales.y.stacked).toBe(true); 380 + expect(cfg.data.datasets[0].fill).toBe(true); 264 381 }); 265 382 });
+148
tests/conditional-format.test.ts
··· 7 7 evaluateRule, evaluateRules, buildCfStyle, 8 8 parseHex, toHex, lerpColor, colorScaleBg, computeColorScale, 9 9 formatRuleLabel, 10 + computeDataBars, computeIconSets, 10 11 } from '../src/sheets/conditional-format.js'; 11 12 12 13 describe('evaluateRule', () => { ··· 485 486 486 487 it('handles missing value gracefully', () => { 487 488 expect(formatRuleLabel({ type: 'greaterThan' } as any)).toBe('Greater than '); 489 + }); 490 + 491 + it('formats dataBar and iconSet rule labels', () => { 492 + expect(formatRuleLabel({ type: 'dataBar' } as any)).toBe('Data bar'); 493 + expect(formatRuleLabel({ type: 'iconSet' } as any)).toBe('Icon set'); 494 + }); 495 + }); 496 + 497 + // ============================================================ 498 + // Data Bars 499 + // ============================================================ 500 + describe('computeDataBars', () => { 501 + it('computes bar widths as percentages of max value', () => { 502 + const cells = new Map<string, unknown>([ 503 + ['A1', 25], 504 + ['A2', 50], 505 + ['A3', 100], 506 + ]); 507 + const result = computeDataBars(cells, { type: 'dataBar', barColor: '#4472c4' }); 508 + expect(result.get('A1')?.barWidthPct).toBeCloseTo(25); 509 + expect(result.get('A2')?.barWidthPct).toBeCloseTo(50); 510 + expect(result.get('A3')?.barWidthPct).toBeCloseTo(100); 511 + }); 512 + 513 + it('uses provided barColor', () => { 514 + const cells = new Map<string, unknown>([['A1', 10]]); 515 + const result = computeDataBars(cells, { type: 'dataBar', barColor: '#ff0000' }); 516 + expect(result.get('A1')?.barColor).toBe('#ff0000'); 517 + }); 518 + 519 + it('defaults barColor to blue when not specified', () => { 520 + const cells = new Map<string, unknown>([['A1', 10]]); 521 + const result = computeDataBars(cells, { type: 'dataBar' }); 522 + expect(result.get('A1')?.barColor).toBe('#4472c4'); 523 + }); 524 + 525 + it('handles negative values with bidirectional bars', () => { 526 + const cells = new Map<string, unknown>([ 527 + ['A1', -50], 528 + ['A2', 0], 529 + ['A3', 100], 530 + ]); 531 + const result = computeDataBars(cells, { type: 'dataBar' }); 532 + // Negative bars should have a width based on their absolute value relative to the full range 533 + expect(result.get('A1')?.barWidthPct).toBeGreaterThan(0); 534 + expect(result.get('A1')?.negative).toBe(true); 535 + expect(result.get('A3')?.negative).toBe(false); 536 + }); 537 + 538 + it('skips non-numeric values', () => { 539 + const cells = new Map<string, unknown>([ 540 + ['A1', 'hello'], 541 + ['A2', 50], 542 + ]); 543 + const result = computeDataBars(cells, { type: 'dataBar' }); 544 + expect(result.has('A1')).toBe(false); 545 + expect(result.has('A2')).toBe(true); 546 + }); 547 + 548 + it('handles all equal values', () => { 549 + const cells = new Map<string, unknown>([ 550 + ['A1', 5], 551 + ['A2', 5], 552 + ]); 553 + const result = computeDataBars(cells, { type: 'dataBar' }); 554 + expect(result.get('A1')?.barWidthPct).toBe(100); 555 + expect(result.get('A2')?.barWidthPct).toBe(100); 556 + }); 557 + 558 + it('returns empty map for empty input', () => { 559 + const result = computeDataBars(new Map(), { type: 'dataBar' }); 560 + expect(result.size).toBe(0); 561 + }); 562 + }); 563 + 564 + // ============================================================ 565 + // Icon Sets 566 + // ============================================================ 567 + describe('computeIconSets', () => { 568 + it('assigns 3-icon set based on percentile thresholds', () => { 569 + const cells = new Map<string, unknown>([ 570 + ['A1', 10], // bottom third → icon index 0 571 + ['A2', 50], // middle → icon index 1 572 + ['A3', 90], // top third → icon index 2 573 + ]); 574 + const result = computeIconSets(cells, { type: 'iconSet', iconSetName: 'arrows3' }); 575 + expect(result.get('A1')?.iconIndex).toBe(0); 576 + expect(result.get('A2')?.iconIndex).toBe(1); 577 + expect(result.get('A3')?.iconIndex).toBe(2); 578 + }); 579 + 580 + it('assigns 4-icon set based on quartile thresholds', () => { 581 + const cells = new Map<string, unknown>([ 582 + ['A1', 0], 583 + ['A2', 30], 584 + ['A3', 60], 585 + ['A4', 100], 586 + ]); 587 + const result = computeIconSets(cells, { type: 'iconSet', iconSetName: 'arrows4' }); 588 + expect(result.get('A1')?.iconIndex).toBe(0); 589 + expect(result.get('A4')?.iconIndex).toBe(3); 590 + }); 591 + 592 + it('assigns 5-icon set based on quintile thresholds', () => { 593 + const cells = new Map<string, unknown>([ 594 + ['A1', 0], 595 + ['A2', 25], 596 + ['A3', 50], 597 + ['A4', 75], 598 + ['A5', 100], 599 + ]); 600 + const result = computeIconSets(cells, { type: 'iconSet', iconSetName: 'arrows5' }); 601 + expect(result.get('A1')?.iconIndex).toBe(0); 602 + expect(result.get('A5')?.iconIndex).toBe(4); 603 + }); 604 + 605 + it('defaults to traffic3 icon set', () => { 606 + const cells = new Map<string, unknown>([['A1', 50]]); 607 + const result = computeIconSets(cells, { type: 'iconSet' }); 608 + expect(result.get('A1')?.iconSetName).toBe('traffic3'); 609 + }); 610 + 611 + it('returns icon set name in result', () => { 612 + const cells = new Map<string, unknown>([['A1', 50]]); 613 + const result = computeIconSets(cells, { type: 'iconSet', iconSetName: 'stars3' }); 614 + expect(result.get('A1')?.iconSetName).toBe('stars3'); 615 + }); 616 + 617 + it('skips non-numeric values', () => { 618 + const cells = new Map<string, unknown>([ 619 + ['A1', 'text'], 620 + ['A2', 50], 621 + ]); 622 + const result = computeIconSets(cells, { type: 'iconSet' }); 623 + expect(result.has('A1')).toBe(false); 624 + expect(result.has('A2')).toBe(true); 625 + }); 626 + 627 + it('handles all equal values — all get top icon', () => { 628 + const cells = new Map<string, unknown>([ 629 + ['A1', 5], 630 + ['A2', 5], 631 + ]); 632 + const result = computeIconSets(cells, { type: 'iconSet', iconSetName: 'arrows3' }); 633 + // When all equal, percentile is 0/0 → clamp to max icon 634 + expect(result.get('A1')?.iconIndex).toBe(2); 635 + expect(result.get('A2')?.iconIndex).toBe(2); 488 636 }); 489 637 });
+233
tests/diagram-layers.test.ts
··· 1 + /** 2 + * Tests for diagrams layer system. 3 + * VSDD: Red phase — tests define the spec. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + createLayerState, 8 + addLayer, 9 + removeLayer, 10 + renameLayer, 11 + toggleLayerVisibility, 12 + toggleLayerLock, 13 + moveLayerUp, 14 + moveLayerDown, 15 + assignShapeToLayer, 16 + getShapeLayer, 17 + isShapeVisible, 18 + isShapeLocked, 19 + shapesOnLayer, 20 + visibleShapes, 21 + sortedLayers, 22 + DEFAULT_LAYER_ID, 23 + } from '../src/diagrams/layers.js'; 24 + import { createWhiteboard, addShape } from '../src/diagrams/whiteboard.js'; 25 + 26 + describe('createLayerState', () => { 27 + it('starts with a single default layer', () => { 28 + const state = createLayerState(); 29 + expect(state.layers).toHaveLength(1); 30 + expect(state.layers[0].id).toBe(DEFAULT_LAYER_ID); 31 + expect(state.layers[0].name).toBe('Default'); 32 + expect(state.layers[0].visible).toBe(true); 33 + expect(state.layers[0].locked).toBe(false); 34 + }); 35 + 36 + it('starts with empty assignments', () => { 37 + const state = createLayerState(); 38 + expect(state.assignments.size).toBe(0); 39 + }); 40 + }); 41 + 42 + describe('addLayer', () => { 43 + it('adds a named layer', () => { 44 + let state = createLayerState(); 45 + state = addLayer(state, 'Background'); 46 + expect(state.layers).toHaveLength(2); 47 + expect(state.layers[1].name).toBe('Background'); 48 + expect(state.layers[1].visible).toBe(true); 49 + expect(state.layers[1].locked).toBe(false); 50 + }); 51 + 52 + it('new layer gets higher order than existing', () => { 53 + let state = createLayerState(); 54 + state = addLayer(state, 'Layer 1'); 55 + state = addLayer(state, 'Layer 2'); 56 + expect(state.layers[2].order).toBeGreaterThan(state.layers[1].order); 57 + }); 58 + }); 59 + 60 + describe('removeLayer', () => { 61 + it('removes a layer and moves shapes to default', () => { 62 + let state = createLayerState(); 63 + state = addLayer(state, 'Temp'); 64 + const tempId = state.layers[1].id; 65 + state = assignShapeToLayer(state, 'shape-1', tempId); 66 + state = removeLayer(state, tempId); 67 + expect(state.layers).toHaveLength(1); 68 + expect(getShapeLayer(state, 'shape-1')).toBe(DEFAULT_LAYER_ID); 69 + }); 70 + 71 + it('cannot remove the default layer', () => { 72 + let state = createLayerState(); 73 + state = removeLayer(state, DEFAULT_LAYER_ID); 74 + expect(state.layers).toHaveLength(1); 75 + }); 76 + }); 77 + 78 + describe('renameLayer', () => { 79 + it('renames a layer', () => { 80 + let state = createLayerState(); 81 + state = renameLayer(state, DEFAULT_LAYER_ID, 'Base'); 82 + expect(state.layers[0].name).toBe('Base'); 83 + }); 84 + }); 85 + 86 + describe('toggleLayerVisibility', () => { 87 + it('hides and shows a layer', () => { 88 + let state = createLayerState(); 89 + expect(state.layers[0].visible).toBe(true); 90 + state = toggleLayerVisibility(state, DEFAULT_LAYER_ID); 91 + expect(state.layers[0].visible).toBe(false); 92 + state = toggleLayerVisibility(state, DEFAULT_LAYER_ID); 93 + expect(state.layers[0].visible).toBe(true); 94 + }); 95 + }); 96 + 97 + describe('toggleLayerLock', () => { 98 + it('locks and unlocks a layer', () => { 99 + let state = createLayerState(); 100 + expect(state.layers[0].locked).toBe(false); 101 + state = toggleLayerLock(state, DEFAULT_LAYER_ID); 102 + expect(state.layers[0].locked).toBe(true); 103 + state = toggleLayerLock(state, DEFAULT_LAYER_ID); 104 + expect(state.layers[0].locked).toBe(false); 105 + }); 106 + }); 107 + 108 + describe('moveLayerUp / moveLayerDown', () => { 109 + it('swaps layer order upward', () => { 110 + let state = createLayerState(); 111 + state = addLayer(state, 'Top'); 112 + const topId = state.layers[1].id; 113 + const defaultOrder = state.layers[0].order; 114 + const topOrder = state.layers[1].order; 115 + state = moveLayerDown(state, topId); 116 + const updated = state.layers.find(l => l.id === topId)!; 117 + const defaultUpdated = state.layers.find(l => l.id === DEFAULT_LAYER_ID)!; 118 + expect(updated.order).toBe(defaultOrder); 119 + expect(defaultUpdated.order).toBe(topOrder); 120 + }); 121 + 122 + it('moveLayerUp at top is no-op', () => { 123 + let state = createLayerState(); 124 + state = addLayer(state, 'Top'); 125 + const topId = state.layers[1].id; 126 + const before = state.layers.find(l => l.id === topId)!.order; 127 + state = moveLayerUp(state, topId); 128 + const after = state.layers.find(l => l.id === topId)!.order; 129 + expect(after).toBe(before); 130 + }); 131 + 132 + it('moveLayerDown at bottom is no-op', () => { 133 + let state = createLayerState(); 134 + const before = state.layers[0].order; 135 + state = moveLayerDown(state, DEFAULT_LAYER_ID); 136 + expect(state.layers[0].order).toBe(before); 137 + }); 138 + }); 139 + 140 + describe('assignShapeToLayer / getShapeLayer', () => { 141 + it('assigns a shape to a layer', () => { 142 + let state = createLayerState(); 143 + state = addLayer(state, 'Overlay'); 144 + const overlayId = state.layers[1].id; 145 + state = assignShapeToLayer(state, 'shape-1', overlayId); 146 + expect(getShapeLayer(state, 'shape-1')).toBe(overlayId); 147 + }); 148 + 149 + it('unassigned shapes are on the default layer', () => { 150 + const state = createLayerState(); 151 + expect(getShapeLayer(state, 'shape-xyz')).toBe(DEFAULT_LAYER_ID); 152 + }); 153 + 154 + it('assigning to default layer removes the explicit assignment', () => { 155 + let state = createLayerState(); 156 + state = addLayer(state, 'Overlay'); 157 + const overlayId = state.layers[1].id; 158 + state = assignShapeToLayer(state, 'shape-1', overlayId); 159 + state = assignShapeToLayer(state, 'shape-1', DEFAULT_LAYER_ID); 160 + expect(state.assignments.has('shape-1')).toBe(false); 161 + }); 162 + }); 163 + 164 + describe('isShapeVisible / isShapeLocked', () => { 165 + it('reflects layer visibility', () => { 166 + let state = createLayerState(); 167 + expect(isShapeVisible(state, 'shape-1')).toBe(true); 168 + state = toggleLayerVisibility(state, DEFAULT_LAYER_ID); 169 + expect(isShapeVisible(state, 'shape-1')).toBe(false); 170 + }); 171 + 172 + it('reflects layer lock state', () => { 173 + let state = createLayerState(); 174 + expect(isShapeLocked(state, 'shape-1')).toBe(false); 175 + state = toggleLayerLock(state, DEFAULT_LAYER_ID); 176 + expect(isShapeLocked(state, 'shape-1')).toBe(true); 177 + }); 178 + }); 179 + 180 + describe('shapesOnLayer', () => { 181 + it('returns shapes assigned to a specific layer', () => { 182 + let layers = createLayerState(); 183 + layers = addLayer(layers, 'Overlay'); 184 + const overlayId = layers.layers[1].id; 185 + 186 + let board = createWhiteboard(); 187 + board = addShape(board, 'rectangle', 0, 0); 188 + board = addShape(board, 'ellipse', 100, 100); 189 + const shapeIds = [...board.shapes.keys()]; 190 + 191 + layers = assignShapeToLayer(layers, shapeIds[1], overlayId); 192 + 193 + const defaultShapes = shapesOnLayer(layers, board, DEFAULT_LAYER_ID); 194 + const overlayShapes = shapesOnLayer(layers, board, overlayId); 195 + 196 + expect(defaultShapes).toHaveLength(1); 197 + expect(defaultShapes[0].kind).toBe('rectangle'); 198 + expect(overlayShapes).toHaveLength(1); 199 + expect(overlayShapes[0].kind).toBe('ellipse'); 200 + }); 201 + }); 202 + 203 + describe('visibleShapes', () => { 204 + it('excludes shapes on hidden layers', () => { 205 + let layers = createLayerState(); 206 + layers = addLayer(layers, 'Hidden'); 207 + const hiddenId = layers.layers[1].id; 208 + 209 + let board = createWhiteboard(); 210 + board = addShape(board, 'rectangle', 0, 0); 211 + board = addShape(board, 'ellipse', 100, 100); 212 + const shapeIds = [...board.shapes.keys()]; 213 + 214 + layers = assignShapeToLayer(layers, shapeIds[1], hiddenId); 215 + layers = toggleLayerVisibility(layers, hiddenId); 216 + 217 + const visible = visibleShapes(layers, board); 218 + expect(visible).toHaveLength(1); 219 + expect(visible[0].kind).toBe('rectangle'); 220 + }); 221 + }); 222 + 223 + describe('sortedLayers', () => { 224 + it('returns layers sorted by order', () => { 225 + let state = createLayerState(); 226 + state = addLayer(state, 'Mid'); 227 + state = addLayer(state, 'Top'); 228 + const sorted = sortedLayers(state); 229 + expect(sorted[0].name).toBe('Default'); 230 + expect(sorted[1].name).toBe('Mid'); 231 + expect(sorted[2].name).toBe('Top'); 232 + }); 233 + });
+242
tests/element-animations.test.ts
··· 1 + /** 2 + * Tests for per-element slide animations. 3 + * VSDD: Red phase — tests define the spec. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + createSlideAnimations, 8 + addAnimation, 9 + removeAnimation, 10 + updateAnimation, 11 + reorderAnimation, 12 + getAnimationsForSlide, 13 + getPlaybackSteps, 14 + effectToCssKeyframes, 15 + animationCss, 16 + effectLabel, 17 + ENTER_EFFECTS, 18 + EXIT_EFFECTS, 19 + ALL_EFFECTS, 20 + } from '../src/slides/element-animations.js'; 21 + 22 + describe('createSlideAnimations', () => { 23 + it('starts empty', () => { 24 + const state = createSlideAnimations(); 25 + expect(state.animations.size).toBe(0); 26 + }); 27 + }); 28 + 29 + describe('addAnimation', () => { 30 + it('adds an animation to a slide', () => { 31 + let state = createSlideAnimations(); 32 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); 33 + const anims = getAnimationsForSlide(state, 0); 34 + expect(anims).toHaveLength(1); 35 + expect(anims[0].elementId).toBe('el-1'); 36 + expect(anims[0].effect).toBe('fadeIn'); 37 + expect(anims[0].trigger).toBe('onClick'); 38 + expect(anims[0].duration).toBe(500); 39 + expect(anims[0].delay).toBe(0); 40 + expect(anims[0].order).toBe(0); 41 + }); 42 + 43 + it('auto-increments order', () => { 44 + let state = createSlideAnimations(); 45 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); 46 + state = addAnimation(state, 0, 'el-2', 'slideInLeft'); 47 + const anims = getAnimationsForSlide(state, 0); 48 + expect(anims[0].order).toBe(0); 49 + expect(anims[1].order).toBe(1); 50 + }); 51 + 52 + it('supports custom trigger, duration, delay', () => { 53 + let state = createSlideAnimations(); 54 + state = addAnimation(state, 0, 'el-1', 'zoomIn', 'withPrevious', 800, 200); 55 + const anims = getAnimationsForSlide(state, 0); 56 + expect(anims[0].trigger).toBe('withPrevious'); 57 + expect(anims[0].duration).toBe(800); 58 + expect(anims[0].delay).toBe(200); 59 + }); 60 + 61 + it('different slides are independent', () => { 62 + let state = createSlideAnimations(); 63 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); 64 + state = addAnimation(state, 1, 'el-2', 'fadeOut'); 65 + expect(getAnimationsForSlide(state, 0)).toHaveLength(1); 66 + expect(getAnimationsForSlide(state, 1)).toHaveLength(1); 67 + expect(getAnimationsForSlide(state, 2)).toHaveLength(0); 68 + }); 69 + }); 70 + 71 + describe('removeAnimation', () => { 72 + it('removes an animation by ID', () => { 73 + let state = createSlideAnimations(); 74 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); 75 + state = addAnimation(state, 0, 'el-2', 'slideInLeft'); 76 + const animId = getAnimationsForSlide(state, 0)[0].id; 77 + state = removeAnimation(state, 0, animId); 78 + const anims = getAnimationsForSlide(state, 0); 79 + expect(anims).toHaveLength(1); 80 + expect(anims[0].elementId).toBe('el-2'); 81 + }); 82 + 83 + it('cleans up empty slide entry', () => { 84 + let state = createSlideAnimations(); 85 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); 86 + const animId = getAnimationsForSlide(state, 0)[0].id; 87 + state = removeAnimation(state, 0, animId); 88 + expect(state.animations.has(0)).toBe(false); 89 + }); 90 + }); 91 + 92 + describe('updateAnimation', () => { 93 + it('updates animation properties', () => { 94 + let state = createSlideAnimations(); 95 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); 96 + const animId = getAnimationsForSlide(state, 0)[0].id; 97 + state = updateAnimation(state, 0, animId, { 98 + effect: 'slideInRight', 99 + duration: 1000, 100 + delay: 100, 101 + }); 102 + const anim = getAnimationsForSlide(state, 0)[0]; 103 + expect(anim.effect).toBe('slideInRight'); 104 + expect(anim.duration).toBe(1000); 105 + expect(anim.delay).toBe(100); 106 + expect(anim.elementId).toBe('el-1'); // unchanged 107 + }); 108 + }); 109 + 110 + describe('reorderAnimation', () => { 111 + it('moves an animation to a new order position', () => { 112 + let state = createSlideAnimations(); 113 + state = addAnimation(state, 0, 'el-1', 'fadeIn'); // order 0 114 + state = addAnimation(state, 0, 'el-2', 'slideInLeft'); // order 1 115 + state = addAnimation(state, 0, 'el-3', 'zoomIn'); // order 2 116 + const animId = getAnimationsForSlide(state, 0)[2].id; // el-3 117 + state = reorderAnimation(state, 0, animId, 0); // move to front 118 + const anims = getAnimationsForSlide(state, 0); 119 + const el3 = anims.find(a => a.elementId === 'el-3')!; 120 + expect(el3.order).toBe(0); 121 + }); 122 + }); 123 + 124 + describe('getPlaybackSteps', () => { 125 + it('groups onClick animations into separate steps', () => { 126 + let state = createSlideAnimations(); 127 + state = addAnimation(state, 0, 'el-1', 'fadeIn', 'onClick'); 128 + state = addAnimation(state, 0, 'el-2', 'fadeIn', 'onClick'); 129 + const steps = getPlaybackSteps(state, 0); 130 + expect(steps).toHaveLength(2); 131 + expect(steps[0]).toHaveLength(1); 132 + expect(steps[1]).toHaveLength(1); 133 + }); 134 + 135 + it('groups withPrevious with preceding onClick', () => { 136 + let state = createSlideAnimations(); 137 + state = addAnimation(state, 0, 'el-1', 'fadeIn', 'onClick'); 138 + state = addAnimation(state, 0, 'el-2', 'slideInLeft', 'withPrevious'); 139 + const steps = getPlaybackSteps(state, 0); 140 + expect(steps).toHaveLength(1); 141 + expect(steps[0]).toHaveLength(2); 142 + }); 143 + 144 + it('groups afterPrevious with preceding onClick', () => { 145 + let state = createSlideAnimations(); 146 + state = addAnimation(state, 0, 'el-1', 'fadeIn', 'onClick'); 147 + state = addAnimation(state, 0, 'el-2', 'fadeIn', 'afterPrevious'); 148 + state = addAnimation(state, 0, 'el-3', 'fadeIn', 'onClick'); 149 + const steps = getPlaybackSteps(state, 0); 150 + expect(steps).toHaveLength(2); 151 + expect(steps[0]).toHaveLength(2); 152 + expect(steps[1]).toHaveLength(1); 153 + }); 154 + 155 + it('returns empty for slide with no animations', () => { 156 + const state = createSlideAnimations(); 157 + expect(getPlaybackSteps(state, 0)).toEqual([]); 158 + }); 159 + }); 160 + 161 + describe('effectToCssKeyframes', () => { 162 + it('maps effects to keyframe names', () => { 163 + expect(effectToCssKeyframes('fadeIn')).toBe('tools-fade-in'); 164 + expect(effectToCssKeyframes('slideOutLeft')).toBe('tools-slide-out-left'); 165 + expect(effectToCssKeyframes('bounceIn')).toBe('tools-bounce-in'); 166 + }); 167 + 168 + it('returns empty for none', () => { 169 + expect(effectToCssKeyframes('none')).toBe(''); 170 + }); 171 + }); 172 + 173 + describe('animationCss', () => { 174 + it('generates CSS animation shorthand', () => { 175 + const css = animationCss({ 176 + id: 'a1', 177 + elementId: 'el-1', 178 + effect: 'fadeIn', 179 + trigger: 'onClick', 180 + duration: 500, 181 + delay: 0, 182 + order: 0, 183 + }); 184 + expect(css).toContain('tools-fade-in'); 185 + expect(css).toContain('500ms'); 186 + expect(css).toContain('ease'); 187 + }); 188 + 189 + it('returns empty for none effect', () => { 190 + const css = animationCss({ 191 + id: 'a1', 192 + elementId: 'el-1', 193 + effect: 'none', 194 + trigger: 'onClick', 195 + duration: 500, 196 + delay: 0, 197 + order: 0, 198 + }); 199 + expect(css).toBe(''); 200 + }); 201 + 202 + it('uses bounce easing for bounceIn', () => { 203 + const css = animationCss({ 204 + id: 'a1', 205 + elementId: 'el-1', 206 + effect: 'bounceIn', 207 + trigger: 'onClick', 208 + duration: 500, 209 + delay: 0, 210 + order: 0, 211 + }); 212 + expect(css).toContain('cubic-bezier'); 213 + }); 214 + }); 215 + 216 + describe('effectLabel', () => { 217 + it('returns human-readable labels', () => { 218 + expect(effectLabel('fadeIn')).toBe('Fade In'); 219 + expect(effectLabel('slideOutRight')).toBe('Slide Out Right'); 220 + expect(effectLabel('none')).toBe('None'); 221 + }); 222 + }); 223 + 224 + describe('effect lists', () => { 225 + it('ENTER_EFFECTS are all in ALL_EFFECTS', () => { 226 + for (const e of ENTER_EFFECTS) { 227 + expect(ALL_EFFECTS).toContain(e); 228 + } 229 + }); 230 + 231 + it('EXIT_EFFECTS are all in ALL_EFFECTS', () => { 232 + for (const e of EXIT_EFFECTS) { 233 + expect(ALL_EFFECTS).toContain(e); 234 + } 235 + }); 236 + 237 + it('no overlap between enter and exit', () => { 238 + for (const e of ENTER_EFFECTS) { 239 + expect(EXIT_EFFECTS).not.toContain(e); 240 + } 241 + }); 242 + });
+92
tests/landing-type-filter.test.ts
··· 1 + /** 2 + * Tests for landing page document type filter. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { filterByType, countByType, DOC_TYPES, DOC_TYPE_LABELS } from '../src/landing-utils.js'; 6 + import type { DocumentMeta } from '../src/landing-types.js'; 7 + 8 + function makeDocs(): DocumentMeta[] { 9 + return [ 10 + { id: '1', type: 'doc', name_encrypted: null, deleted_at: null, tags: null, created_at: '2026-01-01', updated_at: '2026-01-01' }, 11 + { id: '2', type: 'doc', name_encrypted: null, deleted_at: null, tags: null, created_at: '2026-01-02', updated_at: '2026-01-02' }, 12 + { id: '3', type: 'sheet', name_encrypted: null, deleted_at: null, tags: null, created_at: '2026-01-03', updated_at: '2026-01-03' }, 13 + { id: '4', type: 'slide', name_encrypted: null, deleted_at: null, tags: null, created_at: '2026-01-04', updated_at: '2026-01-04' }, 14 + { id: '5', type: 'diagram', name_encrypted: null, deleted_at: null, tags: null, created_at: '2026-01-05', updated_at: '2026-01-05' }, 15 + { id: '6', type: 'form', name_encrypted: null, deleted_at: null, tags: null, created_at: '2026-01-06', updated_at: '2026-01-06' }, 16 + ]; 17 + } 18 + 19 + describe('filterByType', () => { 20 + const docs = makeDocs(); 21 + 22 + it('returns all docs when filter is null', () => { 23 + expect(filterByType(docs, null)).toHaveLength(6); 24 + }); 25 + 26 + it('returns all docs when filter is undefined', () => { 27 + expect(filterByType(docs, undefined)).toHaveLength(6); 28 + }); 29 + 30 + it('returns all docs when filter is "all"', () => { 31 + expect(filterByType(docs, 'all')).toHaveLength(6); 32 + }); 33 + 34 + it('filters to only docs', () => { 35 + const result = filterByType(docs, 'doc'); 36 + expect(result).toHaveLength(2); 37 + expect(result.every(d => d.type === 'doc')).toBe(true); 38 + }); 39 + 40 + it('filters to only sheets', () => { 41 + const result = filterByType(docs, 'sheet'); 42 + expect(result).toHaveLength(1); 43 + expect(result[0].type).toBe('sheet'); 44 + }); 45 + 46 + it('filters to only slides', () => { 47 + const result = filterByType(docs, 'slide'); 48 + expect(result).toHaveLength(1); 49 + }); 50 + 51 + it('returns empty for type with no documents', () => { 52 + const result = filterByType(docs, 'calendar'); 53 + expect(result).toHaveLength(0); 54 + }); 55 + 56 + it('returns empty for unknown type', () => { 57 + const result = filterByType(docs, 'unknown'); 58 + expect(result).toHaveLength(0); 59 + }); 60 + }); 61 + 62 + describe('countByType', () => { 63 + it('counts documents by type', () => { 64 + const docs = makeDocs(); 65 + const counts = countByType(docs); 66 + expect(counts.doc).toBe(2); 67 + expect(counts.sheet).toBe(1); 68 + expect(counts.slide).toBe(1); 69 + expect(counts.diagram).toBe(1); 70 + expect(counts.form).toBe(1); 71 + expect(counts.calendar).toBe(0); 72 + }); 73 + 74 + it('returns zeros for empty array', () => { 75 + const counts = countByType([]); 76 + for (const type of DOC_TYPES) { 77 + expect(counts[type]).toBe(0); 78 + } 79 + }); 80 + }); 81 + 82 + describe('DOC_TYPES and DOC_TYPE_LABELS', () => { 83 + it('has 6 document types', () => { 84 + expect(DOC_TYPES).toHaveLength(6); 85 + }); 86 + 87 + it('every type has a label', () => { 88 + for (const type of DOC_TYPES) { 89 + expect(DOC_TYPE_LABELS[type]).toBeTruthy(); 90 + } 91 + }); 92 + });