diff --git a/apps/website/eslint-rules/component-classification.js b/apps/website/eslint-rules/component-classification.js
new file mode 100644
index 000000000..756dcd12d
--- /dev/null
+++ b/apps/website/eslint-rules/component-classification.js
@@ -0,0 +1,82 @@
+/**
+ * ESLint rule to suggest proper component classification
+ *
+ * Architecture:
+ * - app/ - Pages and layouts only (no business logic)
+ * - components/ - App-level components (can be stateful, can use hooks)
+ * - ui/ - Pure, reusable UI elements (stateless, no hooks)
+ * - hooks/ - Shared stateful logic
+ *
+ * This rule provides SUGGESTIONS, not errors, for component placement.
+ */
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'Suggest proper component classification',
+ category: 'Architecture',
+ recommended: false,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ uiShouldBePure: 'This appears to be a pure UI element. Consider moving to ui/ for maximum reusability.',
+ componentShouldBeInComponents: 'This component uses state/hooks. Consider moving to components/.',
+ pureComponentInComponents: 'Pure component in components/. Consider moving to ui/ for better reusability.',
+ },
+ },
+
+ create(context) {
+ const filename = context.getFilename();
+ const isInUi = filename.includes('/ui/');
+ const isInComponents = filename.includes('/components/');
+ const isInApp = filename.includes('/app/');
+
+ if (!isInUi && !isInComponents) return {};
+
+ return {
+ Program(node) {
+ const sourceCode = context.getSourceCode();
+ const text = sourceCode.getText();
+
+ // Detect stateful patterns
+ const hasState = /useState|useReducer|this\.state/.test(text);
+ const hasEffects = /useEffect|useLayoutEffect/.test(text);
+ const hasContext = /useContext/.test(text);
+ const hasComplexLogic = /if\s*\(|switch\s*\(|for\s*\(|while\s*\(/.test(text);
+
+ // Detect pure UI patterns (just JSX, props, simple functions)
+ const hasOnlyJsx = /^\s*import.*from.*;\s*export\s+function\s+\w+\s*\([^)]*\)\s*{?\s*return\s*\(?.*\)?;?\s*}?\s*$/m.test(text);
+ const hasNoLogic = !hasState && !hasEffects && !hasContext && !hasComplexLogic;
+
+ if (isInUi && hasState) {
+ context.report({
+ loc: { line: 1, column: 0 },
+ messageId: 'componentShouldBeInComponents',
+ });
+ }
+
+ if (isInComponents && hasNoLogic && hasOnlyJsx) {
+ context.report({
+ loc: { line: 1, column: 0 },
+ messageId: 'pureComponentInComponents',
+ });
+ }
+
+ if (isInComponents && !hasState && !hasEffects && !hasContext) {
+ // Check if it's mostly just rendering props
+ const hasManyProps = /\{\s*\.\.\.props\s*\}/.test(text) ||
+ /\{\s*props\./.test(text);
+
+ if (hasManyProps && hasNoLogic) {
+ context.report({
+ loc: { line: 1, column: 0 },
+ messageId: 'uiShouldBePure',
+ });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js
index 3f9a27734..7ed952df3 100644
--- a/apps/website/eslint-rules/index.js
+++ b/apps/website/eslint-rules/index.js
@@ -138,6 +138,16 @@ module.exports = {
// Clean Error Handling Rules
'clean-error-handling': cleanErrorHandling,
'services-implement-contract': servicesImplementContract,
+
+ // Component Architecture Rules
+ 'no-raw-html-in-app': require('./no-raw-html-in-app'),
+ 'ui-element-purity': require('./ui-element-purity'),
+ 'no-nextjs-imports-in-ui': require('./no-nextjs-imports-in-ui'),
+ 'component-classification': require('./component-classification'),
+
+ // Route Configuration Rules
+ 'no-hardcoded-routes': require('./no-hardcoded-routes'),
+ 'no-hardcoded-search-params': require('./no-hardcoded-search-params'),
},
// Configurations for different use cases
@@ -228,6 +238,15 @@ module.exports = {
// Service Rules
'gridpilot-rules/service-function-format': 'error',
'gridpilot-rules/lib-no-next-imports': 'error',
+
+ // Component Architecture Rules
+ 'gridpilot-rules/no-raw-html-in-app': 'error',
+ 'gridpilot-rules/ui-element-purity': 'error',
+ 'gridpilot-rules/no-nextjs-imports-in-ui': 'error',
+ 'gridpilot-rules/component-classification': 'warn',
+
+ // Route Configuration Rules
+ 'gridpilot-rules/no-hardcoded-routes': 'error',
},
},
diff --git a/apps/website/eslint-rules/no-hardcoded-routes.js b/apps/website/eslint-rules/no-hardcoded-routes.js
new file mode 100644
index 000000000..88b5595b7
--- /dev/null
+++ b/apps/website/eslint-rules/no-hardcoded-routes.js
@@ -0,0 +1,288 @@
+/**
+ * @file no-hardcoded-routes.js
+ * Enforces use of RouteConfig.ts instead of hardcoded route strings
+ *
+ * This rule prevents hardcoded route strings in:
+ * - router.push() / router.replace()
+ * - redirect()
+ * - Link href
+ * - a href
+ * - revalidatePath()
+ * - Any string literal matching route patterns
+ */
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Enforce use of RouteConfig.ts instead of hardcoded route strings',
+ category: 'Best Practices',
+ recommended: true,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ hardcodedRoute: 'Hardcoded route "{{route}}". Use routes from RouteConfig.ts instead: import { routes } from "@/lib/routing/RouteConfig"',
+ hardcodedRedirect: 'Hardcoded redirect route "{{route}}". Use routes from RouteConfig.ts',
+ hardcodedLink: 'Hardcoded link href "{{route}}". Use routes from RouteConfig.ts',
+ hardcodedAnchor: 'Hardcoded anchor href "{{route}}". Use routes from RouteConfig.ts',
+ hardcodedRevalidate: 'Hardcoded revalidatePath "{{route}}". Use routes from RouteConfig.ts',
+ },
+ },
+
+ create(context) {
+ // Route patterns that should be in RouteConfig
+ const routePatterns = [
+ '^/auth/',
+ '^/public/',
+ '^/protected/',
+ '^/sponsor/',
+ '^/admin/',
+ '^/league(s)?/',
+ '^/race(s)?/',
+ '^/team(s)?/',
+ '^/driver(s)?/',
+ '^/leaderboard(s)?/',
+ '^/error/',
+ '^/404$',
+ '^/500$',
+ '^/dashboard$',
+ '^/onboarding$',
+ '^/profile',
+ '^/sponsor/signup$',
+ ];
+
+ // Allowed exceptions (non-route paths)
+ const allowedPaths = [
+ '^/api/',
+ '^/legal/',
+ '^/terms$',
+ '^/privacy$',
+ '^/support$',
+ '^/$', // root is allowed
+ ];
+
+ function isHardcodedRoute(value) {
+ if (typeof value !== 'string') return false;
+
+ // Check if it's an allowed path
+ if (allowedPaths.some(pattern => new RegExp(pattern).test(value))) {
+ return false;
+ }
+
+ // Check if it matches a route pattern
+ return routePatterns.some(pattern => new RegExp(pattern).test(value));
+ }
+
+ function getRouteConfigSuggestion(route) {
+ // Map common routes to RouteConfig suggestions
+ const routeMap = {
+ '/auth/login': 'routes.auth.login',
+ '/auth/signup': 'routes.auth.signup',
+ '/auth/forgot-password': 'routes.auth.forgotPassword',
+ '/auth/reset-password': 'routes.auth.resetPassword',
+ '/': 'routes.public.home',
+ '/leagues': 'routes.public.leagues',
+ '/drivers': 'routes.public.drivers',
+ '/teams': 'routes.public.teams',
+ '/leaderboards': 'routes.public.leaderboards',
+ '/races': 'routes.public.races',
+ '/sponsor/signup': 'routes.public.sponsorSignup',
+ '/dashboard': 'routes.protected.dashboard',
+ '/onboarding': 'routes.protected.onboarding',
+ '/profile': 'routes.protected.profile',
+ '/profile/settings': 'routes.protected.profileSettings',
+ '/profile/leagues': 'routes.protected.profileLeagues',
+ '/profile/liveries': 'routes.protected.profileLiveries',
+ '/profile/liveries/upload': 'routes.protected.profileLiveryUpload',
+ '/profile/sponsorship-requests': 'routes.protected.profileSponsorshipRequests',
+ '/sponsor': 'routes.sponsor.root',
+ '/sponsor/dashboard': 'routes.sponsor.dashboard',
+ '/sponsor/billing': 'routes.sponsor.billing',
+ '/sponsor/campaigns': 'routes.sponsor.campaigns',
+ '/sponsor/leagues': 'routes.sponsor.leagues',
+ '/sponsor/settings': 'routes.sponsor.settings',
+ '/admin': 'routes.admin.root',
+ '/admin/users': 'routes.admin.users',
+ '/404': 'routes.error.notFound',
+ '/500': 'routes.error.serverError',
+ };
+
+ // Check for parameterized routes
+ const leagueMatch = route.match(/^\/leagues\/([^\/]+)$/);
+ if (leagueMatch) {
+ return `routes.league.detail('${leagueMatch[1]}')`;
+ }
+
+ const raceMatch = route.match(/^\/races\/([^\/]+)$/);
+ if (raceMatch) {
+ return `routes.race.detail('${raceMatch[1]}')`;
+ }
+
+ const teamMatch = route.match(/^\/teams\/([^\/]+)$/);
+ if (teamMatch) {
+ return `routes.team.detail('${teamMatch[1]}')`;
+ }
+
+ const driverMatch = route.match(/^\/drivers\/([^\/]+)$/);
+ if (driverMatch) {
+ return `routes.driver.detail('${driverMatch[1]}')`;
+ }
+
+ const sponsorLeagueMatch = route.match(/^\/sponsor\/leagues\/([^\/]+)$/);
+ if (sponsorLeagueMatch) {
+ return `routes.sponsor.leagueDetail('${sponsorLeagueMatch[1]}')`;
+ }
+
+ const leagueAdminMatch = route.match(/^\/leagues\/([^\/]+)\/schedule\/admin$/);
+ if (leagueAdminMatch) {
+ return `routes.league.scheduleAdmin('${leagueAdminMatch[1]}')`;
+ }
+
+ return routeMap[route] || null;
+ }
+
+ function reportHardcodedRoute(node, value, messageId, data = {}) {
+ const suggestion = getRouteConfigSuggestion(value);
+ const messageData = {
+ route: value,
+ ...data,
+ };
+
+ context.report({
+ node,
+ messageId,
+ data: messageData,
+ fix(fixer) {
+ if (suggestion) {
+ return fixer.replaceText(node, suggestion);
+ }
+ return null;
+ },
+ });
+ }
+
+ return {
+ // Check router.push() and router.replace()
+ CallExpression(node) {
+ if (node.callee.type === 'MemberExpression' &&
+ node.callee.property.name === 'push' || node.callee.property.name === 'replace') {
+
+ // Check if it's router.push/replace
+ const calleeObj = node.callee.object;
+ if (calleeObj.type === 'Identifier' && calleeObj.name === 'router') {
+ const arg = node.arguments[0];
+ if (arg && arg.type === 'Literal' && isHardcodedRoute(arg.value)) {
+ reportHardcodedRoute(arg, arg.value, 'hardcodedRoute');
+ }
+ }
+ }
+
+ // Check redirect()
+ if (node.callee.type === 'Identifier' && node.callee.name === 'redirect') {
+ const arg = node.arguments[0];
+ if (arg && arg.type === 'Literal' && isHardcodedRoute(arg.value)) {
+ reportHardcodedRoute(arg, arg.value, 'hardcodedRedirect');
+ }
+ }
+
+ // Check revalidatePath()
+ if (node.callee.type === 'Identifier' && node.callee.name === 'revalidatePath') {
+ const arg = node.arguments[0];
+ if (arg && arg.type === 'Literal' && isHardcodedRoute(arg.value)) {
+ reportHardcodedRoute(arg, arg.value, 'hardcodedRevalidate');
+ }
+ }
+ },
+
+ // Check Link href
+ JSXOpeningElement(node) {
+ if (node.name.type === 'JSXIdentifier' && node.name.name === 'Link') {
+ const hrefAttr = node.attributes.find(attr =>
+ attr.type === 'JSXAttribute' && attr.name.name === 'href'
+ );
+
+ if (hrefAttr && hrefAttr.value && hrefAttr.value.type === 'Literal') {
+ const value = hrefAttr.value.value;
+ if (isHardcodedRoute(value)) {
+ reportHardcodedRoute(hrefAttr.value, value, 'hardcodedLink');
+ }
+ }
+ }
+
+ // Check a href
+ if (node.name.type === 'JSXIdentifier' && node.name.name === 'a') {
+ const hrefAttr = node.attributes.find(attr =>
+ attr.type === 'JSXAttribute' && attr.name.name === 'href'
+ );
+
+ if (hrefAttr && hrefAttr.value && hrefAttr.value.type === 'Literal') {
+ const value = hrefAttr.value.value;
+ if (isHardcodedRoute(value)) {
+ reportHardcodedRoute(hrefAttr.value, value, 'hardcodedAnchor');
+ }
+ }
+ }
+ },
+
+ // Check template literals in href
+ JSXAttribute(node) {
+ if (node.name.name === 'href' && node.value && node.value.type === 'JSXExpressionContainer') {
+ const expr = node.value.expression;
+ if (expr.type === 'TemplateLiteral') {
+ // Check if template literal contains hardcoded route
+ for (const quasi of expr.quasis) {
+ if (quasi.value && isHardcodedRoute(quasi.value.raw)) {
+ reportHardcodedRoute(quasi, quasi.value.raw, 'hardcodedLink');
+ }
+ }
+ }
+ }
+ },
+
+ // Check string literals in general (catches other cases)
+ Literal(node) {
+ if (typeof node.value === 'string' && isHardcodedRoute(node.value)) {
+ // Skip if it's already being handled by more specific checks
+ const parent = node.parent;
+
+ // Skip if it's in a comment or already reported
+ if (parent.type === 'Property' && parent.key === node) {
+ return; // Object property key
+ }
+
+ // Check if this is in a context we care about
+ let isInRelevantContext = false;
+ let messageId = 'hardcodedRoute';
+
+ // Walk up to find context
+ let current = parent;
+ while (current) {
+ if (current.type === 'CallExpression') {
+ if (current.callee.type === 'MemberExpression') {
+ const prop = current.callee.property.name;
+ if (prop === 'push' || prop === 'replace') {
+ isInRelevantContext = true;
+ break;
+ }
+ }
+ if (current.callee.type === 'Identifier') {
+ const name = current.callee.name;
+ if (name === 'redirect' || name === 'revalidatePath') {
+ isInRelevantContext = true;
+ messageId = name === 'redirect' ? 'hardcodedRedirect' : 'hardcodedRevalidate';
+ break;
+ }
+ }
+ }
+ current = current.parent;
+ }
+
+ if (isInRelevantContext) {
+ reportHardcodedRoute(node, node.value, messageId);
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/apps/website/eslint-rules/no-hardcoded-search-params.js b/apps/website/eslint-rules/no-hardcoded-search-params.js
new file mode 100644
index 000000000..7c9298a04
--- /dev/null
+++ b/apps/website/eslint-rules/no-hardcoded-search-params.js
@@ -0,0 +1,124 @@
+/**
+ * @file no-hardcoded-search-params.js
+ * Enforces use of SearchParam system instead of manual URLSearchParams manipulation
+ */
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Enforce use of SearchParamBuilder/SearchParamParser instead of manual URLSearchParams manipulation',
+ category: 'Best Practices',
+ recommended: true,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ manualSearchParams: 'Manual URLSearchParams construction. Use SearchParamBuilder instead: import { SearchParamBuilder } from "@/lib/routing/search-params"',
+ manualGetParam: 'Manual search param access with get(). Use SearchParamParser instead: import { SearchParamParser } from "@/lib/routing/search-params"',
+ manualSetParam: 'Manual search param setting with set(). Use SearchParamBuilder instead',
+ },
+ },
+
+ create(context) {
+ return {
+ // Detect: new URLSearchParams()
+ NewExpression(node) {
+ if (node.callee.type === 'Identifier' && node.callee.name === 'URLSearchParams') {
+ // Check if it's being used for construction (not just parsing)
+ const parent = node.parent;
+
+ // If it's in a call expression like new URLSearchParams().toString()
+ // or new URLSearchParams().get()
+ if (parent.type === 'MemberExpression') {
+ const property = parent.property.name;
+ if (property === 'toString' || property === 'set' || property === 'append') {
+ context.report({
+ node,
+ messageId: 'manualSearchParams',
+ });
+ }
+ }
+ // If it's just new URLSearchParams() without further use
+ else if (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression') {
+ context.report({
+ node,
+ messageId: 'manualSearchParams',
+ });
+ }
+ }
+ },
+
+ // Detect: params.get() or params.set()
+ CallExpression(node) {
+ if (node.callee.type === 'MemberExpression') {
+ const object = node.callee.object;
+ const property = node.callee.property.name;
+
+ // Check if it's a URLSearchParams instance
+ if (object.type === 'Identifier' &&
+ (property === 'get' || property === 'set' || property === 'append' || property === 'delete')) {
+
+ // Try to trace back to see if it's URLSearchParams
+ let current = object;
+ let isUrlSearchParams = false;
+
+ // Check if it's from URLSearchParams constructor
+ const scope = context.getScope();
+ const variable = scope.variables.find(v => v.name === current.name);
+
+ if (variable && variable.defs.length > 0) {
+ const def = variable.defs[0];
+ if (def.node.init &&
+ def.node.init.type === 'NewExpression' &&
+ def.node.init.callee.name === 'URLSearchParams') {
+ isUrlSearchParams = true;
+ }
+ }
+
+ if (isUrlSearchParams) {
+ if (property === 'get') {
+ context.report({
+ node,
+ messageId: 'manualGetParam',
+ });
+ } else if (property === 'set' || property === 'append') {
+ context.report({
+ node,
+ messageId: 'manualSetParam',
+ });
+ }
+ }
+ }
+ }
+ },
+
+ // Detect: searchParams.get() directly (from function parameter)
+ MemberExpression(node) {
+ if (node.property.type === 'Identifier' &&
+ (node.property.name === 'get' || node.property.name === 'set')) {
+
+ // Check if object is named "searchParams" or "params"
+ if (node.object.type === 'Identifier' &&
+ (node.object.name === 'searchParams' || node.object.name === 'params')) {
+
+ // Check parent is a call expression
+ if (node.parent.type === 'CallExpression' && node.parent.callee === node) {
+ if (node.property.name === 'get') {
+ context.report({
+ node,
+ messageId: 'manualGetParam',
+ });
+ } else if (node.property.name === 'set') {
+ context.report({
+ node,
+ messageId: 'manualSetParam',
+ });
+ }
+ }
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/apps/website/eslint-rules/no-nextjs-imports-in-ui.js b/apps/website/eslint-rules/no-nextjs-imports-in-ui.js
new file mode 100644
index 000000000..b85d89ba5
--- /dev/null
+++ b/apps/website/eslint-rules/no-nextjs-imports-in-ui.js
@@ -0,0 +1,94 @@
+/**
+ * ESLint rule to forbid Next.js imports in components/ and ui/
+ *
+ * Next.js imports (from 'next/*') should only be used in:
+ * - app/ directory (pages, layouts, routes)
+ * - Server Actions
+ *
+ * Components and UI elements should be framework-agnostic.
+ * Navigation and routing logic should be passed down from pages.
+ *
+ * Rationale:
+ * - Keeps components/ui portable and testable
+ * - Prevents framework coupling in reusable code
+ * - Forces clear separation of concerns
+ */
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Forbid Next.js imports in components/ and ui/ directories',
+ category: 'Architecture',
+ recommended: true,
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ noNextImports: 'Next.js imports are forbidden in {{dir}}/. Pass navigation/routing from app/ or use callbacks.',
+ },
+ },
+
+ create(context) {
+ const filename = context.getFilename();
+ const isInComponents = filename.includes('/components/');
+ const isInUi = filename.includes('/ui/');
+
+ if (!isInComponents && !isInUi) return {};
+
+ const dir = isInComponents ? 'components' : 'ui';
+
+ // Next.js modules that should not be imported
+ const forbiddenModules = [
+ 'next/navigation', // useRouter, usePathname, useSearchParams
+ 'next/router', // useRouter (pages router)
+ 'next/link', // Link component
+ 'next/image', // Image component
+ 'next/script', // Script component
+ 'next/head', // Head component
+ 'next/dynamic', // dynamic import
+ 'next/headers', // cookies, headers (server-only)
+ 'next/cache', // revalidatePath, revalidateTag
+ ];
+
+ return {
+ ImportDeclaration(node) {
+ const importSource = node.source.value;
+
+ // Check if importing from forbidden modules
+ if (forbiddenModules.includes(importSource)) {
+ context.report({
+ node,
+ messageId: 'noNextImports',
+ data: { dir },
+ });
+ }
+
+ // Also check for direct next/* imports
+ if (importSource.startsWith('next/')) {
+ context.report({
+ node,
+ messageId: 'noNextImports',
+ data: { dir },
+ });
+ }
+ },
+
+ // Also check for dynamic imports
+ CallExpression(node) {
+ if (node.callee.type === 'Identifier' && node.callee.name === 'import') {
+ if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
+ const importPath = node.arguments[0].value;
+ if (typeof importPath === 'string' && importPath.startsWith('next/')) {
+ context.report({
+ node,
+ messageId: 'noNextImports',
+ data: { dir },
+ });
+ }
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/apps/website/eslint-rules/no-raw-html-in-app.js b/apps/website/eslint-rules/no-raw-html-in-app.js
new file mode 100644
index 000000000..76556c9ac
--- /dev/null
+++ b/apps/website/eslint-rules/no-raw-html-in-app.js
@@ -0,0 +1,79 @@
+/**
+ * ESLint rule to forbid raw HTML in app/ directory
+ *
+ * All HTML must be encapsulated in React components from components/ or ui/
+ *
+ * Rationale:
+ * - app/ should only contain page/layout components
+ * - Raw HTML with styling violates separation of concerns
+ * - UI logic belongs in components/ui layers
+ */
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Forbid raw HTML with styling in app/ directory',
+ category: 'Architecture',
+ recommended: true,
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ noRawHtml: 'Raw HTML with styling is forbidden in app/. Use a component from components/ or ui/.',
+ },
+ },
+
+ create(context) {
+ const filename = context.getFilename();
+ const isInApp = filename.includes('/app/');
+
+ if (!isInApp) return {};
+
+ // HTML tags that should be wrapped in components
+ const htmlTags = [
+ 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'p', 'button', 'input', 'form', 'label', 'select', 'textarea',
+ 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody',
+ 'section', 'article', 'header', 'footer', 'nav', 'aside',
+ 'main', 'aside', 'figure', 'figcaption', 'blockquote', 'code',
+ 'pre', 'a', 'img', 'svg', 'path', 'g', 'rect', 'circle'
+ ];
+
+ return {
+ JSXElement(node) {
+ const openingElement = node.openingElement;
+
+ if (openingElement.name.type !== 'JSXIdentifier') return;
+
+ const tagName = openingElement.name.name;
+
+ // Check if it's a raw HTML element (lowercase)
+ if (htmlTags.includes(tagName) && tagName[0] === tagName[0].toLowerCase()) {
+
+ // Check for styling attributes
+ const hasClassName = openingElement.attributes.some(
+ attr => attr.type === 'JSXAttribute' && attr.name.name === 'className'
+ );
+ const hasStyle = openingElement.attributes.some(
+ attr => attr.type === 'JSXAttribute' && attr.name.name === 'style'
+ );
+
+ // Check for inline event handlers (also a concern)
+ const hasInlineHandlers = openingElement.attributes.some(
+ attr => attr.type === 'JSXAttribute' &&
+ attr.name.name &&
+ attr.name.name.startsWith('on')
+ );
+
+ if (hasClassName || hasStyle || hasInlineHandlers) {
+ context.report({
+ node,
+ messageId: 'noRawHtml',
+ });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/apps/website/eslint-rules/ui-element-purity.js b/apps/website/eslint-rules/ui-element-purity.js
new file mode 100644
index 000000000..df10bec0c
--- /dev/null
+++ b/apps/website/eslint-rules/ui-element-purity.js
@@ -0,0 +1,103 @@
+/**
+ * ESLint rule to enforce UI element purity
+ *
+ * UI elements in ui/ must be:
+ * - Stateless (no useState, useReducer)
+ * - No side effects (no useEffect)
+ * - Pure functions that only render based on props
+ *
+ * Rationale:
+ * - ui/ is for reusable, pure presentation elements
+ * - Stateful logic belongs in components/ or hooks/
+ * - This ensures maximum reusability and testability
+ */
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Enforce UI elements are pure and stateless',
+ category: 'Architecture',
+ recommended: true,
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ noStateInUi: 'UI elements in ui/ must be stateless. Use components/ for stateful wrappers.',
+ noHooksInUi: 'UI elements must not use hooks. Use components/ or hooks/ for stateful logic.',
+ noSideEffects: 'UI elements must not have side effects. Use components/ for side effect logic.',
+ },
+ },
+
+ create(context) {
+ const filename = context.getFilename();
+ const isInUi = filename.includes('/ui/');
+
+ if (!isInUi) return {};
+
+ let hasStateHooks = false;
+ let hasEffectHooks = false;
+ let hasContext = false;
+
+ return {
+ // Check for state hooks
+ CallExpression(node) {
+ if (node.callee.type !== 'Identifier') return;
+
+ const hookName = node.callee.name;
+
+ // State management hooks
+ if (['useState', 'useReducer', 'useRef'].includes(hookName)) {
+ hasStateHooks = true;
+ context.report({
+ node,
+ messageId: 'noStateInUi',
+ });
+ }
+
+ // Effect hooks
+ if (['useEffect', 'useLayoutEffect', 'useInsertionEffect'].includes(hookName)) {
+ hasEffectHooks = true;
+ context.report({
+ node,
+ messageId: 'noSideEffects',
+ });
+ }
+
+ // Context (can introduce state)
+ if (hookName === 'useContext') {
+ hasContext = true;
+ context.report({
+ node,
+ messageId: 'noStateInUi',
+ });
+ }
+ },
+
+ // Check for class components with state
+ ClassDeclaration(node) {
+ if (node.superClass &&
+ node.superClass.type === 'Identifier' &&
+ node.superClass.name === 'Component') {
+ context.report({
+ node,
+ messageId: 'noStateInUi',
+ });
+ }
+ },
+
+ // Check for direct state assignment (rare but possible)
+ AssignmentExpression(node) {
+ if (node.left.type === 'MemberExpression' &&
+ node.left.property.type === 'Identifier' &&
+ (node.left.property.name === 'state' ||
+ node.left.property.name === 'setState')) {
+ context.report({
+ node,
+ messageId: 'noStateInUi',
+ });
+ }
+ },
+ };
+ },
+};
diff --git a/docs/architecture/website/COMPONENT_ARCHITECTURE.md b/docs/architecture/website/COMPONENT_ARCHITECTURE.md
new file mode 100644
index 000000000..1bbffe952
--- /dev/null
+++ b/docs/architecture/website/COMPONENT_ARCHITECTURE.md
@@ -0,0 +1,400 @@
+# Component Architecture
+
+This document defines the strict separation of concerns for all UI code in the website layer.
+
+## The Three Layers
+
+```
+apps/website/
+├── app/ ← App Layer (Pages & Layouts)
+├── components/ ← Component Layer (App Components)
+├── ui/ ← UI Layer (Pure Elements)
+└── hooks/ ← Shared Logic
+```
+
+---
+
+## 1. App Layer (`app/`)
+
+**Purpose**: Pages, layouts, and routing configuration.
+
+**Characteristics**:
+- ✅ Only page.tsx, layout.tsx, route.tsx files
+- ✅ Can import from `components/`, `ui/`, `hooks/`
+- ✅ Can use Next.js features (redirect, cookies, headers)
+- ✅ Can use Server Components
+- ❌ **NO raw HTML with styling**
+- ❌ **NO business logic**
+- ❌ **NO state management**
+
+**Allowed**:
+```typescript
+// app/dashboard/page.tsx
+export default function DashboardPage() {
+ return (
+