chore: cleanup standards (#6970)

* chore: cleanup standards & skills

* remove: figma mcp doc, not needed anymore
This commit is contained in:
Calum H.
2026-08-10 15:15:44 +00:00
committed by GitHub
parent 98cb6b6b44
commit e8aee15664
39 changed files with 1271 additions and 1130 deletions
+49 -49
View File
@@ -1,25 +1,25 @@
- [Adding a New API Module](#adding-a-new-api-module)
- [Steps](#steps)
- [1. Define types in the module's `types.ts`](#1-define-types-in-the-modules-typests)
- [Add an API module](#add-an-api-module)
- [Procedure](#procedure)
- [1. Define types in `types.ts`](#1-define-types-in-typests)
- [2. Create the module class](#2-create-the-module-class)
- [Request options](#request-options)
- [For uploads](#for-uploads)
- [3. Register in the MODULE\_REGISTRY](#3-register-in-the-module_registry)
- [File uploads](#file-uploads)
- [3. Register the module](#3-register-the-module)
- [4. Export types](#4-export-types)
- [Naming Conventions](#naming-conventions)
- [Key Files](#key-files)
- [Naming conventions](#naming-conventions)
- [Key files](#key-files)
# Adding a New API Module
# Add an API Module
How to add a new API endpoint module to `packages/api-client`.
Use this procedure to add an API endpoint module to `packages/api-client`.
## Steps
## Procedure
### 1. Define types in the module's `types.ts`
### 1. Define Types in `types.ts`
Types must match 1:1 with the backend API response. Do not reshape, rename, or omit fields.
Make the types match the backend API response exactly. Do not change, rename, or remove fields.
Add to an existing namespace or create a new one:
Add the types to an existing namespace, or make a new namespace:
```ts
// modules/labrinth/types.ts (existing namespace)
@@ -30,7 +30,7 @@ export namespace Labrinth {
id: string
name: string
created: string
// ... matches API response exactly
// Match the API response exactly.
}
export type CreateThingRequest = {
@@ -41,11 +41,11 @@ export namespace Labrinth {
}
```
For a new API service, create `modules/<service>/types.ts` with a new top-level namespace and re-export it from `modules/types.ts`.
For a new API service, make `modules/<service>/types.ts` with a new top-level namespace. Export it from `modules/types.ts`.
### 2. Create the module class
### 2. Create the Module Class
Create `modules/<api>/<domain>/v<N>.ts`:
Make `modules/<api>/<domain>/v<N>.ts`:
```ts
// modules/labrinth/things/v3.ts
@@ -84,21 +84,21 @@ export class LabrinthThingsV3Module extends AbstractModule {
}
```
#### Request options
#### Request Options
| Field | Values | Purpose |
| ------------- | ------------------------------------------------- | ---------------------------------- |
| `api` | `'labrinth'`, `'archon'`, or a full URL | Which base URL to use |
| `version` | `2`, `3`, `'internal'`, `'modrinth/v0'`, etc. | URL version segment |
| `method` | `'GET'`, `'POST'`, `'PUT'`, `'PATCH'`, `'DELETE'` | HTTP method |
| `body` | object | JSON request body |
| `params` | `Record<string, string>` | Query parameters |
| `skipAuth` | `boolean` | Skip auth feature for this request |
| `useNodeAuth` | `boolean` | Use node-level auth (kyros) |
| `timeout` | `number` | Request timeout in ms |
| `retry` | `boolean \| number` | Override retry behavior |
| Field | Values | Purpose |
| ------------- | ------------------------------------------------- | -------------------------------------- |
| `api` | `'labrinth'`, `'archon'`, or a full URL | Select the base URL. |
| `version` | `2`, `3`, `'internal'`, `'modrinth/v0'`, and more | Set the URL version segment. |
| `method` | `'GET'`, `'POST'`, `'PUT'`, `'PATCH'`, `'DELETE'` | Set the HTTP method. |
| `body` | object | Set the JSON request body. |
| `params` | `Record<string, string>` | Set the query parameters. |
| `skipAuth` | `boolean` | Bypass the authentication feature. |
| `useNodeAuth` | `boolean` | Use node-level Kyros authentication. |
| `timeout` | `number` | Set the request timeout in milliseconds. |
| `retry` | `boolean \| number` | Override the retry behavior. |
#### For uploads
#### File Uploads
Return an `UploadHandle` instead of a `Promise`:
@@ -111,7 +111,7 @@ public uploadThing(id: string, file: File): UploadHandle<void> {
})
}
// Or with FormData for multipart:
// Use FormData for a multipart upload.
public createWithFiles(data: CreateRequest, files: File[]): UploadHandle<Thing> {
const formData = new FormData()
formData.append('data', JSON.stringify(data))
@@ -121,29 +121,29 @@ public createWithFiles(data: CreateRequest, files: File[]): UploadHandle<Thing>
api: 'labrinth',
version: 3,
formData,
timeout: 60 * 5 * 1000, // longer timeout for uploads
timeout: 60 * 5 * 1000, // Use a longer upload timeout.
})
}
```
### 3. Register in the MODULE_REGISTRY
### 3. Register the Module
Add to `modules/index.ts`:
Add the module to `MODULE_REGISTRY` in `modules/index.ts`:
```ts
import { LabrinthThingsV3Module } from './labrinth/things/v3'
export const MODULE_REGISTRY = {
// ... existing modules
// Existing modules.
labrinth_things_v3: LabrinthThingsV3Module,
} as const
```
The naming convention is `<api>_<domain>_<version>`. This flat key gets transformed into nested access: `client.labrinth.things_v3`.
Use `<api>_<domain>_<version>` for the key. The client converts this flat key to `client.labrinth.things_v3`.
### 4. Export types
### 4. Export Types
If you added to an existing namespace, types are already re-exported. If you created a new `types.ts`, add it to `modules/types.ts`:
Types in an existing namespace already have an export. For a new `types.ts`, add this export to `modules/types.ts`:
```ts
export * from './<service>/types'
@@ -151,17 +151,17 @@ export * from './<service>/types'
## Naming Conventions
| Convention | Example |
| -------------- | ---------------------------------------------------- |
| Module class | `LabrinthThingsV3Module` `{Api}{Domain}V{N}Module` |
| Module ID | `labrinth_things_v3` `{api}_{domain}_v{n}` |
| Type namespace | `Labrinth.MyDomain.v3.Thing` |
| File path | `modules/labrinth/things/v3.ts` |
| Item | Example | Pattern |
| -------------- | ------------------------------- | --------------------------- |
| Module class | `LabrinthThingsV3Module` | `{Api}{Domain}V{N}Module` |
| Module ID | `labrinth_things_v3` | `{api}_{domain}_v{n}` |
| Type namespace | `Labrinth.MyDomain.v3.Thing` | `Api.Domain.version.Type` |
| File path | `modules/labrinth/things/v3.ts` | `modules/api/domain/vN.ts` |
## Key Files
- `src/core/abstract-module.ts` — base class all modules extend
- `src/core/abstract-client.ts` `request()` and `upload()` methods
- `src/modules/index.ts` `MODULE_REGISTRY` and `buildModuleStructure()`
- `src/modules/<api>/types.ts` — type definitions per API
- `src/types/upload.ts` `UploadHandle`, `UploadProgress`, `UploadRequestOptions`
- `src/core/abstract-module.ts`: Base class for all modules.
- `src/core/abstract-client.ts`: Contains the `request()` and `upload()` methods.
- `src/modules/index.ts`: Contains `MODULE_REGISTRY` and `buildModuleStructure()`.
- `src/modules/<api>/types.ts`: Contains the types for each API.
- `src/types/upload.ts`: Contains `UploadHandle`, `UploadProgress`, and `UploadRequestOptions`.
+35 -31
View File
@@ -1,8 +1,8 @@
# Component Structure
## Component folders
## Component Folders
Prefer giving non-trivial components their own folder:
Give each complex component its own folder:
```
components/
@@ -14,25 +14,25 @@ components/
└── use-analytics-chart.ts
```
The folder name should match the public component name in kebab case. The main component in that folder should be `index.vue`.
Use the public component name in kebab case for the folder name. Use `index.vue` for the main component.
This keeps imports short:
This structure keeps imports short:
```ts
import AnalyticsChart from '@/components/analytics-chart/index.vue'
```
If the local resolver supports directory indexes, importing the folder is also fine:
You can import the folder if the local resolver supports directory indexes:
```ts
import AnalyticsChart from '@/components/analytics-chart/'
```
Use the explicit `index.vue` import when the TypeScript setup cannot resolve the directory import reliably.
Use the explicit `index.vue` import if TypeScript cannot resolve the directory import.
## Local implementation files
## Local Implementation Files
Keep files that only exist to support one component inside that component's folder:
Keep files for only one component in that component's folder:
```
analytics-chart/
@@ -44,18 +44,18 @@ analytics-chart/
└── use-chart-hover-state.ts
```
Good candidates for local files:
Use local files for these items:
- Small subcomponents used only by the main component
- Local composables used only by the main component or its local subcomponents
- Helpers that split up a large `<script setup>` block
- Types that describe local component state or props
- Small subcomponents that only the main component uses.
- Local composables that only the component folder uses.
- Helpers that divide a large `<script setup>` block.
- Types for local component state or props.
This is preferred over allowing a single component file to grow into a large, hard-to-review script block.
This structure prevents large script blocks that are difficult to review.
## Naming local subcomponents
## Local Subcomponent Names
Local subcomponents should still have clear names that explain their relationship to the main component:
Use clear names that show the relation between each subcomponent and its main component:
```
analytics-chart/
@@ -64,7 +64,7 @@ analytics-chart/
└── analytics-chart-plot.vue
```
Avoid vague names that make a local component look like a standalone public component:
Do not use names that make a local component look like a public component:
```
analytics-chart/
@@ -73,13 +73,13 @@ analytics-chart/
└── header.vue
```
If a file is local to `analytics-chart`, prefixing it with `analytics-chart-` makes that relationship clear when it appears in search results, editor tabs, and imports.
Add the `analytics-chart-` prefix to local filenames. This prefix shows the relation in search results, editor tabs, and imports.
## Nesting
One level of nesting is usually enough.
Use one nesting level in most component folders.
Prefer this:
Use this structure:
```
analytics-chart/
@@ -90,7 +90,7 @@ analytics-chart/
└── use-chart-selection.ts
```
Avoid this unless a local area has become large enough to justify its own module boundary:
Do not use this structure unless a local area needs its own module boundary:
```
analytics-chart/
@@ -102,11 +102,13 @@ analytics-chart/
└── use-plot-state.ts
```
Subfolders are fine when they reduce real complexity, but do not create a folder for every small subcomponent by default. Deep nesting makes the file tree harder to scan and often adds duplicated names without improving ownership.
Use subfolders when they reduce real complexity. Do not make a folder for each small subcomponent.
## When not to use a folder
Deep nesting makes the file tree difficult to scan. It also causes duplicate names without clearer ownership.
Small, leaf components can stay as a single `.vue` file:
## Small Components
Keep small leaf components in single `.vue` files:
```
components/
@@ -115,14 +117,16 @@ components/
└── project-status-pill.vue
```
Move a component into a folder once it grows local helpers, local composables, or local subcomponents.
Move a component into a folder when it gets local helpers, composables, or subcomponents.
## Public versus local components
## Public and Local Components
Only the main `index.vue` should be treated as the public entry point for the folder. Other files in the folder are implementation details unless there is a clear reason to import them from outside.
Use only the main `index.vue` as the public entry point. Treat the other folder files as implementation details.
If a local subcomponent starts being imported elsewhere, either:
If another component imports a local subcomponent, use one of these solutions:
- Promote it into its own component folder
- Move it to the nearest shared component area if it is genuinely reusable
- Keep it local and pass behavior through the main component if external imports would leak implementation details
- Move the subcomponent into its own component folder.
- Move the subcomponent to the nearest shared component area when it is reusable.
- Keep it local and pass behavior through the main component.
Use the last solution when an external import exposes implementation details.
+78 -46
View File
@@ -1,27 +1,35 @@
# Cross-Platform Pages
Pages that need to exist in both the Modrinth Website (`apps/frontend`) and the Modrinth App (`apps/app-frontend`) live in `packages/ui/src/layouts/`. There are two categories based on whether the page logic differs between platforms.
Put pages for both Modrinth Website and Modrinth App in `packages/ui/src/layouts/`.
Use one of two layout types. Select the type from the differences between the platform logic.
## Shared Layouts (`layouts/shared/`)
For pages where the **logic differs** between the website and app (e.g. the app fetches data via Tauri `invoke` while the website uses `api-client`). Each shared layout is a self-contained module:
Use a shared layout when the website and app use different logic.
For example, the app can use Tauri `invoke`, and the website can use `api-client`.
Make each shared layout a self-contained module:
```
shared/content-tab/
├── layout.vue # Main layout component
├── types.ts # TypeScript types
├── components/ # Internal UI components
├── composables/ # Stateful logic (search, filtering, selection)
├── composables/ # State logic for search, filters, and selection
└── providers/ # DI context definitions
```
### How it works
### Structure
1. A **DI contract** in `providers/` defines all platform-specific operations as an interface.
2. The **layout component** injects that context and handles all UI logic (search, filtering, selection, bulk operations, modals) without knowing the platform.
3. Each **platform provides its own implementation** of the contract.
1. Define all platform operations in a dependency-injection (DI) contract in `providers/`.
2. Inject the contract into the layout component. Keep all common UI logic in this component.
3. Provide a different contract implementation from each platform.
### DI contract example
Common UI logic can include search, filters, selection, bulk operations, and modals.
### DI Contract Example
```ts
// shared/content-tab/providers/content-manager.ts
@@ -29,12 +37,12 @@ export interface ContentManagerContext {
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
loading: Ref<boolean> | ComputedRef<boolean>
// Platform-abstracted operations
// Operations that have platform-specific implementations.
toggleEnabled: (item: ContentItem) => Promise<void>
deleteItem: (item: ContentItem) => Promise<void>
refresh: () => Promise<void>
// Optional capabilities not every platform supports everything
// Optional capabilities are not available on all platforms.
hasUpdateSupport: boolean
updateItem?: (id: string) => void
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
@@ -46,9 +54,9 @@ export const [injectContentManager, provideContentManager] =
createContext<ContentManagerContext>('ContentPageLayout', 'contentManagerContext')
```
### Platform implementations
### Platform Implementations
**Website** uses `api-client` and TanStack Query:
The website uses `api-client` and TanStack Query:
```vue
<!-- apps/frontend/src/pages/instance/content.vue -->
@@ -65,7 +73,7 @@ provideContentManager({
deleteItem: async (item) => {
await client.content_v1.deleteAddon(instanceId, item.id)
},
// ... rest of the contract
// Implement the remaining contract fields.
})
</script>
@@ -74,7 +82,7 @@ provideContentManager({
</template>
```
**App** uses Tauri `invoke`:
The app uses Tauri `invoke`:
```vue
<!-- apps/app-frontend/src/pages/instance/Mods.vue -->
@@ -83,14 +91,14 @@ import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
const items = ref<ContentItem[]>([])
await invoke('get_instance_content', { instanceId }).then(/* map to ContentItem[] */)
await invoke('get_instance_content', { instanceId }).then(/* Map the result to ContentItem[]. */)
provideContentManager({
items,
deleteItem: async (item) => {
await invoke('delete_content', { instanceId, path: item.file_path })
},
// ... rest of the contract
// Implement the remaining contract fields.
})
</script>
@@ -99,29 +107,33 @@ provideContentManager({
</template>
```
### Optional capabilities
### Optional Capabilities
The DI contract uses optional fields for features that not every platform supports. The layout checks for them before rendering the corresponding UI:
Use optional contract fields for capabilities that are not available on all platforms.
Check that an optional field exists before you show its UI:
```ts
// Contract
// Contract fields.
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
shareItems?: (items: ContentItem[], format: string) => void
// Layout checks before showing UI
// Show the UI only when the capability exists.
v-if="ctx.bulkUpdateItems && hasOutdatedProjects"
```
### Props vs DI
### Props and DI
| Use | When |
| --------- | ------------------------------------------------------------------------------------------ |
| **DI** | Data depends on _how_ it's fetched — API calls, file operations, navigation (per-platform) |
| **Props** | Data is the same regardless of platform — configuration flags, display options |
| Use | Condition |
| ----- | -------------------------------------------------------------------------- |
| DI | Use when API calls, file operations, or navigation differ by platform. |
| Props | Use when configuration and display data are the same on all platforms. |
## Wrapped Pages (`layouts/wrapped/`)
For pages where the **logic is identical** on both platforms same API source, same data fetching, same state management. These are full page-level Vue components that directly implement routes:
Use a wrapped page when both platforms use the same API source, data logic, and state logic.
A wrapped page is a complete page-level Vue component. Its directory structure matches the route structure:
```
wrapped/hosting/manage/
@@ -132,7 +144,9 @@ wrapped/hosting/manage/
└── [id]/onboarding.vue
```
Wrapped pages handle their own data fetching (typically via TanStack Query and `api-client`) and are consumed as simple component imports in both frontends:
Wrapped pages get their own data. They usually use TanStack Query and `api-client`.
Import the wrapped page as a simple component in both frontends:
```vue
<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
@@ -145,32 +159,48 @@ import { ServersManageContentPage } from '@modrinth/ui'
</template>
```
### Platform route shells: prefetch with `ensureQueryData`
### Prefetch Data in Platform Route Shells
#### Wrapped layout: `ReadyTransition` and `useReadyState`
#### `ReadyTransition` and `useReadyState`
Many wrapped pages wrap the main UI in [`ReadyTransition`](../../packages/ui/src/components/base/ReadyTransition.vue) with `:pending` driven by [`useReadyState`](../../packages/ui/src/composables/use-ready-state.ts) on the **primary** TanStack query (true only on the first load while that query has no cached data yet—background refetches stay “ready”). That avoids flashing empty content before data exists.
Many wrapped pages put the main UI in [`ReadyTransition`](../../packages/ui/src/components/base/ReadyTransition.vue).
The `:pending` prop usually comes from [`useReadyState`](../../packages/ui/src/composables/use-ready-state.ts) for the primary TanStack query.
The state is true only during the first load when the cache has no data. Background refetches keep the page ready.
This behavior prevents empty content from appearing before the data exists.
```vue
<!-- Conceptual: inside packages/ui wrapped layout -->
<!-- This code is in a packages/ui wrapped layout. -->
<ReadyTransition :pending="readyPending">
<SomePageLayout />
</ReadyTransition>
```
```ts
const primaryQuery = useQuery({ /* ... */ })
const primaryQuery = useQuery({ /* Query options. */ })
const readyPending = useReadyState(primaryQuery)
// or useReadyState({ isLoading, data }) when not using the full query object
// Use this form when the complete query object is not available.
const readyPendingFromState = useReadyState({ isLoading, data })
```
Shell prefetch (below) warms the cache so that on navigation the query often **already has data** when the layout mounts; `pending` stays false and `ReadyTransition` can skip the enter animation on that fast path (see `ReadyTransition` docs and stories).
Shell prefetch adds data to the cache before the layout mounts. On this fast path, `pending` stays false.
#### Rule: `ensureQueryData` in each platform route shell
`ReadyTransition` can then omit its enter animation. Refer to the `ReadyTransition` documentation and stories for details.
When a wrapped layout uses that pattern, the **thin platform page** that imports the layout must **prefetch the same primary query** in `<script setup>` so the cache is warm before the layout mounts and `ReadyTransition`/`useReadyState` behave as intended.
#### Use `ensureQueryData` in Each Route Shell
**Rule:** For each primary `useQuery` in the wrapped layout that gates first paint (and thus `useReadyState` / `ReadyTransition`), the website and app route shells must call `queryClient.ensureQueryData` with the **same** `queryKey`, `queryFn`, and `staleTime` as that query. Wrap the call in `try/catch` and swallow errors so navigation does not fail during setup; the mounted layouts `useQuery` still runs and surfaces errors to the user.
When a wrapped layout uses this ready-state pattern, prefetch the primary query in each thin platform page.
For each query that controls the first paint, call `queryClient.ensureQueryData` in the website and app route shells.
Use the same `queryKey`, `queryFn`, and `staleTime` that the wrapped layout uses.
Put the call in a `try` block. Catch the error so that route setup can continue.
The mounted layout runs its `useQuery` call and shows the error to the user.
```ts
import { injectModrinthClient, injectModrinthServerContext, ServersManageFilesPage } from '@modrinth/ui'
@@ -187,21 +217,23 @@ try {
staleTime: 30_000,
})
} catch {
// Let the mounted layouts useQuery surface errors; do not fail route setup.
// Let the mounted layout show the query error. Do not stop route setup.
}
```
If a route parameter is required for the query (e.g. `worldId`), only call `ensureQueryData` when that value is present, matching the layouts `enabled` logic.
If the query needs a route parameter, call `ensureQueryData` only when the parameter exists.
Duplicating the query definition in the shell is intentional until a shared query-options module exists; keep keys and fetchers aligned when editing the layout or the shell.
Make this condition match the `enabled` condition in the layout query.
A wrapped page may still compose shared layouts internally — for example, the hosting content page uses the shared `content-tab` layout, providing its own `ContentManagerContext` with web API calls.
Duplicate query definitions in the shell until a shared query-option module exists. Keep the keys and fetch functions the same.
A wrapped page can contain shared layouts. For example, a hosting page can provide a `ContentManagerContext` to the shared content layout.
## Composables
Reusable stateful logic lives in `packages/ui/src/layouts/shared/*/composables/`. These are consumed internally by the shared layout:
Put reusable state logic in `packages/ui/src/layouts/shared/*/composables/`. The shared layout uses these composables:
- **Search** — Fuse.js fuzzy search over items
- **Filtering** — Dynamic filter pills
- **Selection** — Multi-select with bulk operation support
- **Bulk operations** — Sequential execution with progress tracking
- Search: Uses Fuse.js to search items.
- Filters: Supplies dynamic filter pills.
- Selection: Supplies item selection for bulk operations.
- Bulk operations: Runs operations in sequence and tracks progress.
+76 -67
View File
@@ -1,26 +1,30 @@
- [Dependency Injection](#dependency-injection)
- [The `createContext` Factory](#the-createcontext-factory)
- [When to Use DI](#when-to-use-di)
- [Platform Abstraction (Primary Use Case)](#platform-abstraction-primary-use-case)
- [Page-Level Context](#page-level-context)
- [Creating a New Provider](#creating-a-new-provider)
- [1. Define the interface in `packages/ui/src/providers/`](#1-define-the-interface-in-packagesuisrcproviders)
- [2. For complex platform-specific logic, use an abstract class](#2-for-complex-platform-specific-logic-use-an-abstract-class)
- [Wiring Up Providers](#wiring-up-providers)
- [App Frontend (Tauri)](#app-frontend-tauri)
- [Website Frontend (Nuxt)](#website-frontend-nuxt)
- [Consuming Providers](#consuming-providers)
- [When NOT to Use DI](#when-not-to-use-di)
- [Existing Providers](#existing-providers)
- [Key Files](#key-files)
- [Dependency injection](#dependency-injection)
- [The `createContext` factory](#the-createcontext-factory)
- [When to use DI](#when-to-use-di)
- [Platform abstraction](#platform-abstraction)
- [Page context](#page-context)
- [Create a provider](#create-a-provider)
- [1. Define the interface](#1-define-the-interface)
- [2. Use an abstract class for complex logic](#2-use-an-abstract-class-for-complex-logic)
- [Connect providers](#connect-providers)
- [App frontend](#app-frontend)
- [Website frontend](#website-frontend)
- [Use providers](#use-providers)
- [When not to use DI](#when-not-to-use-di)
- [Existing providers](#existing-providers)
- [Key files](#key-files)
# Dependency Injection
Modrinth uses a lightweight DI layer built on Vue's `provide`/`inject` for sharing platform-specific capabilities and page-level state across shared UI components.
Modrinth uses a small dependency-injection (DI) layer that uses Vue `provide` and `inject`.
This layer shares platform capabilities and page state with common UI components.
## The `createContext` Factory
All providers are defined using `createContext` from `packages/ui/src/providers/index.ts` (adapted from Reka UI). It produces a typed `[inject, provide]` tuple:
Define all providers with `createContext` from `packages/ui/src/providers/index.ts`. This factory comes from the Reka UI pattern.
The factory returns a typed `[inject, provide]` tuple:
```ts
import { createContext } from '@modrinth/ui'
@@ -33,35 +37,38 @@ interface MyContext {
export const [injectMyContext, provideMyContext] = createContext<MyContext>('MyComponent')
```
- **`provideMyContext(value)`** — call in a parent component's `setup()`.
- **`injectMyContext()`** — call in any descendant's `setup()`. Throws if never provided.
- **`injectMyContext(null)`** — returns `null` instead of throwing (for optional contexts).
- Call `provideMyContext(value)` in the `setup()` function of a parent component.
- Call `injectMyContext()` in the `setup()` function of a descendant. It throws an error when no provider exists.
- Call `injectMyContext(null)` to return `null` when the context is optional.
## When to Use DI
Use DI when:
- **The same interface needs different implementations** depending on the platform (web vs desktop app).
- **Deeply nested components** need access to shared page-level state without prop drilling through 3+ levels.
Use DI in these conditions:
### Platform Abstraction (Primary Use Case)
- The same interface needs different implementations on the website and the desktop app.
- Deep descendant components need the same page state, and props must pass through three or more levels.
`packages/ui` components need capabilities that each frontend fulfils differently:
### Platform Abstraction
| Provider | App Frontend | Website Frontend |
| ------------- | -------------------------------- | ------------------------------ |
| API client | Tauri IPC client | REST fetch client |
| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration |
| File picker | Native Tauri dialogs | Browser file inputs |
| Tags | Tauri commands | Nuxt server state |
| Page context | `sidebar: true`, ad window hooks | `sidebar: false`, no ads |
Components in `packages/ui` can need capabilities that each frontend implements differently:
### Page-Level Context
| Provider | App frontend | Website frontend |
| ------------- | ---------------------------------- | ------------------------------- |
| API client | Tauri IPC client | REST fetch client |
| Notifications | `ref()` state and window control | `useState()` for SSR hydration |
| File picker | Native Tauri dialogs | Browser file inputs |
| Tags | Tauri commands | Nuxt server state |
| Page context | Sidebar and advertisement hooks | No sidebar and no advertisements |
Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components.
### Page Context
## Creating a New Provider
Use DI to share page data with deep descendants. Examples include the project sidebar, header, and version components.
### 1. Define the interface in `packages/ui/src/providers/`
## Create a Provider
### 1. Define the Interface
Define the interface in `packages/ui/src/providers/`:
```ts
// packages/ui/src/providers/my-feature.ts
@@ -77,16 +84,18 @@ export interface MyFeatureContext {
export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('MyFeature')
```
Re-export from the barrel file (`packages/ui/src/providers/index.ts`).
Export the provider from `packages/ui/src/providers/index.ts`.
### 2. For complex platform-specific logic, use an abstract class
### 2. Use an Abstract Class for Complex Logic
Use an abstract class when the provider has complex platform logic:
```ts
export abstract class AbstractMyFeatureManager {
abstract items: Ref<Item[]>
abstract addItem(item: Item): Promise<void>
// Shared logic lives on the base class
// Put common logic in the base class.
handleError(err: unknown) {
console.error(err)
}
@@ -96,13 +105,13 @@ export const [injectMyFeature, provideMyFeature] =
createContext<AbstractMyFeatureManager>('MyFeature')
```
See `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for a real example.
Refer to `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for an example.
## Wiring Up Providers
## Connect Providers
### App Frontend (Tauri)
### App Frontend
Create a setup function in `apps/app-frontend/src/providers/setup/`:
Make a setup function in `apps/app-frontend/src/providers/setup/`:
```ts
// apps/app-frontend/src/providers/setup/my-feature.ts
@@ -126,11 +135,11 @@ export function setupMyFeatureProvider() {
}
```
Register it in `apps/app-frontend/src/providers/setup.ts`, which is called from `App.vue`'s `setup()`.
Register the function in `apps/app-frontend/src/providers/setup.ts`. `App.vue` calls this setup file from its `setup()` function.
### Website Frontend (Nuxt)
### Website Frontend
Provide directly in `apps/frontend/src/app.vue`, using Nuxt's `useState()` where SSR hydration is needed:
Provide the context in `apps/frontend/src/app.vue`. Use Nuxt `useState()` when the state needs SSR hydration:
```ts
provideMyFeature({
@@ -144,9 +153,9 @@ provideMyFeature({
})
```
## Consuming Providers
## Use Providers
In any component across `packages/ui`, `apps/frontend`, or `apps/app-frontend`:
Inject the provider in a component in `packages/ui`, `apps/frontend`, or `apps/app-frontend`:
```vue
<script setup lang="ts">
@@ -161,30 +170,30 @@ const { items, addItem } = injectMyFeature()
</template>
```
## When NOT to Use DI
## When Not to Use DI
Default to props and emits. DI adds indirection — only use it with a concrete reason.
Use props and emits by default. DI adds an indirect layer, so use it only for a specific reason.
- **Parent to direct child** — use props.
- **Data only exists in one frontend** — keep context local to that app, not in `packages/ui`.
- **Shallow prop drilling (12 levels)** — passing through one intermediate is fine.
- **Component-local state** — use `ref()` / `reactive()` locally.
- Use props from a parent to its direct child.
- Keep data in one frontend when only that frontend uses it.
- Use props through one or two intermediate levels.
- Use `ref()` or `reactive()` for component state.
## Existing Providers
| Provider | File | Purpose |
| ---------------------------- | -------------------------------- | ------------------------------ |
| `provideModrinthClient` | `providers/api-client.ts` | API client instance |
| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management |
| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) |
| `provideProjectPageContext` | `providers/project-page.ts` | Project page state + mutations |
| `provideServerContext` | `providers/server-context.ts` | Server hosting state |
| `provideUserPageContext` | `providers/user-page.ts` | User page state |
| Provider | File | Purpose |
| ---------------------------- | -------------------------------- | ----------------------------- |
| `provideModrinthClient` | `providers/api-client.ts` | Supplies the API client. |
| `provideNotificationManager` | `providers/web-notifications.ts` | Manages notifications. |
| `providePageContext` | `providers/page-context.ts` | Supplies page configuration. |
| `provideProjectPageContext` | `providers/project-page.ts` | Manages project page state. |
| `provideServerContext` | `providers/server-context.ts` | Manages server hosting state. |
| `provideUserPageContext` | `providers/user-page.ts` | Manages user page state. |
## Key Files
- `packages/ui/src/providers/index.ts` `createContext` factory + barrel exports
- `packages/ui/src/providers/*.ts` — Provider definitions
- `apps/frontend/src/app.vue` Nuxt root provider setup
- `apps/app-frontend/src/App.vue` Tauri root provider setup
- `apps/app-frontend/src/providers/setup/` — App provider setup functions
- `packages/ui/src/providers/index.ts`: Contains the `createContext` factory and provider exports.
- `packages/ui/src/providers/*.ts`: Contains provider definitions.
- `apps/frontend/src/app.vue`: Contains the Nuxt root-provider setup.
- `apps/app-frontend/src/App.vue`: Contains the Tauri root-provider setup.
- `apps/app-frontend/src/providers/setup/`: Contains the app provider setup functions.
+34 -26
View File
@@ -1,26 +1,32 @@
- [TanStack Query](#tanstack-query)
- [Setup](#setup)
- [Queries](#queries)
- [Query Option Factories](#query-option-factories)
- [Conditional Queries](#conditional-queries)
- [Query-option factories](#query-option-factories)
- [Conditional queries](#conditional-queries)
- [Mutations](#mutations)
- [Optimistic Updates](#optimistic-updates)
- [Query Keys](#query-keys)
- [Key Files](#key-files)
- [Optimistic updates](#optimistic-updates)
- [Query keys](#query-keys)
- [Key files](#key-files)
# TanStack Query
TanStack Query (`@tanstack/vue-query` v5) is used for server state management — caching, background refetching, and cache invalidation. Use it instead of manual `ref()` + `await` patterns for any data that comes from an API.
TanStack Query (`@tanstack/vue-query` v5) manages server state. It supplies caching, background refetches, and cache invalidation.
A TanStack MCP server is available — use `tanstack_doc` and `tanstack_search_docs` tools to look up API details when needed.
Use TanStack Query for all data that comes from an API. Do not use a manual `ref()` and `await` pattern.
A TanStack MCP server is available. Use `tanstack_doc` or `tanstack_search_docs` when you need API details.
## Setup
TanStack Query is configured in `apps/frontend/src/plugins/tanstack.ts` as a Nuxt plugin with SSR hydration support. Default stale time is 5 seconds. The `QueryClient` is available via `useQueryClient()` or `useAppQueryClient()` (which also works in middleware).
`apps/frontend/src/plugins/tanstack.ts` configures TanStack Query as a Nuxt plugin. The plugin supports server-side rendering (SSR) hydration.
The default stale time is 5 seconds. Get the `QueryClient` with `useQueryClient()` or `useAppQueryClient()`.
`useAppQueryClient()` also operates in middleware.
## Queries
Use `useQuery` with the api-client for data fetching:
Use `useQuery` with `api-client` to get data:
```ts
const client = injectModrinthClient()
@@ -32,7 +38,7 @@ const { data, isPending, isError, error } = useQuery({
})
```
In templates:
Use the query state in templates:
```vue
<span v-if="isPending">Loading...</span>
@@ -40,9 +46,9 @@ In templates:
<div v-else>{{ data.title }}</div>
```
### Query Option Factories
### Query-Option Factories
For queries used across multiple components, define reusable query option factories in `packages/ui/src/queries/`:
For a query that multiple components use, define a query-option factory in `packages/ui/src/queries/`:
```ts
// composables/queries/project.ts
@@ -64,7 +70,7 @@ export const projectQueryOptions = {
}
```
Then use them:
Use the factory in each applicable component:
```ts
const { data } = useQuery(projectQueryOptions.v3(projectId, client))
@@ -72,7 +78,7 @@ const { data } = useQuery(projectQueryOptions.v3(projectId, client))
### Conditional Queries
Use `enabled` as a computed for queries that depend on other data:
Use a computed `enabled` value when a query depends on other data:
```ts
const { data: members } = useQuery({
@@ -84,7 +90,7 @@ const { data: members } = useQuery({
## Mutations
Use `useMutation` for create/update/delete operations. Invalidate related queries on success:
Use `useMutation` for create, update, and delete operations. Invalidate related queries after a successful operation:
```ts
const queryClient = useQueryClient()
@@ -100,7 +106,7 @@ Use `createMutation.isPending.value` to disable buttons during submission.
### Optimistic Updates
For mutations where responsiveness matters, use optimistic updates with rollback:
Use an optimistic update and rollback when a mutation needs an immediate UI response:
```ts
const patchMutation = useMutation({
@@ -135,30 +141,32 @@ const patchMutation = useMutation({
## Query Keys
Keys use a hierarchical array pattern:
Use hierarchical arrays for query keys:
```ts
// Resource type version/qualifier ID
// Resource type, version or qualifier, and ID.
['project', 'v3', projectId]
// Resource type ID sub-resource
// Resource type, ID, and subresource.
['project', projectId, 'members']
['project', projectId, 'versions', 'v3']
// Domain action ID
// Domain, action, and ID.
['backups', 'list', serverId]
['tech-reviews']
```
Use `as const` for type safety. Put the resource ID last when possible — this makes partial key matching work for invalidation:
Use `as const` for type safety. Put stable category segments before reactive parameters.
TanStack Query uses key prefixes during invalidation:
```ts
// Invalidates all project queries for this ID
queryClient.invalidateQueries({ queryKey: ['project', projectId] })
// Invalidate all v3 project queries.
queryClient.invalidateQueries({ queryKey: ['project', 'v3'] })
```
## Key Files
- `apps/frontend/src/plugins/tanstack.ts`QueryClient setup + SSR hydration
- `apps/frontend/src/composables/query-client.ts` `useAppQueryClient()` helper
- `apps/frontend/src/composables/queries/` reusable query option factories
- `apps/frontend/src/plugins/tanstack.ts`: Contains the `QueryClient` setup and SSR hydration.
- `apps/frontend/src/composables/query-client.ts`: Contains the `useAppQueryClient()` helper.
- `apps/frontend/src/composables/queries/`: Contains reusable query-option factories.
-32
View File
@@ -1,32 +0,0 @@
- [Figma MCP Usage](#figma-mcp-usage)
- [Available Tools](#available-tools)
- [Adapting Figma Output](#adapting-figma-output)
# Figma MCP Usage
When the Figma MCP server is connected, it can be used to translate Figma designs into production-ready Vue components for this monorepo.
## Available Tools
| Tool | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------------- |
| `get_design_context` | Primary tool. Returns reference code, a screenshot, and metadata for a given node. Always start here. |
| `get_screenshot` | Returns a visual screenshot of a node without full code context. |
| `get_variable_defs` | Returns the design tokens applied to a node. |
| `get_metadata` | Returns an XML overview of node IDs, layer types, names, positions, and sizes for understanding structure. |
Node IDs come from Figma URLs. For `https://figma.com/design/:fileKey/:fileName?node-id=1-2`, the node ID is `1:2` (replace `-` with `:`).
```
get_design_context(nodeId: "1:2", clientLanguages: "typescript,html,css", clientFrameworks: "vue")
```
## Adapting Figma Output
The Figma MCP returns generic reference code. It must be adapted to match the Modrinth codebase:
1. **Read `packages/ui/CLAUDE.md`** for color usage rules, surface token mapping, and component patterns.
2. **Map Figma color variables to `surface-*` tokens** — never use Figma's aliased names like `bg/default` or `bg/raised` directly. The CLAUDE.md has the full mapping table.
3. **Check `packages/assets/styles/variables.scss`** for tokens not exposed in Figma (brand highlights, semantic backgrounds, shadows).
4. **Check for existing components** in `packages/ui/src/components/` before building from scratch.
5. **Match spacing exactly** — do not approximate values from the design.
+78 -51
View File
@@ -1,40 +1,51 @@
- [Internationalization (i18n)](#internationalization-i18n)
- [Translatable Strings](#translatable-strings)
- [Message Definitions](#message-definitions)
- [Rendering Messages](#rendering-messages)
- [ICU Message Format](#icu-message-format)
- [Writing Translation-Friendly Strings](#writing-translation-friendly-strings)
- [Rich-Text Messages](#rich-text-messages)
- [Vue/ICU Delimiter Collisions](#vueicu-delimiter-collisions)
- [Internationalization](#internationalization)
- [Translatable strings](#translatable-strings)
- [Message definitions](#message-definitions)
- [Render messages](#render-messages)
- [ICU message format](#icu-message-format)
- [Write strings for translation](#write-strings-for-translation)
- [Rich-text messages](#rich-text-messages)
- [Vue and ICU delimiter conflicts](#vue-and-icu-delimiter-conflicts)
- [Imports](#imports)
- [Reference Examples](#reference-examples)
- [Reference examples](#reference-examples)
# Internationalization (i18n)
# Internationalization
All user-visible strings in Vue SFCs must use the localization system from `@modrinth/ui`. No hard-coded English strings should appear in templates or script — everything comes from `formatMessage` or `<IntlFormatted>`.
Use the `@modrinth/ui` localization system for all user-visible strings in Vue single-file components (SFCs).
Do not put hard-coded English text in templates or scripts. Get all user-visible text from `formatMessage` or `<IntlFormatted>`.
## Translatable Strings
User-visible strings include: inner text, `alt` attributes, `placeholder` attributes, button labels, dropdown option labels, notification messages, etc.
Translate these user-visible items:
Dynamic expressions (`{{ user.name }}`) and HTML tags are not translatable strings — only static human-readable text.
- Inner text.
- `alt` and `placeholder` attributes.
- Button and dropdown-option labels.
- Notification and error messages.
Do not translate dynamic expressions, HTML tag names, CSS classes, internal identifiers, or log messages.
In `{{ user.name }}`, only the static text around the expression needs translation.
## Message Definitions
Messages are defined with `defineMessage` or `defineMessages` from `@modrinth/ui` in `<script setup>`. Each message has a unique `id` and a `defaultMessage` containing the English string:
Use `defineMessage` or `defineMessages` from `@modrinth/ui` in `<script setup>`.
Give each message a unique `id`. Put the English text in `defaultMessage`:
```ts
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: "You're now part of the community…" },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'You are now part of the community.' },
})
```
Message `id`s should be descriptive and stable (e.g. `error.generic.default.title`). Group related messages together with `defineMessages`.
Use descriptive, stable message IDs, such as `error.generic.default.title`. Put related messages in one `defineMessages` object.
## Rendering Messages
## Render Messages
Use `useVIntl()` from `@modrinth/ui` for simple string formatting:
Use `useVIntl()` from `@modrinth/ui` to format simple strings:
```ts
const { formatMessage } = useVIntl()
@@ -47,51 +58,63 @@ const { formatMessage } = useVIntl()
## ICU Message Format
Dynamic values use ICU placeholders in `defaultMessage`:
Use ICU placeholders for dynamic values in `defaultMessage`:
- **Variables:** `'Hello, {name}!'`
- **Numbers/dates/times:** `'{price, number, ::currency/USD}'`
- **Plurals/selects:** `'{count, plural, one {# message} other {# messages}}'`
- Variable: `'Hello, {name}!'`
- Number, date, or time: `'{price, number, ::currency/USD}'`
- Plural or selection: `'{count, plural, one {# message} other {# messages}}'`
## Writing Translation-Friendly Strings
## Write Strings for Translation
ICU gives you powerful tools (plurals, selects, nested expressions), but translators in other languages face constraints that English doesn't have:
ICU supports plurals, selections, and nested expressions. Languages can have different grammar rules.
- **Word order varies by language.** Don't assume `{action} {noun}` works everywhere — some languages need `{noun} {action}` or require prepositions between them.
- **Plurals aren't just "add an s".** Many languages change internal parts of a word or phrase for pluralization, not just the ending. A simple `{count} {itemType}` breaks if `itemType` is always singular.
- **Grammatical gender affects surrounding words.** Articles, adjectives, and verbs may change based on whether a noun is masculine or feminine. If a variable like `{contentType}` can be "shader" or "mod", translators may need to inflect surrounding text differently for each.
- Word order changes between languages. Do not assume that `{action} {noun}` operates in all languages.
- Plural forms can change a complete word or phrase. Do not only add an `s` to make a plural.
- Grammatical gender can change articles, adjectives, and verbs. Give translators a separate branch for each content type.
### Guidelines
1. **Use `select` for content types, not bare variables.** When a variable represents different content types (mod, shader, modpack, etc.), pass a key and use ICU `select` so translators can write type-specific forms:
1. Use `select` for content types. Do not use a bare variable for a content type.
```
// Bad — translators can't inflect around a pre-rendered noun
Pass a content-type key. Then, use ICU `select` so translators can write a specific form for each type:
```text
// Incorrect. Translators cannot change the grammar around this rendered noun.
'Delete {count} {itemType}'
// Good — translators can write entirely different phrases per type
// Correct. Translators can write a different phrase for each type.
'Delete {count} {contentType, select, mod {{count, plural, one {mod} other {mods}}} shader {{count, plural, one {shader} other {shaders}}} other {items}}'
```
This lets translators write entirely different noun forms per branch, which many languages require.
This structure lets translators write different noun forms in each branch.
2. **Prefer separate messages over complex ICU when branches diverge significantly.** If the singular and plural versions of a string are structurally different (not just a noun change), use two separate message IDs rather than one complex ICU expression.
2. Use separate messages when ICU branches have different sentence structures.
3. **Don't concatenate translated strings.** Never build a sentence by joining multiple `formatMessage` calls — the word order may be wrong in other languages. Put the entire sentence in one message.
If singular and plural text have different structures, use two message IDs. Do not make one complex ICU expression.
4. **Keep variables semantic.** Pass `contentType: 'mod'` (a key), not `contentType: 'Mod'` (a pre-rendered display string). Translators can then map each key to the correct form in their language.
3. Do not join translated strings.
5. **Test with long strings.** German and Finnish words can be 2-3x longer than English equivalents. Ensure UI layouts don't break with longer text.
Do not make a sentence from multiple `formatMessage` calls. Put the complete sentence in one message.
4. Use semantic variable values.
Pass `contentType: 'mod'` as a key. Do not pass `contentType: 'Mod'` as rendered text.
The translator can map each key to the correct form.
5. Test the UI with long strings.
Some translated words can be two or three times longer than the English words. Make sure that the layout remains correct.
## Rich-Text Messages
When a message contains links or markup, wrap the relevant ranges with named tags in `defaultMessage`:
When a message contains links or markup, put named tags around the applicable text in `defaultMessage`:
```
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
```text
"When you create an account, you agree to the <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
```
Render with the `<IntlFormatted>` component using named slots:
Use named slots in `<IntlFormatted>` to render the tags:
```vue
<IntlFormatted :message-id="messages.tosLabel">
@@ -108,7 +131,11 @@ Render with the `<IntlFormatted>` component using named slots:
</IntlFormatted>
```
For simple emphasis (`'Welcome to <strong>Modrinth</strong>!'`):
Use this pattern for simple emphasis:
```text
'Welcome to <strong>Modrinth</strong>!'
```
```vue
<template #strong="{ children }">
@@ -116,7 +143,7 @@ For simple emphasis (`'Welcome to <strong>Modrinth</strong>!'`):
</template>
```
For complex child handling, use `normalizeChildren` from `@modrinth/ui`:
Use `normalizeChildren` from `@modrinth/ui` for complex child content:
```vue
<template #bold="{ children }">
@@ -124,20 +151,20 @@ For complex child handling, use `normalizeChildren` from `@modrinth/ui`:
</template>
```
## Vue/ICU Delimiter Collisions
## Vue and ICU Delimiter Conflicts
If an ICU placeholder ends right before `}}` in a Vue template, insert a space (`} }`) to avoid parsing issues.
If an ICU placeholder ends immediately before `}}`, add a space. Use `} }` to prevent a Vue parser error.
## Imports
All i18n utilities come from `@modrinth/ui`:
Get all internationalization utilities from `@modrinth/ui`:
- `defineMessage` / `defineMessages` — message definitions
- `useVIntl` — composable providing `formatMessage`
- `IntlFormatted` — component for rich-text messages
- `normalizeChildren` — helper for complex rich-text slot children
- `defineMessage` and `defineMessages`: Define messages.
- `useVIntl`: Supplies `formatMessage`.
- `IntlFormatted`: Renders rich-text messages.
- `normalizeChildren`: Normalizes complex rich-text slot children.
## Reference Examples
- Variables and plurals: `apps/frontend/src/pages/frog.vue`
- Rich-text with link tags: `apps/frontend/src/error.vue`
- Variables and plurals: `apps/frontend/src/pages/frog.vue`.
- Rich text with link tags: `apps/frontend/src/error.vue`.
+149 -130
View File
@@ -1,38 +1,39 @@
- [Regular Modals](#regular-modals)
- [Basic Usage](#basic-usage)
- [Props](#props)
- [Slots](#slots)
- [Default slot](#default-slot)
- [`title` slot](#title-slot)
- [`actions` slot](#actions-slot)
- [Scrollable Content](#scrollable-content)
- [Merged Header Mode](#merged-header-mode)
- [Modal Stacking](#modal-stacking)
- [Exposed Methods](#exposed-methods)
- [Multistage Modals](#multistage-modals)
- [Architecture](#architecture)
- [Building a Multistage Modal](#building-a-multistage-modal)
- [1. Define the context](#1-define-the-context)
- [2. Define stage configs](#2-define-stage-configs)
- [3. Create stage components](#3-create-stage-components)
- [4. Create the wrapper component](#4-create-the-wrapper-component)
- [Modal API](#modal-api)
- [Non-Progress Stages (Edit Sub-Flows)](#non-progress-stages-edit-sub-flows)
- [Reference Implementation](#reference-implementation)
- [Standard modals](#standard-modals)
- [Basic use](#basic-use)
- [Props](#props)
- [Slots](#slots)
- [Default slot](#default-slot)
- [`title` slot](#title-slot)
- [`actions` slot](#actions-slot)
- [Scrollable content](#scrollable-content)
- [Merged header](#merged-header)
- [Modal stack](#modal-stack)
- [Exposed methods](#exposed-methods)
- [Multistage modals](#multistage-modals)
- [Architecture](#architecture)
- [Create a multistage modal](#create-a-multistage-modal)
- [1. Define the context](#1-define-the-context)
- [2. Define stage configurations](#2-define-stage-configurations)
- [3. Create stage components](#3-create-stage-components)
- [4. Create the wrapper component](#4-create-the-wrapper-component)
- [Modal API](#modal-api)
- [Non-progress stages](#non-progress-stages)
- [Reference implementation](#reference-implementation)
# Regular Modals
# Standard Modals
Use the `NewModal` component (`packages/ui/src/components/modal/NewModal.vue`) for all standard modals.
Use `NewModal` (`packages/ui/src/components/modal/NewModal.vue`) for all standard modals.
- Set the modals width via the `width` or `maxWidth` props. For responsive sizing, use `min(base-size, calc(95vw - 10rem))`.
- `ModalWrapper` is deprecated — modal behavior is automatically handled via the `injectModalBehavior` DI utility.
- Set the modal width with the `width` or `maxWidth` prop.
- For a responsive width, use `min(base-size, calc(95vw - 10rem))`.
- Do not use `ModalWrapper`. The `injectModalBehavior` DI utility supplies modal behavior.
## Basic Usage
## Basic Use
```vue
<script setup lang="ts">
import { ref } from vue
import { NewModal } from @modrinth/ui
import { ref } from 'vue'
import { NewModal } from '@modrinth/ui'
const modal = ref<InstanceType<typeof NewModal> | null>(null)
</script>
@@ -41,50 +42,54 @@ const modal = ref<InstanceType<typeof NewModal> | null>(null)
<button @click="modal?.show($event)">Open</button>
<NewModal ref="modal" header="My Modal">
<p>Modal content here.</p>
<p>Modal content.</p>
</NewModal>
</template>
```
Call `show(event?)` to open the modal. Passing the `MouseEvent` triggers an animation originating from the click position. Call `hide()` to close it programmatically.
Call `show(event?)` to open the modal. A `MouseEvent` starts the animation at the click position.
Call `hide()` to close the modal from code.
## Props
| Prop | Type | Default | Description |
| --------------------- | ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
| `header` | `string` | — | Title text displayed in the header bar |
| `hideHeader` | `boolean` | `false` | Hides the entire header (title + close button) |
| `mergeHeader` | `boolean` | `false` | Removes the header bar; renders a floating close button over the content |
| `closable` | `boolean` | `true` | Shows the close button and enables ESC / click-outside dismissal |
| `disableClose` | `boolean` | `false` | Disables all close actions (close button, ESC, click-outside). The close button appears disabled |
| `closeOnEsc` | `boolean` | `true` | Allow closing with the Escape key |
| `closeOnClickOutside` | `boolean` | `true` | Allow closing by clicking the overlay |
| `scrollable` | `boolean` | `false` | Enables scroll tracking with top/bottom fade indicators |
| `maxContentHeight` | `string` | `70vh` | Max height of the scrollable content area (only applies when `scrollable`) |
| `noPadding` | `boolean` | `false` | Removes padding from the content area for edge-to-edge layouts |
| `maxWidth` | `string` | `60rem` | Maximum width of the modal |
| `width` | `string` | `fit-content` | Width of the modal body |
| `noblur` | `boolean` | — | Disables backdrop blur. Defaults to the value from `injectModalBehavior` |
| `fade` | `standard \| warning \| danger` | `standard` | Overlay color variant |
| `danger` | `boolean` | `false` | **Deprecated** — use `fade="danger"` instead |
| `onShow` | `() => void` | — | Called when the modal opens |
| `onHide` | `() => void` | — | Called when the modal closes |
| Prop | Type | Default | Description |
| --------------------- | ----------------------------------------- | ------------- | ------------------------------------------------------------------ |
| `header` | `string` | None | Sets the title in the header bar. |
| `hideHeader` | `boolean` | `false` | Hides the title and close button. |
| `mergeHeader` | `boolean` | `false` | Replaces the header bar with a floating close button. |
| `closable` | `boolean` | `true` | Enables the close button, Escape key, and overlay click. |
| `disableClose` | `boolean` | `false` | Disables all close actions and shows a disabled close button. |
| `closeOnEsc` | `boolean` | `true` | Enables the Escape key as a close action. |
| `closeOnClickOutside` | `boolean` | `true` | Enables an overlay click as a close action. |
| `scrollable` | `boolean` | `false` | Enables scroll tracking and edge-fade indicators. |
| `maxContentHeight` | `string` | `'70vh'` | Sets the maximum scrollable-content height. |
| `noPadding` | `boolean` | `false` | Removes content padding for edge-to-edge layouts. |
| `maxWidth` | `string` | `'60rem'` | Sets the maximum modal width. |
| `width` | `string` | `fit-content` | Sets the modal-body width. |
| `noblur` | `boolean` | None | Disables the backdrop blur. The DI behavior supplies the default. |
| `fade` | `'standard' \| 'warning' \| 'danger'` | `'standard'` | Sets the overlay color variant. |
| `danger` | `boolean` | `false` | Deprecated. Use `fade="danger"`. |
| `onShow` | `() => void` | None | Runs when the modal opens. |
| `onHide` | `() => void` | None | Runs when the modal closes. |
`maxContentHeight` has an effect only when `scrollable` is true.
## Slots
### Default slot
### Default Slot
The main content area. Rendered inside a padded, optionally scrollable container.
The default slot contains the main content. `NewModal` puts it in a padded container that can scroll.
```vue
<NewModal ref="modal" header="Confirm">
<p>Are you sure you want to proceed?</p>
<p>Are you sure that you want to continue?</p>
</NewModal>
```
### `title` slot
### `title` Slot
Replaces the default header text. Use this when you need custom markup in the header (e.g. an icon next to the title or a badge).
The `title` slot replaces the default header text. Use it for custom header markup, such as an icon or badge.
```vue
<NewModal ref="modal">
@@ -92,17 +97,19 @@ Replaces the default header text. Use this when you need custom markup in the he
<AlertIcon />
<span class="text-2xl font-semibold text-contrast">Custom Title</span>
</template>
<p>Content here.</p>
<p>Content.</p>
</NewModal>
```
### `actions` slot
### `actions` Slot
Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Use this for confirm/cancel buttons.
The `actions` slot makes an action bar below the content. The bar uses `p-4 pt-0` padding.
Use this slot for confirmation and cancellation buttons:
```vue
<NewModal ref="modal" header="Delete Item" fade="danger">
<p>This action cannot be undone.</p>
<p>You cannot reverse this action.</p>
<template #actions>
<Button type="colored" color="red" @click="handleDelete">Delete</Button>
<Button @click="modal?.hide()">Cancel</Button>
@@ -112,21 +119,25 @@ Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Us
## Scrollable Content
Set `scrollable` to enable scroll tracking. The modal renders animated fade gradients at the top and bottom edges when content is scrolled, giving users a visual cue that more content exists.
Set `scrollable` to enable scroll tracking. Fade gradients appear at the top and bottom when more content exists.
```vue
<NewModal ref="modal" header="Long Content" scrollable max-content-height="60vh">
<!-- Long content that may overflow -->
<!-- Long content can overflow. -->
</NewModal>
```
The `checkScrollState` method is exposed via ref — call it after dynamically changing content to re-evaluate whether fade indicators should appear.
Call the exposed `checkScrollState` method after a dynamic content change. The method recalculates the fade-indicator state.
When `scrollable` is `false` (the default), content uses `overflow-y: auto` without fade indicators.
When `scrollable` is false, the content uses `overflow-y: auto` without fade indicators. False is the default value.
## Merged Header Mode
## Merged Header
When `mergeHeader` is set, the header bar is hidden and a floating close button is rendered in the top-right corner of the modal. Content receives extra top padding to avoid overlapping the button. This is useful for modals with hero images or full-bleed content at the top.
When `mergeHeader` is true, the header bar is hidden. A floating close button appears in the top-right corner.
The content gets more top padding. This padding prevents overlap with the button.
Use this mode for a hero image or full-width content at the top:
```vue
<NewModal ref="modal" merge-header no-padding>
@@ -137,35 +148,39 @@ When `mergeHeader` is set, the header bar is hidden and a floating close button
</NewModal>
```
## Modal Stacking
## Modal Stack
`NewModal` integrates with a modal stack (`useModalStack`). Multiple modals can be open simultaneously — only the topmost modal responds to the Escape key. The document body scroll is locked when any modal is open and restored when the last modal closes.
`NewModal` uses `useModalStack`. Multiple modals can be open at the same time.
Only the top modal responds to the Escape key. The first open modal locks document-body scrolling.
The last modal restores document-body scrolling when it closes.
## Exposed Methods
| Method | Description |
| -------------------- | ------------------------------------------------------- |
| `show(event?)` | Opens the modal. Pass `MouseEvent` for origin animation |
| `hide()` | Closes the modal |
| `checkScrollState()` | Re-evaluates scroll fade indicators (when `scrollable`) |
| Method | Description |
| -------------------- | ------------------------------------------------------------- |
| `show(event?)` | Opens the modal. Pass a `MouseEvent` for the origin animation. |
| `hide()` | Closes the modal. |
| `checkScrollState()` | Recalculates fade indicators when `scrollable` is true. |
# Multistage Modals
The `MultiStageModal` component (`packages/ui/src/components/base/MultiStageModal.vue`) provides a wizard-like modal with progress tracking, conditional stages, and per-stage button configuration.
`MultiStageModal` (`packages/ui/src/components/base/MultiStageModal.vue`) supplies progress, conditional stages, and button configurations for each stage.
## Architecture
A multistage modal has three parts:
1. **Context** — A DI provider that holds all state, business logic, and stage configs
2. **Stage configs** — Data objects describing each stage (title, component, buttons, skip conditions)
3. **Stage components** — Vue components rendered inside the modal, consuming the context
1. The context contains all state, application logic, and stage configurations.
2. Stage configurations define the title, component, buttons, and skip conditions for each stage.
3. Stage components inject the context and render inside the modal.
## Building a Multistage Modal
## Create a Multistage Modal
### 1. Define the context
### 1. Define the Context
Create a DI provider with all the state your wizard needs. Include the modal ref and stage configs.
Make a DI provider that contains the modal state. Include the modal reference and stage configurations.
```ts
// providers/my-feature/my-modal.ts
@@ -175,15 +190,15 @@ import type { MultiStageModal, StageConfigInput } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
export interface MyModalContext {
// State
// State.
formData: Ref<MyFormData>
isSubmitting: Ref<boolean>
// Modal control
// Modal control.
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<MyModalContext>[]
// Business logic
// Application logic.
handleSubmit: () => Promise<void>
}
@@ -210,9 +225,11 @@ export function createMyModalContext(
}
```
### 2. Define stage configs
### 2. Define Stage Configurations
Each stage is a `StageConfigInput<T>` where `T` is your context type. Most fields accept either a static value or a function receiving the context (`MaybeCtxFn<T, R>`).
Each stage is a `StageConfigInput<T>`, where `T` is the context type.
Most fields accept a static value or a function that receives the context. The function type is `MaybeCtxFn<T, R>`.
```ts
// providers/my-feature/stages/details-stage.ts
@@ -227,7 +244,7 @@ export const detailsStageConfig: StageConfigInput<MyModalContext> = {
stageContent: markRaw(DetailsStage),
title: 'Details',
// Conditional behavior based on context
// Set behavior from the context.
skip: (ctx) => ctx.shouldSkipDetails.value,
cannotNavigateForward: (ctx) => !ctx.formData.value.name,
disableClose: (ctx) => ctx.isSubmitting.value,
@@ -247,36 +264,36 @@ export const detailsStageConfig: StageConfigInput<MyModalContext> = {
}
```
**Stage config fields:**
Stage configuration fields:
| Field | Type | Purpose |
| ----------------------- | ------------------------------------------ | ------------------------------------------------ |
| `id` | `string` | Unique stage identifier (used with `setStage()`) |
| `stageContent` | `Component` | Vue component to render (wrap with `markRaw()`) |
| `title` | `MaybeCtxFn<T, string>` | Stage title in breadcrumbs |
| `skip` | `MaybeCtxFn<T, boolean>` | Skip this stage conditionally |
| `nonProgressStage` | `MaybeCtxFn<T, boolean>` | Exclude from progress bar (for edit sub-flows) |
| `hideStageInBreadcrumb` | `MaybeCtxFn<T, boolean>` | Hide from breadcrumb nav |
| `cannotNavigateForward` | `MaybeCtxFn<T, boolean>` | Block forward navigation (validation) |
| `disableClose` | `MaybeCtxFn<T, boolean>` | Disable closing the modal |
| `leftButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Left action button |
| `rightButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Right action button |
| `maxWidth` | `MaybeCtxFn<T, string>` | Per-stage max width (default `560px`) |
| Field | Type | Purpose |
| ----------------------- | ------------------------------------------ | ------------------------------------------------- |
| `id` | `string` | Supplies the unique stage identifier. |
| `stageContent` | `Component` | Supplies the Vue component. Use `markRaw()`. |
| `title` | `MaybeCtxFn<T, string>` | Supplies the breadcrumb title. |
| `skip` | `MaybeCtxFn<T, boolean>` | Skips the stage when the value is true. |
| `nonProgressStage` | `MaybeCtxFn<T, boolean>` | Removes the stage from the progress bar. |
| `hideStageInBreadcrumb` | `MaybeCtxFn<T, boolean>` | Removes the stage from breadcrumb navigation. |
| `cannotNavigateForward` | `MaybeCtxFn<T, boolean>` | Prevents forward navigation. |
| `disableClose` | `MaybeCtxFn<T, boolean>` | Disables modal close actions. |
| `leftButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Configures the left action button. |
| `rightButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Configures the right action button. |
| `maxWidth` | `MaybeCtxFn<T, string>` | Sets the stage width. The default is `560px`. |
**Button config fields:**
Button configuration fields:
| Field | Purpose |
| -------------- | ----------------------- |
| `label` | Button text |
| `icon` | Icon component |
| `iconPosition` | `'before'` or `'after'` |
| `color` | Button color prop |
| `disabled` | Disable the button |
| `onClick` | Click handler |
| Field | Purpose |
| -------------- | --------------------------------------- |
| `label` | Supplies the button text. |
| `icon` | Supplies the icon component. |
| `iconPosition` | Uses `'before'` or `'after'`. |
| `color` | Supplies the `Button` color prop. |
| `disabled` | Disables the button when true. |
| `onClick` | Supplies the click handler. |
### 3. Create stage components
### 3. Create Stage Components
Stage components inject the context and render their UI:
Inject the context into each stage component. Then, render the applicable UI:
```vue
<!-- providers/my-feature/stages/DetailsStage.vue -->
@@ -294,9 +311,9 @@ const { formData } = injectMyModalContext()
</template>
```
### 4. Create the wrapper component
### 4. Create the Wrapper Component
The wrapper provides context and renders `MultiStageModal`:
Provide the context from the wrapper. Then, render `MultiStageModal`:
```vue
<!-- components/MyModalWrapper.vue -->
@@ -319,20 +336,20 @@ defineExpose({ show: () => modal.value?.show() })
## Modal API
`MultiStageModal` exposes via ref:
`MultiStageModal` exposes these methods and properties through its reference:
| Method/Property | Description |
| --------------------- | ----------------------------------- |
| `show()` | Open the modal |
| `hide()` | Close the modal |
| `setStage(indexOrId)` | Jump to stage by index or string id |
| `nextStage()` | Advance to next non-skipped stage |
| `prevStage()` | Go back to previous stage |
| `currentStageIndex` | Ref to current stage index |
| Method or property | Description |
| ---------------------- | -------------------------------------------- |
| `show()` | Opens the modal. |
| `hide()` | Closes the modal. |
| `setStage(indexOrId)` | Goes to a stage by index or string ID. |
| `nextStage()` | Goes to the next applicable stage. |
| `prevStage()` | Goes to the previous stage. |
| `currentStageIndex` | Contains the current stage index as a `Ref`. |
## Non-Progress Stages (Edit Sub-Flows)
## Non-Progress Stages
For stages that shouldn't appear in the progress bar (e.g. editing a specific field from a summary page):
Use a non-progress stage for an edit flow that must not appear in the progress bar:
```ts
export const editLoadersStageConfig: StageConfigInput<MyContext> = {
@@ -351,16 +368,18 @@ export const editLoadersStageConfig: StageConfigInput<MyContext> = {
}
```
Navigate to it with `modal.value?.setStage('edit-loaders')` — it won't affect the progress indicator.
Call `modal.value?.setStage('edit-loaders')` to open the stage. This stage does not change the progress indicator.
## Reference Implementation
The version creation/edit modal is the most complete example:
The version create-and-edit modal is the most complete example:
| File | Purpose |
| ------------------------------------------------------------- | --------------------------------- |
| `apps/frontend/src/providers/version/manage-version-modal.ts` | Context creation + business logic |
| `apps/frontend/src/providers/version/stages/index.ts` | Stage config barrel export |
| `apps/frontend/src/providers/version/stages/*-stage.ts` | Individual stage configs |
| File | Purpose |
| ------------------------------------------------------------- | -------------------------------------- |
| `apps/frontend/src/providers/version/manage-version-modal.ts` | Contains context and application logic. |
| `apps/frontend/src/providers/version/stages/index.ts` | Exports all stage configurations. |
| `apps/frontend/src/providers/version/stages/*-stage.ts` | Contains each stage configuration. |
The context includes computed properties for conditional UI, watchers for auto-fetching dependencies, loading states for granular button disabling, and both "create" and "edit" flows sharing the same stages with different button configs.
The context has computed properties for conditional UI. It also has dependency watchers and granular button loading states.
The create and edit flows use the same stages with different button configurations.
+18 -12
View File
@@ -1,25 +1,31 @@
# Surface System
Use `surface-*` variables to describe UI elevation and separation. The scale is ordered from the page base up through stronger raised surfaces and strokes.
Use `surface-*` variables to show UI elevation and separation. The scale starts at the page base and ends at strong strokes.
## Layers
| Token | Use |
| ----------- | ------------------------------------------------------------------- |
| `surface-1` | Page background. |
| `surface-2` | Default raised surfaces, table rows, and standard card backgrounds. |
| `surface-3` | Header bands, inputs, dropdown surfaces, and card hover states. |
| `surface-4` | Standard strokes and outlines, including table outlines. |
| `surface-5` | Strong strokes for surfaces that need extra separation. |
| Token | Use |
| ----------- | ----------------------------------------------------------------- |
| `surface-1` | Use for the page background. |
| `surface-2` | Use for raised surfaces, table rows, and standard card backgrounds. |
| `surface-3` | Use for header bands, inputs, dropdowns, and card hover states. |
| `surface-4` | Use for standard strokes, outlines, and table outlines. |
| `surface-5` | Use for strong strokes that need more separation. |
## Strokes
Use `surface-4` for normal outlines and dividers. Tables should use `surface-4` for their outer border and row separators.
Use `surface-4` for standard outlines and dividers. Use it for table borders and row separators.
Reserve `surface-5` for stronger outlines, such as modal frames, high-emphasis separators, or hover states on elements that already sit on `surface-4`.
Use `surface-5` for modal frames, strong separators, and hover states above `surface-4`.
## Backgrounds
Use `surface-1` for page backgrounds and `surface-2` for ordinary raised content. Use `surface-3` for header strips, inputs, and temporary elevation such as hover states. Use `surface-4` sparingly as a stronger raised background, usually for controls or badges that need to sit above nearby content.
Use `surface-1` for page backgrounds. Use `surface-2` for standard raised content.
Avoid using legacy aliased background variables for new UI. Prefer explicit `bg-surface-*` and `border-surface-*` utilities so the layer intent is visible in the component.
Use `surface-3` for header strips, inputs, and temporary elevation. A hover state is an example of temporary elevation.
Use `surface-4` only for controls or badges that must appear above adjacent content.
Do not use legacy aliased background variables in new UI. Use explicit `bg-surface-*` and `border-surface-*` utilities.
These utilities show the intended layer in the component.