@wssto2/vue-core
@wssto2/vue-core
The shared Vue 3 frontend library for go-core applications: the app shell (sidebar, top bar, phone drawer, account menu), list, record and form pages, and the controls they are made of. You write features (a typed list, a record, its routes and texts); the library writes the parts every screen repeats: loading, empty and error states, back-to-list, previous / next, section navigation, focus, keyboard, dark mode, reduced motion.
It is the extraction of the ARV frontend (design: "Emerald Native"). It has no business knowledge: permissions, menu destinations, icons and the user are yours, declared to the type system once.
Its plan and rules are in PLAN.md.
What you get
| Import | For |
|---|---|
@wssto2/vue-core/platform |
readBootstrap, createPlatform (HTTP client, session, access), AccessGate, typed contexts |
@wssto2/vue-core/app |
createApplication, defineFeature, currentApplication, currentHttp and application.report (outside setup, handled errors), shell outlets, effects |
@wssto2/vue-core/shell |
backofficeShell, the shell's parts for a custom one |
@wssto2/vue-core/router |
defineRoutes (typed targets), guards, navigation |
@wssto2/vue-core/collection |
defineCollection, useCollection, CollectionPage, DataTable (a table over an array), record neighbors |
@wssto2/vue-core/resource, /page |
useRouteResource, ResourcePage, SectionNavigator, AdaptivePageShell |
@wssto2/vue-core/button, /controls, /content, /state, /overlay, /modal, /icon |
the primitives (/content includes Panel, KeyValueList, StatSummary, the grouped card of numbers) |
@wssto2/vue-core/i18n, /format, /client |
messages, useDescribeError, formatting, HttpClient and ApiError |
@wssto2/vue-core/identity, /access |
go-core's module contracts (types, route tables) and, in /identity, identityFeature (sign-in page, language), identityPlatform() and usersFeature (users, "my profile") and serviceAccountsFeature (service accounts and their API keys): sign-in, users, service accounts |
@wssto2/vue-core/access (screens) |
accessFeature({ catalogue }): roles list and editor, compare, replace, a person's roles (PersonAccess) and what they can do: roles and access |
@wssto2/vue-core/notifications |
go-core's notification and events modules: notificationsFeature({ categories, deadLetters }): the bell with the unread count, the inbox (popover, bottom sheet on phones), the live stream, the failed-events page: notifications |
@wssto2/vue-core/onboarding |
Tip: a card beside something new, shown until the person closes it, remembered in their stored preferences (usePreferences, in /identity): preferences and tips |
@wssto2/vue-core/incidents |
go-core's incident module: incidentsFeature({ destination, appVersion }): the list, an incident with its reports, comments and management form, "Report a bug" in the account menu; clientErrorReporter({ platform }): the browser's own errors reported as incidents: incidents |
@wssto2/vue-core/testing |
createTestPlatform, createTestApp, createTestApplication, fake transports: for your tests, testing |
@wssto2/vue-core/playwright |
mockAuth, serveAccess, serveNotifications, serveProfile, serveUsers: Playwright mocks of the library's endpoints, end-to-end specs |
Nothing is reachable by a deep import. The peers are vue, vue-router and vue-i18n; the library never bundles them.
Install
npm install @wssto2/vue-core vue vue-router vue-i18n
npm install -D vite @vitejs/plugin-vue tailwindcss @tailwindcss/vite typescript vue-tsc
To try a change to the library in an app before it is released, build it and install the packed file: npm run build && npm pack here, then npm install ../vue-core/wssto2-vue-core-<version>.tgz in the app.
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
// Vue's compiler and Tailwind v4's Vite plugin: the library ships no build setup of its own.
export default defineConfig({
plugins: [vue(), tailwindcss()],
});
The page that hosts the app carries the server's public config, which the library reads once at startup:
<body class="bg-surface-page text-content">
<div id="app"></div>
<script id="app-state" type="application/json">{ "locale": "en", "app_name": "Acme", "api_base": "/api/v1" }</script>
<script type="module" src="/src/main.ts"></script>
</body>
The composition root
One file knows every part of the application. readBootstrap validates the page's config, createPlatform builds the services everything shares (nothing runs yet), createApplication validates the whole composition (duplicate feature ids, route names, contexts, message namespaces, destinations, a missing login route) and throws one error listing every problem, then mount starts it.
// The composition root: the one place that knows every part of the application.
import { createApplication } from "@wssto2/vue-core/app";
import { createPlatform, readBootstrap } from "@wssto2/vue-core/platform";
import { backofficeShell } from "@wssto2/vue-core/shell";
import { createAccountsFeature } from "../accounts/feature";
import { createTicketsFeature } from "../tickets/feature";
import { appIcons } from "./declarations";
import { sessionFeature } from "./session/feature";
import "./styles.css";
// 1. What the server embedded in the page (<script id="app-state" type="application/json">), validated once.
const config = readBootstrap();
// 2. The services every part shares: HTTP client, session, access. Nothing runs yet.
const platform = createPlatform({ config });
// 3. The application: an explicit list of features, and the shell around them.
const application = createApplication({
platform,
shell: backofficeShell(),
icons: appIcons,
features: [sessionFeature, createTicketsFeature(platform.http), createAccountsFeature(platform.http)],
});
void application.mount("#app");
Everything else an application sets (locales, persisting the locale, renewing a session, sign-out cleanup, error reporting) is in app-setup. A second shell is in shell.
A feature
A feature is one value made by defineFeature: its routes, typed context, texts, navigation and whatever it contributes to the shell. Declaring it runs nothing; the application installs it.
import { defineFeature, provideContext } from "@wssto2/vue-core/app";
import type { HttpClient } from "@wssto2/vue-core/client";
import { localeMessages } from "@wssto2/vue-core/i18n";
import { createTicketsApi } from "./api";
import { createTicketList } from "./collection";
import { TICKETS } from "./context";
import { ticketRoutes } from "./routes";
// A feature is one value: what the application installs for it. Its collaborators are ordinary
// arguments, visible in the composition root.
export function createTicketsFeature(http: HttpClient) {
const api = createTicketsApi(http);
return defineFeature({
id: "tickets",
routes: ticketRoutes.records,
context: provideContext(TICKETS, { api, list: createTicketList(api) }),
// Loaded the first time a route of this feature is entered, in the active locale.
messages: localeMessages("tickets", { en: () => import("./i18n/en.json"), hr: () => import("./i18n/hr.json") }),
// The backend's menu names destinations; this says which route opens "tickets" and which records keep it highlighted.
navigation: [{ destination: "tickets", to: ticketRoutes.index, within: ["tickets.record"] }],
});
}
Its routes are declared once and used as typed targets. A route with parameters is a function of them; a missing or wrong one does not compile:
import { defineRoutes } from "@wssto2/vue-core/router";
export const ticketRoutes = defineRoutes({
index: {
name: "tickets.index",
path: "/tickets",
component: () => import("./views/Index.vue"),
meta: { access: "tickets:view", titleKey: "tickets.title" },
},
record: {
name: "tickets.record",
path: "/tickets/:ticketID",
component: () => import("./views/Record.vue"),
meta: { access: "tickets:view", titleKey: "tickets.title", remountOnParam: "ticketID" },
},
});
// ticketRoutes.index a route without parameters is a value
// ticketRoutes.record({ ticketID: 12 }) one with parameters is a function; a wrong or missing one does not compile
What its views need (the API, the list definition) comes through a typed context, not a global. useTickets() throws a message naming the missing feature if it is not installed:
import { defineFeatureContext } from "@wssto2/vue-core/platform";
import type { TicketsApi } from "./api";
import type { createTicketList } from "./collection";
export interface TicketsDependencies {
readonly api: TicketsApi;
readonly list: ReturnType<typeof createTicketList>;
}
// A typed key: `useTickets()` fails with a clear message when the feature is not installed.
export const [TICKETS, useTickets] = defineFeatureContext<TicketsDependencies>("tickets");
Its texts are JSON files under the feature, loaded the first time one of its routes is entered, in the active locale:
{
"title": "Tickets",
"description": "Everything customers asked for",
"create": "New ticket",
"subject": "Subject",
"status": "Status",
"assignee": "Assignee",
"created": "Created",
"open": "Open",
"closed": "Closed",
"record": "Ticket",
"details": "Details",
"history": "History",
"history_empty": "Nothing has happened yet.",
"when": "When",
"kind": "Kind",
"author": "Author",
"note": "Note",
"comment": "Comment",
"change": "Status change",
"remove": "Remove",
"pick": "Choose a ticket"
}
The views are a list page and a record page.
Typed routes
go-core's generator writes one route<In, Out>(method, path, options?) per endpoint; client.request(route, input) calls it with the input and the answer typed. Details, a list from a route and the errors: typed routes.
import { createHttpClient, isApiError } from "@wssto2/vue-core/client";
import { ticketsRoutes } from "./routes";
import { route } from "@wssto2/vue-core/client";
const http = createHttpClient({ baseUrl: "/api" });
// A path parameter and the rest as the query: GET /api/v1/tickets/7
export async function loadTicket(id: number, signal: AbortSignal) {
const result = await http.request(ticketsRoutes.show, { id }, { signal });
return result.data; // typed as Ticket
}
Permissions, destinations and icons
The library knows none of your permissions, none of your backend's menu destinations and none of your icons. You declare them by merging into three interfaces, like vue-router's RouteMeta; after that a typo in meta.access, in a navigation binding or in <Icon name> is a compile error:
// What the application tells the types about itself: its permissions, the destinations of its
// backend's menu and its icons. Declaration merging, like vue-router's `RouteMeta`: a permission,
// destination or icon that is not declared here is a compile error where it is used.
import type { IconSet } from "@wssto2/vue-core/icon";
declare module "@wssto2/vue-core/platform" {
interface PermissionRegistry {
"tickets:view": true;
"tickets:update": true;
"accounts:view": true;
// go-core's access module defines these in the catalogue, so a generated union has them (the roles screens name them).
"iam.role:view": true;
"iam.role:manage": true;
"iam.role:delete": true;
"iam.user:view": true;
"iam.user:manage": true;
}
}
declare module "@wssto2/vue-core/router" {
interface DestinationRegistry {
tickets: true;
accounts: true;
users: true;
serviceaccounts: true;
"iam.roles": true;
"events.deadletters": true;
"incidents.index": true;
}
}
declare module "@wssto2/vue-core/icon" {
interface IconRegistry {
ticketLine: true;
}
}
/** The SVG sources of the declared icons (here Remix Icon's "coupon"); a feature may bring its own set. */
export const appIcons = {
ticketLine:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3C21.5523 3 22 3.44772 22 4V9.5C20.6193 9.5 19.5 10.6193 19.5 12C19.5 13.3807 20.6193 14.5 22 14.5V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V14.5C3.38071 14.5 4.5 13.3807 4.5 12C4.5 10.6193 3.38071 9.5 2 9.5V4C2 3.44772 2.44772 3 3 3H21ZM20 5H4V7.8C5.6 8.6 6.5 10.2 6.5 12C6.5 13.8 5.6 15.4 4 16.2V19H20V16.2C18.4 15.4 17.5 13.8 17.5 12C17.5 10.2 18.4 8.6 20 7.8V5Z"></path></svg>',
} satisfies IconSet;
access: "tickets:view" on a route hides the page from anyone without it (a denied address shows a no-access page, live on permission refresh); access.can("tickets:update") decides what a component offers. A route's meta.access also accepts { any: [...] } and { all: [...] }. The navigation the server sends names destinations (tickets); a feature's navigation binds each to a route.
Icons are SVG sources keyed by name. icons takes one set or several partial ones (a feature may bring its own; a name in two sets is an error). The library's own icons (close, search, arrowLeftSLine...) need no registration.
Styles
The library is styled with Tailwind v4 through semantic tokens (bg-surface-page, text-content-muted, rounded-group...). Your app runs its own Tailwind and imports the library's tokens, variants and fonts after it; the library's own utilities are generated from its built package (tailwind.css does the @source), so there is one CSS build:
@import "tailwindcss";
@import "@wssto2/vue-core/tailwind.css";
@import "@wssto2/vue-core/fonts.css";
@import "./brand.css";
The brand accent is a handful of variables you redefine after the import (light on :root, dark on .dark):
/* The brand accent: the palettes and the "Accent" variables of the library's theme.css, redefined
after it (light on :root, dark on .dark). Status colours never follow the brand. */
:root {
--color-primary-50: #EFF6FF;
--color-primary-100: #DBEAFE;
--color-primary-200: #BFDBFE;
--color-primary-300: #93C5FD;
--color-primary-400: #60A5FA;
--color-primary-500: #3B82F6;
--color-primary-600: #2563EB;
--color-primary-700: #1D4ED8;
--color-primary-800: #1E40AF;
--color-primary-900: #1E3A8A;
--color-primary-950: #172554;
--color-anchor-800: #1B2A4A;
--color-anchor-900: #111C33;
--color-anchor-950: #0A1222;
--app-tint: #1D4ED8;
--app-tint-soft: #E8EFFD;
--app-control-on: #2563EB;
--app-tile-brand: #2563EB;
--app-tile-anchor: #1B2A4A;
--app-tile-anchor-foreground: #93C5FD;
--app-content-inverse-accent: #93C5FD;
}
.dark {
--app-tint: #60A5FA;
--app-tint-soft: #1B2A4A;
--app-control-on: #3B82F6;
--app-content-on-tint: #0A1222;
--app-tile-anchor: #22335C;
--app-content-inverse-accent: #1D4ED8;
}
Dark mode is the dark class on <html>; nothing is decided for you. An app that does not write Tailwind utilities imports the prebuilt @wssto2/vue-core/styles.css instead. More in theming.
Texts and locales
The library's own texts are under the core namespace in English, Croatian, Bosnian and Slovenian (t("core.actions.cancel")). createApplication merges them into its vue-i18n instance; override any key with i18n.messages and restrict the offered locales with locale.supported. A feature's texts are a namespace (localeMessages("tickets", { en: () => import("./i18n/en.json") }), used as t("tickets.title")) loaded per locale on demand. A locale switch (the account menu has one) loads every namespace in use first, so nothing shows a raw key; locale.onChange lets you save the choice on the user.
Recipes
- List page: a typed collection, columns, filters, row actions, URL state; a table over an array
- Record page: a simple record, a record with sections, regions that load on their own (
useResource,useLoad), previous / next - Shell: the default shell, contributions from features, keyboard shortcuts and their help, running inside a frame, a custom shell
- App setup: config, session, effects, locales and flags, permissions in the UI, error sentences, startup errors
- Forms: form state, fields, a record edited in group sheets, a long form, commands
- Typed routes: generated routes,
client.request, a list from a route - Sign-in: go-core's identity module: the sign-in page, the session wiring, the language, the module's committed types, the dev server
- Users and profile: the users list and a person's record, "my profile" with the e-mail change by code, opting out, extra sections, refusals
- Service accounts: the list and an account's record, its roles, API keys shown once, tenants, linking
- Roles and access: go-core's access module: the roles list and editor from your catalogue, a person's roles and effective access, where a role applies, the refusals
- Lint:
vue-core lintand@wssto2/vue-core/eslint: ceilings per file and rule that only go down, the typed run on changed files - Notifications: the bell, the inbox and the live stream, categories, the failed-events page, trying it against go-core's dev server
- Incidents: go-core's incident module: the list and an incident, "Report a bug", the browser's errors reported (what is and is not sent), source maps
- Preferences and tips:
usePreferences, the person's stored settings,darkMode()on them, theTipbeside something new - Theming: brand accent, dark mode, tokens, status tones and category hues
- Testing: the application's environment and a fake backend for your tests
- End-to-end specs:
@wssto2/vue-core/playwright, the Playwright mocks of go-core's envelope, session, access, notification, profile and users routes - Feature generator and e2e timings:
vue-core featureandvue-core e2e-timings
Forms have their own recipe when that phase lands.
The vue-core command
The package installs one command, vue-core, with the subcommands lint, check-architecture, feature and e2e-timings (npx vue-core --help). Each reads its own section of one vue-core.config.mjs, found from the current folder upwards (or --config <path>); a command with no section runs on its defaults. eslint, its plugins and @playwright/test are optional peers, installed only by the applications that use them.
npx vue-core --help
npx vue-core lint --config tools/vue-core.config.mjs
import { defineConfig } from "@wssto2/vue-core/config";
// vue-core.config.mjs at the root of the application; one section per command.
export default defineConfig({
lint: {},
architecture: {},
generator: {},
e2eTimings: {},
});
Working on the library
npm run check # lint, typecheck (src and the documented examples), docs = examples, tests, build, packed-consumer check
The code in this README and in the recipes is copied from docs/examples, which npm run typecheck:docs compiles against the library's sources and npm run check:docs compares with the documents. Change an example, then paste it again.
Related Packages
Version History
| Version | Released | License |
|---|---|---|
| 0.15.3 | ||
| 0.15.2 | ||
| 0.15.1 | ||
| 0.15.0 | ||
| 0.14.0 | ||
| 0.13.1 | ||
| 0.13.0 | ||
| 0.12.1 | ||
| 0.12.0 | ||
| 0.11.0 | ||
| 0.10.0 | ||
| 0.9.4 | ||
| 0.9.3 | ||
| 0.9.2 | ||
| 0.9.1 | ||
| 0.9.0 | ||
| 0.8.0 | ||
| 0.7.0 | ||
| 0.6.0 | ||
| 0.5.4 | ||
| 0.5.3 | ||
| 0.5.2 | ||
| 0.5.1 | ||
| 0.5.0 | ||
| 0.4.28 | ||
| 0.4.27 | ||
| 0.4.26 | ||
| 0.4.25 | ||
| 0.4.24 | ||
| 0.4.23 |
Showing the latest 30 of 55. See every release on npm
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |