Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add experimental switch in settings
Merged did:key:z6MkkfM3...sVz5 opened 2 years ago
21 files changed +310 -269 273dff2d 1ad85ac2
modified src/App/Header.svelte
@@ -11,6 +11,7 @@
  import NodeInfo from "@app/App/Header/NodeInfo.svelte";
  import Popover from "@app/components/Popover.svelte";
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";
+
  import { experimental } from "@app/lib/appearance";

  const buttonTitle: Record<HttpdState["state"], string> = {
    stopped: "radicle-httpd is stopped",
@@ -60,24 +61,26 @@
  </div>

  <div class="right">
-
    {#if $httpdStore.state === "stopped"}
-
      <Popover popoverPositionTop="3rem" popoverPositionRight="0">
-
        <Button
-
          slot="toggle"
-
          let:toggle
-
          on:click={toggle}
-
          title={buttonTitle[$httpdStore.state]}
-
          variant="naked-toggle">
-
          <IconSmall name="device" />
-
          Connect
-
        </Button>
-
        <div slot="popover" class="connect-popover">
-
          <ConnectInstructions />
-
        </div>
-
      </Popover>
-
    {:else}
-
      <NodeInfo node={$httpdStore.node} />
-
      <Authenticate />
+
    {#if $experimental}
+
      {#if $httpdStore.state === "stopped"}
+
        <Popover popoverPositionTop="3rem" popoverPositionRight="0">
+
          <Button
+
            slot="toggle"
+
            let:toggle
+
            on:click={toggle}
+
            title={buttonTitle[$httpdStore.state]}
+
            variant="naked-toggle">
+
            <IconSmall name="device" />
+
            Connect
+
          </Button>
+
          <div slot="popover" class="connect-popover">
+
            <ConnectInstructions />
+
          </div>
+
        </Popover>
+
      {:else}
+
        <NodeInfo node={$httpdStore.node} />
+
        <Authenticate />
+
      {/if}
    {/if}
  </div>
</header>
modified src/App/Settings.svelte
@@ -2,7 +2,9 @@
  import {
    codeFont,
    codeFonts,
+
    experimental,
    storeCodeFont,
+
    storeExperimental,
    storeTheme,
    theme,
  } from "@app/lib/appearance";
@@ -14,7 +16,7 @@

<style>
  .settings {
-
    width: 18.5rem;
+
    width: 24rem;
    display: flex;
    flex-direction: column;
    align-items: center;
@@ -76,4 +78,29 @@
      </Radio>
    </div>
  </div>
+
  <div class="item">
+
    <div
+
      style="display: flex; flex-direction: row; align-items: center; gap: 0.5rem;">
+
      Make changes on the web (experimental)
+
    </div>
+
    <div class="right">
+
      <Radio>
+
        <Button
+
          styleBorderRadius="0"
+
          on:click={() => storeExperimental(true)}
+
          variant={$experimental ? "selected" : "not-selected"}>
+
          On
+
        </Button>
+
        <div class="global-spacer" />
+
        <Radio>
+
          <Button
+
            styleBorderRadius="0"
+
            on:click={() => storeExperimental(undefined)}
+
            variant={$experimental ? "not-selected" : "selected"}>
+
            Off
+
          </Button>
+
        </Radio>
+
      </Radio>
+
    </div>
+
  </div>
</div>
modified src/components/ConnectInstructions.svelte
@@ -1,8 +1,11 @@
<script>
-
  import Command from "./Command.svelte";
+
  import { experimental } from "@app/lib/appearance";
  import { api, httpdStore } from "@app/lib/httpd";
  import { routeToPath, activeUnloadedRouteStore } from "@app/lib/router";

+
  import Command from "@app/components/Command.svelte";
+
  import ExternalLink from "./ExternalLink.svelte";
+

  $: path = routeToPath($activeUnloadedRouteStore);
  $: pathParam = path === "/" ? "" : `--path "${path}"`;
</script>
@@ -30,25 +33,41 @@
</style>

<div>
-
  {#if $httpdStore.state === "running"}
-
    <div class="label">Authenticate with your local node to make changes.</div>
-
    <Command
-
      fullWidth
-
      command={`rad web ${window.origin} --connect ${api.hostname}:${api.port} ${pathParam}`} />
+
  {#if $experimental}
+
    {#if $httpdStore.state === "running"}
+
      <div class="label">
+
        Authenticate with your local node to make changes.
+
      </div>
+
      <Command
+
        fullWidth
+
        command={`rad web ${window.origin} --connect ${api.hostname}:${api.port} ${pathParam}`} />
+
    {:else}
+
      <div class="heading">Connect & Authenticate</div>
+
      <div class="label">
+
        Connect to your local node to browse projects on your local machine,
+
        create issues, and participate in discussions.
+
      </div>
+
      <Command fullWidth command={`rad web ${window.origin} ${pathParam}`} />
+

+
      <div class="divider" />
+
      <div class="heading">New to Radicle?</div>
+
      <div class="label">
+
        Visit <ExternalLink href="https://radicle.xyz/#try" /> to download Radicle
+
        and get started.
+
      </div>
+
    {/if}
  {:else}
-
    <div class="heading">Connect & Authenticate</div>
+
    <div class="heading">Browse your local projects</div>
    <div class="label">
-
      Connect to your local node to browse projects on your local machine,
-
      create issues, and participate in discussions.
+
      To browse projects on your local node, run the following command.
    </div>
-
    <Command fullWidth command={`rad web ${window.origin} ${pathParam}`} />
+
    <Command fullWidth command="radicle-httpd" />

    <div class="divider" />
    <div class="heading">New to Radicle?</div>
    <div class="label">
-
      Run the following command and follow the instructions to install Radicle
+
      Visit <ExternalLink href="https://radicle.xyz/#try" /> to download Radicle
      and get started.
    </div>
-
    <Command fullWidth command="curl -sSf https://radicle.xyz/install | sh" />
  {/if}
</div>
modified src/components/Reactions.svelte
@@ -25,16 +25,23 @@

<div class="reactions">
  {#each reactions as { emoji, authors }}
-
    <IconButton
-
      on:click={async () => {
-
        if (handleReaction) {
-
          await handleReaction(authors, emoji);
-
        }
-
      }}>
-
      <div class="reaction txt-tiny">
+
    {#if handleReaction}
+
      <IconButton
+
        on:click={async () => {
+
          if (handleReaction) {
+
            await handleReaction(authors, emoji);
+
          }
+
        }}>
+
        <div class="reaction txt-tiny">
+
          <span>{emoji}</span>
+
          <span title={authors.join("\n")}>{authors.length}</span>
+
        </div>
+
      </IconButton>
+
    {:else}
+
      <div class="reaction txt-tiny" style="padding: 2px 4px;">
        <span>{emoji}</span>
        <span title={authors.join("\n")}>{authors.length}</span>
      </div>
-
    </IconButton>
+
    {/if}
  {/each}
</div>
deleted src/components/TransitionedHeight.svelte
@@ -1,114 +0,0 @@
-
<script lang="ts">
-
  import type { Tweened } from "svelte/motion";
-

-
  import { onMount } from "svelte";
-
  import { cubicInOut } from "svelte/easing";
-
  import { tweened } from "svelte/motion";
-

-
  // Force a height of 0, and optionally apply `negativeMarginWhileCollapsed`.
-
  export let collapsed = false;
-

-
  // If true, all content height changes are transitioned. If false, only
-
  // collapsing and expanding the content is transitioned.
-
  export let transitionHeightChanges = false;
-

-
  // Force a negative margin while collapsed. This is useful when you use
-
  // `transitionedHeight` in the context of some layout where there's further
-
  // content below.
-
  export let negativeMarginWhileCollapsed: string | undefined = undefined;
-

-
  let contentContainerElem: HTMLDivElement;
-
  let fitContent = !collapsed;
-

-
  let containerHeight: Tweened<number> | undefined;
-
  onMount(() => {
-
    if (collapsed) {
-
      containerHeight = tweened(0);
-
    } else {
-
      containerHeight = tweened(
-
        contentContainerElem.getBoundingClientRect().height,
-
      );
-
    }
-
  });
-

-
  let animating = false;
-
  let zeroHeight = collapsed;
-
  let previouslyCollapsed = collapsed;
-

-
  async function updateHeight() {
-
    if (!containerHeight) return;
-

-
    const newHeight = collapsed
-
      ? 0
-
      : contentContainerElem.getBoundingClientRect().height;
-

-
    const collapsedChanged = previouslyCollapsed !== collapsed;
-

-
    const shouldTransition = transitionHeightChanges || collapsedChanged;
-

-
    if (collapsed && !collapsedChanged) return;
-

-
    // Setting fitContent to false so that we can smoothly animate the
-
    // container height.
-
    if (shouldTransition) {
-
      fitContent = false;
-
      animating = true;
-
    }
-

-
    void containerHeight.set(
-
      newHeight,
-
      shouldTransition ? { duration: 300, easing: cubicInOut } : undefined,
-
    );
-

-
    if (shouldTransition && !collapsed) {
-
      setTimeout(() => {
-
        fitContent = true;
-
        animating = false;
-
      }, 300);
-
    }
-

-
    zeroHeight = newHeight === 0;
-

-
    previouslyCollapsed = collapsed;
-
  }
-
  $: {
-
    collapsed;
-
    void updateHeight();
-
  }
-

-
  let sizeObserver: ResizeObserver | undefined;
-
  onMount(() => {
-
    sizeObserver = new ResizeObserver(updateHeight);
-
    sizeObserver.observe(contentContainerElem);
-

-
    return () => sizeObserver?.disconnect();
-
  });
-

-
  $: heightStyleString = fitContent ? "fit-content" : `${$containerHeight}px`;
-
</script>
-

-
<style>
-
  .transitioned-height {
-
    width: 100%;
-
    transition: margin-bottom 0.3s;
-
    position: relative;
-
  }
-

-
  .animating,
-
  .zero-height {
-
    overflow: hidden;
-
  }
-
</style>
-

-
<div
-
  class="transitioned-height"
-
  class:animating
-
  class:zero-height={zeroHeight}
-
  style:margin-bottom={negativeMarginWhileCollapsed && zeroHeight === true
-
    ? negativeMarginWhileCollapsed
-
    : undefined}
-
  style:height={heightStyleString}>
-
  <div class="inner" bind:this={contentContainerElem}>
-
    <slot />
-
  </div>
-
</div>
modified src/lib/appearance.ts
@@ -48,3 +48,25 @@ export function storeCodeFont(newCodeFont: CodeFont): void {
  codeFont.set(newCodeFont);
  window.localStorage.setItem("codefont", newCodeFont);
}
+

+
export const experimental = writable<true | undefined>(
+
  loadExperimentalSetting(),
+
);
+

+
function loadExperimentalSetting(): true | undefined {
+
  const storedExperimental = window.localStorage.getItem("experimental");
+

+
  if (storedExperimental === null) {
+
    return undefined;
+
  } else {
+
    return storedExperimental === "true" ? true : undefined;
+
  }
+
}
+

+
export function storeExperimental(newSetting: true | undefined): void {
+
  experimental.set(newSetting);
+
  window.localStorage.setItem(
+
    "experimental",
+
    newSetting === true ? "true" : "undefined",
+
  );
+
}
modified src/lib/httpd.ts
@@ -6,6 +6,7 @@ import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";
import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
import { deduplicateStore } from "@app/lib/deduplicateStore";
+
import { experimental } from "./appearance";

export interface Session {
  id: string;
@@ -128,6 +129,15 @@ async function checkState() {
      try {
        const node = await api.getNode();

+
        // Return quickly and avoid additional fetches
+
        // if experimental settings aren't updated
+
        if (!get(experimental)) {
+
          update({
+
            state: "running",
+
            node,
+
          });
+
        }
+

        if (httpdState && httpdState.state !== "stopped") {
          httpdState.node = node;
        }
modified src/lib/roles.ts
@@ -1,4 +1,6 @@
import { parseNodeId } from "@app/lib/utils";
+
import { get } from "svelte/store";
+
import { experimental } from "./appearance";

export function isDelegate(
  publicKey: string | undefined,
@@ -27,10 +29,13 @@ function matchAuthor(
// All restricted actions are a combination of either:
// - the user is a delegate
// - the user is an author of the comment, issue, patch, etc.
+
//
+
// If the experimental setting isn't turned on, we return undefined early.
export function isDelegateOrAuthor(
  publicKey: string | undefined,
  delegates: string[],
  author: string,
) {
+
  if (get(experimental) === undefined) return undefined;
  return isDelegate(publicKey, delegates) || matchAuthor(publicKey, author);
}
modified src/views/home/Index.svelte
@@ -6,8 +6,9 @@
  import { literal, union } from "zod";

  import { api, httpdStore } from "@app/lib/httpd";
-
  import { deduplicateStore } from "@app/lib/deduplicateStore";
  import { baseUrlToString } from "@app/lib/utils";
+
  import { deduplicateStore } from "@app/lib/deduplicateStore";
+
  import { experimental } from "@app/lib/appearance";
  import { handleError } from "@app/views/home/error";
  import { isDelegate } from "@app/lib/roles";
  import { preferredSeeds } from "@app/lib/seeds";
@@ -127,54 +128,65 @@

<AppLayout>
  <div class="wrapper">
-
    <div class="global-hide-on-mobile">
-
      <HomepageSection
-
        loading={$httpdStore.state !== "stopped" && localProjects === undefined}
-
        empty={$httpdStore.state === "stopped" ||
-
          (filteredLocalProjects instanceof Array &&
-
            !filteredLocalProjects.length) ||
-
          localProjects instanceof Error}
-
        title="Local projects"
-
        subtitle="Projects you're seeding with your local node">
-
        <svelte:fragment slot="actions">
-
          <FilterButton disabled={!nodeId} bind:value={$localProjectsFilter} />
-
          <NewProjectButton disabled={!nodeId} />
-
        </svelte:fragment>
-
        <svelte:fragment slot="empty">
-
          <div class="empty-state">
-
            {#if !nodeId}
-
              <div style="text-align: left; width: 100%;">
-
                <ConnectInstructions />
-
              </div>
-
            {:else if localProjects instanceof Error}
-
              <ErrorMessage
-
                {...handleError(localProjects, baseUrlToString(api.baseUrl))} />
-
            {:else if !localProjects?.length}
-
              <div class="heading">No local projects</div>
-
              <div class="label">
-
                Seed or check out a project to work with it on your local node.
-
              </div>
-
            {:else}
-
              <div class="heading">Nothing to see here</div>
-
              <div class="label">
-
                No local projects matched your filter settings.
-
              </div>
+
    {#if $experimental}
+
      <div class="global-hide-on-mobile">
+
        <HomepageSection
+
          loading={$httpdStore.state !== "stopped" &&
+
            localProjects === undefined}
+
          empty={$httpdStore.state === "stopped" ||
+
            (filteredLocalProjects instanceof Array &&
+
              !filteredLocalProjects.length) ||
+
            localProjects instanceof Error}
+
          title="Local projects"
+
          subtitle="Projects you're seeding with your local node">
+
          <svelte:fragment slot="actions">
+
            <FilterButton
+
              disabled={!nodeId}
+
              bind:value={$localProjectsFilter} />
+
            <NewProjectButton disabled={!nodeId} />
+
          </svelte:fragment>
+
          <svelte:fragment slot="empty">
+
            <div class="empty-state">
+
              {#if !nodeId}
+
                <div style="text-align: left; width: 100%;">
+
                  <ConnectInstructions />
+
                </div>
+
              {:else if localProjects instanceof Error}
+
                <ErrorMessage
+
                  {...handleError(
+
                    localProjects,
+
                    baseUrlToString(api.baseUrl),
+
                  )} />
+
              {:else if !localProjects?.length}
+
                <div class="heading">No local projects</div>
+
                <div class="label">
+
                  Seed or check out a project to work with it on your local
+
                  node.
+
                </div>
+
              {:else}
+
                <div class="heading">Nothing to see here</div>
+
                <div class="label">
+
                  No local projects matched your filter settings.
+
                </div>
+
              {/if}
+
            </div>
+
          </svelte:fragment>
+
          <div class="project-grid">
+
            {#if filteredLocalProjects && !(filteredLocalProjects instanceof Error)}
+
              {#each filteredLocalProjects as projectInfo}
+
                <ProjectCard
+
                  {projectInfo}
+
                  isSeeding={true}
+
                  isDelegate={isDelegate(
+
                    nodeId,
+
                    projectInfo.project.delegates,
+
                  ) ?? false} />
+
              {/each}
            {/if}
          </div>
-
        </svelte:fragment>
-
        <div class="project-grid">
-
          {#if filteredLocalProjects && !(filteredLocalProjects instanceof Error)}
-
            {#each filteredLocalProjects as projectInfo}
-
              <ProjectCard
-
                {projectInfo}
-
                isSeeding={true}
-
                isDelegate={isDelegate(nodeId, projectInfo.project.delegates) ??
-
                  false} />
-
            {/each}
-
          {/if}
-
        </div>
-
      </HomepageSection>
-
    </div>
+
        </HomepageSection>
+
      </div>
+
    {/if}

    <HomepageSection
      loading={preferredSeedProjects === undefined}
modified src/views/home/components/HomepageSection.svelte
@@ -1,6 +1,5 @@
<script lang="ts">
  import Loading from "@app/components/Loading.svelte";
-
  import TransitionedHeight from "@app/components/TransitionedHeight.svelte";

  export let title: string;
  export let subtitle: string;
@@ -47,7 +46,6 @@

  .empty-container > .inner {
    max-width: 36rem;
-
    min-height: 14rem;
    display: flex;
    flex-direction: column;
    justify-content: center;
@@ -65,23 +63,21 @@
    </div>
  </div>

-
  <TransitionedHeight transitionHeightChanges>
-
    {#if loading}
-
      <div class="empty-container">
-
        <div class="inner">
-
          <Loading small />
-
        </div>
+
  {#if loading}
+
    <div class="empty-container">
+
      <div class="inner">
+
        <Loading small />
      </div>
-
    {:else if empty}
-
      <div class="empty-container">
-
        <div class="inner">
-
          <slot name="empty" />
-
        </div>
-
      </div>
-
    {:else}
-
      <div>
-
        <slot />
+
    </div>
+
  {:else if empty}
+
    <div class="empty-container">
+
      <div class="inner">
+
        <slot name="empty" />
      </div>
-
    {/if}
-
  </TransitionedHeight>
+
    </div>
+
  {:else}
+
    <div>
+
      <slot />
+
    </div>
+
  {/if}
</section>
modified src/views/projects/Header/SeedButton.svelte
@@ -1,8 +1,10 @@
<script lang="ts">
  import * as modal from "@app/lib/modal";
+
  import { experimental } from "@app/lib/appearance";
  import { httpdStore, api } from "@app/lib/httpd";

  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Popover from "@app/components/Popover.svelte";
@@ -70,63 +72,80 @@
  .counter {
    font-weight: var(--font-weight-regular);
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-secondary-counter);
+
    background-color: var(--color-fill-ghost-hover);
    border: 1px solid var(--color-border-secondary-counter);
-
    color: var(--color-foreground-match-background);
+
    color: var(--color-foreground-contrast);
    padding: 0 0.25rem;
  }
  .seeding {
    background-color: var(--color-fill-counter-emphasized);
    color: var(--color-foreground-emphasized);
  }
+
  .not-seeding {
+
    background-color: var(--color-fill-secondary-counter);
+
    color: var(--color-foreground-match-background);
+
  }
  .disabled {
-
    background-color: var(--color-fill-ghost-hover);
+
    background-color: var(--color-fill-float-hover);
    color: var(--color-foreground-disabled);
  }
</style>

<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
  <Button
-
    disabled={!canEditSeeding}
    slot="toggle"
+
    disabled={$experimental ? !canEditSeeding : false}
    let:toggle
    on:click={async () => {
-
      if (!seeding && canEditSeeding) {
+
      if ($experimental && !seeding && canEditSeeding) {
        await editSeeding();
      } else {
        toggle();
      }
    }}
-
    variant={seeding ? "secondary-toggle-on" : "secondary-toggle-off"}>
+
    variant={!$experimental
+
      ? "outline"
+
      : seeding
+
        ? "secondary-toggle-on"
+
        : "secondary-toggle-off"}>
    <IconSmall name="network" />
    <span class="title-counter">
      {seeding ? "Seeding" : "Seed"}
      <span
        class="counter"
-
        class:seeding
-
        class:disabled={!canEditSeeding}
+
        class:seeding={$experimental ? seeding : false}
+
        class:not-seeding={$experimental ? !seeding : false}
+
        class:disabled={$experimental ? !canEditSeeding : false}
        style:font-weight="var(--font-weight-regular)">
        {seedCount}
      </span>
    </span>
  </Button>

-
  <div slot="popover" let:toggle style:width={seeding ? "19.5rem" : "30.5rem"}>
-
    <div class="seed-label txt-bold">Stop seeding</div>
-
    <div class="seed-label">
-
      Are you sure you want to stop seeding this project? If you don't seed a
-
      project it won't appear in the local projects section anymore and any
-
      changes you make to it won't propagate to the network.
-
    </div>
-
    <Button
-
      styleWidth="100%"
-
      disabled={editSeedingInProgress}
-
      on:click={async () => {
-
        await editSeeding();
-
        toggle();
-
      }}>
-
      <IconSmall name="network" />
-
      Stop seeding
-
    </Button>
+
  <div
+
    slot="popover"
+
    style:width={$experimental ? (seeding ? "19.5rem" : "30.5rem") : "auto"}>
+
    {#if $experimental && canEditSeeding && seeding}
+
      <div class="seed-label txt-bold">Stop seeding</div>
+
      <div class="seed-label">
+
        Are you sure you want to stop seeding this project? If you don't seed a
+
        project it won't appear in the local projects section anymore and any
+
        changes you make to it won't propagate to the network.
+
      </div>
+
      <Button
+
        styleWidth="100%"
+
        disabled={editSeedingInProgress}
+
        on:click={async () => {
+
          await editSeeding();
+
        }}>
+
        <IconSmall name="network" />
+
        Stop seeding
+
      </Button>
+
    {:else}
+
      <span class="seed-label">
+
        Use the Radicle CLI to {seeding ? "stop" : "start"} seeding this project.
+
      </span>
+
      <Command command={`rad ${seeding ? "unseed" : "seed"} ${projectId}`} />
+
    {/if}
  </div>
</Popover>
modified src/views/projects/Issue.svelte
@@ -17,6 +17,7 @@
  import * as role from "@app/lib/roles";
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
+
  import { experimental } from "@app/lib/appearance";
  import { HttpdClient } from "@httpd-client";
  import { closeFocused } from "@app/components/Popover.svelte";
  import { httpdStore } from "@app/lib/httpd";
@@ -537,7 +538,7 @@
          {/if}
        </svelte:fragment>
        <div slot="description">
-
          {#if issueState !== "read"}
+
          {#if $experimental && issueState !== "read"}
            <ExtendedTextarea
              isValid={() => newTitle.length > 0}
              disallowEmptyBody
@@ -578,7 +579,7 @@
            <span class="txt-missing">No description</span>
          {/if}
          <div class="reactions">
-
            {#if session}
+
            {#if $experimental && session}
              <ReactionSelector
                reactions={issue.discussion[0].reactions}
                on:select={async ({ detail: { authors, emoji } }) => {
@@ -635,14 +636,20 @@
                  session?.publicKey,
                  project.delegates,
                )}
-
                editComment={session && partial(editComment, session.id)}
-
                createReply={session && partial(createReply, session.id)}
-
                reactOnComment={session && partial(reactOnComment, session)} />
+
                editComment={$experimental &&
+
                  session &&
+
                  partial(editComment, session.id)}
+
                createReply={$experimental &&
+
                  session &&
+
                  partial(createReply, session.id)}
+
                reactOnComment={$experimental &&
+
                  session &&
+
                  partial(reactOnComment, session)} />
              <div class="connector" />
            {/each}
          </div>
        {/if}
-
        {#if session}
+
        {#if $experimental && session}
          {#if threads.length === 0}
            <div class="connector" />{/if}
          <CommentToggleInput
modified src/views/projects/Issues.svelte
@@ -1,12 +1,13 @@
<script lang="ts">
  import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";

+
  import capitalize from "lodash/capitalize";
  import { HttpdClient } from "@httpd-client";
  import { ISSUES_PER_PAGE } from "./router";
+
  import { baseUrlToString, isLocal } from "@app/lib/utils";
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { experimental } from "@app/lib/appearance";
  import { httpdStore } from "@app/lib/httpd";
-
  import { baseUrlToString, isLocal } from "@app/lib/utils";
-
  import capitalize from "lodash/capitalize";

  import Button from "@app/components/Button.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
@@ -146,7 +147,7 @@

    <div style="margin-left: auto; display: flex; gap: 1rem;">
      <Share {baseUrl} />
-
      {#if $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
+
      {#if $experimental && $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
        <Link
          route={{
            resource: "project.newIssue",
modified src/views/projects/Patch.svelte
@@ -46,6 +46,7 @@
  import * as role from "@app/lib/roles";
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
+
  import { experimental } from "@app/lib/appearance";
  import capitalize from "lodash/capitalize";
  import isEqual from "lodash/isEqual";
  import partial from "lodash/partial";
@@ -767,7 +768,7 @@
        </svelte:fragment>
        <svelte:fragment slot="description">
          <div class="revision-description">
-
            {#if session && patchState !== "read" && lastEdit}
+
            {#if $experimental && session && patchState !== "read" && lastEdit}
              <ExtendedTextarea
                isValid={() => patch.title.length > 0}
                enableAttachments
@@ -802,7 +803,7 @@
            {:else}
              <span class="txt-missing">No description available</span>
            {/if}
-
            {#if session || (firstRevision.revisionReactions && firstRevision.revisionReactions.length > 0)}
+
            {#if ($experimental && session) || (firstRevision.revisionReactions && firstRevision.revisionReactions.length > 0)}
              <div class="actions">
                {#if session}
                  <ReactionSelector
@@ -979,15 +980,20 @@
                session?.publicKey,
                project.delegates,
              )}
-
              editRevision={session &&
+
              editRevision={$experimental &&
+
                session &&
                partial(editRevision, session.id, revision.revisionId)}
-
              editComment={session &&
+
              editComment={$experimental &&
+
                session &&
                partial(editComment, session.id, revision.revisionId)}
-
              reactOnComment={session &&
+
              reactOnComment={$experimental &&
+
                session &&
                partial(reactOnComment, session, revision.revisionId)}
-
              reactOnRevision={session &&
+
              reactOnRevision={$experimental &&
+
                session &&
                partial(reactOnRevision, session, revision.revisionId)}
-
              createReply={session &&
+
              createReply={$experimental &&
+
                session &&
                partial(createReply, session.id, revision.revisionId)}
              patchId={patch.id}
              patchState={patch.state}
@@ -995,7 +1001,7 @@
              previousRevId={previousRevision?.id}
              previousRevOid={previousRevision?.oid}>
              {#if index === patch.revisions.length - 1}
-
                {#if session && view.name === "activity"}
+
                {#if $experimental && session && view.name === "activity"}
                  <div class="connector" />
                  <CommentToggleInput
                    rawPath={rawPath(patch.revisions[0].id)}
modified src/views/projects/Patches.svelte
@@ -5,6 +5,7 @@
  import capitalize from "lodash/capitalize";

  import { PATCHES_PER_PAGE } from "./router";
+
  import { experimental } from "@app/lib/appearance";
  import { httpdStore } from "@app/lib/httpd";
  import { baseUrlToString, isLocal } from "@app/lib/utils";

@@ -160,7 +161,7 @@

    <div style="margin-left: auto; display: flex; gap: 1rem;">
      <Share {baseUrl} />
-
      {#if $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
+
      {#if $experimental && $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
        <Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
          <Button
            slot="toggle"
modified src/views/projects/Sidebar.svelte
@@ -2,6 +2,7 @@
  import type { ActiveTab } from "./Header.svelte";
  import type { BaseUrl, Project } from "@httpd-client";

+
  import { experimental } from "@app/lib/appearance";
  import { queryProject } from "@app/lib/projects";
  import { httpdStore, api } from "@app/lib/httpd";
  import { isLocal } from "@app/lib/utils";
@@ -36,7 +37,9 @@
  let queryingLocalProject: boolean = true;
  let localProject: "notFound" | "found" | undefined = undefined;
  $: hideContextHelp =
-
    isLocal(baseUrl.hostname) && $httpdStore.state === "authenticated";
+
    $experimental &&
+
    isLocal(baseUrl.hostname) &&
+
    $httpdStore.state === "authenticated";

  httpdStore.subscribe(async () => {
    if ($httpdStore.state !== "stopped" && !queryingLocalProject) {
@@ -65,7 +68,13 @@
    queryingLocalProject = false;
  }

-
  onMount(async () => await detectLocalProject());
+
  onMount(async () => {
+
    if ($httpdStore.state !== "stopped") {
+
      await detectLocalProject();
+
    } else {
+
      localProject = "notFound";
+
    }
+
  });
</script>

<style>
@@ -250,7 +259,7 @@
  </div>
  <div class="bottom">
    <div class="help" class:expanded>
-
      {#if !hideContextHelp && expanded}
+
      {#if !hideContextHelp && expanded && $experimental}
        {#if !localProject}
          <div
            style="display: flex; justify-content: center; align-items: center; height: 2rem;">
modified src/views/projects/Source/ProjectNameHeader.svelte
@@ -4,12 +4,12 @@
  import { twemoji } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
-
  import CloneButton from "../Header/CloneButton.svelte";
+
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import SeedButton from "../Header/SeedButton.svelte";
+
  import SeedButton from "@app/views/projects/Header/SeedButton.svelte";
  import Share from "@app/views/projects/Share.svelte";

  export let project: Project;
modified src/views/projects/router.ts
@@ -27,6 +27,8 @@ import { ResponseError } from "@httpd-client/lib/fetcher";
import { handleError } from "@app/views/projects/error";
import { nodePath } from "@app/views/nodes/router";
import { unreachable } from "@app/lib/utils";
+
import { get } from "svelte/store";
+
import { experimental } from "@app/lib/appearance";

export const COMMITS_PER_PAGE = 30;
export const PATCHES_PER_PAGE = 10;
@@ -242,6 +244,9 @@ function parseRevisionToOid(
}

async function isLocalNodeSeeding(route: ProjectRoute): Promise<boolean> {
+
  if (!get(experimental) && get(httpd.httpdStore).state === "stopped") {
+
    return false;
+
  }
  try {
    const tracking = await httpd.api.getTracking();
    return tracking.some(({ id }) => id === route.project);
modified tests/build/smoke.spec.ts
@@ -5,5 +5,5 @@ test("exceptions in production build", async ({ page }) => {
  // Wait for scripts to finish executing, there might be exceptions that
  // happen after the page has been painted.
  await page.waitForTimeout(2000);
-
  await expect(page.getByText("Local projects")).toBeVisible();
+
  await expect(page.getByText("Explore", { exact: true })).toBeVisible();
});
modified tests/e2e/landingPage.spec.ts
@@ -5,6 +5,7 @@ test.use({
});

test("show pinned projects", async ({ page }) => {
+
  await page.addInitScript(() => localStorage.setItem("experimental", "true"));
  await page.addInitScript(appConfigWithFixture);
  await page.goto("/");
  await expect(page.getByText("Local projects")).toBeVisible();
modified tests/support/fixtures.ts
@@ -151,6 +151,9 @@ export const test = base.extend<{
  },

  authenticatedPeer: async ({ page, peerManager }, use) => {
+
    await page.addInitScript(() => {
+
      window.localStorage.setItem("experimental", "true");
+
    });
    const peer = await peerManager.createPeer({
      name: "httpd",
      gitOptions: gitOptions["bob"],
@@ -161,6 +164,8 @@ export const test = base.extend<{
    const { stdout } = await peer.spawn("rad-web", [
      "http://localhost:3001",
      "--no-open",
+
      "--path",
+
      "/",
      "--connect",
      `${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
    ]);