slabkit 0.1.0__tar.gz
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.
- slabkit-0.1.0/.gitignore +5 -0
- slabkit-0.1.0/LICENSE +21 -0
- slabkit-0.1.0/PKG-INFO +224 -0
- slabkit-0.1.0/README.md +180 -0
- slabkit-0.1.0/examples/profile.py +67 -0
- slabkit-0.1.0/pyproject.toml +40 -0
- slabkit-0.1.0/slabkit/__init__.py +20 -0
- slabkit-0.1.0/slabkit/material.py +147 -0
- slabkit-0.1.0/slabkit/palette.py +109 -0
- slabkit-0.1.0/slabkit/panels.py +221 -0
- slabkit-0.1.0/tools/build_docs.py +77 -0
slabkit-0.1.0/.gitignore
ADDED
slabkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sverre
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
slabkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: slabkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SVG panel system for GitHub profile READMEs — OKLCH palettes with asserted contrast, superellipse corners, one motion idea per panel.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hellosverre/slab
|
|
6
|
+
Project-URL: Source, https://github.com/hellosverre/slab
|
|
7
|
+
Project-URL: Issues, https://github.com/hellosverre/slab/issues
|
|
8
|
+
Author-email: Sverre <sverresig@proton.me>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Sverre
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: design-system,generative,github,oklch,profile,readme,svg
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
37
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
38
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
39
|
+
Classifier: Typing :: Stubs Only
|
|
40
|
+
Requires-Python: >=3.9
|
|
41
|
+
Provides-Extra: fonts
|
|
42
|
+
Requires-Dist: fonttools>=4.0; extra == 'fonts'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
<div align="center">
|
|
46
|
+
<img alt="slab — SVG panels for GitHub READMEs" width="100%"
|
|
47
|
+
src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/hero.svg">
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div align="center">
|
|
51
|
+
<a href="https://pypi.org/project/slabkit/"><img alt="PyPI" src="https://img.shields.io/pypi/v/slabkit?style=flat-square&color=76D5A1&labelColor=081A10&label=pypi"></a>
|
|
52
|
+
|
|
53
|
+
<img alt="Python" src="https://img.shields.io/badge/python-3.9%2B-081A10?style=flat-square&logo=python&logoColor=76D5A1&labelColor=081A10">
|
|
54
|
+
|
|
55
|
+
<img alt="No runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-none-081A10?style=flat-square&labelColor=081A10">
|
|
56
|
+
|
|
57
|
+
<img alt="MIT" src="https://img.shields.io/badge/licence-MIT-76D5A1?style=flat-square&labelColor=081A10">
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<br>
|
|
61
|
+
|
|
62
|
+
A small Python library that generates the panels on a GitHub profile: a hero banner, a
|
|
63
|
+
stack grid, a status strip, a system diagram, a contact footer. Everything comes out as a
|
|
64
|
+
self-contained animated SVG with no external requests.
|
|
65
|
+
|
|
66
|
+
Every image above and below was generated by this library. If it could not document
|
|
67
|
+
itself it would not be worth publishing.
|
|
68
|
+
|
|
69
|
+
## Why this exists
|
|
70
|
+
|
|
71
|
+
GitHub is a hostile rendering target, and most of the rules only announce themselves once
|
|
72
|
+
something silently breaks:
|
|
73
|
+
|
|
74
|
+
| Constraint | Consequence |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `<style>` is escaped to plain text | No CSS. No `:target`, no `:hover`, no CSS-only toggles. |
|
|
77
|
+
| JavaScript is stripped | Nothing is interactive. |
|
|
78
|
+
| Images are proxied through camo | **Webfonts never load.** A `font-family` pointing at one silently falls back. |
|
|
79
|
+
| SVG is served as `<img>` | Pointer events never reach it, so links inside an SVG are dead. |
|
|
80
|
+
| `id` is rewritten to `user-content-id` | Anchor tricks lose their handle. |
|
|
81
|
+
|
|
82
|
+
What *does* survive is SMIL animation, filters, masks, gradients, and system fonts. slab
|
|
83
|
+
is built entirely inside that envelope, which is why it uses `<animate>` rather than CSS
|
|
84
|
+
keyframes and outlines display type to vector paths rather than linking a font.
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install slabkit
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The distribution is `slabkit` because `slab` was already taken on PyPI by a DSP library,
|
|
93
|
+
and sharing an import name with it would break anyone who has both installed.
|
|
94
|
+
|
|
95
|
+
No runtime dependencies — the whole library is string formatting and arithmetic.
|
|
96
|
+
`fonttools` is an optional extra, needed only for `outline()`, which converts a wordmark
|
|
97
|
+
to paths so a non-system typeface survives camo:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install "slabkit[fonts]"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Quickstart
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from slabkit import Theme, banner, chips, footer, write
|
|
107
|
+
|
|
108
|
+
t = Theme(158) # one hue derives the entire ramp
|
|
109
|
+
|
|
110
|
+
write("banner.svg", banner(
|
|
111
|
+
t, "SVERRE", "building AI systems, fundamentals first",
|
|
112
|
+
meta="TypeScript · Python · Next.js · Postgres",
|
|
113
|
+
note="Ski · Norway"))
|
|
114
|
+
|
|
115
|
+
write("stack.svg", chips(t, [
|
|
116
|
+
("WRITE", ["TypeScript", "Python"]),
|
|
117
|
+
("RUN", ["Linux", "Docker"]),
|
|
118
|
+
]))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Reference the result from your README by its `raw.githubusercontent.com` URL. Relative
|
|
122
|
+
paths are risky on profile READMEs, which render outside the repo's base path.
|
|
123
|
+
|
|
124
|
+
## Theming
|
|
125
|
+
|
|
126
|
+
`Theme(hue)` derives ink, surface, text, muted, dim and accent in OKLCH from a single hue,
|
|
127
|
+
varying almost entirely in lightness. It then **asserts the contrast ratios and raises if
|
|
128
|
+
any role falls below 4.5:1**, so an unreadable palette fails at build time rather than
|
|
129
|
+
shipping.
|
|
130
|
+
|
|
131
|
+
<div align="center">
|
|
132
|
+
<img alt="The same ramp at five hues" width="100%"
|
|
133
|
+
src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/hues.svg">
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
Theme(158).report() # audit any hue before committing to it
|
|
138
|
+
Theme(250, accent="#81C3FF") # override individual roles when you must
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Panels
|
|
142
|
+
|
|
143
|
+
#### `banner(theme, wordmark, tagline, meta, note, font=...)`
|
|
144
|
+
|
|
145
|
+
Pass `font=` a `.ttf`/`.otf` to outline the wordmark to paths. Any typeface works, because
|
|
146
|
+
the glyphs ship as geometry rather than as a font reference.
|
|
147
|
+
|
|
148
|
+
#### `chips(theme, groups)`
|
|
149
|
+
|
|
150
|
+
<img alt="chips panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-chips.svg">
|
|
151
|
+
|
|
152
|
+
A raking light crosses the grid, brightening each chip as it passes. Two copies of the
|
|
153
|
+
grid, the lit one revealed through a soft gradient **mask** — a clip would give a hard
|
|
154
|
+
edge, which reads as a wipe rather than as light.
|
|
155
|
+
|
|
156
|
+
#### `status(theme, label, state, word, headline, detail, right, stamp)`
|
|
157
|
+
|
|
158
|
+
<img alt="status panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-status.svg">
|
|
159
|
+
|
|
160
|
+
`state` is `ok` / `warn` / `bad`. Those colours are semantic rather than brand, which is
|
|
161
|
+
why they are exempt from the one-accent rule — but they are derived at the accent's OKLCH
|
|
162
|
+
lightness so they still belong to the same band.
|
|
163
|
+
|
|
164
|
+
**Always pass `stamp`.** A committed SVG behind camo is stale by design, and a status
|
|
165
|
+
panel implying real-time is simply lying.
|
|
166
|
+
|
|
167
|
+
#### `diagram(theme, title, subtitle, nodes, edges, caption)`
|
|
168
|
+
|
|
169
|
+
<img alt="diagram panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-diagram.svg">
|
|
170
|
+
|
|
171
|
+
Nodes are dicts, edges are index pairs. A request relays along the edges and responses
|
|
172
|
+
return, staggered by graph depth so parallel branches fire together instead of in series.
|
|
173
|
+
Useful when the code is private and the shape is the only thing you can show.
|
|
174
|
+
|
|
175
|
+
#### `footer(theme, headline, sub, right)` · `pill(theme, label, on)`
|
|
176
|
+
|
|
177
|
+
<img alt="footer panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-footer.svg">
|
|
178
|
+
|
|
179
|
+
`pill()` renders one small standalone pill — a language toggle, nav, a tag. One image per
|
|
180
|
+
pill on purpose: markdown hangs a single `href` off an `<img>`, so a combined toggle would
|
|
181
|
+
send both halves to the same place.
|
|
182
|
+
|
|
183
|
+
## Design rules
|
|
184
|
+
|
|
185
|
+
These are the constraints the panels are built to, and they are the actual product. Most
|
|
186
|
+
generated READMEs look identical because they all make the same handful of colour
|
|
187
|
+
decisions; each rule below exists to refuse one of them.
|
|
188
|
+
|
|
189
|
+
- **One accent.** Hierarchy comes from lightness, not chroma. `Theme` keeps the accent
|
|
190
|
+
under 80% HSL saturation.
|
|
191
|
+
- **Never the default dark.** Slate-indigo ink around `#0c0e15` is the night-mode twin of
|
|
192
|
+
the blue-to-purple gradient. Pick a hue and commit to it.
|
|
193
|
+
- **No gradient-clipped headline text.** Solid ink; weight and size do the work.
|
|
194
|
+
- **No coloured glow, no glow blobs.** Depth is one hairline border and a rim light.
|
|
195
|
+
- **Grain on every surface.** A perfectly smooth gradient is the giveaway that nothing
|
|
196
|
+
physical made it.
|
|
197
|
+
- **Superellipse corners, not `border-radius`.** A radius corner has discontinuous
|
|
198
|
+
curvature where the arc meets the edge; a superellipse does not.
|
|
199
|
+
- **One motion idea per panel**, and stagger the timings across panels. Elements pulsing
|
|
200
|
+
in unison is what makes motion look cheap.
|
|
201
|
+
|
|
202
|
+
## Releasing
|
|
203
|
+
|
|
204
|
+
Publishing runs on [Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so
|
|
205
|
+
there is no API token anywhere in this repo — GitHub mints a short-lived credential for
|
|
206
|
+
the `publish` workflow and PyPI verifies it.
|
|
207
|
+
|
|
208
|
+
One-time setup on PyPI (Account → Publishing → add a pending publisher):
|
|
209
|
+
|
|
210
|
+
| Field | Value |
|
|
211
|
+
|---|---|
|
|
212
|
+
| PyPI project name | `slabkit` |
|
|
213
|
+
| Owner | `hellosverre` |
|
|
214
|
+
| Repository name | `slab` |
|
|
215
|
+
| Workflow name | `publish.yml` |
|
|
216
|
+
| Environment name | `pypi` |
|
|
217
|
+
|
|
218
|
+
After that, bump `version` in `pyproject.toml` and publish a GitHub release. The workflow
|
|
219
|
+
builds, runs `twine check`, installs the wheel into a clean venv and renders every panel
|
|
220
|
+
before it uploads anything.
|
|
221
|
+
|
|
222
|
+
## Licence
|
|
223
|
+
|
|
224
|
+
MIT. See [LICENSE](LICENSE).
|
slabkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img alt="slab — SVG panels for GitHub READMEs" width="100%"
|
|
3
|
+
src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/hero.svg">
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div align="center">
|
|
7
|
+
<a href="https://pypi.org/project/slabkit/"><img alt="PyPI" src="https://img.shields.io/pypi/v/slabkit?style=flat-square&color=76D5A1&labelColor=081A10&label=pypi"></a>
|
|
8
|
+
|
|
9
|
+
<img alt="Python" src="https://img.shields.io/badge/python-3.9%2B-081A10?style=flat-square&logo=python&logoColor=76D5A1&labelColor=081A10">
|
|
10
|
+
|
|
11
|
+
<img alt="No runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-none-081A10?style=flat-square&labelColor=081A10">
|
|
12
|
+
|
|
13
|
+
<img alt="MIT" src="https://img.shields.io/badge/licence-MIT-76D5A1?style=flat-square&labelColor=081A10">
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<br>
|
|
17
|
+
|
|
18
|
+
A small Python library that generates the panels on a GitHub profile: a hero banner, a
|
|
19
|
+
stack grid, a status strip, a system diagram, a contact footer. Everything comes out as a
|
|
20
|
+
self-contained animated SVG with no external requests.
|
|
21
|
+
|
|
22
|
+
Every image above and below was generated by this library. If it could not document
|
|
23
|
+
itself it would not be worth publishing.
|
|
24
|
+
|
|
25
|
+
## Why this exists
|
|
26
|
+
|
|
27
|
+
GitHub is a hostile rendering target, and most of the rules only announce themselves once
|
|
28
|
+
something silently breaks:
|
|
29
|
+
|
|
30
|
+
| Constraint | Consequence |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `<style>` is escaped to plain text | No CSS. No `:target`, no `:hover`, no CSS-only toggles. |
|
|
33
|
+
| JavaScript is stripped | Nothing is interactive. |
|
|
34
|
+
| Images are proxied through camo | **Webfonts never load.** A `font-family` pointing at one silently falls back. |
|
|
35
|
+
| SVG is served as `<img>` | Pointer events never reach it, so links inside an SVG are dead. |
|
|
36
|
+
| `id` is rewritten to `user-content-id` | Anchor tricks lose their handle. |
|
|
37
|
+
|
|
38
|
+
What *does* survive is SMIL animation, filters, masks, gradients, and system fonts. slab
|
|
39
|
+
is built entirely inside that envelope, which is why it uses `<animate>` rather than CSS
|
|
40
|
+
keyframes and outlines display type to vector paths rather than linking a font.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install slabkit
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The distribution is `slabkit` because `slab` was already taken on PyPI by a DSP library,
|
|
49
|
+
and sharing an import name with it would break anyone who has both installed.
|
|
50
|
+
|
|
51
|
+
No runtime dependencies — the whole library is string formatting and arithmetic.
|
|
52
|
+
`fonttools` is an optional extra, needed only for `outline()`, which converts a wordmark
|
|
53
|
+
to paths so a non-system typeface survives camo:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install "slabkit[fonts]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from slabkit import Theme, banner, chips, footer, write
|
|
63
|
+
|
|
64
|
+
t = Theme(158) # one hue derives the entire ramp
|
|
65
|
+
|
|
66
|
+
write("banner.svg", banner(
|
|
67
|
+
t, "SVERRE", "building AI systems, fundamentals first",
|
|
68
|
+
meta="TypeScript · Python · Next.js · Postgres",
|
|
69
|
+
note="Ski · Norway"))
|
|
70
|
+
|
|
71
|
+
write("stack.svg", chips(t, [
|
|
72
|
+
("WRITE", ["TypeScript", "Python"]),
|
|
73
|
+
("RUN", ["Linux", "Docker"]),
|
|
74
|
+
]))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Reference the result from your README by its `raw.githubusercontent.com` URL. Relative
|
|
78
|
+
paths are risky on profile READMEs, which render outside the repo's base path.
|
|
79
|
+
|
|
80
|
+
## Theming
|
|
81
|
+
|
|
82
|
+
`Theme(hue)` derives ink, surface, text, muted, dim and accent in OKLCH from a single hue,
|
|
83
|
+
varying almost entirely in lightness. It then **asserts the contrast ratios and raises if
|
|
84
|
+
any role falls below 4.5:1**, so an unreadable palette fails at build time rather than
|
|
85
|
+
shipping.
|
|
86
|
+
|
|
87
|
+
<div align="center">
|
|
88
|
+
<img alt="The same ramp at five hues" width="100%"
|
|
89
|
+
src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/hues.svg">
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
Theme(158).report() # audit any hue before committing to it
|
|
94
|
+
Theme(250, accent="#81C3FF") # override individual roles when you must
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Panels
|
|
98
|
+
|
|
99
|
+
#### `banner(theme, wordmark, tagline, meta, note, font=...)`
|
|
100
|
+
|
|
101
|
+
Pass `font=` a `.ttf`/`.otf` to outline the wordmark to paths. Any typeface works, because
|
|
102
|
+
the glyphs ship as geometry rather than as a font reference.
|
|
103
|
+
|
|
104
|
+
#### `chips(theme, groups)`
|
|
105
|
+
|
|
106
|
+
<img alt="chips panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-chips.svg">
|
|
107
|
+
|
|
108
|
+
A raking light crosses the grid, brightening each chip as it passes. Two copies of the
|
|
109
|
+
grid, the lit one revealed through a soft gradient **mask** — a clip would give a hard
|
|
110
|
+
edge, which reads as a wipe rather than as light.
|
|
111
|
+
|
|
112
|
+
#### `status(theme, label, state, word, headline, detail, right, stamp)`
|
|
113
|
+
|
|
114
|
+
<img alt="status panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-status.svg">
|
|
115
|
+
|
|
116
|
+
`state` is `ok` / `warn` / `bad`. Those colours are semantic rather than brand, which is
|
|
117
|
+
why they are exempt from the one-accent rule — but they are derived at the accent's OKLCH
|
|
118
|
+
lightness so they still belong to the same band.
|
|
119
|
+
|
|
120
|
+
**Always pass `stamp`.** A committed SVG behind camo is stale by design, and a status
|
|
121
|
+
panel implying real-time is simply lying.
|
|
122
|
+
|
|
123
|
+
#### `diagram(theme, title, subtitle, nodes, edges, caption)`
|
|
124
|
+
|
|
125
|
+
<img alt="diagram panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-diagram.svg">
|
|
126
|
+
|
|
127
|
+
Nodes are dicts, edges are index pairs. A request relays along the edges and responses
|
|
128
|
+
return, staggered by graph depth so parallel branches fire together instead of in series.
|
|
129
|
+
Useful when the code is private and the shape is the only thing you can show.
|
|
130
|
+
|
|
131
|
+
#### `footer(theme, headline, sub, right)` · `pill(theme, label, on)`
|
|
132
|
+
|
|
133
|
+
<img alt="footer panel" width="100%" src="https://raw.githubusercontent.com/hellosverre/slab/main/assets/panel-footer.svg">
|
|
134
|
+
|
|
135
|
+
`pill()` renders one small standalone pill — a language toggle, nav, a tag. One image per
|
|
136
|
+
pill on purpose: markdown hangs a single `href` off an `<img>`, so a combined toggle would
|
|
137
|
+
send both halves to the same place.
|
|
138
|
+
|
|
139
|
+
## Design rules
|
|
140
|
+
|
|
141
|
+
These are the constraints the panels are built to, and they are the actual product. Most
|
|
142
|
+
generated READMEs look identical because they all make the same handful of colour
|
|
143
|
+
decisions; each rule below exists to refuse one of them.
|
|
144
|
+
|
|
145
|
+
- **One accent.** Hierarchy comes from lightness, not chroma. `Theme` keeps the accent
|
|
146
|
+
under 80% HSL saturation.
|
|
147
|
+
- **Never the default dark.** Slate-indigo ink around `#0c0e15` is the night-mode twin of
|
|
148
|
+
the blue-to-purple gradient. Pick a hue and commit to it.
|
|
149
|
+
- **No gradient-clipped headline text.** Solid ink; weight and size do the work.
|
|
150
|
+
- **No coloured glow, no glow blobs.** Depth is one hairline border and a rim light.
|
|
151
|
+
- **Grain on every surface.** A perfectly smooth gradient is the giveaway that nothing
|
|
152
|
+
physical made it.
|
|
153
|
+
- **Superellipse corners, not `border-radius`.** A radius corner has discontinuous
|
|
154
|
+
curvature where the arc meets the edge; a superellipse does not.
|
|
155
|
+
- **One motion idea per panel**, and stagger the timings across panels. Elements pulsing
|
|
156
|
+
in unison is what makes motion look cheap.
|
|
157
|
+
|
|
158
|
+
## Releasing
|
|
159
|
+
|
|
160
|
+
Publishing runs on [Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so
|
|
161
|
+
there is no API token anywhere in this repo — GitHub mints a short-lived credential for
|
|
162
|
+
the `publish` workflow and PyPI verifies it.
|
|
163
|
+
|
|
164
|
+
One-time setup on PyPI (Account → Publishing → add a pending publisher):
|
|
165
|
+
|
|
166
|
+
| Field | Value |
|
|
167
|
+
|---|---|
|
|
168
|
+
| PyPI project name | `slabkit` |
|
|
169
|
+
| Owner | `hellosverre` |
|
|
170
|
+
| Repository name | `slab` |
|
|
171
|
+
| Workflow name | `publish.yml` |
|
|
172
|
+
| Environment name | `pypi` |
|
|
173
|
+
|
|
174
|
+
After that, bump `version` in `pyproject.toml` and publish a GitHub release. The workflow
|
|
175
|
+
builds, runs `twine check`, installs the wheel into a clean venv and renders every panel
|
|
176
|
+
before it uploads anything.
|
|
177
|
+
|
|
178
|
+
## Licence
|
|
179
|
+
|
|
180
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Builds a full profile panel set. This is the real config behind github.com/hellosverre.
|
|
2
|
+
|
|
3
|
+
python examples/profile.py
|
|
4
|
+
"""
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
10
|
+
|
|
11
|
+
from slabkit import Theme, banner, chips, diagram, footer, pill, status, write
|
|
12
|
+
|
|
13
|
+
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "out")
|
|
14
|
+
os.makedirs(OUT, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
theme = Theme(158)
|
|
17
|
+
|
|
18
|
+
# Set this to a .ttf/.otf to outline the wordmark; None falls back to the mono stack.
|
|
19
|
+
# Any typeface works because the glyphs ship as paths, not as a font reference.
|
|
20
|
+
WORDMARK_FONT = None
|
|
21
|
+
|
|
22
|
+
STACK = [
|
|
23
|
+
("WRITE", ["TypeScript", "JavaScript", "Python"]),
|
|
24
|
+
("BUILD", ["Next.js", "React", "Hono", "Tailwind", "Tauri"]),
|
|
25
|
+
("STORE", ["Postgres", "libSQL", "Redis", "Drizzle"]),
|
|
26
|
+
("RUN", ["Linux", "Docker", "Cloudflare Tunnel", "Vercel", "Proxmox"]),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
NODES = [
|
|
30
|
+
dict(x=56, w=118, cy=142, label="browser", sub=""),
|
|
31
|
+
dict(x=214, w=168, cy=142, label="Vercel", sub="Next.js 15"),
|
|
32
|
+
dict(x=422, w=176, cy=142, label="Cloudflare", sub="Tunnel"),
|
|
33
|
+
dict(x=638, w=150, cy=142, label="VM", sub="Hono + Drizzle"),
|
|
34
|
+
dict(x=828, w=126, cy=100, label="Postgres", sub=""),
|
|
35
|
+
dict(x=828, w=126, cy=184, label="Redis", sub=""),
|
|
36
|
+
]
|
|
37
|
+
EDGES = [(0, 1), (1, 2), (2, 3), (3, 4), (3, 5)]
|
|
38
|
+
|
|
39
|
+
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
40
|
+
|
|
41
|
+
built = [
|
|
42
|
+
("banner.svg", banner(
|
|
43
|
+
theme, "SVERRE", "building AI systems, fundamentals first",
|
|
44
|
+
meta="TypeScript · Python · Next.js · Hono · Postgres · Docker · Linux",
|
|
45
|
+
note="Ski · Norway", font=WORDMARK_FONT)),
|
|
46
|
+
("stack.svg", chips(theme, STACK)),
|
|
47
|
+
("architecture.svg", diagram(
|
|
48
|
+
theme, "AETHER", "request path", NODES, EDGES,
|
|
49
|
+
caption="No inbound ports open on the VM — the tunnel dials out to Cloudflare.")),
|
|
50
|
+
("status.svg", status(
|
|
51
|
+
theme, "HOME SERVER", "warn", "MAINTENANCE", "storage pool rebuild",
|
|
52
|
+
"Box is down on purpose while I rebuild the storage pool.",
|
|
53
|
+
right="down since 2026-08-24", stamp="generated " + stamp)),
|
|
54
|
+
("footer.svg", footer(
|
|
55
|
+
theme, "sverresig@proton.me",
|
|
56
|
+
"Building an AI system that has to survive contact with real users?",
|
|
57
|
+
right="open to apprenticeship · aug 2027")),
|
|
58
|
+
("lang-en-on.svg", pill(theme, "English", True)),
|
|
59
|
+
("lang-en-off.svg", pill(theme, "English", False)),
|
|
60
|
+
("lang-no-on.svg", pill(theme, "Norsk", True)),
|
|
61
|
+
("lang-no-off.svg", pill(theme, "Norsk", False)),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
print(theme.report(), "\n")
|
|
65
|
+
for name, svg in built:
|
|
66
|
+
p = write(os.path.join(OUT, name), svg)
|
|
67
|
+
print(" %-20s %6d bytes" % (name, os.path.getsize(p)))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "slabkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SVG panel system for GitHub profile READMEs — OKLCH palettes with asserted contrast, superellipse corners, one motion idea per panel."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Sverre", email = "sverresig@proton.me" }]
|
|
13
|
+
keywords = ["svg", "github", "readme", "profile", "design-system", "oklch", "generative"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Multimedia :: Graphics",
|
|
21
|
+
"Topic :: Software Development :: Documentation",
|
|
22
|
+
"Typing :: Stubs Only",
|
|
23
|
+
]
|
|
24
|
+
# Nothing at runtime on purpose: the whole library is string formatting and arithmetic.
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
# Only outline() needs this, and only when you want a wordmark in a non-system typeface.
|
|
29
|
+
fonts = ["fonttools>=4.0"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/hellosverre/slab"
|
|
33
|
+
Source = "https://github.com/hellosverre/slab"
|
|
34
|
+
Issues = "https://github.com/hellosverre/slab/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["slabkit"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["slabkit", "examples", "tools", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""slabkit -- an SVG panel system for GitHub profile READMEs.
|
|
2
|
+
|
|
3
|
+
from slabkit import Theme, banner, chips, footer, write
|
|
4
|
+
|
|
5
|
+
t = Theme(158)
|
|
6
|
+
write("banner.svg", banner(t, "SVERRE", "building AI systems, fundamentals first"))
|
|
7
|
+
|
|
8
|
+
Everything renders to a self-contained SVG with no external requests, because GitHub
|
|
9
|
+
serves README images through a proxy that blocks them.
|
|
10
|
+
"""
|
|
11
|
+
from .palette import Theme, contrast, hsl_saturation, oklch_to_hex
|
|
12
|
+
from .material import MONO, Slab, mono_width, outline, squircle, write
|
|
13
|
+
from .panels import banner, chips, diagram, footer, pill, status
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Theme", "contrast", "hsl_saturation", "oklch_to_hex",
|
|
18
|
+
"MONO", "Slab", "mono_width", "outline", "squircle", "write",
|
|
19
|
+
"banner", "chips", "diagram", "footer", "pill", "status",
|
|
20
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The surface every panel is cut from.
|
|
2
|
+
|
|
3
|
+
A slab is: a two-stop lightness ramp, a superellipse outline, film grain, a rim light
|
|
4
|
+
along the top edge, a hairline border, and one slow specular band crossing it. Nothing
|
|
5
|
+
glows, nothing blurs coloured light, and the only motion is the band.
|
|
6
|
+
|
|
7
|
+
Text is either a system monospace stack or outlined to paths. GitHub proxies README
|
|
8
|
+
images through camo, which blocks webfont requests -- a `font-family` pointing at a
|
|
9
|
+
webfont silently falls back to something else on the reader's machine.
|
|
10
|
+
"""
|
|
11
|
+
import io
|
|
12
|
+
|
|
13
|
+
__all__ = ["MONO", "mono_width", "squircle", "outline", "Slab"]
|
|
14
|
+
|
|
15
|
+
MONO = "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace"
|
|
16
|
+
|
|
17
|
+
# A monospace advance is a fixed fraction of the font size. This is the only reason chip
|
|
18
|
+
# and label widths can be laid out without shaping text at build time. Measured against
|
|
19
|
+
# rendered output, not assumed.
|
|
20
|
+
MONO_ADVANCE = 0.601
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mono_width(text, size):
|
|
24
|
+
"""Rendered width of `text` in the monospace stack at `size` px."""
|
|
25
|
+
return len(text) * size * MONO_ADVANCE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def squircle(w, h, r, n=4.5, seg=18):
|
|
29
|
+
"""Rounded rect whose corners are superellipse quadrants (|x|^n + |y|^n = r^n).
|
|
30
|
+
|
|
31
|
+
A border-radius corner has discontinuous curvature where the arc meets the straight
|
|
32
|
+
edge. A superellipse does not, which is why Apple's shapes use one and why this
|
|
33
|
+
reads as considered next to every `rounded-2xl` card on the page.
|
|
34
|
+
"""
|
|
35
|
+
def quad(cx, cy, sx, sy):
|
|
36
|
+
return [(cx + sx * (r * i / seg),
|
|
37
|
+
cy + sy * (max(r ** n - (r * i / seg) ** n, 0.0)) ** (1.0 / n))
|
|
38
|
+
for i in range(seg + 1)]
|
|
39
|
+
|
|
40
|
+
d = ["M %.2f %.2f" % (r, 0)]
|
|
41
|
+
for pts in (quad(w - r, r, 1, -1), # top-right
|
|
42
|
+
quad(w - r, h - r, 1, 1)[::-1], # bottom-right
|
|
43
|
+
quad(r, h - r, -1, 1), # bottom-left
|
|
44
|
+
quad(r, r, -1, -1)[::-1]): # top-left
|
|
45
|
+
d += ["L %.2f %.2f" % pt for pt in pts]
|
|
46
|
+
d.append("Z")
|
|
47
|
+
return " ".join(d)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def outline(font_path, text, cap_height, tracking_em=0.02):
|
|
51
|
+
"""Glyph outlines as one SVG path, y-flipped into user space, sitting on y=0.
|
|
52
|
+
|
|
53
|
+
Returns (path_d, width, scale). Requires fonttools. Use this for a wordmark in a
|
|
54
|
+
typeface the reader will not have -- outlines survive camo, font references do not.
|
|
55
|
+
"""
|
|
56
|
+
from fontTools.ttLib import TTFont
|
|
57
|
+
from fontTools.pens.svgPathPen import SVGPathPen
|
|
58
|
+
from fontTools.pens.transformPen import TransformPen
|
|
59
|
+
from fontTools.misc.transform import Identity
|
|
60
|
+
|
|
61
|
+
font = TTFont(font_path, fontNumber=0)
|
|
62
|
+
cmap, gs = font.getBestCmap(), font.getGlyphSet()
|
|
63
|
+
upem = font["head"].unitsPerEm
|
|
64
|
+
cap = getattr(font["OS/2"], "sCapHeight", 0) if "OS/2" in font else 0
|
|
65
|
+
scale = cap_height / float(cap or upem * 0.7)
|
|
66
|
+
|
|
67
|
+
pen = SVGPathPen(gs, ntos=lambda v: "%.1f" % v)
|
|
68
|
+
x, track = 0.0, tracking_em * upem
|
|
69
|
+
for ch in text:
|
|
70
|
+
gname = cmap.get(ord(ch))
|
|
71
|
+
if gname is None:
|
|
72
|
+
raise ValueError("no glyph for %r in %s" % (ch, font_path))
|
|
73
|
+
gs[gname].draw(TransformPen(pen, Identity.translate(x, 0).scale(1, -1)))
|
|
74
|
+
x += gs[gname].width + track
|
|
75
|
+
return pen.getCommands(), (x - track) * scale, scale
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Slab:
|
|
79
|
+
"""One panel's surface. Compose content between `open_()` and `close()`.
|
|
80
|
+
|
|
81
|
+
ids are namespaced per panel: GitHub inlines several of these images into a single
|
|
82
|
+
document, and colliding gradient ids would let the last panel win everywhere.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, theme, uid, w, h, radius=30, sweep=13, sweep_delay=0, band=360):
|
|
86
|
+
self.t, self.uid, self.w, self.h = theme, uid, w, h
|
|
87
|
+
self.radius, self.sweep, self.sweep_delay, self.band = radius, sweep, sweep_delay, band
|
|
88
|
+
self.shape = squircle(w, h, radius)
|
|
89
|
+
|
|
90
|
+
def defs(self):
|
|
91
|
+
t, u, w, h = self.t, self.uid, self.w, self.h
|
|
92
|
+
return f'''
|
|
93
|
+
<linearGradient id="{u}-slab" x1="0" y1="0" x2="0.35" y2="1">
|
|
94
|
+
<stop offset="0" stop-color="{t.surface}"/><stop offset="1" stop-color="{t.ink}"/>
|
|
95
|
+
</linearGradient>
|
|
96
|
+
<linearGradient id="{u}-rim" x1="0" y1="0" x2="1" y2="0">
|
|
97
|
+
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.02"/>
|
|
98
|
+
<stop offset="0.35" stop-color="#FFFFFF" stop-opacity="0.16"/>
|
|
99
|
+
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.03"/>
|
|
100
|
+
</linearGradient>
|
|
101
|
+
<linearGradient id="{u}-sheen" x1="0" y1="0" x2="1" y2="0">
|
|
102
|
+
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
103
|
+
<stop offset="0.25" stop-color="#FFFFFF" stop-opacity="0.018"/>
|
|
104
|
+
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.075"/>
|
|
105
|
+
<stop offset="0.75" stop-color="#FFFFFF" stop-opacity="0.018"/>
|
|
106
|
+
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
107
|
+
</linearGradient>
|
|
108
|
+
<filter id="{u}-grain" x="0" y="0" width="100%" height="100%">
|
|
109
|
+
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch" result="n"/>
|
|
110
|
+
<feColorMatrix in="n" type="saturate" values="0"/>
|
|
111
|
+
</filter>
|
|
112
|
+
<clipPath id="{u}-clip"><path d="{self.shape}"/></clipPath>'''
|
|
113
|
+
|
|
114
|
+
def background(self):
|
|
115
|
+
"""Gradient, travelling specular band, grain. A perfectly smooth gradient is the
|
|
116
|
+
giveaway that nothing physical made it, so the grain is not optional."""
|
|
117
|
+
u, w, h, b = self.uid, self.w, self.h, self.band
|
|
118
|
+
return f'''
|
|
119
|
+
<rect width="{w}" height="{h}" fill="url(#{u}-slab)"/>
|
|
120
|
+
<g transform="translate({-b - 100},0)">
|
|
121
|
+
<rect x="0" y="{-h}" width="{b}" height="{h * 3}" fill="url(#{u}-sheen)"
|
|
122
|
+
transform="rotate(14 {b/2} {h/2})"/>
|
|
123
|
+
<animateTransform attributeName="transform" type="translate"
|
|
124
|
+
values="{-b - 100},0; {w + b},0" dur="{self.sweep}s"
|
|
125
|
+
begin="{self.sweep_delay}s" repeatCount="indefinite"/>
|
|
126
|
+
</g>
|
|
127
|
+
<rect width="{w}" height="{h}" filter="url(#{u}-grain)" opacity="0.055"/>'''
|
|
128
|
+
|
|
129
|
+
def render(self, body, label="", extra_defs=""):
|
|
130
|
+
"""Wrap panel content in the full slab. `label` becomes the aria-label."""
|
|
131
|
+
u, w, h = self.uid, self.w, self.h
|
|
132
|
+
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}" role="img" aria-label="{label}">
|
|
133
|
+
<defs>{self.defs()}{extra_defs}</defs>
|
|
134
|
+
<g clip-path="url(#{u}-clip)">
|
|
135
|
+
{self.background()}
|
|
136
|
+
{body}
|
|
137
|
+
<rect width="{w}" height="1.25" fill="url(#{u}-rim)"/>
|
|
138
|
+
</g>
|
|
139
|
+
<path d="{self.shape}" fill="none" stroke="#FFFFFF" stroke-opacity="0.07" stroke-width="1"/>
|
|
140
|
+
</svg>
|
|
141
|
+
'''
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def write(path, svg):
|
|
145
|
+
with io.open(path, "w", encoding="utf-8") as f:
|
|
146
|
+
f.write(svg)
|
|
147
|
+
return path
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Palettes authored in OKLCH, converted to sRGB, with contrast checked rather than hoped for.
|
|
2
|
+
|
|
3
|
+
The whole point of deriving a theme from a single hue is that hierarchy then comes from
|
|
4
|
+
lightness instead of chroma. That is the difference between a palette that looks designed
|
|
5
|
+
and the blue-to-purple gradient every generated UI reaches for.
|
|
6
|
+
"""
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
__all__ = ["oklch_to_hex", "contrast", "hsl_saturation", "Theme"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _lin_to_srgb(c):
|
|
13
|
+
return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _srgb_to_lin(c):
|
|
17
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def oklch_to_hex(L, C, H):
|
|
21
|
+
"""OKLCH -> sRGB hex. Returns (hex, clipped) so callers can notice gamut overflow."""
|
|
22
|
+
a, b = C * math.cos(math.radians(H)), C * math.sin(math.radians(H))
|
|
23
|
+
l_, m_, s_ = (L + 0.3963377774 * a + 0.2158037573 * b,
|
|
24
|
+
L - 0.1055613458 * a - 0.0638541728 * b,
|
|
25
|
+
L - 0.0894841775 * a - 1.2914855480 * b)
|
|
26
|
+
l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
|
|
27
|
+
rgb = (+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
28
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
29
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
|
|
30
|
+
out, clipped = [], False
|
|
31
|
+
for v in rgb:
|
|
32
|
+
v = _lin_to_srgb(v)
|
|
33
|
+
if v < -0.001 or v > 1.001:
|
|
34
|
+
clipped = True
|
|
35
|
+
out.append(max(0.0, min(1.0, v)))
|
|
36
|
+
return "#%02X%02X%02X" % tuple(round(v * 255) for v in out), clipped
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _luminance(hex_):
|
|
40
|
+
r, g, b = (_srgb_to_lin(int(hex_[i:i + 2], 16) / 255) for i in (1, 3, 5))
|
|
41
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def contrast(a, b):
|
|
45
|
+
"""WCAG contrast ratio between two hex colours."""
|
|
46
|
+
la, lb = _luminance(a), _luminance(b)
|
|
47
|
+
hi, lo = max(la, lb), min(la, lb)
|
|
48
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def hsl_saturation(hex_):
|
|
52
|
+
r, g, b = (int(hex_[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
|
53
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
54
|
+
if mx == mn:
|
|
55
|
+
return 0.0
|
|
56
|
+
l, d = (mx + mn) / 2, mx - mn
|
|
57
|
+
return d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# L and C per role. Only the hue moves between themes, which is what keeps a theme
|
|
61
|
+
# coherent no matter which hue you hand it.
|
|
62
|
+
_ROLES = {
|
|
63
|
+
"ink": (0.145, 0.024), # bottom of the surface ramp
|
|
64
|
+
"surface": (0.200, 0.032), # top of the surface ramp
|
|
65
|
+
"text": (0.965, 0.008),
|
|
66
|
+
"muted": (0.740, 0.012),
|
|
67
|
+
"dim": (0.585, 0.012),
|
|
68
|
+
"accent": (0.800, 0.118),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Status hues are semantic rather than brand, so they are exempt from the one-accent
|
|
72
|
+
# rule -- but they are pinned to the accent's lightness so they still belong.
|
|
73
|
+
_STATUS = {"ok": 158, "warn": 78, "bad": 25}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Theme:
|
|
77
|
+
"""A full surface ramp derived from one hue.
|
|
78
|
+
|
|
79
|
+
>>> t = Theme(158)
|
|
80
|
+
>>> t.accent
|
|
81
|
+
'#76D5A1'
|
|
82
|
+
>>> round(contrast(t.text, t.ink))
|
|
83
|
+
18
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, hue=158, min_contrast=4.5, **overrides):
|
|
87
|
+
self.hue = hue
|
|
88
|
+
for name, (L, C) in _ROLES.items():
|
|
89
|
+
setattr(self, name, overrides.get(name) or oklch_to_hex(L, C, hue)[0])
|
|
90
|
+
for name, h in _STATUS.items():
|
|
91
|
+
L, C = (0.700, 0.150) if name == "bad" else _ROLES["accent"]
|
|
92
|
+
setattr(self, name, overrides.get(name) or oklch_to_hex(L, C, h)[0])
|
|
93
|
+
|
|
94
|
+
# Fail at build time rather than shipping unreadable text.
|
|
95
|
+
for role in ("text", "muted", "dim", "accent"):
|
|
96
|
+
got = contrast(getattr(self, role), self.ink)
|
|
97
|
+
if got < min_contrast:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"theme hue %s: %s on ink is %.2f:1, below the %.1f:1 floor"
|
|
100
|
+
% (hue, role, got, min_contrast))
|
|
101
|
+
|
|
102
|
+
def report(self):
|
|
103
|
+
"""Human-readable audit of the theme -- handy when picking a hue."""
|
|
104
|
+
lines = ["hue %d" % self.hue]
|
|
105
|
+
for role in ("ink", "surface", "text", "muted", "dim", "accent", "ok", "warn", "bad"):
|
|
106
|
+
c = getattr(self, role)
|
|
107
|
+
lines.append(" %-8s %s sat=%3.0f%% contrast on ink %5.2f:1"
|
|
108
|
+
% (role, c, hsl_saturation(c) * 100, contrast(c, self.ink)))
|
|
109
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The panels themselves. Each takes a Theme and its content, and returns SVG text.
|
|
2
|
+
|
|
3
|
+
Every panel gets exactly one motion idea. More than one and it reads as a screensaver.
|
|
4
|
+
"""
|
|
5
|
+
from .material import MONO, Slab, mono_width, outline, squircle
|
|
6
|
+
|
|
7
|
+
__all__ = ["banner", "chips", "status", "footer", "pill", "diagram"]
|
|
8
|
+
|
|
9
|
+
PAD = 56
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def banner(theme, wordmark, tagline, meta="", note="", font=None, cap=54,
|
|
13
|
+
tracking=0.022, motif=None, w=1000, h=288, uid="b"):
|
|
14
|
+
"""Hero panel. `font` outlines the wordmark; without it the monospace stack is used.
|
|
15
|
+
|
|
16
|
+
Motion: the specular band, plus whatever `motif` contributes.
|
|
17
|
+
"""
|
|
18
|
+
s = Slab(theme, uid, w, h, sweep=13)
|
|
19
|
+
if font:
|
|
20
|
+
d, _, scale = outline(font, wordmark, cap, tracking)
|
|
21
|
+
mark = (f'<g transform="translate({PAD},112) scale({scale:.5f})">'
|
|
22
|
+
f'<path d="{d}" fill="{theme.text}"/></g>')
|
|
23
|
+
else:
|
|
24
|
+
mark = (f'<text x="{PAD}" y="112" font-family="{MONO}" font-size="{cap}" '
|
|
25
|
+
f'font-weight="700" fill="{theme.text}" letter-spacing="3">{wordmark}</text>')
|
|
26
|
+
|
|
27
|
+
body = [motif(theme) if motif else "", mark,
|
|
28
|
+
f'<rect x="{PAD + 2}" y="128" width="62" height="3" rx="1.5" fill="{theme.accent}"/>',
|
|
29
|
+
f'<text x="{PAD}" y="180" font-family="{MONO}" font-size="19.5" fill="{theme.accent}">›</text>',
|
|
30
|
+
f'<text x="{PAD + 22}" y="180" font-family="{MONO}" font-size="19.5" fill="{theme.muted}">{tagline}</text>']
|
|
31
|
+
if meta:
|
|
32
|
+
body.append(f'<text x="{PAD}" y="240" font-family="{MONO}" font-size="13" '
|
|
33
|
+
f'fill="{theme.dim}" letter-spacing="0.3">{meta}</text>')
|
|
34
|
+
if note:
|
|
35
|
+
body.append(f'<text x="{w - PAD}" y="240" text-anchor="end" font-family="{MONO}" '
|
|
36
|
+
f'font-size="12.5" fill="{theme.dim}">{note}</text>')
|
|
37
|
+
return s.render("".join(body), "%s - %s" % (wordmark, tagline))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def chips(theme, groups, w=1000, uid="s", chip_h=36, fs=15, pad=16, gap=10,
|
|
41
|
+
row_step=60, row_first=66, label_col=168, divider=146):
|
|
42
|
+
"""Labelled rows of chips.
|
|
43
|
+
|
|
44
|
+
Motion: a raking light crosses the grid, brightening each chip as it passes. Done
|
|
45
|
+
with two copies of the grid, the lit one revealed through a soft gradient mask --
|
|
46
|
+
a clip would give a hard edge, which reads as a wipe rather than as light.
|
|
47
|
+
"""
|
|
48
|
+
h = row_first + (len(groups) - 1) * row_step + chip_h // 2 + 36
|
|
49
|
+
s = Slab(theme, uid, w, h, sweep=17, sweep_delay=2)
|
|
50
|
+
band = 300
|
|
51
|
+
|
|
52
|
+
def grid(lit):
|
|
53
|
+
stroke, sop = (theme.accent, "0.55") if lit else ("#FFFFFF", "0.09")
|
|
54
|
+
fop = "0.05" if lit else "0.025"
|
|
55
|
+
colour = theme.accent if lit else theme.muted
|
|
56
|
+
out = []
|
|
57
|
+
for gi, (name, items) in enumerate(groups):
|
|
58
|
+
mid = row_first + gi * row_step
|
|
59
|
+
if not lit:
|
|
60
|
+
out.append(f'<text x="{PAD}" y="{mid + 4}" font-family="{MONO}" font-size="10.5" '
|
|
61
|
+
f'fill="{theme.dim}" letter-spacing="1.6">{name}</text>')
|
|
62
|
+
x = label_col
|
|
63
|
+
for item in items:
|
|
64
|
+
cw = mono_width(item, fs) + pad * 2
|
|
65
|
+
out.append(
|
|
66
|
+
f'<rect x="{x:.1f}" y="{mid - chip_h/2}" width="{cw:.1f}" height="{chip_h}" rx="9" '
|
|
67
|
+
f'fill="#FFFFFF" fill-opacity="{fop}" stroke="{stroke}" stroke-opacity="{sop}" stroke-width="1"/>'
|
|
68
|
+
f'<text x="{x + cw/2:.1f}" y="{mid + 5}" text-anchor="middle" font-family="{MONO}" '
|
|
69
|
+
f'font-size="{fs}" fill="{colour}">{item}</text>')
|
|
70
|
+
x += cw + gap
|
|
71
|
+
return "".join(out)
|
|
72
|
+
|
|
73
|
+
extra = f'''
|
|
74
|
+
<linearGradient id="{uid}-rakeg" x1="0" y1="0" x2="1" y2="0">
|
|
75
|
+
<stop offset="0" stop-color="#000000"/><stop offset="0.5" stop-color="#FFFFFF"/>
|
|
76
|
+
<stop offset="1" stop-color="#000000"/>
|
|
77
|
+
</linearGradient>
|
|
78
|
+
<mask id="{uid}-rake">
|
|
79
|
+
<rect width="{w}" height="{h}" fill="#000000"/>
|
|
80
|
+
<g transform="translate({-band},0)">
|
|
81
|
+
<rect width="{band}" height="{h}" fill="url(#{uid}-rakeg)"/>
|
|
82
|
+
<animateTransform attributeName="transform" type="translate"
|
|
83
|
+
values="{-band},0; {w},0" dur="9s" repeatCount="indefinite"/>
|
|
84
|
+
</g>
|
|
85
|
+
</mask>'''
|
|
86
|
+
body = (f'<line x1="{divider}" y1="{row_first - 30}" x2="{divider}" y2="{h - 36}" '
|
|
87
|
+
f'stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="1"/>'
|
|
88
|
+
+ grid(False) + f'<g mask="url(#{uid}-rake)">{grid(True)}</g>')
|
|
89
|
+
label = "; ".join("%s: %s" % (n, ", ".join(i)) for n, i in groups)
|
|
90
|
+
return s.render(body, label, extra)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def status(theme, label, state, word, headline, detail, right="", stamp="",
|
|
94
|
+
w=1000, h=168, uid="st"):
|
|
95
|
+
"""Service status.
|
|
96
|
+
|
|
97
|
+
`state` picks the semantic colour: ok / warn / bad. Those are exempt from the
|
|
98
|
+
one-accent rule because they carry meaning rather than decoration -- but they are
|
|
99
|
+
derived at the accent's lightness so they still sit in the same band.
|
|
100
|
+
|
|
101
|
+
Motion: the status dot pulses a ring.
|
|
102
|
+
|
|
103
|
+
Always pass `stamp`. A committed SVG behind camo is stale by design, and a status
|
|
104
|
+
panel that implies real-time is simply lying.
|
|
105
|
+
"""
|
|
106
|
+
colour = {"ok": theme.ok, "warn": theme.warn, "bad": theme.bad}[state]
|
|
107
|
+
s = Slab(theme, uid, w, h, sweep=19, sweep_delay=7)
|
|
108
|
+
body = f'''
|
|
109
|
+
<text x="{PAD}" y="48" font-family="{MONO}" font-size="10.5" fill="{theme.dim}" letter-spacing="1.6">{label}</text>
|
|
110
|
+
<circle cx="{PAD + 6}" cy="86" r="5" fill="{colour}"/>
|
|
111
|
+
<circle cx="{PAD + 6}" cy="86" r="5" fill="none" stroke="{colour}" stroke-width="1">
|
|
112
|
+
<animate attributeName="r" values="5;16;16" keyTimes="0;0.7;1" dur="3s" repeatCount="indefinite"/>
|
|
113
|
+
<animate attributeName="opacity" values="0.65;0;0" keyTimes="0;0.7;1" dur="3s" repeatCount="indefinite"/>
|
|
114
|
+
</circle>
|
|
115
|
+
<text x="{PAD + 26}" y="94" font-family="{MONO}" font-size="24" fill="{colour}" letter-spacing="1.5">{word}</text>
|
|
116
|
+
<text x="{PAD + 34 + mono_width(word, 24)}" y="94" font-family="{MONO}" font-size="15" fill="{theme.muted}">{headline}</text>
|
|
117
|
+
<text x="{PAD}" y="128" font-family="{MONO}" font-size="12.5" fill="{theme.dim}">{detail}</text>
|
|
118
|
+
<text x="{w - PAD}" y="86" text-anchor="end" font-family="{MONO}" font-size="12.5" fill="{theme.muted}">{right}</text>
|
|
119
|
+
<text x="{w - PAD}" y="107" text-anchor="end" font-family="{MONO}" font-size="11" fill="{theme.dim}">{stamp}</text>'''
|
|
120
|
+
return s.render(body, "%s: %s - %s" % (label, word, headline))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def footer(theme, headline, sub, right="", w=1000, h=140, uid="f"):
|
|
124
|
+
"""Contact bookend. Motion: a ring pulses out of the right-hand dot."""
|
|
125
|
+
s = Slab(theme, uid, w, h, sweep=15, sweep_delay=5)
|
|
126
|
+
dot = w - PAD - mono_width(right, 12.5) - 20
|
|
127
|
+
body = f'''
|
|
128
|
+
<text x="{PAD}" y="62" font-family="{MONO}" font-size="24" fill="{theme.accent}">{headline}</text>
|
|
129
|
+
<text x="{PAD}" y="94" font-family="{MONO}" font-size="13" fill="{theme.muted}">{sub}</text>'''
|
|
130
|
+
if right:
|
|
131
|
+
body += f'''
|
|
132
|
+
<circle cx="{dot:.1f}" cy="72" r="4" fill="{theme.accent}"/>
|
|
133
|
+
<circle cx="{dot:.1f}" cy="72" r="4" fill="none" stroke="{theme.accent}" stroke-width="1">
|
|
134
|
+
<animate attributeName="r" values="4;13;13" keyTimes="0;0.7;1" dur="2.6s" repeatCount="indefinite"/>
|
|
135
|
+
<animate attributeName="opacity" values="0.7;0;0" keyTimes="0;0.7;1" dur="2.6s" repeatCount="indefinite"/>
|
|
136
|
+
</circle>
|
|
137
|
+
<text x="{w - PAD}" y="77" text-anchor="end" font-family="{MONO}" font-size="12.5" fill="{theme.dim}">{right}</text>'''
|
|
138
|
+
return s.render(body, headline)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def pill(theme, label, on, fs=13, h=36, uid="p"):
|
|
142
|
+
"""A small standalone pill -- language toggles, nav, tags.
|
|
143
|
+
|
|
144
|
+
One image per pill on purpose: markdown hangs a single href off an <img>, so a
|
|
145
|
+
combined toggle would send both halves to the same place.
|
|
146
|
+
"""
|
|
147
|
+
w = int(mono_width(label, fs) + 38)
|
|
148
|
+
stroke, sop = (theme.accent, "0.55") if on else ("#FFFFFF", "0.10")
|
|
149
|
+
colour = theme.accent if on else theme.muted
|
|
150
|
+
shape = squircle(w, h, 10)
|
|
151
|
+
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}" role="img" aria-label="{label}{' (current)' if on else ''}">
|
|
152
|
+
<defs>
|
|
153
|
+
<linearGradient id="{uid}-slab" x1="0" y1="0" x2="0.35" y2="1">
|
|
154
|
+
<stop offset="0" stop-color="{theme.surface}"/><stop offset="1" stop-color="{theme.ink}"/>
|
|
155
|
+
</linearGradient>
|
|
156
|
+
<clipPath id="{uid}-clip"><path d="{shape}"/></clipPath>
|
|
157
|
+
</defs>
|
|
158
|
+
<g clip-path="url(#{uid}-clip)">
|
|
159
|
+
<rect width="{w}" height="{h}" fill="url(#{uid}-slab)"/>
|
|
160
|
+
<text x="{w/2}" y="{h/2 + 4.5}" text-anchor="middle" font-family="{MONO}"
|
|
161
|
+
font-size="{fs}" fill="{colour}" letter-spacing="0.4">{label}</text>
|
|
162
|
+
</g>
|
|
163
|
+
<path d="{shape}" fill="none" stroke="{stroke}" stroke-opacity="{sop}" stroke-width="1"/>
|
|
164
|
+
</svg>
|
|
165
|
+
'''
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def diagram(theme, title, subtitle, nodes, edges, caption="", w=1000, h=268,
|
|
169
|
+
box_h=56, uid="a"):
|
|
170
|
+
"""System diagram with a request relaying along the edges and responses returning.
|
|
171
|
+
|
|
172
|
+
`nodes` are dicts of x/w/cy/label/sub; `edges` are (from_index, to_index) pairs.
|
|
173
|
+
Useful when the code is private and the shape is the only thing you can show.
|
|
174
|
+
"""
|
|
175
|
+
s = Slab(theme, uid, w, h, sweep=21, sweep_delay=3)
|
|
176
|
+
parts = []
|
|
177
|
+
for a_i, b_i in edges:
|
|
178
|
+
a, b = nodes[a_i], nodes[b_i]
|
|
179
|
+
parts.append(f'<path d="M {a["x"]+a["w"]} {a["cy"]} L {b["x"]} {b["cy"]}" fill="none" '
|
|
180
|
+
f'stroke="{theme.accent}" stroke-width="1" opacity="0.26"/>')
|
|
181
|
+
for n in nodes:
|
|
182
|
+
cx, top = n["x"] + n["w"] / 2, n["cy"] - box_h / 2
|
|
183
|
+
parts.append(f'<rect x="{n["x"]}" y="{top}" width="{n["w"]}" height="{box_h}" rx="10" '
|
|
184
|
+
f'fill="#FFFFFF" fill-opacity="0.028" stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="1"/>')
|
|
185
|
+
if n.get("sub"):
|
|
186
|
+
parts.append(f'<text x="{cx}" y="{n["cy"] - 2}" text-anchor="middle" font-family="{MONO}" '
|
|
187
|
+
f'font-size="13" fill="{theme.text}">{n["label"]}</text>'
|
|
188
|
+
f'<text x="{cx}" y="{n["cy"] + 15}" text-anchor="middle" font-family="{MONO}" '
|
|
189
|
+
f'font-size="10.5" fill="{theme.dim}">{n["sub"]}</text>')
|
|
190
|
+
else:
|
|
191
|
+
parts.append(f'<text x="{cx}" y="{n["cy"] + 5}" text-anchor="middle" font-family="{MONO}" '
|
|
192
|
+
f'font-size="13" fill="{theme.text}">{n["label"]}</text>')
|
|
193
|
+
|
|
194
|
+
def hop(a, b, begin, back):
|
|
195
|
+
x1, y1, x2, y2 = a["x"] + a["w"], a["cy"], b["x"], b["cy"]
|
|
196
|
+
if back:
|
|
197
|
+
x1, y1, x2, y2 = x2, y2, x1, y1
|
|
198
|
+
r, op = (1.6, 0.5) if back else (2.1, 0.95)
|
|
199
|
+
return (f'<circle r="{r}" fill="{theme.accent}" opacity="0">'
|
|
200
|
+
f'<animate attributeName="cx" values="{x1};{x2}" dur="0.9s" repeatCount="indefinite" begin="{begin}s"/>'
|
|
201
|
+
f'<animate attributeName="cy" values="{y1};{y2}" dur="0.9s" repeatCount="indefinite" begin="{begin}s"/>'
|
|
202
|
+
f'<animate attributeName="opacity" values="0;{op};{op};0" keyTimes="0;0.15;0.75;1" '
|
|
203
|
+
f'dur="0.9s" repeatCount="indefinite" begin="{begin}s"/></circle>')
|
|
204
|
+
|
|
205
|
+
# depth from the first node, so parallel branches fire together rather than in series
|
|
206
|
+
depth = {0: 0}
|
|
207
|
+
for a_i, b_i in edges:
|
|
208
|
+
depth[b_i] = depth.get(a_i, 0) + 1
|
|
209
|
+
for a_i, b_i in edges:
|
|
210
|
+
t = depth[a_i] * 0.75
|
|
211
|
+
parts.append(hop(nodes[a_i], nodes[b_i], t, False))
|
|
212
|
+
parts.append(hop(nodes[a_i], nodes[b_i], t + 3.2, True))
|
|
213
|
+
|
|
214
|
+
body = (f'<text x="{PAD}" y="46" font-family="{MONO}" font-size="10.5" fill="{theme.dim}" '
|
|
215
|
+
f'letter-spacing="1.6">{title}</text>'
|
|
216
|
+
f'<text x="{PAD + mono_width(title, 10.5) + 26}" y="46" font-family="{MONO}" font-size="10.5" '
|
|
217
|
+
f'fill="{theme.accent}" letter-spacing="1.2">{subtitle}</text>' + "".join(parts))
|
|
218
|
+
if caption:
|
|
219
|
+
body += (f'<text x="{PAD}" y="{h - 28}" font-family="{MONO}" font-size="12" '
|
|
220
|
+
f'fill="{theme.muted}">{caption}</text>')
|
|
221
|
+
return s.render(body, "%s %s" % (title, subtitle))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Builds the images in slab's own README, using slab.
|
|
2
|
+
|
|
3
|
+
If the library cannot document itself, it is not ready to document anyone else.
|
|
4
|
+
|
|
5
|
+
python tools/build_docs.py
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
11
|
+
|
|
12
|
+
from slabkit import (MONO, Slab, Theme, banner, chips, diagram, footer, mono_width,
|
|
13
|
+
status, write)
|
|
14
|
+
|
|
15
|
+
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets")
|
|
16
|
+
os.makedirs(OUT, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
t = Theme(158)
|
|
19
|
+
|
|
20
|
+
HUES = [(158, "jade"), (200, "cyan"), (250, "blue"), (30, "coral"), (110, "moss")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hue_strip(w=1000, h=132):
|
|
24
|
+
"""Low-level API demo: the same ramp at five hues, drawn straight onto a Slab.
|
|
25
|
+
|
|
26
|
+
Every swatch here is generated, not picked -- which is the argument for deriving a
|
|
27
|
+
palette instead of choosing hexes by eye.
|
|
28
|
+
"""
|
|
29
|
+
s = Slab(t, "h", w, h, sweep=16)
|
|
30
|
+
parts = [f'<text x="56" y="46" font-family="{MONO}" font-size="10.5" fill="{t.dim}" '
|
|
31
|
+
f'letter-spacing="1.6">Theme(hue)</text>']
|
|
32
|
+
x, box = 56, 168
|
|
33
|
+
for hue, name in HUES:
|
|
34
|
+
th = Theme(hue)
|
|
35
|
+
parts.append(
|
|
36
|
+
f'<rect x="{x}" y="66" width="{box - 14}" height="40" rx="9" fill="{th.surface}" '
|
|
37
|
+
f'stroke="{th.accent}" stroke-opacity="0.5" stroke-width="1"/>'
|
|
38
|
+
f'<circle cx="{x + 22}" cy="86" r="7" fill="{th.accent}"/>'
|
|
39
|
+
f'<text x="{x + 40}" y="83" font-family="{MONO}" font-size="11.5" fill="{t.text}">{name}</text>'
|
|
40
|
+
f'<text x="{x + 40}" y="96" font-family="{MONO}" font-size="9.5" fill="{t.dim}">{hue}° {th.accent}</text>')
|
|
41
|
+
x += box
|
|
42
|
+
return s.render("".join(parts), "Theme accents at hues %s" % ", ".join(str(h) for h, _ in HUES))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEMO_NODES = [
|
|
46
|
+
dict(x=56, w=150, cy=132, label="client", sub=""),
|
|
47
|
+
dict(x=266, w=180, cy=132, label="edge", sub="cache"),
|
|
48
|
+
dict(x=506, w=170, cy=132, label="api", sub="your service"),
|
|
49
|
+
dict(x=736, w=160, cy=132, label="storage", sub=""),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
built = [
|
|
53
|
+
("hero.svg", banner(
|
|
54
|
+
t, "SLAB", "SVG panels for GitHub READMEs",
|
|
55
|
+
meta="python · no runtime deps · MIT",
|
|
56
|
+
note="generated by slab")),
|
|
57
|
+
("panel-chips.svg", chips(t, [
|
|
58
|
+
("WRITE", ["Python", "TypeScript"]),
|
|
59
|
+
("BUILD", ["anything", "you", "like"]),
|
|
60
|
+
])),
|
|
61
|
+
("panel-status.svg", status(
|
|
62
|
+
t, "SERVICE", "ok", "UP", "all checks passing",
|
|
63
|
+
"Flip state to warn or bad and the colour follows.",
|
|
64
|
+
right="since 2026-08-01", stamp="generated 2026-08-26 19:00 UTC")),
|
|
65
|
+
("panel-diagram.svg", diagram(
|
|
66
|
+
t, "EXAMPLE", "request path", DEMO_NODES, [(0, 1), (1, 2), (2, 3)],
|
|
67
|
+
caption="Nodes are dicts, edges are index pairs. The relay animates itself.",
|
|
68
|
+
h=248)),
|
|
69
|
+
("panel-footer.svg", footer(
|
|
70
|
+
t, "you@example.com", "One accent, one motion idea, no glow.",
|
|
71
|
+
right="available")),
|
|
72
|
+
("hues.svg", hue_strip()),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
for name, svg in built:
|
|
76
|
+
p = write(os.path.join(OUT, name), svg)
|
|
77
|
+
print(" %-20s %6d bytes" % (name, os.path.getsize(p)))
|