buttons: one definition of the shape, worn by a <button> and by an <a>
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s

The download links added in fd1e4ae carried their own copy of BaseButton's
class list, because BaseButton is a <button> and cannot hold an href — and a
download must be an anchor, so the browser's own download manager gets the
3-95 MB transfer instead of a blob this app would have to hold in memory.

A copy is not a solution to that; it is two primary buttons that look alike
until someone changes one. So the shape moves to `.btn` + `.btn-primary` /
`.btn-ghost` in the components layer, where both elements can wear it, and
neither owns it.

The `disabled:` variants stay on BaseButton. An anchor has no :disabled, so
they were never shared and pretending otherwise would put a rule in the
shared definition that only one of its two users can ever match.

Verified there is exactly one shape to unify and no third copy: `px-4 py-2.5`
appears in three other files and all three are something else (a toast, a
dashed quick-add affordance, a retention notice). The smaller brand buttons in
AppShell and NoteEditor are a different size, which is a size-variant question
and not this one. And exactly one call site passes a class to BaseButton —
`shrink-0` — which cannot conflict with anything the shape declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
2026-08-31 08:08:01 -04:00
co-authored by Claude Opus 5
parent fd1e4ae487
commit ef418a8c92
3 changed files with 36 additions and 27 deletions
+7 -9
View File
@@ -11,18 +11,16 @@ withDefaults(
</script> </script>
<template> <template>
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A
download has to be an <a> (only an anchor can carry an href and hand the
transfer to the browser), so the shape has to live somewhere both elements
can wear it. The disabled: variants stay local an anchor has no
:disabled, so they are not shared and never were. -->
<button <button
:type="type" :type="type"
:disabled="disabled || loading" :disabled="disabled || loading"
class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition class="btn disabled:cursor-not-allowed disabled:opacity-60"
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 :class="variant === 'primary' ? 'btn-primary' : 'btn-ghost'"
focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950
disabled:cursor-not-allowed disabled:opacity-60"
:class="
variant === 'primary'
? 'bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700'
: 'text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800'
"
> >
<span <span
v-if="loading" v-if="loading"
+11 -8
View File
@@ -143,17 +143,20 @@ function readableSize(bytes: number): string {
> >
</p> </p>
</div> </div>
<!-- A plain anchor, never BaseButton and never a fetch: these are 395 MB <!-- An anchor, never BaseButton and never a fetch: these are 395 MB and
and the browser's own download manager handles the transfer better the browser's own download manager handles the transfer better than
than anything this app would do with a blob. `download` carries no anything this app would do with a blob. It wears `.btn` — the same
filename because the server already names the file in its definition BaseButton wears, so the two cannot drift.
Content-Disposition, which browsers prefer over this attribute
anyway — a value here would be inert and read as if it weren't. --> `download` carries no filename because the server already names the
file in its Content-Disposition, which browsers prefer over this
attribute anyway — a value here would be inert and read as if it
weren't. -->
<a <a
:href="client.url" :href="client.url"
download download
class="btn-link shrink-0" class="btn shrink-0"
:class="group.prominent ? 'btn-link-primary' : 'btn-link-ghost'" :class="group.prominent ? 'btn-primary' : 'btn-ghost'"
> >
Download Download
</a> </a>
+18 -10
View File
@@ -253,25 +253,33 @@ body {
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3; @apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
} }
} }
/* A DOWNLOAD is an anchor, never a button. These are 3-95 MB files, and the /* THE button shape — the ONE definition of it in the app.
* browser's own download manager handles the transfer better than anything this
* app would do with a blob - but only an <a> can carry an href, and BaseButton
* is a <button>. So the button SHAPE lives here where an anchor can wear it.
* *
* Declarations mirror BaseButton.vue exactly, minus its disabled: variants (an * It lives here, in the components layer, rather than inside BaseButton.vue,
* anchor has no :disabled). The two are a pair: changing the look of one without * because not every button in this app is a <button>. A DOWNLOAD has to be an
* the other is how a page ends up with two kinds of primary button. * anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
* browser's own download manager handles that transfer better than anything the
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
* second copy of its class list for anchors is how a page ends up with two
* kinds of primary button that drift apart.
*
* So: BaseButton.vue wears these, and so does any anchor that must read as a
* button. Neither owns the look.
*
* The `disabled:` variants are NOT here on purpose — an anchor has no
* :disabled. BaseButton adds them itself, which is exactly the split: shared
* where it is shared, local where the element differs.
*/ */
.btn-link { .btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950; dark:focus-visible:ring-offset-neutral-950;
} }
.btn-link-primary { .btn-primary {
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700; @apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
} }
.btn-link-ghost { .btn-ghost {
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800; @apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
} }
.nav-link { .nav-link {