refactor(*): new button

This commit is contained in:
Neko Ayaka
2026-08-04 03:24:32 +08:00
parent faf96ef337
commit 1226417768
90 changed files with 1157 additions and 562 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { createBackgroundRemovalAdapter } from '@proj-airi/stage-ui/libs/inference/adapters/background-removal'
import { Button, Checkbox, InputFile } from '@proj-airi/ui'
import { Button, Checkbox, GhostButton, InputFile } from '@proj-airi/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const adapter = createBackgroundRemovalAdapter()
@@ -228,14 +228,14 @@ function hidePreview() {
/>
<Button
v-if="doneCount > 0"
variant="secondary"
:label="`Download All (${doneCount})`"
icon="i-solar:download-minimalistic-bold"
@click="downloadAllImages"
/>
<Button
<GhostButton
v-if="imageItems.length > 0"
variant="secondary-muted"
label="Clear All"
icon="i-solar:trash-bin-trash-line-duotone"
@click="clearAllImages"
@@ -343,11 +343,12 @@ function hidePreview() {
@click="downloadImage(index)"
/>
<Button
variant="danger"
size="sm"
icon="i-solar:trash-bin-trash-bold"
:disabled="item.status === 'processing'"
@click="removeImage(index)"
color="red"
variant="primary" @click="removeImage(index)"
/>
</div>
</td>
@@ -3,7 +3,7 @@ import { CapacitorBarcodeScanner, CapacitorBarcodeScannerTypeHint } from '@capac
import { errorMessageFrom } from '@moeru/std'
import { parseServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { Button } from '@proj-airi/ui'
import { GhostButton } from '@proj-airi/ui'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
@@ -56,8 +56,8 @@ async function scanServerChannelQrCode() {
{{ t('settings.pages.connection.qr-scan.description') }}
</p>
</div>
<Button
variant="secondary-muted"
<GhostButton
:loading="scanning"
:label="t('settings.pages.connection.qr-scan.action')"
@click="scanServerChannelQrCode"
+7 -1
View File
@@ -45,6 +45,7 @@ import { setupCaptionWindowManager } from './windows/caption'
import { setupChatWindowReusableFunc } from './windows/chat'
import { isDesktopOverlayEnabled, setupDesktopOverlayWindow } from './windows/desktop-overlay'
import { setupDevtoolsWindow } from './windows/devtools'
import { setupEditorWindowManager } from './windows/editor'
import { setupMainWindow } from './windows/main'
import { setupNoticeWindowManager } from './windows/notice'
import { setupOnboardingWindowManager } from './windows/onboarding'
@@ -212,6 +213,11 @@ app.whenReady().then(async () => {
build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn),
})
const editorWindow = injeca.provide('windows:editor', {
dependsOn: { serverChannel, i18n },
build: ({ dependsOn }) => setupEditorWindowManager(dependsOn),
})
const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut, spotlightWindow },
build: async ({ dependsOn }) =>
@@ -222,7 +228,7 @@ app.whenReady().then(async () => {
})
const mainWindow = injeca.provide('windows:main', {
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager },
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager },
build: async ({ dependsOn }) => setupMainWindow({
...dependsOn,
onWindowCreated: (window) => {
@@ -0,0 +1,71 @@
import type { BrowserWindow } from 'electron'
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import { join, resolve } from 'node:path'
import { BrowserWindow as ElectronBrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared'
import { setupEditorWindowInvokes } from './rpc/index.electron'
export interface EditorWindowManager {
/** Returns the live editor window, creating it when necessary. */
getWindow: () => Promise<BrowserWindow>
/** Opens and focuses the reusable editor window. */
openWindow: () => Promise<void>
}
/**
* Creates the reusable window boundary for the Tamagotchi editor.
*
* The renderer starts at an empty Tamagotchi-owned route so the editor can
* evolve independently from the current settings window and shared pages.
*/
export function setupEditorWindowManager(params: {
i18n: I18n
serverChannel: ServerChannel
}): EditorWindowManager {
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
const reusable = createReusableWindow(async () => {
const window = new ElectronBrowserWindow({
title: 'AIRI Editor',
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
show: false,
icon,
webPreferences: {
preload: join(getElectronMainDirname(), '../preload/index.mjs'),
sandbox: false,
},
})
window.on('ready-to-show', () => window.show())
protectPrivilegedWindowNavigation(window)
await setupEditorWindowInvokes({
window,
i18n: params.i18n,
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(rendererBase, '/editor'))
return window
})
async function openWindow() {
toggleWindowShow(await reusable.getWindow())
}
return {
getWindow: reusable.getWindow,
openWindow,
}
}
@@ -0,0 +1,33 @@
import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n'
import type { ServerChannel } from '../../../services/airi/channel-server'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
/**
* Registers only the Electron services required by the empty editor shell.
* Feature-specific RPC handlers should be added here as the editor gains capabilities.
*/
export async function setupEditorWindowInvokes(params: {
window: BrowserWindow
i18n: I18n
serverChannel: ServerChannel
}) {
// TODO: Remove this once Eventa supports window-namespaced Electron contexts.
ipcMain.setMaxListeners(0)
const { context } = createContext(ipcMain, params.window)
await setupBaseWindowElectronInvokes({
context,
window: params.window,
i18n: params.i18n,
serverChannel: params.serverChannel,
})
return context
}
@@ -7,6 +7,7 @@ import type { ServerChannel } from '../../services/airi/channel-server'
import type { GodotStageManager } from '../../services/airi/godot-stage'
import type { McpStdioManager } from '../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../services/electron/auto-updater'
import type { EditorWindowManager } from '../editor'
import type { NoticeWindowManager } from '../notice'
import type { OnboardingWindowManager } from '../onboarding'
import type { SettingsWindowManager } from '../settings'
@@ -50,6 +51,7 @@ const appConfigSchema = object({
type AppConfig = InferOutput<typeof appConfigSchema>
export async function setupMainWindow(params: {
editorWindow: EditorWindowManager
settingsWindow: SettingsWindowManager
chatWindow: () => Promise<BrowserWindow>
widgetsManager: WidgetsWindowManager
@@ -175,6 +177,7 @@ export async function setupMainWindow(params: {
await setupMainWindowElectronInvokes({
window,
editorWindow: params.editorWindow,
settingsWindow: params.settingsWindow,
chatWindow: params.chatWindow,
widgetsManager: params.widgetsManager,
@@ -6,6 +6,7 @@ import type { ServerChannel } from '../../../services/airi/channel-server'
import type { GodotStageManager } from '../../../services/airi/godot-stage'
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
import type { EditorWindowManager } from '../../editor'
import type { NoticeWindowManager } from '../../notice'
import type { OnboardingWindowManager } from '../../onboarding'
import type { SettingsWindowManager } from '../../settings'
@@ -15,7 +16,7 @@ import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronCenterMainWindow, electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { electronCenterMainWindow, electronOpenChat, electronOpenEditor, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createGodotStageService } from '../../../services/airi/godot-stage'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
@@ -28,6 +29,7 @@ import { setupBaseWindowElectronInvokes } from '../../shared/window'
export async function setupMainWindowElectronInvokes(params: {
window: BrowserWindow
editorWindow: EditorWindowManager
settingsWindow: SettingsWindowManager
chatWindow: () => Promise<BrowserWindow>
widgetsManager: WidgetsWindowManager
@@ -57,6 +59,7 @@ export async function setupMainWindowElectronInvokes(params: {
defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window))
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenEditor, () => params.editorWindow.openWindow())
defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route))
defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow()))
defineInvokeHandler(context, noticeWindowEventa.openWindow, payload => params.noticeWindow.open(payload))
+3 -2
View File
@@ -71,7 +71,8 @@ const setLocale = useElectronEventaInvoke(i18nSetLocale)
const initialWindowRoutePath = resolveInitialChatSyncRoutePath(route.path)
const chatSyncLifecycle = createChatSyncWindowLifecycle(route.path)
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
const isSettingsWindowRoute = initialWindowRoutePath.startsWith('/settings')
const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/')
const isEditorWindowRoute = initialWindowRoutePath === '/editor'
if (!isSpotlightWindowRoute)
useAuthProviderSync()
@@ -253,7 +254,7 @@ function createFullStageRuntime() {
}
}
const fullStageRuntime = isSpotlightWindowRoute ? null : createFullStageRuntime()
const fullStageRuntime = isSpotlightWindowRoute || isEditorWindowRoute ? null : createFullStageRuntime()
const { restore: restoreLocale } = useLanguage(language, getMainLocale, setLocale)
@@ -109,7 +109,7 @@ watch(hasPermissions, (nextHasPermissions, previousHasPermissions) => {
<div flex="~ row gap-2 mt-4 justify-end" w-full>
<Button
variant="secondary"
@click="showDialog = false"
>
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.dismiss') }}
@@ -1,18 +1,17 @@
<script setup lang="ts">
import { OverlayButton } from '@proj-airi/ui'
const props = defineProps<{ buttonStyle?: string }>()
</script>
<template>
<button
<OverlayButton
size="sm"
:class="[
'border-2 border-solid border-neutral-200/60 dark:border-neutral-800/10',
'bg-neutral-50/80 dark:bg-neutral-800/70',
'w-fit flex items-center self-end justify-center p-2',
'rounded-xl backdrop-blur-md',
'transition-all hover:transition-none transition-duration-300 transition-ease-out',
'p-2!',
props.buttonStyle,
]"
>
<slot />
</button>
</OverlayButton>
</template>
@@ -25,14 +25,14 @@ const { stopAllSpeaking } = useStopSpeakingButton()
v-if="nowSpeaking"
key="active"
:class="iconClass"
i-carbon:face-activated
i-ph:speaker-simple-high
text-red-500
/>
<div
v-else
key="idle"
:class="iconClass"
i-carbon:face-neutral
i-ph:speaker-none
text="neutral-800 dark:neutral-300"
/>
</Transition>
@@ -315,7 +315,7 @@ onMounted(() => {
</div>
<div>
<Button
variant="primary"
:loading="isBusy"
icon="i-solar:download-minimalistic-outline"
:label="t('tamagotchi.stage.about.update.actions.download')"
@@ -349,7 +349,8 @@ onMounted(() => {
</div>
<div>
<DoubleCheckButton
variant="primary"
color="neutral"
variant="secondary"
@confirm="handleQuitAndInstall()"
>
{{ restartButtonLabel }}
@@ -379,7 +380,8 @@ onMounted(() => {
<div :class="['flex flex-wrap gap-2']">
<Button
v-track-button="{ name: 'update_check_clicked', channel: selectedUpdateChannel }"
:variant="isError ? 'caution' : 'secondary'"
:color="isError ? 'orange' : 'neutral'"
:variant="isError ? 'primary' : 'secondary'"
:loading="isBusy"
:disabled="isDisabled"
:icon="isLatestVersion ? 'i-solar:check-circle-outline' : isDisabled ? 'i-solar:forbidden-circle-outline' : 'i-solar:refresh-outline'"
@@ -419,10 +421,10 @@ onMounted(() => {
</div>
<div class="mt-6 flex justify-end gap-3">
<Button variant="secondary" @click="showChangelog = false">
<Button @click="showChangelog = false">
{{ t('tamagotchi.stage.about.common.cancel') }}
</Button>
<Button variant="primary" icon="i-solar:download-minimalistic-outline" @click="confirmDownload">
<Button icon="i-solar:download-minimalistic-outline" @click="confirmDownload">
{{ t('tamagotchi.stage.about.update.actions.confirm-download') }}
</Button>
</div>
@@ -449,10 +451,10 @@ onMounted(() => {
</div>
<div class="mt-4 flex gap-3">
<Button variant="secondary" block @click="showChangelog = false">
<Button block @click="showChangelog = false">
{{ t('tamagotchi.stage.about.common.cancel') }}
</Button>
<Button variant="primary" block icon="i-solar:download-minimalistic-outline" @click="confirmDownload">
<Button block icon="i-solar:download-minimalistic-outline" @click="confirmDownload">
{{ t('tamagotchi.stage.about.update.actions.download-short') }}
</Button>
</div>
@@ -211,17 +211,18 @@ onUnmounted(() => {
/>
</div>
<div class="flex flex-wrap gap-3">
<Button variant="primary" :disabled="busy" @click="handleRegister">
<Button :disabled="busy" @click="handleRegister">
Register
</Button>
<Button variant="secondary" :disabled="busy" @click="refreshList">
<Button :disabled="busy" @click="refreshList">
Refresh List
</Button>
<Button
class="ml-auto"
variant="danger"
:disabled="busy"
@click="handleUnregisterAll"
color="red"
variant="primary" @click="handleUnregisterAll"
>
Unregister All
</Button>
@@ -305,7 +306,7 @@ onUnmounted(() => {
<td class="px-3 py-2 text-right">
<Button
size="sm"
variant="secondary"
:disabled="busy"
@click="handleUnregister(b.id)"
>
@@ -325,7 +326,7 @@ onUnmounted(() => {
</h3>
<Button
size="sm"
variant="secondary"
:disabled="triggers.length === 0"
@click="clearLog"
>
@@ -35,21 +35,21 @@ const diagnosticsEntries = computed(() => {
<div :class="['flex flex-col gap-4', 'pb-8']">
<div :class="['flex flex-wrap gap-2']">
<Button
variant="secondary"
:loading="isBusy"
icon="i-solar:refresh-outline"
label="Check for updates"
@click="checkForUpdates()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'available'"
icon="i-solar:download-minimalistic-outline"
label="Download update"
@click="downloadUpdate()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'downloaded'"
icon="i-solar:restart-bold-duotone"
label="Restart to install"
@@ -281,21 +281,21 @@ function applyExtensionUiPreset() {
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
:disabled="busy"
@click="applyWeatherPreset"
>
Weather Preset
</Button>
<Button
variant="secondary"
:disabled="busy"
@click="applyMapPreset"
>
Map Preset
</Button>
<Button
variant="secondary"
:disabled="busy"
@click="applyExtensionUiPreset"
>
@@ -306,21 +306,21 @@ function applyExtensionUiPreset() {
<div class="flex flex-wrap gap-3">
<Button
variant="primary"
:disabled="busy"
@click="handleAdd"
>
Spawn / Replace
</Button>
<Button
variant="secondary"
:disabled="busy"
@click="handleUpdate"
>
Update Props
</Button>
<Button
variant="secondary"
:disabled="busy"
@click="handleRemove"
>
@@ -328,9 +328,10 @@ function applyExtensionUiPreset() {
</Button>
<Button
class="ml-auto"
variant="danger"
:disabled="busy"
@click="handleClear"
color="red"
variant="primary" @click="handleClear"
>
Clear All
</Button>
@@ -0,0 +1,8 @@
<template>
<main :class="['h-full w-full', 'bg-$bg-color']" />
</template>
<route lang="yaml">
meta:
layout: plain
</route>
@@ -216,7 +216,7 @@ async function handleAction(action: 'confirm' | 'cancel' | 'close') {
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
<Button
variant="primary"
size="md"
block
:label="t('tamagotchi.stage.notice.fade-on-hover.confirm')"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { ConnectionSettings } from '@proj-airi/stage-ui/components'
import { Button, Callout, FieldCheckbox, FieldInput, Input, SelectTab } from '@proj-airi/ui'
import { Callout, FieldCheckbox, FieldInput, IconButton, Input, SelectTab } from '@proj-airi/ui'
import { refDebounced, useClipboard } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, shallowRef, watch } from 'vue'
@@ -130,22 +130,16 @@ watch(authTokenInputDebounced, (value) => {
:type="authTokenInputType"
:placeholder="t('settings.pages.connection.server-auth-token.placeholder')"
/>
<Button
<IconButton
type="button"
variant="secondary-muted"
size="sm"
shape="square"
:icon="authTokenVisible ? 'i-solar:eye-closed-bold-duotone' : 'i-solar:eye-bold-duotone'"
:aria-label="authTokenVisible ? 'Hide auth token' : 'Show auth token'"
:title="authTokenVisible ? 'Hide auth token' : 'Show auth token'"
data-testid="server-auth-token-visibility-toggle"
@click="authTokenVisible = !authTokenVisible"
/>
<Button
<IconButton
type="button"
variant="secondary-muted"
size="sm"
shape="square"
:icon="authTokenCopied ? 'i-solar:check-circle-bold-duotone' : 'i-solar:copy-line-duotone'"
:disabled="!canCopyAuthToken"
:aria-label="authTokenCopied ? 'Copied auth token' : 'Copy auth token'"
@@ -4,7 +4,7 @@ import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-chan
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { Button, Callout, Collapsible } from '@proj-airi/ui'
import { Callout, Collapsible, GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { renderSVG } from 'uqr'
import { computed, shallowRef, watch } from 'vue'
@@ -145,9 +145,9 @@ watch([hostname, tlsConfig, authToken], () => {
:class="['h-48 w-48']"
>
<Button
<GhostButton
size="sm"
variant="secondary-muted"
:loading="loading"
:label="t('settings.pages.connection.qr.refresh')"
@click="refreshPayload"
@@ -42,7 +42,7 @@ async function triggerOpenDesktopUserDataFolder() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<Button variant="secondary" @click="triggerOpenDesktopUserDataFolder">
<Button @click="triggerOpenDesktopUserDataFolder">
{{ t('settings.pages.data.sections.desktop-folder.open') }}
</Button>
</div>
@@ -57,10 +57,10 @@ async function handleCenterMainWindow() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<Button variant="caution" icon="i-solar:target-linear" @click="handleCenterMainWindow">
<Button icon="i-solar:target-linear" color="orange" variant="primary" @click="handleCenterMainWindow">
{{ t('settings.pages.data.sections.desktop.center') }}
</Button>
<DoubleCheckButton variant="caution" @confirm="resetDesktopState">
<DoubleCheckButton color="orange" @confirm="resetDesktopState">
{{ t('settings.pages.data.sections.desktop.reset') }}
<template #confirm>
{{ t('settings.pages.data.confirmations.yes') }}
@@ -16,7 +16,7 @@ import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/el
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Button, Callout } from '@proj-airi/ui'
import { Callout, GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -389,14 +389,14 @@ onUnmounted(() => {
@patch-godot-view-state="handleGodotViewPatch"
>
<template #actions>
<Button
variant="secondary"
<GhostButton
:loading="switchingGodotStage"
:toggled="usesGodotStage"
:active="usesGodotStage"
@click="handleGodotStageToggle"
>
{{ godotToggleLabel }}
</Button>
</GhostButton>
</template>
</ModelSettingsPanel>
</div>
@@ -49,7 +49,7 @@ const PANEL = 'flex flex-col gap-3 rounded-xl border-2 border-solid border-neutr
:disabled="!props.options.length"
/>
<Button
variant="secondary"
size="md"
:loading="props.running"
:disabled="props.running || !props.options.length"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Button, Callout } from '@proj-airi/ui'
import { Button, Callout, GhostButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
@@ -34,9 +34,9 @@ const JSON_TEXTAREA = 'w-full rounded-lg border-2 border-solid border-primary-10
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" icon="i-solar:folder-open-bold-duotone" :label="tn('actions.open-config')" @click="emit('openConfig')" />
<Button variant="ghost" size="sm" icon="i-solar:restart-line-duotone" :label="tn('actions.reset-draft')" @click="emit('resetDraft')" />
<Button variant="ghost" size="sm" icon="i-solar:magic-stick-3-bold-duotone" :label="tn('actions.format-json')" @click="emit('format')" />
<GhostButton size="sm" icon="i-solar:folder-open-bold-duotone" :label="tn('actions.open-config')" @click="emit('openConfig')" />
<GhostButton size="sm" icon="i-solar:restart-line-duotone" :label="tn('actions.reset-draft')" @click="emit('resetDraft')" />
<GhostButton size="sm" icon="i-solar:magic-stick-3-bold-duotone" :label="tn('actions.format-json')" @click="emit('format')" />
</div>
</div>
@@ -53,8 +53,8 @@ const JSON_TEXTAREA = 'w-full rounded-lg border-2 border-solid border-primary-10
</Callout>
<div class="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button variant="secondary" size="sm" :label="tn('actions.cancel')" @click="emit('close')" />
<Button variant="primary" size="sm" icon="i-solar:check-circle-bold-duotone" :label="tn('actions.apply-json')" @click="emit('apply')" />
<Button size="sm" :label="tn('actions.cancel')" @click="emit('close')" />
<Button size="sm" icon="i-solar:check-circle-bold-duotone" :label="tn('actions.apply-json')" @click="emit('apply')" />
</div>
</section>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ServerForm } from '../mcp-config'
import { Button, FieldInput, FieldKeyValues } from '@proj-airi/ui'
import { Button, FieldInput, FieldKeyValues, GhostButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
defineEmits<{ remove: [] }>()
@@ -55,8 +55,8 @@ const tn = (k: string) => t(`settings.pages.modules.mcp-server.${k}`)
@remove="(i) => model.envEntries.splice(i, 1)"
/>
<div class="flex justify-end">
<Button
variant="ghost" size="sm"
<GhostButton
size="sm"
icon="i-solar:add-circle-bold-duotone" :label="tn('actions.add-env')"
@click="model.envEntries.push({ key: '', value: '' })"
/>
@@ -65,9 +65,10 @@ const tn = (k: string) => t(`settings.pages.modules.mcp-server.${k}`)
<div class="flex justify-end border-t border-neutral-200/70 pt-2 dark:border-neutral-800">
<Button
variant="danger" size="sm"
size="sm"
icon="i-solar:trash-bin-2-bold-duotone" :label="tn('actions.remove')"
@click="$emit('remove')"
color="red"
variant="primary" @click="$emit('remove')"
/>
</div>
</div>
@@ -9,12 +9,7 @@ import type { ServerForm } from './mcp-config'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import {
Button,
Callout,
Checkbox,
TransitionVertical,
} from '@proj-airi/ui'
import { Button, Callout, Checkbox, GhostButton, TransitionVertical } from '@proj-airi/ui'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -358,8 +353,8 @@ onMounted(async () => {
<span class="font-medium">{{ tn('config-path') }}:</span> {{ configPath || '-' }}
</div>
<div class="flex justify-end">
<Button
variant="secondary" size="sm" :toggled="jsonOpen"
<GhostButton
size="sm" :active="jsonOpen"
:icon="jsonOpen ? 'i-solar:close-square-bold-duotone' : 'i-solar:document-text-bold-duotone'"
:label="jsonOpen ? tn('actions.close-json') : tn('actions.edit-json')"
@click="toggleJsonPanel"
@@ -474,16 +469,16 @@ onMounted(async () => {
<McpServerForm :model-value="server" @remove="removeServer(server.rowId)" />
</article>
<Button
<GhostButton
v-track-button="{ name: 'mcp_server_added' }"
variant="secondary-muted" size="md" block :disabled="isBusy"
size="md" block :disabled="isBusy"
icon="i-solar:add-circle-bold-duotone" :label="tn('actions.add-server')"
@click="addServer"
/>
</section>
<Button
variant="primary" size="md" block :disabled="isBusy" :loading="isBusy"
size="md" block :disabled="isBusy" :loading="isBusy"
icon="i-solar:rocket-2-bold-duotone" :label="restartActionLabel"
@click="applyRestartAction"
/>
@@ -35,6 +35,7 @@ export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:ele
export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electron:windows:main:devtools:open')
export const electronCenterMainWindow = defineInvokeEventa<Rectangle>('eventa:invoke:electron:windows:main:center')
export const electronOpenEditor = defineInvokeEventa<void>('eventa:invoke:electron:windows:editor:open')
export const electronOpenSettings = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:settings:open')
export const electronSettingsNavigate = defineEventa<{ route: string }>('eventa:event:electron:windows:settings:navigate')
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { createBackgroundRemovalAdapter } from '@proj-airi/stage-ui/libs/inference/adapters/background-removal'
import { Button, Checkbox, InputFile } from '@proj-airi/ui'
import { Button, Checkbox, GhostButton, InputFile } from '@proj-airi/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const adapter = createBackgroundRemovalAdapter()
@@ -228,14 +228,14 @@ function hidePreview() {
/>
<Button
v-if="doneCount > 0"
variant="secondary"
:label="`Download All (${doneCount})`"
icon="i-solar:download-minimalistic-bold"
@click="downloadAllImages"
/>
<Button
<GhostButton
v-if="imageItems.length > 0"
variant="secondary-muted"
label="Clear All"
icon="i-solar:trash-bin-trash-line-duotone"
@click="clearAllImages"
@@ -343,11 +343,12 @@ function hidePreview() {
@click="downloadImage(index)"
/>
<Button
variant="danger"
size="sm"
icon="i-solar:trash-bin-trash-bold"
:disabled="item.status === 'processing'"
@click="removeImage(index)"
color="red"
variant="primary" @click="removeImage(index)"
/>
</div>
</td>
@@ -7,24 +7,28 @@ const presetButtons = [
name: 'success',
label: 'Success',
icon: 'i-solar:check-circle-bold-duotone',
variant: 'primary',
color: 'neutral',
variant: 'secondary',
},
{
name: 'nudge',
label: 'Nudge',
icon: 'i-solar:hand-stars-bold-duotone',
variant: 'caution',
color: 'orange',
variant: 'primary',
},
{
name: 'error',
label: 'Error',
icon: 'i-solar:danger-triangle-bold-duotone',
variant: 'danger',
color: 'red',
variant: 'primary',
},
{
name: 'buzz',
label: 'Buzz',
icon: 'i-solar:alarm-bold-duotone',
color: 'neutral',
variant: 'secondary',
},
] as const
@@ -58,6 +62,7 @@ const { trigger, isSupported } = useWebHaptics({
block
size="md"
shape="rounded"
:color="preset.color"
:variant="preset.variant"
:icon="preset.icon"
@click="trigger(preset.name)"
@@ -4,7 +4,7 @@ import type { Character, CreateCharacterPayload } from '@proj-airi/stage-ui/type
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/characters'
import { CreateCharacterSchema } from '@proj-airi/stage-ui/types/character'
import { Button, FieldInput } from '@proj-airi/ui'
import { Button, FieldInput, GhostButton } from '@proj-airi/ui'
import {
DialogContent,
DialogOverlay,
@@ -291,9 +291,9 @@ const isOpen = computed({
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 p-4 dark:border-neutral-800">
<Button variant="ghost" @click="isOpen = false">
<GhostButton @click="isOpen = false">
Cancel
</Button>
</GhostButton>
<Button :loading="isSubmitting" @click="handleSubmit">
{{ character ? 'Save Changes' : 'Create' }}
</Button>
@@ -138,7 +138,7 @@ const accentClasses = computed(() => {
<Button
v-if="secondaryActionLabel"
block
variant="secondary"
@click="emit('secondaryAction')"
>
{{ secondaryActionLabel }}
+1 -1
View File
@@ -58,7 +58,7 @@ const router = useRouter()
<Button @click="router.push('/sign-in')">
Sign in
</Button>
<Button variant="secondary" @click="router.go(-1)">
<Button @click="router.go(-1)">
Go back
</Button>
</div>
@@ -237,7 +237,7 @@ onMounted(() => {
v-if="viewModel.primaryActionLabel"
:disabled="viewModel.primaryActionDisabled"
:class="['inline-flex']"
variant="secondary"
@click="openRelayUrl"
>
{{ viewModel.primaryActionLabel }}
+2 -2
View File
@@ -586,7 +586,7 @@ function formatLinkedSince(iso: string): string {
<Button
v-if="linkedAccountsByProvider.get(provider.id)"
variant="secondary"
:class="['shrink-0 px-3 py-1 text-xs']"
:loading="linkActionInFlight === provider.id"
:disabled="!!linkActionInFlight && linkActionInFlight !== provider.id"
@@ -627,7 +627,7 @@ function formatLinkedSince(iso: string): string {
<div :class="['max-w-sm w-full flex flex-col gap-2']">
<Button
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
variant="secondary"
:loading="signOutLoading"
@click="handleSignOut"
>
+44 -7
View File
@@ -102,24 +102,61 @@ renders the fallback when the URL is missing, loading, or fails.
**Slots**: `fallback` (optional override for the built-in user icon)
### Button
### BasicButton
Versatile button with variants, sizes, and states.
Behavioral foundation for custom buttons. It owns disabled/loading behavior,
press animation, sizing, icon/label rendering, and block layout, but supplies no
surface, border, shape, or color styling.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `toggled` | `boolean?` | `false` | Toggle state |
| `icon` | `string?` | — | UnoCSS/Iconify icon class |
| `label` | `string?` | — | Button text |
| `disabled` | `boolean?` | `false` | Disabled state |
| `loading` | `boolean?` | `false` | Loading state |
| `variant` | `'primary' \| 'secondary' \| 'secondary-muted' \| 'danger' \| 'caution' \| 'pure' \| 'ghost'` | `'primary'` | Visual variant |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size |
| `shape` | `'rounded' \| 'pill' \| 'square'` | `'pill'` | Shape |
| `block` | `boolean?` | `false` | Full width |
**Slots**: `default` (fallback when no `label`)
### Button
Solid general-purpose action. It has no border and shows offset outlines on
hover and keyboard focus. Choose a Wind3 color family independently from its
primary or secondary visual emphasis.
Includes all `BasicButton` props, plus:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `shape` | `'rect' \| 'rounded' \| 'circle' \| 'parallelogram'` | `'rect'` | Rectangular, pill-like, circular, or rounded angled geometry |
| `color` | `'neutral' \| 'primary' \| 'cyan' \| 'blue' \| 'green' \| 'lime' \| 'amber' \| 'red' \| 'orange' \| 'purple' \| 'pink'` | `'neutral'` | Wind3 color family; `primary` follows the configured theme hue |
| `variant` | `'primary' \| 'secondary'` | `'secondary'` | Solid high-emphasis or subtle low-emphasis treatment |
| `outline` | `boolean?` | `true` | Show the offset outline on hover and keyboard focus |
### GhostButton
Low-emphasis contextual action with a transparent default surface and compact
spacing. Hover, pressed, and selected states use a subtle primary surface and
text treatment without an outline. The offset outline is reserved for keyboard
focus. Includes all `BasicButton` props.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `active` | `boolean?` | `false` | Persist the subtle selected surface for toggle controls |
### IconButton
Minimal icon-only action for controls such as favorite, like, copy, or retry.
It directly uses `BasicButton`, removes its padding, and does not add a
background, outline, shape, or forced square dimensions. Supply an accessible
`aria-label`. Props: `icon`, `disabled`, and `loading`.
### OverlayButton
Translucent, backdrop-blurred action without an outline, intended for floating
controls such as the Tamagotchi Controls Island. Includes all `BasicButton` props.
### Callout
Alert/callout box with themed accent bar.
@@ -170,8 +207,8 @@ Two-stage confirmation button — click once to reveal confirm/cancel.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `ButtonVariant` | `'danger'` | Confirm button variant |
| `cancelVariant` | `ButtonVariant` | `'secondary'` | Cancel button variant |
| `color` | `ButtonColor` | `'red'` | Confirm button color family |
| `variant` | `ButtonVariant` | `'primary'` | Confirm button visual emphasis |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size |
| `block` | `boolean?` | `false` | Full width |
| `disabled` | `boolean?` | `false` | Disabled |
@@ -2,7 +2,7 @@
import { defaultControlConfig as threeCtrlConf, supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
import { defaultControlConfig as l2dCtrlConf, supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Button } from '@proj-airi/ui'
import { GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
@@ -32,12 +32,12 @@ function handleViewControlsToggle(targetMode: string) {
<div w-full flex flex-1 items-center self-end justify-end gap-2>
<Transition name="fade">
<div v-if="controlEnabled?.enabled.value" w-full flex justify-between gap-2>
<Button
v-for="control in controlEnabled.supported" :key="control" variant="secondary-muted"
:toggled="controlEnabled.mode.value === control" w-full @click="handleViewControlsToggle(control)"
<GhostButton
v-for="control in controlEnabled.supported" :key="control"
:active="controlEnabled.mode.value === control" w-full @click="handleViewControlsToggle(control)"
>
{{ (controlEnabled.conf as any)[control].buttonText }}
</Button>
</GhostButton>
</div>
</Transition>
<button
@@ -221,7 +221,7 @@ function hitVSequence() {
label="Hit V sequence"
icon="i-solar:repeat-one-minimalistic-bold-duotone"
size="sm"
variant="secondary"
@click="hitVSequence"
/>
<FieldCheckbox
@@ -32,7 +32,8 @@ const subsystemFilters = SUBSYSTEM_CONFIGS.map(config => ({
:class="[
'flex items-center gap-1.5',
]"
:variant="isRecording ? 'danger' : 'primary'"
:color="isRecording ? 'red' : 'neutral'"
:variant="isRecording ? 'primary' : 'secondary'"
@click="emit('toggleRecording')"
>
<div
@@ -7,7 +7,7 @@ import type {
import { errorMessageFrom } from '@moeru/std'
import { Section } from '@proj-airi/stage-ui/components'
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
import { Button, Callout, Input } from '@proj-airi/ui'
import { Button, Callout, GhostButton, Input } from '@proj-airi/ui'
import { computed, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
@@ -306,7 +306,7 @@ onMounted(async () => {
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<Button
size="sm"
variant="secondary"
:label="plugin.autoReload ? 'Auto Reload Off' : 'Auto Reload On'"
:icon="plugin.autoReload ? 'i-solar:refresh-circle-bold' : 'i-solar:refresh-bold-duotone'"
:loading="store.loading"
@@ -314,7 +314,7 @@ onMounted(async () => {
/>
<Button
size="sm"
variant="secondary"
:label="plugin.enabled ? 'Disable' : 'Enable'"
:icon="plugin.enabled ? 'i-solar:lock-keyhole-minimalistic-unlocked-bold-duotone' : 'i-solar:lock-keyhole-bold-duotone'"
:loading="store.loading"
@@ -322,16 +322,16 @@ onMounted(async () => {
/>
<Button
size="sm"
variant="secondary"
label="Load"
icon="i-solar:play-bold-duotone"
:disabled="plugin.loaded"
:loading="store.loading"
@click="loadPlugin(plugin)"
/>
<Button
<GhostButton
size="sm"
variant="ghost"
label="Unload"
icon="i-solar:stop-bold-duotone"
:disabled="!plugin.loaded"
@@ -417,7 +417,7 @@ onBeforeUnmount(async () => {
<div class="flex flex-wrap gap-3">
<Button
:disabled="!canStartRecording"
variant="primary"
@click="startRecording"
>
Start Recording
@@ -425,7 +425,7 @@ onBeforeUnmount(async () => {
<Button
:disabled="!canStopRecording"
variant="primary"
@click="stopRecording"
>
Stop Recording
@@ -434,7 +434,7 @@ onBeforeUnmount(async () => {
<Button
v-if="isTranscribing"
:disabled="!canAbortTranscription"
variant="secondary"
@click="abortTranscription"
>
Abort Transcription
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useWebSocketInspectorStore } from '@proj-airi/stage-ui/stores/devtools/websocket-inspector'
import { Button, FieldCheckbox, Input } from '@proj-airi/ui'
import { FieldCheckbox, GhostButton, Input } from '@proj-airi/ui'
import { computed, nextTick, ref, watch } from 'vue'
const store = useWebSocketInspectorStore()
@@ -124,11 +124,11 @@ function payloadClasses(direction: 'incoming' | 'outgoing') {
placeholder="Filter payload..."
class="w-64"
/>
<Button
<GhostButton
label="Clear"
icon="i-solar:trash-bin-trash-bold-duotone"
size="sm"
variant="ghost"
@click="store.clear()"
/>
<div class="flex flex-shrink-0 items-center text-xs">
@@ -5,7 +5,7 @@ import { resolveLinkedAccountOAuthErrorMessageKey, useAnalytics, useLinkedAccoun
import { authClient } from '@proj-airi/stage-ui/libs/auth'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Avatar, Button, FieldInput } from '@proj-airi/ui'
import { Avatar, Button, FieldInput, GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
@@ -438,18 +438,13 @@ async function handleConfirmDelete(event: Event) {
<div :class="['my-2 border-t border-neutral-200/70 dark:border-neutral-800/60']" />
<button
type="button"
:class="[
'w-full text-left rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer',
'flex items-center gap-2',
'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
]"
<GhostButton
block
:class="['justify-start!']"
icon="i-solar:logout-3-bold-duotone"
:label="t('settings.pages.account.logout')"
@click="emit('logout')"
>
<div :class="['i-solar:logout-3-bold-duotone', 'size-4 flex-shrink-0']" />
{{ t('settings.pages.account.logout') }}
</button>
/>
</aside>
<!-- Main column. max-w-3xl keeps long lines (descriptions, the Flux
@@ -716,12 +711,13 @@ async function handleConfirmDelete(event: Event) {
{{ t('settings.pages.account.connections.message.loading') }}
</div>
<ul v-else :class="['flex flex-col gap-2 max-w-md']">
<ul v-else :class="['flex flex-col gap-2 max-w-unset md:max-w-md']">
<li
v-for="provider in defaultSignInProviders"
:key="provider.id"
:class="[
'flex items-center justify-between gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2',
'flex items-center justify-between gap-3 rounded-lg px-3 py-2',
'bg-neutral-100 dark:bg-neutral-900',
]"
>
<div :class="['flex items-center gap-2 min-w-0']">
@@ -745,7 +741,7 @@ async function handleConfirmDelete(event: Event) {
<Button
v-if="linkedAccountsByProvider.get(provider.id)"
variant="secondary"
:class="['shrink-0 px-3 py-1 text-xs']"
:loading="linkActionInFlight === provider.id"
:disabled="!!linkActionInFlight && linkActionInFlight !== provider.id"
@@ -811,9 +807,9 @@ async function handleConfirmDelete(event: Event) {
</div>
<div :class="['flex-shrink-0']">
<Button
variant="danger"
:label="t('settings.pages.account.danger.deleteAccount.action')"
@click="openDeleteDialog"
color="red"
variant="primary" @click="openDeleteDialog"
/>
</div>
</div>
@@ -877,17 +873,18 @@ async function handleConfirmDelete(event: Event) {
<DialogClose as-child>
<Button
type="button"
variant="secondary"
:disabled="deleteLoading"
:label="t('settings.pages.account.danger.deleteAccount.modal.cancel')"
/>
</DialogClose>
<Button
type="submit"
variant="danger"
:loading="deleteLoading"
:disabled="!deleteEmailMatches"
:label="t('settings.pages.account.danger.deleteAccount.modal.confirm')"
color="red" variant="primary"
/>
</div>
</form>
@@ -901,18 +898,11 @@ async function handleConfirmDelete(event: Event) {
reversible (just sign back in) putting it in the destructive
group would over-signal severity. -->
<div :class="['md:hidden pt-2']">
<button
type="button"
:class="[
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm cursor-pointer',
'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
'transition-colors',
]"
<GhostButton
icon="i-solar:logout-3-bold-duotone"
:label="t('settings.pages.account.logout')"
@click="emit('logout')"
>
<div :class="['i-solar:logout-3-bold-duotone', 'size-4']" />
{{ t('settings.pages.account.logout') }}
</button>
/>
</div>
</div>
</div>
@@ -680,14 +680,13 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
<div class="ml-auto mr-1 flex flex-row gap-2">
<Button
variant="secondary"
icon="i-solar:undo-left-bold-duotone"
:label="t('settings.pages.card.cancel')"
:disabled="false"
@click="modelValue = false"
/>
<Button
:variant="isEditingActiveCard ? 'primary' : 'secondary'"
icon="i-solar:check-circle-bold-duotone"
:label="t('settings.pages.card.save')"
:disabled="false"
@@ -695,7 +694,7 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
/>
<Button
v-if="!isEditingActiveCard"
variant="primary"
icon="i-solar:play-circle-bold-duotone"
:label="t('settings.pages.card.save_and_activate')"
:disabled="false"
@@ -359,7 +359,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
<!-- Action buttons -->
<div flex="~ row" gap-2>
<Button
variant="secondary"
icon="i-solar:download-minimalistic-bold-duotone"
:label="t('settings.pages.card.export')"
:disabled="isExportingCard"
@@ -367,7 +367,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
/>
<!-- Activation button -->
<Button
variant="primary"
:icon="isActive ? 'i-solar:check-circle-bold-duotone' : 'i-solar:play-circle-broken'"
:label="isActive ? t('settings.pages.card.active') : t('settings.pages.card.activate')"
:disabled="isActive"
@@ -375,7 +375,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
@click="handleActivate"
/>
<Button
variant="secondary"
icon="i-solar:close-circle-bold-duotone"
:label="t('settings.pages.card.cancel')"
@click="emit('update:modelValue', false)"
@@ -55,16 +55,17 @@ function handleConfirm() {
<div class="flex flex-row justify-end gap-3">
<AlertDialogCancel as-child>
<Button
variant="secondary"
:label="t('settings.pages.card.cancel')"
@click="handleCancel"
/>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button
variant="danger"
:label="t('settings.pages.card.delete')"
@click="handleConfirm"
color="red"
variant="primary" @click="handleConfirm"
/>
</AlertDialogAction>
</div>
@@ -3,7 +3,7 @@ import type { ComfyUIWorkflowTemplate } from '@proj-airi/stage-ui/stores/modules
import { REPLICATE_IMAGEGEN_PRESETS } from '@proj-airi/stage-shared'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { Button, Checkbox, FieldInput, FieldRange, Select } from '@proj-airi/ui'
import { Button, Checkbox, FieldInput, FieldRange, IconButton, Select } from '@proj-airi/ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -171,7 +171,7 @@ function openReplicateModel() {
<Button
v-for="model in REPLICATE_IMAGEGEN_PRESETS"
:key="model.id"
variant="secondary"
:class="[
'h-auto min-h-20 flex flex-col items-center justify-center rounded-xl border p-3 transition-all',
selectedArtistryModel === model.id
@@ -202,7 +202,7 @@ function openReplicateModel() {
<Button
v-for="wf in comfyuiWorkflows"
:key="wf.id"
variant="secondary"
:class="[
'h-auto min-h-20 flex flex-col items-center justify-center rounded-xl border p-3 transition-all',
selectedArtistryModel === wf.id
@@ -225,11 +225,8 @@ function openReplicateModel() {
:description="t('settings.pages.modules.artistry.model.description')"
placeholder="e.g. black-forest-labs/flux-schnell"
/>
<Button
<IconButton
v-if="selectedArtistryProvider === 'replicate' && selectedArtistryModel"
variant="ghost"
size="sm"
shape="square"
:class="[
'absolute right-3 top-9',
]"
@@ -237,7 +234,7 @@ function openReplicateModel() {
@click="openReplicateModel"
>
<div i-solar:link-round-bold-duotone class="text-xl" />
</Button>
</IconButton>
</div>
<div
@@ -253,14 +250,14 @@ function openReplicateModel() {
</p>
<div class="flex items-center gap-2">
<Button
variant="primary"
size="sm"
@click="applyRecommendedInstructions"
>
{{ t('settings.pages.modules.artistry.card.instruction_sync.apply') }}
</Button>
<Button
variant="secondary"
size="sm"
@click="pendingInstructionWf = null"
>
@@ -99,14 +99,14 @@ async function handleImport(event: Event) {
</div>
<div :class="['flex flex-col items-start gap-2 sm:items-end']">
<div :class="['flex flex-wrap gap-2']">
<Button variant="secondary" @click="triggerExport">
<Button @click="triggerExport">
{{ t('settings.pages.data.sections.chats.export') }}
</Button>
<Button variant="primary" @click="triggerImportPicker">
<Button @click="triggerImportPicker">
{{ t('settings.pages.data.sections.chats.import') }}
</Button>
</div>
<DoubleCheckButton variant="danger" @confirm="deleteChats">
<DoubleCheckButton @confirm="deleteChats">
{{ t('settings.pages.data.sections.chats.delete') }}
<template #confirm>
{{ t('settings.pages.data.confirmations.yes') }}
@@ -61,7 +61,7 @@ async function deleteAll() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<DoubleCheckButton variant="danger" @confirm="resetProviders">
<DoubleCheckButton @confirm="resetProviders">
{{ t('settings.pages.data.sections.providers.reset') }}
<template #confirm>
{{ t('settings.pages.data.confirmations.yes') }}
@@ -83,7 +83,7 @@ async function deleteAll() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<DoubleCheckButton variant="danger" @confirm="deleteAll">
<DoubleCheckButton @confirm="deleteAll">
{{ t('settings.pages.data.sections.all.delete') }}
<template #confirm>
{{ t('settings.pages.data.confirmations.yes') }}
@@ -50,7 +50,7 @@ function resetModules() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<DoubleCheckButton variant="danger" @confirm="deleteModels">
<DoubleCheckButton @confirm="deleteModels">
{{ t('settings.pages.data.sections.models.delete') }}
<template #confirm>
{{ t('settings.pages.data.confirmations.yes') }}
@@ -72,7 +72,7 @@ function resetModules() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<DoubleCheckButton variant="caution" @confirm="resetModules">
<DoubleCheckButton color="orange" @confirm="resetModules">
{{ t('settings.pages.data.sections.modules.reset') }}
<template #confirm>
{{ t('settings.pages.data.confirmations.yes') }}
@@ -6,7 +6,7 @@ import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { errorMessageFrom } from '@moeru/std'
import { artistryTestComfyUIConnection, isStageTamagotchi } from '@proj-airi/stage-shared'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { Button, FieldInput } from '@proj-airi/ui'
import { Button, FieldInput, GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -299,7 +299,7 @@ function copyToClipboard(text: string) {
</div>
<Button
class="mb-0.5"
variant="primary"
size="md"
:icon="connectionStatus === 'testing' ? undefined : 'i-solar:plug-circle-bold-duotone'"
:loading="connectionStatus === 'testing'"
@@ -347,7 +347,7 @@ function copyToClipboard(text: string) {
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.title') }}
</h3>
<Button
variant="secondary"
size="sm"
@click="showUploadSection = !showUploadSection"
>
@@ -384,14 +384,14 @@ function copyToClipboard(text: string) {
}) }}
</div>
</div>
<Button
variant="ghost"
<GhostButton
size="sm"
class="!text-red-400 hover:!text-red-500"
@click="removeWorkflow(wf.id)"
>
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.remove') }}
</Button>
</GhostButton>
</div>
<!-- Expanded Details -->
@@ -424,7 +424,7 @@ function copyToClipboard(text: string) {
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.config_snippet') }}
</div>
<Button
variant="secondary"
size="sm"
@click="copyToClipboard(generateExampleJson(wf))"
>
@@ -525,7 +525,7 @@ function copyToClipboard(text: string) {
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-neutral-400">{{ t('settings.pages.providers.provider.comfyui.settings.upload.fields_exposed', { count: totalExposed }) }}</span>
<Button
variant="primary"
size="sm"
:disabled="!pendingWorkflowName.trim() || totalExposed === 0"
@click="saveWorkflow"
@@ -428,10 +428,10 @@ onBeforeUnmount(async () => {
<div class="border border-neutral-200/80 rounded-xl bg-neutral-50/60 p-4 dark:border-neutral-700 dark:bg-neutral-900/40">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-x-3">
<Button :disabled="!canStart" variant="primary" @click="startStreaming">
<Button :disabled="!canStart" @click="startStreaming">
{{ isRecording ? 'Streaming...' : 'Start Realtime Transcription' }}
</Button>
<Button :disabled="!canStop" variant="secondary" @click="stopStreaming">
<Button :disabled="!canStop" @click="stopStreaming">
Stop
</Button>
<Button
@@ -103,7 +103,7 @@ function clearDefault() {
@change="handleFileChange"
>
<Button
variant="primary"
class="flex-1"
@click="triggerUpload"
>
@@ -112,7 +112,7 @@ function clearDefault() {
</Button>
<Button
v-if="activeBackgroundId !== 'none'"
variant="secondary"
@click="clearDefault"
>
<div :class="['i-solar:trash-bin-trash-bold-duotone', 'mr-2']" />
@@ -171,7 +171,7 @@ function clearDefault() {
<Button
v-if="bg.id !== activeBackgroundId"
size="sm"
variant="primary"
@click="setAsBackground(bg.id)"
>
<div :class="['i-solar:check-read-bold-duotone']" />
@@ -179,7 +179,7 @@ function clearDefault() {
<Button
v-if="bg.type !== 'builtin'"
size="sm"
variant="secondary"
:class="['!bg-red-500 hover:!bg-red-600 !text-white']"
@click="removeBackground(bg.id)"
>
+5 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/characters'
import { Button } from '@proj-airi/ui'
import { GhostButton } from '@proj-airi/ui'
import { computed, onMounted } from 'vue'
const characterStore = useCharacterStore()
@@ -170,7 +170,7 @@ const characters = computed(() => Array.from(characterStore.characters.values())
</div>
<div :class="['grid grid-cols-3 items-center']">
<div :class="['flex items-center justify-start']">
<Button variant="ghost" size="sm" aria-label="Bookmark" @click="characterStore.bookmark(character.id)">
<GhostButton size="sm" aria-label="Bookmark" @click="characterStore.bookmark(character.id)">
<div
:class="[
character.bookmarked ? 'i-solar-star-bold' : 'i-solar-star-linear',
@@ -186,10 +186,10 @@ const characters = computed(() => Array.from(characterStore.characters.values())
>
{{ formatCount(character.bookmarks) }}
</span>
</Button>
</GhostButton>
</div>
<div :class="['flex items-center justify-center']">
<Button variant="ghost" size="sm" aria-label="Like" @click="characterStore.like(character.id)">
<GhostButton size="sm" aria-label="Like" @click="characterStore.like(character.id)">
<div
:class="[
character.liked ? 'i-solar-heart-bold' : 'i-solar-heart-outline',
@@ -205,7 +205,7 @@ const characters = computed(() => Array.from(characterStore.characters.values())
>
{{ formatCount(character.likes) }}
</span>
</Button>
</GhostButton>
</div>
<div :class="['flex items-center justify-end']">
<div
@@ -20,7 +20,7 @@ import {
} from '@proj-airi/stage-ui/components'
import { getDefinedProvider, getSchemaDefault, getValidatorsOfProvider, validateProvider } from '@proj-airi/stage-ui/libs'
import { useProviderCatalogStore } from '@proj-airi/stage-ui/stores/provider-catalog'
import { Button, Callout, FieldCombobox, FieldInput, FieldKeyValues } from '@proj-airi/ui'
import { Button, Callout, FieldCombobox, FieldInput, FieldKeyValues, GhostButton } from '@proj-airi/ui'
import { useCloned, useDebounceFn } from '@vueuse/core'
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
import { computed, onMounted, ref, watch } from 'vue'
@@ -361,7 +361,7 @@ function handleDeleteProvider() {
<div :class="['flex', 'items-center', 'gap-2']">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child :aria-label="t('settings.pages.providers.catalog.edit.actions.more-options')">
<Button size="sm" variant="secondary">
<Button size="sm">
<div :class="['i-solar:menu-dots-bold']" />
</Button>
</DropdownMenuTrigger>
@@ -510,7 +510,7 @@ function handleDeleteProvider() {
<div :class="['text-xs', 'text-neutral-400']">
{{ t('settings.pages.providers.catalog.edit.validators.title') }}
</div>
<Button size="sm" variant="secondary" :loading="isValidating" :disabled="isValidating" @click="runValidation">
<Button size="sm" :loading="isValidating" :disabled="isValidating" @click="runValidation">
{{ t('settings.pages.providers.catalog.edit.validators.actions.validate') }}
</Button>
</div>
@@ -532,9 +532,9 @@ function handleDeleteProvider() {
{{ step.label }}
</div>
<div :class="['flex', 'flex-col', 'items-end', 'gap-2']">
<Button
<GhostButton
size="sm"
variant="ghost"
:title="
step.status === 'valid' ? t('settings.pages.providers.catalog.edit.validators.status.valid')
: step.status === 'invalid' ? t('settings.pages.providers.catalog.edit.validators.status.invalid')
@@ -552,7 +552,7 @@ function handleDeleteProvider() {
step.status === 'idle' ? 'i-solar:minus-circle-line-duotone text-neutral-300 dark:text-neutral-600' : '',
]"
/>
</Button>
</GhostButton>
</div>
</div>
</div>
@@ -573,7 +573,7 @@ function handleDeleteProvider() {
<span :class="['text-xs', 'text-neutral-600', 'dark:text-neutral-300']">
{{ t('settings.pages.providers.catalog.edit.validation.failed.description') }}
</span>
<Button size="sm" variant="caution" @click="handleSaveAnyway">
<Button size="sm" color="orange" variant="primary" @click="handleSaveAnyway">
{{ t('settings.pages.providers.catalog.edit.validation.failed.action') }}
</Button>
</div>
+1
View File
@@ -181,6 +181,7 @@
"@iconify-json/eos-icons": "catalog:",
"@iconify-json/lucide": "catalog:",
"@iconify-json/mingcute": "catalog:",
"@iconify-json/ph": "catalog:",
"@iconify-json/simple-icons": "catalog:",
"@iconify-json/solar": "catalog:",
"@iconify-json/svg-spinners": "catalog:",
@@ -86,7 +86,7 @@ function handleSelect(provider: OAuthProvider) {
<Button
v-for="provider in providers"
:key="provider.id"
variant="secondary"
size="lg"
block
:icon="provider.icon"
@@ -1,5 +1,14 @@
<script setup lang="ts">
import { Button } from '@proj-airi/ui'
import { Button, GhostButton, IconButton, OverlayButton } from '@proj-airi/ui'
import { computed, shallowRef } from 'vue'
const buttonColors = ['neutral', 'primary', 'cyan', 'blue', 'green', 'lime', 'amber', 'red', 'orange', 'purple', 'pink'] as const
const liked = shallowRef(false)
const favorited = shallowRef(false)
const heartIcon = computed(() => liked.value ? 'i-ph:heart-fill' : 'i-ph:heart')
const starIcon = computed(() => favorited.value ? 'i-ph:star-fill' : 'i-ph:star')
</script>
<template>
@@ -12,109 +21,89 @@ import { Button } from '@proj-airi/ui'
<ThemeColorsHueControl />
</template>
<Variant
id="basic"
title="Basic Button"
<Variant id="variant" title="Variant">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<Button label="Primary" color="primary" variant="primary" />
<Button label="Secondary" color="primary" variant="secondary" />
</div>
</Variant>
<Variant id="color" title="Color">
<div :class="['grid grid-cols-2 gap-4 p-4 sm:grid-cols-4']">
<div
v-for="color in buttonColors"
:key="color"
:class="['flex flex-col gap-3']"
>
<div flex flex-col>
<div class="flex flex-row gap-4 p-2">
<Button label="Primary Button" />
<Button label="Secondary Button" variant="secondary" />
<Button label="Caution Button" variant="caution" />
<Button label="Danger Button" variant="danger" />
<Button label="Ghost Button" variant="ghost" />
<Button :label="`${color} primary`" :color="color" variant="primary" />
<Button :label="`${color} secondary`" :color="color" variant="secondary" />
<Button :label="`${color} disabled`" :color="color" disabled />
</div>
</div>
</Variant>
<Variant
id="disabled"
title="Disabled State"
>
<div class="flex flex-row gap-4 p-2">
<Button label="Disabled Primary" disabled />
<Button label="Disabled Secondary" variant="secondary" disabled />
<Button label="Disabled Caution" variant="caution" disabled />
<Button label="Disabled Danger" variant="danger" disabled />
<Button label="Disabled Ghost" variant="ghost" disabled />
<Variant id="shape" title="Shape">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<Button label="Rect" shape="rect" />
<Button label="Rounded" shape="rounded" />
<Button label="Parallelogram" shape="parallelogram" />
<Button icon="i-ph:plus" shape="circle" aria-label="Circle" />
</div>
</Variant>
<Variant
id="loading"
title="Loading State"
>
<div class="flex flex-row gap-4 p-2">
<Button label="Loading Primary" loading />
<Button label="Loading Secondary" variant="secondary" loading />
<Button label="Loading Caution" variant="caution" loading />
<Button label="Loading Danger" variant="danger" loading />
<Button label="Loading Ghost" variant="ghost" loading />
<Variant id="states" title="States">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<Button label="Default" />
<Button label="Disabled" disabled />
<Button label="Loading" loading />
</div>
</Variant>
<Variant
id="with-icon"
title="With Icon"
>
<div class="flex flex-row gap-4 p-2">
<Button label="Add Item" icon="i-solar:add-circle-line-duotone" />
<Button label="Warn" variant="caution" icon="i-solar:danger-triangle-line-duotone" />
<Button label="Delete Item" variant="danger" icon="i-solar:trash-bin-trash-line-duotone" />
<Button label="Settings" variant="secondary" icon="i-solar:settings-line-duotone" />
<Button label="Plain" variant="ghost" icon="i-solar:emoji-funny-circle-line-duotone" />
</div>
</Variant>
<Variant
id="icon-only"
title="Icon Only"
>
<div class="flex gap-4 p-2">
<Button icon="i-solar:add-circle-line-duotone" />
<Button variant="secondary" icon="i-solar:settings-line-duotone" />
<Button variant="caution" icon="i-solar:danger-triangle-line-duotone" />
<Button variant="danger" icon="i-solar:trash-bin-trash-line-duotone" />
<Button variant="ghost" icon="i-solar:neutral-face-outline" />
</div>
</Variant>
<Variant
id="sizes"
title="Button Sizes"
>
<div class="flex items-center gap-4 p-2">
<Variant id="size" title="Size">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<Button label="Small" size="sm" />
<Button label="Medium" size="md" />
<Button label="Large" size="lg" />
<Button label="Caution" variant="caution" />
</div>
</Variant>
<Variant
id="block"
title="Block Button"
>
<div class="flex flex-col gap-4 p-2">
<Button label="Block Primary" block />
<Button label="Block Secondary" variant="secondary" block />
<Button label="Block Caution" variant="caution" block />
<Button label="Block Danger" variant="danger" block />
<Variant id="ghost-button" title="Ghost Button">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<GhostButton label="Action" icon="i-solar:pen-new-square-linear" />
<GhostButton label="Active" icon="i-solar:check-circle-linear" active />
<GhostButton label="Disabled" icon="i-solar:forbidden-circle-linear" disabled />
<GhostButton label="Loading" loading />
</div>
</Variant>
<Variant
id="slot"
title="Using Slot Content"
>
<div class="flex flex-row gap-4 p-2">
<Button>Custom Content</Button>
<Button variant="secondary">
<div class="flex items-center gap-2">
<div i-solar:star-line-duotone />
Complex Content
<Variant id="icon-button" title="Icon Button">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<IconButton
:icon="heartIcon"
:class="[liked && 'text-red', 'text-2xl']"
:aria-label="liked ? 'Unlike' : 'Like'"
:aria-pressed="liked"
size="unset"
@click="liked = !liked"
/>
<IconButton
:icon="starIcon"
:class="[favorited && 'text-amber', 'text-2xl']"
:aria-label="favorited ? 'Remove favorite' : 'Favorite'"
:aria-pressed="favorited"
size="unset"
@click="favorited = !favorited"
/>
<IconButton icon="i-ph:trash" disabled aria-label="Delete disabled" />
<IconButton loading aria-label="Loading" />
</div>
</Button>
</Variant>
<Variant id="overlay-button" title="Overlay Button">
<div :class="['flex flex-wrap items-center gap-4 p-4']">
<OverlayButton label="Overlay" icon="i-solar:widget-2-linear" />
<OverlayButton label="Disabled" icon="i-solar:lock-linear" disabled />
<OverlayButton label="Loading" loading />
</div>
</Variant>
</Story>
@@ -34,7 +34,7 @@ function note(message: string) {
</template>
</DoubleCheckButton>
<DoubleCheckButton
variant="caution"
color="orange"
@confirm="note('Confirmed reset')"
@cancel="note('Cancelled reset')"
>
@@ -66,7 +66,7 @@ function note(message: string) {
Never mind
</template>
<template #cancel-botton-icon>
<div class="i-solar:close-square-line-duotone h-4 w-4" />
<div class="i-ph:x h-4 w-4" />
</template>
</DoubleCheckButton>
</Variant>
@@ -72,7 +72,7 @@ function saveSettings() {
<div>
<Button
:label="t('settings.common.save')"
variant="primary"
@click="saveSettings"
/>
</div>
@@ -33,7 +33,7 @@ function saveSettings() {
<div>
<Button
:label="t('settings.common.save')"
variant="primary"
@click="saveSettings"
/>
</div>
@@ -57,7 +57,7 @@ function saveSettings() {
<div>
<Button
:label="t('settings.common.save')"
variant="primary"
@click="saveSettings"
/>
</div>
@@ -2,7 +2,7 @@
import type { ChatHistoryItem, ErrorMessage } from '../../../../types/chat'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
import { IconButton } from '@proj-airi/ui'
import { computed } from 'vue'
import { MarkdownRenderer } from '../../../markdown'
@@ -86,10 +86,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
'self-end mt-1 w-fit',
]"
>
<Button
size="sm"
variant="ghost"
shape="square"
<IconButton
icon="i-solar:refresh-bold"
:aria-label="retryLabel"
@click="emit('retry')"
@@ -56,7 +56,7 @@ function onSubmit(payload: BugReportDialogSubmitPayload) {
]"
>
<Button
variant="danger"
:class="[
'w-fit',
]"
@@ -71,6 +71,7 @@ function onSubmit(payload: BugReportDialogSubmitPayload) {
:class="[
'i-solar:danger-circle-outline h-4 w-4',
]"
color="red" variant="primary"
/>
{{ resolvedTriggerLabel }}
</span>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { Button, GhostButton } from '@proj-airi/ui'
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
@@ -55,7 +55,7 @@ function handleFix(err: string) {
<DialogTitle class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Live2D Model Audit Report
</DialogTitle>
<Button size="sm" variant="secondary" @click="handleClose">
<Button size="sm" @click="handleClose">
Close
</Button>
</div>
@@ -96,9 +96,9 @@ function handleFix(err: string) {
<ul class="list-none pl-0 space-y-1">
<li v-for="(err, i) in report.errors" :key="i" class="flex items-center justify-between gap-2 rounded bg-red-50/50 p-2 text-red-800 dark:bg-red-900/20 dark:text-red-300">
<span>{{ err }}</span>
<Button v-if="canFixError(err)" size="sm" variant="secondary-muted" class="h-6 px-2 text-[10px] tracking-wider uppercase" @click="handleFix(err)">
<GhostButton v-if="canFixError(err)" size="sm" class="h-6 px-2 text-[10px] tracking-wider uppercase" @click="handleFix(err)">
Quick Fix
</Button>
</GhostButton>
</li>
</ul>
</div>
@@ -117,7 +117,7 @@ function handleFix(err: string) {
<!-- Footer -->
<div class="mt-2 flex justify-end gap-2">
<Button variant="secondary" @click="handleClose">
<Button @click="handleClose">
Cancel
</Button>
<Button v-if="report.status !== 'INVALID'" @click="handleConfirm">
@@ -145,7 +145,7 @@ function handleFix(err: string) {
<div class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Model Audit Report
</div>
<Button size="sm" variant="secondary" @click="handleClose">
<Button size="sm" @click="handleClose">
Close
</Button>
</div>
@@ -170,9 +170,9 @@ function handleFix(err: string) {
<ul class="list-none pl-0 space-y-1">
<li v-for="(err, i) in report.errors" :key="i" class="flex items-center justify-between gap-2 rounded bg-red-50/50 p-2 text-red-800 dark:bg-red-900/20 dark:text-red-300">
<span>{{ err }}</span>
<Button v-if="canFixError(err)" size="sm" variant="secondary-muted" class="h-6 px-2 text-[10px] tracking-wider uppercase" @click="handleFix(err)">
<GhostButton v-if="canFixError(err)" size="sm" class="h-6 px-2 text-[10px] tracking-wider uppercase" @click="handleFix(err)">
Fix
</Button>
</GhostButton>
</li>
</ul>
</div>
@@ -189,7 +189,7 @@ function handleFix(err: string) {
<Button v-if="report.status !== 'INVALID'" @click="handleConfirm">
{{ report.status === 'WARNING' ? 'Import Anyway' : 'Confirm Import' }}
</Button>
<Button variant="secondary" @click="handleClose">
<Button @click="handleClose">
Cancel
</Button>
</div>
@@ -430,7 +430,7 @@ mmdDialog.onChange(handleAddMMDModel)
<div>{{ mapFormatRenderer[model.format] }}</div>
</div>
</div>
<Button class="hidden md:block" variant="secondary" @click="handlePick(model)">
<Button class="hidden md:block" @click="handlePick(model)">
{{ t('settings.model-select.select-model.select') }}
</Button>
</div>
@@ -57,7 +57,7 @@ function confirm() {
<DialogTitle class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Tachie Model Audit Report
</DialogTitle>
<Button size="sm" variant="secondary" @click="close">
<Button size="sm" @click="close">
Close
</Button>
</div>
@@ -133,7 +133,7 @@ function confirm() {
</div>
<div :class="['mt-2', 'flex justify-end gap-2']">
<Button variant="secondary" @click="close">
<Button @click="close">
Cancel
</Button>
<Button v-if="report.status !== 'INVALID'" @click="confirm">
@@ -161,7 +161,7 @@ function confirm() {
<div class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Tachie Model Audit Report
</div>
<Button size="sm" variant="secondary" @click="close">
<Button size="sm" @click="close">
Close
</Button>
</div>
@@ -199,7 +199,7 @@ function confirm() {
<Button v-if="report.status !== 'INVALID'" @click="confirm">
{{ report.status === 'WARNING' ? 'Import Anyway' : 'Confirm Import' }}
</Button>
<Button variant="secondary" @click="close">
<Button @click="close">
Cancel
</Button>
</div>
@@ -55,7 +55,7 @@ const { privacyPolicyUrl } = useAnalytics()
</div>
<Button
variant="primary"
:label="t('settings.dialogs.onboarding.next')"
@click="props.onNext"
/>
@@ -90,7 +90,7 @@ const {
<div class="w-full flex-shrink-0">
<!-- Action Buttons -->
<Button
variant="primary"
class="w-full"
:disabled="!activeModel"
:loading="isLoadingActiveProviderModels"
@@ -138,7 +138,7 @@ function handleLocalSetup() {
:enter="{ opacity: 1 }"
:duration="500"
:delay="250"
variant="secondary"
:label="t('settings.dialogs.onboarding.setupWithoutSigningIn')"
:class="['flex-1']"
@click="handleLocalSetup"
@@ -42,7 +42,7 @@ const selectedSteps = computed(() => {
<DialogTitle :class="['text-lg', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">
{{ props.title }}
</DialogTitle>
<Button size="sm" variant="secondary" @click="() => showDialog = false">
<Button size="sm" @click="() => showDialog = false">
Close
</Button>
</div>
@@ -81,7 +81,7 @@ const selectedSteps = computed(() => {
<div :class="['text-lg', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">
{{ props.title }}
</div>
<Button size="sm" variant="secondary" @click="() => showDialog = false">
<Button size="sm" @click="() => showDialog = false">
Close
</Button>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { clearModelCache, formatBytes, getModelCacheSize, isModelCached } from '@proj-airi/stage-ui/libs/inference'
import { Button } from '@proj-airi/ui'
import { Button, GhostButton } from '@proj-airi/ui'
import { onMounted, ref } from 'vue'
const cacheSize = ref(0)
@@ -109,8 +109,8 @@ onMounted(refresh)
<!-- Actions -->
<div flex items-center justify-between>
<Button
variant="secondary-muted"
<GhostButton
size="sm"
label="Refresh"
icon="i-solar:refresh-linear"
@@ -119,13 +119,14 @@ onMounted(refresh)
/>
<Button
v-if="cacheSize > 0"
variant="danger"
size="sm"
:label="clearing ? 'Clearing...' : 'Clear All Cache'"
icon="i-solar:trash-bin-trash-bold"
:disabled="clearing || loading"
:loading="clearing"
@click="handleClearCache"
color="red"
variant="primary" @click="handleClearCache"
/>
</div>
</div>
@@ -9,7 +9,7 @@ defineProps<{
<label
class="w-full flex cursor-pointer items-center justify-between rounded-lg px-4 py-3 text-sm outline-none transition-all duration-250 ease-in-out"
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
hover="bg-neutral-100 dark:bg-neutral-700"
>
<div>
{{ $t(text) }}
@@ -16,7 +16,7 @@ const emits = defineEmits<{
<template>
<Bar :text="text" :description="description">
<Button @click="emits('click')">
<Button :outline="false" @click="emits('click')">
<slot />
</Button>
</Bar>
@@ -283,7 +283,7 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
{{ t('settings.live2d.theme-color-from-model.description') }}
</p>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
<Button :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
{{ t('settings.live2d.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
@@ -438,7 +438,7 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
</div>
<Button
variant="secondary"
class="mt-4 w-full"
@click="resetToDefaultParameters"
>
@@ -446,7 +446,7 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
</Button>
<Button
variant="secondary"
class="mt-2 w-full"
:disabled="clearingCache"
:loading="clearingCache"
@@ -774,10 +774,10 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
<!-- Action buttons -->
<div mt-4 flex gap-2>
<Button variant="secondary" @click="expressionStore.saveDefaults()">
<Button @click="expressionStore.saveDefaults()">
{{ t('settings.live2d.expressions.save-default') }}
</Button>
<Button variant="secondary" @click="expressionStore.resetAll()">
<Button @click="expressionStore.resetAll()">
{{ t('settings.live2d.expressions.reset') }}
</Button>
</div>
@@ -4,7 +4,7 @@ import type { MMDGazeMode, MorphSlot } from '@proj-airi/stage-ui-mmd'
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { controlConfig, useMMD } from '@proj-airi/stage-ui-mmd'
import { Button, FieldCheckbox, FieldCombobox, FieldRange } from '@proj-airi/ui'
import { Button, FieldCheckbox, FieldCombobox, FieldRange, GhostButton } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
@@ -139,7 +139,7 @@ function removeMotion(id: string) {
>
<template v-if="allowExtractColors">
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
<Button :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
{{ t('settings.mmd.theme-color-from-model.button-extract.title') }}
</Button>
</template>
@@ -177,10 +177,10 @@ function removeMotion(id: string) {
</div>
<div />
<template v-for="option in trackingOptions" :key="option.value">
<Button
<GhostButton
:class="[option.class, 'w-auto']"
size="sm"
:variant="gazeMode === option.value ? 'primary' : 'secondary'"
:active="gazeMode === option.value"
:label="option.label"
@click="gazeMode = option.value"
/>
@@ -266,7 +266,7 @@ function removeMotion(id: string) {
<p :class="['mb-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
{{ t('settings.mmd.animation.description') }}
</p>
<Button variant="secondary" :class="['mb-2']" @click="vmdDialog.open()">
<Button :class="['mb-2']" @click="vmdDialog.open()">
{{ t('settings.mmd.animation.import-motion') }}
</Button>
<FieldCombobox
@@ -286,11 +286,11 @@ function removeMotion(id: string) {
{{ motion.name }}
</div>
<div :class="['flex', 'shrink-0', 'items-center', 'gap-1']">
<Button variant="secondary" @click="playMotionOnce(motion.name)">
<Button @click="playMotionOnce(motion.name)">
{{ t('settings.mmd.animation.play-once') }}
</Button>
<Button
variant="secondary"
:aria-label="t('settings.mmd.animation.remove')"
@click="removeMotion(motion.id)"
>
@@ -98,7 +98,7 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
</Callout>
<div :class="['flex flex-wrap items-center gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
<Button variant="secondary">
<Button>
{{ t('settings.model-select.select-model.button') }}
</Button>
</ModelSelectorDialog>
@@ -122,7 +122,7 @@ function handleSkinSelect(skinName: string | number | undefined) {
:expand="false"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
<Button :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
{{ t('settings.spine.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
@@ -82,7 +82,7 @@ const canExtractColors = computed(() => props.runtimeSnapshot.canCapturePreview)
:expand="false"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="!canExtractColors" @click="emit('extractColorsFromModel')">
<Button :disabled="!canExtractColors" @click="emit('extractColorsFromModel')">
{{ t('settings.tachie.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
@@ -2,7 +2,7 @@
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { useModelStore } from '@proj-airi/stage-ui-three'
import { Button, Callout, SelectTab } from '@proj-airi/ui'
import { Button, Callout, GhostButton, SelectTab } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -94,7 +94,7 @@ const envOptions = computed(() => [
>
<template v-if="allowExtractColors">
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="controlsLocked || !canExtractColors" @click="$emit('extractColorsFromModel')">
<Button :disabled="controlsLocked || !canExtractColors" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</template>
@@ -132,11 +132,11 @@ const envOptions = computed(() => [
</div>
<div />
<template v-for="option in trackingOptions" :key="option.value">
<Button
<GhostButton
:class="[option.class, 'w-auto']"
:disabled="controlsLocked"
size="sm"
:variant="trackingMode === option.value ? 'primary' : 'secondary'"
:active="trackingMode === option.value"
:label="option.label"
@click="trackingMode = option.value"
/>
@@ -44,7 +44,7 @@ function handleNotNow() {
'w-full flex items-center gap-2',
]"
>
<Button w-full size="sm" variant="secondary" @click="() => handleNotNow()">
<Button w-full size="sm" @click="() => handleNotNow()">
<div i-solar:close-circle-line-duotone />
<div text-nowrap>
{{ t('base.toaster.pwaUpdateReady.action.notNow') }}
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { BasicButtonProps, ButtonSize } from './button-types'
import { computed } from 'vue'
import { TransitionBidirectional } from '../animations'
const props = withDefaults(defineProps<BasicButtonProps>(), {
disabled: false,
loading: false,
size: 'md',
block: false,
})
const disabled = computed(() => props.disabled || props.loading)
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-3 text-base',
unset: '',
}
</script>
<template>
<button
:disabled="disabled"
:class="[
'inline-flex items-center justify-center whitespace-nowrap font-medium',
'transition-all duration-200 ease-in-out active:scale-95',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100',
sizeClasses[size],
block && 'w-full',
]"
>
<span class="basic-button-content inline-flex items-center justify-center gap-2">
<TransitionBidirectional
from-class="opacity-0 mr-0! w-0!"
active-class="transition-[width,margin] ease-in-out overflow-hidden transition-100"
>
<span v-if="loading || icon" class="h-4 w-4 shrink-0">
<span v-if="loading" class="i-svg-spinners:ring-resize block h-4 w-4" />
<span v-else-if="icon" :class="[icon, 'block', props.size !== 'unset' ? 'h-4 w-4' : '']" />
</span>
</TransitionBidirectional>
<span v-if="label">{{ label }}</span>
<slot v-else />
</span>
</button>
</template>
@@ -0,0 +1,39 @@
/** Available button sizes shared by the button primitives. */
export type ButtonSize = 'sm' | 'md' | 'lg' | 'unset'
/** Geometry used by solid buttons. */
export type ButtonShape = 'rect' | 'rounded' | 'circle' | 'parallelogram'
/** Supported button color families backed by the Wind3 palette. */
export type ButtonColor = 'neutral' | 'primary' | 'cyan' | 'blue' | 'green' | 'lime' | 'amber' | 'red' | 'orange' | 'purple' | 'pink'
/** Visual emphasis within a button color family. */
export type ButtonVariant = 'primary' | 'secondary'
/** Shared interaction and content props implemented by {@link BasicButton}. */
export interface BasicButtonProps {
/** UnoCSS/Iconify class rendered before the label or default slot. */
icon?: string
/** Text content used instead of the default slot. */
label?: string
/** Prevents interaction. */
disabled?: boolean
/** Replaces the icon with a spinner and prevents interaction. */
loading?: boolean
/** Controls padding and type scale. @default 'md' */
size?: ButtonSize
/** Expands the button to the width of its container. @default false */
block?: boolean
}
/** Visual props for the solid button. */
export interface ButtonProps extends BasicButtonProps {
/** Controls the button geometry. @default 'rect' */
shape?: ButtonShape
/** Selects the button color family. @default 'neutral' */
color?: ButtonColor
/** Selects solid or subtle emphasis within the color family. @default 'secondary' */
variant?: ButtonVariant
/** Shows the offset outline on hover and keyboard focus. @default true */
outline?: boolean
}
+419 -159
View File
@@ -1,190 +1,450 @@
<script setup lang="ts">
import type { ButtonColor, ButtonProps, ButtonShape, ButtonSize, ButtonVariant } from './button-types'
import { computed } from 'vue'
import { TransitionBidirectional } from '../animations'
// Define button variants for better type safety and maintainability
type ButtonVariant = 'primary' | 'secondary' | 'secondary-muted' | 'danger' | 'caution' | 'pure' | 'ghost'
type ButtonTheme = 'default'
// Define size options for better flexibility
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps {
toggled?: boolean // Optional toggled state for toggle buttons
icon?: string // Icon class name
label?: string // Button text label
disabled?: boolean // Disabled state
loading?: boolean // Loading state
variant?: ButtonVariant // Button style variant
size?: ButtonSize // Button size variant
shape?: 'rounded' | 'pill' | 'square' // Button shape
theme?: ButtonTheme // Button theme
block?: boolean // Full width button
}
import BasicButton from './basic-button.vue'
const props = withDefaults(defineProps<ButtonProps>(), {
toggled: false,
variant: 'primary',
disabled: false,
loading: false,
color: 'neutral',
outline: true,
shape: 'rect',
size: 'md',
shape: 'pill',
theme: 'default',
block: false,
variant: 'secondary',
})
const isDisabled = computed(() => props.disabled || props.loading)
const shapeClasses: Record<ButtonShape, string> = {
rect: 'rounded-lg',
rounded: 'rounded-full',
circle: 'rounded-full p-0!',
parallelogram: 'button-shape-parallelogram rounded-lg',
}
// Extract variant styles for better organization
const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
default: string[]
nonToggled?: string
toggled?: string
}>> = {
'primary': {
default: {
default: [
'rounded-lg',
'backdrop-blur-md',
'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30',
'focus:ring-none',
'border-2 border-solid border-primary-500/5 dark:border-primary-900/40',
'text-primary-950 dark:text-primary-100',
'focus:ring-2',
const circleSizeClasses: Record<ButtonSize, string> = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
}
const colorClasses: Record<ButtonColor, Record<ButtonVariant, string[]>> = {
neutral: {
primary: [
'backdrop-blur-lg',
'text-neutral-50 dark:text-neutral-900',
'hover:text-neutral-100 dark:hover:text-neutral-900',
'active:text-neutral-100 dark:active:text-neutral-800',
'bg-neutral-800 dark:bg-neutral-200',
'hover:bg-neutral-800 dark:hover:bg-neutral-300',
'active:bg-neutral-800 dark:active:bg-neutral-300',
'focus-visible:bg-neutral-800 dark:focus-visible:bg-neutral-300',
props.outline ? 'hover:outline-neutral-800 dark:hover:outline-neutral-300' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-neutral-800 dark:focus-visible:outline-neutral-300' : '',
],
secondary: [
'backdrop-blur-lg',
'text-neutral-950 dark:text-white',
'hover:text-neutral-900 dark:hover:text-white',
'active:text-neutral-900 dark:active:text-neutral-100',
'bg-neutral-200/75 dark:bg-neutral-700',
'hover:bg-neutral-200 dark:hover:bg-neutral-600/75',
'active:bg-neutral-200 dark:active:bg-neutral-600/50',
'focus-visible:bg-neutral-200/75 dark:focus-visible:bg-neutral-600/50',
props.outline ? 'hover:outline-neutral-300 dark:hover:outline-neutral-600/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-neutral-300 dark:focus-visible:outline-neutral-600/50' : '',
],
},
},
'secondary': {
default: {
default: [
'rounded-lg',
'backdrop-blur-md',
'bg-neutral-100/55 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60',
'focus:ring-none',
'border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30',
'text-neutral-950 dark:text-neutral-100',
'focus:ring-2',
primary: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-primary-100 dark:hover:text-white',
'active:text-primary-100 dark:active:text-primary-100',
'bg-primary-500/85 dark:bg-primary-400/75',
'hover:bg-primary-500/85 dark:hover:bg-primary-400/75',
'active:bg-primary-500 dark:active:bg-primary-400/50',
'focus-visible:bg-primary-500/85 dark:focus-visible:bg-primary-400/50',
props.outline ? 'hover:outline-primary-300/75 dark:hover:outline-primary-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-primary-300/75 dark:focus-visible:outline-primary-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-primary-900 dark:text-primary-200',
'hover:text-primary-900 dark:hover:text-primary-200',
'active:text-primary-900 dark:active:text-primary-200',
'bg-primary-200/75 dark:bg-primary-800/75',
'hover:bg-primary-200/75 dark:hover:bg-primary-800/75',
'active:bg-primary-200 dark:active:bg-primary-800/50',
'focus-visible:bg-primary-200/75 dark:focus-visible:bg-primary-800/50',
props.outline ? 'hover:outline-primary-300/75 dark:hover:outline-primary-800/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-primary-300/75 dark:focus-visible:outline-primary-800/50' : '',
],
},
},
'secondary-muted': {
default: {
default: [
'rounded-lg',
'backdrop-blur-md',
'hover:bg-neutral-50/50 active:bg-neutral-50/90 hover:dark:bg-neutral-800/50 active:dark:bg-neutral-800/90',
'border-2 border-solid border-neutral-100/60 dark:border-neutral-800/30',
'focus:ring-none',
cyan: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-cyan-100 dark:hover:text-white',
'active:text-cyan-100 dark:active:text-cyan-100',
'bg-cyan-500/85 dark:bg-cyan-400/75',
'hover:bg-cyan-500/85 dark:hover:bg-cyan-400/75',
'active:bg-cyan-500 dark:active:bg-cyan-400/50',
'focus-visible:bg-cyan-500/85 dark:focus-visible:bg-cyan-400/50',
props.outline ? 'hover:outline-cyan-300/75 dark:hover:outline-cyan-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-cyan-300/75 dark:focus-visible:outline-cyan-400/50' : '',
],
nonToggled: 'bg-neutral-50/70 dark:bg-neutral-800/70 text-neutral-500 dark:text-neutral-400',
toggled: 'bg-white/90 dark:bg-neutral-500/70 ring-neutral-300/30 dark:ring-neutral-600/60 ring-2 dark:ring-neutral-600/30 text-primary-500 dark:text-primary-100',
},
},
'danger': {
default: {
default: [
'rounded-lg',
'backdrop-blur-md',
'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30',
'focus:ring-none',
'border-2 border-solid border-red-200/30 dark:border-red-900/30',
'text-red-950 dark:text-red-100',
secondary: [
'backdrop-blur-lg',
'text-cyan-900 dark:text-cyan-200',
'hover:text-cyan-900 dark:hover:text-cyan-200',
'active:text-cyan-900 dark:active:text-cyan-200',
'bg-cyan-200/75 dark:bg-cyan-900/75',
'hover:bg-cyan-200/75 dark:hover:bg-cyan-900/75',
'active:bg-cyan-200 dark:active:bg-cyan-900/50',
'focus-visible:bg-cyan-200/75 dark:focus-visible:bg-cyan-900/50',
props.outline ? 'hover:outline-cyan-300/75 dark:hover:outline-cyan-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-cyan-300/75 dark:focus-visible:outline-cyan-900/50' : '',
],
},
},
'caution': {
default: {
default: [
'rounded-lg',
'backdrop-blur-md',
'bg-amber-400/20 hover:bg-amber-400/25 active:bg-amber-400/35 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:active:bg-amber-500/20',
'focus:ring-none',
'border-2 border-solid border-amber-300/30 dark:border-amber-500/15',
'text-amber-900 dark:text-amber-50',
blue: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-blue-100 dark:hover:text-white',
'active:text-blue-100 dark:active:text-blue-100',
'bg-blue-500/85 dark:bg-blue-400/75',
'hover:bg-blue-500/85 dark:hover:bg-blue-400/75',
'active:bg-blue-500 dark:active:bg-blue-400/50',
'focus-visible:bg-blue-500/85 dark:focus-visible:bg-blue-400/50',
props.outline ? 'hover:outline-blue-300/75 dark:hover:outline-blue-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-blue-300/75 dark:focus-visible:outline-blue-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-blue-900 dark:text-blue-200',
'hover:text-blue-900 dark:hover:text-blue-200',
'active:text-blue-900 dark:active:text-blue-200',
'bg-blue-200/75 dark:bg-blue-900/75',
'hover:bg-blue-200/75 dark:hover:bg-blue-900/75',
'active:bg-blue-200 dark:active:bg-blue-900/50',
'focus-visible:bg-blue-200/75 dark:focus-visible:bg-blue-900/50',
props.outline ? 'hover:outline-blue-300/75 dark:hover:outline-blue-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-blue-300/75 dark:focus-visible:outline-blue-900/50' : '',
],
},
},
'pure': {
default: {
default: [
'rounded-lg',
'bg-transparent',
'text-neutral-900 dark:text-neutral-50',
'!px-0 !py-0',
green: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-green-100 dark:hover:text-white',
'active:text-green-100 dark:active:text-green-100',
'bg-green-500/85 dark:bg-green-400/75',
'hover:bg-green-500/85 dark:hover:bg-green-400/75',
'active:bg-green-500 dark:active:bg-green-400/50',
'focus-visible:bg-green-500/85 dark:focus-visible:bg-green-400/50',
props.outline ? 'hover:outline-green-300/75 dark:hover:outline-green-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-green-300/75 dark:focus-visible:outline-green-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-green-900 dark:text-green-200',
'hover:text-green-900 dark:hover:text-green-200',
'active:text-green-900 dark:active:text-green-200',
'bg-green-200/75 dark:bg-green-900/75',
'hover:bg-green-200/75 dark:hover:bg-green-900/75',
'active:bg-green-200 dark:active:bg-green-900/50',
'focus-visible:bg-green-200/75 dark:focus-visible:bg-green-900/50',
props.outline ? 'hover:outline-green-300/75 dark:hover:outline-green-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-green-300/75 dark:focus-visible:outline-green-900/50' : '',
],
},
},
'ghost': {
default: {
default: [
'rounded-lg',
'bg-transparent',
'hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50',
'text-neutral-500 dark:text-neutral-400',
'focus:ring-none',
lime: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-lime-100 dark:hover:text-white',
'active:text-lime-100 dark:active:text-lime-100',
'bg-lime-500/85 dark:bg-lime-400/75',
'hover:bg-lime-500/85 dark:hover:bg-lime-400/75',
'active:bg-lime-500 dark:active:bg-lime-400/50',
'focus-visible:bg-lime-500/85 dark:focus-visible:bg-lime-400/50',
props.outline ? 'hover:outline-lime-300/75 dark:hover:outline-lime-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-lime-300/75 dark:focus-visible:outline-lime-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-lime-900 dark:text-lime-200',
'hover:text-lime-900 dark:hover:text-lime-200',
'active:text-lime-900 dark:active:text-lime-200',
'bg-lime-200/75 dark:bg-lime-900/75',
'hover:bg-lime-200/75 dark:hover:bg-lime-900/75',
'active:bg-lime-200 dark:active:bg-lime-900/50',
'focus-visible:bg-lime-200/75 dark:focus-visible:bg-lime-900/50',
props.outline ? 'hover:outline-lime-300/75 dark:hover:outline-lime-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-lime-300/75 dark:focus-visible:outline-lime-900/50' : '',
],
},
amber: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-amber-100 dark:hover:text-white',
'active:text-amber-100 dark:active:text-amber-100',
'bg-amber-500/85 dark:bg-amber-400/75',
'hover:bg-amber-500/85 dark:hover:bg-amber-400/75',
'active:bg-amber-500 dark:active:bg-amber-400/50',
'focus-visible:bg-amber-500/85 dark:focus-visible:bg-amber-400/50',
props.outline ? 'hover:outline-amber-300/75 dark:hover:outline-amber-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-amber-300/75 dark:focus-visible:outline-amber-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-amber-900 dark:text-amber-200',
'hover:text-amber-900 dark:hover:text-amber-200',
'active:text-amber-900 dark:active:text-amber-200',
'bg-amber-200/75 dark:bg-amber-900/75',
'hover:bg-amber-200/75 dark:hover:bg-amber-900/75',
'active:bg-amber-200 dark:active:bg-amber-900/50',
'focus-visible:bg-amber-200/75 dark:focus-visible:bg-amber-900/50',
props.outline ? 'hover:outline-amber-300/75 dark:hover:outline-amber-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-amber-300/75 dark:focus-visible:outline-amber-900/50' : '',
],
},
red: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-red-100 dark:hover:text-white',
'active:text-red-100 dark:active:text-red-100',
'bg-red-500/85 dark:bg-red-400/75',
'hover:bg-red-500/85 dark:hover:bg-red-400/75',
'active:bg-red-500 dark:active:bg-red-400/50',
'focus-visible:bg-red-500/85 dark:focus-visible:bg-red-400/50',
props.outline ? 'hover:outline-red-300/75 dark:hover:outline-red-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-red-300/75 dark:focus-visible:outline-red-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-red-900 dark:text-red-200',
'hover:text-red-900 dark:hover:text-red-200',
'active:text-red-900 dark:active:text-red-200',
'bg-red-200/75 dark:bg-red-900/75',
'hover:bg-red-200/75 dark:hover:bg-red-900/75',
'active:bg-red-200 dark:active:bg-red-900/50',
'focus-visible:bg-red-200/75 dark:focus-visible:bg-red-900/50',
props.outline ? 'hover:outline-red-300/75 dark:hover:outline-red-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-red-300/75 dark:focus-visible:outline-red-900/50' : '',
],
},
orange: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-orange-100 dark:hover:text-white',
'active:text-orange-100 dark:active:text-orange-100',
'bg-orange-500/85 dark:bg-orange-400/75',
'hover:bg-orange-500/85 dark:hover:bg-orange-400/75',
'active:bg-orange-500 dark:active:bg-orange-400/50',
'focus-visible:bg-orange-500/85 dark:focus-visible:bg-orange-400/50',
props.outline ? 'hover:outline-orange-300/75 dark:hover:outline-orange-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-orange-300/75 dark:focus-visible:outline-orange-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-orange-900 dark:text-orange-200',
'hover:text-orange-900 dark:hover:text-orange-200',
'active:text-orange-900 dark:active:text-orange-200',
'bg-orange-200/75 dark:bg-orange-900/75',
'hover:bg-orange-200/75 dark:hover:bg-orange-900/75',
'active:bg-orange-200 dark:active:bg-orange-900/50',
'focus-visible:bg-orange-200/75 dark:focus-visible:bg-orange-900/50',
props.outline ? 'hover:outline-orange-300/75 dark:hover:outline-orange-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-orange-300/75 dark:focus-visible:outline-orange-900/50' : '',
],
},
purple: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-purple-100 dark:hover:text-white',
'active:text-purple-100 dark:active:text-purple-100',
'bg-purple-500/85 dark:bg-purple-400/75',
'hover:bg-purple-500/85 dark:hover:bg-purple-400/75',
'active:bg-purple-500 dark:active:bg-purple-400/50',
'focus-visible:bg-purple-500/85 dark:focus-visible:bg-purple-400/50',
props.outline ? 'hover:outline-purple-300/75 dark:hover:outline-purple-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-purple-300/75 dark:focus-visible:outline-purple-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-purple-900 dark:text-purple-200',
'hover:text-purple-900 dark:hover:text-purple-200',
'active:text-purple-900 dark:active:text-purple-200',
'bg-purple-200/75 dark:bg-purple-900/75',
'hover:bg-purple-200/75 dark:hover:bg-purple-900/75',
'active:bg-purple-200 dark:active:bg-purple-900/50',
'focus-visible:bg-purple-200/75 dark:focus-visible:bg-purple-900/50',
props.outline ? 'hover:outline-purple-300/75 dark:hover:outline-purple-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-purple-300/75 dark:focus-visible:outline-purple-900/50' : '',
],
},
pink: {
primary: [
'backdrop-blur-lg',
'text-white dark:text-white',
'hover:text-pink-100 dark:hover:text-white',
'active:text-pink-100 dark:active:text-pink-100',
'bg-pink-500/85 dark:bg-pink-400/75',
'hover:bg-pink-500/85 dark:hover:bg-pink-400/75',
'active:bg-pink-500 dark:active:bg-pink-400/50',
'focus-visible:bg-pink-500/85 dark:focus-visible:bg-pink-400/50',
props.outline ? 'hover:outline-pink-300/75 dark:hover:outline-pink-400/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-pink-300/75 dark:focus-visible:outline-pink-400/50' : '',
],
secondary: [
'backdrop-blur-lg',
'text-pink-900 dark:text-pink-200',
'hover:text-pink-900 dark:hover:text-pink-200',
'active:text-pink-900 dark:active:text-pink-200',
'bg-pink-200/75 dark:bg-pink-900/75',
'hover:bg-pink-200/75 dark:hover:bg-pink-900/75',
'active:bg-pink-200 dark:active:bg-pink-900/50',
'focus-visible:bg-pink-200/75 dark:focus-visible:bg-pink-900/50',
props.outline ? 'hover:outline-pink-300/75 dark:hover:outline-pink-900/50' : '',
props.outline ? 'active:outline-none dark:active:outline-none' : '',
props.outline ? 'focus-visible:outline-pink-300/75 dark:focus-visible:outline-pink-900/50' : '',
],
},
}
// Extract size styles for better organization
const sizeClasses: Record<ButtonSize, string> = {
sm: props.shape === 'pill'
? 'px-3 py-1.5 text-xs'
: props.shape === 'square'
? 'p-2 text-xs'
: 'px-4 py-2 text-sm',
md: props.shape === 'pill'
? 'px-4 py-2 text-sm'
: props.shape === 'square'
? 'p-3 text-sm'
: 'px-5 py-3 text-base',
lg: props.shape === 'pill'
? 'px-6 py-3 text-base'
: props.shape === 'square'
? 'p-4 text-base'
: 'px-6 py-3 text-base',
}
const classes = computed(() => [
'border-none',
props.outline && [
'outline outline-2 outline-offset-0 outline-transparent',
'hover:outline-offset-2 focus-visible:outline-offset-2',
],
shapeClasses[props.shape],
props.shape === 'circle' && circleSizeClasses[props.size],
colorClasses[props.color][props.variant],
])
// Base classes that are always applied
const baseClasses = computed(() => {
const variant = variantClasses[props.variant] || variantClasses.primary
const theme = variant[props.theme] || variant.default
return [
'font-medium outline-none whitespace-nowrap',
'transition-all duration-200 ease-in-out',
'disabled:cursor-not-allowed disabled:opacity-50',
'backdrop-blur-md',
props.block ? 'w-full' : '',
sizeClasses[props.size],
theme.default,
props.toggled ? theme.toggled || '' : theme.nonToggled || '',
{ 'opacity-50 cursor-not-allowed': isDisabled.value },
'focus:ring-2',
]
})
const basicProps = computed(() => ({
icon: props.icon,
label: props.label,
disabled: props.disabled,
loading: props.loading,
size: props.size,
block: props.block,
}))
</script>
<template>
<button
:disabled="isDisabled"
:class="baseClasses"
<BasicButton
v-bind="basicProps"
:class="classes"
>
<div class="flex flex-row items-center justify-center gap-2">
<TransitionBidirectional
from-class="opacity-0 mr-0! w-0!"
active-class="transition-[width,margin] ease-in-out overflow-hidden transition-100"
>
<div v-if="loading || icon" class="w-4">
<div v-if="loading" class="i-svg-spinners:ring-resize h-4 w-4" />
<div v-else-if="icon" class="h-4 w-4" :class="icon" />
</div>
</TransitionBidirectional>
<span v-if="label">{{ label }}</span>
<slot v-else />
</div>
</button>
<slot />
</BasicButton>
</template>
<style scoped>
/* Equal and opposite skews keep the button silhouette angled without distorting its content. */
.button-shape-parallelogram {
transform: skewX(-10deg);
}
.button-shape-parallelogram :deep(.basic-button-content) {
transform: skewX(10deg);
}
/* Preserve the silhouette while composing BasicButton's pressed scale. */
.button-shape-parallelogram:active {
transform: skewX(-10deg) scale(0.95);
}
</style>
@@ -3,7 +3,7 @@ import { errorCauseFrom, errorMessageFrom, errorNameFrom, errorStackFrom } from
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, shallowRef } from 'vue'
import Button from './button.vue'
import IconButton from './icon-button.vue'
type HeightPreset = 'sm' | 'md' | 'lg' | 'xl' | 'auto'
@@ -161,22 +161,16 @@ async function copyContent() {
]"
>
<div :class="['absolute right-2 -translate-x-full top-2 z-10']">
<Button
<IconButton
v-if="showCopyButton"
size="sm"
variant="secondary"
shape="square"
icon="i-solar:copy-line-duotone"
:title="copied ? copiedButtonLabel : copyButtonLabel"
:aria-label="copied ? copiedButtonLabel : copyButtonLabel"
@click="copyContent"
/>
<Button
<IconButton
v-if="showFeedbackButton"
size="sm"
variant="secondary"
shape="square"
icon="i-solar:square-share-line-line-duotone"
:title="feedbackButtonLabel"
:aria-label="feedbackButtonLabel"
@@ -1,21 +1,20 @@
<script setup lang="ts">
import type { ButtonColor, ButtonSize, ButtonVariant } from './button-types'
import { computed, ref, watch } from 'vue'
import Button from './button.vue'
type ButtonVariant = 'primary' | 'secondary' | 'secondary-muted' | 'danger' | 'caution'
type ButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(defineProps<{
color?: ButtonColor
variant?: ButtonVariant
cancelVariant?: ButtonVariant
size?: ButtonSize
block?: boolean
disabled?: boolean
loading?: boolean
}>(), {
variant: 'danger',
cancelVariant: 'secondary',
color: 'red',
variant: 'primary',
size: 'md',
block: false,
disabled: false,
@@ -64,12 +63,16 @@ function handleCancel() {
<template>
<div :class="wrapperClasses">
<div class="flex flex-wrap items-center gap-2">
<Transition name="double-check-slide">
<TransitionGroup
name="double-check-slide"
tag="div"
:class="['relative flex flex-wrap items-center gap-2']"
>
<Button
v-if="confirming"
key="cancel"
:variant="cancelVariant"
color="neutral"
variant="secondary"
:size="size"
:block="block"
class="whitespace-nowrap"
@@ -83,9 +86,10 @@ function handleCancel() {
<span><slot name="cancel">Cancel</slot></span>
</div>
</Button>
</Transition>
<Button
key="primary"
:color="color"
:variant="variant"
:size="size"
:block="block"
@@ -107,16 +111,25 @@ function handleCancel() {
</slot>
</span>
</Button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.double-check-slide-move {
transition: transform 180ms ease;
}
.double-check-slide-enter-active,
.double-check-slide-leave-active {
transition: opacity 160ms ease, transform 160ms ease;
}
/* Removing the cancel button from flex flow lets TransitionGroup animate the primary button's layout shift. */
.double-check-slide-leave-active {
position: absolute;
}
.double-check-slide-enter-from,
.double-check-slide-leave-to {
opacity: 0;
@@ -124,7 +137,7 @@ function handleCancel() {
}
.double-check-primary {
transition: min-width 180ms ease, padding 180ms ease;
transition: min-width 180ms ease, padding 180ms ease, transform 180ms ease;
}
.double-check-primary--default {
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { BasicButtonProps, ButtonSize } from './button-types'
import { computed } from 'vue'
import BasicButton from './basic-button.vue'
interface GhostButtonProps extends BasicButtonProps {
/** Keeps the emphasized interaction surface visible for toggle controls. */
active?: boolean
}
const props = withDefaults(defineProps<GhostButtonProps>(), {
active: false,
size: 'md',
})
const sizeClasses: Record<ButtonSize, string> = {
sm: 'min-h-7 px-2! py-1! text-xs',
md: 'min-h-8 px-3! py-1.5! text-sm',
lg: 'min-h-10 px-4! py-2! text-base',
}
const basicProps = computed(() => ({
icon: props.icon,
label: props.label,
disabled: props.disabled,
loading: props.loading,
size: props.size,
block: props.block,
}))
</script>
<template>
<BasicButton
v-bind="basicProps"
:class="[
'rounded-lg border-none bg-transparent outline-none',
'text-neutral-700 dark:text-neutral-200',
'hover:bg-primary-500/10 hover:text-primary-700 dark:hover:bg-primary-400/10 dark:hover:text-primary-300',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-300',
'active:bg-primary-500/20 active:text-primary-800 dark:active:text-primary-200',
active && 'bg-primary-500/15 text-primary-800 dark:bg-primary-400/15 dark:text-primary-200',
sizeClasses[size],
]"
>
<slot />
</BasicButton>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { BasicButtonProps } from './button-types'
import BasicButton from './basic-button.vue'
type IconButtonProps = Pick<BasicButtonProps, 'icon' | 'disabled' | 'loading'>
const props = defineProps<IconButtonProps>()
</script>
<template>
<BasicButton
v-bind="props"
:class="['shrink-0 p-0!']"
>
<slot />
</BasicButton>
</template>
+5
View File
@@ -1,7 +1,12 @@
export { default as Avatar } from './avatar.vue'
export { default as BasicButton } from './basic-button.vue'
export type { BasicButtonProps, ButtonColor, ButtonProps, ButtonShape, ButtonSize, ButtonVariant } from './button-types'
export { default as Button } from './button.vue'
export { default as Callout } from './callout.vue'
export { default as ContainerError } from './container-error.vue'
export { default as DoubleCheckButton } from './double-check-button.vue'
export { default as ErrorBoundary } from './error-boundary.vue'
export { default as GhostButton } from './ghost-button.vue'
export { default as IconButton } from './icon-button.vue'
export { default as OverlayButton } from './overlay-button.vue'
export { default as Progress } from './progress.vue'
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { BasicButtonProps } from './button-types'
import BasicButton from './basic-button.vue'
const props = withDefaults(defineProps<BasicButtonProps>(), {
size: 'md',
})
</script>
<template>
<BasicButton
v-bind="props"
:class="[
'w-fit self-end rounded-xl border-none backdrop-blur-md',
'bg-neutral-50/80 text-neutral-900 dark:bg-neutral-800/70 dark:text-neutral-50',
'hover:text-primary-700 dark:hover:text-primary-200',
'active:bg-primary-200/80 active:text-primary-900',
'dark:active:bg-primary-200/80 dark:active:text-primary-900',
]"
>
<slot />
</BasicButton>
</template>
+3
View File
@@ -4545,6 +4545,9 @@ importers:
'@iconify-json/mingcute':
specifier: 'catalog:'
version: 1.2.7
'@iconify-json/ph':
specifier: 'catalog:'
version: 1.2.2
'@iconify-json/simple-icons':
specifier: 'catalog:'
version: 1.2.78