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
@@ -5,7 +5,9 @@
5
5
  progress bar, pulsing dots, button loader, full-screen loader, pull to
6
6
  refresh, infinite scroll. Every specimen is the engine's own helper
7
7
  (Studio::LoaderHelper, docs/LOADER.md), so what is on this page is what a
8
- view gets. The branded styles themselves are under Tricks.
8
+ view gets, under its trick name (config/studio_tricks.yml): Spinner, Skeleton,
9
+ Progress Bar, Typing Dots, Busy Button, Overlay, Pull Refresh, Infinite Scroll.
10
+ The branded styles themselves are under Tricks.
9
11
 
10
12
  The infinite-scroll specimen is live: its pages come from feed_path, the
11
13
  admin-only demo feed (StyleController#loaders_feed) unless a caller names
@@ -16,18 +18,13 @@
16
18
  # path Studio.routes draws.
17
19
  feed_path = local_assigns[:feed_path] ||
18
20
  (respond_to?(:admin_style_loaders_feed_path) ? admin_style_loaders_feed_path : "/admin/style/loaders_feed")
19
- erb = ->(code) { "<" + "%= " + code + " %" + ">" }
20
- erb_end = "<" + "% end %" + ">"
21
21
  %>
22
22
  <section id="loaders" class="section-target space-y-6" data-style-loaders>
23
23
  <%= render "studio/section_header", title: "Loaders" %>
24
24
  <p class="text-muted text-sm max-w-2xl">Eight ways to say wait. Match the loader to the wait.</p>
25
25
 
26
26
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
27
- <%= render layout: "style/specimen",
28
- locals: { klass: "studio_loader style: :spinner",
29
- note: "A small ring for a short wait beside text or in a control.",
30
- usage: erb.call("studio_loader style: :spinner") } do %>
27
+ <%= render layout: "style/specimen", locals: { trick: "spinner" } do %>
31
28
  <div data-loader-specimen="spinner" class="flex items-center gap-4">
32
29
  <%= studio_loader style: :spinner %>
33
30
  <%= studio_loader style: :spinner, size: 32 %>
@@ -35,40 +32,28 @@
35
32
  </div>
36
33
  <% end %>
37
34
 
38
- <%= render layout: "style/specimen",
39
- locals: { klass: "studio_skeleton :avatar / :card / :lines / :media",
40
- note: "Grey shapes of the content to come, so the page keeps its layout.",
41
- usage: [erb.call("studio_skeleton :avatar, count: 3"), erb.call('studio_skeleton :card, count: 3, class: "sm:grid-cols-3"')].join("\n") } do %>
35
+ <%= render layout: "style/specimen", locals: { trick: "skeleton" } do %>
42
36
  <div data-loader-specimen="skeleton" class="grid w-full gap-4">
43
37
  <%= studio_skeleton :avatar, count: 2, label: "Loading people" %>
44
38
  <%= studio_skeleton :lines, lines: 2, label: "Loading notes" %>
45
39
  </div>
46
40
  <% end %>
47
41
 
48
- <%= render layout: "style/specimen",
49
- locals: { klass: "studio_loader style: :bar, percent:",
50
- note: "A bar for a wait you can measure; with no percent it sweeps.",
51
- usage: [erb.call("studio_loader style: :bar, percent: 62"), erb.call("studio_loader style: :bar"), 'StudioLoader.setPercent(el, 80)'].join("\n") } do %>
42
+ <%= render layout: "style/specimen", locals: { trick: "progress_bar" } do %>
52
43
  <div data-loader-specimen="bar" class="grid w-full gap-4">
53
44
  <%= studio_loader style: :bar, percent: 62, label: "Uploading" %>
54
45
  <%= studio_loader style: :bar, label: "Importing" %>
55
46
  </div>
56
47
  <% end %>
57
48
 
58
- <%= render layout: "style/specimen",
59
- locals: { klass: "studio_loader style: :dots",
60
- note: "Three dots for a reply on its way, such as a chat answer.",
61
- usage: erb.call("studio_loader style: :dots") } do %>
49
+ <%= render layout: "style/specimen", locals: { trick: "typing_dots" } do %>
62
50
  <div data-loader-specimen="dots" class="flex items-center gap-6">
63
51
  <%= studio_loader style: :dots %>
64
52
  <span class="inline-flex items-center gap-2 rounded-2xl bg-inset px-3 py-2 text-sm text-body"><%= studio_loader style: :dots, size: 24, label: "Alex is typing" %></span>
65
53
  </div>
66
54
  <% end %>
67
55
 
68
- <%= render layout: "style/specimen",
69
- locals: { klass: "studio_loader_pending (inside a button)",
70
- note: "The button keeps its place and shows that it is working.",
71
- usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb.call('studio_loader_pending style: :spinner')}", " <span data-loader-busy>Saving…</span>", "</button>"].join("\n") } do %>
56
+ <%= render layout: "style/specimen", locals: { trick: "busy_button" } do %>
72
57
  <div data-loader-specimen="button" class="flex flex-wrap items-center gap-3" x-data="{ busy: false }">
73
58
  <button type="button" class="btn btn-primary" x-bind:aria-busy="busy ? 'true' : 'false'" aria-busy="false"
74
59
  x-on:click="busy = true; setTimeout(() => busy = false, 2000)" data-test="style-loader-save">
@@ -81,10 +66,7 @@
81
66
  </div>
82
67
  <% end %>
83
68
 
84
- <%= render layout: "style/specimen",
85
- locals: { klass: "studio_loader_overlay (full screen)",
86
- note: "The branded overlay, for a page that takes longer than 350ms.",
87
- usage: ['window.StudioLoader.show("Caption")', "window.StudioLoader.hide()"].join("\n") } do %>
69
+ <%= render layout: "style/specimen", locals: { trick: "overlay" } do %>
88
70
  <div data-loader-specimen="overlay">
89
71
  <button type="button" class="btn btn-secondary" data-test="style-loaders-overlay"
90
72
  x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 2000)">
