From 41097f651acd7593288408f46e58310486f02b63 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 16 Oct 2026 13:54:18 +0200 Subject: [PATCH 1/3] feat: add reusable element variant card --- docs/AI_USAGE.md | 12 +++ src/components/ui/DifficultyBadge.tsx | 39 ++++++++++ src/components/ui/ElementVariants.tsx | 28 +++++++ src/components/ui/card.tsx | 106 ++++++++++++++++++++++++++ src/lib/difficulty.ts | 24 ++++++ 5 files changed, 209 insertions(+) create mode 100644 src/components/ui/DifficultyBadge.tsx create mode 100644 src/components/ui/ElementVariants.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/lib/difficulty.ts diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 49e8d28..960bcbd 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -162,6 +162,18 @@ does not, and checked the browser support and the cascade layers in the Tailwind documentation. Claude checked lint, build and tests. +## Element variant card (`src/components/ui/`, `src/lib/difficulty.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Specified a reusable element card and difficulty + badge, including the letter-to-value display, the existing navy palette, + accessible labeling, and a sample catalog image. +- **What GitHub Copilot did:** Added the card primitives, a difficulty badge, + the shared A–J value mapping, and a sample element-variant card. The title + uses an explicit native heading level and the description uses a paragraph. +- **Review:** The team refined the layout, colors, difficulty display, and + accessibility semantics during review. + ## Catalog search (`backend/src/catalog/search.ts`, `eslint.config.js`, `docs/API.md`) - **Tool:** Claude Code diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx new file mode 100644 index 0000000..cfaad2b --- /dev/null +++ b/src/components/ui/DifficultyBadge.tsx @@ -0,0 +1,39 @@ +import { cn } from "cn"; +import { getDifficultyValue } from "@/lib/difficulty"; + +export default function DifficultyBadge({ + difficulty, +}: { + difficulty: string | number; +}) { + const letter = + typeof difficulty === "string" ? difficulty.toUpperCase() : undefined; + const value = letter ? getDifficultyValue(letter) : undefined; + const displayDifficulty = value !== undefined ? letter : difficulty; + + return ( + + + {displayDifficulty} + + {value !== undefined && ( + + {value.toFixed(1)} + + )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx new file mode 100644 index 0000000..62a1197 --- /dev/null +++ b/src/components/ui/ElementVariants.tsx @@ -0,0 +1,28 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import DifficultyBadge from "@/components/ui/DifficultyBadge"; +import elementImage from "../../../data/images/BB/BB-1.101-a.webp"; + +const ElementVariant = () => { + return ( + + + + Code - Name (if any) - Element variant id (a,b,c) + + + +
+

Element description goes here.

+ +
+ Balance beam element diagram +
+
+ ); +}; + +export default ElementVariant; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..4b3f398 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,106 @@ +import * as React from "react" +import { cn } from "cn" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
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", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +type CardTitleProps = React.ComponentProps<"h2"> & { + as: "h2" | "h3" | "h4"; +}; + +function CardTitle({ as: Heading, className, ...props }: CardTitleProps) { + return ( + + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} \ No newline at end of file diff --git a/src/lib/difficulty.ts b/src/lib/difficulty.ts new file mode 100644 index 0000000..4ae74bb --- /dev/null +++ b/src/lib/difficulty.ts @@ -0,0 +1,24 @@ +export const DIFFICULTY_VALUE_BY_LETTER = { + A: 0.1, + B: 0.2, + C: 0.3, + D: 0.4, + E: 0.5, + F: 0.6, + G: 0.7, + H: 0.8, + I: 0.9, + J: 1.0, +} as const; + +export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER; + +export function getDifficultyValue(letter: string): number | undefined { + const normalizedLetter = letter.toUpperCase(); + + if (!Object.hasOwn(DIFFICULTY_VALUE_BY_LETTER, normalizedLetter)) { + return undefined; + } + + return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter]; +} \ No newline at end of file From c08f2915eb9f21216dc730b9fb8284865cf80b50 Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 09:05:00 +0200 Subject: [PATCH 2/3] fix: drop role=img from DifficultyBadge and format with Prettier A role on the container hid the visible letter and value from screen readers. The badge now uses a title for the tooltip and a visually hidden "Difficulty" label, so the visible text is read as it is. --- src/components/ui/DifficultyBadge.tsx | 24 ++++++------- src/components/ui/ElementVariants.tsx | 46 ++++++++++++------------ src/components/ui/card.tsx | 52 +++++++++++++-------------- src/lib/difficulty.ts | 12 +++---- 4 files changed, 67 insertions(+), 67 deletions(-) diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx index cfaad2b..f7c6d57 100644 --- a/src/components/ui/DifficultyBadge.tsx +++ b/src/components/ui/DifficultyBadge.tsx @@ -1,30 +1,30 @@ -import { cn } from "cn"; -import { getDifficultyValue } from "@/lib/difficulty"; +import { cn } from 'cn' +import { getDifficultyValue } from '@/lib/difficulty' export default function DifficultyBadge({ difficulty, }: { - difficulty: string | number; + difficulty: string | number }) { const letter = - typeof difficulty === "string" ? difficulty.toUpperCase() : undefined; - const value = letter ? getDifficultyValue(letter) : undefined; - const displayDifficulty = value !== undefined ? letter : difficulty; + typeof difficulty === 'string' ? difficulty.toUpperCase() : undefined + const value = letter ? getDifficultyValue(letter) : undefined + const displayDifficulty = value !== undefined ? letter : difficulty return ( + Difficulty {displayDifficulty} @@ -35,5 +35,5 @@ export default function DifficultyBadge({ )} - ); -} \ No newline at end of file + ) +} diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx index 62a1197..50453e8 100644 --- a/src/components/ui/ElementVariants.tsx +++ b/src/components/ui/ElementVariants.tsx @@ -1,28 +1,28 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import DifficultyBadge from "@/components/ui/DifficultyBadge"; -import elementImage from "../../../data/images/BB/BB-1.101-a.webp"; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import elementImage from '../../../data/images/BB/BB-1.101-a.webp' const ElementVariant = () => { return ( - - - Code - Name (if any) - Element variant id (a,b,c) - - - -
-

