From 761b4f0eba049e242fd68adccb6a474ce4a83327 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 28 Jul 2026 22:07:34 -0400 Subject: [PATCH 01/11] feat(ui): add PageLayout, the standalone-page twin of TabGroupLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone pages had no shared shell, so each one hand-rolled its frame by dropping a PageTopbar into the default padded `.page-container`. The bar carries the sidebar's surface and a bottom border precisely because it is chrome meant to pin to the frame — inside a padded, max-width-capped well it instead floated in the middle of the content area with a gutter on every side. PageLayout gives those pages the same frame TabGroupLayout already gave a tab group: full-bleed shell, top bar flush to the top of the content region, body scrolling beneath it in the reading column. The shell rules now live in three mixins shared by `.sk-page` and `.sk-tabgroup` so there is one definition of what a page frame is. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/layouts/PageLayout.jsx | 55 +++++++++++++ frontend/src/styles/layout/_main-content.scss | 80 +++++++++++++------ 2 files changed, 112 insertions(+), 23 deletions(-) create mode 100644 frontend/src/layouts/PageLayout.jsx diff --git a/frontend/src/layouts/PageLayout.jsx b/frontend/src/layouts/PageLayout.jsx new file mode 100644 index 00000000..85b1a386 --- /dev/null +++ b/frontend/src/layouts/PageLayout.jsx @@ -0,0 +1,55 @@ +import { cn } from '@/lib/utils'; +import { PageTopbar } from '@/components/ds'; + +// Shell for a STANDALONE page — one that owns its whole route rather than +// living inside a PageTopbar tab group. It is the exact same frame +// TabGroupLayout gives a group (see layouts/TabGroupLayout.jsx): the top bar +// pins flush to the top of the content region edge-to-edge, and the body +// scrolls beneath it, centered at the reading max-width. +// +// } title="Cron Jobs" actions={…}> +// …page body… +// +// +// Rendering a PageTopbar inside the default padded `.page-container` instead +// (what every standalone page used to do by hand) leaves the bar floating in +// the middle of the content well with a gutter on every side — it carries the +// sidebar's surface and a bottom border precisely because it is meant to be +// chrome pinned to the frame, not the first row of the page body. +// +// `fill` hands the body the whole region to manage its own scrolling (log +// consoles, file trees, terminals); the default centers and pads it. +export default function PageLayout({ + icon, + title, + meta, + tabs, + navLabel, + actions, + fill = false, + className, + topbarClassName, + contentClassName, + children, +}) { + return ( +
+ +
+ {fill ? ( +
{children}
+ ) : ( +
{children}
+ )} +
+
+ ); +} diff --git a/frontend/src/styles/layout/_main-content.scss b/frontend/src/styles/layout/_main-content.scss index 4df89011..bea6c6b6 100644 --- a/frontend/src/styles/layout/_main-content.scss +++ b/frontend/src/styles/layout/_main-content.scss @@ -77,12 +77,17 @@ } } -// Tab group shell (Servers / Domains-style route groups). A parent layout -// renders the PageTopbar + routed sub-nav once (see layouts/ServersLayout.jsx) -// and swaps only the content below, so switching tabs feels like real tabs -// instead of a full-page remount. The shell is full-bleed so the header pins -// to the very top and never shifts between tabs. -.sk-tabgroup { +// ============================================================ +// PAGE SHELL +// The one frame every page gets, in two flavours: +// .sk-page — a standalone page (layouts/PageLayout.jsx) +// .sk-tabgroup — a routed tab group (layouts/TabGroupLayout.jsx) +// Both are full-bleed so the top bar pins flush to the top of the content +// region and the body scrolls beneath it. A page should never hand-roll this: +// dropping a `.sk-topbar` into the default padded `.page-container` leaves the +// bar floating in the well with a gutter on every side. +// ============================================================ +@mixin page-shell { height: 100%; min-height: 0; display: flex; @@ -95,14 +100,54 @@ // Match the 18px header icon the per-page top bars used to render. .sk-topbar__ico svg { width: 18px; height: 18px; } + + @media (max-width: $breakpoint-md) { padding: 10px $space-3; } + } +} + +// Scroll region below the bar. +@mixin page-shell-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +// Centered body: the reading column every ordinary page sits in. +@mixin page-shell-inner { + width: 100%; + max-width: 1400px; + margin-inline: auto; + padding: $space-6 $space-6 $space-10; + + @media (max-width: $breakpoint-md) { padding: $space-4 $space-4 $space-8; } +} + +// Standalone pages (Cron Jobs, Settings, Documentation, …). +.sk-page { + @include page-shell; + + &__content { + @include page-shell-content; + + // `fill` bodies (terminals, consoles, trees) take the whole region and + // manage their own scroll instead of riding the centered column. + > .sk-page__fill { flex: 1; min-height: 0; } } + &__inner { @include page-shell-inner; } +} + +// Tab group shell (Servers / Domains-style route groups). A parent layout +// renders the PageTopbar + routed sub-nav once and swaps only the content +// below, so switching tabs feels like real tabs instead of a full-page +// remount — and the header never shifts between tabs. +.sk-tabgroup { + @include page-shell; + &__content { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow-y: auto; + @include page-shell-content; // Full-bleed pages (File Manager) fill the region edge-to-edge and // manage their own internal scroll. Centered pages instead wrap their @@ -113,18 +158,7 @@ // Centered pages (Agent Fleet, Fleet Monitor, Cloud Servers, Config // Templates) keep their max-width framing inside the full-bleed shell. - &__inner { - width: 100%; - max-width: 1400px; - margin-inline: auto; - padding: $space-6 $space-6 $space-10; - } - - @media (max-width: $breakpoint-md) { - > .sk-topbar { padding: 10px $space-3; } - - &__inner { padding: $space-4 $space-4 $space-8; } - } + &__inner { @include page-shell-inner; } } // REMOVED: `.page-header` (the old From 4a21e52d1b27fa4f04f503d5efb4622c501f09c6 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 28 Jul 2026 22:07:46 -0400 Subject: [PATCH 02/11] refactor(ui): move the standalone pages onto the shared page shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen pages each carried their own frame: some a padded `.page-container`, some a bare fragment, some the `app-detail-page` chrome with a per-page `.main-content:has(...)` escape hatch to undo the frame again. All of them now render through PageLayout, which deletes the per-page shell code and puts every top bar flush against the content region. Loading and not-found early returns take the same shell as the loaded page, so the bar no longer appears, shifts, or vanishes as data resolves. The now-dead `.main-content:has(.ws-detail-page)` rule goes with it — the shared full-bleed rule covers it, and two :has() rules fought each other on small screens. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/pages/AppMap.jsx | 8 +-- frontend/src/pages/CloudflareZoneSettings.jsx | 28 +++++---- frontend/src/pages/Coexistence.jsx | 30 ++++----- frontend/src/pages/DeliveryLog.jsx | 44 +++++++------ frontend/src/pages/Documentation.jsx | 37 ++++++----- frontend/src/pages/Email.jsx | 25 ++++---- frontend/src/pages/ImportWizard.jsx | 39 ++++++------ frontend/src/pages/MonitorDetail.jsx | 61 ++++++++++--------- frontend/src/pages/NotFound.jsx | 11 ++-- frontend/src/pages/Notifications.jsx | 38 ++++++------ frontend/src/pages/ProjectDetail.jsx | 47 +++++++------- frontend/src/pages/Settings.jsx | 13 ++-- frontend/src/pages/TestSandbox.jsx | 40 ++++++------ frontend/src/pages/WorkspaceDetail.jsx | 43 +++++++------ frontend/src/styles/pages/_workspaces.scss | 11 ++-- 15 files changed, 241 insertions(+), 234 deletions(-) diff --git a/frontend/src/pages/AppMap.jsx b/frontend/src/pages/AppMap.jsx index 9fe1d0c1..a6783ce2 100644 --- a/frontend/src/pages/AppMap.jsx +++ b/frontend/src/pages/AppMap.jsx @@ -12,7 +12,7 @@ import { Map as MapIcon, Layers, Compass, Network, Info, } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import useTabParam from '../hooks/useTabParam'; const VIEWS = [ @@ -284,9 +284,7 @@ export default function AppMap() { const view = VIEWS.find(v => v.id === activeView) || VIEWS[0]; return ( -
- } title="App Map" /> - + } title="App Map"> {VIEWS.map(v => ( @@ -329,6 +327,6 @@ export default function AppMap() {
- + ); } diff --git a/frontend/src/pages/CloudflareZoneSettings.jsx b/frontend/src/pages/CloudflareZoneSettings.jsx index 757d6e49..0fb61900 100644 --- a/frontend/src/pages/CloudflareZoneSettings.jsx +++ b/frontend/src/pages/CloudflareZoneSettings.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { Cloud, ShieldCheck, Lock, Gauge, Database, Wand2, Eraser, Flame, Zap, Network, HardDrive } from 'lucide-react'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import CloudflareWafPanel from '../components/cloudflare/CloudflareWafPanel'; import WorkersPanel from '../components/cloudflare/WorkersPanel'; import TunnelsPanel from '../components/cloudflare/TunnelsPanel'; @@ -153,8 +153,12 @@ const CloudflareZoneSettings = () => { if (error) { return ( -
- } title={crumbs} /> + } + title={crumbs} + >
{
- + ); } @@ -174,13 +178,13 @@ const CloudflareZoneSettings = () => { const firstTab = tabGroups[0]?.key || 'actions'; return ( -
- } - title={crumbs} - meta={zone?.provider_zone_id ? `Zone ${zone.provider_zone_id}` : undefined} - /> - + } + title={crumbs} + meta={zone?.provider_zone_id ? `Zone ${zone.provider_zone_id}` : undefined} + >
@@ -316,7 +320,7 @@ const CloudflareZoneSettings = () => { variant="danger" /> )} -
+
); }; diff --git a/frontend/src/pages/Coexistence.jsx b/frontend/src/pages/Coexistence.jsx index f0c885ab..8b60a898 100644 --- a/frontend/src/pages/Coexistence.jsx +++ b/frontend/src/pages/Coexistence.jsx @@ -4,7 +4,8 @@ import { Eye, ArrowRightLeft, ServerCog, ShieldCheck, RadioTower, Upload, RefreshCw, ArrowRight, } from 'lucide-react'; -import { PageTopbar, Pill } from '@/components/ds'; +import { Pill } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import EmptyState from '@/components/EmptyState'; import api from '../services/api'; @@ -91,19 +92,18 @@ export default function Coexistence() { const observed = servers.filter(isObserved); return ( -
- } - title="Coexistence" - meta="Running alongside another panel" - actions={ - - } - /> - + } + title="Coexistence" + meta="Running alongside another panel" + actions={ + + } + >

