Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,32 @@ placing anything. Specifically:

A running log of durable decisions and feedback captured into the project config. Newest first.

- 2026-06-26 — **Fix (onboarding sharing didn't save — one-tap + auto-save; SPEC 43 amended; on
`fix/onboarding-share-autosave`, PR pending).** User: "in intimacy & sexuality I click _share with partner all_
and Save, and it doesn't save — and let's save right away when clicked, same with all answers." **Diagnosed
(not assumed):** traced the code — intake sharing was written ONLY by the "Save changes" button
(`submitForm`→`submitSectionForm`). On a sensitive section the bulk "share all" didn't apply on the pick: it
popped an inline **confirm** (§8) that REPLACED the picker, so a person who clicked Save without first clicking
"Share it" silently lost the choice (Save persisted the old Private scopes). The single-scope `intake:setAnswerSharing`
channel existed but the form never called it. **Owner decisions (asked first, both forks):** **one tap, no confirm**
- **auto-save sharing AND answers**. Fix: **(1)** removed the §3.1/§8 sensitive-share confirm — `applyScope`/`applyBulk`
apply directly on one tap (safety preserved by the **default**: a sensitive answer still STARTS Private, so sharing
is still an explicit choice; the confirm machinery — `pendingShare`/`renderConfirm`/`SECTION_BULK` — is gone). **(2)**
new silent `intakeStore.autoSaveForm` (re-runs `intake:submitForm` WITHOUT the `busy` toggle, so controls don't
flicker) + a debounced (600ms) auto-save effect in `IntakeFormPanel` that fires on any answer or scope change **for a
COMPLETED section** (i.e. being edited); the primary button becomes **"Done"** (a flush + advance). A **first-time
section is unchanged** — it still uses the explicit **Continue** (which marks it complete; auto-save never completes
a section being filled the first time, so no premature portrait). Gate green: typecheck (all), lint, **855 desktop**
unit (3 reworked IntakeFormPanel RTL: per-question one-tap+auto-save, bulk share-all auto-save [the reported bug],
first-time-does-NOT-auto-save; removed the two confirm tests), **E2E** (the existing per-question-sharing decrypt
test extended: re-open the now-complete basics section → widen to +Sibling → it persists to the vault with NO Save
click). Visual QA (real Electron screenshot): the section reads clean — honest explainer, one-tap sharing chips, no
confirm clutter. Synced spec 43 (amendment). **Lesson: a confirm that REPLACES the control it guards is a silent
data-loss trap — a user reads the picker as "done" and the pending choice evaporates on the next click; and
per-question intake sharing must be writable on its own (`setAnswerSharing`) OR auto-saved, never gated solely behind
a section-level Save the user may not reach. ALSO (repeated my own footgun): `git checkout <file>` reverts UNCOMMITTED
work — and I started this fix on `main` without branching; commit/branch BEFORE injecting throwaway screenshots.**

- 2026-06-26 — **Build (owner AI intimacy-topic suggester — SPEC 08 §16.5a AI-assist; on `feat/ai-intimacy-topics`,
PR pending).** The last item of the 2026-06-26 batch. The owner-only Intimacy Topics settings control gains a
**"Suggest with AI"** affordance. **All 3 product/UX forks asked first (user-chosen):** lives **in the existing
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/e2e/launch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6592,7 +6592,7 @@ test('onboarding: nudge → turn fills a field → skip intimacy → portrait fe
}
});

