Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 22 additions & 28 deletions standards/frontend/COMPONENT_STRUCTURE.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# Component Structure

For `app-frontend`, choose the feature or shell folder using [Application Structure](app-frontend/APP_FRONTEND_STRUCTURE.md) before organising the component's own files. Keep its supporting code nearby, with larger feature behavior outside the component folder. [App Composables](app-frontend/APP_FRONTEND_COMPOSABLES.md) explains when it helps to separate reactive behavior.

These app-specific placement rules do not change the website or shared UI folder structure.

## Component Folders

Give each complex component its own folder:
Give a component its own folder when it has local helpers, composables, types, or subcomponents. Do not create a folder containing only `index.vue`; keep that component as a named `.vue` file in its parent folder.

```
components/
└── analytics-chart/
├── index.vue
├── analytics-chart-header.vue
├── analytics-chart-plot.vue
├── analytics-chart-data.ts
├── header.vue
├── plot.vue
├── data.ts
└── use-analytics-chart.ts
```

Expand All @@ -37,9 +41,9 @@ Keep files for only one component in that component's folder:
```
analytics-chart/
├── index.vue
├── analytics-chart-header.vue
├── analytics-chart-plot.vue
├── analytics-chart-tooltip.vue
├── header.vue
├── plot.vue
├── tooltip.vue
├── chart-ranges.ts
└── use-chart-hover-state.ts
```
Expand All @@ -55,25 +59,16 @@ This structure prevents large script blocks that are difficult to review.

## Local Subcomponent Names

Use clear names that show the relation between each subcomponent and its main component:

```
analytics-chart/
├── index.vue
├── analytics-chart-header.vue
└── analytics-chart-plot.vue
```

Do not use names that make a local component look like a public component:
Use short names that describe each local component's role. The parent folder already supplies the component name, so `header.vue` is enough inside `analytics-chart/`:

```
analytics-chart/
├── index.vue
├── events.vue
└── header.vue
├── header.vue
└── plot.vue
```

Add the `analytics-chart-` prefix to local filenames. This prefix shows the relation in search results, editor tabs, and imports.
Use a more specific name when the role is unclear, such as `date-filter.vue` or `status-filter.vue`. There is no need to repeat the parent name in every filename.

## Nesting

Expand All @@ -84,19 +79,18 @@ Use this structure:
```
analytics-chart/
├── index.vue
├── analytics-chart-header.vue
├── analytics-chart-plot.vue
├── header.vue
├── plot.vue
├── use-chart-hover-state.ts
└── use-chart-selection.ts
```

Do not use this structure unless a local area needs its own module boundary:
Use a subfolder only when a local area needs its own module boundary and has supporting files:

```
analytics-chart/
├── index.vue
├── header/
│ └── index.vue
├── header.vue
└── plot/
├── index.vue
└── use-plot-state.ts
Expand All @@ -117,15 +111,15 @@ components/
└── project-status-pill.vue
```

Move a component into a folder when it gets local helpers, composables, or subcomponents.
Move a component into a folder when it gets local helpers, composables, types, or subcomponents. If only `index.vue` remains in a folder, move it back to a named file in the parent folder and update its imports. For example, use `app-shell/route-outlet.vue`, not `app-shell/route-outlet/index.vue`. This rule also applies to large components that have no supporting files.

## Public and Local Components

Use only the main `index.vue` as the public entry point. Treat the other folder files as implementation details.
For a component with its own folder, use the main `index.vue` as its public entry point. Treat the supporting files as implementation details. A standalone component uses its named `.vue` file as its entry point; it does not need a folder to be public.

If another component imports a local subcomponent, use one of these solutions:

- Move the subcomponent into its own component folder.
- Promote the subcomponent to a standalone named `.vue` file, or give it its own component folder if it has supporting files.
- Move the subcomponent to the nearest shared component area when it is reusable.
- Keep it local and pass behavior through the main component.

