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
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:
@@ -11,18 +11,16 @@ withDefaults(
|
||||
</script>
|
||||
|
||||
<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
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
class="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 focus-visible:ring-offset-2
|
||||
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'
|
||||
"
|
||||
class="btn disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="variant === 'primary' ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
|
||||
@@ -143,17 +143,20 @@ function readableSize(bytes: number): string {
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<!-- A plain anchor, never BaseButton and never a fetch: these are 3–95 MB
|
||||
and the browser's own download manager handles the transfer better
|
||||
than anything this app would do with a blob. `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. -->
|
||||
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 MB and
|
||||
the browser's own download manager handles the transfer better than
|
||||
anything this app would do with a blob. It wears `.btn` — the same
|
||||
definition BaseButton wears, so the two cannot drift.
|
||||
|
||||
`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
|
||||
:href="client.url"
|
||||
download
|
||||
class="btn-link shrink-0"
|
||||
:class="group.prominent ? 'btn-link-primary' : 'btn-link-ghost'"
|
||||
class="btn shrink-0"
|
||||
:class="group.prominent ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
|
||||
+18
-10
@@ -253,25 +253,33 @@ body {
|
||||
@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
|
||||
* 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.
|
||||
/* THE button shape — the ONE definition of it in the app.
|
||||
*
|
||||
* Declarations mirror BaseButton.vue exactly, minus its disabled: variants (an
|
||||
* anchor has no :disabled). The two are a pair: changing the look of one without
|
||||
* the other is how a page ends up with two kinds of primary button.
|
||||
* It lives here, in the components layer, rather than inside BaseButton.vue,
|
||||
* because not every button in this app is a <button>. A DOWNLOAD has to be an
|
||||
* 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
|
||||
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
|
||||
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;
|
||||
}
|
||||
.btn-link-ghost {
|
||||
.btn-ghost {
|
||||
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
|
||||
}
|
||||
.nav-link {
|
||||
|
||||
Reference in New Issue
Block a user