@ekanos/harness 0.1.2 → 0.1.3

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.
package/README.md CHANGED
@@ -419,9 +419,13 @@ recognise:
419
419
  [harness:tidepool] refused GET https://api.tidepool.example.com/v1/tides/current?…
420
420
  ```
421
421
 
422
- Every third-party call is logged this way — `fixture`, `network` (live mode
423
- only) or `refused` — so the console is the fastest place to see what a
424
- widget actually asked for versus what you recorded.
422
+ Every third-party call is logged this way — `fixture`, `network` (a vendor
423
+ request in live mode) or `refused` — so the console is the fastest place to
424
+ see what a widget actually asked for versus what you recorded. A same-origin
425
+ `/api/…` request logs `fixture` or `refused` in EITHER toolbar mode: it is
426
+ answered from your registry entry whether the switch says Fixtures or Live
427
+ API, because the harness serves no backend either way. Only genuinely
428
+ third-party traffic can log `network`, and only in live mode.
425
429
 
426
430
  2. **The `fetch` call rejects with `NoRecordedResponseError`**, whose message
427
431
  quotes the fixture to paste:
@@ -622,6 +626,81 @@ design draws: Fusion mocked, vendor real.
622
626
  An integration with no `live` block is fixtures-only; the toolbar control is
623
627
  disabled for it and says why.
624
628
 
629
+ ### Knowing the mode, and the activation-data channel
630
+
631
+ Two things a live-mode widget routinely needs and could not get before:
632
+ **which mode is actually running**, and **a credential to authenticate with**.
633
+
634
+ `FetchProvider` — where you already have one — receives both as props, no
635
+ extra plumbing required:
636
+
637
+ ```tsx
638
+ import type { ReactNode } from 'react';
639
+
640
+ import type { IntegrationFetch } from '@ekanos/sdk';
641
+ import type { DataMode } from '@ekanos/harness/registry';
642
+
643
+ export function AcmeFetchProvider({
644
+ fetch,
645
+ mode,
646
+ activationData,
647
+ children,
648
+ }: {
649
+ fetch: IntegrationFetch;
650
+ mode: DataMode;
651
+ activationData: unknown;
652
+ children: ReactNode;
653
+ }) {
654
+ return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
655
+ }
656
+ ```
657
+
658
+ `mode` is `'fixtures' | 'live'` — the EFFECTIVE mode, already accounting for an
659
+ integration with no `live` block or a `live.egress`/definition mismatch (both
660
+ force `'fixtures'`, whatever the toolbar says). `activationData` is whatever
661
+ was last submitted to this integration's activation form — `null` before any
662
+ submission or after Disconnect — held **in memory only**: never written to
663
+ `localStorage`, a fixture, or the registry, and cleared on reload and on
664
+ disconnect. It is a dev-only convenience standing in for `ctx.secrets`, which
665
+ is what production actually hands your server-side handlers.
666
+
667
+ Threading a submitted API token into a live request looks like this:
668
+
669
+ ```tsx
670
+ import type { ReactNode } from 'react';
671
+
672
+ import type { IntegrationFetch } from '@ekanos/sdk';
673
+ import type { DataMode } from '@ekanos/harness/registry';
674
+
675
+ export function AcmeFetchProvider({ fetch, activationData, children }: {
676
+ fetch: IntegrationFetch;
677
+ mode: DataMode;
678
+ activationData: unknown;
679
+ children: ReactNode;
680
+ }) {
681
+ const token = (activationData as { apiToken?: string } | null)?.apiToken;
682
+
683
+ const authedFetch: IntegrationFetch = (input, init) =>
684
+ fetch(input, {
685
+ ...init,
686
+ headers: {
687
+ ...(init?.headers as Record<string, string> | undefined),
688
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
689
+ },
690
+ });
691
+
692
+ return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
693
+ }
694
+ ```
695
+
696
+ No `FetchProvider` mounted, or need the answer somewhere else entirely? Two
697
+ hooks answer the same two questions from `@ekanos/harness/hooks`:
698
+ `useHarnessDataMode()` and `useLiveActivationData()`. Prefer the props above
699
+ wherever you can — they need no import from this package, and behave
700
+ identically whether or not `@ekanos/harness` is even installed at the call
701
+ site. Reach for the hooks only from code that runs nowhere but inside the dev
702
+ harness.
703
+
625
704
  ### `FetchProvider` — the part you have to write yourself
626
705
 
627
706
  > **Optional.** The harness patches `globalThis.fetch`, so it reaches your
@@ -693,12 +772,14 @@ That is the value of the pattern, beyond the harness: the *same* code path
693
772
  serves `ctx.fetch` on the server and an allowlisted browser fetch on the
694
773
  client.
695
774
 
696
- The provider's props are fixed by the type — `{ fetch: IntegrationFetch;
697
- children: ReactNode }` — because the harness mounts it. Omit `FetchProvider`
698
- entirely if your widgets fetch only through your own host routes: fixtures mode
699
- answers those (record them under `/api/…`), but live mode cannot run them —
700
- there is no Supabase and no `ctx` to execute a route with — so live mode has
701
- nothing to offer you yet.
775
+ The provider's props are fixed by the type — `{ fetch: IntegrationFetch; mode:
776
+ DataMode; activationData: unknown; children: ReactNode }` — because the harness
777
+ mounts it (see the previous section for what `mode` and `activationData` are
778
+ for). Omit `FetchProvider` entirely if your widgets fetch only through your own
779
+ host routes: those are answered from fixtures (record them under `/api/…`) in
780
+ EVERY toolbar mode, live included — there is no Supabase and no `ctx` to
781
+ execute a route with, so there is nothing "live" to run for that traffic
782
+ either way.
702
783
 
703
784
  This should be SDK surface, not partner surface. It is on the list.
704
785
 
@@ -827,6 +908,7 @@ API token predictably appears on screen.
827
908
  | `@ekanos/harness/config` | `defineHarnessConfig()` — an identity function that exists for the inference. |
828
909
  | `@ekanos/harness/app` | `RootLayout`, `IndexPage`, `harnessMetadata`. |
829
910
  | `@ekanos/harness/routes` | `IntegrationLayout`, `WidgetsPage`, `SingleWidgetPage`, `TilePage`, `ActivationPage`, `TriggersPage`. |
911
+ | `@ekanos/harness/hooks` | `useHarnessDataMode()`, `useLiveActivationData()` — see "Knowing the mode, and the activation-data channel" above. Prefer `FetchProvider`'s own `mode`/`activationData` props where you can; reach for these only from code that runs nowhere but inside the dev harness. |
830
912
  | `@ekanos/harness/styles.css` | The harness's Tailwind layer: the Tailwind entry, the `@ekanos/ui` token preset, the Font Awesome repairs, and the `@source` globs covering everything the chrome renders. |
831
913
  | `@ekanos/harness/mocks/team-account-workspace` | The stand-in for the host's `useTeamAccountWorkspace()`. The generated `next.config.mjs` aliases `@kit/team-accounts/hooks/use-team-account-workspace` onto it, so unmodified widget source runs unchanged. |
832
914
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public hooks for widget code that only ever runs inside the harness.
3
+ *
4
+ * Most partner code should not need this entry at all: `HarnessLiveMode`'s
5
+ * `FetchProvider` (see `registry.ts`) already receives `mode` and
6
+ * `activationData` as props, which needs no import from this package and
7
+ * behaves the same whether or not `@ekanos/harness` is even installed at the
8
+ * call site. Reach for these two hooks only from code that is genuinely
9
+ * harness-only — a debug banner, a dev-mode-only branch — never from a
10
+ * widget's shipped data-fetching path.
11
+ */
12
+ export { useHarnessDataMode, useLiveActivationData, } from './internal/lib/harness-hooks.js';
package/dist/hooks.js ADDED
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import {
3
+ useHarnessDataMode,
4
+ useLiveActivationData
5
+ } from "./internal/lib/harness-hooks.js";
6
+ export {
7
+ useHarnessDataMode,
8
+ useLiveActivationData
9
+ };
@@ -21,6 +21,7 @@ import {
21
21
  resolveEgress,
22
22
  supportsLiveMode
23
23
  } from "../../registry.js";
24
+ import { getThemeToggleAccessibleLabel } from "../lib/theme-toggle.js";
24
25
  import {
25
26
  RENDER_STATES,
26
27
  VIEWPORTS,
@@ -133,16 +134,30 @@ function DevToolbar() {
133
134
  size: "sm",
134
135
  variant: "outline",
135
136
  onClick: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"),
136
- "aria-label": "Toggle theme",
137
+ "aria-label": getThemeToggleAccessibleLabel(resolvedTheme),
137
138
  children: [
138
- /* @__PURE__ */ jsxs("span", { className: cn("flex items-center dark:hidden"), children: [
139
- /* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-moon", className: "mr-2 h-4 w-4" }),
140
- "Dark"
141
- ] }),
142
- /* @__PURE__ */ jsxs("span", { className: cn("hidden items-center dark:flex"), children: [
143
- /* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-sun-bright", className: "mr-2 h-4 w-4" }),
144
- "Light"
145
- ] })
139
+ /* @__PURE__ */ jsxs(
140
+ "span",
141
+ {
142
+ "aria-hidden": "true",
143
+ className: cn("flex items-center dark:hidden"),
144
+ children: [
145
+ /* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-moon", className: "mr-2 h-4 w-4" }),
146
+ "Dark"
147
+ ]
148
+ }
149
+ ),
150
+ /* @__PURE__ */ jsxs(
151
+ "span",
152
+ {
153
+ "aria-hidden": "true",
154
+ className: cn("hidden items-center dark:flex"),
155
+ children: [
156
+ /* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-sun-bright", className: "mr-2 h-4 w-4" }),
157
+ "Light"
158
+ ]
159
+ }
160
+ )
146
161
  ]
147
162
  }
148
163
  ),
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { Suspense, useMemo } from "react";
3
+ import { Suspense, useMemo, useSyncExternalStore } from "react";
4
4
  import {
5
5
  IntegrationActivationProvider
6
6
  } from "@ekanos/sdk/hooks";
@@ -18,6 +18,13 @@ import {
18
18
  logHarnessFetch
19
19
  } from "../lib/harness-live-fetch.js";
20
20
  import { createHarnessQueryClient } from "../lib/harness-query-client.js";
21
+ import {
22
+ clearLiveActivationData,
23
+ getLiveActivationServerSnapshot,
24
+ getLiveActivationSnapshot,
25
+ setLiveActivationData,
26
+ subscribeLiveActivationData
27
+ } from "../lib/live-activation-store.js";
21
28
  import { redactSensitive } from "../lib/redact.js";
22
29
  import { AskAssistantBridge } from "./ask-assistant-bridge.js";
23
30
  installHarnessFetch();
@@ -27,6 +34,7 @@ const fixtureActivationActions = {
27
34
  "[harness] activate (fixture, not persisted):",
28
35
  redactSensitive(input)
29
36
  );
37
+ setLiveActivationData(input.integrationSlug, input.activationData ?? null);
30
38
  return { success: true };
31
39
  },
32
40
  deactivate: async (input) => {
@@ -34,6 +42,7 @@ const fixtureActivationActions = {
34
42
  "[harness] deactivate (fixture, not persisted):",
35
43
  redactSensitive(input)
36
44
  );
45
+ clearLiveActivationData(input.integrationSlug);
37
46
  return { success: true };
38
47
  }
39
48
  };
@@ -56,6 +65,11 @@ function HarnessProviders({
56
65
  }
57
66
  return createFixturesFetch();
58
67
  }, [mode, egress, slug]);
68
+ const activationData = useSyncExternalStore(
69
+ subscribeLiveActivationData,
70
+ () => getLiveActivationSnapshot(slug),
71
+ getLiveActivationServerSnapshot
72
+ );
59
73
  useMemo(() => {
60
74
  installHarnessFetch();
61
75
  publishHarnessRouting({
@@ -76,7 +90,15 @@ function HarnessProviders({
76
90
  /* @__PURE__ */ jsx(AskAssistantBridge, {}),
77
91
  /* @__PURE__ */ jsx(Suspense, { fallback: null, children })
78
92
  ] }) }) }) }) });
79
- return FetchProvider ? /* @__PURE__ */ jsx(FetchProvider, { fetch: harnessFetch, children: tree }) : tree;
93
+ return FetchProvider ? /* @__PURE__ */ jsx(
94
+ FetchProvider,
95
+ {
96
+ fetch: harnessFetch,
97
+ mode,
98
+ activationData,
99
+ children: tree
100
+ }
101
+ ) : tree;
80
102
  }
81
103
  export {
82
104
  HarnessProviders
@@ -25,19 +25,34 @@ import { type CompiledFixture, NoRecordedResponseError } from './http-fixtures.j
25
25
  *
26
26
  * ── What is deliberately NOT intercepted ─────────────────────────────────────
27
27
  *
28
- * Same-origin traffic passes through untouched — with ONE exception.
28
+ * Same-origin traffic outside `/api/` passes through untouched — in EVERY
29
+ * mode, live included.
29
30
  *
30
- * Measured against a running harness, same-origin traffic is `/_next/static`,
31
- * the document, and the `?_rsc=` payloads client navigation fetches: the
31
+ * Measured against a running harness, that traffic is `/_next/static`, the
32
+ * document, and the `?_rsc=` payloads client navigation fetches: the
32
33
  * framework's own plumbing, none of it the integration talking to an API.
33
34
  * Intercepting it would break the app to no purpose.
34
35
  *
35
- * `/api/` is the exception, because it is the integration's own namespace. A
36
- * first-party integration's widgets call `/api/integrations/<slug>/…` rather
37
- * than the vendor directly the vendor call happens server-side, where the
38
- * credential lives — and our own Acme example is shaped exactly that way. The
39
- * harness serves no backend, so passing those through guarantees a 404; the
40
- * useful answer is a fixture, or a refusal that names the URL.
36
+ * ── `/api/` is answered from fixtures in EVERY mode ──────────────────────────
37
+ *
38
+ * `/api/` is the one same-origin exception, because it is the integration's
39
+ * own namespace. A first-party integration's widgets call
40
+ * `/api/integrations/<slug>/…` rather than the vendor directly the vendor
41
+ * call happens server-side, where the credential lives and our own Acme
42
+ * example is shaped exactly that way.
43
+ *
44
+ * This is answered from the registry's `fixtures` REGARDLESS of the toolbar's
45
+ * fixtures/live switch. The harness serves no backend in either mode, so there
46
+ * is nothing "live" to run for this traffic even when live mode is on: passing
47
+ * it through would guarantee a 404 whichever mode asked for it, and the useful
48
+ * answer — a fixture, or a refusal that names the URL and says to add one — is
49
+ * the one fixtures mode already gives. A miss is `NoRecordedResponseError`,
50
+ * unconditionally, so the failure a partner sees does not change out from
51
+ * under them when they flip the toolbar.
52
+ *
53
+ * Only genuinely third-party traffic — a different origin — is what "live"
54
+ * actually means: reaching the real vendor under the egress allowlist below,
55
+ * instead of a recorded response.
41
56
  *
42
57
  * Scoped to `/api/` rather than "everything except `/_next/`" deliberately: a
43
58
  * rule that has to enumerate the framework's internals is a rule that breaks
@@ -50,9 +50,19 @@ function installHarnessFetch() {
50
50
  } catch {
51
51
  throw new NoRecordedResponseError(method, target, true);
52
52
  }
53
- if (globalThis.location !== void 0 && url.origin === globalThis.location.origin && !url.pathname.startsWith("/api/")) {
53
+ const isSameOrigin = globalThis.location !== void 0 && url.origin === globalThis.location.origin;
54
+ if (isSameOrigin && !url.pathname.startsWith("/api/")) {
54
55
  return original(input, init);
55
56
  }
57
+ if (isSameOrigin) {
58
+ const fixture2 = matchHttpFixture(method, url.toString(), active.fixtures);
59
+ if (!fixture2) {
60
+ notify(url, method, "refused");
61
+ throw new NoRecordedResponseError(method, redact(url), false);
62
+ }
63
+ notify(url, method, "fixture");
64
+ return fixtureResponse(fixture2);
65
+ }
56
66
  if (active.mode === "live") {
57
67
  if (!isEgressAllowed(url.toString(), active.egress)) {
58
68
  notify(url, method, "refused");
@@ -0,0 +1,30 @@
1
+ import { type DataMode } from '../../registry.js';
2
+ /**
3
+ * The two public hooks behind `@ekanos/harness/hooks` (see src/hooks.ts).
4
+ *
5
+ * Both exist so widget code can ask "what mode am I in / what did the user
6
+ * just activate with" WITHOUT reaching for `globalThis.fetch` or reimplementing
7
+ * the toolbar's own state. Prefer the props `HarnessLiveMode.FetchProvider`
8
+ * already receives (`mode`, `activationData`) wherever your `FetchProvider`
9
+ * is already in the tree — those need no import from this package, and behave
10
+ * identically whether or not `@ekanos/harness` is even installed at the call
11
+ * site. Reach for these hooks only from code that runs nowhere but inside the
12
+ * dev harness.
13
+ */
14
+ /**
15
+ * `'fixtures' | 'live'` for the integration currently rendering — the
16
+ * EFFECTIVE mode, not the toolbar's raw preference. See
17
+ * `resolveEffectiveDataMode` in `registry.ts`: an integration with no `live`
18
+ * block, or one whose `live.egress` mismatches its definition, reports
19
+ * `'fixtures'` here even with the toolbar switched to Live API.
20
+ */
21
+ export declare function useHarnessDataMode(): DataMode;
22
+ /**
23
+ * The `activationData` most recently submitted to THIS integration's
24
+ * activation form — `null` before any submission, after Disconnect, or while
25
+ * a different integration's activation is the one currently held.
26
+ *
27
+ * See `live-activation-store.ts` for the full contract: in memory only, never
28
+ * persisted, cleared on reload and on disconnect.
29
+ */
30
+ export declare function useLiveActivationData(): unknown;
@@ -0,0 +1,28 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ import { resolveEffectiveDataMode } from "../../registry.js";
4
+ import { useHarnessIntegration } from "../registry-context.js";
5
+ import {
6
+ getLiveActivationServerSnapshot,
7
+ getLiveActivationSnapshot,
8
+ subscribeLiveActivationData
9
+ } from "./live-activation-store.js";
10
+ import { useToolbar } from "./toolbar-context.js";
11
+ function useHarnessDataMode() {
12
+ const integration = useHarnessIntegration();
13
+ const { state } = useToolbar();
14
+ return resolveEffectiveDataMode(integration, state.dataMode);
15
+ }
16
+ function useLiveActivationData() {
17
+ const integration = useHarnessIntegration();
18
+ const slug = integration?.slug ?? "";
19
+ return useSyncExternalStore(
20
+ subscribeLiveActivationData,
21
+ () => getLiveActivationSnapshot(slug),
22
+ getLiveActivationServerSnapshot
23
+ );
24
+ }
25
+ export {
26
+ useHarnessDataMode,
27
+ useLiveActivationData
28
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * THE LIVE-MODE ACTIVATION-DATA CHANNEL — in memory, never persisted
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * A live-mode widget's own query function has nowhere to get a vendor
7
+ * credential from without this. `ctx.secrets` is server-only, and the
8
+ * activation form's payload used to die the moment its `onSuccess` callback
9
+ * returned. A real partner build found the same workaround every time: a
10
+ * bespoke module-level store, set from the activation form after
11
+ * `useActivateIntegration()` resolved, read by a context the widgets
12
+ * consulted directly (see the Priority Passport reference this generalises —
13
+ * `setLiveCredentials` / `useLiveCredentials`).
14
+ *
15
+ * `fixtureActivationActions.activate` in harness-providers.tsx already HOLDS
16
+ * the submitted `activationData` — it is the function's own argument — so
17
+ * capturing it here costs nothing new. What this module adds is the one thing
18
+ * a bespoke per-integration store cannot: ONE channel every activation flows
19
+ * through (the plain form, the OAuth form — anything built on
20
+ * `useActivateIntegration()`), keyed by `integrationSlug` so switching between
21
+ * two registered integrations without a reload cannot leak one's credential
22
+ * into the other's widgets.
23
+ *
24
+ * The invariants, stated plainly because this is exactly the kind of
25
+ * convenience that leaks into production if it is not:
26
+ *
27
+ * - IN MEMORY ONLY. A module-level variable, nothing else — never written to
28
+ * `localStorage`, a fixture, or the registry.
29
+ * - CLEARED ON RELOAD. There is nothing to clear: the module is
30
+ * re-evaluated and the variable starts back at `null`.
31
+ * - CLEARED ON DISCONNECT. `fixtureActivationActions.deactivate` and the
32
+ * Activation surface's own Disconnect / Reset flow controls all clear the
33
+ * entry for that slug, so a disconnected integration's widgets cannot keep
34
+ * using a credential the UI says is gone.
35
+ * - A DEV-ONLY CONVENIENCE. Production hands your handlers `ctx.secrets`
36
+ * instead — server-side, encrypted, never in the browser at all. This
37
+ * exists only because the harness has no server to hold a secret for the
38
+ * browser to borrow from.
39
+ */
40
+ /** Recorded the moment an activation flow reports success. See above. */
41
+ export declare function setLiveActivationData(slug: string, activationData: unknown): void;
42
+ /** A no-op for any slug other than the one currently held. */
43
+ export declare function clearLiveActivationData(slug: string): void;
44
+ export declare function subscribeLiveActivationData(listener: () => void): () => void;
45
+ /** `null` when nothing has been submitted for `slug` yet, or it belongs to a different one. */
46
+ export declare function getLiveActivationSnapshot(slug: string): unknown;
47
+ export declare function getLiveActivationServerSnapshot(): null;
48
+ /** Test seam: forget everything, regardless of slug. */
49
+ export declare function resetLiveActivationData(): void;
@@ -0,0 +1,34 @@
1
+ let entry = null;
2
+ const listeners = /* @__PURE__ */ new Set();
3
+ function setLiveActivationData(slug, activationData) {
4
+ entry = { slug, activationData: activationData ?? null };
5
+ listeners.forEach((listener) => listener());
6
+ }
7
+ function clearLiveActivationData(slug) {
8
+ if (entry?.slug !== slug) return;
9
+ entry = null;
10
+ listeners.forEach((listener) => listener());
11
+ }
12
+ function subscribeLiveActivationData(listener) {
13
+ listeners.add(listener);
14
+ return () => {
15
+ listeners.delete(listener);
16
+ };
17
+ }
18
+ function getLiveActivationSnapshot(slug) {
19
+ return entry?.slug === slug ? entry.activationData : null;
20
+ }
21
+ function getLiveActivationServerSnapshot() {
22
+ return null;
23
+ }
24
+ function resetLiveActivationData() {
25
+ entry = null;
26
+ }
27
+ export {
28
+ clearLiveActivationData,
29
+ getLiveActivationServerSnapshot,
30
+ getLiveActivationSnapshot,
31
+ resetLiveActivationData,
32
+ setLiveActivationData,
33
+ subscribeLiveActivationData
34
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Pure label logic for the dev toolbar's theme toggle button, split out of
3
+ * `DevToolbar` so it is testable without a DOM (this package's vitest config
4
+ * runs in `node` and deliberately has no component-rendering tests — see
5
+ * `vitest.config.ts`).
6
+ *
7
+ * The button renders BOTH "Dark"/"Light" label spans and lets CSS's `dark:`
8
+ * variant pick one for sighted users — necessary so the very first frame is
9
+ * already correct (see the comment in `dev-toolbar.tsx` on why `resolvedTheme`
10
+ * is not read during render for the visual label). Without an explicit
11
+ * `aria-label`, a screen reader concatenates BOTH spans' text into the
12
+ * accessible name ("DarkLight"), and the visible text names the CURRENT
13
+ * theme rather than the action the button performs. `getAccessibleLabel`
14
+ * supplies the correct accessible name instead: the ACTION the click
15
+ * performs, not the current state.
16
+ */
17
+ export declare function getThemeToggleAccessibleLabel(resolvedTheme: string | undefined): string;
@@ -0,0 +1,6 @@
1
+ function getThemeToggleAccessibleLabel(resolvedTheme) {
2
+ return resolvedTheme === "dark" ? "Switch to light theme" : "Switch to dark theme";
3
+ }
4
+ export {
5
+ getThemeToggleAccessibleLabel
6
+ };
@@ -14,6 +14,11 @@ import {
14
14
  } from "@ekanos/ui/dialog";
15
15
  import { HARNESS_ACCOUNT_ID } from "../../registry.js";
16
16
  import { useDefinitionMockContext } from "../lib/definition-mock-context.js";
17
+ import {
18
+ useHarnessDataMode,
19
+ useLiveActivationData
20
+ } from "../lib/harness-hooks.js";
21
+ import { clearLiveActivationData } from "../lib/live-activation-store.js";
17
22
  import { hasSensitiveValues, redactSensitive } from "../lib/redact.js";
18
23
  import {
19
24
  runOnActivateHook
@@ -30,6 +35,8 @@ function ActivationPage() {
30
35
  });
31
36
  const integration = useHarnessIntegration();
32
37
  const { state: toolbar } = useToolbar();
38
+ const mode = useHarnessDataMode();
39
+ const activationData = useLiveActivationData();
33
40
  const ctx = useDefinitionMockContext(
34
41
  integration ?? { slug: "", name: "", description: "", widgets: [] },
35
42
  toolbar.variant
@@ -79,13 +86,16 @@ function ActivationPage() {
79
86
  {
80
87
  variant: "ghost",
81
88
  size: "sm",
82
- onClick: () => setFlow({
83
- open: false,
84
- connected: false,
85
- result: null,
86
- revealed: false,
87
- activationHook: null
88
- }),
89
+ onClick: () => {
90
+ clearLiveActivationData(integration.slug);
91
+ setFlow({
92
+ open: false,
93
+ connected: false,
94
+ result: null,
95
+ revealed: false,
96
+ activationHook: null
97
+ });
98
+ },
89
99
  children: "Reset flow"
90
100
  }
91
101
  )
@@ -111,13 +121,18 @@ function ActivationPage() {
111
121
  ConnectedState,
112
122
  {
113
123
  name: integration.name,
114
- onDisconnect: () => setFlow({
115
- open: true,
116
- connected: false,
117
- result: null,
118
- revealed: false,
119
- activationHook: null
120
- })
124
+ mode,
125
+ hasLiveActivationData: activationData !== null,
126
+ onDisconnect: () => {
127
+ clearLiveActivationData(integration.slug);
128
+ setFlow({
129
+ open: true,
130
+ connected: false,
131
+ result: null,
132
+ revealed: false,
133
+ activationHook: null
134
+ });
135
+ }
121
136
  }
122
137
  ) : (
123
138
  /*
@@ -270,6 +285,8 @@ function PermissionList({
270
285
  }
271
286
  function ConnectedState({
272
287
  name,
288
+ mode,
289
+ hasLiveActivationData,
273
290
  onDisconnect
274
291
  }) {
275
292
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-5", children: [
@@ -277,6 +294,14 @@ function ConnectedState({
277
294
  name,
278
295
  " is connected to your workspace."
279
296
  ] }),
297
+ /* @__PURE__ */ jsx(
298
+ "p",
299
+ {
300
+ className: "text-muted-foreground text-sm",
301
+ "data-test": "activation-mode-notice",
302
+ children: mode === "live" ? hasLiveActivationData ? "The toolbar is on Live API \u2014 this activation data is available to live-mode widgets on this visit." : "The toolbar is on Live API, but this activation submitted no data for live-mode widgets to authenticate with." : "This surface is Fusion-mocked; widgets read fixtures \u2014 switch the toolbar to Live API to exercise real requests."
303
+ }
304
+ ),
280
305
  /* @__PURE__ */ jsx(
281
306
  Button,
282
307
  {
@@ -5,9 +5,9 @@ import { notFound } from "next/navigation";
5
5
  import { Alert, AlertDescription, AlertTitle } from "@ekanos/ui/alert";
6
6
  import {
7
7
  findEgressMismatch,
8
+ resolveEffectiveDataMode,
8
9
  resolveEgress,
9
- seedsForMode,
10
- supportsLiveMode
10
+ seedsForMode
11
11
  } from "../../registry.js";
12
12
  import { HarnessProviders } from "../components/harness-providers.js";
13
13
  import { SurfaceNav } from "../components/surface-nav.js";
@@ -21,7 +21,7 @@ function IntegrationLayout({ children }) {
21
21
  () => integration ? findEgressMismatch(integration) : null,
22
22
  [integration]
23
23
  );
24
- const mode = supportsLiveMode(integration) && !egressMismatch ? state.dataMode : "fixtures";
24
+ const mode = resolveEffectiveDataMode(integration, state.dataMode);
25
25
  const seeds = useMemo(
26
26
  () => integration ? seedsForMode(integration, state.variant, mode) : [],
27
27
  [integration, state.variant, mode]
@@ -193,12 +193,60 @@ export interface HarnessLiveMode {
193
193
  * the full worked example.
194
194
  *
195
195
  * (This used to say to omit it if your widgets only call your own host
196
- * routes, because the harness could not serve those. It can: a fixture
197
- * whose request is under `/api/` is answered like any other, which is how
198
- * the Acme example's widgets work.)
196
+ * routes, because the harness could not serve those. It can, in EITHER
197
+ * mode: a fixture whose request is under `/api/` is answered from your
198
+ * registry entry whether the toolbar says Fixtures or Live API, which is
199
+ * how the Acme example's widgets work. There is nothing "live" to run for
200
+ * that traffic — the harness serves no backend either way.)
199
201
  */
200
202
  FetchProvider?: ComponentType<{
201
203
  fetch: IntegrationFetch;
204
+ /**
205
+ * `'fixtures' | 'live'` — which mode is actually running right now, so a
206
+ * partner's own provider can expose it to widgets (or just log it) with
207
+ * no context of its own and no import from this package. The
208
+ * `useHarnessDataMode()` hook in `@ekanos/harness/hooks` answers the same
209
+ * question for code that runs outside a mounted `FetchProvider`.
210
+ */
211
+ mode: DataMode;
212
+ /**
213
+ * The `activationData` most recently submitted to THIS integration's
214
+ * activation form — `null` before any submission, after Disconnect, or
215
+ * while a different integration's activation is the one currently held.
216
+ *
217
+ * The harness's sanctioned live-credential channel: every activation flow
218
+ * (the plain form, the OAuth form — anything built on
219
+ * `useActivateIntegration()`) already receives this as its own argument,
220
+ * and the harness threads it here instead of making every partner
221
+ * reinvent the module-level store that motivated this field. IN MEMORY
222
+ * ONLY — never written to `localStorage`, a fixture, or the registry —
223
+ * and cleared on reload and on Disconnect. See
224
+ * `internal/lib/live-activation-store.ts` for the full contract.
225
+ *
226
+ * A DEV-ONLY CONVENIENCE: production hands your handlers `ctx.secrets`
227
+ * instead, server-side and encrypted. Nothing reaching this prop should
228
+ * be mistaken for that.
229
+ *
230
+ * Worked example — an activation field landing in a live request header:
231
+ *
232
+ * ```tsx
233
+ * export function AcmeFetchProvider({ fetch, activationData, children }: {
234
+ * fetch: IntegrationFetch;
235
+ * mode: DataMode;
236
+ * activationData: unknown;
237
+ * children: ReactNode;
238
+ * }) {
239
+ * const token = (activationData as { apiToken?: string } | null)?.apiToken;
240
+ * const authedFetch: IntegrationFetch = (input, init) =>
241
+ * fetch(input, {
242
+ * ...init,
243
+ * headers: { ...init?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
244
+ * });
245
+ * return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
246
+ * }
247
+ * ```
248
+ */
249
+ activationData: unknown;
202
250
  children: ReactNode;
203
251
  }>;
204
252
  /**
@@ -416,3 +464,18 @@ export declare function resolveEgress(integration: HarnessIntegration): readonly
416
464
  export declare function supportsLiveMode(integration: HarnessIntegration | null): integration is HarnessIntegration & {
417
465
  live: HarnessLiveMode;
418
466
  };
467
+ /**
468
+ * The EFFECTIVE data mode for one integration — not the toolbar's raw
469
+ * preference.
470
+ *
471
+ * Live mode has to be forced back to `'fixtures'` whenever the toolbar switch
472
+ * would not mean anything: an integration with no `live` block has nothing to
473
+ * switch, and a `live.egress`/definition mismatch (`findEgressMismatch`) would
474
+ * run the switch against an allowlist production never validated — the false
475
+ * green live mode exists to prevent.
476
+ *
477
+ * `IntegrationLayout` and `useHarnessDataMode()` both call this rather than
478
+ * each recomputing it, so there is exactly one place that decides and the
479
+ * hook can never report a mode other than the one that actually rendered.
480
+ */
481
+ export declare function resolveEffectiveDataMode(integration: HarnessIntegration | null, toolbarMode: DataMode): DataMode;
package/dist/registry.js CHANGED
@@ -64,6 +64,11 @@ function resolveEgress(integration) {
64
64
  function supportsLiveMode(integration) {
65
65
  return integration?.live !== void 0;
66
66
  }
67
+ function resolveEffectiveDataMode(integration, toolbarMode) {
68
+ if (!supportsLiveMode(integration)) return "fixtures";
69
+ if (findEgressMismatch(integration)) return "fixtures";
70
+ return toolbarMode;
71
+ }
67
72
  export {
68
73
  DATA_MODES,
69
74
  FIXTURE_VARIANTS,
@@ -76,6 +81,7 @@ export {
76
81
  findIntegration,
77
82
  findWidget,
78
83
  harnessWidgetsFromDefinition,
84
+ resolveEffectiveDataMode,
79
85
  resolveEgress,
80
86
  seedsForMode,
81
87
  supportsLiveMode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekanos/harness",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "The Ekanos integration dev harness — every surface of an integration rendered in real Fusion chrome, from fixtures, with no Supabase, auth or network.",
6
6
  "license": "MIT",
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/routes.d.ts",
37
37
  "default": "./dist/routes.js"
38
38
  },
39
+ "./hooks": {
40
+ "types": "./dist/hooks.d.ts",
41
+ "default": "./dist/hooks.js"
42
+ },
39
43
  "./styles.css": "./dist/styles.css",
40
44
  "./mocks/team-account-workspace": {
41
45
  "types": "./dist/mocks/team-account-workspace.d.ts",
@@ -62,8 +66,8 @@
62
66
  "tailwindcss": "^4.0.0"
63
67
  },
64
68
  "devDependencies": {
65
- "@ekanos/sdk": "0.1.4",
66
- "@ekanos/ui": "0.1.4",
69
+ "@ekanos/sdk": "0.1.5",
70
+ "@ekanos/ui": "0.1.5",
67
71
  "@kit/eslint-config": "0.2.0",
68
72
  "@kit/prettier-config": "0.1.0",
69
73
  "@kit/tsconfig": "0.1.0",