socle

Merge branch 'first-impressions' into stand-variety

commit 58cb21c98bd702ca19bf7da7a3bb3c8eb7059b8b · parent 245d322a2e · parent 77f351b07a · browse this tree
Ximianth, 2026-10-30 17:30:01 UTC

diff --git a/README.md b/README.md
index 26e9876..c84d9b9 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,16 @@ repos:
   else from the machine hostname --
   but a published socle usually wants to say its own name, not its machine's.
 
+- `hue` (optional) picks the accent's place on the color wheel (0..359),
+  keeping the stock light/dark lightness tuning and its contrast guarantees.
+  When unset, the hue hashes from the sitename: a nice diversifier across hosts,
+  but it cannot be argued with, and some names draw ugly.
+
+- `accent` (optional) takes the wheel entirely: an exact CSS color for both schemes,
+  or a `{light: ..., dark: ...}` pair.  No tuning applies -- picking exact colors
+  means owning their contrast.  The favicon wears the light color.
+  Setting both `hue` and `accent` is a config error.
+
 - `baseurl` (optional) is the canonical outside address of this socle:
   an absolute http(s) URL naming only a host.
   When set, it becomes the displayed clone URL
diff --git a/config.go b/config.go
index 0548343..67b4171 100644
--- a/config.go
+++ b/config.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"regexp"
 	"sort"
 	"strings"
 
@@ -29,6 +30,21 @@ type config struct {
 	// host label when one is configured, else from the machine hostname --
 	// but a published socle usually wants to say its own name, not its machine's.
 	Sitename string `yaml:"sitename"`
+	// Hue picks the accent's place on the color wheel, 0..359 (0 red, 120 green,
+	// 240 blue), keeping the stock light/dark lightness tuning and its contrast
+	// guarantees.  When unset, the hue hashes from the theme seed -- a nice
+	// diversifier across hosts, but it cannot be argued with, and some names
+	// draw ugly.  For exact colors instead, see Accent; setting both is an error.
+	Hue *int `yaml:"hue"`
+	// Accent takes the wheel entirely: explicit accent color(s), bypassing all
+	// tuning -- whoever picks exact colors owns their contrast.  Either one CSS
+	// color for both schemes, or a {light, dark} pair:
+	//
+	//   accent: "#7a5af5"
+	//   accent: { light: "hsl(260 60% 35%)", dark: "hsl(260 70% 75%)" }
+	//
+	// The favicon wears the light color (or the single one).
+	Accent *accentConfig `yaml:"accent"`
 	// BaseURL is the canonical outside address of this socle, e.g. "https://code.example":
 	// scheme and host only, since socle's URL space is rooted at "/".
 	// When set, it becomes the displayed clone URL and enables the go-get vanity-import
@@ -40,6 +56,38 @@ type config struct {
 	Repos map[string]repoConfig `yaml:"repos"`
 }
 
+// accentConfig is an explicit accent: one CSS color per scheme.
+// In YAML it may be a bare string (one color, both schemes) or a {light, dark} map.
+type accentConfig struct {
+	Light string `yaml:"light"`
+	Dark  string `yaml:"dark"`
+}
+
+func (a *accentConfig) UnmarshalYAML(value *yaml.Node) error {
+	if value.Kind == yaml.ScalarNode {
+		var one string
+		if err := value.Decode(&one); err != nil {
+			return err
+		}
+		a.Light, a.Dark = one, one
+		return nil
+	}
+	// A distinct local type sidesteps UnmarshalYAML recursion on the map form.
+	type plain accentConfig
+	var p plain
+	if err := value.Decode(&p); err != nil {
+		return err
+	}
+	*a = accentConfig(p)
+	return nil
+}
+
+// validAccentColor keeps config colors from breaking the stylesheet they land in:
+// a conservative charset covering hex, rgb()/hsl()/oklch(), and named colors --
+// and nothing that can terminate a declaration or smuggle one in.
+// (The config is operator-trusted; this guards against typos, not attackers.)
+var validAccentColor = regexp.MustCompile(`^[A-Za-z0-9#%(),./ -]+$`)
+
 // loadConfig reads and validates the YAML config file at the given path.
 // Relative repo paths are resolved against the config file's directory,
 // so a config travels with the tree it describes.
@@ -61,6 +109,23 @@ func loadConfig(path string) (*config, error) {
 	}
 
 	cfg.Sitename = strings.TrimSpace(cfg.Sitename)
+	if cfg.Hue != nil && (*cfg.Hue < 0 || *cfg.Hue > 359) {
+		// No modulo forgiveness: 3600 is a typo, not a wish.
+		return nil, fmt.Errorf("config %s: hue %d is not on the wheel (0..359)", path, *cfg.Hue)
+	}
+	if cfg.Hue != nil && cfg.Accent != nil {
+		return nil, fmt.Errorf("config %s: hue and accent are both set; pick one (hue keeps the stock tuning, accent takes the wheel)", path)
+	}
+	if cfg.Accent != nil {
+		for name, c := range map[string]string{"light": cfg.Accent.Light, "dark": cfg.Accent.Dark} {
+			if strings.TrimSpace(c) == "" {
+				return nil, fmt.Errorf("config %s: accent is missing its %q color (a bare string sets both schemes; a map needs both)", path, name)
+			}
+			if !validAccentColor.MatchString(c) {
+				return nil, fmt.Errorf("config %s: accent %s color %q contains characters that have no business in a CSS color", path, name, c)
+			}
+		}
+	}
 	if cfg.BaseURL != "" {
 		normalized, err := validateBaseURL(cfg.BaseURL)
 		if err != nil {
diff --git a/serve.go b/serve.go
index bf11a3a..fc222eb 100644
--- a/serve.go
+++ b/serve.go
@@ -61,11 +61,29 @@ func buildFace(cfg *config, seedOverride string) (face, error) {
 	if seed == "" {
 		seed = name
 	}
-	th := display.NewTheme(seed)
+	// Resolve the accent to colors here, once; everything downstream takes colors.
+	// Three sources, most explicit wins (config validation forbids setting two):
+	// an exact accent, a chosen hue through the stock tuning, or the hashed seed.
+	var light, dark, faviconInk string
+	switch {
+	case cfg.Accent != nil:
+		light, dark = cfg.Accent.Light, cfg.Accent.Dark
+		faviconInk = light
+	default:
+		hue := display.ThemeHue(seed)
+		if cfg.Hue != nil {
+			hue = *cfg.Hue
+		}
+		light, dark = display.AccentColors(hue)
+		// The tab is unknown paper: neither scheme's tuned lightness suits it,
+		// so the derived favicon takes the hue at a middle lightness instead.
+		faviconInk = fmt.Sprintf("hsl(%d 70%% 50%%)", hue)
+	}
+	th := display.NewThemeColors(seed, light, dark)
 	return face{
 		siteName: name,
 		theme:    th,
-		sketch:   sketchAssets(seed),
+		sketch:   sketchAssets(seed, faviconInk),
 		style:    buildStyle(th),
 	}, nil
 }
diff --git a/serve_test.go b/serve_test.go
index f0fde18..e07b605 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -365,3 +365,43 @@ func TestFaceIdentityResolution(t *testing.T) {
 		})
 	}
 }
