mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(ui): lower-overhead Reduced effects for constrained GPUs (#1616)
* fix(ui): lower-overhead Reduced effects path for constrained GPUs Disable backdrop-filter glass and solidify chrome fills when data-effects is reduced (Calm default), with Appearance warning and docs for constrained rendering devices. Related to #1614. * test(ui): harden Reduced effects glass e2e and docs wording Parse Color Level 4 slash alpha so opacity asserts are not vacuously true, cover dialog/toast/overlay both ways with stable data-sn hooks, and soften troubleshooting copy that overstated the unmeasured diagnosis. * docs(ui): lead constrained-GPU guidance with Reduced Motion Rename the troubleshooting section to neutral GPU framing, point Appearance and docs at Reduced Motion first, and show the callout whenever Motion is off so Calm or Reduced effects alone do not hide the validated workaround.
This commit is contained in:
@@ -45,9 +45,13 @@ The **Chart palette** sets the colors used on the Security page charts:
|
||||
|
||||
## Motion & effects
|
||||
|
||||
- **Reduced effects** flattens card bevels, the accent glow, and chart gradients for a calmer, flatter surface.
|
||||
- **Reduced motion** minimizes interface animations and transitions (dialogs, menus, expand and collapse). Toasts are unaffected. On constrained graphics, this is the first control to try when the active Sencho tab feels heavy on idle.
|
||||
- When Reduced motion is off, Appearance shows a short notice about constrained graphics and points you at Reduced motion.
|
||||
- **Reduced effects** flattens card bevels, the accent glow, and chart gradients, and uses a simpler rendering path: glass blur is off and chrome fills are solid. Fresh installs get this through the default **Calm** visual style. Treat it as an optional secondary experiment for material cost; it is not a substitute for Reduced motion on the reported idle-GPU symptom. The impact varies by device, browser, and graphics configuration.
|
||||
- **Ambient glow** sets the intensity of the soft, accent-tinted glow behind the page. Slide it to zero for a completely flat background. (Reduced effects already dims it.)
|
||||
|
||||
If the active Sencho tab feels heavy on constrained graphics, see [High GPU usage on constrained graphics](/operations/troubleshooting#high-gpu-usage-on-constrained-graphics).
|
||||
|
||||
## Theme
|
||||
|
||||
The **Mode** control sets the overall surface palette:
|
||||
|
||||
@@ -9,6 +9,23 @@ description: Solutions to the most common Sencho setup and runtime problems.
|
||||
|
||||
If something has broken and you want a quick "what do I do now?" overview rather than a specific symptom, start with the [Recovery guide](/operations/recovery). It walks through getting back to a working state when Sencho, a deploy, sign-in, Docker, or a remote node fails, and links back here for the details.
|
||||
|
||||
## High GPU usage on constrained graphics
|
||||
|
||||
**Symptom:** With Sencho as the active browser tab, GPU use stays high on idle, and can climb further when an update or deploy dialog is open. Other sites in the same browser stay quieter. On some dual-GPU PCs the issue shows up only when the browser is forced onto integrated graphics.
|
||||
|
||||
**What helped on the reported Windows configuration:** Turning on **Reduced motion** dropped idle iGPU use substantially (about 50% to about 7% in that report). **Calm** and **Reduced effects** alone did not. On that machine the issue did not appear when Windows selected the GPU or when Chrome used the discrete GPU.
|
||||
|
||||
**What to try:**
|
||||
|
||||
1. Open **Settings → Appearance → Motion & effects**.
|
||||
2. Turn on **Reduced motion**. That is the first step to try for high idle GPU use on constrained graphics.
|
||||
3. Optionally try **Reduced effects** or **Calm** as a secondary experiment (simpler glass and solid chrome). That path is unverified for this symptom.
|
||||
4. On dual-GPU Windows PCs, you can also set the browser's GPU preference to let the OS decide, or prefer a discrete GPU, if your OS exposes that control.
|
||||
|
||||
**How to help validate a fix on your hardware:** Identify Chrome's GPU process (Shift+Esc), use Task Manager Details at High update speed on that PID's GPU column, leave the Sencho tab in the foreground for about 15 seconds, then record about ten one-second samples and take the median. Repeat for at least three independent runs (close and reopen the tab). Compare idle Home or Settings with Reduced motion off versus on. Separately compare an update or deploy dialog with Raw output hidden and visible. On Linux single-board devices, use one documented GPU or responsiveness counter for the whole matrix, and label CPU-only readings as secondary.
|
||||
|
||||
---
|
||||
|
||||
## Containers won't start after deploy
|
||||
|
||||
**Symptom:** You click Deploy and containers immediately exit or never appear.
|
||||
|
||||
@@ -118,7 +118,8 @@ Control how Sencho looks and how dense the workspace feels. Each browser remembe
|
||||
| Control | What it does |
|
||||
|---------|--------------|
|
||||
| **Chart palette** | Colors the Security page charts: **Muted**, **Heat** (one warm ramp), or **Signature** (saturated). |
|
||||
| **Reduced effects** | Flattens card bevels, the accent glow, and chart gradients. |
|
||||
| **Reduced motion** | Minimizes interface animations and transitions. First control to try for high idle GPU use on constrained graphics. Toasts are unaffected. |
|
||||
| **Reduced effects** | Flattens card bevels, the accent glow, and chart gradients, and turns off glass blur with solid chrome fills. Optional secondary material simplification; not a substitute for Reduced motion on the reported idle-GPU symptom. |
|
||||
| **Ambient glow** | Intensity of the accent-tinted page glow. |
|
||||
|
||||
### Theme and typography
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Reduced-effects glass correctness (not GPU %).
|
||||
*
|
||||
* Proves that data-effects="reduced" clears backdrop-filter and solidifies
|
||||
* chrome/floating fills, and that full-effects still keeps glass. Fresh Calm
|
||||
* installs already set reduced via theme-init; Signature keeps blur until the
|
||||
* operator opts into Reduced effects.
|
||||
*/
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
const CALM_STATE = {
|
||||
theme: 'dim',
|
||||
accent: 'cyan',
|
||||
borderBoost: 0,
|
||||
glow: 0.16,
|
||||
contrast: 0,
|
||||
uiFont: 'Geist',
|
||||
monoFont: 'Geist Mono',
|
||||
typeScale: 1,
|
||||
visualStyle: 'calm',
|
||||
headingStyle: 'clean',
|
||||
chartStyle: 'muted',
|
||||
reducedEffects: true,
|
||||
readability: false,
|
||||
reducedMotion: false,
|
||||
} as const;
|
||||
|
||||
const SIGNATURE_STATE = {
|
||||
...CALM_STATE,
|
||||
visualStyle: 'signature',
|
||||
headingStyle: 'signature',
|
||||
chartStyle: 'signature',
|
||||
reducedEffects: false,
|
||||
} as const;
|
||||
|
||||
async function seedAppearance(page: Page, state: Record<string, unknown>) {
|
||||
await page.addInitScript((payload) => {
|
||||
localStorage.setItem('sencho.appearance.theme', JSON.stringify(payload));
|
||||
}, state);
|
||||
}
|
||||
|
||||
async function computedBackdrop(locator: Locator): Promise<string> {
|
||||
return locator.evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
const v =
|
||||
cs.backdropFilter ||
|
||||
(cs as CSSStyleDeclaration & { webkitBackdropFilter?: string }).webkitBackdropFilter ||
|
||||
'none';
|
||||
return v.trim() || 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse alpha from getComputedStyle backgroundColor. Handles rgb/rgba,
|
||||
* space-separated rgb, and Color Level 4 forms that keep a slash alpha
|
||||
* (oklch / oklab / lab / lch / color / hsl / hwb). Returns 1 only when the
|
||||
* color is genuinely opaque or unparseable without an alpha channel.
|
||||
*/
|
||||
async function backgroundAlpha(locator: Locator): Promise<number> {
|
||||
return locator.evaluate((el) => {
|
||||
const bg = getComputedStyle(el).backgroundColor.trim();
|
||||
if (!bg || bg === 'transparent') return 0;
|
||||
|
||||
const slash = bg.match(/\/\s*([0-9]*\.?[0-9]+)\s*\)\s*$/);
|
||||
if (slash) {
|
||||
const n = Number(slash[1]);
|
||||
return Number.isFinite(n) ? n : 1;
|
||||
}
|
||||
|
||||
const rgbaComma = bg.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i);
|
||||
if (rgbaComma) {
|
||||
return rgbaComma[4] !== undefined ? Number(rgbaComma[4]) : 1;
|
||||
}
|
||||
|
||||
const rgbSpace = bg.match(/^rgba?\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*\/\s*([0-9.]+))?\s*\)$/i);
|
||||
if (rgbSpace) {
|
||||
return rgbSpace[4] !== undefined ? Number(rgbSpace[4]) : 1;
|
||||
}
|
||||
|
||||
// Modern color functions without a slash alpha are opaque.
|
||||
if (/^(oklch|oklab|lab|lch|color|hsl|hwb)\(/i.test(bg)) return 1;
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoBlur(locator: Locator) {
|
||||
const filter = await computedBackdrop(locator);
|
||||
expect(filter === 'none' || filter === 'none none').toBeTruthy();
|
||||
}
|
||||
|
||||
async function expectHasBlur(locator: Locator) {
|
||||
const filter = await computedBackdrop(locator);
|
||||
expect(filter === 'none' || filter === 'none none').toBeFalsy();
|
||||
}
|
||||
|
||||
async function expectOpaque(locator: Locator) {
|
||||
expect(await backgroundAlpha(locator)).toBeGreaterThan(0.99);
|
||||
}
|
||||
|
||||
async function expectTranslucent(locator: Locator) {
|
||||
expect(await backgroundAlpha(locator)).toBeLessThan(0.99);
|
||||
}
|
||||
|
||||
async function openAppearance(page: Page) {
|
||||
await page.getByRole('button', { name: /profile/i }).click();
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(page.getByText('Motion & effects')).toBeVisible();
|
||||
}
|
||||
|
||||
async function setThemeMode(page: Page, label: RegExp) {
|
||||
await openAppearance(page);
|
||||
await page.getByRole('radio', { name: label }).click();
|
||||
}
|
||||
|
||||
async function showInfoToast(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/src/components/ui/toast-store.ts');
|
||||
mod.toast.info('Glass opacity probe', { duration: 60_000 });
|
||||
});
|
||||
await expect(page.locator('[data-sn-glass="panel"]').filter({ hasText: 'Glass opacity probe' })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openCreateStackDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Create Stack' }).click();
|
||||
await expect(page.locator('[data-sn-chrome="dialog"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Reduced-effects glass', () => {
|
||||
test('cold load with Calm applies data-effects by DOMContentLoaded and clears chrome blur', async ({ page }) => {
|
||||
await seedAppearance(page, CALM_STATE);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
document.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
() => {
|
||||
(window as unknown as { __snEffectsAtDCL?: string | null }).__snEffectsAtDCL =
|
||||
document.documentElement.getAttribute('data-effects');
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
const effectsAtDomContent = await page.evaluate(
|
||||
() => (window as unknown as { __snEffectsAtDCL?: string | null }).__snEffectsAtDCL ?? null,
|
||||
);
|
||||
expect(effectsAtDomContent).toBe('reduced');
|
||||
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced');
|
||||
await expectNoBlur(page.locator('[data-sn-chrome="topbar"]'));
|
||||
await expectNoBlur(page.locator('[data-sn-chrome="sidebar"]'));
|
||||
await expectOpaque(page.locator('[data-sn-chrome="topbar"]'));
|
||||
await expectOpaque(page.locator('[data-sn-chrome="sidebar"]'));
|
||||
});
|
||||
|
||||
test('Signature chrome is translucent with blur; Reduced effects reverses both without reload', async ({ page }) => {
|
||||
await seedAppearance(page, SIGNATURE_STATE);
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
const topbar = page.locator('[data-sn-chrome="topbar"]');
|
||||
const sidebar = page.locator('[data-sn-chrome="sidebar"]');
|
||||
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced');
|
||||
await expectHasBlur(topbar);
|
||||
await expectHasBlur(sidebar);
|
||||
await expectTranslucent(sidebar);
|
||||
|
||||
await openAppearance(page);
|
||||
await expect(page.getByText('Constrained graphics', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('switch', { name: 'Reduced effects' }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced');
|
||||
await expect(page.getByText('Constrained graphics', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('switch', { name: 'Reduced motion' }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced');
|
||||
await expect(page.getByText('Constrained graphics', { exact: true })).toHaveCount(0);
|
||||
await expectNoBlur(topbar);
|
||||
await expectNoBlur(sidebar);
|
||||
await expectOpaque(sidebar);
|
||||
|
||||
await page.getByRole('button', { name: /profile/i }).click();
|
||||
const popover = page.locator('[data-sn-chrome="popover"]');
|
||||
await expect(popover).toBeVisible();
|
||||
await expectNoBlur(popover);
|
||||
await expectOpaque(popover);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('switch', { name: 'Reduced motion' }).click();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced');
|
||||
await expect(page.getByText('Constrained graphics', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('switch', { name: 'Reduced effects' }).click();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced');
|
||||
await expectHasBlur(topbar);
|
||||
await expectTranslucent(sidebar);
|
||||
});
|
||||
|
||||
test('dialog toast and overlay glass flip with Reduced effects both ways', async ({ page }) => {
|
||||
await seedAppearance(page, SIGNATURE_STATE);
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
// Dialog (Create Stack uses Modal -> DialogContent)
|
||||
await openCreateStackDialog(page);
|
||||
const dialog = page.locator('[data-sn-chrome="dialog"]');
|
||||
await expectHasBlur(dialog);
|
||||
// Dialog uses bg-popover; under Signature --pop-a is translucent.
|
||||
await expectTranslucent(dialog);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
// Panel toast (data-sn-glass="panel")
|
||||
await showInfoToast(page);
|
||||
const toastPanel = page.locator('[data-sn-glass="panel"]').filter({ hasText: 'Glass opacity probe' });
|
||||
await expectHasBlur(toastPanel);
|
||||
await expectTranslucent(toastPanel);
|
||||
|
||||
// Overlay probe: mount the same contract as Terminal overlay chrome so we
|
||||
// exercise data-sn-glass="overlay" without depending on a live PTY.
|
||||
await page.evaluate(() => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-sn-glass', 'overlay');
|
||||
el.setAttribute('data-testid', 'glass-overlay-probe');
|
||||
el.className = 'fixed bottom-4 left-4 z-[100] h-10 w-40 bg-background/80 backdrop-blur-sm';
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
const overlay = page.getByTestId('glass-overlay-probe');
|
||||
await expect(overlay).toBeVisible();
|
||||
await expectHasBlur(overlay);
|
||||
await expectTranslucent(overlay);
|
||||
|
||||
await openAppearance(page);
|
||||
await page.getByRole('switch', { name: 'Reduced effects' }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced');
|
||||
|
||||
await expectNoBlur(toastPanel);
|
||||
await expectOpaque(toastPanel);
|
||||
await expectNoBlur(overlay);
|
||||
await expectOpaque(overlay);
|
||||
|
||||
await openCreateStackDialog(page);
|
||||
await expectNoBlur(dialog);
|
||||
await expectOpaque(dialog);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Back to full effects without reload: translucency and blur return.
|
||||
await page.getByRole('switch', { name: 'Reduced effects' }).click();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced');
|
||||
await expectHasBlur(toastPanel);
|
||||
await expectTranslucent(toastPanel);
|
||||
await expectHasBlur(overlay);
|
||||
await expectTranslucent(overlay);
|
||||
|
||||
await openCreateStackDialog(page);
|
||||
await expectHasBlur(dialog);
|
||||
await expectTranslucent(dialog);
|
||||
});
|
||||
|
||||
test('Dim OLED Light and Auto media prefs keep reduced chrome opaque', async ({ page }) => {
|
||||
await seedAppearance(page, CALM_STATE);
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
for (const mode of [
|
||||
{ radio: /^Dim$/i, theme: 'dim' },
|
||||
{ radio: /^OLED$/i, theme: 'oled' },
|
||||
{ radio: /^Light$/i, theme: 'light' },
|
||||
] as const) {
|
||||
await setThemeMode(page, mode.radio);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', mode.theme);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced');
|
||||
await expectNoBlur(page.locator('[data-sn-chrome="topbar"]'));
|
||||
await expectOpaque(page.locator('[data-sn-chrome="sidebar"]'));
|
||||
}
|
||||
|
||||
await setThemeMode(page, /^Auto$/i);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dim');
|
||||
await expectNoBlur(page.locator('[data-sn-chrome="topbar"]'));
|
||||
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
await expectNoBlur(page.locator('[data-sn-chrome="topbar"]'));
|
||||
});
|
||||
|
||||
test('mobile tab bar clears blur and solidifies under reduced effects', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await seedAppearance(page, CALM_STATE);
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
const tabBar = page.locator('[data-sn-glass="mobile-tabbar"]');
|
||||
await expect(tabBar).toBeVisible();
|
||||
await expectNoBlur(tabBar);
|
||||
await expectOpaque(tabBar);
|
||||
});
|
||||
|
||||
test('Reduced motion hides the constrained-graphics callout', async ({ page }) => {
|
||||
await seedAppearance(page, SIGNATURE_STATE);
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
await openAppearance(page);
|
||||
|
||||
await expect(page.getByText('Constrained graphics', { exact: true })).toBeVisible();
|
||||
await page.getByRole('switch', { name: 'Reduced motion' }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced');
|
||||
await expect(page.getByText('Constrained graphics', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ function DeployFeedbackPillBase({ isVisible, onExpand }: DeployFeedbackPillProps
|
||||
onClick={onExpand}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 w-[280px] bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-glass-border rounded-full px-3 py-1.5 shadow-lg cursor-pointer flex items-center gap-2 max-md:bottom-[calc(var(--sn-mobile-tabbar-h)_+_env(safe-area-inset-bottom)_+_0.75rem)]"
|
||||
data-sn-glass="panel"
|
||||
>
|
||||
<span className={dotClass} />
|
||||
<span className={cn(textClass, 'flex items-center gap-1 min-w-0 flex-1 overflow-hidden')}>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
|
||||
}, [timedOut, preUpdateStartedAt]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-[10px] backdrop-saturate-[1.15]" data-sn-glass="overlay">
|
||||
<div className="text-center space-y-4">
|
||||
{timedOut ? (
|
||||
<>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UpdateStatusBadge({ status, error, onRetry, onDismiss }: UpdateS
|
||||
<div className="h-2 w-2 rounded-full bg-destructive/60" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" sideOffset={8} align="end">
|
||||
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-lg px-3 py-2 max-w-xs">
|
||||
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-lg px-3 py-2 max-w-xs" data-sn-glass="panel">
|
||||
<p className="font-mono tabular-nums text-xs text-stat-subtitle">{error}</p>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
|
||||
@@ -559,7 +559,7 @@ export function GlobalObservabilityView({ headerActions }: GlobalObservabilityVi
|
||||
</button>
|
||||
)}
|
||||
{fabOpen ? (
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]" data-sn-glass="panel">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10" onClick={() => { setIsPaused(p => !p); setFabOpen(false); }} aria-label={isPaused ? 'Resume stream' : 'Pause stream'}>
|
||||
{isPaused ? <Play className="h-4 w-4" strokeWidth={1.5} /> : <Pause className="h-4 w-4" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
@@ -579,6 +579,7 @@ export function GlobalObservabilityView({ headerActions }: GlobalObservabilityVi
|
||||
onClick={() => setFabOpen(true)}
|
||||
aria-label="Log actions"
|
||||
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-glass-border bg-popover/95 text-stat-value shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]"
|
||||
data-sn-glass="panel"
|
||||
>
|
||||
{isPaused ? <Play className="h-5 w-5" strokeWidth={1.5} /> : <SlidersHorizontal className="h-5 w-5" strokeWidth={1.5} />}
|
||||
</button>
|
||||
@@ -595,7 +596,7 @@ export function GlobalObservabilityView({ headerActions }: GlobalObservabilityVi
|
||||
{pendingCount} new · resume
|
||||
</button>
|
||||
)}
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-md border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-md border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]" data-sn-glass="panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -275,7 +275,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
<div ref={terminalRef} style={{ width: '100%', height: '100%' }} />
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 right-6 z-10 flex items-center gap-2">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-md border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-md border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]" data-sn-glass="panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -77,6 +77,7 @@ export function MobileTabBar({
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary mobile"
|
||||
data-sn-glass="mobile-tabbar"
|
||||
className={cn(
|
||||
'md:hidden flex shrink-0 items-stretch',
|
||||
'border-t border-hairline',
|
||||
|
||||
@@ -366,7 +366,7 @@ export function NotificationPanel({
|
||||
<div className="max-h-[480px] overflow-y-auto border-t border-card-border/60">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="sticky top-0 z-10 border-b border-card-border/40 bg-popover/95 px-[var(--density-row-x)] py-[var(--density-cell-y)] font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<div className="sticky top-0 z-10 border-b border-card-border/40 bg-popover/95 px-[var(--density-row-x)] py-[var(--density-cell-y)] font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle backdrop-blur-[10px] backdrop-saturate-[1.15]" data-sn-glass="panel">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((notif) => (
|
||||
|
||||
@@ -269,12 +269,12 @@ export default function TerminalComponent({ stackName, nodeId, deploySessionId,
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
setIsSearchVisible(true);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" title="Search (Ctrl+F)">
|
||||
}} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" data-sn-glass="overlay" title="Search (Ctrl+F)">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-row items-center p-1 pr-1 bg-background/95 backdrop-blur-sm border border-border shadow-md rounded-md">
|
||||
<div className="flex flex-row items-center p-1 pr-1 bg-background/95 backdrop-blur-sm border border-border shadow-md rounded-md" data-sn-glass="overlay">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchText}
|
||||
@@ -296,7 +296,7 @@ export default function TerminalComponent({ stackName, nodeId, deploySessionId,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDownload} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" title="Download Logs">
|
||||
<Button variant="outline" size="sm" onClick={handleDownload} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" data-sn-glass="overlay" title="Download Logs">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
|
||||
@@ -47,6 +47,7 @@ export function TopBar({
|
||||
const centered = !showLabels && navAlign === 'center';
|
||||
return (
|
||||
<div
|
||||
data-sn-chrome="topbar"
|
||||
className={cn(
|
||||
'relative flex h-14 items-center gap-3 px-4',
|
||||
'border-b border-glass-border bg-sidebar backdrop-blur-md',
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptio
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
|
||||
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
@@ -240,9 +241,28 @@ export function AppearanceSection() {
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Motion & effects" kicker="this browser">
|
||||
{!reducedMotion ? (
|
||||
<SettingsCallout
|
||||
tone="warn"
|
||||
icon={<Info className="h-4 w-4" strokeWidth={1.5} />}
|
||||
title="Constrained graphics"
|
||||
subtitle="If the active Sencho tab feels heavy on integrated graphics or similar devices, turn on Reduced motion. Reduced effects and Calm alone may not be enough for that idle cost."
|
||||
/>
|
||||
) : null}
|
||||
<SettingsField
|
||||
label="Reduced motion"
|
||||
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). First control to try for high idle GPU use on constrained graphics. Toasts are unaffected."
|
||||
>
|
||||
<TogglePill
|
||||
checked={reducedMotion}
|
||||
onChange={setReducedMotion}
|
||||
aria-label="Reduced motion"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Reduced effects"
|
||||
helper="Flattens card bevels, the accent glow, and chart gradients for a calmer surface."
|
||||
helper="Flattens card bevels, the accent glow, and chart gradients, and turns off glass blur with solid chrome fills. Optional secondary material simplification; not a substitute for Reduced motion on reported idle GPU cost."
|
||||
>
|
||||
<TogglePill
|
||||
checked={effectiveReduced}
|
||||
@@ -252,17 +272,6 @@ export function AppearanceSection() {
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Reduced motion"
|
||||
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). Toasts are unaffected."
|
||||
>
|
||||
<TogglePill
|
||||
checked={reducedMotion}
|
||||
onChange={setReducedMotion}
|
||||
aria-label="Reduced motion"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
|
||||
@@ -35,6 +35,25 @@ describe('AppearanceSection', () => {
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('muted');
|
||||
});
|
||||
|
||||
it('shows the constrained-graphics callout when Reduced motion is off, and hides it when on', () => {
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.getByText('Constrained graphics')).toBeTruthy();
|
||||
|
||||
// Reduced effects alone must not hide the Motion guidance.
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Reduced effects' }));
|
||||
expect(screen.getByText('Constrained graphics')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Reduced motion' }));
|
||||
expect(screen.queryByText('Constrained graphics', { exact: true })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Reduced motion' }));
|
||||
expect(screen.getByText('Constrained graphics')).toBeTruthy();
|
||||
|
||||
// Readability does not enable Motion, so the callout stays.
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect(screen.getByText('Constrained graphics')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('readability locks the header + chart controls and disables the glow slider', () => {
|
||||
const { container } = render(<AppearanceSection />);
|
||||
// Baseline: nothing reduced, so no slider is disabled.
|
||||
|
||||
@@ -61,7 +61,10 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-64 max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
|
||||
<div
|
||||
data-sn-chrome="sidebar"
|
||||
className="w-64 max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col"
|
||||
>
|
||||
{/* On mobile the status masthead leads (it carries the node switcher as
|
||||
its kicker chip), so the in-sidebar brand and node rows are redundant
|
||||
there and hidden to save vertical space. */}
|
||||
|
||||
@@ -26,6 +26,7 @@ const DialogContent = React.forwardRef<
|
||||
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-[var(--scrim)] backdrop-blur-sm" />
|
||||
<AnimateDialogContent
|
||||
ref={ref}
|
||||
data-sn-chrome="dialog"
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-glass-border bg-popover p-6 shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] sm:rounded-lg',
|
||||
panelGlow && 'panel-glow',
|
||||
|
||||
@@ -22,6 +22,7 @@ const PopoverContent = React.forwardRef<
|
||||
"z-50 w-72 rounded-md border border-glass-border bg-popover p-4 text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
data-sn-chrome="popover"
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
|
||||
@@ -153,7 +153,7 @@ interface SheetHeaderBandProps {
|
||||
function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps) {
|
||||
const lastIdx = crumb.length - 1;
|
||||
return (
|
||||
<div className="relative bg-popover/95 backdrop-blur-md border-b border-card-border/60 px-6 pt-5 pb-4 pr-14">
|
||||
<div className="relative bg-popover/95 backdrop-blur-md border-b border-card-border/60 px-6 pt-5 pb-4 pr-14" data-sn-glass="panel">
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 w-[2px] bg-brand" />
|
||||
|
||||
<nav aria-label="Sheet location" className={cn(CRUMB_CLASS, 'flex items-center gap-1.5 leading-none')}>
|
||||
@@ -221,7 +221,7 @@ interface ToolbarBandProps {
|
||||
|
||||
function ToolbarBand({ primary, secondaries, destructive }: ToolbarBandProps) {
|
||||
return (
|
||||
<div className="bg-popover/95 backdrop-blur-md flex items-center gap-2 border-b border-card-border/60 px-6 py-3">
|
||||
<div className="bg-popover/95 backdrop-blur-md flex items-center gap-2 border-b border-card-border/60 px-6 py-3" data-sn-glass="panel">
|
||||
{primary && (
|
||||
<Button size="sm" onClick={primary.onClick} disabled={primary.disabled} className="gap-1.5">
|
||||
{primary.icon && <primary.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
@@ -266,7 +266,7 @@ interface TabsBandProps {
|
||||
|
||||
function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) {
|
||||
return (
|
||||
<div role="tablist" className="bg-popover/95 backdrop-blur-md flex items-stretch gap-0 border-b border-card-border/60 px-4">
|
||||
<div role="tablist" className="bg-popover/95 backdrop-blur-md flex items-stretch gap-0 border-b border-card-border/60 px-4" data-sn-glass="panel">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
@@ -305,7 +305,7 @@ function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) {
|
||||
|
||||
function FooterBand({ context }: { context: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-popover/95 backdrop-blur-md border-t border-card-border/60 px-6 py-3">
|
||||
<div className="bg-popover/95 backdrop-blur-md border-t border-card-border/60 px-6 py-3" data-sn-glass="panel">
|
||||
<div className={cn(KICKER_CLASS, 'text-stat-subtitle leading-none')}>{context}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -126,6 +126,7 @@ const ToastItem = memo(function ToastItem({
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-md border border-glass-border bg-popover/95 ring-1 ring-glass-border drop-shadow-xl backdrop-blur-[10px] backdrop-saturate-[1.15]"
|
||||
data-sn-glass="panel"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
role={type === 'error' ? 'alert' : 'status'}
|
||||
|
||||
+40
-4
@@ -436,12 +436,48 @@
|
||||
--sev-vuln: oklch(0.660 0.100 45);
|
||||
}
|
||||
|
||||
/* Reduced effects: flatten the material (Calm only). Charts read the reduced
|
||||
flag from the store; here we flatten the real card top-bevel token. */
|
||||
[data-effects="reduced"] {
|
||||
/* Reduced effects: lower-overhead rendering path (Calm / Reduced effects /
|
||||
Readability). Flatten material, drop glass blur, and solidify translucent
|
||||
chrome so constrained GPUs are not forced to sample backdrop-filter.
|
||||
data-theme and data-effects both live on <html>, so token overrides use
|
||||
combined selectors. Exceptions to the blur kill need a more-specific
|
||||
!important rule, not a normal opt-out class.
|
||||
Charts also read the reduced flag from the store. */
|
||||
html[data-effects="reduced"] {
|
||||
--card-bevel: none;
|
||||
--pop-a: 1;
|
||||
/* Dim (default theme when data-theme is dim or unset on first paint) */
|
||||
--sidebar: oklch(var(--bg) 0 0);
|
||||
}
|
||||
[data-effects="reduced"] .animate-pulse {
|
||||
html[data-theme="oled"][data-effects="reduced"] {
|
||||
--sidebar: oklch(var(--bg) 0 0);
|
||||
}
|
||||
html[data-theme="light"][data-effects="reduced"] {
|
||||
--sidebar: oklch(0.98 0 0);
|
||||
}
|
||||
|
||||
/* Both properties: Chromium uses backdrop-filter; the WebKit-prefixed form
|
||||
still appears in some engines. */
|
||||
html[data-effects="reduced"] *,
|
||||
html[data-effects="reduced"] *::before,
|
||||
html[data-effects="reduced"] *::after {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* Direct-alpha glass surfaces (bg-popover/95, color-mix tab bar, etc.) stay
|
||||
translucent after --pop-a: 1. Mark them with data-sn-glass and solidify here. */
|
||||
html[data-effects="reduced"] [data-sn-glass="panel"] {
|
||||
background-color: var(--popover);
|
||||
}
|
||||
html[data-effects="reduced"] [data-sn-glass="overlay"] {
|
||||
background-color: var(--background);
|
||||
}
|
||||
html[data-effects="reduced"] [data-sn-glass="mobile-tabbar"] {
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
html[data-effects="reduced"] .animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user