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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ examples/out/
4
+ dist/
5
+ *.egg-info/
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
+ &nbsp;
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
+ &nbsp;
55
+ <img alt="No runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-none-081A10?style=flat-square&labelColor=081A10">
56
+ &nbsp;
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).
@@ -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
+ &nbsp;
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
+ &nbsp;
11
+ <img alt="No runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-none-081A10?style=flat-square&labelColor=081A10">
12
+ &nbsp;
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}">&#8250;</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)))