@somewhere-tech/cli 0.34.3 → 0.35.0

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.
@@ -0,0 +1,1397 @@
1
+ import { INIT_AGENTS_MD, INIT_CLAUDE_MD } from './init-agent-guide.js';
2
+ const PACKAGE_JSON = `{
3
+ "name": "somewhere-app",
4
+ "private": true,
5
+ "version": "0.1.0",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "somewhere dev",
9
+ "typecheck": "somewhere typecheck"
10
+ },
11
+ "dependencies": {
12
+ "@somewhere-tech/sdk": "0.11.0",
13
+ "react": "19.2.7",
14
+ "react-dom": "19.2.7"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "19.2.2",
18
+ "@types/react-dom": "19.2.2",
19
+ "typescript": "5.9.3",
20
+ "vite": "7.2.2"
21
+ }
22
+ }
23
+ `;
24
+ const TSCONFIG = `{
25
+ "compilerOptions": {
26
+ "target": "ES2022",
27
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
28
+ "strict": true,
29
+ "noEmit": true,
30
+ "skipLibCheck": true,
31
+ "module": "ESNext",
32
+ "moduleResolution": "Bundler",
33
+ "isolatedModules": true,
34
+ "jsx": "react-jsx"
35
+ },
36
+ "include": ["src", "api", "db", "types"]
37
+ }
38
+ `;
39
+ function indexHtml(appName) {
40
+ const title = appName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
41
+ return `<!doctype html>
42
+ <html lang="en">
43
+ <head>
44
+ <meta charset="UTF-8" />
45
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
46
+ <title>${title}</title>
47
+ </head>
48
+ <body>
49
+ <div id="root"></div>
50
+ <script type="module" src="/src/main.tsx"></script>
51
+ </body>
52
+ </html>
53
+ `;
54
+ }
55
+ // ------------------------------------------------------------------ behaviour
56
+ const AUTH_API = `export { somewhereAuth as default } from '@somewhere-tech/sdk/server';
57
+ `;
58
+ const AUTH_TYPES = `import type { User } from '@somewhere-tech/sdk/auth';
59
+
60
+ export type { User };
61
+
62
+ /**
63
+ * loading: no answer yet (first visit, or another tab changed the account).
64
+ * unavailable: the server could not confirm a session, and none was confirmed
65
+ * on this page. suspended: a re-check failed for the account confirmed on this
66
+ * page; its pages stay mounted but hidden until the session is confirmed.
67
+ */
68
+ export type AuthState =
69
+ | { status: 'loading' }
70
+ | { status: 'signed-out' }
71
+ | { status: 'signed-in'; user: User }
72
+ | { status: 'unavailable'; retrying: boolean; retry(): void }
73
+ | { status: 'suspended'; user: User; retrying: boolean; retry(): void };
74
+
75
+ export type CredentialsMode = 'sign-in' | 'sign-up';
76
+
77
+ export interface CredentialsForm {
78
+ mode: CredentialsMode;
79
+ email: string;
80
+ password: string;
81
+ passwordMinLength: number;
82
+ pending: boolean;
83
+ error: string | null;
84
+ canSubmit: boolean;
85
+ setEmail(value: string): void;
86
+ setPassword(value: string): void;
87
+ toggleMode(): void;
88
+ submit(): Promise<void>;
89
+ }
90
+
91
+ export interface SignOutAction {
92
+ pending: boolean;
93
+ error: string | null;
94
+ signOut(): Promise<void>;
95
+ }
96
+
97
+ /** A sign-out the server has not confirmed; retry() calls it again. */
98
+ export interface UnconfirmedSignOut {
99
+ pending: boolean;
100
+ retry(): void;
101
+ }
102
+ `;
103
+ const AUTH_SERVICE = `import { AuthError, createSomewhereAuth } from '@somewhere-tech/sdk/auth';
104
+
105
+ // The app's auth client (SDK). It talks to api/auth/[...path].ts. The session
106
+ // is an httpOnly cookie the browser keeps; app code never handles a token.
107
+ export const auth = createSomewhereAuth();
108
+
109
+ export const PASSWORD_MIN_LENGTH = 8;
110
+
111
+ export function authErrorMessage(reason: unknown): string {
112
+ if (reason instanceof AuthError && reason.message) return reason.message;
113
+ if (reason instanceof TypeError) return 'Could not reach the server. Check your connection and try again.';
114
+ if (reason instanceof Error && reason.message) return reason.message;
115
+ return 'Something went wrong. Try again.';
116
+ }
117
+ `;
118
+ const AUTH_HOOKS = `import { useRef, useState } from 'react';
119
+ import { useAuth, useAuthState as useSdkAuthState } from '@somewhere-tech/sdk/react';
120
+ import type { AuthState, CredentialsForm, CredentialsMode, SignOutAction, UnconfirmedSignOut, User } from '../../types/auth';
121
+ import { authErrorMessage, PASSWORD_MIN_LENGTH } from '../services/auth';
122
+
123
+ // State and actions over the SDK's session status; no session store here.
124
+ // Only an account the server confirmed is signed in: a cached user waits for
125
+ // the first check, and a failed check is 'unavailable', never signed in.
126
+ export function useAuthState(): AuthState {
127
+ const session = useSdkAuthState();
128
+ const [retrying, setRetrying] = useState(false);
129
+ // The account the server confirmed on this page. Only it may keep its
130
+ // (hidden) pages through a failed re-check.
131
+ const confirmed = useRef<User | null>(null);
132
+ if (session.status === 'authenticated') confirmed.current = session.user;
133
+ if (session.status === 'signed-out' || session.status === 'checking') confirmed.current = null;
134
+
135
+ switch (session.status) {
136
+ case 'authenticated':
137
+ return session.user ? { status: 'signed-in', user: session.user } : { status: 'signed-out' };
138
+ case 'signed-out':
139
+ return { status: 'signed-out' };
140
+ case 'checking':
141
+ return { status: 'loading' };
142
+ case 'indeterminate': {
143
+ const retry = () => {
144
+ setRetrying(true);
145
+ void session.recheck().finally(() => setRetrying(false));
146
+ };
147
+ const kept = confirmed.current;
148
+ return kept && session.user?.id === kept.id
149
+ ? { status: 'suspended', user: kept, retrying, retry }
150
+ : { status: 'unavailable', retrying, retry };
151
+ }
152
+ }
153
+ }
154
+
155
+ export function useCredentialsForm(initialMode: CredentialsMode = 'sign-in'): CredentialsForm {
156
+ const auth = useAuth();
157
+ const [mode, setMode] = useState<CredentialsMode>(initialMode);
158
+ const [email, setEmail] = useState('');
159
+ const [password, setPassword] = useState('');
160
+ const [pending, setPending] = useState(false);
161
+ const [error, setError] = useState<string | null>(null);
162
+
163
+ const passwordReady = mode === 'sign-up' ? password.length >= PASSWORD_MIN_LENGTH : password.length > 0;
164
+ const canSubmit = !pending && email.trim() !== '' && passwordReady;
165
+
166
+ async function submit() {
167
+ if (!canSubmit) return;
168
+ setPending(true);
169
+ setError(null);
170
+ try {
171
+ const credentials = { email: email.trim(), password };
172
+ await (mode === 'sign-in' ? auth.signIn(credentials) : auth.signUp(credentials));
173
+ // The provider now has the user; App replaces the sign-in page.
174
+ } catch (reason: unknown) {
175
+ setError(authErrorMessage(reason));
176
+ setPending(false);
177
+ }
178
+ }
179
+
180
+ return {
181
+ mode,
182
+ email,
183
+ password,
184
+ passwordMinLength: PASSWORD_MIN_LENGTH,
185
+ pending,
186
+ error,
187
+ canSubmit,
188
+ setEmail,
189
+ setPassword,
190
+ toggleMode() {
191
+ setMode(mode === 'sign-in' ? 'sign-up' : 'sign-in');
192
+ setError(null);
193
+ },
194
+ submit,
195
+ };
196
+ }
197
+
198
+ export function useSignOut(): SignOutAction {
199
+ const auth = useAuth();
200
+ const [pending, setPending] = useState(false);
201
+ const [error, setError] = useState<string | null>(null);
202
+
203
+ async function signOut() {
204
+ setPending(true);
205
+ setError(null);
206
+ try {
207
+ await auth.signOut();
208
+ } catch (reason: unknown) {
209
+ setError(authErrorMessage(reason));
210
+ setPending(false);
211
+ }
212
+ }
213
+
214
+ return { pending, error, signOut };
215
+ }
216
+
217
+ /** Non-null while the server has not confirmed the last sign-out: this
218
+ * browser was signed out locally, but the server session may still exist. */
219
+ export function useUnconfirmedSignOut(): UnconfirmedSignOut | null {
220
+ const auth = useAuth();
221
+ const session = useSdkAuthState();
222
+ const [pending, setPending] = useState(false);
223
+ if (!session.signOutUnconfirmed) return null;
224
+ return {
225
+ pending,
226
+ retry() {
227
+ setPending(true);
228
+ void auth.signOut().finally(() => setPending(false));
229
+ },
230
+ };
231
+ }
232
+ `;
233
+ const ROUTES = `import { useEffect, useState, type MouseEvent } from 'react';
234
+
235
+ // Single entry: the platform serves index.html for every extensionless path,
236
+ // so the app routes on location.pathname. To add a page, add its path here
237
+ // and a case in App.tsx.
238
+ export const ROUTES = {
239
+ home: '/',
240
+ } as const;
241
+
242
+ export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
243
+
244
+ export interface LinkProps {
245
+ href: string;
246
+ onClick(event: MouseEvent<HTMLAnchorElement>): void;
247
+ }
248
+
249
+ export function navigate(path: RoutePath): void {
250
+ if (window.location.pathname === path) return;
251
+ window.history.pushState(null, '', path);
252
+ window.dispatchEvent(new PopStateEvent('popstate'));
253
+ }
254
+
255
+ /** Props for an <a> that navigates in-app and keeps new-tab behaviour. */
256
+ export function linkTo(path: RoutePath): LinkProps {
257
+ return {
258
+ href: path,
259
+ onClick(event) {
260
+ if (event.defaultPrevented || event.button !== 0) return;
261
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
262
+ event.preventDefault();
263
+ navigate(path);
264
+ },
265
+ };
266
+ }
267
+
268
+ export function usePathname(): string {
269
+ const [pathname, setPathname] = useState(window.location.pathname);
270
+ useEffect(() => {
271
+ const update = () => setPathname(window.location.pathname);
272
+ window.addEventListener('popstate', update);
273
+ return () => window.removeEventListener('popstate', update);
274
+ }, []);
275
+ return pathname;
276
+ }
277
+ `;
278
+ const APP = `import type { User } from '../types/auth';
279
+ import { useAuthState } from './auth/hooks';
280
+ import { HomePage } from './pages/HomePage';
281
+ import { NotFoundPage } from './pages/NotFoundPage';
282
+ import { SignInPage } from './pages/SignInPage';
283
+ import { ROUTES, usePathname } from './routes';
284
+ import { LoadingScreen, SessionUnavailable } from './ui/feedback';
285
+
286
+ // Routing and the sign-in boundary. This gate is UX: the server and
287
+ // db/schema.ts decide what each request may read or write.
288
+ export function App() {
289
+ const session = useAuthState();
290
+
291
+ if (session.status === 'loading') return <LoadingScreen label="Checking your session…" />;
292
+ if (session.status === 'signed-out') return <SignInPage />;
293
+ if (session.status === 'unavailable') {
294
+ return <SessionUnavailable keepsWork={false} retrying={session.retrying} onRetry={session.retry} />;
295
+ }
296
+ const suspended = session.status === 'suspended';
297
+ return (
298
+ <>
299
+ {suspended ? <SessionUnavailable keepsWork retrying={session.retrying} onRetry={session.retry} /> : null}
300
+ {/* Keyed by user id: signing out or switching accounts unmounts every
301
+ private page. A failed re-check only hides and disables them, so
302
+ unsaved input survives until the session is confirmed again. */}
303
+ <div hidden={suspended} inert={suspended}>
304
+ <PrivateRoutes key={session.user.id} user={session.user} />
305
+ </div>
306
+ </>
307
+ );
308
+ }
309
+
310
+ function PrivateRoutes({ user }: { user: User }) {
311
+ const pathname = usePathname();
312
+ switch (pathname) {
313
+ case ROUTES.home:
314
+ return <HomePage user={user} />;
315
+ default:
316
+ return <NotFoundPage user={user} />;
317
+ }
318
+ }
319
+ `;
320
+ function mainTsx(styled) {
321
+ const styles = styled ? `import './styles/tokens.css';\nimport './styles/app.css';\n` : '';
322
+ return `import { StrictMode } from 'react';
323
+ import { createRoot } from 'react-dom/client';
324
+ import { SomewhereAuthProvider } from '@somewhere-tech/sdk/react';
325
+ import { App } from './App';
326
+ import { auth } from './services/auth';
327
+ ${styles}
328
+ createRoot(document.getElementById('root')!).render(
329
+ <StrictMode>
330
+ <SomewhereAuthProvider client={auth}>
331
+ <App />
332
+ </SomewhereAuthProvider>
333
+ </StrictMode>,
334
+ );
335
+ `;
336
+ }
337
+ function configTs(appName) {
338
+ return `// Shown in the header and on the sign-in page; index.html holds the tab title.
339
+ export const APP_NAME = ${JSON.stringify(appName)};
340
+ `;
341
+ }
342
+ // ------------------------------------------------------- private-data: backend
343
+ const SCHEMA = `import { id, owner, schema, table, text } from 'somewhere/db';
344
+
345
+ // notes: owner() gives each signed-in user their own rows through the
346
+ // generated somewhere:data client, so the browser code sends no user filter.
347
+ // Functions you add can use explicit server-authority calls that skip this
348
+ // scope; they must check the caller themselves. \`somewhere docs declared-data\`
349
+ export default schema({
350
+ notes: table(
351
+ { id: id(), title: text(), body: text({ default: '' }) },
352
+ {
353
+ scope: owner(),
354
+ client: {
355
+ read: ['id', 'title', 'body'],
356
+ create: ['title', 'body'],
357
+ update: ['title', 'body'],
358
+ delete: true,
359
+ identity: 'authenticated',
360
+ },
361
+ },
362
+ ),
363
+ });
364
+ `;
365
+ const NOTES_TYPES = `export interface Note {
366
+ id: number | string;
367
+ title: string;
368
+ body: string;
369
+ }
370
+
371
+ export type NoteId = Note['id'];
372
+
373
+ export interface NoteDraft {
374
+ title: string;
375
+ body: string;
376
+ }
377
+
378
+ export type NoteDraftErrors = Partial<Record<keyof NoteDraft, string>>;
379
+
380
+ /** unchanged: nothing differed, so nothing was written. missing: the note is
381
+ * gone for this user (deleted elsewhere, or never theirs). */
382
+ export type NoteSaveResult =
383
+ | { kind: 'saved'; note: Note }
384
+ | { kind: 'unchanged' }
385
+ | { kind: 'missing' };
386
+
387
+ export type NoteRemoveResult = { kind: 'removed' } | { kind: 'missing' };
388
+
389
+ export type NotesStatus = 'loading' | 'ready' | 'error';
390
+
391
+ export interface NotesController {
392
+ status: NotesStatus;
393
+ notes: Note[];
394
+ /** True when more notes exist than the first page shows. */
395
+ truncated: boolean;
396
+ loadError: string | null;
397
+ reload(): void;
398
+ draft: NoteDraft;
399
+ draftErrors: NoteDraftErrors;
400
+ limits: { title: number; body: number };
401
+ setDraft(draft: NoteDraft): void;
402
+ editingId: NoteId | null;
403
+ startEdit(note: Note): void;
404
+ cancelEdit(): void;
405
+ saving: boolean;
406
+ canSave: boolean;
407
+ save(): Promise<void>;
408
+ removingId: NoteId | null;
409
+ remove(note: Note): Promise<void>;
410
+ /** The last write's outcome, for a polite live region. */
411
+ notice: { tone: 'info' | 'error'; text: string } | null;
412
+ }
413
+ `;
414
+ const NOTES_SERVICE = `import { data, DataError } from 'somewhere:data';
415
+ import type { Note, NoteDraft, NoteDraftErrors, NoteId, NoteRemoveResult, NoteSaveResult } from '../../types/notes';
416
+
417
+ // Length limits are for the form. db/schema.ts has no length constraint, so
418
+ // a hard limit belongs in a function that validates before it writes.
419
+ export const NOTE_LIMITS = { title: 120, body: 4000 } as const;
420
+ const PAGE_SIZE = 100;
421
+
422
+ export function normalizeDraft(draft: NoteDraft): NoteDraft {
423
+ return { title: draft.title.trim(), body: draft.body.trim() };
424
+ }
425
+
426
+ export function validateDraft(draft: NoteDraft): NoteDraftErrors {
427
+ const { title, body } = normalizeDraft(draft);
428
+ const errors: NoteDraftErrors = {};
429
+ if (!title) errors.title = 'Add a title.';
430
+ else if (title.length > NOTE_LIMITS.title) errors.title = 'Keep the title to ' + NOTE_LIMITS.title + ' characters.';
431
+ if (body.length > NOTE_LIMITS.body) errors.body = 'Keep the note to ' + NOTE_LIMITS.body + ' characters.';
432
+ return errors;
433
+ }
434
+
435
+ function sameDraft(note: Note, draft: NoteDraft): boolean {
436
+ const next = normalizeDraft(draft);
437
+ return note.title === next.title && note.body === next.body;
438
+ }
439
+
440
+ // Rows come back scoped to the signed-in user by owner(); send no user filter.
441
+ export async function listNotes(): Promise<{ notes: Note[]; truncated: boolean }> {
442
+ const page = await data.notes.list({ limit: PAGE_SIZE });
443
+ return { notes: page.data, truncated: page.has_more };
444
+ }
445
+
446
+ export async function createNote(draft: NoteDraft): Promise<Note> {
447
+ const result = await data.notes.create(normalizeDraft(draft));
448
+ if (!result.data) throw new Error('The note was not saved. Try again.');
449
+ return result.data;
450
+ }
451
+
452
+ export async function updateNote(note: Note, draft: NoteDraft): Promise<NoteSaveResult> {
453
+ if (sameDraft(note, draft)) return { kind: 'unchanged' };
454
+ try {
455
+ const result = await data.notes.update(note.id, normalizeDraft(draft));
456
+ if (result.changes === 0) return { kind: 'unchanged' };
457
+ return { kind: 'saved', note: result.data ?? { id: note.id, ...normalizeDraft(draft) } };
458
+ } catch (reason: unknown) {
459
+ if (isNotFound(reason)) return { kind: 'missing' };
460
+ throw reason;
461
+ }
462
+ }
463
+
464
+ export async function removeNote(id: NoteId): Promise<NoteRemoveResult> {
465
+ try {
466
+ await data.notes.delete(id);
467
+ return { kind: 'removed' };
468
+ } catch (reason: unknown) {
469
+ if (isNotFound(reason)) return { kind: 'missing' };
470
+ throw reason;
471
+ }
472
+ }
473
+
474
+ /** A missing note and one this user may not write answer the same way. */
475
+ function isNotFound(reason: unknown): boolean {
476
+ return reason instanceof DataError && reason.code === 'DATA_NOT_FOUND';
477
+ }
478
+
479
+ export function isSessionEnded(reason: unknown): boolean {
480
+ return reason instanceof DataError && reason.code === 'AUTH_REQUIRED';
481
+ }
482
+
483
+ export function notesErrorMessage(reason: unknown): string {
484
+ if (reason instanceof DataError) {
485
+ switch (reason.code) {
486
+ case 'AUTH_REQUIRED':
487
+ return 'Your session ended. Sign in again.';
488
+ case 'DATA_CONTRACT_MISMATCH':
489
+ return 'This app was just updated. Reload the page to continue.';
490
+ default:
491
+ return reason.message || 'The request failed (' + reason.status + ').';
492
+ }
493
+ }
494
+ if (reason instanceof TypeError) return 'Could not reach the server. Check your connection and try again.';
495
+ if (reason instanceof Error && reason.message) return reason.message;
496
+ return 'Something went wrong. Try again.';
497
+ }
498
+ `;
499
+ const USE_NOTES = `import { useEffect, useRef, useState } from 'react';
500
+ import { useAuth } from '@somewhere-tech/sdk/react';
501
+ import type { Note, NoteDraft, NoteDraftErrors, NoteId, NotesController, NotesStatus } from '../../types/notes';
502
+ import {
503
+ createNote,
504
+ isSessionEnded,
505
+ listNotes,
506
+ NOTE_LIMITS,
507
+ notesErrorMessage,
508
+ removeNote,
509
+ updateNote,
510
+ validateDraft,
511
+ } from '../services/notes';
512
+
513
+ const EMPTY_DRAFT: NoteDraft = { title: '', body: '' };
514
+
515
+ // Mounted inside App's per-user boundary, so a new account always starts from
516
+ // a fresh hook. Results that arrive after this view unmounted (sign-out,
517
+ // account switch) are dropped, including writes still in flight.
518
+ export function useNotes(): NotesController {
519
+ const auth = useAuth();
520
+ const mounted = useRef(true);
521
+ const [status, setStatus] = useState<NotesStatus>('loading');
522
+ const [notes, setNotes] = useState<Note[]>([]);
523
+ const [truncated, setTruncated] = useState(false);
524
+ const [loadError, setLoadError] = useState<string | null>(null);
525
+ const [reloadKey, setReloadKey] = useState(0);
526
+ const [draft, setDraft] = useState<NoteDraft>(EMPTY_DRAFT);
527
+ const [editingId, setEditingId] = useState<NoteId | null>(null);
528
+ const [saving, setSaving] = useState(false);
529
+ const [removingId, setRemovingId] = useState<NoteId | null>(null);
530
+ const [notice, setNotice] = useState<NotesController['notice']>(null);
531
+
532
+ useEffect(() => {
533
+ mounted.current = true;
534
+ return () => {
535
+ mounted.current = false;
536
+ };
537
+ }, []);
538
+
539
+ useEffect(() => {
540
+ let current = true;
541
+ setStatus('loading');
542
+ setLoadError(null);
543
+ listNotes()
544
+ .then((result) => {
545
+ if (!current) return;
546
+ setNotes(result.notes);
547
+ setTruncated(result.truncated);
548
+ setStatus('ready');
549
+ })
550
+ .catch((reason: unknown) => {
551
+ if (!current) return;
552
+ setLoadError(notesErrorMessage(reason));
553
+ setStatus('error');
554
+ if (isSessionEnded(reason)) void auth.getUser();
555
+ });
556
+ return () => {
557
+ current = false;
558
+ };
559
+ }, [reloadKey, auth]);
560
+
561
+ const errors = validateDraft(draft);
562
+ // An empty title disables Save; it is not flagged while the user types.
563
+ const draftErrors: NoteDraftErrors = draft.title.trim() ? errors : { body: errors.body };
564
+ const editing = editingId === null ? null : notes.find((note) => note.id === editingId) ?? null;
565
+ const canSave = !saving && Object.keys(errors).length === 0;
566
+
567
+ function fail(reason: unknown) {
568
+ setNotice({ tone: 'error', text: notesErrorMessage(reason) });
569
+ if (isSessionEnded(reason)) void auth.getUser();
570
+ }
571
+
572
+ async function save() {
573
+ if (!canSave) return;
574
+ setSaving(true);
575
+ setNotice(null);
576
+ try {
577
+ if (editing) {
578
+ const result = await updateNote(editing, draft);
579
+ if (!mounted.current) return;
580
+ if (result.kind === 'saved') {
581
+ setNotes((list) => list.map((note) => (note.id === editing.id ? result.note : note)));
582
+ setNotice({ tone: 'info', text: 'Saved.' });
583
+ } else if (result.kind === 'unchanged') {
584
+ setNotice({ tone: 'info', text: 'No changes to save.' });
585
+ } else {
586
+ setNotes((list) => list.filter((note) => note.id !== editing.id));
587
+ setNotice({ tone: 'error', text: 'That note was already removed.' });
588
+ }
589
+ } else {
590
+ const created = await createNote(draft);
591
+ if (!mounted.current) return;
592
+ setNotes((list) => [...list, created]);
593
+ setNotice({ tone: 'info', text: 'Added.' });
594
+ }
595
+ setDraft(EMPTY_DRAFT);
596
+ setEditingId(null);
597
+ } catch (reason: unknown) {
598
+ if (mounted.current) fail(reason);
599
+ } finally {
600
+ if (mounted.current) setSaving(false);
601
+ }
602
+ }
603
+
604
+ async function remove(note: Note) {
605
+ setRemovingId(note.id);
606
+ setNotice(null);
607
+ try {
608
+ const result = await removeNote(note.id);
609
+ if (!mounted.current) return;
610
+ setNotes((list) => list.filter((item) => item.id !== note.id));
611
+ if (editingId === note.id) {
612
+ setEditingId(null);
613
+ setDraft(EMPTY_DRAFT);
614
+ }
615
+ setNotice({ tone: 'info', text: result.kind === 'removed' ? 'Deleted.' : 'That note was already removed.' });
616
+ } catch (reason: unknown) {
617
+ if (mounted.current) fail(reason);
618
+ } finally {
619
+ if (mounted.current) setRemovingId(null);
620
+ }
621
+ }
622
+
623
+ return {
624
+ status,
625
+ notes,
626
+ truncated,
627
+ loadError,
628
+ reload: () => setReloadKey((key) => key + 1),
629
+ draft,
630
+ draftErrors,
631
+ limits: NOTE_LIMITS,
632
+ setDraft,
633
+ editingId,
634
+ startEdit(note) {
635
+ setEditingId(note.id);
636
+ setDraft({ title: note.title, body: note.body });
637
+ setNotice(null);
638
+ },
639
+ cancelEdit() {
640
+ setEditingId(null);
641
+ setDraft(EMPTY_DRAFT);
642
+ },
643
+ saving,
644
+ canSave,
645
+ save,
646
+ removingId,
647
+ remove,
648
+ notice,
649
+ };
650
+ }
651
+ `;
652
+ // ---------------------------------------------------------------------- pages
653
+ // One set of pages for both UI modes: hooks in, typed values and callbacks out.
654
+ const SIGN_IN_PAGE = `import { useCredentialsForm, useUnconfirmedSignOut } from '../auth/hooks';
655
+ import { APP_NAME } from '../config';
656
+ import { AuthCard } from '../ui/AuthCard';
657
+ import { SignOutUnconfirmed } from '../ui/feedback';
658
+
659
+ export function SignInPage() {
660
+ const form = useCredentialsForm();
661
+ const unconfirmed = useUnconfirmedSignOut();
662
+ return (
663
+ <>
664
+ {unconfirmed ? <SignOutUnconfirmed pending={unconfirmed.pending} onRetry={unconfirmed.retry} /> : null}
665
+ <AuthCard appName={APP_NAME} form={form} />
666
+ </>
667
+ );
668
+ }
669
+ `;
670
+ const SIGNED_IN_LAYOUT = `import type { ReactNode } from 'react';
671
+ import type { User } from '../../types/auth';
672
+ import { useSignOut } from '../auth/hooks';
673
+ import { APP_NAME } from '../config';
674
+ import { linkTo, ROUTES } from '../routes';
675
+ import { AppShell } from '../ui/AppShell';
676
+
677
+ /** The frame every signed-in page shares. */
678
+ export function SignedInLayout({ user, children }: { user: User; children: ReactNode }) {
679
+ const signOut = useSignOut();
680
+ return (
681
+ <AppShell
682
+ appName={APP_NAME}
683
+ homeLink={linkTo(ROUTES.home)}
684
+ account={{
685
+ label: user.email ?? 'Signed in',
686
+ onSignOut: () => void signOut.signOut(),
687
+ signingOut: signOut.pending,
688
+ error: signOut.error,
689
+ }}
690
+ >
691
+ {children}
692
+ </AppShell>
693
+ );
694
+ }
695
+ `;
696
+ function homePage(privateData) {
697
+ if (privateData) {
698
+ return `import type { User } from '../../types/auth';
699
+ import { useNotes } from '../data/useNotes';
700
+ import { NotesBoard } from '../ui/NotesBoard';
701
+ import { SignedInLayout } from './SignedInLayout';
702
+
703
+ // The notes example is removable: delete this page's body, NotesBoard,
704
+ // useNotes, services/notes.ts, types/notes.ts and the table in db/schema.ts.
705
+ export function HomePage({ user }: { user: User }) {
706
+ const notes = useNotes();
707
+ return (
708
+ <SignedInLayout user={user}>
709
+ <NotesBoard notes={notes} />
710
+ </SignedInLayout>
711
+ );
712
+ }
713
+ `;
714
+ }
715
+ return `import type { User } from '../../types/auth';
716
+ import { EmptyState } from '../ui/feedback';
717
+ import { SignedInLayout } from './SignedInLayout';
718
+
719
+ // Your app starts here.
720
+ export function HomePage({ user }: { user: User }) {
721
+ return (
722
+ <SignedInLayout user={user}>
723
+ <EmptyState title="You're signed in.">
724
+ Build your app in src/pages/HomePage.tsx.
725
+ </EmptyState>
726
+ </SignedInLayout>
727
+ );
728
+ }
729
+ `;
730
+ }
731
+ const NOT_FOUND_PAGE = `import type { User } from '../../types/auth';
732
+ import { linkTo, ROUTES } from '../routes';
733
+ import { EmptyState } from '../ui/feedback';
734
+ import { SignedInLayout } from './SignedInLayout';
735
+
736
+ export function NotFoundPage({ user }: { user: User }) {
737
+ return (
738
+ <SignedInLayout user={user}>
739
+ <EmptyState title="Page not found.">
740
+ <a {...linkTo(ROUTES.home)}>Go home</a>
741
+ </EmptyState>
742
+ </SignedInLayout>
743
+ );
744
+ }
745
+ `;
746
+ // ------------------------------------------------------------ styled views
747
+ // Presentational: props in, markup out. No SDK, service or network calls.
748
+ const STYLED_FEEDBACK = `import type { ReactNode } from 'react';
749
+
750
+ export function LoadingScreen({ label }: { label: string }) {
751
+ return (
752
+ <main className="loading" aria-busy="true">
753
+ <span className="spinner" aria-hidden="true" />
754
+ <p role="status">{label}</p>
755
+ </main>
756
+ );
757
+ }
758
+
759
+ export function Alert({ children, action }: { children: ReactNode; action?: ReactNode }) {
760
+ return (
761
+ <div className="alert" role="alert">
762
+ <p>{children}</p>
763
+ {action}
764
+ </div>
765
+ );
766
+ }
767
+
768
+ export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
769
+ return (
770
+ <div className="empty">
771
+ <p className="empty__title">{title}</p>
772
+ {children ? <p className="empty__body">{children}</p> : null}
773
+ </div>
774
+ );
775
+ }
776
+
777
+ interface SessionUnavailableProps {
778
+ keepsWork: boolean;
779
+ retrying: boolean;
780
+ onRetry(): void;
781
+ }
782
+
783
+ export function SignOutUnconfirmed({ pending, onRetry }: { pending: boolean; onRetry(): void }) {
784
+ return (
785
+ <div className="banner" role="alert">
786
+ <p>We couldn't confirm sign-out with the server, so your session there may still be active.</p>
787
+ <button type="button" className="button button--quiet" onClick={onRetry} disabled={pending} aria-busy={pending}>
788
+ {pending ? 'Signing out…' : 'Sign out again'}
789
+ </button>
790
+ </div>
791
+ );
792
+ }
793
+
794
+ export function SessionUnavailable({ keepsWork, retrying, onRetry }: SessionUnavailableProps) {
795
+ return (
796
+ <main className="loading">
797
+ <div className="panel unavailable" role="alert">
798
+ <p className="panel__title">We can't confirm your sign-in</p>
799
+ <p className="muted">
800
+ The server didn't answer. Check your connection and try again.
801
+ {keepsWork ? ' Your unsaved changes are kept.' : ''}
802
+ </p>
803
+ <button type="button" className="button" onClick={onRetry} disabled={retrying} aria-busy={retrying}>
804
+ {retrying ? 'Trying…' : 'Try again'}
805
+ </button>
806
+ </div>
807
+ </main>
808
+ );
809
+ }
810
+ `;
811
+ const STYLED_AUTH_CARD = `import type { FormEvent } from 'react';
812
+ import type { CredentialsForm } from '../../types/auth';
813
+
814
+ export function AuthCard({ appName, form }: { appName: string; form: CredentialsForm }) {
815
+ const signUp = form.mode === 'sign-up';
816
+
817
+ function onSubmit(event: FormEvent<HTMLFormElement>) {
818
+ event.preventDefault();
819
+ void form.submit();
820
+ }
821
+
822
+ return (
823
+ <main className="auth">
824
+ <div className="auth__intro">
825
+ <p className="auth__app">{appName}</p>
826
+ <h1 className="auth__title">{signUp ? 'Create your account' : 'Welcome back'}</h1>
827
+ <p className="muted">{signUp ? 'It takes a few seconds.' : 'Sign in to continue.'}</p>
828
+ </div>
829
+ <form className="panel auth__form" onSubmit={onSubmit} noValidate aria-label={signUp ? 'Create account' : 'Sign in'}>
830
+ <div className="field">
831
+ <label htmlFor="auth-email">Email</label>
832
+ <input id="auth-email" type="email" name="email" autoComplete="email" inputMode="email" required autoFocus
833
+ value={form.email} onChange={(event) => form.setEmail(event.target.value)} />
834
+ </div>
835
+ <div className="field">
836
+ <label htmlFor="auth-password">Password</label>
837
+ <input id="auth-password" type="password" name="password" required
838
+ autoComplete={signUp ? 'new-password' : 'current-password'}
839
+ minLength={signUp ? form.passwordMinLength : undefined}
840
+ aria-describedby={signUp ? 'auth-password-hint' : undefined}
841
+ value={form.password} onChange={(event) => form.setPassword(event.target.value)} />
842
+ {signUp ? <p id="auth-password-hint" className="hint">At least {form.passwordMinLength} characters.</p> : null}
843
+ </div>
844
+ {form.error ? <p className="error" role="alert">{form.error}</p> : null}
845
+ <button type="submit" className="button" disabled={!form.canSubmit} aria-busy={form.pending}>
846
+ {form.pending ? 'Please wait…' : signUp ? 'Create account' : 'Sign in'}
847
+ </button>
848
+ <p className="auth__switch">
849
+ {signUp ? 'Already have an account?' : 'New here?'}{' '}
850
+ <button type="button" className="link-button" onClick={form.toggleMode} disabled={form.pending}>
851
+ {signUp ? 'Sign in' : 'Create an account'}
852
+ </button>
853
+ </p>
854
+ </form>
855
+ </main>
856
+ );
857
+ }
858
+ `;
859
+ const STYLED_APP_SHELL = `import type { ReactNode } from 'react';
860
+ import type { LinkProps } from '../routes';
861
+
862
+ export interface AccountSummary {
863
+ label: string;
864
+ onSignOut(): void;
865
+ signingOut: boolean;
866
+ error: string | null;
867
+ }
868
+
869
+ interface AppShellProps {
870
+ appName: string;
871
+ homeLink: LinkProps;
872
+ account: AccountSummary;
873
+ children: ReactNode;
874
+ }
875
+
876
+ export function AppShell({ appName, homeLink, account, children }: AppShellProps) {
877
+ return (
878
+ <div className="shell">
879
+ <a className="skip-link" href="#content">Skip to content</a>
880
+ <header className="topbar">
881
+ <a className="brand" {...homeLink}>{appName}</a>
882
+ <div className="account">
883
+ <span className="account__label" title={account.label}>{account.label}</span>
884
+ <button type="button" className="button button--quiet" onClick={account.onSignOut} disabled={account.signingOut}>
885
+ {account.signingOut ? 'Signing out…' : 'Sign out'}
886
+ </button>
887
+ </div>
888
+ </header>
889
+ {account.error ? <p className="error topbar__error" role="alert">{account.error}</p> : null}
890
+ <main id="content" className="content" tabIndex={-1}>{children}</main>
891
+ </div>
892
+ );
893
+ }
894
+ `;
895
+ const STYLED_NOTES_BOARD = `import type { FormEvent } from 'react';
896
+ import type { NotesController } from '../../types/notes';
897
+ import { Alert, EmptyState } from './feedback';
898
+
899
+ export function NotesBoard({ notes }: { notes: NotesController }) {
900
+ const { draft, draftErrors, limits } = notes;
901
+ const editing = notes.editingId !== null;
902
+
903
+ function onSubmit(event: FormEvent<HTMLFormElement>) {
904
+ event.preventDefault();
905
+ void notes.save();
906
+ }
907
+
908
+ return (
909
+ <div className="notes-board">
910
+ <form className="panel" onSubmit={onSubmit} noValidate aria-labelledby="note-form-title">
911
+ <h1 id="note-form-title" className="panel__title">{editing ? 'Edit note' : 'New note'}</h1>
912
+ <div className="field">
913
+ <label htmlFor="note-title">Title</label>
914
+ <input id="note-title" name="title" required value={draft.title}
915
+ aria-invalid={draftErrors.title ? true : undefined}
916
+ aria-describedby={draftErrors.title ? 'note-title-error' : undefined}
917
+ onChange={(event) => notes.setDraft({ ...draft, title: event.target.value })} />
918
+ {draftErrors.title ? <p id="note-title-error" className="error">{draftErrors.title}</p> : null}
919
+ </div>
920
+ <div className="field">
921
+ <label htmlFor="note-body">Note <span className="hint">{draft.body.length}/{limits.body}</span></label>
922
+ <textarea id="note-body" name="body" rows={4} value={draft.body}
923
+ aria-invalid={draftErrors.body ? true : undefined}
924
+ aria-describedby={draftErrors.body ? 'note-body-error' : undefined}
925
+ onChange={(event) => notes.setDraft({ ...draft, body: event.target.value })} />
926
+ {draftErrors.body ? <p id="note-body-error" className="error">{draftErrors.body}</p> : null}
927
+ </div>
928
+ <div className="actions">
929
+ <button type="submit" className="button" disabled={!notes.canSave} aria-busy={notes.saving}>
930
+ {notes.saving ? 'Saving…' : editing ? 'Save changes' : 'Add note'}
931
+ </button>
932
+ {editing ? <button type="button" className="button button--quiet" onClick={notes.cancelEdit} disabled={notes.saving}>Cancel</button> : null}
933
+ </div>
934
+ <p className={notes.notice?.tone === 'error' ? 'notice error' : 'notice'} role="status">{notes.notice?.text ?? ''}</p>
935
+ </form>
936
+
937
+ <section aria-labelledby="notes-title" aria-busy={notes.status === 'loading'}>
938
+ <h2 id="notes-title" className="section-title">Your notes</h2>
939
+ {notes.status === 'loading' ? <p className="muted" role="status">Loading notes…</p> : null}
940
+ {notes.status === 'error' ? (
941
+ <Alert action={<button type="button" className="button button--quiet" onClick={notes.reload}>Try again</button>}>
942
+ {notes.loadError}
943
+ </Alert>
944
+ ) : null}
945
+ {notes.status === 'ready' && notes.notes.length === 0 ? (
946
+ <EmptyState title="No notes yet.">Only you can see the notes you add.</EmptyState>
947
+ ) : null}
948
+ {notes.status === 'ready' && notes.notes.length > 0 ? (
949
+ <ul className="note-list">
950
+ {notes.notes.map((note) => (
951
+ <li key={note.id} className="note" aria-current={note.id === notes.editingId ? 'true' : undefined}>
952
+ <div className="note__text">
953
+ <h3 className="note__title">{note.title}</h3>
954
+ {note.body ? <p className="note__body">{note.body}</p> : null}
955
+ </div>
956
+ <div className="note__actions">
957
+ <button type="button" className="link-button" onClick={() => notes.startEdit(note)} aria-label={'Edit ' + note.title}>Edit</button>
958
+ <button type="button" className="link-button link-button--danger" onClick={() => void notes.remove(note)}
959
+ disabled={notes.removingId === note.id} aria-label={'Delete ' + note.title}>
960
+ {notes.removingId === note.id ? 'Deleting…' : 'Delete'}
961
+ </button>
962
+ </div>
963
+ </li>
964
+ ))}
965
+ </ul>
966
+ ) : null}
967
+ {notes.truncated ? <p className="hint">Showing the first {notes.notes.length} notes.</p> : null}
968
+ </section>
969
+ </div>
970
+ );
971
+ }
972
+ `;
973
+ // ------------------------------------------------------------- plain views
974
+ // --ui headless: the same exports and props as the styled views, as plain
975
+ // semantic markup with no CSS. Replace them with your own components.
976
+ const PLAIN_FEEDBACK = `import type { ReactNode } from 'react';
977
+
978
+ export function LoadingScreen({ label }: { label: string }) {
979
+ return <main aria-busy="true"><p role="status">{label}</p></main>;
980
+ }
981
+
982
+ export function Alert({ children, action }: { children: ReactNode; action?: ReactNode }) {
983
+ return <div role="alert"><p>{children}</p>{action}</div>;
984
+ }
985
+
986
+ export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
987
+ return <div><p><strong>{title}</strong></p>{children ? <p>{children}</p> : null}</div>;
988
+ }
989
+
990
+ interface SessionUnavailableProps {
991
+ keepsWork: boolean;
992
+ retrying: boolean;
993
+ onRetry(): void;
994
+ }
995
+
996
+ export function SignOutUnconfirmed({ pending, onRetry }: { pending: boolean; onRetry(): void }) {
997
+ return (
998
+ <div role="alert">
999
+ <p>We couldn't confirm sign-out with the server, so your session there may still be active.</p>
1000
+ <button type="button" onClick={onRetry} disabled={pending}>{pending ? 'Signing out…' : 'Sign out again'}</button>
1001
+ </div>
1002
+ );
1003
+ }
1004
+
1005
+ export function SessionUnavailable({ keepsWork, retrying, onRetry }: SessionUnavailableProps) {
1006
+ return (
1007
+ <main role="alert">
1008
+ <p>We can't confirm your sign-in. Check your connection and try again.{keepsWork ? ' Your unsaved changes are kept.' : ''}</p>
1009
+ <button type="button" onClick={onRetry} disabled={retrying}>{retrying ? 'Trying…' : 'Try again'}</button>
1010
+ </main>
1011
+ );
1012
+ }
1013
+ `;
1014
+ const PLAIN_AUTH_CARD = `import type { FormEvent } from 'react';
1015
+ import type { CredentialsForm } from '../../types/auth';
1016
+
1017
+ export function AuthCard({ appName, form }: { appName: string; form: CredentialsForm }) {
1018
+ const signUp = form.mode === 'sign-up';
1019
+
1020
+ function onSubmit(event: FormEvent<HTMLFormElement>) {
1021
+ event.preventDefault();
1022
+ void form.submit();
1023
+ }
1024
+
1025
+ return (
1026
+ <main>
1027
+ <h1>{signUp ? 'Create your account' : 'Sign in'} · {appName}</h1>
1028
+ <form onSubmit={onSubmit} noValidate>
1029
+ <p>
1030
+ <label htmlFor="auth-email">Email</label><br />
1031
+ <input id="auth-email" type="email" name="email" autoComplete="email" required autoFocus
1032
+ value={form.email} onChange={(event) => form.setEmail(event.target.value)} />
1033
+ </p>
1034
+ <p>
1035
+ <label htmlFor="auth-password">Password</label><br />
1036
+ <input id="auth-password" type="password" name="password" required
1037
+ autoComplete={signUp ? 'new-password' : 'current-password'}
1038
+ minLength={signUp ? form.passwordMinLength : undefined}
1039
+ value={form.password} onChange={(event) => form.setPassword(event.target.value)} />
1040
+ </p>
1041
+ {form.error ? <p role="alert">{form.error}</p> : null}
1042
+ <button type="submit" disabled={!form.canSubmit} aria-busy={form.pending}>
1043
+ {form.pending ? 'Please wait…' : signUp ? 'Create account' : 'Sign in'}
1044
+ </button>
1045
+ </form>
1046
+ <p>
1047
+ <button type="button" onClick={form.toggleMode} disabled={form.pending}>
1048
+ {signUp ? 'Sign in' : 'Create an account'}
1049
+ </button>
1050
+ </p>
1051
+ </main>
1052
+ );
1053
+ }
1054
+ `;
1055
+ const PLAIN_APP_SHELL = `import type { ReactNode } from 'react';
1056
+ import type { LinkProps } from '../routes';
1057
+
1058
+ export interface AccountSummary {
1059
+ label: string;
1060
+ onSignOut(): void;
1061
+ signingOut: boolean;
1062
+ error: string | null;
1063
+ }
1064
+
1065
+ interface AppShellProps {
1066
+ appName: string;
1067
+ homeLink: LinkProps;
1068
+ account: AccountSummary;
1069
+ children: ReactNode;
1070
+ }
1071
+
1072
+ export function AppShell({ appName, homeLink, account, children }: AppShellProps) {
1073
+ return (
1074
+ <>
1075
+ <header>
1076
+ <a {...homeLink}>{appName}</a>{' '}
1077
+ <span>{account.label}</span>{' '}
1078
+ <button type="button" onClick={account.onSignOut} disabled={account.signingOut}>
1079
+ {account.signingOut ? 'Signing out…' : 'Sign out'}
1080
+ </button>
1081
+ {account.error ? <p role="alert">{account.error}</p> : null}
1082
+ </header>
1083
+ <main>{children}</main>
1084
+ </>
1085
+ );
1086
+ }
1087
+ `;
1088
+ const PLAIN_NOTES_BOARD = `import type { FormEvent } from 'react';
1089
+ import type { NotesController } from '../../types/notes';
1090
+ import { Alert, EmptyState } from './feedback';
1091
+
1092
+ export function NotesBoard({ notes }: { notes: NotesController }) {
1093
+ const { draft, draftErrors } = notes;
1094
+ const editing = notes.editingId !== null;
1095
+
1096
+ function onSubmit(event: FormEvent<HTMLFormElement>) {
1097
+ event.preventDefault();
1098
+ void notes.save();
1099
+ }
1100
+
1101
+ return (
1102
+ <>
1103
+ <h1>{editing ? 'Edit note' : 'New note'}</h1>
1104
+ <form onSubmit={onSubmit} noValidate>
1105
+ <p>
1106
+ <label htmlFor="note-title">Title</label><br />
1107
+ <input id="note-title" name="title" required value={draft.title} aria-invalid={draftErrors.title ? true : undefined}
1108
+ onChange={(event) => notes.setDraft({ ...draft, title: event.target.value })} />
1109
+ {draftErrors.title ? <span role="alert"> {draftErrors.title}</span> : null}
1110
+ </p>
1111
+ <p>
1112
+ <label htmlFor="note-body">Note</label><br />
1113
+ <textarea id="note-body" name="body" value={draft.body} aria-invalid={draftErrors.body ? true : undefined}
1114
+ onChange={(event) => notes.setDraft({ ...draft, body: event.target.value })} />
1115
+ {draftErrors.body ? <span role="alert"> {draftErrors.body}</span> : null}
1116
+ </p>
1117
+ <button type="submit" disabled={!notes.canSave} aria-busy={notes.saving}>
1118
+ {notes.saving ? 'Saving…' : editing ? 'Save changes' : 'Add note'}
1119
+ </button>{' '}
1120
+ {editing ? <button type="button" onClick={notes.cancelEdit}>Cancel</button> : null}
1121
+ <p role="status">{notes.notice?.text ?? ''}</p>
1122
+ </form>
1123
+ <h2>Your notes</h2>
1124
+ {notes.status === 'loading' ? <p role="status">Loading notes…</p> : null}
1125
+ {notes.status === 'error' ? <Alert action={<button type="button" onClick={notes.reload}>Try again</button>}>{notes.loadError}</Alert> : null}
1126
+ {notes.status === 'ready' && notes.notes.length === 0 ? <EmptyState title="No notes yet." /> : null}
1127
+ <ul>
1128
+ {notes.notes.map((note) => (
1129
+ <li key={note.id}>
1130
+ <strong>{note.title}</strong> {note.body}{' '}
1131
+ <button type="button" onClick={() => notes.startEdit(note)} aria-label={'Edit ' + note.title}>Edit</button>{' '}
1132
+ <button type="button" onClick={() => void notes.remove(note)} disabled={notes.removingId === note.id} aria-label={'Delete ' + note.title}>Delete</button>
1133
+ </li>
1134
+ ))}
1135
+ </ul>
1136
+ </>
1137
+ );
1138
+ }
1139
+ `;
1140
+ // --------------------------------------------------------------------- styles
1141
+ const TOKENS_CSS = `/* Shared design values. app.css reads them; components and app.css can
1142
+ still override locally. Local font stacks only (no network fonts). */
1143
+ :root {
1144
+ --font-body: "Avenir Next", "Segoe UI Variable Text", "Segoe UI", "Helvetica Neue", sans-serif;
1145
+ --font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
1146
+
1147
+ --text-sm: 0.875rem;
1148
+ --text-base: 1rem;
1149
+ --text-lg: 1.25rem;
1150
+ --text-display: clamp(2rem, 4.4vw, 3rem);
1151
+
1152
+ --space-1: 4px;
1153
+ --space-2: 8px;
1154
+ --space-3: 12px;
1155
+ --space-4: 16px;
1156
+ --space-6: 24px;
1157
+ --space-8: 32px;
1158
+ --space-12: 48px;
1159
+
1160
+ --radius-control: 8px;
1161
+ --radius-panel: 14px;
1162
+
1163
+ --color-bg: #f5f3ee;
1164
+ --color-surface: #ffffff;
1165
+ --color-text: #1c1b18;
1166
+ --color-muted: #66615a;
1167
+ --color-border: #e0dbd1;
1168
+ --color-accent: #1d6b52;
1169
+ --color-on-accent: #ffffff;
1170
+ --color-danger: #b42318;
1171
+ --color-focus: #1d6b52;
1172
+ }
1173
+ `;
1174
+ const APP_CSS = `*, *::before, *::after { box-sizing: border-box; }
1175
+ body {
1176
+ margin: 0;
1177
+ min-width: 320px;
1178
+ background: var(--color-bg);
1179
+ color: var(--color-text);
1180
+ font: 400 var(--text-base)/1.55 var(--font-body);
1181
+ }
1182
+ button, input, textarea { font: inherit; color: inherit; }
1183
+ :focus-visible { outline: 3px solid var(--color-focus); outline-offset: 2px; }
1184
+ .muted { color: var(--color-muted); }
1185
+ .hint { margin: 0; color: var(--color-muted); font-size: var(--text-sm); font-weight: 400; }
1186
+ .error { margin: 0; color: var(--color-danger); font-size: var(--text-sm); }
1187
+ .skip-link { position: absolute; left: var(--space-3); top: -60px; padding: var(--space-2) var(--space-3); background: var(--color-text); color: var(--color-bg); }
1188
+ .skip-link:focus { top: var(--space-3); }
1189
+
1190
+ .panel {
1191
+ display: grid;
1192
+ gap: var(--space-4);
1193
+ padding: var(--space-6);
1194
+ border: 1px solid var(--color-border);
1195
+ border-radius: var(--radius-panel);
1196
+ background: var(--color-surface);
1197
+ box-shadow: 0 1px 2px rgba(28, 27, 24, 0.05), 0 16px 40px -28px rgba(28, 27, 24, 0.35);
1198
+ }
1199
+ .panel__title { margin: 0; font: 600 var(--text-lg)/1.2 var(--font-display); }
1200
+
1201
+ .field { display: grid; gap: 6px; }
1202
+ .field label { display: flex; justify-content: space-between; font-size: var(--text-sm); font-weight: 600; }
1203
+ .field input, .field textarea {
1204
+ width: 100%;
1205
+ min-height: 44px;
1206
+ padding: 10px 12px;
1207
+ border: 1px solid var(--color-border);
1208
+ border-radius: var(--radius-control);
1209
+ background: var(--color-surface);
1210
+ }
1211
+ .field textarea { resize: vertical; }
1212
+ .field input:hover, .field textarea:hover { border-color: var(--color-muted); }
1213
+ .field [aria-invalid="true"] { border-color: var(--color-danger); }
1214
+
1215
+ .button {
1216
+ display: inline-flex;
1217
+ align-items: center;
1218
+ justify-content: center;
1219
+ min-height: 44px;
1220
+ padding: 0 var(--space-4);
1221
+ border: 1px solid var(--color-accent);
1222
+ border-radius: var(--radius-control);
1223
+ background: var(--color-accent);
1224
+ color: var(--color-on-accent);
1225
+ font-weight: 600;
1226
+ white-space: nowrap;
1227
+ cursor: pointer;
1228
+ }
1229
+ .button:hover:not(:disabled) { filter: brightness(1.08); }
1230
+ .button:disabled { opacity: 0.5; cursor: not-allowed; }
1231
+ .button--quiet { background: transparent; border-color: var(--color-border); color: var(--color-text); }
1232
+ .link-button {
1233
+ min-height: 44px;
1234
+ padding: 0 var(--space-2);
1235
+ border: 0;
1236
+ background: none;
1237
+ color: var(--color-accent);
1238
+ font-weight: 600;
1239
+ text-decoration: underline;
1240
+ text-underline-offset: 3px;
1241
+ cursor: pointer;
1242
+ }
1243
+ .link-button:disabled { opacity: 0.5; cursor: not-allowed; }
1244
+ .link-button--danger { color: var(--color-danger); }
1245
+ .actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
1246
+
1247
+ /* Sign-in: split panel on wide screens, stacked on phones. */
1248
+ .auth {
1249
+ display: grid;
1250
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 400px);
1251
+ gap: var(--space-12);
1252
+ align-items: center;
1253
+ width: min(960px, calc(100% - 2 * var(--space-6)));
1254
+ min-height: 100vh;
1255
+ margin: 0 auto;
1256
+ padding: var(--space-12) 0;
1257
+ }
1258
+ .auth__app { margin: 0 0 var(--space-3); color: var(--color-accent); font-weight: 600; }
1259
+ .auth__title { margin: 0 0 var(--space-3); font: 600 var(--text-display)/1.05 var(--font-display); letter-spacing: -0.02em; }
1260
+ .auth__form .button { width: 100%; }
1261
+ .auth__switch { margin: 0; text-align: center; color: var(--color-muted); font-size: var(--text-sm); }
1262
+
1263
+ .loading { display: grid; place-content: center; justify-items: center; gap: var(--space-3); min-height: 100vh; color: var(--color-muted); }
1264
+ .unavailable { max-width: 380px; margin: 0 var(--space-4); color: var(--color-text); }
1265
+ .unavailable p { margin: 0; }
1266
+ .banner { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: center; padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-danger); background: var(--color-surface); color: var(--color-danger); }
1267
+ .banner p { margin: 0; }
1268
+ .spinner { width: 24px; height: 24px; border: 3px solid var(--color-border); border-top-color: var(--color-accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
1269
+ @keyframes spin { to { transform: rotate(360deg); } }
1270
+
1271
+ /* Signed-in frame */
1272
+ .topbar {
1273
+ display: flex;
1274
+ align-items: center;
1275
+ justify-content: space-between;
1276
+ gap: var(--space-4);
1277
+ width: min(1040px, calc(100% - 2 * var(--space-6)));
1278
+ margin: 0 auto;
1279
+ padding: var(--space-4) 0;
1280
+ border-bottom: 1px solid var(--color-border);
1281
+ }
1282
+ .topbar__error { width: min(1040px, calc(100% - 2 * var(--space-6))); margin: var(--space-2) auto 0; }
1283
+ .brand { font: 600 var(--text-lg)/1 var(--font-display); color: inherit; text-decoration: none; }
1284
+ .account { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
1285
+ .account__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-muted); font-size: var(--text-sm); }
1286
+ .content { width: min(1040px, calc(100% - 2 * var(--space-6))); margin: 0 auto; padding: var(--space-8) 0 var(--space-12); outline: none; }
1287
+
1288
+ .empty { padding: var(--space-8) var(--space-6); border: 1px dashed var(--color-border); border-radius: var(--radius-panel); text-align: center; }
1289
+ .empty__title { margin: 0; font: 600 var(--text-lg)/1.3 var(--font-display); }
1290
+ .empty__body { margin: var(--space-2) 0 0; color: var(--color-muted); }
1291
+
1292
+ @media (max-width: 760px) {
1293
+ .auth { grid-template-columns: 1fr; gap: var(--space-6); align-content: start; }
1294
+ }
1295
+ @media (max-width: 480px) {
1296
+ .panel { padding: var(--space-4); }
1297
+ }
1298
+ @media (prefers-reduced-motion: reduce) {
1299
+ .spinner { animation-duration: 3s; }
1300
+ }
1301
+ `;
1302
+ const NOTES_CSS = `/* Notes example: delete with src/ui/NotesBoard.tsx. */
1303
+ .alert { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-danger); border-radius: var(--radius-control); color: var(--color-danger); background: var(--color-surface); }
1304
+ .alert p { margin: 0; }
1305
+ .notes-board { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: var(--space-8); align-items: start; }
1306
+ .notice { min-height: 1.4em; margin: 0; color: var(--color-muted); font-size: var(--text-sm); }
1307
+ .section-title { margin: 0 0 var(--space-3); font-size: var(--text-sm); font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-muted); }
1308
+ .note-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--color-border); }
1309
+ .note { display: flex; gap: var(--space-4); justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--color-border); }
1310
+ .note[aria-current="true"] { box-shadow: inset 3px 0 0 var(--color-accent); padding-left: var(--space-3); }
1311
+ .note__text { min-width: 0; }
1312
+ .note__title { margin: 0; font: 600 var(--text-base)/1.35 var(--font-body); overflow-wrap: anywhere; }
1313
+ .note__body { margin: var(--space-1) 0 0; color: var(--color-muted); white-space: pre-wrap; overflow-wrap: anywhere; }
1314
+ .note__actions { display: flex; flex: none; }
1315
+
1316
+ @media (max-width: 760px) {
1317
+ .notes-board { grid-template-columns: 1fr; }
1318
+ }
1319
+ @media (max-width: 480px) {
1320
+ .note { flex-direction: column; gap: var(--space-1); }
1321
+ .note__actions { margin-left: calc(-1 * var(--space-2)); }
1322
+ }
1323
+ `;
1324
+ // --------------------------------------------------------------------- readme
1325
+ function readme(selection) {
1326
+ const styled = selection.ui === 'styled';
1327
+ const privateData = selection.modules.includes('private-data');
1328
+ const lines = [
1329
+ '# somewhere.tech app',
1330
+ '',
1331
+ '## Where things go',
1332
+ '',
1333
+ '- Your app: `src/pages/HomePage.tsx`. Add a page: a path in `src/routes.ts`, a case in `src/App.tsx`, a file in `src/pages/`.',
1334
+ styled
1335
+ ? '- Look: `src/styles/tokens.css` (shared values), `src/styles/app.css`, and the views in `src/ui/`. Views take props only; rewrite or replace them freely.'
1336
+ : '- Look: `src/ui/` holds plain semantic views. Replace them with your own components; keep their props, or change the pages that pass them.',
1337
+ '- Sign-in: `src/auth/hooks.ts` (state and actions) over the SDK client in `src/services/auth.ts` and the SDK route `api/auth/[...path].ts`.',
1338
+ ];
1339
+ if (privateData) {
1340
+ lines.push('- Data: `db/schema.ts` (tables and browser permissions), `src/services/notes.ts` (calls), `src/data/useNotes.ts` (state). The notes example is removable.');
1341
+ }
1342
+ lines.push('', '## Auth', '', 'Auth is handled by `@somewhere-tech/sdk`. The session is an httpOnly cookie;', 'app code does not store or send a token. `src/App.tsx` shows private pages only', 'for an account the server confirmed: a loading screen until the first check', 'answers, and a "try again" screen when the server cannot be reached (pages', 'already open stay mounted but hidden, so unsaved input survives). The gate is', 'only UX: functions and `db/schema.ts` decide what a request may read or write.', 'Password reset, OAuth and MFA are not generated.');
1343
+ if (privateData) {
1344
+ lines.push('', '## Private data', '', '`db/schema.ts` declares `notes` with `owner()`, so the generated `somewhere:data`', 'client returns and changes only the signed-in user\'s rows; the browser code', 'sends no user filter. Functions you add can bypass this with explicit', 'server-authority calls and must check the caller themselves. The title/body', 'length limits are form checks only. Contract: `somewhere docs declared-data`.');
1345
+ }
1346
+ lines.push('', '## Run', '', '```sh', 'somewhere typecheck', 'somewhere deploy', 'somewhere verify', '```', '', 'Deploy the raw source; the platform compiles it.', '');
1347
+ return lines.join('\n');
1348
+ }
1349
+ /** Where to build, restyle, route and change data, for the init report. */
1350
+ export function extensionPoints(selection) {
1351
+ const points = {
1352
+ app: 'src/pages/HomePage.tsx',
1353
+ routes: 'src/routes.ts + src/App.tsx',
1354
+ look: selection.ui === 'styled' ? 'src/styles/tokens.css, src/styles/app.css, src/ui/' : 'src/ui/',
1355
+ auth: 'src/auth/hooks.ts',
1356
+ };
1357
+ if (selection.modules.includes('private-data'))
1358
+ points.data = 'db/schema.ts, src/services/notes.ts, src/data/useNotes.ts';
1359
+ return points;
1360
+ }
1361
+ // ---------------------------------------------------------------------- build
1362
+ export function createFeatureTemplate(selection, options) {
1363
+ const styled = selection.ui === 'styled';
1364
+ const privateData = selection.modules.includes('private-data');
1365
+ const files = [
1366
+ { path: '.gitignore', content: 'node_modules\ndist\nbuild\n.env\n' },
1367
+ { path: 'AGENTS.md', content: INIT_AGENTS_MD },
1368
+ { path: 'CLAUDE.md', content: INIT_CLAUDE_MD },
1369
+ { path: 'README.md', content: readme(selection) },
1370
+ { path: 'package.json', content: PACKAGE_JSON },
1371
+ { path: 'tsconfig.json', content: TSCONFIG },
1372
+ { path: 'index.html', content: indexHtml(options.appName) },
1373
+ { path: 'api/auth/[...path].ts', content: AUTH_API },
1374
+ { path: 'types/auth.ts', content: AUTH_TYPES },
1375
+ { path: 'src/main.tsx', content: mainTsx(styled) },
1376
+ { path: 'src/App.tsx', content: APP },
1377
+ { path: 'src/config.ts', content: configTs(options.appName) },
1378
+ { path: 'src/routes.ts', content: ROUTES },
1379
+ { path: 'src/services/auth.ts', content: AUTH_SERVICE },
1380
+ { path: 'src/auth/hooks.ts', content: AUTH_HOOKS },
1381
+ { path: 'src/pages/SignInPage.tsx', content: SIGN_IN_PAGE },
1382
+ { path: 'src/pages/SignedInLayout.tsx', content: SIGNED_IN_LAYOUT },
1383
+ { path: 'src/pages/HomePage.tsx', content: homePage(privateData) },
1384
+ { path: 'src/pages/NotFoundPage.tsx', content: NOT_FOUND_PAGE },
1385
+ { path: 'src/ui/feedback.tsx', content: styled ? STYLED_FEEDBACK : PLAIN_FEEDBACK },
1386
+ { path: 'src/ui/AuthCard.tsx', content: styled ? STYLED_AUTH_CARD : PLAIN_AUTH_CARD },
1387
+ { path: 'src/ui/AppShell.tsx', content: styled ? STYLED_APP_SHELL : PLAIN_APP_SHELL },
1388
+ ];
1389
+ if (styled) {
1390
+ files.push({ path: 'src/styles/tokens.css', content: TOKENS_CSS }, { path: 'src/styles/app.css', content: privateData ? APP_CSS + '\n' + NOTES_CSS : APP_CSS });
1391
+ }
1392
+ if (privateData) {
1393
+ files.push({ path: 'db/schema.ts', content: SCHEMA }, { path: 'types/notes.ts', content: NOTES_TYPES }, { path: 'src/services/notes.ts', content: NOTES_SERVICE }, { path: 'src/data/useNotes.ts', content: USE_NOTES }, { path: 'src/ui/NotesBoard.tsx', content: styled ? STYLED_NOTES_BOARD : PLAIN_NOTES_BOARD });
1394
+ }
1395
+ return files;
1396
+ }
1397
+ //# sourceMappingURL=init-feature-template.js.map