@justin_evo/evo-ui 1.0.2 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justin_evo/evo-ui",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "types": "dist/index.d.ts",
package/src/Nav/Nav.tsx CHANGED
@@ -52,6 +52,9 @@ export interface EvoNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children
52
52
  onDrawerOpenChange?: (open: boolean) => void;
53
53
  /** Hide the built-in hamburger trigger (use when wiring a trigger yourself). */
54
54
  hideTrigger?: boolean;
55
+ /** Collapse to an icon-only rail: labels hide, icons center, and each row
56
+ * shows a native tooltip from its `tooltip` prop. @default false */
57
+ collapsed?: boolean;
55
58
  /** Accessible label for the <nav> landmark. @default 'Main navigation' */
56
59
  'aria-label'?: string;
57
60
  }
@@ -60,6 +63,16 @@ export interface EvoNavGroupProps {
60
63
  label: string;
61
64
  children: ReactNode;
62
65
  className?: string;
66
+ /** Render the heading as a disclosure that expands/collapses the group. */
67
+ collapsible?: boolean;
68
+ /** Uncontrolled initial open state (collapsible only). @default true */
69
+ defaultOpen?: boolean;
70
+ /** Controlled open state (collapsible only). */
71
+ open?: boolean;
72
+ /** Called when the group expands or collapses. */
73
+ onOpenChange?: (open: boolean) => void;
74
+ /** Small count chip shown after the label. */
75
+ count?: number;
63
76
  }
64
77
 