Element description goes here.

- -
- Balance beam element diagram -
-
- ); -}; + + + Code - Name (if any) - Element variant id (a,b,c) + + + +
+

Element description goes here.

+ +
+ Balance beam element diagram +
+ + ) +} -export default ElementVariant; +export default ElementVariant diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 4b3f398..2674a95 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,94 +1,94 @@ -import * as React from "react" -import { cn } from "cn" +import * as React from 'react' +import { cn } from 'cn' function Card({ className, - size = "default", + size = 'default', ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { return (
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", - className + className={cn( + 'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-accent-color py-(--card-spacing) text-sm text-card-foreground ring-1 ring-box-stroke [--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', + className, )} {...props} /> ) } -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
) } -type CardTitleProps = React.ComponentProps<"h2"> & { - as: "h2" | "h3" | "h4"; -}; +type CardTitleProps = React.ComponentProps<'h2'> & { + as: 'h2' | 'h3' | 'h4' +} function CardTitle({ as: Heading, className, ...props }: CardTitleProps) { return ( ) } -function CardDescription({ className, ...props }: React.ComponentProps<"p">) { +function CardDescription({ className, ...props }: React.ComponentProps<'p'>) { return (

) } -function CardAction({ className, ...props }: React.ComponentProps<"div">) { +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { return (

) } -function CardContent({ className, ...props }: React.ComponentProps<"div">) { +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { return (
) } -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
@@ -103,4 +103,4 @@ export { CardAction, CardDescription, CardContent, -} \ No newline at end of file +} diff --git a/src/lib/difficulty.ts b/src/lib/difficulty.ts index 4ae74bb..153fd0d 100644 --- a/src/lib/difficulty.ts +++ b/src/lib/difficulty.ts @@ -9,16 +9,16 @@ export const DIFFICULTY_VALUE_BY_LETTER = { H: 0.8, I: 0.9, J: 1.0, -} as const; +} as const -export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER; +export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER export function getDifficultyValue(letter: string): number | undefined { - const normalizedLetter = letter.toUpperCase(); + const normalizedLetter = letter.toUpperCase() if (!Object.hasOwn(DIFFICULTY_VALUE_BY_LETTER, normalizedLetter)) { - return undefined; + return undefined } - return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter]; -} \ No newline at end of file + return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter] +} From a2e060f1805013a2311a4ca90d937b644f71a5d2 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 21 Oct 2026 12:35:34 +0200 Subject: [PATCH 3/3] fix: read the difficulty badge once for screen readers --- src/components/ui/DifficultyBadge.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx index f7c6d57..2daf98e 100644 --- a/src/components/ui/DifficultyBadge.tsx +++ b/src/components/ui/DifficultyBadge.tsx @@ -12,25 +12,23 @@ export default function DifficultyBadge({ const displayDifficulty = value !== undefined ? letter : difficulty return ( - - Difficulty + + {/* The hidden labels sit inside the same span as their value, so a + screen reader reads "Difficulty B, Value 0.2" once. No title: it + would be read a second time, and a tooltip only works with a mouse. */} + Difficulty {displayDifficulty} + {value !== undefined && ,} {value !== undefined && ( + Value {value.toFixed(1)} )}