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.
- package/README.md +399 -327
- package/package.json +6 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/livelog.js +113 -0
- package/src/core/loop.js +2105 -1659
- package/src/core/provider.js +93 -10
- package/src/core/stuck.js +269 -0
- package/src/core/tests.js +86 -0
- package/src/tools/blocks.js +117 -0
- package/src/tools/browser.js +121 -59
- package/src/tools/cache.js +105 -0
- package/src/tools/deploy.js +283 -0
- package/src/tools/files.js +91 -8
- package/src/tools/index.js +634 -495
- package/src/tools/rename.js +157 -0
- package/src/tools/scaffold.js +85 -6
- package/src/tools/shell.js +799 -701
- package/src/tools/symbols.js +218 -0
- package/src/tools/types.js +179 -0
- package/src/ui/activity.js +203 -0
- package/src/ui/plain.js +22 -3
- package/src/ui/screen.js +65 -19
- package/src/ui/theme.js +5 -1
- package/templates/blocks/app-shell.tsx +81 -0
- package/templates/blocks/data-table.tsx +117 -0
- package/templates/blocks/empty-state.tsx +41 -0
- package/templates/blocks/page-header.tsx +27 -0
- package/templates/blocks/stat-cards.tsx +46 -0
- package/templates/next-shadcn/TEMPLATE.md +53 -9
- package/templates/next-shadcn/_package-lock.json +1335 -148
- package/templates/next-shadcn/components.json +1 -1
- package/templates/next-shadcn/next.config.ts +2 -1
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/presets/citrus.json +77 -0
- package/templates/next-shadcn/presets/graphite.json +77 -0
- package/templates/next-shadcn/presets/grove.json +77 -0
- package/templates/next-shadcn/presets/ocean.json +78 -0
- package/templates/next-shadcn/presets/sunset.json +77 -0
- package/templates/next-shadcn/presets/violet.json +77 -0
- package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
- package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
- package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
- package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
- package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
- package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
- package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
- package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
- package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
- package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
- package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
- package/templates/next-shadcn/src/lib/utils.ts +6 -1
- 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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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 />`.
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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.
|