+
+// TestFaceAccentResolution pins how the accent resolves to colors:
+// an exact accent lands verbatim (and the favicon wears its light color),
+// a chosen hue goes through the stock tuning, and the hashed seed is the default.
+func TestFaceAccentResolution(t *testing.T) {
+	t.Parallel()
+	hue := 150
+	for _, c := range []struct {
+		name        string
+		cfg         config
+		wantCSS     string // a substring of the theme's :root CSS
+		wantFavicon string // a substring of the favicon SVG
+	}{
+		{name: "exact accent, both schemes and favicon",
+			cfg:         config{Sitename: "test", Accent: &accentConfig{Light: "#7a5af5", Dark: "#a78bfa"}},
+			wantCSS:     "--accent: #7a5af5",
+			wantFavicon: `stroke="#7a5af5"`},
+		{name: "chosen hue through the stock tuning",
+			cfg:         config{Sitename: "test", Hue: &hue},
+			wantCSS:     "--accent: hsl(150 70%",
+			wantFavicon: `stroke="hsl(150 70% 50%)"`},
+		{name: "hashed seed by default",
+			cfg:         config{Sitename: "test"},
+			wantCSS:     fmt.Sprintf("--accent: hsl(%d 70%%", display.ThemeHue("test")),
+			wantFavicon: fmt.Sprintf(`stroke="hsl(%d 70%% 50%%)"`, display.ThemeHue("test"))},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			fc, err := buildFace(&c.cfg, "")
+			if err != nil {
+				t.Fatal(err)
+			}
+			if css := string(fc.theme.CSS); !strings.Contains(css, c.wantCSS) {
+				t.Errorf("theme CSS lacks %q:\n%s", c.wantCSS, css)
+			}
+			if svg := string(fc.sketch["favicon.svg"]); !strings.Contains(svg, c.wantFavicon) {
+				t.Errorf("favicon lacks %q", c.wantFavicon)
+			}
+		})
+	}
+}
diff --git a/sketch.go b/sketch.go
index 551b15c..1efb1c6 100644
--- a/sketch.go
+++ b/sketch.go
@@ -5,8 +5,6 @@ import (
 	"hash/fnv"
 	"math"
 	"strings"
-
-	"vitrine/display"
 )
 
 // This file generates the sketch assets: small SVGs of hand-wobbled strokes, boxes,
