@vincentt-xr/harness 1.1.0 → 1.3.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.
@@ -24,10 +24,17 @@ export interface HarnessProviderProps {
24
24
  captureNetwork?: boolean;
25
25
  captureTrace?: boolean;
26
26
  /**
27
- * Mount the floating "Send feedback" button (the reverse-channel capture
28
- * overlay). Defaults to `enabled` — on in preview, gone in production.
27
+ * Mount the Send-feedback chip (the reverse-channel capture overlay) into the
28
+ * shared top-right cluster. Defaults to `enabled` — on in preview, tree-shaken
29
+ * out of production. It is the phone's only way to speak, and f12's central
30
+ * scenario is unreachable without it.
29
31
  */
30
32
  feedback?: boolean;
33
+ /**
34
+ * Mount the in-app Share control (the desktop-to-phone QR handoff overlay).
35
+ * Defaults to `enabled` — on in preview, tree-shaken out of production.
36
+ */
37
+ share?: boolean;
31
38
  }
32
39
  /**
33
40
  * 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
@@ -90,10 +90,42 @@ export function HarnessProvider(props) {
90
90
  captureTrace: props.captureTrace,
91
91
  });
92
92
  });
93
+ let unmountShare;
93
94
  let unmountFeedback;
94
- if (props.feedback !== false) {
95
- // Separate dynamic import so the capture overlay (and its DOM code) also
96
- // drops from a production bundle.
95
+ // SHARE MOUNTS FIRST, AND THE ORDER IS SEQUENCED, NOT RACED.
96
+ //
97
+ // Insertion order into the cluster IS flex order, and Share is the cluster's
98
+ // established first member — a new control must take the adjacent slot
99
+ // without moving it. Two independent `import(...).then(...)` chains resolve
100
+ // in whatever order the loader finishes them, so leaving these parallel puts
101
+ // the cluster's layout at the mercy of module-fetch timing. Feedback is
102
+ // therefore mounted from INSIDE Share's continuation.
103
+ //
104
+ // Both remain separate dynamic imports, which is what lets a production
105
+ // build drop each overlay (and `qrcode`, reached only from share.js). The
106
+ // share import is also written FIRST in this file, so the ordering is legible
107
+ // to a reader (and to a source-level assertion) and not only to the runtime.
108
+ //
109
+ // Share defaults to `enabled`. Feedback defaults to `enabled` too (opt OUT
110
+ // with `feedback={false}`); the chip gates itself on the origin at mount, so
111
+ // the flag only decides whether the module is fetched at all.
112
+ if (props.share !== false) {
113
+ void import("./share.js").then(({ mountShareButton }) => {
114
+ if (cancelled)
115
+ return;
116
+ unmountShare = mountShareButton({});
117
+ // Only now — so Share holds slot one even when its module resolves last.
118
+ mountFeedback();
119
+ });
120
+ }
121
+ else {
122
+ // No Share at all: feedback is the cluster's only member, so there is no
123
+ // order to preserve and nothing to wait for.
124
+ mountFeedback();
125
+ }
126
+ function mountFeedback() {
127
+ if (props.feedback === false)
128
+ return;
97
129
  void import("./annotate.js").then(({ mountFeedbackButton }) => {
98
130
  if (cancelled)
99
131
  return;
@@ -111,6 +143,7 @@ export function HarnessProvider(props) {
111
143
  cancelled = true;
112
144
  teardown?.();
113
145
  unmountFeedback?.();
146
+ unmountShare?.();
114
147
  };
115
148
  // Instrumentation is installed once for the provider's lifetime; option
116
149
  // changes mid-session are not a supported case.
@@ -1,3 +1,4 @@
1
+ import { type FramingView } from "./previewOrigin.js";
1
2
  import type { AnnotationInput, AnnotationSpec } from "../shared/events.js";
2
3
  /**
3
4
  * Capture the current frame as a PNG data URL. Prefers the largest <canvas> (the
@@ -18,17 +19,85 @@ export declare function sendAnnotation(input: AnnotationInput, opts?: SendAnnota
18
19
  id: string;
19
20
  seq: number;
20
21
  }>;
22
+ /** The counter's current value. Exported for tests; not part of the app surface. */
23
+ export declare function getConsecutiveFailures(): number;
24
+ /**
25
+ * ANY failed POST increments — non-2xx, network error, abort alike. The device
26
+ * cannot distinguish a dead tunnel from a dead relay from a dead preview, and
27
+ * must not try.
28
+ */
29
+ export declare function recordSendFailure(): number;
30
+ /**
31
+ * A successful POST resets to 0, UNCONDITIONALLY. One send getting through proves
32
+ * the channel is alive, so the next failure is a first failure again.
33
+ *
34
+ * Nothing else calls this. Not the panel closing, not the chip being tapped, not
35
+ * a timer, not a visibility change — see the module comment.
36
+ */
37
+ export declare function recordSendSuccess(): void;
38
+ /** Test-only: restore the module counter to its initial state. */
39
+ export declare function __resetFailureCounterForTests(): void;
40
+ /** At 2+ consecutive failures the channel is gone, not having a bad moment. */
41
+ export declare function isStranded(): boolean;
21
42
  export interface FeedbackButtonOptions extends SendAnnotationOptions {
22
43
  sessionId?: string;
23
- /** Collect the creator's message. Default: window.prompt. */
24
- promptMessage?: () => string | null | Promise<string | null>;
25
44
  /** Override the captured spec (strokes/pins/labels). Default: empty. */
26
45
  spec?: AnnotationSpec;
46
+ /** Injectable for tests. Defaults to `window.matchMedia`. */
47
+ matchMedia?: typeof window.matchMedia;
48
+ /** Injectable for tests. Defaults to `window.location`. */
49
+ location?: Pick<Location, "origin" | "protocol" | "hostname">;
50
+ /** Injectable for tests. Defaults to the real POST. */
51
+ send?: (input: AnnotationInput) => Promise<unknown>;
52
+ /** Injectable for tests. Defaults to `captureScreenshot`. */
53
+ capture?: () => string;
54
+ /** Injectable for tests. Defaults to the real `window` (self/top identity). */
55
+ view?: FramingView;
27
56
  }
