diff --git a/README.md b/README.md index b0d1938..d9c1d3f 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,15 @@ An immersive RPG extension for browsers that tracks character stats, scene infor ## 🆕 What's New -### v3.5.0 +### v3.6.0 -- Various fixes and upgrades to the existing systems. -- Repaired Auto-generate Avatars. -- Fixed Dynami Weather on mobiles. -- Added an option to decide where to display the weather effects (foreground or background). -- Unified CSS. -- Dice rolls are now sent with the prompt even if you don't have Attributes toggled on. +- You can now choose whether stats are displayed as percentages or numbers. +- Added collapsed strip widgets for desktop. +- Added new effects for the dynamic weather. +- Changed the displayed clock format in the Info Box. +- Fixed customized status field to work. +- Fixed date format toggles. +- Minor CSS and bug fixes. **Special thanks to all the other contributors for this project:** Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610. diff --git a/manifest.json b/manifest.json index 85769dd..c8ae75e 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Marinara", - "version": "3.4.1", + "version": "3.6.0", "homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern" } diff --git a/settings.html b/settings.html index dae88f6..79fc70f 100644 --- a/settings.html +++ b/settings.html @@ -48,7 +48,7 @@
- v3.5.0 + v3.6.0
diff --git a/src/core/persistence.js b/src/core/persistence.js index 2434f96..15b6165 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -147,6 +147,12 @@ export function loadSettings() { // Migrate to preset manager system if presets don't exist migrateToPresetManager(); + + // Initialize custom status fields + initializeCustomStatusFields(); + + // Ensure all stats have maxValue (for number display mode) + ensureStatsHaveMaxValue(); } catch (error) { console.error('[RPG Companion] Error loading settings:', error); console.error('[RPG Companion] Error details:', error.message, error.stack); @@ -694,6 +700,45 @@ export function migrateToPresetManager() { } } +/** + * Initializes custom status fields in userStats based on trackerConfig + * Ensures all defined custom status fields have a value in the userStats object + */ +function initializeCustomStatusFields() { + const customFields = extensionSettings.trackerConfig?.userStats?.statusSection?.customFields || []; + + // Initialize each custom field if it doesn't exist + for (const fieldName of customFields) { + const fieldKey = fieldName.toLowerCase(); + if (extensionSettings.userStats[fieldKey] === undefined) { + extensionSettings.userStats[fieldKey] = 'None'; + // console.log(`[RPG Companion] Initialized custom status field: ${fieldKey}`); + } + } +} + +/** + * Ensures all custom stats have a maxValue property + * This migration supports the number display mode feature + */ +function ensureStatsHaveMaxValue() { + const customStats = extensionSettings.trackerConfig?.userStats?.customStats || []; + + for (const stat of customStats) { + if (stat && stat.maxValue === undefined) { + stat.maxValue = 100; // Default to 100 for backward compatibility + // console.log(`[RPG Companion] Added maxValue to stat: ${stat.id || stat.name}`); + } + } + + // Ensure statsDisplayMode is set (default to percentage) + if (extensionSettings.trackerConfig?.userStats && + extensionSettings.trackerConfig.userStats.statsDisplayMode === undefined) { + extensionSettings.trackerConfig.userStats.statsDisplayMode = 'percentage'; + // console.log('[RPG Companion] Initialized statsDisplayMode to percentage'); + } +} + /** * Gets all available presets * @returns {Object} Map of preset ID to preset data diff --git a/src/core/state.js b/src/core/state.js index 178d213..acc3b13 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -86,7 +86,7 @@ export let extensionSettings = { }, // Desktop strip widget display options (shown in collapsed panel strip) desktopStripWidgets: { - enabled: false, // Master toggle for strip widgets (disabled by default) + enabled: true, // Master toggle for strip widgets (enabled by default) weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.) clock: { enabled: true }, // Current time display date: { enabled: true }, // Date display @@ -125,13 +125,15 @@ export let extensionSettings = { // Tracker customization configuration trackerConfig: { userStats: { + // Stats display mode: 'percentage' or 'number' + statsDisplayMode: 'percentage', // Array of custom stats (allows add/remove/rename) customStats: [ - { id: 'health', name: 'Health', enabled: true, persistInHistory: false }, - { id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false }, - { id: 'energy', name: 'Energy', enabled: true, persistInHistory: false }, - { id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false }, - { id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false } + { id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 }, + { id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 } ], // RPG Attributes (customizable D&D-style attributes) showRPGAttributes: true, diff --git a/src/systems/generation/jsonPromptHelpers.js b/src/systems/generation/jsonPromptHelpers.js index 21fbad8..61c9d30 100644 --- a/src/systems/generation/jsonPromptHelpers.js +++ b/src/systems/generation/jsonPromptHelpers.js @@ -28,6 +28,7 @@ export function buildUserStatsJSONInstruction() { const trackerConfig = extensionSettings.trackerConfig; const userStatsConfig = trackerConfig?.userStats; const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || []; + const displayMode = userStatsConfig?.statsDisplayMode || 'percentage'; let instruction = '{\n'; instruction += ' "stats": [\n'; @@ -36,7 +37,12 @@ export function buildUserStatsJSONInstruction() { for (let i = 0; i < enabledStats.length; i++) { const stat = enabledStats[i]; const comma = i < enabledStats.length - 1 ? ',' : ''; - instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma}\n`; + if (displayMode === 'number') { + const maxValue = stat.maxValue || 100; + instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma} // 0 to ${maxValue}\n`; + } else { + instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma} // 0 to 100 (percentage)\n`; + } } instruction += ' ],\n'; @@ -45,9 +51,24 @@ export function buildUserStatsJSONInstruction() { if (userStatsConfig?.statusSection?.enabled) { instruction += ' "status": {\n'; if (userStatsConfig.statusSection.showMoodEmoji) { - instruction += ' "mood": "Mood Emoji",\n'; + instruction += ' "mood": "Mood Emoji"'; + } + // Add all custom status fields + const customFields = userStatsConfig.statusSection.customFields || []; + if (customFields.length > 0) { + for (let i = 0; i < customFields.length; i++) { + const fieldName = customFields[i].toLowerCase(); + const fieldKey = toSnakeCase(fieldName); + const comma = (i === customFields.length - 1 && !userStatsConfig.statusSection.showMoodEmoji) ? '' : (userStatsConfig.statusSection.showMoodEmoji || i < customFields.length - 1 ? ',\n' : '\n'); + if (i === 0 && userStatsConfig.statusSection.showMoodEmoji) { + instruction += ',\n'; + } + instruction += ` "${fieldKey}": "[${fieldName}1, ${fieldName}2]"${comma}`; + } + } + if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) { + instruction += '\n'; } - instruction += ' "conditions": "[Condition1, Condition2]"\n'; instruction += ' },\n'; } @@ -105,7 +126,8 @@ export function buildInfoBoxJSONInstruction() { let hasFields = false; if (widgets.date?.enabled) { - instruction += ' "date": {"value": "Weekday, Month, Year"}'; + const dateFormat = widgets.date.format || 'Weekday, Month, Year'; + instruction += ` "date": {"value": "${dateFormat}"}`; hasFields = true; } diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index f0bdd05..d2feb7b 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -547,9 +547,15 @@ export function parseUserStats(statsText) { extensionSettings.userStats.mood = statsData.status.mood; // console.log('[RPG Parser] ✓ Set mood =', statsData.status.mood); } - if (statsData.status.conditions) { - extensionSettings.userStats.conditions = statsData.status.conditions; - // console.log('[RPG Parser] ✓ Set conditions =', statsData.status.conditions); + // Extract all custom status fields + const trackerConfig = extensionSettings.trackerConfig; + const customFields = trackerConfig?.userStats?.statusSection?.customFields || []; + for (const fieldName of customFields) { + const fieldKey = fieldName.toLowerCase(); + if (statsData.status[fieldKey]) { + extensionSettings.userStats[fieldKey] = statsData.status[fieldKey]; + // console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, statsData.status[fieldKey]); + } } } @@ -679,6 +685,7 @@ export function parseUserStats(statsText) { const statusConfig = trackerConfig?.userStats?.statusSection; if (statusConfig?.enabled) { let moodMatch = null; + const customFields = statusConfig.customFields || []; // Try Status: format const statusMatch = statsText.match(/Status:\s*(.+)/i); @@ -691,14 +698,30 @@ export function parseUserStats(statsText) { if (emoji) { extensionSettings.userStats.mood = emoji; // Remaining text contains custom status fields - if (text) { - extensionSettings.userStats.conditions = text; + if (text && customFields.length > 0) { + // For first custom field, use the remaining text + const firstFieldKey = customFields[0].toLowerCase(); + extensionSettings.userStats[firstFieldKey] = text; } moodMatch = true; } } else { - // No mood emoji, whole status is conditions - extensionSettings.userStats.conditions = statusContent; + // No mood emoji, whole status goes to first custom field + if (customFields.length > 0) { + const firstFieldKey = customFields[0].toLowerCase(); + extensionSettings.userStats[firstFieldKey] = statusContent; + } + moodMatch = true; + } + } + + // Try to extract individual custom status fields by name + for (const fieldName of customFields) { + const fieldKey = fieldName.toLowerCase(); + const fieldRegex = new RegExp(`${fieldName}:\\s*(.+?)(?:,|$)`, 'i'); + const fieldMatch = statsText.match(fieldRegex); + if (fieldMatch) { + extensionSettings.userStats[fieldKey] = fieldMatch[1].trim(); moodMatch = true; } } @@ -706,7 +729,10 @@ export function parseUserStats(statsText) { debugLog('[RPG Parser] Status match:', { found: !!moodMatch, mood: extensionSettings.userStats.mood, - conditions: extensionSettings.userStats.conditions + customFields: customFields.map(f => ({ + name: f, + value: extensionSettings.userStats[f.toLowerCase()] + })) }); } diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index f74b152..0e43360 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -485,11 +485,22 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) { // Handle common object formats if (field && typeof field === 'object') { - // Status object: {mood, conditions} - if ('mood' in field && 'conditions' in field) { + // Status object: {mood, [customFields...]} + if ('mood' in field) { + const statusParts = []; const mood = getValue(field.mood); - const conditions = getValue(field.conditions); - return `${mood} - ${conditions}`; + if (mood) statusParts.push(mood); + + // Add all other status fields (custom fields) + for (const [key, value] of Object.entries(field)) { + if (key !== 'mood') { + const fieldValue = getValue(value); + if (fieldValue && fieldValue !== 'None') { + statusParts.push(fieldValue); + } + } + } + return statusParts.join(' - '); } // Skill/item/quest objects: {name}, {title}, {name, quantity} @@ -830,9 +841,17 @@ export function formatHistoricalTrackerData(trackerData, trackerConfig, userName // Status section if (shouldInclude(userStatsConfig.statusSection) && userStatsData.status) { const mood = getValue(userStatsData.status.mood || userStatsData.status); - const conditions = getValue(userStatsData.status.conditions); - if (mood) statsFormatted += `Mood: ${mood}, `; - if (conditions && conditions !== 'None') statsFormatted += `Conditions: ${conditions}, `; + if (mood && userStatsConfig.statusSection.showMoodEmoji) statsFormatted += `Mood: ${mood}, `; + + // Add all custom status fields + const customFields = userStatsConfig.statusSection.customFields || []; + for (const fieldName of customFields) { + const fieldKey = fieldName.toLowerCase(); + const fieldValue = getValue(userStatsData.status[fieldKey]); + if (fieldValue && fieldValue !== 'None') { + statsFormatted += `${fieldName}: ${fieldValue}, `; + } + } } // Skills section diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js index 88eed94..31cbe16 100644 --- a/src/systems/rendering/userStats.js +++ b/src/systems/rendering/userStats.js @@ -105,7 +105,8 @@ function updateUserStatsData() { // Then, add any other numeric stats from extensionSettings that aren't in config // (these could be custom stats the AI added or disabled stats) - const excludeFields = new Set(['mood', 'conditions', 'inventory', 'skills', 'level']); + const customFields = config.statusSection?.customFields || []; + const excludeFields = new Set(['mood', ...customFields.map(f => f.toLowerCase()), 'inventory', 'skills', 'level']); Object.entries(stats).forEach(([key, value]) => { if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') { statsArray.push({ @@ -118,12 +119,17 @@ function updateUserStatsData() { jsonData.stats = statsArray; - // Update status + // Update status - include all custom status fields jsonData.status = { - mood: stats.mood || '😐', - conditions: stats.conditions || 'None' + mood: stats.mood || '😐' }; + // Add all custom status fields + for (const fieldName of customFields) { + const fieldKey = fieldName.toLowerCase(); + jsonData.status[fieldKey] = stats[fieldKey] || 'None'; + } + // Update inventory (convert to v3 format) const convertToV3Items = (itemString) => { if (!itemString) return []; @@ -276,16 +282,33 @@ export function renderUserStats() { } html += '
'; const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id); + const displayMode = config.statsDisplayMode || 'percentage'; for (const stat of enabledStats) { const value = stats[stat.id] !== undefined ? stats[stat.id] : 100; + const maxValue = stat.maxValue || 100; + + // Calculate percentage for bar fill + let percentage; + let displayValue; + + if (displayMode === 'number') { + // In number mode, value is already the number (0 to maxValue) + percentage = maxValue > 0 ? (value / maxValue) * 100 : 100; + displayValue = `${value}/${maxValue}`; + } else { + // In percentage mode, value is 0-100 + percentage = value; + displayValue = `${value}%`; + } + html += `
${stat.name}:
-
+
- ${value}% + ${displayValue}
`; } @@ -308,13 +331,15 @@ export function renderUserStats() { // Render custom status fields if (config.statusSection.customFields && config.statusSection.customFields.length > 0) { - // For now, use first field as "conditions" for backward compatibility - let conditionsValue = stats.conditions || 'None'; - // Strip brackets if present (from JSON array format) - if (typeof conditionsValue === 'string') { - conditionsValue = conditionsValue.replace(/^\[|\]$/g, '').trim(); + for (const fieldName of config.statusSection.customFields) { + const fieldKey = fieldName.toLowerCase(); + let fieldValue = stats[fieldKey] || 'None'; + // Strip brackets if present (from JSON array format) + if (typeof fieldValue === 'string') { + fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim(); + } + html += `
${fieldValue}
`; } - html += `
${conditionsValue}
`; } html += '
'; @@ -406,14 +431,31 @@ export function renderUserStats() { // Add event listeners for editable stat values $('.rpg-editable-stat').on('blur', function() { const field = $(this).data('field'); - const textValue = $(this).text().replace('%', '').trim(); - let value = parseInt(textValue); + const mode = $(this).data('mode'); + const maxValue = parseInt($(this).data('max')) || 100; + const textValue = $(this).text().trim(); + let value; - // Validate and clamp value between 0 and 100 - if (isNaN(value)) { - value = 0; + if (mode === 'number') { + // In number mode, parse "X/MAX" or just "X" + const parts = textValue.split('/'); + value = parseInt(parts[0]); + + // Validate and clamp value between 0 and maxValue + if (isNaN(value)) { + value = 0; + } + value = Math.max(0, Math.min(maxValue, value)); + } else { + // In percentage mode, parse "X%" or just "X" + value = parseInt(textValue.replace('%', '')); + + // Validate and clamp value between 0 and 100 + if (isNaN(value)) { + value = 0; + } + value = Math.max(0, Math.min(100, value)); } - value = Math.max(0, Math.min(100, value)); // Update the setting extensionSettings.userStats[field] = value; @@ -445,7 +487,8 @@ export function renderUserStats() { $('.rpg-mood-conditions.rpg-editable').on('blur', function() { const value = $(this).text().trim(); - extensionSettings.userStats.conditions = value || 'None'; + const fieldKey = $(this).data('field'); + extensionSettings.userStats[fieldKey] = value || 'None'; // Update userStats data (maintains JSON or text format) updateUserStatsData(); diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 57c8463..30b7cdc 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -729,13 +729,27 @@ function renderUserStatsTab() { // Custom Stats section html += `

${i18n.getTranslation('template.trackerEditorModal.userStatsTab.customStatsTitle')}

`; + + // Stats display mode toggle + const statsDisplayMode = config.statsDisplayMode || 'percentage'; + html += '
'; + html += ''; + html += '
'; + html += ``; + html += ``; + html += '
'; + html += '
'; + html += '
'; config.customStats.forEach((stat, index) => { + const showMaxValue = statsDisplayMode === 'number'; + const maxValue = stat.maxValue || 100; html += `
+
`; @@ -845,7 +859,8 @@ function setupUserStatsListeners() { extensionSettings.trackerConfig.userStats.customStats.push({ id: newId, name: 'New Stat', - enabled: true + enabled: true, + maxValue: 100 }); // Initialize value if doesn't exist if (extensionSettings.userStats[newId] === undefined) { @@ -873,6 +888,19 @@ function setupUserStatsListeners() { extensionSettings.trackerConfig.userStats.customStats[index].name = $(this).val(); }); + // Change stat max value + $('.rpg-stat-max').off('blur').on('blur', function() { + const index = $(this).data('index'); + const value = parseInt($(this).val()) || 100; + extensionSettings.trackerConfig.userStats.customStats[index].maxValue = Math.max(1, value); + }); + + // Stats display mode toggle + $('input[name="stats-display-mode"]').off('change').on('change', function() { + extensionSettings.trackerConfig.userStats.statsDisplayMode = $(this).val(); + renderUserStatsTab(); // Re-render to show/hide max value fields + }); + // Add attribute $('#rpg-add-attr').off('click').on('click', function() { // Ensure rpgAttributes array exists with defaults if needed @@ -979,9 +1007,7 @@ function renderInfoBoxTab() { html += ``; html += ''; html += '
'; diff --git a/style.css b/style.css index 740742d..109fcf5 100644 --- a/style.css +++ b/style.css @@ -1393,7 +1393,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { max-width: 100%; line-height: 1.1; min-width: 0; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .rpg-calendar-day-text { @@ -1418,6 +1419,24 @@ body:has(.rpg-panel.rpg-position-left) #sheld { overflow: hidden; } +/* Minimal scrollbar styling for calendar day */ +.rpg-calendar-day::-webkit-scrollbar { + width: 3px; +} + +.rpg-calendar-day::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-calendar-day::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.rpg-calendar-day::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + /* Weather Widget */ .rpg-weather-widget { display: flex; @@ -1588,6 +1607,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld { justify-content: center; gap: 0.25em; margin-top: 0.25em; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + white-space: nowrap; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; } .rpg-time-range .rpg-time-value { @@ -1601,6 +1626,24 @@ body:has(.rpg-panel.rpg-position-left) #sheld { opacity: 0.7; } +/* Minimal scrollbar styling for time range display */ +.rpg-time-range::-webkit-scrollbar { + height: 3px; +} + +.rpg-time-range::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-time-range::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.rpg-time-range::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + /* Location Widget - Map */ .rpg-map-bg { width: 100%; @@ -1650,10 +1693,27 @@ body:has(.rpg-panel.rpg-position-left) #sheld { hyphens: auto; flex: 1 1 auto; min-height: 0; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; +} + +/* Minimal scrollbar styling for location text */ +.rpg-location-text::-webkit-scrollbar { + width: 3px; +} + +.rpg-location-text::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-location-text::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.rpg-location-text::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); } /* Row 3: Recent Events */ @@ -4203,6 +4263,34 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 0.95em; } +.rpg-stat-max { + width: 5em; + padding: 0.375em 0.5em; + background: var(--rpg-bg); + border: 1px solid var(--rpg-border); + border-radius: 0.25em; + color: var(--rpg-text); + font-size: 0.95em; + text-align: center; +} + +.rpg-hidden { + display: none !important; +} + +.rpg-radio-group { + display: flex; + gap: 1em; + align-items: center; +} + +.rpg-radio-group label { + display: flex; + align-items: center; + gap: 0.375em; + cursor: pointer; +} + .rpg-stat-remove, .rpg-attr-remove, .rpg-remove-relationship { @@ -5742,6 +5830,10 @@ body:has(.rpg-panel.rpg-mobile-open) .rpg-fab-widget-container { .rpg-time-range { gap: 0.15em; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + white-space: nowrap; } .rpg-time-separator { @@ -11303,7 +11395,7 @@ body:has(.rpg-panel[data-theme="light"]) .rpg-strip-widget { .rpg-strip-widget-container { display: none !important; } - + .rpg-panel.rpg-collapsed.rpg-strip-widgets-enabled { max-width: 2.5rem !important; min-width: 2.5rem !important; diff --git a/template.html b/template.html index c23cb3b..a3b7499 100644 --- a/template.html +++ b/template.html @@ -547,7 +547,7 @@
-

Desktop Collapsed Strip Widgets

+

Desktop Collapsed Strip Widgets

Show compact info widgets in the collapsed panel strip on desktop. Displays stats vertically without needing to expand the panel.