@arclux/arc-ui 1.0.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/package.json +186 -0
  4. package/src/content/accordion-item.js +27 -0
  5. package/src/content/accordion.js +151 -0
  6. package/src/content/animated-number.js +160 -0
  7. package/src/content/aspect-ratio.js +78 -0
  8. package/src/content/avatar-group.js +101 -0
  9. package/src/content/avatar.js +92 -0
  10. package/src/content/badge.js +98 -0
  11. package/src/content/callout.js +141 -0
  12. package/src/content/card.js +75 -0
  13. package/src/content/carousel.js +300 -0
  14. package/src/content/code-block.js +152 -0
  15. package/src/content/collapsible.js +142 -0
  16. package/src/content/color-swatch.js +86 -0
  17. package/src/content/column.js +28 -0
  18. package/src/content/data-table.js +332 -0
  19. package/src/content/divider.js +153 -0
  20. package/src/content/empty-state.js +78 -0
  21. package/src/content/feature-card.js +142 -0
  22. package/src/content/highlight.js +63 -0
  23. package/src/content/icon-library.js +30 -0
  24. package/src/content/icon-registry.js +39 -0
  25. package/src/content/icon.js +95 -0
  26. package/src/content/index.js +44 -0
  27. package/src/content/infinite-scroll.js +144 -0
  28. package/src/content/kbd.js +40 -0
  29. package/src/content/markdown.js +294 -0
  30. package/src/content/marquee.js +166 -0
  31. package/src/content/meter.js +167 -0
  32. package/src/content/scroll-area.js +107 -0
  33. package/src/content/skeleton.js +85 -0
  34. package/src/content/spinner.js +77 -0
  35. package/src/content/stack.js +68 -0
  36. package/src/content/stat.js +72 -0
  37. package/src/content/step.js +22 -0
  38. package/src/content/stepper.js +202 -0
  39. package/src/content/table.js +134 -0
  40. package/src/content/tag.js +156 -0
  41. package/src/content/text.js +111 -0
  42. package/src/content/timeline-item.js +29 -0
  43. package/src/content/timeline.js +170 -0
  44. package/src/content/truncate.js +161 -0
  45. package/src/content/value-card.js +94 -0
  46. package/src/feedback/alert.js +187 -0
  47. package/src/feedback/command-item.js +28 -0
  48. package/src/feedback/command-palette.js +346 -0
  49. package/src/feedback/context-menu.js +299 -0
  50. package/src/feedback/dialog.js +298 -0
  51. package/src/feedback/dropdown-menu.js +259 -0
  52. package/src/feedback/hover-card.js +186 -0
  53. package/src/feedback/index.js +17 -0
  54. package/src/feedback/modal.js +226 -0
  55. package/src/feedback/notification-panel.js +196 -0
  56. package/src/feedback/popover.js +184 -0
  57. package/src/feedback/progress.js +169 -0
  58. package/src/feedback/sheet.js +249 -0
  59. package/src/feedback/toast.js +207 -0
  60. package/src/feedback/tooltip.js +189 -0
  61. package/src/icons/lucide.d.ts +1915 -0
  62. package/src/icons/lucide.js +1915 -0
  63. package/src/icons/phosphor.d.ts +1517 -0
  64. package/src/icons/phosphor.js +1517 -0
  65. package/src/icons/types.d.ts +8 -0
  66. package/src/index.js +9 -0
  67. package/src/input/button.js +127 -0
  68. package/src/input/calendar.js +340 -0
  69. package/src/input/checkbox.js +159 -0
  70. package/src/input/chip.js +120 -0
  71. package/src/input/color-picker.js +461 -0
  72. package/src/input/combobox.js +295 -0
  73. package/src/input/copy-button.js +144 -0
  74. package/src/input/date-picker.js +534 -0
  75. package/src/input/file-upload.js +333 -0
  76. package/src/input/form.js +179 -0
  77. package/src/input/icon-button.js +179 -0
  78. package/src/input/index.js +31 -0
  79. package/src/input/input.js +158 -0
  80. package/src/input/multi-select.js +392 -0
  81. package/src/input/number-input.js +239 -0
  82. package/src/input/otp-input.js +221 -0
  83. package/src/input/pin-input.js +294 -0
  84. package/src/input/radio-group.js +177 -0
  85. package/src/input/radio.js +28 -0
  86. package/src/input/rating.js +195 -0
  87. package/src/input/search.js +371 -0
  88. package/src/input/segmented-control.js +174 -0
  89. package/src/input/select.js +267 -0
  90. package/src/input/slider.js +217 -0
  91. package/src/input/sortable-list.js +345 -0
  92. package/src/input/suggestion.js +26 -0
  93. package/src/input/textarea.js +203 -0
  94. package/src/input/theme-toggle.js +196 -0
  95. package/src/input/toggle.js +166 -0
  96. package/src/layout/app-shell.js +266 -0
  97. package/src/layout/auth-shell.js +153 -0
  98. package/src/layout/container.js +37 -0
  99. package/src/layout/dashboard-grid.js +62 -0
  100. package/src/layout/index.js +15 -0
  101. package/src/layout/page-header.js +100 -0
  102. package/src/layout/page-layout.js +112 -0
  103. package/src/layout/resizable.js +221 -0
  104. package/src/layout/section.js +54 -0
  105. package/src/layout/settings-layout.js +91 -0
  106. package/src/layout/split-pane.js +172 -0
  107. package/src/layout/status-bar.js +84 -0
  108. package/src/layout/toolbar.js +92 -0
  109. package/src/navigation/breadcrumb-item.js +26 -0
  110. package/src/navigation/breadcrumb.js +129 -0
  111. package/src/navigation/drawer.js +183 -0
  112. package/src/navigation/footer.js +99 -0
  113. package/src/navigation/index.js +22 -0
  114. package/src/navigation/link.js +120 -0
  115. package/src/navigation/nav-item.js +46 -0
  116. package/src/navigation/navigation-menu.js +687 -0
  117. package/src/navigation/pagination.js +186 -0
  118. package/src/navigation/scroll-spy.js +198 -0
  119. package/src/navigation/scroll-to-top.js +163 -0
  120. package/src/navigation/sidebar-link.js +28 -0
  121. package/src/navigation/sidebar-section.js +45 -0
  122. package/src/navigation/sidebar.js +336 -0
  123. package/src/navigation/spy-link.js +26 -0
  124. package/src/navigation/tab.js +26 -0
  125. package/src/navigation/tabs.js +202 -0
  126. package/src/navigation/top-bar.js +263 -0
  127. package/src/navigation/tree-item.js +38 -0
  128. package/src/navigation/tree-view.js +255 -0
  129. package/src/shared/index.js +6 -0
  130. package/src/shared/menu-divider.js +9 -0
  131. package/src/shared/menu-item.js +30 -0
  132. package/src/shared/option.js +31 -0
  133. package/src/shared-styles.js +81 -0
  134. package/src/tokens.js +445 -0
  135. package/types/index.d.ts +973 -0
