glib-web 7.2.0 → 7.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: f04f2fbd4180b48a75af32ed8bd22321fc818224ecfb00802edc88b04992c287
4
- data.tar.gz: a5340d00dee67cacb6175428130344910c8ddbeaf69e73a64e0b8bfd8f13f639
3
+ metadata.gz: c46931ac3a3686ca63eb928be8cc23f77f6df86bcb2d3f6ad28c25a57f564fd3
4
+ data.tar.gz: 2b782f018a4220a56c0512f0603d2a712e4b97d57ffa15a64be1601035fda93f
5
5
  SHA512:
6
- metadata.gz: fbe295ea4776d69d9b07f9bf554be771195f1296f5a61c690274c28a72432b43d7f7093884b2fc20cf3d12502880590d86856cb965318c1b0d62383d7952801b
7
- data.tar.gz: 7cbfcda693f301216d38735f7a288b6d0006f38f1bb59a1a5db6a6837d4ea1dbdea43f1663314b214bbdee046245e40c0a4126cceb4c7b35c2956b1541cd3140
6
+ metadata.gz: 8d089424b8a53f6913c60c3cd895434aa168799243536b77ac73c75975ba766202382c52d86701beb6562fe38be03ff869336b353d168f3e4c92a89418ff85d1
7
+ data.tar.gz: 1cb4c4c951bdde00b63495407ee19e2c5738e2152107cbb8898bf19ce26ed91c3c82f6de9d922c7118d1754fe31d28def156b964d33edb3bc004980e019cd524
@@ -50,6 +50,24 @@ module Glib
50
50
  render path
51
51
  end
52
52
 
53
+ # The content pages are plain HTML a web panel embeds — serve them
54
+ # unwrapped, outside the JSON-UI vue shell (spec 204).
55
+ json_libs_skip_json_ui only: :json_ui_garage_content
56
+
57
+ # The garage's engine-served content fixtures (spec 204): plain HTML the
58
+ # wire's web panel embeds, carried by the engine itself so every host
59
+ # that mounts it serves the same bytes — no host's public/ is involved.
60
+ # The name is allowlisted before it reaches a template path, and
61
+ # anything else is a 404. The format is pinned to HTML: the garage
62
+ # pipeline around this controller otherwise resolves every request as
63
+ # JSON (the json_ui_garage pages above).
64
+ def json_ui_garage_content
65
+ name = params[:name]
66
+ return head :not_found unless %w[web_panel_a web_panel_b].include?(name)
67
+
68
+ render "json_ui/garage/content/#{name}", formats: :html, layout: false
69
+ end
70
+
53
71
  def chat
54
72
  chat_params = params[:chat]
55
73
 
@@ -313,6 +313,35 @@ module Glib
313
313
  end
314
314
  end
315
315
 
316
+ # Sub-hash validation, one level deep: each `nested` key names a
317
+ # sub-hash whose own required/optional lists are checked the same
318
+ # way the top level is checked, so a typo inside a composite hash
319
+ # (e.g. a field's `draw` options) fails loudly at render time
320
+ # instead of passing silently. A nested spec that declares any
321
+ # keys (required or optional) CLOSES its sub-hash: keys outside
322
+ # required+optional raise. No recursion: sub-hashes of sub-hashes
323
+ # are the app's own data.
324
+ (options[:nested] || {}).each do |key, sub_options|
325
+ sub_value = value[key]
326
+ next if sub_value.nil?
327
+ raise ArgumentError, "Hash property: '#{key}' must be a hash" if !sub_value.is_a?(Hash)
328
+
329
+ sub_value = ActiveSupport::HashWithIndifferentAccess.new(sub_value)
330
+ sub_required = sub_options[:required] || []
331
+ sub_optional = sub_options[:optional] || []
332
+
333
+ sub_required.each do |req|
334
+ raise ArgumentError, "Hash property: '#{key}.#{req}' is required" if sub_value[req.to_s].nil?
335
+ end
336
+
337
+ allowed = sub_required + sub_optional
338
+ if allowed.present?
339
+ sub_value.each_key do |k|
340
+ raise ArgumentError, "Unknown hash property: '#{key}.#{k}'" if !allowed.include?(k.to_sym)
341
+ end
342
+ end
343
+ end
344
+
316
345
  json.set! propName, value
317
346
  end
318
347
  end
@@ -8,6 +8,11 @@ class Glib::JsonUi::ActionBuilder
8
8
  string :key
9
9
  # views :childViews
10
10
  string :placement
