poetry-core 0.0.2
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 +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
class Tokens
|
|
6
|
+
# An OKLCH color value (with optional alpha) plus the conversion and
|
|
7
|
+
# contrast math the AAA-contrast gate is built on:
|
|
8
|
+
#
|
|
9
|
+
# OKLCH -> OKLab -> linear sRGB -> gamma sRGB (Björn Ottosson's matrices)
|
|
10
|
+
# WCAG 2.x relative luminance + contrast ratio
|
|
11
|
+
# browser-style alpha compositing (gamma-encoded sRGB blend)
|
|
12
|
+
#
|
|
13
|
+
# Pure Ruby, no dependencies - cheap enough to run on every CI build.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# color = Poetry::Core::Tokens::Color.parse("#1A1C1E")
|
|
17
|
+
# color.css # => "oklch(0.225 0.005 248.047)"
|
|
18
|
+
# color.contrast_ratio(Poetry::Core::Tokens::Color::WHITE) # => 17.09...
|
|
19
|
+
class Color
|
|
20
|
+
# WCAG 2.x contrast shared by Color and Blend: relative luminance is
|
|
21
|
+
# computed from gamma-encoded sRGB (the value a browser actually paints).
|
|
22
|
+
module Contrast
|
|
23
|
+
# WCAG 2.x relative luminance of the painted color.
|
|
24
|
+
#
|
|
25
|
+
# @return [Float] 0.0 (black) to 1.0 (white)
|
|
26
|
+
def luminance
|
|
27
|
+
r, g, b = srgb.map { |v| v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055)**2.4 }
|
|
28
|
+
(0.2126 * r) + (0.7152 * g) + (0.0722 * b)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The WCAG contrast ratio against another color, from 1 to 21.
|
|
32
|
+
#
|
|
33
|
+
# @param other [#luminance] a Color or Blend
|
|
34
|
+
# @return [Float]
|
|
35
|
+
def contrast_ratio(other)
|
|
36
|
+
pair = [luminance, other.luminance].sort
|
|
37
|
+
(pair[1] + 0.05) / (pair[0] + 0.05)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The result of alpha-compositing one color over another: a plain
|
|
42
|
+
# gamma-encoded sRGB triplet that still knows how to measure contrast.
|
|
43
|
+
Blend = Struct.new(:srgb) do
|
|
44
|
+
include Contrast
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
include Contrast
|
|
48
|
+
|
|
49
|
+
attr_reader :l, :c, :h, :alpha
|
|
50
|
+
|
|
51
|
+
# An OKLCH color; every component is stored as a Float.
|
|
52
|
+
#
|
|
53
|
+
# @param l [Numeric] lightness, 0 to 1
|
|
54
|
+
# @param c [Numeric] chroma
|
|
55
|
+
# @param h [Numeric] hue, in degrees
|
|
56
|
+
# @param alpha [Numeric] opacity, 0 to 1
|
|
57
|
+
def initialize(l:, c: 0.0, h: 0.0, alpha: 1.0)
|
|
58
|
+
@l = l.to_f
|
|
59
|
+
@c = c.to_f
|
|
60
|
+
@h = h.to_f
|
|
61
|
+
@alpha = alpha.to_f
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Build from a DTCG color $value: {"colorSpace" => "oklch",
|
|
65
|
+
# "components" => [l, c, h], "alpha" => 0.1 (optional)}.
|
|
66
|
+
#
|
|
67
|
+
# @param value [Hash] the token's `$value`
|
|
68
|
+
# @return [Color]
|
|
69
|
+
# @raise [ArgumentError] for any colorSpace other than oklch
|
|
70
|
+
def self.from_dtcg(value)
|
|
71
|
+
color_space = value["colorSpace"]
|
|
72
|
+
unless color_space == "oklch"
|
|
73
|
+
raise ArgumentError, "unsupported colorSpace #{color_space.inspect} (only oklch)"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
l, c, h = value.fetch("components")
|
|
77
|
+
new(l: l, c: c, h: h, alpha: value.fetch("alpha", 1.0))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Pure white - the literal foreground on destructive surfaces.
|
|
81
|
+
WHITE = new(l: 1.0)
|
|
82
|
+
# Pure black - the dark end of the contrast scale, the counterpart
|
|
83
|
+
# of {WHITE}.
|
|
84
|
+
BLACK = new(l: 0.0)
|
|
85
|
+
|
|
86
|
+
# The CSS color spellings DESIGN.md files carry across the design-skill
|
|
87
|
+
# ecosystem: poetry emits oklch; foreign-authored files arrive in
|
|
88
|
+
# hex or rgb().
|
|
89
|
+
OKLCH_CSS = %r{\Aoklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*/\s*([\d.]+)(%?))?\s*\)\z}i
|
|
90
|
+
# The hex color forms #parse accepts (3/4/6/8 digits).
|
|
91
|
+
HEX_CSS = /\A#(\h{3}|\h{4}|\h{6}|\h{8})\z/
|
|
92
|
+
# The rgb()/rgba() color forms #parse accepts.
|
|
93
|
+
RGB_CSS = %r{\Argba?\(\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)\z}i
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
# Parse a CSS color string into a Color, or nil for anything else
|
|
97
|
+
# (named colors, var() refs, gradients) - the DESIGN.md importer
|
|
98
|
+
# DROPS what it cannot parse, never guesses. oklch input keeps its
|
|
99
|
+
# components verbatim so poetry-authored values round-trip
|
|
100
|
+
# byte-exact through parse -> css.
|
|
101
|
+
#
|
|
102
|
+
# @param css [String, #to_s] a CSS color: oklch(), #hex, rgb()/rgba()
|
|
103
|
+
# @return [Color, nil]
|
|
104
|
+
def parse(css)
|
|
105
|
+
value = css.to_s.strip
|
|
106
|
+
if (match = value.match(OKLCH_CSS))
|
|
107
|
+
lightness = match[2] == "%" ? match[1].to_f / 100.0 : match[1].to_f
|
|
108
|
+
new(l: lightness, c: match[3].to_f, h: match[4].to_f, alpha: parse_alpha(match[5], match[6]))
|
|
109
|
+
elsif (match = value.match(HEX_CSS))
|
|
110
|
+
from_hex(match[1])
|
|
111
|
+
elsif (match = value.match(RGB_CSS))
|
|
112
|
+
channels = [match[1], match[2], match[3]].map { |channel| channel.to_i / 255.0 }
|
|
113
|
+
from_srgb(channels, alpha: parse_alpha(match[4], match[5]))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Gamma-encoded sRGB [0,1] triplet -> Color, via Ottosson's inverse
|
|
118
|
+
# path (linear sRGB -> LMS -> OKLab -> LCH). Components round to the
|
|
119
|
+
# 3-decimal precision distributed theme files publish.
|
|
120
|
+
#
|
|
121
|
+
# @param srgb [Array<Numeric>] gamma-encoded r, g, b, each in [0, 1]
|
|
122
|
+
# @param alpha [Numeric] opacity, 0 to 1
|
|
123
|
+
# @return [Color]
|
|
124
|
+
def from_srgb(srgb, alpha: 1.0)
|
|
125
|
+
r, g, b = srgb.map { |v| v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055)**2.4 }
|
|
126
|
+
|
|
127
|
+
l_ = Math.cbrt((0.4122214708 * r) + (0.5363325363 * g) + (0.0514459929 * b))
|
|
128
|
+
m_ = Math.cbrt((0.2119034982 * r) + (0.6806995451 * g) + (0.1073969566 * b))
|
|
129
|
+
s_ = Math.cbrt((0.0883024619 * r) + (0.2817188376 * g) + (0.6299787005 * b))
|
|
130
|
+
|
|
131
|
+
lab_l = (0.2104542553 * l_) + (0.7936177850 * m_) - (0.0040720468 * s_)
|
|
132
|
+
lab_a = (1.9779984951 * l_) - (2.4285922050 * m_) + (0.4505937099 * s_)
|
|
133
|
+
lab_b = (0.0259040371 * l_) + (0.7827717662 * m_) - (0.8086757660 * s_)
|
|
134
|
+
|
|
135
|
+
chroma = Math.sqrt((lab_a**2) + (lab_b**2))
|
|
136
|
+
hue = chroma < 1e-4 ? 0.0 : ((Math.atan2(lab_b, lab_a) * 180.0 / Math::PI) % 360.0)
|
|
137
|
+
new(l: lab_l.clamp(0.0, 1.0).round(3), c: chroma.round(3), h: hue.round(3), alpha: alpha)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def from_hex(digits)
|
|
143
|
+
digits = digits.chars.map { |d| d * 2 }.join if digits.length <= 4
|
|
144
|
+
channels = digits.scan(/\h{2}/).map { |pair| pair.to_i(16) / 255.0 }
|
|
145
|
+
from_srgb(channels.first(3), alpha: channels.fetch(3, 1.0))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_alpha(raw, percent)
|
|
149
|
+
return 1.0 if raw.nil?
|
|
150
|
+
|
|
151
|
+
percent == "%" ? raw.to_f / 100.0 : raw.to_f
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A copy with any component replaced. The import AA-walk moves L in
|
|
156
|
+
# fixed steps while chroma holds - deterministic.
|
|
157
|
+
#
|
|
158
|
+
# @param l [Numeric] lightness, 0 to 1
|
|
159
|
+
# @param c [Numeric] chroma
|
|
160
|
+
# @param h [Numeric] hue, in degrees
|
|
161
|
+
# @param alpha [Numeric] opacity, 0 to 1
|
|
162
|
+
# @return [Color]
|
|
163
|
+
def with(l: self.l, c: self.c, h: self.h, alpha: self.alpha)
|
|
164
|
+
self.class.new(l: l, c: c, h: h, alpha: alpha)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The CSS serialization, matching the distributed themes' formatting:
|
|
168
|
+
# "oklch(0.577 0.245 27.325)" / "oklch(1 0 0 / 10%)".
|
|
169
|
+
#
|
|
170
|
+
# @return [String]
|
|
171
|
+
def css
|
|
172
|
+
base = [l, c, h].map { |v| format("%g", v) }.join(" ")
|
|
173
|
+
alpha < 1.0 ? "oklch(#{base} / #{format("%g", alpha * 100)}%)" : "oklch(#{base})"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Gamma-encoded sRGB components, each clamped to [0, 1].
|
|
177
|
+
#
|
|
178
|
+
# @return [Array<Float>] r, g, b
|
|
179
|
+
def srgb
|
|
180
|
+
@srgb ||= linear_srgb.map { |v| v <= 0.0031308 ? 12.92 * v : (1.055 * (v**(1.0 / 2.4))) - 0.055 }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Alpha-composite this color over an opaque background, the way a
|
|
184
|
+
# browser blends (per-channel, gamma-encoded). Returns a Blend.
|
|
185
|
+
#
|
|
186
|
+
# @param background [Color, Blend] the opaque color underneath
|
|
187
|
+
# (anything answering #srgb)
|
|
188
|
+
# @return [Blend]
|
|
189
|
+
def composite_over(background)
|
|
190
|
+
blended = srgb.zip(background.srgb).map { |fg, bg| (alpha * fg) + ((1.0 - alpha) * bg) }
|
|
191
|
+
Blend.new(blended)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# OKLCH -> OKLab -> linear sRGB (Ottosson's OKLab matrices), clamped to
|
|
197
|
+
# gamut. Clamping matches how out-of-gamut CSS colors rasterize.
|
|
198
|
+
def linear_srgb
|
|
199
|
+
a = c * Math.cos(h * Math::PI / 180.0)
|
|
200
|
+
b = c * Math.sin(h * Math::PI / 180.0)
|
|
201
|
+
|
|
202
|
+
l_ = (l + (0.3963377774 * a) + (0.2158037573 * b))**3
|
|
203
|
+
m_ = (l - (0.1055613458 * a) - (0.0638541728 * b))**3
|
|
204
|
+
s_ = (l - (0.0894841775 * a) - (1.2914855480 * b))**3
|
|
205
|
+
|
|
206
|
+
[
|
|
207
|
+
(+4.0767416621 * l_) - (3.3077115913 * m_) + (0.2309699292 * s_),
|
|
208
|
+
(-1.2684380046 * l_) + (2.6097574011 * m_) - (0.3413193965 * s_),
|
|
209
|
+
(-0.0041960863 * l_) - (0.7034186147 * m_) + (1.7076147010 * s_)
|
|
210
|
+
].map { |v| v.clamp(0.0, 1.0) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
class Tokens
|
|
6
|
+
# The AAA-contrast CI gate: every semantic
|
|
7
|
+
# text pair is asserted in BOTH modes against a locked ledger. WCAG 2.2
|
|
8
|
+
# AA (4.5:1) is the floor; AAA (7:1) is enforced wherever it was
|
|
9
|
+
# achievable at lock time. A pair may never regress below its locked
|
|
10
|
+
# class - AAA pairs must stay AAA, AA exceptions must stay >= AA - so
|
|
11
|
+
# any token change that degrades contrast fails the build.
|
|
12
|
+
#
|
|
13
|
+
# The ledger models *rendered* reality: dark destructive is gated as
|
|
14
|
+
# the theme actually paints it (bg-destructive/60 composited over the page
|
|
15
|
+
# background - 6.5:1); solid dark destructive under white text is 2.9:1
|
|
16
|
+
# and therefore a forbidden pattern, documented here.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# gate = Poetry::Core::Tokens::ContrastGate.new(Poetry::Core::Tokens.load)
|
|
20
|
+
# gate.violations # => [] when every locked pair still holds
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class ContrastGate
|
|
24
|
+
THRESHOLDS = { aaa: 7.0, aa: 4.5 }.freeze
|
|
25
|
+
|
|
26
|
+
# Locked against the shipped neutral theme (ratios at lock
|
|
27
|
+
# in comments). :fg / :bg name color tokens; fg: :white is the literal
|
|
28
|
+
# (destructive surfaces render text-white; white is not a
|
|
29
|
+
# token). :bg_alpha + :bg_over composite the bg before measuring.
|
|
30
|
+
LEDGER = {
|
|
31
|
+
"light" => [
|
|
32
|
+
{ fg: "foreground", bg: "background", lock: :aaa }, # 19.79
|
|
33
|
+
{ fg: "card-foreground", bg: "card", lock: :aaa }, # 19.79
|
|
34
|
+
{ fg: "popover-foreground", bg: "popover", lock: :aaa }, # 19.79
|
|
35
|
+
{ fg: "primary-foreground", bg: "primary", lock: :aaa }, # 17.16
|
|
36
|
+
{ fg: "secondary-foreground", bg: "secondary", lock: :aaa }, # 16.42
|
|
37
|
+
{ fg: "muted-foreground", bg: "muted", lock: :aa }, # 4.54 (parity delta)
|
|
38
|
+
{ fg: "muted-foreground", bg: "background", lock: :aa }, # 4.96
|
|
39
|
+
{ fg: "accent-foreground", bg: "accent", lock: :aaa }, # 16.42
|
|
40
|
+
{ fg: :white, bg: "destructive", lock: :aa }, # 4.76
|
|
41
|
+
{ fg: "sidebar-foreground", bg: "sidebar", lock: :aaa }, # 18.96
|
|
42
|
+
{ fg: "sidebar-primary-foreground", bg: "sidebar-primary", lock: :aaa }, # 17.16
|
|
43
|
+
{ fg: "sidebar-accent-foreground", bg: "sidebar-accent", lock: :aaa } # 16.42
|
|
44
|
+
].freeze,
|
|
45
|
+
"dark" => [
|
|
46
|
+
{ fg: "foreground", bg: "background", lock: :aaa }, # 18.96
|
|
47
|
+
{ fg: "card-foreground", bg: "card", lock: :aaa }, # 17.16
|
|
48
|
+
{ fg: "popover-foreground", bg: "popover", lock: :aaa }, # 17.16
|
|
49
|
+
{ fg: "primary-foreground", bg: "primary", lock: :aaa }, # 14.22
|
|
50
|
+
{ fg: "secondary-foreground", bg: "secondary", lock: :aaa }, # 14.48
|
|
51
|
+
{ fg: "muted-foreground", bg: "muted", lock: :aa }, # 5.83
|
|
52
|
+
{ fg: "muted-foreground", bg: "background", lock: :aaa }, # 7.63
|
|
53
|
+
{ fg: "accent-foreground", bg: "accent", lock: :aaa }, # 14.48
|
|
54
|
+
# Dark mode renders bg-destructive/60 over the page background;
|
|
55
|
+
# measure the composite (6.48). Solid dark destructive is 2.9:1 -
|
|
56
|
+
# never paint white text on it undiluted.
|
|
57
|
+
{ fg: :white, bg: "destructive", bg_alpha: 0.6, bg_over: "background", lock: :aa }, # 6.48
|
|
58
|
+
{ fg: "sidebar-foreground", bg: "sidebar", lock: :aaa }, # 17.16
|
|
59
|
+
{ fg: "sidebar-primary-foreground", bg: "sidebar-primary", lock: :aa }, # 6.54 (blue accent)
|
|
60
|
+
{ fg: "sidebar-accent-foreground", bg: "sidebar-accent", lock: :aaa } # 14.48
|
|
61
|
+
].freeze
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
Result = Struct.new(:mode, :label, :ratio, :lock, :pass) do
|
|
65
|
+
def to_s
|
|
66
|
+
"[#{mode}] #{label}: #{format("%.2f", ratio)}:1 " \
|
|
67
|
+
"(locked #{lock.to_s.upcase}, needs >= #{THRESHOLDS.fetch(lock)})"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(tokens)
|
|
72
|
+
@tokens = tokens
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Every ledger pair measured. The CI test asserts violations.empty?.
|
|
76
|
+
def results
|
|
77
|
+
LEDGER.flat_map do |mode, pairs|
|
|
78
|
+
pairs.map { |spec| measure(mode, spec) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def violations
|
|
83
|
+
results.reject(&:pass)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Completeness: every *-foreground token (plus the bare foreground)
|
|
87
|
+
# must be gated in every mode, so a newly added role can't silently
|
|
88
|
+
# ship ungated. Returns [mode, name] pairs that are missing.
|
|
89
|
+
def ungated_foregrounds
|
|
90
|
+
@tokens.modes.flat_map do |mode|
|
|
91
|
+
gated = LEDGER.fetch(mode, []).map { |spec| spec[:fg] }
|
|
92
|
+
@tokens.color_names(mode)
|
|
93
|
+
.select { |name| name == "foreground" || name.end_with?("-foreground") }
|
|
94
|
+
.reject { |name| gated.include?(name) }
|
|
95
|
+
.map { |name| [mode, name] }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def measure(mode, spec)
|
|
102
|
+
fg = spec[:fg] == :white ? Color::WHITE : @tokens.color(mode, spec[:fg])
|
|
103
|
+
bg = background_for(mode, spec)
|
|
104
|
+
ratio = fg.contrast_ratio(bg)
|
|
105
|
+
label = "#{spec[:fg] == :white ? "white" : spec[:fg]} on #{spec[:bg]}"
|
|
106
|
+
label += "/#{format("%g", spec[:bg_alpha] * 100)}% over #{spec[:bg_over]}" if spec[:bg_alpha]
|
|
107
|
+
Result.new(mode, label, ratio, spec[:lock], ratio >= THRESHOLDS.fetch(spec[:lock]))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def background_for(mode, spec)
|
|
111
|
+
base = @tokens.color(mode, spec[:bg])
|
|
112
|
+
return base unless spec[:bg_alpha]
|
|
113
|
+
|
|
114
|
+
translucent = Color.new(l: base.l, c: base.c, h: base.h, alpha: spec[:bg_alpha])
|
|
115
|
+
translucent.composite_over(@tokens.color(mode, spec[:bg_over]))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Core
|
|
7
|
+
class Tokens
|
|
8
|
+
# Generates every downstream token surface from tokens/tokens.dtcg.json
|
|
9
|
+
# (the single source of truth):
|
|
10
|
+
#
|
|
11
|
+
# tokens/tokens.css CSS custom properties (:root + .dark)
|
|
12
|
+
# tokens/tailwind-theme.css Tailwind v4 @theme inline mapping
|
|
13
|
+
# DESIGN.md front matter regenerated; prose body preserved
|
|
14
|
+
#
|
|
15
|
+
# `verify` powers the CI drift gate (rake tokens:verify): committed
|
|
16
|
+
# artifacts must byte-match a fresh generation, so the canonical JSON
|
|
17
|
+
# and the shipped CSS/DESIGN.md can never disagree.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# Poetry::Core::Tokens::Generator.new.verify # => [] when nothing drifted
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class Generator
|
|
24
|
+
HEADER = "Generated by `bin/rake tokens:generate` from tokens/tokens.dtcg.json - DO NOT EDIT BY HAND."
|
|
25
|
+
|
|
26
|
+
# Documented divergences from the reference v4 neutral theme the
|
|
27
|
+
# compat-var set tracks. Parity
|
|
28
|
+
# is the default; a delta is only taken when the locked AA floor
|
|
29
|
+
# demands it, and it must be recorded here.
|
|
30
|
+
PARITY_DELTAS = [
|
|
31
|
+
"light muted-foreground: oklch(0.545 0 0) vs the source's 0.556 - darkened to clear the locked WCAG AA " \
|
|
32
|
+
"floor (4.54:1 on muted, 4.96:1 on background; the source value is 4.34:1 on muted, sub-AA).",
|
|
33
|
+
"chart-1..5: the source's legacy blue base-color chart ramp (blue-300/500/600/700/800) in both " \
|
|
34
|
+
"modes - the neutral base ships a gray ramp (0.87 / 0.556 / 0.439 / 0.371 / 0.269) that cannot " \
|
|
35
|
+
"separate series.",
|
|
36
|
+
"success, warning, info: poetry additions - the source ships no status roles. success and info " \
|
|
37
|
+
"reuse source hues (a green base chart hue; the blue ramp's third stop); warning is the orange " \
|
|
38
|
+
"base's primary. All three are gated like every other role."
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# The reference theme's distributed radius scale (@theme inline), in the
|
|
42
|
+
# MULTIPLICATIVE form of its current distribution rather
|
|
43
|
+
# than the older additive one: at the default --radius (0.625rem =
|
|
44
|
+
# 10px) every sm..xl stop computes to the same px (6/8/10/14), so
|
|
45
|
+
# the form is pixel-neutral, and it brings the 2xl..4xl stops the
|
|
46
|
+
# style themes use (vega's rounded-2xl toast, rounded-4xl badge).
|
|
47
|
+
RADIUS_SCALE = {
|
|
48
|
+
"sm" => "calc(var(--radius) * 0.6)",
|
|
49
|
+
"md" => "calc(var(--radius) * 0.8)",
|
|
50
|
+
"lg" => "var(--radius)",
|
|
51
|
+
"xl" => "calc(var(--radius) * 1.4)",
|
|
52
|
+
"2xl" => "calc(var(--radius) * 1.8)",
|
|
53
|
+
"3xl" => "calc(var(--radius) * 2.2)",
|
|
54
|
+
"4xl" => "calc(var(--radius) * 2.6)"
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
ARTIFACTS = ["tokens/tokens.css", "tokens/tailwind-theme.css", "DESIGN.md"].freeze
|
|
58
|
+
|
|
59
|
+
def initialize(tokens: Tokens.load, root: Poetry::Core.root)
|
|
60
|
+
@tokens = tokens
|
|
61
|
+
@root = Pathname.new(root)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def tokens_css
|
|
65
|
+
# color-scheme travels with the mode blocks: UA scrollbars,
|
|
66
|
+
# form-control chrome, and canvas defaults follow the app's mode
|
|
67
|
+
# instead of the OS preference - without it, a dark-OS user in a
|
|
68
|
+
# light app gets dark scrollbars on light surfaces (the same
|
|
69
|
+
# split-brain the dark: variant remap below closes).
|
|
70
|
+
lines = ["/* #{HEADER} */", "", ":root {", " color-scheme: light;",
|
|
71
|
+
" --radius: #{@tokens.radius_css};"]
|
|
72
|
+
@tokens.color_names("light").each do |name|
|
|
73
|
+
lines << " --#{name}: #{@tokens.color("light", name).css};"
|
|
74
|
+
end
|
|
75
|
+
lines << "}" << "" << ".dark {" << " color-scheme: dark;"
|
|
76
|
+
@tokens.color_names("dark").each do |name|
|
|
77
|
+
lines << " --#{name}: #{@tokens.color("dark", name).css};"
|
|
78
|
+
end
|
|
79
|
+
lines << "}"
|
|
80
|
+
"#{lines.join("\n")}\n"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tailwind_theme_css
|
|
84
|
+
lines = ["/* #{HEADER} */", ""]
|
|
85
|
+
# poetry dark mode is the .dark CLASS convention. Without
|
|
86
|
+
# re-mapping Tailwind's dark: variant (whose default is the
|
|
87
|
+
# prefers-color-scheme media query), a dark-OS user in a light
|
|
88
|
+
# app gets dark: utilities firing while the .dark tokens do not.
|
|
89
|
+
# Travels with the theme mapping.
|
|
90
|
+
lines << "@custom-variant dark (&:where(.dark, .dark *));"
|
|
91
|
+
lines << ""
|
|
92
|
+
lines << "@theme inline {"
|
|
93
|
+
RADIUS_SCALE.each { |step, value| lines << " --radius-#{step}: #{value};" }
|
|
94
|
+
@tokens.color_names("light").each do |name|
|
|
95
|
+
lines << " --color-#{name}: var(--#{name});"
|
|
96
|
+
end
|
|
97
|
+
lines << "}"
|
|
98
|
+
"#{lines.join("\n")}\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def design_md
|
|
102
|
+
"#{front_matter}#{design_md_body}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Regenerates all artifacts on disk. Returns the written paths.
|
|
106
|
+
def generate!
|
|
107
|
+
artifact_contents.map do |relative, content|
|
|
108
|
+
path = @root.join(relative)
|
|
109
|
+
path.dirname.mkpath
|
|
110
|
+
path.write(content)
|
|
111
|
+
relative
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Relative paths whose on-disk content does not match a fresh
|
|
116
|
+
# generation (missing or stale). Empty means no drift.
|
|
117
|
+
def verify
|
|
118
|
+
artifact_contents.filter_map do |relative, content|
|
|
119
|
+
path = @root.join(relative)
|
|
120
|
+
relative unless path.exist? && path.read == content
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def artifact_contents
|
|
127
|
+
{
|
|
128
|
+
"tokens/tokens.css" => tokens_css,
|
|
129
|
+
"tokens/tailwind-theme.css" => tailwind_theme_css,
|
|
130
|
+
"DESIGN.md" => design_md
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The machine-readable half of DESIGN.md: exact tokens + the contrast
|
|
135
|
+
# policy, regenerated on every pass. The prose constitution below the
|
|
136
|
+
# front matter is human/agent-authored and preserved verbatim.
|
|
137
|
+
def front_matter
|
|
138
|
+
gate = ContrastGate.new(@tokens)
|
|
139
|
+
data = {
|
|
140
|
+
"design_system" => "poetry",
|
|
141
|
+
"source" => Tokens::DEFAULT_RELATIVE_PATH,
|
|
142
|
+
"generator" => "bin/rake tokens:generate",
|
|
143
|
+
"dark_mode" => "class .dark",
|
|
144
|
+
"radius" => @tokens.radius_css,
|
|
145
|
+
"radius_scale" => RADIUS_SCALE.transform_keys { |k| "radius-#{k}" },
|
|
146
|
+
"colors" => @tokens.modes.to_h do |mode|
|
|
147
|
+
[mode, @tokens.color_names(mode).to_h { |n| [n, @tokens.color(mode, n).css] }]
|
|
148
|
+
end,
|
|
149
|
+
"contrast_policy" => {
|
|
150
|
+
"floor" => "WCAG 2.2 AA (4.5:1) - locked, every gated pair",
|
|
151
|
+
"target" => "AAA (7:1) wherever achievable at lock time",
|
|
152
|
+
"gate" => "Poetry::Core::Tokens::ContrastGate (CI: rake test)",
|
|
153
|
+
"aa_exceptions" => gate.results.select { |r| r.lock == :aa }.map(&:to_s)
|
|
154
|
+
},
|
|
155
|
+
"parity" => {
|
|
156
|
+
"base" => "shadcn/ui v4 neutral (cssVarsV4 drop-in var set)",
|
|
157
|
+
"deltas" => PARITY_DELTAS
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
"#{YAML.dump(data)}---\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def design_md_body
|
|
164
|
+
existing = @root.join("DESIGN.md")
|
|
165
|
+
if existing.exist? && (body = existing.read[/\A---\n.*?\n---\n(.*)\z/m, 1])
|
|
166
|
+
body
|
|
167
|
+
else
|
|
168
|
+
STARTER_BODY
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
STARTER_BODY = <<~BODY
|
|
173
|
+
|
|
174
|
+
# DESIGN.md - the poetry design constitution
|
|
175
|
+
|
|
176
|
+
The front matter above is **generated** from `tokens/tokens.dtcg.json`
|
|
177
|
+
(`bin/rake tokens:generate`) and lists the exact tokens; this body is the
|
|
178
|
+
judgment layer - edit it freely, it is preserved across regenerations.
|
|
179
|
+
|
|
180
|
+
## Principles
|
|
181
|
+
|
|
182
|
+
- **Semantic roles only.** Components consume role tokens (`primary`,
|
|
183
|
+
`destructive`, `muted`, `accent`, ...) - never raw palette values, never
|
|
184
|
+
hex/oklch literals in markup (enforced by the class Verifier).
|
|
185
|
+
- **Both modes always.** Every surface must hold in light and dark; dark
|
|
186
|
+
mode is the `.dark` class convention, and the contrast gate
|
|
187
|
+
asserts every pair in both modes.
|
|
188
|
+
- **Contrast is law, not taste.** WCAG 2.2 AA is a locked floor; AAA is
|
|
189
|
+
enforced where achievable. Exceptions are explicit (front matter) and
|
|
190
|
+
reviewed - never silent.
|
|
191
|
+
- **Re-skin at the source.** Restyling the system means editing the DTCG
|
|
192
|
+
tokens (or dropping in a shadcn v4 theme over `tokens/tokens.css`) and
|
|
193
|
+
regenerating - one source, every surface.
|
|
194
|
+
|
|
195
|
+
## Do / Don't
|
|
196
|
+
|
|
197
|
+
- Do pick variants by intent (one `primary` action per view; `destructive`
|
|
198
|
+
only for irreversible actions).
|
|
199
|
+
- Do keep borders subtle (`border`/`input` are deliberately low-contrast;
|
|
200
|
+
they are not text and are not gated as text).
|
|
201
|
+
- Don't paint white text on solid `destructive` in dark mode - dark
|
|
202
|
+
destructive surfaces are rendered at 60% over the background (2.9:1
|
|
203
|
+
solid vs 6.5:1 composited).
|
|
204
|
+
- Don't introduce new colors, shadows, or radii without adding tokens
|
|
205
|
+
here first.
|
|
206
|
+
BODY
|
|
207
|
+
private_constant :STARTER_BODY
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Core
|
|
7
|
+
# The canonical design-token model. Loads tokens/tokens.dtcg.json -
|
|
8
|
+
# the single source of truth - and exposes the semantic color roles per
|
|
9
|
+
# mode plus the radius dimension. Everything else (tokens.css,
|
|
10
|
+
# tailwind-theme.css, the DESIGN.md front matter) is generated from an
|
|
11
|
+
# instance of this class; the AAA-contrast gate asserts against it.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# tokens = Poetry::Core::Tokens.load
|
|
15
|
+
# tokens.color("light", "primary").css # => "oklch(0.205 0 0)"
|
|
16
|
+
# tokens.radius_css # => "0.625rem"
|
|
17
|
+
class Tokens
|
|
18
|
+
# Where the canonical DTCG token file lives, relative to the gem root.
|
|
19
|
+
DEFAULT_RELATIVE_PATH = "tokens/tokens.dtcg.json"
|
|
20
|
+
|
|
21
|
+
# The exact CSS custom-property set of the widely-distributed v4
|
|
22
|
+
# theme convention (cssVarsV4, plus --radius). This is the drop-in
|
|
23
|
+
# contract: any theme block written to that convention defines exactly
|
|
24
|
+
# these names, so it can replace poetry's tokens.css wholesale.
|
|
25
|
+
SHADCN_V4_COMPAT_VARS = %w[
|
|
26
|
+
background foreground
|
|
27
|
+
card card-foreground
|
|
28
|
+
popover popover-foreground
|
|
29
|
+
primary primary-foreground
|
|
30
|
+
secondary secondary-foreground
|
|
31
|
+
muted muted-foreground
|
|
32
|
+
accent accent-foreground
|
|
33
|
+
destructive
|
|
34
|
+
border input ring
|
|
35
|
+
chart-1 chart-2 chart-3 chart-4 chart-5
|
|
36
|
+
sidebar sidebar-foreground
|
|
37
|
+
sidebar-primary sidebar-primary-foreground
|
|
38
|
+
sidebar-accent sidebar-accent-foreground
|
|
39
|
+
sidebar-border sidebar-ring
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# Poetry-original extensions BEYOND the compat set: the soft
|
|
43
|
+
# status vocabulary that set lacks. Kept separate so the
|
|
44
|
+
# drop-in contract stays sharp: a drop-in theme block replaces the
|
|
45
|
+
# compat set wholesale, and these keep their poetry defaults unless
|
|
46
|
+
# the theme chooses to override.
|
|
47
|
+
POETRY_STATUS_VARS = %w[success warning info].freeze
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# The gem's canonical token file path ({DEFAULT_RELATIVE_PATH}
|
|
51
|
+
# under the gem root).
|
|
52
|
+
#
|
|
53
|
+
# @return [Pathname]
|
|
54
|
+
def default_path
|
|
55
|
+
Poetry::Core.root.join(DEFAULT_RELATIVE_PATH)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Loads a DTCG token file into a Tokens instance.
|
|
59
|
+
#
|
|
60
|
+
# @param path [String, Pathname] a DTCG JSON file; defaults to the
|
|
61
|
+
# gem's canonical tokens
|
|
62
|
+
# @return [Tokens]
|
|
63
|
+
def load(path = default_path)
|
|
64
|
+
new(JSON.parse(File.read(path)))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
attr_reader :data
|
|
69
|
+
|
|
70
|
+
# Wraps one parsed DTCG document; {.load} is the file-backed form.
|
|
71
|
+
#
|
|
72
|
+
# @param data [Hash] the parsed DTCG JSON
|
|
73
|
+
def initialize(data)
|
|
74
|
+
@data = data
|
|
75
|
+
@colors = {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mode names ("light", "dark"), skipping DTCG $-metadata keys.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<String>]
|
|
81
|
+
def modes
|
|
82
|
+
data.fetch("color").keys.reject { |k| k.start_with?("$") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The semantic color-role names of one mode, in file order.
|
|
86
|
+
#
|
|
87
|
+
# @param mode [String] "light" or "dark"
|
|
88
|
+
# @return [Array<String>] role names ("primary", "muted-foreground", ...)
|
|
89
|
+
def color_names(mode)
|
|
90
|
+
data.fetch("color").fetch(mode).keys.reject { |k| k.start_with?("$") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The color of one semantic role in one mode.
|
|
94
|
+
#
|
|
95
|
+
# @param mode [String] "light" or "dark"
|
|
96
|
+
# @param name [String] the role name ("primary", "destructive", ...)
|
|
97
|
+
# @return [Color]
|
|
98
|
+
# @raise [KeyError] when the mode has no such role
|
|
99
|
+
def color(mode, name)
|
|
100
|
+
@colors[[mode, name]] ||= begin
|
|
101
|
+
token = data.fetch("color").fetch(mode).fetch(name) do
|
|
102
|
+
raise KeyError, "no #{mode} color token #{name.inspect}"
|
|
103
|
+
end
|
|
104
|
+
Color.from_dtcg(token.fetch("$value"))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The radius dimension as CSS ("0.625rem").
|
|
109
|
+
#
|
|
110
|
+
# @return [String]
|
|
111
|
+
def radius_css
|
|
112
|
+
value = data.fetch("dimension").fetch("radius").fetch("$value")
|
|
113
|
+
"#{format("%g", value.fetch("value"))}#{value.fetch("unit")}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|