diff --git a/apps/web/harness/README.md b/apps/web/harness/README.md
new file mode 100644
index 00000000000..53549ab5e2d
--- /dev/null
+++ b/apps/web/harness/README.md
@@ -0,0 +1,28 @@
+# Onboarding team-step harness
+
+Renders `features/setup/flow/TeamStep` on its own — real component, real
+Tailwind theme, no backend — so the screen can be driven and screenshotted
+without a logged-in session.
+
+The hooks the step needs (user email, contacts, onboarding state, team
+queries/mutations, analytics) are swapped for fixtures in `mocks/` via Vite
+aliases; everything else is the real code.
+
+```bash
+bunx vite -c harness/vite.config.ts # http://localhost:5199
+bun harness/shoot.mjs ./shots # drive it + write screenshots
+```
+
+Query params:
+
+- `?scenario=prefill` (default) — work-domain user with same-domain contacts,
+ so teammates arrive pre-added to the invite list
+- `?scenario=plain` — personal-email user: no suggested domain, no prefill
+- `?theme=dark` — Macro Dark instead of Macro Light
+
+`shoot.mjs` also asserts behavior as it goes (which rows are pre-added, what
+the remove buttons drop, what the mutation and analytics event receive) and
+exits non-zero on a page error, so it doubles as a smoke test.
+
+This directory sits outside `tsconfig.json`'s `include` and knip's `project`
+globs on purpose — it is dev tooling, not shipped code.
diff --git a/apps/web/harness/harness.css b/apps/web/harness/harness.css
new file mode 100644
index 00000000000..4361ba60a92
--- /dev/null
+++ b/apps/web/harness/harness.css
@@ -0,0 +1,3 @@
+@import '../src/index.css';
+
+@source './main.tsx';
diff --git a/apps/web/harness/index.html b/apps/web/harness/index.html
new file mode 100644
index 00000000000..9fa6fa065cb
--- /dev/null
+++ b/apps/web/harness/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Team step harness
+
+
+
+
+
+
+
diff --git a/apps/web/harness/main.tsx b/apps/web/harness/main.tsx
new file mode 100644
index 00000000000..0cf03c5552a
--- /dev/null
+++ b/apps/web/harness/main.tsx
@@ -0,0 +1,63 @@
+import { render } from 'solid-js/web';
+import { NoiseBackground } from '../src/features/setup/flow/shared';
+import { TeamStep } from '../src/features/setup/flow/TeamStep';
+import { DEFAULT_THEMES } from '../src/features/theme/constants';
+import './harness.css';
+
+/** Paints one of the app's real theme presets onto :root. */
+function applyTheme(id: string) {
+ const theme = DEFAULT_THEMES.find((t) => t.id === id);
+ if (!theme) throw new Error(`no theme ${id}`);
+ const root = document.documentElement;
+ for (const [key, v] of Object.entries(
+ theme.tokens as Record
+ )) {
+ root.style.setProperty(`--${key}l`, `${v.l}`);
+ root.style.setProperty(`--${key}c`, `${v.c}`);
+ root.style.setProperty(`--${key}h`, `${v.h}deg`);
+ }
+ const tokens = theme.tokens as Record;
+ root.dataset.themeLight = tokens.b0.l > tokens.c0.l ? 'true' : 'false';
+}
+
+/**
+ * Renders the onboarding team step on its own, with the flow's card chrome
+ * copied from OnboardingFlow, so it can be screenshotted without a backend.
+ * `?scenario=plain` swaps in a personal-email user; `?theme=dark` flips it.
+ */
+function Harness() {
+ const params = new URLSearchParams(window.location.search);
+ applyTheme(params.get('theme') === 'dark' ? 'Macro Dark' : 'Macro Light');
+
+ return (
+
+
+
+
+
+
+
+
+ Macro is meant for teams
+
+
+ Macro is built to be used with others. Invite your team to
+ share docs, channels, and context from day one.
+
+ );
+}
+
+const root = document.getElementById('root');
+if (root) render(() => , root);
diff --git a/apps/web/harness/mocks/analytics.ts b/apps/web/harness/mocks/analytics.ts
new file mode 100644
index 00000000000..2ec759aceed
--- /dev/null
+++ b/apps/web/harness/mocks/analytics.ts
@@ -0,0 +1,4 @@
+export const useAnalytics = () => ({
+ track: (event: string, props?: unknown) =>
+ console.log('[analytics]', event, props),
+});
diff --git a/apps/web/harness/mocks/contacts.ts b/apps/web/harness/mocks/contacts.ts
new file mode 100644
index 00000000000..a890bccb6fe
--- /dev/null
+++ b/apps/web/harness/mocks/contacts.ts
@@ -0,0 +1,21 @@
+import { hasWorkDomain } from '../scenario';
+
+const WORK_CONTACTS = [
+ 'nikhil@macro.com',
+ 'priya@macro.com',
+ 'tom@macro.com',
+ 'sarah@macro.com',
+ 'diego@macro.com',
+ 'ade@macro.com',
+ 'ellen@macro.com',
+];
+
+// Personal-email users still have contacts — just none on a team domain.
+const PERSONAL_CONTACTS = ['mom@aol.com', 'nikhil@macro.com'];
+
+export const useContacts = () => () =>
+ (hasWorkDomain() ? WORK_CONTACTS : PERSONAL_CONTACTS).map((email) => ({
+ id: email,
+ email,
+ name: email.split('@')[0],
+ }));
diff --git a/apps/web/harness/mocks/invitations.ts b/apps/web/harness/mocks/invitations.ts
new file mode 100644
index 00000000000..ca55e60f6b8
--- /dev/null
+++ b/apps/web/harness/mocks/invitations.ts
@@ -0,0 +1,6 @@
+export const useUserInvitesQuery = () => ({ data: { invites: [] } });
+
+export const useJoinTeamMutation = () => ({
+ isPending: false,
+ mutate: (v: unknown) => console.log('[join-team]', v),
+});
diff --git a/apps/web/harness/mocks/onboarding.ts b/apps/web/harness/mocks/onboarding.ts
new file mode 100644
index 00000000000..b85a6107ae7
--- /dev/null
+++ b/apps/web/harness/mocks/onboarding.ts
@@ -0,0 +1,7 @@
+import { hasWorkDomain } from '../scenario';
+
+export const useOnboardingQuery = () => ({
+ data: {
+ suggested_team_domain: hasWorkDomain() ? 'macro.com' : null,
+ },
+});
diff --git a/apps/web/harness/mocks/teams.ts b/apps/web/harness/mocks/teams.ts
new file mode 100644
index 00000000000..c456d2fdb48
--- /dev/null
+++ b/apps/web/harness/mocks/teams.ts
@@ -0,0 +1,8 @@
+export const useUserTeamsQuery = () => ({ data: [] as { name: string }[] });
+
+export const useCreateTeamWithInvitesMutation = () => ({
+ isPending: false,
+ mutateAsync: async (body: unknown) => {
+ console.log('[create-team]', JSON.stringify(body));
+ },
+});
diff --git a/apps/web/harness/mocks/user.ts b/apps/web/harness/mocks/user.ts
new file mode 100644
index 00000000000..3afba5e6cde
--- /dev/null
+++ b/apps/web/harness/mocks/user.ts
@@ -0,0 +1,5 @@
+import { hasWorkDomain } from '../scenario';
+
+/** Mirrors the real hook: a hook returning an Accessor. */
+export const useEmail = () => () =>
+ hasWorkDomain() ? 'jacob@macro.com' : 'jacob@gmail.com';
diff --git a/apps/web/harness/scenario.ts b/apps/web/harness/scenario.ts
new file mode 100644
index 00000000000..dacf0291455
--- /dev/null
+++ b/apps/web/harness/scenario.ts
@@ -0,0 +1,6 @@
+/** Which fixture the page renders, read from `?scenario=`. */
+export const scenario = () =>
+ new URLSearchParams(window.location.search).get('scenario') ?? 'prefill';
+
+/** A work-domain user with same-domain contacts vs. a personal-email user. */
+export const hasWorkDomain = () => scenario() !== 'plain';
diff --git a/apps/web/harness/shoot.mjs b/apps/web/harness/shoot.mjs
new file mode 100644
index 00000000000..fc9eae5b79e
--- /dev/null
+++ b/apps/web/harness/shoot.mjs
@@ -0,0 +1,108 @@
+import { mkdirSync } from 'node:fs';
+import { chromium } from 'playwright';
+
+const OUT = process.argv[2] ?? '/tmp/shots';
+const BASE = 'http://127.0.0.1:5199';
+mkdirSync(OUT, { recursive: true });
+
+const browser = await chromium.launch({
+ executablePath: '/opt/pw-browsers/chromium',
+});
+const page = await browser.newPage({
+ viewport: { width: 900, height: 1000 },
+ deviceScaleFactor: 2,
+});
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+page.on('console', (m) => {
+ if (m.type() === 'error') errors.push(m.text());
+ else console.log(` console: ${m.text()}`);
+});
+
+const shot = (name) => page.screenshot({ path: `${OUT}/${name}.png` });
+
+const open = async (query) => {
+ await page.goto(`${BASE}/${query}`, { waitUntil: 'networkidle' });
+ await page.waitForSelector('#team-name');
+ await page.waitForTimeout(400);
+};
+
+const inviteValues = () =>
+ page.$$eval('input[id^="invite-"]', (els) => els.map((e) => e.value));
+
+// 1. The new default: same-domain teammates already in the list.
+await open('?scenario=prefill');
+console.log('prefilled rows:', await inviteValues());
+console.log('cta:', await page.textContent('button:has-text("Create team")'));
+await shot('01-prefilled-light');
+console.log(
+ 'remove buttons:',
+ await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length),
+ 'of',
+ (await inviteValues()).length,
+ 'rows'
+);
+console.log('page scrollable:', await page.evaluate(() => document.body.scrollHeight > window.innerHeight));
+
+// 2. Hover state on a remove button.
+await page.hover('button[aria-label="Don\'t invite tom@macro.com"]');
+await page.waitForTimeout(200);
+await shot('02-remove-hover');
+
+// 3. Two teammates removed — the X actually drops the right rows.
+await page.click('button[aria-label="Don\'t invite tom@macro.com"]');
+await page.click('button[aria-label="Don\'t invite ade@macro.com"]');
+await page.waitForTimeout(200);
+console.log('after removing 2:', await inviteValues());
+console.log('cta:', await page.textContent('button:has-text("Create team")'));
+await shot('03-after-removing-two');
+
+// 4. Typing into the trailing empty row still works, and the row grows an X.
+const slots = await page.$$('input[id^="invite-"]');
+await slots[slots.length - 1].fill('newhire@macro.com');
+await page.waitForTimeout(200);
+console.log('after typing:', await inviteValues());
+await shot('04-typed-into-empty-row');
+
+// 5. What actually gets submitted (logged by the mocked mutation).
+const submitted = [];
+page.on('console', (m) => {
+ if (m.text().startsWith('[create-team]')) submitted.push(m.text());
+ if (m.text().startsWith('[analytics]')) submitted.push(m.text());
+});
+await page.click('button:has-text("Create team")');
+await page.waitForTimeout(500);
+console.log('submitted:', submitted.join('\n '));
+
+// 6. Dark theme.
+await open('?scenario=prefill&theme=dark');
+await shot('05-prefilled-dark');
+
+// 7. Personal-email user: no domain, no prefill, plain two-slot form.
+await open('?scenario=plain');
+console.log('plain rows:', await inviteValues());
+console.log(
+ 'remove buttons on empty form:',
+ await page.$$eval('button[aria-label^="Remove"]', (e) => e.length),
+ '/ dont-invite buttons:',
+ await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length)
+);
+await shot('06-personal-email-plain');
+
+// 8. Plain form, one address typed by hand — X appears only on that row.
+await page.fill('#invite-0', 'someone@elsewhere.com');
+await page.waitForTimeout(200);
+console.log(
+ 'after typing one:',
+ await page.$$eval('button[aria-label^="Don\'t invite"]', (e) =>
+ e.map((b) => b.getAttribute('aria-label'))
+ )
+);
+await shot('07-plain-typed-one');
+
+await browser.close();
+if (errors.length) {
+ console.log('\nPAGE ERRORS:\n' + errors.join('\n'));
+ process.exit(1);
+}
+console.log('\nno page errors');
diff --git a/apps/web/harness/vite.config.ts b/apps/web/harness/vite.config.ts
new file mode 100644
index 00000000000..249b7fd7d06
--- /dev/null
+++ b/apps/web/harness/vite.config.ts
@@ -0,0 +1,38 @@
+import { fileURLToPath } from 'node:url';
+import tailwind from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+import solid from 'vite-plugin-solid';
+import solidSvg from 'vite-plugin-solid-svg';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+const r = (p: string) => fileURLToPath(new URL(p, import.meta.url));
+
+const mock = (find: RegExp, file: string) => ({
+ find,
+ replacement: r(`./mocks/${file}`),
+});
+
+export default defineConfig({
+ root: r('.'),
+ plugins: [
+ tailwind(),
+ tsconfigPaths({ root: r('..') }),
+ solid(),
+ solidSvg({ defaultAsComponent: true }),
+ ],
+ resolve: {
+ dedupe: ['solid-js'],
+ alias: [
+ mock(/^@app\/lib\/analytics\/analytics-context$/, 'analytics.ts'),
+ mock(/^@core\/context\/user$/, 'user.ts'),
+ mock(/^@queries\/contacts\/contacts$/, 'contacts.ts'),
+ mock(/^@queries\/onboarding$/, 'onboarding.ts'),
+ mock(/^@queries\/team\/invitations$/, 'invitations.ts'),
+ mock(/^@queries\/team\/teams$/, 'teams.ts'),
+ ],
+ },
+ server: {
+ port: 5199,
+ fs: { allow: [r('../../..')] },
+ },
+});
diff --git a/apps/web/src/features/setup/flow/OnboardingFlow.tsx b/apps/web/src/features/setup/flow/OnboardingFlow.tsx
index ab67e6fc949..baacaa35e68 100644
--- a/apps/web/src/features/setup/flow/OnboardingFlow.tsx
+++ b/apps/web/src/features/setup/flow/OnboardingFlow.tsx
@@ -185,7 +185,7 @@ function buildSteps(
...connectorSteps,
{
key: 'team',
- title: 'Set up your team',
+ title: 'Macro is meant for teams',
subtitle:
'Macro is built to be used with others. Invite your team to share docs, channels, and context from day one.',
render: (controls) => (
diff --git a/apps/web/src/features/setup/flow/TeamStep.tsx b/apps/web/src/features/setup/flow/TeamStep.tsx
index b67087b1ba3..09db5d3ddc7 100644
--- a/apps/web/src/features/setup/flow/TeamStep.tsx
+++ b/apps/web/src/features/setup/flow/TeamStep.tsx
@@ -3,6 +3,7 @@ import { useEmail } from '@core/context/user';
import { idToDisplayName } from '@core/user/util';
import CheckIcon from '@phosphor/check.svg';
import Plus from '@phosphor/plus.svg';
+import XIcon from '@phosphor/x.svg';
import { useContacts } from '@queries/contacts/contacts';
import { useOnboardingQuery } from '@queries/onboarding';
import {
@@ -26,14 +27,20 @@ import {
import {
ContinueButton,
deriveTeamName,
- emailDomain,
FormInput,
- isPlausibleEmail,
SkipButton,
} from './shared';
+import {
+ INITIAL_INVITE_SLOTS,
+ prefillableTeammates,
+ removeInviteSlot,
+ validInviteEmails,
+ withPrefilledTeammates,
+} from './teamInvites';
/** Set up your team: already a member → confirmation, pending invites →
- * join, otherwise create (with domain-derived prefill + suggestions). */
+ * join, otherwise create (with a domain-derived name and same-domain
+ * teammates pre-added to the invite list). */
export function TeamStep(props: {
onContinue: () => void;
onSkip: () => void;
@@ -133,11 +140,8 @@ function InvitesPanel(props: {
);
}
-const INITIAL_INVITE_SLOTS = ['', ''];
-const SUGGESTION_CAP = 6;
-
-/** Create a team: pre-derived name + same-domain invite suggestions when the
- * user has a custom domain; the plain form otherwise. */
+/** Create a team: pre-derived name + same-domain teammates already sitting in
+ * the invite list (remove to opt them out); the plain form otherwise. */
function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) {
const analytics = useAnalytics();
const email = useEmail();
@@ -162,40 +166,44 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) {
]);
let inviteListEl: HTMLDivElement | undefined;
- const validInvites = () =>
- [...new Set(inviteSlots().map((value) => value.trim()))].filter(
- (value) => isPlausibleEmail(value) && value !== email()
- );
+ const validInvites = () => validInviteEmails(inviteSlots(), email());
- const suggestions = createMemo(() => {
- const suffix = customDomain();
- if (!suffix) return [];
- const own = email();
- const taken = new Set(inviteSlots().map((value) => value.trim()));
- return contacts()
- .filter(
- (contact) =>
- contact.email !== own &&
- emailDomain(contact.email) === suffix &&
- !taken.has(contact.email)
- )
- .slice(0, SUGGESTION_CAP);
+ // Same-domain teammates are pre-added rather than offered: the default is
+ // "invite them", and removing a row is how you opt one out.
+ const [prefilled, setPrefilled] = createSignal([]);
+ let hasPrefilled = false;
+ createEffect(() => {
+ if (hasPrefilled) return;
+ const teammates = prefillableTeammates({
+ contacts: contacts(),
+ domain: customDomain(),
+ ownEmail: email(),
+ slots: inviteSlots(),
+ });
+ // Contacts and the domain suggestion land asynchronously — keep waiting
+ // (and keep tracking) until there's actually someone to pre-add.
+ if (teammates.length === 0) return;
+ hasPrefilled = true;
+ setPrefilled(teammates);
+ setInviteSlots((slots) => withPrefilledTeammates(slots, teammates));
});
- const addInvite = (address: string) => {
- setInviteSlots((slots) => {
- const empty = slots.findIndex((value) => value.trim() === '');
- if (empty === -1) return [...slots, address];
- return slots.map((value, i) => (i === empty ? address : value));
- });
+ /** Prefilled teammates the user took back out before submitting. */
+ const removedPrefills = () => {
+ const kept = new Set(validInvites());
+ return prefilled().filter((address) => !kept.has(address)).length;
};
+ // The X means "don't invite this person", so it belongs on rows that name
+ // one. Blank rows need no removing — they're dropped on submit anyway.
+ const canRemoveSlot = (value: string) => value.trim() !== '';
+
const addEmptyInvite = () => {
setInviteSlots((slots) => [...slots, '']);
requestAnimationFrame(() => {
- inviteListEl?.scrollTo({
- top: inviteListEl.scrollHeight,
+ inviteListEl?.lastElementChild?.scrollIntoView({
behavior: 'smooth',
+ block: 'nearest',
});
});
};
@@ -204,6 +212,8 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) {
if (createTeam.isPending || name().trim().length === 0) return;
// The mutation owns its toasts; stay put (form intact) on failure.
const invitesSent = validInvites().length;
+ const invitesPrefilled = prefilled().length;
+ const invitesRemoved = removedPrefills();
try {
await createTeam.mutateAsync({
name: name().trim(),
@@ -215,6 +225,8 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) {
analytics.track('onboarding_v4_team', {
action: 'created',
invites_sent: invitesSent,
+ invites_prefilled: invitesPrefilled,
+ invites_removed: invitesRemoved,
used_domain_suggestion: customDomain() !== undefined,
});
props.onContinue();
@@ -222,36 +234,78 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) {
return (
- {
- setNameTouched(true);
- setName(value);
- }}
- />
+ {/* Same row shape as an invite, with the remove gutter left empty, so
+ every input in the form shares one width. Labelled, because the
+ name arrives pre-filled — a placeholder alone would be invisible
+ exactly when the field needs explaining. */}
+
+
+ 0}>
+ {/* Say the quiet part out loud: these go out unless removed. */}
+
+ Your teammates at {customDomain()} are ready to invite — remove anyone
+ you'd rather leave out.
+
+
{/* Index, not For: slots are edited strings, and For keys by value —
- each keystroke would recreate the input node and drop focus. */}
-
(inviteListEl = el)}
- class="flex max-h-48 flex-col gap-3 overflow-y-auto overscroll-contain"
- >
+ each keystroke would recreate the input node and drop focus.
+ No inner scroller: the list opens pre-filled now, and a capped box
+ left a row sliced in half above the buttons — the flow's own
+ scroll container takes the height instead. */}
+