65
78
  interface EvoNavRowProps {
@@ -70,6 +83,8 @@ interface EvoNavRowProps {
70
83
  /** Render as <a href> instead of <button>. */
71
84
  href?: string;
72
85
  onClick?: (event: ReactMouseEvent | ReactKeyboardEvent) => void;
86
+ /** Tooltip text shown (via native title) when the nav is collapsed to a rail. */
87
+ tooltip?: string;
73
88
  /** Controlled expand state (only when row has SubItem children). */
74
89
  open?: boolean;
75
90
  /** Uncontrolled initial expand state. */
@@ -106,6 +121,8 @@ interface NavRootContextValue {
106
121
  closeDrawer: () => void;
107
122
  /** Root id used by the hamburger button's aria-controls. */
108
123
  rootId: string;
124
+ /** Icon-only rail mode — labels hide, rows surface a native tooltip. */
125
+ collapsed: boolean;
109
126
  }
110
127
 
111
128
  const NavRootContext = createContext<NavRootContextValue | null>(null);
@@ -160,7 +177,7 @@ function focusableRows(root: HTMLElement | null): HTMLElement[] {
160
177
  if (!root) return [];
161
178
  return Array.from(
162
179
  root.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]:not([data-disabled="true"])`),
163
- ).filter((el) => el.offsetParent !== null);
180
+ ).filter((el) => el.offsetParent !== null && el.closest('[inert]') === null);
164
181
  }
165
182
 
166
183
  function moveFocus(root: HTMLElement | null, from: HTMLElement, delta: 1 | -1 | 'first' | 'last') {
@@ -242,6 +259,7 @@ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
242
259
  active = false,
243
260
  href,
244
261
  onClick,
262
+ tooltip,
245
263
  open: openProp,
246
264
  defaultOpen = false,
247
265
  onOpenChange,
@@ -252,6 +270,7 @@ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
252
270
  liRef,
253
271
  ) {
254
272
  const rootCtx = useContext(NavRootContext);
273
+ const collapsed = rootCtx?.collapsed ?? false;
255
274
  const { depth } = useContext(NavDepthContext);
256
275
  const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
257
276
  const rowId = useId();
@@ -364,6 +383,7 @@ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
364
383
  id: rowId,
365
384
  className: rowClasses,
366
385
  style: rowStyle,
386
+ title: collapsed && tooltip ? tooltip : undefined,
367
387
  'aria-current': active ? ('page' as const) : undefined,
368
388
  'aria-expanded': expandable ? open : undefined,
369
389
  'aria-controls': expandable ? subListId : undefined,
@@ -450,21 +470,81 @@ export const EvoNavSubItem = forwardRef<HTMLLIElement, EvoNavSubItemProps>(funct
450
470
  EvoNavSubItem.displayName = 'EvoNavSubItem';
451
471
 
452
472
  export const EvoNavGroup = forwardRef<HTMLLIElement, EvoNavGroupProps>(function EvoNavGroup(
453
- { label, children, className },
473
+ { label, children, className, collapsible = false, defaultOpen = true, open: openProp, onOpenChange, count },
454
474
  ref,
455
475
  ) {
456
476
  const headingId = useId();
477
+ const panelId = `${headingId}-panel`;
478
+ const collapsed = useContext(NavRootContext)?.collapsed ?? false;
479
+ // The disclosure is interactive only when collapsible AND not in icon-rail
480
+ // mode. In the rail the accordion is meaningless, so we render a static
481
+ // heading and show the items — avoiding a focusable, label-less toggle that
482
+ // would silently mutate the (forced-open) state.
483
+ const interactive = collapsible && !collapsed;
484
+ const [open, setOpen] = useControllableState(
485
+ interactive ? openProp : true,
486
+ interactive ? defaultOpen : true,
487
+ onOpenChange,
488
+ );
489
+ const effectiveOpen = collapsed ? true : open;
490
+
491
+ // Toggle `inert` imperatively rather than through a prop: `inert` is only a
492
+ // managed React attribute as of React 19, but the peer range allows >=17.
493
+ // The DOM API works on every version and keeps the console warning-free.
494
+ const panelRef = useRef<HTMLDivElement>(null);
495
+ useEffect(() => {
496
+ const el = panelRef.current;
497
+ if (!el) return;
498
+ if (effectiveOpen) el.removeAttribute('inert');
499
+ else el.setAttribute('inert', '');
500
+ }, [effectiveOpen]);
501
+
502
+ const countChip =
503
+ count != null ? (
504
+ <span className={styles.navGroupCount} aria-hidden="true">
505
+ {count}
506
+ </span>
507
+ ) : null;
508
+
509
+ const list = (
510
+ <ul role="group" aria-labelledby={headingId} className={styles.navList}>
511
+ {children}
512
+ </ul>
513
+ );
514
+
457
515
  return (
458
- <li
459
- ref={ref}
460
- className={[styles.navGroup, className].filter(Boolean).join(' ')}
461
- >
462
- <div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
463
- {label}
464
- </div>
465
- <ul role="group" aria-labelledby={headingId} className={styles.navList}>
466
- {children}
467
- </ul>
516
+ <li ref={ref} className={[styles.navGroup, className].filter(Boolean).join(' ')}>
517
+ {interactive ? (
518
+ <button
519
+ type="button"
520
+ id={headingId}
521
+ className={[styles.navGroupLabel, styles.navGroupToggle].join(' ')}
522
+ aria-expanded={effectiveOpen}
523
+ aria-controls={panelId}
524
+ onClick={() => setOpen(!open)}
525
+ >
526
+ <span className={styles.navGroupLabelText}>{label}</span>
527
+ {countChip}
528
+ <ChevronIcon open={effectiveOpen} />
529
+ </button>
530
+ ) : (
531
+ <div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
532
+ <span className={styles.navGroupLabelText}>{label}</span>
533
+ {countChip}
534
+ </div>
535
+ )}
536
+ {interactive ? (
537
+ <div
538
+ ref={panelRef}
539
+ id={panelId}
540
+ className={styles.navGroupPanel}
541
+ data-open={effectiveOpen ? 'true' : 'false'}
542
+ >
543
+ {list}
544
+ </div>
545
+ ) : (
546
+ list
547
+ )}
468
548
  </li>
469
549
  );
470
550
  });
@@ -517,6 +597,7 @@ const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
517
597
  defaultDrawerOpen = false,
518
598
  onDrawerOpenChange,
519
599
  hideTrigger = false,
600
+ collapsed = false,
520
601
  className,
521
602
  'aria-label': ariaLabel = 'Main navigation',
522
603
  ...rest
@@ -560,14 +641,15 @@ const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
560
641
  }, [isMobile, drawerOpen, closeDrawer]);
561
642
 
562
643
  const ctxValue = useMemo<NavRootContextValue>(
563
- () => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId }),
564
- [isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId],
644
+ () => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed }),
645
+ [isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed],
565
646
  );
566
647
 
567
648
  const navClasses = [
568
649
  styles.navContainer,
569
650
  isMobile ? styles.navMobile : styles.navDesktop,
570
651
  isMobile && drawerOpen ? styles.navDrawerOpen : '',
652
+ collapsed ? styles.navCollapsed : '',
571
653
  className,
572
654
  ]
573
655
  .filter(Boolean)
@@ -502,9 +502,26 @@ export const EvoRichTextArea = forwardRef<EvoRichTextHandle, EvoRichTextAreaProp
502
502
 
503
503
  // ---- Insert image (used by paste, drop, button) ----
504
504
  const insertImageAtCaret = useCallback((src: string, alt = '') => {
505
- editorRef.current?.focus();
506
- const html = `<img src="${src.replace(/"/g, '&quot;')}" alt="${alt.replace(/"/g, '&quot;')}" />`;
507
- execCommand('insertHTML', html);
505
+ const el = editorRef.current;
506
+ if (!el) return;
507
+ el.focus();
508
+ const safeSrc = src.replace(/"/g, '&quot;');
509
+ const safeAlt = alt.replace(/"/g, '&quot;');
510
+ // Images render display:block; inserting a bare <img> leaves the caret
511
+ // beside it, which paints at the image's top-right edge. Drop a trailing
512
+ // empty paragraph and move the caret into it, so the user lands on a clean
513
+ // new line *below* the image. (Marker idiom matches unwrapBlocks above.)
514
+ execCommand('insertHTML', `<img src="${safeSrc}" alt="${safeAlt}" /><p data-evo-caret><br></p>`);
515
+ const landing = el.querySelector<HTMLParagraphElement>('p[data-evo-caret]');
516
+ if (landing) {
517
+ landing.removeAttribute('data-evo-caret');
518
+ const sel = window.getSelection();
519
+ const r = document.createRange();
520
+ r.setStart(landing, 0);
521
+ r.collapse(true);
522
+ sel?.removeAllRanges();
523
+ sel?.addRange(r);
524
+ }
508
525
  emitChange();
509
526
  }, [emitChange]);
510
527
 
@@ -32,6 +32,14 @@ export interface EvoTopNavProps
32
32
  onOpenChange?: (open: boolean) => void;
33
33
  /** Width in px below which Menu collapses into the drawer. @default 768 */
34
34
  collapseBelow?: number;
35
+ /** Staggered mount animation for the bar's contents. @default 'none' */
36
+ entrance?: 'none' | 'rise' | 'fade';
37
+ /** Pin the bar with position: sticky; top: 0. @default false */
38
+ sticky?: boolean;
39
+ /** On-scroll treatment of a sticky bar. @default 'none' */
40
+ scrollBehavior?: 'none' | 'elevate' | 'shrink' | 'hide';
41
+ /** Render a thin scroll-progress accent line along the bottom edge. @default false */
42
+ showProgress?: boolean;
35
43
  className?: string;
36
44
  }
37
45
 
@@ -74,6 +82,17 @@ export interface EvoTopNavToggleProps
74
82
  className?: string;
75
83
  }
76
84
 
85
+ export interface EvoTopNavSearchProps
86
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
87
+ /** Placeholder text shown inside the trigger. @default 'Search…' */
88
+ placeholder?: string;
89
+ /** Opt-in global hotkey, e.g. 'mod+k' (mod = ⌘ on macOS, Ctrl elsewhere). Default: none. */
90
+ shortcut?: string;
91
+ /** Override the kbd hint. @default platform-aware ⌘K / Ctrl K */
92
+ shortcutHint?: React.ReactNode;
93
+ className?: string;
94
+ }
95
+
77
96
  export interface EvoTopNavDropdownProps {
78
97
  label: React.ReactNode;
79
98
  icon?: React.ReactNode;
@@ -159,6 +178,53 @@ const useHoverCapable = () =>
159
178
  const usePrefersReducedMotion = () =>
160
179
  useMediaQuery('(prefers-reduced-motion: reduce)');
161
180
 
181
+ function useScrollState(
182
+ enabled: boolean,
183
+ behavior: 'none' | 'elevate' | 'shrink' | 'hide',
184
+ wantProgress: boolean,
185
+ ) {
186
+ const [scrolled, setScrolled] = useState(false);
187
+ const [hidden, setHidden] = useState(false);
188
+ const [progress, setProgress] = useState(0);
189
+ const lastY = useRef(0);
190
+
191
+ useEffect(() => {
192
+ if (typeof window === 'undefined' || !enabled) {
193
+ setScrolled(false);
194
+ setHidden(false);
195
+ setProgress(0);
196
+ lastY.current = 0;
197
+ return;
198
+ }
199
+ let raf = 0;
200
+ const read = () => {
201
+ raf = 0;
202
+ const doc = document.documentElement;
203
+ const y = window.scrollY || doc.scrollTop || 0;
204
+ setScrolled(y > 8);
205
+ setHidden(behavior === 'hide' ? y > lastY.current && y > 64 : false);
206
+ if (wantProgress) {
207
+ const max = doc.scrollHeight - doc.clientHeight || 1;
208
+ setProgress(Math.min(1, Math.max(0, y / max)));
209
+ }
210
+ lastY.current = y;
211
+ };
212
+ const onScroll = () => {
213
+ if (!raf) raf = requestAnimationFrame(read);
214
+ };
215
+ read();
216
+ window.addEventListener('scroll', onScroll, { passive: true });
217
+ window.addEventListener('resize', onScroll, { passive: true });
218
+ return () => {
219
+ if (raf) cancelAnimationFrame(raf);
220
+ window.removeEventListener('scroll', onScroll);
221
+ window.removeEventListener('resize', onScroll);
222
+ };
223
+ }, [enabled, behavior, wantProgress]);
224
+
225
+ return { scrolled, hidden, progress };
226
+ }
227
+
162
228
  function getFocusable(root: HTMLElement | null) {
163
229
  if (!root) return [] as HTMLElement[];
164
230
  return Array.from(
@@ -318,6 +384,23 @@ const ChevronIcon = ({ open }: { open: boolean }) => (
318
384
  </svg>
319
385
  );
320
386
 
387
+ const SearchGlyph = () => (
388
+ <svg
389
+ width="16"
390
+ height="16"
391
+ viewBox="0 0 24 24"
392
+ fill="none"
393
+ stroke="currentColor"
394
+ strokeWidth="2"
395
+ strokeLinecap="round"
396
+ strokeLinejoin="round"
397
+ aria-hidden="true"
398
+ >
399
+ <circle cx="11" cy="11" r="7" />
400
+ <path d="m20 20-3.2-3.2" />
401
+ </svg>
402
+ );
403
+
321
404
  // ============================================================================
322
405
  // EvoTopNav.Brand
323
406
  // ============================================================================
@@ -522,6 +605,69 @@ const EvoTopNavToggle = forwardRef<HTMLButtonElement, EvoTopNavToggleProps>(
522
605
  },
523
606
  );
524
607
 
608
+ // ============================================================================
609
+ // EvoTopNav.Search — presentational ⌘K quick-search trigger
610
+ // ============================================================================
611
+
612
+ const EvoTopNavSearch = forwardRef<HTMLButtonElement, EvoTopNavSearchProps>(
613
+ function EvoTopNavSearch(
614
+ { placeholder = 'Search…', shortcut, shortcutHint, className, onClick, ...rest },
615
+ forwardedRef,
616
+ ) {
617
+ const localRef = useRef<HTMLButtonElement | null>(null);
618
+ const setRef = (node: HTMLButtonElement | null) => {
619
+ localRef.current = node;
620
+ if (typeof forwardedRef === 'function') forwardedRef(node);
621
+ else if (forwardedRef)
622
+ (forwardedRef as React.RefObject<HTMLButtonElement | null>).current = node;
623
+ };
624
+
625
+ // Platform-aware hint resolved after mount to avoid SSR hydration mismatch.
626
+ const [autoHint, setAutoHint] = useState<React.ReactNode>(null);
627
+ useEffect(() => {
628
+ if (shortcutHint !== undefined) return;
629
+ const platform =
630
+ (typeof navigator !== 'undefined' &&
631
+ (navigator.platform || navigator.userAgent)) || '';
632
+ setAutoHint(/Mac|iPhone|iPad|iPod/.test(platform) ? '⌘K' : 'Ctrl K');
633
+ }, [shortcutHint]);
634
+ const hint = shortcutHint !== undefined ? shortcutHint : autoHint;
635
+
636
+ // Opt-in global hotkey → dispatch a real click so onClick fires naturally.
637
+ useEffect(() => {
638
+ if (!shortcut) return;
639
+ const parts = shortcut.toLowerCase().split('+').map((p) => p.trim());
640
+ const wantMod = parts.some((p) => ['mod', 'cmd', 'meta', 'ctrl', 'control'].includes(p));
641
+ const key = parts[parts.length - 1];
642
+ const handler = (e: KeyboardEvent) => {
643
+ const mod = e.metaKey || e.ctrlKey;
644
+ if ((!wantMod || mod) && e.key.toLowerCase() === key) {
645
+ e.preventDefault();
646
+ localRef.current?.click();
647
+ }
648
+ };
649
+ document.addEventListener('keydown', handler);
650
+ return () => document.removeEventListener('keydown', handler);
651
+ }, [shortcut]);
652
+
653
+ return (
654
+ <button
655
+ ref={setRef}
656
+ className={cn(styles.topNavSearch, className)}
657
+ onClick={onClick}
658
+ {...rest}
659
+ type="button"
660
+ >
661
+ <span className={styles.topNavSearchIcon} aria-hidden="true">
662
+ <SearchGlyph />
663
+ </span>
664
+ <span className={styles.topNavSearchText}>{placeholder}</span>
665
+ {hint != null && <kbd className={styles.topNavSearchKbd}>{hint}</kbd>}
666
+ </button>
667
+ );
668
+ },
669
+ );
670
+
525
671
  // ============================================================================
526
672
  // EvoTopNav.Dropdown
527
673
  // ============================================================================
@@ -812,7 +958,12 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
812
958
  defaultOpen = false,
813
959
  onOpenChange,
814
960
  collapseBelow = 768,
961
+ entrance = 'none',
962
+ sticky = false,
963
+ scrollBehavior = 'none',
964
+ showProgress = false,
815
965
  className,
966
+ style,
816
967
  ...rest
817
968
  },
818
969
  ref,
@@ -825,6 +976,12 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
825
976
 
826
977
  const isCollapsed = useIsCollapsed(collapseBelow);
827
978
  const reducedMotion = usePrefersReducedMotion();
979
+ const scrollEnabled = scrollBehavior !== 'none' || showProgress;
980
+ const { scrolled, hidden, progress } = useScrollState(scrollEnabled, scrollBehavior, showProgress);
981
+ const animateEntrance = entrance !== 'none' && !reducedMotion;
982
+ const mergedStyle: React.CSSProperties = showProgress
983
+ ? ({ ...style, ['--evo-topnav-progress' as string]: progress } as React.CSSProperties)
984
+ : (style as React.CSSProperties);
828
985
 
829
986
  const [toggleCount, setToggleCount] = useState(0);
830
987
  const registerToggle = useCallback(() => {
@@ -944,15 +1101,24 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
944
1101
  ref={ref}
945
1102
  className={cn(
946
1103
  styles.topNav,
1104
+ sticky && styles.topNavSticky,
947
1105
  drawerActive && styles.topNavDrawerOpen,
948
1106
  reducedMotion && styles.topNavReducedMotion,
949
1107
  className,
950
1108
  )}
1109
+ style={mergedStyle}
951
1110
  data-collapsed={isCollapsed || undefined}
952
1111
  data-drawer-open={drawerActive || undefined}
1112
+ data-entrance={animateEntrance ? entrance : undefined}
1113
+ data-scroll={scrollBehavior !== 'none' ? scrollBehavior : undefined}
1114
+ data-scrolled={scrolled || undefined}
1115
+ data-hidden={hidden || undefined}
953
1116
  {...rest}
954
1117
  >
955
1118
  <div className={styles.topNavInner}>{children}</div>
1119
+ {showProgress && (
1120
+ <span className={styles.topNavProgress} aria-hidden="true" />
1121
+ )}
956
1122
  {drawerActive && (
957
1123
  <div
958
1124
  className={styles.topNavBackdrop}
@@ -972,6 +1138,7 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
972
1138
  Item: typeof EvoTopNavItem;
973
1139
  Actions: typeof EvoTopNavActions;
974
1140
  Toggle: typeof EvoTopNavToggle;
1141
+ Search: typeof EvoTopNavSearch;
975
1142
  Dropdown: typeof EvoTopNavDropdown;
976
1143
  DropdownItem: typeof EvoTopNavDropdownItem;
977
1144
  };
@@ -981,6 +1148,7 @@ EvoTopNavMenu.displayName = 'EvoTopNav.Menu';
981
1148
  EvoTopNavItem.displayName = 'EvoTopNav.Item';
982
1149
  EvoTopNavActions.displayName = 'EvoTopNav.Actions';
983
1150
  EvoTopNavToggle.displayName = 'EvoTopNav.Toggle';
1151
+ EvoTopNavSearch.displayName = 'EvoTopNav.Search';
984
1152
  EvoTopNavDropdown.displayName = 'EvoTopNav.Dropdown';
985
1153
  EvoTopNavDropdownItem.displayName = 'EvoTopNav.DropdownItem';
986
1154
  (EvoTopNav as { displayName?: string }).displayName = 'EvoTopNav';
@@ -990,5 +1158,6 @@ EvoTopNav.Menu = EvoTopNavMenu;
990
1158
  EvoTopNav.Item = EvoTopNavItem;
991
1159
  EvoTopNav.Actions = EvoTopNavActions;
992
1160
  EvoTopNav.Toggle = EvoTopNavToggle;
1161
+ EvoTopNav.Search = EvoTopNavSearch;
993
1162
  EvoTopNav.Dropdown = EvoTopNavDropdown;
994
1163
  EvoTopNav.DropdownItem = EvoTopNavDropdownItem;
@@ -42,7 +42,7 @@
42
42
 
43
43
  &::after {
44
44
  opacity: 1;
45
- transform: rotate(45deg) scale(1);
45
+ transform: translate(-50%, -60%) rotate(45deg) scale(1);
46
46
  }
47
47
  }
48
48
 
@@ -94,15 +94,18 @@
94
94
  &::after {
95
95
  content: '';
96
96
  position: absolute;
97
- top: 0px;
98
- left: 3px;
97
+ top: 50%;
98
+ left: 50%;
99
99
  width: 5px;
100
- height: 8px;
100
+ height: 9px;
101
101
  border: 2px solid $evo-primary-fg;
102
102
  border-top: none;
103
103
  border-left: none;
104
104
  opacity: 0;
105
- transform: rotate(45deg) scale(0.5);
105
+ // Centered via translate (parent's flex-centering can't reach an absolute
106
+ // child). -60% Y nudges the rotated "L" optically into the middle. The
107
+ // centering translate is folded into both states so the scale stays put.
108
+ transform: translate(-50%, -60%) rotate(45deg) scale(0.5);
106
109
  transition: opacity $transition-fast, transform $transition-fast;
107
110
  }
108
111
  }