Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Move onboarding instructions to popover
Sebastian Martinez committed 11 months ago
commit ff13c1e4f337a9830ce3bdac5b17ae5070326a04
parent f427902
26 files changed +461 -334
modified package-lock.json
@@ -15,7 +15,8 @@
        "@tauri-apps/plugin-dialog": "^2.2.0",
        "@tauri-apps/plugin-log": "^2.3.1",
        "@tauri-apps/plugin-shell": "^2.2.0",
-
        "@tauri-apps/plugin-window-state": "^2.2.1"
+
        "@tauri-apps/plugin-window-state": "^2.2.1",
+
        "zod": "^3.24.4"
      },
      "devDependencies": {
        "@eslint/js": "^9.22.0",
@@ -4837,6 +4838,14 @@
      "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
      "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
      "dev": true
+
    },
+
    "node_modules/zod": {
+
      "version": "3.24.4",
+
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
+
      "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
+
      "funding": {
+
        "url": "https://github.com/sponsors/colinhacks"
+
      }
    }
  }
}
modified package.json
@@ -30,7 +30,8 @@
    "@tauri-apps/plugin-dialog": "^2.2.0",
    "@tauri-apps/plugin-log": "^2.3.1",
    "@tauri-apps/plugin-shell": "^2.2.0",
-
    "@tauri-apps/plugin-window-state": "^2.2.1"
+
    "@tauri-apps/plugin-window-state": "^2.2.1",
+
    "zod": "^3.24.4"
  },
  "devDependencies": {
    "@eslint/js": "^9.22.0",
modified src/components/Border.svelte
@@ -9,6 +9,7 @@
    stylePosition?: string;
    stylePadding?: string;
    styleHeight?: string;
+
    styleMaxHeight?: string;
    styleMinHeight?: string;
    styleMinWidth?: string;
    styleWidth?: string;
@@ -31,6 +32,7 @@
    onclick,
    stylePadding,
    styleHeight,
+
    styleMaxHeight,
    styleMinHeight,
    stylePosition,
    styleWidth,
@@ -245,6 +247,7 @@
  <div class="p3-2"></div>
  <div
    class="p3-3"
+
    style:max-height={styleMaxHeight}
    style:min-width={styleMinWidth}
    style:display={styleDisplay}
    style:position={stylePosition}
modified src/components/CopyableId.svelte
@@ -1,7 +1,13 @@
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

  import Clipboard from "./Clipboard.svelte";

-
  const { id }: { id: string } = $props();
+
  const {
+
    inline = false,
+
    children,
+
    id,
+
  }: { inline?: boolean; children?: Snippet; id: string } = $props();

  let clipboard: Clipboard;
</script>
@@ -10,11 +16,17 @@
  .copyable-id {
    cursor: pointer;
    color: var(--color-foreground-dim);
+
    gap: 0.25rem;
  }

  .copyable-id:hover {
    color: var(--color-foreground-contrast);
  }
+
  .inline {
+
    display: inline-flex;
+
    gap: 0;
+
    white-space: nowrap;
+
  }
</style>

<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -22,7 +34,12 @@
  role="button"
  tabindex="0"
  onclick={() => clipboard.copy()}
+
  class:inline
  class="copyable-id global-flex txt-small txt-monospace">
-
  {id}
+
  {#if children}
+
    {@render children()}
+
  {:else}
+
    {id}
+
  {/if}
  <Clipboard bind:this={clipboard} text={id} />
</div>
modified src/components/Header.svelte
@@ -1,23 +1,59 @@
<script lang="ts">
  import type { Snippet } from "svelte";
+
  import type { Config } from "@bindings/config/Config";
+

+
  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 { dynamicInterval } from "@app/lib/interval";
  import { nodeRunning } from "@app/lib/events";
+
  import { didFromPublicKey, truncateDid } from "@app/lib/utils";

-
  import Avatar from "./Avatar.svelte";
-
  import Border from "./Border.svelte";
-
  import Icon from "./Icon.svelte";
-
  import NakedButton from "./NakedButton.svelte";
-
  import Popover from "./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 NakedButton from "@app/components/NakedButton.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Popover, { setFocused } from "@app/components/Popover.svelte";

  const activeRouteStore = router.activeRouteStore;

+
  const firstLaunchStorage = useLocalStorage(
+
    "appFirstLaunch",
+
    boolean(),
+
    true,
+
    !window.localStorage,
+
  );
+

  interface Props {
-
    publicKey: string;
+
    config: Config;
    center?: Snippet;
  }

-
  const { center, publicKey }: Props = $props();
+
  onMount(async () => {
+
    try {
+
      await checkRadicleCLI();
+
      dynamicInterval("checkRadicleCLI", checkRadicleCLI, 30_000);
+
    } catch {
+
      dynamicInterval("checkRadicleCLI", checkRadicleCLI, 1_000);
+
    }
+

+
    if (firstLaunchStorage.value === true) {
+
      setFocused("popover-guide");
+
      firstLaunchStorage.value = false;
+
    }
+
  });
+

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

<style>
@@ -49,6 +85,15 @@
    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">
@@ -61,7 +106,7 @@
            void router.push({ resource: "home" });
          }}
          stylePadding="0 4px">
