poetry-extract 0.1.3 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed76ffcd49da0ac979971eeef72b4c0a97be11697ffa2ffe550c5ae48e1692ff
4
- data.tar.gz: fb3b471179c0e6c9cc02a483bc4079dbbd2a24589e94f63c11a0e82810ca22c3
3
+ metadata.gz: 5109279ef95e4b1b84d54d211de9c092e5ef5853fb543ec13aab67c438212a3c
4
+ data.tar.gz: 5e5518b6a7ceed15536bb7f6998d8c53ddadc6f023cce646118ccd76701e61be
5
5
  SHA512:
6
- metadata.gz: 39deed401d146e53a7af84c722aa8320ac0ecc27fcbffb25245026161300e0f9ad671a38035ad8fa0d49f7ac7766c35e076dcfba6be5dec9895034f00ab7a393
7
- data.tar.gz: cc808e8c66da94c0529b47a053c81627965ab977927917578860df3a3972ee407eaf6a0eda4764f5554ef1bdc03a52ae5fc7269b88422b4c6824496b27df65f0
6
+ metadata.gz: b1678f0845188edcc2a4e3ca02cd692e2ed883dd32cdd9bdee63fe7e3c5dfbdeffeb1994f06b293036b44a3b97a613b85fe6204e90ea0d93577272251c506b41
7
+ data.tar.gz: 05de37f2f6d66ad3693fd3d471c3f6e5ccfee7befb538d4b973e118383c0761881458f489ab8a9f08735d9e3ebea98de90064b211283b7e57a368872632763c6
data/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "rubydex": {
4
+ "command": "bundle",
5
+ "args": ["exec", "rdx", "mcp"]
6
+ }
7
+ }
8
+ }
data/.yard-lint.yml ADDED
@@ -0,0 +1,25 @@
1
+ # yard-lint: the documentation tier YARD's own gates miss - a documented
2
+ # method missing a @param, an @example that does not parse, option tags,
3
+ # tag order, invalid types. Runs as rake yard:lint (in the default task).
4
+ # UndocumentedObjects stays off: yard:coverage gates public objects at its
5
+ # recorded floor, and this validator counts the @api private internals the
6
+ # gems hide on purpose (--hide-api is not honoured here). MissingReturn is
7
+ # off until the @param backlog is cleared; revisit then.
8
+ AllValidators:
9
+ YardOptions:
10
+ - --no-private
11
+ Documentation/UndocumentedObjects:
12
+ Enabled: false
13
+ Documentation/MissingReturn:
14
+ Enabled: false
15
+ Tags/InvalidTypes:
16
+ Enabled: true
17
+ # A **options splat passed through to a control is documented as @param
18
+ # options [Hash], not one @option per key; and @example blocks here are
19
+ # ERB as often as Ruby, which this validator cannot parse.
20
+ Documentation/UndocumentedOptions:
21
+ Enabled: false
22
+ Tags/OptionTags:
23
+ Enabled: false
24
+ Tags/ExampleSyntax:
25
+ Enabled: false
@@ -0,0 +1 @@
1
+ 0
data/Archspec.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The architecture the family enforces by review, as checks (rake arch:check).
4
+ # extract depends on core alone.
5
+ root "."
6
+ source "lib/**/*.rb"
7
+
8
+ component :lib, in: "lib/**/*.rb"
9
+
10
+ lib.cannot_reference_constants "Poetry::Ui", "Poetry::Charts", "Poetry::Agent", "ApplicationController",
11
+ because: "extract depends on core alone and never names the host"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.5] - 2026-09-18
4
+
5
+ ### Changed
6
+
7
+ - 43 methods the reference already hid with `@api private` are Ruby-private now: each was called only by its own class or template, so the runtime enforces what the tag only stated. A host that reached one gets a NoMethodError instead of an internal that may change without notice. The tag remains on the internals the family shares between its gems and on whole internal classes.
8
+ - Every class, module and method carries a one-sentence description, private helpers included: `rake yard:coverage:all` measures the whole tree (a tag-only docstring counts as blank) and the committed floor now stands at zero.
9
+ - The palette builder reads its sources, chooses its roles, builds the chart series and writes the hash in separate steps; the oracle parity suite holds every output byte for byte, and two tests pin that a primary already on the right side of its canvas is left alone in the derived mode.
10
+
11
+ ## [0.1.4] - 2026-09-15
12
+
13
+ Lockstep release with the family; no changes in this gem.
14
+
3
15
  ## [0.1.3] - 2026-09-13
4
16
 
5
17
  Lockstep release with the family; no changes in this gem.
@@ -110,15 +110,12 @@ module Poetry
110
110
  end
111
111
 
112
112
  # A Messages API text content block.
113
- # @api private
114
113
  def text_block(text) = { type: "text", text: text }
115
114
 
116
115
  # A Messages API URL-source image content block.
117
- # @api private
118
116
  def image_block(url) = { type: "image", source: { type: "url", url: url } }
119
117
 
120
118
  # Join the response's text blocks into one string.
121
- # @api private
122
119
  def extract_text(response)
123
120
  Array(response["content"]).filter_map { |block| block["text"] if block["type"] == "text" }.join
124
121
  end
@@ -144,6 +141,8 @@ module Poetry
144
141
 
145
142
  JSON.parse(response.body)
146
143
  end
144
+
145
+ private_class_method :text_block, :image_block, :extract_text
147
146
  end
148
147
  end
149
148
  end
@@ -23,6 +23,12 @@ module Poetry
23
23
  module DeriveTokens
24
24
  WHITE = { r: 255.0, g: 255.0, b: 255.0 }.freeze
25
25
  BLACK = { r: 0.0, g: 0.0, b: 0.0 }.freeze
26
+ # The dark mode canvas.
27
+ DARK_BACKGROUND = { r: 23.0, g: 23.0, b: 21.0 }.freeze
28
+ # The light mode text color.
29
+ LIGHT_FOREGROUND = { r: 10.0, g: 10.0, b: 10.0 }.freeze
30
+ # The dark mode text color.
31
+ DARK_FOREGROUND = { r: 245.0, g: 245.0, b: 244.0 }.freeze
26
32
 
27
33
  SANS_FALLBACKS = %w[ui-sans-serif system-ui sans-serif].freeze
28
34
  SERIF_FALLBACKS = %w[ui-serif Georgia serif].freeze
@@ -48,6 +54,7 @@ module Poetry
48
54
  ].join("\n")
49
55
  end
50
56
 
57
+ # The vanilla CSS token sheet for a domain: a :root block and a .dark block from the brand and styleguide.
51
58
  def derive_css_variables(domain, brand = nil, styleguide = nil)
52
59
  root_body, dark_body = bodies(brand, styleguide)
53
60
  [
@@ -57,6 +64,7 @@ module Poetry
57
64
  ].join("\n")
58
65
  end
59
66
 
67
+ # The light and dark token bodies: each palette's lines plus the fonts, radius, shadows, tracking and spacing.
60
68
  def bodies(brand, styleguide)
61
69
  light = build_palette(brand, styleguide, "light")
62
70
  dark = build_palette(brand, styleguide, "dark")
@@ -73,6 +81,7 @@ module Poetry
73
81
 
74
82
  # --- Color utilities -------------------------------------------------
75
83
 
84
+ # An RGB hash from a hex color in three, six or eight digits; nil for anything else.
76
85
  def parse_hex(hex)
77
86
  return nil unless hex
78
87
 
@@ -88,17 +97,20 @@ module Poetry
88
97
  end
89
98
  end
90
99
 
100
+ # An RGB hash as a six-digit hex color.
91
101
  def to_hex(color)
92
102
  hx = ->(n) { format("%02x", n.round.clamp(0, 255)) }
93
103
  "##{hx.call(color[:r])}#{hx.call(color[:g])}#{hx.call(color[:b])}"
94
104
  end
95
105
 
106
+ # The linear mix of two RGB colors by an amount from zero to one.
96
107
  def mix(from, to, amount)
97
108
  { r: from[:r] + ((to[:r] - from[:r]) * amount),
98
109
  g: from[:g] + ((to[:g] - from[:g]) * amount),
99
110
  b: from[:b] + ((to[:b] - from[:b]) * amount) }
100
111
  end
101
112
 
113
+ # The relative luminance of an RGB color, per the WCAG formula.
102
114
  def luminance(color)
103
115
  f = lambda do |c|
104
116
  s = c / 255.0
@@ -107,10 +119,13 @@ module Poetry
107
119
  (0.2126 * f.call(color[:r])) + (0.7152 * f.call(color[:g])) + (0.0722 * f.call(color[:b]))
108
120
  end
109
121
 
122
+ # Whether a color is dark enough to want a light foreground.
110
123
  def dark?(color) = luminance(color) < 0.5
111
124
 
125
+ # The foreground that reads on a background: white on dark, near-black on light.
112
126
  def readable(background) = dark?(background) ? "#ffffff" : "#0a0a0a"
113
127
 
128
+ # A number held between min and max.
114
129
  def clamp(number, min, max) = number.clamp(min, max)
115
130
 
116
131
  # JS parseFloat: leading number or nil - "5.9%" => 5.9, "abc" => nil.
@@ -132,6 +147,7 @@ module Poetry
132
147
  js_to_fixed(number, decimals).sub(/\.?0+\z/, "")
133
148
  end
134
149
 
150
+ # An RGB hash as an hsl() token with one decimal on each channel.
135
151
  def rgb_to_hsl_token(color)
136
152
  rr = color[:r] / 255.0
137
153
  gg = color[:g] / 255.0
@@ -156,6 +172,7 @@ module Poetry
156
172
  "hsl(#{format_number(h, 1)} #{format_number(s * 100, 1)}% #{format_number(l * 100, 1)}%)"
157
173
  end
