Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Make all popover and drop-down buttons consistent
Open rudolfs opened 11 months ago

check check-e2e check-unit-test

👉 Workflow runs 👉 Branch on GitHub

33 files changed +871 -581 b484a74d → b1c4b32e
modified src/components/Button.svelte
@@ -31,7 +31,8 @@
      `--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);` +
-
      `--text-color: ${variant === "ghost" ? "var(--color-foreground-contrast)" : "var(--color-foreground-white)"}`,
+
      `--text-color: ${variant === "ghost" ? "var(--color-foreground-contrast)" : "var(--color-foreground-white)"};` +
+
      `--text-color-active: ${variant === "ghost" ? "var(--color-foreground-emphasized)" : "var(--color-foreground-white)"};`,
  );
</script>

@@ -119,7 +120,7 @@
    color: var(--color-foreground-disabled);
  }
  .container.active:not(.disabled) {
-
    color: var(--color-foreground-emphasized);
+
    color: var(--text-color-active);
  }

  .disabled .p1-3,
modified src/components/Changes.svelte
@@ -118,10 +118,15 @@
<div
  class="txt-semibold global-flex"
  style:margin-bottom={hideChanges ? undefined : "1rem"}>
-
  <NakedButton variant="ghost" onclick={() => (hideChanges = !hideChanges)}>
-
    <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
+
  <div class="global-flex">
+
    <NakedButton
+
      variant="ghost"
+
      onclick={() => (hideChanges = !hideChanges)}
+
      stylePadding="0 4px">
+
      <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
+
    </NakedButton>
    <div class="txt-semibold global-flex txt-regular">Changes</div>
-
  </NakedButton>
+
  </div>
  {#if !hideChanges}
    <div style:margin-left="auto">
      <NakedButton
added src/components/CheckoutPatchButton.svelte
@@ -0,0 +1,57 @@
+
<script lang="ts">
+
  import { formatOid } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  interface Props {
+
    patchId: string;
+
    selectedRevisionId: string;
+
    tab: "patch" | "revisions" | "timeline";
+
  }
+

+
  const { patchId, selectedRevisionId, tab }: Props = $props();
+

+
  const checkoutCommand = $derived.by(() => {
+
    if (tab === "revisions" && selectedRevisionId !== patchId) {
+
      return `rad patch checkout ${formatOid(patchId)} --revision ${formatOid(selectedRevisionId)}`;
+
    } else {
+
      return `rad patch checkout ${formatOid(patchId)}`;
+
    }
+
  });
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <Button
+
      styleHeight="2.5rem"
+
      variant="secondary"
+
      {onclick}
+
      active={popoverExpanded}>
+
      <Icon name="checkout" />Checkout patch
+
    </Button>
+
  {/snippet}
+
  {#snippet popover()}
+
    <Border
+
      styleAlignItems="flex-start"
+
      styleBackgroundColor="var(--color-background-float)"
+
      styleFlexDirection="column"
+
      styleGap="0.5rem"
+
      stylePadding="1rem"
+
      styleWidth="max-content"
+
      variant="ghost">
+
      <span class="txt-small">
+
        To checkout this patch in your working copy, run:
+
      </span>
+
      <Command command={checkoutCommand} styleWidth="100%" />
+
    </Border>
+
  {/snippet}
+
</Popover>
added src/components/CheckoutRepoButton.svelte
@@ -0,0 +1,46 @@
+
<script lang="ts">
+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  interface Props {
+
    rid: string;
+
  }
+

+
  const { rid }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <Button
+
      styleHeight="2.5rem"
+
      variant="secondary"
+
      {onclick}
+
      active={popoverExpanded}>
+
      <Icon name="checkout" />Checkout repo
+
    </Button>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border
+
      styleAlignItems="flex-start"
+
      styleBackgroundColor="var(--color-background-float)"
+
      styleFlexDirection="column"
+
      styleGap="0.5rem"
+
      stylePadding="1rem"
+
      styleWidth="max-content"
+
      variant="ghost">
+
      <span class="txt-small">
+
        To checkout a working copy of this repo, run:
+
      </span>
+
      <Command command={`rad checkout ${rid}`} styleWidth="100%" />
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/Command.svelte
@@ -21,7 +21,7 @@
  }
</style>

-
<div class="cmd txt-monospace" style:width={styleWidth}>
+
<div class="cmd txt-monospace txt-small" style:width={styleWidth}>
  <Border
    hoverable
    onclick={() => clipboard.copy()}
modified src/components/ConfirmClear.svelte
@@ -13,14 +13,21 @@
    subject: string;
  }

-
  /* eslint-disable prefer-const */
-
  let { clear, subject }: Props = $props();
-
  /* eslint-enable prefer-const */
+
  const { clear, subject }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
</script>

-
<Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="2.5rem"
+
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
-
    <NakedButton stylePadding="0 0.25rem" variant="ghost" {onclick}>
+
    <NakedButton
+
      stylePadding="0 0.25rem"
+
      variant="ghost"
+
      {onclick}
+
      active={popoverExpanded}>
      <Icon name="broom-double" />
    </NakedButton>
  {/snippet}
modified src/components/Discussion.svelte
@@ -94,12 +94,19 @@

