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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb273757252724d41962bac1a7c6318c0d04006ff41c1ba29735ad413c0b57fc
4
- data.tar.gz: 618a5a39311ac9a316c649a502b44fa6540e8dc70c776fc3c3b2c17455473479
3
+ metadata.gz: 01f352e62e854f4ec09087aec52716a87ca4ac494e19882e554bb19dd154092c
4
+ data.tar.gz: 791a21cfc0130503ff5489f43857bde752892facfe7d1ab7e9233dd7535f6ffe
5
5
  SHA512:
6
- metadata.gz: 9b7bcf2d4dca51566942a2179a6f06e6c4a70293acab99b5a747a76785d9cba69cf51e4992e02410e82e9602f6c317605866ed1bd5b59bbf452954eae5d620aa
7
- data.tar.gz: 1136131a5250c8a014edeb8ddd602c23dced903a82b99d3c57eb3bf27dd2394b735c993d08a559ff6132b8ffcdf337a4d6a91d8f4396ef8ab83c7679b7e2c4d7
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) -> app-owned override
380
- 2. the engine's default gem asset -> inherited default
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
@@ -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.png"
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.png"
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]), preview: attrs[:preview])
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
- # :app — this app uploaded its own (ImageCache row in its bucket)
188
- # :default the inherited engine default (gem asset)
189
- # :none — no image at all; the email sends bannerless
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 :default if default_asset_path(key)
230
+ return :none unless default_asset_path(key)
193
231
 
194
- :none
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
- <% inherited = current_url.present? && Studio::EmailCatalog.source(variant) == :default %>
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:, aspect:, max_width:)
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", "#{Studio.app_name}'s own",
18
- "Uploaded here — this app owns it."]
19
- when :default
20
- ["badge bg-inset text-muted border-subtle", "Inherited default",
21
- "Shared Studio artwork, shipped with the engine."]
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: <%= aspect %>,
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: <%= aspect %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
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
- app_owned = @entries.count { |entry| Studio::EmailCatalog.source(entry.key) == :app }
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. Each one starts on the shared Studio artwork; upload
34
- your own and it belongs to this app from then on.
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 &middot;
38
- <%= app_owned.zero? ? "all inheriting the default artwork" : "#{app_owned} with #{Studio.app_name}'s own artwork" %>
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
- aspect: aspect, max_width: max_width %>
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
- fixed at <%= aspect.to_i %>:1 and saved at up to <%= max_width %>px, which is
90
- retina-sharp in an inbox without tripping a clipping limit.
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 = Studio::EmailCatalog::ASPECT_RATIO
12
+ aspect = @entry.ratio
13
13
 
14
14
  badge_class, badge_label =
15
15
  case source
16
- when :app then ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s own"]
17
- when :default then ["badge bg-inset text-muted border-subtle", "Inherited default"]
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 &ldquo;at&rdquo; 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&rsquo;s own clock. The stamps below are live: the server rendered them in this
123
+ app&rsquo;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" %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.39.0"
2
+ VERSION = "0.40.0"
3
3
  end
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.39.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 00:00:00.000000000 Z
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.png
172
- - app/assets/images/emails/magic-link.png
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