Merge pull request 'Dead CSS deleted, and the instrument that found it sharpened (#2962, #2970)' (#129) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 14s

This commit was merged in pull request #129.
This commit is contained in:
2026-08-23 21:34:17 -04:00
19 changed files with 224 additions and 688 deletions
-151
View File
@@ -15,18 +15,6 @@
padding: 1rem 1.5rem 0.5rem; padding: 1rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color); border-bottom: 1px solid var(--fs-border-color);
} }
.editor-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.editor-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.5rem 1.5rem;
}
/* ── Toolbar & inputs ── */ /* ── Toolbar & inputs ── */
.toolbar { .toolbar {
@@ -106,95 +94,6 @@
.tag-check { .tag-check {
font-size: 0.7rem; font-size: 0.7rem;
} }
/* ── Assist panel ── */
.assist-panel {
width: 320px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
display: flex;
flex-direction: column;
overflow: hidden;
}
.assist-panel-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--fs-border-color);
}
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.assist-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.75rem 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
/* Section list */
.assist-sections-label {
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
margin-bottom: 0.2rem;
}
.assist-sections {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.assist-section-item {
padding: 0.35rem 0.7rem;
cursor: pointer;
font-size: 0.82rem;
border-left: 3px solid transparent;
color: var(--fs-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--fs-surface-raised);
}
.assist-section-item.selected {
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
font-weight: 500;
}
.assist-empty,
.assist-hint {
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
color: var(--fs-text-tertiary);
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--fs-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-target-preview em {
font-style: normal;
color: var(--fs-text-primary);
}
.assist-instruction { .assist-instruction {
width: 100%; width: 100%;
padding: 0.5rem 0.65rem; padding: 0.5rem 0.65rem;
@@ -213,33 +112,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
/* Streaming */
.assist-streaming-label {
font-size: 0.8rem;
color: var(--fs-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-preview-box {
padding: 0.65rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
font-size: 0.9rem;
max-height: 300px;
overflow-y: auto;
}
.typing-indicator {
color: var(--fs-text-tertiary);
font-size: 0.75rem;
letter-spacing: 0.15em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Active hint shown in the panel while output is inline */ /* Active hint shown in the panel while output is inline */
.assist-active-hint { .assist-active-hint {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
@@ -257,16 +129,6 @@
font-size: 0.85rem; font-size: 0.85rem;
color: var(--fs-error); color: var(--fs-error);
} }
/* Review / diff */
.assist-review-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-secondary);
}
.diff-view { .diff-view {
border: 1px solid var(--fs-border-color); border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm); border-radius: var(--fs-radius-sm);
@@ -398,22 +260,9 @@
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.editor-body {
flex-direction: column;
}
.assist-panel {
width: auto;
flex: 0 0 45%;
border-left: none;
border-top: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
}
.editor-header { .editor-header {
padding: 0.75rem 1rem 0.5rem; padding: 0.75rem 1rem 0.5rem;
} }
.editor-main {
padding: 0.5rem 1rem 1rem;
}
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
+1 -8
View File
@@ -272,17 +272,10 @@ button:not(:disabled):active,
display: none !important; display: none !important;
} }
button, button,
[role="button"], [role="button"] {
.btn-new-conv,
.btn-send {
min-height: 44px; min-height: 44px;
} }
} }
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
/* Neutral hairline scrollbars — chrome is structural, not branded */ /* Neutral hairline scrollbars — chrome is structural, not branded */
::-webkit-scrollbar { ::-webkit-scrollbar {
+1 -40
View File
@@ -212,43 +212,6 @@ router.afterEach(() => {
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
} }
/* Status indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: default;
padding: 0 0.25rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-text-tertiary);
}
/* Status dots are indicator lights, not semantic-palette buttons —
they want to read as vital (Moss/Warning/Error are too muted for
a "ready" indicator). Hardcoded bright values; the rest of the
system still uses the semantic tokens. */
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: #ef4444; }
.status-gray .status-dot { background: var(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
/* Icon buttons (?, theme, gear) */ /* Icon buttons (?, theme, gear) */
.btn-icon { .btn-icon {
background: none; background: none;
@@ -263,8 +226,7 @@ router.afterEach(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.btn-icon:hover, .btn-icon:hover {
.btn-icon.active {
background: var(--fs-surface-raised); background: var(--fs-surface-raised);
color: var(--fs-text-primary); color: var(--fs-text-primary);
border-color: var(--fs-accent); border-color: var(--fs-accent);
@@ -382,7 +344,6 @@ router.afterEach(() => {
.nav-center { .nav-center {
display: none; display: none;
} }
.status-indicator,
.btn-icon, .btn-icon,
.user-info { .user-info {
display: none; display: none;
-2
View File
@@ -231,7 +231,6 @@ onMounted(async () => {
color: var(--fs-text-primary); color: var(--fs-text-primary);
} }
.share-tabs { .share-tabs {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
@@ -307,7 +306,6 @@ onMounted(async () => {
.user-result-item:hover { background: var(--fs-surface-raised); } .user-result-item:hover { background: var(--fs-surface-raised); }
.user-result-name { font-weight: 600; font-size: 0.88rem; } .user-result-name { font-weight: 600; font-size: 0.88rem; }
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
.perm-select { .perm-select {
padding: 0.45rem 0.5rem; padding: 0.45rem 0.5rem;
@@ -471,7 +471,6 @@ defineExpose({ reload: loadProjectNotes });
flex: 1; flex: 1;
} }
.rail-search-input { .rail-search-input {
flex: 1; flex: 1;
background: transparent; background: transparent;
@@ -575,8 +574,6 @@ defineExpose({ reload: loadProjectNotes });
align-items: center; align-items: center;
} }
.note-row:hover .btn-delete { opacity: 1; }
/* Editor UI */ /* Editor UI */
.panel-header { .panel-header {
display: flex; display: flex;
@@ -624,8 +621,6 @@ defineExpose({ reload: loadProjectNotes });
} }
.tag-row > :first-child { flex: 1; min-width: 0; } .tag-row > :first-child { flex: 1; min-width: 0; }
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
.tag-suggestions { .tag-suggestions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -653,7 +648,6 @@ defineExpose({ reload: loadProjectNotes });
color: var(--fs-accent); color: var(--fs-accent);
} }
.link-suggest-strip { .link-suggest-strip {
display: flex; display: flex;
align-items: center; align-items: center;
-9
View File
@@ -607,14 +607,6 @@ onUnmounted(() => {
text-decoration: none; text-decoration: none;
font-size: 0.78rem; font-size: 0.78rem;
} }
.today-link {
color: var(--fs-accent);
text-decoration: none;
font-weight: 500;
opacity: 0.85;
transition: opacity 0.15s;
}
.today-link:hover { opacity: 1; }
/* ── Main layout ─────────────────────────────────────────── */ /* ── Main layout ─────────────────────────────────────────── */
.knowledge-layout { .knowledge-layout {
@@ -1042,7 +1034,6 @@ onUnmounted(() => {
height: 100%; height: 100%;
} }
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */ /* A set someone already ruled on — quiet, not celebratory: it means "skip". */
.dup-claimed { .dup-claimed {
font-size: 0.72rem; font-size: 0.72rem;
-6
View File
@@ -366,12 +366,6 @@ function overallPct(project: Project): { total: number; pct: number } {
border-bottom-color: var(--fs-accent); border-bottom-color: var(--fs-accent);
font-weight: 500; font-weight: 500;
} }
.loading-msg {
color: var(--fs-text-tertiary);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg { .error-msg {
margin-top: 1rem; margin-top: 1rem;
} }
+2 -1
View File
@@ -1444,7 +1444,8 @@ async function confirmDelete() {
} }
/* The input itself is the .fs-input canon (components.css); only the /* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */ layout remainder lives here. */
.edit-input, .edit-textarea, .edit-select { box-sizing: border-box; width: 100%; } .edit-textarea,
.edit-select { box-sizing: border-box; width: 100%; }
.edit-textarea { resize: vertical; } .edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */ /* Save panel: Moss action-primary per Hybrid rule */
-442
View File
@@ -2645,70 +2645,6 @@ async function deleteUser(userId: number) {
line-height: 1.5; line-height: 1.5;
} }
/* ── Model Management ───────────────────────────────────────── */
.model-mgmt-header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
}
.model-mgmt-header h2 { margin: 0; }
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.45rem 0.6rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
}
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 500; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--fs-accent) 18%, transparent); color: var(--fs-accent); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--fs-text-tertiary); }
.model-delete-btn {
background: none; border: none; cursor: pointer; color: var(--fs-text-tertiary);
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--fs-action-destructive); background: color-mix(in srgb, var(--fs-action-destructive) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.suggestions-label { font-size: 0.75rem; color: var(--fs-text-tertiary); white-space: nowrap; }
.suggestion-chip {
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
border: 1px solid var(--fs-border-color); background: var(--fs-surface-raised);
cursor: pointer; font-family: monospace; color: var(--fs-text-primary);
transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.25rem; }
.pull-bar-track {
height: 4px; background: var(--fs-border-color); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
height: 100%; background: var(--fs-accent); border-radius: 2px;
transition: width 0.3s ease;
}
.pull-bar-indeterminate {
height: 4px; background: var(--fs-border-color); border-radius: 2px;
position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
content: ""; position: absolute; top: 0; left: -40%;
width: 40%; height: 100%; background: var(--fs-accent); border-radius: 2px;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { left: -40%; } 100% { left: 100%; }
}
/* Assistant — 2-col internal grid */ /* Assistant — 2-col internal grid */
.assistant-grid { .assistant-grid {
display: grid; display: grid;
@@ -2736,11 +2672,6 @@ async function deleteUser(userId: number) {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.field-hint-warn {
display: block;
margin-top: 0.3rem;
color: #f59e0b;
}
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2809,16 +2740,6 @@ async function deleteUser(userId: number) {
color: var(--fs-error); color: var(--fs-error);
} }
.retention-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.retention-input {
width: 6rem;
}
/* Data buttons */ /* Data buttons */
.data-actions { .data-actions {
display: flex; display: flex;
@@ -2945,42 +2866,6 @@ async function deleteUser(userId: number) {
max-width: 480px; max-width: 480px;
} }
/* Push notifications */
.push-unsupported {
font-size: 0.875rem;
color: var(--fs-text-tertiary);
}
.push-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.875rem;
}
.push-status-label {
color: var(--fs-text-secondary);
font-weight: 500;
}
.push-permission-badge,
.push-sub-badge {
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
.perm-granted { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.perm-denied { background: color-mix(in srgb, var(--fs-error) 15%, transparent); color: var(--fs-error); }
.sub-active { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.push-error {
font-size: 0.82rem;
color: var(--fs-error);
margin: 0.25rem 0 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.settings-root { .settings-root {
flex-direction: column; flex-direction: column;
@@ -3175,25 +3060,6 @@ async function deleteUser(userId: number) {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
/* ── About / version ─────────────────────────────────────────── */
.version-line {
margin: 0;
font-size: 0.9rem;
color: var(--fs-text-primary);
display: flex;
align-items: center;
gap: 0.6rem;
}
.version-badge {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-accent);
border-radius: 4px;
color: var(--fs-accent);
}
/* ── Groups tab ──────────────────────────────────────────────── */ /* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */ /* Moss action-primary per Hybrid */
@@ -3278,7 +3144,6 @@ async function deleteUser(userId: number) {
flex-shrink: 0; flex-shrink: 0;
} }
.member-search-wrap { .member-search-wrap {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -3311,7 +3176,6 @@ async function deleteUser(userId: number) {
} }
.member-result-item:hover { background: var(--fs-surface-hover); } .member-result-item:hover { background: var(--fs-surface-hover); }
.member-result-name { font-weight: 500; font-size: 0.85rem; } .member-result-name { font-weight: 500; font-size: 0.85rem; }
.member-result-email { color: var(--fs-text-tertiary); font-size: 0.78rem; }
.role-select { .role-select {
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
@@ -3361,76 +3225,6 @@ async function deleteUser(userId: number) {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
/* Profile — Locations + Journal sections */
.location-row {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.geo-msg {
font-size: 0.78rem;
margin: 0.2rem 0 0;
}
.geo-ok { color: var(--fs-success); }
.geo-error { color: var(--fs-error); }
.geo-pending { color: var(--fs-text-tertiary); }
.unit-toggle {
display: flex;
gap: 0;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
overflow: hidden;
width: fit-content;
margin-top: 0.25rem;
}
.unit-btn {
padding: 0.4rem 1rem;
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.unit-btn:not(:last-child) {
border-right: 1px solid var(--fs-border-color);
}
.unit-btn.active {
background: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.unit-btn:hover:not(.active) {
color: var(--fs-text-primary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.time-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.time-input {
width: 4rem;
text-align: center;
}
.time-sep {
font-size: 1.1rem;
color: var(--fs-text-tertiary);
}
.time-sep--quiet { font-size: 0.9rem; }
/* API Keys tab */ /* API Keys tab */
.api-key-create-form { .api-key-create-form {
display: flex; display: flex;
@@ -3493,24 +3287,6 @@ async function deleteUser(userId: number) {
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; } .scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; } .settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; } .settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
/* Scribe MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
.mcp-pkg-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.65rem 0.9rem;
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
border-radius: 8px;
}
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.75rem; }
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
.mcp-code { .mcp-code {
margin-top: 0.4rem; margin-top: 0.4rem;
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
@@ -3599,207 +3375,6 @@ async function deleteUser(userId: number) {
opacity: 0.7; opacity: 0.7;
line-height: 1.5; line-height: 1.5;
} }
.mcp-other p { font-size: 0.9rem; line-height: 1.6; }
.mcp-plain-list {
list-style: disc;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
line-height: 1.6;
}
/* Voice tab */
.voice-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* Voice Library (admin) */
.voice-library-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.voice-library-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 1rem 0;
}
.voice-library-toolbar .input {
flex: 1;
}
.voice-library-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--fs-border-color);
max-height: 480px;
overflow-y: auto;
}
.voice-library-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.25rem;
border-bottom: 1px solid var(--fs-border-color);
}
.voice-library-meta {
min-width: 0;
flex: 1;
}
.voice-library-id {
font-family: var(--fs-font-mono);
font-size: 0.9rem;
font-weight: 500;
}
.voice-library-sub {
color: var(--fs-text-tertiary);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.voice-library-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.voice-library-empty {
padding: 1rem 0;
color: var(--fs-text-tertiary);
text-align: center;
font-style: italic;
}
.voice-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.voice-badge--bundled {
background: color-mix(in srgb, var(--fs-text-tertiary) 10%, transparent);
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
}
.status-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.status-on {
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: var(--fs-success);
border: 1px solid color-mix(in srgb, var(--fs-success) 40%, transparent);
}
.status-off {
background: color-mix(in srgb, var(--fs-text-tertiary) 10%, transparent);
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.35rem;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
}
.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; }
.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; }
.radio-option strong { font-size: 0.875rem; }
.range-input {
width: 100%;
max-width: 360px;
accent-color: var(--fs-accent);
margin: 0.35rem 0 0.2rem;
}
.range-labels {
display: flex;
justify-content: space-between;
max-width: 360px;
font-size: 0.75rem;
color: var(--fs-text-tertiary);
}
.form-actions {
margin-top: 1rem;
}
.voice-admin-spinner {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 0.4rem;
vertical-align: middle;
}
.voice-admin-spinner span {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--fs-text-tertiary);
animation: va-dot-bounce 1.2s ease-in-out infinite;
}
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }
/* ── Voice blend ────────────────────────────────────────────────────────── */
.blend-slots {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.blend-slot {
background: color-mix(in srgb, var(--fs-surface-hover) 60%, transparent);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.blend-voice-select {
width: 100%;
}
.blend-weight-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.blend-weight-slider {
flex: 1;
}
.blend-weight-label {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
min-width: 2.5rem;
text-align: right;
color: var(--fs-text-secondary);
}
.blend-actions {
margin-top: 0.25rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
/* ── Profile tab ─────────────────────────────────────────────────────────── */ /* ── Profile tab ─────────────────────────────────────────────────────────── */
.day-picker { .day-picker {
@@ -3826,21 +3401,4 @@ async function deleteUser(userId: number) {
color: var(--fs-accent); color: var(--fs-accent);
font-weight: 500; font-weight: 500;
} }
.learned-summary {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-accent);
border-radius: 8px;
padding: 0.85rem 1rem;
font-size: 0.9rem;
line-height: 1.55;
color: var(--fs-text-primary);
white-space: pre-wrap;
margin-bottom: 0.75rem;
}
.learned-empty {
font-size: 0.85rem;
color: var(--fs-text-tertiary);
margin-bottom: 0.75rem;
}
</style> </style>
+1 -6
View File
@@ -383,7 +383,6 @@ function cancel() {
color: var(--fs-accent); color: var(--fs-accent);
} }
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -400,9 +399,6 @@ function cancel() {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
} }
.field-row.three {
grid-template-columns: 1fr 1.4fr 1fr;
}
.field label, .field label,
.location-set legend { .location-set legend {
font-size: 0.8rem; font-size: 0.8rem;
@@ -552,8 +548,7 @@ function cancel() {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.field-row, .field-row {
.field-row.three {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
+2 -1
View File
@@ -803,7 +803,8 @@ useEditorGuards(dirty, save);
max-width: 1600px; max-width: 1600px;
} }
/* Replace .editor-body for task editor */ /* The task editor's own body row. It began as a replacement for the shared
.editor-body, which nothing used afterwards and has since been deleted. */
.task-body { .task-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.44", "version": "0.1.45",
"author": { "name": "Bryan Van Deusen" }, "author": { "name": "Bryan Van Deusen" },
"mcpServers": { "mcpServers": {
"scribe": { "scribe": {
+5 -1
View File
@@ -125,7 +125,11 @@ the last sweep left it. Three surfaces say so without anyone running an audit
templates, one recipe → derive; one template each, different purposes → templates, one recipe → derive; one template each, different purposes →
dismiss. `list_shapes(flag="unused-css")` is the map's negative space — dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
css rules no template names, a deletion candidate to look at, never css rules no template names, a deletion candidate to look at, never
auto-deleted (a class built at runtime is invisible to the map). auto-deleted. The map reads the two class forms templates don't spell out
— a `<Transition name="x">`'s generated classes, and the prefix of a
concatenated name (`` `status-${s}` `` credits every `status-…` rule) —
so what it flags is worth reading. What it still cannot see is a name
assembled in a script (`classList.add`), so confirm before deleting.
After the one-time pay-down the derive queue reads empty; anything in it After the one-time pay-down the derive queue reads empty; anything in it
afterwards is drift of the moment, and the hint already said so at the write. afterwards is drift of the moment, and the hint already said so at the write.
+6 -4
View File
@@ -118,8 +118,9 @@ async def list_shapes(
whose body changed since judged (the judgment stands; confirm whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge); "unused-css" it again with classify_shapes, or re-judge); "unused-css"
(milestone 302): live css rules no file's markup names — a (milestone 302): live css rules no file's markup names — a
deletion candidate to look at, never auto-deleted (the map reads deletion candidate to look at, never auto-deleted. Transition
templates only; a class built at runtime is invisible to it). classes and concatenated names are read (#2970), so the list is
worth acting on; a name assembled in a script still is not.
Returns {"shapes": [...], "total": N} — total counts every match, not Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Every css row carries `used_by` {count, paths} — the just this page. Every css row carries `used_by` {count, paths} — the
@@ -305,8 +306,9 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon confirmation), `derive_groups` (the biggest repeats-with-no-canon
families, each css one with `consumers` — the files whose markup families, each css one with `consumers` — the files whose markup
render it, milestone 302), `unused_css` (css rules no template names; render it, milestone 302), `unused_css` (css rules no template names
None where the map has no evidence of templates), `derive_new` (copies counting a `<Transition name=>`'s generated classes and concatenated
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
that joined a family since the previous that joined a family since the previous
refresh — the drift to act on now: derive the canon, don't queue an refresh — the drift to act on now: derive the canon, don't queue an
audit), `proposer` (what this refresh examined) — plus audit), `proposer` (what this refresh examined) — plus
+54 -3
View File
@@ -294,6 +294,34 @@ _DYNAMIC_CLASS_RE = re.compile(
) )
# Svelte's directive form: class:active={cond}. # Svelte's directive form: class:active={cond}.
_SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=") _SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=")
# Transition classes are applied by the FRAMEWORK, never written in markup:
# <Transition name="toast"> makes Vue add .toast-enter-active et al at
# runtime, and React's <CSSTransition classNames="fade"> does the same. A
# reader of `class=` attributes alone therefore calls every one of those
# rules unused, which is a false positive no amount of care in the
# stylesheet can avoid (#2970). A dynamic `:name="…"` stays unknowable.
_TRANSITION_NAME_RE = re.compile(
r"""<\s*[Tt]ransition(?:-[Gg]roup|Group)?\b[^>]*?(?<![:\w.-])name\s*=\s*"""
r"""(?:"([^"]*)"|'([^']*)')"""
r"""|(?<![:\w.-])classNames\s*=\s*(?:"([^"]*)"|'([^']*)')"""
)
# The union of what Vue 3, Vue 2 and React CSSTransition generate. Naming a
# class that no rule defines costs nothing — it resolves to no row — so the
# union is safer than guessing the framework from the file.
_TRANSITION_SUFFIXES = (
"-enter", "-enter-from", "-enter-active", "-enter-to", "-enter-done",
"-leave", "-leave-from", "-leave-active", "-leave-to",
"-exit", "-exit-active", "-exit-done",
"-appear", "-appear-from", "-appear-active", "-appear-to", "-appear-done",
"-move",
)
# A name built by concatenation — `status-${s}`, 'pri-' + p, class="c-{{ v }}"
# — leaves its static head behind once the hole is blanked. That head is a
# PREFIX reference, spelled `status-*`: "*" cannot occur in a class token, so
# the marker rides the plain token dict without a schema change. Needs a real
# name before the separator; `a-` or a bare `-` says nothing worth matching.
_PREFIX_MIN_STEM = 2
PREFIX_MARK = "*"
# Inside a dynamic expression: string literals (ternary arms, array items, # Inside a dynamic expression: string literals (ternary arms, array items,
# quoted object keys) and the bare keys of object literals. # quoted object keys) and the bare keys of object literals.
_STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""") _STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
@@ -307,8 +335,20 @@ _MUSTACHE_RE = re.compile(r"\{[{%][^}]*[}%]\}")
def _class_tokens(value: str) -> list[str]: def _class_tokens(value: str) -> list[str]:
"""The class tokens of a static attribute value: whitespace-split, only """The class tokens of a static attribute value: whitespace-split, only
well-formed names (an interpolation like `{{ cls }}` contributes none).""" well-formed names. An interpolation (`{{ cls }}`, `${cls}`) is blanked
return [t for t in _MUSTACHE_RE.sub(" ", value).split() if _CLASS_TOKEN_RE.match(t)] before the split, so a name built around one leaves its static head —
`status-` from `status-{{ s }}` — which is emitted as the prefix
reference `status-*` rather than as a class nothing is called."""
out: list[str] = []
for t in _MUSTACHE_RE.sub(" ", value).split():
if not _CLASS_TOKEN_RE.match(t):
continue
if t.endswith(("-", "_")):
if len(t.rstrip("-_")) >= _PREFIX_MIN_STEM:
out.append(t + PREFIX_MARK)
continue
out.append(t)
return out
def _dynamic_class_tokens(expr: str) -> list[str]: def _dynamic_class_tokens(expr: str) -> list[str]:
@@ -336,7 +376,13 @@ def class_references(path: str, text: str) -> dict[str, int]:
files that carry no markup (by suffix). Reads the static `class=` / files that carry no markup (by suffix). Reads the static `class=` /
`className=` attributes, the Vue and React dynamic forms and Svelte's `className=` attributes, the Vue and React dynamic forms and Svelte's
`class:x` directive; never a CSS selector (`.x {` is a definition, read `class:x` directive; never a CSS selector (`.x {` is a definition, read
by extract_definitions) and never a script's `querySelector('.x')`.""" by extract_definitions) and never a script's `querySelector('.x')`.
Two forms name classes without spelling them out, and both are read
(#2970): a transition `name=` stands for every class the framework
generates from it, and a concatenated name contributes the prefix
reference `head-*` — which resolve_consumers matches against every row
whose symbol starts with `head-`."""
if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES): if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES):
return {} return {}
counts: dict[str, int] = {} counts: dict[str, int] = {}
@@ -351,6 +397,11 @@ def class_references(path: str, text: str) -> dict[str, int]:
expr = next((g for g in m.groups() if g is not None), "") expr = next((g for g in m.groups() if g is not None), "")
bump(_dynamic_class_tokens(expr)) bump(_dynamic_class_tokens(expr))
bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)]) bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)])
for m in _TRANSITION_NAME_RE.finditer(text):
name = next((g for g in m.groups() if g is not None), "").strip()
if not _CLASS_TOKEN_RE.match(name):
continue
bump([name + suffix for suffix in _TRANSITION_SUFFIXES])
return counts return counts
+26 -7
View File
@@ -279,20 +279,39 @@ def resolve_consumers(
of that name when F defines it (a scoped rule is consumed by its own of that name when F defines it (a scoped rule is consumed by its own
template); otherwise to every other file's row of that name — a shared template); otherwise to every other file's row of that name — a shared
sheet, or, when several files define it, all of them: the map says sheet, or, when several files define it, all of them: the map says
"ambiguous" by fanning out rather than guessing one.""" "ambiguous" by fanning out rather than guessing one.
A token ending in ``PREFIX_MARK`` is a PREFIX reference (#2970) — the
static head of a name the template concatenates, `status-*` from
`` `status-${s}` ``. It stands for every row whose symbol starts with
that head, each resolved by the same own-file-else-fan-out rule. The
template cannot tell us WHICH of them it built, so the map credits all
of them rather than calling live rules unused."""
# Lazy, like the extract_definitions import below: coverage reaches into
# this module during a refresh, so neither may import the other at load.
from scribe.services.coverage import PREFIX_MARK
by_symbol: dict[str, list[tuple[int, str]]] = {} by_symbol: dict[str, list[tuple[int, str]]] = {}
for sid, path, symbol in css_rows: for sid, path, symbol in css_rows:
by_symbol.setdefault(symbol, []).append((sid, path)) by_symbol.setdefault(symbol, []).append((sid, path))
out: dict[tuple[int, str], int] = {} out: dict[tuple[int, str], int] = {}
def credit(rows: list[tuple[int, str]], consumer: str, count: int) -> None:
own = [sid for sid, path in rows if path == consumer]
for sid in own or [sid for sid, _path in rows]:
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
for consumer, tokens in references.items(): for consumer, tokens in references.items():
for token, count in tokens.items(): for token, count in tokens.items():
rows = by_symbol.get(token) if token.endswith(PREFIX_MARK):
if not rows: head = token[: -len(PREFIX_MARK)]
for symbol, rows in by_symbol.items():
if symbol.startswith(head):
credit(rows, consumer, count)
continue continue
own = [sid for sid, path in rows if path == consumer] rows = by_symbol.get(token)
targets = own or [sid for sid, _path in rows] if rows:
for sid in targets: credit(rows, consumer, count)
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
return out return out
+52
View File
@@ -683,6 +683,58 @@ async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(se
assert derive_new_summary(rows, since=None)["count"] == 0 assert derive_new_summary(rows, since=None)["count"] == 0
@pytest.mark.integration
async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded):
"""#2970 end to end: the two class forms a template never spells out —
a transition `name=` and a concatenated name — travel from the real
extractor through resolution into the flag, so rules they reach stop
being reported as unused. Only a rule nothing can reach stays listed."""
from scribe.services.coverage import class_references
from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
pid, owner = seeded["pid"], seeded["owner"]
defs = _defs(
("assets/anim.css", "css", "toast-enter-active",
".toast-enter-active {", ".toast-enter-active { opacity: 0 }"),
("assets/anim.css", "css", "toast-leave-to",
".toast-leave-to {", ".toast-leave-to { opacity: 0 }"),
("assets/state.css", "css", "status-done",
".status-done {", ".status-done { color: green }"),
("assets/state.css", "css", "status-todo",
".status-todo {", ".status-todo { color: grey }"),
("assets/state.css", "css", "really-dead",
".really-dead {", ".really-dead { color: red }"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="t1")
refs = {
# Vue applies the toast-* classes itself; the markup names none of them.
"v/Toast.vue": class_references(
"v/Toast.vue", '<transition-group name="toast"><li /></transition-group>'
),
# The board builds its status class; which one is unknowable.
"v/Board.vue": class_references("v/Board.vue", '<b :class="`status-${s}`" />'),
}
assert "toast-enter-active" in refs["v/Toast.vue"]
assert refs["v/Board.vue"] == {"status-*": 1}
await sync_repo_consumers(pid, REPO, refs)
live = [r for r in await live_rows(pid) if r.kind == "css"]
used = await used_by_map(live)
by = {(r.path, r.symbol): used[r.id]["count"] for r in live}
assert by[("assets/anim.css", "toast-enter-active")] == 1
assert by[("assets/anim.css", "toast-leave-to")] == 1
# the prefix credits BOTH candidates — the template does not say which
assert by[("assets/state.css", "status-done")] == 1
assert by[("assets/state.css", "status-todo")] == 1
assert by[("assets/state.css", "really-dead")] == 0
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
assert n == 1 and [(r.path, r.symbol) for r in unused] == [
("assets/state.css", "really-dead")
]
@pytest.mark.integration @pytest.mark.integration
async def test_consumer_map_syncs_edges_from_template_references(seeded): async def test_consumer_map_syncs_edges_from_template_references(seeded):
"""Milestone 302: the consumer edges follow the archive — own-file """Milestone 302: the consumer edges follow the archive — own-file
+39
View File
@@ -152,6 +152,45 @@ def test_class_references_reads_vue_static_and_dynamic_forms_only():
} }
def test_class_references_reads_framework_transition_names():
"""A transition `name=` is a class reference: the framework applies
`.toast-enter-active` and friends at runtime, so a stylesheet that
defines them is consumed even though no template spells one out (#2970).
Every spelling of the tag counts; a bound `:name` stays unknowable."""
refs = class_references(
"a/T.vue",
'<transition-group name="toast"><div class="toast-item" /></transition-group>',
)
for suffix in ("-enter-from", "-enter-active", "-leave-to", "-move"):
assert refs["toast" + suffix] == 1, suffix
assert refs["toast-item"] == 1 # the static attribute still counts
assert "peek-enter-active" in class_references("a/T.vue", '<Transition name="peek">')
assert "g-move" in class_references("a/T.vue", '<TransitionGroup name="g">')
# React's CSSTransition names the same idea with a different suffix set
react = class_references("a/T.jsx", '<CSSTransition classNames="fade">')
assert {"fade-enter-active", "fade-exit-active", "fade-exit-done"} <= set(react)
# A bound name is a variable, not a name we can read
assert class_references("a/T.vue", '<Transition :name="dyn">') == {}
def test_class_references_reads_a_concatenated_name_as_a_prefix():
"""`status-${s}` cannot be resolved to one class, but its static head is
real information: it is emitted as the prefix reference `status-*` so
the rows it could have built are not reported unused (#2970). A head too
short to mean anything, or a bare separator, says nothing."""
vue = (
'<div :class="`status-${task.status}`" />'
"<span :class=\"['pri-' + p]\" />"
'<b class="a-" /><u class="-" />'
)
assert class_references("a/P.vue", vue) == {"status-*": 1, "pri-*": 1}
# a server template interpolating into the middle of a name, same reading
assert class_references("t/p.html", '<i class="card-{{ v }}" />') == {"card-*": 1}
# an ordinary name never picks up the marker
assert class_references("a/P.vue", '<div class="page-header" />') == {"page-header": 1}
def test_class_references_reads_react_svelte_and_server_templates(): def test_class_references_reads_react_svelte_and_server_templates():
tsx = ( tsx = (
"export function X({ on }: { on: boolean }) {\n" "export function X({ on }: { on: boolean }) {\n"
+34
View File
@@ -473,6 +473,40 @@ def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names():
assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs)) assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs))
def test_resolve_consumers_credits_every_row_a_prefix_could_have_built():
"""#2970: `status-${s}` names a class the map cannot pin down, so the
prefix reference `status-*` credits every row whose symbol starts with
the head — each under the same own-file-else-fan-out rule. Crediting all
of them is the honest reading: the alternative is calling live rules
unused, which is what the flag existed to avoid."""
from scribe.services.shape_ledger import resolve_consumers
css_rows = [
(1, "assets/app.css", "status-done"),
(2, "assets/app.css", "status-todo"),
(3, "v/Board.vue", "status-done"), # a scoped copy of one of them
(4, "assets/app.css", "btn-primary"),
(5, "assets/anim.css", "toast-enter-active"),
]
# A file that defines none of them fans out across every match.
assert resolve_consumers(css_rows, {"v/List.vue": {"status-*": 2}}) == {
(1, "v/List.vue"): 2, (2, "v/List.vue"): 2, (3, "v/List.vue"): 2,
}
# A file that DOES define one keeps the own-file rule, per symbol: its own
# status-done row, and the shared status-todo it does not define.
assert resolve_consumers(css_rows, {"v/Board.vue": {"status-*": 1}}) == {
(3, "v/Board.vue"): 1, (2, "v/Board.vue"): 1,
}
# A prefix that matches nothing is silent, and exact tokens are untouched.
assert resolve_consumers(css_rows, {"v/X.vue": {"zz-*": 1}}) == {}
assert resolve_consumers(css_rows, {"v/X.vue": {"btn-primary": 3}}) == {
(4, "v/X.vue"): 3,
}
# A transition class resolves exactly, like any other name.
assert resolve_consumers(css_rows, {"c/Toast.vue": {"toast-enter-active": 1}}) == {
(5, "c/Toast.vue"): 1,
}
def test_consumer_edges_table_cascades_with_the_shape(): def test_consumer_edges_table_cascades_with_the_shape():
from scribe.models import Base from scribe.models import Base
from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer