poetry-extract 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 81f7c38854dbd7cbd6dcb70d7a04e1edef055558e8eebf3b074f46fba8f0f20a
4
+ data.tar.gz: 657b0b3f020a67383a0c80f1b27c4fc3a7a047da0d85cfb712b4bffd90a7ed8a
5
+ SHA512:
6
+ metadata.gz: be7f609ae99f3f048947b6c940f4a5520396dd88530ed6f26ab3fb4190520ed218a1d3bffc077db918d1a4d0fc722bbc42e3242f6ef87adb36d62310e0fe242b
7
+ data.tar.gz: fd2c15f0b5cdaae8d2c2838b062a316ca1324bb2e108aedacf837f878c4e1f84bb76391990ba38808deb60118d568f6653e1f3899eaf3a96e6dda5ae8267745d
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Matt Solt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # poetry-extract
2
+
3
+ Domain in, theme out. The optional design-extraction gem for
4
+ [poetry](https://github.com/roboruby/poetry): point it at a public website and get back a
5
+ DESIGN.md document plus deterministic design tokens, ready for poetry's
6
+ AA-gated theme importer.
7
+
8
+ ```bash
9
+ bin/rails "poetry:design:extract[stripe.com]"
10
+ # wrote tmp/poetry/design_extract/stripe.com/{DESIGN.md, theme.tailwind.css, tokens.css}
11
+ # next: bin/rails "poetry:design:import[tmp/poetry/design_extract/stripe.com/DESIGN.md]"
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ gem "poetry-extract"
19
+ ```
20
+
21
+ The fetch step calls [context.dev](https://context.dev) (`CONTEXT_DEV_API_KEY`) and the compose step calls Anthropic (`ANTHROPIC_API_KEY`); set both in the environment before running the task.
22
+
23
+ ## How it works
24
+
25
+ 1. **Fetch** — the site's styleguide, brand colors, a screenshot, and the
26
+ homepage as markdown, via [context.dev](https://context.dev)
27
+ (`CONTEXT_DEV_API_KEY`). Without a key it degrades to a plain homepage
28
+ fetch: less evidence, same pipeline.
29
+ 2. **Compose** — one Claude call (`ANTHROPIC_API_KEY`) writes the
30
+ DESIGN.md prose, screenshot as vision input. Prose only: no token that
31
+ restyles anything comes from the model.
32
+ 3. **Derive** — the token stylesheets (Tailwind v4 `@theme` + CSS `:root`)
33
+ are computed deterministically from the fetched styleguide/brand: same
34
+ inputs, same bytes, every run. This is a parity-gated port of agentcn's
35
+ `derive-tokens` (MIT — see THIRD_PARTY_NOTICES.md).
36
+ 4. **Handoff** — nothing touches your theme. The printed
37
+ `poetry:design:import` invocation runs poetry's existing importer,
38
+ which drops any swatch failing WCAG AA.
39
+
40
+ ## License
41
+
42
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
43
+ Portions adapted from agentcn (MIT) — see `THIRD_PARTY_NOTICES.md`.
@@ -0,0 +1,35 @@
1
+ # Third-party notices - poetry-extract
2
+
3
+ ## agentcn (extract-design-md recipe)
4
+
5
+ - Source: https://github.com/shadcn-labs/agentcn (MIT) - the `extract-design-md`
6
+ recipe, itself "verbatim from designmd-supply"
7
+ - Adapted: `lib/poetry/extract/derive_tokens.rb` is a function-for-function
8
+ Ruby port of the recipe's `derive-tokens.ts`; `lib/poetry/extract/compose.rb`
9
+ adapts its `design-md.ts` prompt spec and `compose.ts` orchestration shape.
10
+ The upstream `derive-tokens.ts` is vendored as the parity ORACLE at
11
+ `test/fixtures/oracle/` (test-only, not packaged) with its license alongside.
12
+
13
+ ```
14
+ MIT License
15
+
16
+ Copyright (c) 2026 Shadcn Labs
17
+
18
+ Permission is hereby granted, free of charge, to any person obtaining a copy
19
+ of this software and associated documentation files (the "Software"), to deal
20
+ in the Software without restriction, including without limitation the rights
21
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
22
+ copies of the Software, and to permit persons to whom the Software is
23
+ furnished to do so, subject to the following conditions:
24
+
25
+ The above copyright notice and this permission notice shall be included in all
26
+ copies or substantial portions of the Software.
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
34
+ SOFTWARE.
35
+ ```
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Poetry
8
+ module Extract
9
+ # The one model call: compose DESIGN.md from the fetched signals. The
10
+ # prompt spec is adapted from an MIT-licensed source (source and
11
+ # license in THIRD_PARTY_NOTICES.md). Direct Anthropic Messages API
12
+ # over Net::HTTP - no SDK dependency; the screenshot rides as a
13
+ # URL-source image block.
14
+ module Compose
15
+ # The Messages API endpoint the compose call POSTs to.
16
+ ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
17
+
18
+ # The pinned anthropic-version header value.
19
+ ANTHROPIC_VERSION = "2023-06-01"
20
+
21
+ # The model used when POETRY_EXTRACT_MODEL is unset.
22
+ DEFAULT_MODEL = "claude-sonnet-5"
23
+
24
+ # The DESIGN.md format summary embedded in every prompt.
25
+ SPEC_SUMMARY = <<~SPEC.strip
26
+ DESIGN.md is a self-contained plain-text representation of a design system. It contains optional YAML frontmatter with normative machine-readable tokens and a Markdown body with human-readable rationale.
27
+
28
+ YAML frontmatter:
29
+ - Must begin and end with a line containing exactly ---
30
+ - Common top-level keys: version, name, description, colors, typography, rounded, spacing, components
31
+ - Color values must be SRGB hex strings beginning with #
32
+ - Typography tokens may include fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, fontFeature, and fontVariation
33
+ - Dimension units may be px, em, or rem; unitless lineHeight is allowed
34
+ - Token references use {path.to.token}; component tokens may reference composite values
35
+
36
+ Markdown body sections should appear in this order when relevant:
37
+ 1. Overview
38
+ 2. Colors
39
+ 3. Typography
40
+ 4. Layout
41
+ 5. Elevation & Depth
42
+ 6. Shapes
43
+ 7. Components
44
+ 8. Do's and Don'ts
45
+
46
+ Recommended token names include colors primary, secondary, tertiary, neutral, surface, on-surface, error; typography headline-display, headline-lg, headline-md, body-lg, body-md, body-sm, label-lg, label-md, label-sm; rounded none, sm, md, lg, xl, full.
47
+ SPEC
48
+
49
+ # The system prompt for the compose call.
50
+ SYSTEM = "You are a senior design systems writer. Produce concise, implementation-grade " \
51
+ "DESIGN.md files that follow the requested spec."
52
+
53
+ module_function
54
+
55
+ # Compose the DESIGN.md document for a domain from its fetched
56
+ # signals in one model call; the screenshot, when present, is
57
+ # attached as an image block.
58
+ #
59
+ # @param domain [String] bare host the document is written for
60
+ # @param signals [Signals] the fetched evidence (nil members allowed)
61
+ # @param http [#call] the transport: request payload hash in, parsed
62
+ # response hash out
63
+ # @return [String] the composed DESIGN.md content
64
+ def design_md(domain:, signals:, http: method(:post_anthropic))
65
+ prompt = build_prompt(domain: domain, signals: signals)
66
+ content = [text_block(prompt)]
67
+ content.unshift(image_block(signals.screenshot_url)) if signals.screenshot_url
68
+
69
+ response = http.call(
70
+ model: ENV.fetch("POETRY_EXTRACT_MODEL", DEFAULT_MODEL),
71
+ max_tokens: 4096,
72
+ temperature: 0.2,
73
+ system: SYSTEM,
74
+ messages: [{ role: "user", content: content }]
75
+ )
76
+ extract_text(response).strip
77
+ end
78
+
79
+ # Assemble the user prompt from the spec summary and the signals.
80
+ # @api private
81
+ def build_prompt(domain:, signals:)
82
+ markdown_excerpt = signals.markdown.to_s.empty? ? "No Markdown returned." : signals.markdown[0, 3000]
83
+ styleguide_json = JSON.pretty_generate(signals.styleguide)[0, 18_000]
84
+
85
+ <<~PROMPT.strip
86
+ Generate a polished DESIGN.md document for #{domain}.
87
+
88
+ Follow this Google DESIGN.md specification summary:
89
+ #{SPEC_SUMMARY}
90
+
91
+ Use the Context.dev extracted styleguide as the primary source of design tokens. Use the screenshot URL and Markdown page content as supporting evidence for tone, component usage, and layout guidance.
92
+
93
+ Requirements:
94
+ - Return only DESIGN.md content, no commentary before or after it.
95
+ - Include YAML frontmatter with version: alpha, name, description, colors, typography, rounded, spacing, and components.
96
+ - Include the Markdown sections in the specified order.
97
+ - Prefer precise values present in the Context.dev payload.
98
+ - If data is missing, infer conservatively and state uncertainty in prose, not in token values.
99
+ - Make Do's and Don'ts concrete enough for another AI agent to use.
100
+
101
+ Context.dev styleguide JSON:
102
+ #{styleguide_json}
103
+
104
+ Screenshot URL:
105
+ #{signals.screenshot_url || "No screenshot returned."}
106
+
107
+ Homepage Markdown excerpt:
108
+ #{markdown_excerpt}
109
+ PROMPT
110
+ end
111
+
112
+ # A Messages API text content block.
113
+ # @api private
114
+ def text_block(text) = { type: "text", text: text }
115
+
116
+ # A Messages API URL-source image content block.
117
+ # @api private
118
+ def image_block(url) = { type: "image", source: { type: "url", url: url } }
119
+
120
+ # Join the response's text blocks into one string.
121
+ # @api private
122
+ def extract_text(response)
123
+ Array(response["content"]).filter_map { |block| block["text"] if block["type"] == "text" }.join
124
+ end
125
+
126
+ # The default transport: POST the payload to the Messages API and
127
+ # parse the JSON response; a non-200 raises Error.
128
+ # @api private
129
+ def post_anthropic(payload)
130
+ key = ENV.fetch("ANTHROPIC_API_KEY") do
131
+ raise Error, "ANTHROPIC_API_KEY is required to compose DESIGN.md"
132
+ end
133
+ uri = URI(ANTHROPIC_URL)
134
+ request = Net::HTTP::Post.new(uri)
135
+ request["x-api-key"] = key
136
+ request["anthropic-version"] = ANTHROPIC_VERSION
137
+ request["content-type"] = "application/json"
138
+ request.body = JSON.generate(payload)
139
+
140
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |net|
141
+ net.request(request)
142
+ end
143
+ raise Error, "Anthropic API error #{response.code}: #{response.body[0, 500]}" unless response.code == "200"
144
+
145
+ JSON.parse(response.body)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,680 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal/util"
4
+
5
+ module Poetry
6
+ module Extract
7
+ # Deterministic token derivation - a function-for-function Ruby port of
8
+ # an MIT-licensed token deriver (source and license in
9
+ # THIRD_PARTY_NOTICES.md). Inputs are the context.dev styleguide
10
+ # and brand payloads as string-keyed hashes (parsed JSON); outputs are
11
+ # the Tailwind v4 theme stylesheet and the vanilla CSS :root variables.
12
+ #
13
+ # Parity doctrine: the ported source is the ORACLE - test/fixtures/oracle
14
+ # runs it on canned inputs and this port must reproduce its outputs
15
+ # byte-for-byte, so behavior must match it bit-for-bit. The source's JS
16
+ # semantics are mirrored deliberately (parseFloat leniency via js_float,
17
+ # toFixed formatting via format_number); do not "clean up" behavior here
18
+ # without regenerating the fixtures oracle-first.
19
+ #
20
+ # Every helper in here is that parity-port math; the gem's public
21
+ # surface is Runner.run!.
22
+ # @api private
23
+ module DeriveTokens
24
+ WHITE = { r: 255.0, g: 255.0, b: 255.0 }.freeze
25
+ BLACK = { r: 0.0, g: 0.0, b: 0.0 }.freeze
26
+
27
+ SANS_FALLBACKS = %w[ui-sans-serif system-ui sans-serif].freeze
28
+ SERIF_FALLBACKS = %w[ui-serif Georgia serif].freeze
29
+ MONO_FALLBACKS = %w[ui-monospace SFMono-Regular Menlo monospace].freeze
30
+
31
+ MIN_SPACING_PX = 3.5
32
+ DEFAULT_SPACING_PX = 4
33
+ MAX_SPACING_PX = 5
34
+
35
+ module_function
36
+
37
+ # --- Public API ------------------------------------------------------
38
+
39
+ def derive_tailwind_theme(_domain, brand = nil, styleguide = nil)
40
+ root_body, dark_body = bodies(brand, styleguide)
41
+ [
42
+ %(@import "tailwindcss";), "",
43
+ "@custom-variant dark (&:is(.dark *));", "",
44
+ ":root {", indent(root_body), "}", "",
45
+ ".dark {", indent(dark_body), "}", "",
46
+ theme_inline_block, "",
47
+ layer_base, ""
48
+ ].join("\n")
49
+ end
50
+
51
+ def derive_css_variables(domain, brand = nil, styleguide = nil)
52
+ root_body, dark_body = bodies(brand, styleguide)
53
+ [
54
+ "/* #{domain} — design tokens (vanilla CSS) */",
55
+ ":root {", indent(root_body), "}", "",
56
+ ".dark {", indent(dark_body), "}", ""
57
+ ].join("\n")
58
+ end
59
+
60
+ def bodies(brand, styleguide)
61
+ light = build_palette(brand, styleguide, "light")
62
+ dark = build_palette(brand, styleguide, "dark")
63
+ fonts = pick_fonts(styleguide)
64
+ radius = pick_radius(styleguide)
65
+ light_shadows = pick_shadows(styleguide, light, "light")
66
+ dark_shadows = pick_shadows(styleguide, dark, "dark")
67
+ spacing = pick_spacing(styleguide)
68
+ tracking = pick_tracking_normal(styleguide)
69
+
70
+ [palette_lines(light) + non_color_lines(fonts, radius, light_shadows, tracking, spacing),
71
+ palette_lines(dark) + non_color_lines(fonts, radius, dark_shadows)]
72
+ end
73
+
74
+ # --- Color utilities -------------------------------------------------
75
+
76
+ def parse_hex(hex)
77
+ return nil unless hex
78
+
79
+ clean = hex.strip.delete_prefix("#")
80
+ return nil unless /\A(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\z/i.match?(clean)
81
+
82
+ if clean.length == 3
83
+ { r: Integer(clean[0] * 2, 16).to_f, g: Integer(clean[1] * 2, 16).to_f,
84
+ b: Integer(clean[2] * 2, 16).to_f }
85
+ else
86
+ { r: Integer(clean[0, 2], 16).to_f, g: Integer(clean[2, 2], 16).to_f,
87
+ b: Integer(clean[4, 2], 16).to_f }
88
+ end
89
+ end
90
+
91
+ def to_hex(color)
92
+ hx = ->(n) { format("%02x", n.round.clamp(0, 255)) }
93
+ "##{hx.call(color[:r])}#{hx.call(color[:g])}#{hx.call(color[:b])}"
94
+ end
95
+
96
+ def mix(from, to, amount)
97
+ { r: from[:r] + ((to[:r] - from[:r]) * amount),
98
+ g: from[:g] + ((to[:g] - from[:g]) * amount),
99
+ b: from[:b] + ((to[:b] - from[:b]) * amount) }
100
+ end
101
+
102
+ def luminance(color)
103
+ f = lambda do |c|
104
+ s = c / 255.0
105
+ s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055)**2.4
106
+ end
107
+ (0.2126 * f.call(color[:r])) + (0.7152 * f.call(color[:g])) + (0.0722 * f.call(color[:b]))
108
+ end
109
+
110
+ def dark?(color) = luminance(color) < 0.5
111
+
112
+ def readable(background) = dark?(background) ? "#ffffff" : "#0a0a0a"
113
+
114
+ def clamp(number, min, max) = number.clamp(min, max)
115
+
116
+ # JS parseFloat: leading number or nil - "5.9%" => 5.9, "abc" => nil.
117
+ def js_float(value)
118
+ m = value.to_s.strip.match(/\A[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i)
119
+ m && Float(m[0])
120
+ end
121
+
122
+ # JS Number#toFixed: ties on the exact binary value round HALF AWAY
123
+ # FROM ZERO (V8: (-0.03125).toFixed(4) => "-0.0313"), where C sprintf
124
+ # rounds half to even ("-0.0312") - BigDecimal over the float's exact
125
+ # expansion mirrors JS. The parity fixtures caught this.
126
+ def js_to_fixed(number, decimals)
127
+ format("%.#{decimals}f", number.to_f.to_d.round(decimals, half: :up))
128
+ end
129
+
130
+ # JS toFixed + trailing-zero strip: 12.0 => "12", 0.18 => "0.18".
131
+ def format_number(number, decimals = 4)
132
+ js_to_fixed(number, decimals).sub(/\.?0+\z/, "")
133
+ end
134
+
135
+ def rgb_to_hsl_token(color)
136
+ rr = color[:r] / 255.0
137
+ gg = color[:g] / 255.0
138
+ bb = color[:b] / 255.0
139
+ max = [rr, gg, bb].max
140
+ min = [rr, gg, bb].min
141
+ delta = max - min
142
+
143
+ h = 0.0
144
+ if delta != 0
145
+ h = if max == rr then ((gg - bb) / delta) % 6
146
+ elsif max == gg then ((bb - rr) / delta) + 2
147
+ else ((rr - gg) / delta) + 4
148
+ end
149
+ h *= 60
150
+ h += 360 if h.negative?
151
+ end
152
+
153
+ l = (max + min) / 2
154
+ s = delta.zero? ? 0.0 : delta / (1 - ((2 * l) - 1).abs)
155
+
156
+ "hsl(#{format_number(h, 1)} #{format_number(s * 100, 1)}% #{format_number(l * 100, 1)}%)"
157
+ end
158
+
159
+ def normalize_css_color(raw)
160
+ return nil unless raw
161
+
162
+ value = raw.strip
163
+ hex = parse_hex(value)
164
+ return { color: rgb_to_hsl_token(hex) } if hex
165
+
166
+ normalize_hsl(value) || normalize_rgb(value)
167
+ end
168
+
169
+ def normalize_hsl(value)
170
+ m = value.match(/\Ahsla?\((.+)\)\z/i)
171
+ return nil unless m
172
+
173
+ channels, alpha_part = split_channels(m[1].strip)
174
+ parts = channels.split(/[,\s]+/).map(&:strip).reject(&:empty?)
175
+ return nil if parts.length < 3
176
+
177
+ h, s, l = parts[0, 3].map { |part| js_float(part) }
178
+ alpha = alpha_part ? js_float(alpha_part) : (parts[3] && js_float(parts[3]))
179
+ return nil unless [h, s, l].all?
180
+
181
+ { color: "hsl(#{format_number(h, 1)} #{format_number(s, 1)}% #{format_number(l, 1)}%)",
182
+ opacity: alpha && clamp(alpha, 0, 1) }.compact
183
+ end
184
+
185
+ def normalize_rgb(value)
186
+ m = value.match(/\Argba?\((.+)\)\z/i)
187
+ return nil unless m
188
+
189
+ channels, alpha_part = split_channels(m[1].strip)
190
+ parts = channels.split(/[,\s]+/).map(&:strip).reject(&:empty?)
191
+ return nil if parts.length < 3
192
+
193
+ to_channel = ->(part) { (n = js_float(part)) && (part.end_with?("%") ? n / 100 * 255 : n) }
194
+ r, g, b = parts[0, 3].map { |part| to_channel.call(part) }
195
+ alpha = alpha_part ? js_float(alpha_part) : (parts[3] && js_float(parts[3]))
196
+ return nil unless [r, g, b].all?
197
+
198
+ { color: rgb_to_hsl_token({ r: r, g: g, b: b }),
199
+ opacity: alpha && clamp(alpha, 0, 1) }.compact
200
+ end
201
+
202
+ def split_channels(body)
203
+ body.include?("/") ? body.split("/", 2) : [body, nil]
204
+ end
205
+
206
+ # --- Length utilities ------------------------------------------------
207
+
208
+ def to_px(value)
209
+ return nil unless value
210
+
211
+ m = value.to_s.strip.match(/\A(-?\d*\.?\d+)\s*(px|rem|em)?\z/i)
212
+ return nil unless m
213
+
214
+ n = Float(m[1])
215
+ unit = (m[2] || "px").downcase
216
+ %w[rem em].include?(unit) ? n * 16 : n
217
+ end
218
+
219
+ def to_px_list(value)
220
+ return [] unless value
221
+
222
+ value.to_s.strip.split(/\s+/).filter_map { |part| to_px(part) }.select(&:positive?)
223
+ end
224
+
225
+ def px_to_rem(pixels)
226
+ "#{js_to_fixed(pixels / 16.0, 4).sub(/\.?0+\z/, "")}rem"
227
+ end
228
+
229
+ # --- Font utilities --------------------------------------------------
230
+
231
+ def quote_if_needed(name)
232
+ trimmed = name.strip.gsub(/\A["']|["']\z/, "")
233
+ return trimmed if trimmed.empty?
234
+ return %("#{trimmed}") if /[^a-zA-Z0-9_-]/.match?(trimmed) && !/\A[a-z-]+\z/.match?(trimmed)
235
+
236
+ trimmed
237
+ end
238
+
239
+ def build_font_stack(primary, fallbacks, generic)
240
+ seen = {}
241
+ stack = []
242
+ push = lambda do |name|
243
+ next unless name
244
+
245
+ trimmed = name.strip.gsub(/\A["']|["']\z/, "")
246
+ next if trimmed.empty?
247
+ next if seen[trimmed.downcase]
248
+
249
+ seen[trimmed.downcase] = true
250
+ stack << quote_if_needed(trimmed)
251
+ end
252
+ push.call(primary)
253
+ (fallbacks || []).each(&push)
254
+ generic.each(&push)
255
+ stack.join(", ")
256
+ end
257
+
258
+ def classify_family(family, font_links)
259
+ return "unknown" unless family
260
+
261
+ first = family.split(",")[0]&.strip&.gsub(/\A["']|["']\z/, "")
262
+ category = first && font_links&.dig(first, "category")
263
+ if category
264
+ cat = category.downcase
265
+ return "mono" if cat.include?("mono")
266
+ return "serif" if cat.include?("serif") && !cat.include?("sans")
267
+ return "sans" if cat.include?("sans") || cat.include?("display") || cat.include?("hand")
268
+ end
269
+ stripped = family.downcase.gsub("sans-serif", "")
270
+ return "mono" if /\b(monospace|mono)\b/.match?(family.downcase)
271
+ return "serif" if /\bserif\b/.match?(stripped)
272
+ return "sans" if family.downcase.include?("sans-serif")
273
+
274
+ "unknown"
275
+ end
276
+
277
+ def find_mono_family(font_links)
278
+ font_links&.each do |name, link|
279
+ return name if link["category"]&.downcase&.include?("mono")
280
+ end
281
+ nil
282
+ end
283
+
284
+ def find_serif_family(font_links, exclude)
285
+ font_links&.each do |name, link|
286
+ next if exclude.include?(name.downcase)
287
+
288
+ cat = link["category"]&.downcase || ""
289
+ return name if cat.include?("serif") && !cat.include?("sans")
290
+ end
291
+ nil
292
+ end
293
+
294
+ def pick_fonts(styleguide)
295
+ body = styleguide&.dig("typography", "p")
296
+ h1 = styleguide&.dig("typography", "headings", "h1")
297
+ links = styleguide&.dig("fontLinks")
298
+
299
+ sans_family = body&.dig("fontFamily") || h1&.dig("fontFamily")
300
+ sans_fallbacks = body&.dig("fontFallbacks") || h1&.dig("fontFallbacks")
301
+ sans = build_font_stack(sans_family, sans_fallbacks, SANS_FALLBACKS)
302
+
303
+ used = {}
304
+ used[sans_family.downcase] = true if sans_family
305
+
306
+ serif = nil
307
+ [h1, styleguide&.dig("typography", "headings", "h2"),
308
+ styleguide&.dig("typography", "headings", "h3")].each do |head|
309
+ fam = head&.dig("fontFamily")
310
+ next if !fam || used[fam.downcase]
311
+ next unless classify_family(fam, links) == "serif"
312
+
313
+ serif = build_font_stack(fam, head["fontFallbacks"], SERIF_FALLBACKS)
314
+ break
315
+ end
316
+ if serif.nil? && (from_links = find_serif_family(links, used.keys))
317
+ serif = build_font_stack(from_links, nil, SERIF_FALLBACKS)
318
+ end
319
+
320
+ mono = build_font_stack(find_mono_family(links), nil, MONO_FALLBACKS)
321
+
322
+ { sans: sans, serif: serif || build_font_stack(nil, nil, SERIF_FALLBACKS), mono: mono }
323
+ end
324
+
325
+ # --- Radius / shadows / spacing --------------------------------------
326
+
327
+ def pick_radius(styleguide)
328
+ card_radius = to_px(styleguide&.dig("components", "card", "borderRadius"))
329
+ button_radius = to_px(styleguide&.dig("components", "button", "primary", "borderRadius"))
330
+ px = card_radius || button_radius
331
+ return "0.5rem" if px.nil?
332
+
333
+ px_to_rem(px.clamp(2, 16))
334
+ end
335
+
336
+ def normalize_px_token(value, fallback)
337
+ px = to_px(value)
338
+ px.nil? ? fallback : "#{format_number(px, 2)}px"
339
+ end
340
+
341
+ def split_shadow_layers(value)
342
+ layers = []
343
+ depth = 0
344
+ start = 0
345
+ value.each_char.with_index do |ch, i|
346
+ depth += 1 if ch == "("
347
+ depth = [0, depth - 1].max if ch == ")"
348
+ if ch == "," && depth.zero?
349
+ layers << value[start...i].strip
350
+ start = i + 1
351
+ end
352
+ end
353
+ layers << value[start..].strip
354
+ layers.reject(&:empty?)
355
+ end
356
+
357
+ def find_color_snippet(layer)
358
+ layer[/\b(?:rgba?|hsla?)\([^)]+\)/i] || layer[/#[0-9a-f]{3,8}\b/i]
359
+ end
360
+
361
+ def parse_box_shadow(value)
362
+ return nil if !value || value.strip.downcase == "none"
363
+
364
+ layer = split_shadow_layers(value)[0]
365
+ return nil if layer.nil? || layer.empty?
366
+
367
+ color_snippet = find_color_snippet(layer)
368
+ color = normalize_css_color(color_snippet)
369
+ without_color = color_snippet ? layer.sub(color_snippet, " ") : layer
370
+ lengths = without_color.gsub(/\binset\b/i, " ").split(/\s+/)
371
+ .map(&:strip).select { |part| to_px(part) }
372
+ return nil if lengths.length < 2
373
+
374
+ { x: normalize_px_token(lengths[0], "0px"),
375
+ y: normalize_px_token(lengths[1], "2px"),
376
+ blur: normalize_px_token(lengths[2], "3px"),
377
+ spread: normalize_px_token(lengths[3], "0px"),
378
+ color: color && color[:color],
379
+ opacity: color && color[:opacity] }
380
+ end
381
+
382
+ def color_with_opacity(color, opacity)
383
+ alpha = format_number(clamp(opacity, 0, 1), 4)
384
+ if (m = color.match(/\Ahsl\((.+)\)\z/i))
385
+ "hsl(#{m[1]} / #{alpha})"
386
+ elsif (m = color.match(/\Argb\((.+)\)\z/i))
387
+ "rgb(#{m[1]} / #{alpha})"
388
+ else
389
+ color
390
+ end
391
+ end
392
+
393
+ def fallback_shadow_color(palette, mode)
394
+ bg = parse_hex(palette[:background])
395
+ fg = parse_hex(palette[:foreground])
396
+ return "hsl(0 0% 5%)" if mode == "dark"
397
+ return rgb_to_hsl_token(mix(fg, bg, 0.15)) if bg && fg
398
+
399
+ fg ? rgb_to_hsl_token(fg) : "hsl(0 0% 5%)"
400
+ end
401
+
402
+ def build_shadow_tokens(base)
403
+ half = base[:opacity] * 0.5
404
+ heavy = [base[:opacity] * 2.5, 0.75].min
405
+ main = color_with_opacity(base[:color], base[:opacity])
406
+ quiet = color_with_opacity(base[:color], half)
407
+ loud = color_with_opacity(base[:color], heavy)
408
+ first = "#{base[:x]} #{base[:y]} #{base[:blur]} #{base[:spread]}"
409
+ second = ->(y, blur, spread) { "#{base[:x]} #{y} #{blur} #{spread}" }
410
+
411
+ { x: base[:x], y: base[:y], blur: base[:blur], spread: base[:spread],
412
+ opacity: format_number(base[:opacity], 4), color: base[:color],
413
+ shadow2xs: "#{first} #{quiet}",
414
+ xs: "#{first} #{quiet}",
415
+ sm: "#{first} #{main}, #{second.call("1px", "2px", "-1px")} #{main}",
416
+ base: "#{first} #{main}, #{second.call("1px", "2px", "-1px")} #{main}",
417
+ md: "#{first} #{main}, #{second.call("2px", "4px", "-1px")} #{main}",
418
+ lg: "#{first} #{main}, #{second.call("4px", "6px", "-1px")} #{main}",
419
+ xl: "#{first} #{main}, #{second.call("8px", "10px", "-1px")} #{main}",
420
+ shadow2xl: "#{first} #{loud}" }
421
+ end
422
+
423
+ def pick_shadows(styleguide, palette, mode)
424
+ s = styleguide&.dig("shadows")
425
+ candidates = [
426
+ s&.dig("md"),
427
+ styleguide&.dig("components", "card", "boxShadow"),
428
+ styleguide&.dig("components", "button", "primary", "boxShadow"),
429
+ s&.dig("sm"), s&.dig("lg"), s&.dig("xl")
430
+ ]
431
+ parsed = candidates.lazy.map { |candidate| parse_box_shadow(candidate) }.find(&:itself)
432
+
433
+ build_shadow_tokens(
434
+ x: parsed&.dig(:x) || "0px",
435
+ y: parsed&.dig(:y) || "2px",
436
+ blur: parsed&.dig(:blur) || "3px",
437
+ spread: parsed&.dig(:spread) || "0px",
438
+ color: parsed&.dig(:color) || fallback_shadow_color(palette, mode),
439
+ opacity: parsed&.dig(:opacity) || 0.18
440
+ )
441
+ end
442
+
443
+ def median(values)
444
+ return nil if values.empty?
445
+
446
+ sorted = values.sort
447
+ mid = sorted.length / 2
448
+ return sorted[mid] if sorted.length.odd?
449
+
450
+ (sorted[mid - 1] + sorted[mid]) / 2.0
451
+ end
452
+
453
+ def pick_spacing(styleguide)
454
+ sp = styleguide&.dig("elementSpacing")
455
+ divided = lambda do |value, divisor|
456
+ px = to_px(value)
457
+ px && !px.zero? ? px / divisor : nil
458
+ end
459
+ scale_candidates = [
460
+ to_px(sp&.dig("xs")),
461
+ divided.call(sp&.dig("sm"), 2), divided.call(sp&.dig("md"), 4),
462
+ divided.call(sp&.dig("lg"), 6), divided.call(sp&.dig("xl"), 8)
463
+ ].compact.select(&:positive?)
464
+
465
+ component_padding = [
466
+ styleguide&.dig("components", "button", "primary", "padding"),
467
+ styleguide&.dig("components", "button", "secondary", "padding"),
468
+ styleguide&.dig("components", "card", "padding")
469
+ ].flat_map { |value| to_px_list(value) }
470
+ padding_candidates = component_padding.map { |value| value / 4.0 }
471
+
472
+ picked = median(scale_candidates + padding_candidates) || DEFAULT_SPACING_PX
473
+ safe_unit = clamp((picked * 2).round / 2.0, MIN_SPACING_PX, MAX_SPACING_PX)
474
+ px_to_rem(safe_unit)
475
+ end
476
+
477
+ def pick_tracking_normal(styleguide)
478
+ values = [styleguide&.dig("typography", "p", "letterSpacing"),
479
+ styleguide&.dig("typography", "headings", "h1", "letterSpacing")]
480
+ value = values.find { |v| v && v.strip.downcase != "normal" }
481
+ return "0em" unless value
482
+
483
+ trimmed = value.strip
484
+ m = trimmed.match(/\A(-?\d*\.?\d+)\s*(px|rem|em)?\z/i)
485
+ return trimmed unless m
486
+
487
+ n = Float(m[1])
488
+ unit = (m[2] || "em").downcase
489
+ unit == "px" ? "#{format_number(n / 16, 4)}em" : "#{format_number(n, 4)}em"
490
+ end
491
+
492
+ # --- Palette ---------------------------------------------------------
493
+
494
+ def build_palette(brand, styleguide, mode)
495
+ sg_mode = styleguide&.dig("mode")
496
+ source_is_light = sg_mode != "dark"
497
+ direct = mode == (source_is_light ? "light" : "dark")
498
+
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")
513
+ 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
563
+ end
564
+
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
571
+ seen = {}
572
+ unique_chart = chart_candidates.select do |color|
573
+ hex = to_hex(color)
574
+ next false if seen[hex]
575
+
576
+ seen[hex] = true
577
+ 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
+
586
+ { 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),
591
+ 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),
594
+ 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),
598
+ 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),
602
+ sidebar_accent: to_hex(accent), sidebar_accent_foreground: readable(accent),
603
+ sidebar_border: to_hex(final_border), sidebar_ring: to_hex(accent) }
604
+ end
605
+
606
+ # --- Output formatting -----------------------------------------------
607
+
608
+ def palette_lines(palette)
609
+ ["--background: #{palette[:background]};", "--foreground: #{palette[:foreground]};",
610
+ "--card: #{palette[:card]};", "--card-foreground: #{palette[:card_foreground]};",
611
+ "--popover: #{palette[:popover]};", "--popover-foreground: #{palette[:popover_foreground]};",
612
+ "--primary: #{palette[:primary]};", "--primary-foreground: #{palette[:primary_foreground]};",
613
+ "--secondary: #{palette[:secondary]};", "--secondary-foreground: #{palette[:secondary_foreground]};",
614
+ "--muted: #{palette[:muted]};", "--muted-foreground: #{palette[:muted_foreground]};",
615
+ "--accent: #{palette[:accent]};", "--accent-foreground: #{palette[:accent_foreground]};",
616
+ "--destructive: #{palette[:destructive]};", "--destructive-foreground: #{palette[:destructive_foreground]};",
617
+ "--border: #{palette[:border]};", "--input: #{palette[:input]};", "--ring: #{palette[:ring]};",
618
+ "--chart-1: #{palette[:chart][0]};", "--chart-2: #{palette[:chart][1]};", "--chart-3: #{palette[:chart][2]};",
619
+ "--chart-4: #{palette[:chart][3]};", "--chart-5: #{palette[:chart][4]};",
620
+ "--sidebar: #{palette[:sidebar]};", "--sidebar-foreground: #{palette[:sidebar_foreground]};",
621
+ "--sidebar-primary: #{palette[:sidebar_primary]};",
622
+ "--sidebar-primary-foreground: #{palette[:sidebar_primary_foreground]};",
623
+ "--sidebar-accent: #{palette[:sidebar_accent]};",
624
+ "--sidebar-accent-foreground: #{palette[:sidebar_accent_foreground]};",
625
+ "--sidebar-border: #{palette[:sidebar_border]};", "--sidebar-ring: #{palette[:sidebar_ring]};"]
626
+ end
627
+
628
+ def non_color_lines(fonts, radius, shadows, tracking_normal = nil, spacing = nil)
629
+ lines = ["--font-sans: #{fonts[:sans]};", "--font-serif: #{fonts[:serif]};",
630
+ "--font-mono: #{fonts[:mono]};", "--radius: #{radius};",
631
+ "--shadow-x: #{shadows[:x]};", "--shadow-y: #{shadows[:y]};",
632
+ "--shadow-blur: #{shadows[:blur]};", "--shadow-spread: #{shadows[:spread]};",
633
+ "--shadow-opacity: #{shadows[:opacity]};", "--shadow-color: #{shadows[:color]};",
634
+ "--shadow-2xs: #{shadows[:shadow2xs]};", "--shadow-xs: #{shadows[:xs]};",
635
+ "--shadow-sm: #{shadows[:sm]};", "--shadow: #{shadows[:base]};",
636
+ "--shadow-md: #{shadows[:md]};", "--shadow-lg: #{shadows[:lg]};",
637
+ "--shadow-xl: #{shadows[:xl]};", "--shadow-2xl: #{shadows[:shadow2xl]};"]
638
+ lines << "--tracking-normal: #{tracking_normal};" if tracking_normal
639
+ lines << "--spacing: #{spacing};" if spacing
640
+ lines
641
+ end
642
+
643
+ def indent(lines)
644
+ lines.map { |line| " #{line}" }.join("\n")
645
+ end
646
+
647
+ def theme_inline_block
648
+ pairs = %w[background foreground card card-foreground popover popover-foreground
649
+ primary primary-foreground secondary secondary-foreground muted
650
+ muted-foreground accent accent-foreground destructive
651
+ destructive-foreground border input ring chart-1 chart-2 chart-3
652
+ chart-4 chart-5 sidebar sidebar-foreground sidebar-primary
653
+ sidebar-primary-foreground sidebar-accent sidebar-accent-foreground
654
+ sidebar-border sidebar-ring]
655
+ lines = pairs.map { |name| "--color-#{name}: var(--#{name});" }
656
+ lines += ["", "--font-sans: var(--font-sans);", "--font-mono: var(--font-mono);",
657
+ "--font-serif: var(--font-serif);", "",
658
+ "--radius-sm: calc(var(--radius) - 4px);", "--radius-md: calc(var(--radius) - 2px);",
659
+ "--radius-lg: var(--radius);", "--radius-xl: calc(var(--radius) + 4px);", ""]
660
+ lines += %w[2xs xs sm].map { |size| "--shadow-#{size}: var(--shadow-#{size});" }
661
+ lines << "--shadow: var(--shadow);"
662
+ lines += %w[md lg xl 2xl].map { |size| "--shadow-#{size}: var(--shadow-#{size});" }
663
+ "@theme inline {\n#{indent(lines)}\n}"
664
+ end
665
+
666
+ def layer_base
667
+ <<~CSS.strip
668
+ @layer base {
669
+ * {
670
+ @apply border-border outline-ring/50;
671
+ }
672
+ body {
673
+ @apply bg-background text-foreground;
674
+ }
675
+ }
676
+ CSS
677
+ end
678
+ end
679
+ end
680
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Poetry
8
+ module Extract
9
+ # The four design signals for a domain. Any member may be nil - the
10
+ # derivers and the prompt both accept the degraded shape.
11
+ Signals = Struct.new(:styleguide, :brand, :screenshot_url, :markdown, keyword_init: true)
12
+
13
+ # The fetch layer. Primary path: the context.dev SDK (the gem's one
14
+ # service dependency - same four calls as upstream). Degraded path (no
15
+ # CONTEXT_DEV_API_KEY): a plain homepage fetch for the markdown signal,
16
+ # nil for the rest - extraction still works, on less evidence.
17
+ module Fetch
18
+ # Styleguide extraction is the slow call - allow it two minutes.
19
+ STYLEGUIDE_TIMEOUT_MS = 120_000
20
+
21
+ module_function
22
+
23
+ # Fetch the four design signals for a domain. Without a client
24
+ # (CONTEXT_DEV_API_KEY unset) it takes the degraded path: homepage
25
+ # markdown only, every other signal nil.
26
+ #
27
+ # @param domain [String] bare host, e.g. "stripe.com"
28
+ # @param client [Object, nil] the context.dev SDK client; nil selects
29
+ # the degraded path
30
+ # @param homepage_fetcher [#call] degraded-path fetcher (domain in,
31
+ # markdown string out)
32
+ # @return [Signals]
33
+ def signals(domain, client: default_client, homepage_fetcher: method(:fetch_homepage))
34
+ return degraded(domain, homepage_fetcher) unless client
35
+
36
+ styleguide = client.web.extract_styleguide(domain: domain, timeout_ms: STYLEGUIDE_TIMEOUT_MS)
37
+ brand = client.brand.retrieve(domain: domain)
38
+ screenshot = client.web.screenshot(domain: domain, full_screenshot: "false",
39
+ handle_cookie_popup: "true")
40
+ markdown = client.web.web_scrape_md(url: "https://#{domain}", include_links: true,
41
+ include_images: false, use_main_content_only: true)
42
+
43
+ Signals.new(
44
+ styleguide: normalize(value_of(styleguide, :styleguide)),
45
+ brand: normalize(value_of(brand, :brand)),
46
+ screenshot_url: value_of(screenshot, :screenshot),
47
+ markdown: value_of(markdown, :markdown).to_s
48
+ )
49
+ end
50
+
51
+ # The no-client Signals shape: homepage markdown, nothing else.
52
+ # @api private
53
+ def degraded(domain, homepage_fetcher)
54
+ Signals.new(styleguide: nil, brand: nil, screenshot_url: nil,
55
+ markdown: homepage_fetcher.call(domain).to_s)
56
+ end
57
+
58
+ # Build the context.dev SDK client when the API key is present.
59
+ # @api private
60
+ def default_client
61
+ return nil unless ENV["CONTEXT_DEV_API_KEY"]
62
+
63
+ require "context_dev"
64
+ ContextDev::Client.new(api_key: ENV.fetch("CONTEXT_DEV_API_KEY"))
65
+ end
66
+
67
+ # SDK responses expose the payload as an accessor; plain-hash fakes
68
+ # (tests, cassettes) work identically.
69
+ # @api private
70
+ def value_of(response, key)
71
+ return response[key.to_s] || response[key] if response.is_a?(Hash)
72
+
73
+ response.public_send(key)
74
+ end
75
+
76
+ # Deep-stringified plain data for the derivers, whatever the SDK's
77
+ # model classes are.
78
+ # @api private
79
+ def normalize(value)
80
+ return nil if value.nil?
81
+
82
+ JSON.parse(JSON.generate(value))
83
+ end
84
+
85
+ # The degraded markdown signal: the homepage body with tags crudely
86
+ # stripped - supporting evidence for the prompt, never token data.
87
+ # @api private
88
+ def fetch_homepage(domain)
89
+ response = Net::HTTP.get_response(URI("https://#{domain}/"))
90
+ return "" unless response.is_a?(Net::HTTPSuccess)
91
+
92
+ response.body.to_s
93
+ .gsub(%r{<(script|style)[^>]*>.*?</\1>}mi, " ")
94
+ .gsub(/<[^>]+>/, " ")
95
+ .gsub(/\s+/, " ")
96
+ .strip
97
+ rescue StandardError
98
+ ""
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Poetry
6
+ module Extract
7
+ # Installs the poetry:design:extract task into any Rails host the
8
+ # moment the gem is bundled.
9
+ class Railtie < Rails::Railtie
10
+ rake_tasks do
11
+ load File.expand_path("../../tasks/poetry/extract.rake", __dir__)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Poetry
6
+ module Extract
7
+ # The orchestration: fetch -> compose -> derive -> write. Never touches
8
+ # the theme - the printed handoff goes through poetry:design:import,
9
+ # where the AA gate stays the single door.
10
+ module Runner
11
+ # Default output root; run! writes into <OUT_ROOT>/<domain>/.
12
+ OUT_ROOT = "tmp/poetry/design_extract"
13
+
14
+ module_function
15
+
16
+ # Extract a domain's design system: fetch the signals, compose
17
+ # DESIGN.md, derive the two token stylesheets, and write all three
18
+ # files under out_root. Prints the poetry:design:import handoff
19
+ # command - importing is deliberately left to the AA gate.
20
+ #
21
+ # @param domain [String] the domain to extract; a URL is normalized
22
+ # down to its bare host
23
+ # @param out_root [String] directory the per-domain folder lands in
24
+ # @param signals [Signals, nil] pre-fetched signals (skips the fetch)
25
+ # @param composer [#design_md] the DESIGN.md composer
26
+ # @param io [IO] progress and handoff output
27
+ # @return [String] the written directory path
28
+ # @example
29
+ # Poetry::Extract::Runner.run!("stripe.com")
30
+ # # => "tmp/poetry/design_extract/stripe.com"
31
+ def run!(domain, out_root: OUT_ROOT, signals: nil, composer: Compose, io: $stdout)
32
+ normalized = normalize_domain(domain)
33
+ raise Error, "#{domain.inspect} is not a valid domain" if normalized.empty?
34
+
35
+ signals ||= Fetch.signals(normalized)
36
+ design_md = composer.design_md(domain: normalized, signals: signals)
37
+ raise Error, "the compose call returned no DESIGN.md content" if design_md.empty?
38
+
39
+ validate!(design_md, io)
40
+
41
+ dir = File.join(out_root, normalized)
42
+ FileUtils.mkdir_p(dir)
43
+ File.write(File.join(dir, "DESIGN.md"), "#{design_md}\n")
44
+ File.write(File.join(dir, "theme.tailwind.css"),
45
+ DeriveTokens.derive_tailwind_theme(normalized, signals.brand, signals.styleguide))
46
+ File.write(File.join(dir, "tokens.css"),
47
+ DeriveTokens.derive_css_variables(normalized, signals.brand, signals.styleguide))
48
+
49
+ io.puts "wrote #{dir}/{DESIGN.md, theme.tailwind.css, tokens.css}"
50
+ io.puts "next: bin/rails \"poetry:design:import[#{dir}/DESIGN.md]\" (the AA gate decides what lands)"
51
+ dir
52
+ end
53
+
54
+ # Reduce a URL or bare domain to its lowercase host.
55
+ #
56
+ # @return [String]
57
+ def normalize_domain(domain)
58
+ domain.to_s.strip.downcase
59
+ .sub(%r{\Ahttps?://}, "").delete_prefix("www.").sub(%r{/.*\z}m, "")
60
+ end
61
+
62
+ # The composed document must at least parse as DESIGN.md; a missing
63
+ # frontmatter is a warning, not a failure - prose-only documents
64
+ # still carry the body sections, and import will say so again.
65
+ def validate!(design_md, io)
66
+ parsed = Poetry::Core::DesignMd.parse(design_md)
67
+ io.puts "note: composed DESIGN.md has no frontmatter tokens" unless parsed.is_a?(Hash) && parsed.any?
68
+ rescue StandardError => e
69
+ io.puts "note: DesignMd.parse could not read the composed document (#{e.class}: #{e.message})"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Extract
5
+ # The gem version.
6
+ VERSION = "0.0.2"
7
+ end
8
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "poetry/core"
4
+ require_relative "extract/version"
5
+ require_relative "extract/derive_tokens"
6
+ require_relative "extract/fetch"
7
+ require_relative "extract/compose"
8
+ require_relative "extract/runner"
9
+
10
+ # The poetry namespace.
11
+ module Poetry
12
+ # The optional design-extraction gem: domain in, theme out. Produces a
13
+ # DESIGN.md + deterministic token stylesheets and hands them to
14
+ # poetry-core's AA-gated design importer - never the theme directly.
15
+ module Extract
16
+ # The gem's one error class: fetch, compose, and write failures all
17
+ # raise it (missing API keys included).
18
+ class Error < StandardError; end
19
+ end
20
+ end
21
+
22
+ require_relative "extract/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :poetry do
4
+ namespace :design do
5
+ desc "Extract a DESIGN.md + design tokens from a public website " \
6
+ "(poetry:design:extract[stripe.com]); hand the result to poetry:design:import"
7
+ task :extract, [:domain] do |_task, args|
8
+ abort "usage: bin/rails \"poetry:design:extract[domain.com]\"" if args[:domain].to_s.empty?
9
+
10
+ Poetry::Extract::Runner.run!(args[:domain])
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: poetry-extract
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Matt Solt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: poetry-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.2
26
+ - !ruby/object:Gem::Dependency
27
+ name: context.dev
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.11'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.11'
40
+ description: 'The optional design-extraction gem for Poetry: fetches a site''s styleguide,
41
+ brand, screenshot, and homepage markdown (context.dev), composes a DESIGN.md in
42
+ one Claude call, derives Tailwind v4 @theme + CSS :root tokens deterministically,
43
+ and hands the result to Poetry''s AA-gated design importer.'
44
+ email:
45
+ - mattsolt@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - THIRD_PARTY_NOTICES.md
54
+ - lib/poetry/extract.rb
55
+ - lib/poetry/extract/compose.rb
56
+ - lib/poetry/extract/derive_tokens.rb
57
+ - lib/poetry/extract/fetch.rb
58
+ - lib/poetry/extract/railtie.rb
59
+ - lib/poetry/extract/runner.rb
60
+ - lib/poetry/extract/version.rb
61
+ - lib/tasks/poetry/extract.rake
62
+ homepage: https://poetryui.com
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://poetryui.com
67
+ documentation_uri: https://poetryui.com/docs
68
+ source_code_uri: https://github.com/roboruby/poetry-extract
69
+ changelog_uri: https://github.com/roboruby/poetry-extract/blob/main/CHANGELOG.md
70
+ bug_tracker_uri: https://github.com/roboruby/poetry-extract/issues
71
+ rubygems_mfa_required: 'true'
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.4.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.16
87
+ specification_version: 4
88
+ summary: 'Domain in, theme out: extract a DESIGN.md + design tokens from any public
89
+ website.'
90
+ test_files: []