diff --git a/AGENT.md b/AGENT.md index dd75caf1..deef7513 100644 --- a/AGENT.md +++ b/AGENT.md @@ -58,6 +58,22 @@ When creating branches or Pull Requests via the `gh` CLI: ## Known Issues & Fixes +### ⚠️ CRITICAL — Never Hardcode Colors in Portal UI +**Issue:** New portal pages render unthemed — wrong background, invisible text, borders that vanish — because the markup carries literal hex values (`bg-[#12151D]`, `border-[#1E232F]`, `text-[#D0F237]`, `bg-[#ccff00]`) or Tailwind's default palette (`text-zinc-400`, `text-white`, `text-black`, `bg-white/[0.04]`). These are frozen dark-theme values: they ignore `--accent`, do not flip under `.light` / `[data-theme="light"]`, and drift from the design system the moment a token changes. +**Cause:** Copying an existing page as a starting point. Several older files still contain hardcoded hex, so the wrong pattern looks like the house style. **A hex value in a neighbouring file is NOT precedent — it is unconverted debt.** +**Fix & Best Practices:** +1. **Always use the semantic utility classes.** The theme is defined in `packages/components/src/styles.css` as CSS variables; the Tailwind utilities built on them are the only supported way to colour portal UI: + - Surfaces: `bg-background`, `bg-background-secondary`, `bg-surface`, `bg-surface-secondary`, `bg-overlay` + - Text: `text-foreground`, `text-muted`, `text-muted-foreground`, `text-accent`, `text-accent-foreground` + - Lines & rings: `border-border`, `border-accent`, `ring-accent`, `ring-focus` + - Status: `text-danger`, `text-success`, `bg-warning` (and their `border-*` / `bg-*` forms) +2. **Never write `text-white` / `text-black` / `text-zinc-*` / `bg-white/[0.04]`.** Map them: primary text → `text-foreground`, secondary text → `text-muted`, hover wash → `hover:bg-surface-secondary`, selected wash → `bg-accent/10`, hairline ring → `ring-border`. +3. **The lime accent is `--accent`, never a literal.** `#ccff00` / `#D0F237` → `bg-accent` with `text-accent-foreground` (the accent needs dark text for contrast; `text-accent-foreground` already encodes that). +4. **Prefer the component over restyling a native element.** A lime CTA is ` - - - - - - Create a project -

- Give it a name to get started — you can rename it later. -

-
-
- - - - - {error} - - - - - - -
-
-
-
- + ); } diff --git a/apps/portal/src/query/projectsQuery.ts b/apps/portal/src/query/projectsQuery.ts index a468813e..12dfbb33 100644 --- a/apps/portal/src/query/projectsQuery.ts +++ b/apps/portal/src/query/projectsQuery.ts @@ -2,9 +2,26 @@ import { type GetAllProjectsQueryParams, projectsService, } from "@/services/projects"; -import { type QueryClient, useQuery } from "@tanstack/react-query"; +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { z } from "zod"; export const projectsQuery = { + create: { + mutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ( + body: z.infer, + ) => projectsService.create(body), + onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", "list"] }), + }); + }, + }, getAll: { useQuery(query: GetAllProjectsQueryParams) { return useQuery({ diff --git a/apps/portal/src/routes/_authed/projects.new.tsx b/apps/portal/src/routes/_authed/projects.new.tsx new file mode 100644 index 00000000..92e994d0 --- /dev/null +++ b/apps/portal/src/routes/_authed/projects.new.tsx @@ -0,0 +1,557 @@ +import { useMemo, useState } from "react"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { + Button, + Checkbox, + Input, + Label, + LazyLoader, + ListBox, + Select, + TextArea, + TextField, + cn, + toast, +} from "@fluxify/components"; +import { + TbArrowLeft, + TbArrowRight, + TbCheck, + TbChevronLeft, + TbChevronRight, + TbSearch, +} from "react-icons/tb"; +import { authClient } from "@/lib/auth"; +import { authQuery } from "@/query/authQuery"; +import { projectsQuery } from "@/query/projectsQuery"; +import { showErrorNotification } from "@/lib/errorNotifier"; +import { createRouteHead } from "@/lib/seo"; +import { ROLES, type Role } from "@/components/common/RoleSelector"; + +// Same roles the project settings member list offers, shown as a dropdown here +// because each row already carries a name and a remove action. +const ROLE_OPTIONS = ROLES.map((role) => ({ id: role.id, label: role.title })); + +export const Route = createFileRoute("/_authed/projects/new")({ + head: createRouteHead( + "New Project", + "Create a project: name it, invite members and set its configuration.", + ), + // Same gate the New Project button honours on the home page — reaching this + // route by URL must not get further than clicking would. + beforeLoad: async () => { + const session = await authClient.getSession(); + if (!(session.data?.user as { isSystemAdmin?: boolean })?.isSystemAdmin) { + throw redirect({ to: "/", search: { tab: "projects" } }); + } + }, + component: CreateProjectPage, +}); + +type UserRow = { id: string; name: string | null; email: string }; +type Member = { userId: string; role: Role; label: string }; + +const STEPS = [ + { key: "basics", label: "Basics" }, + { key: "members", label: "Members" }, + { key: "config", label: "Configuration" }, +] as const; + +const STEP_COPY: Record< + (typeof STEPS)[number]["key"], + { title: string; description: string } +> = { + basics: { + title: "Name the project", + description: "How it appears in the project list. You can rename it later.", + }, + members: { + title: "Who can work on it", + description: + "Optional. Members and their roles are saved with the project — you can change them any time in project settings.", + }, + config: { + title: "Set the project's configuration", + description: + "Optional. Integrations, app configs and routes are set up inside the project once it exists.", + }, +}; + +function initialsOf(name: string | null, email: string) { + if (!name) return email.substring(0, 2).toUpperCase(); + const parts = name.trim().split(" "); + if (parts.length > 1) return (parts[0][0] + parts[1][0]).toUpperCase(); + return name.substring(0, 2).toUpperCase(); +} + +function CreateProjectPage() { + const navigate = useNavigate(); + const create = projectsQuery.create.mutation(); + + const [step, setStep] = useState(0); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [members, setMembers] = useState([]); + const [workerTimeouts, setWorkerTimeouts] = useState(false); + + // `projects.name` is varchar(50) and the API rejects anything longer, so the + // field has to stop the user rather than let the request fail. + const basicsValid = name.trim().length >= 2 && name.trim().length <= 50; + + const isLast = step === STEPS.length - 1; + const currentKey = STEPS[step].key; + + function submit() { + create.mutate( + { + name: name.trim(), + description: description.trim() || undefined, + members: members.length + ? members.map(({ userId, role }) => ({ userId, role })) + : undefined, + settings: { + "experimental.workerTimeouts.enabled": workerTimeouts + ? "true" + : "false", + }, + }, + { + onSuccess: (created) => { + toast.success("Project created"); + navigate({ to: "/$projectId", params: { projectId: created.id } }); + }, + onError: (error) => showErrorNotification(error as Error), + }, + ); + } + + return ( +
+
+ +
+

+ Create a project +

+

+ Name it, pick who works on it, and set how it runs. +

+
+
+ + + +
+
+

+ Step {step + 1} of {STEPS.length} +

+

+ {STEP_COPY[currentKey].title} +

+

+ {STEP_COPY[currentKey].description} +

+
+ +
+ {currentKey === "basics" && ( +
+ 50} + > + + +

+ 2 to 50 characters. Must be unique across the instance. +

+
+ + + +