@spunto/design-system 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -470,6 +470,34 @@ it, the navs and footers (they hard-code URLs and a session), the product logos
470
470
  and OG cards. The rule: *if it needs to know a product, a URL or a session, it's
471
471
  the site; if it only lays out or types, it's the system.*
472
472
 
473
+ ## Brand — `/brand`
474
+
475
+ The Spunto logo, behind its own entry: the one sub-export that knows Spunto by
476
+ construction, kept out of the root so a third-party consumer of the primitives
477
+ never ships the company's mark.
478
+
479
+ ```tsx
480
+ import { SpuntoLogo } from "@spunto/design-system/brand"
481
+
482
+ <SpuntoLogo /> // the brand: flame tile, night ink
483
+ <SpuntoLogo variant="mark" tone="night" /> // S + dot only, what survives 16 px
484
+ <SpuntoLogo tone="ink" className="text-primary" /> // no tile, currentColor
485
+ ```
486
+
487
+ The drawing is a 7 × 7 grid — blocks of 10 on a pitch of 14 — and it tells the
488
+ product: the dot is your container, the two brackets are an S with the dot for
489
+ its middle stroke, three blocks on the left are Build, three lights on the right
490
+ are Run, the belt along the bottom is Ship, three squares on top are the
491
+ machines you already own. `tone` picks a declension (`flame`, `build`,
492
+ `ship`, `run`, `night`, `cream`, `pillars`, `ink`); `paint` overrides
493
+ any role on top of it.
494
+
495
+ The geometry lives as data in `src/brand/logo.ts`, React-free, so the same
496
+ shapes come out as an element (`SpuntoLogo`) and as a file
497
+ (`spuntoLogoSvg()`). `npm run brand:export -- <dir>` writes one `.svg` per
498
+ declension — favicon, per-product tiles — which is how a site gets its static
499
+ copies without ever hand-editing an SVG.
500
+
473
501
  ## Toasts — `SpuntoProvider` + `toast()`
474
502
 
475
503
  `SpuntoProvider` is the design system's client umbrella provider. Mount it once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,10 @@
49
49
  "types": "./src/components/marketing/index.ts",
50
50
  "import": "./src/components/marketing/index.ts"
51
51
  },
52
+ "./brand": {
53
+ "types": "./src/brand/index.ts",
54
+ "import": "./src/brand/index.ts"
55
+ },
52
56
  "./fonts": {
53
57
  "types": "./src/components/fonts.tsx",
54
58
  "import": "./src/components/fonts.tsx"
@@ -62,7 +66,8 @@
62
66
  ],
63
67
  "scripts": {
64
68
  "typecheck": "tsc --noEmit",
65
- "test": "vitest run"
69
+ "test": "vitest run",
70
+ "brand:export": "node scripts/export-logo.ts"
66
71
  },
