cleo_design_tokens 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1c372b6959061b875faa56e38b0f8133d1784fb6dfdcde51506703d12502f0f8
4
+ data.tar.gz: b2ed96b40cbb505dee5238937ad7ae8ea515270ec3538017c0347dda2bfacb45
5
+ SHA512:
6
+ metadata.gz: 3f2761c8357487e4652bf8e590ea04966692c30cde64113e31fc4c1dabfc5c456666a35e37b454f767eaa8ca4db7f76e34290cb24afcb88eea6f389029fbf7cd
7
+ data.tar.gz: 0f8939327b3e5c9544f5dab0183fb253b2c66c9b972496d8668e70b2d33f935b324d1a1bdc2e0fd9f270a60d19404c7f24833257017f32216529b1afabb4b0f5
@@ -0,0 +1,22 @@
1
+ require_relative "errors"
2
+
3
+ module CleoDesignTokens
4
+ # Wraps one frozen lookup with a single-argument `fetch`. Exposed so tests
5
+ # can build one over a fixture lookup, exactly like the module's own
6
+ # `colors.primitives` below.
7
+ class Bucket
8
+ def initialize(lookup)
9
+ @lookup = lookup
10
+ end
11
+
12
+ def fetch(key)
13
+ value = @lookup.fetch(key) { raise UnknownTokenError, "unknown design token: #{key.inspect}" }
14
+ # `Hash#fetch` only guards a missing *key* — a key present with a nil
15
+ # value (malformed data: a primitive leaf with no `$value`) would
16
+ # otherwise return nil instead of raising.
17
+ raise UnknownTokenError, "design token #{key.inspect} has no value" if value.nil?
18
+
19
+ value
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ module CleoDesignTokens
2
+ class UnknownTokenError < KeyError; end
3
+ class UnknownThemeError < ArgumentError; end
4
+ class DuplicateTokenError < KeyError; end
5
+ end
@@ -0,0 +1,62 @@
1
+ require_relative "tree_walker"
2
+ require_relative "semantic_entry"
3
+ require_relative "errors"
4
+
5
+ module CleoDesignTokens
6
+ # Flattens token trees into frozen key -> value lookups, using TreeWalker.
7
+ # Exposed as module functions, rather than baked into module load, so the
8
+ # collision case is testable against fixtures.
9
+ module LookupBuilder
10
+ # Flattens one tree of plain (theme-free) leaves — e.g. the parsed
11
+ # `primitives.json` — into a key -> hex lookup.
12
+ def self.build_lookup(tree)
13
+ flatten_tree(tree) { |leaf| leaf["$value"].freeze }
14
+ end
15
+
16
+ # Flattens `semantic.json` into a key -> SemanticEntry lookup, one entry
17
+ # per role — the theme axis lives inside the entry, not in the key.
18
+ def self.build_semantic_lookup(tree)
19
+ flatten_tree(tree) { |leaf| semantic_entry(leaf) }
20
+ end
21
+
22
+ # Walks `tree`, turning each leaf into a lookup entry via the block —
23
+ # the only thing that differs between the two builders above is how a
24
+ # leaf becomes a value, so that's the only thing left as a parameter.
25
+ #
26
+ # Fails loudly on a collision (two paths normalising to the same key)
27
+ # rather than letting one silently win, shared here so neither builder
28
+ # can drift on that guarantee. `origins` maps key -> the *path array*
29
+ # that produced it, not the key itself — two different paths can
30
+ # normalise to the same key (e.g. a segment that itself contains a
31
+ # literal `.`), and storing the path (rather than re-deriving the
32
+ # identical string) is what lets the error below name both distinctly
33
+ # instead of printing the same string twice.
34
+ def self.flatten_tree(tree)
35
+ lookup = {}
36
+ origins = {}
37
+ TreeWalker.walk(tree) do |path, leaf|
38
+ key = path.join(".")
39
+ if origins.key?(key)
40
+ raise DuplicateTokenError,
41
+ "duplicate design token #{key.inspect}: defined at both " \
42
+ "#{origins.fetch(key).inspect} and #{path.inspect}"
43
+ end
44
+
45
+ origins[key] = path
46
+ lookup[key] = yield(leaf)
47
+ end
48
+ lookup
49
+ end
50
+ private_class_method :flatten_tree
51
+
52
+ def self.semantic_entry(leaf)
53
+ themes = {}
54
+ (leaf["$themes"] || {}).each { |theme, override| themes[theme] = override["$value"].freeze }
55
+
56
+ # `Data#new` freezes the instance itself; the Hash inside it still
57
+ # needs its own `.freeze` — freezing is shallow.
58
+ SemanticEntry.new(leaf["$value"]&.freeze, themes.freeze)
59
+ end
60
+ private_class_method :semantic_entry
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "errors"
2
+
3
+ module CleoDesignTokens
4
+ # `theme:` defaults to `"base"` — `$themes` never carries a `"base"` key
5
+ # (Base isn't an override of itself), so the default and an explicit
6
+ # `theme: :base` resolve identically with no special-casing.
7
+ class SemanticBucket
8
+ THEMES = %w[base chat roast hype].freeze
9
+ private_constant :THEMES
10
+
11
+ def initialize(lookup)
12
+ @lookup = lookup
13
+ end
14
+
15
+ def fetch(key, theme: "base")
16
+ theme = theme.to_s
17
+ raise UnknownThemeError, "unknown theme: #{theme.inspect}" unless THEMES.include?(theme)
18
+
19
+ entry = @lookup.fetch(key) { raise UnknownTokenError, "unknown design token: #{key.inspect}" }
20
+ value = resolve(entry, theme)
21
+ if value.nil?
22
+ raise UnknownTokenError,
23
+ "design token #{key.inspect} has no Base value and no override for theme #{theme.inspect}"
24
+ end
25
+
26
+ value
27
+ end
28
+
29
+ private
30
+
31
+ # What a caller gets for (role, theme): the theme's override when it has
32
+ # one, otherwise the Base value — mirrors transform-core.mjs's
33
+ # `resolveTheme` exactly. Returns nil when neither exists, which is what
34
+ # makes `fetch` raise for the Base-less roles.
35
+ def resolve(entry, theme)
36
+ entry.themes[theme] || entry.value
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ module CleoDesignTokens
2
+ # One semantic role: its Base value (nil when the role only exists under a
3
+ # theme) plus whatever theme overrides genuinely differ from Base. `Data`,
4
+ # not a `Struct` — this is a fixed value, never mutated after it's built,
5
+ # and `Data` instances are frozen on construction with no extra `.freeze`.
6
+ SemanticEntry = Data.define(:value, :themes)
7
+ end
@@ -0,0 +1,28 @@
1
+ module CleoDesignTokens
2
+ # Walks a token tree, skipping `$`-prefixed keys, yielding each leaf's path
3
+ # and node to the block. Keys are the JSON path, dot-joined — the files
4
+ # carry no `color.primitives`/`color.semantic` wrapper to strip, and
5
+ # segments are already normalised on disk, so there's nothing left for a
6
+ # caller to do to a path.
7
+ module TreeWalker
8
+ # A leaf is `{ "$type" => "color", ... }` — matches transform-core.mjs's
9
+ # `isLeaf` exactly. A role can carry `$themes` and no `$value` at all
10
+ # (defined only under a theme, missing from Base in Figma), so `$value`
11
+ # presence is never part of this check.
12
+ def self.leaf?(node)
13
+ node.is_a?(Hash) && node["$type"] == "color"
14
+ end
15
+ private_class_method :leaf?
16
+
17
+ def self.walk(node, path = [], &on_leaf)
18
+ return unless node.is_a?(Hash)
19
+ return on_leaf.call(path, node) if leaf?(node)
20
+
21
+ node.each do |child_key, child_value|
22
+ next if child_key.start_with?("$")
23
+
24
+ walk(child_value, path + [child_key], &on_leaf)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ require "json"
2
+
3
+ module CleoDesignTokens
4
+ # `package.json`'s `version` is the single source of truth — npm requires
5
+ # it to be a literal, so it can't defer to us. The gemspec ships
6
+ # `package.json` alongside `lib/` (see `spec.files`) specifically so this
7
+ # read works from an installed gem too, not just this source checkout.
8
+ VERSION = JSON.parse(File.read(File.expand_path("../../package.json", __dir__))).fetch("version")
9
+ end
@@ -0,0 +1,45 @@
1
+ require "json"
2
+
3
+ # Only what this file references directly. `lookup_builder` pulls in
4
+ # `tree_walker`/`semantic_entry`/`errors` itself, and `bucket`/
5
+ # `semantic_bucket` each pull in `errors` themselves — Ruby's `require` is
6
+ # idempotent, so re-requiring them here would just be noise.
7
+ require_relative "cleo_design_tokens/version"
8
+ require_relative "cleo_design_tokens/lookup_builder"
9
+ require_relative "cleo_design_tokens/bucket"
10
+ require_relative "cleo_design_tokens/semantic_bucket"
11
+
12
+ # Reads Cleo's canonical colour design tokens (packages/tokens/tokens/color)
13
+ # into two frozen lookups, namespaced by token type then layer, with the
14
+ # theme passed alongside the key rather than baked into it.
15
+ #
16
+ # CleoDesignTokens.colors.semantic.fetch("core.content.primary") # => "#47201C"
17
+ # CleoDesignTokens.colors.semantic.fetch("core.content.primary", theme: :roast) # => "#F8F6F2"
18
+ # CleoDesignTokens.colors.primitives.fetch("brown.800") # => "#47201C"
19
+ module CleoDesignTokens
20
+ TOKENS_DIR = File.expand_path("../tokens/color", __dir__)
21
+ PRIMITIVES_PATH = File.join(TOKENS_DIR, "primitives.json")
22
+ SEMANTIC_PATH = File.join(TOKENS_DIR, "semantic.json")
23
+
24
+ # Built at load, into constants — not `@lookup ||=`, which would race under
25
+ # concurrent access. Frozen from the moment the gem loads, so every reader
26
+ # in every thread sees the same immutable lookup.
27
+ PRIMITIVES_LOOKUP = LookupBuilder.build_lookup(JSON.parse(File.read(PRIMITIVES_PATH))).freeze
28
+ SEMANTIC_LOOKUP = LookupBuilder.build_semantic_lookup(JSON.parse(File.read(SEMANTIC_PATH))).freeze
29
+
30
+ # Namespaced by token type (`colors`), then by layer (`primitives`,
31
+ # `semantic`) — lowercase accessors, not `CleoDesignTokens::Colors::
32
+ # Semantic.fetch(...)` module nesting, so the call text stays identical to
33
+ # the TypeScript reader (a colour greps across both repos).
34
+ #
35
+ # `Data`, not a plain Hash: `colors.primitives` needs to be an attribute
36
+ # read (matching the reader's own dot-accessor contract), and a Hash has
37
+ # no `.primitives` method. Plural `Colors`, not `Color` — an instance
38
+ # holds both buckets for the `colors` token type, not a single colour.
39
+ Colors = Data.define(:primitives, :semantic)
40
+ COLORS = Colors.new(primitives: Bucket.new(PRIMITIVES_LOOKUP), semantic: SemanticBucket.new(SEMANTIC_LOOKUP))
41
+
42
+ def self.colors
43
+ COLORS
44
+ end
45
+ end
data/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@meetcleo/design-tokens",
3
+ "version": "0.1.0",
4
+ "description": "Canonical source of truth for Cleo's design tokens (DTCG JSON), plus a TypeScript reader.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/meetcleo/cleonardo.git",
10
+ "directory": "packages/tokens"
11
+ },
12
+ "homepage": "https://github.com/meetcleo/cleonardo/tree/main/packages/tokens",
13
+ "bugs": {
14
+ "url": "https://github.com/meetcleo/cleonardo/issues"
15
+ },
16
+ "publishConfig": {
17
+ "registry": "https://npm.pkg.github.com"
18
+ },
19
+ "engines": {
20
+ "node": ">=24"
21
+ },
22
+ "main": "dist/src/CleoDesignTokens.js",
23
+ "types": "dist/src/CleoDesignTokens.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/src/CleoDesignTokens.d.ts",
27
+ "default": "./dist/src/CleoDesignTokens.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md"
33
+ ],
34
+ "scripts": {
35
+ "transform": "node scripts/transform.mjs",
36
+ "check": "node scripts/transform.mjs --check",
37
+ "verify": "node scripts/verify-tokens.mjs",
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "prepack": "yarn build"
40
+ }
41
+ }