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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +16 -1
- data/app/assets/tailwind/studio_engine/engine.css +210 -11
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -0
- data/app/javascript/studio/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +181 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/views/components/_link_sidebar.html.erb +5 -2
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/show.html.erb +10 -18
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_form.html.erb +1 -1
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -0
- data/app/views/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +17 -28
- data/docs/SITE_FOOTER.md +113 -2
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +22 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +83 -1
- metadata +11 -1
|
@@ -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
|
|
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,
|
|
58
|
-
stack (
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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)`:
|
|
154
|
-
|
|
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`).
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# THE MARKETING KIT (docs/SITE_FOOTER.md, "The marketing kit"): the public page
|
|
4
|
+
# at /marketing-kit, with the app's logos, colours and typefaces for anyone to
|
|
5
|
+
# use. Studio.marketing_kit declares it, as a Hash or a callable taking the view:
|
|
6
|
+
#
|
|
7
|
+
# config.marketing_kit = {
|
|
8
|
+
# tagline: "Everything, by example",
|
|
9
|
+
# logos: [ { title: "Icon", light: { png: "/brand/icon-light.png", svg: "/brand/icon-light.svg" },
|
|
10
|
+
# dark: { png: "/brand/icon-dark.png", svg: "/brand/icon-dark.svg" } } ],
|
|
11
|
+
# palette: [ { name: "Ink", hex: "#1A1535" } ],
|
|
12
|
+
# typefaces: [ { role: "Headings", family: "Montserrat", weight: 800, name: "Montserrat ExtraBold" } ]
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# Every key is optional. A missing `logos` derives from the app's brand artwork
|
|
16
|
+
# (Studio.navbar_wordmark, the footer's lockup, Studio.theme_logos) and a missing
|
|
17
|
+
# `palette` from the colours it paints (ThemeSetting, else Studio.theme_config),
|
|
18
|
+
# so a new app has a working page with no setup. The engine knows no typeface or tagline, so those parts are
|
|
19
|
+
# simply absent until declared.
|
|
20
|
+
#
|
|
21
|
+
# A FILE THAT IS NOT THERE IS NOT AN ERROR. Each file is looked for before it is
|
|
22
|
+
# linked: a tone with no file has no tile and no link, and a logo with no file
|
|
23
|
+
# has no card. So the page shows no broken image, and it does not raise.
|
|
24
|
+
#
|
|
25
|
+
# `light` means the artwork made for a light background; `dark`, for a dark one.
|
|
26
|
+
module Studio
|
|
27
|
+
module MarketingKit
|
|
28
|
+
PATH = "/marketing-kit"
|
|
29
|
+
LABEL = "Marketing Kit"
|
|
30
|
+
CONTROLLER = "studio/marketing_kit"
|
|
31
|
+
ROUTE_NAME = :studio_marketing_kit
|
|
32
|
+
|
|
33
|
+
LIGHT_GROUND = "#FFFFFF"
|
|
34
|
+
FALLBACK_DARK_GROUND = "#111827"
|
|
35
|
+
|
|
36
|
+
HEX = /\A#(?:\h{3}|\h{4}|\h{6}|\h{8})\z/
|
|
37
|
+
# What may sit inside a style attribute: a family list, and a CSS weight.
|
|
38
|
+
FONT_FAMILY = /\A[\w\s"',.-]+\z/
|
|
39
|
+
FONT_WEIGHT = /\A(?:[1-9]00|normal|bold|lighter|bolder)\z/
|
|
40
|
+
|
|
41
|
+
DOWNLOAD_LABELS = { light: "Download Light", dark: "Download Dark" }.freeze
|
|
42
|
+
FORMATS = %i[png svg].freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def validate!(declared)
|
|
47
|
+
return if declared.nil? || declared == false || declared.is_a?(Hash) || declared.respond_to?(:call)
|
|
48
|
+
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"Studio.marketing_kit must be nil (derive it), a Hash, or a callable receiving the view " \
|
|
51
|
+
"(got #{declared.class}). See docs/SITE_FOOTER.md."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The kit the page prints.
|
|
55
|
+
#
|
|
56
|
+
# logos: Studio.theme_logos, for a kit that declares no `logos`
|
|
57
|
+
# artwork: brand artwork of one file per theme, [ title, { light:, dark: } ]
|
|
58
|
+
# rows that come before them (the wordmark, the lockup)
|
|
59
|
+
# theme: the colours the app paints, for a kit that declares no `palette`
|
|
60
|
+
# name: the site's name, for a kit that declares none
|
|
61
|
+
# dark_ground: the dark tile's background
|
|
62
|
+
# locate: a callable answering a file's URL, or nil when it is not there
|
|
63
|
+
def resolve(declared, view, logos: [], artwork: [], theme: {}, name: nil, dark_ground: nil,
|
|
64
|
+
locate: ->(file) { locate(file, view) })
|
|
65
|
+
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
66
|
+
validate!(raw)
|
|
67
|
+
facts = raw ? SiteFooter.symbolize(raw) : {}
|
|
68
|
+
ground = hex(dark_ground) || FALLBACK_DARK_GROUND
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
name: SiteFooter.text(facts[:name]) || SiteFooter.text(name),
|
|
72
|
+
tagline: SiteFooter.text(facts[:tagline]),
|
|
73
|
+
logos: logo_cards(facts.key?(:logos) ? facts[:logos] : derived_artwork(artwork) + derived_logos(logos), locate, ground),
|
|
74
|
+
palette: swatches(facts.key?(:palette) ? facts[:palette] : derived_palette(theme)),
|
|
75
|
+
typefaces: typefaces(facts[:typefaces])
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ---- logos ---------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# One card per logo that has a file, each with one tile per tone that has one.
|
|
82
|
+
# Two theme entries for one file (a Navbar Logo that is also the Auth Logo)
|
|
83
|
+
# are one card.
|
|
84
|
+
def logo_cards(declared, locate, dark_ground)
|
|
85
|
+
Array(declared).filter_map do |row|
|
|
86
|
+
next unless row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
87
|
+
|
|
88
|
+
logo = SiteFooter.symbolize(row)
|
|
89
|
+
tones = { light: LIGHT_GROUND, dark: dark_ground }.filter_map do |tone, ground|
|
|
90
|
+
tile(logo[:title], tone, ground, logo[tone], locate)
|
|
91
|
+
end
|
|
92
|
+
{ title: SiteFooter.text(logo[:title]), tones: tones } unless tones.empty?
|
|
93
|
+
end.uniq { |card| card[:tones].map { |tone| tone[:href] } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A download is the PNG when there is one, else the SVG. The tile shows the
|
|
97
|
+
# SVG when there is one, since it stays sharp at any size.
|
|
98
|
+
def tile(title, tone, ground, files, locate)
|
|
99
|
+
found = formats(files).transform_values { |file| locate.call(file) }.compact
|
|
100
|
+
href = found[:png] || found[:svg]
|
|
101
|
+
return nil if href.nil?
|
|
102
|
+
|
|
103
|
+
{ tone: tone, ground: ground, label: DOWNLOAD_LABELS.fetch(tone), href: href,
|
|
104
|
+
src: tile_src(found[:svg] || found[:png]),
|
|
105
|
+
alt: [SiteFooter.text(title), "for a #{tone} background"].compact.join(", ") }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# { png:, svg: }, or one path whose extension says which it is.
|
|
109
|
+
def formats(files)
|
|
110
|
+
return SiteFooter.symbolize(files).slice(*FORMATS) if files.is_a?(Hash)
|
|
111
|
+
return {} unless files.is_a?(String)
|
|
112
|
+
|
|
113
|
+
format = File.extname(files.split(/[?#]/).first.to_s).delete_prefix(".").downcase.to_sym
|
|
114
|
+
FORMATS.include?(format) ? { format => files } : {}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ?kit keeps a tile's tone. A host may repaint its own logo by the image's
|
|
118
|
+
# exact src on a dark page (the hub's `.dark img[src="/brand/icon-light.svg"]`),
|
|
119
|
+
# which would turn the light tile's artwork into the dark one. The download
|
|
120
|
+
# keeps the plain URL.
|
|
121
|
+
def tile_src(url)
|
|
122
|
+
return url if url.match?(%r{\Ahttps?://}i)
|
|
123
|
+
|
|
124
|
+
"#{url}#{url.include?('?') ? '&' : '?'}kit"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Artwork that already names its file per tone, as the wordmark and the
|
|
128
|
+
# lockup do. Each file may have its other format beside it.
|
|
129
|
+
def derived_artwork(rows)
|
|
130
|
+
Array(rows).filter_map do |title, files|
|
|
131
|
+
next unless files.respond_to?(:to_h) && !files.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
tones = SiteFooter.symbolize(files).slice(:light, :dark).compact
|
|
134
|
+
{ title: title }.merge(tones.transform_values { |file| both_formats(file.to_s.delete_suffix(File.extname(file.to_s))) })
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The logos Studio.theme_logos implies. It names one file per logo and says
|
|
139
|
+
# nothing about tone, so the rest is looked for beside it: the same stem as
|
|
140
|
+
# the other format, and a -light/-dark (or _light/_dark) sibling. A stem with
|
|
141
|
+
# no such hint is the light artwork.
|
|
142
|
+
def derived_logos(theme_logos)
|
|
143
|
+
entries = Array(theme_logos).map { |entry| entry.is_a?(Hash) ? SiteFooter.symbolize(entry) : { file: entry } }
|
|
144
|
+
entries.filter_map do |entry|
|
|
145
|
+
file = SiteFooter.text(entry[:file])
|
|
146
|
+
next if file.nil?
|
|
147
|
+
|
|
148
|
+
title = SiteFooter.text(entry[:title]) || humanize(file)
|
|
149
|
+
next if title.match?(/favicon/i)
|
|
150
|
+
|
|
151
|
+
{ title: title }.merge(tone_candidates(file))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tone_candidates(file)
|
|
156
|
+
stem = file.delete_suffix(File.extname(file))
|
|
157
|
+
base, separator, hint = stem.match(/\A(.+)([-_])(light|dark)\z/)&.captures
|
|
158
|
+
|
|
159
|
+
return { light: both_formats(stem), dark: both_formats("#{stem}-dark") } if hint.nil?
|
|
160
|
+
|
|
161
|
+
other = hint == "light" ? "dark" : "light"
|
|
162
|
+
{ hint.to_sym => both_formats(stem), other.to_sym => both_formats("#{base}#{separator}#{other}") }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def both_formats(stem) = { png: "#{stem}.png", svg: "#{stem}.svg" }
|
|
166
|
+
|
|
167
|
+
def humanize(file)
|
|
168
|
+
File.basename(file, File.extname(file)).tr("-_", " ").split.map(&:capitalize).join(" ")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ---- colours and type ----------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def derived_palette(theme)
|
|
174
|
+
theme.to_h.map { |name, value| { name: name.to_s.capitalize, hex: value } }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Only a real hex is printed: it goes into a style attribute and the clipboard.
|
|
178
|
+
def swatches(declared)
|
|
179
|
+
Array(declared).filter_map do |row|
|
|
180
|
+
next unless row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
181
|
+
|
|
182
|
+
swatch = SiteFooter.symbolize(row)
|
|
183
|
+
value = hex(swatch[:hex])
|
|
184
|
+
{ name: SiteFooter.text(swatch[:name]), hex: value } unless value.nil?
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def hex(value)
|
|
189
|
+
string = value.to_s.strip
|
|
190
|
+
string.match?(HEX) ? string : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A face with no family cannot be shown. A family or weight CSS would not
|
|
194
|
+
# read leaves the specimen in the page's own type, with its label intact.
|
|
195
|
+
def typefaces(declared)
|
|
196
|
+
Array(declared).filter_map do |row|
|
|
197
|
+
next unless row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
198
|
+
|
|
199
|
+
face = SiteFooter.symbolize(row)
|
|
200
|
+
family = SiteFooter.text(face[:family])
|
|
201
|
+
next if family.nil?
|
|
202
|
+
|
|
203
|
+
{ role: SiteFooter.text(face[:role]), family: (family if family.match?(FONT_FAMILY)),
|
|
204
|
+
weight: face[:weight].to_s[FONT_WEIGHT], name: SiteFooter.text(face[:name]) || family }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- finding a file ------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
# The URL of a file, or nil when it is not there. An http(s) URL is taken at
|
|
211
|
+
# its word, since looking would cost the page a network call. A path is
|
|
212
|
+
# looked for in public/; a bare name, then in the asset pipeline.
|
|
213
|
+
def locate(file, view = nil)
|
|
214
|
+
value = SiteFooter.text(file)
|
|
215
|
+
return nil if value.nil?
|
|
216
|
+
return value if value.match?(%r{\Ahttps?://[^\s]+\z}i)
|
|
217
|
+
return nil unless local?(value)
|
|
218
|
+
|
|
219
|
+
path = "/#{value.delete_prefix('/')}"
|
|
220
|
+
return path if public_file?(path)
|
|
221
|
+
|
|
222
|
+
value.start_with?("/") ? nil : pipeline_path(value, view)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# No scheme, no host, no control character and no climbing out of public/.
|
|
228
|
+
def local?(value)
|
|
229
|
+
return false if value.match?(/[\x00-\x1f\x7f\s]/) || value.start_with?("//")
|
|
230
|
+
return false if value.match?(/\A[a-z][a-z0-9+.-]*:/i)
|
|
231
|
+
|
|
232
|
+
!value.split(/[?#]/).first.to_s.split("/").include?("..")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def public_file?(path)
|
|
236
|
+
!PublicFileVersion.version(path.split(/[?#]/).first).nil?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def pipeline_path(name, view)
|
|
240
|
+
return nil unless view.respond_to?(:asset_path) && in_pipeline?(name)
|
|
241
|
+
|
|
242
|
+
view.asset_path(name)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Propshaft answers through its load path; Sprockets through its environment
|
|
246
|
+
# or, precompiled, its manifest. asset_path alone cannot say: a host may have
|
|
247
|
+
# it fall back to a plain path for a file that is not there.
|
|
248
|
+
def in_pipeline?(name)
|
|
249
|
+
return false unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
250
|
+
|
|
251
|
+
app = ::Rails.application
|
|
252
|
+
assets = app.respond_to?(:assets) ? app.assets : nil
|
|
253
|
+
return !assets.load_path.find(name).nil? if assets.respond_to?(:load_path)
|
|
254
|
+
return !assets.find_asset(name).nil? if assets.respond_to?(:find_asset)
|
|
255
|
+
|
|
256
|
+
manifest = app.respond_to?(:assets_manifest) ? app.assets_manifest : nil
|
|
257
|
+
manifest.respond_to?(:assets) && manifest.assets.key?(name)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# ---- the route -----------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
# True when the route set holds a GET of /marketing-kit that is not the
|
|
263
|
+
# engine's: the host's own page, which wins. Asked for every footer on a page
|
|
264
|
+
# that has no engine route, and by the engine route's constraint, so the
|
|
265
|
+
# answer is kept per route set until that set changes size.
|
|
266
|
+
def host_route?(routes)
|
|
267
|
+
table = routes.routes
|
|
268
|
+
known = host_route_memo[routes]
|
|
269
|
+
return known.last if known && known.first == table.size
|
|
270
|
+
|
|
271
|
+
answer = table.any? { |route| host_page_route?(route) }
|
|
272
|
+
host_route_memo[routes] = [table.size, answer]
|
|
273
|
+
answer
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def host_route_memo
|
|
277
|
+
@host_route_memo ||= {}.compare_by_identity
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def host_page_route?(route)
|
|
281
|
+
return false if route.defaults[:controller] == CONTROLLER
|
|
282
|
+
return false unless route.path.spec.to_s.delete_suffix("(.:format)") == PATH
|
|
283
|
+
|
|
284
|
+
verbs = route.verb.to_s
|
|
285
|
+
verbs.empty? || verbs.split("|").include?("GET")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The engine's route gives way to a host route drawn AFTER Studio.routes.
|
|
289
|
+
# (One drawn before it wins by order, and then the engine draws nothing.)
|
|
290
|
+
class YieldToHostRoute
|
|
291
|
+
def initialize(routes) = @routes = routes
|
|
292
|
+
def matches?(_request) = !MarketingKit.host_route?(@routes)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Where the footer links: the page's path, or nil when this app has no page.
|
|
296
|
+
def page_path(view, routes)
|
|
297
|
+
return nil if routes.nil?
|
|
298
|
+
|
|
299
|
+
SiteFooter.host_path(view, ROUTE_NAME, routes) || (PATH if host_route?(routes))
|
|
300
|
+
rescue StandardError
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/studio/site_footer.rb
CHANGED
|
@@ -31,8 +31,8 @@ module Studio
|
|
|
31
31
|
DEFAULT_BOOKING_LABEL = "Schedule a call"
|
|
32
32
|
DEFAULT_ZOOM = 15
|
|
33
33
|
|
|
34
|
-
# The engine's own
|
|
35
|
-
OWN_CONTROLLERS = %w[studio/bookings].freeze
|
|
34
|
+
# The engine's own public pages keep the footer for a signed-in viewer.
|
|
35
|
+
OWN_CONTROLLERS = %w[studio/bookings studio/marketing_kit].freeze
|
|
36
36
|
|
|
37
37
|
# The only schemes a footer href may carry. Anything else, notably
|
|
38
38
|
# `javascript:` and `data:`, is never written into an href: the label is
|
|
@@ -124,7 +124,7 @@ module Studio
|
|
|
124
124
|
# THE DEFAULT FOOTER'S FACTS: what an app that sets nothing gets. Only what
|
|
125
125
|
# is known to be true of the host: a legal line linking the routes in
|
|
126
126
|
# DEFAULT_LEGAL that the host really has. The name and the logo come from
|
|
127
|
-
# `resolve`'s own fallbacks. No address, no email, no tagline, no social row
|
|
127
|
+
# `resolve`'s own fallbacks, and so does the Marketing Kit link. No address, no email, no tagline, no social row
|
|
128
128
|
# and no link columns: the engine cannot know them, and a guessed link is a
|
|
129
129
|
# dead one.
|
|
130
130
|
#
|
|
@@ -171,13 +171,16 @@ module Studio
|
|
|
171
171
|
# address: Studio.site_footer_address: the address of facts that
|
|
172
172
|
# carry none of their own
|
|
173
173
|
# chat: the Chat entry's [label, path] (`chat_row`), or nil
|
|
174
|
-
|
|
174
|
+
# marketing_kit: the kit page's path (Studio::MarketingKit.page_path), or nil
|
|
175
|
+
def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil, chat: nil, marketing_kit: nil)
|
|
175
176
|
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
176
177
|
return nil if raw.nil? || raw == false
|
|
177
178
|
|
|
178
179
|
facts = symbolize(raw)
|
|
179
180
|
site_name = text(facts[:name]) || text(name)
|
|
180
181
|
booking = symbolize(facts[:booking] || {})
|
|
182
|
+
columns = with_chat(Array(facts[:columns]).filter_map { |row| column(row, booking_path) }, chat)
|
|
183
|
+
legal = Array(facts[:legal]).filter_map { |row| link(row, booking_path) }
|
|
181
184
|
|
|
182
185
|
{
|
|
183
186
|
name: site_name,
|
|
@@ -190,8 +193,9 @@ module Studio
|
|
|
190
193
|
email: text(facts[:email]),
|
|
191
194
|
address: address_for(facts, address),
|
|
192
195
|
social: Array(facts[:social]).filter_map { |row| social(row) },
|
|
193
|
-
columns:
|
|
194
|
-
legal:
|
|
196
|
+
columns: columns,
|
|
197
|
+
legal: legal,
|
|
198
|
+
marketing_kit: marketing_kit_link(facts, marketing_kit, columns, legal),
|
|
195
199
|
booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
|
|
196
200
|
booking_title: text(booking[:title])
|
|
197
201
|
}
|
|
@@ -417,6 +421,18 @@ module Studio
|
|
|
417
421
|
end
|
|
418
422
|
end
|
|
419
423
|
|
|
424
|
+
# THE MARKETING KIT LINK, in the brand block: every app with the page gets it
|
|
425
|
+
# without declaring it. An app that links the page itself keeps its own row
|
|
426
|
+
# and gets no second one, and `marketing_kit: false` in the facts shows none.
|
|
427
|
+
def marketing_kit_link(facts, path, columns, legal)
|
|
428
|
+
return nil if path.nil? || (facts.key?(:marketing_kit) && !facts[:marketing_kit])
|
|
429
|
+
|
|
430
|
+
declared = columns.flat_map { |column| column[:links] } + legal
|
|
431
|
+
return nil if declared.any? { |row| Studio::Booking.same_path?(row[:href], path) }
|
|
432
|
+
|
|
433
|
+
link([ Studio::MarketingKit::LABEL, path ])
|
|
434
|
+
end
|
|
435
|
+
|
|
420
436
|
# A column's width hint, or nil for the default share. Anything that is not a
|
|
421
437
|
# number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
|
|
422
438
|
MIN_COLUMN_WIDTH = 0.5
|
data/lib/studio/version.rb
CHANGED