@@ -93,10 +75,7 @@
93
75
  </div>
94
76
  <% end %>
95
77
 
96
- <%= render layout: "style/specimen",
97
- locals: { klass: "studio_pull_to_refresh (phones only)",
98
- note: "On a phone, pull down from the top of the page to reload it.",
99
- usage: [erb.call('studio_pull_to_refresh frame: "feed" do'), " ...", erb_end].join("\n") } do %>
78
+ <%= render layout: "style/specimen", locals: { trick: "pull_refresh" } do %>
100
79
  <%# Drawn still, mid-refresh: this wrapper has no controller, so it never arms. %>
101
80
  <div data-loader-specimen="pull" class="studio-pull-refresh w-40 h-40 overflow-hidden rounded-2xl border border-subtle bg-surface pt-20 px-3" data-pull-state="refreshing">
102
81
  <span class="studio-pull-refresh-indicator" aria-hidden="true">
@@ -106,10 +85,7 @@
106
85
  </div>
107
86
  <% end %>
108
87
 
109
- <%= render layout: "style/specimen",
110
- locals: { klass: "studio_infinite_scroll id:, page:, next_url:",
111
- note: "The next page loads as the list's end comes near, and Load more stays.",
112
- usage: [erb.call('studio_infinite_scroll id: "posts", page: @page, next_url: @next_url do'), " ...one page of items...", erb_end].join("\n") } do %>
88
+ <%= render layout: "style/specimen", locals: { trick: "infinite_scroll" } do %>
113
89
  <div data-loader-specimen="infinite" class="w-full">
114
90
  <%= render "style/loaders_feed_page", page: 1, feed_path: feed_path %>
115
91
  </div>
@@ -14,8 +14,8 @@
14
14
  card behind ours. So this section owns a tiny, self-contained store + overlay
15
15
  and registers all specimen content itself.
16
16
 