158
174
 
175
+ # A CSS color as an hsl() token: hex directly, hsl and rgb functions through their parsers; nil otherwise.
159
176
  def normalize_css_color(raw)
160
177
  return nil unless raw
161
178
 
@@ -166,6 +183,7 @@ module Poetry
166
183
  normalize_hsl(value) || normalize_rgb(value)
167
184
  end
168
185
 
186
+ # An hsl() or hsla() string as an hsl() token with its alpha, or nil when it does not parse.
169
187
  def normalize_hsl(value)
170
188
  m = value.match(/\Ahsla?\((.+)\)\z/i)
171
189
  return nil unless m
@@ -182,6 +200,7 @@ module Poetry
182
200
  opacity: alpha && clamp(alpha, 0, 1) }.compact
183
201
  end
184
202
 
203
+ # An rgb() or rgba() string as an hsl() token with its alpha, or nil when it does not parse.
185
204
  def normalize_rgb(value)
186
205
  m = value.match(/\Argba?\((.+)\)\z/i)
187
206
  return nil unless m
@@ -199,12 +218,14 @@ module Poetry
199
218
  opacity: alpha && clamp(alpha, 0, 1) }.compact
200
219
  end
201
220
 
221
+ # A color function's body split into its channels and the slash alpha, when one is written.
202
222
  def split_channels(body)
203
223
  body.include?("/") ? body.split("/", 2) : [body, nil]
204
224
  end
205
225
 
206
226
  # --- Length utilities ------------------------------------------------
207
227
 
228
+ # A length in pixels from a px, rem or em value; nil when it is not a length.
208
229
  def to_px(value)
209
230
  return nil unless value
210
231
 
@@ -216,18 +237,21 @@ module Poetry
216
237
  %w[rem em].include?(unit) ? n * 16 : n
217
238
  end
218
239
 
240
+ # The positive pixel lengths in a space-separated value.
219
241
  def to_px_list(value)
220
242
  return [] unless value
221
243
 
222
244
  value.to_s.strip.split(/\s+/).filter_map { |part| to_px(part) }.select(&:positive?)
223
245
  end
224
246
 
247
+ # A pixel length as a rem string with trailing zeros trimmed.
225
248
  def px_to_rem(pixels)
226
249
  "#{js_to_fixed(pixels / 16.0, 4).sub(/\.?0+\z/, "")}rem"
227
250
  end
228
251
 
229
252
  # --- Font utilities --------------------------------------------------
230
253
 
254
+ # A font family name quoted when it carries characters a bare CSS identifier cannot.
231
255
  def quote_if_needed(name)
232
256
  trimmed = name.strip.gsub(/\A["']|["']\z/, "")
233
257
  return trimmed if trimmed.empty?
@@ -236,6 +260,7 @@ module Poetry
236
260
  trimmed
237
261
  end
238
262
 
263
+ # A font stack from the primary family, its fallbacks and the generic family, deduplicated case-insensitively.
239
264
  def build_font_stack(primary, fallbacks, generic)
240
265
  seen = {}
241
266
  stack = []
@@ -255,6 +280,7 @@ module Poetry
255
280
  stack.join(", ")
256
281
  end
257
282
 
283
+ # A font family's class, sans, serif, mono or unknown, from the font links or the family name.
258
284
  def classify_family(family, font_links)
259
285
  return "unknown" unless family
260
286
 
@@ -274,6 +300,7 @@ module Poetry
274
300
  "unknown"
275
301
  end
276
302
 
303
+ # The first font link whose category reads as monospace, or nil.
277
304
  def find_mono_family(font_links)
278
305
  font_links&.each do |name, link|
279
306
  return name if link["category"]&.downcase&.include?("mono")
@@ -281,6 +308,7 @@ module Poetry
281
308
  nil
282
309
  end
283
310
 
311
+ # The first font link whose category reads as serif, skipping the excluded names, or nil.
284
312
  def find_serif_family(font_links, exclude)
285
313
  font_links&.each do |name, link|
286
314
  next if exclude.include?(name.downcase)
@@ -291,6 +319,7 @@ module Poetry
291
319
  nil
292
320
  end
293
321
 
322
+ # The sans, serif and mono stacks for a styleguide, from its body and heading fonts and its font links.
294
323
  def pick_fonts(styleguide)
295
324
  body = styleguide&.dig("typography", "p")
296
325
  h1 = styleguide&.dig("typography", "headings", "h1")
@@ -324,6 +353,7 @@ module Poetry
324
353
 
325
354
  # --- Radius / shadows / spacing --------------------------------------
326
355
 
356
+ # The radius token from a styleguide's card or button, clamped between two and sixteen pixels.
327
357
  def pick_radius(styleguide)
328
358
  card_radius = to_px(styleguide&.dig("components", "card", "borderRadius"))
329
359
  button_radius = to_px(styleguide&.dig("components", "button", "primary", "borderRadius"))
@@ -333,11 +363,13 @@ module Poetry
333
363
  px_to_rem(px.clamp(2, 16))
334
364
  end
335
365
 
366
+ # A length as a px token, or the fallback when it is not a length.
336
367
  def normalize_px_token(value, fallback)
337
368
  px = to_px(value)
338
369
  px.nil? ? fallback : "#{format_number(px, 2)}px"
339
370
  end
340
371
 
372
+ # A box-shadow value split into its comma-separated layers, commas inside functions kept.
341
373
  def split_shadow_layers(value)
342
374
  layers = []
343
375
  depth = 0
@@ -354,10 +386,12 @@ module Poetry
354
386
  layers.reject(&:empty?)
355
387
  end
356
388
 
389
+ # The color function or hex color inside one shadow layer, or nil.
357
390
  def find_color_snippet(layer)
358
391
  layer[/\b(?:rgba?|hsla?)\([^)]+\)/i] || layer[/#[0-9a-f]{3,8}\b/i]
359
392
  end
360
393
 
394
+ # The first layer of a box-shadow value as offsets, blur, spread, color and opacity; nil when unusable.
361
395
  def parse_box_shadow(value)
362
396
  return nil if !value || value.strip.downcase == "none"
363
397
 
@@ -379,6 +413,7 @@ module Poetry
379
413
  opacity: color && color[:opacity] }
380
414
  end
381
415
 
416
+ # An hsl() or rgb() token with the opacity written as its alpha.
382
417
  def color_with_opacity(color, opacity)
383
418
  alpha = format_number(clamp(opacity, 0, 1), 4)
384
419
  if (m = color.match(/\Ahsl\((.+)\)\z/i))
@@ -390,6 +425,7 @@ module Poetry
390
425
  end
391
426
  end
392
427
 
428
+ # The shadow color when the styleguide gives none: a foreground tint on light, near-black on dark.
393
429
  def fallback_shadow_color(palette, mode)
394
430
  bg = parse_hex(palette[:background])
395
431
  fg = parse_hex(palette[:foreground])
@@ -399,6 +435,7 @@ module Poetry
399
435
  fg ? rgb_to_hsl_token(fg) : "hsl(0 0% 5%)"
400
436
  end
401
437
 
438
+ # The shadow token set from one base shadow: the base parts and the size ramp at quiet, main and loud opacities.
402
439
  def build_shadow_tokens(base)
403
440
  half = base[:opacity] * 0.5
404
441
  heavy = [base[:opacity] * 2.5, 0.75].min
@@ -420,6 +457,7 @@ module Poetry
420
457
  shadow2xl: "#{first} #{loud}" }
421
458
  end
422
459
 
460
+ # The shadow tokens for a styleguide and palette, from the first parsable shadow among its candidates.
423
461
  def pick_shadows(styleguide, palette, mode)
424
462
  s = styleguide&.dig("shadows")
425
463
  candidates = [
@@ -440,6 +478,7 @@ module Poetry
440
478
  )
441
479
  end
442
480
 
481
+ # The median of a list of numbers, nil for an empty one.
443
482
  def median(values)
444
483
  return nil if values.empty?
445
484
 
@@ -450,6 +489,7 @@ module Poetry
450
489
  (sorted[mid - 1] + sorted[mid]) / 2.0
451
490
  end
452
491
 
492
+ # The spacing base unit implied by a styleguide's element spacing, or nil when none reads.
453
493
  def pick_spacing(styleguide)
454
494
  sp = styleguide&.dig("elementSpacing")
455
495
  divided = lambda do |value, divisor|
@@ -474,6 +514,7 @@ module Poetry
474
514
  px_to_rem(safe_unit)
475
515
  end
476
516
 
517
+ # The normal letter spacing as an em token, from the body or heading tracking.
477
518
  def pick_tracking_normal(styleguide)
478
519
  values = [styleguide&.dig("typography", "p", "letterSpacing"),
479
520
  styleguide&.dig("typography", "headings", "h1", "letterSpacing")]
@@ -491,120 +532,134 @@ module Poetry
491
532
 
492
533
  # --- Palette ---------------------------------------------------------
493
534
 
535
+ # The semantic palette for one mode from the brand colors and the
536
+ # styleguide: the source colors read once, the roles taken directly
537
+ # when the styleguide describes this mode and derived when it
538
+ # describes the other, then the palette written out as hex.
494
539
  def build_palette(brand, styleguide, mode)
495
- sg_mode = styleguide&.dig("mode")
496
- source_is_light = sg_mode != "dark"
540
+ sources = palette_sources(brand, styleguide)
541
+ source_is_light = styleguide&.dig("mode") != "dark"
497
542
  direct = mode == (source_is_light ? "light" : "dark")
543
+ roles = direct ? direct_roles(sources, mode) : derived_roles(sources, mode)
544
+ palette_hash(roles, mode, chart_series(roles, sources[:brand_colors]))
545
+ end
498
546
 
