studio-engine 0.110.0.rc1 → 0.111.0.rc1

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: faad0b30d10e3cfbb58f3af463f47af004c790db2ef4945c7e8c914d533bcc5a
4
- data.tar.gz: 567c93db53a89e63a192abf3ccd03b5dfddbadb03df8072783988cd7aed43482
3
+ metadata.gz: 175723189715b75de2fdc438837730fb96d924f5f87571d34a570560187f0eaf
4
+ data.tar.gz: 18f6f80031d9126bca15efe6a2726aa7f983526dada0717c6be763a6ceb82e87
5
5
  SHA512:
6
- metadata.gz: 014cccc62e8ac88d2c6bbf9d5758eaf1997c42ff654f84bbf889d8f0c20f5e3bd15cd3aa8fb7946775e29f91fceaa1f8916a15f57bb7c7340d6c13a21de4b8dd
7
- data.tar.gz: d3523274d32ed61fcc806a32a73b17f92f857c7c471bb91bd28b8cfeff5c27ab1cd04100156aca35b46472222789fa6425288178b087142394fe7acc8ef1e217
6
+ metadata.gz: 10d63348437f151fd14272ba24637c9ba464046d44814d1c6a0528f67ade717bc218f9758621a699a9d352578df67bed3b62e0d99cc05accf3a82e0fa8959956
7
+ data.tar.gz: 1d2b460819033fd7a7f1ca58c597183808dbc9e748b66e1c5d9beaa9009bec527cef4c45ab5017e6e54d5bec254e458073ff03d2128e3d1d9ae85ad78ba42930
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.111.0 — 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - **The navbar can collapse on the browser's scroll timeline** (opt-in:
12
+ `config.navbar_scroll_timeline = true`, or `scroll_timeline: true` on one
13
+ render of `layouts/_navbar`). From 768px, in a browser with scroll-driven
14
+ animations, the header keeps one height and is sticky at minus its band (its
15
+ expanded height less its collapsed height), so the band scrolls away with the
16
+ page and the compact bar sticks. The logo, title, avatar and name shrink by
17
+ transforms on the page's scroll timeline, and the icon rail slides so the
18
+ gaps stay even. The compositor runs all of it with the scroll: no script per
19
+ frame, no relayout, so a busy page cannot make the header stutter. The
20
+ scrolled line and shadow are drawn by `::after` on the same timeline.
21
+ `studio/nav_collapse` gains a timeline mode for the header: it measures the
22
+ band and the scales once (on connect, resize, font load and a bar resize)
23
+ and keeps the `is-scrolled` marker; phones (under 768px) and browsers without
24
+ scroll timelines (Firefox today) keep the `--nav-p` collapse, and crossing
25
+ 768px swaps modes. On the timeline, `--nav-h` is the compact bar's height,
26
+ so an anchor's `scroll-margin-top` clears what is pinned. Off by default: a
27
+ header without the opt-in renders and behaves byte-identical to before.
28
+
7
29
  ## 0.110.0 — 2026-10-15
8
30
 
9
31
  ### Added
@@ -752,6 +752,100 @@ body.modal-open .studio-app-banner {
752
752
  }
753
753
  }
754
754
 
