Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
Expand Down
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"css.customData": [".vscode/tailwind-css-v4-custom-data.json"],
"css.lint.unknownAtRules": "ignore",
"files.associations": {
"**/apps/web/**/*.css": "tailwindcss"
},
"tailwindCSS.experimental.configFile": "apps/web/src/index.css",
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"[javascript]": {
Expand Down
29 changes: 29 additions & 0 deletions .vscode/tailwind-css-v4-custom-data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@theme",
"description": "Tailwind CSS v4: map design tokens to the framework theme (utilities, variants)."
},
{
"name": "@plugin",
"description": "Tailwind CSS v4: load a first-party or third-party Tailwind plugin."
},
{
"name": "@source",
"description": "Tailwind CSS v4: register source paths for class detection."
},
{
"name": "@utility",
"description": "Tailwind CSS v4: define a custom utility."
},
{
"name": "@variant",
"description": "Tailwind CSS v4: define a custom variant."
},
{
"name": "@custom-variant",
"description": "Tailwind CSS v4: define a custom variant."
}
]
}
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O
- [x] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click.
- [x] **4.5** Component library: evaluate options for SolidJS (e.g. **shadcn-solid** with Tailwind vs smaller stacks); record the decision; add the chosen tooling and migrate or adopt components on at least one real screen so the pattern is established.
- [x] **4.6** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id).
- [ ] **4.7** **shadcn-solid adoption (full):** migrate remaining UI (app shell, home, recipe cards and detail sections, and any shared layout) to registry components and Tailwind utilities where it replaces bespoke CSS; align tokens with **`COMPONENT_LIBRARY.md`**; no orphaned hand-rolled controls that duplicate registry patterns. Update **`COMPONENT_LIBRARY.md`** when scope is complete.
- [x] **4.7** **shadcn-solid adoption (full):** migrate remaining UI (app shell, home, recipe cards and detail sections, and any shared layout) to registry components and Tailwind utilities where it replaces bespoke CSS; align tokens with **`COMPONENT_LIBRARY.md`**; no orphaned hand-rolled controls that duplicate registry patterns. Update **`COMPONENT_LIBRARY.md`** when scope is complete.
- [ ] **4.8** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate.

## 5. Contract testing (Pact)
Expand Down
28 changes: 24 additions & 4 deletions apps/web/COMPONENT_LIBRARY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Component library (PLAN §4.5)
# Component library (PLAN §4.5 / §4.7)

## Decision: [shadcn-solid](https://shadcn-solid.com/)

Expand Down Expand Up @@ -27,7 +27,27 @@ Implementation details (Kobalte, CVA, etc.) are whatever the **published registr
2. Add components: **`pnpm dlx shadcn-solid@latest add <name>`** (e.g. **`button`**, **`card`**). Use **`--overwrite`** when refreshing an existing file.
3. Prefer **`@/`** imports; use **`./`** for same-folder modules where Biome allows (see project rules).

## Adoption so far
If the registry is unreachable, add components by porting the [shadcn/ui](https://ui.shadcn.com/) (React) registry output to Solid primitives, matching existing files in **`src/components/ui/`** (same **`cn()`**, CVA, and bread theme tokens **`var(--*)`** from **`src/index.css`**).

- **`src/components/ui/button.tsx`** — **shadcn-solid-style** `Button` + **`buttonVariants`** (see [Button](https://shadcn-solid.com/docs/components/button)). For router links, the docs recommend **`buttonVariants` on `<A>`** — used on **recipe detail** for “← All recipes” (`RecipePage`).
- Existing bread **CSS variables** in **`src/index.css`** remain the source of theme tokens; utility classes reference them where the default shadcn HSL tokens are not wired yet.
## Adoption (§4.7 complete)

| Component | Role |
| --------- | ---- |
| **`button.tsx`** | **`Button`**, **`buttonVariants`** — links use **`buttonVariants` on `<A>`** (e.g. recipe detail “← All recipes”). |
| **`card.tsx`** | **`Card`**, **`CardHeader`**, **`CardTitle`**, **`CardDescription`**, **`CardContent`**, **`CardFooter`** — home recipe **cards**, recipe **hero** image, **ingredients** / **steps** panels. |
| **`alert.tsx`** | **`Alert`**, **`AlertDescription`** — API **error** states on home and recipe pages. |
| **`skeleton.tsx`** | **`Skeleton`** — **loading** placeholders (lists and detail). |
| **`separator.tsx`** | **`Separator`** — between **ingredients** and **steps** on the recipe detail view. |
| **`typography.tsx`** | **`SiteTitle`**, **`Heading2`**, **`Heading3Panel`**, **`RecipeCardTitle`**, **`TextLede`**, **`TextMuted`**, **`RecipeTimes`**, etc. — shared **type scales** and **meta** text so screens do not repeat long class strings. |
| **`cover-image.tsx`** | **`CoverImage`** — framed **thumbnail / hero** images (aspect ratio and **`object-cover`** on the frame). |
| **`loading-region.tsx`** | **`LoadingRegion`** — **`role="status"`**, **`aria-busy`**, and **skeleton** spacing for list/detail loading. |
| **`layout/shell.tsx`** | **`Shell`**, **`ShellHeader`**, **`ShellMain`**, **`ShellFooter`**, **`SiteBrand`**, **`ShellFooterNote`** — **app chrome** structure. |
| **`layout/PageBackLink.tsx`** | **`PageBackLink`** — back navigation row using **`buttonVariants`**. |
| **`recipe-detail/RecipeDetailPanel.tsx`** | **`RecipeDetailPanel`** — shared **card + panel heading** for **ingredients** and **steps**. |
| **`RecipeList.tsx`** | **`RecipeList`** — home **recipe grid** (`<ul>`). |

Layout (**`AppShell`**) composes **`layout/shell`** primitives; page copy uses **`typography`** and recipe-specific building blocks above. Legacy **`AppShell.css`** / **`Page.css`** are removed.

## Theme tokens

Physical palette and fonts live on **`:root`** in **`src/index.css`** (e.g. **`--accent`**, **`--border`**, **`--heading`**). A Tailwind v4 **`@theme { ... }`** block **maps** those to semantic utilities so components use names like **`text-foreground`**, **`text-heading`**, **`border-border`**, **`bg-card`**, **`text-primary`**, **`font-heading`**, **`shadow-bread`**, **`bg-header`**, **`bg-footer`**, **`bg-accent-subtle`**, **`border-accent-border`**, etc. Light/dark behaviour is unchanged: **`prefers-color-scheme`** still swaps the **`:root`** values; the **`@theme`** aliases follow automatically.
2 changes: 1 addition & 1 deletion apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Configuration: **`biome.json`** (Biome **2.4.x**), **`vite.config.ts`** (include

## App shell (PLAN §4.3)

Routing uses [**`@solidjs/router`**](https://github.com/solidjs/solid-router): **`Router`** with a shared **`AppShell`** layout (header, main outlet, footer). Routes include **`/`** (home) and **`/recipes/:id`** (detail). Global styles live in **`src/index.css`** (warm bread surfaces, cool complementary accents); layout CSS in **`src/layout/AppShell.css`**.
Routing uses [**`@solidjs/router`**](https://github.com/solidjs/solid-router): **`Router`** with a shared **`AppShell`** layout (header, main outlet, footer). Routes include **`/`** (home) and **`/recipes/:id`** (detail). Global styles live in **`src/index.css`** (warm bread surfaces, cool complementary accents); the app shell uses Tailwind utilities in **`src/layout/AppShell.tsx`**.

## Component library (PLAN §4.5)

Expand Down
56 changes: 37 additions & 19 deletions apps/web/src/components/RecipeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,54 @@
import { A } from '@solidjs/router';
import type { JSX } from 'solid-js';
import type { RecipeSummary } from '@/api';
import { Card, CardContent } from '@/components/ui/card';
import { CoverImage } from '@/components/ui/cover-image';
import { RecipeCardTitle, TextMuted } from '@/components/ui/typography';
import { cn } from '@/lib/utils';

const recipeCardLinkClass: string = cn(
'group block rounded-xl text-inherit no-underline outline-offset-2',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary',
);

const recipeCardSurfaceClass: string = cn(
'overflow-hidden p-0 transition-[border-color,box-shadow] duration-150',
'hover:border-accent-border',
'hover:shadow-[rgba(45,70,85,0.1)_0_12px_28px_-8px,rgba(40,55,65,0.06)_0_4px_10px_-4px]',
'dark:hover:shadow-[rgba(0,0,0,0.35)_0_12px_28px_-8px,rgba(0,0,0,0.2)_0_4px_10px_-4px]',
);

export const RecipeCard = ({
id,
imageUrl,
title,
summary,
}: RecipeSummary): JSX.Element => (
<li class="recipe-grid-item">
<li class="m-0 list-none">
<A
href={`/recipes/${id}`}
class="recipe-card"
class={recipeCardLinkClass}
aria-labelledby={`recipe-title-${id}`}
>
<div class="recipe-card-media">
<img
class="recipe-card-image"
src={imageUrl}
alt=""
width={320}
height={240}
loading="lazy"
decoding="async"
/>
</div>
<div class="recipe-card-body">
<h3 class="recipe-card-title" id={`recipe-title-${id}`}>
{title}
</h3>
<p class="recipe-card-summary">{summary}</p>
</div>
<Card class={recipeCardSurfaceClass}>
<CardContent class="grid grid-cols-[minmax(0,120px)_minmax(0,1fr)] gap-3.5 p-2.5 sm:grid-cols-[minmax(0,140px)_minmax(0,1fr)] sm:gap-4 sm:p-3">
<CoverImage
alt=""
src={imageUrl}
width={320}
height={240}
loading="lazy"
decoding="async"
frameClass="aspect-[4/3] min-h-0 rounded-md"
/>
<div class="flex min-w-0 flex-col justify-center gap-1.5">
<RecipeCardTitle id={`recipe-title-${id}`}>{title}</RecipeCardTitle>
<TextMuted class="line-clamp-3 text-[0.92rem] leading-snug">
{summary}
</TextMuted>
</div>
</CardContent>
</Card>
</A>
</li>
);
5 changes: 5 additions & 0 deletions apps/web/src/components/RecipeList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { JSX, ParentProps } from 'solid-js';

export const RecipeList = ({ children }: ParentProps): JSX.Element => (
<ul class="m-0 flex list-none flex-col gap-4 p-0">{children}</ul>
);
18 changes: 18 additions & 0 deletions apps/web/src/components/layout/PageBackLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { A } from '@solidjs/router';
import type { JSX, ParentProps } from 'solid-js';
import { buttonVariants } from '@/components/ui/button';

type PageBackLinkProps = ParentProps & {
href: string;
};

export const PageBackLink = ({
href,
children,
}: PageBackLinkProps): JSX.Element => (
<p class="mb-4">
<A class={buttonVariants({ variant: 'link', size: 'link' })} href={href}>
{children}
</A>
</p>
);
54 changes: 54 additions & 0 deletions apps/web/src/components/layout/shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { A } from '@solidjs/router';
import type { ComponentProps, JSX, ParentProps } from 'solid-js';
import { SiteTitle, TextMutedSmall } from '@/components/ui/typography';
import { cn } from '@/lib/utils';

export const Shell = ({ children }: ParentProps): JSX.Element => (
<div class="flex min-h-full flex-1 flex-col text-left">{children}</div>
);

export const ShellHeader = ({ children }: ParentProps): JSX.Element => (
<header class="shrink-0 border-b border-border bg-header px-5 py-4 sm:px-6">
<div class="mx-auto max-w-2xl">{children}</div>
</header>
);

export const ShellMain = ({ children }: ParentProps): JSX.Element => (
<main class="mx-auto box-border w-full max-w-2xl flex-1 px-5 py-5 pb-10 sm:px-6">
{children}
</main>
);

export const ShellFooter = ({ children }: ParentProps): JSX.Element => (
<footer class="shrink-0 border-t border-border bg-footer px-5 py-4 sm:px-6">
{children}
</footer>
);

export const ShellFooterNote = ({
class: className,
...rest
}: ComponentProps<'p'>): JSX.Element => (
<p
class={cn(
'mx-auto max-w-2xl text-center text-xs text-muted-foreground',
className,
)}
{...rest}
/>
);

export const SiteBrand = (): JSX.Element => (
<>
<SiteTitle>
<A
href="/"
class="rounded text-heading no-underline outline-offset-[3px] hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary"
end
>
Bread Recipes
</A>
</SiteTitle>
<TextMutedSmall>Simple loaves, clear steps</TextMutedSmall>
</>
);
2 changes: 2 additions & 0 deletions apps/web/src/components/recipe-detail/RecipeDetailBody.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { JSX } from 'solid-js';
import type { RecipeDetail } from '@/api';
import { Separator } from '@/components/ui/separator';
import { RecipeDetailHeader } from './RecipeDetailHeader';
import { RecipeDetailHero } from './RecipeDetailHero';
import { RecipeDetailIngredients } from './RecipeDetailIngredients';
Expand All @@ -23,6 +24,7 @@ export const RecipeDetailBody = ({
/>
<RecipeDetailHero imageUrlLarge={imageUrlLarge} />
<RecipeDetailIngredients ingredients={ingredients} />
<Separator class="my-6" decorative />
<RecipeDetailSteps steps={steps} />
</>
);
24 changes: 9 additions & 15 deletions apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { JSX } from 'solid-js';
import { Show } from 'solid-js';
import type { RecipeDetail } from '@/api';
import { Heading2, RecipeTimes, TextLede } from '@/components/ui/typography';

type RecipeDetailHeaderProps = Pick<
RecipeDetail,
Expand All @@ -13,20 +13,14 @@ export const RecipeDetailHeader = ({
prepTimeMinutes,
bakeTimeMinutes,
}: RecipeDetailHeaderProps): JSX.Element => (
<header class="recipe-detail-header">
<h2 id="recipe-title" class="page-title recipe-detail-title">
<header class="mb-4">
<Heading2 id="recipe-title" class="mb-2">
{title}
</h2>
<p class="lede recipe-detail-summary">{summary}</p>
<Show when={prepTimeMinutes !== undefined || bakeTimeMinutes !== undefined}>
<p class="recipe-detail-times">
<Show when={prepTimeMinutes !== undefined}>
<span class="recipe-detail-time">Prep: {prepTimeMinutes} min</span>
</Show>
<Show when={bakeTimeMinutes !== undefined}>
<span class="recipe-detail-time">Bake: {bakeTimeMinutes} min</span>
</Show>
</p>
</Show>
</Heading2>
<TextLede class="mb-3">{summary}</TextLede>
<RecipeTimes
prepTimeMinutes={prepTimeMinutes}
bakeTimeMinutes={bakeTimeMinutes}
/>
</header>
);
10 changes: 6 additions & 4 deletions apps/web/src/components/recipe-detail/RecipeDetailHero.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import type { JSX } from 'solid-js';
import type { RecipeDetail } from '@/api';
import { Card } from '@/components/ui/card';
import { CoverImage } from '@/components/ui/cover-image';

type RecipeDetailHeroProps = Pick<RecipeDetail, 'imageUrlLarge'>;

export const RecipeDetailHero = ({
imageUrlLarge,
}: RecipeDetailHeroProps): JSX.Element => (
<div class="recipe-detail-hero">
<img
class="recipe-detail-image"
<Card class="mb-6 overflow-hidden p-0">
<CoverImage
src={imageUrlLarge}
alt=""
width={720}
height={480}
loading="eager"
decoding="async"
frameClass="aspect-[3/2] max-h-[min(60vh,420px)]"
/>
</div>
</Card>
);
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import type { RecipeDetail } from '@/api';
import { RecipeDetailPanel } from './RecipeDetailPanel';

type RecipeDetailIngredientsProps = Pick<RecipeDetail, 'ingredients'>;

export const RecipeDetailIngredients = ({
ingredients,
}: RecipeDetailIngredientsProps): JSX.Element => (
<section
class="recipe-detail-section"
aria-labelledby="recipe-ingredients-heading"
<RecipeDetailPanel
headingId="recipe-ingredients-heading"
heading="Ingredients"
>
<h3 id="recipe-ingredients-heading" class="recipe-detail-h3">
Ingredients
</h3>
<ul class="recipe-detail-list recipe-detail-list-ingredients">
<ul class="m-0 list-disc pl-5 leading-relaxed text-foreground marker:text-foreground">
<For each={ingredients}>
{(line: string): JSX.Element => <li>{line}</li>}
{(line: string): JSX.Element => <li class="mb-1.5">{line}</li>}
</For>
</ul>
</section>
</RecipeDetailPanel>
);
Loading
Loading