feat: surface an imported fill and ink that fail AA, per spec 11.1's trigger
Spec 14's deferred table had one row whose trigger was "plan 2's grid work". This is it firing. It needed something the spec had assumed we already had: the authored INK. readStyle retained fills and never font colours, so the pair 11.1 is about could not exist in our data at all — a fill on its own cannot fail a contrast check, because the other half was always our own theme's, which is a pairing this app controls. Format gains InkHex, and XLSX import and export carry it. InkHex participates in formatDigest. Formats are interned by that digest, so a field left out of it makes two genuinely different formats collide onto one row and the second cell silently adopts the first's colour. The export condition is `bold || italic || ink`, not `bold || italic`: a cell whose only font property is a colour would otherwise get no Font element and lose the retained ink on the way out — silently, and only for exactly the cells 11.1 is about. WCAG contrast is implemented here rather than borrowed: rastrillo has it, but only inside ui/contrast_test.go, and a _test.go exports nothing to another package. The test checks it against Excel's own failing preset at the figure the spec records from an independent source (#9C6500 on #FFEB9C, 4.12:1), so a disagreement means we are wrong, not the spec. Surfaced, never overridden. A notice names the cells; nothing changes a colour. 11.1 ruled that correcting an imported file would lie about the document.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
13 files changed,
+379
−10
docs/superpowers/specs/2026-08-30-sheets-design.md+9 −2internal/sheets/contrast.go+121 −0internal/sheets/contrast_test.go+172 −0internal/sheets/handlers_sheets.go+13 −5internal/sheets/handlers_table.go+2 −0internal/sheets/migrations/0003_alter_formats.sql+2 −0internal/sheets/migrations/schema.sql+1 −1internal/sheets/store/handle_grid.go+6 −0internal/sheets/store/models.go+12 −1internal/sheets/templates/layout.html+20 −0internal/sheets/templates/sheet.html+1 −0internal/sheets/templates/table.html+1 −0internal/sheets/xlsx/format.go+19 −1
CI passed — run details
diff --git a/docs/superpowers/specs/2026-08-30-sheets-design.md b/docs/superpowers/specs/2026-08-30-sheets-design.md| index 4f2cd24..261c2cf 100644 |
| --- a/docs/superpowers/specs/2026-08-30-sheets-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-sheets-design.md |
| @@ -883,7 +883,14 @@ is working exactly as specified. |
| **The obligation that follows is surfacing, not overriding** — telling a |
| person their imported file contains a pairing that fails, and leaving the |
| -decision to them. That is grid work and belongs to plan 2 (§14). |
| +decision to them. That was grid work and belonged to plan 2 (§14). |
| + |
| +**Built 2026-09-01.** It needed one thing this spec had assumed: the |
| +authored *ink*. `readStyle` retained fills and never font colours, so the |
| +pair §11.1 is about could not exist in our data at all — a fill alone |
| +cannot fail, since the other half was always our own theme's. `Format` |
| +gained `InkHex`, XLSX import and export carry it, and the grid and table |
| +views name the failing cells in a notice. No cell is altered. |
| *(Found 2026-08-31 by ui-guidelines-52 while building rastrillo's colour |
| engine, from Excel's documented preset fill/ink pairs. Related: `#0000FF` |
| @@ -1009,7 +1016,7 @@ it is a fallback rather than a scramble. |
| | `client` shape for anything but the grid | A surface genuinely needs client-held keys | |
| | Print / PDF export | Asked for. docs-e6 keeps its layout engine's page model generic over content boxes so this stays open: paginated grid, repeating header rows, scale-to-width, explicit page breaks, charts as vector | |
| | Live sheet range embedded in a doc | A known future crossing of two collaboration models | |
| -| **Surfacing an imported fill/ink pairing that fails WCAG AA** | The grid renders imported formatting. Trigger: plan 2's grid work — see §11.1 | |
| +| ~~Surfacing an imported fill/ink pairing that fails WCAG AA~~ | **Trigger fired and built, 2026-09-01.** Plan 2's grid work landed, and with it `Format.InkHex` (the authored font colour, which nothing retained before), a WCAG ratio, and a notice naming the failing cells on both the grid and the table view. Surfaced, never overridden — §11.1's ruling stands. | |
| --- |
diff --git a/internal/sheets/contrast.go b/internal/sheets/contrast.go| new file mode 100644 |
| index 0000000..1741821 |
| --- /dev/null |
| +++ b/internal/sheets/contrast.go |
| @@ -0,0 +1,121 @@ |
| +package sheets |
| + |
| +import ( |
| + "math" |
| + "strconv" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// aaFloor is WCAG 2.2's contrast minimum for normal-size text: 4.5:1. |
| +const aaFloor = 4.5 |
| + |
| +// maxWarnedCells bounds the list a page names. Somebody importing a |
| +// heavily formatted file does not need four hundred cell references; |
| +// they need to know it happened and where to start. |
| +const maxWarnedCells = 12 |
| + |
| +// contrastRatio is WCAG 2.x relative luminance, (L1+0.05)/(L2+0.05). |
| +// |
| +// Written here rather than borrowed: rastrillo has this computation, |
| +// but only inside ui/contrast_test.go, and a _test.go file exports |
| +// nothing to another package. Copying twenty lines of a published |
| +// standard beats vendoring a test file or waiting for an upstream |
| +// export — and the constants below are the standard's, checkable |
| +// against it rather than against us. |
| +func contrastRatio(a, b string) (float64, bool) { |
| + la, ok := relativeLuminance(a) |
| + if !ok { |
| + return 0, false |
| + } |
| + lb, ok := relativeLuminance(b) |
| + if !ok { |
| + return 0, false |
| + } |
| + if la < lb { |
| + la, lb = lb, la |
| + } |
| + return (la + 0.05) / (lb + 0.05), true |
| +} |
| + |
| +// relativeLuminance parses an 8-char ARGB or 6-char RGB hex and returns |
| +// WCAG relative luminance. The alpha channel is ignored: a fill in a |
| +// spreadsheet is composited over the sheet, and treating a translucent |
| +// fill as opaque overstates rather than understates the risk, which is |
| +// the safe direction for a warning. |
| +func relativeLuminance(hex string) (float64, bool) { |
| + switch len(hex) { |
| + case 8: |
| + hex = hex[2:] |
| + case 6: |
| + default: |
| + return 0, false |
| + } |
| + channel := func(s string) (float64, bool) { |
| + v, err := strconv.ParseUint(s, 16, 8) |
| + if err != nil { |
| + return 0, false |
| + } |
| + c := float64(v) / 255 |
| + if c <= 0.04045 { |
| + return c / 12.92, true |
| + } |
| + return math.Pow((c+0.055)/1.055, 2.4), true |
| + } |
| + r, okR := channel(hex[0:2]) |
| + g, okG := channel(hex[2:4]) |
| + b, okB := channel(hex[4:6]) |
| + if !okR || !okG || !okB { |
| + return 0, false |
| + } |
| + return 0.2126*r + 0.7152*g + 0.0722*b, true |
| +} |
| + |
| +// lowContrast reports whether a format's AUTHORED pair fails AA. |
| +// |
| +// Both halves must be present. A fill on its own cannot fail: our own |
| +// ink comes from the theme, and that pairing is one this app controls |
| +// and gates elsewhere. What can fail is a fill and an ink that both |
| +// arrived in somebody's file — which is not hypothetical, since Excel |
| +// ships #9C6500 on #FFEB9C at 4.12:1 as a conditional-formatting |
| +// preset (spec §11.1). |
| +// |
| +// It reports, and nothing acts on it beyond telling a person. Spec |
| +// §11.1 ruled that we render an imported file as authored: silently |
| +// correcting it would lie about the document. The obligation is |
| +// surfacing, not overriding. |
| +func lowContrast(f *store.Format) bool { |
| + if f == nil || f.FillHex == "" || f.InkHex == "" { |
| + return false |
| + } |
| + ratio, ok := contrastRatio(f.FillHex, f.InkHex) |
| + if !ok { |
| + return false |
| + } |
| + return ratio < aaFloor |
| +} |
| + |
| +// warnedCells lists the A1 references in a window whose imported |
| +// colours fail AA, newest window first and capped. |
| +func warnedCells(h *store.Handle, idx posIndex, rows, cols int) []string { |
| + coords, err := h.Cells() |
| + if err != nil { |
| + return nil |
| + } |
| + var refs []string |
| + for _, c := range coords { |
| + r, okR := idx.rowPos[c.RowID] |
| + col, okC := idx.colPos[c.ColID] |
| + if !okR || !okC || r >= rows || col >= cols { |
| + continue |
| + } |
| + if !lowContrast(h.Format(c.RowID, c.ColID)) { |
| + continue |
| + } |
| + if len(refs) == maxWarnedCells { |
| + return append(refs, "…") |
| + } |
| + refs = append(refs, colLabel(col)+strconv.Itoa(r+1)) |
| + } |
| + return refs |
| +} |
diff --git a/internal/sheets/contrast_test.go b/internal/sheets/contrast_test.go| new file mode 100644 |
| index 0000000..132a322 |
| --- /dev/null |
| +++ b/internal/sheets/contrast_test.go |
| @@ -0,0 +1,172 @@ |
| +package sheets |
| + |
| +import ( |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// The pair spec §11.1 names: Excel's own light-yellow |
| +// conditional-formatting preset, #9C6500 ink on #FFEB9C fill, which the |
| +// spec records as 4.12:1 against an independent source. If our |
| +// implementation of a published standard disagrees with a figure |
| +// somebody else computed, ours is what is wrong. |
| +func TestExcelsOwnPresetIsBelowTheFloor(t *testing.T) { |
| + got, ok := contrastRatio("FFFFEB9C", "FF9C6500") |
| + if !ok { |
| + t.Fatal("contrastRatio could not read the pair") |
| + } |
| + if math.Abs(got-4.12) > 0.02 { |
| + t.Fatalf("contrast(#FFEB9C, #9C6500) = %.3f, want 4.12 — "+ |
| + "the figure spec §11.1 records against an independent source", got) |
| + } |
| + if got >= aaFloor { |
| + t.Fatalf("%.2f is not below the %.1f floor; this pair is the whole reason §11.1 exists", got, aaFloor) |
| + } |
| +} |
| + |
| +// Anchors from the standard itself, so the implementation is checked |
| +// against WCAG rather than against its own output. |
| +func TestContrastRatioAnchors(t *testing.T) { |
| + for _, tc := range []struct { |
| + a, b string |
| + want float64 |
| + }{ |
| + {"FFFFFFFF", "FF000000", 21}, // white on black is the maximum |
| + {"FF000000", "FFFFFFFF", 21}, // and the order must not matter |
| + {"FF808080", "FF808080", 1}, // a colour against itself |
| + {"FFFFFFFF", "FF767676", 4.54}, // the classic "just passes" grey |
| + } { |
| + got, ok := contrastRatio(tc.a, tc.b) |
| + if !ok { |
| + t.Fatalf("contrastRatio(%s, %s) failed to parse", tc.a, tc.b) |
| + } |
| + if math.Abs(got-tc.want) > 0.02 { |
| + t.Errorf("contrast(%s, %s) = %.3f, want %.2f", tc.a, tc.b, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +func TestContrastRatioRejectsRubbish(t *testing.T) { |
| + for _, bad := range []string{"", "fff", "zzzzzz", "FFFFFFFFFF"} { |
| + if _, ok := contrastRatio(bad, "FF000000"); ok { |
| + t.Errorf("contrastRatio accepted %q", bad) |
| + } |
| + } |
| + // Six-char RGB is accepted as well as eight-char ARGB, because |
| + // excelize hands back both shapes depending on the file. |
| + if _, ok := contrastRatio("FFEB9C", "9C6500"); !ok { |
| + t.Error("contrastRatio rejected plain 6-char RGB") |
| + } |
| +} |
| + |
| +// A fill on its own cannot fail. Our own ink comes from the theme, and |
| +// that pairing is one this app controls; only a pair that both arrived |
| +// in somebody's file can fail here. |
| +func TestLowContrastNeedsBothHalves(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + f *store.Format |
| + want bool |
| + }{ |
| + {"nil format", nil, false}, |
| + {"no colours at all", &store.Format{}, false}, |
| + {"fill only", &store.Format{FillHex: "FFFFEB9C"}, false}, |
| + {"ink only", &store.Format{InkHex: "FF9C6500"}, false}, |
| + {"Excel's failing preset", &store.Format{FillHex: "FFFFEB9C", InkHex: "FF9C6500"}, true}, |
| + {"black on white", &store.Format{FillHex: "FFFFFFFF", InkHex: "FF000000"}, false}, |
| + {"unreadable", &store.Format{FillHex: "FF0000FF", InkHex: "FF000000"}, true}, |
| + } |
| + for _, tc := range cases { |
| + if got := lowContrast(tc.f); got != tc.want { |
| + t.Errorf("%s: lowContrast = %v, want %v", tc.name, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// The obligation is surfacing, not overriding: a person must be told, |
| +// and the cell must be left alone. |
| +func TestAFailingPairIsSurfacedOnBothViews(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Imported") |
| + |
| + // Put a value in A1, then give it the format an import would have |
| + // retained from Excel's own preset. |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"warned"}`) |
| + |
| + h, err := app.openTabFor(s) |
| + if err != nil { |
| + t.Fatalf("open tab: %v", err) |
| + } |
| + rowID, _ := h.EnsureRow(0) |
| + colID, _ := h.EnsureCol(0) |
| + if err := h.SetFormat(rowID, colID, store.Format{ |
| + FillHex: "FFFFEB9C", InkHex: "FF9C6500", |
| + }); err != nil { |
| + t.Fatalf("SetFormat: %v", err) |
| + } |
| + h.Release() |
| + |
| + for _, path := range []string{ |
| + fmt.Sprintf("/sheets/%d", s.ID), |
| + fmt.Sprintf("/sheets/%d/table", s.ID), |
| + } { |
| + body := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)).Body.String() |
| + if !strings.Contains(body, "below the contrast floor") { |
| + t.Errorf("%s does not surface the failing pair", path) |
| + } |
| + if !strings.Contains(body, "<code>A1</code>") { |
| + t.Errorf("%s does not name the cell", path) |
| + } |
| + } |
| +} |
| + |
| +// Surfacing must not fire on a sheet that has no imported colours at |
| +// all, or it becomes a banner everybody learns to ignore. |
| +func TestNoNoticeWithoutAFailingPair(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Plain") |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`) |
| + |
| + body := do(t, app, as(httptest.NewRequest(http.MethodGet, |
| + fmt.Sprintf("/sheets/%d", s.ID), nil), cookies)).Body.String() |
| + if strings.Contains(body, "below the contrast floor") { |
| + t.Fatal("a sheet with no imported colours is warned about anyway") |
| + } |
| +} |
| + |
| +// Spec §11.1: rendered as authored. The warning must not change what |
| +// the cell holds or how it is painted. |
| +func TestSurfacingDoesNotChangeTheCell(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Imported") |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"warned"}`) |
| + |
| + h, err := app.openTabFor(s) |
| + if err != nil { |
| + t.Fatalf("open tab: %v", err) |
| + } |
| + rowID, _ := h.EnsureRow(0) |
| + colID, _ := h.EnsureCol(0) |
| + if err := h.SetFormat(rowID, colID, store.Format{FillHex: "FFFFEB9C", InkHex: "FF9C6500"}); err != nil { |
| + t.Fatalf("SetFormat: %v", err) |
| + } |
| + h.Release() |
| + |
| + do(t, app, as(httptest.NewRequest(http.MethodGet, fmt.Sprintf("/sheets/%d", s.ID), nil), cookies)) |
| + |
| + h2, err := app.openTabFor(s) |
| + if err != nil { |
| + t.Fatalf("reopen: %v", err) |
| + } |
| + defer h2.Release() |
| + f := h2.Format(rowID, colID) |
| + if f == nil || f.FillHex != "FFFFEB9C" || f.InkHex != "FF9C6500" { |
| + t.Fatalf("the colours changed after being surfaced: %+v", f) |
| + } |
| +} |
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go| index 1bbb6c7..53bfa4c 100644 |
| --- a/internal/sheets/handlers_sheets.go |
| +++ b/internal/sheets/handlers_sheets.go |
| @@ -23,6 +23,11 @@ type sheetPageView struct { |
| Sheet SheetSummary |
| Rows int |
| Cols int |
| + // 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 |
| + // notice on the page, never a correction to the cell. |
| + Warned []string |
| } |
| // handleSheetList renders every sheet on the instance. |
| @@ -70,11 +75,14 @@ func (a *App) handleSheetPage(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - a.render(w, r, "sheet", s.Title+" — Oficina Sheets", http.StatusOK, sheetPageView{ |
| - Sheet: s, |
| - Rows: defaultWindowRows, |
| - Cols: defaultWindowCols, |
| - }) |
| + view := sheetPageView{Sheet: s, Rows: defaultWindowRows, Cols: defaultWindowCols} |
| + // Best effort: a sheet whose handle will not open still renders its |
| + // page, and the grid's own fetch reports the failure properly. |
| + if h, err := a.openTabFor(s); err == nil { |
| + view.Warned = warnedCells(h, newPosIndex(h), defaultWindowRows, defaultWindowCols) |
| + h.Release() |
| + } |
| + a.render(w, r, "sheet", s.Title+" — Oficina Sheets", http.StatusOK, view) |
| } |
| // sheetFromURL reads {sheetID} and loads it, answering 404 itself if it |
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go| index 50acb52..a9c1595 100644 |
| --- a/internal/sheets/handlers_table.go |
| +++ b/internal/sheets/handlers_table.go |
| @@ -43,6 +43,7 @@ type tableView struct { |
| Sheet SheetSummary |
| Rows []tableRow |
| Cols []string |
| + Warned []string |
| Page int |
| Pages int |
| PrevPage int |
| @@ -110,6 +111,7 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) { |
| view.Rows = append(view.Rows, row) |
| } |
| + view.Warned = warnedCells(h, idx, maxWindowRows, tableCols) |
| a.render(w, r, "table", s.Title+" — table view", http.StatusOK, view) |
| } |
diff --git a/internal/sheets/migrations/0003_alter_formats.sql b/internal/sheets/migrations/0003_alter_formats.sql| new file mode 100644 |
| index 0000000..a4b1156 |
| --- /dev/null |
| +++ b/internal/sheets/migrations/0003_alter_formats.sql |
| @@ -0,0 +1,2 @@ |
| +ALTER TABLE `formats` ADD `ink_hex` text; |
| + |
diff --git a/internal/sheets/migrations/schema.sql b/internal/sheets/migrations/schema.sql| index dd67e05..0a63a05 100644 |
| --- a/internal/sheets/migrations/schema.sql |
| +++ b/internal/sheets/migrations/schema.sql |
| @@ -7,7 +7,7 @@ CREATE TABLE `cols` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`po |
| CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`tab_id` integer,`actor_id` integer,`payload_sealed` blob,`trigger_id` integer,`derived_count` integer,`created_at` datetime); |
| -CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text); |
| +CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text, `ink_hex` text); |
| CREATE TABLE `members` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text,`created_at` datetime); |
diff --git a/internal/sheets/store/handle_grid.go b/internal/sheets/store/handle_grid.go| index 2a8b6b6..1b90513 100644 |
| --- a/internal/sheets/store/handle_grid.go |
| +++ b/internal/sheets/store/handle_grid.go |
| @@ -225,6 +225,12 @@ func formatDigest(f Format) string { |
| sb.WriteByte('|') |
| sb.WriteString(f.FillHex) |
| sb.WriteByte('|') |
| + // InkHex participates for the same reason every other field does: |
| + // formats are interned by this digest, so a field left out here |
| + // makes two genuinely different formats collide onto one row, and |
| + // the second cell silently adopts the first's colour. |
| + sb.WriteString(f.InkHex) |
| + sb.WriteByte('|') |
| sb.WriteString(f.Align) |
| sum := sha256.Sum256([]byte(sb.String())) |
| return hex.EncodeToString(sum[:]) |
diff --git a/internal/sheets/store/models.go b/internal/sheets/store/models.go| index fd31481..a1383bc 100644 |
| --- a/internal/sheets/store/models.go |
| +++ b/internal/sheets/store/models.go |
| @@ -93,7 +93,18 @@ type Format struct { |
| FillIntentH *float64 // OKLCH hue; nil means no fill |
| FillIntentC *float64 // OKLCH chroma |
| FillHex string // retained from XLSX import for lossless re-export |
| - Align string |
| + // InkHex is the AUTHOR'S font colour, retained from XLSX import the |
| + // same way FillHex is, and for the same reason: re-exporting a file |
| + // with a colour we invented would lie about it. |
| + // |
| + // It exists because spec §11.1's obligation needs a pair. A fill |
| + // alone cannot fail a contrast check — a fill plus the ink somebody |
| + // chose to put on it can, and Excel ships such a pair as a preset |
| + // (#9C6500 on #FFEB9C, 4.12:1). Empty means the file said nothing, |
| + // and the theme's own ink is used, which is a pairing this app |
| + // controls and cannot get wrong. |
| + InkHex string |
| + Align string |
| } |
| // Event is the append-only log: live-sync fan-out, version history, undo |
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html| index e3e84e7..5137f7b 100644 |
| --- a/internal/sheets/templates/layout.html |
| +++ b/internal/sheets/templates/layout.html |
| @@ -59,3 +59,23 @@ |
| </body> |
| </html> |
| {{end}} |
| + |
| +{{define "contrast-notice"}} |
| +{{if .}} |
| +{{/* Spec §11.1: a retained fill can be unreadable, and that is |
| + correct. We render an imported file as authored — silently |
| + correcting it would lie about the document — so the obligation |
| + that follows is to say so and leave the decision to the person. |
| + This is that, and it is deliberately a notice rather than a |
| + change to any cell. */}} |
| +<div rst-callout="warn"> |
| +<div rst-callout-body> |
| +<p><strong>Imported colours below the contrast floor.</strong> |
| +These cells came from a file pairing a fill and a text colour under |
| +WCAG AA's 4.5:1: {{range $i, $ref := .}}{{if $i}}, {{end}}<code>{{$ref}}</code>{{end}}</p> |
| +<p>They are shown exactly as the file wrote them. Changing a colour is |
| +yours to decide, not ours to do quietly.</p> |
| +</div> |
| +</div> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html| index 0982227..668dd36 100644 |
| --- a/internal/sheets/templates/sheet.html |
| +++ b/internal/sheets/templates/sheet.html |
| @@ -57,5 +57,6 @@ read every cell, edit any of them, one page at a time.</p> |
| </div> |
| </noscript> |
| +{{template "contrast-notice" .Data.Warned}} |
| <div data-sheet-grid></div> |
| {{end}} |
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html| index 0bade45..466f87e 100644 |
| --- a/internal/sheets/templates/table.html |
| +++ b/internal/sheets/templates/table.html |
| @@ -8,6 +8,7 @@ Every cell is editable here, with no JavaScript at all.</p> |
| <a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Open the grid</a> |
| </header> |
| +{{template "contrast-notice" .Data.Warned}} |
| <div style="overflow-x:auto"> |
| <table data-cell-table> |
| <caption class="rst-sr-only">{{.Data.Sheet.Title}}, page {{.Data.Page}} of {{.Data.Pages}}</caption> |
diff --git a/internal/sheets/xlsx/format.go b/internal/sheets/xlsx/format.go| index 1a2be31..65fc341 100644 |
| --- a/internal/sheets/xlsx/format.go |
| +++ b/internal/sheets/xlsx/format.go |
| @@ -31,8 +31,17 @@ func styleFromFormat(f *store.Format) (excelize.Style, bool) { |
| st.CustomNumFmt = &nf |
| used = true |
| } |
| - if f.Bold || f.Italic { |
| + // The condition includes InkHex, not just bold and italic: a cell |
| + // whose only font property is a colour would otherwise get no Font |
| + // element at all, and the retained ink would be dropped on export — |
| + // silently, and only for the cells §11.1 is about. |
| + if f.Bold || f.Italic || f.InkHex != "" { |
| st.Font = &excelize.Font{Bold: f.Bold, Italic: f.Italic} |
| + if len(f.InkHex) == 8 { |
| + // Same 6-char RGB convention as Fill.Color below: excelize |
| + // prepends its own alpha. |
| + st.Font.Color = f.InkHex[2:] |
| + } |
| used = true |
| } |
| if f.Align != "" { |
| @@ -82,6 +91,15 @@ func formatFromStyle(st *excelize.Style) (store.Format, bool) { |
| f.Italic = st.Font.Italic |
| used = true |
| } |
| + if st.Font != nil && st.Font.Color != "" { |
| + // Retained, not resolved. Spec §11.1: we render an imported |
| + // file as authored, and surface a pairing that fails AA rather |
| + // than silently correcting it — correcting would lie about the |
| + // document, which is the same class of wrong as inventing a |
| + // font colour on export. |
| + f.InkHex = argbFromStyleColor(st.Font.Color) |
| + used = true |
| + } |
| if st.Alignment != nil && st.Alignment.Horizontal != "" { |
| f.Align = st.Alignment.Horizontal |
| used = true |