metaco 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fafdd0cb9aab0b39bca483167bb12a6caf1bedea3f4270af88badfd21f2e0ab7
4
- data.tar.gz: 4f41f6f4b2ea9b826c799abddf2cfcb09d8d64724e6ac910a45237596774cb8b
3
+ metadata.gz: 338aed008f83c6aed4d0d9e14d2e39176337962507a6e76351f75bfd7c6bb6e2
4
+ data.tar.gz: 9c3a9abed42ab01969d8a57d627d4b6ef9789e7947f0721cb585748ff65cf99c
5
5
  SHA512:
6
- metadata.gz: 229d3371dc99c8248aa8e676264d3f67c240542d22ea676ff9ef90a1ff60e11770d1ff34d059c0eb14f9a3f0b29ce957e247c61df46e556df69c6c22826a47c2
7
- data.tar.gz: e5c641c5d10a0dc278f2405e5f61b8e8fc4097450bf441fe56601953576d963ec8ec7dd30622c8a20be7be8ced73ff61137e31551840548263b01daf3cae58f3
6
+ metadata.gz: 30e20aa8707d371a3ff2836877bebef4afcb2343bd63cf5a915a0add4b339baf35bd200d5e5daf979b34acecf5549c016fa3823f49d0f38a8562233d36e7211a
7
+ data.tar.gz: 8be006a897ee591c15a7d8e3e3e96c5719e6357fdffe2d78000f3f1c2cfa1f39abac050d68d6ba7c25c1274cc2dc15cab40cd9986a8dbad29cfad21ea168e1fc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 - 2026-09-25
6
+
7
+ - Add resizable windows, logical and framebuffer size reporting, Retina-aware dimensions, resize events, and resize-safe pixel/compute resources.
8
+ - Add native Metal input textures, pixel readback, and complete extension API stubs.
9
+ - Release native resources promptly when windows are explicitly destroyed or collected.
10
+ - Wait for outstanding Metal commands before teardown.
11
+ - Clarify that GC cleanup guarantees native resource release while AppKit may retain a closed window object temporarily.
12
+
13
+ ## 0.2.0 - 2026-09-09
14
+
15
+ - Replace integer window pointers with GC-managed `Metaco::Window` handles. Validate closed handles, main-thread access, dimensions, and pixel buffer capacity.
16
+ - Preserve Ruby exception cleanup, UTF-8/NUL key input, and right/middle mouse and drag events. Complete the unsupported-platform API without hiding macOS load errors.
17
+ - Preserve RGBA colors through a Metal render pass, synchronize GPU work without holding the GVL, propagate GPU errors, and validate all rendering/compute allocations.
18
+ - Keep the previous shader on recompilation failure; reject uniforms over 256 bytes and zero unused bytes. Dispatch exactly the image dimensions within pipeline limits.
19
+ - Add native pixel readback and fault-injection regressions; run GUI tests in macOS CI and support mandatory Metal validation on a GPU runner.
20
+
5
21
  ## 0.1.0 - 2026-01-02
6
22
 
7
- - Initial release.
23
+ - Initial release.
data/README.md CHANGED
@@ -1,12 +1,25 @@
1
- # metaco
1
+ <h1 align="center">metaco</h1>
2
2
 
3
- A Ruby C extension providing native macOS window management and Metal GPU acceleration for graphics applications.
3
+ <p align="center">Native macOS window management and Metal GPU acceleration for Ruby.</p>
4
4
 
5
- ## Requirements
5
+ <p align="center">
6
+ <a href="https://rubygems.org/gems/metaco"><img src="https://badge.fury.io/rb/metaco.svg" alt="Gem Version"></a>
7
+ <a href="https://rubygems.org/gems/metaco"><img src="https://img.shields.io/gem/dt/metaco?label=downloads" alt="Downloads"></a>
8
+ <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D3.0-CC342D?logo=ruby&amp;logoColor=white" alt="Ruby Version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="License"></a>
10
+ </p>
6
11
 
