studio-engine 0.123.0 → 0.124.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +16 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +210 -11
  5. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  6. data/app/helpers/studio/site_footer_helper.rb +15 -2
  7. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  8. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  9. data/app/javascript/studio/page_header.js +24 -0
  10. data/app/javascript/studio/page_transitions.js +181 -0
  11. data/app/javascript/studio/pinned_stack.js +12 -1
  12. data/app/javascript/studio/stimulus.js +6 -1
  13. data/app/views/components/_link_sidebar.html.erb +5 -2
  14. data/app/views/components/_user_nav.html.erb +5 -4
  15. data/app/views/layouts/_navbar.html.erb +7 -4
  16. data/app/views/layouts/studio/_head.html.erb +6 -0
  17. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  18. data/app/views/studio/_page_header.html.erb +65 -0
  19. data/app/views/studio/admin_models/show.html.erb +10 -18
  20. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  21. data/app/views/studio/agent_chat/_form.html.erb +1 -1
  22. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  23. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  24. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  25. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  26. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  27. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  28. data/app/views/style/_tricks.html.erb +35 -0
  29. data/app/views/style/index.html.erb +17 -28
  30. data/docs/SITE_FOOTER.md +113 -2
  31. data/lib/studio/marketing_kit.rb +304 -0
  32. data/lib/studio/site_footer.rb +22 -6
  33. data/lib/studio/version.rb +1 -1
  34. data/lib/studio.rb +83 -1
  35. metadata +11 -1
