From 515cc62735e25a66848ea592764cfc3c1b9044c0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 4 Sep 2026 19:35:17 +0900 Subject: [PATCH 01/10] Add Formisch and Valibot dependencies Add Formisch for SolidJS and its Valibot peer dependency to the web package. I limited this commit to dependency installation; Codex applied the manifest and lockfile changes and ran the web TypeScript check successfully. Assisted-by: Codex:gpt-5.6-sol --- packages/web/package.json | 5 +++ pnpm-lock.yaml | 82 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/packages/web/package.json b/packages/web/package.json index 684533e..e111fce 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -46,7 +46,11 @@ "preview": "vite preview" }, "dependencies": { +<<<<<<< HEAD "@kobalte/core": "^0.13.13", +======= + "@formisch/solid": "1.0.0", +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) "@solidjs/meta": "^0.29.4", "@solidjs/router": "^1.0.0", "@solidjs/start": "^2.0.0", @@ -54,6 +58,7 @@ "relay-runtime": "^21.0.1", "solid-js": "^1.9.5", "solid-relay": "1.0.0-beta.29", + "valibot": "1.4.2", "vite": "^8.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adee240..4ac3b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,11 @@ importers: version: 0.6.0-dev.263(@upyo/core@0.6.0-dev.263) drizzle-orm: specifier: 'catalog:' +<<<<<<< HEAD version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) +======= + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) graphql: specifier: 'catalog:' version: 16.14.2 @@ -179,7 +183,11 @@ importers: version: 4.13.0(graphql@16.14.2) '@pothos/plugin-drizzle': specifier: ^0.17.4 +<<<<<<< HEAD version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9))(graphql@16.14.2) +======= + version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)))(graphql@16.14.2) +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@pothos/plugin-errors': specifier: ^4.9.1 version: 4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2) @@ -197,7 +205,11 @@ importers: version: 0.6.0-dev.263(@upyo/core@0.6.0-dev.263) drizzle-orm: specifier: 'catalog:' +<<<<<<< HEAD version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) +======= + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) graphql: specifier: 'catalog:' version: 16.14.2 @@ -237,8 +249,13 @@ importers: version: 2.3.0-dev.840 drizzle-orm: specifier: 'catalog:' +<<<<<<< HEAD version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) postgres: +======= + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) + pg: +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) specifier: 'catalog:' version: 3.4.9 devDependencies: @@ -260,9 +277,15 @@ importers: packages/web: dependencies: +<<<<<<< HEAD '@kobalte/core': specifier: ^0.13.13 version: 0.13.13(solid-js@1.9.14) +======= + '@formisch/solid': + specifier: 1.0.0 + version: 1.0.0(solid-js@1.9.14)(typescript@7.0.2)(valibot@1.4.2(typescript@7.0.2)) +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@solidjs/meta': specifier: ^0.29.4 version: 0.29.4(solid-js@1.9.14) @@ -284,6 +307,9 @@ importers: solid-relay: specifier: 1.0.0-beta.29 version: 1.0.0-beta.29(relay-runtime@21.0.1)(seroval@1.6.0)(solid-js@1.9.14) + valibot: + specifier: 1.4.2 + version: 1.4.2(typescript@7.0.2) vite: specifier: ^8.2.0 version: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) @@ -830,6 +856,7 @@ packages: resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} +<<<<<<< HEAD '@fedify/uri-template@2.4.0-pr.1020.43': resolution: {integrity: sha512-2JsU5q+pDNMuoD3ZcJLwubpZWgixwcy0H4yVY/bpz/vEEEWu+g22pa6r52rloWvn5ARJXAkMxcyHDcBlZojpBg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} @@ -858,6 +885,17 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} +======= + '@formisch/solid@1.0.0': + resolution: {integrity: sha512-J5RF48YCM6SNYVkt+CJ0+5MHPC38wi3zgBM5hOpwnWFU5Q/gtdjb+vO63uJKWLAU7XQlnZVDo7rBzf8bVsvvcA==} + peerDependencies: + solid-js: '>=1.6 <2' + typescript: '>=5 <8' + valibot: '>=1.4.1 <2' + peerDependenciesMeta: + typescript: + optional: true +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@graphql-tools/executor@1.5.3': resolution: {integrity: sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==} @@ -3726,6 +3764,14 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + verkit@0.3.0: resolution: {integrity: sha512-Njrh4U8UODGajoZ44QS2C/BsoEM9DTI/aCqY5swsizb+/ap0FamvnCMcZAxrR5+aoC0ZqkawEfpC/N2SBc+xeA==} engines: {node: '>=18.12.0'} @@ -4297,6 +4343,7 @@ snapshots: '@fedify/uri-template@2.3.1': {} +<<<<<<< HEAD '@fedify/uri-template@2.4.0-pr.1020.43': {} '@fedify/vocab-runtime@2.4.0-pr.1020.43': @@ -4348,6 +4395,14 @@ snapshots: '@floating-ui/utils': 0.2.12 '@floating-ui/utils@0.2.12': {} +======= + '@formisch/solid@1.0.0(solid-js@1.9.14)(typescript@7.0.2)(valibot@1.4.2(typescript@7.0.2))': + dependencies: + solid-js: 1.9.14 + valibot: 1.4.2(typescript@7.0.2) + optionalDependencies: + typescript: 7.0.2 +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@graphql-tools/executor@1.5.3(graphql@16.14.2)': dependencies: @@ -4731,10 +4786,17 @@ snapshots: dependencies: graphql: 16.14.2 +<<<<<<< HEAD '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9))(graphql@16.14.2)': dependencies: '@pothos/core': 4.13.0(graphql@16.14.2) drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) +======= + '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)))(graphql@16.14.2)': + dependencies: + '@pothos/core': 4.13.0(graphql@16.14.2) + drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) graphql: 16.14.2 '@pothos/plugin-errors@4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)': @@ -5349,6 +5411,7 @@ snapshots: asap@2.0.6: {} +<<<<<<< HEAD asn1js@3.0.10: dependencies: pvtsutils: 1.3.6 @@ -5357,6 +5420,10 @@ snapshots: babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): dependencies: +======= + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + dependencies: +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -5522,13 +5589,21 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 +<<<<<<< HEAD drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9): +======= + drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)): +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) optionalDependencies: '@electric-sql/pglite': 0.5.3 '@opentelemetry/api': 1.9.1 '@types/pg': 8.20.0 pg: 8.21.0 +<<<<<<< HEAD postgres: 3.4.9 +======= + valibot: 1.4.2(typescript@7.0.2) +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) dts-resolver@3.0.0: {} @@ -6568,6 +6643,7 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) +<<<<<<< HEAD solid-presence@0.2.0(solid-js@1.9.14): dependencies: '@corvu/utils': 0.4.2(solid-js@1.9.14) @@ -6578,6 +6654,8 @@ snapshots: '@corvu/utils': 0.4.2(solid-js@1.9.14) solid-js: 1.9.14 +======= +>>>>>>> c537fd7 (Add Formisch and Valibot dependencies) solid-refresh@0.6.3(solid-js@1.9.14): dependencies: '@babel/generator': 7.29.8 @@ -6814,6 +6892,10 @@ snapshots: uuid@14.0.1: {} + valibot@1.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + verkit@0.3.0: {} vfile-message@4.0.3: From efd7ac3f74d6e934dafc084fee093a20c85e28f0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 4 Sep 2026 20:15:26 +0900 Subject: [PATCH 02/10] Migrate sign-in form to Formisch Replace manual form parsing with a Valibot schema and Formisch-managed field validation while preserving the existing Relay mutation and response handling. I asked Codex to plan and implement the sign-in migration. Codex added schema validation and accessible field errors, preserved the Relay variable and callback behavior, and verified the result with Oxfmt, Oxlint, the web TypeScript check, and a production web build. Assisted-by: Codex:gpt-5.6-sol --- packages/web/src/routes/sign-in.tsx | 70 +++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 8190302..2594e3b 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -14,13 +14,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +<<<<<<< HEAD import { Alert } from "@kobalte/core/alert"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; +======= +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; +>>>>>>> a7bd83c (Migrate sign-in form to Formisch) import { Title } from "@solidjs/meta"; import { graphql } from "relay-runtime"; import { Match, Show, Switch, createSignal } from "solid-js"; import { createMutation } from "solid-relay"; +import * as v from "valibot"; import type { SignInMutation } from "./__generated__/SignInMutation.graphql.ts"; @@ -34,6 +39,15 @@ const signInMutation = graphql` } `; +const signInSchema = v.object({ + email: v.pipe( + v.string(), + v.trim(), + v.nonEmpty("Enter a valid email address."), + v.email("Enter a valid email address."), + ), +}); + interface SignInResult { message: string; status: "error" | "success"; @@ -43,21 +57,13 @@ export default function SignInPage() { const [result, setResult] = createSignal(); const [commitSignIn, isSigningIn] = createMutation(signInMutation); + const signInForm = createForm({ + schema: signInSchema, + initialInput: { email: "" }, + }); - const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { - event.preventDefault(); - + const submit: SubmitHandler = ({ email }) => { const verifyUrl = `${globalThis.location.origin}/confirm/{token}?code={code}`; - const formData = new FormData(event.currentTarget); - const email = formData.get("email"); - if (typeof email !== "string" || email === "") { - setResult({ - message: "Enter a valid email address.", - status: "error", - }); - return; - } - setResult(undefined); commitSignIn({ variables: { email, verifyUrl }, @@ -96,6 +102,7 @@ export default function SignInPage() {

Enter your email address to receive a secure sign-in link.

