@spunto/design-system 0.25.0 → 0.27.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 +28 -0
- package/package.json +7 -2
- package/src/brand/index.ts +31 -0
- package/src/brand/logo.test.tsx +52 -0
- package/src/brand/logo.ts +238 -0
- package/src/brand/spunto-logo.tsx +90 -0
- package/src/components/workers/types.ts +16 -1
- package/src/components/workers/worker-status.test.ts +87 -0
- package/src/components/workers/worker-status.tsx +60 -8
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.
|
|
3
|
+
"version": "0.27.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
|
+
}
|
|
@@ -26,8 +26,23 @@ export interface WorkerCardWorker {
|
|
|
26
26
|
name?: string | null
|
|
27
27
|
/** Fallback identity when `name` is empty — renders as "Workspace #3". */
|
|
28
28
|
index?: number | null
|
|
29
|
-
/** Lifecycle state: `provisioning` | `building` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
|
|
29
|
+
/** Lifecycle state: `provisioning` | `building` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `exited` | `unknown` | `error`. */
|
|
30
30
|
state?: string | null
|
|
31
|
+
/**
|
|
32
|
+
* Why the worker is in that state, when there is a why: an exit code, a reason, and the status
|
|
33
|
+
* it came from. Read laxly on purpose — every field optional, an unrecognised `reason` yields no
|
|
34
|
+
* detail instead of an invented one. The strict version of this shape lives in
|
|
35
|
+
* `@spunto/build` (`WorkerStatusMeta`), which is where a control plane *writing* it should look.
|
|
36
|
+
*/
|
|
37
|
+
statusMeta?: {
|
|
38
|
+
reason?: string | null
|
|
39
|
+
message?: string | null
|
|
40
|
+
exitCode?: number | null
|
|
41
|
+
oomKilled?: boolean | null
|
|
42
|
+
/** What the worker was before it became `unknown` — the buttons to offer depend on it. */
|
|
43
|
+
previousStatus?: string | null
|
|
44
|
+
at?: string | null
|
|
45
|
+
} | null
|
|
31
46
|
/** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
|
|
32
47
|
dockerState?: string | null
|
|
33
48
|
createdAt?: string | null
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { resolveWorkerStatus, workerStatusConfig } from "./worker-status"
|
|
3
|
+
|
|
4
|
+
describe("resolveWorkerStatus — le node injoignable", () => {
|
|
5
|
+
it("ne prétend rien, et ne prétend surtout pas qu'un setup est en cours", () => {
|
|
6
|
+
const status = resolveWorkerStatus({ id: "w", state: "unknown" })
|
|
7
|
+
expect(status.key).toBe("unknown")
|
|
8
|
+
expect(status.label).toBe("Unknown")
|
|
9
|
+
expect(status.settingUp).toBe(false)
|
|
10
|
+
expect(status.running).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("prime sur un dockerState périmé", () => {
|
|
14
|
+
// Le cas réel : le dernier `inspect` réussi disait « running », puis le node est parti. La
|
|
15
|
+
// pastille doit dire qu'on ne sait pas, pas répéter une observation qu'on ne peut plus refaire.
|
|
16
|
+
const status = resolveWorkerStatus({ id: "w", state: "unknown", dockerState: "running" })
|
|
17
|
+
expect(status.key).toBe("unknown")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("dit pourquoi, quand le plan de contrôle l'a dit", () => {
|
|
21
|
+
const status = resolveWorkerStatus({
|
|
22
|
+
id: "w",
|
|
23
|
+
state: "unknown",
|
|
24
|
+
statusMeta: { reason: "node-disconnected", previousStatus: "ready" },
|
|
25
|
+
})
|
|
26
|
+
expect(status.detail).toBe("The node is unreachable")
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("resolveWorkerStatus — le conteneur mort tout seul", () => {
|
|
31
|
+
it("se résout sans dockerState, pour un produit qui n'a qu'un champ", () => {
|
|
32
|
+
// Spunto Lite n'a pas de second axe : avant, `exited` n'était atteignable que par la branche
|
|
33
|
+
// docker et un worker mort retombait sur le repli `pending`.
|
|
34
|
+
const status = resolveWorkerStatus({ id: "w", state: "exited" })
|
|
35
|
+
expect(status.key).toBe("exited")
|
|
36
|
+
expect(status.label).toBe("Exited")
|
|
37
|
+
expect(status.running).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("porte le code de sortie quand il y en a un", () => {
|
|
41
|
+
const status = resolveWorkerStatus({
|
|
42
|
+
id: "w",
|
|
43
|
+
state: "exited",
|
|
44
|
+
statusMeta: { reason: "oom-killed", exitCode: 137 },
|
|
45
|
+
})
|
|
46
|
+
expect(status.detail).toBe("Ran out of memory (exit 137)")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("garde un code de sortie nul, qui est une valeur et pas une absence", () => {
|
|
50
|
+
const status = resolveWorkerStatus({
|
|
51
|
+
id: "w",
|
|
52
|
+
state: "exited",
|
|
53
|
+
statusMeta: { reason: "completed", exitCode: 0 },
|
|
54
|
+
})
|
|
55
|
+
expect(status.detail).toBe("The container finished and exited cleanly (exit 0)")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("préfère toujours le message du plan de contrôle à sa propre formulation", () => {
|
|
59
|
+
const status = resolveWorkerStatus({
|
|
60
|
+
id: "w",
|
|
61
|
+
state: "exited",
|
|
62
|
+
statusMeta: { reason: "oom-killed", message: "Killed while compiling the kernel" },
|
|
63
|
+
})
|
|
64
|
+
expect(status.detail).toBe("Killed while compiling the kernel")
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("resolveWorkerStatus — la tolérance qui fait vivre la table", () => {
|
|
69
|
+
it("n'invente pas de détail pour une raison qu'elle ne connaît pas", () => {
|
|
70
|
+
const status = resolveWorkerStatus({ id: "w", state: "ready", statusMeta: { reason: "sonar-storm" } })
|
|
71
|
+
expect(status.detail).toBeUndefined()
|
|
72
|
+
expect(status.key).toBe("running")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("laisse les statuts connus intacts", () => {
|
|
76
|
+
expect(resolveWorkerStatus({ id: "w", state: "building" }).settingUp).toBe(true)
|
|
77
|
+
expect(resolveWorkerStatus({ id: "w", state: "ready", dockerState: "running" }).key).toBe("running")
|
|
78
|
+
expect(resolveWorkerStatus({ id: "w", state: "wat" }).key).toBe("pending")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("donne une pastille à chaque clé, sans exception", () => {
|
|
82
|
+
for (const [key, config] of Object.entries(workerStatusConfig)) {
|
|
83
|
+
expect(config.label, `"${key}" has no label`).toBeTruthy()
|
|
84
|
+
expect(config.dotClass, `"${key}" has no dot`).toBeTruthy()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
// This module is the union of both, plus the transient states (`pulling`,
|
|
7
7
|
// `stopping`, `deleting`) that only ever existed as ad-hoc `if`s above the table.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
// `
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// Three keys here are *control plane* states rather than docker ones, and they
|
|
10
|
+
// are the reason this table is not just a mapping of `docker inspect`:
|
|
11
|
+
//
|
|
12
|
+
// - `building` — waiting for the project image, before any container exists. Not
|
|
13
|
+
// folded into `pulling`: building an image and pulling one are different waits,
|
|
14
|
+
// and only one of them has a log worth opening.
|
|
15
|
+
// - `exited` — the container died without being asked to. It already existed as a
|
|
16
|
+
// *docker* state; it is now also a lifecycle one, so a product with a single
|
|
17
|
+
// `state` field resolves it without falling through to the fallback.
|
|
18
|
+
// - `unknown` — the node is unreachable, so we do not know. It claims nothing on
|
|
19
|
+
// purpose: showing the last known status as though it were current is the lie
|
|
20
|
+
// this key exists to avoid. `statusMeta.previousStatus` keeps what it was.
|
|
16
21
|
//
|
|
17
22
|
// No "use client": pure functions and hookless components, so a React Server
|
|
18
23
|
// Component can call them.
|
|
@@ -30,6 +35,7 @@ export type WorkerStatusKey =
|
|
|
30
35
|
| "pending"
|
|
31
36
|
| "setup"
|
|
32
37
|
| "building"
|
|
38
|
+
| "unknown"
|
|
33
39
|
| "pulling"
|
|
34
40
|
| "stopping"
|
|
35
41
|
| "deleting"
|
|
@@ -89,6 +95,9 @@ export const workerStatusConfig: Record<WorkerStatusKey, WorkerStatusConfig> = {
|
|
|
89
95
|
pending: BUSY,
|
|
90
96
|
setup: { ...BUSY, label: "Setting up…" },
|
|
91
97
|
building: { ...BUSY, label: "Building image…" },
|
|
98
|
+
// Volontairement neutre : ni jaune (rien n'est en cours, on ne sait pas), ni rouge
|
|
99
|
+
// (rien n'a échoué). C'est l'absence d'information, et elle a le droit de se voir.
|
|
100
|
+
unknown: { ...IDLE, label: "Unknown" },
|
|
92
101
|
pulling: { ...BUSY, label: "Pulling image…" },
|
|
93
102
|
stopping: { ...BUSY, label: "Stopping…" },
|
|
94
103
|
deleting: { ...BUSY, label: "Deleting…" },
|
|
@@ -100,16 +109,51 @@ export interface WorkerStatus extends WorkerStatusConfig {
|
|
|
100
109
|
settingUp: boolean
|
|
101
110
|
/** Container up and past its setup — stats, git chips and the pulsing dot apply. */
|
|
102
111
|
running: boolean
|
|
112
|
+
/**
|
|
113
|
+
* One sentence for what `statusMeta` carries — "Container ran out of memory", "was ready" —
|
|
114
|
+
* or `undefined` when there is nothing to add. The pill says *what*; this says *why*, and a
|
|
115
|
+
* consumer renders it beside the pill or not at all.
|
|
116
|
+
*/
|
|
117
|
+
detail?: string
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
/** Lifecycle states that mean "not usable yet, a setup is in flight". */
|
|
106
121
|
const SETUP_STATES = new Set(["provisioning", "starting", "setup"])
|
|
107
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Fallback phrasing per reason, used only when the control plane sent no `message`.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately short and deliberately incomplete: a reason this table has never heard of yields
|
|
127
|
+
* no detail rather than an invented one — same rule as the status table itself.
|
|
128
|
+
*/
|
|
129
|
+
const REASON_PHRASES: Record<string, string> = {
|
|
130
|
+
"oom-killed": "Ran out of memory",
|
|
131
|
+
exited: "The container exited on its own",
|
|
132
|
+
completed: "The container finished and exited cleanly",
|
|
133
|
+
"removed-externally": "The container is gone from the node",
|
|
134
|
+
"setup-failed": "Setup failed inside the container",
|
|
135
|
+
"build-failed": "The project image could not be built",
|
|
136
|
+
"spawn-orphaned": "Startup was interrupted and cannot resume",
|
|
137
|
+
"node-lost": "The node disconnected before the container existed",
|
|
138
|
+
"node-disconnected": "The node is unreachable",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What `statusMeta` has to say, if anything: the message it carried, else its reason. */
|
|
142
|
+
function detailOf(meta: WorkerCardWorker["statusMeta"]): string | undefined {
|
|
143
|
+
if (!meta) return undefined
|
|
144
|
+
if (meta.message) return meta.message
|
|
145
|
+
const phrase = meta.reason ? REASON_PHRASES[meta.reason] : undefined
|
|
146
|
+
if (!phrase) return undefined
|
|
147
|
+
// `exitCode` is worth showing when it is there — `0` included, which is why this is not `||`.
|
|
148
|
+
return meta.exitCode != null ? `${phrase} (exit ${meta.exitCode})` : phrase
|
|
149
|
+
}
|
|
150
|
+
|
|
108
151
|
/**
|
|
109
152
|
* Worker snapshot → one status, tolerant of shapes it has never seen.
|
|
110
153
|
*
|
|
111
154
|
* Order matters: the *lifecycle* state wins whenever it describes an action in
|
|
112
|
-
* flight (deleting, stopping, building, pulling, setting up),
|
|
155
|
+
* flight or already settled (deleting, stopping, unknown, exited, building, pulling, setting up),
|
|
156
|
+
* because docker still reports
|
|
113
157
|
* the container as `running` throughout — showing "Running" while a worker is
|
|
114
158
|
* being deleted is the bug this ordering exists to prevent. Past that, the
|
|
115
159
|
* docker state is the truth. Apps with a single `state` field (Spunto Lite) fall
|
|
@@ -120,16 +164,24 @@ export function resolveWorkerStatus(worker: WorkerCardWorker): WorkerStatus {
|
|
|
120
164
|
const state = worker.state ?? null
|
|
121
165
|
const docker = worker.dockerState ?? null
|
|
122
166
|
|
|
167
|
+
const detail = detailOf(worker.statusMeta)
|
|
123
168
|
const of = (key: WorkerStatusKey, flags?: Partial<Pick<WorkerStatus, "settingUp" | "running">>): WorkerStatus => ({
|
|
124
169
|
key,
|
|
125
170
|
...workerStatusConfig[key],
|
|
126
171
|
settingUp: false,
|
|
127
172
|
running: key === "running",
|
|
173
|
+
...(detail ? { detail } : {}),
|
|
128
174
|
...flags,
|
|
129
175
|
})
|
|
130
176
|
|
|
131
177
|
if (state === "deleting") return of("deleting")
|
|
132
178
|
if (state === "stopping") return of("stopping")
|
|
179
|
+
// Le node est injoignable : on ne sait pas. Surtout pas `settingUp` — annoncer une progression
|
|
180
|
+
// pour une machine qu'on ne voit plus est exactement ce que ce statut existe pour éviter.
|
|
181
|
+
if (state === "unknown") return of("unknown")
|
|
182
|
+
// Mort tout seul. Le statut de cycle de vie prime sur `dockerState`, qui dirait la même chose
|
|
183
|
+
// quand il est là — et ne dirait rien du tout chez un produit qui n'a qu'un champ.
|
|
184
|
+
if (state === "exited") return of("exited")
|
|
133
185
|
if (state === "building") return of("building", { settingUp: true })
|
|
134
186
|
if (state === "pulling") return of("pulling", { settingUp: true })
|
|
135
187
|
if (state && SETUP_STATES.has(state)) return of("setup", { settingUp: true })
|