diff --git a/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-2.md b/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-2.md new file mode 100644 index 0000000..92776b9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-2.md @@ -0,0 +1,1207 @@ +# Frontend SPA — Milestone 2 (Object Authoring) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create / edit / delete catalogue objects from the SPA, including the dynamic flexible-field form (all field types) — consuming the existing admin endpoints. + +**Architecture:** New full-width route `/objects/new`; in-pane edit at `/objects/:id/edit` via nested routes under `ObjectsPage` (list + `` right pane). A shared `ObjectForm` (react-hook-form) renders inventory-minimum core fields plus dynamic flexible fields; `FieldInput` switches on `data_type` to the right control (term/authority are id-valued ` keeps the bundle lean and is fully accessible; the shadcn Select +// can replace it later without changing the value contract (option value = id). +function OptionsSelect({ + id, value, onChange, options, lang, placeholder, +}: { + id: string; value: string; onChange: (v: string) => void; + options: { id: string; labels: LabelView[] }[]; lang: string; placeholder: string; +}) { + return ( + + ); +} + +export function FieldInput({ + definition, form, +}: { + definition: FieldDefinitionView; + form: UseFormReturn<{ fields: Record }>; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + const label = labelIn(definition.labels, lang); + const name = `fields.${definition.key}` as const; + const placeholder = t("form.selectPlaceholder"); + + switch (definition.data_type) { + case "integer": + return ( +
+ + +
+ ); + case "date": + return ( +
+ + +
+ ); + case "boolean": + return ( +
+ ( + field.onChange(c === true)} /> + )} /> + +
+ ); + case "localized_text": + return ( +
+ + + + + +
+ ); + case "term": + return ( + + ); + case "authority": + return ( + + ); + case "text": + default: + return ( +
+ + +
+ ); + } +} + +function TermField({ definition, form, label, lang, placeholder }: { + definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record }>; + label: string; lang: string; placeholder: string; +}) { + const { data: terms } = useTerms(definition.vocabulary_id); + return ( +
+ + ( + + )} /> +
+ ); +} + +function AuthorityField({ definition, form, label, lang, placeholder }: { + definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record }>; + label: string; lang: string; placeholder: string; +}) { + const { data: authorities } = useAuthorities(definition.authority_kind); + return ( +
+ + ( + + )} /> +
+ ); +} +``` +NOTES: +- Uses a **native ` + {errors.core?.[key] &&

{t("form.required")}

} + + ); + + return ( +
+ {formError &&

{formError}

} + {coreField("object_number", "objectNumber", { required: true })} + {coreField("object_name", "objectName", { required: true })} + {coreField("number_of_objects", "count", { type: "number", required: true })} + {coreField("brief_description", "briefDescription")} + {coreField("current_location", "currentLocation")} + {coreField("current_owner", "currentOwner")} + {coreField("recorder", "recorder")} + {coreField("recording_date", "recordingDate", { type: "date" })} + + {mode === "create" && ( +
+ + +
+ )} + + {definitions && definitions.length > 0 && ( +
+ {t("form.flexibleHeading")} + {definitions.map((def) => ( +
+ + {errors.fields?.[def.key] && ( +

{t("form.required")}

+ )} +
+ ))} +
+ )} + +
+ + +
+
+ ); +} + +// Drop empty optional values so the replace-semantics field map only carries real values. +function pruneFields(fields: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (value === undefined || value === null || value === "") continue; + if (typeof value === "object" && !Array.isArray(value)) { + const inner = Object.fromEntries( + Object.entries(value as Record).filter(([, v]) => v !== undefined && v !== null && v !== ""), + ); + if (Object.keys(inner).length > 0) out[key] = inner; + continue; + } + out[key] = value; + } + return out; +} +``` +NOTES: +- The `core.object_name` label uses `fieldsLabels.objectName` ("Name"); the test matches `/^name/i` against that label — confirm the M1 `fieldsLabels.objectName` value is "Name"/"Namn" (it is). The `/object number/i` matches `fieldsLabels.objectNumber`. +- `pruneFields` enforces replace-semantics: empty optional fields are omitted; `localized_text` objects drop empty langs and are omitted entirely if both empty. +- Number coercion via `valueAsNumber`; date stays a `YYYY-MM-DD` string from the native date input. +- Required flexible fields use the `definition.required` rule wired in `FieldInput` (Task 2); the error message renders here. + +- [ ] **Step 4: Run** — `pnpm test src/objects/object-form.test.tsx` → PASS (3 tests). Then full `pnpm test` / typecheck / lint / build clean. + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): ObjectForm (core + dynamic flexible fields, RHF, validation)" +``` + +--- + +## Task 4: New-object page + create flow + `/objects/new` route + +**Files:** +- Create: `web/src/objects/object-new-page.tsx`, `web/src/objects/object-new-page.test.tsx` +- Modify: `web/src/app.tsx`, `web/src/objects/object-list.tsx`, `web/src/i18n/{en,sv}.json` + +- [ ] **Step 1: i18n** — add to the `objects` namespace: `en.json` `"new": "New object"`; `sv.json` `"new": "Nytt föremål"`. + +- [ ] **Step 2: Write the failing test** `web/src/objects/object-new-page.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { ObjectNewPage } from "./object-new-page"; + +function tree() { + return ( + + } /> + detail for {window.location.pathname}} /> + edit page} /> + + ); +} + +test("create: POST then PUT fields, then navigate to the new object's detail", async () => { + let postBody: unknown; let fieldsBody: unknown; + server.use( + http.post("/api/admin/objects", async ({ request }) => { + postBody = await request.json(); + return HttpResponse.json({ id: "new-id-1" }, { status: 201 }); + }), + http.put("/api/admin/objects/:id/fields", async ({ request }) => { + fieldsBody = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderApp(tree(), { route: "/objects/new" }); + await userEvent.type(await screen.findByLabelText(/object number/i), "A-9"); + await userEvent.type(screen.getByLabelText(/^name/i), "Amphora"); + await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods"); + await userEvent.click(screen.getByRole("button", { name: /create object/i })); + + await waitFor(() => expect(screen.getByText(/detail for/i)).toBeInTheDocument()); + expect((postBody as { object_number: string }).object_number).toBe("A-9"); + expect((fieldsBody as { inscription: string }).inscription).toBe("To the gods"); +}); + +test("partial create: fields PUT fails -> navigate to edit with an error banner", async () => { + server.use( + http.post("/api/admin/objects", () => HttpResponse.json({ id: "new-id-2" }, { status: 201 })), + http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 422 })), + ); + renderApp(tree(), { route: "/objects/new" }); + await userEvent.type(await screen.findByLabelText(/object number/i), "A-9"); + await userEvent.type(screen.getByLabelText(/^name/i), "Amphora"); + await userEvent.type(screen.getByLabelText(/inscription/i), "x"); + await userEvent.click(screen.getByRole("button", { name: /create object/i })); + await waitFor(() => expect(screen.getByText(/edit page/i)).toBeInTheDocument()); +}); +``` + +- [ ] **Step 3: Implement** — `web/src/objects/object-new-page.tsx` + +```tsx +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ObjectForm, type ObjectFormValues } from "./object-form"; +import { useCreateObject, useSetFields } from "../api/queries"; + +export function ObjectNewPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const create = useCreateObject(); + const setFields = useSetFields(); + const [error, setError] = useState(null); + + const onSubmit = async (values: ObjectFormValues) => { + setError(null); + let id: string; + try { + const created = await create.mutateAsync({ + ...values.core, + visibility: values.visibility ?? "draft", + }); + id = created.id; + } catch { + setError(t("form.rejected")); + return; + } + if (Object.keys(values.fields).length > 0) { + try { + await setFields.mutateAsync({ id, fields: values.fields }); + } catch { + // core record is saved; recover on the edit page so fields can be retried + navigate(`/objects/${id}/edit`, { state: { fieldsError: true } }); + return; + } + } + navigate(`/objects/${id}`); + }; + + return ( +
+ navigate("/objects")} /> +
+ ); +} +``` +(`create.mutateAsync` body spreads `values.core` + `visibility` to match `ObjectCreateRequest`. Confirm `ObjectCreateRequest` accepts exactly these keys; if it requires non-null strings vs nulls for optionals, adapt the core nulls to `undefined`/omit. The edit page reads `location.state.fieldsError` in Task 5 to show the banner.) + +- [ ] **Step 4: Wire the route + list button** + +In `web/src/app.tsx`, add (as a sibling of `/objects`, inside the protected `AppShell` group, BEFORE/independent of the `/objects` route — React Router ranks static `new` above dynamic `:id`): +```tsx +} /> +``` +(Import `ObjectNewPage`. Keep the existing `/objects` + `/objects/:id` routes for now; Task 5 converts them to nested children.) + +In `web/src/objects/object-list.tsx`, add a "New object" link near the list header/footer: +```tsx +import { Link } from "react-router-dom"; +// in the footer or a small header row: +{t("objects.new")} +``` +(Place it so the existing object-list tests still pass — e.g. a header row above the `
    `.) + +- [ ] **Step 5: Run** — `pnpm test src/objects/object-new-page.test.tsx` → PASS (2 tests). Full `pnpm test` / typecheck / lint / build clean. (Confirm `object-list.test.tsx` still green after adding the link.) + +- [ ] **Step 6: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): new-object full-width page + create flow + /objects/new" +``` + +--- + +## Task 5: Nested routes + in-pane edit form + edit flow + +**Files:** +- Create: `web/src/objects/object-edit-form.tsx`, `web/src/objects/object-edit-form.test.tsx` +- Modify: `web/src/objects/objects-page.tsx`, `web/src/app.tsx`, `web/src/objects/object-detail.tsx` + +- [ ] **Step 1: Convert `ObjectsPage` to render an `` right pane** — replace `web/src/objects/objects-page.tsx` + +```tsx +import { Outlet } from "react-router-dom"; +import { ObjectList } from "./object-list"; + +export function ObjectsPage() { + return ( +
    +
    + +
    +
    + +
    +
    + ); +} +``` +Create the index placeholder inline or as a tiny component `web/src/objects/select-prompt.tsx`: +```tsx +import { useTranslation } from "react-i18next"; +export function SelectPrompt() { + const { t } = useTranslation(); + return ( +
    + {t("objects.selectPrompt")} +
    + ); +} +``` + +- [ ] **Step 2: Nest the routes** — in `web/src/app.tsx` + +```tsx +} /> +}> + } /> + } /> + } /> + +``` +(`ObjectDetail` and `ObjectEditForm` now render inside `ObjectsPage`'s ``; both read `:id` via `useParams`. Import `SelectPrompt`, `ObjectEditForm`.) + +- [ ] **Step 3: Write the failing edit test** `web/src/objects/object-edit-form.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { ObjectEditForm } from "./object-edit-form"; +import { amphora } from "../test/fixtures"; + +function tree() { + return ( + + } /> + detail view} /> + + ); +} + +test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async () => { + let putCore: unknown; let putFields: unknown; + server.use( + http.get("/api/admin/objects/:id", () => + HttpResponse.json({ ...amphora, fields: { inscription: "old" } })), + http.put("/api/admin/objects/:id", async ({ request }) => { + putCore = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + http.put("/api/admin/objects/:id/fields", async ({ request }) => { + putFields = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderApp(tree(), { route: `/objects/${amphora.id}/edit` }); + + const name = await screen.findByDisplayValue("Amphora"); + await userEvent.clear(name); + await userEvent.type(name, "Big amphora"); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument()); + expect((putCore as { object_name: string }).object_name).toBe("Big amphora"); + expect((putFields as { inscription: string }).inscription).toBe("old"); +}); +``` +(The default `amphora` fixture has `fields: {}` due to the schema typing; the handler override returns `fields: { inscription: "old" }` as a plain object — same approach as M1's detail test.) + +- [ ] **Step 4: Implement** — `web/src/objects/object-edit-form.tsx` + +```tsx +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ObjectForm, type ObjectFormValues, type ObjectCore } from "./object-form"; +import { useObject, useUpdateObject, useSetFields } from "../api/queries"; + +export function ObjectEditForm() { + const { t } = useTranslation(); + const { id } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const { data: object, isLoading } = useObject(id!); + const update = useUpdateObject(); + const setFields = useSetFields(); + const [error, setError] = useState( + (location.state as { fieldsError?: boolean } | null)?.fieldsError ? t("form.rejected") : null, + ); + + if (isLoading) return
    ; + if (!object) return

    {t("objects.notFound")}

    ; + + const core: ObjectCore = { + object_number: object.object_number, + object_name: object.object_name, + number_of_objects: object.number_of_objects, + brief_description: object.brief_description ?? null, + current_location: object.current_location ?? null, + current_owner: object.current_owner ?? null, + recorder: object.recorder ?? null, + recording_date: object.recording_date ?? null, + }; + const defaults = { core, fields: object.fields as Record }; + + const onSubmit = async (values: ObjectFormValues) => { + setError(null); + try { + await update.mutateAsync({ id: id!, body: values.core }); + await setFields.mutateAsync({ id: id!, fields: values.fields }); + } catch { + setError(t("form.rejected")); + return; + } + navigate(`/objects/${id}`); + }; + + return ( + navigate(`/objects/${id}`)} /> + ); +} +``` + +- [ ] **Step 5: Add the Edit action to detail** — first add the i18n key, then the link. + +In `web/src/i18n/en.json` add an `actions` namespace `"actions": { "edit": "Edit" }`; in `sv.json` `"actions": { "edit": "Redigera" }`. (Task 6 extends this `actions` namespace with `delete`/`confirmDelete`.) + +In `web/src/objects/object-detail.tsx`, add (in the header row next to the badge) a link to edit: +```tsx +import { Link } from "react-router-dom"; +// in the header row next to the badge: +{t("actions.edit")} +``` + +- [ ] **Step 6: Run** — `pnpm test src/objects/object-edit-form.test.tsx` → PASS. Full `pnpm test` (M1 detail + objects-page tests now run under nested routing — confirm they still pass; the `objects-page.test.tsx` from M1 asserted clicking a row shows detail, which still holds via nested routes — update that test's route table to the nested shape if needed), typecheck, lint, build clean. +NOTE: M1's `objects-page.test.tsx` rendered `ObjectsPage` under flat `/objects` + `/objects/:id` routes. With nesting, update that test to render the nested route tree (parent `ObjectsPage` with `:id` child) so the click-through still asserts detail in the right pane. + +- [ ] **Step 7: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): nested object routes + in-pane edit form + edit flow" +``` + +--- + +## Task 6: Delete (confirm dialog + flow) + +**Files:** +- Create: `web/src/objects/delete-object-dialog.tsx`, `web/src/objects/delete-object-dialog.test.tsx` +- Modify: `web/src/objects/object-detail.tsx`, `web/src/i18n/{en,sv}.json` + +- [ ] **Step 1: i18n** — EXTEND the existing `actions` namespace (created in Task 5 with `edit`) by adding `delete` + `confirmDelete`. Final `actions` in `en.json`: `{ "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }`; in `sv.json`: `{ "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }`. + +- [ ] **Step 2: Write the failing test** `web/src/objects/delete-object-dialog.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { DeleteObjectDialog } from "./delete-object-dialog"; + +function tree() { + return ( + + } /> + objects list
    } /> + + ); +} + +test("confirm delete: DELETE then navigate to the list", async () => { + let deleted = false; + server.use(http.delete("/api/admin/objects/:id", () => { deleted = true; return new HttpResponse(null, { status: 204 }); })); + renderApp(tree(), { route: "/objects/o-1" }); + await userEvent.click(await screen.findByRole("button", { name: /delete/i })); + // confirm in the dialog + await userEvent.click(await screen.findByRole("button", { name: /^delete$/i })); + await waitFor(() => expect(screen.getByText("objects list")).toBeInTheDocument()); + expect(deleted).toBe(true); +}); +``` +(If the trigger and confirm buttons collide on the `/delete/i` name, give the trigger an explicit accessible name like the icon-button `aria-label` and match the confirm with an exact `^Delete$` — adapt the queries so the test unambiguously clicks trigger then confirm.) + +- [ ] **Step 3: Implement** — `web/src/objects/delete-object-dialog.tsx` + +```tsx +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useDeleteObject } from "../api/queries"; +import { + AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; + +export function DeleteObjectDialog({ id }: { id: string }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const del = useDeleteObject(); + + const onConfirm = async () => { + await del.mutateAsync(id); + navigate("/objects"); + }; + + return ( + + + + + + {t("actions.delete")} + {t("actions.confirmDelete")} + + {t("form.cancel")} + {t("actions.delete")} + + + + ); +} +``` +(Adapt the import names to the shadcn alert-dialog the CLI generated — Base UI registry may name parts slightly differently. The contract: a trigger button labelled Delete, a confirm action that calls `useDeleteObject().mutateAsync(id)` then navigates to `/objects`. If trigger/confirm both match `/delete/i`, set the trigger's text via `actions.delete` and the dialog action via the same key — disambiguate in the test by dialog role/scoping, or give the trigger an `aria-label`.) + +- [ ] **Step 4: Add Delete to detail** — in `web/src/objects/object-detail.tsx`, render `` in the header actions next to Edit. + +- [ ] **Step 5: Run** — `pnpm test src/objects/delete-object-dialog.test.tsx` → PASS. Full suite / typecheck / lint / build clean. + +- [ ] **Step 6: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): delete object with confirm dialog" +``` + +--- + +## Task 7: Final wiring + full verification + +**Files:** +- Modify: any remaining wiring; `web/src/objects/object-detail.tsx` (confirm Edit + Delete actions present) + +- [ ] **Step 1: Confirm the detail header has both actions** — `object-detail.tsx` shows the object, an `Edit` link to `/objects/:id/edit`, and the `DeleteObjectDialog`. Adjust layout so they sit together; keep the visibility badge. + +- [ ] **Step 2: i18n parity check** — confirm `en.json` and `sv.json` have identical key sets (the new `form.*` and `actions.*` namespaces in both). Run a quick check: +```bash +cd web +node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))" +``` +Expected: `PARITY OK`. Fix any mismatch. + +- [ ] **Step 3: Full verification** + +```bash +cd web +pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size +``` +Expected: typecheck/lint clean; all tests pass; build clean; bundle ≤150 KB gz (report the number — adding RHF + shadcn select/checkbox/alert-dialog should stay under; if it exceeds, lazy-load the new-object page and edit form via `React.lazy` in `app.tsx`). + +- [ ] **Step 4: Manual smoke (optional, recommended)** — with the backend running + a Spectrum-seeded field set (or a manually created field definition) + an admin user: `pnpm dev`, create an object with a flexible field, edit it, delete it; switch sv/en. + +- [ ] **Step 5: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): wire object authoring actions into detail; i18n parity" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** +- New (full-width `/objects/new`) + create flow → Task 4. ✓ +- Edit (in-pane `/objects/:id/edit`) + nested routes + edit flow → Task 5. ✓ +- Delete + confirm dialog → Task 6. ✓ +- Dynamic flexible-field form, all 7 types (incl. term/authority Selects, sv/en localized_text) → Tasks 2–3. ✓ +- Client validation (required, integer, date) + form-level 422 + partial-create recovery → Tasks 3, 4, 5. ✓ +- Mutations + option hooks + invalidation → Task 1. ✓ +- Visibility only Draft/Internal on create, never on edit → Task 3 (test asserts). ✓ +- i18n sv/en parity → Task 7 (+ keys added per task). ✓ +- Testing Vitest+RTL+MSW → Tasks 1–6. ✓ +- Bundle budget → Task 7 (check:size). ✓ + +**Placeholder scan:** none — every code step shows complete code; the "adapt to the generated/shadcn API" notes are verification instructions with concrete contracts, not deferred work. + +**Type consistency:** `ObjectFormValues`/`ObjectCore` defined in Task 3 and consumed in Tasks 4–5; hooks `useTerms`/`useAuthorities`/`useCreateObject`/`useUpdateObject`/`useSetFields`/`useDeleteObject` defined in Task 1 and used in 2/4/5/6; `FieldInput` (Task 2) consumed by `ObjectForm` (Task 3); the `fields.` RHF path shape is consistent across FieldInput and ObjectForm; route shapes (`/objects/new`, `/objects/:id`, `/objects/:id/edit`) consistent across Tasks 4–6. + +## Notes for follow-on +- Searchable combobox + server-side term search for large vocabularies (deferred). +- Per-field server error mapping requires the backend `set_fields` 422 to carry field detail (currently bare). +- `fields` map cast (`Record`) remains pending issue #24. +- The native `