Context
SentenceStudio is a .NET MAUI Blazor Hybrid language learning app. .NET 10 SDK (10.0.101), multi-platform (iOS, Android, Mac Catalyst), with .NET Aspire orchestration.
Project Structure
| Project |
Purpose |
Build |
SentenceStudio.Shared |
Core: DbContext, repos, services, models, migrations |
Multi-TFM |
SentenceStudio.AppLib |
Blazor Hybrid library, Scriban templates |
Multi-TFM |
SentenceStudio.UI |
Blazor RCL — web pages, components |
dotnet build (no -f) |
SentenceStudio.MacCatalyst/iOS/Android |
MAUI app heads |
-f net10.0-{platform} |
SentenceStudio.Api |
ASP.NET Core backend |
net10.0 |
SentenceStudio.WebApp |
Blazor Server frontend |
net10.0 |
SentenceStudio.AppHost |
Aspire orchestration |
net10.0 |
SentenceStudio.Infrastructure |
Server-side persistence (ServerDbContext) |
net10.0 |
SentenceStudio.Workers |
Background services |
net10.0 |
Build Commands
# MAUI (NEVER use dotnet run)
dotnet build -f net10.0-maccatalyst
dotnet build -t:Run -f net10.0-maccatalyst
# UI project (no TFM flag)
dotnet build src/SentenceStudio.UI/SentenceStudio.UI.csproj
# Tests
dotnet test
Testing
- Framework: xUnit + Moq + FluentAssertions (Unit), AutoFixture (Integration), AspNetCore.Mvc.Testing (API)
- Projects:
tests/SentenceStudio.UnitTests, tests/SentenceStudio.IntegrationTests, tests/SentenceStudio.Api.Tests
- Target: net10.0
Blazor Page Patterns
- Bootstrap icons only:
<i class="bi bi-{name}"></i> — NEVER emojis
- Layout:
<PageHeader> → <ToolbarActions> → main content with card card-ss
- Spinners:
<span class="spinner-border spinner-border-sm">
- Alerts:
<div class="alert alert-{danger|warning|success}">
- Auth:
@attribute [Authorize] on protected pages
- Cleanup:
@implements IAsyncDisposable
Database Patterns
- DbContext:
ApplicationDbContext — SQLite on mobile, PostgreSQL on server
- Table names: SINGULAR (configured in OnModelCreating)
- Synced entities: String GUID PKs with
ValueGeneratedNever()
- Non-synced: Int auto-increment PKs
- Migrations: Always via
dotnet ef migrations add — NEVER hand-write, NEVER raw SQL ALTER TABLE
- Data isolation: Filter by
UserProfileId
- CRITICAL: Never call
EnsureCreatedAsync before MigrateAsync
- Migration location:
Migrations/ (PostgreSQL), Migrations/Sqlite/ (mobile)
AI/Prompt Patterns
- Templates: 24+ Scriban templates in
src/SentenceStudio.AppLib/Resources/Raw/*.scriban-txt
- Client:
IChatClient (Microsoft.Extensions.AI) via AiService.SendPrompt<T>()
- DTOs: Use
[Description] attributes on properties — no [JsonPropertyName]
- Connectivity: Check
_connectivity.IsInternetAvailable before AI calls
- Gateway: Optional
IAiGatewayClient for server routing
Service Patterns
- Constructor injection via
IServiceProvider
- All methods async (
Task<T>)
ILogger<T> for structured logging
WeakReferenceMessenger.Default.Send() for cross-component messaging
- Scoped DbContext access:
_serviceProvider.CreateScope() → GetRequiredService<ApplicationDbContext>()
Error Handling
- Structured logging:
_logger.LogError(ex, "context {Field}", value)
InvalidOperationException for state violations
ArgumentException for invalid arguments
- Default/null returns for external API failures (TTS, image)
- Connectivity resilience via
ConnectivityChangedMessage
Activity tracking (ad-hoc + plan items)
- Every activity page calls
ActivityTimer.StartSession(activityType, PlanItemId, resourceId, skillId) unconditionally — when PlanItemId is null/empty the service creates a synthetic DailyPlanCompletion with PlanItemId = "adhoc-{guid}" so freeform sessions get duration tracking.
activityType string must parse to a PlanActivityType enum value or the ad-hoc row is silently dropped. Valid enum values (see IProgressService.cs): VocabularyReview, Reading, Listening, VideoWatching, Shadowing, Cloze, Translation, Writing, SceneDescription, Conversation, VocabularyGame. NOT valid: VocabularyMatching (use VocabularyGame), HowDoYouSay, WordAssociation, MinimalPairs.
ReconstructPlanFromDatabase filters adhoc-* so they don't show up in "Today's Plan"; GetActivityLogAsync includes them so day-detail shows freeform practice with its own "Freeform practice" cluster.
- Query param naming is NOT uniform: most pages use singular
ResourceIdParam; VocabQuiz/VocabMatching use plural ResourceIdsParam (comma-separated — take .Split(',').FirstOrDefault() for ad-hoc persistence).
Scriban prompt loops
Default iteration limit is 1000. Dynamic learning resources ("New Words") can return thousands of unpracticed terms. Any service rendering {{ for t in terms }} must cap the collection before passing to the template — current cap is 40 random words in TranslationService and ClozureService. If adding a new activity that loops vocab, apply the same cap.
MauiReactor Conventions
- Use
VStart() / VEnd() not Top() / Bottom()
- Use
HStart() / HEnd() not Start() / End()
- NEVER use
FillAndExpand — legacy pattern
- NEVER put CollectionView inside scrollable containers
Anti-Patterns (CRITICAL)
- ❌ NEVER uninstall/reinstall apps (destroys user data)
- ❌ NEVER delete database files without permission + backup
- ❌ NEVER use
dotnet run for MAUI apps
- ❌ NEVER use emoji in UI/code/logs
- ❌ NEVER use raw SQL ALTER TABLE — always EF migrations
- ❌ NEVER suppress PendingModelChangesWarning
- ❌ NEVER put CollectionView inside VStack/scrollable containers
- ❌ NEVER use inline FontImageSource — define in ApplicationTheme.Icons.cs
- ❌ All documentation files go in
/docs/ not repo root
1---2name: project-conventions3description: Core conventions and patterns for SentenceStudio4---56## Context78SentenceStudio is a .NET MAUI Blazor Hybrid language learning app. .NET 10 SDK (10.0.101), multi-platform (iOS, Android, Mac Catalyst), with .NET Aspire orchestration.910## Project Structure1112| Project | Purpose | Build |13|---------|---------|-------|14| `SentenceStudio.Shared` | Core: DbContext, repos, services, models, migrations | Multi-TFM |15| `SentenceStudio.AppLib` | Blazor Hybrid library, Scriban templates | Multi-TFM |16| `SentenceStudio.UI` | Blazor RCL — web pages, components | `dotnet build` (no -f) |17| `SentenceStudio.MacCatalyst/iOS/Android` | MAUI app heads | `-f net10.0-{platform}` |18| `SentenceStudio.Api` | ASP.NET Core backend | net10.0 |19| `SentenceStudio.WebApp` | Blazor Server frontend | net10.0 |20| `SentenceStudio.AppHost` | Aspire orchestration | net10.0 |21| `SentenceStudio.Infrastructure` | Server-side persistence (ServerDbContext) | net10.0 |22| `SentenceStudio.Workers` | Background services | net10.0 |2324## Build Commands2526```bash27# MAUI (NEVER use dotnet run)28dotnet build -f net10.0-maccatalyst29dotnet build -t:Run -f net10.0-maccatalyst3031# UI project (no TFM flag)32dotnet build src/SentenceStudio.UI/SentenceStudio.UI.csproj3334# Tests35dotnet test36```3738## Testing3940- **Framework:** xUnit + Moq + FluentAssertions (Unit), AutoFixture (Integration), AspNetCore.Mvc.Testing (API)41- **Projects:** `tests/SentenceStudio.UnitTests`, `tests/SentenceStudio.IntegrationTests`, `tests/SentenceStudio.Api.Tests`42- **Target:** net10.04344## Blazor Page Patterns4546- Bootstrap icons only: `<i class="bi bi-{name}"></i>` — **NEVER emojis**47- Layout: `<PageHeader>` → `<ToolbarActions>` → main content with `card card-ss`48- Spinners: `<span class="spinner-border spinner-border-sm">`49- Alerts: `<div class="alert alert-{danger|warning|success}">`50- Auth: `@attribute [Authorize]` on protected pages51- Cleanup: `@implements IAsyncDisposable`5253## Database Patterns5455- **DbContext:** `ApplicationDbContext` — SQLite on mobile, PostgreSQL on server56- **Table names:** SINGULAR (configured in OnModelCreating)57- **Synced entities:** String GUID PKs with `ValueGeneratedNever()`58- **Non-synced:** Int auto-increment PKs59- **Migrations:** Always via `dotnet ef migrations add` — NEVER hand-write, NEVER raw SQL ALTER TABLE60- **Data isolation:** Filter by `UserProfileId`61- **CRITICAL:** Never call `EnsureCreatedAsync` before `MigrateAsync`62- **Migration location:** `Migrations/` (PostgreSQL), `Migrations/Sqlite/` (mobile)6364## AI/Prompt Patterns6566- **Templates:** 24+ Scriban templates in `src/SentenceStudio.AppLib/Resources/Raw/*.scriban-txt`67- **Client:** `IChatClient` (Microsoft.Extensions.AI) via `AiService.SendPrompt<T>()`68- **DTOs:** Use `[Description]` attributes on properties — no `[JsonPropertyName]`69- **Connectivity:** Check `_connectivity.IsInternetAvailable` before AI calls70- **Gateway:** Optional `IAiGatewayClient` for server routing7172## Service Patterns7374- Constructor injection via `IServiceProvider`75- All methods async (`Task<T>`)76- `ILogger<T>` for structured logging77- `WeakReferenceMessenger.Default.Send()` for cross-component messaging78- Scoped DbContext access: `_serviceProvider.CreateScope()` → `GetRequiredService<ApplicationDbContext>()`7980## Error Handling8182- Structured logging: `_logger.LogError(ex, "context {Field}", value)`83- `InvalidOperationException` for state violations84- `ArgumentException` for invalid arguments85- Default/null returns for external API failures (TTS, image)86- Connectivity resilience via `ConnectivityChangedMessage`8788## Activity tracking (ad-hoc + plan items)8990- Every activity page calls `ActivityTimer.StartSession(activityType, PlanItemId, resourceId, skillId)` **unconditionally** — when `PlanItemId` is null/empty the service creates a synthetic `DailyPlanCompletion` with `PlanItemId = "adhoc-{guid}"` so freeform sessions get duration tracking.91- `activityType` string must parse to a `PlanActivityType` enum value or the ad-hoc row is silently dropped. **Valid enum values** (see `IProgressService.cs`): `VocabularyReview, Reading, Listening, VideoWatching, Shadowing, Cloze, Translation, Writing, SceneDescription, Conversation, VocabularyGame`. **NOT valid**: `VocabularyMatching` (use `VocabularyGame`), `HowDoYouSay`, `WordAssociation`, `MinimalPairs`.92- `ReconstructPlanFromDatabase` filters `adhoc-*` so they don't show up in "Today's Plan"; `GetActivityLogAsync` includes them so day-detail shows freeform practice with its own "Freeform practice" cluster.93- Query param naming is NOT uniform: most pages use singular `ResourceIdParam`; `VocabQuiz`/`VocabMatching` use plural `ResourceIdsParam` (comma-separated — take `.Split(',').FirstOrDefault()` for ad-hoc persistence).9495## Scriban prompt loops9697Default iteration limit is 1000. Dynamic learning resources ("New Words") can return thousands of unpracticed terms. Any service rendering `{{ for t in terms }}` must cap the collection before passing to the template — current cap is **40 random words** in `TranslationService` and `ClozureService`. If adding a new activity that loops vocab, apply the same cap.9899## MauiReactor Conventions100101- Use `VStart()` / `VEnd()` not `Top()` / `Bottom()`102- Use `HStart()` / `HEnd()` not `Start()` / `End()`103- NEVER use `FillAndExpand` — legacy pattern104- NEVER put CollectionView inside scrollable containers105106## Anti-Patterns (CRITICAL)107108- ❌ NEVER uninstall/reinstall apps (destroys user data)109- ❌ NEVER delete database files without permission + backup110- ❌ NEVER use `dotnet run` for MAUI apps111- ❌ NEVER use emoji in UI/code/logs112- ❌ NEVER use raw SQL ALTER TABLE — always EF migrations113- ❌ NEVER suppress PendingModelChangesWarning114- ❌ NEVER put CollectionView inside VStack/scrollable containers115- ❌ NEVER use inline FontImageSource — define in ApplicationTheme.Icons.cs116- ❌ All documentation files go in `/docs/` not repo root