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>
|
</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"
|
||||||
|
|||||||
@@ -143,17 +143,20 @@ function readableSize(bytes: number): string {
|
|||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- A plain anchor, never BaseButton and never a fetch: these are 3–95 MB
|
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user