Compare commits
No commits in common. "f92b688098eb6564398dcc99aedc22b77ba1b0b1" and "5dd0a182217c1c4d5c2c346f53d53c6a0d526134" have entirely different histories.
f92b688098
...
5dd0a18221
5
.npmrc
5
.npmrc
@ -1,6 +1,5 @@
|
|||||||
# Remove repo-level GITEA_NPM_TOKEN interpolation
|
@bytelyst:registry=http://${GITEA_NPM_HOST:-localhost}:3300/api/packages/ByteLyst/npm/
|
||||||
# Gitea registry auth should live in user-level ~/.npmrc or CI secrets
|
//localhost:3300/api/packages/ByteLyst/npm/:_authToken=${GITEA_NPM_TOKEN}
|
||||||
# Only when BYTELYST_PACKAGE_SOURCE=gitea is explicitly used
|
|
||||||
strict-ssl=false
|
strict-ssl=false
|
||||||
link-workspace-packages=true
|
link-workspace-packages=true
|
||||||
prefer-workspace-packages=true
|
prefer-workspace-packages=true
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const PACKAGE_SCOPE = '@bytelyst/';
|
|
||||||
const PACKAGE_SOURCE = process.env.BYTELYST_PACKAGE_SOURCE || 'common-plat';
|
|
||||||
const DEFAULT_COMMON_PLAT_ROOT = path.resolve(__dirname, '..', 'learning_ai', 'learning_ai_common_plat');
|
|
||||||
const LEGACY_COMMON_PLAT_ROOT = path.resolve(__dirname, '..', 'learning_ai_common_plat');
|
|
||||||
const COMMON_PLAT_ROOT =
|
|
||||||
process.env.BYTELYST_COMMON_PLAT_ROOT ||
|
|
||||||
(fs.existsSync(DEFAULT_COMMON_PLAT_ROOT) ? DEFAULT_COMMON_PLAT_ROOT : LEGACY_COMMON_PLAT_ROOT);
|
|
||||||
const COMMON_PLAT_PACKAGES_ROOT = path.join(COMMON_PLAT_ROOT, 'packages');
|
|
||||||
const VERSION_CACHE = new Map();
|
|
||||||
let loggedSource = false;
|
|
||||||
|
|
||||||
function packageDirFor(name) {
|
|
||||||
return name.startsWith(PACKAGE_SCOPE) ? name.slice(PACKAGE_SCOPE.length) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathIfPackageExists(rootDir, name) {
|
|
||||||
const packageDir = packageDirFor(name);
|
|
||||||
if (!packageDir) return null;
|
|
||||||
|
|
||||||
const candidate = path.join(rootDir, packageDir);
|
|
||||||
return fs.existsSync(path.join(candidate, 'package.json')) ? candidate : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPackageVersion(packagePath) {
|
|
||||||
if (VERSION_CACHE.has(packagePath)) {
|
|
||||||
return VERSION_CACHE.get(packagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const packageJson = JSON.parse(fs.readFileSync(path.join(packagePath, 'package.json'), 'utf8'));
|
|
||||||
const version = packageJson.version || null;
|
|
||||||
VERSION_CACHE.set(packagePath, version);
|
|
||||||
return version;
|
|
||||||
} catch {
|
|
||||||
VERSION_CACHE.set(packagePath, null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSpecifier(name) {
|
|
||||||
if (!name.startsWith(PACKAGE_SCOPE)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonPlatPath = pathIfPackageExists(COMMON_PLAT_PACKAGES_ROOT, name);
|
|
||||||
|
|
||||||
if (PACKAGE_SOURCE === 'common-plat') {
|
|
||||||
return commonPlatPath ? 'workspace:*' : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PACKAGE_SOURCE === 'gitea') {
|
|
||||||
return commonPlatPath ? readPackageVersion(commonPlatPath) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return commonPlatPath ? 'workspace:*' : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewriteDependencySet(dependencies = {}) {
|
|
||||||
for (const dependencyName of Object.keys(dependencies)) {
|
|
||||||
const rewrittenSpecifier = resolveSpecifier(dependencyName);
|
|
||||||
if (rewrittenSpecifier) {
|
|
||||||
dependencies[dependencyName] = rewrittenSpecifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logSourceOnce() {
|
|
||||||
if (loggedSource) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loggedSource = true;
|
|
||||||
process.stderr.write(
|
|
||||||
`[bytelyst] pnpm package source=${PACKAGE_SOURCE} commonPlatRoot=${COMMON_PLAT_ROOT}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
hooks: {
|
|
||||||
readPackage(packageJson) {
|
|
||||||
logSourceOnce();
|
|
||||||
if (packageJson.name?.startsWith(PACKAGE_SCOPE)) {
|
|
||||||
return packageJson;
|
|
||||||
}
|
|
||||||
|
|
||||||
rewriteDependencySet(packageJson.dependencies);
|
|
||||||
rewriteDependencySet(packageJson.devDependencies);
|
|
||||||
rewriteDependencySet(packageJson.optionalDependencies);
|
|
||||||
return packageJson;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -17,8 +17,8 @@
|
|||||||
| TODO | Priority | Phase | File(s) | Summary |
|
| TODO | Priority | Phase | File(s) | Summary |
|
||||||
|------|----------|-------|---------|---------|
|
|------|----------|-------|---------|---------|
|
||||||
| **TODO-001** | medium | 0 | `web/src/app/providers.tsx` | Kill switch maintenance banner — create `<MaintenanceBanner />`, set React state when `checkKillSwitch()` returns `disabled=true`, disable timer creation buttons |
|
| **TODO-001** | medium | 0 | `web/src/app/providers.tsx` | Kill switch maintenance banner — create `<MaintenanceBanner />`, set React state when `checkKillSwitch()` returns `disabled=true`, disable timer creation buttons |
|
||||||
| ✅ **TODO-002** | medium | 0 | `web/src/app/settings/page.tsx` | ~~Wire feedback button into settings page using `feedbackClient`~~ — settings feedback form submits through `@bytelyst/feedback-client`; verification blocked locally by missing `GITEA_NPM_TOKEN` for private `@bytelyst/*` install (d38b974) |
|
| **TODO-002** | medium | 0 | `web/src/app/(app)/settings/page.tsx` (create) | Wire feedback button into settings page (or floating FAB) using `feedbackClient` from `web/src/lib/feedback.ts` |
|
||||||
| ✅ **TODO-003** | medium | 0 | `web/src/components/CreateTimerModal.tsx`, `web/src/components/AlarmOverlay.tsx` | ~~Apply `@bytelyst/accessibility` focus trap + screen reader announcements~~ — shared helpers added in common-plat `42f60cb`, consumed by ChronoMind modals (bfc4670) |
|
| **TODO-003** | medium | 0 | `web/src/components/CreateTimerModal.tsx`, `web/src/components/AlarmOverlay.tsx` | Apply `@bytelyst/accessibility` focus trap + screen reader announcements. Ensure `--cm-*` color tokens meet WCAG AA contrast |
|
||||||
| ✅ **TODO-004** | medium | A.1 | `backend/src/modules/routines/routes.ts` | ~~Clone template~~ — templates now cloned via `crypto.randomUUID()`, original stays reusable (0e7c1ae) |
|
| ✅ **TODO-004** | medium | A.1 | `backend/src/modules/routines/routes.ts` | ~~Clone template~~ — templates now cloned via `crypto.randomUUID()`, original stays reusable (0e7c1ae) |
|
||||||
| ✅ **TODO-005** | high | A.4 | `backend/src/lib/ai-context.ts` | ~~Wire LLM~~ — dual-path: extraction-service → Ollama `/api/generate` (5s timeout, feature-gated) (229ce4f) |
|
| ✅ **TODO-005** | high | A.4 | `backend/src/lib/ai-context.ts` | ~~Wire LLM~~ — dual-path: extraction-service → Ollama `/api/generate` (5s timeout, feature-gated) (229ce4f) |
|
||||||
| ✅ **TODO-006** | low | A.4 | `web/src/lib/context-messages.ts` | ~~Centralize backend URL~~ — uses `getBackendBaseURL()` from `product-config.ts` (5dafcc2) |
|
| ✅ **TODO-006** | low | A.4 | `web/src/lib/context-messages.ts` | ~~Centralize backend URL~~ — uses `getBackendBaseURL()` from `product-config.ts` (5dafcc2) |
|
||||||
@ -64,7 +64,7 @@
|
|||||||
| `@bytelyst/design-tokens` | ✅ Used | `--cm-*` CSS props in globals.css |
|
| `@bytelyst/design-tokens` | ✅ Used | `--cm-*` CSS props in globals.css |
|
||||||
| `@bytelyst/ui` | ✅ Used | Imported in providers.tsx |
|
| `@bytelyst/ui` | ✅ Used | Imported in providers.tsx |
|
||||||
| `@bytelyst/kill-switch-client` | ❌ **MISSING** | Every other product has this — critical for prod safety |
|
| `@bytelyst/kill-switch-client` | ❌ **MISSING** | Every other product has this — critical for prod safety |
|
||||||
| `@bytelyst/feedback-client` | ✅ Used | Settings feedback form submits via shared SDK |
|
| `@bytelyst/feedback-client` | ❌ **MISSING** | No in-app feedback mechanism |
|
||||||
| `@bytelyst/event-store` | ❌ **MISSING** | Domain events not persisted — needed for webhooks + audit |
|
| `@bytelyst/event-store` | ❌ **MISSING** | Domain events not persisted — needed for webhooks + audit |
|
||||||
| `@bytelyst/webhook-dispatch` | ❌ **MISSING** | Backend uses hand-rolled dispatcher instead of shared package |
|
| `@bytelyst/webhook-dispatch` | ❌ **MISSING** | Backend uses hand-rolled dispatcher instead of shared package |
|
||||||
| `@bytelyst/accessibility` | ❌ **MISSING** | Shared accessibility helpers not consumed |
|
| `@bytelyst/accessibility` | ❌ **MISSING** | Shared accessibility helpers not consumed |
|
||||||
@ -124,13 +124,13 @@ These proxy to `chronomind-backend` (port 4011) via `chronomind-client.ts`.
|
|||||||
|
|
||||||
- [x] `web/src/lib/feedback.ts` — integrate `@bytelyst/feedback-client` (commit: f3e14e2)
|
- [x] `web/src/lib/feedback.ts` — integrate `@bytelyst/feedback-client` (commit: f3e14e2)
|
||||||
- `createFeedbackClient({ baseUrl, productId, getAccessToken })`
|
- `createFeedbackClient({ baseUrl, productId, getAccessToken })`
|
||||||
- [x] Add feedback button to settings page (or floating FAB) (commit: d38b974; status: implemented, verification blocked locally because `pnpm install` requires `GITEA_NPM_TOKEN` for private `@bytelyst/*` packages)
|
- [ ] Add feedback button to settings page (or floating FAB) (commit: )
|
||||||
- TODO-002: Wire feedback button into settings page using `feedbackClient` from `web/src/lib/feedback.ts`
|
- TODO-002: Wire feedback button into settings page using `feedbackClient` from `web/src/lib/feedback.ts`
|
||||||
|
|
||||||
### 0.3 — Accessibility Package (Web)
|
### 0.3 — Accessibility Package (Web)
|
||||||
|
|
||||||
- [x] Add `@bytelyst/accessibility` to web/package.json (commit: f3e14e2)
|
- [x] Add `@bytelyst/accessibility` to web/package.json (commit: f3e14e2)
|
||||||
- [x] Integrate helpers into existing modals (commit: bfc4670; shared package: `learning_ai_common_plat` 42f60cb; status: `@bytelyst/accessibility` build passed, web typecheck/test/build passed, modal small text promoted from tertiary to secondary for AA contrast)
|
- [ ] Integrate helpers into existing modals (commit: )
|
||||||
- TODO-003: Apply `@bytelyst/accessibility` focus trap + screen reader to CreateTimerModal, AlarmOverlay
|
- TODO-003: Apply `@bytelyst/accessibility` focus trap + screen reader to CreateTimerModal, AlarmOverlay
|
||||||
- Ensure all `--cm-*` color tokens meet WCAG AA contrast
|
- Ensure all `--cm-*` color tokens meet WCAG AA contrast
|
||||||
|
|
||||||
@ -642,8 +642,8 @@ Systematic audit against `learning_ai_common_plat/packages/` (58 packages) revea
|
|||||||
| Gap | Severity | Fixed In |
|
| Gap | Severity | Fixed In |
|
||||||
|-----|----------|----------|
|
|-----|----------|----------|
|
||||||
| No `@bytelyst/kill-switch-client` | **Critical** | Phase 0.1 |
|
| No `@bytelyst/kill-switch-client` | **Critical** | Phase 0.1 |
|
||||||
| No settings feedback entry point using `@bytelyst/feedback-client` | Medium | Phase 0.2 — complete (d38b974) |
|
| No `@bytelyst/feedback-client` | Medium | Phase 0.2 |
|
||||||
| No `@bytelyst/accessibility` focus trap and screen reader helpers | Medium | Phase 0.3 — complete (bfc4670, shared 42f60cb) |
|
| No `@bytelyst/accessibility` helpers | Medium | Phase 0.3 |
|
||||||
| No feature flags for new features | **Critical** | Phase 0.4 |
|
| No feature flags for new features | **Critical** | Phase 0.4 |
|
||||||
| No telemetry events for new features | Medium | Phase 0.5 |
|
| No telemetry events for new features | Medium | Phase 0.5 |
|
||||||
| MCP tools built in product backend (wrong arch) | **Critical** | Phase A (dual-layer: backend REST + mcp-server proxy) |
|
| MCP tools built in product backend (wrong arch) | **Critical** | Phase A (dual-layer: backend REST + mcp-server proxy) |
|
||||||
|
|||||||
@ -1,815 +0,0 @@
|
|||||||
# UX Implementation Guide
|
|
||||||
|
|
||||||
> **Purpose:** Comprehensive guide for UX implementation across ByteLyst products using common platform UI packages, design tokens, and Cipher design system integration.
|
|
||||||
|
|
||||||
> **Target Audience:** Product teams implementing UX improvements and testing infrastructure.
|
|
||||||
|
|
||||||
> **Product:** ChronoMind - AI-powered contextual clock & timer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide documents the complete UX implementation approach for ChronoMind, replicating the patterns established in the trading dashboard. The approach covers:
|
|
||||||
|
|
||||||
- **Common Platform UI Integration** - Leveraging `@bytelyst/ui` shared primitives
|
|
||||||
- **Design Token Usage** - Using `@bytelyst/design-tokens` for consistent styling
|
|
||||||
- **Component Normalization** - Replacing one-off components with shared primitives
|
|
||||||
- **Accessibility Improvements** - Keyboard navigation, ARIA labels, focus management
|
|
||||||
- **Responsive Design** - Viewport matrix testing, shell breakpoints
|
|
||||||
- **Testing Infrastructure** - Playwright E2E tests, Storybook, AI-friendly reports
|
|
||||||
- **Cipher Design System** - Visual hierarchy, spacing, typography principles
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 1: Common Platform UI Integration
|
|
||||||
|
|
||||||
### Local Package Resolution
|
|
||||||
|
|
||||||
**Critical:** Use local common platform packages by default, not Gitea registry.
|
|
||||||
|
|
||||||
**File:** `.pnpmfile.cjs`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// .pnpmfile.cjs
|
|
||||||
// Default to local common platform packages
|
|
||||||
function readPackage(pkg, context) {
|
|
||||||
if (!context.workspace) return pkg;
|
|
||||||
|
|
||||||
// Default to common-plat (local packages)
|
|
||||||
const packageSource = process.env.BYTELYST_PACKAGE_SOURCE || 'common-plat';
|
|
||||||
|
|
||||||
if (packageSource === 'common-plat') {
|
|
||||||
// Resolve @bytelyst/* packages from local common platform
|
|
||||||
if (pkg.name.startsWith('@bytelyst/')) {
|
|
||||||
pkg.dependencies = pkg.dependencies || {};
|
|
||||||
pkg.dependencies[pkg.name] = 'workspace:*';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pkg;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
name: 'bytelyst-package-source',
|
|
||||||
hooks: {
|
|
||||||
readPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**File:** `.npmrc`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# Remove repo-level GITEA_NPM_TOKEN interpolation
|
|
||||||
# Gitea registry auth should live in user-level ~/.npmrc or CI secrets
|
|
||||||
# Only when BYTELYST_PACKAGE_SOURCE=gitea is explicitly used
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default: Use local common platform packages
|
|
||||||
BYTELYST_PACKAGE_SOURCE=common-plat
|
|
||||||
|
|
||||||
# Optional: Use Gitea registry (requires auth)
|
|
||||||
BYTELYST_PACKAGE_SOURCE=gitea
|
|
||||||
# GITEA_NPM_TOKEN should be in ~/.npmrc or CI secrets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verification Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify local package resolution
|
|
||||||
pnpm install @bytelyst/ui @bytelyst/design-tokens
|
|
||||||
|
|
||||||
# Verify packages resolve from local common platform
|
|
||||||
pnpm list @bytelyst/ui
|
|
||||||
# Should show: @bytelyst/ui -> link:../learning_ai_common_plat/packages/ui
|
|
||||||
```
|
|
||||||
|
|
||||||
### Product Adapter Pattern
|
|
||||||
|
|
||||||
Create a product adapter to normalize imports and extend shared primitives with product-specific variants:
|
|
||||||
|
|
||||||
**File:** `web/src/components/ui/Primitives.tsx`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import * as React from 'react';
|
|
||||||
import {
|
|
||||||
Badge as CommonBadge,
|
|
||||||
Button as CommonButton,
|
|
||||||
Input as CommonInput,
|
|
||||||
Select as CommonSelect,
|
|
||||||
type BadgeProps as CommonBadgeProps,
|
|
||||||
type ButtonProps as CommonButtonProps,
|
|
||||||
type InputProps as CommonInputProps,
|
|
||||||
type SelectProps as CommonSelectProps,
|
|
||||||
} from '@bytelyst/ui';
|
|
||||||
|
|
||||||
// Re-export all shared primitives
|
|
||||||
export {
|
|
||||||
ActionMenu,
|
|
||||||
AlertBanner,
|
|
||||||
DataList,
|
|
||||||
DataTable,
|
|
||||||
Drawer,
|
|
||||||
EmptyState,
|
|
||||||
Modal,
|
|
||||||
PageHeader,
|
|
||||||
Panel,
|
|
||||||
Skeleton,
|
|
||||||
Timeline,
|
|
||||||
Toolbar,
|
|
||||||
// ... all other @bytelyst/ui components
|
|
||||||
} from '@bytelyst/ui';
|
|
||||||
|
|
||||||
// Define product-specific variants for ChronoMind
|
|
||||||
type ProductButtonVariant = NonNullable<CommonButtonProps['variant']> | 'link';
|
|
||||||
type ProductButtonSize = NonNullable<CommonButtonProps['size']> | 'icon';
|
|
||||||
type ProductFieldVariant = 'surface' | 'muted';
|
|
||||||
type ProductFieldSize = 'sm' | 'md';
|
|
||||||
type ProductBadgeVariant = NonNullable<CommonBadgeProps['variant']> | 'danger';
|
|
||||||
type ProductStatusTone = 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
|
||||||
|
|
||||||
// Extend interfaces with product-specific props
|
|
||||||
export interface ButtonProps extends Omit<CommonButtonProps, 'variant' | 'size'> {
|
|
||||||
variant?: ProductButtonVariant;
|
|
||||||
size?: ProductButtonSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IconButtonProps extends Omit<ButtonProps, 'children'> {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InputProps extends CommonInputProps {
|
|
||||||
controlSize?: ProductFieldSize;
|
|
||||||
variant?: ProductFieldVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChronoMind-specific status mapping for badges
|
|
||||||
export type ChronoMindStatus =
|
|
||||||
| 'running' | 'paused' | 'stopped' | 'completed' | 'alarm'
|
|
||||||
| 'snoozed' | 'overdue' | 'upcoming' | 'recurring' | 'one-time'
|
|
||||||
| 'focus' | 'break' | 'pomodoro' | 'routine' | 'error' | 'success';
|
|
||||||
|
|
||||||
const chronomindStatusTone: Record<ChronoMindStatus, ProductStatusTone> = {
|
|
||||||
running: 'success',
|
|
||||||
paused: 'warning',
|
|
||||||
stopped: 'neutral',
|
|
||||||
completed: 'success',
|
|
||||||
alarm: 'error',
|
|
||||||
snoozed: 'warning',
|
|
||||||
overdue: 'error',
|
|
||||||
upcoming: 'info',
|
|
||||||
recurring: 'info',
|
|
||||||
'one-time': 'neutral',
|
|
||||||
focus: 'success',
|
|
||||||
break: 'info',
|
|
||||||
pomodoro: 'success',
|
|
||||||
routine: 'info',
|
|
||||||
error: 'error',
|
|
||||||
success: 'success',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to map timer status to tone
|
|
||||||
export function statusToneFor(status: ChronoMindStatus | string | null | undefined): ProductStatusTone {
|
|
||||||
if (!status) return 'neutral';
|
|
||||||
const normalized = status.trim().toLowerCase().replace(/[\s_]+/g, '-') as ChronoMindStatus;
|
|
||||||
return chronomindStatusTone[normalized] ?? 'neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Product-specific component implementations
|
|
||||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ variant = 'primary', size = 'md', className, ...props }, ref) => (
|
|
||||||
<CommonButton
|
|
||||||
ref={ref}
|
|
||||||
variant={variant === 'link' ? 'ghost' : variant}
|
|
||||||
size={size === 'icon' ? 'sm' : size}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
||||||
({ icon, label, variant = 'ghost', size = 'icon', className, ...props }, ref) => (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className="shrink-0"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ controlSize = 'md', variant = 'surface', className, ...props }, ref) => (
|
|
||||||
<CommonInput
|
|
||||||
ref={ref}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ChronoMindStatusBadge({
|
|
||||||
status,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
status: ChronoMindStatus | string | null | undefined;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Badge variant={statusToneFor(status)} dot>
|
|
||||||
{children ?? status ?? 'Unknown'}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits of Product Adapter Pattern:**
|
|
||||||
- Centralized import point for all UI components
|
|
||||||
- Product-specific variants without modifying common platform
|
|
||||||
- Consistent styling across the application
|
|
||||||
- Easy to migrate to new common platform versions
|
|
||||||
- Type-safe extensions with TypeScript
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 2: Design Token Usage
|
|
||||||
|
|
||||||
### CSS Variable Integration
|
|
||||||
|
|
||||||
Use design tokens from `@bytelyst/design-tokens` via CSS custom properties with ChronoMind-specific prefixes:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Surface colors */
|
|
||||||
background: var(--cm-surface-card);
|
|
||||||
background: var(--cm-surface-muted);
|
|
||||||
background: var(--cm-surface-overlay);
|
|
||||||
|
|
||||||
/* Text colors */
|
|
||||||
color: var(--cm-text-primary);
|
|
||||||
color: var(--cm-text-secondary);
|
|
||||||
color: var(--cm-text-muted);
|
|
||||||
|
|
||||||
/* Border colors */
|
|
||||||
border-color: var(--cm-border);
|
|
||||||
border-color: var(--cm-border-muted);
|
|
||||||
|
|
||||||
/* Input styling */
|
|
||||||
background: var(--cm-input);
|
|
||||||
color: var(--cm-text-primary);
|
|
||||||
border-color: var(--cm-border);
|
|
||||||
|
|
||||||
/* Focus states */
|
|
||||||
border-color: var(--cm-focus-ring);
|
|
||||||
box-shadow: 0 0 0 2px var(--cm-focus-ring-muted);
|
|
||||||
|
|
||||||
/* Semantic colors */
|
|
||||||
color: var(--cm-accent);
|
|
||||||
background: var(--cm-success);
|
|
||||||
background: var(--cm-warning);
|
|
||||||
background: var(--cm-error);
|
|
||||||
|
|
||||||
/* Spacing */
|
|
||||||
padding: var(--cm-spacing-sm);
|
|
||||||
padding: var(--cm-spacing-md);
|
|
||||||
padding: var(--cm-spacing-lg);
|
|
||||||
padding: var(--cm-spacing-xl);
|
|
||||||
|
|
||||||
/* Border radius */
|
|
||||||
border-radius: var(--cm-radius-control);
|
|
||||||
border-radius: var(--cm-radius-md);
|
|
||||||
border-radius: var(--cm-radius-lg);
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
font-size: var(--cm-text-sm);
|
|
||||||
font-size: var(--cm-text-base);
|
|
||||||
font-size: var(--cm-text-lg);
|
|
||||||
font-weight: var(--cm-font-medium);
|
|
||||||
font-weight: var(--cm-font-semibold);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Token Patterns
|
|
||||||
|
|
||||||
**Example: Badge Component for ChronoMind**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Using design tokens for badge styling
|
|
||||||
const badgeStyles = {
|
|
||||||
success: {
|
|
||||||
background: 'var(--cm-success-light)',
|
|
||||||
color: 'var(--cm-success-dark)',
|
|
||||||
borderColor: 'var(--cm-success)',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
background: 'var(--cm-warning-light)',
|
|
||||||
color: 'var(--cm-warning-dark)',
|
|
||||||
borderColor: 'var(--cm-warning)',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
background: 'var(--cm-error-light)',
|
|
||||||
color: 'var(--cm-error-dark)',
|
|
||||||
borderColor: 'var(--cm-error)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 3: Component Normalization
|
|
||||||
|
|
||||||
### Badge Normalization
|
|
||||||
|
|
||||||
**Before:** One-off CSS classes for different badge styles
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Old approach - one-off classes */
|
|
||||||
.timer-chip {
|
|
||||||
background: #f0f0f0;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tag {
|
|
||||||
background: #e0e0e0;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
|
||||||
background: #d0d0d0;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:** Shared Badge component from product adapter
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Badge, ChronoMindStatusBadge } from '../components/ui/Primitives';
|
|
||||||
|
|
||||||
// Replace .timer-chip with Badge
|
|
||||||
<Badge variant="success">Running</Badge>
|
|
||||||
|
|
||||||
// Replace .category-tag with Badge
|
|
||||||
<Badge variant="info">Work</Badge>
|
|
||||||
|
|
||||||
// Replace .status-pill with ChronoMindStatusBadge
|
|
||||||
<ChronoMindStatusBadge status="pomodoro" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alert Banner Unification
|
|
||||||
|
|
||||||
**Before:** Different alert implementations across components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TimerCard.tsx - custom alert
|
|
||||||
<div className="alert-banner" style={{ background: '#fff3cd' }}>
|
|
||||||
<span>⚠️</span>
|
|
||||||
<span>Warning message</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// RoutineEditor.tsx - different alert
|
|
||||||
<div className="warning-box" style={{ border: '1px solid #ffc107' }}>
|
|
||||||
<div className="warning-icon">!</div>
|
|
||||||
<div className="warning-text">Warning text</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:** Shared AlertBanner component
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { AlertBanner } from '../components/ui/Primitives';
|
|
||||||
|
|
||||||
// Both components now use shared AlertBanner
|
|
||||||
<AlertBanner tone="warning" title="Warning">
|
|
||||||
Warning message
|
|
||||||
</AlertBanner>
|
|
||||||
|
|
||||||
<AlertBanner tone="error" title="Error">
|
|
||||||
Error message
|
|
||||||
</AlertBanner>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Table Controls Standardization
|
|
||||||
|
|
||||||
**Before:** Custom table controls with inconsistent styling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Different button styles across tables
|
|
||||||
<button className="table-action-btn-small">Edit</button>
|
|
||||||
<button className="timer-filter-btn">Filter</button>
|
|
||||||
<button className="routine-action">Complete</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:** Standardized Button component
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Button, IconButton } from '../components/ui/Primitives';
|
|
||||||
|
|
||||||
// Consistent button styles
|
|
||||||
<Button variant="ghost" size="sm">Edit</Button>
|
|
||||||
<Button variant="secondary" size="sm">Filter</Button>
|
|
||||||
<IconButton icon={<CheckIcon />} label="Complete" />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 4: Accessibility Improvements
|
|
||||||
|
|
||||||
### Keyboard Navigation
|
|
||||||
|
|
||||||
**Focus Management**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Ensure interactive elements are focusable
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Keyboard Toggles**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
>
|
|
||||||
Toggle Option
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### ARIA Labels
|
|
||||||
|
|
||||||
**Button Labels**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Icon buttons need explicit labels
|
|
||||||
<IconButton
|
|
||||||
icon={<PlayIcon />}
|
|
||||||
label="Start timer"
|
|
||||||
aria-label="Start timer"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Status Indicators**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Status badges need descriptive labels
|
|
||||||
<ChronoMindStatusBadge
|
|
||||||
status="running"
|
|
||||||
aria-label="Status: Running"
|
|
||||||
>
|
|
||||||
Running
|
|
||||||
</ChronoMindStatusBadge>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Form Labels**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// All inputs need associated labels
|
|
||||||
<label htmlFor="timer-name">
|
|
||||||
Timer Name
|
|
||||||
<Input id="timer-name" type="text" />
|
|
||||||
</label>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Focus Indicators
|
|
||||||
|
|
||||||
**Visible Focus States**
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Ensure focus is visible */
|
|
||||||
*:focus-visible {
|
|
||||||
outline: 2px solid var(--cm-focus-ring);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Or use design token */
|
|
||||||
*:focus-visible {
|
|
||||||
outline: 2px solid var(--cm-focus-ring);
|
|
||||||
box-shadow: 0 0 0 2px var(--cm-focus-ring-muted);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 5: Responsive Design
|
|
||||||
|
|
||||||
### Shell Breakpoints
|
|
||||||
|
|
||||||
**Responsive Shell Testing**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const breakpoints = {
|
|
||||||
mobile: 'max-width: 560px',
|
|
||||||
tablet: 'max-width: 768px',
|
|
||||||
desktop: 'min-width: 769px',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shell adapts at these breakpoints
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.timer-main {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
.timer-right-panel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.timer-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Viewport Matrix Testing
|
|
||||||
|
|
||||||
**Test all routes across viewports**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const routes = ['/dashboard', '/focus', '/history', '/routines', '/settings'];
|
|
||||||
const viewports = [
|
|
||||||
{ name: 'Desktop', width: 1200, height: 800 },
|
|
||||||
{ name: 'Tablet', width: 768, height: 1024 },
|
|
||||||
{ name: 'Mobile', width: 375, height: 667 },
|
|
||||||
];
|
|
||||||
|
|
||||||
routes.forEach((route) => {
|
|
||||||
viewports.forEach((viewport) => {
|
|
||||||
test(`${route} - ${viewport.name} viewport`, async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
||||||
await page.goto(route);
|
|
||||||
|
|
||||||
// Check for horizontal overflow
|
|
||||||
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
|
|
||||||
expect(bodyWidth).toBeLessThanOrEqual(viewport.width + 10);
|
|
||||||
|
|
||||||
// Check main content visibility
|
|
||||||
const mainContent = page.locator('main');
|
|
||||||
await expect(mainContent).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 6: Testing Infrastructure
|
|
||||||
|
|
||||||
### Playwright Setup
|
|
||||||
|
|
||||||
**Install Dependencies**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
pnpm add -D @playwright/test
|
|
||||||
pnpm exec playwright install chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
**Create Playwright Config**
|
|
||||||
|
|
||||||
Create `web/playwright.config.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './e2e',
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: 'html',
|
|
||||||
use: {
|
|
||||||
baseURL: 'http://localhost:3030',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
||||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
|
||||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
|
||||||
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
|
|
||||||
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Suite Structure
|
|
||||||
|
|
||||||
Create tests in `web/e2e/` directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
web/e2e/
|
|
||||||
├── viewport-matrix.spec.ts # Viewport compliance
|
|
||||||
├── horizontal-overflow.spec.ts # Overflow detection
|
|
||||||
├── alert-positioning.spec.ts # Critical alerts positioning
|
|
||||||
├── timer-flows.spec.ts # Timer start/pause/stop flows
|
|
||||||
├── page-states.spec.ts # Loading/empty/error/success states
|
|
||||||
├── form-validation.spec.ts # Form validation
|
|
||||||
└── keyboard-navigation.spec.ts # Keyboard navigation
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 7: Cipher Design System Integration
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
Follow Cipher design system principles for consistent UX across ChronoMind:
|
|
||||||
|
|
||||||
#### 1. Visual Hierarchy
|
|
||||||
|
|
||||||
**Size and Weight**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Use design tokens for consistent hierarchy
|
|
||||||
<h1 style={{ fontSize: 'var(--cm-text-2xl)', fontWeight: 'var(--cm-font-bold)' }}>
|
|
||||||
Timer Title
|
|
||||||
</h1>
|
|
||||||
<h2 style={{ fontSize: 'var(--cm-text-xl)', fontWeight: 'var(--cm-font-semibold)' }}>
|
|
||||||
Section Header
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: 'var(--cm-text-base)', color: 'var(--cm-text-secondary)' }}>
|
|
||||||
Timer content
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Spacing System
|
|
||||||
|
|
||||||
**Consistent Spacing**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Use design token spacing
|
|
||||||
<div style={{ padding: 'var(--cm-spacing-md)' }}>
|
|
||||||
Content
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ gap: 'var(--cm-spacing-sm)' }}>
|
|
||||||
<Button>First</Button>
|
|
||||||
<Button>Second</Button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Typography
|
|
||||||
|
|
||||||
**Font Scales**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Use design token font sizes
|
|
||||||
const textStyles = {
|
|
||||||
xs: 'var(--cm-text-xs)',
|
|
||||||
sm: 'var(--cm-text-sm)',
|
|
||||||
base: 'var(--cm-text-base)',
|
|
||||||
lg: 'var(--cm-text-lg)',
|
|
||||||
xl: 'var(--cm-text-xl)',
|
|
||||||
'2xl': 'var(--cm-text-2xl)',
|
|
||||||
};
|
|
||||||
|
|
||||||
<p style={{ fontSize: textStyles.base }}>Body text</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Color System
|
|
||||||
|
|
||||||
**Semantic Colors**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Use semantic color tokens, not literal colors
|
|
||||||
const statusColors = {
|
|
||||||
success: 'var(--cm-success)',
|
|
||||||
warning: 'var(--cm-warning)',
|
|
||||||
error: 'var(--cm-error)',
|
|
||||||
info: 'var(--cm-info)',
|
|
||||||
};
|
|
||||||
|
|
||||||
<div style={{ color: statusColors.success }}>Success message</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 8: Implementation Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation Setup
|
|
||||||
|
|
||||||
**Week 1-2**
|
|
||||||
- [ ] Set up local package resolution (.pnpmfile.cjs)
|
|
||||||
- [ ] Remove repo-level GITEA_NPM_TOKEN from .npmrc
|
|
||||||
- [ ] Install common platform packages from local source
|
|
||||||
- [ ] Create product adapter (`Primitives.tsx`)
|
|
||||||
- [ ] Set up design token integration
|
|
||||||
- [ ] Configure Playwright
|
|
||||||
|
|
||||||
### Phase 2: Component Normalization
|
|
||||||
|
|
||||||
**Week 3-4**
|
|
||||||
- [ ] Replace one-off badges with Badge component
|
|
||||||
- [ ] Replace custom buttons with Button component
|
|
||||||
- [ ] Replace custom alerts with AlertBanner component
|
|
||||||
- [ ] Replace custom inputs with Input component
|
|
||||||
- [ ] Remove old CSS classes
|
|
||||||
|
|
||||||
### Phase 3: Design Token Migration
|
|
||||||
|
|
||||||
**Week 5-6**
|
|
||||||
- [ ] Identify hardcoded colors
|
|
||||||
- [ ] Map to semantic tokens
|
|
||||||
- [ ] Replace with CSS variables
|
|
||||||
- [ ] Test across themes
|
|
||||||
- [ ] Audit for missed tokens
|
|
||||||
|
|
||||||
### Phase 4: Accessibility Improvements
|
|
||||||
|
|
||||||
**Week 7**
|
|
||||||
- [ ] Add keyboard navigation
|
|
||||||
- [ ] Add ARIA labels
|
|
||||||
- [ ] Improve focus indicators
|
|
||||||
- [ ] Test with screen readers
|
|
||||||
|
|
||||||
### Phase 5: Responsive Design
|
|
||||||
|
|
||||||
**Week 8**
|
|
||||||
- [ ] Test viewport matrix
|
|
||||||
- [ ] Fix horizontal overflow
|
|
||||||
- [ ] Optimize mobile layout
|
|
||||||
- [ ] Test breakpoints
|
|
||||||
|
|
||||||
### Phase 6: Testing Infrastructure
|
|
||||||
|
|
||||||
**Week 9-10**
|
|
||||||
- [ ] Create E2E test suite
|
|
||||||
- [ ] Set up test runner script
|
|
||||||
- [ ] Configure AI-friendly reports
|
|
||||||
- [ ] Integrate with CI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 9: Verification Commands
|
|
||||||
|
|
||||||
### Type Checking
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify TypeScript types
|
|
||||||
pnpm run typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ensure build succeeds
|
|
||||||
pnpm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Execution
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all E2E tests
|
|
||||||
cd web
|
|
||||||
pnpm test:e2e
|
|
||||||
|
|
||||||
# Run specific test suites
|
|
||||||
pnpm test:e2e:viewport
|
|
||||||
pnpm test:e2e:overflow
|
|
||||||
pnpm test:e2e:timer-flows
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This guide provides a comprehensive approach to implementing UX improvements for ChronoMind using the ByteLyst common platform UI packages, design tokens, and Cipher design system. By following these patterns, ChronoMind will achieve:
|
|
||||||
|
|
||||||
- Consistent UI with other ByteLyst products
|
|
||||||
- Improved accessibility and keyboard navigation
|
|
||||||
- Responsive design across all viewports
|
|
||||||
- Comprehensive testing infrastructure
|
|
||||||
- Maintainable and scalable codebase
|
|
||||||
|
|
||||||
The implementation is organized in phases to allow for incremental adoption and verification at each stage.
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Pack @bytelyst/* tarballs from the sibling common-plat repo for
|
|
||||||
# self-contained Docker builds that don't need the Gitea npm registry.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/docker-prep.sh # pack tarballs + rewrite package.json
|
|
||||||
# ./scripts/docker-prep.sh --restore # undo rewrite
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
COMMON_PLAT="${REPO_DIR}/../learning_ai_common_plat"
|
|
||||||
|
|
||||||
TARBALL_DIR="${REPO_DIR}/.docker-deps"
|
|
||||||
|
|
||||||
# ── Restore mode ───────────────────────────────────────────────────
|
|
||||||
if [[ "${1:-}" == "--restore" ]]; then
|
|
||||||
echo "Restoring original package.json files..."
|
|
||||||
for bak in $(find "$REPO_DIR" -name "package.json.bak" -not -path "*/node_modules/*"); do
|
|
||||||
mv "$bak" "${bak%.bak}"
|
|
||||||
echo " Restored ${bak%.bak}"
|
|
||||||
done
|
|
||||||
rm -rf "$TARBALL_DIR"
|
|
||||||
echo "Done."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Pack mode ──────────────────────────────────────────────────────
|
|
||||||
echo "=== docker-prep: packing @bytelyst/* tarballs ==="
|
|
||||||
|
|
||||||
rm -rf "$TARBALL_DIR"
|
|
||||||
mkdir -p "$TARBALL_DIR"
|
|
||||||
|
|
||||||
# Build all packages first
|
|
||||||
echo "Building @bytelyst/* packages..."
|
|
||||||
(cd "$COMMON_PLAT" && pnpm -r --filter './packages/*' build)
|
|
||||||
|
|
||||||
# Pack each package and build a mapping of name → tarball filename
|
|
||||||
# (uses a temp file instead of associative array for bash 3.2 compat)
|
|
||||||
TARBALL_MAP_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$TARBALL_MAP_FILE"' EXIT
|
|
||||||
|
|
||||||
for pkg_dir in "$COMMON_PLAT"/packages/*/; do
|
|
||||||
pkg_name=$(node -p "require('${pkg_dir}package.json').name" 2>/dev/null || true)
|
|
||||||
if [[ -z "$pkg_name" ]]; then continue; fi
|
|
||||||
|
|
||||||
echo " Packing $pkg_name..."
|
|
||||||
tarball=$(cd "$pkg_dir" && pnpm pack --pack-destination "$TARBALL_DIR" 2>/dev/null | tail -1)
|
|
||||||
filename=$(basename "$tarball")
|
|
||||||
echo "${pkg_name}=${filename}" >> "$TARBALL_MAP_FILE"
|
|
||||||
echo " -> $filename"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Rewrite package.json files ─────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "Rewriting package.json @bytelyst/* refs to .docker-deps/ tarballs..."
|
|
||||||
|
|
||||||
rewrite_package_json() {
|
|
||||||
local pkg_file="$1"
|
|
||||||
local rel_prefix="$2" # relative path from package.json dir to repo root
|
|
||||||
|
|
||||||
if [[ ! -f "$pkg_file" ]]; then return; fi
|
|
||||||
|
|
||||||
# Backup
|
|
||||||
cp "$pkg_file" "${pkg_file}.bak"
|
|
||||||
|
|
||||||
local tmp="${pkg_file}.tmp"
|
|
||||||
cp "$pkg_file" "$tmp"
|
|
||||||
|
|
||||||
while IFS='=' read -r pkg_name tarball; do
|
|
||||||
[[ -z "$pkg_name" ]] && continue
|
|
||||||
# Replace "^0.1.0" (or any semver/file: ref) with "file:../.docker-deps/<tarball>"
|
|
||||||
sed -i "s|\"${pkg_name}\": \"[^\"]*\"|\"${pkg_name}\": \"file:${rel_prefix}.docker-deps/${tarball}\"|g" "$tmp"
|
|
||||||
done < "$TARBALL_MAP_FILE"
|
|
||||||
|
|
||||||
mv "$tmp" "$pkg_file"
|
|
||||||
echo " Rewrote $pkg_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Backend package.json
|
|
||||||
rewrite_package_json "${REPO_DIR}/backend/package.json" "../"
|
|
||||||
|
|
||||||
# Web package.json
|
|
||||||
rewrite_package_json "${REPO_DIR}/web/package.json" "../"
|
|
||||||
|
|
||||||
# ── Inject pnpm.overrides for transitive @bytelyst/* deps ─────────
|
|
||||||
# Tarball packages may depend on other @bytelyst/* packages (e.g.
|
|
||||||
# @bytelyst/fastify-core → @bytelyst/errors). Without overrides, pnpm
|
|
||||||
# tries to fetch them from the npm registry which fails.
|
|
||||||
inject_overrides() {
|
|
||||||
local pkg_file="$1"
|
|
||||||
local rel_prefix="$2"
|
|
||||||
|
|
||||||
if [[ ! -f "$pkg_file" ]]; then return; fi
|
|
||||||
|
|
||||||
local overrides=""
|
|
||||||
while IFS='=' read -r pkg_name tarball; do
|
|
||||||
[[ -z "$pkg_name" ]] && continue
|
|
||||||
if [[ -n "$overrides" ]]; then overrides="$overrides, "; fi
|
|
||||||
overrides="$overrides\"${pkg_name}\": \"file:${rel_prefix}.docker-deps/${tarball}\""
|
|
||||||
done < "$TARBALL_MAP_FILE"
|
|
||||||
|
|
||||||
if [[ -n "$overrides" ]]; then
|
|
||||||
node -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const p = JSON.parse(fs.readFileSync('${pkg_file}', 'utf8'));
|
|
||||||
p.pnpm = p.pnpm || {};
|
|
||||||
p.pnpm.overrides = { ...(p.pnpm.overrides || {}), ...JSON.parse('{${overrides}}') };
|
|
||||||
fs.writeFileSync('${pkg_file}', JSON.stringify(p, null, 2) + '\n');
|
|
||||||
"
|
|
||||||
echo " Injected pnpm.overrides into $pkg_file"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
inject_overrides "${REPO_DIR}/backend/package.json" "../"
|
|
||||||
inject_overrides "${REPO_DIR}/web/package.json" "../"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done. Tarballs in $TARBALL_DIR"
|
|
||||||
echo ""
|
|
||||||
echo "To build Docker images:"
|
|
||||||
echo " docker compose build"
|
|
||||||
echo ""
|
|
||||||
echo "To restore after build:"
|
|
||||||
echo " ./scripts/docker-prep.sh --restore"
|
|
||||||
@ -1,47 +1,21 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Optimized docker-prep.sh with 6 optimizations:
|
# Pack @bytelyst/* tarballs from the sibling common-plat repo for
|
||||||
# 1. Git-based incremental builds (only rebuild changed packages)
|
# self-contained Docker builds that don't need the Gitea npm registry.
|
||||||
# 2. Hash-based caching (content-addressable cache)
|
|
||||||
# 3. Parallel builds (concurrent package building)
|
|
||||||
# 4. Persistent tarball cache (survives git clean)
|
|
||||||
# 5. Smart manifest tracking (track what's been built)
|
|
||||||
# 6. Docker BuildKit integration hints
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/docker-prep-optimized.sh # pack tarballs + rewrite package.json
|
# ./scripts/docker-prep.sh # pack tarballs + rewrite package.json
|
||||||
# ./scripts/docker-prep-optimized.sh --restore # undo rewrite
|
# ./scripts/docker-prep.sh --restore # undo rewrite
|
||||||
# ./scripts/docker-prep-optimized.sh --clean # clear cache
|
|
||||||
# ./scripts/docker-prep-optimized.sh --force # force full rebuild
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
COMMON_PLAT="${COMMON_PLAT:-${REPO_DIR}/../learning_ai/learning_ai_common_plat}"
|
COMMON_PLAT="${REPO_DIR}/../learning_ai_common_plat"
|
||||||
if [[ ! -d "$COMMON_PLAT" && -d "${REPO_DIR}/../learning_ai_common_plat" ]]; then
|
|
||||||
COMMON_PLAT="${REPO_DIR}/../learning_ai_common_plat"
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARBALL_DIR="${REPO_DIR}/.docker-deps"
|
TARBALL_DIR="${REPO_DIR}/.docker-deps"
|
||||||
# Shared cache location - can be overridden with BYTELYST_CACHE_DIR env var
|
|
||||||
CACHE_DIR="${BYTELYST_CACHE_DIR:-${HOME}/.cache/bytelyst-packages}"
|
|
||||||
MANIFEST_FILE="${CACHE_DIR}/.manifest"
|
|
||||||
|
|
||||||
# ── Parse arguments ───────────────────────────────────────────────────
|
# ── Restore mode ───────────────────────────────────────────────────
|
||||||
MODE="pack"
|
if [[ "${1:-}" == "--restore" ]]; then
|
||||||
FORCE_REBUILD=false
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--restore) MODE="restore"; shift ;;
|
|
||||||
--clean) MODE="clean"; shift ;;
|
|
||||||
--force) FORCE_REBUILD=true; shift ;;
|
|
||||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Restore mode ─────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "restore" ]]; then
|
|
||||||
echo "Restoring original package.json files..."
|
echo "Restoring original package.json files..."
|
||||||
for bak in $(find "$REPO_DIR" -name "package.json.bak" -not -path "*/node_modules/*"); do
|
for bak in $(find "$REPO_DIR" -name "package.json.bak" -not -path "*/node_modules/*"); do
|
||||||
mv "$bak" "${bak%.bak}"
|
mv "$bak" "${bak%.bak}"
|
||||||
@ -52,215 +26,54 @@ if [[ "$MODE" == "restore" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Clean mode ───────────────────────────────────────────────────────
|
# ── Pack mode ──────────────────────────────────────────────────────
|
||||||
if [[ "$MODE" == "clean" ]]; then
|
echo "=== docker-prep: packing @bytelyst/* tarballs ==="
|
||||||
echo "Cleaning cache and tarball directory..."
|
|
||||||
rm -rf "$CACHE_DIR" "$TARBALL_DIR"
|
|
||||||
echo "Done."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Validation ───────────────────────────────────────────────────────
|
|
||||||
if [[ ! -d "$COMMON_PLAT" ]]; then
|
|
||||||
echo "Common platform checkout not found: $COMMON_PLAT" >&2
|
|
||||||
echo "Set COMMON_PLAT=/path/to/learning_ai_common_plat or place it at ../learning_ai/learning_ai_common_plat." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Initialize directories ───────────────────────────────────────────
|
|
||||||
mkdir -p "$TARBALL_DIR" "$CACHE_DIR"
|
|
||||||
|
|
||||||
# ── Load manifest if exists ───────────────────────────────────────────
|
|
||||||
# Simple file-based manifest to avoid bash associative array issues
|
|
||||||
|
|
||||||
# ── Determine which packages to rebuild ───────────────────────────────
|
|
||||||
if [[ "$FORCE_REBUILD" == true ]]; then
|
|
||||||
echo "Force rebuild: building all packages"
|
|
||||||
BUILD_ALL=true
|
|
||||||
else
|
|
||||||
BUILD_ALL=false
|
|
||||||
# Git-based incremental build: check which packages changed
|
|
||||||
cd "$COMMON_PLAT"
|
|
||||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
|
||||||
# Get packages changed since last commit
|
|
||||||
CHANGED_PACKAGES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | grep '^packages/' | cut -d'/' -f2 | sort -u || echo "")
|
|
||||||
|
|
||||||
# If no git history or no changes, check against manifest
|
|
||||||
if [[ -z "$CHANGED_PACKAGES" ]] && [[ -f "$MANIFEST_FILE" ]]; then
|
|
||||||
echo "No git changes detected, checking cache validity..."
|
|
||||||
CHANGED_PACKAGES=""
|
|
||||||
for pkg_dir in "$COMMON_PLAT"/packages/*/; do
|
|
||||||
pkg_name=$(node -p "require('${pkg_dir}package.json').name" 2>/dev/null || true)
|
|
||||||
if [[ -z "$pkg_name" ]]; then continue; fi
|
|
||||||
|
|
||||||
# Hash package.json + source files
|
|
||||||
HASH=$(find "$pkg_dir" -name "*.ts" -o -name "*.json" -o -name "*.js" 2>/dev/null | xargs cat 2>/dev/null | sha256sum | cut -d' ' -f1 || echo "")
|
|
||||||
|
|
||||||
# Check manifest for existing hash
|
|
||||||
CACHED_HASH=$(grep "^${pkg_name}=" "$MANIFEST_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")
|
|
||||||
if [[ -n "$HASH" ]] && [[ "$CACHED_HASH" != "$HASH" ]]; then
|
|
||||||
pkg_basename=$(basename "$pkg_dir")
|
|
||||||
CHANGED_PACKAGES="$CHANGED_PACKAGES $pkg_basename"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If still no changes detected but cache is empty, build all
|
|
||||||
if [[ -z "$CHANGED_PACKAGES" ]] && [[ -z "$(ls -A $CACHE_DIR 2>/dev/null)" ]]; then
|
|
||||||
echo "Cache empty, building all packages..."
|
|
||||||
BUILD_ALL=true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Not a git repo or no history, building all packages..."
|
|
||||||
BUILD_ALL=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
|
|
||||||
# ── Build packages with caching ────────────────────────────────────────
|
|
||||||
echo "=== docker-prep-optimized: packing @bytelyst/* tarballs ==="
|
|
||||||
|
|
||||||
rm -rf "$TARBALL_DIR"
|
rm -rf "$TARBALL_DIR"
|
||||||
mkdir -p "$TARBALL_DIR"
|
mkdir -p "$TARBALL_DIR"
|
||||||
|
|
||||||
# Function to build a single package
|
# Build all packages first
|
||||||
build_package() {
|
echo "Building @bytelyst/* packages..."
|
||||||
local pkg_dir=$1
|
(cd "$COMMON_PLAT" && pnpm -r --filter './packages/*' build)
|
||||||
local force_build=${2:-false}
|
|
||||||
|
# Pack each package and build a mapping of name → tarball filename
|
||||||
if [[ ! -d "$pkg_dir" ]]; then
|
# (uses a temp file instead of associative array for bash 3.2 compat)
|
||||||
echo " ✗ Package directory not found: $pkg_dir" >&2
|
TARBALL_MAP_FILE=$(mktemp)
|
||||||
return 1
|
trap 'rm -f "$TARBALL_MAP_FILE"' EXIT
|
||||||
fi
|
|
||||||
|
for pkg_dir in "$COMMON_PLAT"/packages/*/; do
|
||||||
pkg_name=$(node -p "require('${pkg_dir}package.json').name" 2>/dev/null || true)
|
pkg_name=$(node -p "require('${pkg_dir}package.json').name" 2>/dev/null || true)
|
||||||
if [[ -z "$pkg_name" ]]; then
|
if [[ -z "$pkg_name" ]]; then continue; fi
|
||||||
echo " ✗ Could not read package name from: $pkg_dir" >&2
|
|
||||||
return 1
|
echo " Packing $pkg_name..."
|
||||||
fi
|
|
||||||
|
|
||||||
pkg_version=$(node -p "require('${pkg_dir}package.json').version" 2>/dev/null || echo "0.0.0")
|
|
||||||
|
|
||||||
# Hash package.json + source files
|
|
||||||
HASH=$(find "$pkg_dir" -name "*.ts" -o -name "*.json" -o -name "*.js" 2>/dev/null | xargs cat 2>/dev/null | sha256sum | cut -d' ' -f1 || echo "unknown")
|
|
||||||
# Sanitize package name for filename (replace @ and / with -)
|
|
||||||
SAFE_PKG_NAME=$(echo "$pkg_name" | sed 's/@//g' | sed 's/\//_/g')
|
|
||||||
CACHE_FILE="$CACHE_DIR/${SAFE_PKG_NAME}-${pkg_version}.tgz"
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
if [[ -f "$CACHE_FILE" ]] && [[ "$force_build" != true ]] && [[ "$FORCE_REBUILD" != true ]]; then
|
|
||||||
echo " ✓ Cache hit: $pkg_name"
|
|
||||||
cp "$CACHE_FILE" "$TARBALL_DIR/"
|
|
||||||
echo "${pkg_name}=${HASH}" >> "$TARBALL_DIR/.manifest.tmp"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build package
|
|
||||||
echo " → Building: $pkg_name"
|
|
||||||
(cd "$pkg_dir" && pnpm build > /dev/null 2>&1)
|
|
||||||
|
|
||||||
# Pack to cache and tarball dir
|
|
||||||
tarball=$(cd "$pkg_dir" && pnpm pack --pack-destination "$TARBALL_DIR" 2>/dev/null | tail -1)
|
tarball=$(cd "$pkg_dir" && pnpm pack --pack-destination "$TARBALL_DIR" 2>/dev/null | tail -1)
|
||||||
if [[ -z "$tarball" ]]; then
|
filename=$(basename "$tarball")
|
||||||
echo " ✗ Failed to pack: $pkg_name" >&2
|
echo "${pkg_name}=${filename}" >> "$TARBALL_MAP_FILE"
|
||||||
return 1
|
echo " -> $filename"
|
||||||
fi
|
done
|
||||||
|
|
||||||
# Copy to cache
|
|
||||||
cp "$TARBALL_DIR/$(basename $tarball)" "$CACHE_FILE"
|
|
||||||
echo "${pkg_name}=${HASH}" >> "$TARBALL_DIR/.manifest.tmp"
|
|
||||||
|
|
||||||
echo " → $(basename $tarball)"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build packages
|
|
||||||
if [[ "$BUILD_ALL" == true ]]; then
|
|
||||||
echo "Building all packages..."
|
|
||||||
for pkg_dir in "$COMMON_PLAT"/packages/*/; do
|
|
||||||
build_package "$pkg_dir" || true
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "Building changed packages: $CHANGED_PACKAGES"
|
|
||||||
for pkg in $CHANGED_PACKAGES; do
|
|
||||||
pkg_dir="$COMMON_PLAT/packages/$pkg"
|
|
||||||
if [[ -d "$pkg_dir" ]]; then
|
|
||||||
build_package "$pkg_dir" || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copy unchanged packages from cache
|
|
||||||
if [[ "$FORCE_REBUILD" != true ]]; then
|
|
||||||
echo ""
|
|
||||||
echo "Copying unchanged packages from cache..."
|
|
||||||
for pkg_dir in "$COMMON_PLAT"/packages/*/; do
|
|
||||||
pkg_name=$(node -p "require('${pkg_dir}package.json').name" 2>/dev/null || true)
|
|
||||||
if [[ -z "$pkg_name" ]]; then continue; fi
|
|
||||||
|
|
||||||
pkg_version=$(node -p "require('${pkg_dir}package.json').version" 2>/dev/null || echo "0.0.0")
|
|
||||||
# Sanitize package name for filename (replace @ and / with -)
|
|
||||||
SAFE_PKG_NAME=$(echo "$pkg_name" | sed 's/@//g' | sed 's/\//_/g')
|
|
||||||
CACHE_FILE="$CACHE_DIR/${SAFE_PKG_NAME}-${pkg_version}.tgz"
|
|
||||||
|
|
||||||
if [[ -f "$CACHE_FILE" ]] && [[ ! -f "$TARBALL_DIR/$(basename $CACHE_FILE)" ]]; then
|
|
||||||
echo " ✓ Cached: $pkg_name"
|
|
||||||
cp "$CACHE_FILE" "$TARBALL_DIR/"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update manifest
|
|
||||||
if [[ -f "$TARBALL_DIR/.manifest.tmp" ]]; then
|
|
||||||
mv "$TARBALL_DIR/.manifest.tmp" "$MANIFEST_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Rewrite package.json files ─────────────────────────────────────
|
# ── Rewrite package.json files ─────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Rewriting package.json @bytelyst/* refs to .docker-deps/ tarballs..."
|
echo "Rewriting package.json @bytelyst/* refs to .docker-deps/ tarballs..."
|
||||||
|
|
||||||
# Build tarball mapping
|
|
||||||
TARBALL_MAP_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$TARBALL_MAP_FILE"' EXIT
|
|
||||||
|
|
||||||
for tarball in "$TARBALL_DIR"/*.tgz; do
|
|
||||||
[[ -f "$tarball" ]] || continue
|
|
||||||
filename=$(basename "$tarball")
|
|
||||||
# Extract package name from tarball filename
|
|
||||||
pkg_name=$(echo "$filename" | sed 's/-[0-9].*//' | sed 's/^bytelyst-/@bytelyst\//')
|
|
||||||
echo "${pkg_name}=${filename}" >> "$TARBALL_MAP_FILE"
|
|
||||||
done
|
|
||||||
|
|
||||||
rewrite_package_json() {
|
rewrite_package_json() {
|
||||||
local pkg_file="$1"
|
local pkg_file="$1"
|
||||||
local rel_prefix="$2"
|
local rel_prefix="$2" # relative path from package.json dir to repo root
|
||||||
|
|
||||||
if [[ ! -f "$pkg_file" ]]; then return; fi
|
if [[ ! -f "$pkg_file" ]]; then return; fi
|
||||||
|
|
||||||
# Backup
|
# Backup
|
||||||
cp "$pkg_file" "${pkg_file}.bak"
|
cp "$pkg_file" "${pkg_file}.bak"
|
||||||
|
|
||||||
local tmp="${pkg_file}.tmp"
|
local tmp="${pkg_file}.tmp"
|
||||||
cp "$pkg_file" "$tmp"
|
cp "$pkg_file" "$tmp"
|
||||||
|
|
||||||
while IFS='=' read -r pkg_name tarball; do
|
while IFS='=' read -r pkg_name tarball; do
|
||||||
[[ -z "$pkg_name" ]] && continue
|
[[ -z "$pkg_name" ]] && continue
|
||||||
node -e "
|
# Replace "^0.1.0" (or any semver/file: ref) with "file:../.docker-deps/<tarball>"
|
||||||
const fs = require('fs');
|
sed -i '' "s|\"${pkg_name}\": \"[^\"]*\"|\"${pkg_name}\": \"file:${rel_prefix}.docker-deps/${tarball}\"|g" "$tmp"
|
||||||
const file = process.argv[1];
|
|
||||||
const pkgName = process.argv[2];
|
|
||||||
const replacement = process.argv[3];
|
|
||||||
const p = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
||||||
if (p[section] && Object.prototype.hasOwnProperty.call(p[section], pkgName)) {
|
|
||||||
p[section][pkgName] = replacement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fs.writeFileSync(file, JSON.stringify(p, null, 2) + '\n');
|
|
||||||
" "$tmp" "$pkg_name" "file:${rel_prefix}.docker-deps/${tarball}"
|
|
||||||
done < "$TARBALL_MAP_FILE"
|
done < "$TARBALL_MAP_FILE"
|
||||||
|
|
||||||
mv "$tmp" "$pkg_file"
|
mv "$tmp" "$pkg_file"
|
||||||
echo " Rewrote $pkg_file"
|
echo " Rewrote $pkg_file"
|
||||||
}
|
}
|
||||||
@ -272,19 +85,22 @@ rewrite_package_json "${REPO_DIR}/backend/package.json" "../"
|
|||||||
rewrite_package_json "${REPO_DIR}/web/package.json" "../"
|
rewrite_package_json "${REPO_DIR}/web/package.json" "../"
|
||||||
|
|
||||||
# ── Inject pnpm.overrides for transitive @bytelyst/* deps ─────────
|
# ── Inject pnpm.overrides for transitive @bytelyst/* deps ─────────
|
||||||
|
# Tarball packages may depend on other @bytelyst/* packages (e.g.
|
||||||
|
# @bytelyst/fastify-core → @bytelyst/errors). Without overrides, pnpm
|
||||||
|
# tries to fetch them from the npm registry which fails.
|
||||||
inject_overrides() {
|
inject_overrides() {
|
||||||
local pkg_file="$1"
|
local pkg_file="$1"
|
||||||
local rel_prefix="$2"
|
local rel_prefix="$2"
|
||||||
|
|
||||||
if [[ ! -f "$pkg_file" ]]; then return; fi
|
if [[ ! -f "$pkg_file" ]]; then return; fi
|
||||||
|
|
||||||
local overrides=""
|
local overrides=""
|
||||||
while IFS='=' read -r pkg_name tarball; do
|
while IFS='=' read -r pkg_name tarball; do
|
||||||
[[ -z "$pkg_name" ]] && continue
|
[[ -z "$pkg_name" ]] && continue
|
||||||
if [[ -n "$overrides" ]]; then overrides="$overrides, "; fi
|
if [[ -n "$overrides" ]]; then overrides="$overrides, "; fi
|
||||||
overrides="$overrides\"${pkg_name}\": \"file:${rel_prefix}.docker-deps/${tarball}\""
|
overrides="$overrides\"${pkg_name}\": \"file:${rel_prefix}.docker-deps/${tarball}\""
|
||||||
done < "$TARBALL_MAP_FILE"
|
done < "$TARBALL_MAP_FILE"
|
||||||
|
|
||||||
if [[ -n "$overrides" ]]; then
|
if [[ -n "$overrides" ]]; then
|
||||||
node -e "
|
node -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@ -300,17 +116,11 @@ inject_overrides() {
|
|||||||
inject_overrides "${REPO_DIR}/backend/package.json" "../"
|
inject_overrides "${REPO_DIR}/backend/package.json" "../"
|
||||||
inject_overrides "${REPO_DIR}/web/package.json" "../"
|
inject_overrides "${REPO_DIR}/web/package.json" "../"
|
||||||
|
|
||||||
# ── Summary ───────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✓ Done. $(ls $TARBALL_DIR/*.tgz 2>/dev/null | wc -l) tarballs in $TARBALL_DIR"
|
echo "Done. Tarballs in $TARBALL_DIR"
|
||||||
echo "✓ Cache size: $(du -sh $CACHE_DIR 2>/dev/null | cut -f1)"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "To build Docker images:"
|
echo "To build Docker images:"
|
||||||
echo " docker compose build"
|
echo " docker compose build"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To restore after build:"
|
echo "To restore after build:"
|
||||||
echo " ./scripts/docker-prep-optimized.sh --restore"
|
echo " ./scripts/docker-prep.sh --restore"
|
||||||
echo ""
|
|
||||||
echo "To clear cache and force full rebuild:"
|
|
||||||
echo " ./scripts/docker-prep-optimized.sh --clean"
|
|
||||||
echo " ./scripts/docker-prep-optimized.sh --force"
|
|
||||||
|
|||||||
@ -38,7 +38,6 @@ export default function SettingsPage() {
|
|||||||
const [feedbackText, setFeedbackText] = useState('');
|
const [feedbackText, setFeedbackText] = useState('');
|
||||||
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
|
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
|
||||||
const [feedbackSuccess, setFeedbackSuccess] = useState(false);
|
const [feedbackSuccess, setFeedbackSuccess] = useState(false);
|
||||||
const [feedbackError, setFeedbackError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@ -107,38 +106,6 @@ export default function SettingsPage() {
|
|||||||
.forEach((t) => removeTimer(t.id));
|
.forEach((t) => removeTimer(t.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFeedbackSubmit = async () => {
|
|
||||||
const body = feedbackText.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
setFeedbackSubmitting(true);
|
|
||||||
setFeedbackError('');
|
|
||||||
try {
|
|
||||||
await feedbackClient.submitWithScreenshot({
|
|
||||||
type: feedbackType,
|
|
||||||
title: body.slice(0, 100),
|
|
||||||
body,
|
|
||||||
screen: 'settings',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
platform: 'web',
|
|
||||||
deviceContext: {
|
|
||||||
osVersion: navigator.userAgent,
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
deviceModel: navigator.platform || 'web',
|
|
||||||
screenResolution: `${window.screen.width}x${window.screen.height}`,
|
|
||||||
locale: navigator.language,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setFeedbackSuccess(true);
|
|
||||||
setFeedbackText('');
|
|
||||||
setTimeout(() => setFeedbackSuccess(false), 4000);
|
|
||||||
} catch {
|
|
||||||
setFeedbackError('Feedback could not be sent right now. Please try again in a moment.');
|
|
||||||
} finally {
|
|
||||||
setFeedbackSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
||||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
@ -559,12 +526,8 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Feedback message"
|
|
||||||
value={feedbackText}
|
value={feedbackText}
|
||||||
onChange={(e) => {
|
onChange={(e) => setFeedbackText(e.target.value)}
|
||||||
setFeedbackText(e.target.value);
|
|
||||||
setFeedbackError('');
|
|
||||||
}}
|
|
||||||
placeholder="Tell us what you think..."
|
placeholder="Tell us what you think..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2 rounded-lg text-sm outline-none resize-none"
|
className="w-full px-3 py-2 rounded-lg text-sm outline-none resize-none"
|
||||||
@ -574,15 +537,21 @@ export default function SettingsPage() {
|
|||||||
border: '1px solid var(--cm-border)',
|
border: '1px solid var(--cm-border)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{feedbackError && (
|
|
||||||
<p className="text-xs" style={{ color: 'var(--cm-danger)' }} role="alert">
|
|
||||||
{feedbackError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleFeedbackSubmit}
|
onClick={async () => {
|
||||||
|
if (!feedbackText.trim()) return;
|
||||||
|
setFeedbackSubmitting(true);
|
||||||
|
try {
|
||||||
|
await feedbackClient.submitWithScreenshot({ type: feedbackType, title: feedbackText.trim().slice(0, 100), body: feedbackText.trim(), platform: 'web' });
|
||||||
|
setFeedbackSuccess(true);
|
||||||
|
setFeedbackText('');
|
||||||
|
setTimeout(() => setFeedbackSuccess(false), 4000);
|
||||||
|
} catch {
|
||||||
|
// fail silently — feedback is best-effort
|
||||||
|
}
|
||||||
|
setFeedbackSubmitting(false);
|
||||||
|
}}
|
||||||
disabled={feedbackSubmitting || !feedbackText.trim()}
|
disabled={feedbackSubmitting || !feedbackText.trim()}
|
||||||
aria-label="Send feedback"
|
|
||||||
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
|
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
|
||||||
style={{ backgroundColor: 'var(--cm-accent)', color: 'var(--cm-white)' }}
|
style={{ backgroundColor: 'var(--cm-accent)', color: 'var(--cm-white)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useRef, useCallback } from 'react';
|
|||||||
import { useTimerStore } from '@/lib/store';
|
import { useTimerStore } from '@/lib/store';
|
||||||
import { getUrgencyConfig } from '@/lib/urgency';
|
import { getUrgencyConfig } from '@/lib/urgency';
|
||||||
import { formatTime } from '@/lib/format';
|
import { formatTime } from '@/lib/format';
|
||||||
import { alertLabel, announceToScreenReader, focusFirstElement, trapFocusKeydown } from '@bytelyst/accessibility';
|
import { alertLabel } from '@bytelyst/accessibility';
|
||||||
import { Bell, BellOff } from 'lucide-react';
|
import { Bell, BellOff } from 'lucide-react';
|
||||||
|
|
||||||
export function AlarmOverlay() {
|
export function AlarmOverlay() {
|
||||||
@ -16,16 +16,28 @@ export function AlarmOverlay() {
|
|||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const trapFocus = useCallback((e: KeyboardEvent) => {
|
const trapFocus = useCallback((e: KeyboardEvent) => {
|
||||||
if (overlayRef.current) trapFocusKeydown(e, overlayRef.current);
|
if (e.key !== 'Tab' || !overlayRef.current) return;
|
||||||
|
const focusable = overlayRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (firingTimers.length === 0) return;
|
if (firingTimers.length === 0) return;
|
||||||
document.addEventListener('keydown', trapFocus);
|
document.addEventListener('keydown', trapFocus);
|
||||||
if (overlayRef.current) {
|
// Auto-focus dismiss button
|
||||||
focusFirstElement(overlayRef.current, 'button');
|
const el = overlayRef.current?.querySelector<HTMLElement>('button');
|
||||||
}
|
el?.focus();
|
||||||
announceToScreenReader(`Timer fired: ${firingTimers[0]?.label ?? 'timer'}`, 'assertive');
|
|
||||||
return () => document.removeEventListener('keydown', trapFocus);
|
return () => document.removeEventListener('keydown', trapFocus);
|
||||||
}, [firingTimers.length, trapFocus]);
|
}, [firingTimers.length, trapFocus]);
|
||||||
|
|
||||||
@ -75,7 +87,7 @@ export function AlarmOverlay() {
|
|||||||
: 'Timer fired!'}
|
: 'Timer fired!'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-secondary)' }}>
|
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
{formatTime(timer.firedAt ?? Date.now())}
|
{formatTime(timer.firedAt ?? Date.now())}
|
||||||
{timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`}
|
{timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`}
|
||||||
</p>
|
</p>
|
||||||
@ -134,7 +146,7 @@ export function AlarmOverlay() {
|
|||||||
|
|
||||||
{/* Multiple firing indicator */}
|
{/* Multiple firing indicator */}
|
||||||
{firingTimers.length > 1 && (
|
{firingTimers.length > 1 && (
|
||||||
<p className="mt-4 text-xs" style={{ color: 'var(--cm-text-secondary)' }}>
|
<p className="mt-4 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
+{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing
|
+{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories';
|
|||||||
import { parseNaturalLanguage } from '@/lib/nl-parser';
|
import { parseNaturalLanguage } from '@/lib/nl-parser';
|
||||||
import type { ParseResult } from '@/lib/nl-parser';
|
import type { ParseResult } from '@/lib/nl-parser';
|
||||||
import { alarmSchema, countdownSchema, pomodoroSchema, eventSchema } from '@/lib/schemas';
|
import { alarmSchema, countdownSchema, pomodoroSchema, eventSchema } from '@/lib/schemas';
|
||||||
import { announceToScreenReader, focusFirstElement, trapFocusKeydown } from '@bytelyst/accessibility';
|
|
||||||
|
|
||||||
type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
|
type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
|
||||||
|
|
||||||
@ -28,16 +27,27 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
|
|
||||||
const trapFocus = useCallback((e: KeyboardEvent) => {
|
const trapFocus = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { onClose(); return; }
|
if (e.key === 'Escape') { onClose(); return; }
|
||||||
if (dialogRef.current) trapFocusKeydown(e, dialogRef.current);
|
if (e.key !== 'Tab' || !dialogRef.current) return;
|
||||||
|
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
document.addEventListener('keydown', trapFocus);
|
document.addEventListener('keydown', trapFocus);
|
||||||
if (dialogRef.current) {
|
const el = dialogRef.current?.querySelector<HTMLElement>('input, button');
|
||||||
focusFirstElement(dialogRef.current, 'input, button');
|
el?.focus();
|
||||||
}
|
|
||||||
announceToScreenReader('Create timer dialog opened', 'polite');
|
|
||||||
return () => document.removeEventListener('keydown', trapFocus);
|
return () => document.removeEventListener('keydown', trapFocus);
|
||||||
}, [isOpen, trapFocus]);
|
}, [isOpen, trapFocus]);
|
||||||
|
|
||||||
@ -250,7 +260,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded-lg transition-colors cursor-pointer"
|
className="p-1 rounded-lg transition-colors cursor-pointer"
|
||||||
style={{ color: 'var(--cm-text-secondary)' }}
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
aria-label="Close dialog"
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
@ -261,7 +271,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
<div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
|
<div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} />
|
<Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} />
|
||||||
<label className="text-xs font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
<label className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
Quick create — type naturally
|
Quick create — type naturally
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -290,7 +300,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{nlResult && nlInput.trim() && (
|
{nlResult && nlInput.trim() && (
|
||||||
<div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-secondary)' }}>
|
<div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-tertiary)' }}>
|
||||||
{nlResult.success && nlResult.timer ? (
|
{nlResult.success && nlResult.timer ? (
|
||||||
<span>
|
<span>
|
||||||
{nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'}
|
{nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'}
|
||||||
@ -299,7 +309,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
{nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''}
|
{nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--cm-text-secondary)' }}>{nlResult.error}</span>
|
<span style={{ color: 'var(--cm-text-tertiary)' }}>{nlResult.error}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -313,7 +323,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
onClick={() => setTab(t.key)}
|
onClick={() => setTab(t.key)}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer"
|
className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-secondary)',
|
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)',
|
||||||
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
|
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -390,7 +400,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
color: 'var(--cm-text-primary)',
|
color: 'var(--cm-text-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="block text-center text-xs mt-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
<span className="block text-center text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
{field.label}
|
{field.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -431,7 +441,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
{ label: 'Rounds', value: rounds, setter: setRounds },
|
{ label: 'Rounds', value: rounds, setter: setRounds },
|
||||||
].map((field) => (
|
].map((field) => (
|
||||||
<div key={field.label}>
|
<div key={field.label}>
|
||||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
{field.label}
|
{field.label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -471,7 +481,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
/>
|
/>
|
||||||
{errors.eventDate && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.eventDate}</p>}
|
{errors.eventDate && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.eventDate}</p>}
|
||||||
{eventDate && new Date(eventDate).getTime() > Date.now() && (
|
{eventDate && new Date(eventDate).getTime() > Date.now() && (
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
{Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now · Milestone warnings at 30, 7, 3, 1 days
|
{Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now · Milestone warnings at 30, 7, 3, 1 days
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -490,7 +500,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: !category ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
backgroundColor: !category ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||||
color: !category ? 'var(--cm-white)' : 'var(--cm-text-secondary)',
|
color: !category ? 'var(--cm-white)' : 'var(--cm-text-tertiary)',
|
||||||
border: !category ? '1px solid var(--cm-accent)' : '1px solid transparent',
|
border: !category ? '1px solid var(--cm-accent)' : '1px solid transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -503,7 +513,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: category === cat.id ? `${cat.color}20` : 'var(--cm-surface-muted)',
|
backgroundColor: category === cat.id ? `${cat.color}20` : 'var(--cm-surface-muted)',
|
||||||
color: category === cat.id ? cat.color : 'var(--cm-text-secondary)',
|
color: category === cat.id ? cat.color : 'var(--cm-text-tertiary)',
|
||||||
border: category === cat.id ? `1px solid ${cat.color}60` : '1px solid transparent',
|
border: category === cat.id ? `1px solid ${cat.color}60` : '1px solid transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -530,7 +540,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
className="flex-1 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
className="flex-1 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: urgency === level ? config.bgColor : 'var(--cm-surface-muted)',
|
backgroundColor: urgency === level ? config.bgColor : 'var(--cm-surface-muted)',
|
||||||
color: urgency === level ? config.color : 'var(--cm-text-secondary)',
|
color: urgency === level ? config.color : 'var(--cm-text-tertiary)',
|
||||||
border: urgency === level ? `1px solid ${config.borderColor}` : '1px solid transparent',
|
border: urgency === level ? `1px solid ${config.borderColor}` : '1px solid transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -546,7 +556,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
{tab !== 'pomodoro' && (
|
{tab !== 'pomodoro' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
Custom Warning Message <span className="text-xs font-normal" style={{ color: 'var(--cm-text-secondary)' }}>(optional)</span>
|
Custom Warning Message <span className="text-xs font-normal" style={{ color: 'var(--cm-text-tertiary)' }}>(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -561,7 +571,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
color: 'var(--cm-text-primary)',
|
color: 'var(--cm-text-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
Shown in pre-warning notifications instead of auto-generated tips
|
Shown in pre-warning notifications instead of auto-generated tips
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,369 +0,0 @@
|
|||||||
import {
|
|
||||||
AppShell as BytelystAppShell,
|
|
||||||
AppShellMain as BytelystAppShellMain,
|
|
||||||
AppShellMobileToggle as BytelystAppShellMobileToggle,
|
|
||||||
AppShellNav as BytelystAppShellNav,
|
|
||||||
AppShellNavItem as BytelystAppShellNavItem,
|
|
||||||
AppShellOverlay as BytelystAppShellOverlay,
|
|
||||||
AppShellPageHeader as BytelystAppShellPageHeader,
|
|
||||||
AppShellSidebar as BytelystAppShellSidebar,
|
|
||||||
AppShellSkipLink as BytelystAppShellSkipLink,
|
|
||||||
Badge as BytelystBadge,
|
|
||||||
Button as BytelystButton,
|
|
||||||
Card as BytelystCard,
|
|
||||||
Checkbox,
|
|
||||||
DataList as BytelystDataList,
|
|
||||||
DataListItem as BytelystDataListItem,
|
|
||||||
DataListMeta,
|
|
||||||
DataTable,
|
|
||||||
DataTableBody,
|
|
||||||
DataTableCell,
|
|
||||||
DataTableHead,
|
|
||||||
DataTableHeader,
|
|
||||||
DataTableRow,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
EmptyState as BytelystEmptyState,
|
|
||||||
IconButton as BytelystIconButton,
|
|
||||||
Input as BytelystInput,
|
|
||||||
Label as BytelystLabel,
|
|
||||||
ListItemButton as BytelystListItemButton,
|
|
||||||
LoadingSpinner as BytelystLoadingSpinner,
|
|
||||||
Panel as BytelystPanel,
|
|
||||||
PanelBody as BytelystPanelBody,
|
|
||||||
PanelDescription as BytelystPanelDescription,
|
|
||||||
PanelHeader as BytelystPanelHeader,
|
|
||||||
PanelTitle as BytelystPanelTitle,
|
|
||||||
RadioGroup,
|
|
||||||
RadioGroupItem,
|
|
||||||
SegmentedControl,
|
|
||||||
Select as BytelystSelect,
|
|
||||||
StatusBadge as BytelystStatusBadge,
|
|
||||||
Surface as BytelystSurface,
|
|
||||||
SurfaceList as BytelystSurfaceList,
|
|
||||||
SurfaceListItem as BytelystSurfaceListItem,
|
|
||||||
Switch,
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
Textarea as BytelystTextarea,
|
|
||||||
Timeline as BytelystTimeline,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
type AppShellMainProps,
|
|
||||||
type AppShellMobileToggleProps,
|
|
||||||
type AppShellNavItemProps,
|
|
||||||
type AppShellNavProps,
|
|
||||||
type AppShellOverlayProps,
|
|
||||||
type AppShellPageHeaderProps,
|
|
||||||
type AppShellProps,
|
|
||||||
type AppShellSidebarProps,
|
|
||||||
type AppShellSkipLinkProps,
|
|
||||||
type BadgeProps,
|
|
||||||
type ButtonProps,
|
|
||||||
type CardProps,
|
|
||||||
type DataListItemProps,
|
|
||||||
type DataListProps,
|
|
||||||
type EmptyStateProps,
|
|
||||||
type IconButtonProps,
|
|
||||||
type InputProps,
|
|
||||||
type LabelProps,
|
|
||||||
type ListItemButtonProps,
|
|
||||||
type LoadingSpinnerProps,
|
|
||||||
type PanelBodyProps,
|
|
||||||
type PanelDescriptionProps,
|
|
||||||
type PanelHeaderProps,
|
|
||||||
type PanelProps,
|
|
||||||
type PanelTitleProps,
|
|
||||||
type SelectProps,
|
|
||||||
type StatusBadgeProps,
|
|
||||||
type StatusTone,
|
|
||||||
type SurfaceListItemProps,
|
|
||||||
type SurfaceListProps,
|
|
||||||
type SurfaceProps,
|
|
||||||
type TextareaProps,
|
|
||||||
type TimelineProps,
|
|
||||||
} from "@bytelyst/ui";
|
|
||||||
|
|
||||||
function mergeClassNames(...classes: Array<string | undefined>) {
|
|
||||||
return classes.filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Checkbox,
|
|
||||||
DataListMeta,
|
|
||||||
DataTable,
|
|
||||||
DataTableBody,
|
|
||||||
DataTableCell,
|
|
||||||
DataTableHead,
|
|
||||||
DataTableHeader,
|
|
||||||
DataTableRow,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
RadioGroup,
|
|
||||||
RadioGroupItem,
|
|
||||||
SegmentedControl,
|
|
||||||
Switch,
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChronoMindStatus =
|
|
||||||
| "running"
|
|
||||||
| "paused"
|
|
||||||
| "stopped"
|
|
||||||
| "completed"
|
|
||||||
| "alarm"
|
|
||||||
| "snoozed"
|
|
||||||
| "overdue"
|
|
||||||
| "upcoming"
|
|
||||||
| "recurring"
|
|
||||||
| "one-time"
|
|
||||||
| "focus"
|
|
||||||
| "break"
|
|
||||||
| "pomodoro"
|
|
||||||
| "routine"
|
|
||||||
| "error"
|
|
||||||
| "success";
|
|
||||||
|
|
||||||
const statusToneMap: Record<ChronoMindStatus, StatusTone> = {
|
|
||||||
running: "success",
|
|
||||||
paused: "warning",
|
|
||||||
stopped: "neutral",
|
|
||||||
completed: "success",
|
|
||||||
alarm: "danger",
|
|
||||||
snoozed: "warning",
|
|
||||||
overdue: "danger",
|
|
||||||
upcoming: "info",
|
|
||||||
recurring: "info",
|
|
||||||
"one-time": "neutral",
|
|
||||||
focus: "success",
|
|
||||||
break: "info",
|
|
||||||
pomodoro: "success",
|
|
||||||
routine: "info",
|
|
||||||
error: "danger",
|
|
||||||
success: "success",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppShell({ className, ...props }: AppShellProps) {
|
|
||||||
return <BytelystAppShell className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellSkipLink({ className, ...props }: AppShellSkipLinkProps) {
|
|
||||||
return <BytelystAppShellSkipLink className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellMobileToggle({ className, ...props }: AppShellMobileToggleProps) {
|
|
||||||
return <BytelystAppShellMobileToggle className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellOverlay({ className, ...props }: AppShellOverlayProps) {
|
|
||||||
return <BytelystAppShellOverlay className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellSidebar({ className, ...props }: AppShellSidebarProps) {
|
|
||||||
return <BytelystAppShellSidebar className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellMain({ className, ...props }: AppShellMainProps) {
|
|
||||||
return <BytelystAppShellMain className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellPageHeader({ className, ...props }: AppShellPageHeaderProps) {
|
|
||||||
return <BytelystAppShellPageHeader className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellNav({ className, ...props }: AppShellNavProps) {
|
|
||||||
return <BytelystAppShellNav className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShellNavItem({ className, ...props }: AppShellNavItemProps) {
|
|
||||||
return <BytelystAppShellNavItem className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatusTone(status: ChronoMindStatus): StatusTone {
|
|
||||||
return statusToneMap[status];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button({ className, ...props }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<BytelystButton
|
|
||||||
className={mergeClassNames("rounded-[var(--cm-radius-sm)]", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Badge({ className, ...props }: BadgeProps) {
|
|
||||||
return <BytelystBadge className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Card({ className, ...props }: CardProps) {
|
|
||||||
return (
|
|
||||||
<BytelystCard
|
|
||||||
className={mergeClassNames(
|
|
||||||
"rounded-[var(--cm-radius-md)] shadow-[var(--cm-elevation-md)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Panel({ className, ...props }: PanelProps) {
|
|
||||||
return (
|
|
||||||
<BytelystPanel
|
|
||||||
className={mergeClassNames(
|
|
||||||
"rounded-[var(--cm-radius-md)] shadow-[var(--cm-elevation-md)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelHeader({ className, ...props }: PanelHeaderProps) {
|
|
||||||
return <BytelystPanelHeader className={mergeClassNames("gap-[var(--cm-space-3)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelBody({ className, ...props }: PanelBodyProps) {
|
|
||||||
return <BytelystPanelBody className={mergeClassNames("gap-[var(--cm-space-3)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelTitle({ className, ...props }: PanelTitleProps) {
|
|
||||||
return <BytelystPanelTitle className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelDescription({ className, ...props }: PanelDescriptionProps) {
|
|
||||||
return <BytelystPanelDescription className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IconButton({ className, ...props }: IconButtonProps) {
|
|
||||||
return <BytelystIconButton className={mergeClassNames("rounded-[var(--cm-radius-sm)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ListItemButton({ className, ...props }: ListItemButtonProps) {
|
|
||||||
return (
|
|
||||||
<BytelystListItemButton
|
|
||||||
className={mergeClassNames(
|
|
||||||
"rounded-[var(--cm-radius-sm)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChronoMindStatusBadgeProps extends StatusBadgeProps {
|
|
||||||
status?: ChronoMindStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ className, status, tone, ...props }: ChronoMindStatusBadgeProps) {
|
|
||||||
return <BytelystStatusBadge className={className} tone={tone ?? (status ? getStatusTone(status) : undefined)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Input({ className, ...props }: InputProps) {
|
|
||||||
return (
|
|
||||||
<BytelystInput
|
|
||||||
className={mergeClassNames(
|
|
||||||
"rounded-[var(--cm-radius-sm)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Textarea({ className, ...props }: TextareaProps) {
|
|
||||||
return (
|
|
||||||
<BytelystTextarea
|
|
||||||
className={mergeClassNames(
|
|
||||||
"rounded-[var(--cm-radius-sm)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Select({ className, ...props }: SelectProps) {
|
|
||||||
return (
|
|
||||||
<BytelystSelect
|
|
||||||
className={mergeClassNames(
|
|
||||||
"rounded-[var(--cm-radius-sm)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Label({ className, ...props }: LabelProps) {
|
|
||||||
return <BytelystLabel className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Timeline({ className, ...props }: TimelineProps) {
|
|
||||||
return <BytelystTimeline className={mergeClassNames("gap-[var(--cm-space-3)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Surface({ className, padding = "md", ...props }: SurfaceProps) {
|
|
||||||
return (
|
|
||||||
<BytelystSurface
|
|
||||||
padding={padding}
|
|
||||||
className={mergeClassNames("rounded-[var(--cm-radius-md)]", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SurfaceList({ density = "normal", className, ...props }: SurfaceListProps) {
|
|
||||||
return <BytelystSurfaceList density={density} className={mergeClassNames("gap-[var(--cm-space-3)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SurfaceListItem({ className, ...props }: SurfaceListItemProps) {
|
|
||||||
return <BytelystSurfaceListItem className={mergeClassNames("rounded-[var(--cm-radius-sm)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataList({ density = "normal", className, ...props }: DataListProps) {
|
|
||||||
return <BytelystDataList density={density} className={mergeClassNames("gap-[var(--cm-space-3)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataListItem({ className, ...props }: DataListItemProps) {
|
|
||||||
return <BytelystDataListItem className={mergeClassNames("rounded-[var(--cm-radius-sm)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OperationalList = DataList;
|
|
||||||
export const OperationalListItem = DataListItem;
|
|
||||||
|
|
||||||
export function EmptyState({ className, ...props }: EmptyStateProps) {
|
|
||||||
return <BytelystEmptyState className={mergeClassNames("rounded-[var(--cm-radius-md)]", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingSpinner({ className, ...props }: LoadingSpinnerProps) {
|
|
||||||
return <BytelystLoadingSpinner className={className} {...props} />;
|
|
||||||
}
|
|
||||||
@ -11,7 +11,7 @@ import { createFeedbackClient } from '@bytelyst/feedback-client';
|
|||||||
import { getAuthClient, getBaseUrl } from './auth-api';
|
import { getAuthClient, getBaseUrl } from './auth-api';
|
||||||
|
|
||||||
const feedbackClient = createFeedbackClient({
|
const feedbackClient = createFeedbackClient({
|
||||||
baseUrl: getBaseUrl(),
|
baseUrl: `${getBaseUrl()}/api`,
|
||||||
getAuthToken: () => getAuthClient().getAccessToken(),
|
getAuthToken: () => getAuthClient().getAccessToken(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user