Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Refactor modals
Rūdolfs Ošiņš committed 3 years ago
commit 3d018dae1830f4ac0bd8473b4c58455833c1064c
parent b9ae9614c0d329acd8c30e02d230d9456f909c8f
62 files changed +2070 -1936
modified public/elevations.css
@@ -1,11 +1,11 @@
:root {
  --elevation-low: 0px 16px 32px 32px #00000070;
-
  --elevation-high: 0px 8px 64px var(--color-secondary-5);
-
  --elevation-high-negative: 0px 8px 64px var(--color-negative-5);
+
  --elevation-high: 0px 0px 1px var(--color-secondary),
+
    0px 8px 64px var(--color-secondary-5);
}

[data-theme="light"] {
  --elevation-low: 0px 16px 32px 16px #00000020;
-
  --elevation-high: 0px 8px 64px var(--color-secondary-5);
-
  --elevation-high-negative: 0px 8px 64px var(--color-negative-5);
+
  --elevation-high: 0px 0px 1px var(--color-secondary),
+
    0px 8px 64px var(--color-secondary-3);
}
modified public/index.css
@@ -17,8 +17,6 @@
  --button-small-height: 2rem;
  --button-tiny-height: 1.5rem;

-
  --glow: var(--color-secondary-3);
-
  --glow-error: var(--color-negative-3);
  --header-gradient: linear-gradient(
    180deg,
    var(--color-secondary-3) 0%,
modified public/typography.css
@@ -77,7 +77,8 @@
  --font-size-regular: 1rem;
  --font-size-medium: 1.25rem;
  --font-size-large: 1.75rem;
-
  --font-size-huge: 2rem;
+
  --font-size-x-large: 2rem;
+
  --font-size-xx-large: 3rem;
}

[data-codefont="system"] {
@@ -124,7 +125,10 @@ p {
  font-size: var(--font-size-large);
}
.txt-huge {
-
  font-size: var(--font-size-huge);
+
  font-size: var(--font-size-x-large);
+
}
+
.txt-humongous {
+
  font-size: var(--font-size-xx-large);
}

.txt-monospace {
modified src/App.svelte
@@ -4,16 +4,17 @@
  import { Connection, state, session } from "@app/lib/session";
  import { getWallet } from "@app/lib/wallet";
  import { initialize, activeRouteStore } from "@app/lib/router";
-
  import { twemoji, unreachable } from "@app/lib/utils";
+
  import { unreachable } from "@app/lib/utils";

-
  import ColorPalette from "./App/ColorPalette.svelte";
  import Header from "./App/Header.svelte";
+
  import ModalPortal from "./App/ModalPortal.svelte";
+
  import Hotkeys from "./App/Hotkeys.svelte";

  import Loading from "@app/components/Loading.svelte";
-
  import Modal from "@app/components/Modal.svelte";
  import NotFound from "@app/components/NotFound.svelte";
+
  import Error from "@app/components/Error.svelte";

-
  import Faucet from "@app/views/faucet/Routes.svelte";
+
  import Faucet from "@app/views/faucet/Faucet.svelte";
  import Home from "@app/views/home/Index.svelte";
  import Profile from "@app/views/profiles/Profile.svelte";
  import Projects from "@app/views/projects/View.svelte";
@@ -46,16 +47,6 @@
    }
    return wallet;
  });
-

-
  function handleKeydown(event: KeyboardEvent) {
-
    if (event.key === "Enter") {
-
      const elems = document.querySelectorAll<HTMLElement>("button.primary");
-
      if (elems.length === 1) {
-
        // We only allow this when there's one primary button.
-
        elems[0].click();
-
      }
-
    }
-
  }
</script>

<style>
@@ -73,37 +64,31 @@
    flex-direction: column;
    height: 100%;
  }
-
  .emoji {
-
    margin: 1rem 0;
-
  }
</style>

-
<svelte:window on:keydown={handleKeydown} />
<svelte:head>
  <title>Radicle</title>
</svelte:head>

+
<ModalPortal />
+
<Hotkeys />
+