<div style:margin={hideDiscussion ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
  <div class="global-flex">
-
    <NakedButton
-
      variant="ghost"
-
      disabled={commentThreads.length === 0}
-
      onclick={() => (hideDiscussion = !hideDiscussion)}>
-
      <Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />
-
      <div class="txt-semibold global-flex txt-regular">
+
    <div class="global-flex">
+
      <NakedButton
+
        variant="ghost"
+
        stylePadding="0 4px"
+
        disabled={commentThreads.length === 0}
+
        onclick={() => (hideDiscussion = !hideDiscussion)}>
+
        <Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />
+
      </NakedButton>
+
      <div
+
        class="txt-semibold global-flex txt-regular"
+
        style:color={commentThreads.length === 0
+
          ? "var(--color-foreground-disabled)"
+
          : undefined}>
        Discussion <span style:font-weight="var(--font-weight-regular)">
          {sum(
            commentThreads.map(t => {
@@ -108,10 +115,11 @@
          )}
        </span>
      </div>
-
    </NakedButton>
+
    </div>
    <div style:margin-left="auto">
      <NakedButton
        variant="ghost"
+
        active={topLevelReplyOpen}
        onclick={async () => {
          if (hideDiscussion) {
            hideDiscussion = false;
modified src/components/Header.svelte
@@ -1,29 +1,21 @@
<script lang="ts">
-
  import type { Snippet } from "svelte";
  import type { Config } from "@bindings/config/Config";
+
  import type { Snippet } from "svelte";

  import { boolean } from "zod";
  import { onMount } from "svelte";

  import * as router from "@app/lib/router";
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
-
  import {
-
    checkRadicleCLI,
-
    radicleInstalled,
-
  } from "@app/lib/checkRadicleCLI.svelte";
+
  import { checkRadicleCLI } from "@app/lib/checkRadicleCLI.svelte";
  import { dynamicInterval } from "@app/lib/interval";
-
  import { nodeRunning } from "@app/lib/events";
-
  import { didFromPublicKey, truncateDid } from "@app/lib/utils";
+
  import { setFocused } from "@app/components/Popover.svelte";

  import Avatar from "@app/components/Avatar.svelte";
-
  import Border from "@app/components/Border.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
-
  import Repos from "@app/views/home/guides/Repos.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import InfoButton from "@app/components/InfoButton.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-
  import Popover, { setFocused } from "@app/components/Popover.svelte";
+
  import NodeStatusButton from "@app/components/NodeStatusButton.svelte";

  const activeRouteStore = router.activeRouteStore;

@@ -39,6 +31,8 @@
    center?: Snippet;
  }

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

  onMount(async () => {
    try {
      await checkRadicleCLI();
@@ -52,8 +46,6 @@
      firstLaunchStorage.value = false;
    }
  });
-

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

<style>
@@ -85,15 +77,6 @@
    width: 100%;
    justify-content: space-between;
  }
-
  .guide-header {
-
    padding-bottom: 1rem;
-
  }
-
  .spacer {
-
    width: 100%;
-
    border-bottom: 1px solid var(--color-border-default);
-
    height: 1px;
-
    margin: 1rem 0;
-
  }
</style>

<div class="header global-flex">
@@ -102,6 +85,7 @@
      <div class="global-flex" style:gap="0.25rem">
        <NakedButton
          variant="ghost"
+
          active={$activeRouteStore.resource === "home"}
          onclick={() => {
            void router.push({ resource: "home" });
          }}
@@ -129,122 +113,8 @@
      {@render center?.()}

      <div class="global-flex">
-
        <Popover
-
          popoverId="popover-guide"
-
          popoverPadding="0"
-
          popoverPositionTop="2.5rem"
-
          popoverPositionRight="-9.3rem">
-
          {#snippet toggle(onclick)}
-
            <NakedButton variant="ghost" {onclick} stylePadding="0 4px">
-
              <Icon name="info" />
-
            </NakedButton>
-
          {/snippet}
-
          {#snippet popover()}
-
            <Border
-
              variant="ghost"
-
              styleGap="0"
-
              stylePadding="1rem"
-
              styleMinWidth="36rem"
-
              styleOverflow="auto"
-
              styleMaxHeight="calc(100vh - 5rem)"
-
              styleAlignItems="flex-start"
-
              styleFlexDirection="column">
-
              <div
-
                style:position="relative"
-
                style:display="flex"
-
                style:gap="0.5rem"
-
                style:flex-direction="column"
-
                style:padding="1rem"
-
                style:margin-bottom="1rem"
-
                style:width="100%"
-
                style:background-color="var(--color-background-float)">
-
                <div class="txt-semibold txt-medium" style:margin-bottom="1rem">
-
                  Getting started
-
                </div>
-
                <div class="txt-small" style:display="inline">
-
                  Hello <span style:padding-left="0.25rem">
-
                    <NodeId
-
                      inline
-
                      publicKey={config.publicKey}
-
                      alias={config.alias} />,
-
                  </span>
-
                  your identity has been created and stored on your machine.
-
                </div>
-
                <div class="txt-small">
-
                  Your public key is <CopyableId
-
                    inline
-
                    id={didFromPublicKey(config.publicKey)}>
-
                    {truncateDid(config.publicKey)}
-
                  </CopyableId>
-
                  you can share this with anyone to find you on the network.
-
                </div>
-
                <div class="spacer"></div>
-
                {#if radicleInstalled()}
-
                  <div class="global-flex txt-small">
-
                    <Icon name="checkbox-checked" />Radicle CLI is setup
-
                  </div>
-
                {:else}
-
                  <div class="txt-small">
-
                    <div class="global-flex" style:padding-bottom="0.5rem">
-
                      <Icon name="checkbox-unchecked" />Make sure to install
-
                      Radicle CLI
-
                    </div>
-
                    <div style:padding-bottom="0.5rem">
-
                      To be able to interact with repos on the Radicle network
-
                      you'll need to install a node on your computer. This node
-
                      will identify itself on the network with your keys to push
-
                      and pull changes.
-
                    </div>
-
                    <div style:padding-bottom="0.5rem">
-
                      To install the node and other Radicle CLI tooling, simply
-
                      run the command below from your shell:
-
                    </div>
-
                    <Command
-
                      styleWidth="fit-content"
-
                      command="curl -sSf https://radicle.xyz/install | sh" />
-
                  </div>
-
                {/if}
-
              </div>
-
              <div class="guide-header txt-medium txt-semibold">Guide</div>
-

-
              <Repos />
-
            </Border>
-
          {/snippet}
-
        </Popover>
-
        <Popover
-
          popoverPadding="0"
-
          popoverPositionTop="2.5rem"
-
          popoverPositionRight="0">
-
          {#snippet toggle(onclick)}
-
            <NakedButton variant="ghost" {onclick}>
-
              {#if $nodeRunning}
-
                <Icon name="online" />
-
                Online
-
              {:else}
-
                <Icon name="offline" />
-
                Offline
-
              {/if}
-
            </NakedButton>
-
          {/snippet}
-
          {#snippet popover()}
-
            <Border
-
              variant="ghost"
-
              stylePadding="1rem"
-
              styleMinWidth="20rem"
-
              styleAlignItems="flex-start"
-
              styleFlexDirection="column">
-
              <div class="txt-small txt-missing">
-
                {#if $nodeRunning}
-
                  Your node is up and running, your changes will be synced
-
                  automatically.
-
                {:else}
-
                  Your node is not running, changes you make are safe but won't
-
                  be announced.
-
                {/if}
-
              </div>
-
            </Border>
-
          {/snippet}
-
        </Popover>
+
        <InfoButton {config} />
+
        <NodeStatusButton />
        <NakedButton
          variant="ghost"
          stylePadding="0 4px"
modified src/components/Icon.svelte
@@ -23,6 +23,7 @@
      | "checkout"
      | "chevron-down"
      | "chevron-right"
+
      | "chevron-up"
      | "clock"
      | "collapse"
      | "collapse-panel"
@@ -298,6 +299,17 @@
    <path d="M7 10L8 10L8 11L7 11L7 10Z" />
    <path d="M9 8L10 8V9L9 9V8Z" />
    <path d="M8 9H9V10H8L8 9Z" />
+
  {:else if name === "chevron-up"}
+
    <path d="M7 6.5L7 5.5L8 5.5L8 6.5L7 6.5Z" />
+
    <path d="M6 7.5L6 6.5L7 6.5V7.5L6 7.5Z" />
+
    <path d="M5 8.5L5 7.5L6 7.5L6 8.5L5 8.5Z" />
+
    <path d="M4 9.5L4 8.5L5 8.5L5 9.5L4 9.5Z" />
+
    <path d="M3 10.5L3 9.5L4 9.5L4 10.5H3Z" />
+
    <path d="M12 10.5V9.5H13V10.5H12Z" />
+
    <path d="M11 9.5L11 8.5L12 8.5L12 9.5L11 9.5Z" />
+
    <path d="M10 8.5V7.5L11 7.5V8.5L10 8.5Z" />
+
    <path d="M8 6.5L8 5.5L9 5.5L9 6.5L8 6.5Z" />
+
    <path d="M9 7.5V6.5L10 6.5L10 7.5L9 7.5Z" />
  {:else if name === "clock"}
    <path d="M6 13H8V14H6V13Z" />
    <path d="M10 13H8V14H10V13Z" />
added src/components/InfoButton.svelte
@@ -0,0 +1,117 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+

+
  import { didFromPublicKey, truncateDid } from "@app/lib/utils";
+
  import { radicleInstalled } from "@app/lib/checkRadicleCLI.svelte";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import RepoGuide from "@app/components/RepoGuide.svelte";
+

+
  interface Props {
+
    config: Config;
+
  }
+
  const { config }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
<style>
+
  .guide-header {
+
    padding-bottom: 1rem;
+
  }
+
  .spacer {
+
    width: 100%;
+
    border-bottom: 1px solid var(--color-border-default);
+
    height: 1px;
+
    margin: 1rem 0;
+
  }
+
</style>
+

+
<Popover
+
  popoverId="popover-guide"
+
  popoverPadding="0"
+
  popoverPositionTop="2.5rem"
+
  bind:expanded={popoverExpanded}
+
  popoverPositionRight="-9.3rem">
+
  {#snippet toggle(onclick)}
+
    <NakedButton
+
      variant="ghost"
+
      {onclick}
+
      stylePadding="0 4px"
+
      active={popoverExpanded}>
+
      <Icon name="info" /> Guide
+
    </NakedButton>
+
  {/snippet}
+
  {#snippet popover()}
+
    <Border
+
      variant="ghost"
+
      styleGap="0"
+
      stylePadding="1rem"
+
      styleMinWidth="36rem"
+
      styleOverflow="auto"
+
      styleMaxHeight="calc(100vh - 5rem)"
+
      styleAlignItems="flex-start"
+
      styleFlexDirection="column">
+
      <div
+
        style:position="relative"
+
        style:display="flex"
+
        style:gap="0.5rem"
+
        style:flex-direction="column"
+
        style:padding="1rem"
+
        style:margin-bottom="1rem"
+
        style:width="100%"
+
        style:background-color="var(--color-background-float)">
+
        <div class="txt-semibold txt-medium" style:margin-bottom="1rem">
+
          Getting started
+
        </div>
+
        <div class="txt-small" style:display="inline">
+
          Hello <span style:padding-left="0.25rem">
+
            <NodeId inline publicKey={config.publicKey} alias={config.alias} />,
+
          </span>
+
          your identity has been created and stored on your machine.
+
        </div>
+
        <div class="txt-small">
+
          Your public key is <CopyableId
+
            inline
+
            id={didFromPublicKey(config.publicKey)}>
+
            {truncateDid(config.publicKey)}
+
          </CopyableId>
+
          you can share this with anyone to find you on the network.
+
        </div>
+
        <div class="spacer"></div>
+
        {#if radicleInstalled()}
+
          <div class="global-flex txt-small">
+
            <Icon name="checkbox-checked" />Radicle CLI is setup
+
          </div>
+
        {:else}
+
          <div class="txt-small">
+
            <div class="global-flex" style:padding-bottom="0.5rem">
+
              <Icon name="checkbox-unchecked" />Make sure to install Radicle CLI
+
            </div>
+
            <div style:padding-bottom="0.5rem">
+
              To be able to interact with repos on the Radicle network you'll
+
              need to install a node on your computer. This node will identify
+
              itself on the network with your keys to push and pull changes.
+
            </div>
+
            <div style:padding-bottom="0.5rem">
+
              To install the node and other Radicle CLI tooling, simply run the
+
              command below from your shell:
+
            </div>
+
            <Command
+
              styleWidth="fit-content"
+
              command="curl -sSf https://radicle.xyz/install | sh" />
+
          </div>
+
        {/if}
+
      </div>
+
      <div class="guide-header txt-medium txt-semibold">Guide</div>
+

+
      <RepoGuide />
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/IssueSecondColumn.svelte
@@ -1,40 +1,35 @@
<script lang="ts">
  import type { Issue } from "@bindings/cob/issue/Issue";
-
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
  import type { IssueStatus } from "@app/views/repo/router";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

-
  import capitalize from "lodash/capitalize";
  import fuzzysort from "fuzzysort";

  import * as router from "@app/lib/router";
-
  import { issueStatusColor, modifierKey } from "@app/lib/utils";
+
  import { modifierKey } from "@app/lib/utils";

  import Border from "./Border.svelte";
-
  import DropdownList from "./DropdownList.svelte";
-
  import DropdownListItem from "./DropdownListItem.svelte";
  import Icon from "./Icon.svelte";
+
  import IssueStateFilterButton from "./IssueStateFilterButton.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import Link from "./Link.svelte";
  import NakedButton from "./NakedButton.svelte";
  import OutlineButton from "./OutlineButton.svelte";
-
  import Popover, { closeFocused } from "./Popover.svelte";
  import TextInput from "./TextInput.svelte";

  const activeRouteStore = router.activeRouteStore;

  interface Props {
+
    changeFilter: (status: IssueStatus) => void;
+
    issues: Issue[];
    repo: RepoInfo;
    selectedIssueId?: string;
-
    issues: Issue[];
    status: IssueStatus;
    title: string;
-
    changeFilter: (status: IssueStatus) => void;
  }

-
  /* eslint-disable prefer-const */
-
  let { repo, selectedIssueId, issues, status, title, changeFilter }: Props =
+
  const { changeFilter, issues, repo, selectedIssueId, status, title }: Props =
    $props();
-
  /* eslint-enable prefer-const */

  const project = $derived(repo.payloads["xyz.radicle.project"]!);

@@ -81,24 +76,6 @@
  }
</style>

-
{#snippet icons(status: IssueStatus)}
-
  <div
-
    class="icon"
-
    style:color={status === "all" ? undefined : issueStatusColor[status]}>
-
    <Icon name={status === "closed" ? "issue-closed" : "issue"} />
-
  </div>
-
{/snippet}
-

-
{#snippet counters(status: IssueStatus)}
-
  <div style:margin-left="auto" style:padding-left="0.25rem">
-
    {#if status === "all"}
-
      {project.meta.issues.open + project.meta.issues.closed}
-
    {:else}
-
      {project.meta.issues[status]}
-
    {/if}
-
  </div>
-
{/snippet}
-

<div class="container">
  <div
    class="txt-medium global-flex"
@@ -157,37 +134,10 @@

{#if showFilters}
  <div class="global-flex" style:margin="1rem 0">
-
    <Popover popoverPositionLeft="0" popoverPositionTop="3rem">
-
      {#snippet toggle(onclick)}
-
        <OutlineButton variant="ghost" {onclick} styleHeight="2.5rem">
-
          {@render icons(status)}
-
          {capitalize(status)}
-
          {@render counters(status)}
-
          <Icon name="chevron-down" />
-
        </OutlineButton>
-
      {/snippet}
-

-
      {#snippet popover()}
-
        <Border variant="ghost">
-
          <DropdownList items={["all", "open", "closed"] as IssueStatus[]}>
-
            {#snippet item(state)}
-
              <DropdownListItem
-
                styleGap="0.5rem"
-
                styleMinHeight="2.5rem"
-
                selected={status === state}
-
                onclick={() => {
-
                  changeFilter(state);
-
                  closeFocused();
-
                }}>
-
                {@render icons(state)}
-
                {capitalize(state)}
-
                {@render counters(state)}
-
              </DropdownListItem>
-
            {/snippet}
-
          </DropdownList>
-
        </Border>
-
      {/snippet}
-
    </Popover>
+
    <IssueStateFilterButton
+
      {status}
+
      counters={project.meta.issues}
+
      {changeFilter} />
    <TextInput
      onSubmit={async () => {
        if (searchResults.length === 1) {
modified src/components/IssueStateButton.svelte
@@ -19,6 +19,8 @@
  }

  const { selectedState, onSelect }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
</script>

<style>
@@ -39,7 +41,11 @@
  }
</style>

-
<Popover popoverPadding="0" popoverPositionTop="2rem" popoverPositionLeft="0">
+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="2rem"
+
  popoverPositionLeft="0"
+
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
    <button {onclick}>
      <span
@@ -54,7 +60,7 @@
            : `issue-${selectedState.status}`} />
        {capitalize(selectedState.status)}
        {selectedState.status === "closed" ? `as ${selectedState.reason}` : ""}
-
        <Icon name="chevron-down" />
+
        <Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
      </span>
    </button>
  {/snippet}
added src/components/IssueStateFilterButton.svelte
@@ -0,0 +1,83 @@
+
<script lang="ts">
+
  import type { IssueStatus } from "@app/views/repo/router";
+
  import type { ProjectPayloadMeta } from "@bindings/repo/ProjectPayloadMeta";
+

+
  import { closeFocused } from "./Popover.svelte";
+

+
  import capitalize from "lodash/capitalize";
+
  import { issueStatusColor } from "@app/lib/utils";
+

+
  import Border from "./Border.svelte";
+
  import DropdownList from "./DropdownList.svelte";
+
  import DropdownListItem from "./DropdownListItem.svelte";
+
  import Icon from "./Icon.svelte";
+
  import OutlineButton from "./OutlineButton.svelte";
+
  import Popover from "./Popover.svelte";
+

+
  interface Props {
+
    changeFilter: (status: IssueStatus) => void;
+
    status: IssueStatus;
+
    counters: ProjectPayloadMeta["issues"];
+
  }
+

+
  const { changeFilter, counters, status }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
{#snippet iconSnippet(status: IssueStatus)}
+
  <div
+
    class="icon"
+
    style:color={status === "all" ? undefined : issueStatusColor[status]}>
+
    <Icon name={status === "closed" ? "issue-closed" : "issue"} />
+
  </div>
+
{/snippet}
+

+
{#snippet counterSnippet(status: IssueStatus)}
+
  <div style:margin-left="auto" style:padding-left="0.25rem">
+
    {#if status === "all"}
+
      {counters.open + counters.closed}
+
    {:else}
+
      {counters[status]}
+
    {/if}
+
  </div>
+
{/snippet}
+

+
<Popover
+
  popoverPositionLeft="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <OutlineButton
+
      variant="ghost"
+
      {onclick}
+
      styleHeight="2.5rem"
+
      active={popoverExpanded}>
+
      {@render iconSnippet(status)}
+
      {capitalize(status)}
+
      {@render counterSnippet(status)}
+
      <Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
+
    </OutlineButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <DropdownList items={["all", "open", "closed"] as IssueStatus[]}>
+
        {#snippet item(state)}
+
          <DropdownListItem
+
            styleGap="0.5rem"
+
            styleMinHeight="2.5rem"
+
            selected={status === state}
+
            onclick={() => {
+
              changeFilter(state);
+
              closeFocused();
+
            }}>
+
            {@render iconSnippet(state)}
+
            {capitalize(state)}
+
            {@render counterSnippet(state)}
+
          </DropdownListItem>
+
        {/snippet}
+
      </DropdownList>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/NakedButton.svelte
@@ -207,6 +207,10 @@
    background-color: var(--button-color-1);
  }

+
  .container.active:not(.disabled) {
+
    color: var(--color-foreground-emphasized);
+
  }
+

  .container.disabled {
    color: var(--color-foreground-disabled);
    cursor: inherit;
modified src/components/NewPatchButton.svelte
@@ -12,17 +12,30 @@
  }

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

+
  let popoverExpanded: boolean = $state(false);
</script>

-
<Popover popoverPositionRight="0" popoverPositionTop="3rem">
+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
    {#if outline}
-
      <OutlineButton styleHeight="2.5rem" variant="ghost" {onclick}>
-
        <Icon name="add" />New patch<Icon name="chevron-down" />
+
      <OutlineButton
+
        styleHeight="2.5rem"
+
        variant="ghost"
+
        {onclick}
+
        active={popoverExpanded}>
+
        <Icon name="add" />New patch
      </OutlineButton>
    {:else}
-
      <Button styleHeight="2.5rem" variant="secondary" {onclick}>
-
        <Icon name="add" />New patch<Icon name="chevron-down" />
+
      <Button
+
        styleHeight="2.5rem"
+
        variant="secondary"
+
        {onclick}
+
        active={popoverExpanded}>
+
        <Icon name="add" />New patch
      </Button>
    {/if}
  {/snippet}
added src/components/NodeStatusButton.svelte
@@ -0,0 +1,46 @@
+
<script lang="ts">
+
  import { nodeRunning } from "@app/lib/events";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="2.5rem"
+
  bind:expanded={popoverExpanded}
+
  popoverPositionRight="0">
+
  {#snippet toggle(onclick)}
+
    <NakedButton variant="ghost" {onclick} active={popoverExpanded}>
+
      {#if $nodeRunning}
+
        <Icon name="online" />
+
        Online
+
      {:else}
+
        <Icon name="offline" />
+
        Offline
+
      {/if}
+
    </NakedButton>
+
  {/snippet}
+
  {#snippet popover()}
+
    <Border
+
      variant="ghost"
+
      stylePadding="1rem"
+
      styleMinWidth="20rem"
+
      styleAlignItems="flex-start"
+
      styleFlexDirection="column">
+
      <div class="txt-small txt-missing">
+
        {#if $nodeRunning}
+
          Your node is up and running, your changes will be synced
+
          automatically.
+
        {:else}
+
          Your node is not running, changes you make are safe but won't be
+
          announced.
+
        {/if}
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/OutlineButton.svelte
@@ -229,6 +229,10 @@
    background-color: var(--button-color-1);
  }

+
  .container.active:not(.disabled) {
+
    color: var(--color-foreground-emphasized);
+
  }
+

  .container.disabled {
    color: var(--color-foreground-disabled);
  }
modified src/components/PatchStateButton.svelte
@@ -18,6 +18,8 @@
  }

  const { selectedState, onSelect }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
</script>

<style>
@@ -42,7 +44,11 @@
  }
</style>

-
<Popover popoverPadding="0" popoverPositionTop="2rem" popoverPositionLeft="0">
+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="2rem"
+
  popoverPositionLeft="0"
+
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
    <button disabled={selectedState.status === "merged"} {onclick}>
      <span
@@ -57,7 +63,7 @@
            : `patch-${selectedState.status}`} />
        {capitalize(selectedState.status)}
        {#if selectedState.status !== "merged"}
-
          <Icon name="chevron-down" />
+
          <Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
        {/if}
      </span>
    </button>
added src/components/PatchStateFilterButton.svelte
@@ -0,0 +1,86 @@
+
<script lang="ts">
+
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { ProjectPayloadMeta } from "@bindings/repo/ProjectPayloadMeta";
+

+
  import capitalize from "lodash/capitalize";
+

+
  import { patchStatusColor } from "@app/lib/utils";
+

+
  import { closeFocused } from "@app/components/Popover.svelte";
+

+
  import Border from "@app/components/Border.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import OutlineButton from "@app/components/OutlineButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  interface Props {
+
    counters: ProjectPayloadMeta["patches"];
+
    select: (filter: PatchStatus | undefined) => Promise<void>;
+
    status: PatchStatus | undefined;
+
  }
+

+
  const { status, select, counters }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
{#snippet iconSnippet(status: PatchStatus | undefined)}
+
  <div class="icon" style:color={status ? patchStatusColor[status] : undefined}>
+
    <Icon
+
      name={status === undefined || status === "open"
+
        ? "patch"
+
        : `patch-${status}`} />
+
  </div>
+
{/snippet}
+

+
{#snippet counterSnippet(status: PatchStatus | undefined)}
+
  <div style:margin-left="auto" style:padding-left="0.25rem">
+
    {#if status}
+
      {counters[status]}
+
    {:else}
+
      {counters.draft + counters.open + counters.archived + counters.merged}
+
    {/if}
+
  </div>
+
{/snippet}
+

+
<Popover
+
  popoverPositionLeft="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <OutlineButton
+
      variant="ghost"
+
      {onclick}
+
      styleHeight="2.5rem"
+
      active={popoverExpanded}>
+
      {@render iconSnippet(status)}
+
      {status ? capitalize(status) : "All"}
+
      {@render counterSnippet(status)}
+
      <Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
+
    </OutlineButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <DropdownList
+
        items={[undefined, "draft", "open", "archived", "merged"] as const}>
+
        {#snippet item(state)}
+
          <DropdownListItem
+
            styleGap="0.5rem"
+
            styleMinHeight="2.5rem"
+
            selected={status === state}
+
            onclick={async () => {
+
              await select(state);
+
              closeFocused();
+
            }}>
+
            {@render iconSnippet(state)}
+
            {state ? capitalize(state) : "All"}
+
            {@render counterSnippet(state)}
+
          </DropdownListItem>
+
        {/snippet}
+
      </DropdownList>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/Popover.svelte
@@ -32,6 +32,7 @@
    popoverPositionLeft?: string;
    popoverPositionRight?: string;
    popoverPositionTop?: string;
+
    expanded?: boolean;
  }

  /* eslint-disable prefer-const */
@@ -45,6 +46,7 @@
    popoverPositionLeft,
    popoverPositionRight,
    popoverPositionTop,
+
    expanded = $bindable(false),
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -74,6 +76,10 @@
      }
    }
  }
+

+
  $effect(() => {
+
    expanded = focused?.element === thisComponent;
+
  });
</script>

<style>
@@ -96,7 +102,7 @@
  style:min-width={popoverContainerMinWidth}>
  {@render toggle(toggleFn)}

-
  {#if focused?.element === thisComponent}
+
  {#if expanded}
    <div
      class="popover"
      style:bottom={popoverPositionBottom}
added src/components/RepoGuide.svelte
@@ -0,0 +1,67 @@
+
<script lang="ts">
+
  import { z } from "zod";
+

+
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
+

+
  import clone from "@app/components/RepoGuide/clone.md?raw";
+
  import publish from "@app/components/RepoGuide/publish.md?raw";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Tab from "@app/components/Tab.svelte";
+

+
  const tab = useLocalStorage(
+
    "repoGuideTab",
+
    z.union([z.literal("clone"), z.literal("publish")]),
+
    "publish",
+
    !window.localStorage,
+
  );
+
</script>
+

+
<style>
+
  .container {
+
    overflow: scroll;
+
  }
+
  .tab {
+
    height: 1.5rem;
+
    color: var(--color-foreground-contrast);
+
  }
+
</style>
+

+
{#snippet tabSnippet(name: typeof tab.value, content: string)}
+
  <Tab
+
    active={tab.value === name}
+
    onclick={() => {
+
      tab.value = name;
+
    }}>
+
    <span class="tab">{content}</span>
+
  </Tab>
+
{/snippet}
+

+
<Border
+
  stylePosition="relative"
+
  variant="ghost"
+
  flatBottom
+
  styleDisplay="flex"
+
  styleWidth="100%"
+
  styleGap="1rem"
+
  stylePadding="0 1rem">
+
  {@render tabSnippet("clone", "Clone a repo from the network")}
+
  {@render tabSnippet("publish", "Publish existing repo")}
+
</Border>
+

+
<Border
+
  variant="ghost"
+
  flatTop
+
  stylePadding="1rem"
+
  styleDisplay="block"
+
  styleFlexDirection="column"
+
  styleAlignItems="flex-start">
+
  <div class="container txt-small">
+
    {#if tab.value === "clone"}
+
      <Markdown content={clone} />
+
    {:else if tab.value === "publish"}
+
      <Markdown content={publish} />
+
    {/if}
+
  </div>
+
</Border>
added src/components/RepoGuide/clone.md
@@ -0,0 +1,21 @@
+
#### 1. Find a repo on the Radicle network
+

+
You can search for Radicle repos by name or description at [search.radicle.xyz](https://search.radicle.xyz).
+

+
To clone a repo, you’ll need its Repository Identifier (RID) — a unique string that begins with `rad:`.
+

+
#### 2. Start your node
+

+
If you node is Offline, you should start it by running:
+

+
```sh
+
rad node start
+
```
+

+
#### 3. Clone the repo
+

+
To clone a repo, use the `rad clone` command followed by the RID of the repo you want to clone.
+

+
```sh
+
rad clone <RID>
+
```
added src/components/RepoGuide/publish.md
@@ -0,0 +1,15 @@
+
#### Publish existing repo on Radicle
+

+
Navigate to your existing Git repo and publish it to Radicle by following the setup prompts:
+

+
- **Repository Name:** Enter a name for your repo.
+
- **Description:** Provide a brief summary of what your repo does.
+
- **Default Branch:** Typically **main** or **master**.
+
- **Visibility:** Choose **public** to share with others or **private** to not publish it to the network yet.
+

+
```sh
+
cd path/to/your/repo
+
rad init
+
```
+

+
That's it! Your repo is now on the Radicle network. 🚀
added src/components/ReviewButton.svelte
@@ -0,0 +1,142 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { Review } from "@bindings/cob/patch/Review";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+
  import type { Verdict } from "@bindings/cob/patch/Verdict";
+

+
  import { closeFocused } from "./Popover.svelte";
+
  import { didFromPublicKey } from "@app/lib/utils";
+
  import { push } from "@app/lib/router";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import OutlineButton from "@app/components/OutlineButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  interface Props {
+
    rid: string;
+
    patchId: string;
+
    revision: Revision;
+
    config: Config;
+
    status: PatchStatus | undefined;
+
    loadPatch: () => Promise<void>;
+
    createReview: (verdict?: Verdict) => Promise<Review | undefined>;
+
  }
+

+
  const {
+
    rid,
+
    patchId,
+
    revision,
+
    config,
+
    status,
+
    loadPatch,
+
    createReview,
+
  }: Props = $props();
+

+
  const hasOwnReview = $derived(
+
    Boolean(
+
      revision.reviews &&
+
        revision.reviews.some(
+
          value => value.author.did === didFromPublicKey(config.publicKey),
+
        ),
+
    ),
+
  );
+

+
  let popoverExpanded: boolean = $state(false);
+
</script>
+

+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="2.5rem"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <NakedButton
+
      variant="ghost"
+
      disabled={hasOwnReview}
+
      active={popoverExpanded}
+
      {onclick}
+
      title={hasOwnReview ? "You already published a review" : undefined}>
+
      <Icon name="add" />
+
      <span class="txt-small">Review</span>
+
    </NakedButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border
+
      variant="ghost"
+
      stylePadding="1rem"
+
      styleDisplay="flex"
+
      styleFlexDirection="column">
+
      <div class="global-flex">
+
        <OutlineButton
+
          variant="ghost"
+
          disabled={hasOwnReview}
+
          title={hasOwnReview ? "You already published a review" : undefined}
+
          onclick={async () => {
+
            const newReview = await createReview();
+
            if (newReview) {
+
              await push({
+
                resource: "repo.patch",
+
                rid,
+
                patch: patchId,
+
                status,
+
                reviewId: newReview.id,
+
              });
+
            }
+
            closeFocused();
+
          }}>
+
          <span class="global-flex" style:color="var(--color-foreground-dim)">
+
            <Icon name="comment" />
+
            <span class="txt-small">Write Review</span>
+
          </span>
+
        </OutlineButton>
+
        <OutlineButton
+
          variant="ghost"
+
          disabled={hasOwnReview}
+
          title={hasOwnReview ? "You already published a review" : undefined}
+
          onclick={async () => {
+
            await createReview("reject");
+
            await loadPatch();
+
            closeFocused();
+
          }}>
+
          <span class="global-flex" style:color="var(--color-foreground-red)">
+
            <Icon name="comment-cross" />
+
            <span class="txt-small">Reject</span>
+
          </span>
+
        </OutlineButton>
+
        <OutlineButton
+
          variant="ghost"
+
          disabled={hasOwnReview}
+
          title={hasOwnReview ? "You already published a review" : undefined}
+
          onclick={async () => {
+
            await createReview("accept");
+
            await loadPatch();
+
            closeFocused();
+
          }}>
+
          <span
+
            class="global-flex"
+
            style:color="var(--color-foreground-success)">
+
            <Icon name="comment-checkmark" />
+
            <span class="txt-small">Accept</span>
+
            <span></span>
+
          </span>
+
        </OutlineButton>
+
      </div>
+

+
      <div
+
        class="txt-small txt-missing global-flex"
+
        style:margin-top="0.5rem"
+
        style:align-items="flex-start">
+
        <div style:padding-top="3px"><Icon name="info" /></div>
+
        <div>
+
          Clicking the buttons will create a blank review, add comments, a
+
          summary, and your verdict after. Depending on your sync settings your
+
          review might be published to the network right away. We are actively
+
          working on draft reviews, stay tuned.
+
        </div>
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/Reviews.svelte
@@ -6,43 +6,29 @@
  import type { Verdict } from "@bindings/cob/patch/Verdict";

  import { announce } from "@app/components/AnnounceSwitch.svelte";
-
  import { closeFocused } from "./Popover.svelte";
-
  import { didFromPublicKey } from "@app/lib/utils";
  import { invoke } from "@app/lib/invoke";
  import { nodeRunning } from "@app/lib/events";
-
  import { push } from "@app/lib/router";

-
  import Border from "@app/components/Border.svelte";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import OutlineButton from "@app/components/OutlineButton.svelte";
-
  import Popover from "@app/components/Popover.svelte";
+
  import ReviewButton from "@app/components/ReviewButton.svelte";
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";

  interface Props {
-
    rid: string;
+
    config: Config;
+
    loadPatch: () => Promise<void>;
    patchId: string;
    revision: Revision;
-
    config: Config;
+
    rid: string;
    status: PatchStatus | undefined;
-
    loadPatch: () => Promise<void>;
  }

-
  const { rid, patchId, revision, config, status, loadPatch }: Props = $props();
+
  const { config, loadPatch, patchId, revision, rid, status }: Props = $props();

  let hideReviews = $state(
    revision.reviews === undefined || revision.reviews.length === 0,
  );

-
  const hasOwnReview = $derived(
-
    Boolean(
-
      revision.reviews &&
-
        revision.reviews.some(
-
          value => value.author.did === didFromPublicKey(config.publicKey),
-
        ),
-
    ),
-
  );
-

  $effect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
    patchId;
@@ -83,118 +69,36 @@

<div style:margin={hideReviews ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
  <div class="global-flex">
-
    <NakedButton
-
      disabled={revision.reviews === undefined || revision.reviews.length === 0}
-
      variant="ghost"
-
      onclick={() => (hideReviews = !hideReviews)}>
-
      <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
-
      <div class="txt-semibold global-flex txt-regular">
+
    <div class="global-flex">
+
      <NakedButton
+
        stylePadding="0 4px"
+
        disabled={revision.reviews === undefined ||
+
          revision.reviews.length === 0}
+
        variant="ghost"
+
        onclick={() => (hideReviews = !hideReviews)}>
+
        <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
+
      </NakedButton>
+
      <div
+
        class="txt-semibold global-flex txt-regular"
+
        style:color={revision.reviews === undefined ||
+
        revision.reviews.length === 0
+
          ? "var(--color-foreground-disabled)"
+
          : undefined}>
        Reviews <span style:font-weight="var(--font-weight-regular)">
          {revision.reviews?.length ?? 0}
        </span>
      </div>
-
    </NakedButton>
+
    </div>

    <div class="global-flex" style:margin-left="auto">
-
      <Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
-
        {#snippet toggle(onclick)}
-
          <NakedButton
-
            variant="ghost"
-
            disabled={hasOwnReview}
-
            {onclick}
-
            title={hasOwnReview ? "You already published a review" : undefined}>
-
            <Icon name="add" />
-
            <span class="txt-small">Review</span>
-
          </NakedButton>
-
        {/snippet}
-

-
        {#snippet popover()}
-
          <Border
-
            variant="ghost"
-
            stylePadding="1rem"
-
            styleDisplay="flex"
-
            styleFlexDirection="column">
-
            <div class="global-flex">
-
              <OutlineButton
-
                variant="ghost"
-
                disabled={hasOwnReview}
-
                title={hasOwnReview
-
                  ? "You already published a review"
-
                  : undefined}
-
                onclick={async () => {
-
                  const newReview = await createReview();
-
                  if (newReview) {
-
                    await push({
-
                      resource: "repo.patch",
-
                      rid,
-
                      patch: patchId,
-
                      status,
-
                      reviewId: newReview.id,
-
                    });
-
                  }
-
                  closeFocused();
-
                }}>
-
                <span
-
                  class="global-flex"
-
                  style:color="var(--color-foreground-dim)">
-
                  <Icon name="comment" />
-
                  <span class="txt-small">Write Review</span>
-
                </span>
-
              </OutlineButton>
-
              <OutlineButton
-
                variant="ghost"
-
                disabled={hasOwnReview}
-
                title={hasOwnReview
-
                  ? "You already published a review"
-
                  : undefined}
-
                onclick={async () => {
-
                  await createReview("reject");
-
                  await loadPatch();
-
                  closeFocused();
-
                }}>
-
                <span
-
                  class="global-flex"
-
                  style:color="var(--color-foreground-red)">
-
                  <Icon name="comment-cross" />
-
                  <span class="txt-small">Reject</span>
-
                </span>
-
              </OutlineButton>
-
              <OutlineButton
-
                variant="ghost"
-
                disabled={hasOwnReview}
-
                title={hasOwnReview
-
                  ? "You already published a review"
-
                  : undefined}
-
                onclick={async () => {
-
                  await createReview("accept");
-
                  await loadPatch();
-
                  closeFocused();
-
                }}>
-
                <span
-
                  class="global-flex"
-
                  style:color="var(--color-foreground-success)">
-
                  <Icon name="comment-checkmark" />
-
                  <span class="txt-small">Accept</span>
-
                  <span></span>
-
                </span>
-
              </OutlineButton>
-
            </div>
-

-
            <div
-
              class="txt-small txt-missing global-flex"
-
              style:margin-top="0.5rem"
-
              style:align-items="flex-start">
-
              <div style:padding-top="3px"><Icon name="info" /></div>
-
              <div>
-
                Clicking the buttons will create a blank review, add comments, a
-
                summary, and your verdict after. Depending on your sync settings
-
                your review might be published to the network right away. We are
-
                actively working on draft reviews, stay tuned.
-
              </div>
-
            </div>
-
          </Border>
-
        {/snippet}
-
      </Popover>
+
      <ReviewButton
+
        {rid}
+
        {patchId}
+
        {revision}
+
        {config}
+
        {status}
+
        {loadPatch}
+
        {createReview} />
    </div>
  </div>

modified src/components/RevisionSelector.svelte
@@ -43,6 +43,8 @@
      ["desc"],
    ),
  );
+

+
  let popoverExpanded: boolean = $state(false);
</script>

<style>
@@ -54,9 +56,14 @@
  }
</style>

-
<Popover popoverPadding="0" popoverPositionTop="37px" popoverPositionLeft="0">
+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="37px"
+
  popoverPositionLeft="0"
+
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
    <NakedButton
+
      active={popoverExpanded}
      variant="ghost"
      onclick={(e: MouseEvent) => {
        e.stopPropagation();
modified src/components/Settings.svelte
@@ -30,11 +30,18 @@
    styleHeight = "2.5rem",
    popoverProps,
  }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
</script>

-
<Popover {...popoverProps}>
+
<Popover {...popoverProps} bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
-
    <NakedButton title="Settings" variant="ghost" {onclick} {styleHeight}>
+
    <NakedButton
+
      title="Settings"
+
      variant="ghost"
+
      {onclick}
+
      {styleHeight}
+
      active={popoverExpanded}>
      <Icon name="settings" />
      {#if !compact}
        Settings
modified src/components/VerdictButton.svelte
@@ -20,6 +20,8 @@
  }

  const { onSelect, summaryMissing, selectedVerdict }: Props = $props();
+

+
  let popoverExpanded: boolean = $state(false);
</script>

<style>
@@ -47,11 +49,15 @@
  }
</style>

-
<Popover popoverPadding="0" popoverPositionLeft="0" popoverPositionTop="2rem">
+
<Popover
+
  popoverPadding="0"
+
  popoverPositionLeft="0"
+
  popoverPositionTop="2rem"
+
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
    <button {onclick}>
      <VerdictBadge verdict={selectedVerdict} hoverable>
-
        <Icon name="chevron-down" />
+
        <Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
      </VerdictBadge>
    </button>
  {/snippet}
deleted src/views/home/clone.md
@@ -1,21 +0,0 @@
-
#### 1. Find a repo on the Radicle network
-

-
You can search for Radicle repos by name or description at [search.radicle.xyz](https://search.radicle.xyz).
-

-
To clone a repo, you’ll need its Repository Identifier (RID) — a unique string that begins with `rad:`.
-

-
#### 2. Start your node
-

-
If you node is Offline, you should start it by running:
-

-
```sh
-
rad node start
-
```
-

-
#### 3. Clone the repo
-

-
To clone a repo, use the `rad clone` command followed by the RID of the repo you want to clone.
-

-
```sh
-
rad clone <RID>
-
```
deleted src/views/home/guides/Repos.svelte
@@ -1,65 +0,0 @@
-
<script lang="ts">
-
  import { z } from "zod";
-
  import publish from "@app/views/home/publish.md?raw";
-
  import clone from "@app/views/home/clone.md?raw";
-

-
  import Border from "@app/components/Border.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import Tab from "@app/components/Tab.svelte";
-
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
-

-
  const tab = useLocalStorage(
-
    "repoGuideTab",
-
    z.union([z.literal("clone"), z.literal("publish")]),
-
    "publish",
-
    !window.localStorage,
-
  );
-
</script>
-

-
<style>
-
  .container {
-
    overflow: scroll;
-
  }
-
  .tab {
-
    height: 1.5rem;
-
    color: var(--color-foreground-contrast);
-
  }
-
</style>
-

-
{#snippet tabSnippet(name: typeof tab.value, content: string)}
-
  <Tab
-
    active={tab.value === name}
-
    onclick={() => {
-
      tab.value = name;
-
    }}>
-
    <span class="tab">{content}</span>
-
  </Tab>
-
{/snippet}
-

-
<Border
-
  stylePosition="relative"
-
  variant="ghost"
-
  flatBottom
-
  styleDisplay="flex"
-
  styleWidth="100%"
-
  styleGap="1rem"
-
  stylePadding="0 1rem">
-
  {@render tabSnippet("clone", "Clone a repo from the network")}
-
  {@render tabSnippet("publish", "Publish existing repo")}
-
</Border>
-

-
<Border
-
  variant="ghost"
-
  flatTop
-
  stylePadding="1rem"
-
  styleDisplay="block"
-
  styleFlexDirection="column"
-
  styleAlignItems="flex-start">
-
  <div class="container txt-small">
-
    {#if tab.value === "clone"}
-
      <Markdown content={clone} />
-
    {:else if tab.value === "publish"}
-
      <Markdown content={publish} />
-
    {/if}
-
  </div>
-
</Border>
deleted src/views/home/publish.md
@@ -1,15 +0,0 @@
-
#### Publish existing repo on Radicle
-

-
Navigate to your existing Git repo and publish it to Radicle by following the setup prompts:
-

-
- **Repository Name:** Enter a name for your repo.
-
- **Description:** Provide a brief summary of what your repo does.
-
- **Default Branch:** Typically **main** or **master**.
-
- **Visibility:** Choose **public** to share with others or **private** to not publish it to the network yet.
-

-
```sh
-
cd path/to/your/repo
-
rad init
-
```
-

-
That's it! Your repo is now on the Radicle network. 🚀
modified src/views/repo/Patch.svelte
@@ -10,7 +10,6 @@
  import type { Review } from "@bindings/cob/patch/Review";
  import type { Revision } from "@bindings/cob/patch/Revision";

-
  import capitalize from "lodash/capitalize";
  import fuzzysort from "fuzzysort";

  import * as roles from "@app/lib/roles";
@@ -28,11 +27,8 @@

  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
+
  import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
  import EditableTitle from "@app/components/EditableTitle.svelte";
  import Icon from "@app/components/Icon.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
@@ -40,11 +36,10 @@
  import Link from "@app/components/Link.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NewPatchButton from "@app/components/NewPatchButton.svelte";
-
  import OutlineButton from "@app/components/OutlineButton.svelte";
  import PatchStateButton from "@app/components/PatchStateButton.svelte";
+
  import PatchStateFilterButton from "@app/components/PatchStateFilterButton.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchTimeline from "@app/components/PatchTimeline.svelte";
-
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import ReviewComponent from "@app/components/Review.svelte";
  import RevisionBadges from "@app/components/RevisionBadges.svelte";
  import RevisionComponent from "@app/components/Revision.svelte";
@@ -104,13 +99,6 @@
    more = patches.more;
  });

-
  const checkoutCommand = $derived.by(() => {
-
    if (tab === "revisions" && selectedRevision.id !== patch.id) {
-
      return `rad patch checkout ${formatOid(patch.id)} --revision ${formatOid(selectedRevision.id)}`;
-
    } else {
-
      return `rad patch checkout ${formatOid(patch.id)}`;
-
    }
-
  });
  const project = $derived(repo.payloads["xyz.radicle.project"]!);

  async function updateTitle(newTitle: string) {
@@ -334,28 +322,6 @@
  }
</style>

-
{#snippet icons(status: PatchStatus | undefined)}
-
  <div class="icon" style:color={status ? patchStatusColor[status] : undefined}>
-
    <Icon
-
      name={status === undefined || status === "open"
-
        ? "patch"
-
        : `patch-${status}`} />
-
  </div>
-
{/snippet}
-

-
{#snippet counters(status: PatchStatus | undefined)}
-
  <div style:margin-left="auto" style:padding-left="0.25rem">
-
    {#if status}
-
      {project.meta.patches[status]}
-
    {:else}
-
      {project.meta.patches.draft +
-
        project.meta.patches.open +
-
        project.meta.patches.archived +
-
        project.meta.patches.merged}
-
    {/if}
-
  </div>
-
{/snippet}
-

<Layout {config} loadMoreSecondColumn={loadMoreTeasers}>
  {#snippet headerCenter()}
    <CopyableId id={patch.id} />
@@ -410,44 +376,12 @@
    </div>
    {#if showFilters}
      <div class="global-flex" style:margin="1rem 0">
-
        <Popover popoverPositionLeft="0" popoverPositionTop="3rem">
-
          {#snippet toggle(onclick)}
-
            <OutlineButton variant="ghost" {onclick} styleHeight="2.5rem">
-
              {@render icons(status)}
-
              {status ? capitalize(status) : "All"}
-
              {@render counters(status)}
-
              <Icon name="chevron-down" />
-
            </OutlineButton>
-
          {/snippet}
-

-
          {#snippet popover()}
-
            <Border variant="ghost">
-
              <DropdownList
-
                items={[
-
                  undefined,
-
                  "draft",
-
                  "open",
-
                  "archived",
-
                  "merged",
-
                ] as const}>
-
                {#snippet item(state)}
-
                  <DropdownListItem
-
                    styleGap="0.5rem"
-
                    styleMinHeight="2.5rem"
-
                    selected={status === state}
-
                    onclick={async () => {
-
                      await loadPatches(state);
-
                      closeFocused();
-
                    }}>
-
                    {@render icons(state)}
-
                    {state ? capitalize(state) : "All"}
-
                    {@render counters(state)}
-
                  </DropdownListItem>
-
                {/snippet}
-
              </DropdownList>
-
            </Border>
-
          {/snippet}
-
        </Popover>
+
        <PatchStateFilterButton
+
          counters={project.meta.patches}
+
          {status}
+
          select={async selectedState => {
+
            await loadPatches(selectedState);
+
          }} />
        {#if patchTeasers.length > 0}
          <TextInput
            onFocus={async () => {
@@ -562,28 +496,11 @@
          allowedToEdit={true}
          title={patch.title}
          cobId={patch.id} />
-
        <div class="txt-small" style:margin-left="auto" style:z-index="40">
-
          <Popover popoverPositionRight="0" popoverPositionTop="3rem">
-
            {#snippet toggle(onclick)}
-
              <Button styleHeight="2.5rem" variant="secondary" {onclick}>
-
                <Icon name="checkout" />Checkout patch<Icon
-
                  name="chevron-down" />
-
              </Button>
-
            {/snippet}
-
            {#snippet popover()}
-
              <Border
-
                styleAlignItems="flex-start"
-
                styleBackgroundColor="var(--color-background-float)"
-
                styleFlexDirection="column"
-
                styleGap="0.5rem"
-
                stylePadding="1rem"
-
                styleWidth="max-content"
-
                variant="ghost">
-
                To checkout this patch in your working copy, run:
-
                <Command command={checkoutCommand} styleWidth="100%" />
-
              </Border>
-
            {/snippet}
-
          </Popover>
+
        <div style:margin-left="auto" style:z-index="40">
+
          <CheckoutPatchButton
+
            {tab}
+
            selectedRevisionId={selectedRevision.id}
+
            patchId={patch.id} />
        </div>
      </div>
      <Border variant="ghost" styleGap="0">
modified src/views/repo/RepoHome.svelte
@@ -1,18 +1,16 @@
<script lang="ts">
  import type { Config } from "@bindings/config/Config";
-
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
  import type { Readme } from "@bindings/repo/Readme";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import Border from "@app/components/Border.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
+
  import CheckoutRepoButton from "@app/components/CheckoutRepoButton.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import File from "@app/components/File.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Layout from "./Layout.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import Path from "@app/components/Path.svelte";
-
  import Popover from "@app/components/Popover.svelte";
  import RepoHomeSecondColumn from "@app/components/RepoHomeSecondColumn.svelte";
  import RepoMetadata from "@app/components/RepoMetadata.svelte";

@@ -55,28 +53,8 @@
        style:font-weight="var(--font-weight-medium)">
        {project.data.name}
      </div>
-
      <div class="global-flex txt-small" style:margin-left="auto">
-
        <Popover popoverPositionRight="0" popoverPositionTop="3rem">
-
          {#snippet toggle(onclick)}
-
            <Button styleHeight="2.5rem" variant="secondary" {onclick}>
-
              <Icon name="checkout" />Checkout repo<Icon name="chevron-down" />
-
            </Button>
-
          {/snippet}
-

-
          {#snippet popover()}
-
            <Border
-
              styleAlignItems="flex-start"
-
              styleBackgroundColor="var(--color-background-float)"
-
              styleFlexDirection="column"
-
              styleGap="0.5rem"
-
              stylePadding="1rem"
-
              styleWidth="max-content"
-
              variant="ghost">
-
              To checkout a working copy of this repo, run:
-
              <Command command={`rad checkout ${repo.rid}`} styleWidth="100%" />
-
            </Border>
-
          {/snippet}
-
        </Popover>
+
      <div class="global-flex" style:margin-left="auto">
+
        <CheckoutRepoButton rid={repo.rid} />
      </div>
    </div>