@@ -133,7 +131,7 @@ var sketchPalettes = []sketchPalette{
 // sketchAssets builds every sketch SVG for one theme seed, keyed by filename
 // (e.g. "frame-light.svg").  Each asset seeds its own PRNG with seed+purpose,
 // so assets wobble independently and adding one never reshuffles the others.
-func sketchAssets(seed string) map[string][]byte {
+func sketchAssets(seed string, faviconInk string) map[string][]byte {
 	m := map[string][]byte{}
 	for _, p := range sketchPalettes {
 		m["frame-"+p.scheme+".svg"] = frameSVG(seed, p)
@@ -148,7 +146,7 @@ func sketchAssets(seed string) map[string][]byte {
 			m["circle-"+v+"-"+p.scheme+".svg"] = circleSVG(seed+"/"+v, p)
 		}
 	}
-	m["favicon.svg"] = faviconSVG(seed)
+	m["favicon.svg"] = faviconSVG(seed, faviconInk)
 	return m
 }
 
@@ -348,13 +346,11 @@ func plinthSVG(seed string, p sketchPalette) []byte {
 	return []byte(sb.String())
 }
 
-// faviconSVG is the tab's identity mark: the pedestal glyph in the theme hue.
+// faviconSVG is the tab's identity mark: the pedestal glyph in the accent ink.
 // One asset, not per-scheme: a favicon renders on tab chrome whose color socle
-// cannot know, so instead of the accent's paper-tuned per-scheme lightnesses it
-// wears the same hue at a middle lightness that stays visible on both.
+// cannot know, so the caller picks one ink that must carry both (see buildFace).
 // Strokes are proportionally bold: at tab size the usual hairlines would vanish.
-func faviconSVG(seed string) []byte {
-	ink := fmt.Sprintf("hsl(%d 70%% 50%%)", display.ThemeHue(seed))
+func faviconSVG(seed string, ink string) []byte {
 	rng := newSketchRand(seed + "/favicon")
 	var sb strings.Builder
 	svgOpen(&sb, 64, 64)
diff --git a/sketch_test.go b/sketch_test.go
index 3c99ffb..abe3898 100644
--- a/sketch_test.go
+++ b/sketch_test.go
@@ -16,7 +16,13 @@ func TestSketchSnapshots(t *testing.T) {
 	if err != nil {
 		t.Fatalf("reading fixtures: %v", err)
 	}
-	assets := sketchAssets("test")
+	// Assets come via buildFace so the pinned bytes track the real resolution
+	// (seed-hashed hue, derived favicon ink) rather than restating its formulas.
+	fc, err := buildFace(&config{Sitename: "test"}, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	assets := fc.sketch
 	names := make([]string, 0, len(assets))
 	for name := range assets {
 		names = append(names, name)
diff --git a/testdata/config.md b/testdata/config.md
index 3eac772..e3c4145 100644
--- a/testdata/config.md
+++ b/testdata/config.md
@@ -247,3 +247,81 @@ repos:
 ```text
 error: config $BASE/config.yaml: repo "everything": path "/" is the filesystem root; refusing
 ```
+The accent knobs: a hue must be on the wheel.
+
+[testmark]:# (err-hue-range/config.yaml)
+```yaml
+hue: 3600
+repos:
+  parnass:
+    path: "%"
+```
+
+[testmark]:# (err-hue-range/result)
+```text
+error: config $BASE/config.yaml: hue 3600 is not on the wheel (0..359)
+```
+
+Hue and accent together is an ambiguity, so it is refused.
+
+[testmark]:# (err-hue-and-accent/config.yaml)
+```yaml
+hue: 150
+accent: "#7a5af5"
+repos:
+  parnass:
+    path: "%"
+```
+
+[testmark]:# (err-hue-and-accent/result)
+```text
+error: config $BASE/config.yaml: hue and accent are both set; pick one (hue keeps the stock tuning, accent takes the wheel)
+```
+
+An accent map must carry both schemes.
+
+[testmark]:# (err-accent-half/config.yaml)
+```yaml
+accent:
+  light: "#7a5af5"
+repos:
+  parnass:
+    path: "%"
+```
+
+[testmark]:# (err-accent-half/result)
+```text
+error: config $BASE/config.yaml: accent is missing its "dark" color (a bare string sets both schemes; a map needs both)
+```
+
+Accent colors are charset-checked, so a typo cannot break the stylesheet they land in.
+
+[testmark]:# (err-accent-charset/config.yaml)
+```yaml
+accent: "#7a5af5; } body { display: none"
+repos:
+  parnass:
+    path: "%"
+```
+
+[testmark]:# (err-accent-charset/result)
+```text
+error: config $BASE/config.yaml: accent light color "#7a5af5; } body { display: none" contains characters that have no business in a CSS color
+```
+
+A good accent, in the map form.
+
+[testmark]:# (good-accent-map/config.yaml)
+```yaml
+accent:
+  light: "hsl(260 60% 35%)"
+  dark: "hsl(260 70% 75%)"
+repos:
+  parnass:
+    path: "%"
+```
+
+[testmark]:# (good-accent-map/result)
+```text
+repo parnass: path=$BASE/parnass
+```