studio-engine 0.123.0 → 0.125.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 +95 -0
  3. data/README.md +16 -1
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +75 -0
  5. data/app/assets/stylesheets/studio/page_transition_full_load.css +10 -0
  6. data/app/assets/tailwind/studio_engine/engine.css +216 -11
  7. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  8. data/app/helpers/studio/site_footer_helper.rb +15 -2
  9. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  10. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  11. data/app/javascript/studio/page_header.js +24 -0
  12. data/app/javascript/studio/page_transitions.js +263 -0
  13. data/app/javascript/studio/pinned_stack.js +12 -1
  14. data/app/javascript/studio/stimulus.js +6 -1
  15. data/app/views/components/_link_sidebar.html.erb +6 -3
  16. data/app/views/components/_user_nav.html.erb +5 -4
  17. data/app/views/layouts/_navbar.html.erb +7 -4
  18. data/app/views/layouts/studio/_head.html.erb +17 -2
  19. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  20. data/app/views/studio/_page_header.html.erb +65 -0
  21. data/app/views/studio/admin_models/show.html.erb +10 -18
  22. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  23. data/app/views/studio/agent_chat/_form.html.erb +1 -1
  24. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  25. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  26. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  27. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  28. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  29. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  30. data/app/views/style/_tricks.html.erb +35 -0
  31. data/app/views/style/index.html.erb +17 -28
  32. data/docs/SITE_FOOTER.md +113 -2
  33. data/lib/studio/engine.rb +5 -2
  34. data/lib/studio/marketing_kit.rb +304 -0
  35. data/lib/studio/site_footer.rb +22 -6
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +83 -1
  38. metadata +13 -1
@@ -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; }
@@ -44,6 +44,11 @@
44
44
  <% if facts[:email] %>
45
45
  <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
46
46
  <% end %>
47
+ <%# The brand block, not a link column or the legal line: the kit belongs to the
48
+ brand, and an app's own columns and legal line stay exactly what it wrote. %>
49
+ <% if facts[:marketing_kit] %>
50
+ <p class="ftr-kit"><%= render "studio/site_footer/link", link: facts[:marketing_kit], booking: booking %></p>
51
+ <% end %>
47
52
  <% if facts[:social].any? %>
48
53
  <ul class="ftr-socials" aria-label="Social profiles">
49
54
  <% facts[:social].each do |profile| %>
@@ -72,6 +72,41 @@
72
72
  </div>
73
73
  </section>
74
74
 
75
+ <!-- Page header -->
76
+ <section class="space-y-5" data-page-header-specimen>
77
+ <div class="space-y-1">
78
+ <h3 class="text-xl font-bold text-heading">Page header</h3>
79
+ <p class="text-muted text-sm">
80
+ <code class="font-mono text-2xs">studio/_page_header</code>: the title, optional meta beside
81
+ it and optional tabs on the right. This page's own title row is one: scroll to see it take the bar.
82
+ It joins the pinned stack, so a table's sticky header sits under it: navbar, page row, table
83
+ header, each below the last. The rule is in <code class="font-mono text-2xs">docs/PAGE_HEADER.md</code>.
84
+ </p>
85
+ </div>
86
+ <div class="grid gap-4 lg:grid-cols-2">
87
+ <%# The classes alone, not the partial: the partial's row is sticky, joins the
88
+ pinned stack and holds the page's h1, and a page has one of each. %>
89
+ <% [["At rest", {}], ["Stuck", { stuck: "" }]].each do |state, data| %>
90
+ <%= render layout: "style/specimen",
91
+ locals: { klass: "studio/_page_header (#{state.downcase})",
92
+ usage: %(<%= render "studio/page_header",\n title: "Models", meta: "v1.2.3", label: "Models",\n tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %\>) } do %>
93
+ <%= tag.div(class: "page-header w-full px-4", data: data) do %>
94
+ <div class="page-header-lead">
95
+ <p class="page-header-title">Models</p>
96
+ <p class="page-header-meta label-upper">v1.2.3</p>
97
+ </div>
98
+ <div class="page-header-tabs">
99
+ <div class="page-header-track">
100
+ <a href="#tricks" class="btn btn-sm btn-primary">Users</a>
101
+ <a href="#tricks" class="btn btn-sm btn-neutral">Teams</a>
102
+ </div>
103
+ </div>
104
+ <% end %>
105
+ <% end %>
106
+ <% end %>
107
+ </div>
108
+ </section>
109
+
75
110
  <!-- Surfaces, text and form -->
