# 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. In the default dark theme the sidebar, including the logo cell, is purple-black (`#120e1a`) with a faint brand wash. Users who want a neutral sidebar can pick a custom theme. 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 default 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. - Do not hard-code the brand purple (`#6b16ed`, `rgb(107 22 237 / …)`, `rgb(163 145 255 / …)`, `#8a4dff`), the dark canvas (`#0a0a0a`), or the card color (`#121212`) in component CSS. Use the tokens: `--color-brand`, `-hover`, `-press`, `-depth`, `-light`, `-ring`, `-foreground`, `--color-sidebar`, `--color-app`, and `--color-card`. For a translucent tint, use `color-mix(in srgb, var(--color-brand) 14%, transparent)`. A custom theme (`html[data-theme="custom"]`) overrides only these tokens, so a hard-coded value stays purple when the user picks another color. - The custom theme has four colors: Sidebar, Selected (the current page in the sidebar), Accent, and Background. The accent gives the brand fill and its hover, press, depth, and ring shades, and `--color-brand-foreground` (the computed `--theme-accent-foreground`, so text stays readable on a light color such as yellow). The surface, border, and text tokens come from one ladder in `app.css` that mixes the readable text color into the background. The sidebar uses the same ladder with the sidebar color, and it can have the other light/dark scheme than the page (`data-sidebar-scheme`; `dark:` utilities follow it). When you add a color token, give it a value in that ladder. - A translucent fill on a control that sits directly on the page (not in a card or table) shows the floating-navigation dots through it. Mix the tint with the canvas instead: `color-mix(in srgb, var(--color-brand) 6%, var(--color-app))`. Controls in cards keep the translucent tint, so they blend with the card. ## 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 the default dark theme 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. A custom theme gets the neutral sidebar rules and its own sidebar color. - The top bar matches the canvas. It has no bottom border at rest. After the page scrolls under it, dark mode draws a neutral hairline (`rgb(255 255 255 / 0.06)`), 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(255 255 255 / 0.07); background: rgb(255 255 255 / 0.03); color: oklch(72% 0 0); } .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 gray 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 left chevron (`‹ Dashboard`). - On a resource page, the main menu starts with a row that opens the resource menu again. It points right and shows the name of the deepest menu (`adonisjs ›`, `Backup ›`). On other pages the main menu has no row above its items, and Dashboard is the first item, right under Search. ### 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. #### Inherited listbox option A setting that inherits through levels (for example instance → team → server → app for image scan rules) uses `x-forms.inherited-listbox`. It wraps `x-forms.listbox` and prepends one option with the value `''`: ```blade ``` - the label is `Use {level} default ({value})`: `{level}` is the level the inherited value comes from (`Coolify` for the instance level, then `team`, `server`), and `{value}` is that value's option label; - choosing it stores `null`, so the field follows later changes of the level above. Do not copy the inherited value into the row; - the top level has concrete values and no inherit option (pass no `inheritLabel`), and the server rejects an empty value for it; - it saves instantly (`onChange`), like the other instant-save listboxes; - a related choice that is not inheritance, such as "Detect automatically", uses a plain listbox with its own first option instead. The reference is `resources/views/livewire/image-scan/partials/scan-rules.blade.php`. ### 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. ### Fix-state switch The "All findings ({n})" card of the Security page splits findings into "Fixed by an update" (a fixed version exists, or an action of the main card fixes it) and "No fix yet". This is a view switch, not a filter, so it is not in the `x-table.filter` menu and "Reset filters" keeps its meaning: - a segmented control in the table toolbar, after the search: a 4px-padded neutral track (`bg-neutral-100`, dark `white/4%`) with 28px, 6px-radius buttons; the current one is the raised white pill (dark `white/9%`) with a hairline ring; - `role="group"` with an `aria-label`, and `aria-pressed` on each button; - each label has its count in parentheses: `Fixed by an update (5)`. The counts follow the search and the filters, so a switch never hides results without saying how many; - "Fixed by an update" is the default. When it is empty, the empty state says "No finding has a fixed version." and links to "No fix yet ({n})"; - the same segmented anatomy is used for the "By package / By CVE" grouping toggle on the right of the toolbar; - ignored findings are not a third state. The table footer has an "{n} ignored" link that opens the ignored list; - a finding of a Dockerfile build that the base image has too says "Comes from {base}." in its details. The reference is `resources/views/livewire/image-scan/vulnerabilities.blade.php`. ### 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 bare status badge rather than pills or 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. ### Severity badge, security marker, gate status, and the Security main card Image scan findings use shared pieces in `resources/views/components/image-scan/`. They keep the status badge rule: the surface stays neutral and only a small mark carries the color, and the severity word is always in the text, so color is never the only signal. Severity tokens live in `app.css`, with a light value in `@theme` and a dark value in `.dark`. They are OKLCH only and never follow the accent or `--color-warning`, because the custom theme recolors both. | Severity | Dot color | Why | |---|---|---| | critical | `--color-error` (`bg-error`) | the existing error red | | high | `--color-severity-high` (orange) | between error red and warning yellow | | medium | `--color-severity-medium` (sky blue) | not yellow like warnings, not purple like the accent | | low, unknown | neutral | no urgency | - Every severity dot clears 3:1 (non-text contrast) on the badge background in both themes, and medium is more than 10 ΔE (OKLab ×100) from the accent and the warning color. `tests/Unit/ImageScan/SeverityTokenContrastTest.php` enforces both. Change a token and run it. - `` renders `x-status-badge` with the types `severity-critical`, `severity-high`, `severity-medium`, or `severity-low` (unknown is neutral). The label is `2 critical`, or `Critical` (sentence case) when `count` is null. - `` is the security marker for lists: a `shield-alert` icon with visible text, never a tooltip-only count, so it works on touch screens. - Only two statuses get one: update_now ("Update now", the icon in `text-error`) and no_fix ("No fix yet", a neutral icon). The color is on the icon only; the text keeps the normal foreground. Every other status renders nothing. - `tooltip` (`data-tooltip`) is the sentence of the primary action, when update_now has one. `label` (`aria-label`) names the status and that action: "Security: update now. Update to 15.15." The marker sets `data-tooltip-wrap`, so a long action sentence wraps at `max-w-xs` instead of running off the screen. Short labels keep the default one-line tooltip; add `data-tooltip-wrap` only to sentence tooltips. - It sits right after the resource name: the environment list (table and cards), the server Resources tab, and the canvas cards (in the chip row, before the extra servers chip; `Card.svelte` draws the same anatomy from the node's `security` value). - Lists rendered by Alpine pass an expression instead of values: ``. Add `relative` when the row has a full-row link overlay, so the tooltip still opens. - Compute the markers of a whole list at once with `VulnerabilityMarkers::for($resources)`. It reads the stored statuses (`SecurityStatus::storedMany()`) with a fixed number of queries, never one per row, and never reads findings. A resource that is not scanned now (instance does not allow scanning, its server has scanning off, or an app turned its scan off; one rule in `ImageScanPolicyResolver::offState()`) is not_checked: no marker, no sidebar dot, and no dashboard count. - The team Security page (Monitoring > Security, `livewire/image-scan/fleet-vulnerabilities.blade.php`) is a top-level destination: title and summary, one dense table with every resource the user can see. - Columns: Resource (with project / environment under the name), Status, Action, Server, Checked. - Status: the badge of the main card ("Update now" error, "Security update" warning, "No fix yet" and "Check needed" neutral), or muted text "No action needed" (ok) and "Not checked". A resource with several images that needs an update adds "{k} of {n} images" under the badge (11px muted, `TeamVulnerabilities::imagesLine()`). One row per resource; images are never nested rows. - Action: the sentence of the primary action (update_now and update_available), as a link to the resource's Security card (`#security-section`) in the text link style (`underline decoration-neutral-400/40 underline-offset-4`); muted "Check again: {reason}." as the same link for check_needed; muted, unlinked "No fix released yet. Coolify checks daily." for no_fix; muted, unlinked "why not checked" text for not_checked. No buttons and no bulk actions. - Sort: update_now, update_available, no_fix, check_needed, ok, not_checked, then the resource name. Filters: Status (with a count per status, "Update now (3)", and in the URL as `statuses[]`), Server, and Project in one `x-table.filter`. 25 rows per page by default. - A search by CVE, GHSA, or package lists the resources with a matching finding and switches the Status and Action columns to the match and its state ("Fix available upstream", "No fix yet", "Ignored", the words of All findings). A small status badge stays next to the resource name. Only the search decodes findings. - Resources that were not scanned are hidden by default (no status filter), so they do not bury the ones that need an action, but never silently: a muted line under the table says "{n} resources on {m} servers were not scanned, so Coolify cannot tell." with the link "Show them" / "Hide them" (`showNotChecked`, in the URL). The Status filter "Not checked" shows them too. - A scan event reloads the page at most once per 10 seconds (`FleetVulnerabilities::RELOAD_INTERVAL`); a server scan sends one event per image. - Fleet callouts: - The dashboard shows one callout, only for update_now: "{n} {resource needs/resources need} a security update now." + Review (danger), which opens the Security page filtered to update_now. After a dismiss it shows again only when a resource that was not dismissed needs an update now: `resources/js/security-callout.js` keeps the dismissed resource UUIDs in `localStorage` and clears them when no resource needs an update now. Nothing else about security goes on the dashboard. - The Security page shows admins "{n} servers do not scan images." Its `localStorage` key holds the number, so a new number shows it again. - Callout keys are per team (`….dismissed.{teamId}`): a dismiss in one team never hides the callout of another. - `` is the deploy gate state: a `x-status-badge` button ("Blocked" error, or "Not scanned" warning when the scan could not run) that opens a popover with the same anatomy as `x-configuration-warning` (icon tile, headline, links). The popover is `position: fixed` and placed from the trigger, so it escapes the `overflow: hidden` log viewer. It replaces the status dot in the deployment log toolbar when blocked (no card above the log). The application heading teleports it into `#configuration-warning-hud-slot` from `xl` and repeats it in the in-flow heading below `xl`, for the latest non-preview deployment only. A blocked popover shows the sentence of the primary action for the blocked image (`:action`, from `SecurityCard::gateAction()`) and an "Open Security" link; it never runs an update itself. "Deploy anyway" shows only where the page can run it and the user has Write access; otherwise "Ask someone with write access to deploy anyway." - The Security main card (`livewire/image-scan/vulnerabilities.blade.php`, data from `SecurityCard::for()`) is the first layer card of a resource's Security page, for people who are not security experts. It answers "Is this OK, and what do I click?" first: status, sentence, and one action, then the why-list (spec 15.11). It is one component for every resource: a list of the images the status counts. No summary tiles, no severity counts, no separate problems table, and no row menus: a count that the status does not use (upstream package fixes, CPE-only matches, findings that are not relevant) contradicts the sentence. - Card header: title "Security" with the status badge of the resource in the badges slot ("Update now" error, "Security update" warning, "No fix yet" and "Check needed" neutral, none for ok and not checked), and "Checked {time}" (absolute time in the tooltip) and Scan now in the header actions (not for check_needed, where Scan now is the primary action in the body). - Vocabulary: the plain layer (the card) says "problems"; the expert layer (All findings, Packages and SBOM, the row footer, ignores) says "findings". Never "issues" or "vulnerabilities" in the UI. - Not scanned yet: apps say "Not scanned yet. The next deploy scans the image."; databases and services say "Not scanned yet." with a highlighted Scan now (only for users who may update the resource). - One image (a database, a Docker image app, a Dockerfile app): the row is open and has no list frame, no row header, no chevron, and no second status badge; the card badge is the status. The image ref is shown in the sentence only when the sentence starts with it; an own-built app ("This app has…") shows the ref only in the footer. - Rows: every image the status counts. That includes a scanned service application or database without a result and every image of a Compose app without a result in its current deployment (keyed `compose-{md5(service)}`). These rows show "Not checked" and "{ref} has not been checked yet.", and Scan now stays in the card header. - Several images (a service, a Compose app): a 15px medium line "{k} of {n} images need an update" only when k > 0 (k = rows with update_now or update_available). For k = 0 there is no header line: the card badge already says the status, and "All images are up to date" is never used. Then one bordered list (`aria-label="Images"`, rows split by dividers). Rows are worst first (update_now, update_available, no_fix, check_needed, ok, not_checked, then the name). The first row with a problem is open; the others, and every "No action needed" row, are closed. - Row header (several images): a real `