diff --git a/apps/api/src/core/functions/routes/utils/response.ts b/apps/api/src/core/functions/routes/utils/response.ts
index 11b5c3d6a..f24a9982e 100644
--- a/apps/api/src/core/functions/routes/utils/response.ts
+++ b/apps/api/src/core/functions/routes/utils/response.ts
@@ -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))
diff --git a/instructions/form-system-migration.md b/instructions/form-system-migration.md
index 18a845380..fd4129ed3 100644
--- a/instructions/form-system-migration.md
+++ b/instructions/form-system-migration.md
@@ -724,11 +724,35 @@ For each field in `.setupFields({...})`, render the corresponding new field comp
| `amount: { ... }` with type `currency` | `` | Same as key |
| `amount: { ... }` with type `number` | `` | 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 |
diff --git a/packages/ui/DESIGN.md b/packages/ui/DESIGN.md
index ad738ea9f..4a59b3b82 100644
--- a/packages/ui/DESIGN.md
+++ b/packages/ui/DESIGN.md
@@ -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
+
+ {category.name}
+
+```
+
+> [!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
{
minHeight?: ResponsiveProp; maxHeight?: ResponsiveProp
inset?: ResponsiveProp; top?: ResponsiveProp; bottom?: ResponsiveProp
left?: ResponsiveProp; right?: ResponsiveProp; zIndex?: ResponsiveProp
+
+ // Arbitrary CSS Props (responsive strings)
+ aspectRatio?: ResponsiveProp
+ flex?: ResponsiveProp; flexBasis?: ResponsiveProp
+ flexGrow?: ResponsiveProp; flexShrink?: ResponsiveProp
+ 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.
diff --git a/packages/ui/src/components/data-display/ViewModeSelector/index.tsx b/packages/ui/src/components/data-display/ViewModeSelector/index.tsx
index 359fd928a..9bdf7473b 100644
--- a/packages/ui/src/components/data-display/ViewModeSelector/index.tsx
+++ b/packages/ui/src/components/data-display/ViewModeSelector/index.tsx
@@ -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) {
return (
{options.map(({ value, icon, text }) => (
diff --git a/packages/ui/src/components/feedback/EmptyStateScreen/index.tsx b/packages/ui/src/components/feedback/EmptyStateScreen/index.tsx
index 88e93e187..286736346 100644
--- a/packages/ui/src/components/feedback/EmptyStateScreen/index.tsx
+++ b/packages/ui/src/components/feedback/EmptyStateScreen/index.tsx
@@ -58,13 +58,7 @@ export function EmptyStateScreen({
>
{icon !== undefined &&
(typeof icon === 'string' ? (
-
+
) : (
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({
{message.description}
@@ -116,12 +106,8 @@ export function EmptyStateScreen({
{message.namespace === false
diff --git a/packages/ui/src/components/feedback/ErrorScreen/index.tsx b/packages/ui/src/components/feedback/ErrorScreen/index.tsx
index b00a717dd..541f06f2e 100644
--- a/packages/ui/src/components/feedback/ErrorScreen/index.tsx
+++ b/packages/ui/src/components/feedback/ErrorScreen/index.tsx
@@ -28,6 +28,7 @@ export function ErrorScreen({ message, showRetryButton }: ErrorScreenProps) {
{showRetryButton && (