test('onboarding per-question sharing: scope a section to Partner → the derived fact reaches the partner, not the sibling (43)', async () => {
test('onboarding per-question sharing: scope a section to Partner → fact reaches partner not sibling; editing auto-saves (43 + auto-save fix)', async () => {
const { userData, vault } = await seedReadyVault({ 'ai.enabled': true });
await createNodeSecretStore(userData, passthrough).set('anthropic.apiKey', 'sk-ant-e2e');
// Seed a partner B + a sibling C of the owner, and an in-progress intake with only `basics` to do.
Expand Down Expand Up @@ -6734,6 +6734,26 @@ test('onboarding per-question sharing: scope a section to Partner → the derive
expect(siblingCtx).not.toContain('Works as a nurse');
expect(siblingCtx).not.toContain('shared by people related to');
expect(siblingCtx).not.toContain('grief');

// Auto-save on edit (the reported bug fix): re-open the now-COMPLETE basics section and widen its sharing
// to also include Sibling. With NO "Save" click, the change must persist — a completed section auto-saves.
await w
.getByRole('button', { name: /The basics/i })
.first()
.click();
await expect(w.getByRole('button', { name: 'Done' })).toBeVisible(); // complete → auto-saving, "Done" not "Save"
await w.getByRole('button', { name: /this whole section/i }).click();
await w.getByRole('checkbox', { name: 'Sibling' }).click(); // Partner is already on; add Sibling
await w.keyboard.press('Escape');
// No Save/Done click — the auto-save persists the widened scope to the vault within the debounce window.
await expect
.poll(
async () =>
(await getIntakeSession(fs, key, 'owner-1'))?.sections.find((s) => s.id === 'basics')
?.answerSharing?.occupation ?? [],
{ timeout: 4000 },
)
.toContain('sibling');
} finally {
await app.close();
await rm(userData, { recursive: true, force: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,35 +65,6 @@ const intimacyMeta: IntakeSectionMeta = {
],
};

// A NON-restricted section (health) that nonetheless holds a restricted question (substancesUsed) — the
// catalog decides `restricted`, so the synthetic question ids must be real health ids (43 §8 bulk-confirm).
const healthMeta: IntakeSectionMeta = {
id: 'health',
title: 'Health & body',
blurb: 'Body & wellbeing.',
restricted: false,
adult: false,
tier: 'invited',
mode: 'form',
opener: 'A few about your body.',
questions: [
{
id: 'sleepSchedule',
type: 'singleChoice',
prompt: 'Sleep schedule',
required: false,
options: ['Early', 'Late'],
},
{
id: 'substancesUsed',
type: 'multiChoice',
prompt: 'Substances you use',
required: false,
options: ['Cannabis / weed', 'None'],
},
],
};

// An intimacy section carrying the anatomy questions + the 5-point activity matrix (neutral default rows, as
// the bridge sends them). The renderer re-resolves the matrix's oral rows live from the anatomy answers (46).
const intimacyMatrixMeta: IntakeSectionMeta = {
Expand Down Expand Up @@ -437,7 +408,7 @@ describe('IntakeFormPanel — per-question sharing (43)', () => {
expect(screen.getByText('Mixed')).toBeInTheDocument();
});

it('confirms before sharing a sensitive (restricted) answer, then carries the scope into the submit', async () => {
it('shares a sensitive answer on ONE tap (no confirm) and auto-saves a completed section', async () => {
const intakeSubmitForm = vi.fn(() =>
Promise.resolve({
session: {} as never,
Expand All @@ -450,46 +421,87 @@ describe('IntakeFormPanel — per-question sharing (43)', () => {
render(
<IntakeFormPanel
meta={intimacyMeta}
section={section({ id: 'intimacy', restricted: true })}
section={section({ id: 'intimacy', restricted: true, status: 'complete' })}
adultAcknowledged={true}
onAdvance={() => {}}
/>,
);
// The libido chip starts Private (sensitive). Open it and pick Partner → a confirm appears, not applied yet.
// The libido chip starts Private (sensitive). Pick Partner → it applies on ONE tap, no confirm.
fireEvent.click(screen.getByRole('button', { name: /Sex drive: private/i }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' }));
expect(await screen.findByText(/is sensitive — share it/i)).toBeInTheDocument();
// The confirm renders INLINE in the question's sharing slot (44 audit): it REPLACES the picker — the
// checkbox is gone — so it's co-located with the click, not a disconnected top banner.
expect(screen.queryByRole('checkbox', { name: 'Partner' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Share it' }));
expect(screen.queryByText(/is sensitive — share it/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Share it' })).not.toBeInTheDocument();
// No Save click — the completed section auto-saves the new scope (debounced).
await waitFor(
() =>
expect(intakeSubmitForm).toHaveBeenCalledWith(
expect.objectContaining({
sectionId: 'intimacy',
sharing: expect.objectContaining({ libido: ['partner'] }),
}),
),
{ timeout: 2000 },
);
});

fireEvent.click(screen.getByRole('button', { name: /Save changes|Continue/ }));
await waitFor(() =>
expect(intakeSubmitForm).toHaveBeenCalledWith(
expect.objectContaining({
sectionId: 'intimacy',
sharing: expect.objectContaining({ libido: ['partner'] }),
}),
),
it('bulk "share all → partner" on a completed section auto-saves every scope, no confirm (the reported bug)', async () => {
const intakeSubmitForm = vi.fn(() =>
Promise.resolve({
session: {} as never,
sections: [],
aiAvailable: true,
adultAcknowledged: true,
}),
);
installMockBridge({ intakeSubmitForm, relationshipsList: () => Promise.resolve([]) });
render(
<IntakeFormPanel
meta={intimacyMeta}
section={section({ id: 'intimacy', restricted: true, status: 'complete' })}
adultAcknowledged={true}
onAdvance={() => {}}
/>,
);
// Open the per-section bulk control and add Partner → applies to EVERY question on one tap, no confirm.
fireEvent.click(screen.getByRole('button', { name: /this whole section/i }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' }));
expect(screen.queryByText(/includes sensitive answers/i)).not.toBeInTheDocument();
// No Save click — it auto-saves the bulk scope across the section's questions.
await waitFor(
() =>
expect(intakeSubmitForm).toHaveBeenCalledWith(
expect.objectContaining({
sectionId: 'intimacy',
sharing: expect.objectContaining({ libido: ['partner'] }),
}),
),
{ timeout: 2000 },
);
});

it('confirms a bulk share when a non-restricted section holds a sensitive question (health/substances)', async () => {
installMockBridge({ relationshipsList: () => Promise.resolve([]) });
it('does NOT auto-save a first-time (incomplete) section — that still rides the explicit Continue', async () => {
const intakeSubmitForm = vi.fn(() =>
Promise.resolve({
session: {} as never,
sections: [],
aiAvailable: true,
adultAcknowledged: true,
}),
);
installMockBridge({ intakeSubmitForm, relationshipsList: () => Promise.resolve([]) });
render(
<IntakeFormPanel
meta={healthMeta}
section={section({ id: 'health' })}
adultAcknowledged={false}
meta={intimacyMeta}
section={section({ id: 'intimacy', restricted: true })} // no status:'complete' → first-time
adultAcknowledged={true}
onAdvance={() => {}}
/>,
);
// Open the per-section bulk control and add Partner → because `substancesUsed` is restricted, the §8
// confirm must appear before anything is shared (the bulk control must not bypass it).
fireEvent.click(screen.getByRole('button', { name: /this whole section/i }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' }));
expect(await screen.findByText(/includes sensitive answers/i)).toBeInTheDocument();
// Give the debounce window time to pass — a first-time section must NOT auto-submit.
await new Promise((r) => setTimeout(r, 800));
expect(intakeSubmitForm).not.toHaveBeenCalled();
});

it('offers the one-tap "refresh your portrait" once an edit makes the portrait stale', () => {
Expand Down
Loading