studio-engine 0.100.0.rc1 → 0.101.0.rc1

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: ccf0cc77e250c21faca747741337d7b492defe5b3af054169b2e78a89a0908a8
4
- data.tar.gz: 9fcfe0bcdcfd78e53fc67e0d627cabaf40c822428d997756451ad96b11c34f2d
3
+ metadata.gz: 2c4e934f3894dd00a374f95b4fbe030e95fcb1f2fc88a5838703c5aed25244c5
4
+ data.tar.gz: ece60cbff86a6e9bfe6436fbf41fedd8ef0f89a3c1bb907fc5f6990e07910cf2
5
5
  SHA512:
6
- metadata.gz: 8fa59ab9b1cc9538065e73d21264a7cf49b9afb21d0a4e030091ffe4edaf19a66f511fa00a4a17c8303c0531af1866cfa94e343b7725ed40a187f605a04e7aed
7
- data.tar.gz: a90ebd5d35c3f7ab4964bf7ca484d760d44d8fd9ced4c44f3f05dd3423c57c89e972685ed8bb6cadd20ee4eda313980e1312f30503b2845e3270ee27bb01b0fb
6
+ metadata.gz: 596c703216999cf57ceaa621439093da0c4763b38c8fc4e60147594d623920964bf4d2315a947eb82aeeedba70713a01d00beef3e76351cc640af296f434756d
7
+ data.tar.gz: 9ec306df7f9f0375c5056aaa895594874cd94b10c486cefc84fa2ca69d383deb993b54c892383ccb020da65efb7851cdef58885e9677abdd33eedb5ebe2619fd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.101.0 — 2026-10-10
8
+
9
+ ### Breaking
10
+
11
+ - **The hold button takes no JavaScript-string locals.** `studio/_hold_button`
12
+ no longer writes them to `data-*` attributes and `studio/hold_button_hooks`
13
+ no longer evaluates them, so the button needs no `unsafe-eval`. Each removed
14
+ local has an event the button dispatches (they bubble, and every
15
+ `event.detail` carries `{ id }`, the button's `hold_id`):
16
+
17
+ | Removed local | Attribute it wrote | Answer this event |
18
+ |---|---|---|
19
+ | `guard:` | `data-guard` | `hold-button:guard`: `preventDefault()` refuses the hold |
20
+ | `on_hold_start:` | `data-on-hold-start` | `hold-button:start` |
21
+ | `validate:` | `data-validate` | `hold-button:validate`: `event.detail.waitUntil(answer)`, a boolean or a promise of one |
22
+ | `early_action:` | `data-early-action` | `hold-button:early`: `preventDefault()` takes the action over |
23
+ | `early_action_guard:` | `data-early-action-guard` | `hold-button:early`: decide in the listener, and call `preventDefault()` only when the action runs |
24
+ | `on_success:` | `data-on-success` | `hold-button:success`: `preventDefault()` keeps the button's state for the listener |
25
+
26
+ `validate_at:` and `early_action_at:` stay, and time the events.
27
+
28
+ A call site that still passes one fails loudly:
29
+ - `render "studio/hold_button"` with a removed local that is not `nil` raises
30
+ `ArgumentError` naming each local and its event, in every environment
31
+ (`Studio::HoldButton.refuse_removed_locals!`).
32
+ - A `.hold-btn` that carries one of the attributes by hand refuses every
33
+ press, dispatches nothing, and logs a console error naming the attribute
34
+ and its event.
35
+
36
+ `SUCCESS_SETTLE_MS` is no longer exported by `studio/hold_button_hooks`:
37
+ `hold-button:success` is dispatched at the full duration, with no half-second
38
+ delay. The module no longer reads `button.dataset`, so a consumer test that
39
+ scans it for the attribute names it evaluates finds none.
40
+
41
+ ### Added
42
+
43
+ - **The navbar takes a `brand_heading` local.** `layouts/_navbar` draws the
44
+ brand title as the page's `<h1>` by default, exactly as before. Pass
45
+ `brand_heading: false` on a page that carries its own h1 and the brand is drawn
46
+ as a `<div>` with the same classes and contents; no other byte of the navbar
47
+ changes. Styling keys on `.nav-title`, never on the element name.
48
+
7
49
  ## 0.100.0 — 2026-10-10
8
50
 
9
51
  ### Added
@@ -10,7 +10,7 @@
10
10
  // touchend->hold-button#end touchcancel->hold-button#end">
11
11
  //
12
12
  // The timeline, the idle nudge and the fizz portal are studio/hold_button; the
13
- // events and string locals the timeline asks are studio/hold_button_hooks.
13
+ // events the timeline asks through are studio/hold_button_hooks.
14
14
  import { Controller } from "@hotwired/stimulus"
15
15
  import { Hold, mountPortal } from "studio/hold_button"
16
16
  import { holdHooks } from "studio/hold_button_hooks"
@@ -1,70 +1,50 @@
1
1
  // studio/hold_button_hooks: how one hold button asks its page the timeline's
2
- // questions (studio/hold_button). Two voices, both heard on every press:
2
+ // questions (studio/hold_button). It dispatches events on the button, which
3
+ // bubble:
3
4
  //
4
- // 1. EVENTS, dispatched on the button and bubbling. This is the API:
5
+ // hold-button:guard at the press. preventDefault() refuses the hold.
6
+ // hold-button:start the hold began.
7
+ // hold-button:validate at validate_at. event.detail.waitUntil(answer)
8
+ // takes a boolean or a promise of one; a false or
9
+ // failed answer aborts the hold.
10
+ // hold-button:early at early_action_at. preventDefault() takes the
11
+ // action over: the hold never completes.
12
+ // hold-button:success at the full duration. preventDefault() keeps the
13
+ // button in its holding state for the listener to
14
+ // resolve; otherwise it shows its success face.
5
15
  //
6
- // hold-button:guard at the press. preventDefault() refuses the hold.
7
- // hold-button:start the hold began.
8
- // hold-button:validate at validate_at. event.detail.waitUntil(answer)
9
- // takes a boolean or a promise of one; a false or
10
- // failed answer aborts the hold.
11
- // hold-button:early at early_action_at. preventDefault() takes the
12
- // action over: the hold never completes.
13
- // hold-button:success at the full duration. preventDefault() keeps the
14
- // button in its holding state for the listener to
15
- // resolve; otherwise it shows its success face.
16
+ // Every event's detail carries { id }, the button's hold_id.
16
17
  //
17
- // Every event's detail carries { id }, the button's hold_id.
18
+ // <div @hold-button:guard="ready || $event.preventDefault()"
19
+ // @hold-button:success="submit()">
20
+ // <%= render "studio/hold_button", hold_id: "confirm" %>
21
+ // </div>
18
22
  //
19
- // <div @hold-button:guard="ready || $event.preventDefault()"
20
- // @hold-button:success="submit()">
21
- // <%= render "studio/hold_button", hold_id: "confirm" %>
22
- // </div>
23
+ // It evaluates nothing. A button that carries one of REMOVED_HOOKS' attributes
24
+ // was written for the JavaScript-string locals this module no longer runs: every
25
+ // press on it is refused and says so on the console.
23
26
  //
24
- // 2. STRING LOCALS, the partial's guard:, on_hold_start:, validate:,
25
- // early_action:, early_action_guard: and on_success:, which arrive as
26
- // data-* attributes and are evaluated against the nearest [x-data] scope
27
- // with `d` and `data` aliased to its data. They stay while a consumer
28
- // passes them (turf-monster does), and go when none does.
29
- //
30
- // Either voice can refuse: a hold needs every guard to pass. With an [x-data]
31
- // ancestor a press is also refused while that scope's `submitting` is true, so
32
- // a second hold cannot double-submit.
27
+ // With an [x-data] ancestor a press is also refused while that scope's
28
+ // `submitting` is true, so a second hold cannot double-submit.
33
29
  //
34
30
  // It imports nothing; test/javascript/hold_button_hooks.test.mjs loads it as a
35
31
  // data: module.
36
32
 
37
- // How long the button stays in its holding state before a string on_success
38
- // runs.
39
- export const SUCCESS_SETTLE_MS = 500
40
-
41
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
42
-
43
- // An expression against an Alpine scope, synchronously.
44
- function evaluate(Alpine, scope, expression) {
45
- const data = Alpine.$data(scope)
46
- return Alpine.evaluate(scope, expression, { scope: { d: data, data } })
47
- }
48
-
49
- // The same for an expression that may return a promise. Alpine.evaluate
50
- // returns before an async expression settles, so this compiles its own
51
- // function with the names an expression may use.
52
- function evaluateAsync(Alpine, win, scope, expression) {
53
- const data = Alpine.$data(scope)
54
- try {
55
- const fn = new AsyncFunction("d", "data", "Alpine", "window", '"use strict"; return (' + expression + ");")
56
- return fn(data, data, Alpine, win)
57
- } catch (error) {
58
- return Promise.reject(error)
59
- }
33
+ // Attribute => [the removed local that wrote it, the event that answers now].
34
+ // Studio::HoldButton::REMOVED_LOCALS is the same table for the partial.
35
+ export const REMOVED_HOOKS = {
36
+ "data-guard": ["guard", "hold-button:guard"],
37
+ "data-on-hold-start": ["on_hold_start", "hold-button:start"],
38
+ "data-validate": ["validate", "hold-button:validate"],
39
+ "data-early-action": ["early_action", "hold-button:early"],
40
+ "data-early-action-guard": ["early_action_guard", "hold-button:early"],
41
+ "data-on-success": ["on_success", "hold-button:success"]
60
42
  }
61
43
 
62
44
  // The hooks a Hold takes, for one button.
63
45
  export function holdHooks(button, { win } = {}) {
64
46
  const host = win || globalThis
65
- const id = () => button.dataset.holdId || "hold"
66
- const warn = (what, error) => host.console.warn(`[hold:${id()}] ${what}`, error || "")
67
- const fail = (what, error) => host.console.error(`[hold:${id()}] ${what}`, error)
47
+ const id = () => button.getAttribute("data-hold-id") || "hold"
68
48
 
69
49
  const fire = (name, detail = {}) => {
70
50
  const event = new host.CustomEvent(`hold-button:${name}`, {
@@ -74,89 +54,50 @@ export function holdHooks(button, { win } = {}) {
74
54
  return event
75
55
  }
76
56
 
77
- const scope = () => button.closest("[x-data]")
78
- // A string local with nothing to evaluate it against cannot answer.
79
- const scopeFor = (name) => {
80
- const found = host.Alpine ? scope() : null
81
- if (!found) warn(`${name}: no Alpine scope to evaluate it in`)
82
- return found
57
+ // True, and reported, when the button carries a removed hook.
58
+ const stale = () => {
59
+ const found = Object.keys(REMOVED_HOOKS).filter((attribute) => button.hasAttribute(attribute))
60
+ for (const attribute of found) {
61
+ const [local, event] = REMOVED_HOOKS[attribute]
62
+ host.console.error(`[hold:${id()}] ${attribute} (the ${local}: local) is no longer evaluated; ` +
63
+ `answer ${event} instead. The hold is refused.`)
64
+ }
65
+ return found.length > 0
83
66
  }
84
67
 
85
68
  return {
86
- // Refused by the scope's `submitting`, by the guard string, or by a
87
- // hold-button:guard listener. A guard string that cannot be evaluated
88
- // refuses: the caller asked for a check that did not run.
69
+ // Refused by a removed hook, by the scope's `submitting`, or by a
70
+ // hold-button:guard listener.
89
71
  guard() {
90
- const found = host.Alpine ? scope() : null
72
+ if (stale()) return false
73
+ const found = host.Alpine ? button.closest("[x-data]") : null
91
74
  if (found) {
92
75
  const data = host.Alpine.$data(found)
93
76
  if (data && data.submitting) return false
94
77
  }
95
- const expression = button.dataset.guard
96
- if (expression) {
97
- if (!found) { warn("guard: no Alpine scope to evaluate it in; the hold is refused"); return false }
98
- if (!evaluate(host.Alpine, found, expression)) return false
99
- }
100
78
  return !fire("guard").defaultPrevented
101
79
  },
102
80
 
103
81
  started() {
104
- const expression = button.dataset.onHoldStart
105
- const found = expression ? scopeFor("on_hold_start") : null
106
- if (found) {
107
- try { evaluate(host.Alpine, found, expression) } catch (error) { fail("on_hold_start threw", error) }
108
- }
109
82
  fire("start")
110
83
  },
111
84
 
112
- // True unless the validate string or a hold-button:validate listener says
113
- // no. Rejects when either fails, which aborts the hold.
85
+ // True unless a hold-button:validate listener says no. Rejects when one
86
+ // fails, which aborts the hold.
114
87
  validate() {
115
88
  const answers = []
116
- const expression = button.dataset.validate
117
- if (expression) {
118
- const found = scopeFor("validate")
119
- if (found) answers.push(evaluateAsync(host.Alpine, host, found, expression))
120
- }
121
89
  fire("validate", { waitUntil: (answer) => { answers.push(answer) } })
122
90
  return Promise.all(answers).then((all) => all.every(Boolean))
123
91
  },
124
92
 
125
- // True when the early action ran, or a hold-button:early listener took
126
- // the action over.
93
+ // True when a hold-button:early listener took the action over.
127
94
  early() {
128
- let taken = false
129
- const expression = button.dataset.earlyAction
130
- const found = expression ? scopeFor("early_action") : null
131
- if (found) {
132
- const guard = button.dataset.earlyActionGuard
133
- if (!guard || evaluate(host.Alpine, found, guard)) {
134
- taken = true
135
- try { evaluate(host.Alpine, found, expression) } catch (error) { fail("early_action threw", error) }
136
- }
137
- }
138
- return fire("early").defaultPrevented || taken
95
+ return fire("early").defaultPrevented
139
96
  },
140
97
 
141
- // True when a caller owns the button's state from here. A string
142
- // on_success runs SUCCESS_SETTLE_MS later, with the button still in its
143
- // holding state; if it throws, the button shows its success face.
98
+ // True when a listener owns the button's state from here.
144
99
  completed() {
145
- const event = fire("success")
146
- const expression = button.dataset.onSuccess
147
- if (!expression) return event.defaultPrevented
148
-
149
- host.setTimeout(() => {
150
- try {
151
- const found = scopeFor("on_success")
152
- if (found) evaluate(host.Alpine, found, expression)
153
- } catch (error) {
154
- fail("on_success threw", error)
155
- button.classList.remove("process")
156
- button.classList.add("success")
157
- }
158
- }, SUCCESS_SETTLE_MS)
159
- return true
100
+ return fire("success").defaultPrevented
160
101
  }
161
102
  }
162
103
  }
@@ -5,6 +5,9 @@
5
5
  balance_html — raw HTML for balance display (passed through to _user_nav)
6
6
  extra_icons_html — raw HTML for extra icon buttons (passed through to _user_nav)
7
7
  show_logout_link — boolean, show "Log out" link in user nav (passed through to _user_nav)
8
+ brand_heading — boolean, draw the brand title as the page's h1 (default: true).
9
+ Pass false on a page that carries its own h1: the brand is
10
+ drawn as a div with the same classes and contents.
8
11
  %>
9
12
  <% show_user = local_assigns.fetch(:show_logged_in, logged_in?) %>
10
13
  <% is_preview = local_assigns.fetch(:preview, false) %>
@@ -19,6 +22,7 @@
19
22
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
20
23
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
21
24
  <% show_logout_link = local_assigns.fetch(:show_logout_link, false) %>
25
+ <% brand_tag = local_assigns.fetch(:brand_heading, true) ? "h1" : "div" %>
22
26
  <%# Out-of-the-box navigation (engine 0.30): when the host declares
23
27
  Studio.sidebar_sections, the navbar mounts the link-sidebar trigger in its
24
28
  icon rails and renders the slide-out panels after the header. With no
@@ -150,7 +154,7 @@
150
154
  overflow that has to be clipped belongs to each word's own box —
151
155
  `truncate` on the h1 would clip the flex CONTAINER and leave the
152
156
  spans drawing outside it, which is the bug rather than the fix. %>
153
- <h1 class="nav-title font-extrabold text-heading tracking-tight min-w-0"><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span></h1>
157
+ <<%= brand_tag %> class="nav-title font-extrabold text-heading tracking-tight min-w-0"><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span></<%= brand_tag %>>
154
158
  <% end %>
155
159
  <%# Desktop nav — Studio.navbar_links %>
156
160
  <nav class="hidden md:flex items-center gap-4"<%= ' aria-label="Main"'.html_safe if navbar_links.any? %>>
@@ -20,11 +20,9 @@
20
20
  hold-button:success at the full duration; preventDefault() leaves the
21
21
  button's state to the listener
22
22
 
23
- The JS-expression locals below (guard, on_success, validate, early_action,
24
- early_action_guard, on_hold_start) still work: they travel as data-*
25
- attributes and are evaluated against the surrounding x-data scope, with `d`
26
- and `data` aliased to its data. They stay while a consumer passes them;
27
- write new call sites against the events.
23
+ It takes no JavaScript: the guard, on_hold_start, validate, early_action,
24
+ early_action_guard and on_success locals are removed, and passing one raises
25
+ (Studio::HoldButton). Answer the event instead.
28
26
 
29
27
  Locals:
30
28
  default_text - Button label in idle state (default: "Hold to Confirm")
@@ -32,21 +30,10 @@
32
30
  success_text - Text shown on completion (default: "Confirmed!")
33
31
  duration - Hold duration in ms (default: 2000)
34
32
  hold_id - Unique ID for this button instance (default: "hold")
35
- guard - JS expression that must be truthy to allow hold (default: none = always allow)
36
- on_success - JS expression executed on successful hold (default: none)
37
- validate - JS expression returning Promise<boolean>, called at validate_at ms (default: none)
38
- validate_at - ms into the hold at which validation runs: the validate
39
- string and the hold-button:validate event (default: 750)
40
- early_action - JS expression executed early (for Web3 users) (default: none)
41
- early_action_at - ms into the hold at which the early action runs: the
42
- early_action string and the hold-button:early event
43
- (default: 1500)
44
- early_action_guard - JS expression that must be truthy for early action (default: none)
45
- on_hold_start - JS expression executed the instant a hold BEGINS, after
46
- the guard/submitting checks pass (default: none). Used to
47
- kick off work that should run in parallel with the hold
48
- (e.g. the board's authoritative funding pre-check fetch,
49
- resolved by the time the hold completes ~2s later).
33
+ validate_at - ms into the hold at which hold-button:validate is
34
+ dispatched (default: 750)
35
+ early_action_at - ms into the hold at which hold-button:early is
36
+ dispatched (default: 1500)
50
37
  fizz - Render the fizz particle layer (default: true). Pass
51
38
  false for a flat button. Specimens: /admin/style.
52
39
  fizz_level - :lively (default) or :calm. Lively rests at a full
@@ -69,20 +56,15 @@
69
56
  the six picked teams' light / dark / alt colors onto
70
57
  --fizz-c-1..18, three per zone in pick order.
71
58
  %>
59
+ <% Studio::HoldButton.refuse_removed_locals!(local_assigns) %>
72
60
  <% default_text ||= "Hold to Confirm" %>
73
61
  <% hold_text ||= "Almost there..." %>
74
62
  <% success_text ||= "Confirmed!" %>
75
63
  <% error_text ||= "Entry Blocked" %>
76
64
  <% duration ||= 2000 %>
77
65
  <% hold_id ||= "hold" %>
78
- <% guard ||= nil %>
79
- <% on_success ||= nil %>
80
- <% validate ||= nil %>
81
66
  <% validate_at ||= 750 %>
82
- <% early_action ||= nil %>
83
67
  <% early_action_at ||= 1500 %>
84
- <% early_action_guard ||= nil %>
85
- <% on_hold_start ||= nil %>
86
68
  <%# `fizz ||= true` would coerce an explicit `fizz: false` back to true, so
87
69
  read the key instead of the value. %>
88
70
  <% fizz = local_assigns.key?(:fizz) ? local_assigns[:fizz] : true %>
@@ -125,12 +107,8 @@
125
107
  <button class="hold-btn"
126
108
  data-hold-id="<%= hold_id %>"
127
109
  data-duration="<%= duration %>"
128
- <% if guard %>data-guard="<%= guard %>"<% end %>
129
- <% if on_hold_start %>data-on-hold-start="<%= on_hold_start %>"<% end %>
130
- <% if on_success %>data-on-success="<%= on_success %>"<% end %>
131
- <% if validate %>data-validate="<%= validate %>" data-validate-at="<%= validate_at %>"<% elsif local_assigns.key?(:validate_at) %>data-validate-at="<%= validate_at %>"<% end %>
132
- <% if early_action %>data-early-action="<%= early_action %>" data-early-action-at="<%= early_action_at %>"<% elsif local_assigns.key?(:early_action_at) %>data-early-action-at="<%= early_action_at %>"<% end %>
133
- <% if early_action_guard %>data-early-action-guard="<%= early_action_guard %>"<% end %>
110
+ <% if local_assigns.key?(:validate_at) %>data-validate-at="<%= validate_at %>"<% end %>
111
+ <% if local_assigns.key?(:early_action_at) %>data-early-action-at="<%= early_action_at %>"<% end %>
134
112
  style="--duration: <%= duration %>ms"
135
113
  data-studio-action="mousedown->hold-button#start mouseup->hold-button#end mouseleave->hold-button#end touchstart->hold-button#press touchend->hold-button#end touchcancel->hold-button#end">
136
114
  <div class="hold-icon">
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The locals studio/_hold_button no longer takes. Each carried JavaScript as a
5
+ # string for the browser to evaluate; a page answers the event instead.
6
+ module HoldButton
7
+ # Local => the event that answers now. The browser's half of this table is
8
+ # REMOVED_HOOKS in app/javascript/studio/hold_button_hooks.js.
9
+ REMOVED_LOCALS = {
10
+ guard: "hold-button:guard",
11
+ on_hold_start: "hold-button:start",
12
+ validate: "hold-button:validate",
13
+ early_action: "hold-button:early",
14
+ early_action_guard: "hold-button:early",
15
+ on_success: "hold-button:success"
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ # Raises when a caller passes a removed local, in every environment: a
21
+ # button rendered without the check its caller asked for must not reach a
22
+ # page.
23
+ def refuse_removed_locals!(locals)
24
+ passed = REMOVED_LOCALS.select { |name, _| !locals[name].nil? }
25
+ return if passed.empty?
26
+
27
+ named = passed.map { |name, event| "#{name}: (answer #{event})" }.join(", ")
28
+ raise ArgumentError, "studio/hold_button no longer evaluates JavaScript-string locals. Remove #{named}. " \
29
+ "The events are listed at the top of app/views/studio/_hold_button.html.erb."
30
+ end
31
+ end
32
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.100.0.rc1"
2
+ VERSION = "0.101.0.rc1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -26,6 +26,7 @@ require "studio/theme_resolver"
26
26
  require "studio/ui_primitives"
27
27
  require "studio/js_literal"
28
28
  require "studio/js_identifier"
29
+ require "studio/hold_button"
29
30
  require "studio/partial_block"
30
31
  require "studio/sidebar_sections"
31
32
  require "studio/site_footer"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.100.0.rc1
4
+ version: 0.101.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -733,6 +733,7 @@ files:
733
733
  - lib/studio/geo.rb
734
734
  - lib/studio/geo/countries.rb
735
735
  - lib/studio/geo/lookup.rb
736
+ - lib/studio/hold_button.rb
736
737
  - lib/studio/host_schema.rb
737
738
  - lib/studio/image_cache.rb
738
739
  - lib/studio/ip_locations.rb