# Coolify UI design system This file has two parts: - **Part 1, Portable design language:** the look and feel of the Coolify UI, with no dependency on Coolify code. It has the principles, a copy-paste token sheet in plain CSS, and the anatomy of each component with exact values. Use it to give another app the same feel. It works with any stack (plain CSS, Tailwind, React, Vue, Blade). - **Part 2, Coolify implementation:** how Coolify applies the language with its Livewire + Blade + Alpine + Tailwind v4 frontend: the components, routes, page shells, and the rules for this repository. When you change a shared value in Coolify, update Part 1 and Part 2 in the same change. --- # Part 1. Portable design language ## P1. The feel - **Compact and calm.** 13–14px UI text, 32px controls, small radii, short labels. The data is the hero, not the chrome. - **Layers, not boxes.** Depth comes from a few surface steps (canvas, card, well, control), 1px borders, and very soft shadows. Never use thick borders or heavy floating shadows. - **Card and well.** Content sits in an inset panel (the well) inside a soft outer box with a small title row. In light mode the well is lighter than the outer box. In dark mode a top-level card is a near-canvas outer box with a lighter `#121212` well. - **Tactile buttons.** Buttons and navigation icon tiles have a 2px bottom edge. They lift 1px on hover and press down on click. - **Dark gray, not black.** Dark mode page canvas and the top bar are neutral sRGB 10,10,10 (`#0a0a0a`). Card wells sit one step up (`#121212`). Light mode is pure neutral gray. - **One accent.** Coolify purple in both themes (`#6b16ed`, hover `#7317ff`, pressed `#5a12c7`, depth `#4a0fa3`, light text and icons `#a391ff`). Yellow is only for real warnings. The dark sidebar, including the logo cell, is purple-black (`#120e1a`) with a faint brand wash. The dark command palette has a light purple tint. The page canvas stays neutral. - **Quiet text.** Sentence case. A clear text ladder: primary, dim, faint. No em dashes in UI copy. ## P2. Token sheet Copy this sheet into the app's global CSS. Put the dark values on the selector the app uses for dark mode (here `.dark` on ``). Use the tokens in every component; do not hard-code a gray in a component, because then it does not follow the theme. ```css :root { /* Type */ --ui-font-sans: 'Geist Sans', Inter, system-ui, sans-serif; --ui-font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; /* Surfaces (light: pure neutral) */ --ui-canvas: oklch(98.5% 0 0); /* page background and sidebar */ --ui-panel: #ffffff; /* top header bar */ --ui-card-bg: rgb(0 0 0 / 0.75%); /* card outer box */ --ui-card-border: rgb(0 0 0 / 9%); --ui-well-bg: #ffffff; /* card body (inset well) */ --ui-well-border: rgb(0 0 0 / 8%); --ui-well-shadow: 0 1px 2px rgb(0 0 0 / 6%); --ui-control-bg: oklch(96% 0 0); /* inputs, select triggers */ --ui-control-border: oklch(14.5% 0 0 / 0.1); --ui-menu-bg: oklch(96% 0 0); /* dropdown, listbox, popover (opaque) */ --ui-menu-border: oklch(14.5% 0 0 / 0.1); --ui-hover: oklch(92.2% 0 0); /* menu option and row hover */ --ui-divider: oklch(92.2% 0 0); --ui-chrome-border: rgb(0 0 0 / 6%); /* sidebar and header edges */ /* Text */ --ui-fg: oklch(14.5% 0 0); --ui-fg-dim: oklch(40% 0 0); /* card titles */ --ui-fg-faint: oklch(50% 0 0); /* labels, descriptions, meta */ /* Accent and status */ --ui-accent: oklch(49.65% 0.2709 289.33); /* purple */ --ui-accent-top: oklch(52.34% 0.287 289.16); /* primary button gradient */ --ui-accent-bottom: oklch(43.76% 0.2369 289.82); --ui-accent-edge: oklch(38.04% 0.2031 290.47); /* primary button depth edge */ --ui-accent-fg: #ffffff; --ui-success: oklch(48% 0.135 149.58); --ui-warning: oklch(50% 0.105 80); --ui-error: oklch(52% 0.2 27.33); /* Depth */ --ui-card-shadow: 0 1px 2px rgb(0 0 0 / 5%); --ui-menu-shadow: 0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px rgb(0 0 0 / 0.08); --ui-modal-shadow: 0 24px 64px rgb(0 0 0 / 0.55), 0 4px 16px rgb(0 0 0 / 0.4); --ui-depth-color: rgb(0 0 0 / 0.22); /* button and tile bottom edge */ /* Motion */ --ui-ease-out: cubic-bezier(0.23, 1, 0.32, 1); --ui-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); } .dark { /* Surfaces (dark: pure neutral dark gray) */ --ui-canvas: #0a0a0a; /* page and top bar */ --ui-panel: #121212; /* cards and table headers, not the top bar */ --ui-card-bg: rgb(255 255 255 / 6%); --ui-card-border: rgb(255 255 255 / 8%); --ui-well-bg: rgb(0 0 0 / 34%); --ui-well-border: rgb(255 255 255 / 7%); --ui-well-shadow: 0 1px 2px rgb(0 0 0 / 25%); --ui-control-bg: rgb(255 255 255 / 3%); --ui-control-border: rgb(255 255 255 / 8%); --ui-menu-bg: oklch(25.2% 0 0); --ui-menu-border: oklch(36% 0 0); --ui-hover: oklch(29.31% 0 0); --ui-divider: rgb(255 255 255 / 7%); --ui-chrome-border: rgb(255 255 255 / 4%); /* Text */ --ui-fg: oklch(96.12% 0 0); --ui-fg-dim: oklch(76.99% 0 0); --ui-fg-faint: oklch(65% 0 0); /* Accent stays purple. Yellow is the warning color, not the accent. */ --ui-accent: #6b16ed; --ui-accent-top: #7317ff; --ui-accent-bottom: #5a12c7; --ui-accent-edge: #4a0fa3; --ui-accent-fg: #ffffff; --ui-accent-light: #a391ff; --ui-sidebar: #120e1a; --ui-card: #121212; --ui-success: oklch(72.27% 0.192 149.58); --ui-warning: oklch(88.13% 0.1507 91.7); --ui-error: oklch(66% 0.2 27.33); --ui-depth-color: rgb(255 255 255 / 0.08); } ``` Rules for the tokens: - Translucent surfaces (`--ui-card-bg`, `--ui-well-bg`, `--ui-control-bg`) take the color of what is below them. - Floating surfaces (menus, popovers, tooltips, modals) must be opaque. Use `--ui-menu-bg`, never a translucent value, or the content behind shows through. - Keep the page canvas, cards, menus, and logs pure neutral (chroma 0). The dark sidebar, the logo cell above it, and the dark command palette are the tinted surfaces. - **Dark lightness level.** The canvas is at 14.48% and the header panel at 19.13%. To change the level, move every dark surface and border token by the same amount (canvas, panel, menu, hover, borders, and the Coolify surface ladder), so the steps between layers stay the same. If you move only some of them, a hover fill can become darker than its menu. - Primary buttons, focus rings, and active navigation use purple in both themes. Warning yellow is not an accent. ## P3. Scales | Scale | Values | Use | |---|---|---| | Text | 11px | meta, section labels, card descriptions, header links | | | 12px | badges, small descriptions, code snippets | | | 13px | navigation, buttons, table cells, card titles (semibold) | | | 14px | inputs, menu options, empty-state titles | | | 24px | page titles (with a 13px faint summary below) | | Weight | 500 / 600 | 500 for labels and buttons, 600 for titles | | Height | 24px | badges | | | 32px | buttons, inputs, select triggers, menu options | | | 36px | navigation rows with tiles | | | 40px / 48px | table header / table rows | | Radius | 6px | menu options | | | 8px | buttons, inputs, card wells, navigation tiles | | | 10px | menus and popovers (6px options + 4px inset) | | | 12px | card outer box | | | full | badges, pills | | Spacing | 6px | card outer padding | | | 16px | well padding, page gutters on small screens | | | 24px | gap between cards (use a flex or grid gap, not margins) | | Motion | 80ms | button lift and press | | | 120ms | color, border, and focus changes | | | 150ms | menu swaps, row hover | Nested boxes normally follow `outer radius = inner radius + inset`. The card (12px outer, 8px well, 6px inset) is an accepted exception. ## P4. Components Class names below are examples. Keep the values and structure. ### App shell - Three layers: the canvas (page and sidebar, `--ui-canvas`), the top header bar (`--ui-panel`, 48px high), and the cards on the canvas. - In dark mode, the outer box of a top-level card is `color-mix(in srgb, var(--ui-panel) 66%, black)` (about `#0c0c0c`), and its well is `--ui-panel` (`#121212`). Tables use the same pair: the darker mix for the header row and `--ui-panel` for the rows. - In light mode the sidebar is the canvas color and has a 1px `--ui-chrome-border` right edge. In dark mode the sidebar column, including the logo cell, is `#120e1a` with a faint lavender right edge (`rgb(163 145 255 / 0.10)`). The nav has a brand-purple wash that starts at 9% and fades out; the logo cell has a flat 9% wash, so the two read as one surface. - The top bar matches the canvas. It has no bottom border at rest. After the page scrolls under it, dark mode draws a lavender hairline (`rgb(163 145 255 / 0.12)`), a translucent bar, blur, and a soft shadow. The logo cell stays the solid sidebar color. - Keep the chrome borders faint. A strong border on near-black looks like a line drawn on the page. ### App header (logo cell) - The logo cell is the top of the sidebar column, in the 48px top bar. It is as wide as the sidebar: 224px expanded, 64px collapsed. - Expanded: the wordmark (15px semibold, tight tracking, `--ui-fg`), then the version on the same baseline (10.5px medium, `--ui-fg-faint`), with 6px between them and 16px side padding. An update badge sits at the right end. - Collapsed: only the 20px logo mark, centered. The cell gets a bottom border so that it lines up with the top bar edge. - The update badge is a small pill: 18px high, 6px side padding, 9.5px semibold text, fully rounded, a 10% fill, and a 25% inset ring of the same color. It is purple in light mode and warning yellow in dark mode. While an update runs, the same pill says "Updating". It hides when the sidebar is collapsed. - The mobile drawer starts with the same wordmark and version in a 48px row with a bottom border, and a 32px close button on the right. It has no update badge. ### Mobile header - Below the desktop breakpoint (1024px) a sticky top bar replaces the desktop top bar and sidebar. It is about 56px high (12px vertical padding, 16px side padding, 24px from 640px), translucent (95% of `--ui-panel`) with a small blur, and has a faint bottom border. - Left: the logo mark (18px) in a 32px tile with an 8px radius and a neutral fill (`rgb(255 255 255 / 6%)` in dark mode), then the team switcher. The team name truncates. - Right, with 4px gaps: status indicators (configuration warnings, active deployments), the update badge, the user menu, and a 36px sidebar button (the same sidebar glyph as the desktop collapse toggle). The sidebar button opens the drawer from the right. ### Sidebar navigation ```css .nav-section { padding: 4px 10px; font-size: 11px; font-weight: 500; color: var(--ui-fg-faint); } .nav-item { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 10px 0 4px; font-size: 13px; font-weight: 500; color: var(--ui-fg-dim); background: transparent; /* the row itself never fills */ } .nav-item:hover, .nav-item.is-active { color: var(--ui-fg); } .nav-tile { /* 30px tile: 16px icon + 6px padding + 1px border */ box-sizing: content-box; width: 16px; height: 16px; padding: 6px; border: 1px solid rgb(0 0 0 / 9%); border-radius: 8px; background: #fafafa; box-shadow: 0 2px 0 var(--ui-depth-color); transition: transform 80ms, box-shadow 80ms, border-color 120ms, background-color 120ms; } .dark .nav-tile { border-color: rgb(163 145 255 / 0.13); background: rgb(107 22 237 / 0.07); color: #b9adff; } .nav-item:hover .nav-tile { transform: translateY(-1px); box-shadow: 0 3px 0 var(--ui-depth-color); } .nav-item:active .nav-tile { transform: translateY(2px); box-shadow: none; } .nav-item.is-active .nav-tile { color: #fff; border-color: #8a4dff; background: linear-gradient(180deg, var(--ui-accent-top), var(--ui-accent-bottom)); --ui-depth-color: var(--ui-accent-edge); box-shadow: 0 2px 0 var(--ui-accent-edge); /* no glow: the row clips it */ } ``` - Use outline icons (1.5px stroke) at 16px. A brand item can use a one-color brand mark when it is clearer than an outline icon. - Rows with a submenu end in a 12px chevron. In dark mode the chevron is hidden until the row is hovered, focused, or current. - Group rows under section labels (11px in light mode; 10.5px uppercase muted lavender in dark mode), and separate groups with a 1px divider. - Do not put a glow (a blurred `box-shadow`) on a tile. The current row clips it, so it shows as a dim square behind the tile. - In the collapsed sidebar, Search is a tile like the other items: no row fill and no frame around it. Only the expanded sidebar shows Search as an input. - A menu that replaces another menu in place starts with a back row: a centered label and a 12px chevron on the side of the direction. The back row points left (`‹ Dashboard`). The row that opens the resource menu again from the main menu points right (`adonisjs ›`) and shows the resource name. - Keep the menu items in the same place on every page. When a page has no resource menu, keep an empty row of the same height where the resource row would be. ### Card (outer box, title row, well) ```css .card { display: flex; flex-direction: column; border: 1px solid var(--ui-card-border); border-radius: 12px; padding: 6px; background: var(--ui-card-bg); box-shadow: var(--ui-card-shadow); overflow: visible; /* menus and tooltips must escape */ } .dark .card { background: color-mix(in srgb, var(--ui-panel) 66%, black); box-shadow: none; } /* top-level card */ .dark .card > .card-well { background: var(--ui-panel); box-shadow: none; } /* top-level well */ .card-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; padding: 4px 8px 10px; } .card-title { font-size: 13px; font-weight: 600; line-height: 20px; color: var(--ui-fg-dim); } .card-description { margin-top: 2px; font-size: 11px; line-height: 16px; color: var(--ui-fg-faint); } .card-header a { font-size: 11px; font-weight: 500; color: var(--ui-fg-faint); } .card-header a:hover { color: var(--ui-fg); } .card-well { position: relative; padding: 16px; border: 1px solid var(--ui-well-border); border-radius: 8px; background: var(--ui-well-bg); box-shadow: var(--ui-well-shadow); } .card-well.is-flush { padding: 0; } /* tables and lists that touch the edge */ ``` - Card actions (buttons, badges, a filter) go in the title row on the right. - Keep one collection in one card: its toolbar at the top of a flush well, its table below, and its pagination in the same well. ### Inputs and select triggers ```css .input { display: block; width: 100%; height: 32px; padding: 0 12px; border: 1px solid var(--ui-control-border); border-radius: 8px; background: var(--ui-control-bg); color: var(--ui-fg); font-size: 14px; transition: border-color 120ms var(--ui-ease-out), box-shadow 120ms var(--ui-ease-out); } .input::placeholder { color: var(--ui-fg-faint); } .input:focus-visible { outline: none; border-color: var(--ui-accent); } .label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; color: var(--ui-fg-dim); } ``` - Use a custom listbox, not the native `` on application routes, including mobile fallbacks. Use: ```blade ``` Boolean checkboxes should normally become descriptive two-option listboxes. Use `.live` behavior only when the selection needs an immediate server rerender. Keep checkboxes for compact permission matrices and multi-select lists. Those controls must use the shared `x-forms.checkbox` anatomy: an 18px rounded custom box, purple checked fill in light mode, yellow checked fill in dark mode, and a high-contrast check mark. Never expose the browser or Tailwind Forms default checkbox on application pages. The popup panel uses a 10px radius around 6px options with a 4px inset. Keep the option content left-aligned and size the panel to its content or trigger; do not create an unnecessarily wide menu. Every dropdown, menu, listbox panel, and the command palette uses the shared `--shadow-dropdown` token (`0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px rgb(0 0 0 / 0.08)`) for a restrained, consistent lift. Do not hand-roll a heavier `shadow-lg` / `0 18px 50px` / `0.45`-alpha drop shadow on a menu. Reserve the stronger `--shadow-modal` for actual modals, dialogs, and toasts. Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The selected option is indicated inside the menu, not repeated on the trigger. #### Livewire dropdown state synchronization Instant-save listboxes must not flash back to an older value while Livewire is saving or morphing the DOM. Treat the Alpine selection as the current visual state until its request finishes: - await the Livewire change handler and prevent overlapping selections while it is running; - when a client-managed listbox can be rerendered by an unrelated or stale Livewire response, use the listbox's `preserveValue` option so the morph does not replace its newer Alpine value; - scope `preserveValue` to controls whose value is owned by that interaction; do not use it when external server events must replace the displayed value; - after saving through a related model, refresh the parent component's loaded relationship before rendering the response. A database write alone does not update an already-loaded Eloquent collection; - use stable `wire:key` values for rows containing listboxes. Do not include the selected value in the key, because recreating the Alpine component causes a visible reset; - remember that a portalled options panel is teleported outside its visual wrapper. Guard selection in the Alpine handler itself rather than relying only on `pointer-events` or a disabled wrapper. The failure mode to avoid is: selection B is shown optimistically, selection A is chosen next, the response for B morphs the listbox back to B, then the later response finally shows A. The control should remain on the newest accepted selection throughout the save sequence. #### Multi-select filter dropdowns Toolbar filters that can combine criteria use one multi-select listbox rather than separate dropdowns or a single selected value. Follow the deployment history filter in `resources/views/livewire/project/application/deployment/index.blade.php`: - set `aria-multiselectable="true"` on the listbox; - group related options under compact uppercase labels; - keep the dropdown open while options are toggled; - use the shared 16px custom checkbox treatment: purple checked fill in light mode, yellow checked fill in dark mode, and a high-contrast check mark; - show the number of active selections in a small count pill on the static `Filter` trigger; - combine selections within one group with OR logic and combine different groups with AND logic; - constrain only the options area with `max-h-80 overflow-y-auto`; - place a persistent `Reset filters` action in a separate footer below the scrollable options, divided by a top border; - disable the reset action when no filter is active, and close the dropdown after resetting. Do not represent the empty state as a selectable `All` option. The footer reset action is the single way to return the multi-select to its unfiltered state. ### Standard table controls Dense tables use the shared `x-table.*` components so search, filters, sorting, and backend loading states remain visually and behaviorally consistent: - `` owns the responsive search-left/actions-right layout; - `` owns the search icon, optional loading indicator, clear action, sizing, and input anatomy; - `` owns the static Filter trigger, active-count pill, multi-select panel, scrollable options area, and Reset filters footer; - `` owns the static Sort trigger and single-select panel; - `` overlays only the changing table data for backend search, filter, sort, and pagination requests. Tables continue to own their filter options, sort choices, headers, rows, queries, permissions, and empty states. Backend-filtered or paginated tables must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar and control anatomy but do not show an artificial loading state. ### Buttons - neutral actions use the shared `.button`; - primary actions use the theme-aware purple/yellow tint; - destructive actions use the existing error treatment; - use outline Reicons where a matching glyph exists; - avoid raw browser-default buttons and old dark-mode purple fills. ### Unsaved changes `resources/views/components/unsaved-bar.blade.php` is a compact floating bottom-center pill. It contains: - “You have changes that haven't been saved yet.” - a subtle Reset action; - a theme-aware Save changes button matching the tab accent. On small viewports the pill is inset (`inset-x-3`) and stacks: full label on the first line, Reset / Save on the second (right-aligned). From `sm` up it returns to the centered single-row nowrap pill. Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing instant-save pattern. When those requests share a component with a modal draft, pass the unsaved bar a `dirty` Alpine expression comparing that draft with its initial values, so unrelated saves do not hide pending changes. Mount modal save bars only while the modal is open to avoid inactive keyboard shortcuts. --- ## 7. Dense tables Collections with many rows should use the Cloudflare-inspired table pattern: - toolbar above the table; - search on the left; - filters, sort, view toggles, and Add on the right; - 40px header row and roughly 48px data rows; - subtle row hover; - plain text or the shared status badge rather than large colored chips; - compact action at the far right; - no separate layer card for each item. Do not add a summary card above a table when it only repeats the row count, current page, or refresh interval. Keep counts and pagination in the footer. Background polling stays silent unless its state is actionable; do not add a “Live updates” badge just to explain that a table refreshes. Filters only render meaningful values; use the shared listbox instead of a number input or browser-native control. The footer is always inside the table shell: - `Showing X–Y of Z` on the left; - first, previous, current page, next, and last controls on the right. Hide the entire pagination footer when there is only one page (`totalPages > 1`). A lone “1–2 of 2” bar with disabled controls adds noise and is unnecessary. Use `x-status-badge` for resource and execution state. It is a small neutral pill with a semantic dot, not a full colored rectangle. Relevant classes: - `.data-table` - `.data-table-header` - `.data-table-row` - `.table-badge` Create a page-specific grid class when columns differ. Add responsive rules that hide secondary columns before allowing horizontal overflow. --- ### Domain rows on mobile Domain tables become compact summary cards below 600px. Keep the public URL on its own line, followed by a short routing summary such as `HTTP → HTTPS · Port 80 · Noindex`. Put DNS status and the existing icon actions on the final row. Do not squeeze desktop label/value columns into a mobile card or move settings behind an overflow menu. Long domains wrap, and icon actions retain 40px touch targets. ## 8. Modals, confirmations, and toasts ### Modals `x-modal-input` and confirmation dialogs reuse the layer-card shell: - the same outer box, title row, and inset well as layer cards; - content-width desktop sizing; - shared 32px controls; - no redundant description below a self-explanatory title; - custom listboxes instead of native browser selects; - listbox and dropdown panels must render above the modal body and escape its scroll container. Never clip a panel at the modal boundary or make users scroll the modal to see its options; - when there is not enough viewport space below the trigger, open the panel above it while keeping the panel visually on top of the modal; - right-aligned footer actions below a divider; - compact action buttons, never a submit button stretched by a column layout. Edit modals should use the same field layout and option set as their matching create modal. ### Command palette The global search command palette (`livewire:global-search`) is a compact top-anchored overlay: - elevated shell with hairline ring and modal shadow (not a heavy floating card); - recessed-neutral header strip with outline search glyph and 14px input; - compact OS-aware mod+K (`⌘K` on macOS, `Ctrl+K` on Windows/Linux) / `/` / `ESC` kbd chips matching the sidebar search trigger; - nested base-color results body with group labels in sentence case; - dense result rows as inset 6px-radius pills (listbox anatomy), not full-bleed bars with global focus rings; - hover uses neutral fill; keyboard focus uses a soft accent wash plus a 2px left rail — never the global `ring-2` / ring-offset treatment; - create rows use a neutral plus tile that only picks up the accent when the row is focused; - type pills and quickcommand chips stay recessed; they tint with the accent only on the focused row. In dark mode the text of the focused row's chips, its icon tile glyph, and its chevron are white; - in dark mode the shell, header, and results body carry the brand-purple tint from Part 1 (rules after the dark `.view-toggle` rules in `resources/css/app.css`). Light mode stays neutral; - neutral thin scrollbar inside the results body (not brand-colored); - create-resource modals opened from the palette reuse the standard `application-settings-section` layer-card shell. Preserve keyboard navigation (arrow keys, Enter via focused links, Escape to clear then close), `/` and mod+K (⌘K / Ctrl+K by OS) open shortcuts, and the multi-step server → destination → project → environment create flow. ### Toasts `resources/views/components/toast.blade.php` provides the global `window.toast(message, options)` API and Livewire event handling. Current toast behavior: - compact layered card, maximum width 26rem; - Reicon status tile for success, info, warning, danger, or default. The tile (`.toast-icon`) is 3D: a border and a 2px bottom edge in the status color; - a faint edge (`.toast-card`, dark `rgb(255 255 255 / 0.05)`) that blends into the toast background; - title plus optional description; - dismiss and copy-details actions; - normally up to four stacked notifications, without evicting persistent notices; - four-second dismissal, paused while hovered; - `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button; - support for all six screen positions and sanitized custom HTML. Do not bring back the old oversized dark rectangle. --- ## 9. Terminals, logs, and metrics ### Terminals Application and server browser terminals use the same browser-oriented console shell, theme picker, compact header controls, and outline `browser-terminal` Reicon. Hide a container switcher when only one container exists. The themed console shell belongs to an open session. Before a target is selected, the global Terminal page stays a normal top-level destination: a full-width layer card titled `Start a terminal session`, its filter input in the card header actions, and grouped `Servers` / `Containers` rows reusing the command-palette row classes. Do not render an empty full-height console canvas just to host the target picker, and do not offer the console theme selector before a session owns that canvas. Rows show the target name, a muted server column that only appears when the team has more than one server, and the shared chevron. Group headers stick to the top of the scrolling list and carry a count. ### Logs Runtime and deployment logs should feel like a clean terminal surface: - keep a single log stream inside one layer card instead of adding an introductory card above it; - one compact toolbar; - a recessed monospace log viewport; - search and line-count controls aligned with icon actions; - clear live/follow state; - fullscreen support without changing the control language; - custom listbox-style menus instead of browser dropdowns. ### Metrics Metrics pages use separate layer cards for range selection, CPU, and memory. Charts follow the application metrics implementation: - 240px area chart; - smooth 2px stroke and restrained gradient fill; - dashed neutral grid; - no ApexCharts toolbar; - tooltip positioned at the hovered point; - UTC on both axes and tooltip; - 20% headroom above observed values; - downsample long time ranges before rendering. Only add a metric if Sentinel exposes historical data for it. Current Sentinel history endpoints store CPU, memory, disk, network throughput, and load average (Sentinel PR #47 added disk, network, and load). Bulk snapshot endpoints (`/api/summary`, `/api/containers/current`) return the latest host and per-container values in one call for fleet-wide views. The fleet Metrics page (`/metrics`, top-level sidebar) aggregates these across every team server: KPI tiles, per-metric trend cards, a threshold-colored servers table, and a fleet-wide hottest-containers list. --- ## 10. Current reference surfaces Use these as implementation references: | Surface | Reference | |---|---| | Dashboard overview | `resources/views/livewire/dashboard.blade.php` | | Top-level collection cards | `resources/views/livewire/project/index.blade.php`, `resources/views/source/all.blade.php` | | Top-level settings families | `resources/views/components/team/settings-layout.blade.php`, `resources/views/components/notification/settings-layout.blade.php` | | General settings and form anatomy | `resources/views/livewire/project/application/general.blade.php` | | Advanced settings | `resources/views/livewire/project/application/advanced.blade.php` | | Resource actions in the topbar | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/components/split-action.blade.php`, `resources/views/livewire/server/navbar.blade.php` | | Grouped settings sidebar | `resources/views/components/application/configuration-sidebar.blade.php`, `resources/views/components/server/sidebar.blade.php` | | Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | | Standard table toolbar controls | `resources/views/components/table/*` | | Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | | Fleet metrics page | `resources/views/livewire/fleet/metrics.blade.php` | | Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | | Layer card | `resources/views/components/application/settings-section.blade.php` | | Custom dropdown | `resources/views/components/forms/listbox.blade.php` | | Empty state | `resources/views/components/empty.blade.php` | | Status pill | `resources/views/components/status-badge.blade.php` | | Floating save pill | `resources/views/components/unsaved-bar.blade.php` | | Global toast | `resources/views/components/toast.blade.php` | | Command palette / global search | `resources/views/livewire/global-search.blade.php` | | Outline icons | `resources/views/components/reicon.blade.php` | | Shared styling | `resources/css/app.css`, `resources/css/utilities.css` | | HTTP error pages | `resources/views/components/error-page.blade.php`, `resources/views/errors/*` | HTTP error pages (400, 401, 402, 403, 404, 419, 429, 500, 503) use the shared `` component on the public auth-style canvas: theme-aware status code, compact title and muted description, neutral `.button` actions, and an `auth-text-link`-style Contact support link. Keep copy sentence-case and avoid oversized 200px status numbers. --- ## 11. UI implementation checklist 1. Inventory every route and reusable partial in the family before editing. 2. Read the current Blade and Livewire class before changing presentation. 3. Preserve every existing action, authorization check, loading state, and confirmation. 4. Add the grouped settings sidebar and scoped workspace/form class. 5. Convert meaningful groups to layer cards and use `gap-6`. 6. Make the responsive column count match the controls visible in every state. 7. Replace native selects and checkbox-style configuration with listboxes. 8. Use one save model per component: instant-save or one floating dirty bar. 9. Check nested radii using `outer = inner + inset`. 10. Keep modal descriptions purposeful and footer actions compact/right-aligned. 11. Use tables for dense collections and cards for forms or summaries. 12. Use `x-status-badge`, `x-empty`, and `x-reicon`. 13. Confirm light and dark accent behavior. 14. Check fixed-nav anchor offsets and responsive stacking. 15. Sweep every sibling route for legacy controls and shells. 16. Run `git diff --check`. 17. Compile Blade views with `./scripts/dev exec php artisan view:cache`. 18. Build assets with `npm run build`. 19. Hard-refresh and inspect the family routes in both themes.