diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc new file mode 100644 index 00000000..036c29a8 --- /dev/null +++ b/.cursor/rules/architecture.mdc @@ -0,0 +1,112 @@ +--- +description: Astro architecture, component patterns, island strategy, styling approach +globs: src/**,astro.config.mjs +alwaysApply: false +--- + +# Architecture + +## Stack + +| Layer | Technology | Version | Notes | +|-------|-----------|---------|-------| +| Framework | Astro | 5.x | Content collections via glob loader | +| Islands | Preact | 10.x | Via `@astrojs/preact`. Only FilterSidebar and CodeExample | +| Shared State | Nano Stores | latest | `@nanostores/preact` for islands, vanilla subscriber for static HTML | +| Styling | Tailwind CSS | 4.x | Via `@tailwindcss/vite` plugin (not `@astrojs/tailwind`) | +| Fonts | Fontsource | latest | Self-hosted Fraunces, Plus Jakarta Sans, JetBrains Mono | +| Syntax Highlighting | Shiki | built-in | Dual-theme: github-light / github-dark | +| View Transitions | Astro `` | — | Crossfade default | +| Testing | Vitest + Playwright | latest | Unit, integration, E2E (6-browser matrix on main) | + +## Content Collections + +Defined in `src/content.config.ts` using Astro 5 glob loader: + +```ts +defineCollection({ + loader: glob({ pattern: '**/*.md', base: './content/smells' }), + schema: smellSchema, +}) +``` + +`base` resolves relative to the project root (where `astro.config.mjs` lives), NOT relative to `src/content.config.ts`. Content files stay at `content/smells/` — no move, no symlinks. + +The `---` null placeholder in YAML arrays is handled via Zod `.transform(arr => arr.filter(v => v !== '---'))` in `src/schemas/smell.ts`. + +## Routing + +| Route | File | Description | +|-------|------|-------------| +| `/` | `src/pages/index.astro` | Catalog with hero, filter sidebar, card grid | +| `/smells/[slug]` | `src/pages/smells/[slug].astro` | Individual smell article (56 pages) | +| `/about` | `src/pages/about.astro` | About page | +| `/rss.xml` | `src/pages/rss.xml.ts` | RSS feed | +| Custom 404 | `src/pages/404.astro` | 404 with fuzzy matching and diagnoses | + +Uses `[slug].astro` (not `[...slug].astro`) because all 56 slugs are flat — rest params would let `/smells/foo/bar` match instead of 404ing. + +## Internal Links + +Remark plugin `src/plugins/remark-smell-links.ts` rewrites `[Name](./slug.md)` to `Name` at build time. Keeps content files portable. + +## Remark Plugin Pipeline + +Configured in `astro.config.mjs`. Ordering is load-bearing: + +1. `remark-smell-links` — rewrite `./slug.md` links while headings are still original +2. `remark-overview-heading` — normalize first H2 to "Overview" +3. `remark-strip-sections` — strip sections rendered by Astro components (Problems, Example, Refactoring, Sources) +4. `remark-callout-sections` — wrap remaining callout sections + +## Islands Architecture + +Most pages ship **zero framework JavaScript**. Preact is used only for two interactive components: + +| Component | Hydration | Location | +|-----------|-----------|----------| +| FilterSidebar | `client:load` | Catalog — includes search input, dimension filters, active pills, mobile bottom sheet | +| CodeExample | `client:visible` | Article pages — smelly/solution code toggle with copy button | + +**Everything else uses inline ` + + +

Redirecting to ${fullUrl}...

+ +`; +} + +/** + * Parse the smell markdown collection into feed-friendly metadata. + * + * @param {string} [contentDir] + * @returns {SmellEntry[]} + */ +export function getSmellEntries(contentDir = CONTENT_DIR) { + return readdirSync(contentDir) + .filter((fileName) => fileName.endsWith('.md')) + .map((fileName) => { + const slug = fileName.replace(/\.md$/u, ''); + const source = readFileSync(join(contentDir, fileName), 'utf-8'); + const { data } = matter(source); + const rawObstruction = Array.isArray(data.categories?.obstruction) + ? data.categories.obstruction + : []; + + return { + slug, + title: + typeof data.meta?.title === 'string' && data.meta.title.length > 0 + ? data.meta.title + : slug, + lastUpdateDate: normalizeDate(data.meta?.last_update_date), + obstruction: rawObstruction.filter((value) => typeof value === 'string' && value !== '---'), + }; + }) + .sort((left, right) => left.title.localeCompare(right.title)); +} + +/** + * Build a compatibility RSS feed for the legacy GitHub Pages URL. + * + * @param {SmellEntry[]} smells + * @returns {string} + */ +export function buildCompatibilityRssXml(smells) { + const latestDate = smells.reduce((latest, smell) => { + if (smell.lastUpdateDate > latest) { + return smell.lastUpdateDate; + } + + return latest; + }, '1970-01-01'); + + const items = smells + .map((smell) => { + const articleUrl = buildTargetUrl(`/smells/${smell.slug}`); + + return ` + ${escapeXml(smell.title)} + ${escapeXml(articleUrl)} + ${escapeXml(articleUrl)} + ${escapeXml(toUtcRssDate(smell.lastUpdateDate))} + ${escapeXml(buildFeedItemDescription(smell))} + `; + }) + .join('\n'); + + return ` + + + ${escapeXml(SITE_TITLE)} + ${escapeXml(SITE_DESCRIPTION)} + ${TARGET_DOMAIN}/ + + en-us + ${escapeXml(toUtcRssDate(latestDate))} + scripts/generate-gh-pages-redirects.mjs +${items} + + +`; +} + +/** + * Build the full gh-pages artifact map for cutover. + * + * @param {SmellEntry[]} smells + * @param {{ killSwitchSource?: string }} [options] + * @returns {Artifact[]} + */ +export function buildArtifactPlan(smells, options = {}) { + const killSwitchSource = options.killSwitchSource ?? KILL_SWITCH_SOURCE; + + return [ + { + type: 'text', + relativePath: 'index.html', + contents: buildRedirectHtml('/'), + }, + { + type: 'text', + relativePath: 'about/index.html', + contents: buildRedirectHtml('/about'), + }, + { + type: 'text', + relativePath: 'rss.xml', + contents: buildCompatibilityRssXml(smells), + }, + { + type: 'copy', + relativePath: 'sw.js', + sourcePath: killSwitchSource, + }, + ...smells.map((smell) => ({ + type: 'text', + relativePath: `${smell.slug}/index.html`, + contents: buildRedirectHtml(`/smells/${smell.slug}`), + })), + ]; +} + +/** + * Write the artifact plan to disk. + * + * @param {Artifact[]} artifacts + * @param {{ outputDir?: string }} [options] + * @returns {Artifact[]} + */ +export function writeArtifacts(artifacts, options = {}) { + const outputDir = options.outputDir ?? OUTPUT_DIR; + + rmSync(outputDir, { recursive: true, force: true }); + mkdirSync(outputDir, { recursive: true }); + + for (const artifact of artifacts) { + const fullPath = join(outputDir, artifact.relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + + if (artifact.type === 'copy') { + copyFileSync(artifact.sourcePath, fullPath); + continue; + } + + writeFileSync(fullPath, artifact.contents, 'utf-8'); + } + + return artifacts; +} + +/** + * Generate all GitHub Pages cutover artifacts. + * + * @param {{ contentDir?: string; outputDir?: string; killSwitchSource?: string }} [options] + * @returns {{ smells: SmellEntry[]; artifacts: Artifact[] }} + */ +export function generateGhPagesRedirects(options = {}) { + const smells = getSmellEntries(options.contentDir ?? CONTENT_DIR); + const artifacts = buildArtifactPlan(smells, { + killSwitchSource: options.killSwitchSource ?? KILL_SWITCH_SOURCE, + }); + + writeArtifacts(artifacts, { outputDir: options.outputDir ?? OUTPUT_DIR }); + + return { smells, artifacts }; +} + +function main() { + const { smells, artifacts } = generateGhPagesRedirects(); + + console.log(`Generating cutover artifacts for ${smells.length} smells...`); + artifacts.forEach((artifact) => { + console.log(` -> ${artifact.relativePath}`); + }); + console.log(`Done. Generated ${artifacts.length} artifacts in gh-pages-redirects/`); +} + +if (process.argv[1] && resolve(process.argv[1]) === ENTRY_FILE) { + main(); +} diff --git a/scripts/generate-og-images.mjs b/scripts/generate-og-images.mjs new file mode 100644 index 00000000..cc62b2a7 --- /dev/null +++ b/scripts/generate-og-images.mjs @@ -0,0 +1,551 @@ +/** + * generate-og-images.mjs — Generates Open Graph images for all pages. + * + * Produces: + * - /public/og/default.png (generic catalog OG image) + * - /public/og/{slug}.png (per-article OG image, one per smell) + * + * Uses Satori for SVG rendering and @resvg/resvg-js for SVG -> PNG conversion. + * Fonts are vendored locally as TTF files for reliable clean builds. + * + * Usage: node scripts/generate-og-images.mjs + */ +import satori from 'satori'; +import { Resvg } from '@resvg/resvg-js'; +import { readFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import matter from 'gray-matter'; + +const ROOT = resolve(import.meta.dirname, '..'); +const CONTENT_DIR = join(ROOT, 'content', 'smells'); +const OUTPUT_DIR = join(ROOT, 'public', 'og'); +const FONT_DIR = join(ROOT, 'scripts', 'fonts'); + +const BRAND_ACCENT_HEX = '#B45309'; +const MUTED_HEX = '#8A8580'; + +// Keep these aligned with the light-mode tokens in src/styles/global.css. +const OBSTRUCTION_COLORS_HEX = { + Bloaters: '#B45309', + 'Change Preventers': '#DC2626', + Couplers: '#2451B3', + 'Data Dealers': '#0F766E', + Dispensables: '#8A8580', + 'Functional Abusers': '#CA8A04', + 'Lexical Abusers': '#7B2FA8', + Obfuscators: '#CA8A04', + 'Object Oriented Abusers': '#15803D', + Other: '#8A8580', +}; + +// OG image dimensions +const WIDTH = 1200; +const HEIGHT = 630; + +function bufferToArrayBuffer(buffer) { + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} + +function loadVendoredFont(fileName) { + const fontPath = join(FONT_DIR, fileName); + + if (!existsSync(fontPath)) { + throw new Error(`Vendored OG font not found: ${fontPath}`); + } + + return readFileSync(fontPath); +} + +/** Load fonts needed for OG image generation */ +function loadFonts() { + const fraunces = loadVendoredFont('Fraunces-400.ttf'); + const plusJakarta = loadVendoredFont('PlusJakartaSans-600.ttf'); + + return [ + { name: 'Fraunces', data: bufferToArrayBuffer(fraunces), weight: 400, style: 'normal' }, + { + name: 'Plus Jakarta Sans', + data: bufferToArrayBuffer(plusJakarta), + weight: 600, + style: 'normal', + }, + ]; +} + +/** Convert Satori SVG string to PNG buffer via resvg */ +async function svgToPng(svg) { + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: WIDTH }, + }); + const pngData = resvg.render(); + return pngData.asPng(); +} + +/** Build the default (catalog-level) OG image JSX tree */ +function buildDefaultImage() { + return { + type: 'div', + props: { + style: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + background: 'linear-gradient(135deg, #FAF9F6 0%, #F5F0EB 100%)', + fontFamily: 'Plus Jakarta Sans, sans-serif', + position: 'relative', + }, + children: [ + // Top accent stripe + { + type: 'div', + props: { + style: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '6px', + background: 'linear-gradient(90deg, #B45309, #DC2626, #2451B3, #7B2FA8, #15803D)', + }, + }, + }, + // Site name + { + type: 'div', + props: { + style: { + fontSize: '22px', + fontWeight: 600, + color: MUTED_HEX, + letterSpacing: '0.12em', + textTransform: 'uppercase', + marginBottom: '16px', + }, + children: 'Code Smells Catalog', + }, + }, + // Main title + { + type: 'div', + props: { + style: { + fontFamily: 'Fraunces, serif', + fontSize: '72px', + fontWeight: 400, + color: '#1A1A1A', + letterSpacing: '-0.03em', + textAlign: 'center', + lineHeight: 1.15, + maxWidth: '900px', + }, + children: '56 Code Smells', + }, + }, + // Subtitle + { + type: 'div', + props: { + style: { + fontSize: '24px', + color: '#6B6560', + marginTop: '16px', + textAlign: 'center', + maxWidth: '700px', + lineHeight: 1.5, + }, + children: 'A comprehensive catalog — classified, connected, and browsable.', + }, + }, + // Bottom branding + { + type: 'div', + props: { + style: { + position: 'absolute', + bottom: '32px', + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '16px', + color: MUTED_HEX, + }, + children: [ + { + type: 'div', + props: { + style: { + width: '8px', + height: '8px', + borderRadius: '50%', + background: BRAND_ACCENT_HEX, + }, + }, + }, + { type: 'span', props: { children: 'codesmells.org' } }, + ], + }, + }, + ], + }, + }; +} + +/** Build per-article OG image JSX tree */ +function buildArticleImage(title, obstruction) { + const categoryColor = OBSTRUCTION_COLORS_HEX[obstruction] ?? MUTED_HEX; + + return { + type: 'div', + props: { + style: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + padding: '60px 80px', + background: 'linear-gradient(135deg, #FAF9F6 0%, #F5F0EB 100%)', + fontFamily: 'Plus Jakarta Sans, sans-serif', + position: 'relative', + }, + children: [ + // Top category color stripe + { + type: 'div', + props: { + style: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '6px', + background: categoryColor, + }, + }, + }, + // Left accent bar + { + type: 'div', + props: { + style: { + position: 'absolute', + top: '6px', + left: 0, + width: '4px', + bottom: 0, + background: `linear-gradient(to bottom, ${categoryColor}, transparent)`, + }, + }, + }, + // Site name badge + { + type: 'div', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: MUTED_HEX, + letterSpacing: '0.1em', + textTransform: 'uppercase', + marginBottom: '24px', + }, + children: 'Code Smells Catalog', + }, + }, + // Smell title + { + type: 'div', + props: { + style: { + fontFamily: 'Fraunces, serif', + fontSize: title.length > 30 ? '56px' : '68px', + fontWeight: 400, + color: '#1A1A1A', + letterSpacing: '-0.03em', + lineHeight: 1.15, + maxWidth: '900px', + marginBottom: '32px', + }, + children: title, + }, + }, + // Category badge + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '10px', + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 18px', + borderRadius: '100px', + background: `${categoryColor}18`, + border: `1.5px solid ${categoryColor}40`, + fontSize: '18px', + fontWeight: 600, + color: categoryColor, + }, + children: [ + { + type: 'div', + props: { + style: { + width: '10px', + height: '10px', + borderRadius: '50%', + background: categoryColor, + }, + }, + }, + { type: 'span', props: { children: obstruction } }, + ], + }, + }, + ], + }, + }, + // Bottom branding + { + type: 'div', + props: { + style: { + position: 'absolute', + bottom: '32px', + left: '80px', + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '16px', + color: MUTED_HEX, + }, + children: [ + { + type: 'div', + props: { + style: { + width: '8px', + height: '8px', + borderRadius: '50%', + background: BRAND_ACCENT_HEX, + }, + }, + }, + { type: 'span', props: { children: 'codesmells.org' } }, + ], + }, + }, + ], + }, + }; +} + +/** Build the about page OG image JSX tree */ +function buildAboutImage() { + return { + type: 'div', + props: { + style: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + background: 'linear-gradient(135deg, #FAF9F6 0%, #F5F0EB 100%)', + fontFamily: 'Plus Jakarta Sans, sans-serif', + position: 'relative', + }, + children: [ + // Top accent stripe + { + type: 'div', + props: { + style: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '6px', + background: 'linear-gradient(90deg, #B45309, #DC2626, #2451B3, #7B2FA8, #15803D)', + }, + }, + }, + // Site name + { + type: 'div', + props: { + style: { + fontSize: '22px', + fontWeight: 600, + color: MUTED_HEX, + letterSpacing: '0.12em', + textTransform: 'uppercase', + marginBottom: '16px', + }, + children: 'Code Smells Catalog', + }, + }, + // Main title + { + type: 'div', + props: { + style: { + fontFamily: 'Fraunces, serif', + fontSize: '68px', + fontWeight: 400, + color: '#1A1A1A', + letterSpacing: '-0.03em', + textAlign: 'center', + lineHeight: 1.15, + maxWidth: '900px', + }, + children: 'About the Catalog', + }, + }, + // Subtitle + { + type: 'div', + props: { + style: { + fontSize: '24px', + color: '#6B6560', + marginTop: '16px', + textAlign: 'center', + maxWidth: '700px', + lineHeight: 1.5, + }, + children: '56 smells. 5 dimensions. Every relationship mapped.', + }, + }, + // Springer badge + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '8px', + marginTop: '28px', + padding: '8px 20px', + borderRadius: '100px', + background: `${BRAND_ACCENT_HEX}14`, + border: `1.5px solid ${BRAND_ACCENT_HEX}30`, + fontSize: '16px', + fontWeight: 600, + color: BRAND_ACCENT_HEX, + }, + children: 'Published in Springer Nature', + }, + }, + // Bottom branding + { + type: 'div', + props: { + style: { + position: 'absolute', + bottom: '32px', + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '16px', + color: MUTED_HEX, + }, + children: [ + { + type: 'div', + props: { + style: { + width: '8px', + height: '8px', + borderRadius: '50%', + background: BRAND_ACCENT_HEX, + }, + }, + }, + { type: 'span', props: { children: 'codesmells.org/about' } }, + ], + }, + }, + ], + }, + }; +} + +/** Parse all smell markdown files and return metadata */ +function getAllSmells() { + const files = readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.md')); + return files.map((file) => { + const raw = readFileSync(join(CONTENT_DIR, file), 'utf-8'); + const { data } = matter(raw); + return { + slug: data.slug, + title: data.meta?.title ?? file.replace('.md', ''), + obstruction: data.categories?.obstruction?.[0] ?? 'Other', + }; + }); +} + +/** Main generation entry point */ +async function main() { + mkdirSync(OUTPUT_DIR, { recursive: true }); + + console.log('Loading fonts...'); + const fonts = loadFonts(); + + const smells = getAllSmells(); + console.log(`Generating OG images for ${smells.length} smells + default + about...`); + + // Generate default OG image + const defaultSvg = await satori(buildDefaultImage(), { + width: WIDTH, + height: HEIGHT, + fonts, + }); + const defaultPng = await svgToPng(defaultSvg); + await writeFile(join(OUTPUT_DIR, 'default.png'), defaultPng); + console.log(' -> default.png'); + + // Generate about page OG image + const aboutSvg = await satori(buildAboutImage(), { + width: WIDTH, + height: HEIGHT, + fonts, + }); + const aboutPng = await svgToPng(aboutSvg); + await writeFile(join(OUTPUT_DIR, 'about.png'), aboutPng); + console.log(' -> about.png'); + + // Generate per-article OG images + for (const smell of smells) { + const svg = await satori(buildArticleImage(smell.title, smell.obstruction), { + width: WIDTH, + height: HEIGHT, + fonts, + }); + const png = await svgToPng(svg); + await writeFile(join(OUTPUT_DIR, `${smell.slug}.png`), png); + console.log(` -> ${smell.slug}.png`); + } + + console.log(`Done. Generated ${smells.length + 2} OG images in public/og/`); +} + +try { + await main(); +} catch (err) { + console.error('OG image generation failed:', err); + process.exit(1); +} diff --git a/scripts/verify-sitemap.mjs b/scripts/verify-sitemap.mjs new file mode 100644 index 00000000..237acbf4 --- /dev/null +++ b/scripts/verify-sitemap.mjs @@ -0,0 +1,115 @@ +/** + * verify-sitemap.mjs — Post-build sitemap verification. + * + * Checks that dist/sitemap-0.xml (or sitemap-index.xml) contains: + * - All 56 article URLs: /smells/{slug} + * - Catalog page: / + * - About page: /about + * - No trailing slashes + * + * Usage: node scripts/verify-sitemap.mjs + * Run after `pnpm build`. + */ +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '..'); +const DIST_DIR = join(ROOT, 'dist'); +const CONTENT_DIR = join(ROOT, 'content', 'smells'); +const SITE_URL = 'https://codesmells.org'; + +function fail(msg) { + console.error(`FAIL: ${msg}`); + process.exitCode = 1; +} + +function pass(msg) { + console.log(`PASS: ${msg}`); +} + +function main() { + // Find the sitemap file + let sitemapPath = join(DIST_DIR, 'sitemap-0.xml'); + if (!existsSync(sitemapPath)) { + sitemapPath = join(DIST_DIR, 'sitemap-index.xml'); + } + if (!existsSync(sitemapPath)) { + fail('No sitemap file found in dist/. Did you run `pnpm build`?'); + return; + } + + const sitemapContent = readFileSync(sitemapPath, 'utf-8'); + + // If this is a sitemap index, find and read the actual sitemap + let urls = sitemapContent; + if (sitemapContent.includes('([^<]+)<\/loc>/g); + if (sitemapUrlMatch) { + // Read each referenced sitemap + urls = ''; + for (const match of sitemapUrlMatch) { + const url = match.replace(/<\/?loc>/g, ''); + const filename = url.split('/').pop(); + const filePath = join(DIST_DIR, filename); + if (existsSync(filePath)) { + urls += readFileSync(filePath, 'utf-8'); + } + } + } + } + + // Get expected slugs from content directory + const expectedSlugs = readdirSync(CONTENT_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => f.replace('.md', '')); + + let errors = 0; + + // Check all 56 article URLs + for (const slug of expectedSlugs) { + const expectedUrl = `${SITE_URL}/smells/${slug}`; + if (urls.includes(expectedUrl)) { + pass(`Article URL found: /smells/${slug}`); + } else { + fail(`Article URL missing: /smells/${slug}`); + errors++; + } + } + + // Check catalog page + // The root URL may appear as just the site URL or with trailing slash + if (urls.includes(`${SITE_URL}`) || urls.includes(`${SITE_URL}/`)) { + pass('Catalog page URL found: /'); + } else { + fail('Catalog page URL missing: /'); + errors++; + } + + // Check about page + if (urls.includes(`${SITE_URL}/about`)) { + pass('About page URL found: /about'); + } else { + fail('About page URL missing: /about'); + errors++; + } + + // Check no trailing slashes on article URLs + const trailingSlashPattern = /\/smells\/[a-z-]+\/ 0) { + fail(`Found trailing slashes on article URLs: ${trailingSlashMatches.join(', ')}`); + errors++; + } else { + pass('No trailing slashes found on article URLs'); + } + + // Summary + console.log(''); + console.log(`Sitemap verification: ${expectedSlugs.length} articles expected, ${errors} errors`); + if (errors === 0) { + console.log('All sitemap checks passed.'); + } +} + +main(); diff --git a/src/app/index.tsx b/src/app/index.tsx deleted file mode 100644 index b3fe7bed..00000000 --- a/src/app/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import useWindowDimensions from "hooks/UseWindowDimensions" -import React from "react" -import { Provider } from "react-redux" -import configureStore from "store/createStore" -import { isMobile } from "utils/isMobile" -import theme from "utils/theme" - -import { StyledEngineProvider, Theme, ThemeProvider } from "@mui/material/styles" - -declare module "@mui/styles/defaultTheme" { - // eslint-disable-next-line @typescript-eslint/no-empty-interface - interface DefaultTheme extends Theme {} -} - -interface Props { - element: JSX.Element -} - -const App = ({ element }: Props) => { - const width = useWindowDimensions().width - const store = configureStore({ window: { isMobile: isMobile(width), width: width || undefined } }) - - return ( - - - {element} - - - ) -} - -export default App diff --git a/src/components/about/AboutAnatomy.astro b/src/components/about/AboutAnatomy.astro new file mode 100644 index 00000000..47033a40 --- /dev/null +++ b/src/components/about/AboutAnatomy.astro @@ -0,0 +1,1348 @@ +--- +/** + * "Anatomy of an Entry" section — annotated card with four callouts, + * rotating through curated specimens. Relies on shared section styles + * from about.astro. + */ +import type { CollectionEntry } from 'astro:content'; +import { RELATION_TYPE_LABELS } from '../../lib/constants'; +import { DIMENSION_CONFIG } from '../../lib/catalog/dimensions'; +import Icon from '../icons/Icon.astro'; +import { + ICON_ALERT_CIRCLE, + ICON_RELATIONSHIP_GRAPH, + ICON_PENCIL, + ICON_WRENCH, + ICON_ARROW_RIGHT, + ICON_CODE, + ICON_WARNING, + ICON_EXTERNAL_ARROW, + ICON_BOOK_LINES, + ICON_COPY, +} from '../../lib/icon-paths'; +import { groupRelationsByType } from '../../lib/content/smell-utils'; + +interface Props { + allSmells: CollectionEntry<'smells'>[]; +} + +const CURATED_SPECIMENS: { slug: string; fact: string }[] = [ + { + slug: 'feature-envy', + fact: 'Named by Martin Fowler in 1999. A method that uses another class\u2019s data more than its own \u2014 the most common coupler smell in Java.', + }, + { + slug: 'large-class', + fact: 'God Class is the most frequently cited smell in enterprise Java research. Also called \u201CThe Blob\u201D \u2014 it consumes everything.', + }, + { + slug: 'magic-number', + fact: 'The name comes from the fact that the number appears to work \u201Cby magic\u201D \u2014 nobody knows why it\u2019s 86400 until they multiply 60\u00d760\u00d724.', + }, + { + slug: 'duplicated-code', + fact: 'Fowler called it the single worst smell in 1999. It has more aliases than any other entry in this catalog: Clones, Code Clone, External Duplication.', + }, + { + slug: 'long-method', + fact: 'Also called God Method or Brain Method. The average problematic method has 4\u00d7 the lines and 6\u00d7 the cyclomatic complexity of its neighbors.', + }, + { + slug: 'shotgun-surgery', + fact: 'Named by Fowler in 1999 as the inverse of Divergent Change. One fix, many files. The smell that turns a 5-minute bug into a morning of grep.', + }, +]; + +const REL_LABELS = Object.fromEntries( + Object.entries(RELATION_TYPE_LABELS).map(([k, v]) => [k, v.label]), +); + +const DIM_COLOR = Object.fromEntries(DIMENSION_CONFIG.map((d) => [d.key, d.color])) as Record< + (typeof DIMENSION_CONFIG)[number]['key'], + string +>; + +interface AnatomyTag { + label: string; + color: string; +} + +interface AnatomySmell { + title: string; + slug: string; + category: string; + aka: string | null; + description: string; + tags: AnatomyTag[]; + relationships: string; + refactoring: string; + fact: string; +} + +type ExtractedAnatomySmell = Omit; + +function extract(entry: CollectionEntry<'smells'>): ExtractedAnatomySmell { + const d = entry.data; + const obstruction = d.categories.obstruction[0]; + const hierarchy = d.categories.smell_hierarchies[0]; + const aliases = d.meta.known_as ?? []; + + const relGroups = groupRelationsByType(d.relations.related_smells); + + return { + title: d.meta.title, + slug: d.slug, + category: `${obstruction} \u00b7 ${hierarchy}`, + aka: aliases.length > 0 ? aliases.slice(0, 3).join(', ') : null, + description: d.meta.description ?? '', + tags: [ + { label: obstruction, color: DIM_COLOR.obstruction }, + { label: d.categories.occurrence[0], color: DIM_COLOR.occurrence }, + { label: hierarchy, color: DIM_COLOR.smell_hierarchies }, + { + label: d.categories.expanse === 'Between' ? 'Between Classes' : 'Within Class', + color: DIM_COLOR.expanse, + }, + ], + relationships: Object.entries(relGroups) + .filter(([t]) => REL_LABELS[t]) + .map(([t, names]) => `${REL_LABELS[t]}: ${names.join(', ')}`) + .join('
'), + refactoring: d.refactors.join(', '), + }; +} + +const { allSmells } = Astro.props; +const totalSmells = allSmells.length; +const smellsUnrotated: AnatomySmell[] = CURATED_SPECIMENS.map(({ slug, fact }) => { + const entry = allSmells.find((e) => e.data.slug === slug); + if (!entry) throw new Error(`Curated smell not found: ${slug}`); + return { ...extract(entry), fact }; +}); + +const startIdx = Math.floor(Math.random() * smellsUnrotated.length); +const smells = [...smellsUnrotated.slice(startIdx), ...smellsUnrotated.slice(0, startIdx)]; + +const initial = smells[0]; +const nextTitle = smells[1].title; +const smellsJson = JSON.stringify(smells); +--- + +
+ +

Anatomy of a Smell

+

Every smell gets the same treatment. Here’s what you get.

+ +
+
+ +
+
+
+ +
+
Classified
+
+ Five independent dimensions — filter from any angle +
+
+
+
+ +
+
Cause Chains
+
+ Mapped to the smells it triggers — and the ones that trigger it +
+
+
+ + +
+
+
+
+
{initial.category}
+ + {initial.title} + + +
+ {initial.aka ? `Also known as: ${initial.aka}` : ''} +
+
+
+
{initial.description}
+
+
+ { + initial.tags.map((tag) => ( + + {tag.label} + + )) + } +
+
+
+
Relationships
+
+
+
+
Refactoring
+
{initial.refactoring}
+
+
+
+
+ + +
+
+
+ +
+
Plain Language
+
Written for developers, not academic journals
+
+
+
+ +
+
Fixable
+
+ Concrete refactoring techniques, not just “clean this up” +
+
+
+
+
+ + +
+ 1 / {smells.length} + This is {initial.title}. + +
+ + + + + +
+
+ That’s the surface. Every one of the {totalSmells} goes this deep. +
+ Explore the Catalog → +
+ + +
+ +
+
+ + + + diff --git a/src/components/about/AboutHero.astro b/src/components/about/AboutHero.astro new file mode 100644 index 00000000..85b53a0e --- /dev/null +++ b/src/components/about/AboutHero.astro @@ -0,0 +1,374 @@ +--- +/** + * About page hero. Count-up stats are IntersectionObserver-driven + * (no Preact island needed). + */ +import BookIcon from '../icons/BookIcon.astro'; +import { SPRINGER_PAPER_URL, THESIS_URL } from '../../lib/constants'; +import type { CollectionEntry } from 'astro:content'; + +interface Props { + allSmells: CollectionEntry<'smells'>[]; +} + +const { allSmells } = Astro.props; +const sortedSmells = [...allSmells].sort((a, b) => a.data.slug.localeCompare(b.data.slug)); +const epochDay = Math.floor(Date.now() / 86_400_000); +const dailySmell = sortedSmells[epochDay % sortedSmells.length]; +--- + +
+ + +

The catalog that names
what’s wrong with your code

+ +

+ Built for the developer who knows something’s wrong with the code but can’t name it + yet. {allSmells.length} smells, five dimensions, every relationship mapped. +

+ + + + + +
+
+
0
+
Code Smells
+
+
+
0
+
Dimensions
+
+
+
0
+
Sources
+
+
+
0
+
on Google
+
+
+ +
+ Today’s smell: + {dailySmell.data.meta.title} → +
+
+ + + + diff --git a/src/components/about/AboutOrigin.astro b/src/components/about/AboutOrigin.astro new file mode 100644 index 00000000..2bddbc82 --- /dev/null +++ b/src/components/about/AboutOrigin.astro @@ -0,0 +1,187 @@ +--- +/** + * "Why This Catalog Exists" section. Relies on shared section styles + * from about.astro. + */ +import { SPRINGER_PAPER_URL, THESIS_URL } from '../../lib/constants'; +--- + +
+ +

Why This Catalog Exists

+ +

+ I spent the better part of a year reading every academic paper I could find on code smells. What + I found was frustrating: +

+ +
    +
  • + + Papers buried them in academic prose nobody had time to parse. + +
  • +
  • + + Books scattered them across chapters with no unified view. + +
  • +
  • + + Blog posts covered the same handful — always Long Method, always God Class. + +
  • +
+ +

Nowhere had all of them in one place.

+ +

So I built one.

+ +

+ I turned it into my Master’s thesis at Wroclaw University of Science and Technology, published alongside a Springer Nature research paper. One goal: + read everything, classify everything, and make it all browsable. +

+ +

+ Every entry has a paper trail. Who named it, when, where. Which smells it causes and which cause + it. What it breaks, and how to fix it. +

+ +

+ What I wished existed — five ways to look at every smell, and a map of how they’re + all connected. +

+
+ + diff --git a/src/components/about/AboutResearch.astro b/src/components/about/AboutResearch.astro new file mode 100644 index 00000000..002fd4d4 --- /dev/null +++ b/src/components/about/AboutResearch.astro @@ -0,0 +1,1364 @@ +--- +/** + * "Behind the Catalog" section — paper card with citation block + * (APA/BibTeX/Markdown) and author identity. Relies on shared + * section styles from about.astro. + */ +import { + CITATION, + SPRINGER_PAPER_URL, + SPRINGER_DOI, + AUTHOR_PROFILE_URL, + GITHUB_PROFILE_URL, + PERSONAL_WEBSITE_URL, + GITHUB_REPO, + GITHUB_ISSUES_URL, + ACADEMIC_CITATIONS_FALLBACK, + THESIS_URL, +} from '../../lib/constants'; +import Icon from '../icons/Icon.astro'; +import { + ICON_BOOK_LINES, + ICON_USER, + ICON_CALENDAR, + ICON_EXTERNAL_LINK, + ICON_COPY, + ICON_STAR, + ICON_GRADUATION_CAP, + ICON_TAG, + ICON_CLOCK, + ICON_GITHUB, + ICON_GLOBE, + ICON_LINKEDIN, +} from '../../lib/icon-paths'; + +interface Props { + smellCount: number; +} + +const { smellCount } = Astro.props; + +const markdownCitation = `[Code Smells: A Comprehensive Online Catalog and Taxonomy](${SPRINGER_PAPER_URL}) — *Studies in Systems, Decision and Control, vol 462.* Springer, 2023.`; + +const FETCH_TIMEOUT_MS = 5_000; + +const [starsResult, citationsResult] = await Promise.allSettled([ + fetch(`https://api.github.com/repos/${GITHUB_REPO}`, { + headers: { Accept: 'application/vnd.github.v3+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }).then((r) => (r.ok ? r.json() : null)), + + fetch(`https://api.semanticscholar.org/graph/v1/paper/DOI:${SPRINGER_DOI}?fields=citationCount`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }).then((r) => (r.ok ? r.json() : null)), +]); + +const githubStars = + starsResult.status === 'fulfilled' && starsResult.value?.stargazers_count + ? Number(starsResult.value.stargazers_count) + : 0; + +const academicCitations = + citationsResult.status === 'fulfilled' && citationsResult.value?.citationCount != null + ? Number(citationsResult.value.citationCount) + : ACADEMIC_CITATIONS_FALLBACK; +--- + +
+ +

The Research & the Author

+

+ This whole thing started because I couldn’t find a single place that collected all the + smells. So I spent the better part of a year reading the papers, and published the taxonomy + through Springer Nature. If it’s been useful, the citation goes a long way. +

+ + +
+
+
+ +
+
Springer
+
+
+

+ Code Smells: A Comprehensive Online Catalog and Taxonomy +

+
+ + + Marcel Jerzyk, Lech Madeyski + + + + 2023 + + + + Springer Nature · Studies in Systems, vol 462 + +
+

+ What happens when you read 40+ papers about code smells and organize everything into one + place? {smellCount} named smells across 5 taxonomy dimensions — with every + causal relationship mapped. +

+ + + +
+
+
+ Citation +
+ + + +
+
+
{CITATION.apaText}
+ +
+
+
+ + +
+ { + githubStars > 0 && ( +
+
+ +
+
+ {githubStars} +
+
GitHub stars
+
+ ) + } +
+
+ +
+
+ {academicCitations} +
+
Academic citations
+
+
+
+ +
+
{smellCount}
+
Smells cataloged
+
+
+
+ +
+
40+
+
Papers surveyed
+
+
+ + +
+
+
+ MJ +
+
+
+

+ Papers don’t write themselves. Catalogs don’t either. +

+ + +
+ +
+
+ Marcel Jerzyk +
+
+

Marcel Jerzyk

+
+ Senior Software Engineer & Smell Taxonomist +
+
+ Springer Nature, 2023·Wrocław University + of Science and Technology +
+
+
+ + +
+

+ I read 40 papers about code smells so you don’t have to. Then I built this catalog + because the research deserved better than a PDF nobody opens. +

+
+ +

+ Engineer, researcher, music producer. Pattern recognition is the thread. +

+ + + + +

+ Questions about the taxonomy? + Open an issue or + say hi. +

+ + + +
+
+ + + + diff --git a/src/components/about/AboutSectionDots.astro b/src/components/about/AboutSectionDots.astro new file mode 100644 index 00000000..7c5faecc --- /dev/null +++ b/src/components/about/AboutSectionDots.astro @@ -0,0 +1,173 @@ +--- +/** + * AboutSectionDots — Fixed dot-nav for the about page. + * + * Vertically centered on the right edge, scroll-spy driven. + * Appears after the user scrolls past the hero. Each dot + * scrolls to its section on click and shows a label on hover. + * Hidden on mobile (< 900px) and when prefers-reduced-motion + * is set (instant scroll instead of smooth). + */ + +interface DotSection { + label: string; + target: string; +} + +const sections: DotSection[] = [ + { label: 'Origin', target: 'sec-origin' }, + { label: 'Taxonomy', target: 'sec-taxonomy' }, + { label: 'Inside', target: 'sec-anatomy' }, + { label: 'Research', target: 'sec-research' }, +]; +--- + + + + + + diff --git a/src/components/about/AboutTaxonomy.astro b/src/components/about/AboutTaxonomy.astro new file mode 100644 index 00000000..614861aa --- /dev/null +++ b/src/components/about/AboutTaxonomy.astro @@ -0,0 +1,641 @@ +--- +/** + * "Five Ways to Look at Every Smell" section — dimension cards with + * stretched-link pattern and "+N more" popover for overflow categories. + * Relies on shared section styles from about.astro. + */ +import Icon from '../icons/Icon.astro'; +import { ICON_ARROW_RIGHT } from '../../lib/icon-paths'; +import { getDimension, getDisplayLabel } from '../../lib/catalog/dimensions'; +import type { DimensionKey } from '../../lib/catalog/dimensions'; + +interface ChipItem { + label: string; + value: string; +} + +interface ChipInput { + value: string; + label?: string; +} + +interface DimensionCardShared { + name: string; + dim: string; + color: string; + lightColor: string; + href: string; + iconSvg: string; + description: string; +} + +interface FilterableDimensionCard extends DimensionCardShared { + dimensionKey: DimensionKey; + chips: ChipItem[]; + moreChips?: ChipItem[]; +} + +interface StaticDimensionCard extends DimensionCardShared { + chips: readonly string[]; + dimensionKey?: never; + moreChips?: never; +} + +type DimensionCard = FilterableDimensionCard | StaticDimensionCard; + +function isFilterableDimensionCard(card: DimensionCard): card is FilterableDimensionCard { + return card.dimensionKey != null; +} + +function toCssVar(token: string): string { + return `var(${token})`; +} + +function toChip(dimensionKey: DimensionKey, chip: ChipInput): ChipItem { + return { + value: chip.value, + label: chip.label ?? getDisplayLabel(chip.value, dimensionKey), + }; +} + +function buildFilterableCard(input: { + dimensionKey: DimensionKey; + href: string; + iconSvg: string; + description: string; + chips: ChipInput[]; + moreChips?: ChipInput[]; +}): FilterableDimensionCard { + const dimension = getDimension(input.dimensionKey); + + return { + name: dimension.label, + dim: dimension.urlParam, + color: toCssVar(dimension.color), + lightColor: toCssVar(dimension.lightColor), + href: input.href, + iconSvg: input.iconSvg, + description: input.description, + dimensionKey: input.dimensionKey, + chips: input.chips.map((chip) => toChip(input.dimensionKey, chip)), + moreChips: input.moreChips?.map((chip) => toChip(input.dimensionKey, chip)), + }; +} + +const dimensions: DimensionCard[] = [ + buildFilterableCard({ + dimensionKey: 'expanse', + href: '/#expanse=Within', + iconSvg: + '', + description: 'How far it spreads. One class, or leaking across many.', + chips: [{ value: 'Within' }, { value: 'Between' }], + }), + { + name: 'Severity', + dim: 'severity', + color: 'var(--yellow)', + lightColor: 'var(--yellow-light)', + href: '/', + iconSvg: + '', + description: 'How bad is it? A quick triage call.', + chips: ['Major', 'Minor'], + }, + buildFilterableCard({ + dimensionKey: 'smell_hierarchies', + href: '/#hierarchy=Code+Smell', + iconSvg: + '', + description: 'How deep it runs \u2014 from a single line to the whole architecture.', + chips: [{ value: 'Code Smell' }], + moreChips: [ + { value: 'Design Smell' }, + { value: 'Antipattern' }, + { value: 'Architecture Smell', label: 'Architecture Smell' }, + { value: 'Implementation Smell', label: 'Implementation Smell' }, + { value: 'Linguistic Antipattern' }, + { value: 'Linguistic Smell', label: 'Linguistic Smell' }, + ], + }), + buildFilterableCard({ + dimensionKey: 'obstruction', + href: '/#obstruction=Bloaters', + iconSvg: '', + description: 'What it blocks. Why your code fights back.', + chips: [{ value: 'Bloaters' }, { value: 'Couplers' }, { value: 'Dispensables' }], + moreChips: [ + { value: 'Change Preventers' }, + { value: 'Data Dealers' }, + { value: 'Functional Abusers' }, + { value: 'Lexical Abusers' }, + { value: 'Obfuscators' }, + { value: 'Object Oriented Abusers' }, + { value: 'Other' }, + ], + }), + buildFilterableCard({ + dimensionKey: 'occurrence', + href: '/#occurrence=Data', + iconSvg: + '', + description: 'Where it shows up: data, names, interfaces, conditional logic.', + chips: [{ value: 'Data' }, { value: 'Names' }, { value: 'Interfaces' }], + moreChips: [ + { value: 'Duplication' }, + { value: 'Responsibility' }, + { value: 'Measured Smells' }, + { value: 'Unnecessary Complexity' }, + { value: 'Message Calls', label: 'Message Calls' }, + { value: 'Conditional Logic' }, + ], + }), +]; + +function chipHref(card: FilterableDimensionCard, chip: ChipItem): string { + return `/#${getDimension(card.dimensionKey).urlParam}=${encodeURIComponent(chip.value)}`; +} +--- + +
+ +

Five Ways to Look at Every Smell

+

+ Most lists give you a name and move on. This catalog classifies each smell across + five independent dimensions — start from what’s blocking you, where it + shows up, or how deep it goes. +

+ +
+ { + dimensions.map((d) => ( +
+ +
{d.description}
+
+ {isFilterableDimensionCard(d) + ? d.chips.map((chip) => ( + + {chip.label} + + )) + : d.chips.map((chip) => {chip})} + {d.moreChips?.length && ( + + )} +
+ {isFilterableDimensionCard(d) && d.moreChips?.length && ( +
+ {d.moreChips.map((chip) => ( + + {chip.label} + + ))} +
+ )} +
+ )) + } +
+ +
+

+ “Yes, classifying code smells along five dimensions is perhaps a bit obsessive.” +

+
+
+ + + + diff --git a/src/components/article/ArticleHero.astro b/src/components/article/ArticleHero.astro new file mode 100644 index 00000000..b15237c8 --- /dev/null +++ b/src/components/article/ArticleHero.astro @@ -0,0 +1,422 @@ +--- +/** Article hero — breadcrumb, title, dimension badges, and aliases. */ +import type { SmellFrontmatter } from '../../schemas/smell'; +import { getObstructionTheme } from '../../lib/category-colors'; +import { COPY_FEEDBACK_MS } from '../../lib/copy-code'; +import DimensionTag from '../catalog/DimensionTag.astro'; + +interface Props { + data: SmellFrontmatter; + readTime: number; + sourceCount: number; + description: string; +} + +const { data, readTime, sourceCount, description } = Astro.props; +const { meta, categories } = data; + +const formattedDate = new Date(meta.last_update_date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', +}); + +const obstructionColor = getObstructionTheme(categories.obstruction[0]).color; +--- + +
+ {/* Breadcrumb */} + + +

{meta.title}

+ + { + meta.known_as.length > 0 && ( +

Also known as: {meta.known_as.join(', ')}

+ ) + } + + {/* Tags before description — matches mockup order */} +
+ + { + categories.occurrence.map((occ) => ( + + )) + } + { + categories.smell_hierarchies.map((hier) => ( + + )) + } + +
+ + {description &&

{description}

} + + {/* Meta bar with dividers */} +
+ + + {readTime} min read + + + + + + + { + sourceCount > 0 && ( + + + ) + } + + +
+
+ + + + diff --git a/src/components/article/ArticleSidebar.astro b/src/components/article/ArticleSidebar.astro new file mode 100644 index 00000000..38d641c6 --- /dev/null +++ b/src/components/article/ArticleSidebar.astro @@ -0,0 +1,450 @@ +--- +/** + * ArticleSidebar — Right sidebar with related smells and quick metadata. + * + * Contains: + * - Related smells from frontmatter `relations.related_smells` with relationship type badges + * - Quick metadata (expanse, categories) + * Handles articles with no relations gracefully. + */ +import type { SmellFrontmatter } from '../../schemas/smell'; +import BookIcon from '../icons/BookIcon.astro'; +import Icon from '../icons/Icon.astro'; +import { ICON_WARNING, ICON_WRENCH, ICON_CHEVRON_RIGHT } from '../../lib/icon-paths'; +import { getObstructionTheme } from '../../lib/category-colors'; +import { RELATION_TYPE_LABELS } from '../../lib/constants'; +import { findOriginOrEarliestEntry } from '../../lib/content/smell-utils'; + +interface Props { + data: SmellFrontmatter; +} + +const { data } = Astro.props; +const { categories, relations, problems, refactors, history, meta } = data; + +const relatedSmells = relations.related_smells; + +const SIDEBAR_CARD_THEMES = { + classification: { bg: 'var(--green-light)', fg: 'var(--green)' }, + problems: { bg: 'var(--red-light)', fg: 'var(--red)' }, + refactoring: { bg: 'var(--green-light)', fg: 'var(--green)' }, + provenance: { bg: 'var(--yellow-light)', fg: 'var(--yellow)' }, + related: { bg: 'var(--blue-light)', fg: 'var(--blue)' }, +} as const; + +function toCssVars(theme: (typeof SIDEBAR_CARD_THEMES)[keyof typeof SIDEBAR_CARD_THEMES]): string { + return `--sidebar-icon-bg: ${theme.bg}; --sidebar-icon-fg: ${theme.fg};`; +} + +// SIDE-01: Provenance - find origin entry +const originEntry = findOriginOrEarliestEntry(history); + +// SIDE-04: Formatted update date +const formattedDate = new Date(meta.last_update_date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', +}); +--- + + + + diff --git a/src/components/article/EngagementBar.astro b/src/components/article/EngagementBar.astro new file mode 100644 index 00000000..e87f3941 --- /dev/null +++ b/src/components/article/EngagementBar.astro @@ -0,0 +1,83 @@ +--- +import FeedbackButton from '../engagement/FeedbackButton.astro'; +import ShareButton from '../engagement/ShareButton.astro'; +import CiteToggle from '../engagement/CiteToggle.astro'; +import CitePanel from '../engagement/CitePanel.astro'; +import type { CitationData } from '../../lib/types'; + +interface Props { + citation: CitationData; +} + +const { citation } = Astro.props; +--- + + + + + diff --git a/src/components/article/PrevNextNav.astro b/src/components/article/PrevNextNav.astro new file mode 100644 index 00000000..228337ad --- /dev/null +++ b/src/components/article/PrevNextNav.astro @@ -0,0 +1,238 @@ +--- +/** Previous/next smell navigation. Wraps around at edges of the alphabetical list. */ +import { getObstructionTheme } from '../../lib/category-colors'; +import Icon from '../icons/Icon.astro'; +import { ICON_ARROW_LEFT, ICON_ARROW_RIGHT } from '../../lib/icon-paths'; +import type { SmellLink } from '../../lib/types'; +import type { ObstructionCategory } from '../../lib/constants'; + +interface Props { + prev: SmellLink; + next: SmellLink; + category: ObstructionCategory; +} + +const { prev, next, category } = Astro.props; +const prevColor = getObstructionTheme(prev.obstruction).color; +const nextColor = getObstructionTheme(next.obstruction).color; +--- + +{ + (prev || next) && ( + + ) +} + + diff --git a/src/components/article/ProblemCards.astro b/src/components/article/ProblemCards.astro new file mode 100644 index 00000000..f980e842 --- /dev/null +++ b/src/components/article/ProblemCards.astro @@ -0,0 +1,386 @@ +--- +/** + * ProblemCards — Renders problems with rich descriptions from body extraction. + * + * When body descriptions are available, renders cards with heading + description + * + keyword-matched icon + per-card color. Falls back to frontmatter label-only + * cards when no body descriptions exist. + * + * Deduplicates: if a body card heading matches a frontmatter violation principle, + * the badge is suppressed to avoid showing the same concept twice. + */ +import type { ProblemDescription } from '../../lib/content/extract-problems'; +import type { SmellFrontmatter } from '../../schemas/smell'; + +interface Props { + problems: SmellFrontmatter['problems']; + descriptions: ProblemDescription[]; +} + +const { problems, descriptions } = Astro.props; + +const generalProblems = problems.general; +const violatedPrinciples = problems.violation.principles; +const violatedPatterns = problems.violation.patterns; + +const hasDescriptions = descriptions.length > 0; +const hasAnyProblems = + descriptions.length > 0 || + generalProblems.length > 0 || + violatedPrinciples.length > 0 || + violatedPatterns.length > 0; + +// Per-card color palette — rotates by index for visual rhythm +const CARD_COLORS = [ + { color: 'var(--accent)', lightColor: 'var(--accent-light)' }, + { color: 'var(--red)', lightColor: 'var(--red-light)' }, + { color: 'var(--purple)', lightColor: 'var(--purple-light)' }, + { color: 'var(--blue)', lightColor: 'var(--blue-light)' }, + { color: 'var(--teal)', lightColor: 'var(--teal-light)' }, +]; + +/* + * Keyword-based icon matching. Handles heading variants like: + * "Low Readability", "Decreased Readability", "Hard to Read" → 👁 + * "Duplication", "Duplicated Code" → 📋 + * "Bijection Violation", "Bijection Problems" → ↔ + * "Low Testability", "Hard to Test", "Increased Test Complexity" → 🧪 + * "Reusability", "Low Reuse", "Inability to Reuse" → ♻ + * "Coupling", "Inter Component Coupling" → 🔗 + * "Increased Complexity", "More Complex APIs" → 🧩 + * "Comprehensibility", "Comprehensibility Issues" → 📖 + * "Error-Prone", "Reduced Reliability" → ⚠ + * "Code Pollution" → 🗑 + * "Hard to Understand" → 🧠 + * "Side Effects" → 💥 + * "Encapsulation", "Lack of Encapsulation" → 📦 + * "...Violation", "...Principle" → ⚖ + */ +const ICON_KEYWORD_MAP: Record = { + 'hard to read': '👁', + read: '👁', + duplic: '📋', + repeat: '📋', + bijection: '↔', + test: '🧪', + reuse: '♻', + reusa: '♻', + coupl: '🔗', + complex: '🧩', + comprehensi: '📖', + understand: '🧠', + reason: '🧠', + error: '⚠', + reliab: '⚠', + pollut: '🗑', + 'side effect': '💥', + encapsul: '📦', + indirection: '🔀', + cohesion: '🧲', + extend: '🔧', + hidden: '🔍', + abstraction: '🔍', + overload: '📡', + information: '📡', + 'flow state': '🌊', + violation: '⚖', + principle: '⚖', +}; + +const ICON_KEYWORDS = Object.keys(ICON_KEYWORD_MAP).sort((a, b) => b.length - a.length); + +function getIcon(heading: string): string { + const lower = heading.toLowerCase(); + for (const keyword of ICON_KEYWORDS) { + if (lower.includes(keyword)) return ICON_KEYWORD_MAP[keyword]; + } + return '⚠'; +} + +// Deduplicate: suppress violation badges that already appear as body cards +function fuzzyMatch(bodyHeading: string, violationLabel: string): boolean { + const h = bodyHeading.toLowerCase(); + const v = violationLabel.toLowerCase(); + return h.includes(v) || v.includes(h); +} + +const bodyHeadings = descriptions.map((d) => d.heading); +const dedupedPrinciples = violatedPrinciples.filter( + (p) => !bodyHeadings.some((h) => fuzzyMatch(h, p)), +); +const dedupedPatterns = violatedPatterns.filter((p) => !bodyHeadings.some((h) => fuzzyMatch(h, p))); +--- + +{ + hasAnyProblems && ( +
+

+ Problems +

+ + {hasDescriptions && ( +
+ {descriptions.map((desc, index) => { + const { color, lightColor } = CARD_COLORS[index % CARD_COLORS.length]; + return ( +
+
{getIcon(desc.heading)}
+
+ {desc.headingHref ? ( + + {desc.heading} + + ) : ( + {desc.heading} + )} + {desc.descriptionHtml && ( +

+ )} +

+
+ ); + })} +
+ )} + + {!hasDescriptions && generalProblems.length > 0 && ( +
+ {generalProblems.map((problem, index) => { + const { color, lightColor } = CARD_COLORS[index % CARD_COLORS.length]; + return ( +
+
{getIcon(problem)}
+
+ {problem} +
+
+ ); + })} +
+ )} + + {(dedupedPrinciples.length > 0 || dedupedPatterns.length > 0) && ( +
+ {dedupedPrinciples.length > 0 && ( +
+ Violated Principles +
+ {dedupedPrinciples.map((p) => ( + {p} + ))} +
+
+ )} + {dedupedPatterns.length > 0 && ( +
+ Violated Patterns +
+ {dedupedPatterns.map((p) => ( + {p} + ))} +
+
+ )} +
+ )} +
+ ) +} + + diff --git a/src/components/article/RefactoringList.astro b/src/components/article/RefactoringList.astro new file mode 100644 index 00000000..4e9d028e --- /dev/null +++ b/src/components/article/RefactoringList.astro @@ -0,0 +1,113 @@ +--- +interface Props { + refactors: string[]; +} + +const { refactors } = Astro.props; +--- + +{ + refactors.length > 0 && ( +
+

+ Refactoring +

+
    + {refactors.map((refactor) => ( +
  • +
  • + ))} +
+
+ ) +} + + diff --git a/src/components/article/SourcesList.astro b/src/components/article/SourcesList.astro new file mode 100644 index 00000000..55629e5b --- /dev/null +++ b/src/components/article/SourcesList.astro @@ -0,0 +1,164 @@ +--- +import type { HistoryEntry } from '../../lib/content/smell-utils'; + +interface Props { + history: HistoryEntry[]; +} + +const { history } = Astro.props; + +function presentSource(entry: HistoryEntry) { + const href = entry.source.href; + + let url: string | undefined; + if (href) { + if (href.direct_url && !href.direct_url.includes('TBA')) url = href.direct_url; + else if (href.isbn_13) + url = `https://www.google.com/search?q=ISBN+${href.isbn_13.replace(/-/g, '')}`; + else if (href.isbn_10) url = `https://www.google.com/search?q=ISBN+${href.isbn_10}`; + } + + return { + url, + badge: entry.type ? entry.type.toUpperCase() : 'SOURCE', + isbn: href?.isbn_13 || href?.isbn_10 || undefined, + }; +} +--- + +{ + history.length > 0 && ( +
+

+ Sources +

+
    + {history.map((entry) => { + const { url, badge, isbn } = presentSource(entry); + const origin = entry.type === 'origin'; + return ( +
  • + {badge} +
    +
    {entry.author}
    +
    + {url ? ( + + {entry.source.name} + + ) : ( + {entry.source.name} + )} +
    +
    + {entry.source.year} + {isbn && · ISBN {isbn}} +
    +
    +
  • + ); + })} +
+
+ ) +} + + diff --git a/src/components/article/TableOfContents.astro b/src/components/article/TableOfContents.astro new file mode 100644 index 00000000..b6ba7508 --- /dev/null +++ b/src/components/article/TableOfContents.astro @@ -0,0 +1,291 @@ +--- +/** + * TableOfContents — Sticky sidebar with heading links and scroll spy. + * + * Uses IntersectionObserver via inline script for active heading tracking. + * Registers cleanup via astro:before-swap for View Transition navigation. + * Only renders if there are headings to show. + */ +import type { MarkdownHeading } from 'astro'; +import Icon from '../icons/Icon.astro'; +import { ICON_CHEVRON_LEFT, ICON_CHEVRON_RIGHT } from '../../lib/icon-paths'; +import type { SmellLink } from '../../lib/types'; + +interface Props { + headings: MarkdownHeading[]; + prev: SmellLink; + next: SmellLink; +} + +const { headings, prev, next } = Astro.props; + +// Filter to h2 and h3 only +const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3); +--- + +{ + tocHeadings.length > 0 && ( + + ) +} + + + + diff --git a/src/components/catalog/BrowseNav.astro b/src/components/catalog/BrowseNav.astro new file mode 100644 index 00000000..7268ebbb --- /dev/null +++ b/src/components/catalog/BrowseNav.astro @@ -0,0 +1,160 @@ +--- +import GridIcon from '../icons/GridIcon.astro'; + +interface Props { + totalSmells: number; + allSlugs: string[]; + currentSlug: string; +} + +const { totalSmells, allSlugs, currentSlug } = Astro.props; +--- + +
+ + + Browse All {totalSmells} Smells + + + +
+ + + + diff --git a/src/components/catalog/CatalogHero.astro b/src/components/catalog/CatalogHero.astro new file mode 100644 index 00000000..733f3cbe --- /dev/null +++ b/src/components/catalog/CatalogHero.astro @@ -0,0 +1,208 @@ +--- +import BookIcon from '../icons/BookIcon.astro'; +import { SPRINGER_PAPER_URL } from '../../lib/constants'; + +interface Props { + totalCount: number; + categoryCount: number; + hierarchyCount: number; +} + +const { totalCount, categoryCount, hierarchyCount } = Astro.props; +--- + +
+ {/* HERO-01: Fixed hero title */} +

+ A Curated Catalog of Code Smells +

+ + {/* HERO-02: Fixed tagline */} +

+ Every named code smell in one place — classified, connected, and fixable. +

+ + {/* HERO-03 + HERO-04: Interactive stats with corrected labels */} + + + {/* HERO-05: Springer Nature trust badge */} + + + Companion to peer-reviewed research published by Springer Nature + +
+ + diff --git a/src/components/catalog/DimensionTag.astro b/src/components/catalog/DimensionTag.astro new file mode 100644 index 00000000..6408428b --- /dev/null +++ b/src/components/catalog/DimensionTag.astro @@ -0,0 +1,59 @@ +--- +/** + * DimensionTag — Shared tag/pill for dimension values. + * + * Two variants with different interaction contracts: + * - "filter": + ) : ( + + + ) +} + + diff --git a/src/components/catalog/SmellCard.astro b/src/components/catalog/SmellCard.astro new file mode 100644 index 00000000..716097ff --- /dev/null +++ b/src/components/catalog/SmellCard.astro @@ -0,0 +1,521 @@ +--- +/** + * Catalog card. Uses data-slug / data-obstruction for filter-engine + * visibility toggling and a stretched title link for full-card click area. + */ +import { getObstructionTheme } from '../../lib/category-colors'; +import type { SmellCardData } from '../../lib/types'; +import BookIcon from '../icons/BookIcon.astro'; +import Icon from '../icons/Icon.astro'; +import { ICON_WRENCH, ICON_SHARE, ICON_LINK, ICON_CHECK } from '../../lib/icon-paths'; +import DimensionTag from './DimensionTag.astro'; +import { SITE_URL } from '../../lib/constants'; + +type Props = SmellCardData; + +const { + slug, + title, + knownAs, + obstruction, + smellHierarchies, + expanse, + description, + refactorCount, + relatedCount, + originInfo, +} = Astro.props; + +const primaryObstruction = obstruction[0]; +const { color: cardColor, initial: avatarInitial } = getObstructionTheme(primaryObstruction); +const normalizedTitle = title.trim().toLowerCase(); +const filteredAliases = knownAs + .map((alias: string) => alias.trim()) + .filter((alias: string) => alias && alias.toLowerCase() !== normalizedTitle); +const maxAliasesOnCard = 2; +const displayAliases = filteredAliases.slice(0, maxAliasesOnCard); +const extraAliasCount = Math.max(filteredAliases.length - displayAliases.length, 0); +const smellUrl = `/smells/${slug}`; +const shareUrl = `${SITE_URL}${smellUrl}`; +--- + +
+
+ +
+

+ + {title} + +

