From 5f933e163c6da4f047d0028786e123d168953bf4 Mon Sep 17 00:00:00 2001
From: Wouter Lem <32102935+maanlamp@users.noreply.github.com>
Date: Thu, 7 May 2026 13:33:04 +0200
Subject: [PATCH 01/29] feat: localised page title
---
.claude/CLAUDE.md | 17 ++-
.claude/settings.local.json | 9 ++
messages/en-US.json | 2 +
messages/nl-NL.json | 2 +
package.json | 3 +-
src/components/header/app-header.tsx | 25 ++++
src/lib/title-state.tsx | 171 +++++++++++++++++++++++++++
src/lib/title.ts | 24 ++++
src/routes/__root.tsx | 28 +++--
src/routes/_app/index.tsx | 6 +-
src/routes/_auth/forgot-password.tsx | 6 +-
src/routes/_auth/login.tsx | 9 +-
12 files changed, 273 insertions(+), 29 deletions(-)
create mode 100644 .claude/settings.local.json
create mode 100644 src/lib/title-state.tsx
create mode 100644 src/lib/title.ts
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index b2983e9..e947573 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -12,7 +12,7 @@ You are working in a production TypeScript + React repository. Make safe, minima
| --------------- | ------------------------------------------------------------------- |
| Package manager | `bun` |
| Build | Vite |
-| Framework | React 19 + TypeScript 5 |
+| Framework | React 19 + TypeScript 5.9 |
| Routing | `@tanstack/react-router` |
| Data fetching | `openapi-fetch` + `openapi-react-query` + `@tanstack/react-query` |
| API types | Generated — `src/lib/schema.gen.d.ts` + `src/lib/validators.gen.ts` |
@@ -30,8 +30,7 @@ You are working in a production TypeScript + React repository. Make safe, minima
Before finishing any task, run in order:
```
-bun check:types # Check types with tsc
-bun fix # Fixes all formatting issues with oxfmt
+bun fix # Auto-fix formatting, then type-check
```
Never bypass hooks or suggest `--no-verify`.
@@ -46,10 +45,10 @@ Before implementing any interactive widget (dialog, popover, menu, select, check
## Generated files
-These are generated by `bun run dev` and `bun run build`. Do not edit manually:
-
-- `src/lib/api/heyapi`
-- `src/paraglide`
+- Generated files should be gitignored.
+- Do not edit generated files.
+- Include new generated files/folders in the gitignore, under the right comment heading.
+- Gitignored files not under the specific generated files heading are not necessarily generated, and thus are exempt from this rule.
---
@@ -81,7 +80,7 @@ These are generated by `bun run dev` and `bun run build`. Do not edit manually:
## Data fetching
-- Use the existing `openapi-react-query` / TanStack Query patterns
+- Use the existing HeyApi sdk / TanStack Query patterns
- Always handle loading, empty, and error states
- Never hardcode URLs, tokens, or environment-specific values
@@ -109,6 +108,6 @@ These are generated by `bun run dev` and `bun run build`. Do not edit manually:
## Tests
-- Only write tests if similar tests already exist
+- Only write tests if similar tests already exist, or explicitly asked to
- Never use snapshots
- Never write unit tests for components
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..3111e1f
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,9 @@
+{
+ "permissions": {
+ "allow": [
+ "WebFetch(domain:tanstack.com)",
+ "WebFetch(domain:base-ui.com/)",
+ "Bash(bun fix *)"
+ ]
+ }
+}
diff --git a/messages/en-US.json b/messages/en-US.json
index 744529f..ab68324 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -11,6 +11,8 @@
"loading_message_4": "Pretend we're playing elevator music...",
"nav_logout": "Log out",
+ "nav_add_notification": "Add notification",
+ "nav_toggle_urgent": "Toggle urgent",
"home_title": "Home",
diff --git a/messages/nl-NL.json b/messages/nl-NL.json
index 8825340..ff2a866 100644
--- a/messages/nl-NL.json
+++ b/messages/nl-NL.json
@@ -11,6 +11,8 @@
"loading_message_4": "Even geduld. Er zijn 236 wachtenden voor u... (grapje)",
"nav_logout": "Uitloggen",
+ "nav_add_notification": "Melding toevoegen",
+ "nav_toggle_urgent": "Urgent omschakelen",
"home_title": "Home",
diff --git a/package.json b/package.json
index 906b20f..7052b32 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"scripts": {
"dev": "bun i && bun vite",
"build": "bun i --frozen-lockfile && bun vite build && bun tsc",
- "lint": "bun biome check src && bun prettier --check 'src/**/*.{s,}css'"
+ "check": "bun tsc --noEmit && bun biome check src && bun prettier --check 'src/**/*.{s,}css'",
+ "fix": "bun biome check --write src && bun prettier --write 'src/**/*.{s,}css' && bun tsc --noEmit"
},
"dependencies": {
"@inlang/paraglide-js": "2.18.0",
diff --git a/src/components/header/app-header.tsx b/src/components/header/app-header.tsx
index aad9bde..69c729d 100644
--- a/src/components/header/app-header.tsx
+++ b/src/components/header/app-header.tsx
@@ -7,10 +7,12 @@ import { useLocale } from "lib/i18n";
import * as m from "lib/paraglide/messages";
import type { Locale } from "lib/paraglide/runtime";
import { locales, setLocale } from "lib/paraglide/runtime";
+import { useTitleState } from "lib/title-state";
import style from "./app-header.module.scss";
const links = [
{
+ // TODO: How do we get this from the router config so we know it's safe?
to: "/" as const,
icon: ,
label: m.home_title,
@@ -20,6 +22,11 @@ const links = [
const AppHeader = () => {
const locale = useLocale();
const navigate = useNavigate();
+ const {
+ notificationCount,
+ hasUrgentEvent,
+ setTitleState,
+ } = useTitleState();
const languageOptions = locales
.toSorted((a, b) => a.localeCompare(b, locale))
@@ -54,6 +61,24 @@ const AppHeader = () => {
))}
+
+
From 47134a7bac555acde7f1b751e5c54a5bd21625d6 Mon Sep 17 00:00:00 2001
From: Jorn Luiten
Date: Tue, 30 Jun 2026 16:16:55 +0200
Subject: [PATCH 18/29] add CheckboxGroup
---
src/components/form/checkbox/checkbox.tsx | 32 ++++++++++
.../form/checkbox/tsf-checkbox-group.tsx | 64 +++++++++++++++++++
src/components/form/checkbox/tsf-checkbox.tsx | 35 ++++------
src/components/form/field/field.tsx | 1 +
src/lib/forms/index.tsx | 2 +
src/routes/_app/form-example.tsx | 39 +++++++++--
6 files changed, 146 insertions(+), 27 deletions(-)
create mode 100644 src/components/form/checkbox/checkbox.tsx
create mode 100644 src/components/form/checkbox/tsf-checkbox-group.tsx
diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx
new file mode 100644
index 0000000..8e9ac89
--- /dev/null
+++ b/src/components/form/checkbox/checkbox.tsx
@@ -0,0 +1,32 @@
+import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
+import CheckIcon from "./check.svg?react";
+import style from "./checkbox.module.scss";
+
+type CheckboxProps = React.ComponentProps<
+ typeof BaseCheckbox.Root
+> & {
+ label: string;
+};
+
+const Checkbox = ({
+ id,
+ label,
+ ...props
+}: CheckboxProps) => {
+ return (
+
+ );
+};
+
+export default Checkbox;
diff --git a/src/components/form/checkbox/tsf-checkbox-group.tsx b/src/components/form/checkbox/tsf-checkbox-group.tsx
new file mode 100644
index 0000000..6287cbb
--- /dev/null
+++ b/src/components/form/checkbox/tsf-checkbox-group.tsx
@@ -0,0 +1,64 @@
+import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group";
+import { useFieldContext } from "lib/forms";
+import { normalizeFieldErrors } from "lib/forms/validation-helpers";
+import Field, { type FieldProps } from "../field/field";
+import Checkbox from "./checkbox";
+
+export type CheckboxGroupItem = Readonly<{
+ label: string;
+ value: string;
+}>;
+
+// Using TSF `name` as id
+type CheckboxProps = Omit<
+ FieldProps,
+ "id" | "error" | "children"
+> & {
+ label: string;
+ items: CheckboxGroupItem[];
+};
+
+/**
+ * Simple CheckboxGroup intended for checking items in an optional list
+ * All checkboxes have the same name. The value will be an array of checked checkbox strings
+ */
+const TSFCheckboxGroup = ({
+ label: fieldLabel,
+ description,
+ required,
+ noError,
+ className,
+ items,
+}: CheckboxProps) => {
+ const field = useFieldContext();
+ return (
+
+ field.handleChange(value)}
+ >
+ {items.map(({ label, value }) => (
+
+ ))}
+
+
+ );
+};
+
+export default TSFCheckboxGroup;
diff --git a/src/components/form/checkbox/tsf-checkbox.tsx b/src/components/form/checkbox/tsf-checkbox.tsx
index ff74432..5196e7c 100644
--- a/src/components/form/checkbox/tsf-checkbox.tsx
+++ b/src/components/form/checkbox/tsf-checkbox.tsx
@@ -1,9 +1,7 @@
-import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
import { useFieldContext } from "lib/forms";
import { normalizeFieldErrors } from "lib/forms/validation-helpers";
import Field, { type FieldProps } from "../field/field";
-import CheckIcon from "./check.svg?react";
-import style from "./checkbox.module.scss";
+import Checkbox from "./checkbox";
// Using TSF `name` as id
type CheckboxProps = Omit<
@@ -31,26 +29,17 @@ const TSFCheckbox = ({
noError={noError}
className={className}
>
-
+
+ field.handleChange(checked)
+ }
+ onBlur={field.handleBlur}
+ aria-invalid={!field.state.meta.isValid}
+ {...props}
+ />
);
};
diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx
index 3d0e1c1..d4e8ed1 100644
--- a/src/components/form/field/field.tsx
+++ b/src/components/form/field/field.tsx
@@ -31,6 +31,7 @@ const Field = ({
>
{label && (
diff --git a/src/lib/forms/index.tsx b/src/lib/forms/index.tsx
index b7837c7..35dba14 100644
--- a/src/lib/forms/index.tsx
+++ b/src/lib/forms/index.tsx
@@ -3,6 +3,7 @@ import {
createFormHookContexts,
} from "@tanstack/react-form";
import Checkbox from "components/form/checkbox/tsf-checkbox";
+import CheckboxGroup from "components/form/checkbox/tsf-checkbox-group";
import Input from "components/form/input/tsf-input";
export const {
@@ -25,6 +26,7 @@ export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
Checkbox,
+ CheckboxGroup,
Input,
},
formComponents: {},
diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx
index fe82601..7ec7075 100644
--- a/src/routes/_app/form-example.tsx
+++ b/src/routes/_app/form-example.tsx
@@ -12,6 +12,7 @@ import {
} from "lib/forms/validation-helpers";
import type { ValidationError } from "lib/heyapi";
import { makePageTitle } from "lib/title";
+import { useState } from "react";
import z from "zod";
import style from "./form-example.module.scss";
@@ -61,18 +62,33 @@ export const Route = createFileRoute("/_app/form-example")({
meta: [{ title: makePageTitle("Form test") }],
}),
component: FormTest,
+ loader: () => ({
+ // Suppose options are set by cms
+ allOptions: [
+ { label: "I like apples!", value: "apples" },
+ { label: "I like humans!", value: "humans" },
+ { label: "I eat puppies!", value: "puppies" },
+ ],
+ }),
});
function FormTest() {
+ const [disabled, setDisabled] = useState(false);
+ const { allOptions } = Route.useLoaderData();
+
const mutation = useMutation({
- mutationFn: (value: ValidationType) =>
- fakeSubmit(value, false),
+ mutationFn: (values: ValidationType) => {
+ // biome-ignore lint/suspicious/noConsole: DEV -show what is submitted
+ console.log("Will submit data:", values);
+ return fakeSubmit(values, true); // CHANGE this to false to test erros
+ },
onSuccess: () => {
- // biome-ignore lint/suspicious/noConsole: DEV
+ // biome-ignore lint/suspicious/noConsole: DEV -show succes
console.log("Success!");
},
});
+ // Note: Only filled in so it's easier to test
const defaultValues: ValidationType = {
email: "test@test.nl",
postalCode: "1234AZ",
@@ -101,7 +117,7 @@ function FormTest() {
evt.preventDefault();
form.handleSubmit();
}}
- disabled={mutation.isPending}
+ disabled={mutation.isPending || disabled}
>
{(field) => (
@@ -157,8 +173,23 @@ function FormTest() {
)}
+
+ {(field) => (
+
+ )}
+
+
Submit
+ setDisabled((d) => !d)}
+ >
+ Toggle disabled state
+
);
}
From ae1e27be672d8036baf4cd1fd76e185d91d54f9d Mon Sep 17 00:00:00 2001
From: Jorn Luiten
Date: Tue, 30 Jun 2026 16:17:33 +0200
Subject: [PATCH 19/29] Added some minimal, but useful docs that help
adoptation
---
docs/README.md | 3 +++
docs/recipes/extending-base-ui.md | 38 +++++++++++++++++++++++++++++++
docs/working-with-forms.md | 34 +++++++++++++++++++++++++++
3 files changed, 75 insertions(+)
create mode 100644 docs/README.md
create mode 100644 docs/recipes/extending-base-ui.md
create mode 100644 docs/working-with-forms.md
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..01d1e60
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,3 @@
+# Documentation
+
+This folder contains documentation on how to use the IGNE vite-react-template. You can also add project-specific docs.
diff --git a/docs/recipes/extending-base-ui.md b/docs/recipes/extending-base-ui.md
new file mode 100644
index 0000000..22dd4d1
--- /dev/null
+++ b/docs/recipes/extending-base-ui.md
@@ -0,0 +1,38 @@
+# Extending base-ui
+
+[Base UI](https://base-ui.com) covers a bunch of generic components that we use to build our own on.
+
+Oftentimes you may want to implement base-ui as-is, but you need a generic way to apply generic styling or functionality to its compound components.
+This doc describes a good way to do that.
+
+## Import and re-export
+
+For example a menu's Trigger should always contain an arrow:
+
+```tsx
+import { Menu as BaseMenu } from '@base-ui/react/menu';
+import ChevronRight from "assets/icons/chevron-right.svg?react";
+
+const MenuTrigger = ({children, ...rest}: BaseMenu.Trigger.Props) =>
+ {children}
+
+export default {
+ ...BaseMenu,
+ Trigger: MenuTrigger,
+}
+```
+
+This way you only have to redefine the compound components that you change.
+The suffix format (in this case `Menu`Trigger), prevents issues with reserved component names (eg. Error from Field.Error).
+
+## If you add props, always export a type
+
+```tsx
+//...
+
+type MenuTriggerProps = BaseMenu.Trigger.Props & { icon: "chevron" | "arrow" }
+const MenuTrigger = ({children, icon, ...rest}: MenuTriggerProps) =>
+ {children}{icon ? }
+
+//...
+```
diff --git a/docs/working-with-forms.md b/docs/working-with-forms.md
new file mode 100644
index 0000000..35a917a
--- /dev/null
+++ b/docs/working-with-forms.md
@@ -0,0 +1,34 @@
+# Working with forms
+
+We use [Tanstack Form](https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts) (TSF).
+
+In `lib/forms/` you can define reusable form/field components. See the docs on [form composition](https://tanstack.com/form/latest/docs/framework/react/guides/form-composition) for more info.
+
+Form components are in `components/form/`. Tanstack Form connectors should be prefixed with `tsf-`.
+If you need for example an Input component that is not connected to TSF, create a separate component. Be specific, a search component is `` etc.
+
+If you need something VERY specific (used only once), you may not need to make a component at all. Just use BaseUI primitives and reuse the styling classnames.
+
+## How to adopt
+
+### Error handling
+
+The first thing to do is to verify the **error object(s)** with Backend.
+Make sure that the validation/error utils can translate the backend errors into something tanstack form understands.
+See TODO's in `lib/forms/validation-helpers` & `lib/api/error-helpers.ts`.
+
+### Validation options
+
+- Use a validation schema (preferably generated from api spec) to do FE validation of the form. Connect to `onDynamic`.
+- If applicable, use the provided util to handle backend validation errors with `onSubmitAsync` & `mutateAndValidate`.
+ Note: this is a temp solution.
+
+### Submission
+
+Prefer submitting with Tanstack Query (`useMutation`). An option object should be generated by heyapi.
+
+
+See the form-example route to see how it all comes together.
+Note: Normally you would not put everything in the route file, but this is easy to remove when you start a new project.
+
+💡 Remove any example files/code you don't need in your project!
From b1523dca70a1c24779467a55493da557232b5c35 Mon Sep 17 00:00:00 2001
From: Jorn Luiten
Date: Fri, 3 Jul 2026 12:21:36 +0200
Subject: [PATCH 20/29] re-implement mutateAndValidate
---
src/lib/forms/validation-helpers.ts | 24 +++++++++++++-----------
src/routes/_app/form-example.tsx | 9 +++++----
2 files changed, 18 insertions(+), 15 deletions(-)
diff --git a/src/lib/forms/validation-helpers.ts b/src/lib/forms/validation-helpers.ts
index 5844d70..c87d4e2 100644
--- a/src/lib/forms/validation-helpers.ts
+++ b/src/lib/forms/validation-helpers.ts
@@ -79,21 +79,23 @@ type Mutatable = {
};
/**
- * Connect TanStack Form with -Query to handle backend errors.
+ * Submit form and handle possible errors.
* This is a temporary solution until TSF supports it out of the box:
* https://github.com/TanStack/form/issues/2188
*
* @example
* validators: {
- * onSubmitAsync: mutateAndValidate(mutation),
+ * onSubmitAsync: async ({ value }) =>
+ * mutateAndValidate(mutation, { body: value }),
* }
*/
-export const mutateAndValidate =
- (mutation: Mutatable) =>
- async ({ value }: { value: TVariables }) => {
- try {
- await mutation.mutateAsync(value);
- } catch (error) {
- return apiErrorToFormErrors(error);
- }
- };
+export const mutateAndValidate = async (
+ mutation: Mutatable,
+ variables: TVariables,
+) => {
+ try {
+ await mutation.mutateAsync(variables);
+ } catch (error) {
+ return apiErrorToFormErrors(error);
+ }
+};
diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx
index 7ec7075..4307acc 100644
--- a/src/routes/_app/form-example.tsx
+++ b/src/routes/_app/form-example.tsx
@@ -77,10 +77,10 @@ function FormTest() {
const { allOptions } = Route.useLoaderData();
const mutation = useMutation({
- mutationFn: (values: ValidationType) => {
+ mutationFn: ({ body }: { body: ValidationType }) => {
// biome-ignore lint/suspicious/noConsole: DEV -show what is submitted
- console.log("Will submit data:", values);
- return fakeSubmit(values, true); // CHANGE this to false to test erros
+ console.log("Will submit data:", body);
+ return fakeSubmit(body, true); // CHANGE this to false to test erros
},
onSuccess: () => {
// biome-ignore lint/suspicious/noConsole: DEV -show succes
@@ -103,7 +103,8 @@ function FormTest() {
validationLogic: revalidateLogic(),
validators: {
onDynamic: validationSchema,
- onSubmitAsync: mutateAndValidate(mutation),
+ onSubmitAsync: ({ value }) =>
+ mutateAndValidate(mutation, { body: value }),
},
});
From 7e5a96cc85d5f7bac78e15e5eb5dc4237a63b7f0 Mon Sep 17 00:00:00 2001
From: Jorn Luiten
Date: Fri, 3 Jul 2026 12:21:47 +0200
Subject: [PATCH 21/29] update login form
---
src/routes/_auth/login.tsx | 119 ++++++++++++++++++++-----------------
1 file changed, 64 insertions(+), 55 deletions(-)
diff --git a/src/routes/_auth/login.tsx b/src/routes/_auth/login.tsx
index 455f322..0270672 100644
--- a/src/routes/_auth/login.tsx
+++ b/src/routes/_auth/login.tsx
@@ -1,3 +1,5 @@
+import { revalidateLogic } from "@tanstack/react-form";
+import { useMutation } from "@tanstack/react-query";
import {
createFileRoute,
Link,
@@ -5,15 +7,18 @@ import {
} from "@tanstack/react-router";
import classNames from "classnames";
import { ErrorText } from "components/error-text/error-text";
-import { Button, Form, Input } from "components/form";
+import { Button, Form } from "components/form";
import { H1 } from "components/heading/heading";
+import { useAppForm } from "lib/forms";
import {
- postApiAuthLogin,
- type ValidationError,
-} from "lib/heyapi";
+ mutateAndValidate,
+ normalizeFieldErrors,
+} from "lib/forms/validation-helpers";
+import type { LoginRequest } from "lib/heyapi";
+import { postApiAuthLoginMutation } from "lib/heyapi/@tanstack/react-query.gen";
+import { zLoginRequest } from "lib/heyapi/zod.gen";
import * as m from "lib/paraglide/messages";
import { makePageTitle } from "lib/title";
-import { useState } from "react";
import z from "zod";
import style from "./login.module.scss";
@@ -34,30 +39,25 @@ export const Route = createFileRoute("/_auth/login")({
function LoginPage() {
const { redirect } = Route.useSearch();
const navigate = useNavigate();
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [error, setError] = useState();
- const [isPending, setIsPending] = useState(false);
- const handleSubmit = async (
- evt: React.SubmitEvent,
- ) => {
- evt.preventDefault();
- setIsPending(true);
+ const mutation = useMutation({
+ ...postApiAuthLoginMutation(),
+ gcTime: 0,
+ onSuccess: () => navigate({ to: redirect || "/" }),
+ });
- // Auth endpoints do not use TanStack Query
- const result = await postApiAuthLogin({
- body: { email, password },
- });
-
- if (result.error) {
- setError(result.error);
- setIsPending(false);
- return;
- }
-
- navigate({ to: redirect || "/" });
- };
+ const form = useAppForm({
+ defaultValues: {
+ email: "",
+ password: "",
+ } satisfies LoginRequest,
+ validationLogic: revalidateLogic(),
+ validators: {
+ onDynamic: zLoginRequest,
+ onSubmitAsync: ({ value }) =>
+ mutateAndValidate(mutation, { body: value }),
+ },
+ });
return (
<>
@@ -65,36 +65,31 @@ function LoginPage() {
{m.login_title()}