ikura 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: 18b5d492ca45d8e35c47fc6ae5b776465b1c97bbbea3c6bc8685860ff006b638
4
+ data.tar.gz: 48a96a16a338ff94097e3b17c8435ec13c2746568fa5e5cade896cf158e2d623
5
+ SHA512:
6
+ metadata.gz: b28cb7014d7a2896230d81e19b24685f75a625164f48fb289146b27c4d29b06b0759d173d6421da7634b57c07c32c7838b9f918f7f9accba90dd0cad70b38dbe
7
+ data.tar.gz: 541e6c46e6a8708e03610caa899d511caa15db2a191423c2de5f8c9a8306682c803c69af68f4c9e53d198b3b875a6c4181d799d0896dfcd3e3e14b4b02e8e97a
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.2
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-04-04
4
+
5
+ ### Added
6
+
7
+ - Initial release
8
+ - Interactive gunkan-maki sushi toy server
9
+ - Click on the sushi to place ikura (salmon roe) via Ruby running in WebAssembly
10
+ - Turbo Stream-based DOM updates without a frontend framework
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in ikura.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Ikura
2
+
3
+ An interactive gunkan-maki sushi toy server powered by Ruby Wasm.
4
+
5
+ Click anywhere on the sushi in the browser to place ikura (salmon roe). The click handling runs entirely in Ruby via WebAssembly, and DOM updates are delivered as Turbo Streams — no frontend framework required.
6
+
7
+ The server is built on Ruby's built-in `socket` library (TCPServer), so there are **no external runtime dependencies**.
8
+
9
+ ## Installation
10
+
11
+ ```
12
+ gem install ikura
13
+ ```
14
+
15
+ Or add to your Gemfile:
16
+
17
+ ```
18
+ bundle add ikura
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Start the server:
24
+
25
+ ```
26
+ ikura
27
+ ```
28
+
29
+ Then open [http://localhost:8080](http://localhost:8080) in your browser.
30
+
31
+ To use a custom port:
32
+
33
+ ```ruby
34
+ require "ikura"
35
+ Ikura::Server.start(port: 3000)
36
+ ```
37
+
38
+ ## Development
39
+
40
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt.
41
+
42
+ To install this gem onto your local machine:
43
+
44
+ ```
45
+ bundle exec rake install
46
+ ```
47
+
48
+ To release a new version, update `lib/ikura/version.rb`, then run:
49
+
50
+ ```
51
+ bundle exec rake release
52
+ ```
53
+
54
+ ## Contributing
55
+
56
+ Bug reports and pull requests are welcome on GitHub at https://github.com/haruna-tsujita/ikura.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.pattern = "test/**/test_*.rb"
9
+ end
10
+
11
+ task default: %i[test]
data/exe/ikura ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ikura"
5
+
6
+ Ikura::Server.start
@@ -0,0 +1,7 @@
1
+ module Ikura
2
+ module Builder
3
+ def self.append(target, content)
4
+ %(<turbo-stream action="append" target="#{target}"><template>#{content}</template></turbo-stream>)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+ require "erb"
6
+
7
+ module Ikura
8
+ class Server
9
+ TEMPLATE_PATH = File.join(__dir__, "templates", "ikura.html.erb")
10
+
11
+ def self.start(port: 8080)
12
+ new(port:).run
13
+ end
14
+
15
+ COORDS = [
16
+ [50, 50], [35, 50], [65, 50], [20, 50], [80, 50],
17
+ [50, 15], [35, 22], [65, 22], [20, 35], [80, 35],
18
+ [50, 85], [35, 78], [65, 78], [20, 65], [80, 65],
19
+ [10, 50], [90, 50],
20
+ [50, 30], [50, 70], [28, 38], [72, 38], [28, 62], [72, 62],
21
+ ].freeze
22
+
23
+ def initialize(port: 8080)
24
+ @port = port
25
+ @ikura_count = 0
26
+ end
27
+
28
+ def run
29
+ server = TCPServer.new(@port)
30
+ $stdout.sync = true
31
+ puts "🍣 http://localhost:#{@port}"
32
+ puts " (Ctrl+C to stop)\n\n"
33
+
34
+ loop do
35
+ client = server.accept
36
+ req = parse_request(client)
37
+ next unless req
38
+
39
+ puts "#{req[:method]} #{req[:path]}"
40
+ handle(client, req)
41
+ client.close
42
+ rescue => e
43
+ STDERR.puts "Error: #{e}"
44
+ client&.close
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def handle(client, req)
51
+ case [req[:method], req[:path]]
52
+ in ["GET", "/"]
53
+ respond(client, type: "text/html; charset=utf-8", body: html_page)
54
+ in ["POST", "/ikura"]
55
+ coord_idx = parse_form(req[:body])["coord"]&.to_i || 0
56
+ x, y = COORDS[coord_idx] || [50, 50]
57
+ id = @ikura_count
58
+ @ikura_count += 1
59
+
60
+ jx = (x + rand(-8..8)).clamp(5, 95)
61
+ jy = (y + rand(-8..8)).clamp(5, 95)
62
+
63
+ respond(client,
64
+ type: "text/vnd.turbo-stream.html; charset=utf-8",
65
+ body: Ikura::Builder.append("ikura-layer",
66
+ "<li id='ikura_#{id}' class='ikura' style='left:#{jx}%;top:#{jy}%'></li>"))
67
+
68
+ puts " → append ikura_#{id} at (#{jx}%, #{jy}%)"
69
+ else
70
+ respond(client, status: "404 Not Found", type: "text/plain", body: "Not found")
71
+ end
72
+ end
73
+
74
+ def html_page
75
+ coords_html = COORDS.each_with_index.map { |(x, y), i|
76
+ "<div class='coord' style='left:#{x}%;top:#{y}%'></div>"
77
+ }.join("\n")
78
+
79
+ ERB.new(File.read(TEMPLATE_PATH)).result(binding)
80
+ end
81
+
82
+ def parse_request(client)
83
+ line = client.gets
84
+ return nil unless line
85
+
86
+ method, path, _ = line.split(" ")
87
+
88
+ headers = {}
89
+ while (l = client.gets) && l.chomp != ""
90
+ key, val = l.split(": ", 2)
91
+ headers[key.strip.downcase] = val&.strip
92
+ end
93
+
94
+ body = nil
95
+ if (len = headers["content-length"]&.to_i)&.positive?
96
+ body = client.read(len)
97
+ end
98
+
99
+ { method:, path:, headers:, body: }
100
+ end
101
+
102
+ def parse_form(body)
103
+ URI.decode_www_form(body.to_s).to_h
104
+ end
105
+
106
+ def respond(client, status: "200 OK", type:, body:)
107
+ client.print "HTTP/1.1 #{status}\r\n"
108
+ client.print "Content-Type: #{type}\r\n"
109
+ client.print "Content-Length: #{body.bytesize}\r\n"
110
+ client.print "Access-Control-Allow-Origin: *\r\n"
111
+ client.print "Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\n"
112
+ client.print "Access-Control-Allow-Headers: Content-Type, Accept\r\n"
113
+ client.print "Connection: close\r\n"
114
+ client.print "\r\n"
115
+ client.print body
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,114 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>🍣 Ikura</title>
6
+ <script type="module">
7
+ import { DefaultRubyVM } from "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.8.1/dist/browser/+esm";
8
+ const res = await fetch("https://cdn.jsdelivr.net/npm/@ruby/4.0-wasm-wasi@2.8.1/dist/ruby+stdlib.wasm");
9
+ const { vm } = await DefaultRubyVM(await WebAssembly.compileStreaming(res));
10
+ window.rubyVM = vm;
11
+ vm.eval(`
12
+ require "js"
13
+ doc = JS.global[:document]
14
+ nodes = doc.call(:querySelectorAll, ".coord")
15
+ nodes[:length].to_i.times do |i|
16
+ node = nodes.call(:item, i)
17
+ node[:style][:opacity] = "1"
18
+ node.call(:addEventListener, "click", ->(event) {
19
+ opts = JS.eval("return {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
22
+ body: 'coord=#{i}'
23
+ }")
24
+ JS.global.fetch("/ikura", opts).call(:then, ->(response) {
25
+ response.text().call(:then, ->(text) {
26
+ doc = JS.global[:document]
27
+ div = doc.call(:createElement, "div")
28
+ div[:innerHTML] = text.to_s
29
+ div.call(:querySelectorAll, "turbo-stream")[:length].to_i.times do |j|
30
+ el = div.call(:querySelectorAll, "turbo-stream").call(:item, j)
31
+ action = el.call(:getAttribute, "action").to_s
32
+ target = el.call(:getAttribute, "target").to_s
33
+ content = el.call(:querySelector, "template")[:innerHTML].to_s rescue ""
34
+ target_node = doc.call(:getElementById, target)
35
+ case action
36
+ when "append" then target_node.call(:insertAdjacentHTML, "beforeend", content)
37
+ end
38
+ end
39
+ nil
40
+ })
41
+ nil
42
+ })
43
+ nil
44
+ })
45
+ end
46
+ `);
47
+ </script>
48
+ <style>
49
+ * { margin: 0; padding: 0; box-sizing: border-box; }
50
+ body { background: #ffffff; overflow: hidden; width: 100vw; height: 100vh; }
51
+
52
+ #board { position: relative; width: 100vw; height: 100vh; }
53
+
54
+ .gunkan {
55
+ position: absolute;
56
+ left: 50%; top: 50%;
57
+ transform: translate(-50%, -50%);
58
+ width: 140px;
59
+ height: 120px;
60
+ background: #1b2b1b;
61
+ border-radius: 50% / 20%;
62
+ box-shadow: inset 0 -10px 20px rgba(0,0,0,0.5);
63
+ z-index: 10;
64
+ pointer-events: none;
65
+ }
66
+ .gunkan-top {
67
+ position: absolute;
68
+ top: 2px; left: 8px;
69
+ width: 124px; height: 40px;
70
+ background: radial-gradient(ellipse at 50% 60%, #f8f4ec, #e8e0cc);
71
+ border-radius: 50%;
72
+ }
73
+
74
+ .coord {
75
+ position: absolute;
76
+ transform: translate(-50%, -50%);
77
+ width: 16px; height: 16px;
78
+ cursor: crosshair;
79
+ pointer-events: all;
80
+ opacity: 0;
81
+ }
82
+
83
+ #ikura-layer {
84
+ position: absolute; inset: 0;
85
+ pointer-events: none; list-style: none;
86
+ }
87
+
88
+ .ikura {
89
+ position: absolute;
90
+ transform: translate(-50%, -50%);
91
+ width: 14px; height: 14px;
92
+ border-radius: 50%;
93
+ background: radial-gradient(circle at 35% 30%, #ffbb66, #cc2200);
94
+ border: 1px solid rgba(255,180,100,0.3);
95
+ animation: pop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
96
+ }
97
+ @keyframes pop {
98
+ from { transform: translate(-50%, -50%) scale(0); opacity: 0; }
99
+ to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
100
+ }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div id="board">
105
+ <div class="gunkan">
106
+ <div class="gunkan-top">
107
+ <%= coords_html %>
108
+ <ul id="ikura-layer"></ul>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ </body>
114
+ </html>
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ikura
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ikura.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ikura/version"
4
+ require_relative "ikura/builder"
5
+ require_relative "ikura/server"
6
+
7
+ module Ikura
8
+ end
data/sig/ikura.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Ikura
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ikura
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - haruna-tsujita
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: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ description: Ikura is a tiny HTTP server that renders an interactive gunkan-maki sushi
27
+ in the browser. Click anywhere on the sushi to place ikura (salmon roe) using Ruby
28
+ running in WebAssembly.
29
+ email:
30
+ - snwxxx29@gmail.com
31
+ executables:
32
+ - ikura
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".ruby-version"
37
+ - CHANGELOG.md
38
+ - Gemfile
39
+ - README.md
40
+ - Rakefile
41
+ - exe/ikura
42
+ - lib/ikura.rb
43
+ - lib/ikura/builder.rb
44
+ - lib/ikura/server.rb
45
+ - lib/ikura/templates/ikura.html.erb
46
+ - lib/ikura/version.rb
47
+ - sig/ikura.rbs
48
+ homepage: https://github.com/haruna-tsujita/ikura
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ allowed_push_host: https://rubygems.org
53
+ homepage_uri: https://github.com/haruna-tsujita/ikura
54
+ source_code_uri: https://github.com/haruna-tsujita/ikura/tree/master
55
+ changelog_uri: https://github.com/haruna-tsujita/ikura/blob/master/CHANGELOG.md
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 4.0.2
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.6
71
+ specification_version: 4
72
+ summary: An interactive gunkan-maki sushi toy server powered by Ruby Wasm
73
+ test_files: []