@xenosystem/elements-react 0.0.1

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 (153) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +41 -0
  3. package/dist/XenoElement.d.ts +39 -0
  4. package/dist/XenoElement.js +25 -0
  5. package/dist/containers/Avatar.d.ts +42 -0
  6. package/dist/containers/Avatar.js +29 -0
  7. package/dist/containers/Badges.d.ts +61 -0
  8. package/dist/containers/Badges.js +43 -0
  9. package/dist/containers/Card.d.ts +24 -0
  10. package/dist/containers/Card.js +5 -0
  11. package/dist/containers/Chip.d.ts +34 -0
  12. package/dist/containers/Chip.js +17 -0
  13. package/dist/containers/DataTable.d.ts +28 -0
  14. package/dist/containers/DataTable.js +13 -0
  15. package/dist/containers/ListRow.d.ts +37 -0
  16. package/dist/containers/ListRow.js +40 -0
  17. package/dist/containers/MessageBubble.d.ts +33 -0
  18. package/dist/containers/MessageBubble.js +9 -0
  19. package/dist/containers/Panel.d.ts +24 -0
  20. package/dist/containers/Panel.js +6 -0
  21. package/dist/containers/Table.d.ts +39 -0
  22. package/dist/containers/Table.js +20 -0
  23. package/dist/containers/Tile.d.ts +19 -0
  24. package/dist/containers/Tile.js +7 -0
  25. package/dist/content/Callout.d.ts +32 -0
  26. package/dist/content/Callout.js +10 -0
  27. package/dist/content/Caret.d.ts +9 -0
  28. package/dist/content/Caret.js +5 -0
  29. package/dist/content/CodeBlock.d.ts +36 -0
  30. package/dist/content/CodeBlock.js +50 -0
  31. package/dist/content/Collapsible.d.ts +24 -0
  32. package/dist/content/Collapsible.js +19 -0
  33. package/dist/content/InlineCode.d.ts +12 -0
  34. package/dist/content/InlineCode.js +5 -0
  35. package/dist/content/ModelPicker.d.ts +45 -0
  36. package/dist/content/ModelPicker.js +124 -0
  37. package/dist/content/SourceCard.d.ts +23 -0
  38. package/dist/content/SourceCard.js +18 -0
  39. package/dist/content/SourcesDisclosure.d.ts +26 -0
  40. package/dist/content/SourcesDisclosure.js +20 -0
  41. package/dist/controls/Button.d.ts +39 -0
  42. package/dist/controls/Button.js +16 -0
  43. package/dist/controls/ConfirmButton.d.ts +22 -0
  44. package/dist/controls/ConfirmButton.js +22 -0
  45. package/dist/controls/IconButton.d.ts +45 -0
  46. package/dist/controls/IconButton.js +15 -0
  47. package/dist/controls/Switch.d.ts +11 -0
  48. package/dist/controls/Switch.js +23 -0
  49. package/dist/controls/TextInput.d.ts +13 -0
  50. package/dist/controls/TextInput.js +6 -0
  51. package/dist/controls/Textarea.d.ts +8 -0
  52. package/dist/controls/Textarea.js +5 -0
  53. package/dist/controls/ToggleButton.d.ts +23 -0
  54. package/dist/controls/ToggleButton.js +23 -0
  55. package/dist/controls/util.d.ts +48 -0
  56. package/dist/controls/util.js +23 -0
  57. package/dist/forms/Checkbox.d.ts +18 -0
  58. package/dist/forms/Checkbox.js +28 -0
  59. package/dist/forms/RadioGroup.d.ts +51 -0
  60. package/dist/forms/RadioGroup.js +83 -0
  61. package/dist/index.d.ts +75 -0
  62. package/dist/index.js +81 -0
  63. package/dist/layout/ResizablePanel.d.ts +40 -0
  64. package/dist/layout/ResizablePanel.js +108 -0
  65. package/dist/nav/Sidebar.d.ts +60 -0
  66. package/dist/nav/Sidebar.js +84 -0
  67. package/dist/overlays/DateTimePicker.d.ts +71 -0
  68. package/dist/overlays/DateTimePicker.js +191 -0
  69. package/dist/overlays/Menu.d.ts +28 -0
  70. package/dist/overlays/Menu.js +32 -0
  71. package/dist/overlays/MenuItem.d.ts +71 -0
  72. package/dist/overlays/MenuItem.js +15 -0
  73. package/dist/overlays/Modal.d.ts +36 -0
  74. package/dist/overlays/Modal.js +27 -0
  75. package/dist/overlays/PickerField.d.ts +32 -0
  76. package/dist/overlays/PickerField.js +40 -0
  77. package/dist/overlays/PillFilter.d.ts +25 -0
  78. package/dist/overlays/PillFilter.js +41 -0
  79. package/dist/overlays/Popover.d.ts +32 -0
  80. package/dist/overlays/Popover.js +38 -0
  81. package/dist/overlays/Reveal.d.ts +20 -0
  82. package/dist/overlays/Reveal.js +45 -0
  83. package/dist/overlays/SegmentedControl.d.ts +23 -0
  84. package/dist/overlays/SegmentedControl.js +43 -0
  85. package/dist/overlays/Tabs.d.ts +32 -0
  86. package/dist/overlays/Tabs.js +142 -0
  87. package/dist/overlays/Tooltip.d.ts +23 -0
  88. package/dist/overlays/Tooltip.js +61 -0
  89. package/dist/status/ProgressBar.d.ts +20 -0
  90. package/dist/status/ProgressBar.js +11 -0
  91. package/dist/status/Spinner.d.ts +16 -0
  92. package/dist/status/Spinner.js +12 -0
  93. package/dist/status/StatusIndicator.d.ts +19 -0
  94. package/dist/status/StatusIndicator.js +11 -0
  95. package/dist/status/StepTimeline.d.ts +26 -0
  96. package/dist/status/StepTimeline.js +16 -0
  97. package/dist/status/ThinkingCube.d.ts +24 -0
  98. package/dist/status/ThinkingCube.js +11 -0
  99. package/dist/useDialog.d.ts +45 -0
  100. package/dist/useDialog.js +122 -0
  101. package/dist/useGooPill.d.ts +57 -0
  102. package/dist/useGooPill.js +76 -0
  103. package/dist/useMenu.d.ts +50 -0
  104. package/dist/useMenu.js +119 -0
  105. package/dist/useTabs.d.ts +88 -0
  106. package/dist/useTabs.js +93 -0
  107. package/package.json +58 -0
  108. package/src/chrome-separated.css +132 -0
  109. package/src/chrome-unified.css +72 -0
  110. package/src/containers/Badges.css +116 -0
  111. package/src/containers/avatar.css +70 -0
  112. package/src/containers/card.css +75 -0
  113. package/src/containers/chip.css +103 -0
  114. package/src/containers/list-row.css +132 -0
  115. package/src/containers/message-bubble.css +112 -0
  116. package/src/containers/panel.css +120 -0
  117. package/src/containers/table.css +102 -0
  118. package/src/containers/tile.css +17 -0
  119. package/src/content/callout.css +77 -0
  120. package/src/content/caret.css +30 -0
  121. package/src/content/code-block.css +159 -0
  122. package/src/content/collapsible.css +80 -0
  123. package/src/content/inline-code.css +14 -0
  124. package/src/content/model-picker.css +219 -0
  125. package/src/content/source-card.css +99 -0
  126. package/src/content/sources-disclosure.css +118 -0
  127. package/src/controls.css +453 -0
  128. package/src/fonts.css +34 -0
  129. package/src/forms/Checkbox.css +114 -0
  130. package/src/forms/RadioGroup.css +115 -0
  131. package/src/goo.css +97 -0
  132. package/src/icon-motion.css +1909 -0
  133. package/src/layout/ResizablePanel.css +79 -0
  134. package/src/nav/sidebar.css +296 -0
  135. package/src/overlays/date-time-picker.css +238 -0
  136. package/src/overlays/menu.css +195 -0
  137. package/src/overlays/modal.css +167 -0
  138. package/src/overlays/picker-field.css +81 -0
  139. package/src/overlays/pill-filter.css +129 -0
  140. package/src/overlays/reveal.css +56 -0
  141. package/src/overlays/segmented-control.css +185 -0
  142. package/src/overlays/tabs.css +143 -0
  143. package/src/overlays/tooltip.css +126 -0
  144. package/src/scrollbar.css +184 -0
  145. package/src/size.css +179 -0
  146. package/src/status/progress-bar.css +88 -0
  147. package/src/status/spinner.css +33 -0
  148. package/src/status/statusindicator.css +65 -0
  149. package/src/status/steptimeline.css +137 -0
  150. package/src/status/thinkingcube.css +75 -0
  151. package/src/xeno-element.css +60 -0
  152. package/src/xeno-elements.css +71 -0
  153. package/src/xeno-theme.css +99 -0
