studio-engine 0.141.3 → 0.142.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +103 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +90 -18
  5. data/app/components/previews/studio/button_component_preview/disabled.html.erb +12 -0
  6. data/app/components/previews/studio/button_component_preview/icon_and_link.html.erb +7 -0
  7. data/app/components/previews/studio/button_component_preview/loading.html.erb +12 -0
  8. data/app/components/previews/studio/button_component_preview/sizes.html.erb +9 -0
  9. data/app/components/previews/studio/button_component_preview/tones.html.erb +5 -0
  10. data/app/components/previews/studio/button_component_preview.rb +34 -0
  11. data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb +5 -0
  12. data/app/components/previews/studio/empty_state_component_preview.rb +29 -0
  13. data/app/components/previews/studio/table_component_preview/rows.html.erb +14 -0
  14. data/app/components/previews/studio/table_component_preview.rb +33 -0
  15. data/app/components/studio/button_component.html.erb +1 -0
  16. data/app/components/studio/button_component.rb +151 -0
  17. data/app/components/studio/empty_state_component.html.erb +12 -0
  18. data/app/components/studio/empty_state_component.rb +64 -0
  19. data/app/components/studio/table_component.html.erb +32 -0
  20. data/app/components/studio/table_component.rb +152 -0
  21. data/app/controllers/studio/modals_controller.rb +48 -0
  22. data/app/javascript/studio/carousel.js +52 -0
  23. data/app/javascript/studio/controllers/carousel_controller.js +7 -13
  24. data/app/javascript/studio/in_page_links.js +85 -11
  25. data/app/javascript/studio/modal_host.js +219 -0
  26. data/app/models/error_log.rb +5 -5
  27. data/app/views/components/_empty_state.html.erb +2 -6
  28. data/app/views/studio/modals/_host.html.erb +34 -2
  29. data/app/views/studio/modals/remote.html.erb +5 -0
  30. data/app/views/style/_components.html.erb +5 -2
  31. data/app/views/style/_glass.html.erb +9 -1
  32. data/app/views/style/_primitives.html.erb +106 -0
  33. data/config/studio_tricks.yml +6 -2
  34. data/db/migrate/20261109180000_add_engine_not_null_checks.rb +148 -0
  35. data/lib/studio/modals.rb +101 -0
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +11 -0
  38. metadata +22 -1
@@ -37,12 +37,24 @@ export const STATE_KEY = "studioInPage"
37
37
  // The mark on a target that took tabindex=-1 for focus here; engine.css draws no ring on it.
38
38
  export const FOCUS_MARK = "data-in-page-focus"
39
39
  // No scroll for this long ends the scroll, where the browser has no scrollend event. Where it
40
- // has one, only scrollend ends a scroll that moved: on a slow main thread (a phone, or CI at
41
- // 8x CPU) a smooth scroll's frames come further apart than this, and a quiet spell ended the
42
- // jump at its first frame. Then the target moved and nothing aimed again.
40
+ // has one, scrollend ends a scroll that moved (or the START_CAP_MS backstop below): on a slow
41
+ // main thread (a phone, or CI at 8x CPU) a smooth scroll's frames come further apart than
42
+ // this, and a quiet spell ended the jump at its first frame. Then the target moved and nothing
43
+ // aimed again.
43
44
  export const SETTLE_MS = 150
44
45
  // A scroll the reader never sees move still ends: the cap for a scroll that never starts.
46
+ // Where the browser has scrollend, it is also the backstop after each scroll event: a scroll
47
+ // that scrollend never follows (a page that shortens, and the browser pulls the scroll back
48
+ // with one scroll event and no scrollend) still ends. A slow frame (260ms at 8x CPU) does not.
45
49
  export const START_CAP_MS = 400
50
+ // After the end, the target holds its place on the screen for this long: content above it
51
+ // that grows or shrinks (a lazy frame the scroll passed, whose page lands later) moves the
52
+ // page by the same amount, as scroll anchoring would. The body has anchoring off
53
+ // (studio/_head: with it on, the navbar's collapse made the scroll bounce). The reader's own
54
+ // input or any scroll the module did not make ends the hold at once. Growth later than this
55
+ // is not followed: the window is bounded so a page that reflows long after a jump is the
56
+ // reader's, not the jump's. At 8x CPU the lab's feed landed 0.5 to 1.7s after the end.
57
+ export const HOLD_MS = 3000
46
58
  // A scroll aims again at a target that moved more than this, at most this many times.
47
59
  export const AIM_SLACK_PX = 2
48
60
  export const MAX_AIMS = 6
