chore: starter template

This commit is contained in:
e2e
2026-07-10 19:42:56 +02:00
commit e5c3377b6b
157 changed files with 6462 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
node_modules
dist
.vite
.wrangler
# All *.gen.* files are regenerated by `pikku all` — never commit them.
*.gen.ts
*.gen.js
*.gen.json
*.gen.mjs
# Yarn — commit the root lockfile; ignore PnP/cache and generated per-unit lockfiles
.yarn/
.pnp.*
yarn.lock
!/yarn.lock
# Injected/symlinked at sandbox runtime — never commit
vendor/
.opencode
.pikku-runtime
__fabric_scaffold.vite.config.mjs

4
.yarnrc.yml Normal file
View File

@@ -0,0 +1,4 @@
# Force node_modules linker — some toolchain deps (uWebSockets.js native
# addon, pikku CLI bin, esbuild native) resolve cleanly only with a
# traditional tree.
nodeLinker: node-modules

28
AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# Building in this project
This app ships a **component kit** at `@project/components` (source in
`packages/components/src`). **Compose these instead of hand-rolling UI** — it is faster,
consistent, and already themed. Import from `@project/components`.
## Available components
Layout:
- `PageHeader` — page title + optional description + right-aligned `actions` slot. Use one per page.
- `Panel` — bordered section with an optional titled header (`title`, `description`, `actions`). The workhorse container.
- `StatCard` — a single metric tile (`label`, `value`, optional `icon`, `color`).
- `StatGrid` — responsive row of `StatCard`s; pass `stats={[{label, value, icon}]}`.
Data (feed these the output of an RPC you implement):
- `DataTable` — generic typed table. Props: `columns` (`{key, header, render?, align?}`), `rows`, `rowKey`, optional `loading`/`empty`/`onRowClick`. Implement a `listX` RPC and pass its rows.
- `BarChart` — dependency-free horizontal bars from `data={[{label, value, color?}]}`. Good for status/count breakdowns from a stats RPC.
State (already used by the app):
- `EmptyState`, `PageLoader`, `NotFoundState`, `ServerErrorState`, `UserCard`.
## How to build a feature page
1. Implement the backend RPCs (`pikku-rpc` / `pikku-kysely` skills): a `listX` and any mutations, with zod input/output and `auth: true`. Scope rows to the signed-in user (`where('userId','=',session.userId)`).
2. Fetch with `usePikkuQuery('listX', {})` and mutate with `usePikkuMutation('createX')` (from `@project/functions-sdk/pikku/api.gen`).
3. Build the page by composing the kit: `PageHeader` + `Panel` + `DataTable`/`StatGrid`/`BarChart`. Pass i18n strings (`m.key()`) into the component props.
These are props-only components — they never fetch data themselves. You own the page that wires RPC data into them.

42
apps/app/messages/en.json Normal file
View File

@@ -0,0 +1,42 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error__title": "This page isn't running properly",
"error__hint": "If an agent is building this app, just wait — it's likely mid-change and will fix this shortly, and the page will refresh on its own.",
"error__retry": "Try again",
"error__details": "Error details",
"common__loading": "Loading…",
"common__email": "Email",
"common__email_placeholder": "you@example.com",
"common__password": "Password",
"common__password_placeholder": "••••••••",
"common__name": "Name",
"common__name_placeholder": "Ada Lovelace",
"validation__required": "Required.",
"validation__email_invalid": "Enter a valid email address.",
"app__name": "Fabric Starter",
"auth__or": "or",
"auth__continue_with_google": "Continue with Google",
"auth__google_error": "Unable to continue with Google right now.",
"auth__login__title": "Sign in",
"auth__login__description": "Welcome back to {name}.",
"auth__login__cta": "Sign in",
"auth__login__invalid_credentials": "That email and password don't match.",
"auth__login__error": "Unable to sign in right now.",
"auth__login__footer_prompt": "Don't have an account?",
"auth__login__footer_action": "Sign up",
"auth__dev_quick_login": "Quick login (dev)",
"auth__dev_quick_login_hint": "Development only — signs in as test@example.com",
"auth__signup__title": "Create your account",
"auth__signup__description": "Get started with {name}.",
"auth__signup__cta": "Create account",
"auth__signup__email_in_use": "An account with this email already exists.",
"auth__signup__error": "Unable to create your account right now.",
"auth__signup__footer_prompt": "Already have an account?",
"auth__signup__footer_action": "Sign in",
"nav__home": "Home",
"app_shell__sign_out": "Sign out",
"home__title": "Hello, world.",
"home__body": "You're signed in to {name}. This is the bare-bones starter every project begins from — describe what you want to build and it gets reshaped from here.",
"preferences__language": "Language",
"preferences__theme": "Theme"
}

36
apps/app/messages/fr.json Normal file
View File

@@ -0,0 +1,36 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common__loading": "Chargement…",
"common__email": "Email",
"common__email_placeholder": "vous@exemple.com",
"common__password": "Mot de passe",
"common__password_placeholder": "••••••••",
"common__name": "Nom",
"common__name_placeholder": "Ada Lovelace",
"validation__required": "Requis.",
"validation__email_invalid": "Saisissez une adresse email valide.",
"app__name": "Fabric Starter",
"auth__or": "ou",
"auth__continue_with_google": "Continuer avec Google",
"auth__google_error": "Impossible de continuer avec Google pour l'instant.",
"auth__login__title": "Se connecter",
"auth__login__description": "Bon retour sur {name}.",
"auth__login__cta": "Se connecter",
"auth__login__invalid_credentials": "Cet email et ce mot de passe ne correspondent pas.",
"auth__login__error": "Impossible de se connecter pour l'instant.",
"auth__login__footer_prompt": "Pas encore de compte ?",
"auth__login__footer_action": "S'inscrire",
"auth__signup__title": "Créez votre compte",
"auth__signup__description": "Commencez avec {name}.",
"auth__signup__cta": "Créer un compte",
"auth__signup__email_in_use": "Un compte avec cet email existe déjà.",
"auth__signup__error": "Impossible de créer votre compte pour l'instant.",
"auth__signup__footer_prompt": "Vous avez déjà un compte ?",
"auth__signup__footer_action": "Se connecter",
"nav__home": "Accueil",
"app_shell__sign_out": "Se déconnecter",
"home__title": "Bonjour, monde.",
"home__body": "Vous êtes connecté à {name}. C'est le point de départ minimal de chaque projet — décrivez ce que vous voulez construire et tout se reconstruit à partir d'ici.",
"preferences__language": "Langue",
"preferences__theme": "Thème"
}

42
apps/app/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@project/app",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 7104",
"build": "vite build",
"preview": "vite preview --port 7104",
"tsc": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/app-tsc.tsbuildinfo"
},
"dependencies": {
"@assistant-ui/react": "^0.14.24",
"@mantine/core": "^9.2.1",
"@mantine/hooks": "^9.2.1",
"@pikku/assistant-ui": "^0.12.7",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5",
"@project/components": "workspace:*",
"@project/functions-sdk": "workspace:*",
"@project/mantine-themes": "workspace:*",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"better-auth": "^1.6.18",
"lucide-react": "^0.456.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@inlang/paraglide-js": "^2.20.0",
"@tanstack/router-generator": "^1.132.0",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "^5.9",
"vite": "^7.0.0"
}
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "fr"],
"modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -0,0 +1,90 @@
import { AppShell as MantineAppShell, Box, Button, NavLink, Stack } from '@pikku/mantine/core'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useState, type FC } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { signOut } from '@/lib/auth'
import { Wordmark } from './Wordmark'
import { LanguageSelector } from './LanguageSelector'
import { ThemeSelector } from './ThemeSelector'
const HomeGlyph = () => <NavGlyph d="M3 12 12 4l9 8M5 10v9h5v-6h4v6h5v-9" />
const SignOutGlyph = () => (
<NavGlyph d="M16 17l5-5-5-5M21 12H9M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
)
function NavGlyph({ d }: { d: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
)
}
export const AppShell: FC = () => {
useLocale()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const [isSigningOut, setIsSigningOut] = useState(false)
return (
<MantineAppShell navbar={{ width: 236, breakpoint: 'sm' }} padding="xl">
<MantineAppShell.Navbar p="md">
<Stack h="100%" gap={4}>
<Box px="xs" py="sm">
<Wordmark name={m.app__name()} size={26} />
</Box>
<Stack gap={2} mt="xs">
<NavLink
component={Link}
to="/app"
label={m.nav__home()}
leftSection={<HomeGlyph />}
active={pathname === '/app'}
/>
</Stack>
<Stack gap="xs" mt="auto">
<ThemeSelector />
<LanguageSelector />
<Button
variant="subtle"
color="gray"
fullWidth
justify="flex-start"
leftSection={<SignOutGlyph />}
loading={isSigningOut}
onClick={async () => {
setIsSigningOut(true)
try {
await signOut()
window.location.href = '/login'
} finally {
setIsSigningOut(false)
}
}}
>
{m.app_shell__sign_out()}
</Button>
</Stack>
</Stack>
</MantineAppShell.Navbar>
{/* Flex column so a full-height page (e.g. a chat) can fill the remaining
viewport with just `flex: 1, minHeight: 0` — no viewport math needed.
Content-sized pages are unaffected. */}
<MantineAppShell.Main style={{ display: 'flex', flexDirection: 'column' }}>
<Outlet />
</MantineAppShell.Main>
</MantineAppShell>
)
}

View File

@@ -0,0 +1,227 @@
import type { FC } from 'react'
import type { I18nNode, I18nString } from '@pikku/react'
import { useForm } from '@tanstack/react-form'
import {
Box,
Button,
Card,
Divider,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { Wordmark } from './Wordmark'
export type AuthFormValues = { name: string; email: string; password: string }
type AuthCardProps = {
appName: I18nString
title: I18nString
description: I18nString
cta: I18nString
// Show the Name field (signup) above email.
includeName?: boolean
passwordAutoComplete: 'current-password' | 'new-password'
// Server-side error from the page's mutation (bad credentials, email in use…).
error: I18nString | null
googleBusy: boolean
// Submitting state of the page's mutation; drives the primary button spinner.
busy: boolean
footer: I18nNode
// Called with validated values once the TanStack form passes validation.
onAuthSubmit: (values: AuthFormValues) => void
onGoogle: () => void
// Dev-only: prefill the form (e.g. the test account) when provided.
initialValues?: Partial<AuthFormValues>
// Dev-only: a one-click quick-login action rendered below the form.
quickLogin?: { label: I18nString; hint: I18nString; onClick: () => void; busy: boolean } | null
}
const ArrowGlyph = () => (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
)
const GoogleGlyph = () => (
<svg width="16" height="16" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.57c2.08-1.92 3.27-4.74 3.27-8.09Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.76c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.22V7.04H2.18a11 11 0 0 0 0 9.92l3.66-2.86Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.04L5.84 9.9C6.71 7.3 9.14 5.38 12 5.38Z"
/>
</svg>
)
const required = (value: string): I18nString | undefined =>
value.trim() ? undefined : m.validation__required()
const validEmail = (value: string): I18nString | undefined => {
if (!value.trim()) return m.validation__required()
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) ? undefined : m.validation__email_invalid()
}
export const AuthCard: FC<AuthCardProps> = (props) => {
useLocale()
const form = useForm({
defaultValues: { name: '', email: '', password: '', ...props.initialValues } as AuthFormValues,
onSubmit: ({ value }) => props.onAuthSubmit(value),
})
return (
<Box
mih="100vh"
style={{ display: 'grid', placeItems: 'center', background: 'var(--mantine-color-body)' }}
p="xl"
>
<Stack w="100%" maw={380} gap="lg">
<Group justify="center">
<Wordmark name={props.appName} />
</Group>
<Card withBorder radius="lg" shadow="sm" padding="xl">
<Stack gap="lg">
<div>
<Title order={2} fz={21} fw={650} style={{ letterSpacing: '-0.025em' }}>
{props.title}
</Title>
<Text c="dimmed" size="sm" mt={4}>
{props.description}
</Text>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void form.handleSubmit()
}}
>
<Stack gap="sm">
{props.includeName ? (
<form.Field name="name" validators={{ onChange: ({ value }) => required(value) }}>
{(field) => (
<TextInput
label={m.common__name()}
placeholder={m.common__name_placeholder()}
value={field.state.value}
onChange={(event) => field.handleChange(event.currentTarget.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0] as I18nString | undefined}
autoComplete="name"
/>
)}
</form.Field>
) : null}
<form.Field
name="email"
validators={{ onChange: ({ value }) => validEmail(value) }}
>
{(field) => (
<TextInput
label={m.common__email()}
placeholder={m.common__email_placeholder()}
value={field.state.value}
onChange={(event) => field.handleChange(event.currentTarget.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0] as I18nString | undefined}
type="email"
autoComplete="email"
/>
)}
</form.Field>
<form.Field
name="password"
validators={{ onChange: ({ value }) => required(value) }}
>
{(field) => (
<PasswordInput
label={m.common__password()}
placeholder={m.common__password_placeholder()}
value={field.state.value}
onChange={(event) => field.handleChange(event.currentTarget.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0] as I18nString | undefined}
autoComplete={props.passwordAutoComplete}
/>
)}
</form.Field>
{props.error ? (
<Text c="red" size="sm">
{props.error}
</Text>
) : null}
<Button type="submit" loading={props.busy} fullWidth rightSection={<ArrowGlyph />}>
{props.cta}
</Button>
</Stack>
</form>
{props.quickLogin ? (
<Stack gap={4}>
<Button
variant="light"
fullWidth
loading={props.quickLogin.busy}
onClick={props.quickLogin.onClick}
>
{props.quickLogin.label}
</Button>
<Text ta="center" size="xs" c="dimmed">
{props.quickLogin.hint}
</Text>
</Stack>
) : null}
<Divider label={m.auth__or()} labelPosition="center" />
<Button
variant="default"
fullWidth
loading={props.googleBusy}
leftSection={<GoogleGlyph />}
onClick={props.onGoogle}
>
{m.auth__continue_with_google()}
</Button>
</Stack>
</Card>
<Text ta="center" size="sm" c="dimmed">
{props.footer}
</Text>
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react'
import { Box, Button, Group, Stack, Text, Title } from '@pikku/mantine/core'
import { AlertTriangle, ChevronRight } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
// Router-level error boundary (wired as the router's defaultErrorComponent). In
// dev it reveals the raw error in an expandable section — agents watch for this
// while iterating — while production stays a calm, generic message.
export function DefaultErrorPage({ error }: { error: unknown }) {
useLocale()
const [open, setOpen] = useState(false)
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
return (
<Box
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<Stack align="center" gap="md" style={{ maxWidth: 540, width: '100%' }}>
<Box
style={{
width: 56,
height: 56,
borderRadius: 999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'light-dark(rgba(249, 115, 22, 0.1), rgba(249, 115, 22, 0.15))',
color: '#f97316',
}}
>
<AlertTriangle size={28} />
</Box>
<Title order={2} ta="center">
{m.error__title()}
</Title>
<Text c="dimmed" ta="center" size="sm">
{m.error__hint()}
</Text>
<Group>
<Button variant="light" onClick={() => window.location.reload()}>
{m.error__retry()}
</Button>
</Group>
{import.meta.env.DEV && detail ? (
<Stack gap={6} style={{ width: '100%' }}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={
<ChevronRight
size={14}
style={{
transform: open ? 'rotate(90deg)' : undefined,
transition: 'transform 120ms ease',
}}
/>
}
onClick={() => setOpen((v) => !v)}
style={{ alignSelf: 'flex-start' }}
>
{m.error__details()}
</Button>
{open ? (
<Box
component="pre"
style={{
margin: 0,
padding: 12,
borderRadius: 8,
background: 'light-dark(#f8f9fa, #1a1b1e)',
border: '1px solid light-dark(#e9ecef, #2c2e33)',
fontSize: 12,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 320,
overflow: 'auto',
}}
>
{asI18n(detail)}
</Box>
) : null}
</Stack>
) : null}
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,32 @@
import { Select } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { supportedLocales } from '@/i18n/config'
import { usePreferences } from '@/contexts/preferences'
const LOCALE_LABELS: Record<string, string> = {
en: 'English',
fr: 'Français',
}
export function LanguageSelector() {
useLocale()
const { locale, setLocale } = usePreferences()
const data = supportedLocales.map((code) => ({
value: code,
label: LOCALE_LABELS[code] ?? code.toUpperCase(),
}))
return (
<Select
aria-label={m.preferences__language()}
data={data}
value={locale}
onChange={(v) => v && setLocale(v)}
size="xs"
w={110}
allowDeselect={false}
/>
)
}

View File

@@ -0,0 +1,27 @@
import { Select } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { themeList } from '@project/mantine-themes'
import { usePreferences } from '@/contexts/preferences'
// A single theme per app. Themes are normally chosen from the Fabric console
// (which live-injects into the iframe); this picker only appears when more than
// one theme ships, and is hidden otherwise.
export function ThemeSelector() {
useLocale()
const { themeId, setThemeId } = usePreferences()
if (themeList.length <= 1) return null
return (
<Select
aria-label={m.preferences__theme()}
data={themeList.map((t) => ({ value: t.id, label: t.name }))}
value={themeId}
onChange={(v) => v && setThemeId(v)}
size="xs"
w={110}
allowDeselect={false}
/>
)
}

View File

@@ -0,0 +1,39 @@
import type { FC } from 'react'
import { Box, Group, Text } from '@pikku/mantine/core'
import type { I18nString } from '@pikku/react'
// Placeholder brand mark + app name. A gradient tile (brand primary → secondary,
// both theme-driven so it recolours with the active theme) holding a bold spark
// glyph — a confident, generic starting point, NOT a flat monochrome square.
// Reshape the glyph into a real brand mark per project.
export const Wordmark: FC<{ name: I18nString; size?: number }> = ({ name, size = 30 }) => {
return (
<Group gap={10} align="center" wrap="nowrap">
<Box
w={size}
h={size}
style={{
flexShrink: 0,
borderRadius: 'var(--mantine-radius-md)',
// Gradient falls back to the solid primary fill if the theme defines no
// secondary, so it always renders on-brand.
backgroundImage:
'linear-gradient(135deg, var(--mantine-primary-color-filled), var(--mantine-color-secondary-5, var(--mantine-primary-color-filled)))',
color: 'var(--mantine-primary-color-contrast)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<svg width={size * 0.56} height={size * 0.56} viewBox="0 0 24 24" fill="currentColor">
{/* Four-point spark — energetic, distinctive, reads at any size. */}
<path d="M12 1.5c.9 5.1 4.5 8.7 9.6 9.6v1.8c-5.1.9-8.7 4.5-9.6 9.6h-1.8c-.9-5.1-4.5-8.7-9.6-9.6v-1.8c5.1-.9 8.7-4.5 9.6-9.6Z" />
</svg>
</Box>
<Text fw={700} style={{ fontSize: size * 0.55, letterSpacing: '-0.02em' }}>
{name}
</Text>
</Group>
)
}

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react'
export interface PreferencesContextValue {
locale: string
themeId: string
setLocale: (locale: string) => void
setThemeId: (themeId: string) => void
}
export const PreferencesContext = createContext<PreferencesContextValue>({
locale: 'en',
themeId: 'default',
setLocale: () => {},
setThemeId: () => {},
})
export function usePreferences(): PreferencesContextValue {
return useContext(PreferencesContext)
}

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { getAuthSession } from '@/lib/auth'
// Client-side auth gates. They run only after hydration (useEffect never fires
// during SSR), so the redirect decision never depends on the SSR worker seeing
// the session cookie — which it can't on the multi-tenant pikkufabric.dev
// domain, where the Auth.js cookie is host-only on the API origin and so is not
// sent to the app host on a server-rendered request. The client fetch to the
// API host does carry that cookie, so the check is correct on every deploy
// topology (local, platform subdomains, custom domain).
//
// Trade-off: a logged-out visitor to /app sees the shell for a moment before
// being redirected. On a custom domain (single tenant, cookie Domain=.<parent>)
// you could add a createServerFn + server-side gate to remove that flash —
// deferred; not safe to do generically on the shared platform domain.
export function useRedirectIfAuthenticated() {
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
void getAuthSession().then((session) => {
if (!cancelled && session.user?.email) {
void navigate({ to: '/app' })
}
})
return () => {
cancelled = true
}
}, [navigate])
}
export function useRequireAuthentication() {
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
void getAuthSession().then((session) => {
if (!cancelled && !session.user?.email) {
void navigate({ to: '/login' })
}
})
return () => {
cancelled = true
}
}, [navigate])
}

