| 1 | package theme |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "math" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // App is one product of the suite and its place on Calma's colour ring. |
| 10 | type App struct { |
| 11 | Slug string // closed set; see Apps |
| 12 | Name string // the product's name, as a person reads it |
| 13 | Hue float64 // OKLCH hue, degrees |
| 14 | } |
| 15 | |
| 16 | // apps is the ring. Every app's colour is the same OKLCH lightness and |
| 17 | // chroma at a different hue, so no app is louder than another; the hues |
| 18 | // are spaced evenly to the eye rather than to the protractor (34-50°, |
| 19 | // with the widest gap across the yellow-greens, which go muddy at this |
| 20 | // lightness). Paul, 2026-09-29, with Mail and Files still to come. |
| 21 | // |
| 22 | // Oficina and Docs share 262 on purpose: the suite's own accent, used |
| 23 | // where no app is in charge (sign-in, the team's home), is the ring's blue, |
| 24 | // and Docs is where that blue started. Oficina's mark is every hue, not |
| 25 | // this one. |
| 26 | // |
| 27 | // This table is the one source of the suite's colours: the accent blocks |
| 28 | // below, and brand/icons.json's hues (brand_test checks they agree). |
| 29 | var apps = []App{ |
| 30 | {"oficina", "Oficina", 262}, |
| 31 | {"docs", "Docs", 262}, |
| 32 | {"memoria", "Memoria", 302}, |
| 33 | {"mail", "Mail", 342}, |
| 34 | {"calendar", "Calendar", 20}, |
| 35 | {"slides", "Slides", 56}, |
| 36 | {"fichas", "Fichas", 90}, |
| 37 | {"sheets", "Sheets", 140}, |
| 38 | {"files", "Files", 180}, |
| 39 | {"calls", "Calls", 220}, |
| 40 | } |
| 41 | |
| 42 | // Apps returns the ring, Oficina first. |
| 43 | func Apps() []App { return append([]App(nil), apps...) } |
| 44 | |
| 45 | // Lookup returns the app for slug. The set is closed: a slug that is not |
| 46 | // in the ring is not a theme, whatever else it is. |
| 47 | func Lookup(slug string) (App, bool) { |
| 48 | for _, a := range apps { |
| 49 | if a.Slug == slug { |
| 50 | return a, true |
| 51 | } |
| 52 | } |
| 53 | return App{}, false |
| 54 | } |
| 55 | |
| 56 | // The accent's four tokens, as OKLCH lightness and chroma; the hue is the |
| 57 | // app's. Light: a deep accent that carries white text; dark: a light one |
| 58 | // that carries near-black. Chroma is the most each hue can hold at that |
| 59 | // lightness inside sRGB, up to these values (see Hex). |
| 60 | var accentSpec = [2]struct{ accent, strong, soft [2]float64 }{ |
| 61 | {accent: [2]float64{0.49, 0.15}, strong: [2]float64{0.43, 0.14}, soft: [2]float64{0.955, 0.025}}, |
| 62 | {accent: [2]float64{0.76, 0.12}, strong: [2]float64{0.84, 0.09}, soft: [2]float64{0.29, 0.045}}, |
| 63 | } |
| 64 | |
| 65 | // AccentCSS is the block that re-declares the accent for one app. It |
| 66 | // follows estilo.css in the cascade, so the app's colour wins everywhere |
| 67 | // the suite's would have shown. |
| 68 | func AccentCSS(slug string) ([]byte, bool) { |
| 69 | a, ok := Lookup(slug) |
| 70 | if !ok { |
| 71 | return nil, false |
| 72 | } |
| 73 | hex := func(lc [2]float64) string { return Hex(lc[0], lc[1], a.Hue) } |
| 74 | l, d := accentSpec[0], accentSpec[1] |
| 75 | var b strings.Builder |
| 76 | fmt.Fprintf(&b, "/* The accent for %s: hue %g on Calma's ring (estilo theme/apps.go). */\n", a.Name, a.Hue) |
| 77 | b.WriteString(":root {\n") |
| 78 | fmt.Fprintf(&b, " --rst-accent: light-dark(%s, %s);\n", hex(l.accent), hex(d.accent)) |
| 79 | fmt.Fprintf(&b, " --rst-accent-strong: light-dark(%s, %s);\n", hex(l.strong), hex(d.strong)) |
| 80 | fmt.Fprintf(&b, " --rst-accent-soft: light-dark(%s, %s);\n", hex(l.soft), hex(d.soft)) |
| 81 | b.WriteString(" --rst-on-accent: light-dark(#ffffff, #111418);\n") |
| 82 | b.WriteString("}\n") |
| 83 | return []byte(b.String()), true |
| 84 | } |
| 85 | |
| 86 | // ForApp is the whole theme for one app: estilo.css with that app's |
| 87 | // accent after it. Serve it as the app's /static/theme.css. |
| 88 | func ForApp(slug string) ([]byte, bool) { |
| 89 | accent, ok := AccentCSS(slug) |
| 90 | if !ok { |
| 91 | return nil, false |
| 92 | } |
| 93 | out := append(CSS(), '\n') |
| 94 | return append(out, accent...), true |
| 95 | } |
| 96 | |
| 97 | // Hex converts an OKLCH colour to sRGB hex, taking chroma down in small |
| 98 | // steps until the colour fits in sRGB, so the hue and lightness stay true |
| 99 | // and only the saturation gives. (Clipping each channel instead would |
| 100 | // shift the hue, which is the one thing the ring promises.) |
| 101 | func Hex(L, C, h float64) string { |
| 102 | for C > 0 { |
| 103 | if r, g, b := oklchToLinear(L, C, h); inGamut(r) && inGamut(g) && inGamut(b) { |
| 104 | break |
| 105 | } |
| 106 | C -= 0.002 |
| 107 | } |
| 108 | r, g, b := oklchToLinear(L, math.Max(C, 0), h) |
| 109 | return fmt.Sprintf("#%02x%02x%02x", encode(r), encode(g), encode(b)) |
| 110 | } |
| 111 | |
| 112 | func inGamut(c float64) bool { return c >= -1e-4 && c <= 1+1e-4 } |
| 113 | |
| 114 | // oklchToLinear is Björn Ottosson's OKLab to linear sRGB. |
| 115 | func oklchToLinear(L, C, h float64) (r, g, b float64) { |
| 116 | rad := h * math.Pi / 180 |
| 117 | a, bb := C*math.Cos(rad), C*math.Sin(rad) |
| 118 | l := cube(L + 0.3963377774*a + 0.2158037573*bb) |
| 119 | m := cube(L - 0.1055613458*a - 0.0638541728*bb) |
| 120 | s := cube(L - 0.0894841775*a - 1.2914855480*bb) |
| 121 | return 4.0767416621*l - 3.3077115913*m + 0.2309699292*s, |
| 122 | -1.2684380046*l + 2.6097574011*m - 0.3413193965*s, |
| 123 | -0.0041960863*l - 0.7034186147*m + 1.7076147010*s |
| 124 | } |
| 125 | |
| 126 | func cube(x float64) float64 { return x * x * x } |
| 127 | |
| 128 | func encode(c float64) int { |
| 129 | c = math.Max(0, math.Min(1, c)) |
| 130 | if c <= 0.0031308 { |
| 131 | c *= 12.92 |
| 132 | } else { |
| 133 | c = 1.055*math.Pow(c, 1/2.4) - 0.055 |
| 134 | } |
| 135 | return int(math.Round(c * 255)) |
| 136 | } |
| 137 | |