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.
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
- 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.
- 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.

View file

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

View file

@ -46,7 +46,7 @@
</script>
{#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}
<audio controls src={source} preload="metadata" class="my-2 w-full max-w-xl" onerror={onError}
></audio>

View file

@ -71,7 +71,7 @@
</script>
{#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" />
{displayUrl(url)}
</a>

View file

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

View file

@ -30,7 +30,7 @@
<ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader>
<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}
their zap receiver isn't correctly set up.
{:else}

View file

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

View file

@ -16,6 +16,6 @@
const path = makeSpacePath(url, h)
</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} />
</Link>

View file

@ -146,7 +146,7 @@
<ModalBody>
<ModalHeader>
<ModalTitle>Send a Zap</ModalTitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary" /></ModalSubtitle>
</ModalHeader>
{#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 {
@ -246,8 +248,14 @@ body.keyboard-open .room {
}
/* ----------------------------------------------------------------------------
Integrations — rewired onto the shared tokens (unlayered so they win over
third-party defaults)
Integrations — third-party class names rewired onto the shared tokens.
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 */
@ -388,25 +396,6 @@ body.keyboard-open .room {
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
names appear nowhere in src/ — don't delete them on a grep. */
.picker {
@ -431,12 +420,6 @@ body.keyboard-open .room {
}
/* tippy popover */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
@apply pointer-events-auto;
}
.tippy-box {
@apply rounded-3xl;
box-shadow: var(--shadow-lg);
@ -471,13 +454,3 @@ emoji-picker {
overflow: visible;
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 {
.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;
&:hover {
@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;
}
}
/* 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 {
@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;
}
}

View file

@ -2,7 +2,7 @@
Design-system entry point (imported once, in src/routes/+layout.svelte).
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)
flat.css — flat theme token values ([data-fl-theme="flat"] + dark)
navy.css — navy theme token values ([data-fl-theme="navy"] + dark)

View file

@ -41,4 +41,13 @@
.mobile .tip[data-tip]::before {
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;
}
}