@@ -0,0 +1,1909 @@
1
+ /* =============================================================================
2
+ XENO icon motion — bespoke per-glyph hover animations
3
+ -----------------------------------------------------------------------------
4
+ The renderer stamps a `data-glyph` attribute on every icon it draws
5
+ (`<svg class="xeno-element" data-glyph="search"> … </svg>`), gives every shape
6
+ inside it `class="xeno-part"`, and numbers each shape with a `data-part` index
7
+ (its position in the geometry).
8
+
9
+ Each glyph animates the moving parts that carry its MEANING: some drive their
10
+ individual PARTS (a bell's clapper, an eye's pupil, a list's rows), others move
11
+ as a WHOLE icon (a gear spins, an arrow travels). A glyph is one or the other —
12
+ never both — so nothing double-animates.
13
+
14
+ A single pair of triggers plays the right keyframe when the icon itself is
15
+ hovered, or when any interactive host that wraps it is hovered (buttons, chips,
16
+ toggles, list rows, menu items, tabs, …). Animations use transform and opacity
17
+ ONLY (compositor-friendly, no layout thrash); every keyframe starts and ends at
18
+ the identity transform so icons always rest cleanly. Motion is switched off
19
+ entirely under prefers-reduced-motion.
20
+ ============================================================================= */
21
+
22
+ /* ---- Base: defaults every glyph + every part inherits ---------------------- */
23
+ .xeno-element[data-glyph] {
24
+ transform-box: fill-box;
25
+ transform-origin: center;
26
+ --xeno-icon-anim: none;
27
+ --xeno-icon-dur: 520ms;
28
+ --xeno-icon-ease: var(--xeno-ease, cubic-bezier(0.22, 1, 0.36, 1));
29
+ }
30
+ .xeno-element[data-glyph] .xeno-part {
31
+ transform-box: fill-box;
32
+ transform-origin: center;
33
+ --part-anim: none;
34
+ --part-dur: 520ms;
35
+ }
36
+
37
+ /* ---- Per-glyph mapping + keyframes ----------------------------------------- */
38
+
39
+ /* menu (parts) — three bars ripple left-to-right, one after another */
40
+ .xeno-element[data-glyph='menu'] .xeno-part { transform-origin: left center; }
41
+ .xeno-element[data-glyph='menu'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-menu-0; }
42
+ .xeno-element[data-glyph='menu'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-menu-1; }
43
+ .xeno-element[data-glyph='menu'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-menu-2; }
44
+ @keyframes xeno-pt-menu-0 { 0% { transform: translateX(0); } 22% { transform: translateX(3px); } 48% { transform: translateX(0); } 100% { transform: translateX(0); } }
45
+ @keyframes xeno-pt-menu-1 { 0% { transform: translateX(0); } 16% { transform: translateX(0); } 38% { transform: translateX(3px); } 62% { transform: translateX(0); } 100% { transform: translateX(0); } }
46
+ @keyframes xeno-pt-menu-2 { 0% { transform: translateX(0); } 30% { transform: translateX(0); } 54% { transform: translateX(3px); } 78% { transform: translateX(0); } 100% { transform: translateX(0); } }
47
+
48
+ /* search (whole) — a scanning sweep with a lens-zoom */
49
+ .xeno-element[data-glyph='search'] { --xeno-icon-anim: xeno-ic-search; --xeno-icon-dur: 600ms; }
50
+ @keyframes xeno-ic-search {
51
+ 0% { transform: translate(0, 0) scale(1); }
52
+ 20% { transform: translate(2px, -2px) scale(1.08); }
53
+ 40% { transform: translate(3px, 1px) scale(1.12); }
54
+ 60% { transform: translate(-1px, 2px) scale(1.08); }
55
+ 80% { transform: translate(-2px, -1px) scale(1.04); }
56
+ 100% { transform: translate(0, 0) scale(1); }
57
+ }
58
+
59
+ /* plus (parts) — the plus draws itself: the vertical bar grows in, then the horizontal, each with a small overshoot */
60
+ .xeno-element[data-glyph='plus'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-plus-v; --part-dur: 540ms; transform-origin: center; }
61
+ .xeno-element[data-glyph='plus'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-plus-h; --part-dur: 540ms; transform-origin: center; }
62
+ @keyframes xeno-pt-plus-v {
63
+ 0% { transform: scaleY(0); }
64
+ 45% { transform: scaleY(1.15); }
65
+ 70% { transform: scaleY(0.95); }
66
+ 100% { transform: scaleY(1); }
67
+ }
68
+ @keyframes xeno-pt-plus-h {
69
+ 0%, 20% { transform: scaleX(0); }
70
+ 60% { transform: scaleX(1.15); }
71
+ 80% { transform: scaleX(0.95); }
72
+ 100% { transform: scaleX(1); }
73
+ }
74
+
75
+ /* minus (whole) — the bar collapses hard to a stub, holds, then springs back (decisive remove) */
76
+ .xeno-element[data-glyph='minus'] { --xeno-icon-anim: xeno-ic-minus; --xeno-icon-dur: 460ms; }
77
+ @keyframes xeno-ic-minus {
78
+ 0% { transform: scaleX(1); }
79
+ 34% { transform: scaleX(0.5); }
80
+ 54% { transform: scaleX(0.5); }
81
+ 80% { transform: scaleX(1.14); }
82
+ 100% { transform: scaleX(1); }
83
+ }
84
+
85
+ /* x (parts) — the two strokes draw themselves like an X being struck, then a small pop */
86
+ .xeno-element[data-glyph='x'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-x-draw; --part-dur: 520ms; stroke-dasharray: 32; }
87
+ @keyframes xeno-pt-x-draw {
88
+ 0% { stroke-dashoffset: 32; transform: scale(1); }
89
+ 58% { stroke-dashoffset: 0; transform: scale(1); }
90
+ 74% { transform: scale(1.14); }
91
+ 100% { stroke-dashoffset: 0; transform: scale(1); }
92
+ }
93
+
94
+ /* check (parts) — the tick draws itself heel-to-tip at an EVEN pace, then gives a confirming pop.
95
+ A draw-in needs a steady ease: the library's default ease-out front-loads it and the stroke
96
+ appears half-finished in the first few frames. */
97
+ .xeno-element[data-glyph='check'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
98
+ .xeno-element[data-glyph='check'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-check-draw; --part-dur: 900ms; stroke-dasharray: 23; }
99
+ @keyframes xeno-pt-check-draw {
100
+ 0% { stroke-dashoffset: 23; transform: scale(1); }
101
+ 72% { stroke-dashoffset: 0; transform: scale(1); }
102
+ 86% { stroke-dashoffset: 0; transform: scale(1.13); }
103
+ 100% { stroke-dashoffset: 0; transform: scale(1); }
104
+ }
105
+
106
+ /* chevron-down (whole) — dips down as if to expand */
107
+ .xeno-element[data-glyph='chevron-down'] { --xeno-icon-anim: xeno-ic-chevron-down; --xeno-icon-dur: 440ms; }
108
+ @keyframes xeno-ic-chevron-down {
109
+ 0% { transform: translateY(0) scaleY(1); }
110
+ 35% { transform: translateY(4px) scaleY(0.9); }
111
+ 65% { transform: translateY(-2px) scaleY(1.05); }
112
+ 100% { transform: translateY(0) scaleY(1); }
113
+ }
114
+
115
+ /* chevron-right (whole) — nudges onward */
116
+ .xeno-element[data-glyph='chevron-right'] { --xeno-icon-anim: xeno-ic-chevron-right; --xeno-icon-dur: 480ms; }
117
+ @keyframes xeno-ic-chevron-right {
118
+ 0% { transform: translateX(0); }
119
+ 25% { transform: translateX(-2px); }
120
+ 55% { transform: translateX(4px); }
121
+ 75% { transform: translateX(2px); }
122
+ 100% { transform: translateX(0); }
123
+ }
124
+
125
+ /* arrow-right (whole) — shoots forward from its tail */
126
+ .xeno-element[data-glyph='arrow-right'] { --xeno-icon-anim: xeno-ic-arrow-right; --xeno-icon-dur: 500ms; transform-origin: left center; }
127
+ @keyframes xeno-ic-arrow-right {
128
+ 0% { transform: translateX(0) scaleX(1); }
129
+ 30% { transform: translateX(-2px) scaleX(0.9); }
130
+ 60% { transform: translateX(5px) scaleX(1.15); }
131
+ 100% { transform: translateX(0) scaleX(1); }
132
+ }
133
+
134
+ /* arrow-up (whole) — an immediate leap with a floating double-bob */
135
+ .xeno-element[data-glyph='arrow-up'] { --xeno-icon-anim: xeno-ic-arrow-up; --xeno-icon-dur: 540ms; transform-origin: bottom center; }
136
+ @keyframes xeno-ic-arrow-up {
137
+ 0% { transform: translateY(0) scaleY(1); }
138
+ 30% { transform: translateY(-6px) scaleY(1.14); }
139
+ 56% { transform: translateY(-2px) scaleY(0.96); }
140
+ 78% { transform: translateY(-4px) scaleY(1.05); }
141
+ 100% { transform: translateY(0) scaleY(1); }
142
+ }
143
+
144
+ /* gear (whole) — cranks forward in two ratchet steps */
145
+ .xeno-element[data-glyph='gear'] { --xeno-icon-anim: xeno-ic-gear; --xeno-icon-dur: 620ms; }
146
+ @keyframes xeno-ic-gear {
147
+ 0% { transform: rotate(0deg); }
148
+ 20% { transform: rotate(20deg); }
149
+ 38% { transform: rotate(15deg); }
150
+ 58% { transform: rotate(38deg); }
151
+ 76% { transform: rotate(33deg); }
152
+ 100% { transform: rotate(0deg); }
153
+ }
154
+
155
+ /* bell (parts) — the bell rocks gently from its hang while the clapper at the BOTTOM swings hard and lags, like a real bell */
156
+ .xeno-element[data-glyph='bell'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-bell-body; --part-dur: 760ms; transform-origin: 50% 0%; }
157
+ .xeno-element[data-glyph='bell'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-bell-clap; --part-dur: 760ms; transform-origin: 50% 0%; }
158
+ @keyframes xeno-pt-bell-body {
159
+ 0%, 100% { transform: rotate(0deg); }
160
+ 22% { transform: rotate(14deg); }
161
+ 48% { transform: rotate(-11deg); }
162
+ 72% { transform: rotate(5deg); }
163
+ }
164
+ @keyframes xeno-pt-bell-clap {
165
+ 0%, 100% { transform: rotate(0deg); }
166
+ 16% { transform: rotate(34deg); }
167
+ 40% { transform: rotate(-30deg); }
168
+ 62% { transform: rotate(18deg); }
169
+ 82% { transform: rotate(-7deg); }
170
+ }
171
+
172
+ /* mic (parts) — the capsule throbs twice, as if actively catching sound */
173
+ .xeno-element[data-glyph='mic'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-mic-cap; }
174
+ @keyframes xeno-pt-mic-cap {
175
+ 0% { transform: scale(1); }
176
+ 18% { transform: scale(1.42); }
177
+ 36% { transform: scale(0.92); }
178
+ 54% { transform: scale(1.24); }
179
+ 74% { transform: scale(0.97); }
180
+ 100% { transform: scale(1); }
181
+ }
182
+
183
+ /* link (parts) — the two rings tug apart along the chain, then snap back */
184
+ .xeno-element[data-glyph='link'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-link-l; }
185
+ .xeno-element[data-glyph='link'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-link-r; }
186
+ @keyframes xeno-pt-link-l {
187
+ 0% { transform: translate(0, 0); }
188
+ 30% { transform: translate(-2px, 2px); }
189
+ 55% { transform: translate(1px, -1px); }
190
+ 100% { transform: translate(0, 0); }
191
+ }
192
+ @keyframes xeno-pt-link-r {
193
+ 0% { transform: translate(0, 0); }
194
+ 30% { transform: translate(2px, -2px); }
195
+ 55% { transform: translate(-1px, 1px); }
196
+ 100% { transform: translate(0, 0); }
197
+ }
198
+
199
+ /* lock (parts) — the shackle springs open, hovers, then snaps shut with a latch-click overshoot */
200
+ .xeno-element[data-glyph='lock'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-lock-shackle; transform-origin: bottom center; }
201
+ @keyframes xeno-pt-lock-shackle {
202
+ 0% { transform: translateY(0) scaleY(1); }
203
+ 18% { transform: translateY(-3px) scaleY(1.09); }
204
+ 46% { transform: translateY(-3px) scaleY(1.09); }
205
+ 64% { transform: translateY(0.6px) scaleY(0.93); }
206
+ 100% { transform: translateY(0) scaleY(1); }
207
+ }
208
+
209
+ /* heart (parts) — the solid fill rises from the point upward (the heart "fills up"), then the outline beats */
210
+ .xeno-element[data-glyph='heart'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-heart-beat; --part-dur: 820ms; transform-origin: center; }
211
+ .xeno-element[data-glyph='heart'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-heart-fill; --part-dur: 820ms; transform-origin: 50% 100%; opacity: 0; }
212
+ @keyframes xeno-pt-heart-fill {
213
+ 0% { transform: scaleY(0); opacity: 1; }
214
+ 55% { transform: scaleY(1); opacity: 1; }
215
+ 100% { transform: scaleY(1); opacity: 1; }
216
+ }
217
+ @keyframes xeno-pt-heart-beat {
218
+ 0%, 50% { transform: scale(1); }
219
+ 64% { transform: scale(1.14); }
220
+ 78% { transform: scale(0.97); }
221
+ 90% { transform: scale(1.03); }
222
+ 100% { transform: scale(1); }
223
+ }
224
+
225
+ /* user (parts) — the head gives a small nod above the shoulders */
226
+ .xeno-element[data-glyph='user'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-user-head; transform-origin: bottom center; }
227
+ @keyframes xeno-pt-user-head {
228
+ 0% { transform: translateY(0) rotate(0deg); }
229
+ 28% { transform: translateY(-3.2px) rotate(-13deg); }
230
+ 55% { transform: translateY(0) rotate(8deg); }
231
+ 78% { transform: translateY(0) rotate(-3deg); }
232
+ 100% { transform: translateY(0) rotate(0deg); }
233
+ }
234
+
235
+ /* globe (parts) — the world TURNS. Four meridian halves ride the sphere: each one enters at the left limb,
236
+ bulges out, flattens to a straight line as it crosses the poles, bulges the other way and merges into the
237
+ right limb, where it fades and passes behind. Two are on the face at any moment (one, at the quarter turns).
238
+
239
+ The projection is exact, not a fudge. A half-meridian at longitude L projects to the same half-ellipse
240
+ scaled horizontally by sin(L), so scaleX IS the reprojection — sin() drives every stop below, sampled every
241
+ 15deg of turn. The limbs are where it pays off: |scaleX| tops out at sqrt(2), which lands the meridian
242
+ exactly on x = 3 / x = 21, ON the frame, so opacity can switch there and the hand-over is invisible. The
243
+ previous version stopped at 7.0 of a 9 radius and vanished in mid-air, which is why only the middle of the
244
+ globe seemed to move.
245
+
246
+ All four share the polar axis as their transform origin, so they turn as one rigid sphere; the equator and
247
+ parallels do not move, because on a real globe they do not. Linear timing, or the spin would visibly slow
248
+ at the ends. NOTE: 3D transforms (rotateY/perspective) do NOT apply to shapes inside an svg — only to the
249
+ svg itself — so the rotation is carried honestly in 2D. `non-scaling-stroke` holds the line weight steady
250
+ while a meridian is stretched 1.41x, for the duration of the hover only. */
251
+ .xeno-element[data-glyph='globe'] { --xeno-icon-ease: linear; }
252
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='3'],
253
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='4'],
254
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='5'],
255
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='6'] {
256
+ --part-dur: 2800ms; --part-vfx: non-scaling-stroke;
257
+ transform-box: view-box; transform-origin: 12px 12px;
258
+ }
259
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-globe-m1; }
260
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='4'] { --part-anim: xeno-pt-globe-m2; }
261
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='5'] { --part-anim: xeno-pt-globe-m3; }
262
+ .xeno-element[data-glyph='globe'] .xeno-part[data-part='6'] { --part-anim: xeno-pt-globe-m4; }
263
+ /* [3] L=45deg at rest, on the face and heading for the right limb */
264
+ @keyframes xeno-pt-globe-m1 {
265
+ 0% { transform: scaleX(1); opacity: 1; }
266
+ 4.17% { transform: scaleX(1.22); opacity: 1; }
267
+ 8.33% { transform: scaleX(1.37); opacity: 1; }
268
+ 12.5% { transform: scaleX(1.41); opacity: 0; }
269
+ 16.67% { transform: scaleX(1.37); opacity: 0; }
270
+ 20.83% { transform: scaleX(1.22); opacity: 0; }
271
+ 25% { transform: scaleX(1); opacity: 0; }
272
+ 29.17% { transform: scaleX(0.71); opacity: 0; }
273
+ 33.33% { transform: scaleX(0.37); opacity: 0; }
274
+ 37.5% { transform: scaleX(0); opacity: 0; }
275
+ 41.67% { transform: scaleX(-0.37); opacity: 0; }
276
+ 45.83% { transform: scaleX(-0.71); opacity: 0; }
277
+ 50% { transform: scaleX(-1); opacity: 0; }
278
+ 54.17% { transform: scaleX(-1.22); opacity: 0; }
279
+ 58.33% { transform: scaleX(-1.37); opacity: 0; }
280
+ 62.5% { transform: scaleX(-1.41); opacity: 0; }
281
+ 66.67% { transform: scaleX(-1.37); opacity: 1; }
282
+ 70.83% { transform: scaleX(-1.22); opacity: 1; }
283
+ 75% { transform: scaleX(-1); opacity: 1; }
284
+ 79.17% { transform: scaleX(-0.71); opacity: 1; }
285
+ 83.33% { transform: scaleX(-0.37); opacity: 1; }
286
+ 87.5% { transform: scaleX(0); opacity: 1; }
287
+ 91.67% { transform: scaleX(0.37); opacity: 1; }
288
+ 95.83% { transform: scaleX(0.71); opacity: 1; }
289
+ 100% { transform: scaleX(1); opacity: 1; }
290
+ }
291
+ /* [4] L=135deg — behind, comes round the left limb at 41.67% */
292
+ @keyframes xeno-pt-globe-m2 {
293
+ 0% { transform: scaleX(1); opacity: 0; }
294
+ 4.17% { transform: scaleX(0.71); opacity: 0; }
295
+ 8.33% { transform: scaleX(0.37); opacity: 0; }
296
+ 12.5% { transform: scaleX(0); opacity: 0; }
297
+ 16.67% { transform: scaleX(-0.37); opacity: 0; }
298
+ 20.83% { transform: scaleX(-0.71); opacity: 0; }
299
+ 25% { transform: scaleX(-1); opacity: 0; }
300
+ 29.17% { transform: scaleX(-1.22); opacity: 0; }
301
+ 33.33% { transform: scaleX(-1.37); opacity: 0; }
302
+ 37.5% { transform: scaleX(-1.41); opacity: 0; }
303
+ 41.67% { transform: scaleX(-1.37); opacity: 1; }
304
+ 45.83% { transform: scaleX(-1.22); opacity: 1; }
305
+ 50% { transform: scaleX(-1); opacity: 1; }
306
+ 54.17% { transform: scaleX(-0.71); opacity: 1; }
307
+ 58.33% { transform: scaleX(-0.37); opacity: 1; }
308
+ 62.5% { transform: scaleX(0); opacity: 1; }
309
+ 66.67% { transform: scaleX(0.37); opacity: 1; }
310
+ 70.83% { transform: scaleX(0.71); opacity: 1; }
311
+ 75% { transform: scaleX(1); opacity: 1; }
312
+ 79.17% { transform: scaleX(1.22); opacity: 1; }
313
+ 83.33% { transform: scaleX(1.37); opacity: 1; }
314
+ 87.5% { transform: scaleX(1.41); opacity: 0; }
315
+ 91.67% { transform: scaleX(1.37); opacity: 0; }
316
+ 95.83% { transform: scaleX(1.22); opacity: 0; }
317
+ 100% { transform: scaleX(1); opacity: 0; }
318
+ }
319
+ /* [5] L=225deg — the twin of [3], half a turn behind it */
320
+ @keyframes xeno-pt-globe-m3 {
321
+ 0% { transform: scaleX(1); opacity: 0; }
322
+ 4.17% { transform: scaleX(1.22); opacity: 0; }
323
+ 8.33% { transform: scaleX(1.37); opacity: 0; }
324
+ 12.5% { transform: scaleX(1.41); opacity: 0; }
325
+ 16.67% { transform: scaleX(1.37); opacity: 1; }
326
+ 20.83% { transform: scaleX(1.22); opacity: 1; }
327
+ 25% { transform: scaleX(1); opacity: 1; }
328
+ 29.17% { transform: scaleX(0.71); opacity: 1; }
329
+ 33.33% { transform: scaleX(0.37); opacity: 1; }
330
+ 37.5% { transform: scaleX(0); opacity: 1; }
331
+ 41.67% { transform: scaleX(-0.37); opacity: 1; }
332
+ 45.83% { transform: scaleX(-0.71); opacity: 1; }
333
+ 50% { transform: scaleX(-1); opacity: 1; }
334
+ 54.17% { transform: scaleX(-1.22); opacity: 1; }
335
+ 58.33% { transform: scaleX(-1.37); opacity: 1; }
336
+ 62.5% { transform: scaleX(-1.41); opacity: 0; }
337
+ 66.67% { transform: scaleX(-1.37); opacity: 0; }
338
+ 70.83% { transform: scaleX(-1.22); opacity: 0; }
339
+ 75% { transform: scaleX(-1); opacity: 0; }
340
+ 79.17% { transform: scaleX(-0.71); opacity: 0; }
341
+ 83.33% { transform: scaleX(-0.37); opacity: 0; }
342
+ 87.5% { transform: scaleX(0); opacity: 0; }
343
+ 91.67% { transform: scaleX(0.37); opacity: 0; }
344
+ 95.83% { transform: scaleX(0.71); opacity: 0; }
345
+ 100% { transform: scaleX(1); opacity: 0; }
346
+ }
347
+ /* [6] L=315deg — the twin of [4]; at rest it is the left arc of the ellipse */
348
+ @keyframes xeno-pt-globe-m4 {
349
+ 0% { transform: scaleX(1); opacity: 1; }
350
+ 4.17% { transform: scaleX(0.71); opacity: 1; }
351
+ 8.33% { transform: scaleX(0.37); opacity: 1; }
352
+ 12.5% { transform: scaleX(0); opacity: 1; }
353
+ 16.67% { transform: scaleX(-0.37); opacity: 1; }
354
+ 20.83% { transform: scaleX(-0.71); opacity: 1; }
355
+ 25% { transform: scaleX(-1); opacity: 1; }
356
+ 29.17% { transform: scaleX(-1.22); opacity: 1; }
357
+ 33.33% { transform: scaleX(-1.37); opacity: 1; }
358
+ 37.5% { transform: scaleX(-1.41); opacity: 0; }
359
+ 41.67% { transform: scaleX(-1.37); opacity: 0; }
360
+ 45.83% { transform: scaleX(-1.22); opacity: 0; }
361
+ 50% { transform: scaleX(-1); opacity: 0; }
362
+ 54.17% { transform: scaleX(-0.71); opacity: 0; }
363
+ 58.33% { transform: scaleX(-0.37); opacity: 0; }
364
+ 62.5% { transform: scaleX(0); opacity: 0; }
365
+ 66.67% { transform: scaleX(0.37); opacity: 0; }
366
+ 70.83% { transform: scaleX(0.71); opacity: 0; }
367
+ 75% { transform: scaleX(1); opacity: 0; }
368
+ 79.17% { transform: scaleX(1.22); opacity: 0; }
369
+ 83.33% { transform: scaleX(1.37); opacity: 0; }
370
+ 87.5% { transform: scaleX(1.41); opacity: 0; }
371
+ 91.67% { transform: scaleX(1.37); opacity: 1; }
372
+ 95.83% { transform: scaleX(1.22); opacity: 1; }
373
+ 100% { transform: scaleX(1); opacity: 1; }
374
+ }
375
+
376
+ /* eye (parts) — a blink of the lid and a glance of the pupil */
377
+ .xeno-element[data-glyph='eye'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-eye-lid; --part-vfx: non-scaling-stroke; }
378
+ .xeno-element[data-glyph='eye'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-eye-pupil; }
379
+ @keyframes xeno-pt-eye-lid {
380
+ 0% { transform: scaleY(1); }
381
+ 40% { transform: scaleY(1); }
382
+ 50% { transform: scaleY(0.15); }
383
+ 60% { transform: scaleY(1); }
384
+ 100% { transform: scaleY(1); }
385
+ }
386
+ @keyframes xeno-pt-eye-pupil {
387
+ 0% { transform: translateX(0); }
388
+ 22% { transform: translateX(-2.5px); }
389
+ 52% { transform: translateX(2.5px); }
390
+ 78% { transform: translateX(-1px); }
391
+ 100% { transform: translateX(0); }
392
+ }
393
+
394
+ /* alert (parts) — the exclamation shakes inside a steady frame */
395
+ .xeno-element[data-glyph='alert'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-alert-mark; }
396
+ .xeno-element[data-glyph='alert'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-alert-mark; }
397
+ @keyframes xeno-pt-alert-mark {
398
+ 0% { transform: translateX(0); }
399
+ 15% { transform: translateX(-3.2px); }
400
+ 30% { transform: translateX(3.2px); }
401
+ 45% { transform: translateX(-2.5px); }
402
+ 60% { transform: translateX(2.1px); }
403
+ 78% { transform: translateX(-1px); }
404
+ 100% { transform: translateX(0); }
405
+ }
406
+
407
+ /* bookmark (whole) — the ribbon swings on its top pin */
408
+ .xeno-element[data-glyph='bookmark'] { --xeno-icon-anim: xeno-ic-bookmark; --xeno-icon-dur: 640ms; transform-origin: top center; }
409
+ @keyframes xeno-ic-bookmark {
410
+ 0% { transform: translateY(0) rotate(0deg); }
411
+ 22% { transform: translateY(-1px) rotate(-13deg); }
412
+ 50% { transform: translateY(0) rotate(9deg); }
413
+ 74% { transform: translateY(0) rotate(-4deg); }
414
+ 100% { transform: translateY(0) rotate(0deg); }
415
+ }
416
+
417
+ /* star (whole) — a full spin that twinkles at the turns */
418
+ .xeno-element[data-glyph='star'] { --xeno-icon-anim: xeno-ic-star; --xeno-icon-dur: 620ms; }
419
+ @keyframes xeno-ic-star {
420
+ 0% { transform: rotate(0deg) scale(1); opacity: 1; }
421
+ 30% { transform: rotate(120deg) scale(1.18); opacity: 0.86; }
422
+ 55% { transform: rotate(205deg) scale(0.92); opacity: 1; }
423
+ 78% { transform: rotate(300deg) scale(1.06); }
424
+ 100% { transform: rotate(360deg) scale(1); opacity: 1; }
425
+ }
426
+
427
+ /* image (parts) — a day timelapse: the sun arcs up and across the sky (bigger at its peak) then back down */
428
+ .xeno-element[data-glyph='image'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-image-sun; --part-dur: 1300ms; transform-origin: center; }
429
+ @keyframes xeno-pt-image-sun {
430
+ 0% { transform: translate(0, 0) scale(1); }
431
+ 22% { transform: translate(2.5px, -3px) scale(1.14); }
432
+ 48% { transform: translate(5px, -3.5px) scale(1.2); }
433
+ 74% { transform: translate(6.5px, -1px) scale(1.06); }
434
+ 100% { transform: translate(0, 0) scale(1); }
435
+ }
436
+
437
+ /* file (parts) — the document's lines of text write themselves in, top to bottom */
438
+ .xeno-element[data-glyph='file'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-file-l0; --part-dur: 860ms; stroke-dasharray: 8; }
439
+ .xeno-element[data-glyph='file'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-file-l1; --part-dur: 860ms; stroke-dasharray: 8; }
440
+ .xeno-element[data-glyph='file'] .xeno-part[data-part='4'] { --part-anim: xeno-pt-file-l2; --part-dur: 860ms; stroke-dasharray: 5; }
441
+ @keyframes xeno-pt-file-l0 { 0%, 12% { stroke-dashoffset: 8; } 42%, 100% { stroke-dashoffset: 0; } }
442
+ @keyframes xeno-pt-file-l1 { 0%, 32% { stroke-dashoffset: 8; } 62%, 100% { stroke-dashoffset: 0; } }
443
+ @keyframes xeno-pt-file-l2 { 0%, 52% { stroke-dashoffset: 5; } 82%, 100% { stroke-dashoffset: 0; } }
444
+
445
+ /* folder (parts) — a document rises OUT of the folder's mouth and drops back in. The sheet and its lines
446
+ share one origin pinned to the folder's mouth (`view-box` lets all three parts use the same absolute
447
+ point) and grow upward from it, so NOTHING is ever drawn inside the folder — no show-through, which a
448
+ plain translate would give away since the folder's outline has no fill to hide behind. */
449
+ .xeno-element[data-glyph='folder'] { --xeno-icon-ease: cubic-bezier(0.34, 1.2, 0.5, 1); }
450
+ .xeno-element[data-glyph='folder'] .xeno-part[data-part='0'],
451
+ .xeno-element[data-glyph='folder'] .xeno-part[data-part='1'],
452
+ .xeno-element[data-glyph='folder'] .xeno-part[data-part='2'] {
453
+ --part-anim: xeno-pt-folder-doc; --part-dur: 900ms;
454
+ transform-box: view-box; transform-origin: 13.4px 7.4px; opacity: 0;
455
+ }
456
+ .xeno-element[data-glyph='folder'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-folder-body; --part-dur: 900ms; transform-origin: 50% 100%; }
457
+ @keyframes xeno-pt-folder-doc {
458
+ 0% { transform: scaleY(0); opacity: 1; }
459
+ 42% { transform: scaleY(1); opacity: 1; }
460
+ 74% { transform: scaleY(1); opacity: 1; }
461
+ 100% { transform: scaleY(0); opacity: 1; }
462
+ }
463
+ @keyframes xeno-pt-folder-body {
464
+ 0%, 100% { transform: scaleY(1); }
465
+ 42% { transform: scaleY(1.03); }
466
+ 76% { transform: scaleY(1.03); }
467
+ }
468
+
469
+ /* copy (parts) — the front sheet lifts up-and-away to reveal the duplicate beneath it, then settles back */
470
+ .xeno-element[data-glyph='copy'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-copy-front; --part-dur: 640ms; }
471
+ @keyframes xeno-pt-copy-front {
472
+ 0% { transform: translate(0, 0) scale(1); }
473
+ 38% { transform: translate(-2.6px, -2.6px) scale(1.05); }
474
+ 70% { transform: translate(0.8px, 0.8px) scale(0.99); }
475
+ 100% { transform: translate(0, 0) scale(1); }
476
+ }
477
+
478
+ /* edit (parts) — the pencil actually WRITES: it travels to the right, leaving a drawn stroke behind
479
+ its tip, nodding on each press; the stroke then fades and the pencil returns. */
480
+ .xeno-element[data-glyph='edit'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
481
+ .xeno-element[data-glyph='edit'] .xeno-part[data-part='0'],
482
+ .xeno-element[data-glyph='edit'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-edit-hand; --part-dur: 1000ms; transform-origin: 18% 88%; }
483
+ .xeno-element[data-glyph='edit'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-edit-stroke; --part-dur: 1000ms; stroke-dasharray: 7.5; opacity: 0; }
484
+ @keyframes xeno-pt-edit-hand {
485
+ 0% { transform: translate(0, 0) rotate(0deg); }
486
+ 12% { transform: translate(0.3px, 0.5px) rotate(-2deg); }
487
+ 30% { transform: translate(2px, 0.2px) rotate(1.5deg); }
488
+ 48% { transform: translate(3.6px, 0.5px) rotate(-1.5deg); }
489
+ 66% { transform: translate(5.2px, 0.2px) rotate(1.5deg); }
490
+ 78% { transform: translate(5.2px, 0) rotate(0deg); }
491
+ 100% { transform: translate(0, 0) rotate(0deg); }
492
+ }
493
+ @keyframes xeno-pt-edit-stroke {
494
+ 0% { stroke-dashoffset: 7.5; opacity: 0; }
495
+ 12% { stroke-dashoffset: 7.5; opacity: 1; }
496
+ 70% { stroke-dashoffset: 0; opacity: 1; }
497
+ 86% { stroke-dashoffset: 0; opacity: 1; }
498
+ 100% { stroke-dashoffset: 0; opacity: 0; }
499
+ }
500
+
501
+ /* trash (parts) — the lid lifts open, the bin gulps (something drops in), the lid drops shut */
502
+ .xeno-element[data-glyph='trash'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-trash-lid; --part-dur: 760ms; transform-origin: 0% 60%; }
503
+ .xeno-element[data-glyph='trash'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-trash-bin; --part-dur: 760ms; transform-origin: center top; }
504
+ @keyframes xeno-pt-trash-lid {
505
+ 0% { transform: rotate(0deg) translateY(0); }
506
+ 22% { transform: rotate(-26deg) translateY(-1.2px); }
507
+ 55% { transform: rotate(-26deg) translateY(-1.2px); }
508
+ 82%, 100% { transform: rotate(0deg) translateY(0); }
509
+ }
510
+ @keyframes xeno-pt-trash-bin {
511
+ 0%, 30%, 100% { transform: scaleY(1); }
512
+ 44% { transform: scaleY(0.9); }
513
+ 62% { transform: scaleY(1.02); }
514
+ }
515
+
516
+ /* download (parts) — only the arrow plunges down into the tray, which gives a little bounce (split geometry) */
517
+ .xeno-element[data-glyph='download'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-download-arrow; --part-dur: 680ms; transform-origin: center; }
518
+ .xeno-element[data-glyph='download'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-download-tray; --part-dur: 680ms; transform-origin: center; }
519
+ @keyframes xeno-pt-download-arrow {
520
+ 0% { transform: translateY(0) scaleY(1); }
521
+ 18% { transform: translateY(-2.5px) scaleY(1.05); }
522
+ 50% { transform: translateY(4.5px) scaleY(1.08); }
523
+ 66% { transform: translateY(3px) scaleY(0.82); }
524
+ 84% { transform: translateY(0) scaleY(1); }
525
+ 100% { transform: translateY(0) scaleY(1); }
526
+ }
527
+ @keyframes xeno-pt-download-tray {
528
+ 0%, 46%, 100% { transform: translateY(0) scaleX(1); }
529
+ 56% { transform: translateY(0.8px) scaleX(1.06); }
530
+ 72% { transform: translateY(0) scaleX(1); }
531
+ }
532
+
533
+ /* upload (parts) — the arrow launches up out of its tray, fading at the apex as if sent, then settles */
534
+ .xeno-element[data-glyph='upload'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-upload-arrow; transform-origin: bottom center; }
535
+ @keyframes xeno-pt-upload-arrow {
536
+ 0% { transform: translateY(0); opacity: 1; }
537
+ 30% { transform: translateY(-4.5px); opacity: 0.68; }
538
+ 52% { transform: translateY(-0.5px); opacity: 1; }
539
+ 76% { transform: translateY(-2px); }
540
+ 100% { transform: translateY(0); opacity: 1; }
541
+ }
542
+
543
+ /* send (whole) — the plane darts off and fades, then returns */
544
+ .xeno-element[data-glyph='send'] { --xeno-icon-anim: xeno-ic-send; --xeno-icon-dur: 640ms; }
545
+ @keyframes xeno-ic-send {
546
+ 0% { transform: translate(0, 0) scale(1); opacity: 1; }
547
+ 18% { transform: translate(-1.5px, 1.5px) scale(0.92); opacity: 1; }
548
+ 52% { transform: translate(4.5px, -4.5px) scale(1.08); opacity: 0.55; }
549
+ 60% { transform: translate(5px, -5px) scale(1.02); opacity: 0.5; }
550
+ 61% { transform: translate(-3.5px, 3.5px) scale(0.9); opacity: 0.5; }
551
+ 100% { transform: translate(0, 0) scale(1); opacity: 1; }
552
+ }
553
+
554
+ /* code (parts) — the two brackets fly apart to open a gap, then settle (the split geometry, animated) */
555
+ .xeno-element[data-glyph='code'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-code-l; --part-dur: 560ms; }
556
+ .xeno-element[data-glyph='code'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-code-r; --part-dur: 560ms; }
557
+ @keyframes xeno-pt-code-l {
558
+ 0% { transform: translateX(0); }
559
+ 30% { transform: translateX(-2.5px); }
560
+ 55% { transform: translateX(0.8px); }
561
+ 100% { transform: translateX(0); }
562
+ }
563
+ @keyframes xeno-pt-code-r {
564
+ 0% { transform: translateX(0); }
565
+ 30% { transform: translateX(2.5px); }
566
+ 55% { transform: translateX(-0.8px); }
567
+ 100% { transform: translateX(0); }
568
+ }
569
+
570
+ /* terminal (parts) — like a real console, ONLY the cursor blinks; the ">" prompt + window stay still */
571
+ .xeno-element[data-glyph='terminal'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-terminal-cursor; --part-dur: 900ms; }
572
+ @keyframes xeno-pt-terminal-cursor {
573
+ 0%, 100% { opacity: 1; }
574
+ 25% { opacity: 1; }
575
+ 26%, 55% { opacity: 0; }
576
+ 56% { opacity: 1; }
577
+ }
578
+
579
+ /* more (parts) — the three dots bounce in sequence */
580
+ .xeno-element[data-glyph='more'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-more-0; }
581
+ .xeno-element[data-glyph='more'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-more-1; }
582
+ .xeno-element[data-glyph='more'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-more-2; }
583
+ @keyframes xeno-pt-more-0 { 0% { transform: translateY(0); } 22% { transform: translateY(-3px); } 46% { transform: translateY(0); } 100% { transform: translateY(0); } }
584
+ @keyframes xeno-pt-more-1 { 0% { transform: translateY(0); } 14% { transform: translateY(0); } 36% { transform: translateY(-3px); } 60% { transform: translateY(0); } 100% { transform: translateY(0); } }
585
+ @keyframes xeno-pt-more-2 { 0% { transform: translateY(0); } 28% { transform: translateY(0); } 50% { transform: translateY(-3px); } 74% { transform: translateY(0); } 100% { transform: translateY(0); } }
586
+
587
+ /* grid (parts) — the four tiles pop on in a diagonal cascade */
588
+ .xeno-element[data-glyph='grid'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-grid-0; }
589
+ .xeno-element[data-glyph='grid'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-grid-1; }
590
+ .xeno-element[data-glyph='grid'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-grid-2; }
591
+ .xeno-element[data-glyph='grid'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-grid-3; }
592
+ @keyframes xeno-pt-grid-0 { 0% { transform: scale(1); } 20% { transform: scale(1.5); } 44% { transform: scale(1); } 100% { transform: scale(1); } }
593
+ @keyframes xeno-pt-grid-1 { 0% { transform: scale(1); } 12% { transform: scale(1); } 32% { transform: scale(1.5); } 56% { transform: scale(1); } 100% { transform: scale(1); } }
594
+ @keyframes xeno-pt-grid-2 { 0% { transform: scale(1); } 24% { transform: scale(1); } 44% { transform: scale(1.5); } 68% { transform: scale(1); } 100% { transform: scale(1); } }
595
+ @keyframes xeno-pt-grid-3 { 0% { transform: scale(1); } 36% { transform: scale(1); } 56% { transform: scale(1.5); } 80% { transform: scale(1); } 100% { transform: scale(1); } }
596
+
597
+ /* list (parts) — each row's bullet pops and its line slides in, top to bottom */
598
+ .xeno-element[data-glyph='list'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-list-l0; transform-origin: left center; }
599
+ .xeno-element[data-glyph='list'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-list-l1; transform-origin: left center; }
600
+ .xeno-element[data-glyph='list'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-list-l2; transform-origin: left center; }
601
+ .xeno-element[data-glyph='list'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-list-b0; }
602
+ .xeno-element[data-glyph='list'] .xeno-part[data-part='4'] { --part-anim: xeno-pt-list-b1; }
603
+ .xeno-element[data-glyph='list'] .xeno-part[data-part='5'] { --part-anim: xeno-pt-list-b2; }
604
+ @keyframes xeno-pt-list-l0 { 0% { transform: translateX(0); } 20% { transform: translateX(2.5px); } 46% { transform: translateX(0); } 100% { transform: translateX(0); } }
605
+ @keyframes xeno-pt-list-l1 { 0% { transform: translateX(0); } 14% { transform: translateX(0); } 34% { transform: translateX(2.5px); } 60% { transform: translateX(0); } 100% { transform: translateX(0); } }
606
+ @keyframes xeno-pt-list-l2 { 0% { transform: translateX(0); } 28% { transform: translateX(0); } 48% { transform: translateX(2.5px); } 74% { transform: translateX(0); } 100% { transform: translateX(0); } }
607
+ @keyframes xeno-pt-list-b0 { 0% { transform: scale(1); } 20% { transform: scale(1.3); } 46% { transform: scale(1); } 100% { transform: scale(1); } }
608
+ @keyframes xeno-pt-list-b1 { 0% { transform: scale(1); } 14% { transform: scale(1); } 34% { transform: scale(1.3); } 60% { transform: scale(1); } 100% { transform: scale(1); } }
609
+ @keyframes xeno-pt-list-b2 { 0% { transform: scale(1); } 28% { transform: scale(1); } 48% { transform: scale(1.3); } 74% { transform: scale(1); } 100% { transform: scale(1); } }
610
+
611
+ /* filter (whole) — the funnel pinches narrow then relaxes */
612
+ .xeno-element[data-glyph='filter'] { --xeno-icon-anim: xeno-ic-filter; --xeno-icon-dur: 500ms; transform-origin: top center; }
613
+ @keyframes xeno-ic-filter {
614
+ 0% { transform: scaleX(1) scaleY(1); }
615
+ 30% { transform: scaleX(0.68) scaleY(1.06); }
616
+ 50% { transform: scaleX(0.76) scaleY(1.03); }
617
+ 70% { transform: scaleX(1.07) scaleY(0.95); }
618
+ 100% { transform: scaleX(1) scaleY(1); }
619
+ }
620
+
621
+ /* sliders (parts) — the two knobs travel their tracks in opposite directions */
622
+ .xeno-element[data-glyph='sliders'] .xeno-part[data-part='4'] { --part-anim: xeno-pt-sliders-k1; }
623
+ .xeno-element[data-glyph='sliders'] .xeno-part[data-part='5'] { --part-anim: xeno-pt-sliders-k2; }
624
+ @keyframes xeno-pt-sliders-k1 {
625
+ 0% { transform: translateX(0); }
626
+ 30% { transform: translateX(-4px); }
627
+ 60% { transform: translateX(1.5px); }
628
+ 100% { transform: translateX(0); }
629
+ }
630
+ @keyframes xeno-pt-sliders-k2 {
631
+ 0% { transform: translateX(0); }
632
+ 30% { transform: translateX(4px); }
633
+ 60% { transform: translateX(-1.5px); }
634
+ 100% { transform: translateX(0); }
635
+ }
636
+
637
+ /* refresh (whole) — a wind-up then a full clockwise revolution */
638
+ .xeno-element[data-glyph='refresh'] { --xeno-icon-anim: xeno-ic-refresh; --xeno-icon-dur: 680ms; }
639
+ @keyframes xeno-ic-refresh {
640
+ 0% { transform: rotate(0deg) scale(1); }
641
+ 12% { transform: rotate(-28deg) scale(0.95); }
642
+ 60% { transform: rotate(230deg) scale(1.02); }
643
+ 100% { transform: rotate(360deg) scale(1); }
644
+ }
645
+
646
+ /* share (parts) — the three nodes pulse along the branch */
647
+ .xeno-element[data-glyph='share'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-share-0; }
648
+ .xeno-element[data-glyph='share'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-share-1; }
649
+ .xeno-element[data-glyph='share'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-share-2; }
650
+ @keyframes xeno-pt-share-0 { 0% { transform: scale(1); } 22% { transform: scale(1.75); } 48% { transform: scale(1); } 100% { transform: scale(1); } }
651
+ @keyframes xeno-pt-share-1 { 0% { transform: scale(1); } 16% { transform: scale(1); } 38% { transform: scale(1.75); } 62% { transform: scale(1); } 100% { transform: scale(1); } }
652
+ @keyframes xeno-pt-share-2 { 0% { transform: scale(1); } 30% { transform: scale(1); } 52% { transform: scale(1.75); } 76% { transform: scale(1); } 100% { transform: scale(1); } }
653
+
654
+ /* clock (parts) — the hands sweep a full clockwise revolution about the centre, like a real clock; the face holds still */
655
+ .xeno-element[data-glyph='clock'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-clock-hands; --part-dur: 900ms; transform-origin: 0% 69%; }
656
+ @keyframes xeno-pt-clock-hands {
657
+ 0% { transform: rotate(0deg); }
658
+ 100% { transform: rotate(360deg); }
659
+ }
660
+
661
+ /* home (parts) — the house BUILDS itself: the walls rise off the ground, the roof drops on top and lands
662
+ with a small settle, then the door appears last. All three share the ground line as their origin. */
663
+ .xeno-element[data-glyph='home'] { --xeno-icon-ease: cubic-bezier(0.34, 1.3, 0.5, 1); }
664
+ .xeno-element[data-glyph='home'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-home-roof; --part-dur: 1000ms; transform-box: view-box; transform-origin: 12px 20px; }
665
+ .xeno-element[data-glyph='home'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-home-walls; --part-dur: 1000ms; transform-box: view-box; transform-origin: 12px 20px; }
666
+ .xeno-element[data-glyph='home'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-home-door; --part-dur: 1000ms; transform-box: view-box; transform-origin: 12px 20px; }
667
+ @keyframes xeno-pt-home-walls {
668
+ 0% { transform: scaleY(0); }
669
+ 34% { transform: scaleY(1); }
670
+ 100% { transform: scaleY(1); }
671
+ }
672
+ @keyframes xeno-pt-home-roof {
673
+ 0%, 22% { transform: translateY(-9px); opacity: 0; }
674
+ 30% { transform: translateY(-9px); opacity: 1; }
675
+ 56% { transform: translateY(0); opacity: 1; }
676
+ 66% { transform: translateY(-1.2px); opacity: 1; }
677
+ 78% { transform: translateY(0); opacity: 1; }
678
+ 100% { transform: translateY(0); opacity: 1; }
679
+ }
680
+ @keyframes xeno-pt-home-door {
681
+ 0%, 70% { transform: scaleY(0); }
682
+ 88% { transform: scaleY(1); }
683
+ 100% { transform: scaleY(1); }
684
+ }
685
+
686
+ /* info (parts) — only the i-dot lifts and taps back down with a little squash ("gets dotted") */
687
+ .xeno-element[data-glyph='info'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-info-dot; --part-dur: 620ms; }
688
+ @keyframes xeno-pt-info-dot {
689
+ 0% { transform: translateY(0) scale(1, 1); }
690
+ 35% { transform: translateY(-3px) scale(1, 1); }
691
+ 68% { transform: translateY(0.5px) scale(1.18, 0.82); }
692
+ 84% { transform: translateY(0) scale(0.94, 1.06); }
693
+ 100% { transform: translateY(0) scale(1, 1); }
694
+ }
695
+
696
+ /* help (parts) — the question mark writes itself: the hook draws in, then the dot drops onto the page */
697
+ .xeno-element[data-glyph='help'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-help-draw; --part-dur: 720ms; stroke-dasharray: 16; }
698
+ .xeno-element[data-glyph='help'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-help-dot; --part-dur: 720ms; transform-origin: center; }
699
+ @keyframes xeno-pt-help-draw {
700
+ 0% { stroke-dashoffset: 16; }
701
+ 55% { stroke-dashoffset: 0; }
702
+ 100% { stroke-dashoffset: 0; }
703
+ }
704
+ @keyframes xeno-pt-help-dot {
705
+ 0%, 48% { transform: translateY(-3px) scale(0); opacity: 0; }
706
+ 66% { transform: translateY(0) scale(1.3); opacity: 1; }
707
+ 82% { transform: translateY(0) scale(0.9); opacity: 1; }
708
+ 100% { transform: translateY(0) scale(1); opacity: 1; }
709
+ }
710
+
711
+ /* message (parts) — the bubble nudges from its tail while the text lines write themselves in */
712
+ .xeno-element[data-glyph='message'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-message-pop; --part-dur: 620ms; transform-origin: 0% 100%; }
713
+ .xeno-element[data-glyph='message'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-message-lines; --part-dur: 620ms; stroke-dasharray: 12; }
714
+ @keyframes xeno-pt-message-pop {
715
+ 0% { transform: scale(1) rotate(0deg); }
716
+ 40% { transform: scale(1.06) rotate(-2deg); }
717
+ 72% { transform: scale(0.99) rotate(0.5deg); }
718
+ 100% { transform: scale(1) rotate(0deg); }
719
+ }
720
+ @keyframes xeno-pt-message-lines {
721
+ 0% { stroke-dashoffset: 12; opacity: 0; }
722
+ 35% { stroke-dashoffset: 9; opacity: 1; }
723
+ 100% { stroke-dashoffset: 0; opacity: 1; }
724
+ }
725
+
726
+ /* attach (whole) — the clip swings from its top hinge */
727
+ .xeno-element[data-glyph='attach'] { --xeno-icon-anim: xeno-ic-attach; --xeno-icon-dur: 700ms; transform-origin: top center; }
728
+ @keyframes xeno-ic-attach {
729
+ 0% { transform: translateY(0) rotate(0deg); }
730
+ 15% { transform: translateY(1px) rotate(14deg); }
731
+ 38% { transform: translateY(0) rotate(-10deg); }
732
+ 58% { transform: translateY(0) rotate(6deg); }
733
+ 78% { transform: translateY(0) rotate(-3deg); }
734
+ 100% { transform: translateY(0) rotate(0deg); }
735
+ }
736
+
737
+ /* layers (parts) — the top and bottom sheets fan apart, then re-stack */
738
+ .xeno-element[data-glyph='layers'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-layers-top; }
739
+ .xeno-element[data-glyph='layers'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-layers-bot; }
740
+ @keyframes xeno-pt-layers-top {
741
+ 0% { transform: translateY(0); }
742
+ 30% { transform: translateY(-2.5px); }
743
+ 60% { transform: translateY(0.5px); }
744
+ 100% { transform: translateY(0); }
745
+ }
746
+ @keyframes xeno-pt-layers-bot {
747
+ 0% { transform: translateY(0); }
748
+ 30% { transform: translateY(2px); }
749
+ 60% { transform: translateY(-0.5px); }
750
+ 100% { transform: translateY(0); }
751
+ }
752
+
753
+ /* sun (parts) — the rays wheel a full turn while the core pulses warm */
754
+ .xeno-element[data-glyph='sun'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-sun-core; }
755
+ .xeno-element[data-glyph='sun'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-sun-rays; --part-dur: 700ms; }
756
+ @keyframes xeno-pt-sun-core {
757
+ 0% { transform: scale(1); }
758
+ 30% { transform: scale(1.14); }
759
+ 60% { transform: scale(0.96); }
760
+ 100% { transform: scale(1); }
761
+ }
762
+ @keyframes xeno-pt-sun-rays {
763
+ 0% { transform: rotate(0deg); }
764
+ 100% { transform: rotate(360deg); }
765
+ }
766
+
767
+ /* archive (parts) — the lid LIFTS STRAIGHT UP and presses back down, and the box takes the press.
768
+ Deliberately not the trash lid, which hinges open and flaps: archiving is putting something away and
769
+ sealing it, discarding is throwing it out, and the two gestures should not look alike on icons this
770
+ close in meaning. The slot holds still — it is the mark that says things come back out. */
771
+ .xeno-element[data-glyph='archive'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-archive-lid; --part-dur: 620ms; }
772
+ .xeno-element[data-glyph='archive'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-archive-body; --part-dur: 620ms; transform-origin: center bottom; }
773
+ @keyframes xeno-pt-archive-lid {
774
+ 0% { transform: translateY(0); }
775
+ 30% { transform: translateY(-3px); }
776
+ 62% { transform: translateY(0.6px); }
777
+ 100% { transform: translateY(0); }
778
+ }
779
+ @keyframes xeno-pt-archive-body {
780
+ 0% { transform: scaleY(1); }
781
+ 62% { transform: scaleY(0.96); }
782
+ 100% { transform: scaleY(1); }
783
+ }
784
+
785
+ /* user-x (parts) — the cross DRAWS ITSELF, one stroke then the other, while the figure leans away.
786
+ The X is the whole message (this person is not being kept), so it is the thing that performs; the
787
+ figure only acknowledges it. Steady easing, as with `check`: the library's default ease-out
788
+ front-loads a dash and the stroke looks half-finished in the first frames. */
789
+ .xeno-element[data-glyph='user-x'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
790
+ .xeno-element[data-glyph='user-x'] .xeno-part[data-part='0'],
791
+ .xeno-element[data-glyph='user-x'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-userx-figure; --part-dur: 640ms; }
792
+ .xeno-element[data-glyph='user-x'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-userx-a; --part-dur: 640ms; stroke-dasharray: 6.4; }
793
+ .xeno-element[data-glyph='user-x'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-userx-b; --part-dur: 640ms; stroke-dasharray: 6.4; }
794
+ @keyframes xeno-pt-userx-figure {
795
+ 0% { transform: translateX(0); }
796
+ 45% { transform: translateX(-0.8px); }
797
+ 100% { transform: translateX(0); }
798
+ }
799
+ @keyframes xeno-pt-userx-a {
800
+ 0% { stroke-dashoffset: 6.4; }
801
+ 45% { stroke-dashoffset: 0; }
802
+ 100% { stroke-dashoffset: 0; }
803
+ }
804
+ @keyframes xeno-pt-userx-b {
805
+ 0%,
806
+ 45% { stroke-dashoffset: 6.4; }
807
+ 90% { stroke-dashoffset: 0; }
808
+ 100% { stroke-dashoffset: 0; }
809
+ }
810
+
811
+ /* contrast (whole) — the icon turns half a revolution, so the solid half changes sides. That IS the
812
+ control: a theme toggle swaps which half is dark, and rotating the same shape says it without
813
+ redrawing anything. It stops at 180deg rather than returning, then the animation ends and the icon
814
+ snaps back to rest — so a second hover reads as another flip rather than a rewind. */
815
+ /* contrast (parts) — a TIMELAPSE. Daylight crosses the square: the shadow already covering the left
816
+ half sweeps on across the right until the whole face is dark, and it HOLDS there while you point at
817
+ it. Leave, and it is morning again.
818
+ The hold is the point. `forwards` keeps the last frame instead of letting the icon snap back mid-
819
+ gesture, and removing the animation on mouse-out is what performs the reset — so the two states are
820
+ "pointing at it" and "not", which is what the control actually has.
821
+
822
+ It is the FRAME that fills, not a second shape, and that is what keeps the declaration honest. The
823
+ frame carries `fill: none` from the svg root, so with no stylesheet at all the icon still draws as
824
+ the half-solid square it is meant to be — the sweep exists only in CSS. The alternative, a full-square
825
+ fill sitting in the geometry, would render every no-CSS copy of this glyph as a solid block.
826
+
827
+ `clip-path` starts at the centre line rather than the left edge: the left half is already dark, so
828
+ sweeping from x=3 would spend the first half of the animation re-filling pixels that are not
829
+ changing. And clipping the rect leaves its own rounded corners intact all the way across, which
830
+ scaling a straight-edged half could never do — the corner would have arrived square. */
831
+ .xeno-element[data-glyph='contrast'] .xeno-part[data-part='2'] {
832
+ --part-anim: xeno-pt-contrast-sweep;
833
+ --part-dur: 2400ms;
834
+ /* Symmetric easing and a linear-ish middle: a timelapse that accelerates halfway is a timelapse of
835
+ nothing in particular. */
836
+ --xeno-icon-ease: cubic-bezier(0.45, 0, 0.55, 1);
837
+ animation-iteration-count: infinite;
838
+ }
839
+ /* The resting half is hidden for the whole run: the sweeping layer reproduces it exactly at 0%, so
840
+ leaving both would double-paint the same pixels and, worse, pin a solid left half under an animation
841
+ whose whole point is that the left half empties. */
842
+ .xeno-element[data-glyph='contrast'] .xeno-part[data-part='1'] {
843
+ --part-anim: xeno-pt-contrast-rest;
844
+ --part-dur: 1ms;
845
+ animation-fill-mode: forwards;
846
+ }
847
+
848
+ /*
849
+ * A whole day, on a loop, and it has to JOIN UP at both ends.
850
+ *
851
+ * It begins on the resting drawing — the left half dark — so starting the loop is invisible: the frame
852
+ * the animation opens with is the frame that was already there.
853
+ *
854
+ * The seam at the end is the interesting part. The cycle finishes with the square empty, and it has to
855
+ * resume with the left half dark, which is not the same picture. The trick is that "empty" can be
856
+ * spelled two ways: clipped away from the LEFT (where the light left it) or from the RIGHT (where the
857
+ * dark will grow back from). Both show nothing, so switching between them costs nothing to look at —
858
+ * and after the switch, closing the right-hand clip grows darkness inward from the left edge, which is
859
+ * exactly how the day started. No jump, and no rewind: the light always travels one way.
860
+ */
861
+ @keyframes xeno-pt-contrast-sweep {
862
+ /* the resting drawing */
863
+ 0% { fill: currentColor; clip-path: inset(0 50% 0 0); }
864
+ /* dusk — the shadow crosses the right half */
865
+ 32% { fill: currentColor; clip-path: inset(0 0 0 0); }
866
+ /* midnight — a beat of full dark, so the turn reads as a turn and not a bounce */
867
+ 44% { fill: currentColor; clip-path: inset(0 0 0 0); }
868
+ /* dawn — the light sweeps the dark away, from the left */
869
+ 74% { fill: currentColor; clip-path: inset(0 0 0 100%); }
870
+ /* the same nothing, re-anchored to the other edge — invisible, because nothing is showing */
871
+ 74.01% { fill: currentColor; clip-path: inset(0 100% 0 0); }
872
+ /* a beat of bare border, which is the state worth pausing on */
873
+ 82% { fill: currentColor; clip-path: inset(0 100% 0 0); }
874
+ /* dawn's shadow grows back from the left, and lands exactly on the frame it started from */
875
+ 100% { fill: currentColor; clip-path: inset(0 50% 0 0); }
876
+ }
877
+ @keyframes xeno-pt-contrast-rest {
878
+ from,
879
+ to { opacity: 0; }
880
+ }
881
+
882
+ /* briefcase (parts) — the case OPENS. The lid hinges up off the clasp line and the handle rides with
883
+ it, because a handle is fixed to a lid and does not stay behind; the base holds still and takes the
884
+ weight.
885
+
886
+ It replaces a version where the whole icon bobbed up and down, which said nothing: a case that is
887
+ lifted is a case doing what any box does. A case that OPENS is the one thing only a case does — and
888
+ the glyph stands for "customize", where the point is getting at what is inside.
889
+
890
+ The hinge is at the LEFT end of the seam, so the right side lifts further than the left and the gap
891
+ is a wedge rather than a slab sliding off. Two degrees of rotation with a small lift under it: at
892
+ 24px a wedge is all the eye needs, and any more turns the lid into a flap. */
893
+ .xeno-element[data-glyph='briefcase'] .xeno-part[data-part='0'],
894
+ .xeno-element[data-glyph='briefcase'] .xeno-part[data-part='1'] {
895
+ --part-anim: xeno-pt-briefcase-lid;
896
+ --part-dur: 720ms;
897
+ transform-box: view-box;
898
+ transform-origin: 3px 13px;
899
+ }
900
+ .xeno-element[data-glyph='briefcase'] .xeno-part[data-part='2'] {
901
+ --part-anim: xeno-pt-briefcase-base;
902
+ --part-dur: 720ms;
903
+ transform-box: view-box;
904
+ transform-origin: 12px 19.5px;
905
+ }
906
+ @keyframes xeno-pt-briefcase-lid {
907
+ 0% { transform: translateY(0) rotate(0deg); }
908
+ 38% { transform: translateY(-3.4px) rotate(-6deg); }
909
+ 62% { transform: translateY(-3.4px) rotate(-6deg); }
910
+ 100% { transform: translateY(0) rotate(0deg); }
911
+ }
912
+ /* The base only acknowledges it — a hair of squash as the lid comes back down. */
913
+ @keyframes xeno-pt-briefcase-base {
914
+ 0% { transform: scaleY(1); }
915
+ 62% { transform: scaleY(1); }
916
+ 78% { transform: scaleY(0.9); }
917
+ 100% { transform: scaleY(1); }
918
+ }
919
+
920
+ /* building — the windows come on, floor by floor, left to right. The walls hold: a building that rocked
921
+ would be an earthquake, and what this glyph means is that somebody is home.
922
+
923
+ Six parts rather than one, because the whole point is the stagger. `--part-dur` is the same on all six
924
+ and the delay lives in the keyframe percentages instead of in `animation-delay`, so the six finish
925
+ together and the sequence reads as one event with an order rather than six separate ones. */
926
+ .xeno-element[data-glyph='building'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-win-1; --part-dur: 620ms; }
927
+ .xeno-element[data-glyph='building'] .xeno-part[data-part='4'] { --part-anim: xeno-pt-win-2; --part-dur: 620ms; }
928
+ .xeno-element[data-glyph='building'] .xeno-part[data-part='5'] { --part-anim: xeno-pt-win-3; --part-dur: 620ms; }
929
+ .xeno-element[data-glyph='building'] .xeno-part[data-part='6'] { --part-anim: xeno-pt-win-4; --part-dur: 620ms; }
930
+ .xeno-element[data-glyph='building'] .xeno-part[data-part='7'] { --part-anim: xeno-pt-win-5; --part-dur: 620ms; }
931
+ .xeno-element[data-glyph='building'] .xeno-part[data-part='8'] { --part-anim: xeno-pt-win-6; --part-dur: 620ms; }
932
+ @keyframes xeno-pt-win-1 { 0%,4% { opacity: 0.22; } 22%, 100% { opacity: 1; } }
933
+ @keyframes xeno-pt-win-2 { 0%,12% { opacity: 0.22; } 30%, 100% { opacity: 1; } }
934
+ @keyframes xeno-pt-win-3 { 0%,20% { opacity: 0.22; } 38%, 100% { opacity: 1; } }
935
+ @keyframes xeno-pt-win-4 { 0%,28% { opacity: 0.22; } 46%, 100% { opacity: 1; } }
936
+ @keyframes xeno-pt-win-5 { 0%,36% { opacity: 0.22; } 54%, 100% { opacity: 1; } }
937
+ @keyframes xeno-pt-win-6 { 0%,44% { opacity: 0.22; } 62%, 100% { opacity: 1; } }
938
+
939
+ /* next-dismiss — hover motion PER STATE, because the glyph means two different things and a control
940
+ should answer for the one it is showing.
941
+
942
+ Pointing at it while it is an arrow gets `arrow-right`'s own gesture; while it is a cross it gets
943
+ `x`'s pop. Borrowed rather than invented, so hovering this button feels the same as hovering the two
944
+ glyphs it stands in for.
945
+
946
+ It is a whole-icon animation on `transform`, and the morph between the states runs on `d`. Different
947
+ properties, so the two never fight: you can hover mid-morph and both keep going. */
948
+ .xeno-element[data-glyph='next-dismiss'][data-selection='off'] {
949
+ --xeno-icon-anim: xeno-ic-arrow-right;
950
+ --xeno-icon-dur: 500ms;
951
+ transform-origin: left center;
952
+ }
953
+ .xeno-element[data-glyph='next-dismiss'][data-selection='on'] {
954
+ --xeno-icon-anim: xeno-ic-nextdismiss-x;
955
+ --xeno-icon-dur: 420ms;
956
+ }
957
+ @keyframes xeno-ic-nextdismiss-x {
958
+ 0% { transform: scale(1); }
959
+ 46% { transform: scale(1.14); }
960
+ 100% { transform: scale(1); }
961
+ }
962
+
963
+ /* ---- Triggers: play on icon-hover or any interactive host ------------------- */
964
+
965
+ /* There are TWO triggers, not one.
966
+ *
967
+ * Hover is the obvious one: point at a control and its glyph stirs. The second is SELECTION — a host
968
+ * carrying `data-selection="on"` plays the same animation once, at the moment it becomes selected.
969
+ *
970
+ * That second trigger exists because a real product kept hand-rolling it. A row of options where picking
971
+ * one makes its icon respond is a common, meaningful pattern: the motion is not decoration there, it is
972
+ * the confirmation that the choice landed. Without a selection trigger the only way to get it was to
973
+ * abandon the declaration and hand-write an animated SVG per option — which is exactly what the chat's
974
+ * share dialog had done, three times over, in a stroke weight that did not match the rest of the set.
975
+ *
976
+ * CSS gives this for free and it is worth saying why: a rule that only applies while the attribute is on
977
+ * starts its animation when the attribute flips, and stops existing when it flips back. No listener, no
978
+ * state, no replay logic. Flipping to `off` and back to `on` plays it again.
979
+ *
980
+ * Both trigger lists stay at specificity ZERO (`:where`), so any consumer rule still overrides them.
981
+ *
982
+ * There is also a SCOPE, `.xeno-icon-hosts`, and it is the third trigger. Inside an element carrying it,
983
+ * every button, link, menu item, tab and option is a host without saying so.
984
+ *
985
+ * That looks like it contradicts the closed list, and it is the closed list's escape hatch rather than a
986
+ * hole in it. The list exists so an icon does not stir on a stray hover — a table full of glyphs that all
987
+ * twitch as the pointer crosses them is noise. But a product whose every icon button IS a XENO glyph pays
988
+ * that tax as a per-button opt-in it has to remember, forever, on every button anyone adds. Measured on
989
+ * one: thirty-four controls carried a glyph and none of them was a host, and the way that surfaced was a
990
+ * user noticing two buttons out of six that sat still.
991
+ *
992
+ * The scope answers to SELECTION as well as hover, and it did not at first — which turned out to matter
993
+ * more than it sounds. A copy button that swaps its glyph for a check on click had the check draw
994
+ * itself perfectly, so long as the pointer stayed put; move the mouse as you click, as people do, and
995
+ * the check simply appeared, already finished. The draw only existed while a hover trigger matched.
996
+ * Selection is the trigger that does not need a pointer, and a product that has declared its scope
997
+ * should not then have to add `xeno-icon-hover` to one button to reach it.
998
+ *
999
+ * A scope is a decision made once, in the open, by the consumer — which is the difference between an
1000
+ * opt-in and a default.
1001
+ */
1002
+
1003
+ /* whole-icon animations */
1004
+ :where(
1005
+ .xeno-element[data-glyph]:hover,
1006
+ .xeno-btn:hover .xeno-element[data-glyph],
1007
+ .xeno-chip:hover .xeno-element[data-glyph],
1008
+ .xeno-toggle:hover .xeno-element[data-glyph],
1009
+ .xeno-list-row:hover .xeno-element[data-glyph],
1010
+ .xeno-menu-item:hover .xeno-element[data-glyph],
1011
+ .xeno-sidebar-item:hover .xeno-element[data-glyph],
1012
+ .xeno-tab:hover .xeno-element[data-glyph],
1013
+ .xeno-segmented-option:hover .xeno-element[data-glyph],
1014
+ .xeno-field-trigger:hover .xeno-element[data-glyph],
1015
+ .xeno-icon-hover:hover .xeno-element[data-glyph],
1016
+ .xeno-icon-hosts :is(button, a, [role='menuitem'], [role='tab'], [role='option'], [role='radio'], label):hover .xeno-element[data-glyph],
1017
+ .xeno-icon-hover[data-selection='on'] .xeno-element[data-glyph],
1018
+ .xeno-icon-hosts :is(button, a, [role='menuitem'], [role='tab'], [role='option'], [role='radio'], label)[data-selection='on'] .xeno-element[data-glyph],
1019
+ .xeno-list-row[data-selection='on'] .xeno-element[data-glyph],
1020
+ .xeno-menu-item[data-selection='on'] .xeno-element[data-glyph],
1021
+ .xeno-sidebar-item[data-selection='on'] .xeno-element[data-glyph],
1022
+ .xeno-tab[data-selection='on'] .xeno-element[data-glyph],
1023
+ .xeno-segmented-option[data-selection='on'] .xeno-element[data-glyph]
1024
+ ) { animation: var(--xeno-icon-anim) var(--xeno-icon-dur) var(--xeno-icon-ease); }
1025
+
1026
+ /* per-part animations */
1027
+ :where(
1028
+ .xeno-element[data-glyph]:hover,
1029
+ .xeno-btn:hover .xeno-element[data-glyph],
1030
+ .xeno-chip:hover .xeno-element[data-glyph],
1031
+ .xeno-toggle:hover .xeno-element[data-glyph],
1032
+ .xeno-list-row:hover .xeno-element[data-glyph],
1033
+ .xeno-menu-item:hover .xeno-element[data-glyph],
1034
+ .xeno-sidebar-item:hover .xeno-element[data-glyph],
1035
+ .xeno-tab:hover .xeno-element[data-glyph],
1036
+ .xeno-segmented-option:hover .xeno-element[data-glyph],
1037
+ .xeno-field-trigger:hover .xeno-element[data-glyph],
1038
+ .xeno-icon-hover:hover .xeno-element[data-glyph],
1039
+ .xeno-icon-hosts :is(button, a, [role='menuitem'], [role='tab'], [role='option'], [role='radio'], label):hover .xeno-element[data-glyph],
1040
+ .xeno-icon-hover[data-selection='on'] .xeno-element[data-glyph],
1041
+ .xeno-icon-hosts :is(button, a, [role='menuitem'], [role='tab'], [role='option'], [role='radio'], label)[data-selection='on'] .xeno-element[data-glyph],
1042
+ .xeno-list-row[data-selection='on'] .xeno-element[data-glyph],
1043
+ .xeno-menu-item[data-selection='on'] .xeno-element[data-glyph],
1044
+ .xeno-sidebar-item[data-selection='on'] .xeno-element[data-glyph],
1045
+ .xeno-tab[data-selection='on'] .xeno-element[data-glyph],
1046
+ .xeno-segmented-option[data-selection='on'] .xeno-element[data-glyph]
1047
+ ) .xeno-part {
1048
+ animation: var(--part-anim) var(--part-dur) var(--xeno-icon-ease);
1049
+ /* A part that is scaled hard (globe's meridians) opts into a constant line weight for the DURATION of the
1050
+ hover only: `non-scaling-stroke` pins the stroke to screen pixels, which would otherwise leave the icon
1051
+ under-weighted whenever it is drawn larger than 24. At rest the stroke scales with `size`, as it must. */
1052
+ vector-effect: var(--part-vfx, none);
1053
+ }
1054
+
1055
+ /* ── The transport set: play / pause / stop ──────────────────────────────────────────
1056
+ One control in three states, so the three animations are one idea told three ways: play LEANS the way
1057
+ it is going, pause takes the weight on both feet, stop lands. Same 460ms for all three, because they
1058
+ replace each other in place and a state that took longer would read as a slower machine. */
1059
+
1060
+ /* play — the wedge presses forward and settles. It is the only one of the three with a direction, so it
1061
+ is the only one that travels. */
1062
+ .xeno-element[data-glyph='play'] { --xeno-icon-anim: xeno-play-go; --xeno-icon-dur: 460ms; }
1063
+ @keyframes xeno-play-go {
1064
+ 0% { transform: translateX(0); }
1065
+ 34% { transform: translateX(1.4px); }
1066
+ 100% { transform: translateX(0); }
1067
+ }
1068
+
1069
+ /* pause — the bars part a hair and come back. Offset by a beat so it reads as two objects rather than one
1070
+ shape scaling: a pause is two things standing still, and things that move in perfect lockstep look
1071
+ welded. */
1072
+ .xeno-element[data-glyph='pause'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-pause-a; --part-dur: 460ms; }
1073
+ .xeno-element[data-glyph='pause'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-pause-b; --part-dur: 460ms; }
1074
+ @keyframes xeno-pt-pause-a {
1075
+ 0% { transform: translateX(0); }
1076
+ 38% { transform: translateX(-2.4px); }
1077
+ 100% { transform: translateX(0); }
1078
+ }
1079
+ @keyframes xeno-pt-pause-b {
1080
+ 0% { transform: translateX(0); }
1081
+ 48% { transform: translateX(2.4px); }
1082
+ 100% { transform: translateX(0); }
1083
+ }
1084
+
1085
+ /* stop — one contraction, nothing else. A stop is the absence of travel, so anything that moved sideways
1086
+ here would argue with the glyph. */
1087
+ .xeno-element[data-glyph='stop'] { --xeno-icon-anim: xeno-stop-land; --xeno-icon-dur: 460ms; }
1088
+ @keyframes xeno-stop-land {
1089
+ 0% { transform: scale(1); }
1090
+ 38% { transform: scale(0.72); }
1091
+ 70% { transform: scale(1.07); }
1092
+ 100% { transform: scale(1); }
1093
+ }
1094
+
1095
+ /* ── The panel set: the chevron goes where the panel is about to go ──────────────────
1096
+ Four glyphs, one gesture, four directions. Only the chevron moves — the frame is the window and the
1097
+ rail is the panel's edge, and a window that shifted every time you pointed at it would be a bug. The
1098
+ travel is 1.6px: enough to read as intent at 24px, small enough that the chevron never leaves the
1099
+ frame it belongs to. */
1100
+ .xeno-element[data-glyph='panel-left'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-panel-e; --part-dur: 500ms; }
1101
+ .xeno-element[data-glyph='panel-right-close'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-panel-e; --part-dur: 500ms; }
1102
+ .xeno-element[data-glyph='panel-left-close'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-panel-w; --part-dur: 500ms; }
1103
+ .xeno-element[data-glyph='panel-right'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-panel-w; --part-dur: 500ms; }
1104
+ @keyframes xeno-pt-panel-e {
1105
+ 0% { transform: translateX(0); }
1106
+ 36% { transform: translateX(3.4px); }
1107
+ 100% { transform: translateX(0); }
1108
+ }
1109
+ @keyframes xeno-pt-panel-w {
1110
+ 0% { transform: translateX(0); }
1111
+ 36% { transform: translateX(-3.4px); }
1112
+ 100% { transform: translateX(0); }
1113
+ }
1114
+
1115
+ /* ── maximize / minimize — the same gesture with the sign flipped ────────────────────
1116
+ Each corner moves along its OWN diagonal, away from the centre for maximize and toward it for
1117
+ minimize. Four separate keyframes per glyph rather than a scale on the whole icon: scaling would take
1118
+ the stroke weight with it, and a corner bracket that thickens as it travels stops looking like the
1119
+ same bracket. */
1120
+ .xeno-element[data-glyph='maximize'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-max-tl; --part-dur: 520ms; }
1121
+ .xeno-element[data-glyph='maximize'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-max-tr; --part-dur: 520ms; }
1122
+ .xeno-element[data-glyph='maximize'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-max-br; --part-dur: 520ms; }
1123
+ .xeno-element[data-glyph='maximize'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-max-bl; --part-dur: 520ms; }
1124
+ @keyframes xeno-pt-max-tl { 0% { transform: translate(0, 0); } 38% { transform: translate(-2.9px, -2.9px); } 100% { transform: translate(0, 0); } }
1125
+ @keyframes xeno-pt-max-tr { 0% { transform: translate(0, 0); } 38% { transform: translate(2.9px, -2.9px); } 100% { transform: translate(0, 0); } }
1126
+ @keyframes xeno-pt-max-br { 0% { transform: translate(0, 0); } 38% { transform: translate(2.9px, 2.9px); } 100% { transform: translate(0, 0); } }
1127
+ @keyframes xeno-pt-max-bl { 0% { transform: translate(0, 0); } 38% { transform: translate(-2.9px, 2.9px); } 100% { transform: translate(0, 0); } }
1128
+
1129
+ .xeno-element[data-glyph='minimize'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-min-tl; --part-dur: 520ms; }
1130
+ .xeno-element[data-glyph='minimize'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-min-tr; --part-dur: 520ms; }
1131
+ .xeno-element[data-glyph='minimize'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-min-br; --part-dur: 520ms; }
1132
+ .xeno-element[data-glyph='minimize'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-min-bl; --part-dur: 520ms; }
1133
+ @keyframes xeno-pt-min-tl { 0% { transform: translate(0, 0); } 38% { transform: translate(1.3px, 1.3px); } 100% { transform: translate(0, 0); } }
1134
+ @keyframes xeno-pt-min-tr { 0% { transform: translate(0, 0); } 38% { transform: translate(-1.3px, 1.3px); } 100% { transform: translate(0, 0); } }
1135
+ @keyframes xeno-pt-min-br { 0% { transform: translate(0, 0); } 38% { transform: translate(-1.3px, -1.3px); } 100% { transform: translate(0, 0); } }
1136
+ @keyframes xeno-pt-min-bl { 0% { transform: translate(0, 0); } 38% { transform: translate(1.3px, -1.3px); } 100% { transform: translate(0, 0); } }
1137
+
1138
+ /* ── calendar — the posts tap down, the page takes it ────────────────────────────────
1139
+ The binder posts are what hold a calendar to the wall, so they are what moves; the header rule and the
1140
+ page stay put. It is a small motion on purpose — a calendar is usually a label for a date field, not a
1141
+ button, and a page that swung would pull the eye off the value beside it. */
1142
+ .xeno-element[data-glyph='calendar'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-cal-post-a; --part-dur: 540ms; }
1143
+ .xeno-element[data-glyph='calendar'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-cal-post-b; --part-dur: 540ms; }
1144
+ .xeno-element[data-glyph='calendar'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-cal-page; --part-dur: 540ms; transform-box: view-box; transform-origin: 12px 20.5px; }
1145
+ @keyframes xeno-pt-cal-post-a {
1146
+ 0% { transform: translateY(0); }
1147
+ 30% { transform: translateY(-3.1px); }
1148
+ 100% { transform: translateY(0); }
1149
+ }
1150
+ @keyframes xeno-pt-cal-post-b {
1151
+ 0% { transform: translateY(0); }
1152
+ 42% { transform: translateY(-3.1px); }
1153
+ 100% { transform: translateY(0); }
1154
+ }
1155
+ @keyframes xeno-pt-cal-page {
1156
+ 0% { transform: scaleY(1); }
1157
+ 46% { transform: scaleY(0.9); }
1158
+ 100% { transform: scaleY(1); }
1159
+ }
1160
+
1161
+ /* ── The negation set: eye-off / mic-off / timer-off ─────────────────────────────────
1162
+ The slash DRAWS ITSELF, corner to corner, and the thing being denied gives way underneath. One mark,
1163
+ one gesture, across every glyph that negates something — and it is the drawing that carries it: a
1164
+ slash that simply appeared would be a state, while a slash that is drawn is an act.
1165
+
1166
+ `stroke-dasharray: 22.1` is the stroke's own length (4.2,4.2 → 19.8,19.8 = sqrt(2) x 15.6), so the
1167
+ dash offset runs it exactly once. Steady easing, as with `check`: the default ease-out front-loads
1168
+ the dash and the line looks half-finished for most of the animation. */
1169
+ .xeno-element[data-glyph='eye-off'],
1170
+ .xeno-element[data-glyph='mic-off'],
1171
+ .xeno-element[data-glyph='timer-off'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
1172
+
1173
+ .xeno-element[data-glyph='eye-off'] .xeno-part[data-part='2'],
1174
+ .xeno-element[data-glyph='mic-off'] .xeno-part[data-part='3'],
1175
+ .xeno-element[data-glyph='timer-off'] .xeno-part[data-part='2'] {
1176
+ --part-anim: xeno-pt-slash;
1177
+ --part-dur: 560ms;
1178
+ stroke-dasharray: 22.1;
1179
+ }
1180
+ @keyframes xeno-pt-slash {
1181
+ 0% { stroke-dashoffset: 22.1; }
1182
+ 70% { stroke-dashoffset: 0; }
1183
+ 100% { stroke-dashoffset: 0; }
1184
+ }
1185
+
1186
+ /* What each one gives up as the slash arrives. Not the same part in each: the eye loses its pupil (it
1187
+ stops seeing), the mic's capsule shrinks back into its cradle (it stops listening), the clock's hands
1188
+ fall still (it stops counting). Same mark, three different things being denied — a shared animation on
1189
+ "whatever is part 1" would have said nothing about any of them. */
1190
+ .xeno-element[data-glyph='eye-off'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-eyeoff-pupil; --part-dur: 560ms; transform-box: view-box; transform-origin: 12px 12px; }
1191
+ @keyframes xeno-pt-eyeoff-pupil {
1192
+ 0% { transform: scale(1); opacity: 1; }
1193
+ 62% { transform: scale(0.45); opacity: 0.25; }
1194
+ 100% { transform: scale(1); opacity: 1; }
1195
+ }
1196
+ .xeno-element[data-glyph='mic-off'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-micoff-capsule; --part-dur: 560ms; }
1197
+ @keyframes xeno-pt-micoff-capsule {
1198
+ 0% { transform: translateY(0); }
1199
+ 62% { transform: translateY(2.2px); }
1200
+ 100% { transform: translateY(0); }
1201
+ }
1202
+ .xeno-element[data-glyph='timer-off'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-timeroff-hands; --part-dur: 560ms; transform-box: view-box; transform-origin: 12px 12px; }
1203
+ @keyframes xeno-pt-timeroff-hands {
1204
+ 0% { transform: rotate(0deg); }
1205
+ 26% { transform: rotate(26deg); }
1206
+ 62% { transform: rotate(26deg); }
1207
+ 100% { transform: rotate(0deg); }
1208
+ }
1209
+
1210
+ /* ── The marked set: the mark performs, the object holds ─────────────────────────────
1211
+ file-x, message-x — the cross draws itself one stroke then the other, the way `user-x` does. Reusing
1212
+ that timing on purpose: a cross means the same thing on all three, so it should arrive the same way.
1213
+ Stroke length is 7.35 (5.2 x sqrt(2)) for the file, 6.5 for the smaller one in the bubble. */
1214
+ .xeno-element[data-glyph='file-x'],
1215
+ .xeno-element[data-glyph='message-x'],
1216
+ .xeno-element[data-glyph='message-plus'],
1217
+ .xeno-element[data-glyph='file-out'],
1218
+ .xeno-element[data-glyph='folder-up'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
1219
+
1220
+ .xeno-element[data-glyph='file-x'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-mark-a; --part-dur: 600ms; stroke-dasharray: 7.35; }
1221
+ .xeno-element[data-glyph='file-x'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-mark-b; --part-dur: 600ms; stroke-dasharray: 7.35; }
1222
+ .xeno-element[data-glyph='message-x'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-mark-a; --part-dur: 600ms; stroke-dasharray: 6.5; }
1223
+ .xeno-element[data-glyph='message-x'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-mark-b; --part-dur: 600ms; stroke-dasharray: 6.5; }
1224
+ @keyframes xeno-pt-mark-a {
1225
+ 0% { stroke-dashoffset: 8; }
1226
+ 46% { stroke-dashoffset: 0; }
1227
+ 100% { stroke-dashoffset: 0; }
1228
+ }
1229
+ @keyframes xeno-pt-mark-b {
1230
+ 0% { stroke-dashoffset: 8; }
1231
+ 30% { stroke-dashoffset: 8; }
1232
+ 76% { stroke-dashoffset: 0; }
1233
+ 100% { stroke-dashoffset: 0; }
1234
+ }
1235
+
1236
+ /* message-plus — the two strokes meet in the middle rather than being drawn end to end. A plus has no
1237
+ reading order; a cross does, which is why the two glyphs animate differently despite sharing a bubble. */
1238
+ .xeno-element[data-glyph='message-plus'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-plus-h; --part-dur: 600ms; transform-box: view-box; transform-origin: 12.4px 10.5px; }
1239
+ .xeno-element[data-glyph='message-plus'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-plus-v; --part-dur: 600ms; transform-box: view-box; transform-origin: 12.4px 10.5px; }
1240
+ @keyframes xeno-pt-plus-h {
1241
+ 0% { transform: scaleX(0.2); }
1242
+ 56% { transform: scaleX(1); }
1243
+ 100% { transform: scaleX(1); }
1244
+ }
1245
+ @keyframes xeno-pt-plus-v {
1246
+ 0% { transform: scaleY(0.2); }
1247
+ 70% { transform: scaleY(1); }
1248
+ 100% { transform: scaleY(1); }
1249
+ }
1250
+
1251
+ /* file-out — the arrow LEAVES. Head and shaft travel together and come back: an export is one thing
1252
+ going one way, and a head that outran its own shaft would be two things. */
1253
+ .xeno-element[data-glyph='file-out'] .xeno-part[data-part='2'],
1254
+ .xeno-element[data-glyph='file-out'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-fileout; --part-dur: 620ms; }
1255
+ @keyframes xeno-pt-fileout {
1256
+ 0% { transform: translateX(0); }
1257
+ 40% { transform: translateX(2.2px); }
1258
+ 100% { transform: translateX(0); }
1259
+ }
1260
+
1261
+ /* folder-up — the arrow rises and the folder takes it, the same beat `archive` uses for its lid. The
1262
+ two glyphs are neighbours in meaning (things going into a container), so they move alike. */
1263
+ .xeno-element[data-glyph='folder-up'] .xeno-part[data-part='1'],
1264
+ .xeno-element[data-glyph='folder-up'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-folderup-arrow; --part-dur: 620ms; }
1265
+ .xeno-element[data-glyph='folder-up'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-folderup-body; --part-dur: 620ms; transform-box: view-box; transform-origin: 10.4px 18.4px; }
1266
+ @keyframes xeno-pt-folderup-arrow {
1267
+ 0% { transform: translateY(0); }
1268
+ 34% { transform: translateY(-2.4px); }
1269
+ 100% { transform: translateY(0); }
1270
+ }
1271
+ @keyframes xeno-pt-folderup-body {
1272
+ 0% { transform: scaleY(1); }
1273
+ 50% { transform: scaleY(0.97); }
1274
+ 100% { transform: scaleY(1); }
1275
+ }
1276
+
1277
+ /* ── The arrow family — one head, so one gesture ─────────────────────────────────────
1278
+ Every arrow in the set travels along its own shaft and comes back. Distance is 1.6px, the same as the
1279
+ panel chevrons: an arrow is usually a label on a button, and one that lunged would pull the eye off
1280
+ the word beside it. */
1281
+ .xeno-element[data-glyph='arrow-up-right'] { --xeno-icon-anim: xeno-arrow-ne; --xeno-icon-dur: 480ms; }
1282
+ @keyframes xeno-arrow-ne {
1283
+ 0% { transform: translate(0, 0); }
1284
+ 36% { transform: translate(1.2px, -1.2px); }
1285
+ 100% { transform: translate(0, 0); }
1286
+ }
1287
+ /* The swap arrows pass EACH OTHER: same distance, opposite directions, and the lower one starts a beat
1288
+ late. Two arrows moving as one block would be a single object sliding, which is not what a swap is. */
1289
+ .xeno-element[data-glyph='arrow-left-right'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-swap-e; --part-dur: 520ms; }
1290
+ .xeno-element[data-glyph='arrow-left-right'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-swap-w; --part-dur: 520ms; }
1291
+ @keyframes xeno-pt-swap-e {
1292
+ 0% { transform: translateX(0); }
1293
+ 38% { transform: translateX(3.4px); }
1294
+ 100% { transform: translateX(0); }
1295
+ }
1296
+ @keyframes xeno-pt-swap-w {
1297
+ 0% { transform: translateX(0); }
1298
+ 16% { transform: translateX(0); }
1299
+ 50% { transform: translateX(-3.4px); }
1300
+ 100% { transform: translateX(0); }
1301
+ }
1302
+
1303
+ /* trending-up — the line DRAWS itself and the head arrives at the end of it, which is the only order
1304
+ that tells the truth: a trend is a thing that happened over time, so the glyph should take time.
1305
+ Dash length 24 covers the polyline (7.8 + 4.8 + 10.7 ≈ 23.3) with a margin. */
1306
+ .xeno-element[data-glyph='trending-up'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
1307
+ .xeno-element[data-glyph='trending-up'] .xeno-part[data-part='0'] {
1308
+ --part-anim: xeno-pt-trend-line;
1309
+ --part-dur: 720ms;
1310
+ stroke-dasharray: 24;
1311
+ }
1312
+ .xeno-element[data-glyph='trending-up'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-trend-head; --part-dur: 720ms; }
1313
+ @keyframes xeno-pt-trend-line {
1314
+ 0% { stroke-dashoffset: 24; }
1315
+ 62% { stroke-dashoffset: 0; }
1316
+ 100% { stroke-dashoffset: 0; }
1317
+ }
1318
+ @keyframes xeno-pt-trend-head {
1319
+ 0% { opacity: 0; transform: translate(-1.6px, 1.6px); }
1320
+ 56% { opacity: 0; transform: translate(-1.6px, 1.6px); }
1321
+ 78% { opacity: 1; transform: translate(0, 0); }
1322
+ 100% { opacity: 1; transform: translate(0, 0); }
1323
+ }
1324
+
1325
+ /* check-square — the tick draws itself inside a frame that holds still, the same way `check` does alone.
1326
+ The frame is the constant in this family (`stop`, `contrast`, `check-square`); if it moved, the three
1327
+ would stop reading as the same enclosure. Dash 12.6 is the tick's own length. */
1328
+ .xeno-element[data-glyph='check-square'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
1329
+ .xeno-element[data-glyph='check-square'] .xeno-part[data-part='1'] {
1330
+ --part-anim: xeno-pt-checksq;
1331
+ --part-dur: 540ms;
1332
+ stroke-dasharray: 12.6;
1333
+ }
1334
+ @keyframes xeno-pt-checksq {
1335
+ 0% { stroke-dashoffset: 12.6; }
1336
+ 72% { stroke-dashoffset: 0; }
1337
+ 100% { stroke-dashoffset: 0; }
1338
+ }
1339
+
1340
+ /* save — the shutter presses into the shell and the shell takes it. A disk is a thing you push in, and
1341
+ that is the only motion the object has ever had. */
1342
+ .xeno-element[data-glyph='save'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-save-shutter; --part-dur: 520ms; }
1343
+ .xeno-element[data-glyph='save'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-save-shell; --part-dur: 520ms; transform-box: view-box; transform-origin: 12px 19.6px; }
1344
+ @keyframes xeno-pt-save-shutter {
1345
+ 0% { transform: translateY(0); }
1346
+ 36% { transform: translateY(3.1px); }
1347
+ 100% { transform: translateY(0); }
1348
+ }
1349
+ @keyframes xeno-pt-save-shell {
1350
+ 0% { transform: scaleY(1); }
1351
+ 46% { transform: scaleY(0.9); }
1352
+ 100% { transform: scaleY(1); }
1353
+ }
1354
+
1355
+ /* quote — the second mark follows the first, the way a quotation is read. Nothing else moves, because
1356
+ the marks ARE the glyph. */
1357
+ .xeno-element[data-glyph='quote'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-quote-a; --part-dur: 620ms; }
1358
+ .xeno-element[data-glyph='quote'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-quote-b; --part-dur: 620ms; }
1359
+ @keyframes xeno-pt-quote-a {
1360
+ 0% { transform: translateY(0); }
1361
+ 32% { transform: translateY(-3.1px); }
1362
+ 100% { transform: translateY(0); }
1363
+ }
1364
+ @keyframes xeno-pt-quote-b {
1365
+ 0% { transform: translateY(0); }
1366
+ 14% { transform: translateY(0); }
1367
+ 46% { transform: translateY(-3.1px); }
1368
+ 100% { transform: translateY(0); }
1369
+ }
1370
+
1371
+ /* monitor / app-window — the screen wakes. A screen is the one part of either glyph that has a state,
1372
+ so it is the part that changes; the stand and the title bar are furniture and hold still. */
1373
+ .xeno-element[data-glyph='monitor'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-screen-wake; --part-dur: 560ms; transform-box: view-box; transform-origin: 12px 10.2px; }
1374
+ .xeno-element[data-glyph='app-window'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-screen-wake; --part-dur: 560ms; transform-box: view-box; transform-origin: 12px 12px; }
1375
+ /* The screen blinks rather than only shrinking. Scale alone is what this was, and a screen cannot be
1376
+ scaled far without ceasing to be the same monitor — 14% is about the limit, and 14% of a ten-pixel
1377
+ screen is a pixel and a half of edge. Dimming has no such ceiling: it is the one thing a display can
1378
+ do to its whole surface at once, and it is also what waking actually looks like. */
1379
+ @keyframes xeno-pt-screen-wake {
1380
+ 0% { transform: scale(1); opacity: 1; }
1381
+ 30% { transform: scale(0.88); opacity: 0.25; }
1382
+ 62% { transform: scale(1.04); opacity: 1; }
1383
+ 100% { transform: scale(1); opacity: 1; }
1384
+ }
1385
+ /* The window's two marks blink in sequence behind it — the smallest thing in the glyph, doing the least. */
1386
+ .xeno-element[data-glyph='app-window'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-win-dot-a; --part-dur: 560ms; }
1387
+ .xeno-element[data-glyph='app-window'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-win-dot-b; --part-dur: 560ms; }
1388
+ @keyframes xeno-pt-win-dot-a { 0% { opacity: 1; } 30% { opacity: 0.3; } 60% { opacity: 1; } 100% { opacity: 1; } }
1389
+ @keyframes xeno-pt-win-dot-b { 0% { opacity: 1; } 16% { opacity: 1; } 46% { opacity: 0.3; } 76% { opacity: 1; } 100% { opacity: 1; } }
1390
+
1391
+ /* zap — one hard flash and a snap back, no travel. The glyph means "instant", and anything that eased
1392
+ in would be arguing with the word. Steps easing on the way in for the same reason. */
1393
+ .xeno-element[data-glyph='zap'] { --xeno-icon-anim: xeno-zap-strike; --xeno-icon-dur: 440ms; --xeno-icon-ease: cubic-bezier(0.2, 0, 0, 1); }
1394
+ @keyframes xeno-zap-strike {
1395
+ 0% { transform: scale(1); opacity: 1; }
1396
+ 18% { transform: scale(1.32); opacity: 0.3; }
1397
+ 34% { transform: scale(0.9); opacity: 1; }
1398
+ 100% { transform: scale(1); opacity: 1; }
1399
+ }
1400
+
1401
+ /* wrap-text — the hook is drawn, then its head arrives, so the line is seen running out of room before
1402
+ it is seen turning back. Dash 12 covers the hook's run and its arc. */
1403
+ .xeno-element[data-glyph='wrap-text'] { --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1); }
1404
+ .xeno-element[data-glyph='wrap-text'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-wrap-hook; --part-dur: 660ms; stroke-dasharray: 16; }
1405
+ .xeno-element[data-glyph='wrap-text'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-wrap-head; --part-dur: 660ms; }
1406
+ @keyframes xeno-pt-wrap-hook {
1407
+ 0% { stroke-dashoffset: 16; }
1408
+ 60% { stroke-dashoffset: 0; }
1409
+ 100% { stroke-dashoffset: 0; }
1410
+ }
1411
+ @keyframes xeno-pt-wrap-head {
1412
+ 0% { opacity: 0; transform: translateX(1.6px); }
1413
+ 54% { opacity: 0; transform: translateX(1.6px); }
1414
+ 76% { opacity: 1; transform: translateX(0); }
1415
+ 100% { opacity: 1; transform: translateX(0); }
1416
+ }
1417
+
1418
+ /* thumbs-up / thumbs-down — the WHOLE hand goes the way it points and comes back. Up for a like, down
1419
+ for a dislike: the gesture and the glyph say the same thing, which a rotation from the wrist did not
1420
+ — that read as a hand waving, and a wave means neither approval nor its opposite.
1421
+
1422
+ The whole icon rather than the palm alone, because a hand that leaves its own cuff behind is a hand
1423
+ coming off at the wrist. Mirrored exactly, as the geometry is: the pair sits under every answer, and a
1424
+ like that travelled further than a dislike would read as the product having an opinion about which one
1425
+ you should press. */
1426
+ .xeno-element[data-glyph='thumbs-up'] { --xeno-icon-anim: xeno-ic-thumb-up; --xeno-icon-dur: 520ms; }
1427
+ .xeno-element[data-glyph='thumbs-down'] { --xeno-icon-anim: xeno-ic-thumb-down; --xeno-icon-dur: 520ms; }
1428
+ @keyframes xeno-ic-thumb-up {
1429
+ 0% { transform: translateY(0); }
1430
+ 34% { transform: translateY(-2.4px); }
1431
+ 100% { transform: translateY(0); }
1432
+ }
1433
+ @keyframes xeno-ic-thumb-down {
1434
+ 0% { transform: translateY(0); }
1435
+ 34% { transform: translateY(2.4px); }
1436
+ 100% { transform: translateY(0); }
1437
+ }
1438
+
1439
+ /* library — the leaning book straightens and falls back. It is the only part that can move without the
1440
+ shelf becoming a different shelf, and it is also the part that carries the meaning: three upright bars
1441
+ are a chart, and the one out of line is what says these are objects standing up. */
1442
+ .xeno-element[data-glyph='library'] .xeno-part[data-part='2'] {
1443
+ --part-anim: xeno-pt-library-lean;
1444
+ --part-dur: 620ms;
1445
+ transform-box: view-box;
1446
+ transform-origin: 15px 19.2px;
1447
+ }
1448
+ /* Seven degrees on a book five units tall is a third of a pixel at the 13px this is drawn at in the
1449
+ agent rail. It straightens the whole way now, and past vertical, so the lean visibly resolves and
1450
+ comes back — a book being pushed upright rather than nudged. */
1451
+ @keyframes xeno-pt-library-lean {
1452
+ 0% { transform: rotate(0deg); }
1453
+ 36% { transform: rotate(-18deg); }
1454
+ 68% { transform: rotate(4deg); }
1455
+ 100% { transform: rotate(0deg); }
1456
+ }
1457
+
1458
+ /* store — the awning takes a breath and the front holds. An awning is cloth on a frame; it is the only
1459
+ soft thing in the glyph, so it is the only thing that gives. */
1460
+ .xeno-element[data-glyph='store'] .xeno-part[data-part='0'] {
1461
+ --part-anim: xeno-pt-store-awning;
1462
+ --part-dur: 560ms;
1463
+ transform-box: view-box;
1464
+ transform-origin: 12px 9.4px;
1465
+ }
1466
+ /* The weakest motion in the whole set as measured: 0.36px, because 12% of a four-unit awning at 13px is
1467
+ a quarter of a pixel. It billows properly now — half again its depth, and drawn back under itself on
1468
+ the way out, which is what cloth on a frame does when the wind drops. */
1469
+ @keyframes xeno-pt-store-awning {
1470
+ 0% { transform: scaleY(1); }
1471
+ 36% { transform: scaleY(1.55); }
1472
+ 70% { transform: scaleY(0.88); }
1473
+ 100% { transform: scaleY(1); }
1474
+ }
1475
+
1476
+ /* navigation — the pointer travels along the way it points, which is the only direction it is allowed to
1477
+ move: a location marker that drifted sideways would be pointing at one thing and going to another. */
1478
+ .xeno-element[data-glyph='navigation'] { --xeno-icon-anim: xeno-nav-go; --xeno-icon-dur: 520ms; }
1479
+ @keyframes xeno-nav-go {
1480
+ 0% { transform: translateY(0); }
1481
+ 34% { transform: translateY(-1.8px); }
1482
+ 100% { transform: translateY(0); }
1483
+ }
1484
+
1485
+ /* lightbulb — it comes ON. The glass brightens and swells a fraction while the collar and contact hold
1486
+ still, because the socket does not light up. This is the one glyph in the set whose animation IS its
1487
+ meaning rather than a comment on it: an idea is the moment something starts. */
1488
+ .xeno-element[data-glyph='lightbulb'] .xeno-part[data-part='0'] {
1489
+ --part-anim: xeno-pt-bulb-on;
1490
+ --part-dur: 680ms;
1491
+ transform-box: view-box;
1492
+ transform-origin: 12px 15px;
1493
+ }
1494
+ @keyframes xeno-pt-bulb-on {
1495
+ 0% { transform: scale(1); opacity: 0.45; }
1496
+ 30% { transform: scale(1.28); opacity: 1; }
1497
+ 62% { transform: scale(1.05); opacity: 1; }
1498
+ 100% { transform: scale(1); opacity: 0.45; }
1499
+ }
1500
+
1501
+ /* scan-eye — the frame closes in and the eye looks. Corners inward, not outward: this is `maximize`'s
1502
+ geometry with the opposite intent, and the direction is the entire difference between framing
1503
+ something to enlarge it and framing something to examine it. The pupil tracks a beat behind the
1504
+ corners, because the frame arrives first and the looking follows. */
1505
+ .xeno-element[data-glyph='scan-eye'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-scan-tl; --part-dur: 720ms; }
1506
+ .xeno-element[data-glyph='scan-eye'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-scan-tr; --part-dur: 720ms; }
1507
+ .xeno-element[data-glyph='scan-eye'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-scan-br; --part-dur: 720ms; }
1508
+ .xeno-element[data-glyph='scan-eye'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-scan-bl; --part-dur: 720ms; }
1509
+ .xeno-element[data-glyph='scan-eye'] .xeno-part[data-part='5'] { --part-anim: xeno-pt-scan-pupil; --part-dur: 720ms; }
1510
+ @keyframes xeno-pt-scan-tl { 0% { transform: translate(0,0); } 34% { transform: translate(2.4px, 2.4px); } 100% { transform: translate(0,0); } }
1511
+ @keyframes xeno-pt-scan-tr { 0% { transform: translate(0,0); } 34% { transform: translate(-2.4px, 2.4px); } 100% { transform: translate(0,0); } }
1512
+ @keyframes xeno-pt-scan-br { 0% { transform: translate(0,0); } 34% { transform: translate(-2.4px, -2.4px); } 100% { transform: translate(0,0); } }
1513
+ @keyframes xeno-pt-scan-bl { 0% { transform: translate(0,0); } 34% { transform: translate(2.4px, -2.4px); } 100% { transform: translate(0,0); } }
1514
+ @keyframes xeno-pt-scan-pupil {
1515
+ 0% { transform: translateX(0); }
1516
+ 30% { transform: translateX(0); }
1517
+ 54% { transform: translateX(2.1px); }
1518
+ 78% { transform: translateX(-2.1px); }
1519
+ 100% { transform: translateX(0); }
1520
+ }
1521
+
1522
+ /* shapes — the square that sits in front slides clear and comes back, so the overlap is briefly opened.
1523
+ Only the one in front moves: what the glyph is about is that there are two objects, and the way to say
1524
+ that is to let one of them pass over the other. */
1525
+ .xeno-element[data-glyph='shapes'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-shapes-square; --part-dur: 600ms; }
1526
+ @keyframes xeno-pt-shapes-square {
1527
+ 0% { transform: translate(0, 0); }
1528
+ 38% { transform: translate(3.1px, 3.1px); }
1529
+ 100% { transform: translate(0, 0); }
1530
+ }
1531
+
1532
+ /* waves — the three roll, each a beat behind the one above it. The glyph is drawn with them in phase so
1533
+ the motion has somewhere to go: staggered at rest, it would already look like it was moving and the
1534
+ hover would have nothing left to add. Half a wavelength each way, so the crest lands where the trough
1535
+ was and nothing appears to slide off the edge. */
1536
+ .xeno-element[data-glyph='waves'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-wave-a; --part-dur: 900ms; }
1537
+ .xeno-element[data-glyph='waves'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-wave-b; --part-dur: 900ms; }
1538
+ .xeno-element[data-glyph='waves'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-wave-c; --part-dur: 900ms; }
1539
+ @keyframes xeno-pt-wave-a { 0% { transform: translateX(0); } 50% { transform: translateX(2.4px); } 100% { transform: translateX(0); } }
1540
+ @keyframes xeno-pt-wave-b { 0% { transform: translateX(0); } 14% { transform: translateX(0); } 60% { transform: translateX(2.4px); } 100% { transform: translateX(0); } }
1541
+ @keyframes xeno-pt-wave-c { 0% { transform: translateX(0); } 28% { transform: translateX(0); } 70% { transform: translateX(2.4px); } 100% { transform: translateX(0); } }
1542
+
1543
+ /* feather — the quill dips from its point, the way a pen is put to paper. The pivot is the tip, because
1544
+ that is the part that stays put when you write. */
1545
+ /* Percentages, for the reason spelled out on `brain`: this is a whole-icon rule, so px would be CSS
1546
+ pixels off the root element's own box and the tip would only be the tip at size 24. 4.2/24, 19.8/24. */
1547
+ .xeno-element[data-glyph='feather'] {
1548
+ --xeno-icon-anim: xeno-feather-dip;
1549
+ --xeno-icon-dur: 620ms;
1550
+ transform-origin: 17.5% 82.5%;
1551
+ }
1552
+ @keyframes xeno-feather-dip {
1553
+ 0% { transform: rotate(0deg); }
1554
+ 36% { transform: rotate(-7deg); }
1555
+ 100% { transform: rotate(0deg); }
1556
+ }
1557
+
1558
+ /* hand — the fingers ripple, thumb to little finger. Four bars rather than one outline is what buys
1559
+ this: a scalloped silhouette can only wave as a whole, and a hand that waves as a whole is a paddle. */
1560
+ .xeno-element[data-glyph='hand'] .xeno-part[data-part='0'] { --part-anim: xeno-pt-finger-a; --part-dur: 720ms; }
1561
+ .xeno-element[data-glyph='hand'] .xeno-part[data-part='1'] { --part-anim: xeno-pt-finger-b; --part-dur: 720ms; }
1562
+ .xeno-element[data-glyph='hand'] .xeno-part[data-part='2'] { --part-anim: xeno-pt-finger-c; --part-dur: 720ms; }
1563
+ .xeno-element[data-glyph='hand'] .xeno-part[data-part='3'] { --part-anim: xeno-pt-finger-d; --part-dur: 720ms; }
1564
+ @keyframes xeno-pt-finger-a { 0% { transform: translateY(0); } 26% { transform: translateY(-3.1px); } 60% { transform: translateY(0); } 100% { transform: translateY(0); } }
1565
+ @keyframes xeno-pt-finger-b { 0% { transform: translateY(0); } 12% { transform: translateY(0); } 38% { transform: translateY(-3.1px); } 72% { transform: translateY(0); } 100% { transform: translateY(0); } }
1566
+ @keyframes xeno-pt-finger-c { 0% { transform: translateY(0); } 24% { transform: translateY(0); } 50% { transform: translateY(-3.1px); } 84% { transform: translateY(0); } 100% { transform: translateY(0); } }
1567
+ @keyframes xeno-pt-finger-d { 0% { transform: translateY(0); } 36% { transform: translateY(0); } 62% { transform: translateY(-3.1px); } 96% { transform: translateY(0); } 100% { transform: translateY(0); } }
1568
+
1569
+ /* file-clock — the hands sweep a full turn and the sheet holds. One turn, not a nudge: this glyph means
1570
+ history, and the only honest way to say "time passed" is to let some. */
1571
+ .xeno-element[data-glyph='file-clock'] .xeno-part[data-part='3'] {
1572
+ --part-anim: xeno-pt-fileclock;
1573
+ --part-dur: 900ms;
1574
+ --xeno-icon-ease: cubic-bezier(0.65, 0, 0.35, 1);
1575
+ transform-box: view-box;
1576
+ transform-origin: 11.7px 15.3px;
1577
+ }
1578
+ @keyframes xeno-pt-fileclock {
1579
+ 0% { transform: rotate(0deg); }
1580
+ 100% { transform: rotate(360deg); }
1581
+ }
1582
+
1583
+ /* ---------------------------------------------------------------------------
1584
+ A NOTE ON AMPLITUDE, which the seven glyphs below were all calibrated wrong for
1585
+ the first time round.
1586
+
1587
+ Every keyframe here is written in the 24-unit viewBox, and it is natural to judge
1588
+ it in the preview, where icons are drawn at 96 or 48. The chat draws them at 13
1589
+ to 16. At 14px a unit is 0.58 CSS pixels, so a motion that looks like a confident
1590
+ three units on the design grid arrives as 1.7 pixels — and a rotation of a
1591
+ near-symmetric shape arrives as nothing at all, because there is no edge whose
1592
+ travel the eye can follow.
1593
+
1594
+ Measured on the running chat with the host's :hover forced and the part rects
1595
+ sampled through the animation: brain-circuit moved 0.30px, smile 0.43, bot 0.49,
1596
+ target 0.58. All seven read as broken, and none of them was — the triggers fired
1597
+ every time. The rule the set already followed without writing it down is that the
1598
+ motions which work at small sizes are the LARGE ones: refresh spins a full turn
1599
+ (8.1px), chevron travels (4.7px), plus scales from zero (3.7px).
1600
+
1601
+ So: at these sizes, scale from zero and travel. Rotate only what has a long arm.
1602
+ --------------------------------------------------------------------------- */
1603
+
1604
+ /* brain — it tilts, the way a head does when someone is working something out. There is nothing else in
1605
+ this glyph that CAN move: two strokes, one of them the outline and the other the line that divides it,
1606
+ and a sulcus that slid would be a crack rather than a fold. So the motion is the whole object, and the
1607
+ gesture it borrows is the one people actually make when they think.
1608
+
1609
+ The pivot is the base of the brain rather than its centre — a head tilts on a neck, and rotating about
1610
+ the middle reads as a wobble.
1611
+
1612
+ It tips fourteen degrees and comes back through five, rather than the six it started life with. A blob
1613
+ is the hardest thing in this set to rotate legibly — it has no corner to watch — so it needs both the
1614
+ larger angle and the overshoot on the way back, which is the part the eye actually catches. */
1615
+ /* The pivot is a PERCENTAGE, and that is the whole of a bug this shipped with.
1616
+ *
1617
+ * A WHOLE-icon rule targets the root `<svg>`, which is a replaced element with a CSS box — so a
1618
+ * `transform-origin` written in px is CSS pixels measured from that box's corner, not viewBox units.
1619
+ * `12px 19.4px` is only the base of the brain when the icon happens to be drawn at 24. The chat draws
1620
+ * it at 14, where that pivot sits five pixels BELOW the glyph and two from its right edge, and a
1621
+ * fourteen-degree rotation about a point outside the icon is not a tilt — it is the drawing being
1622
+ * thrown sideways. Reported as "it doesn't look like a brain any more", which is what it looked like.
1623
+ *
1624
+ * `transform-box: view-box` looks like the fix and is a no-op here: on the root element the view box
1625
+ * and the fill box are the same rectangle, so the lengths stay CSS pixels either way. It only converts
1626
+ * to user units on a CHILD, which is why the per-part rules in this file (file-clock, library, the
1627
+ * nodes below) can and must use it. A whole-icon rule has to speak in percentages: 12/24 and 19.4/24.
1628
+ *
1629
+ * `feather` had the same defect and got away with it — seven degrees moves the drawing about a pixel,
1630
+ * which reads as slightly odd easing rather than as a bug. */
1631
+ .xeno-element[data-glyph='brain'] {
1632
+ --xeno-icon-anim: xeno-ic-brain-tilt;
1633
+ --xeno-icon-dur: 720ms;
1634
+ transform-origin: 50% 81%;
1635
+ }
1636
+ @keyframes xeno-ic-brain-tilt {
1637
+ 0% { transform: rotate(0deg); }
1638
+ 36% { transform: rotate(-14deg); }
1639
+ 68% { transform: rotate(5deg); }
1640
+ 100% { transform: rotate(0deg); }
1641
+ }
1642
+
1643
+ /* brain-circuit — the signal leaves the tissue and arrives at a node, twice, upper then lower. The order
1644
+ is the point; both at once would be a pulse, and a pulse is a heartbeat.
1645
+
1646
+ The trace DRAWS, from the sulcus outward, and the node then arrives from nothing. That is the second
1647
+ version: the first swelled the nodes 32% in place, which on a 2.8-unit square at 16px is four tenths
1648
+ of a pixel — the animation ran, and no one could have seen it. Growing from zero moves the same square
1649
+ by its whole width, and the trace's far end travels its whole length. Same idea, an order of magnitude
1650
+ more of it.
1651
+
1652
+ `scaleX` from the sulcus end for the trace, so it extends rather than growing from its middle — a
1653
+ signal has a direction. */
1654
+ .xeno-element[data-glyph='brain-circuit'] .xeno-part[data-part='2'] {
1655
+ --part-anim: xeno-pt-trace-a;
1656
+ --part-dur: 780ms;
1657
+ transform-box: view-box;
1658
+ transform-origin: 12px 9.3px;
1659
+ }
1660
+ .xeno-element[data-glyph='brain-circuit'] .xeno-part[data-part='3'] {
1661
+ --part-anim: xeno-pt-trace-b;
1662
+ --part-dur: 780ms;
1663
+ transform-box: view-box;
1664
+ transform-origin: 12px 15.3px;
1665
+ }
1666
+ .xeno-element[data-glyph='brain-circuit'] .xeno-part[data-part='4'] {
1667
+ --part-anim: xeno-pt-node-fire-a;
1668
+ --part-dur: 780ms;
1669
+ transform-box: view-box;
1670
+ transform-origin: 17px 9.3px;
1671
+ }
1672
+ .xeno-element[data-glyph='brain-circuit'] .xeno-part[data-part='5'] {
1673
+ --part-anim: xeno-pt-node-fire-b;
1674
+ --part-dur: 780ms;
1675
+ transform-box: view-box;
1676
+ transform-origin: 17px 15.3px;
1677
+ }
1678
+ @keyframes xeno-pt-trace-a {
1679
+ 0% { transform: scaleX(0); }
1680
+ 30% { transform: scaleX(1); }
1681
+ 100% { transform: scaleX(1); }
1682
+ }
1683
+ @keyframes xeno-pt-trace-b {
1684
+ 0% { transform: scaleX(0); }
1685
+ 26% { transform: scaleX(0); }
1686
+ 56% { transform: scaleX(1); }
1687
+ 100% { transform: scaleX(1); }
1688
+ }
1689
+ @keyframes xeno-pt-node-fire-a {
1690
+ 0% { transform: scale(0); }
1691
+ 30% { transform: scale(0); }
1692
+ 48% { transform: scale(1.25); }
1693
+ 62% { transform: scale(1); }
1694
+ 100% { transform: scale(1); }
1695
+ }
1696
+ @keyframes xeno-pt-node-fire-b {
1697
+ 0% { transform: scale(0); }
1698
+ 56% { transform: scale(0); }
1699
+ 74% { transform: scale(1.25); }
1700
+ 88% { transform: scale(1); }
1701
+ 100% { transform: scale(1); }
1702
+ }
1703
+
1704
+ /* bot — the antenna bobs and it blinks. The eyes are two-unit vertical STROKES, which is what makes the
1705
+ blink possible at all: a segment can close to nothing along one axis and open again. A dot can only
1706
+ shrink towards its own centre, which is a fade, and a robot whose eyes fade is powering down.
1707
+
1708
+ Both eyes on the same frame. Staggered, it would be a wink, and a wink from a piece of software is a
1709
+ different personality than this product has.
1710
+
1711
+ The antenna was added because the blink alone is not enough here. A blink is a real event — the eye is
1712
+ there and then it is not — but at 13px, which is what the empty state's tabs draw, the eye is barely a
1713
+ pixel tall to begin with, so its disappearing is a flicker rather than a gesture. The antenna EXTENDS,
1714
+ which gives the eye something to follow while the blink happens underneath it.
1715
+
1716
+ The stem stretches by exactly what the node travels, anchored at the head. Moving the node alone was
1717
+ the obvious version and it is wrong: a node that leaves its stem behind is a part coming off. */
1718
+ .xeno-element[data-glyph='bot'] .xeno-part[data-part='0'] {
1719
+ --part-anim: xeno-pt-bot-antenna;
1720
+ --part-dur: 700ms;
1721
+ transform-box: view-box;
1722
+ transform-origin: 12px 4px;
1723
+ }
1724
+ .xeno-element[data-glyph='bot'] .xeno-part[data-part='1'] {
1725
+ --part-anim: xeno-pt-bot-stem;
1726
+ --part-dur: 700ms;
1727
+ transform-box: view-box;
1728
+ transform-origin: 12px 8px;
1729
+ }
1730
+ .xeno-element[data-glyph='bot'] .xeno-part[data-part='3'] {
1731
+ --part-anim: xeno-pt-bot-blink;
1732
+ --part-dur: 700ms;
1733
+ transform-box: view-box;
1734
+ transform-origin: 8.8px 13.6px;
1735
+ }
1736
+ .xeno-element[data-glyph='bot'] .xeno-part[data-part='4'] {
1737
+ --part-anim: xeno-pt-bot-blink;
1738
+ --part-dur: 700ms;
1739
+ transform-box: view-box;
1740
+ transform-origin: 15.2px 13.6px;
1741
+ }
1742
+ @keyframes xeno-pt-bot-antenna {
1743
+ 0% { transform: translateY(0); }
1744
+ 30% { transform: translateY(-2.6px); }
1745
+ 62% { transform: translateY(0.4px); }
1746
+ 100% { transform: translateY(0); }
1747
+ }
1748
+ /* 2.6 units of travel on a 2.6-unit stem is exactly double its length, and 0.4 back is 1.15. */
1749
+ @keyframes xeno-pt-bot-stem {
1750
+ 0% { transform: scaleY(1); }
1751
+ 30% { transform: scaleY(2); }
1752
+ 62% { transform: scaleY(0.85); }
1753
+ 100% { transform: scaleY(1); }
1754
+ }
1755
+ @keyframes xeno-pt-bot-blink {
1756
+ 0% { transform: scaleY(1); }
1757
+ 40% { transform: scaleY(1); }
1758
+ 54% { transform: scaleY(0.04); }
1759
+ 70% { transform: scaleY(1); }
1760
+ 100% { transform: scaleY(1); }
1761
+ }
1762
+
1763
+ /* sparkles — the two twinkle out of phase, the small one following the large. That order is the reason
1764
+ there are two: a single star can only pulse, and a pulse has no direction, while a second one arriving
1765
+ late reads as light catching on something.
1766
+
1767
+ A quarter turn goes with the scale. A four-pointed star rotated 90° is the same star, so the turn
1768
+ costs nothing at the ends of the animation and is visible for every frame in between — which is
1769
+ exactly the property a twinkle wants. */
1770
+ .xeno-element[data-glyph='sparkles'] .xeno-part[data-part='0'] {
1771
+ --part-anim: xeno-pt-twinkle-a;
1772
+ --part-dur: 820ms;
1773
+ transform-box: view-box;
1774
+ transform-origin: 9.6px 9.6px;
1775
+ }
1776
+ .xeno-element[data-glyph='sparkles'] .xeno-part[data-part='1'] {
1777
+ --part-anim: xeno-pt-twinkle-b;
1778
+ --part-dur: 820ms;
1779
+ transform-box: view-box;
1780
+ transform-origin: 17.8px 17.4px;
1781
+ }
1782
+ @keyframes xeno-pt-twinkle-a {
1783
+ 0% { transform: rotate(0deg) scale(1); }
1784
+ 46% { transform: rotate(45deg) scale(0.78); }
1785
+ 100% { transform: rotate(90deg) scale(1); }
1786
+ }
1787
+ @keyframes xeno-pt-twinkle-b {
1788
+ 0% { transform: rotate(0deg) scale(1); }
1789
+ 24% { transform: rotate(0deg) scale(1); }
1790
+ 62% { transform: rotate(45deg) scale(0.72); }
1791
+ 100% { transform: rotate(90deg) scale(1); }
1792
+ }
1793
+
1794
+ /* wand-sparkles — the wand flicks and the star answers. Cause and effect, in that order and with a gap
1795
+ between them: the shaft and its joint swing together about the handle, and only after they have turned
1796
+ does the star swell. Simultaneous, the two would look like one object stretching.
1797
+
1798
+ The pivot is the butt of the handle, because that is where a hand would be, and the whole point of the
1799
+ joint being drawn at the lower third is to say so. */
1800
+ .xeno-element[data-glyph='wand-sparkles'] .xeno-part[data-part='0'],
1801
+ .xeno-element[data-glyph='wand-sparkles'] .xeno-part[data-part='1'] {
1802
+ --part-anim: xeno-pt-wand-flick;
1803
+ --part-dur: 720ms;
1804
+ transform-box: view-box;
1805
+ transform-origin: 3.8px 20.2px;
1806
+ }
1807
+ .xeno-element[data-glyph='wand-sparkles'] .xeno-part[data-part='2'] {
1808
+ --part-anim: xeno-pt-wand-star;
1809
+ --part-dur: 720ms;
1810
+ transform-box: view-box;
1811
+ transform-origin: 18.2px 6.4px;
1812
+ }
1813
+ /* Eighteen degrees, not nine. The shaft has a long arm — its tip is fifteen units from the handle — so
1814
+ this is the one glyph in the group where rotation was already the right instrument and simply needed
1815
+ twice as much of it. The star arrives from nothing rather than swelling a quarter, for the same reason
1816
+ the circuit's nodes do. */
1817
+ @keyframes xeno-pt-wand-flick {
1818
+ 0% { transform: rotate(0deg); }
1819
+ 30% { transform: rotate(-18deg); }
1820
+ 64% { transform: rotate(4deg); }
1821
+ 100% { transform: rotate(0deg); }
1822
+ }
1823
+ @keyframes xeno-pt-wand-star {
1824
+ 0% { transform: rotate(0deg) scale(0.2); }
1825
+ 32% { transform: rotate(0deg) scale(0.2); }
1826
+ 58% { transform: rotate(45deg) scale(1.35); }
1827
+ 78% { transform: rotate(70deg) scale(0.94); }
1828
+ 100% { transform: rotate(90deg) scale(1); }
1829
+ }
1830
+
1831
+ /* target — the rings close on the centre and open again. Inner first, then outer, so the tightening
1832
+ reads as one movement travelling INWARD rather than the whole glyph shrinking. The centre mark holds
1833
+ still, because it is the thing being converged on; a bullseye that moved would be the wrong idea
1834
+ entirely. */
1835
+ .xeno-element[data-glyph='target'] .xeno-part[data-part='0'] {
1836
+ --part-anim: xeno-pt-target-outer;
1837
+ --part-dur: 680ms;
1838
+ transform-box: view-box;
1839
+ transform-origin: 12px 12px;
1840
+ }
1841
+ .xeno-element[data-glyph='target'] .xeno-part[data-part='1'] {
1842
+ --part-anim: xeno-pt-target-inner;
1843
+ --part-dur: 680ms;
1844
+ transform-box: view-box;
1845
+ transform-origin: 12px 12px;
1846
+ }
1847
+ /* The rings close much harder than they first did — 0.9 and 0.76 were a tenth and a quarter of rings
1848
+ that are ten and five pixels wide on screen, so the whole convergence happened inside a pixel. The
1849
+ inner ring now collapses most of the way to the centre mark, which is the reading anyway: it is not
1850
+ that the rings shrink a little, it is that they close ON something. */
1851
+ @keyframes xeno-pt-target-outer {
1852
+ 0% { transform: scale(1); }
1853
+ 40% { transform: scale(0.8); }
1854
+ 72% { transform: scale(1.04); }
1855
+ 100% { transform: scale(1); }
1856
+ }
1857
+ @keyframes xeno-pt-target-inner {
1858
+ 0% { transform: scale(1); }
1859
+ 32% { transform: scale(0.42); }
1860
+ 70% { transform: scale(1.08); }
1861
+ 100% { transform: scale(1); }
1862
+ }
1863
+
1864
+ /* smile — the eyes narrow and the mouth widens, together. A smile is not a mouth shape; it is what the
1865
+ eyes do at the same time, which is why the eyes are strokes here and not dots. A face where only the
1866
+ mouth moves is a face being polite about something.
1867
+
1868
+ The mouth widens rather than curving deeper, because the curve is geometry and widening is a
1869
+ transform — and a smile that got WIDER is the same reading as one that got deeper, for free. */
1870
+ .xeno-element[data-glyph='smile'] .xeno-part[data-part='1'] {
1871
+ --part-anim: xeno-pt-smile-eye;
1872
+ --part-dur: 620ms;
1873
+ transform-box: view-box;
1874
+ transform-origin: 9.2px 10.9px;
1875
+ }
1876
+ .xeno-element[data-glyph='smile'] .xeno-part[data-part='2'] {
1877
+ --part-anim: xeno-pt-smile-eye;
1878
+ --part-dur: 620ms;
1879
+ transform-box: view-box;
1880
+ transform-origin: 14.8px 10.9px;
1881
+ }
1882
+ .xeno-element[data-glyph='smile'] .xeno-part[data-part='3'] {
1883
+ --part-anim: xeno-pt-smile-mouth;
1884
+ --part-dur: 620ms;
1885
+ transform-box: view-box;
1886
+ transform-origin: 12px 15.4px;
1887
+ }
1888
+ /* The eyes close ALL the way rather than halfway, and the mouth takes a third more width instead of a
1889
+ seventh. Halfway on a 1.8-unit stroke is nine tenths of a unit — half a pixel where this glyph is
1890
+ actually drawn — so the first version was a smile no one could see happen. Squeezed shut, the eye is
1891
+ simply gone for a moment, which is an event at any size. */
1892
+ @keyframes xeno-pt-smile-eye {
1893
+ 0% { transform: scaleY(1); }
1894
+ 38% { transform: scaleY(0.05) translateY(0.5px); }
1895
+ 72% { transform: scaleY(1.1); }
1896
+ 100% { transform: scaleY(1); }
1897
+ }
1898
+ @keyframes xeno-pt-smile-mouth {
1899
+ 0% { transform: scale(1, 1); }
1900
+ 38% { transform: scale(1.34, 1.45); }
1901
+ 72% { transform: scale(0.96, 0.94); }
1902
+ 100% { transform: scale(1, 1); }
1903
+ }
1904
+
1905
+ /* ---- Accessibility: honor reduced-motion ----------------------------------- */
1906
+ @media (prefers-reduced-motion: reduce) {
1907
+ .xeno-element[data-glyph],
1908
+ .xeno-element[data-glyph] .xeno-part { animation: none !important; }
1909
+ }