studio-engine 0.128.0 → 0.130.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 +86 -0
- data/README.md +5 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +148 -56
- data/app/assets/tailwind/studio_engine/engine.css +203 -19
- data/app/controllers/concerns/studio/error_handling.rb +17 -0
- data/app/helpers/studio/fizz_helper.rb +5 -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/javascript/studio/controllers/style_section_nav_controller.js +4 -4
- data/app/javascript/studio/pinned_stack.js +112 -4
- data/app/views/registrations/new.html.erb +4 -3
- data/app/views/sessions/new.html.erb +4 -3
- data/app/views/studio/_fizz.html.erb +26 -0
- data/app/views/studio/_fizz_layer.html.erb +4 -1
- data/app/views/studio/_hold_button.html.erb +1 -2
- data/app/views/studio/_page_header.html.erb +3 -2
- data/app/views/studio/_section_header.html.erb +23 -0
- data/app/views/studio/agent_chat/_chat.html.erb +2 -1
- data/app/views/style/_components.html.erb +3 -7
- data/app/views/style/_glass.html.erb +1 -1
- data/app/views/style/_host.html.erb +3 -14
- data/app/views/style/_modal_specimen.html.erb +5 -5
- data/app/views/style/_modals.html.erb +29 -224
- data/app/views/style/_scrims.html.erb +1 -8
- data/app/views/style/_site_footer.html.erb +5 -13
- data/app/views/style/_specimen.html.erb +4 -0
- data/app/views/style/_tasks.html.erb +17 -30
- data/app/views/style/_theme.html.erb +3 -9
- data/app/views/style/_tricks.html.erb +155 -216
- data/app/views/style/index.html.erb +7 -3
- data/lib/studio/version.rb +1 -1
- metadata +3 -1
|
@@ -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
|
}
|
|
@@ -11,10 +11,10 @@ import { activeIndex } from "studio/style_section_nav"
|
|
|
11
11
|
|
|
12
12
|
const ACTIVE = "btn-primary"
|
|
13
13
|
const REST = "btn-neutral"
|
|
14
|
-
// A jump lands a section by its scroll
|
|
15
|
-
// header may still be tall; the header then collapses, so the row rises and the
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// A jump lands a section by its scroll margin, the stack's pinned heights, read while the
|
|
15
|
+
// header may still be tall; the header then collapses, so the row rises and the section
|
|
16
|
+
// does not (76px on the hub at phone width). The slack keeps the clicked section past the
|
|
17
|
+
// line, for any later scroll or resize.
|
|
18
18
|
const LINE_SLACK = 80
|
|
19
19
|
|
|
20
20
|
export default class extends Controller {
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
// SIZE something as tall as the header; use --nav-bottom to START something
|
|
38
38
|
// underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
|
|
39
39
|
//
|
|
40
|
+
// THE SECTION LAYER (studio/_section_header, docs/PAGE_HEADER.md) IS THE ONE LAYER
|
|
41
|
+
// THAT IS NOT A data-pin. A page has many section headers and only one is stuck, so
|
|
42
|
+
// each carries `data-pin-section`, and together they publish as one layer, `section`.
|
|
43
|
+
// See stuckSection below.
|
|
44
|
+
//
|
|
40
45
|
// Started once per document by studio/application. The listeners it adds live
|
|
41
46
|
// for the document, and a Turbo visit republishes through them rather than
|
|
42
47
|
// starting it again.
|
|
@@ -152,6 +157,62 @@ export function documentIndex(list, el) {
|
|
|
152
157
|
return list.length;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
// THE SECTION LAYER: MANY HEADERS, ONE LAYER, ONLY THE STUCK ONE COUNTS.
|
|
161
|
+
//
|
|
162
|
+
// A section header is sticky inside its own <section>, so the browser does the
|
|
163
|
+
// swap: the next section's header arrives as the last one is pushed out. It joins
|
|
164
|
+
// the stack so that a table's sticky header and any other reader of
|
|
165
|
+
// --pin-stack-bottom sit under it. It cannot be a plain [data-pin]: composed() takes
|
|
166
|
+
// the lowest edge of every layer, so a header waiting below the fold would drag
|
|
167
|
+
// --pin-stack-bottom down the page, and headers sharing a name would overwrite each
|
|
168
|
+
// other's values.
|
|
169
|
+
//
|
|
170
|
+
// So all of them are one layer named `section`, always in the registry while any is
|
|
171
|
+
// on the page. The roster never changes per swap, so a swap is not a structural write,
|
|
172
|
+
// and --pin-section-top, which every header positions off, stays published when none
|
|
173
|
+
// is stuck. That layer's height is 0 until one is stuck, and then it is the part of the
|
|
174
|
+
// bar still below the layers above, so a header being pushed out gives its room back
|
|
175
|
+
// frame by frame.
|
|
176
|
+
//
|
|
177
|
+
// rects are { top, bottom, parentTop, parentBottom }, in page order; line is
|
|
178
|
+
// --pin-section-top. A header is stuck once it has left its place in the flow and part
|
|
179
|
+
// of it still shows below the line. Its section's top is that place, since it is the
|
|
180
|
+
// section's first child and the section has no top padding.
|
|
181
|
+
//
|
|
182
|
+
// NEITHER FACT READS THE HEADER'S OWN EDGE AGAINST THE LINE. While the navbar collapses
|
|
183
|
+
// the line moves first and the header's `top` follows a publish later, so a comparison
|
|
184
|
+
// would drop the bar for the whole collapse, and a bottom read off the rect would be
|
|
185
|
+
// published one step stale, with nothing left to correct it once the page is still. So
|
|
186
|
+
// the bar's bottom is what sticky will make it: one height under the line, or the end of
|
|
187
|
+
// its section while that pushes it out.
|
|
188
|
+
export function sectionBottom(r, line) {
|
|
189
|
+
return Math.min(line + (r.bottom - r.top), r.parentBottom);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function stuckSection(rects, line) {
|
|
193
|
+
var found = -1;
|
|
194
|
+
for (var i = 0; i < rects.length; i++) {
|
|
195
|
+
var r = rects[i];
|
|
196
|
+
if (r.top - r.parentTop > 0.5 && sectionBottom(r, line) > line + 0.5) found = i;
|
|
197
|
+
}
|
|
198
|
+
return found;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function measureSections(nodes, line) {
|
|
202
|
+
// Below the short-screen floor, or before the publisher runs, the CSS leaves these in
|
|
203
|
+
// the flow (engine.css, @utility section-header). A header scrolling past the line
|
|
204
|
+
// there is not stuck, so it must not count.
|
|
205
|
+
if (window.getComputedStyle(nodes[0]).position !== 'sticky') return null;
|
|
206
|
+
var rects = [];
|
|
207
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
208
|
+
var r = nodes[i].getBoundingClientRect();
|
|
209
|
+
var parent = nodes[i].parentElement.getBoundingClientRect();
|
|
210
|
+
rects.push({ top: r.top, bottom: r.bottom, parentTop: parent.top, parentBottom: parent.bottom });
|
|
211
|
+
}
|
|
212
|
+
var at = stuckSection(rects, line);
|
|
213
|
+
return at < 0 ? null : { el: nodes[at], bottom: sectionBottom(rects[at], line) };
|
|
214
|
+
}
|
|
215
|
+
|
|
155
216
|
// THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
|
|
156
217
|
//
|
|
157
218
|
// This used to build a `pins` array once and hold each element by reference
|
|
@@ -168,8 +229,9 @@ export function documentIndex(list, el) {
|
|
|
168
229
|
// reference IMPOSSIBLE rather than merely unlikely: there is no window between
|
|
169
230
|
// a DOM patch and a re-scan in which this can be pointed at the wrong node.
|
|
170
231
|
//
|
|
171
|
-
// Returns { pins, stack, timeline }: every layer measured and
|
|
172
|
-
// stack's bottom,
|
|
232
|
+
// Returns { pins, stack, timeline, travel, sections, stuck }: every layer measured and
|
|
233
|
+
// composed, the stack's bottom, whether the header is on the scroll timeline and how
|
|
234
|
+
// far it travels, the section headers, and which of them is stuck.
|
|
173
235
|
export function readStack() {
|
|
174
236
|
var header = document.querySelector('header');
|
|
175
237
|
var timeline = onTimeline(header);
|
|
@@ -191,11 +253,28 @@ export function readStack() {
|
|
|
191
253
|
// never publish at all. Both live consumers own their header, so it is
|
|
192
254
|
// adopted as a pin even when it does not ask to be.
|
|
193
255
|
if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
|
|
256
|
+
// In the registry at height 0 first, so the composition says where its line is.
|
|
257
|
+
var sections = document.querySelectorAll('[data-pin-section]');
|
|
258
|
+
var section = null;
|
|
259
|
+
if (sections.length) {
|
|
260
|
+
section = { el: sections[0], name: 'section', legacy: false, h: 0, bottom: 0 };
|
|
261
|
+
list.splice(documentIndex(list, sections[0]), 0, section);
|
|
262
|
+
}
|
|
263
|
+
var stack = timeline ? pinnedAt(list) : composed(list);
|
|
264
|
+
var stuck = section ? measureSections(sections, section.top) : null;
|
|
265
|
+
if (stuck) {
|
|
266
|
+
section.el = stuck.el;
|
|
267
|
+
section.h = stuck.bottom - section.top;
|
|
268
|
+
section.bottom = stuck.bottom;
|
|
269
|
+
stack = timeline ? pinnedAt(list) : composed(list);
|
|
270
|
+
}
|
|
194
271
|
return {
|
|
195
272
|
pins: list,
|
|
196
|
-
stack:
|
|
273
|
+
stack: stack,
|
|
197
274
|
timeline: timeline,
|
|
198
|
-
travel: timeline ? timelineTravel(header) : 0
|
|
275
|
+
travel: timeline ? timelineTravel(header) : 0,
|
|
276
|
+
sections: sections,
|
|
277
|
+
stuck: stuck ? stuck.el : null
|
|
199
278
|
};
|
|
200
279
|
}
|
|
201
280
|
|
|
@@ -414,6 +493,9 @@ export function startPinnedStack() {
|
|
|
414
493
|
settleTimer = null;
|
|
415
494
|
publishAll();
|
|
416
495
|
releaseFollowers();
|
|
496
|
+
// That publish measured the layers while they still held their last copies. Given back,
|
|
497
|
+
// they move to the root's values, so their edges are read once more.
|
|
498
|
+
schedule();
|
|
417
499
|
}
|
|
418
500
|
|
|
419
501
|
// The registry and the measurement are module-level (readStack, measurePin
|
|
@@ -505,12 +587,38 @@ export function startPinnedStack() {
|
|
|
505
587
|
}
|
|
506
588
|
}
|
|
507
589
|
|
|
590
|
+
// WHO WEARS THE BAR, written after every read of the publish. The box does not change
|
|
591
|
+
// with the attribute, so this resizes nothing and moves nothing. The root's attribute
|
|
592
|
+
// squares the page row's corners against the bar under it (engine.css, glass).
|
|
593
|
+
function syncSections(reading) {
|
|
594
|
+
for (var i = 0; i < reading.sections.length; i++) {
|
|
595
|
+
var node = reading.sections[i];
|
|
596
|
+
var on = node === reading.stuck;
|
|
597
|
+
if (node.hasAttribute('data-stuck') !== on) node.toggleAttribute('data-stuck', on);
|
|
598
|
+
}
|
|
599
|
+
var root = document.documentElement;
|
|
600
|
+
if (root.hasAttribute('data-pin-section-stuck') !== !!reading.stuck) root.toggleAttribute('data-pin-section-stuck', !!reading.stuck);
|
|
601
|
+
}
|
|
602
|
+
|
|
508
603
|
var lastPublished = null;
|
|
604
|
+
var lastTops = null;
|
|
509
605
|
function publishAll() {
|
|
606
|
+
// What lets a section header stick at all (engine.css): with no publisher its bar
|
|
607
|
+
// would never come, and it would lay a clear title over the page. Before the read,
|
|
608
|
+
// so the first publish on a restored scroll already sees it sticky.
|
|
609
|
+
var html = document.documentElement;
|
|
610
|
+
if (!html.hasAttribute('data-pin-live')) html.setAttribute('data-pin-live', '');
|
|
510
611
|
var reading = readStack();
|
|
511
612
|
var pins = reading.pins;
|
|
512
613
|
write(pins, reading.stack, reading.timeline, reading.travel);
|
|
614
|
+
syncSections(reading);
|
|
513
615
|
syncObserver(pins);
|
|
616
|
+
// A LAYER UNDER A LAYER SETTLES ONE PUBLISH LATER. A new --pin-page-top moves the page
|
|
617
|
+
// row without resizing it, so nothing wakes the publisher to read the row's new edge,
|
|
618
|
+
// and the layer under the row would keep a stale line once the page is still. So a
|
|
619
|
+
// publish that moved any layer's top asks for one more frame. It ends when none moved.
|
|
620
|
+
var tops = pins.map(function (p) { return p.top; }).join('|');
|
|
621
|
+
if (tops !== lastTops) { lastTops = tops; schedule(); }
|
|
514
622
|
var key = publishedKey(reading);
|
|
515
623
|
if (key !== lastPublished) {
|
|
516
624
|
lastPublished = key;
|
|
@@ -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
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Fizz around any button: bubbles that rise from behind it. Render it as a layout,
|
|
3
|
+
with the button as the block.
|
|
4
|
+
|
|
5
|
+
render layout: "studio/fizz", locals: { seed: "save" } do
|
|
6
|
+
<button class="btn btn-primary">Save</button>
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
Locals:
|
|
10
|
+
seed - the string the bubble table is seeded from, so the scatter is stable
|
|
11
|
+
for this button and differs from its neighbours
|
|
12
|
+
level - :lively (default), which boils at rest and adds a layer on hover, or
|
|
13
|
+
:calm, which simmers at rest and boils on hover
|
|
14
|
+
colors - optional palette, up to 18, three per zone (--fizz-c-1..18)
|
|
15
|
+
|
|
16
|
+
Decoration only. The hold button keeps its own stack (studio/_hold_button).
|
|
17
|
+
%>
|
|
18
|
+
<% lively = local_assigns.fetch(:level, :lively).to_s == "lively" %>
|
|
19
|
+
<% fizz_style = fizz_stack_style(local_assigns[:colors]) %>
|
|
20
|
+
<span class="fizz-stack<%= " fizz-lively" if lively %>"<% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
|
|
21
|
+
<%= render "studio/fizz_layer", seed: seed, neutral: true %>
|
|
22
|
+
<% if lively %>
|
|
23
|
+
<%= render "studio/fizz_layer", seed: "#{seed}~extra", extra: true, neutral: true %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<%= yield %>
|
|
26
|
+
</span>
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
in each other's gaps rather than on top of each other)
|
|
8
8
|
extra - true for the hover-only layer, which rests invisible and fades in
|
|
9
9
|
when the pointer arrives (default: false)
|
|
10
|
+
neutral - true inside a .fizz-stack (studio/_fizz), a button with no hold, so
|
|
11
|
+
the layer carries the neutral class names (default: false)
|
|
10
12
|
|
|
11
13
|
The two layers also split each zone's three colour slots: the resting one
|
|
12
14
|
takes the first, the hover one alternates the second and third.
|
|
13
15
|
Decoration only: aria-hidden here, pointer-events off in CSS.
|
|
14
16
|
%>
|
|
15
17
|
<% extra ||= false %>
|
|
16
|
-
|
|
18
|
+
<% neutral ||= false %>
|
|
19
|
+
<span class="<%= neutral ? "fizz-layer" : "hold-fizz" %><%= (neutral ? " fizz-layer-extra" : " hold-fizz-extra") if extra %>" aria-hidden="true"><% fizz_bits(seed, layer: extra ? :hover : :base).each do |bit| %><i class="fizz-bit" style="<%= fizz_bit_style(bit) %>"></i><% end %></span>
|
|
@@ -74,8 +74,7 @@
|
|
|
74
74
|
<% fizz_portal = fizz && local_assigns[:fizz_portal] %>
|
|
75
75
|
<%# Static palette (gallery, or any call site with fixed colors). A runtime
|
|
76
76
|
palette rides fizz_bind instead. %>
|
|
77
|
-
<% fizz_style =
|
|
78
|
-
.each_with_index.map { |c, i| "--fizz-c-#{i + 1}:#{c}" }.join(";") %>
|
|
77
|
+
<% fizz_style = fizz_stack_style(fizz_colors) %>
|
|
79
78
|
|
|
80
79
|
<%# The stack exists so the fizz can sit BEHIND the button. Inside the button
|
|
81
80
|
a negative z-index still paints above its own background (the button's
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<%# locals: (title:, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, data: {}, row_class: "-mx-4 px-4") %>
|
|
2
|
-
<%# THE PAGE HEADER: the second layer of the
|
|
3
|
-
navbar, then this row, then a
|
|
2
|
+
<%# THE PAGE HEADER: the second layer of the pinned stack (docs/PAGE_HEADER.md).
|
|
3
|
+
navbar, then this row, then a section's header (studio/_section_header), then a
|
|
4
|
+
table's sticky header, each below the last.
|
|
4
5
|
|
|
5
6
|
AT REST it has no background and no border: a title on the left, tabs on the
|
|
6
7
|
right. ONCE STUCK under the navbar it takes the bar. Its box never changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# locals: (title:, level: :h2, row_class: "-mx-4 px-4") %>
|
|
2
|
+
<%# THE SECTION HEADER: a section's title that sticks under the rest of the pinned stack
|
|
3
|
+
until the next section's header takes its place (docs/PAGE_HEADER.md).
|
|
4
|
+
|
|
5
|
+
RENDER IT AS THE FIRST DIRECT CHILD OF ITS <section>. A sticky element travels only
|
|
6
|
+
inside its parent, and that is what gives the swap: inside a wrapper div, it would
|
|
7
|
+
leave with the wrapper. No backdrop-filter, transform or overflow on an ancestor.
|
|
8
|
+
|
|
9
|
+
title the heading's text. The heading is the sticky element, so an intro can
|
|
10
|
+
follow as its next sibling.
|
|
11
|
+
level the tag. A page should stick one level only: one bar at a time.
|
|
12
|
+
row_class the bleed. The default fits a container with px-4, as the page row's does.
|
|
13
|
+
|
|
14
|
+
AT REST a normal title. STUCK it takes the bar. Its box never changes between the two.
|
|
15
|
+
|
|
16
|
+
IT JOINS THE STACK WITH data-pin-section, NOT data-pin. Every header on the page shares
|
|
17
|
+
one layer, `section`, and only the stuck one counts (studio/pinned_stack, stuckSection).
|
|
18
|
+
So it positions off --pin-section-top, which also stands when none is stuck. Anything
|
|
19
|
+
under the stack, such as a table's sticky header, sits under it with no edit. %>
|
|
20
|
+
<%= content_tag(level, title,
|
|
21
|
+
class: "section-header #{row_class}".strip,
|
|
22
|
+
style: "top: var(--pin-section-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: calc(var(--z-sticky, 30) - 1)",
|
|
23
|
+
data: { pin_section: "" }) %>
|
|
@@ -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>
|
|
@@ -5,13 +5,9 @@
|
|
|
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
7
|
to a 404. -%>
|
|
8
|
-
<section id="components" class="space-y-6"
|
|
9
|
-
|
|
10
|
-
|
|
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>
|
|
14
|
-
</div>
|
|
8
|
+
<section id="components" class="section-target space-y-6">
|
|
9
|
+
<%= 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>
|
|
15
11
|
|
|
16
12
|
<% if Studio.lookbook_mounted? %>
|
|
17
13
|
<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">
|
|
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",
|
|
@@ -90,20 +90,9 @@
|
|
|
90
90
|
A reader who assumes dsModals will build a mirror instead: a second copy of
|
|
91
91
|
every card, free to drift from the one the app actually ships, which is the
|
|
92
92
|
opposite of what a living style guide is for. %>
|
|
93
|
-
<section id="host-modals" class="space-y-8" x-data="{}"
|
|
94
|
-
|
|
95
|
-
<
|
|
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>
|
|
106
|
-
</div>
|
|
93
|
+
<section id="host-modals" class="section-target space-y-8" x-data="{}">
|
|
94
|
+
<%= render "studio/section_header", title: Studio.app_name %>
|
|
95
|
+
<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>
|
|
107
96
|
|
|
108
97
|
<%# The app's content. Reached only when it resolves - style/index.html.erb
|
|
109
98
|
owns that guard, because the section nav needs the same answer. %>
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
page when chatting (item 1).
|
|
10
10
|
- an optional row of option toggles lets the user configure the modal
|
|
11
11
|
before opening; their clicks/keys do NOT bubble to the card open (item 2).
|
|
12
|
-
- an optional active-card GLOW (.
|
|
12
|
+
- an optional active-card GLOW (.edge-rotate-glow) that reactively lights
|
|
13
13
|
when this card represents the currently-open modal + step, and slides to
|
|
14
14
|
the next card as the step machine advances (items 5/6). The glow rides an
|
|
15
15
|
un-clipped wrapper (the card itself is overflow-hidden), mirroring TM's
|
|
16
|
-
holo-wrap / holo-card split, and fades via --
|
|
16
|
+
holo-wrap / holo-card split, and fades via --edge-rotate-glow-opacity so
|
|
17
17
|
it transitions smoothly between cards.
|
|
18
18
|
|
|
19
19
|
Locals:
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
<%# Wrapper — carries the active-card glow (un-clipped) + the shared x-data. %>
|
|
54
54
|
<%# h-full so the card stretches to its grid row's tallest (per-row equal
|
|
55
55
|
height via the grid's default align-items: stretch). %>
|
|
56
|
-
<div class="relative h-full<%= "
|
|
56
|
+
<div class="relative h-full<%= " edge-rotate-glow rounded-xl" if glow_when %>"
|
|
57
57
|
x-data="<%= card_x_data %>"
|
|
58
58
|
<%# Fail-closed default: a STATIC inline 0 out-specifies the primitive's CSS
|
|
59
|
-
default (--
|
|
59
|
+
default (--edge-rotate-glow-opacity of 1) so the ring is OFF at first
|
|
60
60
|
paint, BEFORE Alpine hydrates the reactive :style below. Without it every
|
|
61
61
|
glow card shows its ring until Alpine boots (the fail-open flash). The
|
|
62
62
|
:style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH.
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
the Turbo-nav store gap fixed in 0.26.1 (style/_modals dual-guards the
|
|
68
68
|
dsModals registration so it survives Turbo visits, not just first load).
|
|
69
69
|
So the guarantee is: static inline 0 PLUS a registered dsModals. %>
|
|
70
|
-
<% if glow_when %>style="--
|
|
70
|
+
<% if glow_when %>style="--edge-rotate-glow-opacity: 0" :style="{ '--edge-rotate-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
|
|
71
71
|
<article
|
|
72
72
|
class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
|
|
73
73
|
<%= 'aria-disabled="true"'.html_safe if disabled %>
|