diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055c116..62c2ac8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,21 @@ jobs: - run: pnpm test + - run: pnpm knip + + - run: pnpm tsx scripts/check-doc-paths.ts + + # scripts/find-unnamed-buttons.mts lands via a sibling task. Guarded so + # this step is a no-op until that branch merges, rather than failing + # every run in the meantime. + - name: Check for unnamed accessible buttons (client) + run: | + if [ -f scripts/find-unnamed-buttons.mts ]; then + pnpm tsx scripts/find-unnamed-buttons.mts client + else + echo "scripts/find-unnamed-buttons.mts not present yet (lands via a sibling task) — skipping." + fi + # docs/api-routes.md is generated from the Fastify route registry, so it # can only be wrong if someone changed a route and did not regenerate it. # This step is what makes that impossible to merge. It runs last because diff --git a/README.md b/README.md index edc5330..9441e3e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # Tutor +[![CI](https://img.shields.io/github/actions/workflow/status/rsml/tutor/ci.yml?branch=master&label=CI)](https://github.com/rsml/tutor/actions/workflows/ci.yml) +[![License: GPL-3.0](https://img.shields.io/github/license/rsml/tutor)](LICENSE) +[![Latest Release](https://img.shields.io/github/v/release/rsml/tutor)](https://github.com/rsml/tutor/releases/latest) + +Start here. [ARCHITECTURE.md](ARCHITECTURE.md) is the entry point for technical readers. The vocabulary is in [CONTEXT.md](CONTEXT.md) and the decisions, with what each one cost, are in [docs/adr/](docs/adr/README.md). + **Read smarter — personal tutors disguised as books.** Books suggested and generated just for you based on your feedback, quiz results, and unique learning style. Talks about this project live in [rsml/talks](https://github.com/rsml/talks). -Reading the code? Start at [ARCHITECTURE.md](ARCHITECTURE.md). The vocabulary is in [CONTEXT.md](CONTEXT.md) and the decisions, with what each one cost, are in [docs/adr/](docs/adr/README.md). -

Tutor library showing AI-generated books with custom covers

@@ -98,6 +102,34 @@ Give feedback on each chapter. The next one adapts to your quiz results and lear Learning profile and settings

