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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-20
4
+
5
+ - Initial release.
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/auva ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "auva"
6
+ exit Auva::CLI.run(ARGV)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auva
4
+ VERSION = "0.1.0"
5
+ end
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: []