Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement two column layout
Rūdolfs Ošiņš committed 2 years ago
commit 08d16dbbeb31ab94a1584b333eae218bf49c9ded
parent 56eefb86fa4a4038bc95fd8e53604f5ca650efb7
62 files changed +1638 -1076
modified index.html
@@ -69,7 +69,6 @@
    <link rel="stylesheet" type="text/css" href="/katex.min.css" />
    <link rel="stylesheet" type="text/css" href="/colors.css" />
    <link rel="stylesheet" type="text/css" href="/elevations.css" />
-
    <link rel="stylesheet" type="text/css" href="/layout.css" />
    <link rel="stylesheet" type="text/css" href="/prettylights.css" />
    <link rel="stylesheet" type="text/css" href="/index.css" />
    <link rel="icon" href="/radicle.svg" type="image/svg+xml" />
modified public/colors.css
@@ -37,7 +37,7 @@
  --color-fill-danger: #be7495;
  --color-fill-yellow: #ffe609;
  --color-fill-yellow-iconic: #ffff55;
-
  --color-fill-gray: #9494b8;
+
  --color-fill-gray: #9b9bb1;
  --color-fill-diff-red: #efdce4;
  --color-fill-diff-red-light: #f7eef2;
  --color-fill-success: #4fa877;
@@ -48,6 +48,8 @@
  --color-fill-merged: #ffeeff;
  --color-fill-selected: #ebebff;
  --color-fill-warning: #ffffe5;
+
  --color-fill-counter: #dbdbff;
+
  --color-fill-counter-emphasized: #dbdbff;
}

:root[data-theme="dark"] {
@@ -86,10 +88,10 @@
  --color-fill-separator: #24252d;
  --color-fill-primary: #ff55ff;
  --color-fill-primary-hover: #ff80ff;
-
  --color-fill-danger: #4d1929;
+
  --color-fill-danger: #aa5078;
  --color-fill-yellow: #ffe609;
  --color-fill-yellow-iconic: #ffff55;
-
  --color-fill-gray: #9494b8;
+
  --color-fill-gray: #9b9bb1;
  --color-fill-diff-red: #4d1929;
  --color-fill-diff-red-light: #2d060d;
  --color-fill-success: #4fa877;
@@ -100,4 +102,6 @@
  --color-fill-merged: #1a001a;
  --color-fill-selected: #16173d;
  --color-fill-warning: #191500;
+
  --color-fill-counter: #393a46;
+
  --color-fill-counter-emphasized: #232563;
}
modified public/index.css
@@ -39,12 +39,6 @@ body {
  scrollbar-color: var(--color-fill-separator) transparent;
}

-
@media (max-width: 720px) {
-
  body {
-
    min-width: 0;
-
  }
-
}
-

