create-elytra 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -4
  3. package/index.js +8 -5
  4. package/package.json +11 -4
  5. package/src/args.js +93 -0
  6. package/src/cli.js +113 -0
  7. package/src/prompts.js +40 -0
  8. package/src/scaffold.js +96 -0
  9. package/template/CONNECT.md +89 -0
  10. package/template/README.md +51 -0
  11. package/template/cms/blocks.ts +17 -0
  12. package/template/cms/collections/asset.ts +13 -0
  13. package/template/cms/collections/author.ts +12 -0
  14. package/template/cms/collections/index.ts +11 -0
  15. package/template/cms/collections/page.ts +45 -0
  16. package/template/cms/collections/post.ts +104 -0
  17. package/template/cms/collections/settings.ts +27 -0
  18. package/template/cms/index.ts +11 -0
  19. package/template/cms/redirects.ts +7 -0
  20. package/template/cms/routes.ts +34 -0
  21. package/template/components/index.ts +24 -0
  22. package/template/components/marketing/feature-card.tsx +77 -0
  23. package/template/components/marketing/hero.tsx +81 -0
  24. package/template/components/marketing/index.tsx +32 -0
  25. package/template/components/marketing/section.tsx +41 -0
  26. package/template/components/marketing/shared.ts +21 -0
  27. package/template/components/post-body.tsx +47 -0
  28. package/template/components/post-teaser.tsx +46 -0
  29. package/template/components/theme.css +31 -0
  30. package/template/dot-gitignore +34 -0
  31. package/template/elytra.config.ts +39 -0
  32. package/template/frontend/app/[[...slug]]/page.tsx +22 -0
  33. package/template/frontend/app/api/revalidate/route.ts +14 -0
  34. package/template/frontend/app/layout.tsx +14 -0
  35. package/template/frontend/app/not-found.tsx +8 -0
  36. package/template/frontend/app/sitemap.ts +22 -0
  37. package/template/frontend/dot-env +14 -0
  38. package/template/frontend/lib/content.ts +68 -0
  39. package/template/frontend/lib/host.ts +9 -0
  40. package/template/frontend/lib/live-content.ts +270 -0
  41. package/template/frontend/lib/project-config.ts +22 -0
  42. package/template/frontend/next.config.mjs +19 -0
  43. package/template/frontend/package.json +25 -0
  44. package/template/frontend/tsconfig.json +39 -0
  45. package/template/package.json +22 -0
  46. package/template/pnpm-workspace.yaml +3 -0
  47. package/template/studio/convex/assets.ts +1 -0
  48. package/template/studio/convex/auth.config.ts +3 -0
  49. package/template/studio/convex/auth.ts +6 -0
  50. package/template/studio/convex/cliTokens.ts +1 -0
  51. package/template/studio/convex/cms.ts +1 -0
  52. package/template/studio/convex/content.ts +1 -0
  53. package/template/studio/convex/delivery.ts +1 -0
  54. package/template/studio/convex/functions.ts +1 -0
  55. package/template/studio/convex/graphs.ts +1 -0
  56. package/template/studio/convex/guard.ts +1 -0
  57. package/template/studio/convex/http.ts +6 -0
  58. package/template/studio/convex/members.ts +1 -0
  59. package/template/studio/convex/publishing.ts +1 -0
  60. package/template/studio/convex/references.ts +1 -0
  61. package/template/studio/convex/schema.ts +1 -0
  62. package/template/studio/convex/sync.ts +4 -0
  63. package/template/studio/convex/tsconfig.json +17 -0
  64. package/template/studio/convex/users.ts +1 -0
  65. package/template/studio/convex/webhooks.ts +1 -0
  66. package/template/studio/dot-env +18 -0
  67. package/template/studio/package.json +34 -0
  68. package/template/studio/src/routeTree.gen.ts +104 -0
  69. package/template/studio/src/router.tsx +25 -0
  70. package/template/studio/src/routes/$projectId.$.tsx +14 -0
  71. package/template/studio/src/routes/__root.tsx +119 -0
  72. package/template/studio/src/routes/index.tsx +17 -0
  73. package/template/studio/src/routes/sign-in.tsx +159 -0
  74. package/template/studio/src/styles/app.css +11 -0
  75. package/template/studio/src/styles/canvas.css +23 -0
  76. package/template/studio/src/vite-env.d.ts +1 -0
  77. package/template/studio/tsconfig.json +20 -0
  78. package/template/studio/vite.config.ts +26 -0
  79. package/template/turbo.json +18 -0
