@ds-gn/ui 0.8.0 → 0.10.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.
@@ -1219,6 +1219,22 @@
1219
1219
  "description": "Space below the content.",
1220
1220
  "attribute": "padding-bottom",
1221
1221
  "reflects": true
1222
+ },
1223
+ {
1224
+ "kind": "field",
1225
+ "name": "motion",
1226
+ "type": {
1227
+ "text": "MotionType"
1228
+ },
1229
+ "description": "Animate the section in when it scrolls into view. Each heading block\ninside fades in its eyebrow, writes its heading letter by letter, then\nfades in its description and actions; each form block then fades in its\ninput and button together, then its note. A section already in view when\nit loads, and reduced motion, stay static.",
1230
+ "attribute": "motion",
1231
+ "reflects": true
1232
+ },
1233
+ {
1234
+ "kind": "field",
1235
+ "name": "_entrance",
1236
+ "privacy": "private",
1237
+ "default": "new SectionEntranceController( this, () => this.motion === motionValues.ON, )"
1222
1238
  }
1223
1239
  ],
1224
1240
  "superclass": {
@@ -1268,6 +1284,14 @@
1268
1284
  "text": "SectionSpaceType | undefined"
1269
1285
  },
1270
1286
  "description": "Space below the content."
1287
+ },
1288
+ {
1289
+ "name": "motion",
1290
+ "fieldName": "motion",
1291
+ "type": {
1292
+ "text": "MotionType"
1293
+ },
1294
+ "description": "Animate the section in when it scrolls into view. Each heading block\ninside fades in its eyebrow, writes its heading letter by letter, then\nfades in its description and actions; each form block then fades in its\ninput and button together, then its note. A section already in view when\nit loads, and reduced motion, stay static."
1271
1295
  }
1272
1296
  ]
1273
1297
  }
@@ -1520,6 +1544,15 @@
1520
1544
  "privacy": "private",
1521
1545
  "description": "The feature whose detail last faded in; the fade replays when it changes."
1522
1546
  },
1547
+ {
1548
+ "kind": "field",
1549
+ "name": "_motionState",
1550
+ "type": {
1551
+ "text": "'static' | 'held' | 'played' | undefined"
1552
+ },
1553
+ "privacy": "private",
1554
+ "description": "Motion progress, decided by the first visibility report."
1555
+ },
1523
1556
  {
1524
1557
  "kind": "field",
1525
1558
  "name": "surface",
@@ -1606,6 +1639,32 @@
1606
1639
  "description": "Accessible label for the tab list.",
1607
1640
  "attribute": "tabs-aria-label"
1608
1641
  },
1642
+ {
1643
+ "kind": "field",
1644
+ "name": "motion",
1645
+ "type": {
1646
+ "text": "MotionType"
1647
+ },
1648
+ "description": "Animate the section in when it scrolls into view: the header's heading\nletter by letter, then its description, then the tabs as a whole. The\neyebrow stays static. A section already in view when it loads, and\nreduced motion, stay static.",
1649
+ "attribute": "motion",
1650
+ "reflects": true
1651
+ },
1652
+ {
1653
+ "kind": "method",
1654
+ "name": "_updateMotion",
1655
+ "privacy": "private",
1656
+ "description": "Decide the motion from the first visibility report: a section already\non screen stays static; one off screen holds its parts hidden (from\nscript, so nothing is hidden when scripts do not run) and plays when it\ncomes into view, so even a jump to it shows no unanimated frame."
1657
+ },
1658
+ {
1659
+ "kind": "method",
1660
+ "name": "_motionParts",
1661
+ "privacy": "private"
1662
+ },
1663
+ {
1664
+ "kind": "method",
1665
+ "name": "_playMotion",
1666
+ "privacy": "private"
1667
+ },
1609
1668
  {
1610
1669
  "kind": "method",
1611
1670
  "name": "_fadeInDetail",
@@ -1862,6 +1921,14 @@
1862
1921
  },
1863
1922
  "default": "DEFAULT_TABS_ARIA_LABEL",
1864
1923
  "description": "Accessible label for the tab list."
1924
+ },
1925
+ {
1926
+ "name": "motion",
1927
+ "fieldName": "motion",
1928
+ "type": {
1929
+ "text": "MotionType"
1930
+ },
1931
+ "description": "Animate the section in when it scrolls into view: the header's heading\nletter by letter, then its description, then the tabs as a whole. The\neyebrow stays static. A section already in view when it loads, and\nreduced motion, stay static."
1865
1932
  }
1866
1933
  ]
1867
1934
  }