7
- - macOS
8
- - Ruby 3.1+
9
- - Xcode Command Line Tools
12
+ [Features](#features) · [Installation](#installation) · [Usage](#usage) · [API Reference](#api-reference)
13
+
14
+ ***
15
+
16
+ Metaco is a Ruby C extension for Cocoa window management, pixel presentation, input events, and Metal GPU compute.
17
+
18
+ ## Features
19
+
20
+ - Native Cocoa window lifecycle and RGBA pixel presentation.
21
+ - Keyboard, mouse, scroll, focus, and resize event polling.
22
+ - Metal compute shaders, texture upload, and pixel readback.
10
23
 
11
24
  ## Installation
12
25
 
@@ -28,6 +41,12 @@ Or install it yourself as:
28
41
  gem install metaco
29
42
  ```
30
43
 
44
+ ### Requirements
45
+
46
+ - macOS
47
+ - Ruby 3.0 or newer
48
+ - Xcode Command Line Tools
49
+
31
50
  ## Usage
32
51
 
33
52
  ### Basic Window
@@ -39,7 +58,7 @@ require "metaco"
39
58
  Metaco.init
40
59
 
41
60
  # Create a window
42
- handle = Metaco.window_create(800, 600, "My Window")
61
+ handle = Metaco.window_create(800, 600, "My Window", resizable: true, high_dpi: true)
43
62
 
44
63
  # Main loop
45
64
  until Metaco.should_close?(handle)
@@ -87,6 +106,7 @@ if Metaco.metal_compute_available?(handle)
87
106
  constant float4 &uniforms [[buffer(0)]],
88
107
  uint2 gid [[thread_position_in_grid]])
89
108
  {
109
+ if (gid.x >= output.get_width() || gid.y >= output.get_height()) return;
90
110
  float2 uv = float2(gid) / float2(output.get_width(), output.get_height());
91
111
  output.write(float4(uv.x, uv.y, uniforms.x, 1.0), gid);
92
112
  }
@@ -114,17 +134,20 @@ Metaco.window_destroy(handle)
114
134
  | Method | Description |
115
135
  |--------|-------------|
116
136
  | `init` | Initialize Cocoa application |
117
- | `window_create(width, height, title)` | Create a new window, returns handle |
118
- | `window_destroy(handle)` | Destroy the window |
137
+ | `window_create(width, height, title, resizable: false, high_dpi: false)` | Create a new window, returns a `Metaco::Window` handle |
138
+ | `window_destroy(handle)` | Close and release the window; repeated calls are safe |
119
139
  | `should_close?(handle)` | Check if window should close |
120
140
  | `poll_events(handle)` | Poll and return pending events |
141
+ | `window_size(handle)` | Return the current logical `[width, height]` |
142
+ | `framebuffer_size(handle)` | Return the current drawable pixel `[width, height]` |
121
143
 
122
144
  ### Rendering
123
145
 
124
146
  | Method | Description |
125
147
  |--------|-------------|
126
148
  | `set_pixels(handle, buffer, width, height)` | Set pixel data (RGBA format) |
127
- | `present(handle)` | Present the frame |
149
+ | `read_pixels(handle, source: :compute)` | Read top-down RGBA8 pixels from `:compute` or `:set_pixels` |
150
+ | `present(handle)` | Present the frame and wait for GPU completion |
128
151
 
129
152
  ### Compute Shaders
130
153
 
@@ -132,9 +155,12 @@ Metaco.window_destroy(handle)
132
155
  |--------|-------------|
133
156
  | `metal_compute_available?(handle)` | Check if Metal compute is available |
134
157
  | `compile_compute_shader(handle, msl_source)` | Compile MSL compute shader |
135
- | `dispatch_compute(handle, uniforms)` | Execute compute shader |
136
- | `present_compute(handle)` | Present compute shader output |
158
+ | `dispatch_compute(handle, uniforms)` | Execute compute shader and wait for GPU completion |
159
+ | `present_compute(handle)` | Present compute shader output and wait for GPU completion |
137
160
  | `has_compute_shader?(handle)` | Check if shader is compiled |
161
+ | `texture_create(handle, width, height, bytes, filter: :linear, wrap: :clamp)` | Upload RGBA8 pixels as a Metal texture |
162
+ | `texture_update(texture, bytes)` / `texture_destroy(texture)` | Replace pixels or release a texture |
163
+ | `bind_compute_texture(handle, index, texture)` | Bind input texture at `texture(index + 1)` |
138
164
 
139
165
  ### Event Types
140
166
 
@@ -143,6 +169,26 @@ Metaco.window_destroy(handle)
143
169
  - `:mouse_press` - Mouse button pressed (`:x`, `:y`, `:button`)
144
170
  - `:mouse_release` - Mouse button released (`:x`, `:y`, `:button`)
145
171
  - `:mouse_move` - Mouse moved (`:x`, `:y`)
172
+ - `:scroll` - Scroll distance in pixels (`:dx`, `:dy`, `:modifiers`). Wheel ticks are scaled by 10; positive `:dy` follows Cocoa's upward convention.
173
+ - `:focus` / `:blur` - Window key focus changed
174
+ - `:resize` - Window size changed (`:width`, `:height`, `:framebuffer_width`, `:framebuffer_height`)
175
+
176
+ Key and mouse events include `:modifiers`, an array of `:shift`, `:control`, `:option`, and `:command`.
177
+
178
+ ### API contracts
179
+
180
+ - Call `init` before creating windows. All native APIs must run on the process's main thread; worker-thread calls raise `ThreadError`. GPU completion waits release Ruby's GVL.
181
+ - Handles are opaque `Metaco::Window` objects, replacing the integer pointers returned by 0.1.0. Pass them unchanged to Metaco methods. A closed handle raises `ArgumentError` on operations other than `window_destroy`; unrelated objects raise `TypeError`.
182
+ - Release windows in an `ensure` block. GC also schedules AppKit cleanup on the main thread when necessary and releases abandoned window resources; `poll_events` services this queue.
183
+ - Width and height must be between 1 and 16,384. `resizable:` is false by default. `window_size` reports logical points; `framebuffer_size` reports drawable pixels, including the backing scale when `high_dpi: true`. `set_pixels` requires the current framebuffer dimensions and at least `width * height * 4` bytes of row-major RGBA data, with unpremultiplied alpha. Extra trailing bytes are ignored. Invalid dimensions or short buffers raise `ArgumentError`.
184
+ - Titles and shader sources must contain valid UTF-8. Key event `:char` strings use UTF-8 and preserve embedded NUL characters. Mouse buttons are numbered 0 (left), 1 (right), and 2 (middle); dragging produces `:mouse_move` events.
185
+ - Compute shaders use the entry point `compute_shader`, output texture 0, and a uniform buffer at index 0. Uniforms may contain 0–256 bytes; remaining bytes are zeroed on every dispatch. Larger inputs raise `ArgumentError`. Shader uniform structures must fit within 256 bytes.
186
+ - Failed compilation preserves the previous shader and its resources. Dispatch and compute presentation require a compiled shader. GPU command failures raise `RuntimeError` after native resources have been cleaned up.
187
+ - Dispatch covers exactly the window's pixels. Threadgroup dimensions may vary by pipeline and image width; shaders must not assume a fixed group size. The example's bounds check also makes it safe with other dispatch implementations.
188
+ - `read_pixels` copies the selected texture into CPU-visible memory before returning. It requires an open window and a completed render. GPU readback has not been verified on a Metal device in headless CI.
189
+ - Textures belong to the window that created them. Uploads require exactly `width * height * 4` RGBA8 bytes. Input textures use linear sampling and clamp addressing; other options raise `ArgumentError`. Destroyed textures cannot be updated or bound.
190
+ - Presentation is synchronous so pixel uploads cannot overwrite an in-flight frame. An occluded window may have no drawable, in which case presentation is skipped. Rendering resource allocation failures select the bitmap fallback; compute availability then returns `false`.
191
+ - On unsupported platforms both compute availability queries return `false`, and other APIs raise `LoadError`. On macOS, native extension loading errors retain their original diagnostics.
146
192
 
147
193
  ## Development
148
194
 
@@ -155,8 +201,19 @@ bundle exec rake compile
155
201
 
156
202
  # Run tests
157
203
  bundle exec rake test
204
+
205
+ # Require actual GPU execution and validate Metal commands/shaders
206
+ METACO_REQUIRE_METAL=1 MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 bundle exec rake test
158
207
  ```
159
208
 
209
+ The test task includes a separate native test extension for GPU pixel readback, allocation and command failure injection, Unicode input, and GC/exception cleanup. These helpers are excluded from the gem. CI runs window and bitmap tests on macOS even when Metal is unavailable; only GPU-specific tests are omitted in that case. Set `METACO_SKIP_GUI=1` explicitly for a session without WindowServer. Argument and fallback tests still run.
210
+
211
+ The optional `run_metal` workflow-dispatch input runs the same suite with mandatory GPU execution on a self-hosted runner labeled `macOS` and `metal`. It requires a logged-in GUI session and a Metal device.
212
+
213
+ ## Contributing
214
+
215
+ Bug reports and pull requests are welcome at [rbgfx/metaco](https://github.com/rbgfx/metaco).
216
+
160
217
  ## License
161
218
 
162
219
  The gem is available as open source under the terms of the [MIT License](LICENSE).
data/Rakefile CHANGED
@@ -3,6 +3,7 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/extensiontask"
5
5
  require "rake/testtask"
6
+ require "rbconfig"
6
7
 
7
8
  Rake::ExtensionTask.new("metaco") do |ext|
8
9
  ext.lib_dir = "lib/metaco"
@@ -14,5 +15,19 @@ Rake::TestTask.new(:test) do |t|
14
15
  t.test_files = FileList["test/**/test_*.rb"]
15
16
  end
16
17
 
17
- task test: :compile
18
+ desc "Run native regression tests with GPU readback and fault injection"
19
+ task :test_native do
20
+ next unless RUBY_PLATFORM.include?("darwin")
21
+
22
+ build_dir = File.expand_path("tmp/native/#{RUBY_PLATFORM}/#{RUBY_VERSION}", __dir__)
23
+ mkdir_p build_dir
24
+ Dir.chdir(build_dir) do
25
+ ruby File.expand_path("test/native/extconf.rb", __dir__)
26
+ sh RbConfig::CONFIG.fetch("MAKE", "make")
27
+ end
28
+ ruby "-I#{build_dir}", "test/native/regressions.rb"
29
+ end
30
+
31
+ task test: [:compile, :test_native]
18
32
  task default: :test
33
+ task verify: :test
data/docs/index.html ADDED
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en"><head>
3
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <meta name="theme-color" content="#020617"><meta name="description" content="A Ruby C extension for macOS windows, pixel presentation, event polling, and Metal compute.">
5
+ <meta property="og:title" content="Metaco — A native window, from Ruby."><meta property="og:description" content="A Ruby C extension for macOS windows, pixel presentation, event polling, and Metal compute."><meta property="og:type" content="website"><meta property="og:url" content="https://rbgfx.github.io/metaco/">
6
+ <meta name="twitter:card" content="summary"><link rel="canonical" href="https://rbgfx.github.io/metaco/"><title>Metaco — A native window, from Ruby.</title>
7
+ <style>
8
+ :root{color-scheme:dark;--bg:#020617;--panel:#0f172a;--line:#334155;--ink:#e2e8f0;--muted:#94a3b8;--a:#38bdf8;--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--sans:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--w:min(100% - 2.5rem,76rem)}
9
+ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 var(--sans)}a{color:inherit;text-underline-offset:.22em}a:hover{color:var(--a)}a:focus-visible{outline:3px solid var(--a);outline-offset:4px}h1,h2,h3,p,figure,pre{margin:0}h1,h2,h3{line-height:1.08;text-wrap:balance}p{text-wrap:pretty}code,pre,.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}.wrap{width:var(--w);margin:auto}.skip{position:fixed;left:1rem;top:1rem;z-index:1;transform:translateY(-160%);padding:.7rem 1rem;background:var(--ink);color:var(--bg)}.skip:focus{transform:none}
10
+ header{height:4.75rem;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:.7rem;font-weight:750;text-decoration:none}.mark{width:2rem;height:2rem;display:grid;place-items:center;border:1px solid var(--a);color:var(--a);font:.85rem var(--mono)}nav{display:flex;gap:1.5rem;font-size:.9rem}nav a{text-decoration:none;color:var(--muted)}nav a:last-child{color:var(--ink)}
11
+ .hero{display:grid;grid-template-columns:.88fr 1.12fr;align-items:center;gap:clamp(2rem,6vw,6rem);padding-block:clamp(4rem,9vw,8rem)}.kicker,.visual figcaption,.num{font:.76rem/1.4 var(--mono);color:var(--a)}h1{max-width:11ch;margin-top:1rem;font-size:clamp(3rem,7.5vw,6.5rem);letter-spacing:-.065em}.lede{max-width:40rem;margin-top:1.5rem;color:var(--muted);font-size:1.15rem}.actions{display:flex;flex-wrap:wrap;gap:.8rem;margin-top:2rem}.button{min-height:2.8rem;padding:.65rem 1rem;border:1px solid var(--line);display:inline-flex;align-items:center;text-decoration:none;font-weight:650}.button.primary{background:var(--a);border-color:var(--a);color:var(--bg)}.install{margin-top:1.4rem;color:var(--muted);font:.86rem var(--mono)}.install code{color:var(--ink)}
12
+ .visual{border:1px solid var(--line);background:var(--panel)}.art{min-height:18rem;padding:1.25rem;display:grid;place-items:center;overflow:hidden}.art svg{width:100%;max-height:23rem}.visual figcaption{display:flex;justify-content:space-between;gap:1rem;padding:.75rem 1rem;border-top:1px solid var(--line);color:var(--muted)}.visual figcaption span:last-child{color:var(--a)}
13
+ section.info{padding-block:4.5rem;border-top:1px solid var(--line)}.section-head{display:grid;grid-template-columns:.72fr 1.28fr;gap:2rem;align-items:end;margin-bottom:2rem}h2{font-size:clamp(2rem,4vw,3.25rem);letter-spacing:-.045em}.section-head p,.feature p,.try-copy p{color:var(--muted)}.features{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line)}.feature{padding:1.25rem 1.2rem 1.4rem 0;border-bottom:1px solid var(--line)}.feature+.feature{padding-left:1.2rem;border-left:1px solid var(--line)}.feature h3{margin-top:.8rem;font-size:1.1rem}.feature p{margin-top:.5rem;font-size:.94rem}.try{display:grid;grid-template-columns:.75fr 1.25fr;align-items:center;gap:clamp(2rem,7vw,7rem)}.try-copy p{margin-top:1rem}.try-copy a{color:var(--ink)}pre{overflow-x:auto;padding:1.25rem;border:1px solid var(--line);background:var(--panel);font-size:.82rem;line-height:1.8;tab-size:2}
14
+ footer{display:flex;justify-content:space-between;gap:1rem;padding-block:1.6rem;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}footer div{display:flex;gap:1.25rem}
15
+ @media(max-width:800px){.hero{grid-template-columns:1fr;gap:2.5rem}.hero-copy{max-width:42rem}.section-head,.try{grid-template-columns:1fr;gap:1rem}}@media(max-width:560px){:root{--w:min(100% - 1.5rem,76rem)}header{height:4.2rem}nav{gap:.8rem;font-size:.8rem}nav a:first-child{display:none}h1{font-size:clamp(3rem,15vw,5rem)}.features{grid-template-columns:1fr}.feature,.feature+.feature{padding:1.1rem 0;border-left:0}.num{display:inline-block}footer{flex-direction:column}.art{min-height:14rem}section.info{padding-block:3.5rem}pre{font-size:.75rem;padding:1rem}}
16
+ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
17
+ .window{width:100%;max-width:32rem;border:1px solid #475569;background:#020617}.window header{height:auto;padding:.7rem;border-bottom:1px solid #334155;font:.7rem var(--mono);color:#94a3b8}.window svg{display:block;padding:1rem}.window footer{justify-content:center;padding:.7rem;border-top:1px solid #334155;font:.7rem var(--mono);color:#fb923c}
18
+ </style>
19
+ </head><body style="--a:#fb923c"><a class="skip" href="#main">Skip to content</a><div class="wrap">
20
+ <header><a class="brand" href="#top" aria-label="Metaco home"><span class="mark" aria-hidden="true">M</span><span>Metaco</span></a><nav aria-label="Primary navigation"><a href="#features">Overview</a><a href="#quick-start">Quick start</a><a href="https://github.com/rbgfx/metaco">Source ↗</a></nav></header>
21
+ <main id="main"><section class="hero" id="top" aria-labelledby="title"><div class="hero-copy"><p class="kicker">Native macOS · Cocoa · Metal</p><h1 id="title">A native window, from Ruby.</h1><p class="lede">A Ruby C extension for macOS windows, pixel presentation, event polling, and Metal compute.</p><div class="actions"><a class="button primary" href="#quick-start">Get started</a><a class="button" href="https://rubygems.org/gems/metaco">RubyGems ↗</a></div><p class="install"><span aria-hidden="true">$</span> <code>gem install metaco</code></p></div>
22
+ <figure class="visual" aria-labelledby="cap-metaco"><div class="art"><div class="window"><header><span>● ● ●</span><span>Ruby Graphics</span><span>macOS</span></header><svg viewBox="0 0 360 190" aria-hidden="true"><rect width="360" height="190" fill="#111b2d"/><path d="M180 28L314 162H46Z" fill="#431407" stroke="#fb923c" stroke-width="2"/><path d="M180 28L180 162M46 162L247 95M314 162L113 95" stroke="#7c2d12"/></svg><footer>Ruby → Cocoa → Metal</footer></div></div><figcaption id="cap-metaco"><span>Cocoa window</span><span>Ruby calls → native APIs</span></figcaption></figure></section>
23
+ <section class="info" id="features" aria-labelledby="feat"><div class="section-head"><h2 id="feat">A focused bridge to the host.</h2><p>Create a native window, push RGBA pixels, receive input, or run Metal compute.</p></div><div class="features"><article class="feature"><span class="num">01</span><h3>Open a Cocoa window</h3><p>Create, update, present, and destroy windows from Ruby.</p></article><article class="feature"><span class="num">02</span><h3>Handle events</h3><p>Poll keyboard and mouse data in the application loop.</p></article><article class="feature"><span class="num">03</span><h3>Use Metal compute</h3><p>Check support, compile MSL, and dispatch compute work.</p></article></div></section>
24
+ <section class="info try" id="quick-start" aria-labelledby="quick"><div class="try-copy"><p class="kicker">A small first step</p><h2 id="quick">Start with one example.</h2><p>Install the gem, then follow the project guide for platform requirements and the full API.</p><p><a href="https://github.com/rbgfx/metaco#installation">Read the installation guide ↗</a></p></div><pre><code>require &quot;metaco&quot;
25
+
26
+ Metaco.init
27
+ handle = Metaco.window_create(
28
+ 800, 600, &quot;My Window&quot;,
29
+ resizable: true, high_dpi: true
30
+ )
31
+ begin
32
+ # rgba_bytes contains width * height RGBA pixels.
33
+ Metaco.set_pixels(handle, rgba_bytes, 800, 600)
34
+ Metaco.present(handle)
35
+ ensure
36
+ Metaco.window_destroy(handle)
37
+ end</code></pre></section></main>
38
+ <footer><span>Metaco · Ruby 3.0+ · MIT</span><div><a href="https://github.com/rbgfx/metaco">GitHub</a><a href="https://github.com/rbgfx/metaco/blob/main/LICENSE">License</a></div></footer></div></body></html>