arkaos 4.13.0 → 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 (36) 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/core-snapshot.js +44 -20
  33. package/installer/doctor.js +13 -8
  34. package/package.json +1 -1
  35. package/pyproject.toml +1 -1
  36. package/scripts/start-dashboard.sh +3 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.13.0
1
+ 4.13.2
package/bin/arka-doctor CHANGED
@@ -2,7 +2,7 @@
2
2
  # ============================================================================
3
3
  # ARKA OS — Doctor (Health Check System)
4
4
  # Usage: arka doctor [--fix] [--json]
5
- # 16 modular checks: fail = critical, warn = advisory
5
+ # 17 modular checks: fail = critical, warn = advisory
6
6
  # ============================================================================
7
7
  set -euo pipefail
8
8
 
@@ -362,6 +362,20 @@ else
362
362
  fi
363
363
  run_check "plugins" "warn" "$PLUGINS_DESC" "$PLUGINS_OK" "Run: bash install.sh"
364
364
 
365
+ # ─── Check 17: Hyperframes Skills (video production) ──────────────────────
366
+ # Parity with installer/doctor.js `hyperframes-skills` (PR-C2): same name,
367
+ # same sentinel, warn-only — video production is opt-in.
368
+ HF_OK=false
369
+ HF_DESC="Hyperframes skills AUSENTE (video-as-code editing for /content video)"
370
+ for hf_name in hyperframes hyperframes-core; do
371
+ if [ -f "$HOME/.claude/skills/$hf_name/SKILL.md" ]; then
372
+ HF_OK=true
373
+ HF_DESC="Hyperframes skills installed ($hf_name)"
374
+ break
375
+ fi
376
+ done
377
+ run_check "hyperframes-skills" "warn" "$HF_DESC" "$HF_OK" "Run /content video-setup in Claude Code"
378
+
365
379
  # ─── Output ────────────────────────────────────────────────────────────────
366
380
  TOTAL=$((PASS_COUNT + WARN_COUNT + FAIL_COUNT))
367
381
 
@@ -185,20 +185,101 @@ def _mypy_configured(project_dir: Path) -> bool:
185
185
 
186
186
  # ─── Individual checks ──────────────────────────────────────────────────
187
187
 
188
+ _LINTABLE_PY = frozenset({".py"})
189
+ _LINTABLE_JS = frozenset({".js", ".jsx", ".ts", ".tsx", ".vue", ".mjs", ".cjs"})
190
+ _LINTABLE_PHP = frozenset({".php"})
191
+
192
+
193
+ def _scoped_files(
194
+ project_dir: Path, changed: list[str] | None, exts: frozenset[str],
195
+ ) -> list[str]:
196
+ """Changed files that live inside project_dir and carry a lintable ext."""
197
+ if not changed:
198
+ return []
199
+ root = project_dir.resolve()
200
+ out: list[str] = []
201
+ for raw in changed:
202
+ p = Path(raw)
203
+ candidate = p if p.is_absolute() else project_dir / p
204
+ try:
205
+ rel = candidate.resolve().relative_to(root)
206
+ except (ValueError, OSError):
207
+ continue
208
+ if candidate.suffix.lower() in exts and candidate.is_file():
209
+ out.append(str(rel))
210
+ return out
211
+
212
+
213
+ def _labelled(result: CheckResult, label: str) -> CheckResult:
214
+ """Prefix the reported command with the lint scope label."""
215
+ result.command = f"{label} {result.command}".strip()
216
+ return result
217
+
218
+
219
+ def _lint_scoped(
220
+ project_dir: Path, changed: list[str], timeout: int,
221
+ ) -> CheckResult | None:
222
+ """Lint only the changed files when the detected linter supports it.
223
+
224
+ Pre-existing project-wide debt is master's debt, not this change's —
225
+ the same principle _check_security_grep already applies to its diff
226
+ scope. Returns None when no scoped run applies (caller falls back).
227
+ """
228
+ if shutil.which("ruff"):
229
+ files = _scoped_files(project_dir, changed, _LINTABLE_PY)
230
+ if files:
231
+ result = _run("lint", ["ruff", "check", *files], project_dir, timeout)
232
+ return _labelled(result, f"lint(scoped: {len(files)} file(s))")
233
+ eslint = project_dir / "node_modules" / ".bin" / "eslint"
234
+ if eslint.is_file():
235
+ files = _scoped_files(project_dir, changed, _LINTABLE_JS)
236
+ if files:
237
+ result = _run("lint", [str(eslint), *files], project_dir, timeout)
238
+ return _labelled(result, f"lint(scoped: {len(files)} file(s))")
239
+ pint = project_dir / "vendor" / "bin" / "pint"
240
+ if pint.is_file():
241
+ files = _scoped_files(project_dir, changed, _LINTABLE_PHP)
242
+ if files:
243
+ result = _run(
244
+ "lint", [str(pint), "--test", *files], project_dir, timeout,
245
+ )
246
+ return _labelled(result, f"lint(scoped: {len(files)} file(s))")
247
+ return None
248
+
188
249
 
