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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The gem version. Every gem in the family carries the same version
6
+ # and pins poetry-core to it exactly.
7
+ VERSION = "0.0.2"
8
+ end
9
+ end