package theme import ( "fmt" "math" "strings" ) // App is one product of the suite and its place on Calma's colour ring. type App struct { Slug string // closed set; see Apps Name string // the product's name, as a person reads it Hue float64 // OKLCH hue, degrees } // apps is the ring. Every app's colour is the same OKLCH lightness and // chroma at a different hue, so no app is louder than another; the hues // are spaced evenly to the eye rather than to the protractor (34-50°, // with the widest gap across the yellow-greens, which go muddy at this // lightness). Paul, 2026-09-29, with Mail and Files still to come. // // Oficina and Docs share 262 on purpose: the suite's own accent, used // where no app is in charge (sign-in, the team's home), is the ring's blue, // and Docs is where that blue started. Oficina's mark is every hue, not // this one. // // This table is the one source of the suite's colours: the accent blocks // below, and brand/icons.json's hues (brand_test checks they agree). var apps = []App{ {"oficina", "Oficina", 262}, {"docs", "Docs", 262}, {"memoria", "Memoria", 302}, {"mail", "Mail", 342}, {"calendar", "Calendar", 20}, {"slides", "Slides", 56}, {"fichas", "Fichas", 90}, {"sheets", "Sheets", 140}, {"files", "Files", 180}, {"calls", "Calls", 220}, } // Apps returns the ring, Oficina first. func Apps() []App { return append([]App(nil), apps...) } // Lookup returns the app for slug. The set is closed: a slug that is not // in the ring is not a theme, whatever else it is. func Lookup(slug string) (App, bool) { for _, a := range apps { if a.Slug == slug { return a, true } } return App{}, false } // The accent's four tokens, as OKLCH lightness and chroma; the hue is the // app's. Light: a deep accent that carries white text; dark: a light one // that carries near-black. Chroma is the most each hue can hold at that // lightness inside sRGB, up to these values (see Hex). var accentSpec = [2]struct{ accent, strong, soft [2]float64 }{ {accent: [2]float64{0.49, 0.15}, strong: [2]float64{0.43, 0.14}, soft: [2]float64{0.955, 0.025}}, {accent: [2]float64{0.76, 0.12}, strong: [2]float64{0.84, 0.09}, soft: [2]float64{0.29, 0.045}}, } // AccentCSS is the block that re-declares the accent for one app. It // follows estilo.css in the cascade, so the app's colour wins everywhere // the suite's would have shown. func AccentCSS(slug string) ([]byte, bool) { a, ok := Lookup(slug) if !ok { return nil, false } hex := func(lc [2]float64) string { return Hex(lc[0], lc[1], a.Hue) } l, d := accentSpec[0], accentSpec[1] var b strings.Builder fmt.Fprintf(&b, "/* The accent for %s: hue %g on Calma's ring (estilo theme/apps.go). */\n", a.Name, a.Hue) b.WriteString(":root {\n") fmt.Fprintf(&b, " --rst-accent: light-dark(%s, %s);\n", hex(l.accent), hex(d.accent)) fmt.Fprintf(&b, " --rst-accent-strong: light-dark(%s, %s);\n", hex(l.strong), hex(d.strong)) fmt.Fprintf(&b, " --rst-accent-soft: light-dark(%s, %s);\n", hex(l.soft), hex(d.soft)) b.WriteString(" --rst-on-accent: light-dark(#ffffff, #111418);\n") b.WriteString("}\n") return []byte(b.String()), true } // ForApp is the whole theme for one app: estilo.css with that app's // accent after it. Serve it as the app's /static/theme.css. func ForApp(slug string) ([]byte, bool) { accent, ok := AccentCSS(slug) if !ok { return nil, false } out := append(CSS(), '\n') return append(out, accent...), true } // Hex converts an OKLCH colour to sRGB hex, taking chroma down in small // steps until the colour fits in sRGB, so the hue and lightness stay true // and only the saturation gives. (Clipping each channel instead would // shift the hue, which is the one thing the ring promises.) func Hex(L, C, h float64) string { for C > 0 { if r, g, b := oklchToLinear(L, C, h); inGamut(r) && inGamut(g) && inGamut(b) { break } C -= 0.002 } r, g, b := oklchToLinear(L, math.Max(C, 0), h) return fmt.Sprintf("#%02x%02x%02x", encode(r), encode(g), encode(b)) } func inGamut(c float64) bool { return c >= -1e-4 && c <= 1+1e-4 } // oklchToLinear is Björn Ottosson's OKLab to linear sRGB. func oklchToLinear(L, C, h float64) (r, g, b float64) { rad := h * math.Pi / 180 a, bb := C*math.Cos(rad), C*math.Sin(rad) l := cube(L + 0.3963377774*a + 0.2158037573*bb) m := cube(L - 0.1055613458*a - 0.0638541728*bb) s := cube(L - 0.0894841775*a - 1.2914855480*bb) return 4.0767416621*l - 3.3077115913*m + 0.2309699292*s, -1.2684380046*l + 2.6097574011*m - 0.3413193965*s, -0.0041960863*l - 0.7034186147*m + 1.7076147010*s } func cube(x float64) float64 { return x * x * x } func encode(c float64) int { c = math.Max(0, math.Min(1, c)) if c <= 0.0031308 { c *= 12.92 } else { c = 1.055*math.Pow(c, 1/2.4) - 0.055 } return int(math.Round(c * 255)) }