studio-engine 0.132.0 → 0.133.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +52 -24
  4. data/app/assets/tailwind/studio_engine/engine.css +141 -0
  5. data/app/javascript/studio/application.js +1 -0
  6. data/app/javascript/studio/carousel.js +66 -1
  7. data/app/javascript/studio/controllers/carousel_controller.js +139 -8
  8. data/app/javascript/studio/controllers/page_header_controller.js +64 -1
  9. data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
  10. data/app/javascript/studio/in_page_links.js +308 -0
  11. data/app/javascript/studio/page_header.js +17 -0
  12. data/app/views/studio/_carousel.html.erb +33 -5
  13. data/app/views/studio/_page_header.html.erb +64 -12
  14. data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
  15. data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
  16. data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
  17. data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
  18. data/app/views/style/_glass.html.erb +4 -8
  19. data/app/views/style/_loaders.html.erb +11 -35
  20. data/app/views/style/_modals.html.erb +29 -39
  21. data/app/views/style/_scrims.html.erb +10 -20
  22. data/app/views/style/_site_footer.html.erb +10 -18
  23. data/app/views/style/_specimen.html.erb +13 -41
  24. data/app/views/style/_tasks.html.erb +4 -22
  25. data/app/views/style/_trick_label.html.erb +33 -0
  26. data/app/views/style/_tricks.html.erb +127 -207
  27. data/app/views/style/index.html.erb +2 -2
  28. data/app/views/style/modals/_wallet_connect.html.erb +2 -1
  29. data/config/studio_tricks.yml +470 -0
  30. data/lib/studio/page_header.rb +53 -0
  31. data/lib/studio/tricks.rb +110 -0
  32. data/lib/studio/version.rb +1 -1
  33. data/lib/studio.rb +6 -4
  34. metadata +9 -3
  35. data/app/views/style/modals/_ds_email_field.html.erb +0 -58
  36. data/app/views/style/modals/_ds_resend_footer.html.erb +0 -70
