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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ through protanopia, deuteranopia or tritanopia, with every pair the palette is h

| Source | What it makes |
|---|---|
| `palette.toml` | `dist/`: `tokens.css` (every role as `--spin-*`), `starlight.css` (the website's `--sl-color-*`), `dashboard.css` (shadcn's HSL variables, light on `:root`, dark on `.dark`), `tokens.json`, and `perception.js` and `simulate.svg` for the preview |
| `palette.toml` | `dist/`: `tokens.css` (every role as `--spin-*`), `starlight.css` (the website's `--sl-color-*`), `dashboard.css` (shadcn's HSL variables, light on `:root`, dark on `.dark`, and the dashboard's radius, scrim, dense type scale, heading tracking and motion), `tokens.json`, and `perception.js` and `simulate.svg` for the preview |
| spin's `dashboard/e2e/site.spec.ts` | `shots/`: the app as it is, screenshots spin's dashboard suite takes of its e2e stack, with the release and commit of the spin tree that took them (`task screenshots SHOTS=<dir>`); the website copies them from here |
| spin's `dashboard/e2e/catalog.spec.ts` | `screens/`: every route, tab, dialog and menu of the app in both themes at 1440 and 390 px, and what each draws that this palette does not have - a colour, a face, a size, a radius, contrast, overflow, a target too small (`task screens SCREENS=<dir>`). The catalog and the audit are committed; the images, hundreds a release, are an archive on a release of this repository that `screens.json` names and Pages unpacks. [See them](https://spin-stack.github.io/brand/screens.html) |
| `assets/mark.png` | `logos/`: the stack of disks, cut from the mark as it was drawn (1024 px on a clear ground): the tile on the brand's navy (512, 256; the touch icon at 180, the favicon at 64 and 32) for a dark ground, and clear (512, 256) for a light one. Each is the drawing reduced; a size it would have to enlarge fails |
Expand Down
17 changes: 9 additions & 8 deletions cmd/palette/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@ type palette struct {
Brand map[string]string
Dark mode
Light mode
Tint struct{ Hover, Selected float64 }
Tint struct{ Hover, Selected, Scrim float64 }
Type struct {
Sans, Mono string
FallbackSans string `toml:"fallback_sans"`
FallbackMono string `toml:"fallback_mono"`
ReadingSize int `toml:"reading_size"`
DenseSize int `toml:"dense_size"`
LineHeight float64 `toml:"line_height"`
HeadingWeight int `toml:"heading_weight"`
HeadingTracking float64 `toml:"heading_tracking"`
FallbackSans string `toml:"fallback_sans"`
FallbackMono string `toml:"fallback_mono"`
ReadingSize int `toml:"reading_size"`
DenseSize int `toml:"dense_size"`
DenseScale map[string][2]int `toml:"dense_scale"`
LineHeight float64 `toml:"line_height"`
HeadingWeight int `toml:"heading_weight"`
HeadingTracking float64 `toml:"heading_tracking"`
}
Layout struct {
Measure, Sidebar int
Expand Down
54 changes: 42 additions & 12 deletions cmd/palette/outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,40 +159,70 @@ func dashboardCSS(p palette, dark, light map[string]rgb) string {
if isDark {
card = c["surface"]
}
// The label on a fill: white, or the theme's darkest ink - the dark page, the light text -
// whichever reads on it.
ink := c["text"]
if isDark {
ink = c["page"]
}
on := func(fill rgb) rgb {
if contrast(c["page"], fill) >= contrast(rgb{1, 1, 1}, fill) {
if isDark {
return c["page"]
}
return c["text"]
if contrast(ink, fill) >= contrast(rgb{1, 1, 1}, fill) {
return ink
}
return rgb{1, 1, 1}
}
// Hover and the navigation's selection are neutral: the accent is the primary action's
// and the focus ring's alone.
neutral := c["surface"]
if isDark {
neutral = c["raised"]
}
vars := [][2]string{
{"background", hsl(c["page"])}, {"foreground", hsl(c["text"])},
{"card", hsl(card)}, {"card-foreground", hsl(c["text"])},
{"muted", hsl(c["raised"])}, {"muted-foreground", hsl(c["muted"])},
{"border", hsl(c["border"])}, {"input", hsl(c["border_strong"])}, {"ring", hsl(c["accent"])},
{"primary", hsl(c["accent"])}, {"primary-foreground", hsl(c["on_accent"])},
{"secondary", hsl(c["raised"])}, {"secondary-foreground", hsl(c["text"])},
{"accent", hsl(c["accent"].over(c["page"], p.Tint.Selected))}, {"accent-foreground", hsl(c["accent_text"])},
{"destructive", hsl(c["destructive"])}, {"destructive-foreground", hsl(rgb{1, 1, 1})},
{"accent", hsl(neutral)}, {"accent-foreground", hsl(c["text"])},
{"destructive", hsl(c["destructive"])}, {"destructive-foreground", hsl(on(c["destructive"]))},
{"error", hsl(c["error"])}, {"label", hsl(c["label"])},
{"success", hsl(c["success"])}, {"success-foreground", hsl(on(c["success"]))},
{"warning", hsl(c["warning"])}, {"warning-foreground", hsl(on(c["warning"]))},
}
fmt.Fprintf(&b, "%s {\n", sel)
for _, v := range vars {
fmt.Fprintf(&b, "\t--%s: %s;\n", v[0], v[1])
}
if !isDark {
// The dashboard is dense - tables of runners and workspaces - and square enough that
// a table reads as one: its radius is the small one, and a chip's is 2px under it.
fmt.Fprintf(&b, "\t--radius: %grem;\n", float64(p.Layout.RadiusSm)/16)
}
b.WriteString("}\n")
}
block(":root", light, false)
block(".dark", dark, true)

// What does not change with the theme. The dashboard is dense - tables of runners and
// workspaces - and square enough that a table reads as one: every corner is the small radius.
// Its theme (dashboard/src/styles/globals.css) maps Tailwind's names onto these.
b.WriteString(":root {\n")
fmt.Fprintf(&b, "\t--radius: %gpx;\n", float64(p.Layout.RadiusSm))
// The dim behind a dialog: the mark's navy, in either theme, at the palette's scrim.
navy, err := parseHex(p.Brand["navy"])
if err != nil {
panic("palette.toml's brand.navy: " + err.Error())
}
b255 := func(v float64) int { return int(math.Round(v * 255)) }
fmt.Fprintf(&b, "\t--scrim: rgb(%d %d %d / %g);\n", b255(navy.r), b255(navy.g), b255(navy.b), p.Tint.Scrim)
fmt.Fprintf(&b, "\t--spin-font-sans: '%s', %s;\n\t--spin-font-mono: '%s', %s;\n", p.Type.Sans, p.Type.FallbackSans, p.Type.Mono, p.Type.FallbackMono)
for _, k := range []string{"xs", "sm", "base", "lg", "xl", "2xl", "3xl"} {
step, ok := p.Type.DenseScale[k]
if !ok {
panic("palette.toml's dense_scale has no " + k)
}
fmt.Fprintf(&b, "\t--spin-text-%s: %dpx;\n\t--spin-text-%s-lh: %dpx;\n", k, step[0], k, step[1])
}
fmt.Fprintf(&b, "\t--spin-heading-tracking: %gem;\n", p.Type.HeadingTracking)
fmt.Fprintf(&b, "\t--spin-duration-fast: %dms;\n\t--spin-duration: %dms;\n", p.Motion.Fast, p.Motion.Base)
fmt.Fprintf(&b, "\t--spin-duration-slow: %dms;\n\t--spin-ease: %s;\n", p.Motion.Slow, p.Motion.Ease)
b.WriteString("}\n")
return b.String()
}

Expand Down
91 changes: 91 additions & 0 deletions cmd/palette/outputs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"regexp"
"strconv"
"strings"
"testing"

"github.com/BurntSushi/toml"
)

// Every label the dashboard sets on a fill - text on a primary button, a success badge, a
// destructive one - reads at 4.5:1, in both themes, as dashboardCSS writes them from the palette.
// The rule it holds: on() once compared a fill with the light page, white, against white, and
// gave the light success and warning fills dark text at 2.9:1.
func TestTheDashboardsLabelsReadOnTheirFills(t *testing.T) {
var p palette
if _, err := toml.DecodeFile("../../palette.toml", &p); err != nil {
t.Fatal(err)
}
dark, err := colors(p.Dark)
if err != nil {
t.Fatal(err)
}
light, err := colors(p.Light)
if err != nil {
t.Fatal(err)
}
css := dashboardCSS(p, dark, light)
blocks := regexp.MustCompile(`(?s)(:root|\.dark) \{(.*?)\}`).FindAllStringSubmatch(css, -1)
var themes int
for _, blk := range blocks {
vars := map[string]rgb{}
for _, m := range regexp.MustCompile(`--([a-z-]+): ([\d.]+) ([\d.]+)% ([\d.]+)%;`).FindAllStringSubmatch(blk[2], -1) {
h, _ := strconv.ParseFloat(m[2], 64)
s, _ := strconv.ParseFloat(m[3], 64)
l, _ := strconv.ParseFloat(m[4], 64)
vars[m[1]] = fromHSL(h, s/100, l/100)
}
if len(vars) == 0 {
continue
}
themes++
for name, fg := range vars {
fill, ok := strings.CutSuffix(name, "-foreground")
if !ok || fill == "card" || fill == "muted" || fill == "accent" || fill == "secondary" {
continue
}
if c := contrast(fg, vars[fill]); c < 4.5 {
t.Errorf("%s: --%s on --%s is %.2f:1, under 4.5", blk[1], name, fill, c)
}
}
}
if themes != 2 {
t.Fatalf("found %d themes with colors in dashboard.css, want light and dark", themes)
}
}

func fromHSL(h, s, l float64) rgb {
c := (1 - abs(2*l-1)) * s
hp := h / 60
x := c * (1 - abs(mod(hp, 2)-1))
var r, g, b float64
switch {
case hp < 1:
r, g = c, x
case hp < 2:
r, g = x, c
case hp < 3:
g, b = c, x
case hp < 4:
g, b = x, c
case hp < 5:
r, b = x, c
default:
r, b = c, x
}
m := l - c/2
return rgb{r + m, g + m, b + m}
}

func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}

func mod(a, b float64) float64 {
return a - b*float64(int(a/b))
}
42 changes: 35 additions & 7 deletions dist/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@
--primary-foreground: 0.0 0.0% 100.0%;
--secondary: 214.7 40.4% 90.8%;
--secondary-foreground: 220.9 39.3% 11.0%;
--accent: 200.4 45.4% 85.5%;
--accent-foreground: 200.9 89.2% 29.0%;
--accent: 213.3 42.9% 95.9%;
--accent-foreground: 220.9 39.3% 11.0%;
--destructive: 343.9 67.4% 42.2%;
--destructive-foreground: 0.0 0.0% 100.0%;
--error: 318.8 70.3% 35.7%;
--label: 216.0 83.3% 28.2%;
--success: 149.3 57.7% 27.8%;
--success-foreground: 220.9 39.3% 11.0%;
--success-foreground: 0.0 0.0% 100.0%;
--warning: 39.1 100.0% 27.1%;
--warning-foreground: 220.9 39.3% 11.0%;
--radius: 0.25rem;
--warning-foreground: 0.0 0.0% 100.0%;
}
.dark {
--background: 220.9 39.3% 11.0%;
Expand All @@ -37,12 +38,39 @@
--primary-foreground: 220.9 39.3% 11.0%;
--secondary: 221.5 37.9% 20.2%;
--secondary-foreground: 213.8 47.1% 93.3%;
--accent: 200.9 57.7% 20.3%;
--accent-foreground: 192.7 80.8% 53.1%;
--accent: 221.5 37.9% 20.2%;
--accent-foreground: 213.8 47.1% 93.3%;
--destructive: 347.3 60.3% 47.5%;
--destructive-foreground: 0.0 0.0% 100.0%;
--error: 331.7 86.0% 72.0%;
--label: 174.1 70.2% 63.1%;
--success: 154.0 69.4% 55.1%;
--success-foreground: 220.9 39.3% 11.0%;
--warning: 40.4 100.0% 67.1%;
--warning-foreground: 220.9 39.3% 11.0%;
}
:root {
--radius: 4px;
--scrim: rgb(17 24 39 / 0.6);
--spin-font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--spin-font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
--spin-text-xs: 12px;
--spin-text-xs-lh: 16px;
--spin-text-sm: 13px;
--spin-text-sm-lh: 20px;
--spin-text-base: 14px;
--spin-text-base-lh: 22px;
--spin-text-lg: 16px;
--spin-text-lg-lh: 24px;
--spin-text-xl: 18px;
--spin-text-xl-lh: 26px;
--spin-text-2xl: 20px;
--spin-text-2xl-lh: 28px;
--spin-text-3xl: 24px;
--spin-text-3xl-lh: 32px;
--spin-heading-tracking: -0.02em;
--spin-duration-fast: 120ms;
--spin-duration: 200ms;
--spin-duration-slow: 400ms;
--spin-ease: cubic-bezier(0.2, 0.7, 0.2, 1);
}
2 changes: 1 addition & 1 deletion dist/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
--spin-font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--spin-font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
--spin-text-reading: 16px;
--spin-text-dense: 14px;
--spin-text-dense: 13px;
--spin-line-height: 1.6;
--spin-heading-weight: 600;
--spin-heading-tracking: -0.02em;
Expand Down
35 changes: 33 additions & 2 deletions dist/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,46 @@
},
"tint": {
"Hover": 0.1,
"Selected": 0.22
"Selected": 0.22,
"Scrim": 0.6
},
"type": {
"Sans": "IBM Plex Sans",
"Mono": "IBM Plex Mono",
"FallbackSans": "system-ui, -apple-system, 'Segoe UI', sans-serif",
"FallbackMono": "ui-monospace, 'SF Mono', Menlo, monospace",
"ReadingSize": 16,
"DenseSize": 14,
"DenseSize": 13,
"DenseScale": {
"2xl": [
20,
28
],
"3xl": [
24,
32
],
"base": [
14,
22
],
"lg": [
16,
24
],
"sm": [
13,
20
],
"xl": [
18,
26
],
"xs": [
12,
16
]
},
"LineHeight": 1.6,
"HeadingWeight": 600,
"HeadingTracking": -0.02
Expand Down
24 changes: 17 additions & 7 deletions palette.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,31 @@ warning = "#8A5A00"
error = "#9B1B73" # magenta-leaning, as dark's: a deutan tells it from success by its blue
destructive = "#B4234A"

# The accent over a surface, for what is selected or hovered: an alpha, so one value serves every
# surface. The label on a tint is accent_text.
# 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
# hover and its navigation's selection are neutral, the surface a step off the page. The scrim is
# the navy behind a dialog, the only dim there is.
[tint]
hover = 0.10
selected = 0.22
scrim = 0.6

# Type. IBM Plex, served by the site that uses it: a sans for reading and a mono for what a
# machine says - commands, output, ids - and for headings, which set Spinbox in its console's face.
# machine says - commands, output, ids, a state, a time - and for the site's headings, which set
# Spinbox in its console's face. The dashboard's page titles are sans; a title that names a thing
# (a workspace, a runner) is its identifier, in mono.
[type]
sans = "IBM Plex Sans"
mono = "IBM Plex Mono"
fallback_sans = "system-ui, -apple-system, 'Segoe UI', sans-serif"
fallback_mono = "ui-monospace, 'SF Mono', Menlo, monospace"
reading_size = 16 # px: the docs' body. Below 16 iOS zooms into a focused input
dense_size = 14 # px: the dashboard's body, where a table of runners is the content
dense_size = 13 # px: the dashboard's body, where a table of runners is the content
# The dashboard's scale, by the names its Tailwind theme gives them: a step under Tailwind's own,
# which is sized for reading an article; set a table of runners in it and it reads as shouting.
# Each step is [size, line height] in px.
dense_scale = { xs = [12, 16], sm = [13, 20], base = [14, 22], lg = [16, 24], xl = [18, 26], "2xl" = [20, 28], "3xl" = [24, 32] }
line_height = 1.6 # reading; dense text takes 1.45
heading_weight = 600
heading_tracking = -0.02 # em: mono headings are wide; this tightens them to read as one word
Expand All @@ -81,9 +91,9 @@ heading_tracking = -0.02 # em: mono headings are wide; this tightens them to rea
[layout]
measure = 72 # ch: a line of reading text; 45-75 is the range people read without losing it
sidebar = 17 # rem
radius_sm = 4 # px: inputs, chips, code
radius = 8 # px: cards, buttons, panels
radius_lg = 12 # px: dialogs, hero figures
radius_sm = 4 # px: inputs, chips, code; every corner of the dashboard, dialogs included
radius = 8 # px: the site's cards, buttons, panels
radius_lg = 12 # px: the site's dialogs and hero figures
target = 24 # px: the least a pointer target is (WCAG 2.2, 2.5.8)
target_touch = 44 # px: a target on a coarse pointer (WCAG 2.5.5, Apple's HIG)
phone = 390 # px: the narrow width every page is drawn at in preview.html
Expand Down
Loading