Repository: sillsdev/languageforge-lexbox
Pull request #1575: Shadcn multi-select component in sandbox reactive editor
Author: login: myieye
Created: 2025-04-03T18:53:33Z | Merged: 2025-04-10T07:18:46Z

Resolves #1580 

- Popover for desktop
- Drawer for mobile
- Back button closes drawer (on desktop at least, I haven't tested mobile, which is where it should 😬)
  - I built this into the drawer component for mobile-sized screens
- Always a visible "Dismiss" button
- The "X" behaves differently on desktop and mobile
  - On mobile I think it's especially common for an X by the input to clear the input, so I went with that. But there's always a "Cancel" button
  - On Desktop the "X" closes the popup, BUT it dissappears when in "dirty" state. For 2 reasons: (1) so there aren't 2 close/dismiss buttons so close to each other and (2) I think it slightly helps communicate the fact that the popup is in a different state (it's non-dismissable: can only be closed via Cancel or Submit).

https://github.com/user-attachments/assets/d9a6d5e3-f804-4b5b-b6ca-38c3f94039b3

Updates:
- Badges/header in mobile drawer so the user has context:
![image](https://github.com/user-attachments/assets/3f2f66ca-7a19-4cca-9764-d4fc8d0ee24a)

- keyboard navigation
  - Ctrl + Space to toggle selection of an item
  - No tab navigation through checkboxes, because it's weird having 2 ways to navigate the command-item list
  - Ctrl + Enter submits the selection

- A sort strategy can be specified for the selected values

- Moved the submit button on desktop to inside the filter input, so there isn't a layout shift and it's nice and visible.
![image](https://github.com/user-attachments/assets/67c48b31-c2d0-44ab-860d-3707237d5e53)

- Readonly mode
![image](https://github.com/user-attachments/assets/e083beae-f118-42e1-942a-f1be67d9bda7)


Linked issue #1580: Shadcn Multi-select

Diff:
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 7829a64141..9d5afc20c0 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -428,8 +428,8 @@ importers:
         specifier: 'catalog:'
         version: 3.0.5(vitest@3.0.5)
       bits-ui:
-        specifier: ^1.3.10
-        version: 1.3.10(svelte@5.20.0)
+        specifier: 1.3.16
+        version: 1.3.16(svelte@5.20.0)
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
@@ -2940,8 +2940,8 @@ packages:
     resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
     engines: {node: '>=8'}
 
-  bits-ui@1.3.10:
-    resolution: {integrity: sha512-Mg6ENVlI/dwHX9hR1cSiAp5gqzwzowPbXPRW32RxFdUKakuL8qYyewwr446VC7UkZLEpNeoHn7HICcBscJhYdw==}
+  bits-ui@1.3.16:
+    resolution: {integrity: sha512-eBifcu68EspZ1n7mpiyCEjbNAaPoaWLuQZ4aw4lYFt4kJYsOXeAiwKS3+IXwDvYZ9NzBDSSrWbst2k6kKU+8SQ==}
     engines: {node: '>=18', pnpm: '>=8.7.0'}
     peerDependencies:
       svelte: ^5.11.0
@@ -9227,7 +9227,7 @@ snapshots:
 
   binary-extensions@2.3.0: {}
 
-  bits-ui@1.3.10(svelte@5.20.0):
+  bits-ui@1.3.16(svelte@5.20.0):
     dependencies:
       '@floating-ui/core': 1.6.9
       '@floating-ui/dom': 1.6.13
diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json
index 28c791091e..3c7b2d668a 100644
--- a/frontend/viewer/package.json
+++ b/frontend/viewer/package.json
@@ -48,7 +48,7 @@
     "@typescript-eslint/parser": "catalog:",
     "@vitest/ui": "catalog:",
     "autoprefixer": "^10.4.20",
-    "bits-ui": "^1.3.10",
+    "bits-ui": "1.3.16",
     "clsx": "^2.1.1",
     "eslint": "catalog:",
     "eslint-plugin-svelte": "catalog:",
diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss
index 3750ec79bf..e5229f96c2 100644
--- a/frontend/viewer/src/app.postcss
+++ b/frontend/viewer/src/app.postcss
@@ -86,20 +86,40 @@
 }
 
 @layer components {
+  /* shadcn grid start */
   .sm-form {
     grid-template-columns: fit-content(70px) 1fr 1fr;
   }
 
-  .xs-form {
-    grid-template-columns: fit-content(70px) 1fr;
+  .lg-form {
+    grid-template-columns: 180px fit-content(80px) 1fr;
   }
 
   .editor-grid {
     display: grid;
-    grid-template-columns: 170px fit-content(80px) 1fr;
-    @apply sm-form:sm-form;
-    @apply xs-form:xs-form;
+    grid-template-columns: fit-content(70px) 1fr;
+    @apply @lg:sm-form;
+    @apply @3xl:lg-form;
+  }
+
+  .field-root {
+    @apply grid grid-cols-subgrid col-span-full items-baseline;
+    &:not(:last-child) {
+      @apply mb-4;
+    }
+  }
+
+  .field-title {
+    @apply col-span-full ms-2 me-2 mb-2;
+    @apply @3xl:col-span-1;
+  }
+
+  .field-body {
+    @apply grid grid-cols-subgrid;
+    @apply col-span-full;
+    @apply @3xl:col-start-2 @3xl:col-end-4;
   }
+  /* shadcn grid end */
 
   .collapsible-col {
     overflow-x: hidden;
diff --git a/frontend/viewer/src/lib/components/field-editors/field-title.svelte b/frontend/viewer/src/lib/components/field-editors/field-title.svelte
new file mode 100644
index 0000000000..2efa2a22be
--- /dev/null
+++ b/frontend/viewer/src/lib/components/field-editors/field-title.svelte
@@ -0,0 +1,52 @@
+<script lang="ts">
+  import {t} from 'svelte-i18n-lingui';
+  import FieldHelpIcon from '../../entry-editor/FieldHelpIcon.svelte';
+  import {useCurrentView} from '$lib/views/view-service';
+
+  const {
+    liteName,
+    classicName,
+    helpId,
+  }: {
+    liteName: string;
+    classicName: string;
+    helpId: string | undefined;
+  } = $props();
+
+  const view = useCurrentView();
+
+  const { label, title } = $derived(
+    $view.type === 'fw-classic'
+      ? {
+          label: classicName,
+          title: $t`${liteName} (FieldWorks Lite)`,
+        }
+      : {
+          label: liteName,
+          title: $t`${classicName} (FieldWorks)`,
+        },
+  );
+
+  // kind of crazy, but I don't think Svelte's white-space handling let's us use &nbsp; between the label and help icon
+  const { lastWord, otherWords } = $derived.by(() => {
+    const words = label.split(' ');
+    return {
+      lastWord: words.pop(),
+      otherWords: words.join(' '),
+    };
+  })
+</script>
+
+<div class="field-title">
+  <span class="inline-flex items-center relative">
+    <span class="name" {title}>
+      {otherWords}
+      <span class="whitespace-nowrap">
+        {lastWord}
+        {#if helpId}
+          <FieldHelpIcon {helpId} />
+        {/if}
+      </span>
+    </span>
+  </span>
+</div>
diff --git a/frontend/viewer/src/lib/components/field-editors/multi-select.svelte b/frontend/viewer/src/lib/components/field-editors/multi-select.svelte
new file mode 100644
index 0000000000..734d4865c7
--- /dev/null
+++ b/frontend/viewer/src/lib/components/field-editors/multi-select.svelte
@@ -0,0 +1,286 @@
+<script lang="ts" generics="Value">
+  import { Badge } from '$lib/components/ui/badge';
+  import { Button } from '$lib/components/ui/button';
+  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover';
+  import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+  import { tick } from 'svelte';
+  import { t } from 'svelte-i18n-lingui';
+  import { Checkbox } from '../ui/checkbox';
+  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
+  import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from '../ui/drawer';
+  import { Icon } from '../ui/icon';
+  import type {ConditionalKeys, Primitive} from 'type-fest';
+  import {cn} from '$lib/utils';
+  import DrawerFooter from '../ui/drawer/drawer-footer.svelte';
+  import {slide} from 'svelte/transition';
+  import {watch} from 'runed';
+
+  let {
+    values = $bindable(),
+    ...constProps
+  }: {
+    values: Value[];
+    options: Value[];
+    readonly?: boolean;
+    /* eslint-disable @typescript-eslint/no-redundant-type-constituents */
+    idSelector: ConditionalKeys<Value, Primitive> | ((value: Value) => Primitive);
+    labelSelector: ConditionalKeys<Value, string> | ((value: Value) => string);
+    /* eslint-enable @typescript-eslint/no-redundant-type-constituents */
+    placeholder?: string;
+    filterPlaceholder?: string;
+    emptyResultsPlaceholder?: string;
+    drawerTitle?: string;
+    sortValuesBy?: 'selectionOrder' | 'optionOrder' | NonNullable<Parameters<Array<Value>['sort']>[0]>;
+  } = $props();
+
+  const {
+    options,
+    readonly = false,
+    idSelector,
+    labelSelector,
+    placeholder,
+    filterPlaceholder,
+    emptyResultsPlaceholder,
+    drawerTitle,
+    sortValuesBy = 'selectionOrder',
+  } = $derived(constProps);
+
+  const getId = $derived.by(() => {
+    if (typeof idSelector === 'function') return idSelector;
+    return (value: Value) => value[idSelector] as Primitive;
+  });
+
+  const getLabel = $derived.by(() => {
+    if (typeof labelSelector === 'function') return labelSelector;
+    return (value: Value) => value[labelSelector] as string;
+  });
+
+  // A wrapper for caching calculated values
+  type PendingValue = {
+    value: Value;
+    optionIndex: number;
+    id: Primitive;
+  };
+
+  let open = $state(false);
+  let dirty = $state(false);
+  let filterValue = $state('');
+  let pendingValues = $state<PendingValue[]>([]);
+  let displayValues = $derived(dirty ? pendingValues.map(v => v.value) : values);
+  let triggerRef = $state<HTMLButtonElement | null>(null);
+  let commandRef = $state<HTMLElement | null>(null);
+
+  watch(() => open, () => {
+    dirty = false;
+    filterValue = '';
+  });
+
+  watch([() => open, () => options], () => {
+    if (open && options) {
+      pendingValues = values.map(toPendingValue);
+    }
+  });
+
+  function toPendingValue(value: Value): PendingValue {
+    const id = getId(value);
+    const optionIndex = options.findIndex((option) => getId(option) === id);
+    return { value, optionIndex, id };
+  }
+
+  function dismiss() {
+    open = false;
+  }
+
+  function submit() {
+    open = false;
+    values = pendingValues.map(p => p.value);
+    void tick().then(() => {
+      triggerRef?.focus();
+    });
+  }
+
+  function toggleSelected(value: Value, triggerSubmit: boolean = true) {
+    const id = getId(value);
+    const isSelected = pendingValues.some((v) => v.id === id);
+
+    if (!isSelected) { // add
+      pendingValues = [...pendingValues, toPendingValue(value)];
+      if (sortValuesBy === 'selectionOrder') {
+        // happens automatically
+      } else if (sortValuesBy === 'optionOrder') {
+        pendingValues.sort((a, b) => a.optionIndex - b.optionIndex);
+      } else {
+        pendingValues.sort((a, b) => sortValuesBy(a.value, b.value));
+      }
+    } else { // remove
+      const index = pendingValues.findIndex((v) => v.id === id);
+      if (index !== -1) pendingValues.splice(index, 1);
+    }
+
+    if (triggerSubmit) submit();
+    else dirty = true;
+  }
+
+  const filteredOptions = $derived.by(() => {
+    const filterValueLower = filterValue.toLocaleLowerCase();
+    return options.filter((option) => {
+      const label = getLabel(option).toLocaleLowerCase();
+      return label.includes(filterValueLower);
+    });
+  });
+
+  function onkeydown(e: KeyboardEvent) {
+    if (e.key === 'Enter' && dirty && (e.metaKey || e.ctrlKey)) {
+      submit();
+      e.stopPropagation();
+      e.preventDefault();
+    } else if (e.key === ' ' && (e.metaKey || e.ctrlKey)) {
+      tryToggleHighlightedValue();
+      e.stopPropagation();
+      e.preventDefault();
+    }
+  }
+
+  function tryToggleHighlightedValue() {
+      const value = getHighlightedValue();
+      if (value) {
+        toggleSelected(value, false);
+      }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
+  function getHighlightedValue(): Value | undefined {
+    const selectedItem = commandRef?.querySelector('[data-command-item][data-selected]');
+    const index = selectedItem?.getAttribute('data-value-index');
+    if (index) return filteredOptions[Number(index)];
+  }
+</script>
+
+{#snippet displayBadges()}
+  <div class="flex flex-wrap justify-start gap-2">
+    {#each displayValues as value (getId(value))}
+      <Badge>
+        {getLabel(value)}
+      </Badge>
+    {:else}
+      <span class="text-muted-foreground">
+        {placeholder ?? $t`None`}
+        <!-- ensures that:
+          1) baseline alignment works for consumers of this component
+          2) list height doesn't shrink when empty
+          -->
+        <Badge class="max-w-0 invisible">
+          &nbsp;
+        </Badge>
+      </span>
+    {/each}
+  </div>
+{/snippet}
+
+{#snippet trigger({ props }: { props: Record<string, unknown> })}
+  <Button disabled={readonly} bind:ref={triggerRef} variant="outline" {...props} role="combobox" aria-expanded={open}
+    class="w-full h-auto px-2 justify-between disabled:opacity-100 disabled:border-transparent">
+    {@render displayBadges()}
+    {#if !readonly}
+      <Icon icon="i-mdi-chevron-down" class="mr-2 size-5 shrink-0 opacity-50" />
+    {/if}
+  </Button>
+{/snippet}
+
+{#snippet command()}
+  <Command shouldFilter={false} bind:ref={commandRef}>
+    <CommandInput bind:value={filterValue} autofocus placeholder={filterPlaceholder ?? $t`Filter...`} {onkeydown}>
+      <div class="flex items-center gap-2 flex-nowrap">
+        {#if IsMobile.value}
+          {#if filterValue}
+            <Button variant="ghost" size="xs-icon" onclick={() => (filterValue = '')} aria-label={$t`clear`}>
+              <Icon icon="i-mdi-close" />
+            </Button>
+          {/if}
+        {:else}
+          {#if dirty}
+            <div in:slide={{axis: 'x', duration: 200}}>
+              <Button variant="default" size="xs" onclick={submit} aria-label={$t`Submit`}>
+                {$t`Submit`}
+              </Button>
+            </div>
+          {/if}
+          <Button variant={dirty ? 'secondary' : 'ghost'} size="xs-icon" onclick={dismiss} aria-label={$t`Close`}>
+              <Icon icon="i-mdi-close" />
+          </Button>
+        {/if}
+      </div>
+    </CommandInput>
+    <CommandList class="h-[300px] md:max-h-[50vh]">
+      <CommandEmpty>{emptyResultsPlaceholder ?? $t`No items found`}</CommandEmpty>
+      <CommandGroup>
+        {#each filteredOptions as value, i (getId(value))}
+          {@const label = getLabel(value)}
+          {@const id = getId(value)}
+          {@const selected = pendingValues.some(v => v.id === id)}
+          <CommandItem
+            keywords={[label.toLocaleLowerCase()]}
+            value={label.toLocaleLowerCase()}
+            onSelect={() => toggleSelected(value, !dirty && !IsMobile.value)}
+            class="group max-md:h-12"
+            data-value-index={i}
+            aria-label={label}
+          >
+            <Icon icon="i-mdi-check" class={cn('md:hidden', selected || 'invisible')} />
+            <Checkbox
+              checked={selected}
+              tabindex={-1}
+              class={cn(
+                'max-md:hidden mr-2 border-muted-foreground !text-foreground !bg-transparent group-[&:not([data-selected])]:border-transparent',
+                selected || '[&:not(:hover)]:opacity-50')}
+              onclick={(e) => {
+                // prevents command item selection
+                e.stopPropagation();
+              }}
+              onpointerdown={(e) => {
+                // prevents moving focus to the checkbox when clicking on it
+                e.preventDefault();
+              }}
+              onCheckedChange={() => toggleSelected(value, false)}
+            />
+            {label}
+          </CommandItem>
+        {/each}
+      </CommandGroup>
+    </CommandList>
+  </Command>
+{/snippet}
+
+{#if IsMobile.value}
+  <Drawer bind:open dismissible={!dirty}>
+    <DrawerTrigger child={trigger} />
+    <DrawerContent handle={true} class="overflow-hidden">
+      <DrawerHeader class="text-left py-2">
+        {#if drawerTitle}
+          <DrawerTitle class="mb-2">{drawerTitle}</DrawerTitle>
+        {/if}
+        <DrawerDescription>
+          {@render displayBadges()}
+        </DrawerDescription>
+      </DrawerHeader>
+        {@render command()}
+      <DrawerFooter>
+        <div class="flex gap-4 items-center flex-nowrap">
+          <Button variant="secondary" class={cn('basis-1/4 transition-all', dirty || 'basis-3/4')} onclick={dismiss}>
+            {$t`Cancel`}
+          </Button>
+          <Button disabled={!dirty} class={cn('basis-3/4 transition-all', dirty || 'basis-1/4')} onclick={submit}>
+            {$t`Submit`}
+          </Button>
+        </div>
+      </DrawerFooter>
+    </DrawerContent>
+  </Drawer>
+{:else}
+  <Popover bind:open>
+    <PopoverTrigger child={trigger} />
+    <PopoverContent class="p-0 w-96 max-w-[50vw]" align="start" sticky="always" side="bottom" avoidCollisions interactOutsideBehavior={dirty ? 'ignore' : 'close'}>
+      {@render command()}
+    </PopoverContent>
+  </Popover>
+{/if}
diff --git a/frontend/viewer/src/lib/components/ui/button/button.svelte b/frontend/viewer/src/lib/components/ui/button/button.svelte
index 6a2d996d44..dd13c8f340 100644
--- a/frontend/viewer/src/lib/components/ui/button/button.svelte
+++ b/frontend/viewer/src/lib/components/ui/button/button.svelte
@@ -21,6 +21,7 @@
         xs: 'h-8 rounded-md px-2',
         sm: 'h-9 rounded-md px-3',
         lg: 'h-11 rounded-md px-8',
+        'xs-icon': 'h-8 w-8 min-h-8 min-w-8',
         icon: 'h-10 w-10 min-h-10 min-w-10',
         'extended-fab': 'h-14 pl-4 pr-5',
         'fab': 'h-14 px-4'
diff --git a/frontend/viewer/src/lib/components/ui/checkbox/checkbox.svelte b/frontend/viewer/src/lib/components/ui/checkbox/checkbox.svelte
index e2acc52e09..b5cc08a33d 100644
--- a/frontend/viewer/src/lib/components/ui/checkbox/checkbox.svelte
+++ b/frontend/viewer/src/lib/components/ui/checkbox/checkbox.svelte
@@ -23,7 +23,7 @@
   {...restProps}
 >
   {#snippet children({ checked, indeterminate })}
-    <div class="flex size-4 items-center justify-center text-current">
+    <div class="flex size-full items-center justify-center text-current">
       {#if indeterminate}
         <Icon icon="i-mdi-minus" class="size-3.5" />
       {:else}
diff --git a/frontend/viewer/src/lib/components/ui/command/command-input.svelte b/frontend/viewer/src/lib/components/ui/command/command-input.svelte
index aaadc726db..d41afe256e 100644
--- a/frontend/viewer/src/lib/components/ui/command/command-input.svelte
+++ b/frontend/viewer/src/lib/components/ui/command/command-input.svelte
@@ -7,6 +7,7 @@
     ref = $bindable(null),
     class: className,
     value = $bindable(''),
+		children,
     ...restProps
   }: CommandPrimitive.InputProps = $props();
 </script>
@@ -22,4 +23,5 @@
     {...restProps}
     bind:value
   />
+	{@render children?.()}
 </div>
diff --git a/frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte b/frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte
index 0fe889007b..c98e9b19ea 100644
--- a/frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte
+++ b/frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte
@@ -7,10 +7,12 @@
     ref = $bindable(null),
     class: className,
     portalProps,
+    handle = true,
     children,
     ...restProps
   }: DrawerPrimitive.ContentProps & {
     portalProps?: DrawerPrimitive.PortalProps;
+    handle?: boolean;
   } = $props();
 </script>
 
@@ -24,7 +26,9 @@
     )}
     {...restProps}
   >
-    <div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
+    {#if handle}
+      <div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
+    {/if}
     {@render children?.()}
   </DrawerPrimitive.Content>
 </DrawerPrimitive.Portal>
diff --git a/frontend/viewer/src/lib/components/ui/drawer/drawer.svelte b/frontend/viewer/src/lib/components/ui/drawer/drawer.svelte
index a4ca68aa8c..3d3ea62577 100644
--- a/frontend/viewer/src/lib/components/ui/drawer/drawer.svelte
+++ b/frontend/viewer/src/lib/components/ui/drawer/drawer.svelte
@@ -1,4 +1,7 @@
 <script lang="ts">
+  import {IsMobile} from '$lib/hooks/is-mobile.svelte';
+  import {watch} from 'runed';
+  import {onDestroy, onMount} from 'svelte';
   import {Drawer as DrawerPrimitive} from 'vaul-svelte';
 
   let {
@@ -7,6 +10,46 @@
     activeSnapPoint = $bindable(null),
     ...restProps
   }: DrawerPrimitive.RootProps = $props();
+
+  watch(() => open, () => {
+    if (open) {
+      handleOpen();
+    } else {
+      handleClose();
+    }
+  });
+
+  let addedHistory = $state(false);
+
+  function handleOpen() {
+    if (IsMobile.value) {
+      history.pushState({ drawer: true }, '');
+      addedHistory = true;
+    }
+  }
+
+  function handleClose() {
+    if (addedHistory) {
+      addedHistory = false;
+      history.back(); // we need to manually pop our added history
+    }
+  }
+
+  const abortController = new AbortController();
+
+  onMount(() => {
+    window.addEventListener('popstate', () => {
+      if (open && addedHistory) {
+        addedHistory = false; // the user popped our added history
+        open = false;
+      }
+    }, { signal: abortController.signal });
+  });
+
+  onDestroy(() => {
+    if (addedHistory) history.back();
+    abortController.abort();
+  });
 </script>
 
 <DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
diff --git a/frontend/viewer/src/lib/entry-editor/FieldTitle.svelte b/frontend/viewer/src/lib/entry-editor/FieldTitle.svelte
index 096a2ece6f..4df3e0f72d 100644
--- a/frontend/viewer/src/lib/entry-editor/FieldTitle.svelte
+++ b/frontend/viewer/src/lib/entry-editor/FieldTitle.svelte
@@ -2,13 +2,13 @@
   import {fieldName} from '../i18n';
   import {useCurrentView} from '$lib/views/view-service';
   import FieldHelpIcon from './FieldHelpIcon.svelte';
-  import {fieldData} from './field-data';
+  import {fieldData, type FieldId} from './field-data';
   import {type View} from '$lib/views/view-data';
 
   export let id: string;
   export let helpId: string | undefined = undefined;
   export let name: string | undefined = undefined;
-  $: if (!helpId) helpId = fieldData[id]?.helpId;
+  $: if (!helpId) helpId = fieldData[id as FieldId]?.helpId;
 
   const currentView = useCurrentView();
 
diff --git a/frontend/viewer/src/lib/entry-editor/field-data.ts b/frontend/viewer/src/lib/entry-editor/field-data.ts
index 88f30cb1f2..84803cd7ca 100644
--- a/frontend/viewer/src/lib/entry-editor/field-data.ts
+++ b/frontend/viewer/src/lib/entry-editor/field-data.ts
@@ -27,5 +27,10 @@ const privateFieldData = {
 /**
  * This is a list of all standard fields with fixed data that is constant across all views.
  */
-export const fieldData: Record<string, FieldData> = privateFieldData;
-export type FieldIds = keyof typeof fieldData;
+export type FieldId = keyof typeof privateFieldData;
+export const fieldData: Record<FieldId, FieldData> = privateFieldData;
+
+/**
+ * @deprecated I think we want a type that's more type safe i.e. FieldId 👆
+ */
+export type FieldIds = string; // this was always just string, it just wasn't obvious 🙃
diff --git a/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte b/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte
index e78eef17de..13b754ec1c 100644
--- a/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte
+++ b/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte
@@ -30,6 +30,7 @@
 
   const alternateView = {
     id: 'alternate',
+    type: 'fw-classic',
     i18nKey: '',
     label: 'Alternate',
     fields,
@@ -40,6 +41,7 @@
 
   const defaultView = {
     id: 'sandbox',
+    type: 'fw-lite',
     i18nKey: '',
     label: 'Language Forge',
     fields,
diff --git a/frontend/viewer/src/lib/sandbox/Sandbox.svelte b/frontend/viewer/src/lib/sandbox/Sandbox.svelte
index 31acd68116..6e1a969954 100644
--- a/frontend/viewer/src/lib/sandbox/Sandbox.svelte
+++ b/frontend/viewer/src/lib/sandbox/Sandbox.svelte
@@ -1,9 +1,10 @@
 <script lang="ts">
+  import { ResizableHandle, ResizablePane, ResizablePaneGroup } from '$lib/components/ui/resizable';
   import OverrideFields from '$lib/OverrideFields.svelte';
   import {Button} from '$lib/components/ui/button';
   import {Checkbox} from '$lib/components/ui/checkbox';
   import {DotnetService, type ISense} from '$lib/dotnet-types';
-  import type {FieldIds} from '$lib/entry-editor/field-data';
+  import {fieldData, type FieldIds} from '$lib/entry-editor/field-data';
   import SenseEditor from '$lib/entry-editor/object-editors/SenseEditor.svelte';
   import {InMemoryApiService} from '$lib/in-memory-api-service';
   import {AppNotification} from '$lib/notifications/notifications';
@@ -21,6 +22,13 @@
   import LcmRichTextEditor from '$lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte';
   import {lineSeparator} from '$lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte';
   import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString';
+  import MultiSelect from '$lib/components/field-editors/multi-select.svelte';
+  import FieldTitle from '$lib/components/field-editors/field-title.svelte';
+  import {t} from 'svelte-i18n-lingui';
+  import ThemePicker from '$lib/ThemePicker.svelte';
+  import {Tabs, TabsList, TabsTrigger} from '$lib/components/ui/tabs';
+  import {Label} from '$lib/components/ui/label';
+  import {Switch} from '$lib/components/ui/switch';
 
   const crdtOptions: MenuOption[] = [
     {value: 'a', label: 'Alpha'},
@@ -67,11 +75,33 @@
   let richString: IRichString = $state({
     spans: [{text: 'Hello', ws: 'en'}, {text: ' World', ws: 'js'}, {text: ` type ${lineSeparator}script`, ws: 'ts'}],
   });
+
+  const allDomains = $state<{label: string}[]>([
+    { label: 'fruit' }, { label: 'tree' }, { label: 'stars' }, { label: 'earth' },
+  ]);
+  for (let i = 0; i < 100; i++) {
+    allDomains.push({
+      label: allDomains[Math.floor(Math.random() * allDomains.length)].label + '-' + i
+    });
+  }
+  allDomains.sort((a, b) => a.label.localeCompare(b.label));
+
+  function randomSemanticDomainSorter() {
+    return Math.random() - 0.5;
+  }
+
+  let selectedDomains = $state([allDomains[0], allDomains[80]]);
+
+  let semanticDomainOrder = $state<'selectionOrder' | 'optionOrder' | 'randomOrder'>('selectionOrder');
+  const sortSemanticDomainValuesBy = $derived(semanticDomainOrder === 'randomOrder'
+    ? randomSemanticDomainSorter
+    : semanticDomainOrder);
+  let semanticDomainsReadonly = $state(false);
 </script>
 
-<div class="p-6">
-  <h2 class="mb-4">
-    Shadcn Sandbox
+<div class="p-6 shadcn-root">
+  <h2 class="mb-4 flex gap-8 items-center">
+    Shadcn Sandbox <ThemePicker />
   </h2>
   <div class="grid grid-cols-3 gap-6">
     <div class="flex flex-col gap-2 border p-4 justify-between">
@@ -82,7 +112,69 @@
       </label>
     </div>
   </div>
-  <div class="grid grid-cols-3 gap-6">
+  <ResizablePaneGroup direction="horizontal">
+    <ResizablePane class="min-w-72_ !overflow-visible" defaultSize={100}>
+      <div class="@container my-4 border px-4 py-8 relative z-0">
+        <div class="breakpoint-marker w-[32rem] text-orange-600">
+          @lg
+        </div>
+        <div class="breakpoint-marker w-[48rem] text-green-600">
+          @3xl
+        </div>
+        <div class="editor-grid">
+          <div class="field-root">
+            <div class="field-body grid-cols-1">
+              <Label class="mb-2">Order of semantic domains</Label>
+              <Tabs bind:value={semanticDomainOrder} class="mb-1">
+                <TabsList>
+                  <TabsTrigger value="selectionOrder">Selection</TabsTrigger>
+                  <TabsTrigger value="optionOrder">Option</TabsTrigger>
+                  <TabsTrigger value="randomOrder">Random</TabsTrigger>
+                </TabsList>
+              </Tabs>
+              <p class="text-muted-foreground text-sm">
+                Only comes into effect while editing, because we don't want to make any changes implicitly.
+              </p>
+            </div>
+          </div>
+          <div class="field-root">
+            <div class="field-body grid-cols-1">
+              <Switch bind:checked={semanticDomainsReadonly} label="Readonly" />
+            </div>
+          </div>
+          <div class="field-root">
+            <FieldTitle
+              liteName={$t`Semantic domains`}
+              classicName={$t`Semantic domains`}
+              helpId={fieldData.semanticDomains.helpId}
+            />
+            <div class="field-body">
+              <div class="col-span-full">
+                <MultiSelect
+                  readonly={semanticDomainsReadonly}
+                  bind:values={() => selectedDomains,
+                  (newValues) => selectedDomains = newValues}
+                  idSelector="label"
+                  labelSelector={(item) => item.label}
+                  sortValuesBy={sortSemanticDomainValuesBy}
+                  drawerTitle={$t`Semantic domains`}
+                  filterPlaceholder={$t`Filter semantic domains...`}
+                  placeholder={$t`🤷 nothing here`}
+                  emptyResultsPlaceholder={$t`Looked hard, found nothing`}
+                  options={allDomains}>
+                </MultiSelect>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </ResizablePane>
+    <!-- looks cool 🤷 https://github.com/huntabyte/shadcn-svelte/blob/bcbe10a4f65d244a19fb98ffb6a71d929d9603bc/sites/docs/src/lib/components/docs/block-preview.svelte#L65 -->
+    <ResizableHandle class="after:bg-border relative w-3 bg-transparent p-0 after:absolute after:right-0 after:top-1/2 after:h-8 after:w-[6px] after:-translate-y-1/2 after:translate-x-[-1px] after:rounded-full after:transition-all after:hover:h-10" />
+    <ResizablePane>
+    </ResizablePane>
+  </ResizablePaneGroup>
+  <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
     <Button onclick={() => richString = {spans: [{text: 'test', ws: 'en'}]}}>Replace Rich Text</Button>
     <LcmRichTextEditor label="Test Rich Text Editor" bind:value={richString}/>
     <pre>{JSON.stringify(richString, null, 2).replaceAll(lineSeparator, '\n')}</pre>
@@ -107,11 +199,11 @@
 
 <hr class="border-t border-gray-200 my-6"/>
 
-<div class="p-6">
+<div class="p-6 overflow-hidden">
   <h2 class="mb-4">
     Svelte-UX Sandbox
   </h2>
-  <div class="grid grid-cols-3 gap-6">
+  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
     <div class="flex flex-col gap-2 border p-4 justify-between">
       MultiOptionEditor configurations
       <OptionSandbox/>
@@ -170,8 +262,8 @@
       </div>
       <div>
         <p>Shown:</p>
-        <div class="p-2" use:dndzone={{items: senseFields, flipDurationMs: 200}} on:consider={updateFields}
-            on:finalize={updateFields}>
+        <div class="p-2" use:dndzone={{items: senseFields, flipDurationMs: 200}} onconsider={updateFields}
+            onfinalize={updateFields}>
           {#each senseFields as field (field)}
             <div class="p-2 border m-3">{field.id}</div>
           {/each}
@@ -187,3 +279,10 @@
 
   </div>
 </div>
+<div id="bottom" class="fixed bottom-0 left-1/2"></div>
+
+<style lang="postcss">
+  .breakpoint-marker {
+    @apply absolute h-full top-0 border-r-current border-r -z-10 text-right pr-2;
+  }
+</style>
diff --git a/frontend/viewer/src/lib/views/view-data.ts b/frontend/viewer/src/lib/views/view-data.ts
index 5611c00ec3..dba3a59937 100644
--- a/frontend/viewer/src/lib/views/view-data.ts
+++ b/frontend/viewer/src/lib/views/view-data.ts
@@ -30,20 +30,22 @@ export const allFields: Record<FieldIds, FieldView> = {
   reference: {show: false, order: 3},
 };
 
-const defaultView: RootView = {
+export const FW_LITE_VIEW: RootView = {
   id: 'fwlite',
+  type: 'fw-lite',
   i18nKey: '',
   label: 'FieldWorks Lite',
   fields: allFields,
-  get alternateView() { return fieldWorksView; }
+  get alternateView() { return FW_CLASSIC_VIEW; }
 };
 
-const fieldWorksView: RootView = {
+export const FW_CLASSIC_VIEW: RootView = {
   id: 'fieldworks',
+  type: 'fw-classic',
   i18nKey: 'fieldworks',
   label: 'FieldWorks',
   fields: recursiveSpread(allFields, {[defaultDef]: {show: true}}),
-  alternateView: defaultView,
+  alternateView: FW_LITE_VIEW,
 };
 
 const viewDefinitions: CustomViewDefinition[] = [
@@ -51,12 +53,12 @@ const viewDefinitions: CustomViewDefinition[] = [
 ];
 
 export const views: [RootView, RootView, ...CustomView[]] = [
-  defaultView,
-  fieldWorksView,
+  FW_LITE_VIEW,
+  FW_CLASSIC_VIEW,
   ...viewDefinitions.map(view => {
     const fields: Record<FieldIds, FieldView> = recursiveSpread<typeof allFields>(allFields, view.fieldOverrides);
     return {
-      ...defaultView,
+      ...FW_LITE_VIEW,
       ...view,
       fields: fields
     };
@@ -88,6 +90,7 @@ function recursiveSpread<T extends Record<string | symbol, unknown>>(obj1: T, ob
 
 interface ViewDefinition {
   id: string;
+  type: 'fw-lite' | 'fw-classic';
   i18nKey: I18nType;
   label: string;
 }
diff --git a/frontend/viewer/src/project/ProjectDropdown.svelte b/frontend/viewer/src/project/ProjectDropdown.svelte
index c9b04fe3b0..68ac45bb6e 100644
--- a/frontend/viewer/src/project/ProjectDropdown.svelte
+++ b/frontend/viewer/src/project/ProjectDropdown.svelte
@@ -38,7 +38,6 @@
 
   function handleSelect(project: { id: string; name: string }) {
     onSelect(project.name);
-    open = false;
     closeAndFocusTrigger();
   }
 
diff --git a/frontend/viewer/src/theme.postcss b/frontend/viewer/src/theme.postcss
index 93dacc1b3f..f91152964e 100644
--- a/frontend/viewer/src/theme.postcss
+++ b/frontend/viewer/src/theme.postcss
@@ -96,7 +96,7 @@
       --foreground: 0 0% 95%;
       --card: 24 9.8% 10%;
       --card-foreground: 0 0% 95%;
-      --popover: 0 0% 9%;
+      --popover: 20 14.3% 4.1%; /* 0 0% 9%; changed to match --background like other themes */
       --popover-foreground: 0 0% 95%;
       --primary: 142.1 70.6% 45.3%;
       --primary-foreground: 144.9 80.4% 10%;
@@ -143,7 +143,7 @@
       --foreground: 0 0% 95%;
       --card: 24 9.8% 10%;
       --card-foreground: 0 0% 95%;
-      --popover: 0 0% 9%;
+      --popover: 20 14.3% 4.1%; /* 0 0% 9%; changed to match --background like other themes */
       --popover-foreground: 0 0% 95%;
       --primary: 346.8 77.2% 49.8%;
       --primary-foreground: 355.7 100% 97.3%;


Reviews:
user: login: hahn-kev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T07:56:32Z
user: login: hahn-kev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T07:58:54Z
user: login: myieye
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T14:26:11Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>frontend/viewer/src/lib/entry-editor/field-data.ts (1)</summary><blockquote>

`33-36`: **Note the deprecation of FieldIds type**

The `FieldIds` type is now properly marked as deprecated with a clear migration path to use the more type-safe `FieldId` type. Consider updating usages of `FieldIds` across the codebase to use `FieldId` in future PRs.

</blockquote></details>
<details>
<summary>frontend/viewer/src/lib/components/field-editors/field-title.svelte (1)</summary><blockquote>

`30-37`: **Consider simplifying the word splitting logic**

The current implementation for handling the last word separation is a bit complex and introduces extra processing. Consider using CSS to handle the spacing between the label and help icon instead.

```diff
- // kind of crazy, but I don't think Svelte's white-space handling let's us use &nbsp; between the label and help icon
- const { lastWord, otherWords } = $derived.by(() => {
-   const words = label.split(' ');
-   return {
-     lastWord: words.pop(),
-     otherWords: words.join(' '),
-   };
- })
```

And then update the template section to:

```svelte
<span class="name" {title}>
  {label}
  {#if helpId}
    <span class="inline-block ml-1"><FieldHelpIcon {helpId} /></span>
  {/if}
</span>
```

</blockquote></details>
<details>
<summary>frontend/viewer/src/lib/views/view-data.ts (1)</summary><blockquote>

`42-49`: **Consider using a getter for alternateView consistency**

While `FW_CLASSIC_VIEW` works as implemented, for consistency with `FW_LITE_VIEW`, consider using a getter for the `alternateView` property here as well.

```diff
-  alternateView: FW_LITE_VIEW,
+  get alternateView() { return FW_LITE_VIEW; }
```

</blockquote></details>
<details>
<summary>frontend/viewer/src/lib/components/field-editors/multi-select.svelte (2)</summary><blockquote>

`144-149`: **Handle cases where no item is highlighted.**  
The helper function `getHighlightedValue()` can return `undefined` if no item is highlighted. While this is handled gracefully in your `tryToggleHighlightedValue()` method, consider logging or providing a user hint if toggling fails, particularly for accessibility or discoverability.

---

`237-239`: **Fix typographical error in comment.**  
“deault” should be “default.”  

```diff
- // prevents deault command item selection
+ // prevents default command item selection
```

</blockquote></details>
<details>
<summary>frontend/viewer/src/lib/sandbox/Sandbox.svelte (1)</summary><blockquote>

`89-91`: **Verify randomness approach for shuffling lists.**  
Using `return Math.random() - 0.5` inside a custom comparison function can produce random ordering, though it lacks uniformity and stability. If you need a more robust shuffle, consider implementing Fisher–Yates or a similar algorithm.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f0fd8f7d55b53da4dba45ff11381ba7ac56a82c9 and 9fceeea5a3a92c06f1c7ab15c6a64097f207f934.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `frontend/pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml`

</details>

<details>
<summary>📒 Files selected for processing (15)</summary>

* `frontend/viewer/package.json` (1 hunks)
* `frontend/viewer/src/app.postcss` (1 hunks)
* `frontend/viewer/src/lib/components/field-editors/field-title.svelte` (1 hunks)
* `frontend/viewer/src/lib/components/field-editors/multi-select.svelte` (1 hunks)
* `frontend/viewer/src/lib/components/ui/button/button.svelte` (1 hunks)
* `frontend/viewer/src/lib/components/ui/checkbox/checkbox.svelte` (1 hunks)
* `frontend/viewer/src/lib/components/ui/command/command-input.svelte` (2 hunks)
* `frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte` (2 hunks)
* `frontend/viewer/src/lib/components/ui/drawer/drawer.svelte` (2 hunks)
* `frontend/viewer/src/lib/entry-editor/FieldTitle.svelte` (1 hunks)
* `frontend/viewer/src/lib/entry-editor/field-data.ts` (1 hunks)
* `frontend/viewer/src/lib/sandbox/OptionSandbox.svelte` (2 hunks)
* `frontend/viewer/src/lib/sandbox/Sandbox.svelte` (7 hunks)
* `frontend/viewer/src/lib/views/view-data.ts` (2 hunks)
* `frontend/viewer/src/project/ProjectDropdown.svelte` (0 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* frontend/viewer/src/project/ProjectDropdown.svelte

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (1)</summary>

<details>
<summary>frontend/viewer/src/lib/views/view-data.ts (1)</summary><blockquote>

<details>
<summary>frontend/viewer/src/lib/entry-editor/field-data.ts (1)</summary>

* `FieldIds` (36-36)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (27)</summary><blockquote>

<details>
<summary>frontend/viewer/src/lib/components/ui/checkbox/checkbox.svelte (1)</summary>

`26-26`: **Improved icon container sizing**

Changing from fixed size to `size-full` ensures the icon properly fills and centers within its container, creating better visual alignment.

</details>
<details>
<summary>frontend/viewer/src/lib/components/ui/command/command-input.svelte (1)</summary>

`10-10`: **Enhanced component flexibility with children support**

The addition of the `children` property and its conditional rendering allows the command input component to display additional content, which is essential for the multi-select implementation. The optional chaining ensures graceful handling when no children are provided.



Also applies to: 26-26

</details>
<details>
<summary>frontend/viewer/src/lib/components/ui/button/button.svelte (1)</summary>

`24-24`: **Added compact icon button variant**

The new `xs-icon` size variant provides a smaller button option (8x8 units vs 10x10 for regular icon buttons), which is useful for compact UI elements like clear buttons in the multi-select component. This addition enhances the component library's flexibility while maintaining consistent sizing patterns.

</details>
<details>
<summary>frontend/viewer/package.json (1)</summary>

`51-51`: **Fixed bits-ui dependency version for stability**

Locking the bits-ui version to exactly 1.3.16 (removing the caret from ^1.3.10) ensures consistent behavior across all environments. This prevents automatic updates to newer minor versions that might introduce breaking changes, particularly important for the UI components being modified in this PR.

</details>
<details>
<summary>frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte (2)</summary>

`10-10`: **Good enhancement - adds flexibility to the drawer component**

Adding an optional `handle` prop with a default value of `true` maintains backward compatibility while allowing customization when needed.



Also applies to: 15-15

---

`29-31`: **Proper implementation of conditional handle rendering**

The conditional rendering of the handle element aligns well with the PR objectives, supporting the responsive design approach for the multi-select component.

</details>
<details>
<summary>frontend/viewer/src/lib/entry-editor/FieldTitle.svelte (1)</summary>

`5-5`: **Good type safety improvement**

The import of the `FieldId` type and the type casting in the reactive statement enhances type safety by ensuring `id` is treated as a valid field identifier when accessing `fieldData`.



Also applies to: 11-11

</details>
<details>
<summary>frontend/viewer/src/lib/sandbox/OptionSandbox.svelte (1)</summary>

`33-34`: **Proper view type differentiation**

Adding type properties to distinguish between 'fw-classic' and 'fw-lite' views supports the responsive design approach mentioned in the PR objectives, enabling different component behaviors for desktop and mobile users.



Also applies to: 44-45

</details>
<details>
<summary>frontend/viewer/src/lib/entry-editor/field-data.ts (1)</summary>

`30-31`: **Excellent type safety improvement**

Creating a `FieldId` type from the keys of `privateFieldData` and using it to type the `fieldData` export ensures that only valid field identifiers can be used at compile time.

</details>
<details>
<summary>frontend/viewer/src/lib/components/field-editors/field-title.svelte (3)</summary>

`1-5`: **Clean imports with well-defined dependencies**

The imports are appropriately organized, importing only what's needed for the component's functionality: translation utilities, help icon component, and view service.

---

`6-14`: **Props with proper type definitions**

The component defines clear prop types using TypeScript, ensuring type safety for the component inputs. The use of `$props()` follows Svelte 5 conventions.

---

`40-52`: **Clear and semantic markup structure**

The component uses a clean and semantic structure with appropriate class names. The inline-flex approach ensures proper alignment of the text and icon.

</details>
<details>
<summary>frontend/viewer/src/lib/views/view-data.ts (3)</summary>

`33-40`: **Good use of constants and getter for circular reference prevention**

The `FW_LITE_VIEW` constant is well-defined with the new `type` property, and the use of a getter for `alternateView` cleverly prevents circular reference issues that would occur with direct assignments.

---

`55-65`: **Good reuse of view constants in the views array**

Using the defined constants in the views array and spreading `FW_LITE_VIEW` into custom views is a clean approach that ensures consistency across view definitions.

---

`91-96`: **Type safety improvement with union type**

Adding the `type` property to the `ViewDefinition` interface with a specific union type improves type safety and makes view type distinctions explicit in the codebase.

</details>
<details>
<summary>frontend/viewer/src/lib/components/ui/drawer/drawer.svelte (4)</summary>

`2-4`: **Mobile detection integration**

Good addition of the mobile detection hook and necessary imports to enhance drawer behavior across different devices.

---

`14-20`: **Reactive drawer state handling**

The use of the `watch` function to react to open state changes is a clean implementation. This ensures proper handling of browser history when the drawer opens or closes.

---

`22-36`: **Browser history management for mobile UX**

The implementation intelligently handles browser history only on mobile devices, ensuring back button behavior works as expected without affecting desktop navigation.

---

`38-52`: **Proper cleanup with AbortController and lifecycle hooks**

The use of `AbortController` with `onMount` and `onDestroy` ensures proper cleanup of event listeners and history state, preventing memory leaks and unexpected navigation behavior.

</details>
<details>
<summary>frontend/viewer/src/app.postcss (3)</summary>

`94-96`: **New lg-form grid template introduction**

The new `.lg-form` class provides a wider layout option with more space for field labels, enhancing the responsive design system.

---

`98-103`: **Enhanced responsive grid layout**

The updated `.editor-grid` class now applies different grid layouts at different breakpoints, which aligns with the PR's focus on responsive design for both desktop and mobile users.

---

`105-121`: **Well-structured field component CSS classes**

The new field-related classes (`.field-root`, `.field-title`, `.field-body`) create a consistent and responsive layout system for field components. The use of subgrid ensures proper alignment across nested grids.

</details>
<details>
<summary>frontend/viewer/src/lib/components/field-editors/multi-select.svelte (3)</summary>

`18-34`: **Confirm `$bindable()` usage for Svelte compatibility.**  
It appears that these lines rely on non-standard Svelte reactivity helpers, particularly `$bindable()`. Please verify that integrating this feature works correctly in your build environment and does not conflict with standard Svelte patterns or any bundling plugins.

---

`254-286`: **Mobile and desktop integration looks solid.**  
Your Drawer-based approach for mobile users, alongside the Popover for desktop, is a well-structured solution. The distinct flows for “Dismiss” vs. “Submit” align with the design goals, and the toggled states (dirty, open) appear to be managed cleanly.

---

`102-122`: <details>
<summary>✅ Verification successful</summary>

**Consider avoiding in-place array manipulation for reactive updates.**  
Calling `pendingValues.splice(index, 1);` mutates the array in-place. While it may work with `$state` or certain store libraries, in many reactive contexts, splicing can sometimes lead to stale reactivity. To ensure consistent behavior, consider creating a new array instead:  
```diff
- if (index !== -1) pendingValues.splice(index, 1);
+ if (index !== -1) {
+   pendingValues = [
+     ...pendingValues.slice(0, index),
+     ...pendingValues.slice(index + 1)
+   ];
+ }
```

---



</details>

**Action: Update reactive removal in `multi-select.svelte` to avoid in-place mutation**

In the `toggleSelected` function, while adding values a new array is created to trigger reactivity, the removal branch uses in-place mutation with `splice`, which may not update the UI as expected. Instead, reassign a new array after removal to ensure consistent reactive updates. For example, replace:

```diff
- if (index !== -1) pendingValues.splice(index, 1);
+ if (index !== -1) {
+   pendingValues = [
+     ...pendingValues.slice(0, index),
+     ...pendingValues.slice(index + 1)
+   ];
+ }
```

This change aligns with Svelte’s reactive principles and avoids potential stale reactivity issues.

</details>
<details>
<summary>frontend/viewer/src/lib/sandbox/Sandbox.svelte (2)</summary>

`155-156`: **Confirm two-way binding approach for `values`.**  
Your usage of `bind:values={() => selectedDomains, (newValues) => selectedDomains = newValues}` is unconventional compared to standard Svelte’s `bind:values={variable}`. Verify that this syntax works as expected with your chosen reactivity library or store.

---

`2-32`: **Imports and layout adjustments look good.**  
These additions integrate new UI components (e.g., `FieldTitle`, `ThemePicker`, `Tabs`, `Switch`) and reorganize the layout. No major issues are apparent, and the approach seems consistent with the rest of the codebase.  



Also applies to: 25-31, 102-104, 115-117, 127-139, 142-142, 146-149, 152-166, 202-202, 206-206, 282-289

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-08T15:35:57Z
user: login: hahn-kev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T05:44:42Z
user: login: hahn-kev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T05:49:24Z
user: login: hahn-kev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T05:58:36Z
user: login: hahn-kev
body: Looks good, I'd like to fix the weird background in dark mode before this is merged but I'm happy to approve it.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-09T05:59:13Z
user: login: myieye
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T09:10:29Z
user: login: myieye
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T09:11:29Z
user: login: myieye
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T13:08:37Z

Review comments:
diff_hunk: @@ -0,0 +1,121 @@
+<script lang="ts" generics="Value">
+  import { Badge } from '$lib/components/ui/badge';
+  import { Button } from '$lib/components/ui/button';
+  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover';
+  import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+  import { tick } from 'svelte';
+  import { t } from 'svelte-i18n-lingui';
+  import { Checkbox } from '../ui/checkbox';
+  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
+  import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer';
+  import { Icon } from '../ui/icon';
+
+  let {
+    values = $bindable(),
+    ...constProps
+  }: {
+    values: Value[];
+    options: Value[];
+    toLabel: (value: Value) => string;
+    placeholder?: string;
+    filterPlaceholder?: string;
+    resultsPlaceholder?: string;
+  } = $props();
+
+  const { options, toLabel, placeholder, filterPlaceholder, resultsPlaceholder } = constProps;
+
+  let open = $state(false);
+  let triggerRef = $state<HTMLButtonElement | null>(null);
+
+  function closeAndFocusTrigger() {
+    open = false;
+    void tick().then(() => {
+      triggerRef?.focus();
+    });
+  }
+
+  function onSelect(value: Value, wasSelected: boolean, close: boolean = true) {
+    if (!wasSelected) values = [...values, value];
+    else values = values.filter((v) => v !== value);
+    if (close) closeAndFocusTrigger();
+  }
+
+  let filterValue = $state('');
+
+  const filteredOptions = $derived.by(() => {
+    const filterValueLower = filterValue.toLocaleLowerCase();
+    return options.filter((option) => {
+      const label = toLabel(option).toLocaleLowerCase();
+      return label.includes(filterValueLower);
+    });
+  });
+</script>
+
+{#snippet trigger({ props }: { props: Record<string, unknown> })}
+  <Button bind:ref={triggerRef} variant="outline" {...props} role="combobox" aria-expanded={open} class="w-full h-auto">
+    <div class="flex flex-wrap justify-start gap-2">
+      {#each values as value}
+        <Badge>
+          {toLabel(value)}
+        </Badge>
+      {:else}
+        <span class="text-muted-foreground">
+          {placeholder}
+          &nbsp; <!-- ensures baseline alignment works for consumers of this component -->
+        </span>
+      {/each}
+    </div>
+    <div class="grow"></div>
+    <Icon icon="i-mdi-chevron-down" class="mr-2 size-5 shrink-0 opacity-50" />
+  </Button>
+{/snippet}
+
+{#snippet command()}
+  <Command shouldFilter={false}>
+    <CommandInput bind:value={filterValue} autofocus placeholder={filterPlaceholder ?? 'Filter...'}>
+      <Button variant="ghost" size="sm-icon" onclick={() => (open = false)} aria-label={$t`Close`}>
+        <Icon icon="i-mdi-close" />
+      </Button>
+    </CommandInput>
+    <CommandList class="h-[300px] md:max-h-[50vh]">
+      <CommandEmpty>{resultsPlaceholder ?? $t`No items found`}</CommandEmpty>
+      <CommandGroup>
+        {#each filteredOptions as value}
+          {@const label = toLabel(value)}
+          {@const selected = values.includes(value)}
path: frontend/viewer/src/lib/components/field-editors/multi-select.svelte
user: login: hahn-kev
body: I think we need to be able to specify the ID here, otherwise this will only work if each `value` in `values` is the same instances as the objects in `options`
created_at: 2025-04-04T07:56:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,121 @@
+<script lang="ts" generics="Value">
+  import { Badge } from '$lib/components/ui/badge';
+  import { Button } from '$lib/components/ui/button';
+  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover';
+  import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+  import { tick } from 'svelte';
+  import { t } from 'svelte-i18n-lingui';
+  import { Checkbox } from '../ui/checkbox';
+  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
+  import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer';
+  import { Icon } from '../ui/icon';
+
+  let {
+    values = $bindable(),
+    ...constProps
+  }: {
+    values: Value[];
+    options: Value[];
+    toLabel: (value: Value) => string;
+    placeholder?: string;
+    filterPlaceholder?: string;
+    resultsPlaceholder?: string;
+  } = $props();
+
+  const { options, toLabel, placeholder, filterPlaceholder, resultsPlaceholder } = constProps;
path: frontend/viewer/src/lib/components/field-editors/multi-select.svelte
user: login: hahn-kev
body: this doesn't actually make them const though does it, we'll just ignore any changes? I'm not sure there is a way to prevent changes to props.
created_at: 2025-04-04T07:58:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,121 @@
+<script lang="ts" generics="Value">
+  import { Badge } from '$lib/components/ui/badge';
+  import { Button } from '$lib/components/ui/button';
+  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover';
+  import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+  import { tick } from 'svelte';
+  import { t } from 'svelte-i18n-lingui';
+  import { Checkbox } from '../ui/checkbox';
+  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
+  import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer';
+  import { Icon } from '../ui/icon';
+
+  let {
+    values = $bindable(),
+    ...constProps
+  }: {
+    values: Value[];
+    options: Value[];
+    toLabel: (value: Value) => string;
+    placeholder?: string;
+    filterPlaceholder?: string;
+    resultsPlaceholder?: string;
+  } = $props();
+
+  const { options, toLabel, placeholder, filterPlaceholder, resultsPlaceholder } = constProps;
path: frontend/viewer/src/lib/components/field-editors/multi-select.svelte
user: login: myieye
body: It's common to do `const { ... } = $props()`, because/so that the component doesn't write to non-bindeable props. But you can't use `const` if you have a bindeable prop, because the component needs to write to it (`values` in this case). By having 2 declarations, I can still define everything else as const.

But this way buggy. I needed `$derived(constProps)`.
created_at: 2025-04-07T14:26:11Z
author_association: COLLABORATOR
diff_hunk: @@ -67,11 +75,33 @@
   let richString: IRichString = $state({
     spans: [{text: 'Hello', ws: 'en'}, {text: ' World', ws: 'js'}, {text: ` type ${lineSeparator}script`, ws: 'ts'}],
   });
+
+  const allDomains = $state<{label: string}[]>([
+    { label: 'fruit' }, { label: 'tree' }, { label: 'stars' }, { label: 'earth' },
+  ]);
+  for (let i = 0; i < 100; i++) {
+    allDomains.push({
+      label: allDomains[Math.floor(Math.random() * allDomains.length)].label + '-' + i
+    });
+    allDomains.sort((a, b) => a.label.localeCompare(b.label));
+  }
+
path: frontend/viewer/src/lib/sandbox/Sandbox.svelte
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Optimize array population by sorting once after the loop.**  
Pushing items in the loop and calling `allDomains.sort(...)` each time yields unnecessary repeated sorting (100 times). For performance, consider sorting once after pushing all items:  
```diff
  for (let i = 0; i < 100; i++) {
    allDomains.push({
      label: allDomains[Math.floor(Math.random() * allDomains.length)].label + '-' + i
    });
-   allDomains.sort((a, b) => a.label.localeCompare(b.label));
  }
+ allDomains.sort((a, b) => a.label.localeCompare(b.label));
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  const allDomains = $state<{label: string}[]>([
    { label: 'fruit' }, { label: 'tree' }, { label: 'stars' }, { label: 'earth' },
  ]);
  for (let i = 0; i < 100; i++) {
    allDomains.push({
      label: allDomains[Math.floor(Math.random() * allDomains.length)].label + '-' + i
    });
  }
  allDomains.sort((a, b) => a.label.localeCompare(b.label));
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-08T15:35:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,121 @@
+<script lang="ts" generics="Value">
+  import { Badge } from '$lib/components/ui/badge';
+  import { Button } from '$lib/components/ui/button';
+  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover';
+  import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+  import { tick } from 'svelte';
+  import { t } from 'svelte-i18n-lingui';
+  import { Checkbox } from '../ui/checkbox';
+  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
+  import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer';
+  import { Icon } from '../ui/icon';
+
+  let {
+    values = $bindable(),
+    ...constProps
+  }: {
+    values: Value[];
+    options: Value[];
+    toLabel: (value: Value) => string;
+    placeholder?: string;
+    filterPlaceholder?: string;
+    resultsPlaceholder?: string;
+  } = $props();
+
+  const { options, toLabel, placeholder, filterPlaceholder, resultsPlaceholder } = constProps;
path: frontend/viewer/src/lib/components/field-editors/multi-select.svelte
user: login: hahn-kev
body: I see, so this is expressing to the component that these values (eg toLabel) are read only. I think the confusing thing is that I would assume since toLabel is const, that it should never change after the component is created, but that's not true as the component user could change that value after the fact. But I guess that's also the case with `const { ... } = $props()` too.
created_at: 2025-04-09T05:44:42Z
author_association: COLLABORATOR
path: frontend/viewer/src/lib/components/ui/drawer/drawer.svelte
user: login: hahn-kev
body: I wonder if we could pull the changes you made here into a helper which we can use to track state in history and undo it.
created_at: 2025-04-09T05:49:24Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,52 @@
+<script lang="ts">
+  import {t} from 'svelte-i18n-lingui';
+  import FieldHelpIcon from '../../entry-editor/FieldHelpIcon.svelte';
+  import {useCurrentView} from '$lib/views/view-service';
+
+  const {
+    liteName,
+    classicName,
+    helpId,
+  }: {
+    liteName: string;
+    classicName: string;
+    helpId: string | undefined;
+  } = $props();
+
+  const view = useCurrentView();
+
+  const { label, title } = $derived(
+    $view.type === 'fw-classic'
+      ? {
+          label: classicName,
+          title: $t`${liteName} (FieldWorks Lite)`,
+        }
+      : {
+          label: liteName,
+          title: $t`${classicName} (FieldWorks)`,
+        },
+  );
+
+  // kind of crazy, but I don't think Svelte's white-space handling let's us use &nbsp; between the label and help icon
path: frontend/viewer/src/lib/components/field-editors/field-title.svelte
user: login: hahn-kev
body: not sure I understand this comment or how it relates. If you want to insert a non breaking space you could just do this `const nonBreakingSpace = '\u00A0';` and in the html `{nonBreakingSpace}` and svelte won't remove that.
created_at: 2025-04-09T05:58:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,52 @@
+<script lang="ts">
+  import {t} from 'svelte-i18n-lingui';
+  import FieldHelpIcon from '../../entry-editor/FieldHelpIcon.svelte';
+  import {useCurrentView} from '$lib/views/view-service';
+
+  const {
+    liteName,
+    classicName,
+    helpId,
+  }: {
+    liteName: string;
+    classicName: string;
+    helpId: string | undefined;
+  } = $props();
+
+  const view = useCurrentView();
+
+  const { label, title } = $derived(
+    $view.type === 'fw-classic'
+      ? {
+          label: classicName,
+          title: $t`${liteName} (FieldWorks Lite)`,
+        }
+      : {
+          label: liteName,
+          title: $t`${classicName} (FieldWorks)`,
+        },
+  );
+
+  // kind of crazy, but I don't think Svelte's white-space handling let's us use &nbsp; between the label and help icon
path: frontend/viewer/src/lib/components/field-editors/field-title.svelte
user: login: myieye
body: I believe `&nbsp;` is the "better" way to use `\u00A0` in HTML.
The problem is that there's no way to make `&nbsp;` the **only** space character between the label and the inline icon. The fact that the icon is a seperate component and that there's an `if` involved etc. means there's whitespace that's effectively outsided our control.
created_at: 2025-04-09T09:10:29Z
author_association: COLLABORATOR
path: frontend/viewer/src/lib/components/ui/drawer/drawer.svelte
user: login: myieye
body: I considered that and deliberately left it here for now.
I think we should do that when we see where else we need it.
created_at: 2025-04-09T09:11:28Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,52 @@
+<script lang="ts">
+  import {t} from 'svelte-i18n-lingui';
+  import FieldHelpIcon from '../../entry-editor/FieldHelpIcon.svelte';
+  import {useCurrentView} from '$lib/views/view-service';
+
+  const {
+    liteName,
+    classicName,
+    helpId,
+  }: {
+    liteName: string;
+    classicName: string;
+    helpId: string | undefined;
+  } = $props();
+
+  const view = useCurrentView();
+
+  const { label, title } = $derived(
+    $view.type === 'fw-classic'
+      ? {
+          label: classicName,
+          title: $t`${liteName} (FieldWorks Lite)`,
+        }
+      : {
+          label: liteName,
+          title: $t`${classicName} (FieldWorks)`,
+        },
+  );
+
+  // kind of crazy, but I don't think Svelte's white-space handling let's us use &nbsp; between the label and help icon
path: frontend/viewer/src/lib/components/field-editors/field-title.svelte
user: login: myieye
body: Actually, I sort of pinned it down to the fact that icons are `display: inline-block`, which seems to make `&nbsp;` not work.
So, I still don't know how else to do it except the way I have it.
created_at: 2025-04-09T13:08:37Z
author_association: COLLABORATOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-03T18:53:42Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto incremental reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough
This pull request updates various aspects of the project. The dependency version for `bits-ui` is fixed to 1.3.16, and CSS grid layouts have been adjusted with new and removed classes. Several new Svelte components for field titles and multi-select functionality have been added while existing UI components (button, checkbox, command input, drawer) receive enhancements and additional properties. Furthermore, type safety improvements are made in the entry editor, and sandbox/view configurations are extended with new state variables and metadata. Lastly, the project dropdown behavior is modified to no longer auto-close upon selection.

## Changes

| File(s) | Change Summary |
|---------|----------------|
| `frontend/viewer/package.json` | Updated `bits-ui` version from `^1.3.10` to fixed `1.3.16`. |
| `frontend/viewer/src/app.postcss` | Modified grid layout classes: removed `.xs-form`, added `.lg-form`, updated `.editor-grid`, and introduced new classes (`.field-root`, `.field-title`, `.field-body`). |
| `frontend/viewer/src/lib/components/field-editors/…` | Added new Svelte components: `field-title.svelte` (for field titles with i18n and help icons) and `multi-select.svelte` (multi-select dropdown with filtering and badges). |
| `frontend/viewer/src/lib/components/ui/…` | Enhanced UI components: in `button.svelte` added an `'xs-icon'` variant; in `checkbox.svelte` changed class from `size-4` to `size-full`; in `command-input.svelte` added a `children` prop; in `drawer-content.svelte` added a `handle` prop; in `drawer.svelte` integrated history and mobile back handling. |
| `frontend/viewer/src/lib/entry-editor/…` | Improved type safety: updated `FieldTitle.svelte` imports and added type declarations in `field-data.ts`, replacing ambiguous types with stricter definitions (`FieldId` vs. `FieldIds`). |
| `frontend/viewer/src/lib/sandbox/…` and `frontend/viewer/src/lib/views/view-data.ts` | Extended sandbox and view configurations: added `type` properties to view objects, introduced new state variables in `Sandbox.svelte` (including semantic domain management), and defined new view constants (`FW_LITE_VIEW` and `FW_CLASSIC_VIEW`). |
| `frontend/viewer/src/project/ProjectDropdown.svelte` | Removed the automatic dropdown closure on selection by eliminating the `open = false;` line in the handler. |

## Poem
> I'm a hoppy coder rabbit,  
> Skipping through grids and fields so bright,  
> New components and tweaks in every byte,  
> With carrots of code and a playful delight,  
> I nibble on changes from morning till night!

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=sillsdev/languageforge-lexbox&utm_content=1575):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-03T18:54:34Z
body: ## UI unit Tests
12 tests   12 :white_check_mark:  0s :stopwatch:
 4 suites   0 :zzz:
 1 files     0 :x:

Results for commit 443e7102.

[test-results]:data:application/gzip;base64,H4sIAMl09mcC/12MSQ7DIBAEv2JxzsEswZDPWKzSKLaJWE5W/h5CNpzbVE2rduRhcQldBnwaUCqQG7AKtkSVIWwVx4r1kduOfGBOxZg/c4Vbv5+9guUgXIwhvk0s27f4vA/Bl/j1Gne5xn3NhHWFXAExRt2ER6Ilt5Yy684SCz5JTKkkzgqhtPSaC3R/ALbr3rf/AAAA

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-03T18:56:12Z
body: ## C# Unit Tests
112 tests  ±0   112 :white_check_mark: ±0   6s :stopwatch: ±0s
 17 suites ±0     0 :zzz: ±0 
  1 files   ±0     0 :x: ±0 

Results for commit 443e7102. ± Comparison against base commit fc1a7985.

[test-results]:data:application/gzip;base64,H4sIAC119mcC/13MOQ7CMBRF0a1EriniAQ9sBnmULJIYeagQe+fjAEkoz33Se6AQJ1/QZcCnAZUW6woBci3rGtMC5kBYat8w+epamrX/6RbvkMZfCDpOh+BzTvlTclu2zzeOl2vZHrt3h937P5vmOVYAYox6gUdiFHeOMufPCksuFKZUEe+k1EYFwyV6vgC7TkvDBAEAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: hahn-kev
created_at: 2025-04-09T05:39:43Z
body: Looks great! One issue I noticed in dark mode there's a bg-popover on the command which in dark mode looks like this on the mobile view:
![image](https://github.com/user-attachments/assets/b7aab2d8-d837-4892-8fce-459383b7ad0d)

notice the grey background only under the command area. I think it would look fine without that background:
![image](https://github.com/user-attachments/assets/9560c54c-1c06-49db-b447-cf94dfc5f260)

or if there was no rounding on the corners:
![image](https://github.com/user-attachments/assets/1426ea60-7080-411a-8d3d-e3a53db96c19)

but right now it feels cramped with the background having rounded corners.

Otherwise it looks great! Nice work
author_association: COLLABORATOR
user: login: myieye
created_at: 2025-04-09T13:22:03Z
body: Regarding the weird background color:
It turns out that only for green and rose in **dark** mode `--popover != --background`.
That sort of seems like an oversight, so I opted to solve the problem by changing --popover in those 2 themes.
author_association: COLLABORATOR