@@ -0,0 +1,6 @@
1
+ // EC-252: Convex Auth (EC-150) is package-shipped with a default + override seam; the
2
+ // engine's first-principal admin bootstrap lives in the package, not here. This consumer
3
+ // picks up the default (email + password). Add OAuth via defineStudioAuth({ providers }).
4
+ import { defineStudioAuth } from '@elytracms/studio/convex/auth'
5
+
6
+ export const { auth, signIn, signOut, store, isAuthenticated } = defineStudioAuth()
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/cliTokens'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/cms'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/content'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/delivery'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/functions'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/graphs'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/guard'
@@ -0,0 +1,6 @@
1
+ // EC-252: HTTP routes (Convex Auth /.well-known/* + the EC-157 registry-sync endpoint)
2
+ // are package-shipped; the consumer injects only its own auth (provider choice).
3
+ import { auth } from './auth'
4
+ import { defineStudioHttp } from '@elytracms/studio/convex/http'
5
+
6
+ export default defineStudioHttp({ auth })
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/members'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/publishing'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/references'
@@ -0,0 +1 @@
1
+ export { default } from '@elytracms/studio/convex/schema'
@@ -0,0 +1,4 @@
1
+ // EC-252: the EC-157 sync endpoint is package-shipped engine infra (decoupled from
2
+ // ./_generated via string refs). This consumer shim keeps the convex/sync.ts path
3
+ // Convex bundles + codegens against, registering sync:registrySync + sync:exportSnapshot.
4
+ export * from '@elytracms/studio/convex/sync'
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "verbatimModuleSyntax": true,
11
+ "isolatedModules": true,
12
+ "esModuleInterop": true,
13
+ "customConditions": ["development"],
14
+ "types": ["node"]
15
+ },
16
+ "include": ["./**/*.ts"]
17
+ }
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/users'
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/webhooks'
@@ -0,0 +1,18 @@
1
+ # {{projectName}} — studio environment.
2
+ #
3
+ # Backend mode. Exactly one path is active:
4
+ #
5
+ # Playground (default): in-memory, seeded, no signup. The studio boots straight
6
+ # into an editable starter project; data resets on reload. Sign-in is bypassed.
7
+ #
8
+ # Connected: comment out VITE_ELYTRA_MODE and set VITE_CONVEX_URL to your Convex
9
+ # deployment (run `npx convex dev` from this directory). See CONNECT.md.
10
+ #
11
+ {{studioModeEnv}}
12
+
13
+ # --- Connected mode ----------------------------------------------------------
14
+ # VITE_CONVEX_URL=https://<your-deployment>.convex.cloud
15
+ #
16
+ # Optional: auto-sign-in for local testing (a REAL sign-in; role guards still
17
+ # apply). Format email:password.
18
+ # VITE_ELYTRA_DEV_LOGIN=you@example.com:your-password
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "studio",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite dev",
8
+ "build": "vite build",
9
+ "type-check": "tsc --noEmit",
10
+ "convex:dev": "convex dev"
11
+ },
12
+ "dependencies": {
13
+ "@auth/core": "^0.37.4",
14
+ "@convex-dev/auth": "^0.0.94",
15
+ "@elytracms/core": "^0.0.7",
16
+ "@elytracms/studio": "^0.0.7",
17
+ "@tailwindcss/vite": "^4.3.0",
18
+ "@tanstack/react-router": "^1.170.0",
19
+ "@tanstack/react-start": "^1.168.0",
20
+ "convex": "^1.40.0",
21
+ "react": "^19.0.0",
22
+ "react-dom": "^19.0.0",
23
+ "tailwindcss": "^4.3.0",
24
+ "zod": "^4.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "@types/react": "^19.0.0",
29
+ "@types/react-dom": "^19.0.0",
30
+ "@vitejs/plugin-react": "^5.0.0",
31
+ "typescript": "^5.7.0",
32
+ "vite": "^7.0.0"
33
+ }
34
+ }
@@ -0,0 +1,104 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as SignInRouteImport } from './routes/sign-in'
13
+ import { Route as IndexRouteImport } from './routes/index'
14
+ import { Route as ProjectIdSplatRouteImport } from './routes/$projectId.$'
15
+
16
+ const SignInRoute = SignInRouteImport.update({
17
+ id: '/sign-in',
18
+ path: '/sign-in',
19
+ getParentRoute: () => rootRouteImport,
20
+ } as any)
21
+ const IndexRoute = IndexRouteImport.update({
22
+ id: '/',
23
+ path: '/',
24
+ getParentRoute: () => rootRouteImport,
25
+ } as any)
26
+ const ProjectIdSplatRoute = ProjectIdSplatRouteImport.update({
27
+ id: '/$projectId/$',
28
+ path: '/$projectId/$',
29
+ getParentRoute: () => rootRouteImport,
30
+ } as any)
31
+
32
+ export interface FileRoutesByFullPath {
33
+ '/': typeof IndexRoute
34
+ '/sign-in': typeof SignInRoute
35
+ '/$projectId/$': typeof ProjectIdSplatRoute
36
+ }
37
+ export interface FileRoutesByTo {
38
+ '/': typeof IndexRoute
39
+ '/sign-in': typeof SignInRoute
40
+ '/$projectId/$': typeof ProjectIdSplatRoute
41
+ }
42
+ export interface FileRoutesById {
43
+ __root__: typeof rootRouteImport
44
+ '/': typeof IndexRoute
45
+ '/sign-in': typeof SignInRoute
46
+ '/$projectId/$': typeof ProjectIdSplatRoute
47
+ }
48
+ export interface FileRouteTypes {
49
+ fileRoutesByFullPath: FileRoutesByFullPath
50
+ fullPaths: '/' | '/sign-in' | '/$projectId/$'
51
+ fileRoutesByTo: FileRoutesByTo
52
+ to: '/' | '/sign-in' | '/$projectId/$'
53
+ id: '__root__' | '/' | '/sign-in' | '/$projectId/$'
54
+ fileRoutesById: FileRoutesById
55
+ }
56
+ export interface RootRouteChildren {
57
+ IndexRoute: typeof IndexRoute
58
+ SignInRoute: typeof SignInRoute
59
+ ProjectIdSplatRoute: typeof ProjectIdSplatRoute
60
+ }
61
+
62
+ declare module '@tanstack/react-router' {
63
+ interface FileRoutesByPath {
64
+ '/sign-in': {
65
+ id: '/sign-in'
66
+ path: '/sign-in'
67
+ fullPath: '/sign-in'
68
+ preLoaderRoute: typeof SignInRouteImport
69
+ parentRoute: typeof rootRouteImport
70
+ }
71
+ '/': {
72
+ id: '/'
73
+ path: '/'
74
+ fullPath: '/'
75
+ preLoaderRoute: typeof IndexRouteImport
76
+ parentRoute: typeof rootRouteImport
77
+ }
78
+ '/$projectId/$': {
79
+ id: '/$projectId/$'
80
+ path: '/$projectId/$'
81
+ fullPath: '/$projectId/$'
82
+ preLoaderRoute: typeof ProjectIdSplatRouteImport
83
+ parentRoute: typeof rootRouteImport
84
+ }
85
+ }
86
+ }
87
+
88
+ const rootRouteChildren: RootRouteChildren = {
89
+ IndexRoute: IndexRoute,
90
+ SignInRoute: SignInRoute,
91
+ ProjectIdSplatRoute: ProjectIdSplatRoute,
92
+ }
93
+ export const routeTree = rootRouteImport
94
+ ._addFileChildren(rootRouteChildren)
95
+ ._addFileTypes<FileRouteTypes>()
96
+
97
+ import type { getRouter } from './router.tsx'
98
+ import type { createStart } from '@tanstack/react-start'
99
+ declare module '@tanstack/react-start' {
100
+ interface Register {
101
+ ssr: true
102
+ router: Awaited<ReturnType<typeof getRouter>>
103
+ }
104
+ }
@@ -0,0 +1,25 @@
1
+ import { createRouter } from '@tanstack/react-router'
2
+ import config from '../../elytra.config'
3
+ import { createStudio } from '@elytracms/studio/lib/workspace'
4
+ import { setCanvasStylesheetUrl } from '@elytracms/studio/components/builder/canvas-stylesheet'
5
+ import canvasCss from './styles/canvas.css?url'
6
+ import { routeTree } from './routeTree.gen'
7
+
8
+ // EC-223 mount contract: the entry point explicitly hands the studio its project config
9
+ // (`../../elytra.config` — the repo root) — no `import.meta.glob` autoload. This is the
10
+ // thin mount; it runs at module load on both the server and client entries, seeding the
11
+ // workspace state before any route reads it.
12
+ createStudio(config)
13
+
14
+ // The canvas previews inside an isolated iframe and <link>s `canvas.css` — a SEPARATE
15
+ // stylesheet from the chrome's `app.css`: studio CSS (for the portaled editing controls)
16
+ // + the consuming app's delivery theme (for the blocks). Keeping it separate is what lets
17
+ // the delivery theme use natural global rules without leaking into the studio chrome.
18
+ setCanvasStylesheetUrl(canvasCss)
19
+
20
+ export function getRouter() {
21
+ return createRouter({
22
+ routeTree,
23
+ scrollRestoration: true,
24
+ })
25
+ }
@@ -0,0 +1,14 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { StudioProjectScreen } from '@elytracms/studio/components/studio-project-screen'
3
+ import { studioSearch } from '@elytracms/studio/router/search'
4
+
5
+ // EC-223 Stage 6 (catch-all routing): the whole `/$projectId/*` subtree — every
6
+ // section, the bare project index, and unknown URLs — resolves through this ONE
7
+ // splat. The studio package owns the URL→screen map (`router/match.ts` +
8
+ // `StudioProjectScreen`); this consumer file is the thin mount, the only
9
+ // per-project route the app defines. `validateSearch` is the engine's superset
10
+ // parser so deep-link params (`state`/`asset`/`field`/`page`) survive on any URL.
11
+ export const Route = createFileRoute('/$projectId/$')({
12
+ validateSearch: studioSearch,
13
+ component: StudioProjectScreen,
14
+ })
@@ -0,0 +1,119 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ createRootRoute,
4
+ HeadContent,
5
+ Navigate,
6
+ Outlet,
7
+ Scripts,
8
+ useLocation,
9
+ } from '@tanstack/react-router'
10
+ import { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context'
11
+ import { BackendGate } from '@elytracms/studio/components/backend-gate'
12
+ import { StudioProvider } from '@elytracms/studio/components/studio-context'
13
+ import { ThemeProvider } from '@elytracms/studio/components/theme-context'
14
+ import { WorkspaceGate } from '@elytracms/studio/components/workspace-gate'
15
+ import { Text } from '@elytracms/studio/ui'
16
+ import { SIGN_IN_PATH, resolveRouteGuard } from '@elytracms/studio/lib/auth'
17
+ import { activeSlugForLocation, setActiveProjectSlug } from '@elytracms/studio/lib/workspace'
18
+ import { THEME_INIT_SCRIPT } from '@elytracms/studio/lib/theme'
19
+ import appCss from '../styles/app.css?url'
20
+
21
+ export const Route = createRootRoute({
22
+ head: () => ({
23
+ meta: [
24
+ { charSet: 'utf-8' },
25
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
26
+ { title: 'Elytra' },
27
+ ],
28
+ links: [{ rel: 'stylesheet', href: appCss }],
29
+ }),
30
+ component: RootComponent,
31
+ })
32
+
33
+ function RootComponent() {
34
+ const location = useLocation()
35
+ // EC-179: the active project selects its Convex backend. Resolve it from the
36
+ // URL (or the sign-in redirect target) and bind it *before* the auth and
37
+ // persistence adapters are selected, so the initial sign-in authenticates
38
+ // against the project the user is heading for. Keying the auth/studio subtree
39
+ // by the active project remounts it on a project switch, so the adapters
40
+ // re-select against the new backend and the session is restored per-backend.
41
+ const activeSlug = activeSlugForLocation(
42
+ location.pathname,
43
+ (location.search as { redirect?: string }).redirect,
44
+ )
45
+ setActiveProjectSlug(activeSlug)
46
+
47
+ return (
48
+ <RootDocument>
49
+ <ThemeProvider>
50
+ {/* EC-153: the backend gate runs before auth — a misconfigured or
51
+ unreachable backend is a visible boot state, never an in-memory
52
+ fallback (selectAuthAdapter/selectPersistenceAdapter would throw). */}
53
+ <BackendGate>
54
+ {/* EC-154: an invalid workspace config is a deterministic boot error,
55
+ rendered before auth — the studio never silently falls back to
56
+ unconfigured (adapter-listed) behavior while a config is present. */}
57
+ <WorkspaceGate>
58
+ <AuthProvider key={activeSlug ?? '__launcher__'}>
59
+ <AuthGate>
60
+ <StudioProvider>
61
+ <Outlet />
62
+ </StudioProvider>
63
+ </AuthGate>
64
+ </AuthProvider>
65
+ </WorkspaceGate>
66
+ </BackendGate>
67
+ </ThemeProvider>
68
+ </RootDocument>
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Route guard (EC-150): one chokepoint in the root layout. Unauthenticated
74
+ * visitors on protected paths are redirected to the sign-in screen (with the
75
+ * intended target preserved); signed-in users never see the sign-in screen.
76
+ * The decision logic is the pure, unit-tested `resolveRouteGuard`.
77
+ */
78
+ function AuthGate({ children }: { children: ReactNode }) {
79
+ const { status } = useAuth()
80
+ const { pathname } = useLocation()
81
+ const decision = resolveRouteGuard(pathname, status)
82
+
83
+ if (decision.kind === 'pending') return <SessionLoading />
84
+ if (decision.kind === 'redirect-to-sign-in') {
85
+ return <Navigate to={SIGN_IN_PATH} search={{ redirect: decision.redirect }} replace />
86
+ }
87
+ if (decision.kind === 'redirect-home') return <Navigate to="/" replace />
88
+ return children
89
+ }
90
+
91
+ /** Deterministic session-restore state (also the SSR output for protected paths). */
92
+ function SessionLoading() {
93
+ return (
94
+ <div className="grid min-h-dvh place-items-center bg-background">
95
+ <Text size="sm" tone="muted">
96
+ Checking session…
97
+ </Text>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function RootDocument({ children }: { children: ReactNode }) {
103
+ // Dark-first SSR baseline; THEME_INIT_SCRIPT flips the class pre-hydration
104
+ // from the stored preference, and ThemeProvider keeps it reactive thereafter.
105
+ // suppressHydrationWarning: the script intentionally mutates the class before
106
+ // React hydrates, so a className mismatch on <html> is expected, not a bug.
107
+ return (
108
+ <html lang="de" className="dark" suppressHydrationWarning>
109
+ <head>
110
+ <HeadContent />
111
+ <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
112
+ </head>
113
+ <body className="min-h-screen bg-background font-sans text-foreground antialiased">
114
+ {children}
115
+ <Scripts />
116
+ </body>
117
+ </html>
118
+ )
119
+ }
@@ -0,0 +1,17 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ProjectLauncher } from '@elytracms/studio/components/project-list'
3
+ import { parseSectionState } from '@elytracms/studio/components/section-surface'
4
+ import type { SectionState } from '@elytracms/core/studio-core'
5
+
6
+ export const Route = createFileRoute('/')({
7
+ validateSearch: (search: Record<string, unknown>): { state?: SectionState } => {
8
+ const state = parseSectionState(search.state)
9
+ return state ? { state } : {}
10
+ },
11
+ component: HomePage,
12
+ })
13
+
14
+ function HomePage() {
15
+ const { state } = Route.useSearch()
16
+ return <ProjectLauncher forcedState={state ?? null} />
17
+ }
@@ -0,0 +1,159 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useState } from 'react'
3
+ import type { FormEvent } from 'react'
4
+ import { Button, Card, CardContent, Field, Heading, Input, Stack, Text } from '@elytracms/studio/ui'
5
+ import { useAuth } from '@elytracms/studio/components/auth-context'
6
+ import { LOCAL_DEV_CREDENTIALS, authMode, postSignInTarget } from '@elytracms/studio/lib/auth'
7
+ import { getActiveProjectSlug, getWorkspaceState } from '@elytracms/studio/lib/workspace'
8
+
9
+ /**
10
+ * Sign-in screen (EC-150). Deterministic states: idle → submitting →
11
+ * (signed-in redirect | inline structured failure). Invalid credentials are a
12
+ * rendered state, never a silent failure or a crash. In local (in-memory) mode
13
+ * the seeded dev credentials are listed so the whole flow works offline.
14
+ */
15
+
16
+ export const Route = createFileRoute('/sign-in')({
17
+ validateSearch: (search: Record<string, unknown>): { redirect?: string } => {
18
+ const redirect = search.redirect
19
+ return typeof redirect === 'string' && redirect.length > 0 ? { redirect } : {}
20
+ },
21
+ component: SignInPage,
22
+ })
23
+
24
+ type FormPhase = 'idle' | 'submitting'
25
+ type AuthMode = 'signin' | 'signup'
26
+
27
+ /** The project whose backend this sign-in authenticates against (EC-179). */
28
+ function activeProjectName(): string | null {
29
+ const state = getWorkspaceState()
30
+ if (state.kind !== 'config') return null
31
+ const slug = getActiveProjectSlug()
32
+ const project = (slug && state.projects.find((p) => p.slug === slug)) || state.projects[0]
33
+ return project?.name ?? null
34
+ }
35
+
36
+ function SignInPage() {
37
+ const auth = useAuth()
38
+ const navigate = useNavigate()
39
+ const { redirect } = Route.useSearch()
40
+ const [email, setEmail] = useState('')
41
+ const [password, setPassword] = useState('')
42
+ const [phase, setPhase] = useState<FormPhase>('idle')
43
+ const [mode, setMode] = useState<AuthMode>('signin')
44
+ const [error, setError] = useState<string | null>(null)
45
+ const projectName = activeProjectName()
46
+
47
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
48
+ event.preventDefault()
49
+ setPhase('submitting')
50
+ setError(null)
51
+ const result = mode === 'signup' ? await auth.signUp(email, password) : await auth.signIn(email, password)
52
+ if (result.ok) {
53
+ await navigate({ to: postSignInTarget(redirect) })
54
+ return
55
+ }
56
+ // A fresh backend has no account yet: nudge sign-in → create, and vice versa.
57
+ if (result.reason === 'account-exists') setMode('signin')
58
+ setError(result.message)
59
+ setPhase('idle')
60
+ }
61
+
62
+ const submitting = phase === 'submitting'
63
+ const creating = mode === 'signup'
64
+
65
+ return (
66
+ <div className="grid min-h-dvh place-items-center bg-background p-6">
67
+ <Stack gap="4" className="w-full max-w-sm">
68
+ <Stack gap="1" align="center">
69
+ <span className="size-8 rounded-lg bg-primary shadow-sm" aria-hidden />
70
+ <Heading size="h2">
71
+ {creating ? 'Create your account' : 'Sign in to Elytra'}
72
+ </Heading>
73
+ <Text size="sm" tone="muted">
74
+ {projectName
75
+ ? `${creating ? 'Register on' : 'Continue to'} ${projectName}.`
76
+ : 'Use your studio account to continue.'}
77
+ </Text>
78
+ </Stack>
79
+
80
+ <Card>
81
+ <CardContent className="pt-5">
82
+ <form onSubmit={handleSubmit} noValidate>
83
+ <Stack gap="4">
84
+ <Field label="Email" htmlFor="sign-in-email" required>
85
+ <Input
86
+ id="sign-in-email"
87
+ type="email"
88
+ autoComplete="email"
89
+ value={email}
90
+ onChange={(e) => setEmail(e.target.value)}
91
+ placeholder="you@example.com"
92
+ disabled={submitting}
93
+ />
94
+ </Field>
95
+ <Field label="Password" htmlFor="sign-in-password" required error={error}>
96
+ <Input
97
+ id="sign-in-password"
98
+ type="password"
99
+ autoComplete={creating ? 'new-password' : 'current-password'}
100
+ value={password}
101
+ onChange={(e) => setPassword(e.target.value)}
102
+ disabled={submitting}
103
+ />
104
+ </Field>
105
+ <Button type="submit" disabled={submitting} className="w-full">
106
+ {submitting
107
+ ? creating
108
+ ? 'Creating account…'
109
+ : 'Signing in…'
110
+ : creating
111
+ ? 'Create account'
112
+ : 'Sign in'}
113
+ </Button>
114
+ </Stack>
115
+ </form>
116
+ </CardContent>
117
+ </Card>
118
+
119
+ {authMode() === 'convex' ? (
120
+ <Text size="sm" tone="muted" className="text-center">
121
+ {creating ? 'Already have an account on this backend? ' : 'First time on this backend? '}
122
+ <button
123
+ type="button"
124
+ className="font-medium text-primary underline-offset-2 hover:underline"
125
+ onClick={() => {
126
+ setMode(creating ? 'signin' : 'signup')
127
+ setError(null)
128
+ }}
129
+ >
130
+ {creating ? 'Sign in' : 'Create an account'}
131
+ </button>
132
+ </Text>
133
+ ) : null}
134
+
135
+ {authMode() === 'playground' ? <DevCredentialsHint /> : null}
136
+ </Stack>
137
+ </div>
138
+ )
139
+ }
140
+
141
+ /** Seeded dev accounts for the explicit playground mode — fixtures, not secrets. */
142
+ function DevCredentialsHint() {
143
+ return (
144
+ <Card className="border-dashed">
145
+ <CardContent className="pt-4">
146
+ <Stack gap="1">
147
+ <Text size="xs" tone="muted" className="font-medium">
148
+ Playground mode — seeded dev accounts
149
+ </Text>
150
+ {LOCAL_DEV_CREDENTIALS.map((entry) => (
151
+ <Text key={entry.user.id} size="xs" tone="muted" className="font-mono">
152
+ {entry.user.email} / {entry.password}
153
+ </Text>
154
+ ))}
155
+ </Stack>
156
+ </CardContent>
157
+ </Card>
158
+ )
159
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ * The studio CHROME stylesheet (linked by `routes/__root.tsx`). A thin re-export of
3
+ * the package's complete Tailwind entry (theme + tokens + base + utilities + the
4
+ * package `@source`) — the studio UI and nothing else. This app's own route files are
5
+ * auto-detected by Tailwind from the build root, so they need no `@source` here.
6
+ *
7
+ * The page-builder CANVAS does NOT use this file — its preview iframe is a separate,
8
+ * isolated document with its own stylesheet (`canvas.css`), so the consuming app's
9
+ * delivery theme never has to worry about leaking into this chrome. See `canvas.css`.
10
+ */
11
+ @import '@elytracms/studio/styles.css';
@@ -0,0 +1,23 @@
1
+ /*
2
+ * The page-builder CANVAS stylesheet — `<link>`ed into the preview iframe only
3
+ * (`router.tsx` → `setCanvasStylesheetUrl`), a document fully isolated from the studio
4
+ * chrome. It carries BOTH halves the iframe needs:
5
+ *
6
+ * 1. the studio design system — the inline editing controls (RichTextToolbar,
7
+ * SpacingHandles) are portaled INTO this iframe and are styled with studio
8
+ * utilities, so they need it here;
9
+ * 2. the consuming app's DELIVERY theme — the page-builder blocks
10
+ * (`../../../components/*.tsx`) are the consuming app's own components and render
11
+ * against its delivery CSS, NOT the studio's.
12
+ *
13
+ * Because this file lives only in the isolated iframe (the chrome links `app.css`), the
14
+ * delivery theme keeps its natural global `body`/`h1-h6` rules with zero risk of bleeding
15
+ * into the studio UI — the developer authors `components/theme.css` as plain site CSS.
16
+ *
17
+ * `@source` is required: Tailwind only generates classes it can see, and it never scans
18
+ * the cross-dir host components, so without this their block utilities are purged.
19
+ */
20
+ @import '@elytracms/studio/styles.css';
21
+ @import '../../../components/theme.css';
22
+
23
+ @source '../../../components/**/*.{ts,tsx}';
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "verbatimModuleSyntax": true,
12
+ "isolatedModules": true,
13
+ "esModuleInterop": true,
14
+ "resolveJsonModule": true,
15
+ "allowImportingTsExtensions": true,
16
+ "customConditions": ["development"],
17
+ "types": ["node", "vite/client"]
18
+ },
19
+ "include": ["src", "src/routeTree.gen.ts", "vite.config.ts"]
20
+ }