Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement new design
Rūdolfs Ošiņš committed 2 years ago
commit d914710396e410c73df3b1925dc744712fe3498c
parent c5c609167e577d02ac1c07a956e599719fbd0d76
154 files changed +5668 -4485
modified httpd-client/index.ts
@@ -28,6 +28,7 @@ import type {
  Range,
  Review,
  Revision,
+
  Verdict,
} from "./lib/project/patch.js";
import type { RequestOptions, Method } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";
@@ -65,6 +66,7 @@ export type {
  Review,
  Revision,
  Tree,
+
  Verdict,
};

export interface Node {
modified httpd-client/lib/project/patch.ts
@@ -53,7 +53,7 @@ const mergeSchema = object({
  timestamp: number(),
}) satisfies ZodSchema<Merge>;

-
type Verdict = "accept" | "reject";
+
export type Verdict = "accept" | "reject";

export interface Review {
  author: { id: string; alias?: string };
modified index.html
@@ -18,57 +18,51 @@

    <link
      rel="preload"
-
      href="/fonts/Inter-Regular.woff"
+
      href="/fonts/Inter-Regular.woff2"
      as="font"
-
      type="font/woff"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/Inter-SemiBold.otf"
+
      href="/fonts/Inter-Medium.woff2"
      as="font"
-
      type="font/otf"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/Inter-Bold.otf"
+
      href="/fonts/Inter-SemiBold.woff2"
      as="font"
-
      type="font/otf"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/JetBrainsMono-Regular.ttf"
+
      href="/fonts/Inter-Bold.woff2"
      as="font"
-
      type="font/ttf"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/JetBrainsMono-SemiBold.ttf"
+
      href="/fonts/JetBrainsMono-Regular.woff2"
      as="font"
-
      type="font/ttf"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/JetBrainsMono-Bold.ttf"
+
      href="/fonts/JetBrainsMono-Medium.woff2"
      as="font"
-
      type="font/ttf"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/JetBrainsMono-Italic.ttf"
+
      href="/fonts/JetBrainsMono-SemiBold.woff2"
      as="font"
-
      type="font/ttf"
+
      type="font/woff2"
      crossorigin="anonymous" />
    <link
      rel="preload"
-
      href="/fonts/JetBrainsMono-SemiBoldItalic.ttf"
+
      href="/fonts/JetBrainsMono-Bold.woff2"
      as="font"
-
      type="font/ttf"
-
      crossorigin="anonymous" />
-
    <link
-
      rel="preload"
-
      href="/fonts/JetBrainsMono-BoldItalic.ttf"
-
      as="font"
-
      type="font/ttf"
+
      type="font/woff2"
      crossorigin="anonymous" />

    <link rel="stylesheet" type="text/css" href="/typography.css" />
modified public/colors.css
@@ -1,121 +1,87 @@
:root {
-
  --color-primary: #ff55ff;
-
  --color-primary-1: #ff55ff09;
-
  --color-primary-2: #ff55ff11;
-
  --color-primary-3: #ff55ff22;
-
  --color-primary-4: #ff55ff33;
-
  --color-primary-5: #ff55ff77;
-
  --color-primary-6: hsl(300, 100%, 90%);
-

-
  --color-secondary: #5555ff;
-
  --color-secondary-1: #5555ff09;
-
  --color-secondary-2: #5555ff11;
-
  --color-secondary-3: #5555ff22;
-
  --color-secondary-4: #5555ff33;
-
  --color-secondary-5: #5555ff77;
-
  --color-secondary-6: hsl(240, 100%, 90%);
-

-
  --color-tertiary: #55ffff;
-
  --color-tertiary-1: #55ffff09;
-
  --color-tertiary-2: #55ffff11;
-
  --color-tertiary-3: #55ffff22;
-
  --color-tertiary-4: #55ffff33;
-
  --color-tertiary-5: #55ffff77;
-
  --color-tertiary-6: #abf9f9;
-

-
  --color-caution: #ffff99;
-
  --color-caution-1: #ffff9909;
-
  --color-caution-2: #ffff9911;
-
  --color-caution-3: #ffff9922;
-
  --color-caution-4: #ffff9933;
-
  --color-caution-5: #ffff9977;
-
  --color-caution-6: hsl(60, 100%, 90%);
-

-
  --color-positive: #53db53;
-
  --color-positive-1: #53db5309;
-
  --color-positive-2: #53db5311;
-
  --color-positive-3: #53db5322;
-
  --color-positive-4: #53db5333;
-
  --color-positive-5: #53db5377;
-
  --color-positive-6: hsl(120, 100%, 90%);
-

-
  --color-negative: #ff5555;
-
  --color-negative-1: #ff555509;
-
  --color-negative-2: #ff555511;
-
  --color-negative-3: #ff555522;
-
  --color-negative-4: #ff555533;
-
  --color-negative-5: #ff555577;
-
  --color-negative-6: hsl(0, 100%, 90%);
-

-
  --color-foreground: #ffffff;
-
  --color-foreground-1: #121a21;
-
  --color-foreground-2: #181f28;
-
  --color-foreground-3: #212832;
-
  --color-foreground-4: #2b313d;
-
  --color-foreground-5: #585c6f;
-
  --color-foreground-6: #c2c3d4;
-

-
  --color-background: #0b131a;
-
  --color-background-1: #121a21;
+
  --color-background-default: #f5f5ff;
+
  --color-background-float: #fafaff;
+
  --color-background-dip: #ebebff;
+
  --color-foreground-contrast: #14151a;
+
  --color-foreground-dim: #70718f;
+
  --color-foreground-emphasized: #7070ff;
+
  --color-foreground-emphasized-hover: #8585ff;
+
  --color-foreground-match-background: #f5f5ff;
+
  --color-foreground-white: #ffffff;
+
  --color-foreground-black: #000000;
+
  --color-foreground-primary: #ff55ff;
+
  --color-foreground-success: #4fa877;
+
  --color-foreground-red: #aa5078;
+
  --color-foreground-yellow: #e5c001;
+
  --color-foreground-gray: #9494b8;
+
  --color-foreground-disabled: #b2b2cc;
+
  --color-border-hint: #e5e5ff;
+
  --color-border-focus: #7070ff;
+
  --color-border-contrast: #24252d;
+
  --color-border-default: #b8b8ff;
+
  --color-border-error: #aa5078;
+
  --color-border-merged: #ffe5ff;
+
  --color-border-match-background: #f5f5ff;
+
  --color-fill-secondary: #7070ff;
+
  --color-fill-secondary-hover: #8585ff;
+
  --color-fill-ghost: #e5e5ff;
+
  --color-fill-ghost-hover: #ebebff;
+
  --color-fill-separator: #eaeaf1;
+
  --color-fill-primary: #ff70ff;
+
  --color-fill-success: #4fa877;
+
  --color-fill-yellow: #ffe609;
+
  --color-fill-danger: #be7495;
+
  --color-fill-gray: #9494b8;
+
  --color-fill-primary-hover: #ff80ff;
+
  --color-fill-diff-green: #badeca;
+
  --color-fill-diff-green-light: #dcefe5;
+
  --color-fill-diff-red: #efdce4;
+
  --color-fill-diff-red-light: #f7eef2;
+
  --color-fill-float: #fafaff;
+
  --color-fill-float-hover: #ffffff;
+
  --color-fill-merged: #ffeeff;
}

-
[data-theme="light"] {
-
  --color-primary: #ff55ff;
-
  --color-primary-1: #ff55ff30;
-
  --color-primary-2: #ff55ff45;
-
  --color-primary-3: #ff55ff60;
-
  --color-primary-4: #ff55ff80;
-
  --color-primary-5: #ff55ffaa;
-
  --color-primary-6: hsl(300, 40%, 30%);
-

-
  --color-secondary: #5555ff;
-
  --color-secondary-1: #5555ff10;
-
  --color-secondary-2: #5555ff20;
-
  --color-secondary-3: #5555ff60;
-
  --color-secondary-4: #5555ff80;
-
  --color-secondary-5: #5555ffaa;
-
  --color-secondary-6: hsl(240, 40%, 30%);
-

-
  --color-tertiary: #2ed4c1;
-
  --color-tertiary-1: #2ed4c120;
-
  --color-tertiary-2: #2ed4c140;
-
  --color-tertiary-3: #2ed4c160;
-
  --color-tertiary-4: #2ed4c180;
-
  --color-tertiary-5: #2ed4c1aa;
-
  --color-tertiary-6: #005050;
-

-
  --color-caution: hsl(60, 100%, 30%);
-
  --color-caution-1: #d3d34d20;
-
  --color-caution-2: #d3d34d40;
-
  --color-caution-3: #d3d34d60;
-
  --color-caution-4: #d3d34d80;
-
  --color-caution-5: #d3d34daa;
-
  --color-caution-6: hsl(60, 82%, 22%);
-

-
  --color-positive: #53db53;
-
  --color-positive-1: #53db5320;
-
  --color-positive-2: #53db5340;
-
  --color-positive-3: #53db5360;
-
  --color-positive-4: #53db5380;
-
  --color-positive-5: #53db53aa;
-
  --color-positive-6: hsl(120, 40%, 30%);
-

-
  --color-negative: #ff5555;
-
  --color-negative-1: #ff555520;
-
  --color-negative-2: #ff555540;
-
  --color-negative-3: #ff555560;
-
  --color-negative-4: #ff555580;
-
  --color-negative-5: #ff5555aa;
-
  --color-negative-6: hsl(0, 40%, 30%);
-

-
  --color-foreground: #1a1a2c;
-
  --color-foreground-1: #ebecf8;
-
  --color-foreground-2: #e3e3f3;
-
  --color-foreground-3: #d2d2e8;
-
  --color-foreground-4: #9b9bc0;
-
  --color-foreground-5: #6f6f8a;
-
  --color-foreground-6: #42425a;
-

-
  --color-background: #f3f6fd;
-
  --color-background-1: #ffffff;
+
:root[data-theme="dark"] {
+
  --color-background-default: #0a0d10;
+
  --color-background-float: #14151a;
+
  --color-background-dip: #000000;
+
  --color-foreground-contrast: #f9f9fb;
+
  --color-foreground-dim: #9494b8;
+
  --color-foreground-emphasized: #7070ff;
+
  --color-foreground-emphasized-hover: #8585ff;
+
  --color-foreground-match-background: #0a0d10;
+
  --color-foreground-white: #ffffff;
+
  --color-foreground-black: #000000;
+
  --color-foreground-primary: #ff55ff;
+
  --color-foreground-success: #4fa877;
+
  --color-foreground-red: #aa5078;
+
  --color-foreground-gray: #9494b8;
+
  --color-foreground-yellow: #e5c001;
+
  --color-foreground-disabled: #494a5a;
+
  --color-border-hint: #24252d;
+
  --color-border-focus: #7070ff;
+
  --color-border-contrast: #ebebff;
+
  --color-border-default: #2e2f38;
+
  --color-border-error: #aa5078;
+
  --color-border-merged: #3d003d;
+
  --color-border-match-background: #0a0d10;
+
  --color-fill-secondary: #7070ff;
+
  --color-fill-secondary-hover: #8585ff;
+
  --color-fill-ghost: #24252d;
+
  --color-fill-ghost-hover: #2e2f38;
+
  --color-fill-separator: #24252d;
+
  --color-fill-primary: #ff70ff;
+
  --color-fill-success: #4fa877;
+
  --color-fill-danger: #6b2b42;
+
  --color-fill-yellow: #ffe609;
+
  --color-fill-gray: #9494b8;
+
  --color-fill-primary-hover: #ff80ff;
+
  --color-fill-diff-red: #4d1929;
+
  --color-fill-diff-red-light: #2d060d;
+
  --color-fill-diff-green: #183425;
+
  --color-fill-diff-green-light: #142a1d;
+
  --color-fill-float: #14151a;
+
  --color-fill-float-hover: #1b1c22;
+
  --color-fill-merged: #1a001a;
}
modified public/elevations.css
@@ -1,11 +1,7 @@
:root {
-
  --elevation-low: 0px 16px 32px 32px #00000070;
-
  --elevation-high: 0px 0px 1px var(--color-secondary),
-
    0px 8px 64px var(--color-secondary-5);
+
  --elevation-low: 0 0 64px 0 #00000099;
}

[data-theme="light"] {
-
  --elevation-low: 0px 16px 32px 16px #00000020;
-
  --elevation-high: 0px 0px 1px var(--color-secondary),
-
    0px 8px 64px var(--color-secondary-3);
+
  --elevation-low: 0 0 64px 0 #0000001a;
}
deleted public/fonts/Inter-Bold.otf
added public/fonts/Inter-Bold.woff2
added public/fonts/Inter-Medium.woff2
deleted public/fonts/Inter-Regular.otf
deleted public/fonts/Inter-Regular.woff
added public/fonts/Inter-Regular.woff2
deleted public/fonts/Inter-SemiBold.otf
added public/fonts/Inter-SemiBold.woff2
deleted public/fonts/JetBrainsMono-Bold.ttf
added public/fonts/JetBrainsMono-Bold.woff2
deleted public/fonts/JetBrainsMono-BoldItalic.ttf
deleted public/fonts/JetBrainsMono-Italic.ttf
added public/fonts/JetBrainsMono-Medium.woff2
deleted public/fonts/JetBrainsMono-Regular.ttf
added public/fonts/JetBrainsMono-Regular.woff2
deleted public/fonts/JetBrainsMono-SemiBold.ttf
added public/fonts/JetBrainsMono-SemiBold.woff2
deleted public/fonts/JetBrainsMono-SemiBoldItalic.ttf
modified public/index.css
@@ -4,9 +4,9 @@
}

:root {
-
  --border-radius: 0.75rem;
-
  --border-radius-tiny: 0.25rem;
-
  --border-radius-small: 0.5rem;
+
  --border-radius-tiny: 2px;
+
  --border-radius-small: 4px;
+
  --border-radius-regular: 8px;
  --border-radius-round: 10rem;

  --content-max-width: 1920px;
@@ -17,20 +17,6 @@
  --button-regular-height: 2.5rem;
  --button-small-height: 2rem;
  --button-tiny-height: 1.5rem;
-

-
  --header-gradient: linear-gradient(
-
    180deg,
-
    var(--color-secondary-3) 0%,
-
    transparent 100%
-
  );
-
}
-

-
[data-theme="light"] {
-
  --header-gradient: linear-gradient(
-
    180deg,
-
    var(--color-secondary-2) 0%,
-
    transparent 100%
-
  );
}

html {
@@ -45,12 +31,12 @@ body {
  height: 100%;
  margin: 0;
  padding: 0;
-
  color: var(--color-foreground);
+
  color: var(--color-foreground-contrast);
  text-align: left;
-
  background-color: var(--color-background);
+
  background-color: var(--color-background-default);
  scrollbar-width: thin;
  scrollbar-height: thin;
-
  scrollbar-color: var(--color-foreground-4) transparent;
+
  scrollbar-color: var(--color-fill-ghost) transparent;
}

@media (max-width: 720px) {
@@ -60,8 +46,8 @@ body {
}

::selection {
-
  background: var(--color-primary);
-
  color: var(--color-background);
+
  background: var(--color-fill-yellow);
+
  color: var(--color-foreground-black);
}

/* Chrome/Edge/Safari scrollbar */
@@ -74,14 +60,14 @@ body {
}
*::-webkit-scrollbar-thumb {
  background: transparent;
-
  border-radius: var(--border-radius);
+
  border-radius: var(--border-radius-regular);
}
*::-webkit-scrollbar-corner {
  background: transparent;
}
*:hover::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb {
-
  background-color: var(--color-foreground-4);
+
  background-color: var(--color-fill-ghost);
}

main {
@@ -93,7 +79,13 @@ a {
  text-decoration: none;
}

-
a:hover {
-
  color: var(--color-foreground);
-
  border-bottom-color: var(--color-foreground);
+
pre {
+
  margin: 0;
+
}
+

+
.global-hash {
+
  color: var(--color-foreground-emphasized);
+
  font-size: var(--font-size-small);
+
  font-family: var(--font-family-monospace);
+
  font-weight: var(--font-weight-regular);
}
modified public/typography.css
@@ -3,82 +3,74 @@
  font-style: normal;
  font-weight: 400;
  font-display: swap;
-
  src: url("fonts/Inter-Regular.woff");
+
  src: url("fonts/Inter-Regular.woff2");
}

@font-face {
  font-family: "Inter";
-
  font-weight: 600;
+
  font-weight: 500;
  font-display: swap;
-
  src: url("fonts/Inter-SemiBold.otf");
+
  src: url("fonts/Inter-Medium.woff2");
}

@font-face {
  font-family: "Inter";
-
  font-weight: 700;
-
  font-display: swap;
-
  src: url("fonts/Inter-Bold.otf");
-
}
-

-
@font-face {
-
  font-family: "JetBrains Mono";
-
  font-style: normal;
-
  font-weight: 400;
+
  font-weight: 600;
  font-display: swap;
-
  src: url("fonts/JetBrainsMono-Regular.ttf");
+
  src: url("fonts/Inter-SemiBold.woff2");
}

@font-face {
-
  font-family: "JetBrains Mono";
-
  font-weight: 600;
+
  font-family: "Inter";
+
  font-weight: 700;
  font-display: swap;
-
  src: url("fonts/JetBrainsMono-SemiBold.ttf");
+
  src: url("fonts/Inter-Bold.woff2");
}

@font-face {
  font-family: "JetBrains Mono";
-
  font-weight: 700;
+
  font-style: normal;
+
  font-weight: 400;
  font-display: swap;
-
  src: url("fonts/JetBrainsMono-Bold.ttf");
+
  src: url("fonts/JetBrainsMono-Regular.woff2");
}

@font-face {
  font-family: "JetBrains Mono";
-
  font-style: italic;
-
  font-weight: 400;
+
  font-style: normal;
+
  font-weight: 500;
  font-display: swap;
-
  src: url("fonts/JetBrainsMono-Italic.ttf");
+
  src: url("fonts/JetBrainsMono-Medium.woff2");
}

@font-face {
  font-family: "JetBrains Mono";
-
  font-style: italic;
  font-weight: 600;
  font-display: swap;
-
  src: url("fonts/JetBrainsMono-SemiBoldItalic.ttf");
+
  src: url("fonts/JetBrainsMono-SemiBold.woff2");
}

@font-face {
  font-family: "JetBrains Mono";
-
  font-style: italic;
  font-weight: 700;
  font-display: swap;
-
  src: url("fonts/JetBrainsMono-BoldItalic.ttf");
+
  src: url("fonts/JetBrainsMono-Bold.woff2");
}

:root {
  --font-family-sans-serif: Inter, sans-serif;
  --font-family-monospace: monospace;
-
  --font-weight-normal: 400;
-
  --font-weight-medium: 600;
+
  --font-weight-regular: 400;
+
  --font-weight-medium: 500;
+
  --font-weight-semibold: 600;
  --font-weight-bold: 700;
-
  --font-size-tiny: 0.75rem;
-
  --font-size-small: 0.875rem;
-
  --font-size-regular: 1rem;
-
  --font-size-medium: 1.25rem;
-
  --font-size-large: 1.75rem;
-
  --font-size-x-large: 2rem;
-
  --font-size-xx-large: 3rem;
+
  --font-size-tiny: 0.75rem; /* 12px */
+
  --font-size-small: 0.875rem; /* 14px */
+
  --font-size-regular: 1rem; /* 16px */
+
  --font-size-medium: 1.25rem; /* 20px */
+
  --font-size-large: 1.5rem; /* 24px */
+
  --font-size-x-large: 2rem; /* 32px */
+
  --font-size-xx-large: 3rem; /* 48px */
}

[data-codefont="system"] {
@@ -94,21 +86,17 @@ html {
  -webkit-font-smoothing: antialiased;
  -webkit-text-size-adjust: 100%;
  font-family: var(--font-family-sans-serif);
-
  font-feature-settings: "ss01", "ss02", "cv01", "cv03";
+
  font-feature-settings: "ss01", "cv01", "cv03";
  /* The root element font size has to be set in px,
   * otherwise Safari breaks. */
  font-size: 16px;
-
  font-weight: var(--font-weight-normal);
+
  font-weight: var(--font-weight-regular);
  line-height: 1.5;
}

p {
  margin: 1rem 0;
}
-
.txt-highlight {
-
  color: var(--color-secondary);
-
}
-

.txt-tiny {
  font-size: var(--font-size-tiny);
}
@@ -138,17 +126,13 @@ p {
  font-weight: var(--font-weight-bold) !important;
}
.txt-missing {
-
  color: var(--color-foreground-5);
-
  font-style: italic;
+
  color: var(--color-foreground-dim);
}
.txt-link {
-
  color: var(--color-foreground-6);
  text-decoration: none;
-
  border-bottom: 1px dashed var(--color-foreground-5);
}
.txt-link:hover {
-
  color: var(--color-foreground);
-
  border-bottom-color: var(--color-foreground);
+
  color: var(--color-fill-primary);
}
.txt-emoji {
  height: 1em;
modified src/App.svelte
@@ -5,10 +5,11 @@
  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";
-
  import ModalPortal from "./App/ModalPortal.svelte";

  import Commit from "@app/views/projects/Commit.svelte";
  import History from "@app/views/projects/History.svelte";
@@ -42,16 +43,7 @@

<style>
  .app {
-
    height: 100%;
-
    display: flex;
-
    flex-direction: column;
-
    background: var(--header-gradient);
-
    background-repeat: no-repeat;
-
    background-size: 100% 6rem;
-
  }
-
  .wrapper {
    display: flex;
-
    align-items: center;
    flex-direction: column;
    height: 100%;
  }
@@ -61,42 +53,43 @@
  <LoadingBar />
{/if}

-
<ModalPortal />
+
<FullscreenModalPortal />
<Hotkeys />

<div class="app">
  <Header />
-
  <div class="wrapper">
-
    {#if $activeRouteStore.resource === "booting"}
-
      <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}
+
  {#if $activeRouteStore.resource === "booting"}
+
    <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>
deleted src/App/ColorPaletteModal.svelte
@@ -1,159 +0,0 @@
-
<script lang="ts">
-
  import Modal from "@app/components/Modal.svelte";
-

-
  function extractCssVariables(variableName: string) {
-
    return Array.from(document.styleSheets)
-
      .filter(
-
        sheet =>
-
          sheet.href === null || sheet.href.startsWith(window.location.origin),
-
      )
-
      .reduce<string[]>(
-
        (acc, sheet) =>
-
          (acc = [
-
            ...acc,
-
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
            // @ts-ignore
-
            ...Array.from(sheet.cssRules).reduce(
-
              (def, rule) =>
-
                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                // @ts-ignore
-
                (def =
-
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                  // @ts-ignore
-
                  rule.selectorText === ":root"
-
                    ? [
-
                        ...def,
-
                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                        // @ts-ignore
-
                        ...Array.from(rule.style).filter(name =>
-
                          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                          // @ts-ignore
-
                          name.startsWith(variableName),
-
                        ),
-
                      ]
-
                    : def),
-
              [],
-
            ),
-
          ]),
-
        [],
-
      );
-
  }
-

-
  // rg "\--color-\w*(-\d)*" -o --no-line-number --no-filename -g "\!public/colors.css" -g "\!src/ColorPalette.svelte" | sort | uniq | jq -sRM 'split("\n")[:-1]'
-
  const usedColors = [
-
    "--color-background",
-
    "--color-background-1",
-
    "--color-caution",
-
    "--color-caution-2",
-
    "--color-caution-3",
-
    "--color-caution-6",
-
    "--color-foreground",
-
    "--color-foreground-1",
-
    "--color-foreground-2",
-
    "--color-foreground-3",
-
    "--color-foreground-4",
-
    "--color-foreground-5",
-
    "--color-foreground-6",
-
    "--color-negative",
-
    "--color-negative-1",
-
    "--color-negative-2",
-
    "--color-negative-3",
-
    "--color-negative-4",
-
    "--color-negative-5",
-
    "--color-negative-6",
-
    "--color-positive",
-
    "--color-positive-1",
-
    "--color-positive-2",
-
    "--color-positive-3",
-
    "--color-positive-6",
-
    "--color-primary",
-
    "--color-primary-3",
-
    "--color-primary-5",
-
    "--color-primary-6",
-
    "--color-secondary",
-
    "--color-secondary-1",
-
    "--color-secondary-2",
-
    "--color-secondary-3",
-
    "--color-secondary-5",
-
    "--color-secondary-6",
-
    "--color-tertiary",
-
    "--color-tertiary-1",
-
    "--color-tertiary-2",
-
    "--color-tertiary-3",
-
    "--color-tertiary-6",
-
  ];
-

-
  const colors = extractCssVariables("--color").filter(c => {
-
    return !c.startsWith("--color-prettylights-syntax");
-
  });
-

-
  const colorGroups = [
-
    ...new Set(
-
      colors.map(color => {
-
        const match = color.match(/--color-(\w*)-?/);
-
        if (match) {
-
          return match[1];
-
        } else {
-
          return "";
-
        }
-
      }),
-
    ),
-
  ];
-

-
  let checkers = false;
-
</script>
-

-
<style>
-
  .checkers {
-
    background: repeating-conic-gradient(#88888833 0% 25%, transparent 0% 50%)
-
      50% / 20px 20px;
-
    border-radius: 1rem;
-
  }
-

-
  .container {
-
    display: flex;
-
    margin: 0;
-
    padding: 0;
-
  }
-

-
  .color {
-
    width: 3rem;
-
    height: 3rem;
-
    border-radius: 0.5rem;
-
    outline-style: solid !important;
-
    outline-color: #88888899 !important;
-
    outline-offset: 0.3rem;
-
    margin: 1rem;
-
  }
-

-
  .unused {
-
    outline-style: dotted !important;
-
    outline-color: #55555555 !important;
-
  }
-
</style>
-

-
<Modal closeAction={false}>
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <!-- svelte-ignore a11y-no-static-element-interactions -->
-
  <div slot="body">
-
    <div class="container" on:click={() => (checkers = !checkers)}>
-
      <div class:checkers>
-
        {#each colorGroups as colorGroup}
-
          <div style:display="flex">
-
            {#each colors.filter(color => {
-
              return color.match(`--color-${colorGroup}`);
-
            }) as color}
-
              <div style:display="inline-flex">
-
                <div
-
                  class:unused={!usedColors.includes(color)}
-
                  title={color}
-
                  class="color"
-
                  style:background-color={`var(${color})`} />
-
              </div>
-
            {/each}
-
          </div>
-
        {/each}
-
      </div>
-
    </div>
-
  </div>
-
</Modal>
added src/App/Footer.svelte
@@ -0,0 +1,84 @@
+
<script lang="ts">
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import KeyHint from "@app/components/KeyHint.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import RadworksLogo from "@app/components/RadworksLogo.svelte";
+
  import ThemeSettings from "./Header/ThemeSettings.svelte";
+
</script>
+

+
<style>
+
  .footer {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    font-size: var(--font-size-small);
+
    color: var(--color-foreground-dim);
+
    height: 2.25rem;
+
    background-color: var(--color-background-dip);
+
    padding: 1rem 1.5rem;
+
  }
+

+
  .left {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+

+
  .right {
+
    display: flex;
+
    gap: 1.5rem;
+
  }
+

+
  .radworks {
+
    display: flex;
+
    color: var(--color-foreground-contrast);
+
  }
+

+
  .radworks:hover {
+
    color: var(--color-fill-primary);
+
  }
+
</style>
+

+
<div class="footer">
+
  <div class="left">
+
    <span class="layout-desktop">Supported by</span>
+
    <a
+
      target="_blank"
+
      rel="noreferrer"
+
      class="radworks"
+
      href="https://radworks.org">
+
      <RadworksLogo />
+
    </a>
+
  </div>
+

+
  <div class="center layout-desktop">
+
    Press <KeyHint>?</KeyHint>
+
    for keyboard shortcuts
+
  </div>
+
  <div class="right">
+
    <Popover
+
      popoverPositionBottom="2rem"
+
      popoverPositionRight="0"
+
      popoverWidth="21rem">
+
      <IconButton slot="toggle">
+
        <IconSmall name="brush" />
+
        Theme
+
      </IconButton>
+

+
      <ThemeSettings slot="popover" />
+
    </Popover>
+

+
    <a
+
      style:display="flex"
+
      style:align-items="center"
+
      style:gap="0.25rem"
+
      target="_blank"
+
      rel="noreferrer"
+
      class="txt-link"
+
      href="https://radicle.xyz">
+
      radicle.xyz
+
      <IconSmall name="arrow-box-up-right" />
+
    </a>
+
  </div>
+
</div>
added src/App/FullscreenModalPortal.svelte
@@ -0,0 +1,43 @@
+
<script lang="ts">
+
  import { modalStore, hide } from "@app/lib/modal";
+
</script>
+

+
<style>
+
  .container {
+
    height: 100vh;
+
    width: 100vw;
+
    position: fixed;
+
    z-index: 100;
+
    justify-content: center;
+
    overflow: scroll;
+
    display: flex;
+
  }
+

+
  .overlay {
+
    background-color: black;
+
    opacity: 0.7;
+
    height: 100%;
+
    width: 100%;
+
    position: fixed;
+
  }
+

+
  .content {
+
    z-index: 200;
+
    margin: auto;
+
  }
+
</style>
+

+
{#if $modalStore}
+
  <div class="container">
+
    <!-- svelte-ignore a11y-click-events-have-key-events -->
+
    <div
+
      role="button"
+
      tabindex="0"
+
      class="overlay"
+
      on:click={hide}
+
      style:cursor={$modalStore.disableHide ? "not-allowed" : "default"} />
+
    <div class="content">
+
      <svelte:component this={$modalStore.component} {...$modalStore.props} />
+
    </div>
+
  </div>
+
{/if}
modified src/App/Header.svelte
@@ -1,11 +1,8 @@
<script lang="ts">
-
  import Floating from "@app/components/Floating.svelte";
-
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";

  import Connect from "@app/App/Header/Connect.svelte";
  import Search from "@app/App/Header/Search.svelte";
-
  import SettingsDropdown from "@app/App/Header/SettingsDropdown.svelte";
</script>

<style>
@@ -25,32 +22,16 @@
    gap: 1rem;
  }

+
  .logo {
+
    height: var(--button-regular-height);
+
    margin-right: 0.5rem;
+
  }
+

  @media (max-width: 720px) {
    header .right {
      gap: 1rem;
    }
  }
-

-
  .toggle {
-
    width: 2.5rem;
-
    height: 2.5rem;
-
    border-radius: var(--border-radius-round);
-
    border: 1px solid var(--color-foreground);
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    background-color: transparent;
-
    color: var(--color-foreground);
-
    cursor: pointer;
-
  }
-
  .toggle:hover {
-
    background-color: var(--color-foreground);
-
    color: var(--color-background);
-
  }
-
  .logo {
-
    height: var(--button-regular-height);
-
    margin-right: 0.5rem;
-
  }
</style>

<header>
@@ -70,13 +51,5 @@
    <div class="layout-desktop">
      <Connect />
    </div>
-
    <Floating>
-
      <div slot="toggle">
-
        <button class="toggle" aria-label="Settings" name="Settings">
-
          <Icon name="gear" />
-
        </button>
-
      </div>
-
      <SettingsDropdown slot="modal" />
-
    </Floating>
  </div>
</header>
modified src/App/Header/Connect.svelte
@@ -2,26 +2,18 @@
  import type { HttpdState } from "@app/lib/httpd";

  import * as httpd from "@app/lib/httpd";
-
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import * as modal from "@app/lib/modal";
+
  import { closeFocused } from "@app/components/Popover.svelte";
  import { httpdStore } from "@app/lib/httpd";

-
  import Authorship from "@app/components/Authorship.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Clipboard from "@app/components/Clipboard.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import Floating from "@app/components/Floating.svelte";
+
  import ConnectModal from "@app/modals/ConnectModal.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
  import Link from "@app/components/Link.svelte";
-
  import PortInput from "@app/App/Header/Connect/PortInput.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Popover from "@app/components/Popover.svelte";

-
  $: customUrl = `${httpd.api.baseUrl.scheme}://${httpd.api.baseUrl.hostname}:${customPort}`;
-
  $: command = import.meta.env.PROD
-
    ? `rad web --backend ${customUrl}`
-
    : `rad web --frontend ${
-
        new URL(import.meta.url).origin
-
      } --backend ${customUrl}`;
-

-
  let customPort = httpd.api.port;
  const buttonTitle: Record<HttpdState["state"], string> = {
    stopped: "radicle-httpd is stopped",
    running: "radicle-httpd is running",
@@ -30,147 +22,149 @@
</script>

<style>
-
  .dropdown {
-
    align-items: center;
-
    background: var(--color-background-1);
-
    border-radius: var(--border-radius);
-
    box-shadow: var(--elevation-low);
-
    color: var(--color-foreground-6);
-
    position: absolute;
-
    right: 5rem;
-
    top: 5rem;
-
    width: 15rem;
-
  }
-
  .info {
-
    display: flex;
-
    padding: 1rem 1rem 0.5rem 1rem;
-
  }
-
  .avatar-id-container {
+
  .container {
    display: flex;
    flex-direction: column;
-
    padding: 0.5rem 0.5rem 0.5rem 0.8rem;
-
    width: 100%;
-
    gap: 0.5rem;
+
    gap: 1.5rem;
  }
-
  .dropdown-button {
-
    align-items: center;
-
    border-top: 1px solid var(--color-foreground-3);
-
    cursor: pointer;
+
  .host {
    display: flex;
-
    flex-direction: row;
-
    font-weight: 600;
-
    height: 2.5rem;
    justify-content: space-between;
-
    line-height: 2.5rem;
-
    padding: 0 1rem;
-
    user-select: none;
-
    width: 100%;
+
    align-items: center;
+
    font-size: var(--font-size-small);
  }
-
  .dropdown-button:hover {
-
    background-color: var(--color-foreground-3);
-
    color: var(--color-foreground-6);
+
  .status {
+
    font-size: var(--font-size-tiny);
+
    color: var(--color-fill-gray);
+
    text-align: left;
  }
-
  .rounded:last-of-type:hover {
-
    border-bottom-left-radius: var(--border-radius);
-
    border-bottom-right-radius: var(--border-radius);
+
  .separator {
+
    height: 1px;
+
    background-color: var(--color-border-hint);
  }
-
  .stopped {
-
    color: var(--color-foreground-5);
+
  .avatar {
+
    height: 1.5rem;
+
    color: var(--color-fill-secondary);
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
    justify-content: center;
+
    font-weight: var(--font-weight-regular);
+
    font-family: var(--font-family-monospace);
  }
-
  .running {
-
    color: var(--color-foreground);
+
  .indicator {
+
    width: 0.75rem;
+
    height: 0.75rem;
+
    background-color: var(--color-fill-secondary);
+
    border-radius: var(--border-radius-round);
+
    position: absolute;
+
    top: -0.375rem;
+
    right: -0.375rem;
  }
-
  .authenticated {
-
    color: var(--color-positive);
+
  .row {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
  }
-
  .toggle:hover .authenticated {
-
    color: var(--color-positive);
+
  .user {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
  }
+
  .identity {
+
    color: var(--color-fill-secondary);
+
    display: flex;
  }
</style>

-
<Floating>
-
  <div slot="toggle" class="toggle">
+
{#if $httpdStore.state === "authenticated"}
+
  <Popover
+
    popoverPositionTop="3rem"
+
    popoverPositionRight="0"
+
    popoverWidth="25rem">
    <Button
+
      slot="toggle"
      title={buttonTitle[$httpdStore.state]}
-
      style="padding-left: 10px; padding-right: 1rem;"
+
      size="large"
      variant="outline">
-
      <div style="display: flex; gap: 0.5rem">
-
        <div
-
          class:authenticated={$httpdStore.state === "authenticated"}
-
          class:stopped={$httpdStore.state === "stopped"}
-
          class:running={$httpdStore.state === "running"}>
-
          <Icon name="network" />
-
        </div>
-
        radicle.local
+
      <div class="avatar">
+
        <NodeId
+
          large
+
          disableTooltip
+
          nodeId={$httpdStore.session.publicKey}
+
          alias={$httpdStore.session.alias} />
      </div>
    </Button>
-
  </div>

-
  <div slot="modal">
-
    {#if $httpdStore.state === "authenticated"}
-
      <div class="dropdown">
-
        <div class="avatar-id-container">
-
          <div style="align-items: center; display: flex; gap: 0.25rem;">
-
            <Authorship authorId={$httpdStore.session.publicKey} />
-
            <Clipboard text={$httpdStore.session.publicKey} small />
-
          </div>
-
        </div>
+
    <div slot="popover" class="container">
+
      <div class="row">
+
        <div class="status">Httpd server running</div>

-
        <Link
-
          on:afterNavigate={closeFocused}
-
          route={{
-
            resource: "nodes",
-
            params: {
-
              baseUrl: httpd.api.baseUrl,
-
              projectPageIndex: 0,
-
            },
-
          }}>
-
          <div class="dropdown-button">Browse</div>
-
        </Link>
+
        <div class="host">
+
          radicle.local

-
        <!-- svelte-ignore a11y-click-events-have-key-events -->
-
        <!-- svelte-ignore a11y-no-static-element-interactions -->
-
        <div
-
          class="dropdown-button rounded"
-
          on:click={() => {
-
            void httpd.disconnect();
-
            closeFocused();
-
          }}>
-
          Disconnect
+
          <Link
+
            on:afterNavigate={closeFocused}
+
            route={{
+
              resource: "nodes",
+
              params: {
+
                baseUrl: httpd.api.baseUrl,
+
                projectPageIndex: 0,
+
              },
+
            }}>
+
            <IconButton>Browse</IconButton>
+
          </Link>
        </div>
      </div>
-
    {:else if $httpdStore.state === "running"}
-
      <div class="dropdown" style:width="20.5rem">
-
        <div class="info">
-
          To connect to your local Radicle node, run this command in your
-
          terminal:
-
        </div>
-
        <div style:margin="0 1rem 0.5rem 1rem">
-
          <Command {command} />
-
        </div>
-
        <PortInput bind:port={customPort} />
-
        <Link
-
          on:afterNavigate={closeFocused}
-
          route={{
-
            resource: "nodes",
-
            params: {
-
              baseUrl: httpd.api.baseUrl,
-
              projectPageIndex: 0,
-
            },
-
          }}>
-
          <div class="dropdown-button rounded">Browse</div>
-
        </Link>
-
      </div>
-
    {:else}
-
      <div class="dropdown" style:width="20.5rem">
-
        <div class="info">
-
          To access your local Radicle node on this site, run:
-
        </div>
-
        <div style:margin="0.5rem 1rem 1rem 1rem">
-
          <Command command="radicle-httpd" />
+

+
      <div class="separator" />
+

+
      <div class="row">
+
        <div class="status">Authenticated as</div>
+
        <div class="user">
+
          <div class="identity">
+
            <NodeId
+
              nodeId={$httpdStore.session.publicKey}
+
              alias={$httpdStore.session.alias} />
+
          </div>
+
          <IconButton
+
            on:click={() => {
+
              void httpd.disconnect();
+
              closeFocused();
+
            }}>
+
            Disconnect
+
          </IconButton>
        </div>
-
        <PortInput bind:port={customPort} />
      </div>
-
    {/if}
-
  </div>
-
</Floating>
+
    </div>
+
  </Popover>
+
{:else if $httpdStore.state === "running"}
+
  <Button
+
    on:click={() => {
+
      modal.show({
+
        component: ConnectModal,
+
        props: {},
+
      });
+
    }}
+
    title={buttonTitle[$httpdStore.state]}
+
    size="large"
+
    variant="outline">
+
    <Icon name="device" />
+
    Read only
+
    <div class="indicator" />
+
  </Button>
+
{:else}
+
  <Button
+
    on:click={() => {
+
      modal.show({
+
        component: ConnectModal,
+
        props: {},
+
      });
+
    }}
+
    title={buttonTitle[$httpdStore.state]}
+
    size="large"
+
    variant="secondary">
+
    <Icon name="device" />
+
    Connect
+
  </Button>
+
{/if}
deleted src/App/Header/Connect/PortInput.svelte
@@ -1,34 +0,0 @@
-
<script lang="ts">
-
  import * as httpd from "@app/lib/httpd";
-

-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  export let port: string;
-

-
  $: validPortNumber = Number(port) > 0 && Number(port) <= 65535;
-
</script>
-

-
<style>
-
  .item {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    padding: 0.5rem 1rem 0.5rem 1rem;
-
    justify-content: space-between;
-
    border-top: 1px solid var(--color-foreground-3);
-
    height: 2.5rem;
-
  }
-
</style>
-

-
<div class="item">
-
  <span>Port</span>
-
  <div style:width="6.5rem">
-
    <TextInput
-
      name="port"
-
      size="small"
-
      variant="modal"
-
      bind:value={port}
-
      valid={validPortNumber}
-
      on:submit={() => httpd.changeHttpdPort(Number(port))} />
-
  </div>
-
</div>
modified src/App/Header/Search.svelte
@@ -9,7 +9,7 @@
  import { unreachable } from "@app/lib/utils";

  import Icon from "@app/components/Icon.svelte";
-
  import SearchResultsModal from "@app/App/Header/SearchResultsModal.svelte";
+
  import SearchResultsModal from "@app/modals/SearchResultsModal.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  const dispatch = createEventDispatcher<{
@@ -100,6 +100,7 @@
  .search {
    transition: all 0.2s;
    width: 11rem;
+
    color: var(--color-fill-secondary);
  }
  .expanded {
    width: 25.5rem;
deleted src/App/Header/SearchResultsModal.svelte
@@ -1,56 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { ProjectBaseUrl } from "@app/lib/search";
-

-
  import * as modal from "@app/lib/modal";
-
  import { formatRepositoryId } from "@app/lib/utils";
-

-
  import Link from "@app/components/Link.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-

-
  export let query: string;
-
  export let results: ProjectBaseUrl[];
-
</script>
-

-
<style>
-
  .results {
-
    text-align: left;
-
  }
-
  ul {
-
    list-style-type: none;
-
    padding: 0;
-
  }
-
  li {
-
    margin: 0.5rem 0;
-
  }
-
  .id {
-
    color: var(--color-foreground-5);
-
  }
-
</style>
-

-
<Modal emoji="🔍" title={`Results for "${query}"`}>
-
  <span slot="body" class="results">
-
    {#if results.length > 0}
-
      <div class="txt-highlight txt-medium">Projects</div>
-
      <ul>
-
        {#each results as result}
-
          <li>
-
            <Link
-
              on:afterNavigate={modal.hide}
-
              route={{
-
                resource: "project.source",
-
                node: result.baseUrl,
-
                project: result.project.id,
-
              }}>
-
              <span title={result.baseUrl.hostname}>
-
                <span>{result.project.name}</span>
-
                <span class="id">
-
                  &nbsp;{formatRepositoryId(result.project.id)}
-
                </span>
-
              </span>
-
            </Link>
-
          </li>
-
        {/each}
-
      </ul>
-
    {/if}
-
  </span>
-
</Modal>
deleted src/App/Header/SettingsDropdown.svelte
@@ -1,121 +0,0 @@
-
<script lang="ts">
-
  import type { CodeFont } from "@app/lib/appearance";
-

-
  import Icon from "@app/components/Icon.svelte";
-
  import ThemeToggle from "./ThemeToggle.svelte";
-
  import { codeFont, storeCodeFont } from "@app/lib/appearance";
-
  import { codeFonts } from "@app/lib/appearance";
-
  import { quadIn } from "svelte/easing";
-
  import { slide } from "svelte/transition";
-

-
  let showFonts = false;
-

-
  $: document.documentElement.setAttribute("data-codefont", $codeFont);
-

-
  const switchFont = (font: CodeFont) => {
-
    codeFont.set(font);
-
    storeCodeFont(font);
-
  };
-
</script>
-

-
<style>
-
  .dropdown {
-
    position: absolute;
-
    top: 5rem;
-
    right: 1.5rem;
-
    width: 16.5rem;
-
    background: var(--color-background-1);
-
    border-radius: var(--border-radius);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
    color: var(--color-foreground-6);
-
    box-shadow: var(--elevation-low);
-
  }
-
  .dropdown:hover :last-child {
-
    border-bottom-left-radius: var(--border-radius);
-
    border-bottom-right-radius: var(--border-radius);
-
  }
-
  .item {
-
    width: 100%;
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    align-items: center;
-
    height: 2.5rem;
-
    padding: 0 0.8rem;
-
    font-weight: 600;
-
    line-height: 2.5rem;
-
    user-select: none;
-
  }
-
  .item:first-of-type {
-
    border-bottom: 1px solid var(--color-foreground-3);
-
  }
-
  .selector {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: space-between;
-
    cursor: pointer;
-
  }
-
  .fonts {
-
    width: 100%;
-
  }
-
  .fonts > .item {
-
    border-bottom: none;
-
  }
-
  .font {
-
    color: var(--color-foreground-5);
-
    cursor: pointer;
-
  }
-
  .font:last-of-type {
-
    border-bottom-left-radius: var(--border-radius);
-
    border-bottom-right-radius: var(--border-radius);
-
  }
-
  .selector:hover {
-
    background-color: var(--color-foreground-3);
-
    color: var(--color-foreground-6);
-
  }
-
  .font:hover {
-
    background-color: var(--color-foreground-3);
-
    color: var(--color-foreground-5);
-
  }
-
  .active,
-
  .active:hover {
-
    color: var(--color-foreground-6);
-
  }
-
</style>
-

-
<div class="dropdown">
-
  <div class="item">
-
    <span>Theme</span>
-
    <ThemeToggle />
-
  </div>
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <!-- svelte-ignore a11y-no-static-element-interactions -->
-
  <div
-
    class="item selector"
-
    on:click|stopPropagation={() => (showFonts = !showFonts)}>
-
    <div>Code font</div>
-
    <Icon name={`chevron-${showFonts ? "down" : "right"}`} />
-
  </div>
-
  {#if showFonts}
-
    <div class="fonts" transition:slide={{ duration: 150, easing: quadIn }}>
-
      {#each codeFonts as font}
-
        {@const isSelectedFont = $codeFont === font.storedName}
-
        <!-- svelte-ignore a11y-click-events-have-key-events -->
-
        <!-- svelte-ignore a11y-no-static-element-interactions -->
-
        <div
-
          on:click={() => switchFont(font.storedName)}
-
          class="item font"
-
          class:active={isSelectedFont}
-
          style:font-family={font.fontFamily}>
-
          {font.displayName}
-
          {#if isSelectedFont}
-
            <Icon name="checkmark-small" />
-
          {/if}
-
        </div>
-
      {/each}
-
    </div>
-
  {/if}
-
</div>
added src/App/Header/ThemeSettings.svelte
@@ -0,0 +1,89 @@
+
<script lang="ts">
+
  import type { CodeFont, Theme } from "@app/lib/appearance";
+

+
  import {
+
    codeFont,
+
    codeFonts,
+
    storeCodeFont,
+
    storeTheme,
+
    theme,
+
  } from "@app/lib/appearance";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+
  import Button from "@app/components/Button.svelte";
+

+
  $: document.documentElement.setAttribute("data-codefont", $codeFont);
+
  $: document.documentElement.setAttribute("data-theme", $theme);
+

+
  function switchFont(font: CodeFont) {
+
    codeFont.set(font);
+
    storeCodeFont(font);
+
  }
+

+
  function switchTheme(newTheme: Theme) {
+
    theme.set(newTheme);
+
    storeTheme(newTheme);
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    gap: 1.5rem;
+
    font-size: var(--font-size-small);
+
  }
+

+
  .item {
+
    display: flex;
+
    width: 100%;
+
    align-items: center;
+
  }
+

+
  .right {
+
    display: flex;
+
    margin-left: auto;
+
  }
+
</style>
+

+
<div class="container">
+
  <div class="item">
+
    <div>Theme</div>
+
    <div class="right">
+
      <Radio>
+
        <Button
+
          ariaLabel="Light Mode"
+
          styleBorderRadius="0"
+
          variant={$theme === "light" ? "secondary" : "gray"}
+
          on:click={() => switchTheme("light")}>
+
          <Icon name="sun" />
+
        </Button>
+
        <Button
+
          ariaLabel="Dark Mode"
+
          styleBorderRadius="0"
+
          variant={$theme === "dark" ? "secondary" : "gray"}
+
          on:click={() => switchTheme("dark")}>
+
          <Icon name="moon" />
+
        </Button>
+
      </Radio>
+
    </div>
+
  </div>
+
  <div class="item">
+
    <div>Code Font</div>
+
    <div class="right">
+
      <Radio>
+
        {#each codeFonts as font}
+
          <Button
+
            styleBorderRadius="0"
+
            styleFontFamily={font.fontFamily}
+
            on:click={() => switchFont(font.storedName)}
+
            variant={$codeFont === font.storedName ? "secondary" : "gray"}>
+
            {font.displayName}
+
          </Button>
+
        {/each}
+
      </Radio>
+
    </div>
+
  </div>
+
</div>
deleted src/App/Header/ThemeToggle.svelte
@@ -1,29 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-
  import ToggleSwitch from "@app/components/ToggleSwitch.svelte";
-
  import { theme, storeTheme } from "@app/lib/appearance";
-

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

-
<style>
-
  .theme {
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    user-select: none;
-
    gap: 0.5rem;
-
    cursor: pointer;
-
  }
-
</style>
-

-
<div class="theme">
-
  <Icon name="sun" on:click={() => theme.set("light")} />
-
  <ToggleSwitch
-
    checked={$theme === "dark"}
-
    on:change={() => {
-
      theme.set($theme === "dark" ? "light" : "dark");
-
      storeTheme($theme);
-
    }} />
-
  <Icon name="moon" on:click={() => theme.set("dark")} />
-
</div>
modified src/App/Hotkeys.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
  import * as modal from "@app/lib/modal";

-
  import ColorPaletteModal from "@app/App/ColorPaletteModal.svelte";
-
  import HotkeysModal from "@app/App/HotkeysModal.svelte";
+
  import ColorPaletteModal from "@app/modals/ColorPaletteModal.svelte";
+
  import HotkeysModal from "@app/modals/HotkeysModal.svelte";
  import { searchPlaceholder } from "@app/lib/shared";

  const onKeydown = (event: KeyboardEvent) => {
deleted src/App/HotkeysModal.svelte
@@ -1,79 +0,0 @@
-
<script lang="ts">
-
  import Modal from "@app/components/Modal.svelte";
-
</script>
-

-
<style>
-
  .hotkeys {
-
    gap: 3rem;
-
    align-items: flex-start;
-
    justify-content: center;
-
    display: flex;
-
    color: var(--color-foreground-6);
-
  }
-

-
  .key {
-
    border: 1px solid var(--color-secondary-5);
-
    box-shadow: inset 0 -4px 0 var(--color-secondary-5);
-
    height: 36px;
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-secondary-1);
-
    min-width: 2rem;
-
    padding: 0 1rem 4px 1rem;
-
  }
-

-
  .description {
-
    text-align: left;
-
  }
-

-
  .pair {
-
    display: flex;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-

-
  .group {
-
    display: flex;
-
    gap: 2rem;
-
    flex-direction: column;
-
  }
-
</style>
-

-
<Modal emoji="⌨️" title="Keyboard shortcuts" closeAction={false}>
-
  <div slot="body" style:margin="1rem 0">
-
    <div class="hotkeys">
-
      <div class="group">
-
        <div class="pair">
-
          <div class="key txt-bold">?</div>
-
          <div class="description">Shortcuts</div>
-
        </div>
-

-
        <div class="pair">
-
          <div class="key txt-bold">/</div>
-
          <div class="description">Search</div>
-
        </div>
-

-
        {#if import.meta.env.DEV}
-
          <div class="pair">
-
            <div class="key txt-bold">d</div>
-
            <div class="description">Color palette</div>
-
          </div>
-
        {/if}
-
      </div>
-

-
      <div class="group">
-
        <div class="pair">
-
          <div class="key txt-bold">enter</div>
-
          <div class="description">Submit</div>
-
        </div>
-

-
        <div class="pair">
-
          <div class="key txt-bold">esc</div>
-
          <div class="description">Close</div>
-
        </div>
-
      </div>
-
    </div>
-
  </div>
-
</Modal>
modified src/App/LoadingBar.svelte
@@ -5,7 +5,7 @@
<style>
  .loading-bar {
    height: 0.125rem;
-
    background-color: var(--color-secondary);
+
    background-color: var(--color-fill-secondary);
    width: 0%;
    opacity: 0;
    position: fixed;
deleted src/App/ModalPortal.svelte
@@ -1,42 +0,0 @@
-
<script lang="ts">
-
  import { modalStore, hide } from "@app/lib/modal";
-
</script>
-

-
<style>
-
  .container {
-
    height: 100vh;
-
    width: 100vw;
-
    position: fixed;
-
    z-index: 100;
-
    justify-content: center;
-
    overflow: scroll;
-
    display: flex;
-
  }
-

-
  .overlay {
-
    background-color: black;
-
    opacity: 0.7;
-
    height: 100%;
-
    width: 100%;
-
    position: fixed;
-
  }
-

-
  .content {
-
    z-index: 200;
-
    margin: auto;
-
  }
-
</style>
-

-
{#if $modalStore}
-
  <div class="container">
-
    <!-- svelte-ignore a11y-click-events-have-key-events -->
-
    <!-- svelte-ignore a11y-no-static-element-interactions -->
-
    <div
-
      class="overlay"
-
      on:click={hide}
-
      style:cursor={$modalStore.disableHide ? "not-allowed" : "default"} />
-
    <div class="content">
-
      <svelte:component this={$modalStore.component} {...$modalStore.props} />
-
    </div>
-
  </div>
-
{/if}
deleted src/components/Authorship.svelte
@@ -1,71 +0,0 @@
-
<script lang="ts" context="module">
-
  export type AuthorAliasColor =
-
    | "--color-primary-5"
-
    | "--color-foreground-5"
-
    | "--color-positive-5"
-
    | "--color-negative-5"
-
    | undefined;
-
</script>
-

-
<script lang="ts">
-
  import Avatar from "@app/components/Avatar.svelte";
-
  import { formatNodeId, formatTimestamp } from "@app/lib/utils";
-

-
  export let authorId: string;
-
  export let authorAlias: string | undefined = undefined;
-
  export let authorAliasColor: AuthorAliasColor = "--color-foreground-5";
-
  export let caption: string | undefined = undefined;
-
  export let noAvatar: boolean = false;
-
  export let timestamp: number | undefined = undefined;
-

-
  const relativeTimestamp = (time: number | undefined) =>
-
    time ? new Date(time * 1000).toString() : undefined;
-
</script>
-

-
<style>
-
  .authorship {
-
    display: inline-flex;
-
    align-items: center;
-
    color: inherit;
-
    padding: 0.125rem 0;
-
    gap: 0.25rem;
-
  }
-
  .id {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .body {
-
    white-space: nowrap;
-
  }
-
</style>
-

-
<span class="authorship txt-tiny">
-
  {#if !noAvatar}
-
    <Avatar inline nodeId={authorId} />
-
  {/if}
-
  <span class="id layout-desktop">
-
    {formatNodeId(authorId)}
-
    {#if authorAlias}
-
      <span style:color="var({authorAliasColor})">({authorAlias})</span>
-
    {/if}
-
  </span>
-
  <span class="id layout-mobile">
-
    {formatNodeId(authorId).replace("did:key:", "")}
-
    {#if authorAlias}
-
      <span style:color="var({authorAliasColor})">({authorAlias})</span>
-
    {/if}
-
  </span>
-
  {#if !caption}
-
    <slot />
-
  {:else}
-
    <span class="body">
-
      {caption}
-
    </span>
-
  {/if}
-
  {#if timestamp}
-
    <span title={relativeTimestamp(timestamp)}>
-
      {formatTimestamp(timestamp)}
-
    </span>
-
  {/if}
-
</span>
modified src/components/Avatar.svelte
@@ -20,6 +20,7 @@
  .avatar {
    display: block;
    border-radius: var(--border-radius-round);
+
    box-shadow: 0 0 0 1px var(--color-border-match-background);
    min-width: 1rem;
    min-height: 1rem;
    height: 100%;
modified src/components/Badge.svelte
@@ -1,62 +1,96 @@
-
<script lang="ts" context="module">
-
  export type Variant =
+
<script lang="ts">
+
  export let variant:
    | "caution"
    | "foreground"
+
    | "background"
+
    | "neutral"
    | "negative"
    | "positive"
    | "primary"
    | "secondary";
-
</script>
-

-
<script lang="ts">
-
  export let variant: Variant;
  export let style: string | undefined = undefined;
+
  export let size: "tiny" | "small" | "medium" = "tiny";
+
  export let title: string | undefined = undefined;
</script>

<style>
  .badge {
-
    border-radius: var(--border-radius);
-
    padding: 0.125rem 0.5rem;
+
    border-radius: var(--border-radius-round);
    font-size: var(--font-size-tiny);
+
    font-weight: var(--font-weight-bold);
    line-height: 1.6;
    height: var(--button-tiny-height);
    display: flex;
    white-space: nowrap;
+
    align-items: center;
+
    gap: 0.25rem;
+
    max-width: 13.5rem;
+
  }
+
  .background {
+
    color: currentColor;
+
    background: var(--color-background-float);
  }
  .foreground {
-
    color: var(--color-foreground-6);
-
    background: var(--color-foreground-3);
+
    color: var(--color-foreground-match-background);
+
    background: var(--color-foreground-gray);
+
  }
+
  .neutral {
+
    color: var(--color-foreground-contrast);
+
    background: var(--color-fill-ghost);
  }
  .positive {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-3);
+
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-success);
  }
  .secondary {
-
    color: var(--color-secondary-6);
-
    background-color: var(--color-secondary-3);
+
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-secondary);
  }
  .negative {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-3);
+
    color: var(--color-foreground-white);
+
    background-color: var(--color-foreground-red);
  }
  .primary {
-
    color: var(--color-primary-6);
-
    background: var(--color-primary-3);
+
    color: var(--color-foreground-match-background);
+
    background: var(--color-fill-primary);
  }
  .caution {
-
    color: var(--color-caution-6);
-
    background: var(--color-caution-3);
+
    color: var(--color-foreground-black);
+
    background: var(--color-foreground-yellow);
+
  }
+
  .tiny {
+
    height: 1.375rem;
+
    font-size: var(--font-size-tiny);
+
    font-weight: var(--font-weight-semibold);
+
    padding: 0.25rem 0.5rem;
+
  }
+
  .small {
+
    height: 2rem;
+
    font-size: var(--font-size-small);
+
    padding: 0.5rem 0.75rem;
+
  }
+
  .medium {
+
    height: 2.5rem;
+
    font-size: var(--font-size-small);
+
    padding: 0.75rem 1rem;
  }
</style>

-
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
+
  role="button"
+
  tabindex="0"
  on:mouseenter
  on:mouseleave
  class="badge"
  {style}
+
  {title}
+
  class:tiny={size === "tiny"}
+
  class:small={size === "small"}
+
  class:medium={size === "medium"}
  class:caution={variant === "caution"}
+
  class:background={variant === "background"}
  class:foreground={variant === "foreground"}
+
  class:neutral={variant === "neutral"}
  class:negative={variant === "negative"}
  class:positive={variant === "positive"}
  class:primary={variant === "primary"}
modified src/components/Button.svelte
@@ -1,152 +1,213 @@
-
<script lang="ts">
+
<script lang="ts" strictEvents>
+
  export let ariaLabel: string | undefined = undefined;
  export let title: string | undefined = undefined;
  export let variant:
-
    | "foreground"
-
    | "negative"
+
    | "background"
+
    | "dim"
+
    | "gray"
+
    | "gray-white"
+
    | "none"
    | "outline"
    | "primary"
    | "secondary"
-
    | "text";
-
  export let size: "tiny" | "small" | "regular" = "regular";
+
    | "tab" = "gray";
+
  export let size: "small" | "regular" | "large" = "regular";

  export let autofocus: boolean = false;
  export let disabled: boolean = false;
-
  export let waiting: boolean = false;
-
  export let style: string | undefined = undefined;
+

+
  export let styleFontFamily: string | undefined = undefined;
+
  export let stylePadding: string | undefined = undefined;
+
  export let styleWidth: "100%" | undefined = undefined;
+
  export let styleBorderRadius: string | undefined = undefined;
</script>

<style>
  button {
-
    background: transparent;
-
    border-radius: var(--border-radius-round);
-
    border: 1px solid var(--color-foreground);
+
    position: relative;
    cursor: pointer;
-
    font-family: var(--font-family-sans-serif);
-
    font-feature-settings: "ss01", "ss02", "cv01", "cv03";
-
    font-size: var(--font-size-regular);
-
    line-height: 1.6rem;
-
    display: inline-flex;
-
    justify-content: center;
+
    display: flex;
    align-items: center;
+
    justify-content: center;
+
    border: none;
+
    border-radius: var(--border-radius-tiny);
+
    font-family: var(--font-family-sans-serif);
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    white-space: nowrap;
+
    gap: 0.5rem;
  }
-
  button[disabled] {
+

+
  button:disabled {
    cursor: not-allowed;
+
    color: var(--color-foreground-disabled);
+
  }
+

+
  .small {
+
    height: var(--button-tiny-height);
+
    padding: 0 0.6rem;
+
  }
+

+
  .regular {
+
    height: var(--button-small-height);
+
    padding: 0 0.75rem;
  }
-
  button:not([disabled]):hover {
-
    color: var(--color-background);
+

+
  .large {
+
    border-radius: var(--border-radius-small);
+
    height: var(--button-regular-height);
+
    padding: 0 1rem;
  }
-
  .foreground {
-
    color: var(--color-foreground);
+

+
  .background {
+
    color: var(--color-fill-secondary);
+
    background-color: var(--color-background-default);
  }
-
  .foreground[disabled] {
-
    color: var(--color-foreground-5);
-
    border-color: var(--color-foreground-5);
+
  .background[disabled] {
+
    color: var(--color-foreground-disabled);
+
    background-color: var(--color-background-default);
  }
-
  .foreground:not([disabled]):hover {
-
    background-color: var(--color-foreground);
+
  .background:not([disabled]):hover {
+
    background-color: var(--color-fill-ghost);
  }

-
  .primary {
-
    color: var(--color-primary);
-
    border-color: var(--color-primary);
+
  .dim {
+
    background-color: var(--color-fill-float-hover);
+
    color: var(--color-fill-secondary);
  }
-
  .primary[disabled] {
-
    color: var(--color-primary-5);
-
    border-color: var(--color-primary-5);
+
  .dim[disabled] {
+
    background-color: var(--color-fill-float-hover);
+
    color: var(--color-fill-secondary);
  }
-
  .primary:not([disabled]):hover {
-
    background-color: var(--color-primary);
+
  .dim:not([disabled]):hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-fill-secondary);
  }

-
  .secondary {
-
    color: var(--color-secondary);
-
    border-color: var(--color-secondary);
+
  .gray {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-fill-secondary);
  }
-
  .secondary[disabled] {
-
    color: var(--color-secondary-5);
-
    border-color: var(--color-secondary-5);
+
  .gray[disabled] {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-disabled);
  }
-
  .secondary:not([disabled]):hover {
-
    background-color: var(--color-secondary);
+
  .gray:not([disabled]):hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-fill-secondary);
  }

-
  .negative {
-
    color: var(--color-negative);
-
    border-color: var(--color-negative);
+
  .gray-white {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-contrast);
  }
-
  .negative[disabled] {
-
    color: var(--color-negative-5);
-
    border-color: var(--color-negative-5);
+
  .gray-white[disabled] {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-disabled);
  }
-
  .negative:not([disabled]):hover {
-
    background-color: var(--color-negative);
+
  .gray-white:not([disabled]):hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-foreground-contrast);
  }

-
  .text {
-
    color: var(--color-foreground);
-
    border: none;
+
  .none {
+
    background-color: transparent;
+
    color: var(--color-foreground-emphasized);
  }
-
  .text[disabled] {
-
    color: var(--color-foreground-5);
+
  .none[disabled] {
+
    background-color: transparent;
+
    color: var(--color-foreground-emphasized);
  }
-
  .text:not([disabled]):hover {
-
    background-color: var(--color-foreground);
+
  .none:not([disabled]):hover {
+
    background-color: var(--color-fill-ghost);
  }

  .outline {
-
    color: var(--color-foreground);
-
    border: none;
-
    border: 1px solid transparent;
+
    background-color: transparent;
+
    color: var(--color-foreground-contrast);
+
    border: 1px solid var(--color-border-hint);
  }
  .outline[disabled] {
-
    color: var(--color-foreground-5);
+
    background-color: transparent;
+
    color: var(--color-fill-gray);
  }
  .outline:not([disabled]):hover {
-
    border: 1px solid var(--color-foreground);
-
    color: var(--color-foreground);
+
    background-color: transparent;
+
    border: 1px solid var(--color-border-focus);
+
    color: var(--color-foreground-contrast);
  }

-
  .tiny {
-
    font-size: var(--font-size-tiny);
-
    height: var(--button-small-tiny);
-
    padding: 0 0.6rem;
+
  .primary {
+
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-primary);
  }
-
  .small {
-
    font-size: var(--font-size-small);
-
    height: var(--button-small-height);
-
    padding: 0 0.75rem;
+

+
  .primary[disabled] {
+
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-primary);
  }
-
  .regular {
-
    height: var(--button-regular-height);
-
    padding: 0 1.5rem;
-
    min-width: 6rem;
+

+
  .primary:not([disabled]):hover {
+
    background-color: var(--color-fill-primary-hover);
  }

-
  .waiting {
-
    cursor: waiting;
+
  .secondary {
+
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-secondary);
+
  }
+

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

+
  .secondary:not([disabled]):hover {
+
    background-color: var(--color-fill-secondary-hover);
+
  }
+

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

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

+
  .tab:not([disabled]):hover {
+
    background-color: var(--color-fill-secondary-hover);
  }
</style>

<!-- svelte-ignore a11y-autofocus -->
<button
-
  {title}
-
  {disabled}
-
  {style}
+
  aria-label={ariaLabel}
  {autofocus}
+
  {disabled}
+
  {title}
+
  tabindex="0"
+
  style:font-family={styleFontFamily}
+
  style:padding={stylePadding}
+
  style:width={styleWidth}
+
  style:border-radius={styleBorderRadius}
+
  on:blur
  on:click
  on:focus
-
  on:blur
  on:mouseout
  on:mouseover
-
  class:foreground={variant === "foreground"}
-
  class:negative={variant === "negative"}
+
  class:disabled
+
  class:small={size === "small"}
+
  class:regular={size === "regular"}
+
  class:large={size === "large"}
+
  class:background={variant === "background"}
+
  class:dim={variant === "dim"}
+
  class:gray={variant === "gray"}
+
  class:gray-white={variant === "gray-white"}
+
  class:none={variant === "none"}
  class:outline={variant === "outline"}
  class:primary={variant === "primary"}
  class:secondary={variant === "secondary"}
-
  class:text={variant === "text"}
-
  class:tiny={size === "tiny"}
-
  class:small={size === "small"}
-
  class:regular={size === "regular"}
-
  class:waiting>
+
  class:tab={variant === "tab"}>
  <slot />
</button>
deleted src/components/Chip.svelte
@@ -1,52 +0,0 @@
-
<script lang="ts">
-
  export let actionable: boolean = false;
-
</script>
-

-
<style>
-
  .chip {
-
    user-select: none;
-
    display: inline-flex;
-
    justify-content: center;
-
    align-items: stretch;
-
    color: inherit;
-
  }
-
  .section {
-
    display: flex;
-
    width: 100%;
-
    align-items: center;
-
    max-width: 13.5rem;
-
    padding: 0.2rem 0.5rem;
-
  }
-
  .text {
-
    background-color: var(--color-secondary-3);
-
    border-radius: var(--border-radius);
-
  }
-
  .icon {
-
    color: var(--color-secondary);
-
    border: none;
-
    border-bottom-right-radius: var(--border-radius);
-
    border-top-right-radius: var(--border-radius);
-
    background-color: var(--color-secondary-2);
-
    line-height: 1.5;
-
    cursor: pointer;
-
  }
-
  .icon:hover {
-
    background-color: var(--color-secondary-5);
-
    color: var(--color-foreground);
-
  }
-
  .actionable {
-
    border-bottom-right-radius: 0;
-
    border-top-right-radius: 0;
-
  }
-
</style>
-

-
<div class="chip">
-
  <span class="section text" class:actionable>
-
    <slot name="content" />
-
  </span>
-
  {#if actionable}
-
    <span class="section icon">
-
      <slot name="icon" />
-
    </span>
-
  {/if}
-
</div>
modified src/components/Clipboard.svelte
@@ -5,25 +5,24 @@
  import { toClipboard } from "@app/lib/utils";

  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";

  export let text: string;
  export let small = false;
-
  export let tiny = false;
  export let tooltip: string | undefined = undefined;

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

-
  let icon: "clipboard-small" | "checkmark-small" | "clipboard" | "checkmark" =
-
    small ? "clipboard-small" : "clipboard";
+
  let icon: "clipboard" | "checkmark" = "clipboard";

  const restoreIcon = debounce(() => {
-
    icon = small ? "clipboard-small" : "clipboard";
+
    icon = "clipboard";
  }, 800);

-
  async function copy() {
+
  export async function copy() {
    await toClipboard(text);
    dispatch("copied");
-
    icon = small ? "checkmark-small" : "checkmark";
+
    icon = "checkmark";
    restoreIcon();
  }
</script>
@@ -38,29 +37,23 @@
    align-items: center;
    user-select: none;
  }
-
  .clipboard.small {
+
  .small {
    width: 1.5rem;
    height: 1.5rem;
  }
-
  .clipboard.tiny {
-
    width: 1rem;
-
    height: 1rem;
-
  }
-
  .clipboard:hover :global(svg) {
-
    fill: var(--color-foreground);
-
  }
-
  .clipboard:hover {
-
    border-radius: var(--border-radius);
-
  }
</style>

<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
+
  role="button"
+
  tabindex="0"
  title={tooltip}
  class="clipboard"
  class:small
-
  class:tiny
  on:click|stopPropagation={copy}>
-
  <Icon name={icon} />
+
  {#if small}
+
    <IconSmall name={icon} />
+
  {:else}
+
    <Icon name={icon} />
+
  {/if}
</span>
modified src/components/Command.svelte
@@ -1,8 +1,13 @@
<script lang="ts">
+
  import { SvelteComponent } from "svelte";
+

  import Clipboard from "@app/components/Clipboard.svelte";

  export let command: string;
-
  export let color: "caution" | "foreground" = "foreground";
+
  export let fullWidth: boolean = false;
+
  export let showPrompt: boolean = true;
+

+
  let clipboard: SvelteComponent;
</script>

<style>
@@ -10,56 +15,58 @@
    display: flex;
  }
  .cmd {
+
    cursor: pointer;
    height: 2rem;
    line-height: 2rem;
-
    background-color: var(--color-foreground-3);
    border-radius: var(--border-radius-small);
    display: inline-block;
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-semibold);
    overflow: hidden;
-
    padding: 0 0.5rem;
+
    padding: 0 2rem 0 0.75rem;
    position: relative;
    text-overflow: ellipsis;
    white-space: nowrap;
+
    border: 1px solid var(--color-border-hint);
+
    color: var(--color-foreground-dim);
+
    user-select: none;
+
  }
+
  .cmd:hover {
+
    border: 1px solid var(--color-border-default);
+
    color: var(--color-foreground-contrast);
  }
  .clipboard {
    display: flex;
    align-items: center;
-
    justify-content: flex-end;
-
    background-image: linear-gradient(
-
      -90deg,
-
      var(--color-foreground-2),
-
      var(--color-foreground-2),
-
      transparent
-
    );
+
    justify-content: center;
    position: absolute;
    right: 0;
    top: 0;
-
    visibility: hidden;
-
    width: 3rem;
+
    width: 2rem;
    height: 100%;
  }
-
  .cmd:hover .clipboard {
-
    visibility: visible;
-
  }
-
  .caution {
-
    background-color: var(--color-caution-3);
-
    color: var(--color-caution-6);
-
  }
-
  .caution .clipboard {
-
    background: linear-gradient(var(--color-caution-3), var(--color-caution-3)),
-
      linear-gradient(var(--color-background), var(--color-background));
-
    -webkit-mask: linear-gradient(90deg, transparent 0%, #fff 50%);
-
    mask: linear-gradient(90deg, transparent 0%, #fff 50%);
+

+
  .full-width.wrapper,
+
  .full-width.cmd {
+
    width: 100%;
  }
</style>

-
<div class="wrapper">
-
  <div class="cmd" class:caution={color === "caution"}>
+
<div class="wrapper" class:full-width={fullWidth}>
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    role="button"
+
    tabindex="0"
+
    class="cmd"
+
    class:full-width={fullWidth}
+
    on:click={() => {
+
      clipboard.copy();
+
    }}>
+
    {#if showPrompt}${/if}
    {command}
    <div class="clipboard">
-
      <Clipboard text={command} small />
+
      <Clipboard bind:this={clipboard} small text={command} />
    </div>
  </div>
</div>
modified src/components/Comment.svelte
@@ -1,13 +1,11 @@
<script lang="ts" strictEvents>
-
  import type { AuthorAliasColor } from "@app/components/Authorship.svelte";
-

  import { createEventDispatcher } from "svelte";
+

  import { httpdStore } from "@app/lib/httpd";
+
  import * as utils from "@app/lib/utils";

-
  import Authorship from "@app/components/Authorship.svelte";
-
  import Floating, { closeFocused } from "@app/components/Floating.svelte";
-
  import Icon from "@app/components/Icon.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "./NodeId.svelte";
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";
  import Textarea from "@app/components/Textarea.svelte";
@@ -15,17 +13,16 @@
  export let id: string | undefined = undefined;
  export let authorId: string;
  export let authorAlias: string | undefined = undefined;
-
  export let authorAliasColor: AuthorAliasColor = "--color-foreground-5";
-
  export let timestamp: number;
  export let body: string;
  export let reactions: [string, string][];
-
  export let showReplyIcon: boolean = false;
  export let action: "create" | "view" = "view";
  export let caption = "commented";
  export let rawPath: string;
+
  export let timestamp: number;
+
  export let isReply: boolean = false;
+
  export let isLastReply: boolean = false;

  const dispatch = createEventDispatcher<{
-
    toggleReply: null;
    react: { nids: string[]; id: string; reaction: string };
  }>();

@@ -39,75 +36,84 @@
  .card {
    display: flex;
    flex-direction: column;
-
    border-radius: inherit;
-
    background-color: inherit;
+
    padding: 1rem 0;
+
    gap: 0.5rem;
+
  }
+
  .card:not(:last-child) {
+
    box-shadow: -1px 0 0 0 var(--color-fill-separator);
  }
  .card-header {
    display: flex;
    align-items: center;
-
    justify-content: space-between;
-
    padding: 0.5rem 1rem;
-
    height: 3rem;
+
    padding: 0 0.5rem;
+
    height: 1.5rem;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .reply-dot {
+
    border-radius: var(--border-radius-round);
+
    width: 4px;
+
    height: 4px;
+
    position: absolute;
+
    top: 10px;
+
    left: -2.5px;
+
    background-color: var(--color-fill-separator);
+
  }
+
  .icon {
+
    color: var(--color-fill-gray);
+
  }
+
  .timestamp {
+
    color: var(--color-fill-gray);
+
    margin-left: auto;
+
    font-size: var(--font-size-small);
  }
  .card-body {
    word-wrap: break-word;
    font-size: var(--font-size-small);
-
    padding: 0 1rem 0.7rem 1rem;
+
    padding-left: 2rem;
  }
  .actions {
    display: flex;
    flex-direction: row;
-
    justify-content: flex-end;
    align-items: center;
    gap: 0.5rem;
+
    padding-left: 2rem;
+
    height: 22px;
  }
-
  .reaction-selector {
-
    position: absolute;
-
    top: 2rem;
-
    right: 0;
+
  .reply .card-body,
+
  .reply .actions {
+
    padding-left: 1rem;
  }
-
  .toggle:hover {
-
    color: var(--color-foreground-5);
-
    cursor: pointer;
+
  .connector-line {
+
    width: 1px;
+
    height: 28px;
+
    position: absolute;
+
    top: -16px;
+
    left: -1px;
+
    background-color: var(--color-fill-separator);
  }
</style>

-
<div class="card" {id}>
-
  <div class="card-header">
-
    <Authorship
+
<div class="card" {id} class:reply={isReply}>
+
  <div style:position="relative">
+
    {#if isReply}
+
      <div class="reply-dot" />
+
    {/if}
+
    {#if isLastReply}
+
      <div class="connector-line" />
+
    {/if}
+
    <div class="card-header">
+
      <div class="icon">
+
        <slot name="icon" />
+
      </div>
+
      <NodeId nodeId={authorId} alias={authorAlias} />
      {caption}
-
      {authorId}
-
      {authorAlias}
-
      {authorAliasColor}
-
      {timestamp} />
-
    <div class="actions">
-
      {#if showReplyIcon}
-
        <div class="toggle" title="toggle-reply">
-
          <Icon on:click={() => dispatch("toggleReply")} name="arrow-reply" />
-
        </div>
-
      {/if}
-
      {#if id && $httpdStore.state === "authenticated"}
-
        <div style:position="relative">
-
          <Floating>
-
            <div class="reaction-selector" slot="modal">
-
              <ReactionSelector
-
                nid={$httpdStore.session.publicKey}
-
                reactions={groupedReactions}
-
                on:select={event => {
-
                  if (id) {
-
                    dispatch("react", { id, ...event.detail });
-
                    closeFocused();
-
                  }
-
                }} />
-
            </div>
-
            <div class="toggle" title="toggle-reaction" slot="toggle">
-
              <Icon name="face" />
-
            </div>
-
          </Floating>
-
        </div>
-
      {/if}
+
      <div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
+
        {utils.formatTimestamp(timestamp)}
+
      </div>
    </div>
  </div>
+

  <div class="card-body">
    {#if action === "create"}
      <Textarea
@@ -116,20 +122,33 @@
        on:submit
        placeholder="Leave a comment" />
    {:else if body.trim() === ""}
-
      <span class="txt-missing">No description.</span>
+
      <span class="txt-missing">No description</span>
    {:else}
      <Markdown {rawPath} content={body} />
    {/if}
-
    {#if id && groupedReactions.size > 0}
-
      <div style:margin-top="1rem">
+
  </div>
+
  {#if (id && $httpdStore.state === "authenticated") || (id && groupedReactions.size > 0)}
+
    <div class="actions">
+
      {#if id && $httpdStore.state === "authenticated"}
+
        <ReactionSelector
+
          nid={$httpdStore.session.publicKey}
+
          reactions={groupedReactions}
+
          on:select={event => {
+
            if (id) {
+
              dispatch("react", { id, ...event.detail });
+
            }
+
          }} />
+
      {/if}
+
      {#if id && groupedReactions.size > 0}
        <Reactions
+
          clickable={$httpdStore.state === "authenticated"}
          reactions={groupedReactions}
          on:remove={event => {
            if (id) {
              dispatch("react", { id, ...event.detail });
            }
          }} />
-
      </div>
-
    {/if}
-
  </div>
+
      {/if}
+
    </div>
+
  {/if}
</div>
added src/components/CommentTextarea.svelte
@@ -0,0 +1,209 @@
+
<script lang="ts" strictEvents>
+
  import type { Embed } from "@app/lib/file";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import * as modal from "@app/lib/modal";
+
  import * as utils from "@app/lib/utils";
+
  import { embed } from "@app/lib/file";
+

+
  import ErrorModal from "@app/modals/ErrorModal.svelte";
+

+
  import Button from "./Button.svelte";
+
  import IconSmall from "./IconSmall.svelte";
+
  import Markdown from "./Markdown.svelte";
+
  import Radio from "./Radio.svelte";
+
  import Textarea from "./Textarea.svelte";
+

+
  export let enableAttachments: boolean = false;
+
  export let placeholder: string = "Leave your comment";
+
  export let focus: boolean = false;
+
  export let inline: boolean = false;
+

+
  let commentBody: string = "";
+
  let active: boolean = false;
+
  let preview: boolean = false;
+
  let newEmbeds: Embed[] = [];
+
  let selectionStart = 0;
+
  let selectionEnd = 0;
+

+
  const dispatch = createEventDispatcher<{
+
    submit: { comment: string; embeds: Embed[] };
+
    click: null;
+
  }>();
+

+
  function submit() {
+
    dispatch("submit", { comment: commentBody, embeds: newEmbeds });
+
    newEmbeds = [];
+
    active = false;
+
  }
+

+
  const MAX_BLOB_SIZE = 4_194_304;
+

+
  function handleFileDrop(event: DragEvent) {
+
    if (!enableAttachments) {
+
      return;
+
    }
+

+
    event.preventDefault();
+
    if (event.dataTransfer) {
+
      const embeds = Array.from(event.dataTransfer.files).map(embed);
+
      void Promise.all(embeds).then(embeds =>
+
        embeds.forEach(embed => {
+
          if (embed.content.length > MAX_BLOB_SIZE) {
+
            modal.show({
+
              component: ErrorModal,
+
              props: {
+
                title: "File too large",
+
                subtitle: [
+
                  "The file you tried to upload is too large.",
+
                  "The maximum file size is 4MB.",
+
                ],
+
                error: { message: `File ${embed.name} is too large` },
+
              },
+
            });
+
            return;
+
          }
+
          newEmbeds = [
+
            ...newEmbeds,
+
            {
+
              oid: embed.oid,
+
              name: embed.name,
+
              content: embed.content,
+
            },
+
          ];
+
          const embedText = `![${embed.name}](${embed.oid})\n`;
+
          commentBody = commentBody
+
            .slice(0, selectionStart)
+
            .concat(embedText, commentBody.slice(selectionEnd));
+
          selectionStart += embedText.length;
+
          selectionEnd = selectionStart;
+
        }),
+
      );
+
    }
+
  }
+
</script>
+

+
<style>
+
  .comment-section {
+
    border: 1px solid var(--color-border-hint);
+
    padding: 1rem;
+
    border-radius: var(--border-radius-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    gap: 1rem;
+
  }
+
  .inline {
+
    border: 0;
+
    padding: 0;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    width: 100%;
+
  }
+
  .buttons {
+
    display: flex;
+
    margin-left: auto;
+
    gap: 1rem;
+
  }
+
  .caption {
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
  }
+
  .preview {
+
    font-size: var(--font-size-small);
+
    padding-top: 1rem;
+
    padding-left: 1rem;
+
    min-height: 5rem;
+
  }
+
  .inactive {
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
+
    border-radius: var(--border-radius-small);
+
    padding: 0.5rem 0.75rem;
+
    background-color: var(--color-background-dip);
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
    cursor: text;
+
  }
+
  .inactive:hover {
+
    box-shadow: 0 0 0 1px var(--color-border-default);
+
  }
+
</style>
+

+
{#if active}
+
  <div class="comment-section" class:inline>
+
    <Radio>
+
      <Button
+
        styleBorderRadius="0"
+
        variant={!preview ? "secondary" : "gray"}
+
        on:click={() => {
+
          preview = false;
+
        }}>
+
        <IconSmall name="edit" />
+
        Edit
+
      </Button>
+
      <Button
+
        styleBorderRadius="0"
+
        disabled={commentBody === ""}
+
        variant={preview ? "secondary" : "gray"}
+
        on:click={() => {
+
          preview = true;
+
        }}>
+
        <IconSmall name="eye-open" />
+
        Preview
+
      </Button>
+
    </Radio>
+
    {#if preview}
+
      <div class="preview">
+
        <Markdown content={commentBody} embeds={newEmbeds} />
+
      </div>
+
    {:else}
+
      <Textarea
+
        on:drop={handleFileDrop}
+
        bind:selectionEnd
+
        bind:selectionStart
+
        {focus}
+
        resizable
+
        on:submit={submit}
+
        bind:value={commentBody}
+
        {placeholder} />
+
    {/if}
+
    <div class="actions">
+
      {#if !preview}
+
        <div class="caption">
+
          Markdown supported. {#if enableAttachments}Drop attachments into the
+
            text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
+
        </div>
+
      {/if}
+
      <div class="buttons">
+
        <Button
+
          variant="outline"
+
          on:click={() => {
+
            preview = false;
+
            active = false;
+
          }}>
+
          Cancel
+
        </Button>
+
        <Button variant="secondary" disabled={!commentBody} on:click={submit}>
+
          Comment
+
        </Button>
+
      </div>
+
    </div>
+
  </div>
+
{:else}
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    class="inactive"
+
    role="button"
+
    tabindex="0"
+
    on:click={() => {
+
      commentBody = "";
+
      active = true;
+
      dispatch("click");
+
    }}>
+
    {placeholder}
+
  </div>
+
{/if}
modified src/components/DiffStatBadge.svelte
@@ -5,32 +5,36 @@

<style>
  .badge {
+
    display: flex;
    font-size: var(--font-size-tiny);
-
    line-height: 1.6;
+
    font-weight: var(--font-weight-bold);
+
    font-family: var(--font-family-monospace);
    height: var(--button-tiny-height);
-
    display: flex;
-
    flex-direction: row;
    white-space: nowrap;
+
    border-radius: var(--border-radius-round);
+
    overflow: hidden;
  }
  .positive {
-
    padding: 0.125rem 0.5rem;
-
    border-radius: var(--border-radius) 0 0 var(--border-radius);
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-3);
+
    display: flex;
+
    padding: 0 6px;
+
    align-items: center;
+
    color: var(--color-foreground-success);
+
    background-color: var(--color-fill-diff-green-light);
  }
  .negative {
-
    padding: 0.125rem 0.5rem;
-
    border-radius: 0 var(--border-radius) var(--border-radius) 0;
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-3);
+
    display: flex;
+
    padding: 0 6px;
+
    align-items: center;
+
    color: var(--color-foreground-red);
+
    background-color: var(--color-fill-diff-red-light);
  }
</style>

<div class="badge">
-
  <span class="positive">
-
    + {insertions}
-
  </span>
-
  <span class="negative">
-
    - {deletions}
-
  </span>
+
  <div class="positive">
+
    +{insertions}
+
  </div>
+
  <div class="negative">
+
    -{deletions}
+
  </div>
</div>
deleted src/components/Dropdown.svelte
@@ -1,26 +0,0 @@
-
<script lang="ts">
-
  type T = $$Generic;
-

-
  export let items: T[];
-
</script>
-

-
<style>
-
  .dropdown {
-
    align-items: center;
-
    background-color: var(--color-background-1);
-
    margin-top: 0.5rem;
-
    padding: 0.5rem 0;
-
    position: absolute;
-
    box-shadow: var(--elevation-low);
-
    z-index: 10;
-
    border-radius: var(--border-radius-small);
-
    overflow-y: auto;
-
    max-height: 60vh;
-
  }
-
</style>
-

-
<div class="dropdown">
-
  {#each items as item}
-
    <slot name="item" {item} />
-
  {/each}
-
</div>
deleted src/components/Dropdown/DropdownItem.svelte
@@ -1,36 +0,0 @@
-
<script lang="ts">
-
  export let selected: boolean;
-
  export let title: string | undefined = undefined;
-
  export let size: "small" | "tiny";
-
</script>
-

-
<style>
-
  .item {
-
    cursor: pointer;
-
    display: flex;
-
    align-items: center;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    padding: 0.5rem 1rem;
-
    white-space: nowrap;
-
    /* makes sure peer selector items with badges are same height
-
       as ones without */
-
    height: 34px;
-
  }
-
  .item:hover,
-
  .selected {
-
    background-color: var(--color-foreground-2);
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<!-- svelte-ignore a11y-no-static-element-interactions -->
-
<div
-
  class="item"
-
  class:selected
-
  {title}
-
  on:click
-
  class:txt-small={size === "small"}
-
  class:txt-tiny={size === "tiny"}>
-
  <slot />
-
</div>
added src/components/DropdownList.svelte
@@ -0,0 +1,29 @@
+
<script lang="ts">
+
  type T = $$Generic;
+

+
  export let items: T[];
+
</script>
+

+
<style>
+
  .dropdown {
+
    align-items: center;
+
    border-radius: var(--border-radius-small);
+
    max-height: 60vh;
+
    overflow-y: auto;
+
  }
+
  .dropdown-item {
+
    padding: 0.25rem 0.25rem 0 0.25rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .dropdown-item:last-child {
+
    padding-bottom: 0.25rem;
+
  }
+
</style>
+

+
<div class="dropdown">
+
  {#each items as item}
+
    <div class="dropdown-item">
+
      <slot name="item" {item} />
+
    </div>
+
  {/each}
+
</div>
added src/components/DropdownList/DropdownListItem.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts">
+
  export let selected: boolean;
+
  export let disabled: boolean = false;
+
  export let title: string | undefined = undefined;
+
</script>
+

+
<style>
+
  .item {
+
    cursor: pointer;
+
    display: flex;
+
    align-items: center;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    padding: 0.5rem;
+
    white-space: nowrap;
+
    border-radius: var(--border-radius-tiny);
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-semibold);
+
    color: var(--color-foreground-dim);
+
    height: 2rem;
+
  }
+
  .item.disabled {
+
    color: var(--color-foreground-disabled);
+
  }
+
  .item:hover,
+
  .selected {
+
    background-color: var(--color-fill-ghost);
+
  }
+
  .selected {
+
    color: var(--color-foreground-match-background);
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .item:hover.selected {
+
    background-color: var(--color-fill-secondary-hover);
+
  }
+
  .item:hover.selected.disabled {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .item:hover.disabled {
+
    cursor: not-allowed;
+
    background-color: var(--color-background-float);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<div
+
  role="button"
+
  tabindex="0"
+
  class="item"
+
  class:selected
+
  class:disabled
+
  {title}
+
  on:click>
+
  <slot />
+
</div>
modified src/components/ErrorMessage.svelte
@@ -1,36 +1,47 @@
<script lang="ts">
-
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Command from "./Command.svelte";
+
  import Icon from "./Icon.svelte";

  export let message: string;
-
  export let stackTrace: string | undefined = undefined;
+
  export let error: any | undefined = undefined;
</script>

<style>
  .error {
    align-items: center;
-
    background-color: var(--color-negative-3);
    border-radius: inherit;
-
    color: var(--color-negative);
    display: flex;
+
    flex-direction: column;
    font-family: var(--font-family-sans-serif);
    font-size: inherit;
    padding: 1rem;
-
    width: 100%;
+
    border-radius: var(--border-radius-small);
+
    gap: 1rem;
  }
-
  .stack-trace {
-
    display: flex;
-
    align-self: flex-end;
+

+
  .help {
+
    font-size: var(--font-size-small);
+
    text-align: center;
  }
</style>

<div class="error">
+
  <Icon name="alert" size="48" />
  {message}
-
  {#if stackTrace}
-
    <div class="stack-trace">
-
      <Clipboard
-
        small
-
        tooltip="Copy error to clipboard"
-
        text={JSON.stringify({ errorMessage: message, stackTrace }, null, 2)} />
+
  {#if error}
+
    <div class="help">
+
      If you need help resolving this issue, copy the error message
+
      <br />
+
      below and send it to us on
+
      <a class="txt-link" href="https://radicle.zulipchat.com/" target="_blank">
+
        radicle.zulipchat.com
+
      </a>
+
    </div>
+
    <div style:max-width="25rem">
+
      <Command
+
        command={JSON.stringify({ message: error.message, stack: error.stack })}
+
        fullWidth
+
        showPrompt={false} />
    </div>
  {/if}
</div>
added src/components/ExpandButton.svelte
@@ -0,0 +1,24 @@
+
<script lang="ts">
+
  import IconButton from "./IconButton.svelte";
+
  import IconSmall from "./IconSmall.svelte";
+

+
  export let expanded: boolean = true;
+
  export let variant: "left-aligned" | "inline" = "left-aligned";
+
</script>
+

+
<style>
+
  .expand {
+
    display: flex;
+
    background: none;
+
  }
+
</style>
+

+
<IconButton ariaLabel="expand" on:click={() => (expanded = !expanded)}>
+
  <div class="expand">
+
    {#if expanded}
+
      <IconSmall name={variant === "inline" ? "ellipsis" : "chevron-down"} />
+
    {:else}
+
      <IconSmall name={variant === "inline" ? "ellipsis" : "chevron-right"} />
+
    {/if}
+
  </div>
+
</IconButton>
added src/components/File.svelte
@@ -0,0 +1,65 @@
+
<style>
+
  .header {
+
    display: flex;
+
    height: 3rem;
+
    align-items: center;
+
    padding: 0 0.5rem 0 1rem;
+
    border-width: 1px 1px 0 1px;
+
    border-color: var(--color-border-hint);
+
    border-style: solid;
+
    border-top-left-radius: var(--border-radius-small);
+
    border-top-right-radius: var(--border-radius-small);
+
  }
+

+
  .right {
+
    display: flex;
+
    gap: 0.5rem;
+
    margin-left: auto;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+

+
  .left {
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    flex-shrink: 0;
+
    padding-right: 0.5rem;
+
  }
+

+
  .container {
+
    position: relative;
+
    display: flex;
+
    overflow-x: auto;
+
    border: 1px solid var(--color-border-hint);
+
    border-top-style: solid;
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
    background: var(--color-background-float);
+
    width: 100%;
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
  }
+

+
  @media (max-width: 720px) {
+
    .container {
+
      border-left: none;
+
      border-right: none;
+
      border-bottom-left-radius: 0;
+
      border-bottom-right-radius: 0;
+
    }
+
  }
+
</style>
+

+
<div class="header">
+
  <span class="left">
+
    <slot name="left-header" />
+
  </span>
+
  <div class="right">
+
    <slot name="right-header" />
+
  </div>
+
</div>
+

+
<div class="container">
+
  <slot />
+
</div>
added src/components/FilePath.svelte
@@ -0,0 +1,28 @@
+
<script lang="ts">
+
  export let filenameWithPath: string;
+

+
  $: path = filenameWithPath
+
    .match(/^.*\/|/)
+
    ?.values()
+
    .next().value;
+

+
  $: filename = filenameWithPath.split("/").slice(-1);
+
</script>
+

+
<style>
+
  .container {
+
    font-size: var(--font-size-small);
+
  }
+

+
  .path {
+
    color: var(--color-fill-gray);
+
    font-weight: var(--font-weight-regular);
+
  }
+

+
  .filename {
+
    font-weight: var(--font-weight-semibold);
+
  }
+
</style>
+

+
<!-- prettier-ignore -->
+
<span class="container"><span class="path">{path}</span><span class="filename">{filename}</span></span>
deleted src/components/Floating.svelte
@@ -1,57 +0,0 @@
-
<script lang="ts" context="module">
-
  import { writable } from "svelte/store";
-
  const focused = writable<HTMLDivElement | undefined>(undefined);
-

-
  export function closeFocused() {
-
    focused.set(undefined);
-
  }
-
</script>
-

-
<script lang="ts">
-
  export let disabled = false;
-

-
  let expanded = false;
-
  let thisComponent: HTMLDivElement;
-

-
  function clickOutside(ev: MouseEvent | TouchEvent) {
-
    if (!$focused?.contains(ev.target as HTMLDivElement)) {
-
      closeFocused();
-
    }
-
  }
-

-
  function toggle() {
-
    if (!disabled) {
-
      expanded = !expanded;
-
      if ($focused === thisComponent) {
-
        closeFocused();
-
      } else {
-
        focused.set(thisComponent);
-
      }
-
    }
-
  }
-

-
  $: expanded = $focused === thisComponent;
-
</script>
-

-
<style>
-
  .toggle {
-
    user-select: none;
-
  }
-
</style>
-

-
<svelte:window on:click={clickOutside} on:touchstart={clickOutside} />
-

-
<div bind:this={thisComponent}>
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <!-- svelte-ignore a11y-no-static-element-interactions -->
-
  <div
-
    on:click={toggle}
-
    class="toggle"
-
    style:cursor={disabled ? "not-allowed" : "pointer"}>
-
    <slot name="toggle" />
-
  </div>
-

-
  {#if expanded}
-
    <slot name="modal" />
-
  {/if}
-
</div>
added src/components/HoverPopover.svelte
@@ -0,0 +1,56 @@
+
<script lang="ts">
+
  import { debounce } from "lodash";
+

+
  export let disabled: boolean = false;
+

+
  export let onShow: () => void = () => {};
+
  export let popoverPositionLeft: string | undefined = undefined;
+
  export let popoverPositionTop: string | undefined = undefined;
+

+
  let visible: boolean = false;
+

+
  const setVisible = debounce((value: boolean) => {
+
    if (!disabled) {
+
      visible = value;
+
      if (visible) {
+
        onShow();
+
      }
+
    }
+
  }, 150);
+
</script>
+

+
<style>
+
  .popover {
+
    background: var(--color-background-float);
+
    border-radius: var(--border-radius-regular);
+
    border: 1px solid var(--color-border-hint);
+
    box-shadow: var(--elevation-low);
+
    position: relative;
+
    right: 1rem;
+
    z-index: 1;
+
  }
+
</style>
+

+
<div
+
  role="button"
+
  tabindex="0"
+
  on:mouseenter={() => setVisible(true)}
+
  on:mouseleave={() => setVisible(false)}>
+
  <slot name="toggle" />
+

+
  {#if visible}
+
    <!-- If this component is used inside a button (see `NodeId`, for example)
+
       we don’t want clicks in the popover to trigger button actions. So we
+
       stop propagation of click events. -->
+
    <!-- svelte-ignore a11y-click-events-have-key-events -->
+
    <!-- svelte-ignore a11y-no-static-element-interactions -->
+
    <div style:position="absolute" on:click|stopPropagation>
+
      <div
+
        class="popover"
+
        style:left={popoverPositionLeft}
+
        style:top={popoverPositionTop}>
+
        <slot name="popover" />
+
      </div>
+
    </div>
+
  {/if}
+
</div>
modified src/components/Icon.svelte
@@ -1,23 +1,28 @@
<script lang="ts">
  import { unreachable } from "@app/lib/utils";

-
  export let size: "small" | "regular" = "regular";
  export let name:
+
    | "alert"
+
    | "arrow-box-up-right"
    | "arrow-reply"
+
    | "binary-file"
    | "browse"
+
    | "brush"
    | "chat"
    | "checkmark"
-
    | "checkmark-small"
    | "chevron-down"
    | "chevron-left"
    | "chevron-left-right"
    | "chevron-right"
    | "chevron-up"
    | "clipboard"
-
    | "clipboard-small"
    | "cross"
+
    | "desert"
+
    | "device"
    | "diff"
+
    | "download"
    | "ellipsis"
+
    | "empty-file"
    | "exclamation"
    | "exclamation-circle"
    | "face"
@@ -27,12 +32,18 @@
    | "fork"
    | "gear"
    | "issue"
+
    | "keyboard"
    | "magnifying-glass"
    | "moon"
    | "network"
+
    | "no-activity"
+
    | "no-file"
+
    | "no-issues"
+
    | "no-patches"
    | "patch"
-
    | "pen"
+
    | "review"
    | "sun";
+
  export let size: "24" | "48" = "24";
</script>

<style>
@@ -47,11 +58,30 @@
<svg
  role="img"
  on:click
-
  height={size === "regular" ? "24" : "16"}
-
  width={size === "regular" ? "24" : "16"}
+
  height={size}
+
  width={size}
  fill="currentColor"
-
  viewBox="0 0 24 24">
-
  {#if name === "arrow-reply"}
+
  viewBox={"0 0 24 24"}>
+
  {#if name === "alert"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M11 5.5C11 5.77614 11.2238 6 11.5 6C11.7761 6 12 5.77614 12 5.5V2.5C12 2.22386 11.7761 2 11.5 2C11.2238 2 11 2.22386 11 2.5V5.5ZM8.38954 9C8.14425 9 7.93519 9.17793 7.89598 9.42007L6.66846 17H16.2874L14.7491 9.4008C14.7019 9.16763 14.497 9 14.2591 9H8.38954ZM17.3077 17L15.7292 9.20239C15.5876 8.50288 14.9728 8 14.2591 8H8.38954C7.65368 8 7.02647 8.5338 6.90884 9.26021L5.65543 17H4.99999C3.89542 17 2.99999 17.8954 2.99999 19V21H20V19C20 17.8954 19.1046 17 18 17H17.3077ZM16.4898 18L16.5099 18.0992L17 18H18C18.5523 18 19 18.4477 19 19V20H3.99999V19C3.99999 18.4477 4.4477 18 4.99999 18H6L6.49357 18.0799L6.50651 18H16.4898ZM10.346 11C10.1104 11 9.90682 11.1644 9.85722 11.3947L9.48879 13.1053C9.43065 13.3752 9.16468 13.5469 8.89472 13.4888C8.62477 13.4306 8.45307 13.1647 8.51121 12.8947L8.87964 11.1842C9.02844 10.4933 9.63929 10 10.346 10H12C12.2761 10 12.5 10.2239 12.5 10.5C12.5 10.7761 12.2761 11 12 11H10.346ZM15.5661 6.22224C15.3265 6.08497 15.2436 5.77945 15.3808 5.53984L16.8721 2.93675C17.0094 2.69714 17.3149 2.61418 17.5545 2.75144C17.7941 2.88871 17.8771 3.19423 17.7398 3.43384L16.2485 6.03693C16.1113 6.27654 15.8057 6.35951 15.5661 6.22224ZM18.2241 7.93996C17.9574 8.01143 17.7991 8.2856 17.8706 8.55233C17.9421 8.81906 18.2162 8.97735 18.483 8.90588L21.3807 8.12943C21.6475 8.05795 21.8058 7.78379 21.7343 7.51705C21.6628 7.25032 21.3886 7.09203 21.1219 7.1635L18.2241 7.93996ZM4.99311 8.55233C5.06458 8.2856 4.90629 8.01143 4.63955 7.93996L1.74178 7.1635C1.47504 7.09203 1.20088 7.25032 1.1294 7.51705C1.05793 7.78379 1.21623 8.05795 1.48296 8.12943L4.38074 8.90588C4.64747 8.97735 4.92164 8.81906 4.99311 8.55233ZM7.60752 5.53984C7.74479 5.77945 7.66183 6.08497 7.42222 6.22224C7.18261 6.35951 6.87709 6.27654 6.73982 6.03693L5.24855 3.43384C5.11129 3.19423 5.19425 2.88871 5.43386 2.75144C5.67347 2.61418 5.97899 2.69714 6.11625 2.93675L7.60752 5.53984Z" />
+
  {:else if name === "arrow-box-up-right"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12 5.5C11.7239 5.5 11.5
+
    5.27614 11.5 5C11.5 4.72386 11.7239 4.5 12 4.5H19C19.2761 4.5 19.5 4.72386
+
    19.5 5V12C19.5 12.2761 19.2761 12.5 19 12.5C18.7239 12.5 18.5 12.2761 18.5
+
    12V6.20711L10.3536 14.3536C10.1583 14.5488 9.84171 14.5488 9.64645
+
    14.3536C9.45118 14.1583 9.45118 13.8417 9.64645 13.6464L13.7929
+
    9.5H6C5.72386 9.5 5.5 9.72386 5.5 10V18C5.5 18.2761 5.72386 18.5 6
+
    18.5H14C14.2761 18.5 14.5 18.2761 14.5 18V12C14.5 11.7239 14.7239 11.5 15
+
    11.5C15.2761 11.5 15.5 11.7239 15.5 12V18C15.5 18.8284 14.8284 19.5 14
+
    19.5H6C5.17157 19.5 4.5 18.8284 4.5 18V10C4.5 9.17157 5.17157 8.5 6
+
    8.5H14.7929L17.7929 5.5H12Z" />
+
  {:else if name === "arrow-reply"}
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
@@ -67,6 +97,47 @@
      4.72386 10.5 5 10.5H18C18.8284 10.5 19.5 11.1716 19.5 12V18C19.5 18.2761
      19.2761 18.5 19 18.5C18.7239 18.5 18.5 18.2761 18.5 18V12C18.5 11.7239
      18.2761 11.5 18 11.5H5C4.72386 11.5 4.5 11.2761 4.5 11Z" />
+
  {:else if name === "binary-file"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7 4.5C6.72386 4.5 6.5 4.72386 6.5 5V19C6.5 19.2761 6.72386 19.5 7 19.5H17C17.2761 19.5 17.5 19.2761 17.5 19V8.87566C17.5 8.45702 17.325 8.05742 17.0174 7.77346L13.9021 4.8978C13.625 4.64203 13.2618 4.5 12.8847 4.5H7ZM5.5 5C5.5 4.17157 6.17157 3.5 7 3.5H12.8847C13.5132 3.5 14.1186 3.73671 14.5804 4.16299L17.6957 7.03865C18.2084 7.51192 18.5 8.17792 18.5 8.87566V19C18.5 19.8284 17.8284 20.5 17 20.5H7C6.17157 20.5 5.5 19.8284 5.5 19V5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.5 7C8.77614 7 9 7.22386 9 7.5L9 9.5C9 9.77614 8.77614 10 8.5 10C8.22386 10 8 9.77614 8 9.5L8 7.5C8 7.22386 8.22386 7 8.5 7Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14.5 7C14.7761 7 15 7.22386 15 7.5L15 9.5C15 9.77614 14.7761 10 14.5 10C14.2239 10 14 9.77614 14 9.5L14 7.5C14 7.22386 14.2239 7 14.5 7Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.5 11C8.77614 11 9 11.2239 9 11.5L9 13.5C9 13.7761 8.77614 14 8.5 14C8.22386 14 8 13.7761 8 13.5L8 11.5C8 11.2239 8.22386 11 8.5 11Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M10.5 11C10.7761 11 11 11.2239 11 11.5L11 13.5C11 13.7761 10.7761 14 10.5 14C10.2239 14 10 13.7761 10 13.5L10 11.5C10 11.2239 10.2239 11 10.5 11Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14.5 15C14.7761 15 15 15.2239 15 15.5L15 17.5C15 17.7761 14.7761 18 14.5 18C14.2239 18 14 17.7761 14 17.5L14 15.5C14 15.2239 14.2239 15 14.5 15Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12.5 15C12.7761 15 13 15.2239 13 15.5L13 17.5C13 17.7761 12.7761 18 12.5 18C12.2239 18 12 17.7761 12 17.5L12 15.5C12 15.2239 12.2239 15 12.5 15Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M11.5 9C11.7761 9 12 8.77614 12 8.5C12 8.22386 11.7761 8 11.5 8C11.2239 8 11 8.22386 11 8.5C11 8.77614 11.2239 9 11.5 9ZM11.5 10C12.3284 10 13 9.32843 13 8.5C13 7.67157 12.3284 7 11.5 7C10.6716 7 10 7.67157 10 8.5C10 9.32843 10.6716 10 11.5 10Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M13.5 13C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12C13.2239 12 13 12.2239 13 12.5C13 12.7761 13.2239 13 13.5 13ZM13.5 14C14.3284 14 15 13.3284 15 12.5C15 11.6716 14.3284 11 13.5 11C12.6716 11 12 11.6716 12 12.5C12 13.3284 12.6716 14 13.5 14Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M9.5 17C9.77614 17 10 16.7761 10 16.5C10 16.2239 9.77614 16 9.5 16C9.22386 16 9 16.2239 9 16.5C9 16.7761 9.22386 17 9.5 17ZM9.5 18C10.3284 18 11 17.3284 11 16.5C11 15.6716 10.3284 15 9.5 15C8.67157 15 8 15.6716 8 16.5C8 17.3284 8.67157 18 9.5 18Z" />
  {:else if name === "browse"}
    <path
      fill-rule="evenodd"
@@ -83,6 +154,23 @@
      14.4512 16.8417 14.6464 16.6464L18.9393 12.3536C19.1346 12.1583 19.1346
      11.8417 18.9393 11.6464L14.6464 7.35355C14.4512 7.15829 14.4512 6.84171
      14.6464 6.64645Z" />
+
  {:else if name === "brush"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M5.5 4C5.5 3.30964 6.05964 2.75 6.75 2.75H17.25C17.9404 2.75 18.5 3.30964 18.5 4V10.75H17.5V4C17.5 3.86193 17.3881 3.75 17.25 3.75H6.75C6.61193 3.75 6.5 3.86193 6.5 4V10.75H5.5V4Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M5.5 11V11.5C5.5 12.6046 6.39543 13.5 7.5 13.5H9.21066C10.2238 13.5 11.0253 14.3577 10.9566 15.3686L10.7183 18.8783C10.6679 19.6203 11.2562 20.25 12 20.25C12.7438 20.25 13.3321 19.6203 13.2817 18.8783L13.0434 15.3686C12.9747 14.3577 13.7762 13.5 14.7893 13.5H16.5C17.6046 13.5 18.5 12.6046 18.5 11.5V11H5.5ZM4.5 10.75C4.5 10.3358 4.83579 10 5.25 10H18.75C19.1642 10 19.5 10.3358 19.5 10.75V11.5C19.5 13.1569 18.1569 14.5 16.5 14.5H14.7893C14.3551 14.5 14.0116 14.8676 14.0411 15.3008L14.2794 18.8105C14.3691 20.1302 13.3227 21.25 12 21.25C10.6773 21.25 9.63093 20.1302 9.72056 18.8105L9.95894 15.3008C9.98836 14.8676 9.64488 14.5 9.21066 14.5H7.5C5.84315 14.5 4.5 13.1569 4.5 11.5V10.75Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M15.5 7.25V3.25L16.5 3.25V7.25C16.5 7.52614 16.2761 7.75 16 7.75C15.7239 7.75 15.5 7.52614 15.5 7.25Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M13.5 5.25V3.25L14.5 3.25V5.25C14.5 5.52614 14.2761 5.75 14 5.75C13.7239 5.75 13.5 5.52614 13.5 5.25Z" />
  {:else if name === "chat"}
    <path
      fill-rule="evenodd"
@@ -108,16 +196,6 @@
         13.6465C5.84171 13.4512 6.15829 13.4512 6.35355 13.6465L8.69852
         15.9914C9.35028 16.6432 10.4302 16.5584 10.9723 15.813L17.5956
         6.70592C17.7581 6.48259 18.0708 6.43322 18.2941 6.59564Z" />
-
  {:else if name === "checkmark-small"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M16.2481 8.56588C16.4878 8.70288 16.5711 9.00831 16.4341
-
      9.24807L13.0837 15.1113C12.593 15.9701 11.42 16.1271 10.7207
-
      15.4278L8.64645 13.3536C8.45118 13.1583 8.45118 12.8417 8.64645
-
      12.6464C8.84171 12.4512 9.15829 12.4512 9.35355 12.6464L11.4278
-
      14.7207C11.6609 14.9538 12.0519 14.9014 12.2154 14.6152L15.5659
-
      8.75193C15.7029 8.51217 16.0083 8.42887 16.2481 8.56588Z" />
  {:else if name === "chevron-down"}
    <path
      fill-rule="evenodd"
@@ -187,18 +265,6 @@
      15.5 8.5 14.8284 8.5 14V9.5ZM10 5.5C9.72386 5.5 9.5 5.72386 9.5 6V14C9.5
      14.2761 9.72386 14.5 10 14.5H18C18.2761 14.5 18.5 14.2761 18.5 14V6C18.5
      5.72386 18.2761 5.5 18 5.5H10Z" />
-
  {:else if name === "clipboard-small"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M10 6.5C10 5.67157 10.6716 5 11.5 5H17.5C18.3284 5 19 5.67157 19
-
      6.5V12.5C19 13.3284 18.3284 14 17.5 14H15V16.5C15 17.3284 14.3284 18 13.5
-
      18H7.5C6.67157 18 6 17.3284 6 16.5V10.5C6 9.67157 6.67157 9 7.5
-
      9H10V6.5ZM10 10H7.5C7.22386 10 7 10.2239 7 10.5V16.5C7 16.7761 7.22386 17
-
      7.5 17H13.5C13.7761 17 14 16.7761 14 16.5V14H11.5C10.6716 14 10 13.3284
-
      10 12.5V10ZM11.5 13C11.2239 13 11 12.7761 11 12.5V6.5C11 6.22386 11.2239
-
      6 11.5 6H17.5C17.7761 6 18 6.22386 18 6.5V12.5C18 12.7761 17.7761 13 17.5
-
      13H11.5Z" />
  {:else if name === "cross"}
    <path
      fill-rule="evenodd"
@@ -211,6 +277,26 @@
      5.84171 18.5488 5.64645 18.3536C5.45118 18.1583 5.45118 17.8417 5.64645
      17.6464L11.2929 12L5.64645 6.35355C5.45118 6.15829 5.45118 5.84171 5.64645
      5.64645Z" />
+
  {:else if name === "desert"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M23 4C23 5.65685 21.6569 7 20 7C18.3431 7 17 5.65685 17 4C17 2.34315 18.3431 1 20 1C21.6569 1 23 2.34315 23 4ZM22 4C22 5.10457 21.1046 6 20 6C18.8954 6 18 5.10457 18 4C18 2.89543 18.8954 2 20 2C21.1046 2 22 2.89543 22 4Z" />
+
    <path
+
      d="M9 15.5C9 15.7761 8.77614 16 8.5 16C8.22386 16 8 15.7761 8 15.5C8 15.2239 8.22386 15 8.5 15C8.77614 15 9 15.2239 9 15.5Z" />
+
    <path
+
      d="M10 18.5C10 18.7761 9.77614 19 9.5 19C9.22386 19 9 18.7761 9 18.5C9 18.2239 9.22386 18 9.5 18C9.77614 18 10 18.2239 10 18.5Z" />
+
    <path
+
      d="M8 10.5C8 10.2239 8.22386 10 8.5 10C8.77614 10 9 10.2239 9 10.5C9 10.7761 8.77614 11 8.5 11C8.22386 11 8 10.7761 8 10.5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M6 15V20H2V21H6V22C6 22.5523 7.11929 23 8.5 23C9.88071 23 11 22.5523 11 22V21H20V20H11V17H13.5C14.3284 17 15 16.3284 15 15.5V10.5C15 9.67157 14.3284 9 13.5 9C12.6716 9 12 9.67157 12 10.5V14H11V7.5C11 6.11929 9.88071 5 8.5 5C7.11929 5 6 6.11929 6 7.5V12H5V8.5C5 7.67157 4.32843 7 3.5 7C2.67157 7 2 7.67157 2 8.5V13.5C2 14.3284 2.67157 15 3.5 15H6ZM7 7.5007V12.4993C7.00038 12.2235 7.22409 12 7.5 12C7.77614 12 8 12.2239 8 12.5C8 12.7761 7.77614 13 7.5 13C7.22409 13 7.00038 12.7765 7 12.5007V19.4993C7.00038 19.2235 7.22409 19 7.5 19C7.77614 19 8 19.2239 8 19.5C8 19.7761 7.77614 20 7.5 20C7.22409 20 7.00038 19.7765 7 19.5007V21.7328C7.02935 21.7472 7.06377 21.7627 7.10362 21.7786C7.41104 21.9016 7.9043 22 8.5 22C9.09571 22 9.58896 21.9016 9.89638 21.7786C9.93623 21.7627 9.97065 21.7472 10 21.7328V18.5V16.9146C9.4174 16.7087 9 16.1531 9 15.5C9 14.8469 9.4174 14.2913 10 14.0854V7.5C10 6.67157 9.32843 6 8.5 6C7.67211 6 7.00087 6.6707 7 7.49838C7.00087 7.22298 7.2244 7 7.5 7C7.77614 7 8 7.22386 8 7.5C8 7.77614 7.77614 8 7.5 8C7.22409 8 7.00038 7.77652 7 7.5007ZM3.5 14H6V13H4V8.5C4 8.22386 3.77614 8 3.5 8C3.22386 8 3 8.22386 3 8.5V13.5C3 13.7761 3.22386 14 3.5 14ZM13 15H10.5C10.2239 15 10 15.2239 10 15.5C10 15.7761 10.2239 16 10.5 16H13.5C13.7761 16 14 15.7761 14 15.5V10.5C14 10.2239 13.7761 10 13.5 10C13.2239 10 13 10.2239 13 10.5V15Z" />
+
  {:else if name === "device"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M5.99999 4.5C5.72385 4.5 5.49999 4.72386 5.49999 5V12.5H18.5V5C18.5 4.72386 18.2761 4.5 18 4.5H5.99999ZM18.691 13.5H5.30901L3.17081 17.7764C3.00459 18.1088 3.24633 18.5 3.61802 18.5H20.382C20.7536 18.5 20.9954 18.1088 20.8292 17.7764L18.691 13.5ZM4.49999 12.882V5C4.49999 4.17157 5.17156 3.5 5.99999 3.5H18C18.8284 3.5 19.5 4.17157 19.5 5V12.882L21.7236 17.3292C22.2223 18.3265 21.497 19.5 20.382 19.5H3.61802C2.50295 19.5 1.77771 18.3265 2.27638 17.3292L4.49999 12.882Z" />
  {:else if name === "diff"}
    <path d="M12.5 10.5H15V9.5H12.5V7H11.5V9.5H9V10.5H11.5V13H12.5V10.5Z" />
    <path d="M15 16.5H9V15.5H15V16.5Z" />
@@ -221,6 +307,30 @@
      16 2.5H8C5.51472 2.5 3.5 4.51472 3.5 7V17C3.5 19.4853 5.51472 21.5 8
      21.5H16ZM19.5 17C19.5 18.933 17.933 20.5 16 20.5H8C6.067 20.5 4.5 18.933
      4.5 17V7C4.5 5.067 6.067 3.5 8 3.5H16C17.933 3.5 19.5 5.067 19.5 7V17Z" />
+
  {:else if name === "download"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M5 11.5C4.72386 11.5 4.5 11.7239 4.5 12L4.5 18C4.5 18.8284 5.17157
+
      19.5 6 19.5L18 19.5C18.8284 19.5 19.5 18.8284 19.5 18L19.5 12C19.5
+
      11.7239 19.2761 11.5 19 11.5C18.7239 11.5 18.5 11.7239 18.5 12L18.5
+
      18C18.5 18.2761 18.2761 18.5 18 18.5L6 18.5C5.72386 18.5 5.5 18.2761 5.5
+
      18L5.5 12C5.5 11.7239 5.27614 11.5 5 11.5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12 13.5C12.2761 13.5 12.5 13.2761 12.5 13L12.5 5C12.5 4.72386 12.2761
+
      4.5 12 4.5C11.7239 4.5 11.5 4.72386 11.5 5L11.5 13C11.5 13.2761 11.7239
+
      13.5 12 13.5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.64645 9.64648C7.84171 9.45121 8.15829 9.45121 8.35355
+
      9.64648L11.6464 12.9394C11.8417 13.1346 12.1583 13.1346 12.3536
+
      12.9394L15.6464 9.64647C15.8417 9.45121 16.1583 9.45121 16.3536
+
      9.64647C16.5488 9.84174 16.5488 10.1583 16.3536 10.3536L13.0607
+
      13.6465C12.4749 14.2323 11.5251 14.2323 10.9393 13.6465L7.64645
+
      10.3536C7.45118 10.1583 7.45118 9.84174 7.64645 9.64648Z" />
  {:else if name === "ellipsis"}
    <path
      fill-rule="evenodd"
@@ -231,6 +341,11 @@
      10.6716 11.1716 10 12 10C12.8284 10 13.5 10.6716 13.5 11.5ZM20 11.5C20
      12.3284 19.3284 13 18.5 13C17.6716 13 17 12.3284 17 11.5C17 10.6716
      17.6716 10 18.5 10C19.3284 10 20 10.6716 20 11.5Z" />
+
  {:else if name === "empty-file"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7 4.5C6.72386 4.5 6.5 4.72386 6.5 5V19C6.5 19.2761 6.72386 19.5 7 19.5H17C17.2761 19.5 17.5 19.2761 17.5 19V8.87566C17.5 8.45702 17.325 8.05742 17.0174 7.77346L13.9021 4.8978C13.625 4.64203 13.2618 4.5 12.8847 4.5H7ZM5.5 5C5.5 4.17157 6.17157 3.5 7 3.5H12.8847C13.5132 3.5 14.1186 3.73671 14.5804 4.16299L17.6957 7.03865C18.2084 7.51192 18.5 8.17792 18.5 8.87566V19C18.5 19.8284 17.8284 20.5 17 20.5H7C6.17157 20.5 5.5 19.8284 5.5 19V5Z" />
  {:else if name === "exclamation"}
    <path
      fill-rule="evenodd"
@@ -444,6 +559,26 @@
      d="M10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8284 13.5 13.5
    12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716
    10.5 12Z" />
+
  {:else if name === "keyboard"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M21 8H2L2 18H21V8ZM2 7C1.44772 7 1 7.44772 1 8V18C1 18.5523 1.44772 19 2 19H21C21.5523 19 22 18.5523 22 18V8C22 7.44772 21.5523 7 21 7H2Z" />
+
    <path d="M6 15H17V17H6V15Z" />
+
    <path d="M18 15H20V17H18V15Z" />
+
    <path d="M3 15H5V17H3V15Z" />
+
    <path d="M15 12H20V14H15V12Z" />
+
    <path d="M18 12H20V14H18V12Z" />
+
    <path d="M9 12H11V14H9V12Z" />
+
    <path d="M12 12H14V14H12V12Z" />
+
    <path d="M6 12H8V14H6V12Z" />
+
    <path d="M18 9H20V11H18V9Z" />
+
    <path d="M15 9H17V11H15V9Z" />
+
    <path d="M9 9H11V11H9V9Z" />
+
    <path d="M12 9H14V11H12V9Z" />
+
    <path d="M6 9H8V11H6V9Z" />
+
    <path d="M3 9H5V11H3V9Z" />
+
    <path d="M3 12H5V14H3V12Z" />
  {:else if name === "magnifying-glass"}
    <path
      fill-rule="evenodd"
@@ -512,6 +647,48 @@
      17.6471 17.107L18.35 7.96948ZM15.0299 18.6537C15.0894 18.3129 15.2352
      18.0017 15.4439 17.7435L7.97013 14.3463C7.91063 14.6871 7.76483 14.9983
      7.55608 15.2565L15.0299 18.6537Z" />
+
  {:else if name === "no-activity"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12 17C14.7614 17 17 14.7614 17 12C17 9.23858 14.7614 7 12 7C9.23858 7 7 9.23858 7 12C7 14.7614 9.23858 17 12 17ZM12 16C14.2091 16 16 14.2091 16 12C16 11.0756 15.6865 10.2245 15.1599 9.54717L9.54717 15.1599C10.2245 15.6865 11.0756 16 12 16ZM8.84007 14.4528L14.4528 8.84007C13.7755 8.31354 12.9244 8 12 8C9.79086 8 8 9.79086 8 12C8 12.9244 8.31354 13.7755 8.84007 14.4528Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" />
+
  {:else if name === "no-issues"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12 3.5C11.6995 3.5 11.4028 3.51557 11.1106 3.54593C10.836 3.57447 10.5902 3.37494 10.5616 3.10028C10.5331 2.82561 10.7326 2.57982 11.0073 2.55128C11.3337 2.51737 11.6649 2.5 12 2.5C12.3351 2.5 12.6663 2.51737 12.9927 2.55128C13.2674 2.57982 13.4669 2.82561 13.4384 3.10028C13.4098 3.37494 13.164 3.57447 12.8894 3.54593C12.5972 3.51557 12.3005 3.5 12 3.5ZM8.79503 3.57245C8.90749 3.82466 8.79421 4.12028 8.542 4.23274C7.99667 4.47591 7.48153 4.77491 7.00385 5.12255C6.78058 5.28504 6.46786 5.23577 6.30536 5.0125C6.14287 4.78923 6.19214 4.4765 6.41541 4.31401C6.94907 3.92562 7.52487 3.59137 8.13474 3.31943C8.38695 3.20696 8.68257 3.32025 8.79503 3.57245ZM15.205 3.57245C15.3174 3.32025 15.6131 3.20696 15.8653 3.31943C16.4751 3.59137 17.0509 3.92562 17.5846 4.31401C17.8079 4.4765 17.8571 4.78923 17.6946 5.0125C17.5321 5.23577 17.2194 5.28504 16.9961 5.12255C16.5185 4.77491 16.0033 4.47591 15.458 4.23274C15.2058 4.12028 15.0925 3.82466 15.205 3.57245ZM5.0125 6.30536C5.23577 6.46785 5.28504 6.78058 5.12255 7.00385C4.77491 7.48153 4.47591 7.99667 4.23274 8.542C4.12028 8.7942 3.82466 8.90749 3.57245 8.79503C3.32025 8.68257 3.20696 8.38695 3.31943 8.13474C3.59138 7.52487 3.92562 6.94907 4.31401 6.41541C4.4765 6.19214 4.78923 6.14287 5.0125 6.30536ZM18.9875 6.30536C19.2108 6.14287 19.5235 6.19214 19.686 6.41541C20.0744 6.94907 20.4086 7.52487 20.6806 8.13474C20.793 8.38695 20.6798 8.68257 20.4275 8.79503C20.1753 8.90749 19.8797 8.7942 19.7673 8.542C19.5241 7.99667 19.2251 7.48153 18.8774 7.00385C18.715 6.78058 18.7642 6.46786 18.9875 6.30536ZM3.10028 10.5616C3.37494 10.5902 3.57447 10.836 3.54593 11.1106C3.51557 11.4028 3.5 11.6995 3.5 12C3.5 12.3005 3.51557 12.5972 3.54593 12.8894C3.57447 13.164 3.37494 13.4098 3.10028 13.4384C2.82561 13.4669 2.57982 13.2674 2.55128 12.9927C2.51737 12.6663 2.5 12.3351 2.5 12C2.5 11.6649 2.51737 11.3337 2.55128 11.0073C2.57982 10.7326 2.82561 10.5331 3.10028 10.5616ZM20.8997 10.5616C21.1744 10.5331 21.4202 10.7326 21.4487 11.0073C21.4826 11.3337 21.5 11.6649 21.5 12C21.5 12.3351 21.4826 12.6663 21.4487 12.9927C21.4202 13.2674 21.1744 13.4669 20.8997 13.4384C20.6251 13.4098 20.4255 13.164 20.4541 12.8894C20.4844 12.5972 20.5 12.3005 20.5 12C20.5 11.6995 20.4844 11.4028 20.4541 11.1106C20.4255 10.836 20.6251 10.5902 20.8997 10.5616ZM3.57245 15.205C3.82466 15.0925 4.12028 15.2058 4.23274 15.458C4.47591 16.0033 4.77491 16.5185 5.12255 16.9961C5.28504 17.2194 5.23577 17.5321 5.0125 17.6946C4.78923 17.8571 4.4765 17.8079 4.31401 17.5846C3.92562 17.0509 3.59137 16.4751 3.31943 15.8653C3.20696 15.6131 3.32025 15.3174 3.57245 15.205ZM20.4275 15.205C20.6798 15.3174 20.793 15.6131 20.6806 15.8653C20.4086 16.4751 20.0744 17.0509 19.686 17.5846C19.5235 17.8079 19.2108 17.8571 18.9875 17.6946C18.7642 17.5321 18.715 17.2194 18.8774 16.9961C19.2251 16.5185 19.5241 16.0033 19.7673 15.458C19.8797 15.2058 20.1753 15.0925 20.4275 15.205ZM6.30536 18.9875C6.46786 18.7642 6.78058 18.715 7.00385 18.8774C7.48153 19.2251 7.99667 19.5241 8.542 19.7673C8.79421 19.8797 8.90749 20.1753 8.79503 20.4275C8.68257 20.6798 8.38695 20.793 8.13474 20.6806C7.52487 20.4086 6.94907 20.0744 6.41541 19.686C6.19214 19.5235 6.14287 19.2108 6.30536 18.9875ZM17.6946 18.9875C17.8571 19.2108 17.8079 19.5235 17.5846 19.686C17.0509 20.0744 16.4751 20.4086 15.8653 20.6806C15.6131 20.793 15.3174 20.6798 15.205 20.4275C15.0925 20.1753 15.2058 19.8797 15.458 19.7673C16.0033 19.5241 16.5185 19.2251 16.9961 18.8774C17.2194 18.715 17.5321 18.7642 17.6946 18.9875ZM10.5616 20.8997C10.5902 20.6251 10.836 20.4255 11.1106 20.4541C11.4028 20.4844 11.6995 20.5 12 20.5C12.3005 20.5 12.5972 20.4844 12.8894 20.4541C13.164 20.4255 13.4098 20.6251 13.4384 20.8997C13.4669 21.1744 13.2674 21.4202 12.9927 21.4487C12.6663 21.4826 12.3351 21.5 12 21.5C11.6649 21.5 11.3337 21.4826 11.0073 21.4487C10.7326 21.4202 10.5331 21.1744 10.5616 20.8997Z" />
+
    <path
+
      d="M10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8284 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12Z" />
+
  {:else if name === "no-patches"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.5 7C8.32843 7 9 6.32843 9 5.5C9 4.67157 8.32843 4 7.5 4C6.67157 4 6 4.67157 6 5.5C6 6.32843 6.67157 7 7.5 7ZM7.5 8C8.88071 8 10 6.88071 10 5.5C10 4.11929 8.88071 3 7.5 3C6.11929 3 5 4.11929 5 5.5C5 6.88071 6.11929 8 7.5 8Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.5 20C8.32843 20 9 19.3284 9 18.5C9 17.6716 8.32843 17 7.5 17C6.67157 17 6 17.6716 6 18.5C6 19.3284 6.67157 20 7.5 20ZM7.5 21C8.88071 21 10 19.8807 10 18.5C10 17.1193 8.88071 16 7.5 16C6.11929 16 5 17.1193 5 18.5C5 19.8807 6.11929 21 7.5 21Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M17.5 20C18.3284 20 19 19.3284 19 18.5C19 17.6716 18.3284 17 17.5 17C16.6716 17 16 17.6716 16 18.5C16 19.3284 16.6716 20 17.5 20ZM17.5 21C18.8807 21 20 19.8807 20 18.5C20 17.1193 18.8807 16 17.5 16C16.1193 16 15 17.1193 15 18.5C15 19.8807 16.1193 21 17.5 21Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.5 7C7.77614 7 8 7.24873 8 7.55556L8 16.4444C8 16.7513 7.77614 17 7.5 17C7.22386 17 7 16.7513 7 16.4444L7 7.55556C7 7.24873 7.22386 7 7.5 7Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M10 9.5C10 9.77614 10.2239 10 10.5 10L12.5 10C12.7761 10 13 9.77614 13 9.5C13 9.22386 12.7761 9 12.5 9L10.5 9C10.2239 9 10 9.22386 10 9.5ZM15 9.5C15 9.77614 15.2239 10 15.5 10L16.5 10C16.7761 10 17 10.2239 17 10.5L17 11.5C17 11.7761 17.2239 12 17.5 12C17.7761 12 18 11.7761 18 11.5L18 10.5C18 9.67157 17.3284 9 16.5 9L15.5 9C15.2239 9 15 9.22386 15 9.5ZM17.5 14C17.2239 14 17 14.2239 17 14.5L17 16.5C17 16.7761 17.2239 17 17.5 17C17.7761 17 18 16.7761 18 16.5L18 14.5C18 14.2239 17.7761 14 17.5 14Z" />
+
  {:else if name === "no-file"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7 4.5C6.72386 4.5 6.5 4.72386 6.5 5V5.93333C6.5 6.20948 6.27614 6.43333 6 6.43333C5.72386 6.43333 5.5 6.20948 5.5 5.93333V5C5.5 4.17157 6.17157 3.5 7 3.5H8.17694C8.45308 3.5 8.67694 3.72386 8.67694 4C8.67694 4.27614 8.45308 4.5 8.17694 4.5H7ZM11.2078 4C11.2078 3.72386 11.4316 3.5 11.7078 3.5H12.8847C13.5132 3.5 14.1186 3.73671 14.5804 4.16299L15.2035 4.73812C15.4064 4.92543 15.419 5.24176 15.2317 5.44467C15.0444 5.64758 14.7281 5.66023 14.5252 5.47293L13.9021 4.8978C13.625 4.64203 13.2618 4.5 12.8847 4.5H11.7078C11.4316 4.5 11.2078 4.27614 11.2078 4ZM16.3661 6.49178C16.5534 6.28887 16.8697 6.27622 17.0726 6.46352L17.6957 7.03865C18.2084 7.51192 18.5 8.17792 18.5 8.87566V9.8881C18.5 10.1642 18.2761 10.3881 18 10.3881C17.7239 10.3881 17.5 10.1642 17.5 9.8881V8.87566C17.5 8.45702 17.325 8.05742 17.0174 7.77346L16.3944 7.19832C16.1914 7.01102 16.1788 6.69469 16.3661 6.49178ZM6 8.23333C6.27614 8.23333 6.5 8.45719 6.5 8.73333V10.6C6.5 10.8761 6.27614 11.1 6 11.1C5.72386 11.1 5.5 10.8761 5.5 10.6V8.73333C5.5 8.45719 5.72386 8.23333 6 8.23333ZM18 12.4254C18.2761 12.4254 18.5 12.6493 18.5 12.9254V14.9503C18.5 15.2264 18.2761 15.4503 18 15.4503C17.7239 15.4503 17.5 15.2264 17.5 14.9503V12.9254C17.5 12.6493 17.7239 12.4254 18 12.4254ZM6 12.9C6.27614 12.9 6.5 13.1239 6.5 13.4V15.2667C6.5 15.5428 6.27614 15.7667 6 15.7667C5.72386 15.7667 5.5 15.5428 5.5 15.2667V13.4C5.5 13.1239 5.72386 12.9 6 12.9ZM18 17.4876C18.2761 17.4876 18.5 17.7114 18.5 17.9876V19C18.5 19.8284 17.8284 20.5 17 20.5H16C15.7239 20.5 15.5 20.2761 15.5 20C15.5 19.7239 15.7239 19.5 16 19.5H17C17.2761 19.5 17.5 19.2761 17.5 19V17.9876C17.5 17.7114 17.7239 17.4876 18 17.4876ZM6 17.5667C6.27614 17.5667 6.5 17.7905 6.5 18.0667V19C6.5 19.2761 6.72386 19.5 7 19.5H8C8.27614 19.5 8.5 19.7239 8.5 20C8.5 20.2761 8.27614 20.5 8 20.5H7C6.17157 20.5 5.5 19.8284 5.5 19V18.0667C5.5 17.7905 5.72386 17.5667 6 17.5667ZM10.5 20C10.5 19.7239 10.7239 19.5 11 19.5H13C13.2761 19.5 13.5 19.7239 13.5 20C13.5 20.2761 13.2761 20.5 13 20.5H11C10.7239 20.5 10.5 20.2761 10.5 20Z" />
  {:else if name === "patch"}
    <path
      fill-rule="evenodd"
@@ -535,28 +712,31 @@
      16.5ZM18 16.5C17.1716 16.5 16.5 17.1716 16.5 18C16.5 18.8284 17.1716 19.5
      18 19.5C18.8284 19.5 19.5 18.8284 19.5 18C19.5 17.1716 18.8284 16.5 18
      16.5Z" />
-
  {:else if name === "pen"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M17.2984 7.40873C17.6064 7.81195 17.8352 8.27913 17.962 8.7866C18.326
-
      10.2426 17.8003 11.7887 16.6128 12.7123L9.30695 18.3947C9.29734 18.4022
-
      9.28746 18.4093 9.27733 18.416L7.40337 19.6653C6.67511 20.1508 5.74073
-
      20.1399 5.03187 19.6752L4.35355 20.3536C4.15829 20.5488 3.84171 20.5488
-
      3.64645 20.3536C3.45118 20.1583 3.45118 19.8417 3.64645 19.6464L4.34403
-
      18.9489C3.90746 18.2004 3.94386 17.2415 4.48505 16.5199L11.2967
-
      7.43774C10.467 6.66144 9.08443 6.77464 8.41596 7.77735L6.41596
-
      10.7774C6.26278 11.0071 5.95235 11.0692 5.72258 10.916C5.49282 10.7628
-
      5.43073 10.4524 5.58391 10.2227L7.58391 7.22265C8.60869 5.68548 10.7337
-
      5.51886 11.997 6.72279C12.8957 6.02228 14.0823 5.75519 15.2225
-
      6.04024C15.7278 6.16656 16.1916 6.39458 16.5918 6.70111L17.6464
-
      5.64645C17.8417 5.45118 18.1583 5.45118 18.3536 5.64645C18.5488 5.84171
-
      18.5488 6.15829 18.3536 6.35355L17.2984 7.40873ZM6.84866 18.8333C6.39224
-
      19.1376 5.78448 19.0774 5.39659 18.6895C4.97404 18.2669 4.9265 17.598
-
      5.28505 17.1199L6.19694 15.9041L8.21516 17.9223L6.84866 18.8333ZM9.04143
-
      17.3343L6.80303 15.0959L12.1004 8.03272C12.7672 7.14369 13.9018 6.74085
-
      14.9799 7.01038C15.968 7.25739 16.7441 8.03798 16.9919 9.02913C17.2607
-
      10.1045 16.871 11.2447 15.9989 11.923L9.04143 17.3343Z" />
+
  {:else if name === "review"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M17 10.5C17.2761 10.5 17.5 10.7239 17.5 11V17C17.5 17.8284 16.8284 18.5 16 18.5H9C8.72386 18.5 8.5 18.2761 8.5 18C8.5 17.7239 8.72386 17.5 9 17.5H16C16.2761 17.5 16.5 17.2761 16.5 17V11C16.5 10.7239 16.7239 10.5 17 10.5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7 13.5C6.72386 13.5 6.5 13.2761 6.5 13V7C6.5 6.17157 7.17157 5.5 8 5.5H15C15.2761 5.5 15.5 5.72386 15.5 6C15.5 6.27614 15.2761 6.5 15 6.5H8C7.72386 6.5 7.5 6.72386 7.5 7V13C7.5 13.2761 7.27614 13.5 7 13.5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7 16.5C6.17157 16.5 5.5 17.1716 5.5 18C5.5 18.8284 6.17157 19.5 7 19.5C7.82843 19.5 8.5 18.8284 8.5 18C8.5 17.1716 7.82843 16.5 7 16.5ZM4.5 18C4.5 16.6193 5.61929 15.5 7 15.5C8.38071 15.5 9.5 16.6193 9.5 18C9.5 19.3807 8.38071 20.5 7 20.5C5.61929 20.5 4.5 19.3807 4.5 18Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M17 7.5C17.8284 7.5 18.5 6.82843 18.5 6C18.5 5.17157 17.8284 4.5 17 4.5C16.1716 4.5 15.5 5.17157 15.5 6C15.5 6.82843 16.1716 7.5 17 7.5ZM19.5 6C19.5 7.38071 18.3807 8.5 17 8.5C15.6193 8.5 14.5 7.38071 14.5 6C14.5 4.61929 15.6193 3.5 17 3.5C18.3807 3.5 19.5 4.61929 19.5 6Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M20.3536 14.3536C20.1583 14.5488 19.8417 14.5488 19.6464 14.3536L17 11.7071L14.3536 14.3536C14.1583 14.5488 13.8417 14.5488 13.6464 14.3536C13.4512 14.1583 13.4512 13.8417 13.6464 13.6464L16.6464 10.6464C16.8417 10.4512 17.1583 10.4512 17.3536 10.6464L20.3536 13.6464C20.5488 13.8417 20.5488 14.1583 20.3536 14.3536Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M3.64645 9.64645C3.84171 9.45118 4.15829 9.45118 4.35355 9.64645L7 12.2929L9.64645 9.64645C9.84171 9.45118 10.1583 9.45118 10.3536 9.64645C10.5488 9.84171 10.5488 10.1583 10.3536 10.3536L7.35355 13.3536C7.15829 13.5488 6.84171 13.5488 6.64645 13.3536L3.64645 10.3536C3.45118 10.1583 3.45118 9.84171 3.64645 9.64645Z" />
  {:else if name === "sun"}
    <path
      fill-rule="evenodd"
added src/components/IconButton.svelte
@@ -0,0 +1,35 @@
+
<script lang="ts">
+
  export let title: string | undefined = undefined;
+
  export let ariaLabel: string | undefined = undefined;
+
</script>
+

+
<style>
+
  .button {
+
    user-select: none;
+
    background-color: transparent;
+
    border-radius: var(--border-radius-tiny);
+
    color: var(--color-foreground-dim);
+
    cursor: pointer;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    padding: 2px 4px;
+
    gap: 0.25rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .button:hover {
+
    color: var(--color-foreground-contrast);
+
    background-color: var(--color-fill-ghost);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<div
+
  role="button"
+
  tabindex="0"
+
  aria-label={ariaLabel}
+
  {title}
+
  class="button"
+
  on:click>
+
  <slot />
+
</div>
added src/components/IconSmall.svelte
@@ -0,0 +1,388 @@
+
<script lang="ts">
+
  import { unreachable } from "@app/lib/utils";
+
  export let name:
+
    | "activity"
+
    | "arrow-box-up-right"
+
    | "arrow-reply"
+
    | "branch"
+
    | "brush"
+
    | "chat"
+
    | "checkmark"
+
    | "chevron-down"
+
    | "chevron-left"
+
    | "chevron-left-right"
+
    | "chevron-right"
+
    | "chevron-up"
+
    | "clipboard"
+
    | "collapse"
+
    | "commit"
+
    | "cross"
+
    | "delegate"
+
    | "diff"
+
    | "download"
+
    | "edit"
+
    | "ellipsis"
+
    | "exclamation-circle"
+
    | "expand"
+
    | "eye-open"
+
    | "face"
+
    | "file"
+
    | "issue"
+
    | "logo"
+
    | "more"
+
    | "network"
+
    | "patch"
+
    | "plus"
+
    | "user";
+
</script>
+

+
<style>
+
  svg {
+
    display: flex;
+
    flex-shrink: 0;
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
+
<svg
+
  role="img"
+
  on:click
+
  width="16"
+
  height="16"
+
  fill="currentColor"
+
  viewBox="0 0 16 16">
+
  {#if name === "activity"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M2.76032 2.0563C2.56506 1.86104 2.24848 1.86104 2.05322 2.0563C1.85795 2.25156 1.85795 2.56815 2.05322 2.76341L3.41389 4.12408C2.52941 5.16972 1.99551 6.52284 1.99551 8C1.99551 11.3162 4.68381 14.0045 8 14.0045C11.3162 14.0045 14.0045 11.3162 14.0045 8C14.0045 4.68381 11.3162 1.99551 8 1.99551C7.13343 1.99551 6.30847 2.1794 5.5631 2.51075C5.31076 2.62293 5.19714 2.91842 5.30932 3.17075C5.42149 3.42308 5.71698 3.5367 5.96931 3.42453C6.58928 3.14892 7.27606 2.99551 8 2.99551C10.7639 2.99551 13.0045 5.2361 13.0045 8C13.0045 10.7639 10.7639 13.0045 8 13.0045C5.2361 13.0045 2.99551 10.7639 2.99551 8C2.99551 6.79876 3.41827 5.69689 4.12393 4.83412L4.84861 5.5588C4.32565 6.23309 4.01382 7.08042 4.01382 8C4.01382 10.2015 5.7985 11.9862 8 11.9862C10.2015 11.9862 11.9862 10.2015 11.9862 8C11.9862 5.79849 10.2015 4.01382 8 4.01382C7.6541 4.01382 7.31777 4.058 6.99668 4.14128C6.72939 4.21061 6.5689 4.4835 6.63823 4.7508C6.70756 5.01809 6.98045 5.17858 7.24775 5.10925C7.48754 5.04705 7.73952 5.01382 8 5.01382C9.64923 5.01382 10.9862 6.35078 10.9862 8C10.9862 9.64922 9.64923 10.9862 8 10.9862C6.35078 10.9862 5.01382 9.64922 5.01382 8C5.01382 7.35653 5.21697 6.76101 5.56321 6.27341L6.29783 7.00802C6.12778 7.29932 6.03009 7.63844 6.03009 8C6.03009 9.08795 6.91205 9.96991 8 9.96991C9.08794 9.96991 9.9699 9.08795 9.9699 8C9.9699 7.27462 9.57756 6.64143 8.9959 6.30011C8.75773 6.16035 8.45137 6.24013 8.31161 6.47829C8.17185 6.71646 8.25163 7.02282 8.48979 7.16258C8.77813 7.33178 8.9699 7.64377 8.9699 8C8.9699 8.53567 8.53566 8.96991 8 8.96991C7.46433 8.96991 7.03009 8.53567 7.03009 8C7.03009 7.87888 7.05216 7.76309 7.09262 7.6563C7.24121 7.67561 7.39681 7.62819 7.51096 7.51404C7.70622 7.31878 7.70622 7.0022 7.51096 6.80694L2.76032 2.0563Z" />
+
  {:else if name === "arrow-box-up-right"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M8 3a.5.5 0 000 1h3.293l-2 2H3.217C2.545 6 2 6.545 2 7.217v5.566C2 13.455 2.545 14 3.217 14h5.566C9.455 14 10 13.455 10 12.783V8.609a.522.522 0 10-1.043 0v4.174a.174.174 0 01-.174.174H3.217a.174.174 0 01-.174-.174V7.217c0-.096.078-.174.174-.174H8.25L6.146 9.146a.5.5 0 10.708.708L12 4.707V8a.5.5 0 001 0V3.5a.5.5 0 00-.5-.5H8z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "arrow-reply"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M2.87128 8.85806C2.89568 8.91703 2.93185 8.97228 2.97979 9.02022L6.31312 12.3536C6.50839 12.5488 6.82497 12.5488 7.02023 12.3536C7.21549 12.1583 7.21549 11.8417 7.02023 11.6464L4.54045 9.16667H12C12.6443 9.16667 13.1667 8.64433 13.1667 8V4C13.1667 3.72386 12.9428 3.5 12.6667 3.5C12.3905 3.5 12.1667 3.72386 12.1667 4V8C12.1667 8.09205 12.0921 8.16667 12 8.16667H4.54045L7.02023 5.68689C7.21549 5.49162 7.21549 5.17504 7.02023 4.97978C6.82497 4.78452 6.50839 4.78452 6.31312 4.97978L2.97979 8.31311C2.88292 8.40998 2.83411 8.53671 2.83335 8.66367C2.83335 8.66467 2.83334 8.66567 2.83334 8.66667C2.83334 8.66767 2.83335 8.66867 2.83335 8.66967C2.83374 8.73637 2.8472 8.79997 2.87128 8.85806Z" />
+
  {:else if name === "branch"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M11.5 2.22668C11.7761 2.22668 12 2.45054 12 2.72668V8.5C12 9.32843 11.3284 10 10.5 10H5.98108C5.70494 10 5.48108 9.77614 5.48108 9.5C5.48108 9.22386 5.70494 9 5.98108 9H10.5C10.7761 9 11 8.77614 11 8.5V2.72668C11 2.45054 11.2239 2.22668 11.5 2.22668Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.5 8.95271C4.19774 8.95271 3.95271 9.19774 3.95271 9.5C3.95271 9.80226 4.19774 10.0473 4.5 10.0473C4.80226 10.0473 5.04729 9.80226 5.04729 9.5C5.04729 9.19774 4.80226 8.95271 4.5 8.95271ZM3 9.5C3 8.67157 3.67157 8 4.5 8C5.32843 8 6 8.67157 6 9.5C6 10.3284 5.32843 11 4.5 11C3.67157 11 3 10.3284 3 9.5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.5 2C4.77614 2 5 2.22386 5 2.5L5 8.17142C5 8.44756 4.77614 8.67142 4.5 8.67142C4.22386 8.67142 4 8.44756 4 8.17142L4 2.5C4 2.22386 4.22386 2 4.5 2Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.5 11C4.77614 11 5 10.2442 5 10.5455L5 13.4545C5 13.7558 4.77614 14 4.5 14C4.22386 14 4 13.7558 4 13.4545L4 10.5455C4 10.2442 4.22386 11 4.5 11Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M13.8725 4.85251C13.6772 5.04777 13.3606 5.04777 13.1654 4.85251L11.5189 3.20606L9.87247 4.85251C9.67721 5.04777 9.36063 5.04777 9.16537 4.85251C8.97011 4.65724 8.97011 4.34066 9.16537 4.1454L11.1654 2.1454C11.3606 1.95014 11.6772 1.95014 11.8725 2.1454L13.8725 4.1454C14.0677 4.34066 14.0677 4.65724 13.8725 4.85251Z" />
+
  {:else if name === "brush"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M3.5 2.75C3.5 2.19772 3.94772 1.75 4.5 1.75H11.5C12.0523 1.75 12.5 2.19772 12.5 2.75V7.25H11.5V2.75L4.5 2.75V7.25H3.5V2.75Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4 7.75C4 8.30228 4.44772 8.75 5 8.75H6.14044C7.00888 8.75 7.69584 9.4852 7.63699 10.3516L7.47808 12.6914C7.45755 12.9936 7.69713 13.25 8 13.25C8.30287 13.25 8.54245 12.9936 8.52192 12.6914L8.36301 10.3516C8.30416 9.48521 8.99112 8.75 9.85956 8.75H11C11.5523 8.75 12 8.30228 12 7.75L4 7.75ZM3 7.25C3 6.97386 3.22386 6.75 3.5 6.75H12.5C12.7761 6.75 13 6.97386 13 7.25V7.75C13 8.85457 12.1046 9.75 11 9.75H9.85956C9.57008 9.75 9.34109 9.99507 9.36071 10.2839L9.51963 12.6237C9.57938 13.5035 8.88183 14.25 8 14.25C7.11817 14.25 6.42062 13.5035 6.48037 12.6237L6.63929 10.2839C6.65891 9.99507 6.42992 9.75 6.14044 9.75H5C3.89543 9.75 3 8.85457 3 7.75V7.25Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M10.1667 4.91667L10.1667 2.25L11.1667 2.25L11.1667 4.91667C11.1667 5.19281 10.9428 5.41667 10.6667 5.41667C10.3905 5.41667 10.1667 5.19281 10.1667 4.91667Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.83331 3.58333L8.83331 2.25L9.83331 2.25L9.83331 3.58333C9.83331 3.85948 9.60946 4.08333 9.33331 4.08333C9.05717 4.08333 8.83331 3.85948 8.83331 3.58333Z" />
+
  {:else if name === "chat"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M3.5 3a.5.5 0 00-.5.5v6a.5.5 0 00.5.5h5.35A6.5 6.5 0 0113 11.497V3.5a.5.5 0 00-.5-.5h-9zM2 3.5A1.5 1.5 0 013.5 2h9A1.5 1.5 0 0114 3.5v9.143a.5.5 0 01-.845.362l-.512-.488A5.5 5.5 0 008.85 11H3.5A1.5 1.5 0 012 9.5v-6z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M4 5.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0 2a.5.5 0 01.5-.5h4.75a.5.5 0 010 1H4.5a.5.5 0 01-.5-.5z"
+
      clip-rule="evenodd">
+
    </path>
+
  {: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>
+
  {:else if name === "chevron-down"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M12.837 5.15a.49.49 0 010 .73L9.286 9.727a1.76 1.76 0 01-2.357 0L3.163 5.88a.489.489 0 010-.728.587.587 0 01.785 0L7.714 9A.587.587 0 008.5 9l3.552-3.85a.587.587 0 01.785 0z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "chevron-left"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M10.4396 12.4278C10.2385 12.6448 9.91236 12.6448 9.71121 12.4278L5.86207 8.87619C5.25861 8.22532 5.25861 7.17004 5.86207 6.51917L9.71121 2.75323C9.91236 2.53628 10.2385 2.53628 10.4397 2.75323C10.6408 2.97019 10.6408 3.32195 10.4397 3.53891L6.59052 7.30484C6.38936 7.5218 6.38936 7.87356 6.59052 8.09051L10.4396 11.6421C10.6408 11.8591 10.6408 12.2108 10.4396 12.4278Z" />
+
  {:else if name === "chevron-left-right"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M6.104 3.646a.5.5 0 010 .708l-3.28 3.278a.167.167 0 00.001.236l3.279 3.278a.5.5 0 01-.708.708L2.118 8.575a1.167 1.167 0 010-1.65l3.278-3.279a.5.5 0 01.708 0zm3.792 0a.5.5 0 01.707 0l3.28 3.279a1.167 1.167 0 010 1.65l-3.28 3.279a.5.5 0 01-.707-.708l3.279-3.278a.167.167 0 000-.236L9.896 4.354a.5.5 0 010-.708z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "chevron-right"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M5.65087 2.66272C5.85202 2.44576 6.17816 2.44576 6.37931 2.66272L10.0474 6.15876C10.6509 6.80964 10.6509 7.86491 10.0474 8.51579L6.37931 12.3373C6.17816 12.5542 5.85202 12.5542 5.65087 12.3373C5.44971 12.1203 5.44971 11.7686 5.65087 11.5516L9.31896 7.73011C9.52011 7.51315 9.52011 7.1614 9.31896 6.94444L5.65087 3.44839C5.44971 3.23143 5.44971 2.87968 5.65087 2.66272Z" />
+
  {:else if name === "chevron-up"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M3.163 9.85a.489.489 0 010-.73l3.496-3.667a1.76 1.76 0 012.357 0l3.821 3.668a.49.49 0 010 .728.587.587 0 01-.785 0L8.23 6.181a.587.587 0 00-.786 0L3.948 9.849a.587.587 0 01-.785 0z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "clipboard"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M6 2.5A1.5 1.5 0 017.5 1h6A1.5 1.5 0 0115 2.5v6a1.5 1.5 0 01-1.5 1.5H10v3.5A1.5 1.5 0 018.5 15h-6A1.5 1.5 0 011 13.5v-6A1.5 1.5 0 012.5 6H6V2.5zM6 7H2.5a.5.5 0 00-.5.5v6a.5.5 0 00.5.5h6a.5.5 0 00.5-.5V10H7.5A1.5 1.5 0 016 8.5V7zm1.5 2a.5.5 0 01-.5-.5v-6a.5.5 0 01.5-.5h6a.5.5 0 01.5.5v6a.5.5 0 01-.5.5h-6z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "collapse"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.31307 13.3536C4.11781 13.1583 4.11781 12.8417 4.31307 12.6464L7.175 9.78452C7.63061 9.32891 8.36931 9.32891 8.82492 9.78452L11.6868 12.6464C11.8821 12.8417 11.8821 13.1583 11.6868 13.3536C11.4916 13.5488 11.175 13.5488 10.9797 13.3536L8.11781 10.4916C8.05272 10.4265 7.9472 10.4265 7.88211 10.4916L5.02018 13.3536C4.82492 13.5488 4.50833 13.5488 4.31307 13.3536ZM4.31307 3.64645C4.50833 3.45118 4.82492 3.45118 5.02018 3.64645L7.88211 6.50838C7.9472 6.57346 8.05272 6.57346 8.11781 6.50838L10.9797 3.64645C11.175 3.45118 11.4916 3.45118 11.6868 3.64645C11.8821 3.84171 11.8821 4.15829 11.6868 4.35355L8.82492 7.21548C8.3693 7.6711 7.63061 7.67109 7.175 7.21548L4.31307 4.35355C4.11781 4.15829 4.11781 3.84171 4.31307 3.64645Z" />
+
  {:else if name === "commit"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.54485 0.955719C7.82099 0.955719 8.04485 1.17958 8.04485 1.45572V3.99071C9.76553 4.23367 11.0891 5.71226 11.0891 7.5C11.0891 9.28774 9.76553 10.7663 8.04485 11.0093V13.5443C8.04485 13.8204 7.82099 14.0443 7.54485 14.0443C7.2687 14.0443 7.04485 13.8204 7.04485 13.5443L7.04485 11.0093C5.32416 10.7663 4.00056 9.28774 4.00056 7.5C4.00056 5.71226 5.32416 4.23367 7.04485 3.99071V1.45572C7.04485 1.17958 7.2687 0.955719 7.54485 0.955719ZM7.54485 10.0443C8.95001 10.0443 10.0891 8.90517 10.0891 7.5C10.0891 6.09483 8.95001 4.95572 7.54485 4.95572C6.13968 4.95572 5.00056 6.09483 5.00056 7.5C5.00056 8.90517 6.13968 10.0443 7.54485 10.0443Z" />
+
  {:else if name === "cross"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M3.163 3.163a.556.556 0 01.785 0L8 7.214l4.052-4.051a.556.556 0 01.785.785L8.786 8l4.051 4.052a.556.556 0 01-.785.785L8 8.786l-4.052 4.051a.556.556 0 01-.785-.785L7.214 8 3.163 3.948a.556.556 0 010-.785z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "delegate"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M3.5 5a1 1 0 110-2 1 1 0 010 2zm-2-1a2 2 0 104 0 2 2 0 00-4 0zM11.5 5a1 1 0 110-2 1 1 0 010 2zm-2-1a2 2 0 104 0 2 2 0 00-4 0z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M3.5 5.333a.5.5 0 01.5.5V7.5a.5.5 0 00.5.5h6a.5.5 0 00.5-.5V5.833a.5.5 0 011 0V7.5A1.5 1.5 0 0110.5 9H8v2.833c0 .277-.224-.5-.5-.5s-.5.777-.5.5V9H4.5A1.5 1.5 0 013 7.5V5.833a.5.5 0 01.5-.5z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M7.5 14a1 1 0 110-2 1 1 0 010 2zm-2-1a2 2 0 104 0 2 2 0 00-4 0z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "diff"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12.0014 12C12.5537 12 13.0014 11.5523 13.0014 11C13.0014 10.4477 12.5537 10 12.0014 10C11.4491 10 11.0014 10.4477 11.0014 11C11.0014 11.5523 11.4491 12 12.0014 12ZM12.0014 13C13.106 13 14.0014 12.1046 14.0014 11C14.0014 9.89543 13.106 9 12.0014 9C10.8968 9 10.0014 9.89543 10.0014 11C10.0014 12.1046 10.8968 13 12.0014 13Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M10.0667 2.14645C10.262 2.34171 10.262 2.65829 10.0667 2.85355L8.42024 4.5L10.0667 6.14645C10.262 6.34171 10.262 6.65829 10.0667 6.85355C9.87143 7.04882 9.55484 7.04882 9.35958 6.85355L7.35958 4.85355C7.16432 4.65829 7.16432 4.34171 7.35958 4.14645L9.35958 2.14645C9.55484 1.95118 9.87143 1.95118 10.0667 2.14645Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.50421 4.49999C7.50421 4.77613 7.72807 4.99999 8.00421 4.99999L11.3242 4.99998C11.4163 4.99998 11.4909 5.0746 11.4909 5.16665L11.5014 9.32074C11.5014 9.59688 11.7252 9.82074 12.0014 9.82074C12.2775 9.82074 12.5014 9.59688 12.5014 9.32074L12.4909 5.16665C12.4909 4.52232 11.9685 3.99998 11.3242 3.99998L8.00421 3.99999C7.72807 3.99999 7.50421 4.22385 7.50421 4.49999Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4 4C3.44772 4 3 4.44772 3 5C3 5.55228 3.44772 6 4 6C4.55228 6 5 5.55228 5 5C5 4.44772 4.55228 4 4 4ZM4 3C2.89543 3 2 3.89543 2 5C2 6.10457 2.89543 7 4 7C5.10457 7 6 6.10457 6 5C6 3.89543 5.10457 3 4 3Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M6.43612 13.8536C6.24086 13.6583 6.24086 13.3417 6.43612 13.1464L8.08257 11.5L6.43612 9.85355C6.24086 9.65829 6.24086 9.34171 6.43612 9.14645C6.63138 8.95118 6.94796 8.95118 7.14323 9.14645L9.14323 11.1464C9.33849 11.3417 9.33849 11.6583 9.14323 11.8536L7.14323 13.8536C6.94796 14.0488 6.63138 14.0488 6.43612 13.8536Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.49856 11.5C8.49856 11.2239 8.27471 11 7.99856 11L4.67857 11C4.58652 11 4.5119 10.9254 4.5119 10.8333L4.50139 6.5C4.50139 6.22385 4.27753 6 4.00139 6C3.72525 6 3.50139 6.22385 3.50139 6.5L3.5119 10.8333C3.5119 11.4777 4.03423 12 4.67857 12L7.99856 12C8.27471 12 8.49856 11.7761 8.49856 11.5Z" />
+
  {:else if name === "download"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M3.5 8a.5.5 0 00-.5.5v4A1.5 1.5 0 004.5 14h8a1.5 1.5 0 001.5-1.5v-4a.5.5 0 00-1 0v4a.5.5 0 01-.5.5h-8a.5.5 0 01-.5-.5v-4a.5.5 0 00-.5-.5z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M8.5 8.5A.5.5 0 009 8V3a.5.5 0 00-1 0v5a.5.5 0 00.5.5z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M5.084 6.239a.48.48 0 01.693-.149l2.446 1.748c.168.12.386.12.554 0l2.446-1.748a.48.48 0 01.693.149.56.56 0 01-.139.743L9.332 8.73c-.504.36-1.16.36-1.664 0L5.223 6.982a.56.56 0 01-.139-.743z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "edit"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M11.0856 3L13.914 5.82843L12.4998 7.24263L9.6714 4.4142L11.0856 3ZM8.9643 5.12131L3.28903 10.7966L3.00011 13.9139L6.11746 13.625L11.7927 7.94973L8.9643 5.12131ZM10.3785 2.29289C10.769 1.90237 11.4022 1.90237 11.7927 2.29289L14.6212 5.12132C15.0117 5.51184 15.0117 6.14501 14.6212 6.53553L6.82457 14.3321C6.65957 14.4971 6.44208 14.5992 6.20975 14.6207L3.09239 14.9097C2.46913 14.9674 1.94661 14.4449 2.00437 13.8217L2.2933 10.7043C2.31483 10.472 2.41693 10.2545 2.58192 10.0895L10.3785 2.29289Z" />
+
  {:else if name === "ellipsis"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4 9C4.55228 9 5 8.55229 5 8C5 7.44772 4.55228 7 4 7C3.44772 7 3 7.44772 3 8C3 8.55229 3.44772 9 4 9ZM9 8C9 8.55229 8.55228 9 8 9C7.44772 9 7 8.55229 7 8C7 7.44772 7.44772 7 8 7C8.55228 7 9 7.44772 9 8ZM13 8C13 8.55229 12.5523 9 12 9C11.4477 9 11 8.55229 11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8Z" />
+
  {:else if name === "exclamation-circle"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8 5.16667C7.60055 5.16667 7.29371 5.52046 7.3502 5.91589L7.82831 9.26263C7.86736 9.536 7.67741 9.78926 7.40404 9.82831C7.13067 9.86737 6.87741 9.67742 6.83836 9.40405L6.36025 6.05731C6.2177 5.05945 6.992 4.16667 8 4.16667C9.00799 4.16667 9.7823 5.05945 9.63974 6.05731L9.16164 9.40405C9.12259 9.67742 8.86932 9.86737 8.59595 9.82831C8.32259 9.78926 8.13264 9.536 8.17169 9.26263L8.64979 5.91589C8.70628 5.52046 8.39945 5.16667 8 5.16667Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8 2.5C4.96243 2.5 2.5 4.96243 2.5 8C2.5 11.0376 4.96243 13.5 8 13.5C11.0376 13.5 13.5 11.0376 13.5 8C13.5 4.96243 11.0376 2.5 8 2.5ZM1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8Z" />
+
    <path
+
      d="M8.66668 11.3333C8.66668 11.7015 8.3682 12 8.00001 12C7.63182 12 7.33334 11.7015 7.33334 11.3333C7.33334 10.9651 7.63182 10.6667 8.00001 10.6667C8.3682 10.6667 8.66668 10.9651 8.66668 11.3333Z" />
+
  {:else if name === "expand"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.31313 9.64645C4.5084 9.45118 4.82498 9.45118 5.02024 9.64645L7.88217 12.5084C7.94726 12.5735 8.05278 12.5735 8.11787 12.5084L10.9798 9.64644C11.1751 9.45118 11.4916 9.45118 11.6869 9.64644C11.8822 9.84171 11.8822 10.1583 11.6869 10.3536L8.82498 13.2155C8.36937 13.6711 7.63067 13.6711 7.17506 13.2155L4.31313 10.3536C4.11787 10.1583 4.11787 9.84171 4.31313 9.64645ZM4.31313 6.35355C4.11787 6.15829 4.11787 5.84171 4.31313 5.64645L7.17506 2.78452C7.63067 2.3289 8.36937 2.3289 8.82498 2.78452L11.6869 5.64644C11.8822 5.84171 11.8822 6.15829 11.6869 6.35355C11.4916 6.54881 11.1751 6.54881 10.9798 6.35355L8.11787 3.49162C8.05278 3.42653 7.94726 3.42654 7.88217 3.49162L5.02024 6.35355C4.82498 6.54881 4.5084 6.54881 4.31313 6.35355Z" />
+
  {:else if name === "eye-open"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14.1652 7.9838C12.328 5.09942 10.2279 3.5 8.00013 3.5C5.77233 3.5 3.67224 5.09942 1.83502 7.9838C1.55994 8.41566 1.60375 8.98589 1.95654 9.36765C3.8457 11.4119 5.89216 12.5 8.00013 12.5C10.1081 12.5 12.1546 11.4119 14.0437 9.36765C14.3965 8.98589 14.4403 8.41566 14.1652 7.9838ZM2.67845 8.52103C4.4527 5.73552 6.29028 4.5 8.00013 4.5C9.70999 4.5 11.5476 5.73552 13.3218 8.52103C13.3571 8.57643 13.3486 8.64642 13.3093 8.68895C11.5368 10.6069 9.73773 11.5 8.00013 11.5C6.26253 11.5 4.46342 10.6069 2.69096 8.68895C2.65166 8.64642 2.64317 8.57643 2.67845 8.52103ZM6.50012 8C6.50012 7.17157 7.17169 6.5 8.00012 6.5C8.82855 6.5 9.50012 7.17157 9.50012 8C9.50012 8.82843 8.82855 9.5 8.00012 9.5C7.17169 9.5 6.50012 8.82843 6.50012 8ZM8.00012 5.5C6.61941 5.5 5.50012 6.61929 5.50012 8C5.50012 9.38071 6.61941 10.5 8.00012 10.5C9.38083 10.5 10.5001 9.38071 10.5001 8C10.5001 6.61929 9.38083 5.5 8.00012 5.5Z" />
+
  {:else if name === "face"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8 3C5.23857 3 3 5.23857 3 8C3 10.7615 5.23857 13 8 13C10.7615 13 13 10.7615 13 8C13 5.23857 10.7615 3 8 3ZM2 8C2 4.68629 4.68629 2 8 2C11.3137 2 14 4.68629 14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.93334 8.7C5.09902 8.47909 5.41243 8.43431 5.63334 8.6L5.90001 8.8C7.14445 9.73336 8.85557 9.73336 10.1 8.8L10.3667 8.59998C10.5877 8.43431 10.901 8.4791 11.0667 8.70002C11.2324 8.92095 11.1876 9.23435 10.9667 9.40002L10.7 9.59999C10.7 9.6 10.7 9.59998 10.7 9.59999C9.10002 10.8 6.9 10.8 5.30001 9.6C5.30001 9.6 5.30002 9.6 5.30001 9.6L5.03334 9.4C4.81243 9.23432 4.76766 8.92092 4.93334 8.7Z" />
+
    <path
+
      d="M6.66667 7.33334C7.03486 7.33334 7.33333 7.03486 7.33333 6.66667C7.33333 6.29848 7.03486 6 6.66667 6C6.29848 6 6 6.29848 6 6.66667C6 7.03486 6.29848 7.33334 6.66667 7.33334Z" />
+
    <path
+
      d="M9.33332 7.33334C9.70151 7.33334 9.99999 7.03486 9.99999 6.66667C9.99999 6.29848 9.70151 6 9.33332 6C8.96513 6 8.66666 6.29848 8.66666 6.66667C8.66666 7.03486 8.96513 7.33334 9.33332 7.33334Z" />
+
  {:else if name === "file"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4.5 3.33329C4.5 3.24125 4.57462 3.16663 4.66667 3.16663H8.5898C8.79929 3.16663 9.0011 3.24553 9.15503 3.38762L11.2319 5.30473C11.4028 5.46249 11.5 5.68449 11.5 5.91707V12.6666C11.5 12.7587 11.4254 12.8333 11.3333 12.8333H4.66667C4.57462 12.8333 4.5 12.7587 4.5 12.6666V3.33329ZM4.66667 2.16663C4.02233 2.16663 3.5 2.68896 3.5 3.33329V12.6666C3.5 13.311 4.02233 13.8333 4.66667 13.8333H11.3333C11.9777 13.8333 12.5 13.311 12.5 12.6666V5.91707C12.5 5.40539 12.2862 4.91699 11.9102 4.56993L9.83331 2.65282C9.49466 2.34021 9.05068 2.16663 8.5898 2.16663H4.66667ZM6 4.16663C5.72386 4.16663 5.5 4.39048 5.5 4.66663C5.5 4.94277 5.72386 5.16663 6 5.16663H7.33333C7.60948 5.16663 7.83333 4.94277 7.83333 4.66663C7.83333 4.39048 7.60948 4.16663 7.33333 4.16663H6ZM6 6.83329C5.72386 6.83329 5.5 7.05715 5.5 7.33329C5.5 7.60944 5.72386 7.83329 6 7.83329H10C10.2761 7.83329 10.5 7.60944 10.5 7.33329C10.5 7.05715 10.2761 6.83329 10 6.83329H6ZM6 8.83329C5.72386 8.83329 5.5 9.05715 5.5 9.33329C5.5 9.60943 5.72386 9.83329 6 9.83329H8.66667C8.94281 9.83329 9.16667 9.60943 9.16667 9.33329C9.16667 9.05715 8.94281 8.83329 8.66667 8.83329H6ZM6 10.8333C5.72386 10.8333 5.5 11.0571 5.5 11.3333C5.5 11.6094 5.72386 11.8333 6 11.8333H9.33333C9.60948 11.8333 9.83333 11.6094 9.83333 11.3333C9.83333 11.0571 9.60948 10.8333 9.33333 10.8333H6Z" />
+
  {:else if name === "issue"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M2 8a6 6 0 1112 0A6 6 0 012 8zm6-5.077a5.077 5.077 0 100 10.154A5.077 5.077 0 008 2.923z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M7.077 8a.923.923 0 101.846 0 .923.923 0 00-1.846 0z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "logo"}
+
    <path d="M5.04547 1.5H3.86365V2.68182H5.04547V1.5Z" />
+
    <path d="M12.1364 1.5H10.9546V2.68182H12.1364V1.5Z" />
+
    <path d="M6.22726 2.68182H5.04544V3.86363H6.22726V2.68182Z" />
+
    <path d="M10.9546 2.68182H9.77274V3.86363H10.9546V2.68182Z" />
+
    <path d="M6.22726 3.86364H5.04544V5.04546H6.22726V3.86364Z" />
+
    <path d="M7.40908 3.86364H6.22726V5.04546H7.40908V3.86364Z" />
+
    <path d="M8.59091 3.86364H7.40909V5.04546H8.59091V3.86364Z" />
+
    <path d="M9.77273 3.86364H8.59091V5.04546H9.77273V3.86364Z" />
+
    <path d="M10.9546 3.86364H9.77274V5.04546H10.9546V3.86364Z" />
+
    <path d="M5.04547 5.04546H3.86365V6.22727H5.04547V5.04546Z" />
+
    <path d="M6.22726 5.04546H5.04544V6.22727H6.22726V5.04546Z" />
+
    <path d="M7.40908 5.04546H6.22726V6.22727H7.40908V5.04546Z" />
+
    <path d="M8.59091 5.04546H7.40909V6.22727H8.59091V5.04546Z" />
+
    <path d="M9.77273 5.04546H8.59091V6.22727H9.77273V5.04546Z" />
+
    <path d="M10.9546 5.04546H9.77274V6.22727H10.9546V5.04546Z" />
+
    <path d="M12.1364 5.04546H10.9546V6.22727H12.1364V5.04546Z" />
+
    <path d="M3.86364 6.22727H2.68182V7.40909H3.86364V6.22727Z" />
+
    <path d="M5.04547 6.22727H3.86365V7.40909H5.04547V6.22727Z" />
+
    <path d="M8.59091 6.22727H7.40909V7.40909H8.59091V6.22727Z" />
+
    <path d="M9.77273 6.22727H8.59091V7.40909H9.77273V6.22727Z" />
+
    <path d="M13.3182 6.22727H12.1364V7.40909H13.3182V6.22727Z" />
+
    <path d="M3.86364 7.40909H2.68182V8.59091H3.86364V7.40909Z" />
+
    <path d="M5.04547 7.40909H3.86365V8.59091H5.04547V7.40909Z" />
+
    <path d="M7.40908 7.40909H6.22726V8.59091H7.40908V7.40909Z" />
+
    <path d="M8.59091 7.40909H7.40909V8.59091H8.59091V7.40909Z" />
+
    <path d="M9.77273 7.40909H8.59091V8.59091H9.77273V7.40909Z" />
+
    <path d="M12.1364 7.40909H10.9546V8.59091H12.1364V7.40909Z" />
+
    <path d="M13.3182 7.40909H12.1364V8.59091H13.3182V7.40909Z" />
+
    <path d="M2.68182 8.59091H1.5V9.77273H2.68182V8.59091Z" />
+
    <path d="M3.86364 8.59091H2.68182V9.77273H3.86364V8.59091Z" />
+
    <path d="M5.04547 8.59091H3.86365V9.77273H5.04547V8.59091Z" />
+
    <path d="M6.22726 8.59091H5.04544V9.77273H6.22726V8.59091Z" />
+
    <path d="M7.40908 8.59091H6.22726V9.77273H7.40908V8.59091Z" />
+
    <path d="M8.59091 8.59091H7.40909V9.77273H8.59091V8.59091Z" />
+
    <path d="M9.77273 8.59091H8.59091V9.77273H9.77273V8.59091Z" />
+
    <path d="M10.9546 8.59091H9.77274V9.77273H10.9546V8.59091Z" />
+
    <path d="M12.1364 8.59091H10.9546V9.77273H12.1364V8.59091Z" />
+
    <path d="M13.3182 8.59091H12.1364V9.77273H13.3182V8.59091Z" />
+
    <path d="M14.5 8.59091H13.3182V9.77273H14.5V8.59091Z" />
+
    <path d="M5.04547 9.77273H3.86365V10.9545H5.04547V9.77273Z" />
+
    <path d="M7.40908 9.77273H6.22726V10.9545H7.40908V9.77273Z" />
+
    <path d="M9.77273 9.77273H8.59091V10.9545H9.77273V9.77273Z" />
+
    <path d="M12.1364 9.77273H10.9546V10.9545H12.1364V9.77273Z" />
+
    <path d="M5.04547 10.9546H3.86365V12.1364H5.04547V10.9546Z" />
+
    <path d="M7.40908 10.9546H6.22726V12.1364H7.40908V10.9546Z" />
+
    <path d="M9.77273 10.9546H8.59091V12.1364H9.77273V10.9546Z" />
+
    <path d="M12.1364 10.9546H10.9546V12.1364H12.1364V10.9546Z" />
+
    <path d="M7.40908 12.1364H6.22726V13.3182H7.40908V12.1364Z" />
+
    <path d="M9.77273 12.1364H8.59091V13.3182H9.77273V12.1364Z" />
+
    <path d="M6.22726 13.3182H5.04544V14.5H6.22726V13.3182Z" />
+
    <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 === "more"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M9.33334 12C9.33334 11.4477 8.88563 11 8.33334 11C7.78106 11 7.33334 11.4477 7.33334 12C7.33334 12.5523 7.78106 13 8.33334 13C8.88563 13 9.33334 12.5523 9.33334 12ZM8.33334 7C8.88563 7 9.33334 7.44772 9.33334 8C9.33334 8.55228 8.88563 9 8.33334 9C7.78106 9 7.33334 8.55228 7.33334 8C7.33334 7.44772 7.78106 7 8.33334 7ZM8.33334 3C8.88563 3 9.33334 3.44772 9.33334 4C9.33334 4.55229 8.88563 5 8.33334 5C7.78106 5 7.33334 4.55229 7.33334 4C7.33334 3.44772 7.78106 3 8.33334 3Z" />
+
  {:else if name === "network"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M5.5 3a1 1 0 11-2 0 1 1 0 012 0zm1 0A2 2 0 015 4.937v2.126c.308.08.587.23.818.433L9.65 4.76A2 2 0 1112 5.937v5.126a2 2 0 11-2.42 1.375L5.95 10.38A2 2 0 114 7.063V4.937A2 2 0 116.5 3zm-.064 6.506l3.656 2.073c.248-.245.56-.426.908-.516V5.937a1.994 1.994 0 01-.751-.377L6.382 8.322a1.996 1.996 0 01.054 1.184zM3.5 9a1 1 0 112 0 1 1 0 01-2 0zm9-5a1 1 0 11-2 0 1 1 0 012 0zm-1 8a1 1 0 100 2 1 1 0 000-2z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "patch"}
+
    <path
+
      fill-rule="evenodd"
+
      d="M11.5 13a1 1 0 100-2 1 1 0 000 2zm0 1a2 2 0 100-4 2 2 0 000 4zM4.5 4c.276 0 .5.247.5.553v5.894c0 .306-.224.553-.5.553s-.5-.247-.5-.553V4.553C4 4.247 4.224 4 4.5 4zM9.565 3.146a.5.5 0 010 .708L7.92 5.5l1.646 1.646a.5.5 0 11-.707.708l-2-2a.5.5 0 010-.708l2-2a.5.5 0 01.707 0z"
+
      clip-rule="evenodd">
+
    </path>
+
    <path
+
      fill-rule="evenodd"
+
      d="M7.003 5.5a.5.5 0 00.5.5h3.32c.092 0 .166.075.166.167L11 10.32a.5.5 0 001 0l-.01-4.154c0-.645-.523-1.167-1.167-1.167h-3.32a.5.5 0 00-.5.5zM4.5 13a1 1 0 100-2 1 1 0 000 2zm0 1a2 2 0 100-4 2 2 0 000 4zM4.5 4a1 1 0 100-2 1 1 0 000 2zm0 1a2 2 0 100-4 2 2 0 000 4z"
+
      clip-rule="evenodd">
+
    </path>
+
  {:else if name === "plus"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8 2.83334C8.27614 2.83334 8.5 3.0572 8.5 3.33334V12.6667C8.5 12.9428 8.27614 13.1667 8 13.1667C7.72386 13.1667 7.5 12.9428 7.5 12.6667V3.33334C7.5 3.0572 7.72386 2.83334 8 2.83334Z" />
+
    <path
+
      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 === "user"}
+
    <path
+
      d="M12.8336 5.28766C12.1895 5.93182 3.99733 12.0026 3.99733 12.0026C3.99733 12.0026 10.0681 3.8105 10.7123 3.16634C11.3564 2.52219 12.3535 2.47487 12.9393 3.06066C13.5251 3.64645 13.4778 4.64351 12.8336 5.28766Z" />
+
    <path
+
      d="M6.30412 3.33188C5.94755 3.68845 2.7071 8.34312 2.7071 8.34312C2.7071 8.34312 7.36177 5.10266 7.71833 4.7461C8.0749 4.38953 8.04737 3.7839 7.65685 3.39337C7.26632 3.00285 6.66069 2.97532 6.30412 3.33188Z" />
+
    <path
+
      d="M11.2539 8.28164C10.8973 8.6382 7.65684 13.2929 7.65684 13.2929C7.65684 13.2929 12.3115 10.0524 12.6681 9.69585C13.0246 9.33929 12.9971 8.73365 12.6066 8.34313C12.2161 7.9526 11.6104 7.92507 11.2539 8.28164Z" />
+
  {:else}
+
    {unreachable(name)}
+
  {/if}
+
</svg>
modified src/components/InlineMarkdown.svelte
@@ -5,7 +5,7 @@
  import { twemoji } from "@app/lib/utils";

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

  const render = (content: string): string =>
    dompurify.sanitize(markdown.parseInline(content) as string);
@@ -14,17 +14,20 @@
<style>
  .markdown :global(code) {
    font-family: var(--font-family-monospace);
-
    color: var(--color-foreground-6);
-
    background-color: var(--color-foreground-3);
-
    border-radius: 0.5rem;
+
    background-color: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-tiny);
    padding: 0.125rem 0.25rem;
  }
+
  .markdown :global(strong) {
+
    font-weight: var(--font-weight-semibold);
+
  }
</style>

<span
  class="markdown"
  use:twemoji
  class:txt-medium={fontSize === "medium"}
+
  class:txt-regular={fontSize === "regular"}
  class:txt-small={fontSize === "small"}
  class:txt-tiny={fontSize === "tiny"}>
  {@html render(content)}
added src/components/KeyHint.svelte
@@ -0,0 +1,18 @@
+
<script lang="ts">
+
  export let styleDisplay: "inline-flex" | "flex" = "inline-flex";
+
</script>
+

+
<style>
+
  kbd {
+
    align-items: center;
+
    border-radius: var(--border-radius-tiny);
+
    border: 1px solid var(--color-border-default);
+
    font-family: var(--font-family-sans-serif);
+
    justify-content: center;
+
    padding: 0 0.25rem;
+
  }
+
</style>
+

+
<kbd style:display={styleDisplay}>
+
  <slot />
+
</kbd>
modified src/components/Link.svelte
@@ -5,6 +5,7 @@
  import { push, routeToPath, useDefaultNavigation } from "@app/lib/router";

  export let route: Route;
+
  export let disabled: boolean = false;
  export let title: string | undefined = undefined;

  const dispatch = createEventDispatcher<{
@@ -12,6 +13,11 @@
  }>();

  function navigateToRoute(event: MouseEvent): void {
+
    if (disabled) {
+
      event.preventDefault();
+
      return;
+
    }
+

    if (useDefaultNavigation(event)) {
      return;
    }
added src/components/List.svelte
@@ -0,0 +1,44 @@
+
<script lang="ts">
+
  type T = $$Generic;
+

+
  export let items: T[];
+
</script>
+

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

+
  .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;
+
    }
+
  }
+
</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>
modified src/components/LoadError.svelte
@@ -1,7 +1,6 @@
<script lang="ts">
-
  import { twemoji } from "@app/lib/utils";
-

-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Command from "./Command.svelte";
+
  import Icon from "./Icon.svelte";

  export let title: string;
  export let errorMessage: string;
@@ -10,20 +9,41 @@

<style>
  .wrapper {
-
    gap: 1rem;
+
    gap: 1.5rem;
  }

-
  .emoji {
+
  .container {
    display: flex;
-
    font-size: var(--font-size-xx-large);
+
    flex-direction: column;
+
    text-align: center;
+
    gap: 0.5rem;
+
  }
+

+
  .help {
+
    font-size: var(--font-size-small);
  }
</style>

<div class="wrapper layout-centered">
-
  <div class="emoji" use:twemoji>🏜️</div>
-
  <div class="title txt-medium txt-bold txt-highlight">
-
    {title}
+
  <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
+
      <a class="txt-link" href="https://radicle.zulipchat.com/" target="_blank">
+
        radicle.zulipchat.com
+
      </a>
+
    </div>
  </div>

-
  <ErrorMessage message={errorMessage} {stackTrace} />
+
  <div style:max-width="25rem">
+
    <Command
+
      command={JSON.stringify({ errorMessage, stackTrace })}
+
      fullWidth
+
      showPrompt={false} />
+
  </div>
</div>
modified src/components/Loading.svelte
@@ -43,7 +43,7 @@
  .spinner > div {
    width: 18px;
    height: 18px;
-
    background-color: var(--color-secondary);
+
    background-color: var(--color-fill-secondary);
    border-radius: var(--border-radius-round);
    display: inline-block;
    -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
@@ -154,10 +154,16 @@
      class:center
      class:margins
      class:condensed>
-
      <div class="bounce1" style="background-color: var(--color-secondary)" />
+
      <div
+
        class="bounce1"
+
        style="background-color: var(--color-fill-secondary)" />
      {#if !condensed}
-
        <div class="bounce2" style="background-color: var(--color-secondary)" />
-
        <div class="bounce3" style="background-color: var(--color-secondary)" />
+
        <div
+
          class="bounce2"
+
          style="background-color: var(--color-fill-secondary)" />
+
        <div
+
          class="bounce3"
+
          style="background-color: var(--color-fill-secondary)" />
      {/if}
    </div>
  </div>
modified src/components/Markdown.svelte
@@ -15,7 +15,7 @@
    canonicalize,
    isCommit,
  } from "@app/lib/utils";
-
  import { mimes } from "@app/lib/file";
+
  import { mimes, type Embed } from "@app/lib/file";

  export let content: string;
  // If present, resolve all relative links with respect to this URL
@@ -24,9 +24,7 @@
  export let rawPath: string | undefined = undefined;
  // If present, means we are in a preview context,
  // use this for image previews instead of /raw URLs.
-
  export let embeds:
-
    | Map<string, { name: string; content: string }>
-
    | undefined = undefined;
+
  export let embeds: Embed[] | undefined = undefined;

  $: doc = matter(content);
  $: frontMatter = Object.entries(doc.data).filter(
@@ -79,6 +77,24 @@
      }
    }

+
    // If the embed is a preview stored in-memory.
+
    for (const i of container.querySelectorAll("img")) {
+
      const imagePath = i.getAttribute("src");
+

+
      // If the image is an oid embed
+
      if (imagePath && isCommit(imagePath)) {
+
        const embed = embeds?.find(e => {
+
          return e.oid === imagePath;
+
        });
+
        if (embed) {
+
          const fileExtension = embed.name.split(".").pop();
+
          if (fileExtension) {
+
            i.setAttribute("src", embed.content);
+
          }
+
        }
+
      }
+
    }
+

    // Iterate over all images, and replace the source with a canonicalized URL
    // pointing at the projects /raw endpoint.
    if (rawPath) {
@@ -87,23 +103,15 @@

        // If the image is an oid embed
        if (imagePath && isCommit(imagePath)) {
-
          const embed = embeds?.get(imagePath);
-
          if (embed) {
-
            const fileExtension = embed.name.split(".").pop();
-
            if (fileExtension) {
-
              i.setAttribute("src", embed.content);
-
            }
-
          } else {
-
            const fileExtension = i.alt.split(".").pop();
-
            const url = new URL(rawPath);
-
            // If a user changes the alt text of an image,
-
            // the browser is still able to infer the mime type.
-
            if (fileExtension && fileExtension in mimes) {
-
              url.search = `?mime=${mimes[fileExtension]}`;
-
            }
-
            url.pathname = canonicalize(`blobs/${imagePath}`, url.pathname);
-
            i.setAttribute("src", url.toString());
+
          const fileExtension = i.alt.split(".").pop();
+
          const url = new URL(rawPath);
+
          // If a user changes the alt text of an image,
+
          // the browser is still able to infer the mime type.
+
          if (fileExtension && fileExtension in mimes) {
+
            url.search = `?mime=${mimes[fileExtension]}`;
          }
+
          url.pathname = canonicalize(`blobs/${imagePath}`, url.pathname);
+
          i.setAttribute("src", url.toString());
          continue;
        }

@@ -153,8 +161,7 @@
  .front-matter {
    font-size: var(--font-size-tiny);
    font-family: var(--font-family-monospace);
-
    color: var(--color-foreground);
-
    border: 1px dashed var(--color-foreground-4);
+
    border: 1px dashed var(--color-border-default);
    padding: 0.5rem;
    margin-bottom: 2rem;
  }
@@ -168,40 +175,31 @@
    padding-left: 0.5rem;
  }

-
  .markdown :global(h1),
-
  .markdown :global(h2),
-
  .markdown :global(h3),
-
  .markdown :global(h4),
-
  .markdown :global(h5),
-
  .markdown :global(h6) {
-
    color: var(--color-foreground);
-
  }
-

  .markdown :global(h1) {
    font-size: calc(var(--font-size-x-large) * 0.75);
-
    font-weight: var(--font-weight-medium);
+
    font-weight: var(--font-weight-semibold);
    padding: 1rem 0 0.5rem 0;
    margin: 0 0 0.75rem;
-
    border-bottom: 1px solid var(--color-foreground-4);
+
    border-bottom: 1px solid var(--color-border-hint);
  }

  .markdown :global(h2) {
    font-size: var(--font-size-medium);
-
    font-weight: var(--font-weight-normal);
+
    font-weight: var(--font-weight-regular);
    padding: 0.25rem 0;
    margin: 2rem 0 0.5rem;
-
    border-bottom: 1px dashed var(--color-foreground-4);
+
    border-bottom: 1px solid var(--color-border-hint);
  }

  .markdown :global(h3) {
    font-size: calc(var(--font-size-medium) * 0.9);
-
    font-weight: var(--font-weight-medium);
+
    font-weight: var(--font-weight-semibold);
    padding: 0.5rem 0;
    margin: 1rem 0 0.25rem;
  }

  .markdown :global(h4) {
-
    font-weight: var(--font-weight-medium);
+
    font-weight: var(--font-weight-semibold);
    font-size: var(--font-size-regular);
    padding: 0.5rem 0;
    margin: 1rem 0 0.125rem;
@@ -209,14 +207,14 @@

  .markdown :global(h5),
  .markdown :global(h6) {
-
    font-weight: var(--font-weight-medium);
+
    font-weight: var(--font-weight-semibold);
    font-size: var(--font-size-small);
    padding: 0.35rem 0;
    margin: 1rem 0 0.125rem;
  }

  .markdown :global(h6) {
-
    color: var(--color-foreground-6);
+
    color: var(--color-foreground-gray);
  }

  .markdown :global(p) {
@@ -230,14 +228,14 @@
  }

  .markdown :global(blockquote) {
-
    color: var(--color-foreground-6);
-
    border-left: 0.3rem solid var(--color-foreground-4);
+
    color: var(--color-foreground-gray);
+
    border-left: 0.3rem solid var(--color-fill-ghost);
    padding: 0 0 0 1rem;
    margin: 1rem 0 1rem 0;
  }

  .markdown :global(strong) {
-
    font-weight: var(--font-weight-medium);
+
    font-weight: var(--font-weight-semibold);
  }

  .markdown :global(.footnote-ref > a),
@@ -254,9 +252,8 @@
  .markdown :global(code) {
    font-family: var(--font-family-monospace);
    font-size: var(--font-size-small);
-
    color: var(--color-foreground-6);
-
    background-color: var(--color-foreground-2);
-
    border-radius: 0.5rem;
+
    background-color: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-tiny);
    padding: 0.125rem 0.25rem;
  }

@@ -268,7 +265,7 @@
  .markdown :global(pre) {
    font-family: var(--font-family-monospace);
    font-size: var(--font-size-regular);
-
    background-color: var(--color-foreground-2);
+
    background-color: var(--color-fill-ghost);
    padding: 1rem !important;
    border-radius: var(--border-radius-small);
    margin: 1rem 0;
@@ -284,11 +281,10 @@
  .markdown :global(a > code) {
    background: none;
    padding: 0;
-
    color: var(--color-foreground);
  }
  .markdown :global(a) {
    text-decoration: none;
-
    border-bottom: 1px solid var(--color-foreground-6);
+
    border-bottom: 1px solid var(--color-foreground-contrast);
  }
  .markdown :global(a.no-underline) {
    border-bottom: none;
@@ -300,7 +296,7 @@
    overflow: hidden;
    background: transparent;
    border: 0;
-
    border-bottom: 1px solid var(--color-foreground-4);
+
    border-bottom: 1px solid var(--color-border-hint);
  }

  .markdown :global(ol) {
@@ -334,17 +330,17 @@
    border-collapse: collapse;
    border-radius: 0.5rem;
    border-style: hidden;
-
    box-shadow: 0 0 0 1px var(--color-foreground-4);
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
    overflow: hidden;
  }
  .markdown :global(td) {
    text-align: left;
    text-overflow: ellipsis;
-
    border: 1px solid var(--color-foreground-4);
+
    border: 1px solid var(--color-border-hint);
    padding: 0.5rem 1rem;
  }
  .markdown :global(tr:nth-child(even)) {
-
    background-color: var(--color-foreground-2);
+
    background-color: var(--color-background-default);
  }
  .markdown :global(th) {
    text-align: center;
modified src/components/Modal.svelte
@@ -1,114 +1,72 @@
-
<script lang="ts" context="module">
-
  import type { ComponentProps } from "svelte";
-

-
  // When `primaryAction` is passed as a prop, render it.
-
  // When `primaryAction` is not passed as a prop, don't render anything.
-
  export type CloseAction =
-
    | Partial<{
-
        name: string;
-
        callback: () => void;
-
        props?: Partial<ComponentProps<Button>>;
-
      }>
-
    | undefined
-
    | false;
-

-
  // When `closeAction` is not passed as a prop, render default close action.
-
  // When `closeAction={false}`, don't show the close action at all.
-
  // When `closeAction={{ name: "Done" }}`, override one of the default close
-
  // action props.
-
  export type PrimaryAction =
-
    | {
-
        name: string;
-
        callback: () => void;
-
        props?: Partial<ComponentProps<Button>>;
-
      }
-
    | undefined;
-
</script>
-

<script lang="ts">
-
  import twemoji from "twemoji";
-

  import * as modal from "@app/lib/modal";
-
  import { base } from "@app/lib/router";

-
  import Button from "@app/components/Button.svelte";
+
  import Button from "./Button.svelte";

-
  export let emoji: string | undefined = undefined;
  export let title: string | undefined = undefined;
-

-
  export let primaryAction: PrimaryAction = undefined;
-
  export let closeAction: CloseAction = undefined;
+
  export let showCloseButton: boolean = false;
</script>

<style>
  .modal {
-
    padding: 2rem 3rem;
-
    border-radius: var(--border-radius);
+
    padding: 2rem;
+
    border-radius: var(--border-radius-regular);
    font-family: var(--font-family-sans-serif);
-
    background: var(--color-background);
-
    box-shadow: var(--elevation-high);
-
    min-width: 480px;
-
    max-width: 760px;
-
    text-align: center;
+
    background: var(--color-background-float);
+
    border: 1px solid var(--color-border-hint);
+
    box-shadow: var(--elevation-low);
+
    min-width: 34rem;
+
    max-width: 100vw;
+
    gap: 1.5rem;
+
    display: flex;
+
    flex-direction: column;
+
  }
+
  .icon {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
  }
  .title {
-
    color: var(--color-foreground);
-
    font-size: var(--font-size-medium);
-
    font-weight: var(--font-weight-bold);
-
    line-height: 2.625rem;
-
    margin-bottom: 0.5rem;
+
    font-size: var(--font-size-large);
+
    font-weight: var(--font-weight-semibold);
    text-align: center;
    text-overflow: ellipsis;
    overflow: hidden;
  }
  .subtitle {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-regular);
-
    max-width: 90%;
-
    margin: 0 auto;
-
    line-height: 1.5;
+
    font-size: var(--font-size-small);
+
    text-align: center;
  }
  .body {
-
    color: var(--color-foreground);
    font-size: var(--font-size-regular);
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    margin: 3rem 0;
+
    display: flex;
+
    justify-content: center;
  }
  .actions {
-
    gap: 1.5rem;
+
    margin-top: 1rem;
    display: flex;
    justify-content: center;
  }
-
  @media (max-width: 720px) {
-
    .modal {
-
      width: 90%;
-
      min-width: unset;
-
    }
-
  }
</style>

<div class="modal">
-
  {#if emoji}
-
    <div style:font-size="var(--font-size-xx-large)">
-
      {@html twemoji.parse(emoji, {
-
        base,
-
        folder: "twemoji",
-
        ext: ".svg",
-
        className: "txt-emoji",
-
      })}
+
  {#if $$slots.icon}
+
    <div class="icon">
+
      <slot name="icon" />
    </div>
  {/if}

-
  {#if title}
-
    <div class="title">{title}</div>
-
  {/if}
+
  <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+
    {#if title}
+
      <div class="title">{title}</div>
+
    {/if}

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

  {#if $$slots.body}
    <div class="body">
@@ -116,26 +74,9 @@
    </div>
  {/if}

-
  <div class="actions">
-
    {#if primaryAction}
-
      <Button
-
        style={$$slots.body ? "margin-top: 1rem;" : "margin-top: 3rem;"}
-
        autofocus
-
        variant="primary"
-
        {...primaryAction.props}
-
        on:click={primaryAction.callback}>
-
        {primaryAction.name}
-
      </Button>
-
    {/if}
-

-
    {#if closeAction !== false}
-
      <Button
-
        style={$$slots.body ? "margin-top: 1rem;" : "margin-top: 3rem;"}
-
        variant="foreground"
-
        {...closeAction?.props}
-
        on:click={closeAction?.callback ?? modal.hide}>
-
        {closeAction?.name ?? "Close"}
-
      </Button>
-
    {/if}
-
  </div>
+
  {#if showCloseButton}
+
    <div class="actions">
+
      <Button variant="outline" on:click={modal.hide}>Close</Button>
+
    </div>
+
  {/if}
</div>
added src/components/NodeId.svelte
@@ -0,0 +1,60 @@
+
<script lang="ts">
+
  import { formatNodeId } from "@app/lib/utils";
+

+
  import Avatar from "./Avatar.svelte";
+
  import HoverPopover from "./HoverPopover.svelte";
+
  import Clipboard from "./Clipboard.svelte";
+

+
  export let nodeId: string;
+
  export let alias: string | undefined = undefined;
+
  export let disableTooltip: boolean = false;
+
  export let large: boolean = false;
+

+
  export let styleColor: string | undefined = undefined;
+
</script>
+

+
<style>
+
  .avatar-alias {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    gap: 0.25rem;
+
    height: 1rem;
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-bold);
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-secondary);
+
  }
+
  .large {
+
    height: 1.25rem;
+
    gap: 0.5rem;
+
  }
+
  .node-id {
+
    height: 2.5rem;
+
    padding: 0 0.5rem 0 0.75rem;
+
    display: flex;
+
    align-items: center;
+
    white-space: nowrap;
+
  }
+
</style>
+

+
<HoverPopover
+
  disabled={disableTooltip}
+
  popoverPositionLeft="-4.5rem"
+
  popoverPositionTop="-4.5rem">
+
  <div slot="toggle" class="avatar-alias" style:color={styleColor} class:large>
+
    <Avatar {nodeId} />
+
    {#if alias}
+
      {alias}
+
    {:else}
+
      {formatNodeId(nodeId)}
+
    {/if}
+
  </div>
+

+
  <div slot="popover" class="node-id">
+
    <span class="global-hash">{formatNodeId(nodeId)}</span>
+
    <span style:color="var(--color-fill-secondary)">
+
      <Clipboard small text={nodeId} />
+
    </span>
+
  </div>
+
</HoverPopover>
modified src/components/Placeholder.svelte
@@ -1,33 +1,30 @@
<script lang="ts">
-
  import { twemoji } from "@app/lib/utils";
+
  import type { ComponentProps } from "svelte";

-
  export let emoji: string;
+
  import Icon from "./Icon.svelte";
+

+
  export let iconName: ComponentProps<Icon>["name"];
+
  export let iconSize: ComponentProps<Icon>["size"] = "48";
+
  export let caption: string;
+
  export let inline: boolean = false;
</script>

<style>
  .placeholder {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-foreground-5);
-
    overflow-x: hidden;
-
    padding: 1rem 1rem 2rem 1rem;
-
    text-align: center;
-
    text-overflow: ellipsis;
-
    word-wrap: break-word;
-
  }
-
  .placeholder header {
-
    padding: 1rem 0;
-
    font-weight: var(--font-weight-bold);
+
    align-items: center;
+
    color: var(--color-foreground-dim);
+
    display: flex;
+
    flex-direction: column;
+
    font-size: var(--font-size-small);
+
    gap: 0.5rem;
+
    justify-content: center;
  }
-
  .placeholder .emoji {
-
    margin-bottom: 1rem;
+
  .inline {
+
    flex-direction: row;
  }
</style>

-
<div class="placeholder">
-
  <header>
-
    <div class="emoji txt-large" use:twemoji>{emoji}</div>
-
    <slot name="title" />
-
  </header>
-
  <slot name="body" />
+
<div class="placeholder" class:inline>
+
  <Icon name={iconName} size={iconSize} />
+
  {caption}
</div>
added src/components/Popover.svelte
@@ -0,0 +1,81 @@
+
<script lang="ts" context="module">
+
  import { writable } from "svelte/store";
+
  const focused = writable<HTMLDivElement | undefined>(undefined);
+

+
  export function closeFocused() {
+
    focused.set(undefined);
+
  }
+
</script>
+

+
<script lang="ts">
+
  export let disabled = false;
+
  export let popoverBorderRadius: string | undefined = undefined;
+
  export let popoverPadding: string | undefined = undefined;
+
  export let popoverPositionBottom: string | undefined = undefined;
+
  export let popoverPositionLeft: string | undefined = undefined;
+
  export let popoverPositionRight: string | undefined = undefined;
+
  export let popoverPositionTop: string | undefined = undefined;
+
  export let popoverWidth: string | undefined = undefined;
+

+
  let expanded = false;
+
  let thisComponent: HTMLDivElement;
+

+
  function clickOutside(ev: MouseEvent | TouchEvent) {
+
    if (!$focused?.contains(ev.target as HTMLDivElement)) {
+
      closeFocused();
+
    }
+
  }
+

+
  function toggle() {
+
    if (!disabled) {
+
      expanded = !expanded;
+
      if ($focused === thisComponent) {
+
        closeFocused();
+
      } else {
+
        focused.set(thisComponent);
+
      }
+
    }
+
  }
+

+
  $: expanded = $focused === thisComponent;
+
</script>
+

+
<style>
+
  .popover {
+
    background: var(--color-background-float);
+
    border-radius: var(--border-radius-regular);
+
    border: 1px solid var(--color-border-hint);
+
    box-shadow: var(--elevation-low);
+
    padding: 1rem;
+
    position: absolute;
+
    z-index: 10;
+
  }
+
</style>
+

+
<svelte:window on:click={clickOutside} on:touchstart={clickOutside} />
+

+
<div bind:this={thisComponent} style:position="relative">
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    role="button"
+
    tabindex="0"
+
    on:click={toggle}
+
    style:user-select="none"
+
    style:cursor={disabled ? "not-allowed" : "pointer"}>
+
    <slot name="toggle" {expanded} />
+
  </div>
+

+
  {#if expanded}
+
    <div
+
      class="popover"
+
      style:bottom={popoverPositionBottom}
+
      style:left={popoverPositionLeft}
+
      style:right={popoverPositionRight}
+
      style:top={popoverPositionTop}
+
      style:padding={popoverPadding}
+
      style:border-radius={popoverBorderRadius}
+
      style:width={popoverWidth}>
+
      <slot name="popover" />
+
    </div>
+
  {/if}
+
</div>
modified src/components/ProjectCard.svelte
@@ -19,11 +19,11 @@
    flex-direction: row;
    justify-content: space-between;
    padding: 1rem;
-
    border: 1px solid var(--color-secondary-5);
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
    border-radius: var(--border-radius-small);
    min-width: 36rem;
    cursor: pointer;
-
    background: var(--color-background-1);
+
    background: var(--color-background-float);
  }
  .right {
    display: flex;
@@ -55,40 +55,34 @@
  .activity {
    width: 100%;
    max-width: 14rem;
+
    margin-top: 0.5rem;
  }
  .project:hover {
-
    border-color: var(--color-secondary);
-
    background-color: var(--color-secondary-1);
+
    box-shadow: 0 0 0 2px var(--color-border-focus);
  }
  .description {
-
    margin-bottom: 0.25rem;
-
    font-size: var(--font-size-tiny);
-
  }
-
  .stateHash {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-tiny);
-
    font-family: var(--font-family-monospace);
-
    min-height: 2rem;
-
    display: flex;
-
    align-items: center;
+
    font-size: var(--font-size-small);
  }
  .id {
    display: flex;
    justify-content: space-between;
-
    font-size: var(--font-size-regular);
+
    font-size: var(--font-size-medium);
    font-weight: var(--font-weight-medium);
-
    margin-bottom: 0.5rem;
  }
  .rid {
    visibility: hidden;
-
    color: var(--color-foreground-5);
-
    font-weight: var(--font-weight-normal);
+
    color: var(--color-fill-secondary);
    font-family: var(--font-family-monospace);
    font-size: var(--font-size-tiny);
  }
  .project:hover .rid {
    visibility: visible;
  }
+
  .text {
+
    display: flex;
+
    gap: 0.25rem;
+
    flex-direction: column;
+
  }
  @media (max-width: 720px) {
    .project {
      min-width: 0;
@@ -98,17 +92,18 @@

<div class="project" class:compact>
  <div class="left">
-
    <div class="id">
-
      <span class="name">{name}</span>
-
    </div>
-
    <div class="description" use:twemoji>{description}</div>
-
    <div class="stateHash">
-
      {#if compact}
-
        {formatCommit(head)}
-
      {:else}
-
        {head}
-
      {/if}
+
    <div class="text">
+
      <div class="id">{name}</div>
+
      <div class="description" use:twemoji>{description}</div>
+
      <div class="global-hash">
+
        {#if compact}
+
          {formatCommit(head)}
+
        {:else}
+
          {head}
+
        {/if}
+
      </div>
    </div>
+

    {#if compact}
      <div class="activity">
        <ActivityDiagram {activity} viewBoxHeight={70} />
added src/components/Radio.svelte
@@ -0,0 +1,25 @@
+
<script lang="ts">
+
  export let ariaLabel: string | undefined = undefined;
+
  export let outline: boolean = false;
+
</script>
+

+
<style>
+
  .radio {
+
    display: flex;
+
    border-radius: var(--border-radius-tiny);
+
    justify-content: center;
+
    align-items: center;
+
    overflow: hidden;
+
  }
+
  .outline {
+
    display: flex;
+
    border: 1px solid var(--color-fill-secondary);
+
    padding: 3px;
+
    border-radius: var(--border-radius-small);
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
<div aria-label={ariaLabel} class="radio" class:outline>
+
  <slot />
+
</div>
added src/components/RadworksLogo.svelte
@@ -0,0 +1,10 @@
+
<svg
+
  role="img"
+
  width="83"
+
  height="17"
+
  fill="currentColor"
+
  viewBox="0 0 83 17"
+
  xmlns="http://www.w3.org/2000/svg">
+
  <path
+
    d="M76.9886 6.09962H76.2884V6.79981H76.9886V6.09962ZM81.1878 12.3975H81.888V10.9971H81.1878V12.3975ZM81.1878 14.4962H76.9886V13.796H76.2884V13.0958H75.5882V11.6954H76.2884V10.9952H76.9886V11.6954H77.6888V12.3956H78.3889V13.0958H79.7893V12.3956H80.4895V10.9952H79.7893V10.295H79.0891V9.59485H77.6888V8.89466H76.9886V8.19447H76.2884V7.49428H75.5882V5.39561H76.2884V4.69542H76.9886V3.99523H81.1878V4.69542H81.888V6.79409H80.4876V6.09389H79.7874V5.3937H78.387V6.09389H77.6868V6.79409H78.387V7.49428H79.0872V8.19447H80.4876V8.89466H81.1878V9.59485H81.888V10.295H82.5882V13.0939H81.888V13.7941H81.1878V14.4943V14.4962ZM65.0815 13.796H65.7817V9.59676H67.8804V8.89657H65.7817V1.20019H65.0815V13.7979V13.796ZM66.4819 14.4962H64.3832V0.5H66.4819V8.19828H67.1821V7.49809H67.8823V6.7979H68.5825V6.09771H69.2826V5.39752H69.9828V4.69733H70.683V3.99714H73.4819V4.69733H72.7817V5.39752H72.0815V6.09771H71.3813V6.7979H70.6811V7.49809H69.9809V8.19828H69.2807V8.89847H68.5805V9.59866H69.2807V10.2989H69.9809V10.999H70.6811V11.6992H71.3813V12.3994H72.0815V13.0996H72.7817V13.7998H73.4819V14.5H70.683V13.7998H69.9828V13.0996H69.2826V12.3994H68.5825V11.6992H67.8823V10.999H67.1821V10.2989H66.4819V14.4981V14.4962ZM61.3077 5.39752V4.69733H59.9073V5.39752H61.3077ZM56.4083 13.796H57.1085V6.7979H57.8087V6.09771H57.1085V4.69733H56.4083V13.796ZM57.8087 14.4962H55.71V3.99905H57.8087V5.39943H58.5089V4.69924H59.2091V3.99905H62.0079V4.69924H62.7081V5.39943H63.4083V6.79981H62.7081V7.5H62.0079V6.79981H61.3077V6.09962H59.2091V6.79981H58.5089V7.5H57.8087V14.4981V14.4962ZM48.7596 13.796H50.16V13.0958H48.7596V13.796ZM48.7596 5.39752H50.16V4.69733H48.7596V5.39752ZM46.659 7.49618H45.9589V10.9952H46.659V7.49618ZM52.2587 10.9952H52.9588V7.49618H52.2587V10.9952ZM50.8583 14.4943H48.0594V13.7941H46.659V13.0939H45.9589V11.6935H45.2587V6.79409H45.9589V5.3937H46.659V4.69351H48.0594V3.99332H50.8583V4.69351H52.2587V5.3937H52.9588V6.79409H53.659V11.6935H52.9588V13.0939H52.2587V13.7941H50.8583V14.4943ZM48.0594 12.3956H50.8583V11.6954H51.5585V6.79599H50.8583V6.0958H48.0594V6.79599H47.3592V11.6954H48.0594V12.3956ZM32.3213 7.49618V6.79599H31.6212V7.49618H32.3213ZM42.1202 5.39752H42.8204V4.69733H42.1202V5.39752ZM41.42 7.49618H42.1202V6.79599H41.42V7.49618ZM31.6231 5.39752V4.69733H30.9229V5.39752H31.6231ZM40.7217 10.2969H41.4219V8.89657H40.7217V10.2969ZM38.6212 13.796H40.0215V13.0958H38.6212V13.796ZM33.0215 10.2969V8.89657H32.3213V10.2969H33.0215ZM40.0196 12.3956H40.7198V10.9952H40.0196V12.3956ZM33.7217 12.3956V10.9952H33.0215V12.3956H33.7217ZM33.7217 13.796H35.1221V13.0958H33.7217V13.796ZM37.2208 8.89657H36.5206V10.2969H37.2208V8.89657ZM37.2208 8.19638H37.921V10.295H38.6212V12.3937H39.3213V10.295H40.0215V8.19638H40.7217V6.09771H41.4219V3.99905H43.5225V6.09771H42.8223V8.19638H42.1221V10.9952H41.4219V13.0939H40.7217V14.4943H37.9229V13.0939H37.2227V10.9952H36.5225V13.0939H35.8223V14.4943H33.0234V13.0939H32.3233V10.9952H31.6231V8.19638H30.9229V6.09771H30.2227V3.99905H32.3233V6.09771H33.0234V8.19638H33.7236V10.295H34.4238V12.3937H35.124V10.295H35.8242V8.19638H36.5244V6.79599H37.2246V8.19638H37.2208ZM21.9807 14.4943V13.8113H20.6146V13.1282H19.9316V11.7622H19.2486V6.97724H19.9316V5.6112H20.6146V4.92818H21.9807V4.24516H24.7146V4.92818H25.3977V5.6112H26.0807V0.828155H28.1316V14.4981H26.0807V13.1321H25.3977V13.8151H24.7146V14.4981H21.9807V14.4943ZM24.7146 12.4433V11.7603H25.3977V11.0773H26.0807V7.66026H25.3977V6.97724H24.7146V6.29422H21.9807V6.97724H21.2976V11.7622H21.9807V12.4452H24.7146V12.4433ZM11.1954 14.4943V13.8113H9.82938V13.1282H9.14636V11.7622H8.46334V6.97724H9.14636V5.6112H9.82938V4.92818H11.1954V4.24516H13.9294V4.92818H14.6124V5.6112H15.2955V4.24516H17.3464V14.4962H15.2955V13.1301H14.6124V13.8132H13.9294V14.4962H11.1954V14.4943ZM13.9294 12.4433V11.7603H14.6124V11.0773H15.2955V7.66026H14.6124V6.97724H13.9294V6.29422H11.1954V6.97724H10.5124V11.7622H11.1954V12.4452H13.9294V12.4433ZM0 14.4962V4.24516H2.05097V5.6112H2.73399V4.92818H3.41701V4.24516H6.151V4.92818H6.83402V5.6112H7.51704V6.97724H6.83402V7.66026H6.151V6.97724H5.46798V6.29422H3.41701V6.97724H2.73399V7.66026H2.05097V14.4943H0V14.4962Z" />
+
</svg>
modified src/components/ReactionSelector.svelte
@@ -3,6 +3,10 @@

  import config from "@app/config.json";

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

  export let nid: string;
  export let reactions: Map<string, string[]>;

@@ -15,10 +19,7 @@
  .selector {
    display: flex;
    align-items: center;
-
    background-color: var(--color-background-1);
-
    border-radius: var(--border-radius-small);
-
    border: 1px solid var(--color-foreground-3);
-
    box-shadow: var(--elevation-low);
+
    border-radius: var(--border-radius-tiny);
    padding: 0.2rem;
    gap: 0.2rem;
  }
@@ -29,26 +30,36 @@
  }
  .selector button.active {
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-background);
+
    background-color: var(--color-fill-ghost);
  }
  .selector button:hover {
    cursor: pointer;
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-background);
+
    background-color: var(--color-fill-ghost);
  }
</style>

-
<div class="selector">
-
  {#each config.reactions as reaction}
-
    <button
-
      class:active={reactions.get(reaction)?.includes(nid)}
-
      on:click={() => {
-
        dispatch("select", {
-
          nids: reactions.get(reaction) ?? [],
-
          reaction,
-
        });
-
      }}>
-
      {reaction}
-
    </button>
-
  {/each}
-
</div>
+
<Popover
+
  popoverPositionBottom="2rem"
+
  popoverPositionLeft="0"
+
  popoverPadding="0">
+
  <IconButton slot="toggle" title="toggle-reaction-popover">
+
    <IconSmall name="face" />
+
  </IconButton>
+

+
  <div class="selector" slot="popover">
+
    {#each config.reactions as reaction}
+
      <button
+
        class:active={reactions.get(reaction)?.includes(nid)}
+
        on:click={() => {
+
          dispatch("select", {
+
            nids: reactions.get(reaction) ?? [],
+
            reaction,
+
          });
+
          closeFocused();
+
        }}>
+
        {reaction}
+
      </button>
+
    {/each}
+
  </div>
+
</Popover>
modified src/components/Reactions.svelte
@@ -1,10 +1,10 @@
<script lang="ts" strictEvents>
  import { createEventDispatcher } from "svelte";

-
  import Chip from "@app/components/Chip.svelte";
-
  import { httpdStore } from "@app/lib/httpd";
+
  import IconButton from "./IconButton.svelte";

  export let reactions: Map<string, string[]>;
+
  export let clickable: boolean = false;

  const dispatch = createEventDispatcher<{
    remove: { nids: string[]; reaction: string };
@@ -22,35 +22,20 @@
    flex-direction: row;
    gap: 0.5rem;
  }
-
  .close {
-
    color: inherit;
-
    border: none;
-
    border-bottom-right-radius: var(--border-radius);
-
    border-top-right-radius: var(--border-radius);
-
    background-color: transparent;
-
    line-height: 1.5;
-
    padding: 0;
-
    cursor: pointer;
-
  }
-
  .close:hover {
-
    color: var(--color-foreground);
-
  }
</style>

<div class="reactions">
  {#each reactions as [reaction, nids]}
-
    <Chip actionable={Boolean($httpdStore.state === "authenticated")}>
-
      <div slot="content" class="reaction txt-tiny">
+
    <IconButton
+
      on:click={() => {
+
        if (clickable) {
+
          dispatch("remove", { nids, reaction });
+
        }
+
      }}>
+
      <div class="reaction txt-tiny">
        <span>{reaction}</span>
        <span title={nids.join("\n")}>{nids.length}</span>
      </div>
-
      <div slot="icon">
-
        <button
-
          class="close"
-
          on:click={() => dispatch("remove", { nids, reaction })}>
-
-
        </button>
-
      </div>
-
    </Chip>
+
    </IconButton>
  {/each}
</div>
deleted src/components/SquareButton.svelte
@@ -1,81 +0,0 @@
-
<script lang="ts" strictEvents>
-
  export let active: boolean = false;
-
  export let clickable: boolean = false;
-
  export let disabled: boolean = false;
-
  export let hoverable: boolean = true;
-
  export let size: "small" | "regular" = "regular";
-
  export let title: string | undefined = undefined;
-
</script>
-

-
<style>
-
  .square-button {
-
    display: flex;
-
    align-items: center;
-
    height: 2rem;
-
    padding: 0 0.75rem;
-
    background: var(--color-foreground-1);
-
    border: none;
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-foreground);
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    white-space: nowrap;
-
    gap: 0.5rem;
-
  }
-

-
  .small {
-
    height: var(--button-tiny-height);
-
  }
-

-
  .clickable {
-
    cursor: pointer;
-
    user-select: none;
-
  }
-

-
  .active {
-
    color: var(--color-background);
-
    background: var(--color-foreground);
-
    background-color: var(--color-foreground);
-
  }
-

-
  .active:hover {
-
    background-color: var(--color-foreground);
-
  }
-

-
  .hoverable:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-

-
  .active.hoverable:hover {
-
    background-color: var(--color-foreground);
-
  }
-

-
  .disabled {
-
    cursor: not-allowed;
-
    color: var(--color-foreground-5);
-
  }
-
  .disabled.active {
-
    color: var(--color-background);
-
  }
-
  .disabled:hover {
-
    background: var(--color-foreground-1);
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div
-
  {title}
-
  role="button"
-
  tabindex="0"
-
  on:click
-
  class="square-button"
-
  class:active
-
  class:hoverable
-
  class:disabled
-
  class:small={size === "small"}
-
  class:clickable>
-
  <slot name="icon" />
-
  <div style:display="block">
-
    <slot />
-
  </div>
-
</div>
modified src/components/TextInput.svelte
@@ -3,21 +3,22 @@
  import { createEventDispatcher } from "svelte";
  import { onMount } from "svelte";

-
  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import KeyHint from "@app/components/KeyHint.svelte";
  import Loading from "@app/components/Loading.svelte";

  export let name: string | undefined = undefined;
  export let placeholder: string | undefined = undefined;
  export let value: string | undefined = undefined;

-
  export let variant: "regular" | "form" | "modal" = "regular";
  export let size: "regular" | "small" = "regular";

  export let autofocus: boolean = false;
  export let disabled: boolean = false;
  export let loading: boolean = false;
-
  export let valid: boolean = false;
+
  export let valid: boolean = true;
  export let validationMessage: string | undefined = undefined;
+
  export let showKeyHint: boolean = true;

  const dispatch = createEventDispatcher<{
    blur: FocusEvent;
@@ -71,115 +72,127 @@
    margin: 0;
    position: relative;
    flex: 1;
+
    align-items: center;
    height: var(--button-regular-height);
+
    background: var(--color-background-dip);
+
    font-size: var(--font-size-small);
  }
  input {
-
    background: transparent;
-
    border-radius: var(--border-radius-round);
-
    color: var(--color-foreground);
-
    font-size: inherit;
-
    font-family: var(--font-family-sans-serif);
-
    height: var(--button-regular-height);
+
    background: var(--color-background-dip);
+
    color: var(--color-foreground-contrast);
+
    font-family: var(--font-family-monospace);
+
    border: 1px solid var(--color-border-hint);
    line-height: 1.6;
-
    margin: 0;
    outline: none;
    text-overflow: ellipsis;
    width: 100%;
+
    height: 100%;
+
    padding-left: 0.75rem;
+
    margin: 0;
  }
  input::placeholder {
-
    color: var(--color-secondary);
+
    font-family: var(--font-family-sans-serif);
+
    color: var(--color-foreground-dim);
    opacity: 1 !important;
  }
-
  input[disabled] {
-
    color: var(--color-secondary);
-
    cursor: not-allowed;
-
  }
-
  .regular {
-
    border: 1px solid var(--color-secondary);
-
    padding: 1rem 1.5rem;
+
  input:hover:not(.invalid) {
+
    border: 1px solid var(--color-border-default);
  }
-
  .form,
-
  .modal {
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
    border: 1px solid var(--color-foreground-1);
+
  input:hover:not(.invalid) + .right-container {
+
    border-top: 1px solid var(--color-border-default);
+
    border-right: 1px solid var(--color-border-default);
+
    border-bottom: 1px solid var(--color-border-default);
+
    color: var(--color-fill-contrast);
  }
-
  .form::placeholder,
-
  .modal::placeholder {
-
    color: var(--color-foreground-5);
+
  input:focus:not(.invalid) + .right-container {
+
    border-top: 1px solid var(--color-fill-secondary);
+
    border-right: 1px solid var(--color-fill-secondary);
+
    border-bottom: 1px solid var(--color-fill-secondary);
+
    color: var(--color-fill-contrast);
  }
-
  .form:focus,
-
  .form:hover,
-
  .modal:focus,
-
  .modal:hover {
-
    border: 1px solid var(--color-foreground-4);
+
  input:focus:not(.invalid) {
+
    border: 1px solid var(--color-fill-secondary);
  }
-
  .modal {
-
    background: var(--color-background);
+
  input[disabled] {
+
    cursor: not-allowed;
  }
  .left-container {
-
    color: var(--color-secondary);
+
    color: var(--color-fill-secondary);
    position: absolute;
    left: 0;
    top: 0;
    display: flex;
    align-items: center;
-
    height: var(--button-regular-height);
    padding-right: 0.5rem;
    padding-left: 0.5rem;
    gap: 0.5rem;
+
    height: 100%;
  }
  .right-container {
-
    color: var(--color-secondary);
+
    border: 1px solid transparent;
+
    color: var(--color-fill-gray);
    position: absolute;
    right: 0;
    top: 0;
    display: flex;
    align-items: center;
-
    height: var(--button-regular-height);
-
    padding-right: 1rem;
    padding-left: 0.5rem;
-
    gap: 0.5rem;
-
  }
-
  .small {
-
    height: var(--button-small-height);
-
    font-size: var(--font-size-small);
+
    overflow: hidden;
+
    height: 100%;
  }
  .validation-message {
-
    color: var(--color-negative);
-
    font-size: var(--font-size-small);
-
    margin-left: 1rem;
+
    color: var(--color-foreground-red);
    position: relative;
    margin-top: 0.5rem;
  }
  .validation-wrapper {
    position: absolute;
    width: 100%;
+
    height: 100%;
+
  }
+
  .invalid {
+
    border: 1px solid var(--color-border-error);
  }

-
  .key-hint {
-
    color: var(--color-foreground-6);
-
    background-color: var(--color-secondary-1);
-
    border: 1px solid var(--color-secondary-5);
-
    border-radius: 6px;
-
    box-shadow: inset 0 -3px 0 var(--color-secondary-5);
-
    padding: 0 5px;
+
  .small {
+
    height: var(--button-small-height);
+
  }
+
  .small input {
+
    border-radius: var(--border-radius-tiny);
+
  }
+
  .small .right-container {
+
    border-top-right-radius: var(--border-radius-tiny);
+
    border-bottom-right-radius: var(--border-radius-tiny);
+
    gap: 0.25rem;
+
  }
+

+
  .regular {
+
    height: var(--button-regular-height);
+
  }
+
  .regular input {
+
    border-radius: var(--border-radius-small);
+
  }
+
  .regular .right-container {
+
    border-top-right-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
    padding-right: 0.5rem;
+
    gap: 0.5rem;
  }
</style>

-
<div class="wrapper" class:small={size === "small"}>
+
<div
+
  class="wrapper"
+
  class:small={size === "small"}
+
  class:regular={size === "regular"}>
  <div class="validation-wrapper">
-
    <div class="left-container" bind:clientWidth={leftContainerWidth}>
-
      {#if $$slots.left}
+
    {#if $$slots.left}
+
      <div class="left-container" bind:clientWidth={leftContainerWidth}>
        <slot name="left" />
-
      {/if}
-
    </div>
+
      </div>
+
    {/if}

    <input
-
      class:regular={variant === "regular"}
-
      class:form={variant === "form"}
-
      class:modal={variant === "modal"}
-
      class:small={size === "small"}
+
      class:invalid={!valid && value}
      style:padding-left={leftContainerWidth
        ? `${leftContainerWidth}px`
        : "auto"}
@@ -202,28 +215,29 @@
    <div
      class="right-container"
      class:small={size === "small"}
-
      style:padding-right={variant === "modal" ? "0.5rem" : "1rem"}
      bind:clientWidth={rightContainerWidth}>
-
      {#if $$slots.right}
-
        <slot name="right" />
-
      {/if}
-

      {#if loading}
        <Loading small noDelay />
      {/if}

-
      {#if valid && !loading && isFocused}
+
      {#if valid && !loading && isFocused && showKeyHint}
        {#if success}
-
          <Icon name="checkmark" size="small" />
+
          <IconSmall name="checkmark" />
        {:else}
-
          <div class="key-hint">⏎</div>
+
          <KeyHint>⏎</KeyHint>
        {/if}
      {/if}
+

+
      {#if $$slots.right}
+
        <slot name="right" />
+
      {/if}
    </div>

-
    {#if validationMessage}
+
    {#if !valid && validationMessage}
      <div class="validation-message">
-
        {validationMessage}
+
        <div style="display: flex; align-items: center; gap: 0.25rem;">
+
          <IconSmall name="exclamation-circle" />{validationMessage}
+
        </div>
      </div>
    {/if}
  </div>
modified src/components/Textarea.svelte
@@ -60,10 +60,10 @@

<style>
  textarea {
-
    background-color: var(--color-foreground-1);
-
    border: 1px solid var(--color-foreground-1);
-
    color: var(--color-foreground);
-
    border-radius: 0.5rem;
+
    background-color: var(--color-background-dip);
+
    border: 1px solid var(--color-border-hint);
+
    color: var(--color-foreground-default);
+
    border-radius: var(--border-radius-small);
    font-family: inherit;
    height: 5rem;
    padding: 1rem;
@@ -94,17 +94,16 @@
  }

  textarea::placeholder {
-
    color: var(--color-foreground-5);
+
    color: var(--color-foreground-dim);
+
  }
+
  textarea:focus {
+
    border: 1px solid var(--color-border-default);
  }
-

-
  textarea:focus,
  textarea:hover {
-
    border: 1px solid var(--color-foreground-4);
+
    border: 1px solid var(--color-border-default);
  }
-
  .caption {
-
    color: var(--color-foreground-4);
-
    margin-left: 0.75rem;
-
    text-align: left;
+
  textarea:focus {
+
    border: 1px solid var(--color-fill-secondary);
  }
</style>

@@ -121,7 +120,3 @@
  on:drop
  on:keydown|stopPropagation={handleKeydown}
  on:keypress />
-

-
<div class="caption txt-small">
-
  Markdown supported. Press {isMac() ? "⌘" : "ctrl"}↵ to comment.
-
</div>
modified src/components/Thread.svelte
@@ -1,67 +1,25 @@
<script lang="ts" strictEvents>
  import type { Comment } from "@httpd-client";

-
  import Button from "@app/components/Button.svelte";
-
  import CommentComponent from "@app/components/Comment.svelte";
-
  import Textarea from "@app/components/Textarea.svelte";
  import { createEventDispatcher, tick } from "svelte";
-
  import { scrollIntoView } from "@app/lib/utils";
  import { httpdStore } from "@app/lib/httpd";
-
  import { embed } from "@app/lib/file";
+
  import * as utils from "@app/lib/utils";
+

+
  import CommentComponent from "@app/components/Comment.svelte";
+
  import CommentTextarea from "./CommentTextarea.svelte";
+
  import IconSmall from "./IconSmall.svelte";

-
  export let newEmbeds: { name: string; content: string }[] = [];
-
  export let selectionStart = 0;
-
  export let selectionEnd = 0;
  export let thread: { root: Comment; replies: Comment[] };
  export let rawPath: string;
-
  export let showReplyTextarea = false;
-

-
  let replyText = "";
-

-
  function handleFileDrop(event: DragEvent) {
-
    event.preventDefault();
-
    if (event.dataTransfer) {
-
      const embeds = Array.from(event.dataTransfer.files).map(embed);
-
      void Promise.all(embeds).then(embeds =>
-
        embeds.forEach(embed => {
-
          newEmbeds.push({ name: embed.name, content: embed.content });
-
          const embedText = `![${embed.name}](${embed.oid})\n`;
-
          replyText = replyText
-
            .slice(0, selectionStart)
-
            .concat(embedText, replyText.slice(selectionEnd));
-
          selectionStart += embedText.length;
-
          selectionEnd = selectionStart;
-
        }),
-
      );
-
    }
-
  }
-

-
  function cancel() {
-
    showReplyTextarea = false;
-
    scrollIntoView(root.id, {
-
      behavior: "smooth",
-
      block: "center",
-
    });
-
  }
+
  export let enableAttachments: boolean;

  async function toggleReply() {
-
    replyText = "";
-
    showReplyTextarea = !showReplyTextarea;
    // This tick allows the DOM to update before scrolling.
    await tick();
-
    if (showReplyTextarea) {
-
      scrollIntoView(`reply-${root.id}`, {
-
        behavior: "smooth",
-
        block: "center",
-
      });
-
    }
-
  }
-

-
  function reply() {
-
    dispatch("reply", { id: root.id, embeds: newEmbeds, body: replyText });
-
    replyText = "";
-
    newEmbeds = [];
-
    showReplyTextarea = false;
+
    utils.scrollIntoView(`reply-${root.id}`, {
+
      behavior: "smooth",
+
      block: "center",
+
    });
  }

  const dispatch = createEventDispatcher<{
@@ -82,25 +40,29 @@
  .comments {
    display: flex;
    flex-direction: column;
-
    gap: 1rem;
-
  }
-
  .comment {
-
    background-color: var(--color-foreground-1);
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
    border-radius: var(--border-radius-small);
  }
-
  .reply {
-
    margin-left: 1.5rem;
+
  .top-level-comment {
+
    background-color: var(--color-background-float);
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    border-top-left-radius: var(--border-radius-small);
+
    border-top-right-radius: var(--border-radius-small);
  }
-
  .actions {
-
    display: flex;
-
    justify-content: flex-end;
-
    gap: 1rem;
-
    margin-bottom: 1rem;
+
  .replies {
+
    margin-left: 1rem;
+
  }
+
  .reply {
+
    padding: 1rem;
  }
</style>

<div class="comments">
-
  <div class="comment">
+
  <div
+
    class="top-level-comment"
+
    style:border-bottom={replies.length > 0
+
      ? "1px solid var(--color-fill-separator)"
+
      : undefined}>
    <CommentComponent
      {rawPath}
      id={root.id}
@@ -109,45 +71,42 @@
      reactions={root.reactions}
      timestamp={root.timestamp}
      body={root.body}
-
      showReplyIcon={Boolean($httpdStore.state === "authenticated")}
-
      on:toggleReply={toggleReply}
-
      on:react />
+
      on:react>
+
      <IconSmall name="chat" slot="icon" />
+
    </CommentComponent>
  </div>
-
  {#each replies as reply}
-
    <div class="comment reply">
-
      <CommentComponent
-
        {rawPath}
-
        id={reply.id}
-
        authorId={reply.author.id}
-
        authorAlias={reply.author.alias}
-
        caption="replied"
-
        reactions={reply.reactions}
-
        timestamp={reply.timestamp}
-
        body={reply.body}
-
        on:react />
+
  {#if replies.length > 0}
+
    <div class="replies">
+
      {#each replies as reply}
+
        <CommentComponent
+
          {rawPath}
+
          id={reply.id}
+
          authorId={reply.author.id}
+
          authorAlias={reply.author.alias}
+
          caption="replied"
+
          isReply
+
          isLastReply={replies[replies.length - 1] === reply}
+
          reactions={reply.reactions}
+
          timestamp={reply.timestamp}
+
          body={reply.body}
+
          on:react />
+
      {/each}
    </div>
-
  {/each}
-
  {#if showReplyTextarea}
+
  {/if}
+
  {#if $httpdStore.state === "authenticated"}
    <div id={`reply-${root.id}`} class="reply">
-
      <Textarea
-
        resizable
-
        focus={showReplyTextarea}
-
        bind:value={replyText}
-
        on:submit={reply}
-
        on:drop={handleFileDrop}
-
        bind:selectionStart
-
        bind:selectionEnd
-
        placeholder="Leave your reply" />
-
      <div class="actions">
-
        <Button variant="text" size="small" on:click={cancel}>Dismiss</Button>
-
        <Button
-
          variant="secondary"
-
          size="small"
-
          disabled={!replyText}
-
          on:click={reply}>
-
          Reply
-
        </Button>
-
      </div>
+
      <CommentTextarea
+
        inline
+
        placeholder="Reply to comment"
+
        on:click={toggleReply}
+
        {enableAttachments}
+
        on:submit={async event => {
+
          dispatch("reply", {
+
            id: root.id,
+
            embeds: event.detail.embeds,
+
            body: event.detail.comment,
+
          });
+
        }} />
    </div>
  {/if}
</div>
deleted src/components/ToggleSwitch.svelte
@@ -1,55 +0,0 @@
-
<script lang="ts">
-
  // Is not as good as crypto.randomUUID() but we need some kind of fallback
-
  const id = self.crypto.randomUUID
-
    ? self.crypto.randomUUID()
-
    : new Date().getTime().toString();
-

-
  export let checked: boolean;
-
</script>
-

-
<style>
-
  .toggle input[type="checkbox"] {
-
    display: none;
-
  }
-

-
  .toggle label {
-
    background-color: var(--color-background-1);
-
    border: 1px solid var(--color-foreground-6);
-
    border-radius: var(--border-radius-round);
-
    cursor: pointer;
-
    display: block;
-
    position: relative;
-
    transition: transform ease-in-out 0.2s;
-
    width: 2.5rem;
-
    height: 1.5rem;
-
  }
-

-
  .toggle label::after {
-
    background-color: var(--color-foreground-6);
-
    border-radius: var(--border-radius-round);
-
    content: " ";
-
    cursor: pointer;
-
    display: inline-block;
-
    position: absolute;
-
    left: 3px;
-
    top: 3px;
-
    transition: transform ease-in-out 0.2s;
-
    width: 1rem;
-
    height: 1rem;
-
  }
-

-
  .toggle input[type="checkbox"]:checked ~ label {
-
    background-color: var(--color-background-1);
-
    border-color: var(--color-foreground-6);
-
  }
-

-
  .toggle input[type="checkbox"]:checked ~ label::after {
-
    background-color: var(--color-foreground-6);
-
    transform: translateX(15px);
-
  }
-
</style>
-

-
<div class="toggle">
-
  <input type="checkbox" bind:checked on:change {id} />
-
  <label for={id} />
-
</div>
modified src/lib/file.ts
@@ -1,3 +1,9 @@
+
export interface Embed {
+
  oid: string;
+
  name: string;
+
  content: string;
+
}
+

async function parseGitOid(bytes: Uint8Array): Promise<string> {
  // Create the header
  const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
modified src/lib/httpd.ts
@@ -7,6 +7,7 @@ import { config } from "@app/lib/config";
export interface Session {
  id: string;
  publicKey: string;
+
  alias: string;
}

export type HttpdState =
@@ -55,9 +56,14 @@ export async function authenticate(params: {
        sig: params.signature,
        pk: params.publicKey,
      });
+
      const sess = await api.session.getById(params.id);
      update({
        state: "authenticated",
-
        session: { id: params.id, publicKey: params.publicKey },
+
        session: {
+
          id: params.id,
+
          publicKey: params.publicKey,
+
          alias: sess.alias,
+
        },
      });
      return true;
    } catch (error) {
modified src/lib/pluralize.ts
@@ -5,6 +5,7 @@ export const pluralRules = {
  file: "files",
  insertion: "insertions",
  issue: "issues",
+
  node: "nodes",
  patch: "patches",
  remote: "remotes",
} as const;
modified src/lib/shared.ts
@@ -1,4 +1,4 @@
// This file is shared between tests and the UI code. It should only contain
// basic JS primitives, so that it works in both browser and node environments.

-
export const searchPlaceholder = "Enter an RID…";
+
export const searchPlaceholder = "Enter an RID";
modified src/lib/utils.ts
@@ -121,6 +121,10 @@ export function parseUsername(input: string): string {
  return parts[parts.length - 1];
}

+
export function absoluteTimestamp(time: number | undefined) {
+
  return time ? new Date(time * 1000).toString() : undefined;
+
}
+

export const formatTimestamp = (
  timestamp: number,
  current = new Date().getTime(),
added src/modals/AuthenticatedModal.svelte
@@ -0,0 +1,10 @@
+
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
</script>
+

+
<Modal title="Successfully authenticated" showCloseButton>
+
  <Icon name="review" size="48" slot="icon" />
+

+
  <div slot="subtitle">You're now connected to your local Radicle node.</div>
+
</Modal>
added src/modals/AuthenticationErrorModal.svelte
@@ -0,0 +1,15 @@
+
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  export let title: string;
+
  export let subtitle: string[];
+
</script>
+

+
<Modal {title} showCloseButton>
+
  <Icon name="alert" size="48" slot="icon" />
+

+
  <div slot="subtitle">
+
    {@html subtitle.join("<br />")}
+
  </div>
+
</Modal>
added src/modals/ColorPaletteModal.svelte
@@ -0,0 +1,155 @@
+
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+

+
  function extractCssVariables(variableName: string) {
+
    return Array.from(document.styleSheets)
+
      .filter(
+
        sheet =>
+
          sheet.href === null || sheet.href.startsWith(window.location.origin),
+
      )
+
      .reduce<string[]>(
+
        (acc, sheet) =>
+
          (acc = [
+
            ...acc,
+
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
            // @ts-ignore
+
            ...Array.from(sheet.cssRules).reduce(
+
              (def, rule) =>
+
                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                // @ts-ignore
+
                (def =
+
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                  // @ts-ignore
+
                  rule.selectorText === ":root"
+
                    ? [
+
                        ...def,
+
                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                        // @ts-ignore
+
                        ...Array.from(rule.style).filter(name =>
+
                          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                          // @ts-ignore
+
                          name.startsWith(variableName),
+
                        ),
+
                      ]
+
                    : def),
+
              [],
+
            ),
+
          ]),
+
        [],
+
      );
+
  }
+

+
  // rg "\--color-\w*-\w*" -o --no-line-number --no-filename -g "\!public/colors.css" -g "\!ColorPaletteModal.svelte" | sort | uniq | jq -sRM 'split("\n")[:-1]'
+
  const usedColors = [
+
    "--color-background-default",
+
    "--color-background-dip",
+
    "--color-background-float",
+
    "--color-border-default",
+
    "--color-border-error",
+
    "--color-border-focus",
+
    "--color-border-hint",
+
    "--color-border-match",
+
    "--color-border-merged",
+
    "--color-fill-contrast",
+
    "--color-fill-diff",
+
    "--color-fill-float",
+
    "--color-fill-ghost",
+
    "--color-fill-gray",
+
    "--color-fill-merged",
+
    "--color-fill-primary",
+
    "--color-fill-secondary",
+
    "--color-fill-separator",
+
    "--color-fill-success",
+
    "--color-fill-yellow",
+
    "--color-foreground-black",
+
    "--color-foreground-contrast",
+
    "--color-foreground-default",
+
    "--color-foreground-dim",
+
    "--color-foreground-disabled",
+
    "--color-foreground-emphasized",
+
    "--color-foreground-gray",
+
    "--color-foreground-match",
+
    "--color-foreground-red",
+
    "--color-foreground-success",
+
    "--color-foreground-white",
+
    "--color-foreground-yellow",
+
    "--color-prettylights-syntax",
+
  ];
+

+
  const colors = extractCssVariables("--color").filter(c => {
+
    return !c.startsWith("--color-prettylights-syntax");
+
  });
+

+
  const colorGroups = [
+
    ...new Set(
+
      colors.map(color => {
+
        const match = color.match(/--color-(\w*)-?/);
+
        if (match) {
+
          return match[1];
+
        } else {
+
          return "";
+
        }
+
      }),
+
    ),
+
  ];
+

+
  let checkers = false;
+
</script>
+

+
<style>
+
  .checkers {
+
    background: repeating-conic-gradient(#88888833 0% 25%, transparent 0% 50%)
+
      50% / 20px 20px;
+
    border-radius: 1rem;
+
  }
+

+
  .container {
+
    display: flex;
+
    margin: 0;
+
    padding: 0;
+
  }
+

+
  .color {
+
    width: 3rem;
+
    height: 3rem;
+
    border-radius: 0.5rem;
+
    outline-style: solid !important;
+
    outline-color: #88888899 !important;
+
    outline-offset: 0.3rem;
+
    margin: 1rem;
+
  }
+

+
  .unused {
+
    outline-style: dotted !important;
+
    outline-color: #55555555 !important;
+
  }
+
</style>
+

+
<Modal>
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div slot="body">
+
    <div
+
      role="button"
+
      tabindex="0"
+
      class="container"
+
      on:click={() => (checkers = !checkers)}>
+
      <div class:checkers>
+
        {#each colorGroups as colorGroup}
+
          <div style:display="flex">
+
            {#each colors.filter(color => {
+
              return color.match(`--color-${colorGroup}`);
+
            }) as color}
+
              <div style:display="inline-flex">
+
                <div
+
                  class:unused={!usedColors.includes(color)}
+
                  title={color}
+
                  class="color"
+
                  style:background-color={`var(${color})`} />
+
              </div>
+
            {/each}
+
          </div>
+
        {/each}
+
      </div>
+
    </div>
+
  </div>
+
</Modal>
added src/modals/ConnectModal.svelte
@@ -0,0 +1,195 @@
+
<script lang="ts">
+
  import * as httpd from "@app/lib/httpd";
+
  import * as modal from "@app/lib/modal";
+
  import { httpdStore } from "@app/lib/httpd";
+

+
  import Command from "@app/components/Command.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  $: customUrl = `${httpd.api.baseUrl.scheme}://${httpd.api.baseUrl.hostname}:${customPort}`;
+
  $: command = import.meta.env.PROD
+
    ? `rad web --backend ${customUrl}`
+
    : `rad web --frontend ${
+
        new URL(import.meta.url).origin
+
      } --backend ${customUrl}`;
+
  let customPort = httpd.api.port;
+
  $: validPortNumber = Number(customPort) > 0 && Number(customPort) <= 65535;
+

+
  $: if ($httpdStore.state === "authenticated") {
+
    modal.hide();
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    width: 33rem;
+
    margin-top: 1.5rem;
+
    gap: 1.5rem;
+
  }
+
  .progress {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+
  .progress-bar {
+
    height: 6px;
+
    border-radius: var(--border-radius-round);
+
    background-color: var(--color-background-dip);
+
  }
+
  .bar {
+
    display: flex;
+
    background-color: var(--color-fill-secondary);
+
    height: 100%;
+
    border-radius: var(--border-radius-round);
+
  }
+
  .captions {
+
    display: grid;
+
    grid-template-columns: 1fr 1fr 1fr;
+
    color: var(--color-foreground-dim);
+
    font-size: var(--font-size-tiny);
+
    text-align: center;
+
  }
+

+
  .input {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    gap: 0.75rem;
+
  }
+

+
  .status {
+
    font-size: var(--font-size-tiny);
+
    color: var(--color-fill-gray);
+
  }
+
  .separator {
+
    height: 1px;
+
    background-color: var(--color-border-hint);
+
  }
+

+
  .host {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    font-size: var(--font-size-small);
+
  }
+

+
  .label {
+
    font-size: var(--font-size-small);
+
  }
+
</style>
+

+
<Modal title="Connect and authenticate">
+
  <Icon name="review" size="48" slot="icon" />
+

+
  <svelte:fragment slot="subtitle">
+
    Complete the steps below to browse projects on your local machine,
+
    <br />
+
    create issues, and participate in discussions.
+
  </svelte:fragment>
+

+
  <div class="container" slot="body">
+
    {#if $httpdStore.state === "stopped"}
+
      <div class="progress">
+
        <div class="progress-bar">
+
          <div class="bar" style:width="1%" />
+
        </div>
+
        <div class="captions">
+
          <div
+
            style:text-align="left"
+
            style:color="var(--color-fill-secondary)">
+
            Start httpd server
+
          </div>
+
          <div>Authenticate</div>
+
          <div style:text-align="right">Done</div>
+
        </div>
+
      </div>
+

+
      <div class="input">
+
        <div class="label">
+
          Run this command in your terminal to connect to your local node:
+
        </div>
+
        <Command fullWidth command="radicle-httpd" />
+
      </div>
+

+
      <div class="input">
+
        <div class="label">Port:</div>
+
        <div style="width: 100%;">
+
          <TextInput
+
            name="port"
+
            size="small"
+
            bind:value={customPort}
+
            valid={validPortNumber}
+
            validationMessage="Invalid port"
+
            on:submit={() => httpd.changeHttpdPort(Number(customPort))}>
+
            <div
+
              slot="right"
+
              style="height: 100%; display: flex; align-items: center; padding: 0 0.5rem 0 0.25rem;">
+
              <IconSmall name="edit" />
+
            </div>
+
          </TextInput>
+
        </div>
+
      </div>
+
    {:else if $httpdStore.state === "running"}
+
      <div class="progress">
+
        <div class="progress-bar">
+
          <div class="bar" style:width="50%" />
+
        </div>
+
        <div class="captions">
+
          <div style:text-align="left">Start httpd server</div>
+
          <div style:color="var(--color-fill-secondary)">Authenticate</div>
+
          <div style:text-align="right">Done</div>
+
        </div>
+
      </div>
+

+
      <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+
        <div class="status">Httpd server running</div>
+
        <div class="host">
+
          radicle.local
+
          <Link
+
            on:afterNavigate={modal.hide}
+
            route={{
+
              resource: "nodes",
+
              params: {
+
                baseUrl: httpd.api.baseUrl,
+
                projectPageIndex: 0,
+
              },
+
            }}>
+
            <IconButton>Browse</IconButton>
+
          </Link>
+
        </div>
+
      </div>
+
      <div class="separator" />
+
      <div class="input">
+
        <div class="label">
+
          Run this command in your terminal to authenticate yourself:
+
        </div>
+
        <Command fullWidth {command} />
+
      </div>
+
      <div class="input">
+
        <div class="label">Port:</div>
+
        <div style="width: 100%;">
+
          <TextInput
+
            name="port"
+
            size="small"
+
            bind:value={customPort}
+
            valid={validPortNumber}
+
            validationMessage="Invalid port"
+
            on:submit={() => httpd.changeHttpdPort(Number(customPort))}>
+
            <div
+
              slot="right"
+
              style="height: 100%; display: flex; align-items: center; padding: 0 0.5rem 0 0.25rem;">
+
              <IconSmall name="edit" />
+
            </div>
+
          </TextInput>
+
        </div>
+
      </div>
+
    {/if}
+
  </div>
+
</Modal>
added src/modals/ErrorModal.svelte
@@ -0,0 +1,33 @@
+
<script lang="ts">
+
  import Command from "@app/components/Command.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  export let title: string;
+
  export let subtitle: string[];
+
  // This is more explicit than the standard error type.
+
  export let error: { message: string; stack?: string };
+
</script>
+

+
<Modal {title}>
+
  <Icon name="alert" size="48" slot="icon" />
+

+
  <div slot="subtitle">
+
    {@html subtitle.join("<br />")}
+

+
    <br />
+
    <br />
+
    If you need help resolving this issue, copy the error message
+
    <br />
+
    below and send it to us on
+
    <a class="txt-link" href="https://radicle.zulipchat.com/" target="_blank">
+
      radicle.zulipchat.com
+
    </a>
+
  </div>
+

+
  <div slot="body">
+
    <div style:max-width="28rem">
+
      <Command command={JSON.stringify(error)} fullWidth showPrompt={false} />
+
    </div>
+
  </div>
+
</Modal>
added src/modals/HotkeysModal.svelte
@@ -0,0 +1,69 @@
+
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import KeyHint from "@app/components/KeyHint.svelte";
+
</script>
+

+
<style>
+
  .hotkeys {
+
    gap: 3rem;
+
    justify-content: center;
+
    display: flex;
+
    font-size: var(--font-size-small);
+
  }
+

+
  .description {
+
    text-align: left;
+
  }
+

+
  .pair {
+
    display: flex;
+
    width: 8rem;
+
    justify-content: space-between;
+
  }
+

+
  .group {
+
    display: flex;
+
    gap: 1rem;
+
    flex-direction: column;
+
  }
+
</style>
+

+
<Modal title="Keyboard shortcuts">
+
  <Icon name="keyboard" size="48" slot="icon" />
+

+
  <div slot="body">
+
    <div class="hotkeys">
+
      <div class="group">
+
        <div class="pair">
+
          <div class="description">Shortcuts</div>
+
          <KeyHint>?</KeyHint>
+
        </div>
+

+
        <div class="pair">
+
          <div class="description">Search</div>
+
          <KeyHint>/</KeyHint>
+
        </div>
+

+
        {#if import.meta.env.DEV}
+
          <div class="pair">
+
            <div class="description">Color palette</div>
+
            <KeyHint>d</KeyHint>
+
          </div>
+
        {/if}
+
      </div>
+

+
      <div class="group">
+
        <div class="pair">
+
          <div class="description">Submit</div>
+
          <KeyHint>⏎</KeyHint>
+
        </div>
+

+
        <div class="pair">
+
          <div class="description">Close</div>
+
          <KeyHint>esc</KeyHint>
+
        </div>
+
      </div>
+
    </div>
+
  </div>
+
</Modal>
added src/modals/SearchResultsModal.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts" strictEvents>
+
  import type { ProjectBaseUrl } from "@app/lib/search";
+

+
  import * as modal from "@app/lib/modal";
+
  import { formatRepositoryId } from "@app/lib/utils";
+

+
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let query: string;
+
  export let results: ProjectBaseUrl[];
+
</script>
+

+
<style>
+
  .results {
+
    text-align: left;
+
  }
+
  ul {
+
    list-style-type: none;
+
    padding: 0;
+
  }
+
  li {
+
    margin: 0.5rem 0;
+
  }
+
</style>
+

+
<Modal title={`Results for "${query}"`}>
+
  <Icon name="magnifying-glass" size="48" slot="icon" />
+

+
  <span slot="body" class="results">
+
    {#if results.length > 0}
+
      <ul>
+
        {#each results as result}
+
          <li>
+
            <Link
+
              on:afterNavigate={modal.hide}
+
              route={{
+
                resource: "project.source",
+
                node: result.baseUrl,
+
                project: result.project.id,
+
              }}>
+
              <span title={result.baseUrl.hostname}>
+
                <span>{result.project.name}</span>
+
                <span class="id">
+
                  &nbsp;{formatRepositoryId(result.project.id)}
+
                </span>
+
              </span>
+
            </Link>
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
  </span>
+
</Modal>
modified src/views/NotFound.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import { twemoji } from "@app/lib/utils";
+
  import Icon from "@app/components/Icon.svelte";

  export let title: string;
</script>
@@ -9,22 +9,13 @@
    display: flex;
    align-items: center;
    flex-direction: column;
-
    gap: 1rem;
-
  }
-

-
  .emoji {
-
    display: flex;
-
    font-size: var(--font-size-xx-large);
-
  }
-

-
  .title {
-
    color: var(--color-secondary);
+
    gap: 1.5rem;
  }
</style>

<div class="layout-centered">
  <div class="container">
-
    <div class="emoji" use:twemoji>🏜️</div>
+
    <Icon name="desert" size="48" />
    <div class="title txt-medium txt-bold">{title}</div>
  </div>
</div>
modified src/views/home/Index.svelte
@@ -14,36 +14,48 @@

<style>
  .wrapper {
-
    padding: 3rem 3rem;
+
    padding: 3rem 15rem;
    width: 100%;
-
    max-width: 74rem;
  }
  .blurb {
-
    color: var(--color-foreground);
+
    color: var(--color-foreground-contrast);
    padding: 0rem;
-
    max-width: 70%;
+
    max-width: 65%;
    font-size: var(--font-size-medium);
    text-align: left;
-
    border-radius: var(--border-radius);
    margin-bottom: 1.5rem;
  }
  .projects {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
-
    gap: 1rem;
+
    gap: 3rem;
    width: 100%;
  }
  .project {
    width: 16rem;
  }
  .heading {
-
    color: var(--color-secondary);
+
    color: var(--color-foreground-emphasized);
    padding: 1rem 0rem;
    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;
+
    }
+
    .projects {
+
      gap: 1rem;
+
    }
    .blurb {
      max-width: none;
      font-size: var(--font-size-regular);
@@ -65,11 +77,11 @@
  {#if projects.length > 0}
    <div class="heading">
      {#if localProjects}
-
        Explore <span class="txt-bold">projects</span>
-
        on your local node.
+
        <!-- prettier-ignore -->
+
        <span>Explore projects on your <span class="txt-bold">local node</span>.</span>
      {:else}
-
        Explore <span class="txt-bold">projects</span>
-
        on the Radicle network.
+
        <!-- prettier-ignore -->
+
        <span>Explore projects on the <span class="txt-bold">Radicle network</span>.</span>
      {/if}
    </div>

modified src/views/nodes/View.svelte
@@ -6,12 +6,12 @@
  import { isLocal, truncateId } from "@app/lib/utils";
  import { loadProjects } from "@app/views/nodes/router";

-
  import Button from "@app/components/Button.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";
+
  import Button from "@app/components/Button.svelte";

  export let baseUrl: BaseUrl;
  export let nid: string;
@@ -51,108 +51,108 @@
</script>

<style>
+
  .layout {
+
    width: 100%;
+
    display: flex;
+
    justify-content: center;
+
    padding: 3rem 0 5rem 0;
+
  }
  .wrapper {
    width: 720px;
-
    margin: 5rem 0;
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    gap: 3.5rem;
  }
  .header {
-
    align-items: center;
-
    color: var(--color-secondary);
    display: flex;
-
    flex-direction: row;
-
    font-size: var(--font-size-large);
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
  .title {
+
    display: flex;
+
    font-size: var(--font-size-x-large);
    font-weight: var(--font-weight-bold);
-
    justify-content: space-between;
-
    margin-bottom: 2rem;
-
    overflow-x: hidden;
-
    text-align: left;
-
    text-overflow: ellipsis;
-
    width: 100%;
  }
-
  table {
-
    border-collapse: collapse;
+
  .address {
+
    color: var(--color-fill-secondary);
+
    font-family: var(--font-family-monospace);
+
    display: flex;
+
  }
+
  .info {
+
    display: flex;
+
    justify-content: space-between;
  }
-
  td {
-
    padding-bottom: 1.5rem;
-
    padding-right: 3rem;
+
  .version {
+
    color: var(--color-fill-gray);
+
    font-family: var(--font-family-monospace);
  }
-
  .node-address {
+
  .projects {
    display: flex;
-
    align-items: center;
-
    color: var(--color-foreground-6);
-
    white-space: nowrap;
+
    gap: 2rem;
+
    flex-direction: column;
  }
  .more {
-
    margin-top: 2rem;
-
    text-align: center;
-
  }
-
  @media (max-width: 720px) {
-
    .wrapper {
-
      width: 100%;
-
      padding: 1.5rem;
-
    }
+
    min-height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
  }
</style>

-
<div class="wrapper">
-
  <div class="header">
-
    {hostname}
-
  </div>
-

-
  <table>
-
    <tr>
-
      <td class="txt-highlight">Address</td>
-
      <td>
-
        <div class="node-address">
+
<div class="layout">
+
  <div class="wrapper">
+
    <div class="header">
+
      <div class="title">
+
        {hostname}
+
      </div>
+
      <div class="info">
+
        <div class="address">
          {truncateId(nid)}@{baseUrl.hostname}
          <Clipboard
            small
            text={`${nid}@${baseUrl.hostname}:${config.nodes.defaultNodePort}`} />
        </div>
-
      </td>
-
    </tr>
-
    <tr>
-
      <td class="txt-highlight">Version</td>
-
      <td>
-
        {version}
-
      </td>
-
    </tr>
-
  </table>
+
        <div class="version">
+
          v{version}
+
        </div>
+
      </div>
+
    </div>

-
  <div style:margin-bottom="5rem">
-
    <div style:margin-top="1rem">
+
    <div class="projects">
      {#each projects as { project, activity } (project.id)}
-
        <div style:margin-bottom="0.5rem">
-
          <Link
-
            route={{
-
              resource: "project.source",
-
              project: project.id,
-
              node: baseUrl,
-
            }}>
-
            <ProjectCard
-
              {activity}
-
              id={project.id}
-
              name={project.name}
-
              description={project.description}
-
              head={project.head} />
-
          </Link>
-
        </div>
+
        <Link
+
          route={{
+
            resource: "project.source",
+
            project: project.id,
+
            node: baseUrl,
+
          }}>
+
          <ProjectCard
+
            {activity}
+
            id={project.id}
+
            name={project.name}
+
            description={project.description}
+
            head={project.head} />
+
        </Link>
      {/each}
    </div>
+

    {#if loadingProjects}
      <div class="more">
-
        <Loading small />
+
        <Loading noDelay small />
      </div>
    {/if}
+

    {#if showMoreButton}
      <div class="more">
-
        <Button variant="foreground" on:click={loadMore}>More</Button>
+
        <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."
-
        stackTrace={error.stack} />
+
        message="Not able to load more projects from this node"
+
        {error} />
    {/if}
  </div>
</div>
modified src/views/projects/Changeset.svelte
@@ -64,22 +64,22 @@
    margin-left: 1rem;
  }
  .changeset-summary .additions {
-
    color: var(--color-positive-6);
+
    color: var(--color-foreground-success);
  }
  .changeset-summary .deletions {
-
    color: var(--color-negative-6);
+
    color: var(--color-foreground-red);
  }
</style>

<div class="changeset-summary">
  <span>{diffDescription(diff)}</span>
  with
-
  <span class="additions">
+
  <span class:additions={diff.stats.insertions > 0}>
    {diff.stats.insertions}
    {pluralize("insertion", diff.stats.insertions)}
  </span>
  and
-
  <span class="deletions">
+
  <span class:deletions={diff.stats.deletions > 0}>
    {diff.stats.deletions}
    {pluralize("deletion", diff.stats.deletions)}
  </span>
modified src/views/projects/Changeset/FileDiff.svelte
@@ -5,9 +5,15 @@
  import { toHtml } from "hast-util-to-html";

  import * as Syntax from "@app/lib/syntax";
+

  import Badge from "@app/components/Badge.svelte";
-
  import Icon from "@app/components/Icon.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";

  export let filePath: string;
  export let oldContent: string | undefined = undefined;
@@ -25,9 +31,10 @@
  export let projectId: string;
  export let visible: boolean = false;

-
  let collapsed = false;
+
  let expanded = true;
  let selection: Selection | undefined = undefined;
  let highlighting: { new?: string[]; old?: string[] } | undefined = undefined;
+
  let syntaxHighlightingLoading: boolean = false;

  onMount(() => {
    window.addEventListener("click", deselectHandler);
@@ -47,7 +54,11 @@
  });

  $: if (visible) {
-
    void highlightContent().then(output => (highlighting = output));
+
    syntaxHighlightingLoading = true;
+
    void highlightContent().then(output => {
+
      highlighting = output;
+
      syntaxHighlightingLoading = false;
+
    });
  }

  onDestroy(() => {
@@ -229,7 +240,7 @@

<style>
  .wrapper {
-
    border: 1px solid var(--color-foreground-4);
+
    border: 1px solid var(--color-border-default);
    border-radius: var(--border-radius-small);
    margin-bottom: 2rem;
    line-height: 1.5rem;
@@ -242,11 +253,12 @@
    flex-direction: row;
    height: 3rem;
    padding: 1rem;
+
    gap: 0.5rem;
  }
  main {
    font-size: var(--font-size-small);
-
    border-top: 1px dashed var(--color-foreground-4);
-
    background-color: var(--color-foreground-1);
+
    border-top: 1px solid var(--color-border-default);
+
    background: var(--color-background-float);
    border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
    overflow-x: auto;
  }
@@ -256,14 +268,8 @@
    align-items: center;
    gap: 1rem;
  }
-
  .placeholder {
-
    padding: 1rem;
-
    color: var(--color-foreground-5);
-
    text-align: center;
-
  }
  .browse {
    margin-left: auto;
-
    cursor: pointer;
  }
  .expand-button {
    cursor: pointer;
@@ -280,48 +286,91 @@
    vertical-align: top;
  }
  .diff-line.type-addition > * {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-2);
+
    background-color: var(--color-fill-diff-green-light);
  }
  .diff-line.type-deletion > * {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-2);
+
    background-color: var(--color-fill-diff-red-light);
  }
+

  .diff-line.selected > * {
-
    color: var(--color-foreground-6);
-
    background-color: var(--color-foreground-4);
+
    background-color: var(--color-fill-float-hover);
  }
  .diff-line.selected.type-addition > * {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-4);
+
    background-color: var(--color-fill-diff-green);
  }
  .diff-line.selected.type-deletion > * {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-4);
+
    background-color: var(--color-fill-diff-red);
+
  }
+

+
  .type-addition > .diff-line-number,
+
  .type-addition > .diff-line-type {
+
    color: var(--color-foreground-success);
+
  }
+
  .type-deletion > .diff-line-number,
+
  .type-deletion > .diff-line-type {
+
    color: var(--color-foreground-red);
  }
-
  .diff-line.hunk-header.selected {
-
    background-color: var(--color-foreground-4);
+

+
  .diff-line.selected .selection-indicator-left {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-addition.diff-line.selected .selection-indicator-left {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-deletion.diff-line.selected .selection-indicator-left {
+
    background-color: var(--color-fill-secondary);
+
  }
+

+
  .diff-line.selected .selection-indicator-right {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-addition.diff-line.selected .selection-indicator-right {
+
    background-color: var(--color-fill-secondary);
+
  }
+
  .type-deletion.diff-line.selected .selection-indicator-right {
+
    background-color: var(--color-fill-secondary);
+
  }
+

+
  .selection-start {
+
    box-shadow: 0 -1px 0 0 var(--color-fill-secondary);
+
    z-index: 1;
  }
+
  .selection-end {
+
    box-shadow: 0 1px 0 0 var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+

+
  .selection-start.selection-end {
+
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
+
    z-index: 1;
+
  }
+

  .diff-line-number {
+
    font-family: var(--font-family-monospace);
    text-align: right;
    user-select: none;
    line-height: 1.5rem;
    min-width: 3rem;
    cursor: pointer;
+
    color: var(--color-foreground-disabled);
  }
  .diff-line-number.left {
    position: relative;
    padding: 0 0.5rem 0 0.75rem;
  }
-
  .selection-indicator {
+
  .selection-indicator-left {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
-
    width: 4px;
+
    width: 1px;
  }
-
  .diff-line.selected .selection-indicator {
-
    background: var(--color-primary);
+
  .selection-indicator-right {
+
    position: absolute;
+
    right: 0;
+
    top: 0;
+
    bottom: 0;
+
    width: 1px;
  }
  .diff-line-number.right {
    padding: 0 0.75rem 0 0.5rem;
@@ -341,29 +390,27 @@
  }
  .diff-expand-header {
    padding-left: 0.5rem;
-
    color: var(--color-foreground-5);
+
    color: var(--color-foreground-dim);
  }
-
  .diff-line-number {
-
    color: var(--color-foreground-5);
+
  .header-right {
+
    margin-left: auto;
+
    display: flex;
+
    align-items: center;
+
    gap: 1rem;
  }
</style>

<div id={filePath} class="wrapper">
-
  <header class="header">
-
    <!-- svelte-ignore a11y-click-events-have-key-events -->
-
    <!-- svelte-ignore a11y-no-static-element-interactions -->
-
    <div class="expand-button" on:click={() => (collapsed = !collapsed)}>
-
      {#if collapsed}
-
        <Icon name="chevron-right" />
-
      {:else}
-
        <Icon name="chevron-down" />
-
      {/if}
-
    </div>
+
  <div class="header">
+
    <ExpandButton bind:expanded />
    <div class="actions">
-
      {#if headerBadgeCaption === "moved" || headerBadgeCaption === "copied"}
-
        <p class="txt-regular">{oldFilePath} → {filePath}</p>
+
      {#if (headerBadgeCaption === "moved" || headerBadgeCaption === "copied") && oldFilePath}
+
        <span>
+
          <FilePath filenameWithPath={oldFilePath} /> → <FilePath
+
            filenameWithPath={filePath} />
+
        </span>
      {:else}
-
        <p class="txt-regular">{filePath}</p>
+
        <FilePath filenameWithPath={filePath} />
      {/if}
      {#if headerBadgeCaption === "added"}
        <Badge variant="positive">added</Badge>
@@ -376,7 +423,10 @@
      {/if}
    </div>
    {#if revision}
-
      <div class="browse" title="View file">
+
      <div class="header-right">
+
        {#if syntaxHighlightingLoading}
+
          <Loading small />
+
        {/if}
        <Link
          route={{
            resource: "project.source",
@@ -385,12 +435,14 @@
            path: filePath,
            revision,
          }}>
-
          <Icon name="browse" />
+
          <IconButton title="View file">
+
            <IconSmall name="chevron-left-right" />
+
          </IconButton>
        </Link>
      </div>
    {/if}
-
  </header>
-
  {#if !collapsed}
+
  </div>
+
  {#if expanded}
    <main>
      {#if fileDiff.type === "plain"}
        {#if fileDiff.hunks.length > 0}
@@ -400,21 +452,33 @@
                class="diff-line hunk-header"
                class:selected={hunkHeaderSelected(selection, hunkIdx)}>
                <td colspan={2} style:position="relative">
-
                  <div class="selection-indicator" />
+
                  <div class="selection-indicator-left" />
                </td>
-
                <td colspan={6} class="diff-expand-header">
+
                <td
+
                  colspan={6}
+
                  class="diff-expand-header"
+
                  style:position="relative">
                  {hunk.header}
+
                  <div class="selection-indicator-right" />
                </td>
              </tr>
              {#each hunk.lines as line, lineIdx}
                <tr
+
                  style:position="relative"
                  class={`diff-line type-${line.type}`}
+
                  class:selection-start={selection?.startHunk === hunkIdx &&
+
                    selection.startLine === lineIdx}
+
                  class:selection-end={(selection?.endHunk === hunkIdx &&
+
                    selection.endLine === lineIdx) ||
+
                    (selection?.startHunk === hunkIdx &&
+
                      selection.startLine === lineIdx &&
+
                      selection?.endHunk === undefined)}
                  class:selected={isLineSelected(selection, hunkIdx, lineIdx)}>
                  <td
                    id={[filePath, "H" + hunkIdx, "L" + lineIdx].join("-")}
                    class="diff-line-number left"
                    on:click={e => selectLine(hunkIdx, lineIdx, e)}>
-
                    <div class="selection-indicator" />
+
                    <div class="selection-indicator-left" />
                    {lineNumberL(line)}
                  </td>
                  <td
@@ -438,15 +502,20 @@
                      {line.line}
                    {/if}
                  </td>
+
                  <div class="selection-indicator-right" />
                </tr>
              {/each}
            {/each}
          </table>
        {:else}
-
          <div class="placeholder">Empty file</div>
+
          <div style:margin="1rem 0">
+
            <Placeholder iconName="empty-file" caption="Empty file" inline />
+
          </div>
        {/if}
      {:else}
-
        <div class="placeholder">Binary file</div>
+
        <div style:margin="1rem 0">
+
          <Placeholder iconName="binary-file" caption="Binary file" inline />
+
        </div>
      {/if}
    </main>
  {/if}
modified src/views/projects/Changeset/FileLocationChange.svelte
@@ -2,8 +2,10 @@
  import type { BaseUrl } from "@httpd-client";

  import Badge from "@app/components/Badge.svelte";
-
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";

  export let newPath: string;
  export let oldPath: string;
@@ -15,7 +17,7 @@

<style>
  .wrapper {
-
    border: 1px solid var(--color-foreground-4);
+
    border: 1px solid var(--color-border-default);
    border-radius: var(--border-radius-small);
    margin-bottom: 2rem;
    line-height: 1.5rem;
@@ -35,23 +37,22 @@
    align-items: center;
    gap: 1rem;
  }
-
  .browse {
-
    margin-left: auto;
-
    cursor: pointer;
-
  }
</style>

<div id={newPath} class="wrapper">
  <header class="header">
    <div class="actions">
-
      <p class="txt-regular">{oldPath} → {newPath}</p>
+
      <span>
+
        <FilePath filenameWithPath={oldPath} /> → <FilePath
+
          filenameWithPath={newPath} />
+
      </span>
      {#if mode === "moved"}
        <Badge variant="foreground">moved</Badge>
      {:else if mode === "copied"}
        <Badge variant="foreground">copied</Badge>
      {/if}
    </div>
-
    <div class="browse" title="View file">
+
    <div style:margin-left="auto">
      <Link
        route={{
          resource: "project.source",
@@ -60,7 +61,9 @@
          path: newPath,
          revision,
        }}>
-
        <Icon name="browse" />
+
        <IconButton title="View file">
+
          <IconSmall name="chevron-left-right" />
+
        </IconButton>
      </Link>
    </div>
  </header>
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -4,8 +4,9 @@
  import { formatNodeId, parseNodeId } from "@app/lib/utils";

  import Avatar from "@app/components/Avatar.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Chip from "@app/components/Chip.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  const dispatch = createEventDispatcher<{ save: string[] }>();
@@ -17,29 +18,44 @@
  let updatedAssignees: string[] = assignees;
  let inputValue = "";
  let validationMessage: string | undefined = undefined;
+
  let valid: boolean = false;
+
  let assignee: string | undefined = undefined;

-
  $: parsedNodeId = parseNodeId(inputValue);
-

-
  function addAssignee() {
-
    if (parsedNodeId) {
-
      const assignee = `${parsedNodeId.prefix}${parsedNodeId.pubkey}`;
-
      if (updatedAssignees.includes(assignee)) {
-
        validationMessage = "This assignee is already added";
-
      } else {
-
        updatedAssignees = [...updatedAssignees, assignee];
-
        inputValue = "";
-
        if (action === "create") {
-
          dispatch("save", updatedAssignees);
+
  $: {
+
    if (inputValue !== "") {
+
      const parsedNodeId = parseNodeId(inputValue);
+
      if (parsedNodeId) {
+
        assignee = `${parsedNodeId.prefix}${parsedNodeId.pubkey}`;
+
        if (updatedAssignees.includes(assignee)) {
+
          valid = false;
+
          validationMessage = "This assignee is already added";
+
        } else {
+
          valid = true;
+
          validationMessage = undefined;
        }
+
      } else {
+
        valid = false;
+
        validationMessage = "This assignee is not valid";
      }
    } else {
-
      validationMessage = "This assignee is not valid";
+
      valid = false;
+
      validationMessage = "";
    }
  }

-
  function removeAssignee(remove: string) {
-
    updatedAssignees = updatedAssignees.filter(assignee => assignee !== remove);
-
    if (action === "create" || action === "edit") {
+
  function addAssignee() {
+
    if (valid && assignee) {
+
      updatedAssignees = [...updatedAssignees, assignee];
+
      inputValue = "";
+
      if (action === "create") {
+
        dispatch("save", updatedAssignees);
+
      }
+
    }
+
  }
+

+
  function removeAssignee(assignee: string) {
+
    updatedAssignees = updatedAssignees.filter(x => x !== assignee);
+
    if (action === "create") {
      dispatch("save", updatedAssignees);
    }
  }
@@ -48,39 +64,29 @@
<style>
  .header {
    display: flex;
-
    gap: 1rem;
+
    gap: 0.5rem;
    align-items: center;
    font-size: var(--font-size-small);
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-6);
+
  }
+
  .actions {
+
    margin-left: auto;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    gap: 0.5rem;
  }
  .body {
    display: flex;
    flex-wrap: wrap;
    flex-direction: row;
    gap: 0.5rem;
-
    margin-bottom: 1.25rem;
-
  }
-

-
  .close {
-
    color: inherit;
-
    border: none;
-
    border-bottom-right-radius: var(--border-radius);
-
    border-top-right-radius: var(--border-radius);
-
    background-color: transparent;
-
    line-height: 1.5;
-
    padding: 0;
-
    cursor: pointer;
  }
-
  .close:hover {
-
    color: var(--color-foreground);
-
  }
-

-
  .chip-content {
+
  .assignee {
    display: flex;
    align-items: center;
    width: 100%;
-
    gap: 0.5rem;
+
    gap: 0.25rem;
  }
</style>

@@ -88,56 +94,72 @@
  <div class="header">
    <span>Assignees</span>
    {#if action === "edit"}
-
      {#if editInProgress}
-
        <Button
-
          size="tiny"
-
          variant="text"
-
          on:click={() => {
-
            dispatch("save", updatedAssignees);
-
            editInProgress = !editInProgress;
-
          }}>
-
          save
-
        </Button>
-
      {:else}
-
        <Button
-
          size="tiny"
-
          variant="text"
-
          on:click={() => {
-
            editInProgress = !editInProgress;
-
          }}>
-
          edit
-
        </Button>
-
      {/if}
+
      <div class="actions">
+
        {#if editInProgress}
+
          <IconButton
+
            on:click={() => {
+
              dispatch("save", updatedAssignees);
+
              editInProgress = !editInProgress;
+
            }}>
+
            <IconSmall name="checkmark" />
+
          </IconButton>
+
          <IconButton
+
            on:click={() => {
+
              updatedAssignees = assignees;
+
              inputValue = "";
+
              editInProgress = !editInProgress;
+
            }}>
+
            <IconSmall name="cross" />
+
          </IconButton>
+
        {:else}
+
          <IconButton
+
            on:click={() => {
+
              editInProgress = !editInProgress;
+
            }}>
+
            <IconSmall name="edit" />
+
          </IconButton>
+
        {/if}
+
      </div>
    {/if}
  </div>
  <div class="body">
-
    {#each updatedAssignees as assignee (assignee)}
-
      <Chip actionable={editInProgress || action === "create"}>
-
        <div slot="content" aria-label="chip" class="txt-overflow chip-content">
-
          <Avatar inline nodeId={assignee} />
-
          <span>{formatNodeId(assignee)}</span>
-
        </div>
-
        <button
-
          slot="icon"
-
          class="section close"
-
          on:click={() => removeAssignee(assignee)}>
-
-
        </button>
-
      </Chip>
+
    {#if editInProgress || action === "create"}
+
      {#each updatedAssignees as assignee}
+
        <Badge variant="neutral">
+
          <div class="assignee">
+
            <Avatar inline nodeId={assignee} />
+
            <span>{formatNodeId(assignee)}</span>
+
            <span style:cursor="pointer">
+
              <IconSmall
+
                name="cross"
+
                on:click={() => removeAssignee(assignee)} />
+
            </span>
+
          </div>
+
        </Badge>
+
      {:else}
+
        <div class="txt-missing">No assignees</div>
+
      {/each}
    {:else}
-
      <div class="txt-missing">No assignees</div>
-
    {/each}
+
      {#each updatedAssignees as assignee}
+
        <Badge variant="neutral">
+
          <div class="assignee">
+
            <Avatar inline nodeId={assignee} />
+
            <span>{formatNodeId(assignee)}</span>
+
          </div>
+
        </Badge>
+
      {:else}
+
        <div class="txt-missing">No assignees</div>
+
      {/each}
+
    {/if}
  </div>
  {#if editInProgress || action === "create"}
-
    <div style:margin-bottom="1rem">
+
    <div style:margin-bottom="1rem" style:margin-top="1rem">
      <TextInput
+
        {valid}
+
        {validationMessage}
        bind:value={inputValue}
-
        valid={Boolean(parsedNodeId)}
        placeholder="Add assignee"
-
        variant="form"
-
        {validationMessage}
-
        on:submit={addAssignee}
-
        on:input={() => (validationMessage = undefined)} />
+
        on:submit={addAssignee} />
    </div>
  {/if}
</div>
added src/views/projects/Cob/CobCommitTeaser.svelte
@@ -0,0 +1,101 @@
+
<script lang="ts">
+
  import type { BaseUrl, CommitHeader } from "@httpd-client";
+

+
  import { formatCommit, twemoji } from "@app/lib/utils";
+

+
  import CommitAuthorship from "../Commit/CommitAuthorship.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let commit: CommitHeader;
+
  export let projectId: string;
+

+
  let commitMessageVisible = false;
+
</script>
+

+
<style>
+
  .teaser {
+
    display: flex;
+
    font-size: var(--font-size-small);
+
  }
+
  .message {
+
    align-items: center;
+
    display: flex;
+
    flex-direction: row;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
  }
+
  .left {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+
  .right {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 1rem;
+
    margin-left: auto;
+
    color: var(--color-foreground-dim);
+
  }
+
  .summary:hover {
+
    text-decoration: underline;
+
  }
+
  .commit-message {
+
    margin: 0.5rem 0;
+
  }
+

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

+
<div class="teaser">
+
  <div class="left">
+
    <div class="message">
+
      <span class="global-hash">{formatCommit(commit.id)}</span>
+
      <Link
+
        route={{
+
          resource: "project.commit",
+
          project: projectId,
+
          node: baseUrl,
+
          commit: commit.id,
+
        }}>
+
        <div class="summary" use:twemoji>
+
          <InlineMarkdown fontSize="small" content={commit.summary} />
+
        </div>
+
      </Link>
+
      {#if commit.description}
+
        <ExpandButton variant="inline" bind:expanded={commitMessageVisible} />
+
      {/if}
+
    </div>
+
    {#if commitMessageVisible}
+
      <div class="commit-message" style:margin="0.5rem 0">
+
        <pre>{commit.description.trim()}</pre>
+
      </div>
+
    {/if}
+
  </div>
+
  <div class="right">
+
    <div style:display="flex" style:gap="1rem" style:height="1.5rem">
+
      <div style:margin-bottom="1rem">
+
        <CommitAuthorship header={commit} />
+
      </div>
+
      <IconButton title="Browse the repository at this point in the history">
+
        <Link
+
          route={{
+
            resource: "project.source",
+
            project: projectId,
+
            node: baseUrl,
+
            revision: commit.id,
+
          }}>
+
          <IconSmall name="chevron-left-right" />
+
        </Link>
+
      </IconButton>
+
    </div>
+
  </div>
+
</div>
modified src/views/projects/Cob/CobHeader.svelte
@@ -2,14 +2,16 @@
  import { createEventDispatcher } from "svelte";

  import * as utils from "@app/lib/utils";
-
  import Clipboard from "@app/components/Clipboard.svelte";
-
  import Icon from "@app/components/Icon.svelte";
+

+
  import IconSmall from "@app/components/IconSmall.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import TextInput from "@app/components/TextInput.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";

  export let action: "create" | "edit" | "view" = "view";
  export let id: string | undefined = undefined;
  export let title: string = "";
+
  const oldTitle = title;

  const dispatch = createEventDispatcher<{ editTitle: string }>();

@@ -17,13 +19,12 @@
</script>

<style>
-
  header {
+
  .header {
    display: flex;
    flex-direction: column;
-
    gap: 0.3rem;
-
    border-radius: var(--border-radius);
-
    border: 1px solid var(--color-foreground-3);
-
    padding: 1rem;
+
    border: 1px solid var(--color-border-hint);
+
    padding: 1.5rem;
+
    border-radius: var(--border-radius-small);
  }
  .title {
    overflow: hidden;
@@ -32,6 +33,9 @@
    display: flex;
    align-items: center;
    gap: 0.5rem;
+
    font-size: var(--font-size-large);
+
    font-weight: var(--font-weight-medium);
+
    height: 2.5rem;
  }
  .subtitle {
    display: flex;
@@ -39,34 +43,40 @@
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem;
-
    font-size: var(--font-size-tiny);
+
    font-size: var(--font-size-small);
    font-family: var(--font-family-monospace);
-
    color: var(--color-foreground-6);
  }
  .summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
-
  }
-
  .id {
-
    display: flex;
-
    align-items: center;
+
    margin-bottom: 1rem;
  }
  .description {
    font-size: var(--font-size-small);
    margin-top: 1rem;
  }
-
  .toggle:hover {
-
    cursor: pointer;
-
    color: var(--color-foreground-5);
+
  .edit-buttons {
+
    display: flex;
+
    gap: 0.25rem;
  }
</style>

-
<header>
-
  <div class="summary txt-medium">
+
<div class="header">
+
  <div class="summary">
    {#if editable}
-
      <TextInput variant="form" placeholder="Title" bind:value={title} />
+
      <div><slot name="icon" /></div>
+
      <TextInput
+
        placeholder="Title"
+
        bind:value={title}
+
        showKeyHint={action === "edit"}
+
        on:submit={() => {
+
          if (action === "edit") {
+
            editable = !editable;
+
            dispatch("editTitle", title);
+
          }
+
        }} />
    {:else if title}
      <div class="title">
        <div><slot name="icon" /></div>
@@ -76,22 +86,42 @@
      <span class="txt-missing">No title</span>
    {/if}
    {#if action === "edit"}
-
      <div class="toggle" aria-label="editTitle">
-
        <Icon
-
          name={editable ? "checkmark" : "pen"}
-
          on:click={() => {
-
            editable = !editable;
-
            dispatch("editTitle", title);
-
          }} />
+
      <div class="edit-buttons">
+
        {#if editable}
+
          <IconButton
+
            title="save title"
+
            on:click={() => {
+
              editable = !editable;
+
              dispatch("editTitle", title);
+
            }}>
+
            <IconSmall name={"checkmark"} />
+
          </IconButton>
+
          <IconButton
+
            title="dismiss changes"
+
            on:click={() => {
+
              title = oldTitle;
+
              editable = !editable;
+
            }}>
+
            <IconSmall name={"cross"} />
+
          </IconButton>
+
        {:else}
+
          <IconButton
+
            title="edit title"
+
            on:click={() => {
+
              editable = !editable;
+
              dispatch("editTitle", title);
+
            }}>
+
            <IconSmall name={"edit"} />
+
          </IconButton>
+
        {/if}
      </div>
    {/if}
  </div>
  <div class="subtitle">
    <slot name="state" />
    {#if id}
-
      <div class="id">
+
      <div class="global-hash">
        {utils.formatObjectId(id)}
-
        <Clipboard text={id} small />
      </div>
    {/if}
    <slot name="author" />
@@ -99,4 +129,4 @@
  <div class="description">
    <slot name="description" />
  </div>
-
</header>
+
</div>
modified src/views/projects/Cob/CobStateButton.svelte
@@ -1,14 +1,17 @@
<script lang="ts" strictEvents>
-
  import Button from "@app/components/Button.svelte";
-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
-
  import Floating from "@app/components/Floating.svelte";
-
  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";

-
  import { closeFocused } from "@app/components/Floating.svelte";
  import { createEventDispatcher } from "svelte";
  import { isEqual } from "lodash";

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

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

  type CobState = $$Generic;

  export let state: CobState;
@@ -23,10 +26,6 @@
    selectedItem = item;
    closeFocused();
  }
-

-
  const attachableStyle = `border-top-right-radius: 0;
-
    border-bottom-right-radius: 0;
-
    border-right: 0;`;
</script>

<style>
@@ -34,51 +33,44 @@
    display: flex;
    flex-direction: row;
    justify-content: center;
-
  }
-
  .toggle {
-
    cursor: pointer;
-
    border: 1px solid var(--color-foreground);
-
    border-radius: var(--border-radius-round);
-
    border-top-left-radius: 0;
-
    height: var(--button-small-height);
-
    background: transparent;
-
    color: var(--color-foreground);
-
    border-bottom-left-radius: 0;
-
    line-height: 1.6rem;
-
    font-size: var(--font-size-regular);
-
    padding: 0 0.2rem;
-
  }
-
  .toggle:hover {
-
    background-color: var(--color-foreground);
-
    color: var(--color-background);
+
    border: 1px solid transparent;
+
    gap: 1px;
  }
</style>

<div class="main">
  <Button
-
    variant="foreground"
-
    size="small"
-
    on:click={() => dispatch("saveStatus", selectedItem[1])}
-
    style={attachableStyle}>
+
    styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
+
    variant="gray-white"
+
    on:click={() => dispatch("saveStatus", selectedItem[1])}>
+
    <IconSmall name="patch" />
    {selectedItem[0]}
  </Button>
-
  <Floating>
-
    <svelte:fragment slot="toggle">
-
      <button aria-label="stateToggle" class="toggle">
-
        <Icon name="chevron-down" />
-
      </button>
-
    </svelte:fragment>
-
    <svelte:fragment slot="modal">
-
      <Dropdown items={items.filter(i => !isEqual(i, state))}>
+

+
  <Popover
+
    popoverPadding="0"
+
    popoverPositionTop="2.5rem"
+
    popoverPositionRight="0"
+
    popoverBorderRadius="var(--border-radius-small)">
+
    <Button
+
      slot="toggle"
+
      styleBorderRadius="0 var(--border-radius-tiny) var(--border-radius-tiny) 0"
+
      stylePadding="0 0.25rem"
+
      variant="gray-white"
+
      ariaLabel="stateToggle">
+
      <Icon name="chevron-down" />
+
    </Button>
+
    <div slot="popover">
+
      <DropdownList items={items.filter(i => !isEqual(i, state))}>
        <svelte:fragment slot="item" let:item>
-
          <DropdownItem
+
          <DropdownListItem
            selected={false}
-
            on:click={() => switchCaption(item)}
-
            size="small">
+
            on:click={() => switchCaption(item)}>
+
            <IconSmall name="patch" />
            {item[0]}
-
          </DropdownItem>
+
          </DropdownListItem>
        </svelte:fragment>
-
      </Dropdown>
-
    </svelte:fragment>
-
  </Floating>
+
      </DropdownList>
+
    </div>
+
  </Popover>
</div>
modified src/views/projects/Cob/Embeds.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import Chip from "@app/components/Chip.svelte";
+
  import Badge from "@app/components/Badge.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";

  export let embeds: { name: string; content: string }[] = [];
@@ -12,7 +12,6 @@
    align-items: center;
    font-size: var(--font-size-small);
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-6);
  }
  .body {
    display: flex;
@@ -21,13 +20,6 @@
    gap: 0.5rem;
    margin-bottom: 1.25rem;
  }
-

-
  .chip-content {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
    width: 100%;
-
  }
</style>

<div>
@@ -36,15 +28,12 @@
  </div>
  <div class="body">
    {#each embeds as embed}
-
      <Chip actionable>
-
        <div slot="content" aria-label="chip" class="chip-content">
-
          <span class="txt-overflow">{embed.name}</span>
-
        </div>
+
      <Badge variant="neutral">
+
        <span class="txt-overflow">{embed.name}</span>
        <Clipboard
-
          slot="icon"
          text={`![${embed.name}](${embed.content.substring(4)})`}
-
          tiny />
-
      </Chip>
+
          small />
+
      </Badge>
    {:else}
      <div class="txt-missing">No attachments</div>
    {/each}
deleted src/views/projects/Cob/ErrorModal.svelte
@@ -1,18 +0,0 @@
-
<script lang="ts">
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-

-
  export let title: string;
-
  export let subtitle: string[];
-
  // This is more explicit than the standard error type.
-
  export let error: { message: string; stack?: string };
-
</script>
-

-
<Modal {title} emoji="🚨">
-
  <div slot="subtitle">
-
    {@html subtitle.join("<br />")}
-
  </div>
-
  <div slot="body">
-
    <ErrorMessage message={error.message} stackTrace={error.stack} />
-
  </div>
-
</Modal>
modified src/views/projects/Cob/LabelInput.svelte
@@ -1,8 +1,9 @@
<script lang="ts" strictEvents>
  import { createEventDispatcher } from "svelte";

-
  import Button from "@app/components/Button.svelte";
-
  import Chip from "@app/components/Chip.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  const dispatch = createEventDispatcher<{ save: string[] }>();
@@ -14,28 +15,41 @@
  let updatedLabels: string[] = labels;
  let inputValue = "";
  let validationMessage: string | undefined = undefined;
+
  let valid: boolean = false;
+
  let sanitizedValue: string | undefined = undefined;

-
  $: sanitizedValue = inputValue.trim();
+
  $: {
+
    sanitizedValue = inputValue.trim();

-
  function addLabel() {
-
    if (sanitizedValue.length > 0) {
-
      if (updatedLabels.includes(sanitizedValue)) {
-
        validationMessage = "This label is already added";
-
      } else {
-
        updatedLabels = [...updatedLabels, sanitizedValue];
-
        inputValue = "";
-
        if (action === "create" || action === "edit") {
-
          dispatch("save", updatedLabels);
+
    if (inputValue !== "") {
+
      if (sanitizedValue.length > 0) {
+
        if (updatedLabels.includes(sanitizedValue)) {
+
          valid = false;
+
          validationMessage = "This label is already added";
+
        } else {
+
          valid = true;
+
          validationMessage = undefined;
        }
      }
    } else {
-
      validationMessage = "This label is not valid";
+
      valid = false;
+
      validationMessage = "";
+
    }
+
  }
+

+
  function addLabel() {
+
    if (valid && sanitizedValue) {
+
      updatedLabels = [...updatedLabels, sanitizedValue];
+
      inputValue = "";
+
      if (action === "create") {
+
        dispatch("save", updatedLabels);
+
      }
    }
  }

-
  function removeLabel(remove: string) {
-
    updatedLabels = updatedLabels.filter(label => label !== remove);
-
    if (action === "create" || action === "edit") {
+
  function removeLabel(label: string) {
+
    updatedLabels = updatedLabels.filter(x => x !== label);
+
    if (action === "create") {
      dispatch("save", updatedLabels);
    }
  }
@@ -48,27 +62,19 @@
    align-items: center;
    font-size: var(--font-size-small);
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-6);
  }
  .metadata-section-body {
    display: flex;
    flex-wrap: wrap;
    flex-direction: row;
    gap: 0.5rem;
-
    margin-bottom: 1.25rem;
  }
-
  .close {
-
    color: inherit;
-
    border: none;
-
    border-bottom-right-radius: var(--border-radius);
-
    border-top-right-radius: var(--border-radius);
-
    background-color: transparent;
-
    line-height: 1.5;
-
    padding: 0;
-
    cursor: pointer;
-
  }
-
  .close:hover {
-
    color: var(--color-foreground);
+
  .actions {
+
    margin-left: auto;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    gap: 0.5rem;
  }
</style>

@@ -76,52 +82,67 @@
  <div class="metadata-section-header">
    <span>Labels</span>
    {#if action === "edit"}
-
      {#if editInProgress}
-
        <Button
-
          size="tiny"
-
          variant="text"
-
          on:click={() => {
-
            dispatch("save", updatedLabels);
-
            editInProgress = !editInProgress;
-
          }}>
-
          save
-
        </Button>
-
      {:else}
-
        <Button
-
          size="tiny"
-
          variant="text"
-
          on:click={() => {
-
            editInProgress = !editInProgress;
-
          }}>
-
          edit
-
        </Button>
-
      {/if}
+
      <div class="actions">
+
        {#if editInProgress}
+
          <IconButton
+
            title="save labels"
+
            on:click={() => {
+
              dispatch("save", updatedLabels);
+
              editInProgress = !editInProgress;
+
            }}>
+
            <IconSmall name="checkmark" />
+
          </IconButton>
+
          <IconButton
+
            title="dismiss changes"
+
            on:click={() => {
+
              updatedLabels = labels;
+
              inputValue = "";
+
              editInProgress = !editInProgress;
+
            }}>
+
            <IconSmall name="cross" />
+
          </IconButton>
+
        {:else}
+
          <IconButton
+
            title="edit labels"
+
            on:click={() => {
+
              editInProgress = !editInProgress;
+
            }}>
+
            <IconSmall name="edit" />
+
          </IconButton>
+
        {/if}
+
      </div>
    {/if}
  </div>
  <div class="metadata-section-body">
-
    {#each updatedLabels as label}
-
      <Chip actionable={editInProgress || action === "create"}>
-
        <div slot="content" aria-label="chip" class="txt-overflow">{label}</div>
-
        <div slot="icon">
-
          <button class="section close" on:click={() => removeLabel(label)}>
-
-
          </button>
-
        </div>
-
      </Chip>
+
    {#if editInProgress || action === "create"}
+
      {#each updatedLabels as label}
+
        <Badge variant="neutral">
+
          <div aria-label="chip" class="label">{label}</div>
+
          <span style:cursor="pointer">
+
            <IconSmall name="cross" on:click={() => removeLabel(label)} />
+
          </span>
+
        </Badge>
+
      {:else}
+
        <div class="txt-missing">No labels</div>
+
      {/each}
    {:else}
-
      <div class="txt-missing">No labels</div>
-
    {/each}
+
      {#each updatedLabels as label}
+
        <Badge variant="neutral">
+
          {label}
+
        </Badge>
+
      {:else}
+
        <div class="txt-missing">No labels</div>
+
      {/each}
+
    {/if}
  </div>
  {#if editInProgress || action === "create"}
-
    <div style:margin-bottom="1rem">
+
    <div style:margin-bottom="2rem" style:margin-top="1rem">
      <TextInput
+
        {valid}
+
        {validationMessage}
        bind:value={inputValue}
-
        valid={sanitizedValue.length > 0}
        placeholder="Add label"
-
        variant="form"
-
        {validationMessage}
-
        on:submit={addLabel}
-
        on:input={() => (validationMessage = undefined)} />
+
        on:submit={addLabel} />
    </div>
  {/if}
</div>
modified src/views/projects/Cob/Revision.svelte
@@ -1,25 +1,25 @@
<script lang="ts">
-
  import type { BaseUrl, DiffResponse } from "@httpd-client";
+
  import type { BaseUrl, DiffResponse, Verdict } from "@httpd-client";
  import type { Timeline } from "@app/views/projects/Patch.svelte";

  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { onMount } from "svelte";
-
  import { twemoji } from "@app/lib/utils";

-
  import Authorship from "@app/components/Authorship.svelte";
-
  import Avatar from "@app/components/Avatar.svelte";
-
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import CobCommitTeaser from "./CobCommitTeaser.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import Floating from "@app/components/Floating.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
  import Thread from "@app/components/Thread.svelte";

  export let baseUrl: BaseUrl;
@@ -41,7 +41,7 @@

  const api = new HttpdClient(baseUrl);

-
  function formatVerdict(verdict?: string | null) {
+
  function formatVerdict(verdict?: Verdict | null) {
    switch (verdict) {
      case "accept":
        return "accepted revision";
@@ -52,22 +52,24 @@
    }
  }

-
  function aliasColorForVerdict(verdict?: string | null) {
+
  function verdictIconColor(verdict?: Verdict | null) {
    switch (verdict) {
      case "accept":
-
        return "--color-positive-5";
+
        return "var(--color-foreground-success)";
      case "reject":
-
        return "--color-negative-5";
+
        return "var(--color-foreground-red)";
      default:
-
        return "--color-foreground-5";
+
        return "var(--color-foreground-gray)";
    }
  }

  let response: DiffResponse | undefined = undefined;
  let error: any | undefined = undefined;
+
  let loading: boolean = false;

  onMount(async () => {
    try {
+
      loading = true;
      response = await api.project.getDiff(
        projectId,
        revisionBase,
@@ -75,6 +77,8 @@
      );
    } catch (err: any) {
      error = err;
+
    } finally {
+
      loading = false;
    }
  });
</script>
@@ -86,22 +90,20 @@
    align-items: center;
  }
  .merge {
-
    background-color: var(--color-primary-3);
-
    color: var(--color-primary-6);
+
    border: 1px solid var(--color-border-merged);
+
    background-color: var(--color-fill-merged);
  }
  .positive-review {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-3);
+
    border: 1px solid var(--color-fill-diff-green);
+
    background-color: var(--color-fill-diff-green-light);
  }
  .comment-review {
-
    background-color: var(--color-foreground-1);
+
    border: 1px solid var(--color-border-hint);
+
    background-color: var(--color-fill-float);
  }
  .negative-review {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-3);
-
  }
-
  .authorship-box {
-
    padding: 0.5rem 1rem;
+
    border: 1px solid var(--color-fill-diff-red);
+
    background-color: var(--color-fill-diff-red-light);
  }

  .diff-error {
@@ -110,97 +112,105 @@
  .revision {
    display: flex;
    flex-direction: column;
-
    gap: 1rem;
-
    margin-bottom: 1rem;
+
    border-radius: var(--border-radius-small);
  }
  .revision-box {
-
    border: 1px solid var(--color-foreground-3);
    border-radius: var(--border-radius-small);
  }
  .revision-header {
-
    height: 3rem;
    display: flex;
-
    justify-content: space-between;
    align-items: center;
+
    justify-content: center;
    background: none;
-
    padding: 1rem;
-
    padding-right: 1.5rem;
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    height: 2.5rem;
  }
  .revision-name {
    display: flex;
-
    user-select: none;
+
    align-items: center;
+
    gap: 0.5rem;
+
    font-weight: var(--font-weight-medium);
  }
  .revision-data {
-
    gap: 0.5rem;
+
    gap: 0.75rem;
    display: flex;
    align-items: center;
-
  }
-
  .expand-button {
-
    margin-right: 0.5rem;
-
    user-select: none;
-
    cursor: pointer;
+
    margin-left: auto;
+
    color: var(--color-foreground-dim);
  }
  .revision-description {
    margin-bottom: 1rem;
+
    margin-left: 2rem;
  }
-
  .commits {
-
    margin-top: 0.5rem;
+
  .compare-dropdown-item {
+
    font-weight: var(--font-weight-regular);
+
  }
+
  .patch-header {
+
    background-color: var(--color-fill-float);
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    display: flex;
+
    flex-direction: column;
+
    font-size: var(--font-size-small);
  }
-
  .commit-event {
-
    color: var(--color-foreground-6);
-
    padding: 0.5rem 0.5rem 0.5rem 0.25rem;
+
  .authorship-header {
    display: flex;
-
    flex-direction: row;
    align-items: center;
-
    justify-content: space-between;
-
    font-family: var(--font-family-monospace);
+
    min-height: 3.5rem;
+
    gap: 0.5rem;
+
    padding: 0 0.5rem;
+
    font-size: var(--font-size-small);
  }
-
  .commit-event:last-child {
-
    padding: 0.5rem 0.5rem 0 0.25rem;
+
  .timestamp {
+
    margin-left: auto;
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
  }
-
  .commit-event span {
+
  .commits {
    display: flex;
-
    gap: 0.25rem;
-
    align-items: center;
+
    flex-direction: column;
+
    font-size: var(--font-size-small);
+
    border-left: 1px solid var(--color-fill-separator);
+
    margin-left: 1rem;
+
    gap: 0.5rem;
+
    padding: 1rem 1rem;
  }
-
  .commit-pointer {
-
    color: var(--color-foreground-5);
-
    user-select: none;
+

+
  .expanded {
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
  }
-
  .commit-separator {
-
    width: 0;
-
    height: 1rem;
-
    color: var(--color-foreground-5);
-
    position: relative;
-
    top: -1.15rem;
-
    left: -1.155rem;
-
    user-select: none;
+
  .commit-dot {
+
    border-radius: var(--border-radius-round);
+
    width: 4px;
+
    height: 4px;
+
    position: absolute;
+
    top: 0.5rem;
+
    left: -18.5px;
+
    background-color: var(--color-fill-separator);
  }
-
  .commit-summary:hover {
-
    text-decoration: underline;
+
  .connector {
+
    width: 1px;
+
    height: 1.5rem;
+
    margin-left: 1rem;
+
    background-color: var(--color-fill-separator);
  }
</style>

-
<div class="revision">
-
  <div class="revision-box">
+
<div class="revision" style:margin-bottom={expanded ? "2rem" : "0.5rem"}>
+
  <div class="revision-box" class:expanded>
    <div class="revision-header">
      <div class="revision-name">
-
        <div class="expand-button">
-
          <Icon
-
            name={expanded ? "chevron-down" : "chevron-right"}
-
            on:click={() => (expanded = !expanded)} />
-
        </div>
+
        <ExpandButton bind:expanded />
        <span>
-
          <span style:color="var(--color-foreground-6)">Revision</span>
-
          {utils.formatObjectId(revisionId)}
+
          Revision
+
          <span class="global-hash">{utils.formatObjectId(revisionId)}</span>
        </span>
-
        <Clipboard text={revisionId} small />
      </div>
-
      <div class="txt-small" />
      <div class="revision-data">
-
        <span class="layout-desktop txt-small">
-
          {utils.formatTimestamp(revisionTimestamp)}
-
        </span>
+
        {utils.formatTimestamp(revisionTimestamp)}
+
        {#if loading}
+
          <Loading small />
+
        {/if}
        {#if response?.diff.stats}
          {@const { insertions, deletions } = response.diff.stats}
          <DiffStatBadge {insertions} {deletions} />
@@ -221,112 +231,145 @@
                toCommit: revisionOid,
              },
            }}>
-
            <Icon name="diff" />
+
            <IconButton>
+
              <IconSmall name="diff" />
+
            </IconButton>
+
          </Link>
+
        {:else}
+
          <Link
+
            title="Compare {utils.formatObjectId(
+
              projectHead,
+
            )}..{utils.formatObjectId(revisionOid)}"
+
            route={{
+
              resource: "project.patch",
+
              project: projectId,
+
              node: baseUrl,
+
              patch: patchId,
+
              view: {
+
                name: "diff",
+
                fromCommit: projectHead,
+
                toCommit: revisionOid,
+
              },
+
            }}>
+
            <IconButton>
+
              <IconSmall name="diff" />
+
            </IconButton>
          </Link>
        {/if}
-
        <Floating>
-
          <svelte:fragment slot="toggle">
-
            <Icon name="ellipsis" />
-
          </svelte:fragment>
-
          <svelte:fragment slot="modal">
-
            <Dropdown
-
              items={previousRevOid && previousRevId
-
                ? [projectHead, previousRevOid]
-
                : [projectHead]}>
-
              <svelte:fragment slot="item" let:item>
-
                <Link
-
                  title="{item}..{revisionOid}"
-
                  route={{
-
                    resource: "project.patch",
-
                    project: projectId,
-
                    node: baseUrl,
-
                    patch: patchId,
-
                    view: {
-
                      name: "diff",
-
                      fromCommit: item,
-
                      toCommit: revisionOid,
-
                    },
-
                  }}>
-
                  {#if item === projectHead}
-
                    <DropdownItem selected={false} size="small">
-
                      Compare to {projectDefaultBranch} ({utils.formatObjectId(
-
                        projectHead,
-
                      )})
-
                    </DropdownItem>
-
                  {:else if previousRevId}
-
                    <DropdownItem selected={false} size="small">
-
                      Compare to previous revision ({utils.formatObjectId(
-
                        previousRevId,
-
                      )})
-
                    </DropdownItem>
-
                  {/if}
-
                </Link>
-
              </svelte:fragment>
-
            </Dropdown>
-
          </svelte:fragment>
-
        </Floating>
+
        <Popover
+
          popoverPadding="0"
+
          popoverPositionTop="2.5rem"
+
          popoverBorderRadius="var(--border-radius-small)">
+
          <IconButton slot="toggle" title="toggle-context-menu">
+
            <IconSmall name="more" />
+
          </IconButton>
+
          <DropdownList
+
            slot="popover"
+
            items={previousRevOid && previousRevId
+
              ? [projectHead, previousRevOid]
+
              : [projectHead]}>
+
            <Link
+
              let:item
+
              slot="item"
+
              title="{item}..{revisionOid}"
+
              route={{
+
                resource: "project.patch",
+
                project: projectId,
+
                node: baseUrl,
+
                patch: patchId,
+
                view: {
+
                  name: "diff",
+
                  fromCommit: item,
+
                  toCommit: revisionOid,
+
                },
+
              }}>
+
              {#if item === projectHead}
+
                <DropdownListItem selected={false}>
+
                  <span class="compare-dropdown-item">
+
                    Compare to {projectDefaultBranch}:
+
                    <span
+
                      style:color="var(--color-fill-secondary)"
+
                      style:font-weight="var(--font-weight-bold)"
+
                      style:font-family="var(--font-family-monospace)">
+
                      {utils.formatObjectId(projectHead)}
+
                    </span>
+
                  </span>
+
                </DropdownListItem>
+
              {:else if previousRevId}
+
                <DropdownListItem selected={false}>
+
                  <span class="compare-dropdown-item">
+
                    Compare to previous revision: <span
+
                      style:color="var(--color-fill-secondary)"
+
                      style:font-weight="var(--font-weight-bold)"
+
                      style:font-family="var(--font-family-monospace)">
+
                      {utils.formatObjectId(previousRevId)}
+
                    </span>
+
                  </span>
+
                </DropdownListItem>
+
              {/if}
+
            </Link>
+
          </DropdownList>
+
        </Popover>
      </div>
    </div>
    {#if expanded}
-
      {@const caption =
-
        patchId === revisionId
-
          ? "opened this patch"
-
          : `updated to ${utils.formatObjectId(revisionId)}`}
-
      <div style:margin="0 1rem 1rem 2.5rem">
-
        {#if revisionDescription && !first}
-
          <div class="revision-description txt-small">
-
            <Markdown
-
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
-
              content={revisionDescription} />
+
      <div>
+
        <div class="patch-header">
+
          <div
+
            class="authorship-header"
+
            style:border-top="1px solid var(--color-fill-separator)">
+
            <div style:color="var(--color-fill-success)">
+
              <IconSmall name="patch" />
+
            </div>
+

+
            <NodeId nodeId={revisionAuthor.id} alias={revisionAuthor.alias}>
+
            </NodeId>
+

+
            {#if patchId === revisionId}
+
              opened this patch
+
            {:else}
+
              updated to <span class="global-hash">
+
                {utils.formatObjectId(revisionId)}
+
              </span>
+
            {/if}
+

+
            <div
+
              class="timestamp"
+
              title={utils.absoluteTimestamp(revisionTimestamp)}>
+
              {utils.formatTimestamp(revisionTimestamp)}
+
            </div>
          </div>
-
        {/if}
-
        <div class="txt-tiny">
-
          <Authorship
-
            authorId={revisionAuthor.id}
-
            authorAlias={revisionAuthor.alias}
-
            timestamp={revisionTimestamp}>
-
            {caption}
-
          </Authorship>
-
          {#if response?.commits}
-
            <div class="commits txt-tiny">
-
              {#each response.commits.reverse() as commit, i}
-
                <div class="commit-event">
-
                  <span>
-
                    <span class="commit-pointer">╰─</span>
-
                    <span class="commit-separator">
-
                      {i === 0 ? "╎" : "│"}
-
                    </span>
-
                    <Avatar inline nodeId={revisionAuthor.id} />
-
                    <Link
-
                      route={{
-
                        resource: "project.commit",
-
                        project: projectId,
-
                        node: baseUrl,
-
                        commit: commit.id,
-
                      }}>
-
                      <div class="commit-summary" use:twemoji>
-
                        <InlineMarkdown
-
                          content={commit.summary}
-
                          fontSize="tiny" />
-
                      </div>
-
                    </Link>
-
                  </span>
-
                  <span>
-
                    {utils.formatCommit(commit.id)}
-
                  </span>
-
                </div>
-
              {/each}
+
          {#if revisionDescription && !first}
+
            <div class="revision-description txt-small">
+
              <Markdown
+
                rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
+
                content={revisionDescription} />
            </div>
          {/if}
        </div>
+
        {#if loading}
+
          <div style:height="3.5rem">
+
            <Loading small />
+
          </div>
+
        {/if}
+
        {#if response?.commits}
+
          <div class="commits">
+
            {#each response.commits.reverse() as commit}
+
              <div style:position="relative">
+
                <div class="commit-dot" />
+
                <CobCommitTeaser {commit} {baseUrl} {projectId} />
+
              </div>
+
            {/each}
+
          </div>
+
        {/if}
      </div>
      {#if error}
        <div
          class="diff-error txt-monospace txt-small"
          style:border-radius="var(--border-radius-small">
          <ErrorMessage
-
            message="Failed to load diff for this revision."
-
            stackTrace={error.stack.toString()} />
+
            message="Failed to load diff for this revision"
+
            {error} />
        </div>
      {/if}
    {/if}
@@ -334,94 +377,72 @@
  {#if expanded}
    {#if timelines.length > 0}
      {#each timelines as element}
-
        <div style:margin-left="1.5rem">
-
          {#if element.type === "thread"}
-
            <Thread
-
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
-
              thread={element.inner}
-
              on:react
-
              on:reply />
-
          {:else if element.type === "merge"}
-
            <div
-
              class="action merge layout-desktop txt-tiny"
-
              style:padding="1rem">
-
              <Authorship
-
                authorId={element.inner.author.id}
-
                authorAlias={element.inner.author.alias}
-
                timestamp={element.timestamp}
-
                authorAliasColor="--color-primary-5">
-
                merged
-
                {utils.formatCommit(element.inner.commit)}
-
              </Authorship>
-
            </div>
-
            <div class="action merge layout-mobile txt-tiny">
-
              <Authorship
-
                authorId={element.inner.author.id}
-
                authorAlias={element.inner.author.alias}
-
                authorAliasColor="--color-primary-5">
-
                merged
-
                {utils.formatCommit(element.inner.commit)}
-
              </Authorship>
-
            </div>
-
          {:else if element.type === "review"}
-
            {@const [author, review] = element.inner}
-
            {#if review.summary}
-
              <div
-
                class="action"
-
                class:comment-review={review.verdict === null}
-
                class:positive-review={review.verdict === "accept"}
-
                class:negative-review={review.verdict === "reject"}>
-
                <!-- TODO: Empty array for reactions prop is a workaround
-
                  until review comments have reactions -->
-
                <CommentComponent
-
                  caption={formatVerdict(review.verdict)}
-
                  authorId={author}
-
                  authorAlias={review.author.alias}
-
                  authorAliasColor={aliasColorForVerdict(review.verdict)}
-
                  reactions={[]}
-
                  timestamp={review.timestamp}
-
                  rawPath={utils.getRawBasePath(
-
                    projectId,
-
                    baseUrl,
-
                    projectHead,
-
                  )}
-
                  body={review.summary}
-
                  on:react />
+
        {#if element.type === "thread"}
+
          <div class="connector" />
+
          <Thread
+
            enableAttachments={false}
+
            rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
+
            thread={element.inner}
+
            on:react
+
            on:reply />
+
        {:else if element.type === "merge"}
+
          <div class="connector" />
+
          <div class="action merge">
+
            <div class="authorship-header">
+
              <div style:color="var(--color-fill-primary)">
+
                <IconSmall name="patch" />
              </div>
-
            {:else}
+

+
              <NodeId
+
                nodeId={element.inner.author.id}
+
                alias={element.inner.author.alias}>
+
              </NodeId>
+

+
              merged
+
              <span class="global-hash">
+
                {utils.formatCommit(element.inner.commit)}
+
              </span>
+

              <div
-
                class="action layout-desktop-flex txt-tiny"
-
                class:comment-review={review.verdict === null}
-
                class:positive-review={review.verdict === "accept"}
-
                class:negative-review={review.verdict === "reject"}>
-
                <div class="authorship-box">
-
                  <Authorship
-
                    authorId={author}
-
                    authorAlias={review.author.alias}
-
                    authorAliasColor={aliasColorForVerdict(review.verdict)}
-
                    timestamp={element.timestamp}>
-
                    {formatVerdict(review.verdict)}
-
                  </Authorship>
-
                </div>
+
                class="timestamp"
+
                title={utils.absoluteTimestamp(revisionTimestamp)}>
+
                {utils.formatTimestamp(revisionTimestamp)}
              </div>
-
              <div
-
                class="action layout-mobile-flex txt-tiny"
-
                class:comment-review={review.verdict === null}
-
                class:positive-review={review.verdict === "accept"}
-
                class:negative-review={review.verdict === "reject"}>
-
                <div class="authorship-box">
-
                  <Authorship
-
                    authorId={author}
-
                    authorAlias={review.author.alias}
-
                    authorAliasColor={aliasColorForVerdict(review.verdict)}>
-
                    {formatVerdict(review.verdict)}
-
                  </Authorship>
-
                </div>
+
            </div>
+
          </div>
+
        {:else if element.type === "review"}
+
          {@const [author, review] = element.inner}
+
          <div class="connector" />
+
          <div
+
            class="action"
+
            class:comment-review={review.verdict === null}
+
            class:positive-review={review.verdict === "accept"}
+
            class:negative-review={review.verdict === "reject"}>
+
            <!-- TODO: Empty array for reactions prop is a workaround
+
                  until review comments have reactions -->
+
            <CommentComponent
+
              caption={formatVerdict(review.verdict)}
+
              authorId={author}
+
              authorAlias={review.author.alias}
+
              reactions={[]}
+
              timestamp={review.timestamp}
+
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
+
              body={review.summary ?? ""}
+
              on:react>
+
              <div slot="icon" style:color={verdictIconColor(review.verdict)}>
+
                {#if review.verdict === "accept"}
+
                  <IconSmall name="checkmark" />
+
                {:else if review.verdict === "reject"}
+
                  <IconSmall name="cross" />
+
                {:else}
+
                  <IconSmall name="chat" />
+
                {/if}
              </div>
-
            {/if}
-
          {/if}
-
        </div>
+
            </CommentComponent>
+
          </div>
+
        {/if}
      {/each}
    {/if}
+
    <slot />
  {/if}
</div>
modified src/views/projects/Commit.svelte
@@ -17,14 +17,11 @@
</script>

<style>
-
  .commit {
-
    padding: 1rem 2rem 0 8rem;
-
  }
  .header {
-
    padding: 1rem;
-
    margin-bottom: 1.5rem;
-
    border-radius: var(--border-radius);
-
    border: 1px solid var(--color-foreground-3);
+
    margin-bottom: 3rem;
+
    border: 1px solid var(--color-border-hint);
+
    padding: 1.5rem;
+
    border-radius: var(--border-radius-small);
  }
  .summary {
    display: flex;
@@ -39,41 +36,33 @@
  }
  .sha1 {
    align-items: center;
-
    color: var(--color-foreground-5);
+
    color: var(--color-fill-secondary);
    font-size: var(--font-size-small);
  }
-

-
  @media (max-width: 960px) {
-
    .commit {
-
      padding-left: 2rem;
-
    }
-
  }
</style>

<Layout {baseUrl} {project}>
-
  <div class="commit">
-
    <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">
-
          <span>{header.id}</span>
-
          <Clipboard small text={header.id} />
-
        </div>
-
        <div class="layout-mobile-flex txt-monospace sha1 txt-small">
-
          {formatCommit(header.id)}
-
          <Clipboard small text={header.id} />
-
        </div>
+
  <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">
+
        <span>{header.id}</span>
+
        <Clipboard small text={header.id} />
+
      </div>
+
      <div class="layout-mobile-flex txt-monospace sha1 txt-small">
+
        {formatCommit(header.id)}
+
        <Clipboard small text={header.id} />
      </div>
-
      <pre class="description txt-small">{header.description}</pre>
-
      <CommitAuthorship {header} />
    </div>
-
    <Changeset
-
      {baseUrl}
-
      projectId={project.id}
-
      files={commit.files}
-
      diff={commit.diff}
-
      revision={commit.commit.id} />
+
    <pre class="description txt-small">{header.description}</pre>
+
    <CommitAuthorship {header} />
  </div>
+
  <Changeset
+
    {baseUrl}
+
    projectId={project.id}
+
    files={commit.files}
+
    diff={commit.diff}
+
    revision={commit.commit.id} />
</Layout>
modified src/views/projects/Commit/CommitAuthorship.svelte
@@ -4,64 +4,55 @@
  import { formatTimestamp, gravatarURL } from "@app/lib/utils";

  export let header: CommitHeader;
-
  export let noTime = false;
-
  export let noAuthor = false;
</script>

<style>
  .authorship {
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
    color: var(--color-foreground-6);
+
    font-size: var(--font-size-small);
+
    gap: 0.5rem;
+
    flex-wrap: wrap;
  }
-
  .authorship .author,
-
  .authorship .committer {
+
  .person {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: nowrap;
    white-space: nowrap;
+
    gap: 0.5rem;
  }
-
  .authorship .avatar {
+
  .avatar {
    width: 1rem;
    height: 1rem;
-
    border-radius: var(--border-radius);
-
  }
-

-
  @media (max-width: 720px) {
-
    .authorship {
-
      display: none;
-
    }
+
    border-radius: var(--border-radius-round);
  }
</style>

-
<span class="authorship txt-tiny">
+
<span class="authorship">
+
  <slot />
  {#if header.author.email === header.committer.email}
-
    <img
-
      class="avatar"
-
      alt="avatar"
-
      src={gravatarURL(header.committer.email)} />
-
    <span class="layout-desktop-inline committer">
+
    <div class="person">
+
      <img
+
        class="avatar"
+
        alt="avatar"
+
        src={gravatarURL(header.committer.email)} />
      {header.committer.name}
-
    </span>
-
    <span>committed</span>
+
    </div>
+
    committed
+
    {formatTimestamp(header.committer.time)}
  {:else}
-
    {#if !noAuthor}
+
    <div class="person">
      <img class="avatar" alt="avatar" src={gravatarURL(header.author.email)} />
-
      <span class="layout-desktop-inline author">
-
        {header.author.name}
-
      </span>
-
      <span>authored</span>
-
    {/if}
-
    <img
-
      class="avatar"
-
      alt="avatar"
-
      src={gravatarURL(header.committer.email)} />
-
    <span class="layout-desktop-inline committer">
+
      {header.author.name}
+
    </div>
+
    authored and
+
    <div class="person">
+
      <img
+
        class="avatar"
+
        alt="avatar"
+
        src={gravatarURL(header.committer.email)} />
      {header.committer.name}
-
    </span>
-
    <span>committed</span>
-
  {/if}
-
  {#if !noTime}
-
    <span class="layout-desktop-inline">
-
      {formatTimestamp(header.committer.time)}
-
    </span>
+
    </div>
+
    committed
+
    {formatTimestamp(header.committer.time)}
  {/if}
</span>
modified src/views/projects/Commit/CommitTeaser.svelte
@@ -4,7 +4,9 @@
  import { formatCommit, twemoji } from "@app/lib/utils";

  import CommitAuthorship from "./CommitAuthorship.svelte";
-
  import Icon from "@app/components/Icon.svelte";
+
  import ExpandButton from "@app/components/ExpandButton.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";

@@ -12,84 +14,53 @@
  export let commit: CommitHeader;
  export let projectId: string;

-
  let expandCommitMessage = false;
+
  let commitMessageVisible = false;
</script>

<style>
  .teaser {
-
    background-color: var(--color-foreground-1);
-
    padding: 0.75rem 0rem;
    display: flex;
-
    align-items: center;
+
    padding: 1.25rem;
+
    background-color: var(--color-background-float);
  }
  .teaser:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .hash {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    padding: 0 1.5rem;
-
  }
-
  .left {
-
    padding-left: 1rem;
+
    background-color: var(--color-fill-float-hover);
  }
  .message {
    align-items: center;
    display: flex;
    flex-direction: row;
+
    flex-wrap: wrap;
    gap: 0.5rem;
-
    margin-bottom: 0.25rem;
-
  }
-
  .expand-toggle {
-
    background-color: var(--color-foreground-2);
-
    border: 1px solid var(--color-foreground-5);
-
    border-radius: var(--border-radius-tiny);
-
    color: var(--color-foreground);
-
    cursor: pointer;
-
    font-weight: var(--font-weight-medium);
-
    height: 12px;
-
    line-height: 6px;
-
    padding: 0 5px 5px;
  }
-
  .expand-toggle:hover {
-
    background-color: var(--color-foreground-5);
+
  .left {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
  }
  .right {
    display: flex;
-
    align-items: center;
-
    padding-right: 1.5rem;
+
    align-items: flex-start;
+
    gap: 1rem;
    margin-left: auto;
+
    color: var(--color-foreground-dim);
+
    font-size: var(--font-size-tiny);
  }
  .summary {
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
    font-size: var(--font-size-small);
  }
  .summary:hover {
    text-decoration: underline;
  }
-
  .browse {
-
    display: flex;
-
    width: 100%;
-
    height: 100%;
+
  .commit-message {
+
    margin: 0.5rem 0;
+
    font-size: var(--font-size-regular);
  }

  @media (max-width: 720px) {
-
    .hash {
-
      padding-right: 0;
-
    }
    .left {
      overflow: hidden;
    }
-
    .browse {
-
      display: none !important;
-
    }
-
    .summary {
-
      overflow: hidden;
-
      white-space: nowrap;
-
      text-overflow: ellipsis;
-
    }
  }
</style>

@@ -104,41 +75,35 @@
          commit: commit.id,
        }}>
        <div class="summary" use:twemoji>
-
          <InlineMarkdown content={commit.summary} />
+
          <InlineMarkdown fontSize="regular" content={commit.summary} />
        </div>
      </Link>
      {#if commit.description}
-
        <button
-
          class:expand-open={expandCommitMessage}
-
          class="expand-toggle txt-tiny"
-
          on:click={() => (expandCommitMessage = !expandCommitMessage)}>
-
-
        </button>
+
        <ExpandButton variant="inline" bind:expanded={commitMessageVisible} />
      {/if}
    </div>
-
    {#if expandCommitMessage}
-
      <div style:margin="0.5rem 0">
-
        <pre
-
          class="txt-monospace txt-tiny"
-
          style:margin="0">{commit.description.trim()}</pre>
+
    {#if commitMessageVisible}
+
      <div class="commit-message">
+
        <pre>{commit.description.trim()}</pre>
      </div>
    {/if}
-
    <CommitAuthorship header={commit} />
+
    <CommitAuthorship header={commit}>
+
      <span class="global-hash">{formatCommit(commit.id)}</span>
+
    </CommitAuthorship>
  </div>
  <div class="right">
-
    <span class="hash txt-highlight">{formatCommit(commit.id)}</span>
-
    <div
-
      class="browse"
-
      title="Browse the repository at this point in the history">
-
      <Link
-
        route={{
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          revision: commit.id,
-
        }}>
-
        <Icon name="browse" />
-
      </Link>
+
    <div style:display="flex" style:gap="1rem" style:height="1.5rem">
+
      <IconButton title="Browse the repository at this point in the history">
+
        <Link
+
          route={{
+
            resource: "project.source",
+
            project: projectId,
+
            node: baseUrl,
+
            revision: commit.id,
+
          }}>
+
          <IconSmall name="chevron-left-right" />
+
        </Link>
+
      </IconButton>
    </div>
  </div>
</div>
modified src/views/projects/Header.svelte
@@ -5,13 +5,12 @@
<script lang="ts">
  import type { BaseUrl, Project } from "@httpd-client";

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

-
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
-
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
-
  import SquareButton from "@app/components/SquareButton.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;
@@ -20,80 +19,73 @@

<style>
  .header {
-
    font-size: var(--font-size-tiny);
-
    padding: 0 2rem 0 0rem;
    display: flex;
    align-items: center;
    justify-content: left;
    flex-wrap: wrap;
    gap: 0.5rem;
-
    margin-bottom: 1rem;
  }
</style>

<div class="header">
-
  <Link
-
    route={{
-
      resource: "project.source",
-
      project: project.id,
-
      node: baseUrl,
-
      path: "/",
-
    }}>
-
    <SquareButton active={activeTab === "source"}>
-
      <svelte:fragment slot="icon">
-
        <Icon size="small" name="chevron-left-right" />
-
      </svelte:fragment>
-
      Source
-
    </SquareButton>
-
  </Link>
-
  <Link
-
    route={{
-
      resource: "project.issues",
-
      project: project.id,
-
      node: baseUrl,
-
    }}>
-
    <SquareButton active={activeTab === "issues"}>
-
      <svelte:fragment slot="icon">
-
        <Icon size="small" name="issue" />
-
      </svelte:fragment>
-
      <span class="txt-bold">{project.issues.open}</span>
-
      {pluralize("issue", project.issues.open)}
-
    </SquareButton>
-
  </Link>
+
  <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>
+
          {project.issues.open}
+
          {pluralize("issue", project.issues.open)}
+
        </div>
+
      </Button>
+
    </Link>

-
  <Link
-
    route={{
-
      resource: "project.patches",
-
      project: project.id,
-
      node: baseUrl,
-
    }}>
-
    <SquareButton active={activeTab === "patches"}>
-
      <svelte:fragment slot="icon">
-
        <Icon size="small" name="patch" />
-
      </svelte:fragment>
-
      <span class="txt-bold">{project.patches.open}</span>
-
      {pluralize("patch", project.patches.open)}
-
    </SquareButton>
-
  </Link>
-
  <CloneButton {baseUrl} id={project.id} name={project.name} />
+
    <Link
+
      route={{
+
        resource: "project.patches",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      <Button variant={activeTab === "patches" ? "secondary" : "background"}>
+
        <IconSmall name="patch" />
+
        <div>
+
          {project.patches.open}
+
          {pluralize("patch", project.patches.open)}
+
          <div></div>
+
        </div>
+
      </Button>
+
    </Link>

-
  <Link
-
    route={{
-
      resource: "nodes",
-
      params: {
-
        baseUrl,
-
        projectPageIndex: 0,
-
      },
-
    }}>
-
    <SquareButton>
-
      {isLocal(baseUrl.hostname) ? "radicle.local" : baseUrl.hostname}
-
    </SquareButton>
-
  </Link>
-
  <SquareButton hoverable={false} title="Tracked by {project.trackings} nodes">
-
    <svelte:fragment slot="icon">
-
      <Icon size="small" name="network" />
-
    </svelte:fragment>
-
    <span class="txt-bold">{project.trackings}</span>
-
    nodes
-
  </SquareButton>
+
    <div class="layout-desktop">
+
      <Button
+
        disabled
+
        variant="background"
+
        title="Tracked by {project.trackings} {pluralize(
+
          'node',
+
          project.trackings,
+
        )}">
+
        <IconSmall name="network" />
+
        <div>
+
          {project.trackings}
+
          {pluralize("node", project.trackings)}
+
        </div>
+
      </Button>
+
    </div>
+
  </Radio>
</div>
modified src/views/projects/Header/CloneButton.svelte
@@ -4,8 +4,10 @@
  import { parseRepositoryId } from "@app/lib/utils";
  import { config } from "@app/lib/config";

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

  export let baseUrl: BaseUrl;
  export let id: string;
@@ -23,65 +25,41 @@
</script>

<style>
-
  .clone-button {
-
    background-color: var(--color-caution-3);
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-caution-6);
-
    font-family: var(--font-family-monospace);
-
    min-width: max-content;
-
    height: 2rem;
-
    line-height: initial;
-
    padding: 0.5rem 0.75rem;
-
  }
-
  .clone-button:hover {
-
    background-color: var(--color-caution-4);
-
  }
-
  .dropdown {
-
    background-color: var(--color-background-1);
-
    border-radius: var(--border-radius-small);
-
    box-shadow: var(--elevation-low);
-
    margin-top: 0.5rem;
-
    padding: 1rem;
-
    position: absolute;
-
    width: 24rem;
-
    z-index: 10;
-
  }
-
  @media (max-width: 720px) {
-
    .dropdown {
-
      width: auto;
-
      left: 2rem;
-
      right: 2rem;
-
      z-index: 10;
-
    }
-
  }
  label {
-
    color: var(--color-foreground-6);
    display: block;
-
    font-size: var(--font-size-tiny);
-
    padding: 0.5rem 0.5rem 0 0.25rem;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
    margin-bottom: 0.75rem;
  }
</style>

-
<Floating>
-
  <div slot="toggle" class="clone-button" role="button">Clone</div>
-
  <svelte:fragment slot="modal">
-
    <div class="dropdown">
-
      <Command color="caution" command={radCloneUrl} />
+
<Popover
+
  popoverPositionTop="3rem"
+
  popoverPositionRight="0"
+
  popoverWidth="26rem">
+
  <Button slot="toggle" size="large" variant="primary">
+
    Clone
+
    <Icon name="download" />
+
  </Button>
+

+
  <svelte:fragment slot="popover">
+
    <div style:margin-bottom="1.5rem">
      <label for="rad-clone-url">
        Use the <a
          target="_blank"
          rel="noreferrer"
-
          href="https://radicle.xyz/get-started.html"
-
          class="link">
+
          href="https://radicle.xyz/#try"
+
          class="txt-link txt-bold">
          Radicle CLI
        </a>
        to clone this project.
      </label>
-
      <br />
-
      <Command color="caution" command={gitCloneUrl} />
-
      <label for="git-clone-url">
-
        Use Git to clone this repository from the URL above.
-
      </label>
+
      <Command command={radCloneUrl} />
+
    </div>
+

+
    <div>
+
      <label for="git-clone-url">Use Git to clone this repository.</label>
+
      <Command command={gitCloneUrl} />
    </div>
  </svelte:fragment>
-
</Floating>
+
</Popover>
modified src/views/projects/History.svelte
@@ -10,14 +10,15 @@

  import { HttpdClient } from "@httpd-client";
  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 Header from "./Source/Header.svelte";
-
  import { COMMITS_PER_PAGE } from "./router";
+
  import Button from "@app/components/Button.svelte";
+
  import List from "@app/components/List.svelte";

  export let baseUrl: BaseUrl;
  export let branches: string[];
@@ -86,62 +87,72 @@

<style>
  .history {
-
    padding: 0 2rem 0 8rem;
    font-size: var(--font-size-small);
  }
-
  .group {
-
    margin-bottom: 2rem;
-
    border-radius: var(--border-radius);
-
    overflow: hidden;
-
  }
-
  .teaser-wrapper:not(:last-child) {
-
    border-bottom: 1px solid var(--color-background);
-
  }
  .more {
    margin-top: 2rem;
-
    text-align: center;
    min-height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  .group-header {
+
    margin-top: 3rem;
+
    margin-bottom: 1rem;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-medium);
+
    color: var(--color-foreground-dim);
  }
-
  @media (max-width: 960px) {
-
    .history {
-
      padding-left: 2rem;
+
  .group-header:first-child {
+
    margin-top: 0;
+
  }
+

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

-
<Layout {baseUrl} {project} {peer} activeTab="source">
-
  <Header
-
    node={baseUrl}
-
    {project}
-
    peers={peersWithRoute}
-
    branches={branchesWithRoute}
-
    {revision}
-
    {tree}
-
    historyLinkActive={true} />
+
<Layout {baseUrl} {project} 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>

  <div class="history">
    {#each groupCommits(allCommitHeaders) as group (group.time)}
-
      <p style:color="var(--color-foreground-6)">{group.date}</p>
-
      <div class="group">
-
        {#each group.commits as commit (commit.id)}
-
          <div class="teaser-wrapper">
-
            <CommitTeaser projectId={project.id} {baseUrl} {commit} />
-
          </div>
-
        {/each}
-
      </div>
+
      <div class="group-header">{group.date}</div>
+
      <List items={group.commits}>
+
        <CommitTeaser
+
          slot="item"
+
          let:item
+
          projectId={project.id}
+
          {baseUrl}
+
          commit={item} />
+
      </List>
    {/each}
    <div class="more">
      {#if loading}
        <Loading small={page !== 0} center />
      {:else if allCommitHeaders.length < totalCommitCount}
-
        <Button variant="foreground" on:click={loadMore}>More</Button>
+
        <Button size="large" variant="outline" on:click={loadMore}>More</Button>
      {/if}
    </div>
  </div>

  {#if error}
    <div class="message">
-
      <ErrorMessage message="Couldn't load commits." stackTrace={error} />
+
      <ErrorMessage message="Couldn't load commits" {error} />
    </div>
  {/if}
</Layout>
modified src/views/projects/Issue.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
  import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";
+
  import type { Embed } from "@app/lib/file";
  import type { IssueUpdateAction } from "@httpd-client/lib/project/issue";
  import type { Session } from "@app/lib/httpd";

@@ -10,25 +11,22 @@
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { ResponseError } from "@httpd-client/lib/fetcher";
-
  import { embed } from "@app/lib/file";
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
-
  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
+
  import CommentTextarea from "@app/components/CommentTextarea.svelte";
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
-
  import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
-
  import Floating, { closeFocused } from "@app/components/Floating.svelte";
+
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
-
  import Layout from "@app/views/projects/Layout.svelte";
+
  import LabelInput from "./Cob/LabelInput.svelte";
+
  import Layout from "./Layout.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";
-
  import Textarea from "@app/components/Textarea.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";

  export let baseUrl: BaseUrl;
@@ -38,9 +36,6 @@
  const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
  const api = new HttpdClient(baseUrl);

-
  let newEmbeds: { name: string; content: string }[] = [];
-
  let selectionStart = 0;
-
  let selectionEnd = 0;
  let action: "edit" | "view";
  $: action =
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
@@ -52,40 +47,6 @@
    ["Close issue as other", { status: "closed", reason: "other" }],
  ];

-
  const MAX_BLOB_SIZE = 4_194_304;
-

-
  function handleFileDrop(event: DragEvent) {
-
    event.preventDefault();
-
    if (event.dataTransfer) {
-
      const embeds = Array.from(event.dataTransfer.files).map(embed);
-
      void Promise.all(embeds).then(embeds =>
-
        embeds.forEach(embed => {
-
          if (embed.content.length > MAX_BLOB_SIZE) {
-
            modal.show({
-
              component: ErrorModal,
-
              props: {
-
                title: "File too large",
-
                subtitle: [
-
                  "The file you tried to upload is too large.",
-
                  "The maximum file size is 4MB.",
-
                ],
-
                error: { message: `File ${embed.name} is too large` },
-
              },
-
            });
-
            return;
-
          }
-
          newEmbeds.push({ name: embed.name, content: embed.content });
-
          const embedText = `![${embed.name}](${embed.oid})\n`;
-
          commentBody = commentBody
-
            .slice(0, selectionStart)
-
            .concat(embedText, commentBody.slice(selectionEnd));
-
          selectionStart += embedText.length;
-
          selectionEnd = selectionStart;
-
        }),
-
      );
-
    }
-
  }
-

  async function createReply({
    detail: reply,
  }: CustomEvent<{
@@ -112,7 +73,7 @@
    }
  }

-
  async function createComment(body: string) {
+
  async function createComment(body: string, embeds: Embed[]) {
    if ($httpdStore.state === "authenticated" && body.trim().length > 0) {
      const status = await updateIssue(
        project.id,
@@ -120,7 +81,7 @@
        {
          type: "comment",
          body,
-
          embeds: newEmbeds,
+
          embeds: embeds,
          replyTo: issue.id,
        },
        $httpdStore.session,
@@ -354,34 +315,32 @@
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
    new Map<string, string[]>(),
  );
-

-
  let commentBody: string = "";
</script>

<style>
  .issue {
    display: grid;
    grid-template-columns: minmax(0, 3fr) 1fr;
-
    padding: 1rem 2rem 0 8rem;
-
    margin-bottom: 4.5rem;
  }
  .metadata {
    display: flex;
    flex-direction: column;
-
    gap: 2rem;
-
    border-radius: var(--border-radius);
    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;
  }

-
  .actions {
+
  .threads {
    display: flex;
-
    flex-direction: row;
-
    justify-content: flex-end;
-
    margin: 0 0 2.5rem 0;
-
    gap: 1rem;
+
    flex-direction: column;
+
    gap: 1.5rem;
  }
+

  .author {
    display: flex;
    align-items: center;
@@ -389,39 +348,22 @@
    gap: 0.5rem;
  }
  .reactions {
-
    position: relative;
    display: flex;
-
    align-items: center;
-
    flex-direction: row;
    gap: 0.5rem;
-
  }
-
  .thread {
-
    margin: 1rem 0;
+
    height: 22px;
+
    margin-top: 1rem;
  }
  .open {
-
    color: var(--color-positive-6);
+
    color: var(--color-fill-success);
  }
  .closed {
-
    color: var(--color-negative-6);
-
  }
-
  .reaction-selector {
-
    position: absolute;
-
    bottom: 2rem;
-
    left: 0;
-
  }
-
  .toggle {
-
    margin-top: 1rem;
-
  }
-
  .toggle:hover {
-
    color: var(--color-foreground-5);
-
    cursor: pointer;
+
    color: var(--color-foreground-red);
  }

  @media (max-width: 960px) {
    .issue {
      display: grid;
      grid-template-columns: minmax(0, 1fr);
-
      padding-left: 2rem;
    }
    .metadata {
      display: none;
@@ -431,7 +373,7 @@

<Layout {baseUrl} {project} activeTab="issues">
  <div class="issue">
-
    <div>
+
    <div style="display: flex; flex-direction: column; gap: 1.5rem;">
      <CobHeader
        {action}
        id={issue.id}
@@ -447,11 +389,11 @@
        </svelte:fragment>
        <svelte:fragment slot="state">
          {#if issue.state.status === "open"}
-
            <Badge variant="positive">
+
            <Badge size="small" variant="positive">
              {issue.state.status}
            </Badge>
          {:else}
-
            <Badge variant="negative">
+
            <Badge size="small" variant="negative">
              {issue.state.status} as
              {issue.state.reason}
            </Badge>
@@ -463,79 +405,53 @@
            rawPath={utils.getRawBasePath(project.id, baseUrl, project.head)} />
          <div class="reactions">
            {#if $httpdStore.state === "authenticated"}
-
              <Floating>
-
                <div class="reaction-selector" slot="modal">
-
                  <ReactionSelector
-
                    nid={$httpdStore.session.publicKey}
-
                    reactions={issueReactions}
-
                    on:select={async event => {
-
                      await handleReaction({ ...event.detail, id: issue.id });
-
                      closeFocused();
-
                    }} />
-
                </div>
-
                <div class="toggle" slot="toggle">
-
                  <Icon name="face" />
-
                </div>
-
              </Floating>
+
              <ReactionSelector
+
                nid={$httpdStore.session.publicKey}
+
                reactions={issueReactions}
+
                on:select={async event => {
+
                  await handleReaction({ ...event.detail, id: issue.id });
+
                }} />
            {/if}
            {#if issueReactions.size > 0}
-
              <div style:margin-top="1rem">
-
                <Reactions
-
                  reactions={issueReactions}
-
                  on:remove={event =>
-
                    handleReaction({ ...event.detail, id: issue.id })} />
-
              </div>
+
              <Reactions
+
                clickable={$httpdStore.state === "authenticated"}
+
                reactions={issueReactions}
+
                on:remove={event =>
+
                  handleReaction({ ...event.detail, id: issue.id })} />
            {/if}
          </div>
        </div>
        <div class="author" slot="author">
-
          opened by <Authorship
-
            authorId={issue.author.id}
-
            authorAlias={issue.author.alias} />
+
          opened by <NodeId
+
            nodeId={issue.author.id}
+
            alias={issue.author.alias} />
          {utils.formatTimestamp(issue.discussion[0].timestamp)}
        </div>
      </CobHeader>
-
      {#each threads as thread (thread.root.id)}
-
        <div class="thread">
-
          <ThreadComponent
-
            {thread}
-
            {rawPath}
-
            on:reply={createReply}
-
            on:react={event => handleReaction(event.detail)} />
+
      {#if threads.length > 0}
+
        <div class="threads">
+
          {#each threads as thread (thread.root.id)}
+
            <ThreadComponent
+
              enableAttachments
+
              {thread}
+
              {rawPath}
+
              on:reply={createReply}
+
              on:react={event => handleReaction(event.detail)} />
+
          {/each}
        </div>
-
      {/each}
+
      {/if}
      {#if $httpdStore.state === "authenticated"}
-
        <div style:margin-top="1rem">
-
          <Textarea
-
            resizable
-
            bind:selectionStart
-
            bind:selectionEnd
-
            on:drop={handleFileDrop}
-
            on:submit={async () => {
-
              await createComment(commentBody);
-
              newEmbeds = [];
-
              commentBody = "";
-
            }}
-
            bind:value={commentBody}
-
            placeholder="Leave your comment" />
-
          <div class="actions txt-small">
-
            <CobStateButton
-
              items={items.filter(([, state]) => !isEqual(state, issue.state))}
-
              {selectedItem}
-
              state={issue.state}
-
              on:saveStatus={saveStatus} />
-
            <Button
-
              variant="secondary"
-
              size="small"
-
              disabled={!commentBody}
-
              on:click={async () => {
-
                await createComment(commentBody);
-
                newEmbeds = [];
-
                commentBody = "";
-
              }}>
-
              Comment
-
            </Button>
-
          </div>
+
        <CommentTextarea
+
          enableAttachments
+
          on:submit={async event => {
+
            await createComment(event.detail.comment, event.detail.embeds);
+
          }} />
+
        <div style:display="flex">
+
          <CobStateButton
+
            items={items.filter(([, state]) => !isEqual(state, issue.state))}
+
            {selectedItem}
+
            state={issue.state}
+
            on:saveStatus={saveStatus} />
        </div>
      {/if}
    </div>
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -3,11 +3,12 @@

  import { formatObjectId, formatTimestamp } from "@app/lib/utils";

-
  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";

  export let baseUrl: BaseUrl;
  export let issue: Issue;
@@ -19,35 +20,35 @@
    }
    return acc;
  }, 0);
+

+
  let hover = false;
</script>

<style>
  .issue-teaser {
    display: flex;
-
    padding: 0.75rem 0;
-
    background-color: var(--color-foreground-1);
+
    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);
  }
  .issue-teaser:hover {
-
    background-color: var(--color-foreground-2);
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .content {
+
    gap: 0.5rem;
+
    display: flex;
+
    flex-direction: column;
  }
  .subtitle {
-
    color: var(--color-foreground-6);
-
    font-size: var(--font-size-tiny);
-
    font-family: var(--font-family-monospace);
-
    margin-right: 0.4rem;
+
    font-size: var(--font-size-small);
+
    flex-wrap: wrap;
  }
  .summary {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 0.5rem;
-
    padding-right: 1rem;
-
  }
-
  .issue-title {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
    cursor: pointer;
  }
  .issue-title:hover {
    text-decoration: underline;
@@ -56,52 +57,48 @@
    display: flex;
    flex-direction: row;
    align-items: center;
-
    padding-right: 1rem;
    gap: 0.5rem;
-
    color: var(--color-foreground-5);
  }
  .labels {
    display: flex;
    flex-direction: row;
    gap: 0.5rem;
+
    flex-wrap: wrap;
  }
-
  .label {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
  }
-

  .right {
-
    align-self: center;
-
    justify-self: center;
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 1rem;
    margin-left: auto;
+
    color: var(--color-foreground-dim);
+
    font-size: var(--font-size-tiny);
  }
  .state {
    justify-self: center;
-
    align-self: center;
-
    margin: 0 1rem 0 1.25rem;
+
    align-self: flex-start;
+
    margin-right: 1.5rem;
  }
  .open {
-
    color: var(--color-positive-6);
+
    color: var(--color-fill-success);
  }
  .closed {
-
    color: var(--color-negative-6);
-
  }
-

-
  @media (max-width: 960px) {
-
    .labels {
-
      display: none;
-
    }
+
    color: var(--color-foreground-red);
  }
</style>

-
<div class="issue-teaser">
+
<div
+
  role="button"
+
  tabindex="0"
+
  class="issue-teaser"
+
  on:mouseenter={() => (hover = true)}
+
  on:mouseleave={() => (hover = false)}>
  <div
    class="state"
    class:closed={issue.state.status === "closed"}
    class:open={issue.state.status === "open"}>
    <Icon name="issue" />
  </div>
-
  <div>
+
  <div class="content">
    <div class="summary">
      <Link
        route={{
@@ -111,35 +108,38 @@
          issue: issue.id,
        }}>
        <span class="issue-title">
-
          <InlineMarkdown content={issue.title} />
+
          <InlineMarkdown fontSize="regular" content={issue.title} />
        </span>
      </Link>
      <span class="labels">
        {#each issue.labels.slice(0, 4) as label}
-
          <Badge style="max-width:7rem" variant="secondary">
-
            <span class="label">{label}</span>
+
          <Badge variant={hover ? "background" : "neutral"}>
+
            {label}
          </Badge>
        {/each}
        {#if issue.labels.length > 4}
-
          <Badge variant="foreground">
-
            <span class="label">+{issue.labels.length - 4} more labels</span>
+
          <Badge
+
            title={issue.labels.slice(4, undefined).join(" ")}
+
            variant={hover ? "background" : "neutral"}>
+
            +{issue.labels.length - 4} more labels
          </Badge>
        {/if}
      </span>
    </div>
    <div class="summary subtitle">
-
      {formatObjectId(issue.id)} opened {formatTimestamp(
-
        issue.discussion[0].timestamp,
-
      )} by
-
      <Authorship authorId={issue.author.id} authorAlias={issue.author.alias} />
+
      <span class="global-hash">{formatObjectId(issue.id)}</span>
+
      opened {formatTimestamp(issue.discussion[0].timestamp)} by
+
      <NodeId nodeId={issue.author.id} alias={issue.author.alias} />
    </div>
  </div>
-
  {#if commentCount > 0}
-
    <div class="right">
-
      <div class="comment-count">
-
        <Icon name="chat" />
-
        <span>{commentCount}</span>
+
  <div class="right">
+
    {#if commentCount > 0}
+
      <div style:display="flex" style:gap="1rem">
+
        <div class="comment-count">
+
          <IconSmall name="chat" />
+
          <span>{commentCount}</span>
+
        </div>
      </div>
-
    </div>
-
  {/if}
+
    {/if}
+
  </div>
</div>
modified src/views/projects/Issue/New.svelte
@@ -5,12 +5,12 @@
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
-
  import { embed } from "@app/lib/file";
+
  import { embed, type Embed } from "@app/lib/file";
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
-
  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
-
  import Authorship from "@app/components/Authorship.svelte";
+
  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
@@ -19,11 +19,12 @@
  import Layout from "@app/views/projects/Layout.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import Textarea from "@app/components/Textarea.svelte";
+
  import Icon from "@app/components/Icon.svelte";

  export let baseUrl: BaseUrl;
  export let project: Project;

-
  const newEmbeds: Map<string, { name: string; content: string }> = new Map();
+
  let newEmbeds: Embed[] = [];
  let selectionStart = 0;
  let selectionEnd = 0;
  let preview: boolean = false;
@@ -48,7 +49,10 @@
      const embeds = Array.from(event.dataTransfer.files).map(embed);
      void Promise.all(embeds).then(embeds =>
        embeds.forEach(({ oid, name, content }) => {
-
          newEmbeds.set(oid, { name, content });
+
          newEmbeds = [
+
            ...newEmbeds,
+
            { oid: oid, name: name, content: content },
+
          ];
          const embedText = `![${name}](${oid})\n`;
          issueText = issueText
            .slice(0, selectionStart)
@@ -92,12 +96,11 @@
      });
    }
  }
+

+
  $: valid = issueTitle && issueText;
</script>

<style>
-
  main {
-
    padding: 0 2rem 0 8rem;
-
  }
  .form {
    display: grid;
    grid-template-columns: minmax(0, 3fr) 1fr;
@@ -113,8 +116,7 @@
  .metadata {
    display: flex;
    flex-direction: column;
-
    gap: 4rem;
-
    border-radius: var(--border-radius);
+
    gap: 2rem;
    font-size: var(--font-size-small);
    padding-left: 1rem;
    margin-left: 1rem;
@@ -123,16 +125,14 @@
    flex: 2;
    padding-right: 1rem;
  }
+
  .open {
+
    color: var(--color-fill-success);
+
  }
  .author {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }
-
  @media (max-width: 960px) {
-
    main {
-
      padding-left: 2rem;
-
    }
-
  }
  @media (max-width: 720px) {
    .form {
      grid-template-columns: minmax(0, 1fr);
@@ -155,9 +155,14 @@
      <div class="form">
        <div class="editor">
          <CobHeader {action} bind:title={issueTitle}>
+
            <svelte:fragment slot="icon">
+
              <div class="open">
+
                <Icon name="issue" />
+
              </div>
+
            </svelte:fragment>
            <svelte:fragment slot="state">
              {#if action === "view"}
-
                <Badge variant="positive">open</Badge>
+
                <Badge size="small" variant="positive">open</Badge>
              {/if}
            </svelte:fragment>
            <svelte:fragment slot="description">
@@ -169,34 +174,27 @@
                  on:drop={handleFileDrop}
                  bind:value={issueText}
                  on:submit={() => {
-
                    void createIssue(session.id);
+
                    if (valid) {
+
                      void createIssue(session.id);
+
                    }
                  }}
                  placeholder="Write a description" />
              {:else if !issueText}
                <p class="txt-missing">No description</p>
              {:else}
-
                <Markdown
-
                  embeds={newEmbeds}
-
                  content={issueText}
-
                  rawPath={utils.getRawBasePath(
-
                    project.id,
-
                    baseUrl,
-
                    project.head,
-
                  )} />
+
                <Markdown embeds={newEmbeds} content={issueText} />
              {/if}
            </svelte:fragment>
            <div class="author" slot="author">
              {#if action === "view"}
-
                opened by <Authorship
-
                  authorId={$httpdStore.session.publicKey} /> now
+
                opened by <NodeId
+
                  nodeId={$httpdStore.session.publicKey}
+
                  alias={$httpdStore.session.alias} /> now
              {/if}
            </div>
          </CobHeader>
          <div class="actions">
-
            <Button
-
              size="small"
-
              variant="text"
-
              on:click={() => (preview = !preview)}>
+
            <Button variant="none" on:click={() => (preview = !preview)}>
              {#if preview}
                Resume editing
              {:else}
@@ -204,8 +202,7 @@
              {/if}
            </Button>
            <Button
-
              disabled={!issueTitle || !issueText}
-
              size="small"
+
              disabled={!valid}
              variant="secondary"
              on:click={() => void createIssue(session.id)}>
              Submit
@@ -224,7 +221,7 @@
      </div>
    {:else}
      <ErrorMessage
-
        message="Couldn't access issue creation. Make sure you're still logged in." />
+
        message="Couldn't access issue creation. Make sure you're authenticated." />
    {/if}
  </main>
</Layout>
modified src/views/projects/Issues.svelte
@@ -2,19 +2,24 @@
  import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";

  import * as utils from "@app/lib/utils";
-
  import capitalize from "lodash/capitalize";
  import { HttpdClient } from "@httpd-client";
-
  import { httpdStore } from "@app/lib/httpd";
  import { ISSUES_PER_PAGE } from "./router";
+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { httpdStore } from "@app/lib/httpd";

  import Button from "@app/components/Button.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  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";
-
  import SquareButton from "@app/components/SquareButton.svelte";

  export let baseUrl: BaseUrl;
  export let issues: Issue[];
@@ -50,18 +55,11 @@
    }
  }

-
  interface Tab {
-
    value: IssueState["status"];
-
    title: string;
-
    disabled: boolean;
-
  }
-

  const stateOptions: IssueState["status"][] = ["open", "closed"];
-
  $: options = stateOptions.map<Tab>(s => ({
-
    value: s,
-
    title: `${project.issues[s]} ${s}`,
-
    disabled: project.issues[s] === 0,
-
  }));
+
  const stateColor: Record<IssueState["status"], string> = {
+
    open: "var(--color-fill-success)",
+
    closed: "var(--color-foreground-red)",
+
  };

  $: showMoreButton =
    !loading && !error && allIssues.length < project.issues[state];
@@ -69,103 +67,109 @@

<style>
  .issues {
-
    padding: 0 2rem 0 8rem;
    font-size: var(--font-size-small);
  }
-
  .issues-list {
-
    border-radius: var(--border-radius);
-
    overflow: hidden;
-
  }
-
  .teaser:not(:last-child) {
-
    border-bottom: 1px solid var(--color-background);
-
  }
-
  .section-header {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    width: 100%;
-
  }
  .more {
    margin-top: 2rem;
-
    text-align: center;
    min-height: 3rem;
-
  }
-

-
  @media (max-width: 960px) {
-
    .issues {
-
      padding-left: 2rem;
-
    }
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
  }
</style>

<Layout {baseUrl} {project} activeTab="issues">
  <div class="issues">
-
    <div class="section-header">
-
      <div style="margin-bottom: 2rem;">
-
        <div style="display: flex; gap: 0.5rem;">
-
          {#each options as option}
-
            {#if !option.disabled}
-
              <Link
-
                route={{
-
                  resource: "project.issues",
-
                  project: project.id,
-
                  node: baseUrl,
-
                  state: option.value,
-
                }}>
-
                <SquareButton
-
                  clickable={option.disabled}
-
                  active={option.value === state}
-
                  disabled={option.disabled}>
-
                  {option.title}
-
                </SquareButton>
-
              </Link>
-
            {:else}
-
              <SquareButton
-
                clickable={option.disabled}
-
                active={option.value === state}
-
                disabled={option.disabled}>
-
                {option.title}
-
              </SquareButton>
-
            {/if}
-
          {/each}
-
        </div>
+
    <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"
+
            ariaLabel="filter-dropdown"
+
            title="Filter issues by state">
+
            <div style:color={stateColor[state]}>
+
              <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={item === state
+
                    ? "var(--color-foreground-white)"
+
                    : stateColor[item]}>
+
                  <Icon name="issue" />
+
                </div>
+
                {project.issues[item]}
+
                {item}
+
              </DropdownListItem>
+
            </Link>
+
          </DropdownList>
+
        </Popover>
+
        {#if $httpdStore.state === "authenticated" && utils.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>
-
      {#if $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)}
-
        <Link
-
          route={{
-
            resource: "project.newIssue",
-
            project: project.id,
-
            node: baseUrl,
-
          }}>
-
          <SquareButton>New issue</SquareButton>
-
        </Link>
-
      {/if}
-
    </div>
-
    <div class="issues-list">
-
      {#each allIssues as issue (issue.id)}
-
        <div class="teaser">
-
          <IssueTeaser projectId={project.id} {baseUrl} {issue} />
-
        </div>
-
      {:else}
+

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

+
      <svelte:fragment slot="body">
        {#if error}
-
          <ErrorMessage message="Couldn't load issues." stackTrace={error} />
-
        {:else if loading}
-
          <!-- We already show a loader below. -->
-
        {:else}
-
          <Placeholder emoji="🍂">
-
            <div slot="title">{capitalize(state)} issues</div>
-
            <div slot="body">No issues matched the current filter</div>
-
          </Placeholder>
+
          <ErrorMessage message="Couldn't load issues" {error} />
        {/if}
-
      {/each}
-
    </div>
+

+
        {#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>
+

    <div class="more">
      {#if loading}
-
        <Loading small={page !== 0} center />
+
        <Loading noDelay small={page !== 0} center />
      {/if}

      {#if showMoreButton}
-
        <Button variant="foreground" on:click={() => loadIssues(state)}>
+
        <Button
+
          size="large"
+
          variant="outline"
+
          on:click={() => loadIssues(state)}>
          More
        </Button>
      {/if}
modified src/views/projects/Layout.svelte
@@ -5,16 +5,17 @@
  import dompurify from "dompurify";

  import markdown from "@app/lib/markdown";
-
  import { formatNodeId, twemoji } from "@app/lib/utils";
+
  import { twemoji, isLocal } from "@app/lib/utils";

  import Clipboard from "@app/components/Clipboard.svelte";
+
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Button from "@app/components/Button.svelte";

  import Header from "./Header.svelte";

  export let activeTab: ActiveTab = undefined;
  export let baseUrl: BaseUrl;
-
  export let peer: string | undefined = undefined;
  export let project: Project;

  const render = (content: string): string =>
@@ -23,53 +24,36 @@

<style>
  .header {
+
    padding: 3rem 8rem 3rem 8rem;
    width: 100%;
    max-width: var(--content-max-width);
    min-width: var(--content-min-width);
-
    padding: 4rem 2rem 0 8rem;
  }
  .title {
    align-items: center;
-
    color: var(--color-secondary);
+
    color: var(--color-foreground-contrast);
    display: flex;
    font-size: var(--font-size-x-large);
    font-weight: var(--font-weight-bold);
    justify-content: left;
    margin-bottom: 0.5rem;
-
    overflow-x: hidden;
    text-align: left;
    text-overflow: ellipsis;
  }
-
  .divider {
-
    color: var(--color-foreground-4);
-
    margin: 0 0.5rem;
-
    font-weight: var(--font-weight-normal);
-
  }
-
  .node-id {
-
    color: var(--color-foreground-5);
-
    font-weight: var(--font-weight-normal);
-
    display: flex;
-
    align-items: center;
-
    white-space: nowrap;
-
  }
  .project-name:hover {
    color: inherit;
  }
  .id {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    color: var(--color-foreground-5);
+
    color: var(--color-fill-secondary);
    overflow-wrap: anywhere;
    display: flex;
    justify-content: left;
    align-items: center;
    gap: 0.125rem;
-
  }
-
  .description {
-
    margin: 1rem 0 1.5rem 0;
+
    margin: 1rem 0 3rem 0;
  }
  .description :global(a) {
-
    border-bottom: 1px solid var(--color-foreground-6);
+
    border-bottom: 1px solid var(--color-foreground-contrast);
  }
  .truncate {
    white-space: nowrap;
@@ -80,17 +64,25 @@
    width: 100%;
    max-width: var(--content-max-width);
    min-width: var(--content-min-width);
-
    padding-bottom: 4rem;
+
    padding: 0 8rem 4rem 8rem;
  }

  @media (max-width: 960px) {
    .header {
-
      padding: 4rem 0 0 2rem;
+
      padding: 4rem 1rem 3rem 1rem;
+
    }
+
    .content {
+
      padding: 0 1rem 4rem 1rem;
    }
    .title {
      font-size: var(--font-size-medium);
      font-weight: var(--font-weight-bold);
-
      padding-right: 2rem;
+
    }
+
  }
+

+
  @media (max-width: 720px) {
+
    .content {
+
      padding: 0 0 4rem 0;
    }
  }
</style>
@@ -110,25 +102,37 @@
      </Link>
    </span>

-
    {#if peer}
-
      <span class="node-id">
-
        <span class="divider">/</span>
-
        <span title={peer}>{formatNodeId(peer)}</span>
-
        <Clipboard text={peer} />
-
      </span>
-
    {/if}
-
  </div>
+
    <div
+
      class="layout-desktop-flex"
+
      style="margin-left: auto; display: flex; gap: 0.5rem;">
+
      <Link
+
        route={{
+
          resource: "nodes",
+
          params: {
+
            baseUrl,
+
            projectPageIndex: 0,
+
          },
+
        }}>
+
        <Button size="large" variant="outline">
+
          {isLocal(baseUrl.hostname) ? "radicle.local" : baseUrl.hostname}
+
        </Button>
+
      </Link>

-
  <div class="id">
-
    <span class="truncate">{project.id}</span>
-
    <Clipboard small text={project.id} />
+
      <CloneButton {baseUrl} id={project.id} name={project.name} />
+
    </div>
  </div>

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

+
  <div class="id">
+
    <span class="truncate global-hash">{project.id}</span>
+
    <Clipboard small text={project.id} />
+
  </div>
+

  <Header {project} {activeTab} {baseUrl} />
+
  <slot name="subheader" />
</div>

<div class="content">
modified src/views/projects/Patch.svelte
@@ -39,7 +39,7 @@
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
  import type { Session } from "@app/lib/httpd";
-
  import type { Variant } from "@app/components/Badge.svelte";
+
  import type { ComponentProps } from "svelte";

  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
@@ -48,26 +48,27 @@
  import { capitalize, isEqual } from "lodash";
  import { httpdStore } from "@app/lib/httpd";

-
  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import Changeset from "@app/views/projects/Changeset.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
+
  import CommentTextarea from "@app/components/CommentTextarea.svelte";
  import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
-
  import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
-
  import Floating, { closeFocused } from "@app/components/Floating.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
  import Layout from "@app/views/projects/Layout.svelte";
  import Link from "@app/components/Link.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Radio from "@app/components/Radio.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
-
  import SquareButton from "@app/components/SquareButton.svelte";
-
  import Textarea from "@app/components/Textarea.svelte";

  export let baseUrl: BaseUrl;
  export let patch: Patch;
@@ -132,7 +133,7 @@
      }
    }
  }
-
  async function createComment() {
+
  async function createComment(commentBody: string) {
    if (
      $httpdStore.state === "authenticated" &&
      commentBody.trim().length > 0
@@ -168,7 +169,7 @@
      }
    }
  }
-
  function badgeColor(status: string): Variant {
+
  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
    if (status === "draft") {
      return "foreground";
    } else if (status === "open") {
@@ -241,7 +242,12 @@
      : "view"
  ) as "edit" | "view";

-
  let tabs: Record<string, Route>;
+
  type Tab = "activity" | "changes";
+

+
  let tabs: Record<
+
    Tab,
+
    { icon: ComponentProps<IconSmall>["name"]; route: Route }
+
  >;
  $: {
    const baseRoute = {
      resource: "project.patch",
@@ -255,16 +261,18 @@
    const revision = latestRevisionId === revisionId ? undefined : revisionId;
    tabs = {
      activity: {
-
        ...baseRoute,
-
        view: { name: "activity" },
-
      },
-
      commits: {
-
        ...baseRoute,
-
        view: { name: "commits", revision },
+
        route: {
+
          ...baseRoute,
+
          view: { name: "activity" },
+
        },
+
        icon: "activity",
      },
-
      files: {
-
        ...baseRoute,
-
        view: { name: "files", revision },
+
      changes: {
+
        route: {
+
          ...baseRoute,
+
          view: { name: "changes", revision },
+
        },
+
        icon: "diff",
      },
    };
  }
@@ -283,7 +291,6 @@
    return patchReviews;
  }

-
  let commentBody = "";
  let revisionId: string;
  $: if (view.name === "diff") {
    revisionId = patch.revisions[patch.revisions.length - 1].id;
@@ -347,36 +354,28 @@
  .patch {
    display: grid;
    grid-template-columns: minmax(0, 3fr) 1fr;
-
    padding: 1rem 2rem 0 8rem;
-
    margin-bottom: 4.5rem;
  }
  .metadata {
    display: flex;
    flex-direction: column;
-
    gap: 2rem;
-
    border-radius: var(--border-radius);
+
    gap: 1.5rem;
    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;
  }
  .commit-list {
-
    border-radius: var(--border-radius);
+
    border: 1px solid var(--color-border-hint);
+
    border-radius: var(--border-radius-small);
    overflow: hidden;
    margin-top: 1rem;
  }
-
  .tab-line {
+
  .tabs {
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    align-items: center;
-
    margin: 1rem 0;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: flex-end;
-
    margin: 0 0 2.5rem 0;
-
    gap: 1rem;
+
    margin: 3rem 0 1.5rem 0;
  }
  .author {
    display: flex;
@@ -385,40 +384,51 @@
    gap: 0.5rem;
  }
  .draft {
-
    color: var(--color-foreground-6);
+
    color: var(--color-foreground-gray);
  }
  .open {
-
    color: var(--color-positive-6);
+
    color: var(--color-fill-success);
  }
  .archived {
-
    color: var(--color-caution-6);
+
    color: var(--color-foreground-yellow);
  }
  .merged {
-
    color: var(--color-primary-6);
+
    color: var(--color-fill-primary);
  }
  .metadata-section-header {
    font-size: var(--font-size-small);
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-6);
  }
  .metadata-section-body {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
-
    margin-bottom: 1.25rem;
  }
  .review {
+
    color: var(--color-fill-gray);
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
  }
  .review-accept {
-
    color: var(--color-positive);
+
    color: var(--color-foreground-success);
  }
  .review-reject {
-
    color: var(--color-negative);
+
    color: var(--color-foreground-red);
+
  }
+
  .diff-button-range {
+
    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) {
    .patch {
      display: grid;
@@ -428,11 +438,6 @@
      display: none;
    }
  }
-
  @media (max-width: 960px) {
-
    .patch {
-
      padding-left: 2rem;
-
    }
-
  }
</style>

<Layout {baseUrl} {project} activeTab="patches">
@@ -450,7 +455,7 @@
          </div>
        </svelte:fragment>
        <svelte:fragment slot="state">
-
          <Badge variant={badgeColor(patch.state.status)}>
+
          <Badge size="small" variant={badgeColor(patch.state.status)}>
            {patch.state.status}
          </Badge>
        </svelte:fragment>
@@ -468,20 +473,24 @@
          {/if}
        </svelte:fragment>
        <div class="author" slot="author">
-
          opened by <Authorship
-
            authorId={patch.author.id}
-
            authorAlias={patch.author.alias} />
+
          opened by <NodeId
+
            nodeId={patch.author.id}
+
            alias={patch.author.alias} />
          {utils.formatTimestamp(patch.revisions[0].timestamp)}
        </div>
      </CobHeader>

-
      <div class="tab-line">
-
        <div style="display: flex; gap: 0.5rem;">
-
          {#each Object.entries(tabs) as [name, route]}
+
      <div class="tabs">
+
        <Radio>
+
          {#each Object.entries(tabs) as [name, { route, icon }]}
            <Link {route}>
-
              <SquareButton size="small" active={name === view.name}>
+
              <Button
+
                styleBorderRadius="0"
+
                size="regular"
+
                variant={name === view.name ? "secondary" : "gray"}>
+
                <IconSmall name={icon} />
                {capitalize(name)}
-
              </SquareButton>
+
              </Button>
            </Link>
          {/each}
          {#if view.name === "diff"}
@@ -497,52 +506,87 @@
                  toCommit: view.toCommit,
                },
              }}>
-
              <SquareButton size="small" active={true}>
-
                Diff {view.fromCommit.substring(
-
                  0,
-
                  6,
-
                )}..{view.toCommit.substring(0, 6)}
-
              </SquareButton>
+
              <Button styleBorderRadius="0" size="regular" variant="secondary">
+
                Compare <span class="diff-button-range">
+
                  {view.fromCommit.substring(0, 6)}..{view.toCommit.substring(
+
                    0,
+
                    6,
+
                  )}
+
                </span>
+
              </Button>
            </Link>
          {/if}
-
        </div>
+
        </Radio>

-
        {#if view.name === "commits" || view.name === "files"}
-
          <Floating disabled={patch.revisions.length === 1}>
-
            <svelte:fragment slot="toggle">
-
              <SquareButton
-
                size="small"
-
                clickable={patch.revisions.length > 1}
-
                disabled={patch.revisions.length === 1}>
-
                Revision {utils.formatObjectId(view.revision)}
-
              </SquareButton>
-
            </svelte:fragment>
-
            <svelte:fragment slot="modal">
-
              <Dropdown items={patch.revisions}>
-
                <svelte:fragment slot="item" let:item>
-
                  <Link
-
                    on:afterNavigate={closeFocused}
-
                    route={{
-
                      resource: "project.patch",
-
                      project: project.id,
-
                      node: baseUrl,
-
                      patch: patch.id,
-
                      view: {
-
                        name: view.name,
-
                        revision: item.id,
-
                      },
-
                    }}>
-
                    <DropdownItem
-
                      selected={item.id === view.revision}
-
                      size="tiny">
-
                      Revision {utils.formatObjectId(item.id)}
-
                    </DropdownItem>
-
                  </Link>
-
                </svelte:fragment>
-
              </Dropdown>
-
            </svelte:fragment>
-
          </Floating>
-
        {/if}
+
        <div style:margin-left="auto">
+
          {#if $httpdStore.state === "authenticated" && view.name === "activity"}
+
            <CobStateButton
+
              items={items.filter(([, state]) => !isEqual(state, patch.state))}
+
              {selectedItem}
+
              state={patch.state}
+
              on:saveStatus={saveStatus} />
+
          {/if}
+
          {#if view.name === "commits" || view.name === "changes"}
+
            <div style="margin-left: auto;">
+
              <Popover
+
                disabled={patch.revisions.length === 1}
+
                popoverPadding="0"
+
                popoverPositionTop="2.5rem"
+
                popoverBorderRadius="var(--border-radius-small)">
+
                <Button
+
                  let:expanded
+
                  slot="toggle"
+
                  size="regular"
+
                  disabled={patch.revisions.length === 1}>
+
                  <span
+
                    style:font-weight="var(--font-weight-regular)"
+
                    style:color="var(--color-fill-gray)">
+
                    Revision
+
                  </span>
+
                  <span
+
                    style:color="var(--color-fill-secondary)"
+
                    style:font-family="var(--font-family-monospace)">
+
                    {utils.formatObjectId(view.revision)}
+
                  </span>
+
                  <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
                </Button>
+
                <DropdownList slot="popover" items={patch.revisions}>
+
                  <svelte:fragment slot="item" let:item>
+
                    <Link
+
                      on:afterNavigate={closeFocused}
+
                      route={{
+
                        resource: "project.patch",
+
                        project: project.id,
+
                        node: baseUrl,
+
                        patch: patch.id,
+
                        view: {
+
                          name: view.name,
+
                          revision: item.id,
+
                        },
+
                      }}>
+
                      <DropdownListItem selected={item.id === view.revision}>
+
                        <span
+
                          style:font-weight="var(--font-weight-regular)"
+
                          style:color={item.id === view.revision
+
                            ? "var(--color-foreground-match-background)"
+
                            : "var(--color-fill-gray)"}>
+
                          Revision
+
                        </span>
+
                        <span
+
                          style:color={item.id === view.revision
+
                            ? "var(--color-foreground-match-background)"
+
                            : "var(--color-fill-secondary)"}
+
                          style:font-family="var(--font-family-monospace)">
+
                          {utils.formatObjectId(item.id)}
+
                        </span>
+
                      </DropdownListItem>
+
                    </Link>
+
                  </svelte:fragment>
+
                </DropdownList>
+
              </Popover>
+
            </div>
+
          {/if}
+
        </div>
      </div>
      {#if view.name === "diff"}
        <div style:margin-top="1rem">
@@ -570,20 +614,43 @@
            patchId={patch.id}
            expanded={index === patch.revisions.length - 1}
            previousRevId={previousRevision?.id}
-
            previousRevOid={previousRevision?.oid} />
+
            previousRevOid={previousRevision?.oid}>
+
            {#if index === patch.revisions.length - 1}
+
              {#if $httpdStore.state === "authenticated" && view.name === "activity"}
+
                <div class="connector" />
+
                <CommentTextarea
+
                  on:submit={async event => {
+
                    await createComment(event.detail.comment);
+
                  }} />
+
                <div class="connector" />
+
                <div style="display: flex;">
+
                  <CobStateButton
+
                    items={items.filter(
+
                      ([, state]) => !isEqual(state, patch.state),
+
                    )}
+
                    {selectedItem}
+
                    state={patch.state}
+
                    on:saveStatus={saveStatus} />
+
                </div>
+
              {/if}
+
            {/if}
+
          </RevisionComponent>
        {:else}
-
          <Placeholder emoji="🍂">
-
            <div slot="title">No activity</div>
-
            <div slot="body">No activity on this patch yet</div>
-
          </Placeholder>
+
          <div style:margin="4rem 0">
+
            <Placeholder
+
              iconName="no-patches"
+
              caption="No activity on this patch yet" />
+
          </div>
        {/each}
      {:else if view.name === "commits"}
        <div class="commit-list">
          {#each view.commits as commit}
-
            <CommitTeaser projectId={project.id} {baseUrl} {commit} />
+
            <div class="teaser-wrapper">
+
              <CommitTeaser projectId={project.id} {baseUrl} {commit} />
+
            </div>
          {/each}
        </div>
-
      {:else if view.name === "files"}
+
      {:else if view.name === "changes"}
        <div style:margin-top="1rem">
          <Changeset
            {baseUrl}
@@ -595,35 +662,6 @@
      {:else}
        {utils.unreachable(view.name)}
      {/if}
-
      {#if $httpdStore.state === "authenticated" && view.name === "activity"}
-
        <div style:margin-top="1rem">
-
          <Textarea
-
            resizable
-
            on:submit={async () => {
-
              await createComment();
-
              commentBody = "";
-
            }}
-
            bind:value={commentBody}
-
            placeholder="Leave your comment" />
-
          <div class="actions txt-small">
-
            <CobStateButton
-
              items={items.filter(([, state]) => !isEqual(state, patch.state))}
-
              {selectedItem}
-
              state={patch.state}
-
              on:saveStatus={saveStatus} />
-
            <Button
-
              variant="secondary"
-
              size="small"
-
              disabled={!commentBody}
-
              on:click={async () => {
-
                await createComment();
-
                commentBody = "";
-
              }}>
-
              Comment
-
            </Button>
-
          </div>
-
        </div>
-
      {/if}
    </div>

    <div class="metadata">
@@ -636,16 +674,14 @@
                class:review-accept={review.verdict === "accept"}
                class:review-reject={review.verdict === "reject"}>
                {#if review.verdict === "accept"}
-
                  <Icon size="small" name="checkmark" />
+
                  <IconSmall name="checkmark" />
                {:else if review.verdict === "reject"}
-
                  <Icon size="small" name="cross" />
+
                  <IconSmall name="cross" />
                {:else}
-
                  <Icon size="small" name="chat" />
+
                  <IconSmall name="chat" />
                {/if}
              </span>
-
              <Authorship
-
                authorId={review.author.id}
-
                authorAlias={review.author.alias} />
+
              <NodeId nodeId={review.author.id} alias={review.author.alias} />
            </div>
          {:else}
            <div class="txt-missing">No reviews</div>
modified src/views/projects/Patch/PatchTeaser.svelte
@@ -5,12 +5,14 @@
  import { HttpdClient } from "@httpd-client";
  import { formatObjectId, formatTimestamp } from "@app/lib/utils";

-
  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import Loading from "@app/components/Loading.svelte";

  export let projectId: string;
  export let baseUrl: BaseUrl;
@@ -31,56 +33,51 @@
    (acc, curr) => acc + curr.discussions.reduce(acc => acc + 1, 0),
    0,
  );
+
  let hover = false;
</script>

<style>
  .patch-teaser {
    display: flex;
-
    padding: 0.75rem 0;
-
    background-color: var(--color-foreground-1);
+
    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);
  }
  .patch-teaser:hover {
-
    background-color: var(--color-foreground-2);
+
    background-color: var(--color-fill-float-hover);
  }
-
  .meta {
+
  .content {
+
    gap: 0.5rem;
+
    display: flex;
+
    flex-direction: column;
+
  }
+
  .subtitle {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 0.5rem;
-
    color: var(--color-foreground-6);
-
    font-size: var(--font-size-tiny);
-
    font-family: var(--font-family-monospace);
-
    margin: 0 0.5rem;
-
  }
-
  .id {
-
    margin: 0;
+
    font-size: var(--font-size-small);
+
    flex-wrap: wrap;
  }
  .summary {
    display: flex;
    flex-direction: row;
-
    gap: 0.5rem;
-
    padding-right: 2rem;
-
  }
-
  .patch-title {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
+
    gap: 1rem;
  }
  .patch-title:hover {
    text-decoration: underline;
  }
  .right {
    display: flex;
-
    align-items: center;
+
    align-items: flex-start;
    gap: 1rem;
-
    padding-right: 1rem;
    margin-left: auto;
-
    color: var(--color-foreground-5);
  }
  .state {
    justify-self: center;
-
    align-self: center;
-
    margin: 0 1rem 0 1.25rem;
+
    align-self: flex-start;
+
    margin-right: 1.5rem;
  }
  .labels {
    display: flex;
@@ -88,25 +85,27 @@
    gap: 0.5rem;
  }
  .comments {
+
    color: var(--color-foreground-dim);
+
    font-size: var(--font-size-tiny);
    display: flex;
    align-items: center;
-
    gap: 0.5rem;
+
    gap: 0.25rem;
  }
  .label {
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .draft {
-
    color: var(--color-foreground-6);
+
    color: var(--color-foreground-gray);
  }
  .open {
-
    color: var(--color-positive-6);
+
    color: var(--color-fill-success);
  }
  .archived {
-
    color: var(--color-caution-6);
+
    color: var(--color-foreground-yellow);
  }
  .merged {
-
    color: var(--color-primary-6);
+
    color: var(--color-fill-primary);
  }
  @media (max-width: 960px) {
    .labels {
@@ -115,7 +114,12 @@
  }
</style>

-
<div class="patch-teaser">
+
<div
+
  role="button"
+
  tabindex="0"
+
  class="patch-teaser"
+
  on:mouseenter={() => (hover = true)}
+
  on:mouseleave={() => (hover = false)}>
  <div
    class="state"
    class:draft={patch.state.status === "draft"}
@@ -124,7 +128,7 @@
    class:archived={patch.state.status === "archived"}>
    <Icon name="patch" />
  </div>
-
  <div>
+
  <div class="content">
    <div class="summary">
      <Link
        route={{
@@ -134,44 +138,50 @@
          patch: patch.id,
        }}>
        <span class="patch-title">
-
          <InlineMarkdown content={patch.title} />
+
          <InlineMarkdown fontSize="regular" content={patch.title} />
        </span>
      </Link>
      <span class="labels">
        {#each patch.labels.slice(0, 4) as label}
-
          <Badge style="max-width:7rem" variant="secondary">
+
          <Badge
+
            style="max-width:7rem"
+
            variant={hover ? "background" : "neutral"}>
            <span class="label">{label}</span>
          </Badge>
        {/each}
        {#if patch.labels.length > 4}
-
          <Badge variant="foreground">
+
          <Badge
+
            title={patch.labels.slice(4, undefined).join(" ")}
+
            variant={hover ? "background" : "neutral"}>
            <span class="label">+{patch.labels.length - 4} more labels</span>
          </Badge>
        {/if}
      </span>
    </div>
    <div class="summary">
-
      <span class="meta id">
-
        {formatObjectId(patch.id)}
+
      <span class="subtitle">
+
        <span class="global-hash">{formatObjectId(patch.id)}</span>
        {patch.revisions.length > 1 ? "updated" : "opened"}
        {formatTimestamp(latestRevision.timestamp)} by
-
        <Authorship
-
          authorId={patch.author.id}
-
          authorAlias={patch.author.alias} />
+
        <NodeId nodeId={patch.author.id} alias={patch.author.alias} />
      </span>
    </div>
  </div>
  <div class="right">
-
    {#if commentCount > 0}
-
      <div class="comments">
-
        <Icon name="chat" />
-
        <span>{commentCount}</span>
-
      </div>
-
    {/if}
-
    {#await diffPromise then { diff }}
-
      <DiffStatBadge
-
        insertions={diff.stats.insertions}
-
        deletions={diff.stats.deletions} />
-
    {/await}
+
    <div style:display="flex" style:gap="1rem">
+
      {#if commentCount > 0}
+
        <div class="comments">
+
          <IconSmall name="chat" />
+
          <span>{commentCount}</span>
+
        </div>
+
      {/if}
+
      {#await diffPromise}
+
        <Loading small />
+
      {:then { diff }}
+
        <DiffStatBadge
+
          insertions={diff.stats.insertions}
+
          deletions={diff.stats.deletions} />
+
      {/await}
+
    </div>
  </div>
</div>
modified src/views/projects/Patches.svelte
@@ -1,18 +1,22 @@
<script lang="ts">
  import type { BaseUrl, Patch, PatchState, Project } from "@httpd-client";

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

  import Button from "@app/components/Button.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  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";
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import SquareButton from "@app/components/SquareButton.svelte";

  export let baseUrl: BaseUrl;
  export let patches: Patch[];
@@ -48,12 +52,6 @@
    }
  }

-
  interface Tab {
-
    value: PatchState["status"];
-
    title: string;
-
    disabled: boolean;
-
  }
-

  const stateOptions: PatchState["status"][] = [
    "draft",
    "open",
@@ -61,11 +59,12 @@
    "merged",
  ];

-
  $: options = stateOptions.map<Tab>(s => ({
-
    value: s,
-
    title: `${project.patches[s]} ${s}`,
-
    disabled: project.patches[s] === 0,
-
  }));
+
  const stateColor: Record<PatchState["status"], string> = {
+
    draft: "var(--color-fill-gray)",
+
    open: "var(--color-fill-success)",
+
    archived: "var(--color-foreground-yellow)",
+
    merged: "var(--color-fill-primary)",
+
  };

  $: showMoreButton =
    !loading && !error && allPatches.length < project.patches[state];
@@ -73,87 +72,94 @@

<style>
  .patches {
-
    padding: 0 2rem 0 8rem;
    font-size: var(--font-size-small);
  }
-
  .patches-list {
-
    border-radius: var(--border-radius);
-
    overflow: hidden;
-
  }
  .more {
    margin-top: 2rem;
-
    text-align: center;
    min-height: 3rem;
-
  }
-
  .teaser:not(:last-child) {
-
    border-bottom: 1px dashed var(--color-background);
-
  }
-

-
  @media (max-width: 960px) {
-
    .patches {
-
      padding-left: 2rem;
-
    }
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
  }
</style>

<Layout {baseUrl} {project} activeTab="patches">
  <div class="patches">
-
    <div style="margin-bottom: 2rem;">
-
      <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
-
        {#each options as option}
-
          {#if option.disabled}
-
            <SquareButton
-
              clickable={option.disabled}
-
              active={option.value === state}
-
              disabled={option.disabled}>
-
              {option.title}
-
            </SquareButton>
-
          {:else}
+
    <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"
+
            ariaLabel="filter-dropdown"
+
            title="Filter patches by state">
+
            <div style:color={stateColor[state]}>
+
              <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=${option.value}`,
+
                search: `state=${item}`,
              }}>
-
              <SquareButton
-
                clickable={option.disabled}
-
                active={option.value === state}
-
                disabled={option.disabled}>
-
                {option.title}
-
              </SquareButton>
+
              <DropdownListItem selected={item === state}>
+
                <div
+
                  style:color={item === state
+
                    ? "var(--color-foreground-white)"
+
                    : stateColor[item]}>
+
                  <Icon name="patch" />
+
                </div>
+
                {project.patches[item]}
+
                {item}
+
              </DropdownListItem>
            </Link>
-
          {/if}
-
        {/each}
+
          </DropdownList>
+
        </Popover>
      </div>
-
    </div>
-
    <div class="patches-list">
-
      {#each allPatches as patch (patch.id)}
-
        <div class="teaser">
-
          <PatchTeaser {baseUrl} projectId={project.id} {patch} />
-
        </div>
-
      {:else}
+

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

+
      <svelte:fragment slot="body">
        {#if error}
-
          <ErrorMessage message="Couldn't load patches." stackTrace={error} />
-
        {:else if loading}
-
          <!-- We already show a loader below. -->
-
        {:else}
-
          <Placeholder emoji="🍂">
-
            <div slot="title">{capitalize(state)} patches</div>
-
            <div slot="body">No patches matched the current filter</div>
-
          </Placeholder>
+
          <ErrorMessage message="Couldn't load patches" {error} />
        {/if}
-
      {/each}
-
    </div>
+

+
        {#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 class="more">
      {#if loading}
        <div style:margin-top={page === 0 ? "8rem" : ""}>
-
          <Loading small={page !== 0} center />
+
          <Loading noDelay small={page !== 0} center />
        </div>
      {/if}

      {#if showMoreButton}
-
        <Button variant="foreground" on:click={() => loadMore(state)}>
+
        <Button size="large" variant="outline" on:click={() => loadMore(state)}>
          More
        </Button>
      {/if}
modified src/views/projects/Source.svelte
@@ -7,12 +7,14 @@
  import { HttpdClient } from "@httpd-client";

  import Button from "@app/components/Button.svelte";
+
  import File from "@app/components/File.svelte";
+
  import Header from "./Source/Header.svelte";
  import Layout from "./Layout.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import Header from "./Source/Header.svelte";

  import BlobComponent from "./Source/Blob.svelte";
  import TreeComponent from "./Source/Tree.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";

  export let baseUrl: BaseUrl;
  export let blobResult: BlobResult;
@@ -75,35 +77,25 @@
  .container {
    display: flex;
    width: inherit;
-
    margin-bottom: 4rem;
-
    padding: 0 2rem 0 8rem;
  }

  .column-left {
    display: flex;
    flex-direction: column;
-
    padding-right: 1rem;
+
    padding-right: 1.5rem;
  }

  .column-right {
    display: flex;
    flex-direction: column;
-
    padding-left: 1rem;
    min-width: var(--content-min-width);
    width: 100%;
  }

-
  .placeholder {
-
    display: flex;
-
    flex-direction: column;
-
    width: 100%;
-
  }
-

  .source-tree {
    overflow-x: hidden;
-
  }
-
  nav {
-
    padding: 0 2rem;
+
    width: 17.5rem;
+
    padding-right: 0.25rem;
  }
  .sticky {
    position: sticky;
@@ -111,22 +103,12 @@
    max-height: 100vh;
  }

-
  @media (max-width: 960px) {
-
    .container {
-
      padding-left: 2rem;
-
    }
-
  }
-

  @media (max-width: 720px) {
    .column-right {
      padding: 1.5rem 0;
      min-width: 0;
    }
-
    .placeholder {
-
      padding: 1.5rem;
-
    }
    .source-tree {
-
      padding: 0 2rem;
      margin: 1rem 0;
    }
    .container {
@@ -137,44 +119,40 @@
      display: none;
      padding-right: 0;
    }
-
    .column-left-visible {
-
      display: block;
-
    }
    .sticky {
      max-height: initial;
    }
  }
</style>

-
<Layout {baseUrl} {project} {peer} activeTab="source">
-
  <Header
-
    node={baseUrl}
-
    {project}
-
    peers={peersWithRoute}
-
    branches={branchesWithRoute}
-
    {revision}
-
    {tree}
-
    historyLinkActive={false} />
-

-
  <main>
-
    <!-- Mobile navigation -->
-
    {#if tree.entries.length > 0}
-
      <nav class="layout-mobile">
-
        <Button
-
          style="width: 100%;"
-
          variant="secondary"
-
          on:click={() => {
-
            mobileFileTree = !mobileFileTree;
-
          }}>
-
          Browse
-
        </Button>
-
      </nav>
-
    {/if}
+
<Layout {baseUrl} {project} 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} />

-
    <div class="container center-content">
      {#if tree.entries.length > 0}
-
        <div class="column-left" class:column-left-visible={mobileFileTree}>
-
          <div class="source-tree sticky">
+
        <div class="layout-mobile">
+
          <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}
@@ -184,47 +162,58 @@
              {peer}
              {tree}
              on:select={() => {
-
                // Close mobile tree if user navigates to other file.
                mobileFileTree = false;
              }} />
          </div>
-
        </div>
-
        <div class="column-right">
-
          {#if blobResult.ok}
-
            <BlobComponent
-
              {baseUrl}
-
              projectId={project.id}
-
              {peer}
-
              {revision}
-
              {path}
-
              blob={blobResult.blob}
-
              highlighted={blobResult.highlighted}
-
              rawPath={utils.getRawBasePath(
-
                project.id,
-
                baseUrl,
-
                tree.lastCommit.id,
-
              )} />
-
          {:else}
-
            <Placeholder emoji="🍂">
-
              <span slot="title">
-
                <div class="txt-monospace">{blobResult.error.path}</div>
-
              </span>
-
              <span slot="body">
-
                {blobResult.error.message}
-
              </span>
-
            </Placeholder>
-
          {/if}
-
        </div>
-
      {:else}
-
        <div class="placeholder">
-
          <Placeholder emoji="👀">
-
            <span slot="title">Nothing to show</span>
-
            <span slot="body">
-
              We couldn't find any files at this revision.
-
            </span>
-
          </Placeholder>
-
        </div>
+
        {/if}
      {/if}
    </div>
-
  </main>
+
  </svelte:fragment>
+

+
  <div class="container center-content">
+
    {#if tree.entries.length > 0}
+
      <div class="column-left">
+
        <div class="source-tree sticky">
+
          <TreeComponent
+
            projectId={project.id}
+
            {revision}
+
            {baseUrl}
+
            {fetchTree}
+
            {path}
+
            {peer}
+
            {tree} />
+
        </div>
+
      </div>
+
      <div class="column-right">
+
        {#if blobResult.ok}
+
          <BlobComponent
+
            {baseUrl}
+
            projectId={project.id}
+
            {peer}
+
            {revision}
+
            {path}
+
            blob={blobResult.blob}
+
            highlighted={blobResult.highlighted}
+
            rawPath={utils.getRawBasePath(
+
              project.id,
+
              baseUrl,
+
              tree.lastCommit.id,
+
            )} />
+
        {:else}
+
          <File>
+
            <FilePath
+
              slot="left-header"
+
              filenameWithPath={blobResult.error.path} />
+
            <div style:margin="4rem 0" style:width="100%">
+
              <Placeholder iconName="no-file" caption="File not found" />
+
            </div>
+
          </File>
+
        {/if}
+
      </div>
+
    {:else}
+
      <div style:margin="4rem 0" style:width="100%">
+
        <Placeholder iconName="no-file" caption="No files at this revision" />
+
      </div>
+
    {/if}
+
  </div>
</Layout>
modified src/views/projects/Source/Blob.svelte
@@ -5,11 +5,18 @@
  import { toHtml } from "hast-util-to-html";

  import * as Syntax from "@app/lib/syntax";
-
  import { isMarkdownPath, twemoji } from "@app/lib/utils";
+
  import { isMarkdownPath } from "@app/lib/utils";
  import { lineNumbersGutter } from "@app/lib/syntax";
+
  import { routeToPath } from "@app/lib/router";

-
  import Readme from "./Readme.svelte";
-
  import SquareButton from "@app/components/SquareButton.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";

  export let baseUrl: BaseUrl;
  export let projectId: string;
@@ -22,11 +29,6 @@

  $: lastCommit = blob.lastCommit;

-
  $: parentDir = blob.path
-
    .match(/^.*\/|/)
-
    ?.values()
-
    .next().value;
-

  $: content = highlighted ? lineNumbersGutter(highlighted) : undefined;

  let selectedLineId: string | undefined = undefined;
@@ -47,9 +49,29 @@
  $: isMarkdown = isMarkdownPath(blob.path);
  $: showMarkdown = isMarkdown && selectedLineId === undefined;

-
  function toggleMarkdown() {
-
    window.location.hash = "";
-
    showMarkdown = !showMarkdown;
+
  let linkBaseUrl: string | undefined;
+

+
  $: {
+
    if (!path || path === "/") {
+
      // For the default root path, the `tree/<revision>` portion is omitted
+
      // from the URL. This means that links cannot be resolved with respect
+
      // to the current location. To work around this we provide path that
+
      // results a fully expanded URL with which we can resolve all links in the
+
      // Markdown.
+
      linkBaseUrl = new URL(
+
        routeToPath({
+
          resource: "project.source",
+
          project: projectId,
+
          node: baseUrl,
+
          peer,
+
          revision,
+
          path: "README.md",
+
        }),
+
        window.origin,
+
      ).href;
+
    } else {
+
      linkBaseUrl = undefined;
+
    }
  }

  afterUpdate(() => {
@@ -75,61 +97,37 @@
</script>

<style>
-
  header .file-header {
+
  .file-header {
    display: flex;
    height: 3rem;
    align-items: center;
-
    justify-content: space-between;
    padding: 0 0.5rem 0 1rem;
-
    color: var(--color-foreground);
    border-width: 1px 1px 0 1px;
-
    border-color: var(--color-foreground-3);
+
    border-color: var(--color-border-hint);
    border-style: solid;
    border-top-left-radius: var(--border-radius-small);
    border-top-right-radius: var(--border-radius-small);
  }

-
  .file-header .right {
+
  .right {
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: flex-end;
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    width: 100%;
-
  }
-

-
  header .file-name {
-
    font-weight: var(--font-weight-normal);
-
    flex-shrink: 0;
+
    gap: 0.5rem;
+
    margin-left: auto;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
-
    margin-right: 1rem;
-
  }
-

-
  .last-commit {
-
    padding: 0.5rem 0.75rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    font-size: var(--font-size-tiny);
-
    border-radius: var(--border-radius-small);
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .last-commit .hash {
-
    font-weight: var(--font-weight-bold);
-
    font-family: var(--font-family-monospace);
-
    margin-right: 0.25rem;
  }

-
  .toggle {
-
    margin-right: 0.5rem;
+
  .file-name {
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    flex-shrink: 0;
+
    padding-right: 0.5rem;
  }

  .code :global(.line-number) {
-
    color: var(--color-foreground-4);
+
    font-family: var(--font-family-monospace);
+
    color: var(--color-foreground-disabled);
    text-align: right;
    padding: 0;
    user-select: none;
@@ -140,7 +138,7 @@
  }
  .code :global(.line-number:hover) {
    cursor: pointer;
-
    color: var(--color-foreground);
+
    color: var(--color-foreground-gray);
  }

  .code :global(.content) {
@@ -153,10 +151,16 @@
    line-height: 22px; /* This seems to be the line-height of a pre code block */
  }
  .code :global(.highlight) {
-
    background-color: var(--color-caution-3);
+
    background-color: var(--color-fill-float-hover);
+
    box-shadow: 0 0 0 1px var(--color-fill-secondary);
+
  }
+
  .code :global(.highlight td:first-child) {
+
    background-color: var(--color-fill-float-hover);
+
    border-left: 1px solid var(--color-fill-secondary);
  }
-
  .code :global(.highlight td a) {
-
    color: var(--color-foreground);
+
  .code :global(.highlight td:last-child) {
+
    background-color: var(--color-fill-float-hover);
+
    border-right: 1px solid var(--color-fill-secondary);
  }

  .code :global(.line-content) {
@@ -174,108 +178,130 @@
  }

  .container {
-
    position: relative;
-
    display: flex;
    overflow-x: auto;
-
    border: 1px solid var(--color-foreground-3);
-
    border-top-style: dashed;
+
    border: 1px solid var(--color-border-hint);
+
    border-top-style: solid;
    border-bottom-left-radius: var(--border-radius-small);
    border-bottom-right-radius: var(--border-radius-small);
-
    background: var(--color-background-1);
-
  }
-

-
  .binary {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: center;
+
    background: var(--color-background-float);
    width: 100%;
-
    height: 16rem;
-
    background-color: var(--color-foreground-1);
-
    color: var(--color-foreground-6);
-
    font-family: var(--font-family-monospace);
-
  }
-
  .binary > * {
-
    margin-bottom: 1rem;
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
  }

  .no-scrollbar {
    scrollbar-width: none;
  }

-
  .markdown {
-
    max-width: 64rem;
-
  }
-

  .no-scrollbar::-webkit-scrollbar {
    display: none;
  }
-

-
  @media (max-width: 960px) {
-
    .code {
-
      font-size: var(--font-size-small);
-
    }
+
  .commit-teaser {
+
    display: flex;
+
    align-items: center;
+
    overflow: hidden;
+
    border-radius: var(--border-radius-tiny);
+
    background-color: var(--color-fill-ghost-hover);
+
    gap: 0.75rem;
+
    padding-right: 0.75rem;
+
    height: var(--button-small-height);
  }

  @media (max-width: 720px) {
-
    .right {
-
      justify-content: center;
+
    .commit-teaser {
+
      padding: 0 0.75rem;
+
    }
+
    .file-header {
+
      border-top-left-radius: 0;
+
      border-top-right-radius: 0;
+
      border-left: none;
+
      border-right: none;
+
    }
+
    .hash-button {
+
      display: none;
+
    }
+
    .container {
+
      border-left: none;
+
      border-right: none;
+
      border-bottom-left-radius: 0;
+
      border-bottom-right-radius: 0;
    }
  }
</style>

-
<div class:markdown={isMarkdown}>
-
  <header>
-
    <div class="file-header">
-
      <span class="file-name">
-
        <span style:color="var(--color-foreground-5)">{parentDir}</span>
-
        &#8203;
-
        <span>{blob.name}</span>
-
      </span>
-
      <div class="right">
-
        {#if isMarkdown}
-
          <div title="Toggle render method" class="toggle">
-
            <SquareButton clickable on:click={toggleMarkdown}>
-
              {showMarkdown ? "Plain" : "Markdown"}
-
            </SquareButton>
-
          </div>
-
        {/if}
-
        <a href="{rawPath}/{blob.path}" class="toggle">
-
          <SquareButton clickable>Raw</SquareButton>
-
        </a>
-
        <div class="last-commit" title={lastCommit.author.name} use:twemoji>
-
          <span class="hash">
-
            {lastCommit.id.slice(0, 7)}
-
          </span>
-
          {lastCommit.summary}
-
        </div>
+
<div class="file-header">
+
  <span class="file-name">
+
    <FilePath filenameWithPath={blob.path} />
+
  </span>
+
  <div class="right">
+
    <div class="commit-teaser">
+
      <div class="hash-button">
+
        <Link
+
          route={{
+
            resource: "project.commit",
+
            project: projectId,
+
            node: baseUrl,
+
            commit: lastCommit.id,
+
          }}>
+
          <Button variant="gray" styleBorderRadius="0">
+
            <span
+
              class="global-hash"
+
              style:font-weight="var(--font-weight-bold)">
+
              {lastCommit.id.slice(0, 7)}
+
            </span>
+
          </Button>
+
        </Link>
      </div>
+
      <InlineMarkdown fontSize="small" content={lastCommit.summary} />
+
    </div>
+
    <div class="layout-desktop-flex" style:gap="0.5rem">
+
      {#if isMarkdown}
+
        <Radio ariaLabel="Toggle render method">
+
          <Button
+
            styleBorderRadius="0"
+
            variant={showMarkdown ? "secondary" : "gray"}
+
            on:click={() => {
+
              window.location.hash = "";
+
              showMarkdown = true;
+
            }}>
+
            Plain
+
          </Button>
+
          <Button
+
            styleBorderRadius="0"
+
            variant={!showMarkdown ? "secondary" : "gray"}
+
            on:click={() => {
+
              showMarkdown = false;
+
            }}>
+
            Markdown
+
          </Button>
+
        </Radio>
+
      {/if}
+
      <a href="{rawPath}/{blob.path}">
+
        <Button variant="secondary">
+
          Raw
+
          <IconSmall name="arrow-box-up-right" />
+
        </Button>
+
      </a>
    </div>
-
  </header>
-
  <div class="container">
-
    {#if blob.binary}
-
      <div class="binary">
-
        <div use:twemoji>👀</div>
-
        <span class="txt-tiny">Binary content</span>
-
      </div>
-
    {:else if showMarkdown && blob.content}
-
      <Readme
-
        {baseUrl}
-
        {projectId}
-
        {peer}
-
        {revision}
-
        content={blob.content}
-
        {rawPath}
-
        {path} />
-
    {:else if content}
-
      <table class="code no-scrollbar">
-
        {@html toHtml(content)}
-
      </table>
-
    {:else}
-
      <div class="binary">
-
        <div use:twemoji>🍂</div>
-
        <span class="txt-tiny">Empty file</span>
-
      </div>
-
    {/if}
  </div>
</div>
+

+
<div class="container">
+
  {#if blob.binary}
+
    <div style:margin="4rem 0" style:width="100%">
+
      <Placeholder iconName="binary-file" caption="Binary file" />
+
    </div>
+
  {:else if showMarkdown && blob.content}
+
    <div style:padding="2rem">
+
      <Markdown {linkBaseUrl} content={blob.content} {rawPath} {path} />
+
    </div>
+
  {:else if content}
+
    <table class="code no-scrollbar">
+
      {@html toHtml(content)}
+
    </table>
+
  {:else}
+
    <div style:margin="4rem 0" style:width="100%">
+
      <Placeholder iconName="empty-file" caption="Empty file" />
+
    </div>
+
  {/if}
+
</div>
modified src/views/projects/Source/BranchSelector.svelte
@@ -1,85 +1,91 @@
<script lang="ts">
+
  import type { BaseUrl, Project } from "@httpd-client";
+
  import type { Route } from "@app/lib/router";
+

  import * as utils from "@app/lib/utils";
-
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import { closeFocused } from "@app/components/Popover.svelte";

-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
-
  import Floating from "@app/components/Floating.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
-
  import type { Route } from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";

+
  export let branches: Array<{ name: string; route: Route }>;
+
  export let node: BaseUrl;
+
  export let project: Project;
  export let selectedBranch: string | undefined;
  export let selectedCommitId: string;
-
  export let branches: Array<{ name: string; route: Route }>;

  $: hideDropdown = branches.length <= 1;
  $: selectedCommitShortId = utils.formatCommit(selectedCommitId);
</script>

<style>
-
  .commit {
+
  .branch {
    display: flex;
    align-items: center;
    justify-content: center;
-
    line-height: initial;
-

-
    font-family: var(--font-family-monospace);
-
    color: var(--color-secondary);
-
  }
-
  .branch-name {
-
    height: 2rem;
-
    padding: 0.5rem 0.75rem;
-
    background-color: var(--color-secondary-2);
-
    border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
-
  }
-
  .branch-name.not-allowed {
-
    cursor: not-allowed;
-
  }
-
  .branch-name:hover:not(.not-allowed) {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .commit-id {
-
    height: 2rem;
-
    padding: 0.5rem 0.75rem;
-
    background-color: var(--color-secondary-1);
-
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
+
    gap: 1px;
  }
-
  .commit-id.standalone {
-
    border-radius: var(--border-radius-small);
+

+
  .identifier {
+
    display: flex;
+
    gap: 0.5rem;
  }
</style>

-
<div class="commit" title="Current branch">
+
<div class="branch" title="Current branch">
  {#if selectedBranch}
-
    <Floating disabled={hideDropdown}>
-
      <div
+
    <Popover
+
      popoverPadding="0"
+
      popoverPositionTop="2.5rem"
+
      popoverBorderRadius="var(--border-radius-small)"
+
      disabled={hideDropdown}>
+
      <Button
+
        let:expanded
        slot="toggle"
+
        styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
        title="Change branch"
-
        class="branch-name"
-
        class:not-allowed={hideDropdown}>
-
        {selectedBranch}
-
      </div>
-
      <Dropdown slot="modal" items={branches}>
-
        <Link
-
          slot="item"
-
          let:item
-
          route={item.route}
-
          on:afterNavigate={() => closeFocused()}>
-
          <DropdownItem selected={item.name === selectedBranch} size="tiny">
-
            {item.name}
-
          </DropdownItem>
-
        </Link>
-
      </Dropdown>
-
    </Floating>
-
    <div class="commit-id">
-
      {selectedCommitShortId}
-
    </div>
-
  {:else}
-
    <div class="commit-id standalone layout-desktop">
-
      {selectedCommitId}
-
    </div>
-
    <div class="commit-id standalone layout-mobile">
-
      {selectedCommitShortId}
-
    </div>
+
        disabled={hideDropdown}>
+
        <IconSmall name="branch" />
+
        <div class="identifier">{selectedBranch}</div>
+
        <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
      </Button>
+

+
      <DropdownList slot="popover" items={branches}>
+
        <svelte:fragment slot="item" let:item>
+
          <Link route={item.route} on:afterNavigate={() => closeFocused()}>
+
            <DropdownListItem selected={item.name === selectedBranch}>
+
              <div class="identifier">{item.name}</div>
+
            </DropdownListItem>
+
          </Link>
+
        </svelte:fragment>
+
      </DropdownList>
+
    </Popover>
  {/if}
+

+
  <Button
+
    styleBorderRadius={selectedBranch
+
      ? "0 var(--border-radius-tiny) var(--border-radius-tiny) 0"
+
      : "var(--border-radius-tiny)"}>
+
    <Link
+
      route={{
+
        resource: "project.commit",
+
        project: project.id,
+
        node,
+
        commit: selectedCommitId,
+
      }}>
+
      <div
+
        class="identifier global-hash"
+
        style:font-weight="var(--font-weight-bold)">
+
        {#if !selectedBranch}
+
          <IconSmall name="branch" />
+
        {/if}
+

+
        {selectedCommitShortId}
+
      </div>
+
    </Link>
+
  </Button>
</div>
modified src/views/projects/Source/Header.svelte
@@ -7,12 +7,15 @@
  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 SquareButton from "@app/components/SquareButton.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Radio from "@app/components/Radio.svelte";

  export let node: BaseUrl;
  export let branches: Array<{ name: string; route: Route }>;
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
+
  export let filesLinkActive: boolean;
  export let historyLinkActive: boolean;
  export let revision: string | undefined;
  export let tree: Tree;
@@ -35,20 +38,15 @@
<style>
  .header {
    font-size: var(--font-size-tiny);
-
    padding: 0 2rem 0 8rem;
    display: flex;
    align-items: center;
    justify-content: left;
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
    margin-bottom: 2rem;
+
    gap: 1rem;
  }

  @media (max-width: 960px) {
    .header {
-
      padding-left: 2rem;
-
    }
-
    .header {
      margin-bottom: 1.5rem;
    }
  }
@@ -59,24 +57,54 @@
    <PeerSelector {peers} />
  {/if}

-
  <BranchSelector {branches} selectedCommitId={commitId} {selectedBranch} />
+
  <BranchSelector
+
    {branches}
+
    {project}
+
    {node}
+
    selectedCommitId={commitId}
+
    {selectedBranch} />
+

+
  <Radio>
+
    <Link
+
      route={{
+
        resource: "project.source",
+
        project: project.id,
+
        node: node,
+
        peer,
+
        revision,
+
      }}>
+
      <Button
+
        styleBorderRadius="0"
+
        variant={filesLinkActive ? "secondary" : "gray"}>
+
        <IconSmall name="file" />Files
+
      </Button>
+
    </Link>

-
  <Link
-
    route={{
-
      resource: "project.history",
-
      project: project.id,
-
      node: node,
-
      peer,
-
      revision,
-
    }}>
-
    <SquareButton active={historyLinkActive}>
-
      <span class="txt-bold">{tree.stats.commits}</span>
-
      {pluralize("commit", tree.stats.commits)}
-
    </SquareButton>
-
  </Link>
+
    <Link
+
      route={{
+
        resource: "project.history",
+
        project: project.id,
+
        node: node,
+
        peer,
+
        revision,
+
      }}>
+
      <Button
+
        styleBorderRadius="0"
+
        variant={historyLinkActive ? "secondary" : "gray"}>
+
        <IconSmall name="commit" />
+
        <div>
+
          {tree.stats.commits}
+
          {pluralize("commit", tree.stats.commits)}
+
        </div>
+
      </Button>
+
    </Link>

-
  <SquareButton hoverable={false}>
-
    <span class="txt-bold">{tree.stats.contributors}</span>
-
    {pluralize("contributor", tree.stats.contributors)}
-
  </SquareButton>
+
    <Button styleBorderRadius="0" disabled>
+
      <IconSmall name="user" />
+
      <div>
+
        {tree.stats.contributors}
+
        {pluralize("contributor", tree.stats.contributors)}
+
      </div>
+
    </Button>
+
  </Radio>
</div>
modified src/views/projects/Source/PeerSelector.svelte
@@ -2,17 +2,18 @@
  import type { Remote } from "@httpd-client";
  import { type Route } from "@app/lib/router";

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

-
  import Avatar from "@app/components/Avatar.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
  import Badge from "@app/components/Badge.svelte";
-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
-
  import Floating from "@app/components/Floating.svelte";
-
  import Icon from "@app/components/Icon.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Button from "@app/components/Button.svelte";

  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;

@@ -27,104 +28,65 @@
</script>

<style>
-
  .selector {
+
  .avatar-id {
    display: flex;
+
    gap: 0.5rem;
+
    color: var(--color-fill-secondary);
    align-items: center;
    justify-content: center;
-
    font-family: var(--font-family-monospace);
-
  }
-
  .selector .peer {
-
    padding: 0.5rem 0.75rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    border-radius: var(--border-radius-small);
-
  }
-
  .selector .peer.not-allowed {
-
    cursor: not-allowed;
-
  }
-
  .peer:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .prefix {
-
    display: inline-block;
-
    color: var(--color-secondary-6);
-
  }
-
  .stat {
-
    display: flex;
-
    align-items: center;
-
    font-family: var(--font-family-monospace);
-
    padding: 0.5rem;
-
    height: 2rem;
-
    line-height: initial;
-
    background: var(--color-foreground-1);
-
    gap: 0.5rem;
-
  }
-
  .avatar-id {
-
    display: flex;
-
    gap: 0.25rem;
  }
-
  .alias {
-
    color: var(--color-secondary-6);
+
  .avatar-id.selected {
+
    color: red;
  }
</style>

-
<Floating>
-
  <div slot="toggle" class="selector" title="Change peer">
-
    <div class="stat peer" class:not-allowed={!peers}>
-
      {#if selectedPeer}
-
        <span class="avatar-id">
-
          <Avatar nodeId={selectedPeer.id} inline />
-
          <!-- Ignore prettier to avoid getting a whitespace between
-
             did:key: and the nid due to a newline. -->
-
          <!-- prettier-ignore -->
-
          <span><span style:color="var(--color-secondary-5)">did:key:</span>{truncateId(selectedPeer.id)}</span>
-
          {#if selectedPeer.alias}
-
            <span class="alias">({selectedPeer.alias})</span>
-
          {/if}
-
        </span>
-
        {#if selectedPeer.delegate}
-
          <Badge variant="primary">delegate</Badge>
-
        {/if}
-
      {:else}
-
        <Icon size="small" name="fork" />{peers.length}
-
        {pluralize("remote", peers.length)}
+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="2.5rem"
+
  popoverBorderRadius="var(--border-radius-small)">
+
  <Button let:expanded slot="toggle" title="Change peer" disabled={!peers}>
+
    {#if !selectedPeer}
+
      <IconSmall name="delegate" />
+
    {/if}
+

+
    {#if selectedPeer}
+
      <NodeId nodeId={selectedPeer.id} alias={selectedPeer.alias} />
+
      {#if selectedPeer.delegate}
+
        <Badge size="tiny" variant="secondary">delegate</Badge>
      {/if}
-
    </div>
-
  </div>
+
    {:else}
+
      {peers.length}
+
      {pluralize("remote", peers.length)}
+
    {/if}
+
    <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
  </Button>

-
  <svelte:fragment slot="modal">
-
    <Dropdown items={peers}>
-
      <svelte:fragment slot="item" let:item>
-
        <div class="dropdown-item">
-
          <Link on:afterNavigate={() => closeFocused()} route={item.route}>
-
            <DropdownItem
-
              selected={item.selected}
-
              title={createTitle(item.remote)}
-
              size="tiny">
-
              <span class="avatar-id">
-
                <Avatar nodeId={item.remote.id} inline />
-
                <div class="layout-desktop">
-
                  <!-- prettier-ignore -->
-
                  <span><span class="prefix">did:key:</span>{item.remote.id}</span>
-
                  {#if item.remote.alias}
-
                    <span class="alias">({item.remote.alias})</span>
-
                  {/if}
-
                </div>
-
                <div class="layout-mobile">
-
                  <!-- prettier-ignore -->
-
                  <span><span class="prefix">did:key:</span>{truncateId(item.remote.id)}</span>
-
                  {#if item.remote.alias}
-
                    <span class="alias">({item.remote.alias})</span>
-
                  {/if}
-
                </div>
-
              </span>
-
              {#if item.remote.delegate}
-
                <Badge variant="primary">delegate</Badge>
-
              {/if}
-
            </DropdownItem>
-
          </Link>
-
        </div>
-
      </svelte:fragment>
-
    </Dropdown>
-
  </svelte:fragment>
-
</Floating>
+
  <DropdownList slot="popover" items={peers}>
+
    <svelte:fragment slot="item" let:item>
+
      <Link on:afterNavigate={() => closeFocused()} route={item.route}>
+
        <DropdownListItem
+
          selected={item.selected}
+
          title={createTitle(item.remote)}>
+
          <span class="avatar-id" class:selected={item.selected}>
+
            <NodeId
+
              disableTooltip
+
              styleColor={item.selected
+
                ? "var(--color-foreground-match-background)"
+
                : undefined}
+
              nodeId={item.remote.id}
+
              alias={item.remote.alias} />
+
          </span>
+
          {#if item.remote.delegate}
+
            <div style:color="var(--color-fill-secondary)">
+
              <Badge
+
                size="tiny"
+
                variant={item.selected ? "background" : "secondary"}>
+
                delegate
+
              </Badge>
+
            </div>
+
          {/if}
+
        </DropdownListItem>
+
      </Link>
+
    </svelte:fragment>
+
  </DropdownList>
+
</Popover>
deleted src/views/projects/Source/Readme.svelte
@@ -1,53 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
-

-
  import Markdown from "@app/components/Markdown.svelte";
-
  import { routeToPath } from "@app/lib/router";
-

-
  export let projectId: string;
-
  export let peer: string | undefined;
-
  export let baseUrl: BaseUrl;
-
  export let revision: string | undefined;
-
  export let content: string;
-
  export let path: string;
-
  export let rawPath: string;
-

-
  let linkBaseUrl: string | undefined;
-

-
  $: {
-
    if (!path || path === "/") {
-
      // For the default root path, the `tree/<revision>` portion is omitted
-
      // from the URL. This means that links cannot be resolved with respect
-
      // to the current location. To work around this we provide path that
-
      // results a fully expanded URL with which we can resolve all links in the
-
      // Markdown.
-
      linkBaseUrl = new URL(
-
        routeToPath({
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          peer,
-
          revision,
-
          path: "README.md",
-
        }),
-
        window.origin,
-
      ).href;
-
    } else {
-
      linkBaseUrl = undefined;
-
    }
-
  }
-
</script>
-

-
<style>
-
  article {
-
    padding: 2rem;
-
    width: 100%;
-
    background: var(--color-background-1);
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-
</style>
-

-
<article>
-
  <Markdown {linkBaseUrl} {content} {rawPath} {path} />
-
</article>
modified src/views/projects/Source/Tree/File.svelte
@@ -7,23 +7,27 @@

<style>
  .file {
-
    color: var(--color-foreground-6);
-
    border-radius: var(--border-radius-small);
+
    border-radius: var(--border-radius-tiny);
    cursor: pointer;
    display: flex;
    line-height: 1.5em;
-
    margin: 0.125rem 0;
+
    margin: 0.25rem 0;
    padding: 0.25rem;
    width: 100%;
  }

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

  .file.active {
-
    color: var(--color-foreground) !important;
-
    background-color: var(--color-foreground-1);
+
    color: var(--color-foreground-match-background) !important;
+
    background-color: var(--color-fill-secondary);
+
  }
+

+
  .file.active:hover {
+
    color: var(--color-foreground-match-background) !important;
+
    background-color: var(--color-fill-secondary-hover);
  }

  .name {
@@ -32,14 +36,18 @@
    white-space: nowrap;
    text-overflow: ellipsis !important;
    overflow: hidden;
-
    max-width: 24ch;
+
    font-size: var(--font-size-regular);
+
    font-weight: var(--font-weight-medium);
  }
  .icon-container {
-
    color: var(--color-foreground-5);
+
    color: var(--color-fill-secondary);
    display: flex;
    justify-content: center;
    align-items: center;
  }
+
  .active .icon-container {
+
    color: var(--color-foreground-match-background);
+
  }
</style>

<div class="file" class:active>
modified src/views/projects/Source/Tree/Folder.svelte
@@ -43,25 +43,25 @@
    display: flex;
    cursor: pointer;
    padding: 0.25rem;
-
    margin: 0.125rem 0;
-
    color: var(--color-foreground-5);
+
    margin: 0.25rem 0;
    user-select: none;
    line-height: 1.5rem;
    white-space: nowrap;
  }
  .folder:hover {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-tiny);
  }

  .folder-name {
    margin-left: 0.25rem;
-
    color: var(--color-secondary-6);
+
    font-size: var(--font-size-regular);
+
    font-weight: var(--font-weight-medium);
  }

  .container {
-
    padding-left: 0.5rem;
-
    margin: 0 0 0 0.5rem;
+
    padding-left: 1rem;
+
    margin-left: 0.5rem;
  }

  .loading {
@@ -72,12 +72,12 @@
    display: flex;
    justify-content: center;
    align-items: center;
+
    color: var(--color-fill-secondary);
  }
</style>

<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<!-- svelte-ignore a11y-no-static-element-interactions -->
-
<div class="folder" on:click={onClick}>
+
<div role="button" tabindex="0" class="folder" on:click={onClick}>
  <div class="icon-container">
    {#if expanded}
      <Icon name="folder-open" />
modified src/views/projects/router.ts
@@ -83,7 +83,7 @@ interface ProjectPatchRoute {
        name: "activity";
      }
    | {
-
        name: "commits" | "files";
+
        name: "commits" | "changes";
        revision?: string;
      }
    | {
@@ -191,7 +191,7 @@ export type PatchView =
      revision: string;
    }
  | {
-
      name: "commits" | "files";
+
      name: "commits" | "changes";
      revision: string;
      oid: string;
      diff: Diff;
@@ -529,7 +529,7 @@ async function loadPatchView(
      break;
    }
    case "commits":
-
    case "files": {
+
    case "changes": {
      const revisionId = route.view.revision;
      const revision =
        patch.revisions.find(r => r.id === revisionId) || latestRevision;
@@ -724,7 +724,7 @@ function resolvePatchesRoute(
      }
    }

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

  pathSegments.push("patches", route.patch);
-
  if (route.view?.name === "commits" || route.view?.name === "files") {
+
  if (route.view?.name === "commits" || route.view?.name === "changes") {
    if (route.view.revision) {
      pathSegments.push(route.view.revision);
    }
deleted src/views/session/AuthenticatedModal.svelte
@@ -1,10 +0,0 @@
-
<script lang="ts">
-
  import Modal from "@app/components/Modal.svelte";
-
</script>
-

-
<Modal title="Authenticated" emoji="🤝">
-
  <div slot="subtitle">
-
    You're now connected to your <br />
-
    local Radicle node.
-
  </div>
-
</Modal>
deleted src/views/session/AuthenticationErrorModal.svelte
@@ -1,12 +0,0 @@
-
<script lang="ts">
-
  import Modal from "@app/components/Modal.svelte";
-

-
  export let title: string;
-
  export let subtitle: string[];
-
</script>
-

-
<Modal {title} emoji="🚨">
-
  <div slot="subtitle">
-
    {@html subtitle.join("<br />")}
-
  </div>
-
</Modal>
modified src/views/session/Index.svelte
@@ -8,8 +8,8 @@
  import * as httpd from "@app/lib/httpd";
  import Loading from "@app/components/Loading.svelte";

-
  import AuthenticatedModal from "@app/views/session/AuthenticatedModal.svelte";
-
  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
+
  import AuthenticatedModal from "@app/modals/AuthenticatedModal.svelte";
+
  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";

  export let activeRoute: Extract<Route, { resource: "session" }>;

@@ -18,13 +18,6 @@

    if (isAuthenticated) {
      modal.show({ component: AuthenticatedModal, props: {} });
-
      void router.push({
-
        resource: "nodes",
-
        params: {
-
          baseUrl: httpd.api.baseUrl,
-
          projectPageIndex: 0,
-
        },
-
      });
    } else {
      modal.show({
        component: AuthenticationErrorModal,
@@ -36,8 +29,8 @@
          ],
        },
      });
-
      void router.push({ resource: "home" });
    }
+
    void router.push({ resource: "home" });
  });
</script>

modified tests/e2e/clipboard.spec.ts
@@ -42,15 +42,13 @@ test("copy to clipboard", async ({ page, browserName, context }) => {

  // `rad clone` URL.
  {
-
    await page.getByRole("button", { name: "Clone" }).click();
-
    await page.getByText("rad clone").hover();
+
    await page.getByRole("button", { name: "Clone" }).first().click();
    await page.getByText("rad clone").locator(".clipboard").first().click();
    await expectClipboard(`rad clone ${sourceBrowsingRid}`, page);
  }

  // `git clone` URL.
  {
-
    await page.getByText("git clone").hover();
    await page.getByText("git clone").locator(".clipboard").first().click();
    await expectClipboard(
      `git clone http://127.0.0.1/${sourceBrowsingRid.replace(
modified tests/e2e/hotkeys.spec.ts
@@ -22,7 +22,9 @@ test("global hotkeys", async ({ page }) => {
    await expect(page.getByPlaceholder(searchPlaceholder)).toHaveValue(
      "searchquery?",
    );
-
    await expect(page.getByText("Keyboard shortcuts")).not.toBeVisible();
+
    await expect(
+
      page.locator(".modal").getByText("Keyboard shortcuts"),
+
    ).not.toBeVisible();
  }

  // Hitting `Esc` defocuses the input.
modified tests/e2e/httpd.spec.ts
@@ -7,7 +7,7 @@ test("rad web command reacts to port change", async ({ page, peerManager }) => {
  await peer.startHttpd(8090);

  await page.goto("/");
-
  await page.getByRole("button", { name: "radicle.local" }).click();
+
  await page.getByRole("button", { name: "Read only" }).click();

  await expect(
    page.getByText(
modified tests/e2e/modal.spec.ts
@@ -3,16 +3,24 @@ import { test, expect } from "@tests/support/fixtures.js";
test("open and close modal", async ({ page }) => {
  await page.goto("/");
  await page.locator("body").press(`?`);
-
  await expect(page.getByText("Keyboard shortcuts")).toBeVisible();
+
  await expect(
+
    page.locator(".modal").getByText("Keyboard shortcuts"),
+
  ).toBeVisible();

  // Close modal by pressing the `Esc` key.
  await page.locator("body").press("Escape");
-
  await expect(page.getByText("Keyboard shortcuts")).not.toBeVisible();
+
  await expect(
+
    page.locator(".modal").getByText("Keyboard shortcuts"),
+
  ).not.toBeVisible();

  await page.locator("body").press(`?`);
-
  await expect(page.getByText("Keyboard shortcuts")).toBeVisible();
+
  await expect(
+
    page.locator(".modal").getByText("Keyboard shortcuts"),
+
  ).toBeVisible();

  // Close modal by clicking outside of it.
  await page.locator(".overlay").click({ position: { x: 10, y: 10 } });
-
  await expect(page.getByText("Keyboard shortcuts")).not.toBeVisible();
+
  await expect(
+
    page.locator(".modal").getByText("Keyboard shortcuts"),
+
  ).not.toBeVisible();
});
modified tests/e2e/node.spec.ts
@@ -1,8 +1,8 @@
import {
  aliceMainHead,
  expect,
+
  shortNodeRemote,
  sourceBrowsingRid,
-
  nodeRemote,
  test,
} from "@tests/support/fixtures.js";

@@ -12,9 +12,7 @@ test("node metadata", async ({ page }) => {
  await expect(
    page.locator(".header").getByText("radicle.local"),
  ).toBeVisible();
-
  await expect(
-
    page.getByText(`${nodeRemote.substring(0, 6)}…${nodeRemote.slice(-6)}`),
-
  ).toBeVisible();
+
  await expect(page.getByText(shortNodeRemote)).toBeVisible();
  await expect(page.getByText("0.1.0-")).toBeVisible();
});

modified tests/e2e/project.spec.ts
@@ -2,11 +2,9 @@ import type { Page } from "@playwright/test";

import {
  aliceMainHead,
-
  aliceRemote,
  bobHead,
-
  bobRemote,
-
  expect,
  cobUrl,
+
  expect,
  markdownUrl,
  sourceBrowsingRid,
  sourceBrowsingUrl,
@@ -76,12 +74,12 @@ test("show source tree at specific revision", async ({ page }) => {

  await page
    .locator(".teaser", { hasText: "335dd6d" })
-
    .getByTitle("Browse the repository at this point in the history")
+
    .getByRole("button", {
+
      name: "Browse the repository at this point in the history",
+
    })
    .click();

-
  await expect(page.getByTitle("Current branch")).toContainText(
-
    "335dd6dc89b535a4a31e9422c803199bb6b0a09a",
-
  );
+
  await expect(page.getByTitle("Current branch")).toContainText("335dd6d");
  await expect(page.locator(".source-tree")).toHaveText("bin src");
  await expectCounts({ commits: 2, contributors: 1 }, page);
});
@@ -100,7 +98,7 @@ test("source file highlighting", async ({ page }) => {

test("navigate line numbers", async ({ page }) => {
  await page.goto(`${markdownUrl}/tree/main/cheatsheet.md`);
-
  await page.getByRole("button", { name: "Plain" }).click();
+
  await page.getByRole("button", { name: "Markdown" }).click();

  await page.getByRole("link", { name: "5", exact: true }).click();
  await expect(page.locator("#L5")).toHaveClass("line highlight");
@@ -117,7 +115,9 @@ test("navigate line numbers", async ({ page }) => {
  // Check that we go back to the Markdown view when navigating to a different
  // file.
  await page.getByRole("link", { name: "footnotes.md" }).click();
-
  await expect(page.getByRole("button", { name: "Plain" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
+
    /secondary/,
+
  );
});

test("navigate deep file hierarchies", async ({ page }) => {
@@ -212,7 +212,7 @@ test("binary files", async ({ page }) => {
  await page.getByText("bin").click();
  await page.getByText("true").click();

-
  await expect(page.getByText("Binary content")).toBeVisible();
+
  await expect(page.getByText("Binary file")).toBeVisible();
});

test("empty files", async ({ page }) => {
@@ -241,12 +241,21 @@ test("markdown files", async ({ page }) => {

  // Switch between raw and rendered modes.
  {
-
    const toggleButton = page.getByTitle("Toggle render method");
-
    await expect(toggleButton).toHaveText("Plain");
-
    await toggleButton.click();
+
    await expect(page.getByRole("button", { name: "Plain" })).toHaveClass(
+
      /secondary/,
+
    );
+
    await expect(
+
      page.getByRole("button", { name: "Markdown" }),
+
    ).not.toHaveClass(/secondary/);
+
    await page.getByRole("button", { name: "Markdown" }).click();
+
    await expect(page.getByRole("button", { name: "Plain" })).not.toHaveClass(
+
      /secondary/,
+
    );
+
    await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
+
      /secondary/,
+
    );
    await expect(page.getByText("##### Table of Contents")).toBeVisible();
-
    await expect(toggleButton).toHaveText("Markdown");
-
    await toggleButton.click();
+
    await page.getByRole("button", { name: "Plain" }).click();
  }

  // Internal links go to anchor.
@@ -276,19 +285,12 @@ test("peer and branch switching", async ({ page }) => {
  // Alice's peer.
  {
    await page.getByTitle("Change peer").click();
-
    await page.getByText(`${aliceRemote}`).click();
-
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      `did:key:${aliceRemote.substring(8).substring(0, 6)}…${aliceRemote.slice(
-
        -6,
-
      )} (alice) delegate`,
-
    );
-
    await expect(
-
      page.getByText(
-
        `source-browsing / did:key:${aliceRemote
-
          .substring(8)
-
          .substring(0, 6)}…${aliceRemote.slice(-6)}`,
-
      ),
-
    ).toBeVisible();
+
    await page
+
      .getByRole("link", {
+
        name: "alice delegate",
+
      })
+
      .click();
+
    await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");

    // Default `main` branch.
    {
@@ -319,9 +321,7 @@ test("peer and branch switching", async ({ page }) => {
      );
      await expectCounts({ commits: 1, contributors: 1 }, page);

-
      await expect(
-
        page.getByText("We couldn't find any files at this revision."),
-
      ).toBeVisible();
+
      await expect(page.getByText("No files at this revision")).toBeVisible();
    }
  }

@@ -341,12 +341,8 @@ test("peer and branch switching", async ({ page }) => {
  // Bob's peer.
  {
    await page.getByTitle("Change peer").click();
-
    await page.getByText(bobRemote).click();
-
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      `did:key:${bobRemote.substring(8).substring(0, 6)}…${bobRemote.slice(
-
        -6,
-
      )} (bob)`,
-
    );
+
    await page.getByRole("link", { name: "bob" }).click();
+
    await expect(page.getByTitle("Change peer")).toContainText("bob");
    await expect(page.getByTitle("Change peer")).not.toHaveText("delegate");

    // Default `main` branch.
@@ -366,30 +362,34 @@ test("only one modal can be open at a time", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);

  await page.getByTitle("Change peer").click();
-
  await page.getByText(aliceRemote).click();
+
  await page
+
    .getByRole("link", {
+
      name: "alice delegate",
+
    })
+
    .click();

  await page.getByText("Clone").click();
  await expect(page.getByText("Code font")).not.toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).toBeVisible();
-
  await expect(page.getByText("bob hyyzz9")).not.toBeVisible();
+
  await expect(page.getByText("bob")).not.toBeVisible();
  await expect(page.getByText("feature/branch")).not.toBeVisible();

-
  await page.getByRole("button", { name: "Settings" }).click();
+
  await page.getByRole("button", { name: "Theme" }).first().click();
  await expect(page.getByText("Code font")).toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
-
  await expect(page.getByText("bob hyyzz9")).not.toBeVisible();
+
  await expect(page.getByText("bob")).not.toBeVisible();
  await expect(page.getByText("feature/branch")).not.toBeVisible();

  await page.getByTitle("Change branch").click();
  await expect(page.getByText("Code font")).not.toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
-
  await expect(page.getByText("bob hyyzz9")).not.toBeVisible();
+
  await expect(page.getByText("bob")).not.toBeVisible();
  await expect(page.getByText("feature/branch")).toBeVisible();

  await page.getByTitle("Change peer").click();
  await expect(page.getByText("Code font")).not.toBeVisible();
  await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible();
-
  await expect(page.getByText(bobRemote)).toBeVisible();
+
  await expect(page.getByText("bob")).toBeVisible();
  await expect(page.getByText("feature/branch")).not.toBeVisible();
});

@@ -405,7 +405,7 @@ test.describe("browser error handling", () => {
    const sourceTree = page.locator(".source-tree");
    await sourceTree.getByText("src").click();

-
    await expect(page.getByText("Not able to expand directory")).toBeVisible();
+
    await expect(page.getByText("File not found")).toBeVisible();
  });
  test("error appears when file can't be loaded", async ({ page }) => {
    await page.route(
@@ -416,7 +416,7 @@ test.describe("browser error handling", () => {
    await page.goto(sourceBrowsingUrl);
    await page.getByText(".hidden").click();

-
    await expect(page.getByText("Not able to load file")).toBeVisible();
+
    await expect(page.getByText("File not found")).toBeVisible();
  });
  test("error appears when README can't be loaded", async ({ page }) => {
    await page.route(
@@ -425,9 +425,7 @@ test.describe("browser error handling", () => {
    );

    await page.goto(sourceBrowsingUrl);
-
    await expect(
-
      page.getByText("The README could not be loaded"),
-
    ).toBeVisible();
+
    await expect(page.getByText("File not found")).toBeVisible();
  });
  test("error appears when navigating to missing file", async ({ page }) => {
    await page.route(
@@ -437,7 +435,7 @@ test.describe("browser error handling", () => {

    await page.goto(`${sourceBrowsingUrl}/tree/master/.hidden`);

-
    await expect(page.getByText("Not able to load file")).toBeVisible();
+
    await expect(page.getByText("File not found")).toBeVisible();
  });
});

@@ -465,7 +463,7 @@ test("internal file markdown link", async ({ page }) => {
  );
  await expect(
    page.locator(".file-header", {
-
      hasText: "assets/".concat(String.fromCharCode(160), "​black-square.png"),
+
      hasText: "assets/black-square.png",
    }),
  ).toBeVisible();
  await expect(
@@ -475,10 +473,10 @@ test("internal file markdown link", async ({ page }) => {

test("diff selection de-select", async ({ page }) => {
  await page.goto(
-
    `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=files#README.md:H0L0H0L3`,
+
    `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=changes#README.md:H0L0H0L3`,
  );
  await page.getByText("Add subtitle to README").click();
  await expect(page).toHaveURL(
-
    `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=files`,
+
    `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=changes`,
  );
});
modified tests/e2e/project/commit.spec.ts
@@ -1,10 +1,9 @@
import {
-
  test,
+
  aliceRemote,
+
  bobHead,
  expect,
  sourceBrowsingUrl,
-
  bobRemote,
-
  bobHead,
-
  aliceRemote,
+
  test,
} from "@tests/support/fixtures.js";

const commitUrl = `${sourceBrowsingUrl}/commits/${bobHead}`;
@@ -12,7 +11,7 @@ const commitUrl = `${sourceBrowsingUrl}/commits/${bobHead}`;
test("navigation from commit list", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
  await page.getByTitle("Change peer").click();
-
  await page.getByText(bobRemote).click();
+
  await page.getByRole("link", { name: "bob" }).click();
  await page.getByRole("link", { name: "7 commits" }).click();

  await page.getByText("Update readme").click();
@@ -30,9 +29,7 @@ test("relative timestamps", async ({ page }) => {
    };
  });
  await page.goto(commitUrl);
-
  await expect(
-
    page.locator(`.commit .header >> text=${"Bob Belcher committed now"}`),
-
  ).toBeVisible();
+
  await expect(page.getByText("Bob Belcher committed now")).toBeVisible();
});

test("modified file", async ({ page }) => {
@@ -40,9 +37,8 @@ test("modified file", async ({ page }) => {

  // Commit header.
  {
-
    const header = page.locator(".commit .header");
-
    await expect(header.getByText("Update readme")).toBeVisible();
-
    await expect(header.getByText(bobHead)).toBeVisible();
+
    await expect(page.getByText("Update readme")).toBeVisible();
+
    await expect(page.getByText(bobHead)).toBeVisible();
  }

  // Diff header.
@@ -84,10 +80,8 @@ test("moved file", async ({ page }) => {
    `${sourceBrowsingUrl}/remotes/${aliceRemote}/commits/f48a1056a5bd02277978f6e8a00517a967546340`,
  );
  await expect(
-
    page.locator("header").filter({ hasText: "moves/111.txt → moves/222.txt" }),
+
    page.getByText("moves/111.txt → moves/222.txt moved"),
  ).toBeVisible();
-

-
  await expect(page.getByText("moved", { exact: true })).toBeVisible();
  await expect(page.getByText("333")).toBeVisible();
});

@@ -96,11 +90,8 @@ test("copied file", async ({ page }) => {
    `${sourceBrowsingUrl}/remotes/${aliceRemote}/commits/f48a1056a5bd02277978f6e8a00517a967546340`,
  );
  await expect(
-
    page
-
      .locator("header")
-
      .filter({ hasText: "copies/aaa.txt → copies/aaa_copy.txt" }),
+
    page.getByText("copies/aaa.txt → copies/aaa_copy.txt copied"),
  ).toBeVisible();
-
  await expect(page.getByText("copied", { exact: true })).toBeVisible();
});

test("binary file detection in diffs", async ({ page }) => {
@@ -123,9 +114,7 @@ test("navigation to source tree at specific revision", async ({ page }) => {
  await expect(page).toHaveURL(
    `${sourceBrowsingUrl}/tree/0801aceeab500033f8d608778218657bd626ef73/deep/directory/hierarchy/is/entirely/possible/in/git/repositories/.gitkeep`,
  );
-
  await expect(page.getByTitle("Current branch")).toContainText(
-
    "0801aceeab500033f8d608778218657bd626ef73",
-
  );
+
  await expect(page.getByTitle("Current branch")).toContainText("0801ace");
  await expect(page.locator(".source-tree >> text=.gitkeep")).toBeVisible();
  await expect(
    page.locator(
modified tests/e2e/project/commits.spec.ts
@@ -1,11 +1,9 @@
import {
-
  test,
+
  aliceMainHead,
  expect,
-
  sourceBrowsingUrl,
-
  bobRemote,
-
  aliceRemote,
  gitOptions,
-
  aliceMainHead,
+
  sourceBrowsingUrl,
+
  test,
} from "@tests/support/fixtures.js";
import { createProject } from "@tests/support/project";

@@ -16,12 +14,13 @@ test("peer and branch switching", async ({ page }) => {
  // Alice's peer.
  {
    await page.getByTitle("Change peer").click();
-
    await page.getByText(aliceRemote).click();
-
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      `  did:key:${aliceRemote
-
        .substring(8)
-
        .substring(0, 6)}…${aliceRemote.slice(-6)} (alice) delegate`,
-
    );
+
    await page
+
      .getByRole("link", {
+
        name: "alice delegate",
+
      })
+
      .click();
+

+
    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);
@@ -52,26 +51,23 @@ test("peer and branch switching", async ({ page }) => {
      "orphaned-branch af3641c",
    );
    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".group .teaser")).toHaveCount(1);
+
    await expect(page.locator(".list")).toHaveCount(1);
  }

  // Bob's peer.
  {
    await page.getByTitle("Change peer").click();
-
    await page.getByText(bobRemote).click();
-
    await expect(page.getByTitle("Change peer")).toContainText(
-
      ` did:key:${bobRemote.substring(8).substring(0, 6)}…${bobRemote.slice(
-
        -6,
-
      )} `,
-
    );
+
    await page.getByRole("link", { name: "bob" }).click();
+

+
    await expect(page.getByTitle("Change peer")).toContainText("bob");

    await expect(page.getByText("Wednesday, December 21, 2022")).toBeVisible();
-
    await expect(page.locator(".group").first().locator(".teaser")).toHaveCount(
+
    await expect(page.locator(".list").first().locator(".teaser")).toHaveCount(
      1,
    );

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

@@ -90,13 +86,11 @@ 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();
-
  const commitToggle = page
-
    .locator("div")
-
    .filter({ hasText: /^Add a C source file and its binary …$/ })
-
    .getByRole("button", { name: "…" });
+
  const commitToggle = page.getByRole("button", { name: "expand" }).first();
+

  await commitToggle.click();
  const expandedCommit = page.getByText(
-
    "The binary was compiled with: `gcc -Oz -O3 true.c`. Signed-off-by: Alice Liddell",
+
    "Signed-off-by: Alice Liddell <alice@radicle.xyz>",
  );

  await expect(expandedCommit).toBeVisible();
@@ -120,12 +114,8 @@ test("relative timestamps", async ({ page }) => {
  await page.getByRole("link", { name: "6 commits" }).click();

  await page.getByTitle("Change peer").click();
-
  await page.getByText(bobRemote).click();
-
  await expect(page.getByTitle("Change peer")).toHaveText(
-
    `did:key:${bobRemote.substring(8).substring(0, 6)}…${bobRemote.slice(
-
      -6,
-
    )} (bob)`,
-
  );
+
  await page.getByRole("link", { name: "bob" }).click();
+
  await expect(page.getByTitle("Change peer")).toHaveText("bob");
  const latestCommit = page.locator(".teaser").first();
  await expect(latestCommit).toContainText("Bob Belcher committed now");
  await expect(latestCommit).toContainText("28f3710");
modified tests/e2e/project/issues.spec.ts
@@ -7,6 +7,7 @@ test("navigate issue listing", async ({ page }) => {
  await page.getByRole("link", { name: "1 issue" }).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 expect(page).toHaveURL(`${cobUrl}/issues?state=closed`);
});
@@ -40,23 +41,26 @@ test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
  await page.goto(
    `${authenticatedPeer.uiUrl()}/${rid}/issues/48af7d329e5b44ee8d348eeb7e341370243db9ad`,
  );
-
  const commentReactionToggle = page.getByTitle("toggle-reaction");
+
  const commentReactionToggle = page
+
    .getByTitle("toggle-reaction-popover")
+
    .last();
+
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
-
  await page.getByRole("button", { name: "Comment" }).click();
+
  await page.getByRole("button", { name: "Comment" }).first().click();
  await commentReactionToggle.click();
  await page.getByRole("button", { name: "👍" }).click();
-
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "👍 1" })).toBeVisible();

  await commentReactionToggle.click();
  await page.getByRole("button", { name: "🎉" }).click();
-
  await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "🎉 1" })).toBeVisible();
  await expect(page.locator(".reaction")).toHaveCount(2);

-
  await page.locator("span").filter({ hasText: "✕" }).nth(1).click();
+
  await page.getByRole("button", { name: "👍" }).click();
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeHidden();
  await expect(page.locator(".reaction")).toHaveCount(1);

-
  await page.locator("span").filter({ hasText: "✕" }).nth(0).click();
+
  await page.getByRole("button", { name: "🎉" }).click();
  await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeHidden();
  await expect(page.locator(".reaction")).toHaveCount(0);
});
@@ -89,10 +93,13 @@ test("test issue counters", async ({ page, authenticatedPeer }) => {
    ],
    { cwd: projectFolder },
  );
-
  await page.getByRole("button", { name: "1 open" }).click();
+
  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 expect(page.getByRole("button", { name: "2 open" })).toBeVisible();
-
  await expect(page.locator(".issues-list .teaser")).toHaveCount(2);
+
  await expect(
+
    page.getByRole("button", { name: "filter-dropdown" }).first(),
+
  ).toHaveText("2 open");
+
  await expect(page.locator(".list .issue-teaser")).toHaveCount(2);

  await page
    .getByRole("link", { name: "First issue to test counters" })
@@ -133,8 +140,9 @@ test("test issue editing failing", async ({ page, authenticatedPeer }) => {
    `${authenticatedPeer.uiUrl()}/${rid}/issues/ad9114fa910c67f09ce5d42d12c31038eb40fc86`,
  );

+
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
-
  await page.getByRole("button", { name: "Comment" }).click();
+
  await page.getByRole("button", { name: "Comment" }).first().click();
  await expect(page.getByText("Issue editing failed")).toBeVisible();
});

@@ -166,32 +174,31 @@ test("go through the entire ui issue flow", async ({
  await expect(page.getByText("This is a title")).toBeVisible();
  await expect(page.getByText("This is a description")).toBeVisible();
  await expect(
-
    page.getByLabel("chip").filter({
-
      hasText: `did:key:${authenticatedPeer.nodeId.substring(
+
    page.getByText(
+
      `did:key:${authenticatedPeer.nodeId.substring(
        0,
        6,
      )}…${authenticatedPeer.nodeId.slice(-6)}`,
-
    }),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByLabel("chip").filter({ hasText: "documentation" }),
+
    ),
  ).toBeVisible();
  await expect(
-
    page.getByLabel("chip").filter({ hasText: "bug" }),
+
    page.locator(".badge").filter({ hasText: "documentation" }),
  ).toBeVisible();
+
  await expect(page.locator(".badge").filter({ hasText: "bug" })).toBeVisible();

-
  await page.getByLabel("editTitle").click();
+
  await page.getByRole("button", { name: "edit title" }).click();
  await page.getByPlaceholder("Title").fill("This is a new title");
-
  await page.getByLabel("editTitle").click();
+
  await page.getByRole("button", { name: "save title" }).click();
  await expect(page.getByText("This is a new title")).toBeVisible();

+
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
-
  await page.getByRole("button", { name: "Comment" }).click();
+
  await page.getByRole("button", { name: "Comment" }).first().click();
  await expect(page.getByText("This is a comment")).toBeVisible();

-
  await page.getByTitle("toggle-reply").click();
-
  await page.getByPlaceholder("Leave your reply").fill("This is a reply");
-
  await page.getByRole("button", { name: "Reply", exact: true }).click();
+
  await page.getByRole("button", { name: "Reply to comment" }).click();
+
  await page.getByPlaceholder("Reply to comment").fill("This is a reply");
+
  await page.getByRole("button", { name: "Comment", exact: true }).click();
  await expect(page.getByText("This is a reply")).toBeVisible();

  await page.getByRole("button", { name: "Close issue as solved" }).click();
@@ -200,7 +207,7 @@ test("go through the entire ui issue flow", async ({
  await page.getByRole("button", { name: "Reopen issue" }).click();
  await expect(page.getByText("open", { exact: true })).toBeVisible();

-
  await page.getByRole("button", { name: "stateToggle" }).click();
+
  await page.getByRole("button", { name: "stateToggle" }).first().click();
  await page.getByText("Close issue as other").click();
  await page.getByRole("button", { name: "Close issue as other" }).click();
  await expect(page.getByText("closed as other")).toBeVisible();
@@ -265,6 +272,6 @@ test("handling embeds", async ({ page, authenticatedPeer }) => {
  );

  await expect(
-
    page.getByLabel("chip").filter({ hasText: "radicle-228x228.png" }),
+
    page.locator(".badge").filter({ hasText: "radicle-228x228.png" }),
  ).toBeVisible();
});
modified tests/e2e/project/patches.spec.ts
@@ -6,6 +6,7 @@ test("navigate patch listing", async ({ page }) => {
  await page.getByRole("link", { name: "2 patches" }).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 expect(page).toHaveURL(`${cobUrl}/patches?state=merged`);
  await expect(
@@ -25,15 +26,9 @@ test("navigate patch details", async ({ page }) => {
  );
  await page.goBack();
  {
-
    await page.getByRole("link", { name: "Commits" }).click();
+
    await page.getByRole("link", { name: "Changes" }).click();
    await expect(page).toHaveURL(
-
      `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=commits`,
-
    );
-
  }
-
  {
-
    await page.getByRole("link", { name: "Files" }).click();
-
    await expect(page).toHaveURL(
-
      `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=files`,
+
      `${cobUrl}/patches/e35c10c370de7fb94e95dbdf05ab93000132683f?tab=changes`,
    );
  }
});
@@ -61,19 +56,17 @@ test("edit a patch", async ({ page, authenticatedPeer }) => {
  await expect(page.getByRole("button", { name: "1 patch" })).toBeVisible();
  await expect(page.getByText("open", { exact: true })).toBeVisible();

-
  await page.getByRole("button", { name: "edit" }).click();
+
  await page.getByRole("button", { name: "edit labels" }).click();
  await page.getByPlaceholder("Add label").fill("bug");
  await page.getByPlaceholder("Add label").press("Enter");
  await page.getByPlaceholder("Add label").fill("documentation");
  await page.getByPlaceholder("Add label").press("Enter");
-
  await page.getByRole("button", { name: "save" }).click();
+
  await page.getByRole("button", { name: "save labels" }).click();

  await expect(
-
    page.getByLabel("chip").filter({ hasText: "documentation" }),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByLabel("chip").filter({ hasText: "bug" }),
+
    page.locator(".badge").filter({ hasText: "documentation" }),
  ).toBeVisible();
+
  await expect(page.locator(".badge").filter({ hasText: "bug" })).toBeVisible();
});

test("leave a comment and reply", async ({ page, authenticatedPeer }) => {
@@ -96,13 +89,14 @@ test("leave a comment and reply", async ({ page, authenticatedPeer }) => {
  await page.goto(
    `${authenticatedPeer.uiUrl()}/${rid}/patches/d41fbd28b06a5fac51a2ba9e05ad9dc885676d71`,
  );
+
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
  await page.getByRole("button", { name: "Comment" }).click();
  await expect(page.getByText("This is a comment")).toBeVisible();

-
  await page.getByTitle("toggle-reply").click();
-
  await page.getByPlaceholder("Leave your reply").fill("This is a reply");
-
  await page.getByRole("button", { name: "Reply", exact: true }).click();
+
  await page.getByRole("button", { name: "Reply to comment" }).click();
+
  await page.getByPlaceholder("Reply to comment").fill("This is a reply");
+
  await page.getByRole("button", { name: "Comment", exact: true }).click();
  await expect(page.getByText("This is a reply")).toBeVisible();
});

@@ -126,25 +120,26 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
  await page.goto(
    `${authenticatedPeer.uiUrl()}/${rid}/patches/af4099f53e96e28824d6df13136feeae10190679`,
  );
+
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
  await page.getByRole("button", { name: "Comment" }).click();
  const commentReactionToggle = page.getByTitle("toggle-reaction").first();
  await commentReactionToggle.click();
  await page.getByRole("button", { name: "👍" }).click();
-
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "👍 1" })).toBeVisible();

  await commentReactionToggle.click();
  await page.getByRole("button", { name: "🎉" }).click();
-
  await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "🎉 1" })).toBeVisible();
  await expect(page.locator(".reaction")).toHaveCount(2);

-
  await page.getByRole("button", { name: "✕" }).nth(1).click();
+
  await page.getByRole("button", { name: "👍" }).click();
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeHidden();
  await expect(page.locator(".reaction")).toHaveCount(1);

  await commentReactionToggle.click();
-
  await page.getByRole("button", { name: "🎉" }).click();
-
  await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeHidden();
+
  await page.getByRole("button", { name: "🎉" }).first().click();
+
  await expect(page.getByRole("button", { name: "🎉 1" })).toBeHidden();
  await expect(page.locator(".reaction")).toHaveCount(0);
});
test("change patch state", async ({ page, authenticatedPeer }) => {
@@ -167,11 +162,11 @@ test("change patch state", async ({ page, authenticatedPeer }) => {
  await page.goto(
    `${authenticatedPeer.uiUrl()}/${rid}/patches/be66e6ccf14f603e9fec63a30db9dd24cc7adf4c`,
  );
-
  await page.getByRole("button", { name: "Archive patch" }).click();
+
  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 page.getByLabel("stateToggle").click();
+
  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();
@@ -205,45 +200,50 @@ test("patches counters", async ({ page, authenticatedPeer }) => {
  await authenticatedPeer.git(["push", "rad", "HEAD:refs/patches"], {
    cwd: projectFolder,
  });
-
  await page.getByRole("button", { name: "1 open" }).click();
-

+
  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 expect(page.getByRole("button", { name: "2 open" })).toBeVisible();
-
  await expect(page.locator(".patches-list .teaser")).toHaveCount(2);
+
  await expect(
+
    page.getByRole("button", { name: "filter-dropdown" }).first(),
+
  ).toHaveText("2 open");
+
  await expect(page.locator(".list .patch-teaser")).toHaveCount(2);
});

test("use revision selector", async ({ page }) => {
  await page.goto(`${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8`);
-
  await page.getByRole("link", { name: "Files" }).click();
+
  await page.getByRole("link", { name: "Changes" }).click();

  // Validating the latest revision state
  await expect(
    page.getByRole("cell", { name: "Had to push a new revision" }),
  ).toBeVisible();
-
  await page.getByRole("link", { name: "Commits" }).click();
-
  await expect(page.locator(".commit-list .teaser")).toHaveCount(2);
+
  await page.getByRole("link", { name: "Activity" }).click();
+
  await expect(page.locator(".commits .teaser")).toHaveCount(2);
+
  await expect(page.getByRole("link", { name: "Add more text" })).toBeVisible();
+

+
  // Open the first revision and close the latest one
+
  await page.getByLabel("expand").first().click();
+
  await page.getByLabel("expand").last().click();
+

+
  // Validating the initial revision
+
  await expect(page.locator(".commits .teaser")).toHaveCount(1);
  await expect(
-
    page.locator(".commit-list .teaser .markdown").first(),
-
  ).toHaveText("Add more text");
+
    page.getByRole("link", { name: "Rewrite subtitle to README" }),
+
  ).toBeVisible();

+
  await page.getByRole("link", { name: "Changes" }).click();
  // Switching to the initial revision
  await page.getByText("Revision 0535843").click();
  await expect(page.locator(".dropdown")).toBeVisible();
  await page.getByRole("link", { name: "Revision 687c326" }).click();
  await expect(page.locator(".dropdown")).toBeHidden();

-
  // Validating the initial revision
-
  await expect(page.locator(".commit-list .teaser")).toHaveCount(1);
-
  await expect(
-
    page.locator(".commit-list .teaser .markdown").first(),
-
  ).toHaveText("Rewrite subtitle to README");
-
  await page.getByRole("link", { name: "Files" }).click();
  await expect(
    page.getByRole("cell", { name: "Had to push a new revision" }),
  ).toBeHidden();

  await expect(page).toHaveURL(
-
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=files`,
+
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=changes`,
  );
});

@@ -255,23 +255,29 @@ test("navigate through revision diffs", async ({ page }) => {

  // Second revision
  {
-
    await secondRevision.locator(".toggle").click();
    await secondRevision
-
      .getByRole("link", { name: "Compare to main (38c225e)" })
+
      .getByRole("button", { name: "toggle-context-menu" })
+
      .first()
+
      .click();
+
    await secondRevision
+
      .getByRole("link", { name: "Compare to main: 38c225e" })
      .click();
    await expect(
-
      page.getByRole("link", { name: "Diff 38c225..9898da" }),
+
      page.getByRole("link", { name: "Compare 38c225..9898da" }),
    ).toBeVisible();
    await expect(page).toHaveURL(
      `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?diff=38c225e2a0b47ba59def211f4e4825c31d9463ec..9898da6155467adad511f63bf0fb5aa4156b92ef`,
    );
    await page.goBack();
-
    await secondRevision.locator(".toggle").click();
    await secondRevision
-
      .getByRole("link", { name: "Compare to previous revision (687c326)" })
+
      .getByRole("button", { name: "toggle-context-menu" })
+
      .first()
+
      .click();
+
    await secondRevision
+
      .getByRole("link", { name: "Compare to previous revision: 687c326" })
      .click();
    await expect(
-
      page.getByRole("link", { name: "Diff 0dc373..9898da" }),
+
      page.getByRole("link", { name: "Compare 0dc373..9898da" }),
    ).toBeVisible();

    await expect(page).toHaveURL(
@@ -283,18 +289,21 @@ test("navigate through revision diffs", async ({ page }) => {
      .getByRole("link", { name: "Compare 0dc373d..9898da6" })
      .click();
    await expect(
-
      page.getByRole("link", { name: "Diff 0dc373..9898da" }),
+
      page.getByRole("link", { name: "Compare 0dc373..9898da" }),
    ).toBeVisible();
    await page.goBack();
  }
  // First revision
  {
-
    await firstRevision.locator(".toggle").click();
    await firstRevision
-
      .getByRole("link", { name: "Compare to main (38c225e)" })
+
      .getByRole("button", { name: "toggle-context-menu" })
+
      .first()
+
      .click();
+
    await firstRevision
+
      .getByRole("link", { name: "Compare to main: 38c225e" })
      .click();
    await expect(
-
      page.getByRole("link", { name: "Diff 38c225..0dc373" }),
+
      page.getByRole("link", { name: "Compare 38c225..0dc373" }),
    ).toBeVisible();
    await expect(page).toHaveURL(
      `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?diff=38c225e2a0b47ba59def211f4e4825c31d9463ec..0dc373db601ccbcffa80dec932e4006516709ca6`,
@@ -302,10 +311,10 @@ test("navigate through revision diffs", async ({ page }) => {
  }
});

-
test("view file navigation from files tab", async ({ page }) => {
+
test("view file navigation from changes tab", async ({ page }) => {
  await page.goto(`${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8`);
-
  await page.getByRole("button", { name: "Files" }).click();
-
  await page.getByTitle("View file").getByRole("link").click();
+
  await page.getByRole("button", { name: "Changes" }).click();
+
  await page.getByRole("button", { name: "View file" }).click();
  await expect(page).toHaveURL(
    `${cobUrl}/tree/9898da6155467adad511f63bf0fb5aa4156b92ef/README.md`,
  );
deleted tests/e2e/settings.spec.ts
@@ -1,74 +0,0 @@
-
import { test, expect, sourceBrowsingUrl } from "@tests/support/fixtures.js";
-

-
const sourceBrowsingFixture = `${sourceBrowsingUrl}/tree/main/src/true.c`;
-

-
test("default settings", async ({ page }) => {
-
  await page.goto(sourceBrowsingFixture);
-

-
  // Default settings.
-
  {
-
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
-
    await expect(page.locator("html")).toHaveAttribute(
-
      "data-codefont",
-
      "jetbrains",
-
    );
-
  }
-
});
-

-
test("settings persistance", async ({ page }) => {
-
  await page.goto(sourceBrowsingFixture);
-
  await page.getByRole("button", { name: "Settings" }).click();
-

-
  await page.locator(".theme .toggle").click();
-
  await page.getByText("Code font").click();
-
  await page.getByText("System").click();
-

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

-
  await page.reload();
-

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

-
test("change theme", async ({ page }) => {
-
  await page.goto(sourceBrowsingFixture);
-
  await page.getByRole("button", { name: "Settings" }).click();
-

-
  await page.locator(".theme .toggle").click();
-
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
-
  await expect(page.locator("body")).toHaveCSS(
-
    "background-color",
-
    "rgb(243, 246, 253)",
-
  );
-
  // Source highlighting reacts to theme change.
-
  await expect(page.getByText("() {")).toHaveCSS("color", "rgb(26, 26, 44)");
-

-
  await page.locator(".theme .toggle").click();
-
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
-
  await expect(page.locator("body")).toHaveCSS(
-
    "background-color",
-
    "rgb(11, 19, 26)",
-
  );
-
  // Source highlighting reacts to theme change.
-
  await expect(page.getByText("() {")).toHaveCSS("color", "rgb(255, 255, 255)");
-
});
-

-
test("change code font", async ({ page }) => {
-
  await page.goto(sourceBrowsingFixture);
-

-
  await page.getByRole("button", { name: "Settings" }).click();
-
  await page.getByText("Code font").click();
-

-
  await page.getByText("System").click();
-
  await expect(page.getByText("System")).toHaveClass(/active/);
-
  await expect(page.locator("html")).toHaveAttribute("data-codefont", "system");
-

-
  await page.getByText("JetBrains Mono").click();
-
  await expect(page.getByText("JetBrains Mono")).toHaveClass(/active/);
-
  await expect(page.locator("html")).toHaveAttribute(
-
    "data-codefont",
-
    "jetbrains",
-
  );
-
});
added tests/e2e/theme.spec.ts
@@ -0,0 +1,71 @@
+
import { test, expect, sourceBrowsingUrl } from "@tests/support/fixtures.js";
+

+
const sourceBrowsingFixture = `${sourceBrowsingUrl}/tree/main/src/true.c`;
+

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

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

+
test("theme persistance", async ({ page }) => {
+
  await page.goto(sourceBrowsingFixture);
+
  await page.getByRole("button", { name: "Theme" }).first().click();
+

+
  await page.getByText("System").click();
+
  await page.getByRole("button", { name: "Light Mode" }).click();
+

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

+
  await page.reload();
+

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

+
test("change theme", async ({ page }) => {
+
  await page.goto(sourceBrowsingFixture);
+
  await page.getByRole("button", { name: "Theme" }).first().click();
+

+
  await page.getByRole("button", { name: "Light Mode" }).click();
+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+
  await expect(page.locator("body")).toHaveCSS(
+
    "background-color",
+
    "rgb(245, 245, 255)",
+
  );
+
  // Source highlighting reacts to theme change.
+
  await expect(page.getByText("() {")).toHaveCSS("color", "rgb(20, 21, 26)");
+

+
  await page.getByRole("button", { name: "Dark Mode" }).click();
+
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+
  await expect(page.locator("body")).toHaveCSS(
+
    "background-color",
+
    "rgb(10, 13, 16)",
+
  );
+
  // Source highlighting reacts to theme change.
+
  await expect(page.getByText("() {")).toHaveCSS("color", "rgb(249, 249, 251)");
+
});
+

+
test("change code font", async ({ page }) => {
+
  await page.goto(sourceBrowsingFixture);
+

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

+
  await page.getByText("System").click();
+
  await expect(page.getByText("System")).toHaveClass(/secondary/);
+
  await expect(page.locator("html")).toHaveAttribute("data-codefont", "system");
+

+
  await page.getByText("JetBrains Mono").click();
+
  await expect(page.getByText("JetBrains Mono")).toHaveClass(/secondary/);
+
  await expect(page.locator("html")).toHaveAttribute(
+
    "data-codefont",
+
    "jetbrains",
+
  );
+
});
modified tests/support/fixtures.ts
@@ -169,7 +169,7 @@ export const test = base.extend<{
    await peer.startHttpd();
    await peer.startNode();
    await page.goto("/");
-
    await page.getByRole("button", { name: "radicle.local" }).click();
+
    await page.getByRole("button", { name: "Read only" }).click();
    await page
      .locator('input[name="port"]')
      .fill(peer.httpdBaseUrl.port.toString());
@@ -687,6 +687,7 @@ export const sourceBrowsingUrl = `/nodes/127.0.0.1/${sourceBrowsingRid}`;
export const cobUrl = `/nodes/127.0.0.1/${cobRid}`;
export const markdownUrl = `/nodes/127.0.0.1/${markdownRid}`;
export const nodeRemote = "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S";
+
export const shortNodeRemote = "z6MktU…1xB22S";
export const defaultHttpdPort = 8081;
export const gitOptions = {
  alice: {
modified tests/unit/router.test.ts
@@ -196,23 +196,23 @@ describe("route invariant when parsed", () => {
    });
  });

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

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

modified tests/visual/cob.spec.ts
@@ -88,12 +88,7 @@ test("patch page", async ({ page }) => {
  );
  await expect(page).toHaveScreenshot({ fullPage: true });
  await page.goto(
-
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=commits`,
-
    { waitUntil: "networkidle" },
-
  );
-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
  await page.goto(
-
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=files`,
+
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=changes`,
    { waitUntil: "networkidle" },
  );
  await expect(page).toHaveScreenshot({ fullPage: true });
modified tests/visual/mobile/cob.spec.ts
@@ -89,12 +89,7 @@ test("patch page", async ({ page }) => {
  );
  await expect(page).toHaveScreenshot({ fullPage: true });
  await page.goto(
-
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=commits`,
-
    { waitUntil: "networkidle" },
-
  );
-
  await expect(page).toHaveScreenshot({ fullPage: true });
-
  await page.goto(
-
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=files`,
+
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=changes`,
    { waitUntil: "networkidle" },
  );
  await expect(page).toHaveScreenshot({ fullPage: true });
modified tests/visual/project.spec.ts
@@ -64,7 +64,7 @@ test("diff selection", async ({ page }) => {
  });

  await page.goto(
-
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=files#README.md:H0L0H0L3`,
+
    `${cobUrl}/patches/687c3268119d23c5da32055c0b44c03e0e4088b8?tab=changes#README.md:H0L0H0L3`,
  );
  await expect(page).toHaveScreenshot({ fullPage: true });
});