Skip to content

Commit d4bf350

Browse files
committed
Merge branch 'feat/web-sidebar-shell'
2 parents b968869 + 93e31d7 commit d4bf350

5 files changed

Lines changed: 115 additions & 9 deletions

File tree

.changeset/web-sidebar-rows.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
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.

apps/pythinker-web/src/components/SessionRow.vue

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,15 +227,20 @@ defineExpose({ closeMenu, cancelArchive });
227227
.se {
228228
/* --sb-* vars come from .side in Sidebar.vue. The outer margin and reduced
229229
inner padding keep the title at --sb-pad-x + --sb-gutter + --sb-gap. */
230+
/* Default 14px: 14 + 13 = 27px; 14 - 1 = 13px. A MINIMUM, not a fixed
231+
height: the row also carries an 18px tag pill and the archive-confirm
232+
strip, and a fixed height would clip both. */
230233
display: block;
231234
margin: 0 8px;
232-
padding: 7px calc(var(--sb-pad-x, 12px) - 8px);
233-
border-radius: 8px;
235+
padding: 4px calc(var(--sb-pad-x, 12px) - 8px);
236+
min-height: calc(var(--ui-font-size) + 13px);
237+
box-sizing: border-box;
238+
border-radius: var(--r-md);
234239
cursor: pointer;
235240
position: relative;
236241
}
237-
.se:hover { background: var(--panel2); }
238-
.se.on { background: var(--soft); }
242+
.se:hover { background: var(--hover); }
243+
.se.on { background: color-mix(in srgb, var(--soft) 45%, var(--panel)); }
239244
240245
.row {
241246
display: flex;
@@ -278,7 +283,7 @@ defineExpose({ closeMenu, cancelArchive });
278283
279284
.t {
280285
color: var(--ink);
281-
font-size: var(--ui-font-size);
286+
font-size: calc(var(--ui-font-size) - 1px);
282287
font-weight: 400;
283288
flex: 1;
284289
min-width: 0;

apps/pythinker-web/src/components/Sidebar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ onBeforeUnmount(() => {
910910
}
911911
.ws-head-label {
912912
color: var(--muted);
913-
font-size: var(--ui-font-size);
913+
font-size: calc(var(--ui-font-size) - 2px);
914914
min-width: 0;
915915
}
916916
.ws-head-actions {

apps/pythinker-web/src/style.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,14 +351,14 @@ html[data-color-scheme="dark"][data-theme="modern"] {
351351
.sessions because .se is also a (different) class in MobileTopBar. */
352352
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se {
353353
margin: 1px 6px;
354-
border-radius: var(--r-sm);
354+
border-radius: var(--r-md);
355355
/* Trim the row padding by the inset margin so the title still starts at the
356356
same x as the workspace name (whose header has no inset). */
357357
padding: 7px calc(var(--sb-pad-x, 12px) - 6px);
358358
}
359-
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se:hover { background: var(--panel2); }
359+
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se:hover { background: var(--hover); }
360360
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se.on {
361-
background: var(--soft);
361+
background: color-mix(in srgb, var(--soft) 45%, var(--panel));
362362
}
363363

364364
/* Tab bar → clean white strip with a single hairline.

apps/pythinker-web/test/session-row.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
// task), and surfaces the 5-state lifecycle status: awaiting shows its pending
55
// tag, aborted shows a distinct "stopped" tag — neither spins.
66

7+
import { readFileSync } from 'node:fs';
8+
import { resolve } from 'node:path';
79
import { mount } from '@vue/test-utils';
810
import { createI18n } from 'vue-i18n';
911
import { describe, expect, it } from 'vitest';
@@ -21,6 +23,30 @@ const i18n = createI18n({
2123
fallbackWarn: false,
2224
});
2325

26+
const sessionRowSource = readFileSync(
27+
resolve(import.meta.dirname, '../src/components/SessionRow.vue'),
28+
'utf8',
29+
);
30+
const sessionRowStyle = sessionRowSource.match(/<style scoped>([\s\S]*?)<\/style>/u)?.[1];
31+
if (!sessionRowStyle) throw new Error('SessionRow.vue must have a scoped style block');
32+
33+
const sidebarSource = readFileSync(
34+
resolve(import.meta.dirname, '../src/components/Sidebar.vue'),
35+
'utf8',
36+
);
37+
const sidebarStyle = sidebarSource.match(/<style scoped>([\s\S]*?)<\/style>/u)?.[1];
38+
if (!sidebarStyle) throw new Error('Sidebar.vue must have a scoped style block');
39+
40+
const globalStyleSource = readFileSync(
41+
resolve(import.meta.dirname, '../src/style.css'),
42+
'utf8',
43+
);
44+
45+
function declarations(source: string, selector: string): string {
46+
const escaped = selector.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
47+
return source.match(new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`, 'u'))?.[1] ?? '';
48+
}
49+
2450
function row(session: Partial<Session>, extra: Record<string, unknown> = {}) {
2551
const full: Session = { id: 's1', title: 'Demo', time: '1m', status: 'idle', busy: false, ...session };
2652
return mount(SessionRow, {
@@ -57,3 +83,73 @@ describe('SessionRow status / busy', () => {
5783
expect(w.find('.tag-aborted').exists()).toBe(false);
5884
});
5985
});
86+
87+
describe('SessionRow design tokens', () => {
88+
it('uses the translucent hover token instead of a solid panel fill', () => {
89+
const hover = declarations(sessionRowStyle, '.se:hover');
90+
91+
expect(hover).toContain('background: var(--hover)');
92+
expect(hover).not.toContain('var(--panel2)');
93+
});
94+
95+
it('uses MenuRow\'s mixed wash for the selected row', () => {
96+
const selected = declarations(sessionRowStyle, '.se.on');
97+
98+
expect(selected).toContain('background: color-mix(in srgb, var(--soft) 45%, var(--panel))');
99+
expect(selected).not.toContain('background: var(--soft)');
100+
});
101+
102+
it('uses the medium radius token for the row', () => {
103+
expect(declarations(sessionRowStyle, '.se')).toContain('border-radius: var(--r-md)');
104+
});
105+
106+
it('keeps the modern and pythinker row overrides aligned', () => {
107+
const themedRow = ':is(html[data-theme="modern"], html[data-theme="pythinker"]) .sessions .se';
108+
109+
expect(declarations(globalStyleSource, themedRow)).toContain('border-radius: var(--r-md)');
110+
expect(declarations(globalStyleSource, `${themedRow}:hover`)).toContain('background: var(--hover)');
111+
expect(declarations(globalStyleSource, `${themedRow}.on`)).toContain(
112+
'background: color-mix(in srgb, var(--soft) 45%, var(--panel))',
113+
);
114+
});
115+
116+
it('derives row height and title size from the UI font size', () => {
117+
const rowStyle = declarations(sessionRowStyle, '.se');
118+
const titleStyle = declarations(sessionRowStyle, '.t');
119+
120+
expect(rowStyle).toContain('min-height: calc(var(--ui-font-size) + 13px)');
121+
// A MINIMUM, never a fixed height — the row also carries an 18px tag pill
122+
// and the archive-confirm strip, and a fixed height clips both.
123+
expect(rowStyle).not.toMatch(/(^|[^-])height:\s*calc/u);
124+
expect(rowStyle).toContain('box-sizing: border-box');
125+
expect(titleStyle).toContain('font-size: calc(var(--ui-font-size) - 1px)');
126+
expect(sessionRowStyle).toContain('Default 14px: 14 + 13 = 27px; 14 - 1 = 13px.');
127+
});
128+
129+
it('keeps the selected row title visibly bolder', () => {
130+
const wrapper = row({}, { active: true });
131+
132+
expect(wrapper.find('.se').classes()).toContain('on');
133+
expect(declarations(sessionRowStyle, '.se.on .t')).toContain('font-weight: 500');
134+
});
135+
136+
it('keeps the SessionRow free of dark utilities and new color literals', () => {
137+
// The one literal that predates the token migration. Anything else is a new
138+
// hardcoded colour, which breaks two of the three themes. Listed explicitly
139+
// rather than diffed against git, so the check still bites after it lands.
140+
const allowed = new Set(['rgba(0,0,0,0.08)']);
141+
const colorLiterals = sessionRowSource.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/giu) ?? [];
142+
143+
expect(sessionRowSource).not.toMatch(/\bdark:/u);
144+
expect(colorLiterals.filter((literal) => !allowed.has(literal))).toEqual([]);
145+
});
146+
});
147+
148+
describe('Sidebar section heading styling', () => {
149+
it('uses a quiet relative size for the workspace section label', () => {
150+
const heading = declarations(sidebarStyle, '.ws-head-label');
151+
152+
expect(heading).toContain('color: var(--muted)');
153+
expect(heading).toContain('font-size: calc(var(--ui-font-size) - 2px)');
154+
});
155+
});

0 commit comments

Comments
 (0)