Buttons: one definition, aligned to the design system — plus local prior-art recall #94

Merged
bvandeusen merged 12 commits from dev into main 2026-08-02 18:50:04 -04:00
9 changed files with 144 additions and 207 deletions
Showing only changes of commit 1a959b1db0 - Show all commits
+114
View File
@@ -0,0 +1,114 @@
/* Shared component styles — the house recipes, in one place.
* ===========================================================================
*
* WHY THIS FILE EXISTS
*
* `.btn-primary` was defined five times, in five scoped stylesheets, and all
* five had drifted: three paddings, three font sizes, three disabled opacities,
* and one view with no disabled style at all (#2273). Nothing detected that,
* because a scoped duplicate is invisible to every tool — it isn't a rule
* violation, isn't a broken reference, and isn't a recorded snippet.
*
* GEOMETRY LIVES HERE; the class names are the app's existing ones so no
* template changes. Every value is a design-system token, so a palette or scale
* change moves the buttons rather than stranding a copy that no longer matches.
*
* MIGRATION NOTE — this file is deliberately safe to land ahead of the removals.
* These are plain selectors (specificity 0,1,0); a Vue `<style scoped>` rule
* compiles to `.btn-primary[data-v-…]` (0,2,0) and therefore WINS. So a view
* still carrying its own copy is unaffected until that copy is deleted, and
* every intermediate state of the migration is coherent.
*/
/* --- the shared shape ---------------------------------------------------- */
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-danger {
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
border: none;
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
font-family: var(--fs-font-body);
font-size: var(--fs-size-label); /* 12px */
font-weight: var(--fs-weight-medium); /* 500 — the heaviest the system goes */
line-height: var(--fs-leading-body);
white-space: nowrap;
cursor: pointer;
transition: background var(--fs-dur-fast) var(--fs-ease),
border-color var(--fs-dur-fast) var(--fs-ease),
color var(--fs-dur-fast) var(--fs-ease);
}
/* One rule, so a disabled button can never look enabled in one view and
* disabled in another — which is exactly what ProjectListView shipped, having
* no disabled style at all. */
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
.btn-primary:focus-visible,
.btn-secondary:focus-visible,
.btn-ghost:focus-visible,
.btn-danger:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
/* --- variants ------------------------------------------------------------ */
/* The accent is deliberately ABSENT from every filled variant. Action colours
* are universal across the family so a Save button looks identical in every
* app — the accent is identity, not action. */
.btn-primary {
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.btn-primary:not(:disabled):hover {
background: var(--color-action-primary-hover);
}
.btn-secondary {
background: var(--color-action-secondary);
color: var(--fs-text-on-action);
}
.btn-secondary:not(:disabled):hover {
background: var(--color-action-secondary-hover);
}
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
* are the same token rather than two values that happen to match. Hover moves
* the BORDER, not the text to the accent — SnippetDetailView tinted the label
* with the accent on hover, which the house style reserves for identity and
* active state, not for general chrome. */
.btn-ghost {
background: none;
border: var(--fs-border);
color: var(--color-text);
}
.btn-ghost:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--color-hover);
}
/* Destructive is NOT the error colour: an error is a failure that happened, a
* destructive action is one about to happen. Pair with an icon. */
.btn-danger {
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
.btn-danger:not(:disabled):hover {
background: var(--color-action-destructive-hover);
}
/* --- size modifiers ------------------------------------------------------ */
.btn-small,
.btn-sm {
padding: var(--fs-space-1) var(--fs-space-3); /* 4px 12px */
font-size: var(--fs-size-tiny);
}
+2
View File
@@ -3,6 +3,8 @@ import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import "./assets/theme.css"; import "./assets/theme.css";
// After theme.css — it consumes the tokens declared there.
import "./assets/components.css";
import "./assets/prose.css"; import "./assets/prose.css";
const app = createApp(App); const app = createApp(App);
-41
View File
@@ -1262,47 +1262,6 @@ textarea.input {
/* Buttons ---------------------------------------------------------------- */ /* Buttons ---------------------------------------------------------------- */
.btn-primary,
.btn-ghost,
.btn-danger {
border-radius: var(--radius-sm);
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.btn-primary:not(:disabled):hover {
background: var(--color-action-primary-hover);
}
.btn-ghost {
background: none;
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.btn-danger {
background: var(--color-action-destructive);
color: #E8E4D8;
}
.btn-small {
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
}
.btn-primary:disabled,
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-actions { .row-actions {
+28 -17
View File
@@ -12,11 +12,12 @@
* every specimen below is either a REAL component imported from the app, or a * every specimen below is either a REAL component imported from the app, or a
* real token read from the browser, or it is explicitly marked as missing. * real token read from the browser, or it is explicitly marked as missing.
* *
* Buttons are the case where that bites. Rule 65 specifies four variants, but * Buttons WERE the case where that bit: `.btn-primary` was defined five times
* `.btn-primary` is defined four separate times in four `<style scoped>` blocks * in five `<style scoped>` blocks, all five drifted, and this page reported it
* and 30 of 54 SFCs carry their own button CSS (#2273). There is no shared * as a gap because drawing a look-alike would have made it a sixth copy.
* button to import, so rendering one here would just make this a fifth copy. * `assets/components.css` is now the single definition (#2273), so the
* It is reported as a gap instead. * specimens below are the app's real classes — they cannot drift from the app
* without drifting the app itself.
*/ */
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
@@ -225,25 +226,24 @@ const TYPE_SPECIMENS = [
</div> </div>
</section> </section>
<!-- The honest gap. --> <!-- No longer a gap: these are the app's real classes, from the shared
sheet. Nothing here is a look-alike — change components.css and these
specimens change with it, which is the only way this page stays true. -->
<section class="design-section"> <section class="design-section">
<h2>Buttons</h2> <h2>Buttons</h2>
<div class="gap-notice"> <div class="button-specimens">
<strong>Not implemented as a shared component.</strong> <button class="btn-primary">Save</button>
<p> <button class="btn-secondary">Detect</button>
Rule 65 specifies four variants. In practice <code>.btn-primary</code> is <button class="btn-ghost">Cancel</button>
defined four separate times in four <code>&lt;style scoped&gt;</code> <button class="btn-danger">Delete</button>
blocks, and 30 of 54 single-file components carry their own button CSS. <button class="btn-primary" disabled>Disabled</button>
There is no shared button to render here, and drawing one would make <button class="btn-ghost btn-small">Small</button>
this page a fifth copy of it the exact drift this surface exists to
catch. Tracked as issue #2273.
</p>
</div> </div>
<ul class="spec-list"> <ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name"> <li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span> <span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span> <span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status missing">missing</span> <span class="spec-status ok">shared</span>
</li> </li>
</ul> </ul>
</section> </section>
@@ -361,6 +361,17 @@ const TYPE_SPECIMENS = [
/* Gaps ------------------------------------------------------------------- */ /* Gaps ------------------------------------------------------------------- */
/* Layout only. The buttons inside style themselves from the shared sheet —
adding any appearance rule here would recreate the copy this section
just stopped being. */
.button-specimens {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--fs-space-3);
margin-bottom: 1rem;
}
.gap-notice { .gap-notice {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
-14
View File
@@ -284,20 +284,6 @@ function overallPct(project: Project): { total: number; pct: number } {
/* Moss action-primary per Hybrid — list-view utility action, /* Moss action-primary per Hybrid — list-view utility action,
not a brand moment. Empty-state .empty-action below keeps accent. */ not a brand moment. Empty-state .empty-action below keeps accent. */
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--color-action-primary-hover);
}
.filter-tabs { .filter-tabs {
display: flex; display: flex;
-42
View File
@@ -2600,37 +2600,9 @@ function formatUserDate(iso: string): string {
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; } .btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
/* Filled destructive: Oxblood action-destructive */ /* Filled destructive: Oxblood action-destructive */
.btn-danger {
padding: 0.4rem 0.9rem;
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: default; }
/* Secondary: Bronze action-secondary — alternate paths (Detect, Test, /* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
Refresh, Add slot, etc.). Outline form for visual lightness. */ Refresh, Add slot, etc.). Outline form for visual lightness. */
.btn-secondary {
padding: 0.4rem 0.9rem;
background: var(--color-action-secondary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
/* DB maintenance last-run summary */ /* DB maintenance last-run summary */
.db-maint-last { margin-top: 1rem; } .db-maint-last { margin-top: 1rem; }
@@ -3148,20 +3120,6 @@ function formatUserDate(iso: string): string {
/* ── Groups tab ──────────────────────────────────────────────── */ /* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */ /* Moss action-primary per Hybrid */
.btn-primary {
padding: 0.4rem 0.9rem;
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.input-field { .input-field {
width: 100%; width: 100%;
-28
View File
@@ -249,34 +249,6 @@ async function confirmDelete() {
flex-shrink: 0; flex-shrink: 0;
} }
.btn-ghost {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-ghost:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-danger {
padding: 0.35rem 0.8rem;
border: none;
background: var(--color-action-destructive, #6B2118);
color: var(--fs-text-on-action);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.btn-danger:hover {
opacity: 0.9;
}
.when-to-use { .when-to-use {
margin: 0.75rem 0 1.25rem; margin: 0.75rem 0 1.25rem;
-31
View File
@@ -572,37 +572,6 @@ function cancel() {
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.btn-primary {
padding: 0.5rem 1.1rem;
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-secondary {
padding: 0.5rem 1.1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.btn-secondary:hover {
background: var(--color-bg);
}
@media (max-width: 600px) { @media (max-width: 600px) {
.field-row, .field-row,
-34
View File
@@ -543,21 +543,6 @@ function usageTitle(s: SnippetListItem): string {
} }
/* Moss action-primary per Hybrid — utility action, not a brand moment. */ /* Moss action-primary per Hybrid — utility action, not a brand moment. */
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
white-space: nowrap;
}
.btn-primary:hover {
background: var(--color-action-primary-hover);
}
.search-row { .search-row {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@@ -870,21 +855,6 @@ function usageTitle(s: SnippetListItem): string {
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
.btn-ghost {
padding: 0.4rem 0.85rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-ghost:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Selected card = 2px accent border per the design system (featured/active). */ /* Selected card = 2px accent border per the design system (featured/active). */
.snippet-card.selected { .snippet-card.selected {
@@ -931,10 +901,6 @@ function usageTitle(s: SnippetListItem): string {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.select-bar .btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
/* Merge modal */ /* Merge modal */
.modal-overlay { .modal-overlay {