View File

@@ -0,0 +1,87 @@
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
// message functions (`m`).
//
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
import { useSyncExternalStore } from 'react'
import { locales, baseLocale, overwriteGetLocale } from '../paraglide/runtime.js'
export const supportedLocales = locales
export const defaultLocale = baseLocale
export type Locale = (typeof locales)[number]
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
export function detectLocale(pathname: string): Locale {
const segment = pathname.split('/')[1]
if (supportedLocales.includes(segment as Locale)) return segment as Locale
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language?.split('-')[0]
if (supportedLocales.includes(browserLang as Locale)) return browserLang as Locale
}
return defaultLocale
}
// ── reactive locale store ────────────────────────────────────────────────────
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
// it to a tiny external store: `getLocale` reads `activeLocale`; components
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
let activeLocale: Locale = defaultLocale
const listeners = new Set<() => void>()
overwriteGetLocale(() => activeLocale)
export function setActiveLocale(next: Locale): void {
if (next === activeLocale) return
activeLocale = next
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
// used to live; it also returns the active locale + direction for components
// that need them (e.g. the language switcher).
export function useLocale(): {
locale: Locale
dir: 'ltr' | 'rtl'
setLocale: (l: Locale) => void
} {
const locale = useSyncExternalStore<Locale>(
subscribe,
() => activeLocale,
() => activeLocale,
)
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
}
// ── i18n-debug masking ───────────────────────────────────────────────────────
// When enabled, every *translated* string is masked to block glyphs (█) so any
// readable text left on screen is text that never went through a message (a
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
export function isI18nDebug(): boolean {
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
return window.localStorage?.getItem('i18n-debug') === '1'
}
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
export function maskI18n(s: string): string {
return isI18nDebug() ? s.replace(/\S/g, '█') : s
}

View File

@@ -0,0 +1,33 @@
// Typed message functions for the app. `m.landing_title()` returns a branded
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
// `t('landing.title')` used to — no call-site boilerplate.
//
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
// per-message tree-shaking — fine here: locale files are KB and the app bundled
// all of en.json under i18next anyway.
import { m as _m } from '../paraglide/messages.js'
import type { I18nString } from '@pikku/react'
import { maskI18n } from './config.js'
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
const _wrapped: Record<string, unknown> = {}
for (const key of Object.keys(_raw)) {
const fn = _raw[key]
_wrapped[key] =
typeof fn === 'function'
? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args))
: fn
}
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
export const m = _wrapped as unknown as Branded<typeof _m>
// Re-export asI18n so every call site imports BOTH i18n helpers from this one
// module — `import { asI18n, m } from '@/i18n/messages'`. asI18n brands an opaque
// runtime string (a server name/slug/id or a formatted date/number) so it passes
// the @pikku/mantine i18n gate; m.key() is for static copy. Keep them co-located.
export { asI18n } from '@pikku/react'

133
apps/app/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,133 @@
import { createAuthClient } from 'better-auth/client'
import { apiUrl } from './env'
// Better Auth browser client. It targets the worker's catch-all `/api/auth/**`
// routes (generated from src/auth.ts in the functions package). Pass the FULL
// auth base (`<apiUrl>/auth`): better-auth's withPath only appends its default
// `/api/auth` when baseURL has no path, and apiUrl() already carries `/api`, so
// a bare apiUrl() would leave the client calling `/api/get-session` (404) and
// the app would loop back to /login. Cookies ride every request so the session
// round-trips across origins.
//
// Lazily constructed: createAuthClient validates baseURL with `new URL()` at
// construction, so building it at module scope crashes SSR (apiUrl() returns
// the relative `/__api` placeholder there). Every caller runs in the browser.
let _authClient: ReturnType<typeof createAuthClient> | null = null
function authClient() {
_authClient ??= createAuthClient({ baseURL: `${apiUrl()}/auth` })
return _authClient
}
export type AuthSession = {
user?: {
email?: string | null
name?: string | null
image?: string | null
} | null
expires?: string
}
// Thrown by signInWithPassword when the email/password pair is wrong.
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
// Thrown by registerWithPassword when the email is already taken.
export const EMAIL_IN_USE = 'EMAIL_IN_USE'
export async function getAuthSession(): Promise<AuthSession> {
const { data } = await authClient().getSession()
if (!data?.user) {
return {}
}
return {
user: {
email: data.user.email,
name: data.user.name ?? null,
image: data.user.image ?? null,
},
expires: data.session?.expiresAt ? new Date(data.session.expiresAt).toISOString() : undefined,
}
}
// Sign in with an email and password. On success Better Auth sets the session
// cookie; on bad credentials this throws INVALID_CREDENTIALS.
export async function signInWithPassword(email: string, password: string, _redirectPath = '/app') {
const { error } = await authClient().signIn.email({ email, password })
if (error) {
throw new Error(INVALID_CREDENTIALS)
}
}
// Create a new account. Better Auth signs the user in on success (sets the
// session cookie), so they land logged in.
export async function registerWithPassword(
email: string,
password: string,
options: { name?: string; redirectPath?: string } = {},
) {
const { error } = await authClient().signUp.email({
email,
password,
// Better Auth requires a name; fall back to the local-part of the email.
name: options.name?.trim() || email.split('@')[0],
})
if (error) {
// Better Auth returns 422 (UNPROCESSABLE_ENTITY) when the email is taken.
if (error.status === 422 || /exist|taken/i.test(error.message ?? '')) {
throw new Error(EMAIL_IN_USE)
}
throw new Error('Unable to create account')
}
}
// Dev-only convenience account. The Quick-login button (rendered only in non-prod
// builds — sandboxes run `vite dev`, deployments are production builds where
// import.meta.env.DEV is false) signs in with these so you never sign up by hand.
export const DEV_LOGIN = { email: 'test@example.com', password: 'password' } as const
// Sign in with the dev account, provisioning it on first use. Idempotent: if the
// account already exists we just sign in; a concurrent create (EMAIL_IN_USE) is
// fine. Only ever called from the dev-gated Quick-login button.
export async function devQuickLogin(): Promise<void> {
// The account usually already exists — try signing in first. A failure here is
// expected on the very first run (not yet provisioned), so fall through.
const signedIn = await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password)
.then(() => true)
.catch(() => false)
if (signedIn) return
await registerWithPassword(DEV_LOGIN.email, DEV_LOGIN.password, { name: 'Test User' }).catch(
(err) => {
// Another tab/request may have created it between our sign-in and sign-up.
if (!(err instanceof Error && err.message === EMAIL_IN_USE)) throw err
},
)
await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password)
}
// Continue with Google. Better Auth redirects the browser to the provider and
// back to `callbackURL` on success, so this never resolves on the happy path —
// it throws only when the provider isn't configured / the request is rejected.
// Configure the Google provider in the functions package's auth config to enable
// it; until then the button surfaces the error via useMutation.
export async function signInWithGoogle(callbackURL = '/app') {
const { error } = await authClient().signIn.social({ provider: 'google', callbackURL })
if (error) {
throw new Error('Unable to continue with Google')
}
}
// Change the signed-in user's password. Better Auth requires the current
// password to authorize the change.
export async function changePassword(currentPassword: string, newPassword: string) {
const { error } = await authClient().changePassword({ currentPassword, newPassword })
if (error) {
throw new Error('Unable to update password')
}
}
export async function signOut() {
const { error } = await authClient().signOut()
if (error) {
throw new Error('Unable to sign out')
}
}

18
apps/app/src/lib/env.ts Normal file
View File

@@ -0,0 +1,18 @@
// Endpoints come from env, never hardcoded.
//
// In local dev VITE_API_URL is set at build time via .dev.vars and Vite
// replaces the import.meta.env reference inline. In CF Workers deployments
// VITE_API_URL is a runtime text binding — invisible to Vite at build time —
// so import.meta.env.VITE_API_URL is undefined in the bundle. We fall back to
// the current origin + /api, which is correct because the dispatcher worker
// routes every /api/* path to the API units on the same hostname.
export function apiUrl(): string {
if (import.meta.env.SSR) {
// SSR context: PikkuProvider is a client-side provider and its serverUrl
// is only consumed by hooks that run in the browser. Return the build-time
// var when available (local dev) or a placeholder so SSR never throws.
return import.meta.env.VITE_API_URL ?? '/__api'
}
// Client: use the build-time var (local dev) or derive from current origin.
return import.meta.env.VITE_API_URL ?? window.location.origin + '/api'
}

View File