ServerKit has three explicit adoption modes. Pick the one that matches where the box is today — you can move between them as you migrate. The hard rule: keep exactly @@ -212,6 +212,6 @@ export default function Coexistence() {

- + ); } diff --git a/frontend/src/pages/DeliveryLog.jsx b/frontend/src/pages/DeliveryLog.jsx index 26732ea2..cc2d3b7b 100644 --- a/frontend/src/pages/DeliveryLog.jsx +++ b/frontend/src/pages/DeliveryLog.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Send, RefreshCw, Inbox } from 'lucide-react'; import api from '../services/api'; -import { PageTopbar, MetricCard, KpiBand, FilterDrawer, FilterButton } from '@/components/ds'; +import { MetricCard, KpiBand, FilterDrawer, FilterButton } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; @@ -57,34 +58,31 @@ export default function DeliveryLog() { if (!isAdmin) { return ( - <> - } title="Notification Delivery Log" /> + } title="Notification Delivery Log">
Admins only.
- +
); } const byStatus = stats?.by_status || {}; return ( - <> - } - title="Notification Delivery Log" - meta="Outbound deliveries across all channels" - actions={( - <> - setFiltersOpen(true)} - /> - - - )} - /> - + } + title="Notification Delivery Log" + meta="Outbound deliveries across all channels" + actions={( + <> + setFiltersOpen(true)} + /> + + + )} + >
@@ -162,6 +160,6 @@ export default function DeliveryLog() { setChannel(next.channel || 'all'); }} /> - + ); } diff --git a/frontend/src/pages/Documentation.jsx b/frontend/src/pages/Documentation.jsx index c8902e54..fa40a959 100644 --- a/frontend/src/pages/Documentation.jsx +++ b/frontend/src/pages/Documentation.jsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { BookOpen, Search, ExternalLink, FileText } from 'lucide-react'; import { Input } from '@/components/ui/input'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; const REPO_DOCS_URL = 'https://github.com/jhd3197/ServerKit/blob/main/docs'; @@ -82,23 +82,22 @@ export default function Documentation() { const empty = !groups.length && !rootDocs.length; return ( -
- } - title="Documentation" - meta={<>dev only} - actions={( -
- - setQuery(e.target.value)} - /> -
- )} - /> - + } + title="Documentation" + meta={<>dev only} + actions={( +
+ + setQuery(e.target.value)} + /> +
+ )} + > {empty && (
No docs match “{query}”.
)} @@ -124,7 +123,7 @@ export default function Documentation() { ))} -
+ ); } diff --git a/frontend/src/pages/Email.jsx b/frontend/src/pages/Email.jsx index 9a337e0b..77128dfd 100644 --- a/frontend/src/pages/Email.jsx +++ b/frontend/src/pages/Email.jsx @@ -14,7 +14,8 @@ import { } from 'lucide-react'; import api from '../services/api'; import useTabParam from '../hooks/useTabParam'; -import { PageTopbar, Pill, MetricCard, KpiBand, DataTable } from '@/components/ds'; +import { Pill, MetricCard, KpiBand, DataTable } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import EmptyState from '../components/EmptyState'; @@ -109,17 +110,15 @@ export default function Email() { })); return ( - <> - { loadStatus(); loadDomains(); }}> - Refresh - - )} - /> - + { loadStatus(); loadDomains(); }}> + Refresh + + )} + >
{loading ? (
Loading…
@@ -141,7 +140,7 @@ export default function Email() { )}
- +
); } diff --git a/frontend/src/pages/ImportWizard.jsx b/frontend/src/pages/ImportWizard.jsx index a0440dcb..4b830e19 100644 --- a/frontend/src/pages/ImportWizard.jsx +++ b/frontend/src/pages/ImportWizard.jsx @@ -9,7 +9,8 @@ import api from '../services/api'; import HtaccessConverter from '../components/apps/HtaccessConverter'; import { useToast } from '../contexts/ToastContext'; import { useConfirm } from '../hooks/useConfirm'; -import { PageTopbar, Pill } from '@/components/ds'; +import { Pill } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -366,24 +367,22 @@ function ImportWizard() { const stepperIndex = step - 1; return ( - <> - } - title="Import a site" - meta="Bring a site over from another control panel" - actions={( - <> - {/* Imported sites often carry .htaccess rules — offer the translator here. */} - - {step > 1 && ( - - )} - - )} - /> - + } + title="Import a site" + meta="Bring a site over from another control panel" + actions={( + <> + {/* Imported sites often carry .htaccess rules — offer the translator here. */} + + {step > 1 && ( + + )} + + )} + >
{/* Stepper */}
    @@ -680,7 +679,7 @@ function ImportWizard() { )}
