mgv-backoffice 1.32.0 → 1.34.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.
@@ -68,6 +68,18 @@ export declare function fmtMsAsSeconds(ms: number | null | undefined): string;
68
68
  * "2.0 MB"). Returns '' for zero/falsy input.
69
69
  */
70
70
  export declare function fmtBytes(bytes: number | null | undefined): string;
71
+ /**
72
+ * Pretty-print a string that may contain JSON: parseable input is
73
+ * re-serialized with 2-space indentation, anything else is returned
74
+ * verbatim. Handy for request/response bodies of unknown content type.
75
+ */
76
+ export declare function formatJson(content: string): string;
77
+ /**
78
+ * Render an unknown value as a display string: strings pass through,
79
+ * null/undefined become '', everything else is JSON-serialized (falling
80
+ * back to String() for values JSON can't handle, e.g. cycles or BigInt).
81
+ */
82
+ export declare function stringifyValue(value: unknown): string;
71
83
  /**
72
84
  * Format a millisecond duration as compact day/hour/minute units, e.g.
73
85
  * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Pure, dependency-free input validators shared across back-office forms —
3
+ * extracted from WireMate's mock form.
4
+ *
5
+ * Semantics note: the payload validators (`isValidJson`, `isValidXml`,
6
+ * `isValidBase64`) treat empty/whitespace-only input as VALID — callers
7
+ * gate on "is there something to check" separately (required-ness is a
8
+ * different rule from well-formedness). `isValidAbsoluteUrl` is the
9
+ * opposite: it validates a value that must exist, so empty is invalid.
10
+ */
11
+ /**
12
+ * `true` when `value` parses as an absolute http:// or https:// URL.
13
+ * Deliberately tightens the scheme list — backends' HTTP clients won't
14
+ * follow ftp:// or mailto: anyway, so a stray scheme is a typo.
15
+ */
16
+ export declare function isValidAbsoluteUrl(value: string): boolean;
17
+ /**
18
+ * `true` when `str` is empty or parses as JSON.
19
+ */
20
+ export declare function isValidJson(str: string): boolean;
21
+ /**
22
+ * `true` when `str` is empty or is well-formed XML. Runs the browser's
23
+ * DOMParser and checks for a `<parsererror>` node — the only reliable
24
+ * signal DOMParser provides, since it never throws on bad input.
25
+ */
26
+ export declare function isValidXml(str: string): boolean;
27
+ /**
28
+ * `true` when `str` is empty or is well-formed base64 (after stripping
29
+ * whitespace/newlines, which are harmless in both RFC 4648 and most
30
+ * real-world payloads). Rejects lengths that can't possibly decode
31
+ * (`length % 4 !== 0` after padding) — the common "off by a character"
32
+ * copy/paste issue — then defers to the native decoder as the final
33
+ * authority.
34
+ */
35
+ export declare function isValidBase64(str: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseCodeBlock from './BaseCodeBlock.vue'
4
+ import { useTheme } from '../composables/useTheme'
5
+
6
+ function ensureLight() {
7
+ const { isDark, toggleTheme } = useTheme()
8
+ if (isDark.value) toggleTheme()
9
+ }
10
+
11
+ describe('BaseCodeBlock', () => {
12
+ beforeEach(() => {
13
+ ensureLight()
14
+ })
15
+
16
+ it('renders the code verbatim inside a <pre>', () => {
17
+ const code = '{\n "a": 1\n}'
18
+ const wrapper = mount(BaseCodeBlock, { props: { code } })
19
+ const pre = wrapper.find('pre')
20
+ expect(pre.exists()).toBe(true)
21
+ expect(pre.text()).toBe(code)
22
+ expect(pre.classes()).toContain('font-mono')
23
+ expect(pre.classes()).toContain('overflow-auto')
24
+ })
25
+
26
+ it('defaults to the soft variant at size sm', () => {
27
+ const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
28
+ const classes = wrapper.find('pre').classes()
29
+ expect(classes).toContain('bg-gray-100')
30
+ expect(classes).toContain('text-sm')
31
+ expect(classes).toContain('px-5')
32
+ expect(classes).not.toContain('border')
33
+ })
34
+
35
+ it('renders the bordered variant with a border and card fill', () => {
36
+ const wrapper = mount(BaseCodeBlock, {
37
+ props: { code: 'x', variant: 'bordered' },
38
+ })
39
+ const classes = wrapper.find('pre').classes()
40
+ expect(classes).toContain('border')
41
+ expect(classes).toContain('bg-white')
42
+ expect(classes).toContain('border-gray-200')
43
+ })
44
+
45
+ it('renders dense padding at size xs', () => {
46
+ const wrapper = mount(BaseCodeBlock, { props: { code: 'x', size: 'xs' } })
47
+ const classes = wrapper.find('pre').classes()
48
+ expect(classes).toContain('text-xs')
49
+ expect(classes).toContain('p-3')
50
+ })
51
+
52
+ it('applies maxHeightClass and merges fallthrough classes', () => {
53
+ const wrapper = mount(BaseCodeBlock, {
54
+ props: { code: 'x', maxHeightClass: 'max-h-96' },
55
+ attrs: { class: 'mx-4' },
56
+ })
57
+ const classes = wrapper.find('pre').classes()
58
+ expect(classes).toContain('max-h-96')
59
+ expect(classes).toContain('mx-4')
60
+ })
61
+
62
+ it('switches palette when the theme flips', async () => {
63
+ const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
64
+ const { toggleTheme } = useTheme()
65
+ toggleTheme()
66
+ await wrapper.vm.$nextTick()
67
+ expect(wrapper.find('pre').classes()).toContain('bg-gray-800/70')
68
+ toggleTheme()
69
+ })
70
+ })
@@ -0,0 +1,74 @@
1
+ <template>
2
+ <!--
3
+ Themed monospace <pre> for JSON payloads, request dumps and code
4
+ snippets — extracted from WireMate, where the same class soup was
5
+ hand-rolled on every stub/request detail view.
6
+
7
+ Variants:
8
+ • soft — tinted fill, no border (bg-gray-800/70 / bg-gray-100).
9
+ The in-card look used under a section heading.
10
+ • bordered — bordered card fill (bg-gray-900 / bg-white). The
11
+ standalone look used when the block sits directly on the page.
12
+
13
+ Sizing:
14
+ • sm — text-sm with roomy padding, for primary content blocks.
15
+ • xs — text-xs with tight padding, for dense/secondary dumps.
16
+
17
+ `max-height-class` caps tall payloads (e.g. 'max-h-96'); the block
18
+ scrolls both axes. Extra classes (margins etc.) fall through via the
19
+ normal class attr merge.
20
+ -->
21
+ <pre
22
+ class="font-mono overflow-auto"
23
+ :class="[sizeClass, paletteClass, maxHeightClass]"
24
+ >{{ code }}</pre>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { computed } from 'vue'
29
+ import { useTheme } from '../composables/useTheme'
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ /** The raw text to render. Whitespace is preserved verbatim. */
34
+ code: string
35
+ /** Visual style: tinted fill ('soft') or bordered card ('bordered'). */
36
+ variant?: 'soft' | 'bordered'
37
+ /** Text size + padding: 'sm' (roomy) or 'xs' (dense). */
38
+ size?: 'sm' | 'xs'
39
+ /** Optional Tailwind max-height utility, e.g. 'max-h-96'. */
40
+ maxHeightClass?: string
41
+ }>(),
42
+ {
43
+ variant: 'soft',
44
+ size: 'sm',
45
+ maxHeightClass: '',
46
+ },
47
+ )
48
+
49
+ const { isDark } = useTheme()
50
+
51
+ // Full literal class strings so Tailwind detects them at build time.
52
+ const PALETTES: Record<'soft' | 'bordered', { dark: string; light: string }> = {
53
+ soft: {
54
+ dark: 'bg-gray-800/70 text-gray-300',
55
+ light: 'bg-gray-100 text-gray-700',
56
+ },
57
+ bordered: {
58
+ dark: 'border bg-gray-900 border-gray-700 text-gray-300',
59
+ light: 'border bg-white border-gray-200 text-gray-700',
60
+ },
61
+ }
62
+
63
+ const SIZES: Record<'sm' | 'xs', string> = {
64
+ sm: 'text-sm px-5 py-4 rounded-lg',
65
+ xs: 'text-xs p-3 rounded-lg',
66
+ }
67
+
68
+ const paletteClass = computed(() => {
69
+ const entry = PALETTES[props.variant]
70
+ return isDark.value ? entry.dark : entry.light
71
+ })
72
+
73
+ const sizeClass = computed(() => SIZES[props.size])
74
+ </script>
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { defineComponent, h, nextTick } from 'vue'
3
+ import { mount } from '@vue/test-utils'
4
+ import { usePolling, type UsePollingOptions } from './usePolling'
5
+
6
+ function mountWithPolling(
7
+ fn: () => void,
8
+ intervalMs: number | null,
9
+ options?: UsePollingOptions,
10
+ ) {
11
+ let api!: ReturnType<typeof usePolling>
12
+ const wrapper = mount(
13
+ defineComponent({
14
+ setup() {
15
+ api = usePolling(fn, intervalMs, options)
16
+ return () => h('div')
17
+ },
18
+ }),
19
+ )
20
+ return { wrapper, api }
21
+ }
22
+
23
+ function setVisibility(state: 'visible' | 'hidden') {
24
+ Object.defineProperty(document, 'visibilityState', {
25
+ configurable: true,
26
+ get: () => state,
27
+ })
28
+ document.dispatchEvent(new Event('visibilitychange'))
29
+ }
30
+
31
+ describe('usePolling', () => {
32
+ beforeEach(() => {
33
+ vi.useFakeTimers()
34
+ setVisibility('visible')
35
+ })
36
+
37
+ afterEach(() => {
38
+ vi.useRealTimers()
39
+ })
40
+
41
+ it('runs immediately on mount and then on each interval', () => {
42
+ const fn = vi.fn()
43
+ mountWithPolling(fn, 1000)
44
+ expect(fn).toHaveBeenCalledTimes(1)
45
+ vi.advanceTimersByTime(3000)
46
+ expect(fn).toHaveBeenCalledTimes(4)
47
+ })
48
+
49
+ it('skips the initial call when immediate is false', () => {
50
+ const fn = vi.fn()
51
+ mountWithPolling(fn, 1000, { immediate: false })
52
+ expect(fn).not.toHaveBeenCalled()
53
+ vi.advanceTimersByTime(1000)
54
+ expect(fn).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('pauses while hidden and refreshes + resumes on return to visible', async () => {
58
+ const fn = vi.fn()
59
+ mountWithPolling(fn, 1000)
60
+ expect(fn).toHaveBeenCalledTimes(1)
61
+
62
+ setVisibility('hidden')
63
+ await nextTick()
64
+ vi.advanceTimersByTime(5000)
65
+ expect(fn).toHaveBeenCalledTimes(1) // no ticks while hidden
66
+
67
+ setVisibility('visible')
68
+ expect(fn).toHaveBeenCalledTimes(2) // immediate refresh on resume
69
+ vi.advanceTimersByTime(1000)
70
+ expect(fn).toHaveBeenCalledTimes(3) // interval re-armed
71
+ })
72
+
73
+ it('with intervalMs null, runs on mount and on visibility resume only', () => {
74
+ const fn = vi.fn()
75
+ mountWithPolling(fn, null)
76
+ expect(fn).toHaveBeenCalledTimes(1)
77
+ vi.advanceTimersByTime(60000)
78
+ expect(fn).toHaveBeenCalledTimes(1)
79
+
80
+ setVisibility('hidden')
81
+ setVisibility('visible')
82
+ expect(fn).toHaveBeenCalledTimes(2)
83
+ vi.advanceTimersByTime(60000)
84
+ expect(fn).toHaveBeenCalledTimes(2)
85
+ })
86
+
87
+ it('stops on unmount', () => {
88
+ const fn = vi.fn()
89
+ const { wrapper } = mountWithPolling(fn, 1000)
90
+ wrapper.unmount()
91
+ vi.advanceTimersByTime(5000)
92
+ expect(fn).toHaveBeenCalledTimes(1)
93
+ // and the visibility listener is gone too
94
+ setVisibility('hidden')
95
+ setVisibility('visible')
96
+ expect(fn).toHaveBeenCalledTimes(1)
97
+ })
98
+
99
+ it('exposes manual start/stop and an active flag', () => {
100
+ const fn = vi.fn()
101
+ const { api } = mountWithPolling(fn, 1000)
102
+ expect(api.active.value).toBe(true)
103
+ api.stop()
104
+ expect(api.active.value).toBe(false)
105
+ vi.advanceTimersByTime(3000)
106
+ expect(fn).toHaveBeenCalledTimes(1)
107
+ api.start()
108
+ expect(fn).toHaveBeenCalledTimes(2)
109
+ vi.advanceTimersByTime(1000)
110
+ expect(fn).toHaveBeenCalledTimes(3)
111
+ })
112
+ })
@@ -0,0 +1,90 @@
1
+ import { onMounted, onUnmounted, readonly, ref, type Ref } from 'vue'
2
+
3
+ export interface UsePollingOptions {
4
+ /** Run `fn` immediately when polling starts (mount / resume). Default true. */
5
+ immediate?: boolean
6
+ /**
7
+ * Pause the interval while the tab is hidden and refresh + resume when it
8
+ * becomes visible again — a hidden tab has no reason to keep waking the
9
+ * backend. Default true.
10
+ */
11
+ pauseWhenHidden?: boolean
12
+ }
13
+
14
+ /**
15
+ * Visibility-gated polling loop bound to the component lifecycle.
16
+ *
17
+ * Starts on mount, stops on unmount. With `pauseWhenHidden` (the default)
18
+ * the interval is torn down on `document.visibilitychange` → hidden and
19
+ * re-armed — with an immediate refresh — when the tab is shown again.
20
+ *
21
+ * Pass `intervalMs: null` for a refresh-only mode: `fn` runs on mount and
22
+ * on every return-to-visible, but no interval is scheduled. Useful for
23
+ * "reload this badge when the user comes back" data that doesn't warrant
24
+ * a timer.
25
+ *
26
+ * Rejections from an async `fn` are the caller's to handle — catch inside
27
+ * `fn`; the loop itself never swallows or reports them.
28
+ */
29
+ export function usePolling(
30
+ fn: () => void | Promise<void>,
31
+ intervalMs: number | null,
32
+ options: UsePollingOptions = {},
33
+ ): { start: () => void; stop: () => void; active: Readonly<Ref<boolean>> } {
34
+ const { immediate = true, pauseWhenHidden = true } = options
35
+
36
+ let timer: ReturnType<typeof setInterval> | null = null
37
+ const active = ref(false)
38
+
39
+ function startTimer() {
40
+ if (timer !== null || intervalMs === null) return
41
+ timer = setInterval(() => void fn(), intervalMs)
42
+ }
43
+
44
+ function stopTimer() {
45
+ if (timer !== null) {
46
+ clearInterval(timer)
47
+ timer = null
48
+ }
49
+ }
50
+
51
+ function start() {
52
+ if (active.value) return
53
+ active.value = true
54
+ if (immediate) void fn()
55
+ startTimer()
56
+ }
57
+
58
+ function stop() {
59
+ active.value = false
60
+ stopTimer()
61
+ }
62
+
63
+ function onVisibilityChange() {
64
+ if (document.visibilityState === 'visible') {
65
+ // Refresh right away so stale data doesn't linger for a full
66
+ // interval after the user returns, then re-arm the timer.
67
+ active.value = true
68
+ void fn()
69
+ startTimer()
70
+ } else {
71
+ stop()
72
+ }
73
+ }
74
+
75
+ onMounted(() => {
76
+ start()
77
+ if (pauseWhenHidden) {
78
+ document.addEventListener('visibilitychange', onVisibilityChange)
79
+ }
80
+ })
81
+
82
+ onUnmounted(() => {
83
+ stop()
84
+ if (pauseWhenHidden) {
85
+ document.removeEventListener('visibilitychange', onVisibilityChange)
86
+ }
87
+ })
88
+
89
+ return { start, stop, active: readonly(active) }
90
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isReactive } from 'vue'
3
+ import { normalizeClass } from '@vue/shared'
4
+ import { useThemeClasses } from './useThemeClasses'
5
+ import { useTheme } from './useTheme'
6
+
7
+ describe('useThemeClasses', () => {
8
+ it('returns a reactive object whose properties are plain class strings', () => {
9
+ const t = useThemeClasses()
10
+ expect(isReactive(t)).toBe(true)
11
+ expect(typeof t.primaryText).toBe('string')
12
+ expect(t.primaryText).toMatch(/^text-/)
13
+ })
14
+
15
+ it('survives normalizeClass — the regression that leaked ref internals into class attrs', () => {
16
+ // Before the reactive() fix, `:class="t.primaryText"` handed a raw
17
+ // ComputedRef to normalizeClass, which rendered the ref's own keys
18
+ // ("fn dep __v_isRef …") instead of the theme classes.
19
+ const t = useThemeClasses()
20
+ const normalized = normalizeClass(t.primaryText)
21
+ expect(normalized).not.toContain('__v_isRef')
22
+ expect(normalized).toBe(t.primaryText)
23
+ })
24
+
25
+ it('tracks theme flips', () => {
26
+ const { isDark, toggleTheme } = useTheme()
27
+ const t = useThemeClasses()
28
+ const before = t.primaryText
29
+ toggleTheme()
30
+ expect(t.primaryText).not.toBe(before)
31
+ expect(t.primaryText).toBe(isDark.value ? 'text-white' : 'text-gray-900')
32
+ toggleTheme() // restore for other tests sharing the singleton theme
33
+ })
34
+ })
@@ -1,4 +1,4 @@
1
- import { computed, type ComputedRef } from 'vue'
1
+ import { computed, reactive } from 'vue'
2
2
  import { useTheme } from './useTheme'
3
3
 
4
4
  /**
@@ -14,69 +14,73 @@ import { useTheme } from './useTheme'
14
14
  * <p :class="t.mutedText">...</p>
15
15
  * </div>
16
16
  *
17
- * Vue auto-unwraps the computed refs inside template `:class` bindings,
18
- * so callers do not write `.value`.
17
+ * The returned object is `reactive()`, so property access yields the
18
+ * plain class string while staying reactive to theme flips. (It used to
19
+ * be a plain object of ComputedRefs — Vue does NOT unwrap refs nested in
20
+ * plain objects inside template bindings, so `:class="t.card"` rendered
21
+ * the ref's own keys — `fn dep __v_isRef …` — instead of the classes.)
22
+ * Property access is string-valued now: do not append `.value`.
19
23
  */
20
24
  export interface ThemeClasses {
21
25
  // -- Surfaces --
22
26
  /** Card surface: bg-gray-800 / bg-white, with border. */
23
- card: ComputedRef<string>
27
+ card: string
24
28
  /** Slightly darker card surface: bg-gray-900 / bg-white, with border. */
25
- cardAlt: ComputedRef<string>
29
+ cardAlt: string
26
30
  /** Page background. */
27
- pageBg: ComputedRef<string>
31
+ pageBg: string
28
32
 
29
33
  // -- Borders --
30
34
  /** Standard border between cards/sections. */
31
- border: ComputedRef<string>
35
+ border: string
32
36
  /** Subtle divider (lighter than border). */
33
- divider: ComputedRef<string>
37
+ divider: string
34
38
 
35
39
  // -- Text hierarchy --
36
40
  /** Page headings: white / gray-900. */
37
- primaryText: ComputedRef<string>
41
+ primaryText: string
38
42
  /** Softer headings: gray-100 / gray-800. */
39
- primaryTextSoft: ComputedRef<string>
43
+ primaryTextSoft: string
40
44
  /** Body copy on cards: gray-200 / gray-700. */
41
- bodyText: ComputedRef<string>
45
+ bodyText: string
42
46
  /** Form labels: gray-300 / gray-700. */
43
- label: ComputedRef<string>
47
+ label: string
44
48
  /** Secondary text: gray-400 / gray-600. */
45
- mutedText: ComputedRef<string>
49
+ mutedText: string
46
50
  /** Tertiary text: gray-500 / gray-600. */
47
- subtleText: ComputedRef<string>
51
+ subtleText: string
48
52
  /** Dimmest text: gray-500 in dark / gray-400 in light. */
49
- dimText: ComputedRef<string>
53
+ dimText: string
50
54
  /** Slightly more contrast than dimText: gray-400 / gray-500. */
51
- dimTextAlt: ComputedRef<string>
55
+ dimTextAlt: string
52
56
  /** Big illustration / empty-state icon: gray-600 / gray-300. */
53
- illustration: ComputedRef<string>
57
+ illustration: string
54
58
 
55
59
  // -- Inputs --
56
60
  /** Standard text input. */
57
- input: ComputedRef<string>
61
+ input: string
58
62
  /** Input + placeholder colour. */
59
- inputWithPlaceholder: ComputedRef<string>
63
+ inputWithPlaceholder: string
60
64
 
61
65
  // -- Buttons --
62
66
  /** Ghost / Cancel button. */
63
- ghostButton: ComputedRef<string>
67
+ ghostButton: string
64
68
 
65
69
  // -- Accent text colours --
66
70
  /** Emerald accent text: emerald-400 / emerald-600. */
67
- emeraldText: ComputedRef<string>
71
+ emeraldText: string
68
72
  /** Amber accent text: amber-400 / amber-600. */
69
- amberText: ComputedRef<string>
73
+ amberText: string
70
74
  /** Amber heading-weight: amber-300 / amber-700. */
71
- amberTextStrong: ComputedRef<string>
75
+ amberTextStrong: string
72
76
  /** Red accent text: red-400 / red-500. */
73
- redText: ComputedRef<string>
77
+ redText: string
74
78
  }
75
79
 
76
80
  export function useThemeClasses(): ThemeClasses {
77
81
  const { isDark } = useTheme()
78
82
 
79
- return {
83
+ return reactive({
80
84
  card: computed(() =>
81
85
  isDark.value
82
86
  ? 'bg-gray-800 border-gray-700'
@@ -129,5 +133,5 @@ export function useThemeClasses(): ThemeClasses {
129
133
  isDark.value ? 'text-amber-300' : 'text-amber-700',
130
134
  ),
131
135
  redText: computed(() => (isDark.value ? 'text-red-400' : 'text-red-500')),
132
- }
136
+ })
133
137
  }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export { default as BaseChipButton } from './components/BaseChipButton.vue'
37
37
  export { default as BaseRemoveButton } from './components/BaseRemoveButton.vue'
38
38
  export { default as BaseStatusPill } from './components/BaseStatusPill.vue'
39
39
  export { default as BaseFileDropzone } from './components/BaseFileDropzone.vue'
40
+ export { default as BaseCodeBlock } from './components/BaseCodeBlock.vue'
40
41
 
41
42
  // Components — extracted from TradeAutomation
42
43
  export { default as BaseInput } from './components/BaseInput.vue'
@@ -65,6 +66,8 @@ export { useNotifications } from './composables/useNotifications'
65
66
  export { useQueryParamSync } from './composables/useQueryParamSync'
66
67
  export { useFieldClasses } from './composables/useFieldClasses'
67
68
  export type { FieldClasses } from './composables/useFieldClasses'
69
+ export { usePolling } from './composables/usePolling'
70
+ export type { UsePollingOptions } from './composables/usePolling'
68
71
 
69
72
  // Enums
70
73
  export { AlertEnum } from './enums/AlertEnum'
@@ -117,7 +120,15 @@ export {
117
120
  fmtPct,
118
121
  fmtUsd,
119
122
  fmtDuration,
123
+ formatJson,
124
+ stringifyValue,
120
125
  } from './utils/format'
126
+ export {
127
+ isValidAbsoluteUrl,
128
+ isValidJson,
129
+ isValidXml,
130
+ isValidBase64,
131
+ } from './utils/validate'
121
132
  export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
122
133
  export { computePnL } from './utils/pnl'
123
134
  export type { PnL, PnLInputs } from './utils/pnl'
package/src/style.css CHANGED
@@ -1,5 +1,21 @@
1
+ /* Brand typography, shipped by the library so every consumer renders the same:
2
+ Fira Sans for UI text, Fira Code for numerals/data. External @import (kept
3
+ first — CSS ignores @import after any other rule); consumers should add
4
+ <link rel="preconnect"> hints for fonts.googleapis.com / fonts.gstatic.com
5
+ in their index.html for faster first paint. */
6
+ @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
7
+
1
8
  @import "tailwindcss";
2
9
 
10
+ /* Register the fonts as the Tailwind theme defaults. The compiled stylesheet
11
+ re-declares theme variables in its :root, so in consumers that import this
12
+ file after their own `@import "tailwindcss"` these values win the cascade
13
+ for font-sans/font-mono utilities AND the body rule below. */
14
+ @theme {
15
+ --font-sans: 'Fira Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
16
+ --font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
17
+ }
18
+
3
19
  /* Compile `dark:` variants against the `.dark` class that useTheme()/initTheme()
4
20
  toggle on <html> — NOT the OS-level prefers-color-scheme media query
5
21
  (Tailwind v4's default). Both consumers (TradeAutomation, WireMate) switch
@@ -8,3 +24,9 @@
8
24
  Use a specificity-bearing selector (:is, not :where) so dark: utilities win
9
25
  over plain base utilities like bg-slate-50 on a cascade tie. */
10
26
  @custom-variant dark (&:is(.dark, .dark *));
27
+
28
+ /* Apply the brand font app-wide. Plain body rule (not preflight-dependent) so
29
+ it also styles consumers whose own Tailwind preflight came earlier. */
30
+ body {
31
+ font-family: var(--font-sans);
32
+ }
@@ -9,6 +9,8 @@ import {
9
9
  fmtPct,
10
10
  fmtUsd,
11
11
  fmtDuration,
12
+ formatJson,
13
+ stringifyValue,
12
14
  } from './format'
13
15
 
14
16
  describe('fmtDateTimeMs', () => {
@@ -233,3 +235,30 @@ describe('fmtBytes', () => {
233
235
  expect(fmtBytes(null)).toBe('')
234
236
  })
235
237
  })
238
+
239
+ describe('formatJson', () => {
240
+ it('pretty-prints parseable JSON with 2-space indentation', () => {
241
+ expect(formatJson('{"a":1,"b":[2,3]}')).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}')
242
+ })
243
+ it('returns non-JSON input verbatim', () => {
244
+ expect(formatJson('plain text')).toBe('plain text')
245
+ expect(formatJson('<xml/>')).toBe('<xml/>')
246
+ expect(formatJson('')).toBe('')
247
+ })
248
+ })
249
+
250
+ describe('stringifyValue', () => {
251
+ it('passes strings through and blanks null/undefined', () => {
252
+ expect(stringifyValue('abc')).toBe('abc')
253
+ expect(stringifyValue(null)).toBe('')
254
+ expect(stringifyValue(undefined)).toBe('')
255
+ })
256
+ it('JSON-serializes objects, arrays and numbers', () => {
257
+ expect(stringifyValue({ a: 1 })).toBe('{"a":1}')
258
+ expect(stringifyValue([1, 2])).toBe('[1,2]')
259
+ expect(stringifyValue(42)).toBe('42')
260
+ })
261
+ it('falls back to String() when JSON.stringify throws', () => {
262
+ expect(stringifyValue(1n)).toBe('1')
263
+ })
264
+ })