···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788-## [0.16.2] — 2026-03-31
88+## [0.17.0] — 2026-03-31
991010### Added
1111+- Landing page overhaul: grid/list view toggle with localStorage persistence (#274)
1212+- Pinned documents section on landing page showing starred docs as horizontal cards (#274)
1313+- Grid view with CSS grid layout, card UI with hover actions (#274)
1414+- Rich cell types: checkboxes, ratings, and progress bars in sheets (#273)
1515+- Cell type toolbar buttons in sheets overflow menu (#273)
1616+- SEQUENCE(rows, [cols], [start], [step]) dynamic array function (#272)
1717+- Formula tooltip metadata for SEQUENCE, FILTER, SORT, UNIQUE, XLOOKUP, SUMIFS, COUNTIFS, AVERAGEIFS, QUERY (#272)
1818+- Add mobile E2E tests and fix mobile CSS polish (#271)
1119- Mobile E2E tests: 22 tests across landing, docs, and sheets for 3 device viewports (Pixel 7, iPhone 14, iPad gen 7) (#271)
1220- Playwright mobile device projects (mobile-chrome, mobile-safari, tablet)
1321- Unit tests for mobile CSS media query rules (sidebar overlays, link tooltip, toolbar dropdowns)
···4747const backupExportBtn = document.getElementById('backup-export-btn') as HTMLElement;
4848const backupImportBtn = document.getElementById('backup-import-btn') as HTMLElement;
4949const backupImportInput = document.getElementById('backup-import-input') as HTMLInputElement;
5050+const viewToggleBtn = document.getElementById('view-toggle') as HTMLElement;
5051const breadcrumbsEl = document.getElementById('breadcrumbs') as HTMLElement;
5152const trashSection = document.getElementById('trash-section') as HTMLElement;
5253const trashToggle = document.getElementById('trash-toggle') as HTMLElement;
···8081let searchQuery = '';
8182let activeTagFilter: string | null = null;
8283let trashExpanded = false;
8484+let viewMode: 'list' | 'grid' = (localStorage.getItem('tools-view-mode') as 'list' | 'grid') || 'list';
83858486// Folder modal state
8587let folderModalMode: 'create' | 'rename' = 'create';
···492494 }
493495}
494496497497+function updateViewToggle() {
498498+ if (!viewToggleBtn) return;
499499+ const gridIcon = viewToggleBtn.querySelector('.view-icon-grid') as HTMLElement | null;
500500+ const listIcon = viewToggleBtn.querySelector('.view-icon-list') as HTMLElement | null;
501501+ if (gridIcon) gridIcon.style.display = viewMode === 'list' ? '' : 'none';
502502+ if (listIcon) listIcon.style.display = viewMode === 'grid' ? '' : 'none';
503503+}
504504+505505+if (viewToggleBtn) {
506506+ viewToggleBtn.addEventListener('click', () => {
507507+ viewMode = viewMode === 'list' ? 'grid' : 'list';
508508+ localStorage.setItem('tools-view-mode', viewMode);
509509+ renderDocuments();
510510+ });
511511+}
512512+495513function renderRecentSection(keys: Record<string, string>) {
496514 const recentEl = document.getElementById('recent-section');
497515 if (!recentEl) return;
···529547 });
530548}
531549550550+function renderPinnedSection(keys: Record<string, string>) {
551551+ const pinnedEl = document.getElementById('pinned-section');
552552+ if (!pinnedEl) return;
553553+554554+ const starSet = starredIdsSet(stars);
555555+ if (starSet.size === 0) {
556556+ pinnedEl.innerHTML = '';
557557+ return;
558558+ }
559559+560560+ const pinned = allDocs.filter(d => starSet.has(d.id));
561561+ if (pinned.length === 0) {
562562+ pinnedEl.innerHTML = '';
563563+ return;
564564+ }
565565+566566+ let html = '<h3 class="pinned-heading">Pinned</h3><div class="pinned-list">';
567567+ for (const doc of pinned) {
568568+ const path = doc.type === 'doc' ? '/docs' : '/sheets';
569569+ const icon = doc.type === 'doc' ? '✎' : '▦';
570570+ const name = doc._decryptedName || 'Encrypted Document';
571571+ const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#';
572572+ html += `<a class="pinned-card" href="${href}" data-doc-id="${doc.id}">
573573+ <span class="pinned-card-icon">${icon}</span>
574574+ <span class="pinned-card-name">${escapeHtml(name)}</span>
575575+ <span class="pinned-card-type">${doc.type}</span>
576576+ </a>`;
577577+ }
578578+ html += '</div>';
579579+ pinnedEl.innerHTML = html;
580580+}
581581+532582function renderTagFilter(docs: DocumentMeta[]) {
533583 let tagBarEl = document.getElementById('tag-filter-bar');
534584 const allTags = collectAllTags(docs);
···593643 // Render breadcrumbs
594644 renderBreadcrumbs();
595645646646+ // Render pinned section
647647+ renderPinnedSection(keys);
648648+649649+ // Update view toggle icon state
650650+ updateViewToggle();
651651+596652 // Render folder cards (only at root, not inside a folder, and not when searching)
597653 renderFolders(active);
598654···609665 noResultsEl.style.display = searchQuery ? '' : 'none';
610666 } else {
611667 noResultsEl.style.display = 'none';
612612- let html = '<div class="doc-list">';
668668+ const isGrid = viewMode === 'grid';
669669+ let html = `<div class="doc-list${isGrid ? ' grid-view' : ''}">`;
613670 for (const doc of sorted) {
614671 const path = doc.type === 'doc' ? '/docs' : '/sheets';
615672 const icon = doc.type === 'doc' ? '✎' : '▦';
···624681 const docTags = parseTags(doc.tags);
625682 const tagsHtml = docTags.map(t => `<span class="doc-tag-pill">${escapeHtml(t)}</span>`).join('');
626683627627- html += `
684684+ if (isGrid) {
685685+ html += `
686686+ <a class="doc-grid-card" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}">
687687+ <div class="doc-grid-card-header">
688688+ <span class="doc-item-icon">${icon}</span>
689689+ <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button>
690690+ </div>
691691+ <span class="doc-grid-card-name">${escapeHtml(name)}</span>
692692+ ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''}
693693+ <div class="doc-grid-card-footer">
694694+ <span class="doc-item-type">${doc.type}</span>
695695+ <span class="doc-item-date">${date}</span>
696696+ </div>
697697+ <div class="doc-grid-card-actions">
698698+ <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">🏷</button>
699699+ <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">📁</button>
700700+ <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">⧉</button>
701701+ <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">✕</button>
702702+ </div>
703703+ </a>`;
704704+ } else {
705705+ html += `
628706 <a class="doc-item" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}">
629707 <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button>
630708 <span class="doc-item-icon">${icon}</span>
···638716 <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">⧉</button>
639717 <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">✕</button>
640718 </a>`;
719719+ }
641720 }
642721 html += '</div>';
643722 docListEl.innerHTML = html;
+86
src/sheets/formula-tooltip.ts
···842842 ],
843843 },
844844845845+ // --- Dynamic Array Functions ---
846846+ FILTER: {
847847+ desc: 'Returns a filtered version of the source range, returning only rows or columns that meet the specified conditions',
848848+ params: [
849849+ { name: 'range', desc: 'The data to filter', required: true },
850850+ { name: 'include', desc: 'Boolean array indicating rows to include', required: true },
851851+ { name: 'if_empty', desc: 'Value to return if no results match', required: false },
852852+ ],
853853+ },
854854+ SORT: {
855855+ desc: 'Sorts the rows of a given array based on the values in one or more columns',
856856+ params: [
857857+ { name: 'range', desc: 'The data to sort', required: true },
858858+ { name: 'sort_column', desc: 'Column index to sort by (default 1)', required: false },
859859+ { name: 'is_ascending', desc: 'TRUE for ascending, FALSE for descending', required: false },
860860+ { name: 'by_col', desc: 'TRUE to sort columns instead of rows', required: false },
861861+ ],
862862+ },
863863+ UNIQUE: {
864864+ desc: 'Returns unique rows from a range, removing duplicates',
865865+ params: [
866866+ { name: 'range', desc: 'The data to filter for unique entries', required: true },
867867+ { name: 'by_col', desc: 'TRUE to compare columns instead of rows', required: false },
868868+ { name: 'exactly_once', desc: 'TRUE to return only entries that appear exactly once', required: false },
869869+ ],
870870+ },
871871+ SEQUENCE: {
872872+ desc: 'Generates a list of sequential numbers in an array',
873873+ params: [
874874+ { name: 'rows', desc: 'Number of rows to return', required: true },
875875+ { name: 'columns', desc: 'Number of columns to return (default 1)', required: false },
876876+ { name: 'start', desc: 'First number in the sequence (default 1)', required: false },
877877+ { name: 'step', desc: 'Amount to increment each value (default 1)', required: false },
878878+ ],
879879+ },
880880+881881+ // --- Lookup Power Functions ---
882882+ XLOOKUP: {
883883+ desc: 'Searches a range for a match and returns the corresponding item from a second range',
884884+ params: [
885885+ { name: 'search_key', desc: 'The value to search for', required: true },
886886+ { name: 'lookup_range', desc: 'The range to search in', required: true },
887887+ { name: 'return_range', desc: 'The range from which to return a value', required: true },
888888+ { name: 'if_not_found', desc: 'Value to return if no match is found', required: false },
889889+ { name: 'match_mode', desc: '0=exact (default), -1=next smaller, 1=next larger', required: false },
890890+ { name: 'search_mode', desc: '1=first-to-last (default), -1=last-to-first', required: false },
891891+ ],
892892+ },
893893+ SUMIFS: {
894894+ desc: 'Returns the sum of a range based on multiple criteria',
895895+ params: [
896896+ { name: 'sum_range', desc: 'The range to sum', required: true },
897897+ { name: 'criteria_range1', desc: 'First range to check against criteria', required: true },
898898+ { name: 'criteria1', desc: 'First condition to match', required: true },
899899+ { name: 'criteria_range2', desc: 'Additional range to check', required: false },
900900+ { name: 'criteria2', desc: 'Additional condition', required: false },
901901+ ],
902902+ },
903903+ COUNTIFS: {
904904+ desc: 'Returns the count of a range based on multiple criteria',
905905+ params: [
906906+ { name: 'criteria_range1', desc: 'First range to check against criteria', required: true },
907907+ { name: 'criteria1', desc: 'First condition to match', required: true },
908908+ { name: 'criteria_range2', desc: 'Additional range to check', required: false },
909909+ { name: 'criteria2', desc: 'Additional condition', required: false },
910910+ ],
911911+ },
912912+ AVERAGEIFS: {
913913+ desc: 'Returns the average of a range based on multiple criteria',
914914+ params: [
915915+ { name: 'average_range', desc: 'The range to average', required: true },
916916+ { name: 'criteria_range1', desc: 'First range to check against criteria', required: true },
917917+ { name: 'criteria1', desc: 'First condition to match', required: true },
918918+ { name: 'criteria_range2', desc: 'Additional range to check', required: false },
919919+ { name: 'criteria2', desc: 'Additional condition', required: false },
920920+ ],
921921+ },
922922+ QUERY: {
923923+ desc: 'Runs a SQL-like query against a data range',
924924+ params: [
925925+ { name: 'data', desc: 'The range of cells to query', required: true },
926926+ { name: 'query', desc: 'SQL-like query string (SELECT, WHERE, ORDER BY, LIMIT)', required: true },
927927+ { name: 'headers', desc: 'Whether the first row contains headers (default TRUE)', required: false },
928928+ ],
929929+ },
930930+845931 // --- Visualization ---
846932 SPARKLINE: {
847933 desc: 'Creates a miniature chart within a cell (line, bar, or win/loss)',