Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add svelte runes
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago
48 files changed +933 -518 737be793 777430ab
modified src/App.svelte
@@ -59,7 +59,7 @@
    }
  });

-
  $: document.documentElement.setAttribute("data-theme", $theme);
+
  $effect(() => document.documentElement.setAttribute("data-theme", $theme));
</script>

{#if $activeRouteStore.resource === "booting"}
modified src/components/Avatar.svelte
@@ -1,7 +1,11 @@
<script lang="ts">
  import { createIcon } from "@app/lib/blockies";

-
  export let publicKey: string;
+
  const {
+
    publicKey,
+
  }: {
+
    publicKey: string;
+
  } = $props();

  function createContainer(source: string) {
    const seed = source.toLowerCase();
modified src/components/Border.svelte
@@ -1,23 +1,40 @@
<script lang="ts">
  import type { Snippet } from "svelte";

-
  export let children: Snippet;
-
  export let variant: "primary" | "secondary" | "ghost" | "float" | "danger";
-
  export let hoverable: boolean = false;
-
  export let onclick: (() => void) | undefined = undefined;
-

-
  export let stylePadding: string | undefined = undefined;
-
  export let styleHeight: string | undefined = undefined;
-
  export let styleMinHeight: string | undefined = undefined;
-
  export let styleWidth: string | undefined = undefined;
-
  export let styleCursor: "default" | "pointer" | "text" = "default";
-
  export let styleGap: string = "0.5rem";
-
  export let styleOverflow: string | undefined = undefined;
-
  export let flatTop: boolean = false;
-

-
  $: style =
+
  interface Props {
+
    children: Snippet;
+
    variant: "primary" | "secondary" | "ghost" | "float" | "danger";
+
    hoverable?: boolean;
+
    onclick?: () => void;
+
    stylePadding?: string;
+
    styleHeight?: string;
+
    styleMinHeight?: string;
+
    styleWidth?: string;
+
    styleCursor?: "default" | "pointer" | "text";
+
    styleGap?: string;
+
    styleOverflow?: string;
+
    flatTop?: boolean;
+
  }
+

+
  const {
+
    children,
+
    variant,
+
    hoverable = false,
+
    onclick,
+
    stylePadding,
+
    styleHeight,
+
    styleMinHeight,
+
    styleWidth,
+
    styleCursor = "default",
+
    styleGap = "0.5rem",
+
    styleOverflow,
+
    flatTop = false,
+
  }: Props = $props();
+

+
  const style = $derived(
    `--local-button-color-1: var(--color-fill-${variant});` +
-
    `--local-hover-background-color: ${hoverable ? "var(--color-background-float)" : "var(--color-background-default)"}`;
+
      `--local-hover-background-color: ${hoverable ? "var(--color-background-float)" : "var(--color-background-default)"}`,
+
  );
</script>

<style>
@@ -170,7 +187,7 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
  style:width={styleWidth}
  style:cursor={styleCursor}
modified src/components/Button.svelte
@@ -1,22 +1,33 @@
<script lang="ts">
  import type { Snippet } from "svelte";

-
  export let children: Snippet;
-
  export let variant: "primary" | "secondary" | "ghost";
-
  export let onclick: (() => void) | undefined = undefined;
-

-
  export let disabled: boolean = false;
-
  export let active: boolean = false;
+
  interface Props {
+
    children: Snippet;
+
    variant: "primary" | "secondary" | "ghost";
+
    onclick?: () => void;
+
    disabled?: boolean;
+
    active?: boolean;
+
    flatLeft?: boolean;
+
    flatRight?: boolean;
+
  }

-
  export let flatLeft: boolean = false;
-
  export let flatRight: boolean = false;
+
  const {
+
    children,
+
    variant,
+
    onclick = undefined,
+
    disabled = false,
+
    active = false,
+
    flatLeft = false,
+
    flatRight = false,
+
  }: Props = $props();

-
  $: style =
+
  const style = $derived(
    `--button-color-1: var(--color-fill-${variant});` +
-
    `--button-color-2: var(--color-fill-${variant}-hover);` +
-
    `--button-color-3: var(--color-fill-${variant}-shade);` +
-
    // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
-
    `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`;
+
      `--button-color-2: var(--color-fill-${variant}-hover);` +
+
      `--button-color-3: var(--color-fill-${variant}-shade);` +
+
      // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
+
      `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`,
+
  );
</script>

<style>
@@ -347,7 +358,7 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
  class="container active"
  style:cursor={!disabled ? "pointer" : "default"}
modified src/components/Comment.svelte
@@ -18,26 +18,41 @@
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";

-
  export let actions: Snippet | undefined = undefined;
-
  export let id: string | undefined = undefined;
-
  export let rid: string;
-
  export let author: Author;
-
  export let body: string;
-
  export let reactions: Reaction[] | undefined = undefined;
-
  export let embeds: Map<string, Embed> | undefined = undefined;
-
  export let caption = "commented";
-
  export let timestamp: number;
-
  export let lastEdit: Edit | undefined = undefined;
-
  export let disallowEmptyBody: boolean = false;
+
  interface Props {
+
    actions?: Snippet;
+
    id?: string;
+
    rid: string;
+
    author: Author;
+
    body: string;
+
    reactions?: Reaction[];
+
    embeds?: Map<string, Embed>;
+
    caption?: string;
+
    timestamp: number;
+
    lastEdit?: Edit;
+
    disallowEmptyBody?: boolean;
+
    editComment?: (body: string, embeds: Embed[]) => Promise<void>;
+
    reactOnComment?: (authors: Author[], reaction: string) => Promise<void>;
+
  }

-
  export let editComment:
-
    | ((body: string, embeds: Embed[]) => Promise<void>)
-
    | undefined = undefined;
-
  export let reactOnComment:
-
    | ((authors: Author[], reaction: string) => Promise<void>)
-
    | undefined = undefined;
+
  /* eslint-disable prefer-const */
+
  let {
+
    actions,
+
    id,
+
    rid,
+
    author,
+
    body = $bindable(),
+
    reactions,
+
    embeds,
+
    caption = "commented",
+
    timestamp,
+
    lastEdit,
+
    disallowEmptyBody = false,
+
    editComment,
+
    reactOnComment,
+
  }: Props = $props();
+
  /* eslint-enable prefer-const */

-
  let state: "read" | "edit" | "submit" = "read";
+
  let state: "read" | "edit" | "submit" = $state("read");

  async function toggleEdit() {
    if (state === "read") {
@@ -136,7 +151,7 @@
            popoverPositionRight="0"
            popoverPositionBottom="1.5rem"
            {reactions}
-
            on:select={async ({ detail: { authors, emoji } }) => {
+
            select={async ({ authors, emoji }) => {
              try {
                await reactOnComment(authors, emoji);
              } finally {
@@ -169,7 +184,7 @@
            submitInProgress={state === "submit"}
            submitCaption="Save"
            placeholder="Leave a comment"
-
            on:submit={async ({ detail: { comment, embeds } }) => {
+
            submit={async ({ comment, embeds }) => {
              state = "submit";
              try {
                await editComment(comment, Array.from(embeds.values()));
@@ -177,7 +192,7 @@
                state = "read";
              }
            }}
-
            on:close={async () => {
+
            close={async () => {
              body = body;
              await tick();
              state = "read";
@@ -199,7 +214,7 @@
          popoverPositionLeft="0"
          popoverPositionBottom="1.5rem"
          {reactions}
-
          on:select={async ({ detail: { authors, emoji } }) => {
+
          select={async ({ authors, emoji }) => {
            try {
              await reactOnComment(authors, emoji);
            } finally {
modified src/components/CommentToggleInput.svelte
@@ -4,17 +4,33 @@
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import Border from "./Border.svelte";

-
  export let rid: string;
-
  export let placeholder: string | undefined = undefined;
-
  export let focus: boolean = false;
-
  export let submit: (comment: string, embeds: Embed[]) => Promise<void>;
-
  export let onclose: (() => void) | undefined = undefined;
-
  export let onexpand: (() => void) | undefined = undefined;
-
  export let disallowEmptyBody: boolean = false;
+
  interface Props {
+
    rid: string;
+
    placeholder?: string;
+
    focus?: boolean;
+
    submit: (comment: string, embeds: Embed[]) => Promise<void>;
+
    onclose?: () => void;
+
    onexpand?: () => void;
+
    disallowEmptyBody?: boolean;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let {
+
    rid,
+
    placeholder,
+
    focus = false,
+
    submit,
+
    onclose,
+
    onexpand,
+
    disallowEmptyBody = false,
+
  }: Props = $props();
+
  /* eslint-enable prefer-const */

-
  let state: "collapsed" | "expanded" | "submit";
+
  let state: "collapsed" | "expanded" | "submit" | undefined = $state();

-
  $: state = onclose !== undefined ? "expanded" : "collapsed";
+
  $effect(() => {
+
    state = onclose !== undefined ? "expanded" : "collapsed";
+
  });
</script>

<style>
@@ -33,14 +49,14 @@
    submitInProgress={state === "submit"}
    {focus}
    stylePadding="0.5rem 0.75rem"
-
    on:close={() => {
+
    close={() => {
      if (onclose !== undefined) {
        onclose();
      } else {
        state = "collapsed";
      }
    }}
-
    on:submit={async ({ detail: { comment, embeds } }) => {
+
    submit={async ({ comment, embeds }) => {
      try {
        state = "submit";
        await submit(comment, Array.from(embeds.values()));
modified src/components/CopyableId.svelte
@@ -6,9 +6,13 @@

  import Icon from "./Icon.svelte";

-
  export let id: string;
+
  const {
+
    id,
+
  }: {
+
    id: string;
+
  } = $props();

-
  let icon: ComponentProps<Icon>["name"] = "copy";
+
  let icon: ComponentProps<typeof Icon>["name"] = $state("copy");

  const restoreIcon = debounce(() => {
    icon = "copy";
modified src/components/DiffStatBadge.svelte
@@ -1,7 +1,11 @@
<script lang="ts">
  import type { Stats } from "@bindings/cob/Stats";

-
  export let stats: Stats;
+
  interface Props {
+
    stats: Stats;
+
  }
+

+
  const { stats }: Props = $props();
</script>

<style>
modified src/components/DropdownList.svelte
@@ -1,10 +1,14 @@
<script lang="ts" generics="T">
  import type { Snippet } from "svelte";

-
  export let item: Snippet<[T]>;
-
  export let empty: Snippet | undefined = undefined;
-
  export let items: T[];
-
  export let styleDropdownMinWidth: string | undefined = undefined;
+
  interface Props {
+
    item: Snippet<[T]>;
+
    empty?: Snippet;
+
    items: T[];
+
    styleDropdownMinWidth?: string;
+
  }
+

+
  const { item, empty, items, styleDropdownMinWidth }: Props = $props();
</script>

<style>
modified src/components/DropdownListItem.svelte
@@ -1,11 +1,23 @@
<script lang="ts">
  import type { Snippet } from "svelte";

-
  export let children: Snippet;
-
  export let selected: boolean;
-
  export let disabled: boolean = false;
-
  export let title: string | undefined = undefined;
-
  export let style: string | undefined = undefined;
+
  interface Props {
+
    children: Snippet;
+
    selected: boolean;
+
    onclick: () => void;
+
    disabled?: boolean;
+
    title?: string;
+
    style?: string;
+
  }
+

+
  const {
+
    onclick,
+
    children,
+
    selected,
+
    disabled = false,
+
    title,
+
    style,
+
  }: Props = $props();
</script>

<style>
@@ -46,7 +58,7 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
  role="button"
  tabindex="0"
@@ -55,6 +67,6 @@
  class:disabled
  {style}
  {title}
-
  on:click>
+
  {onclick}>
  {@render children()}
</div>
modified src/components/ExtendedTextarea.svelte
@@ -2,8 +2,6 @@
  import type { ComponentProps } from "svelte";
  import type { Embed } from "@bindings/cob/thread/Embed";

-
  import { createEventDispatcher } from "svelte";
-

  import * as utils from "@app/lib/utils";

  import Button from "./Button.svelte";
@@ -12,39 +10,58 @@
  import Textarea from "./Textarea.svelte";
  import OutlineButton from "./OutlineButton.svelte";

-
  export let rid: string;
-
  export let placeholder: string = "Leave your comment";
-
  export let submitCaption: string = "Comment";
-
  export let focus: boolean = false;
-
  export let inline: boolean = false;
-
  export let body: string = "";
-
  export let embeds: Map<string, Embed> = new Map();
-
  export let submitInProgress: boolean = false;
-
  export let disallowEmptyBody: boolean = false;
-
  export let isValid: () => boolean = () => {
-
    return true;
-
  };
-
  export let stylePadding: string | undefined = undefined;
-
  export let borderVariant: ComponentProps<Textarea>["borderVariant"] = "float";
+
  interface Props {
+
    rid: string;
+
    placeholder?: string;
+
    submitCaption?: string;
+
    focus?: boolean;
+
    inline?: boolean;
+
    body?: string;
+
    embeds?: Map<string, Embed>;
+
    submitInProgress?: boolean;
+
    disallowEmptyBody?: boolean;
+
    isValid?: () => boolean;
+
    stylePadding?: string;
+
    borderVariant?: ComponentProps<typeof Textarea>["borderVariant"];
+
    submit: (opts: {
+
      comment: string;
+
      embeds: Map<string, Embed>;
+
    }) => Promise<void>;
+
    close: () => void;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let {
+
    rid,
+
    placeholder = "Leave your comment",
+
    submitCaption = "Comment",
+
    focus = false,
+
    inline = false,
+
    body = $bindable(""),
+
    embeds = new Map(),
+
    submitInProgress = false,
+
    disallowEmptyBody = false,
+
    isValid = () => true,
+
    stylePadding,
+
    borderVariant = "float",
+
    submit,
+
    close,
+
  }: Props = $props();
+
  /* eslint-enable prefer-const */

-
  let preview: boolean = false;
-
  let selectionStart = 0;
-
  let selectionEnd = 0;
-
  let inputFiles: FileList | undefined = undefined;
+
  let preview: boolean = $state(false);
+
  let selectionStart = $state(0);
+
  let selectionEnd = $state(0);
+
  let inputFiles: FileList | undefined = $state(undefined);

  const inputId = `input-label-${crypto.randomUUID()}`;

-
  const dispatch = createEventDispatcher<{
-
    submit: {
-
      comment: string;
-
      embeds: Map<string, Embed>;
-
    };
-
    close: null;
-
  }>();
-

-
  function submit() {
-
    dispatch("submit", { comment: body, embeds });
-
    preview = false;
+
  function submitFn() {
+
    void submit({ comment: body, embeds })
+
      .then(() => (preview = false))
+
      .catch(e => {
+
        console.error(e);
+
      });
  }
</script>

@@ -106,7 +123,7 @@
      bind:selectionEnd
      bind:selectionStart
      {focus}
-
      on:submit={submit}
+
      submit={() => submit({ comment: body, embeds })}
      bind:value={body}
      {placeholder} />
  {/if}
@@ -116,7 +133,7 @@
      variant="ghost"
      onclick={() => {
        preview = false;
-
        dispatch("close");
+
        close();
      }}>
      <Icon name="cross" />Discard
    </OutlineButton>
@@ -138,7 +155,7 @@
        disabled={!isValid() ||
          submitInProgress ||
          (disallowEmptyBody && body.trim() === "")}
-
        onclick={submit}>
+
        onclick={submitFn}>
        <Icon name="checkmark" />
        {#if submitInProgress}
          Saving…
modified src/components/Header.svelte
@@ -11,9 +11,13 @@
  import Popover from "./Popover.svelte";
  import ThemeSwitch from "./ThemeSwitch.svelte";

-
  export let breadcrumbs: Snippet;
-
  export let iconLeft: Snippet | undefined = undefined;
-
  export let center: Snippet | undefined = undefined;
+
  interface Props {
+
    breadcrumbs: Snippet;
+
    iconLeft?: Snippet;
+
    center?: Snippet;
+
  }
+

+
  const { breadcrumbs, iconLeft, center }: Props = $props();
</script>

<style>
@@ -90,7 +94,7 @@
        </OutlineButton>
        <Popover popoverPositionRight="0" popoverPositionTop="3rem">
          {#snippet toggle(onclick)}
-
            <NakedButton variant="ghost" {onclick}>
+
            <NakedButton title="Settings" variant="ghost" {onclick}>
              <Icon name="settings" />
            </NakedButton>
          {/snippet}
modified src/components/Icon.svelte
@@ -1,45 +1,53 @@
<script lang="ts">
  import { unreachable } from "@app/lib/utils";

-
  export let size: "16" | "32" = "16";
-
  export let onclick: (() => void) | undefined = undefined;
-
  export let styleCursor: "default" | "pointer" = "default";
+
  interface Props {
+
    size?: "16" | "32";
+
    onclick?: () => void;
+
    styleCursor?: "default" | "pointer";
+
    name:
+
      | "arrow-left"
+
      | "arrow-right"
+
      | "checkmark"
+
      | "chevron-down"
+
      | "chevron-right"
+
      | "comment"
+
      | "copy"
+
      | "cross"
+
      | "dashboard"
+
      | "delegate"
+
      | "diff"
+
      | "eye"
+
      | "face"
+
      | "file"
+
      | "inbox"
+
      | "issue"
+
      | "lock"
+
      | "markdown"
+
      | "moon"
+
      | "more-vertical"
+
      | "offline"
+
      | "online"
+
      | "patch"
+
      | "pen"
+
      | "plus"
+
      | "reply"
+
      | "repo"
+
      | "revision"
+
      | "seedling"
+
      | "seedling-filled"
+
      | "settings"
+
      | "sidebar"
+
      | "sun"
+
      | "warning";
+
  }

-
  export let name:
-
    | "arrow-left"
-
    | "arrow-right"
-
    | "checkmark"
-
    | "chevron-down"
-
    | "chevron-right"
-
    | "comment"
-
    | "copy"
-
    | "cross"
-
    | "dashboard"
-
    | "delegate"
-
    | "diff"
-
    | "eye"
-
    | "face"
-
    | "file"
-
    | "inbox"
-
    | "issue"
-
    | "lock"
-
    | "markdown"
-
    | "moon"
-
    | "more-vertical"
-
    | "offline"
-
    | "online"
-
    | "patch"
-
    | "pen"
-
    | "plus"
-
    | "reply"
-
    | "repo"
-
    | "revision"
-
    | "seedling"
-
    | "seedling-filled"
-
    | "settings"
-
    | "sidebar"
-
    | "sun"
-
    | "warning";
+
  const {
+
    size = "16",
+
    onclick = undefined,
+
    styleCursor = "default",
+
    name,
+
  }: Props = $props();
</script>

<style>
@@ -52,8 +60,8 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
  style:cursor={styleCursor}
  role="img"
modified src/components/Id.svelte
@@ -2,22 +2,15 @@
  import type { ComponentProps, Snippet } from "svelte";

  import { debounce } from "lodash";
-
  import { writeText } from "@tauri-apps/plugin-clipboard-manager";
+
  import { writeToClipboard } from "@app/lib/invoke";

  import { formatOid } from "@app/lib/utils";

  import Icon from "./Icon.svelte";

-
  export let children: Snippet | undefined = undefined;
-
  export let id: string;
-
  export let clipboard: string = id;
-
  export let shorten: boolean = true;
-
  export let variant: "oid" | "commit" | "none";
-
  export let ariaLabel: string | undefined = undefined;
-

-
  let icon: ComponentProps<Icon>["name"] = "copy";
+
  let icon: ComponentProps<typeof Icon>["name"] = $state("copy");
  const text = "Click to copy";
-
  let tooltip = text;
+
  let tooltip = $state(text);

  const restoreIcon = debounce(() => {
    icon = "copy";
@@ -25,14 +18,32 @@
  }, 1000);

  async function copy() {
-
    await writeText(clipboard);
+
    await writeToClipboard(clipboard);
    icon = "checkmark";
    tooltip = "Copied to clipboard";
    restoreIcon();
  }

-
  let visible: boolean = false;
-
  export let debounceTimeout = 50;
+
  let visible: boolean = $state(false);
+
  interface Props {
+
    children?: Snippet;
+
    id: string;
+
    clipboard?: string;
+
    shorten?: boolean;
+
    variant: "oid" | "commit" | "none";
+
    ariaLabel?: string;
+
    debounceTimeout?: number;
+
  }
+

+
  const {
+
    children,
+
    id,
+
    clipboard = id,
+
    shorten = true,
+
    variant,
+
    ariaLabel,
+
    debounceTimeout = 50,
+
  }: Props = $props();

  const setVisible = debounce((value: boolean) => {
    visible = value;
@@ -73,7 +84,7 @@
</style>

<div class="container">
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
  <div
    onmouseenter={() => {
      setVisible(true);
modified src/components/InlineTitle.svelte
@@ -2,9 +2,12 @@
  import dompurify from "dompurify";
  import escape from "lodash/escape";

-
  export let content: string;
-
  export let fontSize: "tiny" | "small" | "regular" | "medium" | "large" =
-
    "small";
+
  interface Props {
+
    content: string;
+
    fontSize?: "tiny" | "small" | "regular" | "medium" | "large";
+
  }
+

+
  const { content, fontSize = "small" }: Props = $props();

  function formatInlineTitle(input: string): string {
    return input.replaceAll(/`([^`]+)`/g, "<code>$1</code>");
modified src/components/IssueMetadata.svelte
@@ -7,7 +7,11 @@
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
  import NodeId from "@app/components/NodeId.svelte";

-
  export let issue: Issue;
+
  interface Props {
+
    issue: Issue;
+
  }
+

+
  const { issue }: Props = $props();
</script>

<style>
modified src/components/IssueStateBadge.svelte
@@ -4,7 +4,11 @@
  import capitalize from "lodash/capitalize";
  import { issueStatusColor } from "@app/lib/utils";

-
  export let state: Issue["state"];
+
  interface Props {
+
    state: Issue["state"];
+
  }
+

+
  const { state }: Props = $props();
</script>

<div
modified src/components/IssueStateButton.svelte
@@ -12,8 +12,13 @@
  import Icon from "@app/components/Icon.svelte";
  import Popover from "@app/components/Popover.svelte";

-
  export let state: State;
-
  export let save: (state: State) => Promise<void>;
+
  const {
+
    save,
+
    ...rest
+
  }: {
+
    state: State;
+
    save: (state: State) => Promise<void>;
+
  } = $props();

  const actions: { caption: string; state: State }[] = [
    { caption: "Reopen", state: { status: "open" } },
@@ -25,7 +30,9 @@
  ];

  // Pick a default for the action button when the issue state changes.
-
  $: selectedAction = state.status === "open" ? actions[1] : actions[0];
+
  let selectedAction = $state(
+
    rest.state.status === "open" ? actions[1] : actions[0],
+
  );
</script>

<style>
@@ -40,7 +47,7 @@
  <Button
    variant="secondary"
    flatRight
-
    onclick={() => void save(selectedAction["state"])}>
+
    onclick={() => void save($state.snapshot(selectedAction["state"]))}>
    {selectedAction["caption"]}
  </Button>

@@ -57,11 +64,12 @@
    {/snippet}
    {#snippet popover()}
      <Border variant="ghost">
-
        <DropdownList items={actions.filter(a => !isEqual(a.state, state))}>
+
        <DropdownList
+
          items={actions.filter(a => !isEqual(a.state, rest.state))}>
          {#snippet item(action)}
            <DropdownListItem
              selected={isEqual(selectedAction, action)}
-
              on:click={() => {
+
              onclick={() => {
                selectedAction = action;
                closeFocused();
              }}>
modified src/components/IssueTeaser.svelte
@@ -14,8 +14,12 @@
  import InlineTitle from "./InlineTitle.svelte";
  import NodeId from "./NodeId.svelte";

-
  export let issue: Issue;
-
  export let rid: string;
+
  interface Props {
+
    issue: Issue;
+
    rid: string;
+
  }
+

+
  const { issue, rid }: Props = $props();
</script>

<style>
modified src/components/IssueTimelineLifecycleAction.svelte
@@ -7,7 +7,11 @@
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
  import NodeId from "@app/components/NodeId.svelte";

-
  export let operation: Extract<Operation, { type: "lifecycle" }>;
+
  interface Props {
+
    operation: Extract<Operation, { type: "lifecycle" }>;
+
  }
+

+
  const { operation }: Props = $props();
</script>

<Border variant="float" stylePadding="1rem">
modified src/components/Link.svelte
@@ -4,10 +4,19 @@

  import { push, routeToPath } from "@app/lib/router";

-
  export let children: Snippet;
-
  export let route: Route;
-
  export let disabled: boolean = false;
-
  export let variant: "active" | "regular" | "tab" = "regular";
+
  interface Props {
+
    children: Snippet;
+
    route: Route;
+
    disabled?: boolean;
+
    variant?: "active" | "regular" | "tab";
+
  }
+

+
  const {
+
    children,
+
    route,
+
    disabled = false,
+
    variant = "regular",
+
  }: Props = $props();

  function navigateToRoute(event: MouseEvent): void {
    event.preventDefault();
modified src/components/Markdown.svelte
@@ -1,34 +1,35 @@
<script lang="ts">
  import dompurify from "dompurify";
  import matter from "@radicle/gray-matter";
-
  import { afterUpdate } from "svelte";
  import { toDom } from "hast-util-to-dom";

  import { Renderer, markdownWithExtensions } from "@app/lib/markdown";
  import { highlight } from "@app/lib/syntax";
  import { twemoji, scrollIntoView, isCommit } from "@app/lib/utils";
  import { invoke } from "@app/lib/invoke";
+
  import { tick } from "svelte";

-
  export let rid: string;
-
  export let content: string;
-
  // If true, add <br> on a single line break
-
  export let breaks: boolean = false;
+
  interface Props {
+
    rid: string;
+
    content: string;
+
    // If true, add <br> on a single line break
+
    breaks?: boolean;
+
  }
+

+
  const { rid, content, breaks = false }: Props = $props();

  let container: HTMLElement;
-
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  let frontMatter: [string, any][] | undefined = undefined;

-
  $: {
+
  const doc = $derived(matter(content));
+
  const frontMatter = $derived.by(() => {
    try {
-
      const doc = matter(content);
-
      content = doc.content;
-
      frontMatter = Object.entries(doc.data).filter(
+
      return Object.entries(doc.data).filter(
        ([, val]) => typeof val === "string" || typeof val === "number",
      );
    } catch (error) {
      console.error("Not able to parse frontmatter: ", error);
    }
-
  }
+
  });

  function render(content: string): string {
    return dompurify.sanitize(
@@ -39,85 +40,86 @@
    );
  }

-
  afterUpdate(async () => {
-
    for (const e of container.querySelectorAll("a")) {
-
      try {
-
        const url = new URL(e.href);
-
        if (url.origin !== window.origin) {
-
          e.target = "_blank";
+
  $effect(() => {
+
    void tick().then(() => {
+
      for (const e of container.querySelectorAll("a")) {
+
        try {
+
          const url = new URL(e.href);
+
          if (url.origin !== window.origin) {
+
            e.target = "_blank";
+
          }
+
        } catch (e) {
+
          console.warn("Not able to parse url", e);
+
        }
+
        // Don't underline <a> tags that contain images.
+
        // Make an exception for emojis.
+
        if (
+
          e.firstElementChild instanceof HTMLImageElement &&
+
          !e.firstElementChild.classList.contains("txt-emoji")
+
        ) {
+
          e.classList.add("no-underline");
        }
-
      } catch (e) {
-
        console.warn("Not able to parse url", e);
-
      }
-
      // Don't underline <a> tags that contain images.
-
      // Make an exception for emojis.
-
      if (
-
        e.firstElementChild instanceof HTMLImageElement &&
-
        !e.firstElementChild.classList.contains("txt-emoji")
-
      ) {
-
        e.classList.add("no-underline");
      }
-
    }
-

-
    // Replace standard HTML checkboxes with our custom radicle-icon-small element
-
    for (const i of container.querySelectorAll('input[type="checkbox"]')) {
-
      i.parentElement?.classList.add("task-item");
-

-
      const checkbox = document.createElement("radicle-icon-small");
-
      const checked = i.getAttribute("checked");
-
      checkbox.setAttribute(
-
        "name",
-
        checked === null ? "checkbox-unchecked" : "checkbox-checked",
-
      );
-
      i.insertAdjacentElement("beforebegin", checkbox);
-
      i.remove();
-
    }
-

-
    // Iterate over all images, and replace the source with a canonicalized URL
-
    // pointing at the repos /raw endpoint.
-
    for (const i of container.querySelectorAll("img")) {
-
      const imagePath = i.getAttribute("src");

-
      // If the image is an oid embed
-
      if (imagePath && isCommit(imagePath)) {
-
        const base64Content = await invoke<string>("get_file_by_oid", {
-
          rid,
-
          oid: imagePath,
-
        });
-

-
        i.setAttribute("src", `data:image/jpeg;base64,${base64Content}`);
-
        continue;
+
      // Replace standard HTML checkboxes with our custom radicle-icon-small element
+
      for (const i of container.querySelectorAll('input[type="checkbox"]')) {
+
        i.parentElement?.classList.add("task-item");
+

+
        const checkbox = document.createElement("radicle-icon-small");
+
        const checked = i.getAttribute("checked");
+
        checkbox.setAttribute(
+
          "name",
+
          checked === null ? "checkbox-unchecked" : "checkbox-checked",
+
        );
+
        i.insertAdjacentElement("beforebegin", checkbox);
+
        i.remove();
      }
-
    }
-

-
    // Replaces code blocks in the background with highlighted code.
-
    const prefix = "language-";
-
    const nodes = Array.from(document.body.querySelectorAll("pre code"));

-
    const treeChanges: Promise<void>[] = [];
+
      // Iterate over all images, and replace the source with a canonicalized URL
+
      // pointing at the repos /raw endpoint.
+
      for (const i of container.querySelectorAll("img")) {
+
        const imagePath = i.getAttribute("src");
+

+
        // If the image is an oid embed
+
        if (imagePath && isCommit(imagePath)) {
+
          void invoke<string>("get_file_by_oid", {
+
            rid,
+
            oid: imagePath,
+
          }).then(base64Content =>
+
            i.setAttribute("src", `data:image/jpeg;base64,${base64Content}`),
+
          );
+
        }
+
      }

-
    for (const node of nodes) {
-
      const className = Array.from(node.classList).find(name =>
-
        name.startsWith(prefix),
-
      );
-
      if (!className) continue;
-

-
      treeChanges.push(
-
        highlight(node.textContent ?? "", className.slice(prefix.length))
-
          .then(tree => {
-
            if (tree) {
-
              node.replaceChildren(toDom(tree, { fragment: true }));
-
            }
-
          })
-
          .catch(e => console.warn("Not able to highlight code block", e)),
-
      );
-
    }
+
      // Replaces code blocks in the background with highlighted code.
+
      const prefix = "language-";
+
      const nodes = Array.from(document.body.querySelectorAll("pre code"));
+

+
      const treeChanges: Promise<void>[] = [];
+

+
      for (const node of nodes) {
+
        const className = Array.from(node.classList).find(name =>
+
          name.startsWith(prefix),
+
        );
+
        if (!className) continue;
+

+
        treeChanges.push(
+
          highlight(node.textContent ?? "", className.slice(prefix.length))
+
            .then(tree => {
+
              if (tree) {
+
                node.replaceChildren(toDom(tree, { fragment: true }));
+
              }
+
            })
+
            .catch(e => console.warn("Not able to highlight code block", e)),
+
        );
+
      }

-
    await Promise.allSettled(treeChanges);
+
      void Promise.allSettled(treeChanges);

-
    if (window.location.hash) {
-
      scrollIntoView(window.location.hash.substring(1));
-
    }
+
      if (window.location.hash) {
+
        scrollIntoView(window.location.hash.substring(1));
+
      }
+
    });
  });
</script>

@@ -385,17 +387,18 @@
{#if frontMatter && frontMatter.length > 0}
  <div class="front-matter">
    <table>
-
      {#each frontMatter as [key, val]}
-
        <!-- svelte-ignore node_invalid_placement_ssr -->
-
        <tr>
-
          <td><span class="txt-bold">{key}</span></td>
-
          <td>{val}</td>
-
        </tr>
-
      {/each}
+
      <tbody>
+
        {#each frontMatter as [key, val]}
+
          <tr>
+
            <td><span class="txt-bold">{key}</span></td>
+
            <td>{val}</td>
+
          </tr>
+
        {/each}
+
      </tbody>
    </table>
  </div>
{/if}

<div class="markdown" bind:this={container} use:twemoji={{ exclude: ["21a9"] }}>
-
  {@html render(content)}
+
  {@html render(doc.content)}
</div>
modified src/components/NakedButton.svelte
@@ -1,16 +1,22 @@
<script lang="ts">
  import type { Snippet } from "svelte";

-
  export let children: Snippet;
-
  export let variant: "primary" | "secondary" | "ghost";
-
  export let onclick: (() => void) | undefined = undefined;
+
  interface Props {
+
    children: Snippet;
+
    title?: string;
+
    variant: "primary" | "secondary" | "ghost";
+
    onclick?: () => void;
+
  }
+

+
  const { children, title, variant, onclick }: Props = $props();

-
  $: style =
+
  const style = $derived(
    `--button-color-1: var(--color-fill-${variant});` +
-
    `--button-color-2: var(--color-fill-${variant}-hover);` +
-
    `--button-color-3: var(--color-fill-${variant}-shade);` +
-
    // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
-
    `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`;
+
      `--button-color-2: var(--color-fill-${variant}-hover);` +
+
      `--button-color-3: var(--color-fill-${variant}-shade);` +
+
      // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
+
      `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`,
+
  );
</script>

<style>
@@ -197,8 +203,8 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div class="container" {onclick} role="button" tabindex="0" {style}>
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div class="container" {onclick} {title} role="button" tabindex="0" {style}>
  <div class="pixel p1-1"></div>
  <div class="pixel p1-2"></div>
  <div class="pixel p1-3"></div>
modified src/components/NodeId.svelte
@@ -3,12 +3,19 @@

  import Avatar from "./Avatar.svelte";

-
  export let publicKey: string;
-
  export let alias: string | undefined = undefined;
+
  interface Props {
+
    publicKey: string;
+
    alias?: string;
+
    styleFontSize?: string;
+
    styleFontFamily?: string;
+
  }

-
  export let styleFontSize: string | undefined = "var(--font-size-small)";
-
  export let styleFontFamily: string | undefined =
-
    "var(--font-family-monospace)";
+
  const {
+
    publicKey,
+
    alias,
+
    styleFontSize = "var(--font-size-small)",
+
    styleFontFamily = "var(--font-family-monospace)",
+
  }: Props = $props();
</script>

<style>
modified src/components/OutlineButton.svelte
@@ -1,17 +1,22 @@
<script lang="ts">
  import type { Snippet } from "svelte";

-
  export let children: Snippet;
-
  export let variant: "primary" | "secondary" | "ghost";
-
  export let onclick: (() => void) | undefined = undefined;
-
  export let disabled: boolean = false;
+
  interface Props {
+
    children: Snippet;
+
    variant: "primary" | "secondary" | "ghost";
+
    onclick?: () => void;
+
    disabled?: boolean;
+
  }
+

+
  const { children, variant, onclick, disabled = false }: Props = $props();

-
  $: style =
+
  const style = $derived(
    `--button-color-1: var(--color-fill-${variant});` +
-
    `--button-color-2: var(--color-fill-${variant}-hover);` +
-
    `--button-color-3: var(--color-fill-${variant}-shade);` +
-
    // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
-
    `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`;
+
      `--button-color-2: var(--color-fill-${variant}-hover);` +
+
      `--button-color-3: var(--color-fill-${variant}-shade);` +
+
      // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
+
      `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`,
+
  );
</script>

<style>
@@ -241,7 +246,7 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
  class="container"
  style:cursor={!disabled ? "pointer" : "default"}
modified src/components/PatchTeaser.svelte
@@ -18,7 +18,7 @@
  import NodeId from "./NodeId.svelte";
  import Id from "./Id.svelte";

-
  let stats: Stats | undefined = undefined;
+
  let stats: Stats | undefined = $state(undefined);

  onMount(async () => {
    stats = await invoke<Stats>("diff_stats", {
@@ -28,8 +28,12 @@
    });
  });

-
  export let patch: Patch;
-
  export let rid: string;
+
  interface Props {
+
    patch: Patch;
+
    rid: string;
+
  }
+

+
  const { patch, rid }: Props = $props();
</script>

<style>
modified src/components/Popover.svelte
@@ -10,17 +10,33 @@
<script lang="ts">
  import type { Snippet } from "svelte";

-
  export let toggle: Snippet<[() => void]>;
-
  export let popover: Snippet;
-
  export let popoverContainerMinWidth: string | undefined = undefined;
-
  export let popoverPadding: string | undefined = undefined;
-
  export let popoverPositionBottom: string | undefined = undefined;
-
  export let popoverPositionLeft: string | undefined = undefined;
-
  export let popoverPositionRight: string | undefined = undefined;
-
  export let popoverPositionTop: string | undefined = undefined;
+
  interface Props {
+
    toggle: Snippet<[() => void]>;
+
    popover: Snippet;
+
    popoverContainerMinWidth?: string;
+
    popoverPadding?: string;
+
    popoverPositionBottom?: string;
+
    popoverPositionLeft?: string;
+
    popoverPositionRight?: string;
+
    popoverPositionTop?: string;
+
    expanded?: boolean;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let {
+
    toggle,
+
    popover,
+
    popoverContainerMinWidth,
+
    popoverPadding,
+
    popoverPositionBottom,
+
    popoverPositionLeft,
+
    popoverPositionRight,
+
    popoverPositionTop,
+
    expanded = $bindable(false),
+
  }: Props = $props();
+
  /* eslint-enable prefer-const */

-
  export let expanded = false;
-
  let thisComponent: HTMLDivElement;
+
  let thisComponent: HTMLDivElement | undefined = $state();

  function clickOutside(ev: MouseEvent | TouchEvent) {
    if ($focused && !ev.composedPath().includes($focused)) {
@@ -37,7 +53,9 @@
    }
  }

-
  $: expanded = $focused === thisComponent;
+
  $effect(() => {
+
    expanded = $focused === thisComponent;
+
  });
</script>

<style>
modified src/components/ReactionSelector.svelte
@@ -1,22 +1,27 @@
<script lang="ts">
  import type { Reaction } from "@bindings/cob/Reaction";

-
  import { createEventDispatcher } from "svelte";
-

  import Border from "./Border.svelte";
  import Icon from "./Icon.svelte";
  import Popover from "./Popover.svelte";

-
  export let reactions: Reaction[] | undefined = undefined;
-
  export let popoverPositionBottom: string | undefined = undefined;
-
  export let popoverPositionRight: string | undefined = undefined;
-
  export let popoverPositionLeft: string | undefined = undefined;
+
  interface Props {
+
    reactions?: Reaction[];
+
    popoverPositionBottom?: string;
+
    popoverPositionRight?: string;
+
    popoverPositionLeft?: string;
+
    select: (reaction: Reaction) => Promise<void>;
+
  }
+

+
  const {
+
    reactions,
+
    popoverPositionBottom,
+
    popoverPositionRight,
+
    popoverPositionLeft,
+
    select,
+
  }: Props = $props();

  const availableReactions = ["👍", "👎", "😄", "🎉", "🙁", "🚀", "👀"];
-

-
  const dispatch = createEventDispatcher<{
-
    select: Reaction;
-
  }>();
</script>

<style>
@@ -58,12 +63,8 @@
          )}
          <button
            class:active={Boolean(lookedUpReaction)}
-
            onclick={() => {
-
              dispatch(
-
                "select",
-
                lookedUpReaction || { emoji: reaction, authors: [] },
-
              );
-
            }}>
+
            onclick={() =>
+
              select(lookedUpReaction || { emoji: reaction, authors: [] })}>
            {reaction}
          </button>
        {/each}
modified src/components/Reactions.svelte
@@ -2,10 +2,12 @@
  import type { Author } from "@bindings/cob/Author";
  import type { Reaction } from "@bindings/cob/Reaction";

-
  export let reactions: Reaction[];
-
  export let handleReaction:
-
    | ((authors: Author[], reaction: string) => Promise<void>)
-
    | undefined;
+
  interface Props {
+
    reactions: Reaction[];
+
    handleReaction?: (authors: Author[], reaction: string) => Promise<void>;
+
  }
+

+
  const { reactions, handleReaction }: Props = $props();

  function authorsToTooltip(authors: Author[]) {
    return authors.map(a => a.alias ?? a.did).join("\n");
modified src/components/RepoCard.svelte
@@ -8,11 +8,15 @@
  import RepoHeader from "./RepoHeader.svelte";
  import Id from "./Id.svelte";

-
  export let repo: RepoInfo;
-
  export let selfDid: string;
-
  export let onclick: (() => void) | undefined = undefined;
+
  interface Props {
+
    repo: RepoInfo;
+
    selfDid: string;
+
    onclick?: () => void;
+
  }
+

+
  const { repo, selfDid, onclick }: Props = $props();

-
  $: project = repo.payloads["xyz.radicle.project"]!;
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
@@ -48,6 +52,7 @@
      {/if}
    </div>
    <Id
+
      ariaLabel="repo-id"
      clipboard={repo.rid}
      shorten={false}
      variant="oid"
modified src/components/RepoHeader.svelte
@@ -3,12 +3,21 @@

  import Icon from "./Icon.svelte";

-
  export let repo: RepoInfo;
-
  export let selfDid: string;
-
  export let emphasizedTitle: boolean = true;
-
  export let showLabels: boolean = true;
+
  interface Props {
+
    repo: RepoInfo;
+
    selfDid: string;
+
    emphasizedTitle?: boolean;
+
    showLabels?: boolean;
+
  }
+

+
  const {
+
    repo,
+
    selfDid,
+
    emphasizedTitle = true,
+
    showLabels = true,
+
  }: Props = $props();

-
  $: project = repo.payloads["xyz.radicle.project"]!;
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
modified src/components/TextInput.svelte
@@ -2,20 +2,38 @@
  import { onMount } from "svelte";

  import Border from "./Border.svelte";
+
  import type { FormEventHandler } from "svelte/elements";

-
  export let name: string | undefined = undefined;
-
  export let placeholder: string | undefined = undefined;
-
  export let value: string | undefined = undefined;
+
  interface Props {
+
    name?: string;
+
    placeholder?: string;
+
    value?: string;
+
    autofocus?: boolean;
+
    autoselect?: boolean;
+
    disabled?: boolean;
+
    onSubmit?: () => void;
+
    onDismiss?: () => void;
+
    valid?: boolean;
+
    oninput?: FormEventHandler<HTMLInputElement>;
+
  }

-
  export let autofocus: boolean = false;
-
  export let autoselect: boolean = false;
-
  export let disabled: boolean = false;
-
  export let onSubmit: (() => void) | undefined = undefined;
-
  export let onDismiss: (() => void) | undefined = undefined;
-
  export let valid: boolean = true;
+
  /* eslint-disable prefer-const */
+
  let {
+
    name,
+
    placeholder,
+
    value = $bindable(undefined),
+
    autofocus = false,
+
    autoselect = false,
+
    disabled = false,
+
    onSubmit,
+
    onDismiss,
+
    valid = true,
+
    oninput,
+
  }: Props = $props();
+
  /* eslint-enable prefer-const */

-
  let inputElement: HTMLInputElement | undefined = undefined;
-
  let focussed = false;
+
  let inputElement: HTMLInputElement | undefined = $state(undefined);
+
  let focussed = $state(false);

  onMount(() => {
    if (inputElement === undefined) {
@@ -31,6 +49,7 @@
  });

  function handleKeydown(event: KeyboardEvent) {
+
    event.stopPropagation();
    if (event.key === "Enter" && valid && onSubmit) {
      onSubmit();
    }
@@ -72,10 +91,10 @@
  variant={valid ? (focussed ? "secondary" : "ghost") : "danger"}
  styleWidth="100%">
  <input
-
    on:focus={() => {
+
    onfocus={() => {
      focussed = true;
    }}
-
    on:blur={() => {
+
    onblur={() => {
      focussed = false;
    }}
    bind:this={inputElement}
@@ -86,6 +105,6 @@
    bind:value
    autocomplete="off"
    spellcheck="false"
-
    on:keydown|stopPropagation={handleKeydown}
-
    on:input />
+
    onkeydown={handleKeydown}
+
    {oninput} />
</Border>
modified src/components/Textarea.svelte
@@ -1,67 +1,89 @@
<script lang="ts">
+
  import type { FormEventHandler } from "svelte/elements";
  import type { ComponentProps } from "svelte";

-
  import { afterUpdate, beforeUpdate, createEventDispatcher } from "svelte";
+
  import { tick } from "svelte";

  import * as utils from "@app/lib/utils";

  import Border from "./Border.svelte";

-
  export let value: string | undefined = undefined;
-
  export let placeholder: string | undefined = undefined;
-
  export let focus: boolean = false;
-
  export let size: "grow" | "resizable" | "fixed-height" = "grow";
-
  export let styleMinHeight: string | undefined = undefined;
-
  export let stylePadding: string = "0.75rem";
-
  export let borderVariant: ComponentProps<Border>["variant"] = "float";
-

-
  // Defaulting selectionStart and selectionEnd to 0, since no full support yet.
-
  export let selectionStart: number = 0;
-
  export let selectionEnd: number = 0;
+
  interface Props {
+
    borderVariant?: ComponentProps<typeof Border>["variant"];
+
    focus?: boolean;
+
    oninput?: FormEventHandler<HTMLTextAreaElement>;
+
    onkeypress?: FormEventHandler<HTMLTextAreaElement>;
+
    placeholder?: string;
+
    selectionEnd?: number;
+
    selectionStart?: number;
+
    size?: "grow" | "resizable" | "fixed-height";
+
    styleMinHeight?: string;
+
    stylePadding?: string;
+
    submit: () => Promise<void>;
+
    value?: string;
+
  }

-
  let textareaElement: HTMLTextAreaElement | undefined = undefined;
-
  let focussed = false;
+
  /* eslint-disable prefer-const */
+
  let {
+
    borderVariant = "float",
+
    focus = false,
+
    oninput,
+
    onkeypress,
+
    placeholder = undefined,
+
    // Defaulting selectionStart and selectionEnd to 0, since no full support yet.
+
    selectionEnd = $bindable(0),
+
    selectionStart = $bindable(0),
+
    size = "grow",
+
    styleMinHeight = undefined,
+
    stylePadding = "0.75rem",
+
    submit,
+
    value = $bindable(undefined),
+
  }: Props = $props();
+
  /* eslint-enable prefer-const */
+

+
  let textareaElement: HTMLTextAreaElement | undefined = $state(undefined);
+
  let focussed = $state(false);

  // We either auto-grow the textarea, or allow the user to resize it. These
  // options are mutually exclusive because a user resized textarea would
  // automatically shrink upon text input otherwise.
-
  $: if (textareaElement && size === "grow") {
-
    // React to changes to the textarea content.
-
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-
    value;
-

-
    // Reset height to 0px on every value change so that the textarea
-
    // immediately shrinks when all text is deleted.
-
    textareaElement.style.height = `0px`;
-
    textareaElement.style.height = `${textareaElement.scrollHeight}px`;
-
  }
-

-
  $: if (textareaElement && focus) {
-
    textareaElement.focus();
-
    focus = false;
-
  }
+
  $effect(() => {
+
    if (textareaElement && size === "grow") {
+
      // React to changes to the textarea content.
+
      // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
      value;
+

+
      // Reset height to 0px on every value change so that the textarea
+
      // immediately shrinks when all text is deleted.
+
      textareaElement.style.height = `0px`;
+
      textareaElement.style.height = `${textareaElement.scrollHeight}px`;
+
    }
+
    if (textareaElement && focus) {
+
      textareaElement.focus();
+
      focussed = false;
+
    }
+
  });

-
  beforeUpdate(() => {
+
  $effect.pre(() => {
    if (textareaElement) {
      ({ selectionStart, selectionEnd } = textareaElement);
    }
  });

-
  afterUpdate(() => {
-
    if (textareaElement && focus) {
-
      textareaElement.setSelectionRange(selectionStart, selectionEnd);
-
      textareaElement.focus();
-
    }
+
  $effect(() => {
+
    void tick().then(() => {
+
      if (textareaElement && focus) {
+
        textareaElement.setSelectionRange(selectionStart, selectionEnd);
+
        textareaElement.focus();
+
      }
+
    });
  });

-
  const dispatch = createEventDispatcher<{
-
    submit: null;
-
  }>();
-

  function handleKeydown(event: KeyboardEvent) {
+
    event.stopPropagation();
    const auxiliarKey = utils.isMac() ? event.metaKey : event.ctrlKey;
    if (auxiliarKey && event.key === "Enter") {
-
      dispatch("submit");
+
      void submit();
    }
    if (event.key === "Escape") {
      textareaElement?.blur();
@@ -117,10 +139,10 @@
      ? "scroll"
      : undefined}
    {placeholder}
-
    on:input
-
    on:focus={() => (focussed = true)}
-
    on:blur={() => (focussed = false)}
-
    on:keydown|stopPropagation={handleKeydown}
-
    on:keypress>
+
    {oninput}
+
    {onkeypress}
+
    onfocus={() => (focussed = true)}
+
    onblur={() => (focussed = false)}
+
    onkeydown={handleKeydown}>
  </textarea>
</Border>
modified src/components/Thread.svelte
@@ -13,25 +13,38 @@
  import ExtendedTextarea from "./ExtendedTextarea.svelte";
  import Icon from "@app/components/Icon.svelte";

-
  export let thread: {
-
    root: Comment;
-
    replies: Comment[];
-
  };
-
  export let rid: string;
-
  export let canEditComment: (author: string) => true | undefined;
-
  export let editComment:
-
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;
-
  export let createReply:
-
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;
-
  export let reactOnComment:
-
    | ((
-
        commentId: string,
-
        authors: Author[],
-
        reaction: string,
-
      ) => Promise<void>)
-
    | undefined;
+
  interface Props {
+
    thread: {
+
      root: Comment;
+
      replies: Comment[];
+
    };
+
    rid: string;
+
    canEditComment: (author: string) => true | undefined;
+
    editComment?: (
+
      commentId: string,
+
      body: string,
+
      embeds: Embed[],
+
    ) => Promise<void>;
+
    createReply?: (
+
      commentId: string,
+
      comment: string,
+
      embeds: Embed[],
+
    ) => Promise<void>;
+
    reactOnComment?: (
+
      commentId: string,
+
      authors: Author[],
+
      reaction: string,
+
    ) => Promise<void>;
+
  }
+

+
  const {
+
    thread,
+
    rid,
+
    canEditComment,
+
    editComment,
+
    createReply,
+
    reactOnComment,
+
  }: Props = $props();

  async function toggleReply() {
    showReplyForm = !showReplyForm;
@@ -46,16 +59,16 @@
    });
  }

-
  let showReplyForm = false;
-
  let submitInProgress = false;
-

-
  $: root = thread.root;
-
  $: replies = thread.replies;
+
  let showReplyForm = $state(false);
+
  let submitInProgress = $state(false);

-
  $: style =
+
  const root = $derived(thread.root);
+
  const replies = $derived(thread.replies);
+
  const style = $derived(
    replies.length > 0
      ? "--local-clip-path: var(--2px-top-corner-fill)"
-
      : "--local-clip-path: var(--2px-corner-fill)";
+
      : "--local-clip-path: var(--2px-corner-fill)",
+
  );
</script>

<style>
@@ -135,8 +148,8 @@
              submitCaption="Reply"
              focus
              stylePadding="0.5rem 0.75rem"
-
              on:close={() => (showReplyForm = false)}
-
              on:submit={async ({ detail: { comment, embeds } }) => {
+
              close={() => (showReplyForm = false)}
+
              submit={async ({ comment, embeds }) => {
                try {
                  submitInProgress = true;
                  await createReply(
modified src/lib/invoke.ts
@@ -19,3 +19,19 @@ export async function invoke<T = null>(
      });
  }
}
+

+
export async function writeToClipboard(
+
  text: string,
+
  opts?: {
+
    label?: string;
+
  },
+
) {
+
  if (window.__TAURI_INTERNALS__) {
+
    await tauri.invoke("plugin:clipboard-manager|write_text", {
+
      label: opts?.label,
+
      text,
+
    });
+
  } else {
+
    await navigator.clipboard.writeText(text);
+
  }
+
}
modified src/lib/utils.ts
@@ -153,7 +153,7 @@ export const patchStatusBackgroundColor: Record<
  merged: "var(--color-fill-delegate)",
};

-
export function authorForNodeId(author: Author): ComponentProps<NodeId> {
+
export function authorForNodeId(author: Author): ComponentProps<typeof NodeId> {
  return { publicKey: publicKeyFromDid(author.did), alias: author.alias };
}

modified src/views/AuthenticationError.svelte
@@ -1,8 +1,12 @@
<script lang="ts">
  import Icon from "@app/components/Icon.svelte";

-
  export let error: string;
-
  export let hint: string | undefined = undefined;
+
  interface Props {
+
    error: string;
+
    hint?: string;
+
  }
+

+
  const { error, hint }: Props = $props();
</script>

<style>
modified src/views/Home.svelte
@@ -9,8 +9,12 @@
  import RepoCard from "@app/components/RepoCard.svelte";
  import NodeId from "@app/components/NodeId.svelte";

-
  export let repos: RepoInfo[];
-
  export let config: Config;
+
  interface Props {
+
    repos: RepoInfo[];
+
    config: Config;
+
  }
+

+
  const { repos, config }: Props = $props();
</script>

<style>
modified src/views/repo/CreateIssue.svelte
@@ -21,13 +21,17 @@
  import Textarea from "@app/components/Textarea.svelte";
  import Markdown from "@app/components/Markdown.svelte";

-
  export let repo: RepoInfo;
-
  export let issues: Issue[];
-
  export let config: Config;
+
  interface Props {
+
    repo: RepoInfo;
+
    issues: Issue[];
+
    config: Config;
+
  }
+

+
  const { repo, issues, config }: Props = $props();

-
  let title: string = "";
-
  let description: string = "";
-
  let preview: boolean = false;
+
  let title: string = $state("");
+
  let description: string = $state("");
+
  let preview: boolean = $state(false);
  const announce = false;

  const labels: string[] = [];
@@ -47,7 +51,7 @@
    });
  }

-
  $: project = repo.payloads["xyz.radicle.project"]!;
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
@@ -176,6 +180,7 @@
        placeholder="Description"
        bind:value={description}
        size="fixed-height"
+
        submit={createIssue}
        styleMinHeight="100%" />
    {/if}
    <div
modified src/views/repo/Issue.svelte
@@ -35,48 +35,57 @@

  import Layout from "./Layout.svelte";

-
  export let repo: RepoInfo;
-
  export let issue: Issue;
-
  export let issues: Issue[];
-
  export let config: Config;
+
  interface Props {
+
    repo: RepoInfo;
+
    issue: Issue;
+
    issues: Issue[];
+
    config: Config;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { repo, issue, issues: initialIssues, config }: Props = $props();
+
  /* eslint-enable prefer-const */

-
  let topLevelReplyOpen = false;
-
  let editingTitle = false;
-
  let updatedTitle: string = issue.title;
+
  const issues = $state(initialIssues);
+
  let topLevelReplyOpen = $state(false);
+
  let editingTitle = $state(false);
+
  let updatedTitle = $state(issue.title);

  // The view doesn't get destroyed when we switch between different issues in
  // the sidebar and because of that the top-level state gets retained when the
  // issue changes. This reactive statement makes sure we always load the new
  // issue and reset the state to defaults.
  let issueId = issue.id;
-
  $: if (issueId !== issue.id) {
-
    issueId = issue.id;
-
    topLevelReplyOpen = false;
-
    editingTitle = false;
-
    updatedTitle = issue.title;
-
    void loadActivity();
-
  }
+
  $effect(() => {
+
    if (issueId !== issue.id) {
+
      issueId = issue.id;
+
      topLevelReplyOpen = false;
+
      editingTitle = false;
+
      updatedTitle = issue.title;
+
      void loadActivity();
+
    }
+
  });

-
  $: project = repo.payloads["xyz.radicle.project"]!;
-

-
  $: issueBody = issue.discussion[0];
-

-
  $: threads = issue.discussion
-
    .filter(
-
      comment =>
-
        (comment.id !== issueBody.id && !comment.replyTo) ||
-
        comment.replyTo === issueBody.id,
-
    )
-
    .map(thread => {
-
      return {
-
        root: thread,
-
        replies: issue.discussion
-
          .filter(comment => comment.replyTo === thread.id)
-
          .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
-
      };
-
    }, []);
-

-
  let activity: Operation[];
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+
  const issueBody = $derived(issue.discussion[0]);
+
  const threads = $derived(
+
    issue.discussion
+
      .filter(
+
        comment =>
+
          (comment.id !== issueBody.id && !comment.replyTo) ||
+
          comment.replyTo === issueBody.id,
+
      )
+
      .map(thread => {
+
        return {
+
          root: thread,
+
          replies: issue.discussion
+
            .filter(comment => comment.replyTo === thread.id)
+
            .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
+
        };
+
      }, []),
+
  );
+

+
  let activity = $state<Operation[]>([]);

  async function loadActivity() {
    activity = await invoke("activity_by_id", {
modified src/views/repo/Issues.svelte
@@ -17,12 +17,16 @@
  import RepoHeader from "@app/components/RepoHeader.svelte";
  import Button from "@app/components/Button.svelte";

-
  export let repo: RepoInfo;
-
  export let issues: Issue[];
-
  export let config: Config;
-
  export let status: IssueStatus;
+
  interface Props {
+
    repo: RepoInfo;
+
    issues: Issue[];
+
    config: Config;
+
    status: IssueStatus;
+
  }
+

+
  const { repo, issues, config, status }: Props = $props();

-
  $: project = repo.payloads["xyz.radicle.project"]!;
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
modified src/views/repo/Layout.svelte
@@ -7,20 +7,31 @@
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";

-
  export let children: Snippet;
-
  export let breadcrumbs: Snippet;
-
  export let headerCenter: Snippet | undefined = undefined;
-
  export let sidebar: Snippet;
-
  export let loadMore: (() => Promise<void>) | undefined = undefined;
+
  interface Props {
+
    children: Snippet;
+
    breadcrumbs: Snippet;
+
    headerCenter?: Snippet;
+
    sidebar: Snippet;
+
    loadMore?: () => Promise<void>;
+
  }
+

+
  const {
+
    children,
+
    breadcrumbs,
+
    headerCenter = undefined,
+
    sidebar,
+
    loadMore = undefined,
+
  }: Props = $props();

-
  let hidden = false;
-
  let listElement: HTMLElement;
+
  let hidden = $state(false);
+
  let listElement: HTMLElement | undefined = $state();
  let loading = false;

  onMount(() => {
    if (listElement && loadMore) {
      listElement.addEventListener("scroll", async () => {
        if (
+
          listElement &&
          listElement.scrollTop + listElement.clientHeight >=
            listElement.scrollHeight - 600 &&
          loading === false
modified src/views/repo/Patch.svelte
@@ -9,7 +9,6 @@
    formatTimestamp,
    patchStatusColor,
  } from "@app/lib/utils";
-
  import { invoke } from "@app/lib/invoke";

  import Border from "@app/components/Border.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
@@ -21,22 +20,17 @@
  import Markdown from "@app/components/Markdown.svelte";
  import Id from "@app/components/Id.svelte";

-
  export let repo: RepoInfo;
-
  export let patch: Patch;
-
  export let patches: Patch[];
-
  export let revisions: Revision[];
-
  export let config: Config;
+
  interface Props {
+
    repo: RepoInfo;
+
    patch: Patch;
+
    patches: Patch[];
+
    revisions: Revision[];
+
    config: Config;
+
  }

-
  $: void invoke("get_diff", {
-
    rid: repo.rid,
-
    options: {
-
      base: revisions[0].base,
-
      head: revisions[0].head,
-
      unified: 10,
-
    },
-
  }).then(console.log);
+
  const { repo, patch, patches, revisions, config }: Props = $props();

-
  $: project = repo.payloads["xyz.radicle.project"]!;
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
modified src/views/repo/Patches.svelte
@@ -16,14 +16,24 @@
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import RepoHeader from "@app/components/RepoHeader.svelte";

-
  export let repo: RepoInfo;
-
  export let patches: PaginatedQuery<Patch[]>;
-
  export let config: Config;
-
  export let status: PatchStatus | undefined = undefined;
+
  interface Props {
+
    repo: RepoInfo;
+
    patches: PaginatedQuery<Patch[]>;
+
    config: Config;
+
    status?: PatchStatus;
+
  }
+

+
  const { repo, patches, config, status }: Props = $props();
+

+
  let items = $state(patches.content);
+
  let cursor = patches.cursor;
+
  let more = patches.more;

-
  $: items = patches.content;
-
  $: more = patches.more;
-
  $: cursor = patches.cursor;
+
  $effect(() => {
+
    items = patches.content;
+
    cursor = patches.cursor;
+
    more = patches.more;
+
  });

  async function loadMore() {
    if (more) {
@@ -40,7 +50,7 @@
    }
  }

-
  $: project = repo.payloads["xyz.radicle.project"]!;
+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
added tests/e2e/clipboard.spec.ts
@@ -0,0 +1,31 @@
+
import { chromium } from "playwright";
+

+
import { expect, markdownRid, test } from "@tests/support/fixtures.js";
+

+
// We explicitly run all clipboard tests withing the context of a single test
+
// so that we don't run into race conditions, because there is no way to isolate
+
// the clipboard in Playwright yet.
+
test("copy to clipboard", async () => {
+
  const browser = await chromium.launch();
+
  const context = await browser.newContext();
+
  await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+
  const page = await context.newPage();
+

+
  await page.goto("/repos");
+

+
  // Reset system clipboard to a known state.
+
  await page.evaluate<string>("navigator.clipboard.writeText('')");
+

+
  // Repo ID.
+
  {
+
    await page.getByLabel("repo-id").first().click();
+
    const clipboardContent = await page.evaluate<string>(
+
      "navigator.clipboard.readText()",
+
    );
+
    expect(clipboardContent).toBe(markdownRid);
+
  }
+

+
  // Clear the system clipboard contents so developers don't wonder why there's
+
  // random stuff in their clipboard after running tests.
+
  await page.evaluate<string>("navigator.clipboard.writeText('')");
+
});
added tests/e2e/repo/issue.spec.ts
@@ -0,0 +1,8 @@
+
import { test, expect, cobRid } from "@tests/support/fixtures.js";
+

+
test("navigate single issue", async ({ page }) => {
+
  await page.goto(`/repos/${cobRid}/issues?status=all`);
+
  await page.getByText("This title has **markdown**").click();
+

+
  await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/);
+
});
added tests/e2e/repo/issues.spec.ts
@@ -0,0 +1,10 @@
+
import { test, cobRid, expect } from "@tests/support/fixtures.js";
+

+
test("navigate issues listing", async ({ page }) => {
+
  await page.goto(`/repos/${cobRid}/issues?show=all`);
+
  await expect(page.locator(".issue-teaser")).toHaveCount(3);
+

+
  await page.getByRole("link", { name: "Closed" }).click();
+
  await expect(page.locator(".issue-teaser")).toHaveCount(2);
+
  await expect(page).toHaveURL(`/repos/${cobRid}/issues?status=closed`);
+
});
added tests/e2e/theme.spec.ts
@@ -0,0 +1,32 @@
+
import { test, expect } from "@tests/support/fixtures.js";
+

+
test("default theme", async ({ page }) => {
+
  await page.goto("/repos");
+

+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+
});
+

+
test("theme persistence", async ({ page }) => {
+
  await page.goto("/repos");
+
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
+
  await page.getByRole("button", { name: "Settings" }).click();
+

+
  await page.getByRole("button", { name: "Light", exact: true }).click();
+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+

+
  await page.reload();
+

+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+
});
+

+
test("change theme", async ({ page }) => {
+
  await page.goto("/repos");
+
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
+
  await page.getByRole("button", { name: "Settings" }).click();
+

+
  await page.getByRole("button", { name: "Light", exact: true }).click();
+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+

+
  await page.getByRole("button", { name: "Dark", exact: true }).click();
+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+
});