@openephemeris/mcp-server 3.17.0 → 3.19.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.
@@ -22,7 +22,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
22
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
23
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
24
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
25
- import { initTools, toolRegistry, formatToolResponse, modelVisibleTools } from "./tools/index.js";
25
+ import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools } from "./tools/index.js";
26
26
  import { BackendClient, runWithClient } from "./backend/client.js";
27
27
  import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
28
28
  import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
@@ -34,6 +34,7 @@ import { oauthDcrRouter } from "./oauth/dcr.js";
34
34
  import { oauthTokenRouter } from "./oauth/token.js";
35
35
  import { OAuthStore } from "./oauth/store.js";
36
36
  import { createRateLimiter } from "./oauth/rate-limit.js";
37
+ import { isJwtExpired, encodeSessionId, replayTargetMachine, } from "./oauth/session-utils.js";
37
38
  // ---------------------------------------------------------------------------
38
39
  // Helpers
39
40
  // ---------------------------------------------------------------------------
@@ -73,9 +74,14 @@ const version = resolveServerVersion();
73
74
  */
74
75
  async function validateApiKey(apiKey) {
75
76
  try {
76
- const resp = await axios.get(`${BACKEND_URL}/ephemeris/moon/phase`, {
77
+ // Use a genuinely free, auth-gated endpoint. /catalogs/bodies is metered at
78
+ // 0 credits (usage_meter.go EstimateUsageUnits) yet still requires auth (NOT
79
+ // in middleware.go IsPublicPath), so an invalid key returns 401/403 while a
80
+ // valid key is validated without charging a credit on every session init.
81
+ // (The previous /ephemeris/moon/phase probe fell through to the default
82
+ // 1-credit charge — connecting cost the user a credit.)
83
+ const resp = await axios.get(`${BACKEND_URL}/catalogs/bodies`, {
77
84
  headers: { "X-API-Key": apiKey },
78
- params: { datetime: new Date().toISOString() }, // required param per OpenAPI spec
79
85
  timeout: 5_000,
80
86
  validateStatus: () => true, // don't throw on any status
81
87
  });
@@ -279,25 +285,22 @@ function createMcpServer() {
279
285
  try {
280
286
  const startTime = Date.now();
281
287
  console.error(`[MCP] Executing tool: ${toolName}`);
282
- const result = await tool.handler(request.params.arguments ?? {});
288
+ // Bound execution so a hung backend request can't pin the session forever.
289
+ // 120s comfortably covers the slowest known endpoint (cold heatmap
290
+ // composite ~9.3s) with a wide margin.
291
+ const result = await withTimeout(tool.handler(request.params.arguments ?? {}), TOOL_CALL_TIMEOUT_MS, toolName);
283
292
  const durationMs = Date.now() - startTime;
284
293
  return formatToolResponse(toolName, result, durationMs);
285
294
  }
286
295
  catch (error) {
287
- const startTime = Date.now(); // Not strictly accurate for errors but acceptable for logging structure if needed
288
- const durationMs = Date.now() - startTime;
289
296
  const errorMessage = error instanceof Error ? error.message : String(error);
290
- const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
291
- console.error(`[MCP] Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
292
- return {
293
- content: [
294
- {
295
- type: "text",
296
- text: `Tool execution failed:\n\n${errorMessage}\n\nPlease read the error above carefully and assist the user (e.g. by providing them the auth link or explaining the issue).`,
297
- },
298
- ],
299
- isError: !isRetryable,
300
- };
297
+ console.error(`[MCP] Failed: ${toolName} - ${errorMessage}`);
298
+ // All failures surface as isError: true so the host can recover
299
+ // (retry / OAuth refresh). The remote transport has no device-auth flow,
300
+ // so formatToolError's device-auth exception never fires here — a 401
301
+ // (including the "disconnect and reconnect" message) renders as a real
302
+ // error and prompts the host's OAuth refresh.
303
+ return formatToolError(error);
301
304
  }
302
305
  });
303
306
  // --- Resource handlers ---
@@ -370,6 +373,51 @@ function createMcpServer() {
370
373
  return server;
371
374
  }
372
375
  const httpSessions = new Map();
376
+ // Sessions are held in memory and only freed on explicit DELETE / transport
377
+ // close. Hosts that vanish without teardown (tab closed, network drop) would
378
+ // otherwise leak sessions until process restart, so reap anything idle past
379
+ // the TTL. A reaped session 404s on its next request and the client
380
+ // re-initializes per the MCP spec.
381
+ const SESSION_IDLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h
382
+ const TOOL_CALL_TIMEOUT_MS = 120_000;
383
+ function withTimeout(promise, ms, label) {
384
+ return new Promise((resolve, reject) => {
385
+ const timer = setTimeout(() => reject(new Error(`Tool '${label}' timed out after ${ms / 1000}s`)), ms);
386
+ promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
387
+ });
388
+ }
389
+ function reapIdleSessions() {
390
+ const cutoff = Date.now() - SESSION_IDLE_TTL_MS;
391
+ for (const [id, session] of httpSessions) {
392
+ if (session.lastSeen < cutoff) {
393
+ httpSessions.delete(id);
394
+ session.server.close().catch(() => { });
395
+ console.error(`[HTTP] Session reaped (idle > ${SESSION_IDLE_TTL_MS / 60000}m): ${id}`);
396
+ }
397
+ }
398
+ }
399
+ /**
400
+ * Keep a long-lived SSE response alive through Fly's proxy and any
401
+ * intermediaries: disable the socket idle timeout and emit an SSE comment
402
+ * frame every 25s (comment frames are ignored by EventSource parsers).
403
+ * Call only after the transport has sent the SSE headers.
404
+ */
405
+ function startSseKeepalive(res) {
406
+ res.socket?.setTimeout(0);
407
+ const ping = setInterval(() => {
408
+ if (res.writableEnded || res.destroyed) {
409
+ clearInterval(ping);
410
+ return;
411
+ }
412
+ try {
413
+ res.write(":ping\n\n");
414
+ }
415
+ catch {
416
+ clearInterval(ping);
417
+ }
418
+ }, 25_000);
419
+ res.on("close", () => clearInterval(ping));
420
+ }
373
421
  async function main() {
374
422
  await initTools();
375
423
  const app = express();
@@ -391,6 +439,36 @@ async function main() {
391
439
  function isOriginAllowed(origin) {
392
440
  return ORIGIN_ALLOWLIST.some((m) => typeof m === "string" ? m === origin : m.test(origin));
393
441
  }
442
+ // DNS-rebinding defense: Origin validation alone doesn't cover the Host
443
+ // header, which is the vector rebinding attacks use. Restrict the MCP
444
+ // endpoints to the hosts we actually serve. Override/extend with
445
+ // MCP_ALLOWED_HOSTS (comma-separated) if the deployment hostname changes.
446
+ const HOST_ALLOWLIST = [
447
+ /^mcp\.openephemeris\.com$/i,
448
+ /^oe-mcp-live\.fly\.dev$/i,
449
+ /^localhost(:\d+)?$/i,
450
+ /^127\.0\.0\.1(:\d+)?$/i,
451
+ /^\[::1\](:\d+)?$/i,
452
+ ...(process.env.MCP_ALLOWED_HOSTS ?? "")
453
+ .split(",")
454
+ .map((h) => h.trim())
455
+ .filter(Boolean)
456
+ .map((h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(:\\d+)?$`, "i")),
457
+ ];
458
+ const MCP_ROUTES = new Set(["/mcp", "/sse", "/message"]);
459
+ app.use((req, res, next) => {
460
+ if (MCP_ROUTES.has(req.path)) {
461
+ const host = req.headers.host;
462
+ if (host && !HOST_ALLOWLIST.some((m) => m.test(host))) {
463
+ res.status(403).json({
464
+ error: "host_not_allowed",
465
+ error_description: `Host '${host}' is not permitted.`,
466
+ });
467
+ return;
468
+ }
469
+ }
470
+ next();
471
+ });
394
472
  app.use((req, res, next) => {
395
473
  const origin = req.headers.origin;
396
474
  // Browser request → must match allowlist. Native client (no Origin) → allow.
@@ -515,12 +593,53 @@ async function main() {
515
593
  if (sessionId) {
516
594
  const session = httpSessions.get(sessionId);
517
595
  if (!session) {
596
+ // Cross-machine session recovery (Fly): min_machines_running >= 2 but
597
+ // httpSessions is per-process. If the session id encodes a DIFFERENT
598
+ // machine, ask Fly's proxy to replay this request to the owning machine
599
+ // — unless it was already replayed once (loop guard), in which case we
600
+ // fall through to 404 so the client re-initializes per the MCP spec.
601
+ const target = replayTargetMachine(sessionId, {
602
+ alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
603
+ });
604
+ if (target) {
605
+ res.set("fly-replay", `instance=${target}`);
606
+ res.status(307).end();
607
+ return;
608
+ }
518
609
  res.status(404).json({
519
610
  error: "session_not_found",
520
611
  message: "Unknown or expired MCP session. Re-initialize to start a new one.",
521
612
  });
522
613
  return;
523
614
  }
615
+ // A session id alone is not a credential: require the caller to still
616
+ // present a key/JWT on every resume, and reject an expired JWT with the
617
+ // OAuth-refresh signal (same semantics as the init gate below).
618
+ const resumedAuth = extractAuth(req);
619
+ if (!resumedAuth.apiKey && !resumedAuth.jwt) {
620
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
621
+ res.status(401).json({
622
+ error: "auth_required",
623
+ message: "Authentication required on every request. Pass an API key via X-API-Key header, or a Bearer token via Authorization header.",
624
+ });
625
+ return;
626
+ }
627
+ if (resumedAuth.jwt && isJwtExpired(resumedAuth.jwt)) {
628
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
629
+ res.status(401).json({
630
+ error: "invalid_token",
631
+ message: "Your session token has expired. Re-authorize to continue.",
632
+ });
633
+ return;
634
+ }
635
+ // Refresh the session's Bearer JWT if the host rotated it. claude.ai
636
+ // refreshes tokens via /oauth/token roughly hourly (self-signed Supabase
637
+ // JWTs expire in 1h); the frozen JWT captured at init would otherwise 401
638
+ // forever. Re-read the header and push the fresh token into the client.
639
+ if (resumedAuth.jwt) {
640
+ session.client.setJwt(resumedAuth.jwt);
641
+ }
642
+ session.lastSeen = Date.now();
524
643
  await runWithClient(session.client, () => session.transport.handleRequest(req, res, req.body));
525
644
  return;
526
645
  }
@@ -538,6 +657,19 @@ async function main() {
538
657
  });
539
658
  return;
540
659
  }
660
+ // Reject already-expired JWTs at the gate with an OAuth-refresh signal.
661
+ // Signature is validated downstream by the Go API — here we only decode the
662
+ // `exp` claim (no verification) so an expired token triggers the host's
663
+ // token refresh (401 invalid_token) instead of dying as a confusing tool
664
+ // error mid-session.
665
+ if (jwt && isJwtExpired(jwt)) {
666
+ res.set("WWW-Authenticate", `Bearer realm="OpenEphemeris MCP", error="invalid_token", error_description="The access token expired", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`);
667
+ res.status(401).json({
668
+ error: "invalid_token",
669
+ message: "Your session token has expired. Re-authorize to continue.",
670
+ });
671
+ return;
672
+ }
541
673
  // Validate API keys against the Go backend; JWTs are validated downstream
542
674
  // by the Go sidecar's ValidateSupabaseJWT when tool calls are proxied.
543
675
  if (apiKey) {
@@ -570,9 +702,12 @@ async function main() {
570
702
  }
571
703
  // Create per-session transport, server, and client
572
704
  const transport = new StreamableHTTPServerTransport({
573
- sessionIdGenerator: randomUUID,
705
+ // Embed the Fly machine id so a resume request landing on another machine
706
+ // can be replayed to the owner (see the resume branch above). No-op off
707
+ // Fly (FLY_MACHINE_ID unset) — the raw UUID is used unchanged.
708
+ sessionIdGenerator: () => encodeSessionId(randomUUID()),
574
709
  onsessioninitialized: (id) => {
575
- httpSessions.set(id, { server, transport, client });
710
+ httpSessions.set(id, { server, transport, client, lastSeen: Date.now() });
576
711
  console.error(`[HTTP] Session initialized: ${id}`);
577
712
  },
578
713
  });
@@ -599,12 +734,24 @@ async function main() {
599
734
  }
600
735
  const session = httpSessions.get(sessionId);
601
736
  if (!session) {
737
+ // Cross-machine replay for the SSE leg (same scheme as POST /mcp resume).
738
+ const target = replayTargetMachine(sessionId, {
739
+ alreadyReplayed: typeof req.headers["fly-replay-src"] === "string",
740
+ });
741
+ if (target) {
742
+ res.set("fly-replay", `instance=${target}`);
743
+ res.status(307).end();
744
+ return;
745
+ }
602
746
  res.status(404).json({
603
747
  error: "session_not_found",
604
748
  message: "Unknown or expired MCP session.",
605
749
  });
606
750
  return;
607
751
  }
752
+ session.lastSeen = Date.now();
753
+ // The SSE leg stays open indefinitely — keep it alive through proxies.
754
+ startSseKeepalive(res);
608
755
  await session.transport.handleRequest(req, res);
609
756
  });
610
757
  // DELETE /mcp — client-initiated session teardown
@@ -715,6 +862,8 @@ async function main() {
715
862
  try {
716
863
  // server.connect() calls transport.start() internally in SDK v1.27+
717
864
  await server.connect(transport);
865
+ // Stream is established — keep it alive through Fly's proxy.
866
+ startSseKeepalive(res);
718
867
  }
719
868
  catch (err) {
720
869
  console.error(`SSE session ${sessionId} failed to start:`, err);
@@ -742,9 +891,17 @@ async function main() {
742
891
  await transport.handlePostMessage(req, res);
743
892
  }
744
893
  });
745
- app.listen(PORT, "0.0.0.0", () => {
894
+ // Reap abandoned sessions (no DELETE, no transport close) every 10 minutes.
895
+ setInterval(reapIdleSessions, 10 * 60 * 1000).unref();
896
+ const httpServer = app.listen(PORT, "0.0.0.0", () => {
746
897
  console.log(`OpenEphemeris MCP SSE server v${version} listening on port ${PORT}`);
747
898
  });
899
+ // SSE streams are long-lived by design; Node 18+ closes idle connections
900
+ // after 5s keep-alive by default at the server level — the per-response
901
+ // socket timeout is disabled in startSseKeepalive, but also raise the
902
+ // server-wide headers/request timeouts so slow proxies don't get cut.
903
+ httpServer.keepAliveTimeout = 65_000;
904
+ httpServer.headersTimeout = 70_000;
748
905
  }
749
906
  // Prevent SDK recursive close bugs from crashing the process
750
907
  process.on("uncaughtException", (err) => {
@@ -755,6 +912,13 @@ process.on("uncaughtException", (err) => {
755
912
  console.error("Uncaught exception:", err);
756
913
  process.exit(1);
757
914
  });
915
+ // Log-and-continue on unhandled promise rejections. A rejected promise from a
916
+ // single request (e.g. a client that disconnected mid-stream) must never take
917
+ // down the whole server and kill every other live session. Log for triage and
918
+ // keep serving.
919
+ process.on("unhandledRejection", (reason) => {
920
+ console.error("Unhandled promise rejection:", reason);
921
+ });
758
922
  main().catch((error) => {
759
923
  console.error("Fatal error:", error);
760
924
  process.exit(1);
package/dist/tools/dev.js CHANGED
@@ -73,7 +73,7 @@ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.open
73
73
  " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
74
74
  " POST /acg/hits — ACG power at a specific location\n" +
75
75
  " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
76
- " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
76
+ " GET /location/autocomplete — Geocode a place name (query: query=City Name)\n" +
77
77
  " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
78
78
  " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
79
79
  " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
@@ -73,3 +73,28 @@ export declare function validateCoordinates(args: any, latKey: string, lonKey: s
73
73
  * Applies a safety limit to JSON payloads to prevent crashing LLM contexts.
74
74
  */
75
75
  export declare function formatToolResponse(toolName: string, result: any, durationMs: number): any;
76
+ /**
77
+ * Decide whether a thrown tool error is the stdio device-auth-pending case,
78
+ * which must NOT be flagged isError so the model relays the verification link.
79
+ *
80
+ * Everything else — 429, 5xx, network, plain auth failures — is a real error
81
+ * and MUST report isError: true so the host recovers (retry / OAuth refresh)
82
+ * instead of treating a failure as a silent success.
83
+ *
84
+ * Device-auth-pending is uniquely identified by a BackendError with
85
+ * code === "auth_required" AND retryable === true (set only when the background
86
+ * device-auth flow is live and its message carries a verification link). The
87
+ * plain "disconnect and reconnect" 401 is retryable === false → a real error.
88
+ */
89
+ export declare function isDeviceAuthPendingError(error: unknown): boolean;
90
+ /**
91
+ * Build the MCP error content block for a failed tool call. Shared by the stdio
92
+ * and SSE/HTTP CallTool handlers so the isError policy stays consistent.
93
+ */
94
+ export declare function formatToolError(error: unknown): {
95
+ content: {
96
+ type: "text";
97
+ text: string;
98
+ }[];
99
+ isError: boolean;
100
+ };
@@ -216,3 +216,36 @@ export function formatToolResponse(toolName, result, durationMs) {
216
216
  ],
217
217
  };
218
218
  }
219
+ /**
220
+ * Decide whether a thrown tool error is the stdio device-auth-pending case,
221
+ * which must NOT be flagged isError so the model relays the verification link.
222
+ *
223
+ * Everything else — 429, 5xx, network, plain auth failures — is a real error
224
+ * and MUST report isError: true so the host recovers (retry / OAuth refresh)
225
+ * instead of treating a failure as a silent success.
226
+ *
227
+ * Device-auth-pending is uniquely identified by a BackendError with
228
+ * code === "auth_required" AND retryable === true (set only when the background
229
+ * device-auth flow is live and its message carries a verification link). The
230
+ * plain "disconnect and reconnect" 401 is retryable === false → a real error.
231
+ */
232
+ export function isDeviceAuthPendingError(error) {
233
+ const e = error;
234
+ return !!e && e.code === "auth_required" && e.retryable === true;
235
+ }
236
+ /**
237
+ * Build the MCP error content block for a failed tool call. Shared by the stdio
238
+ * and SSE/HTTP CallTool handlers so the isError policy stays consistent.
239
+ */
240
+ export function formatToolError(error) {
241
+ const errorMessage = error instanceof Error ? error.message : String(error);
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: `Tool execution failed:\n\n${errorMessage}\n\nPlease read the error above carefully and assist the user (e.g. by providing them the auth link or explaining the issue).`,
247
+ },
248
+ ],
249
+ isError: !isDeviceAuthPendingError(error),
250
+ };
251
+ }
@@ -2,7 +2,7 @@
2
2
  * output-schemas.ts — Shared JSON Schema output schema definitions for MCP tools.
3
3
  *
4
4
  * MCP spec 2025-11-25 supports `outputSchema` on tool definitions. Smithery
5
- * uses this field to score the "Output schemas" quality criterion (10.37pt / 57 tools).
5
+ * uses this field to score the "Output schemas" quality criterion (scored per registered tool; count guarded by test/allowlist-and-tools.test.ts).
6
6
  *
7
7
  * All tools return an MCP content block response. Most return a single JSON text
8
8
  * block; visual tools return an image block or an image + text block.
@@ -2,7 +2,7 @@
2
2
  * output-schemas.ts — Shared JSON Schema output schema definitions for MCP tools.
3
3
  *
4
4
  * MCP spec 2025-11-25 supports `outputSchema` on tool definitions. Smithery
5
- * uses this field to score the "Output schemas" quality criterion (10.37pt / 57 tools).
5
+ * uses this field to score the "Output schemas" quality criterion (scored per registered tool; count guarded by test/allowlist-and-tools.test.ts).
6
6
  *
7
7
  * All tools return an MCP content block response. Most return a single JSON text
8
8
  * block; visual tools return an image block or an image + text block.
@@ -24,7 +24,9 @@ registerTool({
24
24
  properties: {
25
25
  birth_datetime: {
26
26
  type: "string",
27
- description: "ISO 8601 natal birth date/time.",
27
+ description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
28
+ "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
29
+ "interpreted as UTC and will place the ACG lines in the wrong location.",
28
30
  },
29
31
  birth_latitude: {
30
32
  type: "number",
@@ -114,7 +116,9 @@ registerTool({
114
116
  properties: {
115
117
  birth_datetime: {
116
118
  type: "string",
117
- description: "ISO 8601 natal birth date/time.",
119
+ description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
120
+ "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
121
+ "interpreted as UTC and will place the ACG lines in the wrong location.",
118
122
  },
119
123
  birth_latitude: {
120
124
  type: "number",
@@ -15,7 +15,9 @@ registerTool({
15
15
  properties: {
16
16
  birth_datetime: {
17
17
  type: "string",
18
- description: "ISO 8601 natal birth date/time.",
18
+ description: "ISO 8601 natal birth date/time. MUST include a UTC offset (or 'Z'), " +
19
+ "e.g. '1990-05-15T14:30:00-05:00'. A naive datetime (no offset) is " +
20
+ "interpreted as UTC and will shift the progressed positions.",
19
21
  },
20
22
  birth_latitude: {
21
23
  type: "number",
@@ -7535,32 +7535,29 @@ function Zv(e4) {
7535
7535
  n2(e5);
7536
7536
  });
7537
7537
  }
7538
- var Qv = document.getElementById("loading-state"), $v = document.getElementById("chart-container"), ey = document.getElementById("chart-info"), ty = document.getElementById("chart-status"), ny = document.getElementById("controls-panel"), ry = document.getElementById("patterns-strip"), iy = document.getElementById("aspect-filter"), ay = document.getElementById("overview-table"), oy = document.getElementById("param-controls-container");
7539
- console.log("[ChartWheel] \u{1F680} main.ts loaded, creating App instance...");
7540
- var sy = new r_({ name: "Chart Wheel Explorer", version: "1.0.0" }), $ = null, cy = "placidus", ly = null, uy = false, dy = false;
7538
+ var Qv = document.getElementById("loading-state"), $v = document.getElementById("chart-container"), ey = document.getElementById("chart-info"), ty = document.getElementById("chart-status"), ny = document.getElementById("controls-panel"), ry = document.getElementById("patterns-strip"), iy = document.getElementById("aspect-filter"), ay = document.getElementById("overview-table"), oy = document.getElementById("param-controls-container"), sy = new r_({ name: "Chart Wheel Explorer", version: "1.0.0" }), $ = null, cy = "placidus", ly = null, uy = false, dy = false;
7541
7539
  function fy(e4) {
7542
7540
  ty && (e4 ? (ty.textContent = "\xB7 ".concat(e4), ty.setAttribute("data-active", "")) : (ty.textContent = "", ty.removeAttribute("data-active")));
7543
7541
  }
7544
7542
  sy.ontoolresult = (e4) => {
7545
- var t2, n2, r2, i2, a2;
7546
- console.log("[ChartWheel] \u{1F4E6} ontoolresult received!", { contentLength: (t2 = (n2 = e4.content) == null ? void 0 : n2.length) == null ? 0 : t2, contentTypes: (r2 = (i2 = e4.content) == null ? void 0 : i2.map((e5) => e5.type)) == null ? [] : r2 });
7547
- let o2 = Gv(e4);
7548
- if (Fy(), !o2 || !o2.planets) {
7543
+ var t2;
7544
+ let n2 = Gv(e4);
7545
+ if (Fy(), !n2 || !n2.planets) {
7549
7546
  console.error("[ChartWheel] \u274C No chart data found in tool result"), Iy("Chart data could not be parsed from the server response.", Ly);
7550
7547
  return;
7551
7548
  }
7552
- if ($ = o2, cy = (a2 = o2.house_system) == null ? "placidus" : a2, !ly) {
7553
- var s2, c2, l2, u2, d2, f2, p2, m2, h2, g2;
7554
- let e5 = (s2 = o2._birth_params) == null ? {} : s2, t3 = (c2 = (l2 = (u2 = e5.datetime) == null ? o2.datetime : u2) == null ? o2.birth_datetime : l2) == null ? "" : c2, n3 = (d2 = (f2 = e5.location) == null ? o2.location : f2) == null ? "" : d2, r3 = (p2 = (m2 = e5.timezone) == null ? o2.timezone : m2) == null ? "" : p2, i3 = (h2 = e5.latitude) == null ? o2.latitude : h2, a3 = (g2 = e5.longitude) == null ? o2.longitude : g2, _2 = "", v2 = "12:00";
7549
+ if ($ = n2, cy = (t2 = n2.house_system) == null ? "placidus" : t2, !ly) {
7550
+ var r2, i2, a2, o2, s2, c2, l2, u2, d2, f2;
7551
+ let e5 = (r2 = n2._birth_params) == null ? {} : r2, t3 = (i2 = (a2 = (o2 = e5.datetime) == null ? n2.datetime : o2) == null ? n2.birth_datetime : a2) == null ? "" : i2, p2 = (s2 = (c2 = e5.location) == null ? n2.location : c2) == null ? "" : s2, m2 = (l2 = (u2 = e5.timezone) == null ? n2.timezone : u2) == null ? "" : l2, h2 = (d2 = e5.latitude) == null ? n2.latitude : d2, g2 = (f2 = e5.longitude) == null ? n2.longitude : f2, _2 = "", v2 = "12:00";
7555
7552
  if (t3 && t3.includes("T")) {
7556
7553
  let e6 = t3.split("T");
7557
7554
  _2 = e6[0], v2 = e6[1].substring(0, 5);
7558
7555
  }
7559
- ly = new Iv({ app: sy, container: oy, initialParams: { date: _2, time: v2, locationName: n3, timezone: r3, latitude: typeof i3 == "number" ? i3 : null, longitude: typeof a3 == "number" ? a3 : null }, showStepControls: false, onRefresh: async (e6) => {
7556
+ ly = new Iv({ app: sy, container: oy, initialParams: { date: _2, time: v2, locationName: p2, timezone: m2, latitude: typeof h2 == "number" ? h2 : null, longitude: typeof g2 == "number" ? g2 : null }, showStepControls: false, onRefresh: async (e6) => {
7560
7557
  ly == null || ly.setBusy(true), Qv.removeAttribute("hidden"), $v.setAttribute("hidden", "");
7561
7558
  try {
7562
- var t4, n4, r4, i4;
7563
- let a4 = "".concat(e6.date, "T").concat(e6.time, ":00"), o3 = new Promise((e7, t5) => setTimeout(() => t5(Error("Recalculate timed out after 30s")), 3e4)), s3 = Gv(await Promise.race([sy.callServerTool({ name: "chart_wheel_recalculate", arguments: { datetime: "".concat(a4).concat(e6.timezone ? "" : "Z"), latitude: (t4 = e6.latitude) == null ? void 0 : t4, longitude: (n4 = e6.longitude) == null ? void 0 : n4, timezone: (r4 = e6.timezone) == null ? void 0 : r4, location: (i4 = e6.locationName) == null ? void 0 : i4, house_system: cy || "placidus", chart_type: "natal" } }), o3]));
7559
+ var t4, n3, r3, i3;
7560
+ let a3 = "".concat(e6.date, "T").concat(e6.time, ":00"), o3 = new Promise((e7, t5) => setTimeout(() => t5(Error("Recalculate timed out after 30s")), 3e4)), s3 = Gv(await Promise.race([sy.callServerTool({ name: "chart_wheel_recalculate", arguments: { datetime: "".concat(a3).concat(e6.timezone ? "" : "Z"), latitude: (t4 = e6.latitude) == null ? void 0 : t4, longitude: (n3 = e6.longitude) == null ? void 0 : n3, timezone: (r3 = e6.timezone) == null ? void 0 : r3, location: (i3 = e6.locationName) == null ? void 0 : i3, house_system: cy || "placidus", chart_type: "natal" } }), o3]));
7564
7561
  s3 ? ($ = s3, s3.house_system && (cy = s3.house_system), py(s3)) : $ && (Qv.setAttribute("hidden", ""), $v.removeAttribute("hidden"));
7565
7562
  } catch (e7) {
7566
7563
  console.error("[ChartWheel] Recalculate failed or timed out:", e7), $ && (Qv.setAttribute("hidden", ""), $v.removeAttribute("hidden"));
@@ -7569,7 +7566,7 @@ sy.ontoolresult = (e4) => {
7569
7566
  }
7570
7567
  } });
7571
7568
  }
7572
- console.log("[ChartWheel] \u{1F3A8} Calling renderChart..."), py(o2), console.log("[ChartWheel] \u2705 renderChart completed");
7569
+ py(n2);
7573
7570
  };
7574
7571
  function py(e4) {
7575
7572
  var t2, n2, r2, i2, a2, o2, s2;
@@ -7749,7 +7746,7 @@ function Cy() {
7749
7746
  async function wy(e4, t2) {
7750
7747
  if (uy) try {
7751
7748
  let n2 = "[Chart Wheel] Selected ".concat(e4, " \u2014 ").concat(Object.entries(t2).filter(([, e5]) => e5 != null && e5 !== false && e5 !== "").map(([e5, t3]) => "".concat(e5, ": ").concat(t3)).join(", "));
7752
- await sy.updateModelContext({ content: [{ type: "text", text: n2 }] }), console.log("[ChartWheel] \u{1F4CB} Model context updated: ".concat(e4));
7749
+ await sy.updateModelContext({ content: [{ type: "text", text: n2 }] });
7753
7750
  } catch (e5) {
7754
7751
  console.warn("[ChartWheel] \u26A0\uFE0F updateModelContext failed:", e5);
7755
7752
  }
@@ -7842,10 +7839,8 @@ async function My(e4) {
7842
7839
  t2 = "Interpret the ".concat(n3.angle, " at ").concat(n3.position, " (").concat(n3.sign, " \xB7 ").concat(n3.element, " \xB7 ").concat(n3.modality, ") in this natal chart. Give a focused interpretation of what this angle represents for the native \u2014 identity, life direction, relationships, or roots depending on which angle \u2014 and how the sign on the angle shapes that expression.");
7843
7840
  }
7844
7841
  if (t2) try {
7845
- console.log("[ChartWheel] \u{1F4AC} Sending interpretation request to Claude...");
7846
7842
  let e5 = await sy.sendMessage({ role: "user", content: [{ type: "text", text: t2 }] });
7847
7843
  if (e5.isError) throw console.error("[ChartWheel] \u274C Host rejected sendMessage:", e5), Error("Host rejected message");
7848
- console.log("[ChartWheel] \u2705 Message sent to chat thread");
7849
7844
  } catch (e5) {
7850
7845
  throw console.error("[ChartWheel] \u274C sendMessage failed:", e5), e5;
7851
7846
  }
@@ -7878,11 +7873,11 @@ function Ly() {
7878
7873
  }
7879
7874
  Yv(sy, { loadingEl: Qv, contentEl: $v, statusFn: fy, paramControls: ly }), Py = setTimeout(() => {
7880
7875
  Iy("Chart data is taking longer than expected.", Ly);
7881
- }, 3e4), console.log("[ChartWheel] \u{1F50C} Calling app.connect()..."), (async () => {
7876
+ }, 3e4), (async () => {
7882
7877
  try {
7883
- await sy.connect(), console.log("[ChartWheel] \u2705 app.connect() completed \u2014 handshake OK");
7878
+ await sy.connect();
7884
7879
  let e4 = sy.getHostCapabilities();
7885
- uy = !!(e4 != null && e4.modelContext), dy = !!(e4 != null && e4.sampling), console.log("[ChartWheel] \u{1F50D} Host capabilities:", { modelContext: uy, serverTools: !!(e4 != null && e4.serverTools), sampling: dy }), Jv(sy, document.getElementById("pan-zoom-controls"), ["aspect-filter", "controls-panel", "param-controls-container", "chart-info", "patterns-strip", "info-panel", "cw-action-bar"]), Zv(sy), $ && (console.log("[ChartWheel] \u{1F504} Re-rendering with interpretation mode enabled"), py($));
7880
+ uy = !!(e4 != null && e4.modelContext), dy = !!(e4 != null && e4.sampling), Jv(sy, document.getElementById("pan-zoom-controls"), ["aspect-filter", "controls-panel", "param-controls-container", "chart-info", "patterns-strip", "info-panel", "cw-action-bar"]), Zv(sy), $ && py($);
7886
7881
  } catch (e4) {
7887
7882
  console.error("[ChartWheel] \u274C app.connect() FAILED:", e4), Iy("Connection failed: ".concat(e4 instanceof Error ? e4.message : String(e4)));
7888
7883
  }
@@ -1,33 +1,33 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="color-scheme" content="dark light" />
7
- <meta name="mcp-app-preferred-height" content="560" />
8
- <title>Moon Phase Explorer</title>
9
- <!-- External Google Fonts removed: Claude.ai sandbox enforces hardcoded
10
- frame-src 'self' blob: data: and blocks external CDN fetches at
11
- iframe render time. Styles fall back to the system font stack. -->
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <meta name="mcp-app-preferred-height" content="560" />
8
+ <title>Moon Phase Explorer</title>
9
+ <!-- External Google Fonts removed: Claude.ai sandbox enforces hardcoded
10
+ frame-src 'self' blob: data: and blocks external CDN fetches at
11
+ iframe render time. Styles fall back to the system font stack. -->
12
12
 
13
13
 
14
14
  <style>*,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg-primary:#090b0f;--bg-secondary:#12161d;--bg-tertiary:#1c222b;--bg-hover:#272e3b;--border:#282e38;--border-subtle:#1d2229;--text-primary:#e1e5eb;--text-secondary:#9da5b1;--text-muted:#6c727b;--accent:#62b3ff;--accent-warm:#e6ac3d;--voc-warn:#f6922e;--cue-warm:#f4a34b;--cue-cool:#6ab3fd;--radius:8px;--radius-sm:4px;--radius-lg:12px;--font-display:"Space Grotesk", system-ui, sans-serif;--font-body:"Inter", system-ui, sans-serif;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}@supports (color:color(display-p3 0 0 0)){:root{--bg-primary:color(display-p3 .036047 .0438664 .059078);--bg-secondary:color(display-p3 .0739679 .0866504 .111186);--bg-tertiary:color(display-p3 .114361 .13221 .166818);--bg-hover:color(display-p3 .15679 .180114 .225406);--border:color(display-p3 .161376 .180145 .216325);--border-subtle:color(display-p3 .118728 .13221 .158123);--text-primary:color(display-p3 .883958 .896627 .920543);--text-secondary:color(display-p3 .622745 .646433 .6914);--text-muted:color(display-p3 .429297 .445909 .477439);--accent:color(display-p3 .446063 .694439 1.01077);--accent-warm:color(display-p3 .866978 .684443 .324483);--voc-warn:color(display-p3 .910067 .591092 .274641);--cue-warm:color(display-p3 .911412 .653072 .360243);--cue-cool:color(display-p3 .48245 .695507 .967802)}}@supports (color:lab(0% 0 0)){:root{--bg-primary:lab(3.02674% -.179462 -2.15476);--bg-secondary:lab(7.16596% -.441141 -5.41423);--bg-tertiary:lab(12.9118% -.60384 -7.27841);--bg-hover:lab(18.6882% -.727877 -9.08878);--border:lab(18.7165% -.689313 -7.30941);--border-subtle:lab(12.9391% -.548743 -5.49436);--text-primary:lab(90.6878% -.517368 -3.73452);--text-secondary:lab(67.45% -.932217 -7.41489);--text-muted:lab(47.7476% -.701487 -5.56231);--accent:lab(70.4043% -7.21234 -50.7672);--accent-warm:lab(74.5248% 14.3487 62.5064);--voc-warn:lab(70.3313% 33.71 65.3029);--cue-warm:lab(74.0849% 25.4197 56.7058);--cue-cool:lab(70.5454% -7.4636 -44.2057)}}@media (prefers-color-scheme:light){:root{--bg-primary:#f3f5f9;--bg-secondary:#e7ebf2;--bg-tertiary:#d8dee8;--bg-hover:#d0d8e5;--border:#bdc5d1;--border-subtle:#d2d8e2;--text-primary:#0f1216;--text-secondary:#414853;--text-muted:#585e67;--accent:#0065b2;--accent-warm:#966800;--voc-warn:#a95a00;--cue-warm:#954200;--cue-cool:#1e5ba1;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}@supports (color:color(display-p3 0 0 0)){:root{--bg-primary:color(display-p3 .95471 .961142 .973263);--bg-secondary:color(display-p3 .909797 .922535 .946581);--bg-tertiary:color(display-p3 .852455 .871315 .906983);--bg-hover:color(display-p3 .821139 .84609 .893374);--border:color(display-p3 .745559 .770059 .816515);--border-subtle:color(display-p3 .826933 .845684 .881152);--text-primary:color(display-p3 .0608605 .0691527 .0850621);--text-secondary:color(display-p3 .261513 .281801 .32064);--text-muted:color(display-p3 .350602 .367691 .400197);--accent:color(display-p3 .143802 .388951 .683672);--accent-warm:color(display-p3 .596632 .396992 -.158844);--voc-warn:color(display-p3 .673365 .329149 -.157701);--cue-warm:color(display-p3 .567457 .255699 -.0353967);--cue-cool:color(display-p3 .186321 .349931 .613332)}}@supports (color:lab(0% 0 0)){:root{--bg-primary:lab(96.5044% -.267148 -1.87216);--bg-secondary:lab(93.0079% -.518024 -3.73493);--bg-tertiary:lab(88.3503% -.749856 -5.58736);--bg-hover:lab(86.0117% -.963002 -7.43048);--border:lab(79.0511% -.952899 -7.42528);--border-subtle:lab(86.0302% -.748128 -5.58645);--text-primary:lab(5.23796% -.285208 -3.0582);--text-secondary:lab(30.3224% -.794604 -7.35167);--text-muted:lab(39.6225% -.718519 -5.91861);--accent:lab(41.1205% -1.70796 -50.1423);--accent-warm:lab(47.6194% 20.1868 104.51);--voc-warn:lab(46.8395% 41.0975 106.287);--cue-warm:lab(38.6598% 39.325 67.0649);--cue-cool:lab(37.6228% 1.24846 -44.7496)}}}html,body{width:100%;height:100%;min-height:480px;font-family:var(--font-body);background:var(--bg-primary);color:var(--text-primary);font-size:13px;line-height:1.5;overflow-y:auto}body{flex-direction:column;display:flex}#app{flex-direction:column;flex:1;display:flex;overflow:hidden}#loading-state{color:var(--text-secondary);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:12px;display:flex}#loading-state[hidden]{display:none}.oe-loader{width:48px;height:48px;animation:1.2s linear infinite spin}.oe-loader circle{fill:none;stroke:var(--accent);stroke-width:3px;stroke-dasharray:200;stroke-dashoffset:80px;stroke-linecap:round}@keyframes spin{to{transform:rotate(360deg)}}.chart-status{background:var(--bg-secondary);border-top:1px solid var(--border-subtle);color:var(--text-muted);opacity:0;z-index:50;padding:6px 14px;font-size:11px;transition:opacity .2s,transform .2s;position:fixed;bottom:0;left:0;right:0;transform:translateY(100%)}.chart-status.visible{opacity:1;transform:translateY(0)}.pan-zoom-controls{z-index:40;gap:4px;display:flex;position:fixed;top:8px;right:8px}.pan-zoom-controls[hidden]{display:none}.pan-btn{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);width:28px;height:28px;color:var(--text-secondary);cursor:pointer;justify-content:center;align-items:center;font-size:14px;transition:background .15s,color .15s;display:flex}.pan-btn:hover{background:var(--bg-hover);color:var(--text-primary)}.pan-btn.reset{width:auto;padding:0 10px;font-size:12px}#moon-container{flex-direction:column;flex:1;min-height:440px;padding:16px;display:flex;overflow-y:auto}#moon-container[hidden]{display:none}.moon-layout{flex:1;align-items:flex-start;gap:24px;display:flex}@media (max-width:500px){.moon-layout{flex-direction:column;align-items:center;gap:16px}}.moon-dial-col{flex-direction:column;flex-shrink:0;align-items:center;gap:12px;display:flex}.moon-dial{justify-content:center;align-items:center;width:200px;height:200px;display:flex;position:relative}.moon-svg{filter:drop-shadow(0 0 20px rgba(131,163,187,.2));filter:drop-shadow(0 0 20px color(display-p3 .537913 .6362 .723376/.2));filter:drop-shadow(0 0 20px lab(65.2381% -7.35197 -16.1178/.2));width:100%;height:100%;animation:.8s ease-out moon-fade-in}@keyframes moon-fade-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.moon-illum-ring{background:var(--bg-secondary);border:1px solid var(--border-subtle);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:20px;flex-direction:column;align-items:center;gap:1px;padding:3px 10px;display:flex;position:absolute;bottom:-2px}.moon-illum-value{font-size:22px;font-weight:700;font-family:var(--font-display);font-variant-numeric:tabular-nums;color:var(--accent-warm);letter-spacing:-.02em}.moon-illum-label{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);font-size:9px}.moon-phase-badge{background:var(--bg-secondary);border:1px solid var(--border);border-radius:20px;align-items:center;gap:6px;padding:6px 14px;display:flex}.moon-phase-icon{font-size:18px}.moon-phase-name{font-size:15px;font-weight:700;font-family:var(--font-display);letter-spacing:-.01em;text-transform:capitalize}.moon-direction{color:var(--text-muted);align-items:center;gap:4px;font-size:12px;display:flex}.dir-arrow{font-size:14px;font-weight:700}.dir-waxing{color:#57bc8a;color:color(display-p3 .44464 .726476 .557142);color:lab(69.1118% -39.6203 15.6094)}.dir-waning{color:#e6857e;color:color(display-p3 .850722 .541293 .507352);color:lab(66.3643% 37.397 20.9063)}.moon-info-col{flex-direction:column;flex:1;gap:6px;min-width:0;display:flex}.moon-card{background:var(--bg-secondary);border:1px solid var(--border-subtle);border-radius:var(--radius);cursor:pointer;padding:10px 14px;transition:background .15s,border-color .15s,box-shadow .15s}.moon-card:hover{background:var(--bg-hover);border-color:var(--border);box-shadow:0 2px 12px rgba(0,0,0,.1);box-shadow:0 2px 12px color(display-p3 0 0 0/.1);box-shadow:0 2px 12px lab(0% 0 0/.1)}.moon-card--voc.moon-card--active{border-color:var(--voc-warn);background:#2c1e13;background:color(display-p3 .165435 .120647 .0798046);background:lab(12.9057% 5.79391 10.6146)}@media (prefers-color-scheme:light){.moon-card--voc.moon-card--active{background:#ffead8;background:color(display-p3 1.00032 .917094 .842425);background:lab(94.1113% 7.0433 13.5336)}}.moon-card-header{align-items:center;gap:6px;margin-bottom:4px;display:flex}.moon-card-icon{font-size:14px}.moon-card-title{font-size:11px;font-weight:600;font-family:var(--font-body);text-transform:uppercase;letter-spacing:.07em;color:var(--text-muted)}.moon-card-chevron{color:var(--text-muted);flex-shrink:0;margin-left:auto;font-size:13px;transition:transform .2s}.moon-card--open .moon-card-chevron{transform:rotate(90deg)}.moon-card-value{font-size:16px;font-weight:700;font-family:var(--font-display);color:var(--text-primary);text-transform:capitalize;line-height:1.2}.moon-card-detail{color:var(--text-muted);margin-top:2px;font-size:11px}.moon-card--primary{border-color:var(--border);padding:8px 13px}.moon-card--primary .moon-card-value{letter-spacing:-.02em;font-size:16px}.moon-card--primary .moon-card-header{margin-bottom:2px}.moon-card--primary .moon-card-title{font-size:10px}.moon-card--compact{padding:6px 12px}.moon-card--compact .moon-card-header{margin-bottom:2px}.moon-card--compact .moon-card-value{font-size:13px;font-weight:600}.moon-card--compact .moon-card-title{letter-spacing:.06em;font-size:10px}.moon-card--inline{padding:6px 12px}.moon-card--inline .moon-card-header{gap:8px;margin-bottom:0}.moon-card--inline .moon-card-title{letter-spacing:.06em;font-size:10px}.moon-card-inline-value{font-family:var(--font-display);color:var(--text-primary);font-variant-numeric:tabular-nums;letter-spacing:-.01em;margin-left:auto;font-size:13px;font-weight:600}.moon-card-inline-value.tempo-fast,.moon-card-inline-value.prox-super{color:var(--cue-warm)}.moon-card-inline-value.tempo-slow{color:var(--cue-cool)}.moon-card-inline-value.prox-micro{color:var(--text-secondary)}.moon-card--inline .moon-card-chevron{margin-left:0}.moon-card-expand{grid-template-rows:0fr;margin-top:0;transition:grid-template-rows .22s,margin-top .22s;display:grid}.moon-card--open .moon-card-expand{grid-template-rows:1fr;margin-top:8px}.moon-card-expand-inner{overflow:hidden}.moon-card-expand-text{font-size:11px;font-family:var(--font-body);color:var(--text-secondary);border-top:1px solid var(--border-subtle);padding-top:7px;font-style:italic;line-height:1.55;display:block}.moon-card-row{color:var(--text-secondary);align-items:center;gap:4px;padding:1px 0;font-size:12px;display:flex}.moon-card-row-icon{font-size:12px}.moon-actions{flex-wrap:wrap;gap:8px;margin-top:6px;display:flex}.action-btn{border-radius:var(--radius);border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-secondary);cursor:pointer;align-items:center;gap:4px;padding:8px 14px;font-size:12px;font-weight:600;transition:background .15s,color .15s,box-shadow .15s;display:inline-flex}.action-btn:hover{background:var(--bg-hover);color:var(--text-primary)}.action-btn--primary{color:#cbdfff;color:color(display-p3 .811259 .873257 .992208);color:lab(88.2011% -1.89927 -18.3428);background:#11397b;background:color(display-p3 .112797 .221529 .46413);background:lab(24.6079% 7.13965 -41.9262);border-color:#305289;border-color:color(display-p3 .218625 .316452 .521702);border-color:lab(34.3143% 2.02942 -35.2838)}.action-btn--primary:hover{background:#1b478f;background:color(display-p3 .151166 .273205 .543079);background:lab(30.361% 6.93905 -45.4659);box-shadow:0 0 0 1px #3862a7;box-shadow:0 0 0 1px color(display-p3 .2584 .379139 .634372);box-shadow:0 0 0 1px lab(41.0971% 2.98774 -42.2721)}.error-state,.empty-state{text-align:center;flex-direction:column;flex:1;justify-content:center;align-items:center;gap:8px;padding:32px;display:flex}.error-icon,.empty-icon{font-size:32px}.error-retry-btn{border:1px solid var(--border,rgba(128,128,160,.3));color:var(--accent-warm,var(--text-primary,inherit));font:inherit;letter-spacing:.02em;cursor:pointer;background:0 0;border-radius:999px;margin-top:4px;padding:7px 20px;font-size:11px;font-weight:600;transition:background .18s,border-color .18s}.error-retry-btn:hover{background:rgba(128,128,160,.12)}.tooltip{z-index:100;border-radius:var(--radius-sm);background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);pointer-events:none;opacity:0;max-width:200px;padding:6px 10px;font-size:11px;transition:opacity .15s;position:fixed}.tooltip.visible{opacity:1}.chart-layer{opacity:1;transform-origin:310px 310px;animation:.5s cubic-bezier(.16,1,.3,1) both svgLayerAssemble;animation-delay:calc(var(--layer-index,0) * 80ms);transform:scale(1)}@keyframes svgLayerAssemble{0%{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}.floating-action-btn:after{content:"";background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);width:50%;height:100%;animation:3s 2s infinite shimmer;position:absolute;top:0;left:-100%;transform:skew(-20deg)}@keyframes shimmer{0%{left:-100%}20%{left:200%}to{left:200%}}
15
15
  /*$vite$:1*/</style>
16
- </head>
17
- <body>
18
- <div id="app">
19
- <div id="loading-state">
20
- <svg class="oe-loader" viewBox="0 0 100 100"><circle cx="50" cy="50" r="45"/></svg>
21
- <p>Computing lunar phase…</p>
22
- </div>
23
- <div id="chart-status" class="chart-status" aria-live="polite"></div>
24
- <!-- Moon phase doesn't need zoom — image is fixed. Container is kept so
25
- setupFullscreenToggle has a host to inject the fullscreen button into. -->
26
- <div id="pan-zoom-controls" class="pan-zoom-controls" hidden></div>
27
- <div id="moon-container" hidden></div>
28
- <div id="tooltip" class="tooltip" aria-live="polite"></div>
29
- </div>
30
-
16
+ </head>
17
+ <body>
18
+ <div id="app">
19
+ <div id="loading-state">
20
+ <svg class="oe-loader" viewBox="0 0 100 100"><circle cx="50" cy="50" r="45"/></svg>
21
+ <p>Computing lunar phase…</p>
22
+ </div>
23
+ <div id="chart-status" class="chart-status" aria-live="polite"></div>
24
+ <!-- Moon phase doesn't need zoom — image is fixed. Container is kept so
25
+ setupFullscreenToggle has a host to inject the fullscreen button into. -->
26
+ <div id="pan-zoom-controls" class="pan-zoom-controls" hidden></div>
27
+ <div id="moon-container" hidden></div>
28
+ <div id="tooltip" class="tooltip" aria-live="polite"></div>
29
+ </div>
30
+
31
31
  <script type="module">var e = Object.defineProperty, t = (t2, n2) => {
32
32
  let r2 = {};
33
33
  for (var i2 in t2) e(r2, i2, { get: t2[i2], enumerable: true });
@@ -6783,5 +6783,5 @@ y_.ontoolresult = function(e4) {
6783
6783
  }
6784
6784
  })();
6785
6785
  </script>
6786
- </body>
6787
- </html>
6786
+ </body>
6787
+ </html>