-
          <Avatar {publicKey} />
+
          <Avatar publicKey={config.publicKey} />
        </NakedButton>
        <NakedButton
          variant="ghost"
@@ -85,6 +130,88 @@

      <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">
modified src/components/Icon.svelte
@@ -17,6 +17,8 @@
      | "branch"
      | "broom"
      | "broom-double"
+
      | "checkbox-checked"
+
      | "checkbox-unchecked"
      | "checkmark"
      | "checkout"
      | "chevron-down"
@@ -162,6 +164,19 @@
    <path d="M10 11L9 11L9 12L10 12L10 11Z" />
    <path d="M10 10L9 10L9 13L10 13L10 10Z" />
    <path d="M2 6L3 6L3 10L2 10L2 6Z" />
+
  {:else if name === "checkbox-checked"}
+
    <path d="M2 3H3V13H2V3Z" />
+
    <path d="M13 3H14V13H13V3Z" />
+
    <path d="M3 13H13V14H3L3 13Z" />
+
    <path
+
      d="M6 9.5V8.5H5V7.5L3 7.5L3 2L13 2V4.5L11 4.5V5.5H10L10 6.5H9V7.5H8V8.5H7V9.5H6Z" />
+
    <path
+
      d="M3 8.5H4L4 9.5L5 9.5L5 10.5H6V11.5H7L7 10.5L8 10.5V9.5H9V8.5H10V7.5L11 7.5V6.5H12V5.5H13L13 13H3L3 8.5Z" />
+
  {:else if name === "checkbox-unchecked"}
+
    <path d="M2 3H3V13H2V3Z" />
+
    <path d="M13 3H14V13H13V3Z" />
+
    <path d="M3 13H13V14H3L3 13Z" />
+
    <path d="M3 2H13V3H3L3 2Z" />
  {:else if name === "attachment"}
    <path d="M4 4H12V5H4V4Z" />
    <path d="M4 11H11V12H4V11Z" />
modified src/components/NodeId.svelte
@@ -6,6 +6,7 @@
  interface Props {
    publicKey: string;
    alias?: string;
+
    inline?: boolean;
    styleFontSize?: string;
    styleFontWeight?: string;
  }
@@ -13,6 +14,7 @@
  const {
    publicKey,
    alias,
+
    inline = false,
    styleFontSize = "var(--font-size-small)",
    styleFontWeight = "var(--font-weight-semibold)",
  }: Props = $props();
@@ -27,15 +29,24 @@
  .no-alias {
    color: var(--color-foreground-dim);
  }
+
  .inline {
+
    display: inline-flex;
+
    align-items: center;
+
    gap: 0.375rem;
+
  }
+
  .inline .alias {
+
    align-self: baseline;
+
  }
</style>

<div
  class="avatar-alias"
