fix(frontend): redesign mobile intro banner, segmented step strip, info sheet and card header chips

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 5.5 committed 2026-11-03 09:10:20 -05:00
1 parent a4ea3fccbd
commit b95c516c76
13 files changed
+300 -174

No files matched your search

+25 -11
View File
@@ -1,22 +1,36 @@
import { Fragment } from 'react';
import { ChevronRight } from 'lucide-react';
import { Info, ChevronRight } from 'lucide-react';
import InfoTip from './InfoTip';
// 2-4 steps: icon tile, a short label and an optional (i) with the detail.
// 2-4 steps as equal-width pills (icon + short label) joined by chevrons.
// A step with `info` makes its whole pill the tap/hover target for the detail.
// steps = [{ icon: ReactNode, label: string, info?: ReactNode }]
export default function IconStepStrip({ steps, className = '' }) {
return (
<ol className={`icon-step-strip ${className}`.trim()}>
{steps.map((s, i) => (
<Fragment key={i}>
{i > 0 && <li className="icon-step-strip__arrow" aria-hidden="true"><ChevronRight /></li>}
<li className="icon-step-strip__step">
<span className="icon-step-strip__tile" aria-hidden="true">{s.icon}</span>
{steps.map((s, i) => {
const body = (
<>
<span className="icon-step-strip__icon" aria-hidden="true">{s.icon}</span>
<span className="icon-step-strip__label">{s.label}</span>
{s.info && <InfoTip>{s.info}</InfoTip>}
</>
);
return (
<li className="icon-step-strip__step" key={i}>
{i > 0 && <ChevronRight className="icon-step-strip__arrow" aria-hidden="true" />}
{s.info ? (
<InfoTip
className="icon-step-strip__pill icon-step-strip__pill--info"
title={s.label}
trigger={<>{body}<Info className="icon-step-strip__more" aria-hidden="true" /></>}
>
{s.info}
</InfoTip>
) : (
<span className="icon-step-strip__pill">{body}</span>
)}
</li>
</Fragment>
))}
);
})}
</ol>
);
}
+47 -23
View File
@@ -1,18 +1,19 @@
import { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { Sheet, SheetTrigger, SheetContent, SheetTitle } from '@/components/ui/sheet';
import useMediaQuery from '../hooks/useMediaQuery';
import { t } from '../i18n/t';
// An (i) button that holds the explanation a page used to print inline.
// Mouse: hover/focus previews it. Touch, click, Enter and Space: toggles a
// pinned popover that closes on outside tap or Escape. The panel is portalled
// (no button-in-button) and above Modal/Sheet (.info-tip__panel z-index).
export default function InfoTip({ children, label, side = 'top', size = 'sm', className = '' }) {
// pinned popover that closes on outside tap or Escape. Narrow screens get a
// bottom sheet instead, so the text never sits on top of the content it
// explains. Both are portalled (no button-in-button) and above Modal/Sheet.
// `trigger` replaces the (i) glyph (e.g. a whole step pill); `title` heads the sheet.
export default function InfoTip({ children, label, title, trigger, side = 'top', size = 'sm', className = '' }) {
const [open, setOpen] = useState(false);
// Narrow screens: keep the panel clear of the sticky app header.
const narrow = useMediaQuery('(max-width: 640px)');
const collisionPadding = narrow ? { top: 72, right: 12, bottom: 12, left: 12 } : 8; // geometry: header 56px + safe area
const pinned = useRef(false);
const hoverable = (e) => e.pointerType === 'mouse';
@@ -21,31 +22,54 @@ export default function InfoTip({ children, label, side = 'top', size = 'sm', cl
setOpen(next);
};
const stop = (e) => e.stopPropagation();
const noFocus = (e) => e.preventDefault();
const button = (
// eslint-disable-next-line no-restricted-syntax -- bare 18px glyph button; Button/IconButton add control-sized chrome and a nested Tip
<button
type="button"
className={(trigger ? className : `info-tip info-tip--${size} ${className}`).trim()}
aria-label={trigger ? label : (label || t('common.moreInfo', 'More info'))}
onClick={stop}
onPointerEnter={(e) => { if (hoverable(e)) setOpen(true); }}
onPointerLeave={(e) => { if (hoverable(e) && !pinned.current) setOpen(false); }}
onFocus={(e) => { if (e.currentTarget.matches(':focus-visible')) setOpen(true); }}
onBlur={() => { if (!pinned.current) setOpen(false); }}
>
{trigger || <Info aria-hidden="true" />}
</button>
);
if (narrow) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetTrigger asChild>{button}</SheetTrigger>
<SheetContent
side="bottom"
className="info-tip__sheet"
overlayClassName="info-tip__overlay"
aria-describedby={undefined}
onOpenAutoFocus={noFocus}
onCloseAutoFocus={noFocus}
onClick={stop}
>
<SheetTitle className="info-tip__sheet-title">{title || label || t('common.moreInfo', 'More info')}</SheetTitle>
<div className="info-tip__sheet-body">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
{/* eslint-disable-next-line no-restricted-syntax -- bare 18px glyph button; Button/IconButton add control-sized chrome and a nested Tip */}
<button
type="button"
className={`info-tip info-tip--${size} ${className}`.trim()}
aria-label={label || t('common.moreInfo', 'More info')}
onClick={stop}
onPointerEnter={(e) => { if (hoverable(e)) setOpen(true); }}
onPointerLeave={(e) => { if (hoverable(e) && !pinned.current) setOpen(false); }}
onFocus={(e) => { if (e.currentTarget.matches(':focus-visible')) setOpen(true); }}
onBlur={() => { if (!pinned.current) setOpen(false); }}
>
<Info aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverTrigger asChild>{button}</PopoverTrigger>
<PopoverContent
side={side}
avoidCollisions
collisionPadding={collisionPadding}
collisionPadding={8}
className="info-tip__panel"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={noFocus}
onCloseAutoFocus={noFocus}
onClick={stop}
>
{children}
+21 -6
View File
@@ -1,11 +1,26 @@
import InfoTip from './InfoTip';
import IconStepStrip from './IconStepStrip';
// One short line with the longer explanation tucked in an (i).
export default function SectionIntro({ children, info, className = '' }) {
// One short line with the longer explanation tucked in an (i). With `icon`
// and/or `steps` it becomes a compact card banner: icon tile + title + (i),
// then the IconStepStrip (one row on desktop, two on mobile).
export default function SectionIntro({ children, info, icon, steps, className = '' }) {
if (!icon && !steps) {
return (
<p className={`section-intro ${className}`.trim()}>
<span>{children}</span>
{info && <InfoTip>{info}</InfoTip>}
</p>
);
}
return (
<p className={`section-intro ${className}`.trim()}>
<span>{children}</span>
{info && <InfoTip>{info}</InfoTip>}
</p>
<div className={`section-intro section-intro--banner ${className}`.trim()}>
<div className="section-intro__head">
{icon && <span className="section-intro__icon" aria-hidden="true">{icon}</span>}
<span className="section-intro__title">{children}</span>
{info && <InfoTip title={children}>{info}</InfoTip>}
</div>
{steps && <IconStepStrip steps={steps} />}
</div>
);
}
@@ -1,8 +0,0 @@
// Decorative; the surrounding text carries the meaning.
export default function FileIntegrityIllustration() {
return (
<svg className="tip-illustration" viewBox="0 0 48 48" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
<path d="M12 5h17l8 8v28a2 2 0 0 1-2 2H12a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z" fill="currentColor" fillOpacity="0.12" /><path d="M29 5v8h8" /><path d="m17 29 5 5 9-10" />
</svg>
);
}
@@ -11,11 +11,9 @@ import {
import { useTableSort } from '@/hooks/useTableSort';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { useTranslation } from 'react-i18next';
import { Camera, Eye, GitCompare, ShieldOff } from 'lucide-react';
import { Camera, Eye, FileCheck, GitCompare, ShieldOff } from 'lucide-react';
import InfoTip from '@/components/InfoTip';
import IconStepStrip from '@/components/IconStepStrip';
import SectionIntro from '@/components/SectionIntro';
import FileIntegrityIllustration from '@/components/illustrations/FileIntegrityIllustration';
import { Card as SharedCard, CardHeader as SharedCardHeader, CardContent as SharedCardContent } from '@/components/ui/card';
// Scoped file-integrity monitoring: baseline-and-diff over the paths
@@ -441,23 +439,17 @@ const IntegrityTab = () => {
</div>
)}
<div className="tip-card-head">
<FileIntegrityIllustration />
<div className="tip-card-head__text">
<SectionIntro
info={t('app.integrityTab.baselineAndDiffMonitoringOverThe', 'Baseline-and-diff monitoring over the paths ServerKit manages. Baseline a scope, then check it (or let the scheduled sweep do it). Any added, removed or modified files are flagged and admins are notified. Accepting changes re-baselines the scope.')}
>
{t('app.integrityTab.headerLine', 'Detect changed config files.')}
</SectionIntro>
<IconStepStrip
steps={[
{ icon: <Camera />, label: t('app.integrityTab.stepBaseline', 'Baseline'), info: t('app.integrityTab.stepBaselineInfo', 'Snapshot the current files of a scope.') },
{ icon: <Eye />, label: t('app.integrityTab.stepWatch', 'Watch'), info: t('app.integrityTab.stepWatchInfo', 'Check manually or let the scheduled sweep run.') },
{ icon: <GitCompare />, label: t('app.integrityTab.stepDiff', 'Diff'), info: t('app.integrityTab.stepDiffInfo', 'Added, removed or modified files are flagged and admins are notified. Accepting re-baselines.') },
]}
/>
</div>
</div>
<SectionIntro
icon={<FileCheck />}
info={t('app.integrityTab.baselineAndDiffMonitoringOverThe', 'Baseline-and-diff monitoring over the paths ServerKit manages. Baseline a scope, then check it (or let the scheduled sweep do it). Any added, removed or modified files are flagged and admins are notified. Accepting changes re-baselines the scope.')}
steps={[
{ icon: <Camera />, label: t('app.integrityTab.stepBaseline', 'Baseline'), info: t('app.integrityTab.stepBaselineInfo', 'Snapshot the current files of a scope.') },
{ icon: <Eye />, label: t('app.integrityTab.stepWatch', 'Watch'), info: t('app.integrityTab.stepWatchInfo', 'Check manually or let the scheduled sweep run.') },
{ icon: <GitCompare />, label: t('app.integrityTab.stepDiff', 'Diff'), info: t('app.integrityTab.stepDiffInfo', 'Added, removed or modified files are flagged and admins are notified. Accepting re-baselines.') },
]}
>
{t('app.integrityTab.headerLine', 'Detect changed config files')}
</SectionIntro>
{managedScopes.map(renderScopeCard)}
{appScopes.map(renderScopeCard)}
+2 -2
View File
@@ -18,11 +18,11 @@ const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const SheetContent = React.forwardRef(({ side = 'right', className, children, ...props }, ref) => {
const SheetContent = React.forwardRef(({ side = 'right', className, overlayClassName, children, ...props }, ref) => {
const { t } = useTranslation();
return (
<SheetPortal>
<SheetOverlay />
<SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content
ref={ref}
className={cn('ui-sheet-content', `ui-sheet-content--${side}`, className)}
+1 -1
View File
@@ -3332,7 +3332,7 @@
"change": "Change",
"checkNow": "Check now",
"couldntLoadIntegrity": "Couldn't load file integrity status.",
"headerLine": "Detect changed config files.",
"headerLine": "Detect changed config files",
"hideChanges": "Hide changes",
"lastCheck": "Last check {{age}}",
"nginxConfiguration": "Nginx configuration",
+2 -2
View File
@@ -444,7 +444,7 @@ function ImportWizard() {
<span className="tip-chip">{s.format}</span>
<ChevronRight size={16} className="import-wizard__source-chev" aria-hidden="true" />
</Button>
<InfoTip className="import-wizard__source-tip">{s.description}</InfoTip>
<InfoTip className="import-wizard__source-tip" title={s.name}>{s.description}</InfoTip>
</div>
))}
<div className="import-wizard__source">
@@ -454,7 +454,7 @@ function ImportWizard() {
<span className="tip-chip">{t('app.importWizard.liveSite', 'Live site')}</span>
<ChevronRight size={16} className="import-wizard__source-chev" aria-hidden="true" />
</Link>
<InfoTip className="import-wizard__source-tip">{t('app.importWizard.pullALiveWordpressSiteStraight', 'Pull a live WordPress site straight from its current server. Opens the WordPress import surface.')}</InfoTip>
<InfoTip className="import-wizard__source-tip" title={t('app.importWizard.wordpressOverSsh', 'WordPress over SSH')}>{t('app.importWizard.pullALiveWordpressSiteStraight', 'Pull a live WordPress site straight from its current server. Opens the WordPress import surface.')}</InfoTip>
</div>
</div>
</div>
+5 -7
View File
@@ -649,13 +649,11 @@ const PairAgentForm = ({ groups, onClose, onClaimed }) => {
return (
<form className="server-setup-form" onSubmit={handleClaim}>
<div className="server-setup-form__body">
<div className="pair-instructions">
<IconStepStrip steps={[
{ icon: <Terminal />, label: t('app.servers.stepStartAgent', 'Start agent'), info: t('app.servers.onTheTargetMachineStartThe', 'On the target server, start the agent. It will display a 6-character pair code and a passphrase. Enter both below.') },
{ icon: <Hash />, label: t('app.servers.stepReadCode', 'Read code'), info: t('app.servers.theCodeIsShownInThe', 'The code is shown in the terminal output or system tray.') },
{ icon: <KeyRound />, label: t('app.servers.stepEnterBoth', 'Enter both') },
]} />
</div>
<IconStepStrip steps={[
{ icon: <Terminal />, label: t('app.servers.stepStartAgent', 'Start agent'), info: t('app.servers.onTheTargetMachineStartThe', 'On the target server, start the agent. It will display a 6-character pair code and a passphrase. Enter both below.') },
{ icon: <Hash />, label: t('app.servers.stepReadCode', 'Read code'), info: t('app.servers.theCodeIsShownInThe', 'The code is shown in the terminal output or system tray.') },
{ icon: <KeyRound />, label: t('app.servers.stepEnterBoth', 'Enter both') },
]} />
<div className="form-group">
<label htmlFor={`${fieldId}-code`}>{t('app.servers.pairCode', 'Pair code')}</label>
+3 -2
View File
@@ -16,12 +16,13 @@
@include flex-between();
margin-bottom: $space-4;
// Narrow: title takes the row, trailing chips/actions wrap below.
// Narrow: the title shrinks first so a trailing chip stays on its row;
// only a wide chip/action cluster wraps below.
@include respond-to($breakpoint-sm) {
flex-wrap: wrap;
gap: $space-2;
> h3 { flex: 1 1 auto; min-width: 0; }
> h3 { flex: 1 1 10rem; min-width: 0; }
}
}
+169 -63
View File
@@ -45,6 +45,54 @@
code { font-family: $font-mono; font-size: $text-xs; }
}
// Narrow screens: the tip is a bottom sheet with a grab handle, above any
// open Modal/Sheet, clear of the home indicator.
.ui-sheet-overlay.info-tip__overlay {
z-index: $z-tooltip + 9;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: none;
}
.ui-sheet-content.info-tip__sheet {
z-index: $z-tooltip + 10;
gap: $space-2;
max-height: 70vh;
padding: $space-6 $space-5 calc(#{$space-5} + env(safe-area-inset-bottom));
border-radius: $radius-overlay $radius-overlay 0 0;
text-transform: none;
letter-spacing: normal;
&::before {
content: '';
position: absolute;
inset-block-start: $space-2;
inset-inline-start: 50%;
width: 36px; // geometry: grab handle
height: 4px; // geometry: grab handle
border-radius: $radius-full;
background: $border-default;
transform: translateX(-50%);
}
}
.info-tip__sheet-title {
margin: 0;
padding-inline-end: $space-8;
color: $text-primary;
font-size: $text-base;
font-weight: $font-weight-semibold;
}
.info-tip__sheet-body {
color: $text-secondary;
font-size: $text-base;
line-height: 1.55;
p { margin: 0; }
p + p { margin-top: $space-2; }
code { font-family: $font-mono; font-size: $text-sm; }
}
.section-intro {
display: flex;
align-items: center;
@@ -52,76 +100,157 @@
margin: 0;
color: $text-secondary;
font-size: $text-sm;
}
.icon-step-strip {
display: flex;
align-items: center;
gap: $space-2;
margin: 0;
padding: 0;
list-style: none;
// Compact card: icon tile + title + (i), steps inline on the right.
&--banner {
flex-wrap: wrap;
justify-content: space-between;
gap: $space-3 $space-6;
padding: $space-3 $space-4;
background: $bg-card;
border: 1px solid $border-subtle;
border-radius: $radius-card;
}
&__step {
&__head {
display: flex;
align-items: center;
gap: $space-2;
min-width: 0;
}
&__tile {
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: $control-lg;
height: $control-lg;
width: $control-md;
height: $control-md;
border-radius: $radius-control;
background: $bg-secondary;
border: 1px solid $border-default;
color: $accent-primary;
@include status-variant($accent-primary, $accent-bg);
svg { width: 20px; height: 20px; } // geometry: icon in tile
svg { width: 18px; height: 18px; } // geometry: icon in tile
}
&__label {
&__title {
min-width: 0;
color: $text-primary;
font-size: $text-sm;
font-weight: $font-weight-medium;
font-size: $text-base;
font-weight: $font-weight-semibold;
}
@include respond-to($breakpoint-sm) {
&--banner {
flex-direction: column;
align-items: stretch;
padding: $space-4;
}
&__title { flex: 1; }
}
}
// Equal-width step pills joined by thin chevrons that sit in the gaps.
.icon-step-strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr; // equal pills, but never narrower than a label
gap: $space-5;
margin: 0;
padding: 0;
list-style: none;
&__step {
position: relative;
display: flex;
min-width: 0;
}
&__arrow {
display: inline-flex;
position: absolute;
inset-block-start: 50%;
inset-inline-start: -16px; // geometry: centres 12px chevron in the 20px gap
width: 12px; // geometry: connector icon
height: 12px; // geometry: connector icon
color: $text-muted;
svg { width: 16px; height: 16px; } // geometry: connector icon
transform: translateY(-50%);
}
// Narrow: stay one compact row; label sits under a smaller tile.
@include respond-to($breakpoint-sm) {
flex-wrap: wrap;
align-items: flex-start;
gap: $space-1;
&__pill {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: $space-2;
min-width: 0;
min-height: $control-md;
margin: 0;
padding: $space-1 $space-2;
border: 1px solid $border-default;
border-radius: $radius-control;
background: $bg-secondary;
color: $text-primary;
font: inherit;
text-align: center;
&__step {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: center;
gap: $space-1;
&--info {
cursor: pointer;
@include transition(border-color);
&:hover,
&[data-state='open'] { border-color: $accent-primary; }
&:focus-visible {
outline: 2px solid $accent-primary;
outline-offset: 1px;
}
}
}
&__tile {
grid-column: 1 / -1;
justify-self: center;
width: 36px; // geometry: compact tile
height: 36px; // geometry: compact tile
svg { width: 18px; height: 18px; } // geometry: icon in compact tile
&__icon {
display: inline-flex;
flex: none;
color: $accent-primary;
svg { width: 16px; height: 16px; } // geometry: icon in pill
}
&__label {
min-width: 0;
font-size: $text-sm;
font-weight: $font-weight-medium;
line-height: 1.3;
}
// Touch hint only; on desktop hover + the accent border say "more here".
&__more {
display: none;
width: 12px; // geometry: info hint glyph
height: 12px; // geometry: info hint glyph
color: $text-muted;
}
// Narrow: icon over a wrapping label, the (i) hint tucked in the corner.
@include respond-to($breakpoint-sm) {
gap: $space-4;
&__arrow { inset-inline-start: -14px; } // geometry: centres chevron in the 16px gap
&__pill {
flex-direction: column;
gap: $space-1;
padding: $space-2 $space-1;
}
&__label { font-size: $text-xs; }
// Centre the 14px chevron on the 36px tile.
&__arrow { margin-block-start: 11px; svg { width: 14px; height: 14px; } } // geometry: connector
&__more {
display: block;
position: absolute;
inset-block-start: $space-1;
inset-inline-end: $space-1;
}
}
}
@@ -194,29 +323,6 @@
color: $accent-primary;
}
.tip-card-head {
display: flex;
align-items: center;
gap: $space-3;
&__text { flex: 1; min-width: 0; }
@include respond-to($breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
gap: $space-2;
.tip-illustration {
width: 36px; // geometry: compact illustration
height: 36px; // geometry: compact illustration
}
&__text { flex: none; width: 100%; }
}
&__title { display: flex; align-items: center; gap: $space-1; color: $text-primary; font-weight: $font-weight-medium; }
&__line { margin: 0; color: $text-secondary; font-size: $text-sm; }
}
// Label + (i) tip on one row, for plain <label> markup outside FormField.
.label-with-tip {
display: flex;
+13 -3
View File
@@ -9,7 +9,8 @@
.security-page {
// ── card chrome (all tabs) ──────────────────────────────
.tab-content {
.card {
.card,
.section-intro--banner {
background: var(--surface);
border: 1px solid var(--border-soft);
}
@@ -27,6 +28,17 @@
font-weight: $font-weight-semibold;
color: var(--text-faint);
// One line: the subtitle suffix gives way first (ellipsis).
white-space: nowrap;
// Subtitle suffix gives way first: one line, ellipsis.
.sec-count {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.sec-listtitle--green { color: var(--green); }
&.sec-listtitle--red { color: var(--red); }
}
@@ -66,8 +78,6 @@
font-weight: $font-weight-medium;
color: var(--text-faint);
letter-spacing: 0;
@include respond-to($breakpoint-sm) { display: block; }
}
// Severity / state chip (mono uppercase on a semantic wash) —
-26
View File
@@ -179,32 +179,6 @@
}
}
// Brief instruction callout shown above the pair-code input
.pair-instructions {
display: flex;
gap: $space-3;
padding: $space-3 $space-4;
background: rgba($accent-primary-raw, 0.07);
border: 1px solid rgba($accent-primary-raw, 0.22);
border-radius: $radius-control;
p {
margin: 0;
font-size: $text-xs;
color: $text-secondary;
line-height: 1.5;
code {
font-family: $font-mono;
font-size: $text-xs;
color: $accent-primary;
background: rgba($accent-primary-raw, 0.12);
padding: 1px $space-tight;
border-radius: $radius-badge;
}
}
}
.server-setup-form {
display: flex;
flex-direction: column;