@@ -0,0 +1,22 @@
import type { FC } from 'react'
import { Box, Stack, Text, Title } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const HomePage: FC = () => {
useLocale()
const appName = m.app__name()
return (
<Box mih="70vh" style={{ display: 'grid', placeItems: 'center' }}>
<Stack maw={520} ta="center" gap="md">
<Title order={1} fz={34} fw={650} style={{ letterSpacing: '-0.03em', lineHeight: 1.1 }}>
{m.home__title()}
</Title>
<Text c="dimmed" size="md" style={{ lineHeight: 1.6 }}>
{m.home__body({ name: appName })}
</Text>
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,78 @@
import type { FC } from 'react'
import { Anchor } from '@pikku/mantine/core'
import { Link, useNavigate } from '@tanstack/react-router'
import { useMutation } from '@tanstack/react-query'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { AuthCard, type AuthFormValues } from '@/components/AuthCard'
import {
DEV_LOGIN,
INVALID_CREDENTIALS,
devQuickLogin,
signInWithGoogle,
signInWithPassword,
} from '@/lib/auth'
export const LoginPage: FC = () => {
useLocale()
const navigate = useNavigate()
const appName = m.app__name()
// Dev builds (sandboxes run `vite dev`) prefill the test account and expose a
// one-click quick-login; production builds strip both (import.meta.env.DEV).
const isDev = import.meta.env.DEV
const signIn = useMutation({
mutationFn: (values: AuthFormValues) =>
signInWithPassword(values.email, values.password, '/app'),
onSuccess: () => navigate({ to: '/app' }),
})
const google = useMutation({ mutationFn: () => signInWithGoogle('/app') })
const quick = useMutation({
mutationFn: () => devQuickLogin(),
onSuccess: () => navigate({ to: '/app' }),
})
const error = signIn.isError
? signIn.error instanceof Error && signIn.error.message === INVALID_CREDENTIALS
? m.auth__login__invalid_credentials()
: m.auth__login__error()
: google.isError
? m.auth__google_error()
: quick.isError
? m.auth__login__error()
: null
return (
<AuthCard
appName={appName}
title={m.auth__login__title()}
description={m.auth__login__description({ name: appName })}
cta={m.auth__login__cta()}
passwordAutoComplete="current-password"
busy={signIn.isPending}
googleBusy={google.isPending}
error={error}
initialValues={isDev ? { email: DEV_LOGIN.email, password: DEV_LOGIN.password } : undefined}
quickLogin={
isDev
? {
label: m.auth__dev_quick_login(),
hint: m.auth__dev_quick_login_hint(),
onClick: () => quick.mutate(),
busy: quick.isPending,
}
: null
}
onAuthSubmit={(values) => signIn.mutate(values)}
onGoogle={() => google.mutate()}
footer={
<>
{m.auth__login__footer_prompt()}{' '}
<Anchor component={Link} to="/signup">
{m.auth__login__footer_action()}
</Anchor>
</>
}
/>
)
}

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react'
import { Anchor } from '@pikku/mantine/core'
import { Link, useNavigate } from '@tanstack/react-router'
import { useMutation } from '@tanstack/react-query'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { AuthCard, type AuthFormValues } from '@/components/AuthCard'
import { EMAIL_IN_USE, registerWithPassword, signInWithGoogle } from '@/lib/auth'
export const SignupPage: FC = () => {
useLocale()
const navigate = useNavigate()
const appName = m.app__name()
const signUp = useMutation({
mutationFn: (values: AuthFormValues) =>
registerWithPassword(values.email, values.password, {
name: values.name,
redirectPath: '/app',
}),
onSuccess: () => navigate({ to: '/app' }),
})
const google = useMutation({ mutationFn: () => signInWithGoogle('/app') })
const error = signUp.isError
? signUp.error instanceof Error && signUp.error.message === EMAIL_IN_USE
? m.auth__signup__email_in_use()
: m.auth__signup__error()
: google.isError
? m.auth__google_error()
: null
return (
<AuthCard
appName={appName}
title={m.auth__signup__title()}
description={m.auth__signup__description({ name: appName })}
cta={m.auth__signup__cta()}
includeName
passwordAutoComplete="new-password"
busy={signUp.isPending}
googleBusy={google.isPending}
error={error}
onAuthSubmit={(values) => signUp.mutate(values)}
onGoogle={() => google.mutate()}
footer={
<>
{m.auth__signup__footer_prompt()}{' '}
<Anchor component={Link} to="/login">
{m.auth__signup__footer_action()}
</Anchor>
</>
}
/>
)
}

22
apps/app/src/router.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { DefaultErrorPage } from '@/components/DefaultErrorPage'
// TanStack Start discovers this `getRouter` factory to build a router per
// request (server) and once on the client (hydration). routeTree.gen.ts is
// generated by the tanstackStart() Vite plugin from src/routes/** — don't
// hand-edit it.
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultErrorComponent: DefaultErrorPage,
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react'
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
import {
ColorSchemeScript,
MantineProvider,
mantineHtmlProps,
type MantineThemeOverride,
} from '@pikku/mantine/core'
import '@mantine/core/styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PikkuProvider, createPikku } from '@pikku/react'
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
import {
activeColorScheme,
activeId,
activeTheme,
buildMantineTheme,
googleFontsHref,
themeColorSchemes,
themes,
type ColorScheme,
type Theme,
} from '@project/mantine-themes'
import { defaultLocale, localeDir, supportedLocales, setActiveLocale } from '@/i18n/config'
import { apiUrl } from '@/lib/env'
import { PreferencesContext } from '@/contexts/preferences'
const LOCALE_KEY = 'app-locale'
const THEME_KEY = 'app-theme'
// The active theme id THEME_KEY was saved against — a preference only holds
// while that theme is still the workspace's active one, so a builder-side
// switch (which changes active.json) always wins over a stale local pick.
const THEME_SAVED_ACTIVE_KEY = 'app-theme-saved-active'
const fontsHref = googleFontsHref()
// Root route owns the full HTML document for SSR (<html lang dir> so the tree
// mirrors for RTL locales) and wraps the app in the Mantine + react-query +
// Pikku providers. Mantine v8 SSR needs ColorSchemeScript in <head> and
// mantineHtmlProps on <html>; @mantine/core/styles.css ships the static CSS.
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Pikku App' },
],
}),
component: RootDocument,
})
function RootDocument() {
// Seed from the build-time active theme (active.json) so the applied theme
// drives the initial + SSR render; useEffect syncs from localStorage after
// hydration. Seeding 'default' here was the "applied theme never shows" bug.
const [themeId, setThemeIdRaw] = useState(activeId)
const [locale, setLocaleRaw] = useState(defaultLocale)
// Transient override from the fabric console live-preview (not persisted) —
// the console injects a full theme spec into the iframe so the builder sees
// the look instantly. Takes precedence over the persisted theme.
const [previewTheme, setPreviewTheme] = useState<MantineThemeOverride | null>(null)
// The scheme that goes with the live-preview spec (a light style must render
// light), tracked alongside previewTheme so a preview flips the scheme too.
const [previewScheme, setPreviewScheme] = useState<ColorScheme | null>(null)
const effectiveTheme = previewTheme ?? themes[themeId] ?? activeTheme
// Force the active/previewed theme's natural scheme — a light-first style boots
// light, a dark-first style dark — rather than the old hardcoded dark.
const colorScheme: ColorScheme = previewScheme ?? themeColorSchemes[themeId] ?? activeColorScheme
// Sync preferences from localStorage after hydration.
useEffect(() => {
const savedTheme = localStorage.getItem(THEME_KEY)
const savedAgainst = localStorage.getItem(THEME_SAVED_ACTIVE_KEY)
if (savedTheme && themes[savedTheme] && savedAgainst === activeId) {
setThemeIdRaw(savedTheme)
} else if (savedTheme) {
localStorage.removeItem(THEME_KEY)
localStorage.removeItem(THEME_SAVED_ACTIVE_KEY)
}
const savedLocale = localStorage.getItem(LOCALE_KEY)
if (
savedLocale &&
supportedLocales.includes(savedLocale as (typeof supportedLocales)[number])
) {
setLocaleRaw(savedLocale)
setActiveLocale(savedLocale as (typeof supportedLocales)[number])
}
}, [])
// Keep <html lang dir> in sync with locale changes after hydration.
useEffect(() => {
document.documentElement.lang = locale
document.documentElement.dir = localeDir(locale)
}, [locale])
// Fabric console live-preview: postMessage injects a theme spec to override
// the active theme without persisting. Accepts `theme` (a full spec) or the
// legacy `palette`; null resets to the persisted theme.
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data
if (data?.source !== 'fabric-console' || data.type !== 'set-theme') return
try {
const spec = (data.theme ?? data.palette) as Theme | null
setPreviewTheme(spec ? buildMantineTheme(spec) : null)
setPreviewScheme(spec?.structure?.defaultColorScheme ?? null)
} catch (err) {
console.warn('[mantine-themes] ignoring bad theme payload', err)
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
const setLocale = (next: string) => {
localStorage.setItem(LOCALE_KEY, next)
setLocaleRaw(next)
setActiveLocale(next as (typeof supportedLocales)[number])
}
const setThemeId = (next: string) => {
localStorage.setItem(THEME_KEY, next)
localStorage.setItem(THEME_SAVED_ACTIVE_KEY, activeId)
setThemeIdRaw(next)
setPreviewTheme(null)
setPreviewScheme(null)
}
// Per-render instances keep server requests from sharing client/cache state.
const [queryClient] = useState(() => new QueryClient())
const [pikku] = useState(() =>
createPikku(PikkuFetch, PikkuRPC, {
serverUrl: apiUrl(),
credentials: 'include',
}),
)
return (
<html lang={locale} dir={localeDir(locale)} {...mantineHtmlProps}>
<head>
<HeadContent />
<ColorSchemeScript defaultColorScheme={activeColorScheme} />
{/* Inline data-URI icon so the browser's automatic /favicon.ico request
never 404s (no asset to ship, no network round-trip). Gradient spark
matches the Wordmark; the build agent rebrands this per project. */}
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234f46e5'/%3E%3Cstop offset='1' stop-color='%230ea5e9'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='7' fill='url(%23g)'/%3E%3Cpath d='M16 4c1.1 6.4 5.6 10.9 12 12-6.4 1.1-10.9 5.6-12 12-1.1-6.4-5.6-10.9-12-12C10.4 14.9 14.9 10.4 16 4Z' fill='white'/%3E%3C/svg%3E"
/>
{fontsHref && (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link rel="stylesheet" href={fontsHref} />
</>
)}
</head>
<body>
<MantineProvider
theme={effectiveTheme}
forceColorScheme={colorScheme === 'auto' ? undefined : colorScheme}
>
<PreferencesContext.Provider value={{ locale, themeId, setLocale, setThemeId }}>
<QueryClientProvider client={queryClient}>
<PikkuProvider pikku={pikku}>
<Outlet />
</PikkuProvider>
</QueryClientProvider>
</PreferencesContext.Provider>
</MantineProvider>
<Scripts />
</body>
</html>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { HomePage } from '@/pages/HomePage'
export const Route = createFileRoute('/app/')({
component: HomePage,
})

View File

@@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '@/components/AppShell'
import { useRequireAuthentication } from '@/hooks/useAuthGate'
export const Route = createFileRoute('/app')({
component: AppLayout,
})
function AppLayout() {
useRequireAuthentication()
return <AppShell />
}

View File

@@ -0,0 +1,11 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
// The starter has no marketing landing — entry is the sign-in screen. Redirect
// at the router level so it runs during SSR (and every navigation), instead of a
// client-only effect that leaves `/` blank until hydration. Signed-in visitors
// are then bounced to /app by /login's own auth gate.
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/login' })
},
})

View File

@@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router'
import { LoginPage } from '@/pages/LoginPage'
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
export const Route = createFileRoute('/login')({
component: LoginRoute,
})
function LoginRoute() {
useRedirectIfAuthenticated()
return <LoginPage />
}

View File

@@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router'
import { SignupPage } from '@/pages/SignupPage'
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
export const Route = createFileRoute('/signup')({
component: SignupRoute,
})
function SignupRoute() {
useRedirectIfAuthenticated()
return <SignupPage />
}

23
apps/app/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowJs": true,
"checkJs": false,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts", "wrangler.jsonc"]
}

59
apps/app/vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import { fileURLToPath, URL } from 'node:url'
// Plain TanStack Start config (Node target) — used for local sandbox dev. This
// template is deploy-provider agnostic: it ships NO @cloudflare/vite-plugin. At
// deploy, fabric CI injects the CF adapter (vite.config.cf.ts merges cloudflare()
// on top of this config) to emit the CF Workers SSR bundle. Do not add cloudflare
// here — that would double-apply it and couple the template to a provider.
export default defineConfig({
plugins: [
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR
// on message edits. Must run first.
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
tanstackStart(),
react(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '0.0.0.0',
allowedHosts: true,
// Local dev only — mirrors the sandbox edge (Caddy): /api/auth/* keeps its
// prefix (Better Auth mounts there), every other /api/* reaches the pikku
// dev server unprefixed (/rpc/...). In the sandbox Caddy handles /api
// before Vite, so this proxy never fires there.
// changeOrigin stays false so the backend sees Host = the app origin —
// Better Auth trusts the request origin only when it matches Host.
proxy: {
'/api/auth': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
},
'/api': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
rewrite: (path) => path.replace(/^\/api/, ''),
},
// File content: the pikku dev server serves uploads (PUT) and assets (GET)
// AT these prefixes (pikku.config.json content.uploadUrlPrefix/assetUrlPrefix),
// so proxy them through WITHOUT a rewrite. `/content` (not `/assets`) avoids
// colliding with Vite/TanStack's own built asset paths. In the sandbox Caddy
// handles these before Vite, so this proxy only fires in local dev.
'/upload': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
},
'/content': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
},
},
},
})

9
apps/app/wrangler.jsonc Normal file
View File

@@ -0,0 +1,9 @@
{
// Consumed by @cloudflare/vite-plugin. `main` is TanStack Start's built-in
// server entry (no hand-written worker file); the plugin wires client assets
// and the ssr environment automatically.
"name": "app",
"main": "@tanstack/react-start/server-entry",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
}

1699
bun.lock Normal file

File diff suppressed because it is too large Load Diff

21
db/annotations.ts Normal file
View File

@@ -0,0 +1,21 @@
// Column annotations — compiled by `pikku db migrate` into db/annotations.gen.json,
// which drives BOTH the generated types/zod AND runtime value coercion.
//
// Declare a `kind` for every semantic date/bool/json column you add:
// - kind: 'date' → the column types as `Date` end-to-end. Write real `Date`
// objects, read back `Date` objects; the TEXT storage is coerced both ways.
// - kind: 'bool' → types as `boolean` over the INTEGER 0/1 storage.
// - kind: 'json' → parsed/serialized automatically.
// Without a kind, a SQLite date column is just `string` (write ISO strings via
// `new Date().toISOString()`) and a flag is `number` — that default is fine too;
// just follow the generated type, never fight it with `new Date()` writes or casts.
//
// Optional per column: security ('public' | 'private' | 'pii' | 'secret'),
// classification (anonymize strategy), tsType, format, description.
export const classifications = {
// Example — after adding a `todos` table in a migration:
// todos: {
// created_at: { kind: 'date' },
// done: { kind: 'bool' },
// },
}

3
db/sqlite-seed.sql Normal file
View File

@@ -0,0 +1,3 @@
-- Dev seed (applied via `pikku db seed` after `pikku db migrate`).
-- The starter is auth-only, so there's nothing to seed yet. Add your own
-- INSERT … ON CONFLICT DO NOTHING rows here as you grow the schema.

6
db/sqlite-test-seed.sql Normal file
View File

@@ -0,0 +1,6 @@
-- Test-only seed: a minimal user row so getSession resolves against `user`.
-- This file is only used by the function-tests harness (never in the sandbox
-- or production).
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
VALUES ('user-001', 'Test User', 'test@example.com', 1, datetime('now'), datetime('now'))
ON CONFLICT (id) DO NOTHING;

67
db/sqlite/0001-init.sql Normal file
View File

