diff --git a/.mex/patterns/secure-local-project-hub.md b/.mex/patterns/secure-local-project-hub.md
index 7accbda3..066b1873 100644
--- a/.mex/patterns/secure-local-project-hub.md
+++ b/.mex/patterns/secure-local-project-hub.md
@@ -11,7 +11,7 @@ edges:
condition: "when persisting a Hub job or migrating team.db"
- target: "context/architecture.md"
condition: "when wiring a real Graph or Wiki adapter"
-last_updated: 2026-09-08
+last_updated: 2026-09-10
mex:
id: mx_01M1M0CJQ2BSV71G1C7TXZD9RH
type: pattern
@@ -95,6 +95,14 @@ preview/apply services.
trace, or origin fields. Hub read models must omit those fields and bound
subject/message previews before response validation. Schema-v2 Activity
workflow/custom origin and optional labels use their closed projections.
+- The Overview team-access card POSTs an allowlisted name/email payload (and an
+ optional follow-up) from the browser to `https://api.web3forms.com`. CSP
+ `connect-src` names that host only; Hub must not proxy repo, path, graph, or
+ machine data, and page load must not initiate that request.
+- Keep optional Home dialogs and their styles behind an explicit open-on-demand
+ import; even shared dialog controls can exceed Home's frozen asset budget.
+ Bound external submissions across both the request and response-body read,
+ abort on expiry, and release the form for retry or dismissal after failure.
- New read surfaces need successful-job cache invalidation as well as their
initial query. Context's graph, selected record, and compact code queries all
diff --git a/package-lock.json b/package-lock.json
index 5d896bd0..e6aed83a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4095,7 +4095,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4117,7 +4116,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4139,7 +4137,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4161,7 +4158,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4183,7 +4179,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4205,7 +4200,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4227,7 +4221,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4249,7 +4242,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4271,7 +4263,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4293,7 +4284,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -4315,7 +4305,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
diff --git a/packages/hub-web/scripts/assert-production-build.mjs b/packages/hub-web/scripts/assert-production-build.mjs
index 8d496372..d0d914c1 100644
--- a/packages/hub-web/scripts/assert-production-build.mjs
+++ b/packages/hub-web/scripts/assert-production-build.mjs
@@ -55,6 +55,19 @@ if (!homeEntry || workbenchEntries.some((entry) => entry !== homeEntry && entry.
throw new Error("The production Hub Home workbench is not isolated in its own lazy chunk.");
}
const homeChunks = staticImportClosure(homeEntry.key);
+const teamAccessDialogKey = Object.keys(manifest).find((candidate) => (
+ candidate === "src/pages/TeamAccessDialog.tsx"
+ || manifest[candidate].src === "src/pages/TeamAccessDialog.tsx"
+));
+if (
+ !teamAccessDialogKey
+ || !manifest[teamAccessDialogKey].isDynamicEntry
+ || !(manifest[homeEntry.key].dynamicImports ?? []).includes(teamAccessDialogKey)
+ || homeChunks.has(teamAccessDialogKey)
+ || initialChunks.has(teamAccessDialogKey)
+) {
+ throw new Error("The team-access dialog is not isolated behind its explicit open-on-demand boundary.");
+}
for (const entry of workbenchEntries) {
if (entry !== homeEntry && homeChunks.has(entry.key)) {
throw new Error(`The production Hub Home workbench eagerly imports ${entry.source}.`);
diff --git a/packages/hub-web/src/env.d.ts b/packages/hub-web/src/env.d.ts
index 7d82f4a1..bf0c9f25 100644
--- a/packages/hub-web/src/env.d.ts
+++ b/packages/hub-web/src/env.d.ts
@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
+ readonly VITE_WEB3FORMS_ACCESS_KEY?: string;
}
interface ImportMeta {
diff --git a/packages/hub-web/src/lib/team-access-lead.test.ts b/packages/hub-web/src/lib/team-access-lead.test.ts
new file mode 100644
index 00000000..65a6aa37
--- /dev/null
+++ b/packages/hub-web/src/lib/team-access-lead.test.ts
@@ -0,0 +1,259 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ TEAM_ACCESS_FOLLOW_UP_SUBJECT,
+ TEAM_ACCESS_SOURCE,
+ TEAM_ACCESS_STORAGE_KEY,
+ TEAM_ACCESS_SUBJECT,
+ TEAM_ACCESS_SUBMIT_ERROR,
+ WEB3FORMS_SUBMIT_URL,
+ __setWeb3FormsAccessKeyForTests,
+ buildTeamAccessContactPayload,
+ buildTeamAccessFollowUpPayload,
+ readTeamAccessState,
+ submitTeamAccessPayload,
+ validateTeamAccessContact,
+ writeTeamAccessState,
+} from "./team-access-lead";
+
+afterEach(() => {
+ vi.useRealTimers();
+ __setWeb3FormsAccessKeyForTests(null);
+ window.localStorage.removeItem(TEAM_ACCESS_STORAGE_KEY);
+});
+
+describe("team-access lead payloads", () => {
+ it("builds a closed contact payload without repo or machine fields", () => {
+ __setWeb3FormsAccessKeyForTests("public-test-key");
+ const payload = buildTeamAccessContactPayload({
+ name: " Ada Lovelace ",
+ email: " ada@example.com ",
+ });
+
+ expect(payload).toEqual({
+ access_key: "public-test-key",
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ subject: TEAM_ACCESS_SUBJECT,
+ from_name: "mex Hub",
+ source: TEAM_ACCESS_SOURCE,
+ });
+ expect(Object.keys(payload).sort()).toEqual([
+ "access_key",
+ "email",
+ "from_name",
+ "name",
+ "source",
+ "subject",
+ ]);
+ });
+
+ it("omits empty optional follow-up fields and keeps the same contact identity", () => {
+ __setWeb3FormsAccessKeyForTests("public-test-key");
+ const payload = buildTeamAccessFollowUpPayload({
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ company: " ",
+ teamSize: "",
+ foundMex: "",
+ installReason: "",
+ repoKind: "",
+ othersUseAgents: "",
+ need: "",
+ missing: "",
+ });
+
+ expect(payload).toEqual({
+ access_key: "public-test-key",
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ subject: TEAM_ACCESS_FOLLOW_UP_SUBJECT,
+ from_name: "mex Hub",
+ source: TEAM_ACCESS_SOURCE,
+ });
+ expect(payload).not.toHaveProperty("company");
+ expect(payload).not.toHaveProperty("i_want");
+ expect(payload).not.toHaveProperty("repo");
+ expect(payload).not.toHaveProperty("path");
+ });
+
+ it("includes only allowlisted optional answers", () => {
+ __setWeb3FormsAccessKeyForTests("public-test-key");
+ const payload = buildTeamAccessFollowUpPayload({
+ name: "Ada Lovelace",
+ email: "ada@example.com",
+ company: "Analytical Engines",
+ teamSize: "2–10",
+ foundMex: "GitHub",
+ installReason: "Agent memory",
+ repoKind: "Work",
+ othersUseAgents: "Yes",
+ need: "Shared team memory",
+ missing: "Shared follow-up",
+ });
+
+ expect(payload).toMatchObject({
+ company: "Analytical Engines",
+ team_size: "2–10",
+ found_mex: "GitHub",
+ install_reason: "Agent memory",
+ repo_kind: "Work",
+ others_use_agents: "Yes",
+ i_need: "Shared team memory",
+ whats_missing: "Shared follow-up",
+ });
+ expect(Object.keys(payload).sort()).toEqual([
+ "access_key",
+ "company",
+ "email",
+ "found_mex",
+ "from_name",
+ "i_need",
+ "install_reason",
+ "name",
+ "others_use_agents",
+ "repo_kind",
+ "source",
+ "subject",
+ "team_size",
+ "whats_missing",
+ ]);
+ expect(payload).not.toHaveProperty("repo");
+ expect(payload).not.toHaveProperty("path");
+ });
+
+ it("rejects blank or malformed contact details before submit", () => {
+ expect(validateTeamAccessContact("", "ada@example.com")).toEqual({ name: "Enter your name." });
+ expect(validateTeamAccessContact("Ada", "")).toEqual({ email: "Enter your email." });
+ expect(validateTeamAccessContact("Ada", "not-an-email")).toEqual({ email: "Enter a valid email." });
+ expect(validateTeamAccessContact("Ada", "ada@example.com")).toEqual({});
+ });
+});
+
+describe("team-access Web3Forms submit", () => {
+ it("posts JSON to Web3Forms and requires a success response", async () => {
+ __setWeb3FormsAccessKeyForTests("public-test-key");
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+ const payload = buildTeamAccessContactPayload({ name: "Ada", email: "ada@example.com" });
+ const result = await submitTeamAccessPayload(payload, fetchImpl as unknown as typeof fetch);
+
+ expect(result).toEqual({ ok: true });
+ expect(fetchImpl).toHaveBeenCalledOnce();
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe(WEB3FORMS_SUBMIT_URL);
+ expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ });
+ expect(JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body))).toEqual(payload);
+ });
+
+ it("stays failed when Web3Forms does not accept the payload", async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: false, message: "invalid" }),
+ });
+ const result = await submitTeamAccessPayload(
+ { access_key: "public-test-key", name: "Ada", email: "ada@example.com" },
+ fetchImpl as unknown as typeof fetch,
+ );
+ expect(result).toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR });
+ });
+
+ it("aborts and settles after fifteen seconds when the request stalls", async () => {
+ vi.useFakeTimers();
+ const fetchImpl = vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => new Promise(() => {}));
+ const settled = vi.fn();
+ const result = submitTeamAccessPayload(
+ { access_key: "public-test-key", name: "Ada", email: "ada@example.com" },
+ fetchImpl,
+ ).then(settled);
+ const signal = fetchImpl.mock.calls[0]?.[1]?.signal;
+
+ await vi.advanceTimersByTimeAsync(14_999);
+ expect(settled).not.toHaveBeenCalled();
+ expect(signal?.aborted).toBe(false);
+ await vi.advanceTimersByTimeAsync(1);
+ await result;
+
+ expect(settled).toHaveBeenCalledExactlyOnceWith({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR });
+ expect(signal?.aborted).toBe(true);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each(["rejects", "ignores"] as const)(
+ "bounds a stalled response body when its reader %s the abort",
+ async (abortBehavior) => {
+ vi.useFakeTimers();
+ const bodyAborted = vi.fn();
+ const readBody = vi.fn();
+ const fetchImpl = vi.fn((_url: RequestInfo | URL, init?: RequestInit) => {
+ readBody.mockImplementation(() => new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ bodyAborted();
+ if (abortBehavior === "rejects") reject(new DOMException("Aborted", "AbortError"));
+ }, { once: true });
+ }));
+ return Promise.resolve({ ok: true, json: readBody } as unknown as Response);
+ });
+ const result = submitTeamAccessPayload(
+ { access_key: "public-test-key", name: "Ada", email: "ada@example.com" },
+ fetchImpl,
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(readBody).toHaveBeenCalledOnce();
+ await vi.advanceTimersByTimeAsync(15_000);
+
+ await expect(result).resolves.toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR });
+ expect(bodyAborted).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ },
+ );
+
+ it.each(["success", "rejection"] as const)(
+ "clears the deadline after early %s without later aborting the request",
+ async (outcome) => {
+ vi.useFakeTimers();
+ const fetchImpl = vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => (
+ outcome === "success"
+ ? Promise.resolve({ ok: true, json: async () => ({ success: true }) } as Response)
+ : Promise.reject(new Error("Network unavailable"))
+ ));
+ const result = await submitTeamAccessPayload(
+ { access_key: "public-test-key", name: "Ada", email: "ada@example.com" },
+ fetchImpl,
+ );
+ const signal = fetchImpl.mock.calls[0]?.[1]?.signal;
+
+ expect(result).toEqual(outcome === "success"
+ ? { ok: true }
+ : { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR });
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(15_000);
+ expect(signal?.aborted).toBe(false);
+ },
+ );
+
+ it("does not call the network when the public access key is missing", async () => {
+ const fetchImpl = vi.fn();
+ const result = await submitTeamAccessPayload(
+ { access_key: "", name: "Ada", email: "ada@example.com" },
+ fetchImpl as unknown as typeof fetch,
+ );
+ expect(result).toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR });
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+});
+
+describe("team-access local state", () => {
+ it("remembers that this Hub checkout already sent contact details", () => {
+ expect(readTeamAccessState()).toBeNull();
+ writeTeamAccessState({ contactSent: true });
+ expect(readTeamAccessState()).toEqual({ contactSent: true });
+ });
+});
diff --git a/packages/hub-web/src/lib/team-access-lead.ts b/packages/hub-web/src/lib/team-access-lead.ts
new file mode 100644
index 00000000..7568534d
--- /dev/null
+++ b/packages/hub-web/src/lib/team-access-lead.ts
@@ -0,0 +1,181 @@
+/**
+ * Checkout-local team-access lead capture. The Hub posts only the fields below
+ * to Web3Forms from the browser; it never attaches repo, path, graph, or machine data.
+ *
+ * Configure the public access key here, or override at Hub build time with
+ * VITE_WEB3FORMS_ACCESS_KEY. Web3Forms access keys are designed to ship in
+ * frontend bundles; do not put SMTP passwords or other private secrets here.
+ */
+export const WEB3FORMS_ACCESS_KEY = "20549db8-9c62-4da9-920a-f70a08c8ee44";
+
+export const WEB3FORMS_SUBMIT_URL = "https://api.web3forms.com/submit";
+export {
+ TEAM_ACCESS_STORAGE_KEY,
+ readTeamAccessState,
+ writeTeamAccessState,
+ type TeamAccessLocalState,
+} from "./team-access-state";
+export const TEAM_ACCESS_SUBJECT = "mex Hub team access";
+export const TEAM_ACCESS_FOLLOW_UP_SUBJECT = "mex Hub team access follow-up";
+export const TEAM_ACCESS_FROM_NAME = "mex Hub";
+export const TEAM_ACCESS_SOURCE = "mex-hub";
+export const TEAM_ACCESS_SUBMIT_ERROR = "Could not send your request. Try again.";
+
+export const TEAM_ACCESS_TEAM_SIZES = ["Just me", "2–10", "11–50", "50+"] as const;
+export const TEAM_ACCESS_FOUND_MEX = ["GitHub", "X", "friend", "community", "search", "other"] as const;
+export const TEAM_ACCESS_INSTALL_REASONS = ["Agent memory", "team consistency", "token cost", "curiosity"] as const;
+export const TEAM_ACCESS_REPO_KINDS = ["Work", "personal"] as const;
+export const TEAM_ACCESS_OTHERS_USE_AGENTS = ["Yes", "No"] as const;
+export const TEAM_ACCESS_NEEDS = ["Local only is fine", "Shared team memory", "Not sure"] as const;
+
+export interface TeamAccessContact {
+ name: string;
+ email: string;
+}
+
+export interface TeamAccessFollowUp extends TeamAccessContact {
+ company: string;
+ teamSize: string;
+ foundMex: string;
+ installReason: string;
+ repoKind: string;
+ othersUseAgents: string;
+ need: string;
+ missing: string;
+}
+
+const NAME_MAX = 200;
+const EMAIL_MAX = 320;
+const COMPANY_MAX = 200;
+const MISSING_MAX = 240;
+const SUBMIT_TIMEOUT_MS = 15_000;
+
+let accessKeyOverride: string | null = null;
+
+/** Test seam: inject a public access key without touching import.meta.env. */
+export function __setWeb3FormsAccessKeyForTests(value: string | null): void {
+ accessKeyOverride = value;
+}
+
+export function getWeb3FormsAccessKey(): string {
+ if (accessKeyOverride !== null) return accessKeyOverride;
+ const fromEnv = import.meta.env.VITE_WEB3FORMS_ACCESS_KEY;
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
+ return WEB3FORMS_ACCESS_KEY.trim();
+}
+
+export function boundName(value: string): string {
+ return value.trim().slice(0, NAME_MAX);
+}
+
+export function boundEmail(value: string): string {
+ return value.trim().slice(0, EMAIL_MAX);
+}
+
+export function boundCompany(value: string): string {
+ return value.trim().slice(0, COMPANY_MAX);
+}
+
+export function boundMissing(value: string): string {
+ return value.trim().slice(0, MISSING_MAX);
+}
+
+function includeAllowed(
+ payload: Record,
+ key: string,
+ value: string,
+ allowed: readonly string[],
+): void {
+ if (allowed.includes(value)) payload[key] = value;
+}
+
+export function validateTeamAccessContact(name: string, email: string): {
+ name?: string;
+ email?: string;
+} {
+ const errors: { name?: string; email?: string } = {};
+ if (boundName(name) === "") errors.name = "Enter your name.";
+ const trimmedEmail = boundEmail(email);
+ if (trimmedEmail === "") errors.email = "Enter your email.";
+ else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) errors.email = "Enter a valid email.";
+ return errors;
+}
+
+export function buildTeamAccessContactPayload(contact: TeamAccessContact, accessKey = getWeb3FormsAccessKey()) {
+ return {
+ access_key: accessKey,
+ name: boundName(contact.name),
+ email: boundEmail(contact.email),
+ subject: TEAM_ACCESS_SUBJECT,
+ from_name: TEAM_ACCESS_FROM_NAME,
+ source: TEAM_ACCESS_SOURCE,
+ };
+}
+
+export function buildTeamAccessFollowUpPayload(details: TeamAccessFollowUp, accessKey = getWeb3FormsAccessKey()) {
+ const payload: Record = {
+ access_key: accessKey,
+ name: boundName(details.name),
+ email: boundEmail(details.email),
+ subject: TEAM_ACCESS_FOLLOW_UP_SUBJECT,
+ from_name: TEAM_ACCESS_FROM_NAME,
+ source: TEAM_ACCESS_SOURCE,
+ };
+ const company = boundCompany(details.company);
+ if (company !== "") payload.company = company;
+ includeAllowed(payload, "team_size", details.teamSize, TEAM_ACCESS_TEAM_SIZES);
+ includeAllowed(payload, "found_mex", details.foundMex, TEAM_ACCESS_FOUND_MEX);
+ includeAllowed(payload, "install_reason", details.installReason, TEAM_ACCESS_INSTALL_REASONS);
+ includeAllowed(payload, "repo_kind", details.repoKind, TEAM_ACCESS_REPO_KINDS);
+ includeAllowed(payload, "others_use_agents", details.othersUseAgents, TEAM_ACCESS_OTHERS_USE_AGENTS);
+ includeAllowed(payload, "i_need", details.need, TEAM_ACCESS_NEEDS);
+ const missing = boundMissing(details.missing);
+ if (missing !== "") payload.whats_missing = missing;
+ return payload;
+}
+
+export async function submitTeamAccessPayload(
+ payload: Record,
+ fetchImpl: typeof fetch = fetch,
+): Promise<{ ok: true } | { ok: false; message: string }> {
+ if (payload.access_key.trim() === "") {
+ return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR };
+ }
+ const controller = new AbortController();
+ let timeout: ReturnType | undefined;
+ try {
+ const expired = new Promise((resolve) => {
+ timeout = setTimeout(() => {
+ controller.abort();
+ resolve(false);
+ }, SUBMIT_TIMEOUT_MS);
+ });
+ // Race the complete read so a stalled response body cannot lock the dialog.
+ const accepted = await Promise.race([
+ (async () => {
+ const response = await fetchImpl(WEB3FORMS_SUBMIT_URL, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+ const body: unknown = await response.json().catch(() => null);
+ return response.ok && isWeb3FormsSuccess(body);
+ })(),
+ expired,
+ ]);
+ if (accepted) return { ok: true };
+ return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR };
+ } catch {
+ return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR };
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
+ }
+}
+
+function isWeb3FormsSuccess(body: unknown): boolean {
+ return typeof body === "object" && body !== null && "success" in body && body.success === true;
+}
diff --git a/packages/hub-web/src/lib/team-access-state.ts b/packages/hub-web/src/lib/team-access-state.ts
new file mode 100644
index 00000000..125a14ec
--- /dev/null
+++ b/packages/hub-web/src/lib/team-access-state.ts
@@ -0,0 +1,45 @@
+export const TEAM_ACCESS_STORAGE_KEY = "mex.hub.team-access.v1";
+
+export interface TeamAccessLocalState {
+ contactSent: true;
+}
+
+export function readTeamAccessState(storage: Pick | null = defaultStorage()): TeamAccessLocalState | null {
+ if (storage === null) return null;
+ try {
+ const raw = storage.getItem(TEAM_ACCESS_STORAGE_KEY);
+ if (raw === null || raw === "") return null;
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ typeof parsed === "object"
+ && parsed !== null
+ && "contactSent" in parsed
+ && parsed.contactSent === true
+ ) {
+ return { contactSent: true };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeTeamAccessState(
+ state: TeamAccessLocalState,
+ storage: Pick | null = defaultStorage(),
+): void {
+ if (storage === null) return;
+ try {
+ storage.setItem(TEAM_ACCESS_STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ // Private mode or quota must not block the in-memory done state.
+ }
+}
+
+function defaultStorage(): Storage | null {
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/hub-web/src/pages/HomeOverview.tsx b/packages/hub-web/src/pages/HomeOverview.tsx
index 91c433f6..6269722c 100644
--- a/packages/hub-web/src/pages/HomeOverview.tsx
+++ b/packages/hub-web/src/pages/HomeOverview.tsx
@@ -8,7 +8,6 @@ import {
CheckCircle2,
ChevronDown,
CircleDashed,
- ExternalLink,
GitBranch,
LoaderCircle,
Network,
@@ -80,6 +79,7 @@ import {
activitySubjectRoute,
} from "../lib/activity-presentation";
import { graphParseComposition, shortRepositoryHead } from "../lib/health-presentation";
+import { TeamAccessCard } from "./TeamAccessCard";
import homeStyles from "../styles/home.module.css";
type FocusPanel = Extract;
@@ -513,47 +513,6 @@ function ActivityRow({ item }: { item: ActivityItem }) {
);
}
-/** Hosted voluntary feedback; no address or installation ID passes through the Hub. */
-const UPDATES_FORM = "https://tally.so/r/KYjv4k";
-
-/**
- * The card is permanent and carries no dismissal, which is why it has to stay
- * quiet. Anything that cannot be put away has to be worth living with on every
- * visit, so this one states its offer once and never asks twice — no badge, no
- * count, nothing that reads as unresolved work.
- */
-function UpdatesSignupCard() {
- return (
-
-
-
-
Help shape MEX
-
-
-
-
- Tell us how you use MEX and what we should improve.
-
-
- {/*
- * The trailing arrow is the only remaining cue that this leaves the
- * Hub for a new tab, so it stays where the mail glyph did not.
- */}
- }
- size="sm"
- variant="outline"
- >
- Share feedback
-
-
-