feat:移除了弹窗,服务器添加sls
This commit is contained in:
5
standards/README.md
Normal file
5
standards/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Standards
|
||||
|
||||
This folder contains technical standards and documentation we use to develop Axolotl. The majority of this has been migrated from Modrinth's internal documentation.
|
||||
|
||||
For contributing guidelines, please refer to [CONTRIBUTING.md](../CONTRIBUTING.md) — these documents are more for technical reference, not a guide on making pull requests, features, etc.
|
||||
167
standards/frontend/ADDING_API_MODULES.md
Normal file
167
standards/frontend/ADDING_API_MODULES.md
Normal file
@ -0,0 +1,167 @@
|
||||
- [Adding a New API Module](#adding-a-new-api-module)
|
||||
- [Steps](#steps)
|
||||
- [1. Define types in the module's `types.ts`](#1-define-types-in-the-modules-typests)
|
||||
- [2. Create the module class](#2-create-the-module-class)
|
||||
- [Request options](#request-options)
|
||||
- [For uploads](#for-uploads)
|
||||
- [3. Register in the MODULE\_REGISTRY](#3-register-in-the-module_registry)
|
||||
- [4. Export types](#4-export-types)
|
||||
- [Naming Conventions](#naming-conventions)
|
||||
- [Key Files](#key-files)
|
||||
|
||||
# Adding a New API Module
|
||||
|
||||
How to add a new API endpoint module to `packages/api-client`.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Define types in the module's `types.ts`
|
||||
|
||||
Types must match 1:1 with the backend API response. Do not reshape, rename, or omit fields.
|
||||
|
||||
Add to an existing namespace or create a new one:
|
||||
|
||||
```ts
|
||||
// modules/labrinth/types.ts (existing namespace)
|
||||
export namespace Labrinth {
|
||||
export namespace MyDomain {
|
||||
export namespace v3 {
|
||||
export type Thing = {
|
||||
id: string
|
||||
name: string
|
||||
created: string
|
||||
// ... matches API response exactly
|
||||
}
|
||||
|
||||
export type CreateThingRequest = {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a new API service, create `modules/<service>/types.ts` with a new top-level namespace and re-export it from `modules/types.ts`.
|
||||
|
||||
### 2. Create the module class
|
||||
|
||||
Create `modules/<api>/<domain>/v<N>.ts`:
|
||||
|
||||
```ts
|
||||
// modules/labrinth/things/v3.ts
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthThingsV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_things_v3'
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<Labrinth.MyDomain.v3.Thing> {
|
||||
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
public async create(data: Labrinth.MyDomain.v3.CreateThingRequest): Promise<Labrinth.MyDomain.v3.Thing> {
|
||||
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
return this.client.request(`/thing/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Request options
|
||||
|
||||
| Field | Values | Purpose |
|
||||
| ------------- | ------------------------------------------------- | ---------------------------------- |
|
||||
| `api` | `'labrinth'`, `'archon'`, or a full URL | Which base URL to use |
|
||||
| `version` | `2`, `3`, `'internal'`, `'modrinth/v0'`, etc. | URL version segment |
|
||||
| `method` | `'GET'`, `'POST'`, `'PUT'`, `'PATCH'`, `'DELETE'` | HTTP method |
|
||||
| `body` | object | JSON request body |
|
||||
| `params` | `Record<string, string>` | Query parameters |
|
||||
| `skipAuth` | `boolean` | Skip auth feature for this request |
|
||||
| `useNodeAuth` | `boolean` | Use node-level auth (kyros) |
|
||||
| `timeout` | `number` | Request timeout in ms |
|
||||
| `retry` | `boolean \| number` | Override retry behavior |
|
||||
|
||||
#### For uploads
|
||||
|
||||
Return an `UploadHandle` instead of a `Promise`:
|
||||
|
||||
```ts
|
||||
public uploadThing(id: string, file: File): UploadHandle<void> {
|
||||
return this.client.upload<void>(`/thing/${id}/file`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
// Or with FormData for multipart:
|
||||
public createWithFiles(data: CreateRequest, files: File[]): UploadHandle<Thing> {
|
||||
const formData = new FormData()
|
||||
formData.append('data', JSON.stringify(data))
|
||||
files.forEach((f, i) => formData.append(`file-${i}`, f, f.name))
|
||||
|
||||
return this.client.upload<Thing>(`/thing`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
formData,
|
||||
timeout: 60 * 5 * 1000, // longer timeout for uploads
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register in the MODULE_REGISTRY
|
||||
|
||||
Add to `modules/index.ts`:
|
||||
|
||||
```ts
|
||||
import { LabrinthThingsV3Module } from './labrinth/things/v3'
|
||||
|
||||
export const MODULE_REGISTRY = {
|
||||
// ... existing modules
|
||||
labrinth_things_v3: LabrinthThingsV3Module,
|
||||
} as const
|
||||
```
|
||||
|
||||
The naming convention is `<api>_<domain>_<version>`. This flat key gets transformed into nested access: `client.labrinth.things_v3`.
|
||||
|
||||
### 4. Export types
|
||||
|
||||
If you added to an existing namespace, types are already re-exported. If you created a new `types.ts`, add it to `modules/types.ts`:
|
||||
|
||||
```ts
|
||||
export * from './<service>/types'
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Convention | Example |
|
||||
| -------------- | ---------------------------------------------------- |
|
||||
| Module class | `LabrinthThingsV3Module` — `{Api}{Domain}V{N}Module` |
|
||||
| Module ID | `labrinth_things_v3` — `{api}_{domain}_v{n}` |
|
||||
| Type namespace | `Labrinth.MyDomain.v3.Thing` |
|
||||
| File path | `modules/labrinth/things/v3.ts` |
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/core/abstract-module.ts` — base class all modules extend
|
||||
- `src/core/abstract-client.ts` — `request()` and `upload()` methods
|
||||
- `src/modules/index.ts` — `MODULE_REGISTRY` and `buildModuleStructure()`
|
||||
- `src/modules/<api>/types.ts` — type definitions per API
|
||||
- `src/types/upload.ts` — `UploadHandle`, `UploadProgress`, `UploadRequestOptions`
|
||||
188
standards/frontend/DEPENDENCY_INJECTION.md
Normal file
188
standards/frontend/DEPENDENCY_INJECTION.md
Normal file
@ -0,0 +1,188 @@
|
||||
- [Dependency Injection](#dependency-injection)
|
||||
- [The `createContext` Factory](#the-createcontext-factory)
|
||||
- [When to Use DI](#when-to-use-di)
|
||||
- [Platform Abstraction (Primary Use Case)](#platform-abstraction-primary-use-case)
|
||||
- [Page-Level Context](#page-level-context)
|
||||
- [Creating a New Provider](#creating-a-new-provider)
|
||||
- [1. Define the interface in `packages/ui/src/providers/`](#1-define-the-interface-in-packagesuisrcproviders)
|
||||
- [2. For complex platform-specific logic, use an abstract class](#2-for-complex-platform-specific-logic-use-an-abstract-class)
|
||||
- [Wiring Up Providers](#wiring-up-providers)
|
||||
- [App Frontend (Tauri)](#app-frontend-tauri)
|
||||
- [Website Frontend (Nuxt)](#website-frontend-nuxt)
|
||||
- [Consuming Providers](#consuming-providers)
|
||||
- [When NOT to Use DI](#when-not-to-use-di)
|
||||
- [Existing Providers](#existing-providers)
|
||||
- [Key Files](#key-files)
|
||||
|
||||
# Dependency Injection
|
||||
|
||||
Modrinth uses a lightweight DI layer built on Vue's `provide`/`inject` for sharing platform-specific capabilities and page-level state across shared UI components.
|
||||
|
||||
## The `createContext` Factory
|
||||
|
||||
All providers are defined using `createContext` from `packages/ui/src/providers/index.ts` (adapted from Reka UI). It produces a typed `[inject, provide]` tuple:
|
||||
|
||||
```ts
|
||||
import { createContext } from '@modrinth/ui'
|
||||
|
||||
interface MyContext {
|
||||
someValue: Ref<string>
|
||||
doSomething: () => void
|
||||
}
|
||||
|
||||
export const [injectMyContext, provideMyContext] = createContext<MyContext>('MyComponent')
|
||||
```
|
||||
|
||||
- **`provideMyContext(value)`** — call in a parent component's `setup()`.
|
||||
- **`injectMyContext()`** — call in any descendant's `setup()`. Throws if never provided.
|
||||
- **`injectMyContext(null)`** — returns `null` instead of throwing (for optional contexts).
|
||||
|
||||
## When to Use DI
|
||||
|
||||
Use DI when:
|
||||
- **The same interface needs different implementations** depending on the platform (web vs desktop app).
|
||||
- **Deeply nested components** need access to shared page-level state without prop drilling through 3+ levels.
|
||||
|
||||
### Platform Abstraction (Primary Use Case)
|
||||
|
||||
`packages/ui` components need capabilities that each frontend fulfils differently:
|
||||
|
||||
| Provider | App Frontend | Website Frontend |
|
||||
| ------------- | -------------------------------- | ------------------------------ |
|
||||
| API client | Tauri IPC client | REST fetch client |
|
||||
| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration |
|
||||
| File picker | Native Tauri dialogs | Browser file inputs |
|
||||
| Tags | Tauri commands | Nuxt server state |
|
||||
| Page context | `sidebar: true`, ad window hooks | `sidebar: false`, no ads |
|
||||
|
||||
### Page-Level Context
|
||||
|
||||
Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components.
|
||||
|
||||
## Creating a New Provider
|
||||
|
||||
### 1. Define the interface in `packages/ui/src/providers/`
|
||||
|
||||
```ts
|
||||
// packages/ui/src/providers/my-feature.ts
|
||||
import type { Ref } from 'vue'
|
||||
import { createContext } from '.'
|
||||
|
||||
export interface MyFeatureContext {
|
||||
items: Ref<Item[]>
|
||||
addItem: (item: Item) => Promise<void>
|
||||
removeItem: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('MyFeature')
|
||||
```
|
||||
|
||||
Re-export from the barrel file (`packages/ui/src/providers/index.ts`).
|
||||
|
||||
### 2. For complex platform-specific logic, use an abstract class
|
||||
|
||||
```ts
|
||||
export abstract class AbstractMyFeatureManager {
|
||||
abstract items: Ref<Item[]>
|
||||
abstract addItem(item: Item): Promise<void>
|
||||
|
||||
// Shared logic lives on the base class
|
||||
handleError(err: unknown) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
export const [injectMyFeature, provideMyFeature] =
|
||||
createContext<AbstractMyFeatureManager>('MyFeature')
|
||||
```
|
||||
|
||||
See `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for a real example.
|
||||
|
||||
## Wiring Up Providers
|
||||
|
||||
### App Frontend (Tauri)
|
||||
|
||||
Create a setup function in `apps/app-frontend/src/providers/setup/`:
|
||||
|
||||
```ts
|
||||
// apps/app-frontend/src/providers/setup/my-feature.ts
|
||||
import { ref } from 'vue'
|
||||
import { provideMyFeature } from '@modrinth/ui'
|
||||
|
||||
export function setupMyFeatureProvider() {
|
||||
const items = ref<Item[]>([])
|
||||
|
||||
provideMyFeature({
|
||||
items,
|
||||
addItem: async (item) => {
|
||||
await invoke('add_item', { item })
|
||||
items.value.push(item)
|
||||
},
|
||||
removeItem: async (id) => {
|
||||
await invoke('remove_item', { id })
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Register it in `apps/app-frontend/src/providers/setup.ts`, which is called from `App.vue`'s `setup()`.
|
||||
|
||||
### Website Frontend (Nuxt)
|
||||
|
||||
Provide directly in `apps/website/src/app.vue` when the website needs a shared context:
|
||||
|
||||
```ts
|
||||
provideMyFeature({
|
||||
items: useState<Item[]>('my-feature-items', () => []),
|
||||
addItem: async (item) => {
|
||||
await $fetch('/api/items', { method: 'POST', body: item })
|
||||
},
|
||||
removeItem: async (id) => {
|
||||
await $fetch(`/api/items/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Consuming Providers
|
||||
|
||||
In any component across `packages/ui`, `apps/website`, or `apps/app-frontend`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { injectMyFeature } from '@modrinth/ui'
|
||||
|
||||
const { items, addItem } = injectMyFeature()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
|
||||
<button @click="addItem({ id: '1', name: 'New' })">Add</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When NOT to Use DI
|
||||
|
||||
Default to props and emits. DI adds indirection — only use it with a concrete reason.
|
||||
|
||||
- **Parent to direct child** — use props.
|
||||
- **Data only exists in one frontend** — keep context local to that app, not in `packages/ui`.
|
||||
- **Shallow prop drilling (1–2 levels)** — passing through one intermediate is fine.
|
||||
- **Component-local state** — use `ref()` / `reactive()` locally.
|
||||
|
||||
## Existing Providers
|
||||
|
||||
| Provider | File | Purpose |
|
||||
| ---------------------------- | -------------------------------- | ------------------------------ |
|
||||
| `provideModrinthClient` | `providers/api-client.ts` | API client instance |
|
||||
| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management |
|
||||
| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) |
|
||||
| `provideServerContext` | `providers/server-context.ts` | Server hosting state |
|
||||
|
||||
## Key Files
|
||||
|
||||
- `packages/ui/src/providers/index.ts` — `createContext` factory + barrel exports
|
||||
- `packages/ui/src/providers/*.ts` — Provider definitions
|
||||
- `apps/website/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
|
||||
32
standards/frontend/FIGMA_MCP_USAGE.md
Normal file
32
standards/frontend/FIGMA_MCP_USAGE.md
Normal file
@ -0,0 +1,32 @@
|
||||
- [Figma MCP Usage](#figma-mcp-usage)
|
||||
- [Available Tools](#available-tools)
|
||||
- [Adapting Figma Output](#adapting-figma-output)
|
||||
|
||||
# Figma MCP Usage
|
||||
|
||||
When the Figma MCP server is connected, it can be used to translate Figma designs into production-ready Vue components for this monorepo.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `get_design_context` | Primary tool. Returns reference code, a screenshot, and metadata for a given node. Always start here. |
|
||||
| `get_screenshot` | Returns a visual screenshot of a node without full code context. |
|
||||
| `get_variable_defs` | Returns the design tokens applied to a node. |
|
||||
| `get_metadata` | Returns an XML overview of node IDs, layer types, names, positions, and sizes for understanding structure. |
|
||||
|
||||
Node IDs come from Figma URLs. For `https://figma.com/design/:fileKey/:fileName?node-id=1-2`, the node ID is `1:2` (replace `-` with `:`).
|
||||
|
||||
```
|
||||
get_design_context(nodeId: "1:2", clientLanguages: "typescript,html,css", clientFrameworks: "vue")
|
||||
```
|
||||
|
||||
## Adapting Figma Output
|
||||
|
||||
The Figma MCP returns generic reference code. It must be adapted to match the Modrinth codebase:
|
||||
|
||||
1. **Read `packages/ui/CLAUDE.md`** for color usage rules, surface token mapping, and component patterns.
|
||||
2. **Map Figma color variables to `surface-*` tokens** — never use Figma's aliased names like `bg/default` or `bg/raised` directly. The CLAUDE.md has the full mapping table.
|
||||
3. **Check `packages/assets/styles/variables.scss`** for tokens not exposed in Figma (brand highlights, semantic backgrounds, shadows).
|
||||
4. **Check for existing components** in `packages/ui/src/components/` before building from scratch.
|
||||
5. **Match spacing exactly** — do not approximate values from the design.
|
||||
143
standards/frontend/INTERNATIONALIZATION.md
Normal file
143
standards/frontend/INTERNATIONALIZATION.md
Normal file
@ -0,0 +1,143 @@
|
||||
- [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)
|
||||
- [Imports](#imports)
|
||||
- [Reference Examples](#reference-examples)
|
||||
|
||||
# Internationalization (i18n)
|
||||
|
||||
All user-visible strings in Vue SFCs must use the localization system from `@modrinth/ui`. No hard-coded English strings should appear in templates or script — everything comes from `formatMessage` or `<IntlFormatted>`.
|
||||
|
||||
## Translatable Strings
|
||||
|
||||
User-visible strings include: inner text, `alt` attributes, `placeholder` attributes, button labels, dropdown option labels, notification messages, etc.
|
||||
|
||||
Dynamic expressions (`{{ user.name }}`) and HTML tags are not translatable strings — only static human-readable text.
|
||||
|
||||
## Message Definitions
|
||||
|
||||
Messages are defined with `defineMessage` or `defineMessages` from `@modrinth/ui` in `<script setup>`. Each message has a unique `id` and a `defaultMessage` containing the English string:
|
||||
|
||||
```ts
|
||||
const messages = defineMessages({
|
||||
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
|
||||
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: "You're now part of the community…" },
|
||||
})
|
||||
```
|
||||
|
||||
Message `id`s should be descriptive and stable (e.g. `error.generic.default.title`). Group related messages together with `defineMessages`.
|
||||
|
||||
## Rendering Messages
|
||||
|
||||
Use `useVIntl()` from `@modrinth/ui` for simple string formatting:
|
||||
|
||||
```ts
|
||||
const { formatMessage } = useVIntl()
|
||||
```
|
||||
|
||||
```vue
|
||||
<button>{{ formatMessage(messages.welcomeTitle) }}</button>
|
||||
{{ formatMessage(messages.greeting, { name: user.name }) }}
|
||||
```
|
||||
|
||||
## ICU Message Format
|
||||
|
||||
Dynamic values use ICU placeholders in `defaultMessage`:
|
||||
|
||||
- **Variables:** `'Hello, {name}!'`
|
||||
- **Numbers/dates/times:** `'{price, number, ::currency/USD}'`
|
||||
- **Plurals/selects:** `'{count, plural, one {# message} other {# messages}}'`
|
||||
|
||||
## Writing Translation-Friendly Strings
|
||||
|
||||
ICU gives you powerful tools (plurals, selects, nested expressions), but translators in other languages face constraints that English doesn't have:
|
||||
|
||||
- **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.
|
||||
|
||||
### 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:
|
||||
|
||||
```
|
||||
// Bad — translators can't inflect around a pre-rendered noun
|
||||
'Delete {count} {itemType}'
|
||||
|
||||
// Good — translators can write entirely different phrases per 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Rich-Text Messages
|
||||
|
||||
When a message contains links or markup, wrap the relevant ranges with named tags in `defaultMessage`:
|
||||
|
||||
```
|
||||
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
|
||||
```
|
||||
|
||||
Render with the `<IntlFormatted>` component using named slots:
|
||||
|
||||
```vue
|
||||
<IntlFormatted :message-id="messages.tosLabel">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/terms">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-link="{ children }">
|
||||
<NuxtLink to="/privacy">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
```
|
||||
|
||||
For simple emphasis (`'Welcome to <strong>Axolotl</strong>!'`):
|
||||
|
||||
```vue
|
||||
<template #strong="{ children }">
|
||||
<strong><component :is="() => children" /></strong>
|
||||
</template>
|
||||
```
|
||||
|
||||
For complex child handling, use `normalizeChildren` from `@modrinth/ui`:
|
||||
|
||||
```vue
|
||||
<template #bold="{ children }">
|
||||
<strong><component :is="() => normalizeChildren(children)" /></strong>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Vue/ICU Delimiter Collisions
|
||||
|
||||
If an ICU placeholder ends right before `}}` in a Vue template, insert a space (`} }`) to avoid parsing issues.
|
||||
|
||||
## Imports
|
||||
|
||||
All i18n utilities come from `@modrinth/ui`:
|
||||
|
||||
- `defineMessage` / `defineMessages` — message definitions
|
||||
- `useVIntl` — composable providing `formatMessage`
|
||||
- `IntlFormatted` — component for rich-text messages
|
||||
- `normalizeChildren` — helper for complex rich-text slot children
|
||||
|
||||
## Reference Examples
|
||||
|
||||
- Variables and plurals: `apps/app-frontend/src/components/home/HomeDailyChallenge.vue`
|
||||
- Rich-text with link tags: `apps/website/src/pages/index.vue`
|
||||
370
standards/frontend/MODALS.md
Normal file
370
standards/frontend/MODALS.md
Normal file
@ -0,0 +1,370 @@
|
||||
- [Regular Modals](#regular-modals)
|
||||
- [Basic Usage](#basic-usage)
|
||||
- [Props](#props)
|
||||
- [Slots](#slots)
|
||||
- [Default slot](#default-slot)
|
||||
- [`title` slot](#title-slot)
|
||||
- [`actions` slot](#actions-slot)
|
||||
- [Scrollable Content](#scrollable-content)
|
||||
- [Merged Header Mode](#merged-header-mode)
|
||||
- [Modal Stacking](#modal-stacking)
|
||||
- [Exposed Methods](#exposed-methods)
|
||||
- [Multistage Modals](#multistage-modals)
|
||||
- [Architecture](#architecture)
|
||||
- [Building a Multistage Modal](#building-a-multistage-modal)
|
||||
- [1. Define the context](#1-define-the-context)
|
||||
- [2. Define stage configs](#2-define-stage-configs)
|
||||
- [3. Create stage components](#3-create-stage-components)
|
||||
- [4. Create the wrapper component](#4-create-the-wrapper-component)
|
||||
- [Modal API](#modal-api)
|
||||
- [Non-Progress Stages (Edit Sub-Flows)](#non-progress-stages-edit-sub-flows)
|
||||
- [Reference Implementation](#reference-implementation)
|
||||
|
||||
# Regular Modals
|
||||
|
||||
Use the `NewModal` component (`packages/ui/src/components/modal/NewModal.vue`) for all standard modals.
|
||||
|
||||
- Set the 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.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from ‘vue’
|
||||
import { NewModal } from ‘@modrinth/ui’
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="modal?.show($event)">Open</button>
|
||||
|
||||
<NewModal ref="modal" header="My Modal">
|
||||
<p>Modal content here.</p>
|
||||
</NewModal>
|
||||
</template>
|
||||
```
|
||||
|
||||
Call `show(event?)` to open the modal. Passing the `MouseEvent` triggers an animation originating from the click position. Call `hide()` to close it programmatically.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --------------------- | ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `header` | `string` | — | Title text displayed in the header bar |
|
||||
| `hideHeader` | `boolean` | `false` | Hides the entire header (title + close button) |
|
||||
| `mergeHeader` | `boolean` | `false` | Removes the header bar; renders a floating close button over the content |
|
||||
| `closable` | `boolean` | `true` | Shows the close button and enables ESC / click-outside dismissal |
|
||||
| `disableClose` | `boolean` | `false` | Disables all close actions (close button, ESC, click-outside). The close button appears disabled |
|
||||
| `closeOnEsc` | `boolean` | `true` | Allow closing with the Escape key |
|
||||
| `closeOnClickOutside` | `boolean` | `true` | Allow closing by clicking the overlay |
|
||||
| `scrollable` | `boolean` | `false` | Enables scroll tracking with top/bottom fade indicators |
|
||||
| `maxContentHeight` | `string` | `’70vh’` | Max height of the scrollable content area (only applies when `scrollable`) |
|
||||
| `noPadding` | `boolean` | `false` | Removes padding from the content area for edge-to-edge layouts |
|
||||
| `maxWidth` | `string` | `’60rem’` | Maximum width of the modal |
|
||||
| `width` | `string` | `fit-content` | Width of the modal body |
|
||||
| `noblur` | `boolean` | — | Disables backdrop blur. Defaults to the value from `injectModalBehavior` |
|
||||
| `fade` | `’standard’ \| ‘warning’ \| ‘danger’` | `’standard’` | Overlay color variant |
|
||||
| `danger` | `boolean` | `false` | **Deprecated** — use `fade="danger"` instead |
|
||||
| `onShow` | `() => void` | — | Called when the modal opens |
|
||||
| `onHide` | `() => void` | — | Called when the modal closes |
|
||||
|
||||
## Slots
|
||||
|
||||
### Default slot
|
||||
|
||||
The main content area. Rendered inside a padded, optionally scrollable container.
|
||||
|
||||
```vue
|
||||
<NewModal ref="modal" header="Confirm">
|
||||
<p>Are you sure you want to proceed?</p>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
### `title` slot
|
||||
|
||||
Replaces the default header text. Use this when you need custom markup in the header (e.g. an icon next to the title or a badge).
|
||||
|
||||
```vue
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<AlertIcon />
|
||||
<span class="text-2xl font-semibold text-contrast">Custom Title</span>
|
||||
</template>
|
||||
<p>Content here.</p>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
### `actions` slot
|
||||
|
||||
Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Use this for confirm/cancel buttons.
|
||||
|
||||
```vue
|
||||
<NewModal ref="modal" header="Delete Item" fade="danger">
|
||||
<p>This action cannot be undone.</p>
|
||||
<template #actions>
|
||||
<ButtonStyled color="danger">
|
||||
<button @click="handleDelete">Delete</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
## Scrollable Content
|
||||
|
||||
Set `scrollable` to enable scroll tracking. The modal renders animated fade gradients at the top and bottom edges when content is scrolled, giving users a visual cue that more content exists.
|
||||
|
||||
```vue
|
||||
<NewModal ref="modal" header="Long Content" scrollable max-content-height="60vh">
|
||||
<!-- Long content that may overflow -->
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
The `checkScrollState` method is exposed via ref — call it after dynamically changing content to re-evaluate whether fade indicators should appear.
|
||||
|
||||
When `scrollable` is `false` (the default), content uses `overflow-y: auto` without fade indicators.
|
||||
|
||||
## Merged Header Mode
|
||||
|
||||
When `mergeHeader` is set, the header bar is hidden and a floating close button is rendered in the top-right corner of the modal. Content receives extra top padding to avoid overlapping the button. This is useful for modals with hero images or full-bleed content at the top.
|
||||
|
||||
```vue
|
||||
<NewModal ref="modal" merge-header no-padding>
|
||||
<img src="..." class="w-full" />
|
||||
<div class="p-6">
|
||||
<p>Content below the image.</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
## Modal Stacking
|
||||
|
||||
`NewModal` integrates with a modal stack (`useModalStack`). Multiple modals can be open simultaneously — only the topmost modal responds to the Escape key. The document body scroll is locked when any modal is open and restored when the last modal closes.
|
||||
|
||||
## Exposed Methods
|
||||
|
||||
| Method | Description |
|
||||
| -------------------- | ------------------------------------------------------- |
|
||||
| `show(event?)` | Opens the modal. Pass `MouseEvent` for origin animation |
|
||||
| `hide()` | Closes the modal |
|
||||
| `checkScrollState()` | Re-evaluates scroll fade indicators (when `scrollable`) |
|
||||
|
||||
# Multistage Modals
|
||||
|
||||
The `MultiStageModal` component (`packages/ui/src/components/base/MultiStageModal.vue`) provides a wizard-like modal with progress tracking, conditional stages, and per-stage button configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
A multistage modal has three parts:
|
||||
|
||||
1. **Context** — A DI provider that holds all state, business logic, and stage configs
|
||||
2. **Stage configs** — Data objects describing each stage (title, component, buttons, skip conditions)
|
||||
3. **Stage components** — Vue components rendered inside the modal, consuming the context
|
||||
|
||||
## Building a Multistage Modal
|
||||
|
||||
### 1. Define the context
|
||||
|
||||
Create a DI provider with all the state your wizard needs. Include the modal ref and stage configs.
|
||||
|
||||
```ts
|
||||
// providers/my-feature/my-modal.ts
|
||||
import type { ShallowRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import type { MultiStageModal, StageConfigInput } from '@modrinth/ui'
|
||||
import { createContext } from '@modrinth/ui'
|
||||
|
||||
export interface MyModalContext {
|
||||
// State
|
||||
formData: Ref<MyFormData>
|
||||
isSubmitting: Ref<boolean>
|
||||
|
||||
// Modal control
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
|
||||
stageConfigs: StageConfigInput<MyModalContext>[]
|
||||
|
||||
// Business logic
|
||||
handleSubmit: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectMyModalContext, provideMyModalContext] =
|
||||
createContext<MyModalContext>('MyModal')
|
||||
|
||||
export function createMyModalContext(
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
|
||||
): MyModalContext {
|
||||
const formData = ref<MyFormData>({ ... })
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await saveData(formData.value)
|
||||
modal.value?.hide()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { formData, isSubmitting, modal, stageConfigs, handleSubmit }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Define stage configs
|
||||
|
||||
Each stage is a `StageConfigInput<T>` where `T` is your context type. Most fields accept either a static value or a function receiving the context (`MaybeCtxFn<T, R>`).
|
||||
|
||||
```ts
|
||||
// providers/my-feature/stages/details-stage.ts
|
||||
import { markRaw } from 'vue'
|
||||
import type { StageConfigInput } from '@modrinth/ui'
|
||||
import type { MyModalContext } from '../my-modal'
|
||||
import DetailsStage from './DetailsStage.vue'
|
||||
import { RightArrowIcon, SaveIcon } from '@modrinth/assets'
|
||||
|
||||
export const detailsStageConfig: StageConfigInput<MyModalContext> = {
|
||||
id: 'details',
|
||||
stageContent: markRaw(DetailsStage),
|
||||
title: 'Details',
|
||||
|
||||
// Conditional behavior based on context
|
||||
skip: (ctx) => ctx.shouldSkipDetails.value,
|
||||
cannotNavigateForward: (ctx) => !ctx.formData.value.name,
|
||||
disableClose: (ctx) => ctx.isSubmitting.value,
|
||||
|
||||
leftButtonConfig: (ctx) => ({
|
||||
label: 'Cancel',
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}),
|
||||
|
||||
rightButtonConfig: (ctx) => ({
|
||||
label: 'Next',
|
||||
icon: RightArrowIcon,
|
||||
iconPosition: 'after',
|
||||
disabled: !ctx.formData.value.name,
|
||||
onClick: () => ctx.modal.value?.nextStage(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**Stage config fields:**
|
||||
|
||||
| Field | Type | Purpose |
|
||||
| ----------------------- | ------------------------------------------ | ------------------------------------------------ |
|
||||
| `id` | `string` | Unique stage identifier (used with `setStage()`) |
|
||||
| `stageContent` | `Component` | Vue component to render (wrap with `markRaw()`) |
|
||||
| `title` | `MaybeCtxFn<T, string>` | Stage title in breadcrumbs |
|
||||
| `skip` | `MaybeCtxFn<T, boolean>` | Skip this stage conditionally |
|
||||
| `nonProgressStage` | `MaybeCtxFn<T, boolean>` | Exclude from progress bar (for edit sub-flows) |
|
||||
| `hideStageInBreadcrumb` | `MaybeCtxFn<T, boolean>` | Hide from breadcrumb nav |
|
||||
| `cannotNavigateForward` | `MaybeCtxFn<T, boolean>` | Block forward navigation (validation) |
|
||||
| `disableClose` | `MaybeCtxFn<T, boolean>` | Disable closing the modal |
|
||||
| `leftButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Left action button |
|
||||
| `rightButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Right action button |
|
||||
| `maxWidth` | `MaybeCtxFn<T, string>` | Per-stage max width (default `560px`) |
|
||||
|
||||
**Button config fields:**
|
||||
|
||||
| Field | Purpose |
|
||||
| -------------- | ----------------------- |
|
||||
| `label` | Button text |
|
||||
| `icon` | Icon component |
|
||||
| `iconPosition` | `'before'` or `'after'` |
|
||||
| `color` | ButtonStyled color prop |
|
||||
| `disabled` | Disable the button |
|
||||
| `onClick` | Click handler |
|
||||
|
||||
### 3. Create stage components
|
||||
|
||||
Stage components inject the context and render their UI:
|
||||
|
||||
```vue
|
||||
<!-- providers/my-feature/stages/DetailsStage.vue -->
|
||||
<script setup lang="ts">
|
||||
import { injectMyModalContext } from '../my-modal'
|
||||
|
||||
const { formData } = injectMyModalContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<StyledInput v-model="formData.name" label="Name" />
|
||||
<StyledInput v-model="formData.description" label="Description" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 4. Create the wrapper component
|
||||
|
||||
The wrapper provides context and renders `MultiStageModal`:
|
||||
|
||||
```vue
|
||||
<!-- components/MyModalWrapper.vue -->
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue'
|
||||
import { MultiStageModal } from '@modrinth/ui'
|
||||
import { createMyModalContext, provideMyModalContext } from '../providers/my-feature/my-modal'
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MultiStageModal> | null>(null)
|
||||
const ctx = createMyModalContext(modal)
|
||||
provideMyModalContext(ctx)
|
||||
|
||||
defineExpose({ show: () => modal.value?.show() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Modal API
|
||||
|
||||
`MultiStageModal` exposes via ref:
|
||||
|
||||
| Method/Property | Description |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| `show()` | Open the modal |
|
||||
| `hide()` | Close the modal |
|
||||
| `setStage(indexOrId)` | Jump to stage by index or string id |
|
||||
| `nextStage()` | Advance to next non-skipped stage |
|
||||
| `prevStage()` | Go back to previous stage |
|
||||
| `currentStageIndex` | Ref to current stage index |
|
||||
|
||||
## Non-Progress Stages (Edit Sub-Flows)
|
||||
|
||||
For stages that shouldn't appear in the progress bar (e.g. editing a specific field from a summary page):
|
||||
|
||||
```ts
|
||||
export const editLoadersStageConfig: StageConfigInput<MyContext> = {
|
||||
id: 'edit-loaders',
|
||||
nonProgressStage: true,
|
||||
stageContent: markRaw(EditLoadersStage),
|
||||
title: 'Edit loaders',
|
||||
leftButtonConfig: (ctx) => ({
|
||||
label: 'Back',
|
||||
onClick: () => ctx.modal.value?.setStage('summary'),
|
||||
}),
|
||||
rightButtonConfig: (ctx) => ({
|
||||
...ctx.saveButtonConfig(),
|
||||
label: 'Save',
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
Navigate to it with `modal.value?.setStage('edit-loaders')` — it won't affect the progress indicator.
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
The desktop onboarding flow is the current reference implementation:
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------------------- | --------------------------------- |
|
||||
| `apps/app-frontend/src/components/ui/onboarding/onboardingConfig.ts` | Flow configuration + business logic |
|
||||
| `apps/app-frontend/src/components/ui/onboarding/OnboardingOverlay.vue` | Presentation layer |
|
||||
| `apps/app-frontend/src/components/ui/onboarding/useOnboardingTour.ts` | Runtime control |
|
||||
|
||||
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.
|
||||
25
standards/frontend/SURFACE_SYSTEM.md
Normal file
25
standards/frontend/SURFACE_SYSTEM.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
|
||||
## 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. |
|
||||
|
||||
## Strokes
|
||||
|
||||
Use `surface-4` for normal outlines and dividers. Tables should use `surface-4` for their outer border 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
111
standards/maintaining/CHANGELOG.md
Normal file
111
standards/maintaining/CHANGELOG.md
Normal file
@ -0,0 +1,111 @@
|
||||
# Changelog Style Guide
|
||||
|
||||
## The core rule
|
||||
|
||||
**Each bullet describes one user-visible change, written from the user's perspective, in plain language, as a single sentence.**
|
||||
|
||||
If you can't explain the change without referencing internal code, components, or refactors, it probably doesn't belong in the changelog.
|
||||
|
||||
## 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.
|
||||
|
||||
## Section/verb agreement
|
||||
|
||||
The opening verb must match the section it lives under. Don't put "Fixed X" bullets inside `## Added`.
|
||||
|
||||
| Section | Typical opening verbs |
|
||||
| ------------- | ------------------------------------------------------------------------------- |
|
||||
| `## Added` | Added, Introduced, New |
|
||||
| `## Changed` | Refreshed, Redesigned, Moved, Renamed, Updated, Consolidated, Improved, Rebuilt |
|
||||
| `## Fixed` | Fixed |
|
||||
| `## Security` | Fixed (security framing) |
|
||||
|
||||
In `## Added`, the leading "Added" is often dropped because it's redundant with the heading:
|
||||
|
||||
- `- Server stats inside server settings modal, in info card.`
|
||||
- `- Confirmation modal for resubscribing to a server.`
|
||||
|
||||
In `## Fixed`, the leading "Fixed" is **kept** in most entries - it reads more clearly. Be consistent within a single entry.
|
||||
|
||||
## What to write about
|
||||
|
||||
Describe the **observable behavior**, not the implementation.
|
||||
|
||||
- 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.`
|
||||
|
||||
- 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.`
|
||||
|
||||
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).
|
||||
|
||||
## Specificity
|
||||
|
||||
Be specific enough that a user reading the changelog can recognize the thing you're talking about.
|
||||
|
||||
- Vague: `Fixed a bug on the project page.`
|
||||
- Better: `Fixed project versions table overflowing outside of table. Version tags will now truncate.`
|
||||
|
||||
- Vague: `Improved the UI.`
|
||||
- Better: `Refreshed the server cards UI for 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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` ``.
|
||||
|
||||
## Naming things
|
||||
|
||||
- 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`).
|
||||
|
||||
## Don't
|
||||
|
||||
- **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 - rewriting weak bullets
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
|
||||
## 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.
|
||||
|
||||
## Quick checklist before committing a bullet
|
||||
|
||||
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?
|
||||
Reference in New Issue
Block a user