diff --git a/README.md b/README.md
index 14f8322..294602a 100644
--- a/README.md
+++ b/README.md
@@ -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=
`); 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=`). 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 |
diff --git a/cmd/palette/main.go b/cmd/palette/main.go
index af6de62..d03dcce 100644
--- a/cmd/palette/main.go
+++ b/cmd/palette/main.go
@@ -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
diff --git a/cmd/palette/outputs.go b/cmd/palette/outputs.go
index d282867..1ea515c 100644
--- a/cmd/palette/outputs.go
+++ b/cmd/palette/outputs.go
@@ -159,15 +159,24 @@ 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"])},
@@ -175,8 +184,9 @@ func dashboardCSS(p palette, dark, light map[string]rgb) string {
{"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"]))},
}
@@ -184,15 +194,35 @@ func dashboardCSS(p palette, dark, light map[string]rgb) string {
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()
}
diff --git a/cmd/palette/outputs_test.go b/cmd/palette/outputs_test.go
new file mode 100644
index 0000000..f2151a3
--- /dev/null
+++ b/cmd/palette/outputs_test.go
@@ -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))
+}
diff --git a/dist/dashboard.css b/dist/dashboard.css
index 8efd837..6a00602 100644
--- a/dist/dashboard.css
+++ b/dist/dashboard.css
@@ -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%;
@@ -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);
+}
diff --git a/dist/tokens.css b/dist/tokens.css
index 6ccfcd5..d9782e0 100644
--- a/dist/tokens.css
+++ b/dist/tokens.css
@@ -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;
diff --git a/dist/tokens.json b/dist/tokens.json
index 878d2c6..f80e5f5 100644
--- a/dist/tokens.json
+++ b/dist/tokens.json
@@ -64,7 +64,8 @@
},
"tint": {
"Hover": 0.1,
- "Selected": 0.22
+ "Selected": 0.22,
+ "Scrim": 0.6
},
"type": {
"Sans": "IBM Plex Sans",
@@ -72,7 +73,37 @@
"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
diff --git a/palette.toml b/palette.toml
index 831a307..9917010 100644
--- a/palette.toml
+++ b/palette.toml
@@ -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
@@ -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