@@ -0,0 +1,67 @@
PRAGMA foreign_keys = ON;
-- Better Auth core schema. These four tables (user, session, account,
-- verification) are owned and written entirely by Better Auth — the app never
-- inserts into them directly; sign-up / sign-in / sessions all flow through the
-- generated `/api/auth/**` routes (see src/auth.ts).
--
-- The schema is generated by Better Auth's own migration generator
-- (`getMigrations(...).compileMigrations()`); the columns are spelled in
-- snake_case to match the rest of the app's DB convention. Better Auth is handed
-- the app's kysely (with CamelCasePlugin) in src/auth.ts, so its camelCase field
-- names (emailVerified, userId, createdAt, ...) compile to exactly these
-- snake_case columns at runtime. To change this schema (add a provider field, a
-- plugin table, ...), re-run the generator rather than hand-editing.
--
-- Email + password is the default sign-in method. The credential hash is stored
-- by Better Auth in account.password (provider_id = 'credential'), never on the
-- user row.
CREATE TABLE IF NOT EXISTS "user" (
"id" text not null primary key,
"name" text not null,
"email" text not null unique,
"email_verified" integer not null,
"image" text,
"created_at" date not null,
"updated_at" date not null
);
CREATE TABLE IF NOT EXISTS "session" (
"id" text not null primary key,
"expires_at" date not null,
"token" text not null unique,
"created_at" date not null,
"updated_at" date not null,
"ip_address" text,
"user_agent" text,
"user_id" text not null references "user" ("id") on delete cascade
);
CREATE TABLE IF NOT EXISTS "account" (
"id" text not null primary key,
"account_id" text not null,
"provider_id" text not null,
"user_id" text not null references "user" ("id") on delete cascade,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" date,
"refresh_token_expires_at" date,
"scope" text,
"password" text,
"created_at" date not null,
"updated_at" date not null
);
CREATE TABLE IF NOT EXISTS "verification" (
"id" text not null primary key,
"identifier" text not null,
"value" text not null,
"expires_at" date not null,
"created_at" date not null,
"updated_at" date not null
);
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "session" ("user_id");
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "account" ("user_id");
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");

26
db/sqlite/0002-audit.sql Normal file
View File

@@ -0,0 +1,26 @@
-- Audit log. Fabric's audit service flushes events here via a per-stage
-- CF Queue. Add this table if you want audit capture; remove it (and skip
-- this migration) if your project has no audit requirements.
CREATE TABLE IF NOT EXISTS audit (
audit_id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
type TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'auto',
outcome TEXT,
function_id TEXT,
wire_type TEXT,
trace_id TEXT,
transaction_id TEXT,
query_id TEXT,
actor_user_id TEXT,
actor_org_id TEXT,
tables TEXT, -- JSON array of table names touched
changed_cols TEXT, -- JSON array of changed column names
event TEXT, -- custom event label
old TEXT, -- JSON: previous values
data TEXT -- JSON: new values / event payload
);
CREATE INDEX IF NOT EXISTS idx_audit_occurred_at ON audit (occurred_at);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit (actor_user_id) WHERE actor_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_function ON audit (function_id) WHERE function_id IS NOT NULL;

View File

@@ -0,0 +1,11 @@
-- =============================================================================
-- BETTER AUTH ACTOR PLUGIN — user-flow actors
-- Adds the `actor` boolean the Better Auth `actor()` plugin declares so its
-- schema is covered by an explicit migration and the db drift check passes.
-- Actor rows are synthetic users signed in by pikkuUserFlow via
-- POST /api/auth/sign-in/actor with the server-held USER_FLOW_ACTOR_SECRET;
-- the flag rides into the pikku core session so audits/analytics can address
-- synthetic traffic. A non-actor user can never be signed in this way.
-- =============================================================================
ALTER TABLE "user" ADD COLUMN "actor" integer NOT NULL DEFAULT 0;

16
db/sqlite/0004-admin.sql Normal file
View File

@@ -0,0 +1,16 @@
-- =============================================================================
-- BETTER AUTH ADMIN PLUGIN — user administration
-- Adds the columns the Better Auth `admin()` plugin declares so its schema is
-- covered by an explicit migration and the db drift check passes. The plugin
-- exposes /api/auth/admin/* (listUsers, setRole, ban, impersonateUser, …);
-- `impersonated_by` marks a session created via "view as user" so it can be
-- reverted with admin.stopImpersonating. All fields are optional — a user with
-- `role` = 'admin' is an administrator; everyone else stays a normal user.
-- =============================================================================
ALTER TABLE "user" ADD COLUMN "role" text;
ALTER TABLE "user" ADD COLUMN "banned" integer DEFAULT 0;
ALTER TABLE "user" ADD COLUMN "ban_reason" text;
ALTER TABLE "user" ADD COLUMN "ban_expires" date;
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;

13
db/sqlite/0005-fabric.sql Normal file
View File

@@ -0,0 +1,13 @@
-- =============================================================================
-- BETTER AUTH FABRIC PLUGIN — Fabric operator admin sessions
-- Adds the `fabric` boolean the Better Auth `fabric()` plugin declares so its
-- schema is covered by an explicit migration and the db drift check passes.
-- Fabric rows are synthetic operator users minted by POST /api/auth/sign-in/fabric
-- after verifying a short-lived RS256 token the Fabric control plane signed
-- (checked against FABRIC_AUTH_PUBLIC_KEY). They are created with role 'admin' so
-- the console Users tab can list/impersonate real end-users WITHOUT the operator
-- being one of them; these rows are filtered out of any end-user listing. A real
-- user can never be signed in this way.
-- =============================================================================
ALTER TABLE "user" ADD COLUMN "fabric" integer NOT NULL DEFAULT 0;

34
e2e/README.md Normal file
View File

@@ -0,0 +1,34 @@
# e2e — end-to-end tests (Cucumber + Playwright)
Two tiers:
## `@smoke` — the build gate (mutationless, runs against the live server)
Fast, read-only checks that run against the **already-running** app (the sandbox
dev server, or a local `yarn dev`). They never mutate domain data — they sign in
(an idempotent test-user sign-up) and navigate, nothing more — so they are safe
to run against the normal server on every build.
- `features/auth.feature` — sign in through the real login form.
- `features/pages.feature` — signed in, every static route must render with no
HTTP error, no failed/5xx app API call, no uncaught exception, and no console
error. New routes are picked up automatically from the generated route tree.
Run: `yarn workspace @project/e2e test` (the build agent calls this via the
`fabric-app-smoke` tool).
## `@e2e` — deeper behaviour tests (mutating, isolated server + DB)
Create/edit/delete flows and permission checks. Because they mutate state they
must be **deterministic**, so they run like the seminarhof harness: a separate
pikku dev server with its own golden/isolated DB per scenario, against the same
frontend (`E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1`). Add these as the app grows —
do NOT tag them `@smoke` (they are not part of the fast build gate).
## Extend
Add a `features/<domain>.feature` plus `steps/<domain>.steps.ts` per area. Reuse
the generic steps in `steps/common.steps.ts` and the `AppWorld` primitives
(`gotoApp`, `signIn`, `expectText`). Keep `common.steps.ts` and `world.ts`
generic. Tag a scenario `@smoke` only if it is mutationless and must pass on
every build.

21
e2e/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@project/e2e",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "End-to-end tests \u2014 Cucumber + Playwright. Runs against the live app (sandbox dev server or a local dev server).",
"scripts": {
"test": "cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
"test:headed": "HEADED=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
"tsc": "tsc --noEmit"
},
"devDependencies": {
"@cucumber/cucumber": "^11.0.0",
"@pikku/cucumber": "^0.12.11",
"@playwright/test": "^1.50.0",
"@types/node": "^22",
"tsx": "^4.21.0",
"typescript": "~5.8.0"
}
}

6
e2e/tests/cucumber.mjs Normal file
View File

@@ -0,0 +1,6 @@
export default {
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
paths: ['tests/features/**/*.feature'],
format: ['progress'],
forceExit: true,
}

View File

@@ -0,0 +1,8 @@
@auth @smoke
Feature: Authentication
Users sign in with email + password; the session is cookie-based.
Scenario: a registered user signs in through the login form
Given a test account exists
When "the user" signs in through the login form
Then they land on the app

View File

@@ -0,0 +1,10 @@
@pages @smoke
Feature: Every page loads
Signed in, every static route must render without an HTTP error, a failed or
5xx app API call, an uncaught exception, or a console error. This is the
baseline reliability gate extend it with per-domain behaviour features as
the app grows (create/edit/list flows, permissions, etc.).
Scenario: all pages load cleanly when signed in
Given "the user" is signed in
Then every page loads without errors

View File

@@ -0,0 +1,28 @@
import {
Given,
When,
Then,
Before,
After,
defineParameterType,
setDefaultTimeout,
} from '@cucumber/cucumber'
import { registerBrowserSteps, registerBrowserHooks } from '@pikku/cucumber/browser'
// The shared browser vocabulary ({actor} grammar, form/click/see steps, the
// "every page loads without errors" sweep) and the browser lifecycle hooks.
// Per-domain business steps belong in ../steps/*.steps.ts.
registerBrowserSteps({ Given, When, Then, defineParameterType })
registerBrowserHooks({ Before, After, setDefaultTimeout })
// The page-load sweep visits every route in one step, so give scenarios plenty
// of room. Per-action waits are still bounded by the package's config.timeout.
setDefaultTimeout(Number(process.env.E2E_SCENARIO_TIMEOUT ?? 300_000))
// Don't start a scenario while the dev server is mid-restart (it 5xx's /api
// for a few seconds on boot / file change) — that race reads as a phantom
// failure. Server orchestration itself stays outside these tests.
Before(async function (this: import('./world.js').AppWorld) {
const actor = await this.actor(undefined)
await actor.waitForServerReady()
})

View File

@@ -0,0 +1,15 @@
import { setWorldConstructor } from '@cucumber/cucumber'
import { BrowserWorld } from '@pikku/cucumber/browser'
/**
* AppWorld — the app's cucumber world, built on @pikku/cucumber/browser.
*
* The package owns the browser lifecycle, the actor grammar ("the user",
* "the admin", they) and the generic step vocabulary. Extend this class for
* app-specific needs: override createClients() to expose the generated
* PikkuRPC/PikkuFetch on actors, override resetAppData() to call the app's
* reset RPC, or add helpers for per-domain *.steps.ts files.
*/
export class AppWorld extends BrowserWorld {}
setWorldConstructor(AppWorld)

14
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["tests/**/*.ts"]
}

14
emails/locales/en.json Normal file
View File

@@ -0,0 +1,14 @@
{
"common": {
"footer": "If you did not create this account, you can safely ignore this email."
},
"confirmEmail": {
"subject": "Confirm your email for {{appName}}",
"preview": "Confirm your email address to finish setting up your account.",
"heading": "Confirm your email",
"intro": "Thanks for signing up for {{appName}}! Confirm {{email}} to finish setting up your account.",
"cta": "Confirm email",
"fallback": "If the button does not work, copy and paste this URL into your browser:",
"expiry": "This link expires in 24 hours."
}
}

View File

@@ -0,0 +1,3 @@
<p style="margin:32px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;">
{{t.common.footer}}
</p>

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="{{locale}}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{subject}}</title>
</head>
<body style="margin:0;padding:0;background:{{theme.colors.canvas}};font-family:{{theme.fonts.body}};">
<div style="max-width:560px;margin:0 auto;padding:32px 16px;">
{{content}}
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<p style="margin:0 0 12px;color:{{theme.colors.accent}};font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">
{{appName}}
</p>
<h1 style="margin:0 0 16px;color:{{theme.colors.text}};font-size:28px;line-height:1.1;">
{{t.confirmEmail.heading}}
</h1>
<p style="margin:0 0 24px;color:{{theme.colors.muted}};font-size:16px;line-height:1.6;">
{{t.confirmEmail.intro}}
</p>
<p style="margin:0 0 24px;">
<a
href="{{confirmUrl}}"
style="display:inline-block;background:{{theme.colors.button}};color:{{theme.colors.buttonText}};text-decoration:none;padding:14px 22px;border-radius:999px;font-weight:700;"
>
{{t.confirmEmail.cta}}
</a>
</p>
<p style="margin:0 0 8px;color:{{theme.colors.text}};font-size:14px;line-height:1.6;">
{{t.confirmEmail.fallback}}
</p>
<p style="margin:0;color:{{theme.colors.muted}};font-size:14px;line-height:1.6;word-break:break-all;">
{{confirmUrl}}
</p>
<p style="margin:24px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;">
{{t.confirmEmail.expiry}}
</p>
{{> footer}}

View File

@@ -0,0 +1 @@
{{t.confirmEmail.subject}}

View File

@@ -0,0 +1,7 @@
{{t.confirmEmail.heading}}
{{t.confirmEmail.intro}}
{{confirmUrl}}
{{t.confirmEmail.expiry}}

16
emails/theme.json Normal file
View File

@@ -0,0 +1,16 @@
{
"appName": "Pikku Starter",
"fonts": {
"body": "Inter, Arial, sans-serif"
},
"colors": {
"canvas": "#0b1020",
"surface": "#11182d",
"border": "#263252",
"text": "#f7f8fb",
"muted": "#a8b0c5",
"accent": "#7dd3fc",
"button": "#f59e0b",
"buttonText": "#111827"
}
}

View File

@@ -0,0 +1,23 @@
---
type: Decision
title: Design language
description: UI/UX conventions decided for this app, applied consistently.
tags: [design, ui]
---
# Design Language
High-level UI/UX decisions for this app. Use component inspection and the codebase for implementation details — record only things here that aren't obvious from reading the code, and only once a decision has been made and should be applied consistently.
<!-- e.g.
- Drawers or modals? (which component, when each is used)
- Toast notifications or inline feedback? (library, placement, which events trigger each)
- Sidebar navigation: structure, depth levels, active state, how sections collapse
- Loading states: skeletons, spinners, or shimmer? where each is used
- Empty states: pattern and shared component if one exists
- Confirmation pattern: modal, inline expand, or separate page?
- Button hierarchy: primary / secondary / destructive variants
- Typography: font families and when each is used, label conventions
- Color token conventions: semantic names for status, surfaces, text
- Form layout: label style, field spacing, error placement
- Elevation / layering: how cards, panels, and overlays stack
-->

51
knowledge/index.md Normal file
View File