17
- Groups: Auth (the engine-owned FRAGMENTS an app's own sign-in card is built
18
- from — the engine ships no auth card), Eligibility and entry (the
17
+ Groups: Auth (the engine's standard sign-in card, studio/auth/_sign_in_modal,
18
+ in demo mode), Eligibility and entry (the
19
19
  app-supplied-policy age-gate DOB primitive, gated by Studio.feature?(:age_gate)),
20
20
  Profile (crop / upload, opened
21
21
  through studio/cropper_assets), Web3 (wallet-connect and web3-step-up now
@@ -97,7 +97,8 @@
97
97
  # THE AUTH METHOD-TOGGLE DEFAULTS USED TO LIVE HERE (ml_default / g_default /
98
98
  # w_default / auth_toggles) and are gone with the card they configured. They
99
99
  # belonged to style/modals/_auth, a MIRROR of turf-monster's sign-in modal,
100
- # retired on 2026-09-09. The engine ships no auth card to configure.
100
+ # retired on 2026-09-09. The engine's own card (studio/auth/_sign_in_modal,
101
+ # 2026-11-06) has no toggles: its specimen runs it in demo mode as shipped.
101
102
  #
102
103
  # The rule they were built to enforce OUTLIVES them and is worth keeping: a
103
104
  # TRIGGER must carry at least the gate its target's REGISTRATION carries, or
@@ -202,17 +203,15 @@
202
203
  <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full"
203
204
  :class="$store.dsModals.cardClasses()">
204
205
 
205
- <%# --- Auth fragments --- %>
206
- <%# NOT an auth card. The engine owns no sign-in modal — each app cards
207
- its own on its own host section (turf-monster: style/host/_modals).
208
- These two register the PIECES every app's auth card is built from,
209
- which had no specimen until style/modals/_auth, a mirror of turf's
210
- card, was retired on 2026-09-09 and took their only render with it. %>
211
- <template x-if="$store.dsModals.current().id === 'ds-email-field'">
212
- <div><%= render "style/modals/ds_email_field" %></div>
213
- </template>
214
- <template x-if="$store.dsModals.current().id === 'ds-resend-footer'">
215
- <div><%= render "style/modals/ds_resend_footer" %></div>
206
+ <%# --- Auth --- %>
207
+ <%# The engine's standard sign-in card (studio/auth/_sign_in_modal), the
208
+ REAL partial in demo mode: Google and Send post nothing, and the
209
+ link-sent step and resend cooldown play. It folds in the two pieces
210
+ that used to be carded alone here, studio/modals/shared/_email_field
211
+ and studio/modals/auth/_resend_footer (Alex McRitchie, 2026-11-06:
212
+ "this should use the standard auth component with Google and Email"). %>
213
+ <template x-if="$store.dsModals.current().id === 'ds-sign-in'">
214
+ <div><%= render "studio/auth/sign_in_modal", modal_store: "dsModals", demo: true %></div>
216
215
  </template>
217
216
 
218
217
  <%# --- Onboarding step (its card now lives in Profile) --- %>
@@ -452,36 +451,27 @@
452
451
  <section id="modals-auth" class="space-y-5">
453
452
  <div class="space-y-1">
454
453
  <h3 class="type-h4 text-heading">Auth</h3>
455
- <p class="text-muted text-sm">The engine ships no sign-in card, only these pieces.</p>
454
+ <p class="text-muted text-sm">The engine's standard sign-in card: Google, or, email magic link.</p>
456
455
  </div>
457
456
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
458
- <%# email field — the one piece every app's auth card renders. %>
459
457
  <%= render layout: "style/modal_specimen", locals: {
460
- label: "Email field",
461
- reference: %(the modal family's email input (studio/modals/shared/_email_field) — a themed <input type="email"> that either posts through its own `name` or feeds an enclosing x-model, with locals name/id/value/placeholder/required/autocomplete/autofocus/input_class/x_model/x_ref/disabled_expr/validator. THE `validator` LOCAL IS A FORK, NOT A FLAG: off, the partial is self-contained and the SERVER stays the only boundary; on, it wraps itself in x-data="emailValidator()" and paints a right-edge spinner/check/X that only works if the CONSUMER ships that factory, so passing it without one yields an Alpine scope error and a dead indicator. The specimen draws both faces side by side; this guide ships no emailValidator, which is why the second indicator stays blank. Open with $store.dsModals.open('ds-email-field')),
462
- open_expr: "$store.dsModals.open('ds-email-field')",
463
- glow_when: ds_glow.call("ds-email-field") } do %>
458
+ label: "Sign in",
459
+ reference: %(the engine's standard sign-in card as a modal (studio/auth/_sign_in_modal; the page form is studio/auth/_sign_in_card) — Google, then "or", then the email magic link, and on send the link-sent step with the shared resend footer (studio/modals/auth/_resend_footer) under the shared email field (studio/modals/shared/_email_field). Locals: heading, subheading (default "Sign in to" the app name), return_to (kept to an in-app path, rides both Google and the magic link), preamble and extras (partials above Google and after it, for age attestation or a Solana wallet button), google, magic_link, modal_store, demo. Real use posts Google to /auth/google_oauth2 (button_to POST, Turbo off) and the email to POST /magic_link. THIS SPECIMEN IS demo: true, SO NOTHING POSTS: Google is an inert button, Send waits on a timer, and the resend cooldown is 5s instead of 60s. Open with $store.dsModals.open('ds-sign-in')),
460
+ open_expr: "$store.dsModals.open('ds-sign-in')",
461
+ glow_when: ds_glow.call("ds-sign-in") } do %>
464
462
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
465
- <span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .18"></span>
466
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
467
- <span class="flex items-center h-6 w-full rounded border px-1.5" style="border-color: var(--color-border-strong)">
468
- <span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .12"></span>
469
- <span class="block w-3 h-3 rounded-full ml-1" style="background: var(--color-success); opacity: .55"></span>
463
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
464
+ <span class="flex items-center justify-center gap-1 h-6 w-full rounded border" style="border-color: var(--color-border-strong)">
465
+ <span class="block w-2.5 h-2.5 rounded-full" style="background: conic-gradient(#EA4335 0 25%, #FBBC05 0 50%, #34A853 0 75%, #4285F4 0)"></span>
466
+ <span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .2"></span>
470
467
  </span>
471
- </div>
472
- <% end %>
473
-
474
- <%# resend footer — the piece both link-sent steps share. %>
475
- <%= render layout: "style/modal_specimen", locals: {
476
- label: "Resend footer",
477
- reference: %(the magic-link resend footer (studio/modals/auth/_resend_footer) — the resend link, its error line and the Close beneath it, shared by an auth card's magic-link-sent and magic-link-resent steps because the two are identical, which is why it was extracted. IT RESOLVES FROM AN ENCLOSING x-data, NOT A STORE: it reads props.submitting, props.resendCooldown and props.resendError and calls resendMagicLink(), so a scope supplying none of them fails SILENTLY — nothing errors, the link simply never enables and the cooldown never counts. The specimen supplies a stand-in host so all three states are reachable, and shortens the cooldown from production's 60s to 5s because the partial does not own the duration, the host does. Open with $store.dsModals.open('ds-resend-footer')),
478
- open_expr: "$store.dsModals.open('ds-resend-footer')",
479
- glow_when: ds_glow.call("ds-resend-footer") } do %>
480
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
481
- <div class="text-2xl leading-none">📬</div>
482
- <span class="block h-1.5 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
483
- <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-primary); opacity: .7"></span>
484
- <span class="block h-1.5 w-10 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
468
+ <span class="flex items-center gap-1.5">
469
+ <span class="block h-px flex-1" style="background: var(--color-border-strong)"></span>
470
+ <span class="block h-1 w-2 rounded" style="background: var(--color-text); opacity: .25"></span>
471
+ <span class="block h-px flex-1" style="background: var(--color-border-strong)"></span>
472
+ </span>
473
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
474
+ <span class="block h-5 w-full rounded" style="background: var(--color-primary)"></span>
485
475
  </div>
486
476
  <% end %>
487
477
  </div>
@@ -17,22 +17,15 @@
17
17
  <%
18
18
  media_style = "background-image: repeating-linear-gradient(135deg, var(--color-primary) 0 18px, var(--color-accent) 18px 36px, var(--color-warning) 36px 54px, var(--color-success) 54px 72px)"
19
19
 
20
+ # Each specimen names its trick (config/studio_tricks.yml): Scrim, Tint, Shade, and
21
+ # the Scrim's soft and strong variants, which each keep a stage of their own so the
22
+ # lab can grade every wash on its own panel.
20
23
  specimens = [
21
- { key: "theme", tone: nil, scrim: "scrim",
22
- klass: ".scrim",
23
- note: "Theme-aware: a tint in light mode, a shade in dark." },
24
- { key: "tint", tone: "scrim-tint", scrim: "scrim",
25
- klass: ".scrim-tint .scrim",
26
- note: "Forced tint, in either theme. Dark ink." },
27
- { key: "shade", tone: "scrim-shade", scrim: "scrim",
28
- klass: ".scrim-shade .scrim",
29
- note: "Forced shade, in either theme. Light ink." },
30
- { key: "soft", tone: nil, scrim: "scrim scrim-soft",
31
- klass: ".scrim .scrim-soft",
32
- note: "A lighter wash: more of the media shows." },
33
- { key: "strong", tone: nil, scrim: "scrim scrim-strong",
34
- klass: ".scrim .scrim-strong",
35
- note: "A heavier wash, for long or small text." }
24
+ { key: "theme", tone: nil, scrim: "scrim", trick: "scrim" },
25
+ { key: "tint", tone: "scrim-tint", scrim: "scrim", trick: "tint" },
26
+ { key: "shade", tone: "scrim-shade", scrim: "scrim", trick: "shade" },
27
+ { key: "soft", tone: nil, scrim: "scrim scrim-soft", trick: "scrim", variant: "soft" },
28
+ { key: "strong", tone: nil, scrim: "scrim scrim-strong", trick: "scrim", variant: "strong" }
36
29
  ]
37
30
  %>
38
31
  <section id="scrims" class="space-y-5" data-scrim-group>
@@ -42,16 +35,13 @@
42
35
  </div>
43
36
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
44
37
  <% specimens.each do |s| %>
45
- <% tone_attr = s[:tone] ? %( class="#{s[:tone]} relative ...") : %( class="relative ...") %>
46
- <%= render layout: "style/specimen",
47
- locals: { klass: s[:klass],
48
- usage: %(<section#{tone_attr}>\n <img class="absolute inset-0 ..." src="...">\n <div class="#{s[:scrim]} absolute inset-0"></div>\n <div class="relative z-10">\n <h2 class="scrim-ink">Headline</h2>\n <p class="scrim-ink-muted">Subtitle</p>\n </div>\n</section>) } do %>
38
+ <%= render layout: "style/specimen", locals: { trick: s[:trick], variant: s[:variant] } do %>
49
39
  <div class="relative w-full max-w-xs h-36 rounded-lg overflow-hidden <%= s[:tone] %>" data-scrim-specimen="<%= s[:key] %>">
50
40
  <div class="absolute inset-0" style="<%= media_style %>" aria-hidden="true"></div>
51
41
  <div class="<%= s[:scrim] %> absolute inset-0" data-scrim-wash></div>
52
42
  <div class="relative z-10 h-full flex flex-col items-center justify-center gap-1 px-4 text-center">
53
43
  <p class="scrim-ink text-lg font-extrabold" data-scrim-ink>Solutions For Business</p>
54
- <p class="scrim-ink-muted text-sm" data-scrim-ink-muted><%= s[:note] %></p>
44
+ <p class="scrim-ink-muted text-sm" data-scrim-ink-muted><%= Studio::Tricks.fetch(s[:trick], s[:variant]).note %></p>
55
45
  </div>
56
46
  </div>
57
47
  <% end %>
@@ -2,13 +2,13 @@
2
2
  The primitives a public site ends with: the footer, its map, and the Google
3
3
  Calendar booking frame, popup and link (docs/SITE_FOOTER.md,
4
4
  docs/BOOKING.md). Each card
5
- gives the helper to call; the preview below them is the real footer partial,
5
+ names its trick and the helper to call; the preview below them is the real footer partial,
6
6
  rendered with THIS app's facts when it has declared any and with a sample
7
7
  when it has not, so the group is never empty.
8
8
 
9
9
  The booking frame is DRAWN, not embedded: a style guide should not ask
10
10
  Google for a calendar every time an admin scrolls past. Its two states, whole
11
- and cropped, are quarter-scale drawings of a made-up schedule (a 720px page
11
+ and cropped, share the Booking Frame card: quarter-scale drawings of a made-up schedule (a 720px page
12
12
  whose box runs from 200 to 600), cropped by the same arithmetic the real
13
13
  wrapper uses (Studio::Booking.crop). No real schedule's numbers are here. %>
14
14
  <%
@@ -47,16 +47,12 @@
47
47
  </div>
48
48
 
49
49
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
50
- <%= render layout: "style/specimen",
51
- locals: { klass: "studio_site_footer",
52
- usage: %(studio_site_footer # one line, at the end of the layout body) } do %>
50
+ <%= render layout: "style/specimen", locals: { trick: "site_footer" } do %>
53
51
  <p class="text-sm text-secondary text-center">Previewed below.</p>
54
52
  <% end %>
55
53
 
56
54
  <%= render layout: "style/specimen",
57
- locals: { klass: "studio_booking_link",
58
- disabled: !booking, disabled_label: "no booking_url",
59
- usage: %(studio_booking_link "Schedule a call", class: "btn btn-primary") } do %>
55
+ locals: { trick: "booking_link", disabled: !booking, disabled_label: "no booking_url" } do %>
60
56
  <% if booking %>
61
57
  <%= studio_booking_link "Schedule a call", class: "btn btn-primary" %>
62
58
  <% else %>
@@ -64,16 +60,12 @@
64
60
  <% end %>
65
61
  <% end %>
66
62
 
67
- <% [ [ "studio_booking_frame", nil, "whole",
68
- %(studio_booking_frame # whole at rest: the default, and crop: false),
69
- "Whole at rest, the default." ],
70
- [ "config.booking_crop", sample_crop, "cropped",
71
- %(config.booking_crop = { top: 200, bottom: 600, frame_height: 720 } # bin/booking-crop-measure prints yours),
72
- "Cropped to the slot picker at rest." ]
73
- ].each do |klass, crop, state, usage, caption| %>
74
- <%= render layout: "style/specimen",
75
- locals: { klass: klass, disabled: !booking, disabled_label: "no booking_url", usage: usage } do %>
76
- <div class="w-full space-y-3" data-booking-specimen="<%= state %>">
63
+ <%= render layout: "style/specimen",
64
+ locals: { trick: "booking_frame", disabled: !booking, disabled_label: "no booking_url" } do %>
65
+ <% [ [ nil, "whole", "Whole at rest, the default." ],
66
+ [ sample_crop, "cropped", "Cropped to the slot picker at rest." ]
67
+ ].each do |crop, state, caption| %>
68
+ <div class="space-y-3" data-booking-specimen="<%= state %>">
77
69
  <div style="margin-inline: auto; width: 11rem; overflow: hidden; background: #fff; border-radius: .5rem; border: 1px solid rgb(127 127 127 / .35); height: <%= ((crop ? crop[:window] : 720) * scale).round %>px;">
78
70
  <div style="margin-top: -<%= ((crop ? crop[:offset] : 0) * scale).round %>px;">
79
71
  <% sample_page.each do |label, from, to, box| %>
@@ -1,13 +1,15 @@
1
1
  <%# Specimen card for the living style guide (admin/style).
2
- Reusable chrome around ONE primitive, framed for copy-paste-for-an-agent:
3
- stage - the rendered primitive (yielded from the caller block)
4
- meta - its class name and a copyable usage snippet; one Copy per card (the
5
- snippet's, else the class name's)
2
+ Reusable chrome around ONE primitive, named so Alex McRitchie can say it to an
3
+ agent ("add a Sheen"):
4
+ stage - the rendered primitive (yielded from the caller block), with its
5
+ variants side by side when the card folds them in
6
+ label - the trick's name, a one-line note and its class or helper, read from
7
+ config/studio_tricks.yml by key (style/_trick_label), with one Copy
8
+ that copies the agent line, never a markup snippet
6
9
  locals:
7
- klass (String, required) - the class label, shown prominently with a
8
- copy button so an agent can grab the exact class list.
9
- usage (String, optional) - a copyable usage snippet.
10
- note (String, optional) - one short sentence on what it does.
10
+ trick (String, required) - the registry key (config/studio_tricks.yml).
11
+ variant (String, optional) - a variant with a card of its own
12
+ ("Loader · orbit"); the card is then named for the variant.
11
13
  disabled (Boolean, optional) - render "disabled but present": the
12
14
  primitive stays visible + previewable but is muted/greyed
13
15
  and flagged, for a capability the host app has turned off
@@ -30,7 +32,7 @@
30
32
  # utilities and lift the opacity a touch so the trigger reads as tappable.
31
33
  inner_cls = openable ? "opacity-60 grayscale" : "opacity-40 grayscale select-none pointer-events-none"
32
34
  %>
33
- <article class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %>" <%= 'aria-disabled="true"'.html_safe if disabled %>>
35
+ <article class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %>" <%= tag.attributes(data: { trick: local_assigns.fetch(:trick), trick_variant: local_assigns[:variant] }, aria: { disabled: ("true" if disabled) }) %>>
34
36
  <div class="relative flex flex-1 flex-wrap items-center justify-center gap-4 p-6 bg-surface-alt min-h-32">
35
37
  <% if disabled %>
36
38
  <span class="absolute top-2 right-2 badge z-10" style="color: var(--color-warning); border-color: var(--color-warning)"><%= disabled_label %></span>
@@ -39,37 +41,7 @@
39
41
  <%= yield %>
40
42
  <% end %>
41
43
  </div>
42
- <div class="border-t border-subtle p-3 space-y-2">
43
- <% if local_assigns[:note].present? %>
44
- <p class="text-sm text-body"><%= local_assigns[:note] %></p>
45
- <% end %>
46
- <%# ONE Copy per card. With a usage snippet, the snippet's Copy is the one: it holds
47
- the class too. Without one, the class name carries the Copy. %>
48
- <% snippet = local_assigns[:usage] %>
49
- <div class="flex items-start justify-between gap-2" x-data="{ copied: false }">
50
- <code x-ref="klass" class="font-mono text-xs font-bold text-heading break-all leading-relaxed flex-1 min-w-0"><%= local_assigns[:klass] %></code>
51
- <% unless snippet.present? %>
52
- <button type="button"
53
- class="btn btn-neutral btn-sm shrink-0"
54
- aria-label="Copy class name"
55
- @click="navigator.clipboard?.writeText($refs.klass.innerText); copied = true; setTimeout(() => copied = false, 1200)">
56
- <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
57
- </button>
58
- <% end %>
59
- </div>
60
- <% if snippet.present? %>
61
- <%# The code wraps inside its box, and pr-24 keeps every line clear of the Copy
62
- button, "Copied" included: 74px with its inset on macOS, 3px wider in CI's
63
- Linux fonts, so pr-20 was not enough. %>
64
- <div class="relative" x-data="{ copied: false }">
65
- <pre class="bg-inset text-body text-2xs rounded-lg px-3 py-2 pr-24 whitespace-pre-wrap break-words"><code x-ref="snippet"><%= snippet %></code></pre>
66
- <button type="button"
67
- class="btn btn-neutral btn-sm absolute top-2 right-2"
68
- aria-label="Copy usage snippet"
69
- @click="navigator.clipboard?.writeText($refs.snippet.innerText); copied = true; setTimeout(() => copied = false, 1200)">
70
- <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
71
- </button>
72
- </div>
73
- <% end %>
44
+ <div class="border-t border-subtle p-3">
45
+ <%= render "style/trick_label", trick: local_assigns.fetch(:trick), variant: local_assigns[:variant] %>
74
46
  </div>
75
47
  </article>
@@ -28,20 +28,6 @@
28
28
  cards: [{ id: "ses-email-backend", zone: "shipped", title: "SES email backend", repo: "mcritchie", who: "ST" }] }
29
29
  ]
30
30
 
31
- # Copy-paste-for-an-agent usage. Built by array-join, NOT a heredoc: a heredoc as
32
- # the last expression of an ERB scriptlet trips Erubi, and literal ERB tags in the
33
- # body would close the tag. Plain render() calls read clearly for an agent.
34
- board_usage_snippet = [
35
- "# the board; it needs nothing else on the page:",
36
- 'render "studio/board/board",',
37
- " columns: @columns, # [{ key:, label:, cards:, badge_class: }]",
38
- ' card_partial: "tasks/task_card",',
39
- " reorder_url: tasks_reorder_path, # POST { slugs: [...], zone: }",
40
- ' move_url: "/tasks/:id.json", # PATCH { task: { stage: } }',
41
- ' move_param: { resource: "task", attr: "stage" },',
42
- ' live_channel: "deployments"'
43
- ].join("\n")
44
-
45
31
  # The static reference palette below (unchanged): each column maps to a stage-*
46
32
  # badge scheme.
47
33
  board_columns = [
@@ -84,14 +70,10 @@
84
70
  move_param: { resource: "task", attr: "stage" },
85
71
  group: "studio-board-demo",
86
72
  empty_label: "Drop tasks here" %>
87
- <div class="relative" x-data="{ copied: false }">
88
- <pre class="bg-inset text-body text-2xs rounded-lg px-3 py-2 pr-16 overflow-x-auto"><code x-ref="boardUsage"><%= board_usage_snippet %></code></pre>
89
- <button type="button"
90
- class="btn btn-neutral btn-sm absolute top-2 right-2"
91
- aria-label="Copy usage snippet"
92
- @click="navigator.clipboard?.writeText($refs.boardUsage.innerText); copied = true; setTimeout(() => copied = false, 1200)">
93
- <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
94
- </button>
73
+ <%# Its name, not a snippet (config/studio_tricks.yml): an agent reads the locals
74
+ from studio/board/_board's header. %>
75
+ <div class="card p-3" data-trick="board">
76
+ <%= render "style/trick_label", trick: "board" %>
95
77
  </div>
96
78
  </div>
97
79
 
@@ -0,0 +1,33 @@
1
+ <%# A trick's name, note and token, with one Copy (config/studio_tricks.yml).
2
+ The name is the heading because it is what a person says to an agent ("add a
3
+ Sheen"); the token is what the agent writes, so it sits under it, small and
4
+ muted. Copy puts the agent line on the clipboard: the name and the token,
5
+ variants included (Studio::Tricks.agent_line). No markup snippet: an agent can
6
+ read the markup from the engine, and only the name has to travel by word.
7
+ locals:
8
+ trick (String, required) - the registry key.
9
+ variant (String, optional) - one variant's own label. %>
10
+ <% t = Studio::Tricks.fetch(trick, local_assigns[:variant]) %>
11
+ <div class="flex items-start justify-between gap-3" x-data="{ copied: false }">
12
+ <div class="min-w-0 flex-1 space-y-1">
13
+ <h4 class="type-h4 text-heading" data-trick-name><%= t.name %></h4>
14
+ <p class="text-sm text-body" data-trick-note><%= t.note %></p>
15
+ <code class="block font-mono text-2xs text-muted break-words" data-trick-token><%= t.token %></code>
16
+ <% if t.variants.any? %>
17
+ <%# The variants by name, so "Sheen · loop" is on the card and not only on the
18
+ clipboard. %>
19
+ <ul class="space-y-0.5" data-trick-variants>
20
+ <% t.variants.each do |v| %>
21
+ <li class="text-2xs leading-snug"><span class="text-body font-semibold" data-trick-variant-name><%= v.name %></span> <code class="font-mono text-muted break-words"><%= v.token %></code></li>
22
+ <% end %>
23
+ </ul>
24
+ <% end %>
25
+ </div>
26
+ <button type="button"
27
+ class="btn btn-neutral btn-sm shrink-0"
28
+ aria-label="Copy the name for an agent"
29
+ data-trick-copy="<%= Studio::Tricks.agent_line(trick, local_assigns[:variant]) %>"
30
+ @click="navigator.clipboard?.writeText($el.dataset.trickCopy); copied = true; setTimeout(() => copied = false, 1200)">
31
+ <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
32
+ </button>
33
+ </div>