studio-engine 0.39.0 → 0.40.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 +76 -0
- data/README.md +38 -2
- data/app/assets/images/emails/email-change-confirmation.gif +0 -0
- data/app/assets/images/emails/magic-link.gif +0 -0
- data/app/helpers/studio/at_time_helper.rb +118 -0
- data/app/services/studio/email_catalog.rb +57 -12
- data/app/views/studio/_at_time_script.html.erb +253 -0
- data/app/views/studio/email_images/index.html.erb +5 -1
- data/app/views/studio/emails/_row.html.erb +15 -8
- data/app/views/studio/emails/index.html.erb +23 -8
- data/app/views/studio/emails/show.html.erb +4 -3
- data/app/views/style/_tricks.html.erb +66 -0
- data/app/views/style/index.html.erb +5 -0
- data/lib/studio/version.rb +1 -1
- metadata +6 -4
- data/app/assets/images/emails/email-change-confirmation.png +0 -0
- data/app/assets/images/emails/magic-link.png +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01f352e62e854f4ec09087aec52716a87ca4ac494e19882e554bb19dd154092c
|
|
4
|
+
data.tar.gz: 791a21cfc0130503ff5489f43857bde752892facfe7d1ab7e9233dd7535f6ffe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52b2b4f48c08972164d73c9ffff6276c242a3baa5a139c32076c698e37232867bb15b4d54bb28333bdd0531e413cb3226be46b03f3445bbf102e40dd6e1878c3
|
|
7
|
+
data.tar.gz: 2bd861d11c1342bdb0ffec8865f3a2e04665a7883f1bccd9886fd53a5e93936af658b276015592c599497029b133f33ba31a45e9244238e5a1849e7cc41967c9
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **The "at" time stamp — `at_time_tag` + `studio/at_time_script`.** A shared
|
|
10
|
+
primitive for stamping WHEN something happened, on the READER's clock rather
|
|
11
|
+
than the app's: `at 3:53p`, gaining a date only when the stamp is not today and
|
|
12
|
+
the year only when it differs, with a country flag trailing the clock when the
|
|
13
|
+
reader's timezone is outside the US. The relative phrase moves to the hover
|
|
14
|
+
title. The server renders the app-timezone form as a no-JS fallback and never
|
|
15
|
+
renders a flag — it cannot know where the reader is sitting, so only the
|
|
16
|
+
reader's machine may assert one. Hosts render `studio/at_time_script` once,
|
|
17
|
+
near the end of the layout body; omitting it degrades to app-timezone stamps
|
|
18
|
+
rather than breaking. Specimen: `/admin/style` → Tricks → Time stamps; recipe
|
|
19
|
+
in the README's UI Primitives section.
|
|
20
|
+
|
|
21
|
+
**Banner aspect ratio is now per-entry.** The new artwork is 3:1 while the shared
|
|
22
|
+
default is 2:1, and turf-monster's eight banners are 2:1. A single global constant
|
|
23
|
+
would letterbox one app's artwork or crop the other's on every upload, so:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
Studio::EmailCatalog.register("winnings",
|
|
27
|
+
default_asset: "emails/winnings.jpg",
|
|
28
|
+
aspect_ratio: 2.0) # omit it and you keep the shared default
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The page draws each row's thumbnail in that shape, and the upload cropper
|
|
32
|
+
enforces it, so a replacement lands in the same frame as the artwork it replaces.
|
|
33
|
+
`Studio::EmailCatalog.ratio(key)` reads it; `ASPECT_RATIO` remains the fallback.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
**The two standard emails now ship Mr. McRitchie's real artwork** — "Your Magic
|
|
38
|
+
Link" and "Confirm Email" — replacing the placeholder gradients. Animated, 1800x600,
|
|
39
|
+
with the McRitchie Studio logo. Every app that has not uploaded its own banner
|
|
40
|
+
inherits them.
|
|
41
|
+
|
|
42
|
+
Shipped **animated at full size on purpose**, with the trade measured rather than
|
|
43
|
+
assumed: a static first frame would have been 24 KB against 3.5 MB, and
|
|
44
|
+
re-encoding smaller made the files LARGER (GIF re-dithers the smooth gradient on
|
|
45
|
+
every frame, so colour reduction and frame-dropping both backfired). Outlook
|
|
46
|
+
desktop renders frame one only — frame one is a complete, legible banner.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
**The emails page named the wrong owner for an app's own artwork.**
|
|
51
|
+
`/admin/emails` described ANY registered banner as *"Shared Studio artwork,
|
|
52
|
+
shipped with the engine"*. turf-monster registers its own eight committed
|
|
53
|
+
banners, so its page announced its own artwork as the engine's — the same
|
|
54
|
+
wrong-provenance failure this page exists to end (the page it replaced claimed
|
|
55
|
+
"No image yet" about an email that was visibly sending one).
|
|
56
|
+
|
|
57
|
+
`Studio::EmailCatalog.source` now answers with four states instead of three:
|
|
58
|
+
|
|
59
|
+
| State | Means |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `:app` | uploaded on this app's /admin/emails — revertible |
|
|
62
|
+
| `:app_asset` | registered by this app, committed in its own repo |
|
|
63
|
+
| `:engine_default` | the shared artwork that ships in the gem |
|
|
64
|
+
| `:none` | no banner; the email sends without one |
|
|
65
|
+
|
|
66
|
+
`:default` is gone; it was the value covering the middle two together.
|
|
67
|
+
**`:app` is deliberately unchanged** — turf-monster's suite asserts it on `main`
|
|
68
|
+
and consumer CI runs consumers' default branch.
|
|
69
|
+
|
|
70
|
+
Origin is recorded at **registration**, not inferred later: by the time the page
|
|
71
|
+
asks, a resolved asset path looks identical whether the file came from the gem
|
|
72
|
+
or from the host. The engine seeds its own two as engine-owned; passing
|
|
73
|
+
`default_asset:` makes that artwork the host's. Relabelling an inherited email
|
|
74
|
+
does NOT claim its picture, and overriding its asset DOES.
|
|
75
|
+
|
|
76
|
+
Also new: `Studio::EmailCatalog.app_artwork?(key)` — true when the live banner
|
|
77
|
+
belongs to this app either way. The page's summary line counts it, so an app
|
|
78
|
+
whose banners all ship from its own repo no longer reports "all inheriting the
|
|
79
|
+
default artwork".
|
|
80
|
+
|
|
5
81
|
## 0.39.0 — 2026-08-11
|
|
6
82
|
|
|
7
83
|
### Removed
|
data/README.md
CHANGED
|
@@ -155,6 +155,35 @@ privileges while impersonating.
|
|
|
155
155
|
|
|
156
156
|
## UI Primitives
|
|
157
157
|
|
|
158
|
+
### The "at" time stamp — `at_time_tag`
|
|
159
|
+
|
|
160
|
+
Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
|
|
161
|
+
date only when the stamp is not today and the year only when it differs. A
|
|
162
|
+
country flag trails the clock when the reader's timezone is outside the US, and
|
|
163
|
+
inside the US there is no flag at all — it carries signal only because it is
|
|
164
|
+
unusual. The relative phrase ("7 minutes ago") moves to the hover title.
|
|
165
|
+
|
|
166
|
+
Render the re-stamper **once per page, near the end of the layout body**, then
|
|
167
|
+
use the helper anywhere:
|
|
168
|
+
|
|
169
|
+
```erb
|
|
170
|
+
<%# near the end of <body>, once %>
|
|
171
|
+
<%= render "studio/at_time_script" %>
|
|
172
|
+
|
|
173
|
+
<%# anywhere %>
|
|
174
|
+
<%= at_time_tag(release.shipped_at) %>
|
|
175
|
+
<%= at_time_tag(task.created_at, prefix: nil) %>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Near the END of the body matters.** The script's first pass runs synchronously
|
|
179
|
+
as it parses, so rendering it in `head` finds zero stamps on that pass and leaves
|
|
180
|
+
them until the next one.
|
|
181
|
+
|
|
182
|
+
The server renders the app-timezone form as a no-JS fallback and never renders a
|
|
183
|
+
flag — it cannot know where the reader is sitting, so only the reader's machine
|
|
184
|
+
may assert one. A host that omits the script still gets working stamps, just
|
|
185
|
+
frozen in the app's timezone. Specimen: `/admin/style` → Tricks → Time stamps.
|
|
186
|
+
|
|
158
187
|
### Smooth-load header pin — `.vt-pinned-header`
|
|
159
188
|
|
|
160
189
|
When `Studio.smooth_load` is on, put `vt-pinned-header` on the app's sticky
|
|
@@ -376,11 +405,18 @@ relabeling `magic_link` does not reorder the page or drop its default artwork.
|
|
|
376
405
|
|
|
377
406
|
```
|
|
378
407
|
Studio::EmailCatalog.resolved_url(:magic_link)
|
|
379
|
-
1. this app's ImageCache row (its own S3 bucket) ->
|
|
380
|
-
2. the
|
|
408
|
+
1. this app's ImageCache row (its own S3 bucket) -> uploaded here
|
|
409
|
+
2. the registered default_asset -> a committed file
|
|
381
410
|
3. nil -> sends bannerless
|
|
382
411
|
```
|
|
383
412
|
|
|
413
|
+
`source(key)` says WHOSE the live banner is — `:app` (uploaded here, revertible),
|
|
414
|
+
`:app_asset` (registered by this app, committed in its repo), `:engine_default`
|
|
415
|
+
(the shared artwork in the gem), or `:none`. The origin is recorded when the
|
|
416
|
+
email is registered, because a resolved asset path looks identical either way by
|
|
417
|
+
the time the page asks. Passing `default_asset:` makes that artwork the host's;
|
|
418
|
+
relabelling an inherited email leaves it the engine's.
|
|
419
|
+
|
|
384
420
|
Note the method: **`resolved_url` walks all three layers; `url` returns only
|
|
385
421
|
layer 1** (this app's own image, or nil). That split is deliberate — it keeps
|
|
386
422
|
every caller written before the registry behaving exactly as it did. A mailer
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The "at" format — the shared primitive for stamping WHEN something happened.
|
|
5
|
+
#
|
|
6
|
+
# A relative stamp ("Shipped less than a minute ago") answers *how long ago* and
|
|
7
|
+
# then stops. It never tells the reader what the clock said, so it cannot be
|
|
8
|
+
# placed against the rest of a day. The "at" format answers the other question,
|
|
9
|
+
# and it answers it in the reader's own time:
|
|
10
|
+
#
|
|
11
|
+
# · The clock is 12-hour with a single-letter meridiem — "3:53p".
|
|
12
|
+
# · The date appears ONLY when the stamp is not today. Something that happened
|
|
13
|
+
# minutes ago needs no date; last week's is ambiguous without it. The year
|
|
14
|
+
# joins only when it differs, so "Aug 10, 3:53p" stays short all year.
|
|
15
|
+
# · A country flag TRAILS the clock when the reader's timezone sits outside the
|
|
16
|
+
# US. Inside the US there is no flag at all — the flag carries signal only
|
|
17
|
+
# because it is unusual, and one that fired on every stamp would carry none.
|
|
18
|
+
# · The relative phrase is not thrown away; it moves to the hover title, beside
|
|
19
|
+
# the full local stamp and its zone name.
|
|
20
|
+
#
|
|
21
|
+
# WHO OWNS WHAT. The server renders the app-timezone form as the pre-hydration
|
|
22
|
+
# and no-JS fallback, and never renders a flag — it cannot know where the reader
|
|
23
|
+
# is sitting. `studio/_at_time_script` re-stamps the clock to the VIEWER's local
|
|
24
|
+
# time and adds the flag from the browser's IANA zone. That split is the whole
|
|
25
|
+
# design: the flag is a fact about the reader, so only the reader's machine may
|
|
26
|
+
# assert it.
|
|
27
|
+
#
|
|
28
|
+
# HOSTS: render `studio/at_time_script` ONCE per page, near the END of the layout
|
|
29
|
+
# body (its first pass runs synchronously, so rendering it in head finds zero
|
|
30
|
+
# stamps), then use `at_time_tag` anywhere. Recipe in the README's UI Primitives
|
|
31
|
+
# section. Without the script the stamps still render — in the app's timezone,
|
|
32
|
+
# with no flag — so a host that forgets it degrades to the old behavior rather
|
|
33
|
+
# than breaking. Specimen: /admin/style → Tricks → Time stamps.
|
|
34
|
+
module AtTimeHelper
|
|
35
|
+
MONTH_DAY = "%b %-d"
|
|
36
|
+
|
|
37
|
+
# "3:53p" / "11:07a" — 12-hour clock, no leading zero, single-letter meridiem,
|
|
38
|
+
# no space. Zones the time into the app zone first, so a bare UTC timestamp and
|
|
39
|
+
# an already-zoned one render the same. nil-safe.
|
|
40
|
+
def at_clock(time)
|
|
41
|
+
return nil if time.blank?
|
|
42
|
+
|
|
43
|
+
local = time.in_time_zone
|
|
44
|
+
"#{local.strftime('%-l:%M')}#{local.hour < 12 ? 'a' : 'p'}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The date half of an "at" stamp, or nil when the stamp falls on `now`'s date —
|
|
48
|
+
# today's stamps carry no date. "Aug 10" within the current year, "Aug 10 2025"
|
|
49
|
+
# outside it. `now` is injectable so the boundary is testable.
|
|
50
|
+
def at_date(time, now: Time.current)
|
|
51
|
+
return nil if time.blank?
|
|
52
|
+
|
|
53
|
+
local = time.in_time_zone
|
|
54
|
+
today = now.in_time_zone
|
|
55
|
+
return nil if local.to_date == today.to_date
|
|
56
|
+
|
|
57
|
+
return local.strftime(MONTH_DAY) if local.year == today.year
|
|
58
|
+
|
|
59
|
+
"#{local.strftime(MONTH_DAY)} #{local.year}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The visible text of an "at" stamp: "3:53p" today, "Aug 10, 3:53p" otherwise.
|
|
63
|
+
# The JS half builds the identical string from the viewer's clock — change one
|
|
64
|
+
# and change the other, or the value flickers on hydration.
|
|
65
|
+
def at_stamp_text(time, now: Time.current)
|
|
66
|
+
return nil if time.blank?
|
|
67
|
+
|
|
68
|
+
date = at_date(time, now: now)
|
|
69
|
+
date ? "#{date}, #{at_clock(time)}" : at_clock(time)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The hover title: the relative phrase this format replaced, then the full local
|
|
73
|
+
# stamp with its zone. Server-side that zone is the app's; the script rewrites
|
|
74
|
+
# the whole title in the viewer's.
|
|
75
|
+
#
|
|
76
|
+
# `now` is measured against, not decoration: time_ago_in_words reads the real
|
|
77
|
+
# clock, so a caller that injected `now` used to get a title from a DIFFERENT
|
|
78
|
+
# moment than the label right beside it.
|
|
79
|
+
def at_stamp_title(time, now: Time.current)
|
|
80
|
+
return nil if time.blank?
|
|
81
|
+
|
|
82
|
+
local = time.in_time_zone
|
|
83
|
+
"#{distance_of_time_in_words(local, now)} ago · #{local.strftime('%a, %b %-d, %Y, %-l:%M %p %Z')}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The primitive itself. Renders a <time> carrying the epoch the script re-stamps
|
|
87
|
+
# from, the text slot, and the (server-side empty) flag slot that trails it.
|
|
88
|
+
#
|
|
89
|
+
# at_time_tag(release.shipped_at) => "at 3:53p"
|
|
90
|
+
# at_time_tag(task.created_at, prefix: nil) => "3:53p"
|
|
91
|
+
#
|
|
92
|
+
# nil for a blank time, so a caller can `<%= at_time_tag(t) %>` unguarded.
|
|
93
|
+
def at_time_tag(time, prefix: "at", now: Time.current, css_class: nil)
|
|
94
|
+
return nil if time.blank?
|
|
95
|
+
|
|
96
|
+
# One reading of "no prefix" for BOTH halves. `.presence` folds nil, "" and
|
|
97
|
+
# false to nil, and the data attribute is then omitted rather than carrying a
|
|
98
|
+
# stringified value — `prefix: false` used to render bare server-side and
|
|
99
|
+
# hydrate to "false 3:53p", because the client trusts the attribute it is given.
|
|
100
|
+
prefix = prefix.presence
|
|
101
|
+
local = time.in_time_zone
|
|
102
|
+
text = at_stamp_text(local, now: now)
|
|
103
|
+
text = "#{prefix} #{text}" if prefix
|
|
104
|
+
|
|
105
|
+
tag.time(datetime: local.iso8601,
|
|
106
|
+
title: at_stamp_title(local, now: now),
|
|
107
|
+
class: ["whitespace-nowrap", css_class].compact.join(" "),
|
|
108
|
+
data: { at_stamp: "", at_epoch: local.to_i, at_prefix: prefix }) do
|
|
109
|
+
# No whitespace between the two slots: the gap is `ml-2` on the flag, which
|
|
110
|
+
# collapses with the flag itself when the reader is inside the US.
|
|
111
|
+
safe_join([
|
|
112
|
+
tag.span(text, data: { at_text: "" }),
|
|
113
|
+
tag.span("", class: "ml-2", hidden: true, data: { at_flag: "" })
|
|
114
|
+
])
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -74,11 +74,25 @@ module Studio
|
|
|
74
74
|
# type — :transactional or :marketing.
|
|
75
75
|
# preview — callable returning a Mail, or nil.
|
|
76
76
|
Entry = Struct.new(:key, :label, :description, :default_asset, :type, :preview,
|
|
77
|
-
keyword_init: true) do
|
|
77
|
+
:default_origin, :aspect_ratio, keyword_init: true) do
|
|
78
78
|
def to_s = key
|
|
79
79
|
def previewable? = preview.respond_to?(:call)
|
|
80
80
|
# nil-safe: an Entry built directly (the STANDARD seed) may carry no type.
|
|
81
81
|
def marketing? = type.to_s == "marketing"
|
|
82
|
+
|
|
83
|
+
# WHOSE artwork default_asset names. Recorded at registration rather than
|
|
84
|
+
# inferred later: by the time the page asks, a resolved asset path looks
|
|
85
|
+
# identical whether the file came from the gem or from the host, and
|
|
86
|
+
# guessing from the path is exactly the wrong-answer machine this exists
|
|
87
|
+
# to remove. The engine seeds its own two as :engine; anything a host
|
|
88
|
+
# passes default_asset for is that host's own.
|
|
89
|
+
def engine_artwork? = default_origin.to_s != "app"
|
|
90
|
+
|
|
91
|
+
# The shape of THIS email's banner — the box the page draws and the ratio
|
|
92
|
+
# the upload cropper enforces. Per-entry because the engine's own artwork
|
|
93
|
+
# is 3:1 while turf-monster's eight banners are 2:1; one global constant
|
|
94
|
+
# would letterbox one app's art and crop the other's on every upload.
|
|
95
|
+
def ratio = (aspect_ratio || ASPECT_RATIO).to_f
|
|
82
96
|
end
|
|
83
97
|
|
|
84
98
|
# The emails EVERY Studio app sends. Pre-registered, so a host inherits both
|
|
@@ -88,13 +102,15 @@ module Studio
|
|
|
88
102
|
key: "magic_link",
|
|
89
103
|
label: "Magic-link sign-in",
|
|
90
104
|
description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
|
|
91
|
-
default_asset: "emails/magic-link.
|
|
105
|
+
default_asset: "emails/magic-link.gif",
|
|
106
|
+
aspect_ratio: 3.0
|
|
92
107
|
},
|
|
93
108
|
{
|
|
94
109
|
key: "email_change_confirmation",
|
|
95
110
|
label: "Email change confirmation",
|
|
96
111
|
description: "Confirms a new address before the change takes effect.",
|
|
97
|
-
default_asset: "emails/email-change-confirmation.
|
|
112
|
+
default_asset: "emails/email-change-confirmation.gif",
|
|
113
|
+
aspect_ratio: 3.0
|
|
98
114
|
}
|
|
99
115
|
].freeze
|
|
100
116
|
|
|
@@ -113,7 +129,8 @@ module Studio
|
|
|
113
129
|
# Every keyword is OPTIONAL and omitting one on a re-register KEEPS the
|
|
114
130
|
# existing value — that is what lets a host relabel an inherited email, or
|
|
115
131
|
# attach a preview builder to it, without restating its artwork.
|
|
116
|
-
def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil
|
|
132
|
+
def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil,
|
|
133
|
+
aspect_ratio: nil)
|
|
117
134
|
key = key.to_s
|
|
118
135
|
existing = registry[key]
|
|
119
136
|
registry[key] = Entry.new(
|
|
@@ -122,7 +139,13 @@ module Studio
|
|
|
122
139
|
description: description || existing&.description,
|
|
123
140
|
default_asset: default_asset.nil? ? existing&.default_asset : default_asset.presence,
|
|
124
141
|
type: normalize_type(type || existing&.type),
|
|
125
|
-
preview: preview || existing&.preview
|
|
142
|
+
preview: preview || existing&.preview,
|
|
143
|
+
# Passing artwork here makes it THIS APP's artwork — that is the only
|
|
144
|
+
# moment anyone can know. Omitting it keeps whatever the entry already
|
|
145
|
+
# had, so a host relabelling an inherited email does not accidentally
|
|
146
|
+
# claim the engine's picture as its own.
|
|
147
|
+
default_origin: default_asset.nil? ? (existing&.default_origin || :engine) : :app,
|
|
148
|
+
aspect_ratio: aspect_ratio || existing&.aspect_ratio
|
|
126
149
|
)
|
|
127
150
|
key
|
|
128
151
|
end
|
|
@@ -177,23 +200,45 @@ module Studio
|
|
|
177
200
|
# symbol, not nil) and every reader can trust the shape.
|
|
178
201
|
def registry
|
|
179
202
|
@registry ||= STANDARD.each_with_object({}) do |attrs, out|
|
|
180
|
-
out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]),
|
|
203
|
+
out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]),
|
|
204
|
+
preview: attrs[:preview], default_origin: :engine,
|
|
205
|
+
aspect_ratio: attrs[:aspect_ratio])
|
|
181
206
|
end
|
|
182
207
|
end
|
|
183
208
|
|
|
184
209
|
# --- Resolution --------------------------------------------------------
|
|
185
210
|
|
|
186
|
-
# Where the live banner for this email comes from:
|
|
187
|
-
#
|
|
188
|
-
# :
|
|
189
|
-
#
|
|
211
|
+
# Where the live banner for this email actually comes from:
|
|
212
|
+
#
|
|
213
|
+
# :app — uploaded on this app's /admin/emails (ImageCache row
|
|
214
|
+
# in this app's bucket). Revertible.
|
|
215
|
+
# :app_asset — registered by this app, committed in its own repo.
|
|
216
|
+
# :engine_default — the shared artwork that ships in the gem.
|
|
217
|
+
# :none — no image at all; the email sends bannerless.
|
|
218
|
+
#
|
|
219
|
+
# `:default` USED to cover the middle two together, and the page said
|
|
220
|
+
# "Shared Studio artwork, shipped with the engine" for both — so
|
|
221
|
+
# turf-monster's own eight banners were announced as the engine's. Telling
|
|
222
|
+
# the operator the wrong provenance is the same failure this page was built
|
|
223
|
+
# to end (the page it replaced claimed "No image yet" about an email that
|
|
224
|
+
# was visibly sending one).
|
|
225
|
+
#
|
|
226
|
+
# `:app` is deliberately unchanged: turf-monster's suite on `main` asserts
|
|
227
|
+
# it, and consumer CI runs consumers' default branch.
|
|
190
228
|
def source(key)
|
|
191
229
|
return :app if record(key)
|
|
192
|
-
return :
|
|
230
|
+
return :none unless default_asset_path(key)
|
|
193
231
|
|
|
194
|
-
:
|
|
232
|
+
entry(key)&.engine_artwork? ? :engine_default : :app_asset
|
|
195
233
|
end
|
|
196
234
|
|
|
235
|
+
# True when the live banner belongs to this app either way — uploaded here
|
|
236
|
+
# or committed here. What the page's summary line counts.
|
|
237
|
+
def app_artwork?(key) = %i[app app_asset].include?(source(key))
|
|
238
|
+
|
|
239
|
+
# This email's banner shape, falling back to the shared default.
|
|
240
|
+
def ratio(key) = entry(key)&.ratio || ASPECT_RATIO
|
|
241
|
+
|
|
197
242
|
def app_owned?(key) = source(key) == :app
|
|
198
243
|
|
|
199
244
|
# --- Preview -----------------------------------------------------------
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<%# The client half of the "at" format (Studio::AtTimeHelper). The server renders
|
|
2
|
+
every [data-at-stamp] in the APP timezone with no flag, because it cannot know
|
|
3
|
+
where the reader is sitting; this re-stamps each one to the VIEWER's local clock
|
|
4
|
+
and appends the country flag when that viewer's IANA zone is outside the US.
|
|
5
|
+
|
|
6
|
+
HOSTS: the adoption recipe lives in the README's UI Primitives section. It is
|
|
7
|
+
deliberately NOT repeated here — the first version of this comment carried the
|
|
8
|
+
render snippet, and an ERB comment ends at the first close-tag whatever it sits
|
|
9
|
+
inside, so the comment terminated mid-sentence and the remaining prose rendered
|
|
10
|
+
as page text. That leaked prose contained a script open tag, which swallowed the
|
|
11
|
+
real one: the re-stamper never executed, on every page of every host that
|
|
12
|
+
followed the recipe. Keep this comment free of close-tag characters and of tag
|
|
13
|
+
names spelled inside angle brackets.
|
|
14
|
+
|
|
15
|
+
Render it once per page, near the end of the layout body. Near the END matters:
|
|
16
|
+
the first pass runs synchronously as this script parses, so a host that renders
|
|
17
|
+
it in head finds zero stamps on that pass and waits for the next one. Cloning it
|
|
18
|
+
into an Alpine template or a morphed card would re-run it or never run it. It is
|
|
19
|
+
self-guarded, so a second render is a no-op.
|
|
20
|
+
|
|
21
|
+
It re-queries the DOM on every pass — on load, after Turbo navigations and
|
|
22
|
+
stream renders, and on a slow interval — so a card swapped in by a broadcast is
|
|
23
|
+
picked up with no re-wiring, and the relative phrase in the hover title never
|
|
24
|
+
goes stale in a tab left open.
|
|
25
|
+
|
|
26
|
+
clock() and dateLabel() MUST render the same strings as
|
|
27
|
+
Studio::AtTimeHelper#at_clock and #at_date, or the value visibly flickers when
|
|
28
|
+
hydration overwrites the server's fallback. %>
|
|
29
|
+
<script>
|
|
30
|
+
(function () {
|
|
31
|
+
if (window.__atTimeFmt) return;
|
|
32
|
+
window.__atTimeFmt = true;
|
|
33
|
+
|
|
34
|
+
// Zones inside the United States — the 50 states, DC, and the territories.
|
|
35
|
+
// Membership here means NO FLAG: the flag is a signal, and one that fired on
|
|
36
|
+
// every stamp would carry none.
|
|
37
|
+
//
|
|
38
|
+
// BOTH SPELLINGS OF INDIANA AND KENTUCKY ARE MEMBERS, AND THAT IS NOT
|
|
39
|
+
// REDUNDANCY. A browser does not hand back the tzdb id it was given — it hands
|
|
40
|
+
// back the CLDR-canonical one, and the engines DISAGREE about which that is:
|
|
41
|
+
// Chromium and WebKit report America/Indianapolis and America/Louisville, while
|
|
42
|
+
// Firefox reports America/Indiana/Indianapolis and America/Kentucky/Louisville.
|
|
43
|
+
// The short forms match no US_PREFIXES entry and carry only two path segments,
|
|
44
|
+
// so the parent fallback in zoneFlag() never fires for them either — which is
|
|
45
|
+
// how Chrome and Safari readers in Indianapolis and Louisville were shown the
|
|
46
|
+
// Americas globe while Firefox readers beside them saw the correct bare clock.
|
|
47
|
+
// Delete either spelling and one engine's readers break. (Reached through
|
|
48
|
+
// US/East-Indiana and America/Fort_Wayne too; both canonicalize to
|
|
49
|
+
// America/Indianapolis.)
|
|
50
|
+
var US_ZONES = ("America/New_York America/Detroit America/Chicago America/Menominee " +
|
|
51
|
+
"America/Denver America/Boise America/Phoenix America/Los_Angeles America/Anchorage " +
|
|
52
|
+
"America/Juneau America/Sitka America/Metlakatla America/Yakutat America/Nome " +
|
|
53
|
+
"America/Adak Pacific/Honolulu America/Puerto_Rico America/St_Thomas Pacific/Guam " +
|
|
54
|
+
"Pacific/Saipan Pacific/Pago_Pago Pacific/Midway " +
|
|
55
|
+
"America/Indianapolis America/Louisville").split(" ");
|
|
56
|
+
|
|
57
|
+
// Zone families that resolve to the US without listing every member. The
|
|
58
|
+
// Indiana/Kentucky entries are what Firefox needs; the short aliases above are
|
|
59
|
+
// what Chromium and WebKit need. `US/` matches nothing in any engine shipping
|
|
60
|
+
// today (every US/* id canonicalizes away before it is reported) — it is kept
|
|
61
|
+
// as a cheap floor for an engine that does not canonicalize, not because it is
|
|
62
|
+
// known to fire.
|
|
63
|
+
var US_PREFIXES = ["US/", "America/Indiana/", "America/Kentucky/", "America/North_Dakota/"];
|
|
64
|
+
|
|
65
|
+
// IANA zone → ISO 3166-1 alpha-2, grouped by country so the table reads. Not
|
|
66
|
+
// exhaustive by design: an unlisted zone outside the US falls back to a
|
|
67
|
+
// hemisphere globe rather than guessing a country wrong (see zoneFlag).
|
|
68
|
+
var COUNTRY_ZONES = {
|
|
69
|
+
GB: "Europe/London", IE: "Europe/Dublin", FR: "Europe/Paris", ES: "Europe/Madrid",
|
|
70
|
+
PT: "Europe/Lisbon Atlantic/Azores Atlantic/Madeira", DE: "Europe/Berlin Europe/Busingen",
|
|
71
|
+
IT: "Europe/Rome", NL: "Europe/Amsterdam", BE: "Europe/Brussels", LU: "Europe/Luxembourg",
|
|
72
|
+
CH: "Europe/Zurich", AT: "Europe/Vienna", DK: "Europe/Copenhagen", NO: "Europe/Oslo",
|
|
73
|
+
SE: "Europe/Stockholm", FI: "Europe/Helsinki", IS: "Atlantic/Reykjavik", PL: "Europe/Warsaw",
|
|
74
|
+
CZ: "Europe/Prague", SK: "Europe/Bratislava", HU: "Europe/Budapest", RO: "Europe/Bucharest",
|
|
75
|
+
BG: "Europe/Sofia", GR: "Europe/Athens", TR: "Europe/Istanbul", UA: "Europe/Kyiv Europe/Kiev",
|
|
76
|
+
RU: "Europe/Moscow Europe/Kaliningrad Asia/Yekaterinburg Asia/Novosibirsk Asia/Vladivostok",
|
|
77
|
+
HR: "Europe/Zagreb", RS: "Europe/Belgrade", SI: "Europe/Ljubljana", EE: "Europe/Tallinn",
|
|
78
|
+
LV: "Europe/Riga", LT: "Europe/Vilnius", MT: "Europe/Malta", CY: "Asia/Nicosia",
|
|
79
|
+
BY: "Europe/Minsk", MD: "Europe/Chisinau", AL: "Europe/Tirane", BA: "Europe/Sarajevo",
|
|
80
|
+
MK: "Europe/Skopje", ME: "Europe/Podgorica", MC: "Europe/Monaco", AD: "Europe/Andorra",
|
|
81
|
+
GI: "Europe/Gibraltar",
|
|
82
|
+
|
|
83
|
+
CA: "America/Toronto America/Vancouver America/Edmonton America/Winnipeg America/Halifax " +
|
|
84
|
+
"America/St_Johns America/Regina America/Montreal",
|
|
85
|
+
MX: "America/Mexico_City America/Tijuana America/Monterrey America/Cancun " +
|
|
86
|
+
"America/Chihuahua America/Mazatlan",
|
|
87
|
+
BR: "America/Sao_Paulo America/Bahia America/Fortaleza America/Recife America/Manaus " +
|
|
88
|
+
"America/Belem",
|
|
89
|
+
AR: "America/Argentina America/Buenos_Aires", CL: "America/Santiago", CO: "America/Bogota",
|
|
90
|
+
PE: "America/Lima", VE: "America/Caracas", EC: "America/Guayaquil", UY: "America/Montevideo",
|
|
91
|
+
PY: "America/Asuncion", BO: "America/La_Paz", CR: "America/Costa_Rica", PA: "America/Panama",
|
|
92
|
+
GT: "America/Guatemala", SV: "America/El_Salvador", HN: "America/Tegucigalpa",
|
|
93
|
+
NI: "America/Managua", BZ: "America/Belize", CU: "America/Havana", DO: "America/Santo_Domingo",
|
|
94
|
+
JM: "America/Jamaica", HT: "America/Port-au-Prince", BS: "America/Nassau",
|
|
95
|
+
BB: "America/Barbados", TT: "America/Port_of_Spain", BM: "Atlantic/Bermuda",
|
|
96
|
+
|
|
97
|
+
JP: "Asia/Tokyo", CN: "Asia/Shanghai Asia/Chongqing Asia/Urumqi", HK: "Asia/Hong_Kong",
|
|
98
|
+
MO: "Asia/Macau", TW: "Asia/Taipei", KR: "Asia/Seoul", KP: "Asia/Pyongyang",
|
|
99
|
+
SG: "Asia/Singapore", MY: "Asia/Kuala_Lumpur Asia/Kuching",
|
|
100
|
+
ID: "Asia/Jakarta Asia/Makassar Asia/Jayapura", TH: "Asia/Bangkok",
|
|
101
|
+
VN: "Asia/Ho_Chi_Minh Asia/Saigon", PH: "Asia/Manila", IN: "Asia/Kolkata Asia/Calcutta",
|
|
102
|
+
PK: "Asia/Karachi", BD: "Asia/Dhaka", LK: "Asia/Colombo", NP: "Asia/Kathmandu",
|
|
103
|
+
MM: "Asia/Yangon", KH: "Asia/Phnom_Penh", LA: "Asia/Vientiane", MN: "Asia/Ulaanbaatar",
|
|
104
|
+
KZ: "Asia/Almaty", UZ: "Asia/Tashkent", AZ: "Asia/Baku", GE: "Asia/Tbilisi",
|
|
105
|
+
AM: "Asia/Yerevan", IL: "Asia/Jerusalem Asia/Tel_Aviv", PS: "Asia/Gaza Asia/Hebron",
|
|
106
|
+
LB: "Asia/Beirut", JO: "Asia/Amman", SY: "Asia/Damascus", IQ: "Asia/Baghdad",
|
|
107
|
+
IR: "Asia/Tehran", SA: "Asia/Riyadh", AE: "Asia/Dubai", QA: "Asia/Qatar", KW: "Asia/Kuwait",
|
|
108
|
+
BH: "Asia/Bahrain", OM: "Asia/Muscat", YE: "Asia/Aden", AF: "Asia/Kabul",
|
|
109
|
+
|
|
110
|
+
EG: "Africa/Cairo", MA: "Africa/Casablanca", DZ: "Africa/Algiers", TN: "Africa/Tunis",
|
|
111
|
+
LY: "Africa/Tripoli", NG: "Africa/Lagos", GH: "Africa/Accra", CI: "Africa/Abidjan",
|
|
112
|
+
SN: "Africa/Dakar", KE: "Africa/Nairobi", ET: "Africa/Addis_Ababa",
|
|
113
|
+
TZ: "Africa/Dar_es_Salaam", UG: "Africa/Kampala", ZA: "Africa/Johannesburg",
|
|
114
|
+
NA: "Africa/Windhoek", ZW: "Africa/Harare", ZM: "Africa/Lusaka", MZ: "Africa/Maputo",
|
|
115
|
+
AO: "Africa/Luanda", CD: "Africa/Kinshasa Africa/Lubumbashi", CM: "Africa/Douala",
|
|
116
|
+
RW: "Africa/Kigali", MU: "Indian/Mauritius", MG: "Indian/Antananarivo",
|
|
117
|
+
|
|
118
|
+
AU: "Australia/Sydney Australia/Melbourne Australia/Brisbane Australia/Perth " +
|
|
119
|
+
"Australia/Adelaide Australia/Darwin Australia/Hobart Australia/Canberra",
|
|
120
|
+
NZ: "Pacific/Auckland", FJ: "Pacific/Fiji", PG: "Pacific/Port_Moresby",
|
|
121
|
+
NC: "Pacific/Noumea", PF: "Pacific/Tahiti", TO: "Pacific/Tongatapu", WS: "Pacific/Apia",
|
|
122
|
+
VU: "Pacific/Efate", SB: "Pacific/Guadalcanal"
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
var ZONE_COUNTRY = {};
|
|
126
|
+
Object.keys(COUNTRY_ZONES).forEach(function (cc) {
|
|
127
|
+
COUNTRY_ZONES[cc].split(" ").forEach(function (zone) { ZONE_COUNTRY[zone] = cc; });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// A flag emoji is just its country code in regional-indicator code points, so
|
|
131
|
+
// the table above only has to carry two letters per country.
|
|
132
|
+
function flagFor(cc) {
|
|
133
|
+
return cc.split("").map(function (ch) {
|
|
134
|
+
return String.fromCodePoint(127397 + ch.charCodeAt(0));
|
|
135
|
+
}).join("");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isUS(zone) {
|
|
139
|
+
if (US_ZONES.indexOf(zone) !== -1) return true;
|
|
140
|
+
return US_PREFIXES.some(function (prefix) { return zone.indexOf(prefix) === 0; });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function zoneFlag(zone) {
|
|
144
|
+
if (!zone || isUS(zone)) return "";
|
|
145
|
+
// A machine pinned to UTC/GMT is in no country; say "somewhere else" rather
|
|
146
|
+
// than invent one.
|
|
147
|
+
if (zone === "UTC" || zone === "GMT" || zone.indexOf("Etc/") === 0) return "🌐";
|
|
148
|
+
|
|
149
|
+
var cc = ZONE_COUNTRY[zone];
|
|
150
|
+
if (!cc) {
|
|
151
|
+
// Sub-zone families (America/Argentina/Buenos_Aires) resolve on their
|
|
152
|
+
// parent before giving up.
|
|
153
|
+
var parts = zone.split("/");
|
|
154
|
+
if (parts.length > 2) cc = ZONE_COUNTRY[parts[0] + "/" + parts[1]];
|
|
155
|
+
}
|
|
156
|
+
if (cc) return flagFor(cc);
|
|
157
|
+
|
|
158
|
+
var region = zone.split("/")[0];
|
|
159
|
+
if (region === "Europe" || region === "Africa" || region === "Atlantic") return "🌍";
|
|
160
|
+
if (region === "America") return "🌎";
|
|
161
|
+
return "🌏";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
165
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
166
|
+
|
|
167
|
+
// Mirrors AtTimeHelper#at_clock.
|
|
168
|
+
function clock(d) {
|
|
169
|
+
var h = d.getHours(), m = d.getMinutes(), ap = h < 12 ? "a" : "p";
|
|
170
|
+
h = h % 12; if (h === 0) h = 12;
|
|
171
|
+
return h + ":" + String(m).padStart(2, "0") + ap;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Mirrors AtTimeHelper#at_date — null on today, the year only when it differs.
|
|
175
|
+
function dateLabel(d, now) {
|
|
176
|
+
if (d.toDateString() === now.toDateString()) return null;
|
|
177
|
+
var label = MONTHS[d.getMonth()] + " " + d.getDate();
|
|
178
|
+
return d.getFullYear() === now.getFullYear() ? label : label + " " + d.getFullYear();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Mirrors time_ago_in_words closely enough that hydration does not visibly
|
|
182
|
+
// rewrite the hover title. It must keep climbing past months into YEARS:
|
|
183
|
+
// time_ago_in_words does, so capping here made a year-old stamp read "about 12
|
|
184
|
+
// months ago" on the server and something else after hydration — visible on the
|
|
185
|
+
// style guide's own 1.year.ago specimen.
|
|
186
|
+
function relative(secs) {
|
|
187
|
+
if (secs < 60) return "less than a minute ago";
|
|
188
|
+
var m = Math.floor(secs / 60);
|
|
189
|
+
if (m < 60) return m === 1 ? "1 minute ago" : m + " minutes ago";
|
|
190
|
+
var h = Math.floor(m / 60);
|
|
191
|
+
if (h < 24) return h === 1 ? "about 1 hour ago" : "about " + h + " hours ago";
|
|
192
|
+
var days = Math.floor(h / 24);
|
|
193
|
+
if (days < 30) return days === 1 ? "1 day ago" : days + " days ago";
|
|
194
|
+
var months = Math.floor(days / 30);
|
|
195
|
+
if (months < 12) return months === 1 ? "about 1 month ago" : "about " + months + " months ago";
|
|
196
|
+
var years = Math.floor(days / 365);
|
|
197
|
+
return years === 1 ? "about 1 year ago" : "about " + years + " years ago";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function fullStamp(d) {
|
|
201
|
+
return d.toLocaleString([], {
|
|
202
|
+
weekday: "short", month: "short", day: "numeric", year: "numeric",
|
|
203
|
+
hour: "numeric", minute: "2-digit", timeZoneName: "short"
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function viewerZone() {
|
|
208
|
+
try {
|
|
209
|
+
return (Intl.DateTimeFormat().resolvedOptions().timeZone) || "";
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stamp() {
|
|
216
|
+
var now = new Date();
|
|
217
|
+
var zone = viewerZone();
|
|
218
|
+
var flag = zoneFlag(zone);
|
|
219
|
+
|
|
220
|
+
document.querySelectorAll("[data-at-stamp]").forEach(function (el) {
|
|
221
|
+
var epoch = parseInt(el.dataset.atEpoch || "0", 10);
|
|
222
|
+
if (!epoch) return;
|
|
223
|
+
var d = new Date(epoch * 1000);
|
|
224
|
+
|
|
225
|
+
var textEl = el.querySelector("[data-at-text]");
|
|
226
|
+
if (textEl) {
|
|
227
|
+
var date = dateLabel(d, now);
|
|
228
|
+
var label = date ? date + ", " + clock(d) : clock(d);
|
|
229
|
+
var prefix = el.dataset.atPrefix;
|
|
230
|
+
textEl.textContent = prefix ? prefix + " " + label : label;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
var flagEl = el.querySelector("[data-at-flag]");
|
|
234
|
+
if (flagEl) {
|
|
235
|
+
flagEl.textContent = flag;
|
|
236
|
+
flagEl.hidden = !flag;
|
|
237
|
+
if (flag) flagEl.title = zone;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
el.title = relative(Math.max(0, Math.floor((now - d) / 1000))) + " · " + fullStamp(d);
|
|
241
|
+
el.dataset.atZone = zone;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
stamp();
|
|
246
|
+
document.addEventListener("turbo:load", stamp);
|
|
247
|
+
document.addEventListener("turbo:render", stamp);
|
|
248
|
+
// A stream render swaps nodes in AFTER the event fires, so re-stamp on the
|
|
249
|
+
// next tick rather than against the outgoing DOM.
|
|
250
|
+
document.addEventListener("turbo:before-stream-render", function () { setTimeout(stamp, 0); });
|
|
251
|
+
setInterval(stamp, 30000);
|
|
252
|
+
})();
|
|
253
|
+
</script>
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
sending a banner from a committed repo asset. Even on its way out it
|
|
39
39
|
should tell the truth about what ships. %>
|
|
40
40
|
<% current_url = Studio::EmailCatalog.preview_url(variant) %>
|
|
41
|
-
|
|
41
|
+
<%# NOT `== :default` — that value split into :app_asset / :engine_default.
|
|
42
|
+
Comparing against a value nothing returns would silently never show
|
|
43
|
+
this note again. Anything that is not an upload is still inherited
|
|
44
|
+
from a file, which is all this retiring page needs to say. %>
|
|
45
|
+
<% inherited = current_url.present? && Studio::EmailCatalog.source(variant) != :app %>
|
|
42
46
|
<section class="rounded-xl border border-subtle p-5">
|
|
43
47
|
<h2 class="font-semibold mb-3"><%= label %></h2>
|
|
44
48
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (entry:, uploads_available:,
|
|
1
|
+
<%# locals: (entry:, uploads_available:, max_width:)
|
|
2
2
|
One registered email: its live banner, its name, and where that banner comes
|
|
3
3
|
from. The whole row is the imageUploadHost x-data so the thumbnail, the Edit
|
|
4
4
|
button, and the hidden multipart form the cropper submits all sit together —
|
|
@@ -11,14 +11,21 @@
|
|
|
11
11
|
|
|
12
12
|
# `badge` is a shape-only utility in engine.css — the state color comes from
|
|
13
13
|
# theme-token utilities so it follows each app's palette in light and dark.
|
|
14
|
+
#
|
|
15
|
+
# Four states, because three of them used to read as one. An app that ships
|
|
16
|
+
# its own committed artwork was told that artwork "ships with the engine",
|
|
17
|
+
# which is the wrong answer to the only question this column exists to answer.
|
|
14
18
|
badge_class, badge_label, badge_note =
|
|
15
19
|
case source
|
|
16
20
|
when :app
|
|
17
|
-
["badge bg-success/10 text-success border-success/30", "
|
|
18
|
-
"
|
|
19
|
-
when :
|
|
20
|
-
["badge bg-
|
|
21
|
-
"
|
|
21
|
+
["badge bg-success/10 text-success border-success/30", "Uploaded here",
|
|
22
|
+
"Stored for #{Studio.app_name} — this upload is what ships."]
|
|
23
|
+
when :app_asset
|
|
24
|
+
["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork",
|
|
25
|
+
"Committed in this app's own assets. Upload to replace it."]
|
|
26
|
+
when :engine_default
|
|
27
|
+
["badge bg-inset text-muted border-subtle", "Studio default",
|
|
28
|
+
"Shared artwork that ships with the engine. Upload to make it #{Studio.app_name}'s."]
|
|
22
29
|
else
|
|
23
30
|
["badge bg-warning/10 text-warning border-warning/30", "No image",
|
|
24
31
|
"This email sends without a banner."]
|
|
@@ -32,7 +39,7 @@
|
|
|
32
39
|
<% if uploads_available %>
|
|
33
40
|
x-data="imageUploadHost({
|
|
34
41
|
store: 'emailModals',
|
|
35
|
-
aspectRatio: <%=
|
|
42
|
+
aspectRatio: <%= entry.ratio %>,
|
|
36
43
|
maxWidth: <%= max_width %>,
|
|
37
44
|
transparent: false,
|
|
38
45
|
filename: '<%= entry.key %>.png',
|
|
@@ -46,7 +53,7 @@
|
|
|
46
53
|
|
|
47
54
|
<td class="px-4 py-4">
|
|
48
55
|
<div class="rounded-lg overflow-hidden border border-subtle w-40"
|
|
49
|
-
style="aspect-ratio: <%=
|
|
56
|
+
style="aspect-ratio: <%= entry.ratio %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
|
|
50
57
|
<% if banner_url %>
|
|
51
58
|
<%= image_tag banner_url, class: "w-full h-full object-cover",
|
|
52
59
|
alt: "#{entry.label} email banner", loading: "lazy" %>
|
|
@@ -20,9 +20,12 @@
|
|
|
20
20
|
partial in this non-isolated engine. %>
|
|
21
21
|
<% content_for(:title) { "Emails" } %>
|
|
22
22
|
<%
|
|
23
|
-
aspect = Studio::EmailCatalog::ASPECT_RATIO
|
|
24
23
|
max_width = Studio::EmailCatalog::MAX_WIDTH
|
|
25
|
-
|
|
24
|
+
# Counts the app's OWN artwork either way — uploaded here or committed in this
|
|
25
|
+
# app's assets. Counting only uploads made turf-monster, whose eight banners
|
|
26
|
+
# all ship from its own repo, report "all inheriting the default artwork".
|
|
27
|
+
app_artwork = @entries.count { |entry| Studio::EmailCatalog.app_artwork?(entry.key) }
|
|
28
|
+
bannerless = @entries.count { |entry| Studio::EmailCatalog.source(entry.key) == :none }
|
|
26
29
|
%>
|
|
27
30
|
<div class="max-w-5xl mx-auto px-4 pb-16">
|
|
28
31
|
<header class="space-y-2 pt-8 pb-6">
|
|
@@ -30,12 +33,23 @@
|
|
|
30
33
|
<h1 class="text-3xl font-bold text-heading">Emails</h1>
|
|
31
34
|
<p class="text-body max-w-2xl">
|
|
32
35
|
Every transactional email <%= Studio.app_name %> sends, and the banner that
|
|
33
|
-
rides at the top of it.
|
|
34
|
-
|
|
36
|
+
rides at the top of it. An email starts on the shared Studio artwork, or on
|
|
37
|
+
artwork this app registered itself; upload here and the image belongs to
|
|
38
|
+
<%= Studio.app_name %> from then on.
|
|
35
39
|
</p>
|
|
36
40
|
<p class="text-sm text-muted">
|
|
37
41
|
<%= pluralize(@entries.size, "email") %> registered ·
|
|
38
|
-
|
|
42
|
+
<%
|
|
43
|
+
summary =
|
|
44
|
+
if app_artwork == @entries.size && @entries.any?
|
|
45
|
+
"all on #{Studio.app_name}'s own artwork"
|
|
46
|
+
elsif app_artwork.zero?
|
|
47
|
+
bannerless == @entries.size && @entries.any? ? "none have a banner yet" : "all on the shared Studio artwork"
|
|
48
|
+
else
|
|
49
|
+
"#{app_artwork} on #{Studio.app_name}'s own artwork"
|
|
50
|
+
end
|
|
51
|
+
%>
|
|
52
|
+
<%= summary %>
|
|
39
53
|
</p>
|
|
40
54
|
</header>
|
|
41
55
|
|
|
@@ -77,7 +91,7 @@
|
|
|
77
91
|
<% @entries.each do |entry| %>
|
|
78
92
|
<%= render "studio/emails/row", entry: entry,
|
|
79
93
|
uploads_available: @uploads_available,
|
|
80
|
-
|
|
94
|
+
max_width: max_width %>
|
|
81
95
|
<% end %>
|
|
82
96
|
</tbody>
|
|
83
97
|
</table>
|
|
@@ -86,8 +100,9 @@
|
|
|
86
100
|
|
|
87
101
|
<p class="text-xs text-muted mt-4">
|
|
88
102
|
Banners render full-bleed at 600px wide inside the email card. The crop is
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
saved at up to <%= max_width %>px, which is retina-sharp in an inbox. Each
|
|
104
|
+
email crops to the shape of its own banner, so a replacement lands in the
|
|
105
|
+
same frame as the artwork it is replacing.
|
|
91
106
|
<% if @entries.any? { |entry| Studio::EmailCatalog.source(entry.key) == :none } %>
|
|
92
107
|
An email with no image sends bannerless — the card still renders.
|
|
93
108
|
<% end %>
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
<%
|
|
10
10
|
source = Studio::EmailCatalog.source(@entry.key)
|
|
11
11
|
banner_url = Studio::EmailCatalog.preview_url(@entry.key)
|
|
12
|
-
aspect =
|
|
12
|
+
aspect = @entry.ratio
|
|
13
13
|
|
|
14
14
|
badge_class, badge_label =
|
|
15
15
|
case source
|
|
16
|
-
when :app
|
|
17
|
-
when :
|
|
16
|
+
when :app then ["badge bg-success/10 text-success border-success/30", "Uploaded here"]
|
|
17
|
+
when :app_asset then ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork"]
|
|
18
|
+
when :engine_default then ["badge bg-inset text-muted border-subtle", "Studio default"]
|
|
18
19
|
else ["badge bg-warning/10 text-warning border-warning/30", "No image"]
|
|
19
20
|
end
|
|
20
21
|
%>
|
|
@@ -113,6 +113,72 @@
|
|
|
113
113
|
</div>
|
|
114
114
|
</section>
|
|
115
115
|
|
|
116
|
+
<!-- Time stamps: the "at" format -->
|
|
117
|
+
<section class="space-y-5">
|
|
118
|
+
<div class="space-y-1">
|
|
119
|
+
<h3 class="text-xl font-bold text-heading">Time stamps — the “at” format</h3>
|
|
120
|
+
<p class="text-muted text-sm">
|
|
121
|
+
<code class="font-mono text-2xs">at_time_tag</code> stamps WHEN something happened, on
|
|
122
|
+
the reader’s own clock. The stamps below are live: the server rendered them in this
|
|
123
|
+
app’s timezone and <code class="font-mono text-2xs">studio/at_time_script</code>
|
|
124
|
+
re-stamped them in yours. Hover one for the relative time and your zone.
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
<%# An ERB snippet cannot be written literally inside an ERB template: the
|
|
128
|
+
scanner closes the tag at the first `%` + `>` it sees, whatever Ruby string
|
|
129
|
+
it sits in. So assemble the markers — the raw template never contains one. %>
|
|
130
|
+
<% erb = ->(code) { "<" + "%= " + code + " %" + ">" } %>
|
|
131
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
132
|
+
<%= render layout: "style/specimen",
|
|
133
|
+
locals: { klass: "at_time_tag(time)",
|
|
134
|
+
usage: erb.call("at_time_tag(release.shipped_at)") } do %>
|
|
135
|
+
<span class="text-sm text-body"><%= at_time_tag(Time.current) %></span>
|
|
136
|
+
<% end %>
|
|
137
|
+
|
|
138
|
+
<%# The date joins only when the stamp is not today — the rule is easiest to
|
|
139
|
+
read as two specimens side by side. %>
|
|
140
|
+
<%= render layout: "style/specimen",
|
|
141
|
+
locals: { klass: "at_time_tag — not today",
|
|
142
|
+
usage: erb.call("at_time_tag(task.created_at)") + "\n# the date joins only when it is not today" } do %>
|
|
143
|
+
<span class="text-sm text-body"><%= at_time_tag(3.days.ago) %></span>
|
|
144
|
+
<% end %>
|
|
145
|
+
|
|
146
|
+
<%= render layout: "style/specimen",
|
|
147
|
+
locals: { klass: "at_time_tag — another year",
|
|
148
|
+
usage: erb.call("at_time_tag(old_record.created_at)") + "\n# the year joins only when it differs" } do %>
|
|
149
|
+
<span class="text-sm text-body"><%= at_time_tag(1.year.ago) %></span>
|
|
150
|
+
<% end %>
|
|
151
|
+
|
|
152
|
+
<%= render layout: "style/specimen",
|
|
153
|
+
locals: { klass: "at_time_tag(time, prefix: nil)",
|
|
154
|
+
usage: erb.call("at_time_tag(time, prefix: nil)") + %(\n# bare clock, for a cell that already says "at") } do %>
|
|
155
|
+
<span class="text-sm text-body"><%= at_time_tag(Time.current, prefix: nil) %></span>
|
|
156
|
+
<% end %>
|
|
157
|
+
|
|
158
|
+
<%# STATIC ILLUSTRATION, not the primitive: a reader inside the US never sees
|
|
159
|
+
the flag, so a live specimen cannot show them what it looks like. Hand-built
|
|
160
|
+
to match the hydrated markup exactly (clock, then ml-2, then the flag). %>
|
|
161
|
+
<%= render layout: "style/specimen",
|
|
162
|
+
locals: { klass: "outside the US — the flag trails",
|
|
163
|
+
usage: %(# The flag is added CLIENT-side from the browser's IANA zone.\n# Inside the US there is no flag at all, and its margin collapses with it.) } do %>
|
|
164
|
+
<div class="flex flex-col items-start gap-1.5 text-sm text-body">
|
|
165
|
+
<span class="whitespace-nowrap"><span>at 6:50a</span><span class="ml-2">🇯🇵</span></span>
|
|
166
|
+
<span class="whitespace-nowrap"><span>at 10:50p</span><span class="ml-2">🇬🇧</span></span>
|
|
167
|
+
<span class="whitespace-nowrap"><span>at 9:50p</span><span class="ml-2">🌐</span></span>
|
|
168
|
+
<span class="whitespace-nowrap text-muted"><span>at 4:50p</span></span>
|
|
169
|
+
</div>
|
|
170
|
+
<% end %>
|
|
171
|
+
|
|
172
|
+
<%# The first caller's shape, so a host sees the primitive in a real pill
|
|
173
|
+
rather than only as bare text. %>
|
|
174
|
+
<%= render layout: "style/specimen",
|
|
175
|
+
locals: { klass: "in a status badge",
|
|
176
|
+
usage: %(<span class="badge">Shipped ) + erb.call("at_time_tag(release.shipped_at)") + "</span>" } do %>
|
|
177
|
+
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span>
|
|
178
|
+
<% end %>
|
|
179
|
+
</div>
|
|
180
|
+
</section>
|
|
181
|
+
|
|
116
182
|
<!-- Motion primitives -->
|
|
117
183
|
<section class="space-y-5">
|
|
118
184
|
<div class="space-y-1">
|
|
@@ -58,3 +58,8 @@
|
|
|
58
58
|
<%= render "style/tasks" %>
|
|
59
59
|
</div>
|
|
60
60
|
</div>
|
|
61
|
+
|
|
62
|
+
<%# The "at" format's re-stamper, so the Tricks time-stamp specimens hydrate to the
|
|
63
|
+
reader's clock even on a host that has not yet wired it into its own layout.
|
|
64
|
+
Self-guarded — a host that HAS wired it renders this as a no-op. %>
|
|
65
|
+
<%= render "studio/at_time_script" %>
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.40.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -168,8 +168,8 @@ files:
|
|
|
168
168
|
- Gemfile
|
|
169
169
|
- LICENSE
|
|
170
170
|
- README.md
|
|
171
|
-
- app/assets/images/emails/email-change-confirmation.
|
|
172
|
-
- app/assets/images/emails/magic-link.
|
|
171
|
+
- app/assets/images/emails/email-change-confirmation.gif
|
|
172
|
+
- app/assets/images/emails/magic-link.gif
|
|
173
173
|
- app/assets/images/resend-favicon.png
|
|
174
174
|
- app/assets/images/ses-favicon.png
|
|
175
175
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
@@ -203,6 +203,7 @@ files:
|
|
|
203
203
|
- app/controllers/style_controller.rb
|
|
204
204
|
- app/controllers/theme_settings_controller.rb
|
|
205
205
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
206
|
+
- app/helpers/studio/at_time_helper.rb
|
|
206
207
|
- app/helpers/studio_email_delivery_helper.rb
|
|
207
208
|
- app/helpers/studio_sidebar_helper.rb
|
|
208
209
|
- app/helpers/studio_theme_helper.rb
|
|
@@ -256,6 +257,7 @@ files:
|
|
|
256
257
|
- app/views/sessions/_sso_continue.html.erb
|
|
257
258
|
- app/views/sessions/new.html.erb
|
|
258
259
|
- app/views/studio/_age_verify_assets.html.erb
|
|
260
|
+
- app/views/studio/_at_time_script.html.erb
|
|
259
261
|
- app/views/studio/_board_assets.html.erb
|
|
260
262
|
- app/views/studio/_confirm_interstitial.html.erb
|
|
261
263
|
- app/views/studio/_cropper_assets.html.erb
|
|
Binary file
|
|
Binary file
|