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
21 changes: 21 additions & 0 deletions app/gui/fe/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ export default defineConfig([
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// Catches user-facing strings that bypass i18n in screen-reader-only or
// tooltip attributes — these are invisible during visual review.
// `placeholder` is intentionally not listed: form placeholders in this
// app are numeric ("0", "0.00") or symbolic ("…") and locale-agnostic.
'no-restricted-syntax': ['error',
{
selector: "JSXAttribute[name.name=/^(aria-label|aria-description|aria-roledescription|alt|title)$/] > Literal[value!=''][value!=' ']",
message: "Hardcoded user-facing attribute string. Use t() — e.g. aria-label={t('namespace:key')}.",
},
{
selector: "JSXAttribute[name.name=/^(aria-label|aria-description|aria-roledescription|alt|title)$/] > JSXExpressionContainer > TemplateLiteral",
message: "User-facing attribute should use t() with interpolation, not a template literal that may contain hardcoded English. e.g. aria-label={t('foo', { count })}.",
},
],
},
},
{
// Test files use synthetic fixtures — exempt from the i18n attr rule.
files: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'],
rules: {
'no-restricted-syntax': 'off',
},
},
])
4 changes: 2 additions & 2 deletions app/gui/fe/src/components/EncryptionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,10 @@ function EncryptionCard({
<button
type="button"
onClick={(e) => { e.stopPropagation(); setDescriptionModalOpen(true); }}
aria-label={t('card.openDescription', { defaultValue: 'View full description' })}
aria-label={t('card.openDescription')}
className="mt-1 inline-flex items-center gap-1 text-[11px] text-[var(--link)] hover:text-[var(--link-hover)] focus-visible:text-[var(--link-hover)] rounded transition-colors duration-[var(--transition-fast)]"
>
{t('card.viewFull', { defaultValue: 'View full' })}
{t('card.viewFull')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Expand Down
14 changes: 10 additions & 4 deletions app/gui/fe/src/components/LayoutPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ interface LayoutPopoverProps {
const SIZE_OPTIONS: CardSize[] = ['small', 'medium', 'large'];
const COLUMN_OPTIONS: ColumnCount[] = [2, 3, 4];

const SIZE_LABEL_KEY: Record<CardSize, string> = {
small: 'layoutPopover.cardSizeSmall',
medium: 'layoutPopover.cardSizeMedium',
large: 'layoutPopover.cardSizeLarge',
};

export default function LayoutPopover({
viewMode,
cardSize,
Expand Down Expand Up @@ -139,8 +145,8 @@ export default function LayoutPopover({
? 'bg-[var(--accent-muted)] text-[var(--accent)]'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
title={`${size.charAt(0).toUpperCase() + size.slice(1)} cards`}
aria-label={`${size.charAt(0).toUpperCase() + size.slice(1)} cards`}
title={t(SIZE_LABEL_KEY[size])}
aria-label={t(SIZE_LABEL_KEY[size])}
aria-pressed={cardSize === size}
>
{size === 'small' && (
Expand Down Expand Up @@ -182,8 +188,8 @@ export default function LayoutPopover({
? 'bg-[var(--accent-muted)] text-[var(--accent)]'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
title={`${cols} columns`}
aria-label={`${cols} columns`}
title={t('layoutPopover.columnsCount', { count: cols })}
aria-label={t('layoutPopover.columnsCount', { count: cols })}
aria-pressed={columnCount === cols}
>
{cols === 2 && (
Expand Down
2 changes: 1 addition & 1 deletion app/gui/fe/src/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function ToastContainer({ toasts, onClose, queuedCount = 0, onDismissAll
{queuedCount > 0 && (
<div className="self-end inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium text-[var(--text-secondary)] bg-[var(--bg-elevated)] border border-[var(--border-subtle)] shadow-[var(--shadow-sm)]" aria-hidden="true">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] animate-pulse" />
{t('toast.queueOverflow', { count: queuedCount, defaultValue: '+{{count}} more' })}
{t('toast.queueOverflow', { count: queuedCount })}
</div>
)}
{toasts.length >= 2 && onDismissAll && (
Expand Down
12 changes: 10 additions & 2 deletions app/gui/fe/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"removeListing": "Remove Listing",
"remove": "Remove",
"viewBids": "View Bids",
"openDescription": "View full description",
"viewFull": "View full",
"insufficientBalance": "Insufficient balance",
"insufficientBalanceMinimum": "Insufficient balance (minimum 2 ADA)",
"insufficientBalanceButton": "Insufficient Balance",
Expand Down Expand Up @@ -315,7 +317,12 @@
"gridView": "Grid view",
"listView": "List view",
"cardSize": "Card size",
"columns": "Columns"
"columns": "Columns",
"cardSizeSmall": "Small cards",
"cardSizeMedium": "Medium cards",
"cardSizeLarge": "Large cards",
"columnsCount_one": "{{count}} column",
"columnsCount_other": "{{count}} columns"
},
"ui": {
"loading": "Loading",
Expand Down Expand Up @@ -385,7 +392,8 @@
"copyError": "Copy error to clipboard",
"dismiss": "Dismiss notification",
"dismissAll": "Dismiss all",
"dismissAllQueued": "Dismiss all ({{count}} queued)"
"dismissAllQueued": "Dismiss all ({{count}} queued)",
"queueOverflow": "+{{count}} more"
},
"categories": {
"text": "Text",
Expand Down
8 changes: 8 additions & 0 deletions app/gui/fe/src/locales/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"disconnect": "Disconnect",
"unfinishedListingTitle": "Unfinished Listing Found",
"unfinishedListingBody": "A file listing for \"{{filename}}\" was uploaded but the transaction was not completed. The file is still on Iagon — you can resume without re-uploading.",
"walletCreatedTitle": "Welcome to Veiled",
"walletCreatedBody": "Your wallet is ready. Fund it to start listing or bidding.",
"discard": "Discard",
"resumeListing": "Resume Listing",
"dismissTitle": "Dismiss",
Expand All @@ -55,6 +57,12 @@
"refreshTitle": "Refresh data (Ctrl+R)",
"refreshAria": "Refresh data",
"tabsAria": "Dashboard tabs",
"tabBadgeNewBids_one": "{{count}} new bid",
"tabBadgeNewBids_other": "{{count}} new bids",
"tabBadgeAcceptedBids_one": "{{count}} accepted bid ready to decrypt",
"tabBadgeAcceptedBids_other": "{{count}} accepted bids ready to decrypt",
"tabBadgePendingTx_one": "{{count}} pending transaction",
"tabBadgePendingTx_other": "{{count}} pending transactions",
"relistFromLibraryTitle": "Relist from Library",
"confirmFallback": "Confirm"
},
Expand Down
1 change: 1 addition & 0 deletions app/gui/fe/src/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@
"snarkSetup": "SNARK Setup",
"walletLabel": "Wallet",
"other": "Other",
"segmentTooltip": "{{label}}: {{size}} ({{percent}}%)",
"refresh": "Refresh",
"loading": "Loading...",
"imageCacheTitle": "Image Cache",
Expand Down
10 changes: 5 additions & 5 deletions app/gui/fe/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,8 @@ export default function Dashboard() {
const navState = location.state as { startTutorial?: string; justCreated?: boolean } | null
if (navState?.justCreated) {
toast.celebrate(
t('dashboard:shell.walletCreatedTitle', { defaultValue: 'Welcome to Veiled' }),
t('dashboard:shell.walletCreatedBody', { defaultValue: 'Your wallet is ready. Fund it to start listing or bidding.' }),
t('dashboard:shell.walletCreatedTitle'),
t('dashboard:shell.walletCreatedBody'),
)
navigate(location.pathname, { replace: true, state: null })
return
Expand Down Expand Up @@ -996,17 +996,17 @@ export default function Dashboard() {
* success (green) — "ready / done": accepted bids ready to decrypt
* accent / neutral — count-only, no implied urgency */}
{tab.id === 'my-sales' && effects.bidNotifications.unseenBidCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium bg-[var(--warning)] text-white rounded-full animate-pulse" aria-label={`${effects.bidNotifications.unseenBidCount} new bids`}>
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium bg-[var(--warning)] text-white rounded-full animate-pulse" aria-label={t('dashboard:shell.tabBadgeNewBids', { count: effects.bidNotifications.unseenBidCount })}>
{effects.bidNotifications.unseenBidCount}
</span>
)}
{tab.id === 'my-purchases' && effects.acceptedBidCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium bg-[var(--success)] text-white rounded-full" aria-label={`${effects.acceptedBidCount} accepted bids ready to decrypt`}>
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium bg-[var(--success)] text-white rounded-full" aria-label={t('dashboard:shell.tabBadgeAcceptedBids', { count: effects.acceptedBidCount })}>
{effects.acceptedBidCount}
</span>
)}
{tab.id === 'history' && effects.pendingTxCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium bg-[var(--warning)] text-white rounded-full" aria-label={`${effects.pendingTxCount} pending transactions`}>
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium bg-[var(--warning)] text-white rounded-full" aria-label={t('dashboard:shell.tabBadgePendingTx', { count: effects.pendingTxCount })}>
{effects.pendingTxCount}
</span>
)}
Expand Down
2 changes: 1 addition & 1 deletion app/gui/fe/src/pages/settings/StorageSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export default function StorageSection({
key={seg.label}
className="h-full transition-all duration-300"
style={{ width: `${pct}%`, backgroundColor: seg.color }}
title={`${seg.label}: ${formatBytes(seg.bytes)} (${pct.toFixed(1)}%)`}
title={t('storage.segmentTooltip', { label: seg.label, size: formatBytes(seg.bytes), percent: pct.toFixed(1) })}
/>
)
})}
Expand Down
13 changes: 11 additions & 2 deletions app/gui/fe/src/utils/formatAda.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import i18n from 'i18next';

// Reads from the i18next singleton so number grouping/decimal style follows
// the user's selected app language. Falls back to `undefined` (the runtime
// default) when i18next hasn't been initialized — keeps unit tests stable.
function activeLocale(): string | undefined {
return i18n.language || undefined;
}

/**
* Format a lovelace amount (number) as ADA with 2–6 decimal places.
* Always shows at least 2 decimals for visual consistency (e.g., 2_000_000 → "2.00").
*/
export function formatAda(lovelace: number): string {
const ada = lovelace / 1_000_000;
return ada.toLocaleString(undefined, {
return ada.toLocaleString(activeLocale(), {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
Expand All @@ -17,5 +26,5 @@ export function formatAda(lovelace: number): string {
export function formatAdaDisplay(lovelaceAmount: string | undefined): string {
if (!lovelaceAmount) return '...';
const ada = parseInt(lovelaceAmount) / 1_000_000;
return ada.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return ada.toLocaleString(activeLocale(), { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
10 changes: 8 additions & 2 deletions app/gui/fe/src/utils/formatDate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import i18n from 'i18next';

function activeLocale(): string | undefined {
return i18n.language || undefined;
}

/**
* Format an ISO date string as a short date: "Jan 15, 2026".
*/
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(activeLocale(), {
month: 'short',
day: 'numeric',
year: 'numeric',
Expand All @@ -17,7 +23,7 @@ export function formatDate(dateString: string): string {
export function formatDateTime(dateString?: string): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(activeLocale(), {
month: 'short',
day: 'numeric',
year: 'numeric',
Expand Down
12 changes: 6 additions & 6 deletions app/gui/fe/src/utils/time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ describe('formatRelativeTime', () => {
vi.useRealTimers();
});

it('returns "just now" for dates less than 60 seconds ago', () => {
it('returns "now" for dates less than 60 seconds ago', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-01T12:00:30Z'));
expect(formatRelativeTime('2025-06-01T12:00:00Z')).toBe('just now');
expect(formatRelativeTime('2025-06-01T12:00:00Z')).toBe('now');
});

it('returns "just now" for the current time', () => {
it('returns "now" for the current time', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-01T12:00:00Z'));
expect(formatRelativeTime('2025-06-01T12:00:00Z')).toBe('just now');
expect(formatRelativeTime('2025-06-01T12:00:00Z')).toBe('now');
});

it('returns minutes ago for dates less than 1 hour ago', () => {
Expand Down Expand Up @@ -54,10 +54,10 @@ describe('formatRelativeTime', () => {
expect(formatRelativeTime('2024-06-01T12:00:00Z')).toBe('2y ago');
});

it('returns "just now" for future dates', () => {
it('returns "now" for future dates', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-01T12:00:00Z'));
expect(formatRelativeTime('2025-06-01T13:00:00Z')).toBe('just now');
expect(formatRelativeTime('2025-06-01T13:00:00Z')).toBe('now');
});

it('handles boundary at 30 days (switches to months)', () => {
Expand Down
27 changes: 19 additions & 8 deletions app/gui/fe/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import i18n from 'i18next';

/**
* Format a date as a relative time string ("just now", "5m ago", "2d ago", etc.).
* Handles past dates only; future dates return "just now".
* Format a date as a relative time string. Output is locale-aware via
* Intl.RelativeTimeFormat — English narrow renders as "now", "5m ago",
* "2d ago" matching the prior hardcoded form; other locales get their
* proper CLDR forms. Past dates only; future dates collapse to "now".
*/
export function formatRelativeTime(dateString: string): string {
const seconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
const locale = i18n.language || 'en';

if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 2592000) return `${Math.floor(seconds / 86400)}d ago`;
if (seconds < 31536000) return `${Math.floor(seconds / 2592000)}mo ago`;
return `${Math.floor(seconds / 31536000)}y ago`;
// numeric:'auto' yields "now" for the under-a-minute bucket. Above that,
// numeric:'always' keeps "1m ago" / "1mo ago" / "1y ago" — without it,
// English collapses -1 month → "last mo." which loses the count.
if (seconds < 60) {
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }).format(0, 'second');
}
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' });
if (seconds < 3600) return rtf.format(-Math.floor(seconds / 60), 'minute');
if (seconds < 86400) return rtf.format(-Math.floor(seconds / 3600), 'hour');
if (seconds < 2592000) return rtf.format(-Math.floor(seconds / 86400), 'day');
if (seconds < 31536000) return rtf.format(-Math.floor(seconds / 2592000), 'month');
return rtf.format(-Math.floor(seconds / 31536000), 'year');
}
Loading