refactor(web): finish post-review layout and visual corrections

This commit is contained in:
fy59 2026-09-24 05:09:54 +02:00
parent d7ca6a1924
commit 713238fd0e
15 changed files with 251 additions and 49 deletions

View file

@ -10,6 +10,15 @@ BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
lists factual references and transactionally absorbs a custom duplicate into a
canonical identity while retaining history/MAX context and recording a causal
deletion marker; immutable embedded drafts cause an explicit conflict.
The post-review correction keeps acknowledged-generation capacity recoverable
for legacy Android rows without ACK-ledger evidence, proves two successive
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
generation. Web can now causally remove only an orphaned zero-occurrence active
draft while retaining protection for real active drafts. Mutation UUIDs use
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
Dashboard sizing, Program progress contrast, chart proportions, duration
coverage messaging, and the right-aligned Settings/Sync action cluster are
also refined.
## 0.1.5 — 2026-09-23

View file

@ -4,12 +4,12 @@
"columns": 12,
"max_y": 200,
"tiles": [
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
]
}

View file

@ -315,12 +315,40 @@ exercise to individual detail. Exercise work time is derived exclusively from
Measurements first expose unit-safe relative change cards before real-unit
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
The Dashboard layout keeps a 12-column canonical grid but permits every card
to reach full width and several useful heights; per-card minima, overlap
validation, exact save/cancel/reset semantics, and keyboard resizing remain
authoritative. Settings and synchronization form the rightmost Header action
cluster. Program progress uses the shared success color while retaining its
factual completed/total text. Responsive charts preserve their viewBox aspect
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
partial measured coverage instead of deriving time from sets or repetitions.
The shared Sleep agenda owns one label/track/facts grid. Its five clock labels
and every event, interval and medication marker use the same coordinates:
18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00 at 100%.
The Equipment route projects supplied and custom definitions with exercise,
history, preparation and Program reference counts. Its guarded merge accepts a
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
transaction, preserves occurrence/MAX facts indirectly through occurrence
identity, and writes the existing causal deletion boundary. An immutable
execution-draft JSON reference rejects the operation rather than being edited.
Browser mutation identities use the shared UUIDv4 helper backed by
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
so `http://trainlog.perf` remains supported.
An `execution_drafts` row marked active but containing no `exercises` or
legacy `entries` is an orphaned projection, not a resumable workout. Web may
delete that exact row through the existing revision-guarded causal transaction;
the durable tombstone prevents synchronization replay from resurrecting it.
An active draft with any occurrence remains protected from Web deletion.
Generation admission archives only rows backed by an exact correlated consumed
ACK and retains the newest two acknowledged lineage members. Legacy terminal
Android rows that predate the durable ACK ledger are neither fabricated nor
deleted, but no longer consume the active admission window. Captured,
published, waiting-ACK, and current acknowledged rows continue to count until
safe archival, so capacity recovery never prunes unacknowledged evidence.
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and

View file

@ -13,6 +13,23 @@ touching real user equipment. Strict C compilation, the full Meson inventory,
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
diff checks remain the closeout gates.
Post-review regressions cover a validated Sleep Diary revision plus nocturnal
BPM/RR capture through the production full-generation transport, desktop
import, correlated ACK, exact ACK replay, a second Sleep-only revision and a
second generation. The 24-conversation deployed-capacity regression removes
legacy ACK-ledger rows deliberately, recovers capacity without deleting
generation evidence, and continues through archive/replay. Native Web tests
prove that an active zero-occurrence execution draft is deleted causally and
that repeating its request is byte-identical, while a populated active draft
still conflicts. The equipment client test removes `crypto.randomUUID`, uses
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
request; native merge tests retain their transaction/rollback coverage.
UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted
Dashboard sizing and reset behavior, English preference persistence across a
remount, centralized Dashboard English labels, non-stretched chart aspect
ratios, and distinct missing/partial exercise-timing messages.
Sleep Diary coverage includes schema creation/migration, midnight and DST
offsets, revision conflict, logical deletion/reopen, companion replay and
rollback, Web periods/deep links, Android persistence, and vector PDF

View file

