feat: move notion docs to standards folder (#5590)

* feat: move notion docs to standards folder

* fix: remove skills mention (automatic now)
This commit is contained in:
Calum H.
2026-03-16 17:30:05 +00:00
committed by GitHub
parent d9c7608ade
commit d0c7575a23
16 changed files with 1143 additions and 843 deletions

View File

@@ -0,0 +1,167 @@
- [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)
- [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)
- [4. Export types](#4-export-types)
- [Naming Conventions](#naming-conventions)
- [Key Files](#key-files)
# Adding a New API Module
How to add a new API endpoint module to `packages/api-client`.
## Steps
### 1. Define types in the module's `types.ts`
Types must match 1:1 with the backend API response. Do not reshape, rename, or omit fields.
Add to an existing namespace or create a new one:
```ts
// modules/labrinth/types.ts (existing namespace)
export namespace Labrinth {
export namespace MyDomain {
export namespace v3 {
export type Thing = {
id: string
name: string
created: string
// ... matches API response exactly
}
export type CreateThingRequest = {
name: string
}
}
}
}
```
For a new API service, create `modules/<service>/types.ts` with a new top-level namespace and re-export it from `modules/types.ts`.
### 2. Create the module class
Create `modules/<api>/<domain>/v<N>.ts`:
```ts
// modules/labrinth/things/v3.ts
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthThingsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_things_v3'
}
public async get(id: string): Promise<Labrinth.MyDomain.v3.Thing> {
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
public async create(data: Labrinth.MyDomain.v3.CreateThingRequest): Promise<Labrinth.MyDomain.v3.Thing> {
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}
public async delete(id: string): Promise<void> {
return this.client.request(`/thing/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
```
#### 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 |
#### For uploads
Return an `UploadHandle` instead of a `Promise`:
```ts
public uploadThing(id: string, file: File): UploadHandle<void> {
return this.client.upload<void>(`/thing/${id}/file`, {
api: 'labrinth',
version: 3,
file,
})
}
// Or with FormData for multipart:
public createWithFiles(data: CreateRequest, files: File[]): UploadHandle<Thing> {
const formData = new FormData()
formData.append('data', JSON.stringify(data))
files.forEach((f, i) => formData.append(`file-${i}`, f, f.name))
return this.client.upload<Thing>(`/thing`, {
api: 'labrinth',
version: 3,
formData,
timeout: 60 * 5 * 1000, // longer timeout for uploads
})
}
```
### 3. Register in the MODULE_REGISTRY
Add to `modules/index.ts`:
```ts
import { LabrinthThingsV3Module } from './labrinth/things/v3'
export const MODULE_REGISTRY = {
// ... 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`.
### 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`:
```ts
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` |
## 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`

View File

@@ -0,0 +1,157 @@
# 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.
## 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:
```
shared/content-tab/
├── layout.vue # Main layout component
├── types.ts # TypeScript types
├── components/ # Internal UI components
├── composables/ # Stateful logic (search, filtering, selection)
└── providers/ # DI context definitions
```
### How it works
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.
### DI contract example
```ts
// shared/content-tab/providers/content-manager.ts
export interface ContentManagerContext {
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
loading: Ref<boolean> | ComputedRef<boolean>
// Platform-abstracted operations
toggleEnabled: (item: ContentItem) => Promise<void>
deleteItem: (item: ContentItem) => Promise<void>
refresh: () => Promise<void>
// Optional capabilities — not every platform supports everything
hasUpdateSupport: boolean
updateItem?: (id: string) => void
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
mapToTableItem: (item: ContentItem) => ContentCardTableItem
}
export const [injectContentManager, provideContentManager] =
createContext<ContentManagerContext>('ContentPageLayout', 'contentManagerContext')
```
### Platform implementations
**Website** — uses `api-client` and TanStack Query:
```vue
<!-- apps/frontend/src/pages/instance/content.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
const { data: items } = useQuery({
queryKey: ['content', instanceId],
queryFn: () => client.content_v1.getAddons(instanceId),
})
provideContentManager({
items: computed(() => items.value?.map(addonToContentItem) ?? []),
deleteItem: async (item) => {
await client.content_v1.deleteAddon(instanceId, item.id)
},
// ... rest of the contract
})
</script>
<template>
<ContentPageLayout />
</template>
```
**App** — uses Tauri `invoke`:
```vue
<!-- apps/app-frontend/src/pages/instance/Mods.vue -->
<script setup lang="ts">
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[] */)
provideContentManager({
items,
deleteItem: async (item) => {
await invoke('delete_content', { instanceId, path: item.file_path })
},
// ... rest of the contract
})
</script>
<template>
<ContentPageLayout />
</template>
```
### 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:
```ts
// Contract
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
shareItems?: (items: ContentItem[], format: string) => void
// Layout checks before showing UI
v-if="ctx.bulkUpdateItems && hasOutdatedProjects"
```
### Props vs 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 |
## 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:
```
wrapped/hosting/manage/
├── index.vue
├── content.vue
├── backups.vue
├── files.vue
└── [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:
```vue
<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
<script setup lang="ts">
import { ServersManageContentPage } from '@modrinth/ui'
</script>
<template>
<ServersManageContentPage />
</template>
```
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.
## Composables
Reusable stateful logic lives in `packages/ui/src/layouts/shared/*/composables/`. These are consumed internally by the shared layout:
- **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

View File

@@ -0,0 +1,190 @@
- [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
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.
## 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:
```ts
import { createContext } from '@modrinth/ui'
interface MyContext {
someValue: Ref<string>
doSomething: () => void
}
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).
## 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.
### Platform Abstraction (Primary Use Case)
`packages/ui` components need capabilities that each frontend fulfils differently:
| 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 |
### Page-Level Context
Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components.
## Creating a New Provider
### 1. Define the interface in `packages/ui/src/providers/`
```ts
// packages/ui/src/providers/my-feature.ts
import type { Ref } from 'vue'
import { createContext } from '.'
export interface MyFeatureContext {
items: Ref<Item[]>
addItem: (item: Item) => Promise<void>
removeItem: (id: string) => Promise<void>
}
export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('MyFeature')
```
Re-export from the barrel file (`packages/ui/src/providers/index.ts`).
### 2. For complex platform-specific logic, use an abstract class
```ts
export abstract class AbstractMyFeatureManager {
abstract items: Ref<Item[]>
abstract addItem(item: Item): Promise<void>
// Shared logic lives on the base class
handleError(err: unknown) {
console.error(err)
}
}
export const [injectMyFeature, provideMyFeature] =
createContext<AbstractMyFeatureManager>('MyFeature')
```
See `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for a real example.
## Wiring Up Providers
### App Frontend (Tauri)
Create a setup function in `apps/app-frontend/src/providers/setup/`:
```ts
// apps/app-frontend/src/providers/setup/my-feature.ts
import { ref } from 'vue'
import { provideMyFeature } from '@modrinth/ui'
export function setupMyFeatureProvider() {
const items = ref<Item[]>([])
provideMyFeature({
items,
addItem: async (item) => {
await invoke('add_item', { item })
items.value.push(item)
},
removeItem: async (id) => {
await invoke('remove_item', { id })
items.value = items.value.filter(i => i.id !== id)
},
})
}
```
Register it in `apps/app-frontend/src/providers/setup.ts`, which is called from `App.vue`'s `setup()`.
### Website Frontend (Nuxt)
Provide directly in `apps/frontend/src/app.vue`, using Nuxt's `useState()` where SSR hydration is needed:
```ts
provideMyFeature({
items: useState<Item[]>('my-feature-items', () => []),
addItem: async (item) => {
await $fetch('/api/items', { method: 'POST', body: item })
},
removeItem: async (id) => {
await $fetch(`/api/items/${id}`, { method: 'DELETE' })
},
})
```
## Consuming Providers
In any component across `packages/ui`, `apps/frontend`, or `apps/app-frontend`:
```vue
<script setup lang="ts">
import { injectMyFeature } from '@modrinth/ui'
const { items, addItem } = injectMyFeature()
</script>
<template>
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<button @click="addItem({ id: '1', name: 'New' })">Add</button>
</template>
```
## When NOT to Use DI
Default to props and emits. DI adds indirection — only use it with a concrete 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.
## 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 |
## 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

View File

@@ -0,0 +1,164 @@
- [TanStack Query](#tanstack-query)
- [Setup](#setup)
- [Queries](#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)
# 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.
A TanStack MCP server is available — use `tanstack_doc` and `tanstack_search_docs` tools to look up API details when needed.
## 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).
## Queries
Use `useQuery` with the api-client for data fetching:
```ts
const client = injectModrinthClient()
const { data, isPending, isError, error } = useQuery({
queryKey: ['project', 'v3', projectId],
queryFn: () => client.labrinth.projects_v3.get(projectId),
staleTime: 1000 * 60 * 5,
})
```
In templates:
```vue
<span v-if="isPending">Loading...</span>
<span v-else-if="isError">Error: {{ error.message }}</span>
<div v-else>{{ data.title }}</div>
```
### Query Option Factories
For queries used across multiple components, define reusable query option factories in `packages/ui/src/queries/`:
```ts
// composables/queries/project.ts
export const STALE_TIME = 1000 * 60 * 5
export const STALE_TIME_LONG = 1000 * 60 * 10
export const projectQueryOptions = {
v3: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', 'v3', projectId] as const,
queryFn: () => client.labrinth.projects_v3.get(projectId),
staleTime: STALE_TIME,
}),
members: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'members'] as const,
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
staleTime: STALE_TIME,
}),
}
```
Then use them:
```ts
const { data } = useQuery(projectQueryOptions.v3(projectId, client))
```
### Conditional Queries
Use `enabled` as a computed for queries that depend on other data:
```ts
const { data: members } = useQuery({
queryKey: ['project', projectId, 'members'],
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
enabled: computed(() => !!projectId.value),
})
```
## Mutations
Use `useMutation` for create/update/delete operations. Invalidate related queries on success:
```ts
const queryClient = useQueryClient()
const client = injectModrinthClient()
const createMutation = useMutation({
mutationFn: (name: string) => client.archon.backups_v0.create(serverId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', 'list', serverId] }),
})
```
Use `createMutation.isPending.value` to disable buttons during submission.
### Optimistic Updates
For mutations where responsiveness matters, use optimistic updates with rollback:
```ts
const patchMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v3.patch(projectId, data)
return data
},
onMutate: async ({ projectId, data }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
const previous = queryClient.getQueryData(['project', 'v3', projectId])
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
if (!old) return old
return { ...old, ...data }
})
return { previous }
},
onError: (_err, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(['project', 'v3', projectId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
},
})
```
## Query Keys
Keys use a hierarchical array pattern:
```ts
// Resource type → version/qualifier → ID
['project', 'v3', projectId]
// Resource type → ID → sub-resource
['project', projectId, 'members']
['project', projectId, 'versions', 'v3']
// Domain → action → 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:
```ts
// Invalidates all project queries for this ID
queryClient.invalidateQueries({ queryKey: ['project', projectId] })
```
## 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

View File

@@ -0,0 +1,32 @@
- [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.

View File

@@ -0,0 +1,112 @@
- [Internationalization (i18n)](#internationalization-i18n)
- [Translatable Strings](#translatable-strings)
- [Message Definitions](#message-definitions)
- [Rendering Messages](#rendering-messages)
- [ICU Message Format](#icu-message-format)
- [Rich-Text Messages](#rich-text-messages)
- [Vue/ICU Delimiter Collisions](#vueicu-delimiter-collisions)
- [Imports](#imports)
- [Reference Examples](#reference-examples)
# Internationalization (i18n)
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>`.
## Translatable Strings
User-visible strings include: inner text, `alt` attributes, `placeholder` attributes, button labels, dropdown option labels, notification messages, etc.
Dynamic expressions (`{{ user.name }}`) and HTML tags are not translatable strings — only static human-readable text.
## 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:
```ts
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: "You're 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`.
## Rendering Messages
Use `useVIntl()` from `@modrinth/ui` for simple string formatting:
```ts
const { formatMessage } = useVIntl()
```
```vue
<button>{{ formatMessage(messages.welcomeTitle) }}</button>
{{ formatMessage(messages.greeting, { name: user.name }) }}
```
## ICU Message Format
Dynamic values use ICU placeholders in `defaultMessage`:
- **Variables:** `'Hello, {name}!'`
- **Numbers/dates/times:** `'{price, number, ::currency/USD}'`
- **Plurals/selects:** `'{count, plural, one {# message} other {# messages}}'`
## Rich-Text Messages
When a message contains links or markup, wrap the relevant ranges with named tags in `defaultMessage`:
```
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
```
Render with the `<IntlFormatted>` component using named slots:
```vue
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/terms">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-link="{ children }">
<NuxtLink to="/privacy">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
```
For simple emphasis (`'Welcome to <strong>Modrinth</strong>!'`):
```vue
<template #strong="{ children }">
<strong><component :is="() => children" /></strong>
</template>
```
For complex child handling, use `normalizeChildren` from `@modrinth/ui`:
```vue
<template #bold="{ children }">
<strong><component :is="() => normalizeChildren(children)" /></strong>
</template>
```
## Vue/ICU Delimiter Collisions
If an ICU placeholder ends right before `}}` in a Vue template, insert a space (`} }`) to avoid parsing issues.
## Imports
All i18n utilities come 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
## Reference Examples
- Variables and plurals: `apps/frontend/src/pages/frog.vue`
- Rich-text with link tags: `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`

View File

@@ -0,0 +1,370 @@
- [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)
# Regular Modals
Use the `NewModal` component (`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.
## Basic Usage
```vue
<script setup lang="ts">
import { ref } from vue
import { NewModal } from @modrinth/ui
const modal = ref<InstanceType<typeof NewModal> | null>(null)
</script>
<template>
<button @click="modal?.show($event)">Open</button>
<NewModal ref="modal" header="My Modal">
<p>Modal content here.</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.
## 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 |
## Slots
### Default slot
The main content area. Rendered inside a padded, optionally scrollable container.
```vue
<NewModal ref="modal" header="Confirm">
<p>Are you sure you want to proceed?</p>
</NewModal>
```
### `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).
```vue
<NewModal ref="modal">
<template #title>
<AlertIcon />
<span class="text-2xl font-semibold text-contrast">Custom Title</span>
</template>
<p>Content here.</p>
</NewModal>
```
### `actions` slot
Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Use this for confirm/cancel buttons.
```vue
<NewModal ref="modal" header="Delete Item" fade="danger">
<p>This action cannot be undone.</p>
<template #actions>
<ButtonStyled color="danger">
<button @click="handleDelete">Delete</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">Cancel</button>
</ButtonStyled>
</template>
</NewModal>
```
## 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.
```vue
<NewModal ref="modal" header="Long Content" scrollable max-content-height="60vh">
<!-- Long content that may overflow -->
</NewModal>
```
The `checkScrollState` method is exposed via ref — call it after dynamically changing content to re-evaluate whether fade indicators should appear.
When `scrollable` is `false` (the default), content uses `overflow-y: auto` without fade indicators.
## Merged Header Mode
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.
```vue
<NewModal ref="modal" merge-header no-padding>
<img src="..." class="w-full" />
<div class="p-6">
<p>Content below the image.</p>
</div>
</NewModal>
```
## Modal Stacking
`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.
## 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`) |
# 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.
## 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
## Building a Multistage Modal
### 1. Define the context
Create a DI provider with all the state your wizard needs. Include the modal ref and stage configs.
```ts
// providers/my-feature/my-modal.ts
import type { ShallowRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import type { MultiStageModal, StageConfigInput } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
export interface MyModalContext {
// State
formData: Ref<MyFormData>
isSubmitting: Ref<boolean>
// Modal control
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<MyModalContext>[]
// Business logic
handleSubmit: () => Promise<void>
}
export const [injectMyModalContext, provideMyModalContext] =
createContext<MyModalContext>('MyModal')
export function createMyModalContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): MyModalContext {
const formData = ref<MyFormData>({ ... })
const isSubmitting = ref(false)
async function handleSubmit() {
isSubmitting.value = true
try {
await saveData(formData.value)
modal.value?.hide()
} finally {
isSubmitting.value = false
}
}
return { formData, isSubmitting, modal, stageConfigs, handleSubmit }
}
```
### 2. Define stage configs
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>`).
```ts
// providers/my-feature/stages/details-stage.ts
import { markRaw } from 'vue'
import type { StageConfigInput } from '@modrinth/ui'
import type { MyModalContext } from '../my-modal'
import DetailsStage from './DetailsStage.vue'
import { RightArrowIcon, SaveIcon } from '@modrinth/assets'
export const detailsStageConfig: StageConfigInput<MyModalContext> = {
id: 'details',
stageContent: markRaw(DetailsStage),
title: 'Details',
// Conditional behavior based on context
skip: (ctx) => ctx.shouldSkipDetails.value,
cannotNavigateForward: (ctx) => !ctx.formData.value.name,
disableClose: (ctx) => ctx.isSubmitting.value,
leftButtonConfig: (ctx) => ({
label: 'Cancel',
onClick: () => ctx.modal.value?.hide(),
}),
rightButtonConfig: (ctx) => ({
label: 'Next',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !ctx.formData.value.name,
onClick: () => ctx.modal.value?.nextStage(),
}),
}
```
**Stage config 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`) |
**Button config fields:**
| Field | Purpose |
| -------------- | ----------------------- |
| `label` | Button text |
| `icon` | Icon component |
| `iconPosition` | `'before'` or `'after'` |
| `color` | ButtonStyled color prop |
| `disabled` | Disable the button |
| `onClick` | Click handler |
### 3. Create stage components
Stage components inject the context and render their UI:
```vue
<!-- providers/my-feature/stages/DetailsStage.vue -->
<script setup lang="ts">
import { injectMyModalContext } from '../my-modal'
const { formData } = injectMyModalContext()
</script>
<template>
<div class="flex flex-col gap-4">
<StyledInput v-model="formData.name" label="Name" />
<StyledInput v-model="formData.description" label="Description" />
</div>
</template>
```
### 4. Create the wrapper component
The wrapper provides context and renders `MultiStageModal`:
```vue
<!-- components/MyModalWrapper.vue -->
<script setup lang="ts">
import { shallowRef } from 'vue'
import { MultiStageModal } from '@modrinth/ui'
import { createMyModalContext, provideMyModalContext } from '../providers/my-feature/my-modal'
const modal = shallowRef<InstanceType<typeof MultiStageModal> | null>(null)
const ctx = createMyModalContext(modal)
provideMyModalContext(ctx)
defineExpose({ show: () => modal.value?.show() })
</script>
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
</template>
```
## Modal API
`MultiStageModal` exposes via ref:
| 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 |
## Non-Progress Stages (Edit Sub-Flows)
For stages that shouldn't appear in the progress bar (e.g. editing a specific field from a summary page):
```ts
export const editLoadersStageConfig: StageConfigInput<MyContext> = {
id: 'edit-loaders',
nonProgressStage: true,
stageContent: markRaw(EditLoadersStage),
title: 'Edit loaders',
leftButtonConfig: (ctx) => ({
label: 'Back',
onClick: () => ctx.modal.value?.setStage('summary'),
}),
rightButtonConfig: (ctx) => ({
...ctx.saveButtonConfig(),
label: 'Save',
}),
}
```
Navigate to it with `modal.value?.setStage('edit-loaders')` — it won't affect the progress indicator.
## Reference Implementation
The version creation/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 |
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.