@blotch/dolan 0.0.0-stage → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/dist/commands/add.d.ts +22 -0
  2. package/dist/commands/add.js +132 -0
  3. package/dist/commands/add.js.map +1 -0
  4. package/dist/commands/add.test.d.ts +1 -0
  5. package/dist/commands/add.test.js +209 -0
  6. package/dist/commands/add.test.js.map +1 -0
  7. package/dist/commands/diff.d.ts +13 -0
  8. package/dist/commands/diff.js +155 -0
  9. package/dist/commands/diff.js.map +1 -0
  10. package/dist/commands/diff.test.d.ts +1 -0
  11. package/dist/commands/diff.test.js +327 -0
  12. package/dist/commands/diff.test.js.map +1 -0
  13. package/dist/commands/doctor.d.ts +15 -0
  14. package/dist/commands/doctor.js +248 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/doctor.test.d.ts +1 -0
  17. package/dist/commands/doctor.test.js +301 -0
  18. package/dist/commands/doctor.test.js.map +1 -0
  19. package/dist/commands/eject.d.ts +11 -0
  20. package/dist/commands/eject.js +55 -0
  21. package/dist/commands/eject.js.map +1 -0
  22. package/dist/commands/eject.test.d.ts +1 -0
  23. package/dist/commands/eject.test.js +151 -0
  24. package/dist/commands/eject.test.js.map +1 -0
  25. package/dist/commands/info.d.ts +1 -0
  26. package/dist/commands/info.js +73 -0
  27. package/dist/commands/info.js.map +1 -0
  28. package/dist/commands/info.test.d.ts +1 -0
  29. package/dist/commands/info.test.js +143 -0
  30. package/dist/commands/info.test.js.map +1 -0
  31. package/dist/commands/init.d.ts +1 -0
  32. package/dist/commands/init.js +98 -0
  33. package/dist/commands/init.js.map +1 -0
  34. package/dist/commands/init.test.d.ts +1 -0
  35. package/dist/commands/init.test.js +154 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/list.d.ts +16 -0
  38. package/dist/commands/list.js +124 -0
  39. package/dist/commands/list.js.map +1 -0
  40. package/dist/commands/list.test.d.ts +1 -0
  41. package/dist/commands/list.test.js +285 -0
  42. package/dist/commands/list.test.js.map +1 -0
  43. package/dist/commands/outdated.d.ts +13 -0
  44. package/dist/commands/outdated.js +116 -0
  45. package/dist/commands/outdated.js.map +1 -0
  46. package/dist/commands/outdated.test.d.ts +1 -0
  47. package/dist/commands/outdated.test.js +208 -0
  48. package/dist/commands/outdated.test.js.map +1 -0
  49. package/dist/commands/remove.d.ts +18 -0
  50. package/dist/commands/remove.js +161 -0
  51. package/dist/commands/remove.js.map +1 -0
  52. package/dist/commands/remove.test.d.ts +1 -0
  53. package/dist/commands/remove.test.js +361 -0
  54. package/dist/commands/remove.test.js.map +1 -0
  55. package/dist/commands/theme.d.ts +1 -0
  56. package/dist/commands/theme.js +66 -0
  57. package/dist/commands/theme.js.map +1 -0
  58. package/dist/commands/theme.test.d.ts +1 -0
  59. package/dist/commands/theme.test.js +133 -0
  60. package/dist/commands/theme.test.js.map +1 -0
  61. package/dist/commands/update.d.ts +31 -0
  62. package/dist/commands/update.js +601 -0
  63. package/dist/commands/update.js.map +1 -0
  64. package/dist/commands/update.test.d.ts +1 -0
  65. package/dist/commands/update.test.js +1233 -0
  66. package/dist/commands/update.test.js.map +1 -0
  67. package/dist/config.d.ts +11 -0
  68. package/dist/config.js +19 -0
  69. package/dist/config.js.map +1 -0
  70. package/dist/config.test.d.ts +1 -0
  71. package/dist/config.test.js +48 -0
  72. package/dist/config.test.js.map +1 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +89 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/lockfile.d.ts +21 -0
  77. package/dist/lockfile.js +39 -0
  78. package/dist/lockfile.js.map +1 -0
  79. package/dist/registry.d.ts +14 -0
  80. package/dist/registry.js +85 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/registry.test.d.ts +1 -0
  83. package/dist/registry.test.js +95 -0
  84. package/dist/registry.test.js.map +1 -0
  85. package/dist/test-utils.d.ts +16 -0
  86. package/dist/test-utils.js +65 -0
  87. package/dist/test-utils.js.map +1 -0
  88. package/dist/ui/components/accordion/accordion.test.ts +194 -0
  89. package/dist/ui/components/accordion/accordion.ts +173 -0
  90. package/dist/ui/components/accordion/index.ts +6 -0
  91. package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
  92. package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
  93. package/dist/ui/components/action-sheet/index.ts +6 -0
  94. package/dist/ui/components/alert/alert.test.ts +48 -0
  95. package/dist/ui/components/alert/alert.ts +104 -0
  96. package/dist/ui/components/alert/index.ts +1 -0
  97. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
  98. package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
  99. package/dist/ui/components/alert-dialog/index.ts +7 -0
  100. package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
  101. package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
  102. package/dist/ui/components/area-chart/area-chart.ts +349 -0
  103. package/dist/ui/components/area-chart/index.ts +6 -0
  104. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
  105. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
  106. package/dist/ui/components/aspect-ratio/index.ts +1 -0
  107. package/dist/ui/components/avatar/avatar.test.ts +69 -0
  108. package/dist/ui/components/avatar/avatar.ts +131 -0
  109. package/dist/ui/components/avatar/index.ts +1 -0
  110. package/dist/ui/components/badge/badge.test.ts +49 -0
  111. package/dist/ui/components/badge/badge.ts +97 -0
  112. package/dist/ui/components/badge/index.ts +1 -0
  113. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
  114. package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
  115. package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
  116. package/dist/ui/components/bar-chart/index.ts +6 -0
  117. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
  118. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
  119. package/dist/ui/components/bottom-sheet/index.ts +1 -0
  120. package/dist/ui/components/button/button-render.test.ts +113 -0
  121. package/dist/ui/components/button/button.test.ts +70 -0
  122. package/dist/ui/components/button/button.ts +170 -0
  123. package/dist/ui/components/button/index.ts +6 -0
  124. package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
  125. package/dist/ui/components/button-group/button-group.test.ts +54 -0
  126. package/dist/ui/components/button-group/button-group.ts +209 -0
  127. package/dist/ui/components/button-group/index.ts +8 -0
  128. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
  129. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
  130. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
  131. package/dist/ui/components/candlestick-chart/index.ts +7 -0
  132. package/dist/ui/components/card/card.test.ts +110 -0
  133. package/dist/ui/components/card/card.ts +162 -0
  134. package/dist/ui/components/card/index.ts +8 -0
  135. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
  136. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
  137. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
  138. package/dist/ui/components/cartesian-chart/index.ts +18 -0
  139. package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
  140. package/dist/ui/components/checkbox/checkbox.ts +96 -0
  141. package/dist/ui/components/checkbox/index.ts +1 -0
  142. package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
  143. package/dist/ui/components/collapsible/collapsible.ts +122 -0
  144. package/dist/ui/components/collapsible/index.ts +5 -0
  145. package/dist/ui/components/dialog/dialog.test.ts +157 -0
  146. package/dist/ui/components/dialog/dialog.ts +248 -0
  147. package/dist/ui/components/dialog/index.ts +7 -0
  148. package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
  149. package/dist/ui/components/empty-state/empty-state.ts +46 -0
  150. package/dist/ui/components/empty-state/index.ts +1 -0
  151. package/dist/ui/components/icon/icon.test.ts +52 -0
  152. package/dist/ui/components/icon/icon.ts +54 -0
  153. package/dist/ui/components/icon/icons.ts +569 -0
  154. package/dist/ui/components/icon/index.ts +2 -0
  155. package/dist/ui/components/input/index.ts +1 -0
  156. package/dist/ui/components/input/input.test.ts +134 -0
  157. package/dist/ui/components/input/input.ts +189 -0
  158. package/dist/ui/components/label/index.ts +1 -0
  159. package/dist/ui/components/label/label.test.ts +38 -0
  160. package/dist/ui/components/label/label.ts +24 -0
  161. package/dist/ui/components/line-chart/index.ts +6 -0
  162. package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
  163. package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
  164. package/dist/ui/components/line-chart/line-chart.ts +273 -0
  165. package/dist/ui/components/list/index.ts +1 -0
  166. package/dist/ui/components/list/list.test.ts +35 -0
  167. package/dist/ui/components/list/list.ts +71 -0
  168. package/dist/ui/components/nav-drawer/index.ts +8 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
  171. package/dist/ui/components/pagination/index.ts +1 -0
  172. package/dist/ui/components/pagination/pagination.test.ts +96 -0
  173. package/dist/ui/components/pagination/pagination.ts +147 -0
  174. package/dist/ui/components/progress/index.ts +1 -0
  175. package/dist/ui/components/progress/progress.test.ts +71 -0
  176. package/dist/ui/components/progress/progress.ts +118 -0
  177. package/dist/ui/components/radio-group/index.ts +1 -0
  178. package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
  179. package/dist/ui/components/radio-group/radio-group.ts +129 -0
  180. package/dist/ui/components/scatter-chart/index.ts +6 -0
  181. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
  182. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
  183. package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
  184. package/dist/ui/components/scroll-area/index.ts +1 -0
  185. package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
  186. package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
  187. package/dist/ui/components/select/index.ts +1 -0
  188. package/dist/ui/components/select/select.test.ts +107 -0
  189. package/dist/ui/components/select/select.ts +146 -0
  190. package/dist/ui/components/separator/index.ts +1 -0
  191. package/dist/ui/components/separator/separator.test.ts +26 -0
  192. package/dist/ui/components/separator/separator.ts +45 -0
  193. package/dist/ui/components/sheet/index.ts +7 -0
  194. package/dist/ui/components/sheet/sheet.test.ts +44 -0
  195. package/dist/ui/components/sheet/sheet.ts +114 -0
  196. package/dist/ui/components/skeleton/index.ts +1 -0
  197. package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
  198. package/dist/ui/components/skeleton/skeleton.ts +45 -0
  199. package/dist/ui/components/spinner/index.ts +1 -0
  200. package/dist/ui/components/spinner/spinner.test.ts +39 -0
  201. package/dist/ui/components/spinner/spinner.ts +83 -0
  202. package/dist/ui/components/switch/index.ts +1 -0
  203. package/dist/ui/components/switch/switch.test.ts +63 -0
  204. package/dist/ui/components/switch/switch.ts +98 -0
  205. package/dist/ui/components/tabs/index.ts +1 -0
  206. package/dist/ui/components/tabs/tabs.test.ts +199 -0
  207. package/dist/ui/components/tabs/tabs.ts +317 -0
  208. package/dist/ui/components/textarea/index.ts +1 -0
  209. package/dist/ui/components/textarea/textarea.test.ts +138 -0
  210. package/dist/ui/components/textarea/textarea.ts +221 -0
  211. package/dist/ui/components/toast/index.ts +9 -0
  212. package/dist/ui/components/toast/toast-state.test.ts +54 -0
  213. package/dist/ui/components/toast/toast-state.ts +46 -0
  214. package/dist/ui/components/toast/toast.test.ts +342 -0
  215. package/dist/ui/components/toast/toast.ts +527 -0
  216. package/dist/ui/components/toggle/index.ts +7 -0
  217. package/dist/ui/components/toggle/toggle.test.ts +186 -0
  218. package/dist/ui/components/toggle/toggle.ts +218 -0
  219. package/dist/ui/components/typography/index.ts +5 -0
  220. package/dist/ui/components/typography/typography.test.ts +55 -0
  221. package/dist/ui/components/typography/typography.ts +44 -0
  222. package/dist/ui/theme/dark.css +41 -0
  223. package/dist/ui/theme/default.css +56 -0
  224. package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
  225. package/dist/ui/theme/tailwind-plugin.ts +151 -0
  226. package/dist/ui/utils/animate.test.ts +324 -0
  227. package/dist/ui/utils/animate.ts +544 -0
  228. package/dist/ui/utils/cn.test.ts +27 -0
  229. package/dist/ui/utils/cn.ts +6 -0
  230. package/dist/utils/analyze.d.ts +32 -0
  231. package/dist/utils/analyze.js +109 -0
  232. package/dist/utils/analyze.js.map +1 -0
  233. package/dist/utils/analyze.test.d.ts +1 -0
  234. package/dist/utils/analyze.test.js +146 -0
  235. package/dist/utils/analyze.test.js.map +1 -0
  236. package/dist/utils/animate.d.ts +210 -0
  237. package/dist/utils/animate.js +423 -0
  238. package/dist/utils/animate.js.map +1 -0
  239. package/dist/utils/animate.test.d.ts +1 -0
  240. package/dist/utils/animate.test.js +365 -0
  241. package/dist/utils/animate.test.js.map +1 -0
  242. package/dist/utils/banner.d.ts +1 -0
  243. package/dist/utils/banner.js +15 -0
  244. package/dist/utils/banner.js.map +1 -0
  245. package/dist/utils/banner.test.d.ts +1 -0
  246. package/dist/utils/banner.test.js +29 -0
  247. package/dist/utils/banner.test.js.map +1 -0
  248. package/dist/utils/cn.d.ts +2 -0
  249. package/dist/utils/cn.js +6 -0
  250. package/dist/utils/cn.js.map +1 -0
  251. package/dist/utils/cn.test.d.ts +1 -0
  252. package/dist/utils/cn.test.js +21 -0
  253. package/dist/utils/cn.test.js.map +1 -0
  254. package/dist/utils/format.d.ts +31 -0
  255. package/dist/utils/format.js +95 -0
  256. package/dist/utils/format.js.map +1 -0
  257. package/dist/utils/format.test.d.ts +1 -0
  258. package/dist/utils/format.test.js +71 -0
  259. package/dist/utils/format.test.js.map +1 -0
  260. package/dist/utils/logger.d.ts +6 -0
  261. package/dist/utils/logger.js +16 -0
  262. package/dist/utils/logger.js.map +1 -0
  263. package/dist/utils/logger.test.d.ts +1 -0
  264. package/dist/utils/logger.test.js +36 -0
  265. package/dist/utils/logger.test.js.map +1 -0
  266. package/dist/utils/resolve-paths.d.ts +12 -0
  267. package/dist/utils/resolve-paths.js +29 -0
  268. package/dist/utils/resolve-paths.js.map +1 -0
  269. package/dist/utils/resolve-paths.test.d.ts +1 -0
  270. package/dist/utils/resolve-paths.test.js +52 -0
  271. package/dist/utils/resolve-paths.test.js.map +1 -0
  272. package/package.json +56 -4
  273. package/README.md +0 -3