@@ -2642,6 +2709,32 @@
2642
2709
  "description": "Space below the section. Unset follows the inherited section space.",
2643
2710
  "attribute": "padding-bottom",
2644
2711
  "reflects": true
2712
+ },
2713
+ {
2714
+ "kind": "field",
2715
+ "name": "motion",
2716
+ "type": {
2717
+ "text": "MotionType"
2718
+ },
2719
+ "description": "Animate the content's eyebrow in letter by letter once the hero loads,\neach letter fading in with a small rise.\nEverything else stays static, so the hero's largest text paints at once.\nUnder reduced motion the eyebrow is static too.",
2720
+ "attribute": "motion",
2721
+ "reflects": true
2722
+ },
2723
+ {
2724
+ "kind": "field",
2725
+ "name": "_motion",
2726
+ "privacy": "private",
2727
+ "default": "new MotionController(this)"
2728
+ },
2729
+ {
2730
+ "kind": "method",
2731
+ "name": "_eyebrows",
2732
+ "privacy": "private",
2733
+ "return": {
2734
+ "type": {
2735
+ "text": "Element[]"
2736
+ }
2737
+ }
2645
2738
  }
2646
2739
  ],
2647
2740
  "superclass": {
@@ -2698,6 +2791,14 @@
2698
2791
  "text": "SectionSpaceType | undefined"
2699
2792
  },
2700
2793
  "description": "Space below the section. Unset follows the inherited section space."
2794
+ },
2795
+ {
2796
+ "name": "motion",
2797
+ "fieldName": "motion",
2798
+ "type": {
2799
+ "text": "MotionType"
2800
+ },
2801
+ "description": "Animate the content's eyebrow in letter by letter once the hero loads,\neach letter fading in with a small rise.\nEverything else stays static, so the hero's largest text paints at once.\nUnder reduced motion the eyebrow is static too."
2701
2802
  }
2702
2803
  ]
2703
2804
  }
