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
@@ -1,7 +1,9 @@
1
1
  <%# Tricks section of the living style guide (admin/style). Buttons come first,
2
2
  then what you can add to a button, then the other primitives. Each specimen
3
- (the shared _specimen partial) shows the real engine class live, with its name
4
- and a usage snippet to copy.
3
+ (the shared _specimen partial) shows the real engine class live under its
4
+ trick name from config/studio_tricks.yml ("Sheen", "Border Glow · clear"), the
5
+ name Alex McRitchie says to an agent. Variants of one primitive share a card
6
+ and stand side by side on its stage. The glossary is docs/TRICKS.md.
5
7
 
6
8
  The Leveling group is gated by Studio.feature?(:leveling). With it off, its
7
9
  specimens render "disabled but present": visible, greyed and flagged, never hidden. %>
@@ -20,12 +22,6 @@
20
22
  }
21
23
 
22
24
  leveling_on = Studio.feature?(:leveling)
23
-
24
- # An ERB snippet cannot be written literally inside an ERB template: the scanner
25
- # closes the tag at the first `%` + `>` it sees, whatever Ruby string it sits in.
26
- # So assemble the markers: the raw template never contains one.
27
- erb = ->(code) { "<" + "%= " + code + " %" + ">" }
28
- erb_end = "<" + "% end %" + ">"
29
25
  %>
30
26
  <section id="tricks" class="section-target space-y-12">
31
27
  <%= render "studio/section_header", title: "Tricks" %>
@@ -37,18 +33,14 @@
37
33
  <h3 class="type-h4 text-heading">Buttons</h3>
38
34
  <p class="text-muted text-sm"><code class="font-mono text-2xs">.btn</code> plus one role and one size.</p>
39
35
  </div>
