signalk-maintenance-tracker 1.2.0 → 1.4.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Bare-URL autolinking for markdown output (§7.8). snarkdown only links
3
+ * explicit [text](url), so a note that just pastes a URL renders as dead text.
4
+ * This turns http(s):// and www. runs in the text into anchors.
5
+ *
6
+ * Runs *after* sanitizeHtml() on already-clean HTML: every href here is built
7
+ * from a match that must start with http://, https:// or www., so this pass
8
+ * cannot reintroduce a javascript: URL. Text inside existing links and inside
9
+ * code is left alone. Only <MarkdownView/> consumes this.
10
+ */
11
+
12
+ /** Bare URL run: everything up to whitespace or a tag; trimmed below. */
13
+ const URL_RE = /(?:https?:\/\/|www\.)[^\s<]+/gi;
14
+
15
+ /**
16
+ * Elements whose text must not be linkified.
17
+ * @type {Record<string, boolean>}
18
+ */
19
+ const SKIP_TAGS = {
20
+ A: true,
21
+ CODE: true,
22
+ PRE: true,
23
+ };
24
+
25
+ /** Sentence punctuation that follows a URL far more often than it ends one. */
26
+ const TRAILING = '.,;:!?\'"';
27
+
28
+ /**
29
+ * @param {string} clean sanitized HTML from sanitizeHtml()
30
+ * @returns {string} the same HTML with bare URLs wrapped in anchors
31
+ */
32
+ export function autolinkHtml(clean) {
33
+ const doc = new DOMParser().parseFromString(clean, 'text/html');
34
+ autolinkNode(doc.body);
35
+ return doc.body.innerHTML;
36
+ }
37
+
38
+ /** @param {Node} parent */
39
+ function autolinkNode(parent) {
40
+ // Snapshot: linkifying replaces text nodes with fragments as we walk.
41
+ const children = Array.prototype.slice.call(parent.childNodes);
42
+ for (let i = 0; i < children.length; i++) {
43
+ const node = children[i];
44
+ if (node.nodeType === 3) {
45
+ linkifyText(/** @type {Text} */ (node));
46
+ } else if (
47
+ node.nodeType === 1 &&
48
+ !SKIP_TAGS[/** @type {Element} */ (node).tagName]
49
+ ) {
50
+ autolinkNode(node);
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Replace one text node with its text plus <a> elements for any URLs in it.
57
+ * @param {Text} node
58
+ */
59
+ function linkifyText(node) {
60
+ const text = node.nodeValue || '';
61
+ const doc = node.ownerDocument;
62
+ if (!doc) return;
63
+ let frag = null;
64
+ let last = 0;
65
+ let match;
66
+ URL_RE.lastIndex = 0;
67
+ while ((match = URL_RE.exec(text)) !== null) {
68
+ const url = trimTrailing(match[0]);
69
+ // Resume after what we kept, so trimmed punctuation stays plain text.
70
+ URL_RE.lastIndex = match.index + url.length;
71
+ if (!url) continue;
72
+ if (!frag) frag = doc.createDocumentFragment();
73
+ if (match.index > last) {
74
+ frag.appendChild(doc.createTextNode(text.slice(last, match.index)));
75
+ }
76
+ const href = url.indexOf('www.') === 0 ? 'https://' + url : url;
77
+ const a = doc.createElement('a');
78
+ a.setAttribute('href', href);
79
+ // The host is the readable part; the full URL stays on hover.
80
+ a.setAttribute('title', href);
81
+ a.textContent = hostnameOf(href) || url;
82
+ frag.appendChild(a);
83
+ last = match.index + url.length;
84
+ }
85
+ if (!frag) return;
86
+ if (last < text.length) {
87
+ frag.appendChild(doc.createTextNode(text.slice(last)));
88
+ }
89
+ node.parentNode && node.parentNode.replaceChild(frag, node);
90
+ }
91
+
92
+ /**
93
+ * The host is what identifies a link to a reader; a pasted path is usually
94
+ * noise ("example.com" beats a 90-character tracking URL). Returns '' if the
95
+ * URL will not parse, and the caller falls back to showing it whole.
96
+ *
97
+ * @param {string} href
98
+ * @returns {string}
99
+ */
100
+ function hostnameOf(href) {
101
+ try {
102
+ return new URL(href).hostname;
103
+ } catch (_e) {
104
+ return '';
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Drop punctuation a sentence left on the end of a URL: "see https://x.com."
110
+ * A closing paren only counts as the URL's when the URL opened one, so both
111
+ * "(see https://x.com)" and "https://x.com/a_(b)" come out right.
112
+ *
113
+ * @param {string} url
114
+ * @returns {string}
115
+ */
116
+ function trimTrailing(url) {
117
+ let end = url.length;
118
+ while (end > 0) {
119
+ const ch = url.charAt(end - 1);
120
+ if (TRAILING.indexOf(ch) !== -1) {
121
+ end--;
122
+ } else if (ch === ')' && closesMoreThanOpens(url.slice(0, end))) {
123
+ end--;
124
+ } else {
125
+ break;
126
+ }
127
+ }
128
+ return url.slice(0, end);
129
+ }
130
+
131
+ /** @param {string} s */
132
+ function closesMoreThanOpens(s) {
133
+ const opens = s.match(/\(/g);
134
+ const closes = s.match(/\)/g);
135
+ return (closes ? closes.length : 0) > (opens ? opens.length : 0);
136
+ }
@@ -121,8 +121,25 @@ export function formatUser(name) {
121
121
  return isTokenUser(trimmed) ? trimmed.slice(0, 8) : trimmed;
122
122
  }
123
123
 
124
- /** Status → human label. @param {string} status */
124
+ /**
125
+ * Every task status, most urgent first (mirrors STATUS_RANK in src/types.ts).
126
+ * @type {import('../types.js').Status[]}
127
+ */
128
+ export const STATUSES = ['overdue', 'due_soon', 'ok', 'info', 'archived'];
129
+
130
+ /**
131
+ * Status → human label, cased like the stock badges ("Low stock").
132
+ * @type {Record<string, string>}
133
+ */
134
+ const STATUS_LABELS = {
135
+ overdue: 'Overdue',
136
+ due_soon: 'Due Soon',
137
+ ok: 'OK',
138
+ info: 'Info',
139
+ archived: 'Archived',
140
+ };
141
+
142
+ /** @param {string} status */
125
143
  export function statusLabel(status) {
126
- if (status === 'due_soon') return 'due soon';
127
- return status;
144
+ return STATUS_LABELS[status] || status;
128
145
  }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Authored line breaks for markdown output (§7.8). People type notes into a
3
+ * textarea and mean every newline they press, but markdown does not: snarkdown
4
+ * drops a lone newline to whitespace, and collapses any run of blank lines into
5
+ * a single <br>. So "Maxwell HRC10\n\n\n<url>" came out as one run-on line, and
6
+ * spacing the author added could not be got back.
7
+ *
8
+ * Two steps, on either side of snarkdown:
9
+ *
10
+ * - keepBlankLines() runs first, splitting each run of newlines with a marker
11
+ * character so snarkdown sees single newlines it leaves alone instead of the
12
+ * blank-line run it would collapse.
13
+ * - softBreakHtml() runs last, turning the newlines left in the text into <br>
14
+ * and dropping the markers.
15
+ *
16
+ * The usual one-step trick — rewriting "\n" to " \n" before snarkdown — does
17
+ * not work here: it merges list items and leaves trailing spaces inside code
18
+ * fences. Only <MarkdownView/> consumes this.
19
+ */
20
+
21
+ /**
22
+ * Stands in for a blank line between two newlines. A control character, so it
23
+ * is inert to snarkdown's grammar and cannot occur in a note by accident.
24
+ */
25
+ const MARKER = '\u0001';
26
+
27
+ /**
28
+ * Unmarked newlines at either edge of a text node. Marked ones are blank lines
29
+ * the author typed and survive; these are snarkdown's own output formatting.
30
+ */
31
+ const LEAD_RE = /^\n+/;
32
+ const TAIL_RE = /\n+$/;
33
+
34
+ /** Fenced code, kept whole: blank lines in there are code, not layout. */
35
+ const FENCE_RE = /(```[\s\S]*?```)/g;
36
+
37
+ /**
38
+ * Split runs of blank lines so snarkdown cannot collapse them. Runs before
39
+ * snarkdown; softBreakHtml() clears up after it.
40
+ *
41
+ * @param {string} markdown as the author typed it
42
+ * @returns {string} markdown with blank-line runs marked
43
+ */
44
+ export function keepBlankLines(markdown) {
45
+ // Odd indices are the fences themselves — left exactly as written.
46
+ const chunks = String(markdown).replace(/\r\n?/g, '\n').split(FENCE_RE);
47
+ for (let i = 0; i < chunks.length; i += 2) {
48
+ chunks[i] = chunks[i]
49
+ .split(MARKER)
50
+ .join('')
51
+ .replace(/\n{2,}/g, markRun);
52
+ }
53
+ return chunks.join('');
54
+ }
55
+
56
+ /** @param {string} run two or more newlines */
57
+ function markRun(run) {
58
+ let out = '\n';
59
+ for (let i = 1; i < run.length; i++) out += MARKER + '\n';
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Newlines are content here, not layout.
65
+ * @type {Record<string, boolean>}
66
+ */
67
+ const SKIP_TAGS = {
68
+ CODE: true,
69
+ PRE: true,
70
+ };
71
+
72
+ /**
73
+ * Elements that already break the line. A newline next to one of these came
74
+ * from snarkdown's own output formatting, not from the author.
75
+ * @type {Record<string, boolean>}
76
+ */
77
+ const BLOCK_TAGS = {
78
+ BLOCKQUOTE: true,
79
+ DIV: true,
80
+ H1: true,
81
+ H2: true,
82
+ H3: true,
83
+ H4: true,
84
+ H5: true,
85
+ H6: true,
86
+ HR: true,
87
+ LI: true,
88
+ OL: true,
89
+ P: true,
90
+ PRE: true,
91
+ TABLE: true,
92
+ UL: true,
93
+ };
94
+
95
+ /**
96
+ * @param {string} clean sanitized HTML from sanitizeHtml()
97
+ * @returns {string} the same HTML with authored newlines as <br>
98
+ */
99
+ export function softBreakHtml(clean) {
100
+ const doc = new DOMParser().parseFromString(clean, 'text/html');
101
+ breakNode(doc.body);
102
+ return doc.body.innerHTML;
103
+ }
104
+
105
+ /** @param {Node} parent */
106
+ function breakNode(parent) {
107
+ // Snapshot: breaking replaces text nodes with fragments as we walk.
108
+ const children = Array.prototype.slice.call(parent.childNodes);
109
+ for (let i = 0; i < children.length; i++) {
110
+ const node = children[i];
111
+ if (node.nodeType === 3) {
112
+ breakText(/** @type {Text} */ (node));
113
+ } else if (
114
+ node.nodeType === 1 &&
115
+ !SKIP_TAGS[/** @type {Element} */ (node).tagName]
116
+ ) {
117
+ breakNode(node);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Replace one text node with its lines separated by <br>.
124
+ * @param {Text} node
125
+ */
126
+ function breakText(node) {
127
+ const doc = node.ownerDocument;
128
+ let text = node.nodeValue || '';
129
+ if (!doc || (text.indexOf('\n') === -1 && text.indexOf(MARKER) === -1)) {
130
+ return;
131
+ }
132
+ // A newline against a block is snarkdown's formatting, not a typed line.
133
+ if (isBlockEdge(node.previousSibling)) text = text.replace(LEAD_RE, '');
134
+ if (isBlockEdge(node.nextSibling)) text = text.replace(TAIL_RE, '');
135
+ if (text.indexOf('\n') === -1) {
136
+ node.nodeValue = text.split(MARKER).join('');
137
+ return;
138
+ }
139
+ const lines = text.split('\n');
140
+ const frag = doc.createDocumentFragment();
141
+ for (let i = 0; i < lines.length; i++) {
142
+ if (i > 0) frag.appendChild(doc.createElement('br'));
143
+ // A marked line is a blank one: the <br> pair around it is the whole line.
144
+ const line = lines[i].split(MARKER).join('');
145
+ if (line) frag.appendChild(doc.createTextNode(line));
146
+ }
147
+ node.parentNode && node.parentNode.replaceChild(frag, node);
148
+ }
149
+
150
+ /**
151
+ * True at the start/end of the enclosing block, or against a sibling that
152
+ * breaks the line on its own — a <br> there would be a stray blank line.
153
+ *
154
+ * @param {Node|null} sibling
155
+ * @returns {boolean}
156
+ */
157
+ function isBlockEdge(sibling) {
158
+ if (!sibling) return true;
159
+ return (
160
+ sibling.nodeType === 1 &&
161
+ BLOCK_TAGS[/** @type {Element} */ (sibling).tagName] === true
162
+ );
163
+ }
@@ -4,7 +4,13 @@
4
4
  */
5
5
  import { html } from '../lib/html.js';
6
6
  import { useState } from '../../vendor/preact-hooks.js';
7
- import { useTask, useTaskLogs, deleteTask, deleteLog } from '../api/hooks.js';
7
+ import {
8
+ useTask,
9
+ useTaskLogs,
10
+ updateTask,
11
+ deleteTask,
12
+ deleteLog,
13
+ } from '../api/hooks.js';
8
14
  import { useAuth } from '../auth/auth.js';
9
15
  import {
10
16
  formatDate,
@@ -28,6 +34,7 @@ import { LogEntryModal } from '../components/LogEntryModal.js';
28
34
  import { ConfirmModal } from '../components/ConfirmModal.js';
29
35
  import { DownloadLogModal } from '../components/DownloadLogModal.js';
30
36
  import { apiFetch } from '../api/client.js';
37
+ import { stowageItemUrl } from '../api/stowage.js';
31
38
 
32
39
  /** @typedef {import('../types.js').TaskDTO} TaskDTO */
33
40
  /** @typedef {import('../types.js').LogDTO} LogDTO */
@@ -196,6 +203,23 @@ export function TaskDetailPage(props) {
196
203
  >
197
204
  <i class="bi bi-pencil" />Edit
198
205
  </button>
206
+ <button
207
+ type="button"
208
+ class="btn btn-warning"
209
+ onClick=${async () => {
210
+ await updateTask(task.slug, {
211
+ is_archived: !task.is_archived,
212
+ });
213
+ toast(
214
+ task.is_archived
215
+ ? 'Task unarchived.'
216
+ : 'Task archived.',
217
+ 'success',
218
+ );
219
+ }}
220
+ >
221
+ <i class="bi bi-archive" />${task.is_archived ? 'Unarchive' : 'Archive'}
222
+ </button>
199
223
  <button
200
224
  type="button"
201
225
  class="btn btn-danger"
@@ -214,15 +238,17 @@ export function TaskDetailPage(props) {
214
238
  // Nothing to say without a description or parts: drop the card.
215
239
  task.description || consumables.length
216
240
  ? html`<div class="card">
217
- <h3>Description</h3>
218
241
  ${
219
242
  task.description
220
- ? html`<${MarkdownView} markdown=${task.description} />`
221
- : html`<p class="muted" style="margin:0">No description.</p>`
243
+ ? html`<h3>Description</h3>
244
+ <${MarkdownView} markdown=${task.description} />`
245
+ : null
222
246
  }
223
247
  ${
224
248
  consumables.length
225
- ? html`<div style="margin-top:16px">
249
+ ? html`<div
250
+ style=${task.description ? 'margin-top:16px' : ''}
251
+ >
226
252
  <h3>Consumables</h3>
227
253
  <ul class="consumables-list">
228
254
  ${consumables.map(
@@ -231,7 +257,9 @@ export function TaskDetailPage(props) {
231
257
  class="consumables-row"
232
258
  >
233
259
  <span class="consumables-name"
234
- >${c.item_name}${' '}
260
+ ><a href=${stowageItemUrl(c.item_id)}
261
+ >${c.item_name}</a
262
+ >${' '}
235
263
  <span class="muted"
236
264
  >× ${c.qty_per_service}</span
237
265
  ></span
@@ -7,7 +7,12 @@ import { useState, useEffect } from '../../vendor/preact-hooks.js';
7
7
  import { useTasks, useTags, deleteTask } from '../api/hooks.js';
8
8
  import { useAuth } from '../auth/auth.js';
9
9
  import { useListParams } from '../lib/useListParams.js';
10
- import { formatRemainingHours, formatRemainingTime } from '../lib/format.js';
10
+ import {
11
+ STATUSES,
12
+ formatRemainingHours,
13
+ formatRemainingTime,
14
+ statusLabel,
15
+ } from '../lib/format.js';
11
16
  import { toast } from '../lib/toasts.js';
12
17
  import { Table } from '../components/Table.js';
13
18
  import { Pagination } from '../components/Pagination.js';
@@ -28,6 +33,7 @@ export function TaskListPage() {
28
33
  const page = parseInt(params.page || '1', 10) || 1;
29
34
  const search = params.search || '';
30
35
  const tagsCsv = params.tags || '';
36
+ const statusCsv = params.status || '';
31
37
  const sort = params.sort || '';
32
38
  const order = params.order || '';
33
39
 
@@ -48,6 +54,7 @@ export function TaskListPage() {
48
54
  const tasksRes = useTasks({
49
55
  search: search || undefined,
50
56
  tags: tagsCsv || undefined,
57
+ status: statusCsv || undefined,
51
58
  sort: sort || undefined,
52
59
  order: order || undefined,
53
60
  page: page,
@@ -64,15 +71,29 @@ export function TaskListPage() {
64
71
  );
65
72
  const [deleting, setDeleting] = useState(/** @type {TaskDTO|null} */ (null));
66
73
 
74
+ // Tags and status are single-select, so search, tag and status simply AND
75
+ // together. A task has exactly one status, and tags are used as categories,
76
+ // so combining two of either narrows to nothing more often than not. Clicking
77
+ // a chip replaces the current selection; clicking the selected one clears it.
78
+ // The API still accepts CSV, so a hand-written multi-value URL keeps
79
+ // filtering (and highlighting) — it just collapses on the next click.
67
80
  const selectedTags = tagsCsv ? tagsCsv.split(',').filter(Boolean) : [];
68
81
  /** @param {string} tag */
69
- const toggleTag = (tag) => {
70
- const next =
71
- selectedTags.indexOf(tag) === -1
72
- ? selectedTags.concat([tag])
73
- : selectedTags.filter((t) => t !== tag);
74
- update({ tags: next.join(',') || undefined, page: undefined });
75
- };
82
+ const selectTag = (tag) =>
83
+ update({
84
+ tags: selectedTags.indexOf(tag) === -1 ? tag : undefined,
85
+ page: undefined,
86
+ });
87
+
88
+ const selectedStatuses = statusCsv
89
+ ? statusCsv.split(',').filter(Boolean)
90
+ : [];
91
+ /** @param {string} status */
92
+ const selectStatus = (status) =>
93
+ update({
94
+ status: selectedStatuses.indexOf(status) === -1 ? status : undefined,
95
+ page: undefined,
96
+ });
76
97
 
77
98
  /** @param {string} key */
78
99
  const onSort = (key) => {
@@ -161,6 +182,7 @@ export function TaskListPage() {
161
182
  },
162
183
  ];
163
184
 
185
+ const tagList = tagsRes.data ? tagsRes.data.data : [];
164
186
  const pageData = tasksRes.data;
165
187
 
166
188
  return html`
@@ -191,19 +213,45 @@ export function TaskListPage() {
191
213
  }
192
214
  </div>
193
215
 
194
- <div class="chips">
195
- ${(tagsRes.data ? tagsRes.data.data : []).map(
196
- (tag) => html`
197
- <button
198
- type="button"
199
- key=${tag.name}
200
- class=${'chip' + (selectedTags.indexOf(tag.name) !== -1 ? ' selected' : '')}
201
- onClick=${() => toggleTag(tag.name)}
202
- >
203
- ${tag.name}<span class="chip-count">${tag.count}</span>
204
- </button>
205
- `,
206
- )}
216
+ <div class="chip-filters">
217
+ ${
218
+ tagList.length
219
+ ? html`
220
+ <div class="chips" role="group" aria-labelledby="tag-filter-label">
221
+ <span class="chips-label" id="tag-filter-label">Tags:</span>
222
+ ${tagList.map(
223
+ (tag) => html`
224
+ <button
225
+ type="button"
226
+ key=${tag.name}
227
+ class=${'chip' + (selectedTags.indexOf(tag.name) !== -1 ? ' selected' : '')}
228
+ aria-pressed=${selectedTags.indexOf(tag.name) !== -1}
229
+ onClick=${() => selectTag(tag.name)}
230
+ >
231
+ ${tag.name}
232
+ </button>
233
+ `,
234
+ )}
235
+ </div>
236
+ `
237
+ : null
238
+ }
239
+ <div class="chips" role="group" aria-labelledby="status-filter-label">
240
+ <span class="chips-label" id="status-filter-label">Status:</span>
241
+ ${STATUSES.map(
242
+ (status) => html`
243
+ <button
244
+ type="button"
245
+ key=${status}
246
+ class=${'chip' + (selectedStatuses.indexOf(status) !== -1 ? ' selected' : '')}
247
+ aria-pressed=${selectedStatuses.indexOf(status) !== -1}
248
+ onClick=${() => selectStatus(status)}
249
+ >
250
+ ${statusLabel(status)}
251
+ </button>
252
+ `,
253
+ )}
254
+ </div>
207
255
  </div>
208
256
 
209
257
  ${
@@ -219,7 +267,7 @@ export function TaskListPage() {
219
267
  order=${order}
220
268
  onSort=${onSort}
221
269
  loading=${tasksRes.loading}
222
- emptyMessage=${search || tagsCsv ? 'No tasks match your filters.' : 'No maintenance tasks yet.'}
270
+ emptyMessage=${search || tagsCsv || statusCsv ? 'No tasks match your filters.' : 'No maintenance tasks yet.'}
223
271
  />
224
272
  ${
225
273
  pageData
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** @typedef {'days'|'weeks'|'months'|'years'} TimeUnit */
8
- /** @typedef {'overdue'|'due_soon'|'ok'|'unknown'} Status */
8
+ /** @typedef {'overdue'|'due_soon'|'ok'|'info'|'archived'} Status */
9
9
 
10
10
  /**
11
11
  * @typedef {Object} TaskConsumableDTO
@@ -47,6 +47,7 @@
47
47
  * @property {number|null} remaining_time_ms
48
48
  * @property {number|null} time_fraction
49
49
  * @property {Status|null} time_status
50
+ * @property {boolean} is_archived
50
51
  * @property {Status} status
51
52
  * @property {number} status_rank
52
53
  * @property {number} urgency
@@ -100,6 +101,7 @@
100
101
  * @property {string[]} [tags]
101
102
  * @property {string|null} [last_maintenance]
102
103
  * @property {number|null} [last_runtime]
104
+ * @property {boolean} [is_archived]
103
105
  * @property {TaskConsumableDTO[]} [consumables]
104
106
  */
105
107
 
@@ -246,6 +246,16 @@ pre {
246
246
  border-color: var(--ok-hover);
247
247
  }
248
248
 
249
+ .btn-warning {
250
+ background: var(--warn);
251
+ border-color: var(--warn);
252
+ color: var(--accent-text);
253
+ }
254
+ .btn-warning:hover {
255
+ background: var(--warn-hover);
256
+ border-color: var(--warn-hover);
257
+ }
258
+
249
259
  .btn-icon {
250
260
  display: inline-flex;
251
261
  align-items: center;
@@ -430,6 +440,21 @@ pre {
430
440
  margin-bottom: 4px;
431
441
  }
432
442
 
443
+ /* The task list's two filter rows (tags, status), each behind its own label.
444
+ The labels share a min-width so both rows' chips start on the same column. */
445
+ .chip-filters {
446
+ margin-bottom: 8px;
447
+ }
448
+ .chip-filters .chips {
449
+ margin-bottom: 0;
450
+ }
451
+ .chips-label {
452
+ flex: 0 0 auto;
453
+ min-width: 3.6em;
454
+ color: var(--text-muted);
455
+ font-size: 13.5px;
456
+ }
457
+
433
458
  .chip {
434
459
  display: inline-flex;
435
460
  align-items: center;
@@ -452,10 +477,6 @@ pre {
452
477
  border-color: var(--accent);
453
478
  color: var(--accent-text);
454
479
  }
455
- .chip .chip-count {
456
- opacity: 0.7;
457
- margin-left: 5px;
458
- }
459
480
  .chip .bi {
460
481
  margin-left: 6px;
461
482
  }
@@ -624,9 +645,13 @@ pre {
624
645
  background: var(--ok-bg);
625
646
  color: var(--ok);
626
647
  }
627
- .badge.unknown {
628
- background: var(--unknown-bg);
629
- color: var(--unknown);
648
+ .badge.info {
649
+ background: var(--accent-bg);
650
+ color: var(--accent);
651
+ }
652
+ .badge.archived {
653
+ background: var(--info-bg);
654
+ color: var(--info);
630
655
  }
631
656
 
632
657
  /* stock status badge (parts linked via signalk-stowage-mgmt) */
@@ -18,12 +18,13 @@
18
18
  --danger-hover: #b02525;
19
19
  --danger-bg: #ffe3e3;
20
20
  --warn: #e8590c;
21
+ --warn-hover: #d9480f;
21
22
  --warn-bg: #ffe8cc;
22
23
  --ok: #2b8a3e;
23
24
  --ok-hover: #237434;
24
25
  --ok-bg: #d3f9d8;
25
- --unknown: #5c6570;
26
- --unknown-bg: #e9ecef;
26
+ --info: #5c6570;
27
+ --info-bg: #e9ecef;
27
28
  --shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
28
29
  --overlay: rgba(20, 24, 28, 0.5);
29
30
  }
@@ -44,12 +45,13 @@
44
45
  --danger-hover: #ff8787;
45
46
  --danger-bg: #3d2224;
46
47
  --warn: #ffa94d;
48
+ --warn-hover: #ffc078;
47
49
  --warn-bg: #3d2f1f;
48
50
  --ok: #69db7c;
49
51
  --ok-hover: #8ce99a;
50
52
  --ok-bg: #1e3325;
51
- --unknown: #9aa4ae;
52
- --unknown-bg: #272d33;
53
+ --info: #9aa4ae;
54
+ --info-bg: #272d33;
53
55
  --shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
54
56
  --overlay: rgba(0, 0, 0, 0.65);
55
57
  }