@@ -0,0 +1,51 @@
---
type: Overview
title: Knowledge base
description: The builder agent's long-term memory, in the Open Knowledge Format (OKF).
tags: [meta, okf]
---
# Knowledge base
This folder is the builder agent's long-term memory of **this company and product** — short
markdown notes, not code. The agent reads these at the start of every session and updates
them as it learns durable facts.
## Record ONLY what pikku can't tell you
Never duplicate anything the platform already gives you for free. Database tables and
columns, function signatures, routes, wirings, permissions, and roles are all discoverable
with `pikku meta` and the database tools — do **not** copy them here. They drift the moment
the code changes, and a stale copy is worse than none.
Knowledge is the context introspection can't surface:
- **Domain concepts** — what the business's own terms mean
- **Decisions and their reasons** — *why* an approach was chosen, trade-offs accepted
- **Preferences** — style, scope, and things to avoid
- **Constraints** — external facts (a partner API's quirk, a compliance rule) agreed with the user
## Format — Open Knowledge Format (OKF v0.1)
Each note is a markdown file whose **path is its identity**, with YAML frontmatter and a
markdown body. Frontmatter fields (only `type` is required):
- `type` (required) — e.g. `Concept`, `Decision`, `Glossary`, `Preference`, `Overview`
- `title` — a short human label
- `description` — a one-line summary
- `tags` — a list for grouping and search, e.g. `[security, billing]`
- `resource` — a URL the note documents (a dashboard, doc, external service)
- `timestamp` — ISO-8601 of the last meaningful change
Reserved filenames: `index.md` (this section overview) and `log.md` (a chronological
history of changes). Cross-link notes with normal markdown links — they form a graph.
Suggested notes (create as needed):
- `product.md` — what the product is, who it's for, the core problem it solves
- `glossary.md` — the company's own terms and what they mean
- `decisions.md` — choices made together and why
- `preferences.md` — style, scope, and things to avoid
Keep notes concise and current. Do not store secrets or credentials here.
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf

11
knowledge/security.md Normal file
View File

@@ -0,0 +1,11 @@
---
type: Decision
title: Security
description: High-level, product-level security decisions for this app.
tags: [security]
---
# Security
High-level security decisions for this app. Use `pikku meta functions` to discover the actual auth functions, permissions, and roles — record only context here that isn't in the code.
<!-- e.g. what auth approach was chosen and why, who can access what at a product level, any known security constraints or decisions made with the user -->

11
knowledge/technology.md Normal file
View File

@@ -0,0 +1,11 @@
---
type: Decision
title: Technology
description: High-level technical decisions and context for this app.
tags: [technology, architecture]
---
# Technology
High-level technical decisions and context for this app. Use `pikku meta` and the database tools for implementation details — record only things here that aren't obvious from introspection.
<!-- e.g. why a particular approach was chosen, third-party services integrated and why, known constraints or trade-offs, architectural decisions made with the user -->

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@project/root",
"version": "0.0.1",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": [
"apps/*",
"packages/*",
"design/*",
"e2e",
"!apps/_app_template_"
],
"scripts": {
"prebuild": "cd packages/functions && pikku all",
"dev": "bun run --filter '@project/app' dev",
"tsc": "bun run --filter '*' tsc",
"lint": "oxlint --config ../.oxlintrc.json apps packages",
"format": "oxfmt --config ../.oxfmtrc.json apps packages",
"format:check": "oxfmt --check --config ../.oxfmtrc.json apps packages"
},
"dependencies": {
"@pikku/addon-console": "^0.12.23",
"@pikku/ai-vercel": "^0.12.7",
"@pikku/assistant-ui": "^0.12.7",
"@pikku/better-auth": "^0.12.16",
"@pikku/core": "^0.12.57",
"@pikku/cucumber": "^0.12.11",
"@pikku/deploy-standalone": "^0.12.7",
"@pikku/fetch": "^0.12.6",
"@pikku/kysely": "^0.13.0",
"@pikku/kysely-node-sqlite": "^0.12.3",
"@pikku/kysely-sqlite": "^0.12.7",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5",
"@pikku/schema-cfworker": "^0.12.4"
},
"devDependencies": {
"@pikku/cli": "^0.12.76",
"oxfmt": "^0.53.0",
"oxlint": "^1.71.0"
},
"overrides": {
"@pikku/core": "0.12.57"
}
}

View File

@@ -0,0 +1,24 @@
export { UserCard } from './src/UserCard'
export type { User, UserCardProps } from './src/UserCard'
export { stubQuery } from './src/fixtures/stubQuery'
export { stubMutation } from './src/fixtures/stubMutation'
export { EmptyState } from './src/EmptyState'
export { NotFoundState } from './src/NotFoundState'
export { PageLoader } from './src/PageLoader'
export { ServerErrorState } from './src/ServerErrorState'
// Layout primitives — compose these instead of hand-rolling page chrome.
export { PageHeader } from './src/PageHeader'
export type { PageHeaderProps } from './src/PageHeader'
export { Panel } from './src/Panel'
export type { PanelProps } from './src/Panel'
export { StatCard } from './src/StatCard'
export type { StatCardProps } from './src/StatCard'
export { StatGrid } from './src/StatGrid'
export type { StatGridProps } from './src/StatGrid'
// Data presentation — feed these the output of a list/stats RPC you implement.
export { DataTable } from './src/DataTable'
export type { DataTableProps, DataTableColumn } from './src/DataTable'
export { BarChart } from './src/BarChart'
export type { BarChartProps, BarChartDatum } from './src/BarChart'

View File

@@ -0,0 +1,31 @@
{
"name": "@project/components",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./index.ts"
},
"scripts": {
"tsc": "tsc --noEmit"
},
"devDependencies": {
"@mantine/core": "^9.2.1",
"@mantine/hooks": "^9.2.1",
"@tanstack/react-query": "^5.66.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"lucide-react": "^0.456.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"typescript": "^5.9"
},
"peerDependencies": {
"@mantine/core": "^9.2.1",
"@mantine/hooks": "^9.2.1",
"@tanstack/react-query": "^5.66.0",
"lucide-react": "^0.456.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
}
}

View File

@@ -0,0 +1,57 @@
import React from 'react'
import { Box, Group, Stack, Text } from '@mantine/core'
export interface BarChartDatum {
label: React.ReactNode
value: number
/** Mantine color key for the bar (e.g. 'teal', 'red'). Defaults to the theme primary. */
color?: string
}
export interface BarChartProps {
data: BarChartDatum[]
/** Format the numeric value shown at the end of each bar. */
valueFormatter?: (value: number) => string
}
// A dependency-free horizontal bar chart (CSS only, theme colours). Feed it
// aggregated counts from a stats RPC — no charting library to install or break.
export function BarChart({ data, valueFormatter }: BarChartProps) {
const max = Math.max(1, ...data.map((d) => d.value))
const fmt = valueFormatter ?? ((n: number) => String(n))
return (
<Stack gap="sm">
{data.map((datum, index) => (
<Stack key={index} gap={4}>
<Group justify="space-between" gap="xs">
<Text size="sm">{datum.label}</Text>
<Text size="sm" fw={600}>
{fmt(datum.value)}
</Text>
</Group>
<Box
style={{
height: 8,
borderRadius: 'var(--mantine-radius-xl)',
background: 'var(--mantine-color-default-hover)',
overflow: 'hidden',
}}
>
<Box
style={{
height: '100%',
width: `${(datum.value / max) * 100}%`,
borderRadius: 'var(--mantine-radius-xl)',
background: datum.color
? `var(--mantine-color-${datum.color}-6)`
: 'var(--mantine-primary-color-6)',
transition: 'width 200ms ease',
}}
/>
</Box>
</Stack>
))}
</Stack>
)
}

View File