76
111
  <section class="space-y-5">
77
112
  <div class="space-y-1">
@@ -3,7 +3,7 @@
3
3
  application.html.erb, inheriting that app's navbar and theme, so every
4
4
  specimen restyles per app and in dark or light automatically.
5
5
 
6
- Five ENGINE sections, reached by the sticky title row below and rendered
6
+ Five ENGINE sections, reached by the page header below and rendered
7
7
  as sibling partials: Theme is the landing section (the color foundation - it
8
8
  owns the role tokens/swatches, the theme editor, the live preview, and the
9
9
  logos and type), then Modals (the shared host + engine card blocks), Tricks
@@ -53,36 +53,25 @@
53
53
  ["components", "Components"]
54
54
  ]
55
55
  %>
56
- <div class="max-w-6xl mx-auto px-4 pb-16">
57
- <%# The page's title and its section tabs, one sticky row under the whole pinned
58
- stack (--pin-stack-bottom; then --nav-bottom and --nav-h for a host on an
59
- older publisher; then 0). Not --nav-h first: that is the header's HEIGHT,
60
- and chrome stacked above the header would sit over the row by its own
61
- height. The tabs scroll sideways on a narrow screen, softened by
62
- .fade-edge-x; px-8 matches its 2rem fade, so a tab at rest is not faded.
63
- The row sits at --z-sticky, above --z-raised, so the live hold button's
64
- portal bubbles scroll under it rather than over it.
65
-
56
+ <div class="max-w-6xl mx-auto px-4 pt-6 pb-16">
57
+ <%# The page's title and its section tabs: studio/_page_header, the row of the
58
+ triple stack (docs/PAGE_HEADER.md). It sits at --z-sticky, above --z-raised,
59
+ so the live hold button's portal bubbles scroll under it rather than over it.
60
+
66
61
  The style-section-nav controller moves the highlight as the page scrolls
67
62
  (studio/style_section_nav). Theme starts active, so the first paint is
68
63
  right before the module loads. %>
69
- <nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
70
- style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
71
- aria-label="Style guide sections"
72
- data-studio-controller="style-section-nav">
73
- <h1 class="text-xl font-bold text-heading shrink-0">Style</h1>
74
- <div class="fade-edge-x overflow-x-auto min-w-0 flex-1" data-style-section-nav-target="scroller">
75
- <div class="flex items-center gap-2 min-w-max px-8">
76
- <% sections.each_with_index do |(anchor, label), index| %>
77
- <a href="#<%= anchor %>"
78
- class="btn btn-sm <%= index.zero? ? "btn-primary" : "btn-neutral" %>"
79
- <%= 'aria-current="true"'.html_safe if index.zero? %>
80
- data-style-section-nav-target="link"><%= label %></a>
81
- <% end %>
82
- </div>
83
- </div>
84
- <p class="label-upper shrink-0 hidden md:block" title="studio-engine">v<%= Studio::VERSION %></p>
85
- </nav>
64
+ <%= render "studio/page_header",
65
+ title: "Style",
66
+ meta: "v#{Studio::VERSION}",
67
+ meta_title: "studio-engine",
68
+ label: "Style guide sections",
69
+ data: { studio_controller: "style-section-nav" },
70
+ tabs_data: { style_section_nav_target: "scroller" },
71
+ tabs: sections.each_with_index.map { |(anchor, label), index|
72
+ { label: label, href: "##{anchor}", current: (index.zero? ? "true" : nil),
73
+ data: { style_section_nav_target: "link" } }
74
+ } %>
86
75
 
