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
41 changes: 7 additions & 34 deletions apps/ui/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,7 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';

interface TooltipProps {
text: string;
children: React.ReactNode;
}

export function Tooltip({ text, children }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);

return (
<div
className="relative inline-block"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-bg-card border border-border rounded-sm shadow-sm text-xs text-text-primary whitespace-nowrap pointer-events-none"
>
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-8 border-transparent border-t-bg-card" />
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// Re-export shim. The Tooltip implementation moved to ./ui/Tooltip.tsx
// (Radix-backed, accessible, with collision detection). The original
// custom + Framer Motion implementation has been removed.
//
// Callers must wrap their tree in <TooltipProvider> (exported from
// ./ui/Tooltip) once at the app root before mounting any <Tooltip>.
export { Tooltip, TooltipProvider, type TooltipProps } from './ui/Tooltip';
53 changes: 53 additions & 0 deletions apps/ui/src/components/ui/ChainSeparator.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';

import { ChainSeparator } from './ChainSeparator';

const meta: Meta<typeof ChainSeparator> = {
title: 'UI/ChainSeparator',
component: ChainSeparator,
args: { tone: 'active', animated: true, orientation: 'horizontal' },
argTypes: {
tone: { control: 'radio', options: ['idle', 'active', 'success'] },
orientation: { control: 'radio', options: ['horizontal', 'vertical'] },
},
};

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

export const Horizontal: Story = {
render: (args) => (
<div className="flex items-center w-[320px] h-12 bg-bg-panel rounded-sm border border-border px-3">
<ChainSeparator {...args} />
</div>
),
};

export const Vertical: Story = {
render: () => (
<div className="flex flex-col items-center w-32 py-4 bg-bg-panel rounded-sm border border-border">
<span className="font-mono text-[10px] text-text-secondary">UP</span>
<ChainSeparator tone="active" animated orientation="vertical" />
<span className="font-mono text-[10px] text-text-secondary">DOWN</span>
</div>
),
};

export const Tones: Story = {
render: () => (
<div className="space-y-3 w-[320px]">
{(['idle', 'active', 'success'] as const).map((tone) => (
<div
key={tone}
className="flex items-center h-10 bg-bg-panel rounded-sm border border-border px-3 gap-3"
>
<span className="font-mono text-[10px] uppercase text-text-secondary w-12">
{tone}
</span>
<ChainSeparator tone={tone} animated={tone === 'active'} />
</div>
))}
</div>
),
};
67 changes: 67 additions & 0 deletions apps/ui/src/components/ui/ChainSeparator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';

import { cn } from './cn';

export type SignalTone = 'idle' | 'active' | 'success';

export interface ChainSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
tone?: SignalTone;
animated?: boolean;
orientation?: 'horizontal' | 'vertical';
}

export const ChainSeparator = React.forwardRef<HTMLDivElement, ChainSeparatorProps>(
function ChainSeparator(
{ tone = 'idle', animated = false, orientation = 'horizontal', className, ...rest },
ref,
) {
if (orientation === 'vertical') {
// Vertical chain: cable is a thin vertical line, arrow points down.
const cableTone =
tone === 'active'
? 'bg-[color:var(--color-signal-cable)]'
: tone === 'success'
? 'bg-[color:var(--color-signal-cable-success)]'
: 'bg-[color:var(--color-signal-cable-idle)]';
return (
<div
ref={ref}
className={cn('flex flex-col items-center gap-0', className)}
aria-hidden
{...rest}
>
<div className={cn('w-px h-3 self-center', cableTone)} />
<div
className="w-0 h-0"
style={{
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderTop: `6px solid ${
tone === 'active'
? 'var(--color-signal-cable)'
: tone === 'success'
? 'var(--color-signal-cable-success)'
: 'var(--color-signal-cable-idle)'
}`,
}}
/>
</div>
);
}

return (
<div
ref={ref}
className={cn('flex items-stretch min-w-[1.5rem]', className)}
aria-hidden
{...rest}
>
<div
className={cn('signal-cable', animated && 'signal-cable--animated')}
data-tone={tone}
/>
<div className="signal-arrow" data-tone={tone} />
</div>
);
},
);
58 changes: 58 additions & 0 deletions apps/ui/src/components/ui/DataTable.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';

