studio-engine 0.128.0 → 0.129.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 +45 -0
- data/app/assets/tailwind/studio_engine/engine.css +53 -0
- data/app/controllers/concerns/studio/error_handling.rb +17 -0
- data/app/javascript/studio/agent_chat.js +74 -3
- data/app/javascript/studio/agent_chat_voice.js +17 -1
- data/app/javascript/studio/alpine_stores.js +80 -22
- data/app/views/registrations/new.html.erb +4 -3
- data/app/views/sessions/new.html.erb +4 -3
- data/app/views/studio/agent_chat/_chat.html.erb +2 -1
- data/app/views/style/_components.html.erb +1 -3
- data/app/views/style/_glass.html.erb +1 -1
- data/app/views/style/_host.html.erb +1 -9
- data/app/views/style/_modals.html.erb +27 -220
- data/app/views/style/_scrims.html.erb +1 -8
- data/app/views/style/_site_footer.html.erb +4 -12
- data/app/views/style/_tasks.html.erb +15 -26
- data/app/views/style/_theme.html.erb +1 -7
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed5be658c4ba3547da2294995bc7c13e7181bccbd5710fd07d84c7fb0af95a2c
|
|
4
|
+
data.tar.gz: d48d2fbebaee5b7e33a22c16bc2f6d40b1afc161d0dacb6eb42d51a8e36cd4fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 99e6c74a4c11320449ee3b58d89b56712067f02bc6b2228d602f4ec942b06617b77960e05d8d29dc03dda95e5c92868d4da18bd67922494fbe84abb40b6e308f
|
|
7
|
+
data.tar.gz: 353b2199a4a651ee65a0d00c448c8273731b9a4bebbb5c5eff97fe97ba57177ae8f1a47ab0fa6d4b9f2f99856c46a8992c62c84d4319db28116b0dd557c05adf
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.129.0 — 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **The `/admin/style` copy is trimmed.** Each section keeps at most one short sentence of intro,
|
|
12
|
+
and the notes under specimens are cut the same way. Controls, specimens and error messages are
|
|
13
|
+
unchanged. The hidden copy-to-agent references on the modal cards are unchanged too. The Tricks
|
|
14
|
+
section is not part of this trim.
|
|
15
|
+
|
|
16
|
+
- **A bad or missing CSRF token on a JSON or `Accept: */*` request is now a 422 with no ErrorLog
|
|
17
|
+
row, on every controller using `Studio::ErrorHandling`.** The catch-all answered JSON 500 and
|
|
18
|
+
wrote a row. It now answers `422 {error, reason: "invalid_token"}` in every environment, and a
|
|
19
|
+
test on such a route that asserted `assert_raises(ActionController::InvalidAuthenticityToken)`
|
|
20
|
+
now asserts the 422. An HTML form is unchanged: the redirect home with its alert, and the row.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **A theme toggle is one crossfade.** `$store.theme.toggle()` added `theme-transition` for 300ms, and
|
|
25
|
+
in a host with that rule about 210 elements ran their own 200ms transition while gradients and
|
|
26
|
+
the page background snapped. It now flips the whole page in one 160ms view transition with no
|
|
27
|
+
movement. No element runs its own transition, and no named element keeps its own picture. A
|
|
28
|
+
browser with no view transitions, a reduced-motion reader, and a toggle during a Turbo visit
|
|
29
|
+
flip at once, and the visit's motion plays on. Under the crossfade the class, the store value
|
|
30
|
+
and localStorage change together in the transition's callback, so all three still read the old
|
|
31
|
+
theme for up to a frame after the call: a caller that reads them in the same tick must wait.
|
|
32
|
+
`toggleTheme` and `storedTheme` are no longer exported, and the store starts from the root's
|
|
33
|
+
class. `$store.theme.set("dark" | "light")` is the one setter, and `toggle()` stays. The
|
|
34
|
+
`theme-transition` rule in a host is now dead. Measured on the hub's `/deployments`, signed in,
|
|
35
|
+
2100 elements, Metal GPU: the largest step between frames fell from 35-65% of the brightness
|
|
36
|
+
change to 11%, and element transitions started by one toggle from about 1170 to 0.
|
|
37
|
+
Chromium sends every pointer hit to the root during a view transition, for about 160ms. A pointer
|
|
38
|
+
that stops in that time can show no `:hover` until it moves again, and a second click on the toggle
|
|
39
|
+
is dropped. A host spec that toggles and then hovers must wait for `data-theme-flip` to clear.
|
|
40
|
+
|
|
41
|
+
- **The agent chat at phone width.** Measured at 390x844 and 320x568, light and dark:
|
|
42
|
+
- No blank tail. A turn's `sr-only` name escaped the log, which had no positioned ancestor,
|
|
43
|
+
and stretched the document to 1933px where `main` ended at 977px. The log is now
|
|
44
|
+
`position: relative`.
|
|
45
|
+
- The newest turn shows above the composer, on load and after a new turn. The page scrolls
|
|
46
|
+
the thread's end clear of the stuck composer; not for an empty thread, not for a reader
|
|
47
|
+
scrolled up in the log or the page, and not during a hold, when it waits for the button
|
|
48
|
+
to be let go.
|
|
49
|
+
- A stale page says so. A refused token reads "This page has expired" with a reload hint on
|
|
50
|
+
the voice line and the typed error, and a refused send keeps its words in the box.
|
|
51
|
+
|
|
7
52
|
## 0.128.0 — 2026-11-05
|
|
8
53
|
|
|
9
54
|
### Fixed
|
|
@@ -799,6 +799,59 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
799
799
|
@keyframes pt-unfold-bottom { from { opacity: 0; transform: perspective(1200px) translateY(100%) rotateX(-90deg); } }
|
|
800
800
|
@keyframes pt-unfold-top { from { opacity: 0; transform: perspective(1200px) translateY(-100%) rotateX(90deg); } }
|
|
801
801
|
|
|
802
|
+
/* -- The theme flip --------------------------------------------------------
|
|
803
|
+
$store.theme.set (studio/alpine_stores) puts data-theme-flip on the root for one
|
|
804
|
+
flip. The whole page changes in one crossfade, whatever its size: no element
|
|
805
|
+
runs its own transition, no named element keeps its own picture, and nothing
|
|
806
|
+
here moves. !important because a wildcard pseudo-element is weaker than the
|
|
807
|
+
rules above and than a host's rule for a name. A pointer's click during the
|
|
808
|
+
crossfade hits the root, not the page (Chromium ignores pointer-events on these
|
|
809
|
+
pseudo-elements); a key press still reaches the focused toggle. */
|
|
810
|
+
html[data-theme-flip],
|
|
811
|
+
html[data-theme-flip] *,
|
|
812
|
+
html[data-theme-flip] *::before,
|
|
813
|
+
html[data-theme-flip] *::after {
|
|
814
|
+
transition: none !important;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/* The root keeps its name: it is the one picture. */
|
|
818
|
+
html[data-theme-flip] * {
|
|
819
|
+
view-transition-name: none !important;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
html[data-theme-flip]::view-transition {
|
|
823
|
+
background: none !important;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
html[data-theme-flip]::view-transition-group(*) {
|
|
827
|
+
animation: none !important;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
html[data-theme-flip]::view-transition-old(*),
|
|
831
|
+
html[data-theme-flip]::view-transition-new(*) {
|
|
832
|
+
mix-blend-mode: plus-lighter !important;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
html[data-theme-flip]::view-transition-old(*) {
|
|
836
|
+
animation: vt-theme-out 160ms linear both !important;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
html[data-theme-flip]::view-transition-new(*) {
|
|
840
|
+
animation: vt-theme-in 160ms linear both !important;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
@keyframes vt-theme-out {
|
|
844
|
+
to {
|
|
845
|
+
opacity: 0;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
@keyframes vt-theme-in {
|
|
850
|
+
from {
|
|
851
|
+
opacity: 0;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
802
855
|
/* Turbo's progress bar is the slow-load feedback under no-preview (the old
|
|
803
856
|
page holds until the fresh response). Theme it so every app's bar matches
|
|
804
857
|
its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
|
|
@@ -7,6 +7,8 @@ module Studio
|
|
|
7
7
|
# methods only.
|
|
8
8
|
include Studio::SessionDrift
|
|
9
9
|
|
|
10
|
+
INVALID_TOKEN_MESSAGE = "This page has expired. Reload it, then try again."
|
|
11
|
+
|
|
10
12
|
included do
|
|
11
13
|
# ORDER IS LOAD-BEARING — ActiveSupport::Rescuable resolves handlers with
|
|
12
14
|
# `reverse_each`, so the LAST matching rescue_from registered wins. The
|
|
@@ -19,6 +21,7 @@ module Studio
|
|
|
19
21
|
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
20
22
|
# A string, so including this concern does not load the model concern.
|
|
21
23
|
rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
|
|
24
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_authenticity_token
|
|
22
25
|
|
|
23
26
|
before_action :require_authentication
|
|
24
27
|
after_action :track_user_session
|
|
@@ -271,6 +274,20 @@ module Studio
|
|
|
271
274
|
end
|
|
272
275
|
end
|
|
273
276
|
|
|
277
|
+
# Layer 1: a bad or missing CSRF token on a fetch is a 422 the page can read, with
|
|
278
|
+
# no ErrorLog row: anyone can send one, and the catch-all answered it 500.
|
|
279
|
+
#
|
|
280
|
+
# HTML stays with the catch-all on purpose. Its redirect home hands a stale login or
|
|
281
|
+
# magic-link form a fresh token and a way on, where public/422.html is a dead end
|
|
282
|
+
# with no link (Back shows the same stale form). Its row is what the health report
|
|
283
|
+
# sees when a session config breaks every form at once.
|
|
284
|
+
def handle_invalid_authenticity_token(exception)
|
|
285
|
+
respond_to do |format|
|
|
286
|
+
format.json { render json: { error: INVALID_TOKEN_MESSAGE, reason: "invalid_token" }, status: :unprocessable_entity }
|
|
287
|
+
format.any { handle_unexpected_error(exception) }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
274
291
|
# Layer 1: Catch-all for unexpected errors — log + friendly response.
|
|
275
292
|
# Skips logging if rescue_and_log already captured it.
|
|
276
293
|
def handle_unexpected_error(exception)
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// until the app answers 201; a refusal shows its reason above them.
|
|
19
19
|
// FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
|
|
20
20
|
// it, and always after their own send (the hub's chat_follow rule).
|
|
21
|
+
// REVEAL the composer sticks over the window's bottom, so on a short window the
|
|
22
|
+
// thread's end sits under it. On load and after a new turn the page
|
|
23
|
+
// scrolls that end clear of the composer, with the same manners as
|
|
24
|
+
// FOLLOW: not for an empty thread, not for a reader who scrolled the page
|
|
25
|
+
// up (revealFollowing, until their own send), and never during a hold,
|
|
26
|
+
// when it waits for the button to be let go.
|
|
21
27
|
// REFRESH the public hook for code outside the chat (studio/agent_chat_voice, a
|
|
22
28
|
// host's own composer): dispatch studio:agent-chat:refresh on the
|
|
23
29
|
// chat's section, optionally with {detail: {turn}}, a turn the app
|
|
@@ -43,6 +49,11 @@ export const IDLE_MS = 30000
|
|
|
43
49
|
export const OFFLINE_MS = 15000
|
|
44
50
|
export const FOLLOW_PX = 20
|
|
45
51
|
export const LIMIT = 50
|
|
52
|
+
export const REVEAL_GAP_PX = 12
|
|
53
|
+
export const INVALID_TOKEN = "invalid_token"
|
|
54
|
+
export const EXPIRED_MESSAGE = "This page has expired. Copy your message, then reload the page to send it."
|
|
55
|
+
export const RECORDER_STATE_EVENT = "studio:voice-recorder:state"
|
|
56
|
+
const HOLDING = "[data-voice-recorder-state='recording'],[data-voice-recorder-state='starting']"
|
|
46
57
|
export const REFRESH_EVENT = "studio:agent-chat:refresh"
|
|
47
58
|
export const SENT_EVENT = "studio:agent-chat:sent"
|
|
48
59
|
const UNSETTLED = new Set(["pending", "typing"])
|
|
@@ -102,6 +113,12 @@ export function isFollowing(log, px = FOLLOW_PX) {
|
|
|
102
113
|
return log.scrollHeight - log.clientHeight - log.scrollTop <= px
|
|
103
114
|
}
|
|
104
115
|
|
|
116
|
+
// How many pixels of the thread's end the composer (or the window's bottom) hides;
|
|
117
|
+
// zero or less when it is clear of both. Rects are the elements' own.
|
|
118
|
+
export function coveredPx(endRect, composerRect, windowHeight) {
|
|
119
|
+
return endRect.bottom - Math.min(composerRect.top, windowHeight)
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
// The page's authenticity token: the form's own field, else the csrf meta.
|
|
106
123
|
export function csrfToken(form, doc = globalThis.document) {
|
|
107
124
|
const field = form && form.querySelector("input[name='authenticity_token']")
|
|
@@ -138,13 +155,57 @@ export function mountAgentChat(root, {
|
|
|
138
155
|
const soul = root.dataset.agentChatSoul
|
|
139
156
|
const name = root.dataset.agentChatName || "The agent"
|
|
140
157
|
|
|
141
|
-
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true }
|
|
158
|
+
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false }
|
|
142
159
|
|
|
143
160
|
const onScroll = () => { state.following = isFollowing(log) }
|
|
144
161
|
log.addEventListener("scroll", onScroll, { passive: true })
|
|
145
162
|
|
|
146
163
|
function toEnd() { log.scrollTop = log.scrollHeight }
|
|
147
164
|
|
|
165
|
+
// The last thing in the thread's column above the composer that is showing.
|
|
166
|
+
function threadEnd() {
|
|
167
|
+
return [offlineNote, typing].find((el) => el && el.hidden === false) || log
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function windowHeight() {
|
|
171
|
+
return doc && doc.documentElement ? doc.documentElement.clientHeight : 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function covered() {
|
|
175
|
+
const end = threadEnd()
|
|
176
|
+
if (!form || !end.getBoundingClientRect || !form.getBoundingClientRect) return 0
|
|
177
|
+
return coveredPx(end.getBoundingClientRect(), form.getBoundingClientRect(), windowHeight())
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// scrollIntoView, not window.scrollBy: a host may scroll an inner container. The
|
|
181
|
+
// margin is what the composer takes off the bottom of the window once it sticks:
|
|
182
|
+
// its height and its sticky bottom, not its top now, because a composer lower down
|
|
183
|
+
// the page is not stuck until this scroll has run. Instant, so a host's smooth
|
|
184
|
+
// scrolling cannot leave the page moving when a hold begins.
|
|
185
|
+
function revealEnd() {
|
|
186
|
+
if (root.querySelector(HOLDING)) { state.revealOwed = true; return }
|
|
187
|
+
state.revealOwed = false
|
|
188
|
+
if (!state.turns.length || covered() <= 0) return
|
|
189
|
+
const end = threadEnd()
|
|
190
|
+
const stuckAt = parseFloat(globalThis.getComputedStyle?.(form).bottom) || 0
|
|
191
|
+
end.style.scrollMarginBottom = `${form.getBoundingClientRect().height + stuckAt + REVEAL_GAP_PX}px`
|
|
192
|
+
end.scrollIntoView({ block: "end", behavior: "instant" })
|
|
193
|
+
end.style.scrollMarginBottom = ""
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A scroll of the page or a host's container says whether the reader is still at
|
|
197
|
+
// the thread's end. The log's own scroll, the box's and the picker's say nothing:
|
|
198
|
+
// only something that holds the chat can move it. A reveal passes this too, and
|
|
199
|
+
// ends there.
|
|
200
|
+
const onPageScroll = (event) => {
|
|
201
|
+
if (!event.target || !event.target.contains || !event.target.contains(root)) return
|
|
202
|
+
state.revealFollowing = covered() <= FOLLOW_PX
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const onRecorderState = () => {
|
|
206
|
+
if (state.revealOwed && !root.querySelector(HOLDING) && state.following && state.revealFollowing) revealEnd()
|
|
207
|
+
}
|
|
208
|
+
|
|
148
209
|
function setOffline(offline) {
|
|
149
210
|
state.offline = offline
|
|
150
211
|
if (offlineNote) offlineNote.hidden = !offline
|
|
@@ -185,7 +246,10 @@ export function mountAgentChat(root, {
|
|
|
185
246
|
const waiting = state.pending || state.turns.some((turn) => !settled(turn))
|
|
186
247
|
if (typing) typing.hidden = !waiting
|
|
187
248
|
if (phase) phase.dataset.phase = typingPhase(state.turns)
|
|
188
|
-
if (follow)
|
|
249
|
+
if (follow) {
|
|
250
|
+
toEnd()
|
|
251
|
+
if (state.revealFollowing) revealEnd()
|
|
252
|
+
}
|
|
189
253
|
}
|
|
190
254
|
|
|
191
255
|
function schedule() {
|
|
@@ -233,6 +297,7 @@ export function mountAgentChat(root, {
|
|
|
233
297
|
state.turns = mergeTurns(state.turns, [turn])
|
|
234
298
|
state.pending = turn.status !== "held"
|
|
235
299
|
state.following = true
|
|
300
|
+
state.revealFollowing = true
|
|
236
301
|
state.waitingSince = now()
|
|
237
302
|
setOffline(false)
|
|
238
303
|
render()
|
|
@@ -272,6 +337,7 @@ export function mountAgentChat(root, {
|
|
|
272
337
|
state.turns = mergeTurns(state.turns, [data.turn])
|
|
273
338
|
state.pending = data.turn.status !== "held"
|
|
274
339
|
state.following = true
|
|
340
|
+
state.revealFollowing = true
|
|
275
341
|
setOffline(false)
|
|
276
342
|
render()
|
|
277
343
|
toEnd()
|
|
@@ -281,7 +347,7 @@ export function mountAgentChat(root, {
|
|
|
281
347
|
clearTimer(state.timer)
|
|
282
348
|
state.timer = setTimer(poll, POLL_MS)
|
|
283
349
|
} else {
|
|
284
|
-
showError(data.error || `Couldn't send that to ${name}. Try again.`)
|
|
350
|
+
showError(data.reason === INVALID_TOKEN ? EXPIRED_MESSAGE : data.error || `Couldn't send that to ${name}. Try again.`)
|
|
285
351
|
if (status) status.textContent = ""
|
|
286
352
|
}
|
|
287
353
|
} catch (_error) {
|
|
@@ -313,7 +379,10 @@ export function mountAgentChat(root, {
|
|
|
313
379
|
input && input.addEventListener("keydown", onKeydown)
|
|
314
380
|
retry && retry.addEventListener("click", onRetry)
|
|
315
381
|
root.addEventListener(REFRESH_EVENT, onRefresh)
|
|
382
|
+
root.addEventListener(RECORDER_STATE_EVENT, onRecorderState)
|
|
316
383
|
doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
|
|
384
|
+
// Scroll events do not bubble; capture hears the page's and any container's.
|
|
385
|
+
doc && doc.addEventListener && doc.addEventListener("scroll", onPageScroll, { capture: true, passive: true })
|
|
317
386
|
root.dataset.agentChatReady = "true"
|
|
318
387
|
poll()
|
|
319
388
|
|
|
@@ -330,7 +399,9 @@ export function mountAgentChat(root, {
|
|
|
330
399
|
input && input.removeEventListener("keydown", onKeydown)
|
|
331
400
|
retry && retry.removeEventListener("click", onRetry)
|
|
332
401
|
root.removeEventListener(REFRESH_EVENT, onRefresh)
|
|
402
|
+
root.removeEventListener(RECORDER_STATE_EVENT, onRecorderState)
|
|
333
403
|
doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
|
|
404
|
+
doc && doc.removeEventListener && doc.removeEventListener("scroll", onPageScroll, { capture: true })
|
|
334
405
|
delete root.dataset.agentChatReady
|
|
335
406
|
}
|
|
336
407
|
}
|
|
@@ -50,6 +50,8 @@ export const MESSAGES = {
|
|
|
50
50
|
notConfigured: "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now.",
|
|
51
51
|
rateLimited: "That's a lot of voice messages in a row. Wait a moment, then hold to talk again.",
|
|
52
52
|
signedOut: "Sign in again to talk.",
|
|
53
|
+
expired: "This page has expired. Reload it, then hold to talk again.",
|
|
54
|
+
expiredSend: "This page has expired. Copy your words from the box, then reload the page to send them.",
|
|
53
55
|
unavailable: "I couldn't transcribe that just now. Try again in a moment.",
|
|
54
56
|
failed: "That recording didn't go through. Try again.",
|
|
55
57
|
offline: "Can't reach the server right now. Try again in a moment.",
|
|
@@ -65,11 +67,19 @@ export function retryHint(seconds) {
|
|
|
65
67
|
return ` Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// A refused CSRF token: the engine's own answer (Studio::ErrorHandling), or Rails'
|
|
71
|
+
// {status: 422, error: "Unprocessable Content"} from a host that re-raises it. Retrying
|
|
72
|
+
// on this page can never pass, and Rails' words mean nothing to a person.
|
|
73
|
+
export function tokenRefused(status, body = {}) {
|
|
74
|
+
return status === 422 && !!body && (body.reason === "invalid_token" || body.status === 422)
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
// What POST /voice/takes's refusal means on the page: {state, message}.
|
|
69
78
|
// `body` is the parsed JSON (or {}); `retryAfter` the header, if any.
|
|
70
79
|
export function refusalFor(status, body = {}, retryAfter = null) {
|
|
71
80
|
const said = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : null
|
|
72
81
|
if (status === 401) return { state: "refused", message: MESSAGES.signedOut }
|
|
82
|
+
if (tokenRefused(status, body)) return { state: "refused", message: MESSAGES.expired }
|
|
73
83
|
if (status === 429) {
|
|
74
84
|
return { state: "rate_limited", message: MESSAGES.rateLimited + retryHint(retryAfter ?? (body && body.retry_after)) }
|
|
75
85
|
}
|
|
@@ -235,6 +245,10 @@ export function mountAgentChatVoice(root, {
|
|
|
235
245
|
return
|
|
236
246
|
}
|
|
237
247
|
keepWords(answer.text)
|
|
248
|
+
if (tokenRefused(response.status, data)) {
|
|
249
|
+
say("refused", MESSAGES.expiredSend)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
238
252
|
const reason = typeof data.error === "string" && data.error ? `${data.error} ` : ""
|
|
239
253
|
say(response.status === 429 ? "rate_limited" : "failed", `${reason}${MESSAGES.notSent(name)}`)
|
|
240
254
|
}
|
|
@@ -255,7 +269,9 @@ export function mountAgentChatVoice(root, {
|
|
|
255
269
|
if (!response.ok || typeof body.text !== "string" || !body.text.trim()) {
|
|
256
270
|
const retryAfter = response.headers && response.headers.get ? response.headers.get("Retry-After") : null
|
|
257
271
|
const { state, message } = response.ok ? { state: "no_speech", message: MESSAGES.noSpeech } : refusalFor(response.status, body, retryAfter)
|
|
258
|
-
|
|
272
|
+
// A reload clears the box, so words already in it get the copy-first line.
|
|
273
|
+
const inBox = form && form.querySelector("[data-agent-chat-input]")?.value.trim()
|
|
274
|
+
say(state, inBox && tokenRefused(response.status, body) ? MESSAGES.expiredSend : message)
|
|
259
275
|
return
|
|
260
276
|
}
|
|
261
277
|
await send(body)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// studio/alpine_stores: the Alpine stores every host body binds to,
|
|
2
|
-
// $store.theme and $store.devMode, and the theme
|
|
2
|
+
// $store.theme and $store.devMode, and the theme setter behind them.
|
|
3
3
|
//
|
|
4
4
|
// WHY THIS IS ITS OWN ENTRY POINT. Every host's <body> says
|
|
5
5
|
// :class="{ 'dev-mode': $store.devMode }", and the engine's theme toggle reads
|
|
@@ -27,36 +27,92 @@
|
|
|
27
27
|
// inline, nonced script in the head: it must run before the stylesheet paints,
|
|
28
28
|
// which no deferred module can.
|
|
29
29
|
|
|
30
|
-
// The
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// The theme the root's class shows. The store starts here, not from storage: the pre-paint script
|
|
31
|
+
// reads storage by its own rule, and it decides what the reader already sees.
|
|
32
|
+
function classTheme(root) {
|
|
33
|
+
return root.classList.contains('dark') ? 'dark' : 'light'
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
root.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
36
|
+
// A visit owns the view transition. A second one would skip Turbo's and cut its motion short, so
|
|
37
|
+
// a toggle during a visit flips at once. data-turbo-visit-direction spans a visit; data-page-transition
|
|
38
|
+
// also spans a full load's arrival (studio/page_transitions).
|
|
39
|
+
function visiting(root) {
|
|
40
|
+
return root.dataset.turboVisitDirection !== undefined || root.dataset.pageTransition !== undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function canCrossfade(env) {
|
|
44
|
+
if (!env.doc || typeof env.doc.startViewTransition !== 'function') return false
|
|
45
|
+
if (env.win.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
|
|
46
|
+
return !visiting(env.root)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The theme store. set(value) is the one setter: the root class, the store value and localStorage
|
|
50
|
+
// change together, in one step, so they agree at every moment. Under a crossfade that step runs
|
|
51
|
+
// in the transition's callback, after the old picture: for up to a frame after set() all three
|
|
52
|
+
// still read the old theme. The pre-paint script is the one writer that cannot use this: it runs
|
|
53
|
+
// before any module does.
|
|
54
|
+
//
|
|
55
|
+
// data-theme-flip is on the root from before the old picture until the crossfade ends. engine.css
|
|
56
|
+
// reads it: the page is one picture, no element runs its own transition, and the page-transition
|
|
57
|
+
// motions lose. Without a crossfade it stays until the styles of the new theme have been computed.
|
|
58
|
+
export function themeStore(env) {
|
|
59
|
+
var root = env.root
|
|
60
|
+
var flips = 0
|
|
61
|
+
var wanted = null // the theme asked for, while its step has not run
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
value: classTheme(root),
|
|
65
|
+
get isDark() { return this.value === 'dark' },
|
|
66
|
+
set: function (value) {
|
|
67
|
+
if (value !== 'dark' && value !== 'light') return
|
|
68
|
+
if (value === wanted) return
|
|
69
|
+
var self = this
|
|
70
|
+
var commit = function () {
|
|
71
|
+
root.classList.toggle('dark', value === 'dark')
|
|
72
|
+
self.value = value
|
|
73
|
+
// A full or blocked storage must not throw past the instant path's settle and strand data-theme-flip.
|
|
74
|
+
try { env.storage.setItem('theme', value) } catch (_) { /* the page still flips; the choice is not kept */ }
|
|
75
|
+
}
|
|
76
|
+
// Nothing to show, but the three may have drifted: a host can write the class directly.
|
|
77
|
+
if (wanted === null && value === classTheme(root)) return commit()
|
|
78
|
+
|
|
79
|
+
var mine = ++flips
|
|
80
|
+
// Only the newest flip acts, so a late callback cannot undo a later choice.
|
|
81
|
+
var apply = function () {
|
|
82
|
+
if (mine !== flips) return
|
|
83
|
+
wanted = null
|
|
84
|
+
commit()
|
|
85
|
+
}
|
|
86
|
+
var settle = function () {
|
|
87
|
+
if (mine === flips) delete root.dataset.themeFlip
|
|
88
|
+
}
|
|
89
|
+
wanted = value
|
|
90
|
+
root.dataset.themeFlip = ''
|
|
91
|
+
if (!canCrossfade(env)) {
|
|
92
|
+
apply()
|
|
93
|
+
if (env.win) env.win.getComputedStyle(root).color // no transition starts from these styles
|
|
94
|
+
return env.later(settle, 0)
|
|
95
|
+
}
|
|
96
|
+
var transition = env.doc.startViewTransition(apply)
|
|
97
|
+
// A skipped transition rejects ready; a throw in apply rejects the other two.
|
|
98
|
+
transition.finished.then(settle, settle)
|
|
99
|
+
transition.ready.catch(function () {})
|
|
100
|
+
transition.updateCallbackDone.catch(function () {})
|
|
101
|
+
},
|
|
102
|
+
toggle: function () {
|
|
103
|
+
this.set((wanted || classTheme(root)) === 'dark' ? 'light' : 'dark')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
44
106
|
}
|
|
45
107
|
|
|
46
108
|
// Registers each store Alpine does not already have. env carries what the
|
|
47
|
-
// stores read and write: { storage, root, later }.
|
|
109
|
+
// stores read and write: { storage, root, later, doc, win }.
|
|
48
110
|
export function registerStudioStores(Alpine, env) {
|
|
49
111
|
if (Alpine.store('devMode') === undefined) {
|
|
50
112
|
Alpine.store('devMode', env.storage.getItem('devMode') === 'true')
|
|
51
113
|
}
|
|
52
114
|
if (Alpine.store('theme') === undefined) {
|
|
53
|
-
Alpine.store('theme',
|
|
54
|
-
value: storedTheme(env.storage),
|
|
55
|
-
get isDark() { return this.value === 'dark' },
|
|
56
|
-
toggle: function () {
|
|
57
|
-
this.value = toggleTheme(env.root, env.storage, env.later)
|
|
58
|
-
}
|
|
59
|
-
})
|
|
115
|
+
Alpine.store('theme', themeStore(env))
|
|
60
116
|
}
|
|
61
117
|
}
|
|
62
118
|
|
|
@@ -72,7 +128,9 @@ export function installStudioStores(doc, win) {
|
|
|
72
128
|
registerStudioStores(win.Alpine, {
|
|
73
129
|
storage: win.localStorage,
|
|
74
130
|
root: doc.documentElement,
|
|
75
|
-
later: function (fn, ms) { win.setTimeout(fn, ms) }
|
|
131
|
+
later: function (fn, ms) { win.setTimeout(fn, ms) },
|
|
132
|
+
doc: doc,
|
|
133
|
+
win: win
|
|
76
134
|
})
|
|
77
135
|
})
|
|
78
136
|
}
|
|
@@ -81,9 +81,10 @@
|
|
|
81
81
|
|
|
82
82
|
<% if Studio.auth_method?(:google) %>
|
|
83
83
|
<% if email_signup %>
|
|
84
|
-
<div class="
|
|
85
|
-
<div class="
|
|
86
|
-
<
|
|
84
|
+
<div class="flex items-center gap-3 my-6 text-sm">
|
|
85
|
+
<div class="flex-1 border-t border-subtle"></div>
|
|
86
|
+
<span class="text-secondary">or</span>
|
|
87
|
+
<div class="flex-1 border-t border-subtle"></div>
|
|
87
88
|
</div>
|
|
88
89
|
<% end %>
|
|
89
90
|
|
|
@@ -81,9 +81,10 @@
|
|
|
81
81
|
|
|
82
82
|
<% if Studio.auth_method?(:google) %>
|
|
83
83
|
<% if Studio.password_login_available? || Studio.auth_method?(:magic_link) %>
|
|
84
|
-
<div class="
|
|
85
|
-
<div class="
|
|
86
|
-
<
|
|
84
|
+
<div class="flex items-center gap-3 my-6 text-sm">
|
|
85
|
+
<div class="flex-1 border-t border-subtle"></div>
|
|
86
|
+
<span class="text-secondary">or</span>
|
|
87
|
+
<div class="flex-1 border-t border-subtle"></div>
|
|
87
88
|
</div>
|
|
88
89
|
<% end %>
|
|
89
90
|
|
|
@@ -42,8 +42,9 @@
|
|
|
42
42
|
<div class="flex flex-col gap-3 min-w-0" data-agent-chat-cards data-test="agent-chat-cards"><%= cards %></div>
|
|
43
43
|
<% end %>
|
|
44
44
|
<div class="flex flex-col gap-3 min-w-0">
|
|
45
|
+
<%# relative: a turn's sr-only name is absolutely positioned. With no positioned ancestor it escapes this scroller, is not clipped by it, and stretches the document by the thread's whole scrolled height. %>
|
|
45
46
|
<div role="log" aria-live="polite" aria-label="Conversation with <%= name %>"
|
|
46
|
-
class="flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
|
|
47
|
+
class="relative flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
|
|
47
48
|
data-agent-chat-log data-test="agent-chat-messages">
|
|
48
49
|
<p class="hidden only:block text-center text-secondary text-sm py-6" data-test="agent-chat-empty"><%= empty.presence || "Say hello to #{name}." %></p>
|
|
49
50
|
</div>
|
|
@@ -8,9 +8,7 @@
|
|
|
8
8
|
<section id="components" class="space-y-6" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
9
9
|
<div class="space-y-1">
|
|
10
10
|
<h2 class="type-h3 text-heading">Components</h2>
|
|
11
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
12
|
-
The engine's ViewComponent classes, one preview per state, in the component gallery.
|
|
13
|
-
</p>
|
|
11
|
+
<p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
|
|
14
12
|
</div>
|
|
15
13
|
|
|
16
14
|
<% if Studio.lookbook_mounted? %>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<section class="space-y-4" data-test="style-glass">
|
|
12
12
|
<div>
|
|
13
13
|
<h3 class="type-h4 text-heading">Glass</h3>
|
|
14
|
-
<p class="text-sm text-secondary mt-1">
|
|
14
|
+
<p class="text-sm text-secondary mt-1">Each one is opt-in per app.</p>
|
|
15
15
|
</div>
|
|
16
16
|
<div class="grid gap-4 md:grid-cols-2">
|
|
17
17
|
<%= render layout: "style/specimen",
|
|
@@ -94,15 +94,7 @@
|
|
|
94
94
|
style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
95
95
|
<div class="space-y-1">
|
|
96
96
|
<h2 class="type-h3 text-heading"><%= Studio.app_name %></h2>
|
|
97
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
98
|
-
This app’s own modals, contributed by
|
|
99
|
-
<code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">app/views/style/host/_modals.html.erb</code>.
|
|
100
|
-
Every section above belongs to the engine and is inherited; everything
|
|
101
|
-
here belongs to <%= Studio.app_name %> and ships with it. These specimens
|
|
102
|
-
drive the app’s <strong>real</strong>
|
|
103
|
-
<code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">$store.modals</code>
|
|
104
|
-
host, so the card that opens here is the card that opens in production.
|
|
105
|
-
</p>
|
|
97
|
+
<p class="text-muted text-sm max-w-2xl">This app’s own modals, from <code class="font-mono text-2xs">app/views/style/host/_modals.html.erb</code>.</p>
|
|
106
98
|
</div>
|
|
107
99
|
|
|
108
100
|
<%# The app's content. Reached only when it resolves - style/index.html.erb
|
|
@@ -156,15 +156,7 @@
|
|
|
156
156
|
@age-verified.window="$store.dsModals.open('entry-confirmed', { txSignature: null, lobbyUrl: null, seedsEarned: 0 })">
|
|
157
157
|
<div class="space-y-1">
|
|
158
158
|
<h2 class="type-h3 text-heading">Modals</h2>
|
|
159
|
-
<p class="text-muted text-sm">
|
|
160
|
-
The shared modal host and the reusable modal partials, ported from Turf
|
|
161
|
-
Monster into the engine. Each tile is a <strong>visual representation, not
|
|
162
|
-
to scale</strong> — <strong>click a whole card</strong> to open the real
|
|
163
|
-
modal (Enter/Space work too). The header <strong>Copy</strong> yields an
|
|
164
|
-
agent-ready reference you can paste into a chat; option <strong>toggles</strong>
|
|
165
|
-
configure the modal before you open it; and the card that matches the open
|
|
166
|
-
modal <strong>glows</strong>.
|
|
167
|
-
</p>
|
|
159
|
+
<p class="text-muted text-sm">Click a card to open the real modal.</p>
|
|
168
160
|
</div>
|
|
169
161
|
|
|
170
162
|
<%# ---- The section's own modal host (dsModals) --------------------------
|
|
@@ -462,22 +454,7 @@
|
|
|
462
454
|
<section id="modals-auth" class="space-y-5">
|
|
463
455
|
<div class="space-y-1">
|
|
464
456
|
<h3 class="type-h4 text-heading">Auth</h3>
|
|
465
|
-
<p class="text-muted text-sm">
|
|
466
|
-
<strong>The engine ships no sign-in card.</strong> A sign-in modal is a
|
|
467
|
-
product decision — which methods, what terms, whose legal copy
|
|
468
|
-
— so each app owns its own and cards it on <em>its</em> section of
|
|
469
|
-
this guide, against the partial that actually renders in production.
|
|
470
|
-
What the engine owns is the <strong>pieces</strong> that card is built
|
|
471
|
-
from, and they are below.
|
|
472
|
-
</p>
|
|
473
|
-
<p class="text-muted text-sm">
|
|
474
|
-
Until 2026-09-09 this section held a full auth modal
|
|
475
|
-
(<code class="font-mono text-2xs">style/modals/_auth</code>). It was a
|
|
476
|
-
<strong>mirror</strong> of turf-monster’s card, and it drifted:
|
|
477
|
-
reviewing it told you about a copy nobody shipped. It is gone, and these
|
|
478
|
-
two fragments — which it happened to be the only renderer of —
|
|
479
|
-
now stand on their own.
|
|
480
|
-
</p>
|
|
457
|
+
<p class="text-muted text-sm">The engine ships no sign-in card, only these pieces.</p>
|
|
481
458
|
</div>
|
|
482
459
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
483
460
|
<%# email field — the one piece every app's auth card renders. %>
|
|
@@ -524,31 +501,7 @@
|
|
|
524
501
|
<section id="modals-profile" class="space-y-5">
|
|
525
502
|
<div class="space-y-1">
|
|
526
503
|
<h3 class="type-h4 text-heading">Profile</h3>
|
|
527
|
-
<p class="text-muted text-sm">
|
|
528
|
-
Who the account IS — the name it goes by, the birthday that decides
|
|
529
|
-
eligibility, and the picture. The first three cards are asked at signup
|
|
530
|
-
(a host walks them post-auth), the last three are reachable forever from
|
|
531
|
-
the profile page; all six are engine primitives.
|
|
532
|
-
</p>
|
|
533
|
-
<p class="text-muted text-sm">
|
|
534
|
-
<strong>Birthday and Age gate are one flow, deliberately split into two
|
|
535
|
-
cards.</strong> The birthday card ASKS; when the answer is too young the
|
|
536
|
-
age-gate card ANSWERS, carrying a countdown to the day they qualify, a way
|
|
537
|
-
to watch instead, and a way back to fix a mistyped year. Until 2026-08-24
|
|
538
|
-
that refusal lived inside the birthday card as red text beside a disabled
|
|
539
|
-
button — a state with nothing to press. Open <strong>Birthday</strong>
|
|
540
|
-
with <strong>Age validation</strong> on and submit any date to walk the
|
|
541
|
-
handoff; switch the toggle off for the other mode, an app with no age bar
|
|
542
|
-
that just wants the date.
|
|
543
|
-
</p>
|
|
544
|
-
<p class="text-muted text-sm">
|
|
545
|
-
The avatar modals
|
|
546
|
-
(<code class="font-mono text-2xs">studio/modals/_crop_photo</code>,
|
|
547
|
-
<code class="font-mono text-2xs">studio/modals/_image_upload</code>)
|
|
548
|
-
open live here —
|
|
549
|
-
<code class="font-mono text-2xs">studio/cropper_assets</code> is rendered
|
|
550
|
-
above, so cropper.js loads and the modals mount on this page's host.
|
|
551
|
-
</p>
|
|
504
|
+
<p class="text-muted text-sm">The name, birthday and picture cards.</p>
|
|
552
505
|
</div>
|
|
553
506
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
554
507
|
<%= render layout: "style/modal_specimen", locals: {
|
|
@@ -568,11 +521,6 @@
|
|
|
568
521
|
<%# the skip link that sits under the button, and is also a skip %>
|
|
569
522
|
<span class="block h-1.5 w-14 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
|
|
570
523
|
</div>
|
|
571
|
-
<p class="text-muted text-2xs mt-3">
|
|
572
|
-
Saving or skipping dispatches
|
|
573
|
-
<code class="font-mono">ds-onboarding-step-done</code> here rather than
|
|
574
|
-
the app-level event, so the specimen cannot drive a real chain.
|
|
575
|
-
</p>
|
|
576
524
|
<% end %>
|
|
577
525
|
|
|
578
526
|
<%# BIRTHDAY — the ask. ONE card, two modes, because the difference between
|
|
@@ -718,28 +666,7 @@
|
|
|
718
666
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
|
|
719
667
|
<% end %>
|
|
720
668
|
</div>
|
|
721
|
-
<p class="text-muted text-sm">
|
|
722
|
-
The profile leveling flow, ported from Turf Monster as ONE primitive
|
|
723
|
-
(<code class="font-mono text-2xs">studio/modals/blocks/_leveling_activity</code>,
|
|
724
|
-
with <code class="font-mono text-2xs">_change_username</code> as its username
|
|
725
|
-
specialization). Walk all four states — <strong>Change Username → Great
|
|
726
|
-
Username</strong>, then <strong>Join the Newsletter → Subscribed!</strong>
|
|
727
|
-
— each "updated" card opens its modal straight at the success state, just like
|
|
728
|
-
the Auth cards open a given step. Each card carries its <strong>own Leveling
|
|
729
|
-
toggle</strong> (like the Auth card's method checkboxes): <strong>checked</strong>
|
|
730
|
-
is the Turf Monster shape (the seeds bar + Free Entry Token progress),
|
|
731
|
-
<strong>unchecked</strong> is the McRitchie Studio shape (plain input + Save,
|
|
732
|
-
then a simple confirmation). Flip a card's toggle, then open it — so you can
|
|
733
|
-
preview Change Username leveling-on beside Great Username leveling-off. Same
|
|
734
|
-
modal id either way — the primitive reads
|
|
735
|
-
<code class="font-mono text-2xs">leveling</code> at runtime, so there is no
|
|
736
|
-
with/without fork and <strong>no "Quest N of N" counter</strong>. The save is an
|
|
737
|
-
<strong>app-supplied callback</strong>
|
|
738
|
-
(<code class="font-mono text-2xs">submit_url</code> + an opaque
|
|
739
|
-
<code class="font-mono text-2xs">finalize_hook</code> for a wallet step) —
|
|
740
|
-
<strong>no wallet, signing, or on-chain write lives in the engine</strong>.
|
|
741
|
-
(These demos resolve locally.)
|
|
742
|
-
</p>
|
|
669
|
+
<p class="text-muted text-sm">Each card’s toggle switches leveling on or off before you open it.</p>
|
|
743
670
|
</div>
|
|
744
671
|
<%# The walk, left to right: Change Username -> Great Username -> Join Newsletter
|
|
745
672
|
-> Subscribed. The two "updated" cards open the same id at props.celebrate. %>
|
|
@@ -872,35 +799,10 @@
|
|
|
872
799
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
|
|
873
800
|
<% end %>
|
|
874
801
|
</div>
|
|
875
|
-
<p class="text-muted text-sm">
|
|
876
|
-
|
|
877
|
-
<
|
|
878
|
-
|
|
879
|
-
<%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
|
|
880
|
-
<% unless web3_gem %>
|
|
881
|
-
<strong>This app bundles no solana-studio</strong>, so the three cards it
|
|
882
|
-
backs — Connect wallet and the two Sign Wallet states — are
|
|
883
|
-
listed for reference but <strong>cannot be opened here</strong>. Nothing
|
|
884
|
-
registers them, so a trigger would open an empty panel.
|
|
885
|
-
<% end %>
|
|
886
|
-
The cards run in the order a player meets them — <strong>connect a
|
|
887
|
-
wallet, prove it, spend from it</strong>: Connect wallet → the two
|
|
888
|
-
Sign Wallet states → Entry confirmed. The
|
|
889
|
-
Connect-wallet picker ships its <strong>Phantom / Solflare / Backpack brand
|
|
890
|
-
marks inline from the engine</strong>
|
|
891
|
-
(<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
|
|
892
|
-
so every app inherits them with no per-app icon files.
|
|
893
|
-
The two <strong>Sign Wallet</strong> cards are a different kind of thing
|
|
894
|
-
from the rest of this section: they render solana-studio's
|
|
895
|
-
<code class="font-mono text-2xs">solana_studio/modals/_web3_step_up</code>
|
|
896
|
-
— the <strong>REAL shared partial a host renders in production</strong>,
|
|
897
|
-
not a style-guide copy of it — so what you review here is the
|
|
898
|
-
shipped card, and it cannot drift from what users see. They also carry the
|
|
899
|
-
<strong>standard web3 auth button</strong>: a wallet row (brand mark, the
|
|
900
|
-
wallet’s own name, Installed badge, chevron) rather than a filled
|
|
901
|
-
CTA, glowing via <code class="font-mono text-2xs">pulse-cta</code> because
|
|
902
|
-
it is the one thing to press.
|
|
903
|
-
</p>
|
|
802
|
+
<p class="text-muted text-sm">The wallet and on-chain modals, in the order a player meets them.</p>
|
|
803
|
+
<% unless web3_gem %>
|
|
804
|
+
<p class="text-muted text-sm">This app bundles no solana-studio, so the three cards it backs cannot be opened here.</p>
|
|
805
|
+
<% end %>
|
|
904
806
|
</div>
|
|
905
807
|
<%# ONE ordered run, read left to right as a player meets it: get a wallet,
|
|
906
808
|
prove it, spend from it. This carried three labelled h4 runs for a day
|
|
@@ -1042,28 +944,7 @@
|
|
|
1042
944
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
|
|
1043
945
|
<% end %>
|
|
1044
946
|
</div>
|
|
1045
|
-
<p class="text-muted text-sm">
|
|
1046
|
-
The pre-entry gate and the full entry flow. The age bar is enforced HERE,
|
|
1047
|
-
but its two cards —
|
|
1048
|
-
<code class="font-mono text-2xs">studio/modals/blocks/_birthday</code> (the
|
|
1049
|
-
ask) and <code class="font-mono text-2xs">_age_gate</code> (the refusal)
|
|
1050
|
-
— are shown in the <strong>Profile</strong> section, because they are
|
|
1051
|
-
about who the account is. Both are engine primitives gated by
|
|
1052
|
-
<code class="font-mono text-2xs">Studio.feature?(:age_gate)</code>, and their
|
|
1053
|
-
minimum age, jurisdiction label, endpoint, and legal copy are all
|
|
1054
|
-
<strong>app-supplied</strong> (the engine hardcodes no policy).
|
|
1055
|
-
</p>
|
|
1056
|
-
<p class="text-muted text-sm">
|
|
1057
|
-
<strong>The entry flow itself has no specimen here, on purpose.</strong>
|
|
1058
|
-
Packs, pricing, payment rails and every on-chain write are the app’s
|
|
1059
|
-
— the engine owns none of them, and the walked purchase sequence that
|
|
1060
|
-
used to sit in this section was a <strong>mirror</strong> of turf-monster’s
|
|
1061
|
-
cards. It drifted, and it demonstrated a payment face that had stopped
|
|
1062
|
-
rendering in production. It was retired on 2026-09-09; turf-monster cards
|
|
1063
|
-
the live flow on its own section of this guide.
|
|
1064
|
-
<strong>What stays is the map below</strong>, because the shape of the flow
|
|
1065
|
-
is the part an app should not have to rediscover.
|
|
1066
|
-
</p>
|
|
947
|
+
<p class="text-muted text-sm">The app owns the entry flow, so only its map lives here.</p>
|
|
1067
948
|
<%# The web2/web3 finding — a real divergence, stated honestly, not invented.
|
|
1068
949
|
THIS BOX IS THE SURVIVING HALF of style/modals/_entry_tokens, whose header
|
|
1069
950
|
carried the same model in a comment. When that mirror was deleted the
|
|
@@ -1071,28 +952,12 @@
|
|
|
1071
952
|
nobody opens — the two sentences about what a token IS, and about who
|
|
1072
953
|
signs the mint, came across in that move. %>
|
|
1073
954
|
<div class="mt-2 rounded-lg border border-subtle bg-surface p-3 text-xs text-secondary leading-snug">
|
|
1074
|
-
<p class="text-heading font-semibold mb-1">Web2 vs web3
|
|
1075
|
-
<p class="mb-1">
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
<strong>Web3</strong> (wallet / USDC on-chain) funds USDC directly and
|
|
1081
|
-
<strong>mints no token</strong> — the mint → consume prelude
|
|
1082
|
-
is web2-only.
|
|
1083
|
-
</p>
|
|
1084
|
-
<p class="mb-1">
|
|
1085
|
-
An entry token is a <strong>program-owned prepaid entry credit</strong>
|
|
1086
|
-
recorded on-chain as its own account — <strong>not</strong> a fungible
|
|
1087
|
-
SPL token sitting in the player’s wallet, which is the wrong mental
|
|
1088
|
-
model to carry into the code. And the mint is <strong>server-signed once
|
|
1089
|
-
the payment clears</strong>, never on the buyer’s click: the two
|
|
1090
|
-
on-chain moments a web2 entry has are that mint and the later consume.
|
|
1091
|
-
</p>
|
|
1092
|
-
<p>
|
|
1093
|
-
Both create the <strong>same on-chain Entry PDA</strong> and resolve to the
|
|
1094
|
-
<strong>same “Entry Confirmed” card</strong>.
|
|
1095
|
-
</p>
|
|
955
|
+
<p class="text-heading font-semibold mb-1">Web2 vs web3</p>
|
|
956
|
+
<p class="mb-1">Web2 (card, Stripe, Coinflow, PayPal) mints one prepaid entry token per credit after payment clears, then consumes one to enter.</p>
|
|
957
|
+
<p class="mb-1">Web3 (wallet, USDC) funds USDC directly and mints no token, so the mint and consume steps are web2-only.</p>
|
|
958
|
+
<p class="mb-1">An entry token is a program-owned prepaid credit on-chain, not a wallet SPL token.</p>
|
|
959
|
+
<p class="mb-1">The mint is server-signed once payment clears, never on the buyer’s click.</p>
|
|
960
|
+
<p>Both paths create the same on-chain Entry PDA and end on the same Entry Confirmed card.</p>
|
|
1096
961
|
</div>
|
|
1097
962
|
</div>
|
|
1098
963
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
@@ -1134,10 +999,6 @@
|
|
|
1134
999
|
<section id="modals-system-status" class="space-y-5">
|
|
1135
1000
|
<div class="space-y-1">
|
|
1136
1001
|
<h3 class="type-h4 text-heading">System & status</h3>
|
|
1137
|
-
<p class="text-muted text-sm">
|
|
1138
|
-
The reusable card blocks under <code class="font-mono text-2xs">studio/modals/blocks/</code> —
|
|
1139
|
-
the studio-canonical processing, success, error, and countdown states.
|
|
1140
|
-
</p>
|
|
1141
1002
|
</div>
|
|
1142
1003
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
1143
1004
|
<%= render layout: "style/modal_specimen", locals: {
|
|
@@ -1284,11 +1145,7 @@
|
|
|
1284
1145
|
<section id="modals-templates" class="space-y-5">
|
|
1285
1146
|
<div class="space-y-1">
|
|
1286
1147
|
<h3 class="type-h4 text-heading">Templates</h3>
|
|
1287
|
-
<p class="text-muted text-sm">
|
|
1288
|
-
Reusable archetypes under <code class="font-mono text-2xs">studio/modals/templates/</code> —
|
|
1289
|
-
the copy-from reference cards for a new modal (wizard, form, action,
|
|
1290
|
-
status, success).
|
|
1291
|
-
</p>
|
|
1148
|
+
<p class="text-muted text-sm">Copy from these when you build a new modal.</p>
|
|
1292
1149
|
</div>
|
|
1293
1150
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
1294
1151
|
<% [
|
|
@@ -1325,18 +1182,7 @@
|
|
|
1325
1182
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
|
|
1326
1183
|
<% end %>
|
|
1327
1184
|
</div>
|
|
1328
|
-
<p class="text-muted text-sm">
|
|
1329
|
-
The seeds bar + level-up celebrations, gated by
|
|
1330
|
-
<code class="font-mono text-2xs">Studio.feature?(:leveling)</code> — an
|
|
1331
|
-
app-level setting <strong>independent of :web3</strong> (some apps have
|
|
1332
|
-
Solana but no leveling). Leveling is <strong><%= leveling_on ? "on" : "off" %></strong>
|
|
1333
|
-
here, so these render
|
|
1334
|
-
<%= leveling_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
|
|
1335
|
-
The seed counts, level, and free entry are <strong>app-supplied props</strong>
|
|
1336
|
-
(<code class="font-mono text-2xs">seedsEarned</code>,
|
|
1337
|
-
<code class="font-mono text-2xs">seedsTotal</code>, …) — the engine renders
|
|
1338
|
-
the bar, the app feeds the numbers.
|
|
1339
|
-
</p>
|
|
1185
|
+
<p class="text-muted text-sm">The seeds bar and level-up celebrations.</p>
|
|
1340
1186
|
</div>
|
|
1341
1187
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
1342
1188
|
<%= render layout: "style/modal_specimen", locals: {
|
|
@@ -1396,27 +1242,16 @@
|
|
|
1396
1242
|
<section id="modals-motion-registry" class="space-y-5">
|
|
1397
1243
|
<div class="space-y-1">
|
|
1398
1244
|
<h3 class="type-h4 text-heading">Enter & leave animations</h3>
|
|
1399
|
-
<p class="text-muted text-sm">
|
|
1400
|
-
The per-modal entrance/exit registry —
|
|
1401
|
-
<code class="font-mono text-2xs">window.ModalAnimations</code>, published by
|
|
1402
|
-
<code class="font-mono text-2xs">studio/modals/_host</code>. Any modal opens with
|
|
1403
|
-
<code class="font-mono text-2xs">open(id, { enterAnim, exitAnim })</code>; omit either
|
|
1404
|
-
and it uses <code class="font-mono text-2xs">pop</code>. Every control below is
|
|
1405
|
-
<strong class="text-heading font-semibold">generated from the live registry</strong>,
|
|
1406
|
-
so registering a new animation surfaces it here with no edit to this page. Demo card:
|
|
1407
|
-
<code class="font-mono text-2xs">email-change-pending</code>.
|
|
1408
|
-
</p>
|
|
1245
|
+
<p class="text-muted text-sm">Every control comes from the live animation registry.</p>
|
|
1409
1246
|
</div>
|
|
1410
1247
|
|
|
1411
1248
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
1412
1249
|
<%# One control per registered ENTER key, built by the script below. %>
|
|
1413
1250
|
<div class="card p-5 space-y-3">
|
|
1414
1251
|
<h4 class="text-heading font-semibold text-sm">
|
|
1415
|
-
Enter
|
|
1252
|
+
Enter
|
|
1416
1253
|
</h4>
|
|
1417
|
-
<p class="text-secondary text-xs">
|
|
1418
|
-
Opens the demo card with that entrance. Dismiss with Escape, click-outside, or Close.
|
|
1419
|
-
</p>
|
|
1254
|
+
<p class="text-secondary text-xs">Opens the demo card.</p>
|
|
1420
1255
|
<div id="modal-anim-enter-buttons" class="flex flex-wrap gap-2"></div>
|
|
1421
1256
|
</div>
|
|
1422
1257
|
|
|
@@ -1424,18 +1259,15 @@
|
|
|
1424
1259
|
auto-closes a beat later so the exit keyframe is what you watch. %>
|
|
1425
1260
|
<div class="card p-5 space-y-3">
|
|
1426
1261
|
<h4 class="text-heading font-semibold text-sm">
|
|
1427
|
-
Leave
|
|
1262
|
+
Leave
|
|
1428
1263
|
</h4>
|
|
1429
|
-
<p class="text-secondary text-xs">
|
|
1430
|
-
Opens with a plain entrance, then closes itself so the exit keyframe is the star.
|
|
1431
|
-
</p>
|
|
1264
|
+
<p class="text-secondary text-xs">Opens, then closes itself.</p>
|
|
1432
1265
|
<div id="modal-anim-exit-buttons" class="flex flex-wrap gap-2"></div>
|
|
1433
1266
|
</div>
|
|
1434
1267
|
|
|
1435
1268
|
<%# The full matrix: any entrance crossed with any exit. %>
|
|
1436
1269
|
<div class="card p-5 space-y-3 sm:col-span-2">
|
|
1437
1270
|
<h4 class="text-heading font-semibold text-sm">Combine</h4>
|
|
1438
|
-
<p class="text-secondary text-xs">Pick any entrance and exit, then run the round trip.</p>
|
|
1439
1271
|
<div class="flex flex-wrap items-end gap-3">
|
|
1440
1272
|
<label class="text-xs text-secondary">Enter
|
|
1441
1273
|
<select id="modal-anim-enter-select"
|
|
@@ -1462,18 +1294,7 @@
|
|
|
1462
1294
|
<section id="modals-stack-mechanics" class="space-y-5">
|
|
1463
1295
|
<div class="space-y-1">
|
|
1464
1296
|
<h3 class="type-h4 text-heading">Stack behaviour</h3>
|
|
1465
|
-
<p class="text-muted text-sm">
|
|
1466
|
-
The engine modal-stack mechanics, poked at directly on
|
|
1467
|
-
<code class="font-mono text-2xs">$store.dsModals</code>:
|
|
1468
|
-
<code class="font-mono text-2xs">advance()</code>, dismissibility, the
|
|
1469
|
-
minimum-visible-duration floor, and LIFO stacking. They all run on one
|
|
1470
|
-
throwaway vehicle
|
|
1471
|
-
(<code class="font-mono text-2xs">style/modals/_ds_stack_demo</code>) built
|
|
1472
|
-
from engine blocks — <strong>the mechanics are the subject, the card
|
|
1473
|
-
is not</strong>. Until 2026-09-09 they ran on a copy of turf-monster’s
|
|
1474
|
-
on-chain card, which is how six unrelated demos came to depend on one
|
|
1475
|
-
consumer’s markup.
|
|
1476
|
-
</p>
|
|
1297
|
+
<p class="text-muted text-sm">The modal stack’s rules, shown on a throwaway card.</p>
|
|
1477
1298
|
</div>
|
|
1478
1299
|
|
|
1479
1300
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
@@ -1485,12 +1306,7 @@
|
|
|
1485
1306
|
vehicle carries three faces on one. %>
|
|
1486
1307
|
<div class="card p-5 space-y-3">
|
|
1487
1308
|
<h4 class="text-heading font-semibold text-sm">In-flow advance()</h4>
|
|
1488
|
-
<p class="text-secondary text-xs">
|
|
1489
|
-
<code class="font-mono text-2xs">advance(patch)</code> patches the OPEN
|
|
1490
|
-
entry rather than pushing a new one, so the card’s Alpine scope
|
|
1491
|
-
survives the transition. Watch the timed pair change a card that never
|
|
1492
|
-
left the stack.
|
|
1493
|
-
</p>
|
|
1309
|
+
<p class="text-secondary text-xs">Patches the open card, so its state survives.</p>
|
|
1494
1310
|
<div class="flex flex-wrap gap-2">
|
|
1495
1311
|
<button type="button" class="btn btn-outline btn-sm"
|
|
1496
1312
|
data-studio-action="click->style-modals#demo" data-style-modals-name-param="processing">Processing</button>
|
|
@@ -1509,10 +1325,7 @@
|
|
|
1509
1325
|
cannot orphan an in-flight signature. %>
|
|
1510
1326
|
<div class="card p-5 space-y-3">
|
|
1511
1327
|
<h4 class="text-heading font-semibold text-sm">Dismissibility</h4>
|
|
1512
|
-
<p class="text-secondary text-xs">
|
|
1513
|
-
A processing card passes <code class="font-mono text-2xs">dismissible: false</code>, so
|
|
1514
|
-
Escape and click-outside are both suppressed until it resolves.
|
|
1515
|
-
</p>
|
|
1328
|
+
<p class="text-secondary text-xs">A card with <code class="font-mono text-2xs">dismissible: false</code> ignores Escape and click-outside.</p>
|
|
1516
1329
|
<div class="flex flex-wrap gap-2">
|
|
1517
1330
|
<button type="button" class="btn btn-outline btn-sm"
|
|
1518
1331
|
data-studio-action="click->style-modals#demo" data-style-modals-name-param="processing">Try Escape (locked)</button>
|
|
@@ -1524,10 +1337,7 @@
|
|
|
1524
1337
|
<%# The anti-flicker floor from studio/modal_host (window.StudioModals.holdAtLeast). %>
|
|
1525
1338
|
<div class="card p-5 space-y-3">
|
|
1526
1339
|
<h4 class="text-heading font-semibold text-sm">Hold at least</h4>
|
|
1527
|
-
<p class="text-secondary text-xs">
|
|
1528
|
-
<code class="font-mono text-2xs">StudioModals.holdAtLeast(ms)</code> keeps a fast
|
|
1529
|
-
operation from flashing the spinner. Run both and watch the difference.
|
|
1530
|
-
</p>
|
|
1340
|
+
<p class="text-secondary text-xs">Keeps a fast operation from flashing the spinner.</p>
|
|
1531
1341
|
<div class="flex flex-wrap gap-2">
|
|
1532
1342
|
<button type="button" class="btn btn-primary btn-sm"
|
|
1533
1343
|
data-studio-action="click->style-modals#demo" data-style-modals-name-param="fastWithHold">Fast op, hold 1500ms</button>
|
|
@@ -1539,10 +1349,7 @@
|
|
|
1539
1349
|
<%# LIFO: open pushes, close pops back to what was underneath. %>
|
|
1540
1350
|
<div class="card p-5 space-y-3">
|
|
1541
1351
|
<h4 class="text-heading font-semibold text-sm">Stacking</h4>
|
|
1542
|
-
<p class="text-secondary text-xs">
|
|
1543
|
-
LIFO — a second <code class="font-mono text-2xs">open()</code> pushes the first
|
|
1544
|
-
card behind it, and <code class="font-mono text-2xs">close()</code> pops back to it.
|
|
1545
|
-
</p>
|
|
1352
|
+
<p class="text-secondary text-xs">A second <code class="font-mono text-2xs">open()</code> stacks on top, and <code class="font-mono text-2xs">close()</code> pops back.</p>
|
|
1546
1353
|
<div class="flex flex-wrap gap-2">
|
|
1547
1354
|
<button type="button" class="btn btn-primary btn-sm"
|
|
1548
1355
|
data-studio-action="click->style-modals#demo" data-style-modals-name-param="stackTwo">Open two</button>
|
|
@@ -38,14 +38,7 @@
|
|
|
38
38
|
<section id="scrims" class="space-y-5" data-scrim-group>
|
|
39
39
|
<div class="space-y-1">
|
|
40
40
|
<h3 class="type-h4 text-heading">Tints & shades</h3>
|
|
41
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
42
|
-
A <strong>shade</strong> lays the dark base over media and light ink reads on it; a
|
|
43
|
-
<strong>tint</strong> lays the light base over media and dark ink reads on it.
|
|
44
|
-
<code class="font-mono text-2xs">.scrim</code> picks by theme;
|
|
45
|
-
<code class="font-mono text-2xs">.scrim-tint</code> / <code class="font-mono text-2xs">.scrim-shade</code>
|
|
46
|
-
force one. Put the tone on the shared parent so the wash and the
|
|
47
|
-
<code class="font-mono text-2xs">.scrim-ink</code> text both take it.
|
|
48
|
-
</p>
|
|
41
|
+
<p class="text-muted text-sm max-w-2xl">A shade darkens media under light ink, and a tint lightens it under dark ink.</p>
|
|
49
42
|
</div>
|
|
50
43
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
51
44
|
<% specimens.each do |s| %>
|
|
@@ -36,12 +36,7 @@
|
|
|
36
36
|
<section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
37
37
|
<div class="space-y-1">
|
|
38
38
|
<h3 class="type-h4 text-heading">Site footer and booking</h3>
|
|
39
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
40
|
-
One layout line renders the public site's footer from
|
|
41
|
-
<code class="font-mono text-2xs">config.site_footer</code>; every part is optional. With
|
|
42
|
-
<code class="font-mono text-2xs">config.booking_url</code> the same pages get Google Calendar's
|
|
43
|
-
booking page inline, in a popup and on <code class="font-mono text-2xs">/schedule</code>.
|
|
44
|
-
</p>
|
|
39
|
+
<p class="text-muted text-sm max-w-2xl">One layout line renders the footer, and <code class="font-mono text-2xs">config.booking_url</code> turns on booking.</p>
|
|
45
40
|
<p class="text-muted text-sm" data-site-footer-status>
|
|
46
41
|
This app:
|
|
47
42
|
footer <strong class="text-body"><%= own_facts ? "declared" : (Studio.site_footer_enabled? ? "default (not declared)" : "off") %></strong> ·
|
|
@@ -55,10 +50,7 @@
|
|
|
55
50
|
<%= render layout: "style/specimen",
|
|
56
51
|
locals: { klass: "studio_site_footer",
|
|
57
52
|
usage: %(studio_site_footer # one line, at the end of the layout body) } do %>
|
|
58
|
-
<p class="text-sm text-secondary text-center">
|
|
59
|
-
The footer, where it should show: every page for a visitor, listed controllers when signed in.
|
|
60
|
-
Previewed below.
|
|
61
|
-
</p>
|
|
53
|
+
<p class="text-sm text-secondary text-center">Previewed below.</p>
|
|
62
54
|
<% end %>
|
|
63
55
|
|
|
64
56
|
<%= render layout: "style/specimen",
|
|
@@ -74,10 +66,10 @@
|
|
|
74
66
|
|
|
75
67
|
<% [ [ "studio_booking_frame", nil, "whole",
|
|
76
68
|
%(studio_booking_frame # whole at rest: the default, and crop: false),
|
|
77
|
-
"Whole at rest, the default.
|
|
69
|
+
"Whole at rest, the default." ],
|
|
78
70
|
[ "config.booking_crop", sample_crop, "cropped",
|
|
79
71
|
%(config.booking_crop = { top: 200, bottom: 600, frame_height: 720 } # bin/booking-crop-measure prints yours),
|
|
80
|
-
"Cropped
|
|
72
|
+
"Cropped to the slot picker at rest." ]
|
|
81
73
|
].each do |klass, crop, state, usage, caption| %>
|
|
82
74
|
<%= render layout: "style/specimen",
|
|
83
75
|
locals: { klass: klass, disabled: !booking, disabled_label: "no booking_url", usage: usage } do %>
|
|
@@ -66,14 +66,7 @@
|
|
|
66
66
|
<section id="tasks" class="space-y-8" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
67
67
|
<div class="space-y-1">
|
|
68
68
|
<h2 class="type-h3 text-heading">Tasks</h2>
|
|
69
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
70
|
-
The shared board primitive that every board builds from — columns, cards, the
|
|
71
|
-
<code class="font-mono text-2xs">stage-*</code> badge palette, and the drag / optimistic
|
|
72
|
-
move + reorder. McRitchie Studio's <code class="font-mono text-2xs">/deployments</code> board is
|
|
73
|
-
the top-of-line; the specimen below is the <strong>real</strong>
|
|
74
|
-
<code class="font-mono text-2xs">studio/board/board</code> primitive in
|
|
75
|
-
<code class="font-mono text-2xs">demo: true</code> mode — drag a card, no save.
|
|
76
|
-
</p>
|
|
69
|
+
<p class="text-muted text-sm max-w-2xl">The real board primitive in demo mode, so a drag saves nothing.</p>
|
|
77
70
|
</div>
|
|
78
71
|
|
|
79
72
|
<%# ---- The LIVE primitive (demo mode) --------------------------------- %>
|
|
@@ -81,7 +74,7 @@
|
|
|
81
74
|
<div class="space-y-3">
|
|
82
75
|
<div class="flex items-center gap-2">
|
|
83
76
|
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>
|
|
84
|
-
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · demo
|
|
77
|
+
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · demo</p>
|
|
85
78
|
</div>
|
|
86
79
|
<%= render "studio/board/board",
|
|
87
80
|
demo: true,
|
|
@@ -120,7 +113,7 @@
|
|
|
120
113
|
data-test="chrome-archived-toggle">
|
|
121
114
|
<span x-text="state.showArchived ? 'Hide archived' : 'Show archived'">Show archived</span>
|
|
122
115
|
</button>
|
|
123
|
-
<span class="label-upper"><code class="font-mono text-2xs">header_slot</code>
|
|
116
|
+
<span class="label-upper"><code class="font-mono text-2xs">header_slot</code> · binds to <code class="font-mono text-2xs">state.showArchived</code></span>
|
|
124
117
|
</div>
|
|
125
118
|
<% end %>
|
|
126
119
|
<% chrome_kickoff = capture do %>
|
|
@@ -150,7 +143,7 @@
|
|
|
150
143
|
<div class="space-y-3">
|
|
151
144
|
<div class="flex items-center gap-2">
|
|
152
145
|
<span class="badge" style="color: var(--color-primary); border-color: var(--color-primary)">Enriched</span>
|
|
153
|
-
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> ·
|
|
146
|
+
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · coloured counts, toggle-able column, archive re-parent</p>
|
|
154
147
|
</div>
|
|
155
148
|
<%= render "studio/board/board",
|
|
156
149
|
demo: true,
|
|
@@ -200,7 +193,7 @@
|
|
|
200
193
|
<div class="space-y-3">
|
|
201
194
|
<div class="flex items-center gap-2">
|
|
202
195
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">Grid + Lock</span>
|
|
203
|
-
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> ·
|
|
196
|
+
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · two-level grid, reorder within a lane, 🔒 pinned starters</p>
|
|
204
197
|
</div>
|
|
205
198
|
<%= render "studio/board/board",
|
|
206
199
|
demo: true,
|
|
@@ -246,17 +239,13 @@
|
|
|
246
239
|
|
|
247
240
|
<%# ---- The stage palette ---------------------------------------------- %>
|
|
248
241
|
<div class="space-y-3">
|
|
249
|
-
<p class="label-upper">Stage palette
|
|
242
|
+
<p class="label-upper">Stage palette</p>
|
|
250
243
|
<div class="flex flex-wrap gap-2">
|
|
251
244
|
<% %w[stage-fresh stage-shaping stage-structured stage-refined stage-cohered stage-shipped stage-closed].each do |stage| %>
|
|
252
245
|
<span data-stage="<%= stage %>"><%= render "components/badge", text: stage.sub("stage-", ""), scheme: stage %></span>
|
|
253
246
|
<% end %>
|
|
254
247
|
</div>
|
|
255
|
-
<p class="text-muted text-xs">
|
|
256
|
-
Seven stage roles from <code class="font-mono text-2xs">components/_badge</code>. Every pipeline
|
|
257
|
-
(tasks, deployments, and any future board) draws its columns from this one palette, so a card
|
|
258
|
-
reads the same wherever it lives.
|
|
259
|
-
</p>
|
|
248
|
+
<p class="text-muted text-xs">Every pipeline draws its columns from this palette.</p>
|
|
260
249
|
</div>
|
|
261
250
|
|
|
262
251
|
<%# ---- Standard vs custom -------------------------------------------- %>
|
|
@@ -267,12 +256,12 @@
|
|
|
267
256
|
<p class="text-heading font-semibold text-sm">What every board inherits</p>
|
|
268
257
|
</div>
|
|
269
258
|
<ul class="text-secondary text-sm space-y-1 list-disc list-inside">
|
|
270
|
-
<li>Column layout
|
|
271
|
-
<li>The <code class="font-mono text-2xs">stage-*</code>
|
|
272
|
-
<li>Card chrome
|
|
259
|
+
<li>Column layout and horizontal scroll</li>
|
|
260
|
+
<li>The <code class="font-mono text-2xs">stage-*</code> badges and count chips</li>
|
|
261
|
+
<li>Card chrome and the drag handle</li>
|
|
273
262
|
<li>Empty-column drop state</li>
|
|
274
|
-
<li>
|
|
275
|
-
<li>Toggle-able columns
|
|
263
|
+
<li>Filters and toggles with no outer wrapper</li>
|
|
264
|
+
<li>Toggle-able columns, a kickoff slot and archive re-parent</li>
|
|
276
265
|
</ul>
|
|
277
266
|
</div>
|
|
278
267
|
<div class="card p-5 space-y-2">
|
|
@@ -282,9 +271,9 @@
|
|
|
282
271
|
</div>
|
|
283
272
|
<ul class="text-secondary text-sm space-y-1 list-disc list-inside">
|
|
284
273
|
<li>Which stages become columns</li>
|
|
285
|
-
<li>The card body
|
|
286
|
-
<li>Filter
|
|
287
|
-
<li>Live
|
|
274
|
+
<li>The card body</li>
|
|
275
|
+
<li>Filter logic and header actions</li>
|
|
276
|
+
<li>Live updates</li>
|
|
288
277
|
</ul>
|
|
289
278
|
</div>
|
|
290
279
|
</div>
|
|
@@ -58,11 +58,7 @@
|
|
|
58
58
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
59
59
|
<div class="space-y-1">
|
|
60
60
|
<h2 class="type-h3 text-heading">Theme</h2>
|
|
61
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
62
|
-
The color foundation. The 7-role system, editable here. Edits preview live; Save writes them
|
|
63
|
-
and Regenerate clears the theme cache. Both persist <strong>in place</strong> — the page never
|
|
64
|
-
leaves. Every specimen on this page restyles from these roles.
|
|
65
|
-
</p>
|
|
61
|
+
<p class="text-muted text-sm max-w-2xl">Every specimen on this page restyles from these 7 color roles.</p>
|
|
66
62
|
</div>
|
|
67
63
|
<form action="/admin/theme/regenerate" method="post" accept-charset="UTF-8" @submit.prevent="regenerate($event)">
|
|
68
64
|
<% if csrf_ok %>
|
|
@@ -108,7 +104,6 @@
|
|
|
108
104
|
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" autocomplete="off">
|
|
109
105
|
<% end %>
|
|
110
106
|
<h3 class="type-h4 text-heading">Role colors</h3>
|
|
111
|
-
<p class="text-muted text-sm">Every specimen on this page reads these through its CSS variable, so it follows the app and its light or dark mode.</p>
|
|
112
107
|
|
|
113
108
|
<% roles.each do |role| %>
|
|
114
109
|
<% db_col = db_col_for.call(role) %>
|
|
@@ -164,7 +159,6 @@
|
|
|
164
159
|
<%= render "style/type_ladder" %>
|
|
165
160
|
<div class="space-y-3" x-show="edited" x-cloak data-test="style-theme-preview">
|
|
166
161
|
<p class="label-upper">Preview</p>
|
|
167
|
-
<p class="text-muted text-sm">A live dark/light mock — it tracks the editor as you type, before you save.</p>
|
|
168
162
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
169
163
|
<div class="rounded-xl border border-subtle overflow-hidden">
|
|
170
164
|
<div class="px-3 py-1.5 bg-surface-alt"><span class="text-2xs font-bold uppercase tracking-wider text-secondary">Dark</span></div>
|
data/lib/studio/version.rb
CHANGED