67
72
  "dependencies": {
68
73
  "@xterm/addon-fit": "^0.11.0",
@@ -0,0 +1,31 @@
1
+ // @spunto/design-system/brand — the Spunto logo.
2
+ //
3
+ // Behind its own entry, like every domain sub-export: a `Button` knows nothing
4
+ // about Spunto, and a third-party consumer of the primitives should not carry
5
+ // the company's mark in their bundle. Here it *is* the point: the same drawing
6
+ // for the console, for Spunto Lite, for the showcase, each in its own tone.
7
+ //
8
+ // `SpuntoLogo` is the React element. `spuntoLogoSvg` is the same drawing as a
9
+ // static document — `scripts/export-logo.ts` uses it to write the favicon and
10
+ // the per-product tiles a site serves as files.
11
+
12
+ export { SpuntoLogo } from "./spunto-logo"
13
+ export type { SpuntoLogoProps } from "./spunto-logo"
14
+
15
+ export {
16
+ SPUNTO_LOGO_TONES,
17
+ SPUNTO_LOGO_VIEWBOX,
18
+ SPUNTO_LOGO_TILE_RADIUS,
19
+ spuntoLogoSvg,
20
+ spuntoLogoShapes,
21
+ spuntoLogoTile,
22
+ spuntoLogoFill,
23
+ } from "./logo"
24
+ export type {
25
+ SpuntoLogoPaint,
26
+ SpuntoLogoTone,
27
+ SpuntoLogoVariant,
28
+ SpuntoLogoRole,
29
+ SpuntoLogoShape,
30
+ SpuntoLogoSvgOptions,
31
+ } from "./logo"
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+
4
+ import { SPUNTO_LOGO_TONES, spuntoLogoShapes, spuntoLogoSvg } from "./logo"
5
+ import { SpuntoLogo } from "./spunto-logo"
6
+
7
+ // The whole point of `logo.ts` being data is that the element and the file are
8
+ // the same drawing. These pin that: same shapes, same fills, in the same order.
9
+ // (React closes SVG elements with a tag, the file self-closes them — hence `/?>`.)
10
+ const attrs = (svg: string) =>
11
+ [...svg.matchAll(/<(rect|circle|path)\b([^>]*?)\/?>/g)].map(([, tag, a]) => {
12
+ const kv = Object.fromEntries([...a.matchAll(/(\w+)="([^"]*)"/g)].map(([, k, v]) => [k, v]))
13
+ return { tag, ...kv }
14
+ })
15
+
16
+ describe("SpuntoLogo", () => {
17
+ it("draws exactly what spuntoLogoSvg writes, for every tone", () => {
18
+ for (const tone of Object.keys(SPUNTO_LOGO_TONES) as Array<keyof typeof SPUNTO_LOGO_TONES>) {
19
+ const paint = SPUNTO_LOGO_TONES[tone]
20
+ expect(attrs(renderToStaticMarkup(<SpuntoLogo tone={tone} />))).toEqual(attrs(spuntoLogoSvg(paint)))
21
+ expect(attrs(renderToStaticMarkup(<SpuntoLogo tone={tone} variant="mark" />))).toEqual(
22
+ attrs(spuntoLogoSvg(paint, { variant: "mark" })),
23
+ )
24
+ }
25
+ })
26
+
27
+ it("has no tile and takes currentColor in the ink tone", () => {
28
+ const svg = renderToStaticMarkup(<SpuntoLogo tone="ink" />)
29
+ expect(svg).not.toContain('width="128"')
30
+ expect(svg.match(/fill="currentColor"/g)).toHaveLength(spuntoLogoShapes("glyph").length)
31
+ })
32
+
33
+ it("lets paint override a single role of a tone", () => {
34
+ const svg = renderToStaticMarkup(<SpuntoLogo tone="flame" paint={{ tile: "#be241f" }} />)
35
+ expect(svg).toContain('fill="#be241f"')
36
+ expect(svg).not.toContain('fill="#ec5100"')
37
+ expect(svg).toContain('fill="#0e0805"')
38
+ })
39
+
40
+ it("is hidden from assistive tech unless it is given a name", () => {
41
+ expect(renderToStaticMarkup(<SpuntoLogo />)).toContain('aria-hidden="true"')
42
+ const named = renderToStaticMarkup(<SpuntoLogo title="Spunto — home" />)
43
+ expect(named).toContain('role="img"')
44
+ expect(named).toContain("<title>Spunto — home</title>")
45
+ })
46
+
47
+ it("keeps the mark to the S and the dot, and the glyph to the full story", () => {
48
+ // 2 brackets + 1 dot; then 3 fleet + 3 build + 3 run + 4 ship.
49
+ expect(spuntoLogoShapes("mark")).toHaveLength(3)
50
+ expect(spuntoLogoShapes("glyph")).toHaveLength(3 + 3 + 3 + 3 + 4)
51
+ })
52
+ })
@@ -0,0 +1,238 @@
1
+ // The Spunto logo, as data.
2
+ //
3
+ // This module is deliberately **pure**: no React, no DOM, no colour maths. It
4
+ // describes the mark once — a list of shapes on a grid, and the named paints
5
+ // that go on them — so that the same drawing can be emitted as a React element
6
+ // (`SpuntoLogo`) and as a static `.svg` file (`spuntoLogoSvg`, used by
7
+ // `scripts/export-logo.ts` to write the favicon and the per-product tiles). One
8
+ // source, two outputs, nothing to keep in sync by eye.
9
+ //
10
+ // ─── The drawing ───────────────────────────────────────────────────────────
11
+ //
12
+ // A 7 × 7 grid: blocks of 10 on a pitch of 14, inset 17 from the edge of a
13
+ // 128 × 128 tile. Everything snaps to it — the bars are blocks fused together,
14
+ // the lights are blocks turned round. That grid is what the logo inherited
15
+ // from its predecessor, and what keeps it in the family of the design system's
16
+ // quasi-square 4 px radius: nothing here is drawn freehand.
17
+ //
18
+ // What it says, read from the centre outwards:
19
+ //
20
+ // • the dot — your container. The one thing the platform is about.
21
+ // • the two brackets — an S (for Spunto) whose middle stroke *is* the dot:
22
+ // two L-shapes 180° apart, wrapping the container the
23
+ // way the platform wraps a workspace.
24
+ // • three blocks, left — Build. Layers stacked; an image being made.
25
+ // • three lights, right — Run. Round because they're alive: status lights.
26
+ // • the belt, bottom — Ship. Square, round, square, round: what leaves
27
+ // the build side and comes out running.
28
+ // • three squares, top — the fleet. The machines you already own, the
29
+ // roof everything else sits under.
30
+ //
31
+ // Each of those is a `role`, so a paint can colour the story (the `pillars`
32
+ // tone lights Build / Ship / Run in their own tokens) or flatten it to one ink.
33
+
34
+ export const SPUNTO_LOGO_VIEWBOX = 128
35
+
36
+ /** Corner radius of the tile, in viewBox units — quasi-square, like the system. */
37
+ export const SPUNTO_LOGO_TILE_RADIUS = 14
38
+
39
+ /** Every part of the drawing that can be painted independently. */
40
+ export type SpuntoLogoRole = "tile" | "core" | "build" | "run" | "ship" | "fleet"
41
+
42
+ export type SpuntoLogoShape =
43
+ | { role: SpuntoLogoRole; kind: "rect"; x: number; y: number; w: number; h: number; rx?: number }
44
+ | { role: SpuntoLogoRole; kind: "circle"; cx: number; cy: number; r: number }
45
+ | { role: SpuntoLogoRole; kind: "path"; d: string }
46
+
47
+ /**
48
+ * `glyph` is the whole story — the S, the container and its three pillars.
49
+ * `mark` is the S and the container alone, drawn larger: what survives at
50
+ * 16 px, so it is the favicon and the collapsed-sidebar icon.
51
+ */
52
+ export type SpuntoLogoVariant = "glyph" | "mark"
53
+
54
+ const PITCH = 14
55
+ const BLOCK = 10
56
+ const INSET = 17
57
+ const CENTRE = SPUNTO_LOGO_VIEWBOX / 2
58
+
59
+ /** Grid column/row `i` (0–6) → its top-left coordinate. */
60
+ const at = (i: number) => INSET + PITCH * i
61
+
62
+ const square = (role: SpuntoLogoRole, col: number, row: number): SpuntoLogoShape => ({
63
+ role,
64
+ kind: "rect",
65
+ x: at(col),
66
+ y: at(row),
67
+ w: BLOCK,
68
+ h: BLOCK,
69
+ })
70
+
71
+ const light = (role: SpuntoLogoRole, col: number, row: number): SpuntoLogoShape => ({
72
+ role,
73
+ kind: "circle",
74
+ cx: at(col) + BLOCK / 2,
75
+ cy: at(row) + BLOCK / 2,
76
+ r: BLOCK / 2,
77
+ })
78
+
79
+ /* The two brackets and the dot, in both sizes. Coordinates are spelled out
80
+ rather than computed so the paths read as what they are: an L, its 180°
81
+ twin, and the space between them. In the glyph the brackets are one block
82
+ thick, like every other bar on the grid; in the mark they are 14 — the
83
+ pitch — because at favicon size a 10-unit bar is a single blurred pixel. */
84
+ const CORE = {
85
+ glyph: [
86
+ { role: "core", kind: "path", d: "M31 31h52v10H41v42H31z" }, // Γ — top bar, left stem
87
+ { role: "core", kind: "path", d: "M97 97H45V87h42V45h10z" }, // ⅃ — bottom bar, right stem
88
+ { role: "core", kind: "circle", cx: CENTRE, cy: CENTRE, r: 13 },
89
+ ],
90
+ mark: [
91
+ { role: "core", kind: "path", d: "M18 18h72v14H32v58H18z" },
92
+ { role: "core", kind: "path", d: "M110 110H38V96h58V38h14z" },
93
+ { role: "core", kind: "circle", cx: CENTRE, cy: CENTRE, r: 18 },
94
+ ],
95
+ } satisfies Record<SpuntoLogoVariant, SpuntoLogoShape[]>
96
+
97
+ const PILLARS: SpuntoLogoShape[] = [
98
+ // the fleet, on top
99
+ square("fleet", 2, 0),
100
+ square("fleet", 3, 0),
101
+ square("fleet", 4, 0),
102
+ // Build — blocks stacked on the left
103
+ square("build", 0, 2),
104
+ square("build", 0, 3),
105
+ square("build", 0, 4),
106
+ // Run — lights on the right
107
+ light("run", 6, 2),
108
+ light("run", 6, 3),
109
+ light("run", 6, 4),
110
+ // Ship — the belt along the bottom, build-side square to run-side round
111
+ square("ship", 1, 6),
112
+ light("ship", 2, 6),
113
+ square("ship", 3, 6),
114
+ light("ship", 4, 6),
115
+ ]
116
+
117
+ /**
118
+ * The shapes of a variant, tile excluded (the tile is a paint decision, see
119
+ * `spuntoLogoTile`). Order is paint order.
120
+ */
121
+ export function spuntoLogoShapes(variant: SpuntoLogoVariant = "glyph"): SpuntoLogoShape[] {
122
+ return variant === "mark" ? [...CORE.mark] : [...CORE.glyph, ...PILLARS]
123
+ }
124
+
125
+ /** The tile behind the drawing, when a paint asks for one. */
126
+ export function spuntoLogoTile(): SpuntoLogoShape {
127
+ return {
128
+ role: "tile",
129
+ kind: "rect",
130
+ x: 0,
131
+ y: 0,
132
+ w: SPUNTO_LOGO_VIEWBOX,
133
+ h: SPUNTO_LOGO_VIEWBOX,
134
+ rx: SPUNTO_LOGO_TILE_RADIUS,
135
+ }
136
+ }
137
+
138
+ // ─── Paint ─────────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * What colour goes on what. `ink` is the fallback for every drawn role; a
142
+ * role named explicitly overrides it. No `tile` means a transparent
143
+ * background — the drawing alone, for a nav bar or a print.
144
+ */
145
+ export interface SpuntoLogoPaint {
146
+ tile?: string
147
+ ink: string
148
+ core?: string
149
+ build?: string
150
+ run?: string
151
+ ship?: string
152
+ fleet?: string
153
+ }
154
+
155
+ /**
156
+ * The declensions. Each is a design-system token turned into a fixed value:
157
+ * a logo is an artefact, not a surface, so it does not follow the theme — the
158
+ * flame tile is the same flame at night. The values are the light-theme
159
+ * register of `styles.css` (`--flame`, `--build`, `--ship`, `--run`,
160
+ * `--night`, `--background`) and the sand the marketing pages use for ink on
161
+ * night, converted out of oklch.
162
+ *
163
+ * The idea is one tone per product: Spunto Cloud is the brand and wears
164
+ * `flame`; a product whose marketing accent is `build` wears `build`; the
165
+ * design system, whose accent is ink, wears `night`. `pillars` is the same
166
+ * drawing with its story lit — Build, Ship and Run each in their own token —
167
+ * for a hero or a social card, not for a 24 px header.
168
+ */
169
+ export const SPUNTO_LOGO_TONES = {
170
+ /** The brand: flame tile, night ink. */
171
+ flame: { tile: "#ec5100", ink: "#0e0805" },
172
+ /** `--build` tile — amber. */
173
+ build: { tile: "#f9a733", ink: "#0e0805" },
174
+ /** `--ship` tile — orange. */
175
+ ship: { tile: "#fb7c01", ink: "#0e0805" },
176
+ /** `--run` tile — one step short of flame. */
177
+ run: { tile: "#f35800", ink: "#0e0805" },
178
+ /** `--night` tile, sand ink — what the design system itself wears. */
179
+ night: { tile: "#0e0805", ink: "#e0d3bb" },
180
+ /** Cream tile, night ink, the container lit in flame — for a light print. */
181
+ cream: { tile: "#fbf6ee", ink: "#0e0805", core: "#ec5100" },
182
+ /** Night, with the three pillars in their own tokens (night register). */
183
+ pillars: {
184
+ tile: "#0e0805",
185
+ ink: "#fbf6ee",
186
+ fleet: "#8b837d",
187
+ build: "#ffb649",
188
+ ship: "#ff8b27",
189
+ run: "#ff6917",
190
+ },
191
+ } as const satisfies Record<string, SpuntoLogoPaint>
192
+
193
+ export type SpuntoLogoTone = keyof typeof SPUNTO_LOGO_TONES
194
+
195
+ /** The colour a role takes under a paint. `tile` returns `undefined` when there is none. */
196
+ export function spuntoLogoFill(paint: SpuntoLogoPaint, role: SpuntoLogoRole): string | undefined {
197
+ if (role === "tile") return paint.tile
198
+ return paint[role] ?? paint.ink
199
+ }
200
+
201
+ // ─── Static SVG ────────────────────────────────────────────────────────────
202
+
203
+ export interface SpuntoLogoSvgOptions {
204
+ variant?: SpuntoLogoVariant
205
+ /** `<title>` for the file; omitted, the SVG carries `aria-hidden`. */
206
+ title?: string
207
+ }
208
+
209
+ function shapeToSvg(shape: SpuntoLogoShape, fill: string): string {
210
+ switch (shape.kind) {
211
+ case "rect": {
212
+ const rx = shape.rx ? ` rx="${shape.rx}"` : ""
213
+ return `<rect x="${shape.x}" y="${shape.y}" width="${shape.w}" height="${shape.h}"${rx} fill="${fill}"/>`
214
+ }
215
+ case "circle":
216
+ return `<circle cx="${shape.cx}" cy="${shape.cy}" r="${shape.r}" fill="${fill}"/>`
217
+ case "path":
218
+ return `<path d="${shape.d}" fill="${fill}"/>`
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The logo as a standalone SVG document — what goes on disk as a favicon, an
224
+ * `<img src>`, an `og:image` source. Same shapes, same paints as the React
225
+ * component; only the serialisation differs.
226
+ */
227
+ export function spuntoLogoSvg(paint: SpuntoLogoPaint, options: SpuntoLogoSvgOptions = {}): string {
228
+ const { variant = "glyph", title } = options
229
+ const shapes = paint.tile ? [spuntoLogoTile(), ...spuntoLogoShapes(variant)] : spuntoLogoShapes(variant)
230
+ const body = shapes
231
+ .map((s) => shapeToSvg(s, spuntoLogoFill(paint, s.role) ?? paint.ink))
232
+ .map((line) => ` ${line}`)
233
+ .join("\n")
234
+ const head = title
235
+ ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SPUNTO_LOGO_VIEWBOX} ${SPUNTO_LOGO_VIEWBOX}" role="img" aria-labelledby="t">\n <title id="t">${title}</title>`
236
+ : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SPUNTO_LOGO_VIEWBOX} ${SPUNTO_LOGO_VIEWBOX}" aria-hidden="true">`
237
+ return `${head}\n${body}\n</svg>\n`
238
+ }
@@ -0,0 +1,90 @@
1
+ import type { SVGProps } from "react"
2
+
3
+ import { cn } from "../utils"
4
+ import {
5
+ SPUNTO_LOGO_TONES,
6
+ SPUNTO_LOGO_VIEWBOX,
7
+ spuntoLogoFill,
8
+ spuntoLogoShapes,
9
+ spuntoLogoTile,
10
+ type SpuntoLogoPaint,
11
+ type SpuntoLogoShape,
12
+ type SpuntoLogoTone,
13
+ type SpuntoLogoVariant,
14
+ } from "./logo"
15
+
16
+ /* ─── SpuntoLogo ────────────────────────────────────────────────────────────
17
+ The logo as a React element. Two axes:
18
+
19
+ `variant` — `glyph` (the whole story, from 24 px up) or `mark` (the S and
20
+ the container alone, for 16 px and collapsed sidebars).
21
+
22
+ `tone` — which declension. The seven named ones are fixed paints built
23
+ from the tokens (see `SPUNTO_LOGO_TONES`); `ink` is the odd one out: no
24
+ tile, and the drawing takes `currentColor`, so it follows the text colour
25
+ it sits in — the right choice for a header on a themed surface, where a
26
+ fixed flame tile would fight the page.
27
+
28
+ `paint` overrides any tone field by field. A product whose accent is not a
29
+ design-system token (the site's marketing `rust`, say) passes its own tile
30
+ here rather than getting a tone the package would then have to own. */
31
+
32
+ export interface SpuntoLogoProps extends Omit<SVGProps<SVGSVGElement>, "children" | "viewBox"> {
33
+ variant?: SpuntoLogoVariant
34
+ /** @default "flame" */
35
+ tone?: SpuntoLogoTone | "ink"
36
+ /** Per-role overrides on top of the tone. */
37
+ paint?: Partial<SpuntoLogoPaint>
38
+ /** Width and height, in px. Leave it out to size with `className`. */
39
+ size?: number
40
+ /**
41
+ * Accessible name. Give one when the logo is the only content of a link
42
+ * ("Spunto — home"); leave it out when a wordmark sits next to it, and the
43
+ * SVG is hidden from assistive tech.
44
+ */
45
+ title?: string
46
+ }
47
+
48
+ const INK: SpuntoLogoPaint = { ink: "currentColor" }
49
+
50
+ function Shape({ shape, fill }: { shape: SpuntoLogoShape; fill: string }) {
51
+ switch (shape.kind) {
52
+ case "rect":
53
+ return <rect x={shape.x} y={shape.y} width={shape.w} height={shape.h} rx={shape.rx} fill={fill} />
54
+ case "circle":
55
+ return <circle cx={shape.cx} cy={shape.cy} r={shape.r} fill={fill} />
56
+ case "path":
57
+ return <path d={shape.d} fill={fill} />
58
+ }
59
+ }
60
+
61
+ export function SpuntoLogo({
62
+ variant = "glyph",
63
+ tone = "flame",
64
+ paint: overrides,
65
+ size,
66
+ title,
67
+ className,
68
+ ...rest
69
+ }: SpuntoLogoProps) {
70
+ const base: SpuntoLogoPaint = tone === "ink" ? INK : SPUNTO_LOGO_TONES[tone]
71
+ const paint: SpuntoLogoPaint = { ...base, ...overrides }
72
+ const shapes = paint.tile ? [spuntoLogoTile(), ...spuntoLogoShapes(variant)] : spuntoLogoShapes(variant)
73
+
74
+ return (
75
+ <svg
76
+ viewBox={`0 0 ${SPUNTO_LOGO_VIEWBOX} ${SPUNTO_LOGO_VIEWBOX}`}
77
+ width={size}
78
+ height={size}
79
+ className={cn("shrink-0", className)}
80
+ role={title ? "img" : undefined}
81
+ aria-hidden={title ? undefined : true}
82
+ {...rest}
83
+ >
84
+ {title && <title>{title}</title>}
85
+ {shapes.map((shape, i) => (
86
+ <Shape key={i} shape={shape} fill={spuntoLogoFill(paint, shape.role) ?? paint.ink} />
87
+ ))}
88
+ </svg>
89
+ )
90
+ }