mirror of
https://github.com/modrinth/code.git
synced 2026-08-24 16:44:51 +00:00
chore: cleanup standards (#6970)
* chore: cleanup standards & skills * remove: figma mcp doc, not needed anymore
This commit is contained in:
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -65,8 +65,7 @@ generated
|
||||
app-playground-data/*
|
||||
|
||||
.astro
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.claude/
|
||||
.letta
|
||||
|
||||
# labrinth demo fixtures
|
||||
|
||||
@@ -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.
|
||||
@@ -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 +0,0 @@
|
||||
CLAUDE.md
|
||||
@@ -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.
|
||||
@@ -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 +0,0 @@
|
||||
Read @AGENTS.md
|
||||
@@ -1 +0,0 @@
|
||||
CLAUDE.md
|
||||
@@ -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.
|
||||
@@ -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 +0,0 @@
|
||||
CLAUDE.md
|
||||
@@ -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 50–950): 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')"
|
||||
```
|
||||
@@ -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 50–950): 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
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 layout’s `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 layout’s 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 layout’s `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.
|
||||
|
||||
@@ -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 (1–2 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 modal’s 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–4 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.
|
||||
|
||||
Reference in New Issue
Block a user