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`, and the dashboard's radius, scrim, dense type scale, heading tracking and motion), `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, motion and a terminal's black surface and sixteen ANSI colours as `--terminal-*`), `tokens.json`, and `perception.js` and `simulate.svg` for the preview |
| 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, clear (512, 256) for a light one, and clear with its shadow drawn for the dark page (`mark-dark-*`, 512, 256), where the light one's grey shadow read as a pale ring. Each is the drawing reduced; a size it would have to enlarge fails |

Expand Down
44 changes: 44 additions & 0 deletions cmd/palette/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ func holds(p palette) ([]result, error) {
apart("meaning", pair[0]+" | "+pair[1], c[pair[0]], c[pair[1]], meaningTritan)
}
}
term, err := terminalHolds(p)
if err != nil {
return nil, err
}
out = append(out, term...)
for _, r := range out {
if math.IsNaN(r.contrast) {
return nil, fmt.Errorf("%s %s: not a number", r.mode, r.what)
Expand All @@ -156,6 +161,40 @@ func holds(p palette) ([]result, error) {
return out, nil
}

// terminalHolds is the terminal's pairs, the same in either theme: its text on its background,
// each ANSI colour on it, and the foreground on what a program or a person paints under a cell -
// ANSI black and the selection. The cursor is a block the character under it reads on.
func terminalHolds(p palette) ([]result, error) {
c, err := p.Terminal.colors()
if err != nil {
return nil, fmt.Errorf("terminal: %w", err)
}
var out []result
hold := func(what string, fg, bg rgb, floor float64) {
r := result{mode: "term", kind: "text", what: what, contrast: contrast(fg, bg), floor: floor}
r.ok = r.contrast >= floor
out = append(out, r)
}
bg := c["background"]
hold("foreground on background", c["foreground"], bg, bodyText)
for _, k := range ansi {
switch k {
case "black":
hold("foreground on black", c["foreground"], c[k], bodyText)
case "white", "bright_white":
hold(k+" on background", c[k], bg, bodyText)
default:
hold(k+" on background", c[k], bg, text)
}
}
hold("foreground on selection", c["foreground"], c["selection"], bodyText)
hold("background on cursor", bg, c["cursor"], text)
r := result{mode: "term", kind: "non-text", what: "cursor on background", contrast: contrast(c["cursor"], bg), floor: nonText}
r.ok = r.contrast >= nonText
out = append(out, r)
return out, nil
}

// colors reads a mode's roles by name.
func colors(m mode) (map[string]rgb, error) {
named := map[string]string{
Expand All @@ -164,6 +203,11 @@ func colors(m mode) (map[string]rgb, error) {
"accent": m.Accent, "accent_text": m.AccentText, "on_accent": m.OnAccent, "label": m.Label,
"success": m.Success, "warning": m.Warning, "error": m.Error, "destructive": m.Destructive,
}
return parseNamed(named)
}

// parseNamed parses every color by its name, failing on one that is missing.
func parseNamed(named map[string]string) (map[string]rgb, error) {
out := map[string]rgb{}
for name, hex := range named {
if hex == "" {
Expand Down
39 changes: 38 additions & 1 deletion cmd/palette/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,48 @@ type mode struct {
Destructive string
}

type terminal struct {
Background, Foreground, Cursor, Selection string
Black, Red, Green, Yellow, Blue, Magenta, Cyan, White string
BrightBlack string `toml:"bright_black"`
BrightRed string `toml:"bright_red"`
BrightGreen string `toml:"bright_green"`
BrightYellow string `toml:"bright_yellow"`
BrightBlue string `toml:"bright_blue"`
BrightMagenta string `toml:"bright_magenta"`
BrightCyan string `toml:"bright_cyan"`
BrightWhite string `toml:"bright_white"`
}

// ansi is the sixteen colours by palette.toml's names, in the order a program numbers them.
var ansi = []string{
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"bright_black", "bright_red", "bright_green", "bright_yellow", "bright_blue", "bright_magenta", "bright_cyan", "bright_white",
}

// terminalRoles is the terminal's roles before the sixteen colours, in the order they are written.
var terminalRoles = []string{"background", "foreground", "cursor", "selection"}

// colors reads the terminal's roles and colours by palette.toml's names.
func (t terminal) colors() (map[string]rgb, error) {
named := map[string]string{
"background": t.Background, "foreground": t.Foreground, "cursor": t.Cursor, "selection": t.Selection,
"black": t.Black, "red": t.Red, "green": t.Green, "yellow": t.Yellow,
"blue": t.Blue, "magenta": t.Magenta, "cyan": t.Cyan, "white": t.White,
"bright_black": t.BrightBlack, "bright_red": t.BrightRed, "bright_green": t.BrightGreen,
"bright_yellow": t.BrightYellow, "bright_blue": t.BrightBlue, "bright_magenta": t.BrightMagenta,
"bright_cyan": t.BrightCyan, "bright_white": t.BrightWhite,
}
return parseNamed(named)
}

type palette struct {
Brand map[string]string
Dark mode
Light mode
Tint struct{ Hover, Selected, Scrim, Fill, Badge float64 }
// A terminal's roles and its sixteen ANSI colours, the same in either theme.
Terminal terminal
Tint struct{ Hover, Selected, Scrim, Fill, Badge float64 }
// What lifts a menu or a dialog off what is under it, per theme: a CSS box-shadow.
Elevation struct{ Dark, Light string }
Type struct {
Expand Down
23 changes: 22 additions & 1 deletion cmd/palette/outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ func outputs(p palette, results []result) (map[string][]byte, error) {
if err != nil {
return nil, err
}
term, err := p.Terminal.colors()
if err != nil {
return nil, err
}
files := map[string][]byte{}

j, err := json.MarshalIndent(map[string]any{
"brand": p.Brand, "dark": hexes(dark), "light": hexes(light),
"brand": p.Brand, "dark": hexes(dark), "light": hexes(light), "terminal": hexes(term),
"tint": p.Tint, "type": p.Type, "layout": p.Layout, "controls": p.Controls, "elevation": p.Elevation, "motion": p.Motion,
}, "", " ")
if err != nil {
Expand Down Expand Up @@ -95,6 +99,7 @@ func tokensCSS(p palette, dark, light map[string]rgb) string {
for _, k := range names {
fmt.Fprintf(&b, "\t--spin-brand-%s: %s;\n", k, strings.ToUpper(p.Brand[k]))
}
b.WriteString(terminalVars(p, "spin-terminal"))
fmt.Fprintf(&b, "\t--spin-wordmark: %q;\n", p.Type.Wordmark)
fmt.Fprintf(&b, "\t--spin-font-sans: '%s', %s;\n", p.Type.Sans, p.Type.FallbackSans)
fmt.Fprintf(&b, "\t--spin-font-mono: '%s', %s;\n", p.Type.Mono, p.Type.FallbackMono)
Expand Down Expand Up @@ -264,6 +269,8 @@ func dashboardCSS(p palette, dark, light map[string]rgb) string {
dim := dark["page"]
b255 := func(v float64) int { return int(math.Round(v * 255)) }
fmt.Fprintf(&b, "\t--scrim: rgb(%d %d %d / %g);\n", b255(dim.r), b255(dim.g), b255(dim.b), p.Tint.Scrim)
// A terminal, black in either theme: hex, since xterm reads them off the page for its canvas.
b.WriteString(terminalVars(p, "terminal"))
// The word the sidebar writes beside the mark: the app's catalog audit holds its text to this.
fmt.Fprintf(&b, "\t--spin-wordmark: %q;\n", p.Type.Wordmark)
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)
Expand All @@ -282,6 +289,20 @@ func dashboardCSS(p palette, dark, light map[string]rgb) string {
return b.String()
}

// terminalVars is the terminal's roles, then its sixteen colours in a program's order, as
// --<prefix>-<name>: hex.
func terminalVars(p palette, prefix string) string {
c, err := p.Terminal.colors()
if err != nil {
panic("palette.toml's terminal: " + err.Error())
}
var b strings.Builder
for _, k := range append(append([]string{}, terminalRoles...), ansi...) {
fmt.Fprintf(&b, "\t--%s-%s: %s;\n", prefix, strings.ReplaceAll(k, "_", "-"), c[k].hex())
}
return b.String()
}

// hsl writes a color as shadcn's variables hold it: "H S% L%", for hsl(var(--x)).
func hsl(c rgb) string {
mx, mn := math.Max(c.r, math.Max(c.g, c.b)), math.Min(c.r, math.Min(c.g, c.b))
Expand Down
20 changes: 20 additions & 0 deletions dist/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@
--spin-control-gap: 6px;
--spin-width-page: 1600px;
--scrim: rgb(14 20 33 / 0.64);
--terminal-background: #0A0C10;
--terminal-foreground: #E6EBF2;
--terminal-cursor: #27BFE8;
--terminal-selection: #2C3A55;
--terminal-black: #262C38;
--terminal-red: #F0707A;
--terminal-green: #4CC38A;
--terminal-yellow: #E3B04B;
--terminal-blue: #6A9FF0;
--terminal-magenta: #D58AE6;
--terminal-cyan: #27BFE8;
--terminal-white: #C9D1DE;
--terminal-bright-black: #7D889C;
--terminal-bright-red: #FF9CA4;
--terminal-bright-green: #7EE0AE;
--terminal-bright-yellow: #F5CF7A;
--terminal-bright-blue: #9CC0FF;
--terminal-bright-magenta: #EBB2F7;
--terminal-bright-cyan: #5FE3D6;
--terminal-bright-white: #FFFFFF;
--spin-wordmark: "Spinbox";
--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;
Expand Down
2 changes: 1 addition & 1 deletion dist/perception.js

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions dist/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@
--spin-brand-frost: #EAF0F6;
--spin-brand-mint: #5FE3D6;
--spin-brand-navy: #111827;
--spin-terminal-background: #0A0C10;
--spin-terminal-foreground: #E6EBF2;
--spin-terminal-cursor: #27BFE8;
--spin-terminal-selection: #2C3A55;
--spin-terminal-black: #262C38;
--spin-terminal-red: #F0707A;
--spin-terminal-green: #4CC38A;
--spin-terminal-yellow: #E3B04B;
--spin-terminal-blue: #6A9FF0;
--spin-terminal-magenta: #D58AE6;
--spin-terminal-cyan: #27BFE8;
--spin-terminal-white: #C9D1DE;
--spin-terminal-bright-black: #7D889C;
--spin-terminal-bright-red: #FF9CA4;
--spin-terminal-bright-green: #7EE0AE;
--spin-terminal-bright-yellow: #F5CF7A;
--spin-terminal-bright-blue: #9CC0FF;
--spin-terminal-bright-magenta: #EBB2F7;
--spin-terminal-bright-cyan: #5FE3D6;
--spin-terminal-bright-white: #FFFFFF;
--spin-wordmark: "Spinbox";
--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;
Expand Down
22 changes: 22 additions & 0 deletions dist/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@
"Draw": 700,
"Ease": "cubic-bezier(0.2, 0.7, 0.2, 1)"
},
"terminal": {
"background": "#0A0C10",
"black": "#262C38",
"blue": "#6A9FF0",
"bright_black": "#7D889C",
"bright_blue": "#9CC0FF",
"bright_cyan": "#5FE3D6",
"bright_green": "#7EE0AE",
"bright_magenta": "#EBB2F7",
"bright_red": "#FF9CA4",
"bright_white": "#FFFFFF",
"bright_yellow": "#F5CF7A",
"cursor": "#27BFE8",
"cyan": "#27BFE8",
"foreground": "#E6EBF2",
"green": "#4CC38A",
"magenta": "#D58AE6",
"red": "#F0707A",
"selection": "#2C3A55",
"white": "#C9D1DE",
"yellow": "#E3B04B"
},
"tint": {
"Hover": 0.1,
"Selected": 0.27,
Expand Down
28 changes: 28 additions & 0 deletions palette.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ warning = "#845000"
error = "#9A1F60" # magenta-leaning, as dark's: a deutan tells it from success by its blue
destructive = "#C0303F"

# A terminal - the workspace's shell, an agent's console, a machine's boot log - is black in either
# theme: a console on the page's white read as a document, not a shell (2026-11-08). Its own roles,
# not the dark theme's: the dark page is navy, and a terminal on it would be a card, not a screen.
# The dashboard frames it with the theme's border. The sixteen ANSI colours are what a program asks
# for by number; each reads at 4.5:1 on the background (the foreground and white at 7), except
# black, which a program paints as a cell's background and the foreground reads on.
[terminal]
background = "#0A0C10"
foreground = "#E6EBF2"
cursor = "#27BFE8" # the brand's cyan; the character under it is the background
selection = "#2C3A55" # the foreground holds 7:1 on it
black = "#262C38"
red = "#F0707A"
green = "#4CC38A"
yellow = "#E3B04B"
blue = "#6A9FF0"
magenta = "#D58AE6"
cyan = "#27BFE8"
white = "#C9D1DE"
bright_black = "#7D889C"
bright_red = "#FF9CA4"
bright_green = "#7EE0AE"
bright_yellow = "#F5CF7A"
bright_blue = "#9CC0FF"
bright_magenta = "#EBB2F7"
bright_cyan = "#5FE3D6"
bright_white = "#FFFFFF"

# 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