+ { + displayAliases.length > 0 && ( +

+ aka {displayAliases.join(', ')} + {extraAliasCount > 0 && ( + +{extraAliasCount} more + )} +

+ ) + } +
+ +
+ + {description &&

{description}

} + +
+ { + obstruction.map((cat: string) => ( + + )) + } + { + smellHierarchies.map((h: string) => ( + + )) + } + +
+ +
+ + + {refactorCount} + + + + {relatedCount} + + + { + originInfo && ( + + + {originInfo} + + ) + } +
+
+ + diff --git a/src/components/engagement/CitePanel.astro b/src/components/engagement/CitePanel.astro new file mode 100644 index 00000000..2cc6e8e0 --- /dev/null +++ b/src/components/engagement/CitePanel.astro @@ -0,0 +1,200 @@ +--- +import type { CitationData } from '../../lib/types'; +interface Props { + citation: CitationData; +} + +import Icon from '../icons/Icon.astro'; +import { ICON_COPY } from '../../lib/icon-paths'; +import { SITE_URL } from '../../lib/constants'; + +const { citation } = Astro.props; +const { slug, title, author, year } = citation; +const url = `${SITE_URL}/smells/${slug}`; +--- + + + + + + diff --git a/src/components/engagement/CiteToggle.astro b/src/components/engagement/CiteToggle.astro new file mode 100644 index 00000000..122f6de1 --- /dev/null +++ b/src/components/engagement/CiteToggle.astro @@ -0,0 +1,80 @@ +--- +import BookIcon from '../icons/BookIcon.astro'; +--- + +
+ +
+ + + + diff --git a/src/components/engagement/FeedbackButton.astro b/src/components/engagement/FeedbackButton.astro new file mode 100644 index 00000000..2317d7fe --- /dev/null +++ b/src/components/engagement/FeedbackButton.astro @@ -0,0 +1,222 @@ +--- +interface Props { + slug: string; +} + +const { slug } = Astro.props; +--- + +
+ Was this helpful? + + + Thanks for the feedback! + Thanks — help us improve +
+ + + + diff --git a/src/components/engagement/ShareButton.astro b/src/components/engagement/ShareButton.astro new file mode 100644 index 00000000..ed3b1df8 --- /dev/null +++ b/src/components/engagement/ShareButton.astro @@ -0,0 +1,247 @@ +--- +import Icon from '../icons/Icon.astro'; +import { ICON_SHARE, ICON_LINK } from '../../lib/icon-paths'; + +interface Props { + slug: string; +} + +const { slug } = Astro.props; +const shareDropdownId = `share-dropdown-${slug}`; +--- + +
+ + +
+ + + + diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx deleted file mode 100644 index 3e45faaf..00000000 --- a/src/components/header/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import config from "config" -import useWindowDimensions from "hooks/UseWindowDimensions" -import React from "react" -import { isMobile } from "utils/isMobile" - -import AlternateEmailIcon from "@mui/icons-material/AlternateEmail" -import HelpIcon from "@mui/icons-material/Help" -import HomeIcon from "@mui/icons-material/Home" -import MenuBookIcon from "@mui/icons-material/MenuBook" -import ArticleIcon from "@mui/icons-material/Article" -import { AppBar, IconButton, Toolbar, Typography } from "@mui/material" -import createStyles from "@mui/styles/createStyles" -import makeStyles from "@mui/styles/makeStyles" - -const useStyles = makeStyles(() => - createStyles({ - root: { - flexGrow: 1, - }, - title: { - flexGrow: 1, - }, - }) -) - -export interface Props { - ButtonOpenSidebar: JSX.Element -} - -function Header({ ButtonOpenSidebar }: Props): JSX.Element { - const classes = useStyles() - - const currentWidth = useWindowDimensions().width - - return ( - - - {ButtonOpenSidebar} - - - - - {isMobile(currentWidth) ? config.site.title.letterer : config.site.title.full} - - - - - - - - - - - - - - - - ) -} - -export default Header diff --git a/src/components/icons/BookIcon.astro b/src/components/icons/BookIcon.astro new file mode 100644 index 00000000..95165d78 --- /dev/null +++ b/src/components/icons/BookIcon.astro @@ -0,0 +1,24 @@ +--- +interface Props { + size: number; + class: string; + strokeWidth: number; +} + +const { size, class: className, strokeWidth } = Astro.props; +--- + + diff --git a/src/components/icons/ChevronDownIcon.astro b/src/components/icons/ChevronDownIcon.astro new file mode 100644 index 00000000..6ee532d3 --- /dev/null +++ b/src/components/icons/ChevronDownIcon.astro @@ -0,0 +1,23 @@ +--- +interface Props { + size?: number; + class?: string; + strokeWidth?: number; +} + +const { size = 12, class: className, strokeWidth = 2.5 } = Astro.props; +--- + + diff --git a/src/components/icons/ExternalLinkIcon.astro b/src/components/icons/ExternalLinkIcon.astro new file mode 100644 index 00000000..29b1bc22 --- /dev/null +++ b/src/components/icons/ExternalLinkIcon.astro @@ -0,0 +1,23 @@ +--- +interface Props { + size: number; + class: string; +} + +const { size, class: className } = Astro.props; +--- + + diff --git a/src/components/icons/GridIcon.astro b/src/components/icons/GridIcon.astro new file mode 100644 index 00000000..1b151995 --- /dev/null +++ b/src/components/icons/GridIcon.astro @@ -0,0 +1,26 @@ +--- +interface Props { + size: number; + class: string; + strokeWidth: number; +} + +const { size, class: className, strokeWidth } = Astro.props; +--- + + diff --git a/src/components/icons/Icon.astro b/src/components/icons/Icon.astro new file mode 100644 index 00000000..ad1b8bfb --- /dev/null +++ b/src/components/icons/Icon.astro @@ -0,0 +1,34 @@ +--- +/** + * Generic icon component — renders an IconDef as an . + * + * Since this component has no - {`- `} - { - - {related_smell.name} - - } - {` (${related_smell.type.join(", ")})`} -
-
- )) - - const historyText: JSX.Element[] = codeSmell.history.map((history, index) => ( - - {`${history.author} in ${history.source.type} (${history.source.year}):\n"${history.source.name}"`} - {index !== codeSmell.history.length - 1 ? ( - <> -
-
- - ) : ( - "" - )} -
- )) - - return ( - - } - text={codeSmell.meta.known_as.join("\n")} - /> - } - text={codeSmell.categories.obstruction.join(" / ")} - /> - } - text={codeSmell.categories.occurrence.join(" / ")} - /> - } - text={codeSmell.categories.expanse === "Between" ? "Between Classes" : "Within a Class"} - /> - } text={relatedSmellText} /> - } text={historyText} /> - - ) -} - -export default SmellInformationBox diff --git a/src/components/information-box/info-item/index.tsx b/src/components/information-box/info-item/index.tsx deleted file mode 100644 index 11fd8d92..00000000 --- a/src/components/information-box/info-item/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { ReactElement } from "react" -import { getCapitalizedLabel } from "utils/getCapitalizedLabel" -import { SMELL_PAGE_FONTS } from "utils/theme" - -import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material" -import createStyles from "@mui/styles/createStyles" -import makeStyles from "@mui/styles/makeStyles" - -const useStyles = makeStyles(() => - createStyles({ - root: { - alignItems: "flex-start", - }, - icon: { - backgroundColor: "#bfbfbf", - color: "#3b3b3b", - }, - listItemText: { - whiteSpace: "pre-line", - "& > span": { - fontWeight: "600", - fontFamily: SMELL_PAGE_FONTS, - }, - "& p": { - fontWeight: "600", - fontFamily: SMELL_PAGE_FONTS, - }, - "& a": { - fontWeight: "600", - color: "#000083", - fontFamily: SMELL_PAGE_FONTS, - transition: "all 115ms ease-in-out", - "&:hover": { - textShadow: "0 0 5px #00008357", - }, - }, - }, - }) -) - -interface Props { - section: string - keyName: string - icon: ReactElement - text: string | JSX.Element[] | JSX.Element -} - -const SmellInformationBoxListItem = ({ section, keyName, icon, text }: Props) => { - const classes = useStyles() - - return ( - - - {icon} - - - - ) -} - -export default SmellInformationBoxListItem diff --git a/src/components/islands/CodeExample.css b/src/components/islands/CodeExample.css new file mode 100644 index 00000000..85659c4f --- /dev/null +++ b/src/components/islands/CodeExample.css @@ -0,0 +1,794 @@ +/* ============================================ + CodeExample — BEM-namespaced styles + Interactive code comparison island. + Matched to mockup-all.html visual treatment. + ============================================ */ + +/* Container */ +.code-example { + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--surface); + box-shadow: var(--shadow-sm); + margin: 2rem 0; + transition: box-shadow var(--duration-normal) var(--ease-smooth); +} + +.code-example:hover { + box-shadow: var(--shadow-md); +} + +.code-example--fallback { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--surface); + margin: 2rem 0; +} + +/* Caption with crossfade for tab-aware text */ +.code-example__caption { + padding: 16px 24px 14px 22px; + font-family: var(--font-body); + font-size: clamp(0.84rem, 0.78rem + 0.3vw, 0.95rem); + font-weight: 500; + font-style: italic; + color: var(--text-secondary); + line-height: 1.6; + background: color-mix(in srgb, var(--accent) 3.5%, var(--surface-raised)); + border-bottom: 1px solid var(--border-subtle); + border-left: 3.5px solid color-mix(in srgb, var(--accent) 45%, transparent); + transition: + opacity 0.22s var(--ease-smooth), + transform 0.22s var(--ease-smooth), + filter 0.22s var(--ease-smooth); +} + +.code-example__caption--fading { + opacity: 0; + transform: translateY(-3px); + filter: blur(2px); +} + +[data-theme='dark'] .code-example__caption { + background: color-mix(in srgb, var(--accent) 5%, var(--surface-raised)); + border-left-color: color-mix(in srgb, var(--accent) 35%, transparent); +} + +/* Header with segmented toggle */ +.code-example__header { + border-bottom: 1px solid var(--border); + display: flex; + align-items: stretch; + background: var(--surface-raised); +} + +/* Segmented Toggle */ +.code-toggle { + display: flex; + position: relative; + flex: 1; +} + +.code-segment { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px 20px; + border: none; + background: transparent; + cursor: pointer; + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: 600; + letter-spacing: 0.01em; + transition: + color var(--duration-fast) var(--ease-smooth), + background var(--duration-fast) var(--ease-smooth); + position: relative; + z-index: 1; +} + +.code-segment--smelly { + color: color-mix(in srgb, var(--red) 78%, var(--text-primary)); + background: color-mix(in srgb, var(--red) 3%, var(--surface-raised)); +} + +.code-segment--smelly:hover { + color: color-mix(in srgb, var(--red) 88%, var(--text-primary)); + background: color-mix(in srgb, var(--red) 6%, var(--surface-raised)); +} + +.code-segment--smelly.code-segment--active { + color: color-mix(in srgb, var(--red) 88%, var(--text-primary)); + font-weight: 700; + background: color-mix(in srgb, var(--red) 5%, var(--surface-raised)); +} + +.code-segment--solution { + color: color-mix(in srgb, var(--green) 82%, var(--text-primary)); + background: color-mix(in srgb, var(--green) 3%, var(--surface-raised)); +} + +.code-segment--solution:hover { + color: color-mix(in srgb, var(--green) 90%, var(--text-primary)); + background: color-mix(in srgb, var(--green) 6%, var(--surface-raised)); +} + +.code-segment--solution.code-segment--active { + color: color-mix(in srgb, var(--green) 90%, var(--text-primary)); + font-weight: 700; + background: color-mix(in srgb, var(--green) 5%, var(--surface-raised)); +} + +.code-segment__icon { + font-size: 13px; + line-height: 1; +} + +/* Toggle indicator bar */ +.code-toggle__indicator { + position: absolute; + bottom: 0; + left: 0; + width: 50%; + height: 2.5px; + border-radius: 1.5px; + background: var(--accent); + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + pointer-events: none; + z-index: 2; +} + +.code-segment--smelly.code-segment--active ~ .code-toggle__indicator { + background: var(--red); +} + +.code-segment--solution.code-segment--active ~ .code-toggle__indicator { + background: var(--green); +} + +/* Panels container */ +.code-example__panels { + position: relative; + overflow: hidden; + transition: background 0.4s var(--ease-smooth); +} + +.code-example__panels--smelly { + background: color-mix(in srgb, var(--red) 1.5%, transparent); +} + +.code-example__panels--solution { + background: color-mix(in srgb, var(--green) 1.5%, transparent); +} + +/* Individual panel */ +.code-panel { + padding: 0; + position: relative; +} + +.code-panel--smelly { + --panel-bg: color-mix(in srgb, var(--red) 2.5%, var(--surface)); + background: var(--panel-bg); + border-left: 3px solid var(--red); +} + +.code-panel--solution { + --panel-bg: color-mix(in srgb, var(--green) 2.5%, var(--surface)); + background: var(--panel-bg); + border-left: 3px solid var(--green); +} + +/* Subtle diagonal stripe texture on smelly panels */ +.code-panel--smelly::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 10px, + color-mix(in srgb, var(--red) 30%, var(--text-tertiary)) 10px, + color-mix(in srgb, var(--red) 30%, var(--text-tertiary)) 10.5px + ); + opacity: 0.045; + pointer-events: none; + z-index: 0; +} + +/* Override Shiki pre/code styles inside panels */ +.code-panel pre { + margin: 0; + border-radius: 0; + background: transparent !important; + padding: 22px 26px; + font-size: clamp(12px, 1.2vw + 8px, 13.5px); + line-height: 1.65; + overflow-x: auto; + position: relative; + z-index: 1; +} + +.code-panel code { + font-family: var(--font-mono); + font-size: inherit; + font-feature-settings: + 'liga' 1, + 'calt' 1; +} + +/* Slide animations */ +@keyframes panelSlideInRight { + from { + opacity: 0; + transform: translateX(16px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes panelSlideInLeft { + from { + opacity: 0; + transform: translateX(-16px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.code-panel--slide-right { + animation: panelSlideInRight 0.3s var(--ease-out) both; +} + +.code-panel--slide-left { + animation: panelSlideInLeft 0.3s var(--ease-out) both; +} + +/* Utility bar */ +.code-utility-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 6px 16px; + border-top: 1px solid var(--border-subtle); + background: var(--surface); +} + +.code-utility-bar__lang { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 3px 8px; + background: var(--surface-hover); + border-radius: 4px; + margin-right: auto; +} + +.code-utility-bar__copy { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-secondary); + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 500; + cursor: pointer; + transition: + color var(--duration-fast), + background var(--duration-fast), + border-color var(--duration-fast), + transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.code-utility-bar__copy:hover { + color: var(--text-secondary); + background: var(--surface-hover); + border-color: var(--border); +} + +.code-utility-bar__copy:active { + transform: scale(0.88); +} + +.code-utility-bar__copy--copied { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 30%, transparent); + background: var(--green-light); +} + +/* ============================================ + Dark mode + ============================================ */ + +[data-theme='dark'] .code-example__panels--smelly { + background: color-mix(in srgb, var(--red) 2.5%, transparent); +} + +[data-theme='dark'] .code-example__panels--solution { + background: color-mix(in srgb, var(--green) 2.5%, transparent); +} + +[data-theme='dark'] .code-panel--smelly { + --panel-bg: color-mix(in srgb, var(--red) 5.5%, var(--bg)); + background: var(--panel-bg); +} + +[data-theme='dark'] .code-panel--solution { + --panel-bg: color-mix(in srgb, var(--green) 5.5%, var(--bg)); + background: var(--panel-bg); +} + +[data-theme='dark'] .code-segment--smelly { + color: color-mix(in srgb, var(--red) 76%, var(--text-primary)); + background: color-mix(in srgb, var(--red) 5%, var(--surface-raised)); +} + +[data-theme='dark'] .code-segment--solution { + color: color-mix(in srgb, var(--green) 78%, var(--text-primary)); + background: color-mix(in srgb, var(--green) 5%, var(--surface-raised)); +} + +[data-theme='dark'] .code-segment--smelly.code-segment--active { + background: color-mix(in srgb, var(--red) 12%, var(--surface-raised)); +} + +[data-theme='dark'] .code-segment--solution.code-segment--active { + background: color-mix(in srgb, var(--green) 12%, var(--surface-raised)); +} + +[data-theme='dark'] .code-utility-bar { + background: var(--bg); +} + +/* ============================================ + Compare toggle button + ============================================ */ + +.code-example__compare-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border: none; + background: var(--surface-raised); + color: var(--text-secondary); + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + border-left: 1px solid var(--border-subtle); + transition: + color var(--duration-fast), + background var(--duration-fast); +} + +.code-example__compare-btn:hover { + color: var(--text-secondary); + background: var(--surface-hover); +} + +.code-example__compare-btn--active { + color: var(--accent); + background: var(--accent-light); + border-left: none; +} + +/* ============================================ + Compare mode — stacked vertical panels + ============================================ */ + +.code-example__compare-panels { + display: flex; + flex-direction: column; +} + +.code-example__compare-section { + width: 100%; +} + +/* Panel label bar — lightweight label + icon-only copy */ +.code-example__panel-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 14px; + border-bottom: 1px solid var(--border-subtle); +} + +.code-example__panel-bar--smelly { + background: color-mix(in srgb, var(--red) 6%, var(--surface-raised)); + border-top: 1px solid var(--border-subtle); +} + +.code-example__panel-bar--solution { + background: color-mix(in srgb, var(--green) 6%, var(--surface-raised)); +} + +.code-example__panel-bar-label { + display: flex; + align-items: center; + gap: 5px; + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 500; + cursor: default; + user-select: none; +} + +.code-example__panel-bar--smelly .code-example__panel-bar-label { + color: var(--red); +} + +.code-example__panel-bar--solution .code-example__panel-bar-label { + color: var(--green); +} + +.code-example__panel-bar-actions { + display: flex; + align-items: center; + gap: 4px; +} + +/* Icon-only copy button in panel bars */ +.code-example__panel-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + transition: + color var(--duration-fast), + background var(--duration-fast), + border-color var(--duration-fast), + transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.code-example__panel-copy:hover { + color: var(--text-secondary); + background: var(--surface-hover); + border-color: var(--border); +} + +.code-example__panel-copy:active { + transform: scale(0.88); +} + +.code-example__panel-copy--copied { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 30%, transparent); + background: var(--green-light); +} + +/* Compare button when inside panel bar (no left border, smaller) */ +.code-example__panel-bar .code-example__compare-btn { + border-left: none; + padding: 4px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + font-size: 11px; +} + +.code-example__panel-bar .code-example__compare-btn--active { + border-color: color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* Solution wrapper — grid-based reveal/collapse animation */ +.code-example__solution-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 250ms var(--ease-smooth); +} + +.code-example__solution-wrapper.is-visible { + grid-template-rows: 1fr; +} + +.code-example__solution-wrapper-inner { + overflow: hidden; +} + +/* Compare transform divider */ +.code-example__compare-divider { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px; + animation: compareDividerFadeIn 150ms ease-out 100ms both; +} + +.code-example__compare-divider-line { + flex: 1; + height: 1px; + background: var(--border-subtle); +} + +.code-example__compare-divider-pill { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--surface-raised); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + flex-shrink: 0; +} + +@keyframes compareDividerFadeIn { + from { + opacity: 0; + transform: scale(0.85); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Dark mode for compare elements */ +[data-theme='dark'] .code-example__panel-bar--smelly { + background: color-mix(in srgb, var(--red) 8%, var(--bg)); +} + +[data-theme='dark'] .code-example__panel-bar--solution { + background: color-mix(in srgb, var(--green) 8%, var(--bg)); +} + +[data-theme='dark'] .code-example__panel-copy { + background: var(--bg); +} + +[data-theme='dark'] .code-example__compare-divider-pill { + background: var(--bg); +} + +/* Line numbers */ +.code-panel .line { + display: inline-block; + width: 100%; +} + +.code-panel .line-number { + display: inline-block; + width: 2.5ch; + margin-right: 20px; + padding-right: 12px; + border-right: 1px solid var(--border-subtle); + text-align: right; + color: var(--text-secondary); + opacity: 0.58; + user-select: none; + -webkit-user-select: none; + font-size: 0.85em; +} + +/* ============================================ + Scroll-overflow gradient (right edge fade) + ============================================ */ + +.code-panel-wrap { + position: relative; +} + +.code-panel-wrap::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 48px; + background: linear-gradient(90deg, transparent, var(--panel-bg, var(--surface))); + pointer-events: none; + opacity: 0; + transition: opacity 0.25s; + z-index: 1; +} + +.code-panel-wrap--has-overflow::after { + opacity: 1; +} + +.code-panel-wrap--has-overflow.code-panel-wrap--first-reveal::after { + animation: overflowBreath 0.8s ease-in-out 2; +} + +@keyframes overflowBreath { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +.code-panel-wrap--scrolled-end::after { + opacity: 0; +} + +[data-theme='dark'] .code-panel-wrap::after { + background: linear-gradient(90deg, transparent, var(--panel-bg, var(--bg))); +} + +/* ============================================ + Smelly-only variant + ============================================ */ + +.code-example--smelly-only .code-example__header--smelly-only { + display: flex; + align-items: center; + background: var(--surface-raised); + border-bottom: 1px solid var(--border); +} + +.code-example__smelly-label { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 20px; + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: 700; + color: var(--red); + background: color-mix(in srgb, var(--red) 8%, var(--surface-raised)); + flex: 1; + justify-content: center; +} + +/* ============================================ + Inline code markers (@@token@@ annotations) + ============================================ */ + +.code-panel .smell-mark { + text-decoration: wavy underline var(--red); + text-decoration-skip-ink: none; + text-underline-offset: 3px; + background: color-mix(in srgb, var(--red) 8%, transparent); + border-radius: 2px; + padding: 0 1px; + animation: smellReveal 0.4s var(--ease-out) both; + animation-delay: var(--mark-delay, 0.3s); +} + +.code-panel .fix-mark { + background: color-mix(in srgb, var(--green) 14%, transparent); + border-radius: 3px; + padding: 1px 4px; + animation: fixReveal 0.4s var(--ease-out) both; + animation-delay: var(--mark-delay, 0.3s); +} + +@keyframes smellReveal { + from { + opacity: 0; + text-decoration-color: transparent; + background: transparent; + } + to { + opacity: 1; + } +} + +@keyframes fixReveal { + from { + opacity: 0; + background: transparent; + } + to { + opacity: 1; + } +} + +[data-theme='dark'] .code-panel .smell-mark { + background: color-mix(in srgb, var(--red) 12%, transparent); +} + +[data-theme='dark'] .code-panel .fix-mark { + background: color-mix(in srgb, var(--green) 10%, transparent); +} + +/* ============================================ + Screen-reader only: defined globally in global.css + ============================================ */ + +/* ============================================ + Reduced motion + ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .code-panel--slide-right, + .code-panel--slide-left { + animation: none; + opacity: 1; + transform: none; + } + + .code-toggle__indicator { + transition: none; + } + + .code-example__caption { + transition: none; + } + + .code-example__caption--fading { + opacity: 0; + transform: none; + filter: none; + } + + .code-panel .smell-mark, + .code-panel .fix-mark { + animation: none !important; + opacity: 1; + } + + .code-panel .smell-mark { + text-decoration-color: var(--red); + } + + .code-panel .fix-mark { + background: color-mix(in srgb, var(--green) 14%, transparent); + } + + .code-example__solution-wrapper { + transition: none; + } + + .code-example__compare-divider { + animation: none; + opacity: 1; + } +} + +/* ============================================ + Mobile adjustments + ============================================ */ + +@media (max-width: 599px) { + .code-segment { + min-height: 48px; + font-size: var(--text-base); + } + + .code-panel pre { + padding: 16px 14px; + } + + .code-utility-bar__lang { + display: none; + } + + .code-example__compare-btn { + display: none; + } + + .code-example__caption { + font-size: var(--text-xs); + padding: 10px 16px 10px 14px; + } +} diff --git a/src/components/islands/CodeExample.tsx b/src/components/islands/CodeExample.tsx new file mode 100644 index 00000000..69832340 --- /dev/null +++ b/src/components/islands/CodeExample.tsx @@ -0,0 +1,336 @@ +/** + * Preact island for interactive Smelly/Solution code comparison (client:visible). + * + * Compare mode stacks panels vertically — works because examples are short + * (3-8 lines); revisit if examples grow. Props receive pre-rendered Shiki HTML. + */ +import { useState, useCallback, useRef, useEffect } from 'preact/hooks'; +import { Component } from 'preact'; +import type { ComponentChildren } from 'preact'; +import { SegmentedToggle } from './code-example/SegmentedToggle'; +import { CodePanel } from './code-example/CodePanel'; +import { UtilityBar } from './code-example/UtilityBar'; +import { CompareTransformDivider } from './code-example/CompareTransformDivider'; +import { PanelCopyButton } from './code-example/PanelCopyButton'; +import { trackEvent } from '../../lib/analytics/tracker'; +import type { CodePanelData, CodeTab } from '../../lib/types'; +import { cx } from '../../lib/cx'; +import './CodeExample.css'; + +const COMPARE_TRANSITION_FALLBACK_MS = 400; +const CAPTION_CROSSFADE_MS = 180; +const SLIDE_ANIMATION_MS = 300; + +/** + * Manages the grid-based reveal/collapse animation for the solution panel + * when entering/exiting compare mode. + */ +function useCompareTransition( + compareMode: boolean, + wrapperRef: { current: HTMLDivElement | null }, + isAnimatingRef: { current: boolean }, +) { + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) { + isAnimatingRef.current = false; + return; + } + if (!compareMode) { + isAnimatingRef.current = false; + return; + } + + requestAnimationFrame(() => { + wrapper.classList.add('is-visible'); + }); + + const reducedMotion = globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reducedMotion) { + isAnimatingRef.current = false; + return; + } + + const handleEnd = () => { + isAnimatingRef.current = false; + }; + wrapper.addEventListener('transitionend', handleEnd, { once: true }); + const fallback = setTimeout(() => { + isAnimatingRef.current = false; + }, COMPARE_TRANSITION_FALLBACK_MS); + return () => { + clearTimeout(fallback); + }; + }, [compareMode]); +} + +class InternalErrorBoundary extends Component< + { fallbackHtml: string; children: ComponentChildren }, + { hasError: boolean } +> { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: Error) { + console.error('[CodeExample]', error); + } + render() { + if (this.state.hasError) { + return ( +
+ ); + } + return this.props.children; + } +} + +interface SharedProps { + readonly slug: string; + readonly smelly: CodePanelData; + readonly fallbackHtml: string; +} + +interface SmellyOnlyCodeExampleProps extends SharedProps { + readonly kind: 'smelly-only'; +} + +interface DualCodeExampleProps extends SharedProps { + readonly kind: 'with-solution'; + readonly solution: CodePanelData; +} + +type Props = SmellyOnlyCodeExampleProps | DualCodeExampleProps; + +function SmellyOnlyCodeExample({ fallbackHtml, smelly, slug }: SharedProps) { + return ( + +
+ {smelly.caption &&
{smelly.caption}
} +
+ + {' '} + Smelly + +
+
+ +
+ +
+
+ ); +} + +function DualCodeExample({ slug, smelly, solution, fallbackHtml }: DualCodeExampleProps) { + const [activeTab, setActiveTab] = useState('smelly'); + const [compareMode, setCompareMode] = useState(false); + const [slideDirection, setSlideDirection] = useState<'left' | 'right' | null>(null); + const prevTabRef = useRef('smelly'); + const isAnimatingRef = useRef(false); + const solutionWrapperRef = useRef(null); + const compareBtnRef = useRef(null); + + const captions = { + smelly: smelly.caption || solution.caption, + solution: solution.caption || smelly.caption, + }; + const [displayedCaption, setDisplayedCaption] = useState(captions.smelly); + const [captionFading, setCaptionFading] = useState(false); + const hasCaption = !!(smelly.caption || solution.caption); + const hasDualCaptions = !!( + solution.caption && + smelly.caption && + smelly.caption !== solution.caption + ); + + // Aria-live status message for screen readers + + const [liveMessage, setLiveMessage] = useState(''); + + const crossfadeCaption = useCallback( + (tab: CodeTab) => { + setCaptionFading(true); + setTimeout(() => { + setDisplayedCaption(captions[tab]); + setCaptionFading(false); + }, CAPTION_CROSSFADE_MS); + }, + [captions.smelly, captions.solution], + ); + + const handleSwitch = useCallback( + (tab: CodeTab) => { + if (tab === activeTab) return; + + trackEvent({ name: 'code_toggle', params: { smell: slug, tab } }); + + setSlideDirection(tab === 'solution' ? 'right' : 'left'); + prevTabRef.current = activeTab; + setActiveTab(tab); + + if (hasDualCaptions) crossfadeCaption(tab); + + setTimeout(() => setSlideDirection(null), SLIDE_ANIMATION_MS); + }, + [activeTab, hasDualCaptions, crossfadeCaption], + ); + + const handleCompareToggle = useCallback(() => { + if (isAnimatingRef.current) return; + const entering = !compareMode; + setCompareMode(entering); + isAnimatingRef.current = true; + setLiveMessage(entering ? 'Showing both examples' : 'Returned to single view'); + trackEvent({ + name: 'code_compare', + params: { smell: slug, action: entering ? 'enter' : 'exit' }, + }); + }, [compareMode, slug]); + + useCompareTransition(compareMode, solutionWrapperRef, isAnimatingRef); + + useEffect(() => { + compareBtnRef.current?.focus({ preventScroll: true }); + }, [compareMode]); + + const isSmellyActive = activeTab === 'smelly'; + const currentHtml = isSmellyActive ? smelly.html : solution.html; + const currentLang = isSmellyActive ? smelly.lang : solution.lang; + const compareLang = + smelly.lang === solution.lang ? smelly.lang : `${smelly.lang} / ${solution.lang}`; + + const compareButton = ( + + ); + + return ( + +
+ {/* Aria-live region for screen readers */} +
+ {liveMessage} +
+ + {/* Caption — hidden in compare mode */} + {hasCaption && !compareMode && ( +
+ {displayedCaption} +
+ )} + + {compareMode ? ( + /* ---- Compare mode: stacked panels ---- */ +
+ {/* Smelly panel bar + panel */} +
+
+ + {' '} + Smelly + +
+ + {compareButton} +
+
+ +
+ + + + {/* Solution panel — wrapped for grid-based reveal animation */} +
+
+
+
+ + {' '} + Solution + +
+ +
+
+ +
+
+
+
+ ) : ( + /* ---- Default mode: tabbed panels ---- */ + <> +
+ + {compareButton} +
+
+ +
+ + )} + + {compareMode ? ( +
+ {compareLang && {compareLang.toUpperCase()}} +
+ ) : ( + + )} +
+
+ ); +} + +export default function CodeExample(props: Props) { + if (props.kind === 'smelly-only') { + return ( + + ); + } + + return ; +} diff --git a/src/components/islands/FilterSidebar.css b/src/components/islands/FilterSidebar.css new file mode 100644 index 00000000..490baef5 --- /dev/null +++ b/src/components/islands/FilterSidebar.css @@ -0,0 +1,721 @@ +/* ============================================ + FilterSidebar — BEM-namespaced styles + ============================================ */ + +/* Desktop/mobile visibility */ +.filter-sidebar__desktop { + display: none; +} + +@media (min-width: 900px) { + .filter-sidebar__desktop { + display: block; + } +} + +.filter-sidebar__desktop--error { + padding-bottom: 12px; +} + +.filter-sidebar__error { + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface-raised); + text-align: left; +} + +.filter-sidebar__error--sheet { + margin-bottom: 12px; +} + +.filter-sidebar__error-message { + margin: 0; + font-size: var(--text-sm); + line-height: 1.65; + color: var(--text-tertiary); + text-wrap: pretty; +} + +/* Search input */ +.filter-sidebar__search-wrap { + position: relative; + margin-bottom: 16px; +} + +.filter-sidebar__search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + pointer-events: none; + transition: color 0.25s var(--ease-smooth); +} + +.filter-sidebar__search-input { + width: 100%; + padding: 10px 36px 10px 36px; + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: 450; + color: var(--text-primary); + background: var(--search-input-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + transition: + background-color 0.25s var(--ease-smooth), + border-color 0.25s var(--ease-smooth), + box-shadow 0.25s var(--ease-smooth), + color 0.25s var(--ease-smooth); + letter-spacing: 0.01em; +} + +.filter-sidebar__search-input::placeholder { + color: var(--text-tertiary); + font-weight: 400; +} + +.filter-sidebar__search-input:focus { + border-color: var(--accent); + box-shadow: var(--shadow-glow); +} + +.filter-sidebar__search-input:focus + .filter-sidebar__search-icon { + color: var(--accent); +} + +.filter-sidebar__search-kbd { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + font-weight: 500; + padding: 2px 7px; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + pointer-events: none; + transition: opacity 0.2s var(--ease-smooth); + font-family: var(--font-body); + line-height: 1.4; +} + +@media (pointer: coarse) { + .filter-sidebar__search-kbd { + display: none; + } +} + +.filter-sidebar__search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%) scale(0); + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: var(--surface-raised); + color: var(--text-tertiary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all 0.25s var(--ease-spring); +} + +.filter-sidebar__search-clear--visible { + transform: translateY(-50%) scale(1); +} + +.filter-sidebar__search-clear:hover { + background: var(--accent-light); + color: var(--accent); +} + +/* Header / summary */ +.filter-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 14px; + margin-bottom: 6px; + border-bottom: 1px solid var(--border-subtle); +} + +.filter-sidebar__result-count { + font-family: var(--font-display); + font-size: 24px; + letter-spacing: -0.02em; + font-weight: 400; + font-variation-settings: 'opsz' 32; +} + +.filter-sidebar__result-label { + font-family: var(--font-body); + font-size: var(--text-sm); + color: var(--text-secondary); + margin-left: 4px; + font-weight: 400; +} + +.filter-sidebar__clear-btn { + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + padding: 5px 12px; + border-radius: var(--radius-sm); + transition: all 0.2s var(--ease-smooth); + opacity: 0; + transform: translateX(4px); + pointer-events: none; + letter-spacing: 0.02em; +} + +.filter-sidebar__clear-btn--visible { + opacity: 1; + transform: translateX(0); + pointer-events: auto; +} + +.filter-sidebar__clear-btn:hover { + color: var(--accent); + background: var(--accent-light); +} + +/* Progress bar (FILT-02) */ +.filter-sidebar__progress-wrap { + height: 3px; + background: var(--surface-raised); + border-radius: 2px; + margin-top: 12px; + overflow: hidden; +} + +.filter-sidebar__progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--yellow)); + border-radius: 2px; + transition: width 0.5s var(--ease-out); + min-width: 3px; + position: relative; + overflow: hidden; +} + +.filter-sidebar__progress-bar::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.3) 50%, + transparent 100% + ); + transform: translateX(-100%); + animation: none; +} + +.filter-sidebar__progress-bar--shimmer::after { + animation: filterProgressShimmer 0.8s var(--ease-out); +} + +@keyframes filterProgressShimmer { + from { + transform: translateX(-100%); + } + to { + transform: translateX(100%); + } +} + +/* Filter summary sentence (FILT-03) */ +.filter-sidebar__filter-summary { + font-size: var(--text-xs); + color: var(--text-secondary); + padding: 6px 0 4px; + line-height: 1.55; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: all var(--duration-normal) var(--ease-smooth); + letter-spacing: 0.01em; +} + +.filter-sidebar__filter-summary--visible { + max-height: 60px; + opacity: 1; + padding: 8px 0 12px; +} + +/* Active pills */ +.filter-sidebar__pills { + display: flex; + flex-wrap: wrap; + gap: 5px; + max-height: 0; + overflow: hidden; + transition: all 0.4s var(--ease-out); +} + +.filter-sidebar__pills--visible { + max-height: 200px; + padding: 8px 0; +} + +.filter-sidebar__pill { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 3px 9px 3px 11px; + border-radius: var(--radius-pill); + cursor: pointer; + transition: all 0.2s var(--ease-smooth); + animation: pillIn 0.35s var(--ease-spring) both; + border: 1px solid transparent; + letter-spacing: 0.01em; + background: none; + font-family: var(--font-body); +} + +.filter-sidebar__pill:hover { + filter: brightness(0.92); +} + +.filter-sidebar__pill-x { + font-size: 13px; + line-height: 1; + opacity: 0.45; + transition: opacity var(--duration-fast); +} + +.filter-sidebar__pill:hover .filter-sidebar__pill-x { + opacity: 1; +} + +/* Dimension pills: colors are provided via inline CSS custom properties */ +.filter-sidebar__pill[style] { + background: var(--dim-light); + color: var(--dim-color); + border-color: color-mix(in srgb, var(--dim-color) 12%, transparent); +} + +.filter-sidebar__pill--search { + background: var(--surface-raised); + color: var(--text-secondary); + border-color: var(--border); +} + +@keyframes pillIn { + from { + opacity: 0; + transform: scale(0.75) translateY(2px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Dimension sections */ +.filter-sidebar__dimension { + padding: 16px 0 8px; +} + +.filter-sidebar__dimension + .filter-sidebar__dimension { + border-top: 1px solid var(--border-subtle); +} + +.filter-sidebar__dimension-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + width: 100%; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + user-select: none; +} + +.filter-sidebar__dimension-header:hover .filter-sidebar__dimension-name { + color: var(--text-secondary); +} + +.filter-sidebar__dimension-color-bar { + width: 3px; + height: 14px; + border-radius: 2px; + flex-shrink: 0; + transition: height var(--duration-normal) var(--ease-spring); +} + +.filter-sidebar__dimension-header:hover .filter-sidebar__dimension-color-bar { + height: 18px; +} + +.filter-sidebar__dimension-name { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-secondary); + transition: color var(--duration-fast); + flex: 1; +} + +.filter-sidebar__dimension-count { + font-size: 10px; + color: var(--text-secondary); + opacity: 0.82; + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.filter-sidebar__dimension-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.filter-sidebar__dimension-chevron { + font-size: 9px; + color: var(--text-secondary); + transition: transform 0.3s var(--ease-spring); +} + +.filter-sidebar__dimension--collapsed .filter-sidebar__dimension-chevron { + transform: rotate(-90deg); +} + +.filter-sidebar__dimension-chips-wrap { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.35s var(--ease-out); + overflow: hidden; +} + +.filter-sidebar__dimension--collapsed .filter-sidebar__dimension-chips-wrap { + grid-template-rows: 0fr; +} + +.filter-sidebar__dimension-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 0; + overflow: hidden; + padding: 2px 0; +} + +/* Chips */ +.filter-sidebar__chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-body); + font-size: 12.5px; + font-weight: 500; + padding: 5px 12px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s var(--ease-smooth); + user-select: none; + position: relative; + overflow: hidden; + letter-spacing: 0.01em; +} + +@media (hover: hover) { + .filter-sidebar__chip:hover { + border-color: var(--text-tertiary); + color: var(--text-primary); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + } +} + +.filter-sidebar__chip:active { + transform: translateY(0) scale(0.97); + transition-duration: 0.08s; +} + +.filter-sidebar__chip-count { + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + opacity: 0.82; + transition: all var(--duration-normal); + font-variant-numeric: tabular-nums; +} + +/* Zero-count chip */ +.filter-sidebar__chip--zero { + opacity: 0.3; + cursor: default; +} + +.filter-sidebar__chip--zero:hover { + transform: none; + box-shadow: none; +} + +/* Active chips: color is provided via inline CSS custom properties */ +.filter-sidebar__chip--active { + background: var(--dim-color); + color: white; + border-color: var(--dim-color); + box-shadow: 0 2px 10px color-mix(in srgb, var(--dim-color) 30%, transparent); +} + +.filter-sidebar__chip--active .filter-sidebar__chip-count { + color: rgba(255, 255, 255, 0.7); +} + +/* Empty state */ +.filter-sidebar__empty { + text-align: center; + padding: 48px 24px; +} + +.filter-sidebar__empty-icon { + font-size: 44px; + margin-bottom: 18px; + opacity: 0.25; +} + +.filter-sidebar__empty-text { + font-family: var(--font-display); + font-size: var(--text-xl); + color: var(--text-primary); + margin-bottom: 8px; + font-weight: 400; + font-variation-settings: 'opsz' 32; +} + +.filter-sidebar__empty-hint { + font-size: var(--text-sm); + color: var(--text-tertiary); + margin-bottom: 20px; + line-height: 1.65; +} + +.filter-sidebar__empty-btn { + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 600; + padding: 7px 16px; + border-radius: var(--radius-pill); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-smooth); + background: var(--text-primary); + color: var(--bg); + border: 1px solid var(--text-primary); + letter-spacing: 0.01em; +} + +.filter-sidebar__empty-btn:hover { + opacity: 0.85; +} + +/* Mobile bottom sheet */ +.filter-sidebar__mobile-fab { + display: none; + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: calc(var(--z-overlay) - 1); + padding: 14px 28px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--surface); + box-shadow: var(--shadow-lg); + font-size: 14px; + font-weight: 600; + font-family: var(--font-body); + color: var(--text-primary); + cursor: pointer; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.25s var(--ease-spring); +} + +.filter-sidebar__mobile-fab:hover { + transform: translateX(-50%) scale(1.04); + box-shadow: var(--shadow-lg); +} + +.filter-sidebar__mobile-fab:active { + transform: translateX(-50%) scale(0.97); +} + +.filter-sidebar__mobile-fab-badge { + min-width: 20px; + height: 20px; + border-radius: 10px; + background: var(--accent); + color: white; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; + font-family: var(--font-body); +} + +@media (max-width: 899px) { + .filter-sidebar__mobile-fab { + display: flex; + } +} + +/* Bottom sheet overlay */ +.filter-sidebar__sheet-overlay { + position: fixed; + inset: 0; + z-index: var(--z-overlay); + border: none; + padding: 0; + background: rgba(28, 25, 23, 0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + opacity: 0; + pointer-events: none; + appearance: none; + cursor: pointer; + transition: opacity 0.25s var(--ease-smooth); +} + +.filter-sidebar__sheet-overlay--open { + opacity: 1; + pointer-events: auto; +} + +[data-theme='dark'] .filter-sidebar__sheet-overlay { + background: rgba(0, 0, 0, 0.55); +} + +/* Bottom sheet panel */ +.filter-sidebar__sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: var(--z-sheet); + background: var(--surface); + border-radius: 20px 20px 0 0; + box-shadow: 0 -8px 40px rgba(28, 25, 23, 0.15); + padding: 12px 24px 32px; + padding-bottom: max(32px, env(safe-area-inset-bottom)); + transform: translateY(100%); + transition: transform 0.35s var(--ease-out); + max-height: 80vh; + overflow-y: auto; +} + +.filter-sidebar__sheet--open { + transform: translateY(0); +} + +.filter-sidebar__sheet-handle { + width: 36px; + height: 4px; + border-radius: 2px; + background: var(--border); + margin: 0 auto 12px; +} + +/* Sheet header */ +.filter-sidebar__sheet-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.filter-sidebar__sheet-title { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.filter-sidebar__sheet-close { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s var(--ease-smooth); + flex-shrink: 0; +} + +.filter-sidebar__sheet-close:hover { + background: var(--surface-raised); + color: var(--text-primary); +} + +/* Sheet apply button */ +.filter-sidebar__sheet-apply-wrap { + position: sticky; + bottom: 0; + padding: 12px 0 0; + background: var(--surface); +} + +.filter-sidebar__sheet-apply { + width: 100%; + padding: 16px; + border-radius: 14px; + background: var(--text-primary); + color: var(--bg); + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; + border: none; + cursor: pointer; + transition: opacity 0.2s var(--ease-smooth); +} + +.filter-sidebar__sheet-apply:hover { + opacity: 0.9; +} + +/* Scroll fade gradient */ +.filter-sidebar__scroll-fade { + position: sticky; + bottom: 0; + height: 36px; + background: linear-gradient(to top, var(--bg) 20%, transparent 100%); + pointer-events: none; + margin-top: -36px; +} diff --git a/src/components/islands/FilterSidebar.tsx b/src/components/islands/FilterSidebar.tsx new file mode 100644 index 00000000..aff03df2 --- /dev/null +++ b/src/components/islands/FilterSidebar.tsx @@ -0,0 +1,233 @@ +import { useStore } from '@nanostores/preact'; +import { Component } from 'preact'; +import type { ComponentChildren } from 'preact'; +import { useCallback, useMemo, useRef, useState, useEffect } from 'preact/hooks'; +import { $allSmells } from '../../stores/smells-data'; +import { $filters, toggleFilter, clearFilters } from '../../stores/filters'; +import { $searchQuery, clearSearch } from '../../stores/search'; +import { $filteredSlugs } from '../../stores/derived/filtered-smells'; +import { $activeCount } from '../../stores/derived/active-count'; +import { $filterCounts } from '../../stores/derived/filter-counts'; +import { + DIMENSION_CONFIG, + getSmellDimensionValues, + getDisplayLabel, +} from '../../lib/catalog/dimensions'; +import { trackEvent, normalizeQuery } from '../../lib/analytics/tracker'; +import type { DimensionKey } from '../../lib/catalog/dimensions'; +import { typedFromEntries } from '../../lib/typed-from-entries'; +import { SearchInput } from './filter/SearchInput'; +import { DimensionSection } from './filter/DimensionSection'; +import { ActivePills } from './filter/ActivePills'; +import { FilterSummary } from './filter/FilterSummary'; +import { EmptyState } from './filter/EmptyState'; +import { MobileBottomSheet } from './filter/MobileBottomSheet'; +import './FilterSidebar.css'; + +const SEARCH_ANALYTICS_DEBOUNCE_MS = 500; + +function FilterFallbackBody() { + return ( +

Filters unavailable. Showing all smells below.

+ ); +} + +class FilterErrorBoundary extends Component< + { children: ComponentChildren }, + { hasError: boolean } +> { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: Error) { + console.error('[FilterSidebar]', error); + } + render() { + if (this.state.hasError) { + return ( + <> +
+
+ +
+
+ + +
+ +
+
+ + ); + } + return this.props.children; + } +} + +function FilterSidebarInner() { + const allSmells = useStore($allSmells); + const filters = useStore($filters); + const searchQuery = useStore($searchQuery); + const filteredSlugs = useStore($filteredSlugs); + const activeCount = useStore($activeCount); + const filterCounts = useStore($filterCounts); + + // Collect all unique values for each dimension from the data + const dimensionValues = useMemo( + () => + typedFromEntries( + DIMENSION_CONFIG.map( + (dim) => + [ + dim.key, + [ + ...new Set(allSmells.flatMap((smell) => getSmellDimensionValues(smell, dim.key))), + ].sort((a, b) => a.localeCompare(b)), + ] as const, + ), + ), + [allSmells], + ); + + // Build active filter labels for the summary text + const activeFilterLabels = useMemo( + () => + DIMENSION_CONFIG.filter((dim) => filters[dim.key].size > 0).map((dim) => + [...filters[dim.key]].map((v) => getDisplayLabel(v, dim.key)).join(' or '), + ), + [filters], + ); + + const handleToggle = useCallback((dimension: DimensionKey, value: string) => { + const current = $filters.get()[dimension]; + const action = current.has(value) ? 'remove' : 'add'; + toggleFilter(dimension, value); + trackEvent({ name: 'filter_toggle', params: { dimension, value, action } }); + }, []); + + const handleClearSearch = useCallback(() => { + clearSearch(); + }, []); + + const handleClearAll = useCallback(() => { + clearFilters(); + clearSearch(); + trackEvent({ name: 'filter_reset', params: {} }); + }, []); + + const showEmpty = allSmells.length > 0 && filteredSlugs.size === 0; + const hasSearchQuery = searchQuery.trim().length > 0; + const mobileBadgeCount = activeCount + (hasSearchQuery ? 1 : 0); + + // Scroll-fade: detect if desktop sidebar is scrollable + const desktopRef = useRef(null); + const [showScrollFade, setShowScrollFade] = useState(false); + + useEffect(() => { + const el = desktopRef.current; + if (!el) return; + + const SCROLL_BOTTOM_THRESHOLD_PX = 2; + + const checkScrollable = () => { + const container = el.parentElement; + if (container) { + const isScrollable = container.scrollHeight > container.clientHeight; + const isAtBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < + SCROLL_BOTTOM_THRESHOLD_PX; + setShowScrollFade(isScrollable && !isAtBottom); + } + }; + + checkScrollable(); + + const container = el.parentElement; + if (container) { + container.addEventListener('scroll', checkScrollable, { passive: true }); + } + const resizeObserver = new ResizeObserver(checkScrollable); + resizeObserver.observe(el); + + return () => { + if (container) container.removeEventListener('scroll', checkScrollable); + resizeObserver.disconnect(); + }; + }, [allSmells, filters]); + + // Track search queries for analytics (debounced) + useEffect(() => { + if (!searchQuery.trim()) return; + // 500ms serves two purposes: (1) debounce rapid typing, (2) wait for + // $filteredSlugs derived store to settle after $searchQuery update. + // Do not reduce below 300ms or result_count may read stale values. + const timer = setTimeout(() => { + trackEvent({ + name: 'catalog_search', + params: { query: normalizeQuery(searchQuery), result_count: $filteredSlugs.get().size }, + }); + }, SEARCH_ANALYTICS_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchQuery]); + + const sidebarContent = ( + <> + + + + + {DIMENSION_CONFIG.map((dim) => ( + handleToggle(dim.key, value)} + /> + ))} + + {showEmpty && } + + ); + + return ( + <> + {/* Desktop: rendered in sidebar slot via CatalogLayout */} +
+ {sidebarContent} + {showScrollFade &&
} +
+ + {/* Mobile: bottom sheet with FAB trigger */} + {sidebarContent} + + ); +} + +export default function FilterSidebar() { + return ( + + + + ); +} diff --git a/src/components/islands/Icon.tsx b/src/components/islands/Icon.tsx new file mode 100644 index 00000000..d05b3ce5 --- /dev/null +++ b/src/components/islands/Icon.tsx @@ -0,0 +1,26 @@ +import type { IconDef } from '../../lib/icon-paths'; + +interface Props { + readonly icon: IconDef; + readonly size?: number; + readonly class?: string; + readonly strokeWidth?: number; +} + +export function Icon({ icon, size = 14, class: className, strokeWidth = 2 }: Props) { + return ( + + ); +} diff --git a/src/components/islands/code-example/CodePanel.tsx b/src/components/islands/code-example/CodePanel.tsx new file mode 100644 index 00000000..564a8645 --- /dev/null +++ b/src/components/islands/code-example/CodePanel.tsx @@ -0,0 +1,73 @@ +/** + * Single code panel. Shows a fade gradient on horizontal overflow + * as a scroll affordance. + */ +import { useRef, useEffect, useCallback } from 'preact/hooks'; +import type { CodeTab } from '../../../lib/types'; + +interface Props { + readonly html: string; + readonly variant: CodeTab; + readonly slideDirection: 'left' | 'right' | null; + readonly role?: 'tabpanel' | 'region'; +} + +export function CodePanel({ html, variant, slideDirection, role = 'tabpanel' }: Props) { + const slideClass = slideDirection ? `code-panel--slide-${slideDirection}` : ''; + + const wrapRef = useRef(null); + const panelRef = useRef(null); + const revealedRef = useRef(false); + + const checkOverflow = useCallback(() => { + const wrap = wrapRef.current; + const panel = panelRef.current; + if (!wrap || !panel) return; + + const pre = panel.querySelector('pre'); + if (!pre) return; + + const hasOverflow = pre.scrollWidth > pre.clientWidth; + const atEnd = pre.scrollLeft + pre.clientWidth >= pre.scrollWidth - 2; + + wrap.classList.toggle('code-panel-wrap--has-overflow', hasOverflow); + wrap.classList.toggle('code-panel-wrap--scrolled-end', hasOverflow && atEnd); + + if (hasOverflow && !revealedRef.current) { + revealedRef.current = true; + wrap.classList.add('code-panel-wrap--first-reveal'); + } + }, []); + + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + + const pre = panel.querySelector('pre'); + if (!pre) return; + + checkOverflow(); + + pre.addEventListener('scroll', checkOverflow, { passive: true }); + + const ro = new ResizeObserver(checkOverflow); + ro.observe(pre); + + return () => { + pre.removeEventListener('scroll', checkOverflow); + ro.disconnect(); + }; + }, [html, checkOverflow]); + + return ( +
+
+
+ ); +} diff --git a/src/components/islands/code-example/CompareTransformDivider.tsx b/src/components/islands/code-example/CompareTransformDivider.tsx new file mode 100644 index 00000000..c45ebfb3 --- /dev/null +++ b/src/components/islands/code-example/CompareTransformDivider.tsx @@ -0,0 +1,23 @@ +export function CompareTransformDivider() { + return ( +