@@ -0,0 +1,37 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ type EntrancePart = {
3
+ kind: 'fade';
4
+ els: HTMLElement[];
5
+ } | {
6
+ kind: 'letters';
7
+ el: HTMLElement;
8
+ };
9
+ /**
10
+ * The entrance parts inside `root`, in document order. Each heading block
11
+ * contributes its eyebrow (fade), heading (letter by letter), description,
12
+ * then actions; each form block its input and button together, then its note.
13
+ */
14
+ export declare function sectionEntranceParts(root: Element): EntrancePart[];
15
+ /**
16
+ * Plays a section's entrance when it scrolls into view: the heading blocks
17
+ * and form blocks it contains animate in one timeline (see
18
+ * `sectionEntranceParts`). Decided from the first visibility report: a
19
+ * section already on screen, and reduced motion, stay static; one off screen
20
+ * holds its parts hidden (from script, so nothing is hidden when scripts do
21
+ * not run) and plays once when it comes into view.
22
+ */
23
+ export declare class SectionEntranceController implements ReactiveController {
24
+ private readonly host;
25
+ private readonly enabled;
26
+ private readonly motion;
27
+ private observer?;
28
+ private state?;
29
+ private parts;
30
+ constructor(host: ReactiveControllerHost & HTMLElement, enabled: () => boolean);
31
+ hostUpdated(): void;
32
+ hostDisconnected(): void;
33
+ private report;
34
+ private play;
35
+ private stopObserving;
36
+ }
37
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether a section animates in. Components with their own motion
3
+ * vocabulary (Modal's direction values) declare their own `motion` values.
4
+ */
5
+ export declare const motionValues: {
6
+ readonly ON: "on";
7
+ readonly OFF: "off";
8
+ };
9
+ export declare const motionProps: import("lit").PropertyDeclaration<unknown, unknown>;
10
+ export type MotionType = (typeof motionValues)[keyof typeof motionValues];
@@ -1,6 +1,7 @@
1
1
  import '../../../types/global.d.ts';
2
2
  import { type CSSResultGroup } from 'lit';
3
3
  import { Base } from '../../../common/Base/Base.js';
4
+ import { type MotionType } from '../../../common/props/motion/motion.props.js';
4
5
  import { type ContainerPaddingType, type ContainerSizeType, type SectionSpaceType } from './Container.props.js';
5
6
  /** @slot - Constrained page content. */
6
7
  export declare class Container extends Base {
@@ -22,6 +23,15 @@ export declare class Container extends Base {
22
23
  paddingTop?: SectionSpaceType;
23
24
  /** Space below the content. */
24
25
  paddingBottom?: SectionSpaceType;
26
+ /**
27
+ * Animate the section in when it scrolls into view. Each heading block
28
+ * inside fades in its eyebrow, writes its heading letter by letter, then
29
+ * fades in its description and actions; each form block then fades in its
30
+ * input and button together, then its note. A section already in view when
31
+ * it loads, and reduced motion, stay static.
32
+ */
33
+ motion: MotionType;
34
+ private _entrance;
25
35
  render(): import("lit").TemplateResult<1>;
26
36
  }
27
37
  declare global {
@@ -91,6 +91,146 @@ class Base extends LitElement {
91
91
  }
92
92
  defineElement("dsgn-base", Base);
93
93
 
94
+ // common/controllers/SectionEntranceController.ts
95
+ import {
96
+ prefersReducedMotion,
97
+ splitText
98
+ } from "@ds-gn/motion";
99
+ import { MotionController } from "@ds-gn/motion/lit";
100
+ var HEADING_STAGGER = 20;
101
+ var HEADING_MAX_SPREAD = 500;
102
+ var HEADING_RISE = 4;
103
+ var MOTION_OVERLAP = 160;
104
+ var holdHidden = (el) => el.style.setProperty("opacity", "0");
105
+ var releaseHidden = (el) => el.style.removeProperty("opacity");
106
+ var ENTRANCE_BLOCKS = new Set(["dsgn-heading-block", "dsgn-form-block"]);
107
+ var hasContent = (el) => !["div", "span"].includes(el.localName) || el.children.length > 0 || (el.textContent ?? "").trim() !== "";
108
+ var childrenIn = (parent, ...slots) => Array.from(parent.children).filter((child) => child instanceof HTMLElement && slots.includes(child.slot) && hasContent(child));
109
+ function sectionEntranceParts(root) {
110
+ const parts = [];
111
+ const fade = (els) => {
112
+ if (els.length > 0)
113
+ parts.push({ kind: "fade", els });
114
+ };
115
+ Array.from(root.querySelectorAll("*")).filter((el) => ENTRANCE_BLOCKS.has(el.localName)).forEach((block) => {
116
+ if (block.localName === "dsgn-form-block") {
117
+ fade(childrenIn(block, "input", "button"));
118
+ fade(childrenIn(block, "note"));
119
+ return;
120
+ }
121
+ fade(childrenIn(block, "eyebrow"));
122
+ const [heading] = childrenIn(block, "heading");
123
+ if (heading)
124
+ parts.push({ kind: "letters", el: heading });
125
+ fade(childrenIn(block, "description"));
126
+ fade(childrenIn(block, "action-primary", "action-secondary"));
127
+ });
128
+ return parts;
129
+ }
130
+ var elementsOf = (part) => part.kind === "fade" ? part.els : [part.el];
131
+
132
+ class SectionEntranceController {
133
+ constructor(host, enabled) {
134
+ this.parts = [];
135
+ this.host = host;
136
+ this.enabled = enabled;
137
+ this.motion = new MotionController(host);
138
+ host.addController(this);
139
+ }
140
+ hostUpdated() {
141
+ if (this.observer || this.state !== undefined || !this.enabled())
142
+ return;
143
+ if (typeof IntersectionObserver === "undefined") {
144
+ this.state = "static";
145
+ return;
146
+ }
147
+ this.observer = new IntersectionObserver((entries) => {
148
+ const entry = entries[entries.length - 1];
149
+ if (entry)
150
+ this.report(entry.isIntersecting);
151
+ });
152
+ this.observer.observe(this.host);
153
+ }
154
+ hostDisconnected() {
155
+ this.stopObserving();
156
+ if (this.state === "held") {
157
+ this.parts.flatMap(elementsOf).forEach(releaseHidden);
158
+ this.state = undefined;
159
+ }
160
+ }
161
+ report(inView) {
162
+ if (this.state === undefined) {
163
+ const play = !inView && !prefersReducedMotion();
164
+ this.state = play ? "held" : "static";
165
+ if (!play)
166
+ return this.stopObserving();
167
+ this.parts = sectionEntranceParts(this.host);
168
+ this.parts.flatMap(elementsOf).forEach(holdHidden);
169
+ }
170
+ if (this.state === "held" && inView) {
171
+ this.state = "played";
172
+ this.stopObserving();
173
+ this.play();
174
+ }
175
+ }
176
+ play() {
177
+ this.parts.flatMap(elementsOf).forEach(releaseHidden);
178
+ const splits = [];
179
+ const steps = this.parts.map((part, index) => {
180
+ const overlap = index === 0 ? undefined : MOTION_OVERLAP;
181
+ if (part.kind === "fade") {
182
+ return { targets: part.els, preset: "fade", stagger: 0, overlap };
183
+ }
184
+ const split = splitText(part.el);
185
+ splits.push(split);
186
+ return {
187
+ targets: split.chars,
188
+ preset: "fade",
189
+ y: HEADING_RISE,
190
+ stagger: HEADING_STAGGER,
191
+ maxDuration: HEADING_MAX_SPREAD
192
+ };
193
+ });
194
+ if (steps.length === 0)
195
+ return;
196
+ this.motion.timeline(steps).finished.then(() => splits.forEach((split) => split.revert()));
197
+ }
198
+ stopObserving() {
199
+ this.observer?.disconnect();
200
+ this.observer = undefined;
201
+ }
202
+ }
203
+
204
+ // common/helpers/enum-validator.ts
205
+ var enumValidator = (propName, enumSet, candidateValue) => {
206
+ if (!enumSet.has(candidateValue)) {
207
+ throw new Error(`Invalid prop ${propName} of value ${candidateValue}. Expected one of: ${Array.from(enumSet).join(", ")}`);
208
+ }
209
+ };
210
+
211
+ // common/helpers/enum-creator.ts
212
+ var enumCreator = (acceptableValues, propName, type, other = {}) => {
213
+ const acceptableValuesSet = new Set(acceptableValues);
214
+ return {
215
+ type,
216
+ hasChanged(newValue, oldValue) {
217
+ if (newValue !== oldValue) {
218
+ enumValidator(propName, acceptableValuesSet, newValue);
219
+ return true;
220
+ }
221
+ return false;
222
+ },
223
+ ...other
224
+ };
225
+ };
226
+
227
+ // common/props/motion/motion.props.ts
228
+ var motionValues = {
229
+ ON: "on",
230
+ OFF: "off"
231
+ };
232
+ var motionProps = enumCreator(Object.values(motionValues), "motion", String, { reflect: true });
233
+
94
234
  // components/Container/src/Container.css
95
235
  import { css as css2 } from "lit";
96
236
  var styles2 = css2`/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
@@ -138,6 +278,9 @@ var styles2 = css2`/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.co
138
278
  .mx-auto {
139
279
  margin-inline: auto;
140
280
  }
281
+ .block {
282
+ display: block;
283
+ }
141
284
  .w-full {
142
285
  width: 100%;
143
286
  }
@@ -229,29 +372,6 @@ var styles2 = css2`/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.co
229
372
  `;
230
373
  var Container_default = styles2;
231
374
 
232
- // common/helpers/enum-validator.ts
233
- var enumValidator = (propName, enumSet, candidateValue) => {
234
- if (!enumSet.has(candidateValue)) {
235
- throw new Error(`Invalid prop ${propName} of value ${candidateValue}. Expected one of: ${Array.from(enumSet).join(", ")}`);
236
- }
237
- };
238
-
239
- // common/helpers/enum-creator.ts
240
- var enumCreator = (acceptableValues, propName, type, other = {}) => {
241
- const acceptableValuesSet = new Set(acceptableValues);
242
- return {
243
- type,
244
- hasChanged(newValue, oldValue) {
245
- if (newValue !== oldValue) {
246
- enumValidator(propName, acceptableValuesSet, newValue);
247
- return true;
248
- }
249
- return false;
250
- },
251
- ...other
252
- };
253
- };
254
-
255
375
  // components/Container/src/Container.props.ts
256
376
  var containerSizeValues = {
257
377
  SM: "sm",
@@ -341,6 +461,8 @@ class Container extends Base {
341
461
  super(...arguments);
342
462
  this.size = containerSizeValues.LG;
343
463
  this.section = false;
464
+ this.motion = motionValues.OFF;
465
+ this._entrance = new SectionEntranceController(this, () => this.motion === motionValues.ON);
344
466
  }
345
467
  static styles = [Base.styles, Container_default];
346
468
  render() {
@@ -366,6 +488,9 @@ __legacyDecorateClassTS([
366
488
  __legacyDecorateClassTS([
367
489
  property({ ...paddingBottomProps })
368
490
  ], Container.prototype, "paddingBottom", undefined);
491
+ __legacyDecorateClassTS([
492
+ property({ ...motionProps })
493
+ ], Container.prototype, "motion", undefined);
369
494
  defineElement("dsgn-container", Container);
370
495
  export {
371
496
  Container
@@ -8,6 +8,7 @@ import '../../Icon/src/Icon.js';
8
8
  import { Base } from '../../../common/Base/Base.js';
9
9
  import { type SurfaceType } from '../../../common/props/surface/surface.props.js';
10
10
  import { type CSSResultGroup } from 'lit';
11
+ import { type MotionType } from '../../../common/props/motion/motion.props.js';
11
12
  import { type SectionSpaceType } from '../../Container/src/Container.props.js';
12
13
  import { type FeatureTabData } from './FeatureTabs.props.js';
13
14
  /** @slot header - Optional content above the tab interface. */
@@ -22,6 +23,8 @@ export declare class FeatureTabs extends Base {
22
23
  private _motion;
23
24
  /** The feature whose detail last faded in; the fade replays when it changes. */
24
25
  private _shownIndex?;
26
+ /** Motion progress, decided by the first visibility report. */
27
+ private _motionState?;
25
28
  surface: SurfaceType;
26
29
  features: FeatureTabData[];
27
30
  duration: number;
@@ -37,8 +40,24 @@ export declare class FeatureTabs extends Base {
37
40
  resumeAriaLabel: string;
38
41
  /** Accessible label for the tab list. */
39
42
  tabsAriaLabel: string;
43
+ /**
44
+ * Animate the section in when it scrolls into view: the header's heading
45
+ * letter by letter, then its description, then the tabs as a whole. The
46
+ * eyebrow stays static. A section already in view when it loads, and
47
+ * reduced motion, stay static.
48
+ */
49
+ motion: MotionType;
40
50
  constructor();
41
51
  firstUpdated(): void;
52
+ /**
53
+ * Decide the motion from the first visibility report: a section already
54
+ * on screen stays static; one off screen holds its parts hidden (from
55
+ * script, so nothing is hidden when scripts do not run) and plays when it
56
+ * comes into view, so even a jump to it shows no unanimated frame.
57
+ */
58
+ private _updateMotion;
59
+ private _motionParts;
60
+ private _playMotion;
42
61
  updated(changedProperties: Map<string | number | symbol, unknown>): void;
43
62
  /**
44
63
  * Fade the detail in whenever the active feature changes. `keyed` renders a
@@ -218,6 +218,10 @@ var surfaceValues = {
218
218
  var surfaceProps = enumCreator(Object.values(surfaceValues), "surface", String, { reflect: true });
219
219
 
220
220
  // components/FeatureTabs/src/FeatureTabs.ts
221
+ import {
222
+ prefersReducedMotion as prefersReducedMotion2,
223
+ splitText
224
+ } from "@ds-gn/motion";
221
225
  import { MotionController } from "@ds-gn/motion/lit";
222
226
 
223
227
  // ../../node_modules/.bun/lit-html@3.3.3/node_modules/lit-html/development/is-server.js
@@ -294,6 +298,15 @@ import {
294
298
  import { property } from "lit/decorators.js";
295
299
  import { keyed } from "lit/directives/keyed.js";
296
300
  import { styleMap } from "lit/directives/style-map.js";
301
+
302
+ // common/props/motion/motion.props.ts
303
+ var motionValues = {
304
+ ON: "on",
305
+ OFF: "off"
306
+ };
307
+ var motionProps = enumCreator(Object.values(motionValues), "motion", String, { reflect: true });
308
+
309
+ // components/FeatureTabs/src/FeatureTabs.ts
297
310
  import {
298
311
  containerSizeValues,
299
312
  paddingBottomProps,
@@ -1003,13 +1016,20 @@ var dotPipInnerClassNames = (active) => clsx_default("block", "rounded-full", "h
1003
1016
  var playPauseClassNames = () => clsx_default("appearance-none", "border-0", "outline-none", "bg-transparent", "cursor-pointer", "text-accent", "py-[10px]", "px-[8px]", "flex", "items-center", "justify-center", "rounded-full", "transition-colors", "duration-150");
1004
1017
 
1005
1018
  // components/FeatureTabs/src/FeatureTabs.ts
1019
+ var holdHidden = (el) => el.style.setProperty("opacity", "0");
1020
+ var releaseHidden = (el) => el.style.removeProperty("opacity");
1021
+ var HEADING_STAGGER = 20;
1022
+ var HEADING_MAX_SPREAD = 500;
1023
+ var HEADING_RISE = 4;
1024
+ var MOTION_OVERLAP = 160;
1006
1025
  var FEATURE_TAB_ICONS = {
1007
1026
  terminal: svg`<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
1008
1027
  brain: svg`<path d="M9 4.5a2.5 2.5 0 0 0-2.5 2.5v.5a2.5 2.5 0 0 0-2 4 2.5 2.5 0 0 0 2 4v.5A2.5 2.5 0 0 0 9 18.5a2.5 2.5 0 0 0 3-2.5V6a2.5 2.5 0 0 0-3-1.5z"/><path d="M15 4.5A2.5 2.5 0 0 1 17.5 7v.5a2.5 2.5 0 0 1 2 4 2.5 2.5 0 0 1-2 4v.5A2.5 2.5 0 0 1 15 18.5a2.5 2.5 0 0 1-3-2.5V6a2.5 2.5 0 0 1 3-1.5z"/>`,
1009
1028
  cpu: svg`<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="2" x2="9" y2="5"/><line x1="15" y1="2" x2="15" y2="5"/><line x1="9" y1="19" x2="9" y2="22"/><line x1="15" y1="19" x2="15" y2="22"/><line x1="2" y1="9" x2="5" y2="9"/><line x1="2" y1="15" x2="5" y2="15"/><line x1="19" y1="9" x2="22" y2="9"/><line x1="19" y1="15" x2="22" y2="15"/>`,
1010
1029
  palette: svg`<circle cx="12" cy="12" r="9"/><circle cx="7.5" cy="10.5" r="1" fill="currentColor"/><circle cx="12" cy="7.5" r="1" fill="currentColor"/><circle cx="16.5" cy="10.5" r="1" fill="currentColor"/><path d="M12 21a3 3 0 0 1-3-3c0-1.5 1-2 1-3a2 2 0 0 0-2-2"/>`,
1011
1030
  plug: svg`<path d="M9 2v6"/><path d="M15 2v6"/><path d="M7 8h10v3a5 5 0 0 1-5 5 5 5 0 0 1-5-5z"/><path d="M12 16v6"/>`,
1012
- shield: svg`<path d="M12 3l8 3v5c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>`
1031
+ shield: svg`<path d="M12 3l8 3v5c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>`,
1032
+ zap: svg`<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>`
1013
1033
  };
1014
1034
  var nextTabIndex = (key, index, lastIndex) => {
1015
1035
  switch (key) {
@@ -1046,11 +1066,67 @@ class FeatureTabs extends Base {
1046
1066
  this.pauseAriaLabel = DEFAULT_PAUSE_ARIA_LABEL;
1047
1067
  this.resumeAriaLabel = DEFAULT_RESUME_ARIA_LABEL;
1048
1068
  this.tabsAriaLabel = DEFAULT_TABS_ARIA_LABEL;
1069
+ this.motion = motionValues.OFF;
1049
1070
  this._rotate = new AutoRotateController(this, DEFAULT_DURATION, () => this._inView.value === true);
1050
1071
  }
1051
1072
  firstUpdated() {
1052
1073
  this._rotate.resume(this.features.length);
1053
1074
  }
1075
+ _updateMotion() {
1076
+ const inView = this._inView.value;
1077
+ if (inView === undefined)
1078
+ return;
1079
+ if (this._motionState === undefined) {
1080
+ const play = this.motion === motionValues.ON && !inView && !prefersReducedMotion2();
1081
+ this._motionState = play ? "held" : "static";
1082
+ if (play)
1083
+ this._motionParts().forEach(holdHidden);
1084
+ }
1085
+ if (this._motionState === "held" && inView) {
1086
+ this._motionState = "played";
1087
+ this._playMotion();
1088
+ }
1089
+ }
1090
+ _motionParts() {
1091
+ const header = Array.from(this.children).filter((el) => el.slot === "header");
1092
+ const find = (slot) => header.map((el) => el.querySelector(`[slot="${slot}"]`)).find((el) => el !== null);
1093
+ return [
1094
+ find("heading"),
1095
+ find("description"),
1096
+ ...this.renderRoot.querySelectorAll("[data-motion-body]")
1097
+ ].filter((el) => el !== undefined);
1098
+ }
1099
+ _playMotion() {
1100
+ const parts = this._motionParts();
1101
+ parts.forEach(releaseHidden);
1102
+ const heading = parts.find((el) => el.slot === "heading");
1103
+ const description = parts.find((el) => el.slot === "description");
1104
+ const split = heading ? splitText(heading) : undefined;
1105
+ const steps = [
1106
+ ...split ? [
1107
+ {
1108
+ targets: split.chars,
1109
+ preset: "fade",
1110
+ y: HEADING_RISE,
1111
+ stagger: HEADING_STAGGER,
1112
+ maxDuration: HEADING_MAX_SPREAD
1113
+ }
1114
+ ] : [],
1115
+ ...description ? [
1116
+ {
1117
+ el: description,
1118
+ preset: "fade",
1119
+ overlap: MOTION_OVERLAP
1120
+ }
1121
+ ] : [],
1122
+ {
1123
+ targets: parts.filter((el) => el.hasAttribute("data-motion-body")),
1124
+ preset: "fade",
1125
+ overlap: MOTION_OVERLAP
1126
+ }
1127
+ ];
1128
+ this._motion.timeline(steps).finished.then(() => split?.revert());
1129
+ }
1054
1130
  updated(changedProperties) {
1055
1131
  super.updated(changedProperties);
1056
1132
  this.updateTheme(changedProperties, this.surface);
@@ -1058,6 +1134,7 @@ class FeatureTabs extends Base {
1058
1134
  this._rotate.setDuration(this.duration);
1059
1135
  }
1060
1136
  this._fadeInDetail();
1137
+ this._updateMotion();
1061
1138
  }
1062
1139
  _fadeInDetail() {
1063
1140
  const index = this._rotate.activeIndex;
@@ -1262,12 +1339,12 @@ class FeatureTabs extends Base {
1262
1339
  </div>
1263
1340
 
1264
1341
  <!-- Mobile: detail panel only, no grid -->
1265
- <div class="lg:hidden">
1342
+ <div class="lg:hidden" data-motion-body>
1266
1343
  ${this._renderDetailPanel(idx, activeFeature, progress, total, paused, features)}
1267
1344
  </div>
1268
1345
 
1269
1346
  <!-- Desktop: full grid with tabs + detail -->
1270
- <div class="hidden lg:block">
1347
+ <div class="hidden lg:block" data-motion-body>
1271
1348
  <dsgn-grid cols="7-5" gap="xl" ?reverse=${true}>
1272
1349
  ${this._renderTabList(features, idx, progress, paused, "col-2")}
1273
1350
  ${this._renderDetailPanel(idx, activeFeature, progress, total, paused, features, "col-1")}
@@ -1311,6 +1388,9 @@ __legacyDecorateClassTS([
1311
1388
  __legacyDecorateClassTS([
1312
1389
  property({ type: String, attribute: "tabs-aria-label" })
1313
1390
  ], FeatureTabs.prototype, "tabsAriaLabel", undefined);
1391
+ __legacyDecorateClassTS([
1392
+ property({ ...motionProps })
1393
+ ], FeatureTabs.prototype, "motion", undefined);
1314
1394
  defineElement("dsgn-feature-tabs", FeatureTabs);
1315
1395
  export {
1316
1396
  FeatureTabs
@@ -1,4 +1,4 @@
1
- export type FeatureTabIcon = 'terminal' | 'brain' | 'cpu' | 'palette' | 'plug' | 'shield';
1
+ export type FeatureTabIcon = 'terminal' | 'brain' | 'cpu' | 'palette' | 'plug' | 'shield' | 'zap';
2
2
  export interface FeatureTabData {
3
3
  id: string;
4
4
  eyebrow: string;
@@ -5,6 +5,7 @@ import '../../Grid/src/Grid.js';
5
5
  import '../../HeadingBlock/src/HeadingBlock.js';
6
6
  import { type CSSResultGroup } from 'lit';
7
7
  import { Base } from '../../../common/Base/Base.js';
8
+ import { type MotionType } from '../../../common/props/motion/motion.props.js';
8
9
  import { type SurfaceType } from '../../../common/props/surface/surface.props.js';
9
10
  import { type SectionSpaceType } from '../../Container/src/Container.props.js';
10
11
  import { type HeroContentAlignType, type HeroLayoutType, type HeroMinHeightType } from './Hero.props.js';
@@ -37,6 +38,16 @@ export declare class Hero extends Base {
37
38
  paddingTop?: SectionSpaceType;
38
39
  /** Space below the section. Unset follows the inherited section space. */
39
40
  paddingBottom?: SectionSpaceType;
41
+ /**
42
+ * Animate the content's eyebrow in letter by letter once the hero loads,
43
+ * each letter fading in with a small rise.
44
+ * Everything else stays static, so the hero's largest text paints at once.
45
+ * Under reduced motion the eyebrow is static too.
46
+ */
47
+ motion: MotionType;
48
+ private _motion;
49
+ firstUpdated(): void;
50
+ private _eyebrows;
40
51
  updated(changedProperties: Map<string | number | symbol, unknown>): void;
41
52
  render(): import("lit").TemplateResult<1>;
42
53
  }
@@ -14,6 +14,8 @@ import"../../Container/src/Container.js";
14
14
  import"../../FormBlock/src/FormBlock.js";
15
15
  import"../../Grid/src/Grid.js";
16
16
  import"../../HeadingBlock/src/HeadingBlock.js";
17
+ import { prefersReducedMotion, splitText } from "@ds-gn/motion";
18
+ import { MotionController } from "@ds-gn/motion/lit";
17
19
  import { html as html2 } from "lit";
18
20
  import { property } from "lit/decorators.js";
19
21
 
@@ -118,6 +120,13 @@ var enumCreator = (acceptableValues, propName, type, other = {}) => {
118
120
  };
119
121
  };
120
122
 
123
+ // common/props/motion/motion.props.ts
124
+ var motionValues = {
125
+ ON: "on",
126
+ OFF: "off"
127
+ };
128
+ var motionProps = enumCreator(Object.values(motionValues), "motion", String, { reflect: true });
129
+
121
130
  // common/props/surface/surface.props.ts
122
131
  var surfaceValues = {
123
132
  LIGHT: "light",
@@ -189,6 +198,9 @@ var styles2 = css2`/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.co
189
198
  .grid {
190
199
  display: grid;
191
200
  }
201
+ .hidden {
202
+ display: none;
203
+ }
192
204
  .h-64 {
193
205
  height: calc(var(--spacing) * 64);
194
206
  }
@@ -412,6 +424,11 @@ var centeredFormClassNames = (contentAlign) => {
412
424
  };
413
425
 
414
426
  // components/Hero/src/Hero.ts
427
+ var EYEBROW_DELAY = 500;
428
+ var EYEBROW_STAGGER = 30;
429
+ var EYEBROW_MAX_SPREAD = 600;
430
+ var EYEBROW_RISE = 4;
431
+
415
432
  class Hero extends Base {
416
433
  constructor() {
417
434
  super(...arguments);
@@ -419,8 +436,27 @@ class Hero extends Base {
419
436
  this.layout = heroLayoutValues.ASSET_RIGHT;
420
437
  this.minHeight = heroMinHeightValues.MD;
421
438
  this.contentAlign = heroContentAlignValues.START;
439
+ this.motion = motionValues.OFF;
440
+ this._motion = new MotionController(this);
422
441
  }
423
442
  static styles = [Base.styles, Hero_default];
443
+ firstUpdated() {
444
+ if (this.motion !== motionValues.ON || prefersReducedMotion())
445
+ return;
446
+ this._eyebrows().forEach((eyebrow) => {
447
+ const split = splitText(eyebrow);
448
+ this._motion.sequence(split.chars, {
449
+ preset: "fade",
450
+ y: EYEBROW_RISE,
451
+ delay: EYEBROW_DELAY,
452
+ stagger: EYEBROW_STAGGER,
453
+ maxDuration: EYEBROW_MAX_SPREAD
454
+ }).finished.then(split.revert);
455
+ });
456
+ }
457
+ _eyebrows() {
458
+ return Array.from(this.children).filter((el) => el.slot === "content").flatMap((el) => Array.from(el.querySelectorAll('[slot="eyebrow"]')));
459
+ }
424
460
  updated(changedProperties) {
425
461
  super.updated(changedProperties);
426
462
  this.updateTheme(changedProperties, this.surface);
@@ -526,6 +562,9 @@ __legacyDecorateClassTS([
526
562
  __legacyDecorateClassTS([
527
563
  property({ ...paddingBottomProps })
528
564
  ], Hero.prototype, "paddingBottom", undefined);
565
+ __legacyDecorateClassTS([
566
+ property({ ...motionProps })
567
+ ], Hero.prototype, "motion", undefined);
529
568
  defineElement("dsgn-hero", Hero);
530
569
  export {
531
570
  Hero
@@ -489,6 +489,11 @@ dsgn-hero:not(:defined)[layout='asset-background']:not(:has(> [slot='features'])
489
489
  padding-bottom: calc(var(--spacing) * 6);
490
490
  grid-row: 3;
491
491
  }
492
+ @media (prefers-reduced-motion: no-preference) {
493
+ dsgn-hero:not(:defined)[motion='on'] > [slot='content'] [slot='eyebrow'] {
494
+ opacity: 0;
495
+ }
496
+ }
492
497
  @property --tw-tracking {
493
498
  syntax: "*";
494
499
  inherits: false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ds-gn/ui",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "UI components for the DS-GN Design System",
5
5
  "type": "module",
6
6
  "exports": {
@@ -346,7 +346,7 @@
346
346
  "react-dom": "19.2.4"
347
347
  },
348
348
  "dependencies": {
349
- "@ds-gn/motion": "0.8.0",
349
+ "@ds-gn/motion": "0.9.0",
350
350
  "@ds-gn/tokens": "0.8.0",
351
351
  "@lit-labs/observers": "^2.0.5",
352
352
  "@lit/react": "^1.0.7",