189
250
  def _check_lint(
190
251
  project_dir: Path, changed: list[str] | None,
191
252
  test_command: str | None, timeout: int,
192
253
  ) -> CheckResult:
254
+ if changed:
255
+ scoped = _lint_scoped(project_dir, changed, timeout)
256
+ if scoped is not None:
257
+ return scoped
258
+ lintable = (
259
+ _scoped_files(project_dir, changed, _LINTABLE_PY)
260
+ or _scoped_files(project_dir, changed, _LINTABLE_JS)
261
+ or _scoped_files(project_dir, changed, _LINTABLE_PHP)
262
+ )
263
+ if not lintable:
264
+ return _skip("lint", "changed files contain no lintable sources")
193
265
  if _has_python(project_dir, changed) and shutil.which("ruff"):
194
- return _run("lint", ["ruff", "check", "."], project_dir, timeout)
266
+ return _labelled(
267
+ _run("lint", ["ruff", "check", "."], project_dir, timeout),
268
+ "lint(project-wide)",
269
+ )
195
270
  if _package_json_script(project_dir, "lint"):
196
- return _run(
197
- "lint", ["npm", "run", "--silent", "lint"], project_dir, timeout,
271
+ return _labelled(
272
+ _run(
273
+ "lint", ["npm", "run", "--silent", "lint"], project_dir, timeout,
274
+ ),
275
+ "lint(project-wide)",
198
276
  )
199
277
  pint = project_dir / "vendor" / "bin" / "pint"
200
278
  if pint.is_file():
201
- return _run("lint", [str(pint), "--test"], project_dir, timeout)
279
+ return _labelled(
280
+ _run("lint", [str(pint), "--test"], project_dir, timeout),
281
+ "lint(project-wide)",
282
+ )
202
283
  return _skip("lint", "no lint tooling detected (ruff/eslint/pint)")
203
284
 
204
285
 
@@ -0,0 +1,106 @@
1
+ """Persistent per-session design authorization — frontend-gate resilience.
2
+
3
+ The frontend gate reasons over a 20-message transcript window. Two
4
+ structural blind spots produce false denies in hard mode (the same class
5
+ the flow enforcer solved with ``flow_authorization``, "652 false
6
+ blocks"):
7
+
8
+ - the current turn's ``[arka:design]`` marker is invisible to that
9
+ turn's own PreToolUse hook (a denied tool call never persists its
10
+ turn's text to the transcript), and
11
+ - a tool-heavy sequence serializes as ``<tool_use:...>`` placeholders
12
+ and rolls the marker's text block out of the window.
13
+
14
+ Fix: **persist-on-observe + consult-before-deny**. Whenever ``evaluate``
15
+ observes a structured marker in the window it confirms it here; when the
16
+ window shows nothing, a valid persisted confirmation for the session
17
+ still allows. Deliberately NO turn-grace tier: a session with no design
18
+ evidence at all must keep denying in hard mode (excellence-mandate), and
19
+ the first confirmation has a natural path — emit the marker in a turn
20
+ whose tools are not gated (loading skills / reading the design system),
21
+ which persists to the transcript and confirms on the next evaluation.
22
+
23
+ State dir: ``/tmp/arkaos-design-auth`` (override via
24
+ ``ARKA_DESIGN_AUTH_DIR``).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import time
32
+ from pathlib import Path
33
+
34
+ from core.shared import safe_session_id as _safe_session_id_module
35
+ from core.shared.temp_paths import arkaos_temp_dir
36
+
37
+ _safe_session_id = _safe_session_id_module.safe_session_id
38
+
39
+ # A working session; a stale /tmp record expires on its own.
40
+ DEFAULT_TTL_SECONDS = 12 * 60 * 60
41
+
42
+
43
+ def _base_dir() -> Path:
44
+ override = os.environ.get("ARKA_DESIGN_AUTH_DIR", "").strip()
45
+ return Path(override) if override else arkaos_temp_dir("arkaos-design-auth")
46
+
47
+
48
+ def _auth_path(session_id: str) -> Path | None:
49
+ safe = _safe_session_id(session_id)
50
+ return (_base_dir() / f"{safe}.json") if safe else None
51
+
52
+
53
+ def _read(path: Path | None) -> dict:
54
+ if path is None or not path.exists():
55
+ return {}
56
+ try:
57
+ data = json.loads(path.read_text(encoding="utf-8"))
58
+ except (json.JSONDecodeError, OSError):
59
+ return {}
60
+ return data if isinstance(data, dict) else {}
61
+
62
+
63
+ def confirm(session_id: str, marker: str) -> None:
64
+ """Persist a confirmed design authorization (structured marker seen)."""
65
+ path = _auth_path(session_id)
66
+ if path is None:
67
+ return
68
+ try:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ path.write_text(
71
+ json.dumps({"marker": marker, "confirmed_ts": time.time()}),
72
+ encoding="utf-8",
73
+ )
74
+ except OSError:
75
+ pass # authorization state must never block the hook
76
+
77
+
78
+ def confirmed_marker(
79
+ session_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS
80
+ ) -> str | None:
81
+ """Return the persisted marker if present and within TTL, else None."""
82
+ data = _read(_auth_path(session_id))
83
+ ts = data.get("confirmed_ts")
84
+ if ts is None:
85
+ return None
86
+ if time.time() - float(ts) > ttl_seconds:
87
+ return None
88
+ marker = data.get("marker")
89
+ return marker if isinstance(marker, str) and marker else None
90
+
91
+
92
+ def is_confirmed(
93
+ session_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS
94
+ ) -> bool:
95
+ return confirmed_marker(session_id, ttl_seconds) is not None
96
+
97
+
98
+ def clear(session_id: str) -> None:
99
+ """Remove authorization state for a session (tests / session end)."""
100
+ path = _auth_path(session_id)
101
+ if path is None:
102
+ return
103
+ try:
104
+ path.unlink()
105
+ except (FileNotFoundError, OSError):
106
+ pass
@@ -40,6 +40,7 @@ from datetime import datetime, timezone
40
40
  from pathlib import Path
41
41
 
42
42
  from core.shared import safe_session_id as _safe_session_id_module
43
+ from core.workflow import design_authorization
43
44
  from core.workflow.flow_enforcer import (
44
45
  TRIVIAL_RE,
45
46
  _load_last_assistant_messages,
@@ -186,6 +187,56 @@ def _classify_marker(messages: list[str]) -> tuple[str | None, str]:
186
187
  return None, "none"
187
188
 
188
189
 
190
+ def _resolve_ui_scope(
191
+ tool_name: str, file_path: str, tool_input: dict
192
+ ) -> str | None:
193
+ """Return 'suffix' | 'heuristic' when the call is UI-scoped, else None."""
194
+ if tool_name not in _GATED_TOOLS:
195
+ return None
196
+ if is_ui_file(file_path):
197
+ return "suffix"
198
+ if is_heuristic_ui_file(file_path, tool_name, tool_input):
199
+ return "heuristic"
200
+ return None
201
+
202
+
203
+ def _marker_decision(
204
+ session_id: str, marker: str | None, kind: str,
205
+ mode: str, file_path: str, ui_scope: str,
206
+ ) -> Decision:
207
+ """Decide allow/deny from the classified marker + persisted auth.
208
+
209
+ A structured marker is persisted (persist-on-observe) so later
210
+ evaluations survive transcript window-rolling — the deadlock fix,
211
+ mirroring flow_authorization. Trivial markers do NOT persist (they
212
+ authorize one small edit, not a session). With nothing in the
213
+ window, a valid persisted confirmation still allows.
214
+ """
215
+ if kind == "structured" and marker:
216
+ design_authorization.confirm(session_id, marker)
217
+ if kind in ("structured", "trivial"):
218
+ return Decision(allow=True, reason="design-evidence", mode=mode,
219
+ target_file=file_path, marker_found=marker,
220
+ marker_kind=kind, ui_scope=ui_scope)
221
+ persisted = design_authorization.confirmed_marker(session_id)
222
+ if persisted is not None:
223
+ return Decision(allow=True, reason="design-evidence-persisted",
224
+ mode=mode, target_file=file_path,
225
+ marker_found=persisted, marker_kind="persisted",
226
+ ui_scope=ui_scope)
227
+ # Heuristic scope is WARN-only by design: it never denies, even in
228
+ # hard mode — its telemetry (ui_scope=heuristic) informs whether it
229
+ # ever graduates to denying scope.
230
+ deniable = mode == "hard" and ui_scope == "suffix"
231
+ if kind == "legacy":
232
+ return Decision(allow=not deniable, reason="legacy-marker", mode=mode,
233
+ target_file=file_path, marker_found=marker,
234
+ marker_kind=kind, ui_scope=ui_scope)
235
+ return Decision(allow=not deniable, reason="no-design-marker",
236
+ mode=mode, target_file=file_path, marker_kind=kind,
237
+ ui_scope=ui_scope)
238
+
239
+
189
240
  def evaluate(
190
241
  tool_name: str,
191
242
  transcript_path: str,
@@ -196,13 +247,8 @@ def evaluate(
196
247
  ) -> Decision:
197
248
  """Evaluate one tool call against the frontend excellence gate."""
198
249
  file_path = str(tool_input.get("file_path", ""))
199
- if tool_name not in _GATED_TOOLS:
200
- return Decision(allow=True, reason="not-ui-scope", target_file=file_path)
201
- if is_ui_file(file_path):
202
- ui_scope = "suffix"
203
- elif is_heuristic_ui_file(file_path, tool_name, tool_input):
204
- ui_scope = "heuristic"
205
- else:
250
+ ui_scope = _resolve_ui_scope(tool_name, file_path, tool_input)
251
+ if ui_scope is None:
206
252
  return Decision(allow=True, reason="not-ui-scope", target_file=file_path)
207
253
  mode = _mode()
208
254
  if mode == "off":
@@ -216,21 +262,7 @@ def evaluate(
216
262
  transcript_path, ASSISTANT_WINDOW
217
263
  )
218
264
  marker, kind = _classify_marker(messages)
219
- if kind in ("structured", "trivial"):
220
- return Decision(allow=True, reason="design-evidence", mode=mode,
221
- target_file=file_path, marker_found=marker,
222
- marker_kind=kind, ui_scope=ui_scope)
223
- # Heuristic scope is WARN-only by design: it never denies, even in
224
- # hard mode — its telemetry (ui_scope=heuristic) informs whether it
225
- # ever graduates to denying scope.
226
- deniable = mode == "hard" and ui_scope == "suffix"
227
- if kind == "legacy":
228
- return Decision(allow=not deniable, reason="legacy-marker", mode=mode,
229
- target_file=file_path, marker_found=marker,
230
- marker_kind=kind, ui_scope=ui_scope)
231
- return Decision(allow=not deniable, reason="no-design-marker",
232
- mode=mode, target_file=file_path, marker_kind=kind,
233
- ui_scope=ui_scope)
265
+ return _marker_decision(session_id, marker, kind, mode, file_path, ui_scope)
234
266
 
235
267
 
236
268
  def record_telemetry(session_id: str, tool: str, decision: Decision) -> None:
@@ -1,8 +1,8 @@
1
1
  export default defineAppConfig({
2
2
  ui: {
3
3
  colors: {
4
- primary: 'emerald',
5
- neutral: 'stone'
4
+ primary: 'arka',
5
+ neutral: 'carbon'
6
6
  }
7
7
  }
8
8
  })
@@ -1,18 +1,290 @@
1
1
  @import "tailwindcss" theme(static);
2
2
  @import "@nuxt/ui";
3
3
 
4
+ /* ─────────────────────────────────────────────────────────────────────────
5
+ ArkaOS Pulse — design tokens
6
+ Brand anchors: #00FF88 (signal green) · #0A0A0A (ink) · #FAFAFA (paper)
7
+ Doctrine: dark-first; green means "alive" (live data, primary action) and
8
+ is never decorative; surfaces are carbon with a green undertone, not flat
9
+ black. Full spec: dashboard/DESIGN-SYSTEM.md
10
+ ───────────────────────────────────────────────────────────────────────── */
11
+
4
12
  @theme static {
5
- --font-sans: 'Public Sans', sans-serif;
6
-
7
- --color-green-50: #EFFDF5;
8
- --color-green-100: #D9FBE8;
9
- --color-green-200: #B3F5D1;
10
- --color-green-300: #75EDAE;
11
- --color-green-400: #00DC82;
12
- --color-green-500: #00C16A;
13
- --color-green-600: #00A155;
14
- --color-green-700: #007F45;
15
- --color-green-800: #016538;
16
- --color-green-900: #0A5331;
17
- --color-green-950: #052E16;
13
+ --font-sans: 'Inter', sans-serif;
14
+ --font-display: 'Space Grotesk', sans-serif;
15
+ --font-serif: 'Instrument Serif', serif;
16
+ --font-mono: 'JetBrains Mono', monospace;
17
+
18
+ /* arka — brand signal green, anchored at 400 = #00FF88 */
19
+ --color-arka-50: #E8FFF4;
20
+ --color-arka-100: #C6FFE3;
21
+ --color-arka-200: #8FFFC9;
22
+ --color-arka-300: #4DFFAB;
23
+ --color-arka-400: #00FF88;
24
+ --color-arka-500: #00E67A;
25
+ --color-arka-600: #00BF66;
26
+ --color-arka-700: #009951;
27
+ --color-arka-800: #00733D;
28
+ --color-arka-900: #0A5C34;
29
+ --color-arka-950: #04331C;
30
+
31
+ /* carbon — neutral scale with a green undertone (never flat gray) */
32
+ --color-carbon-50: #F6F8F7;
33
+ --color-carbon-100: #E9EDEB;
34
+ --color-carbon-200: #D4DBD7;
35
+ --color-carbon-300: #B3BDB8;
36
+ --color-carbon-400: #8A958F;
37
+ --color-carbon-500: #6B756F;
38
+ --color-carbon-600: #525B56;
39
+ --color-carbon-700: #3E4642;
40
+ --color-carbon-800: #262C29;
41
+ --color-carbon-900: #161B18;
42
+ --color-carbon-950: #0A0D0B;
43
+ }
44
+
45
+ /* ── Motion tokens ──────────────────────────────────────────────────────
46
+ One rhythm for the whole product. Enter uses --arka-ease-out (expo-like
47
+ deceleration), exit is faster (~65%) with --arka-ease-in. The pulse is
48
+ the signature: only elements that are genuinely live may breathe. */
49
+ :root {
50
+ --arka-motion-fast: 150ms;
51
+ --arka-motion-base: 240ms;
52
+ --arka-motion-slow: 400ms;
53
+ --arka-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
54
+ --arka-ease-in: cubic-bezier(0.7, 0, 0.84, 0);
55
+ --arka-pulse-period: 2.4s;
56
+ --arka-stagger-step: 40ms;
57
+ }
58
+
59
+ /* ── Semantic surfaces ──────────────────────────────────────────────────
60
+ Light: paper + ink. Primary darkened to arka-800: 5.72:1 on paper —
61
+ arka-700 reads 3.55:1 and fails AA for text-primary usage. */
62
+ :root {
63
+ --ui-primary: var(--ui-color-primary-800);
64
+ --ui-bg: #FAFAFA;
65
+ --ui-bg-muted: #F1F4F2;
66
+ --ui-bg-elevated: #FFFFFF;
67
+ --ui-bg-accented: var(--color-carbon-100);
68
+ --ui-border: var(--color-carbon-200);
69
+ --ui-border-muted: var(--color-carbon-100);
70
+ --ui-text: #0A0A0A;
71
+ --ui-text-muted: var(--color-carbon-500);
72
+ --arka-glow: 0 0 0 transparent; /* glow is a dark-mode-only voice */
73
+ --arka-grid-line: color-mix(in srgb, var(--color-carbon-200) 60%, transparent);
74
+ }
75
+
76
+ /* Dark (flagship): void → muted → elevated → accented, all carbon-tinted.
77
+ Primary lifts to raw brand green; glow becomes available. */
78
+ .dark {
79
+ --ui-primary: var(--ui-color-primary-400);
80
+ --ui-bg: #070908;
81
+ --ui-bg-muted: #0C100E;
82
+ --ui-bg-elevated: var(--color-carbon-950);
83
+ --ui-bg-accented: var(--color-carbon-900);
84
+ --ui-border: #1E2521;
85
+ --ui-border-muted: #151A17;
86
+ --ui-text: var(--color-carbon-100);
87
+ --ui-text-muted: var(--color-carbon-400);
88
+ --arka-glow: 0 0 24px color-mix(in srgb, var(--color-arka-400) 25%, transparent);
89
+ --arka-grid-line: color-mix(in srgb, var(--color-arka-400) 7%, transparent);
90
+ }
91
+
92
+ /* ── HUD vernacular ─────────────────────────────────────────────────────
93
+ Eyebrow labels: uppercase mono with wide tracking and a signal tick.
94
+ This — not ornament — is what carries the Jarvis DNA. */
95
+ .arka-eyebrow {
96
+ font-family: var(--font-mono);
97
+ font-size: 0.6875rem;
98
+ font-weight: 500;
99
+ letter-spacing: 0.08em;
100
+ text-transform: uppercase;
101
+ color: var(--ui-text-muted);
102
+ display: inline-flex;
103
+ align-items: center;
104
+ gap: 0.5rem;
105
+ }
106
+ .arka-eyebrow::before {
107
+ content: "";
108
+ width: 0.375rem;
109
+ height: 0.375rem;
110
+ background: var(--ui-primary);
111
+ clip-path: polygon(0 0, 100% 0, 100% 100%);
112
+ }
113
+
114
+ /* Live indicator: a dot that breathes. Reserved for genuinely live data. */
115
+ .arka-live-dot {
116
+ width: 0.5rem;
117
+ height: 0.5rem;
118
+ border-radius: 9999px;
119
+ background: var(--ui-primary);
120
+ box-shadow: var(--arka-glow);
121
+ animation: arka-pulse var(--arka-pulse-period) var(--arka-ease-out) infinite;
122
+ }
123
+
124
+ /* Numbers are data: mono, tabular, never shifting layout as they tick. */
125
+ .arka-data {
126
+ font-family: var(--font-mono);
127
+ font-variant-numeric: tabular-nums;
128
+ }
129
+
130
+ @keyframes arka-pulse {
131
+ 0%, 100% { opacity: 1; transform: scale(1); }
132
+ 50% { opacity: 0.55; transform: scale(0.88); }
133
+ }
134
+
135
+ /* Stream-in: how live content arrives. Pair with a per-item
136
+ animation-delay of calc(var(--arka-stagger-step) * i). */
137
+ @keyframes arka-stream-in {
138
+ from { opacity: 0; transform: translateY(6px); }
139
+ to { opacity: 1; transform: translateY(0); }
140
+ }
141
+ .arka-stream-in {
142
+ animation: arka-stream-in var(--arka-motion-base) var(--arka-ease-out) both;
143
+ }
144
+
145
+ /* Pulse line: the signature EKG. One per view, under live sections. */
146
+ .arka-pulse-line {
147
+ height: 2px;
148
+ background: linear-gradient(
149
+ 90deg,
150
+ transparent,
151
+ var(--ui-primary) 20%,
152
+ var(--ui-primary) 80%,
153
+ transparent
154
+ );
155
+ background-size: 200% 100%;
156
+ animation: arka-pulse-sweep 3.2s linear infinite;
157
+ opacity: 0.7;
158
+ }
159
+ @keyframes arka-pulse-sweep {
160
+ from { background-position: 100% 0; }
161
+ to { background-position: -100% 0; }
162
+ }
163
+
164
+ /* ── Editorial layer (Pulse v2) ─────────────────────────────────────────
165
+ The serif voice: cinematic page titles, Roman-numeral section markers,
166
+ italic brand accents. Serif is display-only — body copy never uses it. */
167
+ .arka-serif-title {
168
+ font-family: var(--font-serif);
169
+ font-weight: 400;
170
+ line-height: 1.05;
171
+ letter-spacing: -0.01em;
172
+ color: var(--ui-text);
173
+ }
174
+ .arka-serif-accent {
175
+ font-family: var(--font-serif);
176
+ font-style: italic;
177
+ font-weight: 400;
178
+ }
179
+ .arka-numeral {
180
+ font-family: var(--font-serif);
181
+ font-style: italic;
182
+ font-variant-numeric: tabular-nums;
183
+ color: color-mix(in srgb, var(--ui-primary) 55%, var(--ui-text-muted));
184
+ }
185
+
186
+ /* Gradient-border card. Live cards raise the primary tint; glow stays a
187
+ dark-mode-only voice. Padding-box mask keeps the 1px gradient ring. */
188
+ .arka-glow-card {
189
+ position: relative;
190
+ border-radius: calc(var(--ui-radius, 0.25rem) * 3);
191
+ background: var(--ui-bg-elevated);
192
+ }
193
+ .arka-glow-card::before {
194
+ content: "";
195
+ position: absolute;
196
+ inset: 0;
197
+ border-radius: inherit;
198
+ padding: 1px;
199
+ background: linear-gradient(
200
+ 135deg,
201
+ color-mix(in srgb, var(--ui-primary) 22%, transparent),
202
+ color-mix(in srgb, var(--color-carbon-700) 45%, transparent) 55%,
203
+ color-mix(in srgb, var(--color-carbon-700) 25%, transparent)
204
+ );
205
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
206
+ -webkit-mask-composite: xor;
207
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
208
+ mask-composite: exclude;
209
+ pointer-events: none;
210
+ }
211
+ .arka-glow-card[data-live="true"]::before {
212
+ background: linear-gradient(
213
+ 135deg,
214
+ color-mix(in srgb, var(--ui-primary) 45%, transparent),
215
+ color-mix(in srgb, var(--ui-primary) 10%, transparent)
216
+ );
217
+ }
218
+ .dark .arka-glow-card[data-live="true"] {
219
+ box-shadow: var(--arka-glow);
220
+ }
221
+ .arka-glow-card[data-interactive="true"] {
222
+ transition:
223
+ transform var(--arka-motion-fast) var(--arka-ease-out),
224
+ box-shadow var(--arka-motion-fast) var(--arka-ease-out);
225
+ }
226
+ .arka-glow-card[data-interactive="true"]:hover {
227
+ transform: translateY(-2px);
228
+ }
229
+
230
+ /* Ambient starfield: layered radial dots in slow drift. Effective star
231
+ opacity stays under ~0.06 so it never competes with text contrast. */
232
+ .arka-starfield {
233
+ position: fixed;
234
+ inset: -10%;
235
+ z-index: 0;
236
+ pointer-events: none;
237
+ opacity: 0.5;
238
+ background-image:
239
+ radial-gradient(1px 1px at 12% 24%, color-mix(in srgb, var(--ui-primary) 12%, transparent), transparent 60%),
240
+ radial-gradient(1px 1px at 34% 68%, color-mix(in srgb, var(--ui-text-muted) 10%, transparent), transparent 60%),
241
+ radial-gradient(1.5px 1.5px at 58% 15%, color-mix(in srgb, var(--ui-primary) 9%, transparent), transparent 60%),
242
+ radial-gradient(1px 1px at 71% 47%, color-mix(in srgb, var(--ui-text-muted) 8%, transparent), transparent 60%),
243
+ radial-gradient(1px 1px at 86% 79%, color-mix(in srgb, var(--ui-primary) 10%, transparent), transparent 60%),
244
+ radial-gradient(1.5px 1.5px at 23% 89%, color-mix(in srgb, var(--ui-text-muted) 7%, transparent), transparent 60%),
245
+ radial-gradient(1px 1px at 47% 38%, color-mix(in srgb, var(--ui-text-muted) 9%, transparent), transparent 60%),
246
+ radial-gradient(1px 1px at 92% 8%, color-mix(in srgb, var(--ui-primary) 8%, transparent), transparent 60%);
247
+ background-size: 55% 55%;
248
+ animation: arka-starfield-drift 140s linear infinite alternate;
249
+ }
250
+ @keyframes arka-starfield-drift {
251
+ to { transform: translate3d(-2.5%, -1.5%, 0); }
252
+ }
253
+
254
+ /* Route transitions: one rhythm, upward continuity (enter rises, exit
255
+ recedes). Wired via app.pageTransition in nuxt.config. */
256
+ .arka-page-enter-active {
257
+ transition:
258
+ opacity var(--arka-motion-base) var(--arka-ease-out),
259
+ transform var(--arka-motion-base) var(--arka-ease-out);
260
+ }
261
+ .arka-page-leave-active {
262
+ transition:
263
+ opacity var(--arka-motion-fast) var(--arka-ease-in),
264
+ transform var(--arka-motion-fast) var(--arka-ease-in);
265
+ }
266
+ .arka-page-enter-from {
267
+ opacity: 0;
268
+ transform: translateY(8px);
269
+ }
270
+ .arka-page-leave-to {
271
+ opacity: 0;
272
+ transform: translateY(-4px);
273
+ }
274
+
275
+ @media (prefers-reduced-motion: reduce) {
276
+ .arka-live-dot,
277
+ .arka-pulse-line,
278
+ .arka-stream-in,
279
+ .arka-starfield {
280
+ animation: none;
281
+ }
282
+ .arka-page-enter-active,
283
+ .arka-page-leave-active {
284
+ transition: none;
285
+ }
286
+ .arka-page-enter-from,
287
+ .arka-page-leave-to {
288
+ transform: none;
289
+ }
18
290
  }