import { DataTable } from './DataTable';
import { Pill } from './Pill';

const meta: Meta<typeof DataTable> = {
title: 'UI/DataTable',
component: DataTable as React.ComponentType<unknown>,
};

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

interface Row {
band: string;
energy: string;
target: string;
delta: string;
status: 'success' | 'warning' | 'error';
}

const rows: Row[] = [
{ band: 'Sub bass', energy: '-14.2 dB', target: '-12.0', delta: '-2.2', status: 'warning' },
{ band: 'Low mids', energy: '-9.1 dB', target: '-10.0', delta: '+0.9', status: 'success' },
{ band: 'Mids', energy: '-8.4 dB', target: '-8.0', delta: '-0.4', status: 'success' },
{ band: 'High mids', energy: '-11.3 dB', target: '-9.0', delta: '-2.3', status: 'warning' },
{ band: 'Highs', energy: '-15.7 dB', target: '-13.0', delta: '-2.7', status: 'error' },
];

export const Default: Story = {
render: () => (
<div className="w-[680px]">
<DataTable<Row>
data={rows}
columns={[
{ key: 'band', label: 'Band', textRole: 'item-title' },
{ key: 'energy', label: 'Energy', align: 'right', monospace: true, textRole: 'value' },
{ key: 'target', label: 'Target', align: 'right', monospace: true, textRole: 'meta' },
{ key: 'delta', label: 'Δ', align: 'right', monospace: true, textRole: 'value' },
{
key: 'status',
label: 'Status',
align: 'right',
render: (row) => (
<Pill
tone={row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success'}
size="xs"
>
{row.status}
</Pill>
),
},
]}
/>
</div>
),
};
108 changes: 108 additions & 0 deletions apps/ui/src/components/ui/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from 'react';

import {
formatDisplayText,
getTextRoleClassName,
type DisplayTextCase,
type TextRole,
} from '../../utils/displayText';
import { cn } from './cn';

export interface DataTableColumn<T> {
key: string;
label: string;
align?: 'left' | 'right';
monospace?: boolean;
displayCase?: DisplayTextCase;
textRole?: TextRole;
render?: (row: T) => React.ReactNode;
}

export interface DataTableProps<T> {
data: T[];
columns: DataTableColumn<T>[];
rowClassName?: (row: T, index: number) => string;
className?: string;
}

export function DataTable<T>({
data,
columns,
rowClassName,
className,
}: DataTableProps<T>) {
return (
<div
className={cn(
'overflow-x-auto rounded-sm border border-border-light',
className,
)}
>
<table className="w-full border-collapse">
<thead>
<tr className="bg-bg-card/75">
{columns.map((column) => (
<th
key={column.key}
data-text-role="eyebrow"
className={cn(
'px-3 py-2 font-normal',
getTextRoleClassName('eyebrow'),
column.align === 'right' ? 'text-right' : 'text-left',
)}
>
{formatDisplayText(column.label, 'eyebrow')}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr
key={rowIndex}
className={cn(
rowIndex % 2 === 0 ? 'bg-bg-surface-dark/95' : 'bg-bg-panel/70',
'border-t border-border-light/60',
rowClassName?.(row, rowIndex),
)}
>
{columns.map((column) => {
const rendered = column.render
? column.render(row)
: ((row as Record<string, unknown>)[column.key] as React.ReactNode);
const textRole = column.textRole ?? 'body';
const isPrimitive =
typeof rendered === 'string' || typeof rendered === 'number';
return (
<td
key={column.key}
className={cn(
'px-3 py-2 align-middle',
column.align === 'right' ? 'text-right' : 'text-left',
)}
>
{isPrimitive ? (
<span
data-text-role={textRole}
className={cn(
getTextRoleClassName(textRole),
column.monospace && 'tabular-nums',
)}
>
{typeof rendered === 'string'
? formatDisplayText(rendered, column.displayCase ?? 'none')
: rendered}
</span>
) : (
rendered
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
Loading
Loading