From 9a777a0d8126b5d345f8c9e9fb310f5cc64285fc Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Tue, 19 May 2026 15:42:05 +0000 Subject: [PATCH] feat(onboarding): add secret sudoku gate behind feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a hidden first onboarding step that presents Arto Inkala's hard sudoku puzzle. Gated by the posthog-code-secret-sudoku-onboarding flag (rolled out to 0% for now). Once enabled, the puzzle blocks every other onboarding step until solved — the dev skip button and arrow-key navigation are disabled while the gate is active, and the hasSolvedSecretSudoku flag is persisted so the gate redirects users back if they try to land on a later step. Generated-By: PostHog Code Task-Id: b21854e6-9af2-4f79-83f1-83e51e34b530 --- .../onboarding/components/OnboardingFlow.tsx | 42 ++- .../onboarding/components/SudokuStep.tsx | 290 ++++++++++++++++++ .../onboarding/hooks/useOnboardingFlow.ts | 23 +- .../onboarding/stores/onboardingStore.ts | 5 + .../renderer/features/onboarding/sudoku.ts | 109 +++++++ .../src/renderer/features/onboarding/types.ts | 2 + apps/code/src/shared/constants.ts | 2 + 7 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 apps/code/src/renderer/features/onboarding/components/SudokuStep.tsx create mode 100644 apps/code/src/renderer/features/onboarding/sudoku.ts diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 8c7fb2ca9..21ddcb390 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -17,6 +17,7 @@ import { InviteCodeStep } from "./InviteCodeStep"; import { ProjectSelectStep } from "./ProjectSelectStep"; import { SignalsStep } from "./SignalsStep"; import { StepIndicator } from "./StepIndicator"; +import { SudokuStep } from "./SudokuStep"; import { WelcomeScreen } from "./WelcomeScreen"; const stepVariants = { @@ -44,6 +45,9 @@ export function OnboardingFlow() { const hasCompletedSetup = useOnboardingStore( (state) => state.hasCompletedSetup, ); + const markSecretSudokuSolved = useOnboardingStore( + (state) => state.markSecretSudokuSolved, + ); const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding); const navigateToSetup = useNavigationStore((state) => state.navigateToSetup); const navigateToTaskInput = useNavigationStore( @@ -55,8 +59,20 @@ export function OnboardingFlow() { ); usePrefetchSignalData(); - useHotkeys("right", next, { enableOnFormTags: false }, [next]); - useHotkeys("left", back, { enableOnFormTags: false }, [back]); + const isSudokuGate = currentStep === "secret-sudoku"; + + useHotkeys( + "right", + next, + { enableOnFormTags: false, enabled: !isSudokuGate }, + [next, isSudokuGate], + ); + useHotkeys( + "left", + back, + { enableOnFormTags: false, enabled: !isSudokuGate }, + [back, isSudokuGate], + ); const handleComplete = () => { completeOnboarding(); @@ -88,7 +104,7 @@ export function OnboardingFlow() { Log out )} - {IS_DEV && ( + {IS_DEV && !isSudokuGate && ( + ); + }), + )} + + + + {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((digit) => ( + + ))} + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index 68956f06b..d8b0ba614 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -1,6 +1,8 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { trpcClient } from "@renderer/trpc/client"; +import { SECRET_SUDOKU_ONBOARDING_FLAG } from "@shared/constants"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; @@ -78,19 +80,32 @@ export function useOnboardingFlow() { ); const hasCodeAccess = useAuthStateValue((state) => state.hasCodeAccess); + const showSecretSudoku = useFeatureFlag(SECRET_SUDOKU_ONBOARDING_FLAG); + const hasSolvedSecretSudoku = useOnboardingStore( + (state) => state.hasSolvedSecretSudoku, + ); + const requiresSudokuGate = showSecretSudoku && !hasSolvedSecretSudoku; const activeSteps = useMemo(() => { + let steps = ONBOARDING_STEPS; + if (!requiresSudokuGate) { + steps = steps.filter((s) => s !== "secret-sudoku"); + } if (hasCodeAccess === true) { - return ONBOARDING_STEPS.filter((s) => s !== "invite-code"); + steps = steps.filter((s) => s !== "invite-code"); } - return ONBOARDING_STEPS; - }, [hasCodeAccess]); + return steps; + }, [hasCodeAccess, requiresSudokuGate]); useEffect(() => { + if (requiresSudokuGate && currentStep !== "secret-sudoku") { + setCurrentStep("secret-sudoku"); + return; + } if (!activeSteps.includes(currentStep)) { setCurrentStep(activeSteps[0]); } - }, [activeSteps, currentStep, setCurrentStep]); + }, [activeSteps, currentStep, requiresSudokuGate, setCurrentStep]); const currentIndex = activeSteps.indexOf(currentStep); const isFirstStep = currentIndex === 0; diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 34af17e24..8732e1df4 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -9,6 +9,7 @@ interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; hasCompletedSetup: boolean; + hasSolvedSecretSudoku: boolean; selectedProjectId: number | null; selectedDirectory: string; } @@ -17,6 +18,7 @@ interface OnboardingStoreActions { setCurrentStep: (step: OnboardingStep) => void; completeOnboarding: () => void; completeSetup: () => void; + markSecretSudokuSolved: () => void; resetOnboarding: () => void; resetSelections: () => void; selectProjectId: (projectId: number | null) => void; @@ -29,6 +31,7 @@ const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, hasCompletedSetup: false, + hasSolvedSecretSudoku: false, selectedProjectId: null, selectedDirectory: "", }; @@ -44,6 +47,7 @@ export const useOnboardingStore = create()( set({ hasCompletedOnboarding: true }); }, completeSetup: () => set({ hasCompletedSetup: true }), + markSecretSudokuSolved: () => set({ hasSolvedSecretSudoku: true }), resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ @@ -59,6 +63,7 @@ export const useOnboardingStore = create()( currentStep: state.currentStep, hasCompletedOnboarding: state.hasCompletedOnboarding, hasCompletedSetup: state.hasCompletedSetup, + hasSolvedSecretSudoku: state.hasSolvedSecretSudoku, selectedProjectId: state.selectedProjectId, selectedDirectory: state.selectedDirectory, }), diff --git a/apps/code/src/renderer/features/onboarding/sudoku.ts b/apps/code/src/renderer/features/onboarding/sudoku.ts new file mode 100644 index 000000000..2534289a4 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/sudoku.ts @@ -0,0 +1,109 @@ +export type SudokuCell = number | null; +export type SudokuBoard = SudokuCell[][]; + +// Arto Inkala's "hardest sudoku" (2012). 21 clues, unique solution. +const RAW_PUZZLE: ReadonlyArray> = [ + [8, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 3, 6, 0, 0, 0, 0, 0], + [0, 7, 0, 0, 9, 0, 2, 0, 0], + [0, 5, 0, 0, 0, 7, 0, 0, 0], + [0, 0, 0, 0, 4, 5, 7, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 3, 0], + [0, 0, 1, 0, 0, 0, 0, 6, 8], + [0, 0, 8, 5, 0, 0, 0, 1, 0], + [0, 9, 0, 0, 0, 0, 4, 0, 0], +]; + +export const PUZZLE: SudokuBoard = RAW_PUZZLE.map((row) => + row.map((cell) => (cell === 0 ? null : cell)), +); + +export function isGivenCell(row: number, col: number): boolean { + return RAW_PUZZLE[row][col] !== 0; +} + +export function cloneBoard(board: SudokuBoard): SudokuBoard { + return board.map((row) => row.slice()); +} + +function isUnitValid(values: SudokuCell[]): boolean { + const seen = new Set(); + for (const value of values) { + if (value == null) return false; + if (value < 1 || value > 9) return false; + if (seen.has(value)) return false; + seen.add(value); + } + return true; +} + +export function isBoardComplete(board: SudokuBoard): boolean { + return board.every((row) => row.every((cell) => cell != null)); +} + +export function isBoardSolved(board: SudokuBoard): boolean { + for (let r = 0; r < 9; r++) { + if (!isUnitValid(board[r])) return false; + } + for (let c = 0; c < 9; c++) { + const col = board.map((row) => row[c]); + if (!isUnitValid(col)) return false; + } + for (let br = 0; br < 3; br++) { + for (let bc = 0; bc < 3; bc++) { + const box: SudokuCell[] = []; + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + box.push(board[br * 3 + r][bc * 3 + c]); + } + } + if (!isUnitValid(box)) return false; + } + } + return true; +} + +export function findConflicts(board: SudokuBoard): boolean[][] { + const conflicts: boolean[][] = Array.from({ length: 9 }, () => + Array.from({ length: 9 }, () => false), + ); + + const markDuplicates = (cells: Array<{ r: number; c: number }>) => { + const map = new Map>(); + for (const { r, c } of cells) { + const value = board[r][c]; + if (value == null) continue; + const list = map.get(value); + if (list) { + list.push({ r, c }); + } else { + map.set(value, [{ r, c }]); + } + } + for (const list of map.values()) { + if (list.length > 1) { + for (const { r, c } of list) conflicts[r][c] = true; + } + } + }; + + for (let r = 0; r < 9; r++) { + markDuplicates(Array.from({ length: 9 }, (_, c) => ({ r, c }))); + } + for (let c = 0; c < 9; c++) { + markDuplicates(Array.from({ length: 9 }, (_, r) => ({ r, c }))); + } + for (let br = 0; br < 3; br++) { + for (let bc = 0; bc < 3; bc++) { + const cells: Array<{ r: number; c: number }> = []; + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + cells.push({ r: br * 3 + r, c: bc * 3 + c }); + } + } + markDuplicates(cells); + } + } + + return conflicts; +} diff --git a/apps/code/src/renderer/features/onboarding/types.ts b/apps/code/src/renderer/features/onboarding/types.ts index de3eef5b0..33c4dfc2a 100644 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ b/apps/code/src/renderer/features/onboarding/types.ts @@ -1,4 +1,5 @@ export type OnboardingStep = + | "secret-sudoku" | "welcome" | "project-select" | "invite-code" @@ -7,6 +8,7 @@ export type OnboardingStep = | "signals"; export const ONBOARDING_STEPS: OnboardingStep[] = [ + "secret-sudoku", "welcome", "project-select", "invite-code", diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index eaf28c98c..179fc62f5 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -1,6 +1,8 @@ export const BILLING_FLAG = "posthog-code-billing"; export const INBOX_GATED_DUE_TO_SCALE_FLAG = "inbox-gated-due-to-scale"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; +export const SECRET_SUDOKU_ONBOARDING_FLAG = + "posthog-code-secret-sudoku-onboarding"; export const BRANCH_PREFIX = "posthog-code/"; export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees";