feat(settings): add Stacks section for stack workflow preferences (#1366)

Move the browser-local Deploy progress, Progress style, and Diff preview
before save controls out of Appearance into a new Stacks section under the
Infrastructure group. These are stack lifecycle and editor workflow
preferences, not visual style, so Settings now groups them where operators
expect to find them.

Add a browser-local masthead scope so these localStorage-backed sections read
SCOPE browser instead of the misleading global, and apply it to both
Appearance and Stacks. Control behavior, storage keys, and backing hooks are
unchanged; this is an information-architecture move only.
This commit is contained in:
Anso
2026-06-12 18:40:53 -04:00
committed by GitHub
parent 6cbab661a0
commit ebf66fd92a
14 changed files with 266 additions and 103 deletions
@@ -1,13 +1,9 @@
import { Combobox } from '@/components/ui/combobox';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { TogglePill } from '@/components/ui/toggle-pill';
import { useDensity } from '@/hooks/use-density';
import type { Density } from '@/hooks/use-density';
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
@@ -29,11 +25,6 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
};
const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
{ value: 'modal', label: 'Modal' },
{ value: 'inline', label: 'Inline' },
];
const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
@@ -43,9 +34,6 @@ const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
export function AppearanceSection() {
const [density, setDensity] = useDensity();
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
const {
@@ -223,58 +211,6 @@ export function AppearanceSection() {
/>
</SettingsField>
)}
<SettingsField
label="Deploy progress"
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without it."
>
<div className="flex items-center gap-2">
<Checkbox
id="deploy-feedback"
checked={isEnabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<label
htmlFor="deploy-feedback"
className="text-sm text-stat-value cursor-pointer select-none"
>
{isEnabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</SettingsField>
{isEnabled && (
<SettingsField
label="Progress style"
helper="Modal opens a centered overlay. Inline shows a quiet status on the stack detail with the full log a click away under View output."
>
<SegmentedControl
value={feedbackStyle}
options={DEPLOY_STYLE_OPTIONS}
onChange={setFeedbackStyle}
ariaLabel="Deploy progress style"
/>
</SettingsField>
)}
<SettingsField
label="Diff preview before save"
helper="Show a side-by-side diff of compose and env edits before they reach disk."
>
<div className="flex items-center gap-2">
<Checkbox
id="compose-diff-preview"
checked={diffPreviewEnabled}
onCheckedChange={(v) => setDiffPreviewEnabled(v === true)}
/>
<label
htmlFor="compose-diff-preview"
className="text-sm text-stat-value cursor-pointer select-none"
>
{diffPreviewEnabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</SettingsField>
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
@@ -21,6 +21,7 @@ import {
getSettingsGroup,
isItemVisible,
isItemLocked,
scopeLabel,
} from './index';
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
import { SettingsSidebar } from './SettingsSidebar';
@@ -228,14 +229,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
);
}
function scopeLabel(item: SettingsItemMeta): string {
// Personal sections (account, appearance) apply to the signed-in operator or
// this browser. Access sections (license, users, sso, api-tokens) are
// instance-global, so they read as global like every other non-node group.
if (item.group === 'personal') return 'operator';
return 'global';
}
function SettingsCommandItem({
item,
glyph,
@@ -13,6 +13,7 @@ import {
DeveloperSection,
DataRetentionSection,
AppStoreSection,
StacksSection,
SupportSection,
AboutSection,
RecoverySection,
@@ -90,6 +91,7 @@ function renderSection(
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => onDirtyChange('data-retention', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'stacks': return <StacksSection />;
case 'recovery': return <RecoverySection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
@@ -0,0 +1,80 @@
import { Checkbox } from '@/components/ui/checkbox';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
{ value: 'modal', label: 'Modal' },
{ value: 'inline', label: 'Inline' },
];
export function StacksSection() {
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
return (
<div className="flex flex-col gap-10">
<SettingsSection title="Workflow" kicker="this browser">
<SettingsField
label="Deploy progress"
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without it."
>
<div className="flex items-center gap-2">
<Checkbox
id="deploy-feedback"
checked={isEnabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<label
htmlFor="deploy-feedback"
className="text-sm text-stat-value cursor-pointer select-none"
>
{isEnabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</SettingsField>
{isEnabled && (
<SettingsField
label="Progress style"
helper="Modal opens a centered overlay. Inline shows a quiet status on the stack detail with the full log a click away under View output."
>
<SegmentedControl
value={feedbackStyle}
options={DEPLOY_STYLE_OPTIONS}
onChange={setFeedbackStyle}
ariaLabel="Deploy progress style"
/>
</SettingsField>
)}
<SettingsField
label="Diff preview before save"
helper="Show a side-by-side diff of compose and env edits before they reach disk."
>
<div className="flex items-center gap-2">
<Checkbox
id="compose-diff-preview"
checked={diffPreviewEnabled}
onCheckedChange={(v) => setDiffPreviewEnabled(v === true)}
/>
<label
htmlFor="compose-diff-preview"
className="text-sm text-stat-value cursor-pointer select-none"
>
{diffPreviewEnabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</SettingsField>
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
saved to this browser only · every device remembers its own choice
</p>
</div>
);
}
@@ -0,0 +1,81 @@
/**
* Guards the move of the stack-workflow controls out of Appearance into Stacks.
*
* The three controls (Deploy progress, Progress style, Diff preview before save)
* are browser-local localStorage preferences. Moving the JSX must not change the
* storage keys they write, so the deploy/editor consumers keep reading the same
* values. These tests assert the controls render in Stacks, still flip the same
* keys, and no longer render in Appearance.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { StacksSection } from '../StacksSection';
import { AppearanceSection } from '../AppearanceSection';
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
import { DEPLOY_FEEDBACK_STYLE_KEY } from '@/hooks/use-deploy-feedback-style';
import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
describe('StacksSection', () => {
it('renders the three workflow controls (Progress style while deploy progress is on)', () => {
render(<StacksSection />);
expect(screen.getByText('Deploy progress')).toBeInTheDocument();
// Deploy progress defaults on, so Progress style is visible.
expect(screen.getByText('Progress style')).toBeInTheDocument();
expect(screen.getByText('Diff preview before save')).toBeInTheDocument();
});
it('flips the deploy-progress key and hides Progress style when disabled', () => {
const { container } = render(<StacksSection />);
const toggle = container.querySelector('#deploy-feedback') as HTMLElement;
// Default on => no stored value yet.
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBeNull();
fireEvent.click(toggle);
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBe('false');
// Progress style is gated on the enabled state and drops out.
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
});
it('round-trips the progress-style key between Inline and Modal', () => {
render(<StacksSection />);
fireEvent.click(screen.getByRole('radio', { name: 'Inline' }));
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('inline');
fireEvent.click(screen.getByRole('radio', { name: 'Modal' }));
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('modal');
});
it('hydrates each control from its stored value (read path survives the move)', () => {
window.localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
window.localStorage.setItem(COMPOSE_DIFF_PREVIEW_KEY, 'true');
const { container } = render(<StacksSection />);
// Deploy progress reads its stored 'false': checkbox unchecked, Progress style hidden.
expect(container.querySelector('#deploy-feedback')?.getAttribute('aria-checked')).toBe('false');
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
// Diff preview reads its stored 'true': checkbox checked.
expect(container.querySelector('#compose-diff-preview')?.getAttribute('aria-checked')).toBe('true');
});
it('flips the diff-preview key when enabled', () => {
const { container } = render(<StacksSection />);
const toggle = container.querySelector('#compose-diff-preview') as HTMLElement;
expect(window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY)).toBeNull();
fireEvent.click(toggle);
expect(window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY)).toBe('true');
});
});
describe('AppearanceSection no longer owns stack-workflow controls', () => {
it('does not render Deploy progress, Progress style, or Diff preview before save', () => {
render(<AppearanceSection />);
expect(screen.queryByText('Deploy progress')).not.toBeInTheDocument();
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
expect(screen.queryByText('Diff preview before save')).not.toBeInTheDocument();
});
});
@@ -7,7 +7,8 @@
* Data Retention, and the renamed labels are applied.
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_GROUPS, SETTINGS_ITEMS } from '../registry';
import { SETTINGS_GROUPS, SETTINGS_ITEMS, scopeLabel } from '../registry';
import type { SettingsItemMeta } from '../registry';
describe('settings registry', () => {
it('points every item at a defined group', () => {
@@ -74,4 +75,38 @@ describe('settings registry', () => {
expect(registries?.tier).toBeNull();
expect(registries?.adminOnly).toBe(true);
});
it('registers the Stacks section under Infrastructure with searchable workflow keywords', () => {
const stacks = SETTINGS_ITEMS.find(i => i.id === 'stacks');
expect(stacks?.group).toBe('infrastructure');
expect(stacks?.tier).toBeNull();
for (const term of ['stack', 'deploy', 'progress', 'diff', 'save']) {
expect(stacks?.keywords, `keyword ${term}`).toContain(term);
}
});
it('scopes the browser-local sections (Appearance, Stacks) to the browser', () => {
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('browser');
});
});
describe('scopeLabel', () => {
const item = (over: Partial<SettingsItemMeta>): SettingsItemMeta => ({
id: 'stacks', group: 'infrastructure', label: 'X', description: '',
keywords: [], tier: null, scope: 'global', ...over,
});
it('reads browser for browser-scoped sections regardless of their group', () => {
expect(scopeLabel(item({ scope: 'browser', group: 'personal' }))).toBe('browser');
expect(scopeLabel(item({ scope: 'browser', group: 'infrastructure' }))).toBe('browser');
});
it('reads operator for the signed-in Account (personal group)', () => {
expect(scopeLabel(item({ scope: 'global', group: 'personal' }))).toBe('operator');
});
it('reads global for other non-node, non-browser groups', () => {
expect(scopeLabel(item({ scope: 'global', group: 'access' }))).toBe('global');
});
});
@@ -10,6 +10,7 @@ export { NotificationsSection } from './NotificationsSection';
export { DeveloperSection } from './DeveloperSection';
export { DataRetentionSection } from './DataRetentionSection';
export { AppStoreSection } from './AppStoreSection';
export { StacksSection } from './StacksSection';
export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
export { RecoverySection } from './RecoverySection';
@@ -30,6 +31,7 @@ export {
getSettingsGroup,
isItemVisible,
isItemLocked,
scopeLabel,
} from './registry';
export type {
SettingsGroupId,
+25 -2
View File
@@ -31,7 +31,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
];
export type TierGate = 'paid' | null;
export type Scope = 'global' | 'node';
export type Scope = 'global' | 'node' | 'browser';
export interface SettingsItemMeta {
id: SectionId;
@@ -64,7 +64,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
description: 'Theme, accent, density, and display preferences saved to this browser.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
tier: null,
scope: 'global',
scope: 'browser',
},
// Access
{
@@ -162,6 +162,15 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
tier: null,
scope: 'node',
},
{
id: 'stacks',
group: 'infrastructure',
label: 'Stacks',
description: 'Stack editor and lifecycle workflow preferences saved to this browser.',
keywords: ['stack', 'compose', 'deploy', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
tier: null,
scope: 'browser',
},
// Monitoring
{
id: 'host-alerts',
@@ -297,3 +306,17 @@ export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): b
export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
return item.tier === 'paid' ? !ctx.isPaid : false;
}
/**
* The masthead SCOPE value for a non-node section. Browser-local sections
* (Appearance, Stacks) persist to this browser's localStorage and read as
* browser regardless of their group; the signed-in Account is operator-scoped;
* Access sections (license, users, sso, api-tokens) are instance-global, so
* they read as global like every other non-node group. Node-scoped sections
* render a NODE pill instead and never reach here.
*/
export function scopeLabel(item: SettingsItemMeta): string {
if (item.scope === 'browser') return 'browser';
if (item.group === 'personal') return 'operator';
return 'global';
}
@@ -59,6 +59,7 @@ export type SectionId =
| 'data-retention'
| 'nodes'
| 'app-store'
| 'stacks'
| 'notification-routing'
| 'recovery'
| 'support'