@@ -0,0 +1,85 @@
import React from 'react'
import { Center, Loader, Table, Text } from '@mantine/core'
export interface DataTableColumn<Row> {
/** Stable column id; also the default cell value (row[key]) when no render is given. */
key: string
header: React.ReactNode
/** Custom cell renderer. Defaults to String(row[key]). */
render?: (row: Row) => React.ReactNode
align?: 'left' | 'center' | 'right'
width?: number | string
}
export interface DataTableProps<Row> {
columns: DataTableColumn<Row>[]
rows: Row[]
/** Stable key per row (e.g. r => r.taskId). */
rowKey: (row: Row) => string
loading?: boolean
/** Shown when there are no rows (pass an <EmptyState/> for a rich version). */
empty?: React.ReactNode
onRowClick?: (row: Row) => void
}
// A generic, typed table. Wire it to any list RPC: implement listX, pass its
// rows here, and describe the columns. Pure presentation — no data fetching.
export function DataTable<Row>({
columns,
rows,
rowKey,
loading,
empty,
onRowClick,
}: DataTableProps<Row>) {
if (loading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
)
}
if (rows.length === 0) {
return (
<Center py="xl">
{empty ?? (
<Text c="dimmed" size="sm">
Nothing here yet.
</Text>
)}
</Center>
)
}
return (
<Table highlightOnHover={!!onRowClick} verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
{columns.map((col) => (
<Table.Th key={col.key} style={{ textAlign: col.align ?? 'left', width: col.width }}>
{col.header}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={onRowClick ? { cursor: 'pointer' } : undefined}
>
{columns.map((col) => (
<Table.Td key={col.key} style={{ textAlign: col.align ?? 'left' }}>
{col.render
? col.render(row)
: String((row as Record<string, unknown>)[col.key] ?? '')}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
)
}

View File

@@ -0,0 +1,58 @@
import { AlertCircle, Boxes, FolderOpen } from 'lucide-react'
import type { Story, StoryMeta } from './csf.types'
import { EmptyState } from './EmptyState'
const meta: StoryMeta = {
title: 'EmptyState',
component: EmptyState,
tags: ['feedback'],
argTypes: {
icon: { description: 'Lucide icon component displayed above the title', control: false },
title: { description: 'Primary heading text', control: 'text' },
description: { description: 'Supporting text below the title', control: 'text' },
action: { description: 'Label for the primary action button', control: 'text' },
onAction: { description: 'Callback fired when the action button is clicked', control: false },
compact: {
description: 'Reduces padding and icon size for inline contexts',
control: 'boolean',
},
docsHref: { description: 'Optional link shown as a Docs button', control: 'text' },
},
}
export default meta
function DefaultStory() {
return (
<EmptyState
icon={Boxes}
title="No items yet"
description="Create your first item to get started."
/>
)
}
export const Default: Story = { render: DefaultStory }
function WithActionStory() {
return (
<EmptyState
icon={FolderOpen}
title="No projects"
description="You don't have any projects yet."
action="New project"
onAction={() => {}}
/>
)
}
export const WithAction: Story = { render: WithActionStory }
function CompactStory() {
return (
<EmptyState
icon={AlertCircle}
title="Nothing here"
description="This section is currently empty."
compact
/>
)
}
export const Compact: Story = { render: CompactStory }

View File

@@ -0,0 +1,77 @@
import React from 'react'
import { Button, Center, Group, Stack, Text } from '@mantine/core'
import { ExternalLink } from 'lucide-react'
interface EmptyStateProps {
icon: React.ComponentType<{ size?: number; strokeWidth?: number }>
title: string
description: React.ReactNode
action?: string
onAction?: () => void
actionIcon?: React.ReactNode
actionLoading?: boolean
secondaryText?: React.ReactNode
docsHref?: string
compact?: boolean
}
export function EmptyState({
icon,
title,
description,
action,
onAction,
actionIcon,
actionLoading,
secondaryText,
docsHref,
compact = false,
}: EmptyStateProps) {
const Icon = icon
return (
<Center flex={1}>
<Stack
align="center"
justify="center"
gap={compact ? 'sm' : 'md'}
py={compact ? 'lg' : 'xl'}
style={{ minHeight: compact ? undefined : '60vh', width: '100%' }}
>
<Icon size={compact ? 36 : 48} strokeWidth={1} />
<Text size={compact ? 'lg' : 'xl'} fw={600}>
{title}
</Text>
<Text c="dimmed" ta="center" maw={500}>
{description}
</Text>
{docsHref || (action && onAction) ? (
<Group justify="center">
{docsHref ? (
<Button
component="a"
href={docsHref}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<ExternalLink size={16} />}
>
Docs
</Button>
) : null}
{action && onAction ? (
<Button onClick={onAction} loading={actionLoading} leftSection={actionIcon}>
{action}
</Button>
) : null}
</Group>
) : null}
{secondaryText ? (
<Text c="dimmed" size="sm" ta="center" maw={500}>
{secondaryText}
</Text>
) : null}
</Stack>
</Center>
)
}

View File

@@ -0,0 +1,25 @@
import type { Story, StoryMeta } from './csf.types'
import { NotFoundState } from './NotFoundState'
const meta: StoryMeta = {
title: 'NotFoundState',
component: NotFoundState,
tags: ['feedback'],
argTypes: {
title: { description: 'Override the "Page not found" heading', control: 'text' },
description: { description: 'Override the default description text', control: 'text' },
},
}
export default meta
export const Default: Story = {}
function CustomStory() {
return (
<NotFoundState
title="Board not found"
description="The board you were looking for has been removed or never existed."
/>
)
}
export const Custom: Story = { render: CustomStory }

View File

@@ -0,0 +1,40 @@
import { Center, Stack, Text } from '@mantine/core'
interface NotFoundStateProps {
title?: string
description?: string
}
export function NotFoundState({
title = 'Page not found',
description = "The resource you're looking for doesn't exist or has been removed.",
}: NotFoundStateProps) {
return (
<Center flex={1}>
<Stack
align="center"
justify="center"
gap="xs"
py="xl"
style={{ minHeight: '60vh', width: '100%' }}
>
<Text
style={{
fontSize: 72,
lineHeight: 1,
letterSpacing: '-0.06em',
fontWeight: 600,
}}
>
404
</Text>
<Text size="xl" fw={600}>
{title}
</Text>
<Text ta="center" maw={520} c="dimmed">
{description}
</Text>
</Stack>
</Center>
)
}

View File

@@ -0,0 +1,32 @@
import { Button } from '@mantine/core'
import { Plus } from 'lucide-react'
import type { Story, StoryMeta } from './csf.types'
import { PageHeader } from './PageHeader'
const meta: StoryMeta = {
title: 'PageHeader',
component: PageHeader,
tags: ['layout'],
argTypes: {
title: { description: 'Page title', control: 'text' },
description: { description: 'Optional sub-text', control: 'text' },
actions: { description: 'Right-aligned actions slot', control: false },
},
}
export default meta
function DefaultStory() {
return <PageHeader title="Tasks" description="Everything on your plate, in one place." />
}
export const Default: Story = { render: DefaultStory }
function WithActionStory() {
return (
<PageHeader
title="Tasks"
description="Everything on your plate, in one place."
actions={<Button leftSection={<Plus size={16} />}>New task</Button>}
/>
)
}
export const WithAction: Story = { render: WithActionStory }

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { Box, Group, Stack, Text, Title } from '@mantine/core'
export interface PageHeaderProps {
/** Page title. Pass an i18n string from the app (m.foo()). */
title: React.ReactNode
/** Optional one-line description under the title. */
description?: React.ReactNode
/** Right-aligned actions (buttons, menus). */
actions?: React.ReactNode
}
// The single shared page header every screen uses: a title, optional
// description, and a right-aligned action slot. Keeps headers consistent across
// pages — compose it, don't hand-roll a per-page header.
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="nowrap" mb="xl">
<Stack gap={4} style={{ minWidth: 0 }}>
<Title order={1} fz={26} fw={680} style={{ letterSpacing: '-0.03em', lineHeight: 1.1 }}>
{title}
</Title>
{description ? (
<Text c="dimmed" size="sm" style={{ lineHeight: 1.5 }}>
{description}
</Text>
) : null}
</Stack>
{actions ? <Box style={{ flexShrink: 0 }}>{actions}</Box> : null}
</Group>
)
}

View File

@@ -0,0 +1,11 @@
import type { Story, StoryMeta } from './csf.types'
import { PageLoader } from './PageLoader'
const meta: StoryMeta = {
title: 'PageLoader',
component: PageLoader,
tags: ['loading'],
}
export default meta
export const Default: Story = {}

View File

@@ -0,0 +1,16 @@
import { Center, Loader } from '@mantine/core'
export function PageLoader() {
return (
<Center
style={{
flex: 1,
width: '100%',
minWidth: 0,
minHeight: 0,
}}
>
<Loader size="md" />
</Center>
)
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { Card, Group, Stack, Text, Title } from '@mantine/core'
export interface PanelProps {
/** Optional panel heading. */
title?: React.ReactNode
/** Optional sub-text under the heading. */
description?: React.ReactNode
/** Right-aligned header actions. */
actions?: React.ReactNode
/** Panel body. */
children: React.ReactNode
/** Remove inner padding (e.g. for a flush table). */
noPadding?: boolean
}
// A bordered content section with an optional titled header. The workhorse
// container — group related content into Panels instead of loose Cards.
export function Panel({ title, description, actions, children, noPadding }: PanelProps) {
return (
<Card withBorder radius="lg" padding={noPadding ? 0 : 'lg'}>
{title || actions ? (
<Group
justify="space-between"
align="flex-end"
wrap="nowrap"
p={noPadding ? 'lg' : 0}
pb={noPadding ? 'sm' : 'md'}
>
<Stack gap={2} style={{ minWidth: 0 }}>
{title ? (
<Title order={2} fz={16} fw={620} style={{ letterSpacing: '-0.02em' }}>
{title}
</Title>
) : null}
{description ? (
<Text c="dimmed" size="xs">
{description}
</Text>
) : null}
</Stack>
{actions ? <div style={{ flexShrink: 0 }}>{actions}</div> : null}
</Group>
) : null}
{children}
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import { useState } from 'react'
import type { Story, StoryMeta } from './csf.types'
import { ServerErrorState } from './ServerErrorState'
const meta: StoryMeta = {
title: 'ServerErrorState',
component: ServerErrorState,
tags: ['feedback'],
argTypes: {
title: { description: 'Override the "Something went wrong" heading', control: 'text' },
description: { description: 'Override the default error description', control: 'text' },
onRetry: { description: 'Callback fired when the retry button is clicked', control: false },
retrying: {
description: 'Shows a loading spinner on the retry button',
control: 'boolean',
},
retryLabel: { description: 'Label for the retry button', control: 'text' },
glyph: {
description: 'Custom element displayed above the title instead of "500"',
control: false,
},
},
}
export default meta
export const Default: Story = {}
function WithRetryStory() {
const [retrying, setRetrying] = useState(false)
const handleRetry = () => {
setRetrying(true)
setTimeout(() => setRetrying(false), 1500)
}
return <ServerErrorState onRetry={handleRetry} retrying={retrying} />
}
export const WithRetry: Story = { render: WithRetryStory }
function CustomGlyphStory() {
return (
<ServerErrorState
glyph={<span style={{ fontSize: 64, lineHeight: 1 }}></span>}
title="Unexpected error"
description="Please try again or contact support if the problem persists."
/>
)
}
export const CustomGlyph: Story = { render: CustomGlyphStory }

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from 'react'
import { Button, Center, Stack, Text } from '@mantine/core'
import { RefreshCw } from 'lucide-react'
interface ServerErrorStateProps {
title?: string
description?: string
onRetry?: () => void
retrying?: boolean
retryLabel?: string
glyph?: ReactNode
}
export function ServerErrorState({
title = 'Something went wrong',
description = 'The server returned an error while loading this page.',
onRetry,
retrying = false,
retryLabel = 'Retry',
glyph,
}: ServerErrorStateProps) {
const normalizedDescription = typeof description === 'string' ? description.trim() : ''
const showDescription =
normalizedDescription.length > 0 &&
normalizedDescription !== 'HTTP 500' &&
normalizedDescription !== 'HTTP 500.'
return (
<Center flex={1}>
<Stack
align="center"
justify="center"
gap="xs"
py="xl"
style={{ minHeight: '60vh', width: '100%' }}
>
{glyph ?? (
<Text
style={{
fontSize: 72,
lineHeight: 1,
letterSpacing: '-0.06em',
fontWeight: 600,
}}
>
500
</Text>
)}
<Text size="xl" fw={600}>
{title}
</Text>
{showDescription ? (
<Text ta="center" maw={520} c="dimmed">
{normalizedDescription}
</Text>
) : null}
{onRetry ? (
<Button
onClick={onRetry}
loading={retrying}
leftSection={<RefreshCw size={16} />}
variant="default"
>
{retryLabel}
</Button>
) : null}
</Stack>
</Center>
)
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { Card, Group, Stack, Text, ThemeIcon } from '@mantine/core'
export interface StatCardProps {
/** Metric label, e.g. "Open tasks". */
label: React.ReactNode
/** The big number / value. */
value: React.ReactNode
/** Optional Lucide icon component. */
icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>
/** Theme color for the icon tile (Mantine color key). Defaults to primary. */
color?: string
}
// A single metric tile: label, large value, optional icon. Compose several in a
// StatGrid for a dashboard summary row.
export function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
return (
<Card withBorder radius="lg" padding="lg">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={4}>
<Text size="xs" c="dimmed" fw={550} tt="uppercase" style={{ letterSpacing: '0.04em' }}>
{label}
</Text>
<Text fz={30} fw={700} lh={1} style={{ letterSpacing: '-0.03em' }}>
{value}
</Text>
</Stack>
{Icon ? (
<ThemeIcon variant="light" color={color} radius="md" size={38}>
<Icon size={20} strokeWidth={1.8} />
</ThemeIcon>
) : null}
</Group>
</Card>
)
}

View File

@@ -0,0 +1,21 @@
import { SimpleGrid } from '@mantine/core'
import { StatCard, type StatCardProps } from './StatCard'
export interface StatGridProps {
stats: StatCardProps[]
/** Columns on wide screens (responsive down to 1). Defaults to stats.length capped at 4. */
columns?: number
}
// A responsive row of StatCards. Feed it the metrics from a stats RPC
// (e.g. getTaskStats) mapped to {label, value, icon}.
export function StatGrid({ stats, columns }: StatGridProps) {
const cols = columns ?? Math.min(stats.length || 1, 4)
return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: cols }} spacing="md">
{stats.map((stat, index) => (
<StatCard key={index} {...stat} />
))}
</SimpleGrid>
)
}

View File

@@ -0,0 +1,83 @@
import { Avatar, Badge, Card, Group, Loader, Stack, Text } from '@mantine/core'
import type { UseQueryResult } from '@tanstack/react-query'
export type User = {
id: string
name: string
email: string
role: string
status: 'active' | 'away' | 'offline'
}
const STATUS_COLOR: Record<User['status'], string> = {
active: 'green',
away: 'yellow',
offline: 'gray',
}
export type UserCardProps = {
query: UseQueryResult<User | null>
}
export const UserCard: React.FC<UserCardProps> = ({ query }) => {
if (query.isLoading) {
return (
<Card withBorder radius="md" padding="lg">
<Group>
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading user
</Text>
</Group>
</Card>
)
}
if (query.isError) {
return (
<Card withBorder radius="md" padding="lg">
<Stack gap={4}>
<Text fw={600} c="red">
Couldnt load user
</Text>
<Text size="sm" c="dimmed">
{query.error instanceof Error ? query.error.message : 'Unknown error'}
</Text>
</Stack>
</Card>
)
}
const user = query.data
if (!user) {
return (
<Card withBorder radius="md" padding="lg">
<Text size="sm" c="dimmed">
No user to show.
</Text>
</Card>
)
}
return (
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Avatar color="initials" name={user.name} radius="xl" />
<Stack gap={0}>
<Text fw={600}>{user.name}</Text>
<Text size="sm" c="dimmed">
{user.email}
</Text>
</Stack>
</Group>
<Badge color={STATUS_COLOR[user.status]} variant="light">
{user.status}
</Badge>
</Group>
<Text size="sm" mt="md">
{user.role}
</Text>
</Card>
)
}

View File

@@ -0,0 +1,21 @@
import type { ComponentType } from 'react'
export interface ArgType {
description?: string
control?: string | false
defaultValue?: unknown
}
export interface StoryMeta {
title: string
component: ComponentType<any>
description?: string
tags?: string[]
argTypes?: Record<string, ArgType>
}
export interface Story {
args?: Record<string, unknown>
render?: ComponentType<any>
name?: string
}

View File

@@ -0,0 +1,69 @@
import type { UseMutationResult } from '@tanstack/react-query'
function build<TData, TVariables>(
partial: Record<string, unknown>,
): UseMutationResult<TData, Error, TVariables> {
return {
failureCount: 0,
failureReason: null,
isPaused: false,
submittedAt: 0,
context: undefined,
variables: undefined,
mutate: () => undefined,
mutateAsync: () => Promise.resolve(undefined as never),
reset: () => undefined,
...partial,
} as unknown as UseMutationResult<TData, Error, TVariables>
}
export const stubMutation = {
idle<TData = unknown, TVariables = unknown>(): UseMutationResult<TData, Error, TVariables> {
return build<TData, TVariables>({
status: 'idle',
isIdle: true,
isPending: false,
isError: false,
isSuccess: false,
data: undefined,
error: null,
})
},
pending<TData = unknown, TVariables = unknown>(): UseMutationResult<TData, Error, TVariables> {
return build<TData, TVariables>({
status: 'pending',
isIdle: false,
isPending: true,
isError: false,
isSuccess: false,
data: undefined,
error: null,
})
},
error<TData = unknown, TVariables = unknown>(
error: Error,
): UseMutationResult<TData, Error, TVariables> {
return build<TData, TVariables>({
status: 'error',
isIdle: false,
isPending: false,
isError: true,
isSuccess: false,
data: undefined,
error,
})
},
success<TData = unknown, TVariables = unknown>(
data: TData,
): UseMutationResult<TData, Error, TVariables> {
return build<TData, TVariables>({
status: 'success',
isIdle: false,
isPending: false,
isError: false,
isSuccess: true,
data,
error: null,
})
},
}

View File

@@ -0,0 +1,66 @@
import type { UseQueryResult } from '@tanstack/react-query'
function build<T>(partial: Record<string, unknown>): UseQueryResult<T> {
return {
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: partial.fetchStatus === 'fetching',
isPaused: false,
isPlaceholderData: false,
isRefetching: false,
isStale: false,
isInitialLoading: partial.isLoading === true,
isLoadingError: false,
isRefetchError: false,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
refetch: () => Promise.resolve(undefined as never),
promise: Promise.resolve(undefined as never),
...partial,
} as unknown as UseQueryResult<T>
}
export const stubQuery = {
loading<T>(): UseQueryResult<T> {
return build<T>({
status: 'pending',
fetchStatus: 'fetching',
isPending: true,
isLoading: true,
isError: false,
isSuccess: false,
data: undefined,
error: null,
})
},
error<T>(error: Error): UseQueryResult<T> {
return build<T>({
status: 'error',
fetchStatus: 'idle',
isPending: false,
isLoading: false,
isError: true,
isSuccess: false,
data: undefined,
error,
})
},
success<T>(data: T): UseQueryResult<T> {
return build<T>({
status: 'success',
fetchStatus: 'idle',
isPending: false,
isLoading: false,
isError: false,
isSuccess: true,
data,
error: null,
})
},
empty<T>(): UseQueryResult<T> {
return stubQuery.success<T>(null as T)
},
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["index.ts", "src"]
}

View File

@@ -0,0 +1,19 @@
{
"name": "@project/functions-sdk",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./pikku/api.gen": "./src/pikku/api.gen.ts",
"./pikku/pikku-fetch.gen": "./src/pikku/pikku-fetch.gen.ts",
"./pikku/pikku-rpc.gen": "./src/pikku/pikku-rpc.gen.ts",
"./pikku/rpc-map.gen": "./src/pikku/rpc-map.gen.d.ts",
"./pikku/http-map.gen": "./src/pikku/http-map.gen.d.ts",
"./pikku/*": "./src/pikku/*"
},
"dependencies": {
"@pikku/fetch": "^0.12.6",
"@pikku/react": "^0.12.5",
"@tanstack/react-query": "^5.66.0"
}
}

View File

@@ -0,0 +1,4 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
export type AgentMap = {}

View File

@@ -0,0 +1,26 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
// AUTO-GENERATED by pikku CLI — do not edit
import { pikkuBetterAuth as _pikkuBetterAuth } from '@pikku/better-auth'
import type { BetterAuthInstance, PikkuBetterAuthFactory } from '@pikku/better-auth'
import type { SingletonServices } from '../function/pikku-function-types.gen.js'
import { TypedSecretService } from '../secrets/pikku-secrets.gen.js'
import { TypedVariablesService } from '../variables/pikku-variables.gen.js'
type AuthSingletonServices = Omit<SingletonServices, 'secrets' | 'variables'> & {
secrets: TypedSecretService
variables: TypedVariablesService
}
export const pikkuBetterAuth = <I extends BetterAuthInstance>(
factory: (services: AuthSingletonServices) => I | Promise<I>
): PikkuBetterAuthFactory<I> =>
_pikkuBetterAuth((services) =>
factory({
...services,
secrets: new TypedSecretService(services.secrets),
variables: new TypedVariablesService(services.variables),
} as AuthSingletonServices)
)

View File

@@ -0,0 +1,89 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
/**
* This provides the structure needed for TypeScript to be aware of channels
*/
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
import type { StubCall } from '/Users/yasser/git/pikku/fabric/templates/starter-template/node_modules/.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker.d.ts'
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
export type AuthHandlerOutput = Promise<void> | Promise<any>
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
export type DeleteAgentThreadOutput = { deleted: boolean; }
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadMessagesOutput = any[]
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadRunsOutput = any[]
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
export type GetAgentThreadsOutput = any[]
export type GetSessionInput = {}
export type GetSessionOutput = {
userId: string;
email: string;
name: string | null;
}
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
export type GraphStarterOutput = { runId: string; }
export type PikkuConsoleGetSecretInput = { secretId: string; }
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleGetVariableInput = { variableId: string; }
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleHasSecretInput = { secretId: string; }
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
export type PikkuConsoleSetSecretOutput = { success: boolean; }
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
export type PikkuConsoleSetVariableOutput = { success: boolean; }
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
export type PikkuScenarioGetStubCallsOutput = StubCall[]
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
export type RealtimeEventStreamInput = { topic: string; }
export type RealtimeSubscribeInput = { topic: string; }
export type RealtimeUnsubscribeInput = { topic: string; }
export type RpcCallerInput = { rpcName: string; data?: unknown; }
export type SecretSchema_betterAuthSecret = string
export type SessionHealthScenarioOutput = { email: string; userId: string; }
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterOutput = { runId: string; }
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
interface ChannelHandler<I, O> {
input: I;
output: O;
}
export type ChannelsMap = {
readonly 'events': {
readonly routes: {
readonly action: {
readonly subscribe: ChannelHandler<RealtimeSubscribeInput, never>,
readonly unsubscribe: ChannelHandler<RealtimeUnsubscribeInput, never>,
},
},
readonly defaultMessage: never,
},
};
export type ChannelDefaultHandlerOf<Channel extends keyof ChannelsMap> =
ChannelsMap[Channel]['defaultMessage'] extends { input: infer I; output: infer O }
? ChannelHandler<I, O>
: never;
export type ChannelWiringHandlerOf<
Channel extends keyof ChannelsMap,
Route extends keyof ChannelsMap[Channel]['routes'],
Method extends keyof ChannelsMap[Channel]['routes'][Route],
> =
ChannelsMap[Channel]['routes'][Route][Method] extends { input: infer I; output: infer O }
? ChannelHandler<I, O>
: never;

View File

@@ -0,0 +1,141 @@
// Generated by @pikku/cli — do not edit by hand.
// Run `pikku db migrate` to refresh.
// Use this type in db/classifications.ts:
// import type { DbClassificationMap } from './.pikku/db/classification-map.gen.d.ts'
// export const classifications = { ... } satisfies DbClassificationMap
export type ColumnEntry = {
/** Privacy level. Defaults to 'private' when omitted. */
security?: 'public' | 'private' | 'pii' | 'secret' | 'encrypted'
/** Anonymize strategy used by `pikku db anonymize`. */
classification?: 'fake:email' | 'fake:name' | 'hash' | 'keep'
/** Column kind override for codegen coercion + typing. */
kind?: 'date' | 'bool' | 'json' | 'uuid'
/** TypeScript type override, e.g. `string[]` or `MyJson`. Wins over `kind`. */
tsType?: string
/** Zod string-format validator (keeps the TS type as `string`). */
format?: 'email' | 'url' | 'emoji' | 'e164' | 'jwt' | 'cuid' | 'cuid2' | 'ulid' | 'nanoid' | 'base64' | 'base64url' | 'ipv4' | 'ipv6' | 'cidrv4' | 'cidrv6' | 'isoDate' | 'isoTime' | 'isoDatetime' | 'isoDuration'
description?: string
}
export type DbClassificationMap = {
"account": {
"id": ColumnEntry
"account_id": ColumnEntry
"provider_id": ColumnEntry
"user_id": ColumnEntry
"access_token": ColumnEntry
"refresh_token": ColumnEntry
"id_token": ColumnEntry
"access_token_expires_at": ColumnEntry
"refresh_token_expires_at": ColumnEntry
"scope": ColumnEntry
"password": ColumnEntry
"created_at": ColumnEntry
"updated_at": ColumnEntry
}
"ai_message": {
"id": ColumnEntry
"thread_id": ColumnEntry
"role": ColumnEntry
"content": ColumnEntry
"created_at": ColumnEntry
}
"ai_run": {
"run_id": ColumnEntry
"agent_name": ColumnEntry
"thread_id": ColumnEntry
"resource_id": ColumnEntry
"status": ColumnEntry
"error_message": ColumnEntry
"suspend_reason": ColumnEntry
"missing_rpcs": ColumnEntry
"usage_input_tokens": ColumnEntry
"usage_output_tokens": ColumnEntry
"usage_model": ColumnEntry
"created_at": ColumnEntry
"updated_at": ColumnEntry
}
"ai_threads": {
"id": ColumnEntry
"resource_id": ColumnEntry
"title": ColumnEntry
"metadata": ColumnEntry
"created_at": ColumnEntry
"updated_at": ColumnEntry
}
"ai_tool_call": {
"id": ColumnEntry
"thread_id": ColumnEntry
"message_id": ColumnEntry
"run_id": ColumnEntry
"tool_name": ColumnEntry
"args": ColumnEntry
"result": ColumnEntry
"approval_status": ColumnEntry
"approval_type": ColumnEntry
"agent_run_id": ColumnEntry
"display_tool_name": ColumnEntry
"display_args": ColumnEntry
"created_at": ColumnEntry
}
"ai_working_memory": {
"id": ColumnEntry
"scope": ColumnEntry
"data": ColumnEntry
"updated_at": ColumnEntry
}
"audit": {
"audit_id": ColumnEntry
"occurred_at": ColumnEntry
"type": ColumnEntry
"source": ColumnEntry
"outcome": ColumnEntry
"function_id": ColumnEntry
"wire_type": ColumnEntry
"trace_id": ColumnEntry
"transaction_id": ColumnEntry
"query_id": ColumnEntry
"actor_user_id": ColumnEntry
"actor_org_id": ColumnEntry
"tables": ColumnEntry
"changed_cols": ColumnEntry
"event": ColumnEntry
"old": ColumnEntry
"data": ColumnEntry
}
"session": {
"id": ColumnEntry
"expires_at": ColumnEntry
"token": ColumnEntry
"created_at": ColumnEntry
"updated_at": ColumnEntry
"ip_address": ColumnEntry
"user_agent": ColumnEntry
"user_id": ColumnEntry
"impersonated_by": ColumnEntry
}
"user": {
"id": ColumnEntry
"name": ColumnEntry
"email": ColumnEntry
"email_verified": ColumnEntry
"image": ColumnEntry
"created_at": ColumnEntry
"updated_at": ColumnEntry
"actor": ColumnEntry
"role": ColumnEntry
"banned": ColumnEntry
"ban_reason": ColumnEntry
"ban_expires": ColumnEntry
"fabric": ColumnEntry
}
"verification": {
"id": ColumnEntry
"identifier": ColumnEntry
"value": ColumnEntry
"expires_at": ColumnEntry
"created_at": ColumnEntry
"updated_at": ColumnEntry
}
}

View File

@@ -0,0 +1,148 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
/**
* This provides the structure needed for typescript to be aware of routes and their return types
*/
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
import type { StreamWorkflowRunInput } from '.bun/@pikku+addon-console@0.12.26+8a6fcc78e6b267a5/node_modules/@pikku/addon-console/dist/.pikku/rpc/pikku-rpc-wirings-map.internal.gen'
import type { WorkflowRunStatus } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/wirings/workflow/workflow.types'
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
export type AuthHandlerOutput = Promise<void> | Promise<any>
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
export type DeleteAgentThreadOutput = { deleted: boolean; }
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadMessagesOutput = any[]
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadRunsOutput = any[]
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
export type GetAgentThreadsOutput = any[]
export type GetSessionInput = {}
export type GetSessionOutput = {
userId: string;
email: string;
name: string | null;
}
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
export type GraphStarterOutput = { runId: string; }
export type PikkuConsoleGetSecretInput = { secretId: string; }
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleGetVariableInput = { variableId: string; }
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleHasSecretInput = { secretId: string; }
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
export type PikkuConsoleSetSecretOutput = { success: boolean; }
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
export type PikkuConsoleSetVariableOutput = { success: boolean; }
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
export type PikkuScenarioGetStubCallsOutput = StubCall[]
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
export type RealtimeEventStreamInput = { topic: string; }
export type RealtimeSubscribeInput = { topic: string; }
export type RealtimeUnsubscribeInput = { topic: string; }
export type RpcCallerInput = { rpcName: string; data?: unknown; }
export type SecretSchema_betterAuthSecret = string
export type SessionHealthScenarioOutput = { email: string; userId: string; }
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterOutput = { runId: string; }
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
// The '& {}' is a workaround for not directly refering to a type since it confuses typescript
export type AgentCallerInputParams = Pick<AgentCallerInput, 'agentName'> & {}
export type AgentCallerInputBody = Omit<AgentCallerInput, 'agentName'> & {}
export type AgentStreamCallerInputParams = Pick<AgentStreamCallerInput, 'agentName'> & {}
export type AgentStreamCallerInputBody = Omit<AgentStreamCallerInput, 'agentName'> & {}
export type AgentApproveCallerInputParams = Pick<AgentApproveCallerInput, 'agentName'> & {}
export type AgentApproveCallerInputBody = Omit<AgentApproveCallerInput, 'agentName'> & {}
export type AgentResumeCallerInputParams = Pick<AgentResumeCallerInput, 'agentName'> & {}
export type AgentResumeCallerInputBody = Omit<AgentResumeCallerInput, 'agentName'> & {}
export type StreamWorkflowRunInputParams = Pick<StreamWorkflowRunInput, 'runId'> & {}
export type StreamWorkflowRunInputBody = Omit<StreamWorkflowRunInput, 'runId'> & {}
export type RealtimeEventStreamInputParams = Pick<RealtimeEventStreamInput, 'topic'> & {}
export type RealtimeEventStreamInputBody = Omit<RealtimeEventStreamInput, 'topic'> & {}
export type RpcCallerInputParams = Pick<RpcCallerInput, 'rpcName'> & {}
export type RpcCallerInputBody = Omit<RpcCallerInput, 'rpcName'> & {}
export type WorkflowStarterInputParams = Pick<WorkflowStarterInput, 'workflowName'> & {}
export type WorkflowStarterInputBody = Omit<WorkflowStarterInput, 'workflowName'> & {}
export type WorkflowRunnerInputParams = Pick<WorkflowRunnerInput, 'workflowName'> & {}
export type WorkflowRunnerInputBody = Omit<WorkflowRunnerInput, 'workflowName'> & {}
export type WorkflowStatusCheckerInputParams = Pick<WorkflowStatusCheckerInput, 'workflowName' | 'runId'> & {}
export type WorkflowStatusCheckerInputBody = Omit<WorkflowStatusCheckerInput, 'workflowName' | 'runId'> & {}
export type WorkflowStatusStreamInputParams = Pick<WorkflowStatusStreamInput, 'workflowName' | 'runId'> & {}
export type WorkflowStatusStreamInputBody = Omit<WorkflowStatusStreamInput, 'workflowName' | 'runId'> & {}
export type WorkflowStatusStreamFullInputParams = Pick<WorkflowStatusStreamFullInput, 'workflowName' | 'runId'> & {}
export type WorkflowStatusStreamFullInputBody = Omit<WorkflowStatusStreamFullInput, 'workflowName' | 'runId'> & {}
export type GraphStarterInputParams = Pick<GraphStarterInput, 'workflowName' | 'nodeId'> & {}
export type GraphStarterInputBody = Omit<GraphStarterInput, 'workflowName' | 'nodeId'> & {}
interface HTTPWiringHandler<I, O> {
input: I;
output: O;
}
export type HTTPWiringsMap = {
readonly '/api/auth{/*splat}': {
readonly GET: HTTPWiringHandler<null, AuthHandlerOutput>,
readonly POST: HTTPWiringHandler<null, AuthHandlerOutput>,
},
readonly '/workflow-run/:runId/stream': {
readonly GET: HTTPWiringHandler<StreamWorkflowRunInput, null>,
},
readonly '/events/:topic': {
readonly GET: HTTPWiringHandler<RealtimeEventStreamInput, null>,
},
readonly '/workflow/:workflowName/status/:runId': {
readonly GET: HTTPWiringHandler<WorkflowStatusCheckerInput, WorkflowRunStatus>,
},
readonly '/workflow/:workflowName/status/:runId/stream': {
readonly GET: HTTPWiringHandler<WorkflowStatusStreamInput, null>,
},
readonly '/workflow/:workflowName/status/:runId/stream/full': {
readonly GET: HTTPWiringHandler<WorkflowStatusStreamFullInput, null>,
},
readonly '/rpc/agent/:agentName': {
readonly POST: HTTPWiringHandler<AgentCallerInput, null>,
},
readonly '/rpc/agent/:agentName/stream': {
readonly POST: HTTPWiringHandler<AgentStreamCallerInput, null>,
},
readonly '/rpc/agent/:agentName/approve': {
readonly POST: HTTPWiringHandler<AgentApproveCallerInput, null>,
},
readonly '/rpc/agent/:agentName/resume': {
readonly POST: HTTPWiringHandler<AgentResumeCallerInput, null>,
},
readonly '/rpc/:rpcName': {
readonly POST: HTTPWiringHandler<RpcCallerInput, null>,
},
readonly '/workflow/:workflowName/start': {
readonly POST: HTTPWiringHandler<WorkflowStarterInput, WorkflowStarterOutput>,
},
readonly '/workflow/:workflowName/run': {
readonly POST: HTTPWiringHandler<WorkflowRunnerInput, null>,
},
readonly '/workflow/:workflowName/graph/:nodeId': {
readonly POST: HTTPWiringHandler<GraphStarterInput, GraphStarterOutput>,
},
};
export type HTTPWiringHandlerOf<HTTPWiring extends keyof HTTPWiringsMap, Method extends keyof HTTPWiringsMap[HTTPWiring]> =
HTTPWiringsMap[HTTPWiring][Method] extends { input: infer I; output: infer O }
? HTTPWiringHandler<I, O>
: never;
export type HTTPWiringsWithMethod<Method extends string> = {
[HTTPWiring in keyof HTTPWiringsMap]: Method extends keyof HTTPWiringsMap[HTTPWiring] ? HTTPWiring : never;
}[keyof HTTPWiringsMap];

View File

@@ -0,0 +1,168 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
/**
* This provides the structure needed for typescript to be aware of RPCs and their return types
*/
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
import type { FunctionCoverageReport } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/v8-coverage-service'
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
export type AuthHandlerOutput = Promise<void> | Promise<any>
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
export type DeleteAgentThreadOutput = { deleted: boolean; }
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadMessagesOutput = any[]
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadRunsOutput = any[]
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
export type GetAgentThreadsOutput = any[]
export type GetSessionInput = {}
export type GetSessionOutput = {
userId: string;
email: string;
name: string | null;
}
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
export type GraphStarterOutput = { runId: string; }
export type PikkuConsoleGetSecretInput = { secretId: string; }
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleGetVariableInput = { variableId: string; }
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleHasSecretInput = { secretId: string; }
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
export type PikkuConsoleSetSecretOutput = { success: boolean; }
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
export type PikkuConsoleSetVariableOutput = { success: boolean; }
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
export type PikkuScenarioGetStubCallsOutput = StubCall[]
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
export type RealtimeEventStreamInput = { topic: string; }
export type RealtimeSubscribeInput = { topic: string; }
export type RealtimeUnsubscribeInput = { topic: string; }
export type RpcCallerInput = { rpcName: string; data?: unknown; }
export type SecretSchema_betterAuthSecret = string
export type SessionHealthScenarioOutput = { email: string; userId: string; }
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterOutput = { runId: string; }
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
interface RPCHandler<I, O> {
input: I;
output: O;
}
export type RPCMap = {
readonly 'getSession': RPCHandler<GetSessionInput, GetSessionOutput>,
readonly 'getAgentThreads': RPCHandler<GetAgentThreadsInput, GetAgentThreadsOutput>,
readonly 'getAgentThreadMessages': RPCHandler<GetAgentThreadMessagesInput, GetAgentThreadMessagesOutput>,
readonly 'getAgentThreadRuns': RPCHandler<GetAgentThreadRunsInput, GetAgentThreadRunsOutput>,
readonly 'deleteAgentThread': RPCHandler<DeleteAgentThreadInput, DeleteAgentThreadOutput>,
readonly 'pikkuConsoleSetSecret': RPCHandler<PikkuConsoleSetSecretInput, PikkuConsoleSetSecretOutput>,
readonly 'pikkuConsoleGetVariable': RPCHandler<PikkuConsoleGetVariableInput, PikkuConsoleGetVariableOutput>,
readonly 'pikkuConsoleSetVariable': RPCHandler<PikkuConsoleSetVariableInput, PikkuConsoleSetVariableOutput>,
readonly 'pikkuConsoleHasSecret': RPCHandler<PikkuConsoleHasSecretInput, PikkuConsoleHasSecretOutput>,
readonly 'pikkuConsoleGetSecret': RPCHandler<PikkuConsoleGetSecretInput, PikkuConsoleGetSecretOutput>,
readonly 'pikkuScenarioTakeLiveCoverage': RPCHandler<null, FunctionCoverageReport>,
readonly 'pikkuScenarioResetLiveCoverage': RPCHandler<null, PikkuScenarioResetLiveCoverageOutput>,
readonly 'pikkuScenarioResetStubs': RPCHandler<null, PikkuScenarioResetStubsOutput>,
readonly 'pikkuScenarioGetStubCalls': RPCHandler<PikkuScenarioGetStubCallsInput, PikkuScenarioGetStubCallsOutput>,
};
// Addon package RPC maps
import type { RPCMap as ConsoleRPCMap } from '@pikku/addon-console/.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js'
// Utility type to prefix keys with namespace (skips 'any' to prevent type poisoning)
type PrefixKeys<T, Prefix extends string> = unknown extends T ? {} : {
[K in keyof T as `${Prefix}:${string & K}`]: T[K]
}
// Merge all RPC maps with namespace prefixes
export type FlattenedRPCMap =
RPCMap & PrefixKeys<ConsoleRPCMap, 'console'>
type IsAny<T> = 0 extends (1 & T) ? true : false
type IsVoidishInput<T> = IsAny<T> extends true
? false
: [T] extends [void | null | undefined]
? true
: false
export type RPCInvoke = <Name extends keyof FlattenedRPCMap>(
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
? [name: Name]
: [name: Name, data: FlattenedRPCMap[Name]['input']]
) => Promise<FlattenedRPCMap[Name]['output']>
export type RPCRemote = <Name extends keyof FlattenedRPCMap>(
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
? [name: Name]
: [name: Name, data: FlattenedRPCMap[Name]['input']]
) => Promise<FlattenedRPCMap[Name]['output']>
import type { FlattenedWorkflowMap } from '../workflow/pikku-workflow-map.gen.d.js'
import type { AgentMap } from '../agent/pikku-agent-map.gen.d.js'
// Addon package Agent maps
import type { AgentMap as ConsoleAgentMap } from '@pikku/addon-console/.pikku/agent/pikku-agent-map.gen.d.js'
type FlattenedAgentMap =
AgentMap & PrefixKeys<ConsoleAgentMap, 'console'>
import type { PikkuRPC } from '@pikku/core/rpc'
interface AIAgentInput {
message: string
threadId: string
resourceId: string
}
export type TypedStartWorkflow = <Name extends keyof FlattenedWorkflowMap>(
name: Name,
input: FlattenedWorkflowMap[Name]['input'],
options?: { startNode?: string }
) => Promise<{ runId: string }>
export type TypedRunWorkflow = <Name extends keyof FlattenedWorkflowMap>(
name: Name,
input: FlattenedWorkflowMap[Name]['input']
) => Promise<FlattenedWorkflowMap[Name]['output']>
export type TypedWorkflowStatus = (
workflowName: string,
runId: string
) => Promise<{ id: string; status: 'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'; output?: unknown; error?: { message?: string } }>
type TypedAgentRun = [keyof FlattenedAgentMap] extends [never]
? (name: string, input: AIAgentInput) => Promise<any>
: <Name extends keyof FlattenedAgentMap>(
name: Name,
input: AIAgentInput
) => Promise<{ runId: string; result: FlattenedAgentMap[Name]['output']; usage: { inputTokens: number; outputTokens: number } }>
type TypedAgentStream = [keyof FlattenedAgentMap] extends [never]
? (name: string, input: AIAgentInput, options?: { requiresToolApproval?: 'all' | 'explicit' | false }) => Promise<void>
: <Name extends keyof FlattenedAgentMap>(
name: Name,
input: AIAgentInput,
options?: { requiresToolApproval?: 'all' | 'explicit' | false }
) => Promise<void>
export type TypedPikkuRPC = PikkuRPC<RPCInvoke, RPCRemote, TypedStartWorkflow, TypedAgentRun, TypedAgentStream>

View File

@@ -0,0 +1,185 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
/**
* This provides the structure needed for typescript to be aware of RPCs and their return types
*/
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
import type { FunctionCoverageReport } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/v8-coverage-service'
import type { WorkflowRunStatus } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/wirings/workflow/workflow.types'
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
export type AuthHandlerOutput = Promise<void> | Promise<any>
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
export type DeleteAgentThreadOutput = { deleted: boolean; }
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadMessagesOutput = any[]
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
export type GetAgentThreadRunsOutput = any[]
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
export type GetAgentThreadsOutput = any[]
export type GetSessionInput = {}
export type GetSessionOutput = {
userId: string;
email: string;
name: string | null;
}
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
export type GraphStarterOutput = { runId: string; }
export type PikkuConsoleGetSecretInput = { secretId: string; }
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleGetVariableInput = { variableId: string; }
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
export type PikkuConsoleHasSecretInput = { secretId: string; }
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
export type PikkuConsoleSetSecretOutput = { success: boolean; }
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
export type PikkuConsoleSetVariableOutput = { success: boolean; }
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
export type PikkuScenarioGetStubCallsOutput = StubCall[]
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
export type RealtimeEventStreamInput = { topic: string; }
export type RealtimeSubscribeInput = { topic: string; }
export type RealtimeUnsubscribeInput = { topic: string; }
export type RpcCallerInput = { rpcName: string; data?: unknown; }
export type SecretSchema_betterAuthSecret = string
export type SessionHealthScenarioOutput = { email: string; userId: string; }
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
export type WorkflowStarterOutput = { runId: string; }
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
interface RPCHandler<I, O> {
input: I;
output: O;
}
export type RPCMap = {
readonly 'getSession': RPCHandler<GetSessionInput, GetSessionOutput>,
readonly 'sessionHealthScenario': RPCHandler<null, SessionHealthScenarioOutput>,
readonly 'agentCaller': RPCHandler<AgentCallerInput, null>,
readonly 'agentStreamCaller': RPCHandler<AgentStreamCallerInput, null>,
readonly 'agentApproveCaller': RPCHandler<AgentApproveCallerInput, null>,
readonly 'agentResumeCaller': RPCHandler<AgentResumeCallerInput, null>,
readonly 'getAgentThreads': RPCHandler<GetAgentThreadsInput, GetAgentThreadsOutput>,
readonly 'getAgentThreadMessages': RPCHandler<GetAgentThreadMessagesInput, GetAgentThreadMessagesOutput>,
readonly 'getAgentThreadRuns': RPCHandler<GetAgentThreadRunsInput, GetAgentThreadRunsOutput>,
readonly 'deleteAgentThread': RPCHandler<DeleteAgentThreadInput, DeleteAgentThreadOutput>,
readonly 'authHandler': RPCHandler<null, AuthHandlerOutput>,
readonly 'pikkuConsoleSetSecret': RPCHandler<PikkuConsoleSetSecretInput, PikkuConsoleSetSecretOutput>,
readonly 'pikkuConsoleGetVariable': RPCHandler<PikkuConsoleGetVariableInput, PikkuConsoleGetVariableOutput>,
readonly 'pikkuConsoleSetVariable': RPCHandler<PikkuConsoleSetVariableInput, PikkuConsoleSetVariableOutput>,
readonly 'pikkuConsoleHasSecret': RPCHandler<PikkuConsoleHasSecretInput, PikkuConsoleHasSecretOutput>,
readonly 'pikkuConsoleGetSecret': RPCHandler<PikkuConsoleGetSecretInput, PikkuConsoleGetSecretOutput>,
readonly 'realtimeSubscribe': RPCHandler<RealtimeSubscribeInput, null>,
readonly 'realtimeUnsubscribe': RPCHandler<RealtimeUnsubscribeInput, null>,
readonly 'realtimeEventStream': RPCHandler<RealtimeEventStreamInput, null>,
readonly 'rpcCaller': RPCHandler<RpcCallerInput, null>,
readonly 'pikkuScenarioTakeLiveCoverage': RPCHandler<null, FunctionCoverageReport>,
readonly 'pikkuScenarioResetLiveCoverage': RPCHandler<null, PikkuScenarioResetLiveCoverageOutput>,
readonly 'pikkuScenarioResetStubs': RPCHandler<null, PikkuScenarioResetStubsOutput>,
readonly 'pikkuScenarioGetStubCalls': RPCHandler<PikkuScenarioGetStubCallsInput, PikkuScenarioGetStubCallsOutput>,
readonly 'workflowStarter': RPCHandler<WorkflowStarterInput, WorkflowStarterOutput>,
readonly 'workflowRunner': RPCHandler<WorkflowRunnerInput, null>,
readonly 'workflowStatusChecker': RPCHandler<WorkflowStatusCheckerInput, WorkflowRunStatus>,
readonly 'workflowStatusStream': RPCHandler<WorkflowStatusStreamInput, null>,
readonly 'workflowStatusStreamFull': RPCHandler<WorkflowStatusStreamFullInput, null>,
readonly 'graphStarter': RPCHandler<GraphStarterInput, GraphStarterOutput>,
};
// Addon package RPC maps
import type { RPCMap as ConsoleRPCMap } from '@pikku/addon-console/.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js'
// Utility type to prefix keys with namespace (skips 'any' to prevent type poisoning)
type PrefixKeys<T, Prefix extends string> = unknown extends T ? {} : {
[K in keyof T as `${Prefix}:${string & K}`]: T[K]
}
// Merge all RPC maps with namespace prefixes
export type FlattenedRPCMap =
RPCMap & PrefixKeys<ConsoleRPCMap, 'console'>
type IsAny<T> = 0 extends (1 & T) ? true : false
type IsVoidishInput<T> = IsAny<T> extends true
? false
: [T] extends [void | null | undefined]
? true
: false
export type RPCInvoke = <Name extends keyof FlattenedRPCMap>(
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
? [name: Name]
: [name: Name, data: FlattenedRPCMap[Name]['input']]
) => Promise<FlattenedRPCMap[Name]['output']>
export type RPCRemote = <Name extends keyof FlattenedRPCMap>(
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
? [name: Name]
: [name: Name, data: FlattenedRPCMap[Name]['input']]
) => Promise<FlattenedRPCMap[Name]['output']>
import type { FlattenedWorkflowMap } from '../workflow/pikku-workflow-map.gen.d.js'
import type { AgentMap } from '../agent/pikku-agent-map.gen.d.js'
// Addon package Agent maps
import type { AgentMap as ConsoleAgentMap } from '@pikku/addon-console/.pikku/agent/pikku-agent-map.gen.d.js'
type FlattenedAgentMap =
AgentMap & PrefixKeys<ConsoleAgentMap, 'console'>
import type { PikkuRPC } from '@pikku/core/rpc'
interface AIAgentInput {
message: string
threadId: string
resourceId: string
}
export type TypedStartWorkflow = <Name extends keyof FlattenedWorkflowMap>(
name: Name,
input: FlattenedWorkflowMap[Name]['input'],
options?: { startNode?: string }
) => Promise<{ runId: string }>
export type TypedRunWorkflow = <Name extends keyof FlattenedWorkflowMap>(
name: Name,
input: FlattenedWorkflowMap[Name]['input']
) => Promise<FlattenedWorkflowMap[Name]['output']>
export type TypedWorkflowStatus = (
workflowName: string,
runId: string
) => Promise<{ id: string; status: 'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'; output?: unknown; error?: { message?: string } }>
type TypedAgentRun = [keyof FlattenedAgentMap] extends [never]
? (name: string, input: AIAgentInput) => Promise<any>
: <Name extends keyof FlattenedAgentMap>(
name: Name,
input: AIAgentInput
) => Promise<{ runId: string; result: FlattenedAgentMap[Name]['output']; usage: { inputTokens: number; outputTokens: number } }>
type TypedAgentStream = [keyof FlattenedAgentMap] extends [never]
? (name: string, input: AIAgentInput, options?: { requiresToolApproval?: 'all' | 'explicit' | false }) => Promise<void>
: <Name extends keyof FlattenedAgentMap>(
name: Name,
input: AIAgentInput,
options?: { requiresToolApproval?: 'all' | 'explicit' | false }
) => Promise<void>
export type TypedPikkuRPC = PikkuRPC<RPCInvoke, RPCRemote, TypedStartWorkflow, TypedAgentRun, TypedAgentStream>

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"runId":{"type":"string"},"approvals":{"type":"array","items":{"type":"object","properties":{"toolCallId":{"type":"string"},"approved":{"type":"boolean"}},"required":["toolCallId","approved"],"additionalProperties":false}}},"required":["agentName","runId","approvals"],"additionalProperties":false,"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"message":{"type":"string"},"threadId":{"type":"string"},"resourceId":{"type":"string"}},"required":["agentName","message","threadId","resourceId"],"additionalProperties":false,"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"runId":{"type":"string"},"toolCallId":{"type":"string"},"approved":{"type":"boolean"}},"required":["agentName","runId","toolCallId","approved"],"additionalProperties":false,"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"message":{"type":"string"},"threadId":{"type":"string"},"resourceId":{"type":"string"},"context":{"type":"string"}},"required":["agentName","message","threadId","resourceId"],"additionalProperties":false,"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"null"},{}],"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"threadId":{"type":"string"},"resourceId":{"type":"string"}},"required":["threadId"],"additionalProperties":false,"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"],"additionalProperties":false,"definitions":{}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"generatedAt":{"type":"string"},"summary":{"type":"object","properties":{"total":{"type":"number"},"covered":{"type":"number"},"partial":{"type":"number"},"uncovered":{"type":"number"},"unknown":{"type":"number"},"overallRatio":{"type":"number"}},"required":["total","covered","partial","uncovered","unknown","overallRatio"],"additionalProperties":false},"functions":{"type":"array","items":{"$ref":"#/definitions/FunctionCoverageEntry"}}},"required":["generatedAt","summary","functions"],"additionalProperties":false,"definitions":{"FunctionCoverageEntry":{"type":"object","properties":{"name":{"type":"string"},"sourceFile":{"type":"string"},"exposed":{"type":"boolean"},"description":{"type":["string","null"]},"coveredLines":{"type":"number"},"totalLines":{"type":"number"},"missedLines":{"type":"array","items":{"type":"number"}},"ratio":{"type":"number"},"status":{"$ref":"#/definitions/CoverageStatus"}},"required":["name","sourceFile","exposed","description","coveredLines","totalLines","missedLines","ratio","status"],"additionalProperties":false},"CoverageStatus":{"type":"string","enum":["covered","partial","uncovered","unknown"]}}}

View File

@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"threadId":{"type":"string"},"resourceId":{"type":"string"}},"required":["threadId"],"additionalProperties":false,"definitions":{}}

Some files were not shown because too many files have changed in this diff Show More