cjm-design-system 0.0.1__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.
Files changed (28) hide show
  1. cjm_design_system-0.0.1/PKG-INFO +66 -0
  2. cjm_design_system-0.0.1/README.md +54 -0
  3. cjm_design_system-0.0.1/cjm_design_system/__init__.py +15 -0
  4. cjm_design_system-0.0.1/cjm_design_system/systems/__init__.py +42 -0
  5. cjm_design_system-0.0.1/cjm_design_system/systems/classical/fonts/CormorantGaramond-Italic[wght].ttf +0 -0
  6. cjm_design_system-0.0.1/cjm_design_system/systems/classical/fonts/CormorantGaramond[wght].ttf +0 -0
  7. cjm_design_system-0.0.1/cjm_design_system/systems/classical/fonts/Lora-Italic[wght].ttf +0 -0
  8. cjm_design_system-0.0.1/cjm_design_system/systems/classical/fonts/Lora[wght].ttf +0 -0
  9. cjm_design_system-0.0.1/cjm_design_system/systems/classical/fonts/OFL.txt +93 -0
  10. cjm_design_system-0.0.1/cjm_design_system/systems/classical/tokens.json +41 -0
  11. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/fonts/CourierPrime-Bold.ttf +0 -0
  12. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/fonts/CourierPrime-Italic.ttf +0 -0
  13. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/fonts/CourierPrime-Regular.ttf +0 -0
  14. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/fonts/OFL.txt +93 -0
  15. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/fonts/Silkscreen-Bold.ttf +0 -0
  16. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/fonts/Silkscreen-Regular.ttf +0 -0
  17. cjm_design_system-0.0.1/cjm_design_system/systems/netrunner/tokens.json +64 -0
  18. cjm_design_system-0.0.1/cjm_design_system/tokens.py +333 -0
  19. cjm_design_system-0.0.1/cjm_design_system/tools/__init__.py +5 -0
  20. cjm_design_system-0.0.1/cjm_design_system/tools/build.py +37 -0
  21. cjm_design_system-0.0.1/cjm_design_system/tools/css_to_tokens.py +123 -0
  22. cjm_design_system-0.0.1/cjm_design_system.egg-info/PKG-INFO +66 -0
  23. cjm_design_system-0.0.1/cjm_design_system.egg-info/SOURCES.txt +26 -0
  24. cjm_design_system-0.0.1/cjm_design_system.egg-info/dependency_links.txt +1 -0
  25. cjm_design_system-0.0.1/cjm_design_system.egg-info/top_level.txt +1 -0
  26. cjm_design_system-0.0.1/pyproject.toml +27 -0
  27. cjm_design_system-0.0.1/setup.cfg +4 -0
  28. cjm_design_system-0.0.1/tests/test_tokens.py +109 -0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-design-system
