@tiniestcloud/create 0.1.8 → 0.1.9
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/package.json +1 -1
- package/skills/tiny-design/SKILL.md +2 -0
- package/skills/tiny-design/app.md +9 -0
- package/templates/app/AGENTS.md +19 -7
- package/templates/app/package.json +1 -0
- package/templates/app/src/App.tsx +24 -29
- package/templates/app/src/components/app-header.tsx +121 -0
- package/templates/app/src/components/filter-chips.tsx +45 -0
- package/templates/app/src/components/search-field.tsx +45 -0
- package/templates/app/src/components/ui/card.tsx +102 -0
- package/templates/app/src/components/ui/input.tsx +18 -0
- package/templates/app/src/index.css +11 -39
- package/templates/app/src/lib/theme.ts +2 -5
- package/templates/app/src/pages/home.tsx +6 -5
package/package.json
CHANGED
|
@@ -327,6 +327,8 @@ apps never come out the same. Every font here is on npm as `@fontsource/<name>`
|
|
|
327
327
|
background; a long list's search (and filters, where there's room) stays pinned under it.
|
|
328
328
|
- The header's logo and last action line up with the page's edges, and nothing in it squeezes
|
|
329
329
|
at a tablet's width (768px).
|
|
330
|
+
- Screens are built from components (the starter's and the app's own), with no bare
|
|
331
|
+
`<button>` or `<input>` and no hand-written stylesheet for them.
|
|
330
332
|
- No control the browser draws: dates use the calendar, dropdowns the select, hints the tooltip,
|
|
331
333
|
questions a dialog — all styled to the theme.
|
|
332
334
|
- None of the giveaways (the design guide's "What gives generated UI away").
|
|
@@ -38,6 +38,15 @@ phone-width column for the screens that are one task; anywhere else it reads as
|
|
|
38
38
|
and the header shrinks to one row. Keep the app's name or logo, the theme switch and the
|
|
39
39
|
account at the edges. The header stays pinned at the top while the page scrolls
|
|
40
40
|
(`sticky top-0`, solid background).
|
|
41
|
+
- **Built from components.** A screen is composed, not written out: the starter's Button,
|
|
42
|
+
Input and Card (src/components/ui), its AppHeader, SearchField and
|
|
43
|
+
FilterChips (src/components), and a component of the app's own for anything drawn more than
|
|
44
|
+
once (a product card, a list row, a stat tile). Styled with Tailwind classes and the theme's
|
|
45
|
+
colours on those components, never a stylesheet of `.card` and `.hero` rules. Anything
|
|
46
|
+
missing (a badge, tabs, a dialog, a select, a table, a sheet, a menu) comes from shadcn/ui
|
|
47
|
+
(`npx shadcn@latest add <name>`), then takes the app's style in its own file: shadcn first,
|
|
48
|
+
the app's style on top, nothing written by hand that shadcn has. The person will never ask
|
|
49
|
+
for this, and every later change depends on it.
|
|
41
50
|
- **Lists are the heart.** A row per item: the main text strong, a secondary line muted,
|
|
42
51
|
status as a small consistent badge, meta (date, amount) right-aligned in tabular figures.
|
|
43
52
|
Search, filters and sort sit above the list and run on the server (`where`, `order`, `limit`);
|
package/templates/app/AGENTS.md
CHANGED
|
@@ -19,6 +19,8 @@ React, TypeScript, Vite, Tailwind CSS and shadcn/ui, deployed to Tiniest Cloud a
|
|
|
19
19
|
platform answers every path with index.html). Keep Vite's default `base`.
|
|
20
20
|
Tiniest Cloud refuses a first deploy whose screens are anywhere else (all in App.tsx, say).
|
|
21
21
|
- `src/components/` — pieces the screens share; `src/components/ui/` for shadcn components.
|
|
22
|
+
Anything drawn more than once is a component here with typed props (a product card, a row,
|
|
23
|
+
a stat tile, a page heading), never markup copied between screens.
|
|
22
24
|
- `src/hooks/` — the app's own `useSomething` hooks. The SDK's hooks stay in src/lib/tiny.tsx.
|
|
23
25
|
- `src/lib/` — plain code: helpers, formatting, types (`types.ts`), calculations.
|
|
24
26
|
- `public/` — files served as they are (images, icons), plus `_functions/`, `tiny.rules.json`
|
|
@@ -26,19 +28,29 @@ React, TypeScript, Vite, Tailwind CSS and shadcn/ui, deployed to Tiniest Cloud a
|
|
|
26
28
|
- `public/icon.svg` — the app's mark (the logo in its header), square, on its own background
|
|
27
29
|
colour. Tiniest Cloud makes it the app's icon: browser tab, home screen, sign-in page, dashboard.
|
|
28
30
|
- A file past about 300 lines is doing too much: move its parts into components and hooks.
|
|
29
|
-
- Components:
|
|
30
|
-
|
|
31
|
+
- Components: build every screen from them. Here already: `Button`, `Input`, `Card`, `Tooltip`
|
|
32
|
+
(src/components/ui); `AppHeader`, `SearchField`, `FilterChips`, `ThemeToggle` (src/components).
|
|
33
|
+
No bare `<button>`, `<input>` or `<select>` in a screen.
|
|
34
|
+
- Anything else (a badge, tabs, a dialog, a select, a table, a sheet, a menu, a toast) comes from
|
|
35
|
+
shadcn/ui, never written by hand: `npx shadcn@latest add <name>`, or without a shell the file
|
|
36
|
+
as shadcn publishes it, in src/components/ui. Then style it for this app in its own file.
|
|
37
|
+
shadcn first, the app's style on top.
|
|
38
|
+
- Styling: Tailwind classes on components, colours from the theme tokens (`bg-background`,
|
|
39
|
+
`text-muted-foreground`). src/index.css holds the palette and fonts, not `.card` or `.hero`
|
|
40
|
+
rules for screens.
|
|
41
|
+
- Header: keep `AppHeader` and its `PAGE` container, which the content shares, so the logo and
|
|
42
|
+
the last action line up with the page. Give it the app's name and logo; restyle, don't rewrite.
|
|
43
|
+
- Theme: switch only with `setTheme` (src/lib/theme.ts), which crossfades the page. Never
|
|
44
|
+
toggle the `dark` class yourself.
|
|
31
45
|
- Design: before the first screen, read the tiny-design skill — `.agents/skills/tiny-design/`
|
|
32
46
|
(the same files are in `.claude/skills/` for Claude Code): SKILL.md, then app.md. Where that
|
|
33
47
|
folder is missing, `tiny_guide` with topic `app` returns the same. Write what you decide in
|
|
34
48
|
DESIGN.md.
|
|
35
49
|
- Look: give the app its own palette in src/index.css (the `:root` and `.dark` blocks) that suits
|
|
36
50
|
what it is and who uses it — never ship the default grey. Lay each screen out for what it
|
|
37
|
-
holds (a sidebar, a grid of cards, a table, a list beside its detail); the
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
device" are chosen with `ThemeToggle` (src/components/theme-toggle.tsx); keep both themes
|
|
41
|
-
looking right.
|
|
51
|
+
holds (a sidebar, a grid of cards, a table, a list beside its detail); the first screen in
|
|
52
|
+
src/pages/home.tsx is a placeholder, and a narrow centred column suits only one focused task.
|
|
53
|
+
Light, dark and "match device" are chosen with `ThemeToggle`; keep both themes looking right.
|
|
42
54
|
- Their own components or brand: if the person points at a folder, a style guide or another app,
|
|
43
55
|
that is the design — reuse their components and bring their colours, fonts and logo into this
|
|
44
56
|
project (copied in, never linked to a path on their computer).
|
|
@@ -1,39 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Home as HomeIcon } from "lucide-react"
|
|
2
|
+
import { BrowserRouter, Route, Routes } from "react-router"
|
|
3
|
+
|
|
4
|
+
import { AppHeader, PAGE, type Screen } from "@/components/app-header"
|
|
3
5
|
import { useMe } from "@/lib/tiny"
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
4
7
|
import Home from "@/pages/home"
|
|
5
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The app's screens, one line each: a file in src/pages, its address, and its name and icon in
|
|
11
|
+
* the navigation. The header and the routes below are both drawn from this list. A detail page
|
|
12
|
+
* (`/orders/:id`) is a screen too, with `hidden: true` to keep it out of the navigation.
|
|
13
|
+
*/
|
|
14
|
+
const screens: Screen[] = [{ path: "/", label: "Home", icon: HomeIcon, element: <Home /> }]
|
|
15
|
+
|
|
6
16
|
export default function App() {
|
|
7
17
|
const me = useMe()
|
|
8
18
|
if (me === undefined) return null
|
|
9
19
|
|
|
10
20
|
return (
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</div>
|
|
24
|
-
) : (
|
|
25
|
-
<Button size="sm" onClick={() => tiny.login()}>
|
|
26
|
-
Sign in
|
|
27
|
-
</Button>
|
|
28
|
-
)}
|
|
29
|
-
</div>
|
|
30
|
-
</div>
|
|
31
|
-
</header>
|
|
32
|
-
{/* A placeholder frame around the screens in src/pages. With more than one screen, add
|
|
33
|
-
React Router here: a route per page, the header shared by all of them. */}
|
|
34
|
-
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">
|
|
35
|
-
<Home />
|
|
36
|
-
</main>
|
|
37
|
-
</div>
|
|
21
|
+
<BrowserRouter>
|
|
22
|
+
<div className="flex min-h-svh flex-col">
|
|
23
|
+
<AppHeader name="App" screens={screens} me={me} />
|
|
24
|
+
<main className={cn(PAGE, "flex-1 pt-6 pb-[calc(var(--nav-h)+1.5rem)]")}>
|
|
25
|
+
<Routes>
|
|
26
|
+
{screens.map((screen) => (
|
|
27
|
+
<Route key={screen.path} path={screen.path} element={screen.element} />
|
|
28
|
+
))}
|
|
29
|
+
</Routes>
|
|
30
|
+
</main>
|
|
31
|
+
</div>
|
|
32
|
+
</BrowserRouter>
|
|
38
33
|
)
|
|
39
34
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react"
|
|
2
|
+
import { NavLink } from "react-router"
|
|
3
|
+
|
|
4
|
+
import { ThemeToggle } from "@/components/theme-toggle"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
/** A screen: a file in src/pages, listed in App.tsx. `hidden` keeps a detail page out of the navigation. */
|
|
10
|
+
export type Screen = {
|
|
11
|
+
path: string
|
|
12
|
+
label: string
|
|
13
|
+
icon: ComponentType<{ className?: string }>
|
|
14
|
+
element: ReactNode
|
|
15
|
+
hidden?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The page's width and side padding, shared by the header's row and the content so their edges line up. */
|
|
19
|
+
export const PAGE = "mx-auto w-full max-w-7xl px-4 sm:px-6"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The header and navigation: one line on a laptop, the screens as icons on a tablet, and a bar
|
|
23
|
+
* at the bottom on a phone. Restyle it to fit the app; keep the structure.
|
|
24
|
+
*/
|
|
25
|
+
export function AppHeader({
|
|
26
|
+
name,
|
|
27
|
+
logo,
|
|
28
|
+
screens,
|
|
29
|
+
me,
|
|
30
|
+
}: {
|
|
31
|
+
name: string
|
|
32
|
+
/** `<img src="/icon.svg" alt="" className="size-7" />` */
|
|
33
|
+
logo?: ReactNode
|
|
34
|
+
screens: Screen[]
|
|
35
|
+
me: Tiny.User | null
|
|
36
|
+
}) {
|
|
37
|
+
const nav = screens.filter((screen) => !screen.hidden)
|
|
38
|
+
const many = nav.length > 1
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<header className="sticky top-0 z-40 border-b bg-background">
|
|
43
|
+
<div className={cn(PAGE, "flex h-14 items-center gap-4")}>
|
|
44
|
+
<NavLink to="/" className="flex shrink-0 items-center gap-2 text-base font-medium">
|
|
45
|
+
{logo}
|
|
46
|
+
<span>{name}</span>
|
|
47
|
+
</NavLink>
|
|
48
|
+
|
|
49
|
+
{many && (
|
|
50
|
+
<TooltipProvider delayDuration={300}>
|
|
51
|
+
<nav aria-label="Screens" className="hidden min-w-0 items-center gap-1 sm:flex">
|
|
52
|
+
{nav.map(({ path, label, icon: Icon }) => (
|
|
53
|
+
<Tooltip key={path}>
|
|
54
|
+
<TooltipTrigger asChild>
|
|
55
|
+
<NavLink
|
|
56
|
+
to={path}
|
|
57
|
+
end={path === "/"}
|
|
58
|
+
aria-label={label}
|
|
59
|
+
// aria-current, not a className function: that would not survive the tooltip's trigger.
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex h-9 shrink-0 items-center gap-2 rounded-full px-3 text-sm text-muted-foreground transition-colors duration-200",
|
|
62
|
+
"hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
63
|
+
"aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<Icon className="size-4 shrink-0" />
|
|
67
|
+
<span className="hidden lg:inline">{label}</span>
|
|
68
|
+
</NavLink>
|
|
69
|
+
</TooltipTrigger>
|
|
70
|
+
<TooltipContent side="bottom" sideOffset={6} className="lg:hidden">
|
|
71
|
+
{label}
|
|
72
|
+
</TooltipContent>
|
|
73
|
+
</Tooltip>
|
|
74
|
+
))}
|
|
75
|
+
</nav>
|
|
76
|
+
</TooltipProvider>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<div className="ml-auto flex shrink-0 items-center gap-3">
|
|
80
|
+
<ThemeToggle className="shrink-0" />
|
|
81
|
+
{me ? (
|
|
82
|
+
<>
|
|
83
|
+
<span className="hidden max-w-48 truncate text-sm text-muted-foreground lg:inline">{me.email}</span>
|
|
84
|
+
<Button variant="outline" size="sm" className="shrink-0" onClick={() => tiny.logout()}>
|
|
85
|
+
Sign out
|
|
86
|
+
</Button>
|
|
87
|
+
</>
|
|
88
|
+
) : (
|
|
89
|
+
<Button size="sm" className="shrink-0" onClick={() => tiny.login()}>
|
|
90
|
+
Sign in
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</header>
|
|
96
|
+
|
|
97
|
+
{/* `data-bottom-nav` sets --nav-h (index.css): the page pads its bottom by it. */}
|
|
98
|
+
{many && (
|
|
99
|
+
<nav
|
|
100
|
+
aria-label="Screens"
|
|
101
|
+
data-bottom-nav
|
|
102
|
+
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)] sm:hidden"
|
|
103
|
+
>
|
|
104
|
+
{nav.slice(0, 5).map(({ path, label, icon: Icon }) => (
|
|
105
|
+
<NavLink
|
|
106
|
+
key={path}
|
|
107
|
+
to={path}
|
|
108
|
+
end={path === "/"}
|
|
109
|
+
className="group flex h-16 min-w-0 flex-1 flex-col items-center justify-center gap-1 text-xs text-muted-foreground aria-[current=page]:font-medium aria-[current=page]:text-foreground"
|
|
110
|
+
>
|
|
111
|
+
<span className="flex h-7 w-14 items-center justify-center rounded-full transition-colors duration-200 group-aria-[current=page]:bg-muted">
|
|
112
|
+
<Icon className="size-5" />
|
|
113
|
+
</span>
|
|
114
|
+
<span className="max-w-full truncate px-1">{label}</span>
|
|
115
|
+
</NavLink>
|
|
116
|
+
))}
|
|
117
|
+
</nav>
|
|
118
|
+
)}
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Button } from "@/components/ui/button"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One choice from a short row of chips: a list's filter ("All · Open · Done"). Each chip is as
|
|
6
|
+
* wide as its words, and the row scrolls sideways when it no longer fits. Controlled: the screen
|
|
7
|
+
* keeps the choice (in the URL, when a link should open the same view).
|
|
8
|
+
*/
|
|
9
|
+
export function FilterChips<T extends string>({
|
|
10
|
+
options,
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
label,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
options: ReadonlyArray<T | { value: T; label: string }>
|
|
17
|
+
value: T
|
|
18
|
+
onChange: (value: T) => void
|
|
19
|
+
/** What the row filters, for a screen reader: "Status". */
|
|
20
|
+
label: string
|
|
21
|
+
className?: string
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<div role="radiogroup" aria-label={label} className={cn("flex max-w-full gap-2 overflow-x-auto [scrollbar-width:none]", className)}>
|
|
25
|
+
{options.map((option) => {
|
|
26
|
+
const item = typeof option === "string" ? { value: option, label: option } : option
|
|
27
|
+
const selected = item.value === value
|
|
28
|
+
return (
|
|
29
|
+
<Button
|
|
30
|
+
key={item.value}
|
|
31
|
+
type="button"
|
|
32
|
+
role="radio"
|
|
33
|
+
aria-checked={selected}
|
|
34
|
+
variant={selected ? "secondary" : "outline"}
|
|
35
|
+
size="sm"
|
|
36
|
+
className="shrink-0 rounded-full px-3.5"
|
|
37
|
+
onClick={() => onChange(item.value)}
|
|
38
|
+
>
|
|
39
|
+
{item.label}
|
|
40
|
+
</Button>
|
|
41
|
+
)
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Search, X } from "lucide-react"
|
|
2
|
+
|
|
3
|
+
import { Input } from "@/components/ui/input"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
/** A search box: the field every list filters by. Controlled: the screen keeps the words. */
|
|
7
|
+
export function SearchField({
|
|
8
|
+
value,
|
|
9
|
+
onChange,
|
|
10
|
+
placeholder = "Search",
|
|
11
|
+
label = placeholder,
|
|
12
|
+
className,
|
|
13
|
+
}: {
|
|
14
|
+
value: string
|
|
15
|
+
onChange: (value: string) => void
|
|
16
|
+
placeholder?: string
|
|
17
|
+
/** What a screen reader calls it. */
|
|
18
|
+
label?: string
|
|
19
|
+
className?: string
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<div className={cn("relative w-full sm:w-64", className)}>
|
|
23
|
+
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
24
|
+
<Input
|
|
25
|
+
type="search"
|
|
26
|
+
inputMode="search"
|
|
27
|
+
aria-label={label}
|
|
28
|
+
placeholder={placeholder}
|
|
29
|
+
value={value}
|
|
30
|
+
onChange={(event) => onChange(event.target.value)}
|
|
31
|
+
className="h-9 rounded-full pr-9 pl-9 [&::-webkit-search-cancel-button]:hidden"
|
|
32
|
+
/>
|
|
33
|
+
{value && (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
aria-label="Clear search"
|
|
37
|
+
onClick={() => onChange("")}
|
|
38
|
+
className="absolute top-1/2 right-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
39
|
+
>
|
|
40
|
+
<X className="size-3.5" />
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "cn"
|
|
3
|
+
|
|
4
|
+
function Card({
|
|
5
|
+
className,
|
|
6
|
+
size = "default",
|
|
7
|
+
...props
|
|
8
|
+
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
data-slot="card"
|
|
12
|
+
data-size={size}
|
|
13
|
+
className={cn(
|
|
14
|
+
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
|
15
|
+
className
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
data-slot="card-header"
|
|
26
|
+
className={cn(
|
|
27
|
+
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
data-slot="card-title"
|
|
39
|
+
className={cn(
|
|
40
|
+
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
data-slot="card-description"
|
|
52
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
data-slot="card-action"
|
|
62
|
+
className={cn(
|
|
63
|
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-slot="card-content"
|
|
75
|
+
className={cn("px-(--card-spacing)", className)}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
data-slot="card-footer"
|
|
85
|
+
className={cn(
|
|
86
|
+
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
|
87
|
+
className
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
Card,
|
|
96
|
+
CardHeader,
|
|
97
|
+
CardFooter,
|
|
98
|
+
CardTitle,
|
|
99
|
+
CardAction,
|
|
100
|
+
CardDescription,
|
|
101
|
+
CardContent,
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "cn"
|
|
3
|
+
|
|
4
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
5
|
+
return (
|
|
6
|
+
<input
|
|
7
|
+
type={type}
|
|
8
|
+
data-slot="input"
|
|
9
|
+
className={cn(
|
|
10
|
+
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Input }
|
|
@@ -8,19 +8,6 @@
|
|
|
8
8
|
@theme inline {
|
|
9
9
|
--font-heading: var(--font-sans);
|
|
10
10
|
--font-sans: 'Geist Variable', sans-serif;
|
|
11
|
-
--color-sidebar-ring: var(--sidebar-ring);
|
|
12
|
-
--color-sidebar-border: var(--sidebar-border);
|
|
13
|
-
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
14
|
-
--color-sidebar-accent: var(--sidebar-accent);
|
|
15
|
-
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
16
|
-
--color-sidebar-primary: var(--sidebar-primary);
|
|
17
|
-
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
18
|
-
--color-sidebar: var(--sidebar);
|
|
19
|
-
--color-chart-5: var(--chart-5);
|
|
20
|
-
--color-chart-4: var(--chart-4);
|
|
21
|
-
--color-chart-3: var(--chart-3);
|
|
22
|
-
--color-chart-2: var(--chart-2);
|
|
23
|
-
--color-chart-1: var(--chart-1);
|
|
24
11
|
--color-ring: var(--ring);
|
|
25
12
|
--color-input: var(--input);
|
|
26
13
|
--color-border: var(--border);
|
|
@@ -67,20 +54,7 @@
|
|
|
67
54
|
--border: oklch(0.922 0 0);
|
|
68
55
|
--input: oklch(0.922 0 0);
|
|
69
56
|
--ring: oklch(0.708 0 0);
|
|
70
|
-
--chart-1: oklch(0.87 0 0);
|
|
71
|
-
--chart-2: oklch(0.556 0 0);
|
|
72
|
-
--chart-3: oklch(0.439 0 0);
|
|
73
|
-
--chart-4: oklch(0.371 0 0);
|
|
74
|
-
--chart-5: oklch(0.269 0 0);
|
|
75
57
|
--radius: 0.625rem;
|
|
76
|
-
--sidebar: oklch(0.985 0 0);
|
|
77
|
-
--sidebar-foreground: oklch(0.145 0 0);
|
|
78
|
-
--sidebar-primary: oklch(0.205 0 0);
|
|
79
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
80
|
-
--sidebar-accent: oklch(0.97 0 0);
|
|
81
|
-
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
82
|
-
--sidebar-border: oklch(0.922 0 0);
|
|
83
|
-
--sidebar-ring: oklch(0.708 0 0);
|
|
84
58
|
}
|
|
85
59
|
|
|
86
60
|
.dark {
|
|
@@ -102,19 +76,6 @@
|
|
|
102
76
|
--border: oklch(1 0 0 / 10%);
|
|
103
77
|
--input: oklch(1 0 0 / 15%);
|
|
104
78
|
--ring: oklch(0.556 0 0);
|
|
105
|
-
--chart-1: oklch(0.87 0 0);
|
|
106
|
-
--chart-2: oklch(0.556 0 0);
|
|
107
|
-
--chart-3: oklch(0.439 0 0);
|
|
108
|
-
--chart-4: oklch(0.371 0 0);
|
|
109
|
-
--chart-5: oklch(0.269 0 0);
|
|
110
|
-
--sidebar: oklch(0.205 0 0);
|
|
111
|
-
--sidebar-foreground: oklch(0.985 0 0);
|
|
112
|
-
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
113
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
114
|
-
--sidebar-accent: oklch(0.269 0 0);
|
|
115
|
-
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
116
|
-
--sidebar-border: oklch(1 0 0 / 10%);
|
|
117
|
-
--sidebar-ring: oklch(0.556 0 0);
|
|
118
79
|
}
|
|
119
80
|
|
|
120
81
|
@layer base {
|
|
@@ -129,6 +90,17 @@
|
|
|
129
90
|
}
|
|
130
91
|
}
|
|
131
92
|
|
|
93
|
+
/* The height of the phone's bottom navigation (src/components/app-header.tsx), 0 when there is
|
|
94
|
+
none: the page pads its bottom by it, and anything else pinned to the bottom sits above it. */
|
|
95
|
+
:root {
|
|
96
|
+
--nav-h: 0px;
|
|
97
|
+
}
|
|
98
|
+
@media (width < 40rem) {
|
|
99
|
+
:root:has([data-bottom-nav]) {
|
|
100
|
+
--nav-h: calc(4rem + env(safe-area-inset-bottom));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
132
104
|
/* The theme switch crossfades the page (src/lib/theme.ts). Short, slow at both ends and quick
|
|
133
105
|
through the middle, so the halfway blend of two opposite themes never registers. */
|
|
134
106
|
::view-transition-old(root),
|
|
@@ -22,11 +22,8 @@ function apply(theme: Theme) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Switch the theme
|
|
26
|
-
*
|
|
27
|
-
* background travel in opposite directions, and eased one by one they meet in the same grey
|
|
28
|
-
* halfway. Where the browser has no view transitions, or the person asked for less motion, the
|
|
29
|
-
* theme just changes.
|
|
25
|
+
* Switch the theme with a crossfade of the whole page (timed in index.css). Where the browser
|
|
26
|
+
* has no view transitions, or the person asked for less motion, the theme just changes.
|
|
30
27
|
*/
|
|
31
28
|
export function setTheme(theme: Theme) {
|
|
32
29
|
document.cookie =
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The first screen. Every screen is a file in src/pages
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The first screen. Every screen is a file in src/pages, listed in App.tsx (its address, its name
|
|
3
|
+
* and icon in the navigation). Lay this one out for what it holds: a grid of cards for things
|
|
4
|
+
* people browse, a table for records, a list with its detail beside it; a narrow centred column
|
|
5
|
+
* only for one focused task. Build it from components: Button, Input and Card in
|
|
6
|
+
* src/components/ui (more from shadcn), SearchField and FilterChips in src/components, and a
|
|
7
|
+
* component of this app's own for anything drawn more than once. Its hooks go in src/hooks.
|
|
7
8
|
*/
|
|
8
9
|
export default function Home() {
|
|
9
10
|
return <div />
|