From 75291905872356e78e7eddc1f9f9e9fe526764a8 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:26:47 +0100 Subject: [PATCH] fix(ui): shared Button content-sizing + padding props, and button call-site cleanups (#6914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A batch of shared **design-system** fixes (Button, SegmentedControl, Chip, a new CarouselDots) and the consumer/call-site cleanups they unlock, following the button consolidation (#6787). Also includes dark-theme token alignment and some portal/auth polish that rides on the same components. The shared Button now sizes to its content instead of clipping it, gains per-axis padding controls, and no longer misbehaves while loading or disabled; several call sites are then migrated onto the proper component APIs. ## Shared components (`core/ui`) ### Button - **Content-driven height.** `--button-height` is now a `min-height`, not a fixed cap. Single-line buttons still land exactly on the shared control-height scale (pixel-aligned with `ActionIcon` / `SegmentedControl`), while taller content — wrapped labels, stacked title + subtitle rows — grows the button instead of being clipped mid-glyph. Short content is re-centered with `align-content`, **without** overriding the root `display`, so a consumer's own layout (e.g. a full-width list row) isn't disturbed. - **Padding props.** New `p` / `px` / `py` props (`none`/`xs`/`sm`/`md`/`lg`/`xl`) override the size-based padding per axis. Vertical padding is applied through a `--sui-btn-py` CSS variable, so consumers can also set it from their own class. - **Loading no longer collapses.** A `fullWidth` button is never treated as icon-only, so an execute button whose label is momentarily absent while files hydrate (e.g. `ScopedOperationButton`) keeps its full width with a centered spinner instead of shrinking to an icon-sized square for a split second. - **Disabled in dark mode.** A disabled *primary* button keeps a muted version of its own accent fill (`opacity: 0.55`) instead of Mantine's near-black `--mantine-color-disabled`, which blended into dark surfaces and made the button all but disappear. Loading spinners are excluded so they stay full-strength. No breaking API changes — buttons that don't opt in render exactly as before. ### SegmentedControl - Fixed a bug where a segment marked `disabled` that also happened to be the currently-selected value was rendered disabled, leaving the active segment un-selectable/greyed. A disabled option is now only disabled when it isn't the current value. ### CarouselDots (new) - New shared dots indicator component (with Storybook story), used by the login carousel. ### Chip / theme - Dark-theme tokens in `theme.css` aligned to the portal's `tokens.css` so the editor and portal (Processor) dark modes stop drifting (chrome surfaces lift off the darker canvas); plus a Chip dark-mode styling fix and a small `mantineTheme` cleanup. ## Consumer / call-site cleanups - **Compare** tool: the swap control is now a regular shared Button placed **between** the Original and Edited file cards (the bespoke full-height vertical swap button and its CSS were removed), and the file cards fill the full available width. - **Certificate format**: replaced the inline-styled buttons with clean two-state (primary / secondary) buttons. - **ToolPicker**: restored the label selectors that #6787 renamed to the never-emitted `.sui-btn__label`, and fixed the sidebar-search row clipping. - **File sidebar**: "View all files" row fix; `FileSidebarFileItem` migrated off `display:flex` + `gap` on the Button root (which no longer reaches the nested label) onto `leftSection` / `rightSection` + a stacked label. ## Portal / auth polish - Portal button consolidation and styling across Header, SettingsModal, Home, Infrastructure, ApiKeyCard, and PopularUseCases. - **Login**: onboarding text now shows the default starting username / password; login carousel uses the new CarouselDots; desktop OAuth styling tweak. ## Verification - Storybook: button sizes measure exactly on the control-height scale and match `ActionIcon`; icon-only buttons stay square and centered; `fullWidth` loading buttons hold full width; disabled dark-mode primary buttons render as a muted accent rather than grey. - Single-line buttons are pixel-identical before/after; only buttons whose content previously overflowed a fixed height render differently (they now fit rather than clip). - `task frontend:lint` clean; typecheck shows only the pre-existing third-party `node_modules` noise also present on `main`. --- .../shared/ButtonSelector.stories.tsx | 42 +++ .../shared/FileSelectorPicker.module.css | 1 + .../components/shared/FileSidebarFileItem.css | 26 ++ .../components/shared/FileSidebarFileItem.tsx | 257 +++++++++--------- .../src/core/components/tools/ToolPanel.css | 8 +- .../certSign/CertificateFormatSettings.tsx | 111 ++------ .../components/tools/compare/compareView.css | 41 +-- .../tools/toolPicker/ToolButton.tsx | 3 + .../tools/toolPicker/ToolPicker.css | 25 +- frontend/editor/src/core/styles/theme.css | 40 +-- .../editor/src/core/theme/mantineTheme.ts | 17 -- frontend/editor/src/core/tools/Compare.tsx | 31 +-- frontend/editor/src/core/ui/Button.css | 30 ++ .../editor/src/core/ui/Button.stories.tsx | 51 +++- frontend/editor/src/core/ui/Button.tsx | 64 +++-- frontend/editor/src/core/ui/CarouselDots.css | 52 ++++ .../src/core/ui/CarouselDots.stories.tsx | 48 ++++ frontend/editor/src/core/ui/CarouselDots.tsx | 59 ++++ frontend/editor/src/core/ui/Chip.css | 11 + frontend/editor/src/core/ui/MantineForms.css | 4 + .../editor/src/core/ui/SegmentedControl.css | 7 + .../editor/src/core/ui/SegmentedControl.tsx | 9 +- frontend/editor/src/core/ui/controlSizes.ts | 41 +++ frontend/editor/src/core/ui/index.ts | 1 + .../components/SetupWizard/desktopOAuth.css | 5 +- .../editor/src/portal/components/Header.css | 15 +- .../editor/src/portal/components/Header.tsx | 15 +- .../components/infrastructure/ApiKeyCard.tsx | 26 +- frontend/editor/src/portal/views/Home.css | 16 +- frontend/editor/src/portal/views/Home.tsx | 137 +++++----- .../src/portal/views/Infrastructure.css | 5 +- .../auth/ui/LoginRightCarousel.tsx | 37 +-- .../editor/src/proprietary/auth/ui/auth.css | 28 +- .../components/policies/Policies.css | 3 +- .../editor/src/proprietary/routes/Login.tsx | 12 +- 35 files changed, 778 insertions(+), 500 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx create mode 100644 frontend/editor/src/core/ui/CarouselDots.css create mode 100644 frontend/editor/src/core/ui/CarouselDots.stories.tsx create mode 100644 frontend/editor/src/core/ui/CarouselDots.tsx diff --git a/frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx b/frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx new file mode 100644 index 0000000000..1169c0b3a7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ButtonSelector from "@app/components/shared/ButtonSelector"; + +/** Reproduces the redaction "Mode" picker (Automatic / Manual). */ +const meta: Meta = { + title: "Shared/ButtonSelector", + component: ButtonSelector, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function ModeDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState("automatic"); + return ( + + ); +} + +/** Enabled (files present): the selected segment must be dark + readable. */ +export const RedactionMode: Story = { render: () => }; + +/** Both options disabled (no files) — the selected segment must STILL be legible. */ +export const DisabledOptions: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css index 7d9b0251db..89c57dd3fa 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css @@ -224,6 +224,7 @@ /* ── Individual file row ─────────────────────────── */ .fileItem { width: 100%; + --sui-btn-py: 0.625rem; padding: 0.625rem 0.875rem; display: flex; align-items: center; diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 7bce3162dc..51278c1673 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -23,6 +23,7 @@ /* ---- File item ---- */ .file-sidebar-file-item { + position: relative; display: flex; align-items: center; gap: 10px; @@ -37,6 +38,31 @@ contain-intrinsic-size: auto 46px; } +.file-sidebar-file-actions { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 2px; + pointer-events: none; +} + +.file-sidebar-file-item:hover .file-sidebar-file-actions, +.file-sidebar-file-item.viewed .file-sidebar-file-actions { + pointer-events: auto; +} + +/* Only shrink the name to clear the buttons while they're visible. */ +.file-sidebar-file-info { + transition: padding-right 0.12s ease; +} +.file-sidebar-file-item:hover .file-sidebar-file-info, +.file-sidebar-file-item.viewed .file-sidebar-file-info { + padding-right: 3.5rem; +} + .file-sidebar-file-item:hover:not(.selected) { background-color: rgba(59, 130, 246, 0.06); } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index 8621466120..8647e18a68 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -365,133 +365,138 @@ export function FileItem({ )} - { - e.stopPropagation(); - onEyeClick(fileId, e); - }} - tabIndex={-1} - aria-label={ - isViewedInViewer - ? t("fileSidebar.fileItem.closeViewer", "Close viewer") - : t("fileSidebar.fileItem.openInViewer", "Open in viewer") - } - > - - - - {(onDelete || - (canSaveToCloud && onSaveToCloud) || - (hasVersionHistory && onVersionHistory)) && ( - - - e.stopPropagation()} - tabIndex={-1} - aria-label={t( - "fileSidebar.fileItem.moreActions", - "More actions", - )} - > - - - - e.stopPropagation()}> - {hasVersionHistory && onVersionHistory && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(fileId); - }} +
+ { + e.stopPropagation(); + onEyeClick(fileId, e); + }} + tabIndex={-1} + aria-label={ + isViewedInViewer + ? t("fileSidebar.fileItem.closeViewer", "Close viewer") + : t("fileSidebar.fileItem.openInViewer", "Open in viewer") + } + > + + + + {(onDelete || + (canSaveToCloud && onSaveToCloud) || + (hasVersionHistory && onVersionHistory)) && ( + + + e.stopPropagation()} + tabIndex={-1} + aria-label={t( + "fileSidebar.fileItem.moreActions", + "More actions", + )} > - {t("fileSidebar.fileItem.versionHistory", "Version history")} - - )} - {canSaveToCloud && - onSaveToCloud && - (() => { - const uploadLabel = isUploadedToCloud - ? t( - "fileSidebar.fileItem.updateOnServer", - "Update on server", - ) - : t( - "fileSidebar.fileItem.uploadToServer", - "Upload to server", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {uploadLabel} - -
-
- ); - })()} - {onDelete && - (() => { - const deleteLabel = t( - "fileSidebar.fileItem.delete", - "Delete", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {deleteLabel} - -
-
- ); - })()} - -
- )} + + + + e.stopPropagation()}> + {hasVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(fileId); + }} + > + {t( + "fileSidebar.fileItem.versionHistory", + "Version history", + )} + + )} + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud + ? t( + "fileSidebar.fileItem.updateOnServer", + "Update on server", + ) + : t( + "fileSidebar.fileItem.uploadToServer", + "Upload to server", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onSaveToCloud(fileId); + }} + > + {uploadLabel} + +
+
+ ); + })()} + {onDelete && + (() => { + const deleteLabel = t( + "fileSidebar.fileItem.delete", + "Delete", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onDelete(fileId); + }} + > + {deleteLabel} + +
+
+ ); + })()} +
+
+ )} + {useRasterThumb && diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index a8ad41588f..e9cbb9b1b2 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -511,7 +511,8 @@ flex-direction: row; gap: 0.75rem; align-items: flex-start; - padding: 0.85rem 0.95rem; + --sui-btn-py: 1rem; + padding: 1rem 0.95rem; border: 1px solid var(--fullscreen-border-subtle-70); border-radius: 0.95rem; background: var(--fullscreen-bg-item); @@ -526,6 +527,11 @@ min-width: 0; } +.tool-panel__fullscreen-item .mantine-Button-label { + gap: 1rem; + align-items: flex-start; +} + .tool-panel__fullscreen-item:focus-visible { outline: 2px solid var(--fullscreen-accent-selected); outline-offset: 3px; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx index 5be808b492..cbc4b67163 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx @@ -1,4 +1,4 @@ -import { Stack } from "@mantine/core"; +import { SimpleGrid } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; @@ -8,106 +8,27 @@ interface CertificateFormatSettingsProps { disabled?: boolean; } +const FORMATS = ["PKCS12", "PFX", "PEM", "JKS"] as const; + const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false, }: CertificateFormatSettingsProps) => { return ( - -
- {/* First row - PKCS#12 and PFX */} -
- - -
- {/* Second row - PEM and JKS */} -
- - -
-
-
+ + {FORMATS.map((format) => ( + )} - + + + {t("compare.edited.label", "Edited PDF")} + + {renderSlot("comparison")} + = { ], }, size: { control: "inline-radio", options: ["sm", "md", "lg", "xl"] }, + p: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, + px: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, + py: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, justify: { control: "inline-radio", options: ["center", "start", "end", "between"], @@ -141,6 +153,34 @@ export const Accents: Story = { ), }; +/** Loading keeps the button's shape. A labelless loader collapses to an icon + square — unless `fullWidth`, which must always span its container (e.g. an + execute button whose label is briefly absent while files hydrate). */ +export const Loading: Story = { + render: (args) => ( +
+
+ ), +}; + +/** Disabled primary — dark mode keeps a muted accent instead of grey. */ +export const DisabledDark: Story = { + render: () => ( +
+
+
+
+ ), +}; + /** Real size differences. */ export const Sizes: Story = { render: (args) => ( @@ -152,15 +192,12 @@ export const Sizes: Story = { ), }; -/** - * `fontSize` scales the label text independently of `size` (which sets height). - * Unset inherits the `size`-derived default (md for a default button). - */ -export const FontSizes: Story = { +/** Padding override via `p` (both axes); `px`/`py` override a single axis. */ +export const Padding: Story = { render: (args) => ( - {(["xs", "sm", "md", "lg", "xl"] as const).map((fontSize) => ( - diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index 221ad0543a..ce78ded6fa 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -15,25 +15,29 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { {open && ( diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index 70752c714b..e1af68b768 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -61,12 +61,9 @@ gap: 0.375rem; } +/* Visual container only — layout (icon / text / arrow) is handled by the + shared Button's leftSection / label / rightSection, not overridden here. */ .portal-home__quick-row { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 0.625rem; - padding: 0.5rem 0.625rem; background: var(--color-bg-subtle); border: 1px solid var(--color-border-light); border-radius: var(--radius-md); @@ -81,6 +78,14 @@ border-color: var(--color-border); } +/* Let the label grow so the arrow (rightSection) is pushed to the far right, + and don't clip the stacked subtitle. */ +.portal-home__quick-row .mantine-Button-label { + flex: 1 1 auto; + justify-content: flex-start; + overflow: visible; +} + .portal-home__quick-icon { display: inline-flex; align-items: center; @@ -95,6 +100,7 @@ .portal-home__quick-text { display: flex; flex-direction: column; + align-items: flex-start; min-width: 0; } diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index b9a42fbab8..a1829d5476 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; +import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { HomeHero } from "@portal/components/HomeHero"; import { HomeGreeting } from "@portal/components/HomeGreeting"; import { RecentActivity } from "@portal/components/RecentActivity"; @@ -13,6 +13,37 @@ import "@portal/views/Home.css"; /* Quick actions card */ /* ──────────────────────────────────────────────────────────────────────── */ +/** Rows for the Quick Actions list. Each `view` navigates the portal. */ +const QUICK_ACTIONS: Array<{ + key: string; + glyph: string; + bg: string; + fg: string; + view: ViewId; +}> = [ + { + key: "buildPipeline", + glyph: "⌃", + bg: "var(--color-purple-light)", + fg: "var(--color-purple)", + view: "pipelines", + }, + { + key: "connectSource", + glyph: "⇢", + bg: "var(--color-green-light)", + fg: "var(--color-green-dark)", + view: "sources", + }, + { + key: "issueApiKey", + glyph: "⚙", + bg: "var(--color-amber-light)", + fg: "var(--color-amber-dark)", + view: "infrastructure", + }, +]; + function QuickActions() { const { t } = useTranslation(); const { setActiveView } = useView(); @@ -27,78 +58,40 @@ function QuickActions() {
- - - + + + {t(`portal.home.quickActions.${action.key}.title`)} + + {t(`portal.home.quickActions.${action.key}.blurb`)} + + + ))}
); diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index f0a2f55da9..bca7406359 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -205,11 +205,8 @@ } .portal-infra__key-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; width: 100%; + --sui-btn-py: 0.875rem; /* 14px */ padding: 0.875rem 1.125rem; background: transparent; text-align: left; diff --git a/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx b/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx index f6ad17ec7e..490aea923c 100644 --- a/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx +++ b/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { Button } from "@app/ui/Button"; +import { CarouselDots } from "@app/ui/CarouselDots"; import bgDefault from "@app/assets/login/LoginBackgroundPanel.png"; export type ImageSlide = { @@ -196,42 +196,21 @@ function LoginRightCarousel({ ))} {/* Dot navigation */} -
- {Array.from({ length: totalSlides }).map((_, i) => ( -
+ /> ); } diff --git a/frontend/editor/src/proprietary/auth/ui/auth.css b/frontend/editor/src/proprietary/auth/ui/auth.css index 249d6cdda5..31f1eb480a 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth.css +++ b/frontend/editor/src/proprietary/auth/ui/auth.css @@ -172,6 +172,19 @@ align-items: center; } +/* These OAuth buttons are shared