499
- brand_colors = (brand&.dig("colors") || []).filter_map { |c| parse_hex(c["hex"]) }
500
- named_brand_accent = parse_hex(
501
- (brand&.dig("colors") || []).find do |c|
502
- /accent|secondary|highlight|support/i.match?(c["name"] || "")
503
- end&.dig("hex")
504
- )
505
-
506
- sg_bg = parse_hex(styleguide&.dig("colors", "background"))
507
- sg_fg = parse_hex(styleguide&.dig("colors", "text"))
508
- sg_accent = parse_hex(styleguide&.dig("colors", "accent"))
509
-
510
- btn_primary = styleguide&.dig("components", "button", "primary")
511
- btn_secondary = styleguide&.dig("components", "button", "secondary")
512
- btn_link = styleguide&.dig("components", "button", "link")
547
+ # The colors the brand and the styleguide supply, parsed, with the
548
+ # source accent resolved: the styleguide's, else the link button's,
549
+ # else a brand color named as one, else the second brand color.
550
+ def palette_sources(brand, styleguide)
551
+ colors = brand&.dig("colors") || []
552
+ button = ->(kind, key) { parse_hex(styleguide&.dig("components", "button", kind, key)) }
513
553
  card = styleguide&.dig("components", "card")
514
-
515
- sg_primary_bg = parse_hex(btn_primary&.dig("backgroundColor"))
516
- sg_primary_fg = parse_hex(btn_primary&.dig("color"))
517
- sg_secondary_bg = parse_hex(btn_secondary&.dig("backgroundColor"))
518
- sg_secondary_fg = parse_hex(btn_secondary&.dig("color"))
519
- sg_link_accent = parse_hex(btn_link&.dig("backgroundColor") || btn_link&.dig("color"))
520
- sg_card_bg = parse_hex(card&.dig("backgroundColor"))
521
- sg_card_fg = parse_hex(card&.dig("textColor"))
522
- sg_card_border = parse_hex(card&.dig("borderColor"))
523
- sg_btn_border = parse_hex(btn_secondary&.dig("borderColor") || btn_primary&.dig("borderColor"))
524
- source_accent = sg_accent || sg_link_accent || named_brand_accent || brand_colors[1]
525
-
526
- if direct
527
- background = sg_bg || (mode == "light" ? WHITE : { r: 23.0, g: 23.0, b: 21.0 })
528
- foreground = sg_fg || (mode == "light" ? { r: 10.0, g: 10.0, b: 10.0 } : { r: 245.0, g: 245.0, b: 244.0 })
529
- primary = sg_primary_bg || brand_colors[0] || source_accent || foreground
530
- primary_foreground = sg_primary_bg ? sg_primary_fg : nil
531
- accent = source_accent || primary
532
- secondary = sg_secondary_bg ||
533
- (source_accent ? mix(background, source_accent, 0.16) : nil) ||
534
- mix(background, primary, 0.18)
535
- secondary_foreground = sg_secondary_bg ? sg_secondary_fg : nil
536
- card_bg = sg_card_bg || mix(background, foreground, 0.03)
537
- card_fg = sg_card_fg
538
- border = sg_card_border || sg_btn_border
539
- else
540
- if mode == "light"
541
- background = WHITE
542
- foreground = { r: 10.0, g: 10.0, b: 10.0 }
543
- else
544
- background = { r: 23.0, g: 23.0, b: 21.0 }
545
- foreground = { r: 245.0, g: 245.0, b: 244.0 }
546
- end
547
- base_primary = sg_primary_bg || brand_colors[0] || source_accent || foreground
548
- primary = if mode == "dark" && dark?(base_primary) then mix(base_primary, WHITE, 0.3)
549
- elsif mode == "light" && !dark?(base_primary) then mix(base_primary, BLACK, 0.15)
550
- else base_primary
551
- end
552
- primary_foreground = sg_primary_bg ? sg_primary_fg : nil
553
- base_accent = source_accent || primary
554
- accent = if mode == "dark" && dark?(base_accent) then mix(base_accent, WHITE, 0.25)
555
- elsif mode == "light" && !dark?(base_accent) then mix(base_accent, BLACK, 0.12)
556
- else base_accent
557
- end
558
- secondary = sg_secondary_bg || mix(background, accent, mode == "light" ? 0.16 : 0.24)
559
- secondary_foreground = sg_secondary_bg ? sg_secondary_fg : nil
560
- card_bg = mix(background, foreground, mode == "light" ? 0.03 : 0.07)
561
- card_fg = nil
562
- border = nil
554
+ named = colors.find { |c| /accent|secondary|highlight|support/i.match?(c["name"] || "") }
555
+ brand_colors = colors.filter_map { |c| parse_hex(c["hex"]) }
556
+ { brand_colors: brand_colors,
557
+ bg: parse_hex(styleguide&.dig("colors", "background")), fg: parse_hex(styleguide&.dig("colors", "text")),
558
+ primary_bg: button.call("primary", "backgroundColor"), primary_fg: button.call("primary", "color"),
559
+ secondary_bg: button.call("secondary", "backgroundColor"), secondary_fg: button.call("secondary", "color"),
560
+ card_bg: parse_hex(card&.dig("backgroundColor")), card_fg: parse_hex(card&.dig("textColor")),
561
+ card_border: parse_hex(card&.dig("borderColor")),
562
+ button_border: button.call("secondary", "borderColor") || button.call("primary", "borderColor"),
563
+ accent: parse_hex(styleguide&.dig("colors", "accent")) ||
564
+ button.call("link", "backgroundColor") || button.call("link", "color") ||
565
+ parse_hex(named&.dig("hex")) || brand_colors[1] }
566
+ end
567
+
568
+ # The mode's default canvas.
569
+ def mode_background(mode) = mode == "light" ? WHITE : DARK_BACKGROUND
570
+
571
+ # The mode's default text color.
572
+ def mode_foreground(mode) = mode == "light" ? LIGHT_FOREGROUND : DARK_FOREGROUND
573
+
574
+ # The roles when the styleguide describes this very mode: what it
575
+ # says, the gaps filled from the brand and by mixing.
576
+ def direct_roles(sources, mode)
577
+ background = sources[:bg] || mode_background(mode)
578
+ foreground = sources[:fg] || mode_foreground(mode)
579
+ primary = sources[:primary_bg] || sources[:brand_colors][0] || sources[:accent] || foreground
580
+ secondary = sources[:secondary_bg] ||
581
+ (sources[:accent] ? mix(background, sources[:accent], 0.16) : nil) ||
582
+ mix(background, primary, 0.18)
583
+ { background: background, foreground: foreground, primary: primary,
584
+ primary_foreground: sources[:primary_bg] ? sources[:primary_fg] : nil,
585
+ accent: sources[:accent] || primary, secondary: secondary,
586
+ secondary_foreground: sources[:secondary_bg] ? sources[:secondary_fg] : nil,
587
+ card_bg: sources[:card_bg] || mix(background, foreground, 0.03), card_fg: sources[:card_fg],
588
+ border: sources[:card_border] || sources[:button_border] }
589
+ end
590
+
591
+ # The roles when the styleguide describes the other mode: this mode's
592
+ # own canvas, the primary and accent pulled toward it, the rest mixed.
593
+ def derived_roles(sources, mode)
594
+ background = mode_background(mode)
595
+ foreground = mode_foreground(mode)
596
+ base_primary = sources[:primary_bg] || sources[:brand_colors][0] || sources[:accent] || foreground
597
+ primary = toward_mode(base_primary, mode, 0.3, 0.15)
598
+ accent = toward_mode(sources[:accent] || primary, mode, 0.25, 0.12)
599
+ { background: background, foreground: foreground, primary: primary,
600
+ primary_foreground: sources[:primary_bg] ? sources[:primary_fg] : nil,
601
+ accent: accent,
602
+ secondary: sources[:secondary_bg] || mix(background, accent, mode == "light" ? 0.16 : 0.24),
603
+ secondary_foreground: sources[:secondary_bg] ? sources[:secondary_fg] : nil,
604
+ card_bg: mix(background, foreground, mode == "light" ? 0.03 : 0.07), card_fg: nil, border: nil }
605
+ end
606
+
607
+ # A color pulled toward the mode's canvas when it would sink into it:
608
+ # lightened on a dark mode when it is dark, darkened on a light mode
609
+ # when it is light, else as it is.
610
+ def toward_mode(color, mode, dark_share, light_share)
611
+ if mode == "dark" && dark?(color) then mix(color, WHITE, dark_share)
612
+ elsif mode == "light" && !dark?(color) then mix(color, BLACK, light_share)
613
+ else color
563
614
  end
