Integration guide
Everything a consuming site needs, in the order it needs it. The one step that silently breaks the UI is the Tailwind @source line — it has its own section below.
Install
Three packages, published public under AGPL-3.0-only. The Nuxt module depends on the Vue package, which depends on the core — installing all three explicitly keeps the versions visible in your manifest.
pnpm add @nextmoe/edit-ui-nuxt @nextmoe/edit-ui-vue @nextmoe/edit-ui-core
pnpm add @kungal/ui-nuxt @kungal/ui-tokens @kungal/ui-vue
pnpm add -D tailwindcss @tailwindcss/vite @iconify-json/lucidepnpm add @nextmoe/edit-ui-vue @nextmoe/edit-ui-core
pnpm add @kungal/ui-vue @kungal/ui-tokens
pnpm add @iconify/vue # see "Icons" under Path A
pnpm add -D tailwindcss @tailwindcss/vite| Package | Peer dependencies | Notes |
|---|---|---|
| @nextmoe/edit-ui-core | none | Bundles diff ^9. No Vue anywhere in its dependency closure. |
| @nextmoe/edit-ui-vue | vue ^3.5.0 · @kungal/ui-vue ^2 | Also pulls @vueuse/core, @vueuse/integrations and sortablejs as normal dependencies. |
| @nextmoe/edit-ui-nuxt | vue ^3.5.0 · nuxt ^4.0.0 · @kungal/ui-vue ^2 | Ships raw TS loaded by Nuxt jiti — no build output. |
Path B — Nuxt
The module registers all 14 components as auto-imports, so templates need no import at all.
// nuxt.config.ts
export default defineNuxtConfig({
// KunUI's Nuxt layer: auto-imports every Kun* component and injects
// NuxtLink / @nuxt/icon / @nuxt/image into KunUI's config.
extends: ['@kungal/ui-nuxt'],
modules: ['@nextmoe/edit-ui-nuxt'],
css: ['~/assets/css/main.css'],
editUi: {
prefix: 'Edit', // default. <SchemaForm> registers as <EditSchemaForm>
global: false // default: lazy registration
}
})<template>
<EditSchemaForm
:fields="fields"
:values="values"
:config="config"
:group-order="['Basics', 'Media']"
layout="tabs"
@update:patch="(patch) => (draft = patch)"
/>
<EditFieldDiff label="Title" diff-hint="inline" :from="a" :to="b" />
<EditReviewQueue v-model:status="status" :items="open" :label-for="labelFor" />
</template> The prefix is why the module exists: a site migrating off its own local copy of these components can set prefix: 'Editkit' and keep every existing <EditkitSchemaForm> tag untouched. The component names come from EDIT_UI_COMPONENT_NAMES in @nextmoe/edit-ui-vue, so the module never drifts from the package.
Path A — Vue 3 + Vite
Components are exported under their bare names; the Edit prefix is applied only by the Nuxt module.
// main.ts
import { createApp } from 'vue'
import { installKunUIConfig } from '@kungal/ui-vue'
import { Icon } from '@iconify/vue'
import { RouterLink } from 'vue-router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
installKunUIConfig(app, {
iconComponent: Icon, // see the note below — without this some icons vanish
linkComponent: RouterLink // optional: keeps KunUI hrefs in the SPA router
})
app.mount('#app')<script setup lang="ts">
import { SchemaForm, FieldDiff } from '@nextmoe/edit-ui-vue'
import type { EditFieldConfigMap, EditSchemaField } from '@nextmoe/edit-ui-vue'
const fields = ref<EditSchemaField[]>([])
const config: EditFieldConfigMap = { title: { label: 'Title', control: 'input' } }
</script>
<template>
<SchemaForm :fields="fields" :values="values" :config="config" />
</template>Icons: KunUI only inlines its bundled registry
KunIcon renders icons from KunUI's own bundled set as inline SVG and never fetches anything. A name outside that set — lucide:undo-2 on the field revert button, for one — renders nothing at all unless you inject an iconComponent through installKunUIConfig. The Nuxt layer does this for you with @nuxt/icon; a plain Vue app must do it itself.
The Tailwind v4 @source line
The single step that costs people an afternoon.
Miss this and the UI degrades with no error at all
These packages ship no CSS. Every class in them is a KunUI utility, and Tailwind v4 only emits the utilities it finds in the files it scans — node_modules is not scanned by default. Without the @source line below, the classes the components use are simply never generated: no build error, no console warning, just a form with collapsed spacing, no borders and no colour.
/* app/assets/css/main.css — paths are relative to THIS file */
@import 'tailwindcss';
@import '@kungal/ui-tokens';
@import '@kungal/ui-vue/style.css';
@source '../../../node_modules/@kungal/ui-vue';
@source '../../../node_modules/@nextmoe/edit-ui-vue/dist';/* src/style.css — one directory below node_modules */
@import 'tailwindcss';
@import '@kungal/ui-tokens';
@import '@kungal/ui-vue/style.css';
@source '../node_modules/@kungal/ui-vue';
@source '../node_modules/@nextmoe/edit-ui-vue/dist';- Point at
/dist, not at the package root:distis the only directory the package publishes, and narrowing the scan keeps the emitted CSS to the classes really used. - Relative to the CSS file, not to the project root. A Nuxt entry at
app/assets/css/main.cssneeds three../to reachnode_modules; a Vite entry atsrc/style.cssneeds one. - Both directories are usually git-ignored and Tailwind scans them anyway — an explicit
@sourceis not subject to the automatic content-detection rules. If your classes are still missing, check the path itself first. - KunUI needs the same treatment — the second
@sourceline above is not optional either, and@kungal/ui-tokensis what defines the palette these classes resolve against. - Dark mode is a
.kun-dark-modeclass on<html>— KunUI's own switch. The components never write adark:prefix; they use semantic tokens that already flip.
The two inputs: schema and config
The schema comes from your server and says what may be edited. The config is yours and says how it looks. Nothing in the packages knows about either your API or your images.
// What your API sends per field — @nextmoe/edit-ui-core
interface EditSchemaField {
key: string
kind: string // text | int | enum | bool | date | imagehash | list | ref
diff_hint: string // inline | lines | items | image
deprecated?: boolean
locked: boolean
can_propose: boolean
can_review: boolean
would_automerge: boolean
}<EditSchemaForm
:fields="fields"
:values="values"
:config="config"
:group-order="['Basics', 'Media']"
:tabbed-groups="['Relations']"
layout="tabs"
:disabled="isReviewer"
@update:patch="onPatch"
/>| fields | EditSchemaField[] | Required. The schema your server sent. |
| values | Record<string, unknown> | Required. The current server values, used as the diff baseline. |
| config | EditFieldConfigMap | Required. Labels, controls, groups, injected functions. |
| group-order | string[] | Section order. Groups you omit keep their natural order after it. |
| tabbed-groups | string[] | These groups render their fields as inner tabs instead of stacking. |
| layout | 'stack' | 'tabs' | Defaults to 'stack'. 'tabs' puts the group list beside the fields. |
| disabled | boolean | Renders every control read-only — the review view of the same form. |
| @update:patch | Record<string, unknown> | Emitted on every edit: the changed fields only, deep-compared against values. Locked, deprecated and un-proposable fields never appear. |
Controls a field config may ask for
Omit control and it is derived from the schema's kind + diff_hint by resolveControl().
Anything that touches a backend is a function you supply
| EditFieldConfig key | Signature | Your job |
|---|---|---|
| uploadImage | (file, currentItems) => Promise<item | null> | Upload one picture and return the item to store. Absent ⇒ the image field renders read-only. |
| resolveImage | (value) => string | Turn a stored image value (a hash, a row, a URL) into something an <img> can show. |
| searchEntities | (keyword) => Promise<EditSelectOption[]> | Search your catalogue for the entity pickers. Absent ⇒ the field does not render as a picker. |
| resolveEntities | (ids) => EditSelectOption[] | Promise<…> | Turn already-stored ids back into labels on first render. |
There is no default, no fallback fetch and no built-in endpoint.
What is in the box
Fourteen exports from @nextmoe/edit-ui-vue. Under the Nuxt module each one gains the configured prefix.
| SchemaForm | The whole form: groups → sections (stack or tabs), emits update:patch. |
| SchemaField | One field; picks the control from the schema + config. |
| ObjectListField | Repeating rows of typed columns. |
| ImageField | Single or multi image upload, drag-sort, pin flag. |
| EntityPicker | Search-and-pick entity references. |
| EntityKindPicker | Entity references that also carry a kind. |
| SourceContext | The read-only "this came from upstream" strip under a field. |
| FieldDiff | One field's before/after, routed by diff_hint. |
| TextDiff | Word-level text diff, long unchanged runs elided. |
| ImageDiff | Only the pictures that changed, never the whole gallery. |
| ProposalCard | One edit proposal. |
| ReviewQueue | Proposals by status, with an item slot. |
| RevisionTimeline | Revisions, two-way selection, emits diff(fromSeq, toSeq). |
| Time | Minimal relative/absolute timestamp used by the two views above. |
Unknown controls degrade, never throw
Your edit engine may start sending a control (or a schema kind) that the installed package predates. SchemaField renders such a field as a read-only text/JSON display rather than throwing, or falling through to a text input that would invite an edit you cannot save. isEditControl() is the check; a spec pins the behaviour.
Built-in strings are zh-CN today
A handful of strings live inside the components — 已修改 / 撤销 on a dirty field, the ReviewQueue status tabs, the relative times. There is no locale option yet. Everything else (field labels, group names, option labels) comes from the config you pass, so it is already in your language.
Still stuck?
The repository's playground app is this very site — it consumes the packages the way your app will, so its source is a working reference.