@devicechain/brand 0.14.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright The DeviceChain Authors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ <!--
2
+ Copyright The DeviceChain Authors
3
+ SPDX-License-Identifier: Apache-2.0
4
+ -->
5
+
6
+ # @devicechain/brand
7
+
8
+ The canonical DeviceChain brand values, and the generated stylesheets each
9
+ consumer actually needs.
10
+
11
+ `tokens.json` is the source of truth. Everything in `css/` and
12
+ `tokens.generated.json` is generated from it and carries a do-not-hand-edit
13
+ header.
14
+
15
+ ## Using it
16
+
17
+ ```bash
18
+ npm install @devicechain/brand
19
+ ```
20
+
21
+ ```css
22
+ /* whichever vocabulary your app speaks — see the table below */
23
+ @import '@devicechain/brand/css/shadcn.css';
24
+ ```
25
+
26
+ ```ts
27
+ import tokens from '@devicechain/brand/tokens';
28
+ ```
29
+
30
+ There is no build step and no JavaScript to run: the stylesheets and the
31
+ generated token JSON are shipped as-is.
32
+
33
+ ## Working on it
34
+
35
+ ```bash
36
+ npm run generate -w @devicechain/brand # regenerate after editing tokens.json
37
+ npm run check:generated -w @devicechain/brand # fail if the committed output is stale
38
+ npm run verify -w @devicechain/brand # report consumers that disagree
39
+
40
+ # the marketing site lives in a separate repo, so point at it explicitly
41
+ DC_WEBSITE=../devicechain-website npm run verify -w @devicechain/brand
42
+ ```
43
+
44
+ ## Why three stylesheets and not one
45
+
46
+ The three consumers do not share a vocabulary, and a single stylesheet cannot
47
+ serve them:
48
+
49
+ | consumer | file | vocabulary | value format |
50
+ | --- | --- | --- | --- |
51
+ | marketing site | `css/brand.css` | `--brand`, `--ink`, `--bg`, accents | hex |
52
+ | console | `css/shadcn.css` | `--primary`, `--ring`, `--sidebar-*` | **bare** `H S% L%` triples |
53
+ | docs | `css/infima.css` | `--ifm-color-primary*` | hex, plus a five-step ramp |
54
+
55
+ Two details that are easy to get wrong:
56
+
57
+ - The console's triples have **no `hsl()` wrapper**. shadcn composes them as
58
+ `hsl(var(--primary) / <alpha>)`, so wrapping them breaks every alpha variant.
59
+ - Infima wants a five-step ramp either side of primary. It is generated from the
60
+ single canonical value so the steps cannot drift out of order, which is the
61
+ usual failure mode of a hand-written ramp.
62
+
63
+ `css/shadcn.css` and `css/infima.css` deliberately cover **brand-derived tokens
64
+ only**. The console still owns the rest of its shadcn scale (card, popover,
65
+ `chart-*`, `sidebar-*` surfaces) and the docs site still owns its own
66
+ background theming.
67
+
68
+ ## Why the values are stored as hex
69
+
70
+ The brandmark artwork is hex (`branding/logo.svg`), so hex is the origin and
71
+ every other form is derived. This is not cosmetic. The console previously
72
+ declared the brand blue as the rounded triple `197 71% 42%`, which renders
73
+ `#1f8cb7` — **not** the `#208cb7` in the artwork. One channel out of 255, invisible
74
+ to the eye, and still two different colours claiming to be the same one.
75
+ Deriving removes that entire class of mismatch.
76
+
77
+ ## The one rule
78
+
79
+ `core.primary` is the only colour that ever means *interactive*. The four
80
+ accents (`aqua` → `violet` → `magenta` → `amber`, in pipeline order: ingest,
81
+ detect, react, command) identify an **area**. They never mark a link, a button
82
+ or a control. Break that and a page stops being navigable.
83
+
84
+ ## What `verify` is for, and what it is not
85
+
86
+ "Keep it in step with the console" was a comment in three files, not a
87
+ guarantee — and it had already failed twice by the time this package was
88
+ written. `verify` turns that comment into something a CI job can enforce.
89
+
90
+ It is deliberately **read-only**. It prints what disagrees and exits non-zero;
91
+ it never rewrites a consumer. Changing a shipped colour is a visual decision,
92
+ so migrate consumers one at a time and look at the result. Do not bulk-rewrite
93
+ to silence the script.
94
+
95
+ ## Migration status
96
+
97
+ No consumer has been migrated yet — this package is currently the source of
98
+ truth plus the drift alarm. Known disagreements at time of writing:
99
+
100
+ - **console** `--primary` (light and dark) — rounded triples, as described
101
+ above. Adopting the derived values is a no-op to the eye.
102
+ - **marketing site** `--brand-bright` — had drifted to `#3aa9d4`
103
+ (`hsl(197 64% 53%)`), a third value that neither the console nor the docs
104
+ used. The console and docs both use `#24a3d6` (`hsl(197 71% 49%)`), and the
105
+ console is the documented source of truth, so it wins here. Applying it is a
106
+ **visible** change to the site's hover states and hero eyebrow and should be
107
+ reviewed on its own.
108
+ - **docs** — already agrees on both light and dark.
package/css/brand.css ADDED
@@ -0,0 +1,55 @@
1
+ /* Copyright The DeviceChain Authors */
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+
4
+ /*
5
+ * GENERATED by tools/generate.mjs from tokens.json — DO NOT HAND-EDIT.
6
+ *
7
+ * Marketing + static-site vocabulary. Consumed by the public website and by
8
+ * anything with no bundler: plain custom properties, hex values, no build step.
9
+ *
10
+ * This is the one output that is COPIED INTO ANOTHER REPOSITORY, so it names
11
+ * its source in full: frontend/packages/brand/tokens.json in the devicechain
12
+ * monorepo. Editing the copy is how the marketing site drifted onto a blue
13
+ * neither the console nor the docs used. Change tokens.json and re-sync.
14
+ */
15
+
16
+ :root {
17
+ /* Core brand — sampled from the brandmark (branding/logo.svg). */
18
+ --brand: #208cb7;
19
+ --brand-bright: #24a3d6;
20
+ --brand-deep: #1f425e;
21
+ --brand-pale: #9aceec;
22
+
23
+ /* Surfaces + text. */
24
+ --bg: #0b1620;
25
+ /* LOAD-BEARING: must equal the hex-field renderer's gl.clearColor. If they
26
+ differ, distant fogged tiles read as a dark silhouette against a lighter
27
+ sky instead of dissolving into it. */
28
+ --bg-deep: #060b11;
29
+ --ink: #e8f1f6;
30
+ --ink-2: #b6ccd8;
31
+ --muted: #8aa6b6;
32
+
33
+ /* Derived surface treatments. */
34
+ --panel: rgba(255, 255, 255, 0.04);
35
+ --border: rgba(122, 183, 217, 0.18);
36
+ --rule: rgba(122, 183, 217, 0.16);
37
+ --rule-strong: rgba(122, 183, 217, 0.34);
38
+ --glow-strong: rgba(32, 140, 183, 0.25);
39
+ --glow-soft: rgba(32, 140, 183, 0.12);
40
+
41
+ /* Area accents. NEVER used for anything interactive — --brand alone
42
+ carries that meaning. Order is pipeline order. */
43
+ /* Ingest and edge. */
44
+ --aqua: #2fd6d0;
45
+ /* Detect. */
46
+ --violet: #7c6cf5;
47
+ /* React. */
48
+ --magenta: #e8489b;
49
+ /* Command. */
50
+ --amber: #f5a524;
51
+
52
+ /* Type stacks. */
53
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
54
+ --mono: ui-monospace, "SF Mono", "DejaVu Sans Mono", "Liberation Mono", Menlo, Consolas, monospace;
55
+ }
package/css/infima.css ADDED
@@ -0,0 +1,32 @@
1
+ /* Copyright The DeviceChain Authors */
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+
4
+ /*
5
+ * GENERATED by tools/generate.mjs from tokens.json — DO NOT HAND-EDIT.
6
+ *
7
+ * Docs vocabulary (Docusaurus/Infima). Infima needs a five-step ramp either
8
+ * side of primary; it is generated from the single canonical value so the
9
+ * steps cannot drift out of order.
10
+ *
11
+ * Brand colours only — the docs site owns its own background/surface theming.
12
+ */
13
+
14
+ :root {
15
+ --ifm-color-primary: #208cb7;
16
+ --ifm-color-primary-dark: #1d7ea5;
17
+ --ifm-color-primary-darker: #1b779c;
18
+ --ifm-color-primary-darkest: #166280;
19
+ --ifm-color-primary-light: #239ac9;
20
+ --ifm-color-primary-lighter: #25a1d3;
21
+ --ifm-color-primary-lightest: #3bafdd;
22
+ }
23
+
24
+ [data-theme='dark'] {
25
+ --ifm-color-primary: #24a3d6;
26
+ --ifm-color-primary-dark: #2093c1;
27
+ --ifm-color-primary-darker: #1f8ab6;
28
+ --ifm-color-primary-darkest: #197296;
29
+ --ifm-color-primary-light: #36addd;
30
+ --ifm-color-primary-lighter: #40b2df;
31
+ --ifm-color-primary-lightest: #61bfe4;
32
+ }
package/css/shadcn.css ADDED
@@ -0,0 +1,33 @@
1
+ /* Copyright The DeviceChain Authors */
2
+ /* SPDX-License-Identifier: Apache-2.0 */
3
+
4
+ /*
5
+ * GENERATED by tools/generate.mjs from tokens.json — DO NOT HAND-EDIT.
6
+ *
7
+ * Console vocabulary. Values are BARE `H S% L%` triples with no hsl() wrapper,
8
+ * because shadcn composes them as `hsl(var(--primary) / <alpha>)` — wrapping
9
+ * them here would silently break every alpha variant.
10
+ *
11
+ * Scope: every token whose value is DETERMINED BY the brand blue — the blue
12
+ * itself wherever shadcn names it, plus the foreground that has to read on
13
+ * top of it. A consumer keeps ownership of everything else in its scale
14
+ * (background, card, popover, chart-*, and the non-primary sidebar-*).
15
+ */
16
+
17
+ :root {
18
+ --primary: 197.1 70.2% 42.2%;
19
+ --primary-foreground: 0 0% 100%;
20
+ --ring: 197.1 70.2% 42.2%;
21
+ --sidebar-primary: 197.1 70.2% 42.2%;
22
+ --sidebar-primary-foreground: 0 0% 100%;
23
+ --sidebar-ring: 197.1 70.2% 42.2%;
24
+ }
25
+
26
+ .dark {
27
+ --primary: 197.2 71.2% 49%;
28
+ --primary-foreground: 0 0% 100%;
29
+ --ring: 197.2 71.2% 49%;
30
+ --sidebar-primary: 197.2 71.2% 49%;
31
+ --sidebar-primary-foreground: 0 0% 100%;
32
+ --sidebar-ring: 197.2 71.2% 49%;
33
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@devicechain/brand",
3
+ "version": "0.14.0-0",
4
+ "description": "Canonical DeviceChain brand tokens, plus the generated per-consumer stylesheets.",
5
+ "keywords": [
6
+ "devicechain",
7
+ "design-tokens",
8
+ "css",
9
+ "theme"
10
+ ],
11
+ "license": "Apache-2.0",
12
+ "homepage": "https://devicechain.io",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/devicechain-io/devicechain.git",
16
+ "directory": "frontend/packages/brand"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/devicechain-io/devicechain/issues"
20
+ },
21
+ "type": "module",
22
+ "sideEffects": [
23
+ "./css/*.css"
24
+ ],
25
+ "exports": {
26
+ "./tokens.json": "./tokens.json",
27
+ "./tokens": "./tokens.generated.json",
28
+ "./css/brand.css": "./css/brand.css",
29
+ "./css/shadcn.css": "./css/shadcn.css",
30
+ "./css/infima.css": "./css/infima.css"
31
+ },
32
+ "files": [
33
+ "css",
34
+ "tokens.json",
35
+ "tokens.generated.json",
36
+ "tools",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "generate": "node tools/generate.mjs",
42
+ "check:generated": "node tools/generate.mjs --check",
43
+ "verify": "node tools/verify.mjs",
44
+ "build": "node tools/generate.mjs --check"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "flat": {
3
+ "core.primary": "#208cb7",
4
+ "core.primaryBright": "#24a3d6",
5
+ "core.primaryDeep": "#1f425e",
6
+ "core.primaryPale": "#9aceec",
7
+ "surface.bg": "#0b1620",
8
+ "surface.bgDeep": "#060b11",
9
+ "surface.ink": "#e8f1f6",
10
+ "surface.ink2": "#b6ccd8",
11
+ "surface.muted": "#8aa6b6",
12
+ "accents.aqua": "#2fd6d0",
13
+ "accents.violet": "#7c6cf5",
14
+ "accents.magenta": "#e8489b",
15
+ "accents.amber": "#f5a524",
16
+ "type.sans": "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
17
+ "type.mono": "ui-monospace, \"SF Mono\", \"DejaVu Sans Mono\", \"Liberation Mono\", Menlo, Consolas, monospace"
18
+ },
19
+ "derived": {
20
+ "primary": {
21
+ "hex": "#208cb7",
22
+ "hsl": {
23
+ "h": 197.1,
24
+ "s": 70.2,
25
+ "l": 42.2
26
+ },
27
+ "shadcn": "197.1 70.2% 42.2%"
28
+ },
29
+ "primaryBright": {
30
+ "hex": "#24a3d6",
31
+ "hsl": {
32
+ "h": 197.2,
33
+ "s": 71.2,
34
+ "l": 49
35
+ },
36
+ "shadcn": "197.2 71.2% 49%"
37
+ },
38
+ "primaryDeep": {
39
+ "hex": "#1f425e",
40
+ "hsl": {
41
+ "h": 206.7,
42
+ "s": 50.4,
43
+ "l": 24.5
44
+ },
45
+ "shadcn": "206.7 50.4% 24.5%"
46
+ },
47
+ "primaryPale": {
48
+ "hex": "#9aceec",
49
+ "hsl": {
50
+ "h": 202,
51
+ "s": 68.3,
52
+ "l": 76.5
53
+ },
54
+ "shadcn": "202 68.3% 76.5%"
55
+ }
56
+ }
57
+ }
package/tokens.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "$license": "Copyright The DeviceChain Authors; SPDX-License-Identifier: Apache-2.0",
3
+ "$about": [
4
+ "THE canonical DeviceChain brand values. Every other representation is",
5
+ "GENERATED from this file — see tools/generate.mjs and the css/ directory.",
6
+ "Edit here, run `npm run generate -w @devicechain/brand`, commit the result.",
7
+ "",
8
+ "Values are stored as hex because the brandmark artwork is hex",
9
+ "(branding/logo.svg). HSL forms are DERIVED, never hand-written: the console",
10
+ "previously declared the brand blue as the rounded triple `197 71% 42%`,",
11
+ "which renders #1f8cb7 and is therefore not the same colour as the #208cb7",
12
+ "in the artwork. Deriving removes that class of mismatch entirely."
13
+ ],
14
+ "core": {
15
+ "primary": {
16
+ "value": "#208cb7",
17
+ "role": "Brand blue. The logo's \"CHAIN\" wordmark and cube. THE interactive colour — the only one that ever means \"you can click this\"."
18
+ },
19
+ "primaryBright": {
20
+ "value": "#24a3d6",
21
+ "role": "Primary lifted for dark grounds and hover states.",
22
+ "$note": "Reconciles a real three-way divergence. The console (dark `hsl(197 71% 49%)`) and the docs (dark `#24a3d6`) already agreed on this value; the marketing site had drifted to #3aa9d4 = hsl(197 64% 53%), which is visibly lighter and less saturated. Console is the documented source of truth and carried the majority, so it wins. Applying this to the website is a VISIBLE change to hover states and the hero eyebrow and must be reviewed on its own."
23
+ },
24
+ "primaryDeep": {
25
+ "value": "#1f425e",
26
+ "role": "Deepest cube facet. Navy, used for grounds rather than text."
27
+ },
28
+ "primaryPale": {
29
+ "value": "#9aceec",
30
+ "role": "Lightest cube facet. Highlights and hairlines."
31
+ }
32
+ },
33
+ "surface": {
34
+ "bg": {
35
+ "value": "#0b1620",
36
+ "role": "Default dark canvas — the \"devicechain.io treatment\" ground."
37
+ },
38
+ "bgDeep": {
39
+ "value": "#060b11",
40
+ "role": "Deeper canvas for pages carrying the hex field.",
41
+ "$note": "LOAD-BEARING: must equal the hex-field renderer's gl.clearColor. If they differ, distant fogged tiles read as a dark silhouette against a lighter sky instead of dissolving into it."
42
+ },
43
+ "ink": {
44
+ "value": "#e8f1f6",
45
+ "role": "Primary text on dark."
46
+ },
47
+ "ink2": {
48
+ "value": "#b6ccd8",
49
+ "role": "Secondary prose — ledes and section body."
50
+ },
51
+ "muted": {
52
+ "value": "#8aa6b6",
53
+ "role": "Tertiary text, captions, metadata."
54
+ }
55
+ },
56
+ "accents": {
57
+ "$about": [
58
+ "The expanded graphical language. The core palette above is a single hue —",
59
+ "every swatch sits between 197 and 207 — which is why the brand reads as",
60
+ "consistent but flat. These five stops walk monotonically THROUGH the",
61
+ "anchor blue rather than away from it, so each product area gets its own",
62
+ "identity without any of them fighting the brand.",
63
+ "",
64
+ "ONE RULE, and it is the whole system: core.primary is the only colour that",
65
+ "ever means \"interactive\". An accent identifies an AREA. It never marks a",
66
+ "link, a button or a control. Break that and the page stops being",
67
+ "navigable.",
68
+ "",
69
+ "Order is pipeline order: ingest -> detect -> react -> command."
70
+ ],
71
+ "aqua": {
72
+ "value": "#2fd6d0",
73
+ "role": "Ingest and edge."
74
+ },
75
+ "violet": {
76
+ "value": "#7c6cf5",
77
+ "role": "Detect."
78
+ },
79
+ "magenta": {
80
+ "value": "#e8489b",
81
+ "role": "React."
82
+ },
83
+ "amber": {
84
+ "value": "#f5a524",
85
+ "role": "Command.",
86
+ "$note": "Also the deliberate antidote to reading a product called DeviceChain as web3 — warm is the one thing that palette never is."
87
+ }
88
+ },
89
+ "type": {
90
+ "sans": {
91
+ "value": "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
92
+ "role": "UI and prose.",
93
+ "$note": "Placeholder stack. A real display/body pairing is still an open brand decision; the hero's headline sizing is currently tuned against whatever system-ui resolves to and will need re-tuning when a webfont lands."
94
+ },
95
+ "mono": {
96
+ "value": "ui-monospace, \"SF Mono\", \"DejaVu Sans Mono\", \"Liberation Mono\", Menlo, Consolas, monospace",
97
+ "role": "Eyebrows, labels, code, tabular data."
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,91 @@
1
+ // Copyright The DeviceChain Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Colour conversion for the token generator. Hand-rolled rather than pulling a
5
+ // dependency: this package must stay installable with zero transitive deps so it
6
+ // can be consumed by the docs build, the console build and (eventually) a static
7
+ // site with no bundler at all.
8
+
9
+ /** @param {string} hex `#rrggbb` */
10
+ export function hexToRgb(hex) {
11
+ const h = hex.trim().replace(/^#/, '');
12
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) {
13
+ throw new Error(`not a 6-digit hex colour: ${hex}`);
14
+ }
15
+ return [
16
+ parseInt(h.slice(0, 2), 16),
17
+ parseInt(h.slice(2, 4), 16),
18
+ parseInt(h.slice(4, 6), 16),
19
+ ];
20
+ }
21
+
22
+ /**
23
+ * sRGB -> HSL. Returns hue in degrees, saturation and lightness as percentages.
24
+ *
25
+ * Rounded to ONE decimal place deliberately. Full precision would emit values
26
+ * like 197.14285714285714 that churn the generated files on every regeneration
27
+ * for no visible benefit; integers would reintroduce exactly the rounding drift
28
+ * this package exists to remove (`197 71% 42%` renders #1f8cb7, not #208cb7).
29
+ */
30
+ export function hexToHsl(hex) {
31
+ const [r8, g8, b8] = hexToRgb(hex);
32
+ const r = r8 / 255;
33
+ const g = g8 / 255;
34
+ const b = b8 / 255;
35
+ const max = Math.max(r, g, b);
36
+ const min = Math.min(r, g, b);
37
+ const l = (max + min) / 2;
38
+ let h = 0;
39
+ let s = 0;
40
+ if (max !== min) {
41
+ const d = max - min;
42
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
43
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
44
+ else if (max === g) h = ((b - r) / d + 2) / 6;
45
+ else h = ((r - g) / d + 4) / 6;
46
+ }
47
+ const round1 = (n) => Math.round(n * 10) / 10;
48
+ return { h: round1(h * 360), s: round1(s * 100), l: round1(l * 100) };
49
+ }
50
+
51
+ /**
52
+ * The bare `H S% L%` triple shadcn/Tailwind expects, e.g. `197.1 70.2% 42.2%`.
53
+ * Note the absence of `hsl(...)` — shadcn composes these into
54
+ * `hsl(var(--primary) / <alpha>)`, so wrapping them breaks alpha support.
55
+ */
56
+ export function hexToShadcnTriple(hex) {
57
+ const { h, s, l } = hexToHsl(hex);
58
+ return `${h} ${s}% ${l}%`;
59
+ }
60
+
61
+ /** HSL (deg, %, %) -> `#rrggbb`. */
62
+ export function hslToHex(h, s, l) {
63
+ const sn = s / 100;
64
+ const ln = l / 100;
65
+ const k = (n) => (n + h / 30) % 12;
66
+ const a = sn * Math.min(ln, 1 - ln);
67
+ const f = (n) => ln - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
68
+ const to255 = (x) => Math.round(x * 255);
69
+ return (
70
+ '#' +
71
+ [to255(f(0)), to255(f(8)), to255(f(4))]
72
+ .map((c) => c.toString(16).padStart(2, '0'))
73
+ .join('')
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Scale a colour's LIGHTNESS by `factor`, preserving hue and saturation.
79
+ *
80
+ * This is how Infima's primary ramp is built, and getting it wrong is not
81
+ * subtle. The obvious implementation — linearly mixing toward white or black in
82
+ * RGB — adds white to all three channels equally, which DESATURATES. Measured
83
+ * against the docs site's existing ramp, a mix-to-white `lightest` came out at
84
+ * 51.5% saturation against the correct 71.1%: a visibly washed-out, greyer blue.
85
+ * Scaling lightness in HSL keeps the hue and the saturation exactly where they
86
+ * were and only moves the value up or down the ramp.
87
+ */
88
+ export function scaleLightness(hex, factor) {
89
+ const { h, s, l } = hexToHsl(hex);
90
+ return hslToHex(h, s, Math.max(0, Math.min(100, l * factor)));
91
+ }
@@ -0,0 +1,243 @@
1
+ // Copyright The DeviceChain Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Generate every consumer-facing representation of the brand from tokens.json.
5
+ //
6
+ // node tools/generate.mjs # write css/*
7
+ // node tools/generate.mjs --check # fail if anything on disk is stale
8
+ //
9
+ // Three outputs, because the three consumers do NOT share a vocabulary and a
10
+ // single stylesheet cannot serve them:
11
+ //
12
+ // css/brand.css marketing/static: --brand, --ink, --bg, accents (hex)
13
+ // css/shadcn.css console: --primary, --foreground, ... (BARE HSL
14
+ // triples, which is what Tailwind needs)
15
+ // css/infima.css docs: --ifm-color-primary* (hex, with the
16
+ // light/dark ramp Infima expects)
17
+ //
18
+ // Discovering that was the main design constraint: the console declares the
19
+ // brand blue as `197 71% 42%` while everyone else writes `#208cb7`, and Infima
20
+ // wants a five-step ramp nobody else has. Generating per-vocabulary is what
21
+ // makes one source of truth actually possible.
22
+
23
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
+ import { dirname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { hexToHsl, hexToShadcnTriple, scaleLightness } from './color.mjs';
27
+
28
+ const HERE = dirname(fileURLToPath(import.meta.url));
29
+ const ROOT = join(HERE, '..');
30
+ const tokens = JSON.parse(readFileSync(join(ROOT, 'tokens.json'), 'utf8'));
31
+
32
+ const v = (group, key) => tokens[group][key].value;
33
+ const HEADER = (what) =>
34
+ `/* Copyright The DeviceChain Authors */\n` +
35
+ `/* SPDX-License-Identifier: Apache-2.0 */\n\n` +
36
+ `/*\n * GENERATED by tools/generate.mjs from tokens.json — DO NOT HAND-EDIT.\n` +
37
+ ` *\n * ${what}\n */\n\n`;
38
+
39
+ /* ── Marketing / static vocabulary ─────────────────────────────────────── */
40
+
41
+ function brandCss() {
42
+ const { core, surface, accents, type } = tokens;
43
+ // Notes are prose and some run long. Wrap them rather than emitting a 140-col
44
+ // line: this is the one generated file that gets COPIED INTO ANOTHER REPO and
45
+ // read there by someone with no access to tokens.json, so the comment is the
46
+ // only explanation they get.
47
+ const wrap = (text, indent) => {
48
+ const width = 76 - indent.length;
49
+ const lines = [];
50
+ let cur = '';
51
+ for (const w of text.split(/\s+/)) {
52
+ if (cur && (cur + ' ' + w).length > width) {
53
+ lines.push(cur);
54
+ cur = w;
55
+ } else cur = cur ? cur + ' ' + w : w;
56
+ }
57
+ if (cur) lines.push(cur);
58
+ return lines.map((l, i) => `${indent}${i === 0 ? '/* ' : ' '}${l}`).join('\n') + ' */\n';
59
+ };
60
+ const line = (name, value, note) =>
61
+ (note ? wrap(note, ' ') : '') + ` --${name}: ${value};\n`;
62
+ let out = HEADER(
63
+ 'Marketing + static-site vocabulary. Consumed by the public website and by\n' +
64
+ ' * anything with no bundler: plain custom properties, hex values, no build step.\n' +
65
+ ' *\n' +
66
+ ' * This is the one output that is COPIED INTO ANOTHER REPOSITORY, so it names\n' +
67
+ ' * its source in full: frontend/packages/brand/tokens.json in the devicechain\n' +
68
+ ' * monorepo. Editing the copy is how the marketing site drifted onto a blue\n' +
69
+ ' * neither the console nor the docs used. Change tokens.json and re-sync.'
70
+ );
71
+ out += ':root {\n';
72
+ out += ' /* Core brand — sampled from the brandmark (branding/logo.svg). */\n';
73
+ out += line('brand', core.primary.value);
74
+ out += line('brand-bright', core.primaryBright.value);
75
+ out += line('brand-deep', core.primaryDeep.value);
76
+ out += line('brand-pale', core.primaryPale.value);
77
+ out += '\n /* Surfaces + text. */\n';
78
+ out += line('bg', surface.bg.value);
79
+ out += line('bg-deep', surface.bgDeep.value, surface.bgDeep.$note);
80
+ out += line('ink', surface.ink.value);
81
+ out += line('ink-2', surface.ink2.value);
82
+ out += line('muted', surface.muted.value);
83
+ out += '\n /* Derived surface treatments. */\n';
84
+ out += line('panel', 'rgba(255, 255, 255, 0.04)');
85
+ out += line('border', 'rgba(122, 183, 217, 0.18)');
86
+ out += line('rule', 'rgba(122, 183, 217, 0.16)');
87
+ out += line('rule-strong', 'rgba(122, 183, 217, 0.34)');
88
+ out += line('glow-strong', 'rgba(32, 140, 183, 0.25)');
89
+ out += line('glow-soft', 'rgba(32, 140, 183, 0.12)');
90
+ out +=
91
+ '\n /* Area accents. NEVER used for anything interactive — --brand alone\n' +
92
+ ' carries that meaning. Order is pipeline order. */\n';
93
+ for (const k of ['aqua', 'violet', 'magenta', 'amber']) {
94
+ out += line(k, accents[k].value, accents[k].role);
95
+ }
96
+ out += '\n /* Type stacks. */\n';
97
+ out += line('sans', type.sans.value);
98
+ out += line('mono', type.mono.value);
99
+ out += '}\n';
100
+ return out;
101
+ }
102
+
103
+ /* ── Console (shadcn / Tailwind) vocabulary ────────────────────────────── */
104
+
105
+ function shadcnCss() {
106
+ const { core } = tokens;
107
+ let out = HEADER(
108
+ 'Console vocabulary. Values are BARE `H S% L%` triples with no hsl() wrapper,\n' +
109
+ ' * because shadcn composes them as `hsl(var(--primary) / <alpha>)` — wrapping\n' +
110
+ ' * them here would silently break every alpha variant.\n' +
111
+ ' *\n' +
112
+ ' * Scope: every token whose value is DETERMINED BY the brand blue — the blue\n' +
113
+ ' * itself wherever shadcn names it, plus the foreground that has to read on\n' +
114
+ ' * top of it. A consumer keeps ownership of everything else in its scale\n' +
115
+ ' * (background, card, popover, chart-*, and the non-primary sidebar-*).'
116
+ );
117
+ const block = (sel, hex) => {
118
+ const t = hexToShadcnTriple(hex);
119
+ return (
120
+ `${sel} {\n` +
121
+ ` --primary: ${t};\n` +
122
+ ' --primary-foreground: 0 0% 100%;\n' +
123
+ ` --ring: ${t};\n` +
124
+ ` --sidebar-primary: ${t};\n` +
125
+ ' --sidebar-primary-foreground: 0 0% 100%;\n' +
126
+ ` --sidebar-ring: ${t};\n` +
127
+ '}\n'
128
+ );
129
+ };
130
+ out += block(':root', core.primary.value);
131
+ out += '\n';
132
+ out += block('.dark', core.primaryBright.value);
133
+ return out;
134
+ }
135
+
136
+ /* ── Docs (Docusaurus / Infima) vocabulary ─────────────────────────────── */
137
+
138
+ function infimaCss() {
139
+ const { core } = tokens;
140
+ // Infima wants a six-step ramp around primary, and it is built by scaling
141
+ // LIGHTNESS in HSL — hue and saturation are held constant. The factors below
142
+ // are Docusaurus's own conventional steps.
143
+ //
144
+ // Do not "simplify" this to an RGB mix toward white/black. That adds white to
145
+ // all three channels equally and therefore desaturates: checked against the
146
+ // docs site's existing ramp, a mix-to-white `lightest` landed at 51.5%
147
+ // saturation against the correct 71.1% — a visibly greyer, washed-out blue.
148
+ const ramp = (base) => ({
149
+ darkest: scaleLightness(base, 0.7),
150
+ darker: scaleLightness(base, 0.85),
151
+ dark: scaleLightness(base, 0.9),
152
+ base,
153
+ light: scaleLightness(base, 1.1),
154
+ lighter: scaleLightness(base, 1.15),
155
+ lightest: scaleLightness(base, 1.3),
156
+ });
157
+ const emit = (sel, base) => {
158
+ const r = ramp(base);
159
+ let s = `${sel} {\n`;
160
+ s += ` --ifm-color-primary: ${r.base};\n`;
161
+ s += ` --ifm-color-primary-dark: ${r.dark};\n`;
162
+ s += ` --ifm-color-primary-darker: ${r.darker};\n`;
163
+ s += ` --ifm-color-primary-darkest: ${r.darkest};\n`;
164
+ s += ` --ifm-color-primary-light: ${r.light};\n`;
165
+ s += ` --ifm-color-primary-lighter: ${r.lighter};\n`;
166
+ s += ` --ifm-color-primary-lightest: ${r.lightest};\n`;
167
+ s += '}\n';
168
+ return s;
169
+ };
170
+ let out = HEADER(
171
+ 'Docs vocabulary (Docusaurus/Infima). Infima needs a five-step ramp either\n' +
172
+ ' * side of primary; it is generated from the single canonical value so the\n' +
173
+ ' * steps cannot drift out of order.\n' +
174
+ ' *\n' +
175
+ ' * Brand colours only — the docs site owns its own background/surface theming.'
176
+ );
177
+ out += emit(':root', tokens.core.primary.value);
178
+ out += '\n';
179
+ out += emit("[data-theme='dark']", core.primaryBright.value);
180
+ return out;
181
+ }
182
+
183
+ /* ── JSON, for anything that would rather read data than parse CSS ─────── */
184
+
185
+ function flatJson() {
186
+ const flat = {};
187
+ for (const [group, entries] of Object.entries(tokens)) {
188
+ if (group.startsWith('$')) continue;
189
+ for (const [key, def] of Object.entries(entries)) {
190
+ if (key.startsWith('$')) continue;
191
+ flat[`${group}.${key}`] = def.value;
192
+ }
193
+ }
194
+ const derived = {};
195
+ for (const k of ['primary', 'primaryBright', 'primaryDeep', 'primaryPale']) {
196
+ derived[k] = {
197
+ hex: tokens.core[k].value,
198
+ hsl: hexToHsl(tokens.core[k].value),
199
+ shadcn: hexToShadcnTriple(tokens.core[k].value),
200
+ };
201
+ }
202
+ return JSON.stringify({ flat, derived }, null, 2) + '\n';
203
+ }
204
+
205
+ /* ── Drive ─────────────────────────────────────────────────────────────── */
206
+
207
+ const OUTPUTS = [
208
+ ['css/brand.css', brandCss()],
209
+ ['css/shadcn.css', shadcnCss()],
210
+ ['css/infima.css', infimaCss()],
211
+ ['tokens.generated.json', flatJson()],
212
+ ];
213
+
214
+ const check = process.argv.includes('--check');
215
+ let stale = [];
216
+
217
+ mkdirSync(join(ROOT, 'css'), { recursive: true });
218
+ for (const [rel, content] of OUTPUTS) {
219
+ const path = join(ROOT, rel);
220
+ if (check) {
221
+ let existing = null;
222
+ try {
223
+ existing = readFileSync(path, 'utf8');
224
+ } catch {
225
+ /* missing counts as stale */
226
+ }
227
+ if (existing !== content) stale.push(rel);
228
+ } else {
229
+ writeFileSync(path, content, 'utf8');
230
+ console.log(`wrote ${rel}`);
231
+ }
232
+ }
233
+
234
+ if (check) {
235
+ if (stale.length) {
236
+ console.error(
237
+ `error: generated files are stale or missing:\n ${stale.join('\n ')}\n` +
238
+ `Run \`npm run generate -w @devicechain/brand\` and commit the result.`
239
+ );
240
+ process.exit(1);
241
+ }
242
+ console.log('generated files are up to date');
243
+ }
@@ -0,0 +1,203 @@
1
+ // Copyright The DeviceChain Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Report where a consumer's declared brand values disagree with tokens.json.
5
+ //
6
+ // node tools/verify.mjs
7
+ // DC_WEBSITE=/path/to/devicechain-website node tools/verify.mjs
8
+ //
9
+ // This exists because "keep it in step with the console" was a COMMENT, not a
10
+ // guarantee, and it had already failed: the marketing site's lifted blue had
11
+ // drifted to a third value that neither the console nor the docs used, and the
12
+ // console's rounded HSL triple was not the same colour as the hex everyone else
13
+ // wrote. A comment cannot catch that. A script run in CI can.
14
+ //
15
+ // Deliberately read-only and advisory about WHICH way to fix things: it prints
16
+ // what disagrees and exits non-zero, but never rewrites a consumer, because
17
+ // changing a shipped colour is a visual decision, not a mechanical one.
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { hexToHsl, hexToShadcnTriple } from './color.mjs';
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const ROOT = join(HERE, '..');
26
+ const REPO = join(ROOT, '..', '..', '..');
27
+ const tokens = JSON.parse(readFileSync(join(ROOT, 'tokens.json'), 'utf8'));
28
+
29
+ const PRIMARY = tokens.core.primary.value;
30
+ const BRIGHT = tokens.core.primaryBright.value;
31
+
32
+ const findings = [];
33
+ const checked = [];
34
+
35
+ function read(rel) {
36
+ try {
37
+ return readFileSync(join(REPO, rel), 'utf8');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** Last declaration wins, matching the cascade, so scan all and keep the last. */
44
+ function declared(css, prop, withinSelector) {
45
+ const scope = withinSelector
46
+ ? (css.match(
47
+ new RegExp(
48
+ `${withinSelector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*)\\}`,
49
+ 'g'
50
+ )
51
+ ) || []).join('\n')
52
+ : css;
53
+ const re = new RegExp(`--${prop}\\s*:\\s*([^;]+);`, 'g');
54
+ let m;
55
+ let last = null;
56
+ while ((m = re.exec(scope))) last = m[1].trim();
57
+ return last;
58
+ }
59
+
60
+ function compare(where, label, actual, expected, note) {
61
+ checked.push(where + ' · ' + label);
62
+ if (actual === null) {
63
+ findings.push({ where, label, actual: '(not declared)', expected, note });
64
+ return;
65
+ }
66
+ if (actual.toLowerCase() !== expected.toLowerCase()) {
67
+ findings.push({ where, label, actual, expected, note });
68
+ }
69
+ }
70
+
71
+ /* ── shadcn-triple consumers ───────────────────────────────────────────── */
72
+ //
73
+ // BOTH front-end apps, not just the console. Checking only the console was a
74
+ // real hole: apps/dashboard/src/theme.css carries the same token contract (it
75
+ // is what @devicechain/widgets reads, so a dashboard renders identically in
76
+ // either host) and had drifted to the same wrong triple — unnoticed, because
77
+ // nothing looked at it. A gate that covers one of two consumers mostly teaches
78
+ // you to trust it.
79
+ for (const rel of [
80
+ 'frontend/apps/console/src/index.css',
81
+ 'frontend/apps/dashboard/src/theme.css',
82
+ ]) {
83
+ const css = read(rel);
84
+ if (!css) {
85
+ console.warn(`skip: ${rel} not found`);
86
+ continue;
87
+ }
88
+ if (/@import\s+['"]@devicechain\/brand\/css\/shadcn\.css['"]/.test(css)) {
89
+ // A MIGRATED consumer declares nothing — it imports. See the docs branch
90
+ // below for why an un-special-cased scan here is worse than useless.
91
+ console.log(`ok: ${rel} imports the generated triples — nothing to compare`);
92
+ continue;
93
+ }
94
+ // The :root block is light mode; .dark carries the lifted values.
95
+ compare(
96
+ rel,
97
+ 'light --primary',
98
+ declared(css, 'primary', ':root'),
99
+ hexToShadcnTriple(PRIMARY),
100
+ `rounded triples do not round-trip: '197 71% 42%' renders #1f8cb7, not ${PRIMARY}`
101
+ );
102
+ compare(rel, 'dark --primary', declared(css, 'primary', '.dark'), hexToShadcnTriple(BRIGHT));
103
+ }
104
+
105
+ /* ── Docs: Infima hex, base AND the full ramp ──────────────────────────── */
106
+ {
107
+ const rel = 'docs/src/css/custom.css';
108
+ const css = read(rel);
109
+ if (!css) {
110
+ console.warn(`skip: ${rel} not found`);
111
+ } else if (/@import\s+['"]@devicechain\/brand\/css\/infima\.css['"]/.test(css)) {
112
+ // A MIGRATED consumer declares nothing — it imports. Without this branch the
113
+ // declaration scan below finds nothing and reports 14 "(not declared)"
114
+ // failures against a consumer that is in fact perfectly correct, which is
115
+ // the fastest way to teach everyone to ignore this script.
116
+ console.log(`ok: ${rel} imports the generated ramp — nothing to compare`);
117
+ } else {
118
+ // Checking only the base colour is NOT enough, and assuming otherwise gave
119
+ // a false all-clear once already: the docs' base matched exactly while its
120
+ // hand-built DARK ramp differed by up to 45/255 on --ifm-color-primary-
121
+ // lightest. Infima renders links, hovers and admonitions out of the ramp,
122
+ // not the base, so the ramp is the part a reader actually sees.
123
+ const generated = readFileSync(join(ROOT, 'css/infima.css'), 'utf8');
124
+ const STEPS = ['', '-dark', '-darker', '-darkest', '-light', '-lighter', '-lightest'];
125
+ for (const [theme, selector] of [
126
+ ['light', ':root'],
127
+ ['dark', "[data-theme='dark']"],
128
+ ]) {
129
+ for (const step of STEPS) {
130
+ const prop = `ifm-color-primary${step}`;
131
+ compare(
132
+ rel,
133
+ `${theme} --${prop}`,
134
+ declared(css, prop, selector),
135
+ declared(generated, prop, selector)
136
+ );
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /* ── Marketing site: a SEPARATE REPO, so opt in by path ────────────────── */
143
+ {
144
+ const base = process.env.DC_WEBSITE;
145
+ if (!base) {
146
+ console.warn(
147
+ 'skip: marketing site not checked — set DC_WEBSITE=/path/to/devicechain-website'
148
+ );
149
+ } else {
150
+ let css = null;
151
+ try {
152
+ css = readFileSync(join(base, 'brand.css'), 'utf8');
153
+ } catch {
154
+ console.warn(`skip: ${join(base, 'brand.css')} not found`);
155
+ }
156
+ if (css) {
157
+ const rel = `${base}/brand.css`;
158
+ compare(rel, '--brand', declared(css, 'brand', ':root'), PRIMARY);
159
+ compare(
160
+ rel,
161
+ '--brand-bright',
162
+ declared(css, 'brand-bright', ':root'),
163
+ BRIGHT,
164
+ 'the site had drifted to a third value that neither console nor docs used'
165
+ );
166
+ compare(rel, '--bg-deep', declared(css, 'bg-deep', ':root'), tokens.surface.bgDeep.value);
167
+ for (const k of ['aqua', 'violet', 'magenta', 'amber']) {
168
+ compare(rel, `--${k}`, declared(css, k, ':root'), tokens.accents[k].value);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ /* ── Report ────────────────────────────────────────────────────────────── */
175
+
176
+ const hsl = hexToHsl(PRIMARY);
177
+ console.log(
178
+ `\ncanonical primary ${PRIMARY} = hsl(${hsl.h} ${hsl.s}% ${hsl.l}%) = shadcn "${hexToShadcnTriple(
179
+ PRIMARY
180
+ )}"`
181
+ );
182
+ console.log(`canonical lifted ${BRIGHT} = shadcn "${hexToShadcnTriple(BRIGHT)}"\n`);
183
+ console.log(`checked ${checked.length} declaration(s)`);
184
+
185
+ if (!findings.length) {
186
+ console.log('no drift — every consumer agrees with tokens.json');
187
+ process.exit(0);
188
+ }
189
+
190
+ console.log(`\n${findings.length} disagreement(s) with tokens.json:\n`);
191
+ for (const f of findings) {
192
+ console.log(` ${f.where}`);
193
+ console.log(` ${f.label}`);
194
+ console.log(` declared: ${f.actual}`);
195
+ console.log(` canonical: ${f.expected}`);
196
+ if (f.note) console.log(` note: ${f.note}`);
197
+ console.log('');
198
+ }
199
+ console.log(
200
+ 'Each of these is a VISUAL decision. Migrate a consumer deliberately —\n' +
201
+ 'do not bulk-rewrite shipped colours to silence this script.'
202
+ );
203
+ process.exit(1);