@@ -0,0 +1,470 @@
1
+ # The trick names: one short name for every specimen on /admin/style, so Alex McRitchie
2
+ # can say "add a Sheen" and an agent knows it means `.sheen` on a `.btn`.
3
+ #
4
+ # THE ONE SOURCE OF TRUTH. The style guide's cards read their name, note and token from
5
+ # here by key (render layout: "style/specimen", locals: { trick: "sheen" }), and
6
+ # docs/TRICKS.md is generated from it: run bin/tricks-doc after an edit.
7
+ # test/lib/tricks_registry_test.rb fails when the doc drifts, a name repeats, or a token
8
+ # names a class, helper, partial or script the engine does not ship.
9
+ #
10
+ # THE NAMING RULE. A name is one or two Title-case words, the noun a person would say
11
+ # ("Sheen", "Border Glow"). A variant is the name, a middle dot and one lower-case word
12
+ # or two ("Sheen · loop"); its key here is that word, with _ for a space. Every name and
13
+ # every variant name is unique across the guide.
14
+ #
15
+ # Each entry:
16
+ # name - the trick's name.
17
+ # group - the style guide group it is staged in (the h3 above its card).
18
+ # token - what to write: the class, helper, partial or call.
19
+ # note - one short sentence on what it does.
20
+ # resolve - what the registry test proves exists: css:<class> (engine CSS),
21
+ # helper:<method>, partial:<path>, js:<text> (app/javascript),
22
+ # config:<Studio setting>.
23
+ # variants - optional, keyed by the variant word; each has token, note, resolve.
24
+
25
+ button:
26
+ name: Button
27
+ group: Buttons
28
+ token: ".btn .btn-primary (or -secondary, -success, -danger, -warning, -neutral, -outline, -google)"
29
+ note: "A button: .btn plus one role."
30
+ resolve: [css:btn, css:btn-primary, css:btn-secondary, css:btn-success, css:btn-danger, css:btn-warning, css:btn-neutral, css:btn-outline, css:btn-google]
31
+ variants:
32
+ size:
33
+ token: ".btn-sm / .btn-lg"
34
+ note: "Smaller or larger; the default size needs no class."
35
+ resolve: [css:btn-sm, css:btn-lg]
36
+
37
+ edge_glow:
38
+ name: Edge Glow
39
+ group: Button tricks
40
+ token: ".edge-rotate-glow"
41
+ note: "Adds a glow that rotates around the button's edge."
42
+ resolve: [css:edge-rotate-glow]
43
+ variants:
44
+ two-color:
45
+ token: "--edge-rotate-glow-color / --edge-rotate-glow-color-b"
46
+ note: "Two colors chase each other round the ring."
47
+ resolve: [css:edge-rotate-glow]
48
+
49
+ border_glow:
50
+ name: Border Glow
51
+ group: Button tricks
52
+ token: ".studio-border-glow"
53
+ note: "Adds a moving rainbow border with a soft halo."
54
+ resolve: [css:studio-border-glow]
55
+ variants:
56
+ clear:
57
+ token: ".studio-border-glow--clear"
58
+ note: "The same ring and halo on a clear button."
59
+ resolve: [css:studio-border-glow--clear]
60
+
61
+ sheen:
62
+ name: Sheen
63
+ group: Button tricks
64
+ token: ".sheen"
65
+ note: "Adds a band of light that sweeps across the button."
66
+ resolve: [css:sheen]
67
+ variants:
68
+ loop:
69
+ token: ".sheen--loop"
70
+ note: "Repeats the sweep."
71
+ resolve: [css:sheen--loop]
72
+
73
+ pulse:
74
+ name: Pulse
75
+ group: Button tricks
76
+ token: ".pulse-cta"
77
+ note: "Adds a slow beat with a ring that spreads from the edge."
78
+ resolve: [css:pulse-cta]
79
+ variants:
80
+ color:
81
+ token: "--pulse-cta-color (and --pulse-cta-speed, --pulse-cta-scale)"
82
+ note: "Sets the ring's color, speed and size."
83
+ resolve: [css:pulse-cta]
84
+
85
+ fizz:
86
+ name: Fizz
87
+ group: Button tricks
88
+ token: "render layout: \"studio/fizz\" around the button"
89
+ note: "Adds bubbles that rise from behind the button."
90
+ resolve: [partial:studio/fizz, css:fizz-stack]
91
+ variants:
92
+ colors:
93
+ token: "colors: [...]"
94
+ note: "Picks the bubbles' colors."
95
+ resolve: [partial:studio/fizz]
96
+
97
+ hold:
98
+ name: Hold
99
+ group: Button tricks
100
+ token: "render \"studio/hold_button\""
101
+ note: "Makes the button confirm only after a two-second hold."
102
+ resolve: [partial:studio/hold_button, css:hold-btn]
103
+ variants:
104
+ recolor:
105
+ token: "--hold-* on a parent"
106
+ note: "Recolors a hold button from any parent element."
107
+ resolve: [css:hold-btn]
108
+
109
+ burst:
110
+ name: Burst
111
+ group: Button tricks
112
+ token: "window.studioConfetti.burst(el)"
113
+ note: "Fires confetti out from behind the button."
114
+ resolve: [js:burst(]
115
+ variants:
116
+ cannons:
117
+ token: "window.studioConfetti.cannons()"
118
+ note: "Fires confetti in from both sides of the screen."
119
+ resolve: [js:cannons(]
120
+
121
+ page_header:
122
+ name: Page Header
123
+ group: Page header
124
+ token: "render \"studio/page_header\""
125
+ note: "Crumbs above the title, tabs on the right, links under it."
126
+ resolve: [partial:studio/page_header, css:page-header]
127
+ variants:
128
+ stuck:
129
+ token: "[data-stuck]"
130
+ note: "How it looks once pinned under the navbar."
131
+ resolve: [css:page-header]
132
+ controls:
133
+ token: "tabs: or anchors:, menu:, action:, tools_html:"
134
+ note: "Tabs or anchors, a Links menu, one action, then tools, at one height."
135
+ resolve: [partial:studio/page_header, css:page-header-menu, css:page-header-menu-list, css:page-header-tools]
136
+
137
+ section_header:
138
+ name: Section Header
139
+ group: Section header
140
+ token: "render \"studio/section_header\""
141
+ note: "A section's title that sticks until the next one takes its place."
142
+ resolve: [partial:studio/section_header, css:section-header]
143
+ variants:
144
+ stuck:
145
+ token: "[data-stuck]"
146
+ note: "How it looks once pinned under the page row."
147
+ resolve: [css:section-header]
148
+
149
+ carousel:
150
+ name: Carousel
151
+ group: Carousel
152
+ token: "render layout: \"studio/carousel\""
153
+ note: "Shows a set too long to grid, a few slides at a time."
154
+ resolve: [partial:studio/carousel, css:carousel]
155
+
156
+ spotlight:
157
+ name: Spotlight Carousel
158
+ group: Carousel
159
+ token: "render layout: \"studio/carousel\", style: :spotlight"
160
+ note: "Centres one slide with its caption; its neighbours shrink and fade."
161
+ resolve: [partial:studio/carousel, css:carousel-spotlight]
162
+
163
+ card:
164
+ name: Card
165
+ group: "Surfaces, text & form"
166
+ token: ".card"
167
+ note: "The standard surface."
168
+ resolve: [css:card]
169
+ variants:
170
+ hover:
171
+ token: ".card-hover"
172
+ note: "A card that lifts under the pointer."
173
+ resolve: [css:card-hover]
174
+
175
+ badge:
176
+ name: Badge
177
+ group: "Surfaces, text & form"
178
+ token: ".badge"
179
+ note: "A small outlined pill; set its color and border-color for a status."
180
+ resolve: [css:badge]
181
+
182
+ input:
183
+ name: Input
184
+ group: "Surfaces, text & form"
185
+ token: ".input-field"
186
+ note: "The text field."
187
+ resolve: [css:input-field]
188
+
189
+ label:
190
+ name: Label
191
+ group: "Surfaces, text & form"
192
+ token: ".label-upper"
193
+ note: "Small upper-case text above a group."
194
+ resolve: [css:label-upper]
195
+
196
+ empty_state:
197
+ name: Empty State
198
+ group: "Surfaces, text & form"
199
+ token: ".empty-state"
200
+ note: "The box a list shows when it has nothing in it."
201
+ resolve: [css:empty-state]
202
+
203
+ glass:
204
+ name: Glass
205
+ group: Glass
206
+ token: "config.surface_style = :glass"
207
+ note: "Frosts the cards and lights the controls from above; opt-in per app."
208
+ resolve: [config:surface_style, css:surface-glass-blur]
209
+ variants:
210
+ lit:
211
+ token: ".btn, .badge, .input-field under surface_style = :glass"
212
+ note: "Buttons, badges and inputs lit from above, in every state."
213
+ resolve: [config:surface_style]
214
+
215
+ glass_sidebar:
216
+ name: Glass Sidebar
217
+ group: Glass
218
+ token: "config.sidebar_style = :glass"
219
+ note: "A frosted link sidebar; opt-in per app."
220
+ resolve: [config:sidebar_style]
221
+
222
+ icon:
223
+ name: Icon
224
+ group: Glass
225
+ token: "studio_icon(\"rocket\", size: 22)"
226
+ note: "A line icon by name."
227
+ resolve: [helper:studio_icon]
228
+
229
+ scrim:
230
+ name: Scrim
231
+ group: "Tints & shades"
232
+ token: ".scrim"
233
+ note: "Theme-aware: a tint in light mode, a shade in dark."
234
+ resolve: [css:scrim, css:scrim-ink]
235
+ variants:
236
+ soft:
237
+ token: ".scrim .scrim-soft"
238
+ note: "A lighter wash: more of the media shows."
239
+ resolve: [css:scrim-soft]
240
+ strong:
241
+ token: ".scrim .scrim-strong"
242
+ note: "A heavier wash, for long or small text."
243
+ resolve: [css:scrim-strong]
244
+
245
+ tint:
246
+ name: Tint
247
+ group: "Tints & shades"
248
+ token: ".scrim-tint .scrim"
249
+ note: "Forced tint, in either theme. Dark ink."
250
+ resolve: [css:scrim-tint]
251
+
252
+ shade:
253
+ name: Shade
254
+ group: "Tints & shades"
255
+ token: ".scrim-shade .scrim"
256
+ note: "Forced shade, in either theme. Light ink."
257
+ resolve: [css:scrim-shade]
258
+
259
+ time_stamp:
260
+ name: Time Stamp
261
+ group: Time stamps
262
+ token: "at_time_tag(time)"
263
+ note: "When it happened, on the reader's clock; the date joins only when not today."
264
+ resolve: [helper:at_time_tag]
265
+ variants:
266
+ bare:
267
+ token: "at_time_tag(time, prefix: nil)"
268
+ note: "A bare clock, for a cell that already says at."
269
+ resolve: [helper:at_time_tag]
270
+ abroad:
271
+ token: "added in the browser, outside the US"
272
+ note: "Outside the US a flag trails the clock; inside, nothing."
273
+ resolve: [helper:at_time_tag]
274
+
275
+ loader:
276
+ name: Loader
277
+ group: Branded loader
278
+ token: "studio_loader"
279
+ note: "This app's icon in its theme colors."
280
+ resolve: [helper:studio_loader, css:studio-loader]
281
+ variants:
282
+ breathe:
283
+ token: "config.loader = :breathe"
284
+ note: "The icon breathes in and out."
285
+ resolve: [helper:studio_loader]
286
+ liquid:
287
+ token: "config.loader = :liquid"
288
+ note: "The icon fills like a glass."
289
+ resolve: [helper:studio_loader]
290
+ orbit:
291
+ token: "config.loader = :orbit"
292
+ note: "A dot circles the icon."
293
+ resolve: [helper:studio_loader]
294
+ steel:
295
+ token: "config.loader = :steel"
296
+ note: "A light sweeps across the icon."
297
+ resolve: [helper:studio_loader]
298
+ weld_bead:
299
+ token: "config.loader = { style: :weld_bead, art: \"/brand/loader-weld_bead.svg\" }"
300
+ note: "A signature style: needs this app's own art file."
301
+ resolve: [helper:studio_loader]
302
+ sparkle:
303
+ token: "config.loader = { style: :sparkle, art: \"/brand/loader-sparkle.svg\" }"
304
+ note: "A signature style: needs this app's own art file."
305
+ resolve: [helper:studio_loader]
306
+
307
+ spinner:
308
+ name: Spinner
309
+ group: Loaders
310
+ token: "studio_loader style: :spinner"
311
+ note: "A small ring for a short wait beside text or in a control."
312
+ resolve: [helper:studio_loader, css:spinner]
313
+ variants:
314
+ css:
315
+ token: ".spinner (--spinner-size, --spinner-color)"
316
+ note: "The bare ring, tuned inline."
317
+ resolve: [css:spinner]
318
+
319
+ skeleton:
320
+ name: Skeleton
321
+ group: Loaders
322
+ token: "studio_skeleton :lines (or :avatar, :card, :media)"
323
+ note: "Grey shapes of the content to come, so the page keeps its layout."
324
+ resolve: [helper:studio_skeleton]
325
+
326
+ progress_bar:
327
+ name: Progress Bar
328
+ group: Loaders
329
+ token: "studio_loader style: :bar, percent: 62"
330
+ note: "A bar for a wait you can measure; with no percent it sweeps."
331
+ resolve: [helper:studio_loader, css:progress-meter]
332
+ variants:
333
+ css:
334
+ token: ".progress-meter (--progress)"
335
+ note: "The bare bar, with its label inked both sides of the fill."
336
+ resolve: [css:progress-meter]
337
+
338
+ typing_dots:
339
+ name: Typing Dots
340
+ group: Loaders
341
+ token: "studio_loader style: :dots"
342
+ note: "Three dots for a reply on its way, such as a chat answer."
343
+ resolve: [helper:studio_loader, css:loading-dots]
344
+ variants:
345
+ css:
346
+ token: ".loading-dots with 3 span children"
347
+ note: "The bare dots, tuned inline."
348
+ resolve: [css:loading-dots]
349
+
350
+ busy_button:
351
+ name: Busy Button
352
+ group: Loaders
353
+ token: "studio_loader_pending inside the .btn"
354
+ note: "The button keeps its place and shows that it is working."
355
+ resolve: [helper:studio_loader_pending]
356
+
357
+ overlay:
358
+ name: Overlay
359
+ group: Loaders
360
+ token: "window.StudioLoader.show(\"Caption\") / .hide()"
361
+ note: "The branded full-screen loader, for a page that takes longer than 350ms."
362
+ resolve: [helper:studio_loader_overlay, js:StudioLoader]
363
+
364
+ pull_refresh:
365
+ name: Pull Refresh
366
+ group: Loaders
367
+ token: "studio_pull_to_refresh"
368
+ note: "On a phone, pull down from the top of the page to reload it."
369
+ resolve: [helper:studio_pull_to_refresh]
370
+
371
+ infinite_scroll:
372
+ name: Infinite Scroll
373
+ group: Loaders
374
+ token: "studio_infinite_scroll id:, page:, next_url:"
375
+ note: "The next page loads as the list's end comes near."
376
+ resolve: [helper:studio_infinite_scroll]
377
+
378
+ ping:
379
+ name: Ping
380
+ group: Motion primitives
381
+ token: ".ping .ping--dot"
382
+ note: "A dot with a ring that pings out from it."
383
+ resolve: [css:ping, css:ping--dot]
384
+
385
+ edge_fade:
386
+ name: Edge Fade
387
+ group: Motion primitives
388
+ token: ".fade-edge-y (or -t, -b, -l, -r, -x)"
389
+ note: "Fades a scroller's content out at its edges."
390
+ resolve: [css:fade-edge-y]
391
+
392
+ gradient_text:
393
+ name: Gradient Text
394
+ group: Effects
395
+ token: ".text-gradient"
396
+ note: "Fills the text with a gradient."
397
+ resolve: [css:text-gradient]
398
+
399
+ glow:
400
+ name: Glow
401
+ group: Effects
402
+ token: ".studio-glow"
403
+ note: "A soft colored halo around the element."
404
+ resolve: [css:studio-glow]
405
+ variants:
406
+ pulse:
407
+ token: ".studio-glow--pulse"
408
+ note: "The halo breathes."
409
+ resolve: [css:studio-glow--pulse]
410
+
411
+ frost:
412
+ name: Frost
413
+ group: Effects
414
+ token: ".surface-glass"
415
+ note: "A frosted panel over whatever is behind it."
416
+ resolve: [css:surface-glass]
417
+
418
+ conic_wash:
419
+ name: Conic Wash
420
+ group: Effects
421
+ token: ".conic-surface"
422
+ note: "A slow rotating gradient behind the content."
423
+ resolve: [css:conic-surface]
424
+
425
+ level_badge:
426
+ name: Level Badge
427
+ group: Leveling
428
+ token: ".level-badge .level-badge-1 … .level-badge-10"
429
+ note: "The ten level pills, plainest to holographic."
430
+ resolve: [css:level-badge, css:level-badge-10]
431
+
432
+ level_pop:
433
+ name: Level Pop
434
+ group: Leveling
435
+ token: ".level-up-pop"
436
+ note: "Pops a level badge with a glow when it changes."
437
+ resolve: [css:level-up-pop]
438
+
439
+ site_footer:
440
+ name: Site Footer
441
+ group: Site footer and booking
442
+ token: "studio_site_footer"
443
+ note: "The public footer, in one line at the end of the layout body."
444
+ resolve: [helper:studio_site_footer]
445
+
446
+ booking_link:
447
+ name: Booking Link
448
+ group: Site footer and booking
449
+ token: "studio_booking_link \"Schedule a call\""
450
+ note: "Opens the booking calendar in a popup."
451
+ resolve: [helper:studio_booking_link]
452
+
453
+ booking_frame:
454
+ name: Booking Frame
455
+ group: Site footer and booking
456
+ token: "studio_booking_frame"
457
+ note: "The booking calendar on a page, whole at rest."
458
+ resolve: [helper:studio_booking_frame]
459
+ variants:
460
+ cropped:
461
+ token: "config.booking_crop = { top:, bottom:, frame_height: }"
462
+ note: "Cropped to the slot picker at rest; bin/booking-crop-measure prints yours."
463
+ resolve: [config:booking_crop]
464
+
465
+ board:
466
+ name: Board
467
+ group: Tasks
468
+ token: "render \"studio/board/board\", columns:, card_partial:"
469
+ note: "Columns of cards to drag between; a move saves itself."
470
+ resolve: [partial:studio/board/board]
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # studio/_page_header's one piece of Ruby: the deprecation note for its old free-HTML
5
+ # slot (docs/PAGE_HEADER.md, "The four kinds of control").
6
+ #
7
+ # actions_html still renders for one release. In development only, the first render
8
+ # that passes it writes one warning to the log per call site, so a host sees each
9
+ # page it has to move and no page repeats.
10
+ module PageHeader
11
+ ACTIONS_HTML_WARNING =
12
+ "studio/_page_header: actions_html is deprecated and goes in the next release. " \
13
+ "Pass tabs: or anchors:, menu:, action: and tools_html: instead (docs/PAGE_HEADER.md)."
14
+
15
+ @warned = {}
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # site: the caller's "path:line", so each page warns once. env and logger default to
20
+ # Rails' and are injectable for a test.
21
+ def deprecate_actions_html(site: nil, env: rails_env, logger: rails_logger)
22
+ return false unless warning_enabled?(env: env) && logger
23
+
24
+ key = site.to_s
25
+ first = @mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
26
+ return false unless first
27
+
28
+ logger.warn([ACTIONS_HTML_WARNING, (" Called from #{site}." if site)].compact.join)
29
+ true
30
+ end
31
+
32
+ # Whether a render should look for its call site at all: development only, so no
33
+ # other environment pays for the caller walk.
34
+ def warning_enabled?(env: rails_env)
35
+ env.to_s == "development"
36
+ end
37
+
38
+ def reset_warnings!
39
+ @mutex.synchronize { @warned.clear }
40
+ end
41
+
42
+ private
43
+
44
+ def rails_env
45
+ defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env : nil
46
+ end
47
+
48
+ def rails_logger
49
+ defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Studio
6
+ # The trick names on /admin/style (config/studio_tricks.yml). Alex McRitchie, 2026-11-06:
7
+ # "I really just need to have a good naming convention so I can convey an idea to my
8
+ # agents." Every specimen card reads its name, note and token from here, and
9
+ # docs/TRICKS.md, the agent-facing glossary, is generated from the same file
10
+ # (bin/tricks-doc), so the page and the doc cannot say different things.
11
+ #
12
+ # Studio::Tricks.fetch("sheen") # => the Sheen trick
13
+ # Studio::Tricks.fetch("sheen", "loop") # => "Sheen · loop", its own card's trick
14
+ # Studio::Tricks.agent_line("sheen") # => "Sheen — .sheen; Sheen · loop — .sheen--loop"
15
+ module Tricks
16
+ PATH = File.expand_path("../../config/studio_tricks.yml", __dir__)
17
+ DOC_PATH = File.expand_path("../../docs/TRICKS.md", __dir__)
18
+ SEPARATOR = " · "
19
+
20
+ # One trick, or one variant of one (base is then its parent).
21
+ Trick = Struct.new(:key, :variant, :name, :group, :token, :note, :resolve, :variants, :base,
22
+ keyword_init: true)
23
+
24
+ class UnknownTrick < KeyError; end
25
+
26
+ module_function
27
+
28
+ # Every top-level trick, in file order (which is page order).
29
+ def all
30
+ @all ||= load_all
31
+ end
32
+
33
+ def reload!
34
+ @all = nil
35
+ all
36
+ end
37
+
38
+ def fetch(key, variant = nil)
39
+ trick = all.find { |t| t.key == key.to_s } or
40
+ raise UnknownTrick, "no trick #{key.inspect} in config/studio_tricks.yml"
41
+ return trick if variant.nil?
42
+
43
+ trick.variants.find { |v| v.variant == variant.to_s } or
44
+ raise UnknownTrick, "trick #{key.inspect} has no variant #{variant.inspect}"
45
+ end
46
+
47
+ # Every name on the guide, variants included.
48
+ def names
49
+ all.flat_map { |t| [t.name, *t.variants.map(&:name)] }
50
+ end
51
+
52
+ # What the card's one Copy puts on the clipboard: the name and what to write, so it
53
+ # can be pasted straight to an agent. A trick's line carries its variants; a
54
+ # variant's line is its own.
55
+ def agent_line(key, variant = nil)
56
+ trick = fetch(key, variant)
57
+ [trick, *trick.variants].map { |t| "#{t.name} — #{t.token}" }.join("; ")
58
+ end
59
+
60
+ def markdown
61
+ lines = []
62
+ lines << "# Tricks"
63
+ lines << ""
64
+ lines << "<!-- Generated from config/studio_tricks.yml by bin/tricks-doc. Edit the YAML, then run it. -->"
65
+ lines << ""
66
+ lines << "The names of the specimens on `/admin/style`. Say the name and an agent writes the"
67
+ lines << "token: \"add a Sheen\" means `.sheen` on a `.btn`; \"make it Sheen · loop\" adds"
68
+ lines << "`.sheen--loop`."
69
+ lines << ""
70
+ lines << "A name is one or two Title-case words. A variant is the name, a middle dot and a"
71
+ lines << "lower-case word (`Border Glow · clear`). Every name is unique across the guide."
72
+ all.group_by(&:group).each do |group, tricks|
73
+ lines << ""
74
+ lines << "## #{group}"
75
+ lines << ""
76
+ lines << "| Name | Write | What it does |"
77
+ lines << "|------|-------|--------------|"
78
+ tricks.each do |trick|
79
+ lines << row("**#{trick.name}**", trick)
80
+ trick.variants.each { |v| lines << row(v.name, v) }
81
+ end
82
+ end
83
+ lines << ""
84
+ lines.join("\n")
85
+ end
86
+
87
+ def row(label, trick)
88
+ "| #{label} | `#{trick.token}` | #{trick.note} |"
89
+ end
90
+
91
+ def load_all
92
+ YAML.safe_load_file(PATH).map do |key, entry|
93
+ base = build(key: key, name: entry.fetch("name"), group: entry.fetch("group"), entry: entry)
94
+ base.variants = (entry["variants"] || {}).map do |word, v|
95
+ build(key: key, variant: word, name: "#{base.name}#{SEPARATOR}#{word.tr('_', ' ')}",
96
+ group: base.group, entry: v, base: base)
97
+ end
98
+ base
99
+ end.freeze
100
+ end
101
+
102
+ def build(key:, name:, group:, entry:, variant: nil, base: nil)
103
+ Trick.new(key: key, variant: variant, name: name, group: group,
104
+ token: entry.fetch("token"), note: entry.fetch("note"),
105
+ resolve: Array(entry["resolve"]), variants: [], base: base)
106
+ end
107
+
108
+ private_class_method :row, :load_all, :build
109
+ end
110
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.132.0"
2
+ VERSION = "0.133.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -45,6 +45,7 @@ require "studio/navbar_identity"
45
45
  require "studio/navbar_wordmark"
46
46
  require "studio/navbar_brand"
47
47
  require "studio/loader"
48
+ require "studio/tricks"
48
49
  require "studio/auth_labels"
49
50
  require "studio/profile_sections"
50
51
  require "studio/profile_image"
@@ -78,6 +79,7 @@ require "studio/survey"
78
79
  require "studio/survey/breakdown"
79
80
  require "studio/survey/export"
80
81
  require "studio/crumbs"
82
+ require "studio/page_header"
81
83
 
82
84
  module Studio
83
85
  mattr_accessor :app_name, default: "Studio"
@@ -2130,10 +2132,10 @@ module Studio
2130
2132
  # the OAuth popup DO stay app-side.
2131
2133
  #
2132
2134
  # The gate is auth_methods, NOT auth_methods && feature?(:web3), even though
2133
- # an app's auth modal computes its wallet button from both (turf-monster's
2134
- # app/views/modals/_auth.html.erb; this engine ships no auth card, and the
2135
- # style-guide copy that used to be cited here was a mirror of turf's,
2136
- # retired 2026-09-09). Deliberate: auth_methods says
2135
+ # an app's auth modal computes its wallet button from both (the engine's
2136
+ # standard card is studio/auth/_sign_in_modal, which takes the wallet
2137
+ # button as an extras: partial; until turf-monster adopts it, its own
2138
+ # app/views/modals/_auth.html.erb computes the button). Deliberate: auth_methods says
2137
2139
  # which CREDENTIALS this app accepts, features gates PRODUCT SURFACES, and
2138
2140
  # these three paths are the credential exchange itself. phantom_callback is
2139
2141
  # the mobile deep-link RETURN url — a wallet app that declared :wallet but