Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Firewall (`go tool sfw check`), from [go-tools](https://github.com/spin-stack/go
panel at 30% backlight, and through each dichromacy (Machado, Oliveira and Fernandes 2009,
severity 1.0), protan and deutan also on the IPS panel. The floors are in `checks.go`, each with
its reason; they follow spin-stack's sibling CONSTRUCT.
- **Charts** (`[chart]`, `tokens.json`'s `chart`): eight series in a fixed order and three
threshold statuses, one set for both modes because Grafana draws one dashboard JSON in either
theme. Each holds 3:1 on dark's page and on light's, and each series is held apart from the
next, and each status from the others, at the meaning tier.
- **No meaning by hue alone**: a status carries a shape too (the preview's states: a dot, a
diamond, a square), since a floor for colour-vision deficiencies is a floor, not comfort.

Expand Down
68 changes: 67 additions & 1 deletion cmd/palette/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ var tiers = map[string][2]float64{
"line": {6, 4}, // a 1px border against the surface outside it
"state": {10, 6}, // selected against not selected
"hover": {3, 2}, // a step under the pointer
"meaning": {20, 10}, // hue that carries meaning: status against status and against the accent
"meaning": {20, 10}, // hue that carries meaning: status against status and against the accent,
// a chart's series against the one drawn next to it, and a chart's statuses against each other
}

// meaningTritan is the floor for meaning through tritanopia, where blue and green are one: a
Expand Down Expand Up @@ -153,6 +154,11 @@ func holds(p palette) ([]result, error) {
return nil, err
}
out = append(out, term...)
ch, err := chartHolds(p)
if err != nil {
return nil, err
}
out = append(out, ch...)
for _, r := range out {
if math.IsNaN(r.contrast) {
return nil, fmt.Errorf("%s %s: not a number", r.mode, r.what)
Expand Down Expand Up @@ -195,6 +201,66 @@ func terminalHolds(p palette) ([]result, error) {
return out, nil
}

// chartHolds is the chart's colours, one set for both modes: each a line or a fill that holds
// 3:1 on either mode's page, each series apart from the one drawn next to it, and each status
// apart from the others.
func chartHolds(p palette) ([]result, error) {
series, statuses, err := p.Chart.colors()
if err != nil {
return nil, fmt.Errorf("chart: %w", err)
}
dark, err := colors(p.Dark)
if err != nil {
return nil, err
}
light, err := colors(p.Light)
if err != nil {
return nil, err
}
var out []result
hold := func(what string, fg rgb) {
for _, page := range []struct {
mode string
c rgb
}{{"dark", dark["page"]}, {"light", light["page"]}} {
r := result{mode: page.mode, kind: "non-text", what: what + " on page", contrast: contrast(fg, page.c), floor: nonText}
r.ok = r.contrast >= nonText
out = append(out, r)
}
}
apart := func(tier, what string, a, b rgb) {
f := tiers[tier]
r := result{mode: "chart", kind: tier, what: what, distance: map[string]float64{}, floor: f[0], worst: f[1], ok: true}
for _, v := range views {
d := deltaE00(v.fn(a.linear()).lab(), v.fn(b.linear()).lab())
r.distance[v.name] = d
floor := f[1]
if v.name == "drawn" {
floor = f[0]
}
if d < floor {
r.ok = false
}
}
out = append(out, r)
}
for i, c := range series {
hold(fmt.Sprintf("series %d", i+1), c)
if i > 0 {
apart("meaning", fmt.Sprintf("series %d | series %d", i, i+1), series[i-1], c)
}
}
for _, s := range chartStatuses {
hold(s, statuses[s])
}
for i, a := range chartStatuses {
for _, b := range chartStatuses[i+1:] {
apart("meaning", a+" | "+b, statuses[a], statuses[b])
}
}
return out, nil
}

// colors reads a mode's roles by name.
func colors(m mode) (map[string]rgb, error) {
named := map[string]string{
Expand Down
31 changes: 31 additions & 0 deletions cmd/palette/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,41 @@ func (t terminal) colors() (map[string]rgb, error) {
return parseNamed(named)
}

// chart is the colours a chart draws with, one set for both modes (palette.toml says why).
type chart struct {
Series []string
Good, Warning, Critical string
}

// chartStatuses are a threshold's steps, in the order a value climbs through them.
var chartStatuses = []string{"good", "warning", "critical"}

// colors is the series in their order and the statuses by name. Fewer than two series is no
// palette: a chart with one series takes the first, and a palette of one was never checked apart.
func (c chart) colors() ([]rgb, map[string]rgb, error) {
if len(c.Series) < 2 {
return nil, nil, fmt.Errorf("%d series, want at least two", len(c.Series))
}
series := make([]rgb, 0, len(c.Series))
for i, hex := range c.Series {
v, err := parseHex(hex)
if err != nil {
return nil, nil, fmt.Errorf("series %d: %w", i+1, err)
}
series = append(series, v)
}
statuses, err := parseNamed(map[string]string{"good": c.Good, "warning": c.Warning, "critical": c.Critical})
if err != nil {
return nil, nil, err
}
return series, statuses, nil
}

type palette struct {
Brand map[string]string
Dark mode
Light mode
Chart chart
// A terminal's roles and its sixteen ANSI colours, the same in either theme.
Terminal terminal
Tint struct{ Hover, Selected, Scrim, Fill, Badge float64 }
Expand Down
15 changes: 14 additions & 1 deletion cmd/palette/outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func outputs(p palette, results []result) (map[string][]byte, error) {
files := map[string][]byte{}

j, err := json.MarshalIndent(map[string]any{
"brand": p.Brand, "dark": hexes(dark), "light": hexes(light), "terminal": hexes(term),
"brand": p.Brand, "dark": hexes(dark), "light": hexes(light), "terminal": hexes(term), "chart": chartJSON(p.Chart),
"tint": p.Tint, "type": p.Type, "layout": p.Layout, "controls": p.Controls, "elevation": p.Elevation, "motion": p.Motion,
}, "", " ")
if err != nil {
Expand All @@ -42,6 +42,19 @@ func outputs(p palette, results []result) (map[string][]byte, error) {

const header = "/* Written by `task palette` from palette.toml (spin-stack/brand). Edit palette.toml, not this file. */\n"

// chartJSON is the chart's colours as tokens.json carries them: the series in their order, and
// each status by name, in the case palette.toml writes them.
func chartJSON(c chart) map[string]any {
series := make([]string, 0, len(c.Series))
for _, s := range c.Series {
series = append(series, strings.ToUpper(s))
}
return map[string]any{
"series": series, "good": strings.ToUpper(c.Good),
"warning": strings.ToUpper(c.Warning), "critical": strings.ToUpper(c.Critical),
}
}

func hexes(c map[string]rgb) map[string]string {
out := map[string]string{}
for k, v := range c {
Expand Down
2 changes: 1 addition & 1 deletion dist/perception.js

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions dist/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@
"mint": "#5FE3D6",
"navy": "#111827"
},
"chart": {
"critical": "#D04A9E",
"good": "#2F9A5C",
"series": [
"#2F6BE3",
"#D9622B",
"#1C9E8C",
"#B58300",
"#C94F9B",
"#4F9A3A",
"#7A5FE0",
"#D24B57"
],
"warning": "#B07C00"
},
"controls": {
"Sm": 28,
"Md": 32,
Expand Down
22 changes: 22 additions & 0 deletions palette.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ bright_magenta = "#EBB2F7"
bright_cyan = "#5FE3D6"
bright_white = "#FFFFFF"

# Charts: the installation's Grafana dashboards, the only charts Spinbox draws. One set for both
# modes, because a dashboard is one JSON that Grafana draws in whichever theme the viewer chose,
# and a colour in it is one hex: each holds 3:1 as a line on dark's page and on light's (WCAG
# 1.4.11), which puts them all in the middle band of lightness. Series are given in this order,
# never cycled - a ninth series is folded into "other", not a generated hue - and the first is the
# mark's blue. The statuses are a threshold's steps (good, warning, critical) and are never a
# series: a value in one is always printed beside it, so the hue is never alone.
[chart]
series = [
"#2F6BE3", # the mark's blue
"#D9622B", # orange
"#1C9E8C", # teal, the mint's hue darkened into the band
"#B58300", # ochre
"#C94F9B", # magenta
"#4F9A3A", # green
"#7A5FE0", # violet
"#D24B57", # red
]
good = "#2F9A5C"
warning = "#B07C00"
critical = "#D04A9E" # magenta-leaning, as error is: a deutan tells it from good by its blue

# The accent over a surface, for what is selected or hovered on the site (the current page in the
# sidebar, a hovered link): an alpha, so one value serves every surface. The label on a tint is
# accent_text. The dashboard keeps the accent for its one primary action and the focus ring: its
Expand Down
Loading