studio-engine 0.108.0 → 0.109.0.rc2

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: 731299717728fa47ee4546968f5e931e3e717043954d5262e6811c255872c1c6
4
- data.tar.gz: 7f0a4f9eedccac043b017cbc25291f60559529e6a5fe655e8dc89c70ab9118dd
3
+ metadata.gz: 19c0cd3c18b09bb009d42eea9911cb5eef64299aa74ba800f7bdd011da66623b
4
+ data.tar.gz: b22bc36fc79bac82a01ff679bbd9e7d9a49ea92f4d5b71fb52d1efc95e75bd0b
5
5
  SHA512:
6
- metadata.gz: 0daadeed40010820350c83f4d029d2681eb56850204e35c2125681b041408a91c932dcc890efe4e687e0b343008a96a353ed61eb809f319b5089e7b70029bbea
7
- data.tar.gz: 8764a4e2134c2fc02ed07344efb5dfb20fcbce6777923cb84d0dfd4c5302d29c36ed7fdeb59077b573af9a4f50ccd9cf8b7f5d31632faf127eace78a17302d65
6
+ metadata.gz: dd07af40833cdeb4acdaab8e37bffc7f021b9369c14c3b5f6d83c417e7344ada891143924f662cda4bcf6b389098b21eb9c85a860c99134798417273caf7b715
7
+ data.tar.gz: e4a4fc94d72ffcb463a1e37117dcd53f2d2897aea9ee070852a732cf1bace6b31ec35f3c0bdaa128528dcebd63c5d008a98142a024eb4415eabda0ccc5a02a69
data/CHANGELOG.md CHANGED
@@ -4,8 +4,37 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Changed
8
+
9
+ - **A held navbar eases its collapse again.** 0.108.0 held the header's
10
+ footprint so the page never moves, and with it let the header follow the
11
+ scroll position exactly. On a hard flick that crossed the whole ramp in a
12
+ frame or two, the header then snapped full to compact (or back) in one frame,
13
+ which read as a jerk on the hub's `/deployments`. The `--nav-max-step` cap
14
+ (5px of header travel per frame) now applies to held headers too, so a flick
15
+ plays as a short animation of about 8-9 frames. The page still never moves:
16
+ the hold keeps the document's height whatever `--nav-p` is. After a hard
17
+ flick to the top, page background shows between the header and the content
18
+ for those frames while the header grows back. A slow scroll is unchanged.
19
+
20
+ ## 0.109.0 — 2026-10-15
21
+
7
22
  ### Added
8
23
 
24
+ - **Tints and shades: the `.scrim` primitive.** A scrim is the wash laid over
25
+ media so text reads on it. A **shade** lays the dark base over the media and
26
+ takes light ink; a **tint** lays the light base over it and takes dark ink.
27
+ `.scrim` picks by theme (a tint in light mode, a shade in dark),
28
+ `.scrim-tint` / `.scrim-shade` force a tone in either theme, `.scrim-soft` /
29
+ `.scrim-strong` step the strength from the tone's default (tint 75/58, shade
30
+ 85/70; the default dark shade is the old navy `rgba(26,21,53,.85/.7/.85)`
31
+ exactly), and `.scrim-ink` / `.scrim-ink-muted` pick the ink for the tone.
32
+ The tone classes set inherited custom properties, so a tone on the parent
33
+ reaches both the wash and its sibling text. `Studio::ThemeResolver` now emits
34
+ the theme-independent `--color-shade`, `--color-tint` and their ink pairs
35
+ (`--color-{shade,tint}-ink`, `--color-{shade,tint}-ink-muted`) in both theme
36
+ blocks, following a custom `dark` / `light` colour. Specimen at
37
+ `/admin/style` → Tricks → Tints & shades; README "Tints and shades".
9
38
  - **`GET /_studio/health_report`: the daily health report as JSON, for the
10
39
  hub's central pull.** McRitchie Studio runs one 07:00 America/Denver job that
11
40
  pulls every app's report and posts it to Discord, so a satellite needs no
data/README.md CHANGED
@@ -209,6 +209,69 @@ flag — it cannot know where the reader is sitting, so only the reader's machin
209
209
  may assert one. A host that omits the script still gets working stamps, just