40
- <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
41
- <% button_variants.each do |name, label| %>
42
- <%= render layout: "style/specimen",
43
- locals: { klass: ".btn .#{name}",
44
- usage: %(<button class="btn #{name}">#{label}</button>) } do %>
36
+ <div class="grid gap-4 sm:grid-cols-2">
37
+ <%= render layout: "style/specimen", locals: { trick: "button" } do %>
38
+ <% button_variants.each do |name, label| %>
45
39
  <button type="button" class="btn <%= name %>"><%= label %></button>
46
40
  <% end %>
47
41
  <% end %>
48
42
 
49
- <%= render layout: "style/specimen",
50
- locals: { klass: ".btn-sm / .btn (default) / .btn-lg",
51
- usage: %(<button class="btn btn-primary btn-sm">Small</button>\n<button class="btn btn-primary">Default</button>\n<button class="btn btn-primary btn-lg">Large</button>) } do %>
43
+ <%= render layout: "style/specimen", locals: { trick: "button", variant: "size" } do %>
52
44
  <div class="flex flex-wrap items-center justify-center gap-3">
53
45
  <button type="button" class="btn btn-primary btn-sm">Small</button>
54
46
  <button type="button" class="btn btn-primary">Default</button>
@@ -69,51 +61,28 @@
69
61
  rings, because the primitive punches the host's box out of both wedge
70
62
  layers. The WRAPPER shape (bare host + opaque child) is staged by the
71
63
  Modals section's glow cards. engine-motion.css section 9b has both. %>
72
- <%= render layout: "style/specimen",
73
- locals: { klass: ".edge-rotate-glow (knobs: --edge-rotate-glow-color / -color-b)",
74
- note: "Adds a glow that rotates around the button's edge.",
75
- usage: %(<button class="btn btn-primary edge-rotate-glow">Save</button>\n<button class="btn btn-primary edge-rotate-glow"\n style="--edge-rotate-glow-color: #F08030; --edge-rotate-glow-color-b: #6890F0">Two colors</button>) } do %>
64
+ <%= render layout: "style/specimen", locals: { trick: "edge_glow" } do %>
76
65
  <button type="button" class="btn btn-primary edge-rotate-glow">Save</button>
77
66
  <button type="button" class="btn btn-primary edge-rotate-glow"
78
67
  style="--edge-rotate-glow-color: #F08030; --edge-rotate-glow-color-b: #6890F0">Two colors</button>
79
68
  <% end %>
80
69
 
81
- <%= render layout: "style/specimen",
82
- locals: { klass: ".studio-border-glow",
83
- note: "Adds a moving rainbow border with a soft halo.",
84
- usage: %(<button class="btn btn-primary studio-border-glow">Save</button>) } do %>
70
+ <%= render layout: "style/specimen", locals: { trick: "border_glow" } do %>
85
71
  <button type="button" class="btn btn-primary studio-border-glow">Save</button>
72
+ <button type="button" class="btn studio-border-glow studio-border-glow--clear" data-border-glow-clear>Clear</button>
86
73
  <% end %>
87
74
 
88
- <%= render layout: "style/specimen",
89
- locals: { klass: ".studio-border-glow--clear",
90
- note: "The same rainbow ring and halo on a clear button.",
91
- usage: %(<button class="btn studio-border-glow studio-border-glow--clear">Save</button>) } do %>
92
- <button type="button" class="btn studio-border-glow studio-border-glow--clear" data-border-glow-clear>Save</button>
93
- <% end %>
94
-
95
- <%= render layout: "style/specimen",
96
- locals: { klass: ".sheen .sheen--loop",
97
- note: "Adds a band of light that sweeps across the button.",
98
- usage: %(<button class="btn btn-primary sheen">Once</button>\n<button class="btn btn-primary sheen sheen--loop">Loop</button>) } do %>
75
+ <%= render layout: "style/specimen", locals: { trick: "sheen" } do %>
99
76
  <button type="button" class="btn btn-primary sheen">Once</button>
100
77
  <button type="button" class="btn btn-primary sheen sheen--loop">Loop</button>
101
78
  <% end %>
102
79
 
103
- <%= render layout: "style/specimen",
104
- locals: { klass: ".pulse-cta (knobs: --pulse-cta-color / -speed / -scale)",
105
- note: "Adds a slow beat with a ring that spreads from the edge.",
106
- usage: %(<button class="btn btn-primary pulse-cta">Save</button>\n<button class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>) } do %>
80
+ <%= render layout: "style/specimen", locals: { trick: "pulse" } do %>
107
81
  <button type="button" class="btn btn-primary pulse-cta">Save</button>
108
82
  <button type="button" class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>
109
83
  <% end %>
110
84
 
111
- <%= render layout: "style/specimen",
112
- locals: { klass: ".fizz-stack (studio/fizz)",
113
- note: "Adds bubbles that rise from behind the button.",
114
- usage: [erb.call(%(render layout: "studio/fizz", locals: { seed: "save" } do)),
115
- %( <button class="btn btn-primary">Save</button>),
116
- erb_end].join("\n") } do %>
85
+ <%= render layout: "style/specimen", locals: { trick: "fizz" } do %>
117
86
  <%= render layout: "studio/fizz", locals: { seed: "style-fizz" } do %>
118
87
  <button type="button" class="btn btn-primary">Save</button>
119
88
  <% end %>
@@ -122,25 +91,17 @@
122
91
  <% end %>
123
92
  <% end %>
124
93
 
125
- <%# The live button: press and hold for two seconds. The card clips, so this one
126
- sets fizz_portal and its bubbles rise past the card's edge. %>
127
- <%= render layout: "style/specimen",
128
- locals: { klass: ".hold-btn (studio/hold_button)",
129
- note: "Makes the button confirm only after a two-second hold.",
130
- usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000, fizz_portal: true) } do %>
94
+ <%# Hold and its recolor, side by side. The live button: press and hold for two
95
+ seconds. The card clips, so it sets fizz_portal and its bubbles rise past the
96
+ card's edge. The recolor is the same button re-themed from an ancestor: no
97
+ new CSS. %>
98
+ <%= render layout: "style/specimen", locals: { trick: "hold" } do %>
131
99
  <div class="w-full max-w-xs" x-data="{ submitting: false }">
132
100
  <%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
133
101
  default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
134
102
  <button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
135
103
  @click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
136
104
  </div>
137
- <% end %>
138
-
139
- <%# The same button, re-themed from an ancestor: no new CSS. %>
140
- <%= render layout: "style/specimen",
141
- locals: { klass: ".hold-btn (--hold-* overrides)",
142
- note: "Recolors a hold button from any parent element.",
143
- usage: %(<div style="--hold-bg-from:#f0abfc;--hold-success-to:#4c1d95">…</div>) } do %>
144
105
  <div class="w-full max-w-xs" x-data="{ submitting: false }"
145
106
  style="--hold-bg-from:#f0abfc;--hold-bg-mid:#a855f7;--hold-bg-to:#6b21a8;
146
107
  --hold-success-from:#c4b5fd;--hold-success-to:#4c1d95;--hold-success-glow-rgb:139 92 246">
@@ -153,18 +114,9 @@
153
114
  </div>
154
115
  <% end %>
155
116
 
156
- <%= render layout: "style/specimen",
157
- locals: { klass: "window.studioConfetti.burst(el)",
158
- note: "Fires confetti out from behind the button.",
159
- usage: %(<button class="btn btn-primary" x-data @click="window.studioConfetti.burst($el)">Save</button>) } do %>
117
+ <%= render layout: "style/specimen", locals: { trick: "burst" } do %>
160
118
  <button type="button" class="btn btn-primary" x-data
161
119
  @click="window.studioConfetti && window.studioConfetti.burst($el)">Burst</button>
162
- <% end %>
163
-
164
- <%= render layout: "style/specimen",
165
- locals: { klass: "window.studioConfetti.cannons()",
166
- note: "Fires confetti in from both sides of the screen.",
167
- usage: %(<button class="btn btn-success" x-data @click="window.studioConfetti.cannons()">Done</button>) } do %>
168
120
  <button type="button" class="btn btn-success" x-data
169
121
  @click="window.studioConfetti && window.studioConfetti.cannons()">Cannons</button>
170
122
  <% end %>
@@ -175,15 +127,16 @@
175
127
  <section class="space-y-5" data-page-header-specimen>
176
128
  <div class="space-y-1">
177
129
  <h3 class="type-h4 text-heading">Page header</h3>
178
- <p class="text-muted text-sm"><code class="font-mono text-2xs">studio/_page_header</code>: crumbs above the title, tabs on the right, other links under it; the open pill glides.</p>
130
+ <p class="text-muted text-sm"><code class="font-mono text-2xs">studio/_page_header</code>: crumbs above the title; tabs or anchors, a Links menu, one action, then tools.</p>
179
131
  </div>
180
- <div class="grid gap-4 lg:grid-cols-2">
181
- <%# The classes alone, not the partial: the partial's row is sticky, joins the
182
- pinned stack and holds the page's h1, and a page has one of each. %>
183
- <% [["At rest", {}], ["Stuck", { stuck: "" }]].each do |state, data| %>
184
- <%= render layout: "style/specimen",
185
- locals: { klass: "studio/_page_header (#{state.downcase})",
186
- usage: %(<%= render "studio/page_header",\n title: "Models", meta: "v1.2.3", label: "Models",\n crumbs: studio_crumbs(parent: :admin),\n tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }],\n links: [{ label: "Sessions", href: sessions_path }] %\>) } do %>
132
+ <%# The classes alone, not the partial: the partial's row is sticky, joins the
133
+ pinned stack and holds the page's h1, and a page has one of each. One card,
134
+ the row at rest above the row stuck (Page Header · stuck). %>
135
+ <%= render layout: "style/specimen", locals: { trick: "page_header" } do %>
136
+ <div class="w-full space-y-4">
137
+ <% [["at rest", {}], ["stuck", { stuck: "" }]].each do |state, data| %>
138
+ <div class="space-y-1">
139
+ <p class="label-upper"><%= state %></p>
187
140
  <%= tag.div(class: "page-header w-full px-4", data: data) do %>
