mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
A batch of UI/UX polish: - New independent "Reduced motion" appearance setting (separate from Reduced effects). Drives framer-motion via MotionConfig and clamps CSS transitions via data-motion on <html>; toasts are unaffected. Defaults off (OS preference still honored). - Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted word-wrap toggle to the file viewer (default on), and add a fullscreen toggle that collapses the Command Center + Logs column so the editor fills the width. - Create Stack > From Git: remove the nested scroll clamp so the deploy toggle and footer are reachable. - Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state headings (calm drops the italic), and fix the Actions card body overlapping the action-row divider. - Snapshots: restyle Restore and Restore all to the ghost button design used by View / Preview / Download, and right-align the per-stack Restore. - Settings sidebar: App Store gradient active style and standard font size. - Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the findings change, via a shared fingerprint-keyed hook. - Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet Snapshots tab (FleetView tabs are now controlled to support the deep link).
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePreflightDismiss } from '../usePreflightDismiss';
|
||||
|
||||
const findings = (sev: string) => [
|
||||
{ ruleId: 'DS001', severity: sev, service: 'web' },
|
||||
{ ruleId: 'DS002', severity: 'warning' },
|
||||
];
|
||||
|
||||
describe('usePreflightDismiss', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('is not dismissed until dismiss() is called', () => {
|
||||
const { result } = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('stays dismissed for an identical finding set (order-independent)', () => {
|
||||
const first = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
act(() => first.result.current.dismiss());
|
||||
// A fresh consumer with the same findings in a different order reads dismissed.
|
||||
const reordered = [...findings('high')].reverse();
|
||||
const second = renderHook(() => usePreflightDismiss('app', 1, reordered));
|
||||
expect(second.result.current.dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('re-surfaces when the findings change', () => {
|
||||
const first = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
act(() => first.result.current.dismiss());
|
||||
// Severity changed -> different fingerprint -> not dismissed.
|
||||
const changed = renderHook(() => usePreflightDismiss('app', 1, findings('blocker')));
|
||||
expect(changed.result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('keys per stack and node', () => {
|
||||
const a = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
act(() => a.result.current.dismiss());
|
||||
expect(renderHook(() => usePreflightDismiss('other', 1, findings('high'))).result.current.dismissed).toBe(false);
|
||||
expect(renderHook(() => usePreflightDismiss('app', 2, findings('high'))).result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty finding set as never dismissed', () => {
|
||||
const { result } = renderHook(() => usePreflightDismiss('app', 1, []));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,9 @@ export interface ThemeState {
|
||||
headingStyle: HeadingStyle;
|
||||
chartStyle: ChartStyle;
|
||||
reducedEffects: boolean;
|
||||
/** Independent of reducedEffects (surface flattening): minimizes UI motion
|
||||
* (dialogs, menus, overlays, transitions). Not part of a visual-style preset. */
|
||||
reducedMotion: boolean;
|
||||
readability: boolean;
|
||||
}
|
||||
|
||||
@@ -136,6 +139,9 @@ const DEFAULT_STATE: ThemeState = {
|
||||
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
|
||||
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
|
||||
...CALM_PRESET,
|
||||
// Independent of the visual-style presets; defaults off so the OS
|
||||
// prefers-reduced-motion still governs via MotionConfig's 'user' mode.
|
||||
reducedMotion: false,
|
||||
};
|
||||
|
||||
const MODE_IDS = new Set<string>(THEME_MODES.map((m) => m.id));
|
||||
@@ -200,6 +206,7 @@ function readStored(): ThemeState {
|
||||
headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : SIGNATURE_PRESET.headingStyle,
|
||||
chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : SIGNATURE_PRESET.chartStyle,
|
||||
reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : SIGNATURE_PRESET.reducedEffects,
|
||||
reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : false,
|
||||
readability: isBool(p.readability) ? p.readability : SIGNATURE_PRESET.readability,
|
||||
};
|
||||
}
|
||||
@@ -252,6 +259,9 @@ function applyToDom(s: ThemeState, systemDark: boolean) {
|
||||
root.dataset.chartStyle = chart;
|
||||
if (reduced) root.dataset.effects = 'reduced';
|
||||
else delete root.dataset.effects;
|
||||
// Motion is independent of effects/readability: only the explicit toggle.
|
||||
if (s.reducedMotion) root.dataset.motion = 'reduced';
|
||||
else delete root.dataset.motion;
|
||||
root.style.setProperty('--border-boost', String(rd ? 0.03 : s.borderBoost));
|
||||
root.style.setProperty('--glow', String(reduced ? s.glow * 0.4 : s.glow));
|
||||
root.style.setProperty('--contrast', String(s.contrast + (rd ? 0.18 : 0)));
|
||||
@@ -284,6 +294,7 @@ function sameState(a: ThemeState, b: ThemeState): boolean {
|
||||
&& a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale
|
||||
&& a.visualStyle === b.visualStyle && a.headingStyle === b.headingStyle
|
||||
&& a.chartStyle === b.chartStyle && a.reducedEffects === b.reducedEffects
|
||||
&& a.reducedMotion === b.reducedMotion
|
||||
&& a.readability === b.readability;
|
||||
}
|
||||
|
||||
@@ -312,6 +323,16 @@ function getSnapshot(): ThemeSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Lean selector for just the reduced-motion flag, so a wrapper like MotionConfig
|
||||
* re-renders only when motion changes, not on every theme tweak. */
|
||||
export function useReducedMotion(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => persisted.reducedMotion,
|
||||
() => DEFAULT_STATE.reducedMotion,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// Cross-tab sync: another tab wrote a new look.
|
||||
window.addEventListener('storage', (e) => {
|
||||
@@ -366,6 +387,7 @@ export function useTheme() {
|
||||
const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []);
|
||||
const setChartStyle = useCallback((chartStyle: ChartStyle) => setState({ chartStyle }), []);
|
||||
const setReducedEffects = useCallback((reducedEffects: boolean) => setState({ reducedEffects }), []);
|
||||
const setReducedMotion = useCallback((reducedMotion: boolean) => setState({ reducedMotion }), []);
|
||||
const setReadability = useCallback((readability: boolean) => setState({ readability }), []);
|
||||
const resolvedTheme = resolveWith(s.theme, s.systemDark);
|
||||
return {
|
||||
@@ -381,6 +403,7 @@ export function useTheme() {
|
||||
headingStyle: s.headingStyle,
|
||||
chartStyle: s.chartStyle,
|
||||
reducedEffects: s.reducedEffects,
|
||||
reducedMotion: s.reducedMotion,
|
||||
readability: s.readability,
|
||||
resolvedTheme,
|
||||
isDarkMode: resolvedTheme !== 'light',
|
||||
@@ -396,6 +419,7 @@ export function useTheme() {
|
||||
setHeadingStyle,
|
||||
setChartStyle,
|
||||
setReducedEffects,
|
||||
setReducedMotion,
|
||||
setReadability,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/** Minimal shape of a preflight finding needed to fingerprint a result set. */
|
||||
interface FindingLike {
|
||||
ruleId: string;
|
||||
severity: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
// Bumped when a dismiss is written so sibling consumers (the Doctor tab dot and
|
||||
// the banner) re-read localStorage and agree, without a full page reload.
|
||||
const DISMISS_EVENT = 'sencho:preflight-dismiss-changed';
|
||||
|
||||
const keyFor = (stackName: string, nodeId: number | undefined) =>
|
||||
`sencho.doctorDismissed.${stackName}.${nodeId ?? 'local'}`;
|
||||
|
||||
/** Stable, content-based fingerprint of the findings. Order-independent so a
|
||||
* reordered-but-identical result still counts as dismissed; any added, removed,
|
||||
* or re-severitied finding changes it, which re-surfaces the banner. */
|
||||
function fingerprint(findings: FindingLike[] | undefined): string {
|
||||
if (!findings || findings.length === 0) return '';
|
||||
return findings
|
||||
.map((f) => `${f.ruleId}:${f.severity}:${f.service ?? ''}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stack dismiss for the Compose Doctor high-risk banner, persisted in
|
||||
* localStorage and keyed to a fingerprint of the findings: the dismissal sticks
|
||||
* across reloads and re-runs that produce identical findings, and clears
|
||||
* automatically once the findings change. Used by both the banner (to hide
|
||||
* itself) and the Doctor tab dot (to clear), kept in sync via a window event.
|
||||
*/
|
||||
export function usePreflightDismiss(
|
||||
stackName: string,
|
||||
nodeId: number | undefined,
|
||||
findings: FindingLike[] | undefined,
|
||||
) {
|
||||
const fp = useMemo(() => fingerprint(findings), [findings]);
|
||||
const storageKey = keyFor(stackName, nodeId);
|
||||
|
||||
const read = useCallback(() => {
|
||||
try { return localStorage.getItem(storageKey); } catch { return null; }
|
||||
}, [storageKey]);
|
||||
|
||||
const [storedFp, setStoredFp] = useState<string | null>(() => read());
|
||||
|
||||
useEffect(() => {
|
||||
setStoredFp(read());
|
||||
const handler = () => setStoredFp(read());
|
||||
window.addEventListener(DISMISS_EVENT, handler);
|
||||
window.addEventListener('storage', handler);
|
||||
return () => {
|
||||
window.removeEventListener(DISMISS_EVENT, handler);
|
||||
window.removeEventListener('storage', handler);
|
||||
};
|
||||
}, [read]);
|
||||
|
||||
const dismissed = fp !== '' && storedFp === fp;
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
try { localStorage.setItem(storageKey, fp); } catch { /* ignore */ }
|
||||
setStoredFp(fp);
|
||||
window.dispatchEvent(new Event(DISMISS_EVENT));
|
||||
}, [storageKey, fp]);
|
||||
|
||||
return { dismissed, dismiss };
|
||||
}
|
||||
Reference in New Issue
Block a user