210
210
  frozen in the app's timezone. Specimen: `/admin/style` → Tricks → Time stamps.
211
211
 
212
+ ### Tints and shades — `.scrim`
213
+
214
+ Text laid over media on a PAGE (a hero photo, a video, a banner) reads through
215
+ a **scrim**, a wash between the two. (The transactional email banner's scrim,
216
+ below, is a separate inline shade built for mail clients.) A scrim comes in one of two tones, and the tone is the
217
+ design decision:
218
+
219
+ | Tone | What it lays over the media | Ink that reads on it | Token |
220
+ |------|-----------------------------|----------------------|-------|
221
+ | **Shade** | the dark base; it darkens the media | light | `--color-shade` |
222
+ | **Tint** | the light base; it lightens the media | dark | `--color-tint` |
223
+
224
+ The shade is the dark theme's natural scrim and the tint the light theme's, so
225
+ `.scrim` on its own picks by theme. Force a tone when the media needs it, not
226
+ the theme: a pale photo that only a shade can carry, or a brand moment that
227
+ wants dark ink in both themes.
228
+
229
+ ```erb
230
+ <section class="relative overflow-hidden">
231
+ <img src="/hero.avif" alt="" class="absolute inset-0 w-full h-full object-cover">
232
+ <div class="scrim absolute inset-0"></div>
233
+ <div class="relative z-10 text-center">
234
+ <h1 class="scrim-ink text-5xl font-extrabold">Solutions For Business</h1>
235
+ <p class="scrim-ink-muted">An acquisition entrepreneur ...</p>
236
+ </div>
237
+ </section>
238
+
239
+ <%# Forced: a shade in both themes. The tone goes on the shared PARENT. %>
240
+ <section class="scrim-shade relative overflow-hidden"> ... </section>
241
+ ```
242
+
243
+ | Class | Effect |
244
+ |-------|--------|
245
+ | `.scrim` | Paints the wash: a 135deg gradient, heavier at both corners than in the middle. Theme-aware with no tone class: a tint in light mode, a shade in dark. |
246
+ | `.scrim-tint` · `.scrim-shade` | Force the tone, in either theme. |
247
+ | `.scrim-soft` · `.scrim-strong` | Step the strength: 15 points lighter, or 10 heavier, from the tone's default. |
248
+ | `.scrim-ink` | The strong ink for the tone (headlines): the dark text colour on a tint, white on a shade. |
249
+ | `.scrim-ink-muted` | The quieter ink (subtitles): the body ink at weight 600 on a tint, the secondary ink at 400 on a shade. |
250
+
251
+ - **Put the tone on the shared parent.** The tone classes set custom
252
+ properties, which inherit. The wash and the text are usually siblings (an
253
+ `absolute inset-0` scrim, then a `relative z-10` content box), so a tone on the
254
+ wash alone leaves the text on the theme's ink.
255
+ - **Default strengths.** A tint is 75% at the corners and 58% in the middle (the
256
+ hub hero's approved light treatment); a shade is 85% and 70%, which on the
257
+ default theme is the old hero navy, `rgba(26,21,53,.85/.7/.85)`, exactly. For an
258
+ exact value set `--scrim-edge` and `--scrim-mid` on the scrim element.
259
+ - **Muted ink on a tint is bolder, not greyer.** The secondary grey washes into
260
+ the media behind a pale tint, so the tint's muted ink is the body colour at
261
+ semibold. `.scrim-ink-muted` sets that weight; do not add a `font-*` beside it.
262
+ - **Tokens.** `Studio::ThemeResolver` emits `--color-shade` (the dark base) and
263
+ `--color-tint` (the light base) with the SAME value in both theme blocks, plus
264
+ their ink pairs `--color-{shade,tint}-ink` and `--color-{shade,tint}-ink-muted`.
265
+ They follow a custom theme's `dark` and `light` colours. Each ink clears WCAG AA
266
+ on its own base; contrast over a busy photo depends on the photo, so pick
267
+ `.scrim-strong` for small or long text.
268
+ - A scrim needs no theme switch of its own: there is no `dark:` variant, because
269
+ the tone is read from `:root` and the root `.dark` class.
270
+
271
+ Specimen: `/admin/style` → Tricks → Tints & shades. Definitions and the
272
+ reasoning: `engine.css` (Scrims: tints and shades). Browser proof:
273
+ `e2e/scrim_tones.spec.js`.
274
+
212
275
  ### Smooth-load header pin — `.vt-pinned-header`
213
276
 
214
277
  When `Studio.smooth_load` is on, put `vt-pinned-header` on the app's sticky
@@ -875,7 +938,9 @@ generated at send time.
875
938
  **The scrim** is a wash between artwork and type, on by default at 0.40.
876
939
  Background art is chosen to look good, not to guarantee contrast, and white text
877
940
  over a pale sky cannot be read. Pass `scrim: 0` for artwork dark enough to carry
878
- the type itself.
941
+ the type itself. This email scrim is its own fixed shade (`24,16,64`, inline for
942
+ mail clients); it is not the page primitive in [Tints and shades](#tints-and-shades--scrim),
943
+ which mail clients could not render.
879
944
 
880
945
  **The markup is deliberately old-fashioned.** Outlook on Windows renders through
881
946
  Word and ignores `background-image`, so the picture is carried by the `<td
@@ -178,6 +178,118 @@ body.modal-open .studio-app-banner {
178
178
  @apply bg-inset rounded-xl border border-subtle p-4 overflow-x-auto;
179
179
  }
180
180
 
181
+ /* -- Scrims: tints and shades ----------------------------------------------
182
+ A scrim is the wash laid over media (a photo, a video) so text reads on it.
183
+ It comes in two TONES, and the tone is a design decision, not a colour pick:
184
+
185
+ SHADE the dark base (--color-shade) over the media. It DARKENS it, and
186
+ LIGHT ink reads on it. The dark theme's natural scrim.
187
+ TINT the light base (--color-tint) over the media. It LIGHTENS it, and
188
+ DARK ink reads on it. The light theme's natural scrim.
189
+
190
+ Both tokens are theme-INDEPENDENT (Studio::ThemeResolver#tone_vars emits
191
+ each with one value in both theme blocks), so a forced tone paints the same
192
+ in either theme. They follow the theme's own bases, so a custom dark or
193
+ light colour carries through.
194
+
195
+ THE CLASSES
196
+ .scrim paints the wash. With no tone class it is
197
+ theme-aware: a tint in light mode, a shade in dark.
198
+ .scrim-tint forces a tint, in either theme.
199
+ .scrim-shade forces a shade, in either theme.
200
+ .scrim-soft a lighter wash (-15 points on both stops).
201
+ .scrim-strong a heavier wash (+10 points on both stops).
202
+ .scrim-ink the strong ink for text on the tone (headlines).
203
+ .scrim-ink-muted the quieter ink (subtitles): on a tint, the body
204
+ ink at 600, because the secondary grey washes into
205
+ the media; on a shade, the secondary ink at 400.
206
+
207
+ THE TONE IS INHERITED CONTEXT, NOT A PROPERTY OF THE WASH. The usual layout
208
+ puts the scrim and the text as SIBLINGS (an `absolute inset-0` scrim, then
209
+ a `relative z-10` content box), so the text cannot read anything off the
210
+ scrim element itself. The tone classes therefore only set custom
211
+ properties, and those inherit: put `.scrim-shade` on the section and both
212
+ the wash and every `.scrim-ink` inside it take the shade. With no tone
213
+ class anywhere, :root and .dark supply the theme-aware default.
214
+
215
+ STRENGTH. Each tone carries its own default stops (--scrim-tone-edge /
216
+ --scrim-tone-mid): a tint 75% / 58% (approved on the hub's landing hero),
217
+ a shade 85% / 70%. The gradient reads --scrim-edge / --scrim-mid first, so
218
+ a caller sets either inline for an exact value, and .scrim-soft /
219
+ .scrim-strong derive them from the tone's own default. Put a strength class
220
+ on the scrim element itself: --scrim-edge inherits like any custom property.
221
+
222
+ THE GRADIENT runs corner to corner (135deg), heavier at both ends than in
223
+ the middle, so the media shows through where the eye lands least. The
224
+ default shade is exactly rgba(26,21,53, .85 / .70 / .85) on the default
225
+ theme: color-mix against `transparent` keeps the colour and scales alpha.
226
+
227
+ Every token carries the engine default as a fallback, so an app that sets
228
+ its own theme vars without studio_theme_css_tag still gets a working wash. */
229
+ :root {
230
+ --scrim-color: var(--color-tint, #f8fafc);
231
+ --scrim-tone-edge: 75%;
232
+ --scrim-tone-mid: 58%;
233
+ --scrim-ink: var(--color-tint-ink, #0f172a);
234
+ --scrim-ink-muted: var(--color-tint-ink-muted, #334155);
235
+ --scrim-ink-muted-weight: 600;
236
+ }
237
+
238
+ .dark {
239
+ --scrim-color: var(--color-shade, #1A1535);
240
+ --scrim-tone-edge: 85%;
241
+ --scrim-tone-mid: 70%;
242
+ --scrim-ink: var(--color-shade-ink, #ffffff);
243
+ --scrim-ink-muted: var(--color-shade-ink-muted, #BAB9C2);
244
+ --scrim-ink-muted-weight: 400;
245
+ }
246
+
247
+ @utility scrim {
248
+ background-image: linear-gradient(
249
+ 135deg,
250
+ color-mix(in srgb, var(--scrim-color) var(--scrim-edge, var(--scrim-tone-edge)), transparent) 0%,
251
+ color-mix(in srgb, var(--scrim-color) var(--scrim-mid, var(--scrim-tone-mid)), transparent) 50%,
252
+ color-mix(in srgb, var(--scrim-color) var(--scrim-edge, var(--scrim-tone-edge)), transparent) 100%
253
+ );
254
+ }
255
+
256
+ @utility scrim-tint {
257
+ --scrim-color: var(--color-tint, #f8fafc);
258
+ --scrim-tone-edge: 75%;
259
+ --scrim-tone-mid: 58%;
260
+ --scrim-ink: var(--color-tint-ink, #0f172a);
261
+ --scrim-ink-muted: var(--color-tint-ink-muted, #334155);
262
+ --scrim-ink-muted-weight: 600;
263
+ }
264
+
265
+ @utility scrim-shade {
266
+ --scrim-color: var(--color-shade, #1A1535);
267
+ --scrim-tone-edge: 85%;
268
+ --scrim-tone-mid: 70%;
269
+ --scrim-ink: var(--color-shade-ink, #ffffff);
270
+ --scrim-ink-muted: var(--color-shade-ink-muted, #BAB9C2);
271
+ --scrim-ink-muted-weight: 400;
272
+ }
273
+
274
+ @utility scrim-soft {
275
+ --scrim-edge: calc(var(--scrim-tone-edge) - 15%);
276
+ --scrim-mid: calc(var(--scrim-tone-mid) - 15%);
277
+ }
278
+
279
+ @utility scrim-strong {
280
+ --scrim-edge: calc(var(--scrim-tone-edge) + 10%);
281
+ --scrim-mid: calc(var(--scrim-tone-mid) + 10%);
282
+ }
283
+
284
+ @utility scrim-ink {
285
+ color: var(--scrim-ink);
286
+ }
287
+
288
+ @utility scrim-ink-muted {
289
+ color: var(--scrim-ink-muted);
290
+ font-weight: var(--scrim-ink-muted-weight);
291
+ }
292
+
181
293
  /* -- Text + form ----------------------------------------------------------- */
182
294
 
183
295
  @utility badge {
@@ -518,9 +630,10 @@ body.modal-open .studio-app-banner {
518
630
  single frame on a flick — measured 39px in turf-monster, the full
519
631
  178 -> 139. navCollapse() converts this to --nav-p units off
520
632
  --nav-ramp's 3x relation to the collapse total, so one number covers
521
- every band. It binds only a header OUT of the page's flow: a header
522
- in flow is held (studio/nav_collapse, the hold), so the page cannot
523
- move, and its progress follows the scroll exactly. */
633
+ every band. It eases every header, held or not: a held header cannot
634
+ move the page (studio/nav_collapse, the hold), so for it the cap only
635
+ shapes the header's own motion, and a flick plays as a short animation
636
+ rather than a one-frame jump. */
524
637
  --nav-max-step: 5px;
525
638
  --nav-pad: calc(1.5rem - 1rem * var(--nav-p, 0)); /* py-6 -> py-2 */
526
639
  --nav-logo-size: calc(3rem - 1rem * var(--nav-p, 0)); /* 48px -> 32px */
@@ -35,12 +35,17 @@
35
35
  // finger moved it, and only the header itself changes size. A header that
36
36
  // is not in flow (fixed or absolute) is left alone: it never moved the page.
37
37
  //
38
- // (Held, the two guards below exist only for an UNHELD header. The document
39
- // no longer shortens, so the short-page flap cannot happen, and the per-frame
40
- // cap, which slowed the header so the page it dragged would not jump, now
41
- // only makes the header lag the scroll. Worse, a lagging header opens a gap
42
- // above the content on a fast scroll back to the top. Held, the progress is
43
- // the scroll position, every frame.)
38
+ // (Held, the short-page guard below is off: the document no longer
39
+ // shortens, so the flap it prevents cannot happen. The per-frame CAP stays
40
+ // on. It was written to stop a flick dragging the page, and the hold already
41
+ // does that; what it buys a held header is the header's own motion. With the
42
+ // cap off, a flick that crossed the ramp in one frame snapped the header
43
+ // from full to compact in one frame, which read as a jerk on the hub's
44
+ // /deployments (2026-10-15). Capped, the header eases over a few frames, and
45
+ // the page under it still holds, because the hold keeps the document's
46
+ // height whatever --nav-p is. Easing open after a flick to the top leaves
47
+ // page background between the header and the content for those frames, the
48
+ // space the header is growing back into.)
44
49
  //
45
50
  // passive + rAF: the listener never blocks the compositor and coalesces a
46
51
  // burst of scroll events (iOS momentum fires far above 60Hz) into one write
@@ -85,7 +90,7 @@ export const DEFAULT_MAX_STEP = 5;
85
90
  // innerHeight window.innerHeight
86
91
  // reduce prefers-reduced-motion matches
87
92
  // held the header holds its footprint (see the hold above): no
88
- // short-page guard, no per-frame cap
93
+ // short-page guard; the per-frame cap still eases the header
89
94
  //
90
95
  // Answers { p, settling }: the progress to publish, and whether the frame
91
96
  // clamp left it short of where the scroll position wants it (so the caller
@@ -107,8 +112,6 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
107
112
  } else {
108
113
  var t = Math.min(1, y / ramp);
109
114
  target = t * t * (3 - 2 * t);
110
- // Held, the progress IS the scroll position: nothing to cap.
111
- if (held) snap = true;
112
115
  }
113
116
 
114
117
  // THE RATE LIMIT: how far the collapse may travel in ONE frame.
@@ -139,7 +142,7 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
139
142
  if (snap) {
140
143
  // A guard refusal and a reduced-motion state are DECISIONS, not
141
144
  // motion; ramping them would animate the very thing each exists to
142
- // avoid. A held header follows the scroll exactly.
145
+ // avoid.
143
146
  next = target;
144
147
  } else {
145
148
  var maxStep = (3 * maxPx) / ramp;
@@ -0,0 +1,67 @@
1
+ <%# Tints and shades: the scrim primitive's group on the living style guide's
2
+ Tricks board (admin/style). Rendered by style/_tricks, and by the e2e lab
3
+ page e2e_lab/scrims, which grades the painted wash and ink in a browser.
4
+
5
+ A SHADE lays the dark base over media and light ink reads on it; a TINT
6
+ lays the light base over media and dark ink reads on it. .scrim alone is
7
+ theme-aware (a tint in light mode, a shade in dark); .scrim-tint and
8
+ .scrim-shade force a tone in either theme. The tone classes only set
9
+ custom properties, which inherit, so each specimen puts its tone on the
10
+ STAGE and the wash and the text, which are siblings, both take it.
11
+
12
+ The striped panel stands in for a photo: vivid and busy, drawn in the role
13
+ colours, which are the same in both themes, so what changes between
14
+ specimens is the scrim alone. The
15
+ definitions and the strength numbers live in engine.css (Scrims: tints
16
+ and shades) and the README section of the same name. %>
17
+ <%
18
+ media_style = "background-image: repeating-linear-gradient(135deg, var(--color-primary) 0 18px, var(--color-accent) 18px 36px, var(--color-warning) 36px 54px, var(--color-success) 54px 72px)"
19
+
20
+ specimens = [
21
+ { key: "theme", tone: nil, scrim: "scrim",
22
+ klass: ".scrim",
23
+ note: "Theme-aware: a tint in light mode, a shade in dark." },
24
+ { key: "tint", tone: "scrim-tint", scrim: "scrim",
25
+ klass: ".scrim-tint .scrim",
26
+ note: "Forced tint, in either theme. Dark ink." },
27
+ { key: "shade", tone: "scrim-shade", scrim: "scrim",
28
+ klass: ".scrim-shade .scrim",
29
+ note: "Forced shade, in either theme. Light ink." },
30
+ { key: "soft", tone: nil, scrim: "scrim scrim-soft",
31
+ klass: ".scrim .scrim-soft",
32
+ note: "A lighter wash: more of the media shows." },
33
+ { key: "strong", tone: nil, scrim: "scrim scrim-strong",
34
+ klass: ".scrim .scrim-strong",
35
+ note: "A heavier wash, for long or small text." }
36
+ ]
37
+ %>
38
+ <section id="scrims" class="space-y-5" data-scrim-group>
39
+ <div class="space-y-1">
40
+ <h3 class="text-xl font-bold text-heading">Tints &amp; shades</h3>
41
+ <p class="text-muted text-sm max-w-2xl">
42
+ A <strong>shade</strong> lays the dark base over media and light ink reads on it; a
43
+ <strong>tint</strong> lays the light base over media and dark ink reads on it.
44
+ <code class="font-mono text-2xs">.scrim</code> picks by theme;
45
+ <code class="font-mono text-2xs">.scrim-tint</code> / <code class="font-mono text-2xs">.scrim-shade</code>
46
+ force one. Put the tone on the shared parent so the wash and the
47
+ <code class="font-mono text-2xs">.scrim-ink</code> text both take it.
48
+ </p>
49
+ </div>
50
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
51
+ <% specimens.each do |s| %>
52
+ <% tone_attr = s[:tone] ? %( class="#{s[:tone]} relative ...") : %( class="relative ...") %>
53
+ <%= render layout: "style/specimen",
54
+ locals: { klass: s[:klass],
55
+ usage: %(<section#{tone_attr}>\n <img class="absolute inset-0 ..." src="...">\n <div class="#{s[:scrim]} absolute inset-0"></div>\n <div class="relative z-10">\n <h2 class="scrim-ink">Headline</h2>\n <p class="scrim-ink-muted">Subtitle</p>\n </div>\n</section>) } do %>
56
+ <div class="relative w-full max-w-xs h-36 rounded-lg overflow-hidden <%= s[:tone] %>" data-scrim-specimen="<%= s[:key] %>">
57
+ <div class="absolute inset-0" style="<%= media_style %>" aria-hidden="true"></div>
58
+ <div class="<%= s[:scrim] %> absolute inset-0" data-scrim-wash></div>
59
+ <div class="relative z-10 h-full flex flex-col items-center justify-center gap-1 px-4 text-center">
60
+ <p class="scrim-ink text-lg font-extrabold" data-scrim-ink>Solutions For Business</p>
61
+ <p class="scrim-ink-muted text-sm" data-scrim-ink-muted><%= s[:note] %></p>
62
+ </div>
63
+ </div>
64
+ <% end %>
65
+ <% end %>
66
+ </div>
67
+ </section>
@@ -34,8 +34,8 @@
34
34
  <p class="text-muted text-sm max-w-2xl">
35
35
  A board of style tricks you can see and hand to an agent to use out of the box.
36
36
  Each specimen surfaces the rendered primitive, its <strong>class name (one-click
37
- copy)</strong>, and a copyable usage snippet - the button, surface, motion, effect,
38
- and leveling primitives. The color tokens live in <a href="#theme" class="underline">Theme</a>.
37
+ copy)</strong>, and a copyable usage snippet - the button, surface, scrim, motion,
38
+ effect, and leveling primitives. The color tokens live in <a href="#theme" class="underline">Theme</a>.
39
39
  </p>
40
40
  </div>
41
41
 
@@ -113,6 +113,9 @@
113
113
  </div>
114
114
  </section>
115
115
 
116
+ <!-- Tints and shades: the scrim primitive -->
117
+ <%= render "style/scrims" %>
118
+
116
119
  <!-- Time stamps: the "at" format -->
117
120
  <section class="space-y-5">
118
121
  <div class="space-y-1">
@@ -28,23 +28,60 @@ module Studio
28
28
  end
29
29
 
30
30
  def to_css
31
- dark_vars = dark_mode_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
32
- light_vars = light_mode_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
31
+ dark = dark_mode_vars
32
+ light = light_mode_vars
33
+ dark_vars = dark.map { |k, v| " #{k}: #{v};" }.join("\n")
34
+ light_vars = light.map { |k, v| " #{k}: #{v};" }.join("\n")
33
35
  palette_vars = primary_palette_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
36
+ tone_css = tone_vars(dark, light).map { |k, v| " #{k}: #{v};" }.join("\n")
34
37
 
35
38
  <<~CSS
36
39
  :root, .dark {
37
40
  #{dark_vars}
38
41
  #{palette_vars}
42
+ #{tone_css}
39
43
  }
40
44
 
41
45
  html:not(.dark) {
42
46
  #{light_vars}
43
47
  #{palette_vars}
48
+ #{tone_css}
44
49
  }
45
50
  CSS
46
51
  end
47
52
 
53
+ # THE SHADE AND THE TINT — the two tones the scrim primitive lays over
54
+ # media (engine.css `.scrim`, README "Tints and shades").
55
+ #
56
+ # A SHADE is the dark base over a photo: it darkens it, and light ink reads
57
+ # on it. A TINT is the light base over a photo: it lightens it, and dark
58
+ # ink reads on it. They are the same pair as the two themes' page colours,
59
+ # but these tokens are THEME-INDEPENDENT: emitted with one value in both
60
+ # blocks, so a forced `.scrim-shade` is the dark base in light mode too.
61
+ # --color-page cannot serve, because it flips with the theme.
62
+ #
63
+ # Each tone carries its ink pair, also theme-independent, because text on a
64
+ # forced tone in the opposite theme cannot use --color-text (it would be
65
+ # light ink on a tint). The pairs are the two themes' own inks, chosen so
66
+ # the theme-aware `.scrim` matches the page's text exactly:
67
+ # shade: --color-text (#fff) and the dark --color-text-secondary;
68
+ # tint: --color-text (#0f172a) and the light --color-text-BODY, which
69
+ # `.scrim-ink-muted` sets at 600. Not the light secondary grey:
70
+ # on the hub hero it washed into the skyline behind the tint, and
71
+ # the body ink at semibold is the treatment Alex approved.
72
+ # Takes the two mode hashes when the caller already built them (to_css),
73
+ # so the contrast searches behind them run once per render, not twice.
74
+ def tone_vars(dark = dark_mode_vars, light = light_mode_vars)
75
+ {
76
+ "--color-shade" => dark["--color-page"],
77
+ "--color-shade-ink" => dark["--color-text"],
78
+ "--color-shade-ink-muted" => dark["--color-text-secondary"],
79
+ "--color-tint" => light["--color-page"],
80
+ "--color-tint-ink" => light["--color-text"],
81
+ "--color-tint-ink-muted" => light["--color-text-body"]
82
+ }
83
+ end
84
+
48
85
  def dark_mode_vars
49
86
  dark_base = colors[:dark] || "#1A1535"
50
87
  primary = colors[:primary] || DEFAULT_PRIMARY
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.108.0"
2
+ VERSION = "0.109.0.rc2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.108.0
4
+ version: 0.109.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -674,6 +674,7 @@ files:
674
674
  - app/views/style/_host.html.erb
675
675
  - app/views/style/_modal_specimen.html.erb
676
676
  - app/views/style/_modals.html.erb
677
+ - app/views/style/_scrims.html.erb
677
678
  - app/views/style/_site_footer.html.erb
678
679
  - app/views/style/_specimen.html.erb
679
680
  - app/views/style/_tasks.html.erb