fix: various ui fixes

This commit is contained in:
melvinchia3636
2026-06-24 22:47:17 +08:00
parent 104976d51c
commit e3de256d87
8 changed files with 105 additions and 64 deletions

View File

@@ -18,14 +18,6 @@ export function clientError({
}) {
const logger = createLogger({ name: moduleName || 'unknown-module' })
fs.readdirSync('medium').forEach(file => {
if (fs.statSync('medium/' + file).isFile()) {
fs.unlinkSync('medium/' + file)
} else {
fs.rmdirSync('medium/' + file, { recursive: true })
}
})
try {
logger.error(
chalk.red(typeof message === 'string' ? message : JSON.stringify(message))
@@ -43,14 +35,6 @@ export function clientError({
export function serverError(res: Response, err?: string, moduleName?: string) {
const logger = createLogger({ name: moduleName || 'unknown-module' })
fs.readdirSync('medium').forEach(file => {
if (fs.statSync('medium/' + file).isFile()) {
fs.unlinkSync('medium/' + file)
} else {
fs.rmdirSync('medium/' + file, { recursive: true })
}
})
try {
logger.error(chalk.red(err))

View File

@@ -724,11 +724,35 @@ For each field in `.setupFields({...})`, render the corresponding new field comp
| `amount: { ... }` with type `currency` | `<CurrencyField name="amount" .../>` | Same as key |
| `amount: { ... }` with type `number` | `<NumberField name="amount" .../>` | Same as key |
**Old field config property → new field prop mapping:**
**Localization Convention for Field Labels:**
| Old `.setupFields` property | New prop | Remarks |
| --------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `label` | `label` | Same — still passed through i18n translation |
Field `label` props are **auto-translated** through the module's i18n namespace (provided by `FormModal`'s `namespace` prop). The locale keys follow this convention:
```json
{
"inputs": {
"modifyCollection": {
"name": "Collection Name"
},
"modifyType": {
"name": "Category Name",
"icon": "Category Icon"
},
"modifyEntry": {
"name": "Music Name",
"author": "Author",
"type": "Score Type",
"collection": "Collection"
}
}
}
```
The label prop references the key **after** `inputs.` — e.g. `label="modifyEntry.name"` resolves to `inputs.modifyEntry.name` in the locale file. There is no `inputs.` prefix needed in the label prop — the `FieldWrapper` component auto-prepends the `inputs.` namespace segment.
> 💡 **Why this convention?** The `inputs.` grouping in the locale file keeps all form field labels organized under one section, while the label prop stays short and focused on the modal-specific key. Each modal gets its own sub-object under `inputs` (e.g. `modifyCollection`, `modifyEntry`, `modifyType`), avoiding flat naming collisions.
**Old field config property → new field prop mapping:**
| `icon` | `icon` | Same |
| `required` | `required` | Same |
| `placeholder` | `placeholder` | Same |

View File

@@ -89,6 +89,25 @@ Typography values scale with `--custom-font-scale`:
- `semibold`: `'600'`
- `bold`: `'700'`
### D. Category/Tag Chips
For dynamic category labels or tag chips that need background colors based on data (not theme tokens), use **inline `style` with `backgroundColor`** set directly from the data source. Do NOT create `.css.ts` files for one-off dynamic colors — inline styles are the correct pattern here because the color value is computed at runtime from external data.
**Correct (dynamic colors from data):**
```tsx
<span
style={{
backgroundColor: category.color + '20',
color: category.color
}}
>
{category.name}
</span>
```
> [!TIP]
> **Dynamic category colors are the exception to Rule 2.** If the background/color pair comes from database or API data (e.g. `bg-green-500/20 text-green-500`), use inline `style` with hex color + opacity. Do not create token mappings or CSS files for dynamic data-driven colors.
---
## 3. Style Resolution & The Styling Engine
@@ -111,6 +130,7 @@ Colors in LifeForge are resolved through an **arbitrary CSS variable architectur
1. **Base Palette:** `transparent`, `dangerous` (`#ef4444`), `muted` (mid-gray), `primary` (user-accented), `inherit`, `custom-50` to `custom-900` (accent shades), and `bg-50` to `bg-950` (system background shades).
2. **Tailwind Palette Names:** Colors like `red-500`, `blue-600`, `emerald-400` map directly to tailwind shades.
3. **Color with Opacity:** Zero-runtime opacity can be added using the `colorWithOpacity(token, opacityValue)` utility.
4. **No `white` or `black` color tokens.** Use `bg-50` for white and `bg-950` for black. The `green-*`, `red-*`, `yellow-*`, and other Tailwind palette names are available for semantic colors (e.g. `red-500` for destructive states, `green-600` for success).
#### Theme & State Specific Variants
@@ -190,7 +210,24 @@ const semiTransparentPrimary = colorWithOpacity('primary', '30%')
_Supported Opacity Levels:_ `'5%'`, `'10%'`, `'20%'`, `'30%'`, `'40%'`, `'50%'`, `'60%'`, `'70%'`, `'80%'`, `'90%'`.
`colorWithOpacity` can be used **directly in the `bg` prop** — no inline `style` needed:
`colorWithOpacity` can be used **directly in the `bg` prop** — no inline `style` needed. It also works in vanilla-extract `.css.ts` files at build time via `.toString()`:
```typescript
// In a .css.ts file (build-time evaluation)
import { colorWithOpacity } from '@lifeforge/ui'
const bg200Opacity30 = colorWithOpacity('bg-200', '30%').toString()
export const stripe = style({
selectors: {
'&:nth-child(odd)': {
backgroundColor: bg200Opacity30
}
}
})
```
> **Important:** `colorWithOpacity` with `'80%'` opacity is the correct replacement for Tailwind's `/80` opacity syntax (e.g. `bg-500/80`). Do not use inline `style` for opacity.
```tsx
<Flex
@@ -279,8 +316,19 @@ interface BoxOwnProps<T extends ElementType = 'div'> {
minHeight?: ResponsiveProp<string>; maxHeight?: ResponsiveProp<string>
inset?: ResponsiveProp<string>; top?: ResponsiveProp<string>; bottom?: ResponsiveProp<string>
left?: ResponsiveProp<string>; right?: ResponsiveProp<string>; zIndex?: ResponsiveProp<string>
// Arbitrary CSS Props (responsive strings)
aspectRatio?: ResponsiveProp<string>
flex?: ResponsiveProp<string>; flexBasis?: ResponsiveProp<string>
flexGrow?: ResponsiveProp<string>; flexShrink?: ResponsiveProp<string>
overflow?: ResponsiveProp<'visible' | 'hidden' | 'scroll' | 'auto'>
overflowX?: ResponsiveProp<'visible' | 'hidden' | 'scroll' | 'auto'>
overflowY?: ResponsiveProp<'visible' | 'hidden' | 'scroll' | 'auto'>
```
> [!TIP]
> **`aspectRatio`, `overflow`, `flex`, `flexGrow`, `flexShrink`, `flexBasis` are available as props on all primitives** — no inline `style` needed. Always check if a CSS property exists in the `ArbitraryProps` type before resorting to inline styles.
> [!WARNING]
> **`top`, `right`, `bottom`, `left`, and `inset` accept raw CSS strings** (e.g. `"0.5rem"`, `"8px"`, `"50%"`), not `SpaceToken` values like `sm`, `md`, `lg`.
> Spacing tokens only work with padding (`p`, `px`, `py`, `pt`, `pr`, `pb`, `pl`) and margin (`m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml`) props.
@@ -1502,6 +1550,9 @@ Before submitting a pull request, verify that you have adhered to all core desig
- [ ] **Correct loaders:** Form/button loading states use the pre-animated `svg-spinners:ring-resize` icon and **never** use custom `animate-spin` utilities.
- [ ] **Type safety:** Typescript `any` is never used. All prop overrides and custom handlers are explicitly typed.
- [ ] **Datetime manipulation:** Standard JavaScript `Date` is never used. `day.js` is imported for any date calculations.
- [ ] **Check ArbitraryProps first:** Before reaching for inline `style` or `.css.ts`, check if the CSS property is available via `ArbitraryProps` (e.g. `aspectRatio`, `overflow`, `flex`, `flexGrow`, `flexShrink`, `flexBasis`).
- [ ] **Component deconstruction:** If a component's JSX grows beyond a single conceptual block, extract sub-sections into their own component files under a `components/` subdirectory.
- [ ] **Hooks co-location:** Hooks that are only consumed by a child component should live inside that child component, not in the parent.
- [ ] **List spacing:** For vertical lists (`Stack`), `mb="lg"` is the standard bottom margin — no need to define responsive sizes like `mb={{ base: '6rem', md: 'lg' }}`. The spacing token `lg` is consistent across breakpoints and is sufficient for all list containers.
- [ ] **Component organization:** Components are strictly separated into individual files under their respective `components/` folders instead of being grouped together.
- [ ] **Conventional functions:** All React components use standard function declarations (`export function Component()`) and avoid arrow functions.

View File

@@ -1,9 +1,15 @@
import { Flex, Icon, Text, Transition } from '@/components/primitives'
import {
Flex,
type FlexProps,
Icon,
Text,
Transition
} from '@/components/primitives'
interface ViewModeSelectorProps<
T extends ReadonlyArray<{ value: string; icon?: string; text?: string }>,
TKey = T[number]['value']
> {
> extends FlexProps<'div'> {
/** The current selected mode */
currentMode: TKey
size?: 'small' | 'default'
@@ -23,7 +29,8 @@ export function ViewModeSelector<
currentMode,
size = 'default',
onModeChange,
options
options,
...rest
}: ViewModeSelectorProps<T, TKey>) {
return (
<Flex
@@ -34,6 +41,7 @@ export function ViewModeSelector<
height="4em"
p={size === 'small' ? 'xs' : 'sm'}
r="lg"
{...rest}
>
{options.map(({ value, icon, text }) => (
<Transition key={value}>

View File

@@ -58,13 +58,7 @@ export function EmptyStateScreen({
>
{icon !== undefined &&
(typeof icon === 'string' ? (
<Icon
icon={icon}
style={{
width: smaller ? '4.5rem' : '8rem',
height: smaller ? '4.5rem' : '8rem'
}}
/>
<Icon icon={icon} size={smaller ? '4rem' : '8rem'} />
) : (
icon
))}
@@ -72,7 +66,7 @@ export function EmptyStateScreen({
align="center"
as="h2"
color={{ base: 'bg-800', dark: 'bg-100' }}
size={smaller ? '2xl' : '3xl'}
size={smaller ? 'xl' : '3xl'}
style={{ paddingLeft: '1.5rem', paddingRight: '1.5rem' }}
weight="semibold"
>
@@ -97,12 +91,8 @@ export function EmptyStateScreen({
<Text
align="center"
as="p"
size={smaller ? 'base' : 'lg'}
style={{
marginTop: '-0.5rem',
paddingLeft: '1.5rem',
paddingRight: '1.5rem'
}}
px="lg"
size={smaller ? 'sm' : 'lg'}
whiteSpace="pre-wrap"
>
{message.description}
@@ -116,12 +106,8 @@ export function EmptyStateScreen({
<Text
align="center"
as="p"
size={smaller ? 'base' : 'lg'}
style={{
marginTop: '-0.5rem',
paddingLeft: '1.5rem',
paddingRight: '1.5rem'
}}
px="lg"
size={smaller ? 'sm' : 'lg'}
whiteSpace="pre-wrap"
>
{message.namespace === false

View File

@@ -28,6 +28,7 @@ export function ErrorScreen({ message, showRetryButton }: ErrorScreenProps) {
{showRetryButton && (
<Button
icon="tabler:refresh"
namespace={false}
variant="secondary"
onClick={() => window.location.reload()}
>

View File

@@ -162,7 +162,6 @@ export function ModuleHeader({
position="relative"
r="md"
style={{ zIndex: 50 }}
width="24em"
>
<Menu as="div">
<Transition>

View File

@@ -1,13 +1,13 @@
import { Flex } from '@/components/primitives'
import { Flex, type FlexProps } from '@/components/primitives'
import { NavButton } from './components/NavButton'
import { PageNumbers } from './components/PageNumbers'
interface PaginationProps {
interface PaginationProps extends FlexProps<'div'> {
/** Current active page */
page: number
/** Callback function when the page is changed */
onPageChange: (page: number | ((prevPage: number) => number)) => void
onPageChange: (page: number) => void
/** Total number of pages */
totalPages: number
/** Additional class names for the pagination container */
@@ -18,30 +18,18 @@ export function Pagination({
page,
onPageChange,
totalPages,
className = ''
...rest
}: PaginationProps): React.ReactElement {
const previousPage = () => {
onPageChange(prevPage => {
if (prevPage > 1) {
return prevPage - 1
}
return prevPage
})
onPageChange(Math.max(1, page - 1))
}
const nextPage = () => {
onPageChange(prevPage => {
if (prevPage < totalPages) {
return prevPage + 1
}
return prevPage
})
onPageChange(Math.min(totalPages, page + 1))
}
return (
<Flex align="center" className={className} gap="sm" justify="between">
<Flex align="center" gap="sm" justify="between" {...rest}>
<NavButton
direction="previous"
hidden={page === 1}