28
57
  /**
29
- * Mount a minimal floating "Send feedback" button. On click it collects a message,
30
- * captures the frame, and sends the annotation. Returns an unmount function. This
31
- * is the smallest useful Send action; a richer draw-on-frame overlay can replace
32
- * the capture/prompt without changing the wire contract.
58
+ * Is the feedback chip shown? The MIRROR of Share's rule, because the two are
59
+ * opposite handoffs. Share is `!isPhone(mm) && isPreviewOrigin(loc)`; feedback
60
+ * keeps the origin arm and DROPS the phone veto, because a phone is the device
61
+ * this control exists for. So the gate is the origin arm alone:
62
+ *
63
+ * | context | Share | feedback |
64
+ * |-------------------------------|--------|----------|
65
+ * | coarse-pointer / narrow | absent | PRESENT |
66
+ * | desktop | present| present |
67
+ * | real preview origin | req'd | req'd |
68
+ * | plain localhost / LAN | absent | ABSENT — no relay reachable |
69
+ *
70
+ * `matchMedia` is NOT a parameter: no viewport class can change this answer, and
71
+ * taking one would imply a veto that does not exist. Never rendered disabled or
72
+ * explained-away — on localhost nothing is appended at all.
73
+ */
74
+ export declare function shouldShowFeedback(loc: Pick<Location, "protocol" | "hostname">): boolean;
75
+ /**
76
+ * Mount the icon-only Send-feedback chip into the shared cluster and wire its
77
+ * composing panel.
78
+ *
79
+ * The show-gate is evaluated ONCE, here at mount. If it fails, nothing is
80
+ * appended to the DOM (no disabled state) and the returned unmount is a no-op.
81
+ *
82
+ * Share mounts first from HarnessProvider, and insertion order IS flex order, so
83
+ * appending here always leaves Share as the cluster's first member.
84
+ *
85
+ * THE FRAMED VETO (f13 `D-Feedback-does-not-mount-in-the-frame`, security
86
+ * MUST-FIX 5). Send POSTs to a route whose own comment states it is
87
+ * "unauthenticated and reachable by anyone holding the capability URL", and the
88
+ * write lands in the creator's coding-agent context. Inside a frame that control
89
+ * is positioned by the embedder's CSS on a page the creator did not choose to
90
+ * load it on, so a UI-redress lure (drag-and-paste into the textarea, a
91
+ * "click twice to continue" chain) reaches a text-injection channel needing no
92
+ * browser permission grant at all — the class of harm the frame's absent `allow`
93
+ * was built to stop, arriving through a door `allow` does not cover.
94
+ *
95
+ * THE MOUNT is gated, not the visibility. A hidden-but-mounted chip still holds
96
+ * the write path, and a hidden control is exactly what an overlay attack wants.
97
+ * Nothing is appended; there is no disabled state to re-enable.
98
+ *
99
+ * The cost is small and known: a creator viewing their own preview framed in
100
+ * their own console cannot Send from inside the frame. It is their own agent and
101
+ * the terminal is where they are already talking to it.
33
102
  */
34
103
  export declare function mountFeedbackButton(opts?: FeedbackButtonOptions): () => void;