Compare commits

...

1 Commits

Author SHA1 Message Date
Jon Staab 0e41680fff AI pass on redesign 2026-06-15 10:39:01 -07:00
45 changed files with 591 additions and 183 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

+203 -12
View File
@@ -2,10 +2,15 @@
@config "../tailwind.config.js"; @config "../tailwind.config.js";
@theme {
--font-sans: "Lato", ui-sans-serif, system-ui, sans-serif;
--font-display: "Baloo 2", "Lato", ui-rounded, system-ui, sans-serif;
}
/* root */ /* root */
:root { :root {
font-family: Lato; font-family: var(--font-sans);
--sait: var(--safe-area-inset-top, env(safe-area-inset-top)); --sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom)); --saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left)); --sail: var(--safe-area-inset-left, env(safe-area-inset-left));
@@ -153,15 +158,15 @@
} }
@utility heading { @utility heading {
@apply text-center text-2xl; @apply font-display text-center text-2xl font-bold tracking-tight;
} }
@utility subheading { @utility brand {
@apply text-center text-xl; @apply font-display text-primary font-bold tracking-tight;
} }
@utility superheading { @utility label {
@apply text-center text-4xl; @apply font-display text-sm font-semibold tracking-wider uppercase opacity-70;
} }
@utility link { @utility link {
@@ -215,8 +220,19 @@
@font-face { @font-face {
font-family: "Lato"; font-family: "Lato";
font-style: bold; font-style: normal;
font-weight: 600; font-weight: 300;
src:
local(""),
url("/fonts/Lato-Light.ttf") format("truetype");
}
/* Lato ships Regular + Bold only; map 600 (semibold) and 700 (bold) to the
Bold file so the browser never synthesizes a faux-bold. */
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 600 700;
src: src:
local(""), local(""),
url("/fonts/Lato-Bold.ttf") format("truetype"); url("/fonts/Lato-Bold.ttf") format("truetype");
@@ -228,13 +244,38 @@
font-weight: 400; font-weight: 400;
src: src:
local(""), local(""),
url("/fonts/Italic.ttf") format("truetype"); url("/fonts/Lato-Italic.ttf") format("truetype");
}
/* Baloo 2 — rounded, friendly display face (self-hosted, Latin subset). */
@font-face {
font-family: "Baloo 2";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/Baloo2-Medium.woff2") format("woff2");
}
@font-face {
font-family: "Baloo 2";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/Baloo2-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "Baloo 2";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/Baloo2-Bold.woff2") format("woff2");
} }
/* root */ /* root */
:root { :root {
font-family: Lato; font-family: var(--font-sans);
text-size-adjust: 100%; text-size-adjust: 100%;
--sait: var(--safe-area-inset-top, env(safe-area-inset-top)); --sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom)); --saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
@@ -284,7 +325,7 @@
opacity: 0.5; opacity: 0.5;
} }
/* tiptap */ /* editors */
.input-editor, .input-editor,
.chat-editor, .chat-editor,
@@ -323,7 +364,11 @@
} }
.chat-editor .tiptap { .chat-editor .tiptap {
@apply rounded-box bg-base-300 pr-12; @apply bg-base-300 rounded-[1.5rem] pr-12 transition-shadow;
}
.chat-editor:focus-within .tiptap {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary), transparent 55%);
} }
.note-editor .tiptap { .note-editor .tiptap {
@@ -448,3 +493,149 @@ body.keyboard-open .chat__compose {
.chat__scroll-down { .chat__scroll-down {
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16; @apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
} }
/* shape, depth & motion */
/* Accessibility: neutralize all motion when the user asks for it. Decorative
motion is otherwise opt-in via `motion-safe:` and the guards below. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Soft, diffuse elevation — replaces ad-hoc hard `shadow-md` uses. */
@utility shadow-soft {
box-shadow:
0 4px 16px -4px oklch(0% 0 0 / 0.18),
0 1px 3px oklch(0% 0 0 / 0.08);
}
/* Organic "hand-drawn" avatar masks. The image (or gradient fallback) fills
the blob; three variants are chosen deterministically by pubkey hash so a
person's shape stays stable across the app. */
@utility avatar-blob {
border-radius: 42% 58% 54% 46% / 58% 46% 54% 42%;
}
@utility avatar-blob-2 {
border-radius: 60% 40% 46% 54% / 43% 57% 43% 57%;
}
@utility avatar-blob-3 {
border-radius: 47% 53% 62% 38% / 50% 62% 38% 50%;
}
/* Friendly rounded-square for space / relay / room tiles. */
@utility squircle {
border-radius: 30%;
}
/* Every DaisyUI button speaks in the rounded display voice and presses in. */
.btn {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.01em;
}
@media (prefers-reduced-motion: no-preference) {
.btn {
transition:
transform 150ms ease,
box-shadow 150ms ease,
background-color 150ms ease,
border-color 150ms ease;
}
.btn:active {
transform: scale(0.96);
}
}
/* ---- Motion vocabulary ---- */
@keyframes nav-button-pop {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes button-pop {
0% {
transform: scale(0.97);
}
40% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
@keyframes pop {
0% {
transform: scale(0);
}
70% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes reaction-pop {
0% {
transform: scale(0.6);
}
60% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
@keyframes wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-4deg);
}
75% {
transform: rotate(4deg);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@utility animate-pop {
animation: pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@utility animate-reaction-pop {
animation: reaction-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@utility animate-float {
animation: float 6s ease-in-out infinite;
}
@utility animate-wiggle {
animation: wiggle 0.4s ease-in-out;
}
+1 -1
View File
@@ -127,7 +127,7 @@
</div> </div>
<Button <Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send" data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full" class="center tooltip tooltip-left bg-primary text-primary-content absolute top-[7px] right-[7px] h-11 w-11 min-w-11 scale-90 rounded-full transition-transform motion-safe:hover:scale-100"
disabled={$uploading || disabled} disabled={$uploading || disabled}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />
+7 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {hash, formatTimestampAsTime} from "@welshman/lib" import {formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app" import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
@@ -16,7 +16,7 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/theme" import {getColor} from "@app/theme"
import {makeDelete} from "@app/deletes" import {makeDelete} from "@app/deletes"
import {makeReaction} from "@app/reactions" import {makeReaction} from "@app/reactions"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@@ -35,7 +35,7 @@
const isOwn = event.pubkey === $pubkey const isOwn = event.pubkey === $pubkey
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id)) const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const colorValue = getColor(event.pubkey)
const reply = () => replyTo(event) const reply = () => replyTo(event)
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
@@ -101,7 +101,9 @@
{/if} {/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}> <div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<TapTarget <TapTarget
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]" class="chat-bubble shadow-soft mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px] {isOwn
? 'bg-primary text-primary-content'
: 'bg-base-100'}"
onTap={showMobileMenu}> onTap={showMobileMenu}>
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -109,7 +111,7 @@
<Button onclick={openProfile} class="flex items-center gap-1"> <Button onclick={openProfile} class="flex items-center gap-1">
<ProfileCircle <ProfileCircle
pubkey={event.pubkey} pubkey={event.pubkey}
class="border border-solid border-base-content" style="box-shadow: 0 0 0 1.5px {colorValue}"
size={4} /> size={4} />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}"> <Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
type Props = {
icon?: string
title: string
children?: Snippet
action?: Snippet
}
const {icon, title, children, action}: Props = $props()
</script>
<div
class="m-auto flex max-w-sm flex-col items-center gap-3 px-4 py-12 text-center"
in:fly={{y: 16}}>
{#if icon}
<div class="bg-primary/10 text-primary center size-16 rounded-full motion-safe:animate-float">
<Icon {icon} size={8} />
</div>
{/if}
<h3 class="font-display text-xl font-bold tracking-tight">{title}</h3>
{#if children}
<p class="text-sm opacity-70">{@render children?.()}</p>
{/if}
{#if action}
<div class="mt-1">{@render action?.()}</div>
{/if}
</div>
@@ -80,8 +80,9 @@
for={id} for={id}
aria-label="Drag and drop files here." aria-label="Drag and drop files here."
style="background-image: url({url});" style="background-image: url({url});"
class="relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid border-base-content bg-base-300 bg-cover bg-center transition-all" class="avatar-blob relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-primary/40 bg-base-300 bg-cover bg-center transition-all motion-safe:hover:rotate-1 motion-safe:hover:scale-[1.02]"
class:transparent={!url} class:transparent={!url}
class:border-solid={url || active}
class:border-primary={active} class:border-primary={active}
ondragenter={stopPropagation(preventDefault(onDragEnter))} ondragenter={stopPropagation(preventDefault(onDragEnter))}
ondragover={stopPropagation(preventDefault(onDragOver))} ondragover={stopPropagation(preventDefault(onDragOver))}
+10 -4
View File
@@ -9,7 +9,7 @@
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte" import SignUp from "@app/components/SignUp.svelte"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/env" import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME, PLATFORM_LOGO} from "@app/env"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const logIn = () => pushModal(LogIn) const logIn = () => pushModal(LogIn)
@@ -19,9 +19,15 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<div class="py-2"> <div class="flex flex-col items-center gap-3 py-2">
<h1 class="heading">Welcome to {PLATFORM_NAME}!</h1> <img
<p class="text-center">The chat app built for self-hosted communities.</p> src={PLATFORM_LOGO}
alt={PLATFORM_NAME}
class="shadow-soft ring-primary/20 size-16 rounded-2xl object-cover ring-4 motion-safe:animate-float" />
<h1 class="heading">Welcome to <span class="brand">{PLATFORM_NAME}</span>!</h1>
<p class="max-w-sm text-center opacity-80">
A cozy home for your community — chat, connect, and own your little corner of the internet.
</p>
</div> </div>
<Button onclick={logIn}> <Button onclick={logIn}>
<CardButton class="btn-primary"> <CardButton class="btn-primary">
+32 -8
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {removeUndefined} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import {deriveProfile} from "@welshman/app" import {deriveProfile, deriveProfileDisplay} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import {getColor, getBlobVariant} from "@app/theme"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = { type Props = {
@@ -10,15 +10,39 @@
class?: string class?: string
size?: number size?: number
url?: string url?: string
shape?: "blob" | "circle"
style?: string
} }
const {pubkey, url, size = 7, ...props}: Props = $props() const {pubkey, url, size = 7, shape = "blob", style = "", ...props}: Props = $props()
const profile = deriveProfile(pubkey, removeUndefined([url])) const profile = deriveProfile(pubkey, removeUndefined([url]))
const display = deriveProfileDisplay(pubkey)
// Organic, hand-drawn-feeling mask. The variant is stable per pubkey so a
// person's silhouette never changes; `shape="circle"` opts back into a disc.
const shapeClass =
shape === "circle"
? "rounded-full"
: ["avatar-blob", "avatar-blob-2", "avatar-blob-3"][getBlobVariant(pubkey) - 1]
const color = getColor(pubkey)
const px = $derived(size * 4)
const initial = $derived([...($display || "")].find(c => c.trim()) || "?")
</script> </script>
<ImageIcon {#if $profile?.picture}
{size} <ImageIcon {size} alt="" {style} class={cx(props.class, shapeClass)} src={$profile.picture} />
alt="" {:else}
class={cx(props.class, "rounded-full")} <!-- Fallback: a subtle gradient derived from the pubkey + the person's initial. -->
src={$profile?.picture || UserRounded} /> <div
class={cx(
props.class,
shapeClass,
"font-display flex shrink-0 items-center justify-center font-bold text-white uppercase select-none",
)}
style="width:{px}px;height:{px}px;font-size:{px *
0.45}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%));{style}">
{initial}
</div>
{/if}
+10 -7
View File
@@ -14,12 +14,14 @@
const {pubkeys, size = 7, limit, class: className}: Props = $props() const {pubkeys, size = 7, limit, class: className}: Props = $props()
const effectiveLimit = $derived(limit ?? (isMobile ? 7 : 10)) const effectiveLimit = $derived(limit ?? (isMobile ? 7 : 10))
// circle is one step smaller than box so the bg-base-100 wrapper reads as a
// thin separating ring between overlapping avatars (Discord-style stack).
const dimensions = $derived( const dimensions = $derived(
size <= 5 size <= 5
? {box: "h-5 w-5", overlap: "-mr-2", overflow: "text-[9px]"} ? {box: "h-5 w-5", circle: 4, overlap: "-mr-2", overflow: "text-[9px]"}
: size <= 6 : size <= 6
? {box: "h-6 w-6", overlap: "-mr-2.5", overflow: "text-[10px]"} ? {box: "h-6 w-6", circle: 5, overlap: "-mr-2.5", overflow: "text-[10px]"}
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"}, : {box: "h-8 w-8", circle: 7, overlap: "-mr-3", overflow: "text-xs"},
) )
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
@@ -37,20 +39,21 @@
</script> </script>
<div class={cx("flex", size <= 5 ? "pr-2" : "pr-3", className)}> <div class={cx("flex", size <= 5 ? "pr-2" : "pr-3", className)}>
{#each displayPubkeys as pubkey (pubkey)} {#each displayPubkeys as pubkey, i (pubkey)}
<div <div
class={cx( class={cx(
"z-feature inline-block flex items-center justify-center rounded-full bg-base-100", "z-feature inline-flex items-center justify-center rounded-full bg-base-100 transition-transform",
dimensions.box, dimensions.box,
dimensions.overlap, dimensions.overlap,
i % 2 === 0 ? "rotate-2" : "-rotate-2",
)}> )}>
<ProfileCircle class={cx(dimensions.box, "bg-base-300")} {pubkey} {size} /> <ProfileCircle class="bg-base-300" shape="circle" {pubkey} size={dimensions.circle} />
</div> </div>
{/each} {/each}
{#if overflowCount > 0} {#if overflowCount > 0}
<div <div
class={cx( class={cx(
"z-feature inline-flex items-center justify-center rounded-full bg-neutral font-medium text-neutral-content", "z-feature bg-primary text-primary-content shadow-soft font-display inline-flex rotate-2 items-center justify-center rounded-full font-bold",
dimensions.box, dimensions.box,
dimensions.overlap, dimensions.overlap,
dimensions.overflow, dimensions.overflow,
+8 -2
View File
@@ -2,8 +2,14 @@
const {current, total}: {current: number; total: number} = $props() const {current, total}: {current: number; total: number} = $props()
</script> </script>
<div class="flex w-full"> <div class="flex w-full gap-1.5">
{#each Array(total) as _, i} {#each Array(total) as _, i}
<div class="h-1 flex-1 transition-colors {i < current ? 'bg-primary' : 'bg-base-300'}"></div> <div
class="h-2 flex-1 rounded-full transition-colors duration-300 {i < current
? 'bg-primary'
: i === current
? 'bg-primary/40 motion-safe:animate-pulse'
: 'bg-base-300'}">
</div>
{/each} {/each}
</div> </div>
+8 -8
View File
@@ -157,15 +157,15 @@
data-tip={tooltip} data-tip={tooltip}
class={cx( class={cx(
reactionClass, reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt", "flex-inline btn btn-xs flex items-center gap-1 rounded-full border text-xs font-normal transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95",
{ {
tooltip: !noTooltip && !isMobile, tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn, "bg-alt border-base-content/15": !isOwn,
"btn-primary": isOwn, "border-primary/50 bg-primary/15 text-primary": isOwn,
}, },
)}> )}>
<Reaction event={zaps[0].request} /> <Reaction event={zaps[0].request} />
<span>{amount}</span> <span class="font-semibold">{amount}</span>
</button> </button>
{/each} {/each}
{#each groupedReactions.entries() as [key, events]} {#each groupedReactions.entries() as [key, events]}
@@ -179,17 +179,17 @@
data-tip={tooltip} data-tip={tooltip}
class={cx( class={cx(
reactionClass, reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt", "flex-inline btn btn-xs gap-1 rounded-full border font-normal transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95",
{ {
tooltip: !noTooltip && !isMobile, tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn, "bg-alt border-base-content/15": !isOwn,
"btn-primary": isOwn, "border-primary/50 bg-primary/15 text-primary": isOwn,
}, },
)} )}
onclick={stopPropagation(preventDefault(onClick))}> onclick={stopPropagation(preventDefault(onClick))}>
<Reaction event={events[0]} /> <Reaction event={events[0]} />
{#if events.length > 1} {#if events.length > 1}
<span>{events.length}</span> <span class="font-semibold">{events.length}</span>
{/if} {/if}
</button> </button>
{/each} {/each}
+8 -2
View File
@@ -10,6 +10,7 @@
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte" import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {getColor} from "@app/theme"
import {makeRoomPath, makeSpaceChatPath} from "@app/routes" import {makeRoomPath, makeSpaceChatPath} from "@app/routes"
type Props = { type Props = {
@@ -25,7 +26,9 @@
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url)) const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
</script> </script>
<Button class="cv card2 bg-alt shadow-md" onclick={onClick}> <Button
class="cv card2 bg-alt shadow-soft block w-full transition-all motion-safe:hover:-translate-y-0.5"
onclick={onClick}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
{#if h} {#if h}
@@ -39,7 +42,10 @@
</span> </span>
</div> </div>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<ProfileCircle pubkey={event.pubkey} size={10} /> <ProfileCircle
pubkey={event.pubkey}
size={10}
style="box-shadow: 0 0 0 2px {getColor(event.pubkey)}" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<NoteContentMinimal {event} /> <NoteContentMinimal {event} />
</div> </div>
+16 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {deriveRelay} from "@welshman/app" import {deriveRelay} from "@welshman/app"
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl" import {getColor} from "@app/theme"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = { type Props = {
@@ -12,10 +13,22 @@
const {url, size = 7, ...props}: Props = $props() const {url, size = 7, ...props}: Props = $props()
const relay = deriveRelay(url) const relay = deriveRelay(url)
const px = size * 4
const color = getColor(url)
const letter = (url.replace(/^wss?:\/\//, "").replace(/^www\./, "")[0] || "?").toUpperCase()
</script> </script>
{#if $relay?.icon} {#if $relay?.icon}
<ImageIcon {size} alt="" src={$relay?.icon} class={props.class} /> <ImageIcon {size} alt="" src={$relay.icon} class={cx(props.class, "squircle")} />
{:else} {:else}
<ImageIcon size={size - 2} alt="" src={RemoteControllerMinimalistic} class={props.class} /> <!-- Lettered workspace tile (Slack/Discord-style) colored by the relay url. -->
<div
class={cx(
props.class,
"squircle font-display flex shrink-0 items-center justify-center font-bold text-white uppercase",
)}
style="width:{px}px;height:{px}px;font-size:{px *
0.42}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%))">
{letter}
</div>
{/if} {/if}
+1 -1
View File
@@ -135,7 +135,7 @@
</div> </div>
<Button <Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send" data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full" class="center tooltip tooltip-left bg-primary text-primary-content absolute top-[7px] right-[7px] h-11 w-11 min-w-11 scale-90 rounded-full transition-transform motion-safe:hover:scale-100"
disabled={$uploading} disabled={$uploading}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />
+13 -4
View File
@@ -3,6 +3,7 @@
import Volume from "@assets/icons/volume.svg?dataurl" import Volume from "@assets/icons/volume.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import {getColor} from "@app/theme"
import {deriveRoom} from "@app/groups" import {deriveRoom} from "@app/groups"
interface Props { interface Props {
@@ -16,18 +17,26 @@
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit) const isVoiceRoom = $derived($room.livekit)
const px = size * 4
// Voice rooms read warm/orange; text rooms get a per-room identity color.
const color = $derived(isVoiceRoom ? "var(--color-secondary)" : getColor(h))
</script> </script>
{#if isVoiceRoom} {#if isVoiceRoom}
<div class="flex shrink-0 items-center gap-1.5"> <div class="flex shrink-0 items-center gap-1.5">
<Icon size={size + 1} icon={Volume} /> <Icon size={size + 1} icon={Volume} class="text-secondary" />
{#if $room.picture} {#if $room.picture}
<span class="text-base">/</span> <span class="text-base">/</span>
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" /> <ImageIcon src={$room.picture} {size} alt="" class="squircle shadow-sm" />
{/if} {/if}
</div> </div>
{:else if $room.picture} {:else if $room.picture}
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" /> <ImageIcon src={$room.picture} {size} alt="" class="squircle shadow-sm" />
{:else} {:else}
<Icon icon={fallbackIcon} {size} /> <!-- Colored room tile with the type glyph in white. -->
<div
class="squircle flex shrink-0 items-center justify-center text-white"
style="width:{px}px;height:{px}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%))">
<Icon icon={fallbackIcon} size={Math.max(3, size - 1)} />
</div>
{/if} {/if}
+7 -16
View File
@@ -1,14 +1,7 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import { import {gte, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
hash,
gte,
now,
displayList,
formatTimestampAsTime,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT, getTag} from "@welshman/util" import {MESSAGE, COMMENT, getTag} from "@welshman/util"
import { import {
@@ -35,7 +28,7 @@
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte" import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte" import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte" import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors} from "@app/theme" import {getColor} from "@app/theme"
import {ENABLE_ZAPS} from "@app/env" import {ENABLE_ZAPS} from "@app/env"
import {deriveEventsForUrl, deriveEvent} from "@app/repository" import {deriveEventsForUrl, deriveEvent} from "@app/repository"
import {publishDelete} from "@app/deletes" import {publishDelete} from "@app/deletes"
@@ -60,7 +53,7 @@
const today = formatTimestampAsDate(now()) const today = formatTimestampAsDate(now())
const profileDisplay = deriveProfileDisplay(event.pubkey, [url]) const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id)) const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const colorValue = getColor(event.pubkey)
const qTag = getTag("q", event.tags) const qTag = getTag("q", event.tags)
const isQuoteOnly = Boolean( const isQuoteOnly = Boolean(
@@ -95,10 +88,7 @@
<div class="flex w-full gap-3 overflow-auto"> <div class="flex w-full gap-3 overflow-auto">
{#if showPubkey} {#if showPubkey}
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0"> <Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
<ProfileCircle <ProfileCircle pubkey={event.pubkey} style="box-shadow: 0 0 0 2px {colorValue}" size={8} />
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button> </Button>
{:else} {:else}
<div class="w-8 shrink-0"></div> <div class="w-8 shrink-0"></div>
@@ -155,8 +145,9 @@
</div> </div>
{#if !isMobile} {#if !isMobile}
<button <button
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2" class="join bg-base-100 shadow-soft absolute right-2 top-0.5 translate-y-1 rounded-full p-0.5 text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}> class:group-hover:opacity-100={!isMobile}
class:group-hover:translate-y-0={!isMobile}>
{#if ENABLE_ZAPS} {#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} /> <RoomItemZapButton {url} {event} />
{/if} {/if}
+5 -3
View File
@@ -95,17 +95,19 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<h1 class="heading">Join {PLATFORM_NAME}</h1> <h1 class="heading">Join <span class="brand">{PLATFORM_NAME}</span></h1>
<p class="m-auto max-w-sm text-center"> <p class="m-auto max-w-sm text-center">
Censorship resistant digital spaces for communities. Meet new people, own your identity. Censorship resistant digital spaces for communities. Meet new people, own your identity.
</p> </p>
{#if hasPomade} {#if hasPomade}
<Button onclick={flows.email.start} class="btn btn-primary"> <Button onclick={flows.email.start} class="btn btn-primary rounded-full">
<Icon icon={Letter} /> <Icon icon={Letter} />
Sign up with email Sign up with email
</Button> </Button>
{/if} {/if}
<Button onclick={flows.nostr.start} class="btn {hasPomade ? 'btn-neutral' : 'btn-primary'}"> <Button
onclick={flows.nostr.start}
class="btn rounded-full {hasPomade ? 'btn-neutral' : 'btn-primary'}">
<Icon icon={Key} /> <Icon icon={Key} />
Generate a key Generate a key
</Button> </Button>
+7 -5
View File
@@ -2,12 +2,11 @@
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl" import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte" import ProgressBar from "@app/components/ProgressBar.svelte"
@@ -24,9 +23,12 @@
<Modal tag="form" onsubmit={preventDefault(next)}> <Modal tag="form" onsubmit={preventDefault(next)}>
<ModalBody> <ModalBody>
<ModalHeader> <div class="flex flex-col items-center gap-3">
<ModalTitle>You're all set!</ModalTitle> <div class="center bg-primary/15 text-primary size-16 rounded-full motion-safe:animate-pop">
</ModalHeader> <Icon icon={CheckCircle} size={9} />
</div>
<h1 class="heading">You're all set!</h1>
</div>
<p> <p>
You've created your profile, saved your keys, and now you're ready to start chatting — all You've created your profile, saved your keys, and now you're ready to start chatting — all
without asking permission! without asking permission!
+2 -2
View File
@@ -139,7 +139,7 @@
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100" class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}> onclick={openMenu}>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<strong class="flex items-center gap-1 relative"> <strong class="font-display relative flex items-center gap-1">
<RelayName {url} class="ellipsize" /> <RelayName {url} class="ellipsize" />
<div <div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0" class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
@@ -311,7 +311,7 @@
<div <div
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav"> class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
<VoiceWidget /> <VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}> <Button class="btn btn-ghost btn-sm bg-base-100 h-10 rounded-full" onclick={showDetail}>
<SocketStatusIndicator {url} /> <SocketStatusIndicator {url} />
</Button> </Button>
</div> </div>
+6 -1
View File
@@ -2,6 +2,8 @@
import type {AbstractThunk} from "@welshman/app" import type {AbstractThunk} from "@welshman/app"
import {thunkHasStatus, thunkIsComplete} from "@welshman/app" import {thunkHasStatus, thunkIsComplete} from "@welshman/app"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ThunkPending from "@app/components/ThunkPending.svelte" import ThunkPending from "@app/components/ThunkPending.svelte"
import type {Toast} from "@app/toast" import type {Toast} from "@app/toast"
import {popToast} from "@app/toast" import {popToast} from "@app/toast"
@@ -35,5 +37,8 @@
{#if !isComplete} {#if !isComplete}
<ThunkPending {thunk} /> <ThunkPending {thunk} />
{:else if !isFailure} {:else if !isFailure}
<p class="text-xs opacity-75">Message sent!</p> <p class="flex items-center gap-1.5 text-xs opacity-80">
<Icon icon={CheckCircle} size={4} class="text-success motion-safe:animate-pop" />
Message sent!
</p>
{/if} {/if}
+27 -16
View File
@@ -1,29 +1,40 @@
import twColors from "tailwindcss/colors" import twColors from "tailwindcss/colors"
import {hash} from "@welshman/lib"
import {kv} from "@app/storage" import {kv} from "@app/storage"
import {synced} from "@welshman/store" import {synced} from "@welshman/store"
// Per-identity color palette, biased toward warm, saturated, brand-adjacent
// hues so each person/space is recognizable at a glance. Deduped (was two
// `sky` entries) and trimmed of low-distinctness gray (`zinc`). The 600 weight
// reads clearly on both the warm-paper light base and the warm-charcoal dark
// base. Each entry is [name, hex].
export const colors = [ export const colors = [
["amber", twColors.amber[600]],
["blue", twColors.blue[600]],
["cyan", twColors.cyan[600]],
["emerald", twColors.emerald[600]],
["fuchsia", twColors.fuchsia[600]],
["green", twColors.green[600]],
["indigo", twColors.indigo[600]],
["sky", twColors.sky[600]],
["lime", twColors.lime[600]],
["orange", twColors.orange[600]],
["pink", twColors.pink[600]],
["purple", twColors.purple[600]], ["purple", twColors.purple[600]],
["red", twColors.red[600]],
["rose", twColors.rose[600]],
["sky", twColors.sky[600]],
["teal", twColors.teal[600]],
["violet", twColors.violet[600]], ["violet", twColors.violet[600]],
["indigo", twColors.indigo[600]],
["fuchsia", twColors.fuchsia[600]],
["pink", twColors.pink[600]],
["rose", twColors.rose[600]],
["red", twColors.red[600]],
["orange", twColors.orange[600]],
["amber", twColors.amber[600]],
["yellow", twColors.yellow[600]], ["yellow", twColors.yellow[600]],
["zinc", twColors.zinc[600]], ["lime", twColors.lime[600]],
["green", twColors.green[600]],
["emerald", twColors.emerald[600]],
["teal", twColors.teal[600]],
["cyan", twColors.cyan[600]],
["sky", twColors.sky[600]],
["blue", twColors.blue[600]],
] ]
// Single source of truth for per-pubkey identity color — reused by username
// text, avatar gradient fallbacks, and avatar rings. Deterministic per pubkey.
export const getColor = (pubkey = "") => colors[hash(pubkey) % colors.length][1]
// Which blob variant (1-3) a pubkey gets, so an avatar's organic shape is stable.
export const getBlobVariant = (pubkey = "") => (hash(pubkey) % 3) + 1
export const theme = synced({ export const theme = synced({
key: "theme", key: "theme",
defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
+3 -1
View File
@@ -17,7 +17,9 @@
"aria-pressed"?: boolean "aria-pressed"?: boolean
} = $props() } = $props()
const className = $derived(`text-left cursor-pointer ${restProps.class}`) const className = $derived(
`text-left cursor-pointer motion-safe:transition-transform motion-safe:duration-150 motion-safe:active:scale-[0.97] ${restProps.class}`,
)
const onClick = (e: Event) => { const onClick = (e: Event) => {
e.preventDefault() e.preventDefault()
+10 -6
View File
@@ -11,21 +11,25 @@
const {...props}: Props = $props() const {...props}: Props = $props()
</script> </script>
<div class="btn flex h-[unset] w-full flex-nowrap py-4 text-left {props.class}"> <div
<div class="flex grow flex-row items-start gap-4"> class="group btn rounded-box shadow-soft flex h-[unset] w-full flex-nowrap py-4 text-left transition-all motion-safe:hover:-translate-y-0.5 {props.class}">
<div class="flex h-14 w-12 shrink-0 items-center justify-center"> <div class="flex grow flex-row items-center gap-4">
<div class="bg-base-content/5 flex size-12 shrink-0 items-center justify-center rounded-2xl">
{@render props.icon?.()} {@render props.icon?.()}
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="text-bold text-lg"> <p class="text-lg font-bold">
{@render props.title?.()} {@render props.title?.()}
</p> </p>
<p class="text-sm"> <p class="text-sm font-normal opacity-70">
{@render props.info?.()} {@render props.info?.()}
</p> </p>
</div> </div>
</div> </div>
<div class="hidden h-14 w-14 items-center justify-end sm:flex"> <div class="hidden h-14 w-14 items-center justify-end sm:flex">
<Icon size={7} icon={AltArrowRight} /> <Icon
size={7}
icon={AltArrowRight}
class="transition-transform motion-safe:group-hover:translate-x-1" />
</div> </div>
</div> </div>
+4 -4
View File
@@ -28,10 +28,10 @@
const innerClass = $derived( const innerClass = $derived(
cx( cx(
"relative text-base-content text-base-content grow pointer-events-auto", "relative text-base-content grow pointer-events-auto",
"rounded-t-box sm:rounded-box", "rounded-t-box sm:rounded-box sm:rounded-[2rem] ring-1 ring-base-content/5",
{ {
"bg-alt shadow-m max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen, "bg-alt shadow-2xl max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen,
}, },
), ),
) )
@@ -48,7 +48,7 @@
<button <button
type="button" type="button"
aria-label="Close dialog" aria-label="Close dialog"
class="absolute inset-0 cursor-pointer bg-black opacity-50 dark:opacity-75" class="absolute inset-0 cursor-pointer bg-[oklch(12%_0.03_285)] opacity-50 backdrop-blur-sm dark:opacity-70"
transition:fade={{duration: 200}} transition:fade={{duration: 200}}
onclick={onClose}> onclick={onClose}>
</button> </button>
+12 -4
View File
@@ -8,10 +8,18 @@
const {children, ...props}: Props = $props() const {children, ...props}: Props = $props()
</script> </script>
<div class="flex items-center gap-2 p-2 text-xs uppercase opacity-50"> <div class="flex items-center gap-3 p-2 text-xs">
<div class="h-px grow bg-base-content opacity-25"></div>
{#if children} {#if children}
<p>{@render children?.()}</p> <div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
<div class="h-px grow bg-base-content opacity-25"></div> </div>
<p
class="font-display bg-base-100 shadow-soft rounded-full px-3 py-1 font-semibold tracking-wide uppercase opacity-80">
{@render children?.()}
</p>
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
</div>
{:else}
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
</div>
{/if} {/if}
</div> </div>
+1 -1
View File
@@ -16,7 +16,7 @@
<div class={cx("fixed bottom-20 mb-sai right-4 z-nav hide-on-keyboard md:hidden", className)}> <div class={cx("fixed bottom-20 mb-sai right-4 z-nav hide-on-keyboard md:hidden", className)}>
<Button <Button
class="btn btn-primary border-none shadow-xl hover:opacity-90 transition-all size-[50px] rounded-xl p-0" class="btn btn-primary size-14 rounded-full border-none p-0 shadow-[0_8px_24px_-6px_var(--color-primary)] transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95"
{onclick}> {onclick}>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
{@render children?.()} {@render children?.()}
+14 -2
View File
@@ -6,9 +6,16 @@
alt: string alt: string
size?: number size?: number
class?: string class?: string
style?: string
} }
const {src, alt, size = 5, ...props}: Props = $props() const {src, alt, size = 5, style = "", ...props}: Props = $props()
// Tailwind can't compile interpolated `h-{size}` classes, so size the box
// with an inline style (size * 4 == the Tailwind `h-{size}` rem scale).
const px = size * 4
let loaded = $state(false)
</script> </script>
{#if src.includes("image/svg") || src.endsWith(".svg")} {#if src.includes("image/svg") || src.endsWith(".svg")}
@@ -17,5 +24,10 @@
<img <img
{src} {src}
{alt} {alt}
class="h-{size} w-{size} min-w-{size} min-h-{size} aspect-square object-cover {props.class}" /> style="width:{px}px;height:{px}px;min-width:{px}px;min-height:{px}px;{style}"
class="aspect-square object-cover motion-safe:transition-opacity motion-safe:duration-300 {loaded
? 'opacity-100'
: 'opacity-0'} {props.class}"
onload={() => (loaded = true)}
onerror={() => (loaded = true)} />
{/if} {/if}
+1 -1
View File
@@ -9,6 +9,6 @@
<div <div
data-component="Page" data-component="Page"
class="relative grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 {props.class}"> class="relative grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 md:rounded-tl-2xl {props.class}">
{@render props.children?.()} {@render props.children?.()}
</div> </div>
+3 -1
View File
@@ -8,6 +8,8 @@
</script> </script>
<div class="column gap-4 py-12"> <div class="column gap-4 py-12">
<h1 class="superheading">{@render title?.()}</h1> <h1 class="font-display text-center text-4xl leading-tight font-bold tracking-tight">
{@render title?.()}
</h1>
<p class="text-center">{@render info?.()}</p> <p class="text-center">{@render info?.()}</p>
</div> </div>
+18 -5
View File
@@ -16,33 +16,46 @@
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus")) const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
const wrapperClass = $derived( const wrapperClass = $derived(
cx("relative h-14 w-14 p-1", { cx("group relative h-14 w-14 p-1", {
"tooltip tooltip-right": title, "tooltip tooltip-right": title,
}), }),
) )
const innerClass = $derived( const innerClass = $derived(
cx( cx(
"flex h-full w-full cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-base-300", "relative flex h-full w-full cursor-pointer items-center justify-center transition-all duration-200 hover:bg-base-300 motion-safe:hover:scale-105 motion-safe:hover:-rotate-3",
restProps.class, restProps.class,
{"bg-base-300 border border-solid border-base-content/20": active}, active
? "rounded-[42%] bg-base-300 ring-2 ring-primary/60 shadow-[0_0_14px_-3px_var(--color-primary)]"
: "rounded-2xl",
), ),
) )
</script> </script>
<div class={wrapperClass} data-tip={title}> <div class={wrapperClass} data-tip={title}>
<!-- Discord-style accent pill: tall when active, a nub on hover -->
<div
class={cx(
"pointer-events-none absolute top-1/2 left-0 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200",
active ? "h-8 opacity-100" : "h-2 opacity-0 group-hover:h-4 group-hover:opacity-60",
)}>
</div>
{#if onclick} {#if onclick}
<Button {onclick} class={innerClass}> <Button {onclick} class={innerClass}>
{@render children?.()} {@render children?.()}
{#if !active && notification} {#if !active && notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div> <div
class="bg-secondary ring-base-200 absolute top-1 right-1 h-2.5 w-2.5 rounded-full ring-2 motion-safe:animate-pulse">
</div>
{/if} {/if}
</Button> </Button>
{:else} {:else}
<a {href} class={innerClass}> <a {href} class={innerClass}>
{@render children?.()} {@render children?.()}
{#if !active && notification} {#if !active && notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div> <div
class="bg-secondary ring-base-200 absolute top-1 right-1 h-2.5 w-2.5 rounded-full ring-2 motion-safe:animate-pulse">
</div>
{/if} {/if}
</a> </a>
{/if} {/if}
+1 -1
View File
@@ -13,7 +13,7 @@
<div <div
class={cx( class={cx(
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav", "mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav md:shadow-[6px_0_24px_-12px_rgba(0,0,0,0.35)]",
visible ? "flex" : "hidden md:flex", visible ? "flex" : "hidden md:flex",
props.class, props.class,
)}> )}>
+1 -1
View File
@@ -6,6 +6,6 @@
const {children}: Props = $props() const {children}: Props = $props()
</script> </script>
<div class="flex items-center justify-between px-1 py-2 text-sm font-bold uppercase"> <div class="label flex items-center justify-between px-1 py-2">
{@render children?.()} {@render children?.()}
</div> </div>
+13 -6
View File
@@ -36,11 +36,15 @@
const active = $derived($page.url.pathname === href) const active = $derived($page.url.pathname === href)
const wrapperClass = $derived( const wrapperClass = $derived(
cx(restProps.class, "relative flex shrink-0 items-center gap-3 text-left transition-all", { cx(
"hover:bg-base-100 hover:text-base-content": true, restProps.class,
"text-base-content bg-base-100": active, "group relative flex shrink-0 items-center gap-3 rounded-xl text-left transition-all",
{
"hover:bg-base-100": true,
"bg-primary/15 text-primary font-semibold": active,
"tooltip tooltip-right": title, "tooltip tooltip-right": title,
}), },
),
) )
</script> </script>
@@ -51,16 +55,19 @@
data-tip={title} data-tip={title}
data-sveltekit-replacestate={replaceState} data-sveltekit-replacestate={replaceState}
class={wrapperClass}> class={wrapperClass}>
{#if active}
<div class="bg-primary absolute top-1/2 left-0 h-5 w-1 -translate-y-1/2 rounded-r-full"></div>
{/if}
{@render children?.()} {@render children?.()}
{#if notification} {#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade> <div class="bg-secondary absolute top-5 right-[1.15rem] h-2 w-2 rounded-full" transition:fade>
</div> </div>
{/if} {/if}
</a> </a>
{:else} {:else}
<button {...restProps} data-tip={title} class={wrapperClass}> <button {...restProps} data-tip={title} class={wrapperClass}>
{#if notification} {#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade> <div class="bg-secondary absolute top-5 right-[1.15rem] h-2 w-2 rounded-full" transition:fade>
</div> </div>
{/if} {/if}
{@render children?.()} {@render children?.()}
+2 -1
View File
@@ -12,7 +12,8 @@
<span class="flex min-h-10 items-center"> <span class="flex min-h-10 items-center">
{#if loading} {#if loading}
<span class="pr-3" transition:slide|local={{axis: "x"}}> <span class="pr-3" transition:slide|local={{axis: "x"}}>
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span> <span class="loading loading-dots text-primary" transition:fade|local={{duration: 100}}
></span>
</span> </span>
{/if} {/if}
{@render children?.()} {@render children?.()}
+9 -3
View File
@@ -1,12 +1,18 @@
// @ts-nocheck // @ts-nocheck
import {cubicOut} from "svelte/easing" import {cubicOut, backOut} from "svelte/easing"
import type {FlyParams} from "svelte/transition" import type {FlyParams} from "svelte/transition"
import {fly as baseFly} from "svelte/transition" import {fly as baseFly} from "svelte/transition"
export {fade, slide} from "svelte/transition" export {fade, slide, scale} from "svelte/transition"
// A short, gently-springy fly is the app's default element entrance.
export const fly = (node: Element, params?: FlyParams | undefined) => export const fly = (node: Element, params?: FlyParams | undefined) =>
baseFly(node, {y: 20, ...params}) baseFly(node, {y: 12, duration: 250, easing: backOut, ...params})
// Staggered variant for list items — pass {i} (the index) to cascade the
// entrance. Delay is capped so long/virtualized lists never feel sluggish.
export const flyStagger = (node: Element, {i = 0, ...params}: any = {}) =>
baseFly(node, {y: 12, duration: 250, easing: backOut, delay: Math.min(i * 40, 240), ...params})
export type TranslateParams = { export type TranslateParams = {
delay?: number delay?: number
+19 -7
View File
@@ -8,7 +8,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import {goToSpace} from "@app/routes" import {goToSpace} from "@app/routes"
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/env" import {PLATFORM_NAME, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
const openChat = () => goto("/chat") const openChat = () => goto("/chat")
@@ -19,16 +19,28 @@
}) })
</script> </script>
<div class="hero min-h-screen overflow-auto pb-8"> <div class="hero relative min-h-screen overflow-auto pb-8">
<div class="hero-content"> <!-- Soft brand-color blobs for a warm, playful backdrop -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="bg-primary/10 absolute top-10 -left-20 size-72 rounded-full blur-3xl"></div>
<div class="bg-secondary/10 absolute right-[-4rem] bottom-10 size-72 rounded-full blur-3xl">
</div>
</div>
<div class="hero-content relative">
<div class="column content gap-4"> <div class="column content gap-4">
<h1 class="text-center text-5xl">Welcome to</h1> <div class="mb-2 flex flex-col items-center gap-3">
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1> <img
src={PLATFORM_LOGO}
alt={PLATFORM_NAME}
class="shadow-soft ring-primary/20 size-20 rounded-3xl object-cover ring-4 motion-safe:animate-float" />
<h1 class="font-display text-2xl font-semibold opacity-60">Welcome to</h1>
<h1 class="brand text-6xl">{PLATFORM_NAME}</h1>
</div>
<div class="col-3"> <div class="col-3">
<Link href="/spaces"> <Link href="/spaces">
<CardButton class="btn-neutral"> <CardButton class="btn-neutral">
{#snippet icon()} {#snippet icon()}
<Icon icon={AddCircle} size={7} /> <Icon icon={AddCircle} size={7} class="text-primary" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<div>Add a space</div> <div>Add a space</div>
@@ -41,7 +53,7 @@
<Button onclick={openChat}> <Button onclick={openChat}>
<CardButton class="btn-neutral"> <CardButton class="btn-neutral">
{#snippet icon()} {#snippet icon()}
<Icon icon={ChatRound} size={7} /> <Icon icon={ChatRound} size={7} class="text-secondary" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<div>Start a conversation</div> <div>Start a conversation</div>
+3 -1
View File
@@ -303,7 +303,9 @@
</div> </div>
{/each} {/each}
{:else if !term} {:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p> <p class="py-10 text-center opacity-70">
You haven't joined any spaces yet — pick one below to dive in!
</p>
{/if} {/if}
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider> <Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
{#each otherSpaces.slice(0, limit) as relay (relay.url)} {#each otherSpaces.slice(0, limit) as relay (relay.url)}
@@ -17,6 +17,7 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte" import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte" import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import EmptyState from "@app/components/EmptyState.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
@@ -147,7 +148,9 @@
<Spinner {loading}>Looking for events...</Spinner> <Spinner {loading}>Looking for events...</Spinner>
</p> </p>
{:else if items.length === 0} {:else if items.length === 0}
<p class="flex h-10 items-center justify-center py-20" transition:fly>No events found.</p> <EmptyState icon={CalendarMinimalistic} title="No events yet">
Planning a meetup or call? Add an event so everyone knows when to show up.
</EmptyState>
{:else} {:else}
<p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p> <p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p>
{/if} {/if}
+10 -9
View File
@@ -16,6 +16,7 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import GoalItem from "@app/components/GoalItem.svelte" import GoalItem from "@app/components/GoalItem.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte" import GoalCreate from "@app/components/GoalCreate.svelte"
import EmptyState from "@app/components/EmptyState.svelte"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
import {makeFeed} from "@app/feeds" import {makeFeed} from "@app/feeds"
@@ -83,15 +84,15 @@
<GoalItem {url} event={$state.snapshot(event)} /> <GoalItem {url} event={$state.snapshot(event)} />
</div> </div>
{/each} {/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading} {#if loading}
Looking for goals... <p class="flex h-10 items-center justify-center py-20">
{:else if items.length === 0} <Spinner loading>Looking for goals...</Spinner>
No goals found.
{:else}
That's all!
{/if}
</Spinner>
</p> </p>
{:else if items.length === 0}
<EmptyState icon={StarFallMinimalistic} title="No goals yet">
Rallying the community around something? Set a goal and watch the support roll in.
</EmptyState>
{:else}
<p class="flex items-center justify-center py-10 text-sm opacity-50">That's all!</p>
{/if}
</PageContent> </PageContent>
+10 -9
View File
@@ -16,6 +16,7 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import PollItem from "@app/components/PollItem.svelte" import PollItem from "@app/components/PollItem.svelte"
import PollCreate from "@app/components/PollCreate.svelte" import PollCreate from "@app/components/PollCreate.svelte"
import EmptyState from "@app/components/EmptyState.svelte"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
import {makeFeed} from "@app/feeds" import {makeFeed} from "@app/feeds"
@@ -83,15 +84,15 @@
<PollItem {url} event={$state.snapshot(event)} /> <PollItem {url} event={$state.snapshot(event)} />
</div> </div>
{/each} {/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading} {#if loading}
Looking for polls... <p class="flex h-10 items-center justify-center py-20">
{:else if items.length === 0} <Spinner loading>Looking for polls...</Spinner>
No polls found.
{:else}
That's all!
{/if}
</Spinner>
</p> </p>
{:else if items.length === 0}
<EmptyState icon={PollIcon} title="No polls yet">
Want to take the room's temperature? Create the first poll and let people weigh in.
</EmptyState>
{:else}
<p class="flex items-center justify-center py-10 text-sm opacity-50">That's all!</p>
{/if}
</PageContent> </PageContent>
+10 -9
View File
@@ -16,6 +16,7 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte" import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte"
import EmptyState from "@app/components/EmptyState.svelte"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
import {makeFeed} from "@app/feeds" import {makeFeed} from "@app/feeds"
@@ -83,15 +84,15 @@
<ThreadItem {url} event={$state.snapshot(event)} /> <ThreadItem {url} event={$state.snapshot(event)} />
</div> </div>
{/each} {/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading} {#if loading}
Looking for threads... <p class="flex h-10 items-center justify-center py-20">
{:else if items.length === 0} <Spinner loading>Looking for threads...</Spinner>
No threads found.
{:else}
That's all!
{/if}
</Spinner>
</p> </p>
{:else if items.length === 0}
<EmptyState icon={NotesMinimalistic} title="No threads yet">
Threads keep longer conversations tidy and easy to follow. Be the first to start one!
</EmptyState>
{:else}
<p class="flex items-center justify-center py-10 text-sm opacity-50">That's all!</p>
{/if}
</PageContent> </PageContent>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32 -2
View File
@@ -33,22 +33,52 @@ export default {
daisyTheme({ daisyTheme({
name: "dark", name: "dark",
...themes["night"], ...themes["night"],
"--color-base-content": "oklch(75% 0.029 256.847)", // Warm charcoal ramp with a wide (~8.5%) lightness spread so stacked
// surfaces (rail / panel / page / card / bubble) read as layered paper
// instead of one flat slab. Hue nudged from cold 265 toward brand 280.
"--color-base-100": "oklch(24% 0.025 280)",
"--color-base-200": "oklch(20% 0.024 280)",
"--color-base-300": "oklch(15.5% 0.022 280)",
"--color-base-content": "oklch(89% 0.02 280)",
"--color-primary": process.env.VITE_PLATFORM_ACCENT, "--color-primary": process.env.VITE_PLATFORM_ACCENT,
"--color-primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF", "--color-primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
"--color-secondary": process.env.VITE_PLATFORM_SECONDARY, "--color-secondary": process.env.VITE_PLATFORM_SECONDARY,
"--color-secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF", "--color-secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF",
// Amber completes the purple -> orange -> amber brand triad (was off-brand pink).
"--color-accent": "#FDB833",
"--color-accent-content": "oklch(22% 0.04 70)",
// Shape + depth: rounder geometry, friendlier borders, and DaisyUI's built-in
// soft top-highlight/bottom-shadow on btn/card/input/badge.
"--depth": "1",
"--radius-box": "1.25rem",
"--radius-field": "0.75rem",
"--radius-selector": "1rem",
"--border": "1.5px",
}), }),
daisyTheme({ daisyTheme({
name: "light", name: "light",
...themes["winter"], ...themes["winter"],
"--color-neutral": "#F2F7FF", // Warm paper ramp (hue ~70) replaces winter's clinical blue-white, with a
// gentle layering step and a warm near-ink text color carrying a faint
// purple undertone so type feels tied to the brand.
"--color-base-100": "oklch(99.5% 0.006 70)",
"--color-base-200": "oklch(97% 0.012 70)",
"--color-base-300": "oklch(93.5% 0.016 75)",
"--color-base-content": "oklch(32% 0.03 285)",
"--color-neutral": "oklch(96% 0.01 70)",
"--color-neutral-content": "var(--color-base-content)", "--color-neutral-content": "var(--color-base-content)",
"--color-warning": "#FD8D0B", "--color-warning": "#FD8D0B",
"--color-primary": process.env.VITE_PLATFORM_ACCENT, "--color-primary": process.env.VITE_PLATFORM_ACCENT,
"--color-primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF", "--color-primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
"--color-secondary": process.env.VITE_PLATFORM_SECONDARY, "--color-secondary": process.env.VITE_PLATFORM_SECONDARY,
"--color-secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF", "--color-secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF",
"--color-accent": "#FDB833",
"--color-accent-content": "oklch(30% 0.05 70)",
"--depth": "1",
"--radius-box": "1.25rem",
"--radius-field": "0.75rem",
"--radius-selector": "1rem",
"--border": "1.5px",
}), }),
], ],
} }