@ -21,13 +21,16 @@ typedef struct TileContract {
} TileContract;
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 4U, 8U, 3U, 7U},
{"activity", 5U, 12U, 3U, 7U},
{"progression", 6U, 12U, 4U, 9U},
{"last-session", 3U, 7U, 3U, 6U},
{"max-records", 3U, 8U, 3U, 7U},
{"muscle-distribution", 5U, 12U, 4U, 9U},
{"cardio-recovery", 5U, 12U, 4U, 8U}};
/* CONTRACT: every tile can reach full width and multiple useful vertical
* sizes. Per-tile minima preserve legibility; the shared 12-column grid
* and overlap validation remain the bounded layout authority. */
{"next-session", 4U, 12U, 3U, 10U},
{"activity", 5U, 12U, 3U, 12U},
{"progression", 6U, 12U, 4U, 12U},
{"last-session", 3U, 12U, 3U, 10U},
{"max-records", 3U, 12U, 3U, 10U},
{"muscle-distribution", 5U, 12U, 4U, 12U},
{"cardio-recovery", 5U, 12U, 4U, 12U}};
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 0U, 0U, 5U, 4U},

View file

@ -166,4 +166,21 @@ describe('shell Trainlog', () => {
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6)
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page')
})
it('persists English and applies centralized Dashboard navigation labels after reload', async () => {
mockHealth()
window.history.replaceState(null, '', '/parametres')
const first = render(<App />)
fireEvent.click(screen.getByRole('radio', { name: 'English' }))
fireEvent.click(screen.getByRole('link', { name: 'Dashboard' }))
expect(await screen.findByRole('button', { name: 'Edit layout' })).toBeInTheDocument()
expect(screen.getByText('Next session')).toBeInTheDocument()
expect(screen.queryByText('Prochaine séance')).not.toBeInTheDocument()
first.unmount()
window.history.replaceState(null, '', '/')
render(<App />)
expect(await screen.findByRole('button', { name: 'Edit layout' })).toBeInTheDocument()
expect(document.documentElement.lang).toBe('en')
window.localStorage.removeItem('trainlog.web.language.v1')
})
})

View file

@ -38,9 +38,11 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
</a>
))}
</nav>
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
<SyncControl onCommitted={onSyncCommitted} />
<div className="header-actions">
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
<SyncControl onCommitted={onSyncCommitted} />
</div>
</header>
)
}

View file

