+
) : null
);
}, [tooltip]);
diff --git a/frontend/src/components/ProgressBar/ProgressBar.module.scss b/frontend/src/components/ProgressBar/ProgressBar.module.scss
index 3ea85aff..9f4cdcf7 100644
--- a/frontend/src/components/ProgressBar/ProgressBar.module.scss
+++ b/frontend/src/components/ProgressBar/ProgressBar.module.scss
@@ -10,7 +10,6 @@
flex-direction: column;
gap: sp.$spacing-gap;
width: 100%;
- gap: sp.$spacing-gap;
position: relative;
}
diff --git a/frontend/src/components/ProgressBar/ProgressBar.stories.tsx b/frontend/src/components/ProgressBar/ProgressBar.stories.tsx
index 0792ce3a..a2b64626 100644
--- a/frontend/src/components/ProgressBar/ProgressBar.stories.tsx
+++ b/frontend/src/components/ProgressBar/ProgressBar.stories.tsx
@@ -3,9 +3,10 @@ import { expect } from 'storybook/test';
import ProgressBar from './ProgressBar';
/**
- * `ProgressBar` shows `value`/`max` as a filled track (Radix `Progress`
- * under the hood). The `label` renders inside the fill once there's room,
- * otherwise it moves outside so it stays readable.
+ * `ProgressBar` shows `value`/`max` as a filled track (Tamagui `Progress`
+ * under the hood, unstyled so `ProgressBar.module.scss` stays the sole
+ * source of visual truth). The `label` renders inside the fill once
+ * there's room, otherwise it moves outside so it stays readable.
*/
const meta: Meta = {
title: 'Shared/ProgressBar',
diff --git a/frontend/src/components/ProgressBar/ProgressBar.test.tsx b/frontend/src/components/ProgressBar/ProgressBar.test.tsx
index 686d8745..639a412f 100644
--- a/frontend/src/components/ProgressBar/ProgressBar.test.tsx
+++ b/frontend/src/components/ProgressBar/ProgressBar.test.tsx
@@ -1,10 +1,23 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
+import { TamaguiProvider } from 'tamagui';
import ProgressBar from './ProgressBar';
+import tamaguiConfig from '../../../tamagui.config';
+
+// ProgressBar's underlying Tamagui `Progress` (#580) needs a TamaguiProvider
+// ancestor - unlike Radix's Progress.Root, it isn't usable standalone. The
+// app root (src/main.tsx) provides this in production; tests need their own.
+function renderProgressBar(ui: React.ReactElement) {
+ return render(
+
+ {ui}
+
+ );
+}
describe('ProgressBar', () => {
it('renders with role="progressbar" and correct aria-value attributes', () => {
- render();
+ renderProgressBar();
const bar = screen.getByRole('progressbar');
expect(bar).toBeInTheDocument();
@@ -14,42 +27,44 @@ describe('ProgressBar', () => {
});
it('exposes label as aria-label on the progressbar', () => {
- render();
+ renderProgressBar();
const bar = screen.getByRole('progressbar', { name: '50/100 XP' });
expect(bar).toBeInTheDocument();
});
it('renders progress bar with default values', () => {
- const { container } = render();
+ renderProgressBar();
- const progressFill = container.querySelector('[style*="width"]');
- expect(progressFill).toBeInTheDocument();
+ const bar = screen.getByRole('progressbar');
+ expect(bar).toBeInTheDocument();
+ expect(bar).toHaveAttribute('aria-valuemin', '0');
+ expect(bar).toHaveAttribute('aria-valuemax', '100');
});
it('calculates percentage correctly', () => {
- const { container } = render();
+ renderProgressBar();
- const progressFill = container.querySelector('[style*="width"]');
- expect(progressFill).toHaveStyle({ width: '50%' });
+ const bar = screen.getByRole('progressbar');
+ expect(bar).toHaveAttribute('aria-valuenow', '50');
});
it('caps percentage at 100%', () => {
- const { container } = render();
+ renderProgressBar();
- const progressFill = container.querySelector('[style*="width"]');
- expect(progressFill).toHaveStyle({ width: '100%' });
+ const bar = screen.getByRole('progressbar');
+ expect(bar).toHaveAttribute('aria-valuenow', '100');
});
it('handles zero values correctly', () => {
- const { container } = render();
+ renderProgressBar();
- const progressFill = container.querySelector('[style*="width"]');
- expect(progressFill).toHaveStyle({ width: '0%' });
+ const bar = screen.getByRole('progressbar');
+ expect(bar).toHaveAttribute('aria-valuenow', '0');
});
it('renders label when provided', () => {
- render();
+ renderProgressBar();
// Label might be inside or outside the progress bar, use getAllByText since there's a hidden copy
const labels = screen.getAllByText('50/100 XP');
@@ -57,28 +72,28 @@ describe('ProgressBar', () => {
});
it('applies custom color class', () => {
- const { container } = render();
+ const { container } = renderProgressBar();
const progressFill = container.querySelector('[class*="warning"]');
expect(progressFill).not.toBeNull();
});
it('applies paused class when paused is true', () => {
- const { container } = render();
+ const { container } = renderProgressBar();
const progressFill = container.querySelector('[class*="paused"]');
expect(progressFill).toBeInTheDocument();
});
it('does not apply paused class when paused is false', () => {
- const { container } = render();
+ const { container } = renderProgressBar();
const progressFill = container.querySelector('[class*="paused"]');
expect(progressFill).toBeNull();
});
it('renders hidden label for measurement', () => {
- render();
+ renderProgressBar();
// There should be a hidden element used for measuring
const hiddenLabel = screen.getAllByText('Test Label').find(el =>
@@ -88,9 +103,10 @@ describe('ProgressBar', () => {
});
it('handles different max values correctly', () => {
- const { container } = render();
+ renderProgressBar();
- const progressFill = container.querySelector('[style*="width"]');
- expect(progressFill).toHaveStyle({ width: '50%' });
+ const bar = screen.getByRole('progressbar');
+ expect(bar).toHaveAttribute('aria-valuenow', '25');
+ expect(bar).toHaveAttribute('aria-valuemax', '50');
});
});
diff --git a/frontend/src/components/ProgressBar/ProgressBar.tsx b/frontend/src/components/ProgressBar/ProgressBar.tsx
index 2fdddfca..8cfe794e 100644
--- a/frontend/src/components/ProgressBar/ProgressBar.tsx
+++ b/frontend/src/components/ProgressBar/ProgressBar.tsx
@@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect } from "react";
-import * as Progress from "@radix-ui/react-progress";
+import { Progress } from "tamagui";
import styles from "./ProgressBar.module.scss";
import type { TimerStatus } from "../../types";
@@ -41,7 +41,7 @@ const ProgressBar = ({
const progressClass = [
styles.progressBarFill,
styles[color] || styles.default,
- paused ? styles.paused : ""
+ paused ? styles.paused : "",
].join(" ");
// Border-only variant of the same colour, applied to the track rather than
@@ -75,23 +75,25 @@ const ProgressBar = ({
{label}
)}
-
-
- {label && showInsideLabel && (
- {label}
- )}
-
-
+
+
+ {label && showInsideLabel && (
+ {label}
+ )}
+
+
+
);
};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index c185c519..6c3bdb5a 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -4,9 +4,11 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { TamaguiProvider } from 'tamagui';
import App from './App';
import { AuthProvider } from './context/AuthContext';
+import tamaguiConfig from '../tamagui.config';
import './styles/main.scss';
function canRenderReactQueryDevtools(): boolean {
@@ -39,12 +41,19 @@ const queryClient = new QueryClient({
const root = createRoot(document.getElementById('root')!);
root.render(
-
-
-
-
- {canRenderReactQueryDevtools() && (
-
- )}
-
+ // `defaultTheme` is required by TamaguiProvider's types; "light" is a
+ // placeholder until the app makes an actual light/dark theming decision -
+ // components ported so far (ProgressBar, #580) opt out of Tamagui's
+ // theme-driven styling entirely (`unstyled`), so this doesn't yet affect
+ // anything rendered.
+
+
+
+
+
+ {canRenderReactQueryDevtools() && (
+
+ )}
+
+
);
diff --git a/frontend/tamagui.config.ts b/frontend/tamagui.config.ts
new file mode 100644
index 00000000..bfd10b2b
--- /dev/null
+++ b/frontend/tamagui.config.ts
@@ -0,0 +1,39 @@
+// Tamagui config for the RN/Expo migration (epic #578/#591, decided in
+// #591). Built from @tamagui/config's default web preset; token values are
+// Tamagui's stock ones for now, not the app's own SCSS scale (see
+// src/styles/semantic/_colors.scss, _typography.scss). Each component
+// ported onto Tamagui is expected to reconcile its own visual output
+// against the SCSS original (see ProgressBar.tsx for the first instance);
+// a shared token source of truth is tracked separately (#590).
+import { config as defaultConfig } from '@tamagui/config';
+import { createTamagui } from 'tamagui';
+
+// @tamagui/config's stock `media` breakpoints (xs: 660, sm: 800, md: 1020...)
+// don't match this app's own breakpoint scale (src/styles/base/_variables.scss:
+// sm: 576, md: 768...) used by the SCSS `respond-to()` mixin. Overriding here
+// so components ported from SCSS produce the same breakpoint behaviour as
+// their originals, not Tamagui's defaults.
+const config = createTamagui({
+ ...defaultConfig,
+ media: {
+ ...defaultConfig.media,
+ sm: { maxWidth: 576 },
+ md: { maxWidth: 768 },
+ // SCSS `respond-to($bp)` defaults to a min-width ("up") query, but
+ // `respond-to($bp, down)` uses the *same* breakpoint name for a
+ // max-width query instead. One Tamagui media key can only ever mean
+ // one direction, so the two call-site directions for "md" can't share
+ // a key the way the SCSS mixin lets them - `mdUp` is a second key for
+ // the "up" case, needed by components with a min-width breakpoint.
+ mdUp: { minWidth: 768 },
+ },
+});
+
+export type Conf = typeof config;
+
+declare module 'tamagui' {
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface TamaguiCustomConfig extends Conf {}
+}
+
+export default config;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 620c603d..13f8d5f5 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -6,6 +6,7 @@ import path from 'node:path'
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
import { playwright } from '@vitest/browser-playwright'
import { viteStaticCopy } from 'vite-plugin-static-copy'
+import { tamaguiPlugin } from '@tamagui/vite-plugin'
const dirname = fileURLToPath(new URL('.', import.meta.url))
@@ -41,6 +42,16 @@ export default defineConfig(() => {
},
],
}),
+ // Activates Tamagui's compiler (extracts static styles at build time
+ // instead of shipping a runtime style engine) for the RN/Expo
+ // migration (#578/#591, decided in #591; first landed by #580).
+ // Without this plugin, `tamagui`/`@tamagui/core` still work but every
+ // style is computed at runtime instead - a materially worse bundle/
+ // perf profile than what #629's PoC measured.
+ tamaguiPlugin({
+ config: './tamagui.config.ts',
+ components: ['tamagui'],
+ }),
],
base: '/',
// maplibre-gl loads its own worker via a dynamically-constructed URL;