| 1 | package sheets |
| 2 | |
| 3 | // The fill and text-colour palette. |
| 4 | // |
| 5 | // **The server owns it, and the client picks by name.** A colour picker |
| 6 | // that posts a hex would put an arbitrary sRGB literal into storage, and |
| 7 | // this app's whole colour design is that a fill WE apply is stored as |
| 8 | // hue and chroma with no lightness, so it can resolve against the |
| 9 | // reader's theme (store/models.go). A hex has a lightness baked in; a |
| 10 | // swatch name does not. Naming them here also means the two places that |
| 11 | // need the list — the toolbar's buttons and the write endpoint's |
| 12 | // validation — read the same one, rather than a Go map and a JavaScript |
| 13 | // array that agree until somebody edits one. |
| 14 | // |
| 15 | // Hues are spread around the OKLCH circle rather than taken from a |
| 16 | // brand palette: these are a DOCUMENT's colours, chosen by whoever is |
| 17 | // writing the sheet, and they should not shift when the app's own |
| 18 | // accent does. |
| 19 | |
| 20 | // swatch is one palette entry: an OKLCH hue in degrees and a chroma. |
| 21 | // Lightness is deliberately absent — that is the whole point. See |
| 22 | // store.Format's FillIntentH/FillIntentC. |
| 23 | type swatch struct { |
| 24 | // Name is the wire value and the stable id. Lowercase, because it |
| 25 | // travels in JSON and a case difference would be a silent miss. |
| 26 | Name string |
| 27 | // Label is what a person reads on the button's accessible name. |
| 28 | Label string |
| 29 | H float64 |
| 30 | C float64 |
| 31 | } |
| 32 | |
| 33 | // fillChroma and inkChroma differ on purpose. |
| 34 | // |
| 35 | // A fill sits behind text and must not fight it, so it is soft. Ink IS |
| 36 | // the text, so it can be saturated — at the same chroma as a fill it |
| 37 | // reads as a mistake rather than a colour. One hue list, two chromas, |
| 38 | // which is why these are constants rather than two hand-written tables |
| 39 | // that could drift apart on the day somebody adds a hue. |
| 40 | const ( |
| 41 | fillChroma = 0.085 |
| 42 | // inkChroma is capped at 0.10 rather than pushed as far as it looks |
| 43 | // good, because TestEverySwatchClearsAA runs every offered swatch |
| 44 | // through this app's own WCAG arithmetic at both themes' lightnesses |
| 45 | // and a more saturated ink stops clearing 4.5:1 in light mode. A |
| 46 | // palette that fails the floor the app warns imported files about |
| 47 | // would be this app shipping the defect it complains of. |
| 48 | inkChroma = 0.10 |
| 49 | ) |
| 50 | |
| 51 | // hues is the ordered hue wheel both palettes are built from. |
| 52 | // |
| 53 | // Ordered, and the order is what the toolbar renders left to right: a |
| 54 | // palette in spectrum order is one a person can aim at, where a palette |
| 55 | // in alphabetical order is one they have to read. |
| 56 | var hues = []struct { |
| 57 | Name, Label string |
| 58 | H float64 |
| 59 | }{ |
| 60 | {"red", "Red", 27}, |
| 61 | {"orange", "Orange", 62}, |
| 62 | {"yellow", "Yellow", 95}, |
| 63 | {"green", "Green", 145}, |
| 64 | {"teal", "Teal", 190}, |
| 65 | {"blue", "Blue", 250}, |
| 66 | {"violet", "Violet", 295}, |
| 67 | {"magenta", "Magenta", 340}, |
| 68 | // Grey is a hue with no chroma. It is in the list because "make |
| 69 | // this row grey" is one of the two or three things anybody actually |
| 70 | // wants from a fill palette, and leaving it out would push people |
| 71 | // to a coloured swatch they did not mean. |
| 72 | {"grey", "Grey", 250}, |
| 73 | } |
| 74 | |
| 75 | // fillSwatches and inkSwatches are the two palettes, derived once. |
| 76 | var ( |
| 77 | fillSwatches = buildSwatches(fillChroma) |
| 78 | inkSwatches = buildSwatches(inkChroma) |
| 79 | ) |
| 80 | |
| 81 | func buildSwatches(chroma float64) []swatch { |
| 82 | out := make([]swatch, 0, len(hues)) |
| 83 | for _, h := range hues { |
| 84 | c := chroma |
| 85 | if h.Name == "grey" { |
| 86 | c = 0 |
| 87 | } |
| 88 | out = append(out, swatch{Name: h.Name, Label: h.Label, H: h.H, C: c}) |
| 89 | } |
| 90 | return out |
| 91 | } |
| 92 | |
| 93 | // numberFormat is one pattern the toolbar and the Format menu offer. |
| 94 | // |
| 95 | // **The list is the validation.** Before it existed, the format route |
| 96 | // took 128 bytes of free text and stored it: an Excel pattern admits |
| 97 | // quoted literals (`"Overdue: "0`), so that was an arbitrary plaintext |
| 98 | // write into the formats table through a public API, in the family spec |
| 99 | // §2.1 calls "user data living inside a format-adjacent record". The UI |
| 100 | // has only ever offered five patterns; accepting only those closes the |
| 101 | // channel and costs nothing. |
| 102 | // |
| 103 | // The XLSX importer is deliberately not bound by this. A file's own |
| 104 | // custom format is the document's, retained as authored the same way |
| 105 | // its fills are — a different path, a different obligation, and one the |
| 106 | // spec already accounts for. |
| 107 | type numberFormat struct { |
| 108 | // Code is the pattern format.Display renders. Empty is "Automatic", |
| 109 | // which is a real state and not a synonym for the general format. |
| 110 | Code string |
| 111 | // Label is what a person reads; Sample shows the pattern's effect |
| 112 | // on a number, which is the only way to tell 0.00 from #,##0.00 |
| 113 | // without knowing the grammar. |
| 114 | Label, Sample string |
| 115 | } |
| 116 | |
| 117 | // numberFormats is what the UI offers, in the order it renders them. |
| 118 | // |
| 119 | // Currency is absent on purpose: the symbol is a locale decision nobody |
| 120 | // has made, and guessing it would put a wrong currency on somebody's |
| 121 | // invoice. |
| 122 | var numberFormats = []numberFormat{ |
| 123 | {"", "Automatic", ""}, |
| 124 | {"0.00", "Number", "1234.57"}, |
| 125 | {"#,##0.00", "Thousands", "1,234.57"}, |
| 126 | {"0%", "Percent", "57%"}, |
| 127 | {"yyyy-mm-dd", "Date", "2026-09-02"}, |
| 128 | } |
| 129 | |
| 130 | // knownNumberFormat reports whether a code is one the UI offers. |
| 131 | func knownNumberFormat(code string) bool { |
| 132 | for _, f := range numberFormats { |
| 133 | if f.Code == code { |
| 134 | return true |
| 135 | } |
| 136 | } |
| 137 | return false |
| 138 | } |
| 139 | |
| 140 | // noColour is the wire value that CLEARS a fill or an ink. |
| 141 | // |
| 142 | // A distinct name rather than an empty string, because an empty string |
| 143 | // is what a field absent from a JSON patch decodes to, and "the caller |
| 144 | // did not mention fill" and "the caller asked for no fill" must not be |
| 145 | // the same value. The patch struct uses pointers for the first |
| 146 | // distinction; this constant carries the second. |
| 147 | const noColour = "none" |
| 148 | |
| 149 | // lookupSwatch resolves a name in a palette. |
| 150 | // |
| 151 | // ok is false for a name the palette does not have, which the write |
| 152 | // endpoint turns into a 400 rather than silently applying nothing: a |
| 153 | // colour button that appears to work and does nothing is the worst |
| 154 | // available failure, because the person has no reason to look again. |
| 155 | func lookupSwatch(list []swatch, name string) (swatch, bool) { |
| 156 | for _, s := range list { |
| 157 | if s.Name == name { |
| 158 | return s, true |
| 159 | } |
| 160 | } |
| 161 | return swatch{}, false |
| 162 | } |
| 163 | |