diff --git a/AGENTS.md b/AGENTS.md index 1ef9380..838bfe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,7 @@ const sizeMap: Record = { ## Validation -Run `npm run build` to catch TypeScript errors before considering work complete. +Run `pnpm run build` to catch TypeScript errors before considering work complete. ## Resources diff --git a/package.json b/package.json index ac04ceb..a5e61ca 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "generate:css": "tsx scripts/generate-css.ts", "build:theme": "tsx scripts/generate-theme.ts", "token-server": "tsx scripts/token-server.ts", + "typecheck": "tsc --noEmit", "lint": "eslint .", "preview": "vite preview", "storybook": "storybook dev -p 6006", diff --git a/scripts/README.md b/scripts/README.md index 84d26c1..232264c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -60,7 +60,7 @@ Lightweight Playwright scripts for checking pages without the MCP server overhea Install Playwright (if not already installed): ```bash -npm install -D playwright +pnpm install -D playwright npx playwright install chromium ``` @@ -70,13 +70,13 @@ npx playwright install chromium ```bash # Check default (http://localhost:5173) -npm run check-page +pnpm run check-page # Check specific URL -npm run check-page http://localhost:5173/customerdashboard +pnpm run check-page http://localhost:5173/customerdashboard # Check with custom screenshot name -npm run check-page http://localhost:5173/publications screenshots/publications.png +pnpm run check-page http://localhost:5173/publications screenshots/publications.png ``` **Output:** @@ -88,10 +88,10 @@ npm run check-page http://localhost:5173/publications screenshots/publications.p ```bash # Full check with all diagnostics -npm run check-page:full +pnpm run check-page:full # Full check specific URL -npm run check-page:full http://localhost:5173/patterns/taskdashboard +pnpm run check-page:full http://localhost:5173/patterns/taskdashboard ``` **Output:** @@ -214,7 +214,7 @@ Runs axe-core against every story in a running Storybook instance to find access ```bash # Requires Storybook running (default: http://localhost:6006) -npm run a11y-audit +pnpm run a11y-audit # Or specify a custom URL node scripts/a11y-audit.cjs http://localhost:9009 diff --git a/scripts/visual-check.cjs b/scripts/visual-check.cjs index 2fe2c26..0c865da 100644 --- a/scripts/visual-check.cjs +++ b/scripts/visual-check.cjs @@ -4,9 +4,9 @@ * Usage: * node scripts/visual-check.js '{"url": "http://localhost:5173", "checks": {...}}' * - * Or use npm scripts: - * npm run check-page - * npm run check-page:full + * Or use pnpm scripts: + * pnpm run check-page + * pnpm run check-page:full */ const { chromium } = require('playwright'); diff --git a/src/components/Image/Image.stories.tsx b/src/components/Image/Image.stories.tsx index ab9e602..61b01bc 100644 --- a/src/components/Image/Image.stories.tsx +++ b/src/components/Image/Image.stories.tsx @@ -127,3 +127,102 @@ export const UserDefaultIconFallback: Story = { size: 'SMALL', }, } + +// --- Issue #113: Custom background colors for avatar fallback --- + +export const UserInitialsWithColors: Story = { + name: 'Avatar with SAIL Color Backgrounds', + args: { + label: 'Team Members', + images: [ + { imageType: 'user' as const, user: { name: 'Jane Doe', initials: 'JD' }, backgroundColor: 'ACCENT' }, + { imageType: 'user' as const, user: { name: 'Bob Johnson', initials: 'BJ' }, backgroundColor: 'POSITIVE' }, + { imageType: 'user' as const, user: { name: 'Alice Williams', initials: 'AW' }, backgroundColor: 'NEGATIVE' }, + { imageType: 'user' as const, user: { name: 'Tom Chen', initials: 'TC' }, backgroundColor: 'TEAL_200' }, + { imageType: 'user' as const, user: { name: 'Sarah Park', initials: 'SP' }, backgroundColor: 'PURPLE_200' }, + { imageType: 'user' as const, user: { name: 'Mike Lee', initials: 'ML' }, backgroundColor: '#E8A87C' }, + ], + style: 'AVATAR', + size: 'TINY', + }, +} + +// --- Issue #121: WebImage support --- + +export const WebImage: Story = { + name: 'Web Image (External URL)', + args: { + label: 'External Images', + instructions: 'Images loaded from external URLs using a!webImage()', + images: [ + { + source: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&h=300&fit=crop', + altText: 'Mountain landscape', + caption: 'Beautiful mountain view', + }, + { + source: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=400&h=300&fit=crop', + altText: 'Nature scene', + caption: 'Peaceful nature', + }, + ], + size: 'MEDIUM_PLUS', + }, +} + +export const WebImageAvatar: Story = { + name: 'Web Image as Avatar', + args: { + labelPosition: 'COLLAPSED', + images: [ + { + source: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200&h=200&fit=crop&crop=face', + altText: 'User avatar from web', + }, + ], + style: 'AVATAR', + size: 'SMALL_PLUS', + }, +} + +// --- Issue #126: Shape/borderRadius prop --- + +export const ShapeSquared: Story = { + name: 'Shape: SQUARED', + args: { + label: 'Squared Images', + images: [ + { document: 'images/case-management-studio.webp', altText: 'Case management' }, + { document: 'images/process-modeler.webp', altText: 'Process modeler' }, + ], + size: 'MEDIUM', + shape: 'SQUARED', + }, +} + +export const ShapeRounded: Story = { + name: 'Shape: ROUNDED', + args: { + label: 'Rounded Images', + images: [ + { document: 'images/case-management-studio.webp', altText: 'Case management' }, + { document: 'images/process-modeler.webp', altText: 'Process modeler' }, + ], + size: 'MEDIUM', + shape: 'ROUNDED', + }, +} + +export const ShapeCircle: Story = { + name: 'Shape: CIRCLE', + args: { + label: 'Circle Images', + instructions: 'Circular crop without using avatar style', + images: [ + { document: 'images/case-management-studio.webp', altText: 'Case management' }, + { document: 'images/process-modeler.webp', altText: 'Process modeler' }, + ], + size: 'SMALL_PLUS', + shape: 'CIRCLE', + }, +} diff --git a/src/components/Image/ImageField.tsx b/src/components/Image/ImageField.tsx index c782138..a22faa9 100644 --- a/src/components/Image/ImageField.tsx +++ b/src/components/Image/ImageField.tsx @@ -1,19 +1,26 @@ import * as React from 'react' import * as Avatar from '@radix-ui/react-avatar' import { FieldWrapper } from '../shared/FieldWrapper' -import type { SAILLabelPosition, SAILMarginSize, SAILAlign } from '../../types/sail' +import type { SAILLabelPosition, SAILMarginSize, SAILAlign, SAILShape } from '../../types/sail' import type { DocumentImageProps } from './DocumentImage' import type { UserImageProps } from './UserImage' -import { alignMap } from '../../utils/sailMaps' +import type { WebImageProps } from './WebImage' +import { alignMap, shapeMap } from '../../utils/sailMaps' +import { resolveColorClass, resolveColorToHex, getContrastColor } from '../../utils/colorResolver' // Union type for all image types supported by ImageField -type ImageFieldImage = DocumentImageProps | UserImageProps +type ImageFieldImage = DocumentImageProps | UserImageProps | WebImageProps // Type guard to check if an image is a UserImage function isUserImage(image: ImageFieldImage): image is UserImageProps { return 'imageType' in image && image.imageType === 'user' } +// Type guard to check if an image is a WebImage +function isWebImage(image: ImageFieldImage): image is WebImageProps { + return 'source' in image +} + type ImageSize = | "ICON" | "ICON_PLUS" @@ -50,6 +57,8 @@ export interface ImageFieldProps { isThumbnail?: boolean /** Determines how the images are rendered */ style?: ImageStyle + /** Determines the border radius of images. Ignored when style is "AVATAR" (always circular). */ + shape?: SAILShape /** Determines alignment of the images */ align?: SAILAlign /** Additional text for screen readers */ @@ -76,6 +85,7 @@ export const ImageField: React.FC = ({ size = "MEDIUM", isThumbnail = false, style = "STANDARD", + shape = "SEMI_ROUNDED", align = "START", accessibilityText, marginAbove = "NONE", @@ -123,13 +133,13 @@ export const ImageField: React.FC = ({ // Style-specific classes const getImageClasses = () => { - // Use avatar size map when style is AVATAR - const activeSizeMap = style === "AVATAR" ? avatarSizeMap : sizeMap + // Use avatar (square) size map when style is AVATAR or shape is CIRCLE + const activeSizeMap = (style === "AVATAR" || shape === "CIRCLE") ? avatarSizeMap : sizeMap const baseClasses = [ activeSizeMap[size], 'object-cover', // Maintain aspect ratio - style === "AVATAR" ? 'rounded-full' : 'rounded-sm', + style === "AVATAR" ? 'rounded-full' : shapeMap[shape], isThumbnail ? 'cursor-pointer hover:opacity-80 transition-opacity' : '' ].filter(Boolean).join(' ') @@ -159,9 +169,34 @@ export const ImageField: React.FC = ({ const activeSizeMap = avatarSizeMap const sizeClasses = activeSizeMap[size] + // Resolve backgroundColor — supports SAIL tokens, palette colors, or hex + let bgClass = 'bg-gray-200' + let bgInlineStyle: React.CSSProperties | undefined + let initialsColor: string | undefined + if (backgroundColor) { + const resolved = resolveColorClass(backgroundColor, 'bg') + if (resolved) { + bgClass = resolved + } else { + // Hex or unknown — use inline style + bgClass = '' + bgInlineStyle = { backgroundColor } + } + // Determine accessible text color for initials + const hex = resolveColorToHex(backgroundColor) + if (hex) { + initialsColor = getContrastColor(hex) + } + } + // Build fallback content (initials or default icon) const fallbackContent = user?.initials ? ( - {user.initials} + + {user.initials} + ) : ( // Default user icon SVG @@ -172,8 +207,8 @@ export const ImageField: React.FC = ({ return ( @@ -183,8 +218,8 @@ export const ImageField: React.FC = ({ alt={imageAlt} /> {fallbackContent} @@ -193,37 +228,99 @@ export const ImageField: React.FC = ({ ) } - // Render document image (standard approach) - const imageSrc = imageProps.document - - const imageClasses = getImageClasses() + // Check if this is a web image + if (isWebImage(imageProps)) { + const imageClasses = getImageClasses() - const handleClick = () => { - if (imageProps.link) { - imageProps.link() - } - } - - return ( -
+ const imgElement = ( {imageProps.altText + ) - {/* Show link indicator if image has link */} - {imageProps.link && ( -
+ if (imageProps.link) { + return ( +
imageProps.link!()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + imageProps.link!() + } + }} + > + {imgElement} +
+
+ + + +
+
+
+ ) + } + + return ( +
+ {imgElement} +
+ ) + } + + // Render document image (standard approach) + const imageSrc = imageProps.document + + const imageClasses = getImageClasses() + + const imgElement = ( + {imageProps.altText + ) + + if (imageProps.link) { + return ( +
imageProps.link!()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + imageProps.link!() + } + }} + > + {imgElement} +
- )} +
+ ) + } + + return ( +
+ {imgElement}
) })} diff --git a/src/components/Image/UserImage.tsx b/src/components/Image/UserImage.tsx index e75d17e..1cd1463 100644 --- a/src/components/Image/UserImage.tsx +++ b/src/components/Image/UserImage.tsx @@ -24,7 +24,7 @@ export interface UserImageProps { link?: () => void /** Controls visibility of the image */ showWhen?: boolean - /** Background color for the initials fallback (hex or CSS color) */ + /** Background color for the initials fallback. Accepts SAIL color tokens (e.g. "ACCENT", "TEAL_200") or hex strings (e.g. "#4A90D9") */ backgroundColor?: string } diff --git a/src/components/Image/WebImage.tsx b/src/components/Image/WebImage.tsx new file mode 100644 index 0000000..89eaa4a --- /dev/null +++ b/src/components/Image/WebImage.tsx @@ -0,0 +1,22 @@ +export interface WebImageProps { + /** The external URL of the image */ + source: string + /** Alternate text for accessibility and screen readers */ + altText?: string + /** Optional caption text for mouseover and slideshow mode */ + caption?: string + /** Link behavior when image is clicked */ + link?: () => void + /** Controls visibility of the image */ + showWhen?: boolean +} + +/** + * Web image interface for use within ImageField + * Maps to SAIL's a!webImage() function + * + * This is not a React component - it's a data structure + * that gets processed by ImageField for rendering + * + * Use this to display images from external URLs (CDN, Unsplash, etc.) + */ diff --git a/src/components/Image/index.ts b/src/components/Image/index.ts index 80b4a8f..daa2997 100644 --- a/src/components/Image/index.ts +++ b/src/components/Image/index.ts @@ -2,3 +2,4 @@ export { ImageField } from './ImageField' export type { ImageFieldProps } from './ImageField' export type { DocumentImageProps } from './DocumentImage' export type { UserImageProps, User } from './UserImage' +export type { WebImageProps } from './WebImage' diff --git a/src/components/Stamp/StampField.tsx b/src/components/Stamp/StampField.tsx index 28cd37b..e622275 100644 --- a/src/components/Stamp/StampField.tsx +++ b/src/components/Stamp/StampField.tsx @@ -85,7 +85,8 @@ export const StampField: React.FC = ({ const shapeMap: Record = { SQUARED: 'rounded-none', SEMI_ROUNDED: 'rounded-sm', - ROUNDED: 'rounded-full' + ROUNDED: 'rounded-full', + CIRCLE: 'rounded-full' } // Background color mapping diff --git a/src/types/sail.ts b/src/types/sail.ts index fbf19a0..106e834 100644 --- a/src/types/sail.ts +++ b/src/types/sail.ts @@ -6,7 +6,7 @@ /** * Border radius/shape values matching SAIL's shape parameter */ -export type SAILShape = "SQUARED" | "SEMI_ROUNDED" | "ROUNDED" +export type SAILShape = "SQUARED" | "SEMI_ROUNDED" | "ROUNDED" | "CIRCLE" /** * Padding values matching SAIL's padding parameter diff --git a/src/utils/colorResolver.ts b/src/utils/colorResolver.ts index 12954be..ab4d815 100644 --- a/src/utils/colorResolver.ts +++ b/src/utils/colorResolver.ts @@ -1,5 +1,5 @@ import type { SAILSemanticColor } from '../types/sail' -import { paletteColorMap } from '../types/palette-colors.generated' +import { paletteColorMap, paletteHexMap } from '../types/palette-colors.generated' import type { SAILPaletteColor } from '../types/palette-colors.generated' /** @@ -49,3 +49,55 @@ export function resolveColorClass(color: string, prefix: TailwindPrefix = 'bg'): // Hex or unknown — caller handles via inline style return '' } + + +/** + * Hex values for semantic colors (matches the Aurora palette tokens). + */ +const semanticHexMap: Record = { + ACCENT: '#2322F0', // blue-500 + POSITIVE: '#357A38', // green-700 + NEGATIVE: '#9B0027', // red-700 + SECONDARY: '#616161', // gray-700 + STANDARD: '#212121', // gray-900 +} + +/** + * Resolve any SAIL color (semantic, palette, or hex) to a hex string. + * Returns the input unchanged if it's already a hex string. + * Returns undefined for unrecognized values. + */ +export function resolveColorToHex(color: string): string | undefined { + if (isSemanticColor(color)) { + return semanticHexMap[color] + } + if (isPaletteColor(color)) { + return paletteHexMap[color] + } + if (color.startsWith('#')) { + return color + } + return undefined +} + +/** + * Returns accessible foreground color ('#ffffff' or '#000000') for a given hex background. + * Uses WCAG 2.x relative luminance to determine contrast. + */ +export function getContrastColor(hex: string): string { + // Normalize shorthand #RGB/#RGBA to #RRGGBB/#RRGGBBAA + if (hex.length === 4 || hex.length === 5) { + hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` + } + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + const toLinear = (c: number) => { + const s = c / 255 + return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + } + const L = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) + const contrastWhite = (1.0 + 0.05) / (L + 0.05) + const contrastBlack = (L + 0.05) / (0.0 + 0.05) + return contrastWhite >= contrastBlack ? '#ffffff' : '#000000' +} diff --git a/src/utils/sailMaps.ts b/src/utils/sailMaps.ts index a487938..ee03875 100644 --- a/src/utils/sailMaps.ts +++ b/src/utils/sailMaps.ts @@ -46,7 +46,8 @@ export const paddingMap: Record = { export const shapeMap: Record = { SQUARED: 'rounded-none', SEMI_ROUNDED: 'rounded-sm', - ROUNDED: 'rounded-md' + ROUNDED: 'rounded-md', + CIRCLE: 'rounded-full' } // --- Button / Interactive Size Maps ---