+<<<<<<< HEAD
@@ -113,6 +120,38 @@ export default function SignInPage() { />
+======= + + +>>>>>>> a7bd83c (Migrate sign-in form to Formisch) {(formResult) => ( From ff55cc69ad91207a5baf242d4595368150101e39 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 4 Sep 2026 20:41:39 +0900 Subject: [PATCH 03/10] Add semantic form status colors Style error notices in red, success notices in green, and warning notices in orange across light and dark themes. Apply the error treatment to invalid sign-in fields and their inline validation messages. I asked Codex to make login failure, success, and warning states visually distinct. Codex added semantic theme tokens and matching notice, input, focus, and field-error styles, then verified the changes with formatting, linting, TypeScript, and a production web build. Assisted-by: Codex:gpt-5.6-sol --- packages/web/src/app.css | 378 ++++++++++++++++++++++++++++ packages/web/src/routes/sign-in.tsx | 2 +- packages/web/src/styles/drfed.css | 4 + 3 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/app.css diff --git a/packages/web/src/app.css b/packages/web/src/app.css new file mode 100644 index 0000000..679629b --- /dev/null +++ b/packages/web/src/app.css @@ -0,0 +1,378 @@ +/* +DrFed: A web-based platform for developing and debugging ActivityPub apps +Copyright (C) 2026 DrFed team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: var(--text-body); +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +.app-shell { + min-height: 100svh; +} + +.app-header { + background: color-mix(in srgb, var(--bg) 92%, transparent); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 10; +} + +.app-header-inner { + align-items: center; + display: flex; + height: var(--header-h); + margin: 0 auto; + max-width: var(--container); + padding: 0 var(--gutter); +} + +.brand { + align-items: center; + color: var(--ink); + display: flex; + font-family: var(--font-serif); + font-size: 1.25rem; + font-weight: 700; + gap: 0.65rem; + letter-spacing: var(--tracking-heading); + text-decoration: none; +} + +.brand img { + border-radius: 50%; +} + +.app-nav { + align-self: stretch; + display: flex; + gap: 0.25rem; + margin-left: clamp(1.5rem, 5vw, 4rem); +} + +.app-nav a, +.header-action { + align-items: center; + color: var(--ink-soft); + display: flex; + font-size: 0.9rem; + font-weight: 600; + padding: 0 0.85rem; + position: relative; + text-decoration: none; +} + +.app-nav a:hover, +.header-action:hover, +.app-nav a.is-active, +.header-action.is-active { + color: var(--ink); +} + +.app-nav a.is-active::after { + background: var(--accent); + bottom: -1px; + content: ""; + height: 3px; + left: 0.85rem; + position: absolute; + right: 0.85rem; +} + +.header-action { + border: 1px solid var(--line-strong); + border-radius: var(--radius-pill); + margin-left: auto; + min-height: 2.4rem; +} + +.header-action.is-active { + background: var(--accent-tint); + border-color: var(--accent-soft); +} + +.app-header a:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} + +.app-content { + margin: 0 auto; + max-width: var(--container); + min-height: calc(100svh - var(--header-h)); + padding: clamp(2rem, 5vw, 4rem) var(--gutter); +} + +.app-content > main:not(.form-page) { + width: 100%; +} + +.app-content > main:not(.form-page) :is(h1, h2, p) { + margin-top: 0; +} + +.app-content h1, +.app-content h2 { + font-family: var(--font-serif); + letter-spacing: var(--tracking-heading); +} + +.app-content h1 { + font-size: var(--text-heading-2); + line-height: var(--leading-heading); +} + +.app-content p { + color: var(--ink-soft); + line-height: var(--leading-body); +} + +.form-page { + align-items: center; + display: flex; + justify-content: center; + min-height: calc(100svh - var(--header-h) - clamp(4rem, 10vw, 8rem)); +} + +.panel { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.form-panel { + max-width: 28rem; + padding: clamp(1.5rem, 4vw, 2rem); + width: 100%; +} + +.panel-header { + border-bottom: 1px solid var(--line); + margin: 0 calc(clamp(1.5rem, 4vw, 2rem) * -1) 1.5rem; + padding: 0 clamp(1.5rem, 4vw, 2rem) 1.5rem; +} + +.panel-header h1 { + font-size: 1.65rem; + margin: 0 0 0.5rem; +} + +.panel-header p { + font-size: 0.95rem; + margin: 0; +} + +.form-panel form { + display: grid; + gap: 1rem; +} + +.field { + display: grid; + gap: 0.5rem; +} + +.field-heading { + align-items: baseline; + display: flex; + font-size: 0.9rem; + font-weight: 650; + justify-content: space-between; +} + +.field-status { + color: var(--ink-faint); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.field-status.required { + color: var(--accent-strong); +} + +.field input { + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--ink); + min-width: 0; + padding: 0.9rem 1rem; + transition: + border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.field input::placeholder { + color: var(--ink-faint); +} + +.field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-tint); + outline: none; +} + +.field input:read-only { + border-style: dashed; + color: var(--ink-soft); + cursor: default; + font-family: var(--font-mono); +} + +.field-hint { + color: var(--ink-faint); + font-size: 0.78rem; + line-height: 1.45; + margin: 0; +} + +.field-error { + color: var(--danger); + font-size: 0.78rem; + line-height: 1.45; + margin: 0; +} + +.field input[aria-invalid="true"] { + border-color: var(--danger); +} + +.field input[aria-invalid="true"]:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); +} + +.button { + align-items: center; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; + display: inline-flex; + font-weight: 700; + justify-content: center; + min-height: 2.9rem; + padding: 0.75rem 1.1rem; +} + +.button:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} + +.button.primary { + background: var(--accent); + color: var(--on-accent); + transition: + background var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.button.primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.button:disabled { + cursor: wait; + opacity: 0.65; +} + +.notice { + background: var(--accent-tint); + border-radius: var(--radius-sm); + color: var(--ink); + line-height: 1.5; + margin: 1.25rem 0 0; + padding: 0.85rem 1rem; +} + +.notice.error { + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); + border-left: 3px solid var(--danger); + color: var(--danger); +} + +.notice.success { + background: color-mix(in srgb, var(--success) 12%, var(--surface)); + border-left: 3px solid var(--success); + color: var(--success); +} + +.notice.warning { + background: color-mix(in srgb, var(--warning) 12%, var(--surface)); + border-left: 3px solid var(--warning); + color: var(--warning); +} + +@media (max-width: 600px) { + .app-header-inner { + padding: 0 1rem; + } + + .brand span { + display: none; + } + + .app-nav { + margin-left: 0.75rem; + } + + .app-nav a, + .header-action { + padding-inline: 0.65rem; + } + + .app-nav a.is-active::after { + left: 0.65rem; + right: 0.65rem; + } + + .app-content { + padding: 1.5rem 1rem 3rem; + } + + .form-page { + align-items: flex-start; + min-height: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .button.primary, + .field input { + transition: none; + } +} diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 2594e3b..967b925 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -142,7 +142,7 @@ export default function SignInPage() { /> {(errors) => ( - + {errors()[0]} )} diff --git a/packages/web/src/styles/drfed.css b/packages/web/src/styles/drfed.css index b306ab6..6301508 100644 --- a/packages/web/src/styles/drfed.css +++ b/packages/web/src/styles/drfed.css @@ -56,6 +56,8 @@ along with this program. If not, see . --line: #e9e5e1; --line-strong: #dad4cf; --danger: #cf3f43; + --success: #247a46; + --warning: #9a5800; /* Elevation */ --shadow-sm: 0 1px 2px rgb(38 33 31 / 5%), 0 2px 8px rgb(38 33 31 / 4%); @@ -100,6 +102,8 @@ along with this program. If not, see . --line: #332f2a; --line-strong: #453f39; --danger: #ef726b; + --success: #66cf8a; + --warning: #f0ad4e; --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%); --shadow-md: 0 6px 24px rgb(0 0 0 / 40%); From 209ca7e6d3ca3be183c3eec72f862897781c8ffd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sat, 5 Sep 2026 18:23:00 +0900 Subject: [PATCH 04/10] Migrate instance creation form to Formisch Manage the generated slug with Formisch and Valibot while preserving Relay mutation handling. Keep the submit button inside the form and associate the read-only hint and validation error with the slug field. I chose to migrate the read-only form for consistency with sign-in. Codex reviewed the migration and accessibility feedback, identified the required form and description associations, formatted the file, and verified it with Oxlint and the web TypeScript check. Assisted-by: Codex:gpt-5.6-sol --- .../src/routes/workspace/create/instance.tsx | 87 ++++++++++++------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index 7063487..f6235bb 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -17,11 +17,13 @@ import { faker } from "@faker-js/faker"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Title } from "@solidjs/meta"; import { useNavigate } from "@solidjs/router"; import { graphql } from "relay-runtime"; import { Show, createSignal } from "solid-js"; import { createMutation } from "solid-relay"; +import * as v from "valibot"; import type { CreateInstanceMutation } from "./__generated__/CreateInstanceMutation.graphql.ts"; @@ -41,22 +43,24 @@ const createInstanceMutation = graphql` } `; +const createInstanceSchema = v.object({ + slug: v.pipe(v.string(), v.trim(), v.nonEmpty("Enter a valid slug.")), +}); + export default function CreateInstancePage() { const navigate = useNavigate(); const [errorMessage, setErrorMessage] = createSignal(); const [commitCreateInstance, isCreatingInstance] = createMutation(createInstanceMutation); - const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { - event.preventDefault(); - - const formData = new FormData(event.currentTarget); - const slug = formData.get("slug"); - if (typeof slug !== "string" || slug === "") { - setErrorMessage("Enter a valid slug."); - return; - } + const createInstanceForm = createForm({ + schema: createInstanceSchema, + initialInput: { + slug: `${faker.word.noun()}-${faker.word.noun()}-${faker.word.noun()}`, + }, + }); + const submit: SubmitHandler = ({ slug }) => { setErrorMessage(undefined); commitCreateInstance({ variables: { slug }, @@ -110,26 +114,48 @@ export default function CreateInstancePage() {

Review the generated identifier for your new instance.

-
- - - Slug - Generated · Read only - - - - - - DrFed generates this identifier automatically. It cannot be - edited. - - - + + + {(field) => ( + + + Slug + Generated · Read only + + + + + + DrFed generates this identifier automatically. It cannot be + edited. + + + + {(errors) => ( + + {errors()[0]} + + )} + + + )} + -
- +

Enter your email address to receive a secure sign-in link.

-<<<<<<< HEAD -
- - - Email address - - Required - - - - - -
-======= - ->>>>>>> a7bd83c (Migrate sign-in form to Formisch) {(formResult) => ( diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index b2a2131..ca36f90 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -15,9 +15,9 @@ // along with this program. If not, see . import { faker } from "@faker-js/faker"; +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; -import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Title } from "@solidjs/meta"; import { useNavigate } from "@solidjs/router"; import { graphql } from "relay-runtime"; From 7d64794419c4cc8a0c203acb44c37f04479d7907 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 6 Sep 2026 16:20:01 +0900 Subject: [PATCH 07/10] Fix dependency conflicts --- packages/web/package.json | 3 -- pnpm-lock.yaml | 70 +++++++-------------------------------- 2 files changed, 12 insertions(+), 61 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index e111fce..32c3936 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -46,11 +46,8 @@ "preview": "vite preview" }, "dependencies": { -<<<<<<< HEAD "@kobalte/core": "^0.13.13", -======= "@formisch/solid": "1.0.0", ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) "@solidjs/meta": "^0.29.4", "@solidjs/router": "^1.0.0", "@solidjs/start": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ac3b38..6380d0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,11 +130,7 @@ importers: version: 0.6.0-dev.263(@upyo/core@0.6.0-dev.263) drizzle-orm: specifier: 'catalog:' -<<<<<<< HEAD - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) -======= - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)) graphql: specifier: 'catalog:' version: 16.14.2 @@ -183,11 +179,7 @@ importers: version: 4.13.0(graphql@16.14.2) '@pothos/plugin-drizzle': specifier: ^0.17.4 -<<<<<<< HEAD - version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9))(graphql@16.14.2) -======= - version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)))(graphql@16.14.2) ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) + version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(graphql@16.14.2) '@pothos/plugin-errors': specifier: ^4.9.1 version: 4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2) @@ -205,11 +197,7 @@ importers: version: 0.6.0-dev.263(@upyo/core@0.6.0-dev.263) drizzle-orm: specifier: 'catalog:' -<<<<<<< HEAD - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) -======= - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)) graphql: specifier: 'catalog:' version: 16.14.2 @@ -249,13 +237,8 @@ importers: version: 2.3.0-dev.840 drizzle-orm: specifier: 'catalog:' -<<<<<<< HEAD - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)) postgres: -======= - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) - pg: ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) specifier: 'catalog:' version: 3.4.9 devDependencies: @@ -277,15 +260,12 @@ importers: packages/web: dependencies: -<<<<<<< HEAD - '@kobalte/core': - specifier: ^0.13.13 - version: 0.13.13(solid-js@1.9.14) -======= '@formisch/solid': specifier: 1.0.0 version: 1.0.0(solid-js@1.9.14)(typescript@7.0.2)(valibot@1.4.2(typescript@7.0.2)) ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) + '@kobalte/core': + specifier: ^0.13.13 + version: 0.13.13(solid-js@1.9.14) '@solidjs/meta': specifier: ^0.29.4 version: 0.29.4(solid-js@1.9.14) @@ -856,7 +836,6 @@ packages: resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} -<<<<<<< HEAD '@fedify/uri-template@2.4.0-pr.1020.43': resolution: {integrity: sha512-2JsU5q+pDNMuoD3ZcJLwubpZWgixwcy0H4yVY/bpz/vEEEWu+g22pa6r52rloWvn5ARJXAkMxcyHDcBlZojpBg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} @@ -885,7 +864,7 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} -======= + '@formisch/solid@1.0.0': resolution: {integrity: sha512-J5RF48YCM6SNYVkt+CJ0+5MHPC38wi3zgBM5hOpwnWFU5Q/gtdjb+vO63uJKWLAU7XQlnZVDo7rBzf8bVsvvcA==} peerDependencies: @@ -895,7 +874,6 @@ packages: peerDependenciesMeta: typescript: optional: true ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@graphql-tools/executor@1.5.3': resolution: {integrity: sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==} @@ -4343,7 +4321,6 @@ snapshots: '@fedify/uri-template@2.3.1': {} -<<<<<<< HEAD '@fedify/uri-template@2.4.0-pr.1020.43': {} '@fedify/vocab-runtime@2.4.0-pr.1020.43': @@ -4395,14 +4372,13 @@ snapshots: '@floating-ui/utils': 0.2.12 '@floating-ui/utils@0.2.12': {} -======= + '@formisch/solid@1.0.0(solid-js@1.9.14)(typescript@7.0.2)(valibot@1.4.2(typescript@7.0.2))': dependencies: solid-js: 1.9.14 valibot: 1.4.2(typescript@7.0.2) optionalDependencies: typescript: 7.0.2 ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@graphql-tools/executor@1.5.3(graphql@16.14.2)': dependencies: @@ -4786,17 +4762,10 @@ snapshots: dependencies: graphql: 16.14.2 -<<<<<<< HEAD - '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9))(graphql@16.14.2)': - dependencies: - '@pothos/core': 4.13.0(graphql@16.14.2) - drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) -======= - '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)))(graphql@16.14.2)': + '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(graphql@16.14.2)': dependencies: '@pothos/core': 4.13.0(graphql@16.14.2) - drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)) ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) + drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)) graphql: 16.14.2 '@pothos/plugin-errors@4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)': @@ -5411,7 +5380,6 @@ snapshots: asap@2.0.6: {} -<<<<<<< HEAD asn1js@3.0.10: dependencies: pvtsutils: 1.3.6 @@ -5420,10 +5388,6 @@ snapshots: babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): dependencies: -======= - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): - dependencies: ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -5589,21 +5553,14 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 -<<<<<<< HEAD - drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9): -======= - drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0)(valibot@1.4.2(typescript@7.0.2)): ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) + drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)): optionalDependencies: '@electric-sql/pglite': 0.5.3 '@opentelemetry/api': 1.9.1 '@types/pg': 8.20.0 pg: 8.21.0 -<<<<<<< HEAD postgres: 3.4.9 -======= valibot: 1.4.2(typescript@7.0.2) ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) dts-resolver@3.0.0: {} @@ -6643,7 +6600,6 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) -<<<<<<< HEAD solid-presence@0.2.0(solid-js@1.9.14): dependencies: '@corvu/utils': 0.4.2(solid-js@1.9.14) @@ -6654,8 +6610,6 @@ snapshots: '@corvu/utils': 0.4.2(solid-js@1.9.14) solid-js: 1.9.14 -======= ->>>>>>> c537fd7 (Add Formisch and Valibot dependencies) solid-refresh@0.6.3(solid-js@1.9.14): dependencies: '@babel/generator': 7.29.8 From e2d5a45fe1df1d52d52f31bf8e6589a4b6fb7fe6 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 6 Sep 2026 16:32:42 +0900 Subject: [PATCH 08/10] Fix form status CSS module styles Move invalid-input and semantic notice styling into the CSS module used by the forms, and remove the unused duplicate global stylesheet. Codex applied the review feedback at the user's request and verified the result with mise run check, mise run build, and git diff --check. Assisted-by: Codex:gpt-5.6-sol --- packages/web/src/app.css | 378 ------------------------ packages/web/src/styles/form.module.css | 28 +- 2 files changed, 25 insertions(+), 381 deletions(-) delete mode 100644 packages/web/src/app.css diff --git a/packages/web/src/app.css b/packages/web/src/app.css deleted file mode 100644 index 679629b..0000000 --- a/packages/web/src/app.css +++ /dev/null @@ -1,378 +0,0 @@ -/* -DrFed: A web-based platform for developing and debugging ActivityPub apps -Copyright (C) 2026 DrFed team - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -* { - box-sizing: border-box; -} - -html { - background: var(--bg); - color: var(--ink); - font-family: var(--font-sans); - font-size: var(--text-body); -} - -body { - margin: 0; -} - -button, -input { - font: inherit; -} - -.app-shell { - min-height: 100svh; -} - -.app-header { - background: color-mix(in srgb, var(--bg) 92%, transparent); - border-bottom: 1px solid var(--line); - position: sticky; - top: 0; - z-index: 10; -} - -.app-header-inner { - align-items: center; - display: flex; - height: var(--header-h); - margin: 0 auto; - max-width: var(--container); - padding: 0 var(--gutter); -} - -.brand { - align-items: center; - color: var(--ink); - display: flex; - font-family: var(--font-serif); - font-size: 1.25rem; - font-weight: 700; - gap: 0.65rem; - letter-spacing: var(--tracking-heading); - text-decoration: none; -} - -.brand img { - border-radius: 50%; -} - -.app-nav { - align-self: stretch; - display: flex; - gap: 0.25rem; - margin-left: clamp(1.5rem, 5vw, 4rem); -} - -.app-nav a, -.header-action { - align-items: center; - color: var(--ink-soft); - display: flex; - font-size: 0.9rem; - font-weight: 600; - padding: 0 0.85rem; - position: relative; - text-decoration: none; -} - -.app-nav a:hover, -.header-action:hover, -.app-nav a.is-active, -.header-action.is-active { - color: var(--ink); -} - -.app-nav a.is-active::after { - background: var(--accent); - bottom: -1px; - content: ""; - height: 3px; - left: 0.85rem; - position: absolute; - right: 0.85rem; -} - -.header-action { - border: 1px solid var(--line-strong); - border-radius: var(--radius-pill); - margin-left: auto; - min-height: 2.4rem; -} - -.header-action.is-active { - background: var(--accent-tint); - border-color: var(--accent-soft); -} - -.app-header a:focus-visible { - outline: 3px solid var(--accent-soft); - outline-offset: 0.2rem; -} - -.app-content { - margin: 0 auto; - max-width: var(--container); - min-height: calc(100svh - var(--header-h)); - padding: clamp(2rem, 5vw, 4rem) var(--gutter); -} - -.app-content > main:not(.form-page) { - width: 100%; -} - -.app-content > main:not(.form-page) :is(h1, h2, p) { - margin-top: 0; -} - -.app-content h1, -.app-content h2 { - font-family: var(--font-serif); - letter-spacing: var(--tracking-heading); -} - -.app-content h1 { - font-size: var(--text-heading-2); - line-height: var(--leading-heading); -} - -.app-content p { - color: var(--ink-soft); - line-height: var(--leading-body); -} - -.form-page { - align-items: center; - display: flex; - justify-content: center; - min-height: calc(100svh - var(--header-h) - clamp(4rem, 10vw, 8rem)); -} - -.panel { - background: var(--card); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow-sm); -} - -.form-panel { - max-width: 28rem; - padding: clamp(1.5rem, 4vw, 2rem); - width: 100%; -} - -.panel-header { - border-bottom: 1px solid var(--line); - margin: 0 calc(clamp(1.5rem, 4vw, 2rem) * -1) 1.5rem; - padding: 0 clamp(1.5rem, 4vw, 2rem) 1.5rem; -} - -.panel-header h1 { - font-size: 1.65rem; - margin: 0 0 0.5rem; -} - -.panel-header p { - font-size: 0.95rem; - margin: 0; -} - -.form-panel form { - display: grid; - gap: 1rem; -} - -.field { - display: grid; - gap: 0.5rem; -} - -.field-heading { - align-items: baseline; - display: flex; - font-size: 0.9rem; - font-weight: 650; - justify-content: space-between; -} - -.field-status { - color: var(--ink-faint); - font-family: var(--font-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.field-status.required { - color: var(--accent-strong); -} - -.field input { - background: var(--surface-2); - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - color: var(--ink); - min-width: 0; - padding: 0.9rem 1rem; - transition: - border-color var(--duration-fast) var(--ease-standard), - box-shadow var(--duration-fast) var(--ease-standard); -} - -.field input::placeholder { - color: var(--ink-faint); -} - -.field input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-tint); - outline: none; -} - -.field input:read-only { - border-style: dashed; - color: var(--ink-soft); - cursor: default; - font-family: var(--font-mono); -} - -.field-hint { - color: var(--ink-faint); - font-size: 0.78rem; - line-height: 1.45; - margin: 0; -} - -.field-error { - color: var(--danger); - font-size: 0.78rem; - line-height: 1.45; - margin: 0; -} - -.field input[aria-invalid="true"] { - border-color: var(--danger); -} - -.field input[aria-invalid="true"]:focus { - border-color: var(--danger); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); -} - -.button { - align-items: center; - border: 0; - border-radius: var(--radius-sm); - cursor: pointer; - display: inline-flex; - font-weight: 700; - justify-content: center; - min-height: 2.9rem; - padding: 0.75rem 1.1rem; -} - -.button:focus-visible { - outline: 3px solid var(--accent-soft); - outline-offset: 0.2rem; -} - -.button.primary { - background: var(--accent); - color: var(--on-accent); - transition: - background var(--duration-fast) var(--ease-standard), - transform var(--duration-fast) var(--ease-standard); -} - -.button.primary:hover:not(:disabled) { - background: var(--accent-strong); -} - -.button:disabled { - cursor: wait; - opacity: 0.65; -} - -.notice { - background: var(--accent-tint); - border-radius: var(--radius-sm); - color: var(--ink); - line-height: 1.5; - margin: 1.25rem 0 0; - padding: 0.85rem 1rem; -} - -.notice.error { - background: color-mix(in srgb, var(--danger) 12%, var(--surface)); - border-left: 3px solid var(--danger); - color: var(--danger); -} - -.notice.success { - background: color-mix(in srgb, var(--success) 12%, var(--surface)); - border-left: 3px solid var(--success); - color: var(--success); -} - -.notice.warning { - background: color-mix(in srgb, var(--warning) 12%, var(--surface)); - border-left: 3px solid var(--warning); - color: var(--warning); -} - -@media (max-width: 600px) { - .app-header-inner { - padding: 0 1rem; - } - - .brand span { - display: none; - } - - .app-nav { - margin-left: 0.75rem; - } - - .app-nav a, - .header-action { - padding-inline: 0.65rem; - } - - .app-nav a.is-active::after { - left: 0.65rem; - right: 0.65rem; - } - - .app-content { - padding: 1.5rem 1rem 3rem; - } - - .form-page { - align-items: flex-start; - min-height: auto; - } -} - -@media (prefers-reduced-motion: reduce) { - .button.primary, - .field input { - transition: none; - } -} diff --git a/packages/web/src/styles/form.module.css b/packages/web/src/styles/form.module.css index 8217954..8e71adc 100644 --- a/packages/web/src/styles/form.module.css +++ b/packages/web/src/styles/form.module.css @@ -102,6 +102,15 @@ along with this program. If not, see . outline: none; } +.input[aria-invalid="true"] { + border-color: var(--danger); +} + +.input[aria-invalid="true"]:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); +} + .input:read-only { border-style: dashed; color: var(--ink-soft); @@ -155,12 +164,25 @@ along with this program. If not, see . display: block; } -.error { +.notice.error { + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); border-left: 3px solid var(--danger); + color: var(--danger); } -.success { - border-left: 3px solid var(--accent); +.notice.success { + background: color-mix(in srgb, var(--success) 12%, var(--surface)); + border-left: 3px solid var(--success); + color: var(--success); +} + +.field .notice.error { + background: transparent; + border-left: 0; + font-size: 0.78rem; + line-height: 1.45; + margin: 0; + padding: 0; } @media (max-width: 600px) { From 9618d256f73a22a5473706ffefcc25e2a55a1789 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 8 Sep 2026 13:20:58 +0900 Subject: [PATCH 09/10] Constrain generated slug word lengths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate each noun with a 1–20 character bound so the three-word instance slug remains within the schema's 4–63 character limit. Codex implemented the pull-request review feedback at the user's request. The change was verified with mise run check and by validating 10,000 generated slugs against the schema constraints. Assisted-by: Codex:gpt-5.6-sol --- packages/web/src/routes/workspace/create/instance.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index ca36f90..4f1441f 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -56,6 +56,8 @@ const createInstanceSchema = v.object({ ), }); +const generateSlugWord = () => faker.word.noun({ length: { min: 1, max: 20 } }); + export default function CreateInstancePage() { const navigate = useNavigate(); const [errorMessage, setErrorMessage] = createSignal(); @@ -65,7 +67,9 @@ export default function CreateInstancePage() { const createInstanceForm = createForm({ schema: createInstanceSchema, initialInput: { - slug: `${faker.word.noun()}-${faker.word.noun()}-${faker.word.noun()}`.toLowerCase(), + slug: [generateSlugWord(), generateSlugWord(), generateSlugWord()] + .join("-") + .toLowerCase(), }, }); From 7a41d5282e82e2b81ffaa1f51b3814e8dbf9ccdd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 8 Sep 2026 14:43:41 +0900 Subject: [PATCH 10/10] Install formisch and valibot skills --- .../formisch/.claude-plugin/plugin.json | 13 + .agents/skills/formisch/SKILL.md | 1118 +++++++++++++++++ .../skills/valibot/.claude-plugin/plugin.json | 13 + .agents/skills/valibot/SKILL.md | 765 +++++++++++ skills-lock.json | 12 + 5 files changed, 1921 insertions(+) create mode 100644 .agents/skills/formisch/.claude-plugin/plugin.json create mode 100644 .agents/skills/formisch/SKILL.md create mode 100644 .agents/skills/valibot/.claude-plugin/plugin.json create mode 100644 .agents/skills/valibot/SKILL.md diff --git a/.agents/skills/formisch/.claude-plugin/plugin.json b/.agents/skills/formisch/.claude-plugin/plugin.json new file mode 100644 index 0000000..05e30c7 --- /dev/null +++ b/.agents/skills/formisch/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "formisch", + "description": "Form validation with Formisch, the schema-based, headless form library", + "version": "1.0.0", + "author": { + "name": "Open Circle", + "url": "https://github.com/open-circle" + }, + "homepage": "https://formisch.dev", + "repository": "https://github.com/open-circle/agent-skills", + "license": "MIT", + "keywords": ["formisch", "forms", "validation", "typescript"] +} diff --git a/.agents/skills/formisch/SKILL.md b/.agents/skills/formisch/SKILL.md new file mode 100644 index 0000000..a2b27aa --- /dev/null +++ b/.agents/skills/formisch/SKILL.md @@ -0,0 +1,1118 @@ +--- +name: formisch +description: Form handling with Formisch, the schema-first and type-safe form library for Angular, Preact, Qwik, React, React Native, Solid, Svelte, and Vue. Use when creating forms, handling form state, validating inputs, working with field arrays, or using @formisch/* packages. +license: MIT +metadata: + author: open-circle + version: "1.3" +--- + +# Formisch + +This skill helps AI agents work effectively with [Formisch](https://formisch.dev/), the schema-based, headless form library for modern frameworks. + +## When to Use This Skill + +- When the user asks about form handling with Formisch +- When managing form state and validation +- When working with Angular, Preact, Qwik, React, React Native, Solid, Svelte, or Vue forms +- When integrating Valibot schemas with forms + +## Introduction + +Formisch is a schema-based, headless form library that works across multiple frameworks. Key highlights: + +- **Small bundle size** — Starting at ~2.5 kB +- **Schema-based validation** — Uses Valibot for type-safe validation +- **Headless design** — You control the UI completely +- **Type safety** — Full TypeScript support with autocompletion +- **Framework-native** — Native performance for each supported framework + +### Supported Frameworks + +| Framework | Package | Hook/Primitive | +| ------------ | ------------------------ | -------------- | +| Angular | `@formisch/angular` | `injectForm` | +| Preact | `@formisch/preact` | `useForm` | +| Qwik | `@formisch/qwik` | `useForm$` | +| React | `@formisch/react` | `useForm` | +| React Native | `@formisch/react-native` | `useForm` | +| SolidJS | `@formisch/solid` | `createForm` | +| Svelte | `@formisch/svelte` | `createForm` | +| Vue | `@formisch/vue` | `useForm` | + +## Installation + +### 1. Install Valibot (peer dependency) + +```bash +npm install valibot +``` + +### 2. Install Formisch for your framework + +```bash +npm install @formisch/react # React +npm install @formisch/angular # Angular +npm install @formisch/vue # Vue +npm install @formisch/solid # SolidJS +npm install @formisch/preact # Preact +npm install @formisch/svelte # Svelte +npm install @formisch/qwik # Qwik +npm install @formisch/react-native # React Native +``` + +## Core Concepts + +### Schema-First Design + +Every form starts with a Valibot schema. Types are automatically inferred from the schema. + +```ts +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe( + v.string("Please enter your email."), + v.nonEmpty("Please enter your email."), + v.email("The email address is badly formatted."), + ), + password: v.pipe( + v.string("Please enter your password."), + v.nonEmpty("Please enter your password."), + v.minLength(8, "Your password must have 8 characters or more."), + ), +}); +``` + +### Form Store + +The form store manages all form state. Access it via the framework-specific hook/primitive. + +**Form Store Properties:** + +- `isSubmitting` — Form is currently being submitted +- `isSubmitted` — Form submission has been attempted +- `isValidating` — Validation is in progress +- `isTouched` — At least one field has been touched +- `isEdited` — At least one field has been edited +- `isDirty` — At least one field differs from initial value +- `isValid` — All fields pass validation +- `errors` — Root-level validation errors + +### Field Store + +Each field has its own reactive store with: + +- `path` — Path array to the field +- `input` — Current field value +- `errors` — Field-specific errors +- `isTouched` — Field has been focused +- `isEdited` — Field value has been edited +- `isDirty` — Field value differs from initial value +- `isValid` — Field passes validation +- `props` — Props to spread onto native elements (Angular connects controls with `[formischControl]` instead) +- `onChange` (React and React Native) / `onInput` (Solid, Svelte, Preact, and Qwik) / `setInput` (Angular) — Sets the field input value programmatically. Use this when the field cannot be connected to a native element. In Vue, set `field.input` directly (for example with `v-model`). + +Store reactivity is framework-specific. React, React Native, Solid, Svelte, and Vue expose plain reactive properties. Angular properties are signals and are called like `field.errors()`, except `path`, which is a plain value. Preact and Qwik properties are signals; use `.value` in conditions and ordinary TypeScript logic. Do not copy one framework's access syntax into another. + +### Dirty Tracking + +Formisch tracks two inputs per field: + +- **Initial input** — Baseline for dirty tracking (server state) +- **Current input** — What the user is editing (client state) + +`isDirty` becomes `true` when current input differs from initial input. + +## Framework Examples + +### Angular Example + +Angular uses signals, dependency injection, and directives instead of a JSX component API. + +```ts +import { Component } from "@angular/core"; +import { + FormischControl, + FormischField, + FormischForm, + injectForm, + type SubmitHandler, +} from "@formisch/angular"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +@Component({ + selector: "app-login", + imports: [FormischForm, FormischField, FormischControl], + template: ` +
+ + + @if (field.errors(); as errors) { +
{{ errors[0] }}
+ } +
+ +
+ `, +}) +export class LoginComponent { + readonly loginForm = injectForm({ schema: LoginSchema }); + + readonly handleSubmit: SubmitHandler = (output) => { + console.log(output); + }; +} +``` + +Let `[formischControl]` synchronize the native control. Do not add competing `[value]` or `[checked]` bindings except when `value` identifies an option in a radio or checkbox group. + +### React Native Example + +React Native has no DOM `
` element or Formisch `Form` component. Use `handleSubmit` and bind `field.props` to `TextInput`. + +```tsx +import { Field, handleSubmit, useForm } from "@formisch/react-native"; +import { Button, TextInput, View } from "react-native"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +export default function LoginScreen() { + const loginForm = useForm({ schema: LoginSchema }); + const submitForm = handleSubmit(loginForm, (output) => console.log(output)); + + return ( + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + ); +} +``` + +### Vue Example + +```vue + + + +``` + +### SolidJS Example + +```tsx +import { Field, Form, createForm } from "@formisch/solid"; +import type { SubmitHandler } from "@formisch/solid"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +export default function LoginPage() { + const loginForm = createForm({ + schema: LoginSchema, + }); + + const handleSubmit: SubmitHandler = (output) => { + console.log(output); + }; + + return ( +
+ + {(field) => ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ )} +
+ + {(field) => ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ )} +
+ +
+ ); +} +``` + +### Svelte Example + +```svelte + + +
+ + {#snippet children(field)} +
+ + {#if field.errors} +
{field.errors[0]}
+ {/if} +
+ {/snippet} +
+ + {#snippet children(field)} +
+ + {#if field.errors} +
{field.errors[0]}
+ {/if} +
+ {/snippet} +
+ +
+``` + +### Qwik Example + +```tsx +import { Field, Form, useForm$ } from "@formisch/qwik"; +import { component$ } from "@qwik.dev/core"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +export default component$(() => { + const loginForm = useForm$(() => ({ + schema: LoginSchema, + })); + + return ( +
console.log(output)}> + ( +
+ + {field.errors.value &&
{field.errors.value[0]}
} +
+ )} + /> + ( +
+ + {field.errors.value &&
{field.errors.value[0]}
} +
+ )} + /> + + + ); +}); +``` + +## Form Configuration + +```ts +const form = useForm({ + // Required: Valibot schema + schema: MySchema, + + // Optional: Initial values (partial allowed) + initialInput: { + email: "user@example.com", + }, + + // Optional: Empty values for required fields without initial input + // Required strings default to ''; number, boolean, and date to undefined + emptyInput: { + number: 0, + }, + + // Optional: When first validation occurs + // Options: 'initial' | 'touch' | 'input' | 'change' | 'blur' | 'submit' (default) + validate: "submit", + + // Optional: When a field is validated again once it already has an + // error or the form has been submitted + // Options: 'touch' | 'input' (default) | 'change' | 'blur' | 'submit' + revalidate: "input", +}); +``` + +In Qwik, `useForm$` must receive a function that returns the config, e.g. `useForm$(() => ({ schema: MySchema }))`. This allows Qwik to convert the config into a QRL. + +Optional and nullable fields remain `undefined`. `emptyInput` only supplies fallbacks for required fields whose input is `undefined`. + +## Field Paths + +Paths are type-safe arrays that reference fields in your schema. + +```tsx +// Top-level field + + +// Nested field (schema: { user: { email: string } }) + + +// Array item field (schema: { todos: [{ label: string }] }) + + +// Dynamic array index +{items.map((item, index) => ( + +))} +``` + +## Form Methods + +All methods follow a consistent API pattern: + +- **First parameter**: Form store +- **Second parameter**: Config object + +### Reading Values + +```ts +import { + getDeepError, + getDeepErrorEntries, + getDeepErrorEntry, + getDeepErrors, + getErrors, + getInput, +} from "@formisch/react"; + +// Get field value +const email = getInput(form, { path: ["email"] }); + +// Get entire form input +const allInputs = getInput(form); + +// Get field errors +const emailErrors = getErrors(form, { path: ["email"] }); + +// Get all errors across all fields (including form-level errors) +const allErrors = getDeepErrors(form); + +// Get all errors of a field and its descendants +const todoErrors = getDeepErrors(form, { path: ["todos"] }); + +// Get every error together with its field path +const errorEntries = getDeepErrorEntries(form); + +// Get only the first error of a field and its descendants +const firstTodoError = getDeepError(form, { path: ["todos"] }); + +// Get only the first error together with its field path +const firstErrorEntry = getDeepErrorEntry(form); +``` + +Form-level `form.errors` and `getErrors(form)` contain only root-level errors. Use the deep-error methods when descendant field errors are needed. The singular variants `getDeepError` and `getDeepErrorEntry` stop at the first field with errors, which is useful for showing a single message for a nested structure. + +### Reading Dirty State + +```ts +import { + getDirtyInput, + getDirtyPaths, + isDirty, + pickDirty, +} from "@formisch/react"; + +// Raw dirty form input, or undefined when nothing is dirty +const dirtyInput = getDirtyInput(form); + +// Paths of dirty fields (arrays are treated as atomic values) +const dirtyPaths = getDirtyPaths(form); + +// Cheap boolean check when the dirty values are not needed +const hasChanges = isDirty(form); + +// Boolean check scoped to a field and its descendants +const emailChanged = isDirty(form, { path: ["email"] }); + +// In a submit handler, keep transformed output only where fields are dirty +const dirtyOutput = pickDirty(form, { from: output }); +``` + +`getDirtyInput` returns raw form input. `pickDirty` applies the form's dirty mask to a supplied value, which is useful for validated and transformed submit output. + +The sibling methods `isTouched`, `isEdited`, and `isValid` follow the same pattern as `isDirty`. Each checks the entire form when called without a config, or a specific field and its descendants when called with a `path`. + +### Setting Values + +```ts +import { setInput, setErrors, reset } from "@formisch/react"; + +// Set field value (updates current input, not initial) +setInput(form, { path: ["email"], input: "new@example.com" }); + +// Set field errors manually +setErrors(form, { path: ["email"], errors: ["Email already taken"] }); + +// Clear errors +setErrors(form, { path: ["email"], errors: null }); + +// Reset entire form +reset(form); + +// Reset a single field +reset(form, { path: ["email"] }); + +// Reset with new initial values +reset(form, { + initialInput: { email: "", password: "" }, +}); + +// Reset but keep current input +reset(form, { + initialInput: newServerData, + keepInput: true, +}); +``` + +`reset` also accepts the flags `keepTouched`, `keepEdited`, and `keepErrors`. The form-level reset additionally accepts `keepSubmitted`. All flags default to `false`. + +### Form Control + +```ts +import { validate, focus, submit, handleSubmit } from "@formisch/react"; + +// Validate form manually (returns a Promise of a Valibot SafeParseResult) +const result = await validate(form); +if (result.success) { + console.log(result.output); +} else { + console.log(result.issues); +} + +// Validate and focus first error field +await validate(form, { shouldFocus: true }); + +// Focus a specific field +focus(form, { path: ["email"] }); + +// Programmatically submit form +submit(form); + +// Create submit handler for external buttons +const onExternalSubmit = handleSubmit(form, (output) => { + console.log(output); +}); +``` + +`submit` requires a registered DOM form and is not exported by `@formisch/react-native`. In React Native and in layouts without a `
` element, call the function returned by `handleSubmit` instead. + +## Field Arrays + +For dynamic lists of fields, use `FieldArray` with array manipulation methods. + +The field array store exposes `path`, `items` (stable item IDs for use as keys), `errors`, `isTouched`, `isEdited`, `isDirty`, and `isValid`. + +### Schema + +```ts +const TodoSchema = v.object({ + heading: v.pipe(v.string(), v.nonEmpty()), + todos: v.pipe( + v.array( + v.object({ + label: v.pipe(v.string(), v.nonEmpty()), + deadline: v.pipe(v.string(), v.nonEmpty()), + }), + ), + v.nonEmpty(), + v.maxLength(10), + ), +}); +``` + +### React Example + +```tsx +import { + Field, + FieldArray, + Form, + useForm, + insert, + remove, + move, + swap, +} from "@formisch/react"; + +export default function TodoPage() { + const todoForm = useForm({ + schema: TodoSchema, + initialInput: { + heading: "", + todos: [{ label: "", deadline: "" }], + }, + }); + + return ( + console.log(output)}> + + {(field) => } + + + + {(fieldArray) => ( +
+ {fieldArray.items.map((item, index) => ( +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + +
+ ))} + {fieldArray.errors &&
{fieldArray.errors[0]}
} +
+ )} +
+ + + + +
+ ); +} +``` + +### Array Methods + +```ts +import { insert, remove, move, swap, replace } from "@formisch/react"; + +// Add item at end +insert(form, { path: ["todos"], initialInput: { label: "", deadline: "" } }); + +// Add item at specific index +insert(form, { + path: ["todos"], + at: 0, + initialInput: { label: "", deadline: "" }, +}); + +// Remove item at index +remove(form, { path: ["todos"], at: index }); + +// Move item from one index to another +move(form, { path: ["todos"], from: 0, to: 3 }); + +// Swap two items +swap(form, { path: ["todos"], at: 0, and: 1 }); + +// Replace item at index +replace(form, { + path: ["todos"], + at: 0, + initialInput: { label: "New task", deadline: "2024-12-31" }, +}); +``` + +## TypeScript Integration + +### Type Inference + +Types are automatically inferred from your Valibot schema: + +```ts +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +const form = useForm({ schema: LoginSchema }); +// form is FormStore + +// Submit handler receives typed output +const handleSubmit: SubmitHandler = (output) => { + output.email; // ✓ string + output.password; // ✓ string + output.username; // ✗ TypeScript error +}; +``` + +### Input vs Output Types + +Schemas with transformations have different input and output types: + +```ts +const ProfileSchema = v.object({ + age: v.pipe( + v.string(), // Input: string + v.transform((input) => Number(input)), // Output: number + v.number(), + ), + birthDate: v.pipe( + v.string(), // Input: string + v.transform((input) => new Date(input)), // Output: Date + v.date(), + ), +}); + +// In Field: field.input is string +// In onSubmit: output.age is number, output.birthDate is Date +``` + +### Type-Safe Props + +Pass forms to child components with proper typing: + +```tsx +import { Form, type FormStore, useForm } from "@formisch/react"; + +export default function LoginPage() { + const loginForm = useForm({ schema: LoginSchema }); + return ; +} + +type FormContentProps = { + of: FormStore; +}; + +function FormContent({ of }: FormContentProps) { + return ( +
console.log(output)}> + {/* ... */} +
+ ); +} +``` + +### Generic Field Components + +Create reusable field components with proper typing: + +```tsx +import { useField, type FormStore } from "@formisch/react"; +import * as v from "valibot"; + +type EmailInputProps = { + of: FormStore>; +}; + +function EmailInput({ of }: EmailInputProps) { + const field = useField(of, { path: ["email"] }); + + return ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ ); +} +``` + +The `v.GenericSchema<{ email: string }>` constraint accepts any form whose schema contains an `email` field of type `string`. TypeScript catches mismatches at compile time. + +### Available Types + +```ts +import type { + FormStore, // Form store type + FieldStore, // Field store type + FieldArrayStore, // Field array store type + SubmitHandler, // Submit handler function type + ValidPath, // Valid field path type + ValidArrayPath, // Valid array field path type + Schema, // Base schema type from Valibot +} from "@formisch/react"; +``` + +## Validation Timing + +### validate Option + +Controls when the **first** validation occurs: + +| Value | Description | +| ----------- | ---------------------------------------------- | +| `'initial'` | Validate immediately on form creation | +| `'touch'` | Validate when a field is first focused | +| `'input'` | Validate on every input event | +| `'change'` | Validate on change events (value is committed) | +| `'blur'` | Validate when field loses focus | +| `'submit'` | Validate only on form submission (default) | + +### revalidate Option + +Controls when a field is validated **again**, once it already has an error or the form has been submitted: + +| Value | Description | +| ---------- | ------------------------------------------------ | +| `'touch'` | Revalidate when a field is first focused | +| `'input'` | Revalidate on every input event (default) | +| `'change'` | Revalidate on change events (value is committed) | +| `'blur'` | Revalidate when field loses focus | +| `'submit'` | Revalidate only on form submission | + +## Special Inputs + +### Select (Single) + +```tsx + + {(field) => ( + + )} + +``` + +### Select (Multiple) + +```tsx + + {(field) => ( + + )} + +``` + +### Checkbox + +```tsx + + {(field) => } + +``` + +### File Input + +File inputs cannot be controlled. Handle via UI around them: + +```tsx + + {(field) => ( +
+ + {field.input && {field.input.name}} +
+ )} +
+``` + +## useField Hook + +For complex field components, use the `useField` hook instead of the `Field` component: + +```tsx +import { useField } from "@formisch/react"; +import { useEffect } from "react"; + +function EmailInput({ form }) { + const field = useField(form, { path: ["email"] }); + + // Access field state in component logic + useEffect(() => { + if (field.errors) { + console.log("Email has errors:", field.errors); + } + }, [field.errors]); + + return ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ ); +} +``` + +**When to use which:** + +- **`Field` component** — Multiple fields in the same component +- **`useField` hook** — Single field with component logic access + +The `useFieldArray` hook is the equivalent counterpart of the `FieldArray` component. In Angular, use the `injectField` and `injectFieldArray` functions or the `*formischField` and `*formischFieldArray` directives. + +## Using Component Libraries + +When using component libraries that don't expose their underlying native elements, you cannot spread `field.props` directly. Instead, update the value programmatically with `field.onChange` (React and React Native), `field.onInput` (Solid, Svelte, Preact, and Qwik), `field.setInput` (Angular), or by assigning to `field.input` (Vue): + +```tsx +import { DatePicker } from "some-component-library"; + + + {(field) => ( + field.onChange(newDate)} + /> + )} +; +``` + +These setters update the field value and trigger validation, just like a native input would. + +This is useful for: + +- **Component libraries** that wrap native elements without exposing them +- **Complex custom inputs** like date pickers, rich text editors, or color pickers + +## Async Submission + +```tsx +import { setErrors, useForm } from "@formisch/react"; +import type { SubmitHandler } from "@formisch/react"; + +const loginForm = useForm({ schema: LoginSchema }); + +const handleSubmit: SubmitHandler = async (output) => { + try { + const response = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(output), + }); + + if (!response.ok) { + // Set server-side errors + const data = await response.json(); + setErrors(loginForm, { path: ["email"], errors: [data.error] }); + } + } catch (error) { + console.error("Submission failed:", error); + } +}; +``` + +The form's `isSubmitting` state stays `true` until the async handler resolves. If the handler throws, Formisch catches the error and sets its message as a root-level form error on `form.errors`. + +## Common Patterns + +### Loading State + +```tsx + +``` + +### Submit on Enter + +Formisch handles this automatically via the native `
` element. + +### Reset After Success + +```tsx +const handleSubmit: SubmitHandler = async (output) => { + await saveData(output); + + // Full reset to initial state + reset(form); + + // Or reset but keep current input values + reset(form, { keepInput: true }); +}; +``` + +### Server Data Sync + +When server data changes, update the baseline without losing user edits: + +```tsx +// After refetching data from server +reset(form, { + initialInput: newServerData, + keepInput: true, // Keep user's current edits + keepTouched: true, // Keep touched state (optional) +}); +``` + +### Conditional Fields + +```tsx + + + {(field) => ( + + )} + + {getInput(form, { path: ["hasAccount"] }) && ( + + {(field) => } + + )} + +``` + +In React, calling `getInput` during render is reactive because `useForm` and `useField` enable signal tracking in the component that calls them. In the other frameworks, the read is tracked by their own reactive scopes. + +## Additional Resources + +- [Formisch Documentation](https://formisch.dev/) +- [Formisch Coding Agents Guide](https://formisch.dev/react/guides/coding-agents/) +- Formisch MCP server: `https://formisch.dev/mcp` (`search_docs`, `get_doc`, and `list_docs`) +- Append `.md` to any documentation URL for agent-friendly Markdown, or use `https://formisch.dev/llms-{framework}.txt` for a framework-specific index +- [Formisch GitHub](https://github.com/open-circle/formisch) +- [Valibot Documentation](https://valibot.dev/) diff --git a/.agents/skills/valibot/.claude-plugin/plugin.json b/.agents/skills/valibot/.claude-plugin/plugin.json new file mode 100644 index 0000000..7587c83 --- /dev/null +++ b/.agents/skills/valibot/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "valibot", + "description": "Schema validation with Valibot, the modular and type-safe schema library", + "version": "1.0.0", + "author": { + "name": "Open Circle", + "url": "https://github.com/open-circle" + }, + "homepage": "https://valibot.dev", + "repository": "https://github.com/open-circle/agent-skills", + "license": "MIT", + "keywords": ["valibot", "schema", "validation", "typescript"] +} diff --git a/.agents/skills/valibot/SKILL.md b/.agents/skills/valibot/SKILL.md new file mode 100644 index 0000000..c280d85 --- /dev/null +++ b/.agents/skills/valibot/SKILL.md @@ -0,0 +1,765 @@ +--- +name: valibot +description: Schema validation with Valibot, the modular and type-safe schema library. Use when the user needs to validate data, create schemas, parse inputs, or work with Valibot in their project. Also use when migrating from Zod to Valibot. +license: MIT +metadata: + author: open-circle + version: "1.0" +--- + +# Valibot + +This skill helps you work effectively with [Valibot](https://valibot.dev), the modular and type-safe schema library for validating structural data. + +## When to use this skill + +- When the user asks about schema validation with Valibot +- When creating or modifying Valibot schemas +- When parsing or validating user input +- When the user mentions Valibot, schema, or validation +- When migrating from Zod to Valibot + +## CRITICAL: Valibot vs Zod — Do Not Confuse! + +**Valibot and Zod have different APIs. Never mix them up!** + +### Key Differences + +| Feature | Zod ❌ | Valibot ✅ | +| ------------------- | ------------------------------------- | --------------------------------------------------------- | +| Import | `import { z } from 'zod'` | `import * as v from 'valibot'` | +| Validations | Chained methods: `.email().min(5)` | Pipeline: `v.pipe(v.string(), v.email(), v.minLength(5))` | +| Parsing | `schema.parse(data)` | `v.parse(schema, data)` | +| Safe parsing | `schema.safeParse(data)` | `v.safeParse(schema, data)` | +| Optional | `z.string().optional()` | `v.optional(v.string())` | +| Nullable | `z.string().nullable()` | `v.nullable(v.string())` | +| Default | `z.string().default('x')` | `v.optional(v.string(), 'x')` | +| Transform | `z.string().transform(fn)` | `v.pipe(v.string(), v.transform(fn))` | +| Refine/Check | `z.string().refine(fn)` | `v.pipe(v.string(), v.check(fn))` | +| Enum | `z.enum(['a', 'b'])` | `v.picklist(['a', 'b'])` | +| Native enum | `z.nativeEnum(MyEnum)` | `v.enum(MyEnum)` | +| Union | `z.union([a, b])` | `v.union([a, b])` | +| Discriminated union | `z.discriminatedUnion('type', [...])` | `v.variant('type', [...])` | +| Intersection | `z.intersection(a, b)` | `v.intersect([a, b])` | +| Min/max length | `.min(5).max(10)` | `v.minLength(5), v.maxLength(10)` | +| Min/max value | `.gte(5).lte(10)` | `v.minValue(5), v.maxValue(10)` | +| Infer type | `z.infer` | `v.InferOutput` | +| Infer input | `z.input` | `v.InferInput` | + +### Common Mistakes to Avoid + +```typescript +// ❌ WRONG - This is Zod syntax, NOT Valibot! +const Schema = v.string().email().min(5); +const result = Schema.parse(data); + +// ✅ CORRECT - Valibot uses functions and pipelines +const Schema = v.pipe(v.string(), v.email(), v.minLength(5)); +const result = v.parse(Schema, data); +``` + +```typescript +// ❌ WRONG - Zod-style optional +const Schema = v.object({ + name: v.string().optional(), +}); + +// ✅ CORRECT - Valibot wraps with optional() +const Schema = v.object({ + name: v.optional(v.string()), +}); +``` + +```typescript +// ❌ WRONG - Zod-style default +const Schema = v.string().default("hello"); + +// ✅ CORRECT - Valibot uses second argument +const Schema = v.optional(v.string(), "hello"); +``` + +## Installation + +```bash +npm install valibot # npm +yarn add valibot # yarn +pnpm add valibot # pnpm +bun add valibot # bun +``` + +Import with a wildcard (recommended): + +```typescript +import * as v from "valibot"; +``` + +Or with individual imports: + +```typescript +import { object, string, pipe, email, parse } from "valibot"; +``` + +## Mental Model + +Valibot's API is divided into three main concepts: + +### 1. Schemas + +Schemas define the expected data type. They are the starting point. + +```typescript +import * as v from "valibot"; + +// Primitive schemas +const StringSchema = v.string(); +const NumberSchema = v.number(); +const BooleanSchema = v.boolean(); +const DateSchema = v.date(); + +// Complex schemas +const ArraySchema = v.array(v.string()); +const ObjectSchema = v.object({ + name: v.string(), + age: v.number(), +}); +``` + +### 2. Methods + +Methods help you use or modify schemas. The schema is always the first argument. + +```typescript +// Parsing +const result = v.parse(StringSchema, "hello"); +const safeResult = v.safeParse(StringSchema, "hello"); + +// Type guard +if (v.is(StringSchema, data)) { + // data is typed as string +} +``` + +### 3. Actions + +Actions validate or transform data within a `pipe()`. They MUST be used inside pipelines. + +```typescript +// Actions are used in pipe() +const EmailSchema = v.pipe( + v.string(), + v.trim(), + v.email(), + v.endsWith("@example.com"), +); +``` + +## Pipelines + +Pipelines extend schemas with validation and transformation actions. A pipeline always starts with a schema, followed by actions. + +```typescript +import * as v from "valibot"; + +const UsernameSchema = v.pipe( + v.string(), + v.trim(), + v.minLength(3, "Username must be at least 3 characters"), + v.maxLength(20, "Username must be at most 20 characters"), + v.regex( + /^[a-z0-9_]+$/i, + "Username can only contain letters, numbers, and underscores", + ), +); + +const AgeSchema = v.pipe( + v.number(), + v.integer("Age must be a whole number"), + v.minValue(0, "Age cannot be negative"), + v.maxValue(150, "Age cannot exceed 150"), +); +``` + +### Common Validation Actions + +**String validations:** + +- `v.email()` — Valid email format +- `v.url()` — Valid URL format +- `v.uuid()` — Valid UUID format +- `v.regex(pattern)` — Match regex pattern +- `v.minLength(n)` — Minimum length +- `v.maxLength(n)` — Maximum length +- `v.length(n)` — Exact length +- `v.nonEmpty()` — Not empty string +- `v.startsWith(str)` — Starts with string +- `v.endsWith(str)` — Ends with string +- `v.includes(str)` — Contains string + +**Number validations:** + +- `v.minValue(n)` — Minimum value (>=) +- `v.maxValue(n)` — Maximum value (<=) +- `v.gtValue(n)` — Greater than (>) +- `v.ltValue(n)` — Less than (<) +- `v.integer()` — Must be integer +- `v.finite()` — Must be finite +- `v.safeInteger()` — Safe integer range +- `v.multipleOf(n)` — Must be multiple of n + +**Array validations:** + +- `v.minLength(n)` — Minimum items +- `v.maxLength(n)` — Maximum items +- `v.length(n)` — Exact item count +- `v.nonEmpty()` — At least one item +- `v.includes(item)` — Contains item +- `v.excludes(item)` — Does not contain item + +### Custom Validation with check() + +```typescript +const PasswordSchema = v.pipe( + v.string(), + v.minLength(8), + v.check( + (input) => /[A-Z]/.test(input), + "Password must contain an uppercase letter", + ), + v.check((input) => /[0-9]/.test(input), "Password must contain a number"), +); +``` + +### Value Transformations + +These actions modify the value without changing its type: + +**String transformations:** + +- `v.trim()` — Remove leading/trailing whitespace +- `v.trimStart()` — Remove leading whitespace +- `v.trimEnd()` — Remove trailing whitespace +- `v.toLowerCase()` — Convert to lowercase +- `v.toUpperCase()` — Convert to uppercase + +**Number transformations:** + +- `v.toMinValue(n)` — Clamp to minimum value (if less than n, set to n) +- `v.toMaxValue(n)` — Clamp to maximum value (if greater than n, set to n) + +```typescript +const NormalizedEmailSchema = v.pipe( + v.string(), + v.trim(), + v.toLowerCase(), + v.email(), +); + +// Clamp number to range 0-100 +const PercentageSchema = v.pipe(v.number(), v.toMinValue(0), v.toMaxValue(100)); +``` + +### Type Transformations + +For converting between data types, use these built-in transformation actions: + +- `v.toNumber()` — Convert to number +- `v.toString()` — Convert to string +- `v.toBoolean()` — Convert to boolean +- `v.toBigint()` — Convert to bigint +- `v.toDate()` — Convert to Date + +```typescript +// Convert string to number +const PortSchema = v.pipe(v.string(), v.toNumber(), v.integer(), v.minValue(1)); + +// Convert ISO string to Date +const TimestampSchema = v.pipe(v.string(), v.isoDateTime(), v.toDate()); + +// Convert to boolean +const FlagSchema = v.pipe(v.string(), v.toBoolean()); +``` + +### Custom Transformations + +For custom transformations, use `v.transform()`: + +```typescript +const DateStringSchema = v.pipe( + v.string(), + v.isoDate(), + v.transform((input) => new Date(input)), +); + +// Custom object transformation +const UserSchema = v.pipe( + v.object({ + firstName: v.string(), + lastName: v.string(), + }), + v.transform((input) => ({ + ...input, + fullName: `${input.firstName} ${input.lastName}`, + })), +); +``` + +## Object Schemas + +### Basic Object + +```typescript +const UserSchema = v.object({ + id: v.number(), + name: v.string(), + email: v.pipe(v.string(), v.email()), + age: v.optional(v.number()), +}); + +type User = v.InferOutput; +``` + +### Object Variants + +```typescript +// Regular object - strips unknown keys (default) +const ObjectSchema = v.object({ key: v.string() }); + +// Loose object - allows and preserves unknown keys +const LooseObjectSchema = v.looseObject({ key: v.string() }); + +// Strict object - throws on unknown keys +const StrictObjectSchema = v.strictObject({ key: v.string() }); + +// Object with rest - validates unknown keys against a schema +const ObjectWithRestSchema = v.objectWithRest( + { key: v.string() }, + v.number(), // unknown keys must be numbers +); +``` + +### Optional and Nullable Fields + +```typescript +const ProfileSchema = v.object({ + // Required + name: v.string(), + + // Optional (can be undefined or missing) + nickname: v.optional(v.string()), + + // Optional with default + role: v.optional(v.string(), "user"), + + // Nullable (can be null) + avatar: v.nullable(v.string()), + + // Nullish (can be null or undefined) + bio: v.nullish(v.string()), + + // Nullish with default + theme: v.nullish(v.string(), "light"), +}); +``` + +### Object Methods + +```typescript +const BaseSchema = v.object({ + id: v.number(), + name: v.string(), + email: v.string(), + password: v.string(), +}); + +// Pick specific keys +const PublicUserSchema = v.pick(BaseSchema, ["id", "name"]); + +// Omit specific keys +const UserWithoutPasswordSchema = v.omit(BaseSchema, ["password"]); + +// Make all optional +const PartialUserSchema = v.partial(BaseSchema); + +// Make all required +const RequiredUserSchema = v.required(PartialUserSchema); + +// Merge objects +const ExtendedUserSchema = v.object({ + ...BaseSchema.entries, + createdAt: v.date(), +}); +``` + +### Cross-Field Validation + +```typescript +const RegistrationSchema = v.pipe( + v.object({ + password: v.pipe(v.string(), v.minLength(8)), + confirmPassword: v.string(), + }), + v.forward( + v.partialCheck( + [["password"], ["confirmPassword"]], + (input) => input.password === input.confirmPassword, + "Passwords do not match", + ), + ["confirmPassword"], + ), +); +``` + +## Arrays and Tuples + +### Arrays + +```typescript +const TagsSchema = v.pipe( + v.array(v.string()), + v.minLength(1, "At least one tag required"), + v.maxLength(10, "Maximum 10 tags allowed"), +); + +// Array of objects +const UsersSchema = v.array( + v.object({ + id: v.number(), + name: v.string(), + }), +); +``` + +### Tuples + +```typescript +// Fixed-length array with specific types +const CoordinatesSchema = v.tuple([v.number(), v.number()]); +// Type: [number, number] + +// Tuple with rest +const ArgsSchema = v.tupleWithRest( + [v.string()], // first arg is string + v.number(), // rest are numbers +); +// Type: [string, ...number[]] +``` + +## Unions and Variants + +### Union + +```typescript +const StringOrNumberSchema = v.union([v.string(), v.number()]); + +const StatusSchema = v.union([ + v.literal("pending"), + v.literal("active"), + v.literal("inactive"), +]); +``` + +### Picklist (for string/number literals) + +```typescript +// Simpler than union of literals +const StatusSchema = v.picklist(["pending", "active", "inactive"]); + +const PrioritySchema = v.picklist([1, 2, 3]); +``` + +### Variant (discriminated union) + +Use `variant` for better performance with discriminated unions: + +```typescript +const EventSchema = v.variant("type", [ + v.object({ + type: v.literal("click"), + x: v.number(), + y: v.number(), + }), + v.object({ + type: v.literal("keypress"), + key: v.string(), + }), + v.object({ + type: v.literal("scroll"), + direction: v.picklist(["up", "down"]), + }), +]); +``` + +## Parsing Data + +### parse() — Throws on Error + +```typescript +import * as v from "valibot"; + +const EmailSchema = v.pipe(v.string(), v.email()); + +try { + const email = v.parse(EmailSchema, "jane@example.com"); + console.log(email); // 'jane@example.com' +} catch (error) { + console.error(error); // ValiError +} +``` + +### safeParse() — Returns Result Object + +```typescript +const result = v.safeParse(EmailSchema, input); + +if (result.success) { + console.log(result.output); // Valid data +} else { + console.log(result.issues); // Array of issues +} +``` + +### is() — Type Guard + +```typescript +if (v.is(EmailSchema, input)) { + // input is typed as string +} +``` + +### Configuration Options + +```typescript +// Abort early - stop at first error +v.parse(Schema, data, { abortEarly: true }); + +// Abort pipe early - stop pipeline at first error +v.parse(Schema, data, { abortPipeEarly: true }); +``` + +## Type Inference + +```typescript +import * as v from "valibot"; + +const UserSchema = v.object({ + name: v.string(), + age: v.pipe(v.string(), v.transform(Number)), + role: v.optional(v.string(), "user"), +}); + +// Output type (after transformations and defaults) +type User = v.InferOutput; +// { name: string; age: number; role: string } + +// Input type (before transformations) +type UserInput = v.InferInput; +// { name: string; age: string; role?: string | undefined } + +// Issue type +type UserIssue = v.InferIssue; +``` + +## Error Handling + +### Custom Error Messages + +```typescript +const LoginSchema = v.object({ + email: v.pipe( + v.string("Email must be a string"), + v.nonEmpty("Please enter your email"), + v.email("Invalid email format"), + ), + password: v.pipe( + v.string("Password must be a string"), + v.nonEmpty("Please enter your password"), + v.minLength(8, "Password must be at least 8 characters"), + ), +}); +``` + +### Flattening Errors + +```typescript +const result = v.safeParse(LoginSchema, data); + +if (!result.success) { + const flat = v.flatten(result.issues); + // { nested: { email: ['Invalid email format'], password: ['...'] } } +} +``` + +### Issue Structure + +Each issue contains: + +- `kind`: 'schema' | 'validation' | 'transformation' +- `type`: Function name (e.g., 'string', 'email', 'min_length') +- `input`: The problematic input +- `expected`: What was expected +- `received`: What was received +- `message`: Human-readable message +- `path`: Array of path items for nested issues + +## Fallback Values + +```typescript +// Static fallback +const NumberSchema = v.fallback(v.number(), 0); +v.parse(NumberSchema, "invalid"); // Returns 0 + +// Dynamic fallback +const DateSchema = v.fallback(v.date(), () => new Date()); +``` + +## Recursive Schemas + +```typescript +import * as v from "valibot"; + +type TreeNode = { + value: string; + children: TreeNode[]; +}; + +const TreeNodeSchema: v.GenericSchema = v.object({ + value: v.string(), + children: v.lazy(() => v.array(TreeNodeSchema)), +}); +``` + +## Async Validation + +For async operations (e.g., database checks), use async variants: + +```typescript +import * as v from "valibot"; + +const isUsernameAvailable = async (username: string) => { + // Check database + return true; +}; + +const UsernameSchema = v.pipeAsync( + v.string(), + v.minLength(3), + v.checkAsync(isUsernameAvailable, "Username is already taken"), +); + +// Must use parseAsync +const username = await v.parseAsync(UsernameSchema, "john"); +``` + +## JSON Schema Conversion + +```typescript +import { toJsonSchema } from "@valibot/to-json-schema"; +import * as v from "valibot"; + +const EmailSchema = v.pipe(v.string(), v.email()); +const jsonSchema = toJsonSchema(EmailSchema); +// { type: 'string', format: 'email' } +``` + +## Naming Conventions + +### Convention 1: Same Name (Recommended for simplicity) + +```typescript +export const User = v.object({ + name: v.string(), + email: v.pipe(v.string(), v.email()), +}); + +export type User = v.InferOutput; + +// Usage +const users: User[] = []; +users.push(v.parse(User, data)); +``` + +### Convention 2: With Suffixes (Recommended when input/output differ) + +```typescript +export const UserSchema = v.object({ + name: v.string(), + age: v.pipe(v.string(), v.transform(Number)), +}); + +export type UserInput = v.InferInput; +export type UserOutput = v.InferOutput; +``` + +## Common Patterns + +### Login Form + +```typescript +const LoginSchema = v.object({ + email: v.pipe( + v.string(), + v.nonEmpty("Please enter your email"), + v.email("Invalid email address"), + ), + password: v.pipe( + v.string(), + v.nonEmpty("Please enter your password"), + v.minLength(8, "Password must be at least 8 characters"), + ), +}); +``` + +### API Response + +```typescript +const ApiResponseSchema = v.variant("status", [ + v.object({ + status: v.literal("success"), + data: v.unknown(), + }), + v.object({ + status: v.literal("error"), + error: v.object({ + code: v.string(), + message: v.string(), + }), + }), +]); +``` + +### Environment Variables + +```typescript +const EnvSchema = v.object({ + NODE_ENV: v.picklist(["development", "production", "test"]), + PORT: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + DATABASE_URL: v.pipe(v.string(), v.url()), + API_KEY: v.pipe(v.string(), v.minLength(32)), +}); + +const env = v.parse(EnvSchema, process.env); +``` + +### Date Handling + +```typescript +// String to Date +const DateFromStringSchema = v.pipe( + v.string(), + v.isoDate(), + v.transform((input) => new Date(input)), +); + +// Date validation +const FutureDateSchema = v.pipe( + v.date(), + v.minValue(new Date(), "Date must be in the future"), +); +``` + +## Additional Resources + +- [Valibot Documentation](https://valibot.dev) +- [Valibot GitHub](https://github.com/open-circle/valibot) +- [API Reference](https://valibot.dev/api/) +- [Migration from Zod](https://valibot.dev/guides/migrate-from-zod/) diff --git a/skills-lock.json b/skills-lock.json index 6555002..57df429 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,12 @@ { "version": 1, "skills": { + "formisch": { + "source": "open-circle/agent-skills", + "sourceType": "github", + "skillPath": "skills/formisch/SKILL.md", + "computedHash": "087c39dfece92b6d7e340d47c842e65492a4ad4f8c787e942df4580226061c92" + }, "graphql-schema-design": { "source": "ChilliCream/agent-skills", "sourceType": "github", @@ -18,6 +24,12 @@ "sourceType": "github", "skillPath": "skills/relay-performance/SKILL.md", "computedHash": "d60fb55230c107f2f32047b5ba7f8309fb32329eb3aa3316167ce7052e31c7a5" + }, + "valibot": { + "source": "open-circle/agent-skills", + "sourceType": "github", + "skillPath": "skills/valibot/SKILL.md", + "computedHash": "dc7ad7dc09548808d26aef0bc58786a4ee3559e153a1e83c9c4574f3a294ea1b" } } }