diff --git a/convex/learningPlans.test.ts b/convex/learningPlans.test.ts index 07362417..cb7436b4 100644 --- a/convex/learningPlans.test.ts +++ b/convex/learningPlans.test.ts @@ -14,6 +14,13 @@ const user = { tokenIdentifier: "test:user", }; +const createKnowledgeQuestions = () => + Array.from({ length: 5 }, (_, index) => ({ + id: `q${index + 1}`, + prompt: `Frage ${index + 1}`, + targetInsight: `Erkenntnis ${index + 1}`, + })); + const createPlan = async (t: TestBackend) => { const examDayEntryId = await t.mutation(api.dayEntries.create, { dayKey: "2026-06-05", @@ -77,6 +84,188 @@ const createAcceptedPlanWithSession = async ( return { learningPlanId, session }; }; +test("list overview exposes unfinished creation progress and its first unanswered question", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + const questions = createKnowledgeQuestions(); + + await t.mutation(internal.learningPlans.storeKnowledgeQuestions, { + learningPlanId, + questions, + sourceSummary: "Testmaterial", + }); + await t.mutation(api.learningPlans.saveKnowledgeAnswer, { + learningPlanId, + questionId: "q1", + answer: "Antwort 1", + }); + await t.mutation(api.learningPlans.saveKnowledgeAnswer, { + learningPlanId, + questionId: "q3", + answer: "Antwort 3", + }); + + const overviews = await t.query(api.learningPlans.listOverview, {}); + + expect(overviews).toHaveLength(1); + expect(overviews[0]).toMatchObject({ + id: learningPlanId, + status: "questionsReady", + creationProgress: { + questionCount: 5, + answeredQuestionCount: 2, + firstUnansweredQuestionIndex: 1, + }, + }); +}); + +test("list overview reports when every creation question has been answered", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + const questions = createKnowledgeQuestions(); + + await t.mutation(internal.learningPlans.storeKnowledgeQuestions, { + learningPlanId, + questions, + sourceSummary: "Testmaterial", + }); + for (const question of questions) { + await t.mutation(api.learningPlans.saveKnowledgeAnswer, { + learningPlanId, + questionId: question.id, + answer: `Antwort ${question.id}`, + }); + } + + const overviews = await t.query(api.learningPlans.listOverview, {}); + + expect(overviews[0]).toMatchObject({ + id: learningPlanId, + creationProgress: { + questionCount: 5, + answeredQuestionCount: 5, + firstUnansweredQuestionIndex: null, + }, + }); +}); + +test("list overview finds current answers after stale question generations", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + const questions = createKnowledgeQuestions(); + + await t.mutation(internal.learningPlans.storeKnowledgeQuestions, { + learningPlanId, + questions, + sourceSummary: "Testmaterial", + }); + await t.run(async (ctx) => { + for (let index = 0; index < 21; index += 1) { + await ctx.db.insert("learningPlanAnswers", { + ownerTokenIdentifier: user.tokenIdentifier, + learningPlanId, + questionId: `stale-${index}`, + answer: `Alte Antwort ${index}`, + createdAt: index, + updatedAt: index, + }); + } + }); + await t.mutation(api.learningPlans.saveKnowledgeAnswer, { + learningPlanId, + questionId: "q1", + answer: "Aktuelle Antwort", + }); + + const overviews = await t.query(api.learningPlans.listOverview, {}); + + expect(overviews[0]).toMatchObject({ + creationProgress: { + answeredQuestionCount: 1, + firstUnansweredQuestionIndex: 1, + }, + }); +}); + +test("list overview keeps unfinished creation progress scoped to its owner", async () => { + const t = convexTest(schema, modules); + const mine = t.withIdentity(user); + const other = t.withIdentity({ tokenIdentifier: "test:other-user" }); + const myLearningPlanId = await createPlan(mine); + const otherLearningPlanId = await createPlan(other); + const questions = [ + { + id: "q1", + prompt: "Frage 1", + targetInsight: "Erkenntnis 1", + }, + ]; + + await mine.mutation(internal.learningPlans.storeKnowledgeQuestions, { + learningPlanId: myLearningPlanId, + questions, + sourceSummary: "Meine Unterlagen", + }); + await other.mutation(internal.learningPlans.storeKnowledgeQuestions, { + learningPlanId: otherLearningPlanId, + questions, + sourceSummary: "Andere Unterlagen", + }); + + const overviews = await mine.query(api.learningPlans.listOverview, {}); + + expect(overviews).toHaveLength(1); + expect(overviews[0]?.id).toBe(myLearningPlanId); + expect(overviews[0]?.id).not.toBe(otherLearningPlanId); +}); + +test("list overview keeps an unfinished creation visible alongside fifty accepted plans", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const creationPlanId = await createPlan(t); + + await t.mutation(internal.learningPlans.storeKnowledgeQuestions, { + learningPlanId: creationPlanId, + questions: [ + { + id: "q1", + prompt: "Frage 1", + targetInsight: "Erkenntnis 1", + }, + ], + sourceSummary: "Testmaterial", + }); + await t.run(async (ctx) => { + for (let index = 0; index < 50; index += 1) { + await ctx.db.insert("learningPlans", { + ownerTokenIdentifier: user.tokenIdentifier, + subject: `Fach ${index + 1}`, + examTypeLabel: "Klausur", + examDateKey: "2026-06-05", + examDateLabel: "5. Juni 2026", + examTime: "09:00", + durationMinutes: 90, + topicDescription: "Testthema", + status: "accepted", + createdAt: index, + updatedAt: index, + }); + } + }); + + const overviews = await t.query(api.learningPlans.listOverview, {}); + + expect(overviews).toHaveLength(51); + expect(overviews).toContainEqual( + expect.objectContaining({ + id: creationPlanId, + status: "questionsReady", + }), + ); + expect( + overviews.filter((overview) => overview.status === "accepted"), + ).toHaveLength(50); +}); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-30T10:00:00.000Z")); diff --git a/convex/learningPlans.ts b/convex/learningPlans.ts index 1e6c6ee1..43e8ada1 100644 --- a/convex/learningPlans.ts +++ b/convex/learningPlans.ts @@ -601,7 +601,7 @@ export const listOverview = query({ args: {}, handler: async (ctx) => { const ownerTokenIdentifier = await requireOwnerTokenIdentifier(ctx); - const plans = await ctx.db + const acceptedPlans = await ctx.db .query("learningPlans") .withIndex("by_ownerTokenIdentifier_and_status", (q) => q @@ -610,6 +610,18 @@ export const listOverview = query({ ) .order("desc") .take(50); + const creationPlans = await ctx.db + .query("learningPlans") + .withIndex("by_ownerTokenIdentifier_and_status", (q) => + q + .eq("ownerTokenIdentifier", ownerTokenIdentifier) + .eq("status", "questionsReady"), + ) + .order("desc") + .take(50); + const plans = [...acceptedPlans, ...creationPlans].sort( + (left, right) => right.updatedAt - left.updatedAt, + ); const overviews = []; for (const plan of plans) { @@ -630,6 +642,44 @@ export const listOverview = query({ sessions.length > 0 ? Math.round((completedCount / sessions.length) * 100) : 0; + let creationProgress: { + questionCount: number; + answeredQuestionCount: number; + firstUnansweredQuestionIndex: number | null; + } | null = null; + if (plan.status === "questionsReady") { + const questions = plan.knowledgeQuestions ?? []; + const answers = await Promise.all( + questions.map((question) => + ctx.db + .query("learningPlanAnswers") + .withIndex("by_learningPlanId_and_questionId", (q) => + q.eq("learningPlanId", plan._id).eq("questionId", question.id), + ) + .unique(), + ), + ); + const answeredQuestionIds = new Set( + answers + .filter( + (answer): answer is NonNullable => + answer !== null && answer.answer.trim().length > 0, + ) + .map((answer) => answer.questionId), + ); + const firstUnansweredQuestionIndex = questions.findIndex( + (question) => !answeredQuestionIds.has(question.id), + ); + + creationProgress = { + questionCount: questions.length, + answeredQuestionCount: answeredQuestionIds.size, + firstUnansweredQuestionIndex: + firstUnansweredQuestionIndex >= 0 + ? firstUnansweredQuestionIndex + : null, + }; + } overviews.push({ id: plan._id, @@ -653,6 +703,7 @@ export const listOverview = query({ completed: currentSession.completed === true, } : null, + creationProgress, updatedAt: plan.updatedAt, }); } diff --git a/docs/contexts/mobile-app/CONTEXT.md b/docs/contexts/mobile-app/CONTEXT.md index 38889bbb..cba067df 100644 --- a/docs/contexts/mobile-app/CONTEXT.md +++ b/docs/contexts/mobile-app/CONTEXT.md @@ -26,6 +26,9 @@ _Avoid_: Called by client, Server Error, raw stack trace - Capture app architecture, routing, UI, and native behavior decisions here. - Put mobile-app ADRs in `docs/contexts/mobile-app/adr/`. +- Keep `expo-constants` as a direct dependency while Expo Router requires it as + a native peer. The generic Expo-upgrade cleanup rule for implicit packages + does not apply when `expo-doctor` reports the package as required. - NativeWind is part of the build pipeline, not only runtime styling. Release build issues around Metro, Tailwind generation, or `expo-updates` should check `metro.config.js` and the NativeWind patch documentation before changing EAS diff --git a/docs/contexts/mobile-app/adr/0001-use-local-ios-system-appearance-bridge.md b/docs/contexts/mobile-app/adr/0001-use-local-ios-system-appearance-bridge.md index 2a5447d4..d5bb82f9 100644 --- a/docs/contexts/mobile-app/adr/0001-use-local-ios-system-appearance-bridge.md +++ b/docs/contexts/mobile-app/adr/0001-use-local-ios-system-appearance-bridge.md @@ -44,7 +44,8 @@ The module: changes; - exposes a synchronous `getColorScheme(): "light" | "dark"` method and an `onChange` event; -- refreshes the value when the app becomes active; +- retries observation when a window becomes key and refreshes the value when + the app becomes active; - enables React Native's key-window appearance mode and refreshes `RCTAppearance` during module creation and foreground activation; - is discovered through Expo Autolinking rather than hand-edited into the @@ -116,7 +117,9 @@ Costs and risks: notification behavior, which are not part of the documented JavaScript API contract and may change during upgrades. - Active-window lookup assumes Dayova's current single-window app model. -- Observer installation requires a window when the first listener attaches. +- Observer installation requires an active window; key-window and foreground + activation events retry an initial race and rehome the observer when the + window changed. - Initialization, trait changes, and foreground refreshes can emit duplicate state values, so consumers must remain idempotent. - The module's iOS 16.4 compatibility is compile-tested but cannot be launched diff --git a/docs/contexts/platform/CONTEXT.md b/docs/contexts/platform/CONTEXT.md index ed62ff5a..739ac160 100644 --- a/docs/contexts/platform/CONTEXT.md +++ b/docs/contexts/platform/CONTEXT.md @@ -16,6 +16,10 @@ _Avoid_: User-facing message, learner error text - `EXPO_PUBLIC_POSTHOG_API_KEY` enables PostHog analytics in the Expo app. Leave it empty to disable analytics locally. - `EXPO_PUBLIC_POSTHOG_HOST` defaults to `https://eu.i.posthog.com`; use a Dayova-owned reverse proxy only if event delivery or privacy requirements justify the extra infrastructure. - Analytics events must go through `src/lib/analytics.ts`. PostHog autocapture, lifecycle events, anonymous pre-auth tracking, and session replay are intentionally disabled for the validation phase. +- Validation event capture begins only after the Clerk user is available and + remains fail-open while the matching Convex user query is loading. Convex + activity marking and student-code enrichment are best effort; do not delay or + discard one-shot product interactions while waiting for that enrichment. - PostHog identity properties are limited to validation-relevant IDs and coarse student context. Do not send names, email addresses, birth dates, avatar URLs, raw notes, uploaded content, or learner answers as analytics properties. - Capture platform conventions, release processes, and environment decisions here. - Put platform ADRs in `docs/contexts/platform/adr/`. diff --git a/docs/contexts/product/CONTEXT.md b/docs/contexts/product/CONTEXT.md index 2831c0a3..1e4c470b 100644 --- a/docs/contexts/product/CONTEXT.md +++ b/docs/contexts/product/CONTEXT.md @@ -6,16 +6,20 @@ Confluence is the current cross-functional documentation hub. Keep this file foc ## Language +**Lernplan-Erstellung**: +The user-facing setup flow that collects the learner's exam details, optional learning material, and answers to five short questions before a learning plan exists. +_Avoid_: Quiz, completed learning-plan steps, Wissensanalyse + **Persönlicher Lernplan**: -The user-facing learning-plan creation flow and accepted study path. It frames the five short pre-plan questions and optional learning material as the setup for a personalized, ordered plan whose sessions build toward exam readiness. -_Avoid_: Wissensanalyse, Quiz +The generated and accepted study path whose ordered sessions build toward exam readiness. It exists only after the `Lernplan-Erstellung` is complete. +_Avoid_: The setup questions or their completion progress, Wissensanalyse, Quiz **Nächster Lernschritt**: The next unfinished session in a `Persönlicher Lernplan`. It is the recommended continuation point, while later sessions can remain visible for flexibility. _Avoid_: Hard lock, hidden future sessions **Pre-plan diagnostic step**: -The internal name for the five-question diagnostic part of `Persönlicher Lernplan`, used when distinguishing it from post-session `Wissensanalyse`. +The internal name for the five-question diagnostic part of `Lernplan-Erstellung`, used when distinguishing it from post-session `Wissensanalyse`. _Avoid_: User-facing copy **Wissensanalyse**: diff --git a/docs/evidence/day-169/android-dark-pause-confirmation.png b/docs/evidence/day-169/android-dark-pause-confirmation.png new file mode 100644 index 00000000..9dbfdb6d Binary files /dev/null and b/docs/evidence/day-169/android-dark-pause-confirmation.png differ diff --git a/docs/evidence/day-169/android-dark-unfinished-card.png b/docs/evidence/day-169/android-dark-unfinished-card.png new file mode 100644 index 00000000..39a5f347 Binary files /dev/null and b/docs/evidence/day-169/android-dark-unfinished-card.png differ diff --git a/docs/evidence/day-169/android-pause-confirmation.png b/docs/evidence/day-169/android-pause-confirmation.png new file mode 100644 index 00000000..2f85013a Binary files /dev/null and b/docs/evidence/day-169/android-pause-confirmation.png differ diff --git a/docs/evidence/day-169/android-resume-flow.mp4 b/docs/evidence/day-169/android-resume-flow.mp4 new file mode 100644 index 00000000..07d0871a Binary files /dev/null and b/docs/evidence/day-169/android-resume-flow.mp4 differ diff --git a/docs/evidence/day-169/android-sdk57-dark-resume-flow.mp4 b/docs/evidence/day-169/android-sdk57-dark-resume-flow.mp4 new file mode 100644 index 00000000..8b3ce854 Binary files /dev/null and b/docs/evidence/day-169/android-sdk57-dark-resume-flow.mp4 differ diff --git a/docs/evidence/day-169/android-unfinished-card.png b/docs/evidence/day-169/android-unfinished-card.png new file mode 100644 index 00000000..854b2439 Binary files /dev/null and b/docs/evidence/day-169/android-unfinished-card.png differ diff --git a/docs/evidence/day-169/ios-dark-pause-confirmation.png b/docs/evidence/day-169/ios-dark-pause-confirmation.png new file mode 100644 index 00000000..11e8ca0a Binary files /dev/null and b/docs/evidence/day-169/ios-dark-pause-confirmation.png differ diff --git a/docs/evidence/day-169/ios-dark-unfinished-card.png b/docs/evidence/day-169/ios-dark-unfinished-card.png new file mode 100644 index 00000000..7e0fc8a1 Binary files /dev/null and b/docs/evidence/day-169/ios-dark-unfinished-card.png differ diff --git a/docs/evidence/day-169/ios-pause-confirmation.png b/docs/evidence/day-169/ios-pause-confirmation.png new file mode 100644 index 00000000..c0840c55 Binary files /dev/null and b/docs/evidence/day-169/ios-pause-confirmation.png differ diff --git a/docs/evidence/day-169/ios-resume-flow.mp4 b/docs/evidence/day-169/ios-resume-flow.mp4 new file mode 100644 index 00000000..244738c2 Binary files /dev/null and b/docs/evidence/day-169/ios-resume-flow.mp4 differ diff --git a/docs/evidence/day-169/ios-unfinished-card.png b/docs/evidence/day-169/ios-unfinished-card.png new file mode 100644 index 00000000..4b107df9 Binary files /dev/null and b/docs/evidence/day-169/ios-unfinished-card.png differ diff --git a/docs/styling.md b/docs/styling.md index cd41167b..b50f9bd6 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -83,10 +83,11 @@ With that configuration: - When adding or removing custom `fontSize` tokens in `tailwind.config.ts`, update the `theme.text` list in `src/lib/utils.ts`. - Use Tailwind's standard spacing scale. Spacing must stay on a 4px rhythm: `gap-1` is 4px, `gap-2` is 8px, `gap-3` is 12px, `gap-4` is 16px, and so on. Do not redefine spacing keys so class numbers mean raw pixels. -- The app supports light, dark, and system theme preferences. Keep palette - changes centralized in `src/global.css`, `src/lib/theme.ts`, and - `src/lib/theme-preference.ts` so NativeWind classes, React Navigation, and - native-only color props stay aligned. +- The app supports light, dark, and system theme preferences. Keep CSS-variable + palette values canonical in `src/global.css`; `pnpm theme:generate` derives + the native runtime map in `src/lib/theme-variables.ts`, and `pnpm check` + rejects a stale generated map. Keep navigation and native-only colors in + `src/lib/theme.ts`, and preference behavior in `src/lib/theme-preference.ts`. - The current typography baseline is Poppins: Regular for body copy and SemiBold for headings and highlighted text. Figma records that baseline but is not an approval gate for app changes; deliberate changes must update the diff --git a/modules/dayova-system-appearance/README.md b/modules/dayova-system-appearance/README.md index 60737fa2..b2826c30 100644 --- a/modules/dayova-system-appearance/README.md +++ b/modules/dayova-system-appearance/README.md @@ -78,7 +78,8 @@ The module is responsible for: - synchronously returning the resolved appearance of the active iOS window; - emitting `onChange` when that window changes between light and dark; -- refreshing the value when the app becomes active; +- retrying observation when a window becomes key and refreshing the value when + the app becomes active; - making React Native resolve system appearance from the key window; and - installing and removing its UIKit observer with the JavaScript listener lifecycle. @@ -219,12 +220,15 @@ than used everywhere. ### Listener and app lifecycle - `OnStartObserving("onChange")` installs the view and immediately emits the - current value. + current value. It also listens for the first or replacement window becoming + key, so a subscription that races window creation is repaired without + waiting for another app activation. - `OnStopObserving("onChange")` removes the view after the final listener is removed. -- `OnAppBecomesActive` emits the current value and refreshes React Native's - appearance state. This covers a system change made while Dayova was in the - background. +- `OnAppBecomesActive` reinstalls the observer when the initial subscription + raced window creation or the active window changed, then emits the current + value and refreshes React Native's appearance state. This covers system + changes made while Dayova was in the background. - `OnDestroy` removes the observer view and releases the reference. Appearance events can repeat around initialization or foreground activation. @@ -271,9 +275,10 @@ module does not cause that app-wide minimum. - **React Native implementation coupling:** The key-window function and native notification behavior can change without a JavaScript API deprecation. - **Single-window assumption:** The active-window lookup is not scene-specific. -- **Observer installation timing:** Installation requires a window to exist - when the first listener attaches. The current hook mounts inside the running - app UI; a future earlier consumer must verify this assumption. +- **Observer installation timing:** Installation still requires an active + window, but key-window and foreground activation events retry when the + initial listener attached too early and rehome the observer when the active + window changed. - **Normalized result:** The API intentionally collapses unknown or unspecified UIKit values to `"light"`. - **Duplicate state events:** Initialization, trait change, and foreground @@ -297,10 +302,11 @@ pnpm test src/lib/ios-appearance-module.test.ts ``` These tests only assert that the podspec contains the iOS 16.4 target and that -the Swift source retains the expected modern and legacy observer markers and -control-flow order. They do not compile Swift, execute UIKit, prove callback -reachability, or validate event delivery. Native Debug/Release builds and the -runtime smoke-test matrix remain the behavioral evidence. +the Swift source retains the expected modern/legacy observer markers, +key-window/foreground recovery calls, and control-flow order. They do not +compile Swift, execute UIKit, prove callback reachability, or validate event +delivery. Native Debug/Release builds and the runtime smoke-test matrix remain +the behavioral evidence. Confirm Expo can discover the module: diff --git a/modules/dayova-system-appearance/ios/DayovaSystemAppearanceModule.swift b/modules/dayova-system-appearance/ios/DayovaSystemAppearanceModule.swift index 091f0573..2afb76fb 100644 --- a/modules/dayova-system-appearance/ios/DayovaSystemAppearanceModule.swift +++ b/modules/dayova-system-appearance/ios/DayovaSystemAppearanceModule.swift @@ -4,6 +4,8 @@ import UIKit public class DayovaSystemAppearanceModule: Module { private var observerView: AppearanceObserverView? + private var keyWindowObserver: NSObjectProtocol? + private var isObserving = false public func definition() -> ModuleDefinition { Name("DayovaSystemAppearance") @@ -23,25 +25,31 @@ public class DayovaSystemAppearanceModule: Module { OnStartObserving("onChange") { [weak self] in DispatchQueue.main.async { - self?.installObserver() + guard let self else { return } + self.isObserving = true + self.startObservingKeyWindowChanges() + self.refreshObservation() } } OnStopObserving("onChange") { [weak self] in DispatchQueue.main.async { - self?.removeObserver() + guard let self else { return } + self.stopObservation() } } OnAppBecomesActive { [weak self] in - self?.emitCurrentColorScheme() + DispatchQueue.main.async { + guard let self, self.isObserving else { return } + self.refreshObservation() + } } OnDestroy { [weak self] in - guard let observerView = self?.observerView else { return } - self?.observerView = nil + guard let self else { return } DispatchQueue.main.async { - observerView.removeFromSuperview() + self.stopObservation() } } } @@ -76,10 +84,13 @@ public class DayovaSystemAppearanceModule: Module { } } - private func installObserver() { - removeObserver() - + private func installObserverIfNeeded() { guard let window = Self.activeWindow() else { return } + if let observerView, observerView.superview === window { + return + } + + removeObserver() let view = AppearanceObserverView() view.isUserInteractionEnabled = false view.onColorSchemeChange = { [weak self] colorScheme in @@ -87,9 +98,37 @@ public class DayovaSystemAppearanceModule: Module { } window.addSubview(view) observerView = view + } + + private func refreshObservation() { + installObserverIfNeeded() emitCurrentColorScheme() } + private func startObservingKeyWindowChanges() { + stopObservingKeyWindowChanges() + keyWindowObserver = NotificationCenter.default.addObserver( + forName: UIWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self, self.isObserving else { return } + self.refreshObservation() + } + } + + private func stopObservingKeyWindowChanges() { + guard let keyWindowObserver else { return } + NotificationCenter.default.removeObserver(keyWindowObserver) + self.keyWindowObserver = nil + } + + private func stopObservation() { + isObserving = false + stopObservingKeyWindowChanges() + removeObserver() + } + private func removeObserver() { observerView?.removeFromSuperview() observerView = nil diff --git a/package.json b/package.json index d452b822..a0a4708e 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,10 @@ "skills:update:expo": "node scripts/update-expo-skills.mjs", "skills:update:matt": "node scripts/update-matt-pocock-skills.mjs", "test": "vitest run", - "check": "run-s lint typecheck", + "check": "run-s theme:check lint typecheck", "check:compiler": "pnpm dlx react-compiler-healthcheck@latest", + "theme:check": "node scripts/generate-theme-variables.mjs --check", + "theme:generate": "node scripts/generate-theme-variables.mjs", "typecheck": "tsc --noEmit", "check:unused": "cross-env APP_VARIANT=development knip" }, diff --git a/scripts/generate-theme-variables.mjs b/scripts/generate-theme-variables.mjs new file mode 100644 index 00000000..b6070d0e --- /dev/null +++ b/scripts/generate-theme-variables.mjs @@ -0,0 +1,54 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const cssPath = resolve(root, "src/global.css"); +const outputPath = resolve(root, "src/lib/theme-variables.ts"); +const checkOnly = process.argv.includes("--check"); + +const css = readFileSync(cssPath, "utf8"); +const darkRoot = css.match(/\.dark:root\s*\{([^}]+)\}/)?.[1]; +if (!darkRoot) { + throw new Error("Unable to find the canonical `.dark:root` palette."); +} + +const variables = Object.fromEntries( + [...darkRoot.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map(([, name, value]) => [ + name, + value.trim(), + ]), +); +if (Object.keys(variables).length === 0) { + throw new Error("The canonical `.dark:root` palette has no CSS variables."); +} + +const generatedEntries = Object.entries(variables) + .map( + ([name, value]) => `\t${JSON.stringify(name)}: ${JSON.stringify(value)},`, + ) + .join("\n"); + +const generated = `/** + * Generated from \`.dark:root\` in \`src/global.css\` by + * \`pnpm theme:generate\`. Do not edit this file directly. + * + * NativeWind's root-variable observer does not invalidate already-mounted + * Fabric views after an Appearance override on React Native 0.86, so the app + * also supplies these generated values through a root runtime variable scope. + */ +export const DARK_THEME_VARIABLES = { +${generatedEntries} +} as const; +`; + +if (checkOnly) { + const current = readFileSync(outputPath, "utf8"); + if (current !== generated) { + console.error( + "Native theme variables are stale. Run `pnpm theme:generate` and commit the result.", + ); + process.exitCode = 1; + } +} else { + writeFileSync(outputPath, generated); +} diff --git a/src/app/(app)/learning-plans.tsx b/src/app/(app)/learning-plans.tsx index 90f0c688..e2b69558 100644 --- a/src/app/(app)/learning-plans.tsx +++ b/src/app/(app)/learning-plans.tsx @@ -1,7 +1,7 @@ import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { @@ -35,6 +35,7 @@ import { import { Text } from "~/components/ui/text"; import { ThemedStatusBar } from "~/components/ui/themed-status-bar"; import { useAuth } from "~/context/AuthContext"; +import { getLearningPlanOverviewState } from "~/features/learning-plans/creation-overview"; import { getDayKey, parseDayKey, useCurrentLocalDay } from "~/lib/day-key"; import { DAYOVA_DESIGN_SYSTEM } from "~/lib/design-system"; import { formatGermanUiText } from "~/lib/german-ui-text"; @@ -61,6 +62,11 @@ type LearningPlanOverview = { examTypeLabel: string; status: "draft" | "questionsReady" | "generated" | "accepted"; progressPercent: number; + creationProgress?: { + questionCount: number; + answeredQuestionCount: number; + firstUnansweredQuestionIndex: number | null; + } | null; completedCount?: number; sessionCount?: number; examDateKey?: string; @@ -92,8 +98,11 @@ type HomeworkOverview = { const getPlanHref = (plan: LearningPlanOverview) => { if (plan.status === "draft") return ROUTES.createLearningPlan; - if (plan.status === "questionsReady") { - return `/learning-plans/${plan.id}/quiz/0` as const; + const overviewState = getLearningPlanOverviewState(plan); + if (overviewState.kind === "creation") { + return overviewState.resumeTarget.kind === "generation" + ? (`/learning-plans/${plan.id}/generating` as const) + : (`/learning-plans/${plan.id}/quiz/${overviewState.resumeTarget.questionIndex}` as const); } return `/learning-plans/${plan.id}` as const; }; @@ -352,7 +361,7 @@ function LearningPlanActionRail({ style, }: { onDelete: () => void; - onEdit: () => void; + onEdit?: () => void; style?: object; }) { return ( @@ -370,20 +379,24 @@ function LearningPlanActionRail({ ]} > - - - - + {onEdit ? ( + <> + + + + + + ) : null} ; + secondaryTextColor: string; + trailing: ReactNode; +}) { + return ( + <> + + + {formatGermanUiText(plan.subject)} + + {trailing} + + + + + {plan.examDateLabel ?? "Termin wird geladen"} + + + + ); +} + function LearningPlanCard({ plan, todayKey, @@ -414,6 +457,7 @@ function LearningPlanCard({ onPress: () => void; }) { const { colors } = useDayovaTheme(); + const overviewState = getLearningPlanOverviewState(plan); const progress = Math.max(0, Math.min(plan.progressPercent, 100)); const status = getStatus(plan, todayKey); const remainingDays = Math.max( @@ -497,108 +541,146 @@ function LearningPlanCard({ {isActionRailVisible ? ( ) : null} - - } - onPress={onPress} - pressType="card" - > - - + {overviewState.kind === "creation" ? ( + + } + onPress={onPress} + pressType="card" + > + + + } + /> - {formatGermanUiText(plan.subject)} - - - - - - - - - - - {plan.examDateLabel ?? "Termin wird geladen"} + {overviewState.actionLabel} - - - - {formatGermanUiText(currentTitle)} - - - - - - - {`${plan.completedCount ?? 0} von ${plan.sessionCount ?? 0} Lerntage`} - - + - {remainingDays === 1 - ? "noch 1 Tag" - : `noch ${remainingDays} Tage`} + {overviewState.progressLabel} - - + ) : ( + + } + onPress={onPress} + pressType="card" + > + + + + + } - end={DAYOVA_DESIGN_SYSTEM.gradients.primaryInteractive.end} - style={{ - height: "100%", - width: `${Math.max(progress, progress > 0 ? 8 : 0)}%`, - borderRadius: 999, - }} /> + + + {formatGermanUiText(currentTitle)} + - - + + + + + {`${plan.completedCount ?? 0} von ${plan.sessionCount ?? 0} Lerntage`} + + + + + {remainingDays === 1 + ? "noch 1 Tag" + : `noch ${remainingDays} Tage`} + + + + + 0 ? 8 : 0)}%`, + borderRadius: 999, + }} + /> + + + + )} @@ -782,6 +864,12 @@ export default function LearningPlansScreen() { ); const visiblePlans = plans ?? []; const visibleHomework = homework ?? []; + const creationPlans = visiblePlans.filter( + (plan) => getLearningPlanOverviewState(plan).kind === "creation", + ); + const createdPlans = visiblePlans.filter( + (plan) => getLearningPlanOverviewState(plan).kind === "created", + ); const confirmDeletePlan = (plan: LearningPlanOverview) => { Alert.alert( @@ -875,17 +963,44 @@ export default function LearningPlansScreen() { showsVerticalScrollIndicator={false} > {activeTab === "learningPlans" ? ( - + {visiblePlans.length > 0 ? ( - visiblePlans.map((plan) => ( - router.push(getPlanHref(plan))} - onDelete={() => confirmDeletePlan(plan)} - /> - )) + <> + {creationPlans.length > 0 ? ( + + + In Erstellung + + {creationPlans.map((plan) => ( + router.push(getPlanHref(plan))} + onDelete={() => confirmDeletePlan(plan)} + /> + ))} + + ) : null} + {createdPlans.length > 0 ? ( + + {creationPlans.length > 0 ? ( + + Lernpläne + + ) : null} + {createdPlans.map((plan) => ( + router.push(getPlanHref(plan))} + onDelete={() => confirmDeletePlan(plan)} + /> + ))} + + ) : null} + ) : ( diff --git a/src/app/(app)/plans.tsx b/src/app/(app)/plans.tsx index 97e6b247..104697b6 100644 --- a/src/app/(app)/plans.tsx +++ b/src/app/(app)/plans.tsx @@ -105,14 +105,16 @@ export default function PlansScreen() { - {learningPlans?.map((plan) => ( - - )) ?? null} + {learningPlans + ?.filter((plan) => plan.status === "accepted") + .map((plan) => ( + + )) ?? null} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 274412ee..8bbeaa10 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -120,7 +120,8 @@ function RootProviders({ convexClient }: { convexClient: ConvexReactClient }) { return ( - + {/* NativeWind vars() must use style to provide runtime CSS variables. */} + , questionIndex: number) => `/learning-plans/${id}/quiz/${questionIndex}` as const; @@ -40,8 +55,11 @@ export default function LearningPlanQuizScreen() { ); const [answer, setAnswer] = useState(""); const [isBusy, setIsBusy] = useState(false); + const [isPauseConfirmationVisible, setIsPauseConfirmationVisible] = + useState(false); const [errorMessage, setErrorMessage] = useState(null); const loadedQuestionIdRef = useRef(null); + const isExitingRef = useRef(false); const snapshot = (useQuery( api.learningPlans.getSnapshot, @@ -79,18 +97,47 @@ export default function LearningPlanQuizScreen() { setErrorMessage(null); }, [currentQuestion, storedAnswer?.answer]); - const goBack = () => { - if (!planId) return true; - if (questionIndex > 0) { - router.replace(quizPath(planId, questionIndex - 1)); + const goBack = useCallback(() => { + if (!planId || isBusy) return true; + + const backIntent = getLearningPlanCreationBackIntent({ + questionIndex, + isPauseConfirmationVisible, + }); + if (backIntent.kind === "previousQuestion") { + router.replace(quizPath(planId, backIntent.questionIndex)); return true; } - router.replace(planPath(planId, "analysis")); + if (backIntent.kind === "confirmPause") { + setIsPauseConfirmationVisible(true); + } + return true; - }; + }, [isBusy, isPauseConfirmationVisible, planId, questionIndex, router]); useBackIntent(Boolean(planId), goBack); + const backSwipeGesture = Gesture.Pan() + .enabled( + Platform.OS === "ios" && + Boolean(planId) && + !isBusy && + !isPauseConfirmationVisible, + ) + .hitSlop({ left: 0, width: 28 }) + .activeOffsetX(20) + .failOffsetY([-20, 20]) + .onEnd((event) => { + if (event.translationX >= 56) scheduleOnRN(goBack); + }); + + const continueLater = () => { + if (isExitingRef.current) return; + isExitingRef.current = true; + setIsPauseConfirmationVisible(false); + dismissToOrReplace(router, ROUTES.learningPlans); + }; + const continueQuestion = async () => { if (!planId || !currentQuestion || isBusy) return; const trimmedAnswer = answer.trim(); @@ -120,33 +167,65 @@ export default function LearningPlanQuizScreen() { }; return ( - - - - -
0} /> - {currentQuestion ? ( - - ) : null} - - + + + + + +
+ {currentQuestion ? ( + + ) : null} + + + setIsPauseConfirmationVisible(false)} + accessibilityLabel="Pause-Dialog schließen" + title="Lernplan-Erstellung pausieren?" + description="Deine bisherigen Antworten bleiben gespeichert. Du kannst die Erstellung später unter Lernpläne fortsetzen." + icon={ + + } + iconContainerClassName="bg-system-subtle" + > + + + + + + + ); } diff --git a/src/features/learning-plans/creation-navigation.test.ts b/src/features/learning-plans/creation-navigation.test.ts new file mode 100644 index 00000000..56411bf0 --- /dev/null +++ b/src/features/learning-plans/creation-navigation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { getLearningPlanCreationBackIntent } from "./creation-navigation"; + +describe("learning plan creation back intent", () => { + test("moves directly to the preceding question after question one", () => { + expect( + getLearningPlanCreationBackIntent({ + questionIndex: 3, + isPauseConfirmationVisible: false, + }), + ).toEqual({ kind: "previousQuestion", questionIndex: 2 }); + }); + + test("requests pause confirmation before leaving question one", () => { + expect( + getLearningPlanCreationBackIntent({ + questionIndex: 0, + isPauseConfirmationVisible: false, + }), + ).toEqual({ kind: "confirmPause" }); + }); + + test("ignores another back intent while pause confirmation is visible", () => { + expect( + getLearningPlanCreationBackIntent({ + questionIndex: 0, + isPauseConfirmationVisible: true, + }), + ).toEqual({ kind: "ignore" }); + }); +}); diff --git a/src/features/learning-plans/creation-navigation.ts b/src/features/learning-plans/creation-navigation.ts new file mode 100644 index 00000000..cf91660e --- /dev/null +++ b/src/features/learning-plans/creation-navigation.ts @@ -0,0 +1,22 @@ +type LearningPlanCreationBackIntent = + | { kind: "previousQuestion"; questionIndex: number } + | { kind: "confirmPause" } + | { kind: "ignore" }; + +export const getLearningPlanCreationBackIntent = ({ + questionIndex, + isPauseConfirmationVisible, +}: { + questionIndex: number; + isPauseConfirmationVisible: boolean; +}): LearningPlanCreationBackIntent => { + if (isPauseConfirmationVisible) return { kind: "ignore" }; + if (questionIndex <= 0) return { kind: "confirmPause" }; + + return { + kind: "previousQuestion", + questionIndex: questionIndex - 1, + }; +}; + +export type { LearningPlanCreationBackIntent }; diff --git a/src/features/learning-plans/creation-overview.test.ts b/src/features/learning-plans/creation-overview.test.ts new file mode 100644 index 00000000..f053c7f2 --- /dev/null +++ b/src/features/learning-plans/creation-overview.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { getLearningPlanOverviewState } from "./creation-overview"; + +describe("learning plan creation overview", () => { + test.each([ + undefined, + null, + ])("does not invent progress when creation data is %s", (creationProgress) => { + expect( + getLearningPlanOverviewState({ + status: "questionsReady", + creationProgress, + }), + ).toMatchObject({ + kind: "creation", + progressLabel: "Fortschritt wird geladen", + resumeTarget: { kind: "question", questionIndex: 0 }, + }); + }); + + test("presents exact creation progress and resumes at the first unanswered question", () => { + expect( + getLearningPlanOverviewState({ + status: "questionsReady", + creationProgress: { + questionCount: 5, + answeredQuestionCount: 2, + firstUnansweredQuestionIndex: 2, + }, + }), + ).toEqual({ + kind: "creation", + badgeLabel: "Noch nicht erstellt", + actionLabel: "Lernplan-Erstellung fortsetzen", + progressLabel: "2 von 5 Fragen beantwortet", + resumeTarget: { kind: "question", questionIndex: 2 }, + }); + }); + + test("continues generation when every question was saved before interruption", () => { + expect( + getLearningPlanOverviewState({ + status: "questionsReady", + creationProgress: { + questionCount: 5, + answeredQuestionCount: 5, + firstUnansweredQuestionIndex: null, + }, + }), + ).toMatchObject({ + kind: "creation", + progressLabel: "5 von 5 Fragen beantwortet", + resumeTarget: { kind: "generation" }, + }); + }); + + test("keeps accepted plans in the created-plan presentation", () => { + expect( + getLearningPlanOverviewState({ + status: "accepted", + }), + ).toEqual({ kind: "created" }); + }); +}); diff --git a/src/features/learning-plans/creation-overview.ts b/src/features/learning-plans/creation-overview.ts new file mode 100644 index 00000000..4d6a3458 --- /dev/null +++ b/src/features/learning-plans/creation-overview.ts @@ -0,0 +1,62 @@ +type LearningPlanStatus = "draft" | "questionsReady" | "generated" | "accepted"; + +type LearningPlanOverviewInput = { + status: LearningPlanStatus; + creationProgress?: { + questionCount: number; + answeredQuestionCount: number; + firstUnansweredQuestionIndex: number | null; + } | null; +}; + +type LearningPlanOverviewState = + | { + kind: "creation"; + badgeLabel: "Noch nicht erstellt"; + actionLabel: "Lernplan-Erstellung fortsetzen"; + progressLabel: string; + resumeTarget: + | { kind: "question"; questionIndex: number } + | { kind: "generation" }; + } + | { kind: "created" }; + +export const getLearningPlanOverviewState = ( + overview: LearningPlanOverviewInput, +): LearningPlanOverviewState => { + if (overview.status !== "questionsReady") return { kind: "created" }; + + let progressLabel: string; + let resumeTarget: + | { kind: "question"; questionIndex: number } + | { kind: "generation" }; + + if (!overview.creationProgress) { + progressLabel = "Fortschritt wird geladen"; + resumeTarget = { kind: "question", questionIndex: 0 }; + } else { + const { + questionCount, + answeredQuestionCount, + firstUnansweredQuestionIndex, + } = overview.creationProgress; + progressLabel = `${answeredQuestionCount} von ${questionCount} Fragen beantwortet`; + resumeTarget = + firstUnansweredQuestionIndex === null + ? { kind: "generation" } + : { + kind: "question", + questionIndex: Math.max(firstUnansweredQuestionIndex, 0), + }; + } + + return { + kind: "creation", + badgeLabel: "Noch nicht erstellt", + actionLabel: "Lernplan-Erstellung fortsetzen", + progressLabel, + resumeTarget, + }; +}; + +export type { LearningPlanOverviewInput, LearningPlanOverviewState }; diff --git a/src/lib/analytics-policy.test.ts b/src/lib/analytics-policy.test.ts new file mode 100644 index 00000000..57b7ae18 --- /dev/null +++ b/src/lib/analytics-policy.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +const ANALYTICS_PATH = resolve(process.cwd(), "src/lib/analytics.ts"); + +describe("validation analytics policy", () => { + test("captures one-shot events before starting Convex activity marking", () => { + const source = readFileSync(ANALYTICS_PATH, "utf8"); + const captureStart = source.indexOf("const capture = useCallback"); + const captureEnd = source.indexOf("\n\treturn { capture };", captureStart); + const captureBody = source.slice(captureStart, captureEnd); + const eventCapture = captureBody.indexOf("captureValidationEvent("); + const activityMark = captureBody.indexOf("void markActivity("); + + expect(captureStart).toBeGreaterThanOrEqual(0); + expect(captureEnd).toBeGreaterThan(captureStart); + expect(captureBody).not.toContain("async ("); + expect(eventCapture).toBeGreaterThanOrEqual(0); + expect(activityMark).toBeGreaterThan(eventCapture); + }); +}); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 6d38704c..3a82bdc9 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -24,34 +24,29 @@ export function useValidationAnalytics() { const clerkId = user?.clerkId; const capture = useCallback( - async ( - eventName: ValidationEventName, - properties?: AnalyticsProperties, - ) => { + (eventName: ValidationEventName, properties?: AnalyticsProperties) => { if (!isPostHogConfigured || !clerkId) return; - let validationStudentCode = convexUser?.validationStudentCode ?? null; - if (convexUser) { - try { - const activity = await markActivity({ - localDayKey: getDayKey(new Date()), - }); - validationStudentCode = activity.validationStudentCode; - } catch (error) { - logDiagnosticError("Failed to mark validation activity.", error, { - source: "analytics.markActivity", - level: "warn", - metadata: { eventName }, - }); - } - } - + // Capture synchronously so one-shot interactions remain fail-open while + // Convex activity marking runs independently as best effort. + const validationStudentCode = convexUser?.validationStudentCode ?? null; captureValidationEvent(posthog, eventName, clerkId, { ...properties, ...(validationStudentCode ? { validation_student_code: validationStudentCode } : {}), }); + + if (!convexUser) return; + void markActivity({ + localDayKey: getDayKey(new Date()), + }).catch((error: unknown) => { + logDiagnosticError("Failed to mark validation activity.", error, { + source: "analytics.markActivity", + level: "warn", + metadata: { eventName }, + }); + }); }, [clerkId, convexUser, markActivity, posthog], ); diff --git a/src/lib/ios-appearance-module.test.ts b/src/lib/ios-appearance-module.test.ts index 95ac68f5..8d3d9654 100644 --- a/src/lib/ios-appearance-module.test.ts +++ b/src/lib/ios-appearance-module.test.ts @@ -72,4 +72,38 @@ describe("iOS system appearance module", () => { expect(modernIosReturn).toBeGreaterThan(availabilityCheck); expect(sharedHandlerCall).toBeGreaterThan(modernIosReturn); }); + + test("reinstalls live appearance observation when the app becomes active", () => { + const module = readFileSync(IOS_MODULE_PATH, "utf8"); + const activationHook = sectionBetween( + module, + "OnAppBecomesActive", + "OnDestroy", + ); + const refreshHelper = sectionBetween( + module, + "private func refreshObservation()", + "private func startObservingKeyWindowChanges()", + ); + + expect(activationHook).toContain("refreshObservation()"); + expect(activationHook).toContain( + "guard let self, self.isObserving else { return }", + ); + expect(refreshHelper).toContain("installObserverIfNeeded()"); + expect(refreshHelper).toContain("emitCurrentColorScheme()"); + }); + + test("installs observation when a window becomes key after subscription", () => { + const module = readFileSync(IOS_MODULE_PATH, "utf8"); + const startHook = sectionBetween( + module, + 'OnStartObserving("onChange")', + 'OnStopObserving("onChange")', + ); + + expect(startHook).toContain("startObservingKeyWindowChanges()"); + expect(module).toContain("UIWindow.didBecomeKeyNotification"); + expect(module).toContain("stopObservingKeyWindowChanges()"); + }); }); diff --git a/src/lib/theme-css.test.ts b/src/lib/theme-css.test.ts index 4bc26625..fa016f6e 100644 --- a/src/lib/theme-css.test.ts +++ b/src/lib/theme-css.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, test } from "vitest"; -import { DARK_THEME_VARIABLES } from "./theme-variables"; const GLOBAL_CSS_PATH = resolve(process.cwd(), "src/global.css"); const APP_CONFIG_PATH = resolve(process.cwd(), "app.config.ts"); @@ -15,20 +14,6 @@ describe("theme CSS", () => { expect(css).not.toContain("\n\t.dark {\n"); }); - test("keeps the native runtime dark variables synchronized with CSS", () => { - const css = readFileSync(GLOBAL_CSS_PATH, "utf8"); - const darkRoot = css.match(/\.dark:root\s*\{([^}]+)\}/)?.[1]; - expect(darkRoot).toBeDefined(); - - const cssVariables = Object.fromEntries( - [...(darkRoot ?? "").matchAll(/(--[\w-]+):\s*([^;]+);/g)].map( - ([, name, value]) => [name, value], - ), - ); - - expect(DARK_THEME_VARIABLES).toEqual(cssVariables); - }); - test("allows native light and dark appearance changes", () => { const appConfig = readFileSync(APP_CONFIG_PATH, "utf8"); diff --git a/src/lib/theme-variables.ts b/src/lib/theme-variables.ts index a4e1c8b7..7b7beb93 100644 --- a/src/lib/theme-variables.ts +++ b/src/lib/theme-variables.ts @@ -1,10 +1,10 @@ /** - * NativeWind's global root-variable observers do not currently invalidate - * already-mounted Fabric views after an Appearance override on React Native - * 0.86. Applying the dark variables through a root View keeps descendants in - * React's variable context while preserving the CSS declarations for web. + * Generated from `.dark:root` in `src/global.css` by + * `pnpm theme:generate`. Do not edit this file directly. * - * Keep these values synchronized with `.dark:root` in global.css. + * NativeWind's root-variable observer does not invalidate already-mounted + * Fabric views after an Appearance override on React Native 0.86, so the app + * also supplies these generated values through a root runtime variable scope. */ export const DARK_THEME_VARIABLES = { "--background": "250 10% 8%", diff --git a/tests/package-scripts.test.ts b/tests/package-scripts.test.ts index 27bf0540..bcf5e614 100644 --- a/tests/package-scripts.test.ts +++ b/tests/package-scripts.test.ts @@ -25,6 +25,16 @@ describe("package scripts", () => { ); }); + it("generates native theme variables from the canonical CSS palette", () => { + expect(packageJson.scripts["theme:generate"]).toBe( + "node scripts/generate-theme-variables.mjs", + ); + expect(packageJson.scripts["theme:check"]).toBe( + "node scripts/generate-theme-variables.mjs --check", + ); + expect(packageJson.scripts.check).toContain("theme:check"); + }); + it("does not use POSIX-only inline environment assignments", () => { const posixOnlyScripts = Object.entries(packageJson.scripts) .filter(([, command]) => /^[A-Za-z_][A-Za-z0-9_]*=[^ ]+ /.test(command))