-
+
The selected Domain will be deleted, along with all associated settings or instances. Check the
@@ -318,7 +318,7 @@ const meta = {
defaultValue: { summary: "'Delete Domain'" }
}
},
- warningTitle: {
+ warningLabel: {
control: 'text',
description: 'Warning message shown at the top of the body.',
table: {
@@ -411,7 +411,7 @@ const meta = {
size: 'medium',
triggerLabel: 'Delete domain',
title: 'Delete Domain',
- warningTitle: "Once confirmed, this action can't be reversed.",
+ warningLabel: "Once confirmed, this action can't be reversed.",
descriptionBeforeLink:
'The selected Domain will be deleted, along with all associated settings or instances. Check the ',
descriptionAfterLink: ' for more details.',
diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json
index c44f917f9..84a9111db 100644
--- a/packages/webkit/catalog.json
+++ b/packages/webkit/catalog.json
@@ -4172,18 +4172,18 @@
"doc": "Visual severity variant (maps Error to danger)."
},
{
- "name": "title",
- "type": "string",
- "default": "—",
- "required": "yes",
- "doc": "Primary message heading."
+ "name": "size",
+ "type": "'small' | 'medium'",
+ "default": "'medium'",
+ "required": "no",
+ "doc": "Size token. Drives the banner height, inline padding, copy scale, and the trailing control sizes."
},
{
- "name": "description",
+ "name": "label",
"type": "string",
"default": "''",
"required": "no",
- "doc": "Supporting body copy below the title."
+ "doc": "Fallback message copy when the default slot is empty."
},
{
"name": "icon",
@@ -4197,7 +4197,7 @@
"type": "string",
"default": "''",
"required": "no",
- "doc": "Label for the built-in text action button; hidden when empty."
+ "doc": "Label for the built-in secondary action button; hidden when empty."
},
{
"name": "closable",
@@ -4235,10 +4235,10 @@
{
"name": "default",
"scope": "—",
- "notes": "Replaces the default icon + title + description layout."
+ "notes": "Message copy. Accepts inline content — plain text plus anchors, which the message region styles with the `.text-link` token. Falls back to `label` when empty."
}
],
- "purpose": "Inline feedback banner that communicates status, alerts, or progress. Presents a severity-colored surface with icon, title, optional description, and an optional text action aligned to Figma Message (478:892)."
+ "purpose": "Inline feedback banner that communicates status, alerts, or progress. Presents a severity-colored surface with a leading icon and a single line of message copy that may carry inline links, plus an optional secondary action button."
},
"empty-state": {
"import": "@aziontech/webkit/empty-state",
diff --git a/packages/webkit/cli-templates/claude/skills/webkit-form/SKILL.md b/packages/webkit/cli-templates/claude/skills/webkit-form/SKILL.md
index 31f0dd16d..720df57a9 100644
--- a/packages/webkit/cli-templates/claude/skills/webkit-form/SKILL.md
+++ b/packages/webkit/cli-templates/claude/skills/webkit-form/SKILL.md
@@ -259,11 +259,11 @@ link on the left and Save on the right. Canonical pattern:
-
+
+ To use the toolbar in production your team members need the Chrome extension, or
+ enable the toolbar for that domain
+ in the toolbar menu.
+
@@ -338,7 +338,7 @@ Canonical pattern:
The selected Application will be deleted, along with all associated settings or
diff --git a/packages/webkit/cli-templates/claude/skills/webkit-ui-states/SKILL.md b/packages/webkit/cli-templates/claude/skills/webkit-ui-states/SKILL.md
index 487fe16b0..d13112982 100644
--- a/packages/webkit/cli-templates/claude/skills/webkit-ui-states/SKILL.md
+++ b/packages/webkit/cli-templates/claude/skills/webkit-ui-states/SKILL.md
@@ -163,7 +163,7 @@ The state-matrix render — Skeleton (data in), then Message / EmptyState / data
; darken the pill text token; add EmptyState to the no-data branch.
+fix: pass :label to ; darken the pill text token; add EmptyState to the no-data branch.
```
End with: `verified — ship` or `N findings — fix before ship`.
diff --git a/packages/webkit/src/components/feedback/message/message.test.ts b/packages/webkit/src/components/feedback/message/message.test.ts
index 6f05fa855..ee332030a 100644
--- a/packages/webkit/src/components/feedback/message/message.test.ts
+++ b/packages/webkit/src/components/feedback/message/message.test.ts
@@ -15,31 +15,79 @@ const { Default } = composeStories(stories)
const realTransition = { global: { stubs: { transition: false } } }
describe('Message', () => {
- it('renders the root with the default testid, status role, and title/description', () => {
+ it('renders the root with the default testid, status role, and the label copy', () => {
const { getByTestId } = render(Message, {
- props: { severity: 'info', title: 'Info title', description: 'Some details' }
+ props: { severity: 'info', label: 'Deployment finished.' }
})
const root = getByTestId('feedback-message')
expect(root).toBeTruthy()
expect(root.getAttribute('role')).toBe('status')
expect(root.getAttribute('data-severity')).toBe('info')
- expect(getByTestId('feedback-message__title').textContent).toContain('Info title')
- expect(getByTestId('feedback-message__description').textContent).toContain('Some details')
+ expect(getByTestId('feedback-message__content').textContent).toContain('Deployment finished.')
})
- it('omits the description node when description is empty', () => {
- const { queryByTestId } = render(Message, {
- props: { title: 'Only a title' }
+ it('lets a consumer data-testid win over the derived fallback', () => {
+ const { getByTestId, queryByTestId } = render(Message, {
+ props: { label: 'Custom testid' },
+ attrs: { 'data-testid': 'my-banner' }
})
- expect(queryByTestId('feedback-message__title')).toBeTruthy()
- expect(queryByTestId('feedback-message__description')).toBeNull()
+ expect(getByTestId('my-banner')).toBeTruthy()
+ expect(queryByTestId('feedback-message')).toBeNull()
+ })
+
+ it('renders the content region even when label is empty', () => {
+ const { getByTestId } = render(Message, { props: {} })
+
+ expect(getByTestId('feedback-message__content').textContent?.trim()).toBe('')
+ })
+
+ describe('size', () => {
+ it('defaults to medium on the root and the content region', () => {
+ const { getByTestId } = render(Message, { props: { label: 'Default size' } })
+
+ expect(getByTestId('feedback-message').getAttribute('data-size')).toBe('medium')
+ expect(getByTestId('feedback-message__content').getAttribute('data-size')).toBe('medium')
+ })
+
+ it.each(['small', 'medium'] as const)('maps size=%s onto data-size', (size) => {
+ const { getByTestId } = render(Message, { props: { size, label: `Size ${size}` } })
+
+ expect(getByTestId('feedback-message').getAttribute('data-size')).toBe(size)
+ expect(getByTestId('feedback-message__content').getAttribute('data-size')).toBe(size)
+ })
+ })
+
+ describe('trailing-control flag', () => {
+ it('is absent when the banner carries no action or close control', () => {
+ const { getByTestId } = render(Message, { props: { label: 'Bare' } })
+
+ expect(getByTestId('feedback-message').hasAttribute('data-trailing')).toBe(false)
+ })
+
+ it.each([
+ ['actionLabel', { label: 'x', actionLabel: 'Retry' }],
+ ['closable', { label: 'x', closable: true }]
+ ])('is set when %s is provided', (_name, props) => {
+ const { getByTestId } = render(Message, { props })
+
+ expect(getByTestId('feedback-message').hasAttribute('data-trailing')).toBe(true)
+ })
+
+ it('is set when the action slot is filled', () => {
+ const { getByTestId } = render(Message, {
+ props: { label: 'x' },
+ slots: { action: 'Go ' }
+ })
+
+ expect(getByTestId('feedback-message').hasAttribute('data-trailing')).toBe(true)
+ })
})
it('normalizes severity="error" to danger and uses the alert role', () => {
const { getByTestId } = render(Message, {
- props: { severity: 'error', title: 'Boom' }
+ props: { severity: 'error', label: 'Boom' }
})
const root = getByTestId('feedback-message')
@@ -49,7 +97,7 @@ describe('Message', () => {
it('uses the alert role for warning severity', () => {
const { getByTestId } = render(Message, {
- props: { severity: 'warning', title: 'Careful' }
+ props: { severity: 'warning', label: 'Careful' }
})
const root = getByTestId('feedback-message')
@@ -57,9 +105,9 @@ describe('Message', () => {
expect(root.getAttribute('role')).toBe('alert')
})
- it('applies an icon override on the leading glyph', () => {
+ it('renders the leading icon on every severity, and applies an icon override', () => {
const { getByTestId } = render(Message, {
- props: { title: 'With icon', icon: 'pi pi-star' }
+ props: { label: 'With icon', icon: 'pi pi-star' }
})
const glyph = getByTestId('feedback-message').querySelector('i')
@@ -68,10 +116,57 @@ describe('Message', () => {
expect(glyph?.getAttribute('aria-hidden')).toBe('true')
})
+ describe('message content', () => {
+ it('renders default-slot content in place of the label', () => {
+ const { getByTestId } = render(Message, {
+ props: { label: 'Ignored label' },
+ slots: { default: 'Slot copy' }
+ })
+
+ const content = getByTestId('feedback-message__content')
+ expect(content.textContent).toContain('Slot copy')
+ expect(content.textContent).not.toContain('Ignored label')
+ })
+
+ it('keeps the leading icon when the default slot is used', () => {
+ const { getByTestId } = render(Message, {
+ slots: { default: 'Slot copy' }
+ })
+
+ expect(getByTestId('feedback-message').querySelector('i')).toBeTruthy()
+ })
+
+ it('renders inline anchors from the default slot as real, focusable links', async () => {
+ const { getByTestId } = render(Message, {
+ props: { severity: 'warning' },
+ slots: {
+ default: 'Close to the limit. Upgrade the plan to continue.'
+ }
+ })
+
+ const content = getByTestId('feedback-message__content')
+ const anchor = content.querySelector('a')
+
+ expect(anchor).toBeTruthy()
+ expect(anchor?.getAttribute('href')).toBe('/billing')
+ expect(anchor?.textContent).toContain('Upgrade the plan')
+
+ // The anchor is reachable by keyboard — it is a real link, not inert markup.
+ anchor?.focus()
+ expect(document.activeElement).toBe(anchor)
+ })
+
+ // The visual side of the inline-link contract (`.text-link` colour + underline,
+ // applied via the content region's descendant-anchor rules) is NOT asserted here:
+ // this env loads no theme CSS and runs no Tailwind, so a computed-style check
+ // would only read the browser's default anchor styling and pass either way.
+ // It is verified in Storybook (the WithLinks story) per src/test/setup.ts.
+ })
+
describe('action button', () => {
it('renders the action button when actionLabel is set and emits "action" with the click event on click', async () => {
const { getByTestId, emitted } = render(Message, {
- props: { title: 'Has action', actionLabel: 'Retry' }
+ props: { label: 'Has action', actionLabel: 'Retry' }
})
const action = getByTestId('feedback-message__action')
@@ -86,9 +181,19 @@ describe('Message', () => {
it('does not render the action button when actionLabel is empty', () => {
const { queryByTestId } = render(Message, {
- props: { title: 'No action' }
+ props: { label: 'No action' }
+ })
+
+ expect(queryByTestId('feedback-message__action')).toBeNull()
+ })
+
+ it('replaces the built-in button with the action slot', () => {
+ const { getByText, queryByTestId } = render(Message, {
+ props: { label: 'Custom action', actionLabel: 'Ignored' },
+ slots: { action: 'Do it ' }
})
+ expect(getByText('Do it')).toBeTruthy()
expect(queryByTestId('feedback-message__action')).toBeNull()
})
})
@@ -96,7 +201,7 @@ describe('Message', () => {
describe('close / dismiss', () => {
it('renders a close control when closable and emits "close" after the dismiss click', async () => {
const { getByTestId, queryByTestId, emitted } = render(Message, {
- props: { title: 'Dismiss me', closable: true },
+ props: { label: 'Dismiss me', closable: true },
...realTransition
})
@@ -117,7 +222,7 @@ describe('Message', () => {
it('does not render a close control when not closable', () => {
const { queryByTestId } = render(Message, {
- props: { title: 'Persistent' }
+ props: { label: 'Persistent' }
})
expect(queryByTestId('feedback-message__close')).toBeNull()
@@ -125,7 +230,7 @@ describe('Message', () => {
it('dismisses on Escape when closable, emitting "close"', async () => {
const { getByTestId, emitted } = render(Message, {
- props: { title: 'Escapable', closable: true },
+ props: { label: 'Escapable', closable: true },
...realTransition
})
@@ -139,7 +244,7 @@ describe('Message', () => {
it('does not dismiss on Escape when not closable', async () => {
const { getByTestId, emitted } = render(Message, {
- props: { title: 'No escape' }
+ props: { label: 'No escape' }
})
const root = getByTestId('feedback-message')
@@ -150,17 +255,17 @@ describe('Message', () => {
expect(emitted('close')).toBeUndefined()
expect(getByTestId('feedback-message')).toBeTruthy()
})
- })
- it('renders default-slot content in place of the title/description block', () => {
- const { getByText, queryByTestId } = render(Message, {
- props: { title: 'Ignored title', description: 'Ignored description' },
- slots: { default: 'Custom slot body' }
- })
+ it('auto-dismisses once life expires', async () => {
+ const { emitted } = render(Message, {
+ props: { label: 'Fleeting', life: 60 },
+ ...realTransition
+ })
- expect(getByText('Custom slot body')).toBeTruthy()
- expect(queryByTestId('feedback-message__title')).toBeNull()
- expect(queryByTestId('feedback-message__description')).toBeNull()
+ await waitFor(() => {
+ expect(emitted('close')).toHaveLength(1)
+ })
+ })
})
describe('accessibility', () => {
@@ -168,8 +273,7 @@ describe('Message', () => {
const { container } = render(Message, {
props: {
severity: 'info',
- title: 'Accessible info',
- description: 'A brief description of the message.',
+ label: 'Your workload finished deploying in 42 seconds.',
actionLabel: 'Label',
closable: true
}
@@ -180,10 +284,18 @@ describe('Message', () => {
it('has no a11y violations for the danger alert variant', async () => {
const { container } = render(Message, {
- props: {
- severity: 'danger',
- title: 'Accessible alert',
- description: 'Something went wrong.'
+ props: { severity: 'danger', label: 'The last deploy failed before reaching the edge.' }
+ })
+
+ await expectNoA11yViolations(container)
+ })
+
+ it('has no a11y violations for copy carrying inline links', async () => {
+ const { container } = render(Message, {
+ props: { severity: 'warning' },
+ slots: {
+ default:
+ 'This workload is close to its request limit. Review the usage report to avoid throttling.'
}
})
@@ -195,7 +307,7 @@ describe('Message', () => {
'renders severity=%s with a data-severity attribute',
(severity) => {
const { getByTestId } = render(Message, {
- props: { severity, title: `Severity ${severity}` }
+ props: { severity, label: `Severity ${severity}` }
})
const expected = severity === 'error' ? 'danger' : severity
@@ -207,6 +319,8 @@ describe('Message', () => {
const { getByTestId } = render(Default())
expect(getByTestId('feedback-message')).toBeTruthy()
- expect(getByTestId('feedback-message__title').textContent).toContain('Info message')
+ expect(getByTestId('feedback-message__content').textContent).toContain(
+ 'Your workload finished deploying in 42 seconds.'
+ )
})
})
diff --git a/packages/webkit/src/components/feedback/message/message.vue b/packages/webkit/src/components/feedback/message/message.vue
index 673583c3d..e1271e324 100644
--- a/packages/webkit/src/components/feedback/message/message.vue
+++ b/packages/webkit/src/components/feedback/message/message.vue
@@ -10,6 +10,7 @@
} from './presets/transitions'
export type MessageSeverity = 'info' | 'success' | 'warning' | 'danger' | 'error'
+ export type MessageSize = 'small' | 'medium'
defineOptions({
name: 'Message',
@@ -19,13 +20,13 @@
interface Props {
/** Visual severity variant (maps Error to danger). */
severity?: MessageSeverity
- /** Primary message heading. */
- title: string
- /** Supporting body copy below the title. */
- description?: string
+ /** Size token. Drives the banner height, inline padding, copy scale, and the trailing control sizes. */
+ size?: MessageSize
+ /** Fallback message copy when the default slot is empty. */
+ label?: string
/** PrimeIcons class override for the leading icon. */
icon?: string
- /** Label for the built-in text action button; hidden when empty. */
+ /** Label for the built-in secondary action button; hidden when empty. */
actionLabel?: string
/** When true, shows a close control that dismisses the message. */
closable?: boolean
@@ -35,7 +36,8 @@
const props = withDefaults(defineProps(), {
severity: 'info',
- description: '',
+ size: 'medium',
+ label: '',
icon: '',
actionLabel: '',
closable: false,
@@ -47,7 +49,7 @@
close: []
}>()
- defineSlots<{
+ const slots = defineSlots<{
default(): unknown
action(): unknown
}>()
@@ -86,6 +88,12 @@
const resolvedIcon = computed(() => props.icon || defaultIcons[normalizedSeverity.value])
+ // A trailing control (action button or close) brings its own inline padding, so the
+ // banner tightens its end edge to keep the optical inset even.
+ const hasTrailingControl = computed(() =>
+ Boolean(props.actionLabel || slots.action || props.closable)
+ )
+
const role = computed(() =>
normalizedSeverity.value === 'danger' || normalizedSeverity.value === 'warning'
? 'alert'
@@ -158,40 +166,32 @@
:role="role"
:data-testid="testId"
:data-severity="normalizedSeverity"
+ :data-size="size"
+ :data-trailing="hasTrailingControl || null"
:style="dismissTransitionStyle"
tabindex="-1"
- class="flex min-h-14 items-center gap-[var(--spacing-sm)] rounded-[var(--shape-button)] border border-[length:var(--border-width-default,1px)] p-[var(--spacing-sm)] shadow-[var(--shadow-xs)] data-[severity=info]:border-[var(--info-border)] data-[severity=info]:bg-[var(--info)] data-[severity=success]:border-[var(--success-border)] data-[severity=success]:bg-[var(--success)] data-[severity=warning]:border-[var(--warning-border)] data-[severity=warning]:bg-[var(--warning)] data-[severity=danger]:border-[var(--danger-border)] data-[severity=danger]:bg-[var(--danger)]"
+ class="relative box-border flex w-full flex-wrap items-center gap-[var(--spacing-sm)] break-words rounded-[var(--shape-button)] border border-[length:var(--border-width-default,1px)] py-1.5 shadow-[var(--shadow-xs)] data-[size=small]:min-h-8 data-[size=small]:px-[var(--spacing-xs)] data-[size=medium]:min-h-9 data-[size=medium]:px-[var(--spacing-sm)] data-[trailing]:pr-[var(--spacing-xs)] data-[severity=info]:border-[var(--info-border)] data-[severity=info]:bg-[var(--info)] data-[severity=success]:border-[var(--success-border)] data-[severity=success]:bg-[var(--success)] data-[severity=warning]:border-[var(--warning-border)] data-[severity=warning]:bg-[var(--warning)] data-[severity=danger]:border-[var(--danger-border)] data-[severity=danger]:bg-[var(--danger)]"
@keydown="handleEscape"
>
-
-
-
-
-
- {{ title }}
-
-
- {{ description }}
-
-
-
+
+
+
+ {{ label }}
+