Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-sidebar-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Quieten the sidebar session rows. Hover becomes a translucent wash instead of a solid fill, the selected row becomes a faint tint instead of a solid accent, and the radius and sizing match the shared menu row, so the row scales with the UI font-size setting. The same change is applied to the per-theme overrides, so all three themes agree.
15 changes: 10 additions & 5 deletions apps/pythinker-web/src/components/SessionRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,20 @@ defineExpose({ closeMenu, cancelArchive });
.se {
/* --sb-* vars come from .side in Sidebar.vue. The outer margin and reduced
inner padding keep the title at --sb-pad-x + --sb-gutter + --sb-gap. */
/* Default 14px: 14 + 13 = 27px; 14 - 1 = 13px. A MINIMUM, not a fixed
height: the row also carries an 18px tag pill and the archive-confirm
strip, and a fixed height would clip both. */
display: block;
margin: 0 8px;
padding: 7px calc(var(--sb-pad-x, 12px) - 8px);
border-radius: 8px;
padding: 4px calc(var(--sb-pad-x, 12px) - 8px);
min-height: calc(var(--ui-font-size) + 13px);
box-sizing: border-box;
border-radius: var(--r-md);
cursor: pointer;
position: relative;
}
.se:hover { background: var(--panel2); }
.se.on { background: var(--soft); }
.se:hover { background: var(--hover); }
.se.on { background: color-mix(in srgb, var(--soft) 45%, var(--panel)); }

.row {
display: flex;
Expand Down Expand Up @@ -278,7 +283,7 @@ defineExpose({ closeMenu, cancelArchive });

.t {
color: var(--ink);
font-size: var(--ui-font-size);
font-size: calc(var(--ui-font-size) - 1px);
font-weight: 400;
flex: 1;
min-width: 0;
Expand Down
2 changes: 1 addition & 1 deletion apps/pythinker-web/src/components/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ onBeforeUnmount(() => {
}
.ws-head-label {
color: var(--muted);
font-size: var(--ui-font-size);
font-size: calc(var(--ui-font-size) - 2px);
min-width: 0;
}
.ws-head-actions {
Expand Down
6 changes: 3 additions & 3 deletions apps/pythinker-web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -349,14 +349,14 @@ html[data-color-scheme="dark"][data-theme="modern"] {
.sessions because .se is also a (different) class in MobileTopBar. */
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se {
margin: 1px 6px;
border-radius: var(--r-sm);
border-radius: var(--r-md);
/* Trim the row padding by the inset margin so the title still starts at the
same x as the workspace name (whose header has no inset). */
padding: 7px calc(var(--sb-pad-x, 12px) - 6px);
}
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se:hover { background: var(--panel2); }
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se:hover { background: var(--hover); }
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se.on {
background: var(--soft);
background: color-mix(in srgb, var(--soft) 45%, var(--panel));
}

/* Tab bar → clean white strip with a single hairline.
Expand Down
96 changes: 96 additions & 0 deletions apps/pythinker-web/test/session-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// task), and surfaces the 5-state lifecycle status: awaiting shows its pending
// tag, aborted shows a distinct "stopped" tag — neither spins.

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { describe, expect, it } from 'vitest';
Expand All @@ -21,6 +23,30 @@ const i18n = createI18n({
fallbackWarn: false,
});

const sessionRowSource = readFileSync(
resolve(import.meta.dirname, '../src/components/SessionRow.vue'),
'utf8',
);
const sessionRowStyle = sessionRowSource.match(/<style scoped>([\s\S]*?)<\/style>/u)?.[1];
if (!sessionRowStyle) throw new Error('SessionRow.vue must have a scoped style block');

const sidebarSource = readFileSync(
resolve(import.meta.dirname, '../src/components/Sidebar.vue'),
'utf8',
);
const sidebarStyle = sidebarSource.match(/<style scoped>([\s\S]*?)<\/style>/u)?.[1];
if (!sidebarStyle) throw new Error('Sidebar.vue must have a scoped style block');

const globalStyleSource = readFileSync(
resolve(import.meta.dirname, '../src/style.css'),
'utf8',
);

function declarations(source: string, selector: string): string {
const escaped = selector.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
return source.match(new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`, 'u'))?.[1] ?? '';
}

function row(session: Partial<Session>, extra: Record<string, unknown> = {}) {
const full: Session = { id: 's1', title: 'Demo', time: '1m', status: 'idle', busy: false, ...session };
return mount(SessionRow, {
Expand Down Expand Up @@ -57,3 +83,73 @@ describe('SessionRow status / busy', () => {
expect(w.find('.tag-aborted').exists()).toBe(false);
});
});

describe('SessionRow design tokens', () => {
it('uses the translucent hover token instead of a solid panel fill', () => {
const hover = declarations(sessionRowStyle, '.se:hover');

expect(hover).toContain('background: var(--hover)');
expect(hover).not.toContain('var(--panel2)');
});

it('uses MenuRow\'s mixed wash for the selected row', () => {
const selected = declarations(sessionRowStyle, '.se.on');

expect(selected).toContain('background: color-mix(in srgb, var(--soft) 45%, var(--panel))');
expect(selected).not.toContain('background: var(--soft)');
});

it('uses the medium radius token for the row', () => {
expect(declarations(sessionRowStyle, '.se')).toContain('border-radius: var(--r-md)');
});

it('keeps the modern and pythinker row overrides aligned', () => {
const themedRow = ':is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se';

expect(declarations(globalStyleSource, themedRow)).toContain('border-radius: var(--r-md)');
expect(declarations(globalStyleSource, `${themedRow}:hover`)).toContain('background: var(--hover)');
expect(declarations(globalStyleSource, `${themedRow}.on`)).toContain(
'background: color-mix(in srgb, var(--soft) 45%, var(--panel))',
);
});

it('derives row height and title size from the UI font size', () => {
const rowStyle = declarations(sessionRowStyle, '.se');
const titleStyle = declarations(sessionRowStyle, '.t');

expect(rowStyle).toContain('min-height: calc(var(--ui-font-size) + 13px)');
// A MINIMUM, never a fixed height — the row also carries an 18px tag pill
// and the archive-confirm strip, and a fixed height clips both.
expect(rowStyle).not.toMatch(/(^|[^-])height:\s*calc/u);
expect(rowStyle).toContain('box-sizing: border-box');
expect(titleStyle).toContain('font-size: calc(var(--ui-font-size) - 1px)');
expect(sessionRowStyle).toContain('Default 14px: 14 + 13 = 27px; 14 - 1 = 13px.');
});

it('keeps the selected row title visibly bolder', () => {
const wrapper = row({}, { active: true });

expect(wrapper.find('.se').classes()).toContain('on');
expect(declarations(sessionRowStyle, '.se.on .t')).toContain('font-weight: 500');
});

it('keeps the SessionRow free of dark utilities and new color literals', () => {
// The one literal that predates the token migration. Anything else is a new
// hardcoded colour, which breaks two of the three themes. Listed explicitly
// rather than diffed against git, so the check still bites after it lands.
const allowed = new Set(['rgba(0,0,0,0.08)']);
const colorLiterals = sessionRowSource.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/giu) ?? [];

expect(sessionRowSource).not.toMatch(/\bdark:/u);
expect(colorLiterals.filter((literal) => !allowed.has(literal))).toEqual([]);
});
});

describe('Sidebar section heading styling', () => {
it('uses a quiet relative size for the workspace section label', () => {
const heading = declarations(sidebarStyle, '.ws-head-label');

expect(heading).toContain('color: var(--muted)');
expect(heading).toContain('font-size: calc(var(--ui-font-size) - 2px)');
});
});
Loading