From 7455714b701535f11151257e2060d66ee241330b Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 7 Oct 2026 15:51:33 +0100 Subject: [PATCH 1/2] chore: standards for new app-frontend structure --- standards/frontend/COMPONENT_STRUCTURE.md | 50 ++++----- standards/frontend/CROSS_PLATFORM_PAGES.md | 20 ++-- standards/frontend/DEPENDENCY_INJECTION.md | 35 ++---- standards/frontend/FETCHING_DATA.md | 7 +- .../app-frontend/APP_FRONTEND_COMPOSABLES.md | 58 ++++++++++ .../APP_FRONTEND_DEPENDENCY_INJECTION.md | 50 +++++++++ .../APP_FRONTEND_FETCHING_DATA.md | 19 ++++ .../app-frontend/APP_FRONTEND_STRUCTURE.md | 100 ++++++++++++++++++ 8 files changed, 269 insertions(+), 70 deletions(-) create mode 100644 standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md create mode 100644 standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md create mode 100644 standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md create mode 100644 standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md diff --git a/standards/frontend/COMPONENT_STRUCTURE.md b/standards/frontend/COMPONENT_STRUCTURE.md index f48ae26e96f..577dfce5c67 100644 --- a/standards/frontend/COMPONENT_STRUCTURE.md +++ b/standards/frontend/COMPONENT_STRUCTURE.md @@ -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 ``` @@ -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 ``` @@ -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 @@ -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 @@ -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. diff --git a/standards/frontend/CROSS_PLATFORM_PAGES.md b/standards/frontend/CROSS_PLATFORM_PAGES.md index f07f6c5a30a..1f59a43b505 100644 --- a/standards/frontend/CROSS_PLATFORM_PAGES.md +++ b/standards/frontend/CROSS_PLATFORM_PAGES.md @@ -82,24 +82,18 @@ provideContentManager({ ``` -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 - ``` +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. diff --git a/standards/frontend/DEPENDENCY_INJECTION.md b/standards/frontend/DEPENDENCY_INJECTION.md index 7f3a82db01e..207256b40e4 100644 --- a/standards/frontend/DEPENDENCY_INJECTION.md +++ b/standards/frontend/DEPENDENCY_INJECTION.md @@ -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 | @@ -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 @@ -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([]) - - 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 @@ -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. diff --git a/standards/frontend/FETCHING_DATA.md b/standards/frontend/FETCHING_DATA.md index 47bc7e271d6..f89fb3dd78e 100644 --- a/standards/frontend/FETCHING_DATA.md +++ b/standards/frontend/FETCHING_DATA.md @@ -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) @@ -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: @@ -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 diff --git a/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md b/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md new file mode 100644 index 00000000000..eb6b228eb78 --- /dev/null +++ b/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md @@ -0,0 +1,58 @@ +# 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 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) { + 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 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. diff --git a/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md b/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md new file mode 100644 index 00000000000..600d0b6fd30 --- /dev/null +++ b/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md @@ -0,0 +1,50 @@ +# Dependency Injection + +This standard applies only to `apps/app-frontend`. It follows the target structure in [Application Structure](APP_FRONTEND_STRUCTURE.md), while the website keeps its existing setup. + +The [shared DI guide](../DEPENDENCY_INJECTION.md) describes `createContext` and the existing shared contracts. + +In the app, use DI when several descendants need the same value or a shared UI component needs desktop behavior. Props and emits are easier to follow for direct children and short component chains. Reusing a composable does not require DI unless its callers need to share state. + +Vue's [provide/inject guide](https://vuejs.org/guide/components/provide-inject.html) explains ancestor providers, optional defaults, reactive values, and symbol keys. + +## Where the value comes from + +Create app-wide values, such as the API client and file picker, once during root setup. Create page state in the page or route layout, and modal state in the modal that needs it. That way, each value lasts as long as the part of the app that uses it. + +Keep registration visible in root setup rather than hiding it inside another feature. For example, starting installation should not also register the app's file picker. The root can use named setup functions when it has several providers to connect. + +Creating a value and providing it are separate steps. This example uses the existing client factory from its proposed platform location: + +```ts +import { provideModrinthClient } from '@modrinth/ui' + +import { createAppClient } from '@/platform/modrinth-client' + +const client = createAppClient() +provideModrinthClient(client) +``` + +Descendant components can now inject that client. Other setup code in the same component uses the local `client` variable or receives it as an argument. A component cannot inject a value that it provides itself, because injection reads its ancestors. + +The [Vue dependency injection API reference](https://vuejs.org/api/composition-api-dependency-injection.html) describes ancestor lookup and the requirement to call `provide` and `inject` synchronously during setup. + +Register the providers during setup before asynchronous loading starts. Listeners and resources created with them should end with their owning component. [Composables](APP_FRONTEND_COMPOSABLES.md) explains how to handle that cleanup. + +## Keep the context close to its purpose + +An app-only context belongs with its feature, page, or component. A contract already used by shared UI stays in `packages/ui`, with its desktop implementation in the app. The application folder structure does not change shared UI's own folders. + +Use the existing `createContext` pattern for new component contexts. Keep the type and injection functions separate from lengthy feature behavior. Choose a unique context name, and preserve its key when moving an existing definition. The factory uses `Symbol.for`, so matching names identify the same context. + +Share the state and actions that children need, such as `openSettings()` or `requestSignIn()`. Keep component refs inside the component that manages them. Children should not need to know which modal implements an action. + +A small interface and object are usually enough for an app-only context. Keep existing shared manager classes when their contracts require them. Adding more fields to a context does not, by itself, require another class. + +## Handle missing providers + +A required injection should fail when its provider is missing. Fix where the value is provided, or pass it directly to the function that needs it. Do not catch that error and return a module-level singleton, because that hides a broken component relationship. + +Use `injectSomething(null)` when the component supports running without that context. Check for `null` before using it. An empty fallback function can hide a required action that never runs. + +DI shares an existing value rather than creating another copy of backend data. Components reading the same resource should use the same query definitions and cache. Keep contexts focused on one purpose instead of combining every app service into one large context. diff --git a/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md b/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md new file mode 100644 index 00000000000..436704cbcbf --- /dev/null +++ b/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md @@ -0,0 +1,19 @@ +# Fetching Data + +This standard applies only to `apps/app-frontend` and follows [Application Structure](APP_FRONTEND_STRUCTURE.md). + +Use TanStack Query for backend data that the app displays, including data returned by app-lib commands. Keep the raw command in `platform/app-lib/` and its query definitions in the feature that owns the resource. Instance queries belong to instances, even when the library also uses them. + +When two views need the same data, use the same query keys and options. Read the query result directly or derive display values with `computed`. A separate writable copy would need its own updates whenever the query changes. Form drafts can keep editable copies until the user saves them. + +TanStack's [query key guide](https://tanstack.com/query/latest/docs/framework/vue/guides/query-keys) explains how keys identify cached data. Its [Vue reactivity guide](https://tanstack.com/query/latest/docs/framework/vue/reactivity) explains how refs and getters keep queries responsive to changing inputs. + +Query-option factories should receive their dependencies as arguments, so components and prefetch code can both use them. They do not need injection or the `use` prefix. [Composables](APP_FRONTEND_COMPOSABLES.md) covers the reactive behavior around those queries. + +The [query options guide](https://tanstack.com/query/latest/docs/framework/vue/guides/query-options) shows how to reuse query definitions across components and prefetch calls. + +Keep refresh rules with the feature that owns the data. App-wide event subscriptions start once at the root and refresh the affected queries. The frontend cache works alongside app-lib's existing caches, without replacing their backend responsibilities. + +Native actions such as opening a file dialog or closing a window can use platform functions directly. They do not need query caching because they do not read a backend resource. + +The [shared TanStack Query guide](../FETCHING_DATA.md) contains examples of queries, mutations, and optimistic updates. diff --git a/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md b/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md new file mode 100644 index 00000000000..b198252209e --- /dev/null +++ b/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md @@ -0,0 +1,100 @@ +# Structure + +This standard applies only to `apps/app-frontend`. It describes how to organise the desktop frontend as we add features and refactor existing code. The website and `packages/ui` keep their existing structures. + +The aim is to keep related code together and make each folder's purpose clear. Someone changing friends should find the friend list, its state, and its actions in the same place. They should not need to search several general folders to understand how that feature works. + +Use this structure for new work and refactors in the area you are changing. Existing files can move gradually as those areas change. + +The `app/`, `platform/`, `features/`, and `shared/` folders are our project convention. The Vue and Tauri references below explain the behavior of their APIs; they do not prescribe this folder structure. + +```text +src/ +├── main.js +├── app/ +│ ├── App.vue +│ ├── router.ts +│ ├── providers.ts +│ ├── runtime/ +│ └── shell/ +├── platform/ +│ ├── app-lib/ +│ │ ├── instances/ +│ │ ├── friends/ +│ │ └── settings/ +│ ├── events/ +│ └── adapters/ +├── features/ +│ ├── instances/ +│ ├── installation/ +│ ├── library/ +│ ├── friends/ +│ └── settings/ +├── shared/ +│ ├── components/ +│ ├── composables/ +│ └── utils/ +├── generated/ +├── assets/ +└── locales/ +``` + +This tree shows the intended structure, rather than a completed migration. The feature names are examples, and a small feature may need only a few files. Create a directory when it helps organise code, rather than creating empty folders in advance. + +## Keep related code in a feature + +A feature contains the code for one part of the app, such as instances, skins, friends, or screenshots. Its pages, components, composables, query definitions, and actions belong together. For example, `features/instances/queries.ts` is where other parts of the app can find instance queries. + +Within a feature, keep a component's supporting files beside that component. A composable used only by the friend list stays with the friend list. Code used throughout the friends feature can live directly under `features/friends/`. [Component Structure](../COMPONENT_STRUCTURE.md) explains when a component needs its own folder. + +Vue's [guide to extracting composables](https://vuejs.org/guide/reusability/composables.html#extracting-composables-for-code-organization) explains how separating related behavior can make a component easier to read, even when that behavior is not reused. + +Desktop route views belong in the feature's `pages/` folder, with their routes registered in `app/router.ts`. A page can also render an existing layout from `@modrinth/ui`. Using that layout does not mean moving its shared implementation into the app. + +## Let app connect the pieces + +The `app/` folder contains startup, route registration, and the code that connects features to shared services. Its `shell/` folder contains the visible frame of the app: navigation, title bars, sidebars, and the area that displays routes. Plugins and directives that the app registers also belong here. + +Keep the behavior behind those controls with the feature it serves. An update button belongs in the shell, while the update workflow belongs in `features/updates/`. The shell can call that workflow without containing all its rules. + +Use `app/runtime/` for work that coordinates the whole app, such as startup order or commands that open different features. Root setup creates app-wide services and registers their providers before loading data. The installation feature should not quietly register an unrelated file picker or user-country provider. + +Root setup can contain several calls, as long as someone reading it can see how the app starts and which values it shares. [Dependency Injection](APP_FRONTEND_DEPENDENCY_INJECTION.md) explains how those values reach child components. + +## Put native access in platform + +The `platform/` folder is where the frontend talks to app-lib and the computer. Typed app-lib calls belong in `platform/app-lib/`, grouped by the area they serve. Native file dialogs, window operations, updates, and the desktop API client also belong under `platform/`. + +An app-lib wrapper should call a command and return its typed result. The feature decides how to use that result, whether to show a notification, or which modal to open. For example, the settings command can move to `platform/app-lib/settings/commands.ts`: + +```ts +import { invoke } from '@tauri-apps/api/core' + +import type { AppSettings } from './types' + +export function get(): Promise { + return invoke('plugin:settings|settings_get') +} +``` + +Keep those command modules independent of Vue setup and TanStack Query. Their types should describe what app-lib actually returns. The settings feature then adds query options, form state, and any changes needed to display the result. + +Tauri's [guide to calling Rust from the frontend](https://v2.tauri.app/develop/calling-rust/) explains how commands receive arguments and return results through `invoke`. + +Use `platform/events/` for receiving and decoding app events. Use `platform/adapters/` for native implementations of existing shared UI contracts, such as the file picker. Feature code uses these functions instead of importing Tauri directly. + +## Keep shared small + +The app's `shared/` folder is for code that several features use and no particular feature owns. A generic input component or formatting function can belong here. Code that still describes instances belongs to instances, even when the library also uses it. + +This folder should remain independent of features and native operations. Components already shared through `packages/ui` stay in that package. The app supplies any desktop behavior through their existing contracts. + +## Keep dependencies easy to follow + +The app connects features, and features use platform functions and shared code. Platform code must not depend on feature implementations. Shared code must not depend on app setup, features, or native implementations. + +A feature can use another feature's named queries or actions when it needs them. It should not reach into that feature's private components or route views. If two features depend on each other, move their shared work to one place or coordinate them from `app/runtime/`. + +As existing helpers move, separate their different jobs. Friend commands go to `platform/app-lib/friends/`, while friend transformations and UI behavior stay in `features/friends/`. New code should follow those names instead of expanding general `helpers`, `providers`, or `composables` folders at the root. + +[Composables](APP_FRONTEND_COMPOSABLES.md) explains how to separate reactive behavior within a feature. The [app data guidance](APP_FRONTEND_FETCHING_DATA.md) explains where queries belong and how to keep their state consistent. From a112525b1ab100a7c3fde1b66901e7983dc22d6d Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 7 Oct 2026 17:06:14 +0100 Subject: [PATCH 2/2] chore: update folder structure --- .../app-frontend/APP_FRONTEND_COMPOSABLES.md | 6 ++ .../APP_FRONTEND_DEPENDENCY_INJECTION.md | 4 +- .../APP_FRONTEND_FETCHING_DATA.md | 2 + .../app-frontend/APP_FRONTEND_STRUCTURE.md | 66 +++++++++++-------- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md b/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md index eb6b228eb78..a5efb94fbe0 100644 --- a/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md +++ b/standards/frontend/app-frontend/APP_FRONTEND_COMPOSABLES.md @@ -12,6 +12,8 @@ Keep small, clear state in the component. A single open flag rarely needs anothe 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. @@ -47,6 +49,10 @@ Vue's [composable conventions and best practices](https://vuejs.org/guide/reusab 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. diff --git a/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md b/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md index 600d0b6fd30..5182f60f61d 100644 --- a/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md +++ b/standards/frontend/app-frontend/APP_FRONTEND_DEPENDENCY_INJECTION.md @@ -14,7 +14,7 @@ Create app-wide values, such as the API client and file picker, once during root Keep registration visible in root setup rather than hiding it inside another feature. For example, starting installation should not also register the app's file picker. The root can use named setup functions when it has several providers to connect. -Creating a value and providing it are separate steps. This example uses the existing client factory from its proposed platform location: +Creating a value and providing it are separate steps. The client factory belongs in `platform/modrinth-client.ts`; root setup provides its result: ```ts import { provideModrinthClient } from '@modrinth/ui' @@ -39,6 +39,8 @@ Use the existing `createContext` pattern for new component contexts. Keep the ty Share the state and actions that children need, such as `openSettings()` or `requestSignIn()`. Keep component refs inside the component that manages them. Children should not need to know which modal implements an action. +Keep account actions in a typed feature context when callers outside the sidebar need them. The shell can forward calls to its account component through that interface. If the provided value depends on a component ref, derive it reactively so consumers see the component when it mounts or is replaced. Treat the value as unavailable while an async component is still loading. + A small interface and object are usually enough for an app-only context. Keep existing shared manager classes when their contracts require them. Adding more fields to a context does not, by itself, require another class. ## Handle missing providers diff --git a/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md b/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md index 436704cbcbf..a9461f87db1 100644 --- a/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md +++ b/standards/frontend/app-frontend/APP_FRONTEND_FETCHING_DATA.md @@ -4,6 +4,8 @@ This standard applies only to `apps/app-frontend` and follows [Application Struc Use TanStack Query for backend data that the app displays, including data returned by app-lib commands. Keep the raw command in `platform/app-lib/` and its query definitions in the feature that owns the resource. Instance queries belong to instances, even when the library also uses them. +The same placement applies to HTTP resources such as the news feed. Keep its query options in `features/news/queries.ts` and read them in the news component. The sidebar renders that component; root startup does not need to fetch its articles into a separate ref. Keep the query key, cache settings, and refresh rules with the query when moving it. + When two views need the same data, use the same query keys and options. Read the query result directly or derive display values with `computed`. A separate writable copy would need its own updates whenever the query changes. Form drafts can keep editable copies until the user saves them. TanStack's [query key guide](https://tanstack.com/query/latest/docs/framework/vue/guides/query-keys) explains how keys identify cached data. Its [Vue reactivity guide](https://tanstack.com/query/latest/docs/framework/vue/reactivity) explains how refs and getters keep queries responsive to changing inputs. diff --git a/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md b/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md index b198252209e..3934f73157e 100644 --- a/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md +++ b/standards/frontend/app-frontend/APP_FRONTEND_STRUCTURE.md @@ -2,7 +2,7 @@ This standard applies only to `apps/app-frontend`. It describes how to organise the desktop frontend as we add features and refactor existing code. The website and `packages/ui` keep their existing structures. -The aim is to keep related code together and make each folder's purpose clear. Someone changing friends should find the friend list, its state, and its actions in the same place. They should not need to search several general folders to understand how that feature works. +Keep the friend list, its state, and its actions together. Someone changing friends should be able to find that code under one feature. Use this structure for new work and refactors in the area you are changing. Existing files can move gradually as those areas change. @@ -12,31 +12,37 @@ The `app/`, `platform/`, `features/`, and `shared/` folders are our project conv src/ ├── main.js ├── app/ -│ ├── App.vue -│ ├── router.ts -│ ├── providers.ts -│ ├── runtime/ -│ └── shell/ +│ ├── App.vue +│ ├── router.ts +│ ├── providers.ts +│ ├── runtime/ # startup flow, auth handling, etc. anything core to the app frontend working +│ └── shell/ # sidebar, title bar, navigation etc. ├── platform/ -│ ├── app-lib/ -│ │ ├── instances/ -│ │ ├── friends/ -│ │ └── settings/ -│ ├── events/ -│ └── adapters/ -├── features/ -│ ├── instances/ -│ ├── installation/ -│ ├── library/ -│ ├── friends/ -│ └── settings/ +│ ├── modrinth-client.ts # packages/api-client impl +│ ├── app-lib/ # invoke stuff +│ │ ├── instances/ +│ │ ├── friends/ +│ │ └── settings/ +│ ├── events/ # app events/listeners +│ └── adapters/ # any cross platform pages get implemented here +├── features/ # pages, flows, modals, etc. +│ ├── instances/ +│ ├── installation/ +│ ├── library/ +│ ├── friends/ +│ ├── settings/ +│ └── skins/ +│ ├── pages/ +│ │ └── skins-page.vue +│ ├── skin-preview.vue +│ └── queries.ts ├── shared/ -│ ├── components/ -│ ├── composables/ -│ └── utils/ -├── generated/ -├── assets/ -└── locales/ +│ ├── components/ +│ ├── composables/ +│ └── utils/ +├── generated/... +├── assets/... +└── locales/... ``` This tree shows the intended structure, rather than a completed migration. The feature names are examples, and a small feature may need only a few files. Create a directory when it helps organise code, rather than creating empty folders in advance. @@ -49,7 +55,9 @@ Within a feature, keep a component's supporting files beside that component. A c Vue's [guide to extracting composables](https://vuejs.org/guide/reusability/composables.html#extracting-composables-for-code-organization) explains how separating related behavior can make a component easier to read, even when that behavior is not reused. -Desktop route views belong in the feature's `pages/` folder, with their routes registered in `app/router.ts`. A page can also render an existing layout from `@modrinth/ui`. Using that layout does not mean moving its shared implementation into the app. +Desktop route views belong in the feature's `pages/` folder, with their routes registered in `app/router.ts`. For example, the skins route renders `features/skins/pages/skins-page.vue`, with skin previews and queries beside the page folder. A feature used only in the sidebar does not need a page. + +A page can also render an existing layout from `@modrinth/ui`. Keep the route component in its feature and the shared layout in `packages/ui`. The adapters entry in the tree refers to the desktop implementations of the contracts those pages use. ## Let app connect the pieces @@ -57,14 +65,20 @@ The `app/` folder contains startup, route registration, and the code that connec Keep the behavior behind those controls with the feature it serves. An update button belongs in the shell, while the update workflow belongs in `features/updates/`. The shell can call that workflow without containing all its rules. -Use `app/runtime/` for work that coordinates the whole app, such as startup order or commands that open different features. Root setup creates app-wide services and registers their providers before loading data. The installation feature should not quietly register an unrelated file picker or user-country provider. +The sidebar arranges feature components such as accounts, friends, onboarding, and news. Those components stay in their features; sidebar layout and promotion placement belong in the shell. + +Use `app/runtime/` for work that coordinates the whole app, such as startup order, session restoration, route loading, or commands that open different features. Root setup creates app-wide services and registers their providers before loading data. The installation feature should not quietly register an unrelated file picker or user-country provider. Root setup can contain several calls, as long as someone reading it can see how the app starts and which values it shares. [Dependency Injection](APP_FRONTEND_DEPENDENCY_INJECTION.md) explains how those values reach child components. +When moving shell code, preserve its props, events, exposed actions, provider scope, and loading behavior. Check remaining script references before removing imports, including icons used in computed menu options. A signed-in menu can fail to render even after authentication succeeds. + ## Put native access in platform The `platform/` folder is where the frontend talks to app-lib and the computer. Typed app-lib calls belong in `platform/app-lib/`, grouped by the area they serve. Native file dialogs, window operations, updates, and the desktop API client also belong under `platform/`. +Use `platform/modrinth-client.ts` to configure the client from `packages/api-client`, including URLs, auth, and logging. Root setup creates and provides that client. + An app-lib wrapper should call a command and return its typed result. The feature decides how to use that result, whether to show a notification, or which modal to open. For example, the settings command can move to `platform/app-lib/settings/commands.ts`: ```ts