+
  class:inline
  style:font-size={styleFontSize}
  style:font-weight={styleFontWeight}>
  <Avatar {publicKey} />
  {#if alias}
-
    <span class="txt-overflow">
+
    <span class="txt-overflow alias">
      {alias}
    </span>
  {:else}
modified src/components/OutlineButton.svelte
@@ -2,6 +2,7 @@
  import type { Snippet } from "svelte";

  interface Props {
+
    popoverToggle?: string;
    active?: boolean;
    children: Snippet;
    disabled?: boolean;
@@ -12,6 +13,7 @@
  }

  const {
+
    popoverToggle,
    active = false,
    children,
    disabled = false,
@@ -271,6 +273,7 @@

<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
+
  data-popover-toggle={popoverToggle}
  class:active
  class:disabled
  class="container"
modified src/components/Popover.svelte
@@ -1,9 +1,21 @@
<script lang="ts" module>
-
  import { writable } from "svelte/store";
-
  const focused = writable<HTMLDivElement | undefined>(undefined);
+
  let focused = $state<{ element: HTMLDivElement; id: string } | undefined>(
+
    undefined,
+
  );

  export function closeFocused() {
-
    focused.set(undefined);
+
    focused = undefined;
+
  }
+

+
  export function setFocused(id: string) {
+
    const thisComponent = document.querySelector(`[data-popover-id="${id}"]`);
+
    if (thisComponent) {
+
      if (focused?.element === thisComponent) {
+
        closeFocused();
+
      } else {
+
        focused = { element: thisComponent as HTMLDivElement, id };
+
      }
+
    }
  }
</script>

@@ -13,49 +25,55 @@
  interface Props {
    toggle: Snippet<[() => void]>;
    popover: Snippet;
+
    popoverId?: string;
    popoverContainerMinWidth?: string;
    popoverPadding?: string;
    popoverPositionBottom?: string;
    popoverPositionLeft?: string;
    popoverPositionRight?: string;
    popoverPositionTop?: string;
-
    expanded?: boolean;
  }

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

+
  const id = popoverId ?? crypto.randomUUID();
  let thisComponent: HTMLDivElement | undefined = $state();

  function clickOutside(ev: MouseEvent | TouchEvent) {
-
    if ($focused && !ev.composedPath().includes($focused)) {
-
      closeFocused();
+
    const toggleElement = document.querySelector(
+
      `[data-popover-toggle="${id}"]`,
+
    );
+
    if (focused && !ev.composedPath().includes(focused.element)) {
+
      if (
+
        thisComponent === focused.element &&
+
        !ev.composedPath().includes(toggleElement!)
+
      ) {
+
        closeFocused();
+
      }
    }
  }

  function toggleFn() {
-
    expanded = !expanded;
-
    if ($focused === thisComponent) {
-
      closeFocused();
-
    } else {
-
      focused.set(thisComponent);
+
    if (thisComponent) {
+
      if (focused?.element === thisComponent) {
+
        closeFocused();
+
      } else {
+
        focused = { element: thisComponent, id };
+
      }
    }
  }
-

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

<style>
@@ -72,12 +90,13 @@
<svelte:window onclick={clickOutside} ontouchstart={clickOutside} />

<div
+
  data-popover-id={id}
  bind:this={thisComponent}
  class="container"
  style:min-width={popoverContainerMinWidth}>
  {@render toggle(toggleFn)}

-
  {#if expanded}
+
  {#if focused?.element === thisComponent}
    <div
      class="popover"
      style:bottom={popoverPositionBottom}
added src/lib/checkRadicleCLI.svelte.ts
@@ -0,0 +1,29 @@
+
import { dynamicInterval } from "@app/lib/interval";
+
import { invoke } from "@app/lib/invoke";
+

+
let lock = false;
+

+
let installed = $state(false);
+

+
export const radicleInstalled = () => installed;
+

+
export async function checkRadicleCLI() {
+
  try {
+
    if (lock) {
+
      return;
+
    }
+
    lock = true;
+
    await invoke<null>("check_radicle_cli");
+
    dynamicInterval(
+
      "checkRadicleCLI",
+
      checkRadicleCLI,
+
      import.meta.env.VITE_CHECK_RADICLE_LONG_DELAY || 30_000,
+
    );
+
    installed = true;
+
  } catch {
+
    dynamicInterval("checkRadicleCLI", checkRadicleCLI, 1_000);
+
    installed = false;
+
  } finally {
+
    lock = false;
+
  }
+
}
modified src/lib/startup.svelte.ts
@@ -1,9 +1,10 @@
import type { SyncStatus } from "@bindings/repo/SyncStatus";

-
import { listen } from "@tauri-apps/api/event";
+
import once from "lodash/once";
import { SvelteMap } from "svelte/reactivity";
+
import { listen } from "@tauri-apps/api/event";
+

import { nodeRunning, syncStatus } from "./events";
-
import once from "lodash/once";

// Will be called once in the startup of the app
export const createEventEmittersOnce = once(async () => {
added src/lib/useLocalStorage.svelte.ts
@@ -0,0 +1,63 @@
+
import { z, type SafeParseReturnType } from "zod";
+

+
export default function useLocalStorage<
+
  S extends z.infer<T>,
+
  T extends z.ZodType = z.ZodType<S>,
+
>(
+
  key: string,
+
  schema: T,
+
  initialValue: z.infer<typeof schema>,
+
  disableLocalStorage = false,
+
) {
+
  const stored = !disableLocalStorage ? localStorage.getItem(key) : null;
+

+
  const parseFromJson = (
+
    content: string,
+
  ): SafeParseReturnType<string, T["_output"]> => {
+
    return z
+
      .string()
+
      .transform((_, ctx) => {
+
        try {
+
          return JSON.parse(content);
+
        } catch {
+
          ctx.addIssue({
+
            code: z.ZodIssueCode.custom,
+
            message: "invalid json",
+
          });
+
          return z.never;
+
        }
+
      })
+
      .pipe(schema)
+
      .safeParse(content);
+
  };
+

+
  let value = $state<S>(initialValue);
+

+
  if (stored) {
+
    try {
+
      const parsed = parseFromJson(stored);
+
      if (parsed.success) {
+
        value = parsed.data;
+
      } else {
+
        console.error("Invalid stored data:", parsed.error);
+
      }
+
    } catch (error) {
+
      console.error("Error parsing stored data:", error);
+
    }
+
  }
+

+
  return {
+
    get value() {
+
      return value;
+
    },
+
    set value(v: S) {
+
      value = v;
+
      if (!disableLocalStorage)
+
        localStorage.setItem(key, JSON.stringify(value));
+
    },
+
    clear() {
+
      value = initialValue;
+
      if (!disableLocalStorage) localStorage.removeItem(key);
+
    },
+
  };
+
}
modified src/views/home/Inbox.svelte
@@ -165,14 +165,14 @@
</style>

<Layout
+
  {config}
  loadMoreContent={async () => {
    if (activeTab) {
      await loadMoreContent();
    }
  }}
  hideSidebar
-
  styleSecondColumnOverflow="visible"
-
  publicKey={config.publicKey}>
+
  styleSecondColumnOverflow="visible">
  {#snippet headerCenter()}
    <CopyableId id={config.publicKey} />
  {/snippet}
deleted src/views/home/Onboarding.svelte
@@ -1,226 +0,0 @@
-
<script lang="ts">
-
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
-

-
  import initialize from "@app/views/home/initialize.md?raw";
-
  import { invoke } from "@app/lib/invoke";
-

-
  import Border from "@app/components/Border.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import Tab from "@app/components/Tab.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import Textarea from "@app/components/Textarea.svelte";
-

-
  const { reload }: { reload: () => Promise<void> } = $props();
-

-
  const errors = $state<{
-
    repoName: ErrorWrapper[];
-
    repoDescription: ErrorWrapper[];
-
  }>({
-
    repoName: [],
-
    repoDescription: [],
-
  });
-
  let tab = $state<"create" | "init">("init");
-
  let repoName = $state<string>("");
-
  let repoDescription = $state<string>("");
-

-
  function validateInput(field: "name" | "description") {
-
    if (field === "name" && !validRepoName) {
-
      errors.repoName.push({ code: "ProjectError.InvalidName" });
-
    }
-
    if (field === "description" && !validRepoDescription) {
-
      errors.repoDescription.push({ code: "ProjectError.InvalidDescription" });
-
    }
-
  }
-

-
  const validRepoName = $derived(/^[a-zA-Z0-9._-]+$/.test(repoName));
-
  const validRepoDescription = $derived(repoDescription.length <= 255);
-

-
  async function createRepo() {
-
    try {
-
      await invoke("create_repo", {
-
        name: repoName,
-
        description: repoDescription,
-
      });
-
      void reload();
-
    } catch (err) {
-
      const e = err as ErrorWrapper;
-
      if (e.code === "ProjectError.InvalidName") {
-
        errors.repoName.push(e);
-
      } else if (e.code === "ProjectError.InvalidDescription") {
-
        errors.repoDescription.push(e);
-
      }
-
      console.error(err);
-
    }
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1rem;
-
  }
-
  .label {
-
    margin-bottom: 0.5rem;
-
  }
-
  .form {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
  }
-
  .tab {
-
    height: 1.5rem;
-
    color: var(--color-foreground-contrast);
-
  }
-
  .hint {
-
    padding: 0.25rem 0 0 0.25rem;
-
    gap: 0.25rem;
-
  }
-
  .create-repo-container {
-
    display: flex;
-
    gap: 2rem;
-
  }
-
</style>
-

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

-
<div class="txt-missing txt-small" style:margin-bottom="1.5rem">
-
  You don't have any repositories in your Radicle storage yet. To get started,
-
  try one of the options below.
-
</div>
-
<Border
-
  stylePosition="relative"
-
  variant="ghost"
-
  flatBottom
-
  styleDisplay="flex"
-
  styleWidth="100%"
-
  styleGap="1rem"
-
  stylePadding="0 1rem">
-
  {@render tabSnippet("create", "Create a new repo")}
-
  {@render tabSnippet("init", "Initialize existing repo")}
-
</Border>
-

-
<Border
-
  variant="ghost"
-
  flatTop
-
  stylePadding="1rem"
-
  styleDisplay="block"
-
  styleFlexDirection="column"
-
  styleAlignItems="flex-start">
-
  {#if tab === "create"}
-
    <div class="txt-small create-repo-container">
-
      <div class="container" style="width: 50%;">
-
        <div>Create a new repo initialized with Radicle.</div>
-

-
        <div class="form">
-
          <div style:text-align="left">
-
            <div class="label txt-tiny">Repository name (required)</div>
-
            <TextInput
-
              bind:value={repoName}
-
              oninput={() => {
-
                errors.repoName = [];
-
                validateInput("name");
-
              }}
-
              placeholder="Name of your repo" />
-
            {#if errors.repoName.length > 0}
-
              {#each errors.repoName as error}
-
                {#if error.code === "ProjectError.InvalidName" && repoName.length > 0}
-
                  <div
-
                    style="color: var(--color-foreground-red);"
-
                    class="hint txt-small global-flex">
-
                    <Icon name="warning" />
-
                    <span>
-
                      Only alphanumeric characters, '-', '_' and '.' are
-
                      allowed.
-
                    </span>
-
                  </div>
-
                {/if}
-
              {/each}
-
            {/if}
-
          </div>
-

-
          <div style:text-align="left">
-
            <div class="label txt-tiny">Description</div>
-
            <Textarea
-
              borderVariant="ghost"
-
              placeholder="Add description"
-
              oninput={() => {
-
                errors.repoDescription = [];
-
                validateInput("description");
-
              }}
-
              submit={async () => {
-
                await invoke("create_repo", {
-
                  name: repoName,
-
                  description: repoDescription,
-
                  defaultBranch: "master",
-
                });
-
                void reload();
-
              }}
-
              bind:value={repoDescription} />
-
            {#if errors.repoDescription.length > 0}
-
              {#each errors.repoDescription as error}
-
                <div
-
                  style="color: var(--color-foreground-red);"
-
                  class="hint txt-small global-flex">
-
                  <Icon name="warning" />
-
                  {#if error.code === "ProjectError.InvalidDescription"}
-
                    <span>Description cannot exceed 255 characters.</span>
-
                  {:else}
-
                    <span>{error.message}</span>
-
                  {/if}
-
                </div>
-
              {/each}
-
            {/if}
-
          </div>
-
          <div style:width="max-content">
-
            <Button
-
              disabled={!(validRepoDescription && validRepoName)}
-
              variant="secondary"
-
              onclick={createRepo}>
-
              Create new repo
-
            </Button>
-
          </div>
-
        </div>
-
      </div>
-
      <Border
-
        styleHeight="max-content"
-
        styleDisplay="flex"
-
        styleFlexDirection="column"
-
        styleAlignItems="flex-start"
-
        stylePadding="1rem"
-
        styleGap="0.5rem"
-
        variant="float"
-
        styleBackgroundColor="var(--color-background-float)">
-
        <div>👾</div>
-
        <div class="txt-bold txt-regular">Did you know?</div>
-
        <div>
-
          This repository will be stored in Radicle's local storage as a bare
-
          Git repository, so you don’t need to choose a folder or manually
-
          create one. Later, you can create a checkout to work with the
-
          repository as needed.
-
          <p>
-
            Want to learn more about how Radicle storage works compared to a
-
            regular Git working copy?
-
          </p>
-
          <!-- For handling whitespace -->
-
          <!-- prettier-ignore -->
-
          <span>Check out the <a target="_blank" class="txt-missing global-link" href="https://radicle.xyz/guides/protocol#local-first-storage">Protocol Guide</a>.</span>
-
        </div>
-
      </Border>
-
    </div>
-
  {:else}
-
    <div class="container txt-small">
-
      <Markdown rid="" content={initialize} />
-
    </div>
-
  {/if}
-
</Border>
modified src/views/home/Repos.svelte
@@ -19,9 +19,10 @@
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Layout from "@app/views/repo/Layout.svelte";
-
  import Onboarding from "@app/views/home/Onboarding.svelte";
+
  import OutlineButton from "@app/components/OutlineButton.svelte";
  import RepoCard from "@app/components/RepoCard.svelte";
  import TextInput from "@app/components/TextInput.svelte";
+
  import { setFocused } from "@app/components/Popover.svelte";

  interface Props {
    activeTab?: HomeReposTab;
@@ -106,10 +107,7 @@
  }
</style>

-
<Layout
-
  hideSidebar
-
  styleSecondColumnOverflow="visible"
-
  publicKey={config.publicKey}>
+
<Layout hideSidebar styleSecondColumnOverflow="visible" {config}>
  {#snippet headerCenter()}
    <CopyableId id={config.publicKey} />
  {/snippet}
@@ -187,7 +185,18 @@
        </Border>
      {/if}
    {:else}
-
      <Onboarding {reload} />
+
      <div class="txt-missing txt-small" style:margin-bottom="1.5rem">
+
        You don't have any repositories in your Radicle storage yet. To get
+
        started, check out the guide below.
+
      </div>
+
      <div style="display: flex; gap: 1rem;">
+
        <OutlineButton
+
          popoverToggle="popover-guide"
+
          onclick={() => setFocused("popover-guide")}
+
          variant="ghost">
+
          <Icon name="info" />Guide
+
        </OutlineButton>
+
      </div>
    {/if}
  </div>
</Layout>
added src/views/home/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/views/home/guides/Repos.svelte
@@ -0,0 +1,65 @@
+
<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/initialize.md
@@ -1,42 +0,0 @@
-
### 1. Install Radicle CLI
-

-
Run the following command in your terminal to install Radicle:
-

-
```sh
-
$ curl -sSf https://radicle.xyz/install | sh
-
```
-

-
### 2. Verify The Installation
-

-
Ensure Radicle CLI is installed correctly by checking its version:
-

-
```sh
-
$ rad --version
-
rad 1.1.0 (70f0cc35)
-
```
-

-
### 3. Initialize Your Repository
-

-
Navigate to your existing Git repository and initialize it with Radicle by following the setup prompts:
-

-
- **Repository Name:** Enter a name for your repository.
-
- **Description:** Provide a brief summary of what your repository does.
-
- **Default Branch:** Typically **main** or **master**.
-
- **Visibility:** Choose **public** to share with others or **private** to restrict access.
-

-
```sh
-
$ cd path/to/your/repository
-
$ rad init
-
```
-

-
### 4. Retrieve Your Repository Identifier (RID)
-

-
After initialization, your repository will show up here.  
-
You can retrieve its unique RID at any time:
-

-
```sh
-
$ rad .
-
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
-
```
-

-
That's it! Your repository is now a Radicle repo. 🚀
added src/views/home/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. 🚀
modified src/views/repo/CreateIssue.svelte
@@ -115,7 +115,7 @@
  }
</style>

-
<Layout publicKey={config.publicKey}>
+
<Layout {config}>
  {#snippet sidebar()}
    <Sidebar activeTab="issues" rid={repo.rid} />
  {/snippet}
modified src/views/repo/Issue.svelte
@@ -314,7 +314,7 @@
  }
</style>

-
<Layout publicKey={config.publicKey}>
+
<Layout {config}>
  {#snippet headerCenter()}
    <CopyableId id={issue.id} />
  {/snippet}
modified src/views/repo/Issues.svelte
@@ -83,10 +83,7 @@
  }
</style>

-
<Layout
-
  hideSidebar
-
  styleSecondColumnOverflow="visible"
-
  publicKey={config.publicKey}>
+
<Layout hideSidebar styleSecondColumnOverflow="visible" {config}>
  {#snippet headerCenter()}
    <CopyableId id={repo.rid} />
  {/snippet}
modified src/views/repo/Layout.svelte
@@ -29,10 +29,11 @@
  import { onMount } from "svelte";

  import Header from "@app/components/Header.svelte";
+
  import type { Config } from "@bindings/config/Config";

  interface Props {
    children: Snippet;
-
    publicKey: string;
+
    config: Config;
    headerCenter?: Snippet;
    secondColumn: Snippet;
    sidebar?: Snippet;
@@ -44,7 +45,7 @@

  const {
    children,
-
    publicKey,
+
    config,
    headerCenter = undefined,
    secondColumn,
    sidebar = undefined,
@@ -104,6 +105,7 @@
  .header {
    grid-column: 1 / 4;
    border-bottom: 2px solid var(--color-background-default);
+
    z-index: 100;
  }

  .sidebar {
@@ -132,7 +134,7 @@

<div class="layout">
  <div class="header">
-
    <Header {publicKey} center={headerCenter}></Header>
+
    <Header {config} center={headerCenter}></Header>
  </div>

  {#if sidebar}
modified src/views/repo/Patch.svelte
@@ -80,7 +80,6 @@
  let cursor: number = $state(0);
  let more: boolean = $state(false);
  let patchTeasers: Patch[] = $state([]);
-
  let checkoutPopoverExpanded = $state(false);

  let patches = $state(initialPatches);
  let status = $state(initialStatus);
@@ -357,7 +356,7 @@
  </div>
{/snippet}

-
<Layout loadMoreSecondColumn={loadMoreTeasers} publicKey={config.publicKey}>
+
<Layout {config} loadMoreSecondColumn={loadMoreTeasers}>
  {#snippet headerCenter()}
    <CopyableId id={patch.id} />
  {/snippet}
@@ -564,10 +563,7 @@
          title={patch.title}
          cobId={patch.id} />
        <div class="txt-small" style:margin-left="auto" style:z-index="40">
-
          <Popover
-
            bind:expanded={checkoutPopoverExpanded}
-
            popoverPositionRight="0"
-
            popoverPositionTop="3rem">
+
          <Popover popoverPositionRight="0" popoverPositionTop="3rem">
            {#snippet toggle(onclick)}
              <Button styleHeight="2.5rem" variant="secondary" {onclick}>
                <Icon name="checkout" />Checkout patch<Icon
modified src/views/repo/Patches.svelte
@@ -121,7 +121,7 @@
  {loadMoreContent}
  hideSidebar
  styleSecondColumnOverflow="visible"
-
  publicKey={config.publicKey}>
+
  {config}>
  {#snippet headerCenter()}
    <CopyableId id={repo.rid} />
  {/snippet}
modified src/views/repo/RepoHome.svelte
@@ -25,8 +25,6 @@
  const { config, readme, repo }: Props = $props();

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

-
  let checkoutPopoverExpanded: boolean = $state(false);
</script>

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

-
<Layout
-
  publicKey={config.publicKey}
-
  hideSidebar
-
  styleSecondColumnOverflow="visible">
+
<Layout {config} hideSidebar styleSecondColumnOverflow="visible">
  {#snippet headerCenter()}
    <CopyableId id={repo.rid} />
  {/snippet}
@@ -61,10 +56,7 @@
        {project.data.name}
      </div>
      <div class="global-flex txt-small" style:margin-left="auto">
-
        <Popover
-
          bind:expanded={checkoutPopoverExpanded}
-
          popoverPositionRight="0"
-
          popoverPositionTop="3rem">
+
        <Popover popoverPositionRight="0" popoverPositionTop="3rem">
          {#snippet toggle(onclick)}
            <Button styleHeight="2.5rem" variant="secondary" {onclick}>
              <Icon name="checkout" />Checkout repo<Icon name="chevron-down" />