@ -21,15 +21,17 @@ import {
import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
import { MeasurementsTile, ProgramTile } from './DashboardTiles'
import type { AnalysisSnapshot } from '../api/analysis'
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
import { dashboardMessages } from './dashboardMessages'
const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>> = {
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile },
activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile },
progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile },
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile },
'max-records': { title: 'Mensurations', eyebrow: 'Évolution', component: MeasurementsTile },
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile },
'cardio-recovery': { title: 'Programme actif', eyebrow: 'Planification', component: ProgramTile },
const dashboardComponents: Readonly<Record<TileId, DashboardTileComponent>> = {
'next-session': NextSessionTile,
activity: ActivityTile,
progression: ProgressionTile,
'last-session': LastSessionTile,
'max-records': MeasurementsTile,
'muscle-distribution': MuscleDistributionTile,
'cardio-recovery': ProgramTile,
}
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
@ -57,6 +59,13 @@ export function DashboardGrid({
preparedItemsFailed = false,
analysis = null,
}: DashboardGridProps) {
const { language } = useLanguagePreferences()
const messages = dashboardMessages[language]
const dashboardTiles = Object.fromEntries(TILE_IDS.map((id) => [id, {
title: messages.tiles[id][0],
eyebrow: messages.tiles[id][1],
component: dashboardComponents[id],
}])) as Readonly<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>>
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
@ -151,27 +160,27 @@ export function DashboardGrid({
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
if (loading) return <p className="layout-loading" role="status">Chargement de l’agencement…</p>
if (loading) return <p className="layout-loading" role="status">{messages.loading}</p>
return (
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
<div className="layout-toolbar" aria-label="Agencement du Dashboard">
<div className="layout-toolbar" aria-label={messages.layout}>
{!editing ? (
<button className="layout-action layout-action-primary" type="button" onClick={enterEditMode}>Modifier l’agencement</button>
<button className="layout-action layout-action-primary" type="button" onClick={enterEditMode}>{messages.edit}</button>
) : (
<>
<p className="layout-help">Flèches : déplacer · Maj + flèches : redimensionner</p>
<p className="layout-help">{messages.help}</p>
{!desktop && <p className="layout-breakpoint-note">Revenez sur un écran large pour modifier la grille canonique.</p>}
<button className="layout-action" type="button" onClick={cancelEditing}>Annuler</button>
<button className="layout-action" type="button" onClick={resetEditing}>Réinitialiser</button>
<button className="layout-action layout-action-primary" type="button" disabled={saving || snapshot === null || !desktop} onClick={() => void saveEditing()}>{saving ? 'Enregistrement…' : 'Enregistrer'}</button>
<button className="layout-action" type="button" onClick={cancelEditing}>{messages.cancel}</button>
<button className="layout-action" type="button" onClick={resetEditing}>{messages.reset}</button>
<button className="layout-action layout-action-primary" type="button" disabled={saving || snapshot === null || !desktop} onClick={() => void saveEditing()}>{saving ? messages.saving : messages.save}</button>
</>
)}
</div>
{preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>}
{pending && <div className="dashboard-data-message" role="status">Chargement des données du Dashboard…</div>}
{failed && <div className="dashboard-data-message is-error" role="alert">Les données du Dashboard sont momentanément indisponibles.</div>}
{dashboard?.meta.invalid_data && <div className="dashboard-data-message is-warning" role="status">Certaines données invalides ont été écartées.</div>}
{pending && <div className="dashboard-data-message" role="status">{messages.dashboardLoading}</div>}
{failed && <div className="dashboard-data-message is-error" role="alert">{messages.dashboardFailed}</div>}
{dashboard?.meta.invalid_data && <div className="dashboard-data-message is-warning" role="status">{messages.invalidData}</div>}
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
<GridLayout
@ -204,7 +213,7 @@ export function DashboardGrid({
size={size}
editable={editing && desktop}
onKeyDown={(event) => onTileKeyDown(event, id)}
stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
stateLabel={tilePending ? (language === 'fr' ? 'CHARGEMENT' : 'LOADING') : available ? (partial ? messages.recent : messages.available) : messages.unavailable}
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
>
{id === 'next-session' ? (

View file

@ -0,0 +1,56 @@
import type { WebLanguage } from '../presentation/LanguagePreferences'
export const dashboardMessages = {
fr: {
tiles: {
'next-session': ['Prochaine séance', 'Planification'],
activity: ['Activité', 'Régularité'],
progression: ['Progression', 'Évolution'],
'last-session': ['Dernière séance', 'Historique'],
'max-records': ['Mensurations', 'Évolution'],
'muscle-distribution': ['Répartition musculaire', 'Zones'],
'cardio-recovery': ['Programme actif', 'Planification'],
},
layout: 'Agencement du Dashboard',
edit: 'Modifier l’agencement',
cancel: 'Annuler',
reset: 'Réinitialiser',
save: 'Enregistrer',
saving: 'Enregistrement…',
help: 'Flèches : déplacer · Maj + flèches : redimensionner',
loading: 'Chargement de l’agencement…',
dashboardLoading: 'Chargement des données du Dashboard…',
dashboardFailed: 'Les données du Dashboard sont momentanément indisponibles.',
invalidData: 'Certaines données invalides ont été écartées.',
unavailable: 'INDISPONIBLE',
available: 'DISPONIBLE',
recent: 'RÉCENT',
tileEditable: 'tuile modifiable',
},
en: {
tiles: {
'next-session': ['Next session', 'Planning'],
activity: ['Activity', 'Consistency'],
progression: ['Progress', 'Evolution'],
'last-session': ['Last session', 'History'],
'max-records': ['Measurements', 'Evolution'],
'muscle-distribution': ['Muscle distribution', 'Zones'],
'cardio-recovery': ['Active program', 'Planning'],
},
layout: 'Dashboard layout',
edit: 'Edit layout',
cancel: 'Cancel',
reset: 'Reset',
save: 'Save',
saving: 'Saving…',
help: 'Arrow keys: move · Shift + arrow keys: resize',
loading: 'Loading layout…',
dashboardLoading: 'Loading Dashboard data…',
dashboardFailed: 'Dashboard data is temporarily unavailable.',
invalidData: 'Some invalid data was excluded.',
unavailable: 'UNAVAILABLE',
available: 'AVAILABLE',
recent: 'RECENT',
tileEditable: 'editable tile',
},
} as const satisfies Record<WebLanguage, object>

View file

@ -9,7 +9,7 @@ interface LanguageContextValue {
setLanguage: (language: WebLanguage) => void
}
const LanguageContext = createContext<LanguageContextValue | null>(null)
const LanguageContext = createContext<LanguageContextValue>({ language: 'fr', setLanguage: () => undefined })
export function LanguagePreferencesProvider({ children }: { children: ReactNode }) {
const [language, updateLanguage] = useState<WebLanguage>(() =>
@ -29,7 +29,5 @@ export function LanguagePreferencesProvider({ children }: { children: ReactNode
}
export function useLanguagePreferences(): LanguageContextValue {
const value = useContext(LanguageContext)
if (value === null) throw new Error('LanguagePreferencesProvider is missing')
return value
return useContext(LanguageContext)
}

View file

@ -188,6 +188,8 @@ describe("AnalysisPage", () => {
expect(delta.parentElement).toHaveClass("analysis-delta");
expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
expect(document.querySelector(".analysis-chart svg"))
.toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
});
it("uses a factual exercises-by-date histogram in the overview", async () => {
@ -201,4 +203,36 @@ describe("AnalysisPage", () => {
expect.stringContaining("1 exercice(s)"),
);
});
it("reports missing and partial exercise timing without inventing duration", async () => {
const missing: AnalysisSnapshot = {
...analysisFixture,
exercise_groups: [{
zone_id: "legs", label: "Jambes", exercises: [{
exercise_id: "ex_timing", name: "Presse", occurrences: 2, sessions: 2,
sets: 5, measured_seconds: 0, measured_occurrences: 0,
}],
}],
};
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify(missing), { status: 200 }),
);
const { rerender } = render(<AnalysisPage />);
expect(await screen.findByTestId("duration-coverage")).toHaveTextContent(
"Temps non mesuré · durée indisponible",
);
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({
...missing,
exercise_groups: [{
...missing.exercise_groups[0],
exercises: [{ ...missing.exercise_groups[0].exercises[0], measured_seconds: 600, measured_occurrences: 1 }],
}],
}), { status: 200 }),
);
rerender(<AnalysisPage key="partial" />);
expect(await screen.findByTestId("duration-coverage")).toHaveTextContent(
"Couverture temporelle partielle",
);
});
});

View file

@ -283,7 +283,7 @@ export function AnalysisLineChart({
viewBox="0 0 112 78"
role="img"
aria-label={`${label}: ${points.length} points`}
preserveAspectRatio="none"
preserveAspectRatio="xMidYMid meet"
>
<line className="chart-axis" x1="10" y1="6" x2="10" y2="62" />
<line className="chart-axis" x1="10" y1="62" x2="108" y2="62" />
@ -680,6 +680,23 @@ export function AnalysisPage() {
/>
)}
</dl>
{(() => {
const occurrences = snapshot.exercise_groups.flatMap((group) => group.exercises)
.reduce((sum, exercise) => sum + exercise.occurrences, 0);
const measured = snapshot.exercise_groups.flatMap((group) => group.exercises)
.reduce((sum, exercise) => sum + exercise.measured_occurrences, 0);
if (occurrences === 0 || measured === occurrences) return null;
const coverage = measured === 0
? (language === "fr"
? "Temps non mesuré · durée indisponible"
: "Time not measured · duration unavailable")
: (language === "fr"
? "Couverture temporelle partielle"
: "Partial time coverage");
return <p className="analysis-note" data-testid="duration-coverage">
{coverage}
</p>;
})()}
{snapshot.overview.sessions === 0 && (
<p className="analysis-empty">{t.noData}</p>
)}

View file

@ -639,9 +639,11 @@ describe("SleepDiaryWorkspace", () => {
9,
);
expect(intake).toHaveStyle({ left: "18.75%" });
expect(document.querySelectorAll(".sleep-hour-axis > span")).toHaveLength(
25,
);
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
["12:00", "75%"], ["18:00", "100%"],
]);
});
it("isolates an empty editor night while retaining the period agenda", async () => {

View file

@ -717,12 +717,12 @@ export function SleepDiaryWorkspace({
<div className="sleep-axis-row" aria-hidden="true">
<span />
<span className="sleep-hour-axis">
{Array.from({ length: 25 }, (_, index) => (
{[0, 6, 12, 18, 24].map((index) => (
<span
key={index}
style={{ left: `${(index / 24) * 100}%` }}
>
{(18 + index) % 24}
{String((18 + index) % 24).padStart(2, "0")}:00
</span>
))}
</span>

View file

@ -114,12 +114,17 @@
border-radius: 2px;
}
.sync-control {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.6rem;
position: relative;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.45rem;
}
.sync-control button {
border: 1px solid var(--lavender);
border-radius: 0.55rem;
@ -514,7 +519,7 @@ h2 {
.analysis-program progress {
width: 100%;
height: 0.7rem;
accent-color: var(--lavender);
accent-color: var(--green);
}
.analysis-next-session {
padding: 0.75rem;
@ -1558,7 +1563,7 @@ h2 {
.program-progress progress {
width: 100%;
height: 0.65rem;
accent-color: var(--lavender);
accent-color: var(--green);
}
.program-progress .program-next {
margin-top: 0.35rem;
@ -2941,6 +2946,11 @@ h2 {
margin-left: 0;
justify-content: space-between;
}
.header-actions {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
.sync-details[open] {
position: static;
min-width: 0;