Browse Source

Upgrade Sheets formulas and Office splash

Expand the spreadsheet engine with cross-sheet references, array-aware evaluation, new Excel-style functions, and formula rewriting for sheet renames and row/column moves. The XLSX import/export path now preserves shared formulas and user-hidden rows, with new tests covering both.

Refresh the Office startup splash with app-specific SVG artwork, larger initial window sizing, and updated Docs/Sheets/Slides integration and documentation. Sheets also gains row hide/unhide UI, better frozen-pane rendering, and related menu/shortcut support.
Toby Chui 2 weeks ago
parent
commit
b91a5a6e0e

+ 3 - 0
src/mod/office/xlsx.go

@@ -33,6 +33,9 @@ type WorkSheet struct {
 	RowH   map[string]float64   `json:"rowH,omitempty"`
 	Merges []string             `json:"merges,omitempty"`
 	Freeze *FreezePane          `json:"freeze,omitempty"`
+	// HiddenRows lists 0-based rows the user hid (<row hidden="1"> in xlsx);
+	// rows hidden by a filter are not in it
+	HiddenRows []int `json:"hiddenRows,omitempty"`
 	// Charts round-trip as native DrawingML chart parts (xlsx_charts.go);
 	// Filter is a webapp-owned blob not representable in xlsx
 	Charts json.RawMessage `json:"charts,omitempty"`

+ 155 - 0
src/mod/office/xlsx_formula.go

@@ -0,0 +1,155 @@
+package office
+
+/*
+	xlsx_formula.go - Shared formulas.
+
+	Excel stores a filled-down formula once: the first cell carries
+	<f t="shared" ref="K3:K66" si="0">B3-C3</f> and every other cell in
+	the block only <f t="shared" si="0"/>. Each follower's own formula is
+	the master's with its relative references moved by the follower's
+	offset from the master, so K4 is B4-C4.
+*/
+
+import (
+	"strconv"
+	"strings"
+)
+
+type sharedFormula struct {
+	text     string
+	col, row int
+}
+
+// shiftFormulaRefs moves every relative cell reference in an A1-style
+// formula (without the leading "=") by dCol columns and dRow rows. $-anchored
+// parts stay, string literals and quoted sheet names are left alone, and a
+// reference pushed off the grid becomes #REF!.
+func shiftFormulaRefs(f string, dCol, dRow int) string {
+	if dCol == 0 && dRow == 0 {
+		return f
+	}
+	var out strings.Builder
+	n := len(f)
+	for i := 0; i < n; {
+		ch := f[i]
+		if ch == '"' || ch == '\'' {
+			// copy a "string" or 'sheet name' through, honouring doubled quotes
+			j := i + 1
+			for j < n {
+				if f[j] == ch {
+					if j+1 < n && f[j+1] == ch {
+						j += 2
+						continue
+					}
+					break
+				}
+				j++
+			}
+			if j < n {
+				j++
+			}
+			out.WriteString(f[i:j])
+			i = j
+			continue
+		}
+		if (ch == '$' || isASCIILetter(ch)) && (i == 0 || !isFormulaIdentChar(f[i-1])) {
+			if ref, l, ok := matchCellRef(f[i:]); ok {
+				out.WriteString(ref.shifted(dCol, dRow))
+				i += l
+				continue
+			}
+			// not a reference: copy the whole identifier so its tail is not
+			// mistaken for one (LOG10 must not turn into LOG11)
+			j := i + 1
+			for j < n && isFormulaIdentChar(f[j]) {
+				j++
+			}
+			out.WriteString(f[i:j])
+			i = j
+			continue
+		}
+		out.WriteByte(ch)
+		i++
+	}
+	return out.String()
+}
+
+type a1Ref struct {
+	absC, absR bool
+	col, row   int // 0-based
+}
+
+func (r a1Ref) shifted(dCol, dRow int) string {
+	c, rw := r.col, r.row
+	if !r.absC {
+		c += dCol
+	}
+	if !r.absR {
+		rw += dRow
+	}
+	if c < 0 || rw < 0 {
+		return "#REF!"
+	}
+	var b strings.Builder
+	if r.absC {
+		b.WriteByte('$')
+	}
+	b.WriteString(colName(c))
+	if r.absR {
+		b.WriteByte('$')
+	}
+	b.WriteString(strconv.Itoa(rw + 1))
+	return b.String()
+}
+
+// matchCellRef reads $A$1 / A1 / $AB12 at the start of s. It refuses
+// function names (LOG10(), sheet names (Q1!) and longer identifiers.
+func matchCellRef(s string) (a1Ref, int, bool) {
+	var r a1Ref
+	i := 0
+	if i < len(s) && s[i] == '$' {
+		r.absC = true
+		i++
+	}
+	ls := i
+	for i < len(s) && isASCIILetter(s[i]) {
+		i++
+	}
+	if i == ls || i-ls > 3 {
+		return r, 0, false
+	}
+	letters := strings.ToUpper(s[ls:i])
+	if i < len(s) && s[i] == '$' {
+		r.absR = true
+		i++
+	}
+	ds := i
+	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
+		i++
+	}
+	if i == ds {
+		return r, 0, false
+	}
+	if i < len(s) && (isFormulaIdentChar(s[i]) || s[i] == '(' || s[i] == '!') {
+		return r, 0, false
+	}
+	rowNum, err := strconv.Atoi(s[ds:i])
+	if err != nil || rowNum < 1 {
+		return r, 0, false
+	}
+	col := 0
+	for k := 0; k < len(letters); k++ {
+		col = col*26 + int(letters[k]-'A'+1)
+	}
+	r.col = col - 1
+	r.row = rowNum - 1
+	return r, i, true
+}
+
+func isASCIILetter(c byte) bool {
+	return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
+}
+
+func isFormulaIdentChar(c byte) bool {
+	return isASCIILetter(c) || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == '$'
+}

+ 63 - 0
src/mod/office/xlsx_formula_test.go

@@ -0,0 +1,63 @@
+package office
+
+import "testing"
+
+func TestShiftFormulaRefs(t *testing.T) {
+	cases := []struct {
+		name       string
+		in         string
+		dCol, dRow int
+		want       string
+	}{
+		{"relative", "B3-C3", 0, 1, "B4-C4"},
+		{"absolute parts stay", "$A$1+A$1+$A1+A1", 1, 1, "$A$1+B$1+$A2+B2"},
+		{"range", "SUM(A1:B2)", 2, 0, "SUM(C1:D2)"},
+		{"strings untouched", `IF(A1="B2",C3,"x""D4")`, 0, 1, `IF(A2="B2",C4,"x""D4")`},
+		{"quoted sheet name untouched", "'Q1 A1'!A1+'It''s'!B2", 0, 1, "'Q1 A1'!A2+'It''s'!B3"},
+		{"unquoted sheet name", "Analysis!$H$2+Analysis!H2", 0, 1, "Analysis!$H$2+Analysis!H3"},
+		{"cell-like sheet name", "AB1!A1", 0, 1, "AB1!A2"},
+		{"function names untouched", "LOG10(A1)+ATAN2(B1,C1)", 0, 1, "LOG10(A2)+ATAN2(B2,C2)"},
+		{"lowercase refs", "sum(a1:a3)", 0, 2, "sum(A3:A5)"},
+		{"off grid", "A1+B2", 0, -2, "#REF!+#REF!"},
+		{"no offset", "A1", 0, 0, "A1"},
+		{"numbers untouched", "1E5+A1*2", 0, 1, "1E5+A2*2"},
+		{"nested IF chain", "IF(AND($V$4<=M3,M3<$W$4),1,2)", 0, 1, "IF(AND($V$4<=M4,M4<$W$4),1,2)"},
+	}
+	for _, c := range cases {
+		if got := shiftFormulaRefs(c.in, c.dCol, c.dRow); got != c.want {
+			t.Errorf("%s: shiftFormulaRefs(%q, %d, %d) = %q, want %q", c.name, c.in, c.dCol, c.dRow, got, c.want)
+		}
+	}
+}
+
+func TestParseWorksheetSharedFormulas(t *testing.T) {
+	sheet := `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
+<row r="3"><c r="K3"><f t="shared" ref="K3:K5" si="0">B3-C3</f><v>1</v></c><c r="L3"><f>INT(C3)</f><v>2</v></c></row>
+<row r="4"><c r="K4"><f t="shared" si="0"/><v>9</v></c><c r="L4"><f t="shared" ref="L4:M4" si="1">$A$1+C4</f><v>0</v></c><c r="M4"><f t="shared" si="1"/><v>0</v></c></row>
+<row r="5"><c r="K5"><f t="shared" si="0"/><v>9</v></c><c r="N5"><f t="shared" si="7"/><v>42</v></c></row>
+</sheetData></worksheet>`
+	tree, err := parseXMLTree([]byte(sheet))
+	if err != nil {
+		t.Fatalf("parse sheet xml: %v", err)
+	}
+	ws := parseWorksheet(tree, nil, nil)
+	want := map[string]string{
+		"K3": "=B3-C3",
+		"K4": "=B4-C4",
+		"K5": "=B5-C5",
+		"L3": "=INT(C3)",
+		"L4": "=$A$1+C4",
+		"M4": "=$A$1+D4",
+		"N5": "42", // follower whose master is missing keeps the cached value
+	}
+	for ref, v := range want {
+		cell := ws.Cells[ref]
+		if cell == nil {
+			t.Errorf("%s: missing cell", ref)
+			continue
+		}
+		if cell.V != v {
+			t.Errorf("%s = %q, want %q", ref, cell.V, v)
+		}
+	}
+}

+ 43 - 0
src/mod/office/xlsx_hidden_test.go

@@ -0,0 +1,43 @@
+package office
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestXlsxHiddenRowsRoundTrip(t *testing.T) {
+	cases := []struct {
+		name   string
+		hidden []int
+	}{
+		{"none", nil},
+		{"row with data", []int{1}},
+		{"empty rows past the data", []int{1, 6, 7}},
+	}
+	for _, c := range cases {
+		wb := &Workbook{Sheets: []*WorkSheet{{
+			Name: "Data",
+			Cells: map[string]*WorkCell{
+				"A1": {V: "keep"},
+				"A2": {V: "hidden"},
+				"A3": {V: "=A1&A2"},
+			},
+			HiddenRows: c.hidden,
+		}}}
+		data, err := BuildXlsx(wb)
+		if err != nil {
+			t.Fatalf("%s: BuildXlsx: %v", c.name, err)
+		}
+		back, err := ParseXlsx(data)
+		if err != nil {
+			t.Fatalf("%s: ParseXlsx: %v", c.name, err)
+		}
+		ws := back.Sheets[0]
+		if !reflect.DeepEqual(ws.HiddenRows, c.hidden) {
+			t.Errorf("%s: hidden rows = %v, want %v", c.name, ws.HiddenRows, c.hidden)
+		}
+		if ws.Cells["A2"] == nil || ws.Cells["A2"].V != "hidden" {
+			t.Errorf("%s: A2 lost its value: %+v", c.name, ws.Cells["A2"])
+		}
+	}
+}

+ 20 - 3
src/mod/office/xlsx_reader.go

@@ -4,7 +4,8 @@ package office
 	xlsx_reader.go - Parse an Excel (.xlsx) file into a Workbook.
 
 	Handles the common SpreadsheetML subset: shared + inline strings,
