@getvitops/core 1.0.0 → 2.1.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/css/animation.css CHANGED
@@ -11,15 +11,18 @@
11
11
  * · animate-view / animate-scroll — keyframe linked to a scroll timeline
12
12
  * · animate-trigger — keyframe, time-based; plays once when
13
13
  * .is-active / [data-active] is set
14
- * · transition-active / -hover — TRANSITION (not keyframe) over the same
15
- * from/to vars, so it reverses on state flip
14
+ * · transition + hover-/focus-/ — TRANSITION (not keyframe) over the same
15
+ * active-<fx> from/to vars, so it reverses on state flip.
16
+ * Each state matches the element OR its
17
+ * direct parent, so an effect that clips
18
+ * itself away (reveal-*) is still reachable.
16
19
  * Both driver families read the same `--<prop>-*` vars, so one effect
17
20
  * (e.g. .fade-in) works in every mode with no redefinition.
18
21
  *
19
22
  * Effects by rendering stage (cheapest → most expensive):
20
23
  * composite: fade-in/out, slide-up/down/left/right, scale-up/down, rotate-cw/ccw
21
24
  * paint: blur-in/out, elevate-up/down, reveal-left/right/up/down
22
- * layout: size-grow, size-shrink (keyframe-only — auto height)
25
+ * layout: size-grow, size-shrink (auto height; needs interpolate-size)
23
26
  * journey: fade-journey, slide-journey, … (multi-phase; view timeline)
24
27
  * One keyframe per element: same-stage effects compose via vars (fade-in +
25
28
  * scale-up); cross-stage (fade + blur) needs nested elements.
@@ -31,8 +34,8 @@
31
34
  * Usage:
32
35
  * <div class="fade-in slide-up animate-view"> <!-- scroll-into-view -->
33
36
  * <div class="fade-in scale-up animate-trigger"> <!-- play once on enter -->
34
- * <button class="scale-up transition-hover"> <!-- grow on hover -->
35
- * <div class="fade-in slide-up transition-active"><!-- flip via .is-active -->
37
+ * <button class="transition hover-scale-up"> <!-- grow on hover -->
38
+ * <div class="transition active-fade-in"> <!-- flip via .is-active -->
36
39
  */
37
40
 
38
41
  /* ===========================================
@@ -220,11 +223,20 @@
220
223
  /* ===========================================
221
224
  * Trigger Play-State
222
225
  * Paused until activated. deferred.ts adds .is-active via IntersectionObserver,
223
- * but [data-active] / .is-active can also be toggled by any state logic. Gated
224
- * on JS (no-js → animation runs immediately, so content is never stuck hidden).
226
+ * but [data-active] / .is-active can also be toggled by any state logic.
227
+ *
228
+ * Gated on `scripting: enabled` because pausing is only safe when something can
229
+ * un-pause it: with no JS, .is-active is never added, and a paused entrance
230
+ * animation is `opacity: 0` — i.e. invisible content, permanently. Note which way
231
+ * this fails. An engine that doesn't know the media feature drops the whole block
232
+ * and the animation simply runs on load: an enhancement lost, nothing hidden.
233
+ * That is the right direction, and it is why this is a media query rather than
234
+ * the `:root:not(.no-js)` class it replaced — that spelling needed a class in the
235
+ * consumer's own <html>, which the Astro integration cannot put there, so every
236
+ * Astro site was silently failing the other way.
225
237
  * =========================================== */
226
238
 