::selection {
  background: var(--color-fill-yellow-iconic);
  color: var(--color-foreground-black);
@@ -89,3 +83,28 @@ pre {
  font-family: var(--font-family-monospace);
  font-weight: var(--font-weight-regular);
}
+

+
.global-counter {
+
  border-radius: var(--border-radius-tiny);
+
  background-color: var(--color-fill-ghost);
+
  color: var(--color-foreground-dim);
+
  padding: 0 0.25rem;
+
  display: block;
+
  min-width: 1.25rem;
+
  text-align: center;
+
}
+

+
@media (max-width: 720px) {
+
  body {
+
    min-width: 0;
+
  }
+
  .global-hide-on-mobile {
+
    display: none !important;
+
  }
+
}
+

+
@media (min-width: 721px) {
+
  .global-hide-on-desktop {
+
    display: none !important;
+
  }
+
}
deleted public/layout.css
@@ -1,41 +0,0 @@
-
.layout-centered {
-
  height: 100%;
-
  padding-top: 5rem;
-
  padding-bottom: 24vh;
-
  display: flex;
-
  flex-direction: column;
-
  justify-content: center;
-
  align-items: center;
-
}
-

-
.layout-mobile,
-
.layout-mobile-inline,
-
.layout-mobile-flex {
-
  display: none !important;
-
}
-
.layout-desktop {
-
  display: block !important;
-
}
-
.layout-desktop-inline {
-
  display: inline !important;
-
}
-
.layout-desktop-flex {
-
  display: flex !important;
-
}
-

-
@media (max-width: 720px) {
-
  .layout-mobile {
-
    display: block !important;
-
  }
-
  .layout-mobile-inline {
-
    display: inline !important;
-
  }
-
  .layout-mobile-flex {
-
    display: flex !important;
-
  }
-
  .layout-desktop,
-
  .layout-desktop-flex,
-
  .layout-desktop-inline {
-
    display: none !important;
-
  }
-
}
modified src/App.svelte
@@ -5,9 +5,7 @@
  import * as httpd from "@app/lib/httpd";
  import { unreachable } from "@app/lib/utils";

-
  import Footer from "./App/Footer.svelte";
  import FullscreenModalPortal from "./App/FullscreenModalPortal.svelte";
-
  import Header from "./App/Header.svelte";
  import Hotkeys from "./App/Hotkeys.svelte";
  import LoadingBar from "./App/LoadingBar.svelte";

@@ -42,10 +40,12 @@
</script>

<style>
-
  .app {
+
  .loading {
    display: flex;
    flex-direction: column;
+
    justify-content: center;
    height: 100%;
+
    align-items: center;
  }
</style>

@@ -56,40 +56,36 @@
<FullscreenModalPortal />
<Hotkeys />

-
<div class="app">
-
  <Header />
-
  {#if $activeRouteStore.resource === "booting"}
+
{#if $activeRouteStore.resource === "booting"}
+
  <div class="loading">
    <Loading />
-
  {:else if $activeRouteStore.resource === "home"}
-
    <Home {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "nodes"}
-
    <Nodes {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "session"}
-
    <Session activeRoute={$activeRouteStore} />
-
  {:else if $activeRouteStore.resource === "project.source"}
-
    <Source {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.history"}
-
    <History {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.commit"}
-
    <Commit {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.issues"}
-
    <Issues {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.newIssue"}
-
    <NewIssue {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.issue"}
-
    <Issue {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.patches"}
-
    <Patches {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "project.patch"}
-
    <Patch {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "loadError"}
-
    <LoadError {...$activeRouteStore.params} />
-
  {:else if $activeRouteStore.resource === "notFound"}
-
    <NotFound {...$activeRouteStore.params} />
-
  {:else}
-
    {unreachable($activeRouteStore)}
-
  {/if}
-
  <div style:margin-top="auto">
-
    <Footer />
  </div>
-
</div>
+
{:else if $activeRouteStore.resource === "home"}
+
  <Home {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "nodes"}
+
  <Nodes {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "session"}
+
  <Session activeRoute={$activeRouteStore} />
+
{:else if $activeRouteStore.resource === "project.source"}
+
  <Source {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.history"}
+
  <History {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.commit"}
+
  <Commit {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.issues"}
+
  <Issues {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.newIssue"}
+
  <NewIssue {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.issue"}
+
  <Issue {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.patches"}
+
  <Patches {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.patch"}
+
  <Patch {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "loadError"}
+
  <LoadError {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "notFound"}
+
  <NotFound {...$activeRouteStore.params} />
+
{:else}
+
  {unreachable($activeRouteStore)}
+
{/if}
added src/App/AppLayout.svelte
@@ -0,0 +1,26 @@
+
<script lang="ts">
+
  import Footer from "@app/App/Footer.svelte";
+
  import Header from "@app/App/Header.svelte";
+
  import MobileFooter from "@app/App/MobileFooter.svelte";
+
</script>
+

+
<style>
+
  .app {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
  }
+
</style>
+

+
<div class="app">
+
  <Header />
+
  <slot />
+
  <div style:margin-top="auto">
+
    <div class="global-hide-on-mobile">
+
      <Footer />
+
    </div>
+
    <div class="global-hide-on-desktop">
+
      <MobileFooter />
+
    </div>
+
  </div>
+
</div>
modified src/App/Footer.svelte
@@ -45,7 +45,7 @@

<div class="footer">
  <div class="left">
-
    <span class="layout-desktop">Supported by</span>
+
    Supported by
    <a
      class="logo"
      target="_blank"
@@ -55,15 +55,15 @@
    </a>
  </div>

-
  <div class="center layout-desktop">
+
  <div class="center">
    Press <KeyHint>?</KeyHint>
    for keyboard shortcuts
  </div>
  <div class="right">
    <Popover popoverPositionBottom="2rem" popoverPositionRight="0">
      <IconButton slot="toggle" let:toggle on:click={toggle}>
-
        <IconSmall name="brush" />
-
        Theme
+
        <IconSmall name="settings" />
+
        Settings
      </IconButton>

      <div slot="popover" style:width="19rem">
modified src/App/Header.svelte
@@ -25,19 +25,19 @@
    justify-content: space-between;
    align-items: center;
    margin: 0;
-
    padding: 1rem;
+
    padding: 0.5rem;
+
    height: 3.5rem;
  }
  .left,
  .right {
    display: flex;
    align-items: center;
-
    height: var(--button-regular-height);
    gap: 1rem;
  }

  .logo {
    height: var(--button-regular-height);
-
    margin-right: 0.5rem;
+
    margin: 0 0.5rem;
  }
  .label {
    display: block;
@@ -59,29 +59,24 @@
    font-weight: var(--font-weight-bold);
    margin-bottom: 0.5rem;
  }
-
  @media (max-width: 720px) {
-
    header .right {
-
      gap: 1rem;
-
    }
-
  }
</style>

-
<header>
+
<header class="global-hide-on-mobile">
  <div class="left">
-
    <Link route={{ resource: "home" }}>
+
    <Link
+
      style="display: flex; align-items: center;"
+
      route={{ resource: "home" }}>
      <img
-
        width="40"
-
        height="40"
+
        width="24"
+
        height="24"
        class="logo"
        alt="Radicle logo"
        src="/radicle.svg" />
    </Link>
-
    <div class="layout-desktop">
-
      <Breadcrumbs />
-
    </div>
+
    <Breadcrumbs />
  </div>

-
  <div class="right layout-desktop-flex">
+
  <div class="right">
    {#if $httpdStore.state === "stopped"}
      <Popover popoverPositionTop="3rem" popoverPositionRight="0">
        <Button
@@ -90,7 +85,7 @@
          on:click={toggle}
          title={buttonTitle[$httpdStore.state]}
          size="large"
-
          variant="secondary-toggle-off">
+
          variant="naked-toggle-off">
          <IconSmall name="device" />
          Connect
        </Button>
modified src/App/Header/Authenticate.svelte
@@ -52,7 +52,7 @@
      let:toggle
      on:click={toggle}
      size="large"
-
      variant="secondary-toggle-on">
+
      variant="naked-toggle-on">
      <div class="peer-info">
        <div style:height="1.25rem" style:margin-right="0.5rem">
          <Avatar nodeId={$httpdStore.session.publicKey} />
@@ -84,7 +84,7 @@
      let:toggle
      on:click={toggle}
      size="large"
-
      variant="secondary-toggle-off">
+
      variant="naked-toggle-off">
      <IconSmall name="key" />
      Authenticate
    </Button>
modified src/App/Header/Breadcrumbs/ProjectSegment.svelte
@@ -8,6 +8,7 @@
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
  import Separator from "./Separator.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";

  export let activeRoute: ProjectLoadedRoute;
</script>
@@ -24,16 +25,18 @@
</style>

<span class="segment">
-
  {#if activeRoute.params.project.visibility?.type === "private"}
-
    <IconSmall name="lock" />
-
  {/if}
  <Link
    route={{
      resource: "project.source",
      project: activeRoute.params.project.id,
      node: activeRoute.params.baseUrl,
    }}>
-
    {activeRoute.params.project.name}
+
    <div class="segment">
+
      {#if activeRoute.params.project.visibility?.type === "private"}
+
        <IconSmall name="lock" />
+
      {/if}
+
      {activeRoute.params.project.name}
+
    </div>
  </Link>
</span>

@@ -79,7 +82,10 @@
      Patches
    </Link>
  {:else if activeRoute.resource === "project.source"}
-
    <!-- Don't show anything, project name already links here -->
+
    {#if activeRoute.params.path !== "/"}
+
      <Separator />
+
      <FilePath filenameWithPath={activeRoute.params.path} />
+
    {/if}
  {:else}
    {unreachable(activeRoute)}
  {/if}
modified src/App/Header/NodeInfo.svelte
@@ -23,7 +23,7 @@
    let:toggle
    on:click={toggle}
    size="large"
-
    variant={running ? "secondary-toggle-on" : "secondary-toggle-off"}>
+
    variant={running ? "naked-toggle-on" : "naked-toggle-off"}>
    <IconSmall name="broadcasting" />
    {#if running}
      Syncing
added src/App/MobileFooter.svelte
@@ -0,0 +1,93 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import ExternalLink from "@app/components/ExternalLink.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import RadworksLogo from "@app/components/RadworksLogo.svelte";
+
  import ThemeSettings from "./Header/ThemeSettings.svelte";
+
</script>
+

+
<style>
+
  .mobile-footer {
+
    width: 100%;
+
    display: flex;
+
    justify-content: space-between;
+
    padding: 0.5rem;
+
    position: fixed;
+
    bottom: 0;
+
    z-index: 1;
+
    gap: 0.5rem;
+
    border-top: 1px solid var(--color-fill-separator);
+
    background-color: var(--color-background-default);
+
  }
+
  .divider {
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    margin: 1.5rem 0;
+
  }
+
  .help {
+
    font-size: var(--font-size-small);
+
    color: var(--color-foreground-dim);
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
  a:hover {
+
    color: var(--color-fill-secondary);
+
  }
+
  .help-item {
+
    display: flex;
+
    justify-content: space-between;
+
    width: 100%;
+
  }
+
</style>
+

+
<div class="mobile-footer">
+
  <Link
+
    style="width: 100%; display: flex; align-items: center; justify-content: center;"
+
    route={{ resource: "home" }}>
+
    <img
+
      width="16"
+
      height="16"
+
      class="logo"
+
      alt="Radicle logo"
+
      src="/radicle.svg" />
+
  </Link>
+

+
  <slot />
+

+
  <div style:width="100%">
+
    <Popover popoverPositionBottom="3rem" popoverPositionRight="0">
+
      <Button
+
        let:expanded
+
        slot="toggle"
+
        variant={expanded ? "secondary" : "secondary-mobile-toggle"}
+
        styleWidth="100%"
+
        let:toggle
+
        on:click={toggle}>
+
        <IconSmall name="menu" />
+
      </Button>
+

+
      <div slot="popover" style:width="18.5rem">
+
        <div class="help">
+
          <div class="help-item">
+
            Supported by
+
            <a
+
              class="logo"
+
              target="_blank"
+
              rel="noreferrer"
+
              href="https://radworks.org">
+
              <RadworksLogo />
+
            </a>
+
          </div>
+
          <div class="help-item">
+
            About
+
            <ExternalLink href="https://radicle.xyz">radicle.xyz</ExternalLink>
+
          </div>
+
        </div>
+
        <div class="divider" />
+
        <ThemeSettings />
+
      </div>
+
    </Popover>
+
  </div>
+
</div>
modified src/components/Button.svelte
@@ -13,7 +13,12 @@
    | "secondary"
    | "secondary-toggle-off"
    | "secondary-toggle-on"
-
    | "tab" = "gray";
+
    | "secondary-mobile"
+
    | "secondary-mobile-toggle"
+
    | "naked-toggle-off"
+
    | "naked-toggle-on"
+
    | "tab"
+
    | "tab-active" = "gray";
  export let size: "small" | "regular" | "large" = "regular";

  export let autofocus: boolean = false;
@@ -24,6 +29,9 @@
  export let stylePadding: string | undefined = undefined;
  export let styleWidth: "100%" | undefined = undefined;
  export let styleBorderRadius: string | undefined = undefined;
+
  export let styleJustifyContent: "center" | "flex-start" = "center";
+

+
  let hover: boolean = false;
</script>

<style>
@@ -32,7 +40,6 @@
    cursor: pointer;
    display: flex;
    align-items: center;
-
    justify-content: center;
    border: none;
    border-radius: var(--border-radius-tiny);
    font-family: var(--font-family-sans-serif);
@@ -41,6 +48,7 @@
    font-feature-settings: inherit;
    white-space: nowrap;
    gap: 0.5rem;
+
    touch-action: manipulation;
  }

  button:disabled {
@@ -67,7 +75,7 @@
  }

  .background {
-
    color: var(--color-fill-secondary);
+
    color: var(--color-foreground-dim);
    background-color: var(--color-background-default);
  }
  .background[disabled] {
@@ -75,6 +83,7 @@
    background-color: var(--color-background-default);
  }
  .background:not([disabled]):hover {
+
    color: var(--color-foreground-contrast);
    background-color: var(--color-fill-ghost);
  }

@@ -131,7 +140,7 @@

  .outline {
    background-color: transparent;
-
    color: var(--color-foreground-contrast);
+
    color: var(--color-foreground-dim);
    border: 1px solid var(--color-border-hint);
  }
  .outline[disabled] {
@@ -141,7 +150,6 @@
  .outline:not([disabled]):hover {
    background-color: transparent;
    border: 1px solid var(--color-border-focus);
-
    color: var(--color-foreground-contrast);
  }

  .primary-toggle-on {
@@ -204,6 +212,35 @@
    color: var(--color-foreground-emphasized);
  }

+
  .naked-toggle-off {
+
    background-color: transparent;
+
    color: var(--color-foreground-dim);
+
    border: 1px solid transparent;
+
  }
+
  .naked-toggle-off[disabled] {
+
    background-color: transparent;
+
    color: var(--color-fill-gray);
+
  }
+
  .naked-toggle-off:not([disabled]):hover {
+
    background-color: transparent;
+
    border: 1px solid var(--color-fill-secondary);
+
  }
+

+
  .naked-toggle-on {
+
    background-color: transparent;
+
    color: var(--color-foreground-emphasized);
+
    border: 1px solid transparent;
+
  }
+
  .naked-toggle-on[disabled] {
+
    background-color: transparent;
+
    color: var(--color-fill-gray);
+
  }
+
  .naked-toggle-on:not([disabled]):hover {
+
    background-color: transparent;
+
    border: 1px solid var(--color-border-focus);
+
    color: var(--color-foreground-emphasized-hover);
+
  }
+

  .secondary {
    color: var(--color-foreground-match-background);
    background-color: var(--color-fill-secondary);
@@ -218,18 +255,61 @@
    background-color: var(--color-fill-secondary-hover);
  }

-
  .tab {
-
    background-color: var(--color-fill-secondary);
+
  .secondary-mobile {
+
    color: var(--color-foreground-dim);
+
    background-color: var(--color-background-default);
+
  }
+

+
  .secondary-mobile[disabled] {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-disabled);
+
  }
+

+
  .secondary-mobile:not([disabled]):active {
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-secondary);
+
  }
+

+
  .secondary-mobile-toggle {
+
    color: var(--color-foreground-dim);
+
    background-color: var(--color-background-default);
+
  }
+

+
  .secondary-mobile-toggle[disabled] {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-disabled);
+
  }
+

+
  .tab {
+
    background-color: var(--color-background-default);
+
    color: var(--color-foreground-contrast);
+
    border: 1px solid transparent;
+
    border-bottom: 1px solid var(--color-fill-separator);
  }

  .tab[disabled] {
-
    background-color: var(--color-fill-secondary);
-
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-background-default);
+
    color: var(--color-foreground-disabled);
  }

  .tab:not([disabled]):hover {
-
    background-color: var(--color-fill-secondary-hover);
+
    background-color: var(--color-fill-float-hover);
+
    border-top-right-radius: var(--border-radius-tiny) !important;
+
    border-top-left-radius: var(--border-radius-tiny) !important;
+
  }
+

+
  .tab-active {
+
    background-color: var(--color-background-default);
+
    border: 1px solid var(--color-fill-separator);
+
    border-bottom: 1px solid var(--color-background-default);
+
    color: var(--color-foreground-contrast);
+
    border-top-right-radius: var(--border-radius-tiny) !important;
+
    border-top-left-radius: var(--border-radius-tiny) !important;
+
  }
+

+
  .tab-active[disabled] {
+
    background-color: var(--color-background-default);
+
    color: var(--color-foreground-disabled);
  }
</style>

@@ -244,11 +324,18 @@
  style:padding={stylePadding}
  style:width={styleWidth}
  style:border-radius={styleBorderRadius}
+
  style:justify-content={styleJustifyContent}
  on:blur
  on:click
  on:focus
  on:mouseout
  on:mouseover
+
  on:mouseenter={() => {
+
    hover = true;
+
  }}
+
  on:mouseleave={() => {
+
    hover = false;
+
  }}
  class:disabled
  class:not-allowed={notAllowed}
  class:small={size === "small"}
@@ -264,7 +351,12 @@
  class:primary-toggle-on={variant === "primary-toggle-on"}
  class:secondary-toggle-off={variant === "secondary-toggle-off"}
  class:secondary-toggle-on={variant === "secondary-toggle-on"}
+
  class:naked-toggle-off={variant === "naked-toggle-off"}
+
  class:naked-toggle-on={variant === "naked-toggle-on"}
  class:secondary={variant === "secondary"}
-
  class:tab={variant === "tab"}>
-
  <slot />
+
  class:secondary-mobile={variant === "secondary-mobile"}
+
  class:secondary-mobile-toggle={variant === "secondary-mobile-toggle"}
+
  class:tab={variant === "tab"}
+
  class:tab-active={variant === "tab-active"}>
+
  <slot {hover} />
</button>
modified src/components/ExternalLink.svelte
@@ -12,14 +12,10 @@
    gap: 0.25rem;
    text-decoration: none;
  }
-

-
  .link {
-
    box-shadow: 0 1px 0 0 var(--color-foreground-contrast);
-
  }
-

-
  a:hover .link {
+
  a:hover {
+
    text-decoration: underline;
+
    text-underline-offset: 2px;
    color: var(--color-fill-secondary);
-
    box-shadow: 0 1px 0 0 var(--color-fill-secondary);
  }

  .icon {
@@ -34,6 +30,6 @@
</style>

<a {href} target="_blank" rel="noreferrer">
-
  <span class="link"><slot>{href}</slot></span>
+
  <slot>{href}</slot>
  <span class="icon"><IconSmall name="arrow-box-up-right" /></span>
</a>
modified src/components/File.svelte
@@ -4,6 +4,7 @@

  export let collapsable: boolean = false;
  export let expanded: boolean = true;
+
  export let sticky: boolean = true;

  let header: HTMLDivElement;
</script>
@@ -18,11 +19,14 @@
    border-top-left-radius: var(--border-radius-small);
    border-top-right-radius: var(--border-radius-small);
    background-color: var(--color-background-default);
-
    position: sticky;
-
    top: 0;
    z-index: 1;
  }

+
  .sticky {
+
    position: sticky;
+
    top: 3.5rem;
+
  }
+

  .collapsed {
    border-radius: var(--border-radius-small);
    border: 1px solid var(--color-border-hint);
@@ -51,16 +55,14 @@
    border-bottom-left-radius: var(--border-radius-small);
    border-bottom-right-radius: var(--border-radius-small);
  }
-

  @media (max-width: 720px) {
-
    .header,
-
    .container {
-
      border-radius: 0;
+
    .sticky {
+
      top: 0rem;
    }
  }
</style>

-
<div bind:this={header} class="header" class:collapsed={!expanded}>
+
<div bind:this={header} class="header" class:collapsed={!expanded} class:sticky>
  <div class="left">
    {#if collapsable}
      <ExpandButton
modified src/components/IconSmall.svelte
@@ -10,8 +10,8 @@
    | "broadcasting"
    | "brush"
    | "chat"
-
    | "checkbox-unchecked"
    | "checkbox-checked"
+
    | "checkbox-unchecked"
    | "checkmark"
    | "chevron-down"
    | "chevron-left"
@@ -35,14 +35,18 @@
    | "face"
    | "file"
    | "globe"
+
    | "help"
+
    | "home"
    | "issue"
    | "key"
-
    | "logo"
    | "lock"
+
    | "logo"
+
    | "menu"
    | "more"
    | "network"
    | "patch"
    | "plus"
+
    | "settings"
    | "user";
</script>

@@ -151,9 +155,8 @@
  {:else if name === "checkmark"}
    <path
      fill-rule="evenodd"
-
      d="M11.748 4.066a.5.5 0 01.186.682l-3.35 5.863a1.5 1.5 0 01-2.363.317L4.146 8.854a.5.5 0 11.708-.707l2.074 2.074a.5.5 0 00.787-.106l3.35-5.863a.5.5 0 01.683-.186z"
-
      clip-rule="evenodd">
-
    </path>
+
      clip-rule="evenodd"
+
      d="M12.748 3.06363C12.9877 3.19578 13.071 3.49038 12.934 3.72165L7.86936 12.2708C7.37865 13.0992 6.20572 13.2507 5.50635 12.5761L2.14645 9.3352C1.95118 9.14686 1.95118 8.84149 2.14645 8.65315C2.34171 8.46481 2.65829 8.46481 2.85355 8.65315L6.21345 11.894C6.44658 12.1189 6.83755 12.0684 7.00112 11.7923L12.0658 3.24309C12.2028 3.01182 12.5082 2.93148 12.748 3.06363Z" />
  {:else if name === "chevron-down"}
    <path
      fill-rule="evenodd"
@@ -309,6 +312,18 @@
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M3.4424 6.38689C4.03734 6.07787 4.81337 5.8403 5.68923 5.68926C5.73546 5.42115 5.78981 5.16239 5.85182 4.91533C5.99241 4.35527 6.17243 3.85525 6.38686 3.44243C5.01515 3.92794 3.92791 5.01518 3.4424 6.38689ZM7.99999 2.16669C4.77833 2.16669 2.16666 4.77836 2.16666 8.00002C2.16666 11.2217 4.77833 13.8334 7.99999 13.8334C11.2217 13.8334 13.8333 11.2217 13.8333 8.00002C13.8333 4.77836 11.2217 2.16669 7.99999 2.16669ZM7.99999 3.16669C7.86865 3.16669 7.59893 3.29241 7.29002 3.87344C7.10927 4.21341 6.94711 4.65475 6.8174 5.17614C6.78706 5.2981 6.75849 5.42444 6.73188 5.55489C7.13996 5.51895 7.56447 5.50002 7.99999 5.50002C8.43551 5.50002 8.86002 5.51895 9.2681 5.55489C9.24022 5.41825 9.2102 5.28611 9.17825 5.15879C9.04926 4.64495 8.8887 4.20963 8.70996 3.87344C8.40105 3.29241 8.13133 3.16669 7.99999 3.16669ZM10.1537 4.93762C10.0123 4.36853 9.83039 3.86072 9.61312 3.44243C10.9848 3.92794 12.0721 5.01518 12.5576 6.38689C11.9626 6.07787 11.1866 5.8403 10.3108 5.68926C10.2659 5.4292 10.2134 5.17795 10.1537 4.93762ZM9.42501 6.575C8.97388 6.52628 8.49569 6.50002 7.99999 6.50002C7.50429 6.50002 7.0261 6.52628 6.57497 6.575C6.52625 7.02613 6.49999 7.50432 6.49999 8.00002C6.49999 8.49572 6.52625 8.97391 6.57497 9.42504C7.02609 9.47376 7.50429 9.50002 7.99999 9.50002C8.49569 9.50002 8.97388 9.47376 9.42501 9.42504C9.47373 8.97391 9.49999 8.49572 9.49999 8.00002C9.49999 7.50432 9.47372 7.02612 9.42501 6.575ZM9.2681 10.4452C8.86002 10.4811 8.43551 10.5 7.99999 10.5C7.56447 10.5 7.13996 10.4811 6.73188 10.4452C6.87227 11.1333 7.06699 11.7071 7.29002 12.1266C7.59893 12.7076 7.86865 12.8334 7.99999 12.8334C8.13133 12.8334 8.40105 12.7076 8.70996 12.1266C8.93299 11.7071 9.12771 11.1333 9.2681 10.4452ZM9.61312 12.5576C9.92214 11.9627 10.1597 11.1866 10.3108 10.3108C11.1866 10.1597 11.9626 9.92217 12.5576 9.61315C12.0721 10.9849 10.9848 12.0721 9.61312 12.5576ZM12.8333 8.00002C12.8333 7.86868 12.7076 7.59896 12.1266 7.29005C11.7071 7.06702 11.1332 6.8723 10.4451 6.73191C10.4811 7.13999 10.5 7.5645 10.5 8.00002C10.5 8.43554 10.4811 8.86005 10.4451 9.26813C11.1333 9.12774 11.7071 8.93302 12.1266 8.70999C12.7076 8.40108 12.8333 8.13136 12.8333 8.00002ZM6.38686 12.5576C5.01515 12.0721 3.92791 10.9849 3.4424 9.61315C4.03734 9.92218 4.81337 10.1597 5.68923 10.3108C5.84027 11.1866 6.07784 11.9627 6.38686 12.5576ZM5.55486 9.26813C4.86673 9.12774 4.2929 8.93302 3.87341 8.70999C3.29238 8.40108 3.16666 8.13136 3.16666 8.00002C3.16666 7.86868 3.29238 7.59896 3.87341 7.29005C4.2929 7.06702 4.86673 6.8723 5.55486 6.73191C5.51892 7.13999 5.49999 7.5645 5.49999 8.00002C5.49999 8.43554 5.51892 8.86005 5.55486 9.26813Z" />
+
  {:else if name === "help"}
+
    <path
+
      d="M9 10.8419C9 11.3865 8.54467 11.8419 8 11.8419C7.45533 11.8419 7 11.3865 7 10.8419C7 10.2972 7.45533 9.84188 8 9.84188C8.54467 9.84188 9 10.2972 9 10.8419Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8 2.5C5.3534 2.5 2.5 4.52821 2.5 8C2.5 11.4718 5.3534 13.5 8 13.5C10.6466 13.5 13.5 11.4718 13.5 8C13.5 7.72386 13.7239 7.5 14 7.5C14.2761 7.5 14.5 7.72386 14.5 8C14.5 12.122 11.0956 14.5 8 14.5C4.90438 14.5 1.5 12.122 1.5 8C1.5 3.87804 4.90438 1.5 8 1.5C9.52918 1.5 10.6747 1.89561 11.4304 2.53262C12.1907 3.17354 12.5274 4.03904 12.4446 4.87775C12.3622 5.71281 11.8678 6.47921 11.0557 6.93153C10.3805 7.30768 9.51064 7.45531 8.5 7.28936V8.35417C8.5 8.63031 8.27614 8.85417 8 8.85417C7.72386 8.85417 7.5 8.63031 7.5 8.35417V6.66667C7.5 6.51033 7.57312 6.363 7.69763 6.26845C7.82214 6.17391 7.9837 6.14305 8.13429 6.18504C9.22008 6.48779 10.0333 6.35637 10.5691 6.05792C11.1056 5.75907 11.4005 5.27625 11.4495 4.77954C11.4981 4.28648 11.3084 3.73766 10.7859 3.2972C10.2588 2.85283 9.36507 2.5 8 2.5Z" />
+
  {:else if name === "home"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.10975 3.42707C8.04691 3.37209 7.95308 3.37209 7.89025 3.42707L2.99592 7.70961C2.7881 7.89145 2.47222 7.87039 2.29038 7.66258C2.10853 7.45476 2.12959 7.13888 2.33741 6.95703L7.23174 2.67449C7.6716 2.28961 8.32839 2.28961 8.76825 2.67449L13.6626 6.95703C13.8704 7.13888 13.8915 7.45476 13.7096 7.66258C13.5278 7.87039 13.2119 7.89145 13.0041 7.70961L8.10975 3.42707ZM4.73737 7.50501C5.01074 7.54407 5.20069 7.79733 5.16164 8.0707L4.71239 11.2155C4.64067 11.7175 5.03022 12.1667 5.53734 12.1667H6.16666V10.6667C6.16666 9.65413 6.98748 8.83332 8 8.83332C9.01252 8.83332 9.83333 9.65413 9.83333 10.6667V12.1667H10.4627C10.9698 12.1667 11.3593 11.7175 11.2876 11.2155L10.8384 8.0707C10.7993 7.79733 10.9893 7.54407 11.2626 7.50501C11.536 7.46596 11.7893 7.65591 11.8283 7.92928L12.2776 11.074C12.4353 12.1785 11.5783 13.1667 10.4627 13.1667H5.53734C4.42167 13.1667 3.56466 12.1785 3.72244 11.074L4.17169 7.92928C4.21074 7.65591 4.46401 7.46596 4.73737 7.50501ZM8.83333 12.1667V10.6667C8.83333 10.2064 8.46023 9.83332 8 9.83332C7.53976 9.83332 7.16666 10.2064 7.16666 10.6667V12.1667H8.83333Z" />
  {:else if name === "issue"}
    <path
      fill-rule="evenodd"
@@ -374,6 +389,19 @@
    <path d="M7.40908 13.3182H6.22726V14.5H7.40908V13.3182Z" />
    <path d="M9.77273 13.3182H8.59091V14.5H9.77273V13.3182Z" />
    <path d="M10.9546 13.3182H9.77274V14.5H10.9546V13.3182Z" />
+
  {:else if name === "menu"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14.2917 5.2002C14.2917 5.47634 14.0679 5.7002 13.7917 5.7002L4.45841 5.70019C4.18227 5.70019 3.95841 5.47634 3.95841 5.20019C3.95841 4.92405 4.18227 4.70019 4.45841 4.70019L13.7917 4.7002C14.0679 4.7002 14.2917 4.92405 14.2917 5.2002Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14.2917 8.2002C14.2917 8.47634 14.0679 8.7002 13.7917 8.7002L4.45841 8.70019C4.18227 8.70019 3.95841 8.47634 3.95841 8.20019C3.95841 7.92405 4.18227 7.70019 4.45841 7.70019L13.7917 7.7002C14.0679 7.7002 14.2917 7.92405 14.2917 8.2002Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14.2917 11.2002C14.2917 11.4763 14.0679 11.7002 13.7917 11.7002L4.45841 11.7002C4.18227 11.7002 3.95841 11.4763 3.95841 11.2002C3.95841 10.9241 4.18227 10.7002 4.45841 10.7002L13.7917 10.7002C14.0679 10.7002 14.2917 10.9241 14.2917 11.2002Z" />
  {:else if name === "lock"}
    <path
      d="M8 12C7.72386 12 7.5 11.7761 7.5 11.5V10.8662C7.2011 10.6933 7 10.3701 7 10C7 9.44772 7.44772 9 8 9C8.55228 9 9 9.44772 9 10C9 10.3701 8.7989 10.6933 8.5 10.8662V11.5C8.5 11.7761 8.27614 12 8 12Z" />
@@ -417,6 +445,19 @@
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M13.1667 8C13.1667 8.27614 12.9428 8.5 12.6667 8.5L3.33334 8.5C3.0572 8.5 2.83334 8.27614 2.83334 8C2.83334 7.72386 3.0572 7.5 3.33334 7.5L12.6667 7.5C12.9428 7.5 13.1667 7.72386 13.1667 8Z" />
+
  {:else if name === "settings"}
+
    <path
+
      d="M12.5 4C12.5 3.44772 12.0523 3 11.5 3C10.9477 3 10.5 3.44772 10.5 4C10.5 4.55228 10.9477 5 11.5 5H14C14.2761 5 14.5 4.77614 14.5 4.5C14.5 4.22386 14.2761 4 14 4H12.5Z" />
+
    <path
+
      d="M2.5 4.5C2.5 4.22386 2.72386 4 3 4H9C9.27614 4 9.5 4.22386 9.5 4.5C9.5 4.77614 9.27614 5 9 5H3C2.72386 5 2.5 4.77614 2.5 4.5Z" />
+
    <path
+
      d="M2.5 12.5C2.5 12.2239 2.72386 12 3 12H6C6.27614 12 6.5 12.2239 6.5 12.5C6.5 12.7761 6.27614 13 6 13H3C2.72386 13 2.5 12.7761 2.5 12.5Z" />
+
    <path
+
      d="M2.5 8.5C2.5 8.22386 2.72386 8 3 8H4.5C4.5 7.44772 4.94772 7 5.5 7C6.05228 7 6.5 7.44772 6.5 8C6.5 8.55228 6.05228 9 5.5 9H3C2.72386 9 2.5 8.77614 2.5 8.5Z" />
+
    <path
+
      d="M9.5 12H14C14.2761 12 14.5 12.2239 14.5 12.5C14.5 12.7761 14.2761 13 14 13H8.5C7.94772 13 7.5 12.5523 7.5 12C7.5 11.4477 7.94772 11 8.5 11C9.05229 11 9.5 11.4477 9.5 12Z" />
+
    <path
+
      d="M8 8C7.72386 8 7.5 8.22386 7.5 8.5C7.5 8.77614 7.72386 9 8 9H14C14.2761 9 14.5 8.77614 14.5 8.5C14.5 8.22386 14.2761 8 14 8H8Z" />
  {:else if name === "user"}
    <path
      fill-rule="evenodd"
modified src/components/InlineMarkdown.svelte
@@ -5,7 +5,8 @@
  import { twemoji } from "@app/lib/utils";

  export let content: string;
-
  export let fontSize: "tiny" | "small" | "regular" | "medium" = "small";
+
  export let fontSize: "tiny" | "small" | "regular" | "medium" | "large" =
+
    "small";

  const render = (content: string): string =>
    dompurify.sanitize(markdown.parseInline(content) as string);
@@ -26,6 +27,7 @@
<span
  class="markdown"
  use:twemoji
+
  class:txt-large={fontSize === "large"}
  class:txt-medium={fontSize === "medium"}
  class:txt-regular={fontSize === "regular"}
  class:txt-small={fontSize === "small"}
modified src/components/List.svelte
@@ -6,39 +6,20 @@

<style>
  .list {
-
    border-radius: var(--border-radius-small);
-
    border: 1px solid var(--color-border-hint);
+
    border-top: 1px solid var(--color-fill-separator);
+
    border-bottom: 1px solid var(--color-fill-separator);
  }
-

  .list-item:not(:last-child) {
-
    border-bottom: 1px solid var(--color-border-hint);
-
  }
-

-
  .header {
-
    padding: 0.5rem;
-

-
    border-bottom: 1px solid var(--color-border-hint);
-
  }
-

-
  @media (max-width: 720px) {
-
    .list {
-
      border-radius: 0;
-
    }
+
    border-bottom: 1px solid var(--color-fill-separator);
  }
</style>

-
<div class="list">
-
  {#if $$slots.header}
-
    <div class="header">
-
      <slot name="header" />
-
    </div>
-
  {/if}
-

-
  {#each items as item}
-
    <div class="list-item">
-
      <slot name="item" {item} />
-
    </div>
-
  {/each}
-

-
  <slot name="body" />
-
</div>
+
{#if items.length > 0}
+
  <div class="list">
+
    {#each items as item}
+
      <div class="list-item">
+
        <slot name="item" {item} />
+
      </div>
+
    {/each}
+
  </div>
+
{/if}
modified src/components/LoadError.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+
  import AppLayout from "@app/App/AppLayout.svelte";
  import Command from "./Command.svelte";
  import ExternalLink from "./ExternalLink.svelte";
  import Icon from "./Icon.svelte";
@@ -11,6 +12,11 @@
<style>
  .wrapper {
    gap: 1.5rem;
+
    height: 100%;
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    align-items: center;
  }

  .container {
@@ -25,26 +31,28 @@
  }
</style>

-
<div class="wrapper layout-centered">
-
  <Icon name="desert" size="48" />
-
  <div class="container">
-
    <div class="txt-medium txt-bold">
-
      {title}
-
    </div>
-
    <div class="help">
-
      If you need help resolving this issue, copy the error message
-
      <br />
-
      below and send it to us on
-
      <ExternalLink href="https://radicle.zulipchat.com">
-
        radicle.zulipchat.com
-
      </ExternalLink>
+
<AppLayout>
+
  <div class="wrapper">
+
    <Icon name="desert" size="48" />
+
    <div class="container">
+
      <div class="txt-medium txt-bold">
+
        {title}
+
      </div>
+
      <div class="help">
+
        If you need help resolving this issue, copy the error message
+
        <br />
+
        below and send it to us on
+
        <ExternalLink href="https://radicle.zulipchat.com">
+
          radicle.zulipchat.com
+
        </ExternalLink>
+
      </div>
    </div>
-
  </div>

-
  <div style:max-width="25rem">
-
    <Command
-
      command={JSON.stringify({ errorMessage, stackTrace })}
-
      fullWidth
-
      showPrompt={false} />
+
    <div style:max-width="25rem">
+
      <Command
+
        command={JSON.stringify({ errorMessage, stackTrace })}
+
        fullWidth
+
        showPrompt={false} />
+
    </div>
  </div>
-
</div>
+
</AppLayout>
modified src/components/Markdown.svelte
@@ -417,7 +417,7 @@
  .markdown :global(table) {
    margin: 1.5rem 0;
    border-collapse: collapse;
-
    border-radius: 0.5rem;
+
    border-radius: var(--border-radius-regular);
    border-style: hidden;
    box-shadow: 0 0 0 1px var(--color-border-hint);
    overflow: hidden;
modified src/components/ProjectCard.svelte
@@ -101,14 +101,6 @@
    gap: 0.25rem;
    flex-direction: column;
  }
-
  @media (max-width: 720px) {
-
    .project {
-
      min-width: 0;
-
    }
-
    .left {
-
      width: 100%;
-
    }
-
  }
</style>

<div class="project" class:compact>
@@ -156,9 +148,9 @@
  {#if !compact}
    <div class="right">
      <div class="id">
-
        <span class="rid layout-desktop">{id}</span>
+
        <span class="rid">{id}</span>
      </div>
-
      <div class="layout-desktop activity">
+
      <div class="activity">
        <ActivityDiagram
          {id}
          {activity}
deleted src/lib/pluralize.ts
@@ -1,18 +0,0 @@
-
export const pluralRules = {
-
  commit: "commits",
-
  contributor: "contributors",
-
  deletion: "deletions",
-
  file: "files",
-
  insertion: "insertions",
-
  issue: "issues",
-
  node: "nodes",
-
  patch: "patches",
-
  remote: "remotes",
-
} as const;
-

-
export function pluralize(
-
  singular: keyof typeof pluralRules,
-
  count: number,
-
): string {
-
  return count === 1 ? singular : pluralRules[singular];
-
}
modified src/modals/ColorPaletteModal.svelte
@@ -111,7 +111,7 @@
  .color {
    width: 3rem;
    height: 3rem;
-
    border-radius: 0.5rem;
+
    border-radius: var(--border-radius-regular);
    outline-style: solid !important;
    outline-color: #88888899 !important;
    outline-offset: 0.3rem;
modified src/views/NotFound.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+
  import AppLayout from "@app/App/AppLayout.svelte";
  import Icon from "@app/components/Icon.svelte";

  export let title: string;
@@ -8,14 +9,16 @@
  .container {
    display: flex;
    align-items: center;
+
    justify-content: center;
    flex-direction: column;
    gap: 1.5rem;
+
    height: 100%;
  }
</style>

-
<div class="layout-centered">
+
<AppLayout>
  <div class="container">
    <Icon name="desert" size="48" />
    <div class="title txt-medium txt-bold">{title}</div>
  </div>
-
</div>
+
</AppLayout>
modified src/views/home/Index.svelte
@@ -4,6 +4,7 @@
  import { api } from "@app/lib/httpd";
  import { twemoji } from "@app/lib/utils";

+
  import AppLayout from "@app/App/AppLayout.svelte";
  import Link from "@app/components/Link.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";

@@ -14,13 +15,12 @@

<style>
  .wrapper {
-
    padding: 3rem 15rem;
+
    padding: 3rem 16vw;
    width: 100%;
  }
  .blurb {
    color: var(--color-foreground-contrast);
    padding: 0rem;
-
    max-width: 65%;
    font-size: var(--font-size-medium);
    text-align: left;
    margin-bottom: 1.5rem;
@@ -29,8 +29,9 @@
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
-
    gap: 3rem;
+
    gap: 2.5rem;
    width: 100%;
+
    max-width: var(--content-max-width);
  }
  .project {
    width: 16rem;
@@ -41,70 +42,64 @@
    font-size: var(--font-size-medium);
    margin-bottom: 1rem;
  }
-
  @media (max-width: 1200px) {
-
    .wrapper {
-
      padding: 3rem 4rem;
-
    }
-
    .projects {
-
      gap: 2rem;
-
    }
-
  }
+

  @media (max-width: 720px) {
-
    .wrapper {
-
      padding: 3rem 2rem;
+
    .project {
+
      width: 100%;
    }
    .projects {
-
      gap: 1rem;
+
      margin-bottom: 4.5rem;
+
      gap: 1.5rem;
    }
-
    .blurb {
-
      max-width: none;
-
      font-size: var(--font-size-regular);
-
    }
-
    .heading {
-
      font-size: var(--font-size-regular);
+
    .wrapper {
+
      width: 100%;
+
      padding: 1rem 1.5rem 0 1.5rem;
    }
  }
</style>

-
<div class="wrapper">
-
  <div class="blurb">
-
    <p use:twemoji>
-
      Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
-
      over a peer-to-peer network 🌐 built on Git.
-
    </p>
-
  </div>
-

-
  {#if projects.length > 0}
-
    <div class="heading">
-
      {#if localProjects}
-
        <!-- prettier-ignore -->
-
        <span>Explore projects on your <span class="txt-bold">local node</span>.</span>
-
      {:else}
-
        <!-- prettier-ignore -->
-
        <span>Explore projects on the <span class="txt-bold">Radicle network</span>.</span>
-
      {/if}
+
<AppLayout>
+
  <div class="wrapper">
+
    <div class="blurb">
+
      <p use:twemoji>
+
        Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
+
        <br class="global-hide-on-mobile" />
+
        over a peer-to-peer network 🌐 built on Git.
+
      </p>
    </div>

-
    <div class="projects">
-
      {#each projects as { project, baseUrl, activity }}
-
        <div class="project">
-
          <Link
-
            route={{
-
              resource: "project.source",
-
              project: project.id,
-
              node: baseUrl,
-
            }}>
-
            <ProjectCard
-
              compact
-
              description={project.description}
-
              head={project.head}
-
              visibility={project.visibility?.type}
-
              id={project.id}
-
              name={project.name}
-
              {activity} />
-
          </Link>
-
        </div>
-
      {/each}
-
    </div>
-
  {/if}
-
</div>
+
    {#if projects.length > 0}
+
      <div class="heading">
+
        {#if localProjects}
+
          <!-- prettier-ignore -->
+
          <span>Explore projects on your <span class="txt-bold">local node</span>.</span>
+
        {:else}
+
          <!-- prettier-ignore -->
+
          <span>Explore projects on the <span class="txt-bold">Radicle network</span>.</span>
+
        {/if}
+
      </div>
+

+
      <div class="projects">
+
        {#each projects as { project, baseUrl, activity }}
+
          <div class="project">
+
            <Link
+
              route={{
+
                resource: "project.source",
+
                project: project.id,
+
                node: baseUrl,
+
              }}>
+
              <ProjectCard
+
                compact
+
                description={project.description}
+
                head={project.head}
+
                visibility={project.visibility?.type}
+
                id={project.id}
+
                name={project.name}
+
                {activity} />
+
            </Link>
+
          </div>
+
        {/each}
+
      </div>
+
    {/if}
+
  </div>
+
</AppLayout>
modified src/views/nodes/View.svelte
@@ -5,6 +5,7 @@
  import { isLocal, truncateId } from "@app/lib/utils";
  import { loadProjects } from "@app/views/nodes/router";

+
  import AppLayout from "@app/App/AppLayout.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
@@ -91,74 +92,71 @@
  }
</style>

-
<div class="layout">
-
  <div class="wrapper">
-
    <div class="header">
-
      <div class="title">
-
        {hostname}
-
      </div>
-
      <div class="info">
-
        <div>
-
          {#each externalAddresses as address}
-
            <!-- If there are externalAddresses this is probably a remote node -->
-
            <!-- in that case, we show all the defined externalAddresses as a listing -->
-
            <CopyableId id={`${nid}@${address}`}>
-
              {truncateId(nid)}@{address}
-
            </CopyableId>
-
          {:else}
-
            <!-- else this is probably a local node -->
-
            <!-- So we show only the nid -->
-
            <div class="layout-desktop">
-
              <CopyableId id={nid} />
-
            </div>
-
            <div class="layout-mobile">
-
              <CopyableId id={nid}>
-
                {truncateId(nid)}
-
              </CopyableId>
-
            </div>
-
          {/each}
+
<AppLayout>
+
  <div class="layout">
+
    <div class="wrapper">
+
      <div class="header">
+
        <div class="title">
+
          {hostname}
        </div>
-
        <div class="version">
-
          v{version}
+
        <div class="info">
+
          <div>
+
            {#each externalAddresses as address}
+
              <!-- If there are externalAddresses this is probably a remote node -->
+
              <!-- in that case, we show all the defined externalAddresses as a listing -->
+
              <CopyableId id={`${nid}@${address}`}>
+
                {truncateId(nid)}@{address}
+
              </CopyableId>
+
            {:else}
+
              <!-- else this is probably a local node -->
+
              <!-- So we show only the nid -->
+
              <CopyableId id={nid} />
+
            {/each}
+
          </div>
+
          <div class="version">
+
            v{version}
+
          </div>
        </div>
      </div>
-
    </div>

-
    <div class="projects">
-
      {#each projects as { project, activity } (project.id)}
-
        <Link
-
          route={{
-
            resource: "project.source",
-
            project: project.id,
-
            node: baseUrl,
-
          }}>
-
          <ProjectCard
-
            {activity}
-
            id={project.id}
-
            name={project.name}
-
            visibility={project.visibility?.type}
-
            description={project.description}
-
            head={project.head} />
-
        </Link>
-
      {/each}
-
    </div>
-

-
    {#if loadingProjects}
-
      <div class="more">
-
        <Loading noDelay small />
+
      <div class="projects">
+
        {#each projects as { project, activity } (project.id)}
+
          <Link
+
            route={{
+
              resource: "project.source",
+
              project: project.id,
+
              node: baseUrl,
+
            }}>
+
            <ProjectCard
+
              {activity}
+
              id={project.id}
+
              name={project.name}
+
              visibility={project.visibility?.type}
+
              description={project.description}
+
              head={project.head} />
+
          </Link>
+
        {/each}
      </div>
-
    {/if}

-
    {#if showMoreButton}
-
      <div class="more">
-
        <Button size="large" variant="outline" on:click={loadMore}>More</Button>
-
      </div>
-
    {/if}
+
      {#if loadingProjects}
+
        <div class="more">
+
          <Loading noDelay small />
+
        </div>
+
      {/if}

-
    {#if error}
-
      <ErrorMessage
-
        message="Not able to load more projects from this node"
-
        {error} />
-
    {/if}
+
      {#if showMoreButton}
+
        <div class="more">
+
          <Button size="large" variant="outline" on:click={loadMore}>
+
            More
+
          </Button>
+
        </div>
+
      {/if}
+

+
      {#if error}
+
        <ErrorMessage
+
          message="Not able to load more projects from this node"
+
          {error} />
+
      {/if}
+
    </div>
  </div>
-
</div>
+
</AppLayout>
modified src/views/projects/Changeset.svelte
@@ -7,8 +7,6 @@
    DiffFile,
  } from "@httpd-client";

-
  import { pluralize } from "@app/lib/pluralize";
-

  import FileDiff from "@app/views/projects/Changeset/FileDiff.svelte";
  import FileLocationChange from "@app/views/projects/Changeset/FileLocationChange.svelte";
  import Observer, { intersection } from "@app/components/Observer.svelte";
@@ -22,6 +20,10 @@

  let expanded = true;

+
  function pluralize(singular: string, count: number): string {
+
    return count === 1 ? singular : `${singular}s`;
+
  }
+

  const diffDescription = ({
    modified,
    added,
@@ -67,7 +69,7 @@
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
-
    padding-bottom: 1.5rem;
+
    padding-bottom: 1rem;
  }
  .additions {
    color: var(--color-foreground-success);
@@ -80,10 +82,13 @@
    flex-direction: column;
    gap: 1.5rem;
  }
+
  .summary {
+
    font-size: var(--font-size-small);
+
  }
</style>

<div class="header">
-
  <summary style:margin-left="1rem">
+
  <div class="summary">
    <span>{diffDescription(diff)}</span>
    with
    <span class:additions={diff.stats.insertions > 0}>
@@ -95,7 +100,7 @@
      {diff.stats.deletions}
      {pluralize("deletion", diff.stats.deletions)}
    </span>
-
  </summary>
+
  </div>
  {#if diff.stats.filesChanged > 1}
    <div style:display="flex" style:gap="1rem">
      <IconButton on:click={() => (expanded = !expanded)}>
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -113,7 +113,8 @@
        </Badge>
      {/each}
      {#if showInput}
-
        <div style="width:100%; display: flex; align-items: center;">
+
        <div
+
          style="width:100%; display: flex; align-items: center; gap: 0.5rem;">
          <TextInput
            {valid}
            autofocus
modified src/views/projects/Cob/CobCommitTeaser.svelte
@@ -48,12 +48,6 @@
    margin: 0.5rem 0;
    font-size: var(--font-size-tiny);
  }
-

-
  @media (max-width: 720px) {
-
    .left {
-
      overflow: hidden;
-
    }
-
  }
</style>

<div class="teaser" aria-label="commit-teaser">
modified src/views/projects/Cob/CobHeader.svelte
@@ -8,8 +8,6 @@
  .header {
    display: flex;
    flex-direction: column;
-
    border: 1px solid var(--color-border-hint);
-
    padding: 1.5rem;
    border-radius: var(--border-radius-small);
  }
  .subtitle {
modified src/views/projects/Cob/LabelInput.svelte
@@ -97,7 +97,8 @@
        </Badge>
      {/each}
      {#if showInput}
-
        <div style="width:100%; display: flex; align-items: center;">
+
        <div
+
          style="width:100%; display: flex; align-items: center; gap: 0.5rem;">
          <TextInput
            autofocus
            {valid}
@@ -137,6 +138,8 @@
        <Badge variant="neutral" size="small">
          {label}
        </Badge>
+
      {:else}
+
        <div class="txt-missing">No labels</div>
      {/each}
    {/if}
  </div>
modified src/views/projects/Commit.svelte
@@ -7,12 +7,10 @@
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Layout from "./Layout.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";

  export let baseUrl: BaseUrl;
  export let commit: Commit;
  export let project: Project;
-
  export let seeding: boolean;

  $: header = commit.commit;
</script>
@@ -20,45 +18,22 @@
<style>
  .header {
    margin-bottom: 3rem;
-
    border: 1px solid var(--color-border-hint);
-
    padding: 1.5rem;
    border-radius: var(--border-radius-small);
  }
-
  .summary {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    align-items: center;
-
  }
  .description {
    font-family: var(--font-family-monospace);
    margin: 1rem 0;
    white-space: pre-wrap;
  }
-
  .sha1 {
-
    align-items: center;
-
    color: var(--color-fill-secondary);
-
    font-size: var(--font-size-small);
-
  }
</style>

-
<Layout {baseUrl} {project} {seeding}>
+
<Layout {baseUrl} {project} styleContentMargin="0">
  <div class="header">
-
    <div class="summary">
-
      <div class="txt-medium txt-bold">
-
        <InlineMarkdown fontSize="medium" content={header.summary} />
-
      </div>
-
      <div class="layout-desktop-flex txt-monospace sha1">
-
        <CopyableId id={header.id} />
-
      </div>
-
      <div class="layout-mobile-flex txt-monospace sha1 txt-small">
-
        <CopyableId id={header.id}>
-
          {formatCommit(header.id)}
-
        </CopyableId>
-
      </div>
-
    </div>
+
    <InlineMarkdown fontSize="large" content={header.summary} />
    <pre class="description txt-small">{header.description}</pre>
-
    <CommitAuthorship {header} />
+
    <CommitAuthorship {header}>
+
      <span class="global-hash">{formatCommit(header.id)}</span>
+
    </CommitAuthorship>
  </div>
  <Changeset
    {baseUrl}
modified src/views/projects/Commit/CommitTeaser.svelte
@@ -56,12 +56,6 @@
    margin: 0.5rem 0;
    font-size: var(--font-size-small);
  }
-

-
  @media (max-width: 720px) {
-
    .left {
-
      overflow: hidden;
-
    }
-
  }
</style>

<div class="teaser">
modified src/views/projects/Header.svelte
@@ -5,12 +5,9 @@
<script lang="ts">
  import type { BaseUrl, Project } from "@httpd-client";

-
  import { pluralize } from "@app/lib/pluralize";
-

  import Link from "@app/components/Link.svelte";
  import Button from "@app/components/Button.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Radio from "@app/components/Radio.svelte";

  export let baseUrl: BaseUrl;
  export let activeTab: ActiveTab = undefined;
@@ -18,58 +15,101 @@
</script>

<style>
-
  .header {
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+

+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+

+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .title-counter {
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    flex-wrap: wrap;
    gap: 0.5rem;
+
    justify-content: space-between;
+
    width: 100%;
  }
</style>

-
<div class="header">
-
  <Radio outline>
-
    <Link
-
      route={{
-
        resource: "project.source",
-
        project: project.id,
-
        node: baseUrl,
-
        path: "/",
-
      }}>
-
      <Button variant={activeTab === "source" ? "secondary" : "background"}>
-
        <IconSmall name="chevron-left-right" />
-
        Source
-
      </Button>
-
    </Link>
-
    <Link
-
      route={{
-
        resource: "project.issues",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      <Button variant={activeTab === "issues" ? "secondary" : "background"}>
-
        <IconSmall name="issue" />
-
        <div>
+
<div class="container">
+
  <Link
+
    route={{
+
      resource: "project.source",
+
      project: project.id,
+
      node: baseUrl,
+
      path: "/",
+
    }}>
+
    <Button
+
      size="large"
+
      styleWidth="100%"
+
      styleJustifyContent="flex-start"
+
      variant={activeTab === "source" ? "gray" : "background"}>
+
      <IconSmall name="chevron-left-right" />
+
      Source
+
    </Button>
+
  </Link>
+
  <Link
+
    route={{
+
      resource: "project.issues",
+
      project: project.id,
+
      node: baseUrl,
+
    }}>
+
    <Button
+
      let:hover
+
      size="large"
+
      styleJustifyContent="flex-start"
+
      styleWidth="100%"
+
      variant={activeTab === "issues" ? "gray" : "background"}>
+
      <IconSmall name="issue" />
+
      <div class="title-counter">
+
        Issues
+
        <span
+
          class="counter"
+
          class:selected={activeTab === "issues"}
+
          class:hover={hover && activeTab !== "issues"}>
          {project.issues.open}
-
          {pluralize("issue", project.issues.open)}
-
        </div>
-
      </Button>
-
    </Link>
+
        </span>
+
      </div>
+
    </Button>
+
  </Link>

-
    <Link
-
      route={{
-
        resource: "project.patches",
-
        project: project.id,
-
        node: baseUrl,
-
      }}>
-
      <Button variant={activeTab === "patches" ? "secondary" : "background"}>
-
        <IconSmall name="patch" />
-
        <div>
+
  <Link
+
    route={{
+
      resource: "project.patches",
+
      project: project.id,
+
      node: baseUrl,
+
    }}>
+
    <Button
+
      let:hover
+
      size="large"
+
      styleWidth="100%"
+
      styleJustifyContent="flex-start"
+
      variant={activeTab === "patches" ? "gray" : "background"}>
+
      <IconSmall name="patch" />
+
      <div class="title-counter">
+
        Patches
+
        <span
+
          class="counter"
+
          class:hover={hover && activeTab !== "patches"}
+
          class:selected={activeTab === "patches"}>
          {project.patches.open}
-
          {pluralize("patch", project.patches.open)}
-
          <div></div>
-
        </div>
-
      </Button>
-
    </Link>
-
  </Radio>
+
        </span>
+
      </div>
+
    </Button>
+
  </Link>
</div>
modified src/views/projects/Header/SeedButton.svelte
@@ -26,6 +26,21 @@
    border-radius: var(--border-radius-tiny);
    padding: 0.125rem 0.25rem;
  }
+
  .title-counter {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+
  .counter {
+
    font-weight: var(--font-weight-regular);
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+
  .seeding {
+
    background-color: var(--color-fill-counter-emphasized);
+
    color: var(--color-foreground-emphasized);
+
  }
</style>

<Popover popoverPositionTop="3rem" popoverPositionRight="0">
@@ -43,9 +58,12 @@
    size="large"
    variant={seeding ? "secondary-toggle-on" : "secondary-toggle-off"}>
    <IconSmall name="network" />
-
    <span>
+
    <span class="title-counter">
      {seeding ? "Seeding" : "Seed"}
-
      <span style:font-weight="var(--font-weight-regular)">
+
      <span
+
        class="counter"
+
        class:seeding
+
        style:font-weight="var(--font-weight-regular)">
        {seedCount}
      </span>
    </span>
modified src/views/projects/History.svelte
@@ -12,13 +12,14 @@
  import { groupCommits } from "@app/lib/commit";
  import { COMMITS_PER_PAGE } from "./router";

+
  import Button from "@app/components/Button.svelte";
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import Header from "./Source/Header.svelte";
  import Layout from "./Layout.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Button from "@app/components/Button.svelte";
  import List from "@app/components/List.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";

  export let baseUrl: BaseUrl;
  export let branches: string[];
@@ -87,17 +88,15 @@
</script>

<style>
-
  .history {
-
    font-size: var(--font-size-small);
-
  }
  .more {
-
    margin-top: 2rem;
+
    margin: 2rem 0;
    min-height: 3rem;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .group-header {
+
    margin-left: 1rem;
    margin-top: 3rem;
    margin-bottom: 1rem;
    font-size: var(--font-size-small);
@@ -107,41 +106,36 @@
  .group-header:first-child {
    margin-top: 0;
  }
-

-
  @media (max-width: 720px) {
-
    .group-header {
-
      margin-left: 1rem;
-
    }
-
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="source">
-
  <svelte:fragment slot="subheader">
-
    <div style:margin-top="1rem">
-
      <Header
-
        node={baseUrl}
-
        {project}
-
        peers={peersWithRoute}
-
        branches={branchesWithRoute}
-
        {revision}
-
        {tree}
-
        filesLinkActive={false}
-
        historyLinkActive={true} />
-
    </div>
-
  </svelte:fragment>
+
<Layout {baseUrl} {project} activeTab="source" styleRightContentPadding="0">
+
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />
+

+
  <div style:margin-top="1rem" style:margin-left="1rem" slot="subheader">
+
    <Header
+
      node={baseUrl}
+
      {project}
+
      peers={peersWithRoute}
+
      branches={branchesWithRoute}
+
      {revision}
+
      {tree}
+
      filesLinkActive={false}
+
      historyLinkActive={true} />
+
  </div>

-
  <div class="history">
-
    {#each groupCommits(allCommitHeaders) as group (group.time)}
-
      <div class="group-header">{group.date}</div>
-
      <List items={group.commits}>
-
        <CommitTeaser
-
          slot="item"
-
          let:item
-
          projectId={project.id}
-
          {baseUrl}
-
          commit={item} />
-
      </List>
-
    {/each}
+
  {#each groupCommits(allCommitHeaders) as group (group.time)}
+
    <div class="group-header">{group.date}</div>
+
    <List items={group.commits}>
+
      <CommitTeaser
+
        slot="item"
+
        let:item
+
        projectId={project.id}
+
        {baseUrl}
+
        commit={item} />
+
    </List>
+
  {/each}
+

+
  {#if loading || allCommitHeaders.length < totalCommitCount}
    <div class="more">
      {#if loading}
        <Loading small={page !== 0} center />
@@ -149,7 +143,7 @@
        <Button size="large" variant="outline" on:click={loadMore}>More</Button>
      {/if}
    </div>
-
  </div>
+
  {/if}

  {#if error}
    <div class="message">
modified src/views/projects/Issue.svelte
@@ -47,7 +47,6 @@
  export let issue: Issue;
  export let project: Project;
  export let rawPath: (commit?: string) => string;
-
  export let seeding: boolean;

  const api = new HttpdClient(baseUrl);

@@ -401,8 +400,8 @@

<style>
  .issue {
-
    display: grid;
-
    grid-template-columns: minmax(0, 3fr) 1fr;
+
    display: flex;
+
    flex: 1;
  }
  .metadata {
    display: flex;
@@ -415,6 +414,7 @@
    border-radius: var(--border-radius-small);
    height: fit-content;
    gap: 1.5rem;
+
    width: 20rem;
  }

  .threads {
@@ -441,7 +441,6 @@
    align-items: center;
    gap: 0.5rem;
    font-size: var(--font-size-large);
-
    font-weight: var(--font-weight-medium);
    height: 2.5rem;
  }
  .reactions {
@@ -457,20 +456,16 @@
    color: var(--color-foreground-red);
  }

-
  @media (max-width: 960px) {
+
  @media (max-width: 720px) {
    .issue {
-
      display: grid;
-
      grid-template-columns: minmax(0, 1fr);
-
    }
-
    .metadata {
-
      display: none;
+
      display: block;
    }
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="issues">
+
<Layout {baseUrl} {project} activeTab="issues" styleContentMargin="0">
  <div class="issue">
-
    <div style="display: flex; flex-direction: column; gap: 1.5rem;">
+
    <div style="display: flex; flex: 1; flex-direction: column; gap: 1.5rem;">
      <CobHeader id={issue.id}>
        <svelte:fragment slot="title">
          {#if issueState !== "read"}
@@ -494,7 +489,7 @@
                class:open={issue.state.status === "open"}>
                <Icon name="issue" />
              </div>
-
              <InlineMarkdown fontSize="medium" content={issue.title} />
+
              <InlineMarkdown fontSize="large" content={issue.title} />
            </div>
          {/if}
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, issue.author.id) && issueState === "read"}
@@ -507,11 +502,11 @@
        </svelte:fragment>
        <svelte:fragment slot="state">
          {#if issue.state.status === "open"}
-
            <Badge size="small" variant="positive">
+
            <Badge size="tiny" variant="positive">
              {issue.state.status}
            </Badge>
          {:else}
-
            <Badge size="small" variant="negative">
+
            <Badge size="tiny" variant="negative">
              {issue.state.status} as
              {issue.state.reason}
            </Badge>
@@ -624,7 +619,7 @@
        </div>
      {/if}
    </div>
-
    <div class="metadata">
+
    <div class="metadata global-hide-on-mobile">
      <AssigneeInput
        locallyAuthenticated={Boolean(
          role.isDelegate(session?.publicKey, project.delegates),
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -29,8 +29,7 @@
    display: flex;
    padding: 1.25rem;
    background-color: var(--color-background-float);
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
+
    border-bottom: 1px solid var(--color-fill-separator);
  }
  .issue-teaser:hover {
    background-color: var(--color-fill-float-hover);
modified src/views/projects/Issue/New.svelte
@@ -25,7 +25,6 @@
  export let baseUrl: BaseUrl;
  export let project: Project;
  export let rawPath: (commit?: string) => string;
-
  export let seeding: boolean;

  let preview: boolean = false;
  let selectionStart = 0;
@@ -101,9 +100,8 @@

<style>
  .form {
-
    display: grid;
-
    grid-template-columns: minmax(0, 3fr) 1fr;
-
    margin-bottom: 1rem;
+
    display: flex;
+
    flex: 1;
  }
  .actions {
    display: flex;
@@ -115,36 +113,27 @@
  .metadata {
    display: flex;
    flex-direction: column;
-
    gap: 2rem;
    font-size: var(--font-size-small);
-
    padding-left: 1rem;
-
    margin-left: 1rem;
+
    padding: 1rem;
+
    margin-left: 3rem;
+
    border: 1px solid var(--color-border-hint);
+
    background-color: var(--color-background-float);
+
    border-radius: var(--border-radius-small);
+
    height: fit-content;
+
    gap: 1.5rem;
+
    width: 20rem;
  }
  .editor {
    flex: 2;
-
    padding-right: 1rem;
  }
  .author {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }
-
  @media (max-width: 720px) {
-
    .form {
-
      grid-template-columns: minmax(0, 1fr);
-
    }
-
    .editor {
-
      padding-right: 0;
-
    }
-
    .metadata {
-
      margin-left: 0;
-
      padding-left: 0;
-
      gap: 2rem;
-
    }
-
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="issues">
+
<Layout {baseUrl} {project} activeTab="issues">
  <main>
    {#if session}
      <div class="form">
modified src/views/projects/Issues.svelte
@@ -6,6 +6,7 @@
  import { closeFocused } from "@app/components/Popover.svelte";
  import { httpdStore } from "@app/lib/httpd";
  import { isLocal } from "@app/lib/utils";
+
  import capitalize from "lodash/capitalize";

  import Button from "@app/components/Button.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
@@ -16,7 +17,6 @@
  import IssueTeaser from "@app/views/projects/Issue/IssueTeaser.svelte";
  import Layout from "./Layout.svelte";
  import Link from "@app/components/Link.svelte";
-
  import List from "@app/components/List.svelte";
  import Loading from "@app/components/Loading.svelte";
  import Popover from "@app/components/Popover.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
@@ -25,7 +25,6 @@
  export let issues: Issue[];
  export let project: Project;
  export let state: IssueState["status"];
-
  export let seeding: boolean;

  let loading = false;
  let page = 0;
@@ -67,99 +66,119 @@
</script>

<style>
-
  .issues {
-
    font-size: var(--font-size-small);
-
  }
  .more {
-
    margin-top: 2rem;
+
    margin: 2rem 0;
    min-height: 3rem;
    display: flex;
    align-items: center;
    justify-content: center;
  }
+
  .dropdown-button-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
    padding: 0 0.25rem;
+
  }
+
  .dropdown-list-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-dim);
+
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="issues">
-
  <div class="issues">
-
    <List items={allIssues}>
-
      <div slot="header" style="display: flex;">
-
        <Popover
-
          popoverPadding="0"
-
          popoverPositionTop="2.5rem"
-
          popoverBorderRadius="var(--border-radius-small)">
-
          <Button
-
            let:expanded
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}
-
            ariaLabel="filter-dropdown"
-
            title="Filter issues by state">
-
            <div style:color={stateColor[state]}>
+
<Layout {baseUrl} {project} activeTab="issues" styleRightContentPadding="0">
+
  <div slot="header" style:display="flex" style:padding="1rem 1rem 0 1rem">
+
    <Popover
+
      popoverPadding="0"
+
      popoverPositionTop="2.5rem"
+
      popoverBorderRadius="var(--border-radius-small)">
+
      <Button
+
        let:expanded
+
        slot="toggle"
+
        let:toggle
+
        on:click={toggle}
+
        ariaLabel="filter-dropdown"
+
        title="Filter issues by state">
+
        <div style:color={stateColor[state]}>
+
          <Icon name="issue" />
+
        </div>
+
        {capitalize(state)}
+
        <div class="dropdown-button-counter">
+
          {project.issues[state]}
+
        </div>
+
        <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
      </Button>
+

+
      <DropdownList slot="popover" items={stateOptions}>
+
        <Link
+
          on:afterNavigate={() => closeFocused()}
+
          slot="item"
+
          let:item
+
          route={{
+
            resource: "project.issues",
+
            project: project.id,
+
            node: baseUrl,
+
            state: item,
+
          }}>
+
          <DropdownListItem selected={item === state}>
+
            <div style:color={stateColor[item]}>
              <Icon name="issue" />
            </div>
-
            {project.issues[state]}
-
            {state}
-
            <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
          </Button>
-

-
          <DropdownList slot="popover" items={stateOptions}>
-
            <Link
-
              on:afterNavigate={() => closeFocused()}
-
              slot="item"
-
              let:item
-
              route={{
-
                resource: "project.issues",
-
                project: project.id,
-
                node: baseUrl,
-
                state: item,
-
              }}>
-
              <DropdownListItem selected={item === state}>
-
                <div style:color={stateColor[item]}>
-
                  <Icon name="issue" />
-
                </div>
+
            <div
+
              style="display: flex; gap: 1rem;justify-content: space-between; width: 100%;">
+
              {capitalize(item)}
+
              <div
+
                class="dropdown-list-counter"
+
                class:selected={item === state}>
                {project.issues[item]}
-
                {item}
-
              </DropdownListItem>
-
            </Link>
-
          </DropdownList>
-
        </Popover>
-
        {#if $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
-
          <div style="margin-left: auto;">
-
            <Link
-
              route={{
-
                resource: "project.newIssue",
-
                project: project.id,
-
                node: baseUrl,
-
              }}>
-
              <Button variant="secondary">
-
                <IconSmall name="plus" />
-
                New Issue
-
              </Button>
-
            </Link>
-
          </div>
-
        {/if}
+
              </div>
+
            </div>
+
          </DropdownListItem>
+
        </Link>
+
      </DropdownList>
+
    </Popover>
+

+
    {#if $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
+
      <div style="margin-left: auto;">
+
        <Link
+
          route={{
+
            resource: "project.newIssue",
+
            project: project.id,
+
            node: baseUrl,
+
          }}>
+
          <Button variant="secondary">
+
            <IconSmall name="plus" />
+
            New Issue
+
          </Button>
+
        </Link>
      </div>
+
    {/if}
+
  </div>

-
      <IssueTeaser
-
        slot="item"
-
        let:item
-
        {baseUrl}
-
        projectId={project.id}
-
        issue={item} />
-

-
      <svelte:fragment slot="body">
-
        {#if error}
-
          <ErrorMessage message="Couldn't load issues" {error} />
-
        {/if}
-

-
        {#if project.issues[state] === 0}
-
          <div style:margin="4rem 0" style:width="100%">
-
            <Placeholder iconName="no-issues" caption={`No ${state} issues`} />
-
          </div>
-
        {/if}
-
      </svelte:fragment>
-
    </List>
+
  {#if allIssues.length > 0}
+
    <div style:border-top="1px solid var(--color-fill-separator)" />
+
  {/if}
+
  {#each allIssues as issue}
+
    <IssueTeaser {baseUrl} projectId={project.id} {issue} />
+
  {/each}
+

+
  {#if error}
+
    <ErrorMessage message="Couldn't load issues" {error} />
+
  {/if}
+

+
  {#if project.issues[state] === 0}
+
    <div
+
      style="height: calc(100vh - 7.5rem); display: flex; align-items: center; justify-content: center;">
+
      <Placeholder iconName="no-issues" caption={`No ${state} issues`} />
+
    </div>
+
  {/if}

+
  {#if loading || showMoreButton}
    <div class="more">
      {#if loading}
        <Loading noDelay small={page !== 0} center />
@@ -174,5 +193,5 @@
        </Button>
      {/if}
    </div>
-
  </div>
+
  {/if}
</Layout>
modified src/views/projects/Layout.svelte
@@ -1,171 +1,427 @@
<script lang="ts">
  import type { ActiveTab } from "./Header.svelte";
  import type { BaseUrl, Project } from "@httpd-client";
+
  import type { SvelteComponent } from "svelte";

-
  import dompurify from "dompurify";
+
  import { onMount } from "svelte";

-
  import * as modal from "@app/lib/modal";
-
  import capitalize from "lodash/capitalize";
-
  import markdown from "@app/lib/markdown";
-
  import { httpdStore, api } from "@app/lib/httpd";
-
  import { twemoji } from "@app/lib/utils";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
+
  import AppHeader from "@app/App/Header.svelte";
+
  import Clipboard from "@app/components/Clipboard.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-
  import Header from "@app/views/projects/Header.svelte";
+
  import ExternalLink from "@app/components/ExternalLink.svelte";
+

+
  import Button from "@app/components/Button.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import KeyHint from "@app/components/KeyHint.svelte";
  import Link from "@app/components/Link.svelte";
-
  import SeedButton from "@app/views/projects/Header/SeedButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import RadworksLogo from "@app/components/RadworksLogo.svelte";
+
  import ThemeSettings from "@app/App/Header/ThemeSettings.svelte";
+
  import MobileFooter from "@app/App/MobileFooter.svelte";

  export let activeTab: ActiveTab = undefined;
  export let baseUrl: BaseUrl;
  export let project: Project;
-
  export let seeding: boolean;
-

-
  let editSeedingInProgress = false;
-

-
  async function editSeeding() {
-
    if ($httpdStore.state === "authenticated") {
-
      try {
-
        editSeedingInProgress = true;
-
        if (seeding) {
-
          await api.stopSeedingById(project.id, $httpdStore.session.id);
-
        } else {
-
          await api.seedById(project.id, $httpdStore.session.id);
-
        }
-
        seeding = !seeding;
-
      } catch (error) {
-
        if (error instanceof Error) {
-
          modal.show({
-
            component: ErrorModal,
-
            props: {
-
              title: seeding
-
                ? "Stop seeding project failed"
-
                : "Seeding project failed",
-
              subtitle: [
-
                `There was an error while trying to ${
-
                  seeding ? "stop seeding" : "seed"
-
                } this project.`,
-
                "Check your radicle-httpd logs for details.",
-
              ],
-
              error: {
-
                message: error.message,
-
                stack: error.stack,
-
              },
-
            },
-
          });
-
        }
-
      } finally {
-
        editSeedingInProgress = false;
-
      }
+
  export let styleRightContentPadding: string = "1rem";
+
  export let styleContentMargin: string = "1rem 0 0 0";
+

+
  let expanded = true;
+

+
  const SIDEBAR_STATE_KEY = "sidebarState";
+
  export function storeSidebarState(expanded: boolean): void {
+
    window.localStorage.setItem(
+
      SIDEBAR_STATE_KEY,
+
      expanded ? "expanded" : "collapsed",
+
    );
+
  }
+

+
  function loadSidebarState(): boolean {
+
    const storedSidebarState = window.localStorage.getItem(SIDEBAR_STATE_KEY);
+

+
    if (storedSidebarState === null) {
+
      return true;
+
    } else {
+
      return storedSidebarState === "expanded" ? true : false;
    }
  }

-
  const render = (content: string): string =>
-
    dompurify.sanitize(markdown.parse(content) as string);
+
  onMount(() => {
+
    expanded = loadSidebarState();
+
  });
+
  let clipboard: SvelteComponent;
+

+
  let outerWidth: number;
+
  let rightContentMaxWidth: string;
+
  let rightContentMargin: string;

-
  $: session =
-
    $httpdStore.state === "authenticated" ? $httpdStore.session : undefined;
+
  $: if (outerWidth <= 720) {
+
    rightContentMaxWidth = "unset";
+
    rightContentMargin = "0 0 3rem 0";
+
  } else {
+
    if (expanded) {
+
      rightContentMaxWidth = `calc(100vw - 23rem)`;
+
      rightContentMargin = "3.5rem 0 0 22.5rem";
+
    } else {
+
      rightContentMaxWidth = `calc(100vw - 4.5rem)`;
+
      rightContentMargin = "3.5rem 0 0 4.5rem";
+
    }
+
  }
</script>

<style>
-
  .header {
-
    padding: 3rem 8rem 3rem 8rem;
-
    width: 100%;
-
    max-width: var(--content-max-width);
-
    min-width: var(--content-min-width);
+
  .layout {
+
    display: flex;
  }
-
  .title {
-
    align-items: center;
-
    gap: 0.5rem;
-
    color: var(--color-foreground-contrast);
+
  .expanded {
+
    width: 22.5rem;
+
    position: fixed;
+
    height: 100%;
+
    justify-content: space-between;
    display: flex;
-
    font-size: var(--font-size-x-large);
-
    font-weight: var(--font-weight-bold);
-
    justify-content: left;
-
    margin-bottom: 0.5rem;
-
    text-align: left;
-
    text-overflow: ellipsis;
+
    flex-direction: column;
+
    top: 0;
+
    left: 0;
+
    padding: 4.5rem 1rem 1rem 1rem;
+
    border-right: 1px solid var(--color-fill-separator);
+
    z-index: 1;
  }
-
  .project-name:hover {
-
    color: inherit;
+
  .collapsed {
+
    width: 4.5rem;
+
    position: fixed;
+
    height: 100%;
+
    justify-content: space-between;
+
    display: flex;
+
    flex-direction: column;
+
    top: 0;
+
    left: 0;
+
    padding: 4.5rem 1rem 1rem 1rem;
+
    border-right: 1px solid var(--color-fill-separator);
+
    z-index: 1;
  }
-
  .description :global(a) {
-
    border-bottom: 1px solid var(--color-foreground-contrast);
+
  .right-content {
+
    margin-top: 3.5rem;
+
    width: 100%;
+
  }
+
  .footer {
+
    display: flex;
+
    justify-content: space-between;
+
    width: 100%;
+
    gap: 1rem;
  }
  .content {
    width: 100%;
    max-width: var(--content-max-width);
    min-width: var(--content-min-width);
-
    padding: 0 8rem 4rem 8rem;
+
  }
+
  .id {
+
    border-radius: var(--border-radius-regular);
+
    border: 1px solid var(--color-border-hint);
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
  }

-
  @media (max-width: 960px) {
-
    .header {
-
      padding: 4rem 1rem 3rem 1rem;
-
    }
-
    .content {
-
      padding: 0 1rem 4rem 1rem;
-
    }
-
    .title {
-
      font-size: var(--font-size-medium);
-
      font-weight: var(--font-weight-bold);
-
    }
+
  .header-container {
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    background-color: var(--color-background-default);
+
    width: 100%;
+
    position: fixed;
+
    z-index: 2;
+
  }
+
  .help {
+
    font-size: var(--font-size-small);
+
    color: var(--color-foreground-dim);
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
  .help-item {
+
    display: flex;
+
    justify-content: space-between;
+
    width: 100%;
+
  }
+
  .logo {
+
    color: var(--color-foreground-contrast);
+
  }
+
  .divider {
+
    border-bottom: 1px solid var(--color-fill-separator);
+
  }
+
  a:hover {
+
    color: var(--color-fill-secondary);
  }

-
  @media (max-width: 720px) {
-
    .content {
-
      padding: 0 0 4rem 0;
-
    }
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+

+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+

+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .title-counter {
+
    display: flex;
+
    gap: 0.5rem;
+
    justify-content: space-between;
+
    width: 100%;
  }
</style>

-
<div class="header">
-
  <div class="title">
-
    <span class="txt-overflow">
+
<svelte:window bind:outerWidth />
+

+
<div class="header-container">
+
  <AppHeader />
+
</div>
+

+
<div class="layout">
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div class="sidebar global-hide-on-mobile">
+
    <div class={expanded ? "expanded" : "collapsed"}>
+
      <div style="display: flex; flex-direction: column; gap: 1rem;">
+
        {#if expanded}
+
          <div class="id" style:padding="0.5rem 0.75rem">
+
            <CopyableId id={project.id} />
+
          </div>
+
        {:else}
+
          <div
+
            title="Copy RID to clipboard"
+
            class="id"
+
            style:color="var(--color-fill-secondary)"
+
            style:cursor="pointer"
+
            style:padding="0.5rem 1rem"
+
            role="button"
+
            tabindex="0"
+
            on:click={() => {
+
              clipboard.copy();
+
            }}>
+
            <Clipboard bind:this={clipboard} text={project.id} />
+
          </div>
+
        {/if}
+
        <div class="container">
+
          <Link
+
            title="Home"
+
            route={{
+
              resource: "project.source",
+
              project: project.id,
+
              node: baseUrl,
+
              path: "/",
+
            }}>
+
            <Button
+
              size="large"
+
              styleWidth="100%"
+
              styleJustifyContent={expanded ? "flex-start" : "center"}
+
              variant={activeTab === "source" ? "gray" : "background"}>
+
              <IconSmall name="home" />
+
              {#if expanded}
+
                Home
+
              {/if}
+
            </Button>
+
          </Link>
+
          <Link
+
            title={`${project.issues.open} Issues`}
+
            route={{
+
              resource: "project.issues",
+
              project: project.id,
+
              node: baseUrl,
+
            }}>
+
            <Button
+
              let:hover
+
              size="large"
+
              styleJustifyContent={expanded ? "flex-start" : "center"}
+
              styleWidth="100%"
+
              variant={activeTab === "issues" ? "gray" : "background"}>
+
              <IconSmall name="issue" />
+
              {#if expanded}
+
                <div class="title-counter">
+
                  Issues
+
                  <span
+
                    class="counter"
+
                    class:selected={activeTab === "issues"}
+
                    class:hover={hover && activeTab !== "issues"}>
+
                    {project.issues.open}
+
                  </span>
+
                </div>
+
              {/if}
+
            </Button>
+
          </Link>
+

+
          <Link
+
            title={`${project.patches.open} Patches`}
+
            route={{
+
              resource: "project.patches",
+
              project: project.id,
+
              node: baseUrl,
+
            }}>
+
            <Button
+
              let:hover
+
              size="large"
+
              styleWidth="100%"
+
              styleJustifyContent={expanded ? "flex-start" : "center"}
+
              variant={activeTab === "patches" ? "gray" : "background"}>
+
              <IconSmall name="patch" />
+
              {#if expanded}
+
                <div class="title-counter">
+
                  Patches
+
                  <span
+
                    class="counter"
+
                    class:hover={hover && activeTab !== "patches"}
+
                    class:selected={activeTab === "patches"}>
+
                    {project.patches.open}
+
                  </span>
+
                </div>
+
              {/if}
+
            </Button>
+
          </Link>
+
        </div>
+
      </div>
+

+
      <div
+
        class="footer"
+
        style:flex-direction={expanded ? "row" : "column-reverse"}>
+
        <IconButton
+
          title={expanded ? "Collapse" : "Expand"}
+
          on:click={() => {
+
            expanded = !expanded;
+
            storeSidebarState(expanded);
+
          }}>
+
          {#if expanded}
+
            <IconSmall name="chevron-left" /> Collapse
+
          {:else}
+
            <IconSmall name="chevron-right" />
+
          {/if}
+
        </IconButton>
+
        <Popover popoverPositionBottom="2rem" popoverPositionLeft="0">
+
          <IconButton
+
            title="Settings"
+
            slot="toggle"
+
            let:toggle
+
            on:click={toggle}>
+
            <IconSmall name="settings" />
+
            {#if expanded}
+
              Settings
+
            {/if}
+
          </IconButton>
+

+
          <div slot="popover" style:width="18.5rem">
+
            <ThemeSettings />
+
          </div>
+
        </Popover>
+

+
        <Popover popoverPositionBottom="2rem" popoverPositionLeft="0">
+
          <IconButton title="Help" slot="toggle" let:toggle on:click={toggle}>
+
            <IconSmall name="help" />
+
            {#if expanded}
+
              Help
+
            {/if}
+
          </IconButton>
+

+
          <div slot="popover" style:width="18.5rem">
+
            <div class="help">
+
              <div class="help-item">
+
                Supported by
+
                <a
+
                  class="logo"
+
                  target="_blank"
+
                  rel="noreferrer"
+
                  href="https://radworks.org">
+
                  <RadworksLogo />
+
                </a>
+
              </div>
+
              <div class="help-item">
+
                About
+
                <ExternalLink href="https://radicle.xyz">
+
                  radicle.xyz
+
                </ExternalLink>
+
              </div>
+
              <div class="divider" />
+
              <div class="help-item">
+
                Keyboard shortcuts <KeyHint>?</KeyHint>
+
              </div>
+
            </div>
+
          </div>
+
        </Popover>
+
      </div>
+
    </div>
+
  </div>
+

+
  <div
+
    class="right-content"
+
    style:padding={styleRightContentPadding}
+
    style:max-width={rightContentMaxWidth}
+
    style:margin={rightContentMargin}>
+
    <slot name="header" />
+
    <slot name="subheader" />
+

+
    <div class="content" style:margin={styleContentMargin}>
+
      <slot />
+
    </div>
+
  </div>
+
</div>
+

+
<div class="global-hide-on-desktop">
+
  <MobileFooter>
+
    <div style:width="100%">
      <Link
+
        title="Home"
        route={{
          resource: "project.source",
          project: project.id,
          node: baseUrl,
+
          path: "/",
        }}>
-
        <span class="project-name">
-
          {project.name}
-
        </span>
+
        <Button
+
          variant={activeTab === "source" ? "secondary" : "secondary-mobile"}
+
          styleWidth="100%">
+
          <IconSmall name="home" />
+
        </Button>
      </Link>
-
    </span>
-
    {#if project.visibility && project.visibility.type === "private"}
-
      <Badge variant="yellowOutline" size="tiny">
-
        {capitalize(project.visibility.type)}
-
      </Badge>
-
    {/if}
-

-
    <div
-
      class="layout-desktop-flex"
-
      style="margin-left: auto; display: flex; gap: 0.5rem;">
-
      <SeedButton
-
        {seeding}
-
        disabled={editSeedingInProgress}
-
        projectId={project.id}
-
        editSeeding={session && editSeeding}
-
        seedCount={project.seeding} />
-
      <CloneButton {baseUrl} id={project.id} name={project.name} />
    </div>
-
  </div>

-
  <div class="description" use:twemoji>
-
    {@html render(project.description)}
-
  </div>
-

-
  <div style:margin-bottom="3rem">
-
    <CopyableId id={project.id} />
-
  </div>
-

-
  <Header {project} {activeTab} {baseUrl} />
-
  <slot name="subheader" />
-
</div>
+
    <div style:width="100%">
+
      <Link
+
        title={`${project.issues.open} Issues`}
+
        route={{
+
          resource: "project.issues",
+
          project: project.id,
+
          node: baseUrl,
+
        }}>
+
        <Button
+
          variant={activeTab === "issues" ? "secondary" : "secondary-mobile"}
+
          styleWidth="100%">
+
          <IconSmall name="issue" />
+
        </Button>
+
      </Link>
+
    </div>

-
<div class="content">
-
  <slot />
+
    <div style:width="100%">
+
      <Link
+
        title={`${project.patches.open} Patches`}
+
        route={{
+
          resource: "project.patches",
+
          project: project.id,
+
          node: baseUrl,
+
        }}>
+
        <Button
+
          variant={activeTab === "patches" ? "secondary" : "secondary-mobile"}
+
          styleWidth="100%">
+
          <IconSmall name="patch" />
+
        </Button>
+
      </Link>
+
    </div>
+
  </MobileFooter>
</div>
modified src/views/projects/Patch.svelte
@@ -57,7 +57,6 @@
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
-
  import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
@@ -83,7 +82,6 @@
  export let rawPath: (commit?: string) => string;
  export let project: Project;
  export let view: PatchView;
-
  export let seeding: boolean;

  $: api = new HttpdClient(baseUrl);

@@ -555,8 +553,8 @@

<style>
  .patch {
-
    display: grid;
-
    grid-template-columns: minmax(0, 3fr) 1fr;
+
    display: flex;
+
    flex: 1;
  }
  .metadata {
    display: flex;
@@ -569,6 +567,7 @@
    background-color: var(--color-background-float);
    border-radius: var(--border-radius-small);
    height: fit-content;
+
    width: 20rem;
  }
  .title {
    overflow: hidden;
@@ -578,18 +577,11 @@
    align-items: center;
    gap: 0.5rem;
    font-size: var(--font-size-large);
-
    font-weight: var(--font-weight-medium);
    height: 2.5rem;
  }
-
  .commit-list {
-
    border: 1px solid var(--color-border-hint);
-
    border-radius: var(--border-radius-small);
-
    overflow: hidden;
-
    margin-top: 1rem;
-
  }
  .tabs {
    display: flex;
-
    margin: 3rem 0 1.5rem 0;
+
    margin: 3rem 0 1rem 0;
  }
  .author {
    display: flex;
@@ -638,29 +630,22 @@
    font-family: var(--font-family-monospace);
    font-weight: var(--font-weight-bold);
  }
-
  .teaser-wrapper:not(:last-child) {
-
    border-bottom: 1px solid var(--color-border-hint);
-
  }
  .connector {
    width: 1px;
    height: 1.5rem;
    margin-left: 1rem;
    background-color: var(--color-fill-separator);
  }
-
  @media (max-width: 1092px) {
+
  @media (max-width: 720px) {
    .patch {
-
      display: grid;
-
      grid-template-columns: minmax(0, 1fr);
-
    }
-
    .metadata {
-
      display: none;
+
      display: block;
    }
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="patches">
+
<Layout {baseUrl} {project} activeTab="patches" styleContentMargin="0">
  <div class="patch">
-
    <div>
+
    <div style="display: flex; flex: 1; flex-direction: column;">
      <CobHeader id={patch.id}>
        <svelte:fragment slot="title">
          {#if patchState !== "read"}
@@ -688,7 +673,7 @@
                class:archived={patch.state.status === "archived"}>
                <Icon name="patch" />
              </div>
-
              <InlineMarkdown fontSize="medium" content={patch.title} />
+
              <InlineMarkdown fontSize="large" content={patch.title} />
            </div>
          {/if}
          {#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && patchState === "read"}
@@ -700,7 +685,7 @@
          {/if}
        </svelte:fragment>
        <svelte:fragment slot="state">
-
          <Badge size="small" variant={badgeColor(patch.state.status)}>
+
          <Badge size="tiny" variant={badgeColor(patch.state.status)}>
            {patch.state.status}
          </Badge>
        </svelte:fragment>
@@ -788,7 +773,7 @@
              state={patch.state}
              save={partial(saveStatus, session.id)} />
          {/if}
-
          {#if view.name === "commits" || view.name === "changes"}
+
          {#if view.name === "changes"}
            <div style="margin-left: auto;">
              <Popover
                popoverPadding="0"
@@ -920,14 +905,6 @@
              caption="No activity on this patch yet" />
          </div>
        {/each}
-
      {:else if view.name === "commits"}
-
        <div class="commit-list">
-
          {#each view.commits as commit}
-
            <div class="teaser-wrapper">
-
              <CommitTeaser projectId={project.id} {baseUrl} {commit} />
-
            </div>
-
          {/each}
-
        </div>
      {:else if view.name === "changes"}
        <div style:margin-top="1rem">
          <Changeset
@@ -938,11 +915,11 @@
            diff={view.diff} />
        </div>
      {:else}
-
        {utils.unreachable(view.name)}
+
        {utils.unreachable(view)}
      {/if}
    </div>

-
    <div class="metadata">
+
    <div class="metadata global-hide-on-mobile">
      <div>
        <div class="metadata-section-header">Reviews</div>
        <div class="metadata-section-body">
modified src/views/projects/Patch/PatchTeaser.svelte
@@ -41,8 +41,7 @@
    display: flex;
    padding: 1.25rem;
    background-color: var(--color-background-float);
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
+
    border-bottom: 1px solid var(--color-fill-separator);
  }
  .patch-teaser:hover {
    background-color: var(--color-fill-float-hover);
@@ -107,11 +106,6 @@
  .merged {
    color: var(--color-fill-primary);
  }
-
  @media (max-width: 960px) {
-
    .labels {
-
      display: none;
-
    }
-
  }
</style>

<div
modified src/views/projects/Patches.svelte
@@ -3,6 +3,7 @@

  import { HttpdClient } from "@httpd-client";
  import { PATCHES_PER_PAGE } from "./router";
+
  import capitalize from "lodash/capitalize";

  import Button from "@app/components/Button.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
@@ -12,7 +13,6 @@
  import IconSmall from "@app/components/IconSmall.svelte";
  import Layout from "./Layout.svelte";
  import Link from "@app/components/Link.svelte";
-
  import List from "@app/components/List.svelte";
  import Loading from "@app/components/Loading.svelte";
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
@@ -22,7 +22,6 @@
  export let patches: Patch[];
  export let project: Project;
  export let state: PatchState["status"];
-
  export let seeding: boolean;

  let loading = false;
  let page = 0;
@@ -72,85 +71,102 @@
</script>

<style>
-
  .patches {
-
    font-size: var(--font-size-small);
-
  }
  .more {
-
    margin-top: 2rem;
+
    margin: 2rem 0;
    min-height: 3rem;
    display: flex;
    align-items: center;
    justify-content: center;
  }
+
  .dropdown-button-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
    padding: 0 0.25rem;
+
  }
+
  .dropdown-list-counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+
  .selected {
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-dim);
+
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="patches">
-
  <div class="patches">
-
    <List items={allPatches}>
-
      <div slot="header" style="display: flex;">
-
        <Popover
-
          popoverPadding="0"
-
          popoverPositionTop="2.5rem"
-
          popoverBorderRadius="var(--border-radius-small)">
-
          <Button
-
            let:expanded
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}
-
            ariaLabel="filter-dropdown"
-
            title="Filter patches by state">
-
            <div style:color={stateColor[state]}>
+
<Layout {baseUrl} {project} activeTab="patches" styleRightContentPadding="0">
+
  <div slot="header" style:display="flex" style:padding="1rem 1rem 0 1rem">
+
    <Popover
+
      popoverPadding="0"
+
      popoverPositionTop="2.5rem"
+
      popoverBorderRadius="var(--border-radius-small)">
+
      <Button
+
        let:expanded
+
        slot="toggle"
+
        let:toggle
+
        on:click={toggle}
+
        ariaLabel="filter-dropdown"
+
        title="Filter patches by state">
+
        <div style:color={stateColor[state]}>
+
          <Icon name="patch" />
+
        </div>
+
        {capitalize(state)}
+
        <div class="dropdown-button-counter">
+
          {project.patches[state]}
+
        </div>
+
        <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
      </Button>
+
      <DropdownList slot="popover" items={stateOptions}>
+
        <Link
+
          slot="item"
+
          let:item
+
          on:afterNavigate={() => closeFocused()}
+
          route={{
+
            resource: "project.patches",
+
            project: project.id,
+
            node: baseUrl,
+
            search: `state=${item}`,
+
          }}>
+
          <DropdownListItem selected={item === state}>
+
            <div style:color={stateColor[item]}>
              <Icon name="patch" />
            </div>
-
            {project.patches[state]}
-
            {state}
-
            <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
          </Button>
-
          <DropdownList slot="popover" items={stateOptions}>
-
            <Link
-
              slot="item"
-
              let:item
-
              on:afterNavigate={() => closeFocused()}
-
              route={{
-
                resource: "project.patches",
-
                project: project.id,
-
                node: baseUrl,
-
                search: `state=${item}`,
-
              }}>
-
              <DropdownListItem selected={item === state}>
-
                <div style:color={stateColor[item]}>
-
                  <Icon name="patch" />
-
                </div>
+
            <div
+
              style="display: flex; gap: 1rem;justify-content: space-between; width: 100%;">
+
              {capitalize(item)}
+
              <div
+
                class="dropdown-list-counter"
+
                class:selected={item === state}>
                {project.patches[item]}
-
                {item}
-
              </DropdownListItem>
-
            </Link>
-
          </DropdownList>
-
        </Popover>
-
      </div>
-

-
      <PatchTeaser
-
        slot="item"
-
        let:item
-
        {baseUrl}
-
        projectId={project.id}
-
        patch={item} />
-

-
      <svelte:fragment slot="body">
-
        {#if error}
-
          <ErrorMessage message="Couldn't load patches" {error} />
-
        {/if}
-

-
        {#if project.patches[state] === 0}
-
          <div style:margin="4rem 0" style:width="100%">
-
            <Placeholder
-
              iconName="no-patches"
-
              caption={`No ${state} patches`} />
-
          </div>
-
        {/if}
-
      </svelte:fragment>
-
    </List>
+
              </div>
+
            </div>
+
          </DropdownListItem>
+
        </Link>
+
      </DropdownList>
+
    </Popover>
+
  </div>

+
  {#if allPatches.length > 0}
+
    <div style:border-top="1px solid var(--color-fill-separator)" />
+
  {/if}
+
  {#each allPatches as patch}
+
    <PatchTeaser {baseUrl} projectId={project.id} {patch} />
+
  {/each}
+

+
  {#if error}
+
    <ErrorMessage message="Couldn't load patches" {error} />
+
  {/if}
+

+
  {#if project.patches[state] === 0}
+
    <div
+
      style="height: calc(100vh - 7.5rem); display: flex; align-items: center; justify-content: center;">
+
      <Placeholder iconName="no-patches" caption={`No ${state} patches`} />
+
    </div>
+
  {/if}
+

+
  {#if loading || showMoreButton}
    <div class="more">
      {#if loading}
        <div style:margin-top={page === 0 ? "8rem" : ""}>
@@ -164,5 +180,5 @@
        </Button>
      {/if}
    </div>
-
  </div>
+
  {/if}
</Layout>
modified src/views/projects/Source.svelte
@@ -12,6 +12,7 @@

  import BlobComponent from "./Source/Blob.svelte";
  import TreeComponent from "./Source/Tree.svelte";
+
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";

  export let baseUrl: BaseUrl;
  export let rawPath: (commit?: string) => string;
@@ -25,7 +26,6 @@
  export let tree: Tree;
  export let seeding: boolean;

-
  // Whether the mobile file tree is visible.
  let mobileFileTree = false;
  let treeElement: HTMLElement | undefined = undefined;
  let treeOverflow: boolean = false;
@@ -82,6 +82,7 @@
  .container {
    display: flex;
    width: inherit;
+
    padding: 0 1rem 1rem 1rem;
  }

  .column-left {
@@ -113,80 +114,60 @@
  }
  .sticky {
    position: sticky;
-
    top: 2rem;
-
    max-height: 100vh;
-
  }
-

-
  @media (max-width: 720px) {
-
    .column-right {
-
      padding: 1.5rem 0;
-
      min-width: 0;
-
    }
-
    .source-tree {
-
      margin: 1rem 0;
-
    }
-
    .container {
-
      padding: 0;
-
      flex-direction: column;
-
    }
-
    .column-left {
-
      display: none;
-
      padding-right: 0;
-
    }
-
    .sticky {
-
      max-height: initial;
-
    }
+
    top: 4.5rem;
+
    max-height: calc(100vh - 5.5rem);
  }
</style>

-
<Layout {baseUrl} {project} {seeding} activeTab="source">
-
  <svelte:fragment slot="subheader">
-
    <div style:margin-top="1rem">
-
      <Header
-
        node={baseUrl}
-
        {project}
-
        peers={peersWithRoute}
-
        branches={branchesWithRoute}
-
        {revision}
-
        {tree}
-
        filesLinkActive={true}
-
        historyLinkActive={false} />
-

-
      {#if tree.entries.length > 0}
-
        <div class="layout-mobile">
-
          <Button
-
            styleWidth="100%"
-
            size="large"
-
            variant="outline"
-
            on:click={() => {
-
              mobileFileTree = !mobileFileTree;
-
            }}>
-
            Browse
-
          </Button>
-
        </div>
+
<Layout {baseUrl} {project} activeTab="source" styleRightContentPadding="0">
+
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />
+

+
  <div style:margin-top="1rem" style:margin-left="1rem" slot="subheader">
+
    <Header
+
      node={baseUrl}
+
      {project}
+
      peers={peersWithRoute}
+
      branches={branchesWithRoute}
+
      {revision}
+
      {tree}
+
      filesLinkActive={true}
+
      historyLinkActive={false} />
+
  </div>
+
  <div class="global-hide-on-desktop">
+
    {#if tree.entries.length > 0}
+
      <div style:margin="1rem">
+
        <Button
+
          styleWidth="100%"
+
          size="large"
+
          variant="outline"
+
          on:click={() => {
+
            mobileFileTree = !mobileFileTree;
+
          }}>
+
          Browse
+
        </Button>
+
      </div>

-
        {#if mobileFileTree}
-
          <div class="layout-mobile" style:margin-top="1rem">
-
            <TreeComponent
-
              projectId={project.id}
-
              {revision}
-
              {baseUrl}
-
              {fetchTree}
-
              {path}
-
              {peer}
-
              {tree}
-
              on:select={() => {
-
                mobileFileTree = false;
-
              }} />
-
          </div>
-
        {/if}
+
      {#if mobileFileTree}
+
        <div class="layout-mobile" style:margin="1rem">
+
          <TreeComponent
+
            projectId={project.id}
+
            {revision}
+
            {baseUrl}
+
            {fetchTree}
+
            {path}
+
            {peer}
+
            {tree}
+
            on:select={() => {
+
              mobileFileTree = false;
+
            }} />
+
        </div>
      {/if}
-
    </div>
-
  </svelte:fragment>
+
    {/if}
+
  </div>

  <div class="container center-content">
    {#if tree.entries.length > 0}
-
      <div class="column-left">
+
      <div class="column-left global-hide-on-mobile">
        <div
          bind:this={treeElement}
          class="source-tree sticky"
modified src/views/projects/Source/Blob.svelte
@@ -150,6 +150,11 @@
    margin-bottom: 1.5rem;
  }

+
  .teaser-buttons {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+

  .no-scrollbar {
    scrollbar-width: none;
  }
@@ -167,18 +172,9 @@
    padding-right: 0.75rem;
    height: var(--button-small-height);
  }
-

-
  @media (max-width: 720px) {
-
    .commit-teaser {
-
      padding: 0 0.75rem;
-
    }
-
    .hash-button {
-
      display: none;
-
    }
-
  }
</style>

-
<File>
+
<File sticky={false}>
  <FilePath slot="left-header" filenameWithPath={blob.path} />
  <svelte:fragment slot="right-header">
    <div class="commit-teaser">
@@ -203,7 +199,7 @@
        <InlineMarkdown fontSize="small" content={lastCommit.summary} />
      </div>
    </div>
-
    <div class="layout-desktop-flex" style:gap="0.5rem">
+
    <div class="global-hide-on-mobile teaser-buttons">
      {#if isMarkdown}
        <Radio ariaLabel="Toggle render method">
          <Button
modified src/views/projects/Source/Header.svelte
@@ -2,16 +2,13 @@
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
  import { type Route } from "@app/lib/router";

-
  import { pluralize } from "@app/lib/pluralize";
-

  import BranchSelector from "./BranchSelector.svelte";
  import PeerSelector from "./PeerSelector.svelte";

-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Link from "@app/components/Link.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Radio from "@app/components/Radio.svelte";
  import HoverPopover from "@app/components/HoverPopover.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";

  export let node: BaseUrl;
  export let branches: Array<{ name: string; route: Route }>;
@@ -37,23 +34,52 @@
</script>

<style>
+
  .top-header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    flex-wrap: wrap;
+
    gap: 1rem;
+
    margin-bottom: 2rem;
+
  }
+

  .header {
    font-size: var(--font-size-tiny);
    display: flex;
    align-items: center;
    justify-content: left;
    flex-wrap: wrap;
-
    gap: 1rem;
+
    position: relative;
+
  }
+
  .header::after {
+
    content: "";
+
    position: absolute;
+
    left: -1rem;
+
    bottom: 0;
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    width: calc(100% + 1rem);
+
    z-index: -1;
  }

-
  @media (max-width: 960px) {
-
    .header {
-
      margin-bottom: 1.5rem;
-
    }
+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-dim);
+
    padding: 0 0.25rem;
+
  }
+

+
  .title-counter {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
+

+
  .selected {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-contrast);
  }
</style>

-
<div class="header">
+
<div class="top-header">
  {#if peers.length > 0}
    <PeerSelector {peers} />
  {/if}
@@ -64,8 +90,10 @@
    {node}
    selectedCommitId={commitId}
    {selectedBranch} />
+
</div>

-
  <Radio>
+
<div class="header">
+
  <div style="display: flex; gap: 0.25rem;">
    <Link
      route={{
        resource: "project.source",
@@ -76,7 +104,7 @@
      }}>
      <Button
        styleBorderRadius="0"
-
        variant={filesLinkActive ? "gray-white" : "dim"}>
+
        variant={filesLinkActive ? "tab-active" : "tab"}>
        <IconSmall name="file" />Files
      </Button>
    </Link>
@@ -91,22 +119,24 @@
      }}>
      <Button
        styleBorderRadius="0"
-
        variant={historyLinkActive ? "gray-white" : "dim"}>
+
        variant={historyLinkActive ? "tab-active" : "tab"}>
        <IconSmall name="commit" />
-
        <div>
-
          {tree.stats.commits}
-
          {pluralize("commit", tree.stats.commits)}
+
        <div class="title-counter">
+
          Commits
+
          <div class="counter" class:selected={historyLinkActive}>
+
            {tree.stats.commits}
+
          </div>
        </div>
      </Button>
    </Link>
-
  </Radio>
+
  </div>

  <HoverPopover stylePopoverPositionLeft="0" stylePopoverPositionTop="0.5rem">
-
    <Button disabled notAllowed={false} variant="outline" slot="toggle">
+
    <Button disabled notAllowed={false} variant="tab" slot="toggle">
      <IconSmall name="user" />
-
      <div>
-
        {tree.stats.contributors}
-
        {pluralize("contributor", tree.stats.contributors)}
+
      <div class="title-counter">
+
        Contributors
+
        <div class="counter">{tree.stats.contributors}</div>
      </div>
    </Button>
    <div class="txt-small" slot="popover">
modified src/views/projects/Source/PeerSelector.svelte
@@ -4,7 +4,6 @@

  import { closeFocused } from "@app/components/Popover.svelte";
  import { formatNodeId } from "@app/lib/utils";
-
  import { pluralize } from "@app/lib/pluralize";

  import NodeId from "@app/components/NodeId.svelte";
  import Badge from "@app/components/Badge.svelte";
@@ -28,6 +27,15 @@
  }
</script>

+
<style>
+
  .counter {
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
+
    padding: 0 0.25rem;
+
  }
+
</style>
+

<Popover
  popoverPadding="0"
  popoverPositionTop="2.5rem"
@@ -49,8 +57,10 @@
        <Badge size="tiny" variant="secondary">delegate</Badge>
      {/if}
    {:else}
-
      {peers.length}
-
      {pluralize("remote", peers.length)}
+
      Remotes
+
      <div class="counter">
+
        {peers.length}
+
      </div>
    {/if}
    <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
  </Button>
added src/views/projects/Source/ProjectNameHeader.svelte
@@ -0,0 +1,120 @@
+
<script lang="ts">
+
  import type { BaseUrl, Project } from "@httpd-client";
+

+
  import * as modal from "@app/lib/modal";
+
  import capitalize from "lodash/capitalize";
+
  import { twemoji } from "@app/lib/utils";
+
  import { httpdStore, api } from "@app/lib/httpd";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import CloneButton from "../Header/CloneButton.svelte";
+
  import ErrorModal from "@app/modals/ErrorModal.svelte";
+
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import SeedButton from "../Header/SeedButton.svelte";
+

+
  export let project: Project;
+
  export let baseUrl: BaseUrl;
+
  export let seeding: boolean;
+

+
  let editSeedingInProgress = false;
+

+
  async function editSeeding() {
+
    if ($httpdStore.state === "authenticated") {
+
      try {
+
        editSeedingInProgress = true;
+
        if (seeding) {
+
          await api.stopSeedingById(project.id, $httpdStore.session.id);
+
        } else {
+
          await api.seedById(project.id, $httpdStore.session.id);
+
        }
+
        seeding = !seeding;
+
      } catch (error) {
+
        if (error instanceof Error) {
+
          modal.show({
+
            component: ErrorModal,
+
            props: {
+
              title: seeding
+
                ? "Stop seeding project failed"
+
                : "Seeding project failed",
+
              subtitle: [
+
                `There was an error while trying to ${
+
                  seeding ? "stop seeding" : "seed"
+
                } this project.`,
+
                "Check your radicle-httpd logs for details.",
+
              ],
+
              error: {
+
                message: error.message,
+
                stack: error.stack,
+
              },
+
            },
+
          });
+
        }
+
      } finally {
+
        editSeedingInProgress = false;
+
      }
+
    }
+
  }
+

+
  $: session =
+
    $httpdStore.state === "authenticated" ? $httpdStore.session : undefined;
+
</script>
+

+
<style>
+
  .title {
+
    align-items: center;
+
    gap: 0.5rem;
+
    color: var(--color-foreground-contrast);
+
    display: flex;
+
    font-size: var(--font-size-large);
+
    justify-content: left;
+
    margin-bottom: 0.5rem;
+
    text-align: left;
+
    text-overflow: ellipsis;
+
    padding: 1rem 1rem 0 1rem;
+
  }
+
  .description {
+
    padding: 0 1rem 1rem 1rem;
+
  }
+
  .project-name:hover {
+
    color: inherit;
+
  }
+
  .description :global(a) {
+
    border-bottom: 1px solid var(--color-foreground-contrast);
+
  }
+
</style>
+

+
<div class="title">
+
  <span class="txt-overflow">
+
    <Link
+
      route={{
+
        resource: "project.source",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      <span class="project-name">
+
        {project.name}
+
      </span>
+
    </Link>
+
  </span>
+
  {#if project.visibility && project.visibility.type === "private"}
+
    <Badge variant="yellowOutline" size="tiny">
+
      {capitalize(project.visibility.type)}
+
    </Badge>
+
  {/if}
+

+
  <div
+
    class="global-hide-on-mobile"
+
    style="margin-left: auto; display: flex; gap: 0.5rem;">
+
    <SeedButton
+
      {seeding}
+
      disabled={editSeedingInProgress}
+
      editSeeding={session && editSeeding}
+
      seedCount={project.seeding}
+
      projectId={project.id} />
+
    <CloneButton {baseUrl} id={project.id} name={project.name} />
+
  </div>
+
</div>
+
<div class="description" use:twemoji>
+
  <InlineMarkdown fontSize="regular" content={project.description} />
+
</div>
modified src/views/projects/router.ts
@@ -85,7 +85,7 @@ interface ProjectPatchRoute {
        name: "activity";
      }
    | {
-
        name: "commits" | "changes";
+
        name: "changes";
        revision?: string;
      }
    | {
@@ -140,7 +140,6 @@ export type ProjectLoadedRoute =
        baseUrl: BaseUrl;
        project: Project;
        commit: Commit;
-
        seeding: boolean;
      };
    }
  | {
@@ -150,7 +149,6 @@ export type ProjectLoadedRoute =
        project: Project;
        rawPath: (commit?: string) => string;
        issue: Issue;
-
        seeding: boolean;
      };
    }
  | {
@@ -160,7 +158,6 @@ export type ProjectLoadedRoute =
        project: Project;
        issues: Issue[];
        state: IssueState["status"];
-
        seeding: boolean;
      };
    }
  | {
@@ -169,7 +166,6 @@ export type ProjectLoadedRoute =
        baseUrl: BaseUrl;
        project: Project;
        rawPath: (commit?: string) => string;
-
        seeding: boolean;
      };
    }
  | {
@@ -179,7 +175,6 @@ export type ProjectLoadedRoute =
        project: Project;
        patches: Patch[];
        state: PatchState["status"];
-
        seeding: boolean;
      };
    }
  | {
@@ -190,7 +185,6 @@ export type ProjectLoadedRoute =
        rawPath: (commit?: string) => string;
        patch: Patch;
        view: PatchView;
-
        seeding: boolean;
      };
    };

@@ -204,7 +198,7 @@ export type PatchView =
      revision: string;
    }
  | {
-
      name: "commits" | "changes";
+
      name: "changes";
      revision: string;
      oid: string;
      diff: Diff;
@@ -275,10 +269,9 @@ export async function loadProjectRoute(
    } else if (route.resource === "project.history") {
      return await loadHistoryView(route);
    } else if (route.resource === "project.commit") {
-
      const [project, commit, seeding] = await Promise.all([
+
      const [project, commit] = await Promise.all([
        api.project.getById(route.project),
        api.project.getCommitBySha(route.project, route.commit),
-
        isLocalNodeSeeding(route),
      ]);

      return {
@@ -287,14 +280,12 @@ export async function loadProjectRoute(
          baseUrl: route.node,
          project,
          commit,
-
          seeding,
        },
      };
    } else if (route.resource === "project.issue") {
-
      const [project, issue, seeding] = await Promise.all([
+
      const [project, issue] = await Promise.all([
        api.project.getById(route.project),
        api.project.getIssueById(route.project, route.issue),
-
        isLocalNodeSeeding(route),
      ]);
      return {
        resource: "project.issue",
@@ -303,7 +294,6 @@ export async function loadProjectRoute(
          project,
          rawPath,
          issue,
-
          seeding,
        },
      };
    } else if (route.resource === "project.patch") {
@@ -311,17 +301,13 @@ export async function loadProjectRoute(
    } else if (route.resource === "project.issues") {
      return await loadIssuesView(route);
    } else if (route.resource === "project.newIssue") {
-
      const [project, seeding] = await Promise.all([
-
        api.project.getById(route.project),
-
        isLocalNodeSeeding(route),
-
      ]);
+
      const project = await api.project.getById(route.project);
      return {
        resource: "project.newIssue",
        params: {
          baseUrl: route.node,
          project,
          rawPath,
-
          seeding,
        },
      };
    } else if (route.resource === "project.patches") {
@@ -369,14 +355,13 @@ async function loadPatchesView(
  const searchParams = new URLSearchParams(route.search || "");
  const state = (searchParams.get("state") as PatchState["status"]) || "open";

-
  const [project, patches, seeding] = await Promise.all([
+
  const [project, patches] = await Promise.all([
    api.project.getById(route.project),
    api.project.getAllPatches(route.project, {
      state,
      page: 0,
      perPage: PATCHES_PER_PAGE,
    }),
-
    isLocalNodeSeeding(route),
  ]);

  return {
@@ -386,7 +371,6 @@ async function loadPatchesView(
      patches,
      state,
      project,
-
      seeding,
    },
  };
}
@@ -397,7 +381,7 @@ async function loadIssuesView(
  const api = new HttpdClient(route.node);
  const state = route.state || "open";

-
  const [project, issues, seeding] = await Promise.all([
+
  const [project, issues] = await Promise.all([
    api.project.getById(route.project),
    api.project.getAllIssues(route.project, {
      state,
@@ -414,7 +398,6 @@ async function loadIssuesView(
      issues,
      state,
      project,
-
      seeding,
    },
  };
}
@@ -575,10 +558,9 @@ async function loadPatchView(
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
      route.project
    }${commit ? `/${commit}` : ""}`;
-
  const [project, patch, seeding] = await Promise.all([
+
  const [project, patch] = await Promise.all([
    api.project.getById(route.project),
    api.project.getPatchById(route.project, route.patch),
-
    isLocalNodeSeeding(route),
  ]);
  const latestRevision = patch.revisions[patch.revisions.length - 1];

@@ -589,7 +571,6 @@ async function loadPatchView(
      view = { name: "activity", revision: latestRevision.id };
      break;
    }
-
    case "commits":
    case "changes": {
      const revisionId = route.view.revision;
      const revision =
@@ -634,7 +615,6 @@ async function loadPatchView(
      rawPath,
      patch,
      view,
-
      seeding,
    },
  };
}
@@ -787,7 +767,7 @@ function resolvePatchesRoute(
      }
    }

-
    if (tab === "commits" || tab === "changes") {
+
    if (tab === "changes") {
      return {
        ...base,
        view: { name: tab, revision },
@@ -888,7 +868,7 @@ function patchRouteToPath(route: ProjectPatchRoute): string {
  const pathSegments = [node, route.project];

  pathSegments.push("patches", route.patch);
-
  if (route.view?.name === "commits" || route.view?.name === "changes") {
+
  if (route.view?.name === "changes") {
    if (route.view.revision) {
      pathSegments.push(route.view.revision);
    }
modified src/views/session/Index.svelte
@@ -6,6 +6,8 @@
  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
  import * as httpd from "@app/lib/httpd";
+

+
  import AppLayout from "@app/App/AppLayout.svelte";
  import Loading from "@app/components/Loading.svelte";

  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
@@ -37,4 +39,6 @@
  });
</script>

-
<Loading center />
+
<AppLayout>
+
  <Loading center />
+
</AppLayout>
modified tests/e2e/hashRouter.spec.ts
@@ -63,7 +63,7 @@ test.describe("project page navigation", () => {
      .waitFor({ state: "hidden" });
    await expect(page).toHaveURL(projectTreeURL);

-
    await page.getByRole("link", { name: "6 commits" }).click();
+
    await page.getByRole("link", { name: "Commits 6" }).click();
    await expect(page).toHaveURL(
      `/#${sourceBrowsingUrl}/history/${aliceMainHead}`,
    );
@@ -86,7 +86,7 @@ test.describe("project page navigation", () => {
    await page.getByText(".hidden").click();
    await expect(page).toHaveURL(`${projectTreeURL}/tree/.hidden`);

-
    await page.getByRole("link", { name: "6 commits" }).click();
+
    await page.getByRole("link", { name: "Commits 6" }).click();
    await expect(page).toHaveURL(`${projectTreeURL}/history`);
  });

modified tests/e2e/historyRouter.spec.ts
@@ -63,7 +63,7 @@ test.describe("project page navigation", () => {
      .waitFor({ state: "hidden" });
    await expect(page).toHaveURL(projectTreeURL);

-
    await page.getByRole("link", { name: "6 commits" }).click();
+
    await page.getByRole("link", { name: "Commits 6" }).click();
    await expect(page).toHaveURL(
      `${sourceBrowsingUrl}/history/${aliceMainHead}`,
    );
@@ -86,7 +86,7 @@ test.describe("project page navigation", () => {
    await page.getByText(".hidden").click();
    await expect(page).toHaveURL(`${projectTreeURL}/tree/.hidden`);

-
    await page.getByRole("link", { name: "6 commits" }).click();
+
    await page.getByRole("link", { name: "Commits 6" }).click();
    await expect(page).toHaveURL(`${sourceBrowsingUrl}/history`);
  });

modified tests/e2e/project.spec.ts
@@ -18,16 +18,12 @@ async function expectCounts(
) {
  await expect(
    page.getByRole("link", {
-
      name: `${params.commits} ${params.commits === 1 ? "commit" : "commits"}`,
+
      name: `Commits ${params.commits}`,
    }),
  ).toBeVisible();

  await expect(
-
    page.getByText(
-
      `${params.contributors} ${
-
        params.contributors === 1 ? "contributor" : "contributors"
-
      }`,
-
    ),
+
    page.getByText(`Contributors ${params.contributors}`),
  ).toBeVisible();
}

@@ -71,7 +67,7 @@ test("navigate to project", async ({ page }) => {

test("show source tree at specific revision", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "6 commits" }).click();
+
  await page.getByRole("link", { name: "Commits 6" }).click();

  await page
    .locator(".teaser", { hasText: "335dd6d" })
@@ -171,22 +167,22 @@ test("files with special characters in the filename", async ({ page }) => {
  await sourceTree.getByText("special").click();

  await sourceTree.getByText("+plus+").click();
-
  await expect(page.locator(".filename")).toContainText("+plus");
+
  await expect(page.getByRole("banner")).toContainText("+plus");

  await sourceTree.getByText("-dash-").click();
-
  await expect(page.locator(".filename")).toContainText("-dash-");
+
  await expect(page.getByRole("banner")).toContainText("-dash-");

  await sourceTree.getByText(":colon:").click();
-
  await expect(page.locator(".filename")).toContainText(":colon:");
+
  await expect(page.getByRole("banner")).toContainText(":colon:");

  await sourceTree.getByText(";semicolon;").click();
-
  await expect(page.locator(".filename")).toContainText(";semicolon;");
+
  await expect(page.getByRole("banner")).toContainText(";semicolon;");

  await sourceTree.getByText("@at@").click();
-
  await expect(page.locator(".filename")).toContainText("@at@");
+
  await expect(page.getByRole("banner")).toContainText("@at@");

  await sourceTree.getByText("_underscore_").click();
-
  await expect(page.locator(".filename")).toContainText("_underscore_");
+
  await expect(page.getByRole("banner")).toContainText("_underscore_");

  // TODO: fix these errors in `radicle-httpd` for the following edge cases.
  //
@@ -198,13 +194,13 @@ test("files with special characters in the filename", async ({ page }) => {
  // );

  await sourceTree.getByText("spaces are okay").click();
-
  await expect(page.locator(".filename")).toContainText("spaces are okay");
+
  await expect(page.getByRole("banner")).toContainText("spaces are okay");

  await sourceTree.getByText("~tilde~").click();
-
  await expect(page.locator(".filename")).toContainText("~tilde~");
+
  await expect(page.getByRole("banner")).toContainText("~tilde~");

  await sourceTree.getByText("👹👹👹").click();
-
  await expect(page.locator(".filename")).toContainText("👹👹👹");
+
  await expect(page.getByRole("banner")).toContainText("👹👹👹");
});

test("binary files", async ({ page }) => {
@@ -380,7 +376,7 @@ test("only one modal can be open at a time", async ({ page }) => {
  await expect(page.getByText("bob")).not.toBeVisible();
  await expect(page.getByText("feature/branch")).not.toBeVisible();

-
  await page.getByRole("button", { name: "Theme" }).first().click();
+
  await page.getByRole("button", { name: "Settings" }).first().click();
  await expect(page.getByText("Code font")).toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
  await expect(page.getByText("bob")).not.toBeVisible();
@@ -458,20 +454,14 @@ test("internal file markdown link", async ({ page }) => {
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
  await page.getByRole("link", { name: "Markdown Cheatsheet" }).click();
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/cheatsheet.md`);
-
  await expect(
-
    page.locator(".filename", { hasText: "cheatsheet.md" }),
-
  ).toBeVisible();
+
  await expect(page.getByText("cheatsheet.md").nth(2)).toBeVisible();

  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
  await page.getByRole("link", { name: "black square" }).click();
  await expect(page).toHaveURL(
    `${markdownUrl}/tree/main/assets/black-square.png`,
  );
-
  await expect(
-
    page.locator(".file-path", {
-
      hasText: "assets/black-square.png",
-
    }),
-
  ).toBeVisible();
+
  await expect(page.getByText("assets/black-square.png").nth(1)).toBeVisible();
  await expect(
    page.getByRole("link", { name: "black-square.png" }),
  ).toBeVisible();
modified tests/e2e/project/commit.spec.ts
@@ -2,6 +2,7 @@ import {
  aliceRemote,
  bobHead,
  expect,
+
  shortBobHead,
  sourceBrowsingUrl,
  test,
} from "@tests/support/fixtures.js";
@@ -12,7 +13,7 @@ test("navigation from commit list", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
  await page.getByTitle("Change peer").click();
  await page.getByRole("link", { name: "bob" }).click();
-
  await page.getByRole("link", { name: "7 commits" }).click();
+
  await page.getByRole("link", { name: "Commits 7" }).click();

  await page.getByText("Update readme").click();
  await expect(page).toHaveURL(commitUrl);
@@ -38,7 +39,9 @@ test("modified file", async ({ page }) => {
  // Commit header.
  {
    await expect(page.getByText("Update readme")).toBeVisible();
-
    await expect(page.getByText(bobHead)).toBeVisible();
+
    await expect(
+
      page.getByRole("button", { name: shortBobHead }),
+
    ).toBeVisible();
  }

  // Diff header.
@@ -117,8 +120,10 @@ test("navigation to source tree at specific revision", async ({ page }) => {
  await expect(page.getByTitle("Current HEAD")).toContainText("0801ace");
  await expect(page.locator(".source-tree >> text=.gitkeep")).toBeVisible();
  await expect(
-
    page.locator(
-
      "text=deep/directory/hierarchy/is/entirely/possible/in/git/repositories/",
-
    ),
+
    page
+
      .locator(
+
        "text=deep/directory/hierarchy/is/entirely/possible/in/git/repositories/",
+
      )
+
      .nth(1),
  ).toBeVisible();
});
modified tests/e2e/project/commits.spec.ts
@@ -9,7 +9,7 @@ import { createProject } from "@tests/support/project";

test("peer and branch switching", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "6 commits" }).click();
+
  await page.getByRole("link", { name: "Commits 6" }).click();

  // Alice's peer.
  {
@@ -23,7 +23,7 @@ test("peer and branch switching", async ({ page }) => {
    await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");

    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".history .teaser")).toHaveCount(6);
+
    await expect(page.locator(".list .teaser")).toHaveCount(6);

    const latestCommit = page.locator(".teaser").first();
    await expect(latestCommit).toContainText("Add README.md");
@@ -42,7 +42,7 @@ test("peer and branch switching", async ({ page }) => {
      page.getByRole("button", { name: "feature/branch" }),
    ).toBeVisible();
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".history .teaser")).toHaveCount(9);
+
    await expect(page.locator(".list .teaser")).toHaveCount(9);

    await page.getByTitle("Change branch").click();
    await page.getByText("orphaned-branch").click();
@@ -85,7 +85,7 @@ test("peer and branch switching", async ({ page }) => {

test("expand commit message", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "6 commits" }).click();
+
  await page.getByRole("link", { name: "Commits 6" }).click();
  const commitToggle = page.getByRole("button", { name: "expand" }).first();

  await commitToggle.click();
@@ -111,7 +111,7 @@ test("relative timestamps", async ({ page }) => {
  });

  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "6 commits" }).click();
+
  await page.getByRole("link", { name: "Commits 6" }).click();

  await page.getByTitle("Change peer").click();
  await page.getByRole("link", { name: "bob" }).click();
@@ -136,7 +136,7 @@ test("pushing changes while viewing history", async ({ page, peerManager }) => {
    name: "alice-project",
  });
  await page.goto(`${alice.uiUrl()}/${rid}`);
-
  await page.getByRole("link", { name: "1 commit" }).click();
+
  await page.getByRole("link", { name: "Commits 1" }).click();
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);

  await alice.git(["commit", "--allow-empty", "--message", "first change"], {
@@ -147,7 +147,7 @@ test("pushing changes while viewing history", async ({ page, peerManager }) => {
  });
  await page.reload();
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
-
  await expect(page.getByRole("link", { name: "2 commits" })).toBeVisible();
+
  await expect(page.getByRole("link", { name: "Commits 2" })).toBeVisible();

  await expect(page.getByLabel("canonical-branch")).toHaveText("main");
  await expect(page.getByTitle("Current HEAD")).toHaveText("516fa74");
@@ -157,7 +157,7 @@ test("pushing changes while viewing history", async ({ page, peerManager }) => {
    .getByRole("link", { name: "alice-project" })
    .click();
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}`);
-
  await page.getByRole("link", { name: "2 commits" }).click();
+
  await page.getByRole("link", { name: "Commits 2" }).click();

  await alice.git(
    [
@@ -175,8 +175,8 @@ test("pushing changes while viewing history", async ({ page, peerManager }) => {
  });
  await page.reload();
  await expect(page).toHaveURL(`${alice.uiUrl()}/${rid}/history`);
-
  await expect(page.getByRole("link", { name: "3 commits" })).toHaveText(
-
    "3 commits",
+
  await expect(page.getByRole("link", { name: "Commits 3" })).toHaveText(
+
    "Commits 3",
  );
  await expect(page.getByLabel("canonical-branch")).toHaveText("main");
  await expect(page.getByTitle("Current HEAD")).toHaveText("bb9089a");
modified tests/e2e/project/issues.spec.ts
@@ -3,11 +3,11 @@ import { createProject } from "@tests/support/project";

test("navigate issue listing", async ({ page }) => {
  await page.goto(cobUrl);
-
  await page.getByRole("link", { name: "1 issue" }).click();
+
  await page.getByRole("link", { name: "Issues 1" }).click();
  await expect(page).toHaveURL(`${cobUrl}/issues`);

  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.getByRole("link", { name: "2 closed" }).click();
+
  await page.getByRole("link", { name: "Closed 2" }).click();
  await expect(page).toHaveURL(`${cobUrl}/issues?state=closed`);
});

@@ -39,18 +39,18 @@ test("issue counters", async ({ page, authenticatedPeer }) => {
    { cwd: projectFolder },
  );
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.locator(".dropdown-item").getByText("1 open").click();
-
  await expect(page.getByRole("button", { name: "2 issues" })).toBeVisible();
+
  await page.locator(".dropdown-item").getByText("Open 1").click();
+
  await expect(page.getByRole("button", { name: "Issues 2" })).toBeVisible();
  await expect(
    page.getByRole("button", { name: "filter-dropdown" }).first(),
-
  ).toHaveText("2 open");
-
  await expect(page.locator(".list .issue-teaser")).toHaveCount(2);
+
  ).toHaveText("Open 2");
+
  await expect(page.locator(".issue-teaser")).toHaveCount(2);

  await page
    .getByRole("link", { name: "First issue to test counters" })
    .click();
  await page.getByRole("button", { name: "Close issue as solved" }).click();
-
  await expect(page.getByRole("button", { name: "1 issue" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "Issues 1" })).toBeVisible();
});

test("create a new issue", async ({ page, authenticatedPeer }) => {
@@ -61,7 +61,7 @@ test("create a new issue", async ({ page, authenticatedPeer }) => {
  await page.goto(
    `/nodes/${authenticatedPeer.httpdBaseUrl.hostname}:${authenticatedPeer.httpdBaseUrl.port}/${rid}`,
  );
-
  await page.getByRole("link", { name: "0 issues" }).click();
+
  await page.getByRole("link", { name: "Issues 0" }).click();
  await page.getByRole("link", { name: "New issue" }).click();
  await page.getByPlaceholder("Title").fill("This is a title");
  await page
modified tests/e2e/project/patch.spec.ts
@@ -155,13 +155,13 @@ test("change patch state", async ({ page, authenticatedPeer }) => {
  await page.goto(`${authenticatedPeer.uiUrl()}/${rid}/patches/${patchId}`);
  await page.getByRole("button", { name: "Archive patch" }).first().click();
  await expect(page.getByText("archived", { exact: true })).toBeVisible();
-
  await expect(page.getByRole("button", { name: "0 patches" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "Patches 0" })).toBeVisible();

  await page.getByLabel("stateToggle").first().click();
  await page.getByText("Convert to draft").click();
  await page.getByText("Convert to draft").click();
  await expect(page.getByText("draft", { exact: true })).toBeVisible();
-
  await expect(page.getByRole("button", { name: "0 patches" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "Patches 0" })).toBeVisible();
});

test("edit patch", async ({ page, authenticatedPeer }) => {
modified tests/e2e/project/patches.spec.ts
@@ -3,11 +3,11 @@ import { createProject } from "@tests/support/project";

test("navigate patch listing", async ({ page }) => {
  await page.goto(cobUrl);
-
  await page.getByRole("link", { name: "2 patches" }).click();
+
  await page.getByRole("link", { name: "Patches 2" }).click();
  await expect(page).toHaveURL(`${cobUrl}/patches`);

  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.getByRole("link", { name: "1 merged" }).click();
+
  await page.getByRole("link", { name: "Merged 1" }).click();
  await expect(page).toHaveURL(`${cobUrl}/patches?state=merged`);
  await expect(
    page.locator(".comments").filter({ hasText: "5" }),
@@ -42,10 +42,10 @@ test("patches counters", async ({ page, authenticatedPeer }) => {
    cwd: projectFolder,
  });
  await page.getByRole("button", { name: "filter-dropdown" }).first().click();
-
  await page.locator(".dropdown-item").getByText("1 open").click();
-
  await expect(page.getByRole("button", { name: "2 patches" })).toBeVisible();
+
  await page.locator(".dropdown-item").getByText("Open 1").click();
+
  await expect(page.getByRole("button", { name: "Patches 2" })).toBeVisible();
  await expect(
    page.getByRole("button", { name: "filter-dropdown" }).first(),
-
  ).toHaveText("2 open");
-
  await expect(page.locator(".list .patch-teaser")).toHaveCount(2);
+
  ).toHaveText("Open 2");
+
  await expect(page.locator(".patch-teaser")).toHaveCount(2);
});
modified tests/e2e/theme.spec.ts
@@ -16,7 +16,10 @@ test("default theme", async ({ page }) => {

test("theme persistance", async ({ page }) => {
  await page.goto(sourceBrowsingFixture);
-
  await page.getByRole("button", { name: "Theme" }).first().click();
+
  await expect(
+
    page.getByRole("banner").getByRole("link", { name: "source-browsing" }),
+
  ).toBeVisible();
+
  await page.getByRole("button", { name: "Settings" }).first().click();

  await page.getByText("System").click();
  await page.getByRole("button", { name: "Light Mode" }).click();
@@ -32,7 +35,10 @@ test("theme persistance", async ({ page }) => {

test("change theme", async ({ page }) => {
  await page.goto(sourceBrowsingFixture);
-
  await page.getByRole("button", { name: "Theme" }).first().click();
+
  await expect(
+
    page.getByRole("banner").getByRole("link", { name: "source-browsing" }),
+
  ).toBeVisible();
+
  await page.getByRole("button", { name: "Settings" }).first().click();

  await page.getByRole("button", { name: "Light Mode" }).click();
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
@@ -55,8 +61,11 @@ test("change theme", async ({ page }) => {

test("change code font", async ({ page }) => {
  await page.goto(sourceBrowsingFixture);
+
  await expect(
+
    page.getByRole("banner").getByRole("link", { name: "source-browsing" }),
+
  ).toBeVisible();

-
  await page.getByRole("button", { name: "Theme" }).first().click();
+
  await page.getByRole("button", { name: "Settings" }).first().click();

  await page.getByText("System").click();
  await expect(page.getByText("System")).toHaveClass(/gray-white/);
modified tests/support/fixtures.ts
@@ -672,6 +672,7 @@ export const aliceRemote =
export const bobRemote =
  "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5";
export const bobHead = "28f37105bb78db48111e36281291ff253dd050e8";
+
export const shortBobHead = "28f3710";
export const sourceBrowsingRid = "rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir";
export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
modified tests/unit/router.test.ts
@@ -176,26 +176,6 @@ describe("route invariant when parsed", () => {
    });
  });

-
  test("projects.patch commits", () => {
-
    expectParsingInvariant({
-
      resource: "project.patch",
-
      node,
-
      project: "PROJECT",
-
      patch: "PATCH",
-
      view: { name: "commits" },
-
    });
-
  });
-

-
  test("projects.patch commits with revision", () => {
-
    expectParsingInvariant({
-
      resource: "project.patch",
-
      node,
-
      project: "PROJECT",
-
      patch: "PATCH",
-
      view: { name: "commits", revision: "REVISION" },
-
    });
-
  });
-

  test("projects.patch changes", () => {
    expectParsingInvariant({
      resource: "project.patch",