Put component classes in a cascade layer so utilities can override them

This commit is contained in:
Coracle-Bot 2026-09-23 00:03:45 +00:00
parent 298cab2e54
commit 36cb34613f
15 changed files with 57 additions and 53 deletions

View file

@ -129,6 +129,7 @@ callbacks and hot paths.
- Never hard-code the app's name. The brand is a build-time `VITE_PLATFORM_*` variable, so user-facing copy interpolates `PLATFORM_NAME` from `@app/env`, and `PLATFORM_URL`, `PLATFORM_LOGO`, `PLATFORM_ABOUT` for the rest of it. Each is set by the deployment, so don't write a `"Flotilla"` fallback behind one either. - Never hard-code the app's name. The brand is a build-time `VITE_PLATFORM_*` variable, so user-facing copy interpolates `PLATFORM_NAME` from `@app/env`, and `PLATFORM_URL`, `PLATFORM_LOGO`, `PLATFORM_ABOUT` for the rest of it. Each is set by the deployment, so don't write a `"Flotilla"` fallback behind one either.
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components - Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
- TailwindCSS styling with css components customized by theme. See lib/components for examples. - TailwindCSS styling with css components customized by theme. See lib/components for examples.
- A component class goes in `@layer components`, in its own file under `lib/components`. An unlayered rule beats a layered one whatever the specificity, so a class outside the layer can never be overridden by a utility in markup. The third-party overrides in `base.css` stay unlayered. The library defaults they beat are unlayered too.
- Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above. - Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above.
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect. - Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax. - When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.

View file

@ -77,7 +77,7 @@
</script> </script>
{#if isRoomOrRelay} {#if isRoomOrRelay}
<ContentLinkUrl {url} class="link-content whitespace-nowrap" /> <ContentLinkUrl {url} class="link-content" />
{:else if isAudio} {:else if isAudio}
<ContentLinkBlockAudio {url} {event} /> <ContentLinkBlockAudio {url} {event} />
{:else if isVideo} {:else if isVideo}
@ -130,6 +130,6 @@
</div> </div>
</Link> </Link>
{:catch} {:catch}
<ContentLinkUrl {url} class="link-content link-content--wrap" /> <ContentLinkUrl {url} class="link-content whitespace-normal wrap-anywhere" />
{/await} {/await}
{/if} {/if}

View file

@ -46,7 +46,7 @@
</script> </script>
{#if failed} {#if failed}
<a href={url} class="link-content link-content--wrap">{displayUrl(url)}</a> <a href={url} class="link-content whitespace-normal wrap-anywhere">{displayUrl(url)}</a>
{:else if source} {:else if source}
<audio controls src={source} preload="metadata" class="my-2 w-full max-w-xl" onerror={onError} <audio controls src={source} preload="metadata" class="my-2 w-full max-w-xl" onerror={onError}
></audio> ></audio>

View file

@ -71,7 +71,7 @@
</script> </script>
{#if hasError} {#if hasError}
<a href={url} class="link-content link-content--wrap"> <a href={url} class="link-content whitespace-normal wrap-anywhere">
<Icon icon={LinkRound} size={3} class="inline-block" /> <Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)} {displayUrl(url)}
</a> </a>

View file

@ -21,11 +21,11 @@
<!-- Use a real link so people can copy the href --> <!-- Use a real link so people can copy the href -->
<a <a
href={url} href={url}
class="link-content link-content--wrap" class="link-content whitespace-normal wrap-anywhere"
onclick={stopPropagation(preventDefault(expand))}> onclick={stopPropagation(preventDefault(expand))}>
<Icon icon={LinkRound} size={3} class="inline-block" /> <Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)} {displayUrl(url)}
</a> </a>
{:else} {:else}
<ContentLinkUrl {url} class="link-content link-content--wrap" /> <ContentLinkUrl {url} class="link-content whitespace-normal wrap-anywhere" />
{/if} {/if}

View file