-	numbers, booleans, formulas, cell styles (bold/italic/underline, font
+	numbers, booleans, formulas (shared formulas expanded per cell,
+	xlsx_formula.go), cell styles (bold/italic/underline, font
 	color/size, fill, alignment, wrap), number formats (mapped back to the
 	webapp's fmt names), column widths, row heights, merged cells and
 	frozen panes. Bar/line/pie charts are mapped back to the webapp chart
@@ -333,7 +334,7 @@ func decimalsInCode(code string) int {
 
 /* ---------- worksheet ---------- */
 
-func parseWorksheet(tree *xnode, shared []string, styleMap []xlsxXfInfo) *WorkSheet {
+func parseWorksheet(tree *xnode, sharedStr []string, styleMap []xlsxXfInfo) *WorkSheet {
 	ws := &WorkSheet{
 		Cells: map[string]*WorkCell{},
 		ColW:  map[string]float64{},
@@ -373,6 +374,7 @@ func parseWorksheet(tree *xnode, shared []string, styleMap []xlsxXfInfo) *WorkSh
 	}
 
 	maxCol, maxRow := 0, 0
+	shared := map[string]sharedFormula{} // si -> master of a shared formula
 	if sd := tree.first("sheetData"); sd != nil {
 		for _, row := range sd.all("row") {
 			rIdx, err := strconv.Atoi(row.attr("r"))
@@ -382,13 +384,28 @@ func parseWorksheet(tree *xnode, shared []string, styleMap []xlsxXfInfo) *WorkSh
 			if ht, err := strconv.ParseFloat(row.attr("ht"), 64); err == nil && row.attr("customHeight") == "1" {
 				ws.RowH[strconv.Itoa(rIdx-1)] = float64(int(rowPtToPx(ht)))
 			}
+			if hd := row.attr("hidden"); hd == "1" || hd == "true" {
+				ws.HiddenRows = append(ws.HiddenRows, rIdx-1)
+				if rIdx-1 > maxRow {
+					maxRow = rIdx - 1
+				}
+			}
 			for _, c := range row.all("c") {
 				ref := c.attr("r")
 				col, rw, ok := parseCellRef(ref)
 				if !ok {
 					continue
 				}
-				cell := parseXlsxCell(c, shared)
+				cell := parseXlsxCell(c, sharedStr)
+				if f := c.first("f"); f != nil && f.attr("t") == "shared" {
+					si := f.attr("si")
+					if text := strings.TrimSpace(f.Text); text != "" {
+						shared[si] = sharedFormula{text: f.Text, col: col, row: rw}
+					} else if m, ok := shared[si]; ok {
+						// a follower: the master's formula moved to this cell
+						cell = "=" + shiftFormulaRefs(m.text, col-m.col, rw-m.row)
+					}
+				}
 				var st *CellStyle
 				if sIdx, err := strconv.Atoi(c.attr("s")); err == nil && sIdx >= 0 && sIdx < len(styleMap) {
 					st = styleMap[sIdx].style

+ 13 - 0
src/mod/office/xlsx_writer.go

@@ -527,6 +527,16 @@ func buildWorksheetXML(ws *WorkSheet, styles *xlsxStyleTable, hasDrawing bool, l
 			maxRow = row
 		}
 	}
+	// hidden rows need a <row> element even when they hold no cells
+	hidden := map[int]bool{}
+	for _, r := range ws.HiddenRows {
+		if r >= 0 && r < 1048576 {
+			hidden[r] = true
+			if _, ok := rows[r]; !ok {
+				rows[r] = nil
+			}
+		}
+	}
 	var rowIdxs []int
 	for r := range rows {
 		rowIdxs = append(rowIdxs, r)
@@ -541,6 +551,9 @@ func buildWorksheetXML(ws *WorkSheet, styles *xlsxStyleTable, hasDrawing bool, l
 		if h, ok := ws.RowH[strconv.Itoa(r)]; ok && h > 0 {
 			attrs += fmt.Sprintf(` ht="%s" customHeight="1"`, trimFloat(pxToRowPt(h)))
 		}
+		if hidden[r] {
+			attrs += ` hidden="1"`
+		}
 		sb.WriteString("<row" + attrs + ">")
 		for _, c := range cells {
 			sb.WriteString(c.xml)

+ 49 - 10
src/web/Office/README.md

@@ -151,7 +151,10 @@ Go structs are the source of truth — they mirror the JS exactly:
     it) — one made in the editor has none and keeps docs.css's 8pt.
 - **Sheets** (`spreadsheet`): [`xlsx.go`](../../mod/office/xlsx.go) —
   `{sheets[{name, cells{"A1":{v,s,n}}, colW, rowH, merges, freeze,
-  charts, filter, cf}], active}`. Cell `v` is the raw input (`=`-prefix =
+  hiddenRows, charts, filter, cf}], active}`. `hiddenRows` is the sorted list
+  of 0-based rows the user hid (row-number context menu, Ctrl+Alt+9 /
+  Ctrl+Shift+9, click the marker to show); it round-trips as
+  `<row hidden="1">` in xlsx. Rows a filter hides are computed, not stored. Cell `v` is the raw input (`=`-prefix =
   formula, evaluated client-side in [`sheets/formula.js`](sheets/formula.js)).
   `cf` is the sheet's conditional-format rules (below) — client-side only,
   so the Go structs do not model it.
@@ -510,9 +513,38 @@ evaluator that also runs under Node, so it is unit-tested directly:
 node web/Office/sheets/test_formula.js    # exits 1 on failure
 ```
 
-Functions: `IF IFS IFERROR IFNA AND OR NOT` · `VLOOKUP HLOOKUP` ·
-`SUM AVERAGE MIN MAX COUNT COUNTA` · `ROUND ABS INT` ·
-`CONCAT LEN UPPER LOWER TRIM` · `TODAY NOW`.
+Functions: `IF IFS IFERROR IFNA AND OR NOT` · `VLOOKUP HLOOKUP CHOOSE` ·
+`SUM AVERAGE MIN MAX COUNT COUNTA SUMPRODUCT` · `ROUND ABS INT MOD` ·
+`CONCAT LEN UPPER LOWER TRIM` ·
+`TODAY NOW DATE YEAR MONTH DAY WEEKDAY HOUR MINUTE SECOND`.
+
+**Cross-sheet references** (`Data!A1`, `'Closed Tickets'!$C$3:$C$5000`) are
+resolved by one workbook-wide calculator: `createCalculator(getRaw, opts)`
+memoizes per sheet + cell, so switching tabs does not recompute anything, and
+every formula reads its unqualified refs from its own sheet. Renaming a sheet
+rewrites the formulas that name it (`renameSheetRefs`); inserting/deleting
+rows or moving a range adjusts refs to that sheet from every sheet.
+
+**Arrays.** Inside `SUMPRODUCT` (and array expressions handed to `SUM` & co.)
+a range evaluates to an array and operators work element by element with
+Excel's broadcasting, which is what report-style workbooks built on
+`SUMPRODUCT((Data!A2:A5000="x")*(Data!B2:B5000))` need. Ranges are cached as
+arrays for the life of a recalc, and so is "cached range compared with a
+constant", because such reports repeat the same criteria in every cell. The
+reference workbook (1,150 SUMPRODUCT-heavy cells over a 5,000-row sheet of
+51k formulas) recalculates in ~1 s cold and matches Excel's cached values in
+every cell. A bare range outside an array context is still `#VALUE!` (no
+spilling).
+
+`AND`/`OR` follow Excel for text: text inside a referenced cell or range is
+skipped, text typed straight into the call is `#VALUE!`. A blank cell equals
+`""`.
+
+**Shared formulas.** Excel writes a filled-down formula once
+(`<f t="shared" ref="K3:K66" si="0">B3-C3</f>`) and leaves the other cells
+as `<f t="shared" si="0"/>`. The Go reader expands every follower by moving
+the master's relative refs (`mod/office/xlsx_formula.go`), so they stay live
+formulas instead of frozen cached values.
 
 Two deliberate departures from Excel, both matching Sheets:
 
@@ -528,8 +560,10 @@ this scans and keeps the best match at or below the key instead — identical
 on sorted data, merely imperfect rather than arbitrary on unsorted. `FALSE`
 means exact match, and a miss is `#N/A` so `IFERROR`/`IFNA` can catch it.
 
-Not implemented: `COUNTIF`/`SUMIF`/`AVERAGEIF`, `INDEX`/`MATCH`, and
-cross-sheet references. Add new functions to the `call()` switch in
+Not implemented: `COUNTIF`/`SUMIF`/`AVERAGEIF`, `INDEX`/`MATCH`, defined
+names, whole-column refs (`A:A`), and dependency tracking (any edit resets
+the memo, so a very heavy workbook recomputes what is on screen after every
+change). Add new functions to the `call()` switch in
 `formula.js` and pin them with a case in `test_formula.js`.
 
 ### Sheets conditional formatting
@@ -641,10 +675,15 @@ Opening a large document takes a moment (fetching and unpacking it, a server
 or WebAssembly conversion for `.docx` / `.xlsx` / `.pptx`, laying out every
 page), so Docs, Sheets and Slides start behind a splash that says what is
 happening — [`common/splash.js`](common/splash.js),
-contract in `CONTRACT.md`. In a desktop float window it is the classic office
-splash (the window opens small, `InitFWSize` 400x240, and grows to 1080x700,
-both centred on the desktop, once the document is on screen); in a browser tab it is a
-white page with the app's icon and the status under it. Sheets grows to
+contract in `CONTRACT.md`. It is a softly tinted card in the app's colour
+with the app icon, "ArozOS Docs/Sheets/Slides", a tagline, loading dots and
+the status in the bottom-left corner (any other app gets the suite card: the
+ArozOS mark and a progress bar). Its artwork is SVG in
+[`img/splash/`](img/splash/) - the icons, the mark, and two corner shapes used
+as CSS masks so one file serves every app colour. In a desktop float window
+the window opens small (`InitFWSize` 480x320) and grows to 1080x700, both
+centred on the desktop, once the document is on screen; in a browser tab the
+same card fills the page and scales with it. Sheets grows to
 1180x720 and Slides to 1220x740. Each `index.html` loads its stylesheets and
 scripts in `<body>`, after the splash, so the splash paints while they arrive.
 

+ 6 - 4
src/web/Office/common/CONTRACT.md

@@ -740,13 +740,15 @@ downstream has to know about them.
 ## Start-up splash (common/splash.js)
 
 An app can show a splash while it starts and opens its document (all three do):
-`<script src="../common/splash.js" data-app="Docs" data-icon="../img/docs.svg"
-data-size="1080x700"></script>` as the **first element of `<body>`**, with the
+`<script src="../common/splash.js" data-app="Docs" data-size="1080x700"></script>` as the **first element of `<body>`**, with the
 app's stylesheets and scripts after it rather than in `<head>` (they would hold
 the first paint back). Inside a web desktop float window it is a coloured card
 in a small window that grows to `data-size` when ready, both centred on the desktop
-(register the app with an `InitFWSize` of 400x240 so the window opens at that
-size); in a full tab it is a plain page with the icon and a status line. It
+(register the app with an `InitFWSize` of 480x320 so the window opens at that
+size); in a full tab the same card fills the page. Its artwork comes from
+`img/splash/` (`<app>.svg`, `arozos.svg`, and the `shape_top` / `shape_bottom`
+masks); `data-icon` overrides the icon. Apps other than docs/sheets/slides get
+the suite card (ArozOS mark and a progress bar). It
 styles itself. `OfficeApp` drives it: `setStatus` / `showBusy` text becomes its
 status line, and it goes away when the opened document is on screen, a dialog
 opens, or an error is reported. An app with an asynchronous importer calls

+ 238 - 153
src/web/Office/common/splash.js

@@ -5,31 +5,36 @@
     has a face and says what is happening ("Opening report.docx...",
     "Laying out the pages...").
 
-    Two looks, chosen by where the app runs:
+    One look - a soft tinted card with the app's icon, "ArozOS <App>", a
+    tagline, moving dots and the status line in the bottom-left corner -
+    in two places:
 
       window    inside a web desktop float window: the window starts small
-                in the middle of the desktop, a coloured card with the app
-                name, moving dots and the status line in the corner - and
-                grows to its working size, still centred on the desktop,
-                once the document is ready
+                in the middle of the desktop and grows to its working size,
+                still centred on the desktop, once the document is ready
       page      a full browser tab (or the mobile desktop, whose windows
-                fill the screen): a plain page with the app icon in the
-                middle and the status under it
+                fill the screen): the same card fills the page
+
+    The card scales with the space it has. Docs, Sheets and Slides get their
+    own colours and tagline; any other app (or none) gets the suite card:
+    the ArozOS mark, "Create · Edit · Collaborate" and a progress bar.
 
     Include it as the FIRST element of <body>, so it covers the page before
     anything else has drawn:
 
-        <script src="../common/splash.js" data-app="Docs"
-                data-icon="../img/docs.svg" data-size="1080x700"></script>
+        <script src="../common/splash.js" data-app="Docs" data-size="1080x700"></script>
 
-    data-size is the window's working size in window mode. The splash brings
-    its own styles: it paints before any stylesheet of the app has arrived,
-    so an app should load its stylesheets and scripts after this tag (in
-    <body>) rather than in <head>, where they would hold the first paint
-    back. The framework (office.js) feeds it status text and
-    calls done() when the document is on screen, when a dialog needs the
-    person, or when something failed; a start that never finishes is let go
-    after a while so the app is never locked behind it.
+    Its artwork is in img/splash/ next to this folder (the app icons, the
+    ArozOS mark and the two corner shapes, which are masks painted in the
+    app's colour); data-icon overrides the icon. data-size is the window's
+    working size in window mode. The splash brings its own styles: it
+    paints before any stylesheet of the app has arrived, so an app should
+    load its stylesheets and scripts after this tag (in <body>) rather than
+    in <head>, where they would hold the first paint back. The framework
+    (office.js) feeds it status text and calls done() when the document is
+    on screen, when a dialog needs the person, or when something failed; a
+    start that never finishes is let go after a while so the app is never
+    locked behind it.
 
         OfficeSplash.status("Opening report.docx...")
         OfficeSplash.done()
@@ -39,143 +44,220 @@
 var OfficeSplash = (function () {
     "use strict";
 
-    var SPLASH_W = 400, SPLASH_H = 240;   // the window while it starts
+    var SPLASH_W = 480, SPLASH_H = 320;   // the window while it starts
     var GIVE_UP_MS = 60000;
 
     var script = document.currentScript;
     var appName = (script && script.getAttribute("data-app")) || "Office";
-    var icon = (script && script.getAttribute("data-icon")) || "";
     var sizeAttr = ((script && script.getAttribute("data-size")) || "1080x700").split("x");
     var workW = parseInt(sizeAttr[0], 10) || 1080, workH = parseInt(sizeAttr[1], 10) || 700;
 
+    // img/splash/, resolved against this script so it works at any depth
+    var ART = "../img/splash/";
+    try { if (script && script.src) ART = new URL("../img/splash/", script.src).href; } catch (e) { }
+    var icon = (script && script.getAttribute("data-icon")) || "";
+
     var root = null, statusEl = null, finished = false, giveUp = null;
 
-    // each app's colour for the window card
-    var APP_COLOURS = { docs: "#1f6fbf", sheets: "#1e8a3c", slides: "#cf6c16" };
+    var APPS = {
+        docs: ["Write, edit and collaborate", "with ease."],
+        sheets: ["Create powerful spreadsheets, analyse data,", "and turn your ideas into insights."],
+        slides: ["Design beautiful presentations,", "share your ideas, make an impact."]
+    };
 
+    /* Sizes are in em against a 600x375 reference card with 16px type; the
+       root font size scales that reference to the space available. */
     var CSS = [
         ".of-splash {",
+        "    --sp-bg: linear-gradient(160deg, #f6f8fe 0%, #eef2fb 100%);",
+        "    --sp-top: linear-gradient(225deg, #8d9af3 0%, #b9c3fa 45%, #e4e8fd 100%);",
+        "    --sp-bottom: linear-gradient(180deg, #d6e2fb 0%, #c9d9fa 100%);",
+        "    --sp-accent: #2f74e0;",
+        "    --sp-accent-soft: rgba(47, 116, 224, 0.3);",
+        "    --sp-ink: #20252c;",
+        "    --sp-sub: #6b7079;",
+        "    --sp-status: #858a93;",
+        "    --sp-track: #dce3f0;",
+        "    --sp-shape-alpha: 1;",
         "    position: fixed;",
         "    inset: 0;",
         "    z-index: 5000;",
         "    box-sizing: border-box;",
-        "    font-family: \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif;",
+        "    overflow: hidden;",
+        "    background: var(--sp-bg);",
+        "    color: var(--sp-ink);",
+        "    font-family: \"Segoe UI Variable Display\", \"Segoe UI\", -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif;",
+        "    font-size: clamp(10px, min(2.667vw, 4.267vh), 17px);",
         "    user-select: none;",
         "    -webkit-user-select: none;",
         "    transition: opacity 0.22s ease;",
         "}",
+        ".of-splash *, .of-splash *::before { box-sizing: border-box; }",
         ".of-splash.of-splash-out { opacity: 0; pointer-events: none; }",
-        ".of-splash-mark { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; }",
-        ".of-splash-window {",
-        "    background: var(--of-splash-bg, #1f6fbf);",
-        "    color: #ffffff;",
+        ".of-splash-docs {",
+        "    --sp-bg: linear-gradient(160deg, #f5f8fe 0%, #edf3fd 55%, #e6effc 100%);",
+        "    --sp-top: linear-gradient(225deg, #b5d0fa 0%, #cfe0fc 60%, #e2ecfd 100%);",
+        "    --sp-bottom: linear-gradient(180deg, #d3e3fc 0%, #c6dbfa 100%);",
+        "    --sp-accent: #2f7be6;",
+        "    --sp-accent-soft: rgba(47, 123, 230, 0.3);",
         "}",
-        ".of-splash-brand {",
+        ".of-splash-sheets {",
+        "    --sp-bg: linear-gradient(160deg, #f8fcf8 0%, #f1f9f2 55%, #e8f5ea 100%);",
+        "    --sp-top: linear-gradient(225deg, #b9e5bf 0%, #cdeed1 60%, #e1f4e3 100%);",
+        "    --sp-bottom: linear-gradient(180deg, #d2eed5 0%, #c3e8c8 100%);",
+        "    --sp-accent: #2e9d45;",
+        "    --sp-accent-soft: rgba(46, 157, 69, 0.32);",
+        "}",
+        ".of-splash-slides {",
+        "    --sp-bg: linear-gradient(160deg, #fffcf7 0%, #fef7ec 55%, #fdf1e0 100%);",
+        "    --sp-top: linear-gradient(225deg, #fcd9ab 0%, #fde6c8 60%, #fef1df 100%);",
+        "    --sp-bottom: linear-gradient(180deg, #fde3c2 0%, #fbd9b0 100%);",
+        "    --sp-accent: #f28a1c;",
+        "    --sp-accent-soft: rgba(242, 138, 28, 0.32);",
+        "}",
+        ".of-splash.of-splash-dark {",
+        "    --sp-bg: linear-gradient(160deg, #1f232a 0%, #1a1e24 100%);",
+        "    --sp-ink: #e6e9ee;",
+        "    --sp-sub: #a4abb5;",
+        "    --sp-status: #8a919b;",
+        "    --sp-track: #353b45;",
+        "    --sp-shape-alpha: 0.16;",
+        "}",
+        ".of-splash-shape {",
         "    position: absolute;",
-        "    left: 12px;",
-        "    top: 9px;",
-        "    display: flex;",
-        "    align-items: center;",
-        "    gap: 6px;",
-        "    font-size: 12px;",
-        "    opacity: 0.95;",
+        "    pointer-events: none;",
+        "    opacity: var(--sp-shape-alpha);",
+        "    -webkit-mask-repeat: no-repeat;",
+        "    mask-repeat: no-repeat;",
+        "    -webkit-mask-size: 100% 100%;",
+        "    mask-size: 100% 100%;",
+        "}",
+        ".of-splash-shape-top {",
+        "    top: 0;",
+        "    right: 0;",
+        "    width: min(29.07vw, 46.51vh);",
+        "    height: min(29.07vw, 46.51vh);",
+        "    background: var(--sp-top);",
+        "}",
+        ".of-splash-shape-bottom {",
+        "    left: 0;",
+        "    bottom: 0;",
+        "    width: min(43.6vw, 69.77vh);",
+        "    height: min(23.26vw, 37.21vh);",
+        "    background: var(--sp-bottom);",
         "}",
-        ".of-splash-mid {",
+        ".of-splash-dark .of-splash-shape { background: var(--sp-accent); }",
+        ".of-splash-center {",
         "    position: absolute;",
         "    left: 0;",
         "    right: 0;",
         "    top: 50%;",
-        "    transform: translateY(-58%);",
+        "    transform: translateY(-50%);",
+        "    padding: 0 1.2em;",
         "    text-align: center;",
         "}",
-        ".of-splash-name {",
-        "    font-size: 46px;",
-        "    font-weight: 300;",
-        "    line-height: 1.1;",
-        "    letter-spacing: 0.5px;",
+        ".of-splash-icon {",
+        "    display: block;",
+        "    width: 4.6em;",
+        "    height: 5.56em;",
+        "    margin: 0 auto 0.75em;",
+        "    filter: drop-shadow(0 0.35em 0.6em var(--sp-accent-soft));",
         "}",
-        ".of-splash-window .of-splash-status {",
-        "    position: absolute;",
-        "    left: 12px;",
-        "    right: 12px;",
-        "    bottom: 9px;",
-        "    font-size: 12px;",
+        ".of-splash-title {",
+        "    font-size: 1.75em;",
+        "    font-weight: 400;",
+        "    line-height: 1.15;",
+        "    letter-spacing: -0.01em;",
         "    white-space: nowrap;",
-        "    overflow: hidden;",
-        "    text-overflow: ellipsis;",
-        "    opacity: 0.95;",
         "}",
+        ".of-splash-title b { font-weight: 400; color: var(--sp-accent); }",
+        ".of-splash-tagline {",
+        "    margin-top: 0.4em;",
+        "    font-size: 0.88em;",
+        "    line-height: 1.45;",
+        "    color: var(--sp-sub);",
+        "}",
+        ".of-splash-tagline span { display: block; }",
         ".of-splash-dots {",
-        "    position: relative;",
-        "    width: 90px;",
-        "    height: 5px;",
-        "    margin: 12px auto 0;",
+        "    display: flex;",
+        "    justify-content: center;",
+        "    gap: 0.55em;",
+        "    margin-top: 1em;",
         "}",
         ".of-splash-dots span {",
-        "    position: absolute;",
-        "    top: 0;",
-        "    left: 0;",
-        "    width: 4px;",
-        "    height: 4px;",
+        "    width: 0.46em;",
+        "    height: 0.46em;",
         "    border-radius: 50%;",
-        "    background: currentColor;",
-        "    opacity: 0;",
-        "    animation: of-splash-dot 2.4s infinite cubic-bezier(0.4, 0, 0.6, 1);",
+        "    background: var(--sp-accent);",
+        "    opacity: 0.3;",
+        "    animation: of-splash-dot 1.35s infinite ease-in-out;",
         "}",
-        ".of-splash-dots span:nth-child(2) { animation-delay: 0.14s; }",
-        ".of-splash-dots span:nth-child(3) { animation-delay: 0.28s; }",
-        ".of-splash-dots span:nth-child(4) { animation-delay: 0.42s; }",
-        ".of-splash-dots span:nth-child(5) { animation-delay: 0.56s; }",
+        ".of-splash-dots span:nth-child(2) { animation-delay: 0.18s; }",
+        ".of-splash-dots span:nth-child(3) { animation-delay: 0.36s; }",
         "@keyframes of-splash-dot {",
-        "    0% { left: 0; opacity: 0; }",
-        "    15% { opacity: 1; }",
-        "    35% { left: 45%; }",
-        "    65% { left: 55%; }",
-        "    85% { opacity: 1; }",
-        "    100% { left: 100%; opacity: 0; }",
+        "    0%, 70%, 100% { opacity: 0.3; transform: scale(0.9); }",
+        "    30% { opacity: 1; transform: scale(1); }",
+        "}",
+        ".of-splash-brand {",
+        "    display: flex;",
+        "    align-items: center;",
+        "    justify-content: center;",
+        "    gap: 0.85em;",
         "}",
-        ".of-splash-page {",
-        "    background: #ffffff;",
-        "    color: #5f6368;",
+        ".of-splash-logo { width: 3.5em; height: 3.5em; display: block; border-radius: 22%; }",
+        ".of-splash-dark .of-splash-logo { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14); }",
+        ".of-splash-wordmark {",
+        "    font-size: 2.6em;",
+        "    font-weight: 500;",
+        "    line-height: 1;",
+        "    letter-spacing: -0.015em;",
         "}",
-        ".of-splash-page.of-splash-dark { background: #1f2328; color: #b8bec6; }",
-        ".of-splash-center {",
+        ".of-splash-motto {",
+        "    margin-top: 0.55em;",
+        "    font-size: 1em;",
+        "    color: #8a93a3;",
+        "    word-spacing: 0.2em;",
+        "}",
+        ".of-splash-dark .of-splash-motto { color: var(--sp-sub); }",
+        ".of-splash-bar {",
+        "    position: relative;",
+        "    width: 9.75em;",
+        "    height: 0.3em;",
+        "    margin: 1.9em auto 0;",
+        "    border-radius: 1em;",
+        "    background: var(--sp-track);",
+        "    overflow: hidden;",
+        "}",
+        ".of-splash-bar::before {",
+        "    content: \"\";",
         "    position: absolute;",
+        "    top: 0;",
+        "    bottom: 0;",
         "    left: 0;",
-        "    right: 0;",
-        "    top: 50%;",
-        "    transform: translateY(-60%);",
-        "    text-align: center;",
+        "    width: 64%;",
+        "    border-radius: inherit;",
+        "    background: var(--sp-accent);",
+        "    animation: of-splash-bar 2s infinite ease-in-out;",
         "}",
-        ".of-splash-icon {",
-        "    width: 88px;",
-        "    height: 88px;",
-        "    display: block;",
-        "    margin: 0 auto 22px;",
+        "@keyframes of-splash-bar {",
+        "    0% { left: 0; width: 0; }",
+        "    55% { left: 0; width: 64%; }",
+        "    100% { left: 100%; width: 10%; }",
         "}",
-        ".of-splash-page .of-splash-status {",
-        "    font-size: 13px;",
-        "    min-height: 1.4em;",
-        "    padding: 0 16px;",
-        "}",
-        ".of-splash-foot {",
+        ".of-splash-status {",
         "    position: absolute;",
-        "    left: 0;",
-        "    right: 0;",
-        "    bottom: 22px;",
-        "    display: flex;",
-        "    align-items: center;",
-        "    justify-content: center;",
-        "    gap: 7px;",
-        "    font-size: 13px;",
+        "    left: 1.3em;",
+        "    right: 1.3em;",
+        "    bottom: 1.1em;",
+        "    font-size: max(10px, 0.7em);",
+        "    color: var(--sp-status);",
+        "    white-space: nowrap;",
+        "    overflow: hidden;",
+        "    text-overflow: ellipsis;",
         "}",
         "@media (prefers-reduced-motion: reduce) {",
         "    .of-splash-dots span { animation: none; opacity: 0.8; }",
-        "    .of-splash-dots span:nth-child(1) { left: 30%; }",
-        "    .of-splash-dots span:nth-child(2) { left: 40%; }",
-        "    .of-splash-dots span:nth-child(3) { left: 50%; }",
-        "    .of-splash-dots span:nth-child(4) { left: 60%; }",
-        "    .of-splash-dots span:nth-child(5) { left: 70%; }",
+        "    .of-splash-bar::before { animation: none; }",
         "    .of-splash { transition: none; }",
         "}",
         "@media print {",
@@ -186,11 +268,19 @@ var OfficeSplash = (function () {
     function injectStyle() {
         var st = document.createElement("style");
         st.setAttribute("data-of-splash", "");
-        var app = document.body ? document.body.getAttribute("data-officeapp") : "";
-        st.textContent = ".of-splash{--of-splash-bg:" + (APP_COLOURS[app] || "#1f6fbf") + ";}\n" + CSS;
+        var masks = ".of-splash-shape-top{-webkit-mask-image:url(\"" + ART + "shape_top.svg\");mask-image:url(\"" + ART + "shape_top.svg\");}\n" +
+            ".of-splash-shape-bottom{-webkit-mask-image:url(\"" + ART + "shape_bottom.svg\");mask-image:url(\"" + ART + "shape_bottom.svg\");}\n";
+        st.textContent = CSS + "\n" + masks;
         (document.head || document.documentElement).appendChild(st);
     }
 
+    // which app this is: the page's data-officeapp, else the data-app name
+    function appKey() {
+        var key = (document.body && document.body.getAttribute("data-officeapp")) || appName;
+        key = String(key || "").toLowerCase();
+        return APPS[key] ? key : "";
+    }
+
     // the web desktop's float window this page sits in, when it can be sized
     // (the mobile desktop has float windows too, but they fill the screen)
     function floatWindow() {
@@ -228,72 +318,67 @@ var OfficeSplash = (function () {
         if (text) n.textContent = text;
         return n;
     }
-    // the suite mark: four rounded squares
-    function mark(cls) {
-        var ns = "http://www.w3.org/2000/svg";
-        var svg = document.createElementNS(ns, "svg");
-        svg.setAttribute("viewBox", "0 0 24 24");
-        svg.setAttribute("class", cls);
-        svg.setAttribute("aria-hidden", "true");
-        [[3.5, 3.5], [13.5, 3.5], [3.5, 13.5], [13.5, 13.5]].forEach(function (p) {
-            var r = document.createElementNS(ns, "rect");
-            r.setAttribute("x", p[0]);
-            r.setAttribute("y", p[1]);
-            r.setAttribute("width", 7);
-            r.setAttribute("height", 7);
-            r.setAttribute("rx", 1.5);
-            svg.appendChild(r);
-        });
-        return svg;
+    function img(cls, src) {
+        var n = el("img", cls);
+        n.src = src;
+        n.alt = "";
+        n.draggable = false;
+        return n;
     }
-    function dots() {
+
+    // Docs / Sheets / Slides: icon, "ArozOS <App>", tagline, dots
+    function appCard(key) {
+        var center = el("div", "of-splash-center");
+        center.appendChild(img("of-splash-icon", icon || (ART + key + ".svg")));
+        var title = el("div", "of-splash-title", "ArozOS ");
+        title.appendChild(el("b", "", appName));
+        center.appendChild(title);
+        var tagline = el("div", "of-splash-tagline");
+        APPS[key].forEach(function (line) { tagline.appendChild(el("span", "", line)); });
+        center.appendChild(tagline);
         var d = el("div", "of-splash-dots");
-        for (var i = 0; i < 5; i++) d.appendChild(el("span"));
-        return d;
+        for (var i = 0; i < 3; i++) d.appendChild(el("span"));
+        center.appendChild(d);
+        return center;
+    }
+
+    // any other app: the ArozOS mark, the motto and a progress bar
+    function suiteCard() {
+        var center = el("div", "of-splash-center");
+        var brand = el("div", "of-splash-brand");
+        brand.appendChild(img("of-splash-logo", icon || (ART + "arozos.svg")));
+        brand.appendChild(el("div", "of-splash-wordmark", "ArozOS"));
+        center.appendChild(brand);
+        center.appendChild(el("div", "of-splash-motto", "Create · Edit · Collaborate"));
+        center.appendChild(el("div", "of-splash-bar"));
+        return center;
     }
 
     function build() {
         injectStyle();
         var fw = floatWindow();
-        root = el("div", "of-splash of-noprint " + (fw ? "of-splash-window" : "of-splash-page"));
+        var key = appKey();
+        root = el("div", "of-splash of-noprint " + (fw ? "of-splash-window" : "of-splash-page") +
+            (key ? " of-splash-" + key : ""));
         root.setAttribute("role", "status");
         root.setAttribute("aria-live", "polite");
         var dark = false;
         try { dark = localStorage.getItem("office_theme") === "dark"; } catch (e) { }
         if (dark) root.classList.add("of-splash-dark");
 
+        root.appendChild(el("div", "of-splash-shape of-splash-shape-top"));
+        root.appendChild(el("div", "of-splash-shape of-splash-shape-bottom"));
+        root.appendChild(key ? appCard(key) : suiteCard());
+        statusEl = el("div", "of-splash-status", "Loading your workspace...");
+        root.appendChild(statusEl);
+
         if (fw) {
-            var brand = el("div", "of-splash-brand");
-            brand.appendChild(mark("of-splash-mark"));
-            brand.appendChild(el("span", "", "ArozOS Office"));
-            root.appendChild(brand);
-            var mid = el("div", "of-splash-mid");
-            mid.appendChild(el("div", "of-splash-name", appName));
-            mid.appendChild(dots());
-            root.appendChild(mid);
-            statusEl = el("div", "of-splash-status", "Loading...");
-            root.appendChild(statusEl);
             // start small, like a splash, in the middle of the desktop;
             // grown back in done()
             try {
                 setResizable(fw, false);
                 placeCentred(fw, SPLASH_W, SPLASH_H);
             } catch (e) { }
-        } else {
-            var center = el("div", "of-splash-center");
-            if (icon) {
-                var img = el("img", "of-splash-icon");
-                img.src = icon;
-                img.alt = "";
-                center.appendChild(img);
-            }
-            statusEl = el("div", "of-splash-status", "Loading " + appName + "...");
-            center.appendChild(statusEl);
-            root.appendChild(center);
-            var foot = el("div", "of-splash-foot");
-            foot.appendChild(mark("of-splash-mark"));
-            foot.appendChild(el("span", "", "ArozOS Office"));
-            root.appendChild(foot);
         }
         document.body.insertBefore(root, document.body.firstChild);
         giveUp = setTimeout(done, GIVE_UP_MS);

+ 2 - 2
src/web/Office/docs/index.html

@@ -6,7 +6,7 @@
     <title>Docs</title>
     <link rel="icon" type="image/png" href="docs.png">
     <!-- fetched first: the start-up splash shows it -->
-    <link rel="preload" href="../img/docs.svg" as="image">
+    <link rel="preload" href="../img/splash/docs.svg" as="image">
     <!-- Dynamic print stylesheet: docs.js writes the @page rule (size,
          orientation, margins, optional page-number margin box) in here
          whenever the page setup changes. Static print rules live in docs.css. -->
@@ -14,7 +14,7 @@
 </head>
 <body data-officeapp="docs">
     <!-- start-up splash: first, so it covers the page before anything draws -->
-    <script src="../common/splash.js" data-app="Docs" data-icon="../img/docs.svg" data-size="1080x700"></script>
+    <script src="../common/splash.js" data-app="Docs" data-size="1080x700"></script>
     <!-- the app's stylesheets and scripts come after the splash, so it can
          paint while they load -->
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">

+ 11 - 0
src/web/Office/img/splash/arozos.svg

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <!-- ArozOS mark for the suite-wide start-up splash (common/splash.js) -->
+  <defs>
+    <linearGradient id="arozosTile" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#454b55"/>
+      <stop offset="1" stop-color="#1c2027"/>
+    </linearGradient>
+  </defs>
+  <rect x="0" y="0" width="64" height="64" rx="14" fill="url(#arozosTile)"/>
+  <path d="M19.5 45L30 20.5Q32 16.5 34 20.5L44.5 45" fill="none" stroke="#ffffff" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 17 - 0
src/web/Office/img/splash/docs.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 116">
+  <!-- Docs app icon for the start-up splash (common/splash.js) -->
+  <defs>
+    <linearGradient id="docsPage" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#4593f7"/>
+      <stop offset="1" stop-color="#1f68dc"/>
+    </linearGradient>
+  </defs>
+  <path d="M12 4H64L92 32V104A8 8 0 0 1 84 112H12A8 8 0 0 1 4 104V12A8 8 0 0 1 12 4Z" fill="url(#docsPage)"/>
+  <path d="M64 4V26A6 6 0 0 0 70 32H92Z" fill="#a9cbfb"/>
+  <g fill="#ffffff">
+    <rect x="23" y="50" width="50" height="5.5" rx="2.75"/>
+    <rect x="23" y="61" width="50" height="5.5" rx="2.75"/>
+    <rect x="23" y="72" width="50" height="5.5" rx="2.75"/>
+    <rect x="23" y="83" width="32" height="5.5" rx="2.75"/>
+  </g>
+</svg>

+ 6 - 0
src/web/Office/img/splash/shape_bottom.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 160">
+  <!-- Start-up splash decoration, bottom-left corner: two soft hills. Used
+       as a CSS mask: only the alpha counts, each app paints its own colour. -->
+  <path d="M0 18C88 20 176 78 222 160H0Z" fill="#000" fill-opacity="0.6"/>
+  <path d="M0 112C46 94 104 72 160 86C210 98 252 130 292 160H0Z" fill="#000" fill-opacity="0.55"/>
+</svg>

+ 6 - 0
src/web/Office/img/splash/shape_top.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
+  <!-- Start-up splash decoration, top-right corner. Used as a CSS mask:
+       only the alpha counts, each app paints its own colour through it. -->
+  <circle cx="250" cy="-20" r="200" fill="#000" fill-opacity="0.62"/>
+  <circle cx="240" cy="-50" r="150" fill="#000" fill-opacity="0.55"/>
+</svg>

+ 25 - 0
src/web/Office/img/splash/sheets.svg

@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 116">
+  <!-- Sheets app icon for the start-up splash (common/splash.js) -->
+  <defs>
+    <linearGradient id="sheetsPage" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#3cbc57"/>
+      <stop offset="1" stop-color="#1e973a"/>
+    </linearGradient>
+  </defs>
+  <path d="M12 4H64L92 32V104A8 8 0 0 1 84 112H12A8 8 0 0 1 4 104V12A8 8 0 0 1 12 4Z" fill="url(#sheetsPage)"/>
+  <path d="M64 4V26A6 6 0 0 0 70 32H92Z" fill="#a8e2b3"/>
+  <g fill="#ffffff">
+    <rect x="22.5" y="52" width="11" height="8.5" rx="1.6"/>
+    <rect x="36" y="52" width="11" height="8.5" rx="1.6"/>
+    <rect x="49.5" y="52" width="11" height="8.5" rx="1.6"/>
+    <rect x="63" y="52" width="11" height="8.5" rx="1.6"/>
+    <rect x="22.5" y="63" width="11" height="8.5" rx="1.6"/>
+    <rect x="36" y="63" width="11" height="8.5" rx="1.6"/>
+    <rect x="49.5" y="63" width="11" height="8.5" rx="1.6"/>
+    <rect x="63" y="63" width="11" height="8.5" rx="1.6"/>
+    <rect x="22.5" y="74" width="11" height="8.5" rx="1.6"/>
+    <rect x="36" y="74" width="11" height="8.5" rx="1.6"/>
+    <rect x="49.5" y="74" width="11" height="8.5" rx="1.6"/>
+    <rect x="63" y="74" width="11" height="8.5" rx="1.6"/>
+  </g>
+</svg>

+ 16 - 0
src/web/Office/img/splash/slides.svg

@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 116">
+  <!-- Slides app icon for the start-up splash (common/splash.js) -->
+  <defs>
+    <linearGradient id="slidesPage" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#ffb632"/>
+      <stop offset="1" stop-color="#f3831b"/>
+    </linearGradient>
+  </defs>
+  <path d="M12 4H64L92 32V104A8 8 0 0 1 84 112H12A8 8 0 0 1 4 104V12A8 8 0 0 1 12 4Z" fill="url(#slidesPage)"/>
+  <path d="M64 4V26A6 6 0 0 0 70 32H92Z" fill="#ffdc8f"/>
+  <g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round">
+    <rect x="25.5" y="50" width="45" height="30" rx="3.5" stroke-width="5"/>
+    <path d="M48 81V88" stroke-width="5"/>
+    <path d="M39 89H57" stroke-width="5"/>
+  </g>
+</svg>

+ 6 - 6
src/web/Office/init.agi

@@ -26,8 +26,8 @@ registerModule(JSON.stringify({
     LaunchEmb: "Office/docs/index.html",
     // the window opens at the size of the start-up splash and grows to
     // 1080x700 once the document is ready (common/splash.js, data-size)
-    InitFWSize: [400, 240],
-    InitEmbSize: [400, 240],
+    InitFWSize: [480, 320],
+    InitEmbSize: [480, 320],
     SupportedExt: [".doca", ".docx"]
 }));
 
@@ -44,8 +44,8 @@ registerModule(JSON.stringify({
     SupportEmb: true,
     LaunchEmb: "Office/sheets/index.html",
     // splash-sized, grows to 1180x720 when ready (common/splash.js)
-    InitFWSize: [400, 240],
-    InitEmbSize: [400, 240],
+    InitFWSize: [480, 320],
+    InitEmbSize: [480, 320],
     SupportedExt: [".xlsa", ".csv", ".tsv", ".xlsx"]
 }));
 
@@ -63,8 +63,8 @@ registerModule(JSON.stringify({
     SupportEmb: true,
     LaunchEmb: "Office/slides/index.html",
     // splash-sized, grows to 1220x740 when ready (common/splash.js)
-    InitFWSize: [400, 240],
-    InitEmbSize: [400, 240],
+    InitFWSize: [480, 320],
+    InitEmbSize: [480, 320],
     SupportedExt: [".ppta", ".pptx"]
 }));
 

+ 545 - 74
src/web/Office/sheets/formula.js

@@ -10,27 +10,32 @@
         cellName(col,row)                        -> "A1" (0-based in, 1-based out)
         parseCellKey("A1")                       -> {col,row} or null
         tokenize(src) / parse(src)               tokens / AST ("src" WITHOUT "=")
-        evaluate(ast, ctx)                       ctx.cell(col,row) -> value
-        createCalculator(getRaw)                 memoized calc w/ cycle detection
-            .value(col,row) -> number|string|boolean|null|FErr
+        evaluate(ast, ctx)                       ctx.cell(col,row,sheetName) -> value
+        createCalculator(getRaw, opts)           memoized workbook calc w/ cycle detection
+            .value(col,row,[sheetIdx]) -> number|string|boolean|null|FErr
             .reset()
         literalValue(raw)                        raw typed text -> value
         rewriteRelative(formula, dCol, dRow)     shift relative refs (copy/fill)
-        adjustInsertDelete(formula, axis, index, count)
+        adjustInsertDelete(formula, axis, index, count, [sheet])
                                                  axis "row"|"col", count<0 = delete
+        renameSheetRefs(formula, oldName, newName)
         isErr(v), FErr, ERR                      error values
         dateToSerial(date) / serialToDate(n)     Excel-style 1900 date serials
 
     Values: number | string | boolean | null (empty) | FErr.
     Errors: #DIV/0! #NAME? #REF! #VALUE! #CYCLE! #NUM! #N/A.
 
+    References: A1, $A$1, A1:B9, Sheet2!A1, 'Closed Tickets'!$C$3:$C$5000.
+    Ranges inside SUMPRODUCT (and array expressions handed to SUM & co.)
+    evaluate as arrays, with operators applied element by element.
+
     Functions:
         logical    IF IFS IFERROR IFNA AND OR NOT
-        lookup     VLOOKUP HLOOKUP
-        aggregate  SUM AVERAGE MIN MAX COUNT COUNTA
-        math       ROUND ABS INT
+        lookup     VLOOKUP HLOOKUP CHOOSE
+        aggregate  SUM AVERAGE MIN MAX COUNT COUNTA SUMPRODUCT
+        math       ROUND ABS INT MOD
         text       CONCAT (=CONCATENATE) LEN UPPER LOWER TRIM
-        date       TODAY NOW
+        date       TODAY NOW DATE YEAR MONTH DAY WEEKDAY HOUR MINUTE SECOND
 
     IF / IFS / IFERROR evaluate only the branch they return, so
     IF(A1=0,"",1/A1) never divides by zero. IF's value_if_false is optional
@@ -115,7 +120,25 @@ var SheetFormula = (function () {
     }
 
     /* ---------- tokenizer ---------- */
-    /* token: {t:"num"|"str"|"err"|"ref"|"name"|"op", v, pos, len, [col,row,absC,absR]} */
+    /* token: {t:"num"|"str"|"err"|"ref"|"name"|"op", v, pos, len,
+               [col,row,absC,absR, sheet, prefix]}
+       A sheet-qualified ref carries sheet (the unquoted name) and prefix
+       (the source text up to and including "!"); pos/len cover both. */
+    var REF_RE = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?![\w.(!])/;
+    // an unquoted sheet name must be followed by "!" and a cell address
+    var SHEET_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_.]*)!(?=\$?[A-Za-z]{1,3}\$?\d)/;
+    var PLAIN_SHEET_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
+
+    // how a sheet name is written in front of "!": quoted unless it is a
+    // plain identifier that cannot be mistaken for a cell address
+    function quoteSheetName(name) {
+        name = String(name);
+        if (PLAIN_SHEET_RE.test(name) && !/^[A-Za-z]{1,3}\d+$/.test(name)) return name;
+        return "'" + name.replace(/'/g, "''") + "'";
+    }
+    function sameSheetName(a, b) {
+        return String(a).toLowerCase() === String(b).toLowerCase();
+    }
     function tokenize(src) {
         var toks = [], i = 0, n = src.length, m;
         while (i < n) {
@@ -153,18 +176,49 @@ var SheetFormula = (function () {
                 i += m[0].length;
                 continue;
             }
+            // sheet-qualified reference: 'Closed Tickets'!$C$3 or Analysis!H2.
+            // The prefix is kept in the token so reference rewriting can put
+            // it back in front of the shifted address.
+            var sheetName = null, prefixLen = 0;
+            if (ch === "'") {
+                var q = i + 1, qname = "";
+                while (q < n) {
+                    if (src.charAt(q) === "'") {
+                        if (src.charAt(q + 1) === "'") { qname += "'"; q += 2; continue; }
+                        break;
+                    }
+                    qname += src.charAt(q);
+                    q++;
+                }
+                if (q >= n || src.charAt(q + 1) !== "!") throw new FErr(ERR.REF, "Malformed sheet name");
+                sheetName = qname;
+                prefixLen = q + 2 - i;
+            } else {
+                m = SHEET_PREFIX_RE.exec(src.slice(i));
+                if (m) { sheetName = m[1]; prefixLen = m[0].length; }
+            }
             // cell reference (possibly $-anchored); a trailing "(" means function name instead
-            m = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?![\w.(])/.exec(src.slice(i));
+            m = REF_RE.exec(src.slice(i + prefixLen));
             if (m) {
-                toks.push({
+                var tok = {
                     t: "ref",
                     absC: m[1] === "$", col: nameToCol(m[2]),
                     absR: m[3] === "$", row: parseInt(m[4], 10) - 1,
-                    pos: i, len: m[0].length
-                });
-                i += m[0].length;
+                    pos: i, len: prefixLen + m[0].length
+                };
+                if (sheetName !== null) {
+                    tok.sheet = sheetName;
+                    tok.prefix = src.substr(i, prefixLen);
+                } else if (toks.length >= 2 && toks[toks.length - 1].t === "op" && toks[toks.length - 1].v === ":" &&
+                    toks[toks.length - 2].t === "ref" && toks[toks.length - 2].sheet !== undefined) {
+                    // the end of Sheet!A1:B2 is on Sheet too (no prefix to rewrite)
+                    tok.sheet = toks[toks.length - 2].sheet;
+                }
+                toks.push(tok);
+                i += tok.len;
                 continue;
             }
+            if (sheetName !== null) throw new FErr(ERR.REF, "Expected a cell reference after " + sheetName + "!");
             m = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(src.slice(i));
             if (m) {
                 toks.push({ t: "name", v: m[0].toUpperCase(), pos: i, len: m[0].length });
@@ -267,9 +321,14 @@ var SheetFormula = (function () {
                     var t2 = peek();
                     if (!t2 || t2.t !== "ref") throw new FErr(ERR.VALUE, "Malformed range");
                     p++;
-                    return { t: "range", a: t, b: t2 };
+                    // Sheet!A1:B2 names the sheet once; Sheet!A1:Sheet!B2 is also legal
+                    var rs = t.sheet !== undefined ? t.sheet : t2.sheet;
+                    if (t.sheet !== undefined && t2.sheet !== undefined && !sameSheetName(t.sheet, t2.sheet)) {
+                        throw new FErr(ERR.REF, "A range cannot span two sheets");
+                    }
+                    return { t: "range", a: t, b: t2, sheet: rs };
                 }
-                return { t: "ref", col: t.col, row: t.row, absC: t.absC, absR: t.absR };
+                return { t: "ref", col: t.col, row: t.row, absC: t.absC, absR: t.absR, sheet: t.sheet };
             }
             if (t.t === "name") {
                 p++;
@@ -346,6 +405,24 @@ var SheetFormula = (function () {
         return new FErr(ERR.VALUE, "Expected a logical value");
     }
 
+    /* An array value: what a range (or an operator applied to ranges)
+       yields inside an array context such as SUMPRODUCT's arguments.
+       data is row-major, rows x cols long. */
+    function Arr(rows, cols, data) {
+        this.rows = rows;
+        this.cols = cols;
+        this.data = data;
+    }
+    function isArr(v) { return v instanceof Arr; }
+
+    /*
+        Evaluate an AST. ctx.cell(col, row, sheet) returns a cell's value;
+        sheet is the name written in the formula, or undefined for the
+        formula's own sheet. ctx.range(c1, r1, c2, r2, sheet), when present,
+        returns the values of a whole range as an Arr (the calculator caches
+        these, which is what keeps thousands of SUMPRODUCTs over the same
+        column affordable); without it ranges are read cell by cell.
+    */
     function evaluate(ast, ctx) {
 
         function ev(n) {
@@ -355,58 +432,219 @@ var SheetFormula = (function () {
                 case "bool": return n.v;
                 case "errlit": return new FErr(n.v, "Error value");
                 case "empty": return null;
-                case "ref": return ctx.cell(n.col, n.row);
+                case "ref": return ctx.cell(n.col, n.row, n.sheet);
                 case "range": return new FErr(ERR.VALUE, "A range cannot be used as a single value");
                 case "pct": {
                     var pv = toNum(ev(n.e));
                     return isErr(pv) ? pv : pv / 100;
                 }
+                case "un": return unValue(n.op, ev(n.e));
+                case "bin":
+                    // comparisons and arithmetic need both sides anyway
+                    return binValue(n.op, ev(n.l), ev(n.r));
+                case "call": return call(n, false);
+                default: return new FErr(ERR.VALUE, "Bad expression");
+            }
+        }
+
+        /* Array-context evaluation: ranges become Arr values and operators
+           apply element by element (Excel's array semantics), so
+           ('Data'!A2:A99="x")*('Data'!B2:B99) is an array of numbers. */
+        function evA(n) {
+            switch (n.t) {
+                case "range": return rangeArr(n);
+                case "pct": return lift1(evA(n.e), function (v) {
+                    v = toNum(v);
+                    return isErr(v) ? v : v / 100;
+                });
                 case "un": {
-                    var uv = toNum(ev(n.e));
-                    if (isErr(uv)) return uv;
-                    return n.op === "-" ? -uv : uv;
+                    var op = n.op;
+                    return lift1(evA(n.e), function (v) { return unValue(op, v); });
                 }
-                case "bin": return binop(n);
-                case "call": return call(n);
-                default: return new FErr(ERR.VALUE, "Bad expression");
+                case "bin": {
+                    var bop = n.op, l = evA(n.l), r = evA(n.r);
+                    if (!isArr(l) && !isArr(r)) return binValue(bop, l, r);
+                    return binArr(bop, l, r);
+                }
+                case "call": return call(n, true);
+                default: return ev(n);
+            }
+        }
+
+        function rangeArr(node) {
+            var b = rangeBox(node);
+            if ((b.c2 - b.c1 + 1) * (b.r2 - b.r1 + 1) > MAX_RANGE_CELLS) {
+                return new FErr(ERR.VALUE, "Range too large");
+            }
+            if (ctx.range) return ctx.range(b.c1, b.r1, b.c2, b.r2, node.sheet);
+            var data = [];
+            for (var r = b.r1; r <= b.r2; r++) {
+                for (var c = b.c1; c <= b.c2; c++) data.push(ctx.cell(c, r, node.sheet));
             }
+            return new Arr(b.r2 - b.r1 + 1, b.c2 - b.c1 + 1, data);
         }
 
-        function binop(n) {
-            var op = n.op;
+        function lift1(v, fn) {
+            if (!isArr(v)) return fn(v);
+            var out = new Array(v.data.length);
+            for (var i = 0; i < out.length; i++) out[i] = fn(v.data[i]);
+            return new Arr(v.rows, v.cols, out);
+        }
+        /* Element-wise over two values with Excel broadcasting: a scalar or a
+           single row / column stretches to fit; cells that exist in neither
+           operand (mismatched sizes) are #N/A. */
+        function lift2(a, b, fn) {
+            var A = isArr(a) ? a : new Arr(1, 1, [a]);
+            var B = isArr(b) ? b : new Arr(1, 1, [b]);
+            var rows = Math.max(A.rows, B.rows), cols = Math.max(A.cols, B.cols);
+            var out = new Array(rows * cols);
+            if (A.rows === B.rows && A.cols === B.cols) {
+                for (var i = 0; i < out.length; i++) out[i] = fn(A.data[i], B.data[i]);
+            } else {
+                for (var r = 0; r < rows; r++) {
+                    for (var c = 0; c < cols; c++) {
+                        var av = pick(A, r, c), bv = pick(B, r, c);
+                        out[r * cols + c] = (av === undefined || bv === undefined) ?
+                            new FErr(ERR.NA, "Array sizes do not match") : fn(av, bv);
+                    }
+                }
+            }
+            return new Arr(rows, cols, out);
+        }
+        /*
+            An operator over arrays. Same-size arrays and array-with-scalar
+            (the shapes SUMPRODUCT criteria produce) run a tight loop that
+            handles number/number, text/text and logical arithmetic inline;
+            anything else falls back to binValue, so results are identical.
+            Text is compared through a lower-cased copy that is cached on the
+            array, and cached ranges keep it across formulas.
+        */
+        function binArr(op, l, r) {
+            var A = isArr(l) ? l : new Arr(1, 1, [l]);
+            var B = isArr(r) ? r : new Arr(1, 1, [r]);
+            var la = A.data.length, lb = B.data.length;
+            var same = A.rows === B.rows && A.cols === B.cols;
+            if (!same && la !== 1 && lb !== 1) {
+                return lift2(A, B, function (a, b) { return binValue(op, a, b); });
+            }
+            var rows = la === 1 ? B.rows : A.rows, cols = la === 1 ? B.cols : A.cols;
+            var len = Math.max(la, lb), out = new Array(len);
+            var sa = la === 1, sb = lb === 1;
+            var ad = A.data, bd = B.data, i, x, y, d, res;
+            var cmp = op === "=" || op === "<>" || op === "<" || op === ">" || op === "<=" || op === ">=";
+            // a cached range against a constant: the same criterion shows up
+            // in every formula of a report, so compute it once per recalc
+            var memoArr = null, memoKey = null;
+            if (cmp && (A.shared ? sb && !isErr(bd[0]) : B.shared && sa && !isErr(ad[0]))) {
+                memoArr = A.shared ? A : B;
+                var other = A.shared ? bd[0] : ad[0];
+                memoKey = (A.shared ? "L" : "R") + op + typeof other + ":" + other;
+                if (!memoArr.cmp) memoArr.cmp = {};
+                if (Object.prototype.hasOwnProperty.call(memoArr.cmp, memoKey)) return memoArr.cmp[memoKey];
+            }
+            if (cmp) {
+                var ax = lowered(A), bx = lowered(B);
+                for (i = 0; i < len; i++) {
+                    x = ax[sa ? 0 : i];
+                    y = bx[sb ? 0 : i];
+                    var tx = typeof x, ty = typeof y;
+                    if (tx === "number" && ty === "number") d = x - y;
+                    else if (tx === "string" && ty === "string") d = x < y ? -1 : (x > y ? 1 : 0);
+                    else { out[i] = compare(op, ad[sa ? 0 : i], bd[sb ? 0 : i]); continue; }
+                    switch (op) {
+                        case "=": out[i] = d === 0; break;
+                        case "<>": out[i] = d !== 0; break;
+                        case "<": out[i] = d < 0; break;
+                        case ">": out[i] = d > 0; break;
+                        case "<=": out[i] = d <= 0; break;
+                        default: out[i] = d >= 0;
+                    }
+                }
+            } else if (op === "+" || op === "-" || op === "*" || op === "/") {
+                for (i = 0; i < len; i++) {
+                    x = ad[sa ? 0 : i];
+                    y = bd[sb ? 0 : i];
+                    if (typeof x === "boolean") x = x ? 1 : 0;
+                    else if (x === null) x = 0;
+                    if (typeof y === "boolean") y = y ? 1 : 0;
+                    else if (y === null) y = 0;
+                    if (typeof x !== "number" || typeof y !== "number" || (op === "/" && y === 0)) {
+                        out[i] = binValue(op, ad[sa ? 0 : i], bd[sb ? 0 : i]);
+                        continue;
+                    }
+                    res = op === "*" ? x * y : op === "+" ? x + y : op === "-" ? x - y : x / y;
+                    out[i] = isFinite(res) ? res : new FErr(ERR.NUM, "Numeric overflow");
+                }
+            } else {
+                for (i = 0; i < len; i++) out[i] = binValue(op, ad[sa ? 0 : i], bd[sb ? 0 : i]);
+            }
+            var result = new Arr(rows, cols, out);
+            // results are never mutated, so handing the same one out is safe
+            if (memoArr) memoArr.cmp[memoKey] = result;
+            return result;
+        }
+        // the array's values with text lower-cased (compare() is case-insensitive)
+        function lowered(A) {
+            if (A.lower) return A.lower;
+            var src = A.data, out = new Array(src.length), any = false;
+            for (var i = 0; i < src.length; i++) {
+                var v = src[i];
+                if (typeof v === "string") { out[i] = v.toLowerCase(); any = true; }
+                else out[i] = v;
+            }
+            A.lower = any ? out : src;
+            return A.lower;
+        }
+        function pick(A, r, c) {
+            var rr = A.rows === 1 ? 0 : r, cc = A.cols === 1 ? 0 : c;
+            if (rr >= A.rows || cc >= A.cols) return undefined;
+            return A.data[rr * A.cols + cc];
+        }
+
+        function unValue(op, v) {
+            v = toNum(v);
+            if (isErr(v)) return v;
+            return op === "-" ? -v : v;
+        }
+
+        function binValue(op, l, r) {
             if (op === "&") {
-                var ls = toStr(ev(n.l));
+                var ls = toStr(l);
                 if (isErr(ls)) return ls;
-                var rs = toStr(ev(n.r));
+                var rs = toStr(r);
                 if (isErr(rs)) return rs;
                 return ls + rs;
             }
             if (op === "=" || op === "<>" || op === "<" || op === ">" || op === "<=" || op === ">=") {
-                return compare(op, ev(n.l), ev(n.r));
+                return compare(op, l, r);
             }
-            var a = toNum(ev(n.l));
+            var a = toNum(l);
             if (isErr(a)) return a;
-            var b = toNum(ev(n.r));
+            var b = toNum(r);
             if (isErr(b)) return b;
-            var r;
+            var res;
             switch (op) {
-                case "+": r = a + b; break;
-                case "-": r = a - b; break;
-                case "*": r = a * b; break;
+                case "+": res = a + b; break;
+                case "-": res = a - b; break;
+                case "*": res = a * b; break;
                 case "/":
                     if (b === 0) return new FErr(ERR.DIV0, "Division by zero");
-                    r = a / b;
+                    res = a / b;
                     break;
-                case "^": r = Math.pow(a, b); break;
+                case "^": res = Math.pow(a, b); break;
                 default: return new FErr(ERR.VALUE, "Bad operator " + op);
             }
-            if (typeof r !== "number" || !isFinite(r)) return new FErr(ERR.NUM, "Numeric overflow");
-            return r;
+            if (typeof res !== "number" || !isFinite(res)) return new FErr(ERR.NUM, "Numeric overflow");
+            return res;
         }
 
         function compare(op, l, r) {
             if (isErr(l)) return l;
             if (isErr(r)) return r;
+            // a blank cell compares as "" against text (so =A1="" is TRUE)
+            // and as 0 against numbers
+            if (l === null && typeof r === "string") l = "";
+            if (r === null && typeof l === "string") r = "";
             if (typeof l === "boolean") l = l ? 1 : 0;
             if (typeof r === "boolean") r = r ? 1 : 0;
             var d;
@@ -440,39 +678,44 @@ var SheetFormula = (function () {
             };
         }
         function eachRangeCell(node, fn) {
-            var c1 = Math.min(node.a.col, node.b.col), c2 = Math.max(node.a.col, node.b.col);
-            var r1 = Math.min(node.a.row, node.b.row), r2 = Math.max(node.a.row, node.b.row);
-            if ((c2 - c1 + 1) * (r2 - r1 + 1) > MAX_RANGE_CELLS) {
-                return new FErr(ERR.VALUE, "Range too large");
-            }
-            for (var r = r1; r <= r2; r++) {
-                for (var c = c1; c <= c2; c++) {
-                    var stop = fn(ctx.cell(c, r));
-                    if (stop !== undefined) return stop;
-                }
+            return eachArrCell(rangeArr(node), fn);
+        }
+        function eachArrCell(arr, fn) {
+            if (isErr(arr)) return arr;
+            for (var i = 0; i < arr.data.length; i++) {
+                var stop = fn(arr.data[i]);
+                if (stop !== undefined) return stop;
             }
             return undefined;
         }
 
         /* Collect numeric/count statistics over the argument list.
            Range cells: numbers counted, strings/booleans only for COUNTA.
+           Array expressions (=SUM((A1:A9>0)*B1:B9)) count like ranges.
            Direct scalars: numbers/booleans/numeric strings are numeric;
            non-numeric strings poison SUM-style aggregates (#VALUE!). */
         function collect(args) {
             var st = { nums: [], count: 0, counta: 0, badString: false, err: null };
+            function fromCells(v) {
+                if (isErr(v)) return v;
+                if (v === null || v === undefined) return undefined;
+                st.counta++;
+                if (typeof v === "number") { st.nums.push(v); st.count++; }
+                return undefined;
+            }
             for (var i = 0; i < args.length; i++) {
                 var a = args[i];
+                var stop;
                 if (a.t === "range") {
-                    var stop = eachRangeCell(a, function (v) {
-                        if (isErr(v)) return v;
-                        if (v === null || v === undefined) return undefined;
-                        st.counta++;
-                        if (typeof v === "number") { st.nums.push(v); st.count++; }
-                        return undefined;
-                    });
+                    stop = eachRangeCell(a, fromCells);
                     if (stop !== undefined) { st.err = stop; return st; }
                 } else {
-                    var v = ev(a);
+                    var v = (a.t === "bin" || a.t === "un" || a.t === "call") ? evA(a) : ev(a);
+                    if (isArr(v)) {
+                        stop = eachArrCell(v, fromCells);
+                        if (stop !== undefined) { st.err = stop; return st; }
+                        continue;
+                    }
                     if (isErr(v)) { st.err = v; return st; }
                     if (v === null || v === undefined) continue;
                     st.counta++;
@@ -541,12 +784,13 @@ var SheetFormula = (function () {
             if (span * depth > MAX_RANGE_CELLS) {
                 return new FErr(ERR.VALUE, "Range too large");
             }
+            var sh = args[1].sheet;
             var keyAt = vertical ?
-                function (i) { return ctx.cell(box.c1, box.r1 + i); } :
-                function (i) { return ctx.cell(box.c1 + i, box.r1); };
+                function (i) { return ctx.cell(box.c1, box.r1 + i, sh); } :
+                function (i) { return ctx.cell(box.c1 + i, box.r1, sh); };
             var resultAt = vertical ?
-                function (i) { return ctx.cell(box.c1 + idx - 1, box.r1 + i); } :
-                function (i) { return ctx.cell(box.c1 + i, box.r1 + idx - 1); };
+                function (i) { return ctx.cell(box.c1 + idx - 1, box.r1 + i, sh); } :
+                function (i) { return ctx.cell(box.c1 + i, box.r1 + idx - 1, sh); };
 
             var best = -1, bestVal = null, i, cv, cmp;
             for (i = 0; i < span; i++) {
@@ -565,7 +809,56 @@ var SheetFormula = (function () {
             return new FErr(ERR.NA, name + " found no match for " + toStr(keyv));
         }
 
-        function call(n) {
+        // whole days of a date serial, as a UTC Date (see serialToDate)
+        function serialDate(args) {
+            var sv = oneNum(args, 0);
+            if (isErr(sv)) return sv;
+            if (sv < 0) return new FErr(ERR.NUM, "Dates cannot be negative");
+            return serialToDate(Math.floor(sv));
+        }
+        // seconds into the day of a date/time serial, rounded like Excel
+        function serialSeconds(args) {
+            var sv = oneNum(args, 0);
+            if (isErr(sv)) return sv;
+            if (sv < 0) return new FErr(ERR.NUM, "Times cannot be negative");
+            return Math.round((sv - Math.floor(sv)) * 86400) % 86400;
+        }
+
+        /*
+            SUMPRODUCT(array1, [array2, ...]): every argument is evaluated in
+            array context, all must be the same size, and the element-wise
+            products are summed. Text, blanks and logicals count as 0 (hence
+            the usual (A1:A9="x")*(B1:B9) idiom, where the multiplication has
+            already turned the logicals into numbers); an error anywhere is
+            the result.
+        */
+        function sumproduct(args) {
+            if (!args.length) return new FErr(ERR.VALUE, "SUMPRODUCT needs an argument");
+            var arrs = [];
+            for (var i = 0; i < args.length; i++) {
+                var v = evA(args[i]);
+                if (isErr(v)) return v;
+                if (!isArr(v)) v = new Arr(1, 1, [v]);
+                if (arrs.length && (v.rows !== arrs[0].rows || v.cols !== arrs[0].cols)) {
+                    return new FErr(ERR.VALUE, "SUMPRODUCT arrays must be the same size");
+                }
+                arrs.push(v);
+            }
+            var total = 0, len = arrs[0].data.length;
+            for (var k = 0; k < len; k++) {
+                var prod = 1;
+                for (var j = 0; j < arrs.length; j++) {
+                    var x = arrs[j].data[k];
+                    if (typeof x === "number") prod *= x;
+                    else if (isErr(x)) return x;
+                    else prod = 0;
+                }
+                total += prod;
+            }
+            return total;
+        }
+
+        function call(n, arrayCtx) {
             var name = n.name === "CONCATENATE" ? "CONCAT" : n.name;
             var args = n.args;
             var st, v, d;
@@ -574,6 +867,23 @@ var SheetFormula = (function () {
                     if (args.length < 2 || args.length > 3) {
                         return new FErr(ERR.VALUE, "IF expects 2 or 3 arguments");
                     }
+                    if (arrayCtx) {
+                        // IF over an array condition picks element by element
+                        var ac = evA(args[0]);
+                        if (isArr(ac)) {
+                            var at = evA(args[1]);
+                            var af = args.length > 2 ? evA(args[2]) : null;
+                            return lift2(lift2(ac, at, function (c, t) { return [c, t]; }), af, function (ct, f) {
+                                var cb = boolify(ct[0]);
+                                if (isErr(cb)) return cb;
+                                return cb ? ct[1] : f;
+                            });
+                        }
+                        var acb = boolify(ac);
+                        if (isErr(acb)) return acb;
+                        if (acb) return evA(args[1]);
+                        return args.length > 2 ? evA(args[2]) : null;
+                    }
                     var cond = boolify(ev(args[0]));
                     if (isErr(cond)) return cond;
                     // only the taken branch is evaluated, so
@@ -611,11 +921,13 @@ var SheetFormula = (function () {
                 case "AND":
                 case "OR": {
                     if (!args.length) return new FErr(ERR.VALUE, name + " needs an argument");
-                    // blanks are skipped, the way a spreadsheet ignores empty
-                    // cells inside a range handed to AND/OR
+                    // blanks are skipped, and so is text that comes from a
+                    // referenced cell or range (Excel's rule); only text
+                    // typed straight into the call is an error
                     var seen = 0, acc = name === "AND";
                     for (var lI = 0; lI < args.length; lI++) {
                         var vals = [];
+                        var fromRef = args[lI].t === "range" || args[lI].t === "ref";
                         if (args[lI].t === "range") {
                             var lStop = eachRangeCell(args[lI], function (cv) {
                                 if (isErr(cv)) return cv;
@@ -630,6 +942,7 @@ var SheetFormula = (function () {
                         }
                         for (var vI = 0; vI < vals.length; vI++) {
                             if (vals[vI] === null || vals[vI] === undefined) continue;
+                            if (fromRef && typeof vals[vI] === "string") continue;
                             var b = boolify(vals[vI]);
                             if (isErr(b)) return b;
                             seen++;
@@ -733,6 +1046,66 @@ var SheetFormula = (function () {
                     v = oneStr(args, 0);
                     return isErr(v) ? v : v.replace(/ +/g, " ").replace(/^ | $/g, "");
                 }
+                case "SUMPRODUCT":
+                    return sumproduct(args);
+                case "CHOOSE": {
+                    // CHOOSE(index, value1, value2, ...) - only the chosen one is evaluated
+                    if (args.length < 2) return new FErr(ERR.VALUE, "CHOOSE expects an index and values");
+                    v = oneNum(args, 0);
+                    if (isErr(v)) return v;
+                    v = Math.trunc(v);
+                    if (v < 1 || v >= args.length) return new FErr(ERR.VALUE, "CHOOSE index " + v + " is out of range");
+                    return arrayCtx ? evA(args[v]) : ev(args[v]);
+                }
+                case "MOD": {
+                    v = oneNum(args, 0);
+                    if (isErr(v)) return v;
+                    d = oneNum(args, 1);
+                    if (isErr(d)) return d;
+                    if (d === 0) return new FErr(ERR.DIV0, "MOD by zero");
+                    // the result takes the divisor's sign, as in Excel
+                    return v - d * Math.floor(v / d);
+                }
+                case "YEAR": case "MONTH": case "DAY": {
+                    var dt = serialDate(args);
+                    if (isErr(dt)) return dt;
+                    if (name === "YEAR") return dt.getUTCFullYear();
+                    return name === "MONTH" ? dt.getUTCMonth() + 1 : dt.getUTCDate();
+                }
+                case "WEEKDAY": {
+                    // return_type 1 (default): Sunday=1 .. Saturday=7;
+                    // 2: Monday=1 .. Sunday=7; 3: Monday=0 .. Sunday=6;
+                    // 11-17: 1 on Monday .. Sunday respectively
+                    var wd = serialDate(args);
+                    if (isErr(wd)) return wd;
+                    var wt = oneNum(args, 1, 1);
+                    if (isErr(wt)) return wt;
+                    var dow = wd.getUTCDay();   // 0 = Sunday
+                    wt = Math.trunc(wt);
+                    if (wt === 1) return dow + 1;
+                    if (wt === 2) return (dow + 6) % 7 + 1;
+                    if (wt === 3) return (dow + 6) % 7;
+                    // 11 starts the week on Monday (getUTCDay 1) .. 17 on Sunday (0)
+                    if (wt >= 11 && wt <= 17) return (dow - (wt - 10) % 7 + 7) % 7 + 1;
+                    return new FErr(ERR.NUM, "WEEKDAY return type " + wt + " is not supported");
+                }
+                case "HOUR": case "MINUTE": case "SECOND": {
+                    var secs = serialSeconds(args);
+                    if (isErr(secs)) return secs;
+                    if (name === "HOUR") return Math.floor(secs / 3600);
+                    return name === "MINUTE" ? Math.floor(secs / 60) % 60 : secs % 60;
+                }
+                case "DATE": {
+                    var y = oneNum(args, 0), mo = oneNum(args, 1), dd = oneNum(args, 2);
+                    if (isErr(y)) return y;
+                    if (isErr(mo)) return mo;
+                    if (isErr(dd)) return dd;
+                    y = Math.trunc(y);
+                    if (y >= 0 && y < 1900) y += 1900;     // Excel: DATE(12,1,1) is 1912
+                    if (y < 0 || y > 9999) return new FErr(ERR.NUM, "DATE year out of range");
+                    var ser = (Date.UTC(y, Math.trunc(mo) - 1, Math.trunc(dd)) - EPOCH) / DAY_MS;
+                    return ser < 0 ? new FErr(ERR.NUM, "DATE before 1900") : ser;
+                }
                 case "TODAY":
                     return Math.floor(dateToSerial(new Date()));
                 case "NOW":
@@ -746,18 +1119,71 @@ var SheetFormula = (function () {
     }
 
     /* ---------- memoized calculator with cycle detection ---------- */
-    function createCalculator(getRaw) {
+    /*
+        createCalculator(getRaw, [opts])
+            getRaw(col, row, sheetIdx) -> the raw text of a cell
+            opts.sheetIndex(name) -> index of the sheet called name, or -1
+            opts.activeSheet()    -> index unqualified lookups default to (0)
+        Without opts there is one anonymous sheet and Sheet!A1 is #REF!.
+
+        Values are memoized per sheet + cell, and whole ranges are cached
+        as arrays, until reset(). Every formula evaluates its unqualified
+        references against its own sheet. ctx always follows the active
+        sheet, so callers can keep a reference to it across tab switches.
+    */
+    function createCalculator(getRaw, opts) {
+        opts = opts || {};
         var memo = {};
+        var ranges = {};
         var inStack = {};
-        var ctx = { cell: cellValue };
+        var astCache = {};
+        var ctxs = {};
+
+        function active() { return opts.activeSheet ? opts.activeSheet() : 0; }
+        function resolve(home, name) {
+            if (name === undefined || name === null) return home;
+            return opts.sheetIndex ? opts.sheetIndex(name) : -1;
+        }
+        function refErr(name) {
+            return new FErr(ERR.REF, "There is no sheet called " + name);
+        }
+        function ctxFor(s) {
+            if (!ctxs[s]) {
+                ctxs[s] = {
+                    cell: function (c, r, name) {
+                        var si = resolve(s, name);
+                        return si < 0 ? refErr(name) : cellValue(c, r, si);
+                    },
+                    range: function (c1, r1, c2, r2, name) {
+                        var si = resolve(s, name);
+                        return si < 0 ? refErr(name) : rangeValues(c1, r1, c2, r2, si);
+                    }
+                };
+            }
+            return ctxs[s];
+        }
 
-        function cellValue(col, row) {
-            var k = col + "," + row;
+        function rangeValues(c1, r1, c2, r2, s) {
+            var k = s + "!" + c1 + "," + r1 + ":" + c2 + "," + r2;
+            if (Object.prototype.hasOwnProperty.call(ranges, k)) return ranges[k];
+            var data = new Array((c2 - c1 + 1) * (r2 - r1 + 1)), i = 0;
+            for (var r = r1; r <= r2; r++) {
+                for (var c = c1; c <= c2; c++) data[i++] = cellValue(c, r, s);
+            }
+            var arr = new Arr(r2 - r1 + 1, c2 - c1 + 1, data);
+            arr.shared = true;      // lives until reset(): binArr may memoize on it
+            ranges[k] = arr;
+            return arr;
+        }
+
+        function cellValue(col, row, s) {
+            if (s === undefined || s === null) s = active();
+            var k = s + "!" + col + "," + row;
             if (Object.prototype.hasOwnProperty.call(memo, k)) return memo[k];
             if (inStack[k]) {
                 return new FErr(ERR.CYCLE, "Circular reference through " + cellName(col, row));
             }
-            var raw = getRaw(col, row);
+            var raw = getRaw(col, row, s);
             var v;
             if (raw === undefined || raw === null || raw === "") {
                 v = null;
@@ -766,7 +1192,14 @@ var SheetFormula = (function () {
                 if (raw.charAt(0) === "=") {
                     inStack[k] = true;
                     try {
-                        v = evaluate(parse(raw.slice(1)), ctx);
+                        // identical formula text (fill-down columns) parses once
+                        var body = raw.slice(1);
+                        var ast = Object.prototype.hasOwnProperty.call(astCache, body) ? astCache[body] : null;
+                        if (!ast) {
+                            ast = parse(body);
+                            astCache[body] = ast;
+                        }
+                        v = evaluate(ast, ctxFor(s));
                     } catch (e) {
                         v = isErr(e) ? e : new FErr(ERR.VALUE, e && e.message ? e.message : "Formula error");
                     }
@@ -781,8 +1214,11 @@ var SheetFormula = (function () {
 
         return {
             value: cellValue,
-            ctx: ctx,
-            reset: function () { memo = {}; inStack = {}; }
+            ctx: {
+                cell: function (c, r, name) { return ctxFor(active()).cell(c, r, name); },
+                range: function (c1, r1, c2, r2, name) { return ctxFor(active()).range(c1, r1, c2, r2, name); }
+            },
+            reset: function () { memo = {}; ranges = {}; inStack = {}; }
         };
     }
 
@@ -799,6 +1235,8 @@ var SheetFormula = (function () {
             if (t.t !== "ref") continue;
             var rep = fn(t);
             if (rep === null || rep === undefined) continue;
+            // the fn rewrites the address; a sheet prefix stays in front of it
+            if (t.prefix && rep !== ERR.REF) rep = t.prefix + rep;
             out += body.slice(last, t.pos) + rep;
             last = t.pos + t.len;
         }
@@ -808,6 +1246,14 @@ var SheetFormula = (function () {
     function refText(absC, col, absR, row) {
         return (absC ? "$" : "") + colToName(col) + (absR ? "$" : "") + (row + 1);
     }
+    /* true when a ref token points at sheet `target` from a formula living
+       on sheet `home`; with no target given every ref qualifies (the
+       single-sheet behaviour) */
+    function refOnSheet(t, target, home) {
+        if (target === undefined || target === null) return true;
+        var s = t.sheet !== undefined ? t.sheet : home;
+        return s !== undefined && s !== null && sameSheetName(s, target);
+    }
     function rewriteRelative(formula, dCol, dRow) {
         return transformRefs(formula, function (t) {
             var c = t.absC ? t.col : t.col + dCol;
@@ -820,9 +1266,11 @@ var SheetFormula = (function () {
     /* Excel "move cells" semantics: every reference (absolute ones too)
        that points INSIDE the moved source range follows it to the new
        location; references outside the range are untouched. rg is
-       {c1,r1,c2,r2} inclusive, 0-based. */
-    function rewriteMovedRange(formula, rg, dCol, dRow) {
+       {c1,r1,c2,r2} inclusive, 0-based. Optional sheet {target, home}:
+       only refs to sheet target count, for a formula on sheet home. */
+    function rewriteMovedRange(formula, rg, dCol, dRow, sheet) {
         return transformRefs(formula, function (t) {
+            if (sheet && !refOnSheet(t, sheet.target, sheet.home)) return null;
             if (t.col < rg.c1 || t.col > rg.c2 || t.row < rg.r1 || t.row > rg.r2) return null;
             var c = t.col + dCol;
             var r = t.row + dRow;
@@ -830,8 +1278,29 @@ var SheetFormula = (function () {
             return refText(t.absC, c, t.absR, r);
         });
     }
-    function adjustInsertDelete(formula, axis, index, count) {
+    /* After renaming a sheet, point Old!A1 / 'Old'!A1 at the new name */
+    function renameSheetRefs(formula, oldName, newName) {
+        var src = String(formula);
+        var hasEq = src.charAt(0) === "=";
+        var body = hasEq ? src.slice(1) : src;
+        var toks;
+        try { toks = tokenize(body); } catch (e) { return src; }
+        var out = "", last = 0;
+        for (var i = 0; i < toks.length; i++) {
+            var t = toks[i];
+            if (t.t !== "ref" || !t.prefix || !sameSheetName(t.sheet, oldName)) continue;
+            out += body.slice(last, t.pos) + quoteSheetName(newName) + "!";
+            last = t.pos + t.prefix.length;
+        }
+        if (!last) return src;
+        out += body.slice(last);
+        return (hasEq ? "=" : "") + out;
+    }
+    /* Optional sheet {target, home} as for rewriteMovedRange: rows/cols are
+       inserted on sheet target, the formula lives on sheet home. */
+    function adjustInsertDelete(formula, axis, index, count, sheet) {
         return transformRefs(formula, function (t) {
+            if (sheet && !refOnSheet(t, sheet.target, sheet.home)) return null;
             var v = axis === "col" ? t.col : t.row;
             var nv;
             if (count > 0) {
@@ -865,6 +1334,8 @@ var SheetFormula = (function () {
         rewriteRelative: rewriteRelative,
         rewriteMovedRange: rewriteMovedRange,
         adjustInsertDelete: adjustInsertDelete,
+        renameSheetRefs: renameSheetRefs,
+        quoteSheetName: quoteSheetName,
         dateToSerial: dateToSerial,
         serialToDate: serialToDate
     };

+ 7 - 3
src/web/Office/sheets/index.html

@@ -6,11 +6,11 @@
     <title>Sheets</title>
     <link rel="icon" type="image/png" href="sheets.png">
     <!-- fetched first: the start-up splash shows it -->
-    <link rel="preload" href="../img/sheets.svg" as="image">
+    <link rel="preload" href="../img/splash/sheets.svg" as="image">
 </head>
 <body data-officeapp="sheets">
     <!-- start-up splash: first, so it covers the page before anything draws -->
-    <script src="../common/splash.js" data-app="Sheets" data-icon="../img/sheets.svg" data-size="1180x720"></script>
+    <script src="../common/splash.js" data-app="Sheets" data-size="1180x720"></script>
     <!-- the app's stylesheets and scripts come after the splash, so it can
          paint while they load -->
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
@@ -43,7 +43,11 @@
             <div id="shColHead"><div id="shColHeadIn"></div></div>
             <div id="shRowHead"><div id="shRowHeadIn"></div></div>
             <div id="shGrid">
-                <div id="shSpacer"></div>
+                <div id="shSpacer">
+                    <div id="shFrozenCols" class="sh-frozen-layer"></div>
+                    <div id="shFrozenRows" class="sh-frozen-layer"></div>
+                    <div id="shFrozenCorner" class="sh-frozen-layer"></div>
+                </div>
                 <div id="shCells"></div>
                 <div id="shRangeBox"></div>
                 <div id="shFillHandle" title="Drag to fill"></div>

+ 49 - 5
src/web/Office/sheets/sheets.css

@@ -106,8 +106,22 @@ body.dark {
     position: relative;
     background: var(--of-paper);
 }
-#shSpacer { position: absolute; left: 0; top: 0; width: 1px; height: 1px; }
+/* #shSpacer is in flow and sized to the whole sheet (rebuildGeometry): it
+   gives the grid its scroll extent and is the box the frozen layers stick
+   within. The frozen panes are position:sticky, so the browser keeps them
+   pinned in the same frame it scrolls - redrawing them at scrollTop from
+   the scroll event lags a frame, and they visibly bounced. */
+#shSpacer { position: relative; width: 1px; height: 1px; }
 #shCells { position: absolute; left: 0; top: 0; }
+.sh-frozen-layer {
+    position: sticky;
+    width: 0;               /* zero-size, so they can travel the full sheet */
+    height: 0;
+    overflow: visible;
+}
+#shFrozenCols { left: 0; z-index: 12; }
+#shFrozenRows { top: 0; z-index: 13; }
+#shFrozenCorner { top: 0; left: 0; z-index: 14; }
 
 /* headers */
 .sh-colh, .sh-rowh {
@@ -124,11 +138,41 @@ body.dark {
     cursor: pointer;
     overflow: hidden;
 }
+/* the tints are translucent: layer them over an opaque base, or frozen
+   headers let the scrolled rows underneath show through */
 .sh-colh.sel, .sh-rowh.sel {
-    background: var(--of-accent-soft);
+    background: linear-gradient(var(--of-accent-soft), var(--of-accent-soft)), var(--sh-headbg);
     color: var(--of-accent);
     font-weight: 600;
 }
+/* where user-hidden rows collapsed: a small up/down chevron pill sitting on
+   the row boundary; clicking it shows the rows again */
+.sh-rowh-hidden {
+    position: absolute;
+    left: 2px;              /* clear of the centred row numbers */
+    width: 13px;
+    height: 12px;
+    margin-top: -6px;
+    box-sizing: border-box;
+    border: 1px solid var(--of-border);
+    border-radius: 3px;
+    background: var(--of-chrome-bg);
+    cursor: pointer;
+    z-index: 7;             /* above the row resize handles (5) */
+}
+.sh-rowh-hidden::before, .sh-rowh-hidden::after {
+    content: "";
+    position: absolute;
+    left: 50%;
+    margin-left: -3px;
+    border-left: 3px solid transparent;
+    border-right: 3px solid transparent;
+}
+.sh-rowh-hidden::before { top: 1px; border-bottom: 3px solid var(--of-fg-soft); }
+.sh-rowh-hidden::after { bottom: 1px; border-top: 3px solid var(--of-fg-soft); }
+.sh-rowh-hidden:hover { border-color: var(--of-accent); }
+.sh-rowh-hidden:hover::before { border-bottom-color: var(--of-accent); }
+.sh-rowh-hidden:hover::after { border-top-color: var(--of-accent); }
 .sh-rz {
     position: absolute;
     z-index: 5;
@@ -177,13 +221,13 @@ body.dark {
 .sh-cell.rgt { justify-content: flex-end; }
 .sh-cell.lft { justify-content: flex-start; }
 .sh-cell.wrap { white-space: pre-wrap; word-break: break-word; align-items: flex-start; }
-.sh-cell.sel { background-color: var(--sh-sel-bg); }
+/* selection tint painted over the cell's own (opaque) fill, so frozen cells
+   stay solid and filled cells still show they are selected */
+.sh-cell.sel { background-image: linear-gradient(var(--sh-sel-bg), var(--sh-sel-bg)); }
 .sh-cell.err { color: var(--of-danger); }
 .sh-cell.b { font-weight: 700; }
 .sh-cell.i { font-style: italic; }
 .sh-cell.u { text-decoration: underline; }
-.sh-cell.frozen { z-index: 12; }
-.sh-cell.frozen-corner { z-index: 14; }
 
 /* active cell + range border + fill handle */
 #shRangeBox {

+ 314 - 47
src/web/Office/sheets/sheets.js

@@ -85,7 +85,7 @@ var SheetsApp = (function () {
     var colX = [0], rowY = [0];   // prefix pixel offsets (geometry cache)
     var mergeAnchor = {};         // "A1" -> {c,r,cs,rs}
     var mergeCover = {};          // covered "B1" -> anchor key
-    var hiddenRows = {};          // row index -> true (from filter)
+    var hiddenRows = {};          // row index -> true (sheet.hiddenRows + filter)
     var clipInternal = null;      // {w,h,src:{c,r},cells:[[{v,s}|null]]}
     var clipTsv = "";             // what we last wrote to system clipboard
     var clipCut = false;
@@ -93,6 +93,7 @@ var SheetsApp = (function () {
     var drag = null;
     var rafPending = false, lastPointerEvt = null;
     var gridEl, cellsEl, spacerEl, colHeadIn, rowHeadIn, inputEl, rangeBoxEl, fillEl;
+    var frozenRowsEl, frozenColsEl, frozenCornerEl;
 
     function esc(t) { return OfficeApp.escapeHtml(t); }
     function deep(o) { return JSON.parse(JSON.stringify(o)); }
@@ -106,7 +107,8 @@ var SheetsApp = (function () {
         return {
             name: name, color: null, cols: 26, rows: 200,
             cells: {}, colW: {}, rowH: {}, merges: [],
-            freeze: { r: 0, c: 0 }, filter: null, charts: [], cfDefs: {}
+            freeze: { r: 0, c: 0 }, filter: null, charts: [], cfDefs: {},
+            hiddenRows: []
         };
     }
     function defaultBody() {
@@ -132,10 +134,23 @@ var SheetsApp = (function () {
             s.filter = s.filter || null;
             s.charts = Array.isArray(s.charts) ? s.charts : [];
             s.cfDefs = (s.cfDefs && typeof s.cfDefs === "object") ? s.cfDefs : {};
+            s.hiddenRows = cleanRowList(s.hiddenRows, s.rows);
         });
         b.active = clamp(parseInt(b.active, 10) || 0, 0, b.sheets.length - 1);
         return b;
     }
+    // sorted, de-duplicated 0-based row indices inside the sheet
+    function cleanRowList(list, rows) {
+        if (!Array.isArray(list)) return [];
+        var seen = {}, out = [];
+        list.forEach(function (v) {
+            var r = parseInt(v, 10);
+            if (isNaN(r) || r < 0 || r >= rows || seen[r]) return;
+            seen[r] = true;
+            out.push(r);
+        });
+        return out.sort(function (a, b) { return a - b; });
+    }
     // a cell stays in the map while it still carries anything at all
     function cellIsBare(cell) {
         return !cell || (!cell.v && !cell.s && !cell.n &&
@@ -243,9 +258,10 @@ var SheetsApp = (function () {
         return mergeAnchor[key(c, r)] || null;
     }
 
-    /* ================= filter (hidden rows) ================= */
+    /* ================= hidden rows (user-hidden + filter) ================= */
     function rebuildFilter() {
         hiddenRows = {};
+        sheet().hiddenRows.forEach(function (r) { hiddenRows[r] = true; });
         var f = sheet().filter;
         if (!f) return;
         var rg = parseRange(f.range);
@@ -302,8 +318,34 @@ var SheetsApp = (function () {
     }
 
     /* ================= calculation & display ================= */
+    /* One calculator for the whole workbook: values are memoized per sheet,
+       so Sheet!A1 references resolve across tabs and switching tabs keeps
+       everything already computed. */
     function rebuildCalc() {
-        calc = F.createCalculator(rawAt);
+        calc = F.createCalculator(function (c, r, s) {
+            var sh = body.sheets[s];
+            var cell = sh && sh.cells[key(c, r)];
+            return cell ? cell.v : "";
+        }, {
+            activeSheet: function () { return body.active; },
+            sheetIndex: sheetIndexByName
+        });
+    }
+    function sheetIndexByName(name) {
+        var want = String(name).toLowerCase();
+        for (var i = 0; i < body.sheets.length; i++) {
+            if (String(body.sheets[i].name).toLowerCase() === want) return i;
+        }
+        return -1;
+    }
+    // run fn(cell, sheetIndex) over every formula cell of every sheet
+    function eachFormulaCell(fn) {
+        body.sheets.forEach(function (sh, si) {
+            Object.keys(sh.cells).forEach(function (k) {
+                var cell = sh.cells[k];
+                if (cell && cell.v && String(cell.v).charAt(0) === "=") fn(cell, si);
+            });
+        });
     }
     function recalc() {
         if (calc) calc.reset();
@@ -466,13 +508,14 @@ var SheetsApp = (function () {
         if (s.bd) css += "border:1px solid var(--of-fg-soft);";
         return css;
     }
-    function renderCellHtml(c, r, sel, pinX, pinY) {
+    /* One cell at its sheet position. Frozen cells use the same coordinates:
+       they go into a sticky layer (#shFrozenRows/Cols/Corner) whose origin
+       is the sheet's, and the browser keeps that layer pinned. */
+    function renderCellHtml(c, r, sel, frozen) {
         var k = key(c, r);
         if (mergeCover[k]) return "";
         var rect = cellRect(c, r);
         if (rect.w === 0 || rect.h === 0) return "";
-        if (pinX !== null) rect = { x: pinX, y: rect.y, w: rect.w, h: rect.h };
-        if (pinY !== null) rect = { x: rect.x, y: pinY, w: rect.w, h: rect.h };
         var s = effStyleAt(c, r);
         var text, fmtd;
         if ((s.fmt || "general") === "text") {
@@ -484,8 +527,7 @@ var SheetsApp = (function () {
         }
         var inSel = sel && c >= sel.c1 && c <= sel.c2 && r >= sel.r1 && r <= sel.r2;
         var cls = cellClasses(c, r, s, fmtd, inSel);
-        if (pinX !== null || pinY !== null) cls += " frozen";
-        if (pinX !== null && pinY !== null) cls += " frozen-corner";
+        if (frozen) cls += " frozen";
         var cellData = sheet().cells[k];
         var noteAttr = "";
         if (cellData && cellData.n) {
@@ -508,36 +550,47 @@ var SheetsApp = (function () {
         var r1 = Math.max(fz.r, idxAt(rowY, st) - 1);
         var r2 = Math.min(s.rows - 1, idxAt(rowY, st + vh) + 1);
 
-        var out = [];
+        var out = [], rowsOut = [], colsOut = [], cornerOut = [];
         var c, r;
         // main quadrant
         for (r = r1; r <= r2; r++) {
             if (hiddenRows[r]) continue;
-            for (c = c1; c <= c2; c++) out.push(renderCellHtml(c, r, sel, null, null));
+            for (c = c1; c <= c2; c++) out.push(renderCellHtml(c, r, sel, false));
         }
-        // frozen rows (pinned vertically)
+        // frozen rows (sticky to the top, scroll sideways with the sheet)
         for (r = 0; r < fz.r; r++) {
             if (hiddenRows[r]) continue;
-            for (c = c1; c <= c2; c++) out.push(renderCellHtml(c, r, sel, null, st + rowY[r]));
+            for (c = c1; c <= c2; c++) rowsOut.push(renderCellHtml(c, r, sel, true));
         }
-        // frozen cols (pinned horizontally)
+        // frozen cols (sticky to the left, scroll vertically with the sheet)
         for (c = 0; c < fz.c; c++) {
             for (r = r1; r <= r2; r++) {
                 if (hiddenRows[r]) continue;
-                out.push(renderCellHtml(c, r, sel, sl + colX[c], null));
+                colsOut.push(renderCellHtml(c, r, sel, true));
             }
         }
-        // frozen corner
+        // frozen corner (sticky both ways)
         for (r = 0; r < fz.r; r++) {
             if (hiddenRows[r]) continue;
-            for (c = 0; c < fz.c; c++) out.push(renderCellHtml(c, r, sel, sl + colX[c], st + rowY[r]));
+            for (c = 0; c < fz.c; c++) cornerOut.push(renderCellHtml(c, r, sel, true));
         }
-        cellsEl.innerHTML = out.join("");
+        setLayerHtml(cellsEl, out);
+        setLayerHtml(frozenRowsEl, rowsOut);
+        setLayerHtml(frozenColsEl, colsOut);
+        setLayerHtml(frozenCornerEl, cornerOut);
 
         renderHeaders(c1, c2, r1, r2, sl, st, sel);
         renderRangeBox(sel);
         if (window.SheetsIO) SheetsIO.renderCharts();
     }
+    // skip the DOM rebuild when a layer's markup did not change (the frozen
+    // corner never does while scrolling)
+    function setLayerHtml(el, parts) {
+        var html = parts.join("");
+        if (el._shHtml === html) return;
+        el._shHtml = html;
+        el.innerHTML = html;
+    }
     function renderHeaders(c1, c2, r1, r2, sl, st, sel) {
         var s = sheet(), fz = s.freeze;
         var out = [], c, r, x, y, w, h;
@@ -576,6 +629,18 @@ var SheetsApp = (function () {
         }
         for (r = r1; r <= r2; r++) { if (!hiddenRows[r]) rowHead(r, false); }
         for (r = 0; r < fz.r; r++) { if (!hiddenRows[r]) rowHead(r, true); }
+        // a marker on the boundary where user-hidden rows collapsed (hidden
+        // rows have no height, so rowY[first] is that boundary); frozen-area
+        // runs are pinned like their rows
+        hiddenRuns().forEach(function (run) {
+            var pinned = run.r1 < fz.r;
+            if (!pinned && (run.r2 < r1 - 1 || run.r1 > r2 + 1)) return;
+            var my = Math.max(6, (pinned ? st : 0) + rowY[run.r1]);
+            var label = run.r1 === run.r2 ? "Row " + (run.r1 + 1) + " is hidden" :
+                "Rows " + (run.r1 + 1) + "-" + (run.r2 + 1) + " are hidden";
+            out.push('<div class="sh-rowh-hidden" data-hr1="' + run.r1 + '" data-hr2="' + run.r2 +
+                '" title="' + label + ' - click to show" style="top:' + my + 'px;"></div>');
+        });
         rowHeadIn.innerHTML = out.join("");
         rowHeadIn.style.transform = "translateY(" + (-st) + "px)";
     }
@@ -623,7 +688,7 @@ var SheetsApp = (function () {
         head = { c: 0, r: 0 };
         selCols = selRows = null;
         selChart = null;
-        rebuildCalc();
+        // no rebuildCalc: the calculator is workbook-wide and follows body.active
         renderAll();
     }
     function addSheet() {
@@ -683,7 +748,14 @@ var SheetsApp = (function () {
             if (!v) return;
             v = v.trim().substring(0, 40);
             if (!v) return;
+            var old = body.sheets[i].name;
+            if (v !== old && sheetIndexByName(v) >= 0 && sheetIndexByName(v) !== i) {
+                OfficeApp.toast("A sheet called " + v + " already exists", "error");
+                return;
+            }
             body.sheets[i].name = v;
+            // formulas that name the sheet follow the rename
+            if (v !== old) eachFormulaCell(function (cell) { cell.v = F.renameSheetRefs(cell.v, old, v); });
             commit();
             renderTabs();
         });
@@ -963,36 +1035,30 @@ var SheetsApp = (function () {
     }
 
     /* ================= cross-sheet reads + sheet creation (pivot) ================= */
-    /* Computed values of a range on ANY sheet. The calculator is bound to
-       the active sheet, so flip it, read, flip back. */
+    /* Computed values of a range on ANY sheet. */
     function readRangeValues(sheetIdx, rgStr) {
         var rg = parseRange(rgStr);
         if (!rg || sheetIdx < 0 || sheetIdx >= body.sheets.length) return null;
-        var saved = body.active;
         var out = [];
-        try {
-            if (sheetIdx !== saved) { body.active = sheetIdx; rebuildCalc(); }
-            for (var r = rg.r1; r <= rg.r2; r++) {
-                var row = [];
-                for (var c = rg.c1; c <= rg.c2; c++) row.push(calc.value(c, r));
-                out.push(row);
-            }
-        } finally {
-            if (body.active !== saved) { body.active = saved; rebuildCalc(); }
+        for (var r = rg.r1; r <= rg.r2; r++) {
+            var row = [];
+            for (var c = rg.c1; c <= rg.c2; c++) row.push(calc.value(c, r, sheetIdx));
+            out.push(row);
         }
         return out;
     }
     /* Print model for the server-side PDF exporter (sheets_io exportPdf):
        per sheet, the used-range grid of formatted display strings plus the
        print-relevant styles and the raw (unzoomed) column widths / row
-       heights in css px. Flips the active sheet like readRangeValues so
-       formulas evaluate against the right sheet. */
+       heights in css px. Flips the active sheet so the per-sheet helpers
+       (styles, merges, conditional formats) read the right one; the
+       calculator is workbook-wide and needs no rebuild. */
     function buildPrintModel() {
         var saved = body.active;
         var out = { sheets: [] };
         try {
             for (var i = 0; i < body.sheets.length; i++) {
-                if (body.active !== i) { body.active = i; rebuildCalc(); }
+                if (body.active !== i) body.active = i;
                 var s = sheet();
                 var ur = usedRange();
                 var colW = [], rowH = [], rows = [];
@@ -1001,6 +1067,7 @@ var SheetsApp = (function () {
                     colW.push(w !== undefined ? w : DEF_COLW);
                 }
                 for (var r = ur.r1; r <= ur.r2; r++) {
+                    if (s.hiddenRows.indexOf(r) >= 0) continue;     // hidden rows do not print
                     var h = s.rowH[String(r)];
                     rowH.push(h !== undefined ? h : DEF_ROWH);
                     var row = [];
@@ -1032,7 +1099,7 @@ var SheetsApp = (function () {
                 out.sheets.push({ name: s.name, colW: colW, rowH: rowH, rows: rows });
             }
         } finally {
-            if (body.active !== saved) { body.active = saved; rebuildCalc(); }
+            body.active = saved;
         }
         return out;
     }
@@ -1321,6 +1388,21 @@ var SheetsApp = (function () {
     }
     function onRowHeadDown(e) {
         var t = e.target;
+        // the marker left where rows are hidden: click to show them again
+        var mark = t.closest ? t.closest(".sh-rowh-hidden") : null;
+        if (mark) {
+            e.preventDefault();
+            if (e.button !== 0) return;
+            unhideRows(parseInt(mark.getAttribute("data-hr1"), 10), parseInt(mark.getAttribute("data-hr2"), 10));
+            e.preventDefault();
+            return;
+        }
+        // right-click inside the selected rows keeps them for the menu
+        if (e.button === 2 && selRows) {
+            var rh = t.closest ? t.closest(".sh-rowh") : null;
+            var rr = rh ? parseInt(rh.getAttribute("data-r"), 10) : -1;
+            if (rr >= selRows.r1 && rr <= selRows.r2) { e.preventDefault(); return; }
+        }
         if (t.hasAttribute && t.hasAttribute("data-rzr")) {
             var r = parseInt(t.getAttribute("data-rzr"), 10);
             drag = { mode: "rzr", r: r, startH: rowHeight(r) / zoomF, startY: e.clientY };
@@ -1484,19 +1566,23 @@ var SheetsApp = (function () {
             }
             cells[key(axis === "col" ? nv : p.col, axis === "row" ? nv : p.row)] = s.cells[k];
         });
-        Object.keys(cells).forEach(function (k) {
-            var cell = cells[k];
-            if (cell.v && String(cell.v).charAt(0) === "=") {
-                cell.v = F.adjustInsertDelete(cell.v, axis, index, count);
-            }
-        });
         s.cells = cells;
+        // formulas on every sheet that point at this one shift with it
+        eachFormulaCell(function (cell, si) {
+            cell.v = F.adjustInsertDelete(cell.v, axis, index, count,
+                { target: s.name, home: body.sheets[si].name });
+        });
         if (axis === "col") {
             s.colW = shiftKeyedMap(s.colW, index, count);
             s.cols = clamp(s.cols + count, 1, MAX_COLS);
         } else {
             s.rowH = shiftKeyedMap(s.rowH, index, count);
             s.rows = clamp(s.rows + count, 1, MAX_ROWS);
+            s.hiddenRows = cleanRowList(s.hiddenRows.map(function (r) {
+                if (count > 0) return r >= index ? r + count : r;
+                if (r >= index && r < index - count) return -1;     // deleted
+                return r >= index - count ? r + count : r;
+            }), s.rows);
         }
         s.merges = s.merges.map(function (m) {
             var rg = parseRange(m);
@@ -1843,12 +1929,10 @@ var SheetsApp = (function () {
                 else delete s.cells[k];
             }
         }
-        // references into the moved range follow it (moved formulas included)
-        Object.keys(s.cells).forEach(function (ck) {
-            var cl = s.cells[ck];
-            if (cl && cl.v && String(cl.v).charAt(0) === "=") {
-                cl.v = F.rewriteMovedRange(cl.v, rg, dC, dR);
-            }
+        // references into the moved range follow it (moved formulas included,
+        // and formulas on other sheets that point here)
+        eachFormulaCell(function (cl, si) {
+            cl.v = F.rewriteMovedRange(cl.v, rg, dC, dR, { target: s.name, home: body.sheets[si].name });
         });
         // merges wholly inside the source range move with it
         s.merges = s.merges.map(function (m) {
@@ -1948,6 +2032,161 @@ var SheetsApp = (function () {
         renderAll();
     }
 
+    /* ================= hide / unhide rows ================= */
+    function userHiddenIn(r1, r2) {
+        return sheet().hiddenRows.filter(function (r) { return r >= r1 && r <= r2; });
+    }
+    // maximal runs [{r1, r2}] of user-hidden rows, for the header markers
+    function hiddenRuns() {
+        var runs = [], list = sheet().hiddenRows;
+        for (var i = 0; i < list.length; i++) {
+            var last = runs[runs.length - 1];
+            if (last && list[i] === last.r2 + 1) last.r2 = list[i];
+            else runs.push({ r1: list[i], r2: list[i] });
+        }
+        return runs;
+    }
+    function hideRows(r1, r2) {
+        commitEdit(false);
+        var s = sheet();
+        r1 = clamp(r1, 0, s.rows - 1);
+        r2 = clamp(r2, r1, s.rows - 1);
+        if (r1 === 0 && r2 === s.rows - 1) {
+            OfficeApp.toast("At least one row has to stay visible", "error");
+            return;
+        }
+        var list = s.hiddenRows.slice();
+        for (var r = r1; r <= r2; r++) list.push(r);
+        s.hiddenRows = cleanRowList(list, s.rows);
+        // the selection now has no height: move to the first visible row
+        // below the block (or above it, at the end of the sheet)
+        var next = r2 + 1;
+        while (next < s.rows && s.hiddenRows.indexOf(next) >= 0) next++;
+        if (next >= s.rows) {
+            next = r1 - 1;
+            while (next > 0 && s.hiddenRows.indexOf(next) >= 0) next--;
+        }
+        commit();       // rebuilds the hidden-row map and geometry
+        setActive(clamp(anchor.c, 0, s.cols - 1), clamp(next, 0, s.rows - 1));
+        OfficeApp.setStatus(r1 === r2 ? "Row " + (r1 + 1) + " hidden" :
+            "Rows " + (r1 + 1) + "-" + (r2 + 1) + " hidden");
+    }
+    // show the user-hidden rows inside r1..r2 (select the rows around a
+    // hidden block to unhide it, as in Excel)
+    function unhideRows(r1, r2) {
+        commitEdit(false);
+        var s = sheet();
+        var gone = userHiddenIn(r1, r2);
+        if (!gone.length) {
+            OfficeApp.setStatus("No hidden rows in the selection");
+            return;
+        }
+        s.hiddenRows = s.hiddenRows.filter(function (r) { return r < r1 || r > r2; });
+        commit();
+        renderAll();
+        OfficeApp.setStatus(gone.length === 1 ? "Row " + (gone[0] + 1) + " shown" : gone.length + " rows shown");
+    }
+    // Ctrl+Shift+9 / menu on a plain cell selection: widen by one row each
+    // side so a block hidden between the selected rows is found
+    function unhideAroundSelection() {
+        var rg = selRange();
+        unhideRows(Math.max(0, rg.r1 - (selRows ? 0 : 1)), rg.r2 + (selRows ? 0 : 1));
+    }
+
+    /* ================= row header context menu ================= */
+    function rowHeaderMenu(x, y) {
+        var rg = selRange();
+        var n = rg.r2 - rg.r1 + 1;
+        var rowsLabel = n === 1 ? "row " + (rg.r1 + 1) : "rows " + (rg.r1 + 1) + "-" + (rg.r2 + 1);
+        var fz = sheet().freeze;
+        var items = [
+            { label: "Cut", icon: "cut", key: "Ctrl+X", action: function () { execClipboard("cut"); } },
+            { label: "Copy", icon: "copy", key: "Ctrl+C", action: function () { execClipboard("copy"); } },
+            { label: "Paste", icon: "paste", key: "Ctrl+V", action: function () { execClipboard("paste"); } },
+            { sep: true },
+            {
+                label: "Insert " + n + " row" + (n > 1 ? "s" : "") + " above", icon: "plus",
+                action: function () { insertDeleteFixed("row", rg.r1, n); }
+            },
+            {
+                label: "Insert " + n + " row" + (n > 1 ? "s" : "") + " below", icon: "plus",
+                action: function () { insertDeleteFixed("row", rg.r2 + 1, n); }
+            },
+            {
+                label: "Delete " + rowsLabel, icon: "minus",
+                action: function () { insertDeleteFixed("row", rg.r1, -n); }
+            },
+            {
+                label: "Clear " + rowsLabel, icon: "eraser", key: "Del",
+                action: function () { clearSelection(false); }
+            },
+            { sep: true },
+            {
+                label: "Hide " + rowsLabel, icon: "eye slash outline", key: "Ctrl+Alt+9",
+                action: function () { hideRows(rg.r1, rg.r2); }
+            },
+            {
+                label: "Unhide rows", icon: "eye", key: "Ctrl+Shift+9",
+                enabled: function () { return userHiddenIn(rg.r1, rg.r2).length > 0; },
+                action: function () { unhideRows(rg.r1, rg.r2); }
+            },
+            {
+                label: "Unhide all rows", icon: "eye",
+                enabled: function () { return sheet().hiddenRows.length > 0; },
+                action: function () { unhideRows(0, sheet().rows - 1); }
+            },
+            { sep: true },
+            {
+                label: "Resize " + rowsLabel + "...", icon: "arrows alternate vertical",
+                action: function () { rowHeightDialog(rg.r1, rg.r2); }
+            },
+            { sep: true },
+            {
+                label: "Freeze up to row " + (rg.r2 + 1), icon: "lock",
+                enabled: function () { return rg.r2 + 1 <= 20; },
+                action: function () { freezeTo(rg.r2 + 1, fz.c); }
+            },
+            {
+                label: "Unfreeze rows", icon: "lock open",
+                enabled: function () { return fz.r > 0; },
+                action: function () { freezeTo(0, fz.c); }
+            }
+        ];
+        OfficeApp.showContextMenu(x, y, items);
+    }
+    function rowHeightDialog(r1, r2) {
+        var cur = sheet().rowH[String(r1)];
+        OfficeApp.prompt("Row height", "Height in pixels (14-400)", String(Math.round(cur !== undefined ? cur : DEF_ROWH)), function (v) {
+            if (v === null || v === undefined) return;
+            var h = parseInt(v, 10);
+            if (isNaN(h)) return;
+            h = clamp(h, 14, 400);
+            for (var r = r1; r <= r2; r++) sheet().rowH[String(r)] = h;
+            commit();
+            renderAll();
+        });
+    }
+    function onRowHeadContextMenu(e) {
+        e.preventDefault();
+        var h = e.target.closest ? e.target.closest(".sh-rowh") : null;
+        if (h) {
+            var r = parseInt(h.getAttribute("data-r"), 10);
+            // right-clicking outside the selected rows selects that row first
+            if (!selRows || r < selRows.r1 || r > selRows.r2) {
+                commitEdit(false);
+                selRows = { r1: r, r2: r };
+                selCols = null;
+                selChart = null;
+                anchor = { c: 0, r: r };
+                head = { c: 0, r: r };
+                afterSelChange();
+            }
+        } else if (!selRows) {
+            return;
+        }
+        rowHeaderMenu(e.clientX, e.clientY);
+    }
+
     /* ================= context menus ================= */
     function cellContextMenu(x, y) {
         var rg = selRange();
@@ -1979,6 +2218,15 @@ var SheetsApp = (function () {
                     insertDeleteFixed("col", rg.c1, -(rg.c2 - rg.c1 + 1));
                 }
             },
+            {
+                label: "Hide row(s)", icon: "eye slash outline", key: "Ctrl+Alt+9",
+                action: function () { hideRows(rg.r1, rg.r2); }
+            },
+            {
+                label: "Unhide rows", icon: "eye", key: "Ctrl+Shift+9",
+                enabled: function () { return userHiddenIn(Math.max(0, rg.r1 - 1), rg.r2 + 1).length > 0; },
+                action: unhideAroundSelection
+            },
             { sep: true },
             {
                 label: "Sort range A → Z", icon: "sort amount down",
@@ -2224,6 +2472,9 @@ var SheetsApp = (function () {
         gridEl = document.getElementById("shGrid");
         cellsEl = document.getElementById("shCells");
         spacerEl = document.getElementById("shSpacer");
+        frozenRowsEl = document.getElementById("shFrozenRows");
+        frozenColsEl = document.getElementById("shFrozenCols");
+        frozenCornerEl = document.getElementById("shFrozenCorner");
         colHeadIn = document.getElementById("shColHeadIn");
         rowHeadIn = document.getElementById("shRowHeadIn");
         inputEl = document.getElementById("shCellInput");
@@ -2248,6 +2499,7 @@ var SheetsApp = (function () {
 
         document.getElementById("shColHead").addEventListener("pointerdown", onColHeadDown);
         document.getElementById("shRowHead").addEventListener("pointerdown", onRowHeadDown);
+        document.getElementById("shRowHead").addEventListener("contextmenu", onRowHeadContextMenu);
         document.getElementById("shCorner").addEventListener("click", function () {
             var ur = usedRange();
             anchor = { c: 0, r: 0 };
@@ -2415,6 +2667,21 @@ var SheetsApp = (function () {
         OfficeApp.registerShortcut("Ctrl+B", function () { toggleStyleFlag("b"); });
         OfficeApp.registerShortcut("Ctrl+I", function () { toggleStyleFlag("i"); });
         OfficeApp.registerShortcut("Ctrl+U", function () { toggleStyleFlag("u"); });
+        // Google Sheets' keys (Excel's Ctrl+9 is taken by the browser's tab switch)
+        var hideOpts = { description: "Hide selected rows", group: "Rows", allowInInput: false, inDialogs: false };
+        OfficeApp.registerShortcut("Ctrl+Alt+9", function () {
+            if (editing) return false;
+            var rg = selRange();
+            hideRows(rg.r1, rg.r2);
+        }, hideOpts);
+        var unhideOpts = { description: "Unhide rows in / around the selection", group: "Rows", allowInInput: false, inDialogs: false };
+        var unhide = function () {
+            if (editing) return false;
+            unhideAroundSelection();
+        };
+        OfficeApp.registerShortcut("Ctrl+Shift+9", unhide, unhideOpts);
+        // with Shift held most layouts report "(" for the 9 key
+        OfficeApp.registerShortcut("Ctrl+Shift+(", unhide, { group: "Rows", allowInInput: false, inDialogs: false });
 
         buildToolbar();
         OfficeApp.addStatusItem("stats", "");

+ 93 - 1
src/web/Office/sheets/test_formula.js

@@ -108,7 +108,9 @@ eq("OR one true", run("OR(1>2,2>1)"), true);
 eq("OR none true", run("OR(1>2,3>4)"), false);
 eq("NOT", run("NOT(1>2)"), true);
 eq("AND over a range of numbers", run("AND(A1:B2)"), true);   // non-zero = true
-eq("AND over text is an error", run("AND(A1:C1)"), "#VALUE!");
+eq("AND skips text in a range (Excel)", run("AND(A1:C1)"), true);
+eq("AND skips text in a ref (Excel)", run("AND(TRUE,C1)"), true);
+eq("AND over typed text is an error", run('AND(TRUE,"hello")'), "#VALUE!");
 eq("AND ignores blank cells", run("AND(TRUE,D9)"), true);
 eq("AND with no logicals", run("AND(D9)"), "#VALUE!");
 eq("IFERROR catches", run('IFERROR(1/0,"oops")'), "oops");
@@ -175,6 +177,96 @@ eq("delete row -> #REF!", F.adjustInsertDelete("=A3", "row", 2, -1), "=#REF!");
 eq("delete row shifts up", F.adjustInsertDelete("=A5", "row", 2, -1), "=A4");
 eq("insert col shifts", F.adjustInsertDelete("=C1", "col", 1, 2), "=E1");
 
+/* blank vs text */
+eq("blank equals empty text", run('D9=""'), true);
+eq("blank is not text", run('D9="x"'), false);
+
+/* math / choose / dates */
+eq("MOD", run("MOD(7,3)"), 1);
+eq("MOD takes divisor sign", run("MOD(-7,3)"), 2);
+eq("MOD fraction of a date-time", run("MOD(41031.75,1)"), 0.75);
+eq("MOD by zero", run("MOD(1,0)"), "#DIV/0!");
+eq("CHOOSE", run('CHOOSE(2,"a","b","c")'), "b");
+eq("CHOOSE out of range", run('CHOOSE(4,"a","b","c")'), "#VALUE!");
+eq("CHOOSE only evaluates its pick", run('CHOOSE(1,"ok",1/0)'), "ok");
+eq("DATE", run("DATE(2012,5,2)"), 41031);
+eq("DATE rolls months over", run("DATE(2012,13,1)"), run("DATE(2013,1,1)"));
+eq("YEAR", run("YEAR(41031.6)"), 2012);
+eq("MONTH", run("MONTH(41031.6)"), 5);
+eq("DAY", run("DAY(41031.6)"), 2);
+eq("WEEKDAY default (Wed=4)", run("WEEKDAY(41031.6)"), 4);
+eq("WEEKDAY type 2 (Wed=3)", run("WEEKDAY(41031,2)"), 3);
+eq("WEEKDAY type 3 (Wed=2)", run("WEEKDAY(41031,3)"), 2);
+eq("WEEKDAY type 12 (Tue=1, Wed=2)", run("WEEKDAY(41031,12)"), 2);
+eq("WEEKDAY type 17 (Sun=1, Wed=4)", run("WEEKDAY(41031,17)"), 4);
+eq("HOUR", run("HOUR(41031.632638888892)"), 15);
+eq("MINUTE", run("MINUTE(41031.632638888892)"), 11);
+eq("SECOND", run("SECOND(0.5+1/86400*7)"), 7);
+
+/* arrays: SUMPRODUCT and array expressions */
+eq("SUMPRODUCT two ranges", run("SUMPRODUCT(A1:A2,B1:B2)"), 10 * 20 + 30 * 40);
+eq("SUMPRODUCT of a condition", run('SUMPRODUCT((F2:F5>"B")*(G2:G5))'), 15 + 9 + 5);
+eq("SUMPRODUCT boolean-only counts nothing", run('SUMPRODUCT(F2:F5="Lemon")'), 0);
+eq("SUMPRODUCT double negation", run('SUMPRODUCT(--(F2:F5="Lemon"))'), 1);
+eq("SUMPRODUCT divided by a scalar", run("SUMPRODUCT((G2:G5>6)*G2:G5/IF(A1>0,2,1))"), (11 + 15 + 9) / 2);
+eq("SUMPRODUCT size mismatch", run("SUMPRODUCT(A1:A2,B1:B3)"), "#VALUE!");
+eq("SUMPRODUCT text counts as 0", run("SUMPRODUCT(F2:F5,G2:G5)"), 0);
+eq("SUMPRODUCT error element", run("SUMPRODUCT((G2:G5)/(G2:G5-9))"), "#DIV/0!");
+eq("SUM over an array expression", run("SUM((G2:G5>9)*H2:H5)"), 1.5 + 2.03);
+eq("IF over an array", run("SUMPRODUCT(IF(G2:G5>9,G2:G5,0))"), 26);
+eq("range outside array context", run("A1:A2*2"), "#VALUE!");
+
+/* cross-sheet references: a two-sheet workbook */
+var book = [
+    { name: "Data", cells: { "0,0": "5", "0,1": "7", "1,0": "x", "1,1": "y", "2,0": "=A1*2" } },
+    { name: "Closed Tickets", cells: { "0,0": "=Data!A1+1", "1,0": "=A2", "0,1": "3" } }
+];
+var bookActive = 0;
+var bcalc = F.createCalculator(function (c, r, s) { return book[s].cells[c + "," + r]; }, {
+    activeSheet: function () { return bookActive; },
+    sheetIndex: function (name) {
+        for (var i = 0; i < book.length; i++) if (book[i].name.toLowerCase() === name.toLowerCase()) return i;
+        return -1;
+    }
+});
+function brun(f, active) {
+    bookActive = active || 0;
+    bcalc.reset();
+    return F.evaluate(F.parse(f), bcalc.ctx);
+}
+eq("sheet ref", brun("Data!A2"), 7);
+eq("quoted sheet ref", brun("'Closed Tickets'!A2"), 3);
+eq("sheet names are case-insensitive", brun("data!A1"), 5);
+eq("formula on another sheet uses its own sheet", brun("'Closed Tickets'!B1"), 3);
+eq("chain across sheets", brun("'Closed Tickets'!A1"), 6);
+eq("unknown sheet", brun("Nope!A1"), "#REF!");
+eq("sheet range in SUM", brun("SUM(Data!A1:A2)"), 12);
+eq("sheet range in SUMPRODUCT", brun('SUMPRODUCT((Data!B1:B2="y")*Data!A1:A2)', 1), 7);
+eq("unqualified ref follows active sheet", brun("A2", 1), 3);
+eq("value() takes a sheet index", bcalc.value(2, 0, 0), 10);
+eq("VLOOKUP on another sheet", brun('VLOOKUP("y",Data!A1:B2,1,FALSE)', 1), "#N/A");
+eq("VLOOKUP key column on another sheet", brun("VLOOKUP(7,Data!A1:B2,2,FALSE)", 1), "y");
+eq("quote in sheet name", F.parse("'Bob''s'!A1").sheet, "Bob's");
+
+/* rewriting keeps sheet prefixes */
+eq("rewrite keeps sheet prefix", F.rewriteRelative("='My Sheet'!A1+B2", 1, 0), "='My Sheet'!B1+C2");
+eq("rewrite absolute sheet ref untouched", F.rewriteRelative("=Data!$A$1", 3, 3), "=Data!$A$1");
+eq("insert row on target sheet only",
+    F.adjustInsertDelete("=A5+Data!A5+'Other'!A5", "row", 2, 1, { target: "Data", home: "Other" }),
+    "=A5+Data!A6+'Other'!A5");
+eq("insert row shifts both ends of a sheet range",
+    F.adjustInsertDelete("=SUM(Data!$C$3:$C$5000)+SUM(C3:C9)", "row", 0, 1, { target: "Data", home: "Other" }),
+    "=SUM(Data!$C$4:$C$5001)+SUM(C3:C9)");
+eq("rename keeps range end unprefixed", F.renameSheetRefs("=SUM(Data!A1:A3)", "Data", "D x"), "=SUM('D x'!A1:A3)");
+eq("insert row: unqualified ref on the target sheet",
+    F.adjustInsertDelete("=A5+Other!A5", "row", 2, 1, { target: "Data", home: "Data" }), "=A6+Other!A5");
+eq("move honours sheet", F.rewriteMovedRange("=A1+Data!A1", mv, 1, 0, { target: "Data", home: "Sheet1" }),
+    "=A1+Data!B1");
+eq("rename sheet refs", F.renameSheetRefs("=Data!A1+'data'!B2+Other!A1", "Data", "Raw data"),
+    "='Raw data'!A1+'Raw data'!B2+Other!A1");
+eq("rename leaves strings alone", F.renameSheetRefs('="Data!A1"&Data!A1', "Data", "D2"), "=\"Data!A1\"&'D2'!A1");
+eq("quoteSheetName plain", F.quoteSheetName("Analysis"), "Analysis");
+
 /* helpers */
 eq("colToName", F.colToName(0), "A");
 eq("colToName AA", F.colToName(26), "AA");

+ 2 - 2
src/web/Office/slides/index.html

@@ -6,11 +6,11 @@
     <title>Slides</title>
     <link rel="icon" type="image/png" href="slides.png">
     <!-- fetched first: the start-up splash shows it -->
-    <link rel="preload" href="../img/slides.svg" as="image">
+    <link rel="preload" href="../img/splash/slides.svg" as="image">
 </head>
 <body data-officeapp="slides">
     <!-- start-up splash: first, so it covers the page before anything draws -->
-    <script src="../common/splash.js" data-app="Slides" data-icon="../img/slides.svg" data-size="1220x740"></script>
+    <script src="../common/splash.js" data-app="Slides" data-size="1220x740"></script>
     <!-- the app's stylesheets and scripts come after the splash, so it can
          paint while they load -->
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">