maverick-wave 5.8.0 → 5.11.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.
Files changed (46) hide show
  1. package/.claude/skills/mw-maverick-wave/SKILL.md +26 -14
  2. package/.claude/skills/mw-maverick-wave/examples/angular-services.md +10 -1
  3. package/.claude/skills/mw-maverick-wave/examples/static-landing-page.md +4 -3
  4. package/.claude/skills/mw-maverick-wave/references/components.md +23 -9
  5. package/.claude/skills/mw-maverick-wave/references/javascript.md +14 -14
  6. package/.claude/skills/mw-maverick-wave/references/layout.md +29 -9
  7. package/.claude/skills/mw-maverick-wave/references/theming.md +48 -25
  8. package/CHANGELOG.md +23 -0
  9. package/README.md +41 -24
  10. package/index.html +356 -20
  11. package/maverick-wave.min.css +61 -5
  12. package/maverick-wave.min.js +1 -1
  13. package/package.json +2 -2
  14. package/scripts/verify.js +7 -1
  15. package/src/js/main.js +27 -7
  16. package/src/partials/palette-container.html +105 -65
  17. package/src/partials/parallax-container.html +20 -1
  18. package/src/partials/preview-container.html +136 -0
  19. package/src/partials/utilities-container.html +38 -0
  20. package/src/scss/abstracts/_functions.scss +31 -3
  21. package/src/scss/abstracts/_mixins.scss +62 -12
  22. package/src/scss/abstracts/_variables.scss +47 -21
  23. package/src/scss/base/_base.scss +97 -8
  24. package/src/scss/components/_badge.scss +1 -1
  25. package/src/scss/components/_buttons.scss +1 -1
  26. package/src/scss/components/_cards.scss +23 -14
  27. package/src/scss/components/_dropdown.scss +58 -1
  28. package/src/scss/components/_kanban.scss +10 -9
  29. package/src/scss/components/_lang-switch.scss +1 -1
  30. package/src/scss/components/_localhost-indicator.scss +1 -1
  31. package/src/scss/components/_modals.scss +13 -1
  32. package/src/scss/components/_pricing.scss +1 -1
  33. package/src/scss/components/_progress.scss +9 -5
  34. package/src/scss/components/_skeleton.scss +1 -1
  35. package/src/scss/components/_spinners.scss +5 -5
  36. package/src/scss/components/_stepper.scss +4 -1
  37. package/src/scss/components/_tabs.scss +28 -8
  38. package/src/scss/components/_tiles.scss +41 -16
  39. package/src/scss/components/_timelines.scss +13 -5
  40. package/src/scss/layout/_header-reveal.scss +5 -5
  41. package/src/scss/layout/_main.scss +1 -1
  42. package/src/scss/layout/_parallax.scss +65 -14
  43. package/src/scss/utilities/_elevation.scss +23 -0
  44. package/src/scss/utilities/_reveal.scss +5 -5
  45. package/src/scss/utilities/_touch-targets.scss +1 -1
  46. package/src/scss/utilities/_variants.scss +74 -7
package/README.md CHANGED
@@ -23,7 +23,7 @@ The result is a framework that balances utility with simplicity, offering develo
23
23
  - A five-step elevation ramp and a motion scale, so every shadow and every transition in the framework comes from one place
24
24
  - Mobile as a first-class target: 44px touch targets on a coarse pointer, modals that become bottom sheets, press states on everything, and hover effects that do not latch after a tap
25
25
  - Easy Customization via CSS Custom Properties
26
- - Built-in Light & Dark Mode with optional theme switching
26
+ - Built-in Light & Dark Mode - follows the OS by default, switchable per reader
27
27
  - SCSS Source Files for advanced customization (Dart Sass, `@use`/`@forward`)
28
28
  - Modals as `<div>` or as `<dialog>` - the latter brings the focus trap, Escape and the inert background from the platform
29
29
  - Native form validation is styled through `:user-invalid`, alongside the class-driven error states for reactive forms
@@ -42,12 +42,12 @@ The result is a framework that balances utility with simplicity, offering develo
42
42
  <title>My MaverickWave Project</title>
43
43
  <link
44
44
  rel="stylesheet"
45
- href="https://cdn.jsdelivr.net/npm/maverick-wave@5.8.0/maverick-wave.min.css"
45
+ href="https://cdn.jsdelivr.net/npm/maverick-wave@5.11.0/maverick-wave.min.css"
46
46
  />
