oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package 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.
23type 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.
40const (
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.
56var 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.
76var (
77 fillSwatches = buildSwatches(fillChroma)
78 inkSwatches = buildSwatches(inkChroma)
79)
80
81func 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.
107type 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.
122var 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.
131func 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.
147const 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.
155func 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