Select the types of activity you want to include in your feed.
Merge pull request 'feat: enhanced charts, CF data bars/icons, layers, animations, type filter (0.38.0)' (#373) from feat/enhanced-charts-and-cf into main
···7788## [Unreleased]
991010+## [0.38.0] — 2026-04-14
1111+1212+### Added
1313+- Sheets: 5 new chart types — area, doughnut, radar, stacked bar, stacked line (#613)
1414+- Sheets: data bars conditional formatting with bidirectional support for negative values (#614)
1515+- Sheets: icon sets conditional formatting with 3/4/5-icon set support (#614)
1616+- Diagrams: named layers panel with visibility toggle, lock, rename, add/remove, shape assignment (#615)
1717+- Diagrams: layers synced via Yjs for real-time collaboration
1818+- Slides: per-element enter/exit animations with 14 effects (fade, slide, zoom, bounce, spin) (#616)
1919+- Slides: animation triggers (on click, with previous, after previous) and playback step grouping (#616)
2020+- Slides: Notes/Animations tabbed panel in right sidebar
2121+- Slides: CSS keyframes for all animation effects
2222+- Landing: document type filter bar with counts (filter by doc, sheet, slide, diagram, form) (#617)
2323+- Landing: type filter pills with active state styling
2424+1025## [0.37.1] — 2026-04-14
11261227### Security
2828+- fix: security + quality issues from adversarial review (SSRF, auth bypass, parser limits, export) (#612)
1329- ICS proxy: block Tailscale CGNAT range `100.64.0.0/10` — previously could be used to reach any Tailscale node on the tailnet (#612)
1430- ICS proxy: block full `127.0.0.0/8` loopback range (was only blocking `127.0.0.1`) (#612)
1531- ICS proxy: set `redirect: 'manual'` and explicitly reject `3xx` responses, preventing redirect-based SSRF bypass (#612)
···1717 * Multiple rules per sheet, evaluated in order (first match wins).
1818 */
19192020-import type { CfRule, CfStyleResult } from './types.js';
2020+import type { CfRule, CfStyleResult, DataBarResult, IconSetResult } from './types.js';
21212222/**
2323 * Evaluate a single conditional formatting rule against a cell value.
···197197198198 if (nums.length === 0) return result;
199199200200- let min = nums[0].val;
201201- let max = nums[0].val;
200200+ let min = nums[0]!.val;
201201+ let max = nums[0]!.val;
202202 for (const { val } of nums) {
203203 if (val < min) min = val;
204204 if (val > max) max = val;
···222222 case 'textContains': return 'Text contains "' + (rule.value ?? '') + '"';
223223 case 'isEmpty': return 'Is empty';
224224 case 'isNotEmpty': return 'Is not empty';
225225+ case 'dataBar': return 'Data bar';
226226+ case 'iconSet': return 'Icon set';
225227 default: return rule.type;
226228 }
227229}
230230+231231+// ============================================================
232232+// Data Bars
233233+// ============================================================
234234+235235+const DEFAULT_BAR_COLOR = '#4472c4';
236236+237237+/**
238238+ * Compute data bar widths for a range of cells.
239239+ * Each bar's width is proportional to the cell's value relative to the range.
240240+ * Negative values produce bars in the opposite direction.
241241+ */
242242+export function computeDataBars(
243243+ cellValues: Map<string, unknown>,
244244+ rule: CfRule,
245245+): Map<string, DataBarResult> {
246246+ const result = new Map<string, DataBarResult>();
247247+ const nums: { id: string; val: number }[] = [];
248248+249249+ for (const [id, v] of cellValues) {
250250+ const n = toNumber(v);
251251+ if (n !== null) nums.push({ id, val: n });
252252+ }
253253+254254+ if (nums.length === 0) return result;
255255+256256+ const barColor = rule.barColor || DEFAULT_BAR_COLOR;
257257+258258+ let min = nums[0]!.val;
259259+ let max = nums[0]!.val;
260260+ for (const { val } of nums) {
261261+ if (val < min) min = val;
262262+ if (val > max) max = val;
263263+ }
264264+265265+ // All equal → 100% bars
266266+ if (min === max) {
267267+ for (const { id } of nums) {
268268+ result.set(id, { barWidthPct: 100, barColor, negative: min < 0 });
269269+ }
270270+ return result;
271271+ }
272272+273273+ const hasNegative = min < 0;
274274+275275+ for (const { id, val } of nums) {
276276+ let barWidthPct: number;
277277+ let negative = false;
278278+279279+ if (hasNegative) {
280280+ // Bidirectional: bar width relative to the full range span
281281+ const span = max - min;
282282+ barWidthPct = (Math.abs(val) / span) * 100;
283283+ negative = val < 0;
284284+ } else {
285285+ // All positive: simple percentage of max
286286+ barWidthPct = (val / max) * 100;
287287+ }
288288+289289+ result.set(id, { barWidthPct, barColor, negative });
290290+ }
291291+292292+ return result;
293293+}
294294+295295+// ============================================================
296296+// Icon Sets
297297+// ============================================================
298298+299299+/** Number of icons per named icon set. */
300300+function iconCount(name: string): number {
301301+ if (name.endsWith('5')) return 5;
302302+ if (name.endsWith('4')) return 4;
303303+ return 3;
304304+}
305305+306306+/**
307307+ * Compute icon set assignments for a range of cells.
308308+ * Values are split into equal percentile bands; higher values get higher icon indices.
309309+ */
310310+export function computeIconSets(
311311+ cellValues: Map<string, unknown>,
312312+ rule: CfRule,
313313+): Map<string, IconSetResult> {
314314+ const result = new Map<string, IconSetResult>();
315315+ const setName = rule.iconSetName || 'traffic3';
316316+ const count = iconCount(setName);
317317+ const nums: { id: string; val: number }[] = [];
318318+319319+ for (const [id, v] of cellValues) {
320320+ const n = toNumber(v);
321321+ if (n !== null) nums.push({ id, val: n });
322322+ }
323323+324324+ if (nums.length === 0) return result;
325325+326326+ let min = nums[0]!.val;
327327+ let max = nums[0]!.val;
328328+ for (const { val } of nums) {
329329+ if (val < min) min = val;
330330+ if (val > max) max = val;
331331+ }
332332+333333+ for (const { id, val } of nums) {
334334+ let iconIndex: number;
335335+ if (min === max) {
336336+ // All equal → assign the highest icon
337337+ iconIndex = count - 1;
338338+ } else {
339339+ const pct = (val - min) / (max - min); // 0..1
340340+ iconIndex = Math.min(count - 1, Math.floor(pct * count));
341341+ }
342342+ result.set(id, { iconIndex, iconSetName: setName });
343343+ }
344344+345345+ return result;
346346+}