From 7621aa4828f3489eafcfa1c2458b589a4611d928 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Fri, 30 Oct 2026 16:44:15 -0300 Subject: [PATCH 01/14] Use Tailwind's slate greys instead of neutral Every grey token in the default theme moves to the matching slate step, in light and dark. The three greys that sat between palette steps keep their lightness and take slate's tint from the steps either side, so contrast does not shift. The onboarding illustration and the component preview's neutral-* classes become slate-* to match. Skins set their own colours and are unchanged. --- app/src/components/component-preview.tsx | 2 +- .../onboarding/laptop-illustration.tsx | 8 +- app/src/theme.css | 108 +++++++++--------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/app/src/components/component-preview.tsx b/app/src/components/component-preview.tsx index 57d991c949..fad1047c31 100644 --- a/app/src/components/component-preview.tsx +++ b/app/src/components/component-preview.tsx @@ -36,7 +36,7 @@ function PreviewNotice({ children }: { children: ReactNode }) { return (
{/* The artwork stays pale in both themes, so this text must stay dark. */} -

{children}

+

{children}

); } diff --git a/app/src/components/onboarding/laptop-illustration.tsx b/app/src/components/onboarding/laptop-illustration.tsx index cd648a6e94..5ce12fac53 100644 --- a/app/src/components/onboarding/laptop-illustration.tsx +++ b/app/src/components/onboarding/laptop-illustration.tsx @@ -18,12 +18,12 @@ export function LaptopIllustration() {
-
+
-
-
+
+
); diff --git a/app/src/theme.css b/app/src/theme.css index 8e30caa8b0..21b1ca5df9 100644 --- a/app/src/theme.css +++ b/app/src/theme.css @@ -2,75 +2,75 @@ :root { color-scheme: light; - --background: oklch(0.985 0 0); - --foreground: oklch(0.145 0 0); + --background: oklch(0.984 0.003 247.858); + --foreground: oklch(0.129 0.042 264.695); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.129 0.042 264.695); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.96 0 0); - --muted-foreground: oklch(0.5 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.96 0.009 251); + --muted-foreground: oklch(0.5 0.045 257.35); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); --destructive: oklch(0.577 0.245 27.325); --success: oklch(0.6 0.118 184.704); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.869 0.022 252.894); + --chart-2: oklch(0.554 0.046 257.417); + --chart-3: oklch(0.446 0.043 257.281); + --chart-4: oklch(0.372 0.044 257.287); + --chart-5: oklch(0.279 0.041 260.031); --radius: 0.5rem; - --sidebar: oklch(0.97 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.5 0 0); - --sidebar-accent: oklch(0.95 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: oklch(0.968 0.007 247.896); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.5 0.045 257.35); + --sidebar-accent: oklch(0.95 0.01 252); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); } .dark { color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); --destructive: oklch(0.704 0.191 22.216); --success: oklch(0.704 0.14 182.503); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); + --ring: oklch(0.554 0.046 257.417); + --chart-1: oklch(0.869 0.022 252.894); + --chart-2: oklch(0.554 0.046 257.417); + --chart-3: oklch(0.446 0.043 257.281); + --chart-4: oklch(0.372 0.044 257.287); + --chart-5: oklch(0.279 0.041 260.031); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar-ring: oklch(0.554 0.046 257.417); } * { From aeb0e2e550bb137183cd8a4d71ab8924ff0c691c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Fri, 30 Oct 2026 16:44:31 -0300 Subject: [PATCH 02/14] Put each page in an inset panel: darker frame, light panel, white cards The sidebar and the window's edge now share one tint, and the page sits in a panel inset 8px with rounded corners and a hairline, using the sidebar's own inset variant. Light mode runs darkest to lightest the way Linear does: the frame, a slate-50 panel, then white cards on it; dark mode was already in that order once the frame became its darkest surface. Admin and settings now scroll inside the panel like the app does, so its corners stay on screen; the audit table and the playground fill the panel instead of the window. Fields fill with the card's white so they read as fields on a card or the panel, and row icon tiles take the panel's grey with a hairline so they still show on a white card. --- app/src/components/admin/admin-sidebar.tsx | 2 +- app/src/components/app-sidebar/app-sidebar.tsx | 2 +- app/src/components/layout/page-parts.tsx | 17 +++++------------ app/src/components/layout/page-shell.tsx | 6 +++--- .../components/settings/settings-sidebar.tsx | 2 +- app/src/components/ui/input-group.tsx | 2 +- app/src/components/ui/input.tsx | 2 +- app/src/components/ui/item.tsx | 2 +- app/src/components/ui/select.tsx | 2 +- app/src/components/ui/sidebar.tsx | 2 +- app/src/components/ui/textarea.tsx | 2 +- app/src/routes/_authed/_app.tsx | 16 ++++++++++++---- app/src/routes/_authed/admin/playground.tsx | 2 +- app/src/routes/_authed/admin/route.tsx | 8 +++++--- app/src/routes/_authed/settings/route.tsx | 8 +++++--- app/src/theme.css | 12 ++++++++---- app/tests/audit-table.test.tsx | 7 ++++--- 17 files changed, 52 insertions(+), 42 deletions(-) diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx index 2b4a7d5d6c..0927359c68 100644 --- a/app/src/components/admin/admin-sidebar.tsx +++ b/app/src/components/admin/admin-sidebar.tsx @@ -130,7 +130,7 @@ export function AdminSidebar({ ...props }: React.ComponentProps) { return ( - + {/* * Pinned to the same height as the app sidebar's header. Left to itself this one is 60px * against the app's 45px — `p-2` around a `size="lg"` button rather than a fixed height — so diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 27fe657b18..e7e8e99b59 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -220,7 +220,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const navigation = navigationFor(currentUser?.role); return ( - + diff --git a/app/src/components/layout/page-parts.tsx b/app/src/components/layout/page-parts.tsx index 7fa01f8dba..492f7491a0 100644 --- a/app/src/components/layout/page-parts.tsx +++ b/app/src/components/layout/page-parts.tsx @@ -40,20 +40,13 @@ export function PageColumn({ return ( <> {/* - * The scroller, and it has to live here rather than in either shell, because the two shells - * this frame is used under disagree about who scrolls. - * - * Under `_authed` — admin, settings — the document scrolls, so a page taller than the window - * has always just worked. Under `_authed/_app` it does not: that shell is `h-svh - * overflow-hidden` on purpose ("one viewport, never scrolls: panes scroll inside it") and its - * `main` is `overflow-hidden` too, so a PageShell taller than the window was silently CLIPPED - * — 246px of Skills sat below the fold with no way to reach it, by keyboard or otherwise. + * The scroller. Every shell — app, admin, settings — is exactly the window's height and holds + * the page in an inset panel that never grows past it, so a page taller than the window + * scrolls here, inside the panel, and the panel's rounded corners stay on screen. * * `min-h-0` is the load-bearing half of `min-h-0 flex-1`: a flex item's default `min-height: - * auto` refuses to shrink below its content, so `flex-1` alone would grow this past the pane - * and clip exactly as before. Under `_authed` both are inert — the parent is not a flex - * container — and `overflow-y-auto` on an auto-height element shows no scrollbar, so those - * eighteen screens are unaffected. + * auto` refuses to shrink below its content, so `flex-1` alone would grow this past the panel + * and the overflow would be clipped instead of scrolled. * * Separate from the centred column below so the scrollbar rides the pane's edge instead of * appearing inside the measure with content either side of it. diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 073ed9e42e..73c4c01727 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -83,10 +83,10 @@ export function PageShell({ if (width === "full") { return ( /* - * One viewport, never scrolls. `min-w-0` lets a table wider than the pane scroll inside its own - * container instead of pushing the page sideways. + * Exactly the panel's height, never scrolls. `min-w-0` lets a table wider than the pane scroll + * inside its own container instead of pushing the page sideways. */ -
+
{barRow}
{header} diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx index 53e5d9bec3..d0694cca64 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -84,7 +84,7 @@ export function SettingsSidebar({ ...props }: React.ComponentProps) { return ( - + {/* Matched to the app sidebar's header, as Admin's is. See admin-sidebar.tsx. */} diff --git a/app/src/components/ui/input-group.tsx b/app/src/components/ui/input-group.tsx index c5af85eeaa..429c2230a1 100644 --- a/app/src/components/ui/input-group.tsx +++ b/app/src/components/ui/input-group.tsx @@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-lg border border-input bg-card transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", className, )} {...props} diff --git a/app/src/components/ui/input.tsx b/app/src/components/ui/input.tsx index 11dff01045..2a5363d8f0 100644 --- a/app/src/components/ui/input.tsx +++ b/app/src/components/ui/input.tsx @@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + "h-9 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className, )} {...props} diff --git a/app/src/components/ui/item.tsx b/app/src/components/ui/item.tsx index 0f68d1c1bf..51b336baca 100644 --- a/app/src/components/ui/item.tsx +++ b/app/src/components/ui/item.tsx @@ -90,7 +90,7 @@ const itemMediaVariants = cva( variant: { default: "bg-transparent", // A 32px tile behind a 16px glyph, so a column of rows reads as a column of marks. - icon: "size-8 rounded-md bg-muted text-muted-foreground group-data-[size=xs]/item:size-6 [&_svg:not([class*='size-'])]:size-4", + icon: "size-8 rounded-md bg-background text-muted-foreground ring-1 ring-border group-data-[size=xs]/item:size-6 [&_svg:not([class*='size-'])]:size-4", image: "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover", }, diff --git a/app/src/components/ui/select.tsx b/app/src/components/ui/select.tsx index f854d1b542..e101c0fb6f 100644 --- a/app/src/components/ui/select.tsx +++ b/app/src/components/ui/select.tsx @@ -43,7 +43,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx index b5148fffe0..89672ca877 100644 --- a/app/src/components/ui/sidebar.tsx +++ b/app/src/components/ui/sidebar.tsx @@ -325,7 +325,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
) {