188
141
  <div class="page-header-trail">
189
142
  <%# A div, not the partial's <nav>: a specimen adds no landmark to the page. %>
@@ -210,7 +163,49 @@
210
163
  <a href="#tricks" class="page-header-link">Invites</a>
211
164
  </div>
212
165
  <% end %>
213
- <% end %>
166
+ </div>
167
+ <% end %>
168
+ </div>
169
+ <% end %>
170
+ <%# The four kinds of right-side control, each with its one look: tabs (views of the
171
+ same thing) or anchors (places on this page) in the pill group, the Links menu
172
+ (pages elsewhere), the one primary action, then the muted tools. The menu is the
173
+ browser's popover, so it opens here with no script (Page Header · controls). %>
174
+ <div data-page-header-kinds-specimen>
175
+ <%= render layout: "style/specimen", locals: { trick: "page_header", variant: "controls" } do %>
176
+ <div class="w-full space-y-4">
177
+ <% [["tabs", "Deployments", [["Deployments", true], ["Triage", false]]],
178
+ ["anchors", "Style", [["Colors", false], ["Buttons", false], ["Forms", false]]]].each do |kind, title, pills| %>
179
+ <%= tag.div(class: "page-header w-full px-4", data: { stuck: "" }) do %>
180
+ <div class="page-header-lead">
181
+ <p class="page-header-title"><%= title %></p>
182
+ </div>
183
+ <div class="page-header-tabs" data-page-header-kind="<%= kind %>">
184
+ <div class="page-header-track">
185
+ <% pills.each do |label, current| %>
186
+ <a href="#tricks" class="page-header-tab btn btn-sm <%= current ? "btn-primary" : "btn-neutral" %>"><span class="page-header-tab-label"><%= label %></span></a>
187
+ <% end %>
188
+ </div>
189
+ </div>
190
+ <div class="page-header-actions">
191
+ <div class="page-header-menu" data-page-header-kind="menu">
192
+ <button type="button" class="page-header-menu-button btn btn-sm btn-neutral"
193
+ popovertarget="style-page-header-menu-<%= kind %>" aria-controls="style-page-header-menu-<%= kind %>">Links<span class="page-header-menu-caret" aria-hidden="true"></span></button>
194
+ <div id="style-page-header-menu-<%= kind %>" class="page-header-menu-list" popover="auto">
195
+ <ul>
196
+ <li><a href="#tricks" class="page-header-menu-item">Stages</a></li>
197
+ <li><a href="#tricks" class="page-header-menu-item">Docs</a></li>
198
+ </ul>
199
+ </div>
200
+ </div>
201
+ <a href="#tricks" class="page-header-action btn btn-sm btn-primary" data-page-header-kind="action">New Task</a>
202
+ <div class="page-header-tools" data-page-header-kind="tools">
203
+ <button type="button" class="btn btn-sm btn-neutral">Seed</button>
204
+ </div>
205
+ </div>
206
+ <% end %>
207
+ <% end %>
208
+ </div>
214
209
  <% end %>
