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
38 changes: 38 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/design-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## Design-system checklist

Use this checklist when your PR changes design tokens, shared components in `packages/ui`, or introduces a new component to the design system.

### API compatibility

- [ ] Component API follows existing conventions: `className` forwarding, `cva` variants, `...props` spread last
- [ ] No breaking API changes without a **migration note** in the PR description (see changelog below)
- [ ] Subpath export added to `packages/ui/package.json` if this is a new component

### Theme & visual

- [ ] Verified in **light theme** and **dark theme**
- [ ] Verified at **mobile** viewport width (360px)
- [ ] No new raw hex colours, arbitrary font sizes, or arbitrary radius values — `bun run check:tokens` passes clean

### Accessibility

- [ ] Interactive elements are operable by **keyboard alone** (Tab, Enter/Escape, arrow keys where applicable)
- [ ] Correct ARIA roles and states are exposed (use Base UI primitives where possible)
- [ ] Colour is never the only signal — shape, label, or position accompanies any colour-coded state

### Testing

- [ ] Non-trivial logic includes a unit test (`*.test.tsx` alongside the component)
- [ ] Component added to the gallery (`apps/web/src/features/gallery/components/gallery-page.tsx`)
- [ ] Visual regression snapshots updated (`bun run test:e2e -- design-system-visual --update-snapshots`) and reviewed in the diff

### Changelog

