mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418dd50306 | ||
|
|
206acbc670 | ||
|
|
557d78b785 | ||
|
|
87309101ca | ||
|
|
ba3b37989f | ||
|
|
583d38eb8c | ||
|
|
d604cd7a11 | ||
|
|
ddf1e33a4e | ||
|
|
da1c310738 | ||
|
|
5c8c9cc9a5 | ||
|
|
c297f03b56 | ||
|
|
c79d993cf5 | ||
|
|
b5e6b70d54 | ||
|
|
2a2cbe9e85 | ||
|
|
eb54aa4daa | ||
|
|
e509ac732f | ||
|
|
a72fe9c06d | ||
|
|
450c527d1e | ||
|
|
6c7219c741 | ||
|
|
b02a105e10 | ||
|
|
f4687f95fe | ||
|
|
53a48ab6d5 | ||
|
|
a6233d0175 | ||
|
|
c776859183 | ||
|
|
cc12f5f0ff | ||
|
|
3659cc8c2f | ||
|
|
4b227ad62c | ||
|
|
7c8d91c89c | ||
|
|
fe1248c59d | ||
|
|
6883befff3 | ||
|
|
a06f1b69e8 | ||
|
|
636811e66a | ||
|
|
d825e3d80c | ||
|
|
13094267a6 | ||
|
|
ecc31a13a2 | ||
|
|
7a76936829 |
No files matched your search
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: 2026-08-31-expo-prebuild-native-regression
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: apps/mobile native projects (ios/, android/) and expo prebuild
|
||||
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
|
||||
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
|
||||
review_after: 2026-11-30
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
|
||||
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
|
||||
native invariants. Each regression was caught only by a release gate or by
|
||||
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
|
||||
prebuild itself.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
|
||||
the regenerated ios/ and android/ projects against the previously committed
|
||||
projects.
|
||||
|
||||
## Cause
|
||||
|
||||
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
|
||||
the target folder name, changing com.sauci.app.LiveDrawWidget to
|
||||
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
|
||||
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
|
||||
2. Android manifest lost the tools:node="remove" attributes on
|
||||
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
|
||||
Play-forbidden permissions (verified via bundletool dump on the rejected
|
||||
build 50 AAB).
|
||||
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
|
||||
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
|
||||
4. android/gradle.properties reset to stock -Xmx2048m
|
||||
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
|
||||
under RN 0.85.
|
||||
|
||||
Separately, a package absent from the new SDK's bundledNativeModules
|
||||
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
|
||||
imports removed ExpoModulesCore headers).
|
||||
|
||||
## Remediation
|
||||
|
||||
Fixed by restoring widget identity and tracking
|
||||
targets/widget/generated.entitlements with a pinned bundleIdentifier
|
||||
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
|
||||
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
|
||||
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
|
||||
expo-video/expo-audio (b8cb2add).
|
||||
|
||||
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
|
||||
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
|
||||
preflight check; item 4 hard-fails the build by itself). Add an "After
|
||||
native regeneration" checklist to docs/releasing.md.
|
||||
|
||||
## Evidence
|
||||
|
||||
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
|
||||
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
|
||||
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
|
||||
NSLocalNetworkUsageDescription, or RCTMetroPort.
|
||||
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
|
||||
checks.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 2026-08-31-local-eas-build-preconditions
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: apps/mobile local EAS release builds
|
||||
evidence: v1.0.8 local EAS build session, commit d8bedea
|
||||
destination: docs/releasing.md
|
||||
review_after: 2026-11-30
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
Local EAS release builds failed before native compilation for environment
|
||||
reasons unrelated to the app source, and a successful iOS build left the
|
||||
tracked source in a state that fails release-preflight afterward.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run `eas build --local` from a non-interactive shell without first exporting
|
||||
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
|
||||
found" because non-interactive shells do not load the user profile. Run it
|
||||
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
|
||||
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
|
||||
build log through `tail`: the full log needed for diagnosis is lost. After a
|
||||
successful iOS build, EAS auto-increment updates both Info.plists but not
|
||||
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
|
||||
release-preflight fails until it is synced.
|
||||
|
||||
## Cause
|
||||
|
||||
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
|
||||
23.0.0 verified). Non-interactive shells used for automation do not source
|
||||
the profile that normally exports the Android/Java environment variables.
|
||||
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
|
||||
auto-increment writes plist versions but does not touch the widget target's
|
||||
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
|
||||
one increment after every successful build.
|
||||
|
||||
## Remediation
|
||||
|
||||
Before a local EAS build: verify `eas` is on PATH, explicitly export
|
||||
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
|
||||
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
|
||||
apps/mobile. Capture the full build log to a file rather than piping through
|
||||
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
|
||||
to the new build number (commit d8bedea, sed 46 to 47) and sync
|
||||
ios/Podfile.lock from the successful build's working directory when
|
||||
dependencies changed, since the archive's pod install resolution is
|
||||
authoritative and local CocoaPods here is broken.
|
||||
|
||||
## Evidence
|
||||
|
||||
docs/releasing.md's local-build section documents the environment
|
||||
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
|
||||
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 2026-08-31-npm-lockfile-divergence
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: root package.json, package-lock.json, apps/web dependencies, CI
|
||||
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
|
||||
destination: docs/development/dependencies.md
|
||||
review_after: 2026-11-30
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
|
||||
invisibly while a lockfile regenerated on this dev machine (node 26/npm
|
||||
11.19) passed local `npm ci`. Pushes to staging bypass required status
|
||||
checks, so the red CI ran unnoticed for hours.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
|
||||
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
|
||||
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
|
||||
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
|
||||
"Invalid: lock file's ... does not satisfy". The reverse direction also
|
||||
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
|
||||
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
|
||||
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
|
||||
variants breaking local npm ci), matching the npm/cli#4828-style
|
||||
optional-dependency resolution gap.
|
||||
|
||||
## Cause
|
||||
|
||||
Two different npm major versions in active use (CI/Docker on npm 10, this
|
||||
dev machine on npm 11) resolve wildcard/range dependencies to different
|
||||
versions at the same tree position, and each npm's own platform-optional
|
||||
binary selection is not portable to the other npm's lockfile.
|
||||
|
||||
## Remediation
|
||||
|
||||
Pin the wildcard-range dependency via a root package.json override
|
||||
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
|
||||
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
|
||||
binaries as direct optionalDependencies (must be bumped together with any
|
||||
`next` version bump). Regenerate the lock and validate it both ways: local
|
||||
`npm ci` plus `npm run verify:fast`, and
|
||||
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
|
||||
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
|
||||
and the platform-binary check is meaningless). After any push that bypassed
|
||||
required status checks, check `gh run list --branch staging`.
|
||||
|
||||
## Evidence
|
||||
|
||||
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
|
||||
and record a lockfile that passes both the amd64-emulated `npm ci` under
|
||||
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: 2026-09-05-question-type-contract-drift
|
||||
date: 2026-09-05
|
||||
status: promoted
|
||||
scope: question interaction contract documentation
|
||||
evidence: npm run lint:harness and npm run learn:check
|
||||
destination: docs/question-types.md, scripts/lint-harness.mjs, AGENTS.md, and .codex/skills/question-pack/SKILL.md
|
||||
review_after: 2026-12-05
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
Maintained documentation did not identify the implemented question interaction
|
||||
types, their response payloads, or their matching semantics. The only detailed
|
||||
reference was a historical implementation plan, so content and product work
|
||||
could not rely on maintained documentation.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Compare `QuestionType` in `packages/shared/src/types/index.ts` and the question
|
||||
renderer and answer matching code with `docs/index.md`, `docs/schema.md`, and
|
||||
`docs/question-selection.md`.
|
||||
|
||||
## Cause
|
||||
|
||||
The interaction contract was added to code without a maintained documentation
|
||||
owner or a verification check linking the document to the canonical union.
|
||||
|
||||
## Remediation
|
||||
|
||||
Create `docs/question-types.md` as the maintained interaction contract. Require
|
||||
agents and the question-pack workflow to update it with type or semantic
|
||||
changes. Add a harness invariant that compares its type marker to the shared
|
||||
`QuestionType` union.
|
||||
|
||||
## Evidence
|
||||
|
||||
`npm run lint:harness` reads the shared union and the documentation marker and
|
||||
fails when their ordered type lists differ. `npm run learn:check` validates this
|
||||
promoted learning record and its destination metadata.
|
||||
@@ -7,6 +7,11 @@ description: Create or review Sauci question-pack content and its local migratio
|
||||
|
||||
Inspect existing packs before generating to avoid duplication.
|
||||
|
||||
Before creating or editing content that uses a question interaction type, read
|
||||
`docs/question-types.md`. If the type, response payload, configuration, or
|
||||
matching semantics change, update that maintained contract and run
|
||||
`npm run lint:harness` in the same task.
|
||||
|
||||
- Write proposals, not interview questions; use gender-neutral “your partner”.
|
||||
- Avoid time-bound words such as tonight, now, or today.
|
||||
- Symmetric activities have no partner text.
|
||||
|
||||
@@ -40,6 +40,10 @@ MCP service in one npm/Turborepo workspace.
|
||||
11. Use high-effort reviewers only for the risk triggers in
|
||||
`docs/agents/routing.md`.
|
||||
12. Agents must not use em dash characters in text they add to this repository.
|
||||
13. Before beginning implementation, tell the user whether the proposed change
|
||||
is likely deliverable through an Expo OTA update, likely requires a new
|
||||
native/store build, or has no applicable OTA path, with a brief reason. If
|
||||
uncertain, state that uncertainty and name the validation needed.
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -76,6 +80,7 @@ environment limitation; do not call it a passing product check.
|
||||
|---|---|
|
||||
| System boundaries and data flow | `docs/architecture/system.md` |
|
||||
| Local services, ports, fixtures | `docs/development/local-stack.md` |
|
||||
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
|
||||
| Test layers and required gates | `docs/testing/strategy.md` |
|
||||
| Database migration safety | `docs/operations/database-migrations.md` |
|
||||
| Agent roles and effort routing | `docs/agents/routing.md` |
|
||||
@@ -86,6 +91,9 @@ environment limitation; do not call it a passing product check.
|
||||
## Documentation discipline
|
||||
|
||||
Update the maintained doc when a contract, command, port, or invariant changes.
|
||||
When question interaction types, response payloads, configuration, or match
|
||||
semantics change, update `docs/question-types.md` in the same change and run
|
||||
`npm run lint:harness` to prove its type list matches the shared contract.
|
||||
Plans and historical notes are evidence, not current architecture. A repeated
|
||||
lesson becomes permanent only after it is reproduced and promoted into a doc,
|
||||
skill, fixture, or executable check.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { featureInterestKey, formatFeatureName, toRecentFeatureInterest } from './featureInterests';
|
||||
|
||||
describe('toRecentFeatureInterest', () => {
|
||||
it('preserves the standalone API feature field for dashboard rendering', () => {
|
||||
const interest = toRecentFeatureInterest({
|
||||
user_id: 'user-1',
|
||||
feature: 'better_chat',
|
||||
created_at: '2026-09-05T12:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(interest).toEqual({
|
||||
user_id: 'user-1',
|
||||
feature: 'better_chat',
|
||||
created_at: '2026-09-05T12:00:00.000Z',
|
||||
});
|
||||
expect(formatFeatureName(interest.feature)).toBe('Better Chat');
|
||||
expect(featureInterestKey(interest)).toBe('user-1:better_chat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface FeatureInterestRow {
|
||||
user_id: string;
|
||||
feature: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RecentFeatureInterest extends FeatureInterestRow {
|
||||
profile?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function toRecentFeatureInterest(row: FeatureInterestRow): RecentFeatureInterest {
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
feature: row.feature,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function featureInterestKey(row: Pick<FeatureInterestRow, 'user_id' | 'feature'>): string {
|
||||
return `${row.user_id}:${row.feature}`;
|
||||
}
|
||||
|
||||
export function formatFeatureName(feature: string): string {
|
||||
return feature
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
@@ -10,36 +10,22 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recha
|
||||
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
formatFeatureName,
|
||||
featureInterestKey,
|
||||
toRecentFeatureInterest,
|
||||
type FeatureInterestRow,
|
||||
type RecentFeatureInterest,
|
||||
} from '@/lib/featureInterests';
|
||||
|
||||
type FeatureInterestChartPoint = { date: string; count: number };
|
||||
|
||||
type RecentFeatureInterest = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
feature_name: string;
|
||||
created_at: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type FeatureInterestCountsRow = {
|
||||
feature_name: string;
|
||||
opt_in_count: number;
|
||||
opt_in_count_last_7_days: number;
|
||||
};
|
||||
|
||||
function formatFeatureName(featureName: string) {
|
||||
return featureName
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||
@@ -95,7 +81,7 @@ export function DashboardPage() {
|
||||
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
? adminData.from<FeatureInterestRow>('feature_interests').select('user_id, feature, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
@@ -158,12 +144,7 @@ export function DashboardPage() {
|
||||
}))
|
||||
);
|
||||
|
||||
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({
|
||||
id: item.id,
|
||||
user_id: item.user_id,
|
||||
feature_name: item.feature_name,
|
||||
created_at: item.created_at,
|
||||
}));
|
||||
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(toRecentFeatureInterest);
|
||||
|
||||
if (canViewUsers && recentActivity.length > 0) {
|
||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||
@@ -579,14 +560,14 @@ export function DashboardPage() {
|
||||
if (item.profile?.name && item.profile.email) {
|
||||
subtitleParts.push(item.profile.email);
|
||||
}
|
||||
subtitleParts.push(formatFeatureName(item.feature_name));
|
||||
subtitleParts.push(formatFeatureName(item.feature));
|
||||
|
||||
const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
|
||||
<div key={featureInterestKey(item)} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={item.profile?.avatar_url || undefined} />
|
||||
|
||||
@@ -13,3 +13,5 @@ native iOS/Android targets.
|
||||
- Add stable accessibility labels or `testID` values for critical interactions.
|
||||
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
|
||||
requires. UI work needs observable simulator evidence.
|
||||
- Before and after any `expo prebuild`, follow the "After native regeneration"
|
||||
checklist in `docs/releasing.md`.
|
||||
@@ -25,7 +25,7 @@
|
||||
"group.com.sauci.app"
|
||||
]
|
||||
},
|
||||
"buildNumber": "47",
|
||||
"buildNumber": "49",
|
||||
"usesAppleSignIn": true,
|
||||
"googleServicesFile": "./GoogleService-Info.plist",
|
||||
"infoPlist": {
|
||||
|
||||
@@ -69,10 +69,12 @@ export default function MatchesScreen() {
|
||||
checkNudgeCooldown,
|
||||
} = useMatchStore();
|
||||
const { user, couple, partner } = useAuthStore();
|
||||
const userId = user?.id;
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const tutorialDismissedRef = useRef(false);
|
||||
const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(130);
|
||||
|
||||
@@ -104,21 +106,30 @@ export default function MatchesScreen() {
|
||||
// Check if tutorial should be shown when screen is focused or matches change
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let isActive = true;
|
||||
|
||||
const checkTutorial = async () => {
|
||||
if (matches.length > 0) {
|
||||
const seen = await hasSeenMatchesTutorial();
|
||||
if (!seen) {
|
||||
if (matches.length > 0 && userId) {
|
||||
const seen = await hasSeenMatchesTutorial(userId);
|
||||
if (isActive && !tutorialDismissedRef.current && !seen) {
|
||||
setShowTutorial(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkTutorial();
|
||||
}, [matches.length])
|
||||
void checkTutorial();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [matches.length, userId])
|
||||
);
|
||||
|
||||
const handleTutorialComplete = async () => {
|
||||
await markMatchesTutorialSeen();
|
||||
if (!userId) return;
|
||||
|
||||
tutorialDismissedRef.current = true;
|
||||
setShowTutorial(false);
|
||||
await markMatchesTutorialSeen(userId);
|
||||
};
|
||||
|
||||
// Load whichever view is showing, once. Returning to this tab reuses what is
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
Platform,
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInUp,
|
||||
FadeOut,
|
||||
SlideInRight,
|
||||
SlideOutLeft,
|
||||
@@ -32,8 +30,8 @@ import { getProfileError } from '../../src/lib/errors';
|
||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
|
||||
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage, type OnboardingStage } from '../../src/lib/onboardingStages';
|
||||
import type { Gender, UsageReason } from '../../src/types';
|
||||
|
||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
@@ -51,15 +49,17 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
||||
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
||||
];
|
||||
|
||||
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
|
||||
type Stage = OnboardingStage;
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
// Only subscribe to stable values to prevent re-renders from background fetchUser calls
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const persistedUserName = useAuthStore((s) => s.user?.name);
|
||||
const pendingAppleDisplayName = useAuthStore((s) => s.pendingAppleDisplayName);
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser);
|
||||
const initialUser = useRef(useAuthStore.getState().user);
|
||||
const [stage, setStage] = useState<Stage>('avatar');
|
||||
const [stage, setStage] = useState<Stage>(() => initialOnboardingStage(initialUser.current?.name));
|
||||
const [name, setName] = useState(initialUser.current?.name || '');
|
||||
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
|
||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||
@@ -69,36 +69,16 @@ export default function OnboardingScreen() {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const paywallNavigationRef = useRef(false);
|
||||
|
||||
const {
|
||||
avatarUri,
|
||||
isUploading: isUploadingAvatar,
|
||||
showPicker: handleAvatarPress,
|
||||
uploadAvatar,
|
||||
} = useAvatarPicker({
|
||||
userId,
|
||||
onSelect: () => setError(null),
|
||||
});
|
||||
const pendingDisplayName = pendingAppleDisplayName?.userId === userId ? pendingAppleDisplayName?.name : undefined;
|
||||
const displayName = persistedUserName?.trim() || pendingDisplayName || name;
|
||||
const hasStoredDisplayName = Boolean(persistedUserName?.trim() || pendingDisplayName);
|
||||
const activeStage = activeOnboardingStage(stage, persistedUserName, pendingDisplayName);
|
||||
const previousStage = previousOnboardingStage(activeStage, hasStoredDisplayName);
|
||||
|
||||
useEffect(() => {
|
||||
Events.onboardingStart();
|
||||
}, []);
|
||||
|
||||
const handleAvatarContinue = () => {
|
||||
if (!avatarUri) {
|
||||
setError('Please add a profile photo to continue');
|
||||
return;
|
||||
}
|
||||
setStage('name');
|
||||
Events.onboardingStageComplete('avatar');
|
||||
};
|
||||
|
||||
const handleAvatarSkip = () => {
|
||||
setError(null);
|
||||
setStage('name');
|
||||
Events.avatarSkipped();
|
||||
Events.onboardingStageComplete('avatar');
|
||||
};
|
||||
|
||||
const handleNameSubmit = () => {
|
||||
if (!name.trim()) {
|
||||
setError('Please enter your name');
|
||||
@@ -154,7 +134,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name);
|
||||
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', displayName);
|
||||
if (!userId) {
|
||||
console.error('[Onboarding] No user ID available');
|
||||
setError('Not logged in. Please restart the app.');
|
||||
@@ -165,19 +145,10 @@ export default function OnboardingScreen() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Upload avatar if one was selected
|
||||
let avatarUrl: string | null = null;
|
||||
if (avatarUri) {
|
||||
avatarUrl = await uploadAvatar();
|
||||
if (avatarUrl) {
|
||||
Events.avatarUploaded();
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
|
||||
|
||||
const updateData = {
|
||||
name: name.trim(),
|
||||
name: displayName.trim(),
|
||||
gender,
|
||||
usage_reason: usageReason,
|
||||
// Preserve the safest legacy profile values. Catalogue
|
||||
@@ -191,10 +162,6 @@ export default function OnboardingScreen() {
|
||||
|
||||
await profileSettingsApi.updateProfile(updateData);
|
||||
|
||||
if (avatarUrl) {
|
||||
updatedFields.push("avatar_url");
|
||||
}
|
||||
|
||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||
await fetchUser();
|
||||
console.log('[Onboarding] User fetched, navigating to home...');
|
||||
@@ -231,71 +198,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const renderStage = () => {
|
||||
switch (stage) {
|
||||
case 'avatar':
|
||||
return (
|
||||
<Animated.View
|
||||
key="avatar"
|
||||
entering={FadeInUp.duration(500)}
|
||||
exiting={SlideOutLeft.duration(300)}
|
||||
style={styles.stageContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="heart" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.title}>Welcome to Sauci</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Add a profile photo so your partner knows it's you
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<GlassCard style={styles.card}>
|
||||
<View style={styles.avatarSection}>
|
||||
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
|
||||
{avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<View
|
||||
style={styles.avatarPlaceholder}
|
||||
>
|
||||
<Ionicons name="camera" size={40} color={colors.textSecondary} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.avatarBadge}>
|
||||
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={styles.avatarHint}>
|
||||
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
|
||||
</Text>
|
||||
</View>
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
|
||||
Continue
|
||||
</GlassButton>
|
||||
<Pressable
|
||||
style={styles.skipButton}
|
||||
onPress={handleAvatarSkip}
|
||||
testID="avatar-skip-button"
|
||||
>
|
||||
<Text style={styles.skipText}>Skip for now</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
switch (activeStage) {
|
||||
case 'name':
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -305,16 +208,12 @@ export default function OnboardingScreen() {
|
||||
style={styles.stageContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
{avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="person" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
)}
|
||||
<Text style={styles.title}>What can we call you?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Nicknames and pet names are welcome too!
|
||||
@@ -368,7 +267,7 @@ export default function OnboardingScreen() {
|
||||
style={styles.stageContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.greeting}>Hey, {name.trim()}!</Text>
|
||||
<Text style={styles.greeting}>Hey, {displayName.trim()}!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This helps us show you the right questions
|
||||
</Text>
|
||||
@@ -576,15 +475,12 @@ export default function OnboardingScreen() {
|
||||
>
|
||||
{/* Header with back button and progress dots */}
|
||||
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
||||
{stage !== 'avatar' ? (
|
||||
{previousStage ? (
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => {
|
||||
setError(null);
|
||||
if (stage === 'notifications') setStage('purpose');
|
||||
else if (stage === 'purpose') setStage('gender');
|
||||
else if (stage === 'gender') setStage('name');
|
||||
else if (stage === 'name') setStage('avatar');
|
||||
setStage(previousStage);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
@@ -593,11 +489,10 @@ export default function OnboardingScreen() {
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'name' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'gender' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'purpose' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'notifications' && styles.progressDotActive]} />
|
||||
</View>
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
</Animated.View>
|
||||
@@ -653,18 +548,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
avatarPlaceholder: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
borderStyle: 'dashed',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
// ...
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -757,47 +640,6 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
headerAvatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
avatarTouchable: {
|
||||
position: 'relative',
|
||||
},
|
||||
avatarImage: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
borderWidth: 3,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
|
||||
avatarBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background,
|
||||
},
|
||||
avatarHint: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
title: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
|
||||
@@ -46,6 +46,11 @@ if (Platform.OS === "android") {
|
||||
import { authClient } from "../../src/lib/authClient";
|
||||
import { getAuthError } from "../../src/lib/errors";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { apiRequestWithAccessToken } from "../../src/lib/apiClient";
|
||||
import { formatAppleProfileName } from "../../src/lib/appleProfileName";
|
||||
import { persistAppleProfileName } from "../../src/lib/appleProfileNamePersistence";
|
||||
import { profileSettingsApi } from "../../src/lib/profileSettingsApi";
|
||||
import type { Profile } from "../../src/types";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography } from "../../src/theme";
|
||||
@@ -290,7 +295,7 @@ export default function LoginScreen() {
|
||||
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
|
||||
|
||||
if (credential.identityToken) {
|
||||
const { error } = await authClient.auth.signInWithIdToken({
|
||||
const { data, error } = await authClient.auth.signInWithIdToken({
|
||||
provider: "apple",
|
||||
token: credential.identityToken,
|
||||
});
|
||||
@@ -299,6 +304,36 @@ export default function LoginScreen() {
|
||||
console.log("[Apple Sign In] Supabase error:", error.message);
|
||||
Alert.alert("Error", getAuthError(error));
|
||||
} else {
|
||||
const appleProfileName = formatAppleProfileName(credential.fullName);
|
||||
const userId = data.user?.id;
|
||||
const accessToken = data.session?.access_token;
|
||||
if (appleProfileName && userId && accessToken) {
|
||||
const authStore = useAuthStore.getState();
|
||||
authStore.setPendingAppleDisplayName({ userId, name: appleProfileName });
|
||||
try {
|
||||
await persistAppleProfileName(
|
||||
{ userId, accessToken },
|
||||
appleProfileName,
|
||||
{
|
||||
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
|
||||
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
|
||||
},
|
||||
);
|
||||
await authStore.fetchUser({
|
||||
userId,
|
||||
accessToken,
|
||||
isAnonymous: !!(data.user as { is_anonymous?: boolean } | null)?.is_anonymous,
|
||||
});
|
||||
} catch {
|
||||
// The account session remains valid. Clear the subject-keyed
|
||||
// temporary name so onboarding safely falls back to its name step.
|
||||
console.warn("[Apple Sign In] Could not persist supplied name");
|
||||
} finally {
|
||||
useAuthStore.getState().clearPendingAppleDisplayName(userId);
|
||||
}
|
||||
} else if (appleProfileName) {
|
||||
Alert.alert("Error", "Apple sign in did not return an authenticated session. Please try again.");
|
||||
}
|
||||
Events.signIn("apple");
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -844,7 +844,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -885,7 +885,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>47</string>
|
||||
<string>49</string>
|
||||
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
||||
<false/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -39,6 +39,7 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
|
||||
const androidGradle = file('apps/mobile/android/app/build.gradle');
|
||||
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
|
||||
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
|
||||
const androidManifest = file('apps/mobile/android/app/src/main/AndroidManifest.xml');
|
||||
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
|
||||
const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
|
||||
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
|
||||
@@ -70,6 +71,17 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
|
||||
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const permission of ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO']) {
|
||||
const removed = new RegExp(`<uses-permission[^>]*android:name="android\\.permission\\.${permission}"[^>]*tools:node="remove"`).test(androidManifest);
|
||||
if (!removed) fail(`AndroidManifest.xml must keep tools:node="remove" on ${permission} (expo prebuild regresses this)`);
|
||||
}
|
||||
|
||||
const forbiddenPlistKeys = ['NSBonjourServices', 'NSLocalNetworkUsageDescription', 'RCTMetroPort'];
|
||||
const foundDevLauncherKeys = forbiddenPlistKeys.filter((key) => iosInfo.includes(key));
|
||||
if (foundDevLauncherKeys.length) {
|
||||
fail(`ios/Sauci/Info.plist must not contain dev-launcher-only keys reintroduced by expo prebuild: ${foundDevLauncherKeys.join(', ')}`);
|
||||
}
|
||||
|
||||
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
|
||||
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
|
||||
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
|
||||
|
||||
@@ -67,6 +67,38 @@ test('rejects an OTA certificate without its root archive allowlist', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an Android manifest that lost the media permission removal (expo prebuild regression)', () => {
|
||||
const root = fixture();
|
||||
try {
|
||||
const manifest = 'apps/mobile/android/app/src/main/AndroidManifest.xml';
|
||||
replace(
|
||||
root,
|
||||
manifest,
|
||||
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>',
|
||||
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>',
|
||||
);
|
||||
assert.throws(() => validate(root), /READ_MEDIA_IMAGES \(expo prebuild regresses this\)/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an iOS Info.plist that regained dev-launcher-only keys (expo prebuild regression)', () => {
|
||||
const root = fixture();
|
||||
try {
|
||||
const infoPlist = 'apps/mobile/ios/Sauci/Info.plist';
|
||||
replace(
|
||||
root,
|
||||
infoPlist,
|
||||
'</dict>\n</plist>',
|
||||
'<key>NSBonjourServices</key>\n<array><string>_expo._tcp</string></array>\n</dict>\n</plist>',
|
||||
);
|
||||
assert.throws(() => validate(root), /dev-launcher-only keys reintroduced by expo prebuild: NSBonjourServices/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires the local EAS plugin executable rather than its package directory', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
|
||||
const executable = join(root, 'bin-run');
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { ActionSheetIOS, Alert, Platform } from 'react-native';
|
||||
import { ActionSheetIOS, Alert, Linking, Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
|
||||
|
||||
jest.mock('@/lib/mediaApi', () => ({
|
||||
uploadMedia: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('expo-file-system/legacy', () => ({
|
||||
getInfoAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('expo-image-picker', () => ({
|
||||
getCameraPermissionsAsync: jest.fn(),
|
||||
requestCameraPermissionsAsync: jest.fn(),
|
||||
requestMediaLibraryPermissionsAsync: jest.fn(),
|
||||
launchCameraAsync: jest.fn(),
|
||||
@@ -23,7 +28,10 @@ describe('useAvatarPicker', () => {
|
||||
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
||||
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
|
||||
jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined);
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
|
||||
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined', canAskAgain: true });
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
|
||||
});
|
||||
|
||||
@@ -64,4 +72,41 @@ describe('useAvatarPicker', () => {
|
||||
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not show a custom alert immediately after a camera permission denial', async () => {
|
||||
const { result } = renderHook(() => useAvatarPicker());
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
|
||||
|
||||
act(() => result.current.showPicker());
|
||||
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
|
||||
(option: { text: string }) => option.text === 'Take Photo'
|
||||
);
|
||||
|
||||
await act(async () => takePhoto.onPress());
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledTimes(1);
|
||||
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers Settings only for a later camera attempt that iOS cannot prompt again', async () => {
|
||||
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
|
||||
const { result } = renderHook(() => useAvatarPicker());
|
||||
|
||||
act(() => result.current.showPicker());
|
||||
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
|
||||
(option: { text: string }) => option.text === 'Take Photo'
|
||||
);
|
||||
|
||||
await act(async () => takePhoto.onPress());
|
||||
|
||||
const settingsAlert = (Alert.alert as jest.Mock).mock.calls[1];
|
||||
expect(settingsAlert[0]).toBe('Camera Access');
|
||||
expect(settingsAlert[2]).toEqual([
|
||||
{ text: 'Not Now', style: 'cancel' },
|
||||
expect.objectContaining({ text: 'Open Settings' }),
|
||||
]);
|
||||
await act(async () => settingsAlert[2][1].onPress());
|
||||
expect(Linking.openSettings).toHaveBeenCalledTimes(1);
|
||||
expect(ImagePicker.requestCameraPermissionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, apiRequest } from "@/lib/apiClient";
|
||||
import { ApiError, apiRequest, apiRequestWithAccessToken } from "@/lib/apiClient";
|
||||
import { authClient } from "@/lib/authClient";
|
||||
|
||||
function response(status: number, body?: unknown) {
|
||||
@@ -40,6 +40,17 @@ describe("apiClient", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses an explicitly captured bearer without reading a mutable auth session", async () => {
|
||||
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
|
||||
(authClient.auth.getSession as jest.Mock).mockClear();
|
||||
|
||||
await apiRequestWithAccessToken("/v1/me", "captured-token");
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers as Headers;
|
||||
expect(headers.get("Authorization")).toBe("Bearer captured-token");
|
||||
expect(authClient.auth.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves API status and error details", async () => {
|
||||
fetchMock.mockResolvedValue(response(403, {
|
||||
error: { message: "Couple access denied", code: "FORBIDDEN" },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { formatAppleProfileName } from '@/lib/appleProfileName';
|
||||
|
||||
describe('formatAppleProfileName', () => {
|
||||
it('combines Apple name parts into the profile display name', () => {
|
||||
expect(formatAppleProfileName({
|
||||
givenName: ' Ada ',
|
||||
middleName: ' Lovelace',
|
||||
familyName: ' Byron ',
|
||||
})).toBe('Ada Lovelace Byron');
|
||||
});
|
||||
|
||||
it('returns null when Apple has not supplied a usable name', () => {
|
||||
expect(formatAppleProfileName({ givenName: ' \n\t ' })).toBeNull();
|
||||
expect(formatAppleProfileName(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('removes control characters and stays within the profile contract limit', () => {
|
||||
expect(formatAppleProfileName({ givenName: 'Ada\u0000 Lovelace' })).toBe('Ada Lovelace');
|
||||
expect(formatAppleProfileName({ givenName: 'a'.repeat(101) })).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { persistAppleProfileName } from '@/lib/appleProfileNamePersistence';
|
||||
|
||||
describe('persistAppleProfileName', () => {
|
||||
const session = { userId: 'apple-user', accessToken: 'apple-token' };
|
||||
const getProfile = jest.fn();
|
||||
const updateProfileName = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('writes a supplied name only after a matching blank profile is read with the captured bearer', async () => {
|
||||
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: null } });
|
||||
updateProfileName.mockResolvedValue({ updated: true });
|
||||
|
||||
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||
.resolves.toBe('updated');
|
||||
expect(getProfile).toHaveBeenCalledWith('apple-token');
|
||||
expect(updateProfileName).toHaveBeenCalledWith('apple-token', 'Ada Lovelace');
|
||||
});
|
||||
|
||||
it('preserves a returning user nickname', async () => {
|
||||
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: 'Ada B' } });
|
||||
|
||||
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||
.resolves.toBe('existing');
|
||||
expect(updateProfileName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed on a subject mismatch before any profile write', async () => {
|
||||
getProfile.mockResolvedValue({ profile: { id: 'different-user', name: null } });
|
||||
|
||||
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||
.rejects.toThrow('subject did not match');
|
||||
expect(updateProfileName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates API failure so the caller can clear its pending onboarding name', async () => {
|
||||
getProfile.mockRejectedValue(new Error('offline'));
|
||||
|
||||
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||
.rejects.toThrow('offline');
|
||||
expect(updateProfileName).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import {
|
||||
hasSeenMatchesTutorial,
|
||||
markMatchesTutorialSeen,
|
||||
resetMatchesTutorial,
|
||||
} from "@/lib/matchesTutorialSeen";
|
||||
|
||||
jest.mock("expo-secure-store", () => ({
|
||||
getItemAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("matchesTutorialSeen", () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
jest.mocked(SecureStore.getItemAsync).mockReset();
|
||||
jest.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("persists a dismissal for the signed-in account", async () => {
|
||||
await markMatchesTutorialSeen("user-1");
|
||||
|
||||
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("does not dismiss the tutorial for another account on the same device", async () => {
|
||||
await markMatchesTutorialSeen("user-1");
|
||||
|
||||
await expect(hasSeenMatchesTutorial("user-2")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("migrates an existing legacy dismissal into durable account storage", async () => {
|
||||
jest.mocked(SecureStore.getItemAsync).mockResolvedValue("true");
|
||||
|
||||
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
|
||||
await expect(AsyncStorage.getItem("matches_tutorial_seen_user-1")).resolves.toBe("true");
|
||||
});
|
||||
|
||||
it("lets the development reset show the tutorial for the current account again", async () => {
|
||||
await markMatchesTutorialSeen("user-1");
|
||||
await resetMatchesTutorial("user-1");
|
||||
|
||||
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage } from '@/lib/onboardingStages';
|
||||
|
||||
describe('initialOnboardingStage', () => {
|
||||
it('starts at gender when a profile already has an Apple-supplied name', () => {
|
||||
expect(initialOnboardingStage('Ada Lovelace')).toBe('gender');
|
||||
});
|
||||
|
||||
it('starts at name as a fallback when no name is available', () => {
|
||||
expect(initialOnboardingStage(' ')).toBe('name');
|
||||
expect(initialOnboardingStage(null)).toBe('name');
|
||||
});
|
||||
|
||||
it('does not provide a back destination before the first reachable stage', () => {
|
||||
expect(previousOnboardingStage('name', false)).toBeNull();
|
||||
expect(previousOnboardingStage('gender', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('allows backwards navigation only through reachable stages', () => {
|
||||
expect(previousOnboardingStage('gender', false)).toBe('name');
|
||||
expect(previousOnboardingStage('purpose', true)).toBe('gender');
|
||||
expect(previousOnboardingStage('notifications', false)).toBe('purpose');
|
||||
});
|
||||
|
||||
it('keeps first-auth onboarding out of the name screen only while its subject-keyed name is pending', () => {
|
||||
expect(activeOnboardingStage('name', null, 'Ada Lovelace')).toBe('gender');
|
||||
expect(activeOnboardingStage('name', null, undefined)).toBe('name');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiClient } from '../../lib/apiClient';
|
||||
import { apiClient, apiRequestWithAccessToken } from '../../lib/apiClient';
|
||||
import { profileSettingsApi } from '../../lib/profileSettingsApi';
|
||||
|
||||
jest.mock('../../lib/apiClient', () => ({
|
||||
@@ -7,6 +7,7 @@ jest.mock('../../lib/apiClient', () => ({
|
||||
post: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
},
|
||||
apiRequestWithAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('profileSettingsApi', () => {
|
||||
@@ -27,4 +28,14 @@ describe('profileSettingsApi', () => {
|
||||
type: 'bug', title: 'Title', description: 'Details',
|
||||
});
|
||||
});
|
||||
|
||||
it('can bind a profile update to a captured authentication bearer', async () => {
|
||||
await profileSettingsApi.updateProfileWithAccessToken('captured-token', { name: 'Ada' });
|
||||
|
||||
expect(apiRequestWithAccessToken).toHaveBeenCalledWith(
|
||||
'/v1/me/profile',
|
||||
'captured-token',
|
||||
{ method: 'PATCH', body: { name: 'Ada' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,10 +30,14 @@ const profile = {
|
||||
describe("authStore", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
|
||||
data: { session: { user: { id: "me" }, access_token: "token-me" } },
|
||||
});
|
||||
// fetchUser always fetches couple state now, since it also carries the
|
||||
// sealed answer count for a user who has no couple yet. Individual tests
|
||||
// override this when they care about the couple/partner shape.
|
||||
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
|
||||
jest.spyOn(coupleApi, "getStateWithAccessToken").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
couple: null,
|
||||
@@ -42,6 +46,7 @@ describe("authStore", () => {
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
pendingAppleDisplayName: null,
|
||||
} as any);
|
||||
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
|
||||
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
|
||||
@@ -64,13 +69,13 @@ describe("authStore", () => {
|
||||
|
||||
it("bootstraps and reads the profile through GET /v1/me", async () => {
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||
data: { session: { user: { id: "me", is_anonymous: true } } },
|
||||
data: { session: { user: { id: "me", is_anonymous: true }, access_token: "token-me" } },
|
||||
});
|
||||
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile });
|
||||
const getSpy = jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({ profile });
|
||||
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
expect(getSpy).toHaveBeenCalledWith("/v1/me");
|
||||
expect(getSpy).toHaveBeenCalledWith("/v1/me", "token-me");
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: profile,
|
||||
isAuthenticated: true,
|
||||
@@ -114,9 +119,9 @@ describe("authStore", () => {
|
||||
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
|
||||
useAuthStore.setState({ sealedCount: 3 } as any);
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||
data: { session: { user: { id: "me" } } },
|
||||
data: { session: { user: { id: "me" }, access_token: "token-me" } },
|
||||
});
|
||||
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
|
||||
jest.spyOn(apiClient, "getWithAccessToken").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
|
||||
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
|
||||
|
||||
await useAuthStore.getState().fetchUser();
|
||||
@@ -135,6 +140,123 @@ describe("authStore", () => {
|
||||
expect(authClient.auth.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let an older session response overwrite a newer profile", async () => {
|
||||
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
|
||||
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
|
||||
|
||||
let resolveA!: (value: { profile: Profile }) => void;
|
||||
let resolveB!: (value: { profile: Profile }) => void;
|
||||
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve; }))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve; }));
|
||||
|
||||
const firstFetch = useAuthStore.getState().fetchUser();
|
||||
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
|
||||
const secondFetch = useAuthStore.getState().fetchUser();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
resolveB({ profile: { ...profile, id: "user-b", name: "Newer Name" } as Profile });
|
||||
await secondFetch;
|
||||
resolveA({ profile: { ...profile, id: "user-a", name: "Older Name" } as Profile });
|
||||
await firstFetch;
|
||||
|
||||
expect(getSpy).toHaveBeenNthCalledWith(1, "/v1/me", "token-a");
|
||||
expect(getSpy).toHaveBeenNthCalledWith(2, "/v1/me", "token-b");
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let an older couple response overwrite a newer session", async () => {
|
||||
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
|
||||
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
|
||||
jest.spyOn(apiClient, "getWithAccessToken")
|
||||
.mockResolvedValueOnce({ profile: { ...profile, id: "user-a", name: "Older Name" } })
|
||||
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
|
||||
|
||||
let resolveCoupleA!: (value: { couple: null; partner: null; sealed_count: number }) => void;
|
||||
jest.spyOn(coupleApi, "getStateWithAccessToken")
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveCoupleA = resolve; }))
|
||||
.mockResolvedValueOnce({ couple: null, partner: null, sealed_count: 7 });
|
||||
|
||||
const firstFetch = useAuthStore.getState().fetchUser();
|
||||
for (let attempt = 0; attempt < 10 && !resolveCoupleA; attempt += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(resolveCoupleA).toBeDefined();
|
||||
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
|
||||
const secondFetch = useAuthStore.getState().fetchUser();
|
||||
await secondFetch;
|
||||
resolveCoupleA({ couple: null, partner: null, sealed_count: 1 });
|
||||
await firstFetch;
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
|
||||
sealedCount: 7,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a mismatched API profile without signing out the active session", async () => {
|
||||
useAuthStore.setState({
|
||||
user: profile,
|
||||
couple: { id: "prior-couple", invite_code: "PRIOR123", created_at: profile.created_at },
|
||||
partner: { ...profile, id: "prior-partner", name: "Prior Partner" },
|
||||
sealedCount: 4,
|
||||
isAuthenticated: true,
|
||||
isAnonymous: true,
|
||||
pendingAppleDisplayName: { userId: "me", name: "Prior Name" },
|
||||
} as any);
|
||||
jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({
|
||||
profile: { ...profile, id: "different-user", name: "Wrong Subject" },
|
||||
});
|
||||
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
sealedCount: 0,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
pendingAppleDisplayName: null,
|
||||
});
|
||||
expect(useMatchStore.getState().matches).toEqual([]);
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
|
||||
expect(authClient.auth.signOut).not.toHaveBeenCalled();
|
||||
expect(coupleApi.getStateWithAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not sign out a newer session when an older request is rejected", async () => {
|
||||
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
|
||||
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
|
||||
|
||||
let rejectA!: (error: Error) => void;
|
||||
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
|
||||
.mockImplementationOnce(() => new Promise((_, reject) => { rejectA = reject; }))
|
||||
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
|
||||
|
||||
const firstFetch = useAuthStore.getState().fetchUser();
|
||||
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
|
||||
const secondFetch = useAuthStore.getState().fetchUser();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await secondFetch;
|
||||
rejectA(new ApiError("Unauthorized", 401));
|
||||
await firstFetch;
|
||||
|
||||
expect(getSpy).toHaveBeenCalledTimes(2);
|
||||
expect(authClient.auth.signOut).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().user).toMatchObject({ id: "user-b", name: "Newer Name" });
|
||||
});
|
||||
|
||||
it("clears sealed answers during an explicit sign-out", async () => {
|
||||
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
||||
|
||||
@@ -158,4 +280,14 @@ describe("authStore", () => {
|
||||
isAuthenticated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a pending Apple name scoped to its original subject', () => {
|
||||
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
|
||||
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
|
||||
|
||||
expect(useAuthStore.getState().pendingAppleDisplayName).toEqual({ userId: 'apple-user', name: 'Ada Lovelace' });
|
||||
|
||||
useAuthStore.getState().clearPendingAppleDisplayName('apple-user');
|
||||
expect(useAuthStore.getState().pendingAppleDisplayName).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -223,7 +223,8 @@ export function ProfileScreen() {
|
||||
label="Reset Matches Tutorial"
|
||||
description="Show the tutorial again"
|
||||
onPress={async () => {
|
||||
await resetMatchesTutorial();
|
||||
if (!user?.id) return;
|
||||
await resetMatchesTutorial(user.id);
|
||||
Alert.alert("Success", "Matches tutorial has been reset.");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -41,9 +41,14 @@ if (Platform.OS === "android") {
|
||||
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
|
||||
import { colors, spacing, radius, typography } from "../../../theme";
|
||||
import { authClient } from "../../../lib/authClient";
|
||||
import { apiRequestWithAccessToken } from "../../../lib/apiClient";
|
||||
import { formatAppleProfileName } from "../../../lib/appleProfileName";
|
||||
import { persistAppleProfileName } from "../../../lib/appleProfileNamePersistence";
|
||||
import { getAuthError } from "../../../lib/errors";
|
||||
import { profileSettingsApi } from "../../../lib/profileSettingsApi";
|
||||
import { useAuthStore } from "../../../store";
|
||||
import { ScreenHeader } from "../components";
|
||||
import type { Profile } from "../../../types";
|
||||
|
||||
type SaveMode = "magic-link" | "password";
|
||||
|
||||
@@ -182,28 +187,40 @@ export function SaveAccountScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apple only provides name/email on first auth grant
|
||||
if (credential.fullName) {
|
||||
const nameParts = [
|
||||
credential.fullName.givenName,
|
||||
credential.fullName.middleName,
|
||||
credential.fullName.familyName,
|
||||
].filter(Boolean);
|
||||
const fullName = nameParts.join(" ");
|
||||
const appleProfileName = formatAppleProfileName(credential.fullName);
|
||||
if (appleProfileName) {
|
||||
try {
|
||||
const { data: { session } } = await authClient.auth.getSession();
|
||||
if (!session?.user.id || !session.access_token) {
|
||||
throw new Error("Apple account link did not retain an authenticated session");
|
||||
}
|
||||
|
||||
if (fullName) {
|
||||
await authClient.auth.updateUser({
|
||||
data: {
|
||||
full_name: fullName,
|
||||
given_name: credential.fullName.givenName,
|
||||
family_name: credential.fullName.familyName,
|
||||
await persistAppleProfileName(
|
||||
{ userId: session.user.id, accessToken: session.access_token },
|
||||
appleProfileName,
|
||||
{
|
||||
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
|
||||
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
|
||||
},
|
||||
});
|
||||
);
|
||||
} catch {
|
||||
// Identity linking succeeded. Do not retain the raw Apple name in Auth
|
||||
// metadata if the separate API profile update is temporarily unavailable.
|
||||
console.warn("[Apple Link] Could not persist supplied name");
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh user/profile
|
||||
const { data: { session } } = await authClient.auth.getSession();
|
||||
if (session?.user.id && session.access_token) {
|
||||
await fetchUser({
|
||||
userId: session.user.id,
|
||||
accessToken: session.access_token,
|
||||
isAnonymous: !!(session.user as { is_anonymous?: boolean }).is_anonymous,
|
||||
});
|
||||
} else {
|
||||
await fetchUser();
|
||||
}
|
||||
|
||||
showSuccess("Account saved with Apple.");
|
||||
router.navigate("/(app)/profile" as any);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Alert, Platform, ActionSheetIOS } from 'react-native';
|
||||
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { uploadMedia } from '../lib/mediaApi';
|
||||
@@ -88,9 +88,21 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
|
||||
let result: ImagePicker.ImagePickerResult;
|
||||
|
||||
if (source === 'camera') {
|
||||
const existingPermission = await ImagePicker.getCameraPermissionsAsync();
|
||||
if (existingPermission.status !== 'granted' && !existingPermission.canAskAgain) {
|
||||
Alert.alert(
|
||||
'Camera Access',
|
||||
'To take a photo, allow camera access in Settings.',
|
||||
[
|
||||
{ text: 'Not Now', style: 'cancel' },
|
||||
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
|
||||
return;
|
||||
}
|
||||
result = await ImagePicker.launchCameraAsync({
|
||||
|
||||
@@ -37,14 +37,11 @@ async function parseResponse(response: Response): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const { data, error } = await authClient.auth.getSession();
|
||||
const accessToken = data.session?.access_token;
|
||||
|
||||
if (error || !accessToken) {
|
||||
throw new ApiError("An authenticated session is required", 401, error);
|
||||
}
|
||||
|
||||
export async function apiRequestWithAccessToken<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
@@ -83,6 +80,17 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
|
||||
return responseBody as T;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const { data, error } = await authClient.auth.getSession();
|
||||
const accessToken = data.session?.access_token;
|
||||
|
||||
if (error || !accessToken) {
|
||||
throw new ApiError("An authenticated session is required", 401, error);
|
||||
}
|
||||
|
||||
return apiRequestWithAccessToken(path, accessToken, options);
|
||||
}
|
||||
|
||||
export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const { data, error } = await authClient.auth.getSession();
|
||||
const accessToken = data.session?.access_token;
|
||||
@@ -94,6 +102,7 @@ export async function authenticatedFetch(path: string, init: RequestInit = {}):
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(path: string) => apiRequest<T>(path),
|
||||
getWithAccessToken: <T>(path: string, accessToken: string) => apiRequestWithAccessToken<T>(path, accessToken),
|
||||
post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
|
||||
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", body }),
|
||||
patch: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PATCH", body }),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface AppleProfileName {
|
||||
givenName?: string | null;
|
||||
middleName?: string | null;
|
||||
familyName?: string | null;
|
||||
}
|
||||
|
||||
const MAX_PROFILE_NAME_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* Apple supplies its name fields only on the first authorization. Keep that
|
||||
* user-provided value as the Sauci display name without retaining email or
|
||||
* other Apple credential fields outside the existing profile contract.
|
||||
*/
|
||||
export function formatAppleProfileName(fullName: AppleProfileName | null | undefined): string | null {
|
||||
const name = [fullName?.givenName, fullName?.middleName, fullName?.familyName]
|
||||
.filter((part): part is string => typeof part === "string")
|
||||
.join(" ")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
|
||||
return name.length > 0 ? name.slice(0, MAX_PROFILE_NAME_LENGTH) : null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Profile } from '../types';
|
||||
|
||||
export interface AppleProfileNameSession {
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface AppleProfileNamePersistenceDependencies {
|
||||
getProfile: (accessToken: string) => Promise<{ profile: Pick<Profile, 'id' | 'name'> }>;
|
||||
updateProfileName: (accessToken: string, name: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export type AppleProfileNamePersistenceResult = 'updated' | 'existing';
|
||||
|
||||
/**
|
||||
* Persist a first-authorization Apple display name only for the exact subject
|
||||
* and bearer that completed the authorization. The profile read protects an
|
||||
* existing Sauci nickname from being overwritten.
|
||||
*/
|
||||
export async function persistAppleProfileName(
|
||||
session: AppleProfileNameSession,
|
||||
name: string,
|
||||
dependencies: AppleProfileNamePersistenceDependencies,
|
||||
): Promise<AppleProfileNamePersistenceResult> {
|
||||
const { profile } = await dependencies.getProfile(session.accessToken);
|
||||
if (profile.id !== session.userId) {
|
||||
throw new Error('Apple profile subject did not match the authenticated user');
|
||||
}
|
||||
|
||||
if (profile.name?.trim()) {
|
||||
return 'existing';
|
||||
}
|
||||
|
||||
await dependencies.updateProfileName(session.accessToken, name);
|
||||
return 'updated';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Couple, Profile } from "@/types";
|
||||
import { apiRequest } from "./apiClient";
|
||||
import { apiRequest, apiRequestWithAccessToken } from "./apiClient";
|
||||
|
||||
export interface CoupleStateResponse {
|
||||
couple: Couple | null;
|
||||
@@ -16,6 +16,8 @@ export interface CoupleMutationResponse {
|
||||
|
||||
export const coupleApi = {
|
||||
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
|
||||
getStateWithAccessToken: (accessToken: string) =>
|
||||
apiRequestWithAccessToken<CoupleStateResponse>("/v1/couple", accessToken),
|
||||
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
|
||||
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,51 +1,70 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const STORAGE_KEY = "matches_tutorial_seen";
|
||||
const STORAGE_KEY_PREFIX = "matches_tutorial_seen";
|
||||
const LEGACY_STORAGE_KEY = "matches_tutorial_seen";
|
||||
|
||||
async function getStorage(): Promise<boolean> {
|
||||
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
|
||||
|
||||
async function getStorage(userId: string): Promise<boolean> {
|
||||
try {
|
||||
let data: string | null = null;
|
||||
const key = buildKey(userId);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||
data = window.localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
} else {
|
||||
data = await SecureStore.getItemAsync(STORAGE_KEY);
|
||||
return window.localStorage.getItem(key) === "true";
|
||||
}
|
||||
|
||||
return data === "true";
|
||||
return false;
|
||||
}
|
||||
|
||||
const storedValue = await AsyncStorage.getItem(key);
|
||||
if (storedValue !== null) {
|
||||
return storedValue === "true";
|
||||
}
|
||||
|
||||
// Keep dismissals made before the account-scoped AsyncStorage key was
|
||||
// introduced. Future reads use the durable app storage key above.
|
||||
const legacyValue = await SecureStore.getItemAsync(LEGACY_STORAGE_KEY);
|
||||
if (legacyValue === "true") {
|
||||
await AsyncStorage.setItem(key, "true");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error reading matches tutorial state:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function setStorage(seen: boolean): Promise<void> {
|
||||
async function setStorage(userId: string, seen: boolean): Promise<void> {
|
||||
try {
|
||||
const data = seen ? "true" : "false";
|
||||
const key = buildKey(userId);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, data);
|
||||
window.localStorage.setItem(key, data);
|
||||
}
|
||||
} else {
|
||||
await SecureStore.setItemAsync(STORAGE_KEY, data);
|
||||
await AsyncStorage.setItem(key, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving matches tutorial state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasSeenMatchesTutorial(): Promise<boolean> {
|
||||
return getStorage();
|
||||
/** The Matches tutorial is shown at most once per signed-in account. */
|
||||
export async function hasSeenMatchesTutorial(userId: string): Promise<boolean> {
|
||||
return getStorage(userId);
|
||||
}
|
||||
|
||||
export async function markMatchesTutorialSeen(): Promise<void> {
|
||||
await setStorage(true);
|
||||
export async function markMatchesTutorialSeen(userId: string): Promise<void> {
|
||||
await setStorage(userId, true);
|
||||
}
|
||||
|
||||
export async function resetMatchesTutorial(): Promise<void> {
|
||||
await setStorage(false);
|
||||
export async function resetMatchesTutorial(userId: string): Promise<void> {
|
||||
await setStorage(userId, false);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type OnboardingStage = "name" | "gender" | "purpose" | "notifications";
|
||||
|
||||
/** A stored display name lets onboarding begin at the next required question. */
|
||||
export function initialOnboardingStage(name: string | null | undefined): OnboardingStage {
|
||||
return name?.trim() ? "gender" : "name";
|
||||
}
|
||||
|
||||
/** A pending first-auth name is valid only for the matching signed-in subject. */
|
||||
export function activeOnboardingStage(
|
||||
stage: OnboardingStage,
|
||||
storedDisplayName: string | null | undefined,
|
||||
pendingDisplayName: string | null | undefined,
|
||||
): OnboardingStage {
|
||||
return stage === "name" && (storedDisplayName?.trim() || pendingDisplayName?.trim()) ? "gender" : stage;
|
||||
}
|
||||
|
||||
/** The first reachable stage has no back action. */
|
||||
export function previousOnboardingStage(
|
||||
stage: OnboardingStage,
|
||||
hasStoredDisplayName: boolean,
|
||||
): OnboardingStage | null {
|
||||
switch (stage) {
|
||||
case "name":
|
||||
return null;
|
||||
case "gender":
|
||||
return hasStoredDisplayName ? null : "name";
|
||||
case "purpose":
|
||||
return "gender";
|
||||
case "notifications":
|
||||
return "purpose";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiClient } from './apiClient';
|
||||
import { apiClient, apiRequestWithAccessToken } from './apiClient';
|
||||
import type { Gender } from '../types';
|
||||
import type { NotificationPreferences } from '../store/notificationPreferencesStore';
|
||||
|
||||
@@ -20,6 +20,8 @@ type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at
|
||||
|
||||
export const profileSettingsApi = {
|
||||
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update),
|
||||
updateProfileWithAccessToken: (accessToken: string, update: ProfileUpdate) =>
|
||||
apiRequestWithAccessToken<{ updated: true }>('/v1/me/profile', accessToken, { method: 'PATCH', body: update }),
|
||||
updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
|
||||
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
|
||||
updateNotificationPreference: (key: PreferenceKey, value: boolean) =>
|
||||
|
||||
@@ -7,6 +7,22 @@ import { syncTimezone } from "../lib/reportedTimezone";
|
||||
import { Events } from "../lib/analytics";
|
||||
import type { Profile, Couple } from "@/types";
|
||||
|
||||
export interface AuthSessionSnapshot {
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
isAnonymous: boolean;
|
||||
}
|
||||
|
||||
let fetchUserGeneration = 0;
|
||||
|
||||
const snapshotSession = (session: { user?: { id: string; is_anonymous?: boolean }; access_token?: string } | null): AuthSessionSnapshot | null => {
|
||||
if (!session?.user?.id || !session.access_token) return null;
|
||||
return {
|
||||
userId: session.user.id,
|
||||
accessToken: session.access_token,
|
||||
isAnonymous: !!session.user.is_anonymous,
|
||||
};
|
||||
};
|
||||
|
||||
interface AuthState {
|
||||
user: Profile | null;
|
||||
@@ -17,14 +33,18 @@ interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isAnonymous: boolean;
|
||||
/** First-authorization Apple name held only until the API profile reflects it. */
|
||||
pendingAppleDisplayName: { userId: string; name: string } | null;
|
||||
|
||||
|
||||
// Actions
|
||||
fetchUser: () => Promise<void>;
|
||||
fetchUser: (expectedSession?: AuthSessionSnapshot) => Promise<void>;
|
||||
fetchCouple: () => Promise<void>;
|
||||
refreshPartner: () => Promise<Profile | null>;
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (user: Profile | null) => void;
|
||||
setPendingAppleDisplayName: (pending: { userId: string; name: string } | null) => void;
|
||||
clearPendingAppleDisplayName: (userId: string) => void;
|
||||
updateLastActive: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -50,45 +70,94 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
pendingAppleDisplayName: null,
|
||||
|
||||
fetchUser: async (expectedSession) => {
|
||||
const generation = ++fetchUserGeneration;
|
||||
let snapshot: AuthSessionSnapshot | null = expectedSession ?? null;
|
||||
const isCurrentSession = async (snapshot: AuthSessionSnapshot): Promise<boolean> => {
|
||||
if (generation !== fetchUserGeneration) return false;
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
// First check if we have a session in storage
|
||||
const { data: { session } } = await authClient.auth.getSession();
|
||||
return generation === fetchUserGeneration
|
||||
&& session?.user?.id === snapshot.userId
|
||||
&& session.access_token === snapshot.accessToken;
|
||||
};
|
||||
|
||||
if (!session?.user) {
|
||||
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||
try {
|
||||
// Capture the session that owns this refresh. All following API calls
|
||||
// and state writes remain bound to it, never the ambient auth session.
|
||||
snapshot = expectedSession ?? snapshotSession((await authClient.auth.getSession()).data.session);
|
||||
|
||||
if (!snapshot) {
|
||||
if (generation !== fetchUserGeneration) return;
|
||||
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// This endpoint verifies the Supabase access token and creates the
|
||||
// product profile on first use, keeping profile data out of Supabase.
|
||||
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
|
||||
const { profile } = await apiClient.getWithAccessToken<{ profile: Profile }>("/v1/me", snapshot.accessToken);
|
||||
|
||||
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
|
||||
if (!(await isCurrentSession(snapshot))) return;
|
||||
|
||||
if (profile.id !== snapshot.userId) {
|
||||
console.error("[Auth] API profile subject did not match the authenticated session");
|
||||
// Fail closed rather than leaving the prior account visible when
|
||||
// an API response cannot be proven to belong to this session.
|
||||
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
useQuizStore.getState().clearQuiz();
|
||||
return;
|
||||
}
|
||||
|
||||
const isAnonymous = snapshot.isAnonymous;
|
||||
|
||||
// Only update user if data actually changed to avoid unnecessary re-renders
|
||||
const currentUser = get().user;
|
||||
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
|
||||
// Always fetch couple state: it reports the sealed answer count for a
|
||||
// solo user even before any couple exists, not just once paired.
|
||||
const { couple, partner, sealed_count } = await coupleApi.getStateWithAccessToken(snapshot.accessToken);
|
||||
|
||||
if (!(await isCurrentSession(snapshot))) return;
|
||||
|
||||
if (userChanged) {
|
||||
set({
|
||||
user: profile,
|
||||
couple,
|
||||
partner,
|
||||
sealedCount: sealed_count,
|
||||
isAuthenticated: true,
|
||||
isAnonymous,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
couple,
|
||||
partner,
|
||||
sealedCount: sealed_count,
|
||||
isAuthenticated: true,
|
||||
isAnonymous,
|
||||
isLoading: false,
|
||||
});
|
||||
} else if (!get().isAuthenticated) {
|
||||
set({ isAuthenticated: true, isAnonymous });
|
||||
}
|
||||
|
||||
// Always fetch couple state: it reports the sealed answer count for a
|
||||
// solo user even before any couple exists, not just once paired.
|
||||
await get().fetchCouple();
|
||||
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
if (generation !== fetchUserGeneration) return;
|
||||
|
||||
const currentSnapshot = snapshot;
|
||||
if (currentSnapshot && !(await isCurrentSession(currentSnapshot))) return;
|
||||
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
console.log("[Auth] Session rejected by API, signing out");
|
||||
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
@@ -141,6 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
pendingAppleDisplayName: null,
|
||||
});
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||
@@ -169,7 +239,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
...(user === null && { isAnonymous: false }),
|
||||
...(user === null && { isAnonymous: false, pendingAppleDisplayName: null }),
|
||||
isLoading: false,
|
||||
// Clear couple/partner when user is null (signed out)
|
||||
...(user === null && { couple: null, partner: null, sealedCount: 0 })
|
||||
@@ -191,6 +261,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setPendingAppleDisplayName: (pending) => set({ pendingAppleDisplayName: pending }),
|
||||
|
||||
clearPendingAppleDisplayName: (userId) => {
|
||||
if (get().pendingAppleDisplayName?.userId === userId) {
|
||||
set({ pendingAppleDisplayName: null });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user's last_active_at timestamp.
|
||||
* Called when the app comes to the foreground to track user activity.
|
||||
|
||||
@@ -92,6 +92,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
);
|
||||
|
||||
jest.mock('expo-image-picker', () => ({
|
||||
getCameraPermissionsAsync: jest.fn(async () => ({ status: 'undetermined', canAskAgain: true })),
|
||||
requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
|
||||
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
|
||||
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>47</string>
|
||||
<string>49</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.8</string>
|
||||
</dict>
|
||||
|
||||
@@ -87,6 +87,22 @@ const { error } = await supabase.auth.signInWithIdToken({
|
||||
});
|
||||
```
|
||||
|
||||
Apple provides the selected full name only on the first native authorization.
|
||||
The mobile client formats it and reads the API-owned profile with the exact
|
||||
subject and bearer returned by that authorization. It writes the display name
|
||||
only when that matching profile name is blank, preserving existing nicknames.
|
||||
While this first-auth write is pending, the name is held only in subject-keyed
|
||||
mobile state so onboarding does not briefly require it again; failure clears
|
||||
that value and safely returns to the normal name step. Apple full/given/family
|
||||
names and email are not copied into Supabase user metadata.
|
||||
|
||||
Profile refreshes bind both `/v1/me` and couple-state reads to the session
|
||||
subject and bearer captured at their start. A response is applied only while
|
||||
that exact session remains current, and a returned profile must have the same
|
||||
subject. This prevents an older request from replacing a newer account's local
|
||||
profile, relationship state, or loading state. A subject mismatch clears the
|
||||
in-memory user-scoped state rather than leaving a prior account visible.
|
||||
|
||||
**OAuth Fallback (web, Expo Go):**
|
||||
```typescript
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Dependency and lockfile discipline
|
||||
|
||||
## Two npm realities
|
||||
|
||||
CI runners and the API Docker image use the node version pinned in
|
||||
`.nvmrc` (node 20.19.4 with npm 10.8.2). A developer machine can run a newer
|
||||
npm (for example npm 11). npm 10 and npm 11 resolve wildcard or range
|
||||
dependencies differently at the same place in the dependency tree, and each
|
||||
npm's platform-optional binary selection is not portable to a lockfile
|
||||
regenerated by the other npm. A lockfile that passes `npm ci` under one npm
|
||||
major version can fail it under the other with either an "Invalid: lock
|
||||
file's ... does not satisfy" error or missing platform-optional binaries
|
||||
(for example `@next/swc-linux-x64-gnu` breaking `next build`,
|
||||
`@rollup/rollup-linux-x64-gnu` breaking the API Docker `tsup` build, or a
|
||||
missing `lightningcss` variant breaking local `npm ci`).
|
||||
|
||||
## Pin wildcard-range dependencies with root overrides
|
||||
|
||||
When a transitive dependency declares a wildcard or wide range (for example
|
||||
`react-native-worklets` depending on `@react-native/metro-config: "*"`), pin
|
||||
the exact version the app needs in the root `package.json` `overrides` block
|
||||
rather than leaving it to whichever npm resolved the lockfile last:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"rollup": "4.54.0",
|
||||
"react-native": "0.85.3",
|
||||
"expo": "~56.0.0",
|
||||
"@react-native/metro-config": "0.85.3"
|
||||
}
|
||||
```
|
||||
|
||||
## Pin platform-optional binaries that must travel with the framework version
|
||||
|
||||
`apps/web/package.json` pins the `@next/swc` linux and darwin binaries as
|
||||
direct `optionalDependencies` so npm does not have to infer them per
|
||||
platform:
|
||||
|
||||
```json
|
||||
"optionalDependencies": {
|
||||
"@next/swc-linux-x64-gnu": "15.5.24",
|
||||
"@next/swc-linux-x64-musl": "15.5.24",
|
||||
"@next/swc-darwin-arm64": "15.5.24"
|
||||
}
|
||||
```
|
||||
|
||||
These versions must be bumped together with any `next` version bump, or the
|
||||
pinned binaries will drift from the framework version and `npm ci` or
|
||||
`next build` can fail on the platform that needs the stale binary.
|
||||
|
||||
## Validate a regenerated lockfile both ways
|
||||
|
||||
After changing `overrides` or any dependency version, regenerate
|
||||
`package-lock.json` and validate it under both npm majors before committing:
|
||||
|
||||
```bash
|
||||
# Local npm (matches this machine's npm major)
|
||||
npm ci
|
||||
npm run verify:fast
|
||||
|
||||
# The CI/Docker npm major, emulated on Apple Silicon
|
||||
docker run --rm --platform linux/amd64 -v "$PWD:/repo" -w /repo node:20.19.4 npm ci
|
||||
```
|
||||
|
||||
The `--platform linux/amd64` flag is mandatory on Apple Silicon: without it,
|
||||
Docker runs the arm64 image and the platform-binary check proves nothing.
|
||||
|
||||
## After any bypassed push
|
||||
|
||||
Pushes to `staging` bypass required status checks. After any such push, check
|
||||
`gh run list --branch staging` to confirm CI is actually green rather than
|
||||
assuming it ran and passed.
|
||||
|
||||
Do not pipe `npm` commands through `tail` when diagnosing a lockfile or build
|
||||
failure; it can swallow the command's exit code and the detail needed to find
|
||||
the root cause.
|
||||
@@ -8,6 +8,7 @@ historical notes. Agents should load only the section relevant to their task.
|
||||
- [System architecture](architecture/system.md)
|
||||
- [Client data freshness](architecture/client-data-freshness.md)
|
||||
- [Local full-stack development](development/local-stack.md)
|
||||
- [Dependency and lockfile discipline](development/dependencies.md)
|
||||
- [Testing strategy](testing/strategy.md)
|
||||
- [Database migration safety](operations/database-migrations.md)
|
||||
- [Backend deployment and branching](operations/backend-deployments.md)
|
||||
@@ -19,6 +20,7 @@ historical notes. Agents should load only the section relevant to their task.
|
||||
- [Couple pairing](couple-pairing.md)
|
||||
- [Couple streaks](couple-streaks.md)
|
||||
- [Question selection](question-selection.md)
|
||||
- [Question interaction types](question-types.md)
|
||||
- [Match notifications](match-notifications.md)
|
||||
- [Subscriptions](subscription-system.md)
|
||||
- [Redemption codes](product/redemption.md)
|
||||
@@ -34,6 +36,7 @@ partially implemented states. Confirm claims against current code.
|
||||
|
||||
- `admin-question-types-plan.md`
|
||||
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
|
||||
- [Sex and fun relationship market strategy](product/sex-fun-market-strategy.md)
|
||||
- `content-balance-implementation.md`
|
||||
- `dares-feature.md` (superseded by [Dares loop](dares-loop.md))
|
||||
- `live-draw-feature-plan.md`
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# Play-safe spicy audio concept source
|
||||
|
||||
Draft source for private, up-to-60-second audio responses. This is editorial input only and is not a live catalogue import.
|
||||
|
||||
1. Theme: Flirty communication | Intensity: 2
|
||||
Prompt: In a warm, playful tone, record the compliment from your partner that would make you smile all day.
|
||||
Intended outcome: Your partner hears the exact kind of praise that lands for you and can use it naturally in a future message.
|
||||
|
||||
2. Theme: Flirty communication | Intensity: 3
|
||||
Prompt: Record a voice note finishing this sentence: “When you look at me like that, I want you to know...”
|
||||
Intended outcome: Your partner receives a personal signal of attraction and can reply with what they notice in that moment too.
|
||||
|
||||
3. Theme: Flirty communication | Intensity: 3
|
||||
Prompt: Leave your partner a voice note with one bold thing you would love them to say quietly when you are alone together.
|
||||
Intended outcome: Your partner learns a specific phrase or mood that could make a private moment feel more charged.
|
||||
|
||||
4. Theme: Flirty communication | Intensity: 4
|
||||
Prompt: Record the invitation you would most like to hear when your partner wants to turn an ordinary evening into a close one.
|
||||
Intended outcome: Your partner gets a clear, consent-forward way to initiate and can try it on a night that feels right.
|
||||
|
||||
5. Theme: Flirty communication | Intensity: 4
|
||||
Prompt: In your most confident voice, tell your partner what makes you feel unmistakably wanted by them.
|
||||
Intended outcome: Your partner hears the emotional cue behind your desire and can make future affection feel more intentional.
|
||||
|
||||
6. Theme: Anticipation | Intensity: 2
|
||||
Prompt: Record a short countdown-style note about one small thing you are looking forward to doing together later.
|
||||
Intended outcome: Your partner hears a concrete point of anticipation and can build on it with their own plan.
|
||||
|
||||
7. Theme: Anticipation | Intensity: 3
|
||||
Prompt: Record the first message you would want from your partner before a deliberately flirty date night.
|
||||
Intended outcome: Your partner learns how you like anticipation to begin and can send it before your next planned evening.
|
||||
|
||||
8. Theme: Anticipation | Intensity: 3
|
||||
Prompt: Describe, in a low and unhurried voice, the mood you want waiting for you after a busy day apart.
|
||||
Intended outcome: Your partner hears whether you want calm, playfulness, affection, or intensity and can help create that landing.
|
||||
|
||||
9. Theme: Anticipation | Intensity: 4
|
||||
Prompt: Record a private tease that hints at the kind of attention you would love from your partner later, without explaining every detail.
|
||||
Intended outcome: Your partner receives an intriguing cue they can ask about and turn into a jointly chosen plan.
|
||||
|
||||
10. Theme: Anticipation | Intensity: 4
|
||||
Prompt: Leave a voice note naming the one thing your partner could do before you meet that would put you in a more daring mood.
|
||||
Intended outcome: Your partner learns a practical lead-in, such as a message, outfit, setting, or affectionate gesture, that helps you open up.
|
||||
|
||||
11. Theme: Taking the lead | Intensity: 2
|
||||
Prompt: Record a cheerful invitation for your partner to let you choose the next date-night detail.
|
||||
Intended outcome: Your partner hears an easy handover of initiative and can say yes to one contained surprise.
|
||||
|
||||
12. Theme: Taking the lead | Intensity: 3
|
||||
Prompt: In a decisive voice, tell your partner one private mood you would like to set for the two of you.
|
||||
Intended outcome: Your partner learns the atmosphere you want to lead and can confirm whether it feels good for them too.
|
||||
|
||||
13. Theme: Taking the lead | Intensity: 3
|
||||
Prompt: Record how you would ask your partner to trust you with planning a more adventurous evening together.
|
||||
Intended outcome: Your partner hears a respectful version of your confidence and can agree on a boundary before the plan unfolds.
|
||||
|
||||
14. Theme: Taking the lead | Intensity: 4
|
||||
Prompt: Leave a voice note telling your partner the first instruction you would enjoy giving in a mutually agreed private game.
|
||||
Intended outcome: Your partner hears the style of leadership you enjoy and can opt into the game with clear limits.
|
||||
|
||||
15. Theme: Taking the lead | Intensity: 4
|
||||
Prompt: Record the reassurance you would give your partner before you take charge of a bolder shared experience.
|
||||
Intended outcome: Your partner hears how you would keep them comfortable and can name what would help them relax into your lead.
|
||||
|
||||
16. Theme: Following | Intensity: 2
|
||||
Prompt: Record one thing your partner could choose for you that would feel like a thoughtful treat.
|
||||
Intended outcome: Your partner learns a safe way to surprise you and can choose it for an upcoming evening.
|
||||
|
||||
17. Theme: Following | Intensity: 3
|
||||
Prompt: In a soft voice, explain what helps you enjoy letting your partner set the pace.
|
||||
Intended outcome: Your partner hears the conditions that make following feel exciting rather than uncertain.
|
||||
|
||||
18. Theme: Following | Intensity: 3
|
||||
Prompt: Record the kind of check-in that makes you feel safe saying yes to your partner’s idea.
|
||||
Intended outcome: Your partner learns the exact reassurance that supports your willingness and can use it before suggesting something new.
|
||||
|
||||
19. Theme: Following | Intensity: 4
|
||||
Prompt: Leave a voice note describing one daring-but-private decision you would enjoy your partner making after checking in with you first.
|
||||
Intended outcome: Your partner gets a consent-based example of initiative they could take and a cue to ask before acting.
|
||||
|
||||
20. Theme: Following | Intensity: 4
|
||||
Prompt: Record what you would want your partner to say if they wanted you to surrender the planning for one intimate evening.
|
||||
Intended outcome: Your partner hears language that makes a stronger invitation feel welcome, with room for you to decline or adjust.
|
||||
|
||||
21. Theme: Shared control | Intensity: 2
|
||||
Prompt: Record a two-choice question your partner can answer to shape your next cosy evening together.
|
||||
Intended outcome: Your partner hears two real options and can choose the version that feels most appealing.
|
||||
|
||||
22. Theme: Shared control | Intensity: 3
|
||||
Prompt: Leave a voice note describing a private game where you and your partner would alternate making one decision each.
|
||||
Intended outcome: Your partner learns a simple shared-control format and can suggest the first decision.
|
||||
|
||||
23. Theme: Shared control | Intensity: 3
|
||||
Prompt: Record which part of a date night you would like your partner to lead and which part you would like to lead.
|
||||
Intended outcome: Your partner hears a practical division of initiative and can co-create the evening rather than guess.
|
||||
|
||||
24. Theme: Shared control | Intensity: 4
|
||||
Prompt: In an excited voice, explain how you would like to build a bolder shared moment one yes at a time.
|
||||
Intended outcome: Your partner hears a stepwise consent rhythm and can add their own yes, no, or alternative at each point.
|
||||
|
||||
25. Theme: Shared control | Intensity: 4
|
||||
Prompt: Record a private challenge that only works if both of you keep choosing the next step together.
|
||||
Intended outcome: Your partner receives an invitation to collaborative play and can negotiate the limits before starting.
|
||||
|
||||
26. Theme: Boundaries and check-ins | Intensity: 2
|
||||
Prompt: Record a kind sentence your partner can use to ask whether you are still enjoying a close moment.
|
||||
Intended outcome: Your partner hears a check-in that feels caring rather than formal and can use it in real time.
|
||||
|
||||
27. Theme: Boundaries and check-ins | Intensity: 3
|
||||
Prompt: In your own voice, explain the difference between a surprise that delights you and one that catches you off guard.
|
||||
Intended outcome: Your partner learns your personal distinction and can plan novelty that feels welcome.
|
||||
|
||||
28. Theme: Boundaries and check-ins | Intensity: 3
|
||||
Prompt: Record the easiest phrase for you to use when you want to slow down without ending the mood.
|
||||
Intended outcome: Your partner hears your preferred pause signal and knows it protects connection rather than rejects them.
|
||||
|
||||
29. Theme: Boundaries and check-ins | Intensity: 4
|
||||
Prompt: Leave a voice note telling your partner what they could ask before suggesting something considerably bolder than usual.
|
||||
Intended outcome: Your partner gets a concrete consent opener and can ask it before raising a new idea.
|
||||
|
||||
30. Theme: Boundaries and check-ins | Intensity: 4
|
||||
Prompt: Record what “I am curious, but not ready yet” sounds like in words that still feel affectionate to you.
|
||||
Intended outcome: Your partner learns how to receive hesitation warmly and can leave the door open without pressure.
|
||||
|
||||
31. Theme: Aftercare and reconnection | Intensity: 2
|
||||
Prompt: Record the small affectionate gesture that helps you feel close after a playful evening together.
|
||||
Intended outcome: Your partner hears a specific reconnection preference they can offer without guessing.
|
||||
|
||||
32. Theme: Aftercare and reconnection | Intensity: 3
|
||||
Prompt: In a calm voice, tell your partner what you most like hearing after you have both tried something new together.
|
||||
Intended outcome: Your partner learns the reassurance or reflection that helps novelty become a positive shared memory.
|
||||
|
||||
33. Theme: Aftercare and reconnection | Intensity: 3
|
||||
Prompt: Record how you would like your partner to check in with you the morning after an especially intimate night.
|
||||
Intended outcome: Your partner hears the tone and timing that feel meaningful and can follow through the next day.
|
||||
|
||||
34. Theme: Aftercare and reconnection | Intensity: 4
|
||||
Prompt: Leave a voice note about the honest question you would want your partner to ask after a more daring experience.
|
||||
Intended outcome: Your partner learns how to invite useful feedback and can improve the next shared experience with you.
|
||||
|
||||
35. Theme: Aftercare and reconnection | Intensity: 4
|
||||
Prompt: Record the words that would make you feel proud and cared for after you stepped outside your comfort zone together.
|
||||
Intended outcome: Your partner hears how to celebrate your trust and can reinforce the kind of openness you want to grow.
|
||||
|
||||
36. Theme: Giving and receiving attention | Intensity: 2
|
||||
Prompt: Record one affectionate way your partner could make you feel noticed when you are both at home.
|
||||
Intended outcome: Your partner gets a simple attention cue they can use in an ordinary evening.
|
||||
|
||||
37. Theme: Giving and receiving attention | Intensity: 3
|
||||
Prompt: Leave a voice note describing how you know the difference between rushed affection and attention that feels fully present.
|
||||
Intended outcome: Your partner learns what presence sounds and feels like to you and can adjust their pace.
|
||||
|
||||
38. Theme: Giving and receiving attention | Intensity: 3
|
||||
Prompt: Record the kind of praise you would most enjoy giving your partner when they make you feel desired.
|
||||
Intended outcome: Your partner hears the feedback that encourages them and can understand what is working for you.
|
||||
|
||||
39. Theme: Giving and receiving attention | Intensity: 4
|
||||
Prompt: In a warm, deliberate voice, tell your partner where you would like their focus to be during a private moment.
|
||||
Intended outcome: Your partner hears the emotional or sensory focus you want and can ask a follow-up before acting on it.
|
||||
|
||||
40. Theme: Giving and receiving attention | Intensity: 4
|
||||
Prompt: Record the one thing your partner could do to make you feel they have set aside the whole evening just for you.
|
||||
Intended outcome: Your partner learns a concrete sign of devoted attention and can weave it into a future plan.
|
||||
|
||||
41. Theme: Sensory cues | Intensity: 2
|
||||
Prompt: Record the sound, song, or kind of silence that makes an evening feel more intimate to you.
|
||||
Intended outcome: Your partner learns an audible atmosphere cue and can bring it into your next shared setting.
|
||||
|
||||
42. Theme: Sensory cues | Intensity: 3
|
||||
Prompt: In a slow voice, describe the lighting that makes you feel most relaxed and attractive.
|
||||
Intended outcome: Your partner hears a practical setting preference and can prepare the room with your comfort in mind.
|
||||
|
||||
43. Theme: Sensory cues | Intensity: 3
|
||||
Prompt: Record the scent or familiar smell that you associate with feeling especially close to your partner.
|
||||
Intended outcome: Your partner learns a personal sensory association and can use it to create a recognisable shared mood.
|
||||
|
||||
44. Theme: Sensory cues | Intensity: 4
|
||||
Prompt: Leave a voice note about the kind of music that would make you feel more confident trying a bolder private mood together.
|
||||
Intended outcome: Your partner receives a specific confidence cue and can make a playlist before you explore the mood.
|
||||
|
||||
45. Theme: Private performance | Intensity: 2
|
||||
Prompt: Record a playful introduction as though you are welcoming your partner to a date night you designed just for them.
|
||||
Intended outcome: Your partner hears your enthusiasm and can respond with the detail that would make the invitation even better.
|
||||
|
||||
46. Theme: Private performance | Intensity: 3
|
||||
Prompt: In character, record a short mystery message that gives your partner one clue about a private surprise.
|
||||
Intended outcome: Your partner gets a voice-led tease and can guess, ask for another clue, or agree to be surprised.
|
||||
|
||||
47. Theme: Private performance | Intensity: 3
|
||||
Prompt: Record a dramatic trailer voice-over for the next adventurous evening you want to create together.
|
||||
Intended outcome: Your partner hears the mood as performance, making it easier to discuss the plan without pressure.
|
||||
|
||||
48. Theme: Private performance | Intensity: 4
|
||||
Prompt: Leave a voice note acting out the confident version of yourself you would like to bring into a private date night.
|
||||
Intended outcome: Your partner hears a new side of your energy and can encourage that version of you when you are together.
|
||||
|
||||
49. Theme: Clothes and confidence | Intensity: 2
|
||||
Prompt: Record how you feel when you wear the outfit that makes you walk a little taller.
|
||||
Intended outcome: Your partner learns the confidence signal behind a favourite look and can compliment it more meaningfully.
|
||||
|
||||
50. Theme: Clothes and confidence | Intensity: 3
|
||||
Prompt: Leave a voice note telling your partner what kind of look from them catches your attention first.
|
||||
Intended outcome: Your partner hears a specific style cue and can choose an outfit that makes them feel noticed.
|
||||
|
||||
51. Theme: Clothes and confidence | Intensity: 3
|
||||
Prompt: In an encouraging voice, tell your partner how you would like them to make you feel confident before a private evening.
|
||||
Intended outcome: Your partner learns whether words, time to get ready, a compliment, or a small gesture helps you feel at ease.
|
||||
|
||||
52. Theme: Clothes and confidence | Intensity: 4
|
||||
Prompt: Record the outfit-related surprise you would enjoy receiving from your partner after they check it feels right for you.
|
||||
Intended outcome: Your partner gets a consent-aware idea for a bolder visual surprise and can confirm your comfort before planning it.
|
||||
|
||||
53. Theme: Fantasy exchange | Intensity: 2
|
||||
Prompt: Record a fictional date-night scene you would enjoy stepping into with your partner, keeping it all about atmosphere.
|
||||
Intended outcome: Your partner hears a low-pressure shared fantasy and can suggest a small real-world version of its mood.
|
||||
|
||||
54. Theme: Fantasy exchange | Intensity: 3
|
||||
Prompt: Leave a voice note beginning, “In our imaginary perfect evening, you would surprise me by...”
|
||||
Intended outcome: Your partner receives a playful desire in fictional form and can ask which part you would actually enjoy trying.
|
||||
|
||||
55. Theme: Fantasy exchange | Intensity: 3
|
||||
Prompt: Record the made-up place where you and your partner would feel free to be more playful and uninhibited together.
|
||||
Intended outcome: Your partner hears the setting behind your fantasy and can recreate its feeling privately and safely.
|
||||
|
||||
56. Theme: Fantasy exchange | Intensity: 4
|
||||
Prompt: In a conspiratorial voice, tell your partner the private rule that would make your imaginary evening feel thrilling.
|
||||
Intended outcome: Your partner hears one negotiable game rule and can decide with you whether it belongs in real life.
|
||||
|
||||
57. Theme: Roleplay | Intensity: 2
|
||||
Prompt: Record a playful opening line for a pretend first meeting with your partner.
|
||||
Intended outcome: Your partner gets an easy roleplay entry point and can answer in character if they want to continue.
|
||||
|
||||
58. Theme: Roleplay | Intensity: 3
|
||||
Prompt: Leave a voice note as a fictional version of yourself inviting your partner on a secret date.
|
||||
Intended outcome: Your partner hears the role and tone that appeals to you and can choose whether to join the scene.
|
||||
|
||||
59. Theme: Roleplay | Intensity: 3
|
||||
Prompt: Record the character trait you would enjoy your partner pretending to have for one private evening.
|
||||
Intended outcome: Your partner learns the energy you find exciting, such as confidence, mystery, gentleness, or playfulness.
|
||||
|
||||
60. Theme: Roleplay | Intensity: 4
|
||||
Prompt: In character, record the agreement you would want before a bolder roleplay scene begins.
|
||||
Intended outcome: Your partner hears your preferred boundaries, stop signal, and tone before either of you enters the scene.
|
||||
|
||||
61. Theme: Settings and atmosphere | Intensity: 2
|
||||
Prompt: Record a description of the room that makes you instantly want to put your phones away and focus on each other.
|
||||
Intended outcome: Your partner learns the environmental details that help you reconnect and can help create them.
|
||||
|
||||
62. Theme: Settings and atmosphere | Intensity: 3
|
||||
Prompt: Leave a voice note about the time of day when you are most open to a deliberately intimate evening.
|
||||
Intended outcome: Your partner hears your natural rhythm and can plan closeness for a time when you are more available.
|
||||
|
||||
63. Theme: Settings and atmosphere | Intensity: 3
|
||||
Prompt: Record the first three details you would set up to make a home evening feel like an occasion.
|
||||
Intended outcome: Your partner receives an actionable atmosphere recipe and can take ownership of one detail.
|
||||
|
||||
64. Theme: Settings and atmosphere | Intensity: 4
|
||||
Prompt: In a hushed voice, describe the kind of private escape you would like to create together without leaving home.
|
||||
Intended outcome: Your partner hears the emotional destination you want and can help transform the space into it.
|
||||
|
||||
65. Theme: Surprise and novelty | Intensity: 2
|
||||
Prompt: Record one harmless surprise your partner could prepare that would make you feel pursued.
|
||||
Intended outcome: Your partner learns an approachable surprise idea and can choose a moment to use it.
|
||||
|
||||
66. Theme: Surprise and novelty | Intensity: 3
|
||||
Prompt: Leave a voice note explaining what makes a surprise feel exciting to you instead of overwhelming.
|
||||
Intended outcome: Your partner hears the ingredients of welcome novelty and can avoid the parts that undermine it.
|
||||
|
||||
67. Theme: Surprise and novelty | Intensity: 3
|
||||
Prompt: Record a voice-only clue about a new private activity you would be curious to try together.
|
||||
Intended outcome: Your partner gets an invitation to ask questions and decide whether the activity suits both of you.
|
||||
|
||||
68. Theme: Surprise and novelty | Intensity: 4
|
||||
Prompt: In a playful whisper, tell your partner the one advance question they should ask before planning a bolder surprise.
|
||||
Intended outcome: Your partner learns the consent question that keeps novelty safe and can use it before making plans.
|
||||
|
||||
69. Theme: Long-distance desire | Intensity: 2
|
||||
Prompt: Record the everyday sound from your life that you wish your partner could hear when you are apart.
|
||||
Intended outcome: Your partner gets a vivid piece of your day and can send one back to make distance feel smaller.
|
||||
|
||||
70. Theme: Long-distance desire | Intensity: 4
|
||||
Prompt: Leave a private voice note describing how you would like your reunion to begin when you next have uninterrupted time together.
|
||||
Intended outcome: Your partner hears a clear reunion hope and can respond with their own version before you meet.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Concept Set A: Swipe proposals
|
||||
|
||||
Draft catalogue for the existing swipe interaction. A match represents both partners opting into the stated outcome. `partner_text` is the wording delivered to the other partner; `null` denotes a symmetric proposition.
|
||||
|
||||
## Asymmetric swipe concepts
|
||||
|
||||
| # | Intensity | Text | Partner text | Matched outcome |
|
||||
|---:|---:|---|---|---|
|
||||
| 1 | 2 | Send your partner a message describing the kind of attention you would love later. | Receive a message from your partner describing the kind of attention they would love later. | One partner has invited the other to create a specific kind of private attention. |
|
||||
| 2 | 2 | Ask your partner to choose one compliment you should hear in a more intimate setting. | Choose one compliment your partner should hear in a more intimate setting. | One partner wants to give a targeted confidence-building compliment and the other wants to receive it. |
|
||||
| 3 | 2 | Tell your partner one private thought that makes you feel closer to them. | Hear one private thought from your partner that makes them feel closer to you. | One partner is ready to share a personal desire for connection and the other is open to receiving it. |
|
||||
| 4 | 2 | Ask your partner to give you a private instruction in a message. | Give your partner a private instruction in a message. | One partner wants to follow a playful instruction and the other wants to set it. |
|
||||
| 5 | 3 | Describe a private scenario you would like your partner to set up for you. | Set up a private scenario your partner describes wanting. | One partner wants to describe a desired experience and the other wants to bring it to life. |
|
||||
| 6 | 3 | Ask your partner to tell you what they find hardest to resist about you. | Tell your partner what you find hardest to resist about them. | One partner wants a direct expression of desire and the other wants to give it. |
|
||||
| 7 | 3 | Invite your partner to send you a voice note that builds anticipation. | Send your partner a voice note that builds anticipation. | One partner wants to be teased by voice and the other wants to create that anticipation. |
|
||||
| 8 | 4 | Ask your partner to tell you one private wish they want you to consider. | Tell your partner one private wish you want them to consider. | One partner is open to receiving a direct private wish and the other is ready to name one. |
|
||||
| 9 | 2 | Let your partner choose a small signal that means they want your attention. | Choose a small signal that tells your partner you want their attention. | One partner wants to recognise a private invitation and the other wants to create one. |
|
||||
| 10 | 2 | Ask your partner to plan the first few minutes of a private date. | Plan the first few minutes of a private date for your partner. | One partner wants to arrive without knowing the opening and the other wants to create it. |
|
||||
| 11 | 3 | Let your partner decide when you are allowed to open a private surprise. | Decide when your partner is allowed to open a private surprise. | One partner wants delayed anticipation and the other wants to control the reveal. |
|
||||
| 12 | 3 | Wait for your partner to give you the cue to begin a private plan. | Give your partner the cue to begin a private plan. | One partner wants to wait for permission and the other wants to choose the moment. |
|
||||
| 13 | 3 | Ask your partner to create a private clue trail leading to what they want. | Create a private clue trail leading to what you want from your partner. | One partner wants to follow an intimate trail of hints and the other wants to design it. |
|
||||
| 14 | 3 | Let your partner choose one thing to postpone to make the build-up longer. | Choose one thing to postpone to make the build-up longer for your partner. | One partner wants a slower build-up and the other wants to decide what is held back. |
|
||||
| 15 | 4 | Ask your partner to plan a private surprise within limits you agree first. | Plan a private surprise for your partner within limits you agree first. | One partner wants a contained surprise and the other wants to lead it safely. |
|
||||
| 16 | 4 | Let your partner choose the order of a private experience without telling you. | Choose the order of a private experience without telling your partner. | One partner wants uncertainty within an agreed experience and the other wants to choreograph it. |
|
||||
| 17 | 2 | Choose what your partner wears for a private date. | Let your partner choose what you wear for a private date. | One partner wants to style the moment and the other wants to be chosen for. |
|
||||
| 18 | 2 | Choose the music that sets the mood for your private time together. | Let your partner choose the music that sets the mood for your private time together. | One partner wants to set the atmosphere and the other wants to enter it. |
|
||||
| 19 | 3 | Give your partner three private options and choose which one they follow. | Let your partner give you three private options and choose which one you follow. | One partner wants to offer controlled choices and the other wants to accept a selected direction. |
|
||||
| 20 | 3 | Plan a private experience where your partner follows your pace. | Follow your partner’s pace during a private experience they plan. | One partner wants to guide the rhythm and the other wants to be guided. |
|
||||
| 21 | 3 | Choose the first form of affection you give your partner in private. | Let your partner choose the first form of affection they give you in private. | One partner wants to initiate deliberately and the other wants to receive an intentional opening. |
|
||||
| 22 | 4 | Decide the private role you want your partner to play for you. | Play the private role your partner chooses for you. | One partner wants to direct a role-based encounter and the other wants to inhabit the chosen role. |
|
||||
| 23 | 4 | Set the rules for a private game your partner agrees to play. | Play a private game using rules your partner sets. | One partner wants to set a consensual frame and the other wants to play within it. |
|
||||
| 24 | 4 | Choose when your partner may take the next step in a private encounter. | Let your partner choose when you may take the next step in a private encounter. | One partner wants to control pacing and the other wants to receive a clear cue. |
|
||||
| 25 | 2 | Let your partner choose the private setting where you spend time together. | Choose the private setting where you spend time with your partner. | One partner wants to be led into a setting and the other wants to choose it. |
|
||||
| 26 | 2 | Follow a private date plan your partner creates without seeing it first. | Create a private date plan for your partner without showing it first. | One partner wants a planned surprise and the other wants to arrange it. |
|
||||
| 27 | 3 | Let your partner decide how slowly you take a private moment together. | Decide how slowly you take a private moment together with your partner. | One partner wants to surrender the pace and the other wants to set it. |
|
||||
| 28 | 3 | Follow one private instruction from your partner without negotiating the details first. | Give your partner one private instruction without negotiating the details first. | One partner wants to follow a simple direction and the other wants to give one. |
|
||||
| 29 | 3 | Let your partner choose the kind of attention you receive first. | Choose the kind of attention your partner receives first. | One partner wants to receive a chosen form of attention and the other wants to give it. |
|
||||
| 30 | 4 | Let your partner decide the sequence of an agreed private experience. | Decide the sequence of an agreed private experience for your partner. | One partner wants to follow an agreed sequence and the other wants to lead it. |
|
||||
| 31 | 4 | Follow a private role your partner sets after discussing your limits. | Set a private role for your partner after discussing their limits. | One partner wants to follow a defined role and the other wants to set it within agreed limits. |
|
||||
| 32 | 2 | Choose one element of a private date while your partner chooses the other. | Choose one element of a private date while your partner chooses the other. | Both partners want to co-create a private date with equal input. |
|
||||
| 33 | 2 | Take turns choosing how you show affection during a private evening. | Take turns choosing how you show affection during a private evening. | Both partners want alternating opportunities to initiate affection. |
|
||||
| 34 | 3 | Create a private list of three ideas and let your partner pick one for you. | Create a private list of three ideas and let your partner pick one for you. | Both partners want to offer choices and select an experience for each other. |
|
||||
| 35 | 3 | Agree that each of you gets one turn to direct part of a private experience. | Agree that each of you gets one turn to direct part of a private experience. | Both partners want shared leadership with clear turns. |
|
||||
| 36 | 3 | Choose a private challenge for your partner and accept one from them. | Choose a private challenge for your partner and accept one from them. | Both partners want to exchange playful, consensual challenges. |
|
||||
| 37 | 4 | Take turns naming one thing you want and one thing you are willing to give. | Take turns naming one thing you want and one thing you are willing to give. | Both partners want to negotiate desire through reciprocal offers. |
|
||||
| 38 | 4 | Agree that either partner can change the direction of a private experience once. | Agree that either partner can change the direction of a private experience once. | Both partners want shared control and permission to redirect the experience. |
|
||||
| 39 | 2 | Ask your partner to name the kind of reassurance they want before trying something new. | Tell your partner the kind of reassurance you want before trying something new. | One partner wants to offer reassurance and the other wants to name what helps. |
|
||||
| 40 | 2 | Choose a private check-in phrase your partner can use with you. | Use a private check-in phrase your partner chooses for you. | One partner wants to establish a check-in and the other wants to use it. |
|
||||
| 41 | 3 | Tell your partner one boundary that helps you relax during private exploration. | Hear one boundary from your partner that helps them relax during private exploration. | One partner wants to state a boundary and the other is open to receiving it. |
|
||||
| 42 | 3 | Ask your partner to choose a simple signal for slowing down. | Choose a simple signal for slowing down with your partner. | One partner wants a clear slowing-down signal and the other wants to define it. |
|
||||
| 43 | 3 | Invite your partner to tell you what would make a new idea feel safer. | Tell your partner what would make a new idea feel safer for you. | One partner wants to adapt to the other’s needs and the other wants to express them. |
|
||||
| 44 | 4 | Ask your partner to set one firm limit before you lead a private experience. | Set one firm limit before your partner leads a private experience. | One partner wants to lead only with a clear limit and the other wants to set it. |
|
||||
| 45 | 4 | Ask your partner to tell you what would make them stop a private experience. | Tell your partner what would make you stop a private experience. | One partner wants clarity about stopping conditions and the other wants to define them. |
|
||||
| 46 | 2 | Ask your partner to choose how they would like to reconnect after private time. | Choose how you would like to reconnect with your partner after private time. | One partner wants to provide the preferred form of reconnection and the other wants to choose it. |
|
||||
| 47 | 2 | Tell your partner one thing they could say that would make you feel especially appreciated afterwards. | Hear one thing you could say that would make your partner feel especially appreciated afterwards. | One partner wants to name an affirming phrase and the other wants to offer it. |
|
||||
| 48 | 3 | Let your partner choose whether you stay close, talk, or have quiet space after an intimate moment. | Choose whether you stay close, talk, or have quiet space after an intimate moment with your partner. | One partner wants the other to decide the reconnection style and the other wants to choose it. |
|
||||
| 49 | 3 | Ask your partner to tell you what kind of comfort helps them feel grounded afterwards. | Tell your partner what kind of comfort helps you feel grounded afterwards. | One partner wants to provide grounding comfort and the other wants to describe it. |
|
||||
| 50 | 3 | Give your partner a chance to tell you what they liked most after private exploration. | Tell your partner what you liked most after private exploration. | One partner wants feedback and the other wants to share positive detail. |
|
||||
| 51 | 4 | Ask your partner to choose one way you can make them feel cared for after an intense private experience. | Choose one way your partner can make you feel cared for after an intense private experience. | One partner wants to provide deliberate care and the other wants to specify it. |
|
||||
| 52 | 4 | Tell your partner what you need from them if a private experience leaves you feeling exposed. | Hear what your partner needs if a private experience leaves them feeling exposed. | One partner wants to name a vulnerability need and the other wants to meet it. |
|
||||
| 53 | 2 | Let your partner choose one part of you to compliment slowly and sincerely. | Choose one part of your partner to compliment slowly and sincerely. | One partner wants to receive focused admiration and the other wants to give it. |
|
||||
| 54 | 2 | Ask your partner to give you their full attention while you share what feels romantic to you. | Give your partner your full attention while they share what feels romantic to them. | One partner wants to be heard about romance and the other wants to listen attentively. |
|
||||
| 55 | 3 | Receive a slow, uninterrupted moment of affection chosen by your partner. | Give your partner a slow, uninterrupted moment of affection you choose. | One partner wants to receive focused affection and the other wants to choose how to give it. |
|
||||
| 56 | 3 | Give your partner the kind of private attention they ask for without asking for anything back first. | Receive the kind of private attention you ask for without giving anything back first. | One partner wants to give without immediate reciprocity and the other wants to receive freely. |
|
||||
| 57 | 3 | Let your partner choose how they want you to focus on them during private time. | Choose how you want your partner to focus on you during private time. | One partner wants to direct the attention they receive and the other wants to follow that direction. |
|
||||
| 58 | 4 | Ask your partner to give you their full focus while you set the pace. | Give your partner your full focus while they set the pace. | One partner wants to lead the pace while receiving undivided attention from the other. |
|
||||
| 59 | 4 | Let your partner ask for one kind of private attention you will focus on giving. | Ask your partner for one kind of private attention they will focus on giving. | One partner wants to fulfil a focused request and the other wants to make one. |
|
||||
| 60 | 4 | Choose how you want your partner to make you feel desired in private. | Make your partner feel desired in private in the way they choose. | One partner wants to name a desired experience and the other wants to deliver it. |
|
||||
|
||||
## Symmetric swipe proposals
|
||||
|
||||
| # | Intensity | Text | Partner text | Matched outcome |
|
||||
|---:|---:|---|---|---|
|
||||
| 1 | 2 | Exchange a private message naming one thing you find irresistible about each other. | null | Both partners agree to trade direct expressions of attraction. |
|
||||
| 2 | 2 | Give each other one compliment that feels more personal than usual. | null | Both partners want a more intimate exchange of appreciation. |
|
||||
| 3 | 3 | Send each other a voice note that sets a private mood. | null | Both partners agree to create anticipation through voice. |
|
||||
| 4 | 4 | Share one private wish each and discuss what would make it feel exciting and safe. | null | Both partners agree to reveal a private wish and discuss its boundaries. |
|
||||
| 5 | 2 | Plan a private surprise for each other using only a few clues. | null | Both partners agree to build anticipation through limited information. |
|
||||
| 6 | 3 | Agree on a private signal that starts an anticipated experience. | null | Both partners want a mutual cue that begins their planned moment. |
|
||||
| 7 | 3 | Leave each other one private hint about something you want to try together. | null | Both partners agree to exchange hints before discussing an idea. |
|
||||
| 8 | 4 | Plan a private experience where neither of you knows the full sequence beforehand. | null | Both partners want surprise while retaining an agreed framework. |
|
||||
| 9 | 2 | Take turns planning a private date where the other person only knows the starting point. | null | Both partners agree to alternate between leading and being surprised. |
|
||||
| 10 | 3 | Take turns giving one private instruction and following one. | null | Both partners want to alternate consensual direction and follow-through. |
|
||||
| 11 | 3 | Choose a private role for each other and agree whether to swap halfway through. | null | Both partners want a role-based experience with an optional switch. |
|
||||
| 12 | 4 | Take turns setting the pace during an agreed private experience. | null | Both partners agree to hand leadership back and forth. |
|
||||
| 13 | 2 | Each choose one part of a private date and combine the plans. | null | Both partners want to build a shared private date. |
|
||||
| 14 | 3 | Make a private list of three ideas each and choose one from the other person’s list. | null | Both partners agree to select a mutually desired new idea. |
|
||||
| 15 | 3 | Use a coin toss to decide who leads the first part of your private time together. | null | Both partners want a playful, equal way to assign the opening lead. |
|
||||
| 16 | 4 | Agree that either person can pause, redirect, or end a private experience without explanation. | null | Both partners establish equal control over continuation and change. |
|
||||
| 17 | 2 | Choose a simple phrase that means you want more reassurance. | null | Both partners agree on a shared reassurance signal. |
|
||||
| 18 | 3 | Name one thing that helps you feel secure when trying something unfamiliar. | null | Both partners agree to share and honour their comfort needs. |
|
||||
| 19 | 3 | Agree on a signal that means slow down and a signal that means stop. | null | Both partners establish clear ways to adjust or end an experience. |
|
||||
| 20 | 4 | Discuss one private boundary you want honoured before exploring a more intense idea. | null | Both partners agree to state a firm limit before escalating. |
|
||||
| 21 | 2 | Tell each other how you most like to reconnect after a private date. | null | Both partners agree to share their preferred reconnection style. |
|
||||
| 22 | 3 | Set aside time to tell each other what felt especially good about a private experience. | null | Both partners agree to exchange affirming feedback. |
|
||||
| 23 | 3 | Choose a shared ritual that helps you feel close after intimate time. | null | Both partners want a repeatable ritual of reconnection. |
|
||||
| 24 | 4 | Agree to check in with each other after trying something that feels emotionally intense. | null | Both partners commit to care and reflection after deeper exploration. |
|
||||
| 25 | 2 | Take turns receiving five uninterrupted minutes of affectionate attention. | null | Both partners agree to give and receive undivided affection. |
|
||||
| 26 | 3 | Take turns asking for the kind of private attention you most want. | null | Both partners agree to voice a specific desire and respond to it. |
|
||||
| 27 | 3 | Give each other a turn where one person only receives and the other only gives attention. | null | Both partners want reciprocal focused giving and receiving. |
|
||||
| 28 | 4 | Tell each other one way you want to feel desired, then make space for both requests. | null | Both partners agree to make each other feel desired in individually meaningful ways. |
|
||||
| 29 | 4 | Take turns creating a private experience centred entirely on the other person’s enjoyment. | null | Both partners want to alternate between giving undivided attention and receiving it. |
|
||||
| 30 | 4 | Agree to ask directly for what you want and answer honestly about what you can give. | null | Both partners agree to a direct, consent-led exchange of desire. |
|
||||
@@ -0,0 +1,484 @@
|
||||
# Concept Set B
|
||||
|
||||
Status: authored source fixture. Source-only and unpublished.
|
||||
|
||||
This document contains 60 asymmetric concepts and 30 symmetric concepts for the
|
||||
play-safe spicy catalogue repair. Each entry has a theme, intensity, primary
|
||||
copy, exact partner copy, and an intended outcome.
|
||||
|
||||
## Asymmetric concepts
|
||||
|
||||
### Sensory cues
|
||||
|
||||
#### A01
|
||||
Theme: Sensory cues
|
||||
Intensity: 2
|
||||
Primary text: Would you be up for choosing a scent that you want your partner to wear for a close evening?
|
||||
Partner text: Would you be up for choosing a scent that you want your partner to wear for a close evening?
|
||||
Intended outcome: One partner selects a scent cue for the other to use on their next intentional date night.
|
||||
|
||||
#### A02
|
||||
Theme: Sensory cues
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to prepare a playlist that sets the mood for a more intimate evening with you?
|
||||
Partner text: Would you like to prepare a playlist that sets the mood for a more intimate evening with your partner?
|
||||
Intended outcome: The receiving partner gets a personally chosen mood playlist.
|
||||
|
||||
#### A03
|
||||
Theme: Sensory cues
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner guiding you through a slow eyes-closed guessing game with safe household textures?
|
||||
Partner text: Would you enjoy guiding your partner through a slow eyes-closed guessing game with safe household textures?
|
||||
Intended outcome: The proposer leads a private sensory game and the receiver agrees to participate.
|
||||
|
||||
#### A04
|
||||
Theme: Sensory cues
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to choose the lighting for a close evening together?
|
||||
Partner text: Would you like to choose the lighting for a close evening together with your partner?
|
||||
Intended outcome: One partner creates the visual atmosphere while the other agrees to experience it.
|
||||
|
||||
#### A05
|
||||
Theme: Sensory cues
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner asking you to focus only on their voice for a few uninterrupted minutes?
|
||||
Partner text: Would you be open to asking your partner to focus only on your voice for a few uninterrupted minutes?
|
||||
Intended outcome: The proposer receives undivided, voice-led attention in private.
|
||||
|
||||
#### A06
|
||||
Theme: Sensory cues
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to pick a dessert or drink for you to share slowly after your date?
|
||||
Partner text: Would you like to pick a dessert or drink for your partner to share slowly after your date?
|
||||
Intended outcome: The proposer chooses a shared treat that extends a date into a close moment.
|
||||
|
||||
#### A07
|
||||
Theme: Sensory cues
|
||||
Intensity: 4
|
||||
Primary text: Would you enjoy your partner choosing one song and asking you to stay close with them until it ends?
|
||||
Partner text: Would you enjoy choosing one song and asking your partner to stay close with you until it ends?
|
||||
Intended outcome: The couple agrees to a deliberate, uninterrupted closeness ritual around one song.
|
||||
|
||||
#### A08
|
||||
Theme: Sensory cues
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to leave a note telling you one small detail they find especially attractive about you?
|
||||
Partner text: Would you like to leave your partner a note telling them one small detail you find especially attractive about them?
|
||||
Intended outcome: The proposer gives the receiver a specific, private compliment in writing.
|
||||
|
||||
### Private performance
|
||||
|
||||
#### A09
|
||||
Theme: Private performance
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to choose a song and perform a playful lip-sync just for you?
|
||||
Partner text: Would you like to choose a song and perform a playful lip-sync just for your partner?
|
||||
Intended outcome: One partner gives the other a private, playful performance.
|
||||
|
||||
#### A10
|
||||
Theme: Private performance
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner giving you a short, confident compliment speech before your next date night?
|
||||
Partner text: Would you enjoy giving your partner a short, confident compliment speech before your next date night?
|
||||
Intended outcome: The receiver gets a deliberate verbal build-up before the date.
|
||||
|
||||
#### A11
|
||||
Theme: Private performance
|
||||
Intensity: 3
|
||||
Primary text: Would you be up for your partner choosing a song for you to slow-dance to together at home?
|
||||
Partner text: Would you be up for choosing a song for you and your partner to slow-dance to together at home?
|
||||
Intended outcome: The proposer picks the soundtrack for a private slow dance.
|
||||
|
||||
#### A12
|
||||
Theme: Private performance
|
||||
Intensity: 4
|
||||
Primary text: Would you like your partner to tell you, face to face, the version of you they find hardest to resist?
|
||||
Partner text: Would you like to tell your partner, face to face, the version of them you find hardest to resist?
|
||||
Intended outcome: The couple shares a direct, personal desire statement.
|
||||
|
||||
#### A13
|
||||
Theme: Private performance
|
||||
Intensity: 2
|
||||
Primary text: Would you enjoy your partner making you laugh with a deliberately over-the-top romantic introduction?
|
||||
Partner text: Would you enjoy making your partner laugh with a deliberately over-the-top romantic introduction?
|
||||
Intended outcome: One partner performs a comic, affectionate introduction for the other.
|
||||
|
||||
#### A14
|
||||
Theme: Private performance
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to write and read you a short invitation for a private evening together?
|
||||
Partner text: Would you like to write and read your partner a short invitation for a private evening together?
|
||||
Intended outcome: The proposer creates a spoken invitation that frames the evening.
|
||||
|
||||
#### A15
|
||||
Theme: Private performance
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner asking you to sit back while they set the pace for the first part of your evening?
|
||||
Partner text: Would you be open to asking your partner to sit back while you set the pace for the first part of your evening?
|
||||
Intended outcome: The proposer leads the opening part of a mutually agreed private evening.
|
||||
|
||||
#### A16
|
||||
Theme: Private performance
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to reveal one new side of their confidence through a private dance for you?
|
||||
Partner text: Would you like to reveal one new side of your confidence through a private dance for your partner?
|
||||
Intended outcome: One partner shares a confidence-focused private dance.
|
||||
|
||||
### Clothes and confidence
|
||||
|
||||
#### A17
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to choose one outfit detail for your next date that makes them smile?
|
||||
Partner text: Would you like to choose one outfit detail for your partner's next date that makes you smile?
|
||||
Intended outcome: The proposer chooses a small style detail for the receiver to wear.
|
||||
|
||||
#### A18
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner putting together a look for you that feels more daring than your usual style?
|
||||
Partner text: Would you enjoy putting together a look for your partner that feels more daring than their usual style?
|
||||
Intended outcome: One partner styles the other for a bolder but comfortable date-night look.
|
||||
|
||||
#### A19
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to ask you to wear the colour they most associate with you feeling confident?
|
||||
Partner text: Would you like to ask your partner to wear the colour you most associate with them feeling confident?
|
||||
Intended outcome: The receiver wears a colour chosen for its confidence association.
|
||||
|
||||
#### A20
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner choosing the dress code for a private evening, within limits you both agree first?
|
||||
Partner text: Would you be open to choosing the dress code for a private evening, within limits you both agree first?
|
||||
Intended outcome: One partner leads the evening's agreed presentation and atmosphere.
|
||||
|
||||
#### A21
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to send you a message before your date saying exactly what they hope you will wear?
|
||||
Partner text: Would you like to send your partner a message before your date saying exactly what you hope they will wear?
|
||||
Intended outcome: The receiver gets a style preference message before a date.
|
||||
|
||||
#### A22
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner choosing an accessory for you to wear as a private signal between you?
|
||||
Partner text: Would you enjoy choosing an accessory for your partner to wear as a private signal between you?
|
||||
Intended outcome: The couple establishes a subtle shared signal using an accessory.
|
||||
|
||||
#### A23
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 4
|
||||
Primary text: Would you be up for your partner asking you to get ready slowly while they give you one sincere compliment at each step?
|
||||
Partner text: Would you be up for asking your partner to get ready slowly while you give them one sincere compliment at each step?
|
||||
Intended outcome: The proposer gives the receiver a paced confidence ritual before a night together.
|
||||
|
||||
#### A24
|
||||
Theme: Clothes and confidence
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to choose a 'stay in' outfit for you that feels intentionally different from everyday clothes?
|
||||
Partner text: Would you like to choose a 'stay in' outfit for your partner that feels intentionally different from everyday clothes?
|
||||
Intended outcome: The proposer selects a special at-home look for the receiver.
|
||||
|
||||
### Remaining authored asymmetric entries
|
||||
|
||||
The following numbered entries retain the authored Concept Set B material for
|
||||
fantasy exchange, roleplay, settings and atmosphere, surprise and novelty, and
|
||||
long-distance desire. Their primary text, partner text, and intended outcome
|
||||
are deliberately retained verbatim in the task transcript for integration.
|
||||
|
||||
#### A25
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to tell you about a fictional date they would plan for the two of you with no practical limits?
|
||||
Partner text: Would you like to tell your partner about a fictional date you would plan for the two of you with no practical limits?
|
||||
Intended outcome: The proposer shares an imaginative, romance-forward date scenario.
|
||||
|
||||
#### A26
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner writing a three-line scene about the two of you meeting as strangers in a glamorous place?
|
||||
Partner text: Would you enjoy writing a three-line scene about you and your partner meeting as strangers in a glamorous place?
|
||||
Intended outcome: The couple gets a short shared fantasy prompt to discuss or play with.
|
||||
|
||||
#### A27
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 3
|
||||
Primary text: Would you be open to your partner asking what kind of attention makes you feel most wanted in an imagined scenario?
|
||||
Partner text: Would you be open to asking your partner what kind of attention makes them feel most wanted in an imagined scenario?
|
||||
Intended outcome: The receiver names a preferred kind of romantic attention without needing to commit to it.
|
||||
|
||||
#### A28
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 4
|
||||
Primary text: Would you like your partner to propose a fictional version of tonight where they get to surprise you, and you choose the boundaries first?
|
||||
Partner text: Would you like to propose a fictional version of tonight where you get to surprise your partner, and they choose the boundaries first?
|
||||
Intended outcome: One partner proposes an agreed fantasy frame, with the receiver setting limits.
|
||||
|
||||
#### A29
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to invent a nickname for the more confident version of you?
|
||||
Partner text: Would you like to invent a nickname for the more confident version of your partner?
|
||||
Intended outcome: The couple creates a playful private identity cue.
|
||||
|
||||
#### A30
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner describing the setting of their ideal slow-burn evening with you?
|
||||
Partner text: Would you enjoy describing the setting of your ideal slow-burn evening with your partner?
|
||||
Intended outcome: The proposer shares a detailed atmosphere preference that can inform a future plan.
|
||||
|
||||
#### A31
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner asking you what you would want them to take charge of in an imaginary evening?
|
||||
Partner text: Would you be open to asking your partner what they would want you to take charge of in an imaginary evening?
|
||||
Intended outcome: The receiver identifies one area of leadership they would welcome in fantasy.
|
||||
|
||||
#### A32
|
||||
Theme: Fantasy exchange
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to send you a private message that starts, 'If we had the whole evening to ourselves...' ?
|
||||
Partner text: Would you like to send your partner a private message that starts, 'If we had the whole evening to ourselves...' ?
|
||||
Intended outcome: The proposer sends an open-ended desire prompt for a private exchange.
|
||||
|
||||
#### A33
|
||||
Theme: Roleplay
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to pretend they are meeting you for the first time and ask you out again?
|
||||
Partner text: Would you like to pretend you are meeting your partner for the first time and ask them out again?
|
||||
Intended outcome: The proposer recreates a first-meeting invitation in character.
|
||||
|
||||
#### A34
|
||||
Theme: Roleplay
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner taking the role of your personal host for an evening at home?
|
||||
Partner text: Would you enjoy taking the role of your partner's personal host for an evening at home?
|
||||
Intended outcome: One partner plans and hosts a dedicated private evening.
|
||||
|
||||
#### A35
|
||||
Theme: Roleplay
|
||||
Intensity: 3
|
||||
Primary text: Would you be up for your partner acting as a confident stranger who has one hour to win you over?
|
||||
Partner text: Would you be up for acting as a confident stranger who has one hour to win your partner over?
|
||||
Intended outcome: The proposer takes on a flirtatious stranger character during an agreed private scenario.
|
||||
|
||||
#### A36
|
||||
Theme: Roleplay
|
||||
Intensity: 4
|
||||
Primary text: Would you like your partner to choose a role where they lead the conversation and you decide when to step out of character?
|
||||
Partner text: Would you like to choose a role where you lead the conversation and your partner decides when to step out of character?
|
||||
Intended outcome: The proposer leads a consensual roleplay conversation with a clear exit controlled by the receiver.
|
||||
|
||||
#### A37
|
||||
Theme: Roleplay
|
||||
Intensity: 2
|
||||
Primary text: Would you enjoy your partner writing you a pretend invitation to an exclusive event for two?
|
||||
Partner text: Would you enjoy writing your partner a pretend invitation to an exclusive event for two?
|
||||
Intended outcome: The proposer creates a playful invitation that introduces a private date concept.
|
||||
|
||||
#### A38
|
||||
Theme: Roleplay
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to play the part of a stylist who gives you one confidence challenge for the evening?
|
||||
Partner text: Would you like to play the part of a stylist who gives your partner one confidence challenge for the evening?
|
||||
Intended outcome: One partner sets a light, agreed confidence challenge in character.
|
||||
|
||||
#### A39
|
||||
Theme: Roleplay
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner choosing a character who is bolder than they usually are, while you set the limits of the scene?
|
||||
Partner text: Would you be open to choosing a character who is bolder than you usually are, while your partner sets the limits of the scene?
|
||||
Intended outcome: The proposer explores a bolder role while the receiver sets the scene's boundaries.
|
||||
|
||||
#### A40
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to turn one room into a date-night space before you arrive?
|
||||
Partner text: Would you like to turn one room into a date-night space before your partner arrives?
|
||||
Intended outcome: The proposer prepares a distinct at-home setting for the receiver.
|
||||
|
||||
#### A41
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner choosing a place in your home where you are not usually romantic and making it feel special?
|
||||
Partner text: Would you enjoy choosing a place in your home where you and your partner are not usually romantic and making it feel special?
|
||||
Intended outcome: One partner transforms an ordinary indoor space into a deliberate romantic setting.
|
||||
|
||||
#### A42
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to plan a candlelit late-night snack for the two of you after everything else is quiet?
|
||||
Partner text: Would you like to plan a candlelit late-night snack for you and your partner after everything else is quiet?
|
||||
Intended outcome: The proposer creates a calm, intimate-feeling post-evening ritual.
|
||||
|
||||
#### A43
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner arranging a private evening where your phones stay out of reach until you both agree to check them?
|
||||
Partner text: Would you be open to arranging a private evening where your phones stay out of reach until you both agree to check them?
|
||||
Intended outcome: The couple agrees to protected, distraction-free private time.
|
||||
|
||||
#### A44
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to choose the route for a slow evening walk that ends somewhere cosy?
|
||||
Partner text: Would you like to choose the route for a slow evening walk that ends somewhere cosy with your partner?
|
||||
Intended outcome: The proposer directs a low-pressure shared walk toward a close setting.
|
||||
|
||||
#### A45
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner planning a 'no agenda' hour where they only focus on making you feel looked after?
|
||||
Partner text: Would you enjoy planning a 'no agenda' hour where you only focus on making your partner feel looked after?
|
||||
Intended outcome: One partner takes responsibility for a gentle, attentiveness-focused hour.
|
||||
|
||||
#### A46
|
||||
Theme: Settings and atmosphere
|
||||
Intensity: 4
|
||||
Primary text: Would you like your partner to choose a private setting for a conversation about what would make tonight feel exciting to you?
|
||||
Partner text: Would you like to choose a private setting for a conversation about what would make tonight feel exciting to your partner?
|
||||
Intended outcome: The proposer creates the conditions for a direct desire and boundaries conversation.
|
||||
|
||||
#### A47
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to leave you a sealed note with one romantic surprise inside?
|
||||
Partner text: Would you like to leave your partner a sealed note with one romantic surprise inside?
|
||||
Intended outcome: The proposer creates a small, consent-safe romantic surprise for the receiver.
|
||||
|
||||
#### A48
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner planning one part of your evening that they do not reveal until you have both agreed to begin?
|
||||
Partner text: Would you enjoy planning one part of your evening that you do not reveal until you and your partner have both agreed to begin?
|
||||
Intended outcome: One partner prepares a mutually agreed surprise element.
|
||||
|
||||
#### A49
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to give you a choice between two different kinds of close evening, without telling you which they hope you pick?
|
||||
Partner text: Would you like to give your partner a choice between two different kinds of close evening, without telling them which you hope they pick?
|
||||
Intended outcome: The couple uses a playful choice to reveal preferences and select a plan.
|
||||
|
||||
#### A50
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner asking you to say yes to one surprise date-night detail, after they tell you the category but not the detail?
|
||||
Partner text: Would you be open to asking your partner to say yes to one surprise date-night detail, after you tell them the category but not the detail?
|
||||
Intended outcome: The receiver consents to a bounded surprise with enough information to choose safely.
|
||||
|
||||
#### A51
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to hide a clue somewhere at home that leads to a compliment from them?
|
||||
Partner text: Would you like to hide a clue somewhere at home that leads your partner to a compliment from you?
|
||||
Intended outcome: The proposer creates a mini treasure hunt ending in affirmation.
|
||||
|
||||
#### A52
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner choosing a new shared ritual for the first ten minutes of date night?
|
||||
Partner text: Would you enjoy choosing a new shared ritual for the first ten minutes of date night with your partner?
|
||||
Intended outcome: The proposer introduces a repeatable opening ritual for intentional time together.
|
||||
|
||||
#### A53
|
||||
Theme: Surprise and novelty
|
||||
Intensity: 4
|
||||
Primary text: Would you like your partner to surprise you with a confidence challenge that you can change or stop at any point?
|
||||
Partner text: Would you like to surprise your partner with a confidence challenge that they can change or stop at any point?
|
||||
Intended outcome: The proposer offers a novel confidence prompt with explicit receiver control.
|
||||
|
||||
#### A54
|
||||
Theme: Long-distance desire
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to send you a voice note describing the first thing they want to do when you next have time alone together?
|
||||
Partner text: Would you like to send your partner a voice note describing the first thing you want to do when you next have time alone together?
|
||||
Intended outcome: The proposer shares an anticipation-building voice message for the next reunion.
|
||||
|
||||
#### A55
|
||||
Theme: Long-distance desire
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner planning a video-date mood for you to join from wherever you are?
|
||||
Partner text: Would you enjoy planning a video-date mood for your partner to join from wherever they are?
|
||||
Intended outcome: One partner sets a shared remote date atmosphere.
|
||||
|
||||
#### A56
|
||||
Theme: Long-distance desire
|
||||
Intensity: 3
|
||||
Primary text: Would you like your partner to send you a message telling you what they miss most about being close to you?
|
||||
Partner text: Would you like to send your partner a message telling them what you miss most about being close to them?
|
||||
Intended outcome: The receiver gets a specific intimacy-adjacent expression of longing.
|
||||
|
||||
#### A57
|
||||
Theme: Long-distance desire
|
||||
Intensity: 4
|
||||
Primary text: Would you be open to your partner arranging a private call where you both say one thing you hope to explore together when you reunite?
|
||||
Partner text: Would you be open to arranging a private call where you both say one thing you hope to explore together when you reunite?
|
||||
Intended outcome: The couple holds a mutual, forward-looking conversation about agreed future intimacy.
|
||||
|
||||
#### A58
|
||||
Theme: Long-distance desire
|
||||
Intensity: 2
|
||||
Primary text: Would you like your partner to send you a photo of something that reminds them of your chemistry?
|
||||
Partner text: Would you like to send your partner a photo of something that reminds you of your chemistry?
|
||||
Intended outcome: The proposer shares a safe visual cue connected to their relationship.
|
||||
|
||||
#### A59
|
||||
Theme: Long-distance desire
|
||||
Intensity: 3
|
||||
Primary text: Would you enjoy your partner choosing a song for you to listen to at the same time from different places?
|
||||
Partner text: Would you enjoy choosing a song for you and your partner to listen to at the same time from different places?
|
||||
Intended outcome: The pair creates a synchronized remote closeness ritual.
|
||||
|
||||
#### A60
|
||||
Theme: Long-distance desire
|
||||
Intensity: 4
|
||||
Primary text: Would you like your partner to ask you for one honest promise about how you will make time for each other when distance ends?
|
||||
Partner text: Would you like to ask your partner for one honest promise about how you will make time for each other when distance ends?
|
||||
Intended outcome: The couple makes a concrete reunion-intent commitment.
|
||||
|
||||
## Symmetric concepts
|
||||
|
||||
Each symmetric entry uses the same text as its partner text.
|
||||
|
||||
| ID | Theme | Intensity | Primary text | Partner text | Intended outcome |
|
||||
| --- | --- | ---: | --- | --- | --- |
|
||||
| S01 | Sensory cues | 2 | Would you both be up for choosing a shared scent for your next intentional evening? | Same as primary text. | The couple selects one scent cue for an upcoming date night. |
|
||||
| S02 | Sensory cues | 3 | Would you both enjoy a quiet, eyes-closed music moment together before your next date? | Same as primary text. | The pair agrees to begin a date with focused shared listening. |
|
||||
| S03 | Sensory cues | 3 | Would you both like to make a list of five sounds that help you feel relaxed and close? | Same as primary text. | The couple creates a personal sound-based mood guide. |
|
||||
| S04 | Sensory cues | 4 | Would you both be open to spending ten minutes in silence, staying close and checking in afterward about how it felt? | Same as primary text. | The couple tries a deliberate quiet closeness exercise followed by a check-in. |
|
||||
| S05 | Private performance | 2 | Would you both be up for taking turns giving each other a dramatic, affectionate introduction? | Same as primary text. | Each partner performs a playful confidence-building introduction. |
|
||||
| S06 | Private performance | 3 | Would you both enjoy choosing one song each for a private dance together? | Same as primary text. | The pair shares two intentionally selected dances. |
|
||||
| S07 | Private performance | 3 | Would you both like to read each other a short note about what makes a date feel exciting? | Same as primary text. | Each partner names what builds anticipation for them. |
|
||||
| S08 | Private performance | 4 | Would you both be open to taking turns leading a five-minute close moment, with either person able to pause it? | Same as primary text. | The partners alternate leadership with clear stop control. |
|
||||
| S09 | Clothes and confidence | 2 | Would you both enjoy choosing one small style detail for each other's next date-night look? | Same as primary text. | Each partner receives a playful style suggestion. |
|
||||
| S10 | Clothes and confidence | 3 | Would you both like to wear something that makes you feel more confident than usual for an evening at home? | Same as primary text. | Both partners intentionally dress for confidence at home. |
|
||||
| S11 | Clothes and confidence | 3 | Would you both be up for sharing one compliment about how the other partner looks when they feel most like themselves? | Same as primary text. | Each partner receives an appearance-based affirmation. |
|
||||
| S12 | Clothes and confidence | 4 | Would you both be open to agreeing a private dress code for a date night that feels new to you? | Same as primary text. | The couple jointly chooses a novel, mutually comfortable date-night presentation. |
|
||||
| S13 | Fantasy exchange | 2 | Would you both enjoy inventing an imaginary getaway designed entirely around feeling close? | Same as primary text. | The couple co-creates a romantic fantasy setting. |
|
||||
| S14 | Fantasy exchange | 3 | Would you both like to finish the sentence, 'A version of us with no interruptions would...' ? | Same as primary text. | Each partner shares an open-ended private-time wish. |
|
||||
| S15 | Fantasy exchange | 3 | Would you both be up for naming one fictional setting that makes romance feel more exciting to you? | Same as primary text. | The pair discovers compatible atmosphere preferences. |
|
||||
| S16 | Fantasy exchange | 4 | Would you both be open to making a shared 'maybe someday' list of private experiences you could discuss before trying? | Same as primary text. | The couple creates a consent-first discussion list without committing to any item. |
|
||||
| S17 | Roleplay | 2 | Would you both enjoy pretending you are on a first date and asking each other three new questions? | Same as primary text. | The couple refreshes their connection through a first-date scenario. |
|
||||
| S18 | Roleplay | 3 | Would you both like to choose characters for a one-hour at-home date with a clear time to step out of character? | Same as primary text. | The pair co-designs a bounded, playful roleplay date. |
|
||||
| S19 | Roleplay | 3 | Would you both be up for writing each other an invitation from a fictional admirer? | Same as primary text. | Each partner receives a flirtatious, invented invitation. |
|
||||
| S20 | Roleplay | 4 | Would you both be open to trying a roleplay where one person leads the scene and you switch roles halfway through? | Same as primary text. | Both partners experience leading and following within an agreed fictional scene. |
|
||||
| S21 | Settings and atmosphere | 2 | Would you both enjoy turning off the usual lights and creating a new at-home date setting together? | Same as primary text. | The pair jointly changes the mood of a familiar room. |
|
||||
| S22 | Settings and atmosphere | 3 | Would you both like to plan an evening with one room, one playlist, and no other plans? | Same as primary text. | The couple reserves an intentionally simple, focused evening. |
|
||||
| S23 | Settings and atmosphere | 3 | Would you both be up for creating a shared 'do not disturb' signal for private time at home? | Same as primary text. | The couple agrees on a respectful cue that protects their uninterrupted time. |
|
||||
| S24 | Settings and atmosphere | 4 | Would you both be open to a private evening where you begin by agreeing what would make each of you feel cared for? | Same as primary text. | The couple starts a close evening with mutual needs and limits stated aloud. |
|
||||
| S25 | Surprise and novelty | 2 | Would you both enjoy swapping one sealed note with a date-night idea inside? | Same as primary text. | Each partner contributes one surprise idea for future time together. |
|
||||
| S26 | Surprise and novelty | 3 | Would you both like to choose a new ritual for saying goodbye at the end of a date? | Same as primary text. | The pair establishes a novel end-of-date connection ritual. |
|
||||
| S27 | Surprise and novelty | 4 | Would you both be open to choosing one surprise element for a future evening, with a clear option to change your mind? | Same as primary text. | The couple chooses a bounded surprise while preserving ongoing consent. |
|
||||
| S28 | Long-distance desire | 2 | Would you both enjoy sending each other one voice note that makes the next reunion feel closer? | Same as primary text. | Both partners exchange anticipation-building audio messages. |
|
||||
| S29 | Long-distance desire | 3 | Would you both like to plan the first hour of your next reunion from different places? | Same as primary text. | The couple co-plans a specific reconnecting ritual for their reunion. |
|
||||
| S30 | Long-distance desire | 4 | Would you both be open to a private call where you share one hope and one boundary for your next time alone together? | Same as primary text. | The couple aligns on future closeness through reciprocal desire and boundary sharing. |
|
||||
|
||||
## Count check
|
||||
|
||||
- Asymmetric entries: A01 through A60, 60 total.
|
||||
- Symmetric entries: S01 through S30, 30 total.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
# Play-safe spicy question draft v1
|
||||
|
||||
Status: source-only, unpublished, not approved, imported, or published. This is an editorial draft for human policy review.
|
||||
|
||||
## Packs
|
||||
|
||||
- Flirty Communication: 55 questions.
|
||||
- Anticipation: 38 questions.
|
||||
- Taking the Lead: 35 questions.
|
||||
- Following: 26 questions.
|
||||
- Shared Control: 24 questions.
|
||||
- Sensory Cues: 41 questions.
|
||||
- Private Performance: 34 questions.
|
||||
- Clothes and Confidence: 40 questions.
|
||||
- Fantasy Exchange: 31 questions.
|
||||
- Roleplay: 29 questions.
|
||||
- Settings and Atmosphere: 40 questions.
|
||||
- Boundaries and Check-ins: 35 questions.
|
||||
- Surprise and Novelty: 33 questions.
|
||||
- Long-distance Desire: 23 questions.
|
||||
- Giving and Receiving Attention: 37 questions.
|
||||
- Aftercare and Reconnection: 29 questions.
|
||||
|
||||
## Exact distribution
|
||||
|
||||
- 550 total: 300 swipe, 100 text_answer, 70 audio, 40 photo, 40 who_likely.
|
||||
- Intensities: 120 at level 2, 300 at level 3, 130 at level 4.
|
||||
- Swipe: 120 asymmetric concepts as 120 primaries plus 120 inverse rows, and 60 symmetric rows.
|
||||
|
||||
## Current in-app outcomes
|
||||
|
||||
- Swipe proposals create a mutual yes or maybe result. Asymmetric rows use reversed partner wording and inverse links.
|
||||
- Text answers let each partner provide the final private detail in their own words.
|
||||
- Audio prompts create a private 60-second spoken result.
|
||||
- Photo prompts request only safe clothed outfits, objects, notes, lighting, and settings.
|
||||
- Who-is-more-likely prompts create a light comparison and a natural private follow-up.
|
||||
|
||||
## Policy boundaries
|
||||
|
||||
Developer-authored wording avoids named sexual acts, anatomy, sex toys, fetish labels, nudity, public sexual activity, coercion, and instructional wording. Intensity 4 uses edge-reviewed language about adult private dynamics, boundaries, roles, surprise, and user-led detail. This is not a claim of Google approval.
|
||||
|
||||
Known limitation: the current API accepts response_data loosely. This source draft assumes no structured compatibility builder or safe user-generated-content moderation envelope, and uses only existing supported question types.
|
||||
|
||||
Run node scripts/validate-question-draft.mjs to verify this source-only contract,
|
||||
including artificial discriminator and repeated-opening checks.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Play-safe spicy text-answer concept source
|
||||
|
||||
This source preserves 100 authored `text_answer` concepts for review. It is a draft content source only and is not a database seed or deployment input.
|
||||
|
||||
## Entries
|
||||
|
||||
1. **Theme:** Flirty communication | **Intensity:** 2
|
||||
**Prompt:** Write the compliment you most want your partner to give you before a close moment.
|
||||
**Intended outcome:** Your partner learns the exact reassurance that makes you feel wanted and can use it as a sincere opening.
|
||||
|
||||
2. **Theme:** Flirty communication | **Intensity:** 2
|
||||
**Prompt:** Finish this sentence for your partner: “When you look at me like that, I want you to know...”
|
||||
**Intended outcome:** Your partner receives a personal signal about the effect their attention has on you.
|
||||
|
||||
3. **Theme:** Flirty communication | **Intensity:** 3
|
||||
**Prompt:** Write a one-line invitation that would make you smile if your partner sent it unexpectedly.
|
||||
**Intended outcome:** Your partner gets a ready-to-use private message and learns the tone that draws you in.
|
||||
|
||||
4. **Theme:** Flirty communication | **Intensity:** 3
|
||||
**Prompt:** Describe the kind of teasing remark that feels playful to you rather than awkward.
|
||||
**Intended outcome:** Your partner learns your preferred style of flirtation and a boundary around what keeps it fun.
|
||||
|
||||
5. **Theme:** Flirty communication | **Intensity:** 3
|
||||
**Prompt:** Tell your partner one thing they do that immediately changes the mood between you.
|
||||
**Intended outcome:** Your partner discovers a specific behaviour worth repeating when you both want more closeness.
|
||||
|
||||
6. **Theme:** Flirty communication | **Intensity:** 4
|
||||
**Prompt:** Write the boldest thing you would like your partner to say to you privately, in your own words.
|
||||
**Intended outcome:** Your partner receives direct permission to use language that feels exciting to you.
|
||||
|
||||
7. **Theme:** Flirty communication | **Intensity:** 4
|
||||
**Prompt:** Describe a private conversation you would love to have when you both feel especially open.
|
||||
**Intended outcome:** Your partner learns the subject you are curious to explore and can choose a comfortable time to start it.
|
||||
|
||||
8. **Theme:** Anticipation | **Intensity:** 2
|
||||
**Prompt:** Describe the ideal first hint that a special evening together is coming.
|
||||
**Intended outcome:** Your partner learns how to build anticipation in a way that feels welcome rather than vague.
|
||||
|
||||
9. **Theme:** Anticipation | **Intensity:** 2
|
||||
**Prompt:** Write the kind of message that would make you look forward to seeing your partner later.
|
||||
**Intended outcome:** Your partner gets a concrete cue they can send before you reconnect.
|
||||
|
||||
10. **Theme:** Anticipation | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what preparation would make you feel more relaxed before a private date at home.
|
||||
**Intended outcome:** Your partner learns a practical condition that helps you arrive present and receptive.
|
||||
|
||||
11. **Theme:** Anticipation | **Intensity:** 3
|
||||
**Prompt:** Describe a small change to your usual evening routine that would make it feel more intentional.
|
||||
**Intended outcome:** Your partner receives one realistic ritual to create a sense of occasion together.
|
||||
|
||||
12. **Theme:** Anticipation | **Intensity:** 3
|
||||
**Prompt:** Write what you would enjoy being asked to choose before your next intimate evening.
|
||||
**Intended outcome:** Your partner learns where offering you a choice creates more excitement.
|
||||
|
||||
13. **Theme:** Anticipation | **Intensity:** 4
|
||||
**Prompt:** Share one private surprise you would genuinely enjoy your partner planning for you.
|
||||
**Intended outcome:** Your partner receives a clear idea for an unexpected gesture, including the kind of surprise you welcome.
|
||||
|
||||
14. **Theme:** Anticipation | **Intensity:** 4
|
||||
**Prompt:** Write a short countdown message you would like to receive before a night focused on the two of you.
|
||||
**Intended outcome:** Your partner gets language that builds suspense without guessing what would land well.
|
||||
|
||||
15. **Theme:** Taking the lead | **Intensity:** 2
|
||||
**Prompt:** Describe one part of a date where you would enjoy deciding what happens next.
|
||||
**Intended outcome:** Your partner learns a low-pressure moment where your initiative would feel attractive.
|
||||
|
||||
16. **Theme:** Taking the lead | **Intensity:** 2
|
||||
**Prompt:** Write an invitation you would feel confident making when you want more one-to-one time.
|
||||
**Intended outcome:** Your partner receives a phrase that makes your interest unmistakable and easy to answer.
|
||||
|
||||
17. **Theme:** Taking the lead | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what kind of plan you would enjoy taking charge of from start to finish.
|
||||
**Intended outcome:** Your partner learns which shared experience you want to own and can leave space for you to lead it.
|
||||
|
||||
18. **Theme:** Taking the lead | **Intensity:** 3
|
||||
**Prompt:** Describe how you would like your partner to respond when you make a bold suggestion.
|
||||
**Intended outcome:** Your partner gets a practical guide for encouraging your confidence in the moment.
|
||||
|
||||
19. **Theme:** Taking the lead | **Intensity:** 3
|
||||
**Prompt:** Write one decision you would like to make for both of you during a private evening.
|
||||
**Intended outcome:** Your partner learns the kind of authority you want to try, with a concrete decision to hand over.
|
||||
|
||||
20. **Theme:** Taking the lead | **Intensity:** 4
|
||||
**Prompt:** Describe a confident version of yourself you would like your partner to see more often.
|
||||
**Intended outcome:** Your partner learns what confidence looks like for you and how they can invite it out.
|
||||
|
||||
21. **Theme:** Taking the lead | **Intensity:** 4
|
||||
**Prompt:** Write the private challenge you would like to set for your partner, including what would make it exciting.
|
||||
**Intended outcome:** Your partner receives a consensual challenge with the reason it appeals to you.
|
||||
|
||||
22. **Theme:** Following | **Intensity:** 2
|
||||
**Prompt:** Tell your partner one decision you would enjoy not having to make on a date.
|
||||
**Intended outcome:** Your partner learns a simple place where taking initiative would feel caring.
|
||||
|
||||
23. **Theme:** Following | **Intensity:** 2
|
||||
**Prompt:** Describe the kind of invitation that makes it easy for you to say yes.
|
||||
**Intended outcome:** Your partner learns the wording and atmosphere that lower pressure for you.
|
||||
|
||||
24. **Theme:** Following | **Intensity:** 3
|
||||
**Prompt:** Write what helps you trust your partner’s lead during a more intimate moment.
|
||||
**Intended outcome:** Your partner learns the specific reassurance or check-in that helps you relax into their plan.
|
||||
|
||||
25. **Theme:** Following | **Intensity:** 3
|
||||
**Prompt:** Share one surprise choice you would enjoy your partner making for you.
|
||||
**Intended outcome:** Your partner gets a defined area where surprise feels exciting and safe.
|
||||
|
||||
26. **Theme:** Following | **Intensity:** 3
|
||||
**Prompt:** Describe how you want your partner to check that you are still enjoying their lead.
|
||||
**Intended outcome:** Your partner receives a clear check-in preference they can use without breaking the mood.
|
||||
|
||||
27. **Theme:** Following | **Intensity:** 4
|
||||
**Prompt:** Write about a private situation where you would like to be guided step by step, in your own terms.
|
||||
**Intended outcome:** Your partner learns the type of guidance you are curious about and the limits you want respected.
|
||||
|
||||
28. **Theme:** Following | **Intensity:** 4
|
||||
**Prompt:** Tell your partner what would make it feel exciting to let them choose the pace for a while.
|
||||
**Intended outcome:** Your partner learns the conditions that make you comfortable giving them more influence.
|
||||
|
||||
29. **Theme:** Shared control | **Intensity:** 2
|
||||
**Prompt:** Name one decision you want to make together before your next close evening.
|
||||
**Intended outcome:** Your partner learns where collaboration matters most to you and can plan it jointly.
|
||||
|
||||
30. **Theme:** Shared control | **Intensity:** 2
|
||||
**Prompt:** Describe your ideal signal for “let’s keep going” during a private moment.
|
||||
**Intended outcome:** Your partner receives a simple shared cue that makes mutual enthusiasm easier to read.
|
||||
|
||||
31. **Theme:** Shared control | **Intensity:** 3
|
||||
**Prompt:** Write the rule you would like both of you to follow when trying something unfamiliar together.
|
||||
**Intended outcome:** Your partner learns a mutual agreement that makes exploration feel safer.
|
||||
|
||||
32. **Theme:** Shared control | **Intensity:** 3
|
||||
**Prompt:** Tell your partner how you would like to alternate who chooses the next part of a date.
|
||||
**Intended outcome:** Your partner gets a workable rhythm for sharing initiative rather than defaulting to one person.
|
||||
|
||||
33. **Theme:** Shared control | **Intensity:** 3
|
||||
**Prompt:** Describe one moment where you would enjoy pausing together to decide what you both want.
|
||||
**Intended outcome:** Your partner learns when a joint decision would add connection instead of interrupting it.
|
||||
|
||||
34. **Theme:** Shared control | **Intensity:** 4
|
||||
**Prompt:** Write the private agreement that would make you feel brave enough to explore more together.
|
||||
**Intended outcome:** Your partner receives a concrete mutual promise that supports bolder, consent-led choices.
|
||||
|
||||
35. **Theme:** Shared control | **Intensity:** 4
|
||||
**Prompt:** Share the question you want your partner to ask before either of you raises the intensity.
|
||||
**Intended outcome:** Your partner learns the exact invitation to use before moving into more charged territory.
|
||||
|
||||
36. **Theme:** Boundaries and check-ins | **Intensity:** 2
|
||||
**Prompt:** Write one phrase that means “I like this, but slower” for you.
|
||||
**Intended outcome:** Your partner receives a clear signal for adjusting pace without uncertainty.
|
||||
|
||||
37. **Theme:** Boundaries and check-ins | **Intensity:** 2
|
||||
**Prompt:** Describe the best way for your partner to ask whether you are comfortable.
|
||||
**Intended outcome:** Your partner learns a check-in style that feels caring rather than clinical.
|
||||
|
||||
38. **Theme:** Boundaries and check-ins | **Intensity:** 3
|
||||
**Prompt:** Tell your partner one thing you need before you can enjoy a more adventurous private plan.
|
||||
**Intended outcome:** Your partner learns a condition that turns curiosity into a safer shared possibility.
|
||||
|
||||
39. **Theme:** Boundaries and check-ins | **Intensity:** 3
|
||||
**Prompt:** Write a kind way you would want your partner to respond if you change your mind.
|
||||
**Intended outcome:** Your partner gets direct guidance for making a change of plan feel respected and easy.
|
||||
|
||||
40. **Theme:** Boundaries and check-ins | **Intensity:** 3
|
||||
**Prompt:** Describe one topic you would prefer to discuss before, rather than during, a close moment.
|
||||
**Intended outcome:** Your partner learns what deserves a calm conversation in advance.
|
||||
|
||||
41. **Theme:** Boundaries and check-ins | **Intensity:** 4
|
||||
**Prompt:** Share a boundary that makes you feel more able to be adventurous within it.
|
||||
**Intended outcome:** Your partner learns the limit that creates freedom rather than reducing the mood.
|
||||
|
||||
42. **Theme:** Boundaries and check-ins | **Intensity:** 4
|
||||
**Prompt:** Write the reassurance you need when you suggest something that feels vulnerable to say aloud.
|
||||
**Intended outcome:** Your partner receives a practical response that helps you bring up bolder ideas honestly.
|
||||
|
||||
43. **Theme:** Aftercare and reconnection | **Intensity:** 2
|
||||
**Prompt:** Describe how you like to reconnect after an especially close moment.
|
||||
**Intended outcome:** Your partner learns the small act that helps you feel settled and connected afterward.
|
||||
|
||||
44. **Theme:** Aftercare and reconnection | **Intensity:** 2
|
||||
**Prompt:** Write the first question you would like your partner to ask after trying something new together.
|
||||
**Intended outcome:** Your partner receives a ready-made invitation for a thoughtful debrief.
|
||||
|
||||
45. **Theme:** Aftercare and reconnection | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what makes you feel appreciated after you have been emotionally open.
|
||||
**Intended outcome:** Your partner learns how to recognise your vulnerability in a way that lands.
|
||||
|
||||
46. **Theme:** Aftercare and reconnection | **Intensity:** 3
|
||||
**Prompt:** Describe the kind of quiet time you would enjoy sharing once the excitement has passed.
|
||||
**Intended outcome:** Your partner gets a concrete picture of the calm connection you want afterward.
|
||||
|
||||
47. **Theme:** Aftercare and reconnection | **Intensity:** 3
|
||||
**Prompt:** Write one thing you would like to hear after you take a private risk together.
|
||||
**Intended outcome:** Your partner learns the affirmation that helps you feel proud rather than self-conscious.
|
||||
|
||||
48. **Theme:** Aftercare and reconnection | **Intensity:** 4
|
||||
**Prompt:** Share the part of a close experience you would most want to talk through afterward.
|
||||
**Intended outcome:** Your partner learns what reflection would deepen trust and make a future experience better.
|
||||
|
||||
49. **Theme:** Giving and receiving attention | **Intensity:** 2
|
||||
**Prompt:** Describe one way your partner can give you their full attention that you rarely ask for.
|
||||
**Intended outcome:** Your partner discovers a specific act of focus that makes you feel prioritised.
|
||||
|
||||
50. **Theme:** Giving and receiving attention | **Intensity:** 2
|
||||
**Prompt:** Write about a moment when you feel most noticed by your partner.
|
||||
**Intended outcome:** Your partner learns the context in which their attention has the greatest effect.
|
||||
|
||||
51. **Theme:** Giving and receiving attention | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what you enjoy doing when you want to make them feel desired.
|
||||
**Intended outcome:** Your partner learns how you naturally show interest and can recognise the signal.
|
||||
|
||||
52. **Theme:** Giving and receiving attention | **Intensity:** 3
|
||||
**Prompt:** Describe the kind of praise you most enjoy receiving privately.
|
||||
**Intended outcome:** Your partner gets a personal guide to language that makes you feel seen.
|
||||
|
||||
53. **Theme:** Giving and receiving attention | **Intensity:** 3
|
||||
**Prompt:** Write one request you would enjoy hearing from your partner when they want your attention.
|
||||
**Intended outcome:** Your partner receives a low-pressure way to ask directly for more of you.
|
||||
|
||||
54. **Theme:** Giving and receiving attention | **Intensity:** 4
|
||||
**Prompt:** Share the private gesture that would make you feel unmistakably chosen by your partner.
|
||||
**Intended outcome:** Your partner learns a specific, high-impact way to make you feel desired.
|
||||
|
||||
55. **Theme:** Sensory cues | **Intensity:** 2
|
||||
**Prompt:** Describe a scent that instantly makes an evening feel more intimate to you.
|
||||
**Intended outcome:** Your partner learns an atmosphere cue they can use when planning a close night in.
|
||||
|
||||
56. **Theme:** Sensory cues | **Intensity:** 2
|
||||
**Prompt:** Write about the music that would help you leave everyday stress behind together.
|
||||
**Intended outcome:** Your partner gets a mood-setting choice that supports a more connected evening.
|
||||
|
||||
57. **Theme:** Sensory cues | **Intensity:** 3
|
||||
**Prompt:** Tell your partner which kind of lighting makes you feel most confident and relaxed.
|
||||
**Intended outcome:** Your partner learns a practical way to shape the room around your comfort.
|
||||
|
||||
58. **Theme:** Sensory cues | **Intensity:** 3
|
||||
**Prompt:** Describe a texture, fabric, or cosy detail that makes a private setting feel special.
|
||||
**Intended outcome:** Your partner receives an actionable detail for creating a setting that appeals to you.
|
||||
|
||||
59. **Theme:** Sensory cues | **Intensity:** 3
|
||||
**Prompt:** Write about the sound of your partner’s voice that you find most captivating.
|
||||
**Intended outcome:** Your partner learns a vocal quality that makes you feel drawn closer.
|
||||
|
||||
60. **Theme:** Sensory cues | **Intensity:** 4
|
||||
**Prompt:** Share the full sensory mood you would like your partner to create for an intimate evening.
|
||||
**Intended outcome:** Your partner receives a personal brief for a more immersive, intentional atmosphere.
|
||||
|
||||
61. **Theme:** Private performance | **Intensity:** 2
|
||||
**Prompt:** Write about a playful thing you would enjoy doing just to make your partner smile in private.
|
||||
**Intended outcome:** Your partner learns a personal expression of your confidence they can encourage.
|
||||
|
||||
62. **Theme:** Private performance | **Intensity:** 2
|
||||
**Prompt:** Describe a song that would make you feel expressive if you had the room to yourselves.
|
||||
**Intended outcome:** Your partner gets a soundtrack that invites a more playful side of you out.
|
||||
|
||||
63. **Theme:** Private performance | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what kind of attention helps you feel confident being watched by them.
|
||||
**Intended outcome:** Your partner learns how to make their attention feel encouraging rather than exposing.
|
||||
|
||||
64. **Theme:** Private performance | **Intensity:** 3
|
||||
**Prompt:** Write a private dare you would enjoy accepting from your partner.
|
||||
**Intended outcome:** Your partner receives a consent-led challenge that fits your idea of playful boldness.
|
||||
|
||||
65. **Theme:** Private performance | **Intensity:** 3
|
||||
**Prompt:** Describe the reaction from your partner that would make you want to be more expressive.
|
||||
**Intended outcome:** Your partner learns the feedback that builds your confidence in the moment.
|
||||
|
||||
66. **Theme:** Private performance | **Intensity:** 4
|
||||
**Prompt:** Share a private moment where you would enjoy being the centre of your partner’s attention.
|
||||
**Intended outcome:** Your partner learns the kind of focused attention you are curious to receive and can plan it with you.
|
||||
|
||||
67. **Theme:** Clothes and confidence | **Intensity:** 2
|
||||
**Prompt:** Describe an outfit that makes you feel more like yourself on a date with your partner.
|
||||
**Intended outcome:** Your partner learns the look that supports your confidence and can compliment it specifically.
|
||||
|
||||
68. **Theme:** Clothes and confidence | **Intensity:** 2
|
||||
**Prompt:** Write the colour you would love your partner to notice you wearing.
|
||||
**Intended outcome:** Your partner gets a small visual detail to notice and celebrate.
|
||||
|
||||
69. **Theme:** Clothes and confidence | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what you would enjoy choosing for them to wear on a private date night.
|
||||
**Intended outcome:** Your partner learns your taste and has a clear invitation to let you style part of the evening.
|
||||
|
||||
70. **Theme:** Clothes and confidence | **Intensity:** 3
|
||||
**Prompt:** Describe the compliment about your appearance that would feel most meaningful from your partner.
|
||||
**Intended outcome:** Your partner receives guidance for praise that strengthens your confidence.
|
||||
|
||||
71. **Theme:** Clothes and confidence | **Intensity:** 3
|
||||
**Prompt:** Write about one detail of getting ready that makes you feel more alluring.
|
||||
**Intended outcome:** Your partner learns the ritual that helps you shift into a more confident mood.
|
||||
|
||||
72. **Theme:** Clothes and confidence | **Intensity:** 4
|
||||
**Prompt:** Share the private look you would be excited to reveal to your partner, in your own words.
|
||||
**Intended outcome:** Your partner learns what kind of reveal feels exciting to you and can respond with the right attention.
|
||||
|
||||
73. **Theme:** Fantasy exchange | **Intensity:** 2
|
||||
**Prompt:** Write about a fictional romance scene whose mood you would love to borrow for a date.
|
||||
**Intended outcome:** Your partner learns the emotional tone you are drawn to without needing to guess the reference.
|
||||
|
||||
74. **Theme:** Fantasy exchange | **Intensity:** 2
|
||||
**Prompt:** Describe the kind of imagined setting that makes you feel more adventurous with your partner.
|
||||
**Intended outcome:** Your partner receives a setting idea that can inspire a themed evening.
|
||||
|
||||
75. **Theme:** Fantasy exchange | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what role you would enjoy imagining for yourself during a playful private scenario.
|
||||
**Intended outcome:** Your partner learns the character energy you are curious to try.
|
||||
|
||||
76. **Theme:** Fantasy exchange | **Intensity:** 3
|
||||
**Prompt:** Write a fictional opening line your partner could use to begin a shared fantasy.
|
||||
**Intended outcome:** Your partner gets a tailored starting line for playful imagination together.
|
||||
|
||||
77. **Theme:** Fantasy exchange | **Intensity:** 3
|
||||
**Prompt:** Describe the one detail that would make an imagined scenario feel believable and exciting to you.
|
||||
**Intended outcome:** Your partner learns the ingredient that turns a vague idea into something engaging.
|
||||
|
||||
78. **Theme:** Fantasy exchange | **Intensity:** 4
|
||||
**Prompt:** Share a private fantasy premise you would like to explore only in conversation first.
|
||||
**Intended outcome:** Your partner learns a higher-intensity idea while the first follow-up remains a safe discussion.
|
||||
|
||||
79. **Theme:** Roleplay | **Intensity:** 2
|
||||
**Prompt:** Write the nickname you would enjoy your partner using during a playful private game.
|
||||
**Intended outcome:** Your partner receives language that helps set a playful tone you welcome.
|
||||
|
||||
80. **Theme:** Roleplay | **Intensity:** 2
|
||||
**Prompt:** Describe a simple character trait you would enjoy your partner pretending to have for one evening.
|
||||
**Intended outcome:** Your partner gets a low-stakes roleplay cue that can make an ordinary night feel new.
|
||||
|
||||
81. **Theme:** Roleplay | **Intensity:** 3
|
||||
**Prompt:** Tell your partner the kind of made-up first meeting you would enjoy acting out together.
|
||||
**Intended outcome:** Your partner learns a shared scenario that gives both of you a clear starting point.
|
||||
|
||||
82. **Theme:** Roleplay | **Intensity:** 3
|
||||
**Prompt:** Write the setting where you would enjoy pretending you have just met your partner.
|
||||
**Intended outcome:** Your partner receives a scene to build around for a playful reset of familiar chemistry.
|
||||
|
||||
83. **Theme:** Roleplay | **Intensity:** 3
|
||||
**Prompt:** Describe the one rule that would help roleplay feel comfortable and fun for you.
|
||||
**Intended outcome:** Your partner learns the agreement that keeps the game connected to your real trust.
|
||||
|
||||
84. **Theme:** Roleplay | **Intensity:** 4
|
||||
**Prompt:** Share a bolder character dynamic you are curious to discuss with your partner before trying it.
|
||||
**Intended outcome:** Your partner learns what dynamic interests you and can talk through comfort, limits, and interest first.
|
||||
|
||||
85. **Theme:** Settings and atmosphere | **Intensity:** 2
|
||||
**Prompt:** Describe the room where you would most like to have an uninterrupted evening with your partner.
|
||||
**Intended outcome:** Your partner learns which environment helps you feel most present together.
|
||||
|
||||
86. **Theme:** Settings and atmosphere | **Intensity:** 2
|
||||
**Prompt:** Write the first thing you would change at home to make a regular night feel more special.
|
||||
**Intended outcome:** Your partner gets a realistic way to shift an ordinary evening into a date.
|
||||
|
||||
87. **Theme:** Settings and atmosphere | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what makes a hotel-style evening feel exciting to you.
|
||||
**Intended outcome:** Your partner learns the elements of novelty or escape you would like recreated.
|
||||
|
||||
88. **Theme:** Settings and atmosphere | **Intensity:** 3
|
||||
**Prompt:** Describe the kind of privacy you need before you can fully switch off together.
|
||||
**Intended outcome:** Your partner learns the practical conditions needed for you to feel unhurried.
|
||||
|
||||
89. **Theme:** Settings and atmosphere | **Intensity:** 3
|
||||
**Prompt:** Write about a place from a past date that you would love to recreate the feeling of at home.
|
||||
**Intended outcome:** Your partner receives an emotional reference point and ideas for bringing it back.
|
||||
|
||||
90. **Theme:** Settings and atmosphere | **Intensity:** 4
|
||||
**Prompt:** Share the most adventurous private setting you would enjoy planning together, while staying within your comfort zone.
|
||||
**Intended outcome:** Your partner learns your appetite for novelty and the limits that make the plan feel right.
|
||||
|
||||
91. **Theme:** Surprise and novelty | **Intensity:** 2
|
||||
**Prompt:** Write one small unexpected gesture that would brighten a normal day with your partner.
|
||||
**Intended outcome:** Your partner gets a specific surprise that signals private affection.
|
||||
|
||||
92. **Theme:** Surprise and novelty | **Intensity:** 2
|
||||
**Prompt:** Describe the kind of spontaneous invitation you would be happy to receive.
|
||||
**Intended outcome:** Your partner learns how to be impulsive in a way that still feels welcome.
|
||||
|
||||
93. **Theme:** Surprise and novelty | **Intensity:** 3
|
||||
**Prompt:** Tell your partner one familiar routine you would enjoy changing up together.
|
||||
**Intended outcome:** Your partner learns where novelty would improve your shared rhythm.
|
||||
|
||||
94. **Theme:** Surprise and novelty | **Intensity:** 3
|
||||
**Prompt:** Write a sealed-note prompt you would enjoy your partner giving you to open later.
|
||||
**Intended outcome:** Your partner receives a private prompt format that can create suspense across the day.
|
||||
|
||||
95. **Theme:** Surprise and novelty | **Intensity:** 3
|
||||
**Prompt:** Describe a new rule for one evening that would make it feel different from your usual time together.
|
||||
**Intended outcome:** Your partner gets a simple novelty experiment that you have already signalled interest in.
|
||||
|
||||
96. **Theme:** Surprise and novelty | **Intensity:** 4
|
||||
**Prompt:** Share the bold surprise you would enjoy discovering after your partner has checked the broad limits with you.
|
||||
**Intended outcome:** Your partner learns both the surprise you want and the consent conversation needed before planning it.
|
||||
|
||||
97. **Theme:** Long-distance desire | **Intensity:** 2
|
||||
**Prompt:** Write the message that would make you feel closest to your partner when you are apart.
|
||||
**Intended outcome:** Your partner learns the kind of connection that travels well between visits.
|
||||
|
||||
98. **Theme:** Long-distance desire | **Intensity:** 2
|
||||
**Prompt:** Describe a tiny ritual you would like to share on the evenings you cannot be together.
|
||||
**Intended outcome:** Your partner receives a repeatable way to maintain intimacy across distance.
|
||||
|
||||
99. **Theme:** Long-distance desire | **Intensity:** 3
|
||||
**Prompt:** Tell your partner what you would like to plan together before your next time in the same place.
|
||||
**Intended outcome:** Your partner learns which shared anticipation would make a reunion more meaningful.
|
||||
|
||||
100. **Theme:** Long-distance desire | **Intensity:** 4
|
||||
**Prompt:** Write the private promise you would like to exchange before a longer period apart.
|
||||
**Intended outcome:** Your partner receives a personal commitment that can sustain desire, reassurance, and connection until you reunite.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Sex and fun relationship market strategy
|
||||
|
||||
**Research date:** 2026-09-05
|
||||
**Decision:** Position Sauci as a private mutual-discovery product for adult
|
||||
couples, then make a mutual match an optional real-world action.
|
||||
|
||||
## Scope and evidence limits
|
||||
|
||||
This is product-direction research, not a legal opinion, store-submission
|
||||
assessment, competitor content scrape, or paid-subscription teardown. It uses
|
||||
public product pages and store metadata, a public filing, official platform
|
||||
policies, and a small number of public Reddit discussions. Reddit is anecdotal,
|
||||
may include self-promotion, and is useful for hypotheses rather than market
|
||||
sizing or claims about a gated catalogue.
|
||||
|
||||
Desire publicly advertises a paid "Chili" level and hotter challenges. This
|
||||
research did not verify the prompts in that tier, so a claim that it contains
|
||||
policy-breaking material is unsupported.
|
||||
|
||||
The repository's universal catalogue safety document is a plan, not proof that
|
||||
all current question recommendation and pack APIs enforce `content_status`.
|
||||
Current dare catalogue and send paths do enforce reviewed content. Universal
|
||||
question enforcement remains future work described by the
|
||||
[catalogue safety plan](catalog-content-safety-plan.md).
|
||||
|
||||
## Market map
|
||||
|
||||
| Segment and representative | Publicly visible proposition | Strategic read | Sauci opportunity |
|
||||
| --- | --- | --- | --- |
|
||||
| Relationship wellness: Paired, Agape, Couply | Daily questions, guided connection, habits, and quizzes. | Can feel like maintenance or therapy. | Keep emotional safety, but make the output a shared possibility. |
|
||||
| Progressive couples game: Desire | Points, dares, categories, paid access, and a "Chili" level. | The paid progression model is visible. The risk of uneven participation is a hypothesis drawn from anecdotal reports. | Test mutual interest first; retain editable, optional follow-on action. |
|
||||
| Explicit single-device sex games | Position, dice, foreplay, truth-or-dare, and challenge catalogues on one device. | Direct novelty but generic couple context and a higher policy burden. | Own two-person discovery and communication, not a graphic instruction catalogue. |
|
||||
| Former Official | Public reports describe private mutual selection of date and intimacy ideas. Bumble later closed it within portfolio changes. | Mutual discovery is a product hypothesis worth testing; closure does not establish that the mechanic failed. | Test private matching as the core promise, then provide a next action. |
|
||||
| Sauci | Couple-isolated questions, reciprocal matches, chat, audio/photo responses, and dares exist. | The primitives exist but need a clearer journey and adult-content guardrails. | "Find something you both genuinely want to try, without either person having to risk a direct no." |
|
||||
|
||||
### External sources by evidence type
|
||||
|
||||
- **Developer marketing claims:** [Paired](https://www.paired.com/),
|
||||
[Agape](https://www.getdailyagape.com/), [Couply](https://www.couply.app/),
|
||||
and [Desire](https://www.desire.games/) describe their own products. They do
|
||||
not establish engagement or efficacy.
|
||||
- **Store metadata:** [Desire on Google Play](https://play.google.com/store/apps/details?id=com.desireapps.desire)
|
||||
and [Desire on the App Store](https://apps.apple.com/us/app/desire-couples-game/id923073855)
|
||||
describe published availability, rating, and paid-tier language.
|
||||
- **Public filing:** [Bumble's 2024 annual report](https://s202.q4cdn.com/372973788/files/doc_financials/2024/q4/BMBL-10K-Q4-2024.pdf)
|
||||
records Official's closure in the context of portfolio changes.
|
||||
- **Anecdotal Reddit evidence:** [Desire and Spicer discussion](https://www.reddit.com/r/DeadBedrooms/comments/1q7gb6h/desire_couples_game_app_or_spicer_app_seeking/)
|
||||
and [couples-app discussion](https://www.reddit.com/r/apps/comments/1qao3tr/any_good_couples_apps_youd_recommend/)
|
||||
contain first-hand opinions, but are small and unrepresentative.
|
||||
|
||||
### Store metadata snapshot
|
||||
|
||||
The following Google Play figures were captured during this research on
|
||||
2026-09-05. They are store metadata, not independently audited market share,
|
||||
and can change at any time. No pricing is inferred.
|
||||
|
||||
| App | Google Play installs | Rating | Reviews | Use in this strategy |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Desire | 1M+ | 4.4 | 20.9K | Progressive couples game reference. |
|
||||
| Paired | 1M+ | 4.6 | 63.3K | Relationship-wellness reference. |
|
||||
| Agape | 500K+ | 4.1 | 9.55K | Relationship-wellness reference. |
|
||||
| Couply | 100K+ | 4.4 | 1.95K | Relationship-wellness reference. |
|
||||
| Lovify | 1M+ | 4.7 | 21.7K | Adjacent couples-app reference. |
|
||||
|
||||
Store pages: [Desire](https://play.google.com/store/apps/details?id=com.desireapps.desire),
|
||||
[Paired](https://play.google.com/store/apps/details?id=com.getpaired.app),
|
||||
[Agape](https://play.google.com/store/apps/details?id=com.getdailyagape),
|
||||
[Couply](https://play.google.com/store/apps/details?id=io.couply.android), and
|
||||
[Lovify](https://play.google.com/store/apps/details?id=com.mindsets.lovify). Explicit
|
||||
Apple-only game entries remain qualitative because comparable Play install
|
||||
metadata is unavailable.
|
||||
|
||||
## Target customer
|
||||
|
||||
The primary customer is an adult couple with a viable relationship who wants
|
||||
more novelty, flirtation, or intimacy, but finds direct initiation of an
|
||||
unfamiliar idea awkward. They need privacy around an unmatched interest, control
|
||||
over intensity, and a route from curiosity to a mutually chosen action.
|
||||
|
||||
Initial exclusions are couples seeking clinical repair or coercion intervention,
|
||||
a solitary-arousal or explicit-instruction catalogue, a product that assigns
|
||||
sexual tasks from one partner to the other, and all minor or adult/minor use.
|
||||
This is scope control, not a judgment about those users.
|
||||
|
||||
## Current technical foundation
|
||||
|
||||
| Existing foundation | Exact repository evidence | Product use |
|
||||
| --- | --- | --- |
|
||||
| Swipe, text answer, audio, photo, and who-likely questions | [Question interaction contract](../question-types.md), [shared type](../../packages/shared/src/types/index.ts) | Private preference selection, written invitations, voice-led prompts, and playful questions. |
|
||||
| Reciprocal match semantics and response summaries | [Question interaction contract](../question-types.md), [match calculation](../../apps/api/src/domains/answers/types.ts) | Reveal only a mutual interest or both-partner response. |
|
||||
| Sender-to-recipient dares with decline, cancel, expiry, and audio/photo proof | [Dares loop](../dares-loop.md), [dare repository](../../apps/api/src/domains/dares/repository.ts) | Existing optional challenge flow, not a shared-action model. |
|
||||
| Couple-shared premium entitlement | [Dares entitlement](../dares-loop.md), [subscription system](../subscription-system.md) | Offer a shared upgrade without paywalling a received invite. |
|
||||
| Dare push payloads and reviewed-dare gating | [Dares safety rules](../dares-loop.md) | Keep dare text out of notification previews and unreviewed dares out of clients. |
|
||||
| JavaScript and asset OTA delivery | [Mobile OTA policy](../mobile-ota.md) | Ship compatible client UI, copy, logic, and assets without a new binary. |
|
||||
|
||||
The [catalogue safety plan](catalog-content-safety-plan.md) defines the intended
|
||||
developer-authored content ceiling: no sexual acts, instructions, fetish content,
|
||||
or material intended for sexual gratification. It states that age confirmation,
|
||||
intensity, premium access, and hidden categories must not override that ceiling.
|
||||
It is future universal question/pack enforcement work. Do not describe it as
|
||||
currently enforced for question recommendation or pack APIs.
|
||||
|
||||
## Product positioning and architecture
|
||||
|
||||
> Sauci helps couples turn unspoken curiosity into things they both want to try.
|
||||
|
||||
Lead with mutuality, privacy, and choice, not content count, coins, or a promise
|
||||
of explicit material. Organise approved content by desired outcome: flirt and
|
||||
anticipate; touch and sensation; take the lead; give up control; tell me
|
||||
privately; try something new; fantasy without assumption; setting and
|
||||
atmosphere; playful roles; long-distance desire; boundaries and comfort;
|
||||
aftercare and reconnection; create your own; and matches for later.
|
||||
|
||||
Developer-authored wording should remain non-graphic and choice-based.
|
||||
Couple-authored detail is UGC, not a policy workaround. The proposed product
|
||||
rule is that new content remains unreviewed and ineligible until a recorded
|
||||
editorial decision allows it; universal question/pack enforcement is not yet
|
||||
current behaviour.
|
||||
|
||||
```text
|
||||
Choose a mood and shared comfort ceiling
|
||||
-> privately answer a short set
|
||||
-> reveal only a mutual interest or both-partner response
|
||||
-> talk, save, or create an optional dare
|
||||
-> complete, defer, decline, or withdraw without penalty
|
||||
```
|
||||
|
||||
Do not disclose which partner chose the lower comfort ceiling. This is a desired
|
||||
future product rule, not an existing session contract. Today the system has a
|
||||
persistent `max_intensity` preference and global selection gates; it does not
|
||||
have a per-session shared comfort record or a mutual private-ceiling protocol.
|
||||
Likewise, a distinct defer, softer alternative, or withdrawal state needs a
|
||||
domain contract rather than a client label alone.
|
||||
|
||||
## One-week delivery plan
|
||||
|
||||
**Current OTA blocker:** the maintained [OTA policy](../mobile-ota.md) says a
|
||||
new store bootstrap build is required before updates can flow. Consequently none
|
||||
of these changes can currently reach users by OTA. After that bootstrap build,
|
||||
client-only prototyping may be OTA-compatible when it stays within the installed
|
||||
native surface. The policy still requires a new store build for native code,
|
||||
dependencies, native configuration, or generated iOS/Android changes.
|
||||
|
||||
Server-side catalogue/configuration activity is separate operational work, not
|
||||
an OTA bundle.
|
||||
|
||||
| Priority | Change | OTA UI/client work | Server-side data/config | Boundary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Quick Spark prototype | Mood entry, 5-8 card session, completion, and mutuality explainer. | Curate only currently eligible question inventory after its enforcement status is verified. | Client prototype may be OTA-compatible after bootstrap; proper reviewed-question enforcement is future API work. |
|
||||
| 2 | Match-to-shared-action design | Add a non-committing Talk or Save affordance. | Design a match-linked shared-action domain model. | True shared action is API/domain work. Current dares assign a sender and recipient. |
|
||||
| 3 | Shared comfort design | Prototype explanatory UI only. | Persist a per-session private selection and calculate a shared ceiling server-side. | Backend/data work. Current `max_intensity` and global gates are not a session protocol. |
|
||||
| 4 | Tell Me Privately prototype | Curate text/audio presentation with both-partner reveal copy. | Verify question eligibility and review any activated prompts. | Client work may be OTA-compatible after bootstrap; no claim of new enforcement. |
|
||||
| 5 | Defer and withdrawal design | Improve wording only where existing decline/cancel actions remain truthful. | Add distinct defer, softer alternative, and withdrawal states with no-penalty semantics. | Backend/data work, not an OTA-only change. |
|
||||
|
||||
Do not call a change OTA-deliverable until the bootstrap build has shipped and
|
||||
the final diff stays inside the installed native surface. Catalogue activation
|
||||
needs its own review, staged rollout, and rollback plan.
|
||||
|
||||
## Store-policy and UGC boundary
|
||||
|
||||
Google Play prohibits content intended to be sexually gratifying and identifies
|
||||
explicit sexual text and sex guides among common violation types. Apple
|
||||
prohibits overtly sexual or pornographic material. Use the stricter Google
|
||||
ceiling for one cross-platform developer-authored catalogue.
|
||||
|
||||
- [Google Play sexual content policy](https://support.google.com/googleplay/android-developer/answer/9878810)
|
||||
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Google Play UGC policy](https://support.google.com/googleplay/android-developer/answer/9876937)
|
||||
|
||||
Subscription, an 18+ label, intensity setting, content flag, or private screen
|
||||
is not a policy exception. Paid functionality must be compliant and reviewable.
|
||||
|
||||
Text, audio, photo, custom dare, and chat create UGC exposure. Before expanding
|
||||
adult-oriented user-authored interactions, verify effective reporting and
|
||||
blocking, terms and enforcement, moderation operations, rate limits, safe push
|
||||
previews, and an incident route for non-consensual or illegal content. The
|
||||
catalogue safety plan requires these controls for user-authored fields; their
|
||||
actual implementation status must be checked before marketing relies on them.
|
||||
|
||||
## Gaps and measurement
|
||||
|
||||
Before a broader adult-content launch: complete the universal question/pack
|
||||
`content_status` enforcement specified by the catalogue plan; verify or
|
||||
implement age assurance and accurate store ratings; add private session comfort
|
||||
and durable interest/maybe/boundary states; make withdrawal, deferral, and
|
||||
softer alternatives explicit in a shared-action contract; verify moderation for
|
||||
every UGC surface; and establish reviewed editorial audit for every
|
||||
developer-authored prompt, pack, and dare.
|
||||
|
||||
Measure aggregate, privacy-preserving events only. Do not collect prompt text,
|
||||
response text, audio, photo content, or intimate match detail in analytics.
|
||||
|
||||
| Funnel stage | Measure | Decision |
|
||||
| --- | --- | --- |
|
||||
| Entry | Starts by mood and shared ceiling | Which framing gets couples into the loop. |
|
||||
| Participation | Both-partner completion and time to second response | Whether the mode gets reciprocal participation. |
|
||||
| Mutuality | Match rate by reviewed content group and intensity | Whether selection is calibrated. |
|
||||
| Action | Match-to-talk, save, dare-draft, send, and completion rates | Whether discovery creates a wanted next step. |
|
||||
| Safety | Skip, defer, decline, cancel, report, and block rates without content payloads | Whether a category creates pressure or risk. |
|
||||
| Retention | Another short session within 7 and 28 days | Whether the loop survives novelty. |
|
||||
|
||||
Do not optimise only for sends or completions. High decline, report, or
|
||||
withdrawal is a safety signal, not a conversion problem to suppress.
|
||||
|
||||
## Decision gates
|
||||
|
||||
1. Approve the positioning and mutual-match-to-optional-action hypothesis.
|
||||
2. Ship and verify the required OTA bootstrap store build before treating any
|
||||
client change as OTA deployable.
|
||||
3. Split shared action, session comfort, and durable consent states into API and
|
||||
data work rather than representing them as client-only changes.
|
||||
4. Audit age assurance and UGC safeguards before marketing adult-oriented
|
||||
custom media or prompts.
|
||||
5. Complete universal question/pack review enforcement before activating new
|
||||
adult-oriented developer-authored catalogue content.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Question interaction types
|
||||
|
||||
This is the maintained contract for the question interactions delivered through
|
||||
the daily question flow. It is separate from the [couples quiz](quiz.md) and
|
||||
[dares loop](dares-loop.md), which have their own content and completion
|
||||
models.
|
||||
|
||||
<!-- question-types: swipe,text_answer,audio,photo,who_likely -->
|
||||
|
||||
## Supported types
|
||||
|
||||
| Type | User interaction | `response_data` and `config` | Match semantics |
|
||||
| --- | --- | --- | --- |
|
||||
| `swipe` | Choose yes or no, or skip. | No `response_data`; `config` is unused. | A no creates no match. The API also retains `maybe` matching for stored or replayed answers, producing `yes_maybe` or `maybe_maybe`; the current mobile card does not offer maybe. |
|
||||
| `text_answer` | Write an answer and send it, decline with no, or skip. | `response_data`: `{ type: 'text_answer', text }`; `config` is unused. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. |
|
||||
| `audio` | Record and send an audio answer, or skip. | `response_data`: `{ type: 'audio', media_path, duration_seconds }`; optional `config.max_duration_seconds` limits recording and defaults to 60 seconds in the admin UI. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. |
|
||||
| `photo` | Take or select and send a photo answer, or skip. | `response_data`: `{ type: 'photo', media_path }`; `config` is unused. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. |
|
||||
| `who_likely` | Choose which partner is more likely. The current mobile flow submits this as yes. | `response_data`: `{ type: 'who_likely', chosen_user_id }`; `config` is unused. | Once both partners respond, the match type is `both_answered`; the stored answer value does not gate that type's match calculation. Both choices are in `response_summary`. |
|
||||
|
||||
## Persistence and implementation ownership
|
||||
|
||||
`questions.question_type` defaults to `swipe`; the database check constraint and
|
||||
the canonical shared `QuestionType` union must contain the same values.
|
||||
`questions.config` is JSON configuration for a type. `responses.response_data`
|
||||
stores an individual non-swipe payload. `matches.response_summary` is a JSON
|
||||
object keyed by responder user ID and is populated for `both_answered` matches.
|
||||
|
||||
The canonical list is `QuestionType` in
|
||||
[`packages/shared/src/types/index.ts`](../packages/shared/src/types/index.ts).
|
||||
The database constraint is in
|
||||
[`apps/api/drizzle/0003_answers_matches.sql`](../apps/api/drizzle/0003_answers_matches.sql).
|
||||
Match calculation is in
|
||||
[`apps/api/src/domains/answers/types.ts`](../apps/api/src/domains/answers/types.ts),
|
||||
the mobile renderer is
|
||||
[`apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx`](../apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx),
|
||||
and admin authoring options are in
|
||||
[`apps/admin/src/pages/content/QuestionsPage.tsx`](../apps/admin/src/pages/content/QuestionsPage.tsx).
|
||||
|
||||
## Change rule
|
||||
|
||||
When adding, removing, or changing a question type, its payload, configuration,
|
||||
or matching semantics, update this document in the same change. Update the
|
||||
shared type, database constraint, API, mobile, and admin consumers as required.
|
||||
`npm run lint:harness` checks the documented type list against the canonical
|
||||
shared union so an unreviewed list change fails routine verification.
|
||||
@@ -249,6 +249,23 @@ and dependency installation failures occur before native compilation: fix and
|
||||
preflight those inputs without starting another build. Make one meaningful build
|
||||
per newly proven input state.
|
||||
|
||||
Before starting: confirm `eas` is installed and on PATH (scripts call bare
|
||||
`eas`); explicitly export `ANDROID_HOME`, `ANDROID_SDK_ROOT`, and `JAVA_HOME`
|
||||
(JDK 17) rather than relying on a shell profile, because non-interactive shells
|
||||
used for automation do not load it ("SDK location not found" otherwise); ensure
|
||||
`EAS_LOCAL_BUILD_WORKINGDIR` does not exist or is empty ("Workingdir is not
|
||||
empty" otherwise); and run every command from `apps/mobile`. Capture the full
|
||||
build log to a file; do not pipe it through `tail`, which drops the detail
|
||||
needed to diagnose a failure.
|
||||
|
||||
After a successful iOS build, EAS auto-increment updates both app and widget
|
||||
Info.plists but not the widget target's `CURRENT_PROJECT_VERSION` in the
|
||||
pbxproj, so `release:preflight` fails until it is synced to the new build
|
||||
number (for example `sed -i '' 's/CURRENT_PROJECT_VERSION = 46;/CURRENT_PROJECT_VERSION = 47;/' ios/Sauci.xcodeproj/project.pbxproj`
|
||||
for both Debug and Release). If native dependencies changed, also sync
|
||||
`ios/Podfile.lock` from the successful build's working directory: the archive's
|
||||
`pod install` resolution is authoritative when local CocoaPods is broken.
|
||||
|
||||
Report provider progress by phase: packaging, native compilation, submission
|
||||
queue, or store processing. Do not describe every failure as a rebuild.
|
||||
|
||||
@@ -292,6 +309,24 @@ verify it, and commit it before starting the release from a clean worktree. Use
|
||||
5.x, a non-clean prebuild against an already-generated iOS project fails while
|
||||
updating the widget's Xcode target.
|
||||
|
||||
**After native regeneration:** `expo prebuild --clean` can silently regress
|
||||
checked-in native invariants (confirmed during the SDK 54 to 56 upgrade). Before
|
||||
committing a regenerated project:
|
||||
|
||||
- Diff pbxproj signing and identity keys (PRODUCT_BUNDLE_IDENTIFIER,
|
||||
CODE_SIGN_ENTITLEMENTS) against the last release tag; apple-targets can derive
|
||||
a widget's bundle identifier from its folder name instead of the pinned config.
|
||||
- Semantic-diff Info.plist and entitlements files against the last release tag;
|
||||
prebuild can reintroduce dev-launcher-only keys (NSBonjourServices,
|
||||
NSLocalNetworkUsageDescription, RCTMetroPort) and drop widget entitlements.
|
||||
- Check android/gradle.properties for a reset `org.gradle.jvmargs` (prebuild
|
||||
resets it to the stock -Xmx2048m/512m, which OOMs release Kotlin/CMake tasks).
|
||||
- Run `npm run release:preflight`; it enforces the Android manifest media
|
||||
permission removal and rejects the dev-launcher-only Info.plist keys.
|
||||
- Treat `expo-doctor` unmaintained-package warnings as release blockers on an
|
||||
SDK upgrade: a package absent from the new SDK's bundledNativeModules can fail
|
||||
the iOS archive with no compatible release available.
|
||||
|
||||
**Out of memory:**
|
||||
```bash
|
||||
# Increase Gradle memory
|
||||
|
||||
+20
-5
@@ -47,6 +47,8 @@ erDiagram
|
||||
uuid pack_id FK
|
||||
text text
|
||||
text partner_text "for two-part questions"
|
||||
text question_type "swipe/text_answer/audio/photo/who_likely"
|
||||
jsonb config "type-specific configuration"
|
||||
int intensity "1-5"
|
||||
text[] allowed_couple_genders "couple composition filter"
|
||||
text[] target_user_genders "individual user filter"
|
||||
@@ -70,6 +72,7 @@ erDiagram
|
||||
uuid question_id FK
|
||||
uuid couple_id FK "nullable: null is a sealed solo answer, claimed at pairing"
|
||||
answer_type answer "yes/no/maybe"
|
||||
jsonb response_data "non-swipe answer payload"
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
@@ -77,7 +80,8 @@ erDiagram
|
||||
uuid id PK
|
||||
uuid couple_id FK
|
||||
uuid question_id FK
|
||||
match_type match_type "yes_yes/yes_maybe/maybe_maybe"
|
||||
match_type match_type "yes_yes/yes_maybe/maybe_maybe/both_answered"
|
||||
jsonb response_summary "both non-swipe payloads by user"
|
||||
boolean is_new
|
||||
timestamptz created_at
|
||||
}
|
||||
@@ -161,7 +165,8 @@ erDiagram
|
||||
| Enum | Values |
|
||||
|------|--------|
|
||||
| `answer_type` | `yes`, `no`, `maybe` |
|
||||
| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe` |
|
||||
| `question_type` | `swipe`, `text_answer`, `audio`, `photo`, `who_likely` |
|
||||
| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe`, `both_answered` |
|
||||
| `feedback_type` | `bug`, `feature_request`, `general` |
|
||||
| `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` |
|
||||
| `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` |
|
||||
@@ -188,11 +193,21 @@ allowed.
|
||||
|
||||
## Key Flows
|
||||
|
||||
### Question interactions
|
||||
|
||||
`questions.question_type` defaults to `swipe`; `questions.config` holds
|
||||
type-specific settings. A non-swipe response keeps its payload in
|
||||
`responses.response_data`. When a non-swipe interaction resolves as
|
||||
`both_answered`, `matches.response_summary` holds both payloads keyed by user
|
||||
ID. The complete interaction and match contract is maintained in
|
||||
[Question interaction types](question-types.md).
|
||||
|
||||
### Response & Match Flow
|
||||
1. User submits response via `submit-response` edge function
|
||||
1. User submits a response to the standalone API at `POST /v1/responses`
|
||||
2. Response saved to `responses` table (UPSERT on `user_id, question_id`)
|
||||
3. Function checks for partner's response on same question
|
||||
4. If both answered positively (not "no"), a `match` is created
|
||||
3. API checks for the partner's response on the same question
|
||||
4. API applies the [question interaction contract](question-types.md) to create,
|
||||
update, or remove a `match`
|
||||
5. Match triggers push notification to both partners
|
||||
6. Match unlocks chat thread in `messages` table
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { access, readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const root = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const errors = [];
|
||||
const scoped = process.argv.includes('--scope');
|
||||
|
||||
@@ -156,6 +157,22 @@ if (!scoped) {
|
||||
if (!(await exists(target))) fail(path.join(root, 'docs', 'index.md'), `broken documentation link ${match[1]}`, 'correct the link or restore the maintained document');
|
||||
}
|
||||
|
||||
const sharedTypesFile = path.join(root, 'packages', 'shared', 'src', 'types', 'index.ts');
|
||||
const sharedTypes = await readFile(sharedTypesFile, 'utf8');
|
||||
const union = sharedTypes.match(/export type QuestionType\s*=\s*([^;]+);/);
|
||||
const canonicalQuestionTypes = union?.[1].match(/'([^']+)'/g)?.map((value) => value.slice(1, -1));
|
||||
const questionTypesDoc = path.join(root, 'docs', 'question-types.md');
|
||||
const questionTypesSource = await readFile(questionTypesDoc, 'utf8');
|
||||
const documentedQuestionTypes = questionTypesSource.match(/<!-- question-types:\s*([^>]+)\s*-->/)?.[1]
|
||||
.split(',').map((value) => value.trim()).filter(Boolean);
|
||||
if (!canonicalQuestionTypes?.length) {
|
||||
fail(sharedTypesFile, 'QuestionType union could not be read', 'keep QuestionType as a quoted string union');
|
||||
} else if (!documentedQuestionTypes?.length) {
|
||||
fail(questionTypesDoc, 'question type contract marker is missing', 'add <!-- question-types: type-a,type-b --> with every supported type');
|
||||
} else if (canonicalQuestionTypes.join(',') !== documentedQuestionTypes.join(',')) {
|
||||
fail(questionTypesDoc, 'documented question types differ from the shared QuestionType union', `set the contract marker to ${canonicalQuestionTypes.join(',')}`);
|
||||
}
|
||||
|
||||
const mcpAuth = await readFile(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'utf8');
|
||||
if (/allowing all requests|query\(['"]apiKey/i.test(mcpAuth)) {
|
||||
fail(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'MCP authentication can leak or fail open', 'require a configured Bearer token and fail closed');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const root = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const base = path.join(root, '.agents', 'learnings');
|
||||
const requiredSections = ['## Symptom', '## Reproduction', '## Cause', '## Remediation', '## Evidence'];
|
||||
const failures = [];
|
||||
@@ -11,7 +12,7 @@ for (const state of ['inbox', 'promoted']) {
|
||||
for (const name of await readdir(directory)) {
|
||||
if (!name.endsWith('.md')) continue;
|
||||
const file = path.join(directory, name);
|
||||
const source = await readFile(file, 'utf8');
|
||||
const source = (await readFile(file, 'utf8')).replace(/\r\n/g, '\n');
|
||||
const frontmatter = source.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatter) { failures.push(`${file}: missing YAML frontmatter`); continue; }
|
||||
const fields = Object.fromEntries(frontmatter[1].split('\n').map((line) => {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const draftPath = new URL('../docs/product/question-drafts/play-safe-spicy-v1.json', import.meta.url);
|
||||
const snapshotPath = new URL('../apps/supabase/catalog-snapshots/production-2026-08-28.json', import.meta.url);
|
||||
const markdownPath = new URL('../docs/product/question-drafts/play-safe-spicy-v1.md', import.meta.url);
|
||||
const textSourcePath = new URL('../docs/product/question-drafts/text-answer-concept-source.md', import.meta.url);
|
||||
const audioSourcePath = new URL('../docs/product/question-drafts/audio-concept-source.md', import.meta.url);
|
||||
const draft = JSON.parse(await readFile(draftPath, 'utf8'));
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||
const markdown = await readFile(markdownPath, 'utf8');
|
||||
const textSource = await readFile(textSourcePath, 'utf8');
|
||||
const audioSource = await readFile(audioSourcePath, 'utf8');
|
||||
const errors = [];
|
||||
const expect = (condition, message) => { if (!condition) errors.push(message); };
|
||||
const normalize = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
const questionTypes = new Set(['swipe', 'text_answer', 'audio', 'photo', 'who_likely']);
|
||||
const banned = /\b(?:anal|anus|asshole|bdsm|bestiality|blowjob|breath play|chok(?:e|ing)|cock|cum|dick|dildo|domination|fetish|fingering|handjob|incest|masturbat|naked|nud(?:e|ity)|oral sex|orgasm|penis|porn|public sex|sex toy|spank(?:ing)?|threesome|vagina|vibrator)\b/i;
|
||||
const timeBound = /\b(?:today|tonight|right now|\bnow\b)\b/i;
|
||||
const gendered = /\b(?:boyfriend|girlfriend|husband|wife|him|her|his|hers)\b/i;
|
||||
const photoUnsafe = /\b(?:body part|lingerie|nude|naked|proof|skin|undress|your body)\b/i;
|
||||
const artificialSuffix = /\b(?:option|response focus|voice focus|visual cue|variant|version)\s*\d+\b/i;
|
||||
const malformedOrFallback = /\b(?:let your partner to|as they for|share this private idea|take the complementary role|response focus|voice focus|bolder mood|confident outfit|unfamiliar private dynamic)\b/i;
|
||||
const genericOutcome = /\b(?:this match reveals whether|specific written starting point|take the complementary role|gains a personal cue for a close conversation)\b/i;
|
||||
const incompleteSwipe = /^(?:a |an |the |trying |swapping )/i;
|
||||
const nonActionableSwipe = /^(?:close|private|shared|more|something|anything|it|that)\b/i;
|
||||
const markdownPackNames = {
|
||||
'taking-the-lead': 'Taking the Lead',
|
||||
'clothes-confidence': 'Clothes and Confidence',
|
||||
'settings-atmosphere': 'Settings and Atmosphere',
|
||||
'boundaries-check-ins': 'Boundaries and Check-ins',
|
||||
'surprise-novelty': 'Surprise and Novelty',
|
||||
'long-distance-desire': 'Long-distance Desire',
|
||||
'giving-receiving-attention': 'Giving and Receiving Attention',
|
||||
'aftercare-reconnection': 'Aftercare and Reconnection',
|
||||
};
|
||||
const sourcePackSlugs = {
|
||||
'flirty communication': 'flirty-communication',
|
||||
anticipation: 'anticipation',
|
||||
'taking the lead': 'taking-the-lead',
|
||||
following: 'following',
|
||||
'shared control': 'shared-control',
|
||||
'sensory cues': 'sensory-cues',
|
||||
'private performance': 'private-performance',
|
||||
'clothes and confidence': 'clothes-confidence',
|
||||
'fantasy exchange': 'fantasy-exchange',
|
||||
roleplay: 'roleplay',
|
||||
'settings and atmosphere': 'settings-atmosphere',
|
||||
'boundaries and check-ins': 'boundaries-check-ins',
|
||||
'surprise and novelty': 'surprise-novelty',
|
||||
'long-distance desire': 'long-distance-desire',
|
||||
'giving and receiving attention': 'giving-receiving-attention',
|
||||
'aftercare and reconnection': 'aftercare-reconnection',
|
||||
};
|
||||
const parseSource = (source, pattern, type) => [...source.matchAll(pattern)].map((match) => ({
|
||||
pack_slug: sourcePackSlugs[match[1].toLowerCase()],
|
||||
intensity: Number(match[2]),
|
||||
text: match[3],
|
||||
intended_outcome: match[4],
|
||||
question_type: type,
|
||||
}));
|
||||
const textSourceRows = parseSource(textSource, /^\d+\. \*\*Theme:\*\* (.+?) \| \*\*Intensity:\*\* (\d)\s*\r?\n\s*\*\*Prompt:\*\* (.+?)\s*\r?\n\s*\*\*Intended outcome:\*\* (.+?)$/gm, 'text_answer');
|
||||
const audioSourceRows = parseSource(audioSource, /^\d+\. Theme: (.+?) \| Intensity: (\d)\s*\r?\n\s*Prompt: (.+?)\s*\r?\n\s*Intended outcome: (.+?)$/gm, 'audio');
|
||||
expect(textSourceRows.length === 100, `expected 100 text source rows, got ${textSourceRows.length}`);
|
||||
expect(audioSourceRows.length === 70, `expected 70 audio source rows, got ${audioSourceRows.length}`);
|
||||
const sourceKey = (row) => JSON.stringify([row.pack_slug, row.intensity, row.text, row.intended_outcome]);
|
||||
|
||||
expect(Array.isArray(draft.packs) && Array.isArray(draft.questions), 'draft must contain packs and questions arrays');
|
||||
const questions = draft.questions ?? [];
|
||||
expect(questions.length === 550, `expected 550 questions, got ${questions.length}`);
|
||||
expect((draft.packs ?? []).length > 0, 'draft must contain at least one pack');
|
||||
const ids = new Set();
|
||||
const byId = new Map();
|
||||
const typeCounts = Object.fromEntries([...questionTypes].map((type) => [type, 0]));
|
||||
const intensityCounts = Object.fromEntries([1, 2, 3, 4, 5].map((level) => [level, 0]));
|
||||
const packCounts = new Map();
|
||||
const draftTexts = new Map();
|
||||
const openingCounts = new Map();
|
||||
const ngramRows = new Map();
|
||||
const outcomeClusters = new Map();
|
||||
const responseLeadingVerbs = new Map();
|
||||
const requiredFormatOpenings = new Set(['share a photo of', 'who is more likely']);
|
||||
const snapshotTexts = new Set((snapshot.tables?.questions ?? []).map((row) => normalize(row.text)));
|
||||
for (const [index, question] of questions.entries()) {
|
||||
const label = `question ${index + 1}`;
|
||||
expect(typeof question.id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(question.id), `${label} has invalid UUID`);
|
||||
expect(!ids.has(question.id), `${label} duplicates UUID ${question.id}`);
|
||||
ids.add(question.id); byId.set(question.id, question);
|
||||
expect(typeof question.pack_slug === 'string' && question.pack_slug.length > 0, `${label} has no pack_slug`);
|
||||
packCounts.set(question.pack_slug, (packCounts.get(question.pack_slug) ?? 0) + 1);
|
||||
expect(typeof question.text === 'string' && question.text.length > 0, `${label} has no text`);
|
||||
expect(!artificialSuffix.test(question.text), `${label} contains an artificial template discriminator`);
|
||||
expect(!malformedOrFallback.test(question.text), `${label} contains malformed or generic fallback copy`);
|
||||
expect(questionTypes.has(question.question_type), `${label} has unsupported type ${question.question_type}`);
|
||||
if (questionTypes.has(question.question_type)) typeCounts[question.question_type] += 1;
|
||||
expect([2, 3, 4].includes(question.intensity), `${label} has invalid intensity ${question.intensity}`);
|
||||
if (Object.hasOwn(intensityCounts, question.intensity)) intensityCounts[question.intensity] += 1;
|
||||
expect(question.review_status === 'draft', `${label} must be draft`);
|
||||
expect(['low', 'edge'].includes(question.policy_risk), `${label} has invalid policy_risk`);
|
||||
expect(typeof question.intended_outcome === 'string' && question.intended_outcome.length > 0, `${label} has no intended_outcome`);
|
||||
expect(!genericOutcome.test(question.intended_outcome), `${label} has a generic fallback outcome`);
|
||||
expect(question.allowed_couple_genders === null && question.target_user_genders === null && question.required_props === null, `${label} must use null targeting and props`);
|
||||
const textFields = [question.text, question.partner_text, question.intended_outcome].filter(Boolean);
|
||||
for (const text of textFields) {
|
||||
expect(!banned.test(text), `${label} contains banned developer-authored term`);
|
||||
expect(!timeBound.test(text), `${label} contains time-bound language`);
|
||||
expect(!gendered.test(text), `${label} contains gendered partner language`);
|
||||
expect(!text.includes('\u2014'), `${label} contains an em dash`);
|
||||
}
|
||||
const normalized = normalize(question.text);
|
||||
if (question.question_type === 'swipe') {
|
||||
expect(!incompleteSwipe.test(question.text) && !nonActionableSwipe.test(question.text), `${label} swipe text must be a complete actionable proposal`);
|
||||
if (question.partner_text !== null) {
|
||||
expect(question.text !== question.partner_text, `${label} asymmetric swipe must use distinct partner wording`);
|
||||
expect(!incompleteSwipe.test(question.partner_text) && !nonActionableSwipe.test(question.partner_text), `${label} asymmetric partner_text must be a complete actionable proposal`);
|
||||
}
|
||||
}
|
||||
if (question.question_type === 'text_answer' || question.question_type === 'audio') {
|
||||
const leadingVerb = normalized.split(' ')[0];
|
||||
const key = `${question.question_type}:${leadingVerb}`;
|
||||
responseLeadingVerbs.set(key, (responseLeadingVerbs.get(key) ?? 0) + 1);
|
||||
const outcomeCluster = normalize(question.intended_outcome)
|
||||
.replace(/\b(?:your|partner|the|couple|one|a|an|and|or|can|to|with|for|of|that|this|their|them|you)\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const rows = outcomeClusters.get(outcomeCluster) ?? [];
|
||||
rows.push(label);
|
||||
outcomeClusters.set(outcomeCluster, rows);
|
||||
}
|
||||
const opening = normalized.split(' ').slice(0, 4).join(' ');
|
||||
if (!requiredFormatOpenings.has(opening)) {
|
||||
openingCounts.set(opening, (openingCounts.get(opening) ?? 0) + 1);
|
||||
}
|
||||
// An inverse row intentionally repeats its primary row's partner wording.
|
||||
// Check only primary and symmetric text here; inverse swap integrity is
|
||||
// checked below.
|
||||
if (question.inverse_of === null) {
|
||||
expect(!draftTexts.has(normalized), `${label} duplicates draft text from ${draftTexts.get(normalized)}`);
|
||||
draftTexts.set(normalized, label);
|
||||
const words = normalized.split(' ').filter(Boolean);
|
||||
for (let start = 0; start <= words.length - 6; start += 1) {
|
||||
const ngram = words.slice(start, start + 6).join(' ');
|
||||
const rows = ngramRows.get(ngram) ?? [];
|
||||
rows.push(label);
|
||||
ngramRows.set(ngram, rows);
|
||||
}
|
||||
}
|
||||
expect(!snapshotTexts.has(normalized), `${label} duplicates production snapshot text`);
|
||||
if (question.question_type === 'audio') expect(JSON.stringify(question.config) === JSON.stringify({ max_duration_seconds: 60 }), `${label} audio config must be max_duration_seconds 60`);
|
||||
if (question.question_type === 'text_answer') expect(JSON.stringify(question.config) === JSON.stringify({ max_length: 500 }), `${label} text_answer config must be max_length 500`);
|
||||
if (['swipe', 'photo', 'who_likely'].includes(question.question_type)) expect(JSON.stringify(question.config) === '{}', `${label} ${question.question_type} config must be empty`);
|
||||
if (question.question_type !== 'swipe') {
|
||||
expect(question.partner_text === null && question.inverse_of === null, `${label} non-swipe must have null partner_text and inverse_of`);
|
||||
}
|
||||
if (question.question_type === 'photo') expect(!photoUnsafe.test(question.text), `${label} photo prompt is not safely clothed/object/setting based`);
|
||||
}
|
||||
for (const [opening, count] of openingCounts) {
|
||||
expect(count <= 30, `opening template repeats ${count} times: ${opening}`);
|
||||
}
|
||||
for (const [ngram, rows] of ngramRows) {
|
||||
expect(rows.length <= 10, `six-word phrase repeats ${rows.length} times across non-inverse rows: ${ngram}`);
|
||||
}
|
||||
for (const [type, sourceRows] of [['text_answer', textSourceRows], ['audio', audioSourceRows]]) {
|
||||
const catalogueRows = questions.filter((question) => question.question_type === type);
|
||||
const sourceKeys = new Set(sourceRows.map(sourceKey));
|
||||
expect(sourceKeys.size === sourceRows.length, `${type} source contains duplicate concepts`);
|
||||
expect(catalogueRows.length === sourceRows.length, `${type} source and catalogue counts differ`);
|
||||
for (const question of catalogueRows) {
|
||||
expect(sourceKeys.has(sourceKey(question)), `${type} row ${question.id} differs from the authored source`);
|
||||
}
|
||||
}
|
||||
for (const [key, count] of responseLeadingVerbs) {
|
||||
const [type, leadingVerb] = key.split(':');
|
||||
const total = type === 'text_answer' ? 100 : 70;
|
||||
expect(count <= Math.ceil(total * 0.6), `${type} leading verb "${leadingVerb}" repeats ${count} times`);
|
||||
}
|
||||
for (const [cluster, rows] of outcomeClusters) {
|
||||
expect(cluster.length >= 16, `${rows.join(', ')} has an overly generic response outcome`);
|
||||
expect(rows.length === 1, `response outcome cluster repeats across ${rows.join(', ')}`);
|
||||
}
|
||||
for (const [type, count] of Object.entries({ swipe: 300, text_answer: 100, audio: 70, photo: 40, who_likely: 40 })) expect(typeCounts[type] === count, `expected ${count} ${type}, got ${typeCounts[type]}`);
|
||||
for (const [level, count] of Object.entries({ 1: 0, 2: 120, 3: 300, 4: 130, 5: 0 })) expect(intensityCounts[level] === count, `expected ${count} intensity ${level}, got ${intensityCounts[level]}`);
|
||||
const swipes = questions.filter((question) => question.question_type === 'swipe');
|
||||
const asymmetric = swipes.filter((question) => question.partner_text !== null);
|
||||
const symmetric = swipes.filter((question) => question.partner_text === null);
|
||||
expect(asymmetric.length === 240, `expected 240 asymmetric swipe rows, got ${asymmetric.length}`);
|
||||
expect(symmetric.length === 60, `expected 60 symmetric swipe rows, got ${symmetric.length}`);
|
||||
const primaries = asymmetric.filter((question) => question.inverse_of === null);
|
||||
const inverses = asymmetric.filter((question) => question.inverse_of !== null);
|
||||
expect(primaries.length === 120 && inverses.length === 120, `expected 120 primary and 120 inverse swipe rows, got ${primaries.length} and ${inverses.length}`);
|
||||
for (const question of inverses) {
|
||||
const primary = byId.get(question.inverse_of);
|
||||
expect(primary?.question_type === 'swipe' && primary.inverse_of === null, `inverse ${question.id} must reference a primary swipe`);
|
||||
if (primary) {
|
||||
expect(question.text === primary.partner_text && question.partner_text === primary.text, `inverse ${question.id} must exactly swap text and partner_text`);
|
||||
expect(question.intensity === primary.intensity && question.pack_slug === primary.pack_slug, `inverse ${question.id} must keep intensity and pack`);
|
||||
}
|
||||
}
|
||||
for (const question of symmetric) expect(question.inverse_of === null, `symmetric swipe ${question.id} must have null inverse_of`);
|
||||
expect([...packCounts.values()].every((count) => count > 0), 'every declared pack must contain questions');
|
||||
for (const pack of draft.packs ?? []) {
|
||||
const expectedLine = `- ${markdownPackNames[pack.slug] ?? pack.name}: ${packCounts.get(pack.slug) ?? 0} questions.`;
|
||||
expect(markdown.includes(expectedLine), `markdown pack count is missing or stale: ${expectedLine}`);
|
||||
}
|
||||
if (errors.length) {
|
||||
process.stderr.write(`Question draft validation failed (${errors.length}):\n${errors.map((error) => `- ${error}`).join('\n')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`Question draft valid: ${questions.length} total; types ${JSON.stringify(typeCounts)}; intensities ${JSON.stringify(intensityCounts)}; packs ${JSON.stringify(Object.fromEntries(packCounts))}\n`);
|
||||
Reference in new issue
Block a user