3
+ Version: 0.0.1
4
+ Summary: A design system as data (ruling a439c226): token schema v1 with its check and its headless resolution into one flat vocabulary, the CSS custom-properties projection for the web, the vendored seed systems (Classical, Netrunner) with their fonts, and the importer for a web-first system's CSS. No Qt: the Qt kit (cjm-substrate-qt-kit) consumes the same tokens. Born on-graph.
5
+ Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Project-URL: Repository, https://github.com/cj-mills/cjm-design-system
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # cjm-design-system
14
+
15
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
16
+
17
+ A design system as data (ruling a439c226): token schema v1 with its check and its headless resolution into one flat vocabulary, the CSS custom-properties projection for the web, the vendored seed systems Classical and Netrunner with their OFL fonts, and the importer for a web-first system's CSS. Nothing here imports Qt, so a web build never needs PySide6; the Qt kit (cjm-substrate-qt-kit) consumes the same tokens and keeps only what Qt reads. Born out of the kit by design 0858bbd0, the theme half of the public-site redesign build 8079ae0f.
18
+
19
+ ## Modules
20
+
21
+ - **`cjm_design_system.__init__`** — A design system as DATA (ruling a439c226): one canonical tokens file per
22
+ - **`cjm_design_system.systems.__init__`** — The design systems this library ships as DATA (ruling a439c226): one
23
+ - **`cjm_design_system.tokens`** — Token schema v1 — a design system as DATA, resolved headlessly (ruling
24
+ - **`cjm_design_system.tools.__init__`** — Design-system tools (run as modules):
25
+ - **`cjm_design_system.tools.build`** — Build the static web projections of a design system: the CSS
26
+ - **`cjm_design_system.tools.css_to_tokens`** — Import a web-first design system's CSS into a schema-v1 tokens file.
27
+
28
+ ## API
29
+
30
+ ### `cjm_design_system.systems.__init__`
31
+
32
+ - `available` _function_ — Every vendored system slug (a directory carrying a tokens.json).
33
+ - `locate` _function_ — The directory of a system: a vendored slug, a directory holding a
34
+ - `tokens_path` _function_
35
+
36
+ ### `cjm_design_system.tokens`
37
+
38
+ - `SchemaError` _class_ — A tokens file that violates schema v1 — every problem listed, so one
39
+ - `check` _function_ — Refuse a malformed system loudly: every missing / mistyped field named
40
+ - `load` _function_ — Read a tokens file (JSON). `check` it before resolving anything.
41
+ - `mix` _function_ — Flatten `fg` at opacity `t` over `bg` -> a solid hex (QPalette, painters,
42
+ - `mode_for_scheme` _function_ — The mode the system maps an OS scheme ("light" / "dark") onto — None
43
+ - `modes` _function_
44
+ - `resolve` _function_ — tokens + mode -> the flat vocabulary every projection reads: the QSS
45
+ - `rgba` _function_ — QSS rgba() with a 0-255 alpha (the form every Qt 6 version parses).
46
+ - `slug` _function_ — The system's file-system / preference slug: lower-case, spaces -> dashes.
47
+ - `to_css` _function_ — The CSS custom-properties layer for a system — the ruling's second
48
+ - `to_hex` _function_
49
+
50
+ ### `cjm_design_system.tools.build`
51
+
52
+ - `main` _function_
53
+
54
+ ### `cjm_design_system.tools.css_to_tokens`
55
+
56
+ - `convert` _function_
57
+ - `darker` _function_
58
+ - `family` _function_
59
+ - `main` _function_
60
+ - `parse_root` _function_
61
+ - `px` _function_
62
+ - `ramp` _function_
63
+
64
+ ## Dependencies
65
+
66
+ **Used by:** `cjm-context-graph-projection`, `cjm-substrate-qt-kit`
@@ -0,0 +1,54 @@
1
+ # cjm-design-system
2
+
3
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
4
+
5
+ A design system as data (ruling a439c226): token schema v1 with its check and its headless resolution into one flat vocabulary, the CSS custom-properties projection for the web, the vendored seed systems Classical and Netrunner with their OFL fonts, and the importer for a web-first system's CSS. Nothing here imports Qt, so a web build never needs PySide6; the Qt kit (cjm-substrate-qt-kit) consumes the same tokens and keeps only what Qt reads. Born out of the kit by design 0858bbd0, the theme half of the public-site redesign build 8079ae0f.
6
+
7
+ ## Modules
8
+
9
+ - **`cjm_design_system.__init__`** — A design system as DATA (ruling a439c226): one canonical tokens file per
10
+ - **`cjm_design_system.systems.__init__`** — The design systems this library ships as DATA (ruling a439c226): one
11
+ - **`cjm_design_system.tokens`** — Token schema v1 — a design system as DATA, resolved headlessly (ruling
12
+ - **`cjm_design_system.tools.__init__`** — Design-system tools (run as modules):
13
+ - **`cjm_design_system.tools.build`** — Build the static web projections of a design system: the CSS
14
+ - **`cjm_design_system.tools.css_to_tokens`** — Import a web-first design system's CSS into a schema-v1 tokens file.
15
+
16
+ ## API
17
+
18
+ ### `cjm_design_system.systems.__init__`
19
+
20
+ - `available` _function_ — Every vendored system slug (a directory carrying a tokens.json).
21
+ - `locate` _function_ — The directory of a system: a vendored slug, a directory holding a
22
+ - `tokens_path` _function_
23
+
24
+ ### `cjm_design_system.tokens`
25
+
26
+ - `SchemaError` _class_ — A tokens file that violates schema v1 — every problem listed, so one
27
+ - `check` _function_ — Refuse a malformed system loudly: every missing / mistyped field named
28
+ - `load` _function_ — Read a tokens file (JSON). `check` it before resolving anything.
29
+ - `mix` _function_ — Flatten `fg` at opacity `t` over `bg` -> a solid hex (QPalette, painters,
30
+ - `mode_for_scheme` _function_ — The mode the system maps an OS scheme ("light" / "dark") onto — None
31
+ - `modes` _function_
32
+ - `resolve` _function_ — tokens + mode -> the flat vocabulary every projection reads: the QSS
33
+ - `rgba` _function_ — QSS rgba() with a 0-255 alpha (the form every Qt 6 version parses).
34
+ - `slug` _function_ — The system's file-system / preference slug: lower-case, spaces -> dashes.
35
+ - `to_css` _function_ — The CSS custom-properties layer for a system — the ruling's second
36
+ - `to_hex` _function_
37
+
38
+ ### `cjm_design_system.tools.build`
39
+
40
+ - `main` _function_
41
+
42
+ ### `cjm_design_system.tools.css_to_tokens`
43
+
44
+ - `convert` _function_
45
+ - `darker` _function_
46
+ - `family` _function_
47
+ - `main` _function_
48
+ - `parse_root` _function_
49
+ - `px` _function_
50
+ - `ramp` _function_
51
+
52
+ ## Dependencies
53
+
54
+ **Used by:** `cjm-context-graph-projection`, `cjm-substrate-qt-kit`
@@ -0,0 +1,15 @@
1
+ """A design system as DATA (ruling a439c226): one canonical tokens file per
2
+ system, from which every consumer derives what it renders — the web (the CSS
3
+ custom-properties layer for the Quarto sites, 8079ae0f) and the Qt kit
4
+ (cjm-substrate-qt-kit: QPalette + QSS + fonts + icons) alike.
5
+
6
+ This library holds the half no consumer class owns (design 0858bbd0): token
7
+ schema v1 with its check and its headless resolution into one flat vocabulary
8
+ (`tokens`), the vendored seed systems' data — each system's tokens.json and
9
+ the font files it names (`systems`: Classical and Netrunner) — and the
10
+ importer for a web-first system's CSS (`tools.css_to_tokens`). Nothing here
11
+ imports Qt, so a web build never needs PySide6; what only Qt reads (QSS
12
+ templates, painted widgets, the Theme runtime) stays in the kit.
13
+ """
14
+
15
+ __version__ = "0.0.1"
@@ -0,0 +1,42 @@
1
+ """The design systems this library ships as DATA (ruling a439c226): one
2
+ directory per system under this package — `tokens.json` (schema v1) and
3
+ `fonts/` (the OFL files its tokens name, with their licence). What only a
4
+ consumer reads lives with that consumer: the Qt kit keeps each system's QSS
5
+ templates and painted widgets under its own `systems/<slug>/` (design
6
+ 0858bbd0).
7
+
8
+ available() -> ["classical", "netrunner"]
9
+ locate("netrunner") -> the system's directory
10
+ locate("/path/to/mysystem/tokens.json") -> that file's directory
11
+
12
+ Custom systems come AFTER the gallery (abb6360d); a path-valued system is
13
+ how one is tried before it is vendored."""
14
+
15
+ from pathlib import Path
16
+ from typing import List, Union
17
+
18
+ SYSTEMS_DIR = Path(__file__).parent
19
+
20
+
21
+ def available() -> List[str]:
22
+ """Every vendored system slug (a directory carrying a tokens.json)."""
23
+ return sorted(p.parent.name for p in SYSTEMS_DIR.glob("*/tokens.json"))
24
+
25
+
26
+ def locate(system: Union[str, Path]) -> Path:
27
+ """The directory of a system: a vendored slug, a directory holding a
28
+ tokens.json, or a tokens.json path. Raises KeyError naming the slugs
29
+ available when nothing matches."""
30
+ p = Path(system)
31
+ if p.suffix == ".json" and p.is_file():
32
+ return p.parent
33
+ if p.is_dir() and (p / "tokens.json").is_file():
34
+ return p
35
+ if (SYSTEMS_DIR / str(system) / "tokens.json").is_file():
36
+ return SYSTEMS_DIR / str(system)
37
+ raise KeyError(f"no design system {system!r} — vendored: {available()}; "
38
+ "or pass a directory holding tokens.json")
39
+
40
+
41
+ def tokens_path(system: Union[str, Path]) -> Path:
42
+ return locate(system) / "tokens.json"
@@ -0,0 +1,93 @@
1
+ Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,41 @@
1
+ {
2
+ "schema": 1,
3
+ "name": "Classical",
4
+ "fonts": {
5
+ "heading": {"family": "Cormorant Garamond", "weight": 600},
6
+ "body": {"family": "Lora", "size": 15},
7
+ "mono": {"family": "", "size": 13},
8
+ "ui": {"family": "Lora", "size": 14},
9
+ "files": ["CormorantGaramond*.ttf", "Lora*.ttf"]
10
+ },
11
+ "space": {"1": 4.6, "2": 9.2, "3": 13.8, "4": 18.4, "6": 27.6, "8": 36.8},
12
+ "radius": {"sm": 2, "md": 4, "lg": 7},
13
+ "type": {"h1": [42, 400], "h2": [32, 400], "h3": [25, 600], "h4": [20, 600], "h5": [16, 600], "title": [17, 600], "kicker": [10, 400], "caption": [11, 400]},
14
+ "ramps": {
15
+ "neutral": ["#f8f4f4", "#eae7e7", "#d7d3d3", "#bab6b6", "#9b9797", "#7d7979", "#605d5d", "#444141", "#2d2b2b"],
16
+ "accent": ["#fff3e4", "#ffe3bf", "#facb8d", "#e1ad66", "#c28d41", "#a06f24", "#7d5411", "#5a3b0a", "#3a270d"]
17
+ },
18
+ "scheme": {"light": "light", "dark": "dark"},
19
+ "reading": {"measure": 68, "line_height": 1.45},
20
+ "icons": {"set": "lucide", "stroke_width": 1.5},
21
+ "modes": {
22
+ "light": {
23
+ "bg": "#f3f2f2", "surface": "#eae9e9", "text": "#201f1d",
24
+ "accent": "#b68235", "accent_pressed": "@accent.600", "accent_text": "@accent.700",
25
+ "tag_bg": "@accent.100", "tag_fg": "@accent.800",
26
+ "tag_neutral_bg": "@neutral.100", "tag_neutral_fg": "@neutral.800",
27
+ "shadow": "@neutral.900", "shadow_strength": 1.0,
28
+ "danger": "#a4382a", "warn": "#8c5e14", "ok": "#3a7a3a",
29
+ "info": "#4a5f96", "meta": "#2a6f7c", "note": "#7a4a8a"
30
+ },
31
+ "dark": {
32
+ "bg": "#1d1b19", "surface": "#272523", "text": "#ede9e3",
33
+ "accent": "#c9964d", "accent_pressed": "@accent.400", "accent_text": "@accent.300",
34
+ "tag_bg": "@accent.900", "tag_fg": "@accent.200",
35
+ "tag_neutral_bg": "@neutral.900", "tag_neutral_fg": "@neutral.200",
36
+ "shadow": "#000000", "shadow_strength": 2.2,
37
+ "danger": "#dc8070", "warn": "#d0a552", "ok": "#7dbb7d",
38
+ "info": "#8fa6d6", "meta": "#67b2bf", "note": "#bb93c9"
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,93 @@
1
+ Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,64 @@
1
+ {
2
+ "schema": 1,
3
+ "name": "Netrunner",
4
+ "fonts": {
5
+ "heading": {"family": "Silkscreen", "weight": 700},
6
+ "body": {"family": "Courier Prime", "size": 13},
7
+ "mono": {"family": "Courier Prime", "size": 13},
8
+ "ui": {"family": "Courier Prime", "size": 13},
9
+ "files": ["Silkscreen*.ttf", "CourierPrime*.ttf"]
10
+ },
11
+ "space": {"1": 4, "2": 8, "3": 12, "4": 16, "6": 24, "8": 32},
12
+ "radius": {"sm": 0, "md": 0, "lg": 0},
13
+ "type": {"display": [30, 700], "section": [20, 400], "h1": [26, 700], "h2": [20, 700], "h3": [16, 400], "h4": [14, 400], "h5": [12, 400], "title": [13, 400], "kicker": [11, 400], "caption": [11, 400]},
14
+ "chamfer": 28,
15
+ "chrome": {"titlebar_ink": "fill_text"},
16
+ "ramps": {
17
+ "neutral": ["#e6e1e3", "#b9b1b4", "#8a8185", "#5e5559", "#3d3438", "#271f23", "#1a1216", "#120a0e", "#0a0407"],
18
+ "accent": ["#ffd6e1", "#ff9ab5", "#ff5a87", "#f02a5e", "#e0124c", "#b8003c", "#a00036", "#6a0024", "#3a0014"]
19
+ },
20
+ "reading": {"measure": 80, "line_height": 1.4},
21
+ "icons": {"set": "lucide", "stroke_width": 2.0},
22
+ "modes": {
23
+ "red": {
24
+ "bg": "#0a0407", "surface": "#140a0e", "text": "#e8124f", "accent": "#e8124f",
25
+ "fill": "#a00036", "fill_text": "#2a000e", "secondary": "#2ee6e6", "art": "#4a1018",
26
+ "accent_pressed": "#ff3b6f", "accent_text": "#2ee6e6",
27
+ "tag_bg": "#a00036", "tag_fg": "#2a000e", "tag_neutral_bg": "#27151b", "tag_neutral_fg": "#e8124f",
28
+ "shadow": "#000000", "shadow_strength": 2.0,
29
+ "danger": "#ff3b6f", "warn": "#f2d20c", "ok": "#36e07a", "info": "#2e9bff", "meta": "#2ee6e6", "note": "#ff7ae0"
30
+ },
31
+ "yellow": {
32
+ "bg": "#0a0904", "surface": "#13110a", "text": "#f2d20c", "accent": "#f2d20c",
33
+ "fill": "#9c8600", "fill_text": "#1c1700", "secondary": "#ff3b6f", "art": "#3f3608",
34
+ "accent_pressed": "#fff066", "accent_text": "#ff3b6f",
35
+ "tag_bg": "#9c8600", "tag_fg": "#1c1700", "tag_neutral_bg": "#26220f", "tag_neutral_fg": "#f2d20c",
36
+ "shadow": "#000000", "shadow_strength": 2.0,
37
+ "danger": "#ff3b6f", "warn": "#f2d20c", "ok": "#36e07a", "info": "#2e9bff", "meta": "#2ee6e6", "note": "#ff7ae0"
38
+ },
39
+ "green": {
40
+ "bg": "#040a06", "surface": "#09130c", "text": "#36e07a", "accent": "#36e07a",
41
+ "fill": "#0f7f40", "fill_text": "#00160a", "secondary": "#e6e62e", "art": "#0e3a1f",
42
+ "accent_pressed": "#7dffb0", "accent_text": "#e6e62e",
43
+ "tag_bg": "#0f7f40", "tag_fg": "#00160a", "tag_neutral_bg": "#10261a", "tag_neutral_fg": "#36e07a",
44
+ "shadow": "#000000", "shadow_strength": 2.0,
45
+ "danger": "#ff3b6f", "warn": "#f2d20c", "ok": "#36e07a", "info": "#2e9bff", "meta": "#2ee6e6", "note": "#ff7ae0"
46
+ },
47
+ "blue": {
48
+ "bg": "#04070b", "surface": "#0a1018", "text": "#2e9bff", "accent": "#2e9bff",
49
+ "fill": "#0a4c9c", "fill_text": "#000f22", "secondary": "#ff2e7a", "art": "#0d2a4a",
50
+ "accent_pressed": "#7cc2ff", "accent_text": "#ff2e7a",
51
+ "tag_bg": "#0a4c9c", "tag_fg": "#000f22", "tag_neutral_bg": "#0f1d2e", "tag_neutral_fg": "#2e9bff",
52
+ "shadow": "#000000", "shadow_strength": 2.0,
53
+ "danger": "#ff3b6f", "warn": "#f2d20c", "ok": "#36e07a", "info": "#2e9bff", "meta": "#2ee6e6", "note": "#ff7ae0"
54
+ },
55
+ "white": {
56
+ "bg": "#080808", "surface": "#111111", "text": "#e6e6e6", "accent": "#e6e6e6",
57
+ "fill": "#8a8a8a", "fill_text": "#0a0a0a", "secondary": "#2ee6e6", "art": "#2e2e2e",
58
+ "accent_pressed": "#ffffff", "accent_text": "#2ee6e6",
59
+ "tag_bg": "#8a8a8a", "tag_fg": "#0a0a0a", "tag_neutral_bg": "#222222", "tag_neutral_fg": "#e6e6e6",
60
+ "shadow": "#000000", "shadow_strength": 2.0,
61
+ "danger": "#ff3b6f", "warn": "#f2d20c", "ok": "#36e07a", "info": "#2e9bff", "meta": "#2ee6e6", "note": "#ff7ae0"
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,333 @@
1
+ """Token schema v1 — a design system as DATA, resolved headlessly (ruling
2
+ a439c226: one canonical tokens file per system, shared by the web and Qt
3
+ projections; DEC 0b1cfd81 fixed the shape).
4
+
5
+ A tokens file holds: `name`, `schema` (= 1), `fonts` (heading / body, the
6
+ optional mono and ui slots, the `files` globs registered at apply time),
7
+ `space`, `radius`, `type` (role -> [px, weight]), `ramps` (100..900 steps for
8
+ neutral and accent), `modes` (one semantic block per mode — a system's OWN
9
+ modes: Classical light / dark, Netrunner red / yellow / green / blue / white —
10
+ every block carrying the base semantics, the SIX STATE ROLES danger / warn /
11
+ ok / info / meta / note, and any system-specific keys that pass straight
12
+ through), the optional `scheme` map (OS light / dark -> a mode; absent = the
13
+ system does not follow the OS), the optional `reading` block (measure,
14
+ line_height — the reading-quality tokens), and the optional `icons`
15
+ declaration (set + stroke_width; Lucide at 1.5 by default). `@ramp.step`
16
+ references resolve against the ramps.
17
+
18
+ Nothing here imports Qt — which is why the schema lives in its own library
19
+ (design 0858bbd0): `check` and `resolve` run in tests, in the build CLIs and
20
+ on the web side, and the Qt kit reads the same module. `resolve` emits ONE
21
+ flat vocabulary — the QSS hole names ($bg $text $surface $accent $muted …)
22
+ plus the state roles, the typography slots and the scale — and every web
23
+ projection and kit widget reads that vocabulary (fork 1 of the design: no
24
+ second vocabulary, no aliases)."""
25
+
26
+ import json
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
30
+
31
+ SCHEMA_VERSION = 1
32
+ STATE_ROLES: Tuple[str, ...] = ("danger", "warn", "ok", "info", "meta", "note")
33
+ MODE_KEYS: Tuple[str, ...] = ("bg", "surface", "text", "accent", "accent_pressed", "accent_text",
34
+ "tag_bg", "tag_fg", "tag_neutral_bg", "tag_neutral_fg", "shadow")
35
+ TYPE_ROLES: Tuple[str, ...] = ("h1", "h2", "h3", "h4", "h5", "title", "kicker", "caption")
36
+ RAMPS: Tuple[str, ...] = ("neutral", "accent")
37
+ READING_DEFAULTS = {"measure": 68, "line_height": 1.45}
38
+ ICONS_DEFAULTS = {"set": "lucide", "stroke_width": 1.5}
39
+
40
+ _HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
41
+ _REF = re.compile(r"^@([a-z_]+)\.([1-9]00)$")
42
+
43
+
44
+ class SchemaError(ValueError):
45
+ """A tokens file that violates schema v1 — every problem listed, so one
46
+ read of the message fixes the file (the loud refusal the ruling asks for)."""
47
+
48
+ def __init__(self, source: str, problems: List[str]):
49
+ self.source = source
50
+ self.problems = list(problems)
51
+ super().__init__("%s: %d schema problem(s):\n - %s"
52
+ % (source, len(problems), "\n - ".join(problems)))
53
+
54
+
55
+ # ---- color arithmetic (the CSS color-mix() equivalents) ---------------------
56
+
57
+ def _rgb(h: str) -> Tuple[int, int, int]:
58
+ h = h.lstrip("#")
59
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
60
+
61
+
62
+ def to_hex(rgb: Iterable[float]) -> str:
63
+ return "#%02x%02x%02x" % tuple(max(0, min(255, round(c))) for c in rgb)
64
+
65
+
66
+ def rgba(h: str, a: float) -> str:
67
+ """QSS rgba() with a 0-255 alpha (the form every Qt 6 version parses)."""
68
+ r, g, b = _rgb(h)
69
+ return f"rgba({r}, {g}, {b}, {round(max(0.0, min(1.0, a)) * 255)})"
70
+
71
+
72
+ def mix(fg: str, bg: str, t: float) -> str:
73
+ """Flatten `fg` at opacity `t` over `bg` -> a solid hex (QPalette, painters,
74
+ rich-text HTML — anything that cannot take an rgba string)."""
75
+ f, b = _rgb(fg), _rgb(bg)
76
+ return to_hex(tuple(b[i] + (f[i] - b[i]) * t for i in range(3)))
77
+
78
+
79
+ # ---- load / check -----------------------------------------------------------
80
+
81
+ def load(path) -> Dict[str, Any]:
82
+ """Read a tokens file (JSON). `check` it before resolving anything."""
83
+ return json.loads(Path(path).read_text(encoding="utf-8"))
84
+
85
+
86
+ def slug(tokens: Dict[str, Any]) -> str:
87
+ """The system's file-system / preference slug: lower-case, spaces -> dashes."""
88
+ return str(tokens.get("slug") or tokens.get("name", "system")).lower().replace(" ", "-")
89
+
90
+
91
+ def modes(tokens: Dict[str, Any]) -> List[str]:
92
+ return list((tokens.get("modes") or {}).keys())
93
+
94
+
95
+ def mode_for_scheme(tokens: Dict[str, Any], scheme: str) -> Optional[str]:
96
+ """The mode the system maps an OS scheme ("light" / "dark") onto — None
97
+ when the system declares no `scheme` map (it does not follow the OS)."""
98
+ m = tokens.get("scheme") or {}
99
+ mode = m.get(scheme)
100
+ return str(mode) if mode in (tokens.get("modes") or {}) else None
101
+
102
+
103
+ def _ref(tokens: Dict[str, Any], v):
104
+ """Resolve '@accent.600' -> the ramp step (100..900); pass anything else."""
105
+ if isinstance(v, str) and v.startswith("@"):
106
+ ramp, step = v[1:].split(".")
107
+ return tokens["ramps"][ramp][int(step) // 100 - 1]
108
+ return v
109
+
110
+
111
+ def check(tokens: Dict[str, Any], source: str = "<tokens>") -> None:
112
+ """Refuse a malformed system loudly: every missing / mistyped field named
113
+ in one SchemaError. A tokens file that passes here resolves for every
114
+ mode it declares."""
115
+ p: List[str] = []
116
+ if not isinstance(tokens, dict):
117
+ raise SchemaError(source, ["the tokens file must be a JSON object"])
118
+ if tokens.get("schema") != SCHEMA_VERSION:
119
+ p.append("schema: expected %d, got %r" % (SCHEMA_VERSION, tokens.get("schema")))
120
+ if not isinstance(tokens.get("name"), str) or not tokens.get("name"):
121
+ p.append("name: a non-empty string is required")
122
+ fonts = tokens.get("fonts")
123
+ if not isinstance(fonts, dict):
124
+ p.append("fonts: an object with heading and body slots is required")
125
+ fonts = {}
126
+ for slot in ("heading", "body"):
127
+ f = fonts.get(slot)
128
+ if not isinstance(f, dict) or not isinstance(f.get("family"), str):
129
+ p.append("fonts.%s.family: a string is required" % slot)
130
+ if isinstance(fonts.get("heading"), dict) and not isinstance(fonts["heading"].get("weight"), int):
131
+ p.append("fonts.heading.weight: an integer is required")
132
+ if isinstance(fonts.get("body"), dict) and not isinstance(fonts["body"].get("size"), (int, float)):
133
+ p.append("fonts.body.size: a pixel size is required")
134
+ for slot in ("mono", "ui"):
135
+ f = fonts.get(slot)
136
+ if f is not None and (not isinstance(f, dict) or not isinstance(f.get("family", ""), str)):
137
+ p.append("fonts.%s: an object with a family string when present" % slot)
138
+ if fonts.get("files") is not None and not (isinstance(fonts["files"], list)
139
+ and all(isinstance(x, str) for x in fonts["files"])):
140
+ p.append("fonts.files: a list of glob strings when present")
141
+ for scale in ("space", "radius"):
142
+ d = tokens.get(scale)
143
+ if not isinstance(d, dict) or not d or not all(isinstance(v, (int, float)) for v in d.values()):
144
+ p.append("%s: a non-empty object of pixel numbers is required" % scale)
145
+ typ = tokens.get("type")
146
+ if not isinstance(typ, dict):
147
+ p.append("type: an object of role -> [px, weight] is required")
148
+ typ = {}
149
+ for role in TYPE_ROLES:
150
+ if role not in typ:
151
+ p.append("type.%s: required" % role)
152
+ for role, val in typ.items():
153
+ if not (isinstance(val, list) and len(val) == 2
154
+ and all(isinstance(x, (int, float)) for x in val)):
155
+ p.append("type.%s: expected [px, weight]" % role)
156
+ ramps = tokens.get("ramps")
157
+ if not isinstance(ramps, dict):
158
+ p.append("ramps: an object with neutral and accent 9-step ramps is required")
159
+ ramps = {}
160
+ for name in RAMPS:
161
+ r = ramps.get(name)
162
+ if not (isinstance(r, list) and len(r) == 9 and all(isinstance(x, str) and _HEX.match(x) for x in r)):
163
+ p.append("ramps.%s: exactly nine #rrggbb steps (100..900) are required" % name)
164
+ mode_map = tokens.get("modes")
165
+ if not isinstance(mode_map, dict) or not mode_map:
166
+ p.append("modes: at least one mode block is required")
167
+ mode_map = {}
168
+ for mode, block in mode_map.items():
169
+ if not isinstance(block, dict):
170
+ p.append("modes.%s: expected an object" % mode)
171
+ continue
172
+ for key in MODE_KEYS + STATE_ROLES:
173
+ val = block.get(key)
174
+ if val is None:
175
+ p.append("modes.%s.%s: required%s" % (mode, key,
176
+ " (a state role — every mode carries all six)" if key in STATE_ROLES else ""))
177
+ elif not isinstance(val, str) or not (_HEX.match(val) or _REF.match(val)):
178
+ p.append("modes.%s.%s: expected #rrggbb or @ramp.step, got %r" % (mode, key, val))
179
+ elif _REF.match(val):
180
+ ramp = _REF.match(val).group(1)
181
+ if ramp not in ramps or not isinstance(ramps.get(ramp), list) or len(ramps[ramp]) != 9:
182
+ p.append("modes.%s.%s: %s references a ramp that does not exist" % (mode, key, val))
183
+ ss = block.get("shadow_strength", 1.0)
184
+ if not isinstance(ss, (int, float)):
185
+ p.append("modes.%s.shadow_strength: expected a number" % mode)
186
+ scheme = tokens.get("scheme")
187
+ if scheme is not None:
188
+ if not isinstance(scheme, dict) or set(scheme) - {"light", "dark"}:
189
+ p.append("scheme: an object with light and/or dark keys naming modes")
190
+ else:
191
+ for k, v in scheme.items():
192
+ if v not in mode_map:
193
+ p.append("scheme.%s: %r is not a declared mode" % (k, v))
194
+ reading = tokens.get("reading")
195
+ if reading is not None:
196
+ if not isinstance(reading, dict):
197
+ p.append("reading: an object with measure / line_height")
198
+ else:
199
+ if "measure" in reading and not isinstance(reading["measure"], int):
200
+ p.append("reading.measure: an integer (characters)")
201
+ if "line_height" in reading and not isinstance(reading["line_height"], (int, float)):
202
+ p.append("reading.line_height: a number (ratio)")
203
+ icons = tokens.get("icons")
204
+ if icons is not None:
205
+ if not isinstance(icons, dict) or not isinstance(icons.get("set", "lucide"), str):
206
+ p.append("icons: an object with a set name (and an optional stroke_width)")
207
+ elif "stroke_width" in icons and not isinstance(icons["stroke_width"], (int, float)):
208
+ p.append("icons.stroke_width: a number")
209
+ chrome = tokens.get("chrome")
210
+ if chrome is not None:
211
+ if not isinstance(chrome, dict):
212
+ p.append("chrome: an object naming the shell chrome's ink (titlebar_ink: a mode key)")
213
+ else:
214
+ ink = chrome.get("titlebar_ink", "text")
215
+ if not isinstance(ink, str) or any(ink not in (block or {}) and ink not in ("text", "accent")
216
+ for block in mode_map.values() if isinstance(block, dict)):
217
+ p.append("chrome.titlebar_ink: must name a key every mode carries (e.g. fill_text)")
218
+ if p:
219
+ raise SchemaError(source, p)
220
+
221
+
222
+ # ---- resolve ----------------------------------------------------------------
223
+
224
+ def resolve(tokens: Dict[str, Any], mode: Optional[str] = None) -> Dict[str, str]:
225
+ """tokens + mode -> the flat vocabulary every projection reads: the QSS
226
+ holes (colors, tints, solids, radii, spacing, type scale), the six state
227
+ roles + `dim`, the four font slots with pixel sizes, and the reading
228
+ tokens. `mode` None = the system's first mode."""
229
+ mode = mode or modes(tokens)[0]
230
+ m = {k: _ref(tokens, v) for k, v in tokens["modes"][mode].items()}
231
+ bg, text, acc = m["bg"], m["text"], m["accent"]
232
+ f = tokens["fonts"]
233
+ body = f["body"]
234
+ mono = f.get("mono") or {}
235
+ ui = f.get("ui") or {}
236
+ reading = {**READING_DEFAULTS, **(tokens.get("reading") or {})}
237
+ icons = {**ICONS_DEFAULTS, **(tokens.get("icons") or {})}
238
+ v: Dict[str, str] = {
239
+ "system": slug(tokens), "name": str(tokens["name"]), "mode": mode,
240
+ "bg": bg, "surface": m["surface"], "text": text,
241
+ "accent": acc, "accent_pressed": m["accent_pressed"], "accent_text": m["accent_text"],
242
+ "tag_bg": m["tag_bg"], "tag_fg": m["tag_fg"],
243
+ "tag_neutral_bg": m["tag_neutral_bg"], "tag_neutral_fg": m["tag_neutral_fg"],
244
+ # ink tints (color-mix(text N%, transparent) in the CSS)
245
+ "divider": rgba(text, .16), "divider_strong": rgba(text, .30), "line": rgba(text, .55),
246
+ "border_hover": rgba(text, .45), "muted": rgba(text, .55),
247
+ "label": rgba(text, .70), "header_text": rgba(text, .60),
248
+ "text_hover": rgba(text, .07), "text_pressed": rgba(text, .14),
249
+ "row_hover": rgba(text, .04), "row_alt": rgba(text, .025),
250
+ "disabled_text": rgba(text, .35), "disabled_border": rgba(text, .10),
251
+ "scroll_handle": rgba(text, .18), "scroll_handle_hover": rgba(text, .34),
252
+ # accent tints
253
+ "accent_hover": rgba(acc, .12), "accent_press_tint": rgba(acc, .22),
254
+ "ghost_hover": rgba(acc, .10), "ghost_press": rgba(acc, .18),
255
+ "selection": rgba(acc, .30),
256
+ # solid equivalents for QPalette / painting / rich-text HTML
257
+ "divider_solid": mix(text, bg, .16), "divider_strong_solid": mix(text, bg, .30),
258
+ "line_solid": mix(text, bg, .55),
259
+ "muted_solid": mix(text, bg, .55), "label_solid": mix(text, bg, .70),
260
+ "selection_solid": mix(acc, bg, .30), "row_alt_solid": mix(text, bg, .025),
261
+ "disabled_solid": mix(text, bg, .35),
262
+ "backdrop": rgba(tokens["ramps"]["neutral"][8], .5),
263
+ "shadow": m["shadow"], "shadow_strength": str(m.get("shadow_strength", 1.0)),
264
+ # the state channel: six roles + dim (muted text, the seventh word the spines use)
265
+ **{role: m[role] for role in STATE_ROLES},
266
+ "dim": mix(text, bg, .55),
267
+ # typography slots (pixel sizes — Qt setPixelSize, CSS px)
268
+ "font_heading": f["heading"]["family"], "heading_weight": str(f["heading"]["weight"]),
269
+ "font_body": body["family"], "fs_body": str(body["size"]),
270
+ "body_weight": str(body.get("weight", 400)),
271
+ "font_mono": str(mono.get("family", "")), "fs_mono": str(mono.get("size", max(1, body["size"] - 1))),
272
+ "font_ui": str(ui.get("family") or body["family"]), "fs_ui": str(ui.get("size", body["size"])),
273
+ # reading
274
+ "measure": str(reading["measure"]), "line_height": str(reading["line_height"]),
275
+ # icons
276
+ "icon_set": str(icons["set"]), "icon_stroke": str(icons["stroke_width"]),
277
+ }
278
+ for k, val in m.items(): # system-specific mode keys (e.g. fill, secondary) pass through
279
+ v.setdefault(k, str(val))
280
+ if isinstance(val, str) and val.startswith("#"):
281
+ v.setdefault(f"{k}_hover", rgba(val, .14))
282
+ # the shell chrome's ink (ruling d1e3043e): the title bar's glyphs are
283
+ # painted pixmaps, so the system names which mode key inks them — the
284
+ # text by default, a bar's own contrast key (Netrunner: fill_text) else
285
+ chrome = tokens.get("chrome") or {}
286
+ v["titlebar_ink"] = v.get(str(chrome.get("titlebar_ink", "text")), text)
287
+ for k, px in tokens["radius"].items():
288
+ v[f"radius_{k}"] = str(round(px))
289
+ for k, px in tokens["space"].items():
290
+ v[f"space_{k}"] = str(round(px))
291
+ for k, (size, weight) in tokens["type"].items():
292
+ v[f"fs_{k}"], v[f"fw_{k}"] = str(size), str(weight)
293
+ return v
294
+
295
+
296
+ # ---- the web projection -----------------------------------------------------
297
+
298
+ def to_css(tokens: Dict[str, Any], selector: str = ":root") -> str:
299
+ """The CSS custom-properties layer for a system — the ruling's second
300
+ consumer (the Quarto site 8079ae0f derives its theme from this, never from
301
+ Bootswatch). The first mode lands on `selector`; every other mode lands on
302
+ `selector[data-mode="<mode>"]`, so a page switches modes by one attribute.
303
+ Ramps, spacing, radii and the type scale are mode-independent and emitted
304
+ once. Variable names mirror the resolved vocabulary with dashes."""
305
+ out: List[str] = []
306
+ first = modes(tokens)[0]
307
+ static: List[str] = []
308
+ for ramp, steps in tokens["ramps"].items():
309
+ static += [f" --{ramp}-{(i + 1) * 100}: {c};" for i, c in enumerate(steps)]
310
+ static += [f" --space-{k}: {v}px;" for k, v in tokens["space"].items()]
311
+ static += [f" --radius-{k}: {v}px;" for k, v in tokens["radius"].items()]
312
+ static += [f" --fs-{k}: {s}px;\n --fw-{k}: {w};" for k, (s, w) in tokens["type"].items()]
313
+ f = tokens["fonts"]
314
+ static += [f' --font-heading: "{f["heading"]["family"]}";',
315
+ f' --heading-weight: {f["heading"]["weight"]};',
316
+ f' --font-body: "{f["body"]["family"]}";',
317
+ f' --fs-body: {f["body"]["size"]}px;']
318
+ if f.get("mono", {}).get("family"):
319
+ static.append(f' --font-mono: "{f["mono"]["family"]}";')
320
+ reading = {**READING_DEFAULTS, **(tokens.get("reading") or {})}
321
+ static += [f" --measure: {reading['measure']}ch;", f" --line-height: {reading['line_height']};"]
322
+ per_mode = tuple(MODE_KEYS) + STATE_ROLES + ("dim", "muted", "divider", "divider_strong", "line",
323
+ "label", "selection", "accent_hover", "row_hover")
324
+ for mode in modes(tokens):
325
+ v = resolve(tokens, mode)
326
+ keys = list(per_mode) + [k for k in tokens["modes"][mode] if k not in per_mode and k in v]
327
+ body = [f" --{k.replace('_', '-')}: {v[k]};" for k in keys if k != "shadow_strength"]
328
+ body.append(f" --shadow-strength: {v['shadow_strength']};")
329
+ sel = selector if mode == first else f'{selector}[data-mode="{mode}"]'
330
+ block = body if mode != first else static + body
331
+ out.append("%s {\n%s\n}" % (sel, "\n".join(block)))
332
+ return "/* %s — design tokens, projected by cjm-design-system (schema v%d) */\n%s\n" % (
333
+ tokens["name"], SCHEMA_VERSION, "\n\n".join(out))
@@ -0,0 +1,5 @@
1
+ """Design-system tools (run as modules):
2
+
3
+ python -m cjm_design_system.tools.css_to_tokens styles.css -o mysystem/tokens.json --name "My System"
4
+ python -m cjm_design_system.tools.build tokens.json -o build/ # check + the CSS layer
5
+ """
@@ -0,0 +1,37 @@
1
+ """Build the static web projections of a design system: the CSS
2
+ custom-properties layer (`tokens.to_css`, the web consumer 8079ae0f), after
3
+ the schema check. The Qt projections (QSS per mode, recolored icons) build
4
+ from the kit (`python -m cjm_substrate_qt_kit.tools.build`).
5
+
6
+ python -m cjm_design_system.tools.build netrunner -o build/
7
+ python -m cjm_design_system.tools.build path/to/mysystem/tokens.json --check
8
+ """
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ from .. import systems, tokens as T
14
+
15
+
16
+ def main(argv=None) -> int:
17
+ ap = argparse.ArgumentParser(prog="python -m cjm_design_system.tools.build")
18
+ ap.add_argument("system", help="a vendored slug or a tokens.json path")
19
+ ap.add_argument("-o", "--out", default="build")
20
+ ap.add_argument("--check", action="store_true", help="validate only; write nothing")
21
+ a = ap.parse_args(argv)
22
+ path = systems.tokens_path(a.system)
23
+ tok = T.load(path)
24
+ T.check(tok, str(path))
25
+ if a.check:
26
+ print(f"{T.slug(tok)}: schema v{T.SCHEMA_VERSION} OK — modes {T.modes(tok)}")
27
+ return 0
28
+ out = Path(a.out)
29
+ out.mkdir(parents=True, exist_ok=True)
30
+ css = out / f"{T.slug(tok)}.css"
31
+ css.write_text(T.to_css(tok), encoding="utf-8")
32
+ print(f"wrote {css}")
33
+ return 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
@@ -0,0 +1,123 @@
1
+ """Import a web-first design system's CSS into a schema-v1 tokens file.
2
+
3
+ python -m cjm_design_system.tools.css_to_tokens styles.css -o mysystem/tokens.json [--name "My System"]
4
+
5
+ Reads the `:root { --color-*; --font-*; --space-*; --radius-* }` block (the
6
+ shape a Claude Design handoff uses) and writes light-mode tokens straight
7
+ from it; dark mode is DERIVED from the 100-900 ramps and the six state roles
8
+ are seeded from the reading-tuned set — review both by eye in the
9
+ gallery (a derivation is a starting point, never a verdict; ruling a439c226:
10
+ the CSS is an import source, the tokens file is the source of truth). The
11
+ output passes `tokens.check` or the tool refuses to write it."""
12
+
13
+ import argparse
14
+ import json
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from .. import tokens as T
19
+
20
+ VAR = re.compile(r"--([\w-]+)\s*:\s*([^;]+);")
21
+ HEX = re.compile(r"#[0-9a-fA-F]{6}")
22
+
23
+ STATE_LIGHT = {"danger": "#a4382a", "warn": "#8c5e14", "ok": "#3a7a3a",
24
+ "info": "#4a5f96", "meta": "#2a6f7c", "note": "#7a4a8a"}
25
+ STATE_DARK = {"danger": "#dc8070", "warn": "#d0a552", "ok": "#7dbb7d",
26
+ "info": "#8fa6d6", "meta": "#67b2bf", "note": "#bb93c9"}
27
+
28
+
29
+ def parse_root(css: str) -> dict:
30
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
31
+ m = re.search(r":root\s*{(.*?)}", css, flags=re.S)
32
+ if not m:
33
+ raise SystemExit("no :root block found")
34
+ return {k: v.strip() for k, v in VAR.findall(m.group(1))}
35
+
36
+
37
+ def family(v: str) -> str:
38
+ return v.split(",")[0].strip().strip("'\"")
39
+
40
+
41
+ def px(v: str) -> float:
42
+ return float(re.sub(r"[^\d.]", "", v) or 0)
43
+
44
+
45
+ def ramp(vars_: dict, role: str):
46
+ steps = [vars_.get(f"color-{role}-{s}") for s in range(100, 1000, 100)]
47
+ return steps if all(steps) else None
48
+
49
+
50
+ def darker(h: str, k: float) -> str:
51
+ r, g, b = (int(h[i:i + 2], 16) for i in (1, 3, 5))
52
+ return "#%02x%02x%02x" % tuple(round(c * k) for c in (r, g, b))
53
+
54
+
55
+ def convert(css: str, name: str) -> dict:
56
+ v = parse_root(css)
57
+ col = lambda k: (HEX.findall(v.get(f"color-{k}", "")) or [None])[0] # noqa: E731
58
+ neutral, accent = ramp(v, "neutral"), ramp(v, "accent")
59
+ if not (neutral and accent):
60
+ raise SystemExit("need --color-neutral-100..900 and --color-accent-100..900 ramps")
61
+ hw = int(px(v.get("font-heading-weight", "600")))
62
+ heading, body = family(v["font-heading"]), family(v["font-body"])
63
+ mono = family(v["font-mono"]) if v.get("font-mono") else ""
64
+ light_ground = int(col("bg")[1:3], 16) > 128
65
+ light = {
66
+ "bg": col("bg"), "surface": col("surface"), "text": col("text"), "accent": col("accent"),
67
+ "accent_pressed": "@accent.600", "accent_text": "@accent.700",
68
+ "tag_bg": "@accent.100", "tag_fg": "@accent.800",
69
+ "tag_neutral_bg": "@neutral.100", "tag_neutral_fg": "@neutral.800",
70
+ "shadow": "@neutral.900", "shadow_strength": 1.0, **STATE_LIGHT,
71
+ }
72
+ dark = {
73
+ "bg": darker(neutral[8], .68), "surface": darker(neutral[8], .86), "text": neutral[0],
74
+ "accent": accent[4] if light_ground else col("accent"),
75
+ "accent_pressed": "@accent.400", "accent_text": "@accent.300",
76
+ "tag_bg": "@accent.900", "tag_fg": "@accent.200",
77
+ "tag_neutral_bg": "@neutral.900", "tag_neutral_fg": "@neutral.200",
78
+ "shadow": "#000000", "shadow_strength": 2.2, **STATE_DARK,
79
+ }
80
+ modes = {"light": light, "dark": dark} if light_ground else {"dark": light}
81
+ out = {
82
+ "schema": T.SCHEMA_VERSION,
83
+ "name": name,
84
+ "fonts": {
85
+ "heading": {"family": heading, "weight": hw},
86
+ "body": {"family": body, "size": 15},
87
+ "mono": {"family": mono, "size": 13},
88
+ "files": [heading.replace(" ", "") + "*.ttf", body.replace(" ", "") + "*.ttf"],
89
+ },
90
+ "space": {k.split("-")[1]: px(x) for k, x in v.items() if re.fullmatch(r"space-\d+", k)},
91
+ "radius": {k.split("-")[1]: px(x) for k, x in v.items() if re.fullmatch(r"radius-\w+", k)},
92
+ "type": {"h1": [42, 400], "h2": [32, 400], "h3": [25, hw], "h4": [20, hw], "h5": [16, hw],
93
+ "title": [17, hw], "kicker": [10, 400], "caption": [11, 400]},
94
+ "ramps": {"neutral": neutral, "accent": accent},
95
+ "scheme": {"light": "light", "dark": "dark"} if light_ground else {"dark": "dark"},
96
+ "reading": dict(T.READING_DEFAULTS),
97
+ "icons": dict(T.ICONS_DEFAULTS),
98
+ "modes": modes,
99
+ }
100
+ if not out["space"]:
101
+ out["space"] = {"1": 4, "2": 8, "3": 12, "4": 16, "6": 24, "8": 32}
102
+ if not out["radius"]:
103
+ out["radius"] = {"sm": 2, "md": 4, "lg": 8}
104
+ T.check(out, f"<converted from CSS: {name}>")
105
+ return out
106
+
107
+
108
+ def main(argv=None) -> int:
109
+ ap = argparse.ArgumentParser(prog="python -m cjm_design_system.tools.css_to_tokens")
110
+ ap.add_argument("css")
111
+ ap.add_argument("-o", "--out", required=True)
112
+ ap.add_argument("--name")
113
+ a = ap.parse_args(argv)
114
+ name = a.name or Path(a.out).parent.name.title()
115
+ out = convert(Path(a.css).read_text(encoding="utf-8"), name)
116
+ Path(a.out).parent.mkdir(parents=True, exist_ok=True)
117
+ Path(a.out).write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")
118
+ print(f"wrote {a.out} modes={list(out['modes'])} heading={out['fonts']['heading']['family']}")
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-design-system
3
+ Version: 0.0.1
4
+ Summary: A design system as data (ruling a439c226): token schema v1 with its check and its headless resolution into one flat vocabulary, the CSS custom-properties projection for the web, the vendored seed systems (Classical, Netrunner) with their fonts, and the importer for a web-first system's CSS. No Qt: the Qt kit (cjm-substrate-qt-kit) consumes the same tokens. Born on-graph.
5
+ Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Project-URL: Repository, https://github.com/cj-mills/cjm-design-system
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # cjm-design-system
14
+
15
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
16
+
17
+ A design system as data (ruling a439c226): token schema v1 with its check and its headless resolution into one flat vocabulary, the CSS custom-properties projection for the web, the vendored seed systems Classical and Netrunner with their OFL fonts, and the importer for a web-first system's CSS. Nothing here imports Qt, so a web build never needs PySide6; the Qt kit (cjm-substrate-qt-kit) consumes the same tokens and keeps only what Qt reads. Born out of the kit by design 0858bbd0, the theme half of the public-site redesign build 8079ae0f.
18
+
19
+ ## Modules
20
+
21
+ - **`cjm_design_system.__init__`** — A design system as DATA (ruling a439c226): one canonical tokens file per
22
+ - **`cjm_design_system.systems.__init__`** — The design systems this library ships as DATA (ruling a439c226): one
23
+ - **`cjm_design_system.tokens`** — Token schema v1 — a design system as DATA, resolved headlessly (ruling
24
+ - **`cjm_design_system.tools.__init__`** — Design-system tools (run as modules):
25
+ - **`cjm_design_system.tools.build`** — Build the static web projections of a design system: the CSS
26
+ - **`cjm_design_system.tools.css_to_tokens`** — Import a web-first design system's CSS into a schema-v1 tokens file.
27
+
28
+ ## API
29
+
30
+ ### `cjm_design_system.systems.__init__`
31
+
32
+ - `available` _function_ — Every vendored system slug (a directory carrying a tokens.json).
33
+ - `locate` _function_ — The directory of a system: a vendored slug, a directory holding a
34
+ - `tokens_path` _function_
35
+
36
+ ### `cjm_design_system.tokens`
37
+
38
+ - `SchemaError` _class_ — A tokens file that violates schema v1 — every problem listed, so one
39
+ - `check` _function_ — Refuse a malformed system loudly: every missing / mistyped field named
40
+ - `load` _function_ — Read a tokens file (JSON). `check` it before resolving anything.
41
+ - `mix` _function_ — Flatten `fg` at opacity `t` over `bg` -> a solid hex (QPalette, painters,
42
+ - `mode_for_scheme` _function_ — The mode the system maps an OS scheme ("light" / "dark") onto — None
43
+ - `modes` _function_
44
+ - `resolve` _function_ — tokens + mode -> the flat vocabulary every projection reads: the QSS
45
+ - `rgba` _function_ — QSS rgba() with a 0-255 alpha (the form every Qt 6 version parses).
46
+ - `slug` _function_ — The system's file-system / preference slug: lower-case, spaces -> dashes.
47
+ - `to_css` _function_ — The CSS custom-properties layer for a system — the ruling's second
48
+ - `to_hex` _function_
49
+
50
+ ### `cjm_design_system.tools.build`
51
+
52
+ - `main` _function_
53
+
54
+ ### `cjm_design_system.tools.css_to_tokens`
55
+
56
+ - `convert` _function_
57
+ - `darker` _function_
58
+ - `family` _function_
59
+ - `main` _function_
60
+ - `parse_root` _function_
61
+ - `px` _function_
62
+ - `ramp` _function_
63
+
64
+ ## Dependencies
65
+
66
+ **Used by:** `cjm-context-graph-projection`, `cjm-substrate-qt-kit`
@@ -0,0 +1,26 @@
1
+ README.md
2
+ pyproject.toml
3
+ cjm_design_system/__init__.py
4
+ cjm_design_system/tokens.py
5
+ cjm_design_system.egg-info/PKG-INFO
6
+ cjm_design_system.egg-info/SOURCES.txt
7
+ cjm_design_system.egg-info/dependency_links.txt
8
+ cjm_design_system.egg-info/top_level.txt
9
+ cjm_design_system/systems/__init__.py
10
+ cjm_design_system/systems/classical/tokens.json
11
+ cjm_design_system/systems/classical/fonts/CormorantGaramond-Italic[wght].ttf
12
+ cjm_design_system/systems/classical/fonts/CormorantGaramond[wght].ttf
13
+ cjm_design_system/systems/classical/fonts/Lora-Italic[wght].ttf
14
+ cjm_design_system/systems/classical/fonts/Lora[wght].ttf
15
+ cjm_design_system/systems/classical/fonts/OFL.txt
16
+ cjm_design_system/systems/netrunner/tokens.json
17
+ cjm_design_system/systems/netrunner/fonts/CourierPrime-Bold.ttf
18
+ cjm_design_system/systems/netrunner/fonts/CourierPrime-Italic.ttf
19
+ cjm_design_system/systems/netrunner/fonts/CourierPrime-Regular.ttf
20
+ cjm_design_system/systems/netrunner/fonts/OFL.txt
21
+ cjm_design_system/systems/netrunner/fonts/Silkscreen-Bold.ttf
22
+ cjm_design_system/systems/netrunner/fonts/Silkscreen-Regular.ttf
23
+ cjm_design_system/tools/__init__.py
24
+ cjm_design_system/tools/build.py
25
+ cjm_design_system/tools/css_to_tokens.py
26
+ tests/test_tokens.py
@@ -0,0 +1 @@
1
+ cjm_design_system
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cjm-design-system"
7
+ dynamic = ["version"]
8
+ description = "A design system as data (ruling a439c226): token schema v1 with its check and its headless resolution into one flat vocabulary, the CSS custom-properties projection for the web, the vendored seed systems (Classical, Netrunner) with their fonts, and the importer for a web-first system's CSS. No Qt: the Qt kit (cjm-substrate-qt-kit) consumes the same tokens. Born on-graph."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Christian J. Mills", email = "9126128+cj-mills@users.noreply.github.com"}]
13
+ classifiers = ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/cj-mills/cjm-design-system"
18
+
19
+ [tool.setuptools.dynamic]
20
+ version = {attr = "cjm_design_system.__version__"}
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["cjm_design_system*"]
24
+
25
+ [tool.setuptools.package-data]
26
+ cjm_design_system = ["systems/*/tokens.json", "systems/*/fonts/*.ttf", "systems/*/fonts/*.otf",
27
+ "systems/*/fonts/OFL.txt"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,109 @@
1
+ """Schema v1 contract: both vendored systems pass `check`; a malformed system
2
+ is refused loudly with every problem named; `resolve` emits the one
3
+ vocabulary (holes, state roles, dim, typography slots, scale) headlessly;
4
+ `to_css` projects the same tokens for the web side."""
5
+
6
+ import copy
7
+ import json
8
+
9
+ import pytest
10
+
11
+ from cjm_design_system import systems
12
+ from cjm_design_system import tokens as T
13
+
14
+
15
+ @pytest.fixture(params=["classical", "netrunner"])
16
+ def system(request):
17
+ return request.param, T.load(systems.tokens_path(request.param))
18
+
19
+
20
+ def test_vendored_systems_pass_check(system):
21
+ slug, tok = system
22
+ T.check(tok, slug)
23
+ assert T.slug(tok) == slug
24
+ assert len(T.modes(tok)) >= 2
25
+
26
+
27
+ def test_check_refuses_loudly_naming_every_problem():
28
+ tok = T.load(systems.tokens_path("classical"))
29
+ bad = copy.deepcopy(tok)
30
+ del bad["modes"]["light"]["danger"] # a state role missing
31
+ bad["modes"]["dark"]["accent"] = "red" # not a hex / ref
32
+ bad["ramps"]["accent"] = bad["ramps"]["accent"][:5] # short ramp
33
+ bad["scheme"]["dark"] = "midnight" # names a mode that does not exist
34
+ bad["schema"] = 2
35
+ with pytest.raises(T.SchemaError) as e:
36
+ T.check(bad, "bad.json")
37
+ msg = str(e.value)
38
+ assert "bad.json" in msg
39
+ for needle in ("modes.light.danger", "modes.dark.accent", "ramps.accent", "scheme.dark", "schema:"):
40
+ assert needle in msg, needle
41
+ assert len(e.value.problems) >= 5
42
+
43
+
44
+ def test_resolve_emits_the_one_vocabulary(system):
45
+ slug, tok = system
46
+ for mode in T.modes(tok):
47
+ v = T.resolve(tok, mode)
48
+ assert v["system"] == slug and v["mode"] == mode
49
+ for key in ("bg", "surface", "text", "accent", "muted", "muted_solid", "divider", "divider_solid",
50
+ "selection", "selection_solid", "disabled_text", "font_heading", "font_body",
51
+ "font_mono", "font_ui", "fs_body", "fs_mono", "fs_ui", "measure", "line_height",
52
+ "radius_md", "space_2", "fs_h1", "fw_h1", "icon_stroke"):
53
+ assert key in v, key
54
+ for role in T.STATE_ROLES:
55
+ assert v[role].startswith("#"), role
56
+ assert v["dim"].startswith("#") and v["dim"] == v["muted_solid"]
57
+ # a ramp reference resolved to its step
58
+ assert not v["accent_pressed"].startswith("@")
59
+
60
+
61
+ def test_resolve_passes_system_keys_through():
62
+ tok = T.load(systems.tokens_path("netrunner"))
63
+ v = T.resolve(tok, "blue")
64
+ assert v["fill"] == tok["modes"]["blue"]["fill"]
65
+ assert v["fill_hover"].startswith("rgba(")
66
+ assert v["fs_display"] == "30"
67
+
68
+
69
+ def test_mode_for_scheme_follows_only_declared_maps():
70
+ classical = T.load(systems.tokens_path("classical"))
71
+ netrunner = T.load(systems.tokens_path("netrunner"))
72
+ assert T.mode_for_scheme(classical, "dark") == "dark"
73
+ assert T.mode_for_scheme(netrunner, "dark") is None
74
+
75
+
76
+ def test_color_helpers():
77
+ assert T.rgba("#ff0000", .5) == "rgba(255, 0, 0, 128)"
78
+ assert T.mix("#000000", "#ffffff", .5) == "#808080"
79
+ assert T.to_hex((300, -5, 12.6)) == "#ff000d"
80
+
81
+
82
+ def test_to_css_projects_every_mode(system):
83
+ slug, tok = system
84
+ css = T.to_css(tok)
85
+ first = T.modes(tok)[0]
86
+ assert ":root {" in css
87
+ for mode in T.modes(tok)[1:]:
88
+ assert f':root[data-mode="{mode}"]' in css
89
+ assert "--neutral-900:" in css and "--fs-h1:" in css and "--font-body:" in css
90
+ assert f"--bg: {T.resolve(tok, first)['bg']};" in css
91
+ assert "--danger:" in css and "--dim:" in css
92
+
93
+
94
+ def test_load_round_trips_json(tmp_path):
95
+ tok = T.load(systems.tokens_path("classical"))
96
+ p = tmp_path / "tokens.json"
97
+ p.write_text(json.dumps(tok))
98
+ assert T.load(p) == tok
99
+
100
+
101
+ def test_systems_locate_by_slug_dir_and_path(tmp_path):
102
+ root = systems.locate("classical")
103
+ assert (root / "tokens.json").is_file()
104
+ assert systems.locate(root) == root
105
+ assert systems.locate(root / "tokens.json") == root
106
+ with pytest.raises(KeyError) as e:
107
+ systems.locate("no-such-system")
108
+ assert "classical" in str(e.value)
109
+ assert systems.available() == ["classical", "netrunner"]