227
- :where(:root:not(.no-js)) {
239
+ @media (scripting: enabled) {
228
240
  .animate-trigger {
229
241
  animation-play-state: paused;
230
242
  }
@@ -261,6 +273,21 @@
261
273
  * Stagger Support
262
274
  * =========================================== */
263
275
 
276
+ /* Both are per-element positions, never something a child should inherit — an
277
+ unregistered custom property would leak the parent's index into every
278
+ descendant that happens to carry a driver. */
279
+ @property --_stagger-index {
280
+ syntax: '<number>';
281
+ inherits: false;
282
+ initial-value: 0;
283
+ }
284
+
285
+ @property --_anim-range-offset {
286
+ syntax: '<length-percentage>';
287
+ inherits: false;
288
+ initial-value: 0%;
289
+ }
290
+
264
291
  .stagger {
265
292
  --_stagger-amount: var(--stagger-amount, 0.1s);
266
293
  }
@@ -270,15 +297,36 @@
270
297
  --_stagger-index inline, which wins. Where sibling-index() is supported
271
298
  it provides a JS-free fallback via the @supports block below. */
272
299
  --_stagger-index: 0;
300
+
301
+ /* Time-based drivers (.animate-trigger, .float) stagger on delay. */
273
302
  animation-delay: calc(var(--_stagger-index) * var(--_stagger-amount));
274
- }
275
303
 
276
- /* sibling-index() is near-baseline (lacking only Firefox as of 2026). Guarded so
277
- that on unsupported engines the declaration is skipped and delay stays 0
278
- (items animate together) rather than producing an invalid animation-delay. */
279
- @supports (--x: sibling-index()) {
304
+ /* Scroll-driven drivers do NOT: animation-delay is time-based and is ignored
305
+ outright on a progress-based timeline, which is why `.stagger > .animate-view`
306
+ used to arrive all at once. Offsetting the view RANGE by the same index is the
307
+ equivalent move — each child starts its entrance a little further into its own
308
+ entry phase. .animate-view folds this into its default range, so one .stagger
309
+ works under both driver families. Journeys set an explicit animation-range and
310
+ opt out by construction.
311
+
312
+ The step is a VIEWPORT length, matching the window it shifts: .animate-view
313
+ now spans 10vh → 25vh of the element's midpoint travel, so a step expressed
314
+ as a percentage of the element's own height (which is what a bare `%` means
315
+ inside the entry range) would be a few pixels against an ~80px window — a
316
+ stagger too small to read, and one that changed meaning with tile size. */
317
+ --_anim-range-offset: calc(var(--_stagger-index) * var(--stagger-range-step, 5vh));
318
+ }
319
+
320
+ /* sibling-index() is near-baseline (lacking only Firefox as of 2026). The test
321
+ must be a REAL property — `@supports (--x: sibling-index())` is always true,
322
+ because any token stream is a valid custom-property value, so it guarded
323
+ nothing. Where it is unsupported the declaration is skipped and the index stays
324
+ 0 (items animate together) rather than producing an invalid delay/range.
325
+ `- 1` because applyRowStagger writes a 0-based index and sibling-index() is
326
+ 1-based; the two must agree or the JS and CSS paths differ by one step. */
327
+ @supports (animation-delay: calc(sibling-index() * 1s)) {
280
328
  .stagger > * {
281
- --_stagger-index: sibling-index();
329
+ --_stagger-index: calc(sibling-index() - 1);
282
330
  }
283
331
  }
284
332
 
@@ -355,11 +403,34 @@
355
403
  .animate-view {
356
404
  animation-name: var(--_anim);
357
405
  animation-timeline: view();
358
- /* Finish the entrance early — by default within the first 20% of the element's
359
- ENTRY into the viewport — so it's done just after it appears (fill: both
360
- holds the end state). Override the stops via --anim-range; journey effects
361
- set their own `entry exit` range and are unaffected. */
362
- animation-range: var(--anim-range, entry 0% entry 20%);
406
+ /* Timed off the element's MIDPOINT, measured into the viewport — the only
407
+ framing that behaves the same for a 4rem card and a full-bleed section.
408
+
409
+ The pivot is `entry 50%`. The entry phase runs from "leading edge at the
410
+ viewport's trailing edge" to "trailing edge at the viewport's trailing
411
+ edge", a scroll of exactly one element height — so at its halfway point the
412
+ element's midpoint sits precisely ON the viewport edge, whatever the element
413
+ measures. Everything after that is stated in viewport units: `+ 10vh` is
414
+ "midpoint 10% of the viewport further in".
415
+
416
+ So the default plays from midpoint 15% in to midpoint 25% in. Both stops are
417
+ the ELEMENT's position on screen, not a fraction of its own height, which is
418
+ what the previous defaults got wrong in both directions: `entry 20%` was ~17px
419
+ of scroll for a small tile (over before it appeared), and `entry 100%` still
420
+ finished with the element flush against the bottom edge.
421
+
422
+ Percentages here resolve against the entry range (the element's height) and
423
+ lengths against the scrollport, so the two mix in one calc() by design.
424
+
425
+ --anim-start / --anim-end move the window without restating the pivot;
426
+ --anim-range replaces it outright; --_anim-range-offset shifts both stops
427
+ together (that's how .stagger offsets scroll-driven children — see Stagger
428
+ Support). Journeys declare their own range and ignore all of it. */
429
+ animation-range: var(
430
+ --anim-range,
431
+ entry calc(50% + var(--anim-start, 10vh) + var(--_anim-range-offset, 0%)) entry
432
+ calc(50% + var(--anim-end, 25vh) + var(--_anim-range-offset, 0%))
433
+ );
363
434
  }
364
435
 
365
436
  .animate-scroll {
@@ -372,13 +443,21 @@
372
443
  animation-duration: var(--trigger-animation-duration);
373
444
  }
374
445
 
375
- /* Fallback: when scroll-driven timelines are unsupported, deferred.ts adds
376
- .no-scroll-timeline to <html>. Without a timeline, view()/scroll() animations
377
- never advance and elements would stick at their `from` keyframe (e.g.
378
- opacity:0, clipped, blurred). Cancel the animation so they render in their
379
- natural, fully-visible state. Trigger animations are unaffected (JS-driven). */
380
- :root.no-scroll-timeline :where(.animate-view, .animate-scroll) {
381
- animation: none;
446
+ /* Fallback: without a scroll-driven timeline, view()/scroll() animations never
447
+ advance and their elements stick at the `from` keyframe (opacity:0, clipped,
448
+ blurred) — an entrance animation that never runs is invisible content. Cancel
449
+ the animation so they render in their natural, fully-visible state. Trigger
450
+ animations are unaffected (JS-driven, see Trigger Play-State above).
451
+
452
+ Same probe as the `.text-reveal` block above, in its negative form — no JS, so
453
+ it holds with scripting disabled, which the `.no-scroll-timeline` class this
454
+ replaced could not (a class only JS can add cannot help a page with no JS).
455
+ `.animate-scroll` uses scroll() rather than view(), but no engine has shipped
456
+ one without the other, so one probe covers both. */
457
+ @supports not (animation-timeline: view()) {
458
+ :where(.animate-view, .animate-scroll) {
459
+ animation: none;
460
+ }
382
461
  }
383
462
 
384
463
  /* ===========================================
@@ -388,7 +467,8 @@
388
467
  * active-<fx> in animation-effects.css) set those vars per state, so different
389
468
  * effects bind to different states on one element:
390
469
  * <div class="transition hover-slide-up active-elevate-up">
391
- * Covers compositor + the full paint chain; layout (auto height) is keyframe-only.
470
+ * Covers compositor, the full paint chain, and — where `interpolate-size` is
471
+ * supported — the layout stage (auto height) too.
392
472
  * =========================================== */
393
473
 
394
474
  .transition {
@@ -409,13 +489,44 @@
409
489
  saturate(var(--t-saturate, var(--saturate-from, 1))) sepia(var(--t-sepia, var(--sepia-from, 0)))
410
490
  hue-rotate(var(--t-hue-rotate, var(--hue-rotate-from, 0deg)));
411
491
  clip-path: var(--t-clip, var(--clip-from, none));
412
- transition:
413
- opacity var(--animation-duration) var(--custom-ease-out),
414
- translate var(--animation-duration) var(--custom-ease-out),
415
- scale var(--animation-duration) var(--custom-ease-out),
416
- rotate var(--animation-duration) var(--custom-ease-out),
417
- filter var(--animation-duration) var(--custom-ease-out),
418
- clip-path var(--animation-duration) var(--custom-ease-out);
492
+ /* Longhands rather than the `transition` shorthand: every property here shares
493
+ one duration and one easing, so listing them once lets the @supports block
494
+ below APPEND height without restating the whole list (a shorthand would
495
+ replace it, and transition-property has no additive form). */
496
+ transition-property: opacity, translate, scale, rotate, filter, clip-path;
497
+ transition-duration: var(--animation-duration);
498
+ transition-timing-function: var(--custom-ease-out);
499
+ }
500
+
501
+ /* Layout stage — `height`, the only one that reflows, and the only one that
502
+ needs a feature gate.
503
+
504
+ `height: 0 → auto` is not interpolable on its own: `auto` isn't a length, so
505
+ the pair falls back to discrete and the transition doesn't run.
506
+ `interpolate-size: allow-keywords` (set on :root in utilities.css) is what
507
+ makes `auto` a real endpoint — NOT `transition-behavior: allow-discrete`,
508
+ which only permits genuinely discrete properties (display,
509
+ content-visibility, overlay) to transition and would at best snap at the
510
+ midpoint here. Gated so engines without it keep no height transition rather
511
+ than that snap. The same dependency applies to the `layout` KEYFRAME, so this
512
+ is not a weaker path than a driver — it is the same requirement, stated once.
513
+
514
+ Declaring `height: auto` on every .transition element is safe because auto is
515
+ the initial value and animation.css is imported second in index.css — before
516
+ layout.css, every pattern and utilities.css — so anything later at equal
517
+ specificity still wins.
518
+
519
+ Caveat worth keeping: patterns/details.css records that transitioning
520
+ block-size to `auto` on ::details-content DEADLOCKS in Chrome 149
521
+ (content-visibility: hidden → no layout → `auto` resolves to 0 → nothing
522
+ runs). That is specific to ::details-content, but the lesson generalises — a
523
+ passing @supports query is not evidence the interaction works. Verify a real
524
+ hover before trusting a change here. */
525
+ @supports (interpolate-size: allow-keywords) {
526
+ .transition {
527
+ height: var(--t-height, var(--height-from, auto));
528
+ transition-property: opacity, translate, scale, rotate, filter, clip-path, height;
529
+ }
419
530
  }
420
531
 
421
532
  /* Respect reduced-motion: drop keyframes, transitions, and continuous floats so
package/css/global.css CHANGED
@@ -8,12 +8,28 @@
8
8
  font-size: 100% !important;
9
9
  }
10
10
 
11
+ /* Border-box sizing, everywhere.
12
+ The framework used to refuse this on the grounds that a global reset would
13
+ silently reflow consumer layouts. Two things changed. It is now LAYERED (this
14
+ file is `vitops.base`, the lowest of the three), so it loses to any unlayered
15
+ consumer rule — the opt-out is `*, *::before, *::after { box-sizing:
16
+ content-box }` in your own stylesheet, and a consumer who already ships a
17
+ border-box reset (Tailwind preflight, normalize, almost any modern base) sees
18
+ no change at all. And several patterns are only CORRECT under border-box: the
19
+ `.split` ratio is a flex basis, which sizes the border box, so under
20
+ content-box a padded column came out wider than its sibling by exactly its
21
+ padding. Stating the assumption beats each pattern re-asserting it and the
22
+ ones that forget being quietly wrong. */
23
+ *,
24
+ *::before,
25
+ *::after {
26
+ box-sizing: border-box;
27
+ }
28
+
11
29
  /* Drop the UA's 8px body margin. The framework owns page gutters via `.centered`
12
30
  (--gutter), so the UA default just offsets every full-bleed surface — sticky
13
31
  headers and `bg-*` bands end up inset by 8px with a sliver of canvas showing
14
- around them. This is the only UA reset the framework makes: it deliberately
15
- does NOT ship a general reset (no global box-sizing change), because that
16
- would silently reflow existing consumer layouts. */
32
+ around them. */
17
33
  body {
18
34
  margin: 0;
19
35
  }
package/css/index.css CHANGED
@@ -7,6 +7,11 @@
7
7
  @import './generated/tokens.css';
8
8
  @import './generated/typography.css';
9
9
  @import './layout.css';
10
+ /* Must precede utilities.css: both are `vitops.utilities`, and utilities.css
11
+ re-emits `.<bp>-flex` AFTER its own `.hidden` so `class="hidden md-flex"`
12
+ reveals. That re-emit is only load-bearing because this file's `.md-flex`
13
+ comes first. */
14
+ @import './layout-utilities.css';
10
15
  /* was layout-extra.css */
11
16
  @import './patterns/cluster.css';
12
17
  @import './patterns/grouped.css';
@@ -17,7 +22,9 @@
17
22
  @import './patterns/sticky.css';
18
23
  @import './patterns/stack.css';
19
24
  @import './patterns/splitter.css';
25
+ @import './patterns/reveal.css';
20
26
  @import './utilities.css';
27
+ @import './generated/spacing.css';
21
28
  @import './generated/patterns.css';
22
29
  /* was overlays.css */
23
30
  @import './patterns/dialog.css';
@@ -38,6 +45,7 @@
38
45
  @import './patterns/details.css';
39
46
  @import './patterns/comment.css';
40
47
  @import './patterns/banner.css';
48
+ @import './patterns/consent.css';
41
49
  @import './patterns/list.css';
42
50
  @import './patterns/tree.css';
43
51
  /* was typographic.css */