755
+ /* THE SCROLL-DRIVEN COLLAPSE (opt-in: config.navbar_scroll_timeline, which puts
756
+ data-nav-timeline on layouts/_navbar's header).
757
+
758
+ The band table above interpolates real sizes off --nav-p, which
759
+ studio/nav_collapse writes from script once per frame. Every one of those
760
+ frames is a style-and-layout pass on the main thread, so the header can only
761
+ be as smooth as the page around it: on a busy page (the hub's /deployments,
762
+ 2026-10-15) a flick dropped frames and the header stepped.
763
+
764
+ Here nothing changes size, so nothing reflows and no script runs per frame:
765
+
766
+ THE BOX IS FIXED at its expanded height, and sticky at minus the BAND (its
767
+ expanded height less its collapsed height, measured by the controller and
768
+ published as --nav-band). So the band scrolls away with the page by the
769
+ browser's own scrolling, and the compact bar is what sticks. The document
770
+ never changes height; the page cannot move by itself.
771
+
772
+ THE SHRINK IS A SCROLL TIMELINE on transforms: the row slides down half the
773
+ band so it stays centred on the visible bar, the brand scales from its left
774
+ edge, the avatar and name from the right (the name by the avatar's ratio,
775
+ the rule of 3), and the icon rail slides right by what the account gave up
776
+ so the gaps stay even. The compositor runs these with the scroll itself.
777
+
778
+ THE SCROLLED LINE AND SHADOW are drawn by ::after on the same timeline,
779
+ not by border-b (which would add a pixel of height and reflow).
780
+
781
+ --nav-start is where the header meets the top of the window: the height of
782
+ the bars above it in flow (studio/banners/_stack). Both it and the scales
783
+ are measured once (on connect, resize, font load, a bar resize), never per
784
+ frame; studio/nav_collapse's timeline mode owns them.
785
+
786
+ Only from 768px, and only where the browser has scroll timelines (Firefox
787
+ does not yet): the controller tests the same two conditions and runs the
788
+ --nav-p collapse otherwise, so the two can never both drive the header. */
789
+ @layer utilities {
790
+ @supports (animation-timeline: scroll()) {
791
+ @media (min-width: 768px) {
792
+ .nav-shell[data-nav-timeline] {
793
+ --nav-band: 40px;
794
+ top: calc(-1 * var(--nav-band));
795
+ }
796
+ .nav-shell[data-nav-timeline]::after {
797
+ content: "";
798
+ position: absolute;
799
+ inset: auto 0 0 0;
800
+ height: 1px;
801
+ background: var(--color-border);
802
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
803
+ pointer-events: none;
804
+ opacity: 0;
805
+ animation: nav-tl-lit linear both;
806
+ animation-timeline: scroll(root block);
807
+ animation-range: calc(var(--nav-start, 0px) + var(--nav-band) - 8px) calc(var(--nav-start, 0px) + var(--nav-band) + 16px);
808
+ }
809
+ /* The row slides down half the band to stay centred on the bar, so its
810
+ bottom padding (empty, transparent) overhangs the header by that much,
811
+ and an overhang still takes the pointer: it ate clicks on the top 20px
812
+ of the hub's pinned board lane headers. The row lets the pointer
813
+ through; its contents, which sit inside the bar, still take it. */
814
+ .nav-shell[data-nav-timeline] > .nav-row { pointer-events: none; }
815
+ .nav-shell[data-nav-timeline] > .nav-row > * { pointer-events: auto; }
816
+ .nav-shell[data-nav-timeline] > .nav-row {
817
+ animation: nav-tl-row linear both;
818
+ animation-timeline: scroll(root block);
819
+ animation-range: var(--nav-start, 0px) calc(var(--nav-start, 0px) + var(--nav-band));
820
+ }
821
+ .nav-shell[data-nav-timeline] .nav-logo-link,
822
+ .nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden],
823
+ .nav-shell[data-nav-timeline] [data-nav-account] > span:first-child,
824
+ .nav-shell[data-nav-timeline] [data-nav-tools] {
825
+ animation-timing-function: ease-in-out;
826
+ animation-fill-mode: both;
827
+ animation-timeline: scroll(root block);
828
+ animation-range: var(--nav-start, 0px) calc(var(--nav-start, 0px) + var(--nav-ramp));
829
+ }
830
+ .nav-shell[data-nav-timeline] .nav-logo-link { transform-origin: left center; animation-name: nav-tl-brand; }
831
+ .nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden] { transform-origin: right center; animation-name: nav-tl-avatar; }
832
+ .nav-shell[data-nav-timeline] [data-nav-account] > span:first-child { transform-origin: right center; animation-name: nav-tl-name; }
833
+ .nav-shell[data-nav-timeline] [data-nav-tools] { animation-name: nav-tl-tools; }
834
+ }
835
+ /* Reduced motion: the shrink snaps at the first pixel, as --nav-p's 0/1
836
+ snap does on the script path. */
837
+ @media (min-width: 768px) and (prefers-reduced-motion: reduce) {
838
+ .nav-shell[data-nav-timeline] { --nav-ramp: 1px; }
839
+ }
840
+ }
841
+ }
842
+ @keyframes nav-tl-row { to { transform: translateY(calc(var(--nav-band) / 2)); } }
843
+ @keyframes nav-tl-brand { to { transform: scale(var(--nav-brand-scale, 1)); } }
844
+ @keyframes nav-tl-avatar { to { transform: scale(var(--nav-avatar-scale, 1)); } }
845
+ @keyframes nav-tl-name { to { transform: translateX(var(--nav-avatar-shrink, 0px)) scale(var(--nav-avatar-scale, 1)); } }
846
+ @keyframes nav-tl-tools { to { transform: translateX(var(--nav-tools-shift, 0px)); } }
847
+ @keyframes nav-tl-lit { to { opacity: 1; } }
848
+
755
849
  /* A board whose controller failed to load (studio/lazy_controllers marks its
756
850
  section data-studio-controller-failed="board") shows the notice
757
851
  studio/board/_board renders with an inline display: none. !important is what
@@ -16,7 +16,13 @@ export default class extends Controller {
16
16
  connect() {
17
17
  this.collapse = new NavCollapse(this.element, (lit) => {
18
18
  if (!this.hasScrolledClass) return
19
- for (const name of this.scrolledClasses) this.element.classList.toggle(name, lit)
19
+ // On the scroll timeline the line and shadow are the stylesheet's
20
+ // (::after, so no border adds a pixel of height); only the marker
21
+ // toggles, and the rest stay off.
22
+ const timeline = this.collapse && this.collapse.timeline
23
+ for (const name of this.scrolledClasses) {
24
+ this.element.classList.toggle(name, lit && (!timeline || name === "is-scrolled"))
25
+ }
20
26
  })
21
27
  this.collapse.start()
22
28
  }
@@ -197,6 +197,63 @@ export function readTuning(style) {
197
197
  };
198
198
  }
199
199
 
200
+ // THE SCROLL-DRIVEN MODE (engine.css, THE SCROLL-DRIVEN COLLAPSE). A header
201
+ // carrying data-nav-timeline, from 768px, in a browser with scroll timelines,
202
+ // collapses in CSS on the compositor, and this controller stops driving --nav-p.
203
+ // What CSS cannot know it is told once, not per frame: the band that scrolls
204
+ // away, the scales, and where the header meets the window. The query is the
205
+ // stylesheet's own; the two must agree, or both would drive the header.
206
+ export var TIMELINE_QUERY = '(min-width: 768px)';
207
+
208
+ export function timelineSupported() {
209
+ return typeof CSS !== 'undefined' && !!CSS.supports && CSS.supports('animation-timeline: scroll()');
210
+ }
211
+
212
+ export function timelineOn(el, wide) {
213
+ return el.hasAttribute('data-nav-timeline') && wide && timelineSupported();
214
+ }
215
+
216
+ // The timeline's custom properties, from the header measured at both ends of
217
+ // the band table (--nav-p 0 and 1). Layout sizes only (offsetHeight and
218
+ // offsetWidth), which a transform does not change, so the reading is the same
219
+ // at any scroll position.
220
+ //
221
+ // rest, collapsed the header's height expanded and collapsed
222
+ // brand0, brand1 the brand link's height expanded and collapsed
223
+ // avatar0, avatar1 the avatar's height expanded and collapsed (0: none)
224
+ // nameW the account name's width
225
+ //
226
+ // The name scales by the avatar's ratio (the rule of 3), and the icon rail
227
+ // slides right by what the name and avatar give up, so the gaps stay even.
228
+ // Without an avatar (signed out) nothing on the right moves.
229
+ export function timelineVars({ rest, collapsed, brand0, brand1, avatar0, avatar1, nameW }) {
230
+ var avatarScale = avatar0 > 0 ? avatar1 / avatar0 : 1;
231
+ var avatarShrink = avatar0 > 0 ? Math.max(0, avatar0 - avatar1) : 0;
232
+ return {
233
+ band: Math.max(0, rest - collapsed),
234
+ brandScale: brand0 > 0 ? brand1 / brand0 : 1,
235
+ avatarScale: avatarScale,
236
+ avatarShrink: avatarShrink,
237
+ toolsShift: avatar0 > 0 ? nameW * (1 - avatarScale) + avatarShrink : 0
238
+ };
239
+ }
240
+
241
+ var TIMELINE_PROPS = ['--nav-band', '--nav-start', '--nav-brand-scale', '--nav-avatar-scale',
242
+ '--nav-avatar-shrink', '--nav-tools-shift'];
243
+
244
+ // What the timeline mode leaves on the header. stopTimeline() clears it, and
245
+ // so does the --nav-p path's start(): a Turbo snapshot taken wide and restored
246
+ // below 768px (a phone rotated, then Back) arrives still carrying it, and
247
+ // pinned_stack would subtract the stale band from --nav-h.
248
+ export function clearTimeline(el) {
249
+ TIMELINE_PROPS.forEach(function (name) { el.style.removeProperty(name); });
250
+ el.removeAttribute('data-nav-timeline-on');
251
+ }
252
+
253
+ function round(n) {
254
+ return Math.round(n * 1000) / 1000;
255
+ }
256
+
200
257
  // THE BINDING: one header, its listeners, and the frame loop. The Stimulus
201
258
  // controller and the Alpine shim both drive this, so the two cannot drift.
202
259
  // onScrolled(lit) fires when the shadow's hysteresis flips.
@@ -220,10 +277,22 @@ export class NavCollapse {
220
277
  this.lastHeight = null;
221
278
  this._observer = null;
222
279
  this._remeasure = this.remeasure.bind(this);
280
+ this.timeline = false;
281
+ this._wide = null;
282
+ this._onWide = this.onWide.bind(this);
283
+ this._onTimelineScroll = this.onTimelineScroll.bind(this);
284
+ this._onTimelineResize = this.measureTimeline.bind(this);
285
+ this._bars = null;
223
286
  }
224
287
 
225
288
  start() {
226
289
  this.stopped = false;
290
+ this._wide = window.matchMedia(TIMELINE_QUERY);
291
+ if (this._wide.addEventListener) this._wide.addEventListener('change', this._onWide);
292
+ if (timelineOn(this.el, this._wide.matches)) {
293
+ this.startTimeline();
294
+ return;
295
+ }
227
296
  this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
228
297
  // A HEADER RESTORED FROM TURBO'S SNAPSHOT CACHE arrives as it was left:
229
298
  // its scrolled classes on, and this controller's own hold still inline
@@ -232,6 +301,7 @@ export class NavCollapse {
232
301
  // under the header, 39-43px on turf-monster's contest page. So the
233
302
  // controller clears what it owns before it reads anything.
234
303
  this.onScrolled(false);
304
+ clearTimeline(this.el);
235
305
  this.el.style.marginBottom = '';
236
306
  this.el.style.removeProperty('--nav-hold');
237
307
  this.el.style.removeProperty('--nav-hold-fix');
@@ -278,6 +348,12 @@ export class NavCollapse {
278
348
  // every visit would stack another listener on window.
279
349
  stop() {
280
350
  this.stopped = true;
351
+ if (this._wide && this._wide.removeEventListener) this._wide.removeEventListener('change', this._onWide);
352
+ this._wide = null;
353
+ if (this.timeline) {
354
+ this.stopTimeline();
355
+ return;
356
+ }
281
357
  window.removeEventListener('scroll', this._onScroll);
282
358
  window.removeEventListener('resize', this._onResize);
283
359
  if (this._observer) this._observer.disconnect();
@@ -292,6 +368,103 @@ export class NavCollapse {
292
368
  }
293
369
  }
294
370
 
371
+ // Crossing 768px swaps modes: undo the one running, start the one that
372
+ // now applies.
373
+ onWide() {
374
+ if (this.stopped) return;
375
+ this.stop();
376
+ this.start();
377
+ }
378
+
379
+ startTimeline() {
380
+ this.timeline = true;
381
+ this.held = false;
382
+ this.p = 0;
383
+ // A header restored from Turbo's cache, or crossing 768px, can carry
384
+ // what the --nav-p path left inline; it goes.
385
+ this.el.style.marginBottom = '';
386
+ this.el.style.removeProperty('--nav-hold');
387
+ this.el.style.removeProperty('--nav-hold-fix');
388
+ this.el.style.removeProperty('--nav-p');
389
+ this.el.setAttribute('data-nav-timeline-on', '');
390
+ this.scrolled = false;
391
+ this.onScrolled(false);
392
+ this.measureTimeline();
393
+ this.onTimelineScroll();
394
+ window.addEventListener('scroll', this._onTimelineScroll, { passive: true });
395
+ window.addEventListener('resize', this._onTimelineResize, { passive: true });
396
+ var bars = document.querySelector('[data-studio-bar-stack]');
397
+ if (bars && typeof ResizeObserver === 'function') {
398
+ this._bars = new ResizeObserver(this._onTimelineResize);
399
+ this._bars.observe(bars);
400
+ }
401
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(this._onTimelineResize);
402
+ }
403
+
404
+ stopTimeline() {
405
+ this.timeline = false;
406
+ window.removeEventListener('scroll', this._onTimelineScroll);
407
+ window.removeEventListener('resize', this._onTimelineResize);
408
+ if (this._bars) this._bars.disconnect();
409
+ this._bars = null;
410
+ clearTimeline(this.el);
411
+ }
412
+
413
+ // Reads the header at both ends of the band table and publishes what the
414
+ // timeline needs. Runs on start, resize, font load and a bar resize; a
415
+ // scroll never calls it. Writes only a value that changed.
416
+ measureTimeline() {
417
+ if (this.stopped || !this.timeline) return;
418
+ var el = this.el;
419
+ var brand = el.querySelector('.nav-logo-link');
420
+ var avatar = el.querySelector('[data-nav-account] > [aria-hidden]');
421
+ var name = el.querySelector('[data-nav-account] > span:first-child');
422
+ var read = function () {
423
+ return {
424
+ h: el.offsetHeight,
425
+ brand: brand ? brand.offsetHeight : 0,
426
+ avatar: avatar ? avatar.offsetHeight : 0
427
+ };
428
+ };
429
+ el.style.removeProperty('--nav-p');
430
+ var open = read();
431
+ el.style.setProperty('--nav-p', '1');
432
+ var shut = read();
433
+ el.style.removeProperty('--nav-p');
434
+ var vars = timelineVars({
435
+ rest: open.h, collapsed: shut.h,
436
+ brand0: open.brand, brand1: shut.brand,
437
+ avatar0: open.avatar, avatar1: shut.avatar,
438
+ nameW: name ? name.offsetWidth : 0
439
+ });
440
+ // Where the header meets the top of the window: the bars above it in flow.
441
+ var bars = document.querySelector('[data-studio-bar-stack]');
442
+ var start = bars && (bars.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
443
+ ? bars.offsetHeight : 0;
444
+ var props = {
445
+ '--nav-band': round(vars.band) + 'px',
446
+ '--nav-start': start + 'px',
447
+ '--nav-brand-scale': String(round(vars.brandScale)),
448
+ '--nav-avatar-scale': String(round(vars.avatarScale)),
449
+ '--nav-avatar-shrink': round(vars.avatarShrink) + 'px',
450
+ '--nav-tools-shift': round(vars.toolsShift) + 'px'
451
+ };
452
+ for (var key in props) {
453
+ if (el.style.getPropertyValue(key) !== props[key]) el.style.setProperty(key, props[key]);
454
+ }
455
+ }
456
+
457
+ // The scrolled marker on the controller's own hysteresis, written only when
458
+ // it flips. No frame is scheduled: the collapse itself is the stylesheet's.
459
+ onTimelineScroll() {
460
+ if (this.stopped || !this.timeline) return;
461
+ var lit = shadowLit(this.scrolled, Math.max(0, window.scrollY));
462
+ if (lit !== this.scrolled) {
463
+ this.scrolled = lit;
464
+ this.onScrolled(lit);
465
+ }
466
+ }
467
+
295
468
  onScroll() {
296
469
  if (this.queued) return;
297
470
  this.queued = true;
@@ -288,7 +288,11 @@ export function startPinnedStack() {
288
288
  // own. That is what lets a layer come and go without anyone declaring a
289
289
  // stacking order, and it is load-bearing rather than incidental.
290
290
  var r = el.getBoundingClientRect();
291
- return { el: el, name: name, legacy: legacy, h: el.offsetHeight, bottom: Math.max(0, r.bottom) };
291
+ // A header on the scroll timeline (studio/nav_collapse) keeps its expanded
292
+ // box, and its top band scrolls away above the window; what pins is the
293
+ // compact bar, so that is its height here. An inline read, no layout.
294
+ var band = el.hasAttribute('data-nav-timeline-on') ? parseFloat(el.style.getPropertyValue('--nav-band')) || 0 : 0;
295
+ return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
292
296
  }
293
297
 
294
298
 
@@ -63,7 +63,11 @@
63
63
  through ($store.devMode on the user nav, the sidebar trigger's @click):
64
64
  Alpine initializes directives only inside a component, and the header was
65
65
  that component while navCollapse() lived on it. %>
66
- <header data-pin="nav"<% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<% end %>
66
+ <%# data-nav-timeline opts into the scroll-driven collapse (Studio.navbar_scroll_timeline,
67
+ or a scroll_timeline: local for one render; engine.css, THE SCROLL-DRIVEN
68
+ COLLAPSE). Never on a preview: the /navbar page drives --nav-p itself. %>
69
+ <% scroll_timeline = local_assigns.fetch(:scroll_timeline, Studio.navbar_scroll_timeline) %>
70
+ <header data-pin="nav"<% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<%= " data-nav-timeline" if scroll_timeline %><% end %>
67
71
  <%# top-0, a STATIC value, and never a custom property. Any bars render as
68
72
  this header's sibling in normal flow (studio/banners/_stack), so they
69
73
  already occupy their own height above it and there is nothing to
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.110.0.rc1"
2
+ VERSION = "0.111.0.rc1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -248,6 +248,17 @@ module Studio
248
248
 
249
249
  mattr_reader :sign_in_label, default: NavbarIdentity::DEFAULT_SIGN_IN_LABEL
250
250
 
251
+ # ---- Navbar scroll timeline ----
252
+ # true opts layouts/_navbar's header into the scroll-driven collapse
253
+ # (engine.css, THE SCROLL-DRIVEN COLLAPSE): from 768px, in a browser with
254
+ # scroll timelines, the header keeps one height, its top band scrolls away,
255
+ # and the shrink runs as compositor transforms with no script per frame.
256
+ # Phones and other browsers keep the --nav-p collapse. false, the default,
257
+ # renders the header byte-identical to before.
258
+ #
259
+ # config.navbar_scroll_timeline = true
260
+ mattr_accessor :navbar_scroll_timeline, default: false
261
+
251
262
  # ---- Navbar wordmark ----
252
263
  # A brand lockup image (icon plus words as one artwork) drawn in place of the
253
264
  # "Navbar Logo" and the app_name text. nil, the default, renders the navbar
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.110.0.rc1
4
+ version: 0.111.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie