Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/frontend/src/renderer/__tests__/ui-charts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { describe, expect, it } from 'vitest';
import { pointsToArea, seriesToPoints } from '@auto-code/ui';
import { barPercent, pointsToArea, seriesToPoints } from '@auto-code/ui';

describe('seriesToPoints', () => {
it('spreads points evenly across the width and inverts Y', () => {
Expand Down Expand Up @@ -50,3 +50,19 @@ describe('pointsToArea', () => {
expect(pointsToArea('', 100, 40)).toBe('');
});
});

describe('barPercent', () => {
it('returns the value share of the scale, clamped to 0-100', () => {
expect(barPercent(26, 26)).toBe(100);
expect(barPercent(13, 26)).toBe(50);
expect(barPercent(1, 26)).toBeCloseTo(3.85, 2);
expect(barPercent(40, 26)).toBe(100);
});

it('yields 0 for non-positive value or scale', () => {
expect(barPercent(0, 26)).toBe(0);
expect(barPercent(-5, 26)).toBe(0);
expect(barPercent(5, 0)).toBe(0);
expect(barPercent(Number.NaN, 26)).toBe(0);
});
});
4 changes: 3 additions & 1 deletion libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export type {
} from './primitives/LineChart';
export { StatTile } from './primitives/StatTile';
export type { StatTileProps, StatTileTone } from './primitives/StatTile';
export { seriesToPoints, pointsToArea, CHART_TONE_VARS } from './primitives/charts';
export { BarList } from './primitives/BarList';
export type { BarListProps, BarListItem } from './primitives/BarList';
export { seriesToPoints, pointsToArea, barPercent, CHART_TONE_VARS } from './primitives/charts';
export type { ChartTone } from './primitives/charts';
export { ThemeProvider, useTheme } from './theme/ThemeProvider';
export type { Theme, ResolvedTheme, ThemeProviderProps } from './theme/ThemeProvider';
Expand Down
40 changes: 40 additions & 0 deletions libs/ui/src/primitives/BarList.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.ac-barlist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.ac-barlist__row {
display: grid;
grid-template-columns: minmax(80px, auto) minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
font-size: 12px;
}
.ac-barlist__label {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ac-barlist__track {
height: 8px;
border-radius: 999px;
background: var(--rail);
overflow: hidden;
}
.ac-barlist__bar {
display: block;
height: 100%;
border-radius: 999px;
min-width: 2px;
}
.ac-barlist__value {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
color: var(--ink);
font-weight: 720;
text-align: right;
}
44 changes: 44 additions & 0 deletions libs/ui/src/primitives/BarList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { BarList } from './BarList';

const meta = {
title: 'Primitives/BarList',
component: BarList,
decorators: [
(Story) => (
<div style={{ maxWidth: 360, padding: 16 }}>
<Story />
</div>
),
],
args: {
ariaLabel: 'PR size distribution',
items: [
{ label: 'XS <50', value: 26, tone: 'good' },
{ label: 'S 50–200', value: 21, tone: 'good' },
{ label: 'M 200–500', value: 10, tone: 'info' },
{ label: 'L 500–1k', value: 4, tone: 'warn' },
{ label: 'XL >1k', value: 1, tone: 'bad' },
],
},
} satisfies Meta<typeof BarList>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Distribution: Story = {};

export const WithValueLabels: Story = {
args: {
ariaLabel: 'Tokens by model',
items: [
{ label: 'Sonnet 4.6', value: 1_240_000, valueLabel: '1.2M', tone: 'info' },
{ label: 'Haiku 4.5', value: 680_000, valueLabel: '680k', tone: 'good' },
{ label: 'Opus 4.8', value: 210_000, valueLabel: '210k', tone: 'warn' },
],
},
};

export const SingleRow: Story = {
args: { ariaLabel: 'One category', items: [{ label: 'Only', value: 7 }] },
};
66 changes: 66 additions & 0 deletions libs/ui/src/primitives/BarList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CHART_TONE_VARS, barPercent, type ChartTone } from './charts';
import './BarList.css';

export interface BarListItem {
label: string;
value: number;
/** Pre-formatted value shown at the row end; defaults to the raw value. */
valueLabel?: string;
tone?: ChartTone;
}

export interface BarListProps {
items: readonly BarListItem[];
/** Bar scale maximum; defaults to the largest item value (min 1). */
max?: number;
/** Accessible name for the whole list (rendered on the <ul>). */
ariaLabel?: string;
className?: string;
}

/**
* Horizontal distribution bars — one labeled row per category with a
* proportional bar and a trailing value, mirroring the `.lazyweb` histogram
* rows. Ideal for `Record<string, number>` breakdowns (PR sizes, per-model
* usage, error patterns). The bar is decorative; the label and value are real
* text, so no extra ARIA is needed on each row.
*/
export function BarList({
items,
max,
ariaLabel,
className,
}: Readonly<BarListProps>) {
const scale = Math.max(
1,
max ?? items.reduce((peak, item) => Math.max(peak, item.value), 0),
);

return (
<ul
className={`ac-barlist${className != null ? ` ${className}` : ''}`}

Check warning on line 41 in libs/ui/src/primitives/BarList.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ-Ukox0RVSYGxsIuMix&open=AZ-Ukox0RVSYGxsIuMix&pullRequest=441
aria-label={ariaLabel}
>
{items.map((item) => {
const pct = barPercent(item.value, scale);
return (
<li key={item.label} className="ac-barlist__row">
<span className="ac-barlist__label">{item.label}</span>
<span className="ac-barlist__track" aria-hidden="true">
<span
className="ac-barlist__bar"
style={{
width: `${pct}%`,
background: CHART_TONE_VARS[item.tone ?? 'info'],
}}
/>
</span>
<span className="ac-barlist__value">
{item.valueLabel ?? String(item.value)}
</span>
</li>
);
})}
</ul>
);
}
9 changes: 9 additions & 0 deletions libs/ui/src/primitives/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@ export function pointsToArea(
function round(n: number): number {
return Math.round(n * 100) / 100;
}

/**
* A value's share of `scale` as a 0–100 percent, clamped. Used by BarList to
* size each row's bar; negative values and a non-positive scale yield 0.
*/
export function barPercent(value: number, scale: number): number {
if (!Number.isFinite(value) || value <= 0 || scale <= 0) return 0;
return Math.min(100, (value / scale) * 100);
}
Loading