diff --git a/.github/scripts/ci-node.sh b/.github/scripts/ci-node.sh new file mode 100644 index 0000000..7a42157 --- /dev/null +++ b/.github/scripts/ci-node.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +npm run build +test -f dist/index.html +npx --no-install playwright install --with-deps chromium +CHROME_BIN="$(node --input-type=module -e "import { chromium } from '@playwright/test'; process.stdout.write(chromium.executablePath())")" +export CHROME_BIN +"$CHROME_BIN" --version +npm test diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index 4ad27ed..456f480 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -12,25 +12,39 @@ permissions: contents: read jobs: + web: + uses: FastR-D/.github/.github/workflows/node-npm-check.yml@98eac6706014d070c5563eb75e3899611507f176 + with: + working-directory: web + validate: + needs: web runs-on: ubuntu-24.04 timeout-minutes: 45 concurrency: group: validate-fasttask-${{ github.ref }} cancel-in-progress: true + env: + GOWORK: 'off' + CGO_ENABLED: '1' steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.26.8' + cache: true + - run: go mod download + - run: go mod verify - uses: actions/setup-node@v4 with: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json - run: go test ./... - - run: CGO_ENABLED=1 go build -trimpath -o bin/fasttask ./cmd/fasttask - - run: npm ci && npm test && npm run build && npm run build:sidecar && npm prune --omit=dev --ignore-scripts + - run: go build -trimpath -o bin/fasttask ./cmd/fasttask + # The reusable web gate tests this revision before the release job rebuilds + # its locked web/sidecar runtime and prunes development-only dependencies. + - run: npm ci && npm run build && npm run build:sidecar && npm prune --omit=dev --ignore-scripts working-directory: web - name: Checkout pinned FastDeploy packager uses: actions/checkout@v4 @@ -50,7 +64,7 @@ jobs: deploy: needs: validate - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 with: app: fasttask diff --git a/contracts/fastresearch-today/v1/README.md b/contracts/fastresearch-today/v1/README.md new file mode 100644 index 0000000..e4fdb29 --- /dev/null +++ b/contracts/fastresearch-today/v1/README.md @@ -0,0 +1,54 @@ +# FastResearch today v1 + +Owner: FastTask. `POST /api/v1/integrations/fastresearch/today`, JSON, `Cache-Control: no-store`. +The path version is the contract release identifier, not a required body field. + +Primary identity: FastCAS client_credentials, scope `integrations:fastresearch`. +Issuer registration must issue the Task audience (default `fasttask-api`); +the SDK grant does not accept an arbitrary audience override. Task verifies the +signature, issuer, audience, expiry, access token_use, service=true, sub=client_id, +client allowlist and each normalized receiver's exact allowlist entry. + +Legacy identity remains an actual supported path: `X-API-Key` (or existing +`X-FastResearch-Provision-Key`) only when `FASTTASK_FASTRESEARCH_LEGACY_INGEST_KEY_ENABLED=true` +and the configured ingest key matches. Never send it with Authorization; never +retry a rejected Bearer using the key. Credentials are server-side only. + +`request.schema.json` describes raw wire input; normalization takes the first 200 +unique nonempty trimmed refs, preserving order. Empty normalized lists return +400. `cases.json` supplies synthetic fixtures for both HTTP receiver and Research +client tests. Unknown receivers have found=false, items=[], zero progress. +Known receivers include user/date/plan metadata and items. A per-member `error` +may accompany lookup/refresh failures; HTTP 200 alone is not a complete-member +success. RefreshSchedule may derive today's plan; this API does not create or +complete underlying tasks. Counts reflect satisfied/in_progress/skipped/planned. + +The positive fixture maps `account-a` to an active user, a frozen current UTC +plan at revision 3, and three real task/item rows: in_progress, satisfied and +planned. The receiver test seeds domain rows in SQLite; it does not inject HTTP +responses or replace RefreshSchedule/PlanItemViews. It compares the entire wire +response, including Task IDs, commitments, completion metadata, counts and the +FastRead/FastWrite workspace projections. `missing-account` stays unknown. +`$today` is replaced with the current UTC calendar date by both runners; stable +IDs and the fixed updated_at remain exact assertions. `research_today` records +the expected Research member projection, including snake_case to camelCase +fields and null workspace for an unclassified task. + +The mapped `outside` account is deliberately absent from the service allowlist: +service queries return 403, while the explicitly enabled legacy shared key can +still read it. Legacy has its existing integration-wide authority, not the +service client's receiver restriction. + +Run `GOWORK=off go test ./internal/httpapi -run TestFastResearchTodayWireContract -count=1`. +For a local cross-product run, also set `FASTRESEARCH_CONTRACT_PROBE` to the +absolute Research `scripts/fasttask-today-contract.mjs` path. This makes the same +receiver test run the installed npm SDK through Research's production factory, +against live loopback Task and a disposable signed OIDC issuer, then starts the +actual Research server and queries its member today route with service-only +configuration. The issuer counts grants to verify token reuse. Ordinary product +CI needs only its own checkout and registry dependencies. + +Research vendors this fixture under `contracts/fastresearch-today/v1`; updates +must be copied from this receiver-owned directory and reviewed on both sides. +No tasks/progress/accounts endpoint is promised by this contract. These two ends +are not evidence of two independent sending products or a deployed FastCAS run. diff --git a/contracts/fastresearch-today/v1/cases.json b/contracts/fastresearch-today/v1/cases.json new file mode 100644 index 0000000..614a970 --- /dev/null +++ b/contracts/fastresearch-today/v1/cases.json @@ -0,0 +1,45 @@ +{ + "request": {"receiver_refs": [" account-a ", "account-a", "", "missing-account"]}, + "normalized_refs": ["account-a", "missing-account"], + "response": {"members": [ + { + "receiver_ref": "account-a", "found": true, + "user_id": "task-user-a", "display_name": "Research member", + "local_date": "$today", "timezone": "UTC", "plan_id": "today-plan-a", + "plan_status": "active", "plan_revision": 3, "updated_at": "2026-11-10T00:00:00Z", + "items": [ + {"id": "item-read", "task_id": "task-read", "kind": "core", "title": "fastread: Read evidence", "commitment": "Extract two claims", "minimum_action": "Read the abstract", "status": "in_progress", "target_minutes": 30, "position": 1, "workspace": {"system": "fastread", "label": "FastRead", "url": "https://fastrd.io/read/"}}, + {"id": "item-write", "task_id": "task-write", "kind": "core", "title": "fastwrite: Draft findings", "commitment": "Save a findings draft", "minimum_action": "Write one paragraph", "status": "satisfied", "completion_type": "result", "completion_summary": "Draft saved", "target_minutes": 45, "position": 2, "workspace": {"system": "fastwrite", "label": "FastWrite", "url": "https://fastrd.io/write/"}}, + {"id": "item-review", "task_id": "task-review", "kind": "core", "title": "Review evidence", "commitment": "Check the claim list", "minimum_action": "Open the checklist", "status": "planned", "target_minutes": 45, "position": 3} + ], + "progress": {"total": 3, "completed": 1, "in_progress": 1, "planned": 1, "skipped": 0} + }, + {"receiver_ref": "missing-account", "found": false, "items": [], "progress": {"total": 0, "completed": 0, "in_progress": 0, "planned": 0, "skipped": 0}} + ]}, + "research_today": { + "received": true, "date": "$today", "timezone": "UTC", "planStatus": "active", "planRevision": 3, "updatedAt": "2026-11-10T00:00:00Z", + "items": [ + {"id": "item-read", "taskId": "task-read", "kind": "core", "title": "fastread: Read evidence", "commitment": "Extract two claims", "minimumAction": "Read the abstract", "status": "in_progress", "completionType": "", "completionSummary": "", "targetMinutes": 30, "position": 1, "workspace": {"system": "fastread", "label": "FastRead", "url": "https://fastrd.io/read/"}}, + {"id": "item-write", "taskId": "task-write", "kind": "core", "title": "fastwrite: Draft findings", "commitment": "Save a findings draft", "minimumAction": "Write one paragraph", "status": "satisfied", "completionType": "result", "completionSummary": "Draft saved", "targetMinutes": 45, "position": 2, "workspace": {"system": "fastwrite", "label": "FastWrite", "url": "https://fastrd.io/write/"}}, + {"id": "item-review", "taskId": "task-review", "kind": "core", "title": "Review evidence", "commitment": "Check the claim list", "minimumAction": "Open the checklist", "status": "planned", "completionType": "", "completionSummary": "", "targetMinutes": 45, "position": 3, "workspace": null} + ], + "progress": {"total": 3, "completed": 1, "inProgress": 1, "planned": 1, "skipped": 0}, "error": "" + }, + "auth_cases": [ + {"name": "service", "status": 200}, + {"name": "legacy", "status": 200}, + {"name": "mixed", "status": 400}, + {"name": "missing", "status": 401}, + {"name": "wrong-scope", "status": 401}, + {"name": "wrong-audience", "status": 401}, + {"name": "wrong-issuer", "status": 401}, + {"name": "wrong-client", "status": 401}, + {"name": "non-service", "status": 401}, + {"name": "wrong-subject", "status": 401}, + {"name": "expired", "status": 401}, + {"name": "wrong-signature", "status": 401}, + {"name": "wrong-token-use", "status": 401}, + {"name": "denied-receiver", "status": 403}, + {"name": "service-disabled", "status": 503} + ] +} diff --git a/contracts/fastresearch-today/v1/request.schema.json b/contracts/fastresearch-today/v1/request.schema.json new file mode 100644 index 0000000..6fe8ae9 --- /dev/null +++ b/contracts/fastresearch-today/v1/request.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FastResearch today wire request", + "type": "object", + "required": ["receiver_refs"], + "properties": {"receiver_refs": {"type": "array", "items": {"type": "string"}}}, + "description": "Receiver trims whitespace, drops empty/duplicate values and takes the first 200 unique refs. At least one normalized ref is required. Unknown properties are ignored." +} diff --git a/docker-compose.yml b/docker-compose.yml index 357111e..b3a0dfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,9 +29,7 @@ services: fasttask: build: context: . - # Pinned in the Dockerfile; override only to track a different FastCAS SDK commit. args: - FASTCAS_REF: ${FASTCAS_REF:-676d49b18f700c009f1f491280a93de7da294836} # The default module proxy is not reachable from this network; the host's own Go # toolchain is configured the same way (go env GOPROXY). GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} diff --git a/go.mod b/go.mod index 944ea5f..e83982a 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/coreos/go-oidc/v3 v3.21.0 + github.com/coreos/go-oidc/v3 v3.21.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/internal/httpapi/fastresearch_contract_test.go b/internal/httpapi/fastresearch_contract_test.go new file mode 100644 index 0000000..65554d6 --- /dev/null +++ b/internal/httpapi/fastresearch_contract_test.go @@ -0,0 +1,232 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/FastR-D/FastTask/internal/application" + "github.com/FastR-D/FastTask/internal/config" + "github.com/FastR-D/FastTask/internal/persistence" + platformauth "github.com/FastR-D/FastTask/internal/platform/auth" + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" +) + +func TestFastResearchTodayWireContract(t *testing.T) { + var fixture struct { + Request json.RawMessage `json:"request"` + Response map[string]any `json:"response"` + AuthCases []struct { + Name string `json:"name"` + Status int `json:"status"` + } `json:"auth_cases"` + } + raw, err := os.ReadFile("../../contracts/fastresearch-today/v1/cases.json") + if err != nil { + t.Fatal(err) + } + if err = json.Unmarshal([]byte(strings.ReplaceAll(string(raw), "$today", persistence.Now().UTC().Format("2006-01-02"))), &fixture); err != nil { + t.Fatal(err) + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var issuer *httptest.Server + var grants atomic.Int32 + token := func(name string) string { + claims := jwt.MapClaims{"iss": issuer.URL, "sub": "research-service", "aud": []string{"fasttask-api"}, "client_id": "research-service", "scope": "integrations:fastresearch", "service": true, "token_use": "access", "exp": time.Now().Add(time.Minute).Unix(), "iat": time.Now().Unix()} + signingKey := key + switch name { + case "wrong-scope": + claims["scope"] = "openid" + case "wrong-audience": + claims["aud"] = []string{"research-api"} + case "wrong-issuer": + claims["iss"] = "https://other.invalid" + case "wrong-client": + claims["sub"] = "other" + claims["client_id"] = "other" + case "non-service": + claims["service"] = false + case "wrong-subject": + claims["sub"] = "user-subject" + case "expired": + claims["exp"] = time.Now().Add(-time.Minute).Unix() + case "wrong-signature": + signingKey = otherKey + case "wrong-token-use": + claims["token_use"] = "id" + } + value := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + value.Header["kid"] = "fixture" + signed, signErr := value.SignedString(signingKey) + if signErr != nil { + t.Fatal(signErr) + } + return signed + } + issuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/.well-known/openid-configuration": + json.NewEncoder(w).Encode(map[string]any{"issuer": issuer.URL, "authorization_endpoint": issuer.URL + "/authorize", "token_endpoint": issuer.URL + "/token", "jwks_uri": issuer.URL + "/jwks", "userinfo_endpoint": issuer.URL + "/userinfo", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic"}}) + case "/jwks": + json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "fixture", Algorithm: "RS256", Use: "sig"}}}) + case "/token": + grants.Add(1) + id, secret, ok := r.BasicAuth() + id, _ = url.QueryUnescape(id) + secret, _ = url.QueryUnescape(secret) + if err := r.ParseForm(); err != nil || !ok || id != "research-service" || secret != "disposable-secret" || r.Form.Get("grant_type") != "client_credentials" || r.Form.Get("scope") != "integrations:fastresearch" { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_client"}) + return + } + json.NewEncoder(w).Encode(map[string]any{"access_token": token("service"), "token_type": "Bearer", "expires_in": 60}) + default: + http.NotFound(w, r) + } + })) + defer issuer.Close() + store, err := persistence.Open(filepath.Join(t.TempDir(), "today.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err = store.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + seedFastResearchTodayContract(t, store) + cfg := config.Config{PublicURL: "http://localhost", JWTSecret: strings.Repeat("s", 40), WebDist: t.TempDir(), FastCASServiceEnabled: true, FastCASServiceIssuer: issuer.URL, FastCASServiceClientID: "task", FastCASServiceAudience: "fasttask-api", FastCASServiceLoopbackHTTP: true, FastCASServiceAllowedClientIDs: []string{"research-service"}, FastCASServiceAllowedReceiverRefs: []string{"account-a", "missing-account"}, FastResearchLegacyIngestKeyEnabled: true, FastResearchIngestKey: "disposable-key"} + server := New(application.New(store), platformauth.New(store, cfg), cfg, nil) + for _, tc := range fixture.AuthCases { + t.Run(tc.Name, func(t *testing.T) { + body := string(fixture.Request) + if tc.Name == "denied-receiver" { + body = `{"receiver_refs":["outside"]}` + } + request := httptest.NewRequest("POST", "/api/v1/integrations/fastresearch/today", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + if tc.Name != "legacy" && tc.Name != "missing" { + request.Header.Set("Authorization", "Bearer "+token(tc.Name)) + } + if tc.Name == "legacy" || tc.Name == "mixed" { + request.Header.Set("X-API-Key", cfg.FastResearchIngestKey) + } + server.cfg.FastCASServiceEnabled = tc.Name != "service-disabled" + if tc.Name == "service-disabled" { + server.auth = platformauth.New(store, server.cfg) + } + defer func() { server.cfg = cfg; server.auth = platformauth.New(store, cfg) }() + response := httptest.NewRecorder() + server.Engine.ServeHTTP(response, request) + if response.Code != tc.Status { + t.Fatalf("status=%d want=%d: %s", response.Code, tc.Status, response.Body.String()) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("missing no-store") + } + if tc.Status == 200 { + var actual map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &actual); err != nil || !reflect.DeepEqual(actual, fixture.Response) { + t.Fatalf("response mismatch: %s (%v)", response.Body.String(), err) + } + } + }) + } + for _, body := range []string{`{"receiver_refs":[]}`, `{"receiver_refs":[" "]}`, `{"receiver_refs":42}`} { + r := httptest.NewRequest("POST", "/api/v1/integrations/fastresearch/today", strings.NewReader(body)) + r.Header.Set("X-API-Key", cfg.FastResearchIngestKey) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + server.Engine.ServeHTTP(w, r) + if w.Code != 400 { + t.Fatalf("invalid refs status=%d", w.Code) + } + } + t.Run("legacy-outside-service-allowlist", func(t *testing.T) { + request := httptest.NewRequest("POST", "/api/v1/integrations/fastresearch/today", strings.NewReader(`{"receiver_refs":["outside"]}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-API-Key", cfg.FastResearchIngestKey) + response := httptest.NewRecorder() + server.Engine.ServeHTTP(response, request) + var actual struct { + Members []fastResearchTodayMember `json:"members"` + } + if err := json.Unmarshal(response.Body.Bytes(), &actual); err != nil || response.Code != 200 || len(actual.Members) != 1 || !actual.Members[0].Found || actual.Members[0].UserID != "task-user-outside" { + t.Fatalf("legacy authorization changed: %d %s (%v)", response.Code, response.Body.String(), err) + } + }) + // Optional local topology joins the real Go receiver to the actual Research + // production client. Product-only CI still runs all wire cases above. + if probe := os.Getenv("FASTRESEARCH_CONTRACT_PROBE"); probe != "" { + task := httptest.NewServer(server.Engine) + defer task.Close() + command := exec.Command("node", probe) + command.Env = append(os.Environ(), "FASTTASK_URL="+task.URL, "FASTTASK_SERVICE_ISSUER="+issuer.URL, "FASTTASK_SERVICE_CLIENT_ID=research-service", "FASTTASK_SERVICE_CLIENT_SECRET=disposable-secret", "FASTTASK_SERVICE_ALLOW_LOOPBACK_HTTP=true", "FASTTASK_API_KEY=disposable-key") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Research consumer: %v\n%s", err, output) + } else { + t.Log(string(output)) + } + if grants.Load() != 2 { + t.Fatalf("want one grant per Research client (probe and production server), got %d", grants.Load()) + } + } +} + +func seedFastResearchTodayContract(t *testing.T, store *persistence.Store) { + t.Helper() + // Seed domain rows, never a pre-serialized HTTP response or a mocked plan + // projection. The real receiver must resolve the mapping and refresh the plan. + stamp := time.Date(2026, time.November, 10, 0, 0, 0, 0, time.UTC) + user := persistence.User{ID: "task-user-a", Identifier: "today-member", PasswordHash: "unused", DisplayName: "Research member", Timezone: "UTC", Locale: "en", Role: "member", Status: "active", Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + outside := user + outside.ID, outside.Identifier, outside.DisplayName = "task-user-outside", "outside-member", "Outside member" + goal := persistence.Goal{ID: "today-goal-a", UserID: user.ID, Title: "Research evidence", SuccessCriteria: "Review findings", Status: "active", Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + plan := persistence.DailyPlan{ID: "today-plan-a", UserID: user.ID, LocalDate: persistence.Now().UTC().Format("2006-01-02"), Timezone: "UTC", Status: "active", AlgorithmVersion: "deterministic-v3", CurrentRevision: 3, Revision: 3, FrozenAt: &stamp, CreatedAt: stamp, UpdatedAt: stamp} + for _, row := range []any{ + &user, &outside, + &persistence.FastResearchAccount{AccountID: "account-a", UserID: user.ID, KeyID: "fixture-key", Person: user.DisplayName, CreatedAt: stamp, UpdatedAt: stamp}, + &persistence.FastResearchAccount{AccountID: "outside", UserID: outside.ID, KeyID: "outside-key", Person: outside.DisplayName, CreatedAt: stamp, UpdatedAt: stamp}, + &goal, &plan, + } { + if err := store.DB.Create(row).Error; err != nil { + t.Fatal(err) + } + } + for index, row := range []struct { + id, title, commitment, minimum, status, completionType, completionSummary string + minutes int + }{ + {"read", "fastread: Read evidence", "Extract two claims", "Read the abstract", "in_progress", "", "", 30}, + {"write", "fastwrite: Draft findings", "Save a findings draft", "Write one paragraph", "satisfied", "result", "Draft saved", 45}, + {"review", "Review evidence", "Check the claim list", "Open the checklist", "planned", "", "", 45}, + } { + task := persistence.Task{ID: "task-" + row.id, UserID: user.ID, GoalID: goal.ID, Type: "action", Title: row.title, Status: "ready", SuccessCriteria: row.commitment, MinimumAction: row.minimum, EstimateMinutes: row.minutes, Position: index + 1, Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + item := persistence.DailyPlanItem{ID: "item-" + row.id, LogicalID: "item-" + row.id, UserID: user.ID, PlanID: plan.ID, TaskID: &task.ID, Kind: "core", Title: row.title, Commitment: row.commitment, MinimumAction: row.minimum, AllowedTypes: "result,step,time,minimum_action", Status: row.status, CompletionType: row.completionType, CompletionSummary: row.completionSummary, PlanRevision: 3, TargetMinutes: row.minutes, Position: index + 1, Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + for _, model := range []any{&task, &item} { + if err := store.DB.Create(model).Error; err != nil { + t.Fatal(err) + } + } + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..12b46b8 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +/test-results/ diff --git a/web/index.html b/web/index.html index fe139ac..8c6a4a1 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,10 @@ - +
- + diff --git a/web/package-lock.json b/web/package-lock.json index 1b357a6..a19e5b3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@ai-sdk/openai-compatible": "3.0.53", "@ai-sdk/provider": "4.0.17", "@assistant-ui/react": "^0.15.21", + "@fastrd/ui-foundation": "0.1.2", "@fontsource/material-icons": "^5.3.0", "@vitejs/plugin-react": "latest", "assistant-stream": "^0.3.44", @@ -25,6 +26,7 @@ "vite": "latest" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@types/node": "26.6.2", "@types/react": "latest", "@types/react-dom": "latest", @@ -1767,6 +1769,12 @@ "node": ">=6.9.0" } }, + "node_modules/@fastrd/ui-foundation": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@fastrd/ui-foundation/-/ui-foundation-0.1.2.tgz", + "integrity": "sha512-Jobx7DanQOejVRFfsT5DFXND7rtcvSUzZ5Zui+lY3oAuretz8vwkzqHh1Gd0+ezJubw82WeFsnsXdvbWB4t1YA==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -1977,6 +1985,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -8232,6 +8256,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 5ee7429..cafba23 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "test:panel": "node --test tests/panel-*.test.mjs" }, "dependencies": { + "@fastrd/ui-foundation": "0.1.2", "@ai-sdk/openai-compatible": "3.0.53", "@ai-sdk/provider": "4.0.17", "@assistant-ui/react": "^0.15.21", @@ -29,6 +30,7 @@ "vite": "latest" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@types/node": "26.6.2", "@types/react": "latest", "@types/react-dom": "latest", diff --git a/web/public/theme-init.js b/web/public/theme-init.js deleted file mode 100644 index 90882ee..0000000 --- a/web/public/theme-init.js +++ /dev/null @@ -1,25 +0,0 @@ -// Apply the saved choices before the first paint so neither colour mode nor visual theme flashes. -// Keep the keys, class names and colours in sync with src/theme.ts. -;(() => { - try { - let stored - try { stored = localStorage.getItem('fasttask.theme') } catch {} - const preference = stored === 'light' || stored === 'dark' ? stored : 'system' - const dark = preference === 'dark' || - (preference === 'system' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) - const root = document.documentElement - let visualTheme - try { visualTheme = localStorage.getItem('fasttask.visual-theme') } catch {} - if (visualTheme !== 'classic' && visualTheme !== 'workbench') visualTheme = 'workbench' - if (root.dataset) root.dataset.ftTheme = visualTheme - root.classList.remove('mdui-theme-auto', 'mdui-theme-light', 'mdui-theme-dark') - root.classList.add(preference === 'system' ? 'mdui-theme-auto' : `mdui-theme-${preference}`) - const metas = [...document.querySelectorAll('meta[name="theme-color"]')] - const target = metas.shift() || document.head.appendChild(Object.assign(document.createElement('meta'), { name: 'theme-color' })) - target.removeAttribute('media') - target.setAttribute('content', dark ? '#10161e' : '#f7f8fa') - metas.forEach(meta => meta.remove()) - } catch { - // Storage or matchMedia unavailable: the default mdui-theme-auto class still follows the system. - } -})() diff --git a/web/src/Gantt.tsx b/web/src/GanttView.tsx similarity index 100% rename from web/src/Gantt.tsx rename to web/src/GanttView.tsx diff --git a/web/src/GoalManager.tsx b/web/src/GoalManager.tsx index 98a6092..306e066 100644 --- a/web/src/GoalManager.tsx +++ b/web/src/GoalManager.tsx @@ -4,7 +4,7 @@ import { createCreationAttempt } from './creation-attempt' import { SESSION_OWNER_CHANGED } from './session-owner' import { ProposalSummary } from './agent/tools/ProposalApproval' import { GoalMapView } from './Lens' -import { GanttView } from './Gantt' +import { GanttView } from './GanttView' import { buildGanttRows, plannedDateError } from './gantt' import { fieldValue, useMduiEvent } from './mdui-react' import { waitJob } from './jobs' diff --git a/web/src/ThemeControl.tsx b/web/src/ThemeControl.tsx index 726ffc4..3fdc64f 100644 --- a/web/src/ThemeControl.tsx +++ b/web/src/ThemeControl.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { fieldValue } from './mdui-react' import { - announceThemeChange, announceVisualThemeChange, isVisualTheme, isThemePreference, @@ -56,7 +55,6 @@ function useThemePreference(): [ThemePreference, (value: ThemePreference) => voi function choose(value: ThemePreference) { setThemePreference(value) setPreference(value) - announceThemeChange(value) } return [preference, choose] } diff --git a/web/src/main.tsx b/web/src/main.tsx index d883e6c..fad8cac 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,8 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './mdui' +import '@fastrd/ui-foundation/tokens.css' +import '@fastrd/ui-foundation/mdui.css' import { App } from './App' import './styles.css' import './workbench-theme.css' diff --git a/web/src/styles.css b/web/src/styles.css index 860a94d..fec232a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -12,87 +12,36 @@ --ft-space-5:1.5rem; /* 24px — between groups */ --ft-space-6:2rem; /* 32px — between sections */ } -/* FastWrite's blue/slate palette, mapped onto MDUI's existing theme contract. - Keep the semantic error and completion colours independent of the brand. */ -:root{ - --mdui-color-primary-light:21,95,184; - --mdui-color-on-primary-light:255,255,255; - --mdui-color-primary-container-light:219,234,254; - --mdui-color-on-primary-container-light:17,61,112; - --mdui-color-inverse-primary-light:100,168,238; - --mdui-color-secondary-light:53,64,82; - --mdui-color-on-secondary-light:255,255,255; - --mdui-color-secondary-container-light:219,234,254; - --mdui-color-on-secondary-container-light:17,61,112; - --mdui-color-background-light:247,248,250; - --mdui-color-on-background-light:21,28,40; - --mdui-color-surface-light:255,255,255; - --mdui-color-surface-dim-light:223,227,232; - --mdui-color-surface-bright-light:255,255,255; - --mdui-color-surface-container-lowest-light:255,255,255; - --mdui-color-surface-container-low-light:252,252,253; - --mdui-color-surface-container-light:239,241,244; - --mdui-color-surface-container-high-light:223,227,232; - --mdui-color-surface-container-highest-light:203,209,217; - --mdui-color-surface-variant-light:239,241,244; - --mdui-color-on-surface-light:21,28,40; - --mdui-color-on-surface-variant-light:75,85,101; - --mdui-color-inverse-surface-light:35,45,61; - --mdui-color-inverse-on-surface-light:244,247,250; - --mdui-color-outline-light:146,155,168; - --mdui-color-outline-variant-light:223,227,232; - --mdui-color-surface-tint-color-light:21,95,184; - --mdui-color-primary-dark:100,168,238; - --mdui-color-on-primary-dark:16,36,61; - --mdui-color-primary-container-dark:22,56,95; - --mdui-color-on-primary-container-dark:183,220,255; - --mdui-color-inverse-primary-dark:21,95,184; - --mdui-color-secondary-dark:182,193,206; - --mdui-color-on-secondary-dark:35,45,61; - --mdui-color-secondary-container-dark:22,56,95; - --mdui-color-on-secondary-container-dark:183,220,255; - --mdui-color-background-dark:16,22,30; - --mdui-color-on-background-dark:244,247,250; - --mdui-color-surface-dark:23,30,39; - --mdui-color-surface-dim-dark:17,22,30; - --mdui-color-surface-bright-dark:42,53,67; - --mdui-color-surface-container-lowest-dark:17,22,30; - --mdui-color-surface-container-low-dark:21,27,36; - --mdui-color-surface-container-dark:28,37,48; - --mdui-color-surface-container-high-dark:42,53,67; - --mdui-color-surface-container-highest-dark:59,72,89; - --mdui-color-surface-variant-dark:42,53,67; - --mdui-color-on-surface-dark:244,247,250; - --mdui-color-on-surface-variant-dark:182,193,206; - --mdui-color-inverse-surface-dark:244,247,250; - --mdui-color-inverse-on-surface-dark:21,28,40; - --mdui-color-outline-dark:130,144,163; - --mdui-color-outline-variant-dark:59,72,89; - --mdui-color-surface-tint-color-dark:100,168,238; - --ft-color-action-fill-light:21,95,184; - --ft-color-action-hover-light:16,74,145; - --ft-color-focus-light:40,120,208; - --ft-color-action-fill-dark:14,99,156; - --ft-color-action-hover-dark:17,119,187; - --ft-color-focus-dark:91,163,234; - --ft-color-on-action:255,255,255; -} -:root,.mdui-theme-light{ - --ft-color-action-fill:var(--ft-color-action-fill-light); - --ft-color-action-hover:var(--ft-color-action-hover-light); - --ft-color-focus:var(--ft-color-focus-light); -} -.mdui-theme-dark{ - --ft-color-action-fill:var(--ft-color-action-fill-dark); - --ft-color-action-hover:var(--ft-color-action-hover-dark); - --ft-color-focus:var(--ft-color-focus-dark); -} -@media(prefers-color-scheme:dark){ - .mdui-theme-auto{ - --ft-color-action-fill:var(--ft-color-action-fill-dark); - --ft-color-action-hover:var(--ft-color-action-hover-dark); - --ft-color-focus:var(--ft-color-focus-dark); - } +/* Remaining MDUI roles used by classic/workbench. Palette and mode come from foundation. */ +.fastui-mdui{ + /* MDUI uses primary for text too; filled actions keep the separate primary-fill role below. */ + --mdui-color-primary:var(--fastui-focus-rgb); + --mdui-color-secondary:var(--fastui-secondary-rgb); + --mdui-color-on-secondary:var(--fastui-surface-rgb); + --mdui-color-secondary-container:var(--fastui-selected-rgb); + --mdui-color-on-secondary-container:var(--fastui-text-rgb); + --mdui-color-tertiary:var(--fastui-success-rgb); + --mdui-color-on-tertiary:var(--fastui-surface-rgb); + --mdui-color-tertiary-container:var(--fastui-raised-rgb); + --mdui-color-on-tertiary-container:var(--fastui-success-rgb); + --mdui-color-on-error:var(--fastui-surface-rgb); + --mdui-color-error-container:var(--fastui-raised-rgb); + --mdui-color-on-error-container:var(--fastui-danger-rgb); + --mdui-color-surface-dim:var(--fastui-workspace-rgb); + --mdui-color-surface-bright:var(--fastui-surface-rgb); + --mdui-color-surface-container-lowest:var(--fastui-page-rgb); + --mdui-color-surface-container-low:var(--fastui-surface-rgb); + --mdui-color-surface-container-high:var(--fastui-border-rgb); + --mdui-color-surface-container-highest:var(--fastui-border-strong-rgb); + --mdui-color-surface-variant:var(--fastui-raised-rgb); + --mdui-color-inverse-surface:var(--fastui-text-rgb); + --mdui-color-inverse-on-surface:var(--fastui-surface-rgb); + --mdui-color-inverse-primary:var(--fastui-focus-rgb); + --mdui-color-surface-tint-color:var(--fastui-primary-rgb); + --ft-color-action-fill:var(--fastui-primary-rgb); + --ft-color-action-hover:var(--fastui-primary-hover-rgb); + --ft-color-focus:var(--fastui-focus-rgb); + --ft-color-on-action:var(--fastui-on-primary-rgb); } /* FastWrite uses a separate dark fill so white button labels stay legible, while links and focus rings can use the brighter blue text palette. */ diff --git a/web/src/theme-init.ts b/web/src/theme-init.ts new file mode 100644 index 0000000..56bf14e --- /dev/null +++ b/web/src/theme-init.ts @@ -0,0 +1,7 @@ +import { initColorMode } from '@fastrd/ui-foundation' +import { applyVisualTheme, syncTheme, THEME_STORAGE_KEY } from './theme' + +// Bundled as a blocking classic script: the installed foundation controller exists before paint. +window.fastTaskColorMode = initColorMode({ storageKey: THEME_STORAGE_KEY }) +applyVisualTheme() +window.fastTaskColorMode.subscribe(syncTheme) diff --git a/web/src/theme.ts b/web/src/theme.ts index e202c70..3aeb188 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,201 +1,80 @@ -// Theme preference for the web client. -// -// The app follows the operating system by default. A pin to light or dark is expressed the way -// MDUI expects it: a class on that rebinds its semantic colour tokens. The boot script in -// index.html applies the same rule before React mounts, so a pinned light theme never starts with a -// dark first paint. Keep the storage key, the class names and the palette colours in sync with it. +import type { initColorMode } from '@fastrd/ui-foundation' export type ThemePreference = 'system' | 'light' | 'dark' export type ResolvedTheme = 'light' | 'dark' export type VisualTheme = 'workbench' | 'classic' - export const THEME_STORAGE_KEY = 'fasttask.theme' export const VISUAL_THEME_STORAGE_KEY = 'fasttask.visual-theme' - -// ThemeControl listens for this event so the rail and the drawer controls cannot disagree after one -// of them changes the setting. It also carries the new value to every mounted control. export const THEME_CHANGE_EVENT = 'fasttask:theme' export const VISUAL_THEME_CHANGE_EVENT = 'fasttask:visual-theme' -const PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark'] -const VISUAL_THEMES: readonly VisualTheme[] = ['workbench', 'classic'] -const THEME_CLASSES = ['mdui-theme-auto', 'mdui-theme-light', 'mdui-theme-dark'] - -// Background colours of the two palettes in styles.css. The browser paints its address bar and the -// installed app's status bar with this value, so it has to follow the resolved theme, not the stored -// preference: "system + dark OS" is a dark chrome and a dark page. -export const THEME_COLORS: Record