From 05ed7cede13d8e87f6498403b0d7e7bf47ddfb3f Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 22 Oct 2026 19:19:34 -0300 Subject: [PATCH] dashboard tokens: the radius, scrim, type scale and motion, and labels that read on their fills The dashboard took one thing from the palette besides its colors, a radius, and wrote the rest by hand: a 13 px body against a palette that said 14, its own easings, five border alphas, three dims behind dialogs. dashboard.css now carries what the console redesign builds on: - the radius, 4 px, for every corner of the dashboard, dialogs included (radius and radius_lg are the site's); - --scrim, the navy at [tint] scrim, the only dim behind a dialog; - the dense type scale by Tailwind's names, each step a size and a line (dense_scale), and dense_size at 13, the body the dashboard ships; - the heading tracking, the motion durations and the easing; - --error and --label, which the palette had and the dashboard did not; - a neutral --accent: hover and the navigation's selection are the surface a step off the page; the accent is the primary action's and the focus ring's alone. on() picked a label for a fill by comparing it with the page, and on the light page - white - against white too, so it always chose dark text: 2.9:1 on the light success and warning fills, and the destructive label was white by fiat (3.2:1 in dark would have been the dark ink's). It now picks between white and the theme's darkest ink, and a test holds every --*-foreground at 4.5:1 on its fill in both themes; with the old on() it fails on four. palette.toml says the decisions: sans page titles in the dashboard, mono for identifiers and the site's headings, the accent tint as the site's. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/palette/main.go | 17 +++---- cmd/palette/outputs.go | 54 +++++++++++++++++----- cmd/palette/outputs_test.go | 91 +++++++++++++++++++++++++++++++++++++ dist/dashboard.css | 42 ++++++++++++++--- dist/tokens.css | 2 +- dist/tokens.json | 35 +++++++++++++- palette.toml | 24 +++++++--- 8 files changed, 229 insertions(+), 38 deletions(-) create mode 100644 cmd/palette/outputs_test.go 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