mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
[demo] PostHog feature flag integration for Run button experiment
- Add FeatureFlaggedRunButton wrapper component - Add ExperimentalRunButton with JSON-driven styling - Extend useFeatureFlags to support remoteConfig and variant payloads - Support 4 experimental button variants (bold-gradient, animated, playful, minimal) - Add documentation for feature flag payload structure NOTE: This is a DEMO and should NOT be merged
This commit is contained in:
229
FEATURE_FLAGS_EXPLANATION.md
Normal file
229
FEATURE_FLAGS_EXPLANATION.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Feature Flags System Explanation
|
||||
|
||||
## Overview
|
||||
|
||||
The `useFeatureFlag` hook (actually named `useFeatureFlags`) is a Vue 3 composable that provides **reactive access to server-side feature flags** received via WebSocket from the backend. It enables capability negotiation between frontend and backend, allowing the UI to adapt based on what features the server supports.
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
```
|
||||
1. Frontend connects via WebSocket
|
||||
2. Frontend sends client feature flags (first message)
|
||||
3. Backend responds with server feature flags
|
||||
4. Frontend stores flags in api.serverFeatureFlags
|
||||
5. Components use useFeatureFlags() to access flags reactively
|
||||
```
|
||||
|
||||
## Core Implementation
|
||||
|
||||
### 1. The `useFeatureFlags` Composable
|
||||
|
||||
**Location:** `src/composables/useFeatureFlags.ts`
|
||||
|
||||
The composable returns two things:
|
||||
|
||||
#### A. Predefined `flags` Object
|
||||
A reactive object with getter properties for commonly-used feature flags:
|
||||
|
||||
```typescript
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// Access predefined flags
|
||||
flags.supportsPreviewMetadata // boolean | undefined
|
||||
flags.maxUploadSize // number | undefined
|
||||
flags.supportsManagerV4 // boolean | undefined
|
||||
flags.modelUploadButtonEnabled // boolean (checks remoteConfig first)
|
||||
flags.assetUpdateOptionsEnabled // boolean (checks remoteConfig first)
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Uses Vue's `reactive()` to make the object reactive
|
||||
- Each getter calls `api.getServerFeature()` which reads from `api.serverFeatureFlags`
|
||||
- Some flags (like `modelUploadButtonEnabled`) check `remoteConfig` first (from `/api/features` endpoint) before falling back to WebSocket flags
|
||||
- Returns a `readonly()` wrapper to prevent external mutation
|
||||
|
||||
#### B. Generic `featureFlag` Function
|
||||
A function that creates a computed ref for any feature flag path:
|
||||
|
||||
```typescript
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
|
||||
// Create a reactive computed ref for any flag
|
||||
const myFlag = featureFlag('custom.feature.path', false) // defaultValue is optional
|
||||
// myFlag is a ComputedRef that updates when serverFeatureFlags changes
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Accepts any string path (supports dot notation for nested values)
|
||||
- Returns a `computed()` ref that automatically updates when flags change
|
||||
- Generic type parameter allows type safety: `featureFlag<boolean>('flag', false)`
|
||||
|
||||
### 2. The Underlying API Layer
|
||||
|
||||
**Location:** `src/scripts/api.ts`
|
||||
|
||||
The `ComfyApi` class manages feature flags:
|
||||
|
||||
```typescript
|
||||
class ComfyApi {
|
||||
// Stores flags received from backend
|
||||
serverFeatureFlags: Record<string, unknown> = {}
|
||||
|
||||
// Retrieves a flag value using dot notation
|
||||
getServerFeature<T>(featureName: string, defaultValue?: T): T {
|
||||
return get(this.serverFeatureFlags, featureName, defaultValue) as T
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How Flags Are Received:**
|
||||
1. WebSocket connection is established
|
||||
2. Frontend sends client feature flags as first message
|
||||
3. Backend responds with a `feature_flags` message type
|
||||
4. The message handler stores it: `this.serverFeatureFlags = msg.data`
|
||||
|
||||
**The `get` Function:**
|
||||
- Uses `es-toolkit/compat`'s `get` function (lodash-style)
|
||||
- Supports dot notation: `'extension.manager.supports_v4'` accesses nested objects
|
||||
- Returns `defaultValue` if the path doesn't exist
|
||||
|
||||
### 3. Remote Config Integration
|
||||
|
||||
**Location:** `src/platform/remoteConfig/remoteConfig.ts`
|
||||
|
||||
Some flags check `remoteConfig` first (loaded from `/api/features` endpoint):
|
||||
|
||||
```typescript
|
||||
// Example from modelUploadButtonEnabled
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ?? // Check remote config first
|
||||
api.getServerFeature(ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, false) // Fallback
|
||||
)
|
||||
```
|
||||
|
||||
**Why Two Sources?**
|
||||
- `remoteConfig`: Fetched via HTTP at app startup, can be updated without WebSocket
|
||||
- WebSocket flags: Real-time capability negotiation, updated on reconnection
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Pattern 1: Using Predefined Flags
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// In template
|
||||
if (flags.supportsPreviewMetadata) {
|
||||
// Use enhanced preview feature
|
||||
}
|
||||
|
||||
// In script
|
||||
const maxSize = flags.maxUploadSize ?? 100 * 1024 * 1024 // Default 100MB
|
||||
```
|
||||
|
||||
### Pattern 2: Using Generic featureFlag Function
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
|
||||
// Create a reactive computed ref
|
||||
const customFeature = featureFlag<boolean>('extension.custom.feature', false)
|
||||
|
||||
// Use in template (automatically reactive)
|
||||
// <div v-if="customFeature">New Feature UI</div>
|
||||
|
||||
// Use in script
|
||||
watch(customFeature, (enabled) => {
|
||||
if (enabled) {
|
||||
// Feature was enabled
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 3: Direct API Access (Non-Reactive)
|
||||
|
||||
```typescript
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Direct access (not reactive, use sparingly)
|
||||
if (api.serverSupportsFeature('supports_preview_metadata')) {
|
||||
// Feature is supported
|
||||
}
|
||||
|
||||
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
|
||||
```
|
||||
|
||||
## Reactivity Explained
|
||||
|
||||
The composable is **reactive** because:
|
||||
|
||||
1. **Predefined flags**: Use `reactive()` with getters, so when `api.serverFeatureFlags` changes, Vue's reactivity system detects it
|
||||
2. **Generic featureFlag**: Returns `computed()`, which automatically tracks `api.getServerFeature()` calls and re-evaluates when flags change
|
||||
3. **WebSocket updates**: When flags are updated via WebSocket, `api.serverFeatureFlags` is reassigned, triggering reactivity
|
||||
|
||||
## Adding New Feature Flags
|
||||
|
||||
### Step 1: Add to Enum (if it's a core flag)
|
||||
|
||||
```typescript
|
||||
// In useFeatureFlags.ts
|
||||
export enum ServerFeatureFlag {
|
||||
// ... existing flags
|
||||
MY_NEW_FEATURE = 'my_new_feature'
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add to flags Object (if commonly used)
|
||||
|
||||
```typescript
|
||||
// In useFeatureFlags.ts flags object
|
||||
get myNewFeature() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MY_NEW_FEATURE, false)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Use in Components
|
||||
|
||||
```typescript
|
||||
const { flags } = useFeatureFlags()
|
||||
if (flags.myNewFeature) {
|
||||
// Use the feature
|
||||
}
|
||||
```
|
||||
|
||||
**OR** use the generic function without modifying the composable:
|
||||
|
||||
```typescript
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const myFeature = featureFlag('my_new_feature', false)
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Flags are server-driven**: The backend controls which flags are available
|
||||
2. **Default values**: Always provide sensible defaults when using `getServerFeature()`
|
||||
3. **Reactivity**: The composable ensures UI updates automatically when flags change (e.g., on WebSocket reconnection)
|
||||
4. **Type safety**: Use TypeScript generics with `featureFlag<T>()` for type safety
|
||||
5. **Dot notation**: Feature flags can be nested, use dot notation: `'extension.manager.supports_v4'`
|
||||
6. **Remote config priority**: Some flags check `remoteConfig` first, then fall back to WebSocket flags
|
||||
|
||||
## Testing
|
||||
|
||||
See `tests-ui/tests/composables/useFeatureFlags.test.ts` for examples of:
|
||||
- Mocking `api.getServerFeature()`
|
||||
- Testing reactive behavior
|
||||
- Testing default values
|
||||
- Testing nested paths
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/composables/useFeatureFlags.ts` - The main composable
|
||||
- `src/scripts/api.ts` - API layer with `getServerFeature()` method
|
||||
- `src/platform/remoteConfig/remoteConfig.ts` - Remote config integration
|
||||
- `docs/FEATURE_FLAGS.md` - Full system documentation
|
||||
- `tests-ui/tests/composables/useFeatureFlags.test.ts` - Unit tests
|
||||
|
||||
196
FEATURE_FLAG_PAYLOAD.md
Normal file
196
FEATURE_FLAG_PAYLOAD.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Feature Flag Payload Shape
|
||||
|
||||
## Feature Flag Key
|
||||
`demo-run-button-experiment`
|
||||
|
||||
## Expected Structure
|
||||
|
||||
The feature flag value should be either:
|
||||
|
||||
### Control (Original Button)
|
||||
- `false`
|
||||
- `null`
|
||||
- `undefined`
|
||||
- Not set
|
||||
|
||||
When any of these values are present, the original SplitButton will be displayed.
|
||||
|
||||
### Experiment (Experimental Button)
|
||||
An object with the following structure:
|
||||
|
||||
```typescript
|
||||
{
|
||||
variant: string, // Required: variant name (e.g., "bold-gradient", "animated", "playful", "minimal")
|
||||
payload?: { // Optional: styling and content overrides
|
||||
label?: string, // Button text (default: "Run")
|
||||
icon?: string, // Icon class (default: variant-specific)
|
||||
backgroundColor?: string, // Background color/class (default: variant-specific)
|
||||
textColor?: string, // Text color/class (default: variant-specific)
|
||||
borderRadius?: string, // Border radius class (default: variant-specific)
|
||||
padding?: string // Padding class (default: "px-4 py-2")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Payloads
|
||||
|
||||
### Bold Gradient Variant
|
||||
```json
|
||||
{
|
||||
"variant": "bold-gradient",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--zap]",
|
||||
"backgroundColor": "transparent",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-xl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animated Variant
|
||||
```json
|
||||
{
|
||||
"variant": "animated",
|
||||
"payload": {
|
||||
"label": "Launch",
|
||||
"icon": "icon-[lucide--rocket]",
|
||||
"backgroundColor": "bg-primary-background",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-full"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Playful Variant
|
||||
```json
|
||||
{
|
||||
"variant": "playful",
|
||||
"payload": {
|
||||
"label": "Go!",
|
||||
"icon": "icon-[lucide--sparkles]",
|
||||
"backgroundColor": "bg-gradient-to-br from-yellow-400 to-orange-500",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-2xl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Variant
|
||||
```json
|
||||
{
|
||||
"variant": "minimal",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--play]",
|
||||
"backgroundColor": "bg-white",
|
||||
"textColor": "text-gray-800",
|
||||
"borderRadius": "rounded-md"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Payload Properties
|
||||
|
||||
### `variant` (required)
|
||||
- Type: `string`
|
||||
- Description: The variant name that determines the base styling and behavior
|
||||
- Supported values:
|
||||
- `"bold-gradient"` - Gradient background with animated effect
|
||||
- `"animated"` - Pulsing animation effect
|
||||
- `"playful"` - Sparkle effects with playful styling
|
||||
- `"minimal"` - Clean, minimal design
|
||||
- Any custom variant name (will use default styling)
|
||||
|
||||
### `payload.label` (optional)
|
||||
- Type: `string`
|
||||
- Default: `"Run"` (or variant-specific default)
|
||||
- Description: The text displayed on the button
|
||||
|
||||
### `payload.icon` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific icon
|
||||
- Description: Icon class name (e.g., `"icon-[lucide--play]"`)
|
||||
- Examples:
|
||||
- `"icon-[lucide--play]"` - Play icon
|
||||
- `"icon-[lucide--zap]"` - Lightning bolt
|
||||
- `"icon-[lucide--rocket]"` - Rocket
|
||||
- `"icon-[lucide--sparkles]"` - Sparkles
|
||||
|
||||
### `payload.backgroundColor` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific background
|
||||
- Description: Tailwind CSS class or CSS color value for background
|
||||
- Examples:
|
||||
- `"bg-primary-background"` - Primary background color
|
||||
- `"bg-white"` - White background
|
||||
- `"transparent"` - Transparent (for gradient overlays)
|
||||
- `"bg-gradient-to-br from-yellow-400 to-orange-500"` - Gradient
|
||||
|
||||
### `payload.textColor` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific text color
|
||||
- Description: Tailwind CSS class or CSS color value for text
|
||||
- Examples:
|
||||
- `"white"` - White text (CSS color)
|
||||
- `"text-white"` - White text (Tailwind class)
|
||||
- `"text-gray-800"` - Dark gray text
|
||||
|
||||
### `payload.borderRadius` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific border radius
|
||||
- Description: Tailwind CSS border radius class
|
||||
- Examples:
|
||||
- `"rounded-md"` - Medium border radius
|
||||
- `"rounded-xl"` - Extra large border radius
|
||||
- `"rounded-full"` - Fully rounded (pill shape)
|
||||
- `"rounded-2xl"` - 2x extra large border radius
|
||||
|
||||
### `payload.padding` (optional)
|
||||
- Type: `string`
|
||||
- Default: `"px-4 py-2"`
|
||||
- Description: Tailwind CSS padding classes
|
||||
- Examples:
|
||||
- `"px-4 py-2"` - Standard padding
|
||||
- `"px-6 py-3"` - Larger padding
|
||||
- `"px-2 py-1"` - Smaller padding
|
||||
|
||||
## Backend Integration
|
||||
|
||||
The backend should send this feature flag via WebSocket in the `feature_flags` message:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "feature_flags",
|
||||
"data": {
|
||||
"demo-run-button-experiment": {
|
||||
"variant": "bold-gradient",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--zap]",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-xl"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or to show the control (original button):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "feature_flags",
|
||||
"data": {
|
||||
"demo-run-button-experiment": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All `payload` properties are optional - if omitted, variant-specific defaults will be used
|
||||
- The `variant` property is required when the flag is truthy
|
||||
- Color values can be either Tailwind classes (e.g., `"text-white"`) or CSS color values (e.g., `"white"`)
|
||||
- The component will automatically handle both formats
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
<template>
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
<FeatureFlaggedRunButton
|
||||
flag-key="demo-run-button-experiment"
|
||||
:on-click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
<template #control>
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
class="comfyui-queue-button"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
</template>
|
||||
</SplitButton>
|
||||
</template>
|
||||
</SplitButton>
|
||||
</FeatureFlaggedRunButton>
|
||||
<BatchCountEdit />
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,6 +59,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div
|
||||
:class="buttonClasses"
|
||||
:style="buttonStyles"
|
||||
class="experimental-run-button relative overflow-hidden transition-all duration-300"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Animated background for gradient variant -->
|
||||
<div
|
||||
v-if="variantName === 'bold-gradient'"
|
||||
class="absolute inset-0 animate-gradient bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 opacity-90"
|
||||
/>
|
||||
|
||||
<!-- Pulsing animation for animated variant -->
|
||||
<div
|
||||
v-if="variantName === 'animated'"
|
||||
class="absolute inset-0 animate-pulse bg-primary-background opacity-20"
|
||||
/>
|
||||
|
||||
<!-- Sparkle effect for playful variant -->
|
||||
<div
|
||||
v-if="variantName === 'playful'"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<i
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="sparkleClasses[i - 1]"
|
||||
class="absolute animate-sparkle text-yellow-300"
|
||||
:style="sparkleStyles[i - 1]"
|
||||
>
|
||||
✨
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<!-- Button content -->
|
||||
<div class="relative z-10 flex items-center justify-center gap-2">
|
||||
<i :class="iconClass" class="text-lg" />
|
||||
<span :class="labelClasses" :style="labelStyles" class="font-semibold">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
|
||||
|
||||
const props = defineProps<{
|
||||
variant: FeatureFlagVariant
|
||||
onClick: () => void
|
||||
}>()
|
||||
|
||||
const variantName = computed(() => props.variant.variant)
|
||||
const payload = computed(() => props.variant.payload || {})
|
||||
|
||||
// Extract styling from payload with defaults
|
||||
const backgroundColor = computed(
|
||||
() => (payload.value.backgroundColor as string) || getDefaultColor()
|
||||
)
|
||||
const textColor = computed(
|
||||
() => (payload.value.textColor as string) || getDefaultTextColor()
|
||||
)
|
||||
const borderRadius = computed(
|
||||
() => (payload.value.borderRadius as string) || getDefaultBorderRadius()
|
||||
)
|
||||
const icon = computed(() => (payload.value.icon as string) || getDefaultIcon())
|
||||
const label = computed(() => (payload.value.label as string) || 'Run')
|
||||
const padding = computed(() => (payload.value.padding as string) || 'px-4 py-2')
|
||||
|
||||
// Size matching - should match PrimeVue small button size
|
||||
const buttonSize = computed(() => {
|
||||
return 'text-sm' // Match PrimeVue small button
|
||||
})
|
||||
|
||||
function getDefaultColor(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'transparent' // Gradient overlay handles it
|
||||
case 'animated':
|
||||
return 'bg-primary-background'
|
||||
case 'playful':
|
||||
return 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
case 'minimal':
|
||||
return 'bg-white border-2 border-gray-300'
|
||||
default:
|
||||
return 'bg-primary-background'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultTextColor(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'text-white'
|
||||
case 'animated':
|
||||
return 'text-white'
|
||||
case 'playful':
|
||||
return 'text-white'
|
||||
case 'minimal':
|
||||
return 'text-gray-800'
|
||||
default:
|
||||
return 'text-white'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultBorderRadius(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'rounded-xl'
|
||||
case 'animated':
|
||||
return 'rounded-full'
|
||||
case 'playful':
|
||||
return 'rounded-2xl'
|
||||
case 'minimal':
|
||||
return 'rounded-md'
|
||||
default:
|
||||
return 'rounded-lg'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultIcon(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'icon-[lucide--zap]'
|
||||
case 'animated':
|
||||
return 'icon-[lucide--rocket]'
|
||||
case 'playful':
|
||||
return 'icon-[lucide--sparkles]'
|
||||
case 'minimal':
|
||||
return 'icon-[lucide--play]'
|
||||
default:
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const base = [
|
||||
'cursor-pointer',
|
||||
'select-none',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
padding.value,
|
||||
borderRadius.value,
|
||||
'shadow-lg',
|
||||
'hover:scale-105',
|
||||
'active:scale-95',
|
||||
'transition-transform',
|
||||
buttonSize.value
|
||||
]
|
||||
|
||||
// Add variant-specific classes
|
||||
if (variantName.value === 'bold-gradient') {
|
||||
base.push('text-white', 'font-bold')
|
||||
} else if (variantName.value === 'animated') {
|
||||
base.push('text-white', 'font-bold', 'hover:shadow-2xl')
|
||||
} else if (variantName.value === 'playful') {
|
||||
base.push('text-white', 'font-bold', 'hover:rotate-1')
|
||||
} else if (variantName.value === 'minimal') {
|
||||
base.push('bg-white', 'text-gray-800', 'hover:bg-gray-50')
|
||||
} else {
|
||||
base.push(backgroundColor.value, textColor.value)
|
||||
}
|
||||
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
const buttonStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
|
||||
// Apply custom styles from payload
|
||||
if (payload.value.backgroundColor && variantName.value !== 'bold-gradient') {
|
||||
styles.backgroundColor = backgroundColor.value
|
||||
}
|
||||
if (payload.value.textColor) {
|
||||
styles.color = textColor.value
|
||||
}
|
||||
if (payload.value.borderRadius && !borderRadius.value.includes('rounded')) {
|
||||
styles.borderRadius = borderRadius.value
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
|
||||
const iconClass = computed(() => icon.value)
|
||||
|
||||
// Text color handling - can be a CSS class or a color value
|
||||
const labelClasses = computed(() => {
|
||||
// If textColor is a Tailwind class, return it; otherwise it's handled by inline styles
|
||||
if (textColor.value.startsWith('text-')) {
|
||||
return textColor.value
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const labelStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
// If textColor is not a Tailwind class, use it as a color value
|
||||
if (!textColor.value.startsWith('text-')) {
|
||||
styles.color = textColor.value
|
||||
}
|
||||
return styles
|
||||
})
|
||||
|
||||
// Sparkle animation positions for playful variant
|
||||
const sparkleClasses = ['top-2 left-4', 'top-4 right-6', 'bottom-2 left-1/2']
|
||||
|
||||
const sparkleStyles = [
|
||||
{ animationDelay: '0s', animationDuration: '2s' },
|
||||
{ animationDelay: '0.5s', animationDuration: '2.5s' },
|
||||
{ animationDelay: '1s', animationDuration: '3s' }
|
||||
]
|
||||
|
||||
function handleClick() {
|
||||
props.onClick()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes gradient {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-sparkle {
|
||||
animation: sparkle 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="feature-flagged-run-button">
|
||||
<!-- Control: Original button -->
|
||||
<slot v-if="!isExperimentActive" name="control" />
|
||||
|
||||
<!-- Experiment: Experimental button -->
|
||||
<ExperimentalRunButton
|
||||
v-else-if="variant"
|
||||
:variant="variant"
|
||||
:on-click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
|
||||
|
||||
import ExperimentalRunButton from './ExperimentalRunButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
flagKey?: string
|
||||
onClick: (e: Event) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const flagKey = computed(() => props.flagKey || 'demo-run-button-experiment')
|
||||
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const flagValue = featureFlag<FeatureFlagVariant | boolean | null>(
|
||||
flagKey.value,
|
||||
false
|
||||
)
|
||||
|
||||
const variant = computed<FeatureFlagVariant | null>(() => {
|
||||
const value = flagValue.value
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'variant' in value &&
|
||||
typeof (value as FeatureFlagVariant).variant === 'string'
|
||||
) {
|
||||
return value as FeatureFlagVariant
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isExperimentActive = computed(() => {
|
||||
return variant.value !== null
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
// Create a synthetic event for the onClick handler
|
||||
const syntheticEvent = new Event('click') as Event
|
||||
props.onClick(syntheticEvent)
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import { computed, reactive, readonly, ref } from 'vue'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -14,6 +14,17 @@ export enum ServerFeatureFlag {
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag variant structure for experiments
|
||||
*/
|
||||
export interface FeatureFlagVariant {
|
||||
variant: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Demo mode: allows manual override for demonstration
|
||||
const demoOverrides = ref<Record<string, unknown>>({})
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -51,7 +62,35 @@ export function useFeatureFlags() {
|
||||
})
|
||||
|
||||
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
|
||||
computed(() => api.getServerFeature(featurePath, defaultValue))
|
||||
computed(() => {
|
||||
// Check demo overrides first
|
||||
if (demoOverrides.value[featurePath] !== undefined) {
|
||||
return demoOverrides.value[featurePath] as T
|
||||
}
|
||||
// Check remote config (from /api/features) - convert hyphens to underscores for lookup
|
||||
const remoteConfigKey = featurePath.replace(/-/g, '_')
|
||||
const remoteValue = (remoteConfig.value as Record<string, unknown>)[
|
||||
remoteConfigKey
|
||||
]
|
||||
if (remoteValue !== undefined) {
|
||||
return remoteValue as T
|
||||
}
|
||||
// Fall back to server feature flags (WebSocket) - try both hyphen and underscore versions
|
||||
const wsValue = api.getServerFeature(featurePath, undefined)
|
||||
if (wsValue !== undefined) {
|
||||
return wsValue as T
|
||||
}
|
||||
// Try underscore version for WebSocket flags
|
||||
const wsValueUnderscore = api.getServerFeature(
|
||||
featurePath.replace(/-/g, '_'),
|
||||
undefined
|
||||
)
|
||||
if (wsValueUnderscore !== undefined) {
|
||||
return wsValueUnderscore as T
|
||||
}
|
||||
// Return default if nothing found
|
||||
return defaultValue as T
|
||||
})
|
||||
|
||||
return {
|
||||
flags: readonly(flags),
|
||||
|
||||
Reference in New Issue
Block a user