UI Generators
The earlier chapters already used ui-generator a little when we added:
- the
Alerts Widget - the
Profilesettings page - the
Notificationssettings page
This chapter steps back and treats that tool as a subject in its own right.
Use ui-generator when you want app-owned UI that is not a CRUD route tree.
That usually means one of four jobs:
- create a route page
- create a small placed UI element
- turn an existing page into a host for child routes
- add a generic placement seam to an existing Vue file
Recap from previous chapters
To get back to the same starting point as the end of the shell chapter, run:
npx @jskit-ai/create-app exampleapp --tenancy-mode none
cd exampleapp
npm installIf you are already continuing from the earlier guide chapters, you are already in the right place and can skip that setup.
One important detail before we start: ui-generator is a CLI tool, not a runtime package you add to the app. The default scaffold already includes shell-web, which is the runtime package that makes these examples meaningful by giving the app real surfaces, placements, and nested shell structure.
ui-generator @jskit-ai/ui-generator (0.1.22)
ui-generator has four subcommands:
pageplaced-elementadd-subpagesoutlet
They are all about app-owned UI, but they solve different problems.
The easiest way to understand them is:
pagecreates a route and the link that reaches itplaced-elementcreates a reusable component and places it somewhereadd-subpagesupgrades a page into a routed child-page hostoutletadds only a placement seam, without routed child-page behavior
The simplest case: page
Start with the most common command:
npx jskit generate ui-generator page \
home/reports/index.vue \
--name "Reports"This command creates a page at:
src/pages/home/reports/index.vueand it also appends a matching link placement into:
src/placement.jsThat second part is important. page is not just a file generator. It also makes the new page reachable inside the shell.
In a fresh home surface app, the generated placement looks like a normal shell menu link. In the throwaway app used to verify this chapter, the command reported:
Generated UI page "/reports" at src/pages/home/reports/index.vue.
Touched files (2):
- src/pages/home/reports/index.vue
- src/placement.jsThat is the baseline behavior of page:
- one app-owned page file
- one app-owned placement entry
The important default is this:
- if JSKIT sees no nearer routed host, the new page gets a normal shell/menu placement
- if JSKIT does see a nearer routed host, the new page gets linked into that host instead
Open the app to see a real /reports page plus a real shell link for it.
Customizing the generated menu link
The generated page command does not ask for an icon up front. That is deliberate. The command's job is to create the route and the matching placement entry, not to force every menu detail during generation.
If you want to customize the link afterwards, edit the placement entry that page wrote into src/placement.js.
That is where things such as:
labelordericonownersurfaces
normally get adjusted.
The important icon rule is this:
- inside
src/placement.jsmenu metadata, import app-specific icons from@mdi/jsand pass the path constant - only use raw
mdi-*strings for the small set of shell-web core icons that JSKIT normalizes - inside normal Vue component props, use the same
@mdi/jspath constants or a Vuetify alias
So this is a valid menu-placement customization:
import { mdiChartBoxOutline } from "@mdi/js";
props: {
label: "Reports",
to: "/reports",
icon: mdiChartBoxOutline
}If you later open the generated page file itself and add a Vuetify icon to the template, use the same @mdi/js pattern:
<script setup>
import { mdiChartBoxOutline } from "@mdi/js";
</script>
<v-icon :icon="mdiChartBoxOutline" />This keeps icon imports local and tree-shakeable. Do not import the whole @mdi/js namespace just to look up icon names dynamically.
What page is really good at
The reason page is the primary subcommand is that it understands more than just "create this file".
It also understands shell topology.
If you create a page that lives under a parent page which has already been upgraded into a child-page host, page does not keep treating it as a top-level shell entry. It changes behavior and links the new page into the nearest host instead.
That is what makes it fit naturally with the rest of JSKIT.
The important page overrides
The happy path is intentionally simple, but page also has a small set of override options that matter as soon as you stop accepting the defaults.
--link-placement
Use this when the generated page link should go somewhere other than the generator's inferred default.
Typical reasons:
- you want the page in a different shell menu
- you want the page link inside a specific existing semantic placement
- you do not want the link to land in the normal top-level menu
For example, if a page should appear in a settings menu rather than the shell drawer, --link-placement is the override that says so.
The value should normally be semantic, such as shell.primary-nav, page.section-nav, or another area.slot placement from jskit list-placements. Concrete host:position outlets remain an escape hatch, not the default authoring path.
--link-to
Use this when you want to override the generated props.to value.
This is especially useful for nested pages, because when JSKIT detects a parent subpages host it normally infers a relative link like:
to: "./exports"That default is usually correct. But if the host needs a different local target shape, or you are wiring a link into a custom outlet with different expectations, --link-to is the escape hatch.
So the rough rule is:
- if the link destination is obvious from the route tree, let JSKIT infer it
- if you need a very specific link destination, use
--link-to
--force
Use this only when the target page file already exists and you intentionally want to overwrite it.
That is most common when:
- you are regenerating an experimental page
- you want to throw away the current file and replace it with fresh scaffold
It is not a routing option. It is simply an overwrite guard.
Turning a page into a child-page host: add-subpages
Suppose Reports should stop being a single screen and start becoming a section with child routes under it.
Run:
npx jskit generate ui-generator add-subpages \
home/reports/index.vue \
--title "Reports" \
--subtitle "View and manage reporting modules."This upgrades src/pages/home/reports/index.vue into a routed host page.
On the first use in a fresh app, it also installs the support shell component:
src/components/SectionContainerShell.vueIn the verified throwaway app, the command reported:
Enabled subpages in src/pages/home/reports/index.vue for "/reports" using outlet target "reports:sub-pages".
Touched files (2):
- src/components/SectionContainerShell.vue
- src/pages/home/reports/index.vueAfter the command, the page gains three important pieces:
SectionContainerShell- a
ShellOutletfor the child-page tabs RouterView
That lets the page keep rendering shared content while child routes render underneath it.
This is the first big distinction in this chapter:
- a plain page is just a page
- a subpages host is a page plus routed child-page structure
If what you need is routed children, add-subpages is the right tool.
When page becomes a tab-like child instead of a top-level menu entry
This is the key transition to understand.
Before add-subpages, page usually creates:
- a page file
- a top-level shell link
After add-subpages, that same page command may create:
- a page file
- a child link inside the nearest parent host outlet
That is why nested pages often feel "tab-like" even though the generator command is still just page.
The route page is still a normal page file. What changes is the inferred placement target:
- no parent host found -> shell/menu entry
- nearest parent host found -> child link inside that host
So JSKIT is not switching to a different generator. The route tree has a routed host above the new page, so the inferred placement behavior follows that host.
Making a child page the default landing route
This is important enough to state explicitly: if a routed host has child pages, and you want the bare parent URL to open one child immediately, use an explicit redirect.
Do not try to make the app "guess the first tab". Do not infer it from placement order. Keep the target explicit.
The standard pattern is:
<script setup>
import { redirectToChild } from "@jskit-ai/kernel/client/pageRedirects";
definePage({
redirect: redirectToChild("exports")
});
</script>If this is placed on the host page, opening /reports lands on /reports/exports.
This is also the right pattern when the host page still renders shared content such as a title, tabs, summary panel, or RouterView. The parent route remains the host, and the child page simply becomes the default destination under it.
Why this is the recommended pattern:
- the destination is explicit and stable
- it survives later placement reordering
- it does not depend on which child link happens to render first
- it is easy to change later by editing one child segment
This is one of the most common things people want once they start using child-page hosts. Treat it as normal JSKIT routing, not a special hack.
Nested pages under an index.vue host
Once Reports is a host, create a child page under it:
npx jskit generate ui-generator page \
home/reports/index/exports/index.vue \
--name "Exports"This path shape matters.
Because the parent host is:
src/pages/home/reports/index.vuethe child belongs under:
src/pages/home/reports/index/exports/index.vueThat index/... folder segment is not an odd JSKIT convention. It is the real nesting rule for children of an index.vue route host.
And this is where page becomes interesting again.
In the throwaway app, the command reported:
Generated UI page "/reports/exports" at src/pages/home/reports/index/exports/index.vue.
Touched files (2):
- src/pages/home/reports/index/exports/index.vue
- src/placement.jsBut the placement it wrote was not another top-level shell link.
Instead, it targeted the host outlet:
page.section-navwith the host owner and relative route:
owner: "reports",
to: "./exports"That is exactly the behavior you want:
Reportsstays the host pageExportsbecomes a child tab under it- the child route renders under the parent instead of becoming another top-level menu entry
This is also why --link-placement and --link-to are often unnecessary in the default nested case. Once the host exists, JSKIT already knows the likely semantic placement, owner, and relative to value. The link renderer comes from src/placementTopology.js.
Nested pages under a file-route host
There is a second nesting shape that matters just as much.
Create a dynamic file-route page:
npx jskit generate ui-generator page \
home/contacts/[contactId].vue \
--name "Contact"Then upgrade that page into a host:
npx jskit generate ui-generator add-subpages \
home/contacts/[contactId].vue \
--title "Contact" \
--subtitle "Contact activity and notes."This time the parent is a file route, not an index.vue route.
So the child-page path is different:
npx jskit generate ui-generator page \
home/contacts/[contactId]/notes/index.vue \
--name "Notes"That is the other important nesting rule:
- if the host is an
index.vuepage, children go underindex/... - if the host is a file-route page, children go under that page's directory
In the verified throwaway app, this command created:
src/pages/home/contacts/[contactId]/notes/index.vueand its link placement targeted:
contacts-contact-id:sub-pagesSo the exact host token changes, but the principle stays the same:
- a nearer routed host changes where
pageplaces the new link - the child page becomes a tab or child link inside that host
This is one of the most important things to understand about ui-generator: nested pages are not a separate feature bolted on afterwards. The generator already knows how to attach them to the right place.
A practical rule for child-page paths
Use these two rules when deciding where the child page file should go:
- host is
.../index.vue-> child pages go under.../index/... - host is
...[param].vueor another file route -> child pages go under.../[param]/...
That is not only a file-layout preference. It is how the router keeps the parent host visible while the child page renders beneath it.
Adding small placed UI: placed-element
Not everything should be a route.
If what you need is a small block of UI rendered into an existing placement target, use placed-element instead:
npx jskit generate ui-generator placed-element \
--name "Alerts Widget"In the verified throwaway app, that command touched:
- packages/main/src/client/providers/MainClientProvider.js
- src/components/AlertsWidgetElement.vue
- src/placement.jsThose three edits explain the feature:
- the component file is created in
src/components/ - the app's local client provider registers a new local token for it
src/placement.jsadds a placement entry that renders that token
This is different from page in a very important way:
pagegives you a URL and a linkplaced-elementgives you a component token and a placement entry
So if the thing you are adding should live inside an existing shell region, not at its own route, placed-element is the right command.
By default, the element targets the semantic status placement:
shell.statusThat is why this is such a good command for widgets, status panels, and compact shell extensions. The default shell topology maps shell.status to the concrete shell status outlet for each layout class.
When --surface matters
placed-element only needs --surface when JSKIT cannot infer the target surface cleanly.
The easiest cases are:
- the app has only one enabled surface
- the chosen placement target clearly belongs to a page-owned outlet on one surface
In those cases, surface inference is straightforward.
The ambiguous cases are the ones to watch for:
- the app has several enabled surfaces
- the target placement is shared shell infrastructure rather than a page-owned outlet
- the placement itself does not tell JSKIT which surface you meant
That is exactly when --surface becomes important.
A practical example is:
npx jskit generate ui-generator placed-element \
--name "Ops Panel" \
--surface admin \
--placement shell.statusshell.status can be global across several surfaces. If your app has several enabled surfaces, --surface admin tells JSKIT which one this element is actually meant for.
So the rule of thumb is:
- page-owned semantic placement target -> surface is often inferable
- shared shell semantic placement in a multi-surface app -> pass
--surface
--placement
Use --placement when the default target shell.status is not what you want.
This is the option that answers:
- where should this element render?
In practice, it is the first override you will use for placed-element.
--placement expects a semantic placement id such as shell.status, shell.global-actions, or settings.sections. Concrete host:position outlets are exposed through topology, not used as normal placed-element authoring targets.
--path
Use --path when the component file should live somewhere other than src/components.
This is not about placement in the UI. It is about placement in the source tree.
Typical reasons:
- you want widgets under
src/widgets - you want admin-specific pieces under a more specific component directory
- you want support scaffold grouped near a feature area instead of dumped into the default component folder
So --path changes where the new Vue file is written, not where it renders.
--force
Use --force when the target component file already exists and you want to replace it with fresh generated scaffold.
That is most useful when:
- you are regenerating a throwaway prototype
- you intentionally want to reset the file to generator output
As with page, this is an overwrite guard, not a placement rule.
Adding a plain placement seam: outlet
Now suppose you already have a component and you do not need routed child pages. You only need a named place where later UI can render.
That is what outlet is for.
In the verified throwaway app, after generating Alerts Widget, I ran:
npx jskit generate ui-generator outlet \
src/components/AlertsWidgetElement.vue \
--target alerts-widget:actions \
--placement page.actionsThat command touched the Vue file and the topology file:
src/components/AlertsWidgetElement.vue
src/placementTopology.jsand injected:
<ShellOutlet target="alerts-widget:actions" />It did not add:
RouterViewSectionContainerShell- child-page tab structure
That is the clean boundary between outlet and add-subpages.
Use outlet when you want:
- a placement seam inside an existing page or component
- later content to be targetable there
- no routed child-page behavior
After adding the outlet, npx jskit list-placements showed the new semantic placement immediately:
- page.actions: ...
- compact -> alerts-widget:actions
- medium -> alerts-widget:actions
- expanded -> alerts-widget:actionsSo outlet is the smallest possible way to add a concrete recipient and expose it through the public placement topology in the same change.
When outlet is the smaller correct tool
This is the right command when you want to say:
- "other things should be able to render here later"
without also saying:
- "this file should become a routed host page"
That is why outlet is often the better choice for:
- summary cards
- header/action regions
- detail panes
- reusable components that need extension points
If you do not need RouterView and you do not need child routes, outlet is usually the cleaner tool.
Choosing a good custom --target and --placement
--target should be meaningful to humans, not just syntactically valid.
A target like:
customer-view:summary-actionsis good because it tells you:
- the host area:
customer-view - the position or outlet purpose:
summary-actions
That is much better than something vague like:
custom-area:slot1because the meaningful target name will later show up in:
jskit list-placements --concrete- topology mappings
- future generator commands
So the target should describe the UI seam you are creating, not just satisfy the host:position format.
--placement is the public authoring target that other entries should use. It should be semantic, such as:
page.actionsAdding an outlet without adding a semantic mapping would leave a low-level recipient that normal generators and humans will not discover by default.
add-subpages versus outlet
This is the distinction that most often causes hesitation.
Use add-subpages when:
- the page should stay visible while child routes render underneath it
- you need a routed host page
- you want later generated child pages to attach there automatically
Use outlet when:
- you only want a placement seam
- the file already exists
- you do not want routed children
So:
add-subpageschanges the routing shape of the pageoutletchanges only the placement shape of the file
add-subpages also tends to be the right tool when the parent page is meant to stay visible while a series of related child pages render under it. outlet is the right tool when the file just needs extension points.
Picking the right command
The short rule is:
- use
pagefor new route pages - use
placed-elementfor reusable placed UI - use
add-subpageswhen the page should become a routed host - use
outletwhen you only need a placement target inside an existing file
And the slightly longer rule is:
- if it needs a URL, start from
page - if it needs child routes, add
add-subpages - if it does not need a URL but should render somewhere, use
placed-element - if the file already exists and only needs a target for later UI, use
outlet
Summary
ui-generator is the non-CRUD side of JSKIT scaffolding.
It writes app-owned UI structure in four different shapes:
- route pages
- placed components
- routed host pages
- plain placement seams
The most important thing to remember is that nested pages are not a special afterthought. Once a page has been upgraded with add-subpages, later page generation automatically treats that host as the real placement target.
That is why the commands fit together cleanly:
pagecreates routesadd-subpagesturns routes into hostspagecan then create nested child routes under those hostsplaced-elementandoutlethandle the non-routed side of the same UI system
Once the UI you want needs real database-backed list/view/new/edit behavior instead of only page structure, move to the CRUD generators chapter.