arkaos 4.13.1 → 4.13.2

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 (35) hide show
  1. package/VERSION +1 -1
  2. package/bin/arka-doctor +15 -1
  3. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  4. package/core/governance/evidence_checks.py +85 -4
  5. package/core/workflow/__pycache__/design_authorization.cpython-313.pyc +0 -0
  6. package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
  7. package/core/workflow/design_authorization.py +106 -0
  8. package/core/workflow/frontend_gate.py +54 -22
  9. package/dashboard/app/app.config.ts +2 -2
  10. package/dashboard/app/assets/css/main.css +285 -13
  11. package/dashboard/app/components/ArkaConstellation.vue +239 -0
  12. package/dashboard/app/components/ArkaCountUp.vue +35 -0
  13. package/dashboard/app/components/ArkaGlowCard.vue +25 -0
  14. package/dashboard/app/components/ArkaLiveFeed.vue +122 -0
  15. package/dashboard/app/components/ArkaNavGroupLabel.vue +11 -0
  16. package/dashboard/app/components/ArkaPageHero.vue +42 -0
  17. package/dashboard/app/components/ArkaSection.vue +20 -0
  18. package/dashboard/app/components/ArkaStarfield.vue +3 -0
  19. package/dashboard/app/components/ArkaStatCard.vue +38 -0
  20. package/dashboard/app/components/ArkaSystemPill.vue +54 -0
  21. package/dashboard/app/components/ArkaTrendChart.vue +68 -0
  22. package/dashboard/app/composables/useCountUp.ts +30 -0
  23. package/dashboard/app/composables/useDashboard.ts +2 -1
  24. package/dashboard/app/composables/useTaskStream.ts +39 -0
  25. package/dashboard/app/layouts/default.vue +11 -2
  26. package/dashboard/app/pages/design-lab.vue +257 -0
  27. package/dashboard/app/pages/index.vue +268 -213
  28. package/dashboard/nuxt.config.ts +21 -0
  29. package/dashboard/package.json +1 -0
  30. package/departments/content/skills/video-produce/SKILL.md +9 -1
  31. package/departments/content/skills/video-setup/SKILL.md +15 -4
  32. package/installer/doctor.js +13 -8
  33. package/package.json +1 -1
  34. package/pyproject.toml +1 -1
  35. package/scripts/start-dashboard.sh +3 -1
@@ -0,0 +1,239 @@
1
+ <script setup lang="ts">
2
+ // Canvas particle field: 86 agents clustered around 17 department hubs.
3
+ // Breathing amplitude scales with the department's calls_30d. Hover finds
4
+ // the nearest node (tooltip); click navigates. rAF pauses when the tab is
5
+ // hidden; prefers-reduced-motion renders one static frame.
6
+ import { useDocumentVisibility, useElementSize } from '@vueuse/core'
7
+
8
+ interface AgentNode {
9
+ id: string
10
+ name: string
11
+ department: string
12
+ tier: number
13
+ }
14
+ interface DeptMeta {
15
+ department: string
16
+ calls_30d: number
17
+ }
18
+
19
+ const props = withDefaults(defineProps<{
20
+ agents: AgentNode[]
21
+ departments: DeptMeta[]
22
+ height?: number
23
+ }>(), {
24
+ height: 380
25
+ })
26
+
27
+ const router = useRouter()
28
+ const wrap = ref<HTMLElement | null>(null)
29
+ const canvas = ref<HTMLCanvasElement | null>(null)
30
+ const { width } = useElementSize(wrap)
31
+ const visibility = useDocumentVisibility()
32
+
33
+ const hovered = ref<{ x: number, y: number, name: string, department: string } | null>(null)
34
+
35
+ interface Star {
36
+ agent: AgentNode
37
+ cx: number
38
+ cy: number
39
+ baseR: number
40
+ phase: number
41
+ speed: number
42
+ intensity: number
43
+ x: number
44
+ y: number
45
+ }
46
+
47
+ let stars: Star[] = []
48
+ let hubs: Record<string, { x: number, y: number }> = {}
49
+ let frame = 0
50
+ let time = 0
51
+
52
+ // Canvas has no reliable color-mix() support below Chromium 119 /
53
+ // Safari 17.2, so token hexes are resolved to rgba() strings instead.
54
+ function cssVar(name: string, fallback: string) {
55
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
56
+ }
57
+ function rgba(hex: string, alpha: number) {
58
+ const h = hex.replace('#', '')
59
+ if (!/^[0-9a-f]{3}$|^[0-9a-f]{6}$/i.test(h)) return hex
60
+ const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h
61
+ const int = Number.parseInt(n, 16)
62
+ return `rgba(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255}, ${alpha})`
63
+ }
64
+
65
+ function layoutHubs(w: number, h: number, depts: string[]) {
66
+ hubs = {}
67
+ depts.forEach((dept, i) => {
68
+ // Golden-angle spiral keeps 17 hubs evenly spread at any aspect ratio.
69
+ const angle = i * 2.399963
70
+ const radius = 0.16 + 0.34 * Math.sqrt((i + 0.5) / depts.length)
71
+ hubs[dept] = {
72
+ x: w / 2 + Math.cos(angle) * radius * w * 0.9,
73
+ y: h / 2 + Math.sin(angle) * radius * h * 0.85
74
+ }
75
+ })
76
+ }
77
+
78
+ function layoutStars(w: number, h: number) {
79
+ const maxCalls = Math.max(1, ...props.departments.map(d => d.calls_30d))
80
+ const callsByDept = Object.fromEntries(props.departments.map(d => [d.department, d.calls_30d]))
81
+ stars = props.agents.map((agent, i) => {
82
+ const hub = hubs[agent.department] ?? { x: w / 2, y: h / 2 }
83
+ const angle = (i * 2.399963) % (Math.PI * 2)
84
+ const spread = 18 + (i % 5) * 9
85
+ return {
86
+ agent,
87
+ cx: hub.x + Math.cos(angle) * spread,
88
+ cy: hub.y + Math.sin(angle) * spread * 0.7,
89
+ baseR: agent.tier === 0 ? 3.2 : agent.tier === 1 ? 2.6 : 2,
90
+ phase: Math.random() * Math.PI * 2,
91
+ speed: 0.4 + Math.random() * 0.5,
92
+ intensity: (callsByDept[agent.department] ?? 0) / maxCalls,
93
+ x: 0,
94
+ y: 0
95
+ }
96
+ })
97
+ }
98
+
99
+ function layout() {
100
+ const w = width.value || 800
101
+ const depts = [...new Set(props.agents.map(a => a.department))].sort()
102
+ layoutHubs(w, props.height, depts)
103
+ layoutStars(w, props.height)
104
+ }
105
+
106
+ function drawLinks(ctx: CanvasRenderingContext2D, gray: string, animate: boolean) {
107
+ ctx.lineWidth = 0.5
108
+ ctx.strokeStyle = rgba(gray, 0.12)
109
+ for (const s of stars) {
110
+ const hub = hubs[s.agent.department]
111
+ if (!hub) continue
112
+ const drift = animate ? Math.sin(time * s.speed + s.phase) * (2 + s.intensity * 4) : 0
113
+ s.x = s.cx + drift
114
+ s.y = s.cy + Math.cos(time * s.speed * 0.8 + s.phase) * (animate ? 2 + s.intensity * 3 : 0)
115
+ ctx.beginPath()
116
+ ctx.moveTo(hub.x, hub.y)
117
+ ctx.lineTo(s.x, s.y)
118
+ ctx.stroke()
119
+ }
120
+ }
121
+
122
+ function drawStars(ctx: CanvasRenderingContext2D, green: string, animate: boolean) {
123
+ for (const s of stars) {
124
+ const breath = animate ? (Math.sin(time * 1.4 * s.speed + s.phase) + 1) / 2 : 0.5
125
+ const r = s.baseR + breath * (0.6 + s.intensity * 1.6)
126
+ const alpha = Math.min(1, 0.45 + s.intensity * 0.4 + breath * 0.15)
127
+ ctx.beginPath()
128
+ ctx.arc(s.x, s.y, r, 0, Math.PI * 2)
129
+ ctx.fillStyle = rgba(green, alpha)
130
+ ctx.shadowColor = s.intensity > 0.15 ? green : 'transparent'
131
+ ctx.shadowBlur = s.intensity * 14
132
+ ctx.fill()
133
+ ctx.shadowBlur = 0
134
+ }
135
+ }
136
+
137
+ function drawHubLabels(ctx: CanvasRenderingContext2D, gray: string) {
138
+ ctx.font = '9px "JetBrains Mono", monospace'
139
+ ctx.fillStyle = rgba(gray, 0.65)
140
+ ctx.textAlign = 'center'
141
+ for (const [dept, hub] of Object.entries(hubs)) {
142
+ ctx.fillText(dept.toUpperCase(), hub.x, hub.y - 16)
143
+ }
144
+ }
145
+
146
+ function draw(animate: boolean) {
147
+ const el = canvas.value
148
+ const ctx = el?.getContext('2d')
149
+ if (!el || !ctx) return
150
+ const w = width.value || 800
151
+ const dpr = window.devicePixelRatio || 1
152
+ if (el.width !== w * dpr || el.height !== props.height * dpr) {
153
+ el.width = w * dpr
154
+ el.height = props.height * dpr
155
+ }
156
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
157
+ ctx.clearRect(0, 0, w, props.height)
158
+ const green = cssVar('--ui-primary', '#00FF88')
159
+ const gray = cssVar('--ui-text-muted', '#8A958F')
160
+ drawLinks(ctx, gray, animate)
161
+ drawStars(ctx, green, animate)
162
+ drawHubLabels(ctx, gray)
163
+ }
164
+
165
+ function loop() {
166
+ time += 0.016
167
+ draw(true)
168
+ frame = requestAnimationFrame(loop)
169
+ }
170
+
171
+ function start() {
172
+ cancelAnimationFrame(frame)
173
+ layout()
174
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
175
+ if (reduce) {
176
+ draw(false)
177
+ return
178
+ }
179
+ loop()
180
+ }
181
+
182
+ watch([width, () => props.agents, visibility], () => {
183
+ cancelAnimationFrame(frame)
184
+ if (visibility.value !== 'visible') return
185
+ if (props.agents.length) start()
186
+ }, { deep: false })
187
+
188
+ onMounted(() => {
189
+ if (props.agents.length) start()
190
+ })
191
+ onUnmounted(() => cancelAnimationFrame(frame))
192
+
193
+ function onMove(e: MouseEvent) {
194
+ const rect = canvas.value?.getBoundingClientRect()
195
+ if (!rect) return
196
+ const mx = e.clientX - rect.left
197
+ const my = e.clientY - rect.top
198
+ let best: Star | null = null
199
+ let bestDist = 18
200
+ for (const s of stars) {
201
+ const d = Math.hypot(s.x - mx, s.y - my)
202
+ if (d < bestDist) {
203
+ best = s
204
+ bestDist = d
205
+ }
206
+ }
207
+ hovered.value = best
208
+ ? { x: best.x, y: best.y, name: best.agent.name, department: best.agent.department }
209
+ : null
210
+ }
211
+
212
+ function onClick() {
213
+ if (hovered.value) router.push('/agents')
214
+ }
215
+ </script>
216
+
217
+ <template>
218
+ <div ref="wrap" class="relative w-full" :style="{ height: `${height}px` }">
219
+ <canvas
220
+ ref="canvas"
221
+ class="size-full"
222
+ :class="hovered ? 'cursor-pointer' : ''"
223
+ :style="{ width: '100%', height: `${height}px` }"
224
+ role="img"
225
+ aria-label="Constellation of all agents grouped by department"
226
+ @mousemove="onMove"
227
+ @mouseleave="hovered = null"
228
+ @click="onClick"
229
+ />
230
+ <div
231
+ v-if="hovered"
232
+ class="pointer-events-none absolute z-10 -translate-x-1/2 rounded border border-default bg-elevated px-2 py-1"
233
+ :style="{ left: `${hovered.x}px`, top: `${hovered.y - 34}px` }"
234
+ >
235
+ <span class="text-xs font-medium">{{ hovered.name }}</span>
236
+ <span class="arka-data ml-1.5 text-[10px] text-muted uppercase">{{ hovered.department }}</span>
237
+ </div>
238
+ </div>
239
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(defineProps<{
3
+ value: number
4
+ format?: 'currency' | 'number' | 'percent' | 'tokens'
5
+ decimals?: number
6
+ duration?: number
7
+ }>(), {
8
+ format: 'number',
9
+ decimals: 0,
10
+ duration: 900
11
+ })
12
+
13
+ const target = computed(() => props.value ?? 0)
14
+ const { display } = useCountUp(target, props.duration)
15
+
16
+ const formatted = computed(() => {
17
+ const v = display.value
18
+ if (props.format === 'currency') {
19
+ return `$${v.toFixed(Math.max(props.decimals, 2))}`
20
+ }
21
+ if (props.format === 'percent') {
22
+ return `${(v * 100).toFixed(props.decimals)}%`
23
+ }
24
+ if (props.format === 'tokens') {
25
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
26
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`
27
+ return Math.round(v).toString()
28
+ }
29
+ return Math.round(v).toLocaleString('en-US')
30
+ })
31
+ </script>
32
+
33
+ <template>
34
+ <span class="arka-data">{{ formatted }}</span>
35
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{
3
+ live?: boolean
4
+ interactive?: boolean
5
+ as?: string
6
+ padded?: boolean
7
+ }>(), {
8
+ live: false,
9
+ interactive: false,
10
+ as: 'div',
11
+ padded: true
12
+ })
13
+ </script>
14
+
15
+ <template>
16
+ <component
17
+ :is="as"
18
+ class="arka-glow-card"
19
+ :class="padded ? 'p-4 sm:p-5' : ''"
20
+ :data-live="live || undefined"
21
+ :data-interactive="interactive || undefined"
22
+ >
23
+ <slot />
24
+ </component>
25
+ </template>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ // One stream, two sources: /api/audit (poll) for enforcement events and
3
+ // /ws/tasks for job lifecycle. WS failure degrades to poll-only.
4
+ import { useDocumentVisibility } from '@vueuse/core'
5
+
6
+ interface AuditEvent {
7
+ ts: string
8
+ tool: string
9
+ reason: string
10
+ kind: string
11
+ }
12
+ interface FeedItem {
13
+ id: string
14
+ ts: number
15
+ icon: string
16
+ tone: 'primary' | 'warning' | 'error' | 'neutral'
17
+ title: string
18
+ detail: string
19
+ }
20
+
21
+ const props = withDefaults(defineProps<{
22
+ limit?: number
23
+ pollInterval?: number
24
+ }>(), {
25
+ limit: 12,
26
+ pollInterval: 15_000
27
+ })
28
+
29
+ const { apiBase } = useApi()
30
+ const visibility = useDocumentVisibility()
31
+ const items = ref<FeedItem[]>([])
32
+ let timer: ReturnType<typeof setInterval> | undefined
33
+
34
+ function pushItems(next: FeedItem[]) {
35
+ const seen = new Set(items.value.map(i => i.id))
36
+ const fresh = next.filter(i => !seen.has(i.id))
37
+ if (!fresh.length) return
38
+ items.value = [...fresh, ...items.value]
39
+ .sort((a, b) => b.ts - a.ts)
40
+ .slice(0, props.limit)
41
+ }
42
+
43
+ async function pollAudit() {
44
+ try {
45
+ const res = await $fetch<{ events: AuditEvent[] }>(`${apiBase}/api/audit`, { query: { limit: props.limit } })
46
+ pushItems((res.events || []).map(e => ({
47
+ id: `audit-${e.ts}-${e.tool}-${(e.reason || '').slice(0, 32)}`,
48
+ ts: Date.parse(e.ts) || Date.now(),
49
+ icon: e.kind === 'bypass' ? 'i-lucide-shield-off' : 'i-lucide-shield',
50
+ tone: e.kind === 'bypass' ? 'warning' : 'neutral',
51
+ title: e.tool || e.kind,
52
+ detail: e.reason || e.kind
53
+ })))
54
+ } catch {
55
+ // API down — keep whatever we have
56
+ }
57
+ }
58
+
59
+ useTaskStream((e) => {
60
+ const toneMap = { job_complete: 'primary', job_failed: 'error', job_cancelled: 'warning', job_progress: 'neutral' } as const
61
+ const iconMap = {
62
+ job_complete: 'i-lucide-check-circle',
63
+ job_failed: 'i-lucide-x-circle',
64
+ job_cancelled: 'i-lucide-circle-slash',
65
+ job_progress: 'i-lucide-loader'
66
+ } as const
67
+ pushItems([{
68
+ id: `job-${e.job_id}-${e.type}-${e.ts}`,
69
+ ts: e.ts,
70
+ icon: iconMap[e.type],
71
+ tone: toneMap[e.type],
72
+ title: e.type.replace('job_', 'job '),
73
+ detail: e.message || e.error || e.job_id
74
+ }])
75
+ })
76
+
77
+ onMounted(() => {
78
+ pollAudit()
79
+ timer = setInterval(() => {
80
+ if (visibility.value === 'visible') pollAudit()
81
+ }, props.pollInterval)
82
+ })
83
+ onUnmounted(() => clearInterval(timer))
84
+
85
+ function timeLabel(ts: number) {
86
+ return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
87
+ }
88
+ const toneClass = { primary: 'text-primary', warning: 'text-warning', error: 'text-error', neutral: 'text-muted' }
89
+ </script>
90
+
91
+ <template>
92
+ <ArkaGlowCard :padded="false">
93
+ <div v-if="!items.length" class="p-6 text-center">
94
+ <p class="arka-data text-xs text-muted">
95
+ listening for signals…
96
+ </p>
97
+ </div>
98
+ <TransitionGroup
99
+ v-else
100
+ tag="ul"
101
+ class="divide-y divide-default"
102
+ enter-active-class="arka-stream-in"
103
+ >
104
+ <li
105
+ v-for="item in items"
106
+ :key="item.id"
107
+ class="flex items-center gap-3 px-4 py-2.5"
108
+ >
109
+ <UIcon :name="item.icon" class="size-4 shrink-0" :class="toneClass[item.tone]" />
110
+ <div class="min-w-0 flex-1">
111
+ <p class="truncate text-sm">
112
+ {{ item.title }}
113
+ </p>
114
+ <p class="truncate text-xs text-muted">
115
+ {{ item.detail }}
116
+ </p>
117
+ </div>
118
+ <span class="arka-data shrink-0 text-[10px] text-muted">{{ timeLabel(item.ts) }}</span>
119
+ </li>
120
+ </TransitionGroup>
121
+ </ArkaGlowCard>
122
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ label: string
4
+ }>()
5
+ </script>
6
+
7
+ <template>
8
+ <p class="arka-eyebrow px-2.5 pt-4 pb-1 text-[10px]">
9
+ {{ label }}
10
+ </p>
11
+ </template>
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import { useTimestamp } from '@vueuse/core'
3
+
4
+ withDefaults(defineProps<{
5
+ numeral: string
6
+ label: string
7
+ title: string
8
+ subtitle?: string
9
+ clock?: boolean
10
+ }>(), {
11
+ subtitle: '',
12
+ clock: true
13
+ })
14
+
15
+ const timestamp = useTimestamp({ interval: 1000 })
16
+ const clockLabel = computed(() =>
17
+ new Date(timestamp.value).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
18
+ )
19
+ </script>
20
+
21
+ <template>
22
+ <header class="space-y-3">
23
+ <div class="flex items-center gap-3">
24
+ <span class="arka-numeral text-2xl leading-none">{{ numeral }}.</span>
25
+ <span class="h-px w-10 bg-[var(--ui-border)]" aria-hidden="true" />
26
+ <span class="arka-eyebrow">{{ label }}</span>
27
+ <div class="ml-auto flex items-center gap-2">
28
+ <slot name="actions" />
29
+ </div>
30
+ </div>
31
+ <h1 class="arka-serif-title text-4xl sm:text-5xl">
32
+ {{ title }}
33
+ </h1>
34
+ <p v-if="subtitle" class="max-w-2xl text-muted">
35
+ {{ subtitle }}
36
+ </p>
37
+ <p v-if="clock" class="arka-data text-xs text-muted uppercase tracking-wide">
38
+ {{ clockLabel }} · local · studio
39
+ </p>
40
+ <slot name="stats" />
41
+ </header>
42
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ numeral: string
4
+ label: string
5
+ }>()
6
+ </script>
7
+
8
+ <template>
9
+ <section class="space-y-5">
10
+ <div class="flex items-center gap-3">
11
+ <span class="arka-numeral text-xl leading-none">{{ numeral }}.</span>
12
+ <span class="h-px w-8 bg-[var(--ui-border)]" aria-hidden="true" />
13
+ <span class="arka-eyebrow">{{ label }}</span>
14
+ <div class="ml-auto flex items-center gap-2">
15
+ <slot name="actions" />
16
+ </div>
17
+ </div>
18
+ <slot />
19
+ </section>
20
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <div class="arka-starfield" aria-hidden="true" />
3
+ </template>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{
3
+ label: string
4
+ value: number | null
5
+ format?: 'currency' | 'number' | 'percent' | 'tokens'
6
+ decimals?: number
7
+ accent?: boolean
8
+ hint?: string
9
+ }>(), {
10
+ format: 'number',
11
+ decimals: 0,
12
+ accent: false,
13
+ hint: ''
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <ArkaGlowCard :live="accent" class="min-w-0">
19
+ <div class="space-y-2" :class="accent ? 'border-l-2 border-primary -ml-4 pl-4 sm:-ml-5 sm:pl-5' : ''">
20
+ <p class="arka-eyebrow">
21
+ {{ label }}
22
+ </p>
23
+ <p class="text-3xl font-medium leading-none">
24
+ <ArkaCountUp
25
+ v-if="value !== null"
26
+ :value="value"
27
+ :format="format"
28
+ :decimals="decimals"
29
+ />
30
+ <span v-else class="arka-data text-muted">—</span>
31
+ </p>
32
+ <p v-if="hint" class="text-xs text-muted">
33
+ {{ hint }}
34
+ </p>
35
+ <slot name="sparkline" />
36
+ </div>
37
+ </ArkaGlowCard>
38
+ </template>
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ // "all systems live" — the shell's heartbeat. Polls /api/health (30s),
3
+ // pauses while the tab is hidden.
4
+ import { useDocumentVisibility } from '@vueuse/core'
5
+
6
+ interface HealthPayload {
7
+ healthy: boolean
8
+ passed: number
9
+ total: number
10
+ failed_blocking: number
11
+ }
12
+
13
+ const { apiBase } = useApi()
14
+ const health = ref<HealthPayload | null>(null)
15
+ const visibility = useDocumentVisibility()
16
+ let timer: ReturnType<typeof setInterval> | undefined
17
+
18
+ async function poll() {
19
+ try {
20
+ health.value = await $fetch<HealthPayload>(`${apiBase}/api/health`)
21
+ } catch {
22
+ health.value = null
23
+ }
24
+ }
25
+
26
+ onMounted(() => {
27
+ poll()
28
+ timer = setInterval(() => {
29
+ if (visibility.value === 'visible') poll()
30
+ }, 30_000)
31
+ })
32
+ onUnmounted(() => clearInterval(timer))
33
+
34
+ const label = computed(() => {
35
+ if (!health.value) return 'link down'
36
+ if (health.value.healthy) return 'all systems live'
37
+ return `${health.value.failed_blocking || health.value.total - health.value.passed} degraded`
38
+ })
39
+ const tone = computed(() => {
40
+ if (!health.value) return 'text-muted'
41
+ return health.value.healthy ? 'text-primary' : 'text-warning'
42
+ })
43
+ </script>
44
+
45
+ <template>
46
+ <NuxtLink
47
+ to="/health"
48
+ class="flex items-center gap-2 rounded-full border border-default bg-elevated px-3 py-1.5 transition-colors hover:border-primary/40"
49
+ >
50
+ <span v-if="health?.healthy" class="arka-live-dot" />
51
+ <span v-else class="size-2 rounded-full" :class="health ? 'bg-warning' : 'bg-carbon-500'" />
52
+ <span class="arka-data text-xs" :class="tone">{{ label }}</span>
53
+ </NuxtLink>
54
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import { VisXYContainer, VisArea, VisLine, VisAxis } from '@unovis/vue'
3
+
4
+ interface TrendPoint {
5
+ date: string
6
+ value: number
7
+ }
8
+
9
+ const props = withDefaults(defineProps<{
10
+ series: TrendPoint[]
11
+ height?: number
12
+ }>(), {
13
+ height: 160
14
+ })
15
+
16
+ const x = (_: TrendPoint, i: number) => i
17
+ const y = (d: TrendPoint) => d.value
18
+
19
+ const tickValues = computed(() => {
20
+ const n = props.series.length
21
+ if (n <= 7) return props.series.map((_, i) => i)
22
+ const step = Math.ceil(n / 6)
23
+ return props.series.map((_, i) => i).filter(i => i % step === 0)
24
+ })
25
+
26
+ function tickFormat(i: number) {
27
+ const point = props.series[Math.round(i)]
28
+ if (!point) return ''
29
+ return point.date.slice(5)
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="arka-trend-chart">
35
+ <VisXYContainer :data="series" :height="height">
36
+ <VisArea
37
+ :x="x"
38
+ :y="y"
39
+ color="var(--ui-primary)"
40
+ :opacity="0.12"
41
+ curve-type="monotoneX"
42
+ />
43
+ <VisLine
44
+ :x="x"
45
+ :y="y"
46
+ color="var(--ui-primary)"
47
+ :line-width="1.5"
48
+ curve-type="monotoneX"
49
+ />
50
+ <VisAxis
51
+ type="x"
52
+ :tick-values="tickValues"
53
+ :tick-format="tickFormat"
54
+ :grid-line="false"
55
+ :domain-line="false"
56
+ :tick-line="false"
57
+ />
58
+ </VisXYContainer>
59
+ </div>
60
+ </template>
61
+
62
+ <style scoped>
63
+ .arka-trend-chart :deep(text) {
64
+ font-family: var(--font-mono);
65
+ font-size: 10px;
66
+ fill: var(--ui-text-muted);
67
+ }
68
+ </style>
@@ -0,0 +1,30 @@
1
+ // rAF count-up on the shared motion rhythm. Reduced-motion (or a zero
2
+ // duration) jumps straight to the target so numbers are never withheld.
3
+ export function useCountUp(target: Ref<number>, duration = 900) {
4
+ const display = ref(0)
5
+ let frame = 0
6
+
7
+ const run = (to: number) => {
8
+ cancelAnimationFrame(frame)
9
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
10
+ if (reduce || duration <= 0 || !Number.isFinite(to)) {
11
+ display.value = to
12
+ return
13
+ }
14
+ const from = display.value
15
+ const start = performance.now()
16
+ const tick = (now: number) => {
17
+ const t = Math.min((now - start) / duration, 1)
18
+ const eased = 1 - Math.pow(1 - t, 3)
19
+ display.value = from + (to - from) * eased
20
+ if (t < 1) frame = requestAnimationFrame(tick)
21
+ else display.value = to
22
+ }
23
+ frame = requestAnimationFrame(tick)
24
+ }
25
+
26
+ watch(target, run, { immediate: true })
27
+ onUnmounted(() => cancelAnimationFrame(frame))
28
+
29
+ return { display }
30
+ }