refactor(ui): one button definition, aligned to the design system
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 39s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 1m12s

`.btn-primary` was defined five times in five scoped stylesheets and all five
had drifted: three paddings, three font sizes, three disabled opacities — and
ProjectListView had no disabled style at all, so a disabled button there looked
enabled. Nothing detected any of it. A scoped duplicate is not a rule
violation, not a broken reference, and not a recorded snippet, so no existing
check could see it (#2273).

assets/components.css is now the single definition of the core four —
primary, secondary, ghost, danger — plus the small modifier, in design-system
tokens throughout. Operator's call to align to the system rather than to the
majority of current values, so buttons move to the 8px radius and 12px label
the system specifies, from the app's 4px/14.4px.

Class names are unchanged, so there are no template edits: the existing surface
is repurposed, not rebuilt.

STAGING PROPERTY that makes this safe to land ahead of the rest: a Vue
`<style scoped>` rule compiles to `.btn-primary[data-v-…]` (specificity 0,2,0)
and beats a plain global selector (0,1,0). So the shared sheet changes nothing
for a view still carrying its own copy, and every intermediate state of the
remaining migration is coherent rather than half-applied.

Two divergences corrected on the way, both worth naming:

- SnippetEditorView's `.btn-secondary` was a GHOST in disguise — outline
  styling under the secondary name, while the house style says secondary is
  filled bronze. It now looks like what it is called.
- SnippetDetailView and SnippetListView tinted a ghost button's label with the
  ACCENT on hover. The house style reserves the accent for identity and active
  state, never general chrome.

DesignView reported "no shared button exists" as an honest gap and declined to
draw a look-alike. That gap is closed, so it now renders the app's real
classes — the specimens cannot drift from the app without drifting the app.

Net -177 lines. Follows: the ~20 semantic one-offs (.btn-save, .btn-delete,
.btn-toggle …) and the compound overrides in ProjectView, one of which puts the
accent on a primary action button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-08-01 19:52:35 -04:00
co-authored by Claude Opus 5
parent 6d01788326
commit 1a959b1db0
9 changed files with 144 additions and 207 deletions
-41
View File
@@ -1262,47 +1262,6 @@ textarea.input {
/* 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 {
+28 -17
View File
@@ -12,11 +12,12 @@
* 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.
*
* Buttons are the case where that bites. Rule 65 specifies four variants, but
* `.btn-primary` is defined four separate times in four `<style scoped>` blocks
* and 30 of 54 SFCs carry their own button CSS (#2273). There is no shared
* button to import, so rendering one here would just make this a fifth copy.
* It is reported as a gap instead.
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
* in five `<style scoped>` blocks, all five drifted, and this page reported it
* as a gap because drawing a look-alike would have made it a sixth copy.
* `assets/components.css` is now the single definition (#2273), so the
* 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";
@@ -225,25 +226,24 @@ const TYPE_SPECIMENS = [
</div>
</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">
<h2>Buttons</h2>
<div class="gap-notice">
<strong>Not implemented as a shared component.</strong>
<p>
Rule 65 specifies four variants. In practice <code>.btn-primary</code> is
defined four separate times in four <code>&lt;style scoped&gt;</code>
blocks, and 30 of 54 single-file components carry their own button CSS.
There is no shared button to render here, and drawing one would make
this page a fifth copy of it the exact drift this surface exists to
catch. Tracked as issue #2273.
</p>
<div class="button-specimens">
<button class="btn-primary">Save</button>
<button class="btn-secondary">Detect</button>
<button class="btn-ghost">Cancel</button>
<button class="btn-danger">Delete</button>
<button class="btn-primary" disabled>Disabled</button>
<button class="btn-ghost btn-small">Small</button>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status missing">missing</span>
<span class="spec-status ok">shared</span>
</li>
</ul>
</section>
@@ -361,6 +361,17 @@ const TYPE_SPECIMENS = [
/* 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 {
background: var(--color-surface);
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,
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 {
display: flex;
-42
View File
@@ -2600,37 +2600,9 @@ function formatUserDate(iso: string): string {
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
/* 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,
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-maint-last { margin-top: 1rem; }
@@ -3148,20 +3120,6 @@ function formatUserDate(iso: string): string {
/* ── Groups tab ──────────────────────────────────────────────── */
/* 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 {
width: 100%;
-28
View File
@@ -249,34 +249,6 @@ async function confirmDelete() {
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 {
margin: 0.75rem 0 1.25rem;
-31
View File
@@ -572,37 +572,6 @@ function cancel() {
gap: 0.5rem;
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) {
.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. */
.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 {
margin-bottom: 1.25rem;
@@ -870,21 +855,6 @@ function usageTitle(s: SnippetListItem): string {
gap: 0.5rem;
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). */
.snippet-card.selected {
@@ -931,10 +901,6 @@ function usageTitle(s: SnippetListItem): string {
flex: 1;
min-width: 0;
}
.select-bar .btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
/* Merge modal */
.modal-overlay {