mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +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>
|
<template>
|
||||||
<div class="queue-button-group flex">
|
<div class="queue-button-group flex">
|
||||||
<SplitButton
|
<FeatureFlaggedRunButton
|
||||||
v-tooltip.bottom="{
|
flag-key="demo-run-button-experiment"
|
||||||
value: queueButtonTooltip,
|
:on-click="queuePrompt"
|
||||||
showDelay: 600
|
|
||||||
}"
|
|
||||||
class="comfyui-queue-button"
|
|
||||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
|
||||||
severity="primary"
|
|
||||||
size="small"
|
|
||||||
:model="queueModeMenuItems"
|
|
||||||
data-testid="queue-button"
|
|
||||||
@click="queuePrompt"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #control>
|
||||||
<i :class="iconClass" />
|
<SplitButton
|
||||||
</template>
|
v-tooltip.bottom="{
|
||||||
<template #item="{ item }">
|
value: queueButtonTooltip,
|
||||||
<Button
|
|
||||||
v-tooltip="{
|
|
||||||
value: item.tooltip,
|
|
||||||
showDelay: 600
|
showDelay: 600
|
||||||
}"
|
}"
|
||||||
:label="String(item.label ?? '')"
|
class="comfyui-queue-button"
|
||||||
:icon="item.icon"
|
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
severity="primary"
|
||||||
size="small"
|
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>
|
</template>
|
||||||
</SplitButton>
|
</FeatureFlaggedRunButton>
|
||||||
<BatchCountEdit />
|
<BatchCountEdit />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,6 +59,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
|||||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||||
|
|
||||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||||
|
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
|
||||||
|
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
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 { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
@@ -14,6 +14,17 @@ export enum ServerFeatureFlag {
|
|||||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
|
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
|
* Composable for reactive access to server-side feature flags
|
||||||
*/
|
*/
|
||||||
@@ -51,7 +62,35 @@ export function useFeatureFlags() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
|
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 {
|
return {
|
||||||
flags: readonly(flags),
|
flags: readonly(flags),
|
||||||
|
|||||||
Reference in New Issue
Block a user