// Package theme is the site and code themes a reader may pick on /me:
// hunk's editor themes (the VS Code themes Shiki bundles), each turned
// into quarry's own palette tokens by gen/main.go.
//
// A theme changes nothing but the values of the tokens paletteCSS
// declares, so no page needs to know one exists. The site theme sets
// them on the root; the code theme sets the code ones on the elements
// that draw code (.chroma, which every diff and file view carries, the
// pikchr source panel and the merge view), so a Gruvbox page can carry
// another editor's colours in its files and diffs.
package theme
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
)
// Theme is one editor theme as quarry's tokens.
type Theme struct {
ID string
Name string
Dark bool
Tokens []Token
}
// Token is one palette variable and its value.
type Token struct{ Name, Value string }
// Value is the theme's value for one token, or "".
func (t Theme) Value(name string) string {
for _, k := range t.Tokens {
if k.Name == name {
return k.Value
}
}
return ""
}
// ByID finds a theme by its id ("gruvbox-dark-hard").
func ByID(id string) (Theme, bool) {
for _, t := range All {
if t.ID == id {
return t, true
}
}
return Theme{}, false
}
// CodeTokens are the tokens a code theme sets: the code ground and
// ink, the diff colours and the syntax colours. The rest (links, rules,
// the page ground) stay the site's.
var CodeTokens = []string{
"--code-block", "--fg", "--muted", "--add", "--del",
"--add-row", "--del-row", "--add-mark", "--del-mark", "--mark",
"--syn-kw", "--syn-str", "--syn-num", "--syn-fn", "--syn-type",
"--syn-com", "--syn-builtin", "--syn-attr",
"--meld-cf", "--meld-cf-bg", "--meld-chg", "--meld-chg-bg", "--meld-ins", "--meld-ins-bg",
}
// codeSelector is every element that draws code: a diff and a file view
// are .chroma, and so is a highlighted block in markdown.
const codeSelector = ".chroma, .pikchr-src, .meld-view"
// SiteCSS is the sheet for a site theme. :root:root outranks both of
// paletteCSS's dark selectors, which are as specific as :root plus one
// attribute and come earlier, so a chosen theme wins over the
// System / Light / Dark choice rather than mixing with it.
func SiteCSS(t Theme) string { return ":root:root{" + siteBody(t) + "}\n" }
// CodeCSS is the sheet for a code theme.
func CodeCSS(t Theme) string { return codeSelector + "{" + codeBody(t) + "}\n" }
// siteBody is every token of t and its colour scheme.
func siteBody(t Theme) string {
var b strings.Builder
for _, k := range t.Tokens {
b.WriteString(k.Name + ":" + k.Value + ";")
}
b.WriteString("color-scheme:" + scheme(t))
return b.String()
}
// codeBody is t's code tokens and its colour scheme.
func codeBody(t Theme) string {
var b strings.Builder
for _, name := range CodeTokens {
if v := t.Value(name); v != "" {
b.WriteString(name + ":" + v + ";")
}
}
b.WriteString("color-scheme:" + scheme(t))
return b.String()
}
func scheme(t Theme) string {
if t.Dark {
return "dark"
}
return "light"
}
// SiteURL and CodeURL are a theme's sheets. Each carries its content's
// hash, so it is served immutable and a regenerated theme is a new URL.
func SiteURL(t Theme) string { return assetURL("site", SiteCSS(t), t.ID) }
func CodeURL(t Theme) string { return assetURL("code", CodeCSS(t), t.ID) }
func assetURL(kind, css, id string) string {
sum := sha256.Sum256([]byte(css))
return "/assets/theme-" + kind + "-" + id + "." + hex.EncodeToString(sum[:6]) + ".css"
}
// Assets is every sheet by its URL, for the server to register: each
// family's, and each single theme's, which a page cached before themes
// came in pairs may still link.
func Assets() map[string]string {
m := make(map[string]string, 2*(len(All)+len(Families)))
for _, t := range All {
m[SiteURL(t)] = SiteCSS(t)
m[CodeURL(t)] = CodeCSS(t)
}
for _, f := range Families {
m[FamilySiteURL(f)] = FamilySiteCSS(f)
m[FamilyCodeURL(f)] = FamilyCodeCSS(f)
}
return m
}
// Contrast is the WCAG contrast ratio of two #rrggbb colours.
func Contrast(a, b string) float64 {
x, y := luminance(a), luminance(b)
return (math.Max(x, y) + 0.05) / (math.Min(x, y) + 0.05)
}
func luminance(h string) float64 {
c := RGB(h)
var l [3]float64
for i, v := range c {
f := v / 255
if f <= 0.03928 {
l[i] = f / 12.92
} else {
l[i] = math.Pow((f+0.055)/1.055, 2.4)
}
}
return 0.2126*l[0] + 0.7152*l[1] + 0.0722*l[2]
}
// RGB reads #rgb, #rrggbb or #rrggbbaa (the alpha ignored).
func RGB(h string) [3]float64 {
h = strings.TrimPrefix(h, "#")
if len(h) == 3 || len(h) == 4 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
var c [3]float64
for i := range c {
if len(h) < 2*i+2 {
break
}
v, _ := strconv.ParseUint(h[2*i:2*i+2], 16, 8)
c[i] = float64(v)
}
return c
}
// Hex writes a colour as #rrggbb.
func Hex(c [3]float64) string {
var b strings.Builder
b.WriteByte('#')
for _, v := range c {
fmt.Fprintf(&b, "%02x", int(math.Round(math.Max(0, math.Min(255, v)))))
}
return b.String()
}