215
210
  </div>
216
211
  </section>
@@ -225,17 +220,18 @@
225
220
  The rule is in <code class="font-mono text-2xs">docs/PAGE_HEADER.md</code>.
226
221
  </p>
227
222
  </div>
228
- <div class="grid gap-4 lg:grid-cols-2">
229
- <%# The classes alone, not the partial: the partial's header sticks and joins the
230
- pinned stack, and a specimen must do neither. %>
231
- <% [["At rest", {}], ["Stuck", { stuck: "" }]].each do |state, data| %>
232
- <%= render layout: "style/specimen",
233
- locals: { klass: "studio/_section_header (#{state.downcase})",
234
- usage: %(<section id="modals" class="section-target">\n <%= render "studio/section_header", title: "Modals" %\>\n ...\n</section>) } do %>
235
- <%= tag.p("Modals", class: "section-header px-4", data: data) %>
223
+ <%# The classes alone, not the partial: the partial's header sticks and joins the
224
+ pinned stack, and a specimen must do neither. %>
225
+ <%= render layout: "style/specimen", locals: { trick: "section_header" } do %>
226
+ <div class="w-full grid gap-4 sm:grid-cols-2">
227
+ <% [["at rest", {}], ["stuck", { stuck: "" }]].each do |state, data| %>
228
+ <div class="space-y-1">
229
+ <p class="label-upper"><%= state %></p>
230
+ <%= tag.p("Modals", class: "section-header px-4", data: data) %>
231
+ </div>
236
232
  <% end %>
237
- <% end %>
238
- </div>
233
+ </div>
234
+ <% end %>
239
235
  </section>
240
236
 
241
237
  <!-- Carousel -->
@@ -247,10 +243,7 @@
247
243
  <%# The live partial: a carousel joins no stack and holds no title, so the
248
244
  specimen is the real thing. Swipe it, press its arrows, or focus it and
249
245
  use the arrow keys. %>
250
- <%= render layout: "style/specimen",
251
- locals: { klass: "studio/_carousel",
252
- note: "Shows a set too long to grid, a few slides at a time.",
253
- usage: %(<%= render layout: "studio/carousel",\n locals: { label: "Portfolio", items: @apps, per_view: 3 } do |app| %\>\n <%= render "apps/card", app: app %\>\n<% end %\>) } do %>
246
+ <%= render layout: "style/specimen", locals: { trick: "carousel" } do %>
254
247
  <div class="w-full min-w-0">
255
248
  <%= render layout: "studio/carousel",
256
249
  locals: { label: "Specimen slides", items: (1..7).to_a, per_view: 3, id: "style-carousel-specimen" } do |n| %>
@@ -262,10 +255,7 @@
262
255
  </div>
263
256
  <% end %>
264
257
 
265
- <%= render layout: "style/specimen",
266
- locals: { klass: "studio/_carousel (style: :spotlight)",
267
- note: "Centres one slide at full size and colour, with its caption, and shrinks, fades and greys its neighbours as they scroll by. It opens on its middle slide; start: takes an index instead.",
268
- usage: %(<%= render layout: "studio/carousel",\n locals: { label: "Team", items: @people, style: :spotlight,\n caption: ->(person) { person.title } } do |person| %\>\n <%= render "people/card", person: person %\>\n<% end %\>) } do %>
258
+ <%= render layout: "style/specimen", locals: { trick: "spotlight" } do %>
269
259
  <div class="w-full min-w-0" data-carousel-spotlight-specimen>
270
260
  <%= render layout: "studio/carousel",