47
47
  </head>
48
48
  <body>
49
49
  <!-- Your content here -->
50
- <script src="https://cdn.jsdelivr.net/npm/maverick-wave@5.8.0/maverick-wave.min.js"></script>
50
+ <script src="https://cdn.jsdelivr.net/npm/maverick-wave@5.11.0/maverick-wave.min.js"></script>
51
51
  </body>
52
52
  </html>
53
53
  ```
@@ -130,7 +130,8 @@ assembled rather than designed.
130
130
  :root {
131
131
  /* Elevation. Each level is two shadows - a tight contact layer that gives
132
132
  the box weight, and a wide ambient one that says how high it floats.
133
- Declared per theme, because the tones they are mixed from differ. */
133
+ One ramp for both themes; the theme arrives through the shadow tokens
134
+ the levels are built from. */
134
135
  --mw-elevation-1: /* resting: inputs, tags, small controls */;
135
136
  --mw-elevation-2: /* raised: cards and panels at rest */;
136
137
  --mw-elevation-3: /* floating: a card under the pointer */;
@@ -347,32 +348,42 @@ layer of its own:
347
348
 
348
349
  ### Site-wide variants
349
350
 
350
- Twelve classes on `<html>` retune the whole look without touching markup or
351
+ Fifteen classes on `<html>` retune the whole look without touching markup or
351
352
  rebuilding. They stack.
352
353
 
353
- | Class | Effect |
354
- | ------------------- | ----------------------------------------------------------------------- |
355
- | `mw-corners-even` | Drops the surface signature - every panel becomes an evenly rounded box |
356
- | `mw-accent-single` | One brand colour instead of two; `mw-btn-secondary` turns outline |
357
- | `mw-shadows-flat` | Elevation 1-3 to `none`; dropdown and modal keep theirs |
358
- | `mw-surfaces-flush` | Cards, panels and footer on the page colour, held by their border |
359
- | `mw-hover-static` | No hover travels - lifts and image zooms go, colour still responds |
360
- | `mw-scroll-static` | No scroll entrance - `mw-reveal` blocks sit where they land |
361
- | `mw-sections-plain` | The hatch behind `mw-section-alternate` collapses into the page colour |
362
- | `mw-headings-caps` | `h1`-`h3` in capitals |
363
- | `mw-btn-pill` | Fully rounded buttons; form fields keep their radius |
364
- | `mw-btn-square` | Buttons cut to a hard corner while the page keeps its radius |
365
- | `mw-btn-glass` | Filled buttons become a translucent wash with a lit top edge |
366
- | `mw-btn-tactile` | Filled buttons stand on a darker edge and sink onto it when pressed |
367
-
368
- Five properties do the rest: `--mw-radius-scale` multiplies every radius (`0`
354
+ | Class | Effect |
355
+ | -------------------- | ----------------------------------------------------------------------- |
356
+ | `mw-corners-even` | Drops the surface signature - every panel becomes an evenly rounded box |
357
+ | `mw-accent-single` | One brand colour instead of two; `mw-btn-secondary` turns outline |
358
+ | `mw-shadows-flat` | Elevation 1-3 to `none`; dropdown and modal keep theirs |
359
+ | `mw-surfaces-flush` | Cards, panels and footer on the page colour, held by their border |
360
+ | `mw-hover-static` | No hover travels - lifts and image zooms go, colour still responds |
361
+ | `mw-scroll-static` | No scroll entrance - `mw-reveal` blocks sit where they land |
362
+ | `mw-sections-plain` | The hatch behind `mw-section-alternate` collapses into the page colour |
363
+ | `mw-headings-caps` | `h1`-`h3` in capitals |
364
+ | `mw-links-underline` | Links underlined at rest; the stroke thickens on hover |
365
+ | `mw-btn-pill` | Fully rounded buttons; form fields keep their radius |
366
+ | `mw-btn-square` | Buttons cut to a hard corner while the page keeps its radius |
367
+ | `mw-btn-glass` | Filled buttons become a translucent wash with a lit top edge |
368
+ | `mw-btn-tactile` | Filled buttons stand on a darker edge and sink onto it when pressed |
369
+ | `mw-density-compact` | Less padding in cards, panels and controls; type stays put |
370
+ | `mw-density-roomy` | More of the same |
371
+
372
+ Six properties do the rest: `--mw-radius-scale` multiplies every radius (`0`
369
373
  squares the framework off), `--mw-root-font-size` moves the whole rem scale,
370
- plus `--mw-font-family-heading`, `--mw-container-width` and
374
+ `--mw-motion-scale` the tempo of every transition, plus
375
+ `--mw-font-family-heading`, `--mw-container-width` and
371
376
  `--mw-section-padding-block`.
372
377
 
378
+ The [showcase](https://maverick-wave.m1well.com) has a picker for all of them,
379
+ and the URL carries whatever is set - send that link and the next person opens
380
+ the page exactly as you left it. The panel prints the same setup as markup and
381
+ CSS to copy into a project.
382
+
373
383
  A variant of your own that retunes a theme-bound token has to target
374
384
  `:root.your-class` **and** `:root.your-class .mw-theme-light` - the light theme
375
- re-declares those on `<body>` and would shadow a root-only value.
385
+ re-declares those on `<body>` and would shadow a root-only value. The elevation
386
+ tokens are the exception and need only `:root`.
376
387
 
377
388
  ### SCSS Source
378
389
 
@@ -458,7 +469,7 @@ above):
458
469
  > change detection. The behaviors it covers (accordion, tabs, modal close, mobile
459
470
  > nav, scroll spy, theme toggle, progress bars, sliders, alerts, galleries) are a
460
471
  > few lines each in a component - the framework's state classes are the whole
461
- > contract. Theme switching, for example, is a single class on `<body>`:
472
+ > contract. Theme switching, for example, is a pair of classes on `<body>`:
462
473
 
463
474
  ```typescript