11
+ # 'element' (default: the component that ran the action, or `targetId`)
12
+ # or 'pointer': the popover anchors to the click/tap point that started
13
+ # the action, for a menu opened from a large control where the control's
14
+ # corner may be far from the finger (a full-width signature box).
15
+ string :anchor
11
16
  int :offset
12
17
  int :width
13
18
  bool :persistent
@@ -10,6 +10,17 @@ module Glib
10
10
  )
11
11
  end
12
12
 
13
+ # The garage's engine-served content pages (spec 204). Unlike
14
+ # json_ui_garage_url, no _render/format params ride along: the content
15
+ # GET is a plain fetch whose recorded path must stay query-less.
16
+ def json_ui_garage_content_url(options = {})
17
+ Glib::Web::Engine.routes.url_helpers.json_ui_garage_content_url(options.merge(
18
+ protocol: request.protocol,
19
+ host: request.host,
20
+ port: request.port)
21
+ )
22
+ end
23
+
13
24
  def glib_chat_url(options = {})
14
25
  Glib::Web::Engine.routes.url_helpers.chat_url(options.merge(
15
26
  protocol: request.protocol,
@@ -916,9 +916,12 @@ class Glib::JsonUi::ViewBuilder
916
916
  # a dialog with the drawing canvas, and Confirm uploads the drawing as a
917
917
  # PNG to `directUploadUrl` and submits the resulting signed id under
918
918
  # `name`. The placeholder then shows the signature, and reopening the
919
- # dialog lets the user edit it. Because the canvas is not in the form,
920
- # the placeholder can be given the same height as a neighbouring input
921
- # (e.g. a typed-name alternative) without losing drawing space.
919
+ # dialog paints it back onto the canvas read-only -- a drawing shown on
920
+ # the canvas is never editable; clear it to draw again (the
921
+ # `draw.confirmedHint` cue says so under the canvas). Because the canvas
922
+ # is not in the form, the placeholder can be given the same height as a
923
+ # neighbouring input (e.g. a typed-name alternative) without losing
924
+ # drawing space.
922
925
  #
923
926
  # `label` titles the dialog; `placeholder` replaces the "Tap to sign"
924
927
  # prompt.
@@ -956,6 +959,36 @@ class Glib::JsonUi::ViewBuilder
956
959
  # width: 220,
957
960
  # height: 100
958
961
  #
962
+ # @example Offered saved signature
963
+ # # The app composes the menu on the empty box from generic pieces:
964
+ # # `onClick` runs while the field is blank, `popovers_show
965
+ # # anchor: 'pointer'` opens it at the tap point, `logics_set` fills the
966
+ # # field with the kept signature's signed id (which `savedSignature`
967
+ # # turns into a preview), and `components_invoke action_open` opens the
968
+ # # dialog. See the garage page for the full two-row composition.
969
+ # form.fields_sign \
970
+ # id: 'signature_offered',
971
+ # name: 'user[signature_offered]',
972
+ # directUploadUrl: glib_direct_uploads_url,
973
+ # width: 320, height: 160,
974
+ # onClick: ->(action) do
975
+ # action.popovers_show key: 'menu', anchor: 'pointer', placement: 'bottom-start',
976
+ # content: ->(popover) do
977
+ # popover.body styleClasses: ['popover', 'popover-menu'], childViews: ->(body) do
978
+ # body.panels_horizontal styleClasses: ['popover-menu-item'], align: 'middle',
979
+ # childViews: ->(row) { row.label text: 'Use my saved signature' },
980
+ # onClick: ->(sub) do
981
+ # sub.logics_set targetId: 'signature_offered', data: { value: 'saved-signed-id' }
982
+ # end
983
+ # end
984
+ # end
985
+ # end,
986
+ # draw: {
987
+ # hint: 'Draw it once. We keep it for next time.',
988
+ # savedSignature: { value: 'saved-signed-id', fileUrl: signature_url },
989
+ # saveOption: { name: 'user[save_signature_offered]', label: 'Save this signature for next time' }
990
+ # }
991
+ #
959
992
  # @note A saved signature is a signed id that cannot be redrawn from the
960
993
  # canvas: when a record is re-edited the field pre-populates `value`
961
994
  # with the existing attachment's signed id (the component renders a
@@ -982,6 +1015,32 @@ class Glib::JsonUi::ViewBuilder
982
1015
  # server routes it to the name attribute.
983
1016
  hash :typedName, optional: [:name, :value, :label, :placeholder]
984
1017
 
1018
+ # Run instead of opening the dialog while the field is BLANK (a filled box
1019
+ # always opens the dialog on what it holds). The app composes whatever the
1020
+ # tap should do first, e.g. a popovers_show menu offering a kept signature
1021
+ # (logics_set of its signed id) or the dialog (components_invoke
1022
+ # name: 'action_open').
1023
+ action :onClick
1024
+
1025
+ # The Draw tab's hash, the parallel of `typedName` for the Type tab. `hint`
1026
+ # is a line under the canvas, shown only while the canvas is blank;
1027
+ # `placeholder` replaces the in-canvas "Draw your signature here" cue;
1028
+ # `confirmedHint` is the line under a painted (read-only) drawing on reopen;
1029
+ # `savedSignature` is plain data: when the field's value is set to `value`
1030
+ # (by logics_set, typically from the onClick menu) the box previews
1031
+ # `fileUrl`; `saveOption` is a checkbox shown under fresh ink, in the
1032
+ # fields_check shape, whose tick is submitted under `saveOption[:name]` as a
1033
+ # SECOND hidden input -- the one exception to the field's single-param
1034
+ # contract, because a boolean cannot share the input that carries the signed
1035
+ # id or the typed name. Distinct from `fileUrl` above: that is the box's OWN
1036
+ # value on re-edit, pre-filled and, like any painted drawing, read-only on
1037
+ # the canvas until cleared.
1038
+ hash :draw, optional: [:hint, :placeholder, :confirmedHint, :savedSignature, :saveOption],
1039
+ nested: {
1040
+ savedSignature: { required: [:value, :fileUrl] },
1041
+ saveOption: { required: [:name], optional: [:label, :value, :tooltip] }
1042
+ }
1043
+
985
1044
  # Override
986
1045
  # A stored signature is an attachment -- submit its signed id (mirrors
987
1046
  # File#determine_value) so re-editing a signed submission pre-fills the
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <!-- The empty data-uri icon suppresses the WebView's automatic
7
+ /favicon.ico fetch, a byproduct request no cassette entry covers
8
+ (the served-through assertion would fail it). -->
9
+ <link rel="icon" href="data:,">
10
+ <title>Web Panel Content A</title>
11
+ </head>
12
+ <body style="margin:0; font-family: sans-serif; background:#e8f5e9;">
13
+ <h1 style="margin:0; padding:12px; font-size:20px; color:#1b5e20;">Web Panel A</h1>
14
+ <div style="margin:12px; height:80px; background:#4caf50; border-radius:4px;"></div>
15
+ <p style="margin:12px; color:#1b5e20;">Content page A for the renderer contract suite.</p>
16
+ </body>
17
+ </html>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <!-- The empty data-uri icon suppresses the WebView's automatic
7
+ /favicon.ico fetch, a byproduct request no cassette entry covers
8
+ (the served-through assertion would fail it). -->
9
+ <link rel="icon" href="data:,">
10
+ <title>Web Panel Content B</title>
11
+ </head>
12
+ <body style="margin:0; font-family: sans-serif; background:#e3f2fd;">
13
+ <h1 style="margin:0; padding:12px; font-size:24px; color:#0d47a1;">Web Panel B</h1>
14
+ <div style="margin:12px; height:120px; background:#2196f3; border-radius:8px;"></div>
15
+ <p style="margin:12px; color:#0d47a1;">Alternate content page B for the swap state.</p>
16
+ </body>
17
+ </html>
@@ -84,6 +84,19 @@ page.body(
84
84
  end
85
85
  )
86
86
 
87
+ res.spacer height: 8
88
+ res.label id: 'chip_keyboard_status', text: 'Chip: idle'
89
+ res.spacer height: 8
90
+ # Chips are keyboard-activatable: Enter/Space run onClick through the
91
+ # same $onClick path a mouse click does.
92
+ res.chip(
93
+ id: 'chip_keyboard',
94
+ text: 'Chip with onClick',
95
+ onClick: ->(action) do
96
+ action.logics_set targetId: 'chip_keyboard_status', data: { text: 'Chip: activated' }
97
+ end
98
+ )
99
+
87
100
  res.spacer height: 16
88
101
  res.hr width: 'matchParent'
89
102
  res.spacer height: 16
@@ -180,6 +180,114 @@ page.body(
180
180
  form.hr width: 'matchParent'
181
181
  form.spacer height: 12
182
182
 
183
+ form.h2 text: 'Offered saved signature'
184
+ form.spacer height: 8
185
+ form.label text: 'onClick composes a menu on the empty box (popovers_show at the pointer): use the kept signature (logics_set) or open the dialog (components_invoke action_open); draw.saveOption asks whether a fresh drawing should be saved.'
186
+ form.spacer height: 8
187
+ form.fields_sign(
188
+ id: 'signature_offered',
189
+ name: 'user[signature_offered]',
190
+ typedName: { label: 'Type your full name', placeholder: 'Full name' },
191
+ directUploadUrl: glib_direct_uploads_url,
192
+ width: 320,
193
+ height: 160,
194
+ onClick: ->(action) do
195
+ action.popovers_show key: 'menu', anchor: 'pointer', placement: 'bottom-start', content: ->(popover) do
196
+ popover.body styleClasses: ['popover', 'popover-menu'], childViews: ->(pbody) do
197
+ pbody.panels_horizontal styleClasses: ['popover-menu-item'], align: 'middle', childViews: ->(row) do
198
+ row.icon name: 'folder_open'
199
+ row.spacer width: 8
200
+ row.label text: 'Use my saved signature'
201
+ end, onClick: ->(sub) do
202
+ sub.runMultiple childActions: ->(m) do
203
+ m.logics_set targetId: 'signature_offered', data: { value: 'saved-signed-id' }
204
+ m.popovers_close key: 'menu'
205
+ end
206
+ end
207
+ pbody.panels_horizontal styleClasses: ['popover-menu-item'], align: 'middle', childViews: ->(row) do
208
+ row.icon name: 'draw'
209
+ row.spacer width: 8
210
+ row.label text: 'Draw or type a new signature'
211
+ end, onClick: ->(sub) do
212
+ sub.runMultiple childActions: ->(m) do
213
+ m.components_invoke targetId: 'signature_offered', name: 'action_open'
214
+ m.popovers_close key: 'menu'
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end,
220
+ draw: {
221
+ hint: 'Draw it once. We keep it for next time.',
222
+ placeholder: 'Sign inside the box',
223
+ confirmedHint: 'Keep it, or clear it and sign again.',
224
+ savedSignature: { value: 'saved-signed-id', fileUrl: preview_data_uri },
225
+ saveOption: {
226
+ name: 'user[save_signature_offered]',
227
+ label: 'Save this signature for next time',
228
+ tooltip: { text: 'This will replace the signature currently saved.' }
229
+ }
230
+ }
231
+ )
232
+ form.spacer height: 8
233
+ form.panels_flow(
234
+ innerPadding: { bottom: 0 },
235
+ width: 'matchParent',
236
+ childViews: ->(flow) do
237
+ flow.button(
238
+ text: 'Reset offered',
239
+ onClick: ->(action) do
240
+ action.fields_reset targetId: 'signature_offered'
241
+ end
242
+ )
243
+ flow.spacer width: 8
244
+ flow.button(
245
+ text: 'Open offered field',
246
+ onClick: ->(action) do
247
+ action.components_invoke targetId: 'signature_offered', name: 'action_open'
248
+ end
249
+ )
250
+ end
251
+ )
252
+
253
+ form.spacer height: 12
254
+ form.hr width: 'matchParent'
255
+ form.spacer height: 12
256
+
257
+ form.h2 text: 'Hints on a blank canvas'
258
+ form.spacer height: 8
259
+ form.fields_sign(
260
+ id: 'signature_hinted',
261
+ name: 'user[signature_hinted]',
262
+ directUploadUrl: glib_direct_uploads_url,
263
+ width: 320,
264
+ height: 160,
265
+ draw: { hint: 'Draw it once. We keep it for next time.', placeholder: 'Sign inside the box',
266
+ confirmedHint: 'Keep it, or clear it and sign again.' }
267
+ )
268
+
269
+ form.spacer height: 12
270
+ form.hr width: 'matchParent'
271
+ form.spacer height: 12
272
+
273
+ form.h2 text: 'Failed image load'
274
+ form.spacer height: 8
275
+ form.label text: 'A dead fileUrl: the dialog must fall back to a drawable blank canvas, not a locked one.'
276
+ form.spacer height: 8
277
+ form.fields_sign(
278
+ id: 'signature_deadfile',
279
+ name: 'user[signature_deadfile]',
280
+ value: 'deadfile-signed-id',
281
+ fileUrl: '/signatures/missing.png',
282
+ directUploadUrl: glib_direct_uploads_url,
283
+ width: 320,
284
+ height: 160
285
+ )
286
+
287
+ form.spacer height: 12
288
+ form.hr width: 'matchParent'
289
+ form.spacer height: 12
290
+
183
291
  form.h2 text: 'Read-only (pre-filled)'
184
292
  form.spacer height: 8
185
293
  form.label text: 'A saved signature on a read-only field: the preview shows, the edit overlay does not.'
@@ -186,6 +186,25 @@ page.body(
186
186
  )
187
187
  end
188
188
  )
189
+ res.spacer height: 8
190
+ res.button(
191
+ text: 'popovers/show (pointer)',
192
+ onClick: ->(action) do
193
+ action.popovers_show(
194
+ key: 'popover_pointer',
195
+ anchor: 'pointer',
196
+ placement: 'bottom-start',
197
+ content: ->(dialog) do
198
+ dialog.body(
199
+ padding: glib_json_padding_body,
200
+ childViews: ->(menu) do
201
+ menu.label text: 'Opened at the click point'
202
+ end
203
+ )
204
+ end
205
+ )
206
+ end
207
+ )
189
208
  end
190
209
  )
191
210
  end
@@ -10,13 +10,14 @@ page.body childViews: ->(body) do
10
10
  res.h2 text: 'Overview'
11
11
  res.p text: 'Web panels embed external web content within a panel container.'
12
12
 
13
- # Hermetic content on the serving host itself (spec 169, Decision 5):
14
- # the recording host's public/ carries the two deterministic pages, so
15
- # the emitted URLs bake an origin the cassette replay can rewrite and
16
- # serve — an external URL would leave the emulator for the real network
17
- # and no golden could depend on it.
18
- url_content = "#{request.base_url}/g-web-panel.html"
19
- url_alt = "#{request.base_url}/g-web-panel-alt.html"
13
+ # Hermetic content served by the engine itself (spec 204): the two
14
+ # deterministic pages ride the engine's json_ui_garage/content route,
15
+ # so every host that mounts the engine serves them identically — no
16
+ # host's public/ is involved. The emitted URLs bake an origin the
17
+ # cassette replay can rewrite and serve; an external URL would leave
18
+ # the emulator for the real network and no golden could depend on it.
19
+ url_content = json_ui_garage_content_url(name: 'web_panel_a')
20
+ url_alt = json_ui_garage_content_url(name: 'web_panel_b')
20
21
 
21
22
  res.spacer height: 12
22
23
  res.hr width: 'matchParent'
@@ -34,7 +34,10 @@ json.pins do
34
34
  json.longitude location[:lng]
35
35
  json.clusterSize location[:size]
36
36
  json.infoWindow do
37
- json.title "TEST #{rand(1000)}"
37
+ # Pinned for byte-stable re-records (spec 203, Decision 14): was
38
+ # "TEST #{rand(1000)}" — every re-record renamed every cluster pin
39
+ # and the cassette diff was noise.
40
+ json.title 'TEST 500'
38
41
  end
39
42
  end
40
43
  end
@@ -24,8 +24,12 @@ json.pins do
24
24
  end
25
25
 
26
26
  json.child! do
27
- json.latitude 13.375903 + rand(0..0.1)
28
- json.longitude 144.749791 + rand(0..0.1)
27
+ # Pinned for byte-stable re-records (spec 203, Decision 14): was
28
+ # 13.375903 + rand(0..0.1) / 144.749791 + rand(0..0.1) — the recorder
29
+ # hits this live fixture, so the jitter moved every pin on every
30
+ # re-record and the cassette diff was noise.
31
+ json.latitude 13.425903
32
+ json.longitude 144.799791
29
33
  json.infoWindow do
30
34
  json.title 'Place 3'
31
35
  json.subtitle 'Random location'
data/config/routes.rb CHANGED
@@ -4,10 +4,11 @@ Glib::Web::Engine.routes.draw do
4
4
  post 'json_ui_garage', to: 'home#json_ui_garage'
5
5
  patch 'json_ui_garage', to: 'home#json_ui_garage'
6
6
  delete 'json_ui_garage', to: 'home#json_ui_garage'
7
+ get 'json_ui_garage/content/:name', to: 'home#json_ui_garage_content', as: :json_ui_garage_content
7
8
  post 'chat', to: 'home#chat'
8
9
 
9
10
  resources :glib_direct_uploads, only: [:create]
10
11
  resources :blob_url_generators, only: [:create]
11
12
  resources :errors, only: [:create]
12
13
  end
13
- end
14
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glib-web
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.2.0
4
+ version: 7.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-13 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activestorage
@@ -312,6 +312,8 @@ files:
312
312
  - app/views/json_ui/garage/actions/index.json.jbuilder
313
313
  - app/views/json_ui/garage/actions/partial_update.json.jbuilder
314
314
  - app/views/json_ui/garage/actions/sheet_content.json.jbuilder
315
+ - app/views/json_ui/garage/content/web_panel_a.html.erb
316
+ - app/views/json_ui/garage/content/web_panel_b.html.erb
315
317
  - app/views/json_ui/garage/empty.json.jbuilder
316
318
  - app/views/json_ui/garage/forms/_alert_post_all_data.json.jbuilder
317
319
  - app/views/json_ui/garage/forms/_alert_post_data.json.jbuilder