271
261
  locals: { label: "Spotlight specimen", items: (1..7).to_a, per_view: 3, style: :spotlight,
@@ -285,18 +275,13 @@
285
275
  <h3 class="type-h4 text-heading">Surfaces, text &amp; form</h3>
286
276
  </div>
287
277
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
288
- <%= render layout: "style/specimen",
289
- locals: { klass: ".card", usage: %(<div class="card p-5">...</div>) } do %>
290
- <div class="card p-5 text-sm text-body w-full max-w-xs text-center">Surface card</div>
291
- <% end %>
292
-
293
- <%= render layout: "style/specimen",
294
- locals: { klass: ".card-hover", usage: %(<div class="card-hover p-5">...</div>) } do %>
295
- <div class="card-hover p-5 text-sm text-body w-full max-w-xs text-center">Hover me</div>
278
+ <%= render layout: "style/specimen", locals: { trick: "card" } do %>
279
+ <div class="card p-5 text-sm text-body text-center">Surface card</div>
280
+ <div class="card-hover p-5 text-sm text-body text-center">Hover me</div>
296
281
  <% end %>
297
282
 
298
283
  <%= render layout: "style/specimen",
299
- locals: { klass: ".badge", usage: %(<span class="badge">Neutral</span>) } do %>
284
+ locals: { trick: "badge" } do %>
300
285
  <div class="flex flex-wrap items-center justify-center gap-2">
301
286
  <span class="badge text-body">Neutral</span>
302
287
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Success</span>
@@ -306,17 +291,17 @@
306
291
  <% end %>
307
292
 
308
293
  <%= render layout: "style/specimen",
309
- locals: { klass: ".input-field", usage: %(<input class="input-field" placeholder="you@example.com">) } do %>
294
+ locals: { trick: "input" } do %>
310
295
  <input type="text" class="input-field" placeholder="you@example.com" aria-label="Sample input">
311
296
  <% end %>
312
297
 
313
298
  <%= render layout: "style/specimen",
314
- locals: { klass: ".label-upper", usage: %(<p class="label-upper">Section label</p>) } do %>
299
+ locals: { trick: "label" } do %>
315
300
  <p class="label-upper">Section label</p>
316
301
  <% end %>
317
302
 
318
303
  <%= render layout: "style/specimen",
319
- locals: { klass: ".empty-state", usage: %(<div class="empty-state">Nothing here yet</div>) } do %>
304
+ locals: { trick: "empty_state" } do %>
320
305
  <div class="empty-state w-full max-w-xs text-sm text-muted">Nothing here yet</div>
321
306
  <% end %>
322
307
  </div>
@@ -334,38 +319,28 @@
334
319
  <p class="text-muted text-sm"><code class="font-mono text-2xs">at_time_tag</code> stamps when something happened, on the reader&rsquo;s own clock.</p>
335
320
  </div>
336
321
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
337
- <%= render layout: "style/specimen",
338
- locals: { klass: "at_time_tag(time)",
339
- usage: erb.call("at_time_tag(release.shipped_at)") } do %>
340
- <span class="text-sm text-body"><%= at_time_tag(Time.current) %></span>
341
- <% end %>
342
-
343
- <%# The date joins only when the stamp is not today — the rule is easiest to
344
- read as two specimens side by side. %>
345
- <%= render layout: "style/specimen",
346
- locals: { klass: "at_time_tag — not today",
347
- usage: erb.call("at_time_tag(task.created_at)") + "\n# the date joins only when it is not today" } do %>
348
- <span class="text-sm text-body"><%= at_time_tag(3.days.ago) %></span>
349
- <% end %>
350
-
351
- <%= render layout: "style/specimen",
352
- locals: { klass: "at_time_tag — another year",
353
- usage: erb.call("at_time_tag(old_record.created_at)") + "\n# the year joins only when it differs" } do %>
354
- <span class="text-sm text-body"><%= at_time_tag(1.year.ago) %></span>
355
- <% end %>
356
-
357
- <%= render layout: "style/specimen",
358
- locals: { klass: "at_time_tag(time, prefix: nil)",
359
- usage: erb.call("at_time_tag(time, prefix: nil)") + %(\n# bare clock, for a cell that already says "at") } do %>
360
- <span class="text-sm text-body"><%= at_time_tag(Time.current, prefix: nil) %></span>
322
+ <%# The date joins only when the stamp is not today, and the year only when it
323
+ differs: the rule reads best as the stamps one under another, each beside
324
+ what it shows. The last row is the first caller's shape, a status badge. %>
325
+ <%= render layout: "style/specimen", locals: { trick: "time_stamp" } do %>
326
+ <dl class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm">
327
+ <dt class="text-muted">today</dt>
328
+ <dd class="text-body"><%= at_time_tag(Time.current) %></dd>
329
+ <dt class="text-muted">not today</dt>
330
+ <dd class="text-body"><%= at_time_tag(3.days.ago) %></dd>
331
+ <dt class="text-muted">another year</dt>
332
+ <dd class="text-body"><%= at_time_tag(1.year.ago) %></dd>
333
+ <dt class="text-muted">bare</dt>
334
+ <dd class="text-body"><%= at_time_tag(Time.current, prefix: nil) %></dd>
335
+ <dt class="text-muted">in a badge</dt>
336
+ <dd><span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span></dd>
337
+ </dl>
361
338
  <% end %>
362
339
 
363
340
  <%# STATIC ILLUSTRATION, not the primitive: a reader inside the US never sees
364
341
  the flag, so a live specimen cannot show them what it looks like. Hand-built
365
342
  to match the hydrated markup exactly (clock, then ml-2, then the flag). %>
366
- <%= render layout: "style/specimen",
367
- locals: { klass: "outside the US — the flag trails",
368
- usage: %(# The flag is added CLIENT-side from the browser's IANA zone.\n# Inside the US there is no flag at all, and its margin collapses with it.) } do %>
343
+ <%= render layout: "style/specimen", locals: { trick: "time_stamp", variant: "abroad" } do %>
369
344
  <div class="flex flex-col items-start gap-1.5 text-sm text-body">
370
345
  <span class="whitespace-nowrap"><span>at 6:50a</span><span class="ml-2">🇯🇵</span></span>
371
346
  <span class="whitespace-nowrap"><span>at 10:50p</span><span class="ml-2">🇬🇧</span></span>
@@ -373,14 +348,6 @@
373
348
  <span class="whitespace-nowrap text-muted"><span>at 4:50p</span></span>
374
349
  </div>
375
350
  <% end %>
376
-
377
- <%# The first caller's shape, so a host sees the primitive in a real pill
378
- rather than only as bare text. %>
379
- <%= render layout: "style/specimen",
380
- locals: { klass: "in a status badge",
381
- usage: %(<span class="badge">Shipped ) + erb.call("at_time_tag(release.shipped_at)") + "</span>" } do %>
382
- <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span>
383
- <% end %>
384
351
  </div>
385
352
  </section>
386
353
 
@@ -397,37 +364,18 @@
397
364
  <p class="text-muted text-sm max-w-2xl">This app's icon in its theme colors, in the <strong><%= loader_config[:style].to_s.tr("_", " ") %></strong> style.</p>
398
365
  </div>
399
366
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
400
- <%= render layout: "style/specimen",
401
- locals: { klass: ".studio-loader (this app's style)",
402
- usage: [erb.call("studio_loader"), erb.call('studio_loader size: 20, label: "Saving"')].join("\n") } do %>
367
+ <%# This app's own loader. Its busy button and its overlay are the Loaders
368
+ section's Busy Button and Overlay; this group is the styles. %>
369
+ <%= render layout: "style/specimen", locals: { trick: "loader" } do %>
403
370
  <%= studio_loader %>
404
371
  <%= studio_loader size: 32 %>
405
372
  <%= studio_loader size: 20 %>
406
373
  <% end %>
407
374
 
408
- <%= render layout: "style/specimen",
409
- locals: { klass: "studio_loader_pending (inside a busy button)",
410
- usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb.call('studio_loader_pending')}", "</button>"].join("\n") } do %>
411
- <button type="button" class="btn btn-primary" aria-busy="true">
412
- <%= studio_loader_pending %><span class="ml-2">Saving…</span>
413
- </button>
414
- <% end %>
415
-
416
- <%= render layout: "style/specimen",
417
- locals: { klass: "studio_loader_overlay (the page-load overlay)",
418
- usage: ["#{erb.call('studio_loader_overlay')} (once, in the layout body)", 'window.StudioLoader.show("Caption") / .hide()'].join("\n") } do %>
419
- <button type="button" class="btn btn-secondary" data-test="style-show-loader-overlay"
420
- x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 3000)">
421
- Show the overlay for 3 seconds
422
- </button>
423
- <% end %>
424
-
425
375
  <% Studio::Loader::STYLES.each do |style| %>
426
- <% signature = Studio::Loader.art_style?(style) %>
427
- <% unavailable = signature && !loader_art_styles.include?(style) %>
376
+ <% unavailable = Studio::Loader.art_style?(style) && !loader_art_styles.include?(style) %>
428
377
  <%= render layout: "style/specimen",
429
- locals: { klass: %(style: :#{style}#{signature ? " (signature, needs art)" : ""}),
430
- usage: %(config.loader = #{signature ? "{ style: :#{style}, art: \"/brand/loader-#{style}.svg\" }" : ":#{style}"}),
378
+ locals: { trick: "loader", variant: style.to_s,
431
379
  disabled: unavailable, disabled_label: "needs this app's art file" } do %>
432
380
  <%= studio_loader style: style, size: 72 %>
433
381
  <%= studio_loader style: style, size: 28 %>
@@ -443,9 +391,8 @@
443
391
  <p class="text-muted text-sm">Tune any knob inline with <code class="font-mono text-2xs">style="--knob: value"</code>.</p>
444
392
  </div>
445
393
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
446
- <%= render layout: "style/specimen",
447
- locals: { klass: ".spinner (knob: --spinner-size)",
448
- usage: %(<span class="spinner"></span>\n<span class="spinner" style="--spinner-size: 2rem"></span>) } do %>
394
+ <%# The bare classes under the Loaders section's helpers, tuned inline. %>
395
+ <%= render layout: "style/specimen", locals: { trick: "spinner", variant: "css" } do %>
449
396
  <span class="spinner"></span>
450
397
  <span class="spinner" style="--spinner-size: 1.6rem"></span>
451
398
  <span class="spinner" style="--spinner-size: 2.4rem; --spinner-thickness: 3px"></span>
@@ -455,24 +402,18 @@
455
402
  </button>
456
403
  <% end %>
457
404
 
458
- <%= render layout: "style/specimen",
459
- locals: { klass: ".loading-dots (3 span children)",
460
- usage: %(<span class="loading-dots"><span></span><span></span><span></span></span>) } do %>
405
+ <%= render layout: "style/specimen", locals: { trick: "typing_dots", variant: "css" } do %>
461
406
  <span class="loading-dots"><span></span><span></span><span></span></span>
462
407
  <span class="loading-dots" style="--loading-dots-size: 12px; --loading-dots-color: var(--color-cta)"><span></span><span></span><span></span></span>
463
408
  <% end %>
464
409
 
465
- <%= render layout: "style/specimen",
466
- locals: { klass: ".ping .ping--dot (knob: --ping-color)",
467
- usage: %(<span class="ping ping--dot"></span>) } do %>
410
+ <%= render layout: "style/specimen", locals: { trick: "ping" } do %>
468
411
  <span class="ping ping--dot"></span>
469
412
  <span class="ping ping--dot" style="--ping-color: var(--color-success)"></span>
470
413
  <span class="ping ping--dot" style="--ping-color: var(--color-danger); --ping-size: 0.9rem"></span>
471
414
  <% end %>
472
415
 
473
- <%= render layout: "style/specimen",
474
- locals: { klass: ".fade-edge-{t,b,l,r,x,y} (knob: --fade-edge-size)",
475
- usage: %(<div class="fade-edge-y overflow-y-auto">...long content...</div>) } do %>
416
+ <%= render layout: "style/specimen", locals: { trick: "edge_fade" } do %>
476
417
  <div class="fade-edge-y overflow-y-auto h-32 w-full max-w-xs bg-surface rounded-lg p-3 text-sm text-body space-y-2">
477
418
  <p>Scroll me - the top and bottom edges dissolve.</p>
478
419
  <p>The mask softens content near either end of the container.</p>
@@ -482,9 +423,7 @@
482
423
  </div>
483
424
  <% end %>
484
425
 
485
- <%= render layout: "style/specimen",
486
- locals: { klass: ".progress-meter (knob: --progress)",
487
- usage: %(<div class="progress-meter" style="--progress: 62%">\n <div class="progress-meter-track"><span class="progress-meter-label">62%</span></div>\n <div class="progress-meter-fill"><span class="progress-meter-label">62%</span></div>\n</div>) } do %>
426
+ <%= render layout: "style/specimen", locals: { trick: "progress_bar", variant: "css" } do %>
488
427
  <div class="w-full max-w-xs space-y-3">
489
428
  <div class="progress-meter" style="--progress: 25%">
490
429
  <div class="progress-meter-track"><span class="progress-meter-label">25%</span></div>
@@ -513,18 +452,14 @@
513
452
  <h3 class="type-h4 text-heading">Effects</h3>
514
453
  </div>
515
454
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
516
- <%= render layout: "style/specimen",
517
- locals: { klass: ".text-gradient (knobs: --text-gradient-from / -to / -angle)",
518
- usage: %(<h2 class="text-gradient text-3xl font-extrabold">Level up</h2>) } do %>
455
+ <%= render layout: "style/specimen", locals: { trick: "gradient_text" } do %>
519
456
  <div class="text-center space-y-2">
520
457
  <p class="text-gradient text-3xl font-extrabold">Level up</p>
521
458
  <p class="text-gradient text-xl font-bold" style="--text-gradient-from: var(--color-success); --text-gradient-to: var(--color-cta)">Season complete</p>
522
459
  </div>
523
460
  <% end %>
524
461
 
525
- <%= render layout: "style/specimen",
526
- locals: { klass: ".studio-glow / .studio-glow--pulse (knob: --studio-glow-color)",
527
- usage: %(<button class="btn btn-primary studio-glow">Launch</button>) } do %>
462
+ <%= render layout: "style/specimen", locals: { trick: "glow" } do %>
528
463
  <div class="flex flex-wrap items-center justify-center gap-6">
529
464
  <button type="button" class="btn btn-primary studio-glow">Launch</button>
530
465
  <span class="studio-glow studio-glow--pulse inline-flex h-12 w-12 items-center justify-center rounded-full text-white font-extrabold"
@@ -532,17 +467,13 @@
532
467
  </div>
533
468
  <% end %>
534
469
 
535
- <%= render layout: "style/specimen",
536
- locals: { klass: ".surface-glass (knobs: --surface-glass-opacity / -blur)",
537
- usage: %(<div class="surface-glass rounded-xl p-4">...</div>) } do %>
470
+ <%= render layout: "style/specimen", locals: { trick: "frost" } do %>
538
471
  <div class="conic-surface w-full max-w-xs rounded-2xl p-4" style="--conic-surface-duration: 22s">
539
472
  <div class="surface-glass rounded-xl p-4 text-center text-sm text-heading">Frosted panel over a live backdrop</div>
540
473
  </div>
541
474
  <% end %>
542
475
 
543
- <%= render layout: "style/specimen",
544
- locals: { klass: ".conic-surface (knob: --conic-surface-duration)",
545
- usage: %(<div class="conic-surface rounded-2xl p-8">...</div>) } do %>
476
+ <%= render layout: "style/specimen", locals: { trick: "conic_wash" } do %>
546
477
  <div class="conic-surface w-full max-w-xs rounded-2xl p-8 text-center">
547
478
  <p class="text-heading font-bold">Ambient wash</p>
548
479
  <p class="text-body text-xs mt-1">Content stays crisp above the rotating gradient.</p>
@@ -565,21 +496,13 @@
565
496
  <p class="text-muted text-sm max-w-2xl">Level pills, gated by <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>.</p>
566
497
  </div>
567
498
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
568
- <%= render layout: "style/specimen",
569
- locals: { klass: ".level-badge .level-badge-1 … .level-badge-10",
570
- disabled: !leveling_on,
571
- usage: %(<span class="level-badge level-badge-10">Level 10</span>) } do %>
499
+ <%# The ten tiers, then two of them in a row, the shape a caller puts them in. %>
500
+ <%= render layout: "style/specimen", locals: { trick: "level_badge", disabled: !leveling_on } do %>
572
501
  <div class="flex flex-wrap items-center justify-center gap-2">
573
502
  <% (1..10).each do |lvl| %>
574
503
  <span class="level-badge level-badge-<%= lvl %>">Level <%= lvl %></span>
575
504
  <% end %>
576
505
  </div>
577
- <% end %>
578
-
579
- <%= render layout: "style/specimen",
580
- locals: { klass: ".level-badge (in a chip context)",
581
- disabled: !leveling_on,
582
- usage: %(<span class="text-heading font-semibold text-sm">Earn a free entry</span>\n<span class="level-badge level-badge-8">Level 8</span>) } do %>
583
506
  <div class="w-full max-w-xs space-y-2">
584
507
  <div class="card flex items-center justify-between gap-3 px-3 py-2">
585
508
  <span class="text-heading font-semibold text-sm">Earn a free entry</span>
@@ -592,10 +515,7 @@
592
515
  </div>
593
516
  <% end %>
594
517
 
595
- <%= render layout: "style/specimen",
596
- locals: { klass: ".level-up-pop (fires the pop + glow burst)",
597
- disabled: !leveling_on,
598
- usage: %(<span class="level-badge level-badge-10 level-up-pop">Level 10</span>) } do %>
518
+ <%= render layout: "style/specimen", locals: { trick: "level_pop", disabled: !leveling_on } do %>
599
519
  <div x-data="{ pop: true }" class="flex flex-col items-center gap-3 py-2">
600
520
  <span class="level-badge level-badge-10" :class="pop && 'level-up-pop'">Level 10</span>
601
521
  <button type="button" class="btn btn-neutral btn-sm"
@@ -63,7 +63,7 @@
63
63
  ]
64
64
  %>
65
65
  <div class="max-w-6xl mx-auto px-4 pt-6 pb-16" data-type-ladder>
66
- <%# The page's title and its section tabs: studio/_page_header, the row of the
66
+ <%# The page's title and its section anchors: studio/_page_header, the row of the
67
67
  pinned stack (docs/PAGE_HEADER.md). It sits at --z-sticky, above --z-raised,
68
68
  so the live hold button's portal bubbles scroll under it rather than over it.
69
69
 
@@ -78,7 +78,7 @@
78
78
  label: "Style guide sections",
79
79
  data: { studio_controller: "style-section-nav" },
80
80
  tabs_data: { style_section_nav_target: "scroller" },
81
- tabs: sections.each_with_index.map { |(anchor, label), index|
81
+ anchors: sections.each_with_index.map { |(anchor, label), index|
82
82
  { label: label, href: "##{anchor}", current: (index.zero? ? "true" : nil),
83
83
  data: { style_section_nav_target: "link" } }
84
84
  } %>
@@ -86,7 +86,8 @@
86
86
  onDeepLink() { this.pick('Phantom'); },
87
87
  // A REAL APP SWAPS BACK TO ITS OWN SIGN-IN CARD HERE, reading props.backTo
88
88
  // — that is the partial's contract and solana-studio still honours it. This
89
- // GUIDE cannot: the engine ships no auth card, and the 'auth' branch that
89
+ // GUIDE does not: its sign-in card (ds-sign-in, the engine's standard card in
90
+ // demo mode) carries no wallet button, and the 'auth' branch that
90
91
  // used to live here pointed at style/modals/_auth, the retired
91
92
  // turf-monster mirror. An unregistered swap target renders an EMPTY card
92
93
  // silently, so the branch was removed rather than left aimed at nothing.