@tiniestcloud/create 0.1.7 → 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 +12 -0
- package/skills/tiny-design/app.md +9 -0
- package/templates/app/AGENTS.md +21 -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 +20 -40
- package/templates/app/src/lib/theme.ts +8 -1
- package/templates/app/src/pages/home.tsx +6 -5
package/package.json
CHANGED
|
@@ -92,6 +92,14 @@ see-through over the content) and its bottom border. Keep it short — about 56
|
|
|
92
92
|
on a phone, where an app's screens move to the bottom bar — so it doesn't eat the screen. A
|
|
93
93
|
sidebar stays put the same way (`sticky top-0 h-svh`), and a detail pane beside a long list
|
|
94
94
|
scrolls on its own.
|
|
95
|
+
**Switching the theme crossfades.** Change light and dark only through the starter's
|
|
96
|
+
`setTheme` (src/lib/theme.ts), never by toggling the class yourself: it fades the whole page
|
|
97
|
+
from one theme to the other in 300ms and remembers the choice.
|
|
98
|
+
**The header lines up with the page.** Its row sits in the same container as the content below
|
|
99
|
+
(the same max width and side padding), so the logo starts on the page's left edge and the last
|
|
100
|
+
action ends on its right. Logo, navigation and actions share one line, centred vertically. Icon
|
|
101
|
+
buttons keep their size at every width (`shrink-0`, a fixed square): at a tablet's width
|
|
102
|
+
(640–1024px) the navigation drops its labels or folds into a menu before anything squeezes.
|
|
95
103
|
A website's header may shrink as you scroll, but stays reachable. A long list's search and
|
|
96
104
|
filters stick just below the header the same way, so people can search without scrolling back
|
|
97
105
|
up — on a phone, at least the search. Measure the header (a `ResizeObserver` writing
|
|
@@ -317,6 +325,10 @@ apps never come out the same. Every font here is on npm as `@fontsource/<name>`
|
|
|
317
325
|
every other bottom bar above it; a detail screen keeps its way back and its actions pinned.
|
|
318
326
|
- The header stays at the top while the page scrolls, on a laptop and a phone, with a solid
|
|
319
327
|
background; a long list's search (and filters, where there's room) stays pinned under it.
|
|
328
|
+
- The header's logo and last action line up with the page's edges, and nothing in it squeezes
|
|
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.
|
|
320
332
|
- No control the browser draws: dates use the calendar, dropdowns the select, hints the tooltip,
|
|
321
333
|
questions a dialog — all styled to the theme.
|
|
322
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,24 +19,38 @@ 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`
|
|
25
27
|
and `tiny.brand.json`.
|
|
28
|
+
- `public/icon.svg` — the app's mark (the logo in its header), square, on its own background
|
|
29
|
+
colour. Tiniest Cloud makes it the app's icon: browser tab, home screen, sign-in page, dashboard.
|
|
26
30
|
- A file past about 300 lines is doing too much: move its parts into components and hooks.
|
|
27
|
-
- Components:
|
|
28
|
-
|
|
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.
|
|
29
45
|
- Design: before the first screen, read the tiny-design skill — `.agents/skills/tiny-design/`
|
|
30
46
|
(the same files are in `.claude/skills/` for Claude Code): SKILL.md, then app.md. Where that
|
|
31
47
|
folder is missing, `tiny_guide` with topic `app` returns the same. Write what you decide in
|
|
32
48
|
DESIGN.md.
|
|
33
49
|
- Look: give the app its own palette in src/index.css (the `:root` and `.dark` blocks) that suits
|
|
34
50
|
what it is and who uses it — never ship the default grey. Lay each screen out for what it
|
|
35
|
-
holds (a sidebar, a grid of cards, a table, a list beside its detail); the
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
device" are chosen with `ThemeToggle` (src/components/theme-toggle.tsx); keep both themes
|
|
39
|
-
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.
|
|
40
54
|
- Their own components or brand: if the person points at a folder, a style guide or another app,
|
|
41
55
|
that is the design — reuse their components and bring their colours, fonts and logo into this
|
|
42
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 {
|
|
@@ -127,4 +88,23 @@
|
|
|
127
88
|
html {
|
|
128
89
|
@apply font-sans;
|
|
129
90
|
}
|
|
130
|
-
}
|
|
91
|
+
}
|
|
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
|
+
|
|
104
|
+
/* The theme switch crossfades the page (src/lib/theme.ts). Short, slow at both ends and quick
|
|
105
|
+
through the middle, so the halfway blend of two opposite themes never registers. */
|
|
106
|
+
::view-transition-old(root),
|
|
107
|
+
::view-transition-new(root) {
|
|
108
|
+
animation-duration: 300ms;
|
|
109
|
+
animation-timing-function: cubic-bezier(0.83, 0, 0.17, 1);
|
|
110
|
+
}
|
|
@@ -21,12 +21,19 @@ function apply(theme: Theme) {
|
|
|
21
21
|
document.documentElement.style.colorScheme = dark ? "dark" : "light"
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
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.
|
|
27
|
+
*/
|
|
24
28
|
export function setTheme(theme: Theme) {
|
|
25
29
|
document.cookie =
|
|
26
30
|
theme === "system"
|
|
27
31
|
? "theme=; path=/; max-age=0; samesite=lax"
|
|
28
32
|
: `theme=${theme}; path=/; max-age=31536000; samesite=lax`
|
|
29
|
-
|
|
33
|
+
const doc = document as Document & { startViewTransition?: (update: () => void) => unknown }
|
|
34
|
+
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
35
|
+
if (still || typeof doc.startViewTransition !== "function") apply(theme)
|
|
36
|
+
else doc.startViewTransition(() => apply(theme))
|
|
30
37
|
window.dispatchEvent(new Event("themechange"))
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -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 />
|