Skip to content
quarry Artifact [3887d0a2b5]
Login

Artifact [3887d0a2b5]

Artifact 3887d0a2b5f495c22124f203fe040b6979db62047d747f1810e51e0b0281b377:


// 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 {
	var b strings.Builder
	b.WriteString(":root:root{")
	for _, k := range t.Tokens {
		b.WriteString(k.Name + ":" + k.Value + ";")
	}
	b.WriteString("color-scheme:" + scheme(t) + "}\n")
	return b.String()
}

// CodeCSS is the sheet for a code theme.
func CodeCSS(t Theme) string {
	var b strings.Builder
	b.WriteString(codeSelector + "{")
	for _, name := range CodeTokens {
		if v := t.Value(name); v != "" {
			b.WriteString(name + ":" + v + ";")
		}
	}
	b.WriteString("color-scheme:" + scheme(t) + "}\n")
	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 theme sheet by its URL, for the server to register.
func Assets() map[string]string {
	m := make(map[string]string, 2*len(All))
	for _, t := range All {
		m[SiteURL(t)] = SiteCSS(t)
		m[CodeURL(t)] = CodeCSS(t)
	}
	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()
}