Expand Down
20 changes: 8 additions & 12 deletions standards/frontend/CROSS_PLATFORM_PAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,31 +82,27 @@ provideContentManager({
</template>
```

The app uses Tauri `invoke`:
For new app code, keep the desktop behavior in a feature and use typed commands from `platform/app-lib/`. This follows [Application Structure](app-frontend/APP_FRONTEND_STRUCTURE.md) without changing the shared layout's folders.

The page then connects that behavior to the layout. This example proposes `useInstanceContentManager`, a composable that returns the existing `ContentManagerContext`:

```vue
<!-- apps/app-frontend/src/pages/instance/Mods.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'

const items = ref<ContentItem[]>([])
await invoke('get_instance_content', { instanceId }).then(/* Map the result to ContentItem[]. */)
import { useInstanceContentManager } from '@/features/instances/content/use-instance-content-manager'

provideContentManager({
items,
deleteItem: async (item) => {
await invoke('delete_content', { instanceId, path: item.file_path })
},
// Implement the remaining contract fields.
})
const contentManager = useInstanceContentManager()
provideContentManager(contentManager)
</script>

<template>
<ContentPageLayout />
</template>
```

The composable manages queries and desktop actions, while the shared layout uses the provided value. The [app composable guidance](app-frontend/APP_FRONTEND_COMPOSABLES.md) explains how to keep that behavior focused.

### Optional Capabilities

Use optional contract fields for capabilities that are not available on all platforms.
Expand Down
35 changes: 6 additions & 29 deletions standards/frontend/DEPENDENCY_INJECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Components in `packages/ui` can need capabilities that each frontend implements

| Provider | App frontend | Website frontend |
| ------------- | ---------------------------------- | ------------------------------- |
| API client | Tauri IPC client | REST fetch client |
| API client | Tauri HTTP 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 |
Expand All @@ -68,7 +68,7 @@ Use DI to share page data with deep descendants. Examples include the project si

### 1. Define the Interface

Define the interface in `packages/ui/src/providers/`:
For shared UI contracts, define the interface in `packages/ui/src/providers/`. App-only contexts follow the app guidance below:

```ts
// packages/ui/src/providers/my-feature.ts
Expand Down Expand Up @@ -111,31 +111,7 @@ Refer to `AbstractWebNotificationManager` in `packages/ui/src/providers/web-noti

### App Frontend

Make 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 the function in `apps/app-frontend/src/providers/setup.ts`. `App.vue` calls this setup file from its `setup()` function.
Follow [App Dependency Injection](app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md) for desktop provider setup and context placement. [App Structure](app-frontend/APP_FRONTEND_STRUCTURE.md) explains where that code belongs.

### Website Frontend

Expand Down Expand Up @@ -195,5 +171,6 @@ Use props and emits by default. DI adds an indirect layer, so use it only for a
- `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.
- `apps/app-frontend/src/App.vue`: Contains the desktop application entry component.
- `apps/app-frontend/src/components/app-shell/index.vue`: Contains root-provider setup before migration to `app/`.
- `apps/app-frontend/src/providers/setup/`: Contains existing provider setup functions before migration.
7 changes: 6 additions & 1 deletion standards/frontend/FETCHING_DATA.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
- [TanStack Query](#tanstack-query)
- [Setup](#setup)
- [App frontend](#app-frontend)
- [Queries](#queries)
- [Query-option factories](#query-option-factories)
- [Conditional queries](#conditional-queries)
Expand All @@ -24,6 +25,10 @@ The default stale time is 5 seconds. Get the `QueryClient` with `useQueryClient(

`useAppQueryClient()` also operates in middleware.

## App Frontend

Follow [App Fetching Data](app-frontend/APP_FRONTEND_FETCHING_DATA.md) for desktop query placement, app-lib commands, and event-driven refreshes.

## Queries

Use `useQuery` with `api-client` to get data:
Expand All @@ -48,7 +53,7 @@ Use the query state in templates:

### Query-Option Factories

For a query that multiple components use, define a query-option factory in `packages/ui/src/queries/`:
For a query used by shared UI components, define a shared query-option factory. App-only queries follow the placement described in [App Fetching Data](app-frontend/APP_FRONTEND_FETCHING_DATA.md):

```ts
// composables/queries/project.ts
Expand Down
64 changes: 64 additions & 0 deletions standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Composables

This standard applies to composables in `apps/app-frontend`. It follows the folder structure in [Application Structure](APP_FRONTEND_STRUCTURE.md).

A composable is a function that manages related Vue state or behavior. It might handle selection in the library, track installation progress, or listen for app events. Its name should describe that job, such as `useLibrarySelection` in `use-library-selection.ts`.

## When a composable helps

Extract a composable when several components need the same behavior, or when a component contains a separate job that hides its main purpose. Reuse is not a requirement. A composable used by one component can still make that component easier to read.

Keep small, clear state in the component. A single open flag rarely needs another file, while selection with several related actions may benefit from one. Dividing a long script into one equally long composable does not make its responsibilities clearer.

Functions that only parse, format, or transform values remain ordinary functions. The same applies to app-lib wrappers and functions that return query options. Reserve the `use` prefix for functions that need Vue state or setup. Use `create` for factories that can run without component context.

Keep layout in the component: wrappers, sizing, overflow, and overlay placement. A composable can track scroll position without deciding where the sidebar or its promotion belongs.

## Keep it near the code that uses it

A composable used by one component belongs in that component's folder. If several parts of a feature use it, keep it in the feature. Only move it to `shared/composables/` when it has no feature-specific behavior.

For example, a composable for selecting instances belongs to the library or instances feature. It does not become generic because more than one component uses it. Composables that coordinate startup or the whole app belong in `app/runtime/`.

## Be clear about state and inputs

Each call normally creates its own local state. Two components calling the same composable do not automatically share that state. When they need the same value, create it once in a common parent and pass it through props or DI.

Keep backend data in TanStack Query and derive display values from its results. Copying those results into another writable store creates two places that need to stay consistent. An editable form draft is different, because the user needs to change it before saving.

If an input can change while the composable is active, accept a ref or getter instead of capturing its initial value. Read it inside `computed`, `watch`, or reactive query options. This example uses the existing instance query factory from the proposed feature folder:

```ts
import { useQuery } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'

import { instanceDetailQueryOptions } from './queries'

export function useInstanceQuery(instanceId: MaybeRefOrGetter<string>) {
return useQuery(computed(() => instanceDetailQueryOptions(toValue(instanceId))))
}
```

Here, changing the instance ID changes the query. The query factory itself remains an ordinary function that can also serve prefetch code.

Return the refs and actions that callers need, with internal details kept inside the composable. A plain object of refs allows callers to destructure the result without losing reactivity. Avoid destructuring primitive fields from a reactive object.

Vue's [composable conventions and best practices](https://vuejs.org/guide/reusability/composables.html#conventions-and-best-practices) cover naming, reactive inputs, returned refs, and when composables can be called.

## Make setup and cleanup part of the job

Call a composable that uses injection or lifecycle hooks during setup, before asynchronous work starts. It can inject an existing context there. Ordinary functions and query-option factories should receive their dependencies as arguments instead.

Create DOM-dependent composables during setup with a nullable element ref, then assign the element after mount. For OverlayScrollbars, connect `useScrollIndicator` to `elements().viewport` and recheck when the scrollbar instance updates. Keep the scroll container constrained by its flex layout; fades can sit outside the viewport as absolute overlays with pointer events disabled.

If a control overlays scrolling content, reserve space equal to its rendered height. Measure controls whose text can wrap. When a fade also provides the background behind that control, keep it visible while the control is shown, including at the end of the scroll area.

Create listeners when the composable runs, rather than when its module loads. Remove external listeners and timers with `onScopeDispose`, and cancel pending work when the API allows it. Vue stops setup-owned watchers automatically, but external subscriptions need their own cleanup.

The [`onScopeDispose` reference](https://vuejs.org/api/reactivity-advanced.html#onscopedispose) explains how cleanup runs when the current effect scope ends, including the scope owned by a component's setup.

Give shared subscriptions a clear lifetime. A listener used throughout the app belongs in root setup, while a page-specific listener belongs with that page. Avoid module-level refs and singleton fallbacks that hide where shared state starts or ends.

If a function also supports callers outside Vue setup, make its cleanup function explicit. Callers need to know which resources they must release.

The [app DI guidance](APP_FRONTEND_DEPENDENCY_INJECTION.md) explains when components should share a value. The [app data guidance](APP_FRONTEND_FETCHING_DATA.md) explains how composables work with backend queries.
Loading
Loading