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' ;
79import { mount } from '@vue/test-utils' ;
810import { createI18n } from 'vue-i18n' ;
911import { 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 ( / < s t y l e s c o p e d > ( [ \s \S ] * ?) < \/ s t y l e > / 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 ( / < s t y l e s c o p e d > ( [ \s \S ] * ?) < \/ s t y l e > / 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+
2450function 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 ( / ( ^ | [ ^ - ] ) h e i g h t : \s * c a l c / 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 - 9 a - f ] { 3 , 8 } | r g b a ? \( [ ^ ) ] * \) / giu) ?? [ ] ;
142+
143+ expect ( sessionRowSource ) . not . toMatch ( / \b d a r k : / 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