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
+23
View File
@@ -0,0 +1,23 @@
---
name: api-module
description: Add an API endpoint module to packages/api-client from an OpenAPI schema. Use for new backend endpoints, API client modules, or tasks that provide an OpenAPI schema.
---
# Add an API Module
Read the applicable `AGENTS.md` files before you edit code.
Read [the API module standard](../../../standards/frontend/ADDING_API_MODULES.md) in full.
1. Identify the OpenAPI schema from the request. If more than one schema is possible, ask the user to select one.
2. Read the schema. Identify each endpoint, HTTP method, request type, response type, and path parameter.
3. Get the service and version from the URL prefix. For example, map `/v3/projects` to `labrinth/v3/`.
4. Define the API types in `types.ts`. Make each type match the schema exactly.
5. Do not change, rename, or remove API fields.
6. Make a module class that extends `AbstractModule`. Implement each endpoint with `this.client.request()` or `this.client.upload()`.
7. Use the request-option pattern from the standard. Do not call `$fetch`, `fetch`, or another HTTP client directly.
8. Add the module to `MODULE_REGISTRY` so the client can instantiate it.
9. Export new service types from the applicable barrel `index.ts`.
10. Check the module paths, registry key, public type exports, and endpoint types.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Add API Module"
short_description: "Add typed API client modules from OpenAPI"
default_prompt: "Use $api-module to add an API client module from this OpenAPI schema."
@@ -0,0 +1,38 @@
---
name: cross-platform-pages
description: Convert a page to the shared Modrinth page system for the website and desktop app. Use for shared layouts, wrapped layouts, or platform dependency-injection contracts.
---
# Convert a Cross-Platform Page
Read the applicable `AGENTS.md` files before you edit code.
Read these standards in full:
- [Cross-platform pages](../../../standards/frontend/CROSS_PLATFORM_PAGES.md)
- [Dependency injection](../../../standards/frontend/DEPENDENCY_INJECTION.md)
1. Identify the target page from the request.
2. Read the page and its route shell. Identify data sources, mutations, navigation, and platform APIs.
3. Use a wrapped layout when both platforms use the same API source and page logic.
4. Use a shared layout when platform data or operations have different implementations.
For a shared layout:
1. Define a provider contract for all platform operations.
2. Put common UI and state logic in the shared layout.
3. Put reusable search, filter, and selection logic in local composables.
4. Implement the contract in `apps/frontend/` and `apps/app-frontend/`.
5. Use optional contract fields only for capabilities that are not available on both platforms.
For a wrapped layout:
1. Move the page to `packages/ui/src/layouts/wrapped/` and preserve its route structure.
2. Replace platform-only imports with common utilities or provider calls.
3. Make each frontend route shell render the wrapped component.
4. Match primary query options in both route shells when the layout uses `ReadyTransition` and `useReadyState`.
5. Prefetch these queries with `ensureQueryData`, as the standard specifies.
Check that both route shells resolve their imports. Check that all required provider fields have implementations.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Convert Cross-Platform Page"
short_description: "Share pages across the web and desktop app"
default_prompt: "Use $cross-platform-pages to convert this page for the website and desktop app."
+21
View File
@@ -0,0 +1,21 @@
---
name: figma-mcp
description: Convert a Figma design into a Modrinth Vue page or component. Use when a request provides a Figma URL or asks to implement a Figma layout.
---
# Implement a Figma Design
Read the applicable `AGENTS.md` files before you edit code.
Read `packages/ui/AGENTS.md` in full.
1. Load the available Figma design-to-code instructions and follow the MCP tool guidance.
2. Call `get_design_context` first with `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`.
3. Treat the result as reference code and adapt it to the Modrinth codebase.
4. Map Figma color variables to the applicable `surface-*` and `text-*` tokens. Do not use aliased Figma names directly.
5. Reuse applicable components from `packages/ui/src/components/` before creating new ones. Also refer to `standards/frontend/COMPONENT_STRUCTURE.md`
6. Read `packages/assets/styles/variables.scss` when Figma does not supply a required token.
7. Use exact spacing values from the design.
8. Implement the result as a Vue SFC with Tailwind classes and the existing component library.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Implement Figma Design"
short_description: "Build Modrinth Vue UI from Figma designs"
default_prompt: "Use $figma-mcp to implement this Figma design as a Modrinth Vue component."
+32
View File
@@ -0,0 +1,32 @@
---
name: i18n-pass
description: Convert hard-coded English text in changed Vue components to the @modrinth/ui localization system. Use for an i18n pass, untranslated-string review, pull request, or component migration.
---
# Do an Internationalization Pass
Read the applicable `AGENTS.md` files before you edit code.
Read [the internationalization standard](../../../standards/frontend/INTERNATIONALIZATION.md) in full.
1. Identify the scope from the request.
2. For a pull request, use `gh pr diff <number>` to identify changed files.
3. For a file path, inspect that file.
4. When the request gives no scope, inspect the current uncommitted diff.
5. Limit the pass to changed `.vue` files.
6. Find user-visible text in templates and scripts.
Check inner text, `alt`, `placeholder`, `aria-label`, buttons, tooltips, notifications, dropdown labels, and error messages.
Do not change dynamic expressions, HTML tag names, CSS classes, internal identifiers, or log messages.
1. Define stable message IDs with `defineMessage` or `defineMessages`.
2. Replace simple text with `formatMessage()` calls.
3. Use `<IntlFormatted>` for text that contains links or markup.
4. Use ICU selections and plurals when grammar depends on a value.
5. Add a space before `}}` when an ICU placeholder ends at the Vue delimiter.
6. Do not change component logic, layout, or reactivity.
7. Do not edit localization JSON files. The user maintains those files.
8. Check the changed templates again for hard-coded English text.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Run Internationalization Pass"
short_description: "Localize user-visible text in Vue files"
default_prompt: "Use $i18n-pass to localize the user-visible text in these changed Vue files."
+40
View File
@@ -0,0 +1,40 @@
---
name: review-changelog
description: Review the latest packages/blog/changelog.ts entry against the Modrinth changelog standard. Use before a pull request or when asked to review or lint a changelog entry.
---
# Review a Changelog Entry
Read [the changelog standard](../../../standards/maintaining/CHANGELOG.md) in full before the review.
1. Open `packages/blog/changelog.ts`.
2. Find the first entry in the `VERSIONS` array.
3. If the request names `web`, `hosting`, or `app`, review the latest entry for that product.
4. Otherwise, review the latest entry and all adjacent entries with the same date.
Check the entry structure:
- `date` contains a valid ISO 8601 timestamp.
- `product` is `web`, `hosting`, or `app`.
- An `app` entry has a `version` value.
- A `web` or `hosting` entry does not have a `version` value.
- Standard headings are `## Added`, `## Changed`, `## Fixed`, and `## Security`.
- A featured release can use a linked heading.
- Flag the legacy `## Improvements` heading.
Check each bullet:
- The voice and tense agree with the section.
- The first verb agrees with the section.
- The bullet describes user-visible behavior, not implementation.
- The bullet identifies the applicable page, tab, modal, or feature.
- The bullet contains one sentence, uses sentence case, and ends with a period.
- Product and UI names use the public labels.
- The bullet does not contain filler, vague intensifiers, apologies, or internal references.
- The bullet is not a duplicate detail of a larger listed change.
Group findings by entry. For each finding, show the original bullet and a proposed replacement.
If the entry has no findings, state this result. Do not edit the changelog unless the user asks you to apply fixes.
When the user asks for fixes, preserve tab indentation and template-literal formatting.
@@ -0,0 +1,4 @@
interface:
display_name: "Review Changelog"
short_description: "Review changelog entries for style problems"
default_prompt: "Use $review-changelog to review the latest changelog entry."
+39
View File
@@ -0,0 +1,39 @@
---
name: tanstack-query
description: Convert Vue server-state code to TanStack Query. Use for useQuery, useMutation, cache invalidation, optimistic updates, or replacement of useAsyncData and manual ref patterns.
---
# Convert Data Code to TanStack Query
Read the applicable `AGENTS.md` files before you edit code.
Read [the TanStack Query standard](../../../standards/frontend/FETCHING_DATA.md) in full.
1. Identify the target file from the request.
2. Find `useAsyncData`, `useFetch`, manual API refs, and fetch calls in `onMounted`.
3. Identify mutations that use manual loading, error, or result refs.
For queries:
1. Replace manual fetch logic with `useQuery`.
2. Get `api-client` with `injectModrinthClient()`.
3. Use a hierarchical query key with the resource, qualifier, and parameters.
4. Use a computed query key for reactive parameters.
5. Use a computed `enabled` option when the query depends on other data.
6. Use a shared query-option factory when multiple components use the query.
For mutations:
1. Replace manual mutation state with `useMutation`.
2. Invalidate or update related query data after success.
3. Use an optimistic update only when the UI needs an immediate response.
4. Cancel the applicable query and save its prior data before an optimistic update.
5. Restore the prior data after an error. Invalidate the query after settlement.
Remove manual loading and error refs that TanStack Query replaces. Remove obsolete `onMounted` fetch calls.
Keep Nuxt SSR behavior. Match route-shell prefetch options when `ReadyTransition` and `useReadyState` depend on the query.
Check query keys, invalidation prefixes, reactive values, and rollback data.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Migrate to TanStack Query"
short_description: "Migrate Vue server state to TanStack Query"
default_prompt: "Use $tanstack-query to migrate this Vue component to TanStack Query."
-18
View File
@@ -1,18 +0,0 @@
---
name: api-module
description: Add a new API endpoint module to packages/api-client from an OpenAPI schema. Use when adding new backend endpoints, creating API client modules, or when an openapi.yml is provided.
argument-hint: <path-to-openapi.yml>
---
Refer to the standard: @standards/frontend/ADDING_API_MODULES.md
## Steps
1. **Read the OpenAPI schema** at `$ARGUMENTS` — identify the endpoints, request/response shapes, and path parameters.
2. **Read the standard above** for naming conventions, type rules, and the module registration pattern.
3. **Determine the service and version** — the URL path prefix tells you which service directory and version namespace to use (e.g. `/v3/projects``labrinth/v3/`).
4. **Define types in `types.ts`** — types must match the API response 1:1. Use the OpenAPI schema as the source of truth. Do not reshape or rename fields.
5. **Create the module class** — extend `BaseModule`, implement each endpoint as a method. Use the correct HTTP verb and request options pattern from the standard.
6. **Register in `MODULE_REGISTRY`** — add the module entry so it's auto-instantiated on the client.
7. **Export types** from the service's barrel `index.ts`.
8. **Verify** — check that the module compiles and the types are accessible from `@modrinth/api-client`.
@@ -1,26 +0,0 @@
---
name: cross-platform-pages
description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction.
argument-hint: <path-to-page>
---
Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standards/frontend/DEPENDENCY_INJECTION.md
## Steps
1. **Read the target page** at `$ARGUMENTS` and understand its data sources, mutations, and navigation.
2. **Read the standards above** to understand the shared vs wrapped distinction and the DI pattern.
3. **Decide the category:**
- **Wrapped** (`layouts/wrapped/`) — if the page uses the same API source on both platforms (e.g. web requests, not Tauri plugins). Just move the page component into `packages/ui` and import it from both frontends.
- **Shared** (`layouts/shared/`) — if the page has different data-fetching logic per platform (e.g. website uses `api-client`, app uses Tauri `invoke`). Requires a DI contract.
4. **For shared layouts:**
- Define a DI contract interface in `providers/` capturing all platform-specific operations.
- Create the layout component that injects the context and handles all UI logic.
- Extract reusable stateful logic (search, filtering, selection) into `composables/`.
- Implement the contract separately in each frontend (`apps/frontend/`, `apps/app-frontend/`).
5. **For wrapped pages:**
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
- Replace any platform-specific imports with shared utilities.
- Import and render the wrapped page from both frontends as a simple component.
- If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`.
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
-22
View File
@@ -1,22 +0,0 @@
---
name: figma-mcp
description: Use the Figma MCP server to translate a Figma design into a Vue page or component layout. Use when the user provides a Figma URL, asks to implement a design, or wants to draft a page layout from Figma.
argument-hint: <figma-url>
---
Refer to the standard: @standards/frontend/FIGMA_MCP_USAGE.md
Also read @packages/ui/CLAUDE.md for color token mapping and component conventions.
## Steps
1. **Parse the Figma URL** from `$ARGUMENTS` — extract the `fileKey` and `nodeId`. Convert `-` to `:` in the node ID.
2. **Read the standards above** for the available tools, adaptation rules, and color usage.
3. **Call `get_design_context`** with the extracted `nodeId` and `fileKey`, using `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`. This is always the first tool to call.
5. **Adapt the output to the Modrinth codebase:**
- Map Figma color variables to `surface-*` / `text-*` tokens — never use Figma's aliased names directly.
- Check `packages/ui/src/components/` for existing components that match elements in the design (buttons, cards, modals, inputs, etc.).
- Check `packages/assets/styles/variables.scss` for tokens not exposed in Figma.
- Match spacing values exactly from the design.
6. **Use `get_screenshot`** if you need a closer visual reference of specific nodes.
7. **Use `get_variable_defs`** to verify which design tokens are applied to ambiguous elements.
8. **Build the component** as a Vue SFC using Tailwind classes and the project's existing component library.
-24
View File
@@ -1,24 +0,0 @@
---
name: i18n-pass
description: Perform an i18n localization pass on changed files or a pull request, converting hard-coded English strings to the @modrinth/ui i18n system. Use when internationalizing a set of changes, reviewing a PR for untranslated strings, or converting a specific component.
argument-hint: [file-path-or-pr-number]
---
Refer to the standard: @standards/frontend/INTERNATIONALIZATION.md
## Steps
1. **Identify the scope of changes:**
- If `$ARGUMENTS` is a PR number, run `gh pr diff $ARGUMENTS` to get the changed files.
- If `$ARGUMENTS` is a file path, use that directly.
- If no argument, check `git diff` for uncommitted changes.
2. **Read the standard above** for the message definition pattern, ICU format rules, and `IntlFormatted` usage.
3. **Filter to Vue SFCs** — only `.vue` files need i18n passes. Skip non-component files.
4. **For each file, scan for hard-coded strings:**
- `<template>`: inner text, `alt`, `placeholder`, `aria-label`, button labels, tooltip text.
- `<script>`: string literals passed to user-visible UI (notification messages, dropdown labels, error messages).
- Skip: dynamic expressions, HTML tag names, CSS classes, internal identifiers, log messages.
5. **Define messages** with `defineMessages` — use descriptive, stable `id`s based on the component's domain (e.g. `project.settings.title`).
6. **Replace strings in templates** with `formatMessage()` calls, or `<IntlFormatted>` for strings containing links or markup.
7. **Handle ICU edge cases** — add a space before `}}` if an ICU placeholder ends at a Vue template delimiter boundary.
8. **Verify** no hard-coded English strings remain in the changed templates. Do not alter logic, layout, or reactivity.
-36
View File
@@ -1,36 +0,0 @@
---
name: review-changelog
description: Review the latest changelog entry in packages/blog/changelog.ts against the project's changelog style guide and flag bullets that need rewriting. Use when checking a freshly added changelog entry before opening a PR, or when the user asks to review/lint the latest changelog.
argument-hint: [product?]
---
Refer to the standard: @standards/maintaining/CHANGELOG.md
## Steps
1. **Locate the latest entry:**
- Open `packages/blog/changelog.ts`.
- The latest entries are at the top of the `VERSIONS` array.
- If `$ARGUMENTS` specifies a product (`web`, `hosting`, `app`), review the most recent entry for that product. Otherwise, review the most recent entry overall, plus any sibling entries sharing the same `date` (coordinated releases ship together).
2. **Read the standard above** in full before reviewing. The bullet rules, section/verb agreement, and "Don't" list are the source of truth.
3. **Check the entry shell:**
- `date` is a valid ISO 8601 timestamp.
- `product` is one of `web`, `hosting`, `app`.
- `version` is present for `app` entries and omitted for `web`/`hosting`.
- Section headings use `## Added`, `## Changed`, `## Fixed`, `## Security` (or a featured-release linked heading). Flag legacy `## Improvements`.
4. **Review each bullet** against the standard. For each bullet, check:
- Voice/tense matches the section heading.
- Opening verb agrees with its section.
- Describes observable behavior, not implementation.
- Specific enough to identify the surface (names the tab/page/modal).
- One sentence, ends with a period, sentence case.
- Uses branded names (Modrinth App, Modrinth Hosting) correctly.
- No filler ("issue with", "issue where", "various", "some"), no vague intensifiers, no apologies, no PR/commit references (unless crediting a third-party contributor with a linked GitHub profile).
- Not a duplicate sub-fix of a bigger change already listed.
5. **Report findings** as a short list grouped by entry. For each problem bullet, show the original line and a suggested rewrite. If the entry is clean, say so explicitly. Do not edit the file unless the user asks - this skill is a review pass, not a rewrite pass.
6. **If the user then asks to apply fixes**, edit `packages/blog/changelog.ts` directly using the suggested rewrites. Preserve tab indentation and template literal formatting.
-27
View File
@@ -1,27 +0,0 @@
---
name: tanstack-query
description: Convert a page or component from useAsyncData/manual ref patterns to TanStack Query for server state management. Use when migrating data fetching to useQuery/useMutation, adding cache invalidation, or replacing useAsyncData with TanStack Query.
argument-hint: <path-to-file>
---
Refer to the standard: @standards/frontend/FETCHING_DATA.md
## Steps
1. **Read the target file** at `$ARGUMENTS` and identify all data-fetching patterns: `useAsyncData`, `useFetch`, manual `ref()` + `await`, or `onMounted` fetch calls.
2. **Read the standard above** for the query/mutation patterns, query key conventions, and optimistic update approach.
3. **Convert queries:**
- Replace `useAsyncData` / `useFetch` / manual fetches with `useQuery`.
- Use the `api-client` via `injectModrinthClient()` for the `queryFn`.
- Design query keys with the `['resource', 'version', ...params]` convention.
- Use `computed` query keys for reactive parameters.
- Use the `enabled` option for conditional queries that depend on other data.
4. **Convert mutations:**
- Replace manual `try/catch` + `ref` patterns with `useMutation`.
- Add `onSuccess` handlers that invalidate or update related query caches.
- Consider optimistic updates for UI-critical mutations (follow the pattern in the standard).
5. **Clean up:**
- Remove manual loading/error `ref()`s that are now handled by TanStack Query's return values (`isPending`, `isError`, `error`).
- Remove manual `onMounted` fetch calls.
- Ensure SSR compatibility — queries in Nuxt pages are automatically awaited during SSR.
6. **Verify** the page still renders correctly and that cache invalidation triggers re-fetches where expected.
+1 -2
View File
@@ -65,8 +65,7 @@ generated
app-playground-data/*
.astro
.claude/*
!.claude/skills/
.claude/
.letta
# labrinth demo fixtures
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+93
View File
@@ -0,0 +1,93 @@
# Modrinth Monorepo
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read its AGENTS.md.
## Architecture
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
- **Indentation:** Use TAB everywhere, never spaces
### Apps (`apps/`)
| App | Description |
| ----------------- | ------------------------------ |
| `frontend` | Main Modrinth website (Nuxt 3) |
| `app-frontend` | Desktop/app frontend (Vue 3) |
| `app` | Desktop/app shell (Tauri) |
| `app-playground` | Testing playground for app |
| `labrinth` | Backend API service |
| `daedalus_client` | Daedalus client implementation |
| `docs` | Documentation site (Astro) |
### Packages (`packages/`)
| Package | Description |
| ------------------ | ----------------------------------------------------- |
| `ui` | Shared Vue component library (`@modrinth/ui`) |
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
| `app-lib` | Shared app library |
| `blog` | Blog system and changelog data |
| `utils` | Shared utility functions (mostly deprecated) |
| `moderation` | Moderation utilities |
| `daedalus` | Daedalus protocol |
| `tooling-config` | ESLint, Prettier, TypeScript configs |
| `ariadne` | Analytics library |
| `modrinth-log` | Logging utilities |
| `modrinth-maxmind` | MaxMind GeoIP |
| `modrinth-util` | General utilities |
| `muralpay` | Payment processing |
| `path-util` | Path utilities |
| `sqlx-tracing` | SQLx query tracing |
## Pre-PR Commands
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
- **Website:** `pnpm prepr:frontend:web`
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
The website and app `prepr` commands
## Dev Commands
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
- **Storybook (packages/ui):** `pnpm storybook`
## Project-Specific Instructions
Each project may have its own file with detailed instructions:
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/AGENTS.md`](apps/frontend/AGENTS.md) - Frontend Website
## Code Guidelines
### Comments
- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- IMPORTANT: Run commands directly without pipes when possible
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
## Standards
Standards available at the @standards/ folder.
-110
View File
@@ -1,110 +0,0 @@
# Modrinth Monorepo
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read it's CLAUDE.md.
## Architecture
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
- **Indentation:** Use TAB everywhere, never spaces
### Apps (`apps/`)
| App | Description |
| ----------------- | ------------------------------ |
| `frontend` | Main Modrinth website (Nuxt 3) |
| `app-frontend` | Desktop/app frontend (Vue 3) |
| `app` | Desktop/app shell (Tauri) |
| `app-playground` | Testing playground for app |
| `labrinth` | Backend API service |
| `daedalus_client` | Daedalus client implementation |
| `docs` | Documentation site (Astro) |
### Packages (`packages/`)
| Package | Description |
| ------------------ | ----------------------------------------------------- |
| `ui` | Shared Vue component library (`@modrinth/ui`) |
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
| `app-lib` | Shared app library |
| `blog` | Blog system and changelog data |
| `utils` | Shared utility functions (mostly deprecated) |
| `moderation` | Moderation utilities |
| `daedalus` | Daedalus protocol |
| `tooling-config` | ESLint, Prettier, TypeScript configs |
| `ariadne` | Analytics library |
| `modrinth-log` | Logging utilities |
| `modrinth-maxmind` | MaxMind GeoIP |
| `modrinth-util` | General utilities |
| `muralpay` | Payment processing |
| `path-util` | Path utilities |
| `sqlx-tracing` | SQLx query tracing |
## Pre-PR Commands
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
- **Website:** `pnpm prepr:frontend:web`
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
The website and app `prepr` commands
## Dev Commands
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
- **Storybook (packages/ui):** `pnpm storybook`
## Project-Specific Instructions
Each project may have its own file with detailed instructions:
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
### Comments
- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- IMPORTANT: Run commands directly without pipes when possible
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
## Edit Tool - Whitespace Handling (CLAUDE ONLY)
The Read tool uses `→` to mark where line numbers end and file content begins.
**Rule:** Copy the EXACT whitespace that appears after the `→` marker.
- Whatever appears between `→` and the code text is what's actually in the file
- That whitespace must be used EXACTLY in Edit tool's old_string
- Don't count arrows, don't interpret - just copy what's after the `→`
**Example:**
14→ private byte tag;
For Edit, use: ` private byte tag;` (copy everything after →, including the two tabs)
**If Edit fails:** Stop and explain the problem. Do not attempt sed/awk/bash workarounds.
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
## Standards
Standards available at the @standards/ folder.
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+42
View File
@@ -0,0 +1,42 @@
# apps/frontend — Modrinth Website
Nuxt 3 application serving the main Modrinth website. Uses Vue 3, Tailwind CSS v3, and file-based routing.
## Architecture
Nuxt 3 with SSR — pages are server-rendered and hydrated on the client. Uses `$fetch` for server-side data fetching and `@modrinth/api-client` (via `NuxtModrinthClient`) for client-side API calls.
## Key Directories
- **`src/pages/`** — file-based routing (`[param].vue` for dynamic segments, nested folders for nested routes)
- **`src/components/`** — website-specific components (not shared with the app)
- **`src/composables/`** — Vue composables, including `queries/` for TanStack Query options
- **`src/providers/`** — page-level DI context providers (e.g., version modal, project page)
- **`src/plugins/`** — Nuxt plugins (TanStack Query setup, theme, etc.)
- **`src/middleware/`** — route guards and auth checks
- **`src/layouts/`** — Nuxt layout components
- **`src/server/`** — server-side plugins, routes, and utilities
- **`src/store/`** — Pinia state management
- **`src/helpers/`** — utility functions
- **`src/locales/`** — i18n translation files
## Components
**Website-specific components go in `src/components/`.** These are components that only make sense in the website context — admin panels, moderation tools, dashboard widgets, brand components, etc.
**Shared components go in `packages/ui`.** If a component could be used by both the website and the desktop app, it belongs in `packages/ui/src/components/`. See `packages/ui/AGENTS.md` for UI standards, color rules, and component patterns.
Rule of thumb: if it doesn't depend on Nuxt-specific APIs or website-only features, it should be in `packages/ui`.
## Data Fetching
Use `@modrinth/api-client` via `injectModrinthClient()` for all API calls. See `packages/api-client/AGENTS.md` for the full API client documentation.
For caching and server state, use TanStack Query (`@tanstack/vue-query`). See the `tanstack-query` skill (`.agents/skills/tanstack-query/SKILL.md`) for patterns and conventions used in this codebase.
### Deprecated Composables
These composables are deprecated and should not be used in new code:
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
-42
View File
@@ -1,42 +0,0 @@
# apps/frontend — Modrinth Website
Nuxt 3 application serving the main Modrinth website. Uses Vue 3, Tailwind CSS v3, and file-based routing.
## Architecture
Nuxt 3 with SSR — pages are server-rendered and hydrated on the client. Uses `$fetch` for server-side data fetching and `@modrinth/api-client` (via `NuxtModrinthClient`) for client-side API calls.
## Key Directories
- **`src/pages/`** — file-based routing (`[param].vue` for dynamic segments, nested folders for nested routes)
- **`src/components/`** — website-specific components (not shared with the app)
- **`src/composables/`** — Vue composables, including `queries/` for TanStack Query options
- **`src/providers/`** — page-level DI context providers (e.g., version modal, project page)
- **`src/plugins/`** — Nuxt plugins (TanStack Query setup, theme, etc.)
- **`src/middleware/`** — route guards and auth checks
- **`src/layouts/`** — Nuxt layout components
- **`src/server/`** — server-side plugins, routes, and utilities
- **`src/store/`** — Pinia state management
- **`src/helpers/`** — utility functions
- **`src/locales/`** — i18n translation files
## Components
**Website-specific components go in `src/components/`.** These are components that only make sense in the website context — admin panels, moderation tools, dashboard widgets, brand components, etc.
**Shared components go in `packages/ui`.** If a component could be used by both the website and the desktop app, it belongs in `packages/ui/src/components/`. See `packages/ui/CLAUDE.md` for UI standards, color rules, and component patterns.
Rule of thumb: if it doesn't depend on Nuxt-specific APIs or website-only features, it should be in `packages/ui`.
## Data Fetching
Use `@modrinth/api-client` via `injectModrinthClient()` for all API calls. See `packages/api-client/CLAUDE.md` for the full API client documentation.
For caching and server state, use TanStack Query (`@tanstack/vue-query`). See the `tanstack-query` skill (`.claude/skills/tanstack-query/SKILL.md`) for patterns and conventions used in this codebase.
### Deprecated Composables
These composables are deprecated and should not be used in new code:
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
-1
View File
@@ -1 +0,0 @@
Read @AGENTS.md
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+208
View File
@@ -0,0 +1,208 @@
# @modrinth/api-client
Platform-agnostic API client for Modrinth's services. Works in Nuxt (SSR + CSR), Tauri (desktop app), and plain Node/browser environments.
## Architecture
```
Request Flow:
Module Method → client.request() → Feature Chain (middleware) → Platform executeRequest()
```
### Key Directories
- **`src/core/`** — base classes (`AbstractModrinthClient`, `AbstractModule`, `AbstractFeature`, etc.)
- **`src/platform/`** — platform implementations (generic, nuxt, tauri, xhr-upload, websocket)
- **`src/features/`** — middleware plugins (auth, retry, circuit-breaker, etc.)
- **`src/modules/`** — API endpoint modules organized by service (`labrinth/`, `archon/`, `kyros/`, `iso3166/`)
- **`src/types/`** — core type definitions (client config, request options, upload types, errors)
### Client Hierarchy
All platform clients extend `XHRUploadClient``AbstractModrinthClient`:
- **`GenericModrinthClient`** — uses `ofetch`, attaches WebSocket client to `archon.sockets`
- **`NuxtModrinthClient`** — uses Nuxt's `$fetch`, SSR-aware, blocks `upload()` during SSR
- **`TauriModrinthClient`** — uses `@tauri-apps/plugin-http`
### Module Access
Modules are lazy-loaded and accessed as a nested structure:
```ts
client.labrinth.projects_v2
client.labrinth.projects_v3
client.labrinth.versions_v3
client.labrinth.collections
client.labrinth.billing_internal
client.archon.servers_v0
client.archon.servers_v1
client.archon.backups_queue_v1
client.archon.backups_v1
client.archon.content_v0
client.kyros.files_v0
client.iso3166.data
... etc.
```
This structure is derived at runtime from the flat `MODULE_REGISTRY` in `modules/index.ts` via `buildModuleStructure()`, and the TypeScript types are inferred automatically via `InferredClientModules`.
## Critical: Always use `this.client.request()`
API modules **must** use `this.client.request()` (or `.upload`) for all HTTP calls — never `$fetch`, `fetch`, or any other HTTP library directly. The request method routes through the platform-specific implementation (Nuxt `$fetch`, Tauri HTTP plugin, etc.) and the feature middleware chain (auth, retry, circuit breaker). Using `$fetch` directly bypasses the platform layer and will fail in Tauri (CORS/sandboxing). The only exception is the `ISO3166Module` which is explicitly node-only.
For external APIs (non-Modrinth), pass the full base URL as the `api` field and set `skipAuth: true`:
```ts
this.client.request<MyType>('/endpoint', {
api: 'https://external-api.com',
version: 1,
method: 'POST',
body: { data },
skipAuth: true,
})
```
## Usage
The client is provided to the component tree via DI (see `standards/frontend/DEPENDENCY_INJECTION.md`). Each app creates a platform-specific client and provides it at the root:
```ts
// apps/frontend/src/app.vue (Nuxt)
const client = new NuxtModrinthClient({ ... })
provideModrinthClient(client)
// apps/app-frontend/src/App.vue (Tauri)
const client = new TauriModrinthClient({ ... })
provideModrinthClient(client)
```
Components anywhere in the tree then inject it:
```ts
const { labrinth, archon, kyros } = injectModrinthClient()
// Fetch data
const project = await labrinth.projects_v3.get(projectId)
// Use with TanStack Query
const { data } = useQuery({
queryKey: ['project', projectId],
queryFn: () => labrinth.projects_v3.get(projectId),
})
```
`provideModrinthClient` and `injectModrinthClient` are exported from `@modrinth/ui` (defined in `packages/ui/src/providers/api-client.ts`). The provider is typed as `AbstractModrinthClient`, so shared components in `packages/ui` work with any platform client.
## Types
Types must match 1:1 with how they are returned from the backend API they are fetching from. Do not reshape, rename, or omit fields — the types should be a direct representation of the API response.
Types are organized in namespaces that mirror the backend services:
```ts
import type { Labrinth, Archon, Kyros, ISO3166 } from '@modrinth/api-client'
const project: Labrinth.Projects.v3.Project = ...
const server: Archon.Servers.v0.Server = ...
const auth: Archon.Websocket.v0.WSAuth = ...
```
Each API has a `types.ts` in its module directory (`modules/labrinth/types.ts`, `modules/archon/types.ts`, etc.) using nested namespaces: `Namespace.Domain.Version.Type`.
## Features (Middleware)
Features wrap requests in a chain. Each feature can modify the request, retry, or short-circuit:
- **`AuthFeature`** — injects `Authorization: Bearer <token>`, supports async token providers
- **`RetryFeature`** — exponential/linear/constant backoff, retries on 408/429/5xx and network errors
- **`CircuitBreakerFeature`** — opens after N consecutive failures per endpoint, resets after timeout
## XHR Upload
File uploads use `XMLHttpRequest` for progress tracking (not available via `fetch`). The `upload()` method returns an `UploadHandle<T>`:
```ts
interface UploadHandle<T> {
promise: Promise<T>
onProgress(callback: (progress: UploadProgress) => void): UploadHandle<T> // chainable
cancel(): void
}
```
Supports two modes:
- **Single file** — `{ file: File | Blob }` sends with `Content-Type: application/octet-stream`
- **FormData** — `{ formData: FormData }` for multipart uploads (browser/platform sets boundary)
Uploads go through the feature chain (auth, retry, etc.). Features detect uploads via `context.metadata.isUpload`.
### Usage Example (server file upload)
```ts
const uploader = client.kyros.files_v0.uploadFile(path, file, {
onProgress: ({ progress }) => {
uploadProgress.value = Math.round(progress * 100)
},
})
// Cancel if needed: uploader.cancel()
await uploader.promise
```
### Usage Example (version creation with FormData)
```ts
const handle = client.labrinth.versions_v3.createVersion(draftVersion, files, projectType)
handle.onProgress((progress) => {
uploadProgress.value = progress
})
await handle.promise
```
See `packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue` and `apps/frontend/src/providers/version/manage-version-modal.ts` for real usage.
## WebSocket
WebSocket support is attached to `client.archon.sockets` (only on `GenericModrinthClient`). It provides event-based communication with Modrinth Hosting servers.
### Connection Flow
```
client.archon.sockets.safeConnect(serverId)
→ fetches JWT auth via archon.servers_v0.getWebSocketAuth()
→ opens wss:// connection
→ sends { event: 'auth', jwt: token }
→ server responds with { event: 'auth-ok' }
→ ready to receive events
```
Auto-reconnects on unexpected disconnection with exponential backoff (base 1s, max 30s, up to 10 attempts).
### Subscribing to Events
```ts
const unsub = client.archon.sockets.on(serverId, 'stats', (data) => {
// data is typed as Archon.Websocket.v0.WSStatsEvent
cpuUsage.value = data.cpu_percent
})
// Clean up
onUnmounted(() => {
unsub()
client.archon.sockets.disconnect(serverId)
})
```
Event types: `log`, `stats`, `power-state`, `uptime`, `backup-progress`, `installation-result`, `filesystem-ops`, `new-mod`, `auth-expiring`, `auth-incorrect`, `auth-ok`.
### Sending Commands
```ts
client.archon.sockets.send(serverId, { event: 'command', cmd: '/say hello' })
```
See `apps/frontend/src/pages/hosting/manage/[id].vue` for the full server panel WebSocket usage.
## Adding a New API Module
See the `api-module` skill (`.agents/skills/api-module/SKILL.md`) for step-by-step instructions.
-208
View File
@@ -1,208 +0,0 @@
# @modrinth/api-client
Platform-agnostic API client for Modrinth's services. Works in Nuxt (SSR + CSR), Tauri (desktop app), and plain Node/browser environments.
## Architecture
```
Request Flow:
Module Method → client.request() → Feature Chain (middleware) → Platform executeRequest()
```
### Key Directories
- **`src/core/`** — base classes (`AbstractModrinthClient`, `AbstractModule`, `AbstractFeature`, etc.)
- **`src/platform/`** — platform implementations (generic, nuxt, tauri, xhr-upload, websocket)
- **`src/features/`** — middleware plugins (auth, retry, circuit-breaker, etc.)
- **`src/modules/`** — API endpoint modules organized by service (`labrinth/`, `archon/`, `kyros/`, `iso3166/`)
- **`src/types/`** — core type definitions (client config, request options, upload types, errors)
### Client Hierarchy
All platform clients extend `XHRUploadClient``AbstractModrinthClient`:
- **`GenericModrinthClient`** — uses `ofetch`, attaches WebSocket client to `archon.sockets`
- **`NuxtModrinthClient`** — uses Nuxt's `$fetch`, SSR-aware, blocks `upload()` during SSR
- **`TauriModrinthClient`** — uses `@tauri-apps/plugin-http`
### Module Access
Modules are lazy-loaded and accessed as a nested structure:
```ts
client.labrinth.projects_v2
client.labrinth.projects_v3
client.labrinth.versions_v3
client.labrinth.collections
client.labrinth.billing_internal
client.archon.servers_v0
client.archon.servers_v1
client.archon.backups_queue_v1
client.archon.backups_v1
client.archon.content_v0
client.kyros.files_v0
client.iso3166.data
... etc.
```
This structure is derived at runtime from the flat `MODULE_REGISTRY` in `modules/index.ts` via `buildModuleStructure()`, and the TypeScript types are inferred automatically via `InferredClientModules`.
## Critical: Always use `this.client.request()`
API modules **must** use `this.client.request()` (or `.upload`) for all HTTP calls — never `$fetch`, `fetch`, or any other HTTP library directly. The request method routes through the platform-specific implementation (Nuxt `$fetch`, Tauri HTTP plugin, etc.) and the feature middleware chain (auth, retry, circuit breaker). Using `$fetch` directly bypasses the platform layer and will fail in Tauri (CORS/sandboxing). The only exception is the `ISO3166Module` which is explicitly node-only.
For external APIs (non-Modrinth), pass the full base URL as the `api` field and set `skipAuth: true`:
```ts
this.client.request<MyType>('/endpoint', {
api: 'https://external-api.com',
version: 1,
method: 'POST',
body: { data },
skipAuth: true,
})
```
## Usage
The client is provided to the component tree via DI (see the `dependency-injection` skill). Each app creates a platform-specific client and provides it at the root:
```ts
// apps/frontend/src/app.vue (Nuxt)
const client = new NuxtModrinthClient({ ... })
provideModrinthClient(client)
// apps/app-frontend/src/App.vue (Tauri)
const client = new TauriModrinthClient({ ... })
provideModrinthClient(client)
```
Components anywhere in the tree then inject it:
```ts
const { labrinth, archon, kyros } = injectModrinthClient()
// Fetch data
const project = await labrinth.projects_v3.get(projectId)
// Use with TanStack Query
const { data } = useQuery({
queryKey: ['project', projectId],
queryFn: () => labrinth.projects_v3.get(projectId),
})
```
`provideModrinthClient` and `injectModrinthClient` are exported from `@modrinth/ui` (defined in `packages/ui/src/providers/api-client.ts`). The provider is typed as `AbstractModrinthClient`, so shared components in `packages/ui` work with any platform client.
## Types
Types must match 1:1 with how they are returned from the backend API they are fetching from. Do not reshape, rename, or omit fields — the types should be a direct representation of the API response.
Types are organized in namespaces that mirror the backend services:
```ts
import type { Labrinth, Archon, Kyros, ISO3166 } from '@modrinth/api-client'
const project: Labrinth.Projects.v3.Project = ...
const server: Archon.Servers.v0.Server = ...
const auth: Archon.Websocket.v0.WSAuth = ...
```
Each API has a `types.ts` in its module directory (`modules/labrinth/types.ts`, `modules/archon/types.ts`, etc.) using nested namespaces: `Namespace.Domain.Version.Type`.
## Features (Middleware)
Features wrap requests in a chain. Each feature can modify the request, retry, or short-circuit:
- **`AuthFeature`** — injects `Authorization: Bearer <token>`, supports async token providers
- **`RetryFeature`** — exponential/linear/constant backoff, retries on 408/429/5xx and network errors
- **`CircuitBreakerFeature`** — opens after N consecutive failures per endpoint, resets after timeout
## XHR Upload
File uploads use `XMLHttpRequest` for progress tracking (not available via `fetch`). The `upload()` method returns an `UploadHandle<T>`:
```ts
interface UploadHandle<T> {
promise: Promise<T>
onProgress(callback: (progress: UploadProgress) => void): UploadHandle<T> // chainable
cancel(): void
}
```
Supports two modes:
- **Single file** — `{ file: File | Blob }` sends with `Content-Type: application/octet-stream`
- **FormData** — `{ formData: FormData }` for multipart uploads (browser/platform sets boundary)
Uploads go through the feature chain (auth, retry, etc.). Features detect uploads via `context.metadata.isUpload`.
### Usage Example (server file upload)
```ts
const uploader = client.kyros.files_v0.uploadFile(path, file, {
onProgress: ({ progress }) => {
uploadProgress.value = Math.round(progress * 100)
},
})
// Cancel if needed: uploader.cancel()
await uploader.promise
```
### Usage Example (version creation with FormData)
```ts
const handle = client.labrinth.versions_v3.createVersion(draftVersion, files, projectType)
handle.onProgress((progress) => {
uploadProgress.value = progress
})
await handle.promise
```
See `packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue` and `apps/frontend/src/providers/version/manage-version-modal.ts` for real usage.
## WebSocket
WebSocket support is attached to `client.archon.sockets` (only on `GenericModrinthClient`). It provides event-based communication with Modrinth Hosting servers.
### Connection Flow
```
client.archon.sockets.safeConnect(serverId)
→ fetches JWT auth via archon.servers_v0.getWebSocketAuth()
→ opens wss:// connection
→ sends { event: 'auth', jwt: token }
→ server responds with { event: 'auth-ok' }
→ ready to receive events
```
Auto-reconnects on unexpected disconnection with exponential backoff (base 1s, max 30s, up to 10 attempts).
### Subscribing to Events
```ts
const unsub = client.archon.sockets.on(serverId, 'stats', (data) => {
// data is typed as Archon.Websocket.v0.WSStatsEvent
cpuUsage.value = data.cpu_percent
})
// Clean up
onUnmounted(() => {
unsub()
client.archon.sockets.disconnect(serverId)
})
```
Event types: `log`, `stats`, `power-state`, `uptime`, `backup-progress`, `installation-result`, `filesystem-ops`, `new-mod`, `auth-expiring`, `auth-incorrect`, `auth-ok`.
### Sending Commands
```ts
client.archon.sockets.send(serverId, { event: 'command', cmd: '/say hello' })
```
See `apps/frontend/src/pages/hosting/manage/[id].vue` for the full server panel WebSocket usage.
## Adding a New API Module
See the `api-module` skill (`.claude/skills/api-module/SKILL.md`) for step-by-step instructions.
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+96
View File
@@ -0,0 +1,96 @@
# Architecture
The shared UI package used by both `apps/frontend` (Nuxt 3) and `apps/app-frontend` (Vue 3 + Tauri). Components here must be platform-agnostic — use dependency injection for platform-specific behavior.
## Folder Structure
```
src/
├── components/ # Vue components organized by feature domain
├── composables/ # Vue 3 composition API hooks
├── layouts/ # Self-contained page layouts (see below)
├── providers/ # Dependency injection contexts (createContext pattern)
├── utils/ # Utility functions and constants
├── pages/ # Cross-platform page components (used in both app-frontend and frontend)
├── locales/ # 34 language locale files (FormatJS)
├── styles/ # Tailwind CSS utilities
└── stories/ # Storybook story files
```
Each subdirectory under `components/` has an `index.ts` barrel file. All public API is re-exported from the root `index.ts`.
### `src/layouts/`
Self-contained page layouts shared across frontends. Split into two categories:
- **`shared/`** — Reusable layout modules with their own components, composables, providers, and types. Each module is a self-contained unit (e.g. `shared/content-tab/` contains the content/mods tab layout with its own `layout.vue`, `components/`, `composables/`, `providers/`, and `types.ts`).
- **`wrapped/`** — Page-level Vue components that mirror route structures (e.g. `wrapped/hosting/manage/`). These are full page implementations consumed by both `apps/frontend` and `apps/app-frontend`.
Files inside `layouts/` use the `#ui/*` import alias (resolved via the `"imports"` field in `package.json`) to reference other `src/` modules like `#ui/components/base/buttons` or `#ui/composables/i18n`.
# Code Guidelines
### Tailwind Configuration
All frontend packages share a Tailwind preset at `packages/tooling-config/tailwind/tailwind-preset.ts`. This package's `tailwind.config.ts` extends it:
```ts
import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
```
CSS custom properties are defined in `packages/assets/styles/variables.scss` with light, dark, and OLED theme variants.
### Color Usage Rules
**Use `surface-*` variables for backgrounds — never aliased `bg-*` color variables:**
| Token | Usage |
| ---------------- | ----------------------------------------- |
| `bg-surface-1` | Deepest background layer |
| `bg-surface-1.5` | Odd row background (tables) |
| `bg-surface-2` | Even row background, secondary panels |
| `bg-surface-3` | Headers, floating bar backgrounds, inputs |
| `bg-surface-4` | Cards, elevated surfaces |
| `bg-surface-5` | Borders, dividers |
**For text colors:**
| Class | Usage |
| ---------------- | -------------------------------- |
| `text-contrast` | Primary headings |
| `text-primary` | Default body text |
| `text-secondary` | Reduced emphasis, secondary info |
**Brand and semantic colors** not all exposed as Figma variables — refer to `packages/assets/styles/variables.scss` for the full set:
- `bg-{color}`, `text-{color}` etc. — Primary brand colors
- `bg-{color}-highlight` — 25% opacity semantic highlights
**Color palette** (each with shades 50950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.).
## Storybook
When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants - do not make or modify a storybook unless the user asks for it or skip if it's incredibly obvious one should not be needed (e.g minor changes or styling changes DO NOT need a storybook edit)
## Dependency Injection
This package defines the DI layer using `createContext` from `src/providers/index.ts`. See `standards/frontend/DEPENDENCY_INJECTION.md` for full documentation.
Key providers exported from this package:
- `provideModrinthClient` / `injectModrinthClient` — API client
- `provideNotificationManager` / `injectNotificationManager` — Notifications
## Vue Template Rules
### Multi-statement event handlers
Never use newline-separated statements in Vue template event handlers like `@click`. Vue's template compiler cannot parse multi-line expressions separated only by newlines. Always use semicolons on a single line:
```vue
<!-- BAD: will cause "Unexpected token" parse error -->
@click=" foo = true $emit('bar') "
<!-- GOOD -->
@click="foo = true; $emit('bar')"
```
-96
View File
@@ -1,96 +0,0 @@
# Architecture
The shared UI package used by both `apps/frontend` (Nuxt 3) and `apps/app-frontend` (Vue 3 + Tauri). Components here must be platform-agnostic — use dependency injection for platform-specific behavior.
## Folder Structure
```
src/
├── components/ # Vue components organized by feature domain
├── composables/ # Vue 3 composition API hooks
├── layouts/ # Self-contained page layouts (see below)
├── providers/ # Dependency injection contexts (createContext pattern)
├── utils/ # Utility functions and constants
├── pages/ # Cross platform page components (used in both app-frontend and frontend)
├── locales/ # 34 language locale files (FormatJS)
├── styles/ # Tailwind CSS utilities
└── stories/ # Storybook story files
```
Each subdirectory under `components/` has an `index.ts` barrel file. All public API is re-exported from the root `index.ts`.
### `src/layouts/`
Self-contained page layouts shared across frontends. Split into two categories:
- **`shared/`** — Reusable layout modules with their own components, composables, providers, and types. Each module is a self-contained unit (e.g. `shared/content-tab/` contains the content/mods tab layout with its own `layout.vue`, `components/`, `composables/`, `providers/`, and `types.ts`).
- **`wrapped/`** — Page-level Vue components that mirror route structures (e.g. `wrapped/hosting/manage/`). These are full page implementations consumed by both `apps/frontend` and `apps/app-frontend`.
Files inside `layouts/` use the `#ui/*` import alias (resolved via the `"imports"` field in `package.json`) to reference other `src/` modules like `#ui/components/base/buttons` or `#ui/composables/i18n`.
# Code Guidelines
### Tailwind Configuration
All frontend packages share a Tailwind preset at `packages/tooling-config/tailwind/tailwind-preset.ts`. This package's `tailwind.config.ts` extends it:
```ts
import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
```
CSS custom properties are defined in `packages/assets/styles/variables.scss` with light, dark, and OLED theme variants.
### Color Usage Rules
**Use `surface-*` variables for backgrounds — never aliased `bg-*` color variables:**
| Token | Usage |
| ---------------- | ----------------------------------------- |
| `bg-surface-1` | Deepest background layer |
| `bg-surface-1.5` | Odd row background (tables) |
| `bg-surface-2` | Even row background, secondary panels |
| `bg-surface-3` | Headers, floating bar backgrounds, inputs |
| `bg-surface-4` | Cards, elevated surfaces |
| `bg-surface-5` | Borders, dividers |
**For text colors:**
| Class | Usage |
| ---------------- | -------------------------------- |
| `text-contrast` | Primary headings |
| `text-primary` | Default body text |
| `text-secondary` | Reduced emphasis, secondary info |
**Brand and semantic colors** not all exposed as Figma variables — refer to `packages/assets/styles/variables.scss` for the full set:
- `bg-{color}`, `text-{color}` etc. — Primary brand colors
- `bg-{color}-highlight` — 25% opacity semantic highlights
**Color palette** (each with shades 50950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.).
## Storybook
When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants - do not make or modify a storybook unless the user asks for it or skip if it's incredibly obvious one should not be needed (e.g minor changes or styling changes DO NOT need a storybook edit)
## Dependency Injection
This package defines the DI layer using `createContext` from `src/providers/index.ts`. See the `dependency-injection` skill (`.claude/skills/dependency-injection/SKILL.md`) for full documentation.
Key providers exported from this package:
- `provideModrinthClient` / `injectModrinthClient` — API client
- `provideNotificationManager` / `injectNotificationManager` — Notifications
## Vue Template Rules
### Multi-statement event handlers
Never use newline-separated statements in Vue template event handlers like `@click`. Vue's template compiler cannot parse multi-line expressions separated only by newlines. Always use semicolons on a single line:
```vue
<!-- BAD: will cause "Unexpected token" parse error -->
@click=" foo = true $emit('bar') "
<!-- GOOD -->
@click="foo = true; $emit('bar')"
```
+4 -2
View File
@@ -1,5 +1,7 @@
# Standards
This folder contains technical standards and documentation we use to develop Modrinth. The majority of this has been migrated from our internal documentation.
This directory contains technical standards that apply to Modrinth development. Most of this content comes from internal Modrinth documentation.
For contributing guidelines, please refer to [docs.modrinth.com](https://docs.modrinth.com/contributing/getting-started/) - these documents are more for technical reference, not a guide on making pull requests, features, etc.
For contribution instructions, refer to [docs.modrinth.com](https://docs.modrinth.com/contributing/getting-started/). Use these documents only as technical references.
Do not use these documents as instructions for pull requests or feature development.
+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.
+93 -68
View File
@@ -1,111 +1,136 @@
# Changelog Style Guide
## The core rule
## Core Rule
**Each bullet describes one user-visible change, written from the user's perspective, in plain language, as a single sentence.**
Each bullet describes one user-visible change. Write one plain-language sentence from the perspective of the user.
If you can't explain the change without referencing internal code, components, or refactors, it probably doesn't belong in the changelog.
Do not add a change that you can explain only with internal code, component, or refactor details.
## Voice and tense
## Voice and Tense
- **Past tense, implied subject.** The section heading (`## Added`, `## Fixed`, `## Changed`) supplies the verb's mood - bullets read as a continuation of it.
- Good: `Fixed a missing gap between the project filter tabs and the project list.`
- Good: `Added support for Java 25.`
- Avoid: `We fixed...`, `This fixes...`, `Fixes...` (present tense), `Will fix...`
- **No first person.** Don't say "we" or "our" inside a bullet. The exception is featured release callouts that link to a blog post (`We've overhauled the Content tab...`).
- **No second person except for direct user actions.** "You" is fine when describing what the user can now do (`Joining a server from the app downloads the required content and launches you directly into the server.`), but don't address the user gratuitously.
- Use the past tense with an implicit subject. The section heading supplies the context for the bullet.
- Correct: `Fixed a missing gap between the project filter tabs and the project list.`
- Correct: `Added support for Java 25.`
- Incorrect: `We fixed...`, `This fixes...`, `Fixes...`, or `Will fix...`.
- Do not use the first person. A featured release that links to a blog post is an exception.
- Use the second person only for a direct user action.
## Section/verb agreement
Example of a direct action: `Joining a server downloads the required content and opens the server.`
The opening verb must match the section it lives under. Don't put "Fixed X" bullets inside `## Added`.
## Section and Verb Agreement
| Section | Typical opening verbs |
| ------------- | ------------------------------------------------------------------------------- |
| `## Added` | Added, Introduced, New |
| `## Changed` | Refreshed, Redesigned, Moved, Renamed, Updated, Consolidated, Improved, Rebuilt |
| `## Fixed` | Fixed |
| `## Security` | Fixed (security framing) |
Make the first verb agree with its section. Do not put a `Fixed` bullet in `## Added`.
In `## Added`, the leading "Added" is often dropped because it's redundant with the heading:
| Section | Typical first words |
| ------------- | -------------------------------------------------------------------------- |
| `## Added` | Added, Introduced, New |
| `## Changed` | Refreshed, Redesigned, Moved, Renamed, Updated, Consolidated, Improved |
| `## Fixed` | Fixed |
| `## Security` | Fixed, with a clear security context |
- `- Server stats inside server settings modal, in info card.`
- `- Confirmation modal for resubscribing to a server.`
You can omit `Added` in the `## Added` section because the heading supplies it:
In `## Fixed`, the leading "Fixed" is **kept** in most entries - it reads more clearly. Be consistent within a single entry.
- `Server statistics in an information card inside the server settings modal.`
- `Confirmation modal for server resubscription.`
## What to write about
Keep `Fixed` in most `## Fixed` bullets because it makes the text clear. Use one pattern in each entry.
Describe the **observable behavior**, not the implementation.
## Content
- Good: `Server CPU and memory graphs no longer freeze on the last value after a hard crash or out-of-memory kill.`
- Bad: `Refactored the metrics polling hook to clear stale state on socket disconnect.`
Describe the result that the user can see. Do not describe the implementation.
- Good: `Historical log files are now fetched in the background when opening the Logs page, so switching between them is instant.`
- Bad: `Moved log file fetching into a background worker.`
- Correct: `Server CPU and memory graphs no longer freeze after a hard crash or out-of-memory termination.`
- Incorrect: `Refactored the metrics polling hook to clear stale state after a socket disconnection.`
If a refactor has no user-visible effect, **don't list it**. Internal cleanup, dependency bumps, and code moves don't belong in the changelog unless they produce a noticeable difference (perf, reliability, consistency).
- Correct: `Historical log files now load in the background, so selection between files is immediate.`
- Incorrect: `Moved log file fetching into a background worker.`
## Specificity
Do not list a refactor that has no user-visible result.
Be specific enough that a user reading the changelog can recognize the thing you're talking about.
You can list an internal change when it gives a visible improvement in performance, reliability, or consistency.
## Specific Terms
Give sufficient detail for the user to identify the applicable item.
- Vague: `Fixed a bug on the project page.`
- Better: `Fixed project versions table overflowing outside of table. Version tags will now truncate.`
- Specific: `Fixed project version rows that extended past the table. Version tags now truncate.`
- Vague: `Improved the UI.`
- Better: `Refreshed the server cards UI for consistency.`
- Specific: `Refreshed the server cards for visual consistency.`
Name the page, tab, modal, or feature you're talking about. "The Content tab", "the server panel header", "the Worlds tab", "the project page" - these give the reader a concrete anchor.
Name the applicable page, tab, modal, or feature. Examples include the Content tab, server panel header, Worlds tab, and project page.
## Length
- **One sentence per bullet.** If you need two sentences, you probably have two bullets, or one bullet plus a sub-bullet.
- Aim for under ~25 words. Long bullets are usually a sign that the change is being over-explained or is actually multiple changes.
- Sub-bullets (indented with a tab) are allowed when one change has several facets - see the `## Added` section in the v0.12.0 app release for a good example.
- Write one sentence in each bullet.
- Use a second bullet when the change needs a second sentence.
- Use fewer than 25 words when possible.
- Use tab-indented sub-bullets when one change has multiple related parts.
Refer to the `## Added` section in the v0.12.0 app release for a sub-bullet example.
## Punctuation
- **End every bullet with a period.** This is inconsistent in the historical file, but periods are the more common pattern and the one to follow going forward.
- Use sentence case, not Title Case.
- Use straight quotes, not curly quotes (`"foo"` not `"foo"`).
- Use proper code formatting for filenames, flags, and literal strings: `` `.log` ``, `` `Restart` ``.
- End each bullet with a period.
- Use sentence case, not title case.
- Use straight quotation marks, not curly quotation marks: `"foo"`.
- Use code formatting for filenames, flags, and literal strings: `.log` and `Restart`.
## Naming things
Historical entries do not always use periods. Use periods in all new entries.
- Use the public, branded name: **Modrinth App**, **Modrinth Hosting**, **Modrinth** - not "the app", "servers", "Modrinth Servers" (deprecated). Capitalize product names.
- Refer to UI surfaces by the label the user sees: **Content tab**, **Worlds tab**, **Files tab**, **Logs page**, **server panel**, **project page**, **Discover page**.
- Capitalize tab and page names when referring to them by name (`the Content tab`), but not when used generically (`browse content`).
## Product and UI Names
## Don't
- Use the public names `Modrinth App`, `Modrinth Hosting`, and `Modrinth`.
- Do not use deprecated names, such as `Modrinth Servers`.
- Use the labels that appear in the UI.
- Capitalize a tab or page name when you refer to its label.
- Use lowercase when you refer to a generic action, such as `browse content`.
- **Don't blame.** Avoid "fixed a regression introduced in v0.12.0" - just describe the fix.
- **Don't reference PRs, issues, or commits.** The changelog is for users, not contributors - the exception is notable third-party contributions, where you should credit the contributor by linking their GitHub profile (e.g. `Added support for Java 25. Thanks to [@username](https://github.com/username)!`). Sharing credit for community contributions is encouraged.
- **Don't reference internal team members or processes.** No "as requested by support", no "per the design review".
- **Don't apologize or editorialize.** Skip "unfortunately", "finally", "long-awaited", "we know this has been a pain point". State the change.
- **Don't use vague intensifiers.** "Significantly improved", "much better", "vastly faster" - quantify if you can, otherwise drop the adverb.
- **Don't list every sub-fix of a bigger change separately.** If you redesigned the server panel header, write one bullet about the redesign rather than six bullets about each moved element.
- **Don't use "issue with" / "issue where" as filler.** `Fixed an issue where buttons were misaligned``Fixed misaligned buttons.`
Examples of UI labels include Content tab, Worlds tab, Files tab, Logs page, server panel, project page, and Discover page.
## Examples - rewriting weak bullets
## Prohibited Content
- Do not assign blame. Describe the correction without the release that caused the problem.
- Do not refer to pull requests, issues, or commits.
- Do not refer to internal team members or processes.
- Do not apologize or add an opinion about the change.
- Do not use vague intensifiers. Give a measurement when possible, or remove the adverb.
- Do not list each small correction from one larger change.
- Do not use `issue with` or `issue where` as filler.
You can credit a notable community contribution with a link to the contributor's GitHub profile.
Example: `Added support for Java 25. Thanks to [@username](https://github.com/username)!`
Replace `Fixed an issue with misaligned buttons` with `Fixed misaligned buttons.`
## Weak-Bullet Rewrites
| Weak | Better |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `Fixed a bug.` | `Fixed project icons becoming extremely bright on hover.` |
| `Various improvements to the server panel.` | Split into specific bullets, or drop entirely. |
| `Fixed a bug.` | `Fixed excessive brightness on project icons during hover.` |
| `Various improvements to the server panel.` | Divide it into specific bullets, or remove it. |
| `Refactored the logs page to use a new component.` | `Redesigned the Logs page to match the Modrinth Hosting server panel.` |
| `Fixed an issue where the server address wasn't copyable.` | `Server address in the panel header can now be clicked to copy it to your clipboard.` |
| `Made some changes to the content tab.` | Either drop, or list each user-visible change as its own bullet. |
| `Fixed UX issues.` | Name the specific UX issue. |
| `Fixed an issue where the server address was not copyable.` | `The server address in the panel header now copies to the clipboard when selected.` |
| `Made some changes to the Content tab.` | List each user-visible change, or remove the bullet. |
| `Fixed UX issues.` | Name the specific user-experience problem. |
## Featured release bullets
## Featured Release Bullets
When an entry has a linked blog post heading (e.g. `## [Introducing Server Projects](/news/article/...)`), the bullets underneath summarize the *highlights* in 14 lines, then link out. They don't need to be exhaustive - that's what the blog post is for.
A featured release has a linked blog-post heading, such as `## [Introducing Server Projects](/news/article/...)`.
## Quick checklist before committing a bullet
Use one to four lines below the heading to summarize the primary changes. Then, link to the blog post.
1. Would a non-developer user understand it?
2. Does it describe behavior, not implementation?
3. Is the verb in the right tense for its section?
4. Does it name the specific surface (tab/page/modal)?
5. Is it one sentence, ending in a period?
6. Is there a vague word ("issue", "bug", "various", "some") I can replace with something concrete?
The bullets do not need to contain all details. The blog post contains the complete information.
## Bullet Checklist
Before you commit a bullet, make sure that it meets these requirements:
1. A user who is not a developer can understand it.
2. It describes behavior, not implementation.
3. Its verb uses the correct tense for the section.
4. It identifies the applicable tab, page, modal, or feature.
5. It contains one sentence and ends with a period.
6. It replaces vague words with specific terms.