615
+ end
564
616
 
565
- muted = mix(background, foreground, mode == "light" ? 0.05 : 0.1)
566
- muted_fg = mix(foreground, background, 0.4)
567
- final_border = border || mix(background, foreground, mode == "light" ? 0.12 : 0.2)
568
- sidebar = mix(background, foreground, mode == "light" ? 0.04 : 0.05)
569
-
570
- chart_candidates = [accent, primary, secondary] + brand_colors
617
+ # Five chart colors: the accent, primary, secondary and brand colors
618
+ # without repeats, padded with tints and shades of the primary.
619
+ def chart_series(roles, brand_colors)
571
620
  seen = {}
572
- unique_chart = chart_candidates.select do |color|
621
+ unique = ([roles[:accent], roles[:primary], roles[:secondary]] + brand_colors).select do |color|
573
622
  hex = to_hex(color)
574
623
  next false if seen[hex]
575
624
 
576
625
  seen[hex] = true
577
626
  end
578
- chart = [
579
- unique_chart[0] || primary,
580
- unique_chart[1] || mix(primary, WHITE, 0.3),
581
- unique_chart[2] || mix(primary, BLACK, 0.25),
582
- unique_chart[3] || mix(primary, WHITE, 0.55),
583
- unique_chart[4] || mix(primary, BLACK, 0.45)
584
- ]
585
-
627
+ primary = roles[:primary]
628
+ [unique[0] || primary, unique[1] || mix(primary, WHITE, 0.3), unique[2] || mix(primary, BLACK, 0.25),
629
+ unique[3] || mix(primary, WHITE, 0.55), unique[4] || mix(primary, BLACK, 0.45)]
630
+ end
631
+
632
+ # The palette as hex: the roles, the muted, border and sidebar tones
633
+ # mixed from the canvas, and the readable foregrounds.
634
+ def palette_hash(roles, mode, chart)
635
+ background, foreground, primary, accent, secondary =
636
+ roles.values_at(:background, :foreground, :primary, :accent, :secondary)
637
+ light = mode == "light"
638
+ border = roles[:border] || mix(background, foreground, light ? 0.12 : 0.2)
639
+ card_fg = roles[:card_fg] || foreground
640
+ primary_fg = roles[:primary_foreground] ? to_hex(roles[:primary_foreground]) : readable(primary)
641
+ secondary_fg = roles[:secondary_foreground] ? to_hex(roles[:secondary_foreground]) : readable(secondary)
586
642
  { background: to_hex(background), foreground: to_hex(foreground),
587
- card: to_hex(card_bg), card_foreground: to_hex(card_fg || foreground),
588
- popover: to_hex(card_bg), popover_foreground: to_hex(card_fg || foreground),
589
- primary: to_hex(primary),
590
- primary_foreground: primary_foreground ? to_hex(primary_foreground) : readable(primary),
643
+ card: to_hex(roles[:card_bg]), card_foreground: to_hex(card_fg),
644
+ popover: to_hex(roles[:card_bg]), popover_foreground: to_hex(card_fg),
645
+ primary: to_hex(primary), primary_foreground: primary_fg,
591
646
  secondary: to_hex(secondary),
592
- secondary_foreground: secondary_foreground ? to_hex(secondary_foreground) : readable(secondary),
593
- muted: to_hex(muted), muted_foreground: to_hex(muted_fg),
647
+ secondary_foreground: secondary_fg,
648
+ muted: to_hex(mix(background, foreground, light ? 0.05 : 0.1)),
649
+ muted_foreground: to_hex(mix(foreground, background, 0.4)),
594
650
  accent: to_hex(accent), accent_foreground: readable(accent),
595
- destructive: mode == "light" ? "#dc2626" : "#ef4444",
596
- destructive_foreground: "#ffffff",
597
- border: to_hex(final_border), input: to_hex(final_border), ring: to_hex(accent),
651
+ destructive: light ? "#dc2626" : "#ef4444", destructive_foreground: "#ffffff",
652
+ border: to_hex(border), input: to_hex(border), ring: to_hex(accent),
598
653
  chart: chart.map { |color| to_hex(color) },
599
- sidebar: to_hex(sidebar), sidebar_foreground: to_hex(foreground),
600
- sidebar_primary: to_hex(primary),
601
- sidebar_primary_foreground: primary_foreground ? to_hex(primary_foreground) : readable(primary),
654
+ sidebar: to_hex(mix(background, foreground, light ? 0.04 : 0.05)), sidebar_foreground: to_hex(foreground),
655
+ sidebar_primary: to_hex(primary), sidebar_primary_foreground: primary_fg,
602
656
  sidebar_accent: to_hex(accent), sidebar_accent_foreground: readable(accent),
603
- sidebar_border: to_hex(final_border), sidebar_ring: to_hex(accent) }
657
+ sidebar_border: to_hex(border), sidebar_ring: to_hex(accent) }
604
658
  end