@@ -186,13 +198,67 @@ export function installInPageLinks(doc) {
186
198
  if (margin !== null) target.style.scrollMarginTop = inline
187
199
  }
188
200
  const pageTop = (target) => target.getBoundingClientRect().top + win.scrollY
201
+ const INPUTS = ["wheel", "touchstart", "keydown", "pointerdown"]
202
+
203
+ // After a jump's end: for HOLD_MS, a target that moved on the page moves the page with it,
204
+ // at once, so its place on the screen stays (scroll anchoring, for this one element). The
205
+ // check runs in the microtask after each change to the page's elements (a lazy frame's
206
+ // render), so no script and no frame sees the target out of place, and in each animation
207
+ // frame, for a size that changed with no new element (an image). It lets go at the first
208
+ // input, at any scroll that is not its own (the reader's scrollbar, an app's scrollTo, a
209
+ // clamp), at the next jump, at a Back or Forward, after MAX_AIMS moves, or at HOLD_MS.
210
+ const hold = (target) => {
211
+ if (!win.requestAnimationFrame) return
212
+ let held = win.scrollY
213
+ let top = target.getBoundingClientRect().top
214
+ let moves = 0
215
+ let frame = null
216
+ let timer = null
217
+ let observer = null
218
+ let done = false
219
+ const release = () => {
220
+ done = true
221
+ win.cancelAnimationFrame(frame)
222
+ win.clearTimeout(timer)
223
+ if (observer) observer.disconnect()
224
+ for (const name of INPUTS) win.removeEventListener(name, release, true)
225
+ if (settling === release) settling = null
226
+ }
227
+ const follow = () => {
228
+ if (done) return
229
+ // A scroll the hold did not make is the reader's or the app's: it lets go first.
230
+ if (Math.abs(win.scrollY - held) > AIM_SLACK_PX) return release()
231
+ // A target a render took out of the page has no place to keep.
232
+ if (target.isConnected === false) return release()
233
+ const now = target.getBoundingClientRect().top
234
+ if (Math.abs(now - top) <= AIM_SLACK_PX) return
235
+ if (moves >= MAX_AIMS) return release()
236
+ moves += 1
237
+ win.scrollBy({ top: now - top, behavior: "instant" })
238
+ held = win.scrollY
239
+ top = target.getBoundingClientRect().top
240
+ }
241
+ const check = () => {
242
+ follow()
243
+ if (!done) frame = win.requestAnimationFrame(check)
244
+ }
245
+ settling = release
246
+ if (win.MutationObserver) {
247
+ observer = new win.MutationObserver(follow)
248
+ observer.observe(doc.documentElement, { childList: true, subtree: true })
249
+ }
250
+ for (const name of INPUTS) win.addEventListener(name, release, { capture: true, passive: true })
251
+ timer = win.setTimeout(release, HOLD_MS)
252
+ frame = win.requestAnimationFrame(check)
253
+ }
189
254
 
190
255
  // One jump, from its start to its end. The browser fixes a smooth scroll's end where the
191
256
  // target was at the start. Content that grows on the way (a lazy frame the scroll passes,
192
257
  // the navbar's collapse) moves the target, so the scroll aims again, up to MAX_AIMS times,
193
258
  // until the reader takes over (a wheel, a touch, a key, a press). The end is the browser's
