detached 12c9f08

fix: replace native color picker and confirm dialog with custom components (fixes #12)

commit
12c9f08c223aa1b1e7ad75a636da05faab122f3c
author
brice · · 2025-12-23 20:15 UTC
committer
brice · · 2025-12-24 16:11 UTC
parent
f8b9839
signature
unsigned
17 files · +381 -19
M webui/package-lock.json +32 -0
@@ -26,6 +26,7 @@
2626 "eslint": "^8.57.0",
2727 "postcss": "^8.4.47",
2828 "svelte": "^5.0.0",
29+ "svelte-awesome-color-picker": "^4.1.0",
2930 "svelte-check": "^4.0.0",
3031 "tailwind-variants": "^3.2.2",
3132 "tailwindcss": "^4.0.0",
@@ -2284,6 +2285,13 @@
22842285 "dev": true,
22852286 "license": "MIT"
22862287 },
2288+ "node_modules/colord": {
2289+ "version": "2.9.3",
2290+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
2291+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
2292+ "dev": true,
2293+ "license": "MIT"
2294+ },
22872295 "node_modules/concat-map": {
22882296 "version": "0.0.1",
22892297 "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4078,6 +4086,30 @@
40784086 "node": ">=18"
40794087 }
40804088 },
4089+ "node_modules/svelte-awesome-color-picker": {
4090+ "version": "4.1.0",
4091+ "resolved": "https://registry.npmjs.org/svelte-awesome-color-picker/-/svelte-awesome-color-picker-4.1.0.tgz",
4092+ "integrity": "sha512-afiSB3eTBlqu96f4+rjBvqG3eCaLwuneNYHe587Wr4Ien6yQWeztGZunPT0FmiI7wFFBVNUlJQLYutII8LfQUg==",
4093+ "dev": true,
4094+ "license": "MIT",
4095+ "dependencies": {
4096+ "colord": "^2.9.3",
4097+ "svelte-awesome-slider": "2.0.0"
4098+ },
4099+ "peerDependencies": {
4100+ "svelte": "^5.0.0"
4101+ }
4102+ },
4103+ "node_modules/svelte-awesome-slider": {
4104+ "version": "2.0.0",
4105+ "resolved": "https://registry.npmjs.org/svelte-awesome-slider/-/svelte-awesome-slider-2.0.0.tgz",
4106+ "integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==",
4107+ "dev": true,
4108+ "license": "MIT",
4109+ "peerDependencies": {
4110+ "svelte": "^5.0.0"
4111+ }
4112+ },
40814113 "node_modules/svelte-check": {
40824114 "version": "4.3.4",
40834115 "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz",
@@ -26,6 +26,7 @@
26 "eslint": "^8.57.0",26 "eslint": "^8.57.0",
27 "postcss": "^8.4.47",27 "postcss": "^8.4.47",
28 "svelte": "^5.0.0",28 "svelte": "^5.0.0",
29+ "svelte-awesome-color-picker": "^4.1.0",
29 "svelte-check": "^4.0.0",30 "svelte-check": "^4.0.0",
30 "tailwind-variants": "^3.2.2",31 "tailwind-variants": "^3.2.2",
31 "tailwindcss": "^4.0.0",32 "tailwindcss": "^4.0.0",
@@ -2284,6 +2285,13 @@
2284 "dev": true,2285 "dev": true,
2285 "license": "MIT"2286 "license": "MIT"
2286 },2287 },
2288+ "node_modules/colord": {
2289+ "version": "2.9.3",
2290+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
2291+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
2292+ "dev": true,
2293+ "license": "MIT"
2294+ },
2287 "node_modules/concat-map": {2295 "node_modules/concat-map": {
2288 "version": "0.0.1",2296 "version": "0.0.1",
2289 "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",2297 "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4078,6 +4086,30 @@
4078 "node": ">=18"4086 "node": ">=18"
4079 }4087 }
4080 },4088 },
4089+ "node_modules/svelte-awesome-color-picker": {
4090+ "version": "4.1.0",
4091+ "resolved": "https://registry.npmjs.org/svelte-awesome-color-picker/-/svelte-awesome-color-picker-4.1.0.tgz",
4092+ "integrity": "sha512-afiSB3eTBlqu96f4+rjBvqG3eCaLwuneNYHe587Wr4Ien6yQWeztGZunPT0FmiI7wFFBVNUlJQLYutII8LfQUg==",
4093+ "dev": true,
4094+ "license": "MIT",
4095+ "dependencies": {
4096+ "colord": "^2.9.3",
4097+ "svelte-awesome-slider": "2.0.0"
4098+ },
4099+ "peerDependencies": {
4100+ "svelte": "^5.0.0"
4101+ }
4102+ },
4103+ "node_modules/svelte-awesome-slider": {
4104+ "version": "2.0.0",
4105+ "resolved": "https://registry.npmjs.org/svelte-awesome-slider/-/svelte-awesome-slider-2.0.0.tgz",
4106+ "integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==",
4107+ "dev": true,
4108+ "license": "MIT",
4109+ "peerDependencies": {
4110+ "svelte": "^5.0.0"
4111+ }
4112+ },
4081 "node_modules/svelte-check": {4113 "node_modules/svelte-check": {
4082 "version": "4.3.4",4114 "version": "4.3.4",
4083 "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz",4115 "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz",
M webui/package.json +1 -0
@@ -29,6 +29,7 @@
2929 "eslint": "^8.57.0",
3030 "postcss": "^8.4.47",
3131 "svelte": "^5.0.0",
32+ "svelte-awesome-color-picker": "^4.1.0",
3233 "svelte-check": "^4.0.0",
3334 "tailwind-variants": "^3.2.2",
3435 "tailwindcss": "^4.0.0",
@@ -29,6 +29,7 @@
29 "eslint": "^8.57.0",29 "eslint": "^8.57.0",
30 "postcss": "^8.4.47",30 "postcss": "^8.4.47",
31 "svelte": "^5.0.0",31 "svelte": "^5.0.0",
32+ "svelte-awesome-color-picker": "^4.1.0",
32 "svelte-check": "^4.0.0",33 "svelte-check": "^4.0.0",
33 "tailwind-variants": "^3.2.2",34 "tailwind-variants": "^3.2.2",
34 "tailwindcss": "^4.0.0",35 "tailwindcss": "^4.0.0",
A webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +17 -0
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.ActionProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Action
14+ bind:ref
15+ class={cn(buttonVariants(), className)}
16+ {...restProps}
17+/>
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.ActionProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Action
14+ bind:ref
15+ class={cn(buttonVariants(), className)}
16+ {...restProps}
17+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +17 -0
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.CancelProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Cancel
14+ bind:ref
15+ class={cn(buttonVariants({ variant: "outline" }), className)}
16+ {...restProps}
17+/>
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.CancelProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Cancel
14+ bind:ref
15+ class={cn(buttonVariants({ variant: "outline" }), className)}
16+ {...restProps}
17+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +24 -0
@@ -0,0 +1,24 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+ import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5+ import AlertDialogPortal from "./alert-dialog-portal.svelte";
6+
7+ let {
8+ ref = $bindable(null),
9+ class: className,
10+ ...restProps
11+ }: AlertDialogPrimitive.ContentProps = $props();
12+</script>
13+
14+<AlertDialogPortal>
15+ <AlertDialogOverlay />
16+ <AlertDialogPrimitive.Content
17+ bind:ref
18+ class={cn(
19+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20+ className
21+ )}
22+ {...restProps}
23+ />
24+</AlertDialogPortal>
@@ -0,0 +1,24 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+ import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5+ import AlertDialogPortal from "./alert-dialog-portal.svelte";
6+
7+ let {
8+ ref = $bindable(null),
9+ class: className,
10+ ...restProps
11+ }: AlertDialogPrimitive.ContentProps = $props();
12+</script>
13+
14+<AlertDialogPortal>
15+ <AlertDialogOverlay />
16+ <AlertDialogPrimitive.Content
17+ bind:ref
18+ class={cn(
19+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20+ className
21+ )}
22+ {...restProps}
23+ />
24+</AlertDialogPortal>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.DescriptionProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Description
13+ bind:ref
14+ class={cn("text-sm text-muted-foreground", className)}
15+ {...restProps}
16+/>
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.DescriptionProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Description
13+ bind:ref
14+ class={cn("text-sm text-muted-foreground", className)}
15+ {...restProps}
16+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +19 -0
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +19 -0
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +19 -0
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.OverlayProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Overlay
13+ bind:ref
14+ class={cn(
15+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16+ className
17+ )}
18+ {...restProps}
19+/>
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.OverlayProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Overlay
13+ bind:ref
14+ class={cn(
15+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16+ className
17+ )}
18+ {...restProps}
19+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
@@ -0,0 +1,7 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5+</script>
6+
7+<AlertDialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5+</script>
6+
7+<AlertDialogPrimitive.Portal {...restProps} />
A webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TitleProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Title
13+ bind:ref
14+ class={cn("text-lg font-semibold", className)}
15+ {...restProps}
16+/>
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TitleProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Title
13+ bind:ref
14+ class={cn("text-lg font-semibold", className)}
15+ {...restProps}
16+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +16 -0
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TriggerProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Trigger
13+ bind:ref
14+ class={cn(className)}
15+ {...restProps}
16+/>
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TriggerProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Trigger
13+ bind:ref
14+ class={cn(className)}
15+ {...restProps}
16+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +10 -0
@@ -0,0 +1,10 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let {
5+ open = $bindable(false),
6+ ...restProps
7+ }: AlertDialogPrimitive.RootProps = $props();
8+</script>
9+
10+<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,10 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let {
5+ open = $bindable(false),
6+ ...restProps
7+ }: AlertDialogPrimitive.RootProps = $props();
8+</script>
9+
10+<AlertDialogPrimitive.Root bind:open {...restProps} />
A webui/src/lib/components/ui/alert-dialog/index.ts +37 -0
@@ -0,0 +1,37 @@
1+import Root from "./alert-dialog.svelte";
2+import Trigger from "./alert-dialog-trigger.svelte";
3+import Portal from "./alert-dialog-portal.svelte";
4+import Overlay from "./alert-dialog-overlay.svelte";
5+import Content from "./alert-dialog-content.svelte";
6+import Header from "./alert-dialog-header.svelte";
7+import Footer from "./alert-dialog-footer.svelte";
8+import Title from "./alert-dialog-title.svelte";
9+import Description from "./alert-dialog-description.svelte";
10+import Action from "./alert-dialog-action.svelte";
11+import Cancel from "./alert-dialog-cancel.svelte";
12+
13+export {
14+ Root,
15+ Trigger,
16+ Portal,
17+ Overlay,
18+ Content,
19+ Header,
20+ Footer,
21+ Title,
22+ Description,
23+ Action,
24+ Cancel,
25+ //
26+ Root as AlertDialog,
27+ Trigger as AlertDialogTrigger,
28+ Portal as AlertDialogPortal,
29+ Overlay as AlertDialogOverlay,
30+ Content as AlertDialogContent,
31+ Header as AlertDialogHeader,
32+ Footer as AlertDialogFooter,
33+ Title as AlertDialogTitle,
34+ Description as AlertDialogDescription,
35+ Action as AlertDialogAction,
36+ Cancel as AlertDialogCancel,
37+};
@@ -0,0 +1,37 @@
1+import Root from "./alert-dialog.svelte";
2+import Trigger from "./alert-dialog-trigger.svelte";
3+import Portal from "./alert-dialog-portal.svelte";
4+import Overlay from "./alert-dialog-overlay.svelte";
5+import Content from "./alert-dialog-content.svelte";
6+import Header from "./alert-dialog-header.svelte";
7+import Footer from "./alert-dialog-footer.svelte";
8+import Title from "./alert-dialog-title.svelte";
9+import Description from "./alert-dialog-description.svelte";
10+import Action from "./alert-dialog-action.svelte";
11+import Cancel from "./alert-dialog-cancel.svelte";
12+
13+export {
14+ Root,
15+ Trigger,
16+ Portal,
17+ Overlay,
18+ Content,
19+ Header,
20+ Footer,
21+ Title,
22+ Description,
23+ Action,
24+ Cancel,
25+ //
26+ Root as AlertDialog,
27+ Trigger as AlertDialogTrigger,
28+ Portal as AlertDialogPortal,
29+ Overlay as AlertDialogOverlay,
30+ Content as AlertDialogContent,
31+ Header as AlertDialogHeader,
32+ Footer as AlertDialogFooter,
33+ Title as AlertDialogTitle,
34+ Description as AlertDialogDescription,
35+ Action as AlertDialogAction,
36+ Cancel as AlertDialogCancel,
37+};
A webui/src/lib/components/ui/color-picker/color-picker.svelte +89 -0
@@ -0,0 +1,89 @@
1+<script lang="ts" module>
2+ export type ColorPickerProps = {
3+ value: string;
4+ onValueChange?: (value: string) => void;
5+ id?: string;
6+ class?: string;
7+ };
8+</script>
9+
10+<script lang="ts">
11+ import ColorPicker from "svelte-awesome-color-picker";
12+ import { Popover } from "bits-ui";
13+ import { cn } from "$lib/utils.js";
14+
15+ let {
16+ class: className,
17+ value = $bindable("#000000"),
18+ onValueChange,
19+ id,
20+ }: ColorPickerProps = $props();
21+
22+ let open = $state(false);
23+
24+ // svelte-awesome-color-picker uses hex binding
25+ let hex = $state(value);
26+
27+ // Sync hex when value changes externally
28+ $effect(() => {
29+ hex = value;
30+ });
31+
32+ function handleInput(event: { hex: string | null }) {
33+ if (event.hex) {
34+ hex = event.hex;
35+ value = event.hex;
36+ onValueChange?.(event.hex);
37+ }
38+ }
39+</script>
40+
41+<Popover.Root bind:open>
42+ <Popover.Trigger
43+ {id}
44+ class={cn(
45+ "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
46+ className
47+ )}
48+ >
49+ <span
50+ class="block h-6 w-8 border border-border/50"
51+ style="background-color: {value};"
52+ aria-hidden="true"
53+ ></span>
54+ <span class="sr-only">Choose color: {value}</span>
55+ </Popover.Trigger>
56+
57+ <Popover.Portal>
58+ <Popover.Content
59+ class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
60+ sideOffset={4}
61+ >
62+ <ColorPicker
63+ bind:hex
64+ onInput={handleInput}
65+ isDialog={false}
66+ />
67+ <!-- Hex value display -->
68+ <div class="mt-2 flex items-center gap-2">
69+ <span
70+ class="block h-6 w-6 shrink-0 border border-border"
71+ style="background-color: {hex};"
72+ aria-hidden="true"
73+ ></span>
74+ <span class="font-mono text-sm text-foreground">{hex}</span>
75+ </div>
76+ </Popover.Content>
77+ </Popover.Portal>
78+</Popover.Root>
79+
80+<style>
81+ /* Override svelte-awesome-color-picker styles to match our theme */
82+ :global(.color-picker-popover) {
83+ --cp-bg-color: var(--background);
84+ --cp-border-color: var(--border);
85+ --cp-text-color: var(--foreground);
86+ --cp-input-color: var(--background);
87+ --cp-button-hover-color: var(--accent);
88+ }
89+</style>
@@ -0,0 +1,89 @@
1+<script lang="ts" module>
2+ export type ColorPickerProps = {
3+ value: string;
4+ onValueChange?: (value: string) => void;
5+ id?: string;
6+ class?: string;
7+ };
8+</script>
9+
10+<script lang="ts">
11+ import ColorPicker from "svelte-awesome-color-picker";
12+ import { Popover } from "bits-ui";
13+ import { cn } from "$lib/utils.js";
14+
15+ let {
16+ class: className,
17+ value = $bindable("#000000"),
18+ onValueChange,
19+ id,
20+ }: ColorPickerProps = $props();
21+
22+ let open = $state(false);
23+
24+ // svelte-awesome-color-picker uses hex binding
25+ let hex = $state(value);
26+
27+ // Sync hex when value changes externally
28+ $effect(() => {
29+ hex = value;
30+ });
31+
32+ function handleInput(event: { hex: string | null }) {
33+ if (event.hex) {
34+ hex = event.hex;
35+ value = event.hex;
36+ onValueChange?.(event.hex);
37+ }
38+ }
39+</script>
40+
41+<Popover.Root bind:open>
42+ <Popover.Trigger
43+ {id}
44+ class={cn(
45+ "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
46+ className
47+ )}
48+ >
49+ <span
50+ class="block h-6 w-8 border border-border/50"
51+ style="background-color: {value};"
52+ aria-hidden="true"
53+ ></span>
54+ <span class="sr-only">Choose color: {value}</span>
55+ </Popover.Trigger>
56+
57+ <Popover.Portal>
58+ <Popover.Content
59+ class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
60+ sideOffset={4}
61+ >
62+ <ColorPicker
63+ bind:hex
64+ onInput={handleInput}
65+ isDialog={false}
66+ />
67+ <!-- Hex value display -->
68+ <div class="mt-2 flex items-center gap-2">
69+ <span
70+ class="block h-6 w-6 shrink-0 border border-border"
71+ style="background-color: {hex};"
72+ aria-hidden="true"
73+ ></span>
74+ <span class="font-mono text-sm text-foreground">{hex}</span>
75+ </div>
76+ </Popover.Content>
77+ </Popover.Portal>
78+</Popover.Root>
79+
80+<style>
81+ /* Override svelte-awesome-color-picker styles to match our theme */
82+ :global(.color-picker-popover) {
83+ --cp-bg-color: var(--background);
84+ --cp-border-color: var(--border);
85+ --cp-text-color: var(--foreground);
86+ --cp-input-color: var(--background);
87+ --cp-button-hover-color: var(--accent);
88+ }
89+</style>
A webui/src/lib/components/ui/color-picker/index.ts +8 -0
@@ -0,0 +1,8 @@
1+import Root, { type ColorPickerProps } from "./color-picker.svelte";
2+
3+export {
4+ Root,
5+ Root as ColorPicker,
6+ type ColorPickerProps,
7+ type ColorPickerProps as Props,
8+};
@@ -0,0 +1,8 @@
1+import Root, { type ColorPickerProps } from "./color-picker.svelte";
2+
3+export {
4+ Root,
5+ Root as ColorPicker,
6+ type ColorPickerProps,
7+ type ColorPickerProps as Props,
8+};
M webui/src/pages/ConfigPage.svelte +34 -19
@@ -2,7 +2,9 @@
22 import { onMount } from "svelte";
33 import ConfigShell from "./config/ConfigShell.svelte";
44 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
56 import { Button } from "$lib/components/ui/button";
7+ import { ColorPicker } from "$lib/components/ui/color-picker";
68 import { Input } from "$lib/components/ui/input";
79 import { Label } from "$lib/components/ui/label";
810 import { Spinner } from "$lib/components/ui/spinner";
@@ -84,13 +86,10 @@
8486 loadConfig();
8587 }
8688
87- async function resetToDefaults() {
88- saveSuccess = false;
89- const confirmed = window.confirm(
90- "Reset this page to default settings? (You still need to click Save to persist.)",
91- );
92- if (!confirmed) return;
89+ let resetDialogOpen = $state(false);
9390
91+ async function doResetToDefaults() {
92+ saveSuccess = false;
9493 try {
9594 const response = await fetch("/api/config/default");
9695 if (!response.ok) throw new Error("Failed to fetch default config");
@@ -98,6 +97,7 @@
9897 } catch (e: any) {
9998 console.error("[config] reset defaults failed", e);
10099 }
100+ resetDialogOpen = false;
101101 }
102102
103103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
@@ -305,15 +305,12 @@
305305 <div class="space-y-2">
306306 <Label for={`light_${field.key}`}>{field.label}</Label>
307307 <div class="flex items-center gap-3">
308- <input
308+ <ColorPicker
309309 id={`light_${field.key}`}
310- type="color"
311310 value={config.appearance.light_palette[field.key]}
312- oninput={(e) => {
313- const v = (e.currentTarget as HTMLInputElement).value;
311+ onValueChange={(v) => {
314312 config!.appearance.light_palette[field.key] = v;
315313 }}
316- class="h-10 w-12 border border-border bg-background"
317314 />
318315 <Input bind:value={config.appearance.light_palette[field.key]} />
319316 </div>
@@ -334,15 +331,12 @@
334331 <div class="space-y-2">
335332 <Label for={`dark_${field.key}`}>{field.label}</Label>
336333 <div class="flex items-center gap-3">
337- <input
334+ <ColorPicker
338335 id={`dark_${field.key}`}
339- type="color"
340336 value={config.appearance.dark_palette[field.key]}
341- oninput={(e) => {
342- const v = (e.currentTarget as HTMLInputElement).value;
337+ onValueChange={(v) => {
343338 config!.appearance.dark_palette[field.key] = v;
344339 }}
345- class="h-10 w-12 border border-border bg-background"
346340 />
347341 <Input bind:value={config.appearance.dark_palette[field.key]} />
348342 </div>
@@ -394,9 +388,30 @@
394388 {/if}
395389 </div>
396390 <div class="flex items-center gap-3">
397- <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button">
398- Reset Defaults
399- </Button>
391+ <AlertDialog.Root bind:open={resetDialogOpen}>
392+ <AlertDialog.Trigger disabled={saving}>
393+ {#snippet child({ props })}
394+ <Button variant="outline" {...props} type="button">
395+ Reset Defaults
396+ </Button>
397+ {/snippet}
398+ </AlertDialog.Trigger>
399+ <AlertDialog.Content>
400+ <AlertDialog.Header>
401+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
402+ <AlertDialog.Description>
403+ This will reset all settings on this page to their default values.
404+ You will still need to click Save to persist the changes.
405+ </AlertDialog.Description>
406+ </AlertDialog.Header>
407+ <AlertDialog.Footer>
408+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
409+ <AlertDialog.Action onclick={doResetToDefaults}>
410+ Reset Defaults
411+ </AlertDialog.Action>
412+ </AlertDialog.Footer>
413+ </AlertDialog.Content>
414+ </AlertDialog.Root>
400415 <Button onclick={saveConfig} disabled={saving} type="button">
401416 {saving ? "Saving…" : "Save"}
402417 </Button>
@@ -2,7 +2,9 @@
2 import { onMount } from "svelte";2 import { onMount } from "svelte";
3 import ConfigShell from "./config/ConfigShell.svelte";3 import ConfigShell from "./config/ConfigShell.svelte";
4 import ShortcutsTable from "./config/ShortcutsTable.svelte";4 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
5 import { Button } from "$lib/components/ui/button";6 import { Button } from "$lib/components/ui/button";
7+ import { ColorPicker } from "$lib/components/ui/color-picker";
6 import { Input } from "$lib/components/ui/input";8 import { Input } from "$lib/components/ui/input";
7 import { Label } from "$lib/components/ui/label";9 import { Label } from "$lib/components/ui/label";
8 import { Spinner } from "$lib/components/ui/spinner";10 import { Spinner } from "$lib/components/ui/spinner";
@@ -84,13 +86,10 @@
84 loadConfig();86 loadConfig();
85 }87 }
86 88
87- async function resetToDefaults() {89+ let resetDialogOpen = $state(false);
88- saveSuccess = false;
89- const confirmed = window.confirm(
90- "Reset this page to default settings? (You still need to click Save to persist.)",
91- );
92- if (!confirmed) return;
93 90
91+ async function doResetToDefaults() {
92+ saveSuccess = false;
94 try {93 try {
95 const response = await fetch("/api/config/default");94 const response = await fetch("/api/config/default");
96 if (!response.ok) throw new Error("Failed to fetch default config");95 if (!response.ok) throw new Error("Failed to fetch default config");
@@ -98,6 +97,7 @@
98 } catch (e: any) {97 } catch (e: any) {
99 console.error("[config] reset defaults failed", e);98 console.error("[config] reset defaults failed", e);
100 }99 }
100+ resetDialogOpen = false;
101 }101 }
102 102
103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
@@ -305,15 +305,12 @@
305 <div class="space-y-2">305 <div class="space-y-2">
306 <Label for={`light_${field.key}`}>{field.label}</Label>306 <Label for={`light_${field.key}`}>{field.label}</Label>
307 <div class="flex items-center gap-3">307 <div class="flex items-center gap-3">
308- <input308+ <ColorPicker
309 id={`light_${field.key}`}309 id={`light_${field.key}`}
310- type="color"
311 value={config.appearance.light_palette[field.key]}310 value={config.appearance.light_palette[field.key]}
312- oninput={(e) => {311+ onValueChange={(v) => {
313- const v = (e.currentTarget as HTMLInputElement).value;
314 config!.appearance.light_palette[field.key] = v;312 config!.appearance.light_palette[field.key] = v;
315 }}313 }}
316- class="h-10 w-12 border border-border bg-background"
317 />314 />
318 <Input bind:value={config.appearance.light_palette[field.key]} />315 <Input bind:value={config.appearance.light_palette[field.key]} />
319 </div>316 </div>
@@ -334,15 +331,12 @@
334 <div class="space-y-2">331 <div class="space-y-2">
335 <Label for={`dark_${field.key}`}>{field.label}</Label>332 <Label for={`dark_${field.key}`}>{field.label}</Label>
336 <div class="flex items-center gap-3">333 <div class="flex items-center gap-3">
337- <input334+ <ColorPicker
338 id={`dark_${field.key}`}335 id={`dark_${field.key}`}
339- type="color"
340 value={config.appearance.dark_palette[field.key]}336 value={config.appearance.dark_palette[field.key]}
341- oninput={(e) => {337+ onValueChange={(v) => {
342- const v = (e.currentTarget as HTMLInputElement).value;
343 config!.appearance.dark_palette[field.key] = v;338 config!.appearance.dark_palette[field.key] = v;
344 }}339 }}
345- class="h-10 w-12 border border-border bg-background"
346 />340 />
347 <Input bind:value={config.appearance.dark_palette[field.key]} />341 <Input bind:value={config.appearance.dark_palette[field.key]} />
348 </div>342 </div>
@@ -394,9 +388,30 @@
394 {/if}388 {/if}
395 </div>389 </div>
396 <div class="flex items-center gap-3">390 <div class="flex items-center gap-3">
397- <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button">391+ <AlertDialog.Root bind:open={resetDialogOpen}>
398- Reset Defaults392+ <AlertDialog.Trigger disabled={saving}>
399- </Button>393+ {#snippet child({ props })}
394+ <Button variant="outline" {...props} type="button">
395+ Reset Defaults
396+ </Button>
397+ {/snippet}
398+ </AlertDialog.Trigger>
399+ <AlertDialog.Content>
400+ <AlertDialog.Header>
401+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
402+ <AlertDialog.Description>
403+ This will reset all settings on this page to their default values.
404+ You will still need to click Save to persist the changes.
405+ </AlertDialog.Description>
406+ </AlertDialog.Header>
407+ <AlertDialog.Footer>
408+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
409+ <AlertDialog.Action onclick={doResetToDefaults}>
410+ Reset Defaults
411+ </AlertDialog.Action>
412+ </AlertDialog.Footer>
413+ </AlertDialog.Content>
414+ </AlertDialog.Root>
400 <Button onclick={saveConfig} disabled={saving} type="button">415 <Button onclick={saveConfig} disabled={saving} type="button">
401 {saving ? "Saving…" : "Save"}416 {saving ? "Saving…" : "Save"}
402 </Button>417 </Button>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close