Compare commits

...

10 Commits

Author SHA1 Message Date
bastian 6aeb4685fc Merge pull request 'SEV-337: add shared animations.css' (#7) from sev-337/shared-animations into main 2026-06-11 14:04:56 -04:00
Bastian de Byl 63b6e185d4 SEV-337: add shared animations.css (keyframes + helper classes)
Centralizes the animation primitives duplicated across public/vendor/admin
style.css — shimmer/.skeleton/.ai-shimmer, fadeIn/.fade-in, fadeInUp/.fade-in-up,
stagger delays, shake/.shake — as a plain-CSS export each app can import
(@switchev/web-shared/animations.css). Bumps to 0.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:04:41 -04:00
bastian c0a113dda7 Merge pull request 'SEV-408: combobox displayValue option (v0.5.0)' (#6) from sev-408/combobox-displayvalue into main 2026-06-10 20:21:14 -04:00
Bastian de Byl 6881d31650 SEV-408: combobox displayValue option (show code, search by label) v0.5.0
When displayValue:true, the input shows the selected option's value (e.g. a
state code "TX") while the dropdown stays label-searchable ("Texas") — for a
field searched by name but displayed compactly. Backward-compatible
(default false). 20/20 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:20:59 -04:00
bastian cab06d7c83 Merge pull request 'SEV-392: combobox setOptions() for reactive parent-fed option lists' (#5) from sev-392/combobox-setoptions into main 2026-06-10 19:09:15 -04:00
Bastian de Byl a943dd0ebd SEV-392: combobox setOptions() for reactive parent-fed option lists
Passing a parent component's reactive array into the nested combobox x-data
needs x-effect="setOptions(vpicMakes)" — a plain closure over the parent
scope doesn't track Alpine updates. Falls back to config.options for static
lists. 18/18 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:09:00 -04:00
bastian 6789da1574 Merge pull request 'SEV-392: reusable fuzzy combobox component (+fuzzysort) v0.4.0' (#4) from sev-392/combobox into main 2026-06-10 19:07:17 -04:00
Bastian de Byl a676a3e99a SEV-392: reusable fuzzy combobox component (+fuzzysort) v0.4.0
comboboxData(config) Alpine factory: typeable input + fuzzysort-ranked
dropdown (prefix/substring/abbreviation subsequence matching), keyboard
nav (Arrow/Enter/Escape/Tab), a11y roles, x-modelable value binding.
allowFree (default) accepts off-list typed values so classics/kit cars are
never blocked; allowFree:false for closed lists (US states) snaps back to a
valid option on blur. Foundation for replacing native <select> chrome with
inputs that match our text fields. 17/17 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:06:38 -04:00
bastian b7abf51087 Merge pull request 'SEV-378: add tolerant formatDate/formatDateTime (v0.3.0)' (#3) from sev-378/format-date into main 2026-06-10 16:36:43 -04:00
Bastian de Byl e405985c34 SEV-378: add tolerant formatDate/formatDateTime to shared (v0.3.0)
Our Go/Turso layer emits SQLite datetime('now') as "YYYY-MM-DD HH:MM:SS"
(UTC, no zone), which new Date() can't parse → "Invalid Date" in admin.
formatDate/formatDateTime normalize that shape (space→T + Z) and RFC3339,
returning '' for falsy/invalid. Tests cover both formats + empties.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:36:24 -04:00
9 changed files with 474 additions and 6 deletions
+28 -1
View File
@@ -9,7 +9,7 @@ All work in this repo should reference the relevant Jira ticket (project: `SEV`)
**JQL shortcut:** `project = SEV AND parent in (SEV-7, SEV-6) ORDER BY status` **JQL shortcut:** `project = SEV AND parent in (SEV-7, SEV-6) ORDER BY status`
Full TODO-to-Jira mapping: `/Users/bastian/src/switchev/TODO.md` Use the Jira MCP tools (`mcp__atlassian-switchev__jira_*`) to search, create, and update tickets directly.
## Project ## Project
SwitchEV is an EV conversion marketplace. This repo contains shared constants, validation schemas, and type definitions used across the api/ and frontend sites. SwitchEV is an EV conversion marketplace. This repo contains shared constants, validation schemas, and type definitions used across the api/ and frontend sites.
@@ -21,3 +21,30 @@ SwitchEV is an EV conversion marketplace. This repo contains shared constants, v
## Usage ## Usage
Keep this package minimal. Only add code here if it is genuinely shared between multiple repos. Keep this package minimal. Only add code here if it is genuinely shared between multiple repos.
## Git Hosting & CI/CD
- **Gitea:** `git.debyl.io/SwitchEV/shared` — PRs, code review
- **MCP:** Use the `gitea-personal` MCP server at `git.debyl.io` for PRs and all Gitea operations; fallback is `tea` CLI
- **CI:** No Gitea Actions workflow — shared library; tests run in consuming repos (api)
### Git Workflow
1. Create a working branch off main, make changes, commit, and push
2. Create a PR via the `gitea-personal` MCP (or `tea` CLI as fallback)
3. Wait for CI to pass (if applicable)
4. Ask the user for code review before merging
5. Merge the PR (remote branch is auto-deleted)
6. Locally: `git checkout main && git pull && git fetch --prune`
### tea CLI Reference (fallback)
```bash
tea pr create --repo SwitchEV/shared --login git.debyl.io --head <branch> --base main --title "Title" --description "Body"
tea pr list --repo SwitchEV/shared --login git.debyl.io
tea pr merge --repo SwitchEV/shared --login git.debyl.io <PR#> --style merge
```
### Branch Protection
Main branch is protected via Gitea:
- Direct push to main is blocked — all changes go through PRs
- No required approvals (single-maintainer repo)
- **Auto-delete branch after merge** is enabled — remote branches are cleaned up automatically
- Locally, `git fetch --prune` cleans up stale remote-tracking branches
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@switchev/web-shared",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@switchev/web-shared",
"version": "0.4.0",
"dependencies": {
"fuzzysort": "^3.1.0"
},
"peerDependencies": {
"dompurify": ">=3",
"marked": ">=12"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/fuzzysort": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
}
}
}
+10 -4
View File
@@ -1,20 +1,26 @@
{ {
"name": "@switchev/web-shared", "name": "@switchev/web-shared",
"version": "0.2.0", "version": "0.6.0",
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends", "description": "Shared browser helpers (formatters, markdown, image polling, combobox, animations) for the SwitchEV frontends",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.js", ".": "./src/index.js",
"./format": "./src/format.js", "./format": "./src/format.js",
"./markdown": "./src/markdown.js", "./markdown": "./src/markdown.js",
"./wait-image": "./src/wait-image.js" "./wait-image": "./src/wait-image.js",
"./combobox": "./src/combobox.js",
"./animations.css": "./src/animations.css"
}, },
"files": [ "files": [
"src" "src"
], ],
"sideEffects": [ "sideEffects": [
"./src/markdown.js" "./src/markdown.js",
"./src/animations.css"
], ],
"dependencies": {
"fuzzysort": "^3.1.0"
},
"peerDependencies": { "peerDependencies": {
"dompurify": ">=3", "dompurify": ">=3",
"marked": ">=12" "marked": ">=12"
+70
View File
@@ -0,0 +1,70 @@
/* @switchev/web-shared — shared animation primitives (SEV-337).
*
* Plain CSS (no Tailwind utilities), so it can be imported directly by each
* SwitchEV frontend instead of copy-pasting the same keyframes + helper classes
* into every app's style.css. Import once per app (e.g. in main.js):
* import '@switchev/web-shared/animations.css';
* App-specific, non-animation styles (brand theme, star ratings, gradient text)
* stay in each app's own stylesheet.
*/
/* Skeleton loading shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.375rem;
}
/* SEV-238: AI-working shimmer — applied to a field while an AI draft is in
flight so the disabled state reads as "AI is writing here", not broken.
Reuses the skeleton shimmer keyframes with a brand-green tint. */
.ai-shimmer {
background-image: linear-gradient(90deg,
rgba(22, 163, 74, 0.06) 25%,
rgba(22, 163, 74, 0.18) 50%,
rgba(22, 163, 74, 0.06) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite;
}
/* Fade-in */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.5s ease-out both;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out both;
}
/* Staggered animation delays */
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
.delay-400 { animation-delay: 0.4s; }
/* Shake — invalid input (e.g. a wrong verification code) */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.shake {
animation: shake 0.5s ease-in-out;
}
+170
View File
@@ -0,0 +1,170 @@
// SEV-392: a reusable typeable, fuzzy-matching combobox for the SwitchEV
// frontends — replaces native <select> chrome with an input that matches our
// text fields and is keyboard- + screen-reader-friendly.
//
// comboboxData(config) returns an Alpine data object. Bind the committed value
// to a parent form field with x-modelable:
//
// <div class="relative"
// x-data="combobox({ options: () => vpicMakes, allowFree: true, placeholder: 'Make' })"
// x-modelable="value" x-model="form.make"
// @keydown.escape.stop="close()">
// <input type="text" role="combobox" :aria-expanded="isOpen"
// x-model="query" @input="onInput()" @focus="open()" @blur="onBlur()"
// @keydown="onKeydown($event)" :placeholder="placeholder"
// class="w-full px-4 py-3 border rounded-xl focus:ring-2 outline-none transition" />
// <ul x-show="isOpen" x-cloak role="listbox"
// class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-xl shadow-lg max-h-60 overflow-auto">
// <template x-for="(opt, i) in filtered" :key="opt.value">
// <li role="option" :aria-selected="i === highlighted"
// @mousedown.prevent="choose(opt)" @mouseenter="highlighted = i"
// :class="i === highlighted ? 'bg-brand-50' : ''"
// class="px-4 py-2.5 cursor-pointer text-sm text-gray-700"
// x-text="opt.label"></li>
// </template>
// <li x-show="!filtered.length" class="px-4 py-2.5 text-sm text-gray-400">No matches</li>
// </ul>
// </div>
//
// allowFree (default true): the input accepts ANY typed value as the committed
// value — so off-list entries (classic cars, kit builds) are never blocked and
// no separate "enter manually" mode is needed. Set allowFree:false for closed
// lists (e.g. US states) where the value must come from the options.
import fuzzysort from 'fuzzysort';
function normalizeOptions(raw) {
const arr = typeof raw === 'function' ? raw() : raw;
if (!Array.isArray(arr)) return [];
return arr.map((o) =>
o && typeof o === 'object'
? { value: String(o.value ?? ''), label: String(o.label ?? o.value ?? '') }
: { value: String(o), label: String(o) }
);
}
export function comboboxData(config = {}) {
const allowFree = config.allowFree !== false;
const fuzzy = config.fuzzy !== false;
const limit = config.limit || 50;
// displayValue: after selecting, show the option's VALUE (e.g. a state code
// "TX") in the input instead of its LABEL ("Texas"), while the dropdown stays
// label-searchable. Lets a field be searched by name but display the code.
const displayValue = config.displayValue === true;
return {
value: config.value ?? '',
query: '',
isOpen: false,
highlighted: -1,
placeholder: config.placeholder || '',
_opts: null, // set reactively via setOptions(); see _options getter
init() {
this.syncQueryFromValue();
// Reflect external value changes (e.g. a reset) back into the input,
// but don't fight the user while the menu is open. $watch is Alpine-only;
// guarded so the factory stays unit-testable without Alpine.
if (typeof this.$watch === 'function') {
this.$watch('value', () => {
if (!this.isOpen) this.syncQueryFromValue();
});
}
},
// setOptions lets a parent feed reactive option lists in via x-effect —
// e.g. x-effect="setOptions(vpicMakes)" — which is the reliable way to
// pass a parent component's reactive array into this nested x-data (a
// plain closure over the parent scope would NOT track updates). Falls back
// to config.options when never called (static lists).
setOptions(raw) {
this._opts = normalizeOptions(raw);
// a shrinking list shouldn't leave a stale highlight selected
if (this.highlighted >= this._opts.length) this.highlighted = -1;
},
get _options() {
return this._opts != null ? this._opts : normalizeOptions(config.options);
},
syncQueryFromValue() {
const v = this.value;
if (v === '' || v == null) {
this.query = '';
return;
}
const match = this._options.find((o) => o.value === String(v));
if (match) this.query = displayValue ? match.value : match.label;
else this.query = allowFree ? String(v) : '';
},
get filtered() {
const opts = this._options;
const q = (this.query || '').trim();
if (!q) return opts.slice(0, limit);
if (!fuzzy) {
const lower = q.toLowerCase();
return opts.filter((o) => o.label.toLowerCase().includes(lower)).slice(0, limit);
}
return fuzzysort.go(q, opts, { key: 'label', limit }).map((r) => r.obj);
},
open() {
this.isOpen = true;
this.highlighted = -1;
},
close() {
this.isOpen = false;
this.highlighted = -1;
},
onInput() {
this.isOpen = true;
this.highlighted = -1;
if (allowFree) this.value = this.query; // capture free text live
},
choose(opt) {
this.value = opt.value;
this.query = displayValue ? opt.value : opt.label;
this.close();
},
onBlur() {
// @mousedown.prevent on options keeps focus so a click commits before
// this fires; this handles real focus-out (tab/click elsewhere).
if (allowFree) {
this.value = this.query;
} else {
this.syncQueryFromValue(); // snap back to the committed label
}
this.close();
},
onKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!this.isOpen) this.open();
const n = this.filtered.length;
if (n) this.highlighted = (this.highlighted + 1) % n;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const n = this.filtered.length;
if (n) this.highlighted = (this.highlighted - 1 + n) % n;
} else if (e.key === 'Enter') {
if (this.isOpen && this.highlighted >= 0 && this.filtered[this.highlighted]) {
e.preventDefault();
this.choose(this.filtered[this.highlighted]);
} else if (allowFree) {
this.value = this.query;
this.close();
}
} else if (e.key === 'Escape') {
this.close();
if (!allowFree) this.syncQueryFromValue();
} else if (e.key === 'Tab') {
this.close();
}
},
};
}
+93
View File
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { comboboxData } from './combobox.js';
test('free-typing combobox commits typed text as the value (SEV-392)', () => {
const c = comboboxData({ options: ['BMW', 'Porsche', 'Tesla'] });
c.init();
c.query = 'Devin Sportscar'; // off-list classic
c.onInput();
assert.equal(c.value, 'Devin Sportscar'); // accepted as-is, never blocked
});
test('fuzzy filter matches prefix, substring + abbreviation subsequences (SEV-392)', () => {
const c = comboboxData({ options: ['BMW', 'Mercedes-Benz', 'Porsche'] });
// prefix
c.query = 'merc';
assert.ok(c.filtered.map((o) => o.label).includes('Mercedes-Benz'));
// abbreviation subsequence (M…B)
c.query = 'mb';
assert.ok(c.filtered.map((o) => o.label).includes('Mercedes-Benz'));
// and it excludes non-matches
c.query = 'xyz';
assert.equal(c.filtered.length, 0);
});
test('choose() commits an option value + label; supports {value,label} (SEV-392)', () => {
const c = comboboxData({
options: [{ value: 'CA', label: 'California' }, { value: 'OR', label: 'Oregon' }],
allowFree: false,
});
c.init();
c.choose({ value: 'OR', label: 'Oregon' });
assert.equal(c.value, 'OR');
assert.equal(c.query, 'Oregon');
assert.equal(c.isOpen, false);
});
test('closed-list blur snaps back to the committed label (SEV-392)', () => {
const c = comboboxData({
options: [{ value: 'CA', label: 'California' }],
allowFree: false,
value: 'CA',
});
c.init();
assert.equal(c.query, 'California'); // init syncs query from value
c.query = 'typing garbage';
c.onBlur();
assert.equal(c.value, 'CA'); // unchanged
assert.equal(c.query, 'California'); // reverted
});
test('empty query lists all options (capped) (SEV-392)', () => {
const c = comboboxData({ options: ['a', 'b', 'c'] });
c.query = '';
assert.equal(c.filtered.length, 3);
});
test('setOptions feeds reactive option lists (SEV-392)', () => {
const c = comboboxData({ allowFree: true });
c.setOptions([]); // async source not loaded yet
assert.equal(c.filtered.length, 0);
c.setOptions(['BMW', 'Tesla']); // makes arrive
assert.equal(c.filtered.length, 2);
c.query = 'tes';
assert.deepEqual(c.filtered.map((o) => o.value), ['Tesla']);
});
test('displayValue shows the code in the input, searches by label (SEV-408)', () => {
const c = comboboxData({
options: [{ value: 'CA', label: 'California' }, { value: 'TX', label: 'Texas' }],
allowFree: false,
displayValue: true,
});
c.init();
// search by name
c.query = 'tex';
assert.deepEqual(c.filtered.map((o) => o.value), ['TX']);
// choosing shows the CODE, not the name
c.choose({ value: 'TX', label: 'Texas' });
assert.equal(c.value, 'TX');
assert.equal(c.query, 'TX');
});
test('displayValue syncs the code from an external value (SEV-408)', () => {
const c = comboboxData({
options: [{ value: 'OR', label: 'Oregon' }],
allowFree: false,
displayValue: true,
value: 'OR',
});
c.init();
assert.equal(c.query, 'OR'); // shows code, not "Oregon"
});
+26
View File
@@ -95,3 +95,29 @@ export function vehicleName({ year, make, model, commissioned } = {}, fallback =
const v = [year, make, model].filter(Boolean).join(' '); const v = [year, make, model].filter(Boolean).join(' ');
return v || fallback; return v || fallback;
} }
// SEV-378: our Go/Turso layer emits SQLite `datetime('now')` timestamps as
// "YYYY-MM-DD HH:MM:SS" (UTC, no zone), which `new Date()` can't reliably
// parse → "Invalid Date" in the admin UI. normalizeTs handles both that shape
// and RFC3339; returns null for falsy/invalid so callers render ''.
function normalizeTs(ts) {
if (!ts) return null;
const s = String(ts).trim();
const sqlite = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s);
const d = new Date(sqlite ? s.replace(' ', 'T') + 'Z' : s);
return isNaN(d.getTime()) ? null : d;
}
/** Localized date (e.g. "Jun 10, 2026"). '' for missing/invalid input. */
export function formatDate(ts) {
const d = normalizeTs(ts);
return d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '';
}
/** Localized date + time (e.g. "Jun 10, 2026, 2:42 PM"). '' for missing/invalid. */
export function formatDateTime(ts) {
const d = normalizeTs(ts);
return d
? d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
: '';
}
+19 -1
View File
@@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
formatLabel, formatStatus, formatTimeline, formatRange, formatLabel, formatStatus, formatTimeline, formatRange,
formatBudget, formatBudgetBucket, vehicleName, formatBudget, formatBudgetBucket, vehicleName, formatDate, formatDateTime,
} from './format.js'; } from './format.js';
test('formatLabel replaces _ and - and title-cases', () => { test('formatLabel replaces _ and - and title-cases', () => {
@@ -68,3 +68,21 @@ test('vehicleName handles commissioned and sparse vehicles', () => {
assert.equal(vehicleName({ make: 'BMW' }, 'Lead'), 'BMW'); assert.equal(vehicleName({ make: 'BMW' }, 'Lead'), 'BMW');
assert.equal(vehicleName(undefined), 'Project'); assert.equal(vehicleName(undefined), 'Project');
}); });
test('formatDate handles SQLite and RFC3339 timestamps (SEV-378)', () => {
// SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
assert.equal(formatDate('2026-06-10 14:42:27'), 'Jun 10, 2026');
// RFC3339
assert.equal(formatDate('2026-06-10T14:42:27Z'), 'Jun 10, 2026');
// falsy / invalid → ''
assert.equal(formatDate(''), '');
assert.equal(formatDate(null), '');
assert.equal(formatDate('not a date'), '');
});
test('formatDateTime handles both formats + empties (SEV-378)', () => {
assert.match(formatDateTime('2026-06-10 14:42:27'), /Jun 10, 2026/);
assert.match(formatDateTime('2026-06-10T14:42:27Z'), /Jun 10, 2026/);
assert.equal(formatDateTime(''), '');
assert.equal(formatDateTime('garbage'), '');
});
+2
View File
@@ -3,6 +3,8 @@
export { export {
formatLabel, formatStatus, formatTimeline, formatRange, formatLabel, formatStatus, formatTimeline, formatRange,
formatBudget, formatBudgetBucket, vehicleName, formatBudget, formatBudgetBucket, vehicleName,
formatDate, formatDateTime,
} from './format.js'; } from './format.js';
export { renderMarkdown } from './markdown.js'; export { renderMarkdown } from './markdown.js';
export { waitForImage } from './wait-image.js'; export { waitForImage } from './wait-image.js';
export { comboboxData } from './combobox.js';