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 +```