87
76
  <div class="space-y-16 pt-10">
88
77
  <%= render "style/theme" %>
data/docs/SITE_FOOTER.md CHANGED
@@ -53,6 +53,9 @@ It prints only what the engine knows to be true of the app:
53
53
  when a GET reaches it and its path needs no arguments. A host with neither
54
54
  gets the © line alone.
55
55
 
56
+ - **A Marketing Kit link**, when the app has the page: every app that calls
57
+ `Studio.routes` does ([The marketing kit](#the-marketing-kit)).
58
+
56
59
  It has no address, no map, no phone, no email, no tagline, no social row and no
57
60
  link columns, and the page names no Leaflet. The engine cannot know those, and
58
61
  a guessed link is a dead one. For any of them, add a location (below) or
@@ -150,8 +153,9 @@ Leave `config.site_footer` unset and the same app gets
150
153
  [the default footer](#the-default-footer), its name read from `config.app_name`
151
154
  since there is no site-identity table to ask.
152
155
 
153
- Do not call `Studio.routes(self)`: everything it draws (sign-in, admin, error
154
- logs) needs a database. The app also needs an asset pipeline (Propshaft or
156
+ Do not call `Studio.routes(self)`: what it draws (sign-in, admin, error logs)
157
+ needs a database. The one route that does not is the marketing kit's, which such
158
+ an app can draw alone ([The marketing kit](#the-marketing-kit)). The app also needs an asset pipeline (Propshaft or
155
159
  Sprockets), as every Rails 8 app has, and a mapping of the theme tokens onto its
156
160
  own palette (see [Colours](#colours)).
157
161
 
@@ -200,6 +204,7 @@ Every key is optional. A missing key removes its part of the footer.
200
204
  | `columns` | Link columns under bold headings. A column may carry a width hint: `[ "Contact", links, { width: 1.5 } ]` | Brand only |
201
205
  | `legal` | The centred line above the © | The © alone |
202
206
  | `booking` | `{ label:, title: }` for the popup's heading and the frame's accessible name | "Schedule a call", and "Schedule a call with <name>" |
207
+ | `marketing_kit` | `false` removes the footer's link to the kit ([The marketing kit](#the-marketing-kit)) | The link, on an app with the page |
203
208
 
204
209
  Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links: }`,
205
210
  `{ label:, icon:, url: }`). The rules a row can carry:
@@ -319,6 +324,103 @@ config.site_footer = ->(view) {
319
324
  only with a lockup (`studio/site_footer/_lockup_assets`). An engine older
320
325
  than the fact ignores the key, so a host can declare it before it upgrades.
321
326
 
327
+ ## The marketing kit
328
+
329
+ Every app has a public page at `/marketing-kit`, with its logos, colours and
330
+ typefaces, and the footer links it. Neither needs a line of setup.
331
+
332
+ **The page.** `Studio.routes` draws it by default, with no sign-in. The route's
333
+ helper is `studio_marketing_kit_path`.
334
+
335
+ | Setting | Meaning | Unset |
336
+ |---------|---------|-------|
337
+ | `config.draw_marketing_kit_routes` | `false` draws no page, and with no page the footer links none | `true` |
338
+ | `config.marketing_kit` | The kit: a Hash, or a callable taking the view | Derived from the theme (below) |
339
+
340
+ ```ruby
341
+ # config/initializers/studio.rb
342
+ config.marketing_kit = {
343
+ tagline: "Everything, by example",
344
+ logos: [ { title: "Icon", light: { png: "/brand/icon-light.png", svg: "/brand/icon-light.svg" },
345
+ dark: { png: "/brand/icon-dark.png", svg: "/brand/icon-dark.svg" } } ],
346
+ palette: [ { name: "Ink", hex: "#1A1535" } ],
347
+ typefaces: [ { role: "Headings", family: "Montserrat", weight: 800, name: "Montserrat ExtraBold" } ]
348
+ }
349
+ ```
350
+
351
+ Every key is optional, and each key stands alone: a kit that declares only
352
+ `tagline:` still derives its logos and colours. `logos: []` shows none. `name:`
353
+ overrides the site name in the page's first line.
354
+
355
+ | Key | With no declaration |
356
+ |-----|---------------------|
357
+ | `logos` | `config.navbar_wordmark` and the footer's `lockup`, each with its two tones, then one logo for each entry in `config.theme_logos` but the favicon. A theme entry names one file, so the rest is looked for beside it: a `-light`/`-dark` or `_light`/`_dark` sibling. A name with no tone in it is the light artwork. Every file's other format (`.png`, `.svg`) is looked for too |
358
+ | `palette` | The colours the app paints: its `/admin/theme` values over `Studio.theme_config` (primary, dark, light, success, warning, danger, accent) |
359
+ | `typefaces` | None. The engine cannot know them, so the section is gone |
360
+ | `tagline` | None |
361
+
362
+ - **`light` is the artwork for a light background**, and `dark` for a dark one.
363
+ - **A logo offers "Download Light" and "Download Dark".** Each downloads the PNG when
364
+ there is one, else the SVG. Make the PNG transparent. The tile shows the SVG when
365
+ there is one. Only PNG and SVG are offered.
366
+ - **A file that is not there is not an error.** Each file is looked for before it is
367
+ linked, in `public/` and, for a name with no leading `/`, in the asset pipeline. A
368
+ tone with no file has no tile and no link. A logo with no file has no card. An
369
+ `http(s)` URL is taken at its word and is not fetched, so a dead one shows a broken
370
+ image; a browser downloads a cross-origin file only if that host says so.
371
+ - **A colour copies on one click**, on the chip, the name or the hex, and shows
372
+ "Copied" on the chip. It runs from the lazy `copy-text` controller
373
+ (`studio/controllers/copy_text_controller`). Only a real hex is printed.
374
+ - **A tile's image carries `?kit`.** A host may repaint its own logo by the image's
375
+ exact `src` on a dark page, as the hub's `.dark img[src="/brand/icon-light.svg"]`
376
+ does, and that would turn a light tile's artwork dark. The download keeps the plain URL.
377
+ - **A callable that raises** shows locally. In production it is logged once and the
378
+ page shows what derives from the theme.
379
+
380
+ **A host's own page wins.** A GET route of the path `/marketing-kit` beats the
381
+ engine's, whichever side of `Studio.routes` it is drawn on. Drawn before, the engine
382
+ draws nothing; drawn after, the engine's route yields at request time. So the hub
383
+ keeps its page until it adopts this one.
384
+
385
+ **Sign-in.** `Studio::MarketingKitController` inherits the host's
386
+ `ApplicationController` for its layout and skips each gate it knows by name
387
+ (`HOST_GATES`): `require_authentication`, the hub's `require_admin_wall`,
388
+ `require_admin`, `require_login`, `authenticate_user!` and
389
+ `require_profile_completion`. A gate of another name needs one line:
390
+
391
+ ```ruby
392
+ # config/initializers/studio.rb
393
+ Rails.application.config.to_prepare do
394
+ Studio::MarketingKitController.skip_before_action :my_gate, raise: false
395
+ end
396
+ ```
397
+
398
+ A host whose test suite walks its route table sees `studio/marketing_kit#show` as a
399
+ public page; list it wherever that suite lists them.
400
+
401
+ **An app with no database** does not call `Studio.routes`, so it has no page and its
402
+ footer links none. To have both, draw the one route. The page reads no table and no
403
+ session:
404
+
405
+ ```ruby
406
+ # config/routes.rb
407
+ Studio.marketing_kit_routes(self)
408
+ ```
409
+
410
+ **The footer link** is "Marketing Kit", in the brand block, under the tagline and the
411
+ email. Every viewer who sees the footer sees it, on every app with the page, with
412
+ nothing declared. It is not in a link column or on the legal line, so an app's columns
413
+ and legal line stay exactly what it wrote.
414
+
415
+ - An app that already links the page in a column or on the legal line keeps its own
416
+ row and gets no second link. The comparison is on the path, so a query or a trailing
417
+ `/` still counts.
418
+ - `marketing_kit: false` in the facts shows no link.
419
+ - A signed-in viewer sees the footer on the page itself, as on the booking page.
420
+
421
+ To render the kit in a page of your own:
422
+ `render "studio/marketing_kit/kit", kit: Studio.marketing_kit_for(self)`.
423
+
322
424
  ## The Chat link
323
425
 
324
426
  The standard way to reach an app's agent chat ([`AGENT_CHAT.md`](AGENT_CHAT.md)):
@@ -478,6 +580,7 @@ These class names are stable, and an app may style against them:
478
580
  | `.ftr-link`, `.ftr-link-solid`, `.ftr-link-disabled` | A link; an address kept on one line; a "coming soon" label. `--ftr-link-ink` on `.ftr` is the link colour |
479
581
  | `.ftr-location`, `.ftr-location-title`, `.ftr-address` | The Location band |
480
582
  | `.ftr-legal`, `.ftr-copyright` | The legal line and the © line |
583
+ | `.ftr-kit` | The Marketing Kit link's line in the brand block |
481
584
  | `.ftr-map` | The map |
482
585
  | `.ftr-lockup`, `.ftr-lockup-light`, `.ftr-lockup-dark` | The lockup's images; `--ftr-lockup-height` on `.ftr` is its height |
483
586
 
@@ -527,5 +630,13 @@ link. Those rules are more specific than the engine's and still apply.
527
630
  line inside its column at each, and the line heights. These lab pages load Turbo, so the map's remount after a Turbo visit is
528
631
  covered.
529
632
 
633
+ - `test/lib/studio/marketing_kit_test.rb`: what a kit resolves to, derived and
634
+ declared, and that a missing file or a malformed row costs nothing.
635
+ - `test/integration/marketing_kit_test.rb`: the page through a real signed-out
636
+ request in a host that gates every controller, the footer link and its no-double
637
+ rule, and a host route of the same path winning from either side.
638
+ - `e2e/marketing_kit.spec.js`: the hex on the clipboard, the confirmation, the
639
+ refused copy, and the downloads, in a browser.
640
+
530
641
  `/admin/style` shows the footer under Tricks, with this app's own facts, or a
531
642
  sample when it has declared none (the default footer or `false`).
data/lib/studio/engine.rb CHANGED
@@ -89,8 +89,9 @@ module Studio
89
89
  # loaded as an ES module (Alpine by the head's type="module" tag,
90
90
  # canvas-confetti and SortableJS through their importmap pins, Leaflet by
91
91
  # studio/footer_map's import()), and studio/session.js, the one classic
92
- # script (layouts/studio/_head says why). The engine's own modules are
93
- # under app/javascript, enumerated below.
92
+ # script (layouts/studio/_head says why), with studio/page_transition_reveal.js,
93
+ # the other classic one. The engine's own modules are under app/javascript,
94
+ # enumerated below.
94
95
  app.config.assets.precompile += %w[
95
96
  studio/sticky_table_header.css
96
97
  studio/alpine.js
@@ -99,6 +100,8 @@ module Studio
99
100
  studio/leaflet.js
100
101
  studio/leaflet.css
101
102
  studio/session.js
103
+ studio/page_transition_full_load.css
104
+ studio/page_transition_reveal.js
102
105
  studio/montserrat-latin.woff2
103
106
  studio/montserrat-latin-ext.woff2
104
107
  ]