464
475
  // theme.service.ts - mw-theme-switching suppresses the transitions the flip
@@ -467,10 +478,16 @@ const root = document.documentElement;
467
478
 
468
479
  root.classList.add('mw-theme-switching');
469
480
  document.body.classList.toggle('mw-theme-light', isLight);
481
+ // the explicit counterpart - without it a dark choice on a light machine
482
+ // falls back to the OS preference
483
+ document.body.classList.toggle('mw-theme-dark', !isLight);
470
484
  void root.offsetHeight;
471
485
  root.classList.remove('mw-theme-switching');
472
486
  ```
473
487
 
488
+ With neither class the page follows `prefers-color-scheme`, so an app that has
489
+ nothing stored yet can simply leave both off.
490
+
474
491
  The `mw-field` wrapper groups label, control, hint and error. Bind the error
475
492
  state yourself - the framework does not style Angular's `ng-invalid` /
476
493
  `ng-touched` classes:
package/index.html CHANGED
@@ -97,6 +97,21 @@
97
97
  );
98
98
  }
99
99
 
100
+ /* The travel has to sit on the animated section, the height on the block
101
+ inside it */
102
+ .parallax-band-demo {
103
+ --mw-parallax-slow-travel: 60vh;
104
+ }
105
+
106
+ .parallax-band-demo .mw-parallax {
107
+ min-height: 75vh;
108
+ }
109
+
110
+ /* Reuses the hero picture through the token, not a second download */
111
+ .parallax-band-demo .mw-parallax-media {
112
+ background-image: var(--mw-hero-background);
113
+ }
114
+
100
115
  /* A calendar fills its container; across the full page that stretches the
101
116
  cells to 150px, so the demos get a realistic column instead */
102
117
  .calendar-demo {
@@ -292,6 +307,26 @@
292
307
  border-top: 1px solid var(--mw-border);
293
308
  }
294
309
 
310
+ /* Splits the list by where a switch shows up, because half of them act on
311
+ parts of the page the preview does not contain */
312
+ .variant-group {
313
+ margin: 20px 0 2px;
314
+ font-size: 0.78rem;
315
+ font-weight: 600;
316
+ color: var(--mw-text-color);
317
+ }
318
+
319
+ .variant-group:first-child {
320
+ margin-top: 4px;
321
+ }
322
+
323
+ .variant-group-hint {
324
+ margin: 0 0 4px;
325
+ font-size: 0.68rem;
326
+ line-height: 1.35;
327
+ color: var(--mw-text-muted-color);
328
+ }
329
+
295
330
  .variant-row {
296
331
  display: flex;
297
332
  align-items: center;
@@ -369,14 +404,129 @@
369
404
  background: var(--mw-primary-accent-text-color);
370
405
  }
371
406
 
407
+ /* The sticky bar would otherwise sit on the section title it jumps to */
408
+ #preview {
409
+ scroll-margin-top: calc(var(--mw-header-height) + 1rem);
410
+ }
411
+
412
+ .preview-head {
413
+ display: flex;
414
+ align-items: flex-start;
415
+ justify-content: space-between;
416
+ gap: 1rem;
417
+ margin-bottom: 1.5rem;
418
+ }
419
+
420
+ .preview-title {
421
+ margin: 0 0 0.35rem;
422
+ }
423
+
424
+ .preview-lede {
425
+ margin: 0;
426
+ max-inline-size: 60ch;
427
+ }
428
+
429
+ /* The panel is fixed over the right of the page, flush with the container
430
+ edge. Below xl there is no room to dodge it and it simply covers part of
431
+ the preview; from there the preview keeps to the strip beside it,
432
+ because a switch that moves something hidden behind the panel
433
+ demonstrates nothing. */
434
+ @media (width >= 1200px) {
435
+ body:has(.palette-panel:not([hidden])) #preview > .mw-container {
436
+ padding-inline-end: 460px;
437
+ }
438
+ }
439
+
440
+ /* Both wrap rather than squeezing a field to nothing on a phone */
441
+ .preview-row,
442
+ .preview-actions {
443
+ display: flex;
444
+ flex-wrap: wrap;
445
+ align-items: center;
446
+ gap: 0.75rem;
447
+ }
448
+
449
+ .preview-row .mw-input {
450
+ flex: 1 1 12rem;
451
+ }
452
+
453
+ /* The one thing here that keeps moving without being touched, so Motion
454
+ tempo has something to show that a hover state cannot */
455
+ .preview-status {
456
+ display: flex;
457
+ align-items: center;
458
+ gap: 14px;
459
+ margin: 0 0 1.25rem;
460
+ font-size: 0.82rem;
461
+ color: var(--mw-text-muted-color);
462
+ }
463
+
464
+ .preview-status strong {
465
+ display: block;
466
+ color: var(--mw-text-color);
467
+ font-weight: 500;
468
+ }
469
+
470
+ .preview-status small {
471
+ font-size: 0.75rem;
472
+ }
473
+
474
+ .preview-row .mw-select {
475
+ flex: 0 1 14rem;
476
+ }
477
+
478
+ /* The spacing sits on the row and not on the label: the button is taller
479
+ than the caption beside it, so a margin under the label leaves the
480
+ button itself resting on the box */
481
+ .variant-setup-head {
482
+ display: flex;
483
+ align-items: baseline;
484
+ justify-content: space-between;
485
+ gap: 10px;
486
+ margin: 14px 0 10px;
487
+ }
488
+
372
489
  .variant-setup-label {
373
490
  display: block;
374
- margin-top: 14px;
375
- margin-bottom: 5px;
491
+ margin: 0;
376
492
  font-size: 0.7rem;
377
493
  color: var(--mw-text-muted-color);
378
494
  }
379
495
 
496
+ /* The one control here that leads somewhere - everything else in the
497
+ panel only changes what is on screen. Carries the brand colour so it
498
+ reads as the way out rather than as one more switch. */
499
+ .variant-share {
500
+ display: inline-flex;
501
+ align-items: center;
502
+ gap: 6px;
503
+ flex-shrink: 0;
504
+ padding: 5px 12px;
505
+ border-radius: 999px;
506
+ border: 1px solid var(--mw-primary-color);
507
+ background: var(--mw-primary-color);
508
+ color: var(--mw-primary-accent-text-color);
509
+ font: inherit;
510
+ font-size: 0.72rem;
511
+ font-weight: 500;
512
+ cursor: pointer;
513
+ transition: var(--mw-transition-fast);
514
+ }
515
+
516
+ .variant-share:hover {
517
+ background: var(--mw-primary-color-hover, var(--mw-primary-color));
518
+ transform: translateY(-1px);
519
+ }
520
+
521
+ .variant-share:active {
522
+ transform: translateY(0);
523
+ }
524
+
525
+ .variant-share:focus-visible {
526
+ outline: var(--mw-focus-ring-width) solid var(--mw-focus-ring-color);
527
+ outline-offset: var(--mw-focus-ring-offset);
528
+ }
529
+
380
530
  .variant-setup {
381
531
  display: block;
382
532
  width: 100%;
@@ -401,7 +551,7 @@
401
551
  <a class="mw-skip-link" href="#main">Skip to content</a>
402
552
 
403
553
  <!-- Header -->
404
- <header class="mw-header mw-header-reveal mw-localhost-indicator-activated">
554
+ <header class="mw-header mw-localhost-indicator-activated">
405
555
  <div class="mw-container">
406
556
  @@include('./src/partials/header-container.html')
407
557
  </div>
@@ -566,6 +716,22 @@
566
716
  </div>
567
717
  </section>
568
718
 
719
+ <!-- Parallax band -->
720
+ <section class="mw-parallax-slow parallax-band-demo">
721
+ <div class="mw-parallax mw-parallax-dimmed">
722
+ <div class="mw-parallax-media"></div>
723
+ <div class="mw-parallax-content mw-text-center">
724
+ <div class="mw-container">
725
+ <h2>The page rides over the picture</h2>
726
+ <p class="mw-mb-0">
727
+ This band travels at a quarter of your scrolling. The components
728
+ close in from below.
729
+ </p>
730
+ </div>
731
+ </div>
732
+ </div>
733
+ </section>
734
+
569
735
  <!-- Components section -->
570
736
  <section id="components" class="mw-section">
571
737
  <div class="mw-container">
@@ -823,6 +989,18 @@
823
989
  @@include('./src/partials/history-container.html')
824
990
  </div>
825
991
  </section>
992
+ <!-- Preview section, showcase only - see the partial -->
993
+ <section id="preview" class="mw-section">
994
+ <div class="mw-container">
995
+ <h2 class="mw-section-title">Preview</h2>
996
+ <p class="mw-section-intro">
997
+ One page-shaped example, so the variant switches in the palette
998
+ panel have something to act on that is not a component gallery. Open
999
+ the panel and the page lands here.
1000
+ </p>
1001
+ @@include('./src/partials/preview-container.html')
1002
+ </div>
1003
+ </section>
826
1004
  </main>
827
1005
 
828
1006
  <!-- Footer -->
@@ -1013,9 +1191,21 @@
1013
1191
  input.addEventListener('change', settle);
1014
1192
  });
1015
1193
 
1194
+ const preview = document.getElementById('preview');
1195
+
1016
1196
  const toggle = (open) => {
1017
1197
  panel.hidden = !open;
1018
1198
  toggleBtn.setAttribute('aria-expanded', String(open));
1199
+
1200
+ // Or the switches act on whatever section the reader is parked at,
1201
+ // which is one component at a time and says little about a look.
1202
+ //
1203
+ // `instant` against the smooth default in the reset: the preview sits
1204
+ // at the end of a page some 80 000px long, and over that distance a
1205
+ // smooth scroll either crawls through every section on the way or, as
1206
+ // here, never starts at all.
1207
+ if (open)
1208
+ preview?.scrollIntoView({ block: 'start', behavior: 'instant' });
1019
1209
  };
1020
1210
 
1021
1211
  toggleBtn.addEventListener('click', (event) => {
@@ -1116,6 +1306,58 @@
1116
1306
  const root = document.documentElement;
1117
1307
  const controls = [...list.querySelectorAll('input, select')];
1118
1308
 
1309
+ // Built on every render, put into the address bar by nobody: a setup
1310
+ // written to the URL as you go comes back on the next reload, and then
1311
+ // the showcase opens in someone else's look for good. Copy link is the
1312
+ // one way out of here, and what it hands over is this.
1313
+ //
1314
+ // URLSearchParams and not a hand-rolled separator: a value like the
1315
+ // heading font or the page width is full of commas, quotes and brackets,
1316
+ // and escaping those by hand is where this kind of thing breaks.
1317
+ let shareUrl = location.origin + location.pathname;
1318
+
1319
+ const buildShareUrl = (classes, dropped, pairs) => {
1320
+ const params = new URLSearchParams();
1321
+ if (classes.length) params.set('c', classes.join(' '));
1322
+ if (dropped.length) params.set('d', dropped.join(' '));
1323
+ pairs.forEach(([prop, value]) => params.set(prop, value));
1324
+
1325
+ const query = params.toString();
1326
+ return (
1327
+ location.origin + location.pathname + (query ? '#' + query : '')
1328
+ );
1329
+ };
1330
+
1331
+ // Back into the shape load() reads, so the link and a pasted setup go
1332
+ // through one parser rather than two that can drift apart. The target in
1333
+ // front of `: no` is only there to make the line read like the printed
1334
+ // one - load() matches on the class name alone.
1335
+ const readHash = () => {
1336
+ const params = new URLSearchParams(location.hash.slice(1));
1337
+ const lines = [];
1338
+
1339
+ const classes = params.get('c');
1340
+ if (classes) lines.push('html class="' + classes + '"');
1341
+
1342
+ (params.get('d') || '')
1343
+ .split(/\s+/)
1344
+ .filter(Boolean)
1345
+ .forEach((name) => lines.push('markup: no ' + name));
1346
+
1347
+ const pairs = [...params.entries()].filter(([key]) =>
1348
+ key.startsWith('--')
1349
+ );
1350
+ if (pairs.length) {
1351
+ lines.push(
1352
+ ':root {',
1353
+ ...pairs.map(([key, value]) => ` ${key}: ${value};`),
1354
+ '}'
1355
+ );
1356
+ }
1357
+
1358
+ return lines.length ? lines.join('\n') : null;
1359
+ };
1360
+
1119
1361
  const isOn = (input) =>
1120
1362
  input.hasAttribute('data-variant-invert')
1121
1363
  ? !input.checked
@@ -1127,8 +1369,10 @@
1127
1369
  if (document.activeElement === setup) return;
1128
1370
 
1129
1371
  const classes = [];
1372
+ // Unformatted, because both the box and the link are written from
1373
+ // these and formatting only one of the two ways is a bug waiting
1130
1374
  const notes = [];
1131
- const props = [];
1375
+ const pairs = [];
1132
1376
 
1133
1377
  // The palette is part of the setup, and it is printed first because a
1134
1378
  // project sets its brand colours before it retunes anything else
@@ -1141,7 +1385,7 @@
1141
1385
  ['--mw-dark-page-background', palette.dark],
1142
1386
  ['--mw-light-page-background', palette.light],
1143
1387
  ].forEach(([prop, value]) => {
1144
- if (value) props.push(` ${prop}: ${value};`);
1388
+ if (value) pairs.push([prop, value]);
1145
1389
  });
1146
1390
 
1147
1391
  controls.forEach((control) => {
@@ -1152,9 +1396,7 @@
1152
1396
  if (control.hasAttribute('data-variant-classes')) {
1153
1397
  classes.push(control.value);
1154
1398
  } else {
1155
- props.push(
1156
- ` ${control.dataset.variantProp}: ${control.value};`
1157
- );
1399
+ pairs.push([control.dataset.variantProp, control.value]);
1158
1400
  }
1159
1401
  return;
1160
1402
  }
@@ -1164,7 +1406,7 @@
1164
1406
  const target = control.dataset.variantTarget;
1165
1407
  if (target) {
1166
1408
  if (!isOn(control)) {
1167
- notes.push(target + ': no ' + control.dataset.variantClass);
1409
+ notes.push([target, control.dataset.variantClass]);
1168
1410
  }
1169
1411
  } else if (isOn(control)) {
1170
1412
  classes.push(control.dataset.variantClass);
@@ -1175,12 +1417,24 @@
1175
1417
  if (classes.length) {
1176
1418
  lines.push('html class="' + classes.join(' ') + '"');
1177
1419
  }
1178
- lines.push(...notes);
1179
- if (props.length) lines.push(':root {', ...props, '}');
1420
+ lines.push(...notes.map(([target, name]) => `${target}: no ${name}`));
1421
+ if (pairs.length) {
1422
+ lines.push(
1423
+ ':root {',
1424
+ ...pairs.map(([prop, value]) => ` ${prop}: ${value};`),
1425
+ '}'
1426
+ );
1427
+ }
1180
1428
 
1181
1429
  setup.value = lines.length
1182
1430
  ? lines.join('\n')
1183
1431
  : 'Default setup - nothing to change.';
1432
+
1433
+ shareUrl = buildShareUrl(
1434
+ classes,
1435
+ notes.map(([, name]) => name),
1436
+ pairs
1437
+ );
1184
1438
  };
1185
1439
 
1186
1440
  const optionClasses = (control) =>
@@ -1252,21 +1506,103 @@
1252
1506
  // Picking a palette repaints the page, and the box has to say so
1253
1507
  window.mwRefreshSetup = render;
1254
1508
 
1255
- setup.addEventListener('input', () => load(setup.value));
1509
+ setup.addEventListener('input', () => inPlace(() => load(setup.value)));
1256
1510
  // Normalises what was pasted back into the shape render() writes
1257
1511
  setup.addEventListener('blur', render);
1258
1512
 
1513
+ // Type and spacing switches change the height of all 80 000px of page
1514
+ // above the preview as well, so the section slides out from under
1515
+ // whoever is watching it - Text size alone moves it by 16 000px. Hold
1516
+ // its top edge where it was and the change happens in place.
1517
+ //
1518
+ // Only while the preview is on screen: correcting the scroll for someone
1519
+ // parked up in the component sections would move *them* by those 16 000
1520
+ // instead. `instant` because the smooth default would animate the
1521
+ // correction as a scroll of its own.
1522
+ const previewSection = document.getElementById('preview');
1523
+
1524
+ const inPlace = (change) => {
1525
+ const box = previewSection?.getBoundingClientRect();
1526
+ const watching =
1527
+ box && box.bottom > 0 && box.top < window.innerHeight;
1528
+ if (!watching) return change();
1529
+
1530
+ const before = box.top;
1531
+ change();
1532
+ const shift = previewSection.getBoundingClientRect().top - before;
1533
+ if (shift) window.scrollBy({ top: shift, behavior: 'instant' });
1534
+ };
1535
+
1259
1536
  controls.forEach((control) =>
1260
- control.addEventListener('change', () => {
1261
- apply(control);
1262
- render();
1263
- })
1537
+ control.addEventListener('change', () =>
1538
+ inPlace(() => {
1539
+ apply(control);
1540
+ render();
1541
+ })
1542
+ )
1264
1543
  );
1265
1544
 
1266
- // Applied on load too, so a browser that restored a checked box after a
1267
- // reload does not leave the page and the panel disagreeing
1268
- controls.forEach(apply);
1269
- render();
1545
+ const shared = readHash();
1546
+ if (shared) {
1547
+ // Drives the switches, which drive the page - then render() normalises
1548
+ // the box and writes the link back in the order it prints
1549
+ load(shared);
1550
+ render();
1551
+ } else {
1552
+ // Without a link the showcase opens in its own look, every time. That
1553
+ // has to be spelled out: Firefox restores form state across a reload,
1554
+ // so the switches would otherwise come back set the way the last visit
1555
+ // left them, and the page with them.
1556
+ //
1557
+ // The markup is the one source for that starting state - the written
1558
+ // defaults for a plain switch, and for a targeted one whether the
1559
+ // class it stands for is actually on the element it names. A header
1560
+ // shipped without mw-header-reveal keeps it off.
1561
+ controls.forEach((control) => {
1562
+ const target = control.dataset.variantTarget;
1563
+ const el = target && document.querySelector(target);
1564
+
1565
+ if (el) {
1566
+ const present = el.classList.contains(
1567
+ control.dataset.variantClass
1568
+ );
1569
+ control.checked = control.hasAttribute('data-variant-invert')
1570
+ ? !present
1571
+ : present;
1572
+ } else if (control.type === 'checkbox') {
1573
+ control.checked = control.defaultChecked;
1574
+ } else {
1575
+ const preset = [...control.options].find(
1576
+ (o) => o.defaultSelected
1577
+ );
1578
+ control.value = preset ? preset.value : '';
1579
+ }
1580
+ });
1581
+
1582
+ controls.forEach(apply);
1583
+ render();
1584
+ }
1585
+
1586
+ document
1587
+ .getElementById('variant-share')
1588
+ ?.addEventListener('click', async (event) => {
1589
+ const button = event.currentTarget;
1590
+ const label = document.getElementById('variant-share-label');
1591
+ try {
1592
+ await navigator.clipboard.writeText(shareUrl);
1593
+ label.textContent = 'Copied';
1594
+ } catch {
1595
+ // Denied permission or an insecure origin. The address bar is no
1596
+ // help here - it never carries the setup - so hand the link over
1597
+ // in the box instead, where it can be selected by hand.
1598
+ setup.value = shareUrl;
1599
+ setup.select();
1600
+ label.textContent = 'Copy from the box';
1601
+ }
1602
+ setTimeout(() => {
1603
+ label.textContent = 'Copy link';
1604
+ }, 1600);
1605
+ });
1270
1606
  });
1271
1607
 
1272
1608
  // for the modals - the framework handles both shapes, div and <dialog>