diff --git a/apps/studio/ui/src/app/App.vue b/apps/studio/ui/src/app/App.vue
index af4bbcd..a403503 100644
--- a/apps/studio/ui/src/app/App.vue
+++ b/apps/studio/ui/src/app/App.vue
@@ -4,7 +4,9 @@ import { RouterView, useRoute } from 'vue-router'
import DialogHost from '@/features/dialogs/DialogHost.vue'
import { useDialog } from '@/features/dialogs/use-dialog'
-import { setAPIDialogHandler } from '@/features/api/client'
+import ToastHost from '@/features/toasts/ToastHost.vue'
+import { useToast } from '@/features/toasts/use-toast'
+import { setAPIDialogHandler, setAPIToastHandler } from '@/features/api/client'
import { iconForEntity } from '@/features/metadata/entity-icons'
import { routeParam, RouteName } from '@/router/routes'
import { useMetadataEntitiesQuery } from '@/features/metadata/metadata.query'
@@ -18,12 +20,17 @@ const route = useRoute()
const authStore = useAuthStore()
const navigationStore = useNavigationStore()
const dialog = useDialog()
+const toast = useToast()
setAPIDialogHandler((request) => {
void dialog.open(request)
})
+setAPIToastHandler((request) => {
+ toast.show(request)
+})
onUnmounted(() => {
setAPIDialogHandler(null)
+ setAPIToastHandler(null)
})
const usesShell = computed(() => !route.meta.public)
@@ -117,6 +124,7 @@ function humanizeEntity(value: string): string {
+
diff --git a/apps/studio/ui/src/features/toasts/toasts.store.test.ts b/apps/studio/ui/src/features/toasts/toasts.store.test.ts
new file mode 100644
index 0000000..d4ad1fb
--- /dev/null
+++ b/apps/studio/ui/src/features/toasts/toasts.store.test.ts
@@ -0,0 +1,35 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createPinia, setActivePinia } from 'pinia'
+
+import { useToastStore } from './toasts.store.ts'
+
+test('toast store defaults and dismisses toasts', () => {
+ setActivePinia(createPinia())
+ const store = useToastStore()
+
+ store.show({ title: 'Saved' })
+ assert.equal(store.toasts.length, 1)
+ assert.equal(store.toasts[0].type, 'info')
+ assert.equal(store.toasts[0].duration, 4000)
+
+ store.dismiss(store.toasts[0].id)
+ assert.equal(store.toasts.length, 0)
+})
+
+test('toast store validates title and duration', () => {
+ setActivePinia(createPinia())
+ const store = useToastStore()
+
+ assert.throws(() => store.show({ title: '' }), /toast title is required/)
+ assert.throws(() => store.show({ title: 'Bad', duration: -1 }), /duration/)
+})
+
+test('toast store keeps zero duration sticky', () => {
+ setActivePinia(createPinia())
+ const store = useToastStore()
+
+ store.show({ title: 'Stay open', duration: 0 })
+
+ assert.equal(store.toasts[0].duration, 0)
+})
diff --git a/apps/studio/ui/src/features/toasts/toasts.store.ts b/apps/studio/ui/src/features/toasts/toasts.store.ts
new file mode 100644
index 0000000..c42c616
--- /dev/null
+++ b/apps/studio/ui/src/features/toasts/toasts.store.ts
@@ -0,0 +1,62 @@
+import { defineStore } from 'pinia'
+
+const defaultDuration = 4000
+
+export type StudioToastType = 'info' | 'success' | 'warning' | 'danger'
+
+export type StudioToastRequest = {
+ title: string
+ content?: string
+ type?: StudioToastType
+ duration?: number
+}
+
+export type StudioToast = Required & {
+ id: number
+}
+
+let nextToastID = 1
+const toastTimers = new Map>()
+
+export const useToastStore = defineStore('toasts', {
+ state: () => ({
+ toasts: [] as StudioToast[],
+ }),
+
+ actions: {
+ show(request: StudioToastRequest) {
+ const toast = normalizeToast(request)
+ this.toasts.push(toast)
+ if (toast.duration > 0) {
+ toastTimers.set(toast.id, setTimeout(() => this.dismiss(toast.id), toast.duration))
+ }
+ },
+
+ dismiss(id: number) {
+ const timer = toastTimers.get(id)
+ if (timer) {
+ clearTimeout(timer)
+ toastTimers.delete(id)
+ }
+ this.toasts = this.toasts.filter((toast) => toast.id !== id)
+ },
+ },
+})
+
+function normalizeToast(request: StudioToastRequest): StudioToast {
+ const title = request.title.trim()
+ if (!title) {
+ throw new Error('toast title is required')
+ }
+ if (request.duration !== undefined && request.duration < 0) {
+ throw new Error('toast duration must not be negative')
+ }
+
+ return {
+ id: nextToastID++,
+ title,
+ content: request.content?.trim() ?? '',
+ type: request.type ?? 'info',
+ duration: request.duration ?? defaultDuration,
+ }
+}
diff --git a/apps/studio/ui/src/features/toasts/use-toast.ts b/apps/studio/ui/src/features/toasts/use-toast.ts
new file mode 100644
index 0000000..7b420a0
--- /dev/null
+++ b/apps/studio/ui/src/features/toasts/use-toast.ts
@@ -0,0 +1,15 @@
+import { useToastStore, type StudioToastRequest } from './toasts.store'
+
+export function useToast() {
+ const store = useToastStore()
+
+ return {
+ show: (request: StudioToastRequest) => store.show(request),
+ success: (title: string, content?: string) => store.show({ title, content, type: 'success' }),
+ error: (title: string, content?: string) => store.show({ title, content, type: 'danger' }),
+ warning: (title: string, content?: string) => store.show({ title, content, type: 'warning' }),
+ info: (title: string, content?: string) => store.show({ title, content, type: 'info' }),
+ }
+}
+
+export type { StudioToastRequest }
diff --git a/apps/studio/ui/src/pages/RecordFormPage.vue b/apps/studio/ui/src/pages/RecordFormPage.vue
index a50a71e..bb140a9 100644
--- a/apps/studio/ui/src/pages/RecordFormPage.vue
+++ b/apps/studio/ui/src/pages/RecordFormPage.vue
@@ -5,6 +5,7 @@ import { Plus, RotateCcw, Save, Trash2 } from '@lucide/vue'
import { ErrorState, Spinner } from '@/design'
import { useDialog } from '@/features/dialogs/use-dialog'
+import { useToast } from '@/features/toasts/use-toast'
import type { MetadataEntityMeta, MetadataField } from '@/features/metadata/metadata.api'
import { useMetadataEntityMetaQuery } from '@/features/metadata/metadata.query'
import {
@@ -37,6 +38,7 @@ type ConvertedValue = {
const router = useRouter()
const dialog = useDialog()
+const toast = useToast()
const entityMetaQuery = useMetadataEntityMetaQuery(() => props.entity)
const draft = ref({})
@@ -314,6 +316,7 @@ async function saveRecord() {
})
resetToRecord(record)
+ toast.success(isNew.value ? 'Record created' : 'Record saved')
const nextName = typeof record.name === 'string' ? record.name : ''
if (!isSingle.value && nextName && (isNew.value || nextName !== props.recordName)) {
await router.replace({ name: RouteName.RecordDetail, params: { entity: props.entity, recordName: nextName } })
@@ -351,6 +354,7 @@ async function deleteRecord() {
recordName: props.recordName ?? '',
id: currentRecordID(),
})
+ toast.success('Record deleted')
await router.replace({ name: RouteName.EntityRecords, params: { entity: props.entity } })
} catch {
// TanStack owns the mutation error for display.
diff --git a/docs/index.md b/docs/index.md
index 791a285..cb39628 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -44,6 +44,7 @@ Use this index to find the right document for the task. The docs are kept in the
- [Studio](studio.md) explains the first-party global UI app, route model, and design responsibilities.
- [Dialogs](dialogs.md) records the proposed shared Studio dialog API.
+- [Toasts](toasts.md) records the proposed shared Studio toast API.
## Maintainers
diff --git a/docs/toasts.md b/docs/toasts.md
new file mode 100644
index 0000000..410529c
--- /dev/null
+++ b/docs/toasts.md
@@ -0,0 +1,87 @@
+# Toasts
+
+Status: v1 decision log.
+
+Studio needs a small non-blocking message surface for short-lived feedback.
+
+## Decided
+
+- Use `toast` as the public Studio term.
+- Use `useToast()` as the frontend API name.
+- Toasts are transient, non-blocking messages.
+- Dialogs stay blocking; toasts do not replace dialogs.
+- Persistent system items are future notifications, not v1 toasts.
+- Toasts render in the bottom-right corner.
+- Toasts stack newest-first.
+- Newest toast appears closest to the bottom-right edge; older toasts move upward.
+- Toasts auto-dismiss by default.
+- Toasts can be manually dismissed.
+- Manual dismissal means the user closes the toast; v1 does not expose programmatic close handles.
+- V1 toast types are `info`, `success`, `warning`, and `danger`.
+- V1 toast requests require `title`.
+- V1 toast requests may include `content`, `type`, and `duration`.
+- Empty `title` is invalid.
+- Empty `content` is allowed.
+- `type` defaults to `info`.
+- `duration` is milliseconds.
+- Missing `duration` uses the Studio default duration.
+- Default duration is `4000` ms.
+- `duration: 0` means sticky until dismissed.
+- Negative `duration` is invalid.
+- V1 toasts do not include actions.
+- If a message needs required user action, use a dialog.
+- `useToast()` exposes `show`, `success`, `error`, `warning`, and `info`.
+- `show` accepts the full toast request shape.
+- `success`, `error`, `warning`, and `info` are convenience methods for common toast types.
+- `error` creates a `danger` toast.
+- Server responses may include a single toast intent in v1.
+- Server `toast` is best-effort and must not change API request semantics.
+- Use `dygo.Toast` as the public SDK type name.
+- Server response envelopes should use a single `toast` field in v1.
+- Success responses put `toast` beside `data`.
+- Error responses put `toast` inside the existing `error` envelope.
+- Do not add `toasts` arrays in v1.
+- Duplicate identical toasts are allowed in v1.
+- Toasts are not persisted.
+- Toasts are not written to Activity.
+
+## Shape
+
+```ts
+type StudioToastType = "info" | "success" | "warning" | "danger"
+
+type StudioToastRequest = {
+ title: string
+ content?: string
+ type?: StudioToastType
+ duration?: number
+}
+
+type UseToast = {
+ show(request: StudioToastRequest): void
+ success(title: string, content?: string): void
+ error(title: string, content?: string): void
+ warning(title: string, content?: string): void
+ info(title: string, content?: string): void
+}
+```
+
+## SDK Shape
+
+```go
+type ToastType string
+
+const (
+ ToastInfo ToastType = "info"
+ ToastSuccess ToastType = "success"
+ ToastWarning ToastType = "warning"
+ ToastDanger ToastType = "danger"
+)
+
+type Toast struct {
+ Title string `json:"title"`
+ Content string `json:"content,omitempty"`
+ Type ToastType `json:"type,omitempty"`
+ Duration *int `json:"duration,omitempty"`
+}
+```
diff --git a/internal/server/server.go b/internal/server/server.go
index 97f53f5..1fa4815 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -428,6 +428,7 @@ func writeAuthError(w http.ResponseWriter, err error) {
type dataEnvelope struct {
Data any `json:"data"`
Dialog *dygo.Dialog `json:"dialog,omitempty"`
+ Toast *dygo.Toast `json:"toast,omitempty"`
}
type bootDefaults struct {
@@ -512,6 +513,7 @@ type listEnvelope struct {
Data any `json:"data"`
Meta any `json:"meta"`
Dialog *dygo.Dialog `json:"dialog,omitempty"`
+ Toast *dygo.Toast `json:"toast,omitempty"`
}
type recordListMeta struct {
@@ -530,6 +532,7 @@ type apiError struct {
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
Dialog *dygo.Dialog `json:"dialog,omitempty"`
+ Toast *dygo.Toast `json:"toast,omitempty"`
}
func writeErrorEnvelope(w http.ResponseWriter, status int, code string, message string, details map[string]any) {
diff --git a/pkg/dygo/dialogs.go b/pkg/dygo/dialogs.go
index 599890f..5dd0d7a 100644
--- a/pkg/dygo/dialogs.go
+++ b/pkg/dygo/dialogs.go
@@ -35,3 +35,21 @@ type Dialog struct {
Actions []DialogAction `json:"actions,omitempty"`
Dismissible *bool `json:"dismissible,omitempty"`
}
+
+// ToastType controls the visual intent of a Studio toast.
+type ToastType string
+
+const (
+ ToastInfo ToastType = "info"
+ ToastSuccess ToastType = "success"
+ ToastWarning ToastType = "warning"
+ ToastDanger ToastType = "danger"
+)
+
+// Toast is a server-provided Studio toast intent.
+type Toast struct {
+ Title string `json:"title"`
+ Content string `json:"content,omitempty"`
+ Type ToastType `json:"type,omitempty"`
+ Duration *int `json:"duration,omitempty"`
+}
diff --git a/pkg/dygo/dialogs_test.go b/pkg/dygo/dialogs_test.go
index c9e7ec2..64ca5bd 100644
--- a/pkg/dygo/dialogs_test.go
+++ b/pkg/dygo/dialogs_test.go
@@ -25,3 +25,36 @@ func TestDialogJSONIncludesExplicitFalseDismissible(t *testing.T) {
t.Fatalf("Dialog JSON = %s, want %s", data, want)
}
}
+
+func TestToastJSONIncludesDuration(t *testing.T) {
+ duration := 4000
+ data, err := json.Marshal(Toast{
+ Title: "Saved",
+ Type: ToastSuccess,
+ Duration: &duration,
+ })
+ if err != nil {
+ t.Fatalf("Marshal(Toast) error = %v", err)
+ }
+
+ want := `{"title":"Saved","type":"success","duration":4000}`
+ if string(data) != want {
+ t.Fatalf("Toast JSON = %s, want %s", data, want)
+ }
+}
+
+func TestToastJSONIncludesExplicitZeroDuration(t *testing.T) {
+ duration := 0
+ data, err := json.Marshal(Toast{
+ Title: "Stay open",
+ Duration: &duration,
+ })
+ if err != nil {
+ t.Fatalf("Marshal(Toast) error = %v", err)
+ }
+
+ want := `{"title":"Stay open","duration":0}`
+ if string(data) != want {
+ t.Fatalf("Toast JSON = %s, want %s", data, want)
+ }
+}