@@ -0,0 +1,181 @@
1
+ // studio/page_transitions: the app-to-app page transition (Studio.page_transition_direction,
2
+ // docs/PAGE_TRANSITIONS.md). Loaded by its own tag in layouts/studio/_head, only with the
3
+ // setting on. It names a style on <html>, and engine.css draws it. It imports nothing.
4
+ //
5
+ // Turbo calls document.startViewTransition BEFORE turbo:before-render, and the browser takes
6
+ // the old page's picture before Turbo's callback runs. So what the old picture holds is
7
+ // settled ahead of the visit: the held banner stack and the morph names are decided here on
8
+ // turbo:visit and from the response, never first at before-render.
9
+ //
10
+ // The effects are from the Codrops Page Transitions demo; the keyframes were written fresh.
11
+
12
+ export const DIRECTIONS = Object.freeze({
13
+ left: Object.freeze(["fade-left", "unfold-right"]),
14
+ right: Object.freeze(["fade-right", "unfold-left"]),
15
+ top: Object.freeze(["fade-top", "unfold-bottom"]),
16
+ bottom: Object.freeze(["fade-bottom", "unfold-top"]),
17
+ })
18
+
19
+ // A same-page visit (a refresh, a poll): the transition still runs, and draws nothing.
20
+ export const STILL = "still"
21
+ export const READY_CAP_MS = 500
22
+ const MAX_READY_IMAGES = 12
23
+ const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
24
+
25
+ export function pickStyle(direction, random = Math.random) {
26
+ if (!Object.hasOwn(DIRECTIONS, direction)) return null
27
+ const pair = DIRECTIONS[direction]
28
+ return pair[Math.floor(random() * pair.length)]
29
+ }
30
+
31
+ // The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
32
+ // and a morph between two pages stands).
33
+ export function planVisit({ direction, from, to, transitions, sharedName }, random = Math.random) {
34
+ if (!transitions || !from || !to || !Object.hasOwn(DIRECTIONS, direction)) return null
35
+ if (from === to) return STILL
36
+ return sharedName ? null : pickStyle(direction, random)
37
+ }
38
+
39
+ // A slow image must not hold a click: past the cap the swap goes ahead.
40
+ export function withCap(promise, ms, timer = setTimeout) {
41
+ return Promise.race([promise, new Promise((done) => timer(done, ms))])
42
+ }
43
+
44
+ export function morphNames(root) {
45
+ return new Set([...root.querySelectorAll("[data-vt-morph]")].map((el) => el.getAttribute("data-vt-morph")))
46
+ }
47
+
48
+ export function shareName(here, there) {
49
+ return !!there && [...here].some((name) => there.has(name))
50
+ }
51
+
52
+ // Exactly one element takes the stack's view-transition name, however many a page renders:
53
+ // a duplicate name silently cancels the whole transition.
54
+ export function markHeld(root) {
55
+ const stack = root.querySelector(".studio-bar-stack")
56
+ if (stack) stack.setAttribute("data-vt-held", "")
57
+ }
58
+
59
+ export function eagerImages(body) {
60
+ return [...body.querySelectorAll("img")]
61
+ .filter((img) => img.getAttribute("loading") !== "lazy" && (img.getAttribute("src") || img.getAttribute("srcset")))
62
+ .slice(0, MAX_READY_IMAGES)
63
+ }
64
+
65
+ // The incoming body is inert, so each image is copied here to load, <picture> and all, so
66
+ // the browser chooses the same source. Marked decoding=sync, the real one paints at once.
67
+ function loadImage(doc, img) {
68
+ img.decoding = "sync"
69
+ const source = img.closest("picture") || img
70
+ const copy = doc.importNode(source, true)
71
+ const loader = copy.tagName === "IMG" ? copy : copy.querySelector("img")
72
+ return loader.decode().catch(() => {})
73
+ }
74
+
75
+ function readyForSwap(doc, body) {
76
+ const fonts = doc.fonts ? doc.fonts.ready.catch(() => {}) : null
77
+ // At least one tick, so Turf's one-tick hold for its snapshot stays honest.
78
+ const tick = new Promise((done) => setTimeout(done, 0))
79
+ return Promise.all([...eagerImages(body).map((img) => loadImage(doc, img)), fonts, tick])
80
+ }
81
+
82
+ // Turbo's resume is a promise's resolve, so the FIRST caller starts the render. Another
83
+ // listener's short hold (Turf's turbo_snapshot_cache resumes on a timeout of 0) would cut
84
+ // this wait to nothing, so a listener that runs after this one gets a resume that does
85
+ // nothing, and this wait releases the render. It always does: cap, failure or success.
86
+ export function holdRender(event, wait, cap = READY_CAP_MS) {
87
+ const resume = event.detail && event.detail.resume
88
+ if (typeof resume !== "function") return false
89
+ event.preventDefault()
90
+ event.detail.resume = () => {}
91
+ const release = () => resume()
92
+ withCap(Promise.resolve().then(wait), cap).then(release, release)
93
+ return true
94
+ }
95
+
96
+ function readDirection(doc) {
97
+ const meta = doc.querySelector('meta[name="page-transition-direction"]')
98
+ return meta ? meta.content : null
99
+ }
100
+
101
+ let installed = false
102
+
103
+ export function installPageTransitions(doc) {
104
+ if (installed || !readDirection(doc)) return false
105
+ installed = true
106
+
107
+ const win = doc.defaultView
108
+ const root = doc.documentElement
109
+ const known = new Map()
110
+ let from = win.location.pathname
111
+ let target = null
112
+
113
+ const nameTheMorph = (shared) => {
114
+ if (shared) root.dataset.pageTransitionMorph = ""
115
+ else delete root.dataset.pageTransitionMorph
116
+ }
117
+ const predict = (names) => nameTheMorph(target !== from && shareName(morphNames(doc), names))
118
+
119
+ root.dataset.pageTransitionDirection = readDirection(doc)
120
+ markHeld(doc)
121
+ known.set(from, morphNames(doc))
122
+
123
+ doc.addEventListener("turbo:visit", (event) => {
124
+ root.dataset.pageTransitionVisit = ""
125
+ target = new URL(event.detail.url).pathname
126
+ predict(known.get(target))
127
+ })
128
+
129
+ // A page never visited: read its names from the response, ahead of the old page's picture.
130
+ // Only a page with names of its own can share one, so every other page skips the parse.
131
+ doc.addEventListener("turbo:before-fetch-response", (event) => {
132
+ if (event.target && event.target.tagName === "TURBO-FRAME") return
133
+ if (!morphNames(doc).size) return
134
+ const { fetchResponse } = event.detail
135
+ Promise.resolve(fetchResponse.responseHTML).then((html) => {
136
+ if (!html) return
137
+ const names = morphNames(new win.DOMParser().parseFromString(html, "text/html"))
138
+ const path = new URL(fetchResponse.response.url).pathname
139
+ known.set(path, names)
140
+ if (target && (path === target || fetchResponse.redirected)) predict(names)
141
+ }).catch(() => {})
142
+ })
143
+
144
+ // In capture, so the hold is in place before any other listener takes Turbo's resume.
145
+ doc.addEventListener("turbo:before-render", (event) => {
146
+ const { newBody } = event.detail
147
+ markHeld(newBody)
148
+ const names = morphNames(newBody)
149
+ if (target) known.set(target, names)
150
+ const sharedName = shareName(morphNames(doc), names)
151
+ const style = planVisit({
152
+ direction: readDirection(doc),
153
+ from,
154
+ to: target,
155
+ transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
156
+ sharedName,
157
+ })
158
+ nameTheMorph(sharedName && style !== STILL)
159
+ if (style) root.dataset.pageTransition = style
160
+ else delete root.dataset.pageTransition
161
+ if (style && style !== STILL) holdRender(event, () => readyForSwap(doc, newBody))
162
+ }, { capture: true })
163
+
164
+ // Turbo fires this after the transition has finished.
165
+ doc.addEventListener("turbo:load", () => {
166
+ from = win.location.pathname
167
+ target = null
168
+ known.set(from, morphNames(doc))
169
+ delete root.dataset.pageTransition
170
+ delete root.dataset.pageTransitionVisit
171
+ nameTheMorph(false)
172
+ })
173
+
174
+ // A visit that never renders: the stack must not keep its name at rest.
175
+ for (const name of ["turbo:fetch-request-error", "turbo:reload"]) {
176
+ doc.addEventListener(name, () => { delete root.dataset.pageTransitionVisit })
177
+ }
178
+ return true
179
+ }
180
+
181
+ if (typeof document !== "undefined") installPageTransitions(document)
@@ -141,6 +141,17 @@ export function measurePin(el, name, legacy, timeline) {
141
141
  return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
142
142
  }
