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 +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/THIRD_PARTY_NOTICES.md +35 -0
- data/lib/poetry/extract/compose.rb +149 -0
- data/lib/poetry/extract/derive_tokens.rb +680 -0
- data/lib/poetry/extract/fetch.rb +102 -0
- data/lib/poetry/extract/railtie.rb +15 -0
- data/lib/poetry/extract/runner.rb +73 -0
- data/lib/poetry/extract/version.rb +8 -0
- data/lib/poetry/extract.rb +22 -0
- data/lib/tasks/poetry/extract.rake +13 -0
- metadata +90 -0
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
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,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: []
|