ucode-agent 1.5.0 → 1.7.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.
Files changed (71) hide show
  1. package/README.md +399 -327
  2. package/package.json +6 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/livelog.js +113 -0
  6. package/src/core/loop.js +2105 -1659
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/core/tests.js +86 -0
  10. package/src/tools/blocks.js +117 -0
  11. package/src/tools/browser.js +121 -59
  12. package/src/tools/cache.js +105 -0
  13. package/src/tools/deploy.js +283 -0
  14. package/src/tools/files.js +91 -8
  15. package/src/tools/index.js +634 -495
  16. package/src/tools/rename.js +157 -0
  17. package/src/tools/scaffold.js +85 -6
  18. package/src/tools/shell.js +799 -701
  19. package/src/tools/symbols.js +218 -0
  20. package/src/tools/types.js +179 -0
  21. package/src/ui/activity.js +203 -0
  22. package/src/ui/plain.js +22 -3
  23. package/src/ui/screen.js +65 -19
  24. package/src/ui/theme.js +5 -1
  25. package/templates/blocks/app-shell.tsx +81 -0
  26. package/templates/blocks/data-table.tsx +117 -0
  27. package/templates/blocks/empty-state.tsx +41 -0
  28. package/templates/blocks/page-header.tsx +27 -0
  29. package/templates/blocks/stat-cards.tsx +46 -0
  30. package/templates/next-shadcn/TEMPLATE.md +53 -9
  31. package/templates/next-shadcn/_package-lock.json +1335 -148
  32. package/templates/next-shadcn/components.json +1 -1
  33. package/templates/next-shadcn/next.config.ts +2 -1
  34. package/templates/next-shadcn/package.json +4 -2
  35. package/templates/next-shadcn/presets/citrus.json +77 -0
  36. package/templates/next-shadcn/presets/graphite.json +77 -0
  37. package/templates/next-shadcn/presets/grove.json +77 -0
  38. package/templates/next-shadcn/presets/ocean.json +78 -0
  39. package/templates/next-shadcn/presets/sunset.json +77 -0
  40. package/templates/next-shadcn/presets/violet.json +77 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  45. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  47. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  48. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  49. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  50. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  51. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  52. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  53. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  54. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  55. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  56. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  57. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  58. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  59. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  60. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  61. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  62. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  63. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  64. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  65. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  66. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  67. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  68. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  69. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  70. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  71. package/ucode.js +8 -1
package/src/ui/screen.js CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
42
  shortenPath, asLabel, ensureColour, planLine, bare,
43
43
  } from './theme.js';
44
+ import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
45
  import { renderer, render, polish } from './markdown.js';
45
46
  import { VERSION } from '../core/version.js';
46
47
 