+## Architecture + +```mermaid +flowchart LR + features["client/features/
feature slices"] --> api["client/api/
typed API client"] + api -->|"HTTP and SSE"| routes["server/routes/
thin routes"] + routes --> services["server/services/"] + services --> ports["server/ports/"] + ports --> adapters["server/adapters/"] +``` + +Client feature slices call the server through one typed API client. Routes stay thin and hand off to services, which depend on ports rather than concrete adapters. + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the full five-diagram hub, including how the pieces talk to each other, the server hexagon, the adaptive loop, and the dependency rule. + +What each top-level directory is, in one line each. + +``` +client/ React renderer, feature slices, one typed API client +server/ Fastify core, thin routes over services over ports over adapters +shared/ Zod domain schemas and contract types both sides import +electron/ desktop shell, window chrome, IPC, embedded server boot +e2e/ Playwright journeys driving the real app on fake adapters + (unit and contract tests are colocated with their source) +scripts/ generators and repo checks, all wired into CI +docs/ ADRs, the generated API reference, screenshots, phase plans +``` + ## Build Standalone DMG ```bash @@ -112,6 +144,7 @@ pnpm install pnpm dev:server # Keep this running one tab pnpm electron:dev # Run this in a different tab pnpm test # Run tests +pnpm e2e # Run end-to-end tests ``` Set your Claude, ChatGPT or Gemini API key in Settings (gear icon) on first launch. @@ -128,7 +161,7 @@ Set your Claude, ChatGPT or Gemini API key in Settings (gear icon) on first laun | AI | Vercel AI SDK | | Storage | Filesystem (Markdown + YAML) | | Desktop | Electron (via vite-plugin-electron) | -| Testing | Vitest | +| Testing | Vitest + Playwright (e2e) | ## License diff --git a/client/api/chat.ts b/client/api/chat.ts index d0c8cd5..b7003b7 100644 --- a/client/api/chat.ts +++ b/client/api/chat.ts @@ -12,9 +12,6 @@ import { streamText } from './sse' * browser bundle. */ -/** One turn of chat history, sent to the server as context for the next reply. */ -export type ChatHistoryMessage = z.infer['history'][number] - /** Everything streamChat needs to ask the tutor about a chapter or a selection. */ export type StreamChatParams = z.infer & { /** Lets the caller abort the request in flight, since the chat panel cancels one whenever the user restarts or clears the conversation. */ diff --git a/client/api/index.ts b/client/api/index.ts index ff77579..c710b3d 100644 --- a/client/api/index.ts +++ b/client/api/index.ts @@ -13,8 +13,7 @@ * endpoints. */ -export { ApiError, apiUrl, getApiPort, initApiBase } from './http' -export type { ApiRequestInit, JsonRequestInit } from './http' +export { ApiError, getApiPort } from './http' export { audiobookFileUrl, coverUrl, voicePreviewUrl } from './urls' diff --git a/client/api/profile.ts b/client/api/profile.ts index 41f3142..effed44 100644 --- a/client/api/profile.ts +++ b/client/api/profile.ts @@ -5,7 +5,8 @@ import type { SuggestSkillsBodySchema, UpdateProfileBodySchema, } from '@shared/contracts' -import type { LearningProfile, Preferences } from '@shared/domain' +import type { LearningProfile } from '@shared/domain' +import type { ProfileResponse } from '@shared/responses' import { request } from './http' import { streamNdjson } from './sse' @@ -25,21 +26,7 @@ export type Skill = LearningProfile['skills'][number] /** The model and provider choice every AI-backed profile call sends. */ type AiRequest = z.infer -/** - * The learning profile as the server answers or accepts it over the wire. - * - * This is not LearningProfile from shared/domain.ts. That type's fields are - * style and identity, the shape the profile is persisted as on disk. The - * profile route folds those two fields into a single aboutMe string before - * it answers. That aboutMe field is what every caller actually reads, so - * this module names the wire shape on its own rather than importing a type - * that would be misleading. - */ -export interface ProfileResponse { - aboutMe: string - preferences: Preferences - skills: Skill[] -} +export type { ProfileResponse } /** Fetch the learning profile, meaning About Me, preferences, and prior knowledge skills. */ export async function getProfile(): Promise { diff --git a/client/components/ui/command.tsx b/client/components/ui/command.tsx deleted file mode 100644 index 621ac2f..0000000 --- a/client/components/ui/command.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client" - -import * as React from "react" -import { Command as CommandPrimitive } from "cmdk" - -import { cn } from "@client/lib/utils" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@client/components/ui/dialog" -import { - InputGroup, - InputGroupAddon, -} from "@client/components/ui/input-group" -import { SearchIcon, CheckIcon } from "lucide-react" - -function Command({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", - children, - className, - showCloseButton = false, - ...props -}: Omit, "children"> & { - title?: string - description?: string - className?: string - showCloseButton?: boolean - children: React.ReactNode -}) { - return ( - - - {title} - {description} - - - {children} - - - ) -} - -function CommandInput({ - className, - ...props -}: React.ComponentProps) { - return ( -
- - - - - - -
- ) -} - -function CommandList({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandEmpty({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - {children} - - - ) -} - -function CommandShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -} diff --git a/client/components/ui/input-group.tsx b/client/components/ui/input-group.tsx deleted file mode 100644 index 219fc6e..0000000 --- a/client/components/ui/input-group.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@client/lib/utils" -import { Button } from "@client/components/ui/button" -import { Input } from "@client/components/ui/input" -import { Textarea } from "@client/components/ui/textarea" - -function InputGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", - className - )} - {...props} - /> - ) -} - -const inputGroupAddonVariants = cva( - "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", - { - variants: { - align: { - "inline-start": - "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", - "inline-end": - "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", - "block-start": - "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", - "block-end": - "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", - }, - }, - defaultVariants: { - align: "inline-start", - }, - } -) - -function InputGroupAddon({ - className, - align = "inline-start", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
{ - if ((e.target as HTMLElement).closest("button")) { - return - } - e.currentTarget.parentElement?.querySelector("input")?.focus() - }} - {...props} - /> - ) -} - -const inputGroupButtonVariants = cva( - "flex items-center gap-2 text-sm shadow-none", - { - variants: { - size: { - xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", - sm: "", - "icon-xs": - "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", - "icon-sm": "size-8 p-0 has-[>svg]:p-0", - }, - }, - defaultVariants: { - size: "xs", - }, - } -) - -function InputGroupButton({ - className, - type = "button", - variant = "ghost", - size = "xs", - ...props -}: Omit, "size" | "type"> & - VariantProps & { - type?: "button" | "submit" | "reset" - }) { - return ( -