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
12 changes: 12 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## Missing fields in the GraphQL schema (`backend/src/schema.graphql`, `docs/API.md`)

- **Tool:** Claude Code, and Claude for the research.
Expand Down
37 changes: 37 additions & 0 deletions src/components/ui/DifficultyBadge.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Just a tiny polish for screen readers:

We can remove the title attribute so mouse tooltips don't clutter the UI, and wrap "Difficulty " and "Value " in <span className="sr-only"> directly next to their respective values. That way, assistive tech reads "Difficulty A, Value 0.1" cleanly without double-announcing.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 (
<span className="inline-flex shrink-0 flex-col items-center gap-1">
{/* 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. */}
<span
className={cn(
'inline-flex size-10 items-center justify-center rounded-full',
'bg-text-color-dark px-1 text-lg font-semibold leading-none text-white',
)}
>
<span className="sr-only">Difficulty </span>
{displayDifficulty}
{value !== undefined && <span className="sr-only">,</span>}
</span>
{value !== undefined && (
<span className="text-xs font-medium leading-none text-text-color-dark">
<span className="sr-only">Value </span>
{value.toFixed(1)}
</span>
)}
</span>
)
}
28 changes: 28 additions & 0 deletions src/components/ui/ElementVariants.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader className="text-left">
<CardTitle as="h2">
Code - Name (if any) - Element variant id (a,b,c)
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<p>Element description goes here.</p>
<DifficultyBadge difficulty="B" />
</div>
<img
src={elementImage}
alt="Balance beam element diagram"
className="mt-4 block w-full rounded-xl"
/>
</CardContent>
</Card>
)
}

export default ElementVariant
106 changes: 106 additions & 0 deletions src/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-slot="card"
data-size={size}
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'>) {
return (
<div
data-slot="card-header"
className={cn(
'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)',
className,
)}
{...props}
/>
)
}

type CardTitleProps = React.ComponentProps<'h2'> & {
as: 'h2' | 'h3' | 'h4'
}

function CardTitle({ as: Heading, className, ...props }: CardTitleProps) {
return (
<Heading
data-slot="card-title"
className={cn(
'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className,
)}
{...props}
/>
)
}

function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="card-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}

function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}

function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-(--card-spacing)', className)}
{...props}
/>
)
}

function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn(
'flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)',
className,
)}
{...props}
/>
)
}

export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
24 changes: 24 additions & 0 deletions src/lib/difficulty.ts
Original file line number Diff line number Diff line change
@@ -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]
}