@ -30,7 +30,7 @@
<ModalTitle>Unable to Zap</ModalTitle> <ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader> </ModalHeader>
<p> <p>
Zapping <ProfileLink {pubkey} class="text-primary!" /> isn't possible right now because Zapping <ProfileLink {pubkey} class="text-primary" /> isn't possible right now because
{#if $zapper} {#if $zapper}
their zap receiver isn't correctly set up. their zap receiver isn't correctly set up.
{:else} {:else}

View file

@ -18,8 +18,6 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
<Button <Button onclick={preventDefault(openProfile)} class={cx(props.class, {"link-content": !unstyled})}>
onclick={preventDefault(openProfile)}
class={cx(props.class, {"link-content bg-surface": !unstyled})}>
@<ProfileName {pubkey} {url} /> @<ProfileName {pubkey} {url} />
</Button> </Button>

View file

@ -16,6 +16,6 @@
const path = makeSpacePath(url, h) const path = makeSpacePath(url, h)
</script> </script>
<Link href={path} class={cx(props.class, {"link-content bg-surface": !unstyled})}> <Link href={path} class={cx(props.class, {"link-content": !unstyled})}>
#<RoomName {h} {url} /> #<RoomName {h} {url} />
</Link> </Link>

View file

@ -146,7 +146,7 @@
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Send a Zap</ModalTitle> <ModalTitle>Send a Zap</ModalTitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle> <ModalSubtitle>To <ProfileLink {pubkey} class="text-primary" /></ModalSubtitle>
</ModalHeader> </ModalHeader>
{#if invoice} {#if invoice}

View file

@ -189,7 +189,9 @@
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
Keyboard-open adjustments Keyboard-open adjustments — unlayered on purpose. Every element carrying
.hide-on-keyboard also carries display utilities (flex, md:hidden), and a
utility beats @layer components whatever the specificity.
---------------------------------------------------------------------------- */ ---------------------------------------------------------------------------- */
body.keyboard-open { body.keyboard-open {
@ -246,8 +248,14 @@ body.keyboard-open .room {
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
Integrations — rewired onto the shared tokens (unlayered so they win over Integrations — third-party class names rewired onto the shared tokens.
third-party defaults)
These stay unlayered. Every library here ships its own stylesheet outside any
cascade layer, and an unlayered rule beats a layered one whatever the
specificity, so a layered override would lose to the default it overrides.
@welshman/editor sets `.tiptap {min-height: 0}`, which beats a layered
`.note-editor .tiptap`. Our own classes go in @layer components, in the file
that owns them.
---------------------------------------------------------------------------- */ ---------------------------------------------------------------------------- */
/* tiptap editor */ /* tiptap editor */
@ -388,25 +396,6 @@ body.keyboard-open .room {
box-shadow: none; box-shadow: none;
} }
/* link-content (tiptap pill) */
.link-content {
@apply bg-surface-more text-content max-w-full overflow-hidden px-1 text-ellipsis whitespace-nowrap;
border-radius: 3px;
}
.link-content--wrap {
@apply whitespace-normal;
overflow-wrap: anywhere;
}
/* welshman/content */
.welshman-content a {
@apply text-primary cursor-pointer underline underline-offset-2;
}
.welshman-content-error a {
@apply underline;
}
/* date picker. @svelte-plugins/datepicker renders its own markup, so these class /* date picker. @svelte-plugins/datepicker renders its own markup, so these class
names appear nowhere in src/ — don't delete them on a grep. */ names appear nowhere in src/ — don't delete them on a grep. */
.picker { .picker {
@ -431,12 +420,6 @@ body.keyboard-open .room {
} }
/* tippy popover */ /* tippy popover */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
@apply pointer-events-auto;
}
.tippy-box { .tippy-box {
@apply rounded-3xl; @apply rounded-3xl;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
@ -471,13 +454,3 @@ emoji-picker {
overflow: visible; overflow: visible;
max-width: none; max-width: none;
} }
/* content width for fixed elements (sidebar offsets) */
.left-content {
left: var(--sail);
}
@media (min-width: 768px) {
.left-content {
left: calc(18.5rem + var(--sail));
}
}

View file

@ -1,9 +1,22 @@
@layer components { @layer components {
.link { /* .welshman-content wraps rendered note content, whose links are raw anchors */
.link,
.welshman-content a {
@apply text-primary cursor-pointer underline underline-offset-2; @apply text-primary cursor-pointer underline underline-offset-2;
&:hover { &:hover {
@apply text-primary-bold; @apply text-primary-bold;
} }
} }
/* an error toast colors its own text, so the link only needs the underline */
.welshman-content-error a {
@apply underline;
}
/* an inline pill for a link, mention or topic inside rendered content */
.link-content {
@apply bg-surface-more text-content max-w-full overflow-hidden px-1 text-ellipsis whitespace-nowrap;
border-radius: 3px;
}
} }

View file

@ -10,4 +10,13 @@
@apply p-2 sm:p-4; @apply p-2 sm:p-4;
} }
} }
/* left edge of the content column, for fixed elements the page cannot position */
.left-content {
left: var(--sail);
@media (min-width: 768px) {
left: calc(18.5rem + var(--sail));
}
}
} }

View file

@ -69,7 +69,8 @@
.room-pins__message { .room-pins__message {
@apply text-primary-content text-sm leading-relaxed; @apply text-primary-content text-sm leading-relaxed;
:where(.link-content, a, p, span, code) { /* a pill draws its own background, so it keeps its own color */
:where(a, p, span, code):not(.link-content) {
color: inherit; color: inherit;
} }
} }

View file

@ -2,7 +2,7 @@
Design-system entry point (imported once, in src/routes/+layout.svelte). Design-system entry point (imported once, in src/routes/+layout.svelte).
tailwindcss — framework + our @theme token registration (via base.css) tailwindcss — framework + our @theme token registration (via base.css)
base.css — theme-independent structure (components, utilities, base) base.css — theme-independent structure (base, utilities, integrations)
clay.css — clay theme token values ([data-fl-theme="clay"] + dark) clay.css — clay theme token values ([data-fl-theme="clay"] + dark)
flat.css — flat theme token values ([data-fl-theme="flat"] + dark) flat.css — flat theme token values ([data-fl-theme="flat"] + dark)
navy.css — navy theme token values ([data-fl-theme="navy"] + dark) navy.css — navy theme token values ([data-fl-theme="navy"] + dark)

View file

@ -41,4 +41,13 @@
.mobile .tip[data-tip]::before { .mobile .tip[data-tip]::before {
display: none !important; display: none !important;
} }
/* the host tippy renders its popovers into; see getTippyTarget in lib/html.ts */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
@apply pointer-events-auto;
}
} }