- +
); } diff --git a/frontend/src/pages/MonitorDetail.jsx b/frontend/src/pages/MonitorDetail.jsx index 663d2c05..4f850486 100644 --- a/frontend/src/pages/MonitorDetail.jsx +++ b/frontend/src/pages/MonitorDetail.jsx @@ -18,8 +18,9 @@ import EmptyState from '../components/EmptyState'; import UptimeBars from '../components/monitoring/UptimeBars'; import { monitorStateOf } from '../components/monitoring/monitorShared'; import { - AreaChart, DataTable, KpiBand, MetricCard, PageTopbar, Pill, SegControl, + AreaChart, DataTable, KpiBand, MetricCard, Pill, SegControl, } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; const POLL_MS = 15000; @@ -112,24 +113,26 @@ export default function MonitorDetail() { }; }, [series]); + // Both pre-load states keep the shell, so the bar is in place from the + // first paint rather than appearing once the monitor resolves. if (loading && !monitor) { return ( -
+ } title="Monitor"> -
+ ); } if (notFound || !monitor) { return ( -
+ } title="Monitor"> navigate('/monitoring/monitors')}>Back to monitors} /> -
+ ); } @@ -189,30 +192,30 @@ export default function MonitorDetail() { }; return ( -
- } - title={monitor.name} - meta={`${monitor.check_type} · ${monitor.check_target || 'bound site'} · every ${monitor.check_interval}s`} - actions={( - <> - {state.label} - + + {isHttpish && monitor.check_target && ( + - - {isHttpish && monitor.check_target && ( - - )} - - )} - /> + )} + + )} + >
)} - +
); } diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx index bbd4f1e7..0c990bf5 100644 --- a/frontend/src/pages/NotFound.jsx +++ b/frontend/src/pages/NotFound.jsx @@ -1,9 +1,9 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { FileQuestion, LayoutDashboard, Blocks } from 'lucide-react'; -import { PageTopbar } from '@/components/ds'; import { Button } from '@/components/ui/button'; import EmptyState from '@/components/EmptyState'; import { useContributions } from '@/plugins/contributions'; +import PageLayout from '../layouts/PageLayout'; // Catch-all route (App.jsx `path="*"`). Reached for a genuinely unknown URL and, // importantly, for an extension route whose extension isn't installed/active @@ -22,15 +22,14 @@ export default function NotFound() { if (!__ready) { return ( -
+ -
+ ); } return ( -
- + )} /> -
+ ); } diff --git a/frontend/src/pages/Notifications.jsx b/frontend/src/pages/Notifications.jsx index 21873b05..b95da6a0 100644 --- a/frontend/src/pages/Notifications.jsx +++ b/frontend/src/pages/Notifications.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Bell, CheckCheck, ScrollText, X } from 'lucide-react'; import api from '../services/api'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { useAuth } from '../contexts/AuthContext'; import { useNotifications } from '../contexts/NotificationsContext'; @@ -92,25 +92,23 @@ export default function Notifications() { const noticeItems = (ctxItems || []).filter((it) => it.kind === 'notice'); return ( - <> - } - title="Notifications" - meta={unreadCount ? `${unreadCount} unread` : 'All caught up'} - actions={( - <> - {isAdmin && ( - - )} - - - )} - /> - + )} + + + )} + >
- + ); } diff --git a/frontend/src/pages/ProjectDetail.jsx b/frontend/src/pages/ProjectDetail.jsx index 577a5b84..47dce9ff 100644 --- a/frontend/src/pages/ProjectDetail.jsx +++ b/frontend/src/pages/ProjectDetail.jsx @@ -14,7 +14,7 @@ import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; import EmptyState from '../components/EmptyState'; import { ConfirmDialog } from '../components/ConfirmDialog'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -102,17 +102,19 @@ const ProjectDetail = () => { } } + // Loading / not-found take the same shell as the loaded page, so the top + // bar does not appear, move, or vanish as the project resolves. if (loading) { return ( -
+ } title="Project"> -
+ ); } if (error || !project) { return ( -
+ } title="Project"> { } /> -
+ ); } @@ -132,23 +134,22 @@ const ProjectDetail = () => { const unassignedApps = apps.filter(a => !a.environment_id); return ( -
- } - title={project.name} - meta={project.slug} - actions={ - <> - - - - } - /> - + } + title={project.name} + meta={project.slug} + actions={ + <> + + + + } + >
{project.description && (

{project.description}

@@ -281,7 +282,7 @@ const ProjectDetail = () => { onConfirm={handleDeleteEnvironment} onCancel={() => setDeleteEnv(null)} /> -
+
); }; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index c0c8a559..66bdbfbe 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -25,7 +25,8 @@ import AboutTab from '../components/settings/AboutTab'; import PluginSlot from '../components/PluginSlot'; import { Activity, Code, Database, Layers, Link2, PaintBucket, Sparkles, Webhook, Settings as SettingsIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { PageTopbar, SegControl } from '@/components/ds'; +import { SegControl } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { useNavigate } from 'react-router-dom'; const VALID_TABS = ['profile', 'security', 'connections', 'appearance', 'sidebar', 'whitelabel', 'notifications', 'system', 'users', 'activity', 'site', 'sso', 'api', 'webhooks', 'ai', 'modules', 'migrations', 'developer', 'about']; @@ -55,9 +56,11 @@ const Settings = () => { }, [isAdmin]); return ( -
- } title="Settings" /> - + } + title="Settings" + className="settings-page" + >
-
+ ); }; diff --git a/frontend/src/pages/TestSandbox.jsx b/frontend/src/pages/TestSandbox.jsx index ddc279c3..94b81837 100644 --- a/frontend/src/pages/TestSandbox.jsx +++ b/frontend/src/pages/TestSandbox.jsx @@ -2,7 +2,8 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; import EmptyState from '../components/EmptyState'; -import { Pill, PageTopbar } from '@/components/ds'; +import { Pill } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { timeAgo, formatDuration } from '../utils/time'; @@ -343,29 +344,28 @@ const TestSandbox = () => { if (loading) { return ( -
+ } title="Test Sandbox"> -
+ ); } return ( -
- } - title="Test Sandbox" - actions={( - - )} - /> - + } + title="Test Sandbox" + actions={( + + )} + > {loadError && (
{loadError} @@ -549,7 +549,7 @@ const TestSandbox = () => {
)} -
+ ); }; diff --git a/frontend/src/pages/WorkspaceDetail.jsx b/frontend/src/pages/WorkspaceDetail.jsx index af3d65a7..64595e1a 100644 --- a/frontend/src/pages/WorkspaceDetail.jsx +++ b/frontend/src/pages/WorkspaceDetail.jsx @@ -7,7 +7,8 @@ import { useAuth } from '../contexts/AuthContext'; import ConfirmDialog from '../components/ConfirmDialog'; import EmptyState from '../components/EmptyState'; import Modal from '@/components/Modal'; -import { Pill, ServiceTile, PageTopbar } from '@/components/ds'; +import { Pill, ServiceTile } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import { LayoutGrid, ChevronLeft, Server, Box, Globe, @@ -180,14 +181,21 @@ const WorkspaceDetail = () => { } catch (err) { toast.error(err.message); } }; - if (loading) return
; + // Pre-load states take the same shell as the loaded page. + if (loading) { + return ( + } title="Workspace"> + + + ); + } if (!ws) { return ( -
+ } title="Workspace"> All workspaces -
+ ); } @@ -200,18 +208,19 @@ const WorkspaceDetail = () => { const since = formatSince(ws.created_at); return ( -
- } - title={( - - Workspaces - / - {ws.name} - - )} - /> + } + title={( + + Workspaces + / + {ws.name} + + )} + >
@@ -364,7 +373,7 @@ const WorkspaceDetail = () => { variant="danger" /> )} -
+ ); }; diff --git a/frontend/src/styles/pages/_workspaces.scss b/frontend/src/styles/pages/_workspaces.scss index 625f76f8..8e1fd121 100644 --- a/frontend/src/styles/pages/_workspaces.scss +++ b/frontend/src/styles/pages/_workspaces.scss @@ -359,13 +359,10 @@ } } -.main-content:has(.ws-detail-page) { - padding: 0; - - @media (max-width: $breakpoint-md) { - padding: calc(#{$mobile-header-height} + env(safe-area-inset-top) + #{$space-5}) $space-5 $space-5; - } -} +// No `.main-content:has(.ws-detail-page)` escape hatch here any more: the page +// renders through layouts/PageLayout, so the shared full-bleed rule in +// _main-content.scss already drops the frame (and keeps the mobile header +// clearance). A second :has() rule would fight it on small screens. // Workspace detail page using the shared app-detail-page chrome. .ws-detail-page { From 5879f7689a265b0512ed680f5b045994f260b627 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 28 Jul 2026 22:07:53 -0400 Subject: [PATCH 03/11] fix(ui): import the SchedulePicker stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/_schedule-picker.scss was never added to main.scss, so the shared cron picker has been rendering unstyled everywhere it is used — raw bordered boxes with the preset label and its cron expression jammed together, and the next-run list running into the description. Adding the one import restores it for Backups' schedule card and the server-detail cron tab as well. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/styles/main.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss index 88c6ee05..8eb36868 100644 --- a/frontend/src/styles/main.scss +++ b/frontend/src/styles/main.scss @@ -64,6 +64,7 @@ @import 'components/_empty-state'; @import 'components/_status-badge'; @import 'components/_server-selector'; +@import 'components/_schedule-picker'; // shared cron picker (CronJobs, Backups, server-detail) @import 'components/_contribution-graph'; @import 'components/_command-palette'; @import 'components/_logs-drawer'; From 62507f750c4349790ecc3631d11774cb0e3fa518 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 28 Jul 2026 22:08:03 -0400 Subject: [PATCH 04/11] feat(cron): rebuild the create surface as a drawer on SchedulePicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a job went through a plain modal with a preset setJobForm({ ...jobForm, name: e.target.value })} - placeholder="My Backup Job" - required - /> -
- -
- - setJobForm({ ...jobForm, command: e.target.value })} - placeholder="/usr/bin/backup.sh" - required - /> - The command or script to execute -
- -
- -
- - {jobForm.usePreset ? ( -
- - -
- ) : ( -
- - setJobForm({ ...jobForm, schedule: e.target.value })} - placeholder="0 0 * * *" - required={!jobForm.usePreset} - /> - - Format: minute hour day month weekday (e.g., "0 0 * * *" for daily at midnight) - -
- )} - -
- -