605
659
 
606
660
  # --- Output formatting -----------------------------------------------
607
661
 
662
+ # A palette as CSS custom property declarations.
608
663
  def palette_lines(palette)
609
664
  ["--background: #{palette[:background]};", "--foreground: #{palette[:foreground]};",
610
665
  "--card: #{palette[:card]};", "--card-foreground: #{palette[:card_foreground]};",
@@ -625,6 +680,7 @@ module Poetry
625
680
  "--sidebar-border: #{palette[:sidebar_border]};", "--sidebar-ring: #{palette[:sidebar_ring]};"]
626
681
  end
627
682
 
683
+ # The font, radius, shadow, tracking and spacing declarations.
628
684
  def non_color_lines(fonts, radius, shadows, tracking_normal = nil, spacing = nil)
629
685
  lines = ["--font-sans: #{fonts[:sans]};", "--font-serif: #{fonts[:serif]};",
630
686
  "--font-mono: #{fonts[:mono]};", "--radius: #{radius};",
@@ -640,10 +696,12 @@ module Poetry
640
696
  lines
641
697
  end
642
698
 
699
+ # The lines indented by two spaces and joined.
643
700
  def indent(lines)
644
701
  lines.map { |line| " #{line}" }.join("\n")
645
702
  end
646
703
 
704
+ # The inline theme block mapping the color, font and radius tokens onto the framework's names.
647
705
  def theme_inline_block
648
706
  pairs = %w[background foreground card card-foreground popover popover-foreground
649
707
  primary primary-foreground secondary secondary-foreground muted
@@ -663,6 +721,7 @@ module Poetry
663
721
  "@theme inline {\n#{indent(lines)}\n}"
664
722
  end
665
723
 
724
+ # The base layer applying the border, outline, background and foreground defaults.
666
725
  def layer_base
667
726
  <<~CSS.strip
668
727
  @layer base {
@@ -675,6 +734,17 @@ module Poetry
675
734
  }
676
735
  CSS
677
736
  end
737
+
738
+ private_class_method :bodies, :parse_hex, :to_hex, :mix, :readable, :js_float, :js_to_fixed, :format_number
739
+ private_class_method :rgb_to_hsl_token, :normalize_css_color, :normalize_hsl, :normalize_rgb, :split_channels
740
+ private_class_method :to_px, :to_px_list, :px_to_rem, :quote_if_needed, :build_font_stack, :classify_family
741
+ private_class_method :find_mono_family, :find_serif_family, :pick_fonts, :pick_radius, :normalize_px_token
742
+ private_class_method :split_shadow_layers, :find_color_snippet, :parse_box_shadow, :color_with_opacity
743
+ private_class_method :fallback_shadow_color, :build_shadow_tokens, :pick_shadows, :pick_spacing
744
+ private_class_method :pick_tracking_normal, :build_palette, :palette_lines, :non_color_lines
745
+ private_class_method :palette_sources, :mode_background, :mode_foreground, :direct_roles, :derived_roles
746
+ private_class_method :toward_mode, :chart_series, :palette_hash
747
+ private_class_method :theme_inline_block, :layer_base
678
748
  end
679
749
  end
680
750
  end
@@ -49,7 +49,6 @@ module Poetry
49
49
  end
50
50
 
51
51
  # The no-client Signals shape: homepage markdown, nothing else.
52
- # @api private
53
52
  def degraded(domain, homepage_fetcher)
54
53
  Signals.new(styleguide: nil, brand: nil, screenshot_url: nil,
55
54
  markdown: homepage_fetcher.call(domain).to_s)
@@ -66,7 +65,6 @@ module Poetry
66
65
 
67
66
  # SDK responses expose the payload as an accessor; plain-hash fakes
68
67
  # (tests, cassettes) work identically.
69
- # @api private
70
68
  def value_of(response, key)
71
69
  return response[key.to_s] || response[key] if response.is_a?(Hash)
72
70
 
@@ -97,6 +95,8 @@ module Poetry
97
95
  rescue StandardError
98
96
  ""
99
97
  end
98
+
99
+ private_class_method :degraded, :value_of
100
100
  end
101
101
  end
102
102
  end
@@ -53,6 +53,7 @@ module Poetry
53
53
 
54
54
  # Reduce a URL or bare domain to its lowercase host.
55
55
  #
56
+ # @param domain [String] a URL or a bare host
56
57
  # @return [String]
57
58
  def normalize_domain(domain)
58
59
  domain.to_s.strip.downcase
@@ -62,6 +63,9 @@ module Poetry
62
63
  # The composed document must at least parse as DESIGN.md; a missing
63
64
  # frontmatter is a warning, not a failure - prose-only documents
64
65
  # still carry the body sections, and import will say so again.
66
+ #
67
+ # @param design_md [String] the composed DESIGN.md document
68
+ # @param io [IO] where warnings are printed
65
69
  def validate!(design_md, io)
66
70
  parsed = Poetry::Core::DesignMd.parse(design_md)
67
71
  io.puts "note: composed DESIGN.md has no frontmatter tokens" unless parsed.is_a?(Hash) && parsed.any?
@@ -3,6 +3,6 @@
3
3
  module Poetry
4
4
  module Extract
5
5
  # The gem version.
6
- VERSION = "0.1.3"
6
+ VERSION = "0.1.5"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poetry-extract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Solt
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.3
18
+ version: 0.1.5
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.1.3
25
+ version: 0.1.5
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: context.dev
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -47,6 +47,10 @@ executables: []
47
47
  extensions: []
48
48
  extra_rdoc_files: []
49
49
  files:
50
+ - ".mcp.json"
51
+ - ".yard-lint.yml"
52
+ - ".yard_coverage_all"
53
+ - Archspec.rb
50
54
  - CHANGELOG.md
51
55
  - LICENSE.txt
52
56
  - README.md
@@ -83,7 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
87
  - !ruby/object:Gem::Version
84
88
  version: '0'
85
89
  requirements: []
86
- rubygems_version: 4.0.16
90
+ rubygems_version: 4.0.20
87
91
  specification_version: 4
88
92
  summary: 'Domain in, theme out: extract a DESIGN.md + design tokens from any public
89
93
  website.'