<div class="app">
  {#await loadWallet}
-
    <!-- Loading wallet -->
    <div class="wrapper">
      <Loading center />
    </div>
  {:then wallet}
-
    <ColorPalette />
    <Header session={$session} {wallet} />
    <div class="wrapper">
      {#if $activeRouteStore.resource === "home"}
        <Home />
      {:else if $activeRouteStore.resource === "faucet"}
-
        <Faucet {wallet} activeRoute={$activeRouteStore} />
+
        <Faucet {wallet} />
      {:else if $activeRouteStore.resource === "seeds"}
        <Seeds host={$activeRouteStore.params.host} />
      {:else if $activeRouteStore.resource === "registrations"}
-
        <Registrations
-
          {wallet}
-
          session={$session}
-
          activeRoute={$activeRouteStore} />
+
        <Registrations {wallet} activeRoute={$activeRouteStore} />
      {:else if $activeRouteStore.resource === "vesting"}
        <Vesting {wallet} activeRoute={$activeRouteStore} />
      {:else if $activeRouteStore.resource === "projects"}
@@ -113,23 +98,18 @@
          addressOrName={$activeRouteStore.params.addressOrName}
          {wallet} />
      {:else if $activeRouteStore.resource === "404"}
-
        <NotFound title="404" subtitle="Nothing here" />
+
        <div class="wrapper" style:justify-content="center">
+
          <NotFound title="404" subtitle="Nothing here" />
+
        </div>
      {:else}
        {unreachable($activeRouteStore)}
      {/if}
    </div>
  {:catch err}
-
    <div class="wrapper">
-
      <Modal error subtle>
-
        <span slot="title">
-
          <div class="emoji" use:twemoji>👻</div>
-
          <div>Error connecting to network</div>
-
        </span>
-

-
        <span slot="body">
-
          {err.message ? err.message : JSON.stringify(err)}
-
        </span>
-
      </Modal>
+
    <div class="wrapper" style:justify-content="center">
+
      <Error
+
        title="Error connecting to network"
+
        message={err.message ? err.message : JSON.stringify(err)} />
    </div>
  {/await}
</div>
modified src/App/ColorPalette.svelte
@@ -1,4 +1,6 @@
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+

  function extractCssVariables(variableName: string) {
    return Array.from(document.styleSheets)
      .filter(
@@ -75,10 +77,14 @@
    "--color-tertiary",
    "--color-tertiary-1",
    "--color-tertiary-2",
+
    "--color-tertiary-3",
    "--color-tertiary-6",
  ];

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

  const colorGroups = [
    ...new Set(
      colors.map(color => {
@@ -92,70 +98,29 @@
    ),
  ];

-
  let show = false;
  let checkers = false;
-

-
  const onKeydown = (event: KeyboardEvent) => {
-
    if (import.meta.env.PROD) {
-
      return;
-
    }
-

-
    const hasInputTarget =
-
      event.target &&
-
      ((event.target as HTMLInputElement).type === "text" ||
-
        (event.target as HTMLTextAreaElement).type === "textarea");
-

-
    if (
-
      hasInputTarget ||
-
      event.repeat ||
-
      event.altKey ||
-
      event.metaKey ||
-
      event.ctrlKey
-
    ) {
-
      return false;
-
    }
-

-
    if (event.key === "d") {
-
      show = !show;
-
    }
-
  };
-

-
  function clickOutside(ev: MouseEvent) {
-
    if (thisComponent && !thisComponent.contains(ev.target as HTMLDivElement)) {
-
      show = !show;
-
    }
-
  }
-

-
  let thisComponent: HTMLDivElement;
</script>

<style>
-
  .container {
-
    position: fixed;
-
    background: var(--color-background);
-
    padding: 2rem;
-
    top: 50%;
-
    left: 50%;
-
    transform: translate(-50%, -50%);
-
    box-shadow: var(--elevation-low);
-
    border-radius: 1rem;
-
    z-index: 100;
-
    min-width: 46rem;
-
  }
-

  .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: 4rem;
-
    height: 4rem;
+
    width: 3rem;
+
    height: 3rem;
    border-radius: 0.5rem;
    outline-style: solid !important;
    outline-color: #88888899 !important;
-
    outline-offset: 0.5rem;
+
    outline-offset: 0.3rem;
    margin: 1rem;
  }

@@ -165,30 +130,27 @@
  }
</style>

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

-
{#if show}
+
<Modal closeAction={false}>
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <div
-
    bind:this={thisComponent}
-
    class="container"
-
    on:click={() => (checkers = !checkers)}>
-
    <div class:checkers>
-
      {#each colorGroups as colorGroup}
-
        <div>
-
          {#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 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>
-
{/if}
+
</Modal>
modified src/App/Header.svelte
@@ -1,6 +1,5 @@
<script lang="ts">
  import type { Wallet } from "@app/lib/wallet";
-
  import type { ProjectsAndProfiles } from "./Header/Search.svelte";
  import type { Session } from "@app/lib/session";

  import Avatar from "@app/components/Avatar.svelte";
@@ -14,7 +13,6 @@

  import Logo from "./Header/Logo.svelte";
  import Search from "./Header/Search.svelte";
-
  import SearchResults from "./Header/SearchResults.svelte";

  import { Profile, ProfileType } from "@app/lib/profile";
  import { closeFocused } from "@app/components/Floating.svelte";
@@ -24,9 +22,6 @@
  export let session: Session | null;
  export let wallet: Wallet;

-
  let query: string;
-
  let results: ProjectsAndProfiles | null = null;
-

  let sessionButtonHover = false;

  $: address = session && session.address;
@@ -146,11 +141,7 @@
  <div class="left">
    <Link route={{ resource: "home" }}><span class="logo"><Logo /></span></Link>
    <div class="search">
-
      <Search
-
        {wallet}
-
        on:search={e => {
-
          ({ query, results } = e.detail);
-
        }} />
+
      <Search {wallet} />
    </div>
  </div>

@@ -159,7 +150,6 @@
      <Link
        route={{
          resource: "faucet",
-
          params: { view: { resource: "form" } },
        }}>
        <span class="network">Goerli</span>
      </Link>
@@ -171,7 +161,7 @@
    <Link
      route={{
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
+
        params: { view: { resource: "form" } },
      }}>
      <span class="register">Register</span>
    </Link>
@@ -235,15 +225,12 @@
                {wallet}
                on:finished={() => {
                  closeFocused();
-
                }}
-
                on:search={e => {
-
                  ({ query, results } = e.detail);
                }} />
            </div>
            <Link
              route={{
                resource: "registrations",
-
                params: { view: { resource: "validateName" } },
+
                params: { view: { resource: "form" } },
              }}
              on:click={() => {
                closeFocused();
@@ -255,14 +242,4 @@
      </Floating>
    </div>
  </div>
-

-
  {#if results}
-
    <SearchResults
-
      {wallet}
-
      {results}
-
      {query}
-
      on:close={() => {
-
        results = null;
-
      }} />
-
  {/if}
</header>
modified src/App/Header/Search.svelte
@@ -1,129 +1,3 @@
-
<script lang="ts" context="module">
-
  import type { Host } from "@app/lib/api";
-
  import type { ProjectInfo } from "@app/lib/project";
-

-
  import { ethers } from "ethers";
-

-
  import * as utils from "@app/lib/utils";
-
  import { Profile } from "@app/lib/profile";
-
  import { Project } from "@app/lib/project";
-
  import { config } from "@app/lib/config";
-

-
  export interface ProjectsAndProfiles {
-
    projects: { info: ProjectInfo; seed: Host }[];
-
    profiles: Profile[];
-
  }
-

-
  type SearchResult =
-
    | { type: "nothing" }
-
    | { type: "error"; message: string }
-
    | { type: "singleProfile"; id: string }
-
    | { type: "singleProject"; seedHost: string; id: string }
-
    | { type: "projectsAndProfiles"; projectsAndProfiles: ProjectsAndProfiles };
-

-
  async function searchProjectsAndProfiles(
-
    query: string,
-
    wallet: Wallet,
-
  ): Promise<SearchResult> {
-
    try {
-
      // The query is a plain Ethereum address.
-
      if (ethers.utils.isAddress(query)) {
-
        return { type: "singleProfile", id: query };
-
      }
-

-
      const projectOnSeeds = config.seeds.pinned.map(seed => ({
-
        nameOrId: query,
-
        seed: seed.host,
-
      }));
-

-
      // The query is a radicle project ID.
-
      if (utils.isRadicleId(query)) {
-
        const projects = await Project.getMulti(projectOnSeeds);
-

-
        if (projects.length === 1) {
-
          return {
-
            type: "singleProject",
-
            seedHost: projects[0].seed.host,
-
            id: query,
-
          };
-
        } else if (projects.length === 0) {
-
          return { type: "nothing" };
-
        } else {
-
          return {
-
            type: "projectsAndProfiles",
-
            projectsAndProfiles: { projects, profiles: [] },
-
          };
-
        }
-
      }
-

-
      // The query is either a project or a profile name.
-
      const normalizedQuery = query.toLowerCase();
-
      const projectsAndProfiles: ProjectsAndProfiles = {
-
        projects: [],
-
        profiles: [],
-
      };
-

-
      try {
-
        const projects = await Project.getMulti(projectOnSeeds);
-
        projectsAndProfiles.projects.push(...projects);
-
      } catch {
-
        // TODO: collect errors and forward to user.
-
      }
-

-
      try {
-
        let params: string[];
-
        if (utils.isENSName(normalizedQuery, wallet)) {
-
          params = [normalizedQuery];
-
        } else {
-
          params = [
-
            `${normalizedQuery}.${wallet.registrar.domain}`,
-
            `${normalizedQuery}.eth`,
-
          ];
-
        }
-
        const profiles = await Profile.getMulti(params, wallet);
-
        projectsAndProfiles.profiles.push(...profiles);
-
      } catch {
-
        // TODO: collect errors and forward to user.
-
      }
-

-
      const projectCount = projectsAndProfiles.projects.length;
-
      const profileCount = projectsAndProfiles.profiles.length;
-

-
      if (profileCount === 1 && projectCount === 0) {
-
        return {
-
          type: "singleProfile",
-
          id: projectsAndProfiles.profiles[0].address,
-
        };
-
      }
-

-
      if (profileCount === 0 && projectCount === 1) {
-
        return {
-
          type: "singleProject",
-
          seedHost: projectsAndProfiles.projects[0].seed.host,
-
          id: query,
-
        };
-
      }
-

-
      if (profileCount > 0 || projectCount > 0) {
-
        return {
-
          type: "projectsAndProfiles",
-
          projectsAndProfiles,
-
        };
-
      }
-

-
      return { type: "nothing" };
-
    } catch (error) {
-
      let message = "An unknown error occoured while searching.";
-

-
      if (error instanceof Error) {
-
        message = error.message;
-
      }
-

-
      return { type: "error", message };
-
    }
-
  }
-
</script>
-

<script lang="ts" strictEvents>
  import type { Wallet } from "@app/lib/wallet";

@@ -131,6 +5,9 @@
  import { createEventDispatcher } from "svelte";
  import * as router from "@app/lib/router";

+
  import * as Search from "@app/lib/search";
+
  import * as modal from "@app/lib/modal";
+
  import SearchResultsModal from "@app/App/Header/SearchResultsModal.svelte";
  import TextInput from "@app/components/TextInput.svelte";
  import { unreachable } from "@app/lib/utils";

@@ -138,7 +15,6 @@

  const dispatch = createEventDispatcher<{
    finished: never;
-
    search: { query: string; results: ProjectsAndProfiles };
  }>();

  let input = "";
@@ -161,7 +37,7 @@
    loading = true;

    const query = input;
-
    const searchResult = await searchProjectsAndProfiles(input, wallet);
+
    const searchResult = await Search.searchProjectsAndProfiles(input, wallet);

    if (searchResult.type === "nothing") {
      shake();
@@ -193,9 +69,13 @@
    } else if (searchResult.type === "projectsAndProfiles") {
      // TODO: show some kind of notification about any errors to the user.
      input = "";
-
      dispatch("search", {
-
        query,
-
        results: searchResult.projectsAndProfiles,
+
      modal.show({
+
        component: SearchResultsModal,
+
        props: {
+
          wallet,
+
          results: searchResult.projectsAndProfiles,
+
          query,
+
        },
      });
      dispatch("finished");
    } else {
deleted src/App/Header/SearchResults.svelte
@@ -1,89 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import Modal from "@app/components/Modal.svelte";
-
  import { formatRadicleId, getSeedEmoji, twemoji } from "@app/lib/utils";
-
  import type { Wallet } from "@app/lib/wallet";
-
  import Address from "@app/components/Address.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import { createEventDispatcher } from "svelte";
-
  import type { ProjectsAndProfiles } from "./Search.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let query: string;
-
  export let results: ProjectsAndProfiles;
-
  export let wallet: Wallet;
-

-
  const dispatch = createEventDispatcher<{ close: never }>();
-
</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>
-

-
<svelte:window on:click={() => dispatch("close")} />
-

-
<Modal center floating>
-
  <span slot="title" use:twemoji>️🔍</span>
-
  <span slot="subtitle">
-
    <p class="txt-highlight txt-medium">
-
      <span class="txt-bold">
-
        Results for <q>{query}</q>
-
      </span>
-
    </p>
-
  </span>
-
  <span class="results" slot="body">
-
    {#if results.projects.length > 0}
-
      <p class="txt-highlight txt-medium">Projects</p>
-
      <ul>
-
        {#each results.projects as project}
-
          <li>
-
            <Link
-
              route={{
-
                resource: "projects",
-
                params: {
-
                  view: { resource: "tree" },
-
                  seed: project.seed.host,
-
                  id: project.info.id,
-
                },
-
              }}>
-
              <span title={project.seed.host}>
-
                <span>
-
                  {getSeedEmoji(project.seed.host)}&nbsp;{project.info.name}
-
                </span>
-
                <span class="id">
-
                  &nbsp;{formatRadicleId(project.info.id)}
-
                </span>
-
              </span>
-
            </Link>
-
          </li>
-
        {/each}
-
      </ul>
-
    {/if}
-
    {#if results.profiles.length > 0}
-
      <p class="txt-highlight txt-medium">ENS names</p>
-
      <ul>
-
        {#each results.profiles as profile}
-
          <li>
-
            <Address address={profile.address} {profile} {wallet} resolve />
-
          </li>
-
        {/each}
-
      </ul>
-
    {/if}
-
  </span>
-
  <span slot="actions">
-
    <Button variant="foreground" on:click={() => dispatch("close")}>
-
      Close
-
    </Button>
-
  </span>
-
</Modal>
added src/App/Header/SearchResultsModal.svelte
@@ -0,0 +1,73 @@
+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { ProjectsAndProfiles } from "@app/lib/search";
+

+
  import Address from "@app/components/Address.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { formatRadicleId, getSeedEmoji } from "@app/lib/utils";
+
  import * as modal from "@app/lib/modal";
+

+
  export let query: string;
+
  export let results: ProjectsAndProfiles;
+
  export let wallet: Wallet;
+
</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.projects.length > 0}
+
      <div class="txt-highlight txt-medium">Projects</div>
+
      <ul>
+
        {#each results.projects as project}
+
          <li>
+
            <Link
+
              on:click={modal.hide}
+
              route={{
+
                resource: "projects",
+
                params: {
+
                  view: { resource: "tree" },
+
                  seed: project.seed.host,
+
                  id: project.info.id,
+
                },
+
              }}>
+
              <span title={project.seed.host}>
+
                <span>
+
                  {getSeedEmoji(project.seed.host)}&nbsp;{project.info.name}
+
                </span>
+
                <span class="id">
+
                  &nbsp;{formatRadicleId(project.info.id)}
+
                </span>
+
              </span>
+
            </Link>
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
    {#if results.profiles.length > 0}
+
      <div class="txt-highlight txt-medium">ENS names</div>
+
      <ul>
+
        {#each results.profiles as profile}
+
          <li>
+
            <Address address={profile.address} {profile} {wallet} resolve />
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
  </span>
+
</Modal>
added src/App/HelpModal.svelte
@@ -0,0 +1,79 @@
+
<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>
added src/App/Hotkeys.svelte
@@ -0,0 +1,35 @@
+
<script lang="ts">
+
  import * as modal from "@app/lib/modal";
+

+
  import ColorPalette from "./ColorPalette.svelte";
+
  import HelpModal from "./HelpModal.svelte";
+

+
  const onKeydown = (event: KeyboardEvent) => {
+
    if (event.key === "Escape") {
+
      modal.hide();
+
      return;
+
    }
+

+
    switch (event.key) {
+
      case "?":
+
        modal.toggle({ component: HelpModal, props: {} });
+
        break;
+
      case "/": {
+
        event.preventDefault();
+
        const searchInput: HTMLElement | null = document.querySelector(
+
          '*[placeholder="Search a name or address…"]',
+
        );
+
        searchInput?.focus();
+
        break;
+
      }
+
      case "d":
+
        if (import.meta.env.PROD) {
+
          return;
+
        }
+
        modal.toggle({ component: ColorPalette, props: {} });
+
        break;
+
    }
+
  };
+
</script>
+

+
<svelte:window on:keydown={onKeydown} />
added src/App/ModalPortal.svelte
@@ -0,0 +1,41 @@
+
<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
+
      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/components/Button.svelte
@@ -8,6 +8,7 @@
    | "text";
  export let size: "tiny" | "small" | "regular" = "regular";

+
  export let autofocus: boolean = false;
  export let disabled: boolean = false;
  export let waiting: boolean = false;
  export let style: string | undefined = undefined;
@@ -112,10 +113,12 @@
  }
</style>

+
<!-- svelte-ignore a11y-autofocus -->
<button
  {title}
  {disabled}
  {style}
+
  {autofocus}
  on:click|stopPropagation
  on:focus
  on:blur
modified src/components/Connect.svelte
@@ -1,11 +1,11 @@
<script lang="ts">
-
  import type { Err } from "@app/lib/error";
  import type { Wallet } from "@app/lib/wallet";

  import { get } from "svelte/store";

+
  import * as modal from "@app/lib/modal";
  import Button from "@app/components/Button.svelte";
-
  import ConnectWallet from "@app/components/Connect/ConnectWallet.svelte";
+
  import ConnectWalletModal from "@app/components/Connect/ConnectWalletModal.svelte";
  import ErrorModal from "@app/components/ErrorModal.svelte";

  import { Connection, state } from "@app/lib/session";
@@ -13,8 +13,7 @@
  export let caption = "Connect";
  export let wallet: Wallet;
  export let buttonVariant: "foreground" | "primary";
-

-
  let error: Err | null = null;
+
  export let autofocus: boolean = false;

  const onModalClose = () => {
    const wcs = get(wallet.walletConnect.state);
@@ -24,20 +23,46 @@
      wcs.onClose();
    }
  };
+

  const onConnect = async () => {
    try {
      await state.connectWalletConnect(wallet);
-
    } catch (e: any) {
-
      walletConnectState.set({ state: "close" });
-
      error = e;
+
    } catch (error: any) {
+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Connection failed",
+
          error: error.message,
+
        },
+
      });
    }
  };
+
  const modalStore = modal.modalStore;

  $: connecting = $state.connection === Connection.Connecting;
  $: walletConnectState = wallet.walletConnect.state;
+
  $: if ($walletConnectState.state === "open") {
+
    modal.show({
+
      component: ConnectWalletModal,
+
      props: {
+
        wallet,
+
        uri: $walletConnectState.uri,
+
        onClose: () => {
+
          onModalClose();
+
          modal.hide();
+
        },
+
      },
+
      hideCallback: onModalClose,
+
    });
+
  } else {
+
    if ($modalStore?.component === ConnectWalletModal) {
+
      modal.hide();
+
    }
+
  }
</script>

<Button
+
  {autofocus}
  on:click={onConnect}
  variant={buttonVariant}
  disabled={connecting}
@@ -48,17 +73,3 @@
    {caption}
  {/if}
</Button>
-

-
{#if $walletConnectState.state === "open"}
-
  <ConnectWallet
-
    {wallet}
-
    uri={$walletConnectState.uri}
-
    on:close={onModalClose} />
-
{:else if error}
-
  <ErrorModal
-
    floating
-
    emoji="👛"
-
    title="Connection failed"
-
    {error}
-
    on:close={() => (error = null)} />
-
{/if}
deleted src/components/Connect/ConnectWallet.svelte
@@ -1,93 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import { createEventDispatcher } from "svelte";
-
  import { qrcode } from "pure-svg-code";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import { state } from "@app/lib/session";
-
  import { twemoji } from "@app/lib/utils";
-

-
  export let uri: string;
-
  export let wallet: Wallet;
-

-
  $: svgString = qrcode({
-
    content: uri,
-
    width: 200,
-
    height: 200,
-
    color: "black",
-
    padding: 0,
-
    background: "white",
-
    ecl: "M",
-
  });
-

-
  const dispatch = createEventDispatcher<{ close: never }>();
-
  const onClickConnect = () => {
-
    state.connectMetamask(wallet);
-
  };
-
  const onClose = () => {
-
    dispatch("close");
-
  };
-
</script>
-

-
<style>
-
  .qrcode-wrapper {
-
    width: min-content;
-
    margin: 0 auto;
-
    padding: 0.75rem;
-
    background-color: white;
-
  }
-
  .qrcode {
-
    width: 200px;
-
    height: 200px;
-
  }
-
  .wrapper {
-
    cursor: default;
-
    display: flex;
-
    flex-direction: column;
-
    height: 100%;
-
  }
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    gap: 0.75rem;
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <Modal floating center>
-
    <div slot="title">
-
      <div use:twemoji>👛</div>
-
      <div>Connect your wallet</div>
-
    </div>
-

-
    <div slot="subtitle">
-
      <div class="txt-small">
-
        Scan the QR code with <span class="txt-bold">WalletConnect</span>
-
        or use
-
        <span class="txt-bold">Metamask</span>
-
        .
-
      </div>
-
    </div>
-

-
    <div slot="body">
-
      <div class="qrcode-wrapper">
-
        <div class="qrcode">
-
          {@html svgString}
-
        </div>
-
      </div>
-
    </div>
-

-
    <div class="actions" slot="actions">
-
      <Button
-
        variant="secondary"
-
        size="small"
-
        on:click={onClickConnect}
-
        disabled={!wallet.metamask.signer}>
-
        Connect with Metamask
-
      </Button>
-
      <Button variant="text" size="small" on:click={onClose}>Close</Button>
-
    </div>
-
  </Modal>
-
</div>
added src/components/Connect/ConnectWalletModal.svelte
@@ -0,0 +1,72 @@
+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { qrcode } from "pure-svg-code";
+

+
  import Modal from "@app/components/Modal.svelte";
+
  import { state } from "@app/lib/session";
+

+
  export let uri: string;
+
  export let wallet: Wallet;
+
  export let onClose: () => void;
+

+
  $: svgString = qrcode({
+
    content: uri,
+
    width: 200,
+
    height: 200,
+
    color: "black",
+
    padding: 0,
+
    background: "white",
+
    ecl: "M",
+
  });
+
</script>
+

+
<style>
+
  .qrcode-wrapper {
+
    width: min-content;
+
    margin: 0 auto;
+
    padding: 0.75rem;
+
    background-color: white;
+
    border-radius: var(--border-radius-small);
+
  }
+
  .qrcode {
+
    width: 200px;
+
    height: 200px;
+
  }
+
</style>
+

+
<Modal
+
  emoji="👛"
+
  title="Connect your wallet"
+
  primaryAction={{
+
    name: "Connect with Metamask",
+
    callback: () => {
+
      state.connectMetamask(wallet);
+
    },
+
    props: {
+
      variant: "secondary",
+
      size: "small",
+
      disabled: !wallet.metamask.signer,
+
    },
+
  }}
+
  closeAction={{
+
    callback: onClose,
+
    props: { size: "small" },
+
  }}>
+
  <div slot="subtitle">
+
    <div class="txt-small">
+
      Scan the QR code with <span class="txt-bold">WalletConnect</span>
+
      or use
+
      <span class="txt-bold">Metamask</span>
+
      .
+
    </div>
+
  </div>
+

+
  <div slot="body" style:margin="1rem auto">
+
    <div class="qrcode-wrapper">
+
      <div class="qrcode">
+
        {@html svgString}
+
      </div>
+
    </div>
+
  </div>
+
</Modal>
added src/components/Emoji.svelte
@@ -0,0 +1,16 @@
+
<script lang="ts">
+
  // Use this component if you need an emoji that is reactive. For static
+
  // emojis use the `twemoji` action from the utils module.
+

+
  import twemoji from "twemoji";
+
  import { base } from "@app/lib/router";
+

+
  export let emoji: string;
+
</script>
+

+
{@html twemoji.parse(emoji, {
+
  base,
+
  folder: "twemoji",
+
  ext: ".svg",
+
  className: "txt-emoji",
+
})}
added src/components/Error.svelte
@@ -0,0 +1,35 @@
+
<script lang="ts">
+
  import { twemoji } from "@app/lib/utils";
+

+
  export let emoji: string = "👻";
+
  export let title: string;
+
  export let message: string;
+
</script>
+

+
<style>
+
  .error {
+
    display: flex;
+
    align-items: center;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+

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

+
  .title {
+
    color: var(--color-negative);
+
  }
+

+
  .message {
+
    color: var(--color-foreground-6);
+
  }
+
</style>
+

+
<div class="error">
+
  <div class="emoji" use:twemoji>{emoji}</div>
+
  <div class="title txt-medium txt-bold">{title}</div>
+
  <div class="message">{message}</div>
+
</div>
modified src/components/ErrorModal.svelte
@@ -1,51 +1,83 @@
-
<script lang="ts" strictEvents>
-
  import type { Err } from "@app/lib/error";
+
<script lang="ts">
+
  import type {
+
    CloseAction,
+
    PrimaryAction,
+
  } from "@app/components/Modal.svelte";

-
  import { createEventDispatcher } from "svelte";
-

-
  import Button from "@app/components/Button.svelte";
+
  import debounce from "lodash/debounce";
  import Modal from "@app/components/Modal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

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

+
  export let title: string;
+
  export let caption: string = "There was an error with your transaction.";
+
  export let error: string | undefined = undefined;

-
  import { twemoji } from "@app/lib/utils";
+
  export let closeAction: CloseAction = undefined;
+
  export let primaryAction: PrimaryAction = undefined;

-
  const dispatch = createEventDispatcher<{ close: never }>();
+
  const emoji = "🚨";
+
  let clipboardIcon: "clipboard" | "checkmark" = "clipboard";

-
  export let error: Err | null = null;
-
  export let title = "Error";
-
  export let emoji = "";
-
  export let subtitle = "";
-
  export let message = "";
-
  export let floating = false;
-
  export let subtle = false;
-
  export let action = floating ? "Close" : "Back";
+
  const resetIcon = debounce(() => {
+
    clipboardIcon = "clipboard";
+
  }, 800);

-
  const body = message || (error && error.message) || "";
+
  function copy() {
+
    if (error) {
+
      toClipboard(error);
+
    }
+
    clipboardIcon = "checkmark";
+
    resetIcon();
+
  }
</script>

-
<Modal on:close error {floating} {subtle}>
-
  <span slot="title" use:twemoji>
-
    {#if emoji}
-
      <div>{emoji}</div>
-
    {/if}
-
    {title}
-
  </span>
-

-
  <span slot="subtitle">
-
    {subtitle}
-
  </span>
-

-
  <span slot="body">
-
    <slot>
-
      <span class="txt-bold">Error:</span>
-
      {body}
-
    </slot>
-
  </span>
-

-
  <span slot="actions">
-
    <slot name="actions">
-
      <Button variant="negative" on:click={() => dispatch("close")}>
-
        {action}
-
      </Button>
-
    </slot>
-
  </span>
-
</Modal>
+
<style>
+
  .container {
+
    overflow: hidden;
+
    border-radius: var(--border-radius);
+
    position: relative;
+
  }
+

+
  .copy {
+
    position: absolute;
+
    right: 10px;
+
    top: 10px;
+
    padding: 2px;
+
    background-color: var(--color-foreground-2);
+
    border-radius: var(--border-radius-round);
+
    cursor: pointer;
+
  }
+

+
  .message {
+
    font-size: var(--font-size-tiny);
+
    word-wrap: break-word;
+
    max-height: 8rem;
+
    background-color: var(--color-foreground-2);
+
    overflow-y: auto;
+
    padding: 1rem;
+
    text-align: left;
+
  }
+
</style>
+

+
{#if error}
+
  <Modal {title} {emoji} {closeAction} {primaryAction}>
+
    <div slot="subtitle">{caption}</div>
+
    <div slot="body">
+
      <div class="container">
+
        <!-- svelte-ignore a11y-click-events-have-key-events -->
+
        <div class="copy" on:click={copy}>
+
          <Icon name={clipboardIcon} />
+
        </div>
+
        <div class="message txt-monospace txt-small">
+
          {error}
+
        </div>
+
      </div>
+
    </div>
+
  </Modal>
+
{:else}
+
  <Modal {title} {emoji} {closeAction} {primaryAction}>
+
    <div slot="subtitle">{caption}</div>
+
  </Modal>
+
{/if}
modified src/components/Markdown.svelte
@@ -121,7 +121,7 @@
  }

  .markdown :global(h1) {
-
    font-size: calc(var(--font-size-huge) * 0.75);
+
    font-size: calc(var(--font-size-x-large) * 0.75);
    font-weight: var(--font-weight-medium);
    padding: 1rem 0 0.5rem 0;
    margin: 0 0 0.75rem;
modified src/components/Message.svelte
@@ -9,7 +9,7 @@
  .message-error {
    color: var(--color-negative);
    border-radius: var(--border-radius);
-
    background-color: var(--glow-error);
+
    background-color: var(--color-negative-3);
  }
</style>

modified src/components/Modal.svelte
@@ -1,35 +1,47 @@
+
<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">
-
  export let floating = false;
-
  export let error = false;
-
  export let subtle = false;
-
  export let small = false;
-
  export let narrow = false;
-
  export let center = false;
+
  import * as modal from "@app/lib/modal";
+

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

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

+
  export let primaryAction: PrimaryAction = undefined;
+
  export let closeAction: CloseAction = undefined;
</script>

<style>
-
  .floating,
-
  .overlay {
-
    position: fixed;
-
    top: 0;
-
    left: 0;
-
    width: 100%;
-
    height: 100%;
-
    overflow: hidden;
-
  }
-
  .floating {
-
    z-index: 300;
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  .overlay {
-
    z-index: 200;
-
    background-color: #00000075;
-
  }
  .modal {
    padding: 2rem 3rem;
-
    border: 1px solid var(--color-secondary);
+
    border-radius: var(--border-radius);
    font-family: var(--font-family-sans-serif);
    background: var(--color-background);
    box-shadow: var(--elevation-high);
@@ -37,17 +49,6 @@
    max-width: 760px;
    text-align: center;
  }
-
  .narrow {
-
    max-width: 600px;
-
  }
-
  .subtle {
-
    border: none;
-
    box-shadow: none;
-
    background: radial-gradient(var(--glow) 0%, transparent 70%);
-
  }
-
  .subtle.error {
-
    background: radial-gradient(var(--glow-error) 0%, transparent 70%);
-
  }
  .title {
    color: var(--color-foreground);
    font-size: var(--font-size-medium);
@@ -73,22 +74,10 @@
    margin: 3rem 0;
  }
  .actions {
-
    padding-top: 0.5rem;
-
    margin-top: 2rem;
-
    text-align: center;
-
  }
-
  .small .subtitle {
-
    color: var(--color-foreground);
-
  }
-
  .error {
-
    box-shadow: var(--elevation-high-negative);
-
    border-color: var(--color-negative);
-
  }
-

-
  .error > * {
-
    color: var(--color-negative);
+
    gap: 1.5rem;
+
    display: flex;
+
    justify-content: center;
  }
-

  @media (max-width: 720px) {
    .modal {
      width: 90%;
@@ -97,25 +86,49 @@
  }
</style>

-
{#if floating}
-
  <div class="overlay" />
-
{/if}
-

-
<div class:floating class:layout-centered={!center}>
-
  <div class="modal" class:subtle class:narrow class:small class:error>
-
    <div class="title">
-
      <slot name="title" />
+
<div class="modal">
+
  {#if emoji}
+
    <div style:font-size="var(--font-size-xx-large)">
+
      <Emoji {emoji} />
    </div>
+
  {/if}
+

+
  {#if title}
+
    <div class="title">{title}</div>
+
  {/if}
+

+
  {#if $$slots.subtitle}
    <div class="subtitle">
      <slot name="subtitle" />
    </div>
-
    {#if $$slots.body && !small}
-
      <div class="body">
-
        <slot name="body" />
-
      </div>
-
    {/if}
-
    <div class="actions">
-
      <slot name="actions" />
+
  {/if}
+

+
  {#if $$slots.body}
+
    <div class="body">
+
      <slot name="body" />
    </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>
</div>
modified src/components/NotFound.svelte
@@ -3,21 +3,39 @@
  import { twemoji } from "@app/lib/utils";

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

-
  export let title = "";
-
  export let subtitle = "";
+
  export let title: string;
+
  export let subtitle: string;
</script>

-
<Modal subtle>
-
  <span slot="title" use:twemoji>🏜️</span>
-
  <span slot="body">
-
    <p class="txt-medium txt-highlight">
-
      <span class="txt-bold">{title}</span>
-
    </p>
-
    <p>{subtitle}</p>
-
  </span>
-
  <span slot="actions">
+
<style>
+
  .container {
+
    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);
+
  }
+

+
  .actions {
+
    margin-top: 1rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <div class="emoji" use:twemoji>🏜️</div>
+
  <div class="title txt-medium txt-bold">{title}</div>
+
  <div>{subtitle}</div>
+

+
  <div class="actions">
    <Button variant="foreground" on:click={router.pop}>Back</Button>
-
  </span>
-
</Modal>
+
  </div>
+
</div>
modified src/components/TextInput.svelte
@@ -34,6 +34,10 @@
    if (event.key === "Enter" && valid) {
      dispatch("submit");
    }
+

+
    if (event.key === "Escape") {
+
      inputElement?.blur();
+
    }
  }
</script>

@@ -101,10 +105,12 @@
  }

  .key-hint {
-
    border-radius: 0.25rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    padding: 0 0.5rem;
+
    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;
  }
</style>

modified src/components/Toggle.svelte
@@ -13,7 +13,7 @@
  }

  .toggle label {
-
    background-color: var(--color-background-1);
+
    background-color: var(--color-foreground-1);
    border: 1px solid var(--color-foreground-6);
    border-radius: var(--border-radius-round);
    cursor: pointer;
@@ -39,7 +39,7 @@
  }

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

added src/lib/modal.ts
@@ -0,0 +1,76 @@
+
import { derived, get, writable } from "svelte/store";
+
import type {
+
  ComponentProps,
+
  ComponentType,
+
  SvelteComponentTyped,
+
} from "svelte";
+

+
type HideCallback = () => void;
+

+
type Modal = {
+
  component: ComponentType;
+
  props: Record<string, unknown>;
+
  hideCallback?: HideCallback;
+
  disableHide?: boolean;
+
};
+

+
const store = writable<Modal | undefined>(undefined);
+
export const modalStore = derived(store, s => s);
+

+
export function enableHide() {
+
  store.update(s => {
+
    if (s) {
+
      return { ...s, disableHide: false };
+
    }
+
  });
+
}
+

+
export function disableHide() {
+
  store.update(s => {
+
    if (s) {
+
      return { ...s, disableHide: true };
+
    }
+
  });
+
}
+

+
export function hide(): void {
+
  const stored = get(modalStore);
+
  if (!stored || stored.disableHide) {
+
    return;
+
  }
+

+
  if (stored.hideCallback) {
+
    stored.hideCallback();
+
  }
+
  store.set(undefined);
+
}
+

+
interface ShowArgs<T extends SvelteComponentTyped> {
+
  component: ComponentType<T>;
+
  props: ComponentProps<T>;
+
  hideCallback?: HideCallback;
+
}
+

+
export function show<Component extends SvelteComponentTyped>(
+
  args: ShowArgs<Component>,
+
): void {
+
  // Defocus any active input elements, so that we can always close an open
+
  // modal via the `esc` hotkey.
+
  if (document.activeElement instanceof HTMLElement) {
+
    document.activeElement.blur();
+
  }
+
  store.set(args);
+
}
+

+
export function toggle<Component extends SvelteComponentTyped>(
+
  args: ShowArgs<Component>,
+
): void {
+
  const stored = get(modalStore);
+

+
  if (stored && stored.component === args.component) {
+
    hide();
+
    return;
+
  }
+

+
  show(args);
+
}
modified src/lib/router.ts
@@ -151,21 +151,10 @@ function pathToRoute(path: string): Route | null {
    case "registrations": {
      const nameOrDomain = segments.shift();
      const view = segments.shift();
-
      const owner = url.searchParams.get("owner");
      const retry = url.searchParams.get("retry");

      if (nameOrDomain) {
-
        if (view === "checkNameAvailability" || view === "register") {
-
          return {
-
            resource: "registrations",
-
            params: {
-
              view: {
-
                resource: view,
-
                params: { nameOrDomain, owner },
-
              },
-
            },
-
          };
-
        } else if (!view) {
+
        if (!view) {
          return {
            resource: "registrations",
            params: {
@@ -179,23 +168,11 @@ function pathToRoute(path: string): Route | null {
      }
      return {
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
+
        params: { view: { resource: "form" } },
      };
    }
    case "faucet": {
-
      const view = segments.shift();
-
      if (view === "withdraw") {
-
        return {
-
          resource: "faucet",
-
          params: {
-
            view: {
-
              resource: "withdraw",
-
              params: { amount: url.searchParams.get("amount") },
-
            },
-
          },
-
        };
-
      }
-
      return { resource: "faucet", params: { view: { resource: "form" } } };
+
      return { resource: "faucet" };
    }
    case "vesting": {
      const contract = segments.shift();
@@ -284,11 +261,7 @@ export function routeToPath(route: Route) {
  if (route.resource === "home") {
    return "/";
  } else if (route.resource === "faucet") {
-
    if (route.params.view.resource === "form") {
-
      return "/faucet";
-
    } else if (route.params.view.resource === "withdraw") {
-
      return `/faucet/withdraw?amount=${route.params.view.params.amount}`;
-
    }
+
    return "/faucet";
  } else if (route.resource === "vesting") {
    if (route.params.view.resource === "form") {
      return "/vesting";
@@ -356,18 +329,10 @@ export function routeToPath(route: Route) {
      return `${hostPrefix}/${route.params.id}${peer}${content}`;
    }
  } else if (route.resource === "registrations") {
-
    if (route.params.view.resource === "validateName") {
+
    if (route.params.view.resource === "form") {
      return `/registrations`;
    } else if (route.params.view.resource === "view") {
      return `/registrations/${route.params.view.params.nameOrDomain}?retry=${route.params.view.params.retry}`;
-
    } else if (
-
      route.params.view.resource === "checkNameAvailability" ||
-
      route.params.view.resource === "register"
-
    ) {
-
      if (route.params.view.params.owner) {
-
        return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}?owner=${route.params.view.params.owner}`;
-
      }
-
      return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}`;
    }
  } else if (route.resource === "profile") {
    return `/${route.params.addressOrName}`;
modified src/lib/router/definitions.ts
@@ -1,10 +1,10 @@
import type { VestingInfo } from "@app/lib/vesting";

export type Route =
-
  | FaucetRoute
  | ProjectRoute
  | RegistrationRoute
  | VestingRoute
+
  | { resource: "faucet" }
  | { resource: "home" }
  | { resource: "404"; params: { url: string } }
  | { resource: "profile"; params: { addressOrName: string } }
@@ -37,24 +37,10 @@ export interface VestingParams {
    | { resource: "view"; params: { contract: string; info?: VestingInfo } };
}

-
export interface FaucetParams {
-
  view:
-
    | { resource: "form" }
-
    | { resource: "withdraw"; params: { amount: string | null } };
-
}
-

export interface RegistrationParams {
  view:
    | {
-
        resource: "validateName";
-
      }
-
    | {
-
        resource: "checkNameAvailability";
-
        params: { nameOrDomain: string; owner: string | null };
-
      }
-
    | {
-
        resource: "register";
-
        params: { nameOrDomain: string; owner: string | null };
+
        resource: "form";
      }
    | {
        resource: "view";
@@ -63,7 +49,6 @@ export interface RegistrationParams {
}

export type ProjectRoute = { resource: "projects"; params: ProjectsParams };
-
export type FaucetRoute = { resource: "faucet"; params: FaucetParams };
export type VestingRoute = { resource: "vesting"; params: VestingParams };
export type RegistrationRoute = {
  resource: "registrations";
added src/lib/search.ts
@@ -0,0 +1,124 @@
+
import type { Host } from "@app/lib/api";
+
import type { ProjectInfo } from "@app/lib/project";
+
import type { Wallet } from "@app/lib/wallet";
+

+
import { ethers } from "ethers";
+

+
import * as utils from "@app/lib/utils";
+
import { Profile } from "@app/lib/profile";
+
import { Project } from "@app/lib/project";
+
import { config } from "@app/lib/config";
+

+
export interface ProjectsAndProfiles {
+
  projects: { info: ProjectInfo; seed: Host }[];
+
  profiles: Profile[];
+
}
+

+
type SearchResult =
+
  | { type: "nothing" }
+
  | { type: "error"; message: string }
+
  | { type: "singleProfile"; id: string }
+
  | { type: "singleProject"; seedHost: string; id: string }
+
  | { type: "projectsAndProfiles"; projectsAndProfiles: ProjectsAndProfiles };
+

+
export async function searchProjectsAndProfiles(
+
  query: string,
+
  wallet: Wallet,
+
): Promise<SearchResult> {
+
  try {
+
    // The query is a plain Ethereum address.
+
    if (ethers.utils.isAddress(query)) {
+
      return { type: "singleProfile", id: query };
+
    }
+

+
    const projectOnSeeds = config.seeds.pinned.map(seed => ({
+
      nameOrId: query,
+
      seed: seed.host,
+
    }));
+

+
    // The query is a radicle project ID.
+
    if (utils.isRadicleId(query)) {
+
      const projects = await Project.getMulti(projectOnSeeds);
+

+
      if (projects.length === 1) {
+
        return {
+
          type: "singleProject",
+
          seedHost: projects[0].seed.host,
+
          id: query,
+
        };
+
      } else if (projects.length === 0) {
+
        return { type: "nothing" };
+
      } else {
+
        return {
+
          type: "projectsAndProfiles",
+
          projectsAndProfiles: { projects, profiles: [] },
+
        };
+
      }
+
    }
+

+
    // The query is either a project or a profile name.
+
    const normalizedQuery = query.toLowerCase();
+
    const projectsAndProfiles: ProjectsAndProfiles = {
+
      projects: [],
+
      profiles: [],
+
    };
+

+
    try {
+
      const projects = await Project.getMulti(projectOnSeeds);
+
      projectsAndProfiles.projects.push(...projects);
+
    } catch {
+
      // TODO: collect errors and forward to user.
+
    }
+

+
    try {
+
      let params: string[];
+
      if (utils.isENSName(normalizedQuery, wallet)) {
+
        params = [normalizedQuery];
+
      } else {
+
        params = [
+
          `${normalizedQuery}.${wallet.registrar.domain}`,
+
          `${normalizedQuery}.eth`,
+
        ];
+
      }
+
      const profiles = await Profile.getMulti(params, wallet);
+
      projectsAndProfiles.profiles.push(...profiles);
+
    } catch {
+
      // TODO: collect errors and forward to user.
+
    }
+

+
    const projectCount = projectsAndProfiles.projects.length;
+
    const profileCount = projectsAndProfiles.profiles.length;
+

+
    if (profileCount === 1 && projectCount === 0) {
+
      return {
+
        type: "singleProfile",
+
        id: projectsAndProfiles.profiles[0].address,
+
      };
+
    }
+

+
    if (profileCount === 0 && projectCount === 1) {
+
      return {
+
        type: "singleProject",
+
        seedHost: projectsAndProfiles.projects[0].seed.host,
+
        id: query,
+
      };
+
    }
+

+
    if (profileCount > 0 || projectCount > 0) {
+
      return {
+
        type: "projectsAndProfiles",
+
        projectsAndProfiles,
+
      };
+
    }
+

+
    return { type: "nothing" };
+
  } catch (error) {
+
    let message = "An unknown error occoured while searching.";
+

+
    if (error instanceof Error) {
+
      message = error.message;
+
    }
+

+
    return { type: "error", message };
+
  }
+
}
modified src/lib/utils.ts
@@ -33,19 +33,6 @@ export interface Token {
  balance: BigNumber;
}

-
export enum Status {
-
  Signing,
-
  Pending,
-
  Success,
-
  Failed,
-
}
-

-
export type State =
-
  | { status: Status.Signing }
-
  | { status: Status.Pending }
-
  | { status: Status.Success }
-
  | { status: Status.Failed; error: string };
-

export async function isReverseRecordSet(
  address: string,
  domain: string,
modified src/lib/vesting.ts
@@ -5,10 +5,13 @@ import { BigNumber, ethers } from "ethers";
import { writable } from "svelte/store";

import * as cache from "@app/lib/cache";
+
import * as modal from "@app/lib/modal";
import * as session from "@app/lib/session";
import * as utils from "@app/lib/utils";
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";

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

export interface VestingInfo {
  token: string;
  symbol: string;
@@ -24,7 +27,6 @@ export interface VestingInfo {
export type VestingState =
  | { type: "idle" }
  | { type: "loading" }
-
  | { type: "error"; error: string }
  | { type: "withdrawingSign" }
  | { type: "withdrawing"; tx: TransactionResponse }
  | { type: "withdrawn" };
@@ -36,7 +38,13 @@ export async function withdrawVested(
  wallet: Wallet,
): Promise<void> {
  if (!wallet.signer) {
-
    state.set({ type: "error", error: "No signer available" });
+
    modal.show({
+
      component: ErrorModal,
+
      props: {
+
        title: "Withdraw failed",
+
        caption: "No signer available. Is your wallet connected?",
+
      },
+
    });
    return;
  }

@@ -160,6 +168,12 @@ export function handleEtherErrorState(e: unknown, message: string): void {
      ? e.reason
      : message;

-
  state.set({ type: "error", error });
+
  modal.show({
+
    component: ErrorModal,
+
    props: {
+
      title: "Withdraw failed",
+
      error,
+
    },
+
  });
  console.warn(e);
}
modified src/lib/wallet.ts
@@ -144,7 +144,7 @@ export class Wallet {
          state.set({ state: "open", uri, onClose });
        },
        close: () => {
-
          // We handle the "close" event through the "disconnect" handler.
+
          state.set({ state: "close" });
        },
      },
    });
added src/views/faucet/Faucet.svelte
@@ -0,0 +1,174 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { formatEther } from "@ethersproject/units";
+

+
  import * as modal from "@app/lib/modal";
+
  import WithdrawModal from "@app/views/faucet/WithdrawModal.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import {
+
    calculateTimeLock,
+
    getMaxWithdrawAmount,
+
    lastWithdrawalByUser,
+
  } from "@app/lib/faucet";
+
  import { session } from "@app/lib/session";
+
  import { setOpenGraphMetaTag, toWei, capitalize } from "@app/lib/utils";
+

+
  export let wallet: Wallet;
+

+
  let amount: string = "";
+
  let loading: boolean = false;
+
  let validationMessage: string | undefined = undefined;
+
  let valid: boolean = false;
+

+
  setOpenGraphMetaTag([
+
    { prop: "og:title", content: "Radicle Faucet" },
+
    { prop: "og:description", content: "Goerli Testnet Faucet" },
+
    { prop: "og:url", content: window.location.href },
+
  ]);
+

+
  async function withdraw(amount: string) {
+
    if (!valid || !$session) {
+
      return;
+
    }
+

+
    loading = true;
+
    try {
+
      const currentTime = new Date().getTime();
+
      const timelock = await calculateTimeLock(amount, $session.signer, wallet);
+
      const lastWithdrawal = await lastWithdrawalByUser(
+
        $session.signer,
+
        wallet,
+
      );
+
      const maxWithdrawAmount = await getMaxWithdrawAmount(
+
        $session.signer,
+
        wallet,
+
      );
+

+
      if (toWei(amount).gt(maxWithdrawAmount)) {
+
        validationMessage = `Reduce amount, max withdrawal is ${formatEther(
+
          maxWithdrawAmount,
+
        )}.`;
+
        return;
+
      }
+

+
      // Converting a 10 digit to 13 digit timestamp by multiplying by 1000
+
      // since JS doesn't display a correct Date string when passing a 10 digit
+
      // timestamp.
+
      const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
+
      if (nextAvailableWithdraw.gt(currentTime)) {
+
        validationMessage = `Not ready to withdraw, return after ${new Date(
+
          nextAvailableWithdraw.toNumber(),
+
        ).toLocaleString("en-GB")}`;
+
        return;
+
      }
+

+
      modal.show({ component: WithdrawModal, props: { amount, wallet } });
+
    } catch (error) {
+
      validationMessage = "There was an error, check the dev console.";
+
      console.error(error);
+
    } finally {
+
      loading = false;
+
    }
+
  }
+

+
  function validate(amount: string) {
+
    if (amount === "") {
+
      return { valid: false };
+
    }
+

+
    if (isNaN(Number(amount)) || Number(amount) <= 0) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a positive number.",
+
      };
+
    }
+

+
    return { valid: true };
+
  }
+

+
  $: ({ valid, validationMessage } = validate(amount));
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 28rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .subtitle {
+
    color: var(--color-secondary);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Faucet</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Obtain RAD tokens on <span class="txt-bold">
+
      {capitalize(wallet.network.name)}
+
    </span>
+
  </div>
+

+
  {#if wallet.network.name === "homestead"}
+
    <div class="subtitle">
+
      To get RAD tokens on <span class="txt-bold">
+
        {capitalize(wallet.network.name)},
+
      </span>
+
      please
+
      <br />
+
      check
+
      <a
+
        href="https://docs.radicle.xyz/get-involved/obtain-rad"
+
        class="txt-link">
+
        popular exchanges
+
      </a>
+
      &#8203;.
+
    </div>
+
  {:else if !$session}
+
    <div class="subtitle">
+
      To get RAD tokens on <span class="txt-bold">
+
        {capitalize(wallet.network.name)}
+
      </span>
+
      &#8203;,
+
      <br />
+
      please connect your wallet.
+
    </div>
+
  {:else}
+
    <div class="form">
+
      <TextInput
+
        autofocus
+
        placeholder="Enter amount to withdraw"
+
        {validationMessage}
+
        on:submit={() => {
+
          withdraw(amount);
+
        }}
+
        bind:value={amount}
+
        {valid}
+
        {loading} />
+

+
      <Button
+
        variant="primary"
+
        on:click={() => withdraw(amount)}
+
        disabled={!valid || loading}>
+
        Withdraw
+
      </Button>
+
    </div>
+
  {/if}
+
</main>
deleted src/views/faucet/Form.svelte
@@ -1,176 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import { formatEther } from "@ethersproject/units";
-

-
  import * as router from "@app/lib/router";
-
  import Button from "@app/components/Button.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import {
-
    calculateTimeLock,
-
    getMaxWithdrawAmount,
-
    lastWithdrawalByUser,
-
  } from "@app/lib/faucet";
-
  import { session } from "@app/lib/session";
-
  import { setOpenGraphMetaTag, toWei, capitalize } from "@app/lib/utils";
-

-
  export let wallet: Wallet;
-

-
  let amount: string = "";
-
  let loading: boolean = false;
-
  let validationMessage: string | undefined = undefined;
-
  let valid: boolean = false;
-

-
  setOpenGraphMetaTag([
-
    { prop: "og:title", content: "Radicle Faucet" },
-
    { prop: "og:description", content: "Goerli Testnet Faucet" },
-
    { prop: "og:url", content: window.location.href },
-
  ]);
-

-
  async function withdraw(amount: string) {
-
    if (!valid || !$session) {
-
      return;
-
    }
-

-
    loading = true;
-
    try {
-
      const currentTime = new Date().getTime();
-
      const timelock = await calculateTimeLock(amount, $session.signer, wallet);
-
      const lastWithdrawal = await lastWithdrawalByUser(
-
        $session.signer,
-
        wallet,
-
      );
-
      const maxWithdrawAmount = await getMaxWithdrawAmount(
-
        $session.signer,
-
        wallet,
-
      );
-

-
      if (toWei(amount).gt(maxWithdrawAmount)) {
-
        validationMessage = `Reduce amount, max withdrawal is ${formatEther(
-
          maxWithdrawAmount,
-
        )}.`;
-
        return;
-
      }
-

-
      // Converting a 10 digit to 13 digit timestamp by multiplying by 1000
-
      // since JS doesn't display a correct Date string when passing a 10 digit
-
      // timestamp.
-
      const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
-
      if (nextAvailableWithdraw.gt(currentTime)) {
-
        validationMessage = `Not ready to withdraw, return after ${new Date(
-
          nextAvailableWithdraw.toNumber(),
-
        ).toLocaleString("en-GB")}`;
-
        return;
-
      }
-

-
      router.push({
-
        resource: "faucet",
-
        params: { view: { resource: "withdraw", params: { amount } } },
-
      });
-
    } catch (error) {
-
      validationMessage = "There was an error, check the dev console.";
-
      console.error(error);
-
    } finally {
-
      loading = false;
-
    }
-
  }
-

-
  function validate(amount: string) {
-
    if (amount === "") {
-
      return { valid: false };
-
    }
-

-
    if (isNaN(Number(amount)) || Number(amount) <= 0) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a positive number.",
-
      };
-
    }
-

-
    return { valid: true };
-
  }
-

-
  $: ({ valid, validationMessage } = validate(amount));
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 28rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .subtitle {
-
    color: var(--color-secondary);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Faucet</title>
-
</svelte:head>
-

-
<main>
-
  <div class="title">
-
    Obtain RAD tokens on <span class="txt-bold">
-
      {capitalize(wallet.network.name)}
-
    </span>
-
  </div>
-

-
  {#if wallet.network.name === "homestead"}
-
    <div class="subtitle">
-
      To get RAD tokens on <span class="txt-bold">
-
        {capitalize(wallet.network.name)},
-
      </span>
-
      please
-
      <br />
-
      check
-
      <a
-
        href="https://docs.radicle.xyz/get-involved/obtain-rad"
-
        class="txt-link">
-
        popular exchanges
-
      </a>
-
      &#8203;.
-
    </div>
-
  {:else if !$session}
-
    <div class="subtitle">
-
      To get RAD tokens on <span class="txt-bold">
-
        {capitalize(wallet.network.name)}
-
      </span>
-
      &#8203;,
-
      <br />
-
      please connect your wallet.
-
    </div>
-
  {:else}
-
    <div class="form">
-
      <TextInput
-
        autofocus
-
        placeholder="Enter amount to withdraw"
-
        {validationMessage}
-
        on:submit={() => {
-
          withdraw(amount);
-
        }}
-
        bind:value={amount}
-
        {valid}
-
        {loading} />
-

-
      <Button
-
        variant="primary"
-
        on:click={() => withdraw(amount)}
-
        disabled={!valid || loading}>
-
        Withdraw
-
      </Button>
-
    </div>
-
  {/if}
-
</main>
deleted src/views/faucet/Routes.svelte
@@ -1,16 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/lib/wallet";
-
  import type { FaucetRoute } from "@app/lib/router/definitions";
-

-
  import Form from "@app/views/faucet/Form.svelte";
-
  import Withdraw from "@app/views/faucet/Withdraw.svelte";
-

-
  export let activeRoute: FaucetRoute;
-
  export let wallet: Wallet;
-
</script>
-

-
{#if activeRoute.params.view.resource === "form"}
-
  <Form {wallet} />
-
{:else if activeRoute.params.view.resource === "withdraw"}
-
  <Withdraw {wallet} amount={activeRoute.params.view.params.amount} />
-
{/if}
deleted src/views/faucet/Withdraw.svelte
@@ -1,100 +0,0 @@
-
<script lang="ts">
-
  import type { State } from "@app/lib/utils";
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import { onMount } from "svelte";
-
  import { twemoji } from "@app/lib/utils";
-

-
  import * as router from "@app/lib/router";
-
  import Button from "@app/components/Button.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import { Status } from "@app/lib/utils";
-
  import { session } from "@app/lib/session";
-
  import { withdraw } from "@app/lib/faucet";
-

-
  export let wallet: Wallet;
-
  export let amount: string | null;
-

-
  let error: Error;
-
  let state: State = {
-
    status: Status.Failed,
-
    error: "Error withdrawing, something happened.",
-
  };
-
  $: requester = $session && $session.address;
-

-
  function back() {
-
    router.push({ resource: "faucet", params: { view: { resource: "form" } } });
-
  }
-

-
  onMount(async () => {
-
    try {
-
      if (!amount) {
-
        throw new Error("You must supply the withdrawable amount.");
-
      }
-
      if ($session) {
-
        state.status = Status.Signing;
-
        const tx = await withdraw(amount, $session.signer, wallet);
-
        state.status = Status.Pending;
-
        await tx.wait();
-
        state.status = Status.Success;
-
      } else {
-
        back();
-
      }
-
    } catch (e: any) {
-
      console.error(e);
-
      error = e;
-
      state = { status: Status.Failed, error: e.message };
-
    }
-
  });
-
</script>
-

-
<style>
-
  .loader {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
  }
-
</style>
-

-
{#if error}
-
  <ErrorModal
-
    title="Transaction failed"
-
    message={error.message}
-
    on:close={back} />
-
{:else}
-
  <Modal>
-
    <span slot="title" use:twemoji>
-
      {#if state.status === Status.Success}
-
        <div>🎉</div>
-
      {:else}
-
        <div>🌐</div>
-
      {/if}
-
      Withdrawal
-
    </span>
-

-
    <span slot="subtitle">
-
      {#if state.status === Status.Signing}
-
        Signing transaction. Please confirm in your wallet.
-
      {:else if state.status === Status.Pending}
-
        Awaiting transaction.
-
      {/if}
-
    </span>
-

-
    <span slot="body" class="loader">
-
      {#if state.status === Status.Success}
-
        The amount of {amount} RAD tokens has been successfully transfered to
-
        <span class="txt-highlight">{requester}</span>
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if state.status === Status.Success}
-
        <Button variant="foreground" on:click={back}>Back</Button>
-
      {/if}
-
    </span>
-
  </Modal>
-
{/if}
added src/views/faucet/WithdrawModal.svelte
@@ -0,0 +1,85 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import * as modal from "@app/lib/modal";
+
  import * as utils from "@app/lib/utils";
+
  import { session } from "@app/lib/session";
+
  import { withdraw } from "@app/lib/faucet";
+

+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+

+
  export let wallet: Wallet;
+
  export let amount: string;
+

+
  let state: "initial" | "signing" | "pending" | "success" = "initial";
+

+
  onMount(async () => {
+
    modal.disableHide();
+
    try {
+
      if ($session) {
+
        state = "signing";
+
        const tx = await withdraw(amount, $session.signer, wallet);
+
        state = "pending";
+
        await tx.wait();
+
        state = "success";
+
        modal.enableHide();
+
      } else {
+
        modal.enableHide();
+
        modal.hide();
+
      }
+
    } catch (error: unknown) {
+
      let message: string | undefined;
+

+
      if (error instanceof Error) {
+
        message = error.message;
+
      } else {
+
        message = "Unknown error. Check dev console for details.";
+
        console.error(error);
+
      }
+

+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Transaction failed",
+
          error: message,
+
        },
+
      });
+
    }
+
  });
+
</script>
+

+
<Modal
+
  emoji={state === "success" ? "🎉" : "🌐"}
+
  title="Withdraw"
+
  closeAction={state === "success" ? { name: "Done" } : false}>
+
  <span slot="subtitle">
+
    {#if state === "signing"}
+
      {#if $session?.address}
+
        Send {amount} RAD to {utils.formatAddress($session.address)}.
+
        <br />
+
      {/if}
+
      Please confirm the transaction in your wallet.
+
    {:else if state === "pending"}
+
      Waiting for transaction to be processed…
+
    {/if}
+
  </span>
+

+
  <span slot="body">
+
    {#if state === "success"}
+
      {amount} RAD has been sent to
+
      {#if $session?.address}
+
        <span class="txt-highlight">
+
          {utils.formatAddress($session.address)}.
+
        </span>
+
      {/if}
+
    {:else}
+
      <div style:margin-top="2rem">
+
        <Loading noDelay small center />
+
      </div>
+
    {/if}
+
  </span>
+
</Modal>
modified src/views/profiles/Profile.svelte
@@ -1,17 +1,17 @@
<script lang="ts">
-
  import type { SvelteComponent } from "svelte";
  import type { Wallet } from "@app/lib/wallet";
  import type { Seed, Stats } from "@app/lib/seed";
  import type { ProjectInfo } from "@app/lib/project";
  import type { VestingInfo } from "@app/lib/vesting";

+
  import * as modal from "@app/lib/modal";
  import * as utils from "@app/lib/utils";
  import Address from "@app/components/Address.svelte";
  import Async from "@app/components/Async.svelte";
  import Avatar from "@app/components/Avatar.svelte";
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Error from "@app/components/Error.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
@@ -19,8 +19,8 @@
  import Projects from "@app/views/seeds/View/Projects.svelte";
  import RadicleId from "@app/components/RadicleId.svelte";
  import SeedAddress from "@app/components/SeedAddress.svelte";
-
  import SetName from "./SetName.svelte";
-
  import Withdraw from "@app/views/vesting/Withdraw.svelte";
+
  import SetNameModal from "@app/views/profiles/SetNameModal.svelte";
+
  import WithdrawModal from "@app/views/vesting/WithdrawModal.svelte";
  import { MissingReverseRecord, NotFoundError } from "@app/lib/error";
  import { User, Profile, ProfileType } from "@app/lib/profile";
  import { defaultNodePort } from "@app/lib/seed";
@@ -37,15 +37,6 @@
  export let addressOrName: string;

  let vestingInfo: VestingInfo | undefined = undefined;
-
  let setNameForm: typeof SvelteComponent | undefined = undefined;
-
  let withdrawVestingModal: typeof SvelteComponent | undefined = undefined;
-
  const setName = () => {
-
    setNameForm = SetName;
-
  };
-
  const withdrawVesting = () => {
-
    withdrawVestingModal = Withdraw;
-
  };
-

  const getProjectsAndStats = async (
    seed: Seed,
    id?: string,
@@ -61,7 +52,7 @@
  // Refresh vestingInfo and close modal if addressOrName changes
  $: {
    vestingInfo = undefined;
-
    withdrawVestingModal = undefined;
+
    modal.hide();
    getInfo(addressOrName, wallet)
      .then(info => {
        vestingInfo = info;
@@ -175,6 +166,12 @@
      grid-template-columns: max-content auto;
    }
  }
+
  .container {
+
    height: 100%;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
</style>

<svelte:head>
@@ -313,7 +310,18 @@
        </div>
        <div class="layout-desktop">
          {#if isUserAuthorized(profile.address) && !profile.org}
-
            <Button variant="secondary" size="small" on:click={setName}>
+
            <Button
+
              variant="secondary"
+
              size="small"
+
              on:click={() => {
+
                modal.show({
+
                  component: SetNameModal,
+
                  props: {
+
                    entity: new User(profile.address),
+
                    wallet,
+
                  },
+
                });
+
              }}>
              Set
            </Button>
          {/if}
@@ -344,7 +352,23 @@
        </div>
        <div class="layout-desktop">
          {#if isUserAuthorized(vestingInfo.beneficiary) && parseFloat(vestingInfo.withdrawableBalance) > 0}
-
            <Button variant="secondary" size="small" on:click={withdrawVesting}>
+
            <Button
+
              variant="secondary"
+
              size="small"
+
              on:click={() => {
+
                if (vestingInfo) {
+
                  modal.show({
+
                    component: WithdrawModal,
+
                    props: {
+
                      beneficiary: vestingInfo.beneficiary,
+
                      contractAddress: addressOrName,
+
                      balance: vestingInfo.withdrawableBalance,
+
                      currency: vestingInfo.symbol,
+
                      wallet,
+
                    },
+
                  });
+
                }
+
              }}>
              Withdraw
            </Button>
          {/if}
@@ -389,29 +413,20 @@
      </Async>
    {/if}
  </main>
-

-
  <svelte:component
-
    this={withdrawVestingModal}
-
    info={vestingInfo}
-
    contractAddress={addressOrName}
-
    {wallet}
-
    on:close={() => (withdrawVestingModal = undefined)} />
-

-
  <svelte:component
-
    this={setNameForm}
-
    entity={new User(profile.address)}
-
    {wallet}
-
    on:close={() => (setNameForm = undefined)} />
{:catch err}
-
  {#if err instanceof NotFoundError}
-
    <NotFound
-
      title={addressOrName}
-
      subtitle="Sorry, the requested address or domain was not found." />
-
  {:else if err instanceof MissingReverseRecord}
-
    <NotFound
-
      title={addressOrName}
-
      subtitle="Sorry, the requested name has no reverse record set." />
-
  {:else}
-
    <ErrorModal error={err} />
-
  {/if}
+
  <div class="container">
+
    {#if err instanceof NotFoundError}
+
      <NotFound
+
        title={addressOrName}
+
        subtitle="Sorry, the requested address or domain was not found." />
+
    {:else if err instanceof MissingReverseRecord}
+
      <NotFound
+
        title={addressOrName}
+
        subtitle="Sorry, the requested name has no reverse record set." />
+
    {:else}
+
      <Error
+
        title={`Could not load "${addressOrName}".`}
+
        message={`Error: ${err.message}`} />
+
    {/if}
+
  </div>
{/await}
deleted src/views/profiles/SetName.svelte
@@ -1,185 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Wallet } from "@app/lib/wallet";
-
  import type { User } from "@app/lib/profile";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import * as router from "@app/lib/router";
-
  import Button from "@app/components/Button.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import { formatAddress, isAddressEqual, twemoji } from "@app/lib/utils";
-

-
  const dispatch = createEventDispatcher<{ close: never }>();
-

-
  export let entity: User;
-
  export let wallet: Wallet;
-

-
  enum State {
-
    Idle,
-
    Checking,
-

-
    Signing,
-
    Pending,
-
    Success,
-

-
    Failed,
-
    Mismatch,
-
  }
-

-
  let name = "";
-
  let state = State.Idle;
-
  let error: string | null = null;
-

-
  const onSubmit = async () => {
-
    if (!valid) {
-
      return;
-
    }
-
    state = State.Checking;
-

-
    const domain = `${name}.${wallet.registrar.domain}`;
-
    const resolved = await wallet.provider.resolveName(domain);
-

-
    if (resolved && isAddressEqual(resolved, entity.address)) {
-
      try {
-
        state = State.Signing;
-
        const tx = await entity.setName(domain, wallet);
-
        state = State.Pending;
-
        await tx.wait();
-
        state = State.Success;
-
      } catch (e) {
-
        console.error(e);
-
        state = State.Failed;
-
        if (e instanceof Error) {
-
          error = e.message;
-
        } else {
-
          error = "Unknown error. Check dev console for details.";
-
        }
-
      }
-
    } else {
-
      state = State.Mismatch;
-
    }
-
  };
-

-
  $: valid = name !== "" && state === State.Idle;
-
</script>
-

-
<style>
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    gap: 1rem;
-
  }
-
</style>
-

-
{#if state === State.Success}
-
  <Modal floating>
-
    <div slot="title" use:twemoji>✅</div>
-

-
    <div slot="subtitle">
-
      The ENS name for {entity.address} was set to
-
      <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
-
      .
-
    </div>
-

-
    <div slot="actions">
-
      <Button variant="secondary" on:click={() => dispatch("close")}>
-
        Done
-
      </Button>
-
    </div>
-
  </Modal>
-
{:else if state === State.Mismatch}
-
  <ErrorModal floating title="🧣" on:close>
-
    The name <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
-
    does not resolve to
-
    <span class="txt-bold">{entity.address}</span>
-
    . Please update the ENS record for {name}.{wallet.registrar.domain} to to the
-
    correct address and try again.
-

-
    <div slot="actions">
-
      <Button
-
        variant="negative"
-
        on:click={() =>
-
          router.push({
-
            resource: "registrations",
-
            params: {
-
              view: {
-
                resource: "view",
-
                params: { nameOrDomain: name, retry: false },
-
              },
-
            },
-
          })}>
-
        Go to registration &rarr;
-
      </Button>
-
      <Button variant="negative" on:click={() => dispatch("close")}>
-
        Close
-
      </Button>
-
    </div>
-
  </ErrorModal>
-
{:else if state === State.Failed && error}
-
  <ErrorModal floating title="Transaction failed" message={error} on:close />
-
{:else}
-
  <Modal floating>
-
    <div slot="title">
-
      <div use:twemoji>🧣</div>
-
      <span>Associate profile</span>
-
    </div>
-

-
    <div slot="subtitle">
-
      {#if state === State.Signing}
-
        Please confirm the transaction in your wallet.
-
      {:else if state === State.Pending}
-
        Waiting for transaction to be processed…
-
      {:else}
-
        Set an ENS name for <span class="txt-bold">
-
          {formatAddress(entity.address)}
-
        </span>
-
        to associate a profile. ENS profiles provide human-identifiable data to your
-
        profile, such as a unique name, avatar and URL, and help make your profile
-
        more discoverable.
-
      {/if}
-
    </div>
-

-
    <div slot="body" style="display: flex; justify-content:center;">
-
      {#if state === State.Idle || state === State.Checking}
-
        <div style="width: 22rem;">
-
          <TextInput
-
            autofocus
-
            disabled={state !== State.Idle}
-
            on:submit={onSubmit}
-
            loading={state === State.Checking}
-
            {valid}
-
            bind:value={name}>
-
            <svelte:fragment slot="right">
-
              .{wallet.registrar.domain}
-
            </svelte:fragment>
-
          </TextInput>
-
        </div>
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </div>
-

-
    <div slot="actions" class="actions">
-
      {#if state === State.Signing}
-
        <Button variant="secondary" on:click={() => dispatch("close")}>
-
          Cancel
-
        </Button>
-
      {:else if state === State.Pending}
-
        <Button variant="secondary" on:click={() => dispatch("close")}>
-
          Close
-
        </Button>
-
      {:else}
-
        <Button variant="primary" on:click={onSubmit} disabled={!valid}>
-
          Submit
-
        </Button>
-

-
        <Button variant="text" on:click={() => dispatch("close")}>
-
          Cancel
-
        </Button>
-
      {/if}
-
    </div>
-
  </Modal>
-
{/if}
added src/views/profiles/SetNameModal.svelte
@@ -0,0 +1,145 @@
+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { User } from "@app/lib/profile";
+

+
  import * as modal from "@app/lib/modal";
+
  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import { formatAddress, isAddressEqual } from "@app/lib/utils";
+

+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  import SuccessModal from "@app/views/profiles/SetNameModal/SuccessModal.svelte";
+

+
  export let entity: User;
+
  export let wallet: Wallet;
+

+
  let name = "";
+
  let state: "idle" | "checking" | "signing" | "pending" = "idle";
+

+
  const submit = async () => {
+
    if (!valid) {
+
      return;
+
    }
+
    state = "checking";
+

+
    const domain = `${name}.${wallet.registrar.domain}`;
+
    const resolved = await wallet.provider.resolveName(domain);
+

+
    if (resolved && isAddressEqual(resolved, entity.address)) {
+
      modal.disableHide();
+
      try {
+
        state = "signing";
+
        const tx = await entity.setName(domain, wallet);
+

+
        state = "pending";
+
        await tx.wait();
+
        modal.show({
+
          component: SuccessModal,
+
          props: {
+
            name,
+
            domain: wallet.registrar.domain,
+
            address: entity.address,
+
          },
+
        });
+
      } catch (error: unknown) {
+
        let message: string;
+
        if (error instanceof Error) {
+
          message = error.message;
+
        } else {
+
          message = "Unknown error. Check dev console for details.";
+
          console.error(error);
+
        }
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Transaction failed",
+
            error: message,
+
          },
+
        });
+
      }
+
    } else {
+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Registration mismatch",
+
          caption: `The name ${domain} does not resolve to ${utils.formatAddress(
+
            entity.address,
+
          )}. Please update the ENS record for ${domain} to the correct address and try again.`,
+
          primaryAction: {
+
            name: "Go to registration",
+
            callback: () => {
+
              modal.hide();
+
              router.push({
+
                resource: "registrations",
+
                params: {
+
                  view: {
+
                    resource: "view",
+
                    params: { nameOrDomain: domain, retry: false },
+
                  },
+
                },
+
              });
+
            },
+
          },
+
        },
+
      });
+
    }
+
  };
+

+
  $: valid = name !== "" && state === "idle";
+

+
  $: primaryAction = ["idle", "checking"].includes(state)
+
    ? { name: "Submit", callback: submit, props: { disabled: !valid } }
+
    : undefined;
+
</script>
+

+
<Modal
+
  emoji="🧣"
+
  title="Associate profile"
+
  {primaryAction}
+
  closeAction={state === "idle" || state === "checking"
+
    ? { name: "Cancel" }
+
    : false}>
+
  <div slot="subtitle">
+
    {#if state === "signing"}
+
      Please confirm the transaction in your wallet.
+
    {:else if state === "pending"}
+
      Waiting for transaction to be processed…
+
    {:else}
+
      Set a ENS name for <span class="txt-bold">
+
        {formatAddress(entity.address)}
+
      </span>
+
      to associate a profile.
+
    {/if}
+
  </div>
+

+
  <div slot="body">
+
    {#if state === "idle" || state === "checking"}
+
      <div style:margin-bottom="1.5rem">
+
        ENS profiles provide human-identifiable data to your profile, such as a
+
        <br />
+
        unique name, avatar and URL, and help make your profile more discoverable.
+
      </div>
+
      <div style:width="25rem" style:margin="auto">
+
        <TextInput
+
          autofocus
+
          disabled={state !== "idle"}
+
          on:submit={submit}
+
          loading={state === "checking"}
+
          {valid}
+
          bind:value={name}>
+
          <svelte:fragment slot="right">
+
            .{wallet.registrar.domain}
+
          </svelte:fragment>
+
        </TextInput>
+
      </div>
+
    {:else}
+
      <div style:margin-top="1.5rem">
+
        <Loading noDelay small center />
+
      </div>
+
    {/if}
+
  </div>
+
</Modal>
added src/views/profiles/SetNameModal/SuccessModal.svelte
@@ -0,0 +1,17 @@
+
<script lang="ts" strictEvents>
+
  import * as utils from "@app/lib/utils";
+
  import Modal from "@app/components/Modal.svelte";
+

+
  export let address: string;
+
  export let name: string;
+
  export let domain: string;
+
</script>
+

+
<Modal emoji="✅" title="Associate profile" closeAction={{ name: "Done" }}>
+
  <div slot="subtitle" style:margin-bottom="1rem">
+
    The ENS name for {utils.formatAddress(address)}
+
    <br />
+
    was set to
+
    <span class="txt-bold">{name}.{domain}</span>
+
  </div>
+
</Modal>
modified src/views/projects/Blob.svelte
@@ -153,7 +153,7 @@
    width: 100%;
    border-spacing: 0;
    overflow-x: auto;
-
    font-size: 1rem;
+
    font-size: var(--font-size-regular);
    padding-top: 1rem;
    margin-bottom: 1.5rem;
  }
modified src/views/projects/ProjectMeta.svelte
@@ -21,7 +21,7 @@
    display: flex;
    align-items: center;
    justify-content: left;
-
    font-size: var(--font-size-huge);
+
    font-size: var(--font-size-x-large);
    margin-bottom: 0.5rem;
  }
  .title .divider {
modified src/views/projects/View.svelte
@@ -86,6 +86,13 @@
    padding: 0 2rem 0 8rem;
  }

+
  .container {
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    height: 100%;
+
  }
+

  @media (max-width: 960px) {
    main > header {
      padding-left: 2rem;
@@ -100,12 +107,14 @@
  }
</style>

-
<main>
-
  {#await getProject(id, peer, profile, seed)}
+
{#await getProject(id, peer, profile, seed)}
+
  <main>
    <header>
      <Loading center />
    </header>
-
  {:then project}
+
  </main>
+
{:then project}
+
  <main>
    <ProjectMeta {project} {peer} />
    {#await project.getRoot(revision)}
      <Loading center />
@@ -201,7 +210,9 @@
        {/if}
      </div>
    {/await}
-
  {:catch}
-
    <NotFound title={id} subtitle="This project was not found." />
-
  {/await}
-
</main>
+
  </main>
+
{:catch}
+
  <div class="container">
+
    <NotFound subtitle={id} title="This project was not found" />
+
  </div>
+
{/await}
deleted src/views/registrations/BlockTimer.svelte
@@ -1,33 +0,0 @@
-
<script lang="ts">
-
  export let startBlock: number;
-
  export let duration: number;
-
  export let latestBlock: number;
-

-
  let progress: number = 0;
-

-
  $: if (latestBlock < startBlock + duration) {
-
    progress = (latestBlock - startBlock) * Math.floor(100 / duration);
-
  } else {
-
    progress = 100;
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    text-align: center;
-
    height: 0.5rem;
-
    width: 100%;
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-secondary-2);
-
  }
-
  .progress-bar {
-
    height: 0.5rem;
-
    width: 0px;
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-secondary);
-
  }
-
</style>
-

-
<div class="container">
-
  <div class="progress-bar" style:width={`${progress}%`} />
-
</div>
added src/views/registrations/CheckNameModal.svelte
@@ -0,0 +1,126 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import * as modal from "@app/lib/modal";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import RegisterNameModal from "@app/views/registrations/RegisterNameModal.svelte";
+
  import { formatAddress } from "@app/lib/utils";
+
  import { registrar } from "@app/lib/registrar";
+
  import { session } from "@app/lib/session";
+

+
  export let wallet: Wallet;
+
  export let name: string;
+
  export let owner: string | null;
+

+
  // We only support lower-case names.
+
  name = name.toLowerCase();
+

+
  let state: "checkingAvailability" | "nameAvailable" | "nameUnavailable" =
+
    "checkingAvailability";
+

+
  function register() {
+
    if ($session) {
+
      modal.show({
+
        component: RegisterNameModal,
+
        props: {
+
          wallet,
+
          session: $session,
+
          name,
+
          owner: registrationOwner,
+
        },
+
      });
+
    } else {
+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Registration failed",
+
          caption: "You must connect your wallet to register",
+
        },
+
      });
+
    }
+
  }
+

+
  onMount(async () => {
+
    try {
+
      const isAvailable = await registrar(wallet).available(name);
+

+
      if (isAvailable) {
+
        state = "nameAvailable";
+
      } else {
+
        state = "nameUnavailable";
+
      }
+
    } catch (err: any) {
+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Checking name availability failed",
+
          error: err.message,
+
        },
+
      });
+
    }
+
  });
+

+
  $: registrationOwner = owner || ($session && $session.address);
+
  $: primaryAction =
+
    state === "nameAvailable"
+
      ? $session
+
        ? {
+
            name: "Begin registration",
+
            callback: register,
+
          }
+
        : {
+
            name: "Connect to register",
+
            callback: () => {
+
              // FIXME: this is a workaround until we refactor the
+
              // wallet/session and can simplify the Connect button logic.
+
              Array.from(document.querySelectorAll("button"))
+
                .find(e => {
+
                  return e.textContent === "Connect";
+
                })
+
                ?.click();
+
            },
+
          }
+
      : undefined;
+
</script>
+

+
<style>
+
  .highlight {
+
    color: var(--color-secondary);
+
  }
+
</style>
+

+
<Modal emoji="🌐" title="Register a name" {primaryAction}>
+
  <span slot="subtitle">
+
    {name}.{wallet.registrar.domain}
+
  </span>
+

+
  <span slot="body">
+
    {#if state === "nameAvailable"}
+
      {#if registrationOwner}
+
        The name <span class="highlight">
+
          {name}
+
        </span>
+
        is available for registration
+
        <br />
+
        under the account
+
        <span class="txt-bold">{formatAddress(registrationOwner)}.</span>
+
      {:else}
+
        The name <span class="highlight">
+
          {name}
+
        </span>
+
        is available.
+
      {/if}
+
    {:else if state === "nameUnavailable"}
+
      The name <span class="highlight">{name}</span>
+
      is
+
      <span class="txt-bold">not available</span>
+
      for registration.
+
    {:else if state === "checkingAvailability"}
+
      <Loading noDelay small center />
+
    {/if}
+
  </span>
+
</Modal>
deleted src/views/registrations/Index.svelte
@@ -1,124 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import * as router from "@app/lib/router";
-

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

-
  export let wallet: Wallet;
-

-
  let input = "";
-
  let valid: boolean = false;
-
  let validationMessage: string | undefined = undefined;
-

-
  function register() {
-
    if (!valid) {
-
      return;
-
    }
-
    router.push({
-
      resource: "registrations",
-
      params: {
-
        view: {
-
          resource: "checkNameAvailability",
-
          params: { nameOrDomain: ensName, owner: null },
-
        },
-
      },
-
    });
-
  }
-

-
  function validate(input: string) {
-
    if (input === "") {
-
      return { valid: false };
-
    }
-

-
    if (input && input.includes(".")) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please do not use dots as separators.",
-
      };
-
    }
-
    if (input && input.length < 2) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a minimum of 2 characters.",
-
      };
-
    }
-
    if (input && input.length > 128) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a maximum of 128 characters.",
-
      };
-
    }
-

-
    return { valid: true };
-
  }
-

-
  $: ensName = input.trim();
-
  $: ({ valid, validationMessage } = validate(ensName));
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 32rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .subtitle {
-
    color: var(--color-secondary);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Register</title>
-
</svelte:head>
-

-
<main>
-
  <div class="title">
-
    Register a <span class="txt-bold">{wallet.registrar.domain}</span>
-
    name
-
  </div>
-

-
  <div class="subtitle">
-
    Register a unique name with our ENS registrar, under <br />
-
    the
-
    <span class="txt-bold">{wallet.registrar.domain}</span>
-
    domain (e.g. cloudhead.{wallet.registrar.domain}).
-
    <br />
-
    Radicle names never expire and free to register.
-
  </div>
-

-
  <div class="form">
-
    <TextInput
-
      bind:value={input}
-
      autofocus
-
      on:submit={register}
-
      {valid}
-
      {validationMessage}>
-
      <svelte:fragment slot="right">
-
        .{wallet.registrar.domain}
-
      </svelte:fragment>
-
    </TextInput>
-

-
    <Button
-
      disabled={!valid}
-
      variant="primary"
-
      size="regular"
-
      on:click={register}>
-
      Check
-
    </Button>
-
  </div>
-
</main>
deleted src/views/registrations/New.svelte
@@ -1,133 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import { onMount } from "svelte";
-

-
  import * as router from "@app/lib/router";
-
  import Button from "@app/components/Button.svelte";
-
  import Connect from "@app/components/Connect.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Message from "@app/components/Message.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import { formatAddress, twemoji } from "@app/lib/utils";
-
  import { registrar } from "@app/lib/registrar";
-
  import { session } from "@app/lib/session";
-

-
  enum State {
-
    CheckingAvailability,
-
    CheckingFailed,
-
    NameAvailable,
-
    NameUnavailable,
-
  }
-

-
  export let wallet: Wallet;
-
  export let name: string;
-
  export let owner: string | null;
-

-
  // We only support lower-case names.
-
  name = name.toLowerCase();
-

-
  let state = State.CheckingAvailability;
-
  let error: string | null = null;
-
  $: registrationOwner = owner || ($session && $session.address);
-

-
  function begin() {
-
    router.push({
-
      resource: "registrations",
-
      params: {
-
        view: {
-
          resource: "register",
-
          params: { nameOrDomain: name, owner: registrationOwner },
-
        },
-
      },
-
    });
-
  }
-

-
  onMount(async () => {
-
    try {
-
      const isAvailable = await registrar(wallet).available(name);
-

-
      if (isAvailable) {
-
        state = State.NameAvailable;
-
      } else {
-
        state = State.NameUnavailable;
-
      }
-
    } catch (err: any) {
-
      state = State.CheckingFailed;
-
      error = err.message;
-
    }
-
  });
-

-
  function goToValidateName() {
-
    router.push({
-
      resource: "registrations",
-
      params: { view: { resource: "validateName" } },
-
    });
-
  }
-
</script>
-

-
<style>
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    gap: 0.75rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle: Register {name}</title>
-
</svelte:head>
-

-
<Modal narrow>
-
  <span slot="title">
-
    <div use:twemoji>🌐</div>
-
    <span>Register a name</span>
-
  </span>
-

-
  <span slot="subtitle">
-
    {name}.{wallet.registrar.domain}
-
  </span>
-

-
  <span slot="body">
-
    {#if state === State.NameAvailable}
-
      {#if registrationOwner}
-
        The name <span class="txt-bold">{name}</span>
-
        is available for registration under account
-
        <span class="txt-bold">{formatAddress(registrationOwner)}</span>
-
        .
-
      {:else}
-
        The name <span class="txt-bold">{name}</span>
-
        is available.
-
      {/if}
-
    {:else if state === State.NameUnavailable}
-
      This name is <span class="txt-bold">not available</span>
-
      for registration.
-
    {:else if state === State.CheckingAvailability}
-
      <Loading small center />
-
    {:else if state === State.CheckingFailed && error}
-
      <Message error>
-
        <span class="txt-bold">Error:</span>
-
        {error}
-
      </Message>
-
    {/if}
-
  </span>
-

-
  <span class="actions" slot="actions">
-
    {#if state === State.NameAvailable}
-
      {#if $session}
-
        <Button on:click={begin} variant="primary">
-
          Begin registration &rarr;
-
        </Button>
-
      {:else}
-
        <Connect
-
          caption="Connect to register"
-
          buttonVariant="primary"
-
          {wallet} />
-
      {/if}
-

-
      <Button on:click={goToValidateName} variant="text">Cancel</Button>
-
    {:else if state === State.NameUnavailable || state === State.CheckingFailed}
-
      <Button variant="foreground" on:click={goToValidateName}>Back</Button>
-
    {/if}
-
  </span>
-
</Modal>
added src/views/registrations/RegisterNameModal.svelte
@@ -0,0 +1,142 @@
+
<script lang="ts">
+
  import type { Session } from "@app/lib/session";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import * as modal from "@app/lib/modal";
+
  import * as utils from "@app/lib/utils";
+
  import * as router from "@app/lib/router";
+
  import BlockTimer from "@app/views/registrations/RegisterNameModal/BlockTimer.svelte";
+
  import CheckNameModal from "@app/views/registrations/CheckNameModal.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { registerName, State, state } from "@app/lib/registrar";
+

+
  export let wallet: Wallet;
+
  export let name: string;
+
  export let owner: string | null;
+
  export let session: Session;
+

+
  const registrationOwner = owner || session.address;
+

+
  onMount(async () => {
+
    modal.disableHide();
+
    try {
+
      await registerName(name, registrationOwner, wallet);
+
    } catch (error: any) {
+
      console.error("Error", error);
+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Could not register name",
+
          error: error.message,
+
          closeAction: {
+
            callback: () => {
+
              modal.show({
+
                component: CheckNameModal,
+
                props: {
+
                  wallet,
+
                  name,
+
                  owner: null,
+
                },
+
              });
+
            },
+
          },
+
        },
+
      });
+
    }
+
  });
+

+
  let latestBlock: number;
+
  wallet.provider.on("block", (block: number) => {
+
    latestBlock = block;
+
  });
+

+
  $: closeAction =
+
    $state.connection === State.Registered
+
      ? {
+
          name: "View",
+
          callback: () => {
+
            modal.enableHide();
+
            modal.hide();
+
            router.push({
+
              resource: "registrations",
+
              params: {
+
                view: {
+
                  resource: "view",
+
                  params: {
+
                    nameOrDomain: `${name}.${wallet.registrar.domain}`,
+
                    retry: true,
+
                  },
+
                },
+
              },
+
            });
+
          },
+
          props: { variant: "foreground" as const },
+
        }
+
      : (false as const);
+
</script>
+

+
<style>
+
  .loader {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    margin-top: 1.5rem;
+
    padding: 0 2rem;
+
  }
+
</style>
+

+
<Modal
+
  emoji={$state.connection === State.Registered ? "🎉" : "🌐"}
+
  title={`${name}.${wallet.registrar.domain}`}
+
  {closeAction}>
+
  <span slot="subtitle">
+
    {#if $state.connection === State.Connecting}
+
      Connecting…
+
    {:else if $state.connection === State.SigningPermit}
+
      Approving registration fee. <br />
+
      Please confirm in your wallet.
+
    {:else if $state.connection === State.SigningCommit}
+
      Committing to <span class="txt-bold">{name}.</span>
+
      <br />
+
      Please confirm transaction in your wallet.
+
    {:else if $state.connection === State.Committing}
+
      Waiting for <span class="txt-bold">commit</span>
+
      transaction to be processed…
+
    {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
+
      Waiting for commitment to mature.
+
      <br />
+
      This may take a moment.
+
    {:else if $state.connection === State.SigningRegister}
+
      Proceeding with registration.
+
      <br />
+
      Please confirm transaction in your wallet.
+
    {:else if $state.connection === State.Registering}
+
      Waiting for the <span class="txt-bold">register</span>
+
      transaction to be processed…
+
    {/if}
+
  </span>
+

+
  <span slot="body">
+
    {#if $state.connection === State.Registered}
+
      This name has been successfully registered to
+
      <span class="txt-highlight">
+
        {utils.formatAddress(registrationOwner)}.
+
      </span>
+
    {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
+
      <div class="loader">
+
        <BlockTimer
+
          {latestBlock}
+
          startBlock={$state.commitmentBlock}
+
          duration={$state.minAge} />
+
      </div>
+
    {:else}
+
      <div style:margin-top="1.5rem">
+
        <Loading noDelay small center />
+
      </div>
+
    {/if}
+
  </span>
+
</Modal>
added src/views/registrations/RegisterNameModal/BlockTimer.svelte
@@ -0,0 +1,33 @@
+
<script lang="ts">
+
  export let startBlock: number;
+
  export let duration: number;
+
  export let latestBlock: number;
+

+
  let progress: number = 0;
+

+
  $: if (latestBlock < startBlock + duration) {
+
    progress = (latestBlock - startBlock) * Math.floor(100 / duration);
+
  } else {
+
    progress = 100;
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    text-align: center;
+
    height: 0.5rem;
+
    width: 100%;
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-secondary-2);
+
  }
+
  .progress-bar {
+
    height: 0.5rem;
+
    width: 0px;
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-secondary);
+
  }
+
</style>
+

+
<div class="container">
+
  <div class="progress-bar" style:width={`${progress}%`} />
+
</div>
added src/views/registrations/RegistrationForm.svelte
@@ -0,0 +1,124 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import * as modal from "@app/lib/modal";
+

+
  import CheckNameModal from "@app/views/registrations/CheckNameModal.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  export let wallet: Wallet;
+

+
  let input = "";
+
  let valid: boolean = false;
+
  let validationMessage: string | undefined = undefined;
+

+
  function register() {
+
    if (!valid) {
+
      return;
+
    }
+
    modal.show({
+
      component: CheckNameModal,
+
      props: {
+
        wallet,
+
        name: ensName,
+
        owner: null,
+
      },
+
    });
+
  }
+

+
  function validate(input: string) {
+
    if (input === "") {
+
      return { valid: false };
+
    }
+

+
    if (input && input.includes(".")) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please do not use dots as separators.",
+
      };
+
    }
+
    if (input && input.length < 2) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a minimum of 2 characters.",
+
      };
+
    }
+
    if (input && input.length > 128) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a maximum of 128 characters.",
+
      };
+
    }
+

+
    return { valid: true };
+
  }
+

+
  $: ensName = input.trim();
+
  $: ({ valid, validationMessage } = validate(ensName));
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 32rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .subtitle {
+
    color: var(--color-secondary);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Register</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Register a <span class="txt-bold">{wallet.registrar.domain}</span>
+
    name
+
  </div>
+

+
  <div class="subtitle">
+
    Register a unique name with our ENS registrar, under <br />
+
    the
+
    <span class="txt-bold">{wallet.registrar.domain}</span>
+
    domain (e.g. cloudhead.{wallet.registrar.domain}).
+
    <br />
+
    Radicle names never expire and free to register.
+
  </div>
+

+
  <div class="form">
+
    <TextInput
+
      bind:value={input}
+
      autofocus
+
      on:submit={register}
+
      {valid}
+
      {validationMessage}>
+
      <svelte:fragment slot="right">
+
        .{wallet.registrar.domain}
+
      </svelte:fragment>
+
    </TextInput>
+

+
    <Button
+
      disabled={!valid}
+
      variant="primary"
+
      size="regular"
+
      on:click={register}>
+
      Check
+
    </Button>
+
  </div>
+
</main>
modified src/views/registrations/Routes.svelte
@@ -1,46 +1,18 @@
<script lang="ts">
  import type { RegistrationRoute } from "@app/lib/router/definitions";
-
  import type { Session } from "@app/lib/session";
-
  import { unreachable } from "@app/lib/utils";
  import type { Wallet } from "@app/lib/wallet";

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

-
  import New from "@app/views/registrations/New.svelte";
-
  import Submit from "@app/views/registrations/Submit.svelte";
-
  import Index from "@app/views/registrations/Index.svelte";
+
  import RegistrationForm from "@app/views/registrations/RegistrationForm.svelte";
  import View from "@app/views/registrations/View.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";

  export let wallet: Wallet;
  export let activeRoute: RegistrationRoute;
-
  export let session: Session | null;
</script>

-
{#if activeRoute.params.view.resource === "validateName"}
-
  <Index {wallet} />
-
{:else if activeRoute.params.view.resource === "checkNameAvailability"}
-
  <New
-
    {wallet}
-
    name={activeRoute.params.view.params.nameOrDomain}
-
    owner={activeRoute.params.view.params.owner} />
-
{:else if activeRoute.params.view.resource === "register"}
-
  {#if session}
-
    <Submit
-
      {wallet}
-
      name={activeRoute.params.view.params.nameOrDomain}
-
      owner={activeRoute.params.view.params.owner}
-
      {session} />
-
  {:else}
-
    <ErrorModal
-
      message={"You must connect your wallet to register"}
-
      on:close={() => {
-
        router.push({
-
          resource: "registrations",
-
          params: { view: { resource: "validateName" } },
-
        });
-
      }} />
-
  {/if}
+
{#if activeRoute.params.view.resource === "form"}
+
  <RegistrationForm {wallet} />
{:else if activeRoute.params.view.resource === "view"}
  <View
    {wallet}
deleted src/views/registrations/Submit.svelte
@@ -1,129 +0,0 @@
-
<script lang="ts">
-
  import type { Session } from "@app/lib/session";
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import { onMount } from "svelte";
-

-
  import * as router from "@app/lib/router";
-
  import BlockTimer from "@app/views/registrations/BlockTimer.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import { registerName, State, state } from "@app/lib/registrar";
-
  import { twemoji } from "@app/lib/utils";
-

-
  export let wallet: Wallet;
-
  export let name: string;
-
  export let owner: string | null;
-
  export let session: Session;
-

-
  let error: Error | null = null;
-
  const registrationOwner = owner || session.address;
-

-
  function view() {
-
    router.push({
-
      resource: "registrations",
-
      params: {
-
        view: {
-
          resource: "view",
-
          params: {
-
            nameOrDomain: `${name}.${wallet.registrar.domain}`,
-
            retry: true,
-
          },
-
        },
-
      },
-
    });
-
  }
-

-
  onMount(async () => {
-
    try {
-
      await registerName(name, registrationOwner, wallet);
-
    } catch (e: any) {
-
      console.error("Error", e);
-

-
      state.set({ connection: State.Failed });
-
      error = e;
-
    }
-
  });
-

-
  let latestBlock: number;
-
  wallet.provider.on("block", (block: number) => {
-
    latestBlock = block;
-
  });
-
</script>
-

-
<style>
-
  .loader {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>{name}</title>
-
</svelte:head>
-

-
{#if error}
-
  <ErrorModal
-
    title="Transaction failed"
-
    message={error.message}
-
    on:close={() =>
-
      router.push({
-
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
-
      })} />
-
{:else}
-
  <Modal>
-
    <span slot="title" use:twemoji>
-
      {#if $state.connection === State.Registered}
-
        <div>🎉</div>
-
      {:else}
-
        <div>🌐</div>
-
      {/if}
-
      {name}.{wallet.registrar.domain}
-
    </span>
-

-
    <span slot="subtitle">
-
      {#if $state.connection === State.Connecting}
-
        Connecting…
-
      {:else if $state.connection === State.SigningPermit}
-
        Approving registration fee. Please confirm in your wallet.
-
      {:else if $state.connection === State.SigningCommit}
-
        Committing to <span class="txt-bold">{name}</span>
-
        . Please confirm transaction in your wallet.
-
      {:else if $state.connection === State.Committing}
-
        Waiting for <span class="txt-bold">commit</span>
-
        transaction to be processed&hellip;
-
      {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
-
        Waiting for commitment to mature. This may take a moment.
-
      {:else if $state.connection === State.SigningRegister}
-
        Proceeding with registration. Please confirm transaction in your wallet.
-
      {:else if $state.connection === State.Registering}
-
        Waiting for <span class="txt-bold">register</span>
-
        transaction to be processed&hellip;
-
      {/if}
-
    </span>
-

-
    <span slot="body" class="loader">
-
      {#if $state.connection === State.Registered}
-
        This name has been successfully registered to
-
        <span class="txt-highlight">{registrationOwner}</span>
-
      {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
-
        <BlockTimer
-
          {latestBlock}
-
          startBlock={$state.commitmentBlock}
-
          duration={$state.minAge} />
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if $state.connection === State.Registered}
-
        <Button on:click={view} variant="foreground">View</Button>
-
      {/if}
-
    </span>
-
  </Modal>
-
{/if}
modified src/views/registrations/Update.svelte
@@ -1,81 +1,75 @@
-
<script lang="ts" strictEvents>
+
<script lang="ts">
  import type { EnsRecord } from "@app/lib/resolver";
  import type { Registration } from "@app/lib/registrar";
  import type { Wallet } from "@app/lib/wallet";
-
  import type { State } from "@app/lib/utils";

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

+
  import * as modal from "@app/lib/modal";
+
  import { setRecords } from "@app/lib/resolver";
+

+
  import ErrorModal from "@app/components/ErrorModal.svelte";
  import Loading from "@app/components/Loading.svelte";
  import Modal from "@app/components/Modal.svelte";
-
  import { Status } from "@app/lib/utils";
-
  import { onMount, createEventDispatcher } from "svelte";
-
  import { setRecords } from "@app/lib/resolver";
-
  import { twemoji } from "@app/lib/utils";

  export let domain: string;
  export let wallet: Wallet;
  export let records: EnsRecord[];
  export let registration: Registration;

-
  const dispatch = createEventDispatcher<{ close: never }>();
-

-
  let state: State = {
-
    status: Status.Failed,
-
    error: "Error registering, something happened.",
-
  };
+
  let state: "initial" | "signing" | "pending" | "success" = "initial";

  onMount(async () => {
+
    modal.disableHide();
    try {
-
      state.status = Status.Signing;
+
      state = "signing";
      const tx = await setRecords(
        domain,
        records,
        registration.resolver,
        wallet,
      );
-
      state.status = Status.Pending;
+
      state = "pending";
      await tx.wait();
-
      state.status = Status.Success;
-
    } catch (e: any) {
-
      console.error(e);
-
      state = { status: Status.Failed, error: e.message };
+
      state = "success";
+
    } catch (error: any) {
+
      modal.show({
+
        component: ErrorModal,
+
        props: {
+
          title: "Updating registration failed",
+
          error: error.message,
+
        },
+
      });
    }
  });
-

-
  const onDone = () => {
-
    // Reload page to load updates to the registration.
-
    location.reload();
-
  };
-

-
  const onClose = () => {
-
    dispatch("close");
-
  };
</script>

-
<Modal floating error={state.status === Status.Failed}>
-
  <span slot="title">
-
    <div use:twemoji>🧾</div>
-
    <div>Update registration</div>
-
  </span>
+
<Modal
+
  emoji="🧾"
+
  title="Update registration"
+
  closeAction={state === "success"
+
    ? {
+
        name: "Done",
+
        callback: () => {
+
          location.reload();
+
        },
+
      }
+
    : false}>
  <span slot="subtitle">
-
    {#if state.status === Status.Signing}
+
    {#if state === "signing"}
      <p>Please confirm the transaction in your wallet</p>
-
    {:else if state.status === Status.Pending}
+
    {:else if state === "pending"}
      <p>Waiting for transaction to be processed…</p>
-
    {:else if state.status === Status.Success}
-
      <p>Your registration was successfully updated.</p>
-
    {:else if state.status === Status.Failed}
-
      <span class="txt-bold">Error:</span>
-
      {state.error}
+
    {:else if state === "success"}
+
      <p>Your registration was successfully updated</p>
    {/if}
  </span>
-
  <span slot="actions">
-
    {#if [Status.Signing, Status.Pending].includes(state.status)}
-
      <Loading center small />
-
    {:else if state.status === Status.Success}
-
      <Button variant="foreground" on:click={onDone}>Done</Button>
-
    {:else if state.status === Status.Failed}
-
      <Button variant="negative" on:click={onClose}>Close</Button>
+

+
  <div slot="body">
+
    {#if ["signing", "pending"].includes(state)}
+
      <div style="margin-top: 1.5rem;">
+
        <Loading noDelay small center />
+
      </div>
    {/if}
-
  </span>
+
  </div>
</Modal>
modified src/views/registrations/View.svelte
@@ -7,32 +7,21 @@

  import { onMount } from "svelte";

+
  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
-
  import Button from "@app/components/Button.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";
-
  import Form from "@app/components/Form.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import Update from "./Update.svelte";
  import { assert } from "@app/lib/error";
  import { defaultSeedPort } from "@app/lib/seed";
  import { getRegistration, getOwner } from "@app/lib/registrar";
  import { isAddressEqual, isReverseRecordSet, twemoji } from "@app/lib/utils";
  import { session } from "@app/lib/session";

-
  enum Status {
-
    Loading,
-
    Found,
-
    NotFound,
-
    Failed,
-
  }
+
  import Button from "@app/components/Button.svelte";
+
  import Form from "@app/components/Form.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";

-
  type State =
-
    | { status: Status.Loading }
-
    | { status: Status.NotFound }
-
    | { status: Status.Found; registration: Registration; owner: string }
-
    | { status: Status.Failed; error: string };
+
  import Update from "@app/views/registrations/Update.svelte";
+
  import CheckNameModal from "@app/views/registrations/CheckNameModal.svelte";

  export let domain: string;
  export let wallet: Wallet;
@@ -40,7 +29,17 @@

  domain = domain.toLowerCase();

-
  let state: State = { status: Status.Loading };
+
  if (!domain.includes(".")) {
+
    domain = `${domain}.${wallet.registrar.domain}`;
+
  }
+

+
  let state:
+
    | { type: "loading" }
+
    | { type: "notFound" }
+
    | { type: "found"; registration: Registration; owner: string } = {
+
    type: "loading",
+
  };
+

  let editable = false;
  let fields: Field[] = [];
  let updateRecords: EnsRecord[] | null = null;
@@ -155,35 +154,64 @@
          editable: true,
        },
      ];
-
      state = { status: Status.Found, registration: r, owner };
+
      state = { type: "found", registration: r, owner };
    } else {
-
      state = { status: Status.NotFound };
+
      state = { type: "notFound" };
    }
    if (retry) retries -= 1;
    return r;
  }

+
  function showErrorModal(message: string) {
+
    modal.show({
+
      component: ErrorModal,
+
      props: {
+
        title: "Registration could not be loaded",
+
        error: message,
+
        closeAction: {
+
          callback: () => {
+
            router.push({
+
              resource: "registrations",
+
              params: { view: { resource: "form" } },
+
            });
+
            modal.hide();
+
          },
+
        },
+
      },
+
    });
+
  }
+

  onMount(() => {
    getRegistration(domain, wallet, resolver)
      .then(parseRecords)
-
      .catch(err => {
-
        state = { status: Status.Failed, error: err };
+
      .catch(error => {
+
        showErrorModal(error.message);
      });
  });

  const onSave = async (event: { detail: RegistrationRecord[] }) => {
-
    assert(state.status === Status.Found, "registration must be found");
+
    assert(state.type === "found", "registration must be found");

    updateRecords = event.detail.map(f => {
      return { name: f.name, value: f.value };
    });
+

+
    modal.show({
+
      component: Update,
+
      props: {
+
        wallet,
+
        domain,
+
        registration: state.registration,
+
        records: updateRecords,
+
      },
+
    });
  };

-
  $: if (retry && state.status === Status.NotFound && retries > 0) {
+
  $: if (retry && state.type === "notFound" && retries > 0) {
    getRegistration(domain, wallet, resolver)
      .then(parseRecords)
-
      .catch(err => {
-
        state = { status: Status.Failed, error: err };
+
      .catch(error => {
+
        showErrorModal(error.message);
      });
  }

@@ -202,17 +230,12 @@
    justify-content: left;
    margin-bottom: 2rem;
  }
-
  .register {
-
    color: var(--color-primary);
-
    border-bottom-color: var(--color-primary-5);
-
  }
-
  .register:hover {
-
    color: var(--color-primary-5);
-
    border-bottom-color: var(--color-primary-5);
-
  }
  main > header > * {
    margin: 0 1rem 0 0;
  }
+
  .emoji {
+
    font-size: var(--font-size-xx-large);
+
  }
  @media (max-width: 720px) {
    main {
      width: 100%;
@@ -220,54 +243,50 @@
      padding-right: 1rem;
    }
  }
+

+
  .placeholder {
+
    text-align: center;
+
  }
+
  .container {
+
    height: 100%;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
</style>

<svelte:head>
  <title>{domain}</title>
</svelte:head>

-
{#if state.status === Status.Loading}
+
{#if state.type === "loading"}
  <Loading />
-
{:else if state.status === Status.Failed}
-
  <ErrorModal
-
    title="Registration could not be loaded"
-
    on:close={() =>
-
      router.push({
-
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
-
      })}>
-
    {state.error}
-
  </ErrorModal>
-
{:else if state.status === Status.NotFound}
-
  <Modal subtle>
-
    <span slot="title" class="txt-highlight">
-
      <div use:twemoji>🍄</div>
-
      {domain}
-
    </span>
-

-
    <span slot="body">
-
      <p>
+
{:else if state.type === "notFound"}
+
  <div class="container">
+
    <div class="placeholder">
+
      <div class="emoji" use:twemoji>🍄</div>
+
      <div class="txt-highlight txt-medium txt-bold">{domain}</div>
+
      <p style:margin-bottom="2rem">
        The name <span class="txt-bold">{domain}</span>
        is not registered.
      </p>
-
    </span>
-

-
    <span slot="actions">
-
      <Link
-
        route={{
-
          resource: "registrations",
-
          params: {
-
            view: {
-
              resource: "register",
-
              params: { nameOrDomain: domain, owner: null },
+
      <Button
+
        on:click={() => {
+
          modal.show({
+
            component: CheckNameModal,
+
            props: {
+
              name: domain.replace(`.${wallet.registrar.domain}`, ""),
+
              wallet,
+
              owner: null,
            },
-
          },
-
        }}>
-
        <span class="txt-link register">Register &rarr;</span>
-
      </Link>
-
    </span>
-
  </Modal>
-
{:else if state.status === Status.Found}
+
          });
+
        }}
+
        variant="primary">
+
        Register
+
      </Button>
+
    </div>
+
  </div>
+
{:else if state.type === "found"}
  <main>
    <header>
      <div class="txt-title txt-bold">{domain}</div>
@@ -293,13 +312,4 @@
      on:save={onSave}
      on:cancel={() => (editable = false)} />
  </main>
-

-
  {#if updateRecords}
-
    <Update
-
      {wallet}
-
      {domain}
-
      on:close={() => (updateRecords = null)}
-
      registration={state.registration}
-
      records={updateRecords} />
-
  {/if}
{/if}
modified src/views/seeds/View.svelte
@@ -122,7 +122,9 @@
    </Async>
  </main>
{:catch}
-
  <NotFound
-
    title={host}
-
    subtitle="Not able to query information from this seed." />
+
  <main class="layout-centered">
+
    <NotFound
+
      title={host}
+
      subtitle="Not able to query information from this seed." />
+
  </main>
{/await}
modified src/views/vesting/Routes.svelte
@@ -2,6 +2,8 @@
  import type { Wallet } from "@app/lib/wallet";
  import type { VestingRoute } from "@app/lib/router/definitions";

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

  import Form from "@app/views/vesting/Form.svelte";
  import Profile from "@app/views/profiles/Profile.svelte";

@@ -13,4 +15,6 @@
  <Form {wallet} />
{:else if activeRoute.params.view.resource === "view"}
  <Profile {wallet} addressOrName={activeRoute.params.view.params.contract} />
+
{:else}
+
  {unreachable(activeRoute.params.view)}
{/if}
deleted src/views/vesting/Withdraw.svelte
@@ -1,72 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { VestingInfo } from "@app/lib/vesting";
-
  import type { Wallet } from "@app/lib/wallet";
-

-
  import * as utils from "@app/lib/utils";
-
  import Button from "@app/components/Button.svelte";
-
  import ErrorModal from "@app/components/ErrorModal.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import Modal from "@app/components/Modal.svelte";
-
  import { createEventDispatcher, onMount } from "svelte";
-
  import { state } from "@app/lib/vesting";
-
  import { withdrawVested } from "@app/lib/vesting";
-

-
  export let contractAddress: string;
-
  export let info: VestingInfo;
-
  export let wallet: Wallet;
-

-
  const dispatch = createEventDispatcher<{ close: never }>();
-

-
  onMount(async () => {
-
    await withdrawVested(contractAddress, wallet);
-
  });
-
</script>
-

-
<style>
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    flex-direction: row;
-
    gap: 1rem;
-
  }
-
</style>
-

-
{#if $state.type === "error"}
-
  <ErrorModal
-
    floating
-
    title="Withdraw failed"
-
    message={$state.error}
-
    on:close={() => dispatch("close")} />
-
{:else}
-
  <Modal on:close floating>
-
    <span slot="title">
-
      {utils.formatAddress(contractAddress)}
-
    </span>
-

-
    <span slot="subtitle">
-
      {#if $state.type === "withdrawingSign"}
-
        <span class="txt-missing">Waiting for a signature…</span>
-
      {:else if $state.type === "withdrawing"}
-
        <span class="txt-missing">Waiting for confirmation…</span>
-
      {/if}
-
    </span>
-

-
    <span slot="body">
-
      {#if $state.type === "withdrawn"}
-
        <span>
-
          Tokens have been withdrawn to <span class="txt-highlight">
-
            {utils.formatAddress(info.beneficiary)}
-
          </span>
-
        </span>
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </span>
-

-
    <span class="actions" slot="actions">
-
      <Button variant="foreground" on:click={() => dispatch("close")}>
-
        Close
-
      </Button>
-
    </span>
-
  </Modal>
-
{/if}
added src/views/vesting/WithdrawModal.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+

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

+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { onMount } from "svelte";
+
  import { state } from "@app/lib/vesting";
+
  import { withdrawVested } from "@app/lib/vesting";
+

+
  export let contractAddress: string;
+
  export let beneficiary: string;
+
  export let wallet: Wallet;
+
  export let balance: string;
+
  export let currency: string;
+

+
  onMount(async () => {
+
    modal.disableHide();
+
    await withdrawVested(contractAddress, wallet);
+
    modal.enableHide();
+
  });
+
</script>
+

+
<Modal
+
  emoji="💰"
+
  title="Withdraw funds"
+
  closeAction={$state.type === "withdrawn" ? { name: "Done" } : false}>
+
  <span slot="subtitle">
+
    {#if $state.type === "withdrawingSign"}
+
      Send {balance}
+
      {currency} to
+
      {utils.formatAddress(beneficiary)}.
+
      <br />
+
      Please confirm in your wallet.
+
    {:else if $state.type === "withdrawing"}
+
      Waiting for transaction to be processed…
+
    {/if}
+
  </span>
+

+
  <span slot="body">
+
    {#if $state.type === "withdrawn"}
+
      <span>
+
        Tokens have been withdrawn to <span class="txt-highlight">
+
          {utils.formatAddress(beneficiary)}
+
        </span>
+
      </span>
+
    {:else}
+
      <div style:margin-top="1.5rem">
+
        <Loading noDelay small center />
+
      </div>
+
    {/if}
+
  </span>
+
</Modal>
added tests/e2e/hotkeys.spec.ts
@@ -0,0 +1,36 @@
+
import { test, expect } from "@tests/support/fixtures.js";
+

+
const searchPlaceholder = "Search a name or address…";
+

+
test("global hotkeys", async ({ page }) => {
+
  await page.goto("/");
+
  await page.locator("body").press(`/`);
+
  await page.keyboard.type("searchquery");
+

+
  // Keyboard hint shows up in the search bar.
+
  {
+
    await expect(page.getByText("⏎")).toBeVisible();
+
    await expect(page.getByPlaceholder(searchPlaceholder)).toHaveValue(
+
      "searchquery",
+
    );
+
  }
+

+
  // Other hotkeys don't trigger while input is focussed.
+
  {
+
    await page.keyboard.type("?");
+
    await expect(page.getByPlaceholder(searchPlaceholder)).toHaveValue(
+
      "searchquery?",
+
    );
+
    await expect(page.getByText("Keyboard shortcuts")).not.toBeVisible();
+
  }
+

+
  // Hitting `Esc` defocuses the input.
+
  {
+
    await page.locator("body").press("Escape");
+
    await expect(page.getByPlaceholder(searchPlaceholder)).toHaveValue(
+
      "searchquery?",
+
    );
+
    await expect(page.getByText("⏎")).toBeVisible();
+
    await expect(page.getByPlaceholder(searchPlaceholder)).not.toBeFocused();
+
  }
+
});
added tests/e2e/modal.spec.ts
@@ -0,0 +1,18 @@
+
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();
+

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

+
  await page.locator("body").press(`?`);
+
  await expect(page.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();
+
});
modified tests/unit/router.test.ts
@@ -14,7 +14,7 @@ describe("routeToPath", () => {
      description: "Vesting Route",
    },
    {
-
      input: { resource: "faucet", params: { view: { resource: "form" } } },
+
      input: { resource: "faucet" },
      output: "/faucet",
      description: "Faucet Form Route",
    },
@@ -35,7 +35,7 @@ describe("routeToPath", () => {
      input: {
        resource: "registrations",
        params: {
-
          view: { resource: "validateName" },
+
          view: { resource: "form" },
        },
      },
      output: "/registrations",
@@ -69,34 +69,6 @@ describe("routeToPath", () => {
    },
    {
      input: {
-
        resource: "registrations",
-
        params: {
-
          view: {
-
            resource: "checkNameAvailability",
-
            params: {
-
              nameOrDomain: "sebastinez",
-
            },
-
          },
-
        },
-
      },
-
      output: "/registrations/sebastinez/checkNameAvailability",
-
      description: "registrations Form Route",
-
    },
-
    {
-
      input: {
-
        resource: "registrations",
-
        params: {
-
          view: {
-
            resource: "register",
-
            params: { nameOrDomain: "sebastinez" },
-
          },
-
        },
-
      },
-
      output: "/registrations/sebastinez/register",
-
      description: "registrations Submit Route",
-
    },
-
    {
-
      input: {
        resource: "projects",
        params: {
          view: { resource: "tree" },
@@ -129,18 +101,10 @@ describe("pathToRoute", () => {
    },
    {
      input: "/faucet",
-
      output: { resource: "faucet", params: { view: { resource: "form" } } },
+
      output: { resource: "faucet" },
      description: "Faucet Form Route",
    },
    {
-
      input: "/faucet/withdraw?amount=10",
-
      output: {
-
        resource: "faucet",
-
        params: { view: { resource: "withdraw", params: { amount: "10" } } },
-
      },
-
      description: "Faucet Withdraw Route",
-
    },
-
    {
      input: "/cloudhead.eth",
      output: {
        resource: "profile",
@@ -158,7 +122,7 @@ describe("pathToRoute", () => {
      output: {
        resource: "registrations",
        params: {
-
          view: { resource: "validateName" },
+
          view: { resource: "form" },
        },
      },
      description: "registrations Index Route",
@@ -190,35 +154,6 @@ describe("pathToRoute", () => {
      description: "registrations View Route",
    },
    {
-
      input: "/registrations/sebastinez/checkNameAvailability",
-
      output: {
-
        resource: "registrations",
-
        params: {
-
          view: {
-
            resource: "checkNameAvailability",
-
            params: {
-
              nameOrDomain: "sebastinez",
-
              owner: null,
-
            },
-
          },
-
        },
-
      },
-
      description: "registrations Form Route",
-
    },
-
    {
-
      input: "/registrations/sebastinez/register",
-
      output: {
-
        resource: "registrations",
-
        params: {
-
          view: {
-
            resource: "register",
-
            params: { nameOrDomain: "sebastinez", owner: null },
-
          },
-
        },
-
      },
-
      description: "registrations Submit Route",
-
    },
-
    {
      input: "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
      output: {
        resource: "projects",