@@ -0,0 +1,294 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { tokenStyles } from '../shared-styles.js';
3
+
4
+ /**
5
+ * Sanitize an HTML string by stripping <script> tags and on* event attributes.
6
+ */
7
+ function sanitizeHtml(raw) {
8
+ const doc = new DOMParser().parseFromString(raw, 'text/html');
9
+ for (const el of doc.querySelectorAll('script')) el.remove();
10
+ for (const el of doc.querySelectorAll('*')) {
11
+ for (const attr of [...el.attributes]) {
12
+ if (attr.name.startsWith('on')) el.removeAttribute(attr.name);
13
+ }
14
+ }
15
+ return doc.body.innerHTML;
16
+ }
17
+
18
+ /**
19
+ * Lightweight regex-based Markdown-to-HTML parser.
20
+ * Handles headings, bold, italic, inline code, code blocks, lists,
21
+ * blockquotes, links, images, horizontal rules, and paragraphs.
22
+ */
23
+ function parseMarkdown(src) {
24
+ if (!src) return '';
25
+
26
+ let out = '';
27
+ // Normalize line endings
28
+ const text = src.replace(/\r\n?/g, '\n');
29
+
30
+ // Extract fenced code blocks first to protect them from further parsing
31
+ const codeBlocks = [];
32
+ const withPlaceholders = text.replace(/^```(\w*)\n([\s\S]*?)^```/gm, (_match, lang, code) => {
33
+ const idx = codeBlocks.length;
34
+ const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
35
+ const langAttr = lang ? ` class="language-${lang}"` : '';
36
+ codeBlocks.push(`<pre><code${langAttr}>${escaped}</code></pre>`);
37
+ return `\x00CODEBLOCK_${idx}\x00`;
38
+ });
39
+
40
+ // Split into blocks by double newline
41
+ const blocks = withPlaceholders.split(/\n{2,}/);
42
+
43
+ for (const block of blocks) {
44
+ const trimmed = block.trim();
45
+ if (!trimmed) continue;
46
+
47
+ // Code block placeholder
48
+ const cbMatch = trimmed.match(/^\x00CODEBLOCK_(\d+)\x00$/);
49
+ if (cbMatch) {
50
+ out += codeBlocks[parseInt(cbMatch[1])];
51
+ continue;
52
+ }
53
+
54
+ // Heading
55
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
56
+ if (headingMatch) {
57
+ const level = headingMatch[1].length;
58
+ out += `<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`;
59
+ continue;
60
+ }
61
+
62
+ // Horizontal rule
63
+ if (/^[-*_]{3,}\s*$/.test(trimmed)) {
64
+ out += '<hr>';
65
+ continue;
66
+ }
67
+
68
+ // Blockquote
69
+ if (/^>\s?/.test(trimmed)) {
70
+ const content = trimmed
71
+ .split('\n')
72
+ .map(l => l.replace(/^>\s?/, ''))
73
+ .join('\n');
74
+ out += `<blockquote>${parseMarkdown(content)}</blockquote>`;
75
+ continue;
76
+ }
77
+
78
+ // Unordered list
79
+ if (/^[\-*]\s/.test(trimmed)) {
80
+ const items = trimmed.split('\n').filter(l => /^[\-*]\s/.test(l.trim()));
81
+ out += '<ul>' + items.map(l => `<li>${inlineMarkdown(l.trim().replace(/^[\-*]\s+/, ''))}</li>`).join('') + '</ul>';
82
+ continue;
83
+ }
84
+
85
+ // Ordered list
86
+ if (/^\d+\.\s/.test(trimmed)) {
87
+ const items = trimmed.split('\n').filter(l => /^\d+\.\s/.test(l.trim()));
88
+ out += '<ol>' + items.map(l => `<li>${inlineMarkdown(l.trim().replace(/^\d+\.\s+/, ''))}</li>`).join('') + '</ol>';
89
+ continue;
90
+ }
91
+
92
+ // Paragraph (default)
93
+ out += `<p>${inlineMarkdown(trimmed.replace(/\n/g, ' '))}</p>`;
94
+ }
95
+
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Parse inline markdown: images, links, bold, italic, inline code.
101
+ */
102
+ function inlineMarkdown(text) {
103
+ let s = text;
104
+ // Escape HTML entities in source (but not our generated tags)
105
+ s = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
106
+ // Inline code (before bold/italic so backticks are handled first)
107
+ s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
108
+ // Images
109
+ s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
110
+ // Links
111
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
112
+ // Bold
113
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
114
+ // Italic
115
+ s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
116
+ return s;
117
+ }
118
+
119
+ /**
120
+ * @arc-prism static — renders Markdown content as styled HTML
121
+ */
122
+ export class ArcMarkdown extends LitElement {
123
+ static properties = {
124
+ content: { type: String },
125
+ };
126
+
127
+ static styles = [
128
+ tokenStyles,
129
+ css`
130
+ :host { display: block; }
131
+
132
+ .markdown {
133
+ font-family: var(--font-body);
134
+ font-size: var(--body-size);
135
+ font-weight: var(--body-weight);
136
+ line-height: var(--body-lh);
137
+ color: var(--text-secondary);
138
+ }
139
+
140
+ .markdown h1,
141
+ .markdown h2,
142
+ .markdown h3,
143
+ .markdown h4,
144
+ .markdown h5,
145
+ .markdown h6 {
146
+ font-family: var(--font-body);
147
+ color: var(--text-primary);
148
+ line-height: 1.3;
149
+ margin-top: var(--space-xl);
150
+ margin-bottom: var(--space-md);
151
+ }
152
+
153
+ .markdown h1 { font-size: var(--text-3xl); font-weight: 600; }
154
+ .markdown h2 { font-size: var(--heading-size); font-weight: 600; }
155
+ .markdown h3 { font-size: var(--text-lg); font-weight: 600; }
156
+ .markdown h4 { font-size: var(--body-size); font-weight: 600; }
157
+ .markdown h5 { font-size: var(--body-size); font-weight: 500; }
158
+ .markdown h6 { font-size: var(--code-size); font-weight: 500; text-transform: uppercase; letter-spacing: 1px; }
159
+
160
+ .markdown h1:first-child,
161
+ .markdown h2:first-child,
162
+ .markdown h3:first-child,
163
+ .markdown h4:first-child,
164
+ .markdown h5:first-child,
165
+ .markdown h6:first-child { margin-top: 0; }
166
+
167
+ .markdown p {
168
+ margin: 0 0 var(--space-md) 0;
169
+ }
170
+
171
+ .markdown p:last-child { margin-bottom: 0; }
172
+
173
+ .markdown strong { color: var(--text-primary); font-weight: 600; }
174
+
175
+ .markdown em { font-style: italic; }
176
+
177
+ .markdown a {
178
+ color: var(--accent-primary);
179
+ text-decoration: none;
180
+ transition: color var(--transition-fast);
181
+ }
182
+
183
+ .markdown a:hover {
184
+ text-decoration: underline;
185
+ }
186
+
187
+ .markdown code {
188
+ font-family: var(--font-mono);
189
+ font-size: var(--code-size);
190
+ color: var(--accent-secondary);
191
+ background: var(--bg-surface);
192
+ padding: 2px calc(var(--space-xs) + 2px); /* cosmetic 2px vertical for inline code */
193
+ border-radius: var(--radius-sm);
194
+ }
195
+
196
+ .markdown pre {
197
+ background: var(--bg-surface);
198
+ border: 1px solid var(--border-subtle);
199
+ border-radius: var(--radius-md);
200
+ padding: var(--space-md);
201
+ overflow-x: auto;
202
+ margin: 0 0 var(--space-md) 0;
203
+ }
204
+
205
+ .markdown pre code {
206
+ background: none;
207
+ padding: 0;
208
+ border-radius: 0;
209
+ font-size: var(--code-size);
210
+ line-height: var(--code-lh);
211
+ color: var(--text-primary);
212
+ }
213
+
214
+ .markdown blockquote {
215
+ margin: 0 0 var(--space-md) 0;
216
+ padding: var(--space-sm) var(--space-md);
217
+ background: var(--bg-card);
218
+ border: 1px solid var(--border-subtle);
219
+ border-radius: var(--radius-md);
220
+ }
221
+
222
+ .markdown blockquote p:last-child { margin-bottom: 0; }
223
+
224
+ .markdown ul,
225
+ .markdown ol {
226
+ margin: 0 0 var(--space-md) 0;
227
+ padding-left: var(--space-lg);
228
+ color: var(--text-secondary);
229
+ }
230
+
231
+ .markdown li {
232
+ margin-bottom: var(--space-xs);
233
+ }
234
+
235
+ .markdown li:last-child { margin-bottom: 0; }
236
+
237
+ .markdown hr {
238
+ border: none;
239
+ height: 1px;
240
+ background: var(--border-subtle);
241
+ margin: var(--space-xl) 0;
242
+ }
243
+
244
+ .markdown img {
245
+ max-width: 100%;
246
+ height: auto;
247
+ border-radius: var(--radius-md);
248
+ margin: var(--space-sm) 0;
249
+ }
250
+
251
+ @media (prefers-reduced-motion: reduce) {
252
+ :host *,
253
+ :host *::before,
254
+ :host *::after {
255
+ animation-duration: 0.01ms !important;
256
+ animation-iteration-count: 1 !important;
257
+ transition-duration: 0.01ms !important;
258
+ }
259
+ }
260
+ `,
261
+ ];
262
+
263
+ constructor() {
264
+ super();
265
+ this.content = '';
266
+ }
267
+
268
+ _getSource() {
269
+ if (this.content) return this.content;
270
+ // Fall back to slotted text content
271
+ const slot = this.shadowRoot?.querySelector('slot');
272
+ if (slot) {
273
+ const nodes = slot.assignedNodes({ flatten: true });
274
+ return nodes.map(n => n.textContent).join('');
275
+ }
276
+ return this.textContent || '';
277
+ }
278
+
279
+ render() {
280
+ const source = this.content || this.textContent || '';
281
+ const parsed = sanitizeHtml(parseMarkdown(source));
282
+ return html`
283
+ <div class="markdown" part="markdown" .innerHTML=${parsed}></div>
284
+ <slot style="display:none" @slotchange=${this._onSlotChange}></slot>
285
+ `;
286
+ }
287
+
288
+ _onSlotChange() {
289
+ // Re-render when slot content changes and no content prop is set
290
+ if (!this.content) this.requestUpdate();
291
+ }
292
+ }
293
+
294
+ customElements.define('arc-markdown', ArcMarkdown);
@@ -0,0 +1,166 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { tokenStyles } from '../shared-styles.js';
3
+
4
+ export class ArcMarquee extends LitElement {
5
+ static properties = {
6
+ speed: { type: Number },
7
+ direction: { type: String, reflect: true },
8
+ 'pause-on-hover': { type: Boolean, reflect: true, attribute: 'pause-on-hover' },
9
+ gap: { type: String },
10
+ _animDuration: { state: true },
11
+ };
12
+
13
+ static styles = [
14
+ tokenStyles,
15
+ css`
16
+ :host {
17
+ display: block;
18
+ overflow: hidden;
19
+ }
20
+
21
+ .marquee {
22
+ display: flex;
23
+ width: max-content;
24
+ will-change: transform;
25
+ }
26
+
27
+ .marquee--left {
28
+ animation: marquee-scroll-left var(--marquee-duration, 10s) linear infinite;
29
+ }
30
+
31
+ .marquee--right {
32
+ animation: marquee-scroll-right var(--marquee-duration, 10s) linear infinite;
33
+ }
34
+
35
+ :host([pause-on-hover]) .marquee:hover {
36
+ animation-play-state: paused;
37
+ }
38
+
39
+ @media (prefers-reduced-motion: reduce) {
40
+ .marquee {
41
+ animation-play-state: paused !important;
42
+ }
43
+ }
44
+
45
+ @keyframes marquee-scroll-left {
46
+ from { transform: translateX(0); }
47
+ to { transform: translateX(-50%); }
48
+ }
49
+
50
+ @keyframes marquee-scroll-right {
51
+ from { transform: translateX(-50%); }
52
+ to { transform: translateX(0); }
53
+ }
54
+
55
+ .marquee__group {
56
+ display: flex;
57
+ align-items: center;
58
+ flex-shrink: 0;
59
+ }
60
+
61
+ ::slotted(*) {
62
+ flex-shrink: 0;
63
+ }
64
+ `,
65
+ ];
66
+
67
+ constructor() {
68
+ super();
69
+ this.speed = 40;
70
+ this.direction = 'left';
71
+ this['pause-on-hover'] = true;
72
+ this.gap = 'var(--space-xl)';
73
+ this._animDuration = '10s';
74
+ this._resizeObserver = null;
75
+ }
76
+
77
+ firstUpdated() {
78
+ this._updateDuplicate();
79
+ this._setupResizeObserver();
80
+ }
81
+
82
+ updated(changed) {
83
+ if (changed.has('speed') || changed.has('gap')) {
84
+ this._recalcDuration();
85
+ }
86
+ }
87
+
88
+ disconnectedCallback() {
89
+ super.disconnectedCallback();
90
+ if (this._resizeObserver) {
91
+ this._resizeObserver.disconnect();
92
+ this._resizeObserver = null;
93
+ }
94
+ }
95
+
96
+ _setupResizeObserver() {
97
+ this._resizeObserver = new ResizeObserver(() => this._recalcDuration());
98
+ const group = this.shadowRoot.querySelector('.marquee__group--primary');
99
+ if (group) this._resizeObserver.observe(group);
100
+ }
101
+
102
+ _recalcDuration() {
103
+ requestAnimationFrame(() => {
104
+ const group = this.shadowRoot.querySelector('.marquee__group--primary');
105
+ if (!group) return;
106
+ const width = group.scrollWidth;
107
+ if (width > 0 && this.speed > 0) {
108
+ const seconds = width / this.speed;
109
+ this._animDuration = `${seconds.toFixed(2)}s`;
110
+ }
111
+ });
112
+ }
113
+
114
+ _onSlotChange() {
115
+ this._updateDuplicate();
116
+ this._recalcDuration();
117
+ }
118
+
119
+ /**
120
+ * Clone slotted light-DOM children into the shadow-DOM duplicate group
121
+ * for seamless looping. The duplicate is aria-hidden since it's decorative.
122
+ */
123
+ _updateDuplicate() {
124
+ const dupGroup = this.shadowRoot.querySelector('.marquee__group--duplicate');
125
+ if (!dupGroup) return;
126
+
127
+ // Clear previous clones
128
+ while (dupGroup.firstChild) dupGroup.removeChild(dupGroup.firstChild);
129
+
130
+ // Clone each assigned node from the primary slot
131
+ const slot = this.shadowRoot.querySelector('slot:not([name])');
132
+ if (!slot) return;
133
+ const nodes = slot.assignedNodes({ flatten: true });
134
+ for (const node of nodes) {
135
+ dupGroup.appendChild(node.cloneNode(true));
136
+ }
137
+ }
138
+
139
+ render() {
140
+ const dirClass = this.direction === 'right' ? 'marquee--right' : 'marquee--left';
141
+
142
+ return html`
143
+ <div
144
+ class="marquee ${dirClass}"
145
+ style="--marquee-duration: ${this._animDuration}; gap: ${this.gap}"
146
+ part="track"
147
+ >
148
+ <div
149
+ class="marquee__group marquee__group--primary"
150
+ style="gap: ${this.gap}"
151
+ part="group"
152
+ >
153
+ <slot @slotchange=${this._onSlotChange}></slot>
154
+ </div>
155
+ <div
156
+ class="marquee__group marquee__group--duplicate"
157
+ style="gap: ${this.gap}"
158
+ aria-hidden="true"
159
+ part="group-duplicate"
160
+ ></div>
161
+ </div>
162
+ `;
163
+ }
164
+ }
165
+
166
+ customElements.define('arc-marquee', ArcMarquee);
@@ -0,0 +1,167 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { tokenStyles } from '../shared-styles.js';
3
+
4
+ export class ArcMeter extends LitElement {
5
+ static properties = {
6
+ value: { type: Number, reflect: true },
7
+ min: { type: Number },
8
+ max: { type: Number },
9
+ low: { type: Number },
10
+ high: { type: Number },
11
+ optimum: { type: Number },
12
+ label: { type: String },
13
+ };
14
+
15
+ static styles = [
16
+ tokenStyles,
17
+ css`
18
+ :host { display: block; }
19
+
20
+ .meter {
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: var(--space-xs);
24
+ }
25
+
26
+ .meter__header {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ }
31
+
32
+ .meter__label {
33
+ font-family: var(--font-body);
34
+ font-size: var(--body-size);
35
+ color: var(--text-secondary);
36
+ user-select: none;
37
+ }
38
+
39
+ .meter__value {
40
+ font-family: var(--font-mono);
41
+ font-size: var(--code-size);
42
+ color: var(--text-muted);
43
+ }
44
+
45
+ .meter__track {
46
+ position: relative;
47
+ width: 100%;
48
+ height: 8px;
49
+ background: var(--bg-elevated);
50
+ border-radius: var(--radius-full);
51
+ overflow: hidden;
52
+ }
53
+
54
+ .meter__fill {
55
+ height: 100%;
56
+ border-radius: var(--radius-full);
57
+ transition: width var(--transition-base), background var(--transition-base);
58
+ min-width: 0;
59
+ }
60
+
61
+ .meter__fill--success { background: var(--color-success); }
62
+ .meter__fill--warning { background: var(--color-warning); }
63
+ .meter__fill--error { background: var(--color-error); }
64
+
65
+ @media (prefers-reduced-motion: reduce) {
66
+ :host *,
67
+ :host *::before,
68
+ :host *::after {
69
+ animation-duration: 0.01ms !important;
70
+ animation-iteration-count: 1 !important;
71
+ transition-duration: 0.01ms !important;
72
+ }
73
+ }
74
+ `,
75
+ ];
76
+
77
+ constructor() {
78
+ super();
79
+ this.value = 0;
80
+ this.min = 0;
81
+ this.max = 100;
82
+ this.low = undefined;
83
+ this.high = undefined;
84
+ this.optimum = undefined;
85
+ this.label = '';
86
+ }
87
+
88
+ /** Clamp and compute fill percentage. */
89
+ get _percent() {
90
+ const range = this.max - this.min;
91
+ if (range <= 0) return 0;
92
+ const clamped = Math.max(this.min, Math.min(this.max, this.value));
93
+ return ((clamped - this.min) / range) * 100;
94
+ }
95
+
96
+ /**
97
+ * Determine color zone based on low / high / optimum thresholds.
98
+ *
99
+ * Logic mirrors the HTML <meter> algorithm:
100
+ * - If optimum is in the "good" segment and value is there too -> success
101
+ * - If value is in the middle segment (between low and high) -> warning
102
+ * - If value is in the far-from-optimum segment -> error
103
+ * - Fallback when thresholds are not set: use simple thirds.
104
+ */
105
+ get _zone() {
106
+ const { value, min, max } = this;
107
+ const low = this.low ?? min + (max - min) * 0.33;
108
+ const high = this.high ?? min + (max - min) * 0.67;
109
+ const optimum = this.optimum ?? (low + high) / 2;
110
+
111
+ // Determine which segment the optimum lives in
112
+ const optimumInLow = optimum <= low;
113
+ const optimumInHigh = optimum >= high;
114
+
115
+ if (optimumInLow) {
116
+ // Lower is better (e.g. error count)
117
+ if (value <= low) return 'success';
118
+ if (value <= high) return 'warning';
119
+ return 'error';
120
+ }
121
+
122
+ if (optimumInHigh) {
123
+ // Higher is better (e.g. battery level)
124
+ if (value >= high) return 'success';
125
+ if (value >= low) return 'warning';
126
+ return 'error';
127
+ }
128
+
129
+ // Optimum is in the middle segment
130
+ if (value >= low && value <= high) return 'success';
131
+ if (value < low) return 'warning';
132
+ return 'warning';
133
+ }
134
+
135
+ render() {
136
+ const percent = this._percent;
137
+ const zone = this._zone;
138
+
139
+ return html`
140
+ <div
141
+ class="meter"
142
+ part="meter"
143
+ role="meter"
144
+ aria-valuemin=${this.min}
145
+ aria-valuemax=${this.max}
146
+ aria-valuenow=${this.value}
147
+ aria-label=${this.label || 'Meter'}
148
+ >
149
+ ${this.label ? html`
150
+ <div class="meter__header" part="header">
151
+ <span class="meter__label" part="label">${this.label}</span>
152
+ <span class="meter__value" part="value">${Math.round(percent)}%</span>
153
+ </div>
154
+ ` : ''}
155
+ <div class="meter__track" part="track">
156
+ <div
157
+ class="meter__fill meter__fill--${zone}"
158
+ style="width: ${percent}%"
159
+ part="fill"
160
+ ></div>
161
+ </div>
162
+ </div>
163
+ `;
164
+ }
165
+ }
166
+
167
+ customElements.define('arc-meter', ArcMeter);