oficina / estilo Public

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

Plain git — no account needed to clone.

Download

Download this file

1package theme
2
3import (
4 "fmt"
5 "math"
6 "strings"
7)
8
9// App is one product of the suite and its place on Calma's colour ring.
10type 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).
29var 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.
43func 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.
47func 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).
60var 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.
68func 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.
88func 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.)
101func 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
112func inGamut(c float64) bool { return c >= -1e-4 && c <= 1+1e-4 }
113
114// oklchToLinear is Björn Ottosson's OKLab to linear sRGB.
115func 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
126func cube(x float64) float64 { return x * x * x }
127
128func 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