1818 :title =" app.name"
1919 role =" menuitem" >
2020 <span class =" app-item__circle" >
21- <img
21+ <span
22+ v-if =" app.icon"
2223 class =" app-item__icon"
23- :src =" app.icon"
24- alt =" "
25- aria-hidden =" true" >
24+ :style =" iconStyle"
25+ aria-hidden =" true" />
2626 <span
2727 v-if =" app.unread"
2828 class =" app-item__unread"
@@ -57,6 +57,11 @@ const props = withDefaults(defineProps<{
5757 tabindex: - 1 ,
5858})
5959
60+ // Escaped so a crafted path cannot break out of the url() token.
61+ const iconStyle = computed (() => ({
62+ ' --app-item-icon-url' : ` url("${props .app .icon .replace (/ ["\\ ] / g , ' \\ $&' )}") ` ,
63+ }))
64+
6065const unreadLabel = computed (() => {
6166 if (! props .app .unread ) {
6267 return undefined
@@ -72,63 +77,91 @@ const unreadLabel = computed(() => {
7277 </script >
7378
7479<style scoped lang="scss">
80+ $bevel :
81+ inset 0 -1px 0 0 color-mix (in srgb , var (--color-primary-element-light ), 10% var (--color-primary-element )),
82+ inset 0 -4px 6px -4px color-mix (in srgb , var (--color-primary-element-light ), 16% var (--color-primary-element ));
83+
7584.app-item {
76- --app-item-circle-size : calc (var (--default-grid-baseline ) * 10 );
77- --app-item-icon-size : 22px ;
85+ --app-item-circle-size : calc (var (--default-grid-baseline ) * 12 );
86+ // 28px on a 48px circle, so it follows when the circle is resized.
87+ --app-item-icon-size : calc (var (--app-item-circle-size ) * 7 / 12 );
88+ --app-item-bevel : #{$bevel } ;
7889 display : flex ;
7990 flex-direction : column ;
8091 align-items : center ;
8192 gap : var (--default-grid-baseline );
82- // Inset so the hover/focus highlight floats around the circle and label
83- // rather than sitting flush against the icon at the top edge.
93+ // Keeps the grown circle and the focus ring off the tile's edge.
8494 padding-block : var (--default-grid-baseline );
8595 border-radius : var (--border-radius-element );
8696 text-decoration : none ;
8797 color : var (--color-main-text );
8898 min-width : 0 ;
8999
90- & :hover ,
91- & :focus-visible {
92- background-color : var (--color-background-hover );
93- }
94-
95100 // Inset ring instead of outline + offset: the offset version visibly
96101 // clips at the popover's rounded edge for items in the first/last row
97- // or column. The inset shadow stays inside the highlight rectangle .
102+ // or column. The inset shadow stays inside the tile's own bounds .
98103 & :focus-visible {
99104 outline : none ;
100105 box-shadow : inset 0 0 0 2px var (--color-primary-element );
101106 }
102107
108+ & :hover ,
109+ & :focus-visible {
110+ --app-item-circle-scale : 1.08 ;
111+ }
112+
113+ & :active {
114+ --app-item-circle-scale : 0.96 ;
115+ }
116+
117+ @media (prefers-color-scheme : dark ) {
118+ --app-item-bevel : none ;
119+ }
120+
103121 & __circle {
104122 box-sizing : border-box ;
105123 position : relative ;
124+ display : flex ;
125+ align-items : center ;
126+ justify-content : center ;
106127 width : var (--app-item-circle-size );
107128 height : var (--app-item-circle-size );
108129 border-radius : 50% ;
109- background-color : var (--color-primary-element );
130+ transform : scale (var (--app-item-circle-scale , 1 ));
131+ transition : transform var (--animation-quick ) ease-out ;
132+ background-color : var (--color-primary-element-light );
110133 background-image : linear-gradient (
111134 to bottom ,
112- rgba (255 , 255 , 255 , 0.18 ) 0% ,
113- rgba (255 , 255 , 255 , 0 ) 45% ,
114- rgba (0 , 0 , 0 , 0.15 ) 100%
135+ color-mix (in srgb , var (--color-primary-element-light ), 15% var (--color-main-background )) 0% ,
136+ var (--color-primary-element-light ) 100%
115137 );
116- box-shadow :
117- inset 0 1px 0 0 rgba (255 , 255 , 255 , 0.25 ),
118- inset 0 -1px 0 0 rgba (0 , 0 , 0 , 0.2 ),
119- 0 2px 4px rgba (0 , 0 , 0 , 0.15 );
120- display : flex ;
121- align-items : center ;
122- justify-content : center ;
138+ box-shadow : var (--app-item-bevel );
139+
140+ @media (prefers-reduced-motion : reduce) {
141+ transition : none ;
142+ }
123143 }
124144
125145 & __icon {
126146 width : var (--app-item-icon-size );
127147 height : var (--app-item-icon-size );
128- // App icons are bright by default; flip them to dark when the
129- // primary color (circle background) is bright (e.g. white in dark mode).
130- filter : var (--primary-invert-if-bright );
131- mask : var (--header-menu-icon-mask );
148+ // Masked rather than shown: app icons ship a hardcoded fill, so
149+ // currentColor never applies and a filter could only flip black and white.
150+ background-color : var (--color-primary-element );
151+ background-image : linear-gradient (
152+ to bottom ,
153+ color-mix (in srgb , var (--color-primary-element ), 28% var (--color-primary-element-light )) 0% ,
154+ var (--color-primary-element ) 100%
155+ );
156+ mask : var (--app-item-icon-url ) center / contain no-repeat ;
157+ }
158+
159+ // Masked backgrounds are not force-adjusted the way <img> is.
160+ @media (forced-colors : active ) {
161+ & __icon {
162+ background-color : CanvasText;
163+ background-image : none ;
164+ }
132165 }
133166
134167 & __unread {
@@ -169,8 +202,17 @@ const unreadLabel = computed(() => {
169202 }
170203
171204 & --outlined & __icon {
172- filter : var (--background-invert-if-dark );
173- mask : none ;
205+ background-color : var (--color-main-text );
206+ background-image : none ;
174207 }
175208}
209+
210+ // An explicit theme choice must beat the media query above, which only sees the OS.
211+ :global([data-themes *= dark ] .app-item ) {
212+ --app-item-bevel : none ;
213+ }
214+
215+ :global([data-themes *= light ] .app-item ) {
216+ --app-item-bevel : #{$bevel } ;
217+ }
176218 </style >
0 commit comments