feat(ui): a menu bar, a toolbar, and cells that look like their formats
The editor was one strip: a formula bar and five ghost links. This is
the shell spec §10.2 ruled — menu bar, slim toolbar, name box, formula
bar, tab strip — built on rastrillo's own disclosure menus rather than a
menubar widget of ours.
- Menus are <details>/<summary>. A disclosure is a control browsers and
screen readers already handle, and it opens with scripting off; the
arrow keys are added on top and nothing about the menu depends on
them. Top-level menus share rastrillo's exclusive group; a submenu
carries no name, because grouped it would close the parent it is
nested inside. TestNoNestedExclusiveDetails pins that structurally.
- Cells paint their formats. Bold, italic, alignment, fill and ink, from
the Format rows import has written since it landed and nothing has
drawn. Attributes on the td and two custom properties; every rule
stays in sheets.css, where the three CSS guards read it. A generated
stylesheet would be invisible to all three.
- Fills follow the theme. A colour picked here is hue and chroma with no
lightness, resolved against --sheet-fill-l; a colour an imported file
authored is painted exactly as the file wrote it, in both themes,
which is what spec §11.1 rules and what the contrast notice on that
page already promises. TestEverySwatchClearsAA runs all 81 ink-on-fill
pairs through the app's own WCAG arithmetic at both lightnesses.
- data-contrast="low" is finally set. The rule that renders the corner
flag has been in sheets.css since import; nothing ever set the
attribute, so the page warned in prose about cells it did not mark.
- Export has a route. xlsx.Export has existed with nothing able to reach
it — PRODUCT.md said so — so File ▸ Download as .xlsx was going to be
the second absent item anybody noticed.
- A viewer gets no editing chrome at all. MayWrite decides what renders;
without it a viewer would get a toolbar whose every control 404s.
No {{icon}} call for bold, italic, align or undo: rastrillo vendors
twelve Lucide slugs and returns an empty string for anything else, with
no error. Those four are letters and hand-written SVG.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182f7QfojuuqdS4Xd2fYFaL
17 files changed,
+2028
−109
internal/guardstest/menugroup_test.go+91 −0internal/sheets/app.go+1 −0internal/sheets/gridstyle.go+15 −0internal/sheets/handlers_export.go+118 −0internal/sheets/handlers_sheets.go+24 −1internal/sheets/palette.go+7 −1internal/sheets/palette_test.go+124 −0internal/sheets/sheetsvc.go+34 −5internal/sheets/static/commands.js+235 −0internal/sheets/static/formulabar.js+140 −0internal/sheets/static/grid.js+127 −70internal/sheets/static/menubar.js+227 −0internal/sheets/static/paint.js+127 −0internal/sheets/static/sheets.css+416 −12internal/sheets/templates/layout.html+11 −0internal/sheets/templates/sheet.html+315 −20internal/sheets/xlsx/colors.go+16 −0
diff --git a/internal/guardstest/menugroup_test.go b/internal/guardstest/menugroup_test.go| new file mode 100644 |
| index 0000000..686730d |
| --- /dev/null |
| +++ b/internal/guardstest/menugroup_test.go |
| @@ -0,0 +1,91 @@ |
| +package guards |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestNoNestedExclusiveDetails is the guard on the one trap the menu |
| +// bar is built over. |
| +// |
| +// `name` on a <details> makes an exclusive accordion: opening one |
| +// member closes every other. That is exactly what a row of top-level |
| +// menus wants. It is exactly wrong for a submenu, which lives INSIDE a |
| +// grouped <details> — grouped, opening the submenu closes the parent it |
| +// is nested in, and the submenu goes down with it. |
| +// |
| +// The failure is silent and looks like a rendering glitch: the menu |
| +// blinks shut when you reach for the thing inside it. layout.html |
| +// already carries a long comment about the same trap on the shell menu, |
| +// which is how we know it is the kind of mistake that gets made twice. |
| +// |
| +// The check is structural rather than a name-by-name allowlist: any |
| +// <details name=…> that CONTAINS another <details name=…> fails, |
| +// whatever they are called. |
| +func TestNoNestedExclusiveDetails(t *testing.T) { |
| + root := repoRoot(t) |
| + files := templateFiles(t, root) |
| + if len(files) == 0 { |
| + t.Fatal("guards: found no templates; this test is not checking anything") |
| + } |
| + |
| + openTag := regexp.MustCompile(`(?i)<details\b[^>]*>`) |
| + closeTag := regexp.MustCompile(`(?i)</details\s*>`) |
| + named := regexp.MustCompile(`(?i)\bname\s*=`) |
| + |
| + for _, rel := range files { |
| + body, err := os.ReadFile(filepath.Join(root, rel)) |
| + if err != nil { |
| + t.Fatalf("guards: read %s: %v", rel, err) |
| + } |
| + checkNesting(t, rel, string(body), openTag, closeTag, named) |
| + } |
| +} |
| + |
| +// checkNesting walks a template's <details> tags keeping a stack of |
| +// whether each open element carried a name. |
| +// |
| +// Tag-level and not a real parser, deliberately: these templates are |
| +// hand-written HTML with no self-closing <details> and no <details> |
| +// inside a comment or an attribute value, and a regex that can be read |
| +// in one sitting beats a dependency for a rule this narrow. If a |
| +// template ever grows a case this cannot see, the fix is to stop |
| +// writing that case. |
| +func checkNesting(t *testing.T, rel, body string, openTag, closeTag, named *regexp.Regexp) { |
| + t.Helper() |
| + |
| + type tag struct { |
| + named bool |
| + line int |
| + } |
| + var stack []tag |
| + |
| + for i, line := range strings.Split(body, "\n") { |
| + // A line may hold both an open and a close; the opens are |
| + // processed first, which is the order they appear in practice |
| + // (a one-line <details>…</details> is not written here). |
| + for _, m := range openTag.FindAllString(line, -1) { |
| + isNamed := named.MatchString(m) |
| + if isNamed { |
| + for _, parent := range stack { |
| + if parent.named { |
| + t.Errorf("%s:%d: a <details name=…> is nested inside the one opened "+ |
| + "at line %d, which also carries name=. Grouped, opening the inner "+ |
| + "one CLOSES its parent and takes itself down with it — the menu "+ |
| + "blinks shut as you reach into it. A submenu carries no name.", |
| + rel, i+1, parent.line) |
| + } |
| + } |
| + } |
| + stack = append(stack, tag{named: isNamed, line: i + 1}) |
| + } |
| + for range closeTag.FindAllString(line, -1) { |
| + if len(stack) > 0 { |
| + stack = stack[:len(stack)-1] |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 9a0f59e..69fffe6 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -194,6 +194,7 @@ func (a *App) Router() http.Handler { |
| // The scriptless view. Spec §6 names the grid editor, drag-fill |
| // and live sync as the only script-required things; everything |
| // else, including editing any cell, works here. |
| + r.Get("/sheets/{sheetID}/export.xlsx", a.handleExport) |
| r.Get("/sheets/{sheetID}/table", a.handleTable) |
| r.Post("/sheets/{sheetID}/table", a.handleTableWrite) |
| // Sharing. Sheet owners only — see sheetForSharing. |
diff --git a/internal/sheets/gridstyle.go b/internal/sheets/gridstyle.go| index 8404119..893514b 100644 |
| --- a/internal/sheets/gridstyle.go |
| +++ b/internal/sheets/gridstyle.go |
| @@ -45,6 +45,19 @@ type wireStyle struct { |
| // Pointers, because 0 is a real hue (red) and a real chroma (grey). |
| FillH *float64 `json:"fh,omitempty"` |
| FillC *float64 `json:"fc,omitempty"` |
| + |
| + // InkH and InkC are ink's intent, FillH/FillC's twin. See |
| + // store.Format.InkIntentH. |
| + InkH *float64 `json:"kh,omitempty"` |
| + InkC *float64 `json:"kc,omitempty"` |
| + |
| + // LC marks an imported fill+ink pair below WCAG AA's 4.5:1. |
| + // |
| + // contrast.go has computed this since import landed, and sheets.css |
| + // has carried the corner flag that renders it since the same day — |
| + // and nothing ever connected the two, so the rule matched no |
| + // element and the page warned in prose about cells it did not mark. |
| + LC bool `json:"lc,omitempty"` |
| } |
| // styleOf converts a stored Format to its wire shape. |
| @@ -56,6 +69,8 @@ func styleOf(f *store.Format) wireStyle { |
| Bold: f.Bold, Italic: f.Italic, Align: f.Align, NumFmt: f.NumberFmt, |
| FillHex: f.FillHex, Ink: f.InkHex, |
| FillH: f.FillIntentH, FillC: f.FillIntentC, |
| + InkH: f.InkIntentH, InkC: f.InkIntentC, |
| + LC: lowContrast(f), |
| } |
| } |
diff --git a/internal/sheets/handlers_export.go b/internal/sheets/handlers_export.go| new file mode 100644 |
| index 0000000..82b5be4 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_export.go |
| @@ -0,0 +1,118 @@ |
| +package sheets |
| + |
| +import ( |
| + "bytes" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "unicode" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| + "amadan.net/oficina/sheets/internal/sheets/xlsx" |
| +) |
| + |
| +// maxFilenameRunes caps the sheet title used in a download's filename. |
| +// |
| +// Long enough that a real title survives whole, short enough that the |
| +// Content-Disposition header stays a header. Some proxies truncate long |
| +// ones, and a truncated filename is a corrupt-looking download. |
| +const maxFilenameRunes = 60 |
| + |
| +// handleExport downloads a sheet as .xlsx. |
| +// |
| +// xlsx.Export has existed since import landed and had no route — |
| +// PRODUCT.md said so in as many words ("Export's HTTP surface and CSV |
| +// are both still to build"). A converter nothing can reach is not a |
| +// feature, and a File menu whose Download item is absent is the second |
| +// thing anybody notices about a spreadsheet. |
| +// |
| +// A VIEWER may download. That is deliberate and worth stating: this app |
| +// does not pretend a read-only grant stops somebody keeping a copy — |
| +// they can already read every cell, and a download restriction would be |
| +// security theatre over a person who can select-all and paste. What |
| +// gates the file is the grant itself, checked the same way every other |
| +// route checks it. |
| +func (a *App) handleExport(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.sheetFromURL(w, r) |
| + if !ok { |
| + return |
| + } |
| + if a.db == nil || a.sealer == nil { |
| + a.fail(w, r, errNoSealer) |
| + return |
| + } |
| + h, err := store.Open(a.db, a.sealer, s.TabID) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + defer h.Release() |
| + |
| + // Built in a buffer before a byte is sent. Streaming straight to the |
| + // ResponseWriter means a failure halfway through has already sent a |
| + // 200 and half a workbook, and Excel opens that as a corrupt file |
| + // with no indication of what went wrong — the same reason render |
| + // buffers a page. |
| + var buf bytes.Buffer |
| + if err := xlsx.Export(h, &buf); err != nil { |
| + // The error is not surfaced: excelize's messages can quote a |
| + // cell reference and a value, and this string is one hop from a |
| + // log line (AGENTS.md). |
| + a.fail(w, r, err) |
| + return |
| + } |
| + |
| + w.Header().Set("Content-Type", |
| + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") |
| + w.Header().Set("Content-Disposition", `attachment; filename="`+downloadName(s)+`"`) |
| + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) |
| + // A sealed document must not sit in a shared cache: the response |
| + // body is every plaintext cell in the sheet. |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(http.StatusOK) |
| + _, _ = w.Write(buf.Bytes()) |
| +} |
| + |
| +// downloadName turns a sheet's title into a filename. |
| +// |
| +// ASCII, quoted, and conservative about what survives, because this |
| +// goes into a header whose quoting rules are older than Unicode and |
| +// because a filename with a quote or a newline in it is a header |
| +// somebody can split. The title is not secret from the person |
| +// downloading it — they are reading the sheet — so the sanitising here |
| +// is about the header, not about disclosure. |
| +// |
| +// A title that sanitises to nothing (all punctuation, or a script this |
| +// keeps none of) falls back to the sheet's id, which is a filename |
| +// somebody can still tell apart from another download. |
| +func downloadName(s SheetSummary) string { |
| + var b strings.Builder |
| + lastSpace := false |
| + for _, r := range s.Title { |
| + switch { |
| + case r > unicode.MaxASCII: |
| + continue |
| + case unicode.IsLetter(r) || unicode.IsDigit(r): |
| + b.WriteRune(r) |
| + lastSpace = false |
| + case r == '-' || r == '_': |
| + b.WriteRune(r) |
| + lastSpace = false |
| + case unicode.IsSpace(r): |
| + // Runs of whitespace collapse to one space rather than |
| + // being dropped: "Q1 Budget" should not become "Q1Budget". |
| + if !lastSpace && b.Len() > 0 { |
| + b.WriteByte(' ') |
| + lastSpace = true |
| + } |
| + } |
| + if b.Len() >= maxFilenameRunes { |
| + break |
| + } |
| + } |
| + name := strings.TrimSpace(b.String()) |
| + if name == "" { |
| + name = "sheet-" + strconv.FormatUint(s.ID, 10) |
| + } |
| + return name + ".xlsx" |
| +} |
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go| index 1a45b16..0f79ea3 100644 |
| --- a/internal/sheets/handlers_sheets.go |
| +++ b/internal/sheets/handlers_sheets.go |
| @@ -34,6 +34,24 @@ type sheetPageView struct { |
| // checks again — this only decides what to render, and a button |
| // that 404s is worse than no button. |
| MayShare bool |
| + // MayWrite decides whether the editing chrome is rendered at all. |
| + // |
| + // Without it a viewer gets a toolbar and a Format menu whose every |
| + // control 404s — the route checks again, correctly, and the person |
| + // is left pressing buttons that silently do nothing. A control you |
| + // are not allowed to use should be absent, not broken. |
| + MayWrite bool |
| + // MayComment decides whether Insert offers the comment link. A |
| + // commenter may not edit a cell but may say something about one, |
| + // which is a different question from MayWrite and has to be asked |
| + // separately. |
| + MayComment bool |
| + // FillSwatches and InkSwatches are the palettes the toolbar's two |
| + // colour menus render. They come from the page view rather than a |
| + // template func so there is exactly one list, defined in Go, that |
| + // both the buttons and the write endpoint's validation read. |
| + FillSwatches []swatch |
| + InkSwatches []swatch |
| // Warned lists cells whose IMPORTED fill and ink fail WCAG AA. |
| // Spec §11.1 ruled that such a pair is rendered as authored and |
| // that the obligation is surfacing, not overriding — so this is a |
| @@ -103,9 +121,14 @@ func (a *App) handleSheetPage(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - view := sheetPageView{Sheet: s, Rows: defaultWindowRows, Cols: defaultWindowCols} |
| + view := sheetPageView{ |
| + Sheet: s, Rows: defaultWindowRows, Cols: defaultWindowCols, |
| + FillSwatches: fillSwatches, InkSwatches: inkSwatches, |
| + } |
| if viewer, ok := a.actor(r); ok { |
| view.MayShare = a.mayShare(s.ID, viewer) |
| + view.MayWrite = a.mayWrite(s.ID, viewer) |
| + view.MayComment = a.mayComment(s.ID, viewer) |
| } |
| // Best effort: a sheet whose handle will not open still renders its |
| // page, and the grid's own fetch reports the failure properly. |
diff --git a/internal/sheets/palette.go b/internal/sheets/palette.go| index 488982a..f891ed2 100644 |
| --- a/internal/sheets/palette.go |
| +++ b/internal/sheets/palette.go |
| @@ -39,7 +39,13 @@ type swatch struct { |
| // that could drift apart on the day somebody adds a hue. |
| const ( |
| fillChroma = 0.085 |
| - inkChroma = 0.135 |
| + // inkChroma is capped at 0.10 rather than pushed as far as it looks |
| + // good, because TestEverySwatchClearsAA runs every offered swatch |
| + // through this app's own WCAG arithmetic at both themes' lightnesses |
| + // and a more saturated ink stops clearing 4.5:1 in light mode. A |
| + // palette that fails the floor the app warns imported files about |
| + // would be this app shipping the defect it complains of. |
| + inkChroma = 0.10 |
| ) |
| // hues is the ordered hue wheel both palettes are built from. |
diff --git a/internal/sheets/palette_test.go b/internal/sheets/palette_test.go| new file mode 100644 |
| index 0000000..cafaca4 |
| --- /dev/null |
| +++ b/internal/sheets/palette_test.go |
| @@ -0,0 +1,124 @@ |
| +package sheets |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/xlsx" |
| +) |
| + |
| +// The lightnesses sheets.css resolves --sheet-fill-l and --sheet-ink-l |
| +// to, restated here. |
| +// |
| +// Restated, not shared, because CSS custom properties are not readable |
| +// from Go and the alternative — generating the stylesheet — would put a |
| +// build step in an app whose frontend rule is that there is none. So |
| +// this is a second place to remember, and TestLightnessesMatchTheStylesheet |
| +// below is what stops the two drifting: it greps sheets.css for the |
| +// declarations and fails if either differs. |
| +const ( |
| + fillLightnessLight = 0.92 |
| + fillLightnessDark = 0.30 |
| + inkLightnessLight = 0.42 |
| + inkLightnessDark = 0.80 |
| +) |
| + |
| +// TestEverySwatchClearsAA is the gate on the palette this app OFFERS. |
| +// |
| +// The app warns about imported fills that pair with ink below 4.5:1 |
| +// (contrast.go, spec §11.1). Shipping a picker whose own combinations |
| +// fail that floor would be this app committing the defect it complains |
| +// of — and it would fail silently, because a low-contrast cell renders |
| +// perfectly, it is just unreadable. |
| +// |
| +// Every ink against every fill, at both themes' lightnesses: 81 pairs |
| +// per theme, because a person may pick any ink on any fill and the |
| +// palette makes no combination harder to reach than another. |
| +// |
| +// **Ink on an UNFILLED cell is deliberately not gated here.** That pair |
| +// is ink against the theme's own background token, and checking it in |
| +// Go would mean copying rastrillo's palette hexes into this repo — a |
| +// coupling nothing watches, which would go stale the first time the |
| +// theme moved and would then be a test asserting the wrong thing while |
| +// passing. It is checked in the browser drive instead, against the |
| +// colour that actually paints. |
| +func TestEverySwatchClearsAA(t *testing.T) { |
| + for _, theme := range []struct { |
| + name string |
| + fillL, inkL float64 |
| + }{ |
| + {"light", fillLightnessLight, inkLightnessLight}, |
| + {"dark", fillLightnessDark, inkLightnessDark}, |
| + } { |
| + t.Run(theme.name, func(t *testing.T) { |
| + for _, fill := range fillSwatches { |
| + for _, ink := range inkSwatches { |
| + fillHex := xlsx.IntentHex(theme.fillL, fill.C, fill.H) |
| + inkHex := xlsx.IntentHex(theme.inkL, ink.C, ink.H) |
| + ratio, ok := contrastRatio(fillHex, inkHex) |
| + if !ok { |
| + t.Fatalf("%s ink on %s fill: could not measure %q on %q", |
| + ink.Name, fill.Name, inkHex, fillHex) |
| + } |
| + if ratio < aaFloor { |
| + t.Errorf("%s ink on %s fill = %.2f:1, below AA's %.1f:1 "+ |
| + "(%s on %s) — lower the chroma or move the lightness apart", |
| + ink.Name, fill.Name, ratio, aaFloor, inkHex, fillHex) |
| + } |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestLightnessesMatchTheStylesheet pins the four numbers above to the |
| +// four in sheets.css. |
| +// |
| +// They are declared in two places because CSS custom properties are not |
| +// readable from Go and the app has no build step to generate one from |
| +// the other — a rule its frontend section states outright. So the |
| +// duplication is deliberate and this is what makes it safe: change |
| +// --sheet-fill-l in the stylesheet without changing the constant here |
| +// and the contrast gate above would go on measuring the old value, |
| +// passing while the shipped palette failed. |
| +func TestLightnessesMatchTheStylesheet(t *testing.T) { |
| + body, err := os.ReadFile(filepath.Join("static", "sheets.css")) |
| + if err != nil { |
| + t.Fatalf("read sheets.css: %v", err) |
| + } |
| + css := string(body) |
| + |
| + for _, want := range []struct { |
| + prop string |
| + value float64 |
| + }{ |
| + {"--sheet-fill-l", fillLightnessLight}, |
| + {"--sheet-ink-l", inkLightnessLight}, |
| + {"--sheet-fill-l", fillLightnessDark}, |
| + {"--sheet-ink-l", inkLightnessDark}, |
| + } { |
| + decl := want.prop + ": " + strconv.FormatFloat(want.value, 'f', 2, 64) + ";" |
| + if !strings.Contains(css, decl) { |
| + t.Errorf("sheets.css does not declare %q — the contrast gate in this file "+ |
| + "measures a lightness the stylesheet no longer uses", decl) |
| + } |
| + } |
| + |
| + // Both dark blocks must exist. The media query alone leaves an |
| + // explicit dark choice unstyled; the [data-theme="dark"] rule alone |
| + // leaves "follow the OS" — the default — unstyled. layout.html |
| + // writes NO attribute for auto, which is the mechanism, so the pair |
| + // is required and neither is redundant. |
| + for _, guard := range []string{ |
| + `:root:not([data-theme="light"])`, |
| + `:root[data-theme="dark"]`, |
| + } { |
| + if !strings.Contains(css, guard) { |
| + t.Errorf("sheets.css has no %s block, so one of the two ways a person "+ |
| + "can be in dark mode paints fills at the light lightness", guard) |
| + } |
| + } |
| +} |
diff --git a/internal/sheets/sheetsvc.go b/internal/sheets/sheetsvc.go| index 4e8212a..cc675df 100644 |
| --- a/internal/sheets/sheetsvc.go |
| +++ b/internal/sheets/sheetsvc.go |
| @@ -52,9 +52,19 @@ var errNoSealer = errors.New("sheets: no sealer configured") |
| // opens. Carrying it here means the grid page never has to query for it, |
| // and multi-tab navigation later adds a field rather than a round trip. |
| type SheetSummary struct { |
| - ID uint64 |
| - Title string |
| - TabID uint64 |
| + ID uint64 |
| + Title string |
| + TabID uint64 |
| + // TabName is the first tab's name, for the tab strip along the |
| + // bottom of the grid. Sealed at rest like every other piece of |
| + // authored text, and unsealed here beside the title so the page has |
| + // one place that opens a sheet's names rather than two. |
| + // |
| + // A tab whose name will not unseal falls back to "Sheet 1" rather |
| + // than failing the page: the strip is chrome, and a grid nobody can |
| + // open because its tab has an unreadable name is a worse outcome |
| + // than a tab labelled generically. |
| + TabName string |
| UpdatedAt time.Time |
| } |
| @@ -129,7 +139,8 @@ func (a *App) newSheet(title string, by Member, filling bool) (SheetSummary, err |
| return SheetSummary{}, err |
| } |
| return SheetSummary{ |
| - ID: sheet.ID, Title: clean, TabID: tab.ID, UpdatedAt: sheet.UpdatedAt, |
| + ID: sheet.ID, Title: clean, TabID: tab.ID, |
| + TabName: firstTabName, UpdatedAt: sheet.UpdatedAt, |
| }, nil |
| } |
| @@ -234,6 +245,24 @@ func (a *App) summarize(s store.Sheet) (SheetSummary, error) { |
| return SheetSummary{}, err |
| } |
| return SheetSummary{ |
| - ID: s.ID, Title: string(title), TabID: tab.ID, UpdatedAt: s.UpdatedAt, |
| + ID: s.ID, Title: string(title), TabID: tab.ID, |
| + TabName: tabName(a, tab), UpdatedAt: s.UpdatedAt, |
| }, nil |
| } |
| + |
| +// tabName unseals a tab's name, falling back to a generic label. |
| +// |
| +// The fallback covers two real cases and not a hypothetical one: a tab |
| +// created before anything wrote a name (NameSealed is empty on the |
| +// first tab of every sheet this app has ever made), and a name that |
| +// will not open. Both should leave a person looking at a working grid. |
| +func tabName(a *App, tab store.Tab) string { |
| + if len(tab.NameSealed) == 0 { |
| + return "Sheet 1" |
| + } |
| + name, err := a.sealer.Open("tab.name", tab.NameSealed) |
| + if err != nil || len(name) == 0 { |
| + return "Sheet 1" |
| + } |
| + return string(name) |
| +} |
diff --git a/internal/sheets/static/commands.js b/internal/sheets/static/commands.js| new file mode 100644 |
| index 0000000..ce60bd0 |
| --- /dev/null |
| +++ b/internal/sheets/static/commands.js |
| @@ -0,0 +1,235 @@ |
| +/* Commands: what every menu item, toolbar button and shortcut does. |
| + * |
| + * One dispatch table, read by three callers — a click on a [data-cmd], |
| + * a keyboard shortcut, and the toolbar's own state reflection. Three |
| + * separate implementations of "make this bold" is how a menu item and |
| + * its shortcut end up disagreeing, and only one of them gets fixed. |
| + * |
| + * Every control that needs this module is rendered hidden and revealed |
| + * below. A button that does nothing because its script did not load is |
| + * worse than no button, and this app has a scriptless path — the table |
| + * view — to send people to instead. |
| + */ |
| +(function () { |
| + 'use strict'; |
| + |
| + var surface = document.querySelector('[data-sheet-surface]'); |
| + if (!surface) return; |
| + |
| + function ready() { |
| + var g = window.SheetsGrid; |
| + if (!g) return; |
| + |
| + /* ---- what a command does --------------------------------------- |
| + * |
| + * A toggle sends its TARGET state, worked out from the anchor cell, |
| + * rather than sending "toggle". Over a mixed selection those differ: |
| + * every spreadsheet makes the whole selection bold when any of it is |
| + * not, and a per-cell flip would invert a checkerboard instead. */ |
| + var anchor = function () { |
| + var s = g.selection(); |
| + return s ? (g.styleAt(s.r, s.c) || {}) : {}; |
| + }; |
| + |
| + var CMDS = { |
| + undo: function () { |
| + /* The same route the scriptless form posts to, so there is one |
| + * undo and not two that can disagree. */ |
| + fetch('/sheets/' + surface.dataset.sheetId + '/undo', { |
| + method: 'POST', |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| + body: '' |
| + }).then(g.refetch).catch(g.refetch); |
| + }, |
| + bold: function () { g.format({ bold: !anchor().b }); }, |
| + italic: function () { g.format({ italic: !anchor().i }); }, |
| + align: function (v) { g.format({ align: anchor().a === v ? '' : v }); }, |
| + numfmt: function (v) { g.format({ numfmt: v }); }, |
| + fill: function (v) { g.format({ fill: v }); }, |
| + ink: function (v) { g.format({ ink: v }); }, |
| + 'clear-format': function () { |
| + g.format({ bold: false, italic: false, align: '', numfmt: '', fill: 'none', ink: 'none' }); |
| + }, |
| + |
| + clear: function () { eachSelected(function (r, c) { g.write(r, c, ''); }); }, |
| + |
| + /* Fill down and fill right are the menu equivalents of the grid's |
| + * drag handle (AGENTS.md: every drag-only affordance has one). |
| + * They copy the anchor's raw INPUT, not its displayed value, so |
| + * filling a formula down fills the formula. */ |
| + 'fill-down': function () { fillFrom(1, 0); }, |
| + 'fill-right': function () { fillFrom(0, 1); }, |
| + |
| + 'select-all': function () { |
| + var ws = g.worksheet(); |
| + if (ws && ws.updateSelectionFromCoords) { |
| + ws.updateSelectionFromCoords(0, 0, g.cols - 1, g.rows - 1); |
| + } |
| + }, |
| + copy: function () { var ws = g.worksheet(); if (ws && ws.copy) ws.copy(); }, |
| + cut: function () { var ws = g.worksheet(); if (ws && ws.copy) ws.copy(true); }, |
| + |
| + /* Insert a function over the run of cells directly above the |
| + * anchor, which is what somebody clicking "SUM" under a column of |
| + * numbers means. An empty run inserts the call with no arguments |
| + * rather than guessing a range that is not there. */ |
| + fn: function (name) { |
| + var s = g.selection(); |
| + if (!s) return; |
| + var top = s.r; |
| + while (top > 0 && g.rawAt(top - 1, s.c) !== '') top--; |
| + var arg = top < s.r ? colName(s.c) + (top + 1) + ':' + colName(s.c) + s.r : ''; |
| + g.write(s.r, s.c, '=' + name + '(' + arg + ')'); |
| + } |
| + }; |
| + |
| + function colName(c) { |
| + var out = ''; |
| + c += 1; |
| + while (c > 0) { out = String.fromCharCode(65 + (c - 1) % 26) + out; c = Math.floor((c - 1) / 26); } |
| + return out; |
| + } |
| + |
| + function eachSelected(fn) { |
| + var s = g.selection(); |
| + if (!s) return; |
| + for (var r = Math.min(s.r, s.r2); r <= Math.max(s.r, s.r2); r++) { |
| + for (var c = Math.min(s.c, s.c2); c <= Math.max(s.c, s.c2); c++) fn(r, c); |
| + } |
| + } |
| + |
| + function fillFrom(dr, dc) { |
| + var s = g.selection(); |
| + if (!s) return; |
| + var from = g.rawAt(s.r, s.c); |
| + /* Over a selection, fill the rest of it from the anchor; over a |
| + * single cell, fill the one next along — which is what the drag |
| + * handle does with a one-cell drag. */ |
| + var lastR = Math.max(s.r, s.r2), lastC = Math.max(s.c, s.c2); |
| + if (lastR === s.r && lastC === s.c) { |
| + var r = Math.min(s.r + dr, g.rows - 1), c = Math.min(s.c + dc, g.cols - 1); |
| + if (r !== s.r || c !== s.c) g.write(r, c, from); |
| + return; |
| + } |
| + eachSelected(function (r, c) { |
| + if (r === s.r && c === s.c) return; |
| + g.write(r, c, from); |
| + }); |
| + } |
| + |
| + function run(name, value) { |
| + var fn = CMDS[name]; |
| + if (fn) fn(value); |
| + } |
| + |
| + /* ---- wiring ----------------------------------------------------- */ |
| + |
| + document.querySelectorAll('[data-needs-js]').forEach(function (el) { |
| + el.hidden = false; |
| + el.removeAttribute('data-needs-js'); |
| + }); |
| + document.querySelectorAll('[data-cmd][hidden]').forEach(function (el) { |
| + el.hidden = false; |
| + }); |
| + |
| + document.addEventListener('click', function (e) { |
| + var el = e.target.closest('[data-cmd]'); |
| + if (!el || el.tagName === 'SUMMARY') return; |
| + e.preventDefault(); |
| + run(el.dataset.cmd, el.dataset.value !== undefined ? el.dataset.value : el.dataset.fn); |
| + var panel = el.closest('[data-tool-menu]'); |
| + if (panel) panel.open = false; |
| + }); |
| + |
| + /* ---- shortcuts --------------------------------------------------- |
| + * |
| + * Never while a cell editor, the formula bar or the name box has |
| + * focus: there Ctrl+B belongs to the text field, and stealing it |
| + * would make the editor feel broken. |
| + * |
| + * Ctrl+R is deliberately absent. It is reload in every browser, and |
| + * fill right is not worth taking it. */ |
| + var KEYS = { b: 'bold', i: 'italic', d: 'fill-down', z: 'undo' }; |
| + |
| + document.addEventListener('keydown', function (e) { |
| + var t = e.target; |
| + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; |
| + |
| + if (e.key === 'Delete' && g.mayWrite) { |
| + if (!g.selection()) return; |
| + e.preventDefault(); |
| + run('clear'); |
| + return; |
| + } |
| + if (!e.ctrlKey && !e.metaKey) return; |
| + if (e.altKey || e.shiftKey) return; |
| + var cmd = KEYS[e.key.toLowerCase()]; |
| + if (!cmd) return; |
| + if (cmd !== 'undo' && !g.mayWrite) return; |
| + e.preventDefault(); |
| + run(cmd); |
| + }); |
| + |
| + /* ---- the toolbar reflects the anchor cell ------------------------- |
| + * |
| + * A toolbar that does not show the selection's state is decoration: |
| + * you cannot tell whether pressing B will bold or un-bold. */ |
| + var toggles = document.querySelectorAll('[data-tool][aria-pressed]'); |
| + var fillChip = document.querySelector('[data-fill-chip]'); |
| + var inkChip = document.querySelector('[data-ink-chip]'); |
| + |
| + g.onSelection(function () { |
| + var st = anchor(); |
| + toggles.forEach(function (btn) { |
| + var on = false; |
| + if (btn.dataset.cmd === 'bold') on = !!st.b; |
| + else if (btn.dataset.cmd === 'italic') on = !!st.i; |
| + else if (btn.dataset.cmd === 'align') on = st.a === btn.dataset.value; |
| + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); |
| + }); |
| + chip(fillChip, st.fh, st.fc, '--sheet-h', '--sheet-c'); |
| + chip(inkChip, st.kh, st.kc, '--sheet-kh', '--sheet-kc'); |
| + }); |
| + |
| + function chip(el, h, c, hProp, cProp) { |
| + if (!el) return; |
| + if (typeof h !== 'number' || typeof c !== 'number') { |
| + el.removeAttribute('data-set'); |
| + return; |
| + } |
| + el.style.setProperty(hProp, String(h)); |
| + el.style.setProperty(cProp, String(c)); |
| + el.setAttribute('data-set', ''); |
| + } |
| + |
| + /* ---- the toolbar is one tab stop --------------------------------- |
| + * |
| + * Fourteen buttons, fourteen tab stops, is a strip a keyboard user |
| + * has to walk through to reach the grid. Roving tabindex with arrow |
| + * keys is the convention for a toolbar, and matches what the menu |
| + * bar beside it does. */ |
| + var strip = document.querySelector('[data-toolbar]'); |
| + if (!strip) return; |
| + var stops = Array.prototype.slice.call(strip.querySelectorAll('[data-tool]')); |
| + if (!stops.length) return; |
| + stops.forEach(function (el, i) { el.tabIndex = i === 0 ? 0 : -1; }); |
| + |
| + strip.addEventListener('keydown', function (e) { |
| + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; |
| + var i = stops.indexOf(e.target.closest('[data-tool]')); |
| + if (i < 0) return; |
| + e.preventDefault(); |
| + /* Jspreadsheet listens for arrows on document and would walk the |
| + * cell cursor as well. */ |
| + e.stopPropagation(); |
| + var next = stops[(i + (e.key === 'ArrowRight' ? 1 : -1) + stops.length) % stops.length]; |
| + stops.forEach(function (el) { el.tabIndex = -1; }); |
| + next.tabIndex = 0; |
| + next.focus(); |
| + }); |
| + } |
| + |
| + if (window.SheetsGrid) ready(); |
| + else document.addEventListener('sheets:ready', ready, { once: true }); |
| +}()); |
diff --git a/internal/sheets/static/formulabar.js b/internal/sheets/static/formulabar.js| new file mode 100644 |
| index 0000000..f7a04b8 |
| --- /dev/null |
| +++ b/internal/sheets/static/formulabar.js |
| @@ -0,0 +1,140 @@ |
| +/* The name box and the formula bar. |
| + * |
| + * The formula bar shows the selected cell's INPUT while the grid shows |
| + * its computed value. That difference is the whole point: it is what |
| + * makes this a spreadsheet rather than a table of numbers, and it is |
| + * the reliable way to read a formula — the in-cell editor swap is best |
| + * effort by design. |
| + * |
| + * Nothing here evaluates or formats anything. Committing posts the raw |
| + * text and paints whatever the server sends back. |
| + */ |
| +(function () { |
| + 'use strict'; |
| + |
| + var box = document.querySelector('[data-name-box]'); |
| + var bar = document.querySelector('[data-formula-input]'); |
| + var size = document.querySelector('[data-selection-size]'); |
| + if (!bar && !box) return; |
| + |
| + /* colName turns 0 into A, 25 into Z, 26 into AA. Written out rather |
| + * than borrowed from the library so the name box agrees with the |
| + * column headers even if the vendored grid is ever swapped. */ |
| + function colName(c) { |
| + var s = ''; |
| + c += 1; |
| + while (c > 0) { |
| + var rem = (c - 1) % 26; |
| + s = String.fromCharCode(65 + rem) + s; |
| + c = Math.floor((c - 1) / 26); |
| + } |
| + return s; |
| + } |
| + |
| + function colIndex(name) { |
| + var n = 0; |
| + for (var i = 0; i < name.length; i++) { |
| + n = n * 26 + (name.charCodeAt(i) - 64); |
| + } |
| + return n - 1; |
| + } |
| + |
| + function refOf(sel) { |
| + var a = colName(sel.c) + (sel.r + 1); |
| + if (sel.r === sel.r2 && sel.c === sel.c2) return a; |
| + return a + ':' + colName(sel.c2) + (sel.r2 + 1); |
| + } |
| + |
| + /* parseRef accepts A1 or A1:C3, case-insensitively. null for anything |
| + * else, which the caller turns into "put back what was there" — a |
| + * name box that silently jumped somewhere on a typo would be worse |
| + * than one that appears to ignore you. */ |
| + function parseRef(text) { |
| + var m = /^\s*([A-Za-z]{1,3})(\d{1,7})(?::([A-Za-z]{1,3})(\d{1,7}))?\s*$/.exec(text); |
| + if (!m) return null; |
| + var out = { |
| + c: colIndex(m[1].toUpperCase()), r: parseInt(m[2], 10) - 1, |
| + c2: colIndex((m[3] || m[1]).toUpperCase()), r2: parseInt(m[4] || m[2], 10) - 1 |
| + }; |
| + var g = window.SheetsGrid; |
| + if (out.r < 0 || out.c < 0) return null; |
| + if (g && (out.r2 >= g.rows || out.c2 >= g.cols)) return null; |
| + return out; |
| + } |
| + |
| + function describe(sel) { |
| + if (!size) return; |
| + var n = (Math.abs(sel.r2 - sel.r) + 1) * (Math.abs(sel.c2 - sel.c) + 1); |
| + /* The COUNT, never a total. A sum here would be the client doing |
| + * arithmetic on strings the server rendered for display, which is |
| + * exactly the business this design keeps out of JavaScript. */ |
| + size.textContent = n > 1 ? n + ' cells' : ''; |
| + } |
| + |
| + function ready() { |
| + var g = window.SheetsGrid; |
| + if (!g) return; |
| + |
| + g.onSelection(function (sel) { |
| + if (!sel) return; |
| + if (box && document.activeElement !== box) box.value = refOf(sel); |
| + if (bar && document.activeElement !== bar) bar.value = g.rawAt(sel.r, sel.c); |
| + describe(sel); |
| + }); |
| + |
| + if (box) { |
| + box.addEventListener('keydown', function (e) { |
| + if (e.key === 'Escape') { |
| + var sel = g.selection(); |
| + if (sel) box.value = refOf(sel); |
| + g.focus(); |
| + return; |
| + } |
| + if (e.key !== 'Enter') return; |
| + e.preventDefault(); |
| + var want = parseRef(box.value); |
| + var ws = g.worksheet(); |
| + if (!want || !ws || !ws.updateSelectionFromCoords) { |
| + var back = g.selection(); |
| + if (back) box.value = refOf(back); |
| + return; |
| + } |
| + ws.updateSelectionFromCoords(want.c, want.r, want.c2, want.r2); |
| + g.focus(); |
| + }); |
| + } |
| + |
| + if (!bar || bar.readOnly) return; |
| + |
| + bar.addEventListener('keydown', function (e) { |
| + var sel = g.selection(); |
| + if (!sel) return; |
| + |
| + if (e.key === 'Escape') { |
| + e.preventDefault(); |
| + bar.value = g.rawAt(sel.r, sel.c); |
| + g.focus(); |
| + return; |
| + } |
| + /* Enter commits and moves down, Tab commits and moves right — |
| + * the two motions every spreadsheet has, and the reason typing a |
| + * column of numbers never needs the mouse. */ |
| + var down = e.key === 'Enter'; |
| + var right = e.key === 'Tab' && !e.shiftKey; |
| + if (!down && !right) return; |
| + e.preventDefault(); |
| + |
| + g.write(sel.r, sel.c, bar.value); |
| + var ws = g.worksheet(); |
| + if (ws && ws.updateSelectionFromCoords) { |
| + var r = Math.min(sel.r + (down ? 1 : 0), g.rows - 1); |
| + var c = Math.min(sel.c + (right ? 1 : 0), g.cols - 1); |
| + ws.updateSelectionFromCoords(c, r, c, r); |
| + } |
| + g.focus(); |
| + }); |
| + } |
| + |
| + if (window.SheetsGrid) ready(); |
| + else document.addEventListener('sheets:ready', ready, { once: true }); |
| +}()); |
diff --git a/internal/sheets/static/grid.js b/internal/sheets/static/grid.js| index 53f3123..609adaf 100644 |
| --- a/internal/sheets/static/grid.js |
| +++ b/internal/sheets/static/grid.js |
| @@ -11,6 +11,13 @@ |
| * That way the grid shows 42 and the formula bar shows =6*7, which is |
| * what a spreadsheet does. The alternative — raw in the data — shows |
| * you =6*7 in every cell and is not a spreadsheet. |
| + * |
| + * The chrome above and below the grid is not wired here. This module |
| + * publishes window.SheetsGrid and menubar.js and commands.js build on |
| + * it, because everything they add — a menu bar, a toolbar, shortcuts, |
| + * format writes — would otherwise put this file past the 300-line cap |
| + * and, worse, put the selection model and the button wiring in one |
| + * place where only the first has any reason to be subtle. |
| */ |
| (function () { |
| 'use strict'; |
| @@ -22,18 +29,22 @@ |
| var tabId = surface.dataset.tabId; |
| var rows = parseInt(surface.dataset.rows, 10) || 100; |
| var cols = parseInt(surface.dataset.cols, 10) || 26; |
| - var base = '/api/sheets/' + sheetId + '/tabs/' + tabId + '/cells'; |
| + var base = '/api/sheets/' + sheetId + '/tabs/' + tabId; |
| + var host = surface.querySelector('[data-sheet-grid]'); |
| /* A per-tab id so this tab can recognise its own edit coming back |
| * over the stream. Without it the tab you typed in repaints the cell |
| * you are still editing. */ |
| var clientId = 'c' + Math.random().toString(36).slice(2) + Date.now().toString(36); |
| - var raw = Object.create(null); /* "r,c" -> the input text */ |
| - var version = 0; /* the newest version painted */ |
| + var raw = Object.create(null); /* "r,c" -> the input text */ |
| + var fmtOf = Object.create(null); /* "r,c" -> a format key, or absent */ |
| + var styles = Object.create(null); /* format key -> the style */ |
| + var version = 0; /* the newest version painted */ |
| var ws = null; |
| - var selection = null; |
| - var applying = false; /* true while we write into the grid ourselves */ |
| + var sel = null; /* {r,c,r2,c2}; r,c is the anchor */ |
| + var applying = false; /* true while we write into the grid ourselves */ |
| + var listeners = []; |
| function key(r, c) { return r + ',' + c; } |
| @@ -47,37 +58,75 @@ |
| return grid; |
| } |
| + /* tdAt reaches the cell element Jspreadsheet rendered. Verified |
| + * against the vendored build: setValueFromCoords rewrites the td's |
| + * innerHTML and leaves the element, its attributes and its style |
| + * alone, so a paint applied here survives every later value update. |
| + * The only code that resets a className is the delete-column path, |
| + * which is disabled below. */ |
| + function tdAt(r, c) { |
| + var rec = ws && ws.records && ws.records[r]; |
| + return (rec && rec[c] && rec[c].element) || null; |
| + } |
| + |
| + function repaint(r, c) { |
| + if (!window.SheetsPaint) return; |
| + window.SheetsPaint.cell(tdAt(r, c), styles[fmtOf[key(r, c)]]); |
| + } |
| + |
| /* applyBatch paints a server response. |
| * |
| * Out-of-order paints are discarded BY VERSION, not by arrival (spec |
| * §7). Without this, optimistic painting plus last-write-wins |
| * produces a visibly wrong grid under two concurrent editors — the |
| - * exact bug the optimism is meant to hide. */ |
| + * exact bug the optimism is meant to hide. |
| + * |
| + * A format write does not advance the version (nothing is appended to |
| + * the event log for it), so the equal case must pass, not be |
| + * discarded — hence `<` and not `<=`. */ |
| function applyBatch(payload) { |
| if (!payload || !payload.cells) return; |
| if (payload.version && payload.version < version) return; |
| if (payload.version) version = payload.version; |
| + /* MERGE, never replace. A batch carries only the formats it |
| + * mentions, so replacing would drop every style outside the edited |
| + * cell the first time somebody typed into an imported sheet. */ |
| + if (payload.formats) { |
| + Object.keys(payload.formats).forEach(function (k) { |
| + styles[k] = payload.formats[k]; |
| + }); |
| + } |
| + |
| applying = true; |
| try { |
| payload.cells.forEach(function (cell) { |
| if (cell.r >= rows || cell.c >= cols) return; |
| - raw[key(cell.r, cell.c)] = cell.raw || ''; |
| + var k = key(cell.r, cell.c); |
| + raw[k] = cell.raw || ''; |
| + if (cell.f) { fmtOf[k] = cell.f; } else { delete fmtOf[k]; } |
| ws.setValueFromCoords(cell.c, cell.r, cell.display, true); |
| + repaint(cell.r, cell.c); |
| }); |
| } finally { |
| applying = false; |
| } |
| if (payload.resync) refetch(); |
| - showFormula(); |
| + announce(); |
| } |
| function refetch() { |
| - fetch(base + '?rows=' + rows + '&cols=' + cols, { credentials: 'same-origin' }) |
| + fetch(base + '/cells?rows=' + rows + '&cols=' + cols, { credentials: 'same-origin' }) |
| .then(function (r) { return r.ok ? r.json() : null; }) |
| .then(function (payload) { |
| if (!payload) return; |
| - version = 0; /* a snapshot supersedes everything */ |
| + /* A snapshot supersedes everything, including formats a cell |
| + * lost while this tab was disconnected — those cells are simply |
| + * absent from it, so nothing below would ever unpaint them. */ |
| + version = 0; |
| + fmtOf = Object.create(null); |
| + styles = Object.create(null); |
| + if (window.SheetsPaint) window.SheetsPaint.sweep(host); |
| applyBatch(payload); |
| }) |
| .catch(function () { /* the stream's own reconnect will try again */ }); |
| @@ -90,7 +139,7 @@ |
| * what turns "=6*7" into "42", and it is what corrects a value the |
| * engine read differently from how it looked. */ |
| function write(r, c, text) { |
| - return fetch(base, { |
| + return fetch(base + '/cells', { |
| method: 'POST', |
| credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| @@ -106,12 +155,26 @@ |
| }); |
| } |
| - var bar = document.querySelector('[data-formula-input]'); |
| + /* format posts a patch for the current selection. The caller passes |
| + * only the fields it means to change — see the Go side's formatPatch |
| + * for why that distinction is the whole design. */ |
| + function format(patch) { |
| + if (!sel) return Promise.resolve(); |
| + var body = { r1: sel.r, c1: sel.c, r2: sel.r2, c2: sel.c2, client: clientId }; |
| + Object.keys(patch).forEach(function (k) { body[k] = patch[k]; }); |
| + return fetch(base + '/format', { |
| + method: 'POST', |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/json' }, |
| + body: JSON.stringify(body) |
| + }).then(function (resp) { |
| + if (!resp.ok) { refetch(); return; } |
| + return resp.json().then(applyBatch); |
| + }).catch(refetch); |
| + } |
| - function showFormula() { |
| - if (!bar || !selection) return; |
| - if (document.activeElement === bar) return; |
| - bar.value = raw[key(selection.r, selection.c)] || ''; |
| + function announce() { |
| + listeners.forEach(function (fn) { fn(sel); }); |
| } |
| function boot(payload) { |
| @@ -119,11 +182,14 @@ |
| (payload.cells || []).forEach(function (cell) { |
| if (cell.r >= rows || cell.c >= cols) return; |
| data[cell.r][cell.c] = cell.display; |
| - raw[key(cell.r, cell.c)] = cell.raw || ''; |
| + var k = key(cell.r, cell.c); |
| + raw[k] = cell.raw || ''; |
| + if (cell.f) fmtOf[k] = cell.f; |
| }); |
| + if (payload.formats) styles = payload.formats; |
| version = payload.version || 0; |
| - var sheet = jspreadsheet(surface.querySelector('[data-sheet-grid]'), { |
| + var sheet = jspreadsheet(host, { |
| /* Our engine computes; the client paints. See the file comment. */ |
| parseFormulas: false, |
| @@ -137,9 +203,12 @@ |
| if (applying) return; |
| write(parseInt(r, 10), parseInt(c, 10), value === null ? '' : String(value)); |
| }, |
| - onselection: function (instance, x1, y1) { |
| - selection = { r: y1, c: x1 }; |
| - showFormula(); |
| + onselection: function (instance, x1, y1, x2, y2) { |
| + sel = { |
| + r: parseInt(y1, 10), c: parseInt(x1, 10), |
| + r2: parseInt(y2, 10), c2: parseInt(x2, 10) |
| + }; |
| + announce(); |
| }, |
| /* The in-cell editor should show the formula, not its result. |
| * The input does not exist yet when this fires, so it is filled |
| @@ -166,61 +235,49 @@ |
| }); |
| ws = sheet.worksheets ? sheet.worksheets[0] : sheet[0]; |
| - wireToolbar(); |
| - if (window.SheetsLive) window.SheetsLive.start(sheetId, tabId, clientId, applyBatch, refetch); |
| - } |
| + /* The library keeps its own client-side undo stack and its document |
| + * keydown is registered before ours. Left on, Ctrl-Z reverts |
| + * locally, then our handler posts the server undo and refetches: |
| + * the net state is right and the grid flashes on the way. Undo is |
| + * the server's (spec §7) — there is one undo, not two that can |
| + * disagree — so the local stack is switched off rather than raced. */ |
| + ws.ignoreHistory = true; |
| - /* Every drag-only affordance has a menu equivalent (AGENTS.md). |
| - * Fill-down and fill-right live on a drag handle in the grid, so they |
| - * are also buttons — reachable by keyboard, and reachable at all on a |
| - * touch screen. */ |
| - function wireToolbar() { |
| - surface.querySelectorAll('[data-action]').forEach(function (btn) { |
| - btn.hidden = false; |
| - btn.addEventListener('click', function () { |
| - if (!selection) return; |
| - var from = raw[key(selection.r, selection.c)] || ''; |
| - if (btn.dataset.action === 'fill-down' && selection.r + 1 < rows) { |
| - write(selection.r + 1, selection.c, from); |
| - } |
| - if (btn.dataset.action === 'fill-right' && selection.c + 1 < cols) { |
| - write(selection.r, selection.c + 1, from); |
| - } |
| + /* Paint the formats the snapshot arrived with. Deferred a frame: |
| + * the records array is populated during construction, and painting |
| + * inside the same tick reaches a table the library has not finished |
| + * laying out. */ |
| + requestAnimationFrame(function () { |
| + Object.keys(fmtOf).forEach(function (k) { |
| + var parts = k.split(','); |
| + repaint(parseInt(parts[0], 10), parseInt(parts[1], 10)); |
| }); |
| }); |
| - /* Ctrl-Z / Cmd-Z. Own-edits-only and cell-level (spec §7), which |
| - * is the server's rule, not this handler's — it posts to the same |
| - * endpoint the scriptless button does and then refetches, so there |
| - * is one undo and not two that can disagree. |
| - * |
| - * Not bound while a cell editor or the formula bar has focus: there |
| - * Ctrl-Z means "undo my typing", and stealing it would make the |
| - * editor feel broken. */ |
| - document.addEventListener('keydown', function (e) { |
| - if (e.key !== 'z' && e.key !== 'Z') return; |
| - if (!e.ctrlKey && !e.metaKey) return; |
| - var t = e.target; |
| - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; |
| - e.preventDefault(); |
| - fetch('/sheets/' + sheetId + '/undo', { |
| - method: 'POST', |
| - credentials: 'same-origin', |
| - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| - body: '' |
| - }).then(refetch).catch(refetch); |
| - }); |
| - |
| - if (!bar) return; |
| - bar.addEventListener('keydown', function (e) { |
| - if (e.key !== 'Enter' || !selection) return; |
| - e.preventDefault(); |
| - write(selection.r, selection.c, bar.value); |
| - bar.blur(); |
| - }); |
| + if (window.SheetsLive) window.SheetsLive.start(sheetId, tabId, clientId, applyBatch, refetch); |
| + document.dispatchEvent(new CustomEvent('sheets:ready')); |
| } |
| - fetch(base + '?rows=' + rows + '&cols=' + cols, { credentials: 'same-origin' }) |
| + /* The API the chrome is built on. Deliberately small: everything the |
| + * menu bar and toolbar need to read the selection, change a cell and |
| + * change a format, and nothing that would let them paint a string the |
| + * server did not render. */ |
| + window.SheetsGrid = { |
| + write: write, |
| + format: format, |
| + refetch: refetch, |
| + rows: rows, |
| + cols: cols, |
| + mayWrite: surface.dataset.mayWrite === '1', |
| + selection: function () { return sel; }, |
| + rawAt: function (r, c) { return raw[key(r, c)] || ''; }, |
| + styleAt: function (r, c) { return styles[fmtOf[key(r, c)]] || null; }, |
| + worksheet: function () { return ws; }, |
| + focus: function () { if (host) host.focus(); }, |
| + onSelection: function (fn) { listeners.push(fn); if (sel) fn(sel); } |
| + }; |
| + |
| + fetch(base + '/cells?rows=' + rows + '&cols=' + cols, { credentials: 'same-origin' }) |
| .then(function (r) { |
| if (!r.ok) throw new Error('window'); |
| return r.json(); |
diff --git a/internal/sheets/static/menubar.js b/internal/sheets/static/menubar.js| new file mode 100644 |
| index 0000000..e0b382d |
| --- /dev/null |
| +++ b/internal/sheets/static/menubar.js |
| @@ -0,0 +1,227 @@ |
| +/* Keyboard navigation for the menu bar. |
| + * |
| + * The menus are <details>/<summary>, not role="menubar". That is a |
| + * decision, not a shortcut: a disclosure is a control browsers and |
| + * screen readers already handle correctly, and it opens with scripting |
| + * switched off. Claiming menubar semantics and then not delivering the |
| + * whole pattern — typeahead, submenu roles, the lot — is worse than an |
| + * honest disclosure. This module ADDS the arrow keys on top; nothing |
| + * here is load-bearing for the menu working at all. |
| + * |
| + * rastrillo.js already closes menus on a click outside and on Escape, |
| + * and returns focus to the summary. None of that is repeated here — a |
| + * second Escape handler would move focus twice. |
| + * |
| + * The one thing this module must not skip: stopping every key it |
| + * consumes. Jspreadsheet binds arrow keys on `document` and acts |
| + * whenever a worksheet is current, which it still is while the menu has |
| + * focus — so without the stop, ArrowDown in an open menu also walks the |
| + * cell cursor down the sheet. |
| + */ |
| +(function () { |
| + 'use strict'; |
| + |
| + var bar = document.querySelector('[data-menubar]'); |
| + if (!bar) return; |
| + |
| + var menus = Array.prototype.slice.call(bar.children).filter(function (el) { |
| + return el.tagName === 'DETAILS'; |
| + }); |
| + if (!menus.length) return; |
| + |
| + function summaryOf(details) { return details.querySelector(':scope > summary'); } |
| + |
| + /* Roving tabindex: the bar is ONE tab stop, not six. Tab should move |
| + * past the menus to the toolbar, not walk through them — arrow keys |
| + * are what move within a group, which is the whole convention this |
| + * makes the bar follow. */ |
| + function rove(to) { |
| + menus.forEach(function (d) { |
| + var s = summaryOf(d); |
| + if (s) s.tabIndex = (d === to ? 0 : -1); |
| + }); |
| + } |
| + rove(menus[0]); |
| + |
| + /* items lists what is reachable inside an open menu, in visual order. |
| + * Hidden items are excluded — several are hidden until commands.js |
| + * wires them — and a closed submenu contributes its summary only, not |
| + * the items inside it. */ |
| + function items(details) { |
| + var out = []; |
| + var panel = details.querySelector(':scope > [rst-dropdown-menu]'); |
| + if (!panel) return out; |
| + walk(panel); |
| + return out; |
| + |
| + function walk(node) { |
| + Array.prototype.forEach.call(node.children, function (el) { |
| + if (el.hidden) return; |
| + if (el.tagName === 'FORM') { walk(el); return; } |
| + if (el.tagName === 'DETAILS') { |
| + var s = summaryOf(el); |
| + if (s) out.push(s); |
| + if (el.open) { |
| + var inner = el.querySelector(':scope > div'); |
| + if (inner) walk(inner); |
| + } |
| + return; |
| + } |
| + if (el.tagName === 'A' || el.tagName === 'BUTTON') out.push(el); |
| + else if (el.children.length) walk(el); |
| + }); |
| + } |
| + } |
| + |
| + function openMenu(details, focusFirst) { |
| + menus.forEach(function (d) { if (d !== details) d.open = false; }); |
| + details.open = true; |
| + rove(details); |
| + var list = focusFirst ? items(details) : []; |
| + if (list.length) list[0].focus(); |
| + else { var s = summaryOf(details); if (s) s.focus(); } |
| + } |
| + |
| + function step(from, delta) { |
| + var i = menus.indexOf(from); |
| + if (i < 0) return null; |
| + return menus[(i + delta + menus.length) % menus.length]; |
| + } |
| + |
| + function ownerMenu(el) { |
| + for (var i = 0; i < menus.length; i++) { |
| + if (menus[i].contains(el)) return menus[i]; |
| + } |
| + return null; |
| + } |
| + |
| + /* A submenu is a <details rst-menu-group>. It deliberately carries no |
| + * name= — grouped, opening it would close the top-level menu it lives |
| + * inside, and take itself down with it. */ |
| + function submenuOf(el) { |
| + var d = el.closest('details[rst-menu-group]'); |
| + return (d && summaryOf(d) === el) ? d : null; |
| + } |
| + |
| + bar.addEventListener('keydown', function (e) { |
| + if (e.altKey || e.ctrlKey || e.metaKey) return; |
| + var target = e.target; |
| + var menu = ownerMenu(target); |
| + if (!menu) return; |
| + |
| + var isTop = summaryOf(menu) === target; |
| + var consumed = true; |
| + |
| + switch (e.key) { |
| + case 'ArrowDown': |
| + if (isTop) openMenu(menu, true); |
| + else move(menu, target, 1); |
| + break; |
| + case 'ArrowUp': |
| + if (isTop) openMenu(menu, true); |
| + else move(menu, target, -1); |
| + break; |
| + case 'ArrowRight': |
| + var sub = submenuOf(target); |
| + if (sub && !sub.open) { sub.open = true; focusInside(sub); break; } |
| + openMenu(step(menu, 1), !isTop || menu.open); |
| + break; |
| + case 'ArrowLeft': |
| + var inner = target.closest('details[rst-menu-group]'); |
| + if (inner && summaryOf(inner) !== target) { |
| + inner.open = false; |
| + summaryOf(inner).focus(); |
| + break; |
| + } |
| + if (inner && inner.open) { inner.open = false; break; } |
| + openMenu(step(menu, -1), !isTop || menu.open); |
| + break; |
| + case 'Home': |
| + if (isTop) { consumed = false; break; } |
| + move(menu, null, 1); |
| + break; |
| + case 'End': |
| + if (isTop) { consumed = false; break; } |
| + move(menu, null, -1); |
| + break; |
| + case 'Enter': |
| + case ' ': |
| + /* Only the top-level summary needs help here: <summary> toggles |
| + * on Enter natively but not always on Space, and an item is an |
| + * <a> or a <button>, which both do the right thing already. */ |
| + if (!isTop) { consumed = false; break; } |
| + e.preventDefault(); |
| + openMenu(menu, true); |
| + break; |
| + default: |
| + consumed = false; |
| + } |
| + |
| + if (consumed) { |
| + e.preventDefault(); |
| + /* Jspreadsheet is listening on document. See the file comment. */ |
| + e.stopPropagation(); |
| + } |
| + }); |
| + |
| + function move(menu, from, delta) { |
| + var list = items(menu); |
| + if (!list.length) return; |
| + if (from === null) { |
| + list[delta > 0 ? 0 : list.length - 1].focus(); |
| + return; |
| + } |
| + var i = list.indexOf(from); |
| + if (i < 0) { list[0].focus(); return; } |
| + list[(i + delta + list.length) % list.length].focus(); |
| + } |
| + |
| + function focusInside(details) { |
| + var first = details.querySelector(':scope > div a, :scope > div button'); |
| + if (first) first.focus(); |
| + } |
| + |
| + /* Tab out closes. focusout rather than blur, because blur does not |
| + * bubble and the focus may be leaving from any depth. The relatedTarget |
| + * check is what distinguishes "moved to another item in this menu" |
| + * from "left the menu entirely". */ |
| + bar.addEventListener('focusout', function (e) { |
| + var menu = ownerMenu(e.target); |
| + if (!menu || !menu.open) return; |
| + var to = e.relatedTarget; |
| + if (to && menu.contains(to)) return; |
| + menu.open = false; |
| + }); |
| + |
| + /* Activating an item closes its menu and puts focus back on the grid. |
| + * |
| + * Two rules, both fixed and different on purpose: Escape returns focus |
| + * to the summary, because you changed your mind and are still in the |
| + * menu; activating returns it to the grid, because the thing you asked |
| + * for happened to a cell and that is where you want to be next. |
| + * |
| + * Links and submenu summaries are excluded — a link is navigating away |
| + * and a summary is opening, not activating. */ |
| + bar.addEventListener('click', function (e) { |
| + var btn = e.target.closest('button[data-cmd]'); |
| + if (!btn) return; |
| + var menu = ownerMenu(btn); |
| + if (menu) menu.open = false; |
| + if (window.SheetsGrid) window.SheetsGrid.focus(); |
| + }); |
| + |
| + /* Pointer hover switches between open menus, the way every menu bar |
| + * does: open File, slide to Edit, Edit opens. Pointer only — on a |
| + * touch screen "hover" is the tap that opened it, and this would |
| + * reopen a menu the tap was closing. */ |
| + menus.forEach(function (d) { |
| + var s = summaryOf(d); |
| + if (!s) return; |
| + s.addEventListener('pointerenter', function (e) { |
| + if (e.pointerType !== 'mouse') return; |
| + if (d.open) return; |
| + if (!menus.some(function (m) { return m.open; })) return; |
| + openMenu(d, false); |
| + }); |
| + }); |
| +}()); |
diff --git a/internal/sheets/static/paint.js b/internal/sheets/static/paint.js| new file mode 100644 |
| index 0000000..b3baa03 |
| --- /dev/null |
| +++ b/internal/sheets/static/paint.js |
| @@ -0,0 +1,127 @@ |
| +/* Cell formatting: attributes on, colours in, nothing else. |
| + * |
| + * Every rule that paints a formatted cell lives in sheets.css, where |
| + * three guard tests read it. This module only sets the attributes those |
| + * rules select on, and two pairs of custom properties they read. It |
| + * deliberately does not build a stylesheet: a stylesheet JavaScript |
| + * generates is invisible to the colour-literal guard, the token guard |
| + * and the vendored-paint restatement guard alike. |
| + * |
| + * A style is CLEARED by painting undefined. The server sends a cell's |
| + * format key only when it has one, so a cell that just lost its |
| + * formatting arrives with no key at all — and if clearing were the |
| + * caller's job to remember, it would be forgotten exactly once and the |
| + * fill would stay on screen until a reload. |
| + */ |
| +(function () { |
| + 'use strict'; |
| + |
| + /* Every attribute this module owns. Listed once, used by both the |
| + * clear path and the sweep grid.js does before a full snapshot, so |
| + * the two cannot disagree about what "painted" means. */ |
| + var ATTRS = ['data-b', 'data-i', 'data-a', 'data-fill', 'data-ink', |
| + 'data-fx', 'data-k', 'data-contrast']; |
| + |
| + var PROPS = ['--sheet-h', '--sheet-c', '--sheet-kh', '--sheet-kc', |
| + '--sheet-fx', '--sheet-k']; |
| + |
| + /* PAINTED is the selector grid.js sweeps with. Built from ATTRS so |
| + * adding an attribute above cannot leave a cell painted after a |
| + * snapshot that no longer mentions it. */ |
| + var PAINTED = ATTRS.map(function (a) { return 'td[' + a + ']'; }).join(','); |
| + |
| + /* hex accepts only what a document could legitimately have put in a |
| + * fill: six or eight hex digits, the eight-digit form being XLSX's |
| + * ARGB with the alpha dropped. Anything else returns '' and the cell |
| + * is painted unstyled. |
| + * |
| + * This is a real check and not a formality. The value arrives from a |
| + * spreadsheet somebody uploaded and is about to be written into a CSS |
| + * custom property; without the pattern, a crafted "fill" could carry |
| + * arbitrary CSS into the page's style attribute. A rejected colour |
| + * costs one unstyled cell. */ |
| + function hex(v) { |
| + if (typeof v !== 'string') return ''; |
| + if (!/^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$/.test(v)) return ''; |
| + return '#' + (v.length === 8 ? v.slice(2) : v); |
| + } |
| + |
| + /* num accepts a finite number only. Hue and chroma go into oklch() |
| + * the same way, and for the same reason. */ |
| + function num(v) { |
| + return (typeof v === 'number' && isFinite(v)) ? String(v) : ''; |
| + } |
| + |
| + function clear(td) { |
| + var i; |
| + for (i = 0; i < ATTRS.length; i++) td.removeAttribute(ATTRS[i]); |
| + for (i = 0; i < PROPS.length; i++) td.style.removeProperty(PROPS[i]); |
| + } |
| + |
| + /* cell paints one td. Clearing first means a format change never has |
| + * to be a diff: whatever the cell looked like, it now looks like s. */ |
| + function cell(td, s) { |
| + if (!td) return; |
| + clear(td); |
| + if (!s) return; |
| + |
| + if (s.b) td.setAttribute('data-b', ''); |
| + if (s.i) td.setAttribute('data-i', ''); |
| + /* centerContinuous is Excel's "centre across the selection", which |
| + * this app does not implement; centring the cell itself is the |
| + * closest honest rendering. fill, justify and distributed are |
| + * dropped rather than approximated. */ |
| + var a = s.a === 'centerContinuous' ? 'center' : s.a; |
| + if (a === 'left' || a === 'center' || a === 'right') { |
| + td.setAttribute('data-a', a); |
| + } |
| + |
| + /* The author's literal wins over our intent when both are present. |
| + * They should never be — writing a fill here clears the hex — but |
| + * a row written by an older build could carry both, and rendering |
| + * the document as authored is the rule that settles the tie. */ |
| + var fx = hex(s.fx); |
| + if (fx) { |
| + td.style.setProperty('--sheet-fx', fx); |
| + td.setAttribute('data-fx', ''); |
| + } else { |
| + var h = num(s.fh), c = num(s.fc); |
| + if (h && c) { |
| + td.style.setProperty('--sheet-h', h); |
| + td.style.setProperty('--sheet-c', c); |
| + td.setAttribute('data-fill', ''); |
| + } |
| + } |
| + |
| + var k = hex(s.k); |
| + if (k) { |
| + td.style.setProperty('--sheet-k', k); |
| + td.setAttribute('data-k', ''); |
| + } else { |
| + var kh = num(s.kh), kc = num(s.kc); |
| + if (kh && kc) { |
| + td.style.setProperty('--sheet-kh', kh); |
| + td.style.setProperty('--sheet-kc', kc); |
| + td.setAttribute('data-ink', ''); |
| + } |
| + } |
| + |
| + /* The corner flag for an imported pair below WCAG AA. The rule for |
| + * it has been in sheets.css since import landed and nothing set the |
| + * attribute, so the page warned in prose about cells it did not |
| + * mark. The server decides — contrast.go owns the arithmetic. */ |
| + if (s.lc) td.setAttribute('data-contrast', 'low'); |
| + } |
| + |
| + /* sweep unpaints everything, for the one case a per-cell repaint |
| + * cannot cover: a full snapshot supersedes the whole window, and a |
| + * cell whose format was removed while this tab was disconnected is |
| + * simply absent from it. */ |
| + function sweep(root) { |
| + if (!root) return; |
| + var painted = root.querySelectorAll(PAINTED); |
| + for (var i = 0; i < painted.length; i++) clear(painted[i]); |
| + } |
| + |
| + window.SheetsPaint = { cell: cell, sweep: sweep }; |
| +}()); |
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css| index c93fd0c..0cff160 100644 |
| --- a/internal/sheets/static/sheets.css |
| +++ b/internal/sheets/static/sheets.css |
| @@ -42,37 +42,357 @@ |
| min-height: 0; |
| } |
| -[data-sheet-bar] { |
| +/* ── The chrome ────────────────────────────────────────────────────── |
| + * |
| + * Three strips around the grid, and a budget: the whole lot is about |
| + * 7rem above the grid and 2rem below, against Google's ~9.5rem, because |
| + * the document title sits in the app's own top bar rather than in a row |
| + * of its own. |
| + * |
| + * Row B — the menu bar and the toolbar, sharing one row. Six short |
| + * words are about 300px, so a row holding only the menus wastes |
| + * two-thirds of itself. They wrap onto separate rows when they no |
| + * longer fit, which is also what keeps this inside a 320px viewport |
| + * with no horizontal scroll. */ |
| +[data-chrome] { |
| display: flex; |
| align-items: center; |
| - gap: var(--rst-sp-2); |
| flex-wrap: wrap; |
| - padding: var(--rst-sp-2) var(--rst-sp-3); |
| + gap: var(--rst-sp-1) var(--rst-sp-3); |
| + padding: 0 var(--rst-sp-3); |
| border-bottom: 1px solid var(--rst-line); |
| background: var(--rst-surface); |
| color: var(--rst-text); |
| } |
| -[data-sheet-title] { |
| +/* The document title, in the top bar beside the brand. It truncates |
| + * rather than wrapping: the bar is a fixed height, and a two-line title |
| + * would push the account menu out of it. */ |
| +[data-doc-title] { |
| + color: var(--rst-text); |
| font-weight: 600; |
| + max-inline-size: 22rem; |
| + overflow: hidden; |
| + text-overflow: ellipsis; |
| + white-space: nowrap; |
| +} |
| + |
| +[data-menubar] { |
| + display: flex; |
| + align-items: center; |
| + flex-wrap: wrap; |
| +} |
| + |
| +[data-menubar] > [rst-dropdown] > summary { |
| + border-radius: var(--rst-radius-sm); |
| + font-size: var(--rst-fs-sm); |
| + padding: var(--rst-sp-1) var(--rst-sp-2); |
| +} |
| + |
| +[data-menubar] > [rst-dropdown][open] > summary { |
| + background: var(--rst-accent-soft); |
| + color: var(--rst-accent); |
| +} |
| + |
| +/* rastrillo's dropdown panel is right-aligned, which is right for the |
| + * account menu it was written for and wrong for a menu bar: a File menu |
| + * hanging off the right edge of the word "File" opens away from the |
| + * item you clicked. */ |
| +[data-menubar] [rst-dropdown-menu] { |
| + inset-inline-start: 0; |
| + inset-inline-end: auto; |
| + min-inline-size: 15rem; |
| +} |
| + |
| +/* An item is a row with its shortcut pushed to the far end, which is |
| + * what makes a menu readable as a table of keys as well as a list of |
| + * actions. The form wrapper around a scriptless POST is display:contents |
| + * so its button lays out as though the form were not there. */ |
| +[data-menubar] [rst-dropdown-menu] > form { |
| + display: contents; |
| +} |
| + |
| +[data-menubar] [rst-dropdown-menu] a, |
| +[data-menubar] [rst-dropdown-menu] button { |
| + align-items: baseline; |
| + display: flex; |
| + gap: var(--rst-sp-4); |
| + justify-content: space-between; |
| +} |
| + |
| +[data-menubar] kbd { |
| + color: var(--rst-text-faint); |
| + font-family: var(--rst-font); |
| + font-size: var(--rst-fs-xs); |
| + white-space: nowrap; |
| +} |
| + |
| +[data-menubar] [data-sample] { |
| + color: var(--rst-text-faint); |
| + font-size: var(--rst-fs-xs); |
| +} |
| + |
| +[data-menubar] hr { |
| + border: 0; |
| + border-top: 1px solid var(--rst-line); |
| + margin: var(--rst-sp-1) 0; |
| +} |
| + |
| +/* The Help panel is the shortcut list itself rather than a link to a |
| + * page that does not exist, so it is a definition list and not a stack |
| + * of menu items. */ |
| +[data-shortcuts] dl { |
| + display: grid; |
| + grid-template-columns: auto 1fr; |
| + gap: var(--rst-sp-1) var(--rst-sp-4); |
| + margin: 0; |
| + padding: var(--rst-sp-2); |
| +} |
| + |
| +[data-shortcuts] dt { |
| + text-align: end; |
| +} |
| + |
| +[data-shortcuts] dd { |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + margin: 0; |
| +} |
| + |
| +/* ── The toolbar ───────────────────────────────────────────────────── |
| + * |
| + * `rst-btn="compact"` is not a rastrillo variant — the word appears in |
| + * tokens.css only inside comments — so a toolbar built on it would be |
| + * silently unstyled. These buttons are ours. |
| + * |
| + * 1.75rem square, clear of the 24px minimum target, and the pressed |
| + * state is a box-shadow ring rather than a border: a border costs |
| + * layout, and eleven of them would widen the strip by 22px against a |
| + * hard 320px reflow budget. A full ring rather than an underline, |
| + * because a one-sided inset shadow follows the border radius round the |
| + * corners and reads as a tab stem. */ |
| +[data-toolbar] { |
| + align-items: center; |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-1); |
| + padding: var(--rst-sp-1) 0; |
| +} |
| + |
| +[data-tool] { |
| + align-items: center; |
| + background: transparent; |
| + border: 0; |
| + border-radius: var(--rst-radius-sm); |
| + color: var(--rst-text-muted); |
| + cursor: pointer; |
| + display: inline-flex; |
| + font: inherit; |
| + font-size: var(--rst-fs-sm); |
| + gap: 0.1rem; |
| + justify-content: center; |
| + min-block-size: 1.75rem; |
| + min-inline-size: 1.75rem; |
| + padding: 0 var(--rst-sp-1); |
| + /* [rst-tip]::after is absolutely positioned and rastrillo does not |
| + * position the host, so the tooltip would anchor to the page. */ |
| + position: relative; |
| +} |
| + |
| +[data-tool]:hover { |
| + background: var(--rst-surface-2); |
| + color: var(--rst-text); |
| +} |
| + |
| +[data-tool][aria-pressed="true"], |
| +[data-tool][aria-current] { |
| + background: var(--rst-accent-soft); |
| + box-shadow: inset 0 0 0 1px currentColor; |
| + color: var(--rst-accent); |
| +} |
| + |
| +[data-tool]:focus-visible { |
| + outline: 2px solid var(--rst-accent); |
| + outline-offset: 2px; |
| +} |
| + |
| +[data-tool] svg { |
| + block-size: 1rem; |
| + fill: none; |
| + inline-size: 1rem; |
| + stroke: currentColor; |
| + stroke-linecap: round; |
| + stroke-width: 1.5; |
| +} |
| + |
| +[data-tool-rule] { |
| + align-self: stretch; |
| + border-inline-start: 1px solid var(--rst-line); |
| + margin: var(--rst-sp-1) var(--rst-sp-1); |
| +} |
| + |
| +[data-tool-menu] > summary { |
| + padding: 0; |
| +} |
| + |
| +[data-tool-menu] > summary::-webkit-details-marker { |
| + display: none; |
| +} |
| + |
| +[data-tool-menu] [rst-dropdown-menu] { |
| + inset-inline-start: 0; |
| + inset-inline-end: auto; |
| +} |
| + |
| +[data-tool] [rst-caret] svg { |
| + block-size: 0.75rem; |
| + inline-size: 0.75rem; |
| +} |
| + |
| +/* The two colour chips on the toolbar show what the anchor cell has, |
| + * so the button says what it will do rather than only what it is. |
| + * --sheet-h and --sheet-c are set by commands.js from the selection. */ |
| +[data-fill-chip], |
| +[data-ink-chip] { |
| + border-radius: 2px; |
| + display: block; |
| + inline-size: 1rem; |
| + line-height: 1; |
| + text-align: center; |
| +} |
| + |
| +[data-fill-chip] { |
| + block-size: 1rem; |
| + border: 1px solid var(--rst-line); |
| + background: var(--rst-surface-2); |
| +} |
| + |
| +[data-fill-chip][data-set] { |
| + background: oklch(var(--sheet-fill-l) var(--sheet-c) var(--sheet-h)); |
| +} |
| + |
| +[data-ink-chip] { |
| + border-bottom: 3px solid var(--rst-text-muted); |
| + font-weight: 700; |
| +} |
| + |
| +[data-ink-chip][data-set] { |
| + border-bottom-color: oklch(var(--sheet-ink-l) var(--sheet-kc) var(--sheet-kh)); |
| +} |
| + |
| +/* A swatch button is its colour with its name beside it. Named, not |
| + * colour alone: a picker that only shows colour is unusable to somebody |
| + * who cannot tell two of them apart, and the name is also what the |
| + * server is sent. */ |
| +[data-swatches] { |
| + min-inline-size: 11rem; |
| +} |
| + |
| +[data-swatch]::before { |
| + border: 1px solid var(--rst-line); |
| + border-radius: 2px; |
| + content: ""; |
| + display: inline-block; |
| + block-size: 0.85rem; |
| + inline-size: 0.85rem; |
| + margin-inline-end: var(--rst-sp-2); |
| + vertical-align: -2px; |
| + background: oklch(var(--sheet-fill-l) var(--sheet-c) var(--sheet-h)); |
| +} |
| + |
| +[data-ink-swatch]::before { |
| + background: oklch(var(--sheet-ink-l) var(--sheet-kc) var(--sheet-kh)); |
| +} |
| + |
| +/* ── Row C: the name box and the formula bar ───────────────────────── |
| + * |
| + * The formula bar gets the whole row and all the slack. It used to |
| + * share a strip with five links and was capped at 40rem so they had |
| + * somewhere to sit; on its own row a cap only truncates long formulas |
| + * for no gain. */ |
| +[data-formula-row] { |
| + align-items: center; |
| + background: var(--rst-surface); |
| + border-bottom: 1px solid var(--rst-line); |
| + display: flex; |
| + gap: var(--rst-sp-2); |
| + padding: var(--rst-sp-1) var(--rst-sp-3); |
| +} |
| + |
| +[data-name-box] { |
| + flex: 0 0 auto; |
| + inline-size: 6rem; |
| + text-align: center; |
| +} |
| + |
| +[data-fx] { |
| + color: var(--rst-text-faint); |
| + flex: 0 0 auto; |
| + font-size: var(--rst-fs-sm); |
| } |
| -/* The formula bar takes the slack, up to a point. Full width pushes |
| - * every control off to the right on a wide screen; a fixed width |
| - * truncates a long formula on a narrow one. */ |
| [data-formula-input] { |
| - flex: 1 1 12rem; |
| - max-inline-size: 40rem; |
| - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| + flex: 1; |
| + min-inline-size: 0; |
| +} |
| + |
| +/* ── Row D: the tab strip ──────────────────────────────────────────── |
| + * |
| + * One tab today, and deliberately no "+": nothing in this build |
| + * creates, names or switches a tab, and spec §14 makes "a second tab is |
| + * reachable in the UI" the trigger for multi-worksheet import. */ |
| +[data-tabstrip] { |
| + align-items: stretch; |
| + background: var(--rst-surface-2); |
| + border-top: 1px solid var(--rst-line); |
| + display: flex; |
| + gap: var(--rst-sp-3); |
| + min-block-size: 2rem; |
| + padding-inline: var(--rst-sp-3); |
| +} |
| + |
| +[data-sheet-tabs] { |
| + display: flex; |
| +} |
| + |
| +[data-sheet-tabs] a { |
| + align-items: center; |
| + border-bottom: 2px solid transparent; |
| + color: var(--rst-text-muted); |
| + display: flex; |
| + font-size: var(--rst-fs-sm); |
| + padding-inline: var(--rst-sp-3); |
| + text-decoration: none; |
| +} |
| + |
| +[data-sheet-tabs] a:hover { |
| + color: var(--rst-text); |
| +} |
| + |
| +[data-sheet-tabs] a[aria-current] { |
| + background: var(--rst-surface); |
| + border-bottom-color: var(--rst-accent); |
| + color: var(--rst-text); |
| + font-weight: 600; |
| +} |
| + |
| +/* The size of the selection, not a sum of it. A total would be the |
| + * client doing arithmetic on strings the server rendered, which is the |
| + * one thing this design keeps out of JavaScript. */ |
| +[data-selection-size] { |
| + align-self: center; |
| + color: var(--rst-text-faint); |
| + font-size: var(--rst-fs-xs); |
| } |
| /* The live indicator. It says connected or reconnecting and nothing |
| * else — presence is a later slice, and a dot that claims more than it |
| * knows is worse than no dot. */ |
| [data-sheet-live] { |
| + align-self: center; |
| margin-inline-start: auto; |
| - font-size: var(--rst-fs-sm); |
| - color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-xs); |
| + color: var(--rst-tone-positive-fg); |
| } |
| [data-sheet-live][data-state="off"] { |
| @@ -450,3 +770,87 @@ |
| transition: none; |
| } |
| } |
| + |
| +/* ── Cell formatting ───────────────────────────────────────────────── |
| + * |
| + * The paint for a cell's stored Format: bold, italic, alignment, fill |
| + * and ink. Every rule is here, in the file the guards read, rather than |
| + * in a stylesheet JavaScript generates — a generated sheet is invisible |
| + * to TestOurStylesheetHasNoColourLiterals, TestEveryTokenWeUseExists |
| + * and TestVendorPaintIsRestated alike, which is three reasons not to |
| + * have one. paint.js only sets attributes and two custom properties. |
| + * |
| + * Specificity: [data-sheet-grid] .jss_worksheet > tbody > tr > td[attr] |
| + * is (0,3,3) — one step above the grid-line rule at (0,2,3), as the |
| + * restatement rule requires, and equal to the selection rules. So these |
| + * are placed AFTER them: a filled cell keeps its fill while selected, |
| + * and the selection outline does the work of showing the selection, |
| + * which is what every spreadsheet does. |
| + * |
| + * A colour a person picks HERE is a hue and a chroma with no lightness |
| + * — see store/models.go — and the lightness comes from the theme below. |
| + * A colour an imported FILE authored is painted exactly as authored, |
| + * in both themes, because spec §11.1 says we render a document as |
| + * written and warn rather than silently correcting it. */ |
| + |
| +/* light-dark() is a colour function and cannot hold a number, so these |
| + * are written the long way. They still resolve before first paint: |
| + * data-theme is server-rendered into the first bytes of the response |
| + * (layout.html), so there is no flash of the wrong lightness. */ |
| +:root { |
| + --sheet-fill-l: 0.92; |
| + --sheet-ink-l: 0.42; |
| +} |
| + |
| +@media (prefers-color-scheme: dark) { |
| + :root:not([data-theme="light"]) { |
| + --sheet-fill-l: 0.30; |
| + --sheet-ink-l: 0.80; |
| + } |
| +} |
| + |
| +:root[data-theme="dark"] { |
| + --sheet-fill-l: 0.30; |
| + --sheet-ink-l: 0.80; |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-b] { |
| + font-weight: 600; |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-i] { |
| + font-style: italic; |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-a="left"] { |
| + text-align: start; |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-a="center"] { |
| + text-align: center; |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-a="right"] { |
| + text-align: end; |
| +} |
| + |
| +/* Ours: hue and chroma from the cell, lightness from the theme. */ |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-fill] { |
| + background-color: oklch(var(--sheet-fill-l) var(--sheet-c) var(--sheet-h)); |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-ink] { |
| + color: oklch(var(--sheet-ink-l) var(--sheet-kc) var(--sheet-kh)); |
| +} |
| + |
| +/* The author's: painted as the file wrote it. --sheet-fx and --sheet-k |
| + * carry a hex that came from a document, which is why a literal is |
| + * acceptable here and nowhere else in this file — it is data, set by |
| + * script from the document, not a colour this stylesheet chose. */ |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-fx] { |
| + background-color: var(--sheet-fx); |
| +} |
| + |
| +[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-k] { |
| + color: var(--sheet-k); |
| +} |
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html| index 7d2e7c5..6920a26 100644 |
| --- a/internal/sheets/templates/layout.html |
| +++ b/internal/sheets/templates/layout.html |
| @@ -35,6 +35,17 @@ |
| <a rst-skip href="#main">Skip to content</a> |
| <header rst-shell-bar> |
| <a rst-shell-brand href="/">Sheets</a> |
| +{{/* The open document's title, in the top bar beside the brand — |
| + spec §10.2's ruling ("Top bar is title, Share, avatar"), and it |
| + is what buys the grid page a row: Google spends a whole row on |
| + the title, and this app already has a bar to put it in. |
| + |
| + Static text, not an input. There is no rename route, so anything |
| + that looked editable would be a promise the app cannot keep. |
| + Rendered only on a page that supplies one, which is why it is a |
| + block rather than a field on PageData: no other screen has a |
| + document open. */}} |
| +{{block "doctitle" .}}{{end}} |
| {{if .SignedIn}} |
| {{/* The narrow-viewport disclosure, and without it there is no way to |
| sign out on a phone. |
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html| index 4825d5a..375910c 100644 |
| --- a/internal/sheets/templates/sheet.html |
| +++ b/internal/sheets/templates/sheet.html |
| @@ -2,13 +2,23 @@ |
| <link rel="stylesheet" href="{{asset "static/vendor/jsuites.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/vendor/jspreadsheet.css"}}"> |
| {{/* Vendored, in dependency order, and deferred so neither blocks the |
| - first paint. live.js registers window.SheetsLive; grid.js calls it |
| - once the window has loaded — and defer preserves document order, |
| - so live.js is always defined by then. */}} |
| + first paint. defer preserves document order, so each module below |
| + is defined before the one that calls it: live.js registers |
| + window.SheetsLive and paint.js window.SheetsPaint; grid.js calls |
| + both once the window has loaded; menubar.js and commands.js wire |
| + the chrome and read window.SheetsGrid, which grid.js publishes. */}} |
| <script defer src="{{asset "static/vendor/jsuites.js"}}"></script> |
| <script defer src="{{asset "static/vendor/jspreadsheet.js"}}"></script> |
| <script defer src="{{asset "static/live.js"}}"></script> |
| +<script defer src="{{asset "static/paint.js"}}"></script> |
| <script defer src="{{asset "static/grid.js"}}"></script> |
| +<script defer src="{{asset "static/menubar.js"}}"></script> |
| +<script defer src="{{asset "static/formulabar.js"}}"></script> |
| +<script defer src="{{asset "static/commands.js"}}"></script> |
| +{{end}} |
| + |
| +{{define "doctitle"}} |
| +<span data-doc-title title="{{.Data.Sheet.Title}}">{{.Data.Sheet.Title}}</span> |
| {{end}} |
| {{define "main"}} |
| @@ -19,31 +29,298 @@ |
| data-sheet-id="{{.Data.Sheet.ID}}" |
| data-tab-id="{{.Data.Sheet.TabID}}" |
| data-rows="{{.Data.Rows}}" |
| - data-cols="{{.Data.Cols}}"> |
| + data-cols="{{.Data.Cols}}" |
| + {{if .Data.MayWrite}}data-may-write="1"{{end}}> |
| {{template "content" .}} |
| </main> |
| {{end}} |
| +{{/* --- Glyphs ------------------------------------------------------- |
| + |
| + Written here rather than fetched with {{icon}}. rastrillo vendors |
| + twelve Lucide slugs — alert-triangle, check, check-circle, |
| + chevron-down, help-circle, info, kebab, menu, plus, search, x, |
| + x-circle — and rastrillo.Icon returns an EMPTY STRING for a slug it |
| + does not have, with no error. So {{icon "bold"}} is not a missing |
| + icon, it is a button with no label and no complaint, which is the |
| + same silent-failure shape as an invented CSS token. |
| + |
| + Bold and italic are letters, because that is what they are on every |
| + spreadsheet anybody has used. The three alignments are rules, |
| + because "left" as a word is longer than the button. */}} |
| + |
| +{{define "glyph-align-left"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 3.5h12M2 6.5h7M2 9.5h12M2 12.5h7"/></svg>{{end}} |
| +{{define "glyph-align-center"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 3.5h12M4.5 6.5h7M2 9.5h12M4.5 12.5h7"/></svg>{{end}} |
| +{{define "glyph-align-right"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 3.5h12M7 6.5h7M2 9.5h12M7 12.5h7"/></svg>{{end}} |
| +{{define "glyph-undo"}}<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 8h7a3 3 0 0 1 0 6H6M3 8l3-3M3 8l3 3"/></svg>{{end}} |
| + |
| {{define "content"}} |
| -<div data-sheet-bar> |
| -<span data-sheet-title>{{.Data.Sheet.Title}}</span> |
| -{{/* The formula bar. It shows the selected cell's INPUT while the grid |
| + |
| +{{/* Row B — the menu bar and the toolbar share one row. |
| + Six short words are about 300px; a row holding only them wastes |
| + two-thirds of itself. They wrap onto separate rows below ~900px, |
| + which is also what keeps the chrome inside the 320px reflow |
| + budget. */}} |
| +<div data-chrome> |
| + |
| +<nav data-menubar aria-label="Sheet menu"> |
| + |
| +{{/* Every top-level menu shares rastrillo's "rst-menus" group, which |
| + makes them an exclusive accordion: opening File closes Edit, and |
| + closes the account menu in the topbar too, which is what a person |
| + expects. rastrillo.js already handles click-outside and Escape. |
| + |
| + A SUBMENU MUST NEVER CARRY name=. It is nested inside a grouped |
| + <details>; grouped, opening it closes the parent it lives in and |
| + takes itself down with it. TestNoNestedExclusiveDetails pins |
| + this. */}} |
| + |
| +<details rst-dropdown name="rst-menus"> |
| +<summary>File</summary> |
| +<div rst-dropdown-menu> |
| +<form method="post" action="/sheets"><button type="submit">New sheet</button></form> |
| +<a href="/sheets">Import a spreadsheet…</a> |
| +<a href="/sheets/{{.Data.Sheet.ID}}/export.xlsx">Download as .xlsx</a> |
| +<hr> |
| +<a href="/sheets/{{.Data.Sheet.ID}}/table">Open as table</a> |
| +<a href="/sheets/{{.Data.Sheet.ID}}/history">Version history</a> |
| +{{if .Data.MayShare}}<a href="/sheets/{{.Data.Sheet.ID}}/share">Share…</a>{{end}} |
| +</div> |
| +</details> |
| + |
| +{{if .Data.MayWrite}} |
| +<details rst-dropdown name="rst-menus"> |
| +<summary>Edit</summary> |
| +<div rst-dropdown-menu> |
| +{{/* Undo is a form, not a button, so it works with scripting off — |
| + it posts to the same route the keyboard shortcut fetches, so |
| + there is one undo and not two that can disagree. */}} |
| +<form method="post" action="/sheets/{{.Data.Sheet.ID}}/undo"> |
| +<button type="submit">Undo my last edit <kbd>Ctrl Z</kbd></button> |
| +</form> |
| +<hr> |
| +<button type="button" data-cmd="cut" hidden>Cut <kbd>Ctrl X</kbd></button> |
| +<button type="button" data-cmd="copy" hidden>Copy <kbd>Ctrl C</kbd></button> |
| +<button type="button" data-cmd="clear" hidden>Clear contents <kbd>Delete</kbd></button> |
| +<hr> |
| +{{/* Fill down and fill right are the menu equivalents of the grid's |
| + drag handle — AGENTS.md requires one for every drag-only |
| + affordance, and a drag handle is unreachable by keyboard and |
| + awkward on a touch screen. |
| + |
| + Ctrl+D only. Ctrl+R is reload in every browser, so fill right is |
| + deliberately shortcut-free rather than fighting the browser for |
| + a key people rely on. */}} |
| +<button type="button" data-cmd="fill-down" hidden>Fill down <kbd>Ctrl D</kbd></button> |
| +<button type="button" data-cmd="fill-right" hidden>Fill right</button> |
| +<button type="button" data-cmd="select-all" hidden>Select all <kbd>Ctrl A</kbd></button> |
| +</div> |
| +</details> |
| +{{end}} |
| + |
| +<details rst-dropdown name="rst-menus"> |
| +<summary>Insert</summary> |
| +<div rst-dropdown-menu> |
| +{{if .Data.MayWrite}} |
| +{{/* No name= — see the note above the File menu. */}} |
| +<details rst-menu-group hidden data-needs-js> |
| +<summary>Function</summary> |
| +<div> |
| +{{/* Exactly the aggregates the engine registers and that a |
| + one-click insert can guess the arguments for. VLOOKUP, INDEX |
| + and MATCH are registered too and are deliberately absent: |
| + nothing here can guess their second and third arguments, and |
| + inserting =VLOOKUP() for somebody to fill in is a worse |
| + starting point than an empty cell. */}} |
| +<button type="button" data-cmd="fn" data-fn="SUM">SUM</button> |
| +<button type="button" data-cmd="fn" data-fn="AVERAGE">AVERAGE</button> |
| +<button type="button" data-cmd="fn" data-fn="COUNT">COUNT</button> |
| +<button type="button" data-cmd="fn" data-fn="MIN">MIN</button> |
| +<button type="button" data-cmd="fn" data-fn="MAX">MAX</button> |
| +</div> |
| +</details> |
| +{{end}} |
| +{{if .Data.MayComment}}<a href="/sheets/{{.Data.Sheet.ID}}/comments">Comment…</a>{{end}} |
| +</div> |
| +</details> |
| + |
| +{{if .Data.MayWrite}} |
| +{{/* The Format menu is entirely script-driven, so the whole menu is |
| + hidden until commands.js reveals it. A menu of items that do |
| + nothing is worse than no menu. */}} |
| +<details rst-dropdown name="rst-menus" hidden data-needs-js> |
| +<summary>Format</summary> |
| +<div rst-dropdown-menu> |
| +<button type="button" data-cmd="bold">Bold <kbd>Ctrl B</kbd></button> |
| +<button type="button" data-cmd="italic">Italic <kbd>Ctrl I</kbd></button> |
| +<hr> |
| +<details rst-menu-group> |
| +<summary>Alignment</summary> |
| +<div> |
| +<button type="button" data-cmd="align" data-value="left">Left</button> |
| +<button type="button" data-cmd="align" data-value="center">Centre</button> |
| +<button type="button" data-cmd="align" data-value="right">Right</button> |
| +</div> |
| +</details> |
| +<details rst-menu-group> |
| +<summary>Number</summary> |
| +<div> |
| +{{/* Every pattern here is one the server's own format package |
| + renders. Currency is absent on purpose: the symbol is a |
| + locale decision nobody has made, and guessing it would put a |
| + wrong currency on somebody's invoice. */}} |
| +<button type="button" data-cmd="numfmt" data-value="">Automatic</button> |
| +<button type="button" data-cmd="numfmt" data-value="0.00">Number <span data-sample>1234.57</span></button> |
| +<button type="button" data-cmd="numfmt" data-value="#,##0.00">Thousands <span data-sample>1,234.57</span></button> |
| +<button type="button" data-cmd="numfmt" data-value="0%">Percent <span data-sample>57%</span></button> |
| +<button type="button" data-cmd="numfmt" data-value="yyyy-mm-dd">Date <span data-sample>2026-09-02</span></button> |
| +</div> |
| +</details> |
| +<details rst-menu-group> |
| +<summary>Fill colour</summary> |
| +<div data-swatches> |
| +<button type="button" data-cmd="fill" data-value="none">No fill</button> |
| +{{range .Data.FillSwatches}} |
| +<button type="button" data-cmd="fill" data-value="{{.Name}}" aria-label="Fill {{.Label}}" |
| + data-swatch style="--sheet-h:{{.H}};--sheet-c:{{.C}}">{{.Label}}</button> |
| +{{end}} |
| +</div> |
| +</details> |
| +<details rst-menu-group> |
| +<summary>Text colour</summary> |
| +<div data-swatches> |
| +<button type="button" data-cmd="ink" data-value="none">Default</button> |
| +{{range .Data.InkSwatches}} |
| +<button type="button" data-cmd="ink" data-value="{{.Name}}" aria-label="Text {{.Label}}" |
| + data-swatch data-ink-swatch style="--sheet-kh:{{.H}};--sheet-kc:{{.C}}">{{.Label}}</button> |
| +{{end}} |
| +</div> |
| +</details> |
| +<hr> |
| +<button type="button" data-cmd="clear-format">Clear formatting</button> |
| +</div> |
| +</details> |
| +{{end}} |
| + |
| +<details rst-dropdown name="rst-menus"> |
| +<summary>Data</summary> |
| +<div rst-dropdown-menu> |
| +{{/* Both items lead to the table view, and the labels say so. The |
| + grid does not apply a saved sort or filter yet — snapshot never |
| + reads the ViewSpec, only the table does — and a "Sort A→Z" here |
| + that silently moved you to another page would be worse than a |
| + link that admits where it goes. */}} |
| +<a href="/sheets/{{.Data.Sheet.ID}}/table">Sort and filter, in the table view…</a> |
| +{{if .Data.MayWrite}} |
| +<form method="post" action="/sheets/{{.Data.Sheet.ID}}/view"> |
| +<input type="hidden" name="sortCol" value="-1"> |
| +<input type="hidden" name="filterCol" value="-1"> |
| +<button type="submit">Clear sort and filter</button> |
| +</form> |
| +{{end}} |
| +</div> |
| +</details> |
| + |
| +<details rst-dropdown name="rst-menus"> |
| +<summary>Help</summary> |
| +{{/* The panel IS the shortcut list rather than a link to a page that |
| + does not exist. It needs no route and no script, and a person |
| + looking for "what are the keys" finds them in the place they |
| + looked rather than one navigation away. */}} |
| +<div rst-dropdown-menu data-shortcuts> |
| +<dl> |
| +<dt><kbd>Ctrl Z</kbd></dt><dd>Undo my last edit</dd> |
| +<dt><kbd>Ctrl B</kbd></dt><dd>Bold</dd> |
| +<dt><kbd>Ctrl I</kbd></dt><dd>Italic</dd> |
| +<dt><kbd>Ctrl D</kbd></dt><dd>Fill down</dd> |
| +<dt><kbd>Delete</kbd></dt><dd>Clear the selected cells</dd> |
| +<dt><kbd>Enter</kbd></dt><dd>Commit and move down</dd> |
| +<dt><kbd>Tab</kbd></dt><dd>Commit and move right</dd> |
| +<dt><kbd>Esc</kbd></dt><dd>Abandon the edit</dd> |
| +</dl> |
| +</div> |
| +</details> |
| + |
| +</nav> |
| + |
| +{{if .Data.MayWrite}} |
| +{{/* The toolbar. Hidden until commands.js wires it: every control |
| + posts, so an unwired one is a button that does nothing. |
| + |
| + role="toolbar" with a roving tabindex, so the whole strip is one |
| + tab stop rather than fourteen — the same shape the Docs app uses, |
| + deliberately, so the two apps behave alike. */}} |
| +<div data-toolbar role="toolbar" aria-label="Formatting" aria-controls="main" hidden data-needs-js> |
| + |
| +<button type="button" data-tool data-cmd="undo" aria-label="Undo" data-tip="Undo (Ctrl+Z)">{{template "glyph-undo"}}</button> |
| + |
| +<span data-tool-rule aria-hidden="true"></span> |
| + |
| +<details rst-dropdown name="rst-tools" data-tool-menu> |
| +<summary data-tool aria-label="Number format" data-tip="Number format">123<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary> |
| +<div rst-dropdown-menu> |
| +<button type="button" data-cmd="numfmt" data-value="">Automatic</button> |
| +<button type="button" data-cmd="numfmt" data-value="0.00">Number</button> |
| +<button type="button" data-cmd="numfmt" data-value="#,##0.00">Thousands</button> |
| +<button type="button" data-cmd="numfmt" data-value="0%">Percent</button> |
| +<button type="button" data-cmd="numfmt" data-value="yyyy-mm-dd">Date</button> |
| +</div> |
| +</details> |
| + |
| +<span data-tool-rule aria-hidden="true"></span> |
| + |
| +<button type="button" data-tool data-cmd="bold" aria-pressed="false" aria-label="Bold" data-tip="Bold (Ctrl+B)"><b>B</b></button> |
| +<button type="button" data-tool data-cmd="italic" aria-pressed="false" aria-label="Italic" data-tip="Italic (Ctrl+I)"><i>I</i></button> |
| + |
| +<span data-tool-rule aria-hidden="true"></span> |
| + |
| +<details rst-dropdown name="rst-tools" data-tool-menu> |
| +<summary data-tool aria-label="Fill colour" data-tip="Fill colour"><span data-fill-chip aria-hidden="true"></span><span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary> |
| +<div rst-dropdown-menu data-swatches> |
| +<button type="button" data-cmd="fill" data-value="none">No fill</button> |
| +{{range .Data.FillSwatches}} |
| +<button type="button" data-cmd="fill" data-value="{{.Name}}" aria-label="Fill {{.Label}}" |
| + data-swatch style="--sheet-h:{{.H}};--sheet-c:{{.C}}">{{.Label}}</button> |
| +{{end}} |
| +</div> |
| +</details> |
| + |
| +<details rst-dropdown name="rst-tools" data-tool-menu> |
| +<summary data-tool aria-label="Text colour" data-tip="Text colour"><span data-ink-chip aria-hidden="true">A</span><span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary> |
| +<div rst-dropdown-menu data-swatches> |
| +<button type="button" data-cmd="ink" data-value="none">Default</button> |
| +{{range .Data.InkSwatches}} |
| +<button type="button" data-cmd="ink" data-value="{{.Name}}" aria-label="Text {{.Label}}" |
| + data-swatch data-ink-swatch style="--sheet-kh:{{.H}};--sheet-kc:{{.C}}">{{.Label}}</button> |
| +{{end}} |
| +</div> |
| +</details> |
| + |
| +<span data-tool-rule aria-hidden="true"></span> |
| + |
| +<button type="button" data-tool data-cmd="align" data-value="left" aria-pressed="false" aria-label="Align left" data-tip="Align left">{{template "glyph-align-left"}}</button> |
| +<button type="button" data-tool data-cmd="align" data-value="center" aria-pressed="false" aria-label="Align centre" data-tip="Align centre">{{template "glyph-align-center"}}</button> |
| +<button type="button" data-tool data-cmd="align" data-value="right" aria-pressed="false" aria-label="Align right" data-tip="Align right">{{template "glyph-align-right"}}</button> |
| + |
| +</div> |
| +{{end}} |
| + |
| +</div> |
| + |
| +{{/* Row C — the name box and the formula bar, full width. |
| + The formula bar shows the selected cell's INPUT while the grid |
| shows its computed value, which is the difference between a |
| spreadsheet and a table of numbers. It is also the reliable way to |
| see a formula: the in-cell editor swap is best effort. */}} |
| -<label class="rst-sr-only" for="formula">Selected cell</label> |
| -<input rst-input id="formula" data-formula-input type="text" autocomplete="off" |
| - placeholder="Select a cell"> |
| -{{/* Every drag-only affordance has a menu equivalent (AGENTS.md). |
| - Hidden until grid.js wires them, so a scriptless visitor is not |
| - offered two buttons that do nothing. */}} |
| -<button rst-btn="ghost" type="button" data-action="fill-down" hidden>Fill down</button> |
| -<button rst-btn="ghost" type="button" data-action="fill-right" hidden>Fill right</button> |
| -<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/table">Table view</a> |
| -<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/history">History</a> |
| -<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/comments">Comments</a> |
| -{{if .Data.MayShare}}<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/share">Share</a>{{end}} |
| -<span data-sheet-live data-state="off" role="status">Connecting</span> |
| +<div data-formula-row> |
| +<label class="rst-sr-only" for="namebox">Cell reference</label> |
| +<input rst-input id="namebox" class="rst-mono" data-name-box type="text" |
| + autocomplete="off" spellcheck="false" value="A1"> |
| +<label for="formula" data-fx aria-hidden="true"><i>fx</i></label> |
| +<span class="rst-sr-only" id="formula-label">Selected cell contents</span> |
| +<input rst-input id="formula" class="rst-mono" data-formula-input type="text" |
| + aria-labelledby="formula-label" autocomplete="off" spellcheck="false" |
| + placeholder="Select a cell"{{if not .Data.MayWrite}} readonly{{end}}> |
| </div> |
| <noscript> |
| @@ -61,5 +338,23 @@ read every cell, edit any of them, one page at a time.</p> |
| </noscript> |
| {{template "contrast-notice" .Data.Warned}} |
| + |
| <div data-sheet-grid></div> |
| + |
| +{{/* Row D — the tabs along the bottom, and the status that used to sit |
| + in the strip above the grid. |
| + |
| + One tab today, rendered as a link to the page it is already on, so |
| + the strip is real markup rather than a picture of tabs. There is |
| + deliberately no "+": nothing in this build creates, names or |
| + switches a tab, and spec §14 makes "a second tab is reachable in |
| + the UI" the trigger for multi-worksheet import. A + that did |
| + nothing would fake that trigger. */}} |
| +<div data-tabstrip> |
| +<nav data-sheet-tabs aria-label="Sheets in this document"> |
| +<a href="/sheets/{{.Data.Sheet.ID}}" aria-current="page">{{.Data.Sheet.TabName}}</a> |
| +</nav> |
| +<span data-selection-size role="status"></span> |
| +<span data-sheet-live data-state="off" role="status">Connecting</span> |
| +</div> |
| {{end}} |
diff --git a/internal/sheets/xlsx/colors.go b/internal/sheets/xlsx/colors.go| index 143233b..d2bbc4f 100644 |
| --- a/internal/sheets/xlsx/colors.go |
| +++ b/internal/sheets/xlsx/colors.go |
| @@ -166,3 +166,19 @@ func cbrt(x float64) float64 { |
| } |
| return math.Cbrt(x) |
| } |
| + |
| +// IntentHex renders a colour intent at a given lightness. |
| +// |
| +// Exported for the app's contrast gate, which has to answer "does every |
| +// swatch we offer clear WCAG AA at both themes' lightnesses" and cannot |
| +// do that without the same OKLCH->sRGB transform the exporter uses. Two |
| +// implementations of Björn Ottosson's transform, one in Go and one |
| +// implied by the browser's oklch(), is already one more than ideal; |
| +// three — with a test carrying its own copy — would make the gate |
| +// measure something other than what ships. |
| +// |
| +// l is 0-1, as everywhere else in this file. The result is 8-char ARGB. |
| +func IntentHex(l, chroma, hue float64) string { |
| + r, g, b := oklchToSRGB(l, chroma, hue) |
| + return argbHex(r, g, b) |
| +} |