143
143
 
144
+ // WHERE AN ADOPTED HEADER GOES: its place in the document, since order is
145
+ // document order (composed). Appended, it read as below every layer, so a layer
146
+ // under it (studio/_page_header) composed a top of 0 and pinned over it.
147
+ export function documentIndex(list, el) {
148
+ var FOLLOWING = 4; // Node.DOCUMENT_POSITION_FOLLOWING
149
+ for (var i = 0; i < list.length; i++) {
150
+ if (el.compareDocumentPosition(list[i].el) & FOLLOWING) return i;
151
+ }
152
+ return list.length;
153
+ }
154
+
144
155
  // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
145
156
  //
146
157
  // This used to build a `pins` array once and hold each element by reference
@@ -179,7 +190,7 @@ export function readStack() {
179
190
  // contest board and mcritchie-studio's heartbeat CSS all read — would
180
191
  // never publish at all. Both live consumers own their header, so it is
181
192
  // adopted as a pin even when it does not ask to be.
182
- if (header && !headerPinned) list.push(measurePin(header, 'nav', true, timeline));
193
+ if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
183
194
  return {
184
195
  pins: list,
185
196
  stack: timeline ? pinnedAt(list) : composed(list),
@@ -65,6 +65,9 @@ export const schema = {
65
65
  // says recording did not load (data-studio-controller-failed="voice-recorder").
66
66
  // So is the chat's mic: a mic whose controller failed says voice did not load,
67
67
  // and the box still types (data-studio-controller-failed="agent-chat-voice").
68
+ // So is copy-text: a colour whose controller failed still shows its hex to select.
69
+ // So is the page header: its tabs are links, and a row whose controller failed
70
+ // keeps its bar at all times (data-studio-controller-failed="page-header").
68
71
  export const LAZY = {
69
72
  "board": () => import("studio/controllers/board_controller"),
70
73
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -78,7 +81,9 @@ export const LAZY = {
78
81
  "agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
79
82
  "voice-profile": () => import("studio/controllers/voice_profile_controller"),
80
83
  "microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
81
- "voice-recorder": () => import("studio/controllers/voice_recorder_controller")
84
+ "voice-recorder": () => import("studio/controllers/voice_recorder_controller"),
85
+ "copy-text": () => import("studio/controllers/copy_text_controller"),
86
+ "page-header": () => import("studio/controllers/page_header_controller")
82
87
  }
83
88
 
84
89
  export const application = Application.start(document.documentElement, schema)
@@ -6,6 +6,9 @@
6
6
  this automatically when sections are declared. %>
7
7
  <% sections = studio_sidebar_sections %>
8
8
  <% sidebar_admin = respond_to?(:admin?) && admin? %>
9
+ <%# A signed-out visitor's panel has no title: its links say what it is. It keeps its
10
+ accessible name (aria_label) and its close button. %>
11
+ <% sidebar_title = if sidebar_admin then "Admin Menu" elsif respond_to?(:logged_in?) && logged_in? then "Links" end %>
9
12
  <% close_action = "$store.sidebars.linkTreeOpen = false" %>
10
13
 
11
14
  <style>
@@ -113,7 +116,7 @@
113
116
 
114
117
  <%= render "components/sidebar_panel",
115
118
  id: "studio-link-sidebar",
116
- title: sidebar_admin ? "Admin Menu" : "Links",
119
+ title: sidebar_title,
117
120
  open: "$store.sidebars.linkTreeOpen",
118
121
  body_html: body_html,
119
122
  footer_html: footer_html,
@@ -126,7 +129,7 @@
126
129
 
127
130
  <%= render "components/sidebar_panel",
128
131
  id: "studio-link-sidebar-mobile",
129
- title: sidebar_admin ? "Admin Menu" : "Links",
132
+ title: sidebar_title,
130
133
  open: "$store.sidebars.linkTreeOpen",
131
134
  body_html: body_html,
132
135
  footer_html: footer_html,
@@ -5,8 +5,8 @@
5
5
  { partial: "path", locals: { ... } } when the partial needs locals:
6
6
  balance_slot — balance display, rendered at the start of the
7
7
  top row (e.g. a wallet balance link)
8
- extra_icons_slot — app-specific icon buttons, rendered before the
9
- admin gear + theme toggle
8
+ extra_icons_slot — app-specific icon buttons, rendered between the
9
+ theme toggle and the admin gear
10
10
  div2_slot — replaces the default second row (the wallet
11
11
  address + level progress bar) entirely
12
12
  (e.g. a seeds bar)
@@ -329,13 +329,14 @@
329
329
  <div data-nav-avatar-badge><%= studio_navbar_slot_tag(avatar_badge) %></div>
330
330
  <% end %>
331
331
  <% else %>
332
+ <%# The signed-in rail's order: the theme toggle, then the gear. Desktop
333
+ only, as above: the phone row carries the toggle below md. %>
334
+ <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
332
335
  <% if menu_trigger %>
333
336
  <span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
334
337
  <% else %>
335
338
  <%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
336
339
  <% end %>
337
- <%# Desktop only, as above: the phone row carries the toggle below md. %>
338
- <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
339
340
  <div class="flex items-center gap-3 ml-auto">
340
341
  <%= link_to Studio.sign_in_label, login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
341
342
  <%= link_to "Sign up", signup_path, class: "btn btn-primary btn-sm" %>
@@ -23,8 +23,9 @@
23
23
  put — hosts no longer shadow this partial just to add the class. Non-preview
24
24
  branch ONLY: preview renders can repeat per page, and a duplicate
25
25
  view-transition-name silently disables every transition. With the flag off,
26
- nothing extra renders. %>
27
- <% pin_header = Studio.smooth_load && !is_preview %>
26
+ nothing extra renders. Studio.page_transition_direction turns the same flag
27
+ on (layouts/studio/_smooth_load). %>
28
+ <% pin_header = (Studio.smooth_load || @studio_page_transition_direction || Studio.page_transition_direction) && !is_preview %>
28
29
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
29
30
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
30
31
  <%# The user nav's slot locals, forwarded only when passed, so a navbar that
@@ -244,8 +245,9 @@
244
245
  <%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, **user_nav_slots %>
245
246
  <% else %>
246
247
  <div class="flex items-center justify-end gap-3">
247
- <%= sidebar_trigger_desktop if sidebar_active %>
248
+ <%# The signed-in rail's order: the theme toggle, then the gear. %>
248
249
  <span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
250
+ <%= sidebar_trigger_desktop if sidebar_active %>
249
251
  <% if signed_out_cta %>
250
252
  <%# sign_in_link: false drops the quiet link (the CTA is the sign-in);
251
253
  sign_in_path points it elsewhere; modal: opens a modal from the
@@ -277,6 +279,8 @@
277
279
  <% if signed_out_cta && signed_out_cta[:sign_in_link] != false %>
278
280
  <%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
279
281
  <% end %>
282
+ <%# The rail's order at this width too: the theme toggle, then the gears. %>
283
+ <%= render "components/theme_toggle_morph" %>
280
284
  <%= render "components/link_sidebar_trigger" if sidebar_active %>
281
285
  <%# Studio.navbar_menu_trigger takes the admin dropdown's seat here too: its
282
286
  second render (the rail is the first), so the partial carries no ids. %>
@@ -286,7 +290,6 @@
286
290
  <% else %>
287
291
  <%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
288
292
  <% end %>
289
- <%= render "components/theme_toggle_morph" %>
290
293
  </span>
291
294
  </div>
292
295
  </header>
@@ -208,6 +208,12 @@
208
208
  controllers, the pinned stack, and the Alpine shims. A module tag, so it
209
209
  carries the nonce. %>
210
210
  <%= javascript_import_module_tag "studio/application" %>
211
+ <%# THE PAGE TRANSITION, BY ITS OWN DOOR, only with the setting on: an app without
212
+ it fetches nothing and renders these bytes as before. Not in the boot graph, so
213
+ it is not preloaded either. Its listeners are bound before the first visit. %>
214
+ <% if @studio_page_transition_direction || Studio.page_transition_direction %>
215
+ <%= javascript_import_module_tag "studio/page_transitions" %>
216
+ <% end %>
211
217
  <%# THE STIMULUS APPLICATION, BY ITS OWN DOOR. studio/stimulus starts the engine's
212
218
  Stimulus application and registers the hold button on it. Its only imports
213
219
  are Stimulus, studio/lazy_controllers and the hold button's controller, so
@@ -8,8 +8,17 @@
8
8
  Transition styling ships in engine.css ("Smooth-load convention" block);
9
9
  pin an app's sticky header with the .vt-pinned-header utility (exactly one
10
10
  element per page — a duplicate view-transition-name silently disables the
11
- whole transition). %>
12
- <% if Studio.smooth_load %>
11
+ whole transition).
12
+
13
+ Studio.page_transition_direction is the one switch for the app-to-app page
14
+ transition (docs/PAGE_TRANSITIONS.md): set, it turns all of this on, whatever
15
+ Studio.smooth_load says. The ivar lets one request set it without writing the
16
+ process-wide setting (the e2e lab). %>
17
+ <% page_transition_direction = @studio_page_transition_direction || Studio.page_transition_direction %>
18
+ <% if Studio.smooth_load || page_transition_direction %>
13
19
  <meta name="view-transition" content="same-origin">
14
20
  <meta name="turbo-cache-control" content="no-preview">
15
21
  <% end %>
22
+ <% if page_transition_direction %>
23
+ <meta name="page-transition-direction" content="<%= page_transition_direction %>">
24
+ <% end %>
@@ -0,0 +1,65 @@
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 triple stack (docs/PAGE_HEADER.md).
3
+ navbar, then this row, then a table's sticky header, each below the last.
4
+
5
+ AT REST it has no background and no border: a title on the left, tabs on the
6
+ right. ONCE STUCK under the navbar it takes the bar. Its box never changes
7
+ between the two, so a state change moves nothing and republishes nothing.
8
+
9
+ title the page's one h1.
10
+ meta short text beside the title, such as the version. Hidden on a
11
+ phone, where the tabs need the width.
12
+ meta_title the meta's title attribute.
13
+ label the nav's accessible name. A row with tabs needs it.
14
+ tabs [{ label:, href:, current:, data: }]. `current` is the
15
+ aria-current value ("page" or "true"), and blank on the others.
16
+ tabs_data data attributes for the tabs' scroller.
17
+ actions_html a control on the right, for a row with no tabs.
18
+ data data attributes for the row, such as the page's own controller.
19
+ row_class the bleed. The default fits a container with px-4; pass "" in a
20
+ container with no gutter of its own.
21
+
22
+ NOT components/_page_header: a host's view wins over the engine's at the same
23
+ path, and the hub has its own with other locals.
24
+
25
+ THE ROW IS NEVER A <header>. studio/pinned_stack takes the document's first
26
+ <header> as the navbar, so on a page with no navbar this row would publish as
27
+ --nav-bottom.
28
+
29
+ IT JOINS THE STACK WITH data-pin="page" AND POSITIONS OFF --pin-page-top, the
30
+ bottom of everything above it. --pin-stack-bottom includes the row's own edge,
31
+ so a row positioned off it would chase itself down the page. Anything below,
32
+ such as the sticky table header's clone, sits under the row with no edit.
33
+
34
+ THE SENTINEL stays at the row's place in the flow, and the row's distance from
35
+ it is what "stuck" means, on every kind of header. It is absolutely positioned,
36
+ so a space-y parent adds nothing for it. It carries the controller, so the row
37
+ keeps its data-studio-controller for the page's own. So the row carries no top
38
+ margin: the space above it belongs to its container. %>
39
+ <div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
40
+ <%= content_tag(tabs.any? ? :nav : :div,
41
+ class: "page-header sticky #{row_class}".strip,
42
+ style: "top: var(--pin-page-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)",
43
+ aria: { label: label.presence },
44
+ data: data.merge(pin: "page")) do %>
45
+ <div class="page-header-lead">
46
+ <h1 class="page-header-title"><%= title %></h1>
47
+ <% if meta.present? %>
48
+ <p class="page-header-meta label-upper"<%= %( title="#{ERB::Util.h(meta_title)}").html_safe if meta_title.present? %>><%= meta %></p>
49
+ <% end %>
50
+ </div>
51
+ <% if tabs.any? %>
52
+ <%= tag.div(class: "page-header-tabs", data: tabs_data) do %>
53
+ <div class="page-header-track">
54
+ <% tabs.each do |tab| %>
55
+ <%= link_to tab.fetch(:label), tab.fetch(:href),
56
+ class: "btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
57
+ aria: { current: tab[:current].presence },
58
+ data: tab.fetch(:data, {}) %>
59
+ <% end %>
60
+ </div>
61
+ <% end %>
62
+ <% elsif actions_html.present? %>
63
+ <div class="page-header-actions"><%= actions_html %></div>
64
+ <% end %>
65
+ <% end %>
@@ -1,23 +1,15 @@
1
1
  <% content_for(:title, "#{@config.fetch(:label)} - Models - Admin") %>
2
2
 
3
- <div class="max-w-7xl mx-auto px-4 pb-8 space-y-6">
4
- <%# The title and a tab per model, one sticky row, as on /admin/style. A tab is a
5
- link, so the open one is the page's own model. %>
6
- <nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
7
- style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
8
- aria-label="Models">
9
- <h1 class="text-xl font-bold text-heading shrink-0">Models</h1>
10
- <div class="overflow-x-auto min-w-0 flex-1">
11
- <div class="flex items-center gap-2 min-w-max pl-4">
12
- <% admin_model_configs.each do |key, config| %>
13
- <% open = key.to_s == @key %>
14
- <%= link_to config.fetch(:label), admin_model_path(key),
15
- class: "btn btn-sm #{open ? 'btn-primary' : 'btn-neutral'}",
16
- aria: { current: (open ? "page" : nil) } %>
17
- <% end %>
18
- </div>
19
- </div>
20
- </nav>
3
+ <div class="max-w-7xl mx-auto px-4 pt-6 pb-8 space-y-6">
4
+ <%# The title and a tab per model: studio/_page_header, as on /admin/style. A
5
+ tab is a link, so the open one is the page's own model. The table's sticky
6
+ header sits under this row (docs/PAGE_HEADER.md). %>
7
+ <%= render "studio/page_header",
8
+ title: "Models",
9
+ label: "Models",
10
+ tabs: admin_model_configs.map { |key, config|
11
+ { label: config.fetch(:label), href: admin_model_path(key), current: (key.to_s == @key ? "page" : nil) }
12
+ } %>
21
13
 
22
14
  <p class="text-muted text-sm">
23
15
  <%= @config.fetch(:description) %>.
@@ -3,8 +3,8 @@
3
3
  first answer, never on a page view. %>
4
4
  <% content_for(:title, "Surveys") %>
5
5
  <div class="max-w-4xl mx-auto px-4 py-6" data-admin-surveys>
6
- <h1 class="text-2xl font-extrabold text-heading mb-1">Surveys</h1>
7
- <p class="text-sm text-secondary mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
6
+ <%= render "studio/page_header", title: "Surveys" %>
7
+ <p class="text-sm text-secondary mt-1 mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
8
8
 
9
9
  <% if @rows.empty? %>
10
10
  <div class="empty-state">
@@ -3,7 +3,7 @@
3
3
  <%= form_with url: studio_agent_chat_messages_path, method: :post, local: true,
4
4
  html: { style: "view-transition-name: agent-composer;" },
5
5
  class: "sticky bottom-3 z-10 rounded-3xl border border-subtle bg-surface shadow-lg focus-within:border-primary transition-colors p-2 flex flex-col gap-2",
6
- data: { agent_chat_form: true, turbo: false, test: "agent-chat-form" } do |f| %>
6
+ data: { agent_chat_form: true, turbo: false, test: "agent-chat-form", vt_morph: ("agent-composer" if @studio_page_transition_direction || Studio.page_transition_direction) } do |f| %>
7
7
  <%= hidden_field_tag :soul, soul, id: nil %>
8
8
  <p class="px-3 pt-1 text-xs text-danger-ink" role="alert" data-agent-chat-error hidden data-test="agent-chat-error"></p>
9
9
  <p class="sr-only" role="status" aria-live="polite" data-agent-chat-status data-test="agent-chat-status"></p>
@@ -1,7 +1,7 @@
1
1
  <%# locals: (name:, portrait: nil, role: nil) %>
2
2
  <%# The chat's head (studio/agent_chat/_chat): the agent's portrait, a big circle centered on top, its name and role under it. `portrait` is ready markup; with none, the name's initial on a plain circle. The portrait keeps one view-transition name, as on the hub's agent pages. %>
3
3
  <div class="flex flex-col items-center gap-2 text-center min-w-0" data-test="agent-chat-header" data-agent-name="<%= name %>">
4
- <div class="rounded-full shadow-md overflow-hidden" style="view-transition-name: agent-portrait;" data-test="agent-chat-portrait">
4
+ <div class="rounded-full shadow-md overflow-hidden" style="view-transition-name: agent-portrait;"<% if @studio_page_transition_direction || Studio.page_transition_direction %> data-vt-morph="agent-portrait"<% end %> data-test="agent-chat-portrait">
5
5
  <% if portrait.present? %>
6
6
  <%= portrait %>
7
7
  <% else %>
@@ -0,0 +1,44 @@
1
+ <%# The kit's scoped styles, read from the theme's tokens with a fallback beside
2
+ each, like the footer's. Inline, so nothing joins the app's asset manifest or
3
+ its Tailwind build. %>
4
+ <style>
5
+ .mkit { --mkit-ink: var(--color-text, #111827); --mkit-soft: var(--color-text-secondary, #4b5563);
6
+ --mkit-line: var(--color-border, rgb(127 127 127 / .3)); --mkit-card: var(--color-surface, transparent);
7
+ --mkit-primary: var(--color-primary, currentColor);
8
+ --mkit-link: var(--color-primary-ink, var(--mkit-primary));
9
+ max-width: 64rem; margin-inline: auto; padding: 4rem 1rem; display: grid; gap: 3rem; }
10
+ .mkit-hero { max-width: 48rem; margin-inline: auto; text-align: center; }
11
+ .mkit-tagline { margin: 0 0 .5rem; color: var(--mkit-link); font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
12
+ .mkit-title { margin: 0; color: var(--mkit-ink); font-size: 2.25rem; line-height: 1.1; font-weight: 800; letter-spacing: -.025em; }
13
+ .mkit-lede { margin: 1rem 0 0; color: var(--mkit-soft); font-size: 1.125rem; line-height: 1.625; }
14
+ .mkit-heading { margin: 0 0 1rem; color: var(--mkit-ink); font-size: 1.25rem; font-weight: 700; }
15
+ .mkit-card { overflow: hidden; border: 1px solid var(--mkit-line); border-radius: .75rem; background: var(--mkit-card); }
16
+ .mkit-card-body { display: block; padding: 1rem; }
17
+ .mkit-name { display: block; margin: 0; color: var(--mkit-ink); font-weight: 600; }
18
+
19
+ .mkit-logos { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); }
20
+ .mkit-tiles { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
21
+ .mkit-tile { display: flex; align-items: center; justify-content: center; height: 8rem; padding: 1rem; }
22
+ .mkit-tile-img { max-width: 100%; max-height: 100%; object-fit: contain; }
23
+ .mkit-downloads { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-top: .5rem; font-size: .875rem; }
24
+ .mkit-download { color: var(--mkit-link); text-decoration: underline; text-underline-offset: 2px; }
25
+ .mkit-download:hover { text-decoration-thickness: 2px; }
26
+
27
+ .mkit-palette { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); }
28
+ .mkit-swatch { position: relative; }
29
+ .mkit-swatch-button { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: copy; }
30
+ .mkit-swatch-button:focus-visible { outline: 2px solid var(--mkit-primary); outline-offset: -2px; }
31
+ .mkit-swatch:hover { border-color: var(--mkit-primary); }
32
+ .mkit-chip { display: block; height: 5rem; border-bottom: 1px solid var(--mkit-line); }
33
+ .mkit-hex { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .875rem; color: var(--mkit-soft); user-select: text; }
34
+ /* The confirmation sits on the chip, on a ground of its own, so it reads over any colour. */
35
+ .mkit-status { position: absolute; top: .5rem; right: .5rem; padding: .125rem .5rem; border-radius: 9999px;
36
+ background: #111827; color: #fff; font-size: .75rem; font-weight: 600; pointer-events: none; }
37
+ /* Empty, it shows nothing but stays in the tree: a live region that was display: none can miss its announcement. */
38
+ .mkit-status:empty { padding: 0; background: none; }
39
+
40
+ .mkit-face { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .75rem; padding: 1rem; }
41
+ .mkit-face + .mkit-face { border-top: 1px solid var(--mkit-line); }
42
+ .mkit-specimen { margin: 0; color: var(--mkit-ink); font-size: 1.25rem; }
43
+ .mkit-face-label { margin: 0; color: var(--mkit-soft); font-size: .875rem; }
44
+ </style>
@@ -0,0 +1,84 @@
1
+ <%# The marketing kit: logos, colours and typefaces (a resolved `kit`, from
2
+ Studio::MarketingKit.resolve). Each part goes when it has nothing to show. The
3
+ layout is scoped CSS (.mkit-*), not Tailwind utilities, for the footer's
4
+ reason: a utility a consumer's build has not seen compiles to nothing. %>
5
+ <%= render "studio/marketing_kit/assets" %>
6
+ <div class="mkit" data-marketing-kit>
7
+ <header class="mkit-hero">
8
+ <% if kit[:tagline] %>
9
+ <p class="mkit-tagline"><%= kit[:tagline] %></p>
10
+ <% end %>
11
+ <h1 class="mkit-title"><%= Studio::MarketingKit::LABEL %></h1>
12
+ <p class="mkit-lede">The <%= kit[:name] ? "#{kit[:name]} " : "" %>logos, colours and typefaces. Use them when you write about us.</p>
13
+ </header>
14
+
15
+ <% if kit[:logos].any? %>
16
+ <section aria-labelledby="mkit-logos" data-test="kit-logos">
17
+ <h2 id="mkit-logos" class="mkit-heading">Logos</h2>
18
+ <div class="mkit-logos">
19
+ <% kit[:logos].each do |logo| %>
20
+ <div class="mkit-card">
21
+ <div class="mkit-tiles">
22
+ <% logo[:tones].each do |tone| %>
23
+ <div class="mkit-tile" style="background-color: <%= tone[:ground] %>">
24
+ <img src="<%= tone[:src] %>" alt="<%= tone[:alt] %>" class="mkit-tile-img">
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ <div class="mkit-card-body">
29
+ <% if logo[:title] %>
30
+ <p class="mkit-name"><%= logo[:title] %></p>
31
+ <% end %>
32
+ <div class="mkit-downloads">
33
+ <% logo[:tones].each do |tone| %>
34
+ <a href="<%= tone[:href] %>" download class="mkit-download"><%= tone[:label] %></a>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <% end %>
40
+ </div>
41
+ </section>
42
+ <% end %>
43
+
44
+ <% if kit[:palette].any? %>
45
+ <section aria-labelledby="mkit-colours" data-test="kit-colours">
46
+ <h2 id="mkit-colours" class="mkit-heading">Colours</h2>
47
+ <div class="mkit-palette">
48
+ <% kit[:palette].each do |swatch| %>
49
+ <%# One button holds the chip, the name and the hex, so a click on any of
50
+ them copies. The confirmation sits outside it: a button's children
51
+ are not announced, and a status must be. %>
52
+ <div class="mkit-card mkit-swatch" data-studio-controller="copy-text" data-copy-text-text-value="<%= swatch[:hex] %>">
53
+ <button type="button" class="mkit-swatch-button" data-studio-action="click->copy-text#copy"
54
+ aria-label="Copy <%= [ swatch[:name], swatch[:hex] ].compact.join(", ") %>">
55
+ <span class="mkit-chip" style="background-color: <%= swatch[:hex] %>"></span>
56
+ <span class="mkit-card-body">
57
+ <% if swatch[:name] %>
58
+ <span class="mkit-name"><%= swatch[:name] %></span>
59
+ <% end %>
60
+ <code class="mkit-hex"><%= swatch[:hex] %></code>
61
+ </span>
62
+ </button>
63
+ <span class="mkit-status" role="status" data-copy-text-target="status"></span>
64
+ </div>
65
+ <% end %>
66
+ </div>
67
+ </section>
68
+ <% end %>
69
+
70
+ <% if kit[:typefaces].any? %>
71
+ <section aria-labelledby="mkit-type" data-test="kit-type">
72
+ <h2 id="mkit-type" class="mkit-heading">Typefaces</h2>
73
+ <div class="mkit-card">
74
+ <% kit[:typefaces].each do |face| %>
75
+ <div class="mkit-face">
76
+ <% style = [ ("font-family: #{face[:family]}, sans-serif" if face[:family]), ("font-weight: #{face[:weight]}" if face[:weight]) ].compact.join("; ") %>
77
+ <p class="mkit-specimen"<% if style.present? %> style="<%= style %>"<% end %>><%= kit[:name] || face[:name] %></p>
78
+ <p class="mkit-face-label"><%= [ face[:role], face[:name] ].compact.join(" · ") %></p>
79
+ </div>
80
+ <% end %>
81
+ </div>
82
+ </section>
83
+ <% end %>
84
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# /marketing-kit (Studio::MarketingKitController). The kit is a partial so a host
2
+ can render it in a page of its own. %>
3
+ <% kit = studio_marketing_kit %>
4
+ <% content_for(:title, [ Studio::MarketingKit::LABEL, kit[:name] ].compact.join(" · ")) %>
5
+ <% content_for(:full_width, true) %>
6
+ <%= render "studio/marketing_kit/kit", kit: kit %>
@@ -78,6 +78,7 @@
78
78
  .ftr-wordmark-accent { color: var(--ftr-primary); }
79
79
  .ftr-tagline { margin: 1.5rem 0 0; line-height: 1.625; color: var(--color-text-secondary, inherit); }
80
80
  .ftr-email { margin: .75rem 0 0; }
81
+ .ftr-kit { margin: .75rem 0 0; }
81
82
  .ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
82
83
  .ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
83
84
  border-radius: 9999px; background: var(--ftr-primary); color: #fff; transition: transform .15s ease, filter .15s ease; }