194
- // scrollend where it has one, a quiet spell where not; then the end event, once. A scroll
195
- // that never starts ends at the cap either way.
259
+ // scrollend where it has one (else START_CAP_MS after the last scroll event), a quiet spell
260
+ // where not; then the end event, once. A scroll that never starts ends at the cap either way. Then, unless the reader took over, the
261
+ // target holds its place for HOLD_MS (hold, above).
196
262
  const jump = (target, behavior) => {
197
263
  if (settling) settling()
198
264
  const hasScrollend = "onscrollend" in win
@@ -201,12 +267,15 @@ export function installInPageLinks(doc) {
201
267
  let aims = 0
202
268
  let reader = false
203
269
  let live = true
270
+ // A scroll event has come since the last aim. Chrome can send the scrollend of a scroll an
271
+ // aim replaced before the new scroll's first frame; that scrollend is not this aim's end.
272
+ let scrolled = false
204
273
  let aimed = pageTop(target)
205
274
  const takeOver = () => { reader = true }
206
- const INPUTS = ["wheel", "touchstart", "keydown", "pointerdown"]
207
275
  const reaim = () => {
208
276
  if (reader || aims >= MAX_AIMS || Math.abs(pageTop(target) - aimed) <= AIM_SLACK_PX) return false
209
277
  aims += 1
278
+ scrolled = false
210
279
  scrollTo(target, behavior)
211
280
  aimed = pageTop(target)
212
281
  return true
@@ -220,28 +289,31 @@ export function installInPageLinks(doc) {
220
289
  // The target moved after the scroll stopped: aim once more and wait for that end.
221
290
  if (reaim()) return wait(START_CAP_MS)
222
291
  win.removeEventListener("scroll", onScroll)
223
- win.removeEventListener("scrollend", end)
292
+ win.removeEventListener("scrollend", onScrollend)
224
293
  for (const name of INPUTS) win.removeEventListener(name, takeOver, true)
225
294
  win.clearTimeout(quiet)
226
295
  win.clearTimeout(cap)
227
296
  live = false
228
297
  if (settling === stop) settling = null
298
+ if (!reader) hold(target)
229
299
  doc.dispatchEvent(new win.CustomEvent(SCROLL_END_EVENT))
230
300
  }
231
301
  const onScroll = () => {
232
302
  win.clearTimeout(cap)
303
+ scrolled = true
233
304
  reaim()
234
- // The scroll has started: with scrollend, it alone ends it (an aim's cap is spent too).
235
- if (hasScrollend) win.clearTimeout(quiet)
236
- else wait(SETTLE_MS)
305
+ // The scroll has started. With scrollend, scrollend ends it, and START_CAP_MS after the
306
+ // last scroll event is the backstop for a scroll that scrollend never follows.
307
+ wait(hasScrollend ? START_CAP_MS : SETTLE_MS)
237
308
  }
309
+ const onScrollend = () => { if (scrolled) end() }
238
310
  const stop = () => {
239
311
  reader = true
240
312
  end()
241
313
  }
242
314
  settling = stop
243
315
  win.addEventListener("scroll", onScroll, { passive: true })
244
- if (hasScrollend) win.addEventListener("scrollend", end)
316
+ if (hasScrollend) win.addEventListener("scrollend", onScrollend)
245
317
  for (const name of INPUTS) win.addEventListener(name, takeOver, { capture: true, passive: true })
246
318
  cap = win.setTimeout(end, START_CAP_MS)
247
319
  scrollTo(target, behavior)
@@ -286,6 +358,8 @@ export function installInPageLinks(doc) {
286
358
  return
287
359
  }
288
360
  shownUrl = url
361
+ // The traversal's place wins over a jump or a hold still running.
362
+ if (settling) settling()
289
363
  // Turbo would start a restore visit; without Turbo the browser restores the scroll itself.
290
364
  if (event.state && event.state.turbo) {
291
365
  event.stopImmediatePropagation()
@@ -284,6 +284,170 @@ function focusTrap(env, held) {
284
284
  }
285
285
  }
286
286
 
287
+ // ---- on-demand modals (Studio.modals) ---------------------------------------
288
+ //
289
+ // A host whose app registers modals with Studio.modals advertises them on its
290
+ // template:
291
+ //
292
+ // data-modal-host-remote-value='{"url":"/_studio/modals/__id__",
293
+ // "ids":["contest-rules",…],"prefetch":["auth"]}'
294
+ //
295
+ // and marks its card data-modal-host-card. open(id) for an advertised id whose
296
+ // registration is not in the host yet fetches GET /_studio/modals/<id> once,
297
+ // wraps the fragment in the same <template x-if> a layout would have rendered,
298
+ // appends it to the card (in the host template's content, so every later mount
299
+ // has it, and in a card already on screen), then opens. An id the host does not
300
+ // advertise takes the plain open() path untouched.
301
+ //
302
+ // THE WAIT. A fetch that answers inside LOAD_DELAY_MS opens with no loading
303
+ // state at all. A slower one opens the card with $store.modals._remote[id] =
304
+ // 'loading' (the host's processing card), held at least MIN_LOAD_MS so it never
305
+ // flashes, then clears the flag and the content takes its place. A failure sets
306
+ // 'error' (the host's error card) and forgets the fetch, so the next open
307
+ // retries. So for the first LOAD_DELAY_MS of a first open, isOpen(id) is false.
308
+ //
309
+ // THE CACHE lives for the page's lifetime: fragments by id, beside the store,
310
+ // not on it. A Turbo visit brings a fresh host without the inserted templates
311
+ // (or a snapshot clone with them), so insertion checks the host every time and
312
+ // inserts only what is missing; that check is what keeps a Turbo visit from
313
+ // inserting a modal twice. A fetch that resolves after a Turbo visit replaced
314
+ // the body (doc.body changed) opens nothing on the new page.
315
+
316
+ export const REMOTE_MODAL_ATTR = 'data-studio-remote-modal'
317
+ export const LOAD_DELAY_MS = 150
318
+
319
+ // The host template advertising on-demand modals, if this page has one.
320
+ export function remoteHost(doc) {
321
+ return doc.querySelector ? doc.querySelector('template[data-modal-host-remote-value]') : null
322
+ }
323
+
324
+ // The advertised config, or null on a page without one (or with bad JSON).
325
+ export function remoteConfig(doc) {
326
+ var el = remoteHost(doc)
327
+ if (!el) return null
328
+ try {
329
+ var cfg = JSON.parse(el.getAttribute('data-modal-host-remote-value'))
330
+ if (!cfg || typeof cfg.url !== 'string' || !Array.isArray(cfg.ids)) return null
331
+ if (!Array.isArray(cfg.prefetch)) cfg.prefetch = []
332
+ return cfg
333
+ } catch (e) {
334
+ return null
335
+ }
336
+ }
337
+
338
+ // The <template x-if> a layout would have rendered around the partial. Ids are
339
+ // held to [a-z0-9_-] by Studio::Modals, so splicing one into the expression
340
+ // needs no escaping; anything else is refused before it gets here.
341
+ export function remoteTemplate(doc, id, markup) {
342
+ var t = doc.createElement('template')
343
+ t.setAttribute(REMOTE_MODAL_ATTR, id)
344
+ t.setAttribute('x-if', "$store.modals.current()?.id === '" + id + "' && !$store.modals._remote['" + id + "']")
345
+ t.innerHTML = markup
346
+ return t
347
+ }
348
+
349
+ // Every card the fragment belongs in: the one inside the host template's
350
+ // content, and any mounted on screen.
351
+ function remoteCards(doc) {
352
+ var cards = []
353
+ var host = remoteHost(doc)
354
+ var inner = host && host.content && host.content.querySelector('[data-modal-host-card]')
355
+ if (inner) cards.push(inner)
356
+ var live = doc.querySelectorAll ? doc.querySelectorAll('[data-modal-host-card]') : []
357
+ for (var i = 0; i < live.length; i++) cards.push(live[i])
358
+ return cards
359
+ }
360
+
361
+ function hasRemote(card, id) {
362
+ var kids = card.children || []
363
+ for (var i = 0; i < kids.length; i++) {
364
+ if (kids[i].getAttribute && kids[i].getAttribute(REMOTE_MODAL_ATTR) === id) return true
365
+ }
366
+ return false
367
+ }
368
+
369
+ // Appends the registration to every card missing it. Answers whether the host
370
+ // template now carries it (false: no marked card on this page).
371
+ export function insertRemoteModal(doc, id, markup) {
372
+ var cards = remoteCards(doc)
373
+ if (cards.length === 0) return false
374
+ for (var i = 0; i < cards.length; i++) {
375
+ if (!hasRemote(cards[i], id)) cards[i].appendChild(remoteTemplate(doc, id, markup))
376
+ }
377
+ return true
378
+ }
379
+
380
+ export function remoteModalUrl(cfg, id) {
381
+ return cfg.url.replace('__id__', encodeURIComponent(id))
382
+ }
383
+
384
+ // One fetch. Rejects unless the answer is the fragment the endpoint sends
385
+ // (X-Studio-Modal names the id): a host filter's redirect to a full page, or a
386
+ // 401, is an error, never a modal.
387
+ export function fetchRemoteModal(win, cfg, id) {
388
+ return win.fetch(remoteModalUrl(cfg, id), {
389
+ credentials: 'same-origin',
390
+ headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' }
391
+ }).then(function (res) {
392
+ if (!res.ok || !res.headers || res.headers.get('X-Studio-Modal') !== id) {
393
+ throw new Error('studio/modal_host: ' + id + ' answered ' + res.status)
394
+ }
395
+ return res.text()
396
+ })
397
+ }
398
+
399
+ // requestIdleCallback where the browser has it, else a timeout.
400
+ export function whenIdle(win, fn) {
401
+ if (typeof win.requestIdleCallback === 'function') win.requestIdleCallback(fn, { timeout: 4000 })
402
+ else win.setTimeout(fn, 1500)
403
+ }
404
+
405
+ // The loader one shared store keeps in its closure.
406
+ export function createRemoteModals(env) {
407
+ var doc = env.doc
408
+ var win = env.win
409
+ var pending = {} // id -> Promise<markup>, while in flight or resolved
410
+ var ready = {} // id -> markup, once resolved
411
+
412
+ function load(cfg, id) {
413
+ if (ready[id] !== undefined) return Promise.resolve(ready[id])
414
+ if (!pending[id]) {
415
+ pending[id] = fetchRemoteModal(win, cfg, id).then(function (markup) {
416
+ ready[id] = markup
417
+ return markup
418
+ }, function (err) {
419
+ delete pending[id]
420
+ throw err
421
+ })
422
+ }
423
+ return pending[id]
424
+ }
425
+
426
+ return {
427
+ // The config when id is advertised here, else null.
428
+ handles: function (id) {
429
+ if (typeof id !== 'string') return null
430
+ var cfg = remoteConfig(doc)
431
+ return (cfg && cfg.ids.indexOf(id) !== -1) ? cfg : null
432
+ },
433
+ load: load,
434
+ // Synchronous when the fragment is already here: inserts what this host
435
+ // lacks and answers true, so open() needs no wait.
436
+ insertIfReady: function (id) {
437
+ return ready[id] !== undefined && insertRemoteModal(doc, id, ready[id])
438
+ },
439
+ insert: function (id, markup) { return insertRemoteModal(doc, id, markup) },
440
+ prefetch: function () {
441
+ var cfg = remoteConfig(doc)
442
+ if (!cfg || typeof win.fetch !== 'function') return
443
+ cfg.prefetch.forEach(function (id) {
444
+ // A failed prefetch is retried by the open that needs it.
445
+ load(cfg, id).catch(function () {})
446
+ })
447
+ }
448
+ }
449
+ }
450
+
287
451
  // ---- the shared host's store, $store.modals --------------------------------
288
452
  //
289
453
  // env is { doc, win }: the document, and the window that carries setTimeout,
@@ -293,13 +457,68 @@ export function createModalStore(env) {
293
457
  var held = { returnFocusTo: null, backdrop: null }
294
458
  var store = focusTrap(env, held)
295
459
  store.stack = []
460
+ // id -> 'loading' | 'error' for an on-demand modal; read by the host's
461
+ // loading and error cards and by each fetched registration's x-if.
462
+ store._remote = {}
463
+ var remote = createRemoteModals(env)
296
464
 
297
465
  // open(id, props, opts)
298
466
  // opts.replace: swap the top entry. With a visible card, a directional
299
467
  // slide: it exits right (modal-card-swap-out) and the new one enters
300
468
  // left; opts.direction 'back' mirrors both. A plain mount when empty.
301
469
  // otherwise: push. Closing the new card reveals the one beneath.
470
+ // An on-demand id not yet in the host loads first (openRemote below).
302
471
  store.open = function (id, props, opts) {
472
+ var cfg = remote.handles(id)
473
+ if (cfg && !remote.insertIfReady(id)) return openRemote(this, cfg, id, props, opts)
474
+ return openNow.call(this, id, props, opts)
475
+ }
476
+
477
+ // The first open of an on-demand id. Never throws and never leaves a promise
478
+ // unhandled: every outcome is the modal, the loading card, or the error card.
479
+ function openRemote(self, cfg, id, props, opts) {
480
+ var body = env.doc.body
481
+ var shown = false
482
+ var hold = null
483
+ var done = false
484
+ if (self._remote[id] === 'error') delete self._remote[id]
485
+
486
+ var timer = win.setTimeout(function () {
487
+ if (done || env.doc.body !== body) return
488
+ shown = true
489
+ hold = holdAtLeast((win.StudioModals && win.StudioModals.MIN_LOAD_MS) || MIN_LOAD_MS)
490
+ self._remote[id] = 'loading'
491
+ openNow.call(self, id, props, opts)
492
+ }, LOAD_DELAY_MS)
493
+
494
+ function fail() {
495
+ if (done) return
496
+ done = true
497
+ win.clearTimeout && win.clearTimeout(timer)
498
+ if (env.doc.body !== body) return
499
+ self._remote[id] = 'error'
500
+ if (!shown) openNow.call(self, id, props, opts)
501
+ }
502
+
503
+ remote.load(cfg, id).then(function (markup) {
504
+ if (done) return
505
+ if (env.doc.body !== body) { done = true; win.clearTimeout && win.clearTimeout(timer); return }
506
+ if (!remote.insert(id, markup)) { fail(); return }
507
+ done = true
508
+ win.clearTimeout && win.clearTimeout(timer)
509
+ if (!shown) { openNow.call(self, id, props, opts); return }
510
+ hold.then(function () {
511
+ if (self._remote[id] === 'loading') delete self._remote[id]
512
+ })
513
+ }).catch(fail)
514
+ }
515
+
516
+ // Schedules the advertised prefetches once, when the browser idles.
517
+ if (env.doc && typeof env.doc.querySelector === 'function') {
518
+ whenIdle(win, function () { remote.prefetch() })
519
+ }
520
+
521
+ function openNow(id, props, opts) {
303
522
  props = props || {}
304
523
  opts = opts || {}
305
524
  var self = this
@@ -1,9 +1,9 @@
1
1
  class ErrorLog < ApplicationRecord
2
- # `slug` is unique (RequireSlugsOnEngineTables) and becomes NOT NULL a release
3
- # later, so every insert carries one, including a host's bare
4
- # `ErrorLog.create!`. Sluggable writes a unique placeholder before the insert
5
- # and settles it to `error-log-<id>` right after, inside the create, once the
6
- # id exists.
2
+ # `slug` is unique (RequireSlugsOnEngineTables) and refuses NULL (CHECK
3
+ # error_logs_slug_present, AddEngineNotNullChecks), so every insert carries
4
+ # one, including a host's bare `ErrorLog.create!`. Sluggable writes a unique
5
+ # placeholder before the insert and settles it to `error-log-<id>` right after,
6
+ # inside the create, once the id exists.
7
7
  include Sluggable
8
8
 
9
9
  belongs_to :target, polymorphic: true, optional: true
@@ -1,7 +1,3 @@
1
1
  <%# locals: (message:, detail: nil) %>
2
- <div class="empty-state">
3
- <p class="text-secondary text-lg"><%= message %></p>
4
- <% if detail.present? %>
5
- <p class="text-muted text-sm mt-1"><%= detail %></p>
6
- <% end %>
7
- </div>
2
+ <%# Kept for one release as a wrapper: Studio::EmptyStateComponent is the empty state now (docs/FRONT_END_STANDARD.md, "From partial to component"). -%>
3
+ <%= render Studio::EmptyStateComponent.new(message: message, detail: detail) %>
@@ -212,9 +212,17 @@
212
212
  bound to this host by the modal-host controller on the template below. The
213
213
  store registers before Alpine starts; see that module's header. %>
214
214
 
215
+ <%# ON-DEMAND MODALS (Studio.modals). Only when the app registers one does the
216
+ host advertise them (data-modal-host-remote-value), mark its card
217
+ (data-modal-host-card) and carry the loading and error cards below; an app
218
+ that registers none renders this partial exactly as before. studio/modal_host
219
+ fetches a registered id the first time it opens and inserts its
220
+ <template x-if> into the card. See the README, "Modals on demand". %>
221
+ <% studio_remote_modals = Studio.modals.any? %>
215
222
  <template x-if="$store.modals.current()"
216
223
  data-studio-controller="modal-host"
217
- data-modal-host-store-value="modals">
224
+ data-modal-host-store-value="modals"<% if studio_remote_modals %>
225
+ data-modal-host-remote-value="<%= Studio.modals.client_config(url_template: studio_modal_path(id: "0").delete_suffix("0") + "__id__", logged_in: respond_to?(:logged_in?) && logged_in?).to_json %>"<% end %>>
218
226
  <%# Backdrop + card use CSS keyframe animations (NOT Alpine
219
227
  x-transition), all shipped in the inline style block above. On
220
228
  mount, the .modal-backdrop-mount + card mount classes fire fade-in
@@ -271,7 +279,8 @@
271
279
  <%# No max-w-* here — cardClasses() supplies exactly one, per modal id, from
272
280
  the CARD_WIDTHS registry (studio/modal_host). max-h/overflow are unrelated and stay. %>
273
281
  <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full max-h-[85dvh] overflow-y-auto"
274
- :class="$store.modals.cardClasses()">
282
+ :class="$store.modals.cardClasses()"<% if studio_remote_modals %>
283
+ data-modal-host-card<% end %>>
275
284
  <%# Consumer-provided content registrations. Each block typically
276
285
  contains a <template x-if="$store.modals.current().id === 'X'">
277
286
  render "modals/X" </template>.
@@ -299,6 +308,29 @@
299
308
  <% if lookup_context.exists?("host_extras", ["modals"], true) %>
300
309
  <%= render "modals/host_extras" %>
301
310
  <% end %>
311
+
312
+ <%# The on-demand states, keyed on $store.modals._remote[id], which only
313
+ studio/modal_host sets and only for a registered id: 'loading' while a
314
+ slow first fetch runs (the processing card, held MIN_LOAD_MS so it
315
+ never flashes), 'error' when the fetch fails (the error card, whose
316
+ button closes). Fetched registrations are appended after these. %>
317
+ <% if studio_remote_modals %>
318
+ <template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'loading'">
319
+ <div data-modal-remote-state="loading">
320
+ <%= render "studio/modals/blocks/processing_card", title: "Loading…" %>
321
+ </div>
322
+ </template>
323
+ <template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'error'">
324
+ <div data-modal-remote-state="error">
325
+ <%= render "studio/modals/blocks/error_card",
326
+ title: "Couldn't open this",
327
+ message: "Check your connection and try again.",
328
+ icon_emoji: "⚠️",
329
+ cta_label: "Close",
330
+ cta_close: true %>
331
+ </div>
332
+ </template>
333
+ <% end %>
302
334
  </div>
303
335
  </div>
304
336
  </template>
@@ -0,0 +1,5 @@
1
+ <%# One on-demand modal's content, as Studio::ModalsController serves it: the
2
+ registered partial with its locals, resolved in THIS view, and nothing else.
3
+ No layout, no script: studio/modal_host wraps it in the host's
4
+ <template x-if> on the client. %>
5
+ <%= render @studio_modal.partial, @studio_modal.locals_for(self) %>
@@ -4,10 +4,13 @@
4
4
  The previews ARE the specimens, so this list cannot drift from the components.
5
5
  An app that does not draw the gallery here (lookbook not in its bundle, or
6
6
  production without Studio.lookbook_in_production) says so instead of linking
7
- to a 404. -%>
7
+ to a 404. Above the list, style/_primitives draws the Button, EmptyState and
8
+ Table components live on every app, gallery or not. -%>
8
9
  <section id="components" class="section-target space-y-6">
9
10
  <%= render "studio/section_header", title: "Components" %>
10
- <p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
11
+ <p class="text-muted text-sm max-w-2xl">The UI primitives every app renders, live, then one preview per state in the component gallery.</p>
12
+
13
+ <%= render "style/primitives" %>
11
14
 
12
15
  <% if Studio.lookbook_mounted? %>
13
16
  <ul class="grid gap-4 sm:grid-cols-2" data-component-previews>
@@ -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">Each one is opt-in per app.</p>
14
+ <p class="text-sm text-secondary mt-1">The standard for a new app. An existing app turns each switch on itself.</p>
15
15
  </div>
16
16
  <div class="grid gap-4 md:grid-cols-2">
17
17
  <%= render layout: "style/specimen",
@@ -64,6 +64,14 @@
64
64
  </div>
65
65
  </div>
66
66
  <% end %>
67
+ <%= render layout: "style/specimen",
68
+ locals: { trick: "glass", variant: "tile",
69
+ disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
70
+ <div class="flex flex-wrap items-center gap-3" data-test="style-lit-tiles">
71
+ <a href="#tricks" class="lit-tile inline-flex min-h-12 items-center bg-primary px-5 text-xs font-bold uppercase tracking-wide text-white">.lit-tile</a>
72
+ <a href="#tricks" class="lit-tile-soft inline-flex min-h-12 items-center border border-subtle px-5 text-xs font-bold uppercase tracking-wide text-heading">.lit-tile-soft</a>
73
+ </div>
74
+ <% end %>
67
75
  <%= render layout: "style/specimen",
68
76
  locals: { trick: "icon" } do %>
69
77
  <ul class="grid grid-cols-4 gap-3 w-full text-heading" data-test="style-glass-icons">
@@ -0,0 +1,106 @@
1
+ <%# locals: () -%>
2
+ <%# The engine's UI primitives, drawn live: every tone, size and state of
3
+ Studio::ButtonComponent, Studio::EmptyStateComponent and
4
+ Studio::TableComponent. Rendered from the components themselves, never a
5
+ copy of their markup, so a change to a component changes its specimen.
6
+
7
+ LIGHT AND DARK. Each specimen renders once and follows the page's theme: the
8
+ theme tokens are scoped to html and html.dark (Studio::ThemeResolver), and
9
+ nothing scopes a panel back to light inside a dark page. Flip the theme to
10
+ see the other one; e2e/ui_primitives_screenshots.spec.js captures both.
11
+
12
+ e2e/ui_primitives_screenshots.spec.js screenshots each [data-specimen] block
13
+ (the lab page /lab/ui_primitives renders this partial). Renaming one moves a
14
+ baseline. -%>
15
+ <%
16
+ tones = Studio::ButtonComponent::TONES.keys
17
+ sizes = Studio::ButtonComponent::SIZES.keys
18
+ plus = tag.svg(tag.path(d: "M10 4a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H5a1 1 0 1 1 0-2h4V5a1 1 0 0 1 1-1Z"),
19
+ class: "h-4 w-4", viewBox: "0 0 20 20", fill: "currentColor", aria: { hidden: true })
20
+ people = [
21
+ { name: "Ada Lovelace", role: "Admin", tasks: 12, status: "passed" },
22
+ { name: "Grace Hopper", role: "Builder", tasks: 7, status: "blocked" },
23
+ { name: "Katherine Johnson", role: "Reviewer", tasks: 3, status: "waiting" }
24
+ ]
25
+ status_tones = { "passed" => :success, "blocked" => :danger, "waiting" => :warning }
26
+ columns = [
27
+ { key: :name, label: "Member", sortable: true },
28
+ { key: :role, label: "Role" },
29
+ { key: :tasks, label: "Tasks", sortable: true, align: :right },
30
+ { key: :status, label: "Status", align: :center,
31
+ cell: ->(row) { render Studio::BadgeComponent.new(text: row[:status], tone: status_tones.fetch(row[:status])) } }
32
+ ]
33
+ sort_url = ->(key, direction) { "?sort=#{key}&direction=#{direction}#components" }
34
+ -%>
35
+ <div class="space-y-6" data-primitives>
36
+ <div class="card p-5 space-y-3" data-specimen="button-tones">
37
+ <h3 class="type-h4 text-heading">Button · tones</h3>
38
+ <div class="flex flex-wrap items-center gap-3">
39
+ <% tones.each do |tone| %>
40
+ <%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone) %>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="card p-5 space-y-3" data-specimen="button-sizes">
46
+ <h3 class="type-h4 text-heading">Button · sizes</h3>
47
+ <% tones.each do |tone| %>
48
+ <div class="flex flex-wrap items-center gap-3">
49
+ <% sizes.each do |size| %>
50
+ <%= render Studio::ButtonComponent.new(label: "#{tone.to_s.capitalize} #{size}", tone: tone, size: size) %>
51
+ <% end %>
52
+ </div>
53
+ <% end %>
54
+ </div>
55
+
56
+ <div class="card p-5 space-y-3" data-specimen="button-states">
57
+ <h3 class="type-h4 text-heading">Button · disabled, loading, icon, link</h3>
58
+ <div class="flex flex-wrap items-center gap-3">
59
+ <% tones.each do |tone| %>
60
+ <%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, disabled: true) %>
61
+ <% end %>
62
+ </div>
63
+ <div class="flex flex-wrap items-center gap-3">
64
+ <% tones.each do |tone| %>
65
+ <%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, loading: true) %>
66
+ <% end %>
67
+ <%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
68
+ </div>
69
+ <div class="flex flex-wrap items-center gap-3">
70
+ <%= render(Studio::ButtonComponent.new(label: "New task")) { |button| button.with_icon { plus } } %>
71
+ <%= render Studio::ButtonComponent.new(label: "Read the docs", tone: :quiet, href: "#components") %>
72
+ <%= render Studio::ButtonComponent.new(label: "Disabled link", tone: :quiet, href: "#components", disabled: true) %>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="grid gap-6 md:grid-cols-2">
77
+ <div class="space-y-3" data-specimen="empty-state">
78
+ <h3 class="type-h4 text-heading">Empty state · message and detail</h3>
79
+ <%= render Studio::EmptyStateComponent.new(message: "No errors logged yet.",
80
+ detail: "Errors appear here when ErrorLog.capture! records one.") %>
81
+ </div>
82
+ <div class="space-y-3" data-specimen="empty-state-action">
83
+ <h3 class="type-h4 text-heading">Empty state · icon and action</h3>
84
+ <%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start the team.")) do |empty| %>
85
+ <% empty.with_icon { plus } %>
86
+ <% empty.with_action(label: "Invite a member", href: "#components") %>
87
+ No members yet.
88
+ <% end %>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="space-y-3" data-specimen="table">
93
+ <h3 class="type-h4 text-heading">Table · sorted by member, a badge cell</h3>
94
+ <div class="card overflow-hidden">
95
+ <%= render Studio::TableComponent.new(rows: people, columns: columns, sort: "name", direction: "asc",
96
+ sort_url: sort_url, min_width: 520) %>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="space-y-3" data-specimen="table-empty">
101
+ <h3 class="type-h4 text-heading">Table · no rows</h3>
102
+ <div class="card overflow-hidden">
103
+ <%= render Studio::TableComponent.new(rows: [], columns: columns, sort_url: sort_url, empty: "No members yet.") %>
104
+ </div>
105
+ </div>
106
+ </div>