@@ -0,0 +1,349 @@
1
+ import {
2
+ Component,
3
+ ViewEncapsulation,
4
+ computed,
5
+ inject,
6
+ input,
7
+ output,
8
+ } from '@angular/core';
9
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
10
+
11
+ import {
12
+ type ChartPadding,
13
+ type ChartPoint,
14
+ UiCartesianChart,
15
+ niceScale,
16
+ sampleSmoothLine,
17
+ } from '../cartesian-chart/cartesian-chart';
18
+ import { UiLineSeries } from '../line-chart/line-chart';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Pure geometry (exported so it can be unit-tested without Angular)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** One vertical fill column: a rectangle `height` px tall starting at (`x`, `top`). */
25
+ export type AreaColumn = {
26
+ x: number;
27
+ width: number;
28
+ top: number;
29
+ height: number;
30
+ };
31
+
32
+ /**
33
+ * Linearly interpolates the polyline's y at pixel `x`. `points` must be sorted
34
+ * ascending by x (as cartesian-chart data always is). Returns `null` when `x`
35
+ * falls outside the data's x-range, so the fill is only drawn *under the actual
36
+ * series* — never extrapolated flat past the first or last point.
37
+ */
38
+ const interpolateY = (
39
+ points: readonly ChartPoint[],
40
+ x: number,
41
+ ): number | null => {
42
+ const first = points[0];
43
+ const last = points[points.length - 1];
44
+ if (x < first.x || x > last.x) return null;
45
+ for (let i = 0; i < points.length - 1; i++) {
46
+ const p1 = points[i];
47
+ const p2 = points[i + 1];
48
+ if (x >= p1.x && x <= p2.x) {
49
+ const span = p2.x - p1.x;
50
+ if (span === 0) return p1.y; // duplicate x — avoid divide-by-zero
51
+ return p1.y + ((x - p1.x) / span) * (p2.y - p1.y);
52
+ }
53
+ }
54
+ /* v8 ignore next 1 */
55
+ return last.y; // defensive: x === last.x but the loop missed it
56
+ };
57
+
58
+ /**
59
+ * Rasterizes the region between a polyline and a horizontal baseline into thin
60
+ * vertical `<view>` columns — the Lynx substitute for a filled area.
61
+ *
62
+ * Lynx has no filled-polygon primitive: `clip-path` supports no `polygon()` and
63
+ * its inline `<svg>` is unreliable, so an area is approximated as a run of
64
+ * adjacent rectangles, one per `stripWidth` band, each reaching from the
65
+ * interpolated line height down to `baseY`. The crisp line is drawn on top (see
66
+ * `UiAreaSeries`), which hides the columns' stepped upper edge. Sampling at each
67
+ * strip's center keeps that step within about `stripWidth / 2 × slope` px.
68
+ *
69
+ * Guards: fewer than two points, or a non-positive `plotWidth`/`stripWidth`,
70
+ * yield no columns; near-zero-height columns (line meeting the baseline) are
71
+ * skipped so we never emit a degenerate 0-height view.
72
+ */
73
+ export const computeAreaColumns = (
74
+ points: readonly ChartPoint[],
75
+ baseY: number,
76
+ plotWidth: number,
77
+ stripWidth: number,
78
+ ): AreaColumn[] => {
79
+ const columns: AreaColumn[] = [];
80
+ if (points.length < 2 || plotWidth <= 0 || stripWidth <= 0) return columns;
81
+
82
+ for (let x = 0; x < plotWidth; x += stripWidth) {
83
+ // Sample the line at the strip's center (clamped to the plot's right edge).
84
+ const center = Math.min(x + stripWidth / 2, plotWidth);
85
+ const lineY = interpolateY(points, center);
86
+ if (lineY === null) continue;
87
+
88
+ const top = Math.min(lineY, baseY);
89
+ const height = Math.abs(lineY - baseY);
90
+ if (height < 0.01) continue; // line rests on the baseline here — nothing to fill
91
+
92
+ // A hair of overlap (stripWidth + 1) closes sub-pixel seams between adjacent
93
+ // columns. Harmless: the shared `opacity` layer composites them once, so the
94
+ // overlaps never double-darken.
95
+ columns.push({ x, width: stripWidth + 1, top, height });
96
+ }
97
+ return columns;
98
+ };
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Rendering helpers (mirror the equivalents in line-chart.ts — kept local so
102
+ // this module stays self-contained when scaffolded by `dolan add`)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Formats a pixel value for an inline style, avoiding scientific notation.
107
+ */
108
+ const px = (n: number): string => `${n.toFixed(2)}px`;
109
+
110
+ /**
111
+ * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
112
+ */
113
+ const defaultTickFormat = (value: number): string =>
114
+ String(Math.round(value * 100) / 100);
115
+
116
+ const clamp = (value: number, lo: number, hi: number): number =>
117
+ Math.max(lo, Math.min(hi, value));
118
+
119
+ /**
120
+ * Width of each fill strip in px. Smaller means a smoother top edge but more
121
+ * native views (roughly `plotWidth / STRIP_WIDTH` per series); 3px keeps a
122
+ * ~250px plot near ~83 views, and the crisp line drawn on top hides the residual
123
+ * stepping. Lynx offers no polygon `clip-path` or reliable `<svg>` fill, so the
124
+ * area is rasterized this way rather than drawn as a single shape.
125
+ */
126
+ const STRIP_WIDTH = 3;
127
+
128
+ /**
129
+ * A single area series drawn inside a `<ui-cartesian-chart>`: a translucent fill
130
+ * beneath a line. It reuses `<ui-line-series>` for the crisp line + dots and adds
131
+ * only the fill — an area chart is, structurally, a line chart with the region
132
+ * down to a baseline filled in.
133
+ *
134
+ * ## Why the host is `position: absolute; width/height: 100%`
135
+ * Identical to `UiLineSeries`: Lynx resolves an absolute child against its
136
+ * **direct parent** (there is no `position: static`), so the host itself must be
137
+ * the plot-sized containing block. Both the fill layer and the nested
138
+ * `<ui-line-series>` are direct children of this host and fill it exactly.
139
+ *
140
+ * ## Why the nested `<ui-line-series>` still finds the chart
141
+ * Element injectors are parented by *template nesting*, not physical projection.
142
+ * `<ui-line-series>` sits in this component's template, so its injector chains up
143
+ * through this host to the `UiCartesianChart` that `<ui-area-series>` is projected
144
+ * into — the same `inject(UiCartesianChart)` that `UiLineSeries` already relies
145
+ * on, one level deeper.
146
+ */
147
+ @Component({
148
+ selector: 'ui-area-series',
149
+ standalone: true,
150
+ imports: [LYNX_ELEMENTS, UiLineSeries],
151
+ encapsulation: ViewEncapsulation.None,
152
+ host: {
153
+ style:
154
+ 'position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;',
155
+ '[class]': 'userClass()',
156
+ },
157
+ template: `
158
+ <!-- Fill layer first so the line + dots (below) paint on top of it. Its own
159
+ opacity makes the whole fill translucent in a single composite —
160
+ independent of the color's format (rgba/var()/named), so there's no need
161
+ to inject an alpha channel into an arbitrary CSS color string. -->
162
+ <view [style]="fillLayerStyle()">
163
+ @for (column of columns(); track $index) {
164
+ <view [style]="column.style"></view>
165
+ }
166
+ </view>
167
+ <ui-line-series
168
+ [data]="data()"
169
+ [color]="color()"
170
+ [strokeWidth]="strokeWidth()"
171
+ [dotRadius]="dotRadius()"
172
+ [showDots]="showDots()"
173
+ [smooth]="smooth()"
174
+ (pointTap)="pointTap.emit($event)"
175
+ />
176
+ `,
177
+ })
178
+ export class UiAreaSeries {
179
+ readonly #chart = inject(UiCartesianChart);
180
+
181
+ readonly data = input.required<ChartPoint[]>();
182
+ readonly color = input('var(--primary)');
183
+ readonly strokeWidth = input(2);
184
+ readonly dotRadius = input(3);
185
+ readonly showDots = input(true);
186
+ readonly fillOpacity = input(0.2);
187
+ /** Data-space y the fill descends to (the flat edge of the area). */
188
+ readonly baseline = input(0);
189
+ /** Fill under a smooth (monotone-cubic) curve instead of straight segments.
190
+ * Forwarded to the nested line so the crisp edge matches the fill's top. */
191
+ readonly smooth = input(false);
192
+ readonly userClass = input<string>('', { alias: 'class' });
193
+
194
+ readonly pointTap = output<ChartPoint>();
195
+
196
+ // Project each data point into plot-local pixel space via the shared scales.
197
+ readonly #pixelPoints = computed(() => {
198
+ const scaleX = this.#chart.xScale();
199
+ const scaleY = this.#chart.yScale();
200
+ return this.data().map((point) => ({
201
+ x: scaleX(point.x),
202
+ y: scaleY(point.y),
203
+ }));
204
+ });
205
+
206
+ protected readonly fillLayerStyle = computed(
207
+ () =>
208
+ `position: absolute; left: 0px; top: 0px; width: 100%; height: 100%; opacity: ${this.fillOpacity()};`,
209
+ );
210
+
211
+ protected readonly columns = computed(() => {
212
+ const fill = this.color();
213
+ // Clamp the baseline into the plot so a baseline outside the domain anchors
214
+ // the fill to the nearest edge (top/bottom) instead of drawing off-plot.
215
+ const baseY = clamp(
216
+ this.#chart.yScale()(this.baseline()),
217
+ 0,
218
+ this.#chart.plotHeight(),
219
+ );
220
+ // In smooth mode the fill traces the same monotone-cubic curve as the line:
221
+ // sampling the polyline densely means `computeAreaColumns` (which linearly
222
+ // interpolates the height at each strip) closely follows the curve's top edge.
223
+ const pixels = this.#pixelPoints();
224
+ const line = this.smooth() ? sampleSmoothLine(pixels) : pixels;
225
+ return computeAreaColumns(
226
+ line,
227
+ baseY,
228
+ this.#chart.plotWidth(),
229
+ STRIP_WIDTH,
230
+ ).map((c) => ({
231
+ style:
232
+ `position: absolute; left: ${px(c.x)}; top: ${px(c.top)}; ` +
233
+ `width: ${px(c.width)}; height: ${px(c.height)}; background-color: ${fill};`,
234
+ }));
235
+ });
236
+ }
237
+
238
+ /**
239
+ * A ready-to-use area chart for the common single-series case. Composes a
240
+ * `<ui-cartesian-chart>` with one `<ui-area-series>` and auto-derives rounded
241
+ * axis domains + ticks from the data. For multiple series or full control over
242
+ * the axes, use `<ui-cartesian-chart>` with `<ui-area-series>` children directly.
243
+ */
244
+ @Component({
245
+ selector: 'ui-area-chart',
246
+ standalone: true,
247
+ imports: [LYNX_ELEMENTS, UiCartesianChart, UiAreaSeries],
248
+ encapsulation: ViewEncapsulation.None,
249
+ template: `
250
+ <ui-cartesian-chart
251
+ [xDomain]="xAxis().domain"
252
+ [yDomain]="yAxis().domain"
253
+ [xTicks]="xAxis().ticks"
254
+ [yTicks]="yAxis().ticks"
255
+ [width]="width()"
256
+ [height]="height()"
257
+ [tickCount]="tickCount()"
258
+ [showXGrid]="showXGrid()"
259
+ [xAxisLabel]="xAxisLabel()"
260
+ [yAxisLabel]="yAxisLabel()"
261
+ [padding]="padding()"
262
+ [xTickFormat]="xTickFormat()"
263
+ [yTickFormat]="yTickFormat()"
264
+ [zoomable]="zoomable()"
265
+ [zoomAxes]="zoomAxes()"
266
+ [showZoomControls]="showZoomControls()"
267
+ [minZoom]="minZoom()"
268
+ [maxZoom]="maxZoom()"
269
+ [zoomStep]="zoomStep()"
270
+ [class]="userClass()"
271
+ >
272
+ <ui-area-series
273
+ [data]="data()"
274
+ [color]="color()"
275
+ [strokeWidth]="strokeWidth()"
276
+ [dotRadius]="dotRadius()"
277
+ [showDots]="showDots()"
278
+ [fillOpacity]="fillOpacity()"
279
+ [baseline]="baseline()"
280
+ [smooth]="smooth()"
281
+ (pointTap)="pointTap.emit($event)"
282
+ />
283
+ </ui-cartesian-chart>
284
+ `,
285
+ })
286
+ export class UiAreaChart {
287
+ readonly data = input.required<ChartPoint[]>();
288
+ readonly color = input('var(--primary)');
289
+ readonly width = input(320);
290
+ readonly height = input(200);
291
+ readonly tickCount = input(5);
292
+ readonly strokeWidth = input(2);
293
+ readonly dotRadius = input(3);
294
+ readonly showDots = input(true);
295
+ readonly fillOpacity = input(0.2);
296
+ readonly baseline = input(0);
297
+ /** Fill under a smooth (monotone-cubic) curve instead of straight segments. */
298
+ readonly smooth = input(false);
299
+ /** Draw vertical gridlines at each x tick. */
300
+ readonly showXGrid = input(false);
301
+ /** Title for the x-axis (centered below the tick labels). */
302
+ readonly xAxisLabel = input<string>('');
303
+ /** Title for the y-axis (rotated in the left gutter). */
304
+ readonly yAxisLabel = input<string>('');
305
+ /** Inner inset of the plot area, each side a fraction (0–1) — see {@link ChartPadding}. */
306
+ readonly padding = input<ChartPadding>({});
307
+ /** Force the x-axis lower/upper bound instead of deriving it from the data.
308
+ * Omitted bounds fall back to the data extremes. */
309
+ readonly xMin = input<number | undefined>(undefined);
310
+ readonly xMax = input<number | undefined>(undefined);
311
+ /** Force the y-axis lower/upper bound. The `baseline` is still folded in, so
312
+ * `yMin` only lowers the floor further — it can't clip the fill's base off. */
313
+ readonly yMin = input<number | undefined>(undefined);
314
+ readonly yMax = input<number | undefined>(undefined);
315
+ readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
316
+ readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
317
+ readonly userClass = input<string>('', { alias: 'class' });
318
+
319
+ // Pan/zoom — forwarded to the underlying <ui-cartesian-chart>. Off by default.
320
+ readonly zoomable = input(false);
321
+ readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
322
+ readonly showZoomControls = input(true);
323
+ readonly minZoom = input(1);
324
+ readonly maxZoom = input(8);
325
+ readonly zoomStep = input(1.4);
326
+
327
+ readonly pointTap = output<ChartPoint>();
328
+
329
+ // Auto-compute rounded axes from the data extremes, letting an explicit
330
+ // min/max override either end. `Math.min/max(...[])` yield ±Infinity for empty
331
+ // data, which `niceScale` handles by padding.
332
+ protected readonly xAxis = computed(() => {
333
+ const xs = this.data().map((p) => p.x);
334
+ const lo = this.xMin() ?? Math.min(...xs);
335
+ const hi = this.xMax() ?? Math.max(...xs);
336
+ return niceScale(lo, hi, this.tickCount());
337
+ });
338
+ // Fold the baseline into the y-extent so the axis always spans the whole filled
339
+ // region: a positive-only series still shows the baseline at the bottom, and
340
+ // negative values pull the domain below it. An explicit yMin/yMax overrides the
341
+ // corresponding data-derived end (the baseline still participates in the other).
342
+ protected readonly yAxis = computed(() => {
343
+ const ys = this.data().map((p) => p.y);
344
+ const base = this.baseline();
345
+ const lo = this.yMin() ?? Math.min(base, ...ys);
346
+ const hi = this.yMax() ?? Math.max(base, ...ys);
347
+ return niceScale(lo, hi, this.tickCount());
348
+ });
349
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ UiAreaChart,
3
+ UiAreaSeries,
4
+ computeAreaColumns,
5
+ type AreaColumn,
6
+ } from './area-chart';
@@ -0,0 +1,10 @@
1
+ import { render } from '@blotch/angular-lynx-testing-library';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { UiAspectRatio } from './aspect-ratio';
4
+
5
+ describe('UiAspectRatio', () => {
6
+ it('renders with the default 1:1 ratio', async () => {
7
+ const { container } = await render(UiAspectRatio);
8
+ expect(container).toBeTruthy();
9
+ });
10
+ });
@@ -0,0 +1,28 @@
1
+ import { Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
3
+
4
+ import { cn } from '../../utils/cn';
5
+
6
+ @Component({
7
+ selector: 'ui-aspect-ratio',
8
+ standalone: true,
9
+ imports: [LYNX_ELEMENTS],
10
+ encapsulation: ViewEncapsulation.None,
11
+ template: `
12
+ <view [class]="containerClass()" [style]="containerStyle()">
13
+ <ng-content />
14
+ </view>
15
+ `,
16
+ })
17
+ export class UiAspectRatio {
18
+ readonly ratio = input(1);
19
+ readonly userClass = input<string>('', { alias: 'class' });
20
+
21
+ protected readonly containerClass = computed(() =>
22
+ cn('w-full overflow-hidden', this.userClass()),
23
+ );
24
+
25
+ protected readonly containerStyle = computed(
26
+ () => `aspect-ratio: ${this.ratio()};`,
27
+ );
28
+ }
@@ -0,0 +1 @@
1
+ export { UiAspectRatio } from './aspect-ratio';
@@ -0,0 +1,69 @@
1
+ import { Component } from '@angular/core';
2
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { setInputSignal } from '../../../test-utils/set-input-signal';
6
+ import { UiAvatar } from './avatar';
7
+
8
+ @Component({
9
+ standalone: true,
10
+ imports: [UiAvatar, LYNX_ELEMENTS],
11
+ template: `<ui-avatar [src]="src" [fallback]="fallback" />`,
12
+ })
13
+ class AvatarHost {
14
+ src = '';
15
+ fallback = 'AB';
16
+ }
17
+
18
+ describe('UiAvatar', () => {
19
+ it('renders the fallback branch when src is empty', async () => {
20
+ const { container } = await render(AvatarHost);
21
+ expect(container).toBeTruthy();
22
+ });
23
+
24
+ it('renders without a src (default fallback path)', async () => {
25
+ const { container } = await render(UiAvatar);
26
+ expect(container).toBeTruthy();
27
+ });
28
+
29
+ it('renders the image branch when src is programmatically set', async () => {
30
+ // JIT template bindings are inert against input(); drive src directly so
31
+ // the @if(src()) branch fires and the image + skeleton render.
32
+ const { componentRef, container } = await render(UiAvatar);
33
+ setInputSignal(
34
+ (componentRef.instance as UiAvatar).src,
35
+ 'https://example.com/avatar.png',
36
+ );
37
+ await waitForUpdate();
38
+ expect(container.querySelector('image')).toBeTruthy();
39
+ });
40
+
41
+ it('keeps the image load / error handlers idempotent', async () => {
42
+ // Directly invoke the protected handlers via the instance to cover the
43
+ // re-entrancy latches — Lynx's on-device behavior re-dispatches the load
44
+ // event when the skeleton animation tears down.
45
+ const { componentRef } = await render(UiAvatar);
46
+ const inst = componentRef.instance as unknown as {
47
+ onImageLoad: () => void;
48
+ onImageError: () => void;
49
+ loaded: () => boolean;
50
+ errored: () => boolean;
51
+ };
52
+ inst.onImageLoad();
53
+ inst.onImageLoad(); // second call is a no-op (loaded latch)
54
+ expect(inst.loaded()).toBe(true);
55
+
56
+ inst.onImageError();
57
+ inst.onImageError(); // second call is a no-op (errored latch)
58
+ expect(inst.errored()).toBe(true);
59
+ });
60
+
61
+ it('applies size-specific classes for each supported size', async () => {
62
+ const { componentRef, container } = await render(UiAvatar);
63
+ for (const size of ['sm', 'default', 'lg', 'xl', '2xl'] as const) {
64
+ setInputSignal((componentRef.instance as UiAvatar).size, size);
65
+ await waitForUpdate();
66
+ expect(container).toBeTruthy();
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,131 @@
1
+ import {
2
+ Component,
3
+ ViewEncapsulation,
4
+ computed,
5
+ input,
6
+ signal,
7
+ } from '@angular/core';
8
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
9
+
10
+ import { UiSkeleton } from '../skeleton/skeleton';
11
+ import { cn } from '../../utils/cn';
12
+
13
+ @Component({
14
+ selector: 'ui-avatar',
15
+ standalone: true,
16
+ imports: [LYNX_ELEMENTS, UiSkeleton],
17
+ encapsulation: ViewEncapsulation.None,
18
+ template: `
19
+ <view [class]="containerClass()">
20
+ @if (src() && !errored()) {
21
+ <!-- The <image> stays mounted whenever src is set so its bindload /
22
+ binderror events can fire — an @if-swapped-out image would never
23
+ start loading, so the load event that reveals it would never come.
24
+ Native Lynx <image> renders nothing until loaded (there is no
25
+ broken-image glyph like the web), so while loading the box is
26
+ simply empty; the skeleton below sits on top of it as a placeholder.
27
+ onImageLoad() flips the loaded state, removing the skeleton to
28
+ reveal the image underneath. -->
29
+ <image
30
+ [attr.src]="src()"
31
+ [class]="imageClass()"
32
+ (bindload)="onImageLoad()"
33
+ (binderror)="onImageError()"
34
+ />
35
+ <!-- Overlay placeholder shown only while the image is loading. It paints
36
+ above the <image> because it comes later in source order (Lynx paint
37
+ order follows the DOM, so no z-index is needed). -->
38
+ @if (!loaded()) {
39
+ <ui-skeleton [class]="skeletonClass()" />
40
+ }
41
+ } @else {
42
+ <!-- Shared by the no-src case and the load-error case: initials if a
43
+ fallback was provided, otherwise a plain muted circle. -->
44
+ <view [class]="fallbackClass()">
45
+ <text [class]="initialsClass()">{{ fallback() }}</text>
46
+ </view>
47
+ }
48
+ </view>
49
+ `,
50
+ })
51
+ export class UiAvatar {
52
+ readonly src = input<string>('');
53
+ readonly fallback = input<string>('');
54
+ readonly size = input<'default' | 'sm' | 'lg' | 'xl' | '2xl'>('default');
55
+ readonly userClass = input<string>('', { alias: 'class' });
56
+
57
+ /** True once the remote image has successfully loaded. */
58
+ protected readonly loaded = signal(false);
59
+ /** True if the remote image failed to load (offline / firewall / 404). */
60
+ protected readonly errored = signal(false);
61
+
62
+ /**
63
+ * Reveal the image by removing the skeleton overlay once it loads.
64
+ *
65
+ * Guarded against re-entrancy: on Lynx, starting or canceling an element
66
+ * animation can synchronously re-dispatch the image's `load` event — and
67
+ * removing the skeleton here tears down its pulse animation, which does
68
+ * exactly that. Without the one-way `loaded` latch short-circuiting the
69
+ * re-entry, this handler recurses and overflows the main-thread stack.
70
+ */
71
+ protected onImageLoad(): void {
72
+ if (this.loaded()) return;
73
+ this.loaded.set(true);
74
+ }
75
+
76
+ /**
77
+ * On load failure, drop the skeleton and fall through to the fallback so a
78
+ * broken image does not leave the placeholder pulsing forever. Guarded for
79
+ * the same re-entrancy reason as onImageLoad().
80
+ */
81
+ protected onImageError(): void {
82
+ if (this.errored()) return;
83
+ this.errored.set(true);
84
+ }
85
+
86
+ protected readonly containerClass = computed(() =>
87
+ cn(
88
+ // `relative` makes the container the positioning context for the absolutely
89
+ // positioned skeleton overlay below.
90
+ 'items-center rounded-full flex relative justify-center overflow-hidden',
91
+ {
92
+ 'h-10 w-10': this.size() === 'default',
93
+ 'h-8 w-8': this.size() === 'sm',
94
+ 'h-14 w-14': this.size() === 'lg',
95
+ 'h-20 w-20': this.size() === 'xl',
96
+ 'h-28 w-28': this.size() === '2xl',
97
+ },
98
+ this.userClass(),
99
+ ),
100
+ );
101
+
102
+ protected readonly imageClass = computed(() =>
103
+ cn('h-full w-full', {
104
+ 'h-10 w-10': this.size() === 'default',
105
+ 'h-8 w-8': this.size() === 'sm',
106
+ 'h-14 w-14': this.size() === 'lg',
107
+ 'h-20 w-20': this.size() === 'xl',
108
+ 'h-28 w-28': this.size() === '2xl',
109
+ }),
110
+ );
111
+
112
+ // Skeleton overlay filling the circular container. `inset-0` and `h-full
113
+ // w-full` together guard against flex-collapse; `rounded-full` matches the
114
+ // container (which also `overflow-hidden`-clips it to a circle).
115
+ protected readonly skeletonClass = computed(() =>
116
+ cn('h-full w-full rounded-full absolute inset-0'),
117
+ );
118
+
119
+ protected readonly fallbackClass = computed(() =>
120
+ cn('h-full w-full items-center rounded-full bg-muted flex justify-center'),
121
+ );
122
+
123
+ protected readonly initialsClass = computed(() =>
124
+ cn('font-medium text-muted-foreground', {
125
+ 'text-sm': this.size() === 'default' || this.size() === 'sm',
126
+ 'text-lg': this.size() === 'lg',
127
+ 'text-2xl': this.size() === 'xl',
128
+ 'text-3xl': this.size() === '2xl',
129
+ }),
130
+ );
131
+ }
@@ -0,0 +1 @@
1
+ export { UiAvatar } from './avatar';
@@ -0,0 +1,49 @@
1
+ import { render } from '@blotch/angular-lynx-testing-library';
2
+ import { Component } from '@angular/core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
5
+ import { UiBadge, badgeVariants } from './badge';
6
+
7
+ /**
8
+ * Host wrappers project content and bind inputs the way real app templates do.
9
+ * Required-input components would need this; UiBadge has none but the pattern
10
+ * keeps content projection (<ng-content>) exercised.
11
+ */
12
+ @Component({
13
+ standalone: true,
14
+ imports: [UiBadge, LYNX_ELEMENTS],
15
+ template: `<ui-badge [variant]="variant" [animated]="false"
16
+ >Status</ui-badge
17
+ >`,
18
+ })
19
+ class BadgeHost {
20
+ variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
21
+ }
22
+
23
+ describe('badgeVariants', () => {
24
+ it('returns default variant classes', () => {
25
+ expect(badgeVariants()).toContain('bg-primary');
26
+ });
27
+
28
+ it('returns each explicit variant', () => {
29
+ expect(badgeVariants({ variant: 'secondary' })).toContain('bg-secondary');
30
+ expect(badgeVariants({ variant: 'destructive' })).toContain(
31
+ 'bg-destructive',
32
+ );
33
+ expect(badgeVariants({ variant: 'outline' })).toContain('border');
34
+ });
35
+ });
36
+
37
+ describe('UiBadge', () => {
38
+ it('renders projected content', async () => {
39
+ const { getByText } = await render(BadgeHost);
40
+ expect(getByText('Status')).toBeTruthy();
41
+ });
42
+
43
+ it('runs the pop-in animation when animated is true', async () => {
44
+ const { getByText } = await render(UiBadge);
45
+ // With no host binding, animated defaults to true → effect runs popIn on the
46
+ // container. Just assert it renders without throwing.
47
+ expect(getByText).toBeTruthy();
48
+ });
49
+ });