auva 0.1.0
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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +38 -0
- data/Rakefile +8 -0
- data/exe/auva +6 -0
- data/lib/auva/version.rb +5 -0
- data/lib/auva.rb +523 -0
- data/sig/auva.rbs +13 -0
- metadata +100 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2d5a58d9d63594cd4bec804137ae667e67a6b771e4d6dd99b5df344841c05c76
|
|
4
|
+
data.tar.gz: f7bfc056380e510ea32ccd873e588ee13e7d03f42557943c7e0ecaf511aea799
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 62b6e3da3cbc77c36eb6ea3d1eea4a5d025acbd5da19a54a541234f5244efd9875a4f5b60354de8c1205cd45b7e8911c611c3d1c070bedae2906b394f84e9a27
|
|
7
|
+
data.tar.gz: f75f1723c4280fdbf1ddf5b9dedecc8cbb28b0e6f5421e0ac888ccd0b4f6d499430be29b5524a6c1c2671c092773a6c16c93cf3867c408759acb665ba49c6bc6
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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,38 @@
|
|
|
1
|
+
# Auva
|
|
2
|
+
|
|
3
|
+
JSONC design-token loading and deterministic theme sheets for Zaniah. Auva
|
|
4
|
+
validates token names from Zaniah's own `members`, reports source lines, and
|
|
5
|
+
checks WCAG contrast ratios.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install auva
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
auva tokens.jsonc --check --strict
|
|
17
|
+
auva --builtin dark --export public/theme
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The export directory contains deterministic `colors.png`, `typography.png`,
|
|
21
|
+
`spacing.png`, `radii.png`, `shadows.png`, `motion.png`, `buttons.png`, and
|
|
22
|
+
`components.png` sheets. Library
|
|
23
|
+
consumers can load a theme with `Auva.load("tokens.jsonc")`.
|
|
24
|
+
|
|
25
|
+
Supported token categories are `extends`, `colors`, `typography`, `spacing`,
|
|
26
|
+
`radii`, `shadows`, and `motion`; unknown names fail with a line number.
|
|
27
|
+
|
|
28
|
+
## Development
|
|
29
|
+
|
|
30
|
+
Run `rake spec` and `gem build --strict auva.gemspec`.
|
|
31
|
+
|
|
32
|
+
## Contributing
|
|
33
|
+
|
|
34
|
+
Bug reports and pull requests are welcome at https://github.com/noxdea/auva.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT.
|
data/Rakefile
ADDED
data/exe/auva
ADDED
data/lib/auva/version.rb
ADDED
data/lib/auva.rb
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "kochab"
|
|
7
|
+
require "zaniah"
|
|
8
|
+
require "zaniah/ui"
|
|
9
|
+
require_relative "auva/version"
|
|
10
|
+
|
|
11
|
+
module Auva
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :path, :line
|
|
14
|
+
|
|
15
|
+
def initialize(message, path: nil, line: nil)
|
|
16
|
+
@path = path
|
|
17
|
+
@line = line
|
|
18
|
+
suffix = [line && "line #{line}", path && path.join(".")].compact.join(": ")
|
|
19
|
+
super(suffix.empty? ? message : "#{message} (#{suffix})")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
PAIRS = [
|
|
24
|
+
%w[text background], %w[text_muted background], %w[text surface],
|
|
25
|
+
%w[accent_text accent], %w[text_inverse accent], %w[text surface_hover]
|
|
26
|
+
].freeze
|
|
27
|
+
Contrast = Data.define(:pair, :ratio, :aa, :aaa)
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def load(path)
|
|
32
|
+
source = File.read(path, encoding: "UTF-8")
|
|
33
|
+
document = Kochab.parse(source)
|
|
34
|
+
tokens = document.value
|
|
35
|
+
raise Error, "top-level tokens must be an object" unless tokens.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
ThemeBuilder.new(tokens, document).call
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
raise Error, "token file not found: #{path}"
|
|
40
|
+
rescue Kochab::ParseError => error
|
|
41
|
+
first = error.errors.find { |entry| entry.severity == :error }
|
|
42
|
+
line = first && source&.byteslice(0...first.range.begin).to_s.count("\n") + 1
|
|
43
|
+
raise Error.new(error.message, line: line)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def builtin(name)
|
|
47
|
+
case name.to_s
|
|
48
|
+
when "dark" then Zaniah::Theme.dark
|
|
49
|
+
when "light" then Zaniah::Theme.light
|
|
50
|
+
when "high_contrast", "high-contrast" then Zaniah::Theme.high_contrast
|
|
51
|
+
when "none" then Zaniah::Theme.dark
|
|
52
|
+
else raise Error, "unknown built-in theme: #{name}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def contrast(theme, pairs: PAIRS)
|
|
57
|
+
pairs.map do |foreground, background|
|
|
58
|
+
fg = theme.colors.public_send(foreground)
|
|
59
|
+
bg = theme.colors.public_send(background)
|
|
60
|
+
ratio = fg.contrast_ratio(bg)
|
|
61
|
+
Contrast.new(pair: "#{foreground} / #{background}", ratio: ratio,
|
|
62
|
+
aa: ratio >= 4.5, aaa: ratio >= 7.0)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class ThemeBuilder
|
|
67
|
+
def initialize(tokens, document)
|
|
68
|
+
@tokens = tokens
|
|
69
|
+
@document = document
|
|
70
|
+
@base = Auva.builtin(tokens.fetch("extends", "dark"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call
|
|
74
|
+
validate_top_level
|
|
75
|
+
@base.with(
|
|
76
|
+
colors: build_colors,
|
|
77
|
+
typography: build_data(:typography),
|
|
78
|
+
motion: build_data(:motion),
|
|
79
|
+
spacing: build_hash(:spacing, integer_keys: true),
|
|
80
|
+
radii: build_hash(:radii, symbol_keys: true),
|
|
81
|
+
shadows: build_shadows
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate_top_level
|
|
88
|
+
unknown = @tokens.keys.map(&:to_s) - %w[extends colors typography spacing radii shadows motion]
|
|
89
|
+
raise_token("unknown token category: #{unknown.join(", ")}", [unknown.first]) unless unknown.empty?
|
|
90
|
+
base = @tokens["extends"]
|
|
91
|
+
unless base.nil? || %w[dark light high_contrast high-contrast none].include?(base)
|
|
92
|
+
raise_token("extends must be dark, light, high_contrast, or none", ["extends"])
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_colors
|
|
97
|
+
values = @tokens.fetch("colors", {})
|
|
98
|
+
ensure_hash(values, ["colors"])
|
|
99
|
+
validate_keys(values, @base.colors.members.map(&:to_s), ["colors"])
|
|
100
|
+
overrides = values.to_h { |key, value| [key.to_sym, parse_color(value, ["colors", key])] }
|
|
101
|
+
@base.colors.with(**overrides)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_data(category)
|
|
105
|
+
values = @tokens.fetch(category.to_s, {})
|
|
106
|
+
ensure_hash(values, [category.to_s])
|
|
107
|
+
validate_keys(values, @base.public_send(category).members.map(&:to_s), [category.to_s])
|
|
108
|
+
converted = values.to_h do |key, value|
|
|
109
|
+
path = [category.to_s, key]
|
|
110
|
+
converted_value = convert_scalar(value, path)
|
|
111
|
+
validate_scalar(converted_value, @base.public_send(category).public_send(key.to_sym), path)
|
|
112
|
+
[key.to_sym, converted_value]
|
|
113
|
+
end
|
|
114
|
+
@base.public_send(category).with(**converted)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_hash(category, integer_keys: false, symbol_keys: false)
|
|
118
|
+
values = @tokens.fetch(category.to_s, {})
|
|
119
|
+
ensure_hash(values, [category.to_s])
|
|
120
|
+
base = @base.public_send(category)
|
|
121
|
+
validate_keys(values, base.keys.map(&:to_s), [category.to_s]) if symbol_keys
|
|
122
|
+
values.each_with_object(base.dup) do |(key, value), result|
|
|
123
|
+
normalized = integer_keys ? Integer(key, 10) : (symbol_keys ? key.to_sym : key)
|
|
124
|
+
result[normalized] = numeric(value, [category.to_s, key])
|
|
125
|
+
rescue ArgumentError
|
|
126
|
+
raise_token("#{category} key must be numeric", [category.to_s, key])
|
|
127
|
+
end.freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_shadows
|
|
131
|
+
values = @tokens.fetch("shadows", {})
|
|
132
|
+
ensure_hash(values, ["shadows"])
|
|
133
|
+
validate_keys(values, @base.shadows.keys.map(&:to_s), ["shadows"])
|
|
134
|
+
values.each_with_object(@base.shadows.dup) do |(key, value), result|
|
|
135
|
+
ensure_hash(value, ["shadows", key])
|
|
136
|
+
known = Zaniah::Shadow.members.map(&:to_s)
|
|
137
|
+
validate_keys(value, known, ["shadows", key])
|
|
138
|
+
converted = value.transform_keys(&:to_sym)
|
|
139
|
+
converted[:color] = parse_color(converted[:color], ["shadows", key, "color"]) if converted.key?(:color)
|
|
140
|
+
%i[x y blur spread].each do |name|
|
|
141
|
+
next unless converted.key?(name)
|
|
142
|
+
raise_token("expected a number", ["shadows", key, name]) unless converted[name].is_a?(Numeric)
|
|
143
|
+
end
|
|
144
|
+
raise_token("expected a boolean", ["shadows", key, "inset"]) unless !converted.key?(:inset) || converted[:inset] == true || converted[:inset] == false
|
|
145
|
+
result[key.to_sym] = @base.shadows.fetch(key.to_sym).with(**converted)
|
|
146
|
+
end.freeze
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_keys(values, known, path)
|
|
150
|
+
unknown = values.keys.map(&:to_s) - known
|
|
151
|
+
raise_token("unknown token: #{unknown.join(", ")}", path + [unknown.first]) unless unknown.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ensure_hash(value, path)
|
|
155
|
+
raise_token("expected an object", path) unless value.is_a?(Hash)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def convert_scalar(value, path)
|
|
159
|
+
value.is_a?(String) && path.first == "motion" && path.last.to_s.start_with?("easing_") ? value.to_sym : value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_scalar(value, base, path)
|
|
163
|
+
valid = case base
|
|
164
|
+
when Numeric then value.is_a?(Numeric)
|
|
165
|
+
when Symbol then value.is_a?(Symbol)
|
|
166
|
+
when String then value.is_a?(String)
|
|
167
|
+
when TrueClass, FalseClass then value == true || value == false
|
|
168
|
+
else true
|
|
169
|
+
end
|
|
170
|
+
raise_token("invalid value", path) unless valid
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def numeric(value, path)
|
|
174
|
+
raise_token("expected a number", path) unless value.is_a?(Numeric)
|
|
175
|
+
value
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_color(value, path)
|
|
179
|
+
Zaniah::Color.parse(value)
|
|
180
|
+
rescue ArgumentError, TypeError
|
|
181
|
+
raise_token("invalid color", path)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def raise_token(message, path)
|
|
185
|
+
range = @document.range_of(path.compact)
|
|
186
|
+
line = range && @document.text.byteslice(0...range.begin).count("\n") + 1
|
|
187
|
+
raise Error.new(message, path: path.compact, line: line)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class Png
|
|
192
|
+
def self.write(theme, path, width: 800, height: 480, category: :colors)
|
|
193
|
+
File.binwrite(path, bytes(theme, width: width, height: height, category: category))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.bytes(theme, width: 800, height: 480, category: :colors)
|
|
197
|
+
return element_bytes(theme, width: width, height: height, category: category) if
|
|
198
|
+
%i[typography spacing radii shadows motion buttons components].include?(category.to_sym)
|
|
199
|
+
colors = category_colors(theme, category)
|
|
200
|
+
background = rgba(theme.colors.background)
|
|
201
|
+
pixels = Array.new(width * height, background)
|
|
202
|
+
colors.each_with_index do |color, index|
|
|
203
|
+
x0 = (index % 5) * (width / 5)
|
|
204
|
+
y0 = 60 + (index / 5) * 100 + category_offset(category)
|
|
205
|
+
value = rgba(color)
|
|
206
|
+
(y0...[y0 + 70, height].min).each do |y|
|
|
207
|
+
(x0...[x0 + width / 5, width].min).each { |x| pixels[y * width + x] = value }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
encode(width, height, pixels)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.write_sheets(theme, directory)
|
|
214
|
+
FileUtils.mkdir_p(directory)
|
|
215
|
+
%w[colors typography spacing radii shadows motion buttons components].each do |category|
|
|
216
|
+
write(theme, File.join(directory, "#{category}.png"), category: category.to_sym)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.category_colors(theme, category)
|
|
221
|
+
names = case category.to_sym
|
|
222
|
+
when :buttons then %i[accent accent_hover text_inverse]
|
|
223
|
+
when :typography then %i[text text_muted accent]
|
|
224
|
+
when :spacing then %i[accent info]
|
|
225
|
+
when :radii then %i[accent_hover accent]
|
|
226
|
+
when :shadows then %i[overlay_scrim border_focus]
|
|
227
|
+
when :motion then %i[success warning danger]
|
|
228
|
+
when :components then %i[accent success warning danger info]
|
|
229
|
+
else theme.colors.members
|
|
230
|
+
end
|
|
231
|
+
names.map { |name| theme.colors.public_send(name) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.category_offset(category)
|
|
235
|
+
category.to_s.bytes.sum % 31
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def self.element_bytes(theme, width:, height:, category:)
|
|
239
|
+
preview_theme = theme.with(motion: theme.motion.with(reduced: true))
|
|
240
|
+
app = Zaniah::App.new
|
|
241
|
+
window = app.open_window(backend: :headless, width: width, height: height)
|
|
242
|
+
app.global(:theme, preview_theme)
|
|
243
|
+
window.draw { Preview.element(preview_theme, category: category) }
|
|
244
|
+
window.tick
|
|
245
|
+
Zaniah::PNG.encode(window.device.width.to_i, window.device.height.to_i, window.device.pixels)
|
|
246
|
+
ensure
|
|
247
|
+
window&.close
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private_class_method :category_colors, :category_offset, :element_bytes
|
|
251
|
+
|
|
252
|
+
def self.encode(width, height, pixels)
|
|
253
|
+
raw = (0...height).map { |y| "\0".b + pixels.slice(y * width, width).join }.join
|
|
254
|
+
"\x89PNG\r\n\x1a\n".b + png_chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNCCCCC")) +
|
|
255
|
+
png_chunk("IDAT", Zlib::Deflate.deflate(raw)) + png_chunk("IEND", "")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.rgba(color)
|
|
259
|
+
color.to_a.map { |channel| (channel.clamp(0, 1) * 255).round }.pack("C4")
|
|
260
|
+
end
|
|
261
|
+
private_class_method :rgba
|
|
262
|
+
|
|
263
|
+
def self.png_chunk(type, payload)
|
|
264
|
+
[payload.bytesize, type, payload, Zlib.crc32(type + payload)].pack("N a4 a* N")
|
|
265
|
+
end
|
|
266
|
+
private_class_method :png_chunk
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
class Preview
|
|
270
|
+
CATEGORIES = %i[colors typography spacing radii shadows motion buttons components].freeze
|
|
271
|
+
COMPONENT_COUNT = 31
|
|
272
|
+
GalleryItem = Data.define(:name, :component) do
|
|
273
|
+
def label = name
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.render(theme, category: :colors, width: 800, height: 480)
|
|
277
|
+
raise Error, "unknown preview category: #{category}" unless CATEGORIES.include?(category.to_sym)
|
|
278
|
+
Png.bytes(theme, width: width, height: height, category: category)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.render_all(theme, directory)
|
|
282
|
+
Png.write_sheets(theme, directory)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.element(theme, category: :colors)
|
|
286
|
+
category = category.to_sym
|
|
287
|
+
raise Error, "unknown preview category: #{category}" unless CATEGORIES.include?(category)
|
|
288
|
+
return component_gallery(theme) if category == :components
|
|
289
|
+
return typography_preview(theme) if category == :typography
|
|
290
|
+
return token_preview(theme, category) unless category == :colors
|
|
291
|
+
colors = Png.send(:category_colors, theme, category)
|
|
292
|
+
title = Zaniah::Text.new("Auva · #{category}", size: 26, color: theme.colors.text)
|
|
293
|
+
root = Zaniah::Div.new.flex_col.p(32).gap(12).bg(theme.colors.background).child(title)
|
|
294
|
+
colors.each_with_index do |color, index|
|
|
295
|
+
swatch = Zaniah::Div.new.w(36).h(36).bg(color)
|
|
296
|
+
root = root.child(Zaniah::Div.new.flex_row.gap(12).items_center.child(swatch).child(
|
|
297
|
+
Zaniah::Text.new("#{category}[#{index}]", size: 16, color: theme.colors.text)
|
|
298
|
+
))
|
|
299
|
+
end
|
|
300
|
+
root
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def self.typography_preview(theme)
|
|
304
|
+
sizes = %i[xs sm md lg xl] + ["2xl"]
|
|
305
|
+
body = Zaniah::Div.new.flex_col.gap(12).children(sizes.map do |size|
|
|
306
|
+
Zaniah::UI::Card.new(
|
|
307
|
+
Zaniah::UI::Label.new(size.to_s, tone: :muted, size: :xs),
|
|
308
|
+
size == "2xl" ? Zaniah::Text.new("Aa あいう漢字 — #{size}", size: theme.typography.size_2xl, color: theme.colors.text) :
|
|
309
|
+
Zaniah::UI::Label.new("Aa あいう漢字 — #{size}", size: size, wrap: :word)
|
|
310
|
+
)
|
|
311
|
+
end)
|
|
312
|
+
Zaniah::Div.new.flex_col.p(32).gap(12).bg(theme.colors.background)
|
|
313
|
+
.child(Zaniah::UI::Label.new("Auva · typography", size: :xl)).child(Zaniah::ScrollView.new(scrollbar: :always).flex_1.child(body))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def self.token_preview(theme, category)
|
|
317
|
+
title = Zaniah::UI::Label.new("Auva · #{category}", size: :xl)
|
|
318
|
+
body = case category
|
|
319
|
+
when :spacing
|
|
320
|
+
theme.spacing.sort_by { |key, _value| key }.first(8).map do |key, value|
|
|
321
|
+
Zaniah::Div.new.flex_row.items_center.gap(12).child(
|
|
322
|
+
Zaniah::UI::Label.new("spacing[#{key}] = #{value}", size: :sm)
|
|
323
|
+
).child(Zaniah::Div.new.w([value, 240].min).h(24).bg(theme.colors.accent))
|
|
324
|
+
end
|
|
325
|
+
when :radii
|
|
326
|
+
theme.radii.map do |name, radius|
|
|
327
|
+
Zaniah::Div.new.flex_row.items_center.gap(12).child(
|
|
328
|
+
Zaniah::UI::Label.new("radii.#{name} = #{radius}", size: :sm)
|
|
329
|
+
).child(Zaniah::Div.new.w(180).h(56).bg(theme.colors.surface).border(1)
|
|
330
|
+
.border_color(theme.colors.border).rounded(radius))
|
|
331
|
+
end
|
|
332
|
+
when :shadows
|
|
333
|
+
theme.shadows.map do |name, shadow|
|
|
334
|
+
Zaniah::Div.new.flex_row.items_center.gap(12).child(
|
|
335
|
+
Zaniah::UI::Label.new("shadows.#{name}", size: :sm)
|
|
336
|
+
).child(Zaniah::Div.new.w(180).h(56).bg(theme.colors.surface).rounded(theme.radii[:md])
|
|
337
|
+
.style(shadows: shadow))
|
|
338
|
+
end
|
|
339
|
+
when :motion
|
|
340
|
+
%i[duration_fast duration_base duration_slow easing_standard easing_decelerate easing_accelerate reduced].map do |name|
|
|
341
|
+
value = theme.motion.public_send(name)
|
|
342
|
+
Zaniah::UI::Card.new(Zaniah::UI::Label.new(name.to_s, tone: :muted, size: :xs),
|
|
343
|
+
Zaniah::UI::Label.new(value.to_s, size: :md))
|
|
344
|
+
end
|
|
345
|
+
when :buttons
|
|
346
|
+
disabled = Zaniah::UI::Button.new("Disabled").disabled
|
|
347
|
+
[Zaniah::UI::Button.new("Primary"), Zaniah::UI::Button.new("Secondary", variant: :secondary),
|
|
348
|
+
Zaniah::UI::Button.new("Danger", variant: :danger), disabled]
|
|
349
|
+
else
|
|
350
|
+
[]
|
|
351
|
+
end
|
|
352
|
+
body = Zaniah::Div.new.flex_col.gap(12).children(body)
|
|
353
|
+
Zaniah::Div.new.flex_col.p(32).gap(16).bg(theme.colors.background).child(title).child(
|
|
354
|
+
Zaniah::ScrollView.new(scrollbar: :always).flex_1.child(body)
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def self.component_gallery(theme)
|
|
359
|
+
rows = [
|
|
360
|
+
["Button", Zaniah::UI::Button.new("Primary")],
|
|
361
|
+
["IconButton", Zaniah::UI::IconButton.new(:info, label: "Info")],
|
|
362
|
+
["ToggleButton", Zaniah::UI::ToggleButton.new("Toggle", value: true)],
|
|
363
|
+
["ButtonGroup", Zaniah::UI::ButtonGroup.new(Zaniah::UI::Button.new("One"), Zaniah::UI::Button.new("Two", variant: :secondary))],
|
|
364
|
+
["Checkbox", Zaniah::UI::Checkbox.new("Accept", value: true)],
|
|
365
|
+
["Radio", Zaniah::UI::Radio.new("Choice", value: true)],
|
|
366
|
+
["RadioGroup", Zaniah::UI::RadioGroup.new([["Dark", :dark], ["Light", :light]], value: :dark)],
|
|
367
|
+
["Switch", Zaniah::UI::Switch.new("Enabled", value: true)],
|
|
368
|
+
["Slider", Zaniah::UI::Slider.new(value: 65, label: "Volume")],
|
|
369
|
+
["RangeSlider", Zaniah::UI::RangeSlider.new(value: [20, 80])],
|
|
370
|
+
["ProgressBar", Zaniah::UI::ProgressBar.new(value: 72)],
|
|
371
|
+
["Spinner", Zaniah::UI::Spinner.new],
|
|
372
|
+
["Badge", Zaniah::UI::Badge.new("New", variant: :success)],
|
|
373
|
+
["Card", Zaniah::UI::Card.new(Zaniah::UI::Label.new("Card content"))],
|
|
374
|
+
["TextField", Zaniah::UI::TextField.new("Example", label: "Name")],
|
|
375
|
+
["TextArea", Zaniah::UI::TextArea.new("Longer text", rows: 2, label: "Description")],
|
|
376
|
+
["Select", Zaniah::UI::Select.new([["Dark", :dark], ["Light", :light]], value: :dark)],
|
|
377
|
+
["MultiSelect", Zaniah::UI::MultiSelect.new(%w[Ruby UI], value: ["Ruby"])],
|
|
378
|
+
["Tabs", Zaniah::UI::Tabs.new([["Overview", Zaniah::UI::Label.new("Overview")], ["Details", Zaniah::UI::Label.new("Details")]])],
|
|
379
|
+
["Accordion", Zaniah::UI::Accordion.new([["Details", Zaniah::UI::Label.new("Expanded content")]], open: [0])],
|
|
380
|
+
["Breadcrumb", Zaniah::UI::Breadcrumb.new(["Home", "Design", "Preview"])],
|
|
381
|
+
["Pagination", Zaniah::UI::Pagination.new(page: 2, pages: 4)],
|
|
382
|
+
["Table", Zaniah::UI::Table.new([{name: "Alpha", value: "1"}, {name: "Beta", value: "2"}], columns: [
|
|
383
|
+
{key: :name, label: "Name", width: 160, sortable: false}, {key: :value, label: "Value", width: 100, sortable: false}
|
|
384
|
+
], height: 110, selection: :none)],
|
|
385
|
+
["ListView", Zaniah::UI::ListView.new(%w[First Second Third], height: 100)],
|
|
386
|
+
["TreeView", Zaniah::UI::TreeView.new([{id: :root, label: "Root", children: [{id: :leaf, label: "Leaf"}]}], height: 100)],
|
|
387
|
+
["EmptyState", Zaniah::UI::EmptyState.new("Nothing here", message: "Add an item to continue")],
|
|
388
|
+
["Sparkline", Zaniah::UI::Sparkline.new([1, 3, 2, 5])],
|
|
389
|
+
["BarChart", Zaniah::UI::BarChart.new({"Build" => [2, 4, 3]}, width: 240, height: 100)],
|
|
390
|
+
["RichText", Zaniah::UI::RichText.new([{text: "Highlighted", color: theme.colors.accent}, " text"])],
|
|
391
|
+
["Divider", Zaniah::UI::Divider.new],
|
|
392
|
+
["StatusBar", Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready"), Zaniah::UI::Badge.new("OK", variant: :success))]
|
|
393
|
+
]
|
|
394
|
+
items = rows.map { |name, component| GalleryItem.new(name, component) }
|
|
395
|
+
body = Zaniah::UI::ListView.new(items, height: 560, row_height: 180) do |item, _index|
|
|
396
|
+
Zaniah::UI::Card.new(Zaniah::UI::Label.new(item.name, tone: :muted, size: :xs), item.component)
|
|
397
|
+
end
|
|
398
|
+
Zaniah::Div.new.flex_col.p(32).gap(12).bg(theme.colors.background)
|
|
399
|
+
.child(Zaniah::UI::Label.new("Auva · components (#{rows.length})", size: :xl))
|
|
400
|
+
.child(Zaniah::ScrollView.new(scrollbar: :always).flex_1.child(body))
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def self.show(theme, category: :colors, backend: :auto, watcher: nil, title: "Auva", themes: nil)
|
|
404
|
+
selected = backend == :auto ? (RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mswin|mingw/) ? :windows : :linux) : backend
|
|
405
|
+
app = Zaniah::App.new
|
|
406
|
+
window = app.open_window(backend: selected, width: 800, height: 600, title: title)
|
|
407
|
+
current = theme
|
|
408
|
+
app.global(:theme, current)
|
|
409
|
+
tabs = build_theme_tabs(themes, category) do |index|
|
|
410
|
+
current = themes.fetch(index).last
|
|
411
|
+
app.global(:theme, current)
|
|
412
|
+
window.request_frame
|
|
413
|
+
end if themes && themes.length > 1
|
|
414
|
+
window.draw do
|
|
415
|
+
root = tabs ? Zaniah::Div.new.flex_col.bg(current.colors.background).child(tabs) : element(current, category: category)
|
|
416
|
+
root
|
|
417
|
+
end
|
|
418
|
+
window.on_tick do
|
|
419
|
+
if watcher&.poll
|
|
420
|
+
unless watcher.error
|
|
421
|
+
current = watcher.theme
|
|
422
|
+
app.global(:theme, current)
|
|
423
|
+
end
|
|
424
|
+
window.request_frame
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
window.run
|
|
428
|
+
ensure
|
|
429
|
+
window&.close
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def self.build_theme_tabs(themes, category, &on_change)
|
|
433
|
+
items = themes.map { |name, preview_theme| [name.to_s, element(preview_theme, category: category)] }
|
|
434
|
+
Zaniah::UI::Tabs.new(items).on_change { |index, _event, _context| on_change&.call(index) }
|
|
435
|
+
end
|
|
436
|
+
private_class_method :build_theme_tabs
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
class Watcher
|
|
440
|
+
attr_reader :theme, :error
|
|
441
|
+
|
|
442
|
+
def initialize(path, theme: nil, latency: 0.1)
|
|
443
|
+
@path, @theme, @latency, @last = path, theme || Auva.load(path), latency, File.mtime(path)
|
|
444
|
+
@watch = begin
|
|
445
|
+
Zaniah::Platform.watch(File.dirname(File.expand_path(path)), latency: latency)
|
|
446
|
+
rescue StandardError => error
|
|
447
|
+
@error = error
|
|
448
|
+
nil
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def poll
|
|
453
|
+
events = @watch.poll(timeout: 0)
|
|
454
|
+
changed = events.any? { |event| File.expand_path(event.path) == File.expand_path(@path) }
|
|
455
|
+
changed ||= File.file?(@path) && File.mtime(@path) != @last
|
|
456
|
+
return false unless changed
|
|
457
|
+
@last = File.mtime(@path)
|
|
458
|
+
@theme = Auva.load(@path)
|
|
459
|
+
@error = nil
|
|
460
|
+
true
|
|
461
|
+
rescue StandardError => error
|
|
462
|
+
@error = error
|
|
463
|
+
false
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
class CLI
|
|
468
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
469
|
+
options = {check: false, strict: false, export: nil, builtin: nil, themes: nil, no_watch: false, watch: false}
|
|
470
|
+
parser = OptionParser.new do |opts|
|
|
471
|
+
opts.banner = "Usage: auva [TOKENS.jsonc] [options]"
|
|
472
|
+
opts.on("--builtin NAME", "use dark, light, or high_contrast") { |v| options[:builtin] = v }
|
|
473
|
+
opts.on("--check", "check WCAG contrast") { options[:check] = true }
|
|
474
|
+
opts.on("--strict", "fail when AA is not met") { options[:strict] = true }
|
|
475
|
+
opts.on("--export DIR", "write a deterministic palette PNG") { |v| options[:export] = v }
|
|
476
|
+
opts.on("--themes NAMES", "comma-separated built-in themes") { |v| options[:themes] = v.split(",").map(&:strip) }
|
|
477
|
+
opts.on("--no-watch", "disable file watching") { options[:no_watch] = true }
|
|
478
|
+
opts.on("--watch", "watch token files until interrupted") { options[:watch] = true }
|
|
479
|
+
end
|
|
480
|
+
parser.parse!(argv)
|
|
481
|
+
names = options[:themes] || [options[:builtin] || "file"]
|
|
482
|
+
source_path = argv.first
|
|
483
|
+
themes = names.map { |name| name == "file" ? Auva.load(argv.fetch(0)) : Auva.builtin(name) }
|
|
484
|
+
failures = themes.each_with_index.sum do |theme, index|
|
|
485
|
+
results = Auva.contrast(theme)
|
|
486
|
+
if options[:check] || options[:strict]
|
|
487
|
+
results.each { |result| out.puts "#{result.pair}: #{format("%.2f", result.ratio)}:1 #{result.aaa ? "AAA" : result.aa ? "AA" : "FAIL"}" }
|
|
488
|
+
end
|
|
489
|
+
if options[:export]
|
|
490
|
+
destination = themes.length == 1 ? options[:export] : File.join(options[:export], names[index])
|
|
491
|
+
Png.write_sheets(theme, destination)
|
|
492
|
+
end
|
|
493
|
+
results.count { |result| !result.aa }
|
|
494
|
+
end
|
|
495
|
+
if !options[:export] && !options[:check] && !options[:strict] && out.tty? && defined?(Zaniah::Platform)
|
|
496
|
+
watcher = source_path && !options[:builtin] && !options[:themes] ? Watcher.new(source_path) : nil
|
|
497
|
+
theme_tabs = options[:themes] ? names.zip(themes) : nil
|
|
498
|
+
begin
|
|
499
|
+
Preview.show(themes.first, watcher: watcher, title: "Auva · #{names.first}", themes: theme_tabs)
|
|
500
|
+
rescue StandardError => error
|
|
501
|
+
err.puts "auva: preview unavailable: #{error.message}"
|
|
502
|
+
out.puts "auva: loaded #{themes.length} theme(s)"
|
|
503
|
+
end
|
|
504
|
+
return 0
|
|
505
|
+
end
|
|
506
|
+
if options[:watch] && !options[:no_watch] && source_path && !options[:builtin] && !options[:themes]
|
|
507
|
+
watcher = Watcher.new(source_path)
|
|
508
|
+
out.puts "auva: watching #{source_path}"
|
|
509
|
+
loop do
|
|
510
|
+
sleep 0.1
|
|
511
|
+
if watcher.poll
|
|
512
|
+
out.puts watcher.error ? "auva: #{watcher.error.message}" : "auva: updated"
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
out.puts "auva: loaded #{themes.length} theme(s)" unless options[:check] || options[:strict] || options[:export]
|
|
517
|
+
options[:strict] && failures.positive? ? 1 : 0
|
|
518
|
+
rescue OptionParser::ParseError, KeyError, Error => error
|
|
519
|
+
err.puts "auva: #{error.message}"
|
|
520
|
+
1
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
data/sig/auva.rbs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Auva
|
|
2
|
+
VERSION: String
|
|
3
|
+
def self.load: (String path) -> untyped
|
|
4
|
+
def self.builtin: (String name) -> untyped
|
|
5
|
+
def self.contrast: (untyped theme, ?pairs: Array[Array[String]]) -> Array[untyped]
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
class Watcher
|
|
9
|
+
attr_reader theme: untyped
|
|
10
|
+
def initialize: (String path, ?theme: untyped, ?latency: Numeric) -> void
|
|
11
|
+
def poll: () -> bool
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: auva
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: kochab
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '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.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: fiddle
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: zaniah
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.6.0
|
|
47
|
+
- - "<"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0.7'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: 0.6.0
|
|
57
|
+
- - "<"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0.7'
|
|
60
|
+
description: Load JSONC design tokens into Zaniah themes and check WCAG contrast ratios.
|
|
61
|
+
email:
|
|
62
|
+
- t.yudai92@gmail.com
|
|
63
|
+
executables:
|
|
64
|
+
- auva
|
|
65
|
+
extensions: []
|
|
66
|
+
extra_rdoc_files: []
|
|
67
|
+
files:
|
|
68
|
+
- CHANGELOG.md
|
|
69
|
+
- LICENSE.txt
|
|
70
|
+
- README.md
|
|
71
|
+
- Rakefile
|
|
72
|
+
- exe/auva
|
|
73
|
+
- lib/auva.rb
|
|
74
|
+
- lib/auva/version.rb
|
|
75
|
+
- sig/auva.rbs
|
|
76
|
+
homepage: https://github.com/noxdea/auva
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
allowed_push_host: https://rubygems.org
|
|
81
|
+
source_code_uri: https://github.com/noxdea/auva
|
|
82
|
+
rubygems_mfa_required: 'true'
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: 3.2.0
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 4.0.16
|
|
98
|
+
specification_version: 4
|
|
99
|
+
summary: Design token loader and preview utilities for Zaniah
|
|
100
|
+
test_files: []
|