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

Configure Feed

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

Merge pull request 'feat: SEQUENCE function, rich cell types, landing page overhaul' (#210) from feat/sequence-rich-cells-landing into main

scott 0d0a279d f8c94d16

+830 -6
+9 -1
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 - ## [0.16.2] — 2026-03-31 8 + ## [0.17.0] — 2026-03-31 9 9 10 10 ### Added 11 + - Landing page overhaul: grid/list view toggle with localStorage persistence (#274) 12 + - Pinned documents section on landing page showing starred docs as horizontal cards (#274) 13 + - Grid view with CSS grid layout, card UI with hover actions (#274) 14 + - Rich cell types: checkboxes, ratings, and progress bars in sheets (#273) 15 + - Cell type toolbar buttons in sheets overflow menu (#273) 16 + - SEQUENCE(rows, [cols], [start], [step]) dynamic array function (#272) 17 + - Formula tooltip metadata for SEQUENCE, FILTER, SORT, UNIQUE, XLOOKUP, SUMIFS, COUNTIFS, AVERAGEIFS, QUERY (#272) 18 + - Add mobile E2E tests and fix mobile CSS polish (#271) 11 19 - Mobile E2E tests: 22 tests across landing, docs, and sheets for 3 device viewports (Pixel 7, iPhone 14, iPad gen 7) (#271) 12 20 - Playwright mobile device projects (mobile-chrome, mobile-safari, tablet) 13 21 - Unit tests for mobile CSS media query rules (sidebar overlays, link tooltip, toolbar dropdowns)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.16.2", 3 + "version": "0.17.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+186
src/css/app.css
··· 508 508 color: var(--color-text-faint); 509 509 } 510 510 511 + /* --- Pinned section --- */ 512 + .pinned-section { 513 + margin-top: var(--space-lg); 514 + } 515 + .pinned-heading { 516 + font-family: var(--font-body); 517 + font-size: 0.7rem; 518 + font-weight: 600; 519 + text-transform: uppercase; 520 + letter-spacing: 0.08em; 521 + color: var(--color-text-faint); 522 + margin-bottom: var(--space-sm); 523 + } 524 + .pinned-list { 525 + display: flex; 526 + gap: var(--space-sm); 527 + overflow-x: auto; 528 + padding-bottom: var(--space-sm); 529 + } 530 + .pinned-card { 531 + display: flex; 532 + flex-direction: column; 533 + gap: var(--space-xs); 534 + min-width: 140px; 535 + max-width: 180px; 536 + padding: var(--space-sm) var(--space-md); 537 + border: 1px solid var(--color-border); 538 + border-radius: var(--radius-md); 539 + text-decoration: none; 540 + color: inherit; 541 + transition: box-shadow 0.15s, border-color 0.15s; 542 + background: var(--color-bg-elevated); 543 + } 544 + .pinned-card:hover { 545 + box-shadow: var(--shadow-md); 546 + border-color: var(--color-accent); 547 + } 548 + .pinned-card-icon { font-size: 1.2rem; } 549 + .pinned-card-name { 550 + font-size: 0.8rem; 551 + font-weight: 500; 552 + overflow: hidden; 553 + text-overflow: ellipsis; 554 + white-space: nowrap; 555 + } 556 + .pinned-card-type { 557 + font-size: 0.65rem; 558 + text-transform: uppercase; 559 + letter-spacing: 0.05em; 560 + color: var(--color-text-faint); 561 + } 562 + 563 + /* --- View toggle --- */ 564 + .view-toggle { 565 + display: flex; 566 + align-items: center; 567 + justify-content: center; 568 + width: 32px; 569 + height: 32px; 570 + border: 1px solid var(--color-border); 571 + border-radius: var(--radius-sm); 572 + background: var(--color-bg); 573 + cursor: pointer; 574 + color: var(--color-text-secondary); 575 + transition: border-color 0.15s, color 0.15s; 576 + } 577 + .view-toggle:hover { 578 + border-color: var(--color-accent); 579 + color: var(--color-accent); 580 + } 581 + .view-icon { fill: currentColor; } 582 + 511 583 .doc-section { 512 584 margin-top: var(--space-2xl); 513 585 } ··· 529 601 background: var(--color-border); 530 602 border-radius: var(--radius-md); 531 603 overflow: hidden; 604 + } 605 + 606 + /* Grid view */ 607 + .doc-list.grid-view { 608 + display: grid; 609 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 610 + gap: var(--space-md); 611 + background: transparent; 612 + overflow: visible; 613 + } 614 + .doc-grid-card { 615 + display: flex; 616 + flex-direction: column; 617 + gap: var(--space-xs); 618 + padding: var(--space-md); 619 + border: 1px solid var(--color-border); 620 + border-radius: var(--radius-md); 621 + text-decoration: none; 622 + color: inherit; 623 + background: var(--color-bg); 624 + transition: box-shadow 0.15s, border-color 0.15s; 625 + position: relative; 626 + } 627 + .doc-grid-card:hover { 628 + box-shadow: var(--shadow-md); 629 + border-color: var(--color-accent); 630 + } 631 + .doc-grid-card-header { 632 + display: flex; 633 + justify-content: space-between; 634 + align-items: center; 635 + } 636 + .doc-grid-card-header .doc-item-icon { font-size: 1.5rem; } 637 + .doc-grid-card-name { 638 + font-size: 0.85rem; 639 + font-weight: 500; 640 + overflow: hidden; 641 + text-overflow: ellipsis; 642 + white-space: nowrap; 643 + } 644 + .doc-grid-card-footer { 645 + display: flex; 646 + justify-content: space-between; 647 + align-items: center; 648 + margin-top: auto; 649 + } 650 + .doc-grid-card-footer .doc-item-type { 651 + font-size: 0.65rem; 652 + text-transform: uppercase; 653 + letter-spacing: 0.05em; 654 + color: var(--color-text-faint); 655 + } 656 + .doc-grid-card-footer .doc-item-date { 657 + font-size: 0.65rem; 658 + color: var(--color-text-faint); 659 + } 660 + .doc-grid-card-actions { 661 + display: none; 662 + position: absolute; 663 + bottom: var(--space-xs); 664 + right: var(--space-xs); 665 + gap: 2px; 666 + } 667 + .doc-grid-card:hover .doc-grid-card-actions { 668 + display: flex; 532 669 } 533 670 534 671 .doc-item { ··· 2116 2253 :root:not([data-theme="light"]) .sheet-grid td.spill-target .cell-display { 2117 2254 color: oklch(0.7 0 0); 2118 2255 } 2256 + } 2257 + 2258 + /* --- Rich cell types --- */ 2259 + .cell-checkbox { 2260 + cursor: pointer; 2261 + font-size: 1.1em; 2262 + user-select: none; 2263 + display: flex; 2264 + align-items: center; 2265 + justify-content: center; 2266 + width: 100%; 2267 + } 2268 + .cell-rating { 2269 + display: flex; 2270 + gap: 1px; 2271 + align-items: center; 2272 + width: 100%; 2273 + } 2274 + .cell-rating-star { 2275 + cursor: pointer; 2276 + color: oklch(0.7 0 0); 2277 + font-size: 1em; 2278 + line-height: 1; 2279 + user-select: none; 2280 + transition: color 0.1s; 2281 + } 2282 + .cell-rating-star.filled { 2283 + color: oklch(0.75 0.15 85); 2284 + } 2285 + .cell-rating-star:hover { 2286 + color: oklch(0.8 0.18 85); 2287 + } 2288 + .cell-progress { 2289 + display: flex; 2290 + align-items: center; 2291 + gap: 0.3rem; 2292 + width: 100%; 2293 + min-width: 60px; 2294 + } 2295 + .cell-progress-bar { 2296 + height: 0.6rem; 2297 + border-radius: 3px; 2298 + transition: width 0.2s; 2299 + flex-shrink: 0; 2300 + } 2301 + .cell-progress-label { 2302 + font-size: 0.7rem; 2303 + color: oklch(0.6 0 0); 2304 + white-space: nowrap; 2119 2305 } 2120 2306 2121 2307 .sheet-grid td.editing .cell-display { display: none; }
+5
src/index.html
··· 63 63 64 64 <section class="doc-section" id="main-content"> 65 65 <div id="recent-section" class="recent-section"></div> 66 + <div id="pinned-section" class="pinned-section"></div> 66 67 <div class="doc-toolbar"> 67 68 <div class="doc-breadcrumbs" id="breadcrumbs"></div> 68 69 <div class="doc-toolbar-actions"> ··· 79 80 <button class="sort-option" data-sort="type">Type</button> 80 81 </div> 81 82 </div> 83 + <button class="view-toggle" id="view-toggle" title="Toggle grid/list view" aria-label="Toggle view"> 84 + <svg class="view-icon view-icon-grid" viewBox="0 0 16 16" width="16" height="16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg> 85 + <svg class="view-icon view-icon-list" viewBox="0 0 16 16" width="16" height="16"><rect x="1" y="2" width="14" height="2.5" rx="0.5"/><rect x="1" y="6.75" width="14" height="2.5" rx="0.5"/><rect x="1" y="11.5" width="14" height="2.5" rx="0.5"/></svg> 86 + </button> 82 87 <button class="btn-secondary" id="new-folder-btn" title="New Folder">+ Folder</button> 83 88 <button class="btn-secondary" id="backup-export-btn" title="Export backup">&#8681; Backup</button> 84 89 <button class="btn-secondary" id="backup-import-btn" title="Import backup">&#8679; Restore</button>
+81 -2
src/landing.ts
··· 47 47 const backupExportBtn = document.getElementById('backup-export-btn') as HTMLElement; 48 48 const backupImportBtn = document.getElementById('backup-import-btn') as HTMLElement; 49 49 const backupImportInput = document.getElementById('backup-import-input') as HTMLInputElement; 50 + const viewToggleBtn = document.getElementById('view-toggle') as HTMLElement; 50 51 const breadcrumbsEl = document.getElementById('breadcrumbs') as HTMLElement; 51 52 const trashSection = document.getElementById('trash-section') as HTMLElement; 52 53 const trashToggle = document.getElementById('trash-toggle') as HTMLElement; ··· 80 81 let searchQuery = ''; 81 82 let activeTagFilter: string | null = null; 82 83 let trashExpanded = false; 84 + let viewMode: 'list' | 'grid' = (localStorage.getItem('tools-view-mode') as 'list' | 'grid') || 'list'; 83 85 84 86 // Folder modal state 85 87 let folderModalMode: 'create' | 'rename' = 'create'; ··· 492 494 } 493 495 } 494 496 497 + function updateViewToggle() { 498 + if (!viewToggleBtn) return; 499 + const gridIcon = viewToggleBtn.querySelector('.view-icon-grid') as HTMLElement | null; 500 + const listIcon = viewToggleBtn.querySelector('.view-icon-list') as HTMLElement | null; 501 + if (gridIcon) gridIcon.style.display = viewMode === 'list' ? '' : 'none'; 502 + if (listIcon) listIcon.style.display = viewMode === 'grid' ? '' : 'none'; 503 + } 504 + 505 + if (viewToggleBtn) { 506 + viewToggleBtn.addEventListener('click', () => { 507 + viewMode = viewMode === 'list' ? 'grid' : 'list'; 508 + localStorage.setItem('tools-view-mode', viewMode); 509 + renderDocuments(); 510 + }); 511 + } 512 + 495 513 function renderRecentSection(keys: Record<string, string>) { 496 514 const recentEl = document.getElementById('recent-section'); 497 515 if (!recentEl) return; ··· 529 547 }); 530 548 } 531 549 550 + function renderPinnedSection(keys: Record<string, string>) { 551 + const pinnedEl = document.getElementById('pinned-section'); 552 + if (!pinnedEl) return; 553 + 554 + const starSet = starredIdsSet(stars); 555 + if (starSet.size === 0) { 556 + pinnedEl.innerHTML = ''; 557 + return; 558 + } 559 + 560 + const pinned = allDocs.filter(d => starSet.has(d.id)); 561 + if (pinned.length === 0) { 562 + pinnedEl.innerHTML = ''; 563 + return; 564 + } 565 + 566 + let html = '<h3 class="pinned-heading">Pinned</h3><div class="pinned-list">'; 567 + for (const doc of pinned) { 568 + const path = doc.type === 'doc' ? '/docs' : '/sheets'; 569 + const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; 570 + const name = doc._decryptedName || 'Encrypted Document'; 571 + const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#'; 572 + html += `<a class="pinned-card" href="${href}" data-doc-id="${doc.id}"> 573 + <span class="pinned-card-icon">${icon}</span> 574 + <span class="pinned-card-name">${escapeHtml(name)}</span> 575 + <span class="pinned-card-type">${doc.type}</span> 576 + </a>`; 577 + } 578 + html += '</div>'; 579 + pinnedEl.innerHTML = html; 580 + } 581 + 532 582 function renderTagFilter(docs: DocumentMeta[]) { 533 583 let tagBarEl = document.getElementById('tag-filter-bar'); 534 584 const allTags = collectAllTags(docs); ··· 593 643 // Render breadcrumbs 594 644 renderBreadcrumbs(); 595 645 646 + // Render pinned section 647 + renderPinnedSection(keys); 648 + 649 + // Update view toggle icon state 650 + updateViewToggle(); 651 + 596 652 // Render folder cards (only at root, not inside a folder, and not when searching) 597 653 renderFolders(active); 598 654 ··· 609 665 noResultsEl.style.display = searchQuery ? '' : 'none'; 610 666 } else { 611 667 noResultsEl.style.display = 'none'; 612 - let html = '<div class="doc-list">'; 668 + const isGrid = viewMode === 'grid'; 669 + let html = `<div class="doc-list${isGrid ? ' grid-view' : ''}">`; 613 670 for (const doc of sorted) { 614 671 const path = doc.type === 'doc' ? '/docs' : '/sheets'; 615 672 const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; ··· 624 681 const docTags = parseTags(doc.tags); 625 682 const tagsHtml = docTags.map(t => `<span class="doc-tag-pill">${escapeHtml(t)}</span>`).join(''); 626 683 627 - html += ` 684 + if (isGrid) { 685 + html += ` 686 + <a class="doc-grid-card" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 687 + <div class="doc-grid-card-header"> 688 + <span class="doc-item-icon">${icon}</span> 689 + <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 690 + </div> 691 + <span class="doc-grid-card-name">${escapeHtml(name)}</span> 692 + ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''} 693 + <div class="doc-grid-card-footer"> 694 + <span class="doc-item-type">${doc.type}</span> 695 + <span class="doc-item-date">${date}</span> 696 + </div> 697 + <div class="doc-grid-card-actions"> 698 + <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">&#127991;</button> 699 + <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#128193;</button> 700 + <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 701 + <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 702 + </div> 703 + </a>`; 704 + } else { 705 + html += ` 628 706 <a class="doc-item" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 629 707 <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 630 708 <span class="doc-item-icon">${icon}</span> ··· 638 716 <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 639 717 <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 640 718 </a>`; 719 + } 641 720 } 642 721 html += '</div>'; 643 722 docListEl.innerHTML = html;
+86
src/sheets/formula-tooltip.ts
··· 842 842 ], 843 843 }, 844 844 845 + // --- Dynamic Array Functions --- 846 + FILTER: { 847 + desc: 'Returns a filtered version of the source range, returning only rows or columns that meet the specified conditions', 848 + params: [ 849 + { name: 'range', desc: 'The data to filter', required: true }, 850 + { name: 'include', desc: 'Boolean array indicating rows to include', required: true }, 851 + { name: 'if_empty', desc: 'Value to return if no results match', required: false }, 852 + ], 853 + }, 854 + SORT: { 855 + desc: 'Sorts the rows of a given array based on the values in one or more columns', 856 + params: [ 857 + { name: 'range', desc: 'The data to sort', required: true }, 858 + { name: 'sort_column', desc: 'Column index to sort by (default 1)', required: false }, 859 + { name: 'is_ascending', desc: 'TRUE for ascending, FALSE for descending', required: false }, 860 + { name: 'by_col', desc: 'TRUE to sort columns instead of rows', required: false }, 861 + ], 862 + }, 863 + UNIQUE: { 864 + desc: 'Returns unique rows from a range, removing duplicates', 865 + params: [ 866 + { name: 'range', desc: 'The data to filter for unique entries', required: true }, 867 + { name: 'by_col', desc: 'TRUE to compare columns instead of rows', required: false }, 868 + { name: 'exactly_once', desc: 'TRUE to return only entries that appear exactly once', required: false }, 869 + ], 870 + }, 871 + SEQUENCE: { 872 + desc: 'Generates a list of sequential numbers in an array', 873 + params: [ 874 + { name: 'rows', desc: 'Number of rows to return', required: true }, 875 + { name: 'columns', desc: 'Number of columns to return (default 1)', required: false }, 876 + { name: 'start', desc: 'First number in the sequence (default 1)', required: false }, 877 + { name: 'step', desc: 'Amount to increment each value (default 1)', required: false }, 878 + ], 879 + }, 880 + 881 + // --- Lookup Power Functions --- 882 + XLOOKUP: { 883 + desc: 'Searches a range for a match and returns the corresponding item from a second range', 884 + params: [ 885 + { name: 'search_key', desc: 'The value to search for', required: true }, 886 + { name: 'lookup_range', desc: 'The range to search in', required: true }, 887 + { name: 'return_range', desc: 'The range from which to return a value', required: true }, 888 + { name: 'if_not_found', desc: 'Value to return if no match is found', required: false }, 889 + { name: 'match_mode', desc: '0=exact (default), -1=next smaller, 1=next larger', required: false }, 890 + { name: 'search_mode', desc: '1=first-to-last (default), -1=last-to-first', required: false }, 891 + ], 892 + }, 893 + SUMIFS: { 894 + desc: 'Returns the sum of a range based on multiple criteria', 895 + params: [ 896 + { name: 'sum_range', desc: 'The range to sum', required: true }, 897 + { name: 'criteria_range1', desc: 'First range to check against criteria', required: true }, 898 + { name: 'criteria1', desc: 'First condition to match', required: true }, 899 + { name: 'criteria_range2', desc: 'Additional range to check', required: false }, 900 + { name: 'criteria2', desc: 'Additional condition', required: false }, 901 + ], 902 + }, 903 + COUNTIFS: { 904 + desc: 'Returns the count of a range based on multiple criteria', 905 + params: [ 906 + { name: 'criteria_range1', desc: 'First range to check against criteria', required: true }, 907 + { name: 'criteria1', desc: 'First condition to match', required: true }, 908 + { name: 'criteria_range2', desc: 'Additional range to check', required: false }, 909 + { name: 'criteria2', desc: 'Additional condition', required: false }, 910 + ], 911 + }, 912 + AVERAGEIFS: { 913 + desc: 'Returns the average of a range based on multiple criteria', 914 + params: [ 915 + { name: 'average_range', desc: 'The range to average', required: true }, 916 + { name: 'criteria_range1', desc: 'First range to check against criteria', required: true }, 917 + { name: 'criteria1', desc: 'First condition to match', required: true }, 918 + { name: 'criteria_range2', desc: 'Additional range to check', required: false }, 919 + { name: 'criteria2', desc: 'Additional condition', required: false }, 920 + ], 921 + }, 922 + QUERY: { 923 + desc: 'Runs a SQL-like query against a data range', 924 + params: [ 925 + { name: 'data', desc: 'The range of cells to query', required: true }, 926 + { name: 'query', desc: 'SQL-like query string (SELECT, WHERE, ORDER BY, LIMIT)', required: true }, 927 + { name: 'headers', desc: 'Whether the first row contains headers (default TRUE)', required: false }, 928 + ], 929 + }, 930 + 845 931 // --- Visualization --- 846 932 SPARKLINE: { 847 933 desc: 'Creates a miniature chart within a cell (line, bar, or win/loss)',
+21
src/sheets/formulas.ts
··· 1503 1503 return flatResult; 1504 1504 } 1505 1505 1506 + // --- SEQUENCE (#86) --- 1507 + case 'SEQUENCE': { 1508 + // SEQUENCE(rows, [cols], [start], [step]) 1509 + const seqRows = Math.max(1, Math.floor(toNum(args[0]))); 1510 + const seqCols = args.length > 1 ? Math.max(1, Math.floor(toNum(args[1]))) : 1; 1511 + const seqStart = args.length > 2 ? toNum(args[2]) : 1; 1512 + const seqStep = args.length > 3 ? toNum(args[3]) : 1; 1513 + const seqValues: unknown[] = []; 1514 + let seqCurrent = seqStart; 1515 + for (let r = 0; r < seqRows; r++) { 1516 + for (let c = 0; c < seqCols; c++) { 1517 + seqValues.push(seqCurrent); 1518 + seqCurrent += seqStep; 1519 + } 1520 + } 1521 + const seqResult: RangeArray = seqValues as RangeArray; 1522 + seqResult._rangeRows = seqRows; 1523 + seqResult._rangeCols = seqCols; 1524 + return seqResult; 1525 + } 1526 + 1506 1527 // --- QUERY (#85) --- 1507 1528 case 'QUERY': { 1508 1529 // QUERY(data, query, [headers])
+15
src/sheets/index.html
··· 251 251 </button> 252 252 <div class="toolbar-dropdown-divider"></div> 253 253 254 + <!-- Section: Cell Types --> 255 + <button class="toolbar-dropdown-item" id="tb-celltype-checkbox" title="Set as checkbox" role="menuitem"> 256 + <span class="item-icon">☑</span><span class="item-label">Checkbox</span> 257 + </button> 258 + <button class="toolbar-dropdown-item" id="tb-celltype-rating" title="Set as star rating" role="menuitem"> 259 + <span class="item-icon">★</span><span class="item-label">Rating</span> 260 + </button> 261 + <button class="toolbar-dropdown-item" id="tb-celltype-progress" title="Set as progress bar" role="menuitem"> 262 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1" y="6" width="14" height="4" rx="1" fill="none" stroke="currentColor"/><rect x="1" y="6" width="9" height="4" rx="1" fill="currentColor" opacity="0.5"/></svg></span><span class="item-label">Progress bar</span> 263 + </button> 264 + <button class="toolbar-dropdown-item" id="tb-celltype-clear" title="Clear cell type" role="menuitem"> 265 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 3l10 10"/><path d="M13 3l-10 10"/></svg></span><span class="item-label">Clear cell type</span> 266 + </button> 267 + <div class="toolbar-dropdown-divider"></div> 268 + 254 269 <!-- Section: Import / Export --> 255 270 <button class="toolbar-dropdown-item" id="tb-export-csv" title="Export as CSV" role="menuitem"> 256 271 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="11" y2="10"/></svg></span><span class="item-label">Export CSV</span>
+56 -2
src/sheets/main.ts
··· 20 20 import { multiColumnSort } from './sort.js'; 21 21 import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 22 22 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 23 + import { renderInteractiveCell, handleRichCellClick } from './rich-cells.js'; 23 24 import { parseDateValue, showDatePicker } from './date-picker.js'; 24 25 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 25 26 import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; ··· 607 608 const errClass = errInfo ? ' cell-error' : ''; 608 609 const errData = errInfo ? ' data-error-title="' + escapeHtml(errInfo.title) + '" data-error-desc="' + escapeHtml(errInfo.description) + '" data-error-hint="' + escapeHtml(errInfo.hint) + '"' : ''; 609 610 610 - tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 611 + // Rich cell types (checkbox, rating, progress bar) 612 + const richHtml = renderInteractiveCell(cellData?.s?.cellType, cellData?.v ?? displayValue); 613 + const cellContent = richHtml ?? escapeHtml(displayValue); 614 + 615 + tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + cellContent + '</div>'; 611 616 } 612 617 613 618 // Dropdown arrow for list validation ··· 4607 4612 4608 4613 document.getElementById('tb-validation').addEventListener('click', () => { closeAllDropdowns(); showValidationModal(); }); 4609 4614 4615 + // --- Cell type toolbar buttons --- 4616 + function setCellTypeForSelection(cellType: string | undefined) { 4617 + closeAllDropdowns(); 4618 + const range = selectionRange ? normalizeRange(selectionRange) : { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 4619 + for (let r = range.startRow; r <= range.endRow; r++) { 4620 + for (let c = range.startCol; c <= range.endCol; c++) { 4621 + const id = cellId(c, r); 4622 + const data = getCellData(id); 4623 + const style = data?.s ?? {}; 4624 + if (cellType) { 4625 + (style as Record<string, unknown>).cellType = cellType; 4626 + } else { 4627 + delete (style as Record<string, unknown>).cellType; 4628 + } 4629 + // Set default value if empty 4630 + let value = data?.v ?? ''; 4631 + if (cellType && (value === '' || value === undefined)) { 4632 + if (cellType === 'boolean') value = false; 4633 + else if (cellType === 'rating') value = 0; 4634 + else if (cellType === 'progress') value = 0; 4635 + } 4636 + setCellData(id, { v: value, f: data?.f ?? '', s: style }); 4637 + } 4638 + } 4639 + renderGrid(); 4640 + } 4641 + document.getElementById('tb-celltype-checkbox')!.addEventListener('click', () => setCellTypeForSelection('boolean')); 4642 + document.getElementById('tb-celltype-rating')!.addEventListener('click', () => setCellTypeForSelection('rating')); 4643 + document.getElementById('tb-celltype-progress')!.addEventListener('click', () => setCellTypeForSelection('progress')); 4644 + document.getElementById('tb-celltype-clear')!.addEventListener('click', () => setCellTypeForSelection(undefined)); 4645 + 4646 + // --- Rich cell click handler (checkbox toggle, star rating) --- 4647 + grid.addEventListener('click', (e) => { 4648 + const target = e.target as HTMLElement; 4649 + const richEl = target.closest('[data-rich]') as HTMLElement | null; 4650 + if (!richEl) return; 4651 + const td = richEl.closest('td'); 4652 + if (!td) return; 4653 + const cellId = td.dataset.id; 4654 + if (!cellId) return; 4655 + const cellData = getCellData(cellId); 4656 + const result = handleRichCellClick(target, cellData?.v); 4657 + if (result) { 4658 + e.stopPropagation(); 4659 + setCellData(cellId, { v: result.value, f: '', s: cellData?.s ?? {} }); 4660 + refreshVisibleCells(); 4661 + } 4662 + }); 4663 + 4610 4664 // --- Validation dropdown click handler --- 4611 4665 grid.addEventListener('click', (e) => { 4612 - const arrow = e.target.closest('.cell-dropdown-arrow'); 4666 + const arrow = (e.target as HTMLElement).closest('.cell-dropdown-arrow'); 4613 4667 if (!arrow) return; 4614 4668 e.stopPropagation(); 4615 4669 const cellIdStr = arrow.dataset.dropdownCell;
+75
src/sheets/rich-cells.ts
··· 201 201 return { type }; 202 202 } 203 203 } 204 + 205 + // --- Interactive Rich Cell Renderers --- 206 + 207 + /** 208 + * Render interactive HTML for a rich cell type. 209 + * Returns an HTML string for the cell-display div, or null if not an interactive type. 210 + */ 211 + export function renderInteractiveCell( 212 + cellType: RichCellType | undefined, 213 + value: unknown, 214 + ): string | null { 215 + if (!cellType) return null; 216 + switch (cellType) { 217 + case 'boolean': return renderCheckboxHtml(value); 218 + case 'rating': return renderRatingHtml(value); 219 + case 'progress': return renderProgressHtml(value); 220 + default: return null; 221 + } 222 + } 223 + 224 + /** Render a checkbox cell. */ 225 + function renderCheckboxHtml(value: unknown): string { 226 + const checked = value === true || value === 1 || value === 'true' || value === 'TRUE'; 227 + return `<span class="cell-checkbox" data-rich="checkbox" role="checkbox" aria-checked="${checked}">${checked ? '☑' : '☐'}</span>`; 228 + } 229 + 230 + /** Render a star rating cell (1-5 stars). */ 231 + function renderRatingHtml(value: unknown): string { 232 + const num = typeof value === 'number' ? value : Number(value) || 0; 233 + const rating = Math.max(0, Math.min(5, Math.round(num))); 234 + const stars: string[] = []; 235 + for (let i = 1; i <= 5; i++) { 236 + const filled = i <= rating; 237 + stars.push( 238 + `<span class="cell-rating-star${filled ? ' filled' : ''}" data-rich="rating" data-star="${i}" role="radio" aria-checked="${filled}">★</span>`, 239 + ); 240 + } 241 + return `<span class="cell-rating">${stars.join('')}</span>`; 242 + } 243 + 244 + /** Render a progress bar cell (0-100). */ 245 + function renderProgressHtml(value: unknown): string { 246 + const num = typeof value === 'number' ? value : Number(value) || 0; 247 + const pct = Math.max(0, Math.min(100, Math.round(num))); 248 + const hue = Math.round((pct / 100) * 120); // 0=red → 120=green 249 + return `<span class="cell-progress" data-rich="progress" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><span class="cell-progress-bar" style="width:${pct}%;background:oklch(0.65 0.15 ${hue})"></span><span class="cell-progress-label">${pct}%</span></span>`; 250 + } 251 + 252 + /** 253 + * Handle a click on a rich cell element. 254 + * Returns the new value, or null if the click wasn't on an interactive element. 255 + */ 256 + export function handleRichCellClick( 257 + target: HTMLElement, 258 + currentValue: unknown, 259 + ): { value: unknown } | null { 260 + const richAttr = target.dataset.rich || target.closest<HTMLElement>('[data-rich]')?.dataset.rich; 261 + if (!richAttr) return null; 262 + 263 + switch (richAttr) { 264 + case 'checkbox': { 265 + const checked = currentValue === true || currentValue === 1 || currentValue === 'true' || currentValue === 'TRUE'; 266 + return { value: !checked }; 267 + } 268 + case 'rating': { 269 + const starEl = target.closest<HTMLElement>('[data-star]'); 270 + if (!starEl) return null; 271 + const star = parseInt(starEl.dataset.star || '0', 10); 272 + const current = typeof currentValue === 'number' ? currentValue : Number(currentValue) || 0; 273 + return { value: star === Math.round(current) ? 0 : star }; 274 + } 275 + default: 276 + return null; 277 + } 278 + }
+3
src/sheets/types.ts
··· 20 20 right?: string; 21 21 } 22 22 23 + export type InteractiveCellType = 'boolean' | 'rating' | 'progress'; 24 + 23 25 export interface CellStyle { 24 26 bold?: boolean; 25 27 italic?: boolean; ··· 34 36 format?: 'auto' | 'number' | 'currency' | 'percent' | 'date' | 'text'; 35 37 borders?: BorderStyle; 36 38 wrap?: boolean; 39 + cellType?: InteractiveCellType; 37 40 } 38 41 39 42 // --- Cell Data Types ---
+51
tests/formulas.test.ts
··· 867 867 expect(evalWith('LAMBDA(x, x+1)')).toBe('#VALUE!'); 868 868 }); 869 869 }); 870 + 871 + // --- SEQUENCE (#86) --- 872 + describe('SEQUENCE', () => { 873 + it('generates a single column of sequential numbers', () => { 874 + const result = evalWith('SEQUENCE(4)'); 875 + expect(Array.isArray(result)).toBe(true); 876 + expect([...result]).toEqual([1, 2, 3, 4]); 877 + expect(result._rangeRows).toBe(4); 878 + expect(result._rangeCols).toBe(1); 879 + }); 880 + 881 + it('generates a 2D grid', () => { 882 + const result = evalWith('SEQUENCE(3, 2)'); 883 + expect([...result]).toEqual([1, 2, 3, 4, 5, 6]); 884 + expect(result._rangeRows).toBe(3); 885 + expect(result._rangeCols).toBe(2); 886 + }); 887 + 888 + it('supports custom start value', () => { 889 + const result = evalWith('SEQUENCE(3, 1, 10)'); 890 + expect([...result]).toEqual([10, 11, 12]); 891 + }); 892 + 893 + it('supports custom step value', () => { 894 + const result = evalWith('SEQUENCE(4, 1, 0, 5)'); 895 + expect([...result]).toEqual([0, 5, 10, 15]); 896 + }); 897 + 898 + it('supports negative step', () => { 899 + const result = evalWith('SEQUENCE(3, 1, 10, -3)'); 900 + expect([...result]).toEqual([10, 7, 4]); 901 + }); 902 + 903 + it('supports decimal start and step', () => { 904 + const result = evalWith('SEQUENCE(3, 1, 0.5, 0.5)'); 905 + expect([...result]).toEqual([0.5, 1, 1.5]); 906 + }); 907 + 908 + it('generates 2x3 grid with start and step', () => { 909 + const result = evalWith('SEQUENCE(2, 3, 100, 10)'); 910 + expect([...result]).toEqual([100, 110, 120, 130, 140, 150]); 911 + expect(result._rangeRows).toBe(2); 912 + expect(result._rangeCols).toBe(3); 913 + }); 914 + 915 + it('clamps rows and cols to minimum of 1', () => { 916 + const result = evalWith('SEQUENCE(0)'); 917 + expect([...result]).toEqual([1]); 918 + expect(result._rangeRows).toBe(1); 919 + }); 920 + });
+87
tests/landing-overhaul.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { readFileSync } from 'fs'; 3 + import { resolve } from 'path'; 4 + 5 + function loadFile(relativePath: string) { 6 + return readFileSync(resolve(__dirname, '..', relativePath), 'utf-8'); 7 + } 8 + 9 + describe('Landing page overhaul', () => { 10 + describe('HTML structure', () => { 11 + let html: string; 12 + 13 + beforeEach(() => { 14 + html = loadFile('src/index.html'); 15 + }); 16 + 17 + it('has pinned section container', () => { 18 + expect(html).toMatch(/id="pinned-section"/); 19 + }); 20 + 21 + it('has view toggle button', () => { 22 + expect(html).toMatch(/id="view-toggle"/); 23 + }); 24 + 25 + it('view toggle has grid and list icons', () => { 26 + expect(html).toMatch(/view-icon-grid/); 27 + expect(html).toMatch(/view-icon-list/); 28 + }); 29 + 30 + it('has cell type buttons in sheets toolbar', () => { 31 + const sheetsHtml = loadFile('src/sheets/index.html'); 32 + expect(sheetsHtml).toMatch(/id="tb-celltype-checkbox"/); 33 + expect(sheetsHtml).toMatch(/id="tb-celltype-rating"/); 34 + expect(sheetsHtml).toMatch(/id="tb-celltype-progress"/); 35 + expect(sheetsHtml).toMatch(/id="tb-celltype-clear"/); 36 + }); 37 + }); 38 + 39 + describe('CSS styles', () => { 40 + let css: string; 41 + 42 + beforeEach(() => { 43 + css = loadFile('src/css/app.css'); 44 + }); 45 + 46 + it('has pinned section styles', () => { 47 + expect(css).toMatch(/\.pinned-section/); 48 + expect(css).toMatch(/\.pinned-heading/); 49 + expect(css).toMatch(/\.pinned-list/); 50 + expect(css).toMatch(/\.pinned-card/); 51 + }); 52 + 53 + it('has view toggle styles', () => { 54 + expect(css).toMatch(/\.view-toggle/); 55 + expect(css).toMatch(/\.view-icon/); 56 + }); 57 + 58 + it('has grid view styles', () => { 59 + expect(css).toMatch(/\.doc-list\.grid-view/); 60 + expect(css).toMatch(/\.doc-grid-card/); 61 + expect(css).toMatch(/\.doc-grid-card-header/); 62 + expect(css).toMatch(/\.doc-grid-card-name/); 63 + expect(css).toMatch(/\.doc-grid-card-footer/); 64 + expect(css).toMatch(/\.doc-grid-card-actions/); 65 + }); 66 + 67 + it('grid view uses CSS grid layout', () => { 68 + expect(css).toMatch(/\.doc-list\.grid-view[\s\S]*?display:\s*grid/); 69 + expect(css).toMatch(/grid-template-columns.*auto-fill/); 70 + }); 71 + 72 + it('grid card actions hidden by default, shown on hover', () => { 73 + expect(css).toMatch(/\.doc-grid-card-actions[\s\S]*?display:\s*none/); 74 + expect(css).toMatch(/\.doc-grid-card:hover\s+\.doc-grid-card-actions[\s\S]*?display:\s*flex/); 75 + }); 76 + 77 + it('has rich cell type styles', () => { 78 + expect(css).toMatch(/\.cell-checkbox/); 79 + expect(css).toMatch(/\.cell-rating/); 80 + expect(css).toMatch(/\.cell-rating-star/); 81 + expect(css).toMatch(/\.cell-progress/); 82 + expect(css).toMatch(/\.cell-progress-bar/); 83 + }); 84 + }); 85 + }); 86 + 87 + import { beforeEach } from 'vitest';
+154
tests/rich-cells.test.ts
··· 1 + // @vitest-environment jsdom 1 2 import { describe, it, expect } from 'vitest'; 2 3 import { 3 4 detectCellType, ··· 6 7 validateRichValue, 7 8 richCellClass, 8 9 defaultConfig, 10 + renderInteractiveCell, 11 + handleRichCellClick, 9 12 type RichCellConfig, 10 13 } from '../src/sheets/rich-cells.js'; 11 14 ··· 167 170 expect(c.maxRating).toBe(5); 168 171 }); 169 172 }); 173 + 174 + describe('renderInteractiveCell', () => { 175 + it('returns null for undefined type', () => { 176 + expect(renderInteractiveCell(undefined, 'hello')).toBeNull(); 177 + }); 178 + 179 + it('returns null for non-interactive types', () => { 180 + expect(renderInteractiveCell('text', 'hello')).toBeNull(); 181 + expect(renderInteractiveCell('number', 42)).toBeNull(); 182 + }); 183 + 184 + describe('checkbox', () => { 185 + it('renders unchecked for false', () => { 186 + const html = renderInteractiveCell('boolean', false)!; 187 + expect(html).toContain('cell-checkbox'); 188 + expect(html).toContain('☐'); 189 + expect(html).toContain('aria-checked="false"'); 190 + }); 191 + 192 + it('renders checked for true', () => { 193 + const html = renderInteractiveCell('boolean', true)!; 194 + expect(html).toContain('☑'); 195 + expect(html).toContain('aria-checked="true"'); 196 + }); 197 + 198 + it('renders checked for truthy values', () => { 199 + expect(renderInteractiveCell('boolean', 1)).toContain('☑'); 200 + expect(renderInteractiveCell('boolean', 'TRUE')).toContain('☑'); 201 + expect(renderInteractiveCell('boolean', 'true')).toContain('☑'); 202 + }); 203 + 204 + it('renders unchecked for falsy values', () => { 205 + expect(renderInteractiveCell('boolean', 0)).toContain('☐'); 206 + expect(renderInteractiveCell('boolean', '')).toContain('☐'); 207 + }); 208 + }); 209 + 210 + describe('rating', () => { 211 + it('renders 5 stars', () => { 212 + const html = renderInteractiveCell('rating', 3)!; 213 + expect(html).toContain('cell-rating'); 214 + const filled = (html.match(/filled/g) || []).length; 215 + expect(filled).toBe(3); 216 + const stars = (html.match(/cell-rating-star/g) || []).length; 217 + expect(stars).toBe(5); 218 + }); 219 + 220 + it('renders 0 filled stars for 0', () => { 221 + const html = renderInteractiveCell('rating', 0)!; 222 + expect(html).not.toContain('filled'); 223 + }); 224 + 225 + it('renders all filled for 5', () => { 226 + const html = renderInteractiveCell('rating', 5)!; 227 + const filled = (html.match(/filled/g) || []).length; 228 + expect(filled).toBe(5); 229 + }); 230 + 231 + it('clamps to 0-5 range', () => { 232 + const html = renderInteractiveCell('rating', 10)!; 233 + const filled = (html.match(/filled/g) || []).length; 234 + expect(filled).toBe(5); 235 + }); 236 + 237 + it('includes data-star attributes', () => { 238 + const html = renderInteractiveCell('rating', 2)!; 239 + expect(html).toContain('data-star="1"'); 240 + expect(html).toContain('data-star="5"'); 241 + }); 242 + }); 243 + 244 + describe('progress', () => { 245 + it('renders progress bar with percentage', () => { 246 + const html = renderInteractiveCell('progress', 75)!; 247 + expect(html).toContain('cell-progress'); 248 + expect(html).toContain('cell-progress-bar'); 249 + expect(html).toContain('width:75%'); 250 + expect(html).toContain('75%'); 251 + }); 252 + 253 + it('clamps to 0-100', () => { 254 + const html = renderInteractiveCell('progress', 150)!; 255 + expect(html).toContain('width:100%'); 256 + expect(html).toContain('100%'); 257 + }); 258 + 259 + it('renders 0% for negative values', () => { 260 + const html = renderInteractiveCell('progress', -10)!; 261 + expect(html).toContain('width:0%'); 262 + expect(html).toContain('0%'); 263 + }); 264 + 265 + it('has progressbar ARIA role', () => { 266 + const html = renderInteractiveCell('progress', 50)!; 267 + expect(html).toContain('role="progressbar"'); 268 + expect(html).toContain('aria-valuenow="50"'); 269 + }); 270 + }); 271 + }); 272 + 273 + describe('handleRichCellClick', () => { 274 + function makeEl(attrs: Record<string, string>): HTMLElement { 275 + const el = document.createElement('span'); 276 + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); 277 + return el; 278 + } 279 + 280 + it('returns null for non-rich elements', () => { 281 + const el = makeEl({}); 282 + expect(handleRichCellClick(el, false)).toBeNull(); 283 + }); 284 + 285 + describe('checkbox toggle', () => { 286 + it('toggles false to true', () => { 287 + const el = makeEl({ 'data-rich': 'checkbox' }); 288 + expect(handleRichCellClick(el, false)).toEqual({ value: true }); 289 + }); 290 + 291 + it('toggles true to false', () => { 292 + const el = makeEl({ 'data-rich': 'checkbox' }); 293 + expect(handleRichCellClick(el, true)).toEqual({ value: false }); 294 + }); 295 + 296 + it('toggles 1 to false', () => { 297 + const el = makeEl({ 'data-rich': 'checkbox' }); 298 + expect(handleRichCellClick(el, 1)).toEqual({ value: false }); 299 + }); 300 + }); 301 + 302 + describe('rating click', () => { 303 + it('sets rating from star click', () => { 304 + const star = makeEl({ 'data-rich': 'rating', 'data-star': '3' }); 305 + expect(handleRichCellClick(star, 0)).toEqual({ value: 3 }); 306 + }); 307 + 308 + it('clears rating when clicking same star', () => { 309 + const star = makeEl({ 'data-rich': 'rating', 'data-star': '3' }); 310 + expect(handleRichCellClick(star, 3)).toEqual({ value: 0 }); 311 + }); 312 + 313 + it('changes rating to different star', () => { 314 + const star = makeEl({ 'data-rich': 'rating', 'data-star': '5' }); 315 + expect(handleRichCellClick(star, 3)).toEqual({ value: 5 }); 316 + }); 317 + 318 + it('returns null without data-star', () => { 319 + const el = makeEl({ 'data-rich': 'rating' }); 320 + expect(handleRichCellClick(el, 3)).toBeNull(); 321 + }); 322 + }); 323 + });