diff --git a/.claude/rules/loading-states.md b/.claude/rules/loading-states.md new file mode 100644 index 000000000..9c7e8d6b2 --- /dev/null +++ b/.claude/rules/loading-states.md @@ -0,0 +1,12 @@ +--- +paths: + - '**/src/pages/**/*.tsx' + - '**/src/components/**/*.tsx' +--- + +# Loading states + +- Every asynchronous read renders loading, error and settled states, and empty where that can happen. Tests and stories cover each one. +- Loading renders a skeleton in the shape of the content it stands in for, built from `LoadingContainer` (`src/components/Layout/LoadingContainer/`). A page's skeleton is `[PageName]Skeleton`, a component's `[ComponentName]Skeleton`, in that page's `components/` folder. +- Data goes stale when a refetch fails or a live feed stops. Show that a value is stale or unavailable; never present an old value as current. +- While data loads, controls that would act on it are disabled. diff --git a/.claude/rules/react.md b/.claude/rules/react.md new file mode 100644 index 000000000..62d5ce68a --- /dev/null +++ b/.claude/rules/react.md @@ -0,0 +1,13 @@ +--- +paths: + - '**/src/**/*.tsx' + - '**/src/**/*.ts' +--- + +# React + +- Components are function declarations returning `JSX.Element`, defined at module level (never inside another component), with props typed in `.types.ts`. +- Derive values during render. Don't copy props or query data into state, or set state in an effect to mirror something you can compute; to reset state when a prop changes, give the component a `key` or compare with the previous value during render. +- Keep render pure: no `Date.now()`, `Math.random()` or `ref.current` during render. Take the time from a clock hook or a prop, and read refs in effects and event handlers. +- Key list items by what identifies them in the data, never by their index. +- `useMemo`/`memo` only for expensive work or for an identity a downstream memo depends on. The React Compiler's rules run in lint only; the build does not memoize for you. diff --git a/.claude/rules/storybook.md b/.claude/rules/storybook.md index de236dba8..2e9d0587a 100644 --- a/.claude/rules/storybook.md +++ b/.claude/rules/storybook.md @@ -1,3 +1,10 @@ +--- +paths: + - '**/src/**/*.stories.tsx' + - '**/src/**/*.mdx' + - '**/.storybook/**' +--- + # Storybook When creating a new story, add the following decorators: @@ -5,7 +12,7 @@ When creating a new story, add the following decorators: ```tsx decorators: [ Story => ( -
+
), @@ -13,3 +20,11 @@ decorators: [ ``` When choosing a title, use the full nested path to the story, e.g. `Components/Layout/Container`. + +## Stories are tests + +Every story is a test in Chromium under the light, dark and star themes (`pnpm test:storybook`). A failed `play`, a console error, an accessibility violation or an unmocked `/api/` request fails it. Fix the story or the component; the `test-exclude` tag is only for stories that never settle (endless timers, WebGL). + +- Mock the API per story with `parameters.msw.handlers`, reusing `@/test-utils/handlers`: the pending handler for loading, the failing one for errors. A keyed handler (`config`, `bounds`) replaces the preview's default for that key. +- Fixture times come from `NOW_MS` in `@/test-utils/time`. A component that shows times relative to now gets `beforeEach: freezeNow` on its meta. Random data comes from `seededRandom` in `@/test-utils/random`, so a story renders the same on every run. +- Each `play` awaits the state its story shows, with `findBy…` for async data. Await every `expect` and `userEvent`. diff --git a/.claude/rules/styling.md b/.claude/rules/styling.md new file mode 100644 index 000000000..5485456a1 --- /dev/null +++ b/.claude/rules/styling.md @@ -0,0 +1,42 @@ +--- +paths: + - '**/src/**/*.tsx' + - '**/src/**/*.ts' + - '**/src/**/*.css' + - '**/src/**/*.mdx' +--- + +# Styling + +- Every value is a token in `src/styles/tokens.css`, used as a Tailwind class. Tailwind's defaults are removed, so + `bg-red-500`, `text-white`, `text-7xl` and `shadow-2xl` do not exist. Spacing and sizing use the 4px step (`p-4`, + `gap-6`, `h-75`); each text size carries its line height, and `text-sm/6` sets another. +- Lint rejects classes and colours that bypass the tokens: arbitrary values (`h-[300px]`), `dark:` variants, + `leading-*`, opacity modifiers (`bg-primary/10`), literal values in `style`, and colour literals in SVG colour + attributes. `style` only carries values computed at runtime, such as a height passed as a prop. Lint can't see a fixed + value behind a constant (`height: ROW_HEIGHT`), so make that a class too, unless the constant keeps several elements + aligned. It reads class strings in `className` and `…ClassName` props, `clsx`, the router's `activeProps` and + `inactiveProps`, class constants and variant maps named `…ClassName`, `…Classes` or `…Styles`, and arrow-function + helpers named `get…Class` or `get…Styles`. Write each class whole (not `` `text-${tone}` ``) so lint checks it and + Tailwind finds it. +- Name tokens for their role (`danger`, `muted`, `panel`), never their look. Tags and chips name their category. +- Every base colour is a `light-dark(light, dark)` pair with its own value in `html.star`. Lab has three themes: light + and dark are the two sides of each pair, and star is a dark scheme with its own palette. Colours derived from the + base ones (tints, `panel`, `on-solid`) follow all three. Give a colour a pair or use a derived token; never a + `dark:` variant. `on-dark:` is for what a token cannot carry, such as swapping a logo, and never takes a colour. +- Layouts Tailwind has no class for (grid templates, a size less the viewport, card insets) are named utilities in + `src/styles/utilities.css`. +- No state is carried by colour alone: a word or a mark goes with it. +- Charts read colours through `useThemeColors()` and `src/utils/dataVizColors.ts`, which resolve the tokens for the + active theme. + +## Adding a token + +1. Add it to the `@theme static` block in `tokens.css`, in Tailwind's namespace for it (`--color-*`, `--text-*` with + its `--text-*--line-height`, `--radius-*`, `--container-*`, …). A base colour is `light-dark()` of two hex values, + with its star value in `html.star`. Any other colour derives from the base ones with `var()`, or is a tint: + `--color-primary-15: color-mix(in oklab, var(--color-primary) 15%, transparent)`. +2. Add a base colour to its group in `src/styles/tokens.ts`, and to a `CONTRAST_RULES` entry or to + `CONTRAST_EXEMPT` with the reason. A derived colour goes in `DERIVED_COLORS`. +3. `pnpm test:unit` checks the file, the colour names and tints, contrast in all three themes, and that `index.html` + and `ThemeProvider` give the browser each theme's background. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md new file mode 100644 index 000000000..e88094895 --- /dev/null +++ b/.claude/rules/tests.md @@ -0,0 +1,13 @@ +--- +paths: + - '**/src/**/*.test.ts' + - '**/src/**/*.test.tsx' + - '**/src/test-utils/**' +--- + +# Tests + +- Unit tests render through `@/test-utils` (`render`, `renderHook`: the app's query client without retries and a router; pass `client`, `wrapper` or `initialProps` when a test needs them) and mock the API with `server.use(...)` MSW handlers. A request no handler answers fails the test. Don't stub `fetch`; lint rejects importing `@testing-library/react` directly. +- `src/test-utils/handlers/.ts` holds a fixture typed with the app's types and a `Handlers` object with a handler for each state a test or story uses (such as `success`, `failure`, and `pending`, which never resolves), re-exported from `handlers/index.ts`. Unit tests and stories share them. +- Fixtures, tests and stories take the current time from `@/test-utils/time` (`NOW_MS`), never `Date.now()` or `new Date()`, and random data from `seededRandom` in `@/test-utils/random`, never `Math.random()`. +- Fix a flaky or failing test; lint fails on `.only` and `.skip`. diff --git a/.claude/rules/theming.md b/.claude/rules/theming.md deleted file mode 100644 index b18aff66b..000000000 --- a/.claude/rules/theming.md +++ /dev/null @@ -1,28 +0,0 @@ -# Theming - -Theme uses a **two-tier color architecture** defined in `src/index.css`: - -**Tier 1:** Primitive scales (terracotta, sand, neutral) with 50-950 shades -**Tier 2:** Semantic tokens that reference Tier 1 - -## Semantic Tokens - -- `primary`, `secondary`, `accent` - Brand colors -- `background`, `surface`, `foreground`, `muted`, `border` - UI colors -- `success`, `warning`, `danger` - State colors - -## Usage - -```tsx -// Always use semantic tokens -className="bg-primary text-foreground border-border" -className="hover:bg-accent text-muted" - -// Programmatic access -import { useThemeColors } from '@/hooks/useThemeColors'; -const colors = useThemeColors(); // { primary, background, ... } -``` - -**Never use primitive scales directly** (`bg-terracotta-500`, `bg-sand-100`) - only semantic tokens. - -**Modify theme:** Edit semantic mappings in `src/index.css` at `@layer base` (`:root` for light, `html.dark` for dark). diff --git a/.claude/skills/clean-copy/SKILL.md b/.claude/skills/clean-copy/SKILL.md index b8200b024..134b9e0aa 100644 --- a/.claude/skills/clean-copy/SKILL.md +++ b/.claude/skills/clean-copy/SKILL.md @@ -32,7 +32,7 @@ field, or the value beside it already says the thing, the caption is noise. A fi The tell: copy that rules out a failure rather than describing a behaviour. > Cancel upload -> *Always keeps the files already transferred — never wipes the folder like the old cancel did.* +> _Always keeps the files already transferred — never wipes the folder like the old cancel did._ That second clause exists because of a bug. The user doesn't know about the bug, doesn't share your memory of fixing it, and now has a worry they arrived without. **The fix for a fixed bug @@ -48,16 +48,16 @@ component; an explanatory `

` gets deleted, not shortened to a stub. Copy stays when the user genuinely cannot act without it: -- A real consequence at the moment of choosing. Under a destructive button: *"Anything not yet - synced is lost."* They need it before they click, and they can't infer it. -- A compatibility or scope fact they can't see. *"Works with keys issued after March; older - keys need rotating first."* The checkbox alone doesn't tell them whether it applies to them. -- What a blank state means and what to do next. *"No projects yet. Create one and it will - appear here."* +- A real consequence at the moment of choosing. Under a destructive button: _"Anything not yet + synced is lost."_ They need it before they click, and they can't infer it. +- A compatibility or scope fact they can't see. _"Works with keys issued after March; older + keys need rotating first."_ The checkbox alone doesn't tell them whether it applies to them. +- What a blank state means and what to do next. _"No projects yet. Create one and it will + appear here."_ - Units, scale, or format on a bare value. -Note the first example is one sentence. Had it continued *"— only use this when a sync is -stuck"*, that half would go: it instructs the user on when they're allowed to press a button +Note the first example is one sentence. Had it continued _"— only use this when a sync is +stuck"_, that half would go: it instructs the user on when they're allowed to press a button they can already see. Keep the consequence, cut the permission slip. ## Working style diff --git a/.claude/skills/create-new-component/SKILL.md b/.claude/skills/create-new-component/SKILL.md index 1441e75f8..1846688c7 100644 --- a/.claude/skills/create-new-component/SKILL.md +++ b/.claude/skills/create-new-component/SKILL.md @@ -9,7 +9,7 @@ This skill creates React components following Lab's established architecture pat ## Technology Stack -- **React**: v19 (using forwardRef, JSX.Element) +- **React**: v19 (`ref` as a prop, JSX.Element) - **TypeScript**: v5 (strict typing) - **Tailwind CSS**: v4 (semantic color tokens) - **Storybook**: v9 (autodocs with decorators) @@ -23,6 +23,7 @@ This skill creates React components following Lab's established architecture pat **STOP AND ASK QUESTIONS** unless the user has provided complete specifications. Ask the user to clarify: + 1. **Component scope**: Is this reusable across pages (core) or page-specific? 2. **Category**: Which category fits best? (see Component Categories below) 3. **Functionality**: What should this component do? What props are needed? @@ -34,8 +35,10 @@ Only proceed after understanding the requirements. ## Component Architecture ### Core Components (`src/components/`) + **When**: Reusable across multiple pages/sections **Requirements**: + - Generic and configurable via props - No page-specific business logic - No direct API calls (accept data via props) @@ -44,8 +47,10 @@ Only proceed after understanding the requirements. - Compose from other core components when logical ### Page-Scoped Components (`src/pages/[section]/components/`) + **When**: Used within specific page/section only **Requirements**: + - Page-specific business logic allowed - Can use hooks (useNetwork, API hooks, etc.) - Compose/extend core components @@ -55,9 +60,11 @@ Only proceed after understanding the requirements. ## Component Categories ### Core Component Categories (src/components/) + - Use `ls` to list the components in the `src/components/` directory. ### Page Sections (src/pages/) + - Use `ls` to list the pages in the `src/pages/` directory. ## Standard Component Structure @@ -73,7 +80,9 @@ ComponentName/ ## Implementation Steps ### 1. Research Existing Components + Before implementing, examine similar existing components: + - Check category for existing patterns - Review how props are structured - Observe Tailwind class patterns @@ -82,11 +91,13 @@ Before implementing, examine similar existing components: ### 2. Create Component Structure **For Core Components:** + ``` src/components/[Category]/[ComponentName]/ ``` **For Page-Scoped Components:** + ``` src/pages/[section]/components/[ComponentName]/ ``` @@ -94,8 +105,10 @@ src/pages/[section]/components/[ComponentName]/ ### 3. Implement Component File **Key Patterns:** + ```tsx -import { forwardRef } from 'react'; +import type { JSX } from 'react'; +import clsx from 'clsx'; import type { ComponentNameProps } from './ComponentName.types'; // Inline Tailwind class definitions (not objects) @@ -103,34 +116,36 @@ const baseClasses = 'inline-flex items-center font-semibold'; // Variant classes when needed const variantClasses = { - primary: 'bg-primary text-white hover:bg-primary/90', - secondary: 'bg-white text-gray-900 hover:bg-gray-50', + primary: 'bg-primary text-on-fill hover:bg-primary-hover', + secondary: 'bg-surface text-foreground hover:bg-surface-hover', }; -export const ComponentName = forwardRef( - ({ variant = 'primary', className = '', children, ...props }, ref) => { - const classes = [baseClasses, variantClasses[variant], className] - .filter(Boolean) - .join(' '); - - return ( - - {children} - - ); - } -); - -ComponentName.displayName = 'ComponentName'; +// React 19 passes ref as a prop, so there is no forwardRef or displayName +export function ComponentName({ + variant = 'primary', + className, + children, + ref, + ...props +}: ComponentNameProps): JSX.Element { + return ( +

+ {children} +
+ ); +} ``` **Semantic Colors:** + - Use tokens: `bg-surface`, `text-foreground`, `text-muted`, `border-border` - Brand: `bg-primary`, `text-primary`, `bg-secondary`, `bg-accent` -- States: `text-success`, `text-warning`, `text-error` -- Avoid hard-coded colors (no `text-gray-500`) +- States: `text-success`, `text-warning`, `text-danger` +- Only token classes: no hard-coded colors (`text-gray-500`) or arbitrary values (`min-w-[600px]`); see + [Styling](../../rules/styling.md) **Conditional Classes:** + ```tsx import { clsx } from 'clsx'; @@ -145,12 +160,17 @@ className={clsx( ### 4. Create Types File (when needed) ```tsx -import type { ComponentPropsWithoutRef, ReactNode } from 'react'; +import type { ComponentPropsWithoutRef, ReactNode, Ref } from 'react'; -export type ComponentVariant = 'primary' | 'secondary'; -export type ComponentSize = 'sm' | 'md' | 'lg'; +// Export a type only once another module imports it; pnpm knip fails on unused exports +type ComponentVariant = 'primary' | 'secondary'; + +export interface ComponentNameProps extends ComponentPropsWithoutRef<'div'> { + /** + * Ref to the rendered element + */ + ref?: Ref; -export interface ComponentNameProps extends ComponentPropsWithoutRef<'element'> { /** * The visual style variant * @default 'primary' @@ -167,6 +187,7 @@ export interface ComponentNameProps extends ComponentPropsWithoutRef<'element'> ### 5. Create Storybook Stories (Core Components Only) **Required Template:** + ```tsx import type { Meta, StoryObj } from '@storybook/react-vite'; import { ComponentName } from './ComponentName'; @@ -179,7 +200,7 @@ const meta = { }, decorators: [ Story => ( -
+
), @@ -214,6 +235,7 @@ export const AllVariants: Story = { ``` **Story Best Practices:** + - Use full nested path: `Components/[Category]/ComponentName` - Include `decorators` with `bg-surface` wrapper - Show all variants/states @@ -222,18 +244,16 @@ export const AllVariants: Story = { ### 6. Create Barrel Export +Export what other modules import, usually just the component. Add a type only once a consumer imports it: `pnpm knip` fails on exports that nothing imports. + ```tsx export { ComponentName } from './ComponentName'; -export type { - ComponentNameProps, - ComponentVariant, - ComponentSize -} from './ComponentName.types'; ``` ## Composition Patterns ### Core Composing Core + ```tsx // NetworkSelect composes SelectMenu import { SelectMenu } from '@/components/Forms/SelectMenu'; @@ -251,6 +271,7 @@ export function NetworkSelect({ showLabel = true }: Props) { ``` ### Page-Scoped Composing Core + ```tsx // UserCard composes Card, Badge, ClientLogo import { Card } from '@/components/Layout/Card'; @@ -271,19 +292,18 @@ export function UserCard({ username, clients }: Props) { ## Common Component Patterns ### Icon Handling (Heroicons) + ```tsx -import { cloneElement, isValidElement } from 'react'; - -// Clone icon element to apply size classes -{leadingIcon && isValidElement(leadingIcon) && - cloneElement(leadingIcon, { - 'aria-hidden': 'true', - className: 'size-5', - } as Record) -} +// Size the icon from a box-less wrapper, as Button does: the icon keeps its own classes and stays a flex item +{leadingIcon && ( + +)} ``` ### Size Variants + ```tsx const sizeClasses = { sm: 'px-2 py-1 text-sm', @@ -293,31 +313,33 @@ const sizeClasses = { ``` ### Interactive States + ```tsx +// primary-hover is a light-dark() token, so no dark: variant is needed const baseClasses = ` inline-flex items-center - hover:bg-primary/90 + hover:bg-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 - dark:hover:bg-primary/80 `; ``` ### Headless UI Integration + ```tsx import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'; - + {/* Button content */} - - {options.map((option, index) => ( + + {options.map(option => ( {option.label} @@ -329,6 +351,7 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headless ## Validation Checklist Before completing: + - [ ] Matches existing component patterns in category - [ ] Uses semantic Tailwind tokens (not hard-coded colors) - [ ] TypeScript types properly defined @@ -338,20 +361,23 @@ Before completing: - [ ] Stories include all variants/states - [ ] Component composes existing components when appropriate - [ ] No page-specific logic in core components -- [ ] Accessibility attributes included (aria-*, role) +- [ ] Accessibility attributes included (aria-\*, role) - [ ] Dark mode handled via semantic tokens -- [ ] Icon handling uses cloneElement pattern +- [ ] Icons are sized from a wrapper, never with cloneElement - [ ] `pnpm lint` passes +- [ ] `pnpm knip` passes - [ ] `pnpm build` passes ## Testing in Storybook For core components, use Storybook for iteration: + ```bash pnpm storybook ``` Use Playwright MCP tools to: + 1. Navigate to component story 2. Take screenshots of variants 3. Test interactions @@ -360,6 +386,7 @@ Use Playwright MCP tools to: ## Common Mistakes to Avoid ❌ **DON'T**: + - Create new component without asking scope questions - Hard-code colors (`text-gray-500` instead of `text-muted`) - Skip Storybook stories for core components @@ -369,6 +396,7 @@ Use Playwright MCP tools to: - Create standalone files (use folder structure) ✅ **DO**: + - Research existing patterns first - Ask clarifying questions - Use semantic color tokens @@ -403,6 +431,7 @@ Use Playwright MCP tools to: 5. **Validate**: - Test in Storybook - Run `pnpm lint` + - Run `pnpm knip` - Run `pnpm build` - Verify all checklist items diff --git a/.claude/skills/create-new-route/SKILL.md b/.claude/skills/create-new-route/SKILL.md index c600b8a8e..78b145dc8 100644 --- a/.claude/skills/create-new-route/SKILL.md +++ b/.claude/skills/create-new-route/SKILL.md @@ -8,8 +8,9 @@ description: Learn how to create a new route in The Lab. This skill creates production-ready React routes following Lab's established architecture patterns. ## Routing & Pages + - Route files in `src/routes/` - thin TanStack Router definitions - Page components in `src/pages/` - UI implementations - Page-scoped components in `pages/[section]/components/` - Dynamic routes use `$param.tsx` syntax -- Always barrel export via `index.tsx` \ No newline at end of file +- Always barrel export via `index.tsx` diff --git a/.claude/skills/visualize-data/skill.md b/.claude/skills/visualize-data/skill.md index a51eda05f..37743af5d 100644 --- a/.claude/skills/visualize-data/skill.md +++ b/.claude/skills/visualize-data/skill.md @@ -3,45 +3,47 @@ name: Visualize any data description: Learn how to visualize data in a sustainable, accurate, and theme aware way. --- -Whenever Claude is asked to visualize data, you must follow the steps below. +Whenever Claude is asked to visualize data, you must follow the steps below. + - Chart components live in the `src/components/Charts` directory -- If you need a new core component type, create it in the `src/components/Charts` directory. Inspect the existing components to see how to create a new one. Seperation of concerns is important. Make storybook stories for the new component. +- If you need a new core component type, create it in the `src/components/Charts` directory. Inspect the existing components to see how to create a new one. Seperation of concerns is important. Make storybook stories for the new component. - `echarts` and `echarts-for-react` are the only libraries you can use to visualize data, and you must use them in the `src/components/Charts` directory. - If you think the chart can be reusable, create it within the `src/components/*` directory. For example, `src/components/Ethereum/AttestationsByEntity`. ## Step 1: What Are You Trying to Show? -| Goal | Best Chart Types | Use When | -|------|------------------|----------| -| **Trend over time** | Line, Step, Area | X-axis is temporal (time, slot, epoch, block) | -| **Compare categories** | Bar, Column, Dot plot | Comparing distinct groups | -| **Show distribution** | Histogram, Box plot, Violin | Understanding spread/shape of data | -| **Show relationship** | Scatter, Bubble | Correlation between two+ variables | -| **Show composition** | Stacked bar, Pie, Treemap | Parts of a whole | -| **Show single value** | Number card, Gauge | Key metric snapshot | +| Goal | Best Chart Types | Use When | +| ---------------------- | --------------------------- | --------------------------------------------- | +| **Trend over time** | Line, Step, Area | X-axis is temporal (time, slot, epoch, block) | +| **Compare categories** | Bar, Column, Dot plot | Comparing distinct groups | +| **Show distribution** | Histogram, Box plot, Violin | Understanding spread/shape of data | +| **Show relationship** | Scatter, Bubble | Correlation between two+ variables | +| **Show composition** | Stacked bar, Pie, Treemap | Parts of a whole | +| **Show single value** | Number card, Gauge | Key metric snapshot | ## Step 2: Time-Series Data Patterns ### Data Type Detection → Interpolation Method -| Data Characteristics | Chart Config | Example Metrics | -|---------------------|--------------|-----------------| -| **Integer values only, non-negative** | Step chart: `step: 'middle'` | blob count, transaction count, user count | -| **Values hold between updates (state changes)** | Step chart: `step: 'start'` | validator status, network state, config value | -| **Continuous decimals, volatile** | Line: `smooth: false` | gas price, latency, response time | -| **Continuous decimals, trend focus** | Line: `smooth: true` | moving averages, smoothed rates | -| **Categorical states** | Step chart: `step: 'start'` + colors | validator status, network state | -| **Sparse/irregular events** | Scatter plot | slashing events, errors, alerts | +| Data Characteristics | Chart Config | Example Metrics | +| ----------------------------------------------- | ------------------------------------ | --------------------------------------------- | +| **Integer values only, non-negative** | Step chart: `step: 'middle'` | blob count, transaction count, user count | +| **Values hold between updates (state changes)** | Step chart: `step: 'start'` | validator status, network state, config value | +| **Continuous decimals, volatile** | Line: `smooth: false` | gas price, latency, response time | +| **Continuous decimals, trend focus** | Line: `smooth: true` | moving averages, smoothed rates | +| **Categorical states** | Step chart: `step: 'start'` + colors | validator status, network state | +| **Sparse/irregular events** | Scatter plot | slashing events, errors, alerts | ### Quick Decision Tree for Time-Series + ``` -Is data always integers? +Is data always integers? ├─ YES → Can you have 3.5 of this thing? │ ├─ NO → Step chart (step: 'middle') │ └─ YES → It's rounded data → Line chart │ └─ NO → Does value stay constant between measurements? - ├─ YES → Step chart (step: 'start') + ├─ YES → Step chart (step: 'start') └─ NO → Line chart (linear or smooth) ``` @@ -49,103 +51,103 @@ Is data always integers? ### Comparison Charts -| Scenario | Chart Type | Configuration Notes | -|----------|-----------|---------------------| -| Compare 3-10 categories | **Bar chart** (horizontal if labels long) | Start axis at zero | -| Compare 10+ categories | **Dot plot** or **Horizontal bar** | Easier to read labels | -| Compare across 2 dimensions | **Grouped bar** | Limit to 2-4 groups per category | -| Compare ratios/percentages | **Bar chart** | NOT pie chart | -| Compare ranges | **Box plot** | Shows min/max/quartiles | +| Scenario | Chart Type | Configuration Notes | +| --------------------------- | ----------------------------------------- | -------------------------------- | +| Compare 3-10 categories | **Bar chart** (horizontal if labels long) | Start axis at zero | +| Compare 10+ categories | **Dot plot** or **Horizontal bar** | Easier to read labels | +| Compare across 2 dimensions | **Grouped bar** | Limit to 2-4 groups per category | +| Compare ratios/percentages | **Bar chart** | NOT pie chart | +| Compare ranges | **Box plot** | Shows min/max/quartiles | ### Distribution Charts -| Data Type | Chart Type | Use When | -|-----------|-----------|----------| -| Single continuous variable | **Histogram** | Show frequency distribution | -| Multiple distributions to compare | **Box plot** or **Violin plot** | Compare shapes across groups | -| Small dataset (<50 points) | **Dot plot** or **Strip plot** | Show individual points | -| Check for outliers | **Box plot** | Visualize quartiles and extremes | +| Data Type | Chart Type | Use When | +| --------------------------------- | ------------------------------- | -------------------------------- | +| Single continuous variable | **Histogram** | Show frequency distribution | +| Multiple distributions to compare | **Box plot** or **Violin plot** | Compare shapes across groups | +| Small dataset (<50 points) | **Dot plot** or **Strip plot** | Show individual points | +| Check for outliers | **Box plot** | Visualize quartiles and extremes | ### Relationship Charts -| Scenario | Chart Type | Notes | -|----------|-----------|-------| -| Two continuous variables | **Scatter plot** | Look for correlation | -| Three variables (2 continuous + 1 size) | **Bubble chart** | Size = third dimension | -| Many overlapping points | **Hex bin** or **2D histogram** | Shows density | -| Categorical + continuous | **Box plot** or **Violin** | Distribution per category | +| Scenario | Chart Type | Notes | +| --------------------------------------- | ------------------------------- | ------------------------- | +| Two continuous variables | **Scatter plot** | Look for correlation | +| Three variables (2 continuous + 1 size) | **Bubble chart** | Size = third dimension | +| Many overlapping points | **Hex bin** or **2D histogram** | Shows density | +| Categorical + continuous | **Box plot** or **Violin** | Distribution per category | ### Composition Charts -| Scenario | Chart Type | Use When | -|----------|-----------|----------| -| Parts of whole (2-5 parts) | **Donut chart** | Simple proportions | -| Parts of whole (6+ parts) | **Bar chart** (NOT pie) | Too many slices are unreadable | -| Hierarchical composition | **Treemap** or **Sunburst** | Nested categories | -| Composition over time | **Stacked area** | Show how parts change | -| 100% composition | **Stacked bar (100%)** | Compare proportions across groups | +| Scenario | Chart Type | Use When | +| -------------------------- | --------------------------- | --------------------------------- | +| Parts of whole (2-5 parts) | **Donut chart** | Simple proportions | +| Parts of whole (6+ parts) | **Bar chart** (NOT pie) | Too many slices are unreadable | +| Hierarchical composition | **Treemap** or **Sunburst** | Nested categories | +| Composition over time | **Stacked area** | Show how parts change | +| 100% composition | **Stacked bar (100%)** | Compare proportions across groups | ## Step 4: Universal Rules ### Always Do: -| Rule | Why | How | -|------|-----|-----| -| **Start Y-axis at zero for bars** | Human eyes judge area; starting elsewhere misleads | Set `yAxis: { min: 0 }` | -| **Use consistent colors** | Same metric = same color across charts | Define color palette | -| **Label axes clearly** | Include units (gwei, %, count, etc.) | `name: 'Gas Price (gwei)'` | -| **Limit colors per chart** | Too many colors = cognitive overload | Max 5-7 distinct colors | -| **Show data directly when <20 points** | Let users see actual values | Add data labels or table | -| **Use integers for count data** | Fractional counts don't exist | `yAxis: { minInterval: 1 }` | -| **Use filled step chart for count data** | Step chart is the most accurate way to represent count data | `step: 'middle'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2` | -| **Disable forced max label** | Prevents awkward tick spacing (e.g., 1, 6, 11, 16, 21, 26, 31, 32) | `axisLabel: { showMaxLabel: false }` in x-axis config | +| Rule | Why | How | +| ---------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| **Start Y-axis at zero for bars** | Human eyes judge area; starting elsewhere misleads | Set `yAxis: { min: 0 }` | +| **Use consistent colors** | Same metric = same color across charts | Define color palette | +| **Label axes clearly** | Include units (gwei, %, count, etc.) | `name: 'Gas Price (gwei)'` | +| **Limit colors per chart** | Too many colors = cognitive overload | Max 5-7 distinct colors | +| **Show data directly when <20 points** | Let users see actual values | Add data labels or table | +| **Use integers for count data** | Fractional counts don't exist | `yAxis: { minInterval: 1 }` | +| **Use filled step chart for count data** | Step chart is the most accurate way to represent count data | `step: 'middle'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2` | +| **Disable forced max label** | Prevents awkward tick spacing (e.g., 1, 6, 11, 16, 21, 26, 31, 32) | `axisLabel: { showMaxLabel: false }` in x-axis config | ### Never Do: -| Mistake | Why It's Bad | Correct Approach | -|---------|--------------|------------------| -| **Smooth lines for count data** | Implies fractional values exist (e.g., 3.5 transactions) | Use step chart | -| **Pie charts with >5 slices** | Impossible to compare similar-sized slices | Use bar chart | -| **3D charts** | Distorts perception, harder to read | Use 2D always | -| **Dual-axis with unrelated scales** | Can manipulate perception | Only if truly related metrics | -| **Too many series on one chart** | Becomes unreadable | Split into multiple charts | -| **Define your own color palette** | Use the `useThemeColors` hook to get the current theme colors | `const themeColors = useThemeColors();` | +| Mistake | Why It's Bad | Correct Approach | +| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------- | +| **Smooth lines for count data** | Implies fractional values exist (e.g., 3.5 transactions) | Use step chart | +| **Pie charts with >5 slices** | Impossible to compare similar-sized slices | Use bar chart | +| **3D charts** | Distorts perception, harder to read | Use 2D always | +| **Dual-axis with unrelated scales** | Can manipulate perception | Only if truly related metrics | +| **Too many series on one chart** | Becomes unreadable | Split into multiple charts | +| **Define your own color palette** | Use the `useThemeColors` hook to get the current theme colors | `const themeColors = useThemeColors();` | ## Step 5: Common Metric Patterns ### Pattern Library (Generic) -| Metric Type | Example | Chart | Settings | -|-------------|---------|-------|----------| -| **Event count per period** | "transactions per block" | Step | `step: 'middle'`, `minInterval: 1` | -| **Total/cumulative** | "total users", "cumulative revenue" | Area or Line | `smooth: false` or filled area | -| **Rate/percentage** | "success rate", "utilization %" | Line | `smooth: false`, range 0-100 | -| **Price/fee** | "gas price", "transaction fee" | Line or Step | Step if updates discrete, Line if continuous | -| **Average** | "average latency", "mean value" | Line | `smooth: true` for trends | -| **Status/state** | "server status", "order state" | Step | `step: 'start'`, use colors | -| **Distribution** | "transaction sizes" | Histogram | Bin count = √n as starting point | -| **Comparison** | "revenue by product" | Bar | Horizontal if many categories | -| **Correlation** | "price vs volume" | Scatter | Add trendline if useful | +| Metric Type | Example | Chart | Settings | +| -------------------------- | ----------------------------------- | ------------ | -------------------------------------------- | +| **Event count per period** | "transactions per block" | Step | `step: 'middle'`, `minInterval: 1` | +| **Total/cumulative** | "total users", "cumulative revenue" | Area or Line | `smooth: false` or filled area | +| **Rate/percentage** | "success rate", "utilization %" | Line | `smooth: false`, range 0-100 | +| **Price/fee** | "gas price", "transaction fee" | Line or Step | Step if updates discrete, Line if continuous | +| **Average** | "average latency", "mean value" | Line | `smooth: true` for trends | +| **Status/state** | "server status", "order state" | Step | `step: 'start'`, use colors | +| **Distribution** | "transaction sizes" | Histogram | Bin count = √n as starting point | +| **Comparison** | "revenue by product" | Bar | Horizontal if many categories | +| **Correlation** | "price vs volume" | Scatter | Add trendline if useful | ## Step 6: Data Volume Guidelines -| Data Points | Approach | Reasoning | -|-------------|----------|-----------| -| **< 20 points** | Show all detail, consider data labels | Every point is visible | -| **20-100 points** | Show all, use tooltips | Still manageable | -| **100-1,000 points** | Show all with interaction (zoom/pan) | Need exploration tools | -| **1,000-10,000 points** | Aggregate OR downsample | Too dense for individual points | -| **> 10,000 points** | Must aggregate or use density visualization | Line chart becomes solid blob | +| Data Points | Approach | Reasoning | +| ----------------------- | ------------------------------------------- | ------------------------------- | +| **< 20 points** | Show all detail, consider data labels | Every point is visible | +| **20-100 points** | Show all, use tooltips | Still manageable | +| **100-1,000 points** | Show all with interaction (zoom/pan) | Need exploration tools | +| **1,000-10,000 points** | Aggregate OR downsample | Too dense for individual points | +| **> 10,000 points** | Must aggregate or use density visualization | Line chart becomes solid blob | ## Step 7: Interpolation Quick Reference ### Step Chart: When to Use Which Direction -| Direction | When to Use | Example | -|-----------|-------------|---------| -| `step: 'middle'` | Value **REPRESENTS THE ENTIRE PERIOD** | Blob count in slot N, transactions in block N, gas used in epoch N | -| `step: 'start'` | Value **HOLDS FROM** this point until next change | Validator status changed at slot N, config updated at block N | -| `step: 'end'` | Rarely used - value measured at end of period | Uncommon - consider `step: 'middle'` instead | +| Direction | When to Use | Example | +| ---------------- | ------------------------------------------------- | ------------------------------------------------------------------ | +| `step: 'middle'` | Value **REPRESENTS THE ENTIRE PERIOD** | Blob count in slot N, transactions in block N, gas used in epoch N | +| `step: 'start'` | Value **HOLDS FROM** this point until next change | Validator status changed at slot N, config updated at block N | +| `step: 'end'` | Rarely used - value measured at end of period | Uncommon - consider `step: 'middle'` instead | ### Step Chart Visual Styling (IMPORTANT) @@ -162,18 +164,19 @@ Is data always integers? ``` **Why this pattern:** + - ❌ **Heavy style** (`areaOpacity: 1`, `lineWidth: 0`) looks blocky, too visually dominant - ✅ **Light style** (`areaOpacity: 0.3`, `lineWidth: 2`) maintains data visibility while being easier to read - The visible line helps trace values, while the light fill provides context without overwhelming ### Smooth vs Linear for Continuous Data -| Use Case | Setting | When | -|----------|---------|------| +| Use Case | Setting | When | +| ----------------------------- | --------------- | ----------------------------------------- | | **Show volatility/precision** | `smooth: false` | Raw measurements, user needs exact values | -| **Show overall trend** | `smooth: true` | Filtered/averaged data, focus on pattern | -| **Moving average** | `smooth: true` | Already smoothed data | -| **Real-time fluctuations** | `smooth: false` | Price tickers, live monitoring | +| **Show overall trend** | `smooth: true` | Filtered/averaged data, focus on pattern | +| **Moving average** | `smooth: true` | Already smoothed data | +| **Real-time fluctuations** | `smooth: false` | Price tickers, live monitoring | ## Step 8: Validation Checklist @@ -185,7 +188,6 @@ Before finalizing any chart, ask: - [ ] **Zero baseline**: For bar charts, does Y-axis start at zero? - If NO and showing bars → Fix it - - [ ] **Color count**: Am I using more than 7 colors? - If YES → Reduce or group categories @@ -200,17 +202,17 @@ Before finalizing any chart, ask: ## Quick Decision Matrix -| I Have... | I Want To... | Use This | -|-----------|--------------|----------| -| Counts over time | Show exact counts per period | Step chart with filled area (`step: 'middle'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2`) | -| Measurements over time | Show trend | Line (smooth) | -| Measurements over time | Show volatility | Line (linear) | -| Values that update occasionally | Show when changes occur | Step chart with filled area (`step: 'start'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2`) | -| Categories to compare | Compare values | Bar chart (horizontal if labels long) | -| Parts of a whole | Show composition | Donut (if ≤5) or Bar chart | -| Two variables | Find correlation | Scatter plot | -| One variable distribution | Understand spread | Histogram or Box plot (small dataset (<50 points) use Dot plot or Strip plot) | -| States over time | Show transitions | Step chart with filled area (`step: 'start'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2`) + colors | +| I Have... | I Want To... | Use This | +| ------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Counts over time | Show exact counts per period | Step chart with filled area (`step: 'middle'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2`) | +| Measurements over time | Show trend | Line (smooth) | +| Measurements over time | Show volatility | Line (linear) | +| Values that update occasionally | Show when changes occur | Step chart with filled area (`step: 'start'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2`) | +| Categories to compare | Compare values | Bar chart (horizontal if labels long) | +| Parts of a whole | Show composition | Donut (if ≤5) or Bar chart | +| Two variables | Find correlation | Scatter plot | +| One variable distribution | Understand spread | Histogram or Box plot (small dataset (<50 points) use Dot plot or Strip plot) | +| States over time | Show transitions | Step chart with filled area (`step: 'start'`, `showArea: true`, `areaOpacity: 0.3`, `lineWidth: 2`) + colors | ## Key Principles to Remember @@ -218,4 +220,4 @@ Before finalizing any chart, ask: 2. **Simpler is better** - Use the simplest chart that answers the question 3. **Context matters** - Same data may need different viz for different questions 4. **Always label clearly** - Include units, axis names, and legends -5. **Test readability** - Can someone understand it in 5 seconds? \ No newline at end of file +5. **Test readability** - Can someone understand it in 5 seconds? diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md index f03e1f3ce..774d1f2e8 100644 --- a/.design-sync/NOTES.md +++ b/.design-sync/NOTES.md @@ -34,11 +34,11 @@ neither. The adaptations below make it work. Read these before a re-sync. auto-bundling of `.storybook/preview.tsx` decorators fails (the CSS/`.woff2` font import has no loader in the decorator esbuild pass), and lab's chain can't be expressed as static provider JSON (hydrated QueryClient + memory router). `DesignSyncProvider` replicates the - preview decorator (QueryClient hydrated with `mockConfig`/`mockBounds`, ThemeProvider, - ConfigGate, memory Router → NetworkProvider) and is added to the bundle via - `cfg.extraEntries`. **Theme is pinned to dark** (`themeOverride="dark"`) to match the - storybook default deterministically (storybook uses `withThemeByClassName` defaultTheme - `dark`, which the converter stubs to inert). + preview decorator (QueryClient hydrated with `configFixture`/`boundsFixture` from + `src/test-utils/handlers`, ThemeProvider, ConfigGate, memory Router → NetworkProvider) + and is added to the bundle via `cfg.extraEntries`. **Theme is pinned to dark** + (`themeOverride="dark"`) to match the storybook default deterministically (storybook uses + `withThemeByClassName` defaultTheme `dark`, which the converter stubs to inert). - **`.design-sync/ds-body-guard.ts`** (imported first by ds-providers) — lab computes dataviz colors at import time by probing CSS vars via `document.body.appendChild` (`getDataVizColors → resolveCssColorToHex`). The `[BUNDLE_EXPORT]` smoke check injects the diff --git a/.design-sync/ds-entry.ts b/.design-sync/ds-entry.ts index 7d6e0c3f5..0959e1922 100644 --- a/.design-sync/ds-entry.ts +++ b/.design-sync/ds-entry.ts @@ -101,13 +101,3 @@ export * from '../src/pages/ethereum/live/components/BlobDataAvailability/BlobDa export * from '../src/pages/ethereum/live/components/DataColumnDataAvailability/DataColumnDataAvailability'; export * from '../src/pages/ethereum/live/components/Timeline/Timeline'; export * from '../src/pages/ethereum/slots/components/PreparedBlocksComparisonChart/PreparedBlocksComparisonChart'; -export * from '../src/pages/xatu/contributors/components/UserCard/UserCard'; -export * from '../src/pages/xatu/contributors/components/UserCardSkeleton/UserCardSkeleton'; -export * from '../src/pages/xatu/contributors/components/UserDetailsSkeleton/UserDetailsSkeleton'; -export * from '../src/pages/xatu/fork-readiness/components/ClientReadinessCard/ClientReadinessCard'; -export * from '../src/pages/xatu/fork-readiness/components/ForkReadinessLoading/ForkReadinessLoading'; -export * from '../src/pages/xatu/geographical-checklist/components/GeographicalChecklistSkeleton/GeographicalChecklistSkeleton'; -export * from '../src/pages/xatu/geographical-checklist/components/GeographicalFilters/GeographicalFilters'; -export * from '../src/pages/xatu/geographical-checklist/components/GeographicalListView/GeographicalListView'; -export * from '../src/pages/xatu/geographical-checklist/components/GeographicalMapView/GeographicalMapView'; -export * from '../src/pages/xatu/locally-built-blocks/components/LocallyBuiltBlocksSkeleton/LocallyBuiltBlocksSkeleton'; diff --git a/.design-sync/ds-providers.tsx b/.design-sync/ds-providers.tsx index 828222227..3e7564a5b 100644 --- a/.design-sync/ds-providers.tsx +++ b/.design-sync/ds-providers.tsx @@ -5,7 +5,9 @@ import { RouterProvider, createMemoryHistory, createRootRoute, createRouter } fr import { ThemeProvider } from '@/providers/ThemeProvider'; import { NetworkProvider } from '@/providers/NetworkProvider'; import { ConfigGate } from '@/components/Overlays/ConfigGate'; -import { mockConfig, mockBounds } from '../.storybook/mocks'; +import { boundsFixture } from '@/test-utils/handlers/bounds'; +import { configFixture } from '@/test-utils/handlers/config'; +import { getBoundsQueryKey, getConfigQueryKey } from '@/lab-api/@tanstack/react-query.gen'; /** * Provider chain for design-sync previews. Mirrors the decorator chain in @@ -14,14 +16,14 @@ import { mockConfig, mockBounds } from '../.storybook/mocks'; * previews get the same context the storybook reference does. Theme is pinned * to dark to match the storybook default theme deterministically. */ -export function DesignSyncProvider({ children }: { children: React.ReactNode }) { +export function DesignSyncProvider({ children }: { children: React.ReactNode }): React.JSX.Element { const [{ queryClient, router }] = React.useState(() => { const qc = new QueryClient({ defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, }); - qc.setQueryData(['config'], mockConfig); - mockConfig.networks.forEach(network => { - qc.setQueryData(['bounds', network.name], mockBounds); + qc.setQueryData(getConfigQueryKey(), configFixture); + configFixture.networks.forEach(network => { + qc.setQueryData(getBoundsQueryKey({ path: { network: network.name } }), boundsFixture); }); const rootRoute = createRootRoute({ diff --git a/.design-sync/tsconfig.paths.json b/.design-sync/tsconfig.paths.json index 35059ceba..b742a259a 100644 --- a/.design-sync/tsconfig.paths.json +++ b/.design-sync/tsconfig.paths.json @@ -8,24 +8,12 @@ "@/pages/ethereum/execution/gas-profiler/components/ContractInteractionsTable": [ "src/pages/ethereum/execution/gas-profiler/components/ContractInteractionsTable/index" ], - "@/pages/xatu/geographical-checklist/components/GeographicalChecklistSkeleton": [ - "src/pages/xatu/geographical-checklist/components/GeographicalChecklistSkeleton/index" - ], "@/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2": [ "src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/index" ], "@/pages/ethereum/execution/gas-profiler/components/GlamsterdamPresetModal": [ "src/pages/ethereum/execution/gas-profiler/components/GlamsterdamPresetModal/index" ], - "@/pages/ethereum/execution/gas-profiler/components/TopContractsByGasChart": [ - "src/pages/ethereum/execution/gas-profiler/components/TopContractsByGasChart/index" - ], - "@/pages/ethereum/execution/gas-profiler/components/TransactionSearchInput": [ - "src/pages/ethereum/execution/gas-profiler/components/TransactionSearchInput/index" - ], - "@/pages/ethereum/execution/gas-profiler/components/TransactionSummaryCard": [ - "src/pages/ethereum/execution/gas-profiler/components/TransactionSummaryCard/index" - ], "@/pages/ethereum/consensus/overview/components/ConsensusOverviewSkeleton": [ "src/pages/ethereum/consensus/overview/components/ConsensusOverviewSkeleton/index" ], @@ -44,9 +32,6 @@ "@/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown": [ "src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/index" ], - "@/pages/xatu/locally-built-blocks/components/LocallyBuiltBlocksSkeleton": [ - "src/pages/xatu/locally-built-blocks/components/LocallyBuiltBlocksSkeleton/index" - ], "@/pages/ethereum/data-availability/components/DataAvailabilitySkeleton": [ "src/pages/ethereum/data-availability/components/DataAvailabilitySkeleton/index" ], @@ -71,21 +56,12 @@ "@/pages/ethereum/data-availability/custody/components/CellProbeDialog": [ "src/pages/ethereum/data-availability/custody/components/CellProbeDialog/index" ], - "@/pages/ethereum/data-availability/custody/components/LiveProbeEvents": [ - "src/pages/ethereum/data-availability/custody/components/LiveProbeEvents/index" - ], "@/pages/ethereum/execution/gas-profiler/components/BlockOpcodeHeatmap": [ "src/pages/ethereum/execution/gas-profiler/components/BlockOpcodeHeatmap/index" ], "@/pages/ethereum/execution/gas-profiler/components/ContractStorageCTA": [ "src/pages/ethereum/execution/gas-profiler/components/ContractStorageCTA/index" ], - "@/pages/ethereum/execution/gas-profiler/components/OpcodeDistribution": [ - "src/pages/ethereum/execution/gas-profiler/components/OpcodeDistribution/index" - ], - "@/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle": [ - "src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/index" - ], "@/pages/ethereum/execution/gas-profiler/components/TopItemsByGasTable": [ "src/pages/ethereum/execution/gas-profiler/components/TopItemsByGasTable/index" ], @@ -104,18 +80,9 @@ "@/pages/ethereum/execution/gas-profiler/components/CategoryPieChart": [ "src/pages/ethereum/execution/gas-profiler/components/CategoryPieChart/index" ], - "@/pages/ethereum/execution/gas-profiler/components/GasBreakdownCard": [ - "src/pages/ethereum/execution/gas-profiler/components/GasBreakdownCard/index" - ], "@/pages/ethereum/execution/gas-profiler/components/ResourceGasTable": [ "src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/index" ], - "@/pages/xatu/geographical-checklist/components/GeographicalListView": [ - "src/pages/xatu/geographical-checklist/components/GeographicalListView/index" - ], - "@/components/Ethereum/SlotProgressTimeline/components/TimelineAxis": [ - "src/components/Ethereum/SlotProgressTimeline/components/TimelineAxis/index" - ], "@/pages/ethereum/execution/gas-profiler/components/CallTreeSection": [ "src/pages/ethereum/execution/gas-profiler/components/CallTreeSection/index" ], @@ -125,21 +92,6 @@ "@/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp": [ "src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/index" ], - "@/pages/ethereum/execution/gas-profiler/components/TransactionList": [ - "src/pages/ethereum/execution/gas-profiler/components/TransactionList/index" - ], - "@/pages/ethereum/execution/state-growth/components/StateDeltaCards": [ - "src/pages/ethereum/execution/state-growth/components/StateDeltaCards/index" - ], - "@/pages/ethereum/execution/state-growth/components/StateMetricCard": [ - "src/pages/ethereum/execution/state-growth/components/StateMetricCard/index" - ], - "@/pages/xatu/geographical-checklist/components/GeographicalFilters": [ - "src/pages/xatu/geographical-checklist/components/GeographicalFilters/index" - ], - "@/pages/xatu/geographical-checklist/components/GeographicalMapView": [ - "src/pages/xatu/geographical-checklist/components/GeographicalMapView/index" - ], "@/pages/ethereum/contracts/components/ContractStateExpirySkeleton": [ "src/pages/ethereum/contracts/components/ContractStateExpirySkeleton/index" ], @@ -149,54 +101,27 @@ "@/pages/ethereum/execution/gas-profiler/components/OpcodeAnalysis": [ "src/pages/ethereum/execution/gas-profiler/components/OpcodeAnalysis/index" ], - "@/pages/ethereum/execution/gas-profiler/components/TransactionRow": [ - "src/pages/ethereum/execution/gas-profiler/components/TransactionRow/index" - ], "@/pages/ethereum/execution/state-expiry/components/PolicySelector": [ "src/pages/ethereum/execution/state-expiry/components/PolicySelector/index" ], "@/pages/ethereum/execution/gas-profiler/components/CallTraceView": [ "src/pages/ethereum/execution/gas-profiler/components/CallTraceView/index" ], - "@/pages/ethereum/execution/state-expiry/components/SavingsMatrix": [ - "src/pages/ethereum/execution/state-expiry/components/SavingsMatrix/index" - ], - "@/pages/ethereum/slots/components/AttestationHeadCorrectnessCard": [ - "src/pages/ethereum/slots/components/AttestationHeadCorrectnessCard/index" - ], "@/pages/ethereum/slots/components/AttestationVotesBreakdownTable": [ "src/pages/ethereum/slots/components/AttestationVotesBreakdownTable/index" ], - "@/pages/xatu/geographical-checklist/components/GeographicalStats": [ - "src/pages/xatu/geographical-checklist/components/GeographicalStats/index" - ], - "@/pages/xatu/locally-built-blocks/components/ClientPairingMatrix": [ - "src/pages/xatu/locally-built-blocks/components/ClientPairingMatrix/index" - ], "@/components/Ethereum/SlotProgressTimeline/components/PhaseNode": [ "src/components/Ethereum/SlotProgressTimeline/components/PhaseNode/index" ], - "@/pages/ethereum/execution/gas-profiler/components/CallFrameRow": [ - "src/pages/ethereum/execution/gas-profiler/components/CallFrameRow/index" - ], "@/pages/ethereum/execution/gas-profiler/components/GasHistogram": [ "src/pages/ethereum/execution/gas-profiler/components/GasHistogram/index" ], "@/pages/ethereum/slots/components/PreparedBlocksComparisonChart": [ "src/pages/ethereum/slots/components/PreparedBlocksComparisonChart/index" ], - "@/pages/ethereum/execution/gas-profiler/components/BlockHeader": [ - "src/pages/ethereum/execution/gas-profiler/components/BlockHeader/index" - ], "@/pages/ethereum/execution/state-expiry/components/ContractRow": [ "src/pages/ethereum/execution/state-expiry/components/ContractRow/index" ], - "@/pages/ethereum/slots/components/AttestationParticipationCard": [ - "src/pages/ethereum/slots/components/AttestationParticipationCard/index" - ], - "@/pages/xatu/locally-built-blocks/components/BlockDetailsModal": [ - "src/pages/xatu/locally-built-blocks/components/BlockDetailsModal/index" - ], "@/pages/ethereum/execution/gas-profiler/components/GasFormula": [ "src/pages/ethereum/execution/gas-profiler/components/GasFormula/index" ], @@ -206,27 +131,9 @@ "@/pages/ethereum/slots/components/BlockClassificationCDFChart": [ "src/pages/ethereum/slots/components/BlockClassificationCDFChart/index" ], - "@/pages/xatu/geographical-checklist/components/InsightsDialog": [ - "src/pages/xatu/geographical-checklist/components/InsightsDialog/index" - ], - "@/pages/xatu/geographical-checklist/hooks/useGeographicalData": [ - "src/pages/xatu/geographical-checklist/hooks/useGeographicalData/index" - ], - "@/pages/ethereum/data-availability/components/ViewModeToggle": [ - "src/pages/ethereum/data-availability/components/ViewModeToggle/index" - ], - "@/pages/xatu/contributors/components/AttestationLatencyChart": [ - "src/pages/xatu/contributors/components/AttestationLatencyChart/index" - ], - "@/pages/xatu/geographical-checklist/components/FiltersDialog": [ - "src/pages/xatu/geographical-checklist/components/FiltersDialog/index" - ], "@/pages/ethereum/data-availability/components/ProbeEventRow": [ "src/pages/ethereum/data-availability/components/ProbeEventRow/index" ], - "@/pages/ethereum/entities/components/AttestationVolumeChart": [ - "src/pages/ethereum/entities/components/AttestationVolumeChart/index" - ], "@/pages/ethereum/execution/payloads/components/PayloadsView": [ "src/pages/ethereum/execution/payloads/components/PayloadsView/index" ], @@ -242,39 +149,15 @@ "@/pages/ethereum/slots/components/BlobDataColumnSpreadChart": [ "src/pages/ethereum/slots/components/BlobDataColumnSpreadChart/index" ], - "@/pages/xatu/contributors/components/DataColumnLatencyChart": [ - "src/pages/xatu/contributors/components/DataColumnLatencyChart/index" - ], - "@/pages/xatu/fork-readiness/components/ForkReadinessLoading": [ - "src/pages/xatu/fork-readiness/components/ForkReadinessLoading/index" - ], - "@/pages/ethereum/contracts/components/ContractPageSkeleton": [ - "src/pages/ethereum/contracts/components/ContractPageSkeleton/index" - ], - "@/pages/ethereum/entities/components/ActiveValidatorsChart": [ - "src/pages/ethereum/entities/components/ActiveValidatorsChart/index" - ], "@/pages/ethereum/execution/payloads/components/FilterPanel": [ "src/pages/ethereum/execution/payloads/components/FilterPanel/index" ], "@/pages/ethereum/slots/components/AttestationArrivalsChart": [ "src/pages/ethereum/slots/components/AttestationArrivalsChart/index" ], - "@/pages/ethereum/slots/components/BlockSizeEfficiencyChart": [ - "src/pages/ethereum/slots/components/BlockSizeEfficiencyChart/index" - ], "@/pages/ethereum/validators/components/DailyBreakdownTable": [ "src/pages/ethereum/validators/components/DailyBreakdownTable/index" ], - "@/pages/xatu/fork-readiness/components/ClientReadinessCard": [ - "src/pages/xatu/fork-readiness/components/ClientReadinessCard/index" - ], - "@/pages/ethereum/entities/components/AttestationRateChart": [ - "src/pages/ethereum/entities/components/AttestationRateChart/index" - ], - "@/pages/ethereum/entities/components/ValidatorStatusChart": [ - "src/pages/ethereum/entities/components/ValidatorStatusChart/index" - ], "@/pages/ethereum/execution/timings/components/GetBlobsTab": [ "src/pages/ethereum/execution/timings/components/GetBlobsTab/index" ], @@ -293,42 +176,18 @@ "@/pages/ethereum/validators/components/ValidatorsSkeleton": [ "src/pages/ethereum/validators/components/ValidatorsSkeleton/index" ], - "@/pages/xatu/locally-built-blocks/components/SlotTimeline": [ - "src/pages/xatu/locally-built-blocks/components/SlotTimeline/index" - ], - "@/pages/ethereum/entities/components/BlockProposalsTable": [ - "src/pages/ethereum/entities/components/BlockProposalsTable/index" - ], - "@/pages/ethereum/entities/components/EntityBasicInfoCard": [ - "src/pages/ethereum/entities/components/EntityBasicInfoCard/index" - ], - "@/pages/ethereum/entities/components/RecentActivityTable": [ - "src/pages/ethereum/entities/components/RecentActivityTable/index" - ], "@/pages/ethereum/slots/components/RelayDistributionChart": [ "src/pages/ethereum/slots/components/RelayDistributionChart/index" ], "@/pages/ethereum/validators/components/DateRangeSelector": [ "src/pages/ethereum/validators/components/DateRangeSelector/index" ], - "@/pages/ethereum/validators/components/StatusSummaryCard": [ - "src/pages/ethereum/validators/components/StatusSummaryCard/index" - ], - "@/pages/xatu/contributors/components/UserDetailsSkeleton": [ - "src/pages/xatu/contributors/components/UserDetailsSkeleton/index" - ], - "@/pages/ethereum/entities/components/BlockProposalChart": [ - "src/pages/ethereum/entities/components/BlockProposalChart/index" - ], "@/pages/ethereum/slots/components/BlockPropagationChart": [ "src/pages/ethereum/slots/components/BlockPropagationChart/index" ], "@/pages/ethereum/validators/components/ValidatorHeatmap": [ "src/pages/ethereum/validators/components/ValidatorHeatmap/index" ], - "@/pages/xatu/contributors/components/SlotPlayerControls": [ - "src/pages/xatu/contributors/components/SlotPlayerControls/index" - ], "@/pages/ethereum/contracts/components/ContractInfoCard": [ "src/pages/ethereum/contracts/components/ContractInfoCard/index" ], @@ -341,12 +200,6 @@ "@/pages/ethereum/slots/components/SlotProgressTimeline": [ "src/pages/ethereum/slots/components/SlotProgressTimeline/index" ], - "@/pages/ethereum/validators/components/ThresholdConfig": [ - "src/pages/ethereum/validators/components/ThresholdConfig/index" - ], - "@/pages/xatu/contributors/components/BlockLatencyChart": [ - "src/pages/xatu/contributors/components/BlockLatencyChart/index" - ], "@/pages/ethereum/entities/components/EntitiesSkeleton": [ "src/pages/ethereum/entities/components/EntitiesSkeleton/index" ], @@ -359,24 +212,12 @@ "@/pages/ethereum/validators/components/ValidatorTable": [ "src/pages/ethereum/validators/components/ValidatorTable/index" ], - "@/pages/xatu/contributors/components/BlobLatencyChart": [ - "src/pages/xatu/contributors/components/BlobLatencyChart/index" - ], - "@/pages/xatu/contributors/components/HeadLatencyChart": [ - "src/pages/xatu/contributors/components/HeadLatencyChart/index" - ], - "@/pages/xatu/contributors/components/UserCardSkeleton": [ - "src/pages/xatu/contributors/components/UserCardSkeleton/index" - ], "@/pages/ethereum/live/components/AttestationArrivals": [ "src/pages/ethereum/live/components/AttestationArrivals/index" ], "@/pages/ethereum/slots/components/SlotDetailSkeleton": [ "src/pages/ethereum/slots/components/SlotDetailSkeleton/index" ], - "@/pages/xatu/contributors/components/MetricsSkeleton": [ - "src/pages/xatu/contributors/components/MetricsSkeleton/index" - ], "@/pages/ethereum/live/hooks/useBlobAvailabilityData": [ "src/pages/ethereum/live/hooks/useBlobAvailabilityData/index" ], @@ -389,15 +230,6 @@ "@/pages/ethereum/slots/components/SlotDownloadModal": [ "src/pages/ethereum/slots/components/SlotDownloadModal/index" ], - "@/pages/ethereum/slots/hooks/useAllAttestationVotes": [ - "src/pages/ethereum/slots/hooks/useAllAttestationVotes/index" - ], - "@/pages/xatu/contributors/hooks/useContributorsData": [ - "src/pages/xatu/contributors/hooks/useContributorsData/index" - ], - "@/pages/xatu/fork-readiness/components/ForkOverview": [ - "src/pages/xatu/fork-readiness/components/ForkOverview/index" - ], "@/pages/ethereum/epochs/components/EpochSlotsTable": [ "src/pages/ethereum/epochs/components/EpochSlotsTable/index" ], @@ -410,42 +242,18 @@ "@/pages/ethereum/execution/state-growth/components": [ "src/pages/ethereum/execution/state-growth/components/index" ], - "@/pages/xatu/fork-readiness/components/ForkSection": [ - "src/pages/xatu/fork-readiness/components/ForkSection/index" - ], - "@/pages/xatu/fork-readiness/hooks/useForkReadiness": [ - "src/pages/xatu/fork-readiness/hooks/useForkReadiness/index" - ], "@/components/Ethereum/MevBuilderDistributionChart": [ "src/components/Ethereum/MevBuilderDistributionChart/index" ], - "@/pages/ethereum/epochs/components/EpochsSkeleton": [ - "src/pages/ethereum/epochs/components/EpochsSkeleton/index" - ], - "@/pages/ethereum/live/components/BlockDetailsCard": [ - "src/pages/ethereum/live/components/BlockDetailsCard/index" - ], - "@/pages/ethereum/live/components/DataAvailability": [ - "src/pages/ethereum/live/components/DataAvailability/index" - ], "@/pages/ethereum/live/components/MobileSlotHeader": [ "src/pages/ethereum/live/components/MobileSlotHeader/index" ], "@/pages/ethereum/slots/hooks/useSlotEngineTimings": [ "src/pages/ethereum/slots/hooks/useSlotEngineTimings/index" ], - "@/pages/ethereum/slots/hooks/useSlotNodeNetworkIo": [ - "src/pages/ethereum/slots/hooks/useSlotNodeNetworkIo/index" - ], - "@/pages/ethereum/slots/hooks/useSlotNodeResources": [ - "src/pages/ethereum/slots/hooks/useSlotNodeResources/index" - ], "@/pages/ethereum/live/components/MobileSlotStats": [ "src/pages/ethereum/live/components/MobileSlotStats/index" ], - "@/pages/ethereum/slots/hooks/useSlotProgressData": [ - "src/pages/ethereum/slots/hooks/useSlotProgressData/index" - ], "@/components/Ethereum/MevRelayDistributionChart": [ "src/components/Ethereum/MevRelayDistributionChart/index" ], @@ -461,12 +269,6 @@ "@/pages/ethereum/live/hooks/useBlockDetailsData": [ "src/pages/ethereum/live/hooks/useBlockDetailsData/index" ], - "@/pages/ethereum/live/hooks/useSlotProgressData": [ - "src/pages/ethereum/live/hooks/useSlotProgressData/index" - ], - "@/pages/ethereum/slots/components/NodeResources": [ - "src/pages/ethereum/slots/components/NodeResources/index" - ], "@/pages/ethereum/consensus/overview/components": [ "src/pages/ethereum/consensus/overview/components/index" ], @@ -482,15 +284,6 @@ "@/pages/ethereum/slots/hooks/useSlotDetailData": [ "src/pages/ethereum/slots/hooks/useSlotDetailData/index" ], - "@/pages/ethereum/slots/hooks/useSlotNodeDiskIo": [ - "src/pages/ethereum/slots/hooks/useSlotNodeDiskIo/index" - ], - "@/pages/ethereum/slots/hooks/useSlotNodeMemory": [ - "src/pages/ethereum/slots/hooks/useSlotNodeMemory/index" - ], - "@/pages/ethereum/execution/gas-profiler/hooks": [ - "src/pages/ethereum/execution/gas-profiler/hooks/index" - ], "@/pages/ethereum/execution/gas-profiler/utils": [ "src/pages/ethereum/execution/gas-profiler/utils/index" ], @@ -500,21 +293,12 @@ "@/pages/ethereum/execution/state-growth/hooks": [ "src/pages/ethereum/execution/state-growth/hooks/index" ], - "@/pages/ethereum/execution/timings/components": [ - "src/pages/ethereum/execution/timings/components/index" - ], "@/pages/ethereum/forks/components/ForksHeader": [ "src/pages/ethereum/forks/components/ForksHeader/index" ], - "@/pages/xatu/contributors/components/UserCard": [ - "src/pages/xatu/contributors/components/UserCard/index" - ], "@/components/Ethereum/BlockArrivalTimesChart": [ "src/components/Ethereum/BlockArrivalTimesChart/index" ], - "@/pages/xatu/locally-built-blocks/components": [ - "src/pages/xatu/locally-built-blocks/components/index" - ], "@/components/Ethereum/TransactionCountChart": [ "src/components/Ethereum/TransactionCountChart/index" ], @@ -527,9 +311,6 @@ "@/components/Ethereum/AttestationsByEntity": [ "src/components/Ethereum/AttestationsByEntity/index" ], - "@/components/Ethereum/SlotProgressTimeline": [ - "src/components/Ethereum/SlotProgressTimeline/index" - ], "@/pages/ethereum/data-availability/custody": [ "src/pages/ethereum/data-availability/custody/index" ], @@ -545,18 +326,12 @@ "@/pages/ethereum/data-availability/probes": [ "src/pages/ethereum/data-availability/probes/index" ], - "@/pages/ethereum/execution/payloads/hooks": [ - "src/pages/ethereum/execution/payloads/hooks/index" - ], "@/pages/ethereum/live/components/Timeline": [ "src/pages/ethereum/live/components/Timeline/index" ], "@/components/Ethereum/EIP7870SpecsBanner": [ "src/components/Ethereum/EIP7870SpecsBanner/index" ], - "@/pages/ethereum/execution/timings/hooks": [ - "src/pages/ethereum/execution/timings/hooks/index" - ], "@/pages/ethereum/live/components/Sidebar": [ "src/pages/ethereum/live/components/Sidebar/index" ], @@ -566,9 +341,6 @@ "@/routes/ethereum/execution/gas-profiler": [ "src/routes/ethereum/execution/gas-profiler/index" ], - "@/components/DateTimePickers/DatePicker": [ - "src/components/DateTimePickers/DatePicker/index" - ], "@/components/Ethereum/BlockExplorerLink": [ "src/components/Ethereum/BlockExplorerLink/index" ], @@ -584,12 +356,6 @@ "@/pages/ethereum/execution/state-growth": [ "src/pages/ethereum/execution/state-growth/index" ], - "@/pages/xatu/locally-built-blocks/hooks": [ - "src/pages/xatu/locally-built-blocks/hooks/index" - ], - "@/pages/xatu/locally-built-blocks/utils": [ - "src/pages/xatu/locally-built-blocks/utils/index" - ], "@/components/Ethereum/MevAdoptionChart": [ "src/components/Ethereum/MevAdoptionChart/index" ], @@ -605,9 +371,6 @@ "@/pages/ethereum/validators/components": [ "src/pages/ethereum/validators/components/index" ], - "@/pages/xatu/fork-readiness/components": [ - "src/pages/xatu/fork-readiness/components/index" - ], "@/components/Elements/CopyToClipboard": [ "src/components/Elements/CopyToClipboard/index" ], @@ -617,15 +380,9 @@ "@/components/Ethereum/BlockValueChart": [ "src/components/Ethereum/BlockValueChart/index" ], - "@/components/Navigation/ProgressSteps": [ - "src/components/Navigation/ProgressSteps/index" - ], "@/pages/ethereum/contracts/components": [ "src/pages/ethereum/contracts/components/index" ], - "@/components/Elements/TimezoneToggle": [ - "src/components/Elements/TimezoneToggle/index" - ], "@/components/Ethereum/BlobCountChart": [ "src/components/Ethereum/BlobCountChart/index" ], @@ -662,9 +419,6 @@ "@/components/Ethereum/NetworkSelect": [ "src/components/Ethereum/NetworkSelect/index" ], - "@/components/Navigation/ProgressBar": [ - "src/components/Navigation/ProgressBar/index" - ], "@/pages/ethereum/consensus/overview": [ "src/pages/ethereum/consensus/overview/index" ], @@ -674,18 +428,9 @@ "@/pages/ethereum/execution/payloads": [ "src/pages/ethereum/execution/payloads/index" ], - "@/pages/home/components/HeroSection": [ - "src/pages/home/components/HeroSection/index" - ], - "@/pages/xatu/geographical-checklist": [ - "src/pages/xatu/geographical-checklist/index" - ], "@/routes/ethereum/execution/timings": [ "src/routes/ethereum/execution/timings/index" ], - "@/components/Charts/GasFlowDiagram": [ - "src/components/Charts/GasFlowDiagram/index" - ], "@/components/Charts/ScatterAndLine": [ "src/components/Charts/ScatterAndLine/index" ], @@ -698,9 +443,6 @@ "@/components/Ethereum/BaseFeeChart": [ "src/components/Ethereum/BaseFeeChart/index" ], - "@/components/Ethereum/ClientSelect": [ - "src/components/Ethereum/ClientSelect/index" - ], "@/components/Ethereum/GasUsedChart": [ "src/components/Ethereum/GasUsedChart/index" ], @@ -749,24 +491,9 @@ "@/pages/ethereum/validators/hooks": [ "src/pages/ethereum/validators/hooks/index" ], - "@/pages/ethereum/validators/utils": [ - "src/pages/ethereum/validators/utils/index" - ], - "@/pages/xatu/fork-readiness/hooks": [ - "src/pages/xatu/fork-readiness/hooks/index" - ], - "@/pages/xatu/fork-readiness/utils": [ - "src/pages/xatu/fork-readiness/utils/index" - ], - "@/pages/xatu/locally-built-blocks": [ - "src/pages/xatu/locally-built-blocks/index" - ], "@/components/Ethereum/ClientLogo": [ "src/components/Ethereum/ClientLogo/index" ], - "@/components/Forms/CheckboxGroup": [ - "src/components/Forms/CheckboxGroup/index" - ], "@/components/Overlays/ConfigGate": [ "src/components/Overlays/ConfigGate/index" ], @@ -797,9 +524,6 @@ "@/pages/ethereum/entities/hooks": [ "src/pages/ethereum/entities/hooks/index" ], - "@/pages/xatu/contributors/hooks": [ - "src/pages/xatu/contributors/hooks/index" - ], "@/components/Charts/FlameGraph": [ "src/components/Charts/FlameGraph/index" ], @@ -842,12 +566,6 @@ "@/components/Feedback/InfoBox": [ "src/components/Feedback/InfoBox/index" ], - "@/components/Forms/RadioGroup": [ - "src/components/Forms/RadioGroup/index" - ], - "@/components/Forms/RangeInput": [ - "src/components/Forms/RangeInput/index" - ], "@/components/Forms/SelectMenu": [ "src/components/Forms/SelectMenu/index" ], @@ -929,9 +647,6 @@ "@/pages/ethereum/validators": [ "src/pages/ethereum/validators/index" ], - "@/pages/xatu/fork-readiness": [ - "src/pages/xatu/fork-readiness/index" - ], "@/providers/NetworkProvider": [ "src/providers/NetworkProvider/index" ], @@ -956,36 +671,18 @@ "@/routes/xatu/contributors": [ "src/routes/xatu/contributors/index" ], - "@/components/Charts/Donut": [ - "src/components/Charts/Donut/index" - ], - "@/components/Charts/Gauge": [ - "src/components/Charts/Gauge/index" - ], - "@/components/Charts/Globe": [ - "src/components/Charts/Globe/index" - ], "@/components/Charts/Map2D": [ "src/components/Charts/Map2D/index" ], - "@/components/Charts/Radar": [ - "src/components/Charts/Radar/index" - ], "@/components/Forms/Toggle": [ "src/components/Forms/Toggle/index" ], "@/contexts/NetworkContext": [ "src/contexts/NetworkContext/index" ], - "@/hooks/useStableCallback": [ - "src/hooks/useStableCallback/index" - ], "@/pages/ethereum/entities": [ "src/pages/ethereum/entities/index" ], - "@/pages/xatu/contributors": [ - "src/pages/xatu/contributors/index" - ], "@/providers/ThemeProvider": [ "src/providers/ThemeProvider/index" ], @@ -1013,9 +710,6 @@ "@/components/Charts/Bar": [ "src/components/Charts/Bar/index" ], - "@/components/Charts/Map": [ - "src/components/Charts/Map/index" - ], "@/contexts/ThemeContext": [ "src/contexts/ThemeContext/index" ], @@ -1058,9 +752,6 @@ "@/hooks/useCanHover": [ "src/hooks/useCanHover/index" ], - "@/hooks/useHashTabs": [ - "src/hooks/useHashTabs/index" - ], "@/hooks/useInterval": [ "src/hooks/useInterval/index" ], @@ -1070,9 +761,6 @@ "@/hooks/useTimezone": [ "src/hooks/useTimezone/index" ], - "@/pages/experiments": [ - "src/pages/experiments/index" - ], "@/hooks/useNetwork": [ "src/hooks/useNetwork/index" ], diff --git a/.github/dependabot.yml b/.github/dependabot.yml index adfbcbdc4..b032796a3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,10 @@ version: 2 updates: - - package-ecosystem: github-actions - directory: / - schedule: - interval: monthly - groups: - actions: - patterns: - - '*' + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: + - '*' diff --git a/.github/workflows/alpha-release.yaml b/.github/workflows/alpha-release.yaml index 80f310bca..458151d11 100644 --- a/.github/workflows/alpha-release.yaml +++ b/.github/workflows/alpha-release.yaml @@ -8,13 +8,19 @@ on: permissions: contents: write +# Serialized per branch so concurrent runs cannot compute the same next tag. +concurrency: + group: alpha-release-${{ github.ref_name }} + jobs: alpha-release: name: Build and Create Alpha Release runs-on: ubuntu-latest + timeout-minutes: 15 steps: + # Keeps the token in git config: the job pushes the new tag. - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -53,14 +59,15 @@ jobs: git push origin ${{ steps.version.outputs.new_tag }} - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + # No dependency cache: the release archives are built from a clean + # install, so a poisoned cache cannot reach them. - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml + node-version-file: package.json + package-manager-cache: false - name: Install dependencies run: pnpm install --frozen-lockfile @@ -74,7 +81,7 @@ jobs: cd dist && zip -r ../lab-${{ steps.version.outputs.new_tag }}.zip . && cd .. - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ steps.version.outputs.new_tag }} name: ${{ steps.extract.outputs.release_name }} ${{ steps.version.outputs.new_version }} (Alpha) diff --git a/.github/workflows/deploy-storybook.yaml b/.github/workflows/deploy-storybook.yaml index 7e3fb69a7..7faca7b97 100644 --- a/.github/workflows/deploy-storybook.yaml +++ b/.github/workflows/deploy-storybook.yaml @@ -5,6 +5,12 @@ on: branches: - master +# One deploy at a time: a newer push waits for the running deploy instead of +# cancelling it partway through. +concurrency: + group: pages + cancel-in-progress: false + permissions: contents: read pages: write @@ -13,21 +19,25 @@ permissions: jobs: build-and-deploy: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - node-version: '24' - - - name: Deploy Storybook to GitHub Pages - uses: bitovi/github-actions-storybook-to-github-pages@b75ece8f984a32b2adb322065d7a8a2cc8db2d52 # v1.0.3 + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: package.json + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build Storybook + run: pnpm build-storybook:gh-pages + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: - install_command: pnpm install - build_command: pnpm build-storybook:gh-pages path: storybook-static + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 707de26f8..73ca57443 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -42,20 +42,23 @@ jobs: name: Build and Publish Release if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip release]') runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false # A release created here with GITHUB_TOKEN emits no workflow events, # so this run does not re-trigger itself via the release trigger. - name: Resolve release tag env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | if [ "${{ github.event_name }}" = "release" ]; then - TAG_NAME="${{ github.event.release.tag_name }}" + TAG_NAME="$RELEASE_TAG" else LATEST_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1) @@ -85,14 +88,15 @@ jobs: echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + # No dependency cache: the release archives are built from a clean + # install, so a poisoned cache cannot reach them. - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml + node-version-file: package.json + package-manager-cache: false - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/vet.yaml b/.github/workflows/vet.yaml index 6719dd986..dc02f08a9 100644 --- a/.github/workflows/vet.yaml +++ b/.github/workflows/vet.yaml @@ -1,9 +1,17 @@ name: Vet +# Runs on master too, so the dependency and Playwright caches its runs save +# are there for every pull request to restore. on: + push: + branches: [master] pull_request: workflow_dispatch: - branches: [ '**' ] + +# One run per branch or PR; a newer push cancels the older run. +concurrency: + group: vet-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true permissions: contents: read @@ -12,55 +20,86 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml + node-version-file: package.json + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run linter run: pnpm lint + - name: Check formatting + run: pnpm format:check + - name: Find unused files, exports and dependencies + run: pnpm knip build: name: Build runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml + node-version-file: package.json + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run typecheck and build run: pnpm build + build-storybook: + name: Build Storybook + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: package.json + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build Storybook + run: pnpm build-storybook + unit: name: Unit Test runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml + node-version-file: package.json + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run unit tests @@ -68,27 +107,42 @@ jobs: storybook: name: Storybook Test (${{ matrix.shard }}/2) - # Playwright 1.58 has no Chromium build for ubuntu-latest's 26.04 image yet - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml + node-version-file: package.json + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - # Vitest browser mode runs headless Chromium only + # Vitest browser mode runs headless Chromium only. The download is keyed + # on the Playwright version, so it is fetched once per bump, not per run. + - name: Read Playwright version + id: playwright + run: echo "version=$(jq -r .devDependencies.playwright package.json)" >> "$GITHUB_OUTPUT" + - name: Cache Playwright browsers + id: browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} - name: Install Playwright browsers + if: steps.browsers.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps --only-shell chromium + - name: Install Playwright system dependencies + if: steps.browsers.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium - name: Run Storybook tests run: pnpm test:storybook --shard=${{ matrix.shard }}/2 diff --git a/.gitignore b/.gitignore index 72bbb93b1..d628ba533 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ dist-ssr /build/stats.html storybook-static +# Unit test coverage +/coverage + # Environment variables .env .env.local @@ -34,12 +37,11 @@ storybook-static # Playwright .playwright-mcp +/test-results/ # Tanstack .tanstack -# ESLint -.eslintcache # design-sync (claude.ai/design) .design-sync/sb-reference/ .design-sync/learnings/ diff --git a/.interface-design/system.md b/.interface-design/system.md index 53d59eef9..a47a2065f 100644 --- a/.interface-design/system.md +++ b/.interface-design/system.md @@ -18,39 +18,33 @@ ### Color Architecture -Two-tier system defined in `src/index.css`: +Every colour is a token in `src/styles/tokens.css` (rules: `.claude/rules/styling.md`); Tailwind's palette is +removed. Each base colour is a `light-dark(light, dark)` pair, and star, a dark scheme with its own palette, sets its +values in `html.star`. Tints (`bg-primary-10`) and derived colours (`panel`, `on-solid`, `primary-hover`) follow all +three themes, so components never carry `dark:` variants. Storybook's Foundations pages list every token. -**Tier 1 — Primitives** (never use directly in components): -- `terracotta` (50–950): warm accent, the brand signature -- `sand` (50–950): warm neutrals for light mode -- `neutral` (50–950): grayscale for dark mode -- `aurora-*` (cyan, green, purple, pink, blue): star mode - -**Tier 2 — Semantic** (always use these): - `primary` / `secondary` / `accent` — brand - `background` / `surface` / `foreground` / `muted` / `border` — UI structure - `success` / `warning` / `danger` — state -**Theme mappings:** - -| Token | Light | Dark | Star | -|---|---|---|---| -| primary | terracotta-600 | terracotta-400 | aurora-cyan-400 | -| secondary | sand-200 | neutral-900 | aurora-purple-600 | -| accent | terracotta-500 | terracotta-300 | aurora-cyan-300 | -| background | sand-100 | neutral-950 | aurora-blue-950 | -| surface | sand-50 | #242424 | #0a1428 | -| foreground | sand-950 | neutral-50 | aurora-cyan-50 | -| muted | sand-600 | neutral-400 | aurora-cyan-700 | -| border | sand-300 | neutral-800 | #1a2847 | -| success | #337344 | #76be86 | #96c979 | -| warning | #a56217 | #e8a95c | #e8a95c | -| danger | #af3d36 | #e2726b | #e2726b | - -Star is a dark theme: the `dark:` variant matches `.star` too, and `star:` overrides it where star needs -something different. +**Theme values:** + +| Token | Light | Dark | Star | +| ---------- | ------- | ------- | ------- | +| primary | #c95f3f | #e09770 | #1af7f7 | +| secondary | #dedbca | #3d3d3d | #7d00e6 | +| accent | #d77651 | #ebbd9e | #4dffff | +| background | #efeee5 | #0f0f0e | #000a1a | +| surface | #fbfaf6 | #181817 | #0a1428 | +| foreground | #2c251e | #f5f5f4 | #e6ffff | +| muted | #7d6b51 | #a19f9a | #009999 | +| border | #dcd7c5 | #2c2b29 | #1a2847 | +| success | #337344 | #76be86 | #96c979 | +| warning | #a56217 | #e8a95c | #e8a95c | +| danger | #af3d36 | #e2726b | #e2726b | **Programmatic access:** + ```tsx const colors = useThemeColors(); // { primary, secondary, accent, background, surface, foreground, muted, border, success, warning, danger } @@ -62,6 +56,7 @@ Constant across all themes. Every data colour maps to a **data hue** (`--color-d `bg-data-emerald`, `getDataVizColors().DATA_COLORS.emerald`): Tailwind's 500 hues at about 60% chroma, so charts sit with the earthy UI. Don't hard-code Tailwind hex in charts; series that encode a judgement (canonical, missed, accept, failed) use the status tokens instead. + - **Chart categorical** (0–14): `--color-chart-{n}` — blue, emerald, amber, red, cyan, lime, orange, purple, pink, violet, teal, indigo, rose, fuchsia, sky - **Blob indices** (0–5): `--color-blob-{n}` — cyan, pink, amber, green, violet, red - **Continents**: `--color-continent-{code}` — af(red), as(amber), eu(emerald), na(blue), oc(cyan), sa(violet), an(slate) @@ -74,12 +69,12 @@ encode a judgement (canonical, missed, accept, failed) use the status tokens ins Status colour is a judgement, and each colour has one meaning everywhere: badges, status pills, pass/fail values, tags, table cells, icons and dots. -| Meaning | Text / fill | Badge | Examples | -|---|---|---|---| -| Good | `success` | `green` | canonical, included, signed, valid input, ready, delivered, passing a threshold, live, "Copied" | -| Bad | `danger` | `red` | missed, failed, slashed, invalid input, failing a threshold, errors | -| Warning | `warning` | `orange` | late, orphaned, degraded, lagging, near a threshold, transient failures | -| Neutral | `foreground` / `muted` | `gray` | plain values and counts, identifiers, states that are neither good nor bad (pending, unknown) | +| Meaning | Text / fill | Badge | Examples | +| ------- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------- | +| Good | `success` | `green` | canonical, included, signed, valid input, ready, delivered, passing a threshold, live, "Copied" | +| Bad | `danger` | `red` | missed, failed, slashed, invalid input, failing a threshold, errors | +| Warning | `warning` | `orange` | late, orphaned, degraded, lagging, near a threshold, transient failures | +| Neutral | `foreground` / `muted` | `gray` | plain values and counts, identifiers, states that are neither good nor bad (pending, unknown) | Solid status fills take `text-white` in light mode and `text-background` in dark and star. @@ -105,23 +100,23 @@ no shadows in light mode, no lift on hover. The palette has three clearly separa page (`background`), panel (`surface`), hairline (`border`) — so panels read without shadows. The sidebar uses the page background, so content panels are the only raised surfaces. -| Token | Light | Dark | -|---|---|---| -| background | sand-100 | #0f0f0e | -| surface | #fbfaf6 | #181817 | -| border | #dcd7c5 | #2c2b29 | -| muted | sand-700 | #a19f9a | -| foreground | sand-950 | #f5f5f4 | +| Token | Light | Dark | +| ---------- | ------- | ------- | +| background | #efeee5 | #0f0f0e | +| surface | #fbfaf6 | #181817 | +| border | #dcd7c5 | #2c2b29 | +| muted | #7d6b51 | #a19f9a | +| foreground | #2c251e | #f5f5f4 | ## Border Radius -Two radii, set as tokens in `src/index.css`: +Two radii, set as tokens in `src/styles/tokens.css`: -| Class | Value | Use | -|---|---|---| -| `rounded-xs` | 4px | Inline marks: badges, tags, skeleton bars, toggle knobs | -| `rounded-sm` | 6px | Every container and control: cards, buttons, inputs, selects, toggles, menus, nav items | -| `rounded-full` | — | Dots, avatars, progress tracks only | +| Class | Value | Use | +| -------------- | ----- | --------------------------------------------------------------------------------------- | +| `rounded-xs` | 4px | Inline marks: badges, tags, skeleton bars, toggle knobs | +| `rounded-sm` | 6px | Every container and control: cards, buttons, inputs, selects, toggles, menus, nav items | +| `rounded-full` | — | Dots, avatars, progress tracks only | `rounded-md` through `rounded-3xl` alias to `rounded-sm`, so legacy classes can't drift. Card, Button, ButtonGroup, SelectMenu, Toggle and Checkbox are rounded by default; their `rounded` prop @@ -131,16 +126,16 @@ exists only to opt out (for example a card bleeding into a page edge). **Base unit: 4px (Tailwind default)** -| Context | Value | Classes | -|---|---|---| -| Container padding | responsive | `p-4 sm:p-6 lg:p-8` | +| Context | Value | Classes | +| ------------------- | ---------- | ------------------------------------------------------------ | +| Container padding | responsive | `p-4 sm:p-6 lg:p-8` | | Container max-width | responsive | `max-w-screen-2xl 3xl:max-w-screen-3xl 4xl:max-w-screen-4xl` | -| Card header padding | responsive | `px-4 py-3 sm:px-5` | -| Card body padding | responsive | `p-4 sm:p-5` | -| Card footer padding | responsive | `px-4 py-3 sm:px-5` | -| Page header margin | 32px | `mb-8` | -| Grid gap | 24px | `gap-6` | -| Section dividers | divide-y | `divide-y divide-border` | +| Card header padding | responsive | `px-4 py-3 sm:px-5` | +| Card body padding | responsive | `p-4 sm:p-5` | +| Card footer padding | responsive | `px-4 py-3 sm:px-5` | +| Page header margin | 32px | `mb-8` | +| Grid gap | 24px | `gap-6` | +| Section dividers | divide-y | `divide-y divide-border` | --- @@ -148,18 +143,18 @@ exists only to opt out (for example a card bleeding into a page edge). All UI text uses Geist (`font-sans`). Identifiers use Geist Mono (`font-mono`). Space Grotesk (`font-display`) is reserved for the brand. -| Role | Classes | -|---|---| -| Page title (md) | `text-2xl/8 font-semibold tracking-tight text-foreground` | -| Page title (sm) | `text-lg/7 font-semibold tracking-tight text-foreground` | -| Page description | `text-sm/6 text-muted max-w-3xl` | -| Card title | `text-sm/6 font-semibold text-foreground` (rendered by `Card title`) | -| Card subtitle | `text-xs/5 text-muted` (rendered by `Card subtitle`) | -| Section header | `text-xs font-semibold tracking-wider text-foreground` | -| Subsection header | `text-2xs font-semibold tracking-wider text-muted/60 uppercase` | -| Body text | `text-sm text-foreground` | -| Metadata | `text-xs text-muted` | -| Dense label | `text-2xs` (10px) — axis ticks, HUD keys, badges in tight cells | +| Role | Classes | +| ----------------- | -------------------------------------------------------------------- | +| Page title (md) | `text-2xl/8 font-semibold tracking-tight text-foreground` | +| Page title (sm) | `text-lg/7 font-semibold tracking-tight text-foreground` | +| Page description | `text-sm/6 text-muted max-w-3xl` | +| Card title | `text-sm/6 font-semibold text-foreground` (rendered by `Card title`) | +| Card subtitle | `text-xs/5 text-muted` (rendered by `Card subtitle`) | +| Section header | `text-xs font-semibold tracking-wider text-foreground` | +| Subsection header | `text-2xs font-semibold tracking-wider text-muted-60 uppercase` | +| Body text | `text-sm text-foreground` | +| Metadata | `text-xs text-muted` | +| Dense label | `text-2xs` (10px) — axis ticks, HUD keys, badges in tight cells | The ramp is `text-2xs` → `xs` → `sm` → `base` → `lg` → `2xl`. Never use arbitrary pixel sizes (`text-[9px]`, `text-[11px]`); `text-2xs` is the floor. @@ -170,24 +165,24 @@ The ramp is `text-2xs` → `xs` → `sm` → `base` → `lg` → `2xl`. Never us Filter rows mix selects, segmented controls and buttons, so they share one size: -| Control | Recipe | -|---|---| -| Height | `h-8` (32px) for SelectMenu, SegmentedControl, Button `sm`, filter inputs | -| Text | `text-sm` (selects, inputs, buttons), `text-xs font-medium` (segments) | -| Surface | `bg-surface border border-border` — no blur, no shadow | -| Hover | `hover:border-primary/40` | -| Chevron | `size-4 text-muted` | +| Control | Recipe | +| ------- | ------------------------------------------------------------------------- | +| Height | `h-8` (32px) for SelectMenu, SegmentedControl, Button `sm`, filter inputs | +| Text | `text-sm` (selects, inputs, buttons), `text-xs font-medium` (segments) | +| Surface | `bg-surface border border-border` — no blur, no shadow | +| Hover | `hover:border-primary-40` | +| Chevron | `size-4 text-muted` | ## Interactive States -| State | Pattern | -|---|---| -| Default link/nav | `text-muted` | -| Hover | `hover:bg-primary/10 hover:text-primary` | -| Active/Selected | `bg-primary/10 text-primary shadow-xs ring-1 ring-primary/20` | -| Transition | `transition-all duration-200` or `transition-colors` | -| Icon button | `rounded-sm p-1 transition-colors hover:bg-muted/20` | -| Icon in button | `size-5 text-muted group-hover:text-foreground` | +| State | Pattern | +| ---------------- | ------------------------------------------------------------- | +| Default link/nav | `text-muted` | +| Hover | `hover:bg-primary-10 hover:text-primary` | +| Active/Selected | `bg-primary-10 text-primary shadow-xs ring-1 ring-primary-20` | +| Transition | `transition-all duration-200` or `transition-colors` | +| Icon button | `rounded-sm p-1 transition-colors hover:bg-muted-20` | +| Icon in button | `size-5 text-muted group-hover:text-foreground` | --- @@ -230,6 +225,7 @@ Wraps Card with expand-to-modal and download capabilities. Any list of a sequence that goes further back than one screen (slots, epochs, an entity's proposals) pages through its full indexed history. Never hard-cap to "the last N" and never use "Load more". + - `RangePager` (`src/components/Lists/RangePager`): range on screen, Latest / Newer / Older, and "Go to" a number. Put it above the table (and below long tables). - Pages are addressed by their top number in the URL (`?from=`), so every page is linkable. @@ -241,13 +237,13 @@ proposals) pages through its full indexed history. Never hard-cap to "the last N ### Stats (KPI strip) Headline numbers are one panel, not a card per number: a hairline panel split by internal rules. + - Cell: `px-4 py-3.5 sm:px-5`; label `text-xs/5 font-medium text-muted` with an optional `size-3.5` icon (tinted by `iconColor` when given); value `text-2xl/8 font-semibold tracking-tight tabular-nums` - Delta: `text-xs` with a 16px arrow, inline after the value - `accentColor` draws a 2px rule on the cell's top edge - Pages control columns with `gridClassName`; gaps are always zero - `Table` (`src/components/Lists/Table`) has one density for every list of records: - Rows: `py-2.5` (~40px), `divide-y divide-border`, no zebra, `hover:bg-background` @@ -272,12 +268,12 @@ Page wrapper. `mx-auto p-4 sm:p-6 lg:p-8 max-w-screen-2xl`. ### App frame -- Top bar: `h-12 border-b border-border bg-background/85 backdrop-blur-sm`, sticky on desktop, holds +- Top bar: `h-12 border-b border-border bg-background`, sticky on desktop, holds the breadcrumb. Its rule continues the sidebar header's rule, so the frame is one line. - Breadcrumb: ancestors `text-muted hover:text-foreground`, current page `font-medium text-foreground`, - separators `text-muted/50`. No accent color. -- Navigation states are neutral: hover `bg-foreground/5 text-foreground`, active - `bg-foreground/[0.07] text-foreground` with a primary icon. Primary color marks selection in + separators `text-muted-50`. No accent color. +- Navigation states are neutral: hover `bg-foreground-5 text-foreground`, active + `bg-foreground-7 text-foreground` with a primary icon. Primary color marks selection in controls, not navigation. ### Sidebar @@ -287,7 +283,7 @@ Page wrapper. `mx-auto p-4 sm:p-6 lg:p-8 max-w-screen-2xl`. - Border: `border-r border-border` - Nav items: `rounded-sm px-2 py-1 text-sm/5 font-medium` - Icons: `size-4 shrink-0` -- Version badge: `text-[10px] text-muted/60` +- Version badge: `text-2xs text-muted-60` --- @@ -336,14 +332,16 @@ opts out. Legends stay: they toggle series. ### Legend (Interactive) Borderless toggles (not native ECharts legend), with no rule between legend and chart: -- `rounded-xs px-1.5 py-0.5 text-xs/5 hover:bg-muted/10` + +- `rounded-xs px-1.5 py-0.5 text-xs/5 hover:bg-muted-10` - Visible: `text-foreground`, filled dot -- Hidden: `text-muted/60`, hollow dot +- Hidden: `text-muted-60`, hollow dot - Color indicator: `h-2 w-2 rounded-full` ### Synchronized Crosshairs Charts sharing x-axis domain use `syncGroup` prop: + - `"consensus-overview"` — consensus charts - `"execution-overview"` — execution charts - Disabled in modal: `syncGroup={inModal ? undefined : 'group-name'}` @@ -386,13 +384,15 @@ Two-column responsive grid with time controls: ### Time Period Selector `SegmentedControl` (`src/components/Forms/SegmentedControl`) — never hand-roll pill rows. + - Track: `h-8 rounded-sm bg-surface p-0.5 ring-1 ring-border` -- Selected segment: `bg-primary/15 text-primary` (the app-wide selected state) -- Other segments: `text-muted hover:bg-muted/10 hover:text-foreground` +- Selected segment: `bg-primary-15 text-primary` (the app-wide selected state) +- Other segments: `text-muted hover:bg-muted-10 hover:text-foreground` ### Fork Annotations Vertical mark lines on time-series charts: + - Consensus forks, execution forks, blob schedule changes - Different line styles per category - Shown/hidden via "Show Forks" toggle @@ -401,14 +401,14 @@ Vertical mark lines on time-series charts: ## Animations -| Name | Duration | Easing | Use | -|---|---|---|---| -| shimmer | 2s linear infinite | linear | Loading skeletons | -| fade-in | 0.8s | ease-out | Page entry | -| fade-in-delay | 0.8s, 0.2s delay | ease-out | Staggered entry | -| highlight-new | 0.5s | ease-out | New items | -| row-new | 0.3s | ease-out | Live data rows | -| transitions | 0.2s | default | Interactive states | +| Name | Duration | Easing | Use | +| ------------- | ------------------ | -------- | ------------------ | +| shimmer | 2s linear infinite | linear | Loading skeletons | +| fade-in | 0.8s | ease-out | Page entry | +| fade-in-delay | 0.8s, 0.2s delay | ease-out | Staggered entry | +| highlight-new | 0.5s | ease-out | New items | +| row-new | 0.3s | ease-out | Live data rows | +| transitions | 0.2s | default | Interactive states | Elements with fade-in start with `opacity: 0`. @@ -416,7 +416,7 @@ Elements with fade-in start with `opacity: 0`. ## Don't -- Use primitive color scales directly (`bg-terracotta-500`, `bg-sand-100`) +- Use Tailwind's palette, colour literals, opacity modifiers or `dark:` variants; use a token or add one - Use locale formatting for slots/epochs (no commas) - Override chart grid config in wrapper components unless necessary - Mix depth strategies (shadows in dark mode, outlines in light mode) diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b7425b9ee..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -enable-pre-post-scripts=true \ No newline at end of file diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index a45fd52cc..000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..d350b7ac0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +pnpm-lock.yaml +src/api/ +src/routeTree.gen.ts +public/mockServiceWorker.js +public/data/ +.design-sync/ +src/lab-api/ diff --git a/.prettierrc b/.prettierrc index 035c0fb9e..8a7adc1e5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -8,5 +8,11 @@ "trailingComma": "es5", "bracketSpacing": true, "arrowParens": "avoid", - "printWidth": 120 + "printWidth": 120, + "overrides": [ + { + "files": "*.md", + "options": { "embeddedLanguageFormatting": "off" } + } + ] } diff --git a/.storybook/main.ts b/.storybook/main.ts index 94ad075a4..f85fc2b26 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from '@storybook/react-vite'; +import { resolve } from 'node:path'; const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], @@ -6,12 +7,16 @@ const config: StorybookConfig = { '@chromatic-com/storybook', '@storybook/addon-docs', '@storybook/addon-vitest', + '@storybook/addon-a11y', '@storybook/addon-themes', 'storybook/viewport', ], framework: { name: '@storybook/react-vite', - options: {}, + options: { + // Not the app's vite.config.ts: the router generator, head plugin and proxy belong to the app. + builder: { viteConfigPath: resolve(import.meta.dirname, 'vite.config.ts') }, + }, }, core: { builder: '@storybook/builder-vite', diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index cd6139f23..a2d2995fc 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,28 +1,51 @@ import type { Preview, ReactRenderer } from '@storybook/react-vite'; import { INITIAL_VIEWPORTS } from 'storybook/viewport'; import { RouterProvider, createMemoryHistory, createRootRoute, createRouter } from '@tanstack/react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { NetworkProvider } from '../src/providers/NetworkProvider'; -import { ThemeProvider } from '../src/providers/ThemeProvider'; -import { ConfigGate } from '../src/components/Overlays/ConfigGate'; -import { initialize, mswLoader } from 'msw-storybook-addon'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { setupWorker, type SetupWorker } from 'msw/browser'; +import { mswLoader } from 'msw-storybook-addon/csf3'; import { withThemeByClassName } from '@storybook/addon-themes'; -import { handlers, mockConfig, mockBounds } from './mocks'; -import '../src/index.css'; +import { NetworkProvider } from '@/providers/NetworkProvider'; +import { NotificationProvider } from '@/providers/NotificationProvider'; +import { ThemeProvider } from '@/providers/ThemeProvider'; +import { ConfigGate } from '@/components/Overlays/ConfigGate'; +import type { Theme } from '@/contexts/ThemeContext'; +import { boundsFixture, boundsHandlers, configFixture, configHandlers } from '@/test-utils/handlers'; +import { BASE_URL } from '@/utils/api-config'; +import { createQueryClient } from '@/utils/query-client'; +import { getBoundsQueryKey, getConfigQueryKey } from '@/lab-api/@tanstack/react-query.gen'; +import { createCatchAllAssetHandler } from './assetHandlers'; +import '@/index.css'; // Get the base path from environment -const basePath = import.meta.env.STORYBOOK_BASE_PATH || ''; - -// Initialize MSW with correct service worker URL for GitHub Pages -initialize({ - onUnhandledRequest: 'bypass', - quiet: true, // Suppress MSW startup messages - serviceWorker: { - // Use base path for service worker URL - url: basePath ? `${basePath}/mockServiceWorker.js` : '/mockServiceWorker.js', - }, +const basePath = (import.meta.env.STORYBOOK_BASE_PATH || '').replace(/\/$/, ''); + +// An API request a story did not mock is a defect: answer 404 and log it, +// which fails the story's test (see vitest-setup.ts). Stories add handlers +// with `parameters.msw.handlers`; those take precedence over this one. +const unmockedApi = http.all(`${BASE_URL}/api/*`, ({ request }) => { + console.error(`Unmocked API request in story: ${request.method} ${new URL(request.url).pathname}`); + return new HttpResponse(null, { status: 404 }); }); +async function startWorker(): Promise { + // The MSW Vite plugin serves the worker script and emits it into the build. + const worker = setupWorker(unmockedApi, ...createCatchAllAssetHandler()); + await worker.start({ + onUnhandledFrame: 'bypass', + quiet: true, + serviceWorker: { url: `${basePath}/mockServiceWorker.js` }, + }); + return worker; +} + +const themes: Record = { light: '', dark: 'dark', star: 'star' }; + +// The toolbar's starting theme. Vitest runs every story under each theme instead +// (vitest.config.ts sets the theme global per project). +const defaultTheme: Theme = 'dark'; + /** * Storybook Preview Configuration * @@ -45,40 +68,29 @@ initialize({ * This allows testing components that depend on URL search params (e.g., network selection). */ const preview: Preview = { - loaders: [mswLoader], + loaders: [mswLoader(startWorker)], decorators: [ - withThemeByClassName({ - themes: { - light: '', - dark: 'dark', - star: 'star', - }, - defaultTheme: 'dark', - }), + withThemeByClassName({ themes, defaultTheme }), (Story, context) => { // Allow stories to override query options via parameters.tanstackQuery const queryOptions = context.parameters.tanstackQuery?.queries || {}; // Create QueryClient inside decorator for proper isolation per story - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 0, // No retries by default - staleTime: Infinity, - ...queryOptions, // Story-level overrides - }, - }, + const queryClient = createQueryClient({ + retry: false, + staleTime: Infinity, + ...queryOptions, // Story-level overrides }); // Hydrate the QueryClient with mock data (unless story opts out via parameters.hydration = false) - // This mirrors the hydration pattern in __root.tsx + // This mirrors the configuration and bounds lab-backend injects into the page if (context.parameters.hydration !== false) { // Hydrate config - queryClient.setQueryData(['config'], mockConfig); + queryClient.setQueryData(getConfigQueryKey(), configFixture); // Hydrate bounds for each network - mockConfig.networks.forEach(network => { - queryClient.setQueryData(['bounds', network.name], mockBounds); + configFixture.networks.forEach(network => { + queryClient.setQueryData(getBoundsQueryKey({ path: { network: network.name } }), boundsFixture); }); } @@ -113,20 +125,43 @@ const preview: Preview = { defaultPendingMinMs: 0, }); + // The app's provider follows the theme the toolbar (or the test project) chose, + // so components that read the theme agree with the class on . + const theme: Theme = context.parameters.themes?.themeOverride || context.globals.theme || defaultTheme; + return ( - - - - + + + + + + ); }, - ] as ReactRenderer['decorators'], + ], parameters: { + // Accessibility violations fail the story's test. + a11y: { + test: 'error', + config: { + rules: [ + // Contrast comes from the theme tokens, which are being reworked separately. Turn this + // back on once they meet it. + { id: 'color-contrast', enabled: false }, + // react-datepicker names a day "Choose Wednesday, September 30th, 2026", which does not + // contain its visible "30" as a word. Every other element is still checked. + { id: 'label-content-name-mismatch', selector: '*:not(.react-datepicker__day)' }, + ], + }, + }, msw: { - handlers: handlers, + handlers: { + config: configHandlers.success, + bounds: boundsHandlers.success, + }, }, viewport: { viewports: INITIAL_VIEWPORTS, @@ -140,6 +175,7 @@ const preview: Preview = { options: { storySort: { method: 'alphabetical', + order: ['Foundations', ['Introduction', 'Colours', 'Typography', 'Layout'], '*'], }, }, }, diff --git a/.storybook/vite.config.ts b/.storybook/vite.config.ts new file mode 100644 index 000000000..b1fc4d7c9 --- /dev/null +++ b/.storybook/vite.config.ts @@ -0,0 +1,14 @@ +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { msw } from 'msw/vite'; +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +/** + * Preview compilation only. MSW serves and emits its worker script; + * preview.tsx starts the worker itself. + */ +export default defineConfig({ + plugins: [react(), tailwindcss(), msw({ mode: 'worker-only' })], + resolve: { alias: { '@': resolve(import.meta.dirname, '../src') } }, +}); diff --git a/.storybook/vitest-setup.ts b/.storybook/vitest-setup.ts index bca1d4d7d..2c6f63a1c 100644 --- a/.storybook/vitest-setup.ts +++ b/.storybook/vitest-setup.ts @@ -1,32 +1,18 @@ -import '@testing-library/jest-dom'; -import { beforeAll, vi } from 'vitest'; -import { setProjectAnnotations } from '@storybook/react-vite'; -import * as projectAnnotations from './preview'; +import { configure } from 'storybook/test'; +import { afterEach, beforeEach, vi } from 'vitest'; -setProjectAnnotations([projectAnnotations]); +// findBy/waitFor default to 1s; MSW-backed queries can take longer under load. +configure({ asyncUtilTimeout: 5000 }); -// Setup mocks for ECharts v6 test environment -beforeAll(() => { - // Mock DOM dimensions for ECharts (jsdom doesn't compute actual dimensions) - // This fixes "Can't get DOM width or height" warnings in tests - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { - configurable: true, - value: 800, - }); +// A story that logs a React or console error fails, even if it rendered. +let consoleError: ReturnType; - Object.defineProperty(HTMLElement.prototype, 'clientHeight', { - configurable: true, - value: 600, - }); - - // Mock ResizeObserver for ECharts and echarts-for-react - // This fixes "Cannot read properties of undefined (reading 'disconnect')" errors - // Using regular function constructor (not arrow function) for proper instantiation - const ResizeObserverMock = vi.fn(function (this: ResizeObserver) { - this.observe = vi.fn(); - this.unobserve = vi.fn(); - this.disconnect = vi.fn(); - }); +beforeEach(() => { + consoleError = vi.spyOn(console, 'error'); +}); - vi.stubGlobal('ResizeObserver', ResizeObserverMock); +afterEach(() => { + const errors = (consoleError.mock.calls as unknown[][]).map(args => args.map(String).join(' ')); + consoleError.mockRestore(); + if (errors.length > 0) throw new Error(errors.join('\n')); }); diff --git a/CLAUDE.md b/CLAUDE.md index fda79a508..f340b95c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,22 +5,29 @@ ```bash pnpm dev # Dev server proxying to local backend (localhost:8080) BACKEND=production pnpm dev # Dev server proxying to production (lab.ethpandaops.io) -pnpm lint -pnpm test +pnpm lint # ESLint; warnings fail too +pnpm typecheck # tsc -b over the app, the tests and stories, and the config files +pnpm format:check # Prettier over the whole repo; pnpm format rewrites +pnpm knip # Unused files, exports and dependencies +pnpm test # Unit tests (jsdom), then every story in Chromium under each theme +pnpm test:unit # jsdom only; pnpm test:storybook for stories only +pnpm test:coverage # Unit tests with a v8 coverage report in coverage/ pnpm build pnpm storybook ``` +Story tests need Chromium once: `pnpm exec playwright install chromium`. + The `BACKEND` env var controls the API proxy target. Values: `local` (default), `production`, or any custom URL. ## Libraries -- pnpm v10, node v24, vite v7, react v19, typescript v5 +- pnpm v12, node v26, vite v7, react v19, typescript v5 - tailwindcss v4, headlessui v2, heroicons v2 - @tanstack/react-query v5, @tanstack/router-plugin v1 - zod v4, react-hook-form v7, clsx - echarts v6, echarts-for-react v3, echarts-gl v2 -- storybook v10, vitest v4 +- storybook v10, vitest v5, msw v3 ## Project Structure @@ -62,10 +69,13 @@ src/ providers/ # React Context Providers contexts/ # React Context definitions (with .types.ts) hooks/ # Custom React hooks (with tests) - api/ # Generated API client (do not edit) - @tanstack/react-query.gen.ts # TanStack Query hooks - USE THIS for all API calls + api/ # Generated CBT API client (do not edit) + @tanstack/react-query.gen.ts # TanStack Query options - USE THIS for all API calls + lab-api/ # Generated lab-backend client from openapi/lab-backend.yaml (do not edit) utils/ # Utility functions and helpers - index.css # Global styles and Tailwind theme config + index.css # Entry stylesheet: fonts, tokens, utilities, base styles; Tailwind scans src + styles/ # tokens.css (every design token), base.css, utilities.css, token tests + design-docs/ # Foundations MDX pages generated from the tokens ``` ## Architecture @@ -73,11 +83,13 @@ src/ ### Component & State Philosophy **Core/Shared** (`src/components/`, `src/hooks/`, `src/contexts/`): + - App-wide reusable building blocks — generic and configurable - Work in any context without page logic - Core components include Storybook stories **Page-Scoped** (`src/pages/[section]/[page-name]/components|hooks|contexts|providers|utils/`): + - Used within specific page/section only - Compose/extend core items for page needs - Contain page-specific business logic @@ -128,12 +140,12 @@ Route directories also include `beacon/` and `xatu-data/` alongside the sections - Storybook stories for all core components - Write Vitest tests for all components, hooks, and utilities - Use JSDoc style comments for functions, components, hooks, and complex types -- Use Tailwind v4 classes with semantic color tokens from `src/index.css` theme -- Use `@/api/@tanstack/react-query.gen.ts` hooks for API calls -- Use path aliases (`@/`) over relative imports +- Use Tailwind v4 classes with semantic color tokens from `src/styles/tokens.css` +- Read API data through the generated query options (see [Data](#data)) +- Import through `@/`, never `../`; lint rejects parent-relative imports - Use `clsx` for conditional classes -- Run `pnpm lint` and `pnpm build` before committing -- React `useMemo`/`memo` should only be used for genuinely expensive calculations +- Run `pnpm lint`, `pnpm typecheck`, `pnpm format:check`, `pnpm knip` and `pnpm build` before committing +- Test-only code (tests, stories, `src/test-utils`, `src/design-docs`, `src/styles/**/*.ts`) is excluded from `tsconfig.app.json` and type-checked by `tsconfig.test.json`; a new test-only path goes in both ### Naming Conventions @@ -151,13 +163,18 @@ Route directories also include `beacon/` and `xatu-data/` alongside the sections - **Required for**: All hooks, utilities, and core components - **Vitest** for hooks and utilities, **Storybook interactions** for components - **Location**: Co-located with source files (`.test.ts(x)` for Vitest, `.stories.tsx` for Storybook) +- `vitest.config.ts` holds both projects: `unit` (jsdom) and `storybook` (Chromium, once per theme: `storybook:light`, `storybook:dark`, `storybook:star`, each in UTC, `en-US` and reduced motion). Mocks and stubbed globals are restored before every test. +- Unit tests render through `@/test-utils` and mock the API with MSW; fixtures and time come from `@/test-utils` too. See [Tests](.claude/rules/tests.md). +- Vitest caps jsdom at four workers, then runs Storybook with two Chromium pages per theme. The test scripts raise `VITEST_CHROMIUM_GC_DISK_THRESHOLD_GB` so Vitest's Linux workaround collects Chromium garbage after each file. -### Loading States +## Data -- Use `LoadingContainer` from `src/components/Layout/LoadingContainer/` as base -- Create page-specific skeletons: `[PageName]Skeleton` or `[ComponentName]Skeleton` -- Place in `pages/[section]/[page-name]/components/` alongside other page components -- Show skeleton UI that matches actual content structure +- Read server data with `useQuery(Options(...))` from the generated clients: `@/api/@tanstack/react-query.gen` for the CBT tables, `@/lab-api/@tanstack/react-query.gen` for lab-backend's own routes (config, bounds, downloads, gas profiler). Types come from `@/api` and `@/lab-api`, schemas from their `zod.gen`. Never `fetch` in a component or an effect. +- Actions, such as a download or a simulation, run the generated SDK function or `Mutation()` through `useMutation`. +- A lab-backend route the UI needs goes into `openapi/lab-backend.yaml`, matching the backend's handler in `internal/api`; `pnpm generate:api` regenerates both clients. The CBT client validates requests and responses against its spec. The lab-backend client doesn't: its spec is written by hand, and a field it gets wrong must not stop the app from loading. +- The generated clients throw the response body, not an `Error`. Read lab-backend's message with `apiErrorMessage` from `@/utils/api-error`. +- `createQueryClient` (`@/utils/query-client`) makes the app's query client; tests and stories use it too, with `{ retry: false }`. +- Read configuration with `useConfig()` and bounds with `useBounds()`, never `window.__CONFIG__` or `window.__BOUNDS__`. lab-backend injects both into the page, and `createQueryClient` seeds the cache with them before the first render. ## Slot & Epoch Display @@ -183,13 +200,16 @@ import { formatSlot, formatEpoch } from '@/utils'; ## Additional Rules -Detailed standards for specific topics are in `.claude/rules/`: +Detailed standards for specific topics are in `.claude/rules/`. Claude Code loads each for the paths in its front matter; other agents read the matching file before editing those files. - [Charts](.claude/rules/charts.md) — Visual standards, axis formatting, grid padding, component architecture - [Forms](.claude/rules/forms.md) — Zod search param validation, react-hook-form patterns +- [Loading states](.claude/rules/loading-states.md) — Loading, error, empty and stale states for async data +- [React](.claude/rules/react.md) — Components, hooks and the React Compiler rules lint enforces - [SEO](.claude/rules/seo.md) — Head meta hierarchy, feature images, route implementation -- [Theming](.claude/rules/theming.md) — Two-tier color architecture, semantic tokens -- [Storybook](.claude/rules/storybook.md) — Decorator pattern, story title convention +- [Styling](.claude/rules/styling.md) — Tokens, themes, Tailwind classes, adding a token +- [Storybook](.claude/rules/storybook.md) — Stories as tests, API mocks, decorator pattern, story title convention +- [Tests](.claude/rules/tests.md) — `@/test-utils`, MSW handlers, fixtures and time in unit tests ## Layout diff --git a/eslint-rules/README.md b/eslint-rules/README.md index db4ca1fd2..0acd28afa 100644 --- a/eslint-rules/README.md +++ b/eslint-rules/README.md @@ -1,20 +1,11 @@ # Custom ESLint Rules for Lab -This directory contains custom ESLint rules that enforce color usage standards in the Lab project. +This directory contains custom ESLint rules for the Lab project. ## Overview -Lab uses a **two-tier color architecture** defined in `src/index.css`: - -- **Tier 1: Primitive Color Scales** - Foundation colors (`terracotta-*`, `sand-*`, `neutral-*`, `aurora-*`) - - These should ONLY be referenced in the theme definition (`src/index.css`) - - Should NEVER be used directly in application code - -- **Tier 2: Semantic Tokens** - Application-level colors - - **Brand**: `primary`, `secondary`, `accent` - - **Surface**: `background`, `surface`, `foreground`, `muted`, `border` - - **State**: `success`, `warning`, `danger` - - **Data Viz**: `blob-*`, `continent-*`, `chart-*`, `performance-*` +Colour, arbitrary-value and `dark:` enforcement is ESLint config, not a custom rule: see the better-tailwindcss block in +`eslint.config.js` and `.claude/rules/styling.md`. Only rules the config cannot express live here. ## Rules @@ -23,6 +14,7 @@ Lab uses a **two-tier color architecture** defined in `src/index.css`: Validates that all images referenced in route meta tags (og:image, twitter:image) actually exist in the public directory. **❌ Incorrect:** + ```tsx // In src/routes/xatu/contributors.tsx export const Route = createFileRoute('/xatu/contributors')({ @@ -36,6 +28,7 @@ export const Route = createFileRoute('/xatu/contributors')({ ``` **✅ Correct:** + ```tsx // In src/routes/xatu/contributors.tsx export const Route = createFileRoute('/xatu/contributors')({ @@ -49,184 +42,13 @@ export const Route = createFileRoute('/xatu/contributors')({ ``` **Image Path Convention:** + - Images should be placed at: `public/images/[section]/[page-name].png` - Referenced as: `/images/[section]/[page-name].png` - Example: `public/images/xatu/contributors.png` → `/images/xatu/contributors.png` **When to create images:** + - Every page should have a feature image for social sharing (og:image, twitter:image) - Images should be PNG format - Follow the section-based directory structure - -### `lab/no-hardcoded-colors` - -Bans hardcoded color values (hex, RGB, HSL, named colors) in Tailwind className strings and React inline styles. - -**❌ Incorrect:** -```tsx -// Tailwind arbitrary values -
Red background
-
Red text
-
Red border
-
Green background
- -// Inline styles -
Red text
-
Red background
-
Red border
-
Named color
-``` - -**✅ Correct:** -```tsx -// Tailwind semantic tokens -
Red background
-
Red text
-
Red border
-
Green background
- -// Inline styles with CSS variables -
Red text
-
Primary background
- -// Inline styles with useThemeColors hook -const themeColors = useThemeColors(); -
Red text
- -// Allowed special values -
Transparent
-
Current color
-
Inherit
-``` - -**Disabling the rule for specific cases:** -```tsx -// For legitimate use cases (e.g., dynamic colors from API): -// eslint-disable-next-line lab/no-hardcoded-colors -
API color
- -/* eslint-disable lab/no-hardcoded-colors */ -// Multiple lines that need to be disabled - -/* eslint-enable lab/no-hardcoded-colors */ -``` - -### `lab/no-primitive-color-scales` - -Bans direct usage of primitive color scales in Tailwind className strings. - -**❌ Incorrect:** -```tsx -
Terracotta background
-
Sand text
-
Neutral border
-
Aurora background
-
Terracotta text
-``` - -**✅ Correct:** -```tsx -
Primary background
-
Foreground text
-
Border
-
Accent background
-
Muted text
-``` - -## Why These Rules? - -1. **Single Source of Truth**: All colors are defined in `src/index.css`, making it easy to maintain consistent theming -2. **Theme Switching**: Semantic tokens automatically adapt when switching between light, dark, and star themes -3. **Future-Proof**: Changing brand colors only requires updating `src/index.css`, not hunting through the codebase -4. **Accessibility**: Semantic tokens ensure proper contrast ratios across all themes -5. **Developer Experience**: Clear, meaningful names (`primary`, `danger`) instead of arbitrary numbers (`terracotta-500`) - -## Using Colors in Charts - -For chart components (ECharts, etc.), use the `useThemeColors()` hook: - -```tsx -import { useThemeColors } from '@/hooks/useThemeColors'; - -export function MyChart() { - const themeColors = useThemeColors(); - - const option = { - backgroundColor: themeColors.background, - textStyle: { color: themeColors.foreground }, - series: [{ - itemStyle: { color: themeColors.primary } - }] - }; - - return ; -} -``` - -## Configuration - -These rules are configured in `eslint.config.js`: - -```js -{ - plugins: { - lab: customRules, - }, - rules: { - 'lab/validate-route-images': 'error', - 'lab/no-hardcoded-colors': 'error', - 'lab/no-primitive-color-scales': 'error', - }, -} -``` - -## Exceptions - -The only place where primitive color scales should be used is in `src/index.css` when defining the semantic tokens: - -```css -@layer base { - :root { - --color-primary: theme('colors.terracotta.500'); /* ✅ OK - defining semantic token */ - --color-background: theme('colors.sand.50'); /* ✅ OK - defining semantic token */ - } -} -``` - -## Disabling Rules - -You can disable rules for specific lines or blocks using ESLint comments: - -```tsx -// Disable for next line only -// eslint-disable-next-line lab/no-hardcoded-colors -
Specific use case
- -// Disable for entire block -/* eslint-disable lab/no-hardcoded-colors */ - // Colors from external API -/* eslint-enable lab/no-hardcoded-colors */ - -// Disable both rules -// eslint-disable-next-line lab/no-hardcoded-colors, lab/no-primitive-color-scales -
Legacy code
-``` - -**When to disable:** -- Dynamic colors from external APIs or user input -- Third-party library requirements -- Data visualization with specific color requirements -- Legacy code during migration (temporary only) - -## Testing - -To test these rules, create a file with violations and run: - -```bash -pnpm eslint path/to/file.tsx -``` - -Example violations: -- `className="bg-[#ff0000]"` → triggers `lab/no-hardcoded-colors` -- `style={{ color: '#ff0000' }}` → triggers `lab/no-hardcoded-colors` -- `className="bg-terracotta-500"` → triggers `lab/no-primitive-color-scales` diff --git a/eslint-rules/index.cjs b/eslint-rules/index.cjs index 09a4ad2ab..023223c86 100644 --- a/eslint-rules/index.cjs +++ b/eslint-rules/index.cjs @@ -2,15 +2,11 @@ * @fileoverview Custom ESLint rules for Lab project * @author Lab Team * - * This module exports custom ESLint rules that enforce color usage standards - * in the Lab project. All colors should be defined in src/index.css using - * the two-tier color architecture (primitive scales + semantic tokens). + * Styling rules (tokens, colours, arbitrary values) are config in eslint.styling.js. */ module.exports = { rules: { - 'no-hardcoded-colors': require('./no-hardcoded-colors.cjs'), - 'no-primitive-color-scales': require('./no-primitive-color-scales.cjs'), 'validate-route-images': require('./validate-route-images.cjs'), }, }; diff --git a/eslint-rules/no-hardcoded-colors.cjs b/eslint-rules/no-hardcoded-colors.cjs deleted file mode 100644 index 99814c1fe..000000000 --- a/eslint-rules/no-hardcoded-colors.cjs +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @fileoverview Ban hardcoded colors (hex, rgb, hsl) in Tailwind classes and inline styles - * @author Lab Team - * - * This rule prevents developers from using hardcoded color values in Tailwind - * className strings and React inline styles. All colors should come from the - * semantic color tokens defined in src/index.css. - * - * Examples of incorrect code: - * className="bg-[#ff0000]" - * className="text-[rgb(255,0,0)]" - * className="border-[hsl(0,100%,50%)]" - * style={{ color: '#ff0000' }} - * style={{ backgroundColor: 'rgb(255,0,0)' }} - * - * Examples of correct code: - * className="bg-primary" - * className="text-danger" - * className="border-accent" - * style={{ color: 'var(--color-primary)' }} - * const themeColors = useThemeColors(); style={{ color: themeColors.primary }} - */ - -/** @type {import('eslint').Rule.RuleModule} */ -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Disallow hardcoded colors in Tailwind classes', - category: 'Best Practices', - recommended: true, - }, - messages: { - hardcodedHexColor: - 'Hardcoded hex color "{{value}}" detected. Use semantic color tokens from src/index.css instead (e.g., bg-primary, var(--color-primary), or useThemeColors()).', - hardcodedRgbColor: - 'Hardcoded RGB color "{{value}}" detected. Use semantic color tokens from src/index.css instead (e.g., bg-primary, var(--color-primary), or useThemeColors()).', - hardcodedHslColor: - 'Hardcoded HSL color "{{value}}" detected. Use semantic color tokens from src/index.css instead (e.g., bg-primary, var(--color-primary), or useThemeColors()).', - hardcodedNamedColor: - 'Hardcoded named color "{{value}}" detected in {{property}}. Use semantic color tokens from src/index.css instead (e.g., var(--color-primary) or useThemeColors()).', - }, - schema: [], - }, - - create(context) { - // Patterns to detect hardcoded colors in Tailwind arbitrary values - const hexPattern = /\[#[0-9a-fA-F]{3,8}\]/g; - const rgbPattern = /\[rgba?\([^)]+\)\]/g; - const hslPattern = /\[hsla?\([^)]+\)\]/g; - - // Patterns for inline style values (without Tailwind brackets) - const hexValuePattern = /^#[0-9a-fA-F]{3,8}$/; - const rgbValuePattern = /^rgba?\([^)]+\)$/; - const hslValuePattern = /^hsla?\([^)]+\)$/; - - // Common named colors to ban (not exhaustive, but covers most common cases) - // Excludes 'transparent', 'currentColor', 'inherit' which are valid - const namedColors = new Set([ - 'black', - 'white', - 'red', - 'green', - 'blue', - 'yellow', - 'orange', - 'purple', - 'pink', - 'gray', - 'grey', - 'brown', - 'cyan', - 'magenta', - 'lime', - 'indigo', - 'violet', - 'gold', - 'silver', - 'navy', - 'teal', - 'olive', - 'maroon', - 'aqua', - 'fuchsia', - ]); - - // Style properties that accept color values - const colorProperties = new Set([ - 'color', - 'backgroundColor', - 'borderColor', - 'borderTopColor', - 'borderRightColor', - 'borderBottomColor', - 'borderLeftColor', - 'outlineColor', - 'textDecorationColor', - 'fill', - 'stroke', - 'caretColor', - 'accentColor', - ]); - - /** - * Check if a string value contains hardcoded color values - * @param {import('estree').Node} node - The AST node - * @param {string} value - The string value to check - */ - function checkForHardcodedColors(node, value) { - // Check for hex colors - const hexMatches = value.match(hexPattern); - if (hexMatches) { - hexMatches.forEach(match => { - context.report({ - node, - messageId: 'hardcodedHexColor', - data: { value: match }, - }); - }); - } - - // Check for RGB colors - const rgbMatches = value.match(rgbPattern); - if (rgbMatches) { - rgbMatches.forEach(match => { - context.report({ - node, - messageId: 'hardcodedRgbColor', - data: { value: match }, - }); - }); - } - - // Check for HSL colors - const hslMatches = value.match(hslPattern); - if (hslMatches) { - hslMatches.forEach(match => { - context.report({ - node, - messageId: 'hardcodedHslColor', - data: { value: match }, - }); - }); - } - } - - /** - * Check if a style object property has a hardcoded color value - * @param {import('estree').Node} node - The property node - * @param {string} propertyName - The property name - * @param {*} valueNode - The value node - */ - function checkStyleProperty(node, propertyName, valueNode) { - if (!colorProperties.has(propertyName)) { - return; - } - - // Handle string literal values - if (valueNode.type === 'Literal' && typeof valueNode.value === 'string') { - const value = valueNode.value; - - // Check for hex colors - if (hexValuePattern.test(value)) { - context.report({ - node: valueNode, - messageId: 'hardcodedHexColor', - data: { value }, - }); - return; - } - - // Check for RGB colors - if (rgbValuePattern.test(value)) { - context.report({ - node: valueNode, - messageId: 'hardcodedRgbColor', - data: { value }, - }); - return; - } - - // Check for HSL colors - if (hslValuePattern.test(value)) { - context.report({ - node: valueNode, - messageId: 'hardcodedHslColor', - data: { value }, - }); - return; - } - - // Check for named colors (but allow CSS variables, currentColor, transparent, inherit) - if ( - namedColors.has(value.toLowerCase()) && - !value.startsWith('var(') && - value !== 'currentColor' && - value !== 'transparent' && - value !== 'inherit' - ) { - context.report({ - node: valueNode, - messageId: 'hardcodedNamedColor', - data: { value, property: propertyName }, - }); - } - } - } - - return { - // Handle JSX className attributes - JSXAttribute(node) { - if ( - node.name.name === 'className' && - node.value && - node.value.type === 'Literal' && - typeof node.value.value === 'string' - ) { - checkForHardcodedColors(node, node.value.value); - } - - // Handle template literals in className - if ( - node.name.name === 'className' && - node.value && - node.value.type === 'JSXExpressionContainer' && - node.value.expression.type === 'TemplateLiteral' - ) { - node.value.expression.quasis.forEach(quasi => { - checkForHardcodedColors(node, quasi.value.raw); - }); - } - - // Handle style attribute with object expression - if ( - node.name.name === 'style' && - node.value && - node.value.type === 'JSXExpressionContainer' && - node.value.expression.type === 'ObjectExpression' - ) { - node.value.expression.properties.forEach(prop => { - if (prop.type === 'Property' && prop.key.type === 'Identifier') { - checkStyleProperty(node, prop.key.name, prop.value); - } - }); - } - }, - - // Handle clsx/classnames/cn function calls - CallExpression(node) { - const functionName = node.callee.name; - if (['clsx', 'classnames', 'cn', 'cva'].includes(functionName)) { - node.arguments.forEach(arg => { - if (arg.type === 'Literal' && typeof arg.value === 'string') { - checkForHardcodedColors(arg, arg.value); - } - if (arg.type === 'TemplateLiteral') { - arg.quasis.forEach(quasi => { - checkForHardcodedColors(arg, quasi.value.raw); - }); - } - }); - } - }, - }; - }, -}; diff --git a/eslint-rules/no-hardcoded-colors.test.cjs b/eslint-rules/no-hardcoded-colors.test.cjs deleted file mode 100644 index ca1aad5b4..000000000 --- a/eslint-rules/no-hardcoded-colors.test.cjs +++ /dev/null @@ -1,193 +0,0 @@ -/** - * @fileoverview Tests for no-hardcoded-colors rule - * @author Lab Team - * - * Run with: node eslint-rules/no-hardcoded-colors.test.cjs - */ - -const rule = require('./no-hardcoded-colors.cjs'); -const { RuleTester } = require('eslint'); - -const ruleTester = new RuleTester({ - languageOptions: { - ecmaVersion: 2021, - sourceType: 'module', - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, -}); - -ruleTester.run('no-hardcoded-colors', rule, { - valid: [ - // Tailwind semantic tokens - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - - // CSS variables in inline styles - { code: '
Text
' }, - { code: '
Text
' }, - - // Special allowed values - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - - // Non-literal values (theme colors from hook) - { code: '
Text
' }, - { code: '
Text
' }, - - // clsx with semantic tokens - { code: 'clsx("bg-primary", "text-foreground")' }, - { code: 'cn("bg-success", isActive && "text-danger")' }, - ], - - invalid: [ - // Tailwind arbitrary values - hex - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedHexColor', - data: { value: '[#ff0000]' }, - }, - ], - }, - - // Tailwind arbitrary values - rgb - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedRgbColor', - data: { value: '[rgb(255,0,0)]' }, - }, - ], - }, - - // Tailwind arbitrary values - hsl - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedHslColor', - data: { value: '[hsl(0,100%,50%)]' }, - }, - ], - }, - - // Inline styles - hex - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedHexColor', - data: { value: '#ff0000' }, - }, - ], - }, - - // Inline styles - rgb - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedRgbColor', - data: { value: 'rgb(255,0,0)' }, - }, - ], - }, - - // Inline styles - rgba - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedRgbColor', - data: { value: 'rgba(255,0,0,0.5)' }, - }, - ], - }, - - // Inline styles - hsl - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedHslColor', - data: { value: 'hsl(0,100%,50%)' }, - }, - ], - }, - - // Inline styles - named colors - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedNamedColor', - data: { value: 'red', property: 'color' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedNamedColor', - data: { value: 'blue', property: 'backgroundColor' }, - }, - ], - }, - - // Multiple violations - { - code: '
Text
', - errors: [ - { - messageId: 'hardcodedHexColor', - data: { value: '#ffffff' }, - }, - { - messageId: 'hardcodedNamedColor', - data: { value: 'blue', property: 'backgroundColor' }, - }, - { - messageId: 'hardcodedRgbColor', - data: { value: 'rgb(0,255,0)' }, - }, - ], - }, - - // clsx with violations - { - code: 'clsx("bg-[#00ff00]", "text-primary")', - errors: [ - { - messageId: 'hardcodedHexColor', - data: { value: '[#00ff00]' }, - }, - ], - }, - - // cn with violations - { - code: 'cn("bg-[#ff0000] text-[rgb(0,255,0)]")', - errors: [ - { - messageId: 'hardcodedHexColor', - data: { value: '[#ff0000]' }, - }, - { - messageId: 'hardcodedRgbColor', - data: { value: '[rgb(0,255,0)]' }, - }, - ], - }, - ], -}); - -console.log('✅ All no-hardcoded-colors tests passed!'); diff --git a/eslint-rules/no-primitive-color-scales.cjs b/eslint-rules/no-primitive-color-scales.cjs deleted file mode 100644 index 17ecdd9c1..000000000 --- a/eslint-rules/no-primitive-color-scales.cjs +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @fileoverview Ban primitive color scales in Tailwind classes - * @author Lab Team - * - * This rule prevents developers from using primitive color scales directly - * (terracotta-*, sand-*, neutral-*, aurora-*) in Tailwind className strings. - * All UI colors should use semantic tokens defined in src/index.css. - * - * Primitive scales are Tier 1 foundation colors and should only be referenced - * in the theme definition (src/index.css). Application code should use Tier 2 - * semantic tokens. - * - * Examples of incorrect code: - * className="bg-terracotta-500" - * className="text-sand-600" - * className="border-neutral-700" - * className="bg-aurora-cyan-400" - * - * Examples of correct code: - * className="bg-primary" - * className="text-foreground" - * className="border-border" - * className="bg-accent" - * - * Exception: Data visualization colors (blob-*, continent-*, chart-*, - * performance-*) are allowed as they are semantic tokens for specific use cases. - */ - -/** @type {import('eslint').Rule.RuleModule} */ -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Disallow primitive color scales in Tailwind classes', - category: 'Best Practices', - recommended: true, - }, - messages: { - primitiveColorScale: - 'Primitive color scale "{{scale}}" detected in "{{match}}". Use semantic tokens instead:\n' + - ' • Brand: primary, secondary, accent\n' + - ' • Surface: background, surface, foreground, muted, border\n' + - ' • State: success, warning, danger\n' + - ' • Data viz: blob-*, continent-*, chart-*, performance-*', - }, - schema: [], - }, - - create(context) { - // Primitive color scales that should NOT be used directly - const primitiveScales = [ - 'terracotta', - 'sand', - 'neutral', - 'aurora-cyan', - 'aurora-green', - 'aurora-purple', - 'aurora-pink', - 'aurora-blue', - ]; - - // Build regex pattern to match any Tailwind class using primitive scales - // Matches patterns like: bg-terracotta-500, text-sand-600, border-neutral-700 - const primitivePattern = new RegExp( - `\\b(?:bg|text|border|from|via|to|ring|outline|decoration|divide|accent|caret|fill|stroke|shadow)-(?:${primitiveScales.join('|')})(?:-(?:50|100|200|300|400|500|600|700|800|900|950))?(?:\\/\\d+)?\\b`, - 'g' - ); - - /** - * Check if a string value contains primitive color scale usage - * @param {import('estree').Node} node - The AST node - * @param {string} value - The string value to check - */ - function checkForPrimitiveColors(node, value) { - const matches = value.match(primitivePattern); - if (matches) { - matches.forEach(match => { - // Extract the scale name from the match - const scaleMatch = primitiveScales.find(scale => match.includes(scale)); - context.report({ - node, - messageId: 'primitiveColorScale', - data: { - scale: scaleMatch, - match: match, - }, - }); - }); - } - } - - return { - // Handle JSX className attributes - JSXAttribute(node) { - if ( - node.name.name === 'className' && - node.value && - node.value.type === 'Literal' && - typeof node.value.value === 'string' - ) { - checkForPrimitiveColors(node, node.value.value); - } - - // Handle template literals in className - if ( - node.name.name === 'className' && - node.value && - node.value.type === 'JSXExpressionContainer' && - node.value.expression.type === 'TemplateLiteral' - ) { - node.value.expression.quasis.forEach(quasi => { - checkForPrimitiveColors(node, quasi.value.raw); - }); - } - }, - - // Handle clsx/classnames/cn function calls - CallExpression(node) { - const functionName = node.callee.name; - if (['clsx', 'classnames', 'cn', 'cva'].includes(functionName)) { - node.arguments.forEach(arg => { - if (arg.type === 'Literal' && typeof arg.value === 'string') { - checkForPrimitiveColors(arg, arg.value); - } - if (arg.type === 'TemplateLiteral') { - arg.quasis.forEach(quasi => { - checkForPrimitiveColors(arg, quasi.value.raw); - }); - } - }); - } - }, - }; - }, -}; diff --git a/eslint-rules/no-primitive-color-scales.test.cjs b/eslint-rules/no-primitive-color-scales.test.cjs deleted file mode 100644 index 835f564d6..000000000 --- a/eslint-rules/no-primitive-color-scales.test.cjs +++ /dev/null @@ -1,230 +0,0 @@ -/** - * @fileoverview Tests for no-primitive-color-scales rule - * @author Lab Team - * - * Run with: node eslint-rules/no-primitive-color-scales.test.cjs - */ - -const rule = require('./no-primitive-color-scales.cjs'); -const { RuleTester } = require('eslint'); - -const ruleTester = new RuleTester({ - languageOptions: { - ecmaVersion: 2021, - sourceType: 'module', - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, -}); - -ruleTester.run('no-primitive-color-scales', rule, { - valid: [ - // Semantic tokens (brand) - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - - // Semantic tokens (surface) - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - - // Semantic tokens (state) - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - - // Data viz tokens (allowed) - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - { code: '
Text
' }, - - // clsx with semantic tokens - { code: 'clsx("bg-primary", "text-foreground")' }, - { code: 'cn("bg-success", isActive && "text-danger")' }, - ], - - invalid: [ - // Terracotta scale - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'terracotta', match: 'bg-terracotta-500' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'terracotta', match: 'text-terracotta-300' }, - }, - ], - }, - - // Sand scale - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'sand', match: 'bg-sand-50' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'sand', match: 'text-sand-600' }, - }, - ], - }, - - // Neutral scale - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'neutral', match: 'bg-neutral-700' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'neutral', match: 'border-neutral-500' }, - }, - ], - }, - - // Aurora scales - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'aurora-cyan', match: 'bg-aurora-cyan-400' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'aurora-purple', match: 'bg-aurora-purple-500' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'aurora-pink', match: 'text-aurora-pink-300' }, - }, - ], - }, - - // With opacity - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'terracotta', match: 'bg-terracotta-500/50' }, - }, - ], - }, - - // Multiple violations - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'terracotta', match: 'bg-terracotta-500' }, - }, - { - messageId: 'primitiveColorScale', - data: { scale: 'sand', match: 'text-sand-600' }, - }, - { - messageId: 'primitiveColorScale', - data: { scale: 'neutral', match: 'border-neutral-700' }, - }, - ], - }, - - // clsx with violations - { - code: 'clsx("bg-terracotta-500", "text-primary")', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'terracotta', match: 'bg-terracotta-500' }, - }, - ], - }, - - // cn with violations - { - code: 'cn("bg-sand-100 text-neutral-900")', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'sand', match: 'bg-sand-100' }, - }, - { - messageId: 'primitiveColorScale', - data: { scale: 'neutral', match: 'text-neutral-900' }, - }, - ], - }, - - // Various Tailwind utilities - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'terracotta', match: 'from-terracotta-500' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'sand', match: 'via-sand-400' }, - }, - ], - }, - { - code: '
Text
', - errors: [ - { - messageId: 'primitiveColorScale', - data: { scale: 'neutral', match: 'to-neutral-600' }, - }, - ], - }, - ], -}); - -console.log('✅ All no-primitive-color-scales tests passed!'); diff --git a/eslint-rules/validate-route-images.cjs b/eslint-rules/validate-route-images.cjs index cf8f26975..5e3174a01 100644 --- a/eslint-rules/validate-route-images.cjs +++ b/eslint-rules/validate-route-images.cjs @@ -33,7 +33,7 @@ module.exports = { }, create(context) { - const filename = context.getFilename(); + const filename = context.filename; // Only check files in src/routes directory if (!filename.includes('/src/routes/')) { diff --git a/eslint.config.js b/eslint.config.js index 30af8dbed..7b6c6ef3c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,65 +1,88 @@ -// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from 'eslint-plugin-storybook'; - +import { defineConfig } from 'eslint/config'; import js from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -import reactPlugin from 'eslint-plugin-react'; -import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import eslintReact from '@eslint-react/eslint-plugin'; +import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import prettier from 'eslint-plugin-prettier/recommended'; -import { fixupPluginRules } from '@eslint/compat'; -import betterTailwindcss from 'eslint-plugin-better-tailwindcss'; +import vitest from '@vitest/eslint-plugin'; +import storybook from 'eslint-plugin-storybook'; +import betterTailwind from 'eslint-plugin-better-tailwindcss'; +import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'; import customRules from './eslint-rules/index.cjs'; -export default tseslint.config( +const noColor = 'Colours come from tokens in src/styles/tokens.css, used as Tailwind classes.'; +const noFixedStyle = 'A fixed value is a Tailwind class; style only carries values computed at runtime.'; +// A colour literal: hex, rgb() or hsl(). +const colorLiteral = '^(?:#[0-9a-fA-F]{3,8}|(?:rgb|hsl)a?\\(.*\\))$'; +const colorAttribute = '^(?:fill|stroke|color|stopColor|floodColor|lightingColor)$'; +const styleValue = "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property"; +// An opacity modifier (`bg-primary/10`). `w-1/2` is a fraction, and `text-sm/6` gives a type +// size a line height from the spacing step. +const modifier = '(?:^|:)!?(?!text-(?:2xs|xs|sm|base|lg|\\d?xl)/)[a-z-]*[a-z]/\\d+!?$'; +const noParentImports = { group: ['../*'], message: 'Import through the @/ alias instead of parent-relative paths.' }; + +export default defineConfig( { + // src/api and src/lab-api are generated from the CBT and lab-backend OpenAPI specs (pnpm generate:api). ignores: [ 'dist', 'node_modules', 'coverage', - 'eslint_report.json', - 'src/api', 'storybook-static', 'lab', 'eslint-rules', + 'src/api', + 'src/lab-api', + 'src/routeTree.gen.ts', ], }, + { + // A suppression only stays honest if a dead one fails the build. + linterOptions: { + reportUnusedDisableDirectives: 'error', + }, + }, js.configs.recommended, ...tseslint.configs.recommended, prettier, + { + files: ['**/*.{ts,tsx}'], + ...eslintReact.configs['recommended-typescript'], + }, { files: ['**/*.{ts,tsx}'], languageOptions: { globals: globals.browser, }, plugins: { - react: reactPlugin, - 'react-hooks': reactHooksPlugin, + 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { - ...reactPlugin.configs.recommended.rules, - ...reactPlugin.configs['jsx-runtime'].rules, + // eslint-plugin-react-hooks owns hooks correctness; @eslint-react's + // overlapping rules are off so nothing reports twice. + '@eslint-react/rules-of-hooks': 'off', + '@eslint-react/exhaustive-deps': 'off', 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/exhaustive-deps': 'error', + // React Compiler static analysis. The compiler is not enabled in the + // build; these rules are a lint-time guard only. + 'react-hooks/preserve-manual-memoization': 'error', + 'react-hooks/immutability': 'error', + 'react-hooks/refs': 'error', + 'react-hooks/set-state-in-effect': 'error', + 'react-hooks/set-state-in-render': 'error', + 'react-hooks/purity': 'error', + 'react-hooks/static-components': 'error', + 'react-hooks/use-memo': 'error', + 'react-hooks/globals': 'error', + 'react-hooks/error-boundaries': 'error', + 'react-hooks/no-deriving-state-in-effects': 'error', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'TSAnyKeyword', - message: - 'The `any` type defeats the purpose of TypeScript. Use proper types instead or `unknown` if the type is truly unknown.', - }, - ], '@typescript-eslint/explicit-function-return-type': [ 'warn', { @@ -69,67 +92,160 @@ export default tseslint.config( allowDirectConstAssertionInArrowFunctions: true, }, ], - }, - settings: { - react: { - version: 'detect', - }, + 'no-restricted-imports': ['error', { patterns: [noParentImports] }], + 'no-restricted-syntax': [ + 'error', + // A value written into an inline style is a class instead; style keeps what is computed at runtime. + { + selector: `${styleValue} > :matches(Literal, TemplateLiteral[expressions.length=0], UnaryExpression[argument.type='Literal']).value`, + message: noFixedStyle, + }, + { + selector: `${styleValue} > ConditionalExpression.value:matches([consequent.type='Literal'][alternate.type='Literal'], [consequent.type='Literal'][alternate.name='undefined'], [consequent.name='undefined'][alternate.type='Literal'])`, + message: noFixedStyle, + }, + // An element's SVG colour attributes take currentColor, none or a url() paint. Components keep colour + // props (Badge's color="green" names a variant) but never a colour literal. + { + selector: `JSXOpeningElement[name.name=/^[a-z]/] > JSXAttribute[name.name=/${colorAttribute}/] > Literal[value!=/^(?:currentColor|none|url\\(.*\\))$/i]`, + message: noColor, + }, + { + selector: `JSXOpeningElement:not([name.name=/^[a-z]/]) > JSXAttribute[name.name=/${colorAttribute}/] > Literal[value=/${colorLiteral}/]`, + message: noColor, + }, + ], }, }, - // Tailwind CSS conflict detection (Prettier handles sorting) { - plugins: { - 'better-tailwindcss': fixupPluginRules(betterTailwindcss), - }, - rules: { - // Correctness rules only - no styling/sorting rules - 'better-tailwindcss/no-conflicting-classes': 'warn', // Less expensive than error - 'better-tailwindcss/no-duplicate-classes': 'warn', - // Disabled: Custom component classes in @layer components are intentional - 'better-tailwindcss/no-unknown-classes': 'off', - 'better-tailwindcss/no-deprecated-classes': 'off', // Disable expensive rule - Tailwind 4 is stable - // Disable all stylistic rules (Prettier handles these) - 'better-tailwindcss/enforce-consistent-class-order': 'off', - 'better-tailwindcss/enforce-consistent-important-position': 'off', - 'better-tailwindcss/enforce-consistent-line-wrapping': 'off', - 'better-tailwindcss/enforce-consistent-variable-syntax': 'off', - 'better-tailwindcss/enforce-shorthand-classes': 'off', - 'better-tailwindcss/no-unnecessary-whitespace': 'off', - }, + // Class names must be tokens: Tailwind's defaults are reset in src/styles/tokens.css, so + // an unknown class is one no token covers. Besides className, clsx and the plugin's other + // defaults, it reads class constants and variant maps (`…ClassName`, `…Classes`, + // `…Styles`), props named `…ClassName`, helpers named `get…Class` or `get…Styles` written + // as arrow functions, and the router's activeProps / inactiveProps. + files: ['src/**/*.{ts,tsx}'], + // Tests pass placeholder class names to check that props reach the DOM. + ignores: ['src/**/*.test.{ts,tsx}'], + extends: [betterTailwind.configs['correctness-error']], settings: { 'better-tailwindcss': { - // Point to Tailwind v4 CSS-based config entryPoint: 'src/index.css', - // Detect classes in these utility functions - callees: ['classnames', 'clsx', 'cn', 'cva'], + selectors: [ + ...getDefaultSelectors(), + { + kind: 'variable', + name: '^[a-z][A-Za-z]*(?:Class|Classes|ClassName|Styles)$', + match: [{ type: 'strings' }, { type: 'objectValues' }], + }, + { kind: 'attribute', name: '^[a-z][A-Za-z]*ClassName$', match: [{ type: 'strings' }] }, + { + kind: 'variable', + name: '^get[A-Z][A-Za-z]*(?:Class|Classes|ClassName|Styles)$', + match: [{ type: 'anonymousFunctionReturn', match: [{ type: 'strings' }] }], + }, + { + kind: 'attribute', + name: '^(?:active|inactive)Props$', + match: [{ type: 'objectValues', path: '^className$' }], + }, + ], }, }, + rules: { + 'better-tailwindcss/enforce-canonical-classes': 'error', + // dark and star mark the theme on ; a story may set one to preview it. + 'better-tailwindcss/no-unknown-classes': ['error', { ignore: ['^dark$', '^star$'] }], + 'better-tailwindcss/no-restricted-classes': [ + 'error', + { + restrict: [ + { pattern: '\\[[^\\[\\]]*\\](?!:)', message: 'Arbitrary values bypass the tokens; add a token instead.' }, + { pattern: '\\(--[^()]*\\)', message: 'Arbitrary variables bypass the tokens; add a token instead.' }, + { + pattern: '(?:^|:)dark:', + message: 'Tokens change with the theme; give the colour a light-dark() pair instead of a dark: variant.', + }, + { + pattern: + '(?:^|:)on-dark:!?(?:bg|text|border|ring|inset-ring|outline|fill|stroke|from|via|to|shadow|divide|decoration|accent|caret|placeholder)-', + message: 'on-dark: is for what a token cannot carry, such as images; colours are light-dark() tokens.', + }, + { pattern: '(?:^|:)!?leading-', message: 'Line height comes with each text size; text-sm/6 sets another.' }, + { + pattern: modifier, + message: 'Opacity modifiers bypass the tokens; use a tint token (bg-primary-10) or add one.', + }, + ], + }, + ], + }, }, - // Custom color theming rules { - files: ['**/*.{ts,tsx}'], // Only TSX/TS files have JSX and className + files: ['**/*.{ts,tsx}'], plugins: { lab: customRules, }, rules: { - // Ban hardcoded colors (hex, rgb, hsl) in Tailwind classes - 'lab/no-hardcoded-colors': 'error', - // Ban primitive color scales (terracotta-*, sand-*, neutral-*, aurora-*) - 'lab/no-primitive-color-scales': 'error', - // Validate route images exist 'lab/validate-route-images': 'error', }, }, - // TanStack Router route files export both components and Route objects { - files: ['src/routes/**/*.{ts,tsx}'], + // The Foundations blocks draw swatches in the colours they document. + files: ['src/design-docs/**/*.tsx'], + rules: { 'no-restricted-syntax': 'off' }, + }, + { + // Loading placeholders are identical and never reorder, so their index is their identity. + files: ['src/**/*Skeleton.tsx'], + rules: { '@eslint-react/no-array-index-key': 'off' }, + }, + { + // Tests and stories see the same data on every run (src/test-utils/time.ts and random.ts). + files: ['src/**/*.test.{ts,tsx}', 'src/**/*.stories.tsx'], + rules: { + 'no-restricted-properties': [ + 'error', + { object: 'Date', property: 'now', message: 'Take the time from NOW_MS in @/test-utils/time.' }, + { object: 'Math', property: 'random', message: 'Use seededRandom from @/test-utils/random.' }, + ], + }, + }, + { + files: ['src/**/*.test.{ts,tsx}'], + plugins: { vitest }, + rules: { + 'vitest/no-focused-tests': 'error', + 'vitest/no-disabled-tests': 'warn', + 'no-restricted-imports': [ + 'error', + { + paths: [{ name: '@testing-library/react', message: 'Import Testing Library helpers from @/test-utils.' }], + patterns: [noParentImports], + }, + ], + }, + }, + { + // The design-sync bundle resolves modules through its own path map (.design-sync/tsconfig.paths.json). + files: ['.design-sync/**'], + rules: { 'no-restricted-imports': 'off' }, + }, + { + // Route files export the Route object; Storybook config is not hot-reloaded. + files: ['src/routes/**/*.{ts,tsx}', '.storybook/**/*.{ts,tsx}'], rules: { 'react-refresh/only-export-components': 'off', }, }, + { + files: ['*.config.{js,ts}', 'knip.ts', 'vite-plugins/**/*.ts', '.storybook/*.ts'], + languageOptions: { + globals: globals.node, + }, + }, storybook.configs['flat/recommended'], { - files: ['**/.storybook/**/*.{js,ts}'], + files: ['.storybook/**/*.{js,ts}'], rules: { 'storybook/no-uninstalled-addons': [ 'error', diff --git a/index.html b/index.html index d575f3d8e..3c4806048 100644 --- a/index.html +++ b/index.html @@ -5,27 +5,11 @@ - + diff --git a/src/components/Ethereum/BlobCountChart/BlobCountChart.stories.tsx b/src/components/Ethereum/BlobCountChart/BlobCountChart.stories.tsx index 78a47939f..45017d844 100644 --- a/src/components/Ethereum/BlobCountChart/BlobCountChart.stories.tsx +++ b/src/components/Ethereum/BlobCountChart/BlobCountChart.stories.tsx @@ -1,5 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BlobCountChart } from './BlobCountChart'; +import { seededRandom } from '@/test-utils/random'; +import { NOW_MS } from '@/test-utils/time'; + +const random = seededRandom(18); const meta = { title: 'Components/Ethereum/BlobCountChart', @@ -10,7 +14,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -27,7 +31,7 @@ export const SlotGranularity: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - value: Math.floor(Math.random() * 7), // 0-6 blobs per slot + value: Math.floor(random() * 7), // 0-6 blobs per slot })), xAxis: { name: 'Slot', @@ -44,7 +48,7 @@ export const EpochGranularity: Story = { args: { data: Array.from({ length: 100 }, (_, i) => ({ x: 300000 + i, - value: Math.floor(Math.random() * 100 + 50), // 50-150 blobs per epoch + value: Math.floor(random() * 100 + 50), // 50-150 blobs per epoch })), xAxis: { name: 'Epoch', @@ -59,8 +63,8 @@ export const EpochGranularity: Story = { export const TimestampGranularity: Story = { args: { data: Array.from({ length: 30 }, (_, i) => ({ - x: Date.now() / 1000 - (30 - i) * 86400, // Last 30 days - value: Math.floor(Math.random() * 50 + 100), // 100-150 avg blobs + x: NOW_MS / 1000 - (30 - i) * 86400, // Last 30 days + value: Math.floor(random() * 50 + 100), // 100-150 avg blobs })), xAxis: { name: 'Date', @@ -81,7 +85,7 @@ export const CustomLabels: Story = { args: { data: Array.from({ length: 50 }, (_, i) => ({ x: 20000000 + i, - value: Math.floor(Math.random() * 6), + value: Math.floor(random() * 6), })), xAxis: { name: 'Block Number', @@ -98,7 +102,7 @@ export const SparseData: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - value: i % 5 === 0 ? Math.floor(Math.random() * 6) : 0, // Blobs only every 5th slot + value: i % 5 === 0 ? Math.floor(random() * 6) : 0, // Blobs only every 5th slot })), xAxis: { name: 'Slot', @@ -115,7 +119,7 @@ export const MaxBlobs: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - value: Math.random() > 0.2 ? 6 : Math.floor(Math.random() * 6), // Mostly 6 blobs + value: random() > 0.2 ? 6 : Math.floor(random() * 6), // Mostly 6 blobs })), xAxis: { name: 'Slot', diff --git a/src/components/Ethereum/BlobCountChart/BlobCountChart.types.ts b/src/components/Ethereum/BlobCountChart/BlobCountChart.types.ts index c340510f6..a7aa9a9d0 100644 --- a/src/components/Ethereum/BlobCountChart/BlobCountChart.types.ts +++ b/src/components/Ethereum/BlobCountChart/BlobCountChart.types.ts @@ -1,7 +1,7 @@ /** * Data point for blob count timeseries */ -export interface BlobCountDataPoint { +interface BlobCountDataPoint { /** X-axis value (slot, epoch, timestamp, etc.) */ x: number; /** Number of blobs */ @@ -16,7 +16,7 @@ export interface BlobCountDataPoint { /** * X-axis configuration for BlobCountChart */ -export interface BlobCountXAxisConfig { +interface BlobCountXAxisConfig { /** Axis label (e.g., "Slot", "Epoch", "Block Number") */ name: string; /** Optional minimum value */ diff --git a/src/components/Ethereum/BlobCountChart/index.ts b/src/components/Ethereum/BlobCountChart/index.ts index 655976c8f..bb4dda403 100644 --- a/src/components/Ethereum/BlobCountChart/index.ts +++ b/src/components/Ethereum/BlobCountChart/index.ts @@ -1,2 +1 @@ export { BlobCountChart } from './BlobCountChart'; -export type { BlobCountChartProps, BlobCountDataPoint, BlobCountXAxisConfig } from './BlobCountChart.types'; diff --git a/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.stories.tsx b/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.stories.tsx index 186074141..167ca6a43 100644 --- a/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.stories.tsx +++ b/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.stories.tsx @@ -11,7 +11,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), diff --git a/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.types.ts b/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.types.ts index 42e2b29c0..93dfddfff 100644 --- a/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.types.ts +++ b/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.types.ts @@ -168,5 +168,3 @@ export const BLOB_POSTERS = [ 'zksync-gateway', 'zora', ] as const; - -export type BlobPoster = (typeof BLOB_POSTERS)[number]; diff --git a/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.utils.ts b/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.utils.ts deleted file mode 100644 index d902b83e7..000000000 --- a/src/components/Ethereum/BlobPosterLogo/BlobPosterLogo.utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Short abbreviations for blob poster names (2-4 characters) - */ -const SHORT_NAME_MAP: Record = { - abstract: 'ABS', - aevo: 'AEVO', - arbitrum: 'ARB', - 'arbitrum one': 'ARB', - 'arena-z': 'ARNZ', - aztec: 'AZT', - base: 'BASE', - binary: 'BIN', - blast: 'BLST', - bob: 'BOB', - 'bob network': 'BOB', - boba: 'BOBA', - 'boba network': 'BOBA', - codex: 'CDX', - debank: 'DBK', - 'debank chain': 'DBK', - ethernity: 'ERN', - forknet: 'FORK', - fuel: 'FUEL', - hashkey: 'HKEY', - hemi: 'HEMI', - ink: 'INK', - jovay: 'JVY', - katana: 'KTN', - kinto: 'KTO', - kroma: 'KROM', - lachain: 'LA', - lambda: 'LMDA', - lighter: 'LGHT', - linea: 'LNA', - lisk: 'LSK', - lumio: 'LUM', - mantle: 'MNT', - metal: 'MTL', - metis: 'MTS', - mint: 'MINT', - mode: 'MODE', - morph: 'MRPH', - nanon: 'NAN', - 'nanon network': 'NAN', - 'op mainnet': 'OP', - optimism: 'OP', - opbnb: 'OPNB', - optopia: 'OPTA', - orderly: 'ORDR', - phala: 'PHA', - 'phala network': 'PHA', - pk910: 'PK', - polynomial: 'POLY', - r0ar: 'R0AR', - 'r0ar chain': 'R0AR', - race: 'RACE', - redstone: 'RSTM', - scroll: 'SCRL', - shape: 'SHPE', - snaxchain: 'SNAX', - soneium: 'SONM', - soon: 'SOON', - spire: 'SPIR', - 'spire da builder': 'SPIR', - superseed: 'SSED', - swan: 'SWAN', - 'swan chain': 'SWAN', - swell: 'SWEL', - taiko: 'TAKO', - unichain: 'UNI', - world: 'WRLD', - 'world chain': 'WRLD', - xterio: 'XTER', - zero: 'ZERO', - 'zero network': 'ZERO', - 'zerϴ network': 'ZERO', - zircuit: 'ZIRC', - zksync: 'ZKS', - 'zksync era': 'ZKS', - 'zksync gateway': 'ZKGW', - zora: 'ZORA', - unknown: 'UNKNOWN', -}; - -/** - * Get short abbreviation for a blob poster name - */ -export function getBlobPosterShortName(posterName: string): string { - const normalized = posterName.toLowerCase().trim(); - return SHORT_NAME_MAP[normalized] ?? posterName.slice(0, 4).toUpperCase(); -} diff --git a/src/components/Ethereum/BlobPosterLogo/index.ts b/src/components/Ethereum/BlobPosterLogo/index.ts index 4b350684e..8a0307161 100644 --- a/src/components/Ethereum/BlobPosterLogo/index.ts +++ b/src/components/Ethereum/BlobPosterLogo/index.ts @@ -1,4 +1,2 @@ export { BlobPosterLogo } from './BlobPosterLogo'; -export { getBlobPosterShortName } from './BlobPosterLogo.utils'; -export type { BlobPosterLogoProps, BlobPoster } from './BlobPosterLogo.types'; export { BLOB_POSTERS, BLOB_POSTER_DISPLAY_NAMES } from './BlobPosterLogo.types'; diff --git a/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.stories.tsx b/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.stories.tsx index a30426969..0e5add0b4 100644 --- a/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.stories.tsx +++ b/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.stories.tsx @@ -1,5 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BlockArrivalTimesChart } from './BlockArrivalTimesChart'; +import { seededRandom } from '@/test-utils/random'; + +const random = seededRandom(19); const meta = { title: 'Components/Ethereum/BlockArrivalTimesChart', @@ -9,7 +12,7 @@ const meta = { }, decorators: [ Story => ( -
+
), @@ -33,7 +36,7 @@ const generateSampleData = (): Array<{ return Array.from({ length: 32 }, (_, i) => { const slot = 10000000 + i; // Simulate block arrival times in milliseconds (most arrive early, some late) - const baseTime = 500 + Math.random() * 1000; // 0.5-1.5 seconds base + const baseTime = 500 + random() * 1000; // 0.5-1.5 seconds base return { x: slot, min: Math.max(100, baseTime - 400), @@ -75,7 +78,7 @@ export const HighVariation: Story = { data: Array.from({ length: 32 }, (_, i) => { const slot = 10000000 + i; // Simulate higher variation - const baseTime = 800 + Math.random() * 2000; + const baseTime = 800 + random() * 2000; return { x: slot, min: Math.max(100, baseTime - 700), @@ -108,7 +111,7 @@ export const MissedBlocks: Story = { max: null, }; } - const baseTime = 500 + Math.random() * 1000; + const baseTime = 500 + random() * 1000; return { x: slot, min: Math.max(100, baseTime - 400), diff --git a/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.tsx b/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.tsx index 04553192a..588d95a73 100644 --- a/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.tsx +++ b/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { MultiLineChart } from '@/components/Charts/MultiLine'; import { PopoutCard } from '@/components/Layout/PopoutCard'; import { getDataVizColors } from '@/utils/dataVizColors'; -import { DEFAULT_BEACON_SLOT_PHASES } from '@/utils/beacon'; +import { ATTESTATION_DEADLINE_MS } from '@/utils/beacon'; import type { BlockArrivalTimesChartProps } from './BlockArrivalTimesChart.types'; @@ -49,8 +49,8 @@ export function BlockArrivalTimesChart({ }: BlockArrivalTimesChartProps): React.JSX.Element { const { CHART_CATEGORICAL_COLORS, DATA_COLORS } = getDataVizColors(); - // Calculate attestation deadline from slot phases (end of Block phase) - const attestationDeadlineSeconds = DEFAULT_BEACON_SLOT_PHASES[0].duration / 1000; + // Attestation deadline (end of Block phase) + const attestationDeadlineSeconds = ATTESTATION_DEADLINE_MS / 1000; const { series, minX, maxY } = useMemo(() => { if (data.length === 0) { diff --git a/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.types.ts b/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.types.ts index e28980bcf..514a65ced 100644 --- a/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.types.ts +++ b/src/components/Ethereum/BlockArrivalTimesChart/BlockArrivalTimesChart.types.ts @@ -3,7 +3,7 @@ import type { PopoutCardProps } from '@/components/Layout/PopoutCard'; /** * Data point for block arrival times chart */ -export interface BlockArrivalDataPoint { +interface BlockArrivalDataPoint { /** X-axis value (typically slot number) */ x: number; /** Minimum arrival time (ms from slot start) */ @@ -26,7 +26,7 @@ export interface BlockArrivalDataPoint { /** * X-axis configuration */ -export interface XAxisConfig { +interface XAxisConfig { /** X-axis name */ name: string; /** Minimum x value */ diff --git a/src/components/Ethereum/BlockArrivalTimesChart/index.ts b/src/components/Ethereum/BlockArrivalTimesChart/index.ts index fce359a28..c081395fe 100644 --- a/src/components/Ethereum/BlockArrivalTimesChart/index.ts +++ b/src/components/Ethereum/BlockArrivalTimesChart/index.ts @@ -1,2 +1 @@ export { BlockArrivalTimesChart } from './BlockArrivalTimesChart'; -export type { BlockArrivalTimesChartProps, BlockArrivalDataPoint } from './BlockArrivalTimesChart.types'; diff --git a/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.tsx b/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.tsx index 96538faeb..4174bffd2 100644 --- a/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.tsx +++ b/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.tsx @@ -64,7 +64,7 @@ export function BlockExplorerLink({ if (!url) return null; const getIcon = (): React.JSX.Element => { - const iconClassName = 'h-5 w-5'; + const iconClassName = 'size-5'; switch (type) { case 'beaconchain': return ; diff --git a/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.types.ts b/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.types.ts index 96f36c486..40425486f 100644 --- a/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.types.ts +++ b/src/components/Ethereum/BlockExplorerLink/BlockExplorerLink.types.ts @@ -1,4 +1,4 @@ -export type BlockExplorerType = 'beaconchain' | 'etherscan' | 'dora' | 'tracoor'; +type BlockExplorerType = 'beaconchain' | 'etherscan' | 'dora' | 'tracoor'; export interface BlockExplorerLinkProps { type: BlockExplorerType; diff --git a/src/components/Ethereum/BlockExplorerLink/index.ts b/src/components/Ethereum/BlockExplorerLink/index.ts index 4f76af435..d8de432ee 100644 --- a/src/components/Ethereum/BlockExplorerLink/index.ts +++ b/src/components/Ethereum/BlockExplorerLink/index.ts @@ -1,2 +1 @@ export { BlockExplorerLink } from './BlockExplorerLink'; -export type { BlockExplorerLinkProps, BlockExplorerType } from './BlockExplorerLink.types'; diff --git a/src/components/Ethereum/BlockSizeChart/BlockSizeChart.stories.tsx b/src/components/Ethereum/BlockSizeChart/BlockSizeChart.stories.tsx index b4b802a38..81f2ebc27 100644 --- a/src/components/Ethereum/BlockSizeChart/BlockSizeChart.stories.tsx +++ b/src/components/Ethereum/BlockSizeChart/BlockSizeChart.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BlockSizeChart } from './BlockSizeChart'; +import { seededRandom } from '@/test-utils/random'; +import { NOW_MS } from '@/test-utils/time'; + +const random = seededRandom(20); const meta = { title: 'Components/Ethereum/BlockSizeChart', @@ -11,7 +15,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -37,15 +41,15 @@ function generateBlockSizeData( executionSizeCompressed: number; }> { return Array.from({ length: count }, (_, i) => { - const clUncompressed = Math.floor(Math.random() * 100000 + 50000); // 50-150 KB in bytes - const elUncompressed = Math.floor(Math.random() * 60000 + 20000); // 20-80 KB in bytes + const clUncompressed = Math.floor(random() * 100000 + 50000); // 50-150 KB in bytes + const elUncompressed = Math.floor(random() * 60000 + 20000); // 20-80 KB in bytes return { x: startX + i, consensusSize: clUncompressed, - consensusSizeCompressed: Math.floor(clUncompressed * (0.6 + Math.random() * 0.1)), // 60-70% + consensusSizeCompressed: Math.floor(clUncompressed * (0.6 + random() * 0.1)), // 60-70% executionSize: elUncompressed, - executionSizeCompressed: Math.floor(elUncompressed * (0.6 + Math.random() * 0.1)), // 60-70% + executionSizeCompressed: Math.floor(elUncompressed * (0.6 + random() * 0.1)), // 60-70% }; }); } @@ -82,11 +86,11 @@ export const EpochGranularity: Story = { export const TimestampGranularity: Story = { args: { data: Array.from({ length: 30 }, (_, i) => { - const clUncompressed = Math.floor(Math.random() * 100000 + 50000); - const elUncompressed = Math.floor(Math.random() * 60000 + 20000); + const clUncompressed = Math.floor(random() * 100000 + 50000); + const elUncompressed = Math.floor(random() * 60000 + 20000); return { - x: Date.now() / 1000 - (30 - i) * 86400, // Last 30 days + x: NOW_MS / 1000 - (30 - i) * 86400, // Last 30 days consensusSize: clUncompressed, consensusSizeCompressed: Math.floor(clUncompressed * 0.65), executionSize: elUncompressed, @@ -125,8 +129,8 @@ export const CustomLabels: Story = { export const CLDominant: Story = { args: { data: Array.from({ length: 32 }, (_, i) => { - const clUncompressed = Math.floor(Math.random() * 50000 + 100000); // Large CL blocks - const elUncompressed = Math.floor(Math.random() * 20000 + 10000); // Small EL blocks + const clUncompressed = Math.floor(random() * 50000 + 100000); // Large CL blocks + const elUncompressed = Math.floor(random() * 20000 + 10000); // Small EL blocks return { x: 10000000 + i, @@ -150,8 +154,8 @@ export const CLDominant: Story = { export const ELDominant: Story = { args: { data: Array.from({ length: 32 }, (_, i) => { - const clUncompressed = Math.floor(Math.random() * 30000 + 40000); // Smaller CL blocks - const elUncompressed = Math.floor(Math.random() * 80000 + 60000); // Large EL blocks + const clUncompressed = Math.floor(random() * 30000 + 40000); // Smaller CL blocks + const elUncompressed = Math.floor(random() * 80000 + 60000); // Large EL blocks return { x: 10000000 + i, @@ -186,8 +190,8 @@ export const SparseData: Story = { }; } - const clUncompressed = Math.floor(Math.random() * 100000 + 50000); - const elUncompressed = Math.floor(Math.random() * 60000 + 20000); + const clUncompressed = Math.floor(random() * 100000 + 50000); + const elUncompressed = Math.floor(random() * 60000 + 20000); return { x: 10000000 + i, diff --git a/src/components/Ethereum/BlockSizeChart/BlockSizeChart.types.ts b/src/components/Ethereum/BlockSizeChart/BlockSizeChart.types.ts index ce3dd5afe..0380465a1 100644 --- a/src/components/Ethereum/BlockSizeChart/BlockSizeChart.types.ts +++ b/src/components/Ethereum/BlockSizeChart/BlockSizeChart.types.ts @@ -1,7 +1,7 @@ /** * Data point for block size timeseries */ -export interface BlockSizeDataPoint { +interface BlockSizeDataPoint { /** X-axis value (slot, epoch, timestamp, etc.) */ x: number; /** Consensus layer block size in bytes (uncompressed) */ @@ -22,7 +22,7 @@ export interface BlockSizeDataPoint { /** * X-axis configuration for BlockSizeChart */ -export interface BlockSizeXAxisConfig { +interface BlockSizeXAxisConfig { /** Axis label (e.g., "Slot", "Epoch", "Block Number") */ name: string; /** Optional minimum value */ diff --git a/src/components/Ethereum/BlockSizeChart/index.ts b/src/components/Ethereum/BlockSizeChart/index.ts index 2c60bff89..d0f2ec3b1 100644 --- a/src/components/Ethereum/BlockSizeChart/index.ts +++ b/src/components/Ethereum/BlockSizeChart/index.ts @@ -1,2 +1 @@ export { BlockSizeChart } from './BlockSizeChart'; -export type { BlockSizeChartProps, BlockSizeDataPoint, BlockSizeXAxisConfig } from './BlockSizeChart.types'; diff --git a/src/components/Ethereum/BlockValueChart/BlockValueChart.stories.tsx b/src/components/Ethereum/BlockValueChart/BlockValueChart.stories.tsx index 543297857..ef3445dfa 100644 --- a/src/components/Ethereum/BlockValueChart/BlockValueChart.stories.tsx +++ b/src/components/Ethereum/BlockValueChart/BlockValueChart.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BlockValueChart } from './BlockValueChart'; +import { seededRandom } from '@/test-utils/random'; +import { NOW_MS } from '@/test-utils/time'; + +const random = seededRandom(21); const meta = { title: 'Components/Ethereum/BlockValueChart', @@ -11,7 +15,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -30,7 +34,7 @@ export const SlotGranularity: Story = { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, // ~30% MEV slots with values between 0.01-0.5 ETH - value: Math.random() > 0.7 ? Math.random() * 0.49 + 0.01 : 0, + value: random() > 0.7 ? random() * 0.49 + 0.01 : 0, })), xAxis: { name: 'Slot', @@ -48,7 +52,7 @@ export const EpochGranularity: Story = { data: Array.from({ length: 100 }, (_, i) => ({ x: 300000 + i, // Average ~2-8 ETH total MEV per epoch - value: Math.random() * 6 + 2, + value: random() * 6 + 2, })), xAxis: { name: 'Epoch', @@ -65,7 +69,7 @@ export const BlockGranularity: Story = { data: Array.from({ length: 100 }, (_, i) => ({ x: 20000000 + i, // ~40% MEV blocks with values between 0.01-0.8 ETH - value: Math.random() > 0.6 ? Math.random() * 0.79 + 0.01 : 0, + value: random() > 0.6 ? random() * 0.79 + 0.01 : 0, })), xAxis: { name: 'Block Number', @@ -80,9 +84,9 @@ export const BlockGranularity: Story = { export const TimestampGranularity: Story = { args: { data: Array.from({ length: 30 }, (_, i) => ({ - x: Date.now() / 1000 - (30 - i) * 86400, // Last 30 days + x: NOW_MS / 1000 - (30 - i) * 86400, // Last 30 days // Daily MEV total: 50-200 ETH - value: Math.random() * 150 + 50, + value: random() * 150 + 50, })), xAxis: { name: 'Date', @@ -104,7 +108,7 @@ export const HighMEVPeriod: Story = { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, // 60% MEV slots with higher values (0.1-1.5 ETH) - value: Math.random() > 0.4 ? Math.random() * 1.4 + 0.1 : 0, + value: random() > 0.4 ? random() * 1.4 + 0.1 : 0, })), xAxis: { name: 'Slot', @@ -123,7 +127,7 @@ export const LowMEVPeriod: Story = { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, // 15% MEV slots with lower values (0.001-0.05 ETH) - value: Math.random() > 0.85 ? Math.random() * 0.049 + 0.001 : 0, + value: random() > 0.85 ? random() * 0.049 + 0.001 : 0, })), xAxis: { name: 'Slot', @@ -141,11 +145,11 @@ export const MajorMEVOpportunity: Story = { args: { data: Array.from({ length: 32 }, (_, i) => { // Slot 15 has a major MEV opportunity (5+ ETH) - if (i === 15) return { x: 10000000 + i, value: Math.random() * 3 + 5 }; + if (i === 15) return { x: 10000000 + i, value: random() * 3 + 5 }; // Other slots have normal MEV activity return { x: 10000000 + i, - value: Math.random() > 0.7 ? Math.random() * 0.3 + 0.01 : 0, + value: random() > 0.7 ? random() * 0.3 + 0.01 : 0, }; }), xAxis: { diff --git a/src/components/Ethereum/BlockValueChart/BlockValueChart.types.ts b/src/components/Ethereum/BlockValueChart/BlockValueChart.types.ts index f78335f13..63695783b 100644 --- a/src/components/Ethereum/BlockValueChart/BlockValueChart.types.ts +++ b/src/components/Ethereum/BlockValueChart/BlockValueChart.types.ts @@ -1,7 +1,7 @@ /** * Data point for block value timeseries */ -export interface BlockValueDataPoint { +interface BlockValueDataPoint { /** X-axis value (slot, epoch, block, timestamp, etc.) */ x: number; /** Block value in ETH */ @@ -16,7 +16,7 @@ export interface BlockValueDataPoint { /** * X-axis configuration for BlockValueChart */ -export interface BlockValueXAxisConfig { +interface BlockValueXAxisConfig { /** Axis label (e.g., "Slot", "Epoch", "Block Number") */ name: string; /** Optional minimum value */ diff --git a/src/components/Ethereum/BlockValueChart/index.ts b/src/components/Ethereum/BlockValueChart/index.ts index 4e1578cb0..142564697 100644 --- a/src/components/Ethereum/BlockValueChart/index.ts +++ b/src/components/Ethereum/BlockValueChart/index.ts @@ -1,2 +1 @@ export { BlockValueChart } from './BlockValueChart'; -export type { BlockValueChartProps, BlockValueDataPoint, BlockValueXAxisConfig } from './BlockValueChart.types'; diff --git a/src/components/Ethereum/ClientLabel/ClientLabel.stories.tsx b/src/components/Ethereum/ClientLabel/ClientLabel.stories.tsx index 6149e06a7..df0a71d99 100644 --- a/src/components/Ethereum/ClientLabel/ClientLabel.stories.tsx +++ b/src/components/Ethereum/ClientLabel/ClientLabel.stories.tsx @@ -10,7 +10,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), diff --git a/src/components/Ethereum/ClientLabel/index.ts b/src/components/Ethereum/ClientLabel/index.ts index 261ba55d1..bd9516470 100644 --- a/src/components/Ethereum/ClientLabel/index.ts +++ b/src/components/Ethereum/ClientLabel/index.ts @@ -1,2 +1 @@ export { ClientLabel } from './ClientLabel'; -export type { ClientLabelProps } from './ClientLabel'; diff --git a/src/components/Ethereum/ClientLogo/ClientLogo.stories.tsx b/src/components/Ethereum/ClientLogo/ClientLogo.stories.tsx index cad9f3e3c..045f88020 100644 --- a/src/components/Ethereum/ClientLogo/ClientLogo.stories.tsx +++ b/src/components/Ethereum/ClientLogo/ClientLogo.stories.tsx @@ -10,7 +10,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), diff --git a/src/components/Ethereum/ClientLogo/ClientLogo.tsx b/src/components/Ethereum/ClientLogo/ClientLogo.tsx index 87f03e41b..8c844b53f 100644 --- a/src/components/Ethereum/ClientLogo/ClientLogo.tsx +++ b/src/components/Ethereum/ClientLogo/ClientLogo.tsx @@ -20,7 +20,7 @@ export function ClientLogo({ if (hasError || client.toLowerCase() === 'unknown') { return (
@@ -36,7 +36,7 @@ export function ClientLogo({ alt={decorative ? '' : client} className={clsx( 'rounded-sm', - LIGHT_GLYPH_CLIENTS.has(normalizeClientName(client)) && 'invert dark:invert-0 star:invert-0', + LIGHT_GLYPH_CLIENTS.has(normalizeClientName(client)) && 'invert on-dark:invert-0', !linked && className )} style={{ width: `${size}px`, height: `${size}px` }} diff --git a/src/components/Ethereum/ClientLogo/index.ts b/src/components/Ethereum/ClientLogo/index.ts index 98740fcb4..45478e4bd 100644 --- a/src/components/Ethereum/ClientLogo/index.ts +++ b/src/components/Ethereum/ClientLogo/index.ts @@ -1,2 +1 @@ export { ClientLogo } from './ClientLogo'; -export type { ClientLogoProps } from './ClientLogo.types'; diff --git a/src/components/Ethereum/ClientSelect/ClientSelect.types.ts b/src/components/Ethereum/ClientSelect/ClientSelect.types.ts index bd30efc9d..e718f3c93 100644 --- a/src/components/Ethereum/ClientSelect/ClientSelect.types.ts +++ b/src/components/Ethereum/ClientSelect/ClientSelect.types.ts @@ -1,4 +1,4 @@ -export interface ClientOption { +interface ClientOption { /** * Client implementation name (e.g., "lighthouse", "prysm", "teku") */ diff --git a/src/components/Ethereum/ClientSelect/index.ts b/src/components/Ethereum/ClientSelect/index.ts deleted file mode 100644 index e97d66477..000000000 --- a/src/components/Ethereum/ClientSelect/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ClientSelect } from './ClientSelect'; -export type { ClientSelectProps, ClientOption } from './ClientSelect.types'; diff --git a/src/components/Ethereum/ClusterSpecsModal/ClusterSpecsModal.tsx b/src/components/Ethereum/ClusterSpecsModal/ClusterSpecsModal.tsx index 64d756914..8be373470 100644 --- a/src/components/Ethereum/ClusterSpecsModal/ClusterSpecsModal.tsx +++ b/src/components/Ethereum/ClusterSpecsModal/ClusterSpecsModal.tsx @@ -29,7 +29,7 @@ export function ClusterSpecsModal({ open, onClose, clusterName }: ClusterSpecsMo {cluster.name} cluster - EIP-7870 + EIP-7870 } size="md" diff --git a/src/components/Ethereum/ClusterSpecsModal/index.ts b/src/components/Ethereum/ClusterSpecsModal/index.ts index fa5fd33bc..f37fb9498 100644 --- a/src/components/Ethereum/ClusterSpecsModal/index.ts +++ b/src/components/Ethereum/ClusterSpecsModal/index.ts @@ -1,2 +1 @@ export { ClusterSpecsModal } from './ClusterSpecsModal'; -export type { ClusterSpecsModalProps } from './ClusterSpecsModal.types'; diff --git a/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.stories.tsx b/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.stories.tsx index 51f40e248..fa6e8b8ef 100644 --- a/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.stories.tsx +++ b/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.stories.tsx @@ -11,7 +11,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -38,7 +38,7 @@ export const Collapsed: Story = { export const WithCustomClass: Story = { args: { - className: 'border-primary/30', + className: 'border-primary-30', }, parameters: { docs: { diff --git a/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.tsx b/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.tsx index b7887235f..249971a49 100644 --- a/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.tsx +++ b/src/components/Ethereum/EIP7870SpecsBanner/EIP7870SpecsBanner.tsx @@ -51,7 +51,7 @@ export function EIP7870SpecsBanner({ className }: EIP7870SpecsBannerProps): JSX. {CLUSTER_SPECS.map(cluster => ( - +
@@ -60,18 +60,18 @@ export function EIP7870SpecsBanner({ className }: EIP7870SpecsBannerProps): JSX.
{cluster.cpu.model}
-
up to {cluster.cpu.maxFrequency}
+
up to {cluster.cpu.maxFrequency}
{cluster.cpu.cores}c / {cluster.cpu.threads}t
{cluster.cpu.passmarkSingle} Single
-
{cluster.cpu.passmarkMulti} Multi
+
{cluster.cpu.passmarkMulti} Multi
{cluster.memory.total}
-
+
{cluster.memory.type} @ {cluster.memory.speed}
@@ -79,14 +79,14 @@ export function EIP7870SpecsBanner({ className }: EIP7870SpecsBannerProps): JSX.
{cluster.storage.model} {cluster.storage.capacity}
-
{cluster.storage.interface}
+
{cluster.storage.interface}
))}
-
+

Reference Xatu nodes are controlled by ethPandaOps.

( -
+
), @@ -92,6 +92,9 @@ export const WithoutIcon: Story = { }; export const AllForks: Story = { + args: { + fork: 'phase0', + }, render: () => (
@@ -108,6 +111,9 @@ export const AllForks: Story = { }; export const AllSizes: Story = { + args: { + fork: 'deneb', + }, render: () => (
diff --git a/src/components/Ethereum/ForkLabel/ForkLabel.tsx b/src/components/Ethereum/ForkLabel/ForkLabel.tsx index e94adb585..830e80e36 100644 --- a/src/components/Ethereum/ForkLabel/ForkLabel.tsx +++ b/src/components/Ethereum/ForkLabel/ForkLabel.tsx @@ -33,7 +33,7 @@ export function ForkLabel({ version: fork, name: fork, emoji: '🔮', - color: 'bg-muted/10 text-muted', + color: 'bg-muted-10 text-muted', description: `Upcoming fork: ${fork}`, }; diff --git a/src/components/Ethereum/ForkLabel/index.ts b/src/components/Ethereum/ForkLabel/index.ts index 29c7a1124..222832af9 100644 --- a/src/components/Ethereum/ForkLabel/index.ts +++ b/src/components/Ethereum/ForkLabel/index.ts @@ -1,2 +1 @@ export { ForkLabel } from './ForkLabel'; -export type { ForkLabelProps } from './ForkLabel.types'; diff --git a/src/components/Ethereum/GasUsedChart/GasUsedChart.stories.tsx b/src/components/Ethereum/GasUsedChart/GasUsedChart.stories.tsx index 940737266..4470e4d16 100644 --- a/src/components/Ethereum/GasUsedChart/GasUsedChart.stories.tsx +++ b/src/components/Ethereum/GasUsedChart/GasUsedChart.stories.tsx @@ -1,5 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { GasUsedChart } from './GasUsedChart'; +import { seededRandom } from '@/test-utils/random'; +import { NOW_MS } from '@/test-utils/time'; + +const random = seededRandom(22); const meta = { title: 'Components/Ethereum/GasUsedChart', @@ -10,7 +14,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -27,7 +31,7 @@ export const WithGasLimit: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - gasUsed: Math.floor(Math.random() * 5000000 + 25000000), // 25-30M gas + gasUsed: Math.floor(random() * 5000000 + 25000000), // 25-30M gas gasLimit: 30000000, // 30M gas limit })), xAxis: { @@ -45,7 +49,7 @@ export const WithoutGasLimit: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - gasUsed: Math.floor(Math.random() * 5000000 + 25000000), // 25-30M gas + gasUsed: Math.floor(random() * 5000000 + 25000000), // 25-30M gas })), xAxis: { name: 'Slot', @@ -62,7 +66,7 @@ export const EpochGranularity: Story = { args: { data: Array.from({ length: 100 }, (_, i) => ({ x: 300000 + i, - gasUsed: Math.floor(Math.random() * 200000000 + 900000000), // 900M-1.1B gas per epoch + gasUsed: Math.floor(random() * 200000000 + 900000000), // 900M-1.1B gas per epoch })), xAxis: { name: 'Epoch', @@ -79,7 +83,7 @@ export const VaryingUtilization: Story = { data: Array.from({ length: 32 }, (_, i) => { const gasLimit = 30000000; // Simulate varying utilization (50%-100%) - const utilization = 0.5 + Math.random() * 0.5; + const utilization = 0.5 + random() * 0.5; return { x: 10000000 + i, gasUsed: Math.floor(gasLimit * utilization), @@ -103,7 +107,7 @@ export const HighUtilization: Story = { data: Array.from({ length: 32 }, (_, i) => { const gasLimit = 30000000; // 95%-100% utilization (congested) - const utilization = 0.95 + Math.random() * 0.05; + const utilization = 0.95 + random() * 0.05; return { x: 10000000 + i, gasUsed: Math.floor(gasLimit * utilization), @@ -127,7 +131,7 @@ export const LowUtilization: Story = { data: Array.from({ length: 32 }, (_, i) => { const gasLimit = 30000000; // 10%-30% utilization (quiet) - const utilization = 0.1 + Math.random() * 0.2; + const utilization = 0.1 + random() * 0.2; return { x: 10000000 + i, gasUsed: Math.floor(gasLimit * utilization), @@ -149,8 +153,8 @@ export const LowUtilization: Story = { export const TimestampGranularity: Story = { args: { data: Array.from({ length: 30 }, (_, i) => ({ - x: Date.now() / 1000 - (30 - i) * 86400, // Last 30 days - gasUsed: Math.floor(Math.random() * 100000000 + 950000000), // 950M-1.05B daily gas + x: NOW_MS / 1000 - (30 - i) * 86400, // Last 30 days + gasUsed: Math.floor(random() * 100000000 + 950000000), // 950M-1.05B daily gas })), xAxis: { name: 'Date', diff --git a/src/components/Ethereum/GasUsedChart/GasUsedChart.types.ts b/src/components/Ethereum/GasUsedChart/GasUsedChart.types.ts index f79d5c631..46d4530fd 100644 --- a/src/components/Ethereum/GasUsedChart/GasUsedChart.types.ts +++ b/src/components/Ethereum/GasUsedChart/GasUsedChart.types.ts @@ -1,7 +1,7 @@ /** * Data point for gas usage timeseries */ -export interface GasUsedDataPoint { +interface GasUsedDataPoint { /** X-axis value (slot, epoch, block, timestamp, etc.) */ x: number; /** Gas used */ @@ -18,7 +18,7 @@ export interface GasUsedDataPoint { /** * X-axis configuration for GasUsedChart */ -export interface GasUsedXAxisConfig { +interface GasUsedXAxisConfig { /** Axis label (e.g., "Slot", "Epoch", "Block Number") */ name: string; /** Optional minimum value */ diff --git a/src/components/Ethereum/GasUsedChart/index.ts b/src/components/Ethereum/GasUsedChart/index.ts index acb2f766b..91cb12da9 100644 --- a/src/components/Ethereum/GasUsedChart/index.ts +++ b/src/components/Ethereum/GasUsedChart/index.ts @@ -1,2 +1 @@ export { GasUsedChart } from './GasUsedChart'; -export type { GasUsedChartProps, GasUsedDataPoint, GasUsedXAxisConfig } from './GasUsedChart.types'; diff --git a/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.stories.tsx b/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.stories.tsx index 10ac83806..2216c8611 100644 --- a/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.stories.tsx +++ b/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.stories.tsx @@ -1,5 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { MevAdoptionChart } from './MevAdoptionChart'; +import { seededRandom } from '@/test-utils/random'; + +const random = seededRandom(23); const meta = { title: 'Components/Ethereum/MevAdoptionChart', @@ -10,7 +13,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -85,7 +88,7 @@ export const NoAdoption: Story = { export const LargeDataset: Story = { args: { data: Array.from({ length: 225 }, () => ({ - hasMev: Math.random() > 0.15, // ~85% MEV + hasMev: random() > 0.15, // ~85% MEV })), subtitle: 'MEV-boost usage over 7 epochs', }, @@ -97,7 +100,7 @@ export const LargeDataset: Story = { export const SmallDataset: Story = { args: { data: Array.from({ length: 32 }, () => ({ - hasMev: Math.random() > 0.25, // ~75% MEV + hasMev: random() > 0.25, // ~75% MEV })), }, }; diff --git a/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.types.ts b/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.types.ts index c9886643c..6d69ed3f9 100644 --- a/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.types.ts +++ b/src/components/Ethereum/MevAdoptionChart/MevAdoptionChart.types.ts @@ -1,7 +1,7 @@ /** * Data point for MEV adoption */ -export interface MevAdoptionDataPoint { +interface MevAdoptionDataPoint { /** Whether this data point used MEV-boost */ hasMev: boolean; } diff --git a/src/components/Ethereum/MevAdoptionChart/index.ts b/src/components/Ethereum/MevAdoptionChart/index.ts index 5f0da5f08..e74ccc572 100644 --- a/src/components/Ethereum/MevAdoptionChart/index.ts +++ b/src/components/Ethereum/MevAdoptionChart/index.ts @@ -1,2 +1 @@ export { MevAdoptionChart } from './MevAdoptionChart'; -export type { MevAdoptionChartProps, MevAdoptionDataPoint } from './MevAdoptionChart.types'; diff --git a/src/components/Ethereum/MevBuilderDistributionChart/MevBuilderDistributionChart.stories.tsx b/src/components/Ethereum/MevBuilderDistributionChart/MevBuilderDistributionChart.stories.tsx index 47dfe65d0..1be24a0d0 100644 --- a/src/components/Ethereum/MevBuilderDistributionChart/MevBuilderDistributionChart.stories.tsx +++ b/src/components/Ethereum/MevBuilderDistributionChart/MevBuilderDistributionChart.stories.tsx @@ -11,7 +11,7 @@ const meta: Meta = { }, decorators: [ Story => ( -
+
), diff --git a/src/components/Ethereum/MevBuilderDistributionChart/index.ts b/src/components/Ethereum/MevBuilderDistributionChart/index.ts index e450015ac..b3ed68b3f 100644 --- a/src/components/Ethereum/MevBuilderDistributionChart/index.ts +++ b/src/components/Ethereum/MevBuilderDistributionChart/index.ts @@ -1,2 +1 @@ export { MevBuilderDistributionChart } from './MevBuilderDistributionChart'; -export type { MevBuilderDistributionChartProps, MevBuilderDataPoint } from './MevBuilderDistributionChart.types'; diff --git a/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.stories.tsx b/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.stories.tsx index c4d5cac4b..b6f2d085e 100644 --- a/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.stories.tsx +++ b/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.stories.tsx @@ -1,5 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { MevRelayDistributionChart } from './MevRelayDistributionChart'; +import { seededRandom } from '@/test-utils/random'; + +const random = seededRandom(24); const meta = { title: 'Components/Ethereum/MevRelayDistributionChart', @@ -10,7 +13,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -58,7 +61,7 @@ export const Default: Story = { */ export const ManyRelays: Story = { args: { - data: RELAY_NAMES.flatMap(relay => Array.from({ length: Math.floor(Math.random() * 20) + 1 }, () => ({ relay }))), + data: RELAY_NAMES.flatMap(relay => Array.from({ length: Math.floor(random() * 20) + 1 }, () => ({ relay }))), countAxisName: 'Blocks', subtitle: 'Diverse relay ecosystem', }, @@ -69,7 +72,7 @@ export const ManyRelays: Story = { */ export const TopFive: Story = { args: { - data: RELAY_NAMES.flatMap(relay => Array.from({ length: Math.floor(Math.random() * 15) + 1 }, () => ({ relay }))), + data: RELAY_NAMES.flatMap(relay => Array.from({ length: Math.floor(random() * 15) + 1 }, () => ({ relay }))), topN: 5, countAxisName: 'Blocks', title: 'Top 5 Relays', @@ -190,7 +193,7 @@ export const TwoRelays: Story = { */ export const LargeDataset: Story = { args: { - data: RELAY_NAMES.flatMap(relay => Array.from({ length: Math.floor(Math.random() * 100) + 50 }, () => ({ relay }))), + data: RELAY_NAMES.flatMap(relay => Array.from({ length: Math.floor(random() * 100) + 50 }, () => ({ relay }))), topN: 10, countAxisName: 'Blocks', title: 'Multi-Epoch Analysis', diff --git a/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.types.ts b/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.types.ts index 9651b253f..49660877c 100644 --- a/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.types.ts +++ b/src/components/Ethereum/MevRelayDistributionChart/MevRelayDistributionChart.types.ts @@ -1,7 +1,7 @@ /** * Data point containing MEV relay information */ -export interface MevRelayDataPoint { +interface MevRelayDataPoint { /** Relay name/identifier */ relay: string | null; /** Optional additional context (e.g., slot, epoch, block number) */ diff --git a/src/components/Ethereum/MevRelayDistributionChart/index.ts b/src/components/Ethereum/MevRelayDistributionChart/index.ts index c423f4577..de7d8c2f8 100644 --- a/src/components/Ethereum/MevRelayDistributionChart/index.ts +++ b/src/components/Ethereum/MevRelayDistributionChart/index.ts @@ -1,2 +1 @@ export { MevRelayDistributionChart } from './MevRelayDistributionChart'; -export type { MevRelayDistributionChartProps, MevRelayDataPoint } from './MevRelayDistributionChart.types'; diff --git a/src/components/Ethereum/NetworkIcon/NetworkIcon.tsx b/src/components/Ethereum/NetworkIcon/NetworkIcon.tsx index 25240e9f0..cd286c505 100644 --- a/src/components/Ethereum/NetworkIcon/NetworkIcon.tsx +++ b/src/components/Ethereum/NetworkIcon/NetworkIcon.tsx @@ -45,19 +45,9 @@ export function NetworkIcon({ networkName, className }: NetworkIconProps): JSX.E return ( <> {/* Light mode */} - + {/* Dark/star mode */} - + ); } diff --git a/src/components/Ethereum/NetworkIcon/index.ts b/src/components/Ethereum/NetworkIcon/index.ts index e20c077d3..38df1efb0 100644 --- a/src/components/Ethereum/NetworkIcon/index.ts +++ b/src/components/Ethereum/NetworkIcon/index.ts @@ -1,2 +1 @@ export { NetworkIcon } from './NetworkIcon'; -export type { NetworkIconProps } from './NetworkIcon.types'; diff --git a/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx b/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx index 0b25715d9..d5f074ba3 100644 --- a/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx +++ b/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx @@ -56,8 +56,8 @@ export function NetworkSelect({ @@ -80,8 +80,8 @@ export function NetworkSelect({ anchor="bottom start" transition className={clsx( - 'z-[9999] mt-1 max-h-96 overflow-auto rounded-sm border border-border bg-surface pb-1 focus:outline-hidden data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0', - expandToFit ? 'w-max min-w-[var(--button-width)]' : 'w-[var(--button-width)]' + 'z-9999 mt-1 max-h-96 overflow-auto rounded-sm border border-border bg-surface pb-1 focus:outline-hidden data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0', + expandToFit ? 'w-max min-w-button' : 'w-button' )} > {groups.map((group, index) => ( @@ -89,11 +89,11 @@ export function NetworkSelect({ key={group.id} role="group" aria-label={group.label} - className={clsx(index > 0 && 'mt-1 border-t border-border/50')} + className={clsx(index > 0 && 'mt-1 border-t border-border-50')} > @@ -102,7 +102,7 @@ export function NetworkSelect({ key={network.name} value={network} title={getNetworkFullLabel(network)} - className="group relative flex cursor-pointer items-center gap-2 py-2 pr-9 pl-3 text-foreground transition-colors select-none data-focus:bg-primary/10 data-focus:text-primary data-selected:text-primary" + className="group relative flex cursor-pointer items-center gap-2 py-2 pr-9 pl-3 text-foreground transition-colors select-none data-focus:bg-primary-10 data-focus:text-primary data-selected:text-primary" > diff --git a/src/components/Ethereum/NetworkSelect/index.ts b/src/components/Ethereum/NetworkSelect/index.ts index 5067f8538..17de95044 100644 --- a/src/components/Ethereum/NetworkSelect/index.ts +++ b/src/components/Ethereum/NetworkSelect/index.ts @@ -1,2 +1 @@ export { NetworkSelect } from './NetworkSelect'; -export type { NetworkSelectProps } from './NetworkSelect.types'; diff --git a/src/components/Ethereum/Slot/index.ts b/src/components/Ethereum/Slot/index.ts index d5a7a788f..f521c503f 100644 --- a/src/components/Ethereum/Slot/index.ts +++ b/src/components/Ethereum/Slot/index.ts @@ -1,2 +1 @@ export { Slot } from './Slot'; -export type { SlotProps } from './Slot.types'; diff --git a/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.stories.tsx b/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.stories.tsx index c584bc40e..85137d71e 100644 --- a/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.stories.tsx +++ b/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.stories.tsx @@ -19,7 +19,7 @@ const meta = { }, decorators: [ Story => ( -
+
), @@ -313,13 +313,13 @@ export const LiveAnimated: Story = {
diff --git a/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.tsx b/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.tsx index dcf4dac45..6d95660b1 100644 --- a/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.tsx +++ b/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.tsx @@ -88,7 +88,7 @@ export function SlotProgressTimeline({ const nextPhaseTime = nextPhase?.timestamp; // Determine if this phase is completed - let isCompleted = false; + let isCompleted: boolean; if (phase.duration !== undefined) { // Has explicit duration isCompleted = currentTime >= phaseTime + phase.duration; @@ -160,8 +160,8 @@ export function SlotProgressTimeline({ {enrichedPhases.map((phase, index) => { const status = phase.isActive ? 'active' : phase.isCompleted ? 'completed' : 'pending'; const hasConnection = index < enrichedPhases.length - 1; - const isConnectionActive = - mode === 'live' && connectionProgress[index] > 0 && connectionProgress[index] < 100; + const progress = connectionProgress[index] ?? 0; + const isConnectionActive = mode === 'live' && progress > 0 && progress < 100; return ( @@ -178,11 +178,7 @@ export function SlotProgressTimeline({ {/* Connection to next phase */} {hasConnection && (
- +
)}
@@ -193,12 +189,12 @@ export function SlotProgressTimeline({ {/* Mobile: Vertical layout */}
-
+
{enrichedPhases.map((phase, index) => { const status = phase.isActive ? 'active' : phase.isCompleted ? 'completed' : 'pending'; const hasConnection = index < enrichedPhases.length - 1; - const isConnectionActive = - mode === 'live' && connectionProgress[index] > 0 && connectionProgress[index] < 100; + const progress = connectionProgress[index] ?? 0; + const isConnectionActive = mode === 'live' && progress > 0 && progress < 100; return (
@@ -213,11 +209,7 @@ export function SlotProgressTimeline({ {/* Connection to next phase */} {hasConnection && (
- +
)}
diff --git a/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.types.ts b/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.types.ts index 0f098a109..883401e16 100644 --- a/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.types.ts +++ b/src/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.types.ts @@ -29,7 +29,7 @@ export interface PhaseData { /** * Timeline mode configuration */ -export type TimelineMode = 'live' | 'static'; +type TimelineMode = 'live' | 'static'; /** * Props for the SlotProgressTimeline component @@ -78,17 +78,3 @@ export interface PhaseConnectionProps { /** Optional CSS class name */ className?: string; } - -/** - * Props for the TimelineAxis sub-component - */ -export interface TimelineAxisProps { - /** Layout orientation */ - orientation: 'horizontal' | 'vertical'; - /** Total duration in milliseconds (default: 12000) */ - totalDuration?: number; - /** Number of tick marks to show (default: 7 for 0s, 2s, 4s, 6s, 8s, 10s, 12s) */ - tickCount?: number; - /** Optional CSS class name */ - className?: string; -} diff --git a/src/components/Ethereum/SlotProgressTimeline/components/PhaseConnection/PhaseConnection.tsx b/src/components/Ethereum/SlotProgressTimeline/components/PhaseConnection/PhaseConnection.tsx index 585c4d2d6..1c3d74b6f 100644 --- a/src/components/Ethereum/SlotProgressTimeline/components/PhaseConnection/PhaseConnection.tsx +++ b/src/components/Ethereum/SlotProgressTimeline/components/PhaseConnection/PhaseConnection.tsx @@ -1,6 +1,6 @@ import type { JSX } from 'react'; import clsx from 'clsx'; -import type { PhaseConnectionProps } from '../../SlotProgressTimeline.types'; +import type { PhaseConnectionProps } from '@/components/Ethereum/SlotProgressTimeline/SlotProgressTimeline.types'; /** * PhaseConnection - Simple line between phases in the slot timeline @@ -16,7 +16,7 @@ export function PhaseConnection({ return (
{phase.id !== 'attesting' && phase.timestamp !== undefined @@ -77,10 +77,10 @@ export function PhaseNode({ phase, status, showStats = true, onClick, className {/* Stats - always rendered with fixed line height, hidden with opacity when empty */} - * - * // Vertical axis (typically hidden) - * - * ``` - */ -export function TimelineAxis({ - orientation, - totalDuration = 12000, - tickCount = 7, - className, -}: TimelineAxisProps): JSX.Element { - const isHorizontal = orientation === 'horizontal'; - - // Generate tick marks - const ticks = Array.from({ length: tickCount }, (_, i) => { - const timeMs = (i / (tickCount - 1)) * totalDuration; - const timeSec = timeMs / 1000; - const position = (i / (tickCount - 1)) * 100; - - return { - id: i, - label: `${timeSec.toFixed(0)}s`, - position, - }; - }); - - if (!isHorizontal) { - // Vertical axis is typically not shown, but we can render it if needed - return ( -
- {ticks.map(tick => ( -
-
- {tick.label} -
- ))} -
- ); - } - - // Horizontal axis - return ( -
- {/* Axis line */} -
- - {/* Tick marks */} -
- {ticks.map(tick => ( -
- {/* Tick mark */} -
- {/* Tick label */} - {tick.label} -
- ))} -
-
- ); -} diff --git a/src/components/Ethereum/SlotProgressTimeline/components/TimelineAxis/index.ts b/src/components/Ethereum/SlotProgressTimeline/components/TimelineAxis/index.ts deleted file mode 100644 index 611f43c80..000000000 --- a/src/components/Ethereum/SlotProgressTimeline/components/TimelineAxis/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TimelineAxis } from './TimelineAxis'; diff --git a/src/components/Ethereum/SlotProgressTimeline/index.ts b/src/components/Ethereum/SlotProgressTimeline/index.ts deleted file mode 100644 index ab8f7d5dc..000000000 --- a/src/components/Ethereum/SlotProgressTimeline/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { SlotProgressTimeline } from './SlotProgressTimeline'; -export type { - PhaseData, - TimelineMode, - SlotProgressTimelineProps, - PhaseNodeProps, - PhaseConnectionProps, - TimelineAxisProps, -} from './SlotProgressTimeline.types'; -export { PhaseNode } from './components/PhaseNode'; -export { PhaseConnection } from './components/PhaseConnection'; -export { TimelineAxis } from './components/TimelineAxis'; diff --git a/src/components/Ethereum/SlotTimeline/SlotTimeline.stories.tsx b/src/components/Ethereum/SlotTimeline/SlotTimeline.stories.tsx index 86a30d1d3..d0494ad11 100644 --- a/src/components/Ethereum/SlotTimeline/SlotTimeline.stories.tsx +++ b/src/components/Ethereum/SlotTimeline/SlotTimeline.stories.tsx @@ -11,7 +11,7 @@ const meta = { }, decorators: [ Story => ( -
+
), @@ -171,10 +171,10 @@ export const ShortTimeline: Story = { export const CustomPhases: Story = { args: { phases: [ - { label: 'Init', duration: 2000, className: 'bg-primary', textClassName: 'text-white' }, - { label: 'Process', duration: 5000, className: 'bg-secondary', textClassName: 'text-white' }, - { label: 'Validate', duration: 2000, className: 'bg-success', textClassName: 'text-white' }, - { label: 'Finalize', duration: 1000, className: 'bg-accent', textClassName: 'text-white' }, + { label: 'Init', duration: 2000, className: 'bg-primary', textClassName: 'text-on-fill' }, + { label: 'Process', duration: 5000, className: 'bg-secondary', textClassName: 'text-on-fill' }, + { label: 'Validate', duration: 2000, className: 'bg-success', textClassName: 'text-on-fill' }, + { label: 'Finalize', duration: 1000, className: 'bg-accent', textClassName: 'text-on-fill' }, ], currentTime: 4500, showPhaseLabels: true, @@ -188,26 +188,26 @@ export const CustomTextColors: Story = { { label: 'Light Phase', duration: 3000, - className: 'bg-white border border-border', + className: 'bg-surface border border-border', textClassName: 'text-foreground', }, { label: 'Dark Phase', duration: 3000, - className: 'bg-slate-900', - textClassName: 'text-white', + className: 'bg-data-slate', + textClassName: 'text-on-fill', }, { label: 'Accent Phase', duration: 3000, - className: 'bg-amber-200', - textClassName: 'text-amber-900', + className: 'bg-data-amber-30', + textClassName: 'text-foreground', }, { label: 'Primary Phase', duration: 3000, - className: 'bg-cyan-500', - textClassName: 'text-cyan-50', + className: 'bg-data-cyan', + textClassName: 'text-on-fill', }, ], currentTime: 6000, @@ -281,8 +281,8 @@ export const MultipleSlots: Story = {
{/* Phase labels shown once at bottom */}
- {DEFAULT_BEACON_SLOT_PHASES.map((phase, index) => ( -
+ {DEFAULT_BEACON_SLOT_PHASES.map(phase => ( +
{phase.label}
diff --git a/src/components/Ethereum/SlotTimeline/SlotTimeline.tsx b/src/components/Ethereum/SlotTimeline/SlotTimeline.tsx index 16dbafff5..add6fdd1d 100644 --- a/src/components/Ethereum/SlotTimeline/SlotTimeline.tsx +++ b/src/components/Ethereum/SlotTimeline/SlotTimeline.tsx @@ -37,15 +37,12 @@ function SlotTimelineComponent({ const totalDuration = slotDuration ?? phases.reduce((sum, phase) => sum + phase.duration, 0); // Calculate cumulative positions for each phase - let cumulativeTime = 0; - const phasePositions = phases.map(phase => { - const startTime = cumulativeTime; + const phasePositions = phases.map((phase, index) => { + const startTime = phases.slice(0, index).reduce((sum, earlier) => sum + earlier.duration, 0); const endTime = startTime + phase.duration; const startPercent = (startTime / totalDuration) * 100; const widthPercent = (phase.duration / totalDuration) * 100; - cumulativeTime = endTime; - return { ...phase, startTime, @@ -121,8 +118,6 @@ function SlotTimelineComponent({ return (
-

{ariaLabel}

- {/* Timeline container */}
{/* Phase segments */} @@ -153,11 +148,11 @@ function SlotTimelineComponent({ > {phasePositions.map((phase, index) => (
- + {phase.label}
@@ -173,7 +168,7 @@ function SlotTimelineComponent({ {/* Optional time labels inside segments */} {showTimeLabels && !showInlineLabels && (
- + {phase.startTime / 1000}s
@@ -184,7 +179,7 @@ function SlotTimelineComponent({ {/* Hover preview indicator */} {hoverPercent !== null && (
)} @@ -197,8 +192,8 @@ function SlotTimelineComponent({ {showTimeCutovers && (
0s - {phasePositions.map((phase, index) => ( - + {phasePositions.map(phase => ( + {(phase.endTime / 1000).toFixed(0)}s ))} @@ -208,8 +203,8 @@ function SlotTimelineComponent({ {/* Current time label */} {showCurrentTime && (
{(currentTime / 1000).toFixed(1)}s
@@ -219,14 +214,14 @@ function SlotTimelineComponent({ {/* Phase labels below timeline */} {showPhaseLabels && phases.length > 0 && (
- {phasePositions.map((phase, index) => ( -
+ {phasePositions.map(phase => ( +
{/* Color indicator */}
{/* Phase label */} {phase.label} - {showTimeLabels && ({phase.duration / 1000}s)} + {showTimeLabels && ({phase.duration / 1000}s)}
))} diff --git a/src/components/Ethereum/SlotTimeline/index.ts b/src/components/Ethereum/SlotTimeline/index.ts index 3b3f9417d..949a7b8e1 100644 --- a/src/components/Ethereum/SlotTimeline/index.ts +++ b/src/components/Ethereum/SlotTimeline/index.ts @@ -1,2 +1 @@ export { SlotTimeline } from './SlotTimeline'; -export type { SlotTimelineProps } from './SlotTimeline.types'; diff --git a/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.stories.tsx b/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.stories.tsx index d875358b8..ba86cb61e 100644 --- a/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.stories.tsx +++ b/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.stories.tsx @@ -2,6 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { EntityMetricDataPoint } from './TopEntitiesChart.types'; import { TopEntitiesChart } from './TopEntitiesChart'; +import { seededRandom } from '@/test-utils/random'; + +const random = seededRandom(25); const meta: Meta = { title: 'Components/Ethereum/TopEntitiesChart', @@ -11,7 +14,7 @@ const meta: Meta = { }, decorators: [ Story => ( -
+
), @@ -35,11 +38,11 @@ function generateEntityData( for (let x = minX; x <= maxX; x++) { for (let e = 0; e < entityCount; e++) { // Random chance of having data for this entity at this x - if (Math.random() > 0.3) { + if (random() > 0.3) { data.push({ x, entity: `${entityPrefix} ${e + 1}`, - count: Math.floor(Math.random() * 50) + 1, + count: Math.floor(random() * 50) + 1, }); } } @@ -114,7 +117,7 @@ export const SingleEntity: Story = { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, entity: 'Solo Validator', - count: Math.floor(Math.random() * 20) + 1, + count: Math.floor(random() * 20) + 1, })), xAxis: { name: 'Slot' }, yAxis: { name: 'Missed Attestations' }, diff --git a/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.tsx b/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.tsx index 1a5744c42..836410ef1 100644 --- a/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.tsx +++ b/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.tsx @@ -111,8 +111,8 @@ export function TopEntitiesChart({ // Calculate x-axis range (use provided or calculate from data) const allX = [...new Set(data.map(d => d.x))].sort((a, b) => a - b); - const minX = xAxis.min ?? allX[0]; - const maxX = xAxis.max ?? allX[allX.length - 1]; + const minX = xAxis.min ?? allX[0] ?? 0; + const maxX = xAxis.max ?? allX[allX.length - 1] ?? 0; // Build series data for each entity // Create a map of entity -> x -> count diff --git a/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.types.ts b/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.types.ts index deee49b27..2d364613d 100644 --- a/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.types.ts +++ b/src/components/Ethereum/TopEntitiesChart/TopEntitiesChart.types.ts @@ -20,7 +20,7 @@ export interface EntityMetricDataPoint { /** * X-axis configuration */ -export interface XAxisConfig { +interface XAxisConfig { /** Axis label (e.g., 'Slot', 'Epoch', 'Block') */ name: string; /** Optional minimum value (if not provided, calculated from data) */ @@ -39,7 +39,7 @@ export interface XAxisConfig { /** * Y-axis configuration */ -export interface YAxisConfig { +interface YAxisConfig { /** Axis label (e.g., 'Missed Attestations', 'Count') */ name: string; /** diff --git a/src/components/Ethereum/TopEntitiesChart/index.ts b/src/components/Ethereum/TopEntitiesChart/index.ts index aec27ecd3..e90c32d4e 100644 --- a/src/components/Ethereum/TopEntitiesChart/index.ts +++ b/src/components/Ethereum/TopEntitiesChart/index.ts @@ -1,2 +1 @@ export { TopEntitiesChart } from './TopEntitiesChart'; -export type { TopEntitiesChartProps, EntityMetricDataPoint } from './TopEntitiesChart.types'; diff --git a/src/components/Ethereum/TransactionCountChart/TransactionCountChart.stories.tsx b/src/components/Ethereum/TransactionCountChart/TransactionCountChart.stories.tsx index 3dc9a0ff4..ddaf4b84e 100644 --- a/src/components/Ethereum/TransactionCountChart/TransactionCountChart.stories.tsx +++ b/src/components/Ethereum/TransactionCountChart/TransactionCountChart.stories.tsx @@ -1,5 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { TransactionCountChart } from './TransactionCountChart'; +import { seededRandom } from '@/test-utils/random'; +import { NOW_MS } from '@/test-utils/time'; + +const random = seededRandom(26); const meta = { title: 'Components/Ethereum/TransactionCountChart', @@ -10,7 +14,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), @@ -27,7 +31,7 @@ export const SlotGranularity: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - value: Math.floor(Math.random() * 100 + 50), // 50-150 transactions per slot + value: Math.floor(random() * 100 + 50), // 50-150 transactions per slot })), xAxis: { name: 'Slot', @@ -44,7 +48,7 @@ export const EpochGranularity: Story = { args: { data: Array.from({ length: 100 }, (_, i) => ({ x: 300000 + i, - value: Math.floor(Math.random() * 2000 + 3000), // 3000-5000 transactions per epoch + value: Math.floor(random() * 2000 + 3000), // 3000-5000 transactions per epoch })), xAxis: { name: 'Epoch', @@ -60,7 +64,7 @@ export const BlockGranularity: Story = { args: { data: Array.from({ length: 100 }, (_, i) => ({ x: 20000000 + i, - value: Math.floor(Math.random() * 150 + 50), // 50-200 transactions per block + value: Math.floor(random() * 150 + 50), // 50-200 transactions per block })), xAxis: { name: 'Block Number', @@ -75,8 +79,8 @@ export const BlockGranularity: Story = { export const TimestampGranularity: Story = { args: { data: Array.from({ length: 30 }, (_, i) => ({ - x: Date.now() / 1000 - (30 - i) * 86400, // Last 30 days - value: Math.floor(Math.random() * 50000 + 900000), // 900k-950k daily transactions + x: NOW_MS / 1000 - (30 - i) * 86400, // Last 30 days + value: Math.floor(random() * 50000 + 900000), // 900k-950k daily transactions })), xAxis: { name: 'Date', @@ -97,7 +101,7 @@ export const HighActivity: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - value: Math.floor(Math.random() * 50 + 150), // 150-200 transactions (congested) + value: Math.floor(random() * 50 + 150), // 150-200 transactions (congested) })), xAxis: { name: 'Slot', @@ -115,7 +119,7 @@ export const LowActivity: Story = { args: { data: Array.from({ length: 32 }, (_, i) => ({ x: 10000000 + i, - value: Math.floor(Math.random() * 30 + 10), // 10-40 transactions (quiet) + value: Math.floor(random() * 30 + 10), // 10-40 transactions (quiet) })), xAxis: { name: 'Slot', diff --git a/src/components/Ethereum/TransactionCountChart/TransactionCountChart.types.ts b/src/components/Ethereum/TransactionCountChart/TransactionCountChart.types.ts index f070564d0..95a6f0ccf 100644 --- a/src/components/Ethereum/TransactionCountChart/TransactionCountChart.types.ts +++ b/src/components/Ethereum/TransactionCountChart/TransactionCountChart.types.ts @@ -1,7 +1,7 @@ /** * Data point for transaction count timeseries */ -export interface TransactionCountDataPoint { +interface TransactionCountDataPoint { /** X-axis value (slot, epoch, block, timestamp, etc.) */ x: number; /** Number of transactions */ @@ -16,7 +16,7 @@ export interface TransactionCountDataPoint { /** * X-axis configuration for TransactionCountChart */ -export interface TransactionCountXAxisConfig { +interface TransactionCountXAxisConfig { /** Axis label (e.g., "Slot", "Epoch", "Block Number") */ name: string; /** Optional minimum value */ diff --git a/src/components/Ethereum/TransactionCountChart/index.ts b/src/components/Ethereum/TransactionCountChart/index.ts index fa843cffb..d6285d36b 100644 --- a/src/components/Ethereum/TransactionCountChart/index.ts +++ b/src/components/Ethereum/TransactionCountChart/index.ts @@ -1,6 +1 @@ export { TransactionCountChart } from './TransactionCountChart'; -export type { - TransactionCountChartProps, - TransactionCountDataPoint, - TransactionCountXAxisConfig, -} from './TransactionCountChart.types'; diff --git a/src/components/Ethereum/Validator/Validator.stories.tsx b/src/components/Ethereum/Validator/Validator.stories.tsx index 1b3be1464..754a5128d 100644 --- a/src/components/Ethereum/Validator/Validator.stories.tsx +++ b/src/components/Ethereum/Validator/Validator.stories.tsx @@ -10,7 +10,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
+
), diff --git a/src/components/Ethereum/Validator/index.ts b/src/components/Ethereum/Validator/index.ts index d4cd5241c..90f057b60 100644 --- a/src/components/Ethereum/Validator/index.ts +++ b/src/components/Ethereum/Validator/index.ts @@ -1,2 +1 @@ export { Validator } from './Validator'; -export type { ValidatorProps } from './Validator.types'; diff --git a/src/components/Feedback/Alert/Alert.stories.tsx b/src/components/Feedback/Alert/Alert.stories.tsx index 59e70cc56..4870027bf 100644 --- a/src/components/Feedback/Alert/Alert.stories.tsx +++ b/src/components/Feedback/Alert/Alert.stories.tsx @@ -113,7 +113,7 @@ export const WithAccentBorder: Story = { description: ( <> You have no credits left.{' '} - + Upgrade your account to add more credits. diff --git a/src/components/Feedback/Alert/Alert.tsx b/src/components/Feedback/Alert/Alert.tsx index f4344d4dc..d07dfca67 100644 --- a/src/components/Feedback/Alert/Alert.tsx +++ b/src/components/Feedback/Alert/Alert.tsx @@ -17,9 +17,9 @@ const base = { description: 'text-muted', descriptionBorder: 'text-muted', actionText: 'text-foreground', - actionHover: 'hover:bg-muted/10', + actionHover: 'hover:bg-muted-10', dismissText: 'text-muted hover:text-foreground', - dismissHover: 'hover:bg-muted/10', + dismissHover: 'hover:bg-muted-10', }; const variantConfig = { @@ -91,7 +91,7 @@ export function Alert({
{/* Title and description section */} - {title && !link &&

{title}

} + {title && !link &&

{title}

} {description && (
@@ -103,8 +103,8 @@ export function Alert({ {items && items.length > 0 && (
    - {items.map((item, index) => ( -
  • {item}
  • + {items.map(item => ( +
  • {item}
  • ))}
@@ -124,9 +124,9 @@ export function Alert({ {actions && actions.length > 0 && (
- {actions.map((action, index) => ( + {actions.map(action => ( @@ -201,8 +201,8 @@ export function FilterToggle({ selected, onClick, children }: FilterToggleProps) className={clsx( 'inline-flex h-8 items-center gap-1.5 rounded-sm border px-3 text-xs font-medium transition-colors', selected - ? 'border-primary bg-primary/10 text-primary' - : 'border-border bg-surface text-muted hover:border-primary/40 hover:text-foreground' + ? 'border-primary bg-primary-10 text-primary' + : 'border-border bg-surface text-muted hover:border-primary-40 hover:text-foreground' )} > {children} diff --git a/src/components/Forms/FilterBar/index.ts b/src/components/Forms/FilterBar/index.ts index db58c164e..c699a25a9 100644 --- a/src/components/Forms/FilterBar/index.ts +++ b/src/components/Forms/FilterBar/index.ts @@ -1,3 +1,3 @@ export { FilterBar, FilterField, FilterRange, FilterToggle } from './FilterBar'; export { formatRange } from './FilterBar.utils'; -export type { FilterBarProps, FilterChipItem, FilterRangeProps, FilterToggleProps } from './FilterBar'; +export type { FilterChipItem } from './FilterBar'; diff --git a/src/components/Forms/Input/Input.stories.tsx b/src/components/Forms/Input/Input.stories.tsx index 99e6b64f3..1c46d2589 100644 --- a/src/components/Forms/Input/Input.stories.tsx +++ b/src/components/Forms/Input/Input.stories.tsx @@ -15,7 +15,7 @@ const meta = { }, decorators: [ Story => ( -
+
), @@ -539,7 +539,7 @@ export const UserInteractions: Story = { return (
-
+
Interaction Testing: This story tests onChange, onBlur, and onFocus events. Open the Interactions panel to see event handlers being called.
@@ -584,7 +584,7 @@ export const UserInteractions: Story = { export const InputTypes: Story = { render: () => (
-
+
Input Types: Testing different HTML input types with proper attributes.
@@ -831,13 +831,13 @@ export const ReactHookFormIntegration: Story = { return (
-
+
React Hook Form + Zod: This example uses react-hook-form with Zod validation. The submit button is disabled until all required fields are valid. Try filling out the form to see validation in action.
{submitted && submittedData && ( -
+
✅ Form Submitted Successfully!
{JSON.stringify(submittedData, null, 2)}
@@ -910,7 +910,7 @@ export const ReactHookFormIntegration: Story = { {!isFormValid && isDirty && ( Please fill all required fields correctly )} - {isFormValid && ✓ Form is ready to submit} + {isFormValid && ✓ Form is ready to submit}
); @@ -965,7 +965,7 @@ export const ReactHookFormIntegration: Story = { export const ErrorHandlingPatterns: Story = { render: () => (
-
+
Error Handling Best Practices:
  • Use error + errorMessage for validation failures
  • @@ -1105,7 +1105,7 @@ export const CustomValidationExamples: Story = { return (
    -
    +
    Custom Validation Patterns: These examples show common validation scenarios with appropriate inputMode for better mobile UX. Try filling out the fields to see validation in action.
    diff --git a/src/components/Forms/Input/Input.test.tsx b/src/components/Forms/Input/Input.test.tsx index d8b9170e0..7b382c39c 100644 --- a/src/components/Forms/Input/Input.test.tsx +++ b/src/components/Forms/Input/Input.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen } from '@/test-utils'; import { Input } from './Input'; /** diff --git a/src/components/Forms/Input/Input.tsx b/src/components/Forms/Input/Input.tsx index c1d6fa2fb..4b3b11bc2 100644 --- a/src/components/Forms/Input/Input.tsx +++ b/src/components/Forms/Input/Input.tsx @@ -1,4 +1,4 @@ -import React, { useId, createContext, useContext, type JSX } from 'react'; +import React, { useId, createContext, use, type JSX, type ReactNode } from 'react'; import clsx from 'clsx'; import { Button } from '@/components/Elements/Button'; import type { InputProps, InputFieldProps, InputSlotProps, InputSize, SelectAddonProps } from './Input.types'; @@ -10,18 +10,101 @@ interface InputContextValue { inline: boolean; inputId: string; labelVariant: 'standard' | 'inset' | 'overlapping'; + /** Ids of the helper or error text and the corner hint, for the field's aria-describedby */ + describedBy?: string; + required?: boolean; } const InputContext = createContext(null); const useInputContext = (): InputContextValue => { - const context = useContext(InputContext); + const context = use(InputContext); if (!context) { throw new Error('Input subcomponents must be used within an Input component'); } return context; }; +/** + * What a slot holds: its explicit type, or one detected from its child + */ +function slotContent(children: ReactNode, type: InputSlotProps['type']): NonNullable { + if (type) return type; + if (!React.isValidElement(children)) return 'text'; // Default to text for string children + if ( + children.type === 'select' || + ('options' in (children.props as object) && Array.isArray((children.props as { options?: unknown }).options)) + ) { + return 'select'; + } + if (children.type === Button) return 'button'; + if ( + children.type === 'svg' || + typeof children.type === 'function' || + (typeof children.type === 'object' && children.type !== null) + ) { + // SVG, function component, or forwardRef component (likely an icon) + // Note: Heroicons v2 uses forwardRef which returns an object + return 'icon'; + } + return 'text'; +} + +/* + * Input never inspects its children. Every slot marks its root with data-input-slot (plus data-input-leading or + * data-input-trailing, and data-input-icon for icons), and the box around the slots and the field lays itself out + * from what it holds: + * - icons overlay the field in one grid cell, with the field padded clear of the slots + * - other addons sit beside the field + * - inline addons share one bordered box with the field, which drops its own + */ +const containerStyles = { + external: clsx( + 'has-data-input-icon:grid has-data-input-icon:grid-cols-1', + 'not-has-data-input-icon:has-data-input-slot:flex' + ), + inline: clsx( + 'has-data-input-slot:flex has-data-input-slot:items-center has-data-input-slot:bg-surface has-data-input-slot:px-3', + 'has-data-input-slot:outline-1 has-data-input-slot:-outline-offset-1 has-data-input-slot:outline-border', + 'has-data-input-slot:focus-within:outline-2 has-data-input-slot:focus-within:-outline-offset-2 has-data-input-slot:focus-within:outline-primary' + ), +}; + +// The field under icons: first to paint, in the grid cell the slots share, padded clear of them +const overlayFieldStyles: Record = { + sm: clsx( + 'group-has-data-input-icon/input:order-first group-has-data-input-icon/input:col-start-1 group-has-data-input-icon/input:row-start-1', + 'group-has-data-input-icon/input:group-has-data-input-leading/input:pl-9', + 'group-has-data-input-icon/input:group-has-data-input-trailing/input:pr-9' + ), + md: clsx( + 'group-has-data-input-icon/input:order-first group-has-data-input-icon/input:col-start-1 group-has-data-input-icon/input:row-start-1', + 'group-has-data-input-icon/input:group-has-data-input-leading/input:pl-10', + 'group-has-data-input-icon/input:group-has-data-input-trailing/input:pr-10' + ), + lg: clsx( + 'group-has-data-input-icon/input:order-first group-has-data-input-icon/input:col-start-1 group-has-data-input-icon/input:row-start-1', + 'group-has-data-input-icon/input:group-has-data-input-leading/input:pl-11', + 'group-has-data-input-icon/input:group-has-data-input-trailing/input:pr-11' + ), +}; + +// The field beside inline addons: borderless and transparent, padded only towards the addons +const inlineFieldClasses = clsx( + 'group-has-data-input-slot/input:min-w-0 group-has-data-input-slot/input:grow group-has-data-input-slot/input:bg-transparent!', + 'group-has-data-input-slot/input:shadow-none group-has-data-input-slot/input:outline-hidden', + 'group-has-data-input-slot/input:focus:outline-hidden group-has-data-input-slot/input:focus-visible:outline-hidden', + 'group-has-data-input-slot/input:sm:text-sm/6 group-has-data-input-slot/input:px-0', + 'group-has-data-input-slot/input:group-has-data-input-leading/input:pl-1', + 'group-has-data-input-slot/input:group-has-data-input-trailing/input:pr-1' +); + +const slotIconStyles: Record = { + sm: '*:size-4', + md: '*:size-5', + lg: '*:size-5', +}; + /** * Input.Leading - Renders content before the input field * @@ -44,70 +127,48 @@ const useInputContext = (): InputContextValue => { * ``` */ export const InputLeading = ({ children, className, type }: InputSlotProps): React.ReactNode => { - const { size, inline } = useInputContext(); - - // Determine content type (explicit type prop takes precedence) - let contentType = type; - - // Auto-detect if no explicit type provided - if (!contentType && React.isValidElement(children)) { - if ( - children.type === 'select' || - ('options' in (children.props as object) && Array.isArray((children.props as { options?: unknown }).options)) - ) { - contentType = 'select'; - } else if (children.type === Button) { - contentType = 'button'; - } else if ( - children.type === 'svg' || - typeof children.type === 'function' || - (typeof children.type === 'object' && children.type !== null) - ) { - // SVG, function component, or forwardRef component (likely an icon) - // Note: Heroicons v2 uses forwardRef which returns an object - contentType = 'icon'; - } else { - contentType = 'text'; - } - } else if (!contentType) { - contentType = 'text'; // Default to text for string children - } + const { size, inline, labelVariant } = useInputContext(); + + // An inset label takes the slots' place + if (labelVariant === 'inset') return null; + + // Explicit type prop takes precedence over detection + const contentType = slotContent(children, type); // Render based on content type if (contentType === 'icon') { - const iconSizeStyles = { - sm: 'size-4', - md: 'size-5', - lg: 'size-5', - }; - return ( -
    - {React.isValidElement(children) && - React.cloneElement(children, { - 'aria-hidden': 'true', - className: clsx( - iconSizeStyles[size], - 'ml-3', - 'pointer-events-none self-center text-muted dark:text-muted', - (children.props as { className?: string }).className - ), - } as Record)} +
    + {React.isValidElement(children) && ( + + )}
    ); } if (contentType === 'select') { - return renderSelect(React.isValidElement(children) ? (children.props as SelectAddonProps) : undefined); + return renderSelect(React.isValidElement(children) ? (children.props as SelectAddonProps) : undefined, 'leading'); } // Text addon (default) return (
    @@ -141,78 +202,60 @@ InputLeading.displayName = 'Input.Leading'; * ``` */ export const InputTrailing = ({ children, className, type }: InputSlotProps): React.ReactNode => { - const { size, inline } = useInputContext(); - - // Determine content type (explicit type prop takes precedence) - let contentType = type; - - // Auto-detect if no explicit type provided - if (!contentType && React.isValidElement(children)) { - if ( - children.type === 'select' || - ('options' in (children.props as object) && Array.isArray((children.props as { options?: unknown }).options)) - ) { - contentType = 'select'; - } else if (children.type === Button) { - contentType = 'button'; - } else if ( - children.type === 'svg' || - typeof children.type === 'function' || - (typeof children.type === 'object' && children.type !== null) - ) { - // SVG, function component, or forwardRef component (likely an icon) - // Note: Heroicons v2 uses forwardRef which returns an object - contentType = 'icon'; - } else { - contentType = 'text'; - } - } else if (!contentType) { - contentType = 'text'; // Default to text for string children - } + const { size, inline, labelVariant } = useInputContext(); + + // An inset label takes the slots' place + if (labelVariant === 'inset') return null; + + // Explicit type prop takes precedence over detection + const contentType = slotContent(children, type); // Render based on content type if (contentType === 'icon') { - const iconSizeStyles = { - sm: 'size-4', - md: 'size-5', - lg: 'size-5', - }; - return ( -
    - {React.isValidElement(children) && - React.cloneElement(children, { - 'aria-hidden': 'true', - className: clsx( - iconSizeStyles[size], - 'mr-3', - 'pointer-events-none self-center text-muted dark:text-muted', - (children.props as { className?: string }).className - ), - } as Record)} +
    + {React.isValidElement(children) && ( + + )}
    ); } if (contentType === 'button') { return ( -
    +
    {children}
    ); } if (contentType === 'select') { - return renderSelect(React.isValidElement(children) ? (children.props as SelectAddonProps) : undefined); + return renderSelect(React.isValidElement(children) ? (children.props as SelectAddonProps) : undefined, 'trailing'); } // Text addon (default) return (
    @@ -240,11 +283,23 @@ InputTrailing.displayName = 'Input.Trailing'; * ``` */ export const InputField = ({ className, ref, inputMode, ...props }: InputFieldProps): JSX.Element => { - const { size, error, inputId, labelVariant } = useInputContext(); + const { size, error, inline, inputId, labelVariant, describedBy, required } = useInputContext(); const styles = getStyleConfig(); + // The Input's helper text, corner hint and required flag take precedence over the field's own + const aria = { + ...(describedBy ? { 'aria-describedby': describedBy } : {}), + ...(required ? { 'aria-required': 'true' as const } : {}), + }; + // For inset labels, skip base styles (container handles styling) if (labelVariant === 'inset') { + // Inset label - no outline on input (container handles it) + const textSize = { + sm: 'text-sm/6', + md: 'text-base/6', + lg: 'text-base/6', + }; return ( ); } @@ -267,11 +328,14 @@ export const InputField = ({ className, ref, inputMode, ...props }: InputFieldPr aria-invalid={error ? ('true' as const) : undefined} inputMode={inputMode} {...props} + {...aria} className={clsx( styles.base, error ? styles.outline.error : styles.outline.default, styles.size[size], paddingClasses, + // How the field sits among the slots, if there are any + inline ? inlineFieldClasses : overlayFieldStyles[size], className )} /> @@ -337,6 +401,11 @@ const InputBase = ({ inline, inputId, labelVariant, + describedBy: + errorMessage || helperText || cornerHint + ? [errorMessage || helperText ? `${inputId}-error` : null, cornerHintId].filter(Boolean).join(' ') + : undefined, + required, }; /** @@ -371,7 +440,7 @@ const InputBase = ({ return ( @@ -381,7 +450,7 @@ const InputBase = ({ // Inset label if (labelVariant === 'inset') { return ( -
    +
    ), diff --git a/src/components/Forms/RangeInput/RangeInput.tsx b/src/components/Forms/RangeInput/RangeInput.tsx index f7befc859..f9b4a89ec 100644 --- a/src/components/Forms/RangeInput/RangeInput.tsx +++ b/src/components/Forms/RangeInput/RangeInput.tsx @@ -53,9 +53,9 @@ export function RangeInput({ '[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-sm [&::-moz-range-track]:bg-border', // Thumb styles - with vertical centering '[&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:transition-colors', - '[&::-webkit-slider-thumb]:hover:bg-primary/80', + '[&::-webkit-slider-thumb]:hover:bg-primary-80', '[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:transition-colors', - '[&::-moz-range-thumb]:hover:bg-primary/80', + '[&::-moz-range-thumb]:hover:bg-primary-80', // Focus styles 'focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary' )} diff --git a/src/components/Forms/RangeInput/index.ts b/src/components/Forms/RangeInput/index.ts deleted file mode 100644 index b2061d4ea..000000000 --- a/src/components/Forms/RangeInput/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { RangeInput } from './RangeInput'; -export type { RangeInputProps } from './RangeInput.types'; diff --git a/src/components/Forms/SegmentedControl/SegmentedControl.stories.tsx b/src/components/Forms/SegmentedControl/SegmentedControl.stories.tsx index 2db29fa66..14e72973f 100644 --- a/src/components/Forms/SegmentedControl/SegmentedControl.stories.tsx +++ b/src/components/Forms/SegmentedControl/SegmentedControl.stories.tsx @@ -10,7 +10,7 @@ const meta = { }, decorators: [ Story => ( -
    +
    ), diff --git a/src/components/Forms/SegmentedControl/SegmentedControl.test.tsx b/src/components/Forms/SegmentedControl/SegmentedControl.test.tsx index b2aba431a..f8cb44fef 100644 --- a/src/components/Forms/SegmentedControl/SegmentedControl.test.tsx +++ b/src/components/Forms/SegmentedControl/SegmentedControl.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent } from '@/test-utils'; import { SegmentedControl } from './SegmentedControl'; const options = [ @@ -29,7 +29,7 @@ describe('SegmentedControl', () => { const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: HTMLElement ) { - const left = this.getAttribute('role') === 'group' ? 0 : lefts[this.textContent ?? '']; + const left = this.getAttribute('role') === 'group' ? 0 : (lefts[this.textContent ?? ''] ?? 0); return { left, right: this.getAttribute('role') === 'group' ? 100 : left + 40 } as DOMRect; }); const scrollWidth = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(124); diff --git a/src/components/Forms/SegmentedControl/SegmentedControl.tsx b/src/components/Forms/SegmentedControl/SegmentedControl.tsx index 1e5c623e2..8d225c20f 100644 --- a/src/components/Forms/SegmentedControl/SegmentedControl.tsx +++ b/src/components/Forms/SegmentedControl/SegmentedControl.tsx @@ -6,7 +6,7 @@ import type { SegmentedControlProps } from './SegmentedControl.types'; * Single-choice control for short option sets such as time windows. * * Shares the 32px control height and rounded-sm radius with SelectMenu and Button so a row of - * filters lines up. The selected segment uses the app-wide selected state (`bg-primary/15 + * filters lines up. The selected segment uses the app-wide selected state (`bg-primary-15 * text-primary`) rather than a solid fill. When the options overflow a narrow screen, the control * narrows to the whole segments around the selected one and scrolls sideways to the rest, so no * segment is cut in half. @@ -37,15 +37,24 @@ export function SegmentedControl({ const rect = button.getBoundingClientRect(); return { left: rect.left - origin, right: rect.right - origin }; }); - const inset = edges[0].left; + const start = edges[0]; + const selectedEdge = edges[selected]; + if (!start || !selectedEdge) return; + const inset = start.left; const room = group.clientWidth - 2 * inset; // As many whole segments as fit, from as early as the selected one allows - let first = selected; - while (first > 0 && edges[selected].right - edges[first - 1].left <= room) first--; - let last = selected; - while (last < edges.length - 1 && edges[last + 1].right - edges[first].left <= room) last++; - group.style.width = `${edges[last].right - edges[first].left + 2 * inset}px`; - group.scrollLeft = edges[first].left - inset; + let left = selectedEdge.left; + for (const edge of edges.slice(0, selected).reverse()) { + if (selectedEdge.right - edge.left > room) break; + left = edge.left; + } + let right = selectedEdge.right; + for (const edge of edges.slice(selected + 1)) { + if (edge.right - left > room) break; + right = edge.right; + } + group.style.width = `${right - left + 2 * inset}px`; + group.scrollLeft = left - inset; }; fit(); // Refit when the screen width changes, not on the height changes of a mobile browser's toolbar @@ -79,9 +88,9 @@ export function SegmentedControl({ disabled={option.disabled} onClick={() => onChange(option.value)} className={clsx( - 'h-full shrink-0 rounded-xs px-2.5 text-xs font-medium whitespace-nowrap transition-colors focus:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/50', - isSelected ? 'bg-primary/15 text-primary' : 'text-muted hover:bg-muted/10 hover:text-foreground', - 'disabled:cursor-not-allowed disabled:text-muted/40 disabled:hover:bg-transparent' + 'h-full shrink-0 rounded-xs px-2.5 text-xs font-medium whitespace-nowrap transition-colors focus:outline-hidden focus-visible:ring-2 focus-visible:ring-primary-50', + isSelected ? 'bg-primary-15 text-primary' : 'text-muted hover:bg-muted-10 hover:text-foreground', + 'disabled:cursor-not-allowed disabled:text-muted-40 disabled:hover:bg-transparent' )} > {option.label} diff --git a/src/components/Forms/SegmentedControl/SegmentedControl.types.ts b/src/components/Forms/SegmentedControl/SegmentedControl.types.ts index a420927a0..1e0928227 100644 --- a/src/components/Forms/SegmentedControl/SegmentedControl.types.ts +++ b/src/components/Forms/SegmentedControl/SegmentedControl.types.ts @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; -export interface SegmentedControlOption { +interface SegmentedControlOption { value: T; label: ReactNode; /** Shown but not selectable, e.g. a filter with no matching rows */ diff --git a/src/components/Forms/SegmentedControl/index.ts b/src/components/Forms/SegmentedControl/index.ts index e9e5e6e69..e66d1d3cc 100644 --- a/src/components/Forms/SegmentedControl/index.ts +++ b/src/components/Forms/SegmentedControl/index.ts @@ -1,2 +1 @@ export { SegmentedControl } from './SegmentedControl'; -export type { SegmentedControlProps, SegmentedControlOption } from './SegmentedControl.types'; diff --git a/src/components/Forms/SelectMenu/SelectMenu.stories.tsx b/src/components/Forms/SelectMenu/SelectMenu.stories.tsx index bab5ee3e5..bff01b5e8 100644 --- a/src/components/Forms/SelectMenu/SelectMenu.stories.tsx +++ b/src/components/Forms/SelectMenu/SelectMenu.stories.tsx @@ -28,8 +28,10 @@ interface Country { name: string; } +const unitedStates: Country = { code: 'us', name: 'United States' }; + const countries: SelectMenuOption[] = [ - { value: { code: 'us', name: 'United States' }, label: 'United States' }, + { value: unitedStates, label: 'United States' }, { value: { code: 'ca', name: 'Canada' }, label: 'Canada' }, { value: { code: 'mx', name: 'Mexico' }, label: 'Mexico' }, { value: { code: 'uk', name: 'United Kingdom' }, label: 'United Kingdom' }, @@ -46,7 +48,7 @@ const countriesWithIcons: SelectMenuOption[] = countries.map(country => */ export const Default: Story = { render: () => { - const [selected, setSelected] = useState(countries[0].value); + const [selected, setSelected] = useState(unitedStates); return ; }, }; @@ -56,7 +58,7 @@ export const Default: Story = { */ export const WithoutLabel: Story = { render: () => { - const [selected, setSelected] = useState(countries[0].value); + const [selected, setSelected] = useState(unitedStates); return ; }, }; @@ -66,7 +68,7 @@ export const WithoutLabel: Story = { */ export const WithIcons: Story = { render: () => { - const [selected, setSelected] = useState(countriesWithIcons[0].value); + const [selected, setSelected] = useState(unitedStates); return ( ); @@ -78,7 +80,7 @@ export const WithIcons: Story = { */ export const Disabled: Story = { render: () => { - const [selected, setSelected] = useState(countries[0].value); + const [selected, setSelected] = useState(unitedStates); return ( ); @@ -108,7 +110,7 @@ export const DifferentIcons: Story = { */ export const Rounded: Story = { render: () => { - const [selected, setSelected] = useState(countries[0].value); + const [selected, setSelected] = useState(unitedStates); return ; }, }; @@ -118,8 +120,8 @@ export const Rounded: Story = { */ export const RoundedComparison: Story = { render: () => { - const [selectedSharp, setSelectedSharp] = useState(countries[0].value); - const [selectedRounded, setSelectedRounded] = useState(countries[0].value); + const [selectedSharp, setSelectedSharp] = useState(unitedStates); + const [selectedRounded, setSelectedRounded] = useState(unitedStates); return (
    diff --git a/src/components/Forms/SelectMenu/SelectMenu.tsx b/src/components/Forms/SelectMenu/SelectMenu.tsx index 5b84616ca..54ea5e409 100644 --- a/src/components/Forms/SelectMenu/SelectMenu.tsx +++ b/src/components/Forms/SelectMenu/SelectMenu.tsx @@ -50,9 +50,9 @@ export function SelectMenu({
    @@ -73,15 +73,15 @@ export function SelectMenu({ anchor="bottom start" transition className={clsx( - 'z-[9999] mt-1 max-h-60 overflow-auto rounded-sm border border-border bg-surface data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0', - expandToFit ? 'w-max' : 'w-[var(--button-width)]' + 'z-9999 mt-1 max-h-60 overflow-auto rounded-sm border border-border bg-surface data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0', + expandToFit ? 'w-max' : 'w-button' )} > - {options.map((option, index) => ( + {options.map(option => ( {option.icon && {option.icon}} diff --git a/src/components/Forms/SelectMenu/index.ts b/src/components/Forms/SelectMenu/index.ts index 67e5175c1..a86cbb1bf 100644 --- a/src/components/Forms/SelectMenu/index.ts +++ b/src/components/Forms/SelectMenu/index.ts @@ -1,2 +1,2 @@ export { SelectMenu } from './SelectMenu'; -export type { SelectMenuProps, SelectMenuOption } from './SelectMenu.types'; +export type { SelectMenuOption } from './SelectMenu.types'; diff --git a/src/components/Forms/TagInput/TagInput.stories.tsx b/src/components/Forms/TagInput/TagInput.stories.tsx index a6bd5a397..b921bc090 100644 --- a/src/components/Forms/TagInput/TagInput.stories.tsx +++ b/src/components/Forms/TagInput/TagInput.stories.tsx @@ -12,7 +12,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
    +
    ), diff --git a/src/components/Forms/TagInput/TagInput.test.tsx b/src/components/Forms/TagInput/TagInput.test.tsx index 6f54c1ed5..0cba8cc02 100644 --- a/src/components/Forms/TagInput/TagInput.test.tsx +++ b/src/components/Forms/TagInput/TagInput.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent } from '@/test-utils'; import userEvent from '@testing-library/user-event'; import { TagInput } from './TagInput'; @@ -174,7 +174,10 @@ describe('TagInput', () => { render(); const removeButtons = screen.getAllByRole('button', { name: 'Remove' }); - await userEvent.click(removeButtons[0]); + expect(removeButtons).toHaveLength(2); + const [removeHello] = removeButtons; + if (!removeHello) throw new Error('Expected a Remove button for the first tag'); + await userEvent.click(removeHello); expect(onTagsChange).toHaveBeenCalledWith(['world']); }); diff --git a/src/components/Forms/TagInput/TagInput.tsx b/src/components/Forms/TagInput/TagInput.tsx index 9d7140773..b667a0ed3 100644 --- a/src/components/Forms/TagInput/TagInput.tsx +++ b/src/components/Forms/TagInput/TagInput.tsx @@ -3,6 +3,19 @@ import clsx from 'clsx'; import { Badge } from '@/components/Elements/Badge'; import type { TagInputProps } from './TagInput.types'; +/** + * Pairs each tag with its key: the tag's text, numbered from the second time the same text appears (tags passed in, + * e.g. from a URL, can repeat) + */ +function withKeys(tags: string[]): { tag: string; key: string }[] { + const seen = new Map(); + return tags.map(tag => { + const count = (seen.get(tag) ?? 0) + 1; + seen.set(tag, count); + return { tag, key: count === 1 ? tag : `${tag}#${count}` }; + }); +} + /** * A chip/tag input component that renders entered values as removable Badge chips. * @@ -132,16 +145,16 @@ export function TagInput({ 'flex max-h-40 cursor-text flex-wrap items-center gap-1.5 overflow-y-auto rounded-sm px-2 py-1.5', 'bg-surface outline-1 -outline-offset-1 transition-colors', error - ? 'outline-danger/50 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-danger' + ? 'outline-danger-50 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-danger' : 'outline-border focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-primary', disabled && 'cursor-not-allowed opacity-50' )} > - {tags.map((tag, index) => { + {withKeys(tags).map(({ tag, key }, index) => { const validation = validate?.(tag); return ( } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle theme" /> @@ -64,7 +64,7 @@ export const OnDarkBackground: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle theme" /> @@ -78,7 +78,7 @@ export const SmallSize: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle theme" size="small" @@ -100,7 +100,7 @@ export const SizeComparison: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle theme default" /> @@ -110,7 +110,7 @@ export const SizeComparison: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle theme small" size="small" @@ -130,7 +130,7 @@ export const Rounded: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle theme" rounded @@ -150,7 +150,7 @@ export const RoundedComparison: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle without rounded" /> @@ -160,7 +160,7 @@ export const RoundedComparison: Story = { } rightIcon={} - leftColor="text-amber-500" + leftColor="text-data-amber" rightColor="text-primary" srLabel="Toggle with rounded" rounded diff --git a/src/components/Forms/Toggle/Toggle.tsx b/src/components/Forms/Toggle/Toggle.tsx index 4c6892b75..62cb6a655 100644 --- a/src/components/Forms/Toggle/Toggle.tsx +++ b/src/components/Forms/Toggle/Toggle.tsx @@ -22,8 +22,8 @@ export function Toggle({ knob: size === 'small' ? 'size-5' : 'size-6', knobTranslate: size === 'small' - ? 'translate-x-0.5 group-data-[checked]:translate-x-6.5' - : 'translate-x-1 group-data-[checked]:translate-x-9', + ? 'translate-x-0.5 group-data-checked:translate-x-6.5' + : 'translate-x-1 group-data-checked:translate-x-9', innerIcon: size === 'small' ? 'size-3' : 'size-4', }; return ( @@ -35,7 +35,7 @@ export function Toggle({ rounded && 'rounded-sm', 'bg-border transition-colors duration-200 ease-in-out', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary', - 'data-[checked]:bg-primary/30 dark:data-[checked]:bg-primary/40', + 'data-checked:bg-primary-toggle', sizeClasses.switch )} > @@ -68,7 +68,7 @@ export function Toggle({ 'pointer-events-none relative inline-flex transform items-center justify-center', rounded && 'rounded-xs', 'bg-surface outline-1 -outline-offset-1 outline-border transition-all duration-200 ease-in-out', - 'group-data-[checked]:outline-primary/50', + 'group-data-checked:outline-primary-50', sizeClasses.knob, sizeClasses.knobTranslate )} @@ -80,7 +80,7 @@ export function Toggle({ className={clsx( leftColor, 'absolute opacity-100 transition-opacity duration-200 ease-in-out', - 'group-data-[checked]:opacity-0', + 'group-data-checked:opacity-0', sizeClasses.innerIcon )} > @@ -90,7 +90,7 @@ export function Toggle({ className={clsx( rightColor, 'absolute opacity-0 transition-opacity duration-200 ease-in-out', - 'group-data-[checked]:opacity-100', + 'group-data-checked:opacity-100', sizeClasses.innerIcon )} > diff --git a/src/components/Forms/Toggle/index.ts b/src/components/Forms/Toggle/index.ts index 7d316a474..87fdc4348 100644 --- a/src/components/Forms/Toggle/index.ts +++ b/src/components/Forms/Toggle/index.ts @@ -1,2 +1 @@ export { Toggle } from './Toggle'; -export type { ToggleProps } from './Toggle.types'; diff --git a/src/components/Layout/Card/Card.stories.tsx b/src/components/Layout/Card/Card.stories.tsx index 869e79f22..ede0ee62d 100644 --- a/src/components/Layout/Card/Card.stories.tsx +++ b/src/components/Layout/Card/Card.stories.tsx @@ -49,7 +49,7 @@ export const RoundedComparison: Story = { }, decorators: [ Story => ( -
    +
    ), @@ -88,7 +88,7 @@ export const WithTitle: Story = { title: 'Consensus clients', subtitle: 'Share of proposed blocks over 7 days', actions: ( - ), @@ -153,7 +153,7 @@ export const InteractiveWithSections: Story = { */ export const CustomStyled: Story = { args: { - className: 'border-4 border-blue-500', + className: 'border-4 border-data-blue', children:

    This card has a custom blue border

    , }, }; @@ -166,7 +166,7 @@ export const StatsCard: Story = { header: (

    Total Validators

    - +12.5% + +12.5%
    ), children: ( @@ -191,7 +191,7 @@ export const ProfileCard: Story = { args: { header: (
    -
    +
    JD
    @@ -221,7 +221,7 @@ export const ProfileCard: Story = { ), footer: (
    -
    @@ -274,7 +274,7 @@ export const GridLayout: Story = { }, decorators: [ Story => ( -
    +
    ), @@ -285,7 +285,7 @@ export const GridLayout: Story = { header={

    Validators

    - +5.2% + +5.2%
    } footer={Last 24 hours} @@ -298,7 +298,7 @@ export const GridLayout: Story = { header={

    Blocks

    - Live + Live
    } footer={Updated just now} @@ -311,7 +311,7 @@ export const GridLayout: Story = { header={

    Network Health

    - Healthy + Healthy
    } footer={} @@ -397,7 +397,7 @@ export const AllVariants: Story = { }, decorators: [ Story => ( -
    +
    ), @@ -494,7 +494,7 @@ export const TallCard: Story = {
    ), - featureImage: Fork Readiness, + featureImage: Fork Readiness, }, decorators: [ Story => ( @@ -516,7 +516,7 @@ export const WideCard: Story = { header: (

    Block Production Flow

    - Live + Live
    ), children: ( @@ -540,7 +540,7 @@ export const WideCard: Story = { }, decorators: [ Story => ( -
    +
    ), @@ -557,7 +557,7 @@ export const MultipleExperimentImages: Story = { }, decorators: [ Story => ( -
    +
    ), @@ -574,7 +574,7 @@ export const MultipleExperimentImages: Story = { Live Slots} - featureImage={Live Slots} + featureImage={Live Slots} >

    8,234

    Current slot

    @@ -582,7 +582,7 @@ export const MultipleExperimentImages: Story = { Locally Built Blocks} - featureImage={Locally Built Blocks} + featureImage={Locally Built Blocks} >

    67%

    Local block rate

    @@ -598,7 +598,7 @@ export const MultipleExperimentImages: Story = { Fork Readiness} - featureImage={Fork Readiness} + featureImage={Fork Readiness} >

    98.5%

    Clients ready

    diff --git a/src/components/Layout/Card/Card.tsx b/src/components/Layout/Card/Card.tsx index bfbd2fa3d..0ea395295 100644 --- a/src/components/Layout/Card/Card.tsx +++ b/src/components/Layout/Card/Card.tsx @@ -11,33 +11,33 @@ const variantStyles: Record{headerContent}
    }
    {children}
    diff --git a/src/components/Layout/Card/index.ts b/src/components/Layout/Card/index.ts index 409f95083..1e15afd14 100644 --- a/src/components/Layout/Card/index.ts +++ b/src/components/Layout/Card/index.ts @@ -1,2 +1 @@ export { Card } from './Card'; -export type { CardProps } from './Card.types'; diff --git a/src/components/Layout/Container/index.ts b/src/components/Layout/Container/index.ts index 6c3580085..b3999f020 100644 --- a/src/components/Layout/Container/index.ts +++ b/src/components/Layout/Container/index.ts @@ -1,2 +1 @@ export { Container } from './Container'; -export type { ContainerProps } from './Container.types'; diff --git a/src/components/Layout/Disclosure/Disclosure.stories.tsx b/src/components/Layout/Disclosure/Disclosure.stories.tsx index f0cee9c5b..20d291f28 100644 --- a/src/components/Layout/Disclosure/Disclosure.stories.tsx +++ b/src/components/Layout/Disclosure/Disclosure.stories.tsx @@ -6,7 +6,7 @@ const meta: Meta = { component: Disclosure, decorators: [ Story => ( -
    +
    ), @@ -60,7 +60,7 @@ export const WithRightContent: Story = { args: { title: 'Filter Series (29 of 31 shown)', rightContent: ( - + Show All ), diff --git a/src/components/Layout/Disclosure/Disclosure.tsx b/src/components/Layout/Disclosure/Disclosure.tsx index 420946339..bad07b558 100644 --- a/src/components/Layout/Disclosure/Disclosure.tsx +++ b/src/components/Layout/Disclosure/Disclosure.tsx @@ -18,7 +18,7 @@ export function Disclosure({ {({ open }) => ( <> - +
    {open ? '▼' : '▶'} {title} diff --git a/src/components/Layout/Disclosure/index.ts b/src/components/Layout/Disclosure/index.ts index 2114d2ece..2c8e20a56 100644 --- a/src/components/Layout/Disclosure/index.ts +++ b/src/components/Layout/Disclosure/index.ts @@ -1,2 +1 @@ export { Disclosure } from './Disclosure'; -export type { DisclosureProps } from './Disclosure.types'; diff --git a/src/components/Layout/Divider/Divider.types.ts b/src/components/Layout/Divider/Divider.types.ts index 3b1a4890b..c589c51d0 100644 --- a/src/components/Layout/Divider/Divider.types.ts +++ b/src/components/Layout/Divider/Divider.types.ts @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; -export type DividerAlignment = 'center' | 'left'; -export type DividerVariant = 'label' | 'icon' | 'title' | 'button' | 'toolbar'; +type DividerAlignment = 'center' | 'left'; +type DividerVariant = 'label' | 'icon' | 'title' | 'button' | 'toolbar'; export interface DividerProps { /** diff --git a/src/components/Layout/Divider/index.ts b/src/components/Layout/Divider/index.ts index 1d1b94181..8ba836ab7 100644 --- a/src/components/Layout/Divider/index.ts +++ b/src/components/Layout/Divider/index.ts @@ -1,2 +1 @@ export { Divider } from './Divider'; -export type { DividerProps, DividerAlignment, DividerVariant } from './Divider.types'; diff --git a/src/components/Layout/Header/Header.types.ts b/src/components/Layout/Header/Header.types.ts index efd8a30b3..a475daa87 100644 --- a/src/components/Layout/Header/Header.types.ts +++ b/src/components/Layout/Header/Header.types.ts @@ -1,4 +1,4 @@ -export type HeaderSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +type HeaderSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; export interface HeaderProps { /** diff --git a/src/components/Layout/Header/index.ts b/src/components/Layout/Header/index.ts index 5c7d7607f..29429dc97 100644 --- a/src/components/Layout/Header/index.ts +++ b/src/components/Layout/Header/index.ts @@ -1,2 +1 @@ export { Header } from './Header'; -export type { HeaderProps } from './Header.types'; diff --git a/src/components/Layout/ListContainer/ListContainer.stories.tsx b/src/components/Layout/ListContainer/ListContainer.stories.tsx index 44f5cb54b..4bef9d9b4 100644 --- a/src/components/Layout/ListContainer/ListContainer.stories.tsx +++ b/src/components/Layout/ListContainer/ListContainer.stories.tsx @@ -10,7 +10,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
    +
    ), @@ -214,7 +214,7 @@ export const WithComplexContent: Story = {

    Network Status

    All systems operational

    - + Healthy
    @@ -225,7 +225,7 @@ export const WithComplexContent: Story = {

    API Response Time

    Average: 145ms

    - + Degraded
    @@ -236,7 +236,7 @@ export const WithComplexContent: Story = {

    Database Connection

    Connection failed

    - + Error
    @@ -409,7 +409,7 @@ export const CompactNoDividers: Story = { {items.map(item => ( -
    +

    {item.title}

    diff --git a/src/components/Layout/ListContainer/ListContainer.tsx b/src/components/Layout/ListContainer/ListContainer.tsx index 948ec36ef..b3ecd1d82 100644 --- a/src/components/Layout/ListContainer/ListContainer.tsx +++ b/src/components/Layout/ListContainer/ListContainer.tsx @@ -1,4 +1,4 @@ -import { type JSX, createContext, useContext } from 'react'; +import { type JSX, createContext, use } from 'react'; import clsx from 'clsx'; import type { ListContainerProps, ListItemProps, ListContainerVariant } from './ListContainer.types'; @@ -42,9 +42,8 @@ const variantStyles: Record< itemMobileFull: 'px-4 sm:px-6', }, flat: { - wrapper: 'overflow-hidden border border-border bg-surface dark:border-border dark:bg-background', - wrapperMobileFull: - 'overflow-hidden border border-border bg-surface sm:rounded-sm dark:border-border dark:bg-background', + wrapper: 'overflow-hidden border border-border bg-panel', + wrapperMobileFull: 'overflow-hidden border border-border bg-panel sm:rounded-sm', list: '', listWithDividers: 'divide-y divide-border', item: 'px-6', @@ -57,8 +56,8 @@ const variantStyles: Record< }, }; -export function ListItem({ children, className, onClick }: ListItemProps): JSX.Element { - const { variant, fullWidthOnMobile, compact } = useContext(ListContainerContext); +export function ListItem({ children, className, onClick, as: Component = 'li' }: ListItemProps): JSX.Element { + const { variant, fullWidthOnMobile, compact } = use(ListContainerContext); const styles = variantStyles[variant]; const itemClass = fullWidthOnMobile && styles.itemMobileFull ? styles.itemMobileFull : styles.item; @@ -66,9 +65,9 @@ export function ListItem({ children, className, onClick }: ListItemProps): JSX.E const paddingClass = compact ? 'py-0.5' : 'py-4'; return ( -
  • + {children} -
  • + ); } @@ -84,6 +83,9 @@ export function ListContainer({ const styles = variantStyles[variant]; const contextValue: ListContainerContextValue = { variant, fullWidthOnMobile, compact, withDividers }; + // A div is not a list (its items carry another role, such as radio options) + const role = Component === 'div' ? undefined : 'list'; + // Apply dividers based on withDividers prop and variant // Note: 'separate' variant doesn't use dividers const listClassName = clsx(styles.list, withDividers && variant !== 'separate' && styles.listWithDividers); @@ -91,7 +93,7 @@ export function ListContainer({ // Variants with wrapper need an extra container if (styles.wrapper) { const list = ( - + {children} ); @@ -99,18 +101,18 @@ export function ListContainer({ const wrapperClass = fullWidthOnMobile && styles.wrapperMobileFull ? styles.wrapperMobileFull : styles.wrapper; return ( - +
    {list}
    -
    + ); } // Simple variants render list directly const list = ( - + {children} ); - return {list}; + return {list}; } diff --git a/src/components/Layout/ListContainer/ListContainer.types.ts b/src/components/Layout/ListContainer/ListContainer.types.ts index f89865dab..563120045 100644 --- a/src/components/Layout/ListContainer/ListContainer.types.ts +++ b/src/components/Layout/ListContainer/ListContainer.types.ts @@ -39,7 +39,8 @@ export interface ListContainerProps { className?: string; /** - * The HTML element to render as the list container + * The HTML element to render as the list container. A `div` is not a list, for items + * that carry another role, such as radio options. * @default 'ul' */ as?: 'ul' | 'ol' | 'div'; @@ -60,4 +61,10 @@ export interface ListItemProps { * Click handler for interactive items */ onClick?: () => void; + + /** + * The HTML element to render; `div` when the container is a `div` + * @default 'li' + */ + as?: 'li' | 'div'; } diff --git a/src/components/Layout/ListContainer/ListSection.stories.tsx b/src/components/Layout/ListContainer/ListSection.stories.tsx index e3e1bb670..80f5b3e5b 100644 --- a/src/components/Layout/ListContainer/ListSection.stories.tsx +++ b/src/components/Layout/ListContainer/ListSection.stories.tsx @@ -10,7 +10,7 @@ const meta = { tags: ['autodocs'], decorators: [ Story => ( -
    +
    ), @@ -64,7 +64,7 @@ export const WithNestedSubsections: Story = { Live @@ -72,7 +72,7 @@ export const WithNestedSubsections: Story = { Validators @@ -83,7 +83,7 @@ export const WithNestedSubsections: Story = { Blocks @@ -91,7 +91,7 @@ export const WithNestedSubsections: Story = { Transactions @@ -160,7 +160,7 @@ export const SidebarExample: Story = { Live @@ -168,7 +168,7 @@ export const SidebarExample: Story = { Validators @@ -176,7 +176,7 @@ export const SidebarExample: Story = {
    Attestations @@ -187,7 +187,7 @@ export const SidebarExample: Story = { Blocks @@ -195,7 +195,7 @@ export const SidebarExample: Story = { Transactions @@ -207,7 +207,7 @@ export const SidebarExample: Story = { Contributors @@ -215,7 +215,7 @@ export const SidebarExample: Story = { Geographical Checklist @@ -223,7 +223,7 @@ export const SidebarExample: Story = { Locally Built Blocks diff --git a/src/components/Layout/ListContainer/ListSection.tsx b/src/components/Layout/ListContainer/ListSection.tsx index c0123dc32..d46ca2dd8 100644 --- a/src/components/Layout/ListContainer/ListSection.tsx +++ b/src/components/Layout/ListContainer/ListSection.tsx @@ -6,7 +6,7 @@ import type { ListSectionProps } from './ListSection.types'; /** * ListSection component for creating sections and subsections within lists. - * Renders a section header with children - does not create nested containers. + * Renders a list item holding the section header and a nested list of its children. * Use within a ListContainer and wrap children in ListItems. */ export function ListSection({ @@ -25,7 +25,7 @@ export function ListSection({ const headerElement = nested ? (
    ); - const content =
    {children}
    ; + const content = ( +
      + {children} +
    + ); if (collapsible) { return ( -
    +
  • {!isCollapsed && content} -
  • + ); } return ( -
    +
  • {headerElement} {content} -
  • + ); } diff --git a/src/components/Layout/LoadingContainer/LoadingContainer.stories.tsx b/src/components/Layout/LoadingContainer/LoadingContainer.stories.tsx index 54da79773..87d6a5d34 100644 --- a/src/components/Layout/LoadingContainer/LoadingContainer.stories.tsx +++ b/src/components/Layout/LoadingContainer/LoadingContainer.stories.tsx @@ -77,7 +77,7 @@ export const MultipleElements: Story = { export const CardSkeleton: Story = { render: () => ( -
    +
    diff --git a/src/components/Layout/LoadingContainer/LoadingContainer.tsx b/src/components/Layout/LoadingContainer/LoadingContainer.tsx index 8c44962f4..92d015190 100644 --- a/src/components/Layout/LoadingContainer/LoadingContainer.tsx +++ b/src/components/Layout/LoadingContainer/LoadingContainer.tsx @@ -7,7 +7,7 @@ export function LoadingContainer({ className, shimmer = true }: LoadingContainer return (