@@ -58,6 +59,7 @@ export function isLabel(text) {
58
59
  export const COMMANDS = [
59
60
  '/help', '/model', '/models', '/session', '/sessions', '/resume',
60
61
  '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
62
+ '/stats', '/doctor', '/deploy',
61
63
  ];
62
64
 
63
65
  // ANSI ----------------------------------------------------------------------
@@ -135,6 +137,8 @@ export class Screen {
135
137
  this.onInterrupt = null;
136
138
  this.onModeChange = null;
137
139
  this.spinTimer = null;
140
+ this.activity = null; // the turn in flight: when it began, how many steps
141
+ this.tick = 0; // animation frames painted, for the spinner
138
142
  this.pendingPrompt = null;
139
143
 
140
144
  this.cols = output.columns || 80;
@@ -164,7 +168,9 @@ export class Screen {
164
168
  }
165
169
 
166
170
  stop() {
171
+ this.activity = null;
167
172
  this.stopSpinner();
173
+ this.stopTimer();
168
174
  this.output.off?.('resize', this.onResize);
169
175
  this.input.setRawMode?.(false);
170
176
  this.input.pause();
@@ -633,14 +639,23 @@ export class Screen {
633
639
  let middle = '';
634
640
  if (this.flashText) {
635
641
  middle = dim(clip(this.flashText, between - 2));
636
- } else if (this.status.busy) {
637
- const frame = blue(SPINNER[this.status.frame]);
638
- const secs = Math.round((Date.now() - (this.status.since || Date.now())) / 1000);
639
- const elapsed = secs >= 2 ? dim(` ${secs}s`) : '';
640
- const room = between - 20;
641
- middle = room > 8
642
- ? `${frame} ${dim(clip(this.status.text, room))}${elapsed} ${dim('esc to stop')}`
643
- : `${frame}${elapsed}`;
642
+ } else if (this.status.busy || this.activity) {
643
+ // The whole turn, not just the current tool: the timer and step count
644
+ // keep going through the gaps between calls, so a long build never
645
+ // looks like it has stopped.
646
+ const now = Date.now();
647
+ const a = this.activity;
648
+ const since = a?.start ?? this.status.since ?? now;
649
+ const meta = [];
650
+ if (a?.steps) meta.push({ text: `step ${a.steps}`, paint: stepPaint(now - a.movedAt < 900) });
651
+ if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
652
+ middle = fitActivity({
653
+ glyph: spinnerGlyph(this.tick, now),
654
+ label: this.status.busy ? this.status.text : 'working',
655
+ meta,
656
+ hint: 'esc to stop',
657
+ paint: (s) => shimmer(s, now),
658
+ }, between - 3);
644
659
  }
645
660
 
646
661
  // The percentage is pinned to the right border whatever is in the middle,
@@ -700,13 +715,7 @@ export class Screen {
700
715
  // `since` is what makes a long think legible: the label may not change for
701
716
  // a minute, so the seconds beside it are the proof it is still alive.
702
717
  this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
703
- if (!this.spinTimer) {
704
- this.spinTimer = setInterval(() => {
705
- this.status.frame = (this.status.frame + 1) % SPINNER.length;
706
- this.paintStatus();
707
- }, 80);
708
- this.spinTimer.unref?.();
709
- }
718
+ this.startTimer();
710
719
  this.paintStatus();
711
720
  }
712
721
 
@@ -717,16 +726,53 @@ export class Screen {
717
726
  }
718
727
 
719
728
  stopSpinner() {
720
- if (this.spinTimer) {
721
- clearInterval(this.spinTimer);
722
- this.spinTimer = null;
723
- }
729
+ if (!this.activity) this.stopTimer();
724
730
  if (this.status.busy) {
725
731
  this.status = { busy: false, text: '', frame: 0, since: 0 };
726
732
  this.paintStatus();
727
733
  }
728
734
  }
729
735
 
736
+ // -- the turn in flight ----------------------------------------------------
737
+
738
+ /** A turn begins: the timer and step count run until turnEnd(). */
739
+ turnStart() {
740
+ this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
741
+ this.startTimer();
742
+ }
743
+
744
+ /** One more model step in this turn. */
745
+ step() {
746
+ if (!this.activity) return;
747
+ this.activity.steps++;
748
+ this.activity.movedAt = Date.now();
749
+ }
750
+
751
+ /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
752
+ turnEnd({ ok = true } = {}) {
753
+ const a = this.activity;
754
+ this.activity = null;
755
+ if (!this.status.busy) this.stopTimer();
756
+ if (a && ok && Date.now() - a.start >= 2000) this.push(` ${doneLine(Date.now() - a.start, a.steps)}`);
757
+ this.paintStatus();
758
+ }
759
+
760
+ /** The animation clock: only the status row repaints, about twelve times a second. */
761
+ startTimer() {
762
+ if (this.spinTimer) return;
763
+ this.spinTimer = setInterval(() => {
764
+ this.tick++;
765
+ this.paintStatus();
766
+ }, FRAME_MS);
767
+ this.spinTimer.unref?.();
768
+ }
769
+
770
+ stopTimer() {
771
+ if (!this.spinTimer) return;
772
+ clearInterval(this.spinTimer);
773
+ this.spinTimer = null;
774
+ }
775
+
730
776
  // -- input ---------------------------------------------------------------
731
777
 
732
778
  nextLine() {
package/src/ui/theme.js CHANGED
@@ -252,7 +252,11 @@ export function asLabel(text) {
252
252
  return String(text ?? '')
253
253
  .trim()
254
254
  .replace(/\s+/g, ' ')
255
- .replace(/(?<=[\w)\]"'`])[.。]+$/, '');
255
+ // A trailing stop, from a model's sentence or a tool's own output
256
+ // ("Building…", "Completing…"), is noise on a one-line label. A dot that
257
+ // is the argument itself — "Listing ." — is not, so a word has to come
258
+ // before it.
259
+ .replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
256
260
  }
257
261
 
258
262
  /**
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { ReactNode, useState } from "react";
4
+ import Link from "next/link";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
7
+
8
+ export type NavItem = { href: string; label: string; icon?: ReactNode };
9
+
10
+ /**
11
+ * The frame every page sits in: a sidebar on a wide screen, the same nav
12
+ * behind a button on a phone. One list of links drives both, so they cannot
13
+ * drift apart.
14
+ */
15
+ export function AppShell({
16
+ nav,
17
+ current,
18
+ title,
19
+ children,
20
+ }: {
21
+ nav: NavItem[];
22
+ current?: string;
23
+ title: string;
24
+ children: ReactNode;
25
+ }) {
26
+ const [open, setOpen] = useState(false);
27
+
28
+ const links = (onNavigate?: () => void) => (
29
+ <nav className="space-y-1" aria-label="Main">
30
+ {nav.map((item) => {
31
+ const active = current === item.href;
32
+ return (
33
+ <Link
34
+ key={item.href}
35
+ href={item.href}
36
+ onClick={onNavigate}
37
+ aria-current={active ? "page" : undefined}
38
+ className={
39
+ active
40
+ ? "flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-sm font-medium"
41
+ : "flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
42
+ }
43
+ >
44
+ {item.icon}
45
+ {item.label}
46
+ </Link>
47
+ );
48
+ })}
49
+ </nav>
50
+ );
51
+
52
+ return (
53
+ <div className="flex min-h-svh">
54
+ <aside className="hidden w-60 shrink-0 border-r p-4 md:block">
55
+ <div className="mb-6 px-3 text-sm font-semibold tracking-tight">{title}</div>
56
+ {links()}
57
+ </aside>
58
+
59
+ <div className="flex min-w-0 flex-1 flex-col">
60
+ <header className="flex h-14 items-center gap-3 border-b px-4 md:px-6">
61
+ <Sheet open={open} onOpenChange={setOpen}>
62
+ <SheetTrigger asChild>
63
+ <Button variant="ghost" size="sm" className="md:hidden" aria-label="Open menu">
64
+ Menu
65
+ </Button>
66
+ </SheetTrigger>
67
+ <SheetContent side="left" className="w-64 p-4">
68
+ <SheetTitle className="mb-6 px-3 text-sm font-semibold">{title}</SheetTitle>
69
+ {links(() => setOpen(false))}
70
+ </SheetContent>
71
+ </Sheet>
72
+ <span className="text-sm font-medium md:hidden">{title}</span>
73
+ </header>
74
+
75
+ <main className="min-w-0 flex-1 p-4 md:p-8">
76
+ <div className="mx-auto w-full max-w-6xl space-y-8">{children}</div>
77
+ </main>
78
+ </div>
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState, ReactNode } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import {
6
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
7
+ } from "@/components/ui/table";
8
+
9
+ export type Column<T> = {
10
+ key: keyof T & string;
11
+ header: string;
12
+ /** Right-align and tabular-nums, for money and counts. */
13
+ numeric?: boolean;
14
+ render?: (row: T) => ReactNode;
15
+ };
16
+
17
+ /**
18
+ * A table of things you can search and sort.
19
+ *
20
+ * Sorting and filtering happen here, over rows already in memory: it is the
21
+ * right shape up to a few thousand rows and the wrong one past that, where
22
+ * the server should be doing both.
23
+ */
24
+ export function DataTable<T extends { id: string | number }>({
25
+ rows,
26
+ columns,
27
+ searchPlaceholder = "Search…",
28
+ empty,
29
+ }: {
30
+ rows: T[];
31
+ columns: Column<T>[];
32
+ searchPlaceholder?: string;
33
+ empty?: ReactNode;
34
+ }) {
35
+ const [query, setQuery] = useState("");
36
+ const [sort, setSort] = useState<{ key: string; asc: boolean } | null>(null);
37
+
38
+ const shown = useMemo(() => {
39
+ const needle = query.trim().toLowerCase();
40
+ let out = needle
41
+ ? rows.filter((row) =>
42
+ columns.some((c) => String(row[c.key] ?? "").toLowerCase().includes(needle)))
43
+ : rows.slice();
44
+ if (sort) {
45
+ out.sort((a, b) => {
46
+ const x = a[sort.key as keyof T];
47
+ const y = b[sort.key as keyof T];
48
+ if (typeof x === "number" && typeof y === "number") return sort.asc ? x - y : y - x;
49
+ return sort.asc
50
+ ? String(x ?? "").localeCompare(String(y ?? ""))
51
+ : String(y ?? "").localeCompare(String(x ?? ""));
52
+ });
53
+ }
54
+ return out;
55
+ }, [rows, columns, query, sort]);
56
+
57
+ const toggle = (key: string) =>
58
+ setSort((s) => (s?.key === key ? { key, asc: !s.asc } : { key, asc: true }));
59
+
60
+ return (
61
+ <div className="space-y-4">
62
+ <Input
63
+ value={query}
64
+ onChange={(e) => setQuery(e.target.value)}
65
+ placeholder={searchPlaceholder}
66
+ className="max-w-xs"
67
+ aria-label={searchPlaceholder}
68
+ />
69
+
70
+ <div className="overflow-x-auto rounded-lg border">
71
+ <Table>
72
+ <TableHeader>
73
+ <TableRow>
74
+ {columns.map((c) => (
75
+ <TableHead key={c.key} className={c.numeric ? "text-right" : undefined}>
76
+ <button
77
+ type="button"
78
+ onClick={() => toggle(c.key)}
79
+ className="inline-flex items-center gap-1 hover:text-foreground"
80
+ aria-label={`Sort by ${c.header}`}
81
+ >
82
+ {c.header}
83
+ <span aria-hidden className="text-xs text-muted-foreground">
84
+ {sort?.key === c.key ? (sort.asc ? "↑" : "↓") : ""}
85
+ </span>
86
+ </button>
87
+ </TableHead>
88
+ ))}
89
+ </TableRow>
90
+ </TableHeader>
91
+ <TableBody>
92
+ {shown.length === 0 ? (
93
+ <TableRow>
94
+ <TableCell colSpan={columns.length} className="h-28 text-center text-sm text-muted-foreground">
95
+ {query ? `Nothing matches “${query}”.` : empty ?? "Nothing here yet."}
96
+ </TableCell>
97
+ </TableRow>
98
+ ) : (
99
+ shown.map((row) => (
100
+ <TableRow key={row.id}>
101
+ {columns.map((c) => (
102
+ <TableCell
103
+ key={c.key}
104
+ className={c.numeric ? "text-right tabular-nums" : undefined}
105
+ >
106
+ {c.render ? c.render(row) : String(row[c.key] ?? "")}
107
+ </TableCell>
108
+ ))}
109
+ </TableRow>
110
+ ))
111
+ )}
112
+ </TableBody>
113
+ </Table>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,41 @@
1
+ import { ReactNode } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+
4
+ /**
5
+ * What a list looks like before anything is in it.
6
+ *
7
+ * An empty screen is where people decide whether a product is working or
8
+ * broken, so this says which it is and offers the one action that fills it.
9
+ */
10
+ export function EmptyState({
11
+ icon,
12
+ title,
13
+ description,
14
+ actionLabel,
15
+ onAction,
16
+ }: {
17
+ icon?: ReactNode;
18
+ title: string;
19
+ description?: string;
20
+ actionLabel?: string;
21
+ onAction?: () => void;
22
+ }) {
23
+ return (
24
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-16 text-center">
25
+ {icon ? (
26
+ <div className="mb-4 flex size-11 items-center justify-center rounded-full bg-muted text-muted-foreground">
27
+ {icon}
28
+ </div>
29
+ ) : null}
30
+ <h3 className="text-base font-medium">{title}</h3>
31
+ {description ? (
32
+ <p className="mt-1.5 max-w-sm text-sm text-muted-foreground text-pretty">{description}</p>
33
+ ) : null}
34
+ {actionLabel ? (
35
+ <Button className="mt-6" onClick={onAction}>
36
+ {actionLabel}
37
+ </Button>
38
+ ) : null}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,27 @@
1
+ import { ReactNode } from "react";
2
+
3
+ /**
4
+ * The top of a page: what it is, what it is for, and what you can do here.
5
+ * Actions sit on the right on a wide screen and wrap underneath on a phone.
6
+ */
7
+ export function PageHeader({
8
+ title,
9
+ description,
10
+ actions,
11
+ }: {
12
+ title: string;
13
+ description?: string;
14
+ actions?: ReactNode;
15
+ }) {
16
+ return (
17
+ <div className="flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between">
18
+ <div className="space-y-1.5">
19
+ <h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1>
20
+ {description ? (
21
+ <p className="max-w-2xl text-sm text-muted-foreground text-pretty">{description}</p>
22
+ ) : null}
23
+ </div>
24
+ {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,46 @@
1
+ import { Card, CardContent } from "@/components/ui/card";
2
+
3
+ export type Stat = {
4
+ label: string;
5
+ value: string;
6
+ /** Change since the last period, e.g. 12 or -3.4. Omit for no delta. */
7
+ change?: number;
8
+ hint?: string;
9
+ };
10
+
11
+ /**
12
+ * The row of numbers at the top of a dashboard.
13
+ *
14
+ * A number on its own says nothing, so each one carries what it is measured
15
+ * against. Rising is not always good, so the colour follows the sign and the
16
+ * caller words the label.
17
+ */
18
+ export function StatCards({ stats }: { stats: Stat[] }) {
19
+ return (
20
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
21
+ {stats.map((stat) => (
22
+ <Card key={stat.label}>
23
+ <CardContent className="space-y-2 p-5">
24
+ <p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
25
+ <div className="flex items-baseline gap-2">
26
+ <span className="text-2xl font-semibold tabular-nums tracking-tight">{stat.value}</span>
27
+ {stat.change !== undefined ? (
28
+ <span
29
+ className={
30
+ stat.change >= 0
31
+ ? "text-xs font-medium text-emerald-600 dark:text-emerald-400"
32
+ : "text-xs font-medium text-rose-600 dark:text-rose-400"
33
+ }
34
+ >
35
+ {stat.change >= 0 ? "+" : ""}
36
+ {stat.change}%
37
+ </span>
38
+ ) : null}
39
+ </div>
40
+ {stat.hint ? <p className="text-xs text-muted-foreground">{stat.hint}</p> : null}
41
+ </CardContent>
42
+ </Card>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -6,16 +6,16 @@ Do not re-run create-next-app or `shadcn init` — everything below is in place.
6
6
  ## Stack
7
7
 
8
8
  - Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4
9
- - shadcn/ui ("base-nova" style, built on `@base-ui/react`), lucide-react icons
10
- - next-themes (light / dark / system), sonner toasts, date-fns
9
+ - shadcn/ui on Radix (the standard shadcn you know — `asChild` works), lucide-react icons
10
+ - next-themes (light / dark / system), sonner toasts, date-fns, react-day-picker
11
11
 
12
- Next 16 changed APIs. `AGENTS.md` explains, and `node_modules/next/dist/docs/`
12
+ Next 16 changed some APIs. `AGENTS.md` explains, and `node_modules/next/dist/docs/`
13
13
  has the guides — check them before using an API you are unsure of.
14
14
 
15
15
  ## Already wired
16
16
 
17
17
  - `src/app/layout.tsx` — font (Geist, registered as `--font-sans`), `ThemeProvider`,
18
- `TooltipProvider`, and `<Toaster />`. Call `toast()` from `sonner` anywhere.
18
+ `TooltipProvider`, and `<Toaster />`.
19
19
  - `src/components/theme-toggle.tsx` — a light/dark button, ready to place.
20
20
  - `src/app/globals.css` — the design tokens. The palette is a starting point:
21
21
  re-tint `--primary`, `--accent` and the neutrals for this app's direction.
@@ -23,17 +23,61 @@ has the guides — check them before using an API you are unsure of.
23
23
  (`bg-success`, `text-warning`, ...).
24
24
  - `src/app/page.tsx` — a placeholder. Replace it.
25
25
 
26
+ `layout.tsx`, `theme-provider.tsx`, `theme-toggle.tsx`, `src/lib/utils.ts` and
27
+ everything in `src/components/ui/` are finished and build. Use them; do not
28
+ rewrite them — a rewrite from memory brings back APIs that no longer exist.
29
+
26
30
  ## Components in `src/components/ui/`
27
31
 
28
- alert-dialog, avatar, badge, button, calendar, card, checkbox, command, dialog,
29
- dropdown-menu, input, input-group, label, popover, progress, scroll-area, select,
30
- separator, sheet, skeleton, sonner, switch, tabs, textarea, tooltip
32
+ accordion, alert-dialog, avatar, badge, button, calendar, card, checkbox, collapsible, command, dialog, dropdown-menu, hover-card, input-group, input, label, popover, progress, radio-group, scroll-area, select, separator, sheet, skeleton, slider, sonner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
33
+
34
+ Anything else: `npx shadcn@latest add <name> -y` with cwd set to this folder.
35
+
36
+ ## APIs that are easy to get wrong
37
+
38
+ ```tsx
39
+ // Toasts — sonner. A message first, options second. There is no toast({ title }).
40
+ import { toast } from "sonner";
41
+ toast.success("Saved");
42
+ toast("Task deleted", { description: "Buy milk", action: { label: "Undo", onClick: () => restore() } });
43
+
44
+ // Slider — value is an ARRAY, even for one thumb.
45
+ <Slider value={[tip]} onValueChange={([v]) => setTip(v)} min={0} max={30} step={1} />
46
+
47
+ // Select — value is a string.
48
+ <Select value={list} onValueChange={(v: string) => setList(v)}>
49
+ <SelectTrigger><SelectValue placeholder="Pick a list" /></SelectTrigger>
50
+ <SelectContent><SelectItem value="inbox">Inbox</SelectItem></SelectContent>
51
+ </Select>
52
+
53
+ // Date picker — Calendar inside a Popover.
54
+ <Popover>
55
+ <PopoverTrigger asChild><Button variant="outline">{date ? format(date, "PPP") : "Pick a date"}</Button></PopoverTrigger>
56
+ <PopoverContent className="w-auto p-0"><Calendar mode="single" selected={date} onSelect={setDate} /></PopoverContent>
57
+ </Popover>
58
+
59
+ // Toggle group — value is a string for type="single".
60
+ <ToggleGroup type="single" value={preset} onValueChange={(v) => v && setPreset(v)}>
61
+ <ToggleGroupItem value="15">15%</ToggleGroupItem>
62
+ </ToggleGroup>
63
+
64
+ // Anything using useState, events, localStorage or browser APIs needs "use client"
65
+ // as the first line of its file.
66
+
67
+ // localStorage — read it in useEffect, never in render or a useState initializer:
68
+ // the page is also rendered on the server, where localStorage does not exist.
69
+ const [bill, setBill] = useState("");
70
+ useEffect(() => { setBill(localStorage.getItem("bill") ?? ""); }, []);
71
+ useEffect(() => { localStorage.setItem("bill", bill); }, [bill]);
31
72
 
32
- Anything else: `npx shadcn@latest add <name> -y`.
73
+ // next-themes there is no "next-themes/dist/types". Import from "next-themes".
74
+ ```
33
75
 
34
76
  ## Conventions
35
77
 
78
+ - Components are named exports — `export function BillInput()` — imported with
79
+ braces: `import { BillInput } from "@/components/calculator/bill-input"`.
80
+ Only `page.tsx` and `layout.tsx` use `export default`.
36
81
  - One component per file, grouped by feature: `src/components/<feature>/`.
37
82
  - Shared types in `src/lib/types.ts`, helpers in `src/lib/`.
38
- - `"use client"` only on components that need state or events.
39
83
  - `cn()` from `@/lib/utils` to merge class names.