studio-engine 0.129.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +192 -0
- data/README.md +48 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
- data/app/assets/tailwind/studio_engine/engine.css +737 -19
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/fizz_helper.rb +5 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pinned_stack.js +112 -4
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_fizz.html.erb +26 -0
- data/app/views/studio/_fizz_layer.html.erb +4 -1
- data/app/views/studio/_hold_button.html.erb +1 -2
- data/app/views/studio/_page_header.html.erb +55 -13
- data/app/views/studio/_section_header.html.erb +23 -0
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_components.html.erb +3 -5
- data/app/views/style/_host.html.erb +3 -6
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modal_specimen.html.erb +5 -5
- data/app/views/style/_modals.html.erb +15 -5
- data/app/views/style/_site_footer.html.erb +1 -1
- data/app/views/style/_specimen.html.erb +21 -10
- data/app/views/style/_tasks.html.erb +3 -5
- data/app/views/style/_theme.html.erb +2 -2
- data/app/views/style/_tricks.html.erb +218 -217
- data/app/views/style/index.html.erb +21 -11
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +22 -2
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
<%# Tricks section of the living style guide (admin/style).
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
specimens for real.
|
|
9
|
-
|
|
10
|
-
The Leveling group renders the REAL .level-badge tiers ported into the engine
|
|
11
|
-
from Turf Monster, capability-gated by Studio.feature?(:leveling). When a host
|
|
12
|
-
has leveling OFF (McRitchie Studio's default), its specimens render "disabled
|
|
13
|
-
but present" via the _specimen `disabled:` local - visible and previewable,
|
|
14
|
-
but greyed and flagged, never hidden.
|
|
15
|
-
|
|
16
|
-
The color tokens/swatches now live in the Theme section (the landing section
|
|
17
|
-
owns "the colors"); this section is the trick board. %>
|
|
1
|
+
<%# Tricks section of the living style guide (admin/style). Buttons come first,
|
|
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.
|
|
5
|
+
|
|
6
|
+
The Leveling group is gated by Studio.feature?(:leveling). With it off, its
|
|
7
|
+
specimens render "disabled but present": visible, greyed and flagged, never hidden. %>
|
|
18
8
|
<%
|
|
19
9
|
# Keyed by the WHOLE class, never "btn-" + a name: Tailwind compiles only the
|
|
20
10
|
# @utility names it can read in source.
|
|
@@ -30,26 +20,22 @@
|
|
|
30
20
|
}
|
|
31
21
|
|
|
32
22
|
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 %" + ">"
|
|
33
29
|
%>
|
|
34
|
-
<section id="tricks" class="space-y-12"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
38
|
-
A board of style tricks you can see and hand to an agent to use out of the box.
|
|
39
|
-
Each specimen surfaces the rendered primitive, its <strong>class name (one-click
|
|
40
|
-
copy)</strong>, and a copyable usage snippet - the button, surface, scrim, motion,
|
|
41
|
-
effect, and leveling primitives. The color tokens live in <a href="#theme" class="underline">Theme</a>.
|
|
42
|
-
</p>
|
|
43
|
-
</div>
|
|
30
|
+
<section id="tricks" class="section-target space-y-12">
|
|
31
|
+
<%= render "studio/section_header", title: "Tricks" %>
|
|
32
|
+
<p class="text-muted text-sm max-w-2xl">Buttons first, then what you can add to them.</p>
|
|
44
33
|
|
|
45
34
|
<!-- Buttons -->
|
|
46
35
|
<section class="space-y-5">
|
|
47
36
|
<div class="space-y-1">
|
|
48
37
|
<h3 class="type-h4 text-heading">Buttons</h3>
|
|
49
|
-
<p class="text-muted text-sm">
|
|
50
|
-
<code class="font-mono text-2xs">.btn</code> base plus one role variant, and the
|
|
51
|
-
<code class="font-mono text-2xs">.btn-sm</code> / <code class="font-mono text-2xs">.btn-lg</code> sizes.
|
|
52
|
-
</p>
|
|
38
|
+
<p class="text-muted text-sm"><code class="font-mono text-2xs">.btn</code> plus one role and one size.</p>
|
|
53
39
|
</div>
|
|
54
40
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
55
41
|
<% button_variants.each do |name, label| %>
|
|
@@ -72,16 +58,124 @@
|
|
|
72
58
|
</div>
|
|
73
59
|
</section>
|
|
74
60
|
|
|
61
|
+
<!-- Button tricks -->
|
|
62
|
+
<section id="button-tricks" class="section-subtarget space-y-5">
|
|
63
|
+
<div class="space-y-1">
|
|
64
|
+
<h3 class="type-h4 text-heading">Button tricks</h3>
|
|
65
|
+
<p class="text-muted text-sm">Add any of these to a <code class="font-mono text-2xs">.btn</code>.</p>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
68
|
+
<%# The CARD shape: the button carries its own background and the ring still
|
|
69
|
+
rings, because the primitive punches the host's box out of both wedge
|
|
70
|
+
layers. The WRAPPER shape (bare host + opaque child) is staged by the
|
|
71
|
+
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 %>
|
|
76
|
+
<button type="button" class="btn btn-primary edge-rotate-glow">Save</button>
|
|
77
|
+
<button type="button" class="btn btn-primary edge-rotate-glow"
|
|
78
|
+
style="--edge-rotate-glow-color: #F08030; --edge-rotate-glow-color-b: #6890F0">Two colors</button>
|
|
79
|
+
<% end %>
|
|
80
|
+
|
|
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 %>
|
|
85
|
+
<button type="button" class="btn btn-primary studio-border-glow">Save</button>
|
|
86
|
+
<% end %>
|
|
87
|
+
|
|
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 %>
|
|
99
|
+
<button type="button" class="btn btn-primary sheen">Once</button>
|
|
100
|
+
<button type="button" class="btn btn-primary sheen sheen--loop">Loop</button>
|
|
101
|
+
<% end %>
|
|
102
|
+
|
|
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 %>
|
|
107
|
+
<button type="button" class="btn btn-primary pulse-cta">Save</button>
|
|
108
|
+
<button type="button" class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>
|
|
109
|
+
<% end %>
|
|
110
|
+
|
|
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 %>
|
|
117
|
+
<%= render layout: "studio/fizz", locals: { seed: "style-fizz" } do %>
|
|
118
|
+
<button type="button" class="btn btn-primary">Save</button>
|
|
119
|
+
<% end %>
|
|
120
|
+
<%= render layout: "studio/fizz", locals: { seed: "style-fizz-colors", colors: %w[#8b5cf6 #8b5cf6 #8b5cf6 #22d3ee #22d3ee #22d3ee #f59e0b #f59e0b #f59e0b #ef4444 #ef4444 #ef4444 #10b981 #10b981 #10b981 #ec4899 #ec4899 #ec4899] } do %>
|
|
121
|
+
<button type="button" class="btn btn-neutral">Six colors</button>
|
|
122
|
+
<% end %>
|
|
123
|
+
<% end %>
|
|
124
|
+
|
|
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 %>
|
|
131
|
+
<div class="w-full max-w-xs" x-data="{ submitting: false }">
|
|
132
|
+
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
|
|
133
|
+
default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
|
|
134
|
+
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
135
|
+
@click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
|
|
136
|
+
</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
|
+
<div class="w-full max-w-xs" x-data="{ submitting: false }"
|
|
145
|
+
style="--hold-bg-from:#f0abfc;--hold-bg-mid:#a855f7;--hold-bg-to:#6b21a8;
|
|
146
|
+
--hold-success-from:#c4b5fd;--hold-success-to:#4c1d95;--hold-success-glow-rgb:139 92 246">
|
|
147
|
+
<%= render "studio/hold_button", hold_id: "style-hold-custom", duration: 2000,
|
|
148
|
+
default_text: "Hold to Confirm", success_text: "Confirmed!",
|
|
149
|
+
fizz_colors: %w[#f0abfc #6b21a8 #a855f7 #a5b4fc #312e81 #6366f1 #fbcfe8 #831843 #ec4899
|
|
150
|
+
#bae6fd #0c4a6e #0ea5e9 #ddd6fe #4c1d95 #8b5cf6 #fde68a #78350f #f59e0b] %>
|
|
151
|
+
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
152
|
+
@click="document.querySelector('.hold-btn[data-hold-id=style-hold-custom]').classList.remove('process','success','error','loading')">Reset</button>
|
|
153
|
+
</div>
|
|
154
|
+
<% end %>
|
|
155
|
+
|
|
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 %>
|
|
160
|
+
<button type="button" class="btn btn-primary" x-data
|
|
161
|
+
@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
|
+
<button type="button" class="btn btn-success" x-data
|
|
169
|
+
@click="window.studioConfetti && window.studioConfetti.cannons()">Cannons</button>
|
|
170
|
+
<% end %>
|
|
171
|
+
</div>
|
|
172
|
+
</section>
|
|
173
|
+
|
|
75
174
|
<!-- Page header -->
|
|
76
175
|
<section class="space-y-5" data-page-header-specimen>
|
|
77
176
|
<div class="space-y-1">
|
|
78
177
|
<h3 class="type-h4 text-heading">Page header</h3>
|
|
79
|
-
<p class="text-muted text-sm">
|
|
80
|
-
<code class="font-mono text-2xs">studio/_page_header</code>: the title, optional meta beside
|
|
81
|
-
it and optional tabs on the right. This page's own title row is one: scroll to see it take the bar.
|
|
82
|
-
It joins the pinned stack, so a table's sticky header sits under it: navbar, page row, table
|
|
83
|
-
header, each below the last. The rule is in <code class="font-mono text-2xs">docs/PAGE_HEADER.md</code>.
|
|
84
|
-
</p>
|
|
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>
|
|
85
179
|
</div>
|
|
86
180
|
<div class="grid gap-4 lg:grid-cols-2">
|
|
87
181
|
<%# The classes alone, not the partial: the partial's row is sticky, joins the
|
|
@@ -89,8 +183,18 @@
|
|
|
89
183
|
<% [["At rest", {}], ["Stuck", { stuck: "" }]].each do |state, data| %>
|
|
90
184
|
<%= render layout: "style/specimen",
|
|
91
185
|
locals: { klass: "studio/_page_header (#{state.downcase})",
|
|
92
|
-
usage: %(<%= render "studio/page_header",\n title: "Models", meta: "v1.2.3", label: "Models",\n tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %\>) } do %>
|
|
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 %>
|
|
93
187
|
<%= tag.div(class: "page-header w-full px-4", data: data) do %>
|
|
188
|
+
<div class="page-header-trail">
|
|
189
|
+
<%# A div, not the partial's <nav>: a specimen adds no landmark to the page. %>
|
|
190
|
+
<div class="page-header-crumbs">
|
|
191
|
+
<ol>
|
|
192
|
+
<li><a href="#tricks" class="page-header-crumb">Admin</a></li>
|
|
193
|
+
<li><span class="page-header-crumb-current">Models</span></li>
|
|
194
|
+
</ol>
|
|
195
|
+
</div>
|
|
196
|
+
<a href="#tricks" class="page-header-parent"><span aria-hidden="true">‹</span> Admin</a>
|
|
197
|
+
</div>
|
|
94
198
|
<div class="page-header-lead">
|
|
95
199
|
<p class="page-header-title">Models</p>
|
|
96
200
|
<p class="page-header-meta label-upper">v1.2.3</p>
|
|
@@ -101,17 +205,84 @@
|
|
|
101
205
|
<a href="#tricks" class="btn btn-sm btn-neutral">Teams</a>
|
|
102
206
|
</div>
|
|
103
207
|
</div>
|
|
208
|
+
<div class="page-header-links">
|
|
209
|
+
<a href="#tricks" class="page-header-link">Sessions</a>
|
|
210
|
+
<a href="#tricks" class="page-header-link">Invites</a>
|
|
211
|
+
</div>
|
|
104
212
|
<% end %>
|
|
105
213
|
<% end %>
|
|
106
214
|
<% end %>
|
|
107
215
|
</div>
|
|
108
216
|
</section>
|
|
109
217
|
|
|
218
|
+
<!-- Section header -->
|
|
219
|
+
<section class="space-y-5" data-section-header-specimen>
|
|
220
|
+
<div class="space-y-1">
|
|
221
|
+
<h3 class="type-h4 text-heading">Section header</h3>
|
|
222
|
+
<p class="text-muted text-sm">
|
|
223
|
+
<code class="font-mono text-2xs">studio/_section_header</code>: a section's title that sticks under
|
|
224
|
+
the page row until the next section's takes its place. This page's own section titles are these.
|
|
225
|
+
The rule is in <code class="font-mono text-2xs">docs/PAGE_HEADER.md</code>.
|
|
226
|
+
</p>
|
|
227
|
+
</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) %>
|
|
236
|
+
<% end %>
|
|
237
|
+
<% end %>
|
|
238
|
+
</div>
|
|
239
|
+
</section>
|
|
240
|
+
|
|
241
|
+
<!-- Carousel -->
|
|
242
|
+
<section class="space-y-5" data-carousel-specimen>
|
|
243
|
+
<div class="space-y-1">
|
|
244
|
+
<h3 class="type-h4 text-heading">Carousel</h3>
|
|
245
|
+
<p class="text-muted text-sm"><code class="font-mono text-2xs">studio/_carousel</code> is a swipeable row of any slides, with arrows, dots and arrow keys.</p>
|
|
246
|
+
</div>
|
|
247
|
+
<%# The live partial: a carousel joins no stack and holds no title, so the
|
|
248
|
+
specimen is the real thing. Swipe it, press its arrows, or focus it and
|
|
249
|
+
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 %>
|
|
254
|
+
<div class="w-full min-w-0">
|
|
255
|
+
<%= render layout: "studio/carousel",
|
|
256
|
+
locals: { label: "Specimen slides", items: (1..7).to_a, per_view: 3, id: "style-carousel-specimen" } do |n| %>
|
|
257
|
+
<div class="card h-full p-5 space-y-1">
|
|
258
|
+
<p class="label-upper">Slide <%= n %></p>
|
|
259
|
+
<p class="text-sm text-body">Any block content.</p>
|
|
260
|
+
</div>
|
|
261
|
+
<% end %>
|
|
262
|
+
</div>
|
|
263
|
+
<% end %>
|
|
264
|
+
|
|
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.",
|
|
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 %>
|
|
269
|
+
<div class="w-full min-w-0" data-carousel-spotlight-specimen>
|
|
270
|
+
<%= render layout: "studio/carousel",
|
|
271
|
+
locals: { label: "Spotlight specimen", items: (1..7).to_a, per_view: 3, style: :spotlight,
|
|
272
|
+
id: "style-carousel-spotlight-specimen", caption: ->(n) { "Slide #{n}" } } do |n| %>
|
|
273
|
+
<div class="card h-full p-5 space-y-1 aspect-[4/3]" style="background-image: linear-gradient(150deg, rgb(var(--color-primary-rgb) / 0.35), transparent)">
|
|
274
|
+
<p class="label-upper">Slide <%= n %></p>
|
|
275
|
+
<p class="text-sm text-body">Any block content.</p>
|
|
276
|
+
</div>
|
|
277
|
+
<% end %>
|
|
278
|
+
</div>
|
|
279
|
+
<% end %>
|
|
280
|
+
</section>
|
|
281
|
+
|
|
110
282
|
<!-- Surfaces, text and form -->
|
|
111
283
|
<section class="space-y-5">
|
|
112
284
|
<div class="space-y-1">
|
|
113
285
|
<h3 class="type-h4 text-heading">Surfaces, text & form</h3>
|
|
114
|
-
<p class="text-muted text-sm">Cards, badges, inputs, the empty state, and the uppercase meta label.</p>
|
|
115
286
|
</div>
|
|
116
287
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
117
288
|
<%= render layout: "style/specimen",
|
|
@@ -160,17 +331,8 @@
|
|
|
160
331
|
<section class="space-y-5">
|
|
161
332
|
<div class="space-y-1">
|
|
162
333
|
<h3 class="type-h4 text-heading">Time stamps — the “at” format</h3>
|
|
163
|
-
<p class="text-muted text-sm">
|
|
164
|
-
<code class="font-mono text-2xs">at_time_tag</code> stamps WHEN something happened, on
|
|
165
|
-
the reader’s own clock. The stamps below are live: the server rendered them in this
|
|
166
|
-
app’s timezone and <code class="font-mono text-2xs">studio/at_time_script</code>
|
|
167
|
-
re-stamped them in yours. Hover one for the relative time and your zone.
|
|
168
|
-
</p>
|
|
334
|
+
<p class="text-muted text-sm"><code class="font-mono text-2xs">at_time_tag</code> stamps when something happened, on the reader’s own clock.</p>
|
|
169
335
|
</div>
|
|
170
|
-
<%# An ERB snippet cannot be written literally inside an ERB template: the
|
|
171
|
-
scanner closes the tag at the first `%` + `>` it sees, whatever Ruby string
|
|
172
|
-
it sits in. So assemble the markers — the raw template never contains one. %>
|
|
173
|
-
<% erb = ->(code) { "<" + "%= " + code + " %" + ">" } %>
|
|
174
336
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
175
337
|
<%= render layout: "style/specimen",
|
|
176
338
|
locals: { klass: "at_time_tag(time)",
|
|
@@ -225,26 +387,19 @@
|
|
|
225
387
|
<!-- Branded loader -->
|
|
226
388
|
<%
|
|
227
389
|
loader_config = Studio.loader
|
|
228
|
-
# An ERB tag as text, built so this file never holds a closing tag inside one.
|
|
229
|
-
erb_tag = ->(code) { "<" + "%= #{code} %" + ">" }
|
|
230
390
|
loader_art_styles = Studio::Loader::ART_STYLES.keys.select do |style|
|
|
231
391
|
loader_config[:art] && Studio::Loader.art(loader_config[:art], style)
|
|
232
392
|
end
|
|
233
393
|
%>
|
|
234
|
-
<section id="loader" class="space-y-5"
|
|
394
|
+
<section id="loader" class="section-subtarget space-y-5">
|
|
235
395
|
<div class="space-y-1">
|
|
236
396
|
<h3 class="type-h4 text-heading">Branded loader</h3>
|
|
237
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
238
|
-
This app's own icon in its theme colours, in the style <code class="font-mono text-2xs">config.loader</code>
|
|
239
|
-
names (<strong><%= loader_config[:style].to_s.tr("_", " ") %></strong>). Page loads, long waits and pending
|
|
240
|
-
buttons all draw it. One class, <code class="font-mono text-2xs">.studio-loader</code>, sized by
|
|
241
|
-
<code class="font-mono text-2xs">--studio-loader-size</code>. studio-engine docs/LOADER.md.
|
|
242
|
-
</p>
|
|
397
|
+
<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>
|
|
243
398
|
</div>
|
|
244
399
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
245
400
|
<%= render layout: "style/specimen",
|
|
246
401
|
locals: { klass: ".studio-loader (this app's style)",
|
|
247
|
-
usage: [
|
|
402
|
+
usage: [erb.call("studio_loader"), erb.call('studio_loader size: 20, label: "Saving"')].join("\n") } do %>
|
|
248
403
|
<%= studio_loader %>
|
|
249
404
|
<%= studio_loader size: 32 %>
|
|
250
405
|
<%= studio_loader size: 20 %>
|
|
@@ -252,7 +407,7 @@
|
|
|
252
407
|
|
|
253
408
|
<%= render layout: "style/specimen",
|
|
254
409
|
locals: { klass: "studio_loader_pending (inside a busy button)",
|
|
255
|
-
usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{
|
|
410
|
+
usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb.call('studio_loader_pending')}", "</button>"].join("\n") } do %>
|
|
256
411
|
<button type="button" class="btn btn-primary" aria-busy="true">
|
|
257
412
|
<%= studio_loader_pending %><span class="ml-2">Saving…</span>
|
|
258
413
|
</button>
|
|
@@ -260,7 +415,7 @@
|
|
|
260
415
|
|
|
261
416
|
<%= render layout: "style/specimen",
|
|
262
417
|
locals: { klass: "studio_loader_overlay (the page-load overlay)",
|
|
263
|
-
usage: ["#{
|
|
418
|
+
usage: ["#{erb.call('studio_loader_overlay')} (once, in the layout body)", 'window.StudioLoader.show("Caption") / .hide()'].join("\n") } do %>
|
|
264
419
|
<button type="button" class="btn btn-secondary" data-test="style-show-loader-overlay"
|
|
265
420
|
x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 3000)">
|
|
266
421
|
Show the overlay for 3 seconds
|
|
@@ -285,18 +440,9 @@
|
|
|
285
440
|
<section class="space-y-5">
|
|
286
441
|
<div class="space-y-1">
|
|
287
442
|
<h3 class="type-h4 text-heading">Motion primitives</h3>
|
|
288
|
-
<p class="text-muted text-sm">
|
|
289
|
-
The seven <code class="font-mono text-2xs">engine-motion.css</code> primitives. Each knob is a themeable
|
|
290
|
-
CSS variable - tune it inline with <code class="font-mono text-2xs">style="--knob: value"</code>.
|
|
291
|
-
</p>
|
|
443
|
+
<p class="text-muted text-sm">Tune any knob inline with <code class="font-mono text-2xs">style="--knob: value"</code>.</p>
|
|
292
444
|
</div>
|
|
293
445
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
294
|
-
<%= render layout: "style/specimen",
|
|
295
|
-
locals: { klass: ".studio-border-glow (rounded-xl wrapper)",
|
|
296
|
-
usage: %(<div class="studio-border-glow rounded-xl">...</div>) } do %>
|
|
297
|
-
<div class="studio-border-glow rounded-xl p-5 bg-surface text-center text-sm text-heading">Border glow</div>
|
|
298
|
-
<% end %>
|
|
299
|
-
|
|
300
446
|
<%= render layout: "style/specimen",
|
|
301
447
|
locals: { klass: ".spinner (knob: --spinner-size)",
|
|
302
448
|
usage: %(<span class="spinner"></span>\n<span class="spinner" style="--spinner-size: 2rem"></span>) } do %>
|
|
@@ -316,13 +462,6 @@
|
|
|
316
462
|
<span class="loading-dots" style="--loading-dots-size: 12px; --loading-dots-color: var(--color-cta)"><span></span><span></span><span></span></span>
|
|
317
463
|
<% end %>
|
|
318
464
|
|
|
319
|
-
<%= render layout: "style/specimen",
|
|
320
|
-
locals: { klass: ".sheen .sheen--loop",
|
|
321
|
-
usage: %(<span class="sheen sheen--loop">...</span>) } do %>
|
|
322
|
-
<span class="sheen sheen--loop badge text-body">Level 5 unlocked</span>
|
|
323
|
-
<span class="sheen sheen--loop card px-4 py-2 text-sm text-body" style="--sheen-duration: 3s">Sheen sweep</span>
|
|
324
|
-
<% end %>
|
|
325
|
-
|
|
326
465
|
<%= render layout: "style/specimen",
|
|
327
466
|
locals: { klass: ".ping .ping--dot (knob: --ping-color)",
|
|
328
467
|
usage: %(<span class="ping ping--dot"></span>) } do %>
|
|
@@ -372,12 +511,6 @@
|
|
|
372
511
|
<section class="space-y-5">
|
|
373
512
|
<div class="space-y-1">
|
|
374
513
|
<h3 class="type-h4 text-heading">Effects</h3>
|
|
375
|
-
<p class="text-muted text-sm">
|
|
376
|
-
The <code class="font-mono text-2xs">engine-motion.css</code> visual-effect primitives beyond motion -
|
|
377
|
-
gradient text, a soft glow halo, a one- or two-color rotating <strong>selection glow</strong>
|
|
378
|
-
(ported from Turf Monster's selected-team-card ring), frosted glass, and a conic wash. Each is
|
|
379
|
-
themed through the role tokens and tunable via CSS-var knobs.
|
|
380
|
-
</p>
|
|
381
514
|
</div>
|
|
382
515
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
383
516
|
<%= render layout: "style/specimen",
|
|
@@ -399,21 +532,6 @@
|
|
|
399
532
|
</div>
|
|
400
533
|
<% end %>
|
|
401
534
|
|
|
402
|
-
<%# The CARD shape: the host carries its own background and the ring still
|
|
403
|
-
rings, because the primitive punches the host's box out of both wedge
|
|
404
|
-
layers. The WRAPPER shape (bare host + opaque child) is staged by the
|
|
405
|
-
Modals section's glow cards — both are documented in engine-motion.css
|
|
406
|
-
§9b. Two wedges travel the edge, so a pair of colors reads as a pair. %>
|
|
407
|
-
<%= render layout: "style/specimen",
|
|
408
|
-
locals: { klass: ".studio-team-glow (knobs: --studio-team-glow-color / -color-b)",
|
|
409
|
-
usage: %(<div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>\n<div class="studio-team-glow rounded-xl bg-surface p-5"\n style="--studio-team-glow-color: #F08030; --studio-team-glow-color-b: #6890F0">Fire / Flying</div>) } do %>
|
|
410
|
-
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
411
|
-
<div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Selected</div>
|
|
412
|
-
<div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center"
|
|
413
|
-
style="--studio-team-glow-color: #F08030; --studio-team-glow-color-b: #6890F0">Fire / Flying</div>
|
|
414
|
-
</div>
|
|
415
|
-
<% end %>
|
|
416
|
-
|
|
417
535
|
<%= render layout: "style/specimen",
|
|
418
536
|
locals: { klass: ".surface-glass (knobs: --surface-glass-opacity / -blur)",
|
|
419
537
|
usage: %(<div class="surface-glass rounded-xl p-4">...</div>) } do %>
|
|
@@ -433,115 +551,6 @@
|
|
|
433
551
|
</div>
|
|
434
552
|
</section>
|
|
435
553
|
|
|
436
|
-
<!-- Hold to confirm -->
|
|
437
|
-
<section class="space-y-5">
|
|
438
|
-
<div class="space-y-1">
|
|
439
|
-
<h3 class="type-h4 text-heading">Hold to confirm</h3>
|
|
440
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
441
|
-
A press-and-hold CTA for an action you do not want taken by accident, and the
|
|
442
|
-
carbonation behind it. Ported from Turf Monster, where it confirms a contest entry.
|
|
443
|
-
The bubbles sit <em>behind</em> the button and show only where they escape its edges;
|
|
444
|
-
they simmer at rest, boil on hover and through the hold, then burst outward on
|
|
445
|
-
success. The whole thing themes off the primary role token, so it wears this app’s
|
|
446
|
-
brand — and every color also reads a <code class="font-mono text-2xs">--hold-*</code>
|
|
447
|
-
input, so one instance can be re-themed from any ancestor.
|
|
448
|
-
<code class="font-mono text-2xs">prefers-reduced-motion</code> switches the bubbles off.
|
|
449
|
-
Inside a card that clips, pass <code class="font-mono text-2xs">fizz_portal: true</code>
|
|
450
|
-
and the bubbles rise past the card’s edge instead of stopping at it.
|
|
451
|
-
</p>
|
|
452
|
-
</div>
|
|
453
|
-
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
454
|
-
<%# 1. The fizz on its own: no button, just the layers. %>
|
|
455
|
-
<%= render layout: "style/specimen",
|
|
456
|
-
locals: { klass: ".hold-stack > .hold-fizz (.fizz-bit)",
|
|
457
|
-
usage: %(render "studio/fizz_layer", seed: "demo") } do %>
|
|
458
|
-
<div class="w-full max-w-xs">
|
|
459
|
-
<span class="hold-stack fizz-lively" style="height:3rem;--fizz-c-1:#8b5cf6;--fizz-c-4:#22d3ee;--fizz-c-7:#f59e0b;--fizz-c-10:#ef4444;--fizz-c-13:#10b981;--fizz-c-16:#ec4899">
|
|
460
|
-
<%= render "studio/fizz_layer", seed: "style-fizz" %>
|
|
461
|
-
<%= render "studio/fizz_layer", seed: "style-fizz~extra", extra: true %>
|
|
462
|
-
<span class="flex items-center justify-center h-12 text-xs text-muted">six zones, six colors</span>
|
|
463
|
-
</span>
|
|
464
|
-
</div>
|
|
465
|
-
<% end %>
|
|
466
|
-
|
|
467
|
-
<%# 2. The button itself, live: press and hold for two seconds. The card
|
|
468
|
-
clips, so this one sets fizz_portal and its bubbles rise past the
|
|
469
|
-
card's edge. %>
|
|
470
|
-
<%= render layout: "style/specimen",
|
|
471
|
-
locals: { klass: ".hold-btn (studio/hold_button)",
|
|
472
|
-
usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000, fizz_portal: true) } do %>
|
|
473
|
-
<div class="w-full max-w-xs" x-data="{ submitting: false }">
|
|
474
|
-
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
|
|
475
|
-
default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
|
|
476
|
-
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
477
|
-
@click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
|
|
478
|
-
</div>
|
|
479
|
-
<% end %>
|
|
480
|
-
|
|
481
|
-
<%# 3. The same button, re-themed from an ancestor — no new CSS. %>
|
|
482
|
-
<%= render layout: "style/specimen",
|
|
483
|
-
locals: { klass: ".hold-btn (--hold-* overrides)",
|
|
484
|
-
usage: %(<div style="--hold-bg-from:#f0abfc;--hold-success-to:#4c1d95">…</div>) } do %>
|
|
485
|
-
<div class="w-full max-w-xs" x-data="{ submitting: false }"
|
|
486
|
-
style="--hold-bg-from:#f0abfc;--hold-bg-mid:#a855f7;--hold-bg-to:#6b21a8;
|
|
487
|
-
--hold-success-from:#c4b5fd;--hold-success-to:#4c1d95;--hold-success-glow-rgb:139 92 246">
|
|
488
|
-
<%= render "studio/hold_button", hold_id: "style-hold-custom", duration: 2000,
|
|
489
|
-
default_text: "Hold to Confirm", success_text: "Confirmed!",
|
|
490
|
-
fizz_colors: %w[#f0abfc #6b21a8 #a855f7 #a5b4fc #312e81 #6366f1 #fbcfe8 #831843 #ec4899
|
|
491
|
-
#bae6fd #0c4a6e #0ea5e9 #ddd6fe #4c1d95 #8b5cf6 #fde68a #78350f #f59e0b] %>
|
|
492
|
-
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
493
|
-
@click="document.querySelector('.hold-btn[data-hold-id=style-hold-custom]').classList.remove('process','success','error','loading')">Reset</button>
|
|
494
|
-
</div>
|
|
495
|
-
<% end %>
|
|
496
|
-
</div>
|
|
497
|
-
</section>
|
|
498
|
-
|
|
499
|
-
<!-- Confetti & pulse -->
|
|
500
|
-
<section class="space-y-5">
|
|
501
|
-
<div class="space-y-1">
|
|
502
|
-
<h3 class="type-h4 text-heading">Confetti & pulse</h3>
|
|
503
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
504
|
-
Celebration effects lifted from Turf Monster into the engine. Confetti is
|
|
505
|
-
<code class="font-mono text-2xs">window.studioConfetti</code> - a zero-dependency,
|
|
506
|
-
no-CDN effect (canvas-confetti is vendored into the engine and shipped through the
|
|
507
|
-
asset pipeline). Two callable variants, both honoring
|
|
508
|
-
<code class="font-mono text-2xs">prefers-reduced-motion</code>. The pulse is the
|
|
509
|
-
<code class="font-mono text-2xs">.pulse-cta</code> attention beat. Fire the confetti
|
|
510
|
-
with the buttons below.
|
|
511
|
-
</p>
|
|
512
|
-
</div>
|
|
513
|
-
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
514
|
-
<%= render layout: "style/specimen",
|
|
515
|
-
locals: { klass: "window.studioConfetti.burst(el)",
|
|
516
|
-
usage: %(window.studioConfetti.burst(cardEl)\n// radial burst from behind a card/modal, from its center) } do %>
|
|
517
|
-
<div x-data="{}" class="flex flex-col items-center gap-3 py-2">
|
|
518
|
-
<div x-ref="burstCard" class="card px-5 py-4 text-sm font-semibold text-heading text-center">Success!</div>
|
|
519
|
-
<button type="button" class="btn btn-primary btn-sm"
|
|
520
|
-
@click="window.studioConfetti && window.studioConfetti.burst($refs.burstCard)">Fire burst</button>
|
|
521
|
-
</div>
|
|
522
|
-
<% end %>
|
|
523
|
-
|
|
524
|
-
<%= render layout: "style/specimen",
|
|
525
|
-
locals: { klass: "window.studioConfetti.cannons()",
|
|
526
|
-
usage: %(window.studioConfetti.cannons()\n// left + right side-cannons, full-screen celebration) } do %>
|
|
527
|
-
<div x-data="{}" class="flex flex-col items-center gap-3 py-2">
|
|
528
|
-
<p class="text-sm font-semibold text-heading text-center">You joined a contest!</p>
|
|
529
|
-
<button type="button" class="btn btn-success btn-sm"
|
|
530
|
-
@click="window.studioConfetti && window.studioConfetti.cannons()">Fire cannons</button>
|
|
531
|
-
</div>
|
|
532
|
-
<% end %>
|
|
533
|
-
|
|
534
|
-
<%= render layout: "style/specimen",
|
|
535
|
-
locals: { klass: ".pulse-cta (knobs: --pulse-cta-color / -speed / -scale)",
|
|
536
|
-
usage: %(<button class="btn btn-primary pulse-cta">Change Username</button>\n<button class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>) } do %>
|
|
537
|
-
<div class="flex flex-wrap items-center justify-center gap-4">
|
|
538
|
-
<button type="button" class="btn btn-primary pulse-cta">Change Username</button>
|
|
539
|
-
<button type="button" class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>
|
|
540
|
-
</div>
|
|
541
|
-
<% end %>
|
|
542
|
-
</div>
|
|
543
|
-
</section>
|
|
544
|
-
|
|
545
554
|
<!-- Leveling (capability-gated: Studio.feature?(:leveling)) -->
|
|
546
555
|
<section class="space-y-5">
|
|
547
556
|
<div class="space-y-1">
|
|
@@ -553,15 +562,7 @@
|
|
|
553
562
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
|
|
554
563
|
<% end %>
|
|
555
564
|
</div>
|
|
556
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
557
|
-
The real <code class="font-mono text-2xs">.level-badge</code> tiers, gated by
|
|
558
|
-
<code class="font-mono text-2xs">Studio.feature?(:leveling)</code>. Ported into the engine
|
|
559
|
-
(<code class="font-mono text-2xs">engine-motion.css</code>) from Turf Monster's leveling - the
|
|
560
|
-
outline starter through the holographic Level 10 - so any consumer with leveling on ships the
|
|
561
|
-
polished ladder. The tiers theme off <code class="font-mono text-2xs">--color-primary-*</code>, so
|
|
562
|
-
they restyle per app. Leveling is <strong><%= leveling_on ? "on" : "off" %></strong> here, so these
|
|
563
|
-
render <%= leveling_on ? "live" : "disabled-but-present - visible and previewable, but greyed and flagged" %>.
|
|
564
|
-
</p>
|
|
565
|
+
<p class="text-muted text-sm max-w-2xl">Level pills, gated by <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>.</p>
|
|
565
566
|
</div>
|
|
566
567
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
567
568
|
<%= render layout: "style/specimen",
|