- [ ] Entry added to [`packages/ui/CHANGELOG.md`](../../packages/ui/CHANGELOG.md) under the correct section (`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, or `Accessibility`)
- [ ] Breaking changes include a `### Migration note` subsection

### CI

- [ ] `bun run typecheck --filter=@workspace/ui` passes
- [ ] `bun run lint --filter=@workspace/ui` passes
- [ ] `bun run test:e2e -- design-system-visual` passes (or snapshots intentionally updated)
43 changes: 43 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,11 @@ then review the diffs in `e2e/design-system-visual.spec.ts-snapshots/` in your P

- give reviewers one place to see every variant of a component at once instead of hunting through feature pages for one that happens to use the state you changed
- act as the fixed, deterministic target for the visual regression suite above
- provide an **RTL toggle** (top-right corner) that switches the page between left-to-right and right-to-left layout, making directional-CSS regressions visible at a glance

When you add a new component to `packages/ui`, add it to the gallery in the same PR — see [`packages/ui/CONTRIBUTING.md`](./packages/ui/CONTRIBUTING.md) for the full checklist.

<<<<<<< HEAD
## Separators and dividers

Borders are the cheapest way to make a layout look organised and the fastest way to make it look noisy. Linear's calm hierarchy comes from *space* and *surface* doing the grouping, with lines reserved for the few places where a relationship genuinely needs marking. [`packages/ui/src/components/separator.tsx`](./packages/ui/src/components/separator.tsx) (DS-080) exists so those few places look the same everywhere.
Expand All @@ -140,6 +142,47 @@ Borders are the cheapest way to make a layout look organised and the fastest way
**Labelled dividers.** `<Divider label="or" />` names a break instead of just drawing one — grouped form sections, "or" between auth methods, date breaks in a feed. The rules are decorative and the label is ordinary text, so assistive technology reads the words rather than the geometry. Label contrast comes from `text-text-secondary`, which holds up in light, dark, and high-contrast themes.

Existing page borders were left alone in DS-080; migrate them opportunistically when you're already touching the markup.
=======
## Right-to-left (RTL) support

### Logical CSS properties

The design system uses **logical CSS properties** instead of physical directional properties wherever the behaviour is semantic (i.e. describes content flow rather than a fixed visual direction):

| Physical | Logical equivalent |
|---|---|
| `left` / `right` | `inset-inline-start` / `inset-inline-end` |
| `margin-left` / `margin-right` | `margin-inline-start` / `margin-inline-end` |
| `padding-left` / `padding-right` | `padding-inline-start` / `padding-inline-end` |
| `border-left` / `border-right` | `border-inline-start` / `border-inline-end` |
| `text-left` / `text-right` | `text-start` / `text-end` |
| `translate-x` | Use `inset-inline-start` + `translate` or percent-based |

Tailwind v4 provides logical utility classes — `ms-*`, `me-*`, `ps-*`, `pe-*`, `border-s-*`, `border-e-*`, `text-start`, `text-end`, `inset-inline-start-*`, `inset-inline-end-*` — which map to the correct physical side at runtime based on the `dir` attribute.

### Justified physical directions

Some directional properties are intentionally kept physical:

- **Numeric financial values**: columns containing prices, sizes, volumes, APY, and other numbers use `text-right` (not `text-end`) because financial convention requires right-aligned digits in all locales, regardless of script direction.
- **Chart time flow**: time-series charts (candlesticks, line charts) render left-to-right regardless of page direction, per financial-market convention.
- **Data-attributed sides**: components that accept a physical `side` prop (e.g. `sheet` with `side="left"` or `side="right"`, tooltip arrow positioning for `data-[side=left]`/`data-[side=right]`) remain physical because they refer to the screen-relative position, not the content-flow direction. The logical `inline-start` / `inline-end` variants are also supported for direction-aware placement.

### DirectionProvider

A `DirectionProvider` (in `apps/web/src/ui/direction-provider.tsx`) persists the chosen direction to `localStorage` under `so4-direction` and sets the `dir` attribute on `<html>`. It wraps the app in the provider tree alongside `ThemeProvider`.

### RTL gallery fixture

The component gallery at `/gallery` includes an RTL/LTR toggle button (top-right corner). Use it during development and review to verify that:

- Dialogs, menus, fields, tabs, breadcrumbs, tables, and navigation remain usable in RTL
- Leading/trailing icons and controls mirror correctly
- Focus order follows DOM order and remains logical
- Numeric financial values preserve readable direction

The visual regression suite (`e2e/design-system-visual.spec.ts`) captures gallery screenshots in both directions across all themes and viewports. Run `bun run test:e2e -- design-system-visual` to verify, or `bun run test:e2e -- design-system-visual --update-snapshots` to update baselines after an intentional change.
>>>>>>> e242e90 (Audit layouts for RTL and logical CSS properties)

## Audit history

Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/providers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ErrorInfo, ReactNode } from "react"
import { useWalletStore } from "@/features/wallet/store/wallet-store"
import { NETWORK } from "@/app/config/network"
import { ThemeProvider } from "@/ui/theme-provider"
import { DirectionProvider } from "@/ui/direction-provider"
import { ErrorPage } from "@/app/error-page"
import { validateIndexerConfig } from "@/app/config/indexer"
import { useIndexerInvalidation } from "@/lib/graphql/use-indexer-invalidation"
Expand Down Expand Up @@ -149,7 +150,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
<ErrorBoundary>
<QueryProvider>
<WalletProvider>
<ThemeProvider>{children}</ThemeProvider>
<ThemeProvider>
<DirectionProvider>{children}</DirectionProvider>
</ThemeProvider>
</WalletProvider>
</QueryProvider>
</ErrorBoundary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export function DiscoverTab() {
</FilterButton>
</div>

<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="ms-auto flex items-center gap-1 text-xs text-muted-foreground">
<span>Sort</span>
<SortButton active={sort === "apy"} onClick={() => setSort("apy")}>
APY {sort === "apy" && "↓"}
Expand Down Expand Up @@ -247,7 +247,7 @@ export function DiscoverTab() {
<span className="h-2.5 w-2.5 rounded-full bg-amber-400/70" />
Short token
</div>
<p className="ml-auto text-11 text-muted-foreground">
<p className="ms-auto text-11 text-muted-foreground">
APY based on trailing 30-day performance
</p>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@ export function DistributionsTable({ distributions, onClaim }: DistributionsTabl
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/25 text-left">
<tr className="border-b border-border bg-muted/25 text-start">
<th className="px-5 py-3 font-medium text-muted-foreground">Epoch</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Date</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Amount</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Token</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Status</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Tx</th>
<th className="px-5 py-3 text-end font-medium text-muted-foreground">Tx</th>
</tr>
</thead>
<tbody>
Expand Down Expand Up @@ -124,7 +124,7 @@ export function DistributionsTable({ distributions, onClaim }: DistributionsTabl
<StatusBadge status={row.status} />
)}
</td>
<td className="px-5 py-3.5 text-right">
<td className="px-5 py-3.5 text-end">
{row.txHash ? (
<span className="font-mono text-muted-foreground">
{row.txHash.slice(0, 8)}…
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function LoadingRows() {
<div key={i} className="flex items-center gap-4 px-4 py-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-28" />
<Skeleton className="ml-auto h-4 w-16" />
<Skeleton className="ms-auto h-4 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-14" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function RewardsBar() {
isLoading={isLoading}
/>

<div className="ml-auto">
<div className="ms-auto">
<LoadingButton
variant="outline"
size="lg"
Expand Down
50 changes: 31 additions & 19 deletions apps/web/src/features/gallery/components/gallery-page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react"
import { useDirection } from "@/ui/direction-provider"
import { AppShell } from "@workspace/ui/components/app-shell"
import { Avatar, AvatarGroup } from "@workspace/ui/components/avatar"
import { Badge } from "@workspace/ui/components/badge"
Expand Down Expand Up @@ -48,30 +49,41 @@ function Section({ title, children }: { title: string; children: React.ReactNode
* Just render every shipped variant so a regression is visible on sight.
*/
export function GalleryPage() {
const { direction, setDirection } = useDirection()
const [sliderValue, setSliderValue] = useState<Array<number>>([40])
const [announceCount, setAnnounceCount] = useState(0)

return (
<main className="mx-auto max-w-4xl space-y-10 p-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Component Gallery</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every packages/ui primitive, all variants. See{" "}
<a
href="https://github.com/SO4-Markets/interface/blob/main/DESIGN.md"
className="text-primary underline underline-offset-2"
>
DESIGN.md
</a>{" "}
and{" "}
<a
href="https://github.com/SO4-Markets/interface/blob/main/packages/ui/CONTRIBUTING.md"
className="text-primary underline underline-offset-2"
>
packages/ui/CONTRIBUTING.md
</a>{" "}
for how to add to this page.
</p>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Component Gallery</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every packages/ui primitive, all variants. See{" "}
<a
href="https://github.com/SO4-Markets/interface/blob/main/DESIGN.md"
className="text-primary underline underline-offset-2"
>
DESIGN.md
</a>{" "}
and{" "}
<a
href="https://github.com/SO4-Markets/interface/blob/main/packages/ui/CONTRIBUTING.md"
className="text-primary underline underline-offset-2"
>
packages/ui/CONTRIBUTING.md
</a>{" "}
for how to add to this page.
</p>
</div>
<button
type="button"
onClick={() => setDirection(direction === "ltr" ? "rtl" : "ltr")}
className="shrink-0 rounded-md border border-border bg-card px-3 py-1.5 text-13 font-medium text-foreground transition-colors hover:bg-muted"
aria-label={`Switch to ${direction === "ltr" ? "right-to-left" : "left-to-right"} layout`}
>
{direction === "ltr" ? "LTR" : "RTL"}
</button>
</div>

<Section title="Button">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/pools/components/gm-pool-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function ValueCell({
className?: string
}) {
if (isLoading) {
return <Skeleton className="ml-auto h-4 w-20 max-w-full" />
return <Skeleton className="ms-auto h-4 w-20 max-w-full" />
}

return (
Expand Down Expand Up @@ -207,7 +207,7 @@ export function GmPoolRow({ market, variant, onMetricsChange }: GmPoolRowProps)
<td className="px-4 py-4 text-right font-mono text-sm">
<ValueCell value={userGmLabel} title={userGmTitle} isLoading={isLoading} />
</td>
<td className="px-5 py-4 text-right">
<td className="px-5 py-4 text-end">
<PoolActions
hasWallet={!!address && isConnected}
hasUserGm={hasUserGm}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/pools/components/pool-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function PoolActions({
</div>

{pendingTx ? (
<div className="ml-auto max-w-56 rounded-md border border-primary/20 bg-primary/5 p-2 text-left text-11 leading-relaxed text-muted-foreground">
<div className="ms-auto max-w-56 rounded-md border border-primary/20 bg-primary/5 p-2 text-start text-11 leading-relaxed text-muted-foreground">
<p className="font-medium text-foreground">
{pendingTx.mode === "deposit" ? "Deposit pending" : "Withdrawal pending"}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ export function DistributionsTab() {
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/25 text-left">
<tr className="border-b border-border bg-muted/25 text-start">
<th className="px-5 py-3 font-medium text-muted-foreground">Epoch</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Date</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Amount</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Token</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">USD value</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Action</th>
<th className="px-5 py-3 text-end font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
Expand All @@ -118,7 +118,7 @@ export function DistributionsTab() {
<td className="px-5 py-3.5 text-right font-mono">{formatToken(d.amount, d.token)}</td>
<td className="px-5 py-3.5 font-mono">{d.token}</td>
<td className="px-5 py-3.5 text-right font-mono">{formatUsd(d.amountUsd)}</td>
<td className="px-5 py-3.5 text-right">
<td className="px-5 py-3.5 text-end">
<Button
size="xs"
disabled={claiming === d.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ function DistributionsHistory() {
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/25 text-left">
<tr className="border-b border-border bg-muted/25 text-start">
<th className="px-5 py-3 font-medium text-muted-foreground">Epoch</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Date</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Amount</th>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/trade/components/TradePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function TradePage() {
</div>

{/* ── Right: Trade Panel ─────────────────────────────────────── */}
<div className="w-full shrink-0 overflow-x-hidden overflow-y-auto border-t border-border lg:border-t-0 lg:border-l lg:w-80">
<div className="w-full shrink-0 overflow-x-hidden overflow-y-auto border-t border-border lg:border-t-0 lg:border-inline-start lg:w-80">
<TradePanel trade={trade} />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function MarketRow({
role="option"
aria-selected={isActive}
tabIndex={-1}
className={`flex w-full cursor-pointer items-center justify-between gap-4 rounded px-3 py-2 text-left text-sm transition-colors ${
className={`flex w-full cursor-pointer items-center justify-between gap-4 rounded px-3 py-2 text-start text-sm transition-colors ${
isActive ? "bg-accent/60" : "hover:bg-accent"
}`}
onClick={onSelect}
Expand Down Expand Up @@ -228,7 +228,7 @@ export function MarketSelector({ symbol, onSelect }: Props) {
)}

{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-64 rounded-md border border-border bg-popover shadow-lg">
<div className="absolute inset-inline-start-0 top-full z-50 mt-1 w-64 rounded-md border border-border bg-popover shadow-lg">
<div className="p-2">
<label htmlFor={`${comboboxId}-input`} className="sr-only" id={`${comboboxId}-label`}>
Search markets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function MarketSelector({ markets, activeMarketId, onSelect }: Props) {
</button>

{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover shadow-lg">
<div className="absolute inset-inline-start-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover shadow-lg">
<div className="p-2">
<input
autoFocus
Expand All @@ -93,7 +93,7 @@ export function MarketSelector({ markets, activeMarketId, onSelect }: Props) {
setOpen(false)
setQuery("")
}}
className={`flex w-full rounded px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
className={`flex w-full rounded px-3 py-2 text-start text-sm transition-colors hover:bg-accent ${
market.id === activeMarketId ? "bg-accent/60 font-medium" : ""
}`}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ export function ClosePositionDialog({ position, open, onClose, onConfirm }: Prop
placeholder="0.00"
value={partialUsd}
onChange={(e) => setPartialUsd(e.target.value)}
className="pr-14 font-mono text-sm"
className="pe-14 font-mono text-sm"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
<span className="absolute inset-inline-end-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
Expand Down
Loading
Loading