Buttons: one definition, aligned to the design system — plus local prior-art recall #94
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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><style scoped></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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user