diff --git a/.gitattributes b/.gitattributes index 638e943..5fc750e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,3 +17,6 @@ gradlew text eol=lf *.png binary *.webp binary *.ico binary +# Bundled type families (res/font). `text=auto` already detects these as binary, +# but a font is too easy to corrupt silently to leave to a heuristic. +*.ttf binary diff --git a/app/licenses/EBGaramond-OFL.txt b/app/licenses/EBGaramond-OFL.txt new file mode 100644 index 0000000..c1ec5e1 --- /dev/null +++ b/app/licenses/EBGaramond-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/licenses/IMFellEnglish-OFL.txt b/app/licenses/IMFellEnglish-OFL.txt new file mode 100644 index 0000000..1bf2ab0 --- /dev/null +++ b/app/licenses/IMFellEnglish-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, Igino Marini (mail@iginomarini.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/licenses/Inter-OFL.txt b/app/licenses/Inter-OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/app/licenses/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/licenses/Merriweather-OFL.txt b/app/licenses/Merriweather-OFL.txt new file mode 100644 index 0000000..17d2c51 --- /dev/null +++ b/app/licenses/Merriweather-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/licenses/PlayfairDisplay-OFL.txt b/app/licenses/PlayfairDisplay-OFL.txt new file mode 100644 index 0000000..1315389 --- /dev/null +++ b/app/licenses/PlayfairDisplay-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/licenses/SourceSans3-OFL.txt b/app/licenses/SourceSans3-OFL.txt new file mode 100644 index 0000000..80b9a8b --- /dev/null +++ b/app/licenses/SourceSans3-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/licenses/WorkSans-OFL.txt b/app/licenses/WorkSans-OFL.txt new file mode 100644 index 0000000..d3a046c --- /dev/null +++ b/app/licenses/WorkSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/java/com/runicgateway/app/MainActivity.kt b/app/src/main/java/com/runicgateway/app/MainActivity.kt index 4139f87..2a22b4b 100644 --- a/app/src/main/java/com/runicgateway/app/MainActivity.kt +++ b/app/src/main/java/com/runicgateway/app/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier import com.runicgateway.app.core.auth.sso.SsoAuthManager import com.runicgateway.app.core.push.PushNotifier import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.ui.AppViewModel import com.runicgateway.app.ui.AppViewModel.AppState @@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver import com.runicgateway.app.ui.RunicApp import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.connect.ConnectScreen +import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.ui.theme.RunicGatewayTheme -import com.runicgateway.app.ui.theme.parseBrandColor import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,8 +40,8 @@ import javax.inject.Inject /** * Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run * connect screen until a shard site is configured (§3), then the main app. - * The Material theme is seeded from the per-shard brand accent, and asset-path - * resolution is provided to the whole tree. + * The Material theme is resolved from the shard's published appearance (M12), + * and asset-path resolution is provided to the whole tree. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -69,9 +70,21 @@ class MainActivity : ComponentActivity() { val appViewModel: AppViewModel = hiltViewModel() val state by appViewModel.state.collectAsStateWithLifecycle() - val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) } + // The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the + // resolved token map is applied field by field over the shipped palette, + // so NONE — before the site is connected, or when settings can't be + // read — is the app exactly as it shipped. + val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE - RunicGatewayTheme(accent = accent) { + // The admin's theme and nav can change while the app is backgrounded + // (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session + // re-validation RunicApp already does. Best-effort and silent. + LifecycleResumeEffect(Unit) { + appViewModel.refreshAppearance() + onPauseOrDispose { } + } + + RunicGatewayTheme(appearance = appearance) { CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) { Surface( modifier = Modifier.fillMaxSize(), @@ -83,7 +96,7 @@ class MainActivity : ComponentActivity() { ConnectScreen(onConnected = appViewModel::onConnected) is AppState.Ready -> RunicApp( - brand = s.brand, + appearance = s.appearance, onChangeServer = appViewModel::changeServer, deepLinkStream = pendingStream, onDeepLinkConsumed = { pendingStream = null }, diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt index 52655c9..3d69e7a 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt @@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement /** * DTOs for the public site/identity endpoints. Shapes mirror the backend @@ -80,4 +81,28 @@ data class SettingsDto( val brand: BrandDto = BrandDto(), /** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */ val push: PushConfigDto = PushConfigDto(), + /** + * The admin's **resolved** theme tokens — the CSS custom properties the site + * paints, already layered `:root ← preset ← custom` by the server + * (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which + * means "the shipped defaults" and is the untouched-instance path. + * + * Held as a raw [JsonElement] rather than a `Map` on + * purpose: a single unexpected value must not fail the decode of the whole + * settings payload and take `brand` and `push` down with it. It is coerced + * field-by-field by `SiteAppearance.from`. + * + * The raw `theme_visual` / `brand_assets` rows ride along in this same + * response and are deliberately **not** modeled — they are inputs, and + * re-deriving a palette from them would be a second `resolveThemeTokens` in + * Kotlin, guaranteed to drift (§3). + */ + val theme: JsonElement? = null, + /** + * The public nav overrides, as the raw JSON **string** stored in + * `settings.value` (TEXT) — so it is parsed a second time, exactly as the web + * client's `parseJsonSetting` does. Absent when the admin never edited the + * nav. + */ + @SerialName("nav_public") val navPublic: String? = null, ) diff --git a/app/src/main/java/com/runicgateway/app/data/appearance/SettingsJson.kt b/app/src/main/java/com/runicgateway/app/data/appearance/SettingsJson.kt new file mode 100644 index 0000000..a8d0670 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/appearance/SettingsJson.kt @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.appearance + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +/** + * Parse a JSON-valued settings row, client side — the second stage of decoding + * `nav_public` (THEMING_AND_NAV.md §3). + * + * The Kotlin counterpart to the web client's `lib/settingsJson.js`, and + * deliberately the same three lines of judgement: `settings.value` is TEXT, so + * the row arrives as a **string inside** the already-decoded settings object, + * and a malformed or wrong-shaped one must read as **absent** — the surface + * falls back to the coded default — never as an error and never as a + * half-applied object. + */ +private val settingsJson = Json { ignoreUnknownKeys = true } + +/** + * @param raw the raw stored value, as it arrived in the settings payload + * @return the parsed object, or null when absent/malformed + */ +fun parseJsonSetting(raw: String?): JsonObject? { + if (raw.isNullOrEmpty()) return null + val parsed = try { + settingsJson.parseToJsonElement(raw) + } catch (_: SerializationException) { + return null + } + // Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to + // every consumer of these keys as a syntax error is. + return parsed as? JsonObject +} diff --git a/app/src/main/java/com/runicgateway/app/data/appearance/SiteAppearance.kt b/app/src/main/java/com/runicgateway/app/data/appearance/SiteAppearance.kt new file mode 100644 index 0000000..806004d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/appearance/SiteAppearance.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.appearance + +import com.runicgateway.app.data.api.dto.BrandDto +import com.runicgateway.app.data.api.dto.SettingsDto +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Everything the app renders itself with that the shard's admin controls + * (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the + * public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel], + * so the theme and the drawer can never disagree about which shard they are showing. + * + * **[NONE] is the shipped app.** An instance with no settings rows, a backend + * that predates the feature, and a settings call that failed outright are all the + * same state here, and all three must render exactly as the app did before this + * milestone existed (§2). That is why nothing on this class is nullable except + * [brand], which was already nullable and whose absence already meant "use the + * bundled strings". + */ +data class SiteAppearance( + /** The per-shard branding block; null when settings couldn't be loaded. */ + val brand: BrandDto? = null, + /** + * The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`). + * Empty means "the shipped defaults" — the server never emits an empty map, + * but absent and empty are the same thing to the app and it must not depend + * on that. + */ + val theme: Map = emptyMap(), + /** + * The parsed `nav_public` row, or null when the admin never edited the nav. + * Kept as the raw object here; reading `items` / `sections` / `links` out of + * it is the job of the phases that render them. + */ + val navPublic: JsonObject? = null, +) { + companion object { + /** The shipped app: no brand, no overrides. Also what a failed load means. */ + val NONE = SiteAppearance() + + /** + * Build the appearance from a `GET /public/settings` body. Forgiving + * field by field (§2): a bad `--accent` must not discard a good `--bg` + * beside it, and a malformed `nav_public` must not cost the theme. + */ + fun from(settings: SettingsDto?): SiteAppearance { + if (settings == null) return NONE + return SiteAppearance( + brand = settings.brand, + theme = themeTokens(settings.theme as? JsonObject), + navPublic = parseJsonSetting(settings.navPublic), + ) + } + + // Every themable token is a string server-side (validated on write, and + // resolveThemeTokens only ever copies a validated value). Anything else + // is dropped rather than coerced, so an unexpected value costs exactly + // its own token and the rest of the palette still applies. + private fun themeTokens(raw: JsonObject?): Map { + if (raw.isNullOrEmpty()) return emptyMap() + return buildMap { + for ((token, value) in raw) { + val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content + if (!text.isNullOrBlank()) put(token, text) + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt index 885a397..ca6d7f9 100644 --- a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt @@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.push.PushManager import com.runicgateway.app.core.result.ApiResult -import com.runicgateway.app.data.api.dto.BrandDto +import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.data.repository.ConnectionRepository import com.runicgateway.app.data.repository.SettingsRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -20,8 +20,8 @@ import javax.inject.Inject /** * Top-level app gate (PLAN.md §3): decides whether the first-run connect screen - * or the main UI shows, and holds the per-shard branding the theme is seeded - * from. Activity-scoped so the whole app observes one state. + * or the main UI shows, and holds the per-shard [SiteAppearance] the theme and + * the drawer are built from. Activity-scoped so the whole app observes one state. */ @HiltViewModel class AppViewModel @Inject constructor( @@ -38,8 +38,11 @@ class AppViewModel @Inject constructor( /** No shard site configured yet — show the connect screen. */ data object NeedsConnection : AppState - /** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */ - data class Ready(val brand: BrandDto?) : AppState + /** + * A site is configured. [appearance] is [SiteAppearance.NONE] when settings + * couldn't be loaded — the shipped app, still fully usable (§2). + */ + data class Ready(val appearance: SiteAppearance) : AppState } private val _state = MutableStateFlow(AppState.Loading) @@ -48,7 +51,7 @@ class AppViewModel @Inject constructor( init { viewModelScope.launch { _state.value = if (connectionRepository.restore()) { - AppState.Ready(loadBrand()) + AppState.Ready(loadAppearance()) } else { AppState.NeedsConnection } @@ -57,7 +60,30 @@ class AppViewModel @Inject constructor( /** Called by the connect screen once a site has been validated + saved. */ fun onConnected() { - viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) } + viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) } + } + + /** + * Re-read the appearance while the app is already running — on resume, beside + * the session's own re-validation (§5.5). An admin who re-skins the site from + * a laptop and picks the phone up should see it. + * + * Best-effort, and silent either way: a failed refresh **keeps the last good + * appearance** rather than dropping back to the shipped one, so a moment of + * no connectivity does not repaint a themed shard. There is no loading state + * and no error surface. Ignored unless a site is configured. + */ + fun refreshAppearance() { + if (_state.value !is AppState.Ready) return + viewModelScope.launch { + val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch + pushManager.setNtfyUrl(settings.push.ntfyUrl) + // changeServer() may have raced us back to the connect screen while the + // call was in flight; don't resurrect Ready on top of it. + if (_state.value is AppState.Ready) { + _state.value = AppState.Ready(SiteAppearance.from(settings)) + } + } } /** Settings → Server switch: hard reset back to the connect screen (§3). */ @@ -69,14 +95,14 @@ class AppViewModel @Inject constructor( } /** - * Load public settings for branding and feed the shard's push relay URL into the - * [PushManager] (§11) — its arrival is what lets push re-register after a restart - * or sign-in. Returns the brand block (null if settings couldn't be loaded). + * Load public settings for the appearance and feed the shard's push relay URL into + * the [PushManager] (§11) — its arrival is what lets push re-register after a restart + * or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded. */ - private suspend fun loadBrand(): BrandDto? { + private suspend fun loadAppearance(): SiteAppearance { val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data pushManager.setNtfyUrl(settings?.push?.ntfyUrl) - return settings?.brand + return SiteAppearance.from(settings) } /** diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index ddcc9a0..059b530 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemColors import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -48,17 +52,23 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.runicgateway.app.R import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.web.WebHandoff import com.runicgateway.app.data.api.dto.BrandDto +import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen import com.runicgateway.app.ui.auth.TrustedDevicesScreen import com.runicgateway.app.ui.auth.roleLabelRes +import com.runicgateway.app.ui.components.BrandLogo import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.navigation.APP_MENU +import com.runicgateway.app.ui.navigation.NavNode import com.runicgateway.app.ui.navigation.Routes -import com.runicgateway.app.ui.navigation.visibleEntries +import com.runicgateway.app.ui.navigation.buildNavTree +import com.runicgateway.app.ui.navigation.isEntryVisible +import com.runicgateway.app.ui.navigation.pruneNav import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen import com.runicgateway.app.ui.admin.AdminContentScreen @@ -84,6 +94,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen import com.runicgateway.app.ui.shard.RulesScreen import com.runicgateway.app.ui.shard.ShardBoard import com.runicgateway.app.ui.shard.ShardScreen +import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.wiki.WikiPageScreen import com.runicgateway.app.ui.wiki.WikiScreen import kotlinx.coroutines.launch @@ -109,13 +120,14 @@ private val TOP_LEVEL_ROUTES = setOf( @OptIn(ExperimentalMaterial3Api::class) @Composable fun RunicApp( - brand: BrandDto?, + appearance: SiteAppearance, onChangeServer: () -> Unit, modifier: Modifier = Modifier, deepLinkStream: String? = null, onDeepLinkConsumed: () -> Unit = {}, sessionViewModel: SessionViewModel = hiltViewModel(), ) { + val brand = appearance.brand val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -141,9 +153,37 @@ fun RunicApp( } val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route + // A destination's route is its NavHost *pattern*, so News reports + // "news?category={category}" (§6.2). Compare on the part before the query. + val currentRoute = backStackEntry?.destination?.route?.substringBefore('?') val isTopLevel = currentRoute in TOP_LEVEL_ROUTES - val entries = visibleEntries(APP_MENU, session, shardFeatures) + // The admin's nav overrides, then the gates — never the other way round. An + // override is presentation only: it may relabel, reorder, group and hide, so + // `pruneNav` still decides what this caller may see and remains the boundary + // (§6.1, AC-3). With no stored row the merge returns APP_MENU itself. + val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) { + isEntryVisible(it, session, shardFeatures) + } + + val context = LocalContext.current + // An added link's path is site-relative; a hand-off needs it absolute against + // the configured base URL, which is exactly what the asset resolver does (§6.3). + val resolveUrl = LocalAssetResolver.current + val openNode: (NavNode) -> Unit = { node -> + scope.launch { drawerState.close() } + when (node) { + is NavNode.Item -> navController.navigateTopLevel(node.entry.route) + // A link the app resolved opens like any other drawer row, detail screen + // or not: one rule, and back-press lands on Home as it does from every + // row. One it could not resolve goes to the browser, absolute against + // the site's base URL (§6.3). + is NavNode.Link -> node.route + ?.let { navController.navigateTopLevel(it) } + ?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) } + // Section headers aren't clickable — the group is always open (§6.3). + is NavNode.Section -> Unit + } + } ModalNavigationDrawer( drawerState = drawerState, @@ -161,6 +201,14 @@ fun RunicApp( // unreachable. See RunicGateway M10. Column(Modifier.verticalScroll(rememberScrollState())) { Spacer(Modifier.height(12.dp)) + // The instance's logo above its name (§5.6). Decorative — the name + // is the very next line — and absent on an instance that uploaded + // none, in which case the header is exactly what it was before M12. + BrandLogo( + logo = brand?.logo, + height = 32.dp, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp), + ) Text( text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, @@ -169,17 +217,30 @@ fun RunicApp( ) HorizontalDivider() Spacer(Modifier.height(8.dp)) - entries.forEach { entry -> - NavigationDrawerItem( - label = { Text(stringResource(entry.labelRes)) }, - selected = currentRoute == entry.route, - onClick = { - scope.launch { drawerState.close() } - navController.navigateTopLevel(entry.route) - }, - colors = drawerItemColors, - modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), - ) + nav.forEach { node -> + if (node is NavNode.Section) { + // A group the admin created: its label as a header, its rows + // beneath it. Always open — a drawer is already a vertical + // list, so the website's dropdown does not translate (§6.3). + Text( + text = node.label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = 28.dp, + end = 28.dp, + top = 12.dp, + bottom = 4.dp, + ), + ) + node.items.forEach { child -> + NavRow(child, currentRoute, drawerItemColors, indented = true) { + openNode(child) + } + } + } else { + NavRow(node, currentRoute, drawerItemColors) { openNode(node) } + } } HorizontalDivider(Modifier.padding(vertical = 8.dp)) @@ -203,6 +264,7 @@ fun RunicApp( } }, colors = drawerItemColors, + shape = LocalShardStructure.current.pill, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), ) NavigationDrawerItem( @@ -213,6 +275,7 @@ fun RunicApp( onChangeServer() }, colors = drawerItemColors, + shape = LocalShardStructure.current.pill, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), ) } @@ -230,13 +293,24 @@ fun RunicApp( actionIconContentColor = MaterialTheme.colorScheme.onSurface, ), title = { - Text( - text = (brand?.name?.takeIf { it.isNotBlank() } - ?: stringResource(R.string.app_name)).uppercase(), - style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + val name = brand?.name?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.app_name) + // The logo stands in for the title here, so unlike the drawer's + // it is named for a screen reader — and it falls back to the + // text when the instance has no logo or the load fails (§5.6). + BrandLogo( + logo = brand?.logo, + height = 24.dp, + contentDescription = name, + ) { + Text( + text = name.uppercase(), + style = MaterialTheme.typography.titleSmall + .copy(letterSpacing = 1.2.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } }, navigationIcon = { if (isTopLevel) { @@ -267,6 +341,62 @@ fun RunicApp( } } +/** + * One drawer row: a coded entry, or an admin's added link (§6.3). + * + * A link that the app can open natively is deliberately indistinguishable from a + * coded row — that is the point of resolving it. One that hands off to the browser + * carries a trailing icon, so leaving the app is never a surprise. + */ +@Composable +private fun NavRow( + node: NavNode, + currentRoute: String?, + colors: NavigationDrawerItemColors, + indented: Boolean = false, + onClick: () -> Unit, +) { + val route = when (node) { + is NavNode.Item -> node.entry.route + is NavNode.Link -> node.route + is NavNode.Section -> null + } + val label = when (node) { + // An admin's label wins over the bundled one, and is the same string in + // every locale — see MenuEntry.label. + is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes) + is NavNode.Link -> node.label + is NavNode.Section -> return + } + val handsOff = node is NavNode.Link && node.route == null + + NavigationDrawerItem( + label = { Text(label) }, + selected = route != null && currentRoute == route.substringBefore('?'), + onClick = onClick, + badge = if (!handsOff) { + null + } else { + { + Icon( + Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = stringResource(R.string.nav_opens_in_browser), + modifier = Modifier.size(18.dp), + ) + } + }, + colors = colors, + // Like Card's elevation, NavigationDrawerItem takes its shape as a default + // argument (CircleShape) rather than from the theme, so --radius-pill has to + // be handed to it at every call site or the selected row stays fully round + // while every other radius follows the shard (phase 8's AC-5 walk). + shape = LocalShardStructure.current.pill, + modifier = Modifier + .padding(NavigationDrawerItemDefaults.ItemPadding) + .padding(start = if (indented) 16.dp else 0.dp), + ) +} + @Composable private fun RunicNavHost( navController: NavHostController, @@ -284,7 +414,19 @@ private fun RunicNavHost( composable(Routes.HOME) { HomeScreen(brand = brand) } - composable(Routes.NEWS) { + // The category is optional: navigating to plain Routes.NEWS matches this + // pattern with no argument and opens the default tab, which is every route + // into the screen except an admin's nav override or added link (§6.2). + composable( + route = Routes.NEWS_ROUTE, + arguments = listOf( + navArgument(Routes.Args.CATEGORY) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { NewsScreen(onOpenPost = { category, idOrSlug -> navController.navigate(Routes.post(category, idOrSlug)) }) diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt index 01aab33..35740f5 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -46,6 +45,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -139,7 +139,7 @@ private fun PostsTab( } } items(state.data, key = { it.id }) { post -> - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.padding(12.dp)) { Text(post.title, style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(4.dp)) @@ -190,7 +190,7 @@ private fun WikiTab( } } items(state.data, key = { it.id }) { cat -> - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.padding(12.dp)) { Text(cat.title, style = MaterialTheme.typography.bodyLarge) Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt index 5a7eb91..9bafad0 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** * The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close, @@ -100,7 +100,7 @@ private fun SupportPageCard( onReply: () -> Unit, onClose: () -> Unit, ) { - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.padding(12.dp)) { val who = page.sender?.name ?: page.sender?.account ?: page.pageId Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index f771c93..d3daebb 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -107,7 +107,7 @@ fun AccountScreen( @Composable private fun IdentityCard(username: String, roleLabel: String) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(20.dp)) { Text(text = username, style = MaterialTheme.typography.titleLarge) StatusPill( @@ -154,7 +154,7 @@ private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCode @Composable private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { - Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) { Column(Modifier.padding(16.dp)) { Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium) content() diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt index 4c518f6..21f3e98 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -38,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ShardCard /** * Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a @@ -117,7 +117,7 @@ fun RecoveryCodesShowOnceCard(codes: List, onDismiss: () -> Unit) { val clipboard = LocalClipboardManager.current val joined = remember(codes) { codes.joinToString("\n") } - Card(Modifier.fillMaxWidth().padding(top = 16.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium) Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt index c8e45fb..907a61d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -30,6 +29,7 @@ import com.runicgateway.app.data.api.dto.TrustedDeviceDto import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** * Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip @@ -108,7 +108,7 @@ fun TrustedDevicesScreen( @Composable private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) { - Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) { Row( Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/runicgateway/app/ui/components/BrandAssets.kt b/app/src/main/java/com/runicgateway/app/ui/components/BrandAssets.kt new file mode 100644 index 0000000..1effaa6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/components/BrandAssets.kt @@ -0,0 +1,157 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.runicgateway.app.ui.LocalAssetResolver + +/** + * The two brand assets an instance can upload — the logo and the hero + * (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since + * M1 and neither has ever been drawn; the app has always spelled the instance + * out in text wherever the website shows a mark. + * + * **The rule that governs this whole file: an empty slot renders nothing.** Not + * a placeholder, not a reserved gap, not the app's own emblem — an instance + * that has uploaded no logo must lay out exactly as it did before this phase + * existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens + * with the same `if (!brand.logo) return null`. + * + * **A failed load is an empty slot.** No broken-image icon and no retry: an + * asset that 404s, or that can't be reached because the shard is down, must + * degrade to the same layout as an instance that never uploaded one. That is + * why nothing here reserves its space up front — every size modifier hangs off + * the image itself, so when the image isn't composed neither is its padding. + * A caller that wants space *below* a hero passes it as `Modifier.padding` + * rather than a sibling `Spacer`, and gets both cases right for free. + */ + +/** + * Widest a logo may draw, as a multiple of its height. Mirrors the website's + * `maxWidth: height * 6` — an operator who uploads a long wordmark gets it + * scaled down rather than pushing the drawer header or the top bar's title out + * of shape. + */ +private const val LOGO_MAX_ASPECT = 6f + +/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */ +private val HERO_HEIGHT = 180.dp + +/** + * The instance's uploaded logo at [height], or [fallback] when there is none. + * + * [fallback] defaults to drawing nothing, which is what the drawer header wants: + * the instance name sits directly below it, so an instance with no logo simply + * has the name where it has always been. The top bar passes the name itself, + * because there the logo *replaces* the title — leaving that blank on a failed + * load would strand the app in an unnamed shell until the next resume refresh, + * and "a failed load is an empty slot" means the slot falls back to whatever + * empty would have shown, which for the top bar is the text. + * + * There is deliberately no fallback while the load is still in flight. Drawing + * the text first would flash text → logo on every navigation for the sake of + * one frame, since Coil serves the second and later reads from its memory cache. + * + * Pass [contentDescription] only where the logo stands alone. Beside or above + * the name in text it is decorative, and describing it would have a screen + * reader say the instance's name twice — the same call the website's `alt=''` + * makes. + */ +@Composable +fun BrandLogo( + logo: String?, + height: Dp, + modifier: Modifier = Modifier, + contentDescription: String? = null, + fallback: @Composable () -> Unit = {}, +) { + val url = brandAssetUrl(logo, LocalAssetResolver.current) + // Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets + // a fresh attempt rather than inheriting the old one's failure. + var failed by remember(url) { mutableStateOf(false) } + + if (url == null || failed) { + fallback() + return + } + AsyncImage( + model = url, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + onError = { failed = true }, + modifier = modifier + .height(height) + .widthIn(max = height * LOGO_MAX_ASPECT), + ) +} + +/** + * The instance's hero image as a full-width band above Home's title block, or + * nothing when there is none. + * + * **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's + * other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a + * CSS background driven by `hero_layout`, which the app does not port, so the + * app needs its own rule — and the website's *default* hero is a square emblem, + * so an uploaded square is a case to expect rather than an edge one. At the + * intrinsic aspect that square would be a ~360dp block that pushes the status + * card off the first screenful; cropped to a band, a wide banner and a square + * both give the same frame above the title. + * + * Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card` + * like every other surface the admin can round off (§5.2). + * + * Decorative: Home spells the instance's name and tagline out in text directly + * below, so the hero carries no content description. + */ +@Composable +fun BrandHero(hero: String?, modifier: Modifier = Modifier) { + val url = brandAssetUrl(hero, LocalAssetResolver.current) + var failed by remember(url) { mutableStateOf(false) } + + if (url == null || failed) return + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + onError = { failed = true }, + modifier = modifier + .fillMaxWidth() + .height(HERO_HEIGHT) + .clip(MaterialTheme.shapes.medium), + ) +} + +/** + * Resolve a brand asset slot to a loadable URL, or null when the slot is empty. + * + * The blank check has to happen on **both** sides of [resolve]: `BrandDto` + * defaults every asset field to `""` rather than null (the server publishes the + * empty string for "not set"), and a resolver given a path it cannot make + * absolute may hand one straight back. Null out of here is the signal for "draw + * nothing", so a blank slipping through would put a zero-size image request in + * the layout instead of no image at all. + * + * Pulled out of the composables purely so it can be tested: the app has no + * Robolectric, so a composable body cannot run in a JVM unit test, but this rule + * is the whole of §5.6's "renders nothing when unset" and it is worth pinning. + */ +internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? = + path?.takeIf { it.isNotBlank() } + ?.let(resolve) + ?.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt b/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt index 15ccc19..e463376 100644 --- a/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt +++ b/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt @@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.runicgateway.app.ui.theme.ShardCardBottom -import com.runicgateway.app.ui.theme.ShardCardTop +import com.runicgateway.app.ui.theme.LocalShardPalette +import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.theme.ShardDanger import com.runicgateway.app.ui.theme.ShardDangerBg -import com.runicgateway.app.ui.theme.ShardElevated -import com.runicgateway.app.ui.theme.ShardFaint -import com.runicgateway.app.ui.theme.ShardOutline -import com.runicgateway.app.ui.theme.ShardPillBg -import com.runicgateway.app.ui.theme.ShardPillFg import com.runicgateway.app.ui.theme.ShardSuccess import com.runicgateway.app.ui.theme.ShardSuccessBg import com.runicgateway.app.ui.theme.ShardSuccessDot @@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg * (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card, * and stat-bar motifs the mockup repeats across screens. Pure presentation — * no state, no data dependencies — so any screen can adopt them. + * + * This is the app's **only** file that reaches past `MaterialTheme` for a + * themable value, so it is the one place M12 had to migrate: the surface, line + * and accent tokens come from [LocalShardPalette] and the pill shape and card + * depth from [LocalShardStructure], both following the shard's theme + * (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants + * stay imported directly — those are semantic and never themed, mirroring the + * server's `FIXED_TOKENS`. */ /** Semantic tone for a [StatusPill] / [OnlineDot]. */ @@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info } private data class PillColors(val fg: Color, val bg: Color) +@Composable private fun toneColors(tone: PillTone): PillColors = when (tone) { PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg) PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg) PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg) - PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg) + PillTone.Neutral, PillTone.Info -> + LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) } } /** * A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a * rounded filled background tinted by [tone]. Mirrors the mockup's pill badges. + * + * The one place `--radius-pill` lands: the app's other two [CircleShape] uses are + * 8dp status dots, and a dot stays a dot however square the shard makes its site. */ @Composable fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) { val c = toneColors(tone) - Surface(color = c.bg, shape = CircleShape, modifier = modifier) { + Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) { Text( text = text.uppercase(), style = MaterialTheme.typography.labelSmall, @@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) { PillTone.Success -> ShardSuccessDot PillTone.Warning -> ShardWarning PillTone.Danger -> ShardDanger - PillTone.Neutral, PillTone.Info -> ShardFaint + PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint } Box(modifier.size(8.dp).clip(CircleShape).background(color)) } @@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) { Text( text = text.uppercase(), style = MaterialTheme.typography.labelSmall, - color = ShardFaint, + color = LocalShardPalette.current.faint, modifier = modifier, ) } @@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) { * The elevated "feature" card: a vertical blue gradient with a hairline outline and * soft shadow, used for the home status card, the shard-online banner, and the * vendor card. [content] is laid out in a padded [Column]. + * + * The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was + * built with — the same value, now following `--radius-card`'s ratio (§5.2). The + * shadow this doc always claimed is finally drawn, at the depth `--shadow-card` + * resolves to (§5.4). */ @Composable fun FeatureCard( @@ -111,17 +127,40 @@ fun FeatureCard( contentPadding: Int = 18, content: @Composable ColumnScope.() -> Unit, ) { + val palette = LocalShardPalette.current + val shape = MaterialTheme.shapes.medium Box( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom))) - .border(1.dp, ShardOutline, RoundedCornerShape(12.dp)), + .shadow(LocalShardStructure.current.cardElevation, shape) + .clip(shape) + .background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom))) + .border(1.dp, palette.outline, shape), ) { Column(Modifier.padding(contentPadding.dp), content = content) } } +/** + * A Material [Card] at the shard's resolved depth — the app's standard card, and + * the reason every screen's `Card(` became a `ShardCard(`. + * + * `Card` takes its elevation as a **default argument**, not from the theme, so + * unlike the color scheme and the shape scale there is no way to make + * `--shadow-card` reach ~24 call sites without a wrapper. Passing + * [CardDefaults.cardElevation] at each site instead would have put the same line + * in eighteen files and let one drift. A `Card(` outside this file is therefore a + * card the shard cannot theme, which makes the invariant greppable. + */ +@Composable +fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = modifier, + elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation), + content = content, + ) +} + /** * A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is * the slate accent over a bordered dark track. @@ -129,13 +168,14 @@ fun FeatureCard( @Composable fun StatBar(fraction: Float, modifier: Modifier = Modifier) { val pct = fraction.coerceIn(0f, 1f) + val palette = LocalShardPalette.current Box( modifier = modifier .fillMaxWidth() .height(6.dp) .clip(RoundedCornerShape(3.dp)) - .background(ShardElevated) - .border(1.dp, ShardOutline, RoundedCornerShape(3.dp)), + .background(palette.elevated) + .border(1.dp, palette.outline, RoundedCornerShape(3.dp)), ) { Box( Modifier diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt index d841497..3fecd9e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt @@ -26,6 +26,7 @@ import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.BrandHero import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.FeatureCard import com.runicgateway.app.ui.components.LoadingView @@ -59,6 +60,12 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier .verticalScroll(rememberScrollState()) .padding(20.dp), ) { + // The instance's hero above the title block (§5.6) — Home is the one screen + // with a hero-shaped space. Its bottom gap rides on the image's own modifier + // rather than a Spacer, so an instance with no hero (or one whose hero fails + // to load) opens on the title exactly where it has always been. + BrandHero(hero = brand?.hero, modifier = Modifier.padding(bottom = 16.dp)) + Text( text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium, diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 994a9fb..56a9071 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -52,6 +52,15 @@ data class MenuEntry( * isn't shard-derived and only [access] applies. */ val feature: String? = null, + /** + * An admin's own label for this row, from the shard's `nav_public` override + * (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands. + * + * A label set this way is **not localized**: it is one string for every locale, + * which is what an admin typing a label means, and it matches the website. It + * only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it. + */ + val label: String? = null, ) /** @@ -99,14 +108,26 @@ fun visibleEntries( entries: List, session: Session, features: ShardFeatures? = null, -): List = - entries.filter { entry -> - val allowedByRole = when (entry.access) { - MenuAccess.PUBLIC -> true - MenuAccess.SIGNED_IN -> session is Session.SignedIn - MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff) - MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff - MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator - } - allowedByRole && (entry.feature == null || canSee(features, entry.feature)) +): List = entries.filter { isEntryVisible(it, session, features) } + +/** + * [visibleEntries] for a single entry — the same two filters, and the same + * boundary. Split out because the drawer is a tree once an admin groups rows into + * sections (§6.3): [pruneNav] applies this predicate inside a section as well, and + * both callers must ask exactly one question or a sectioned row could be gated by + * a rule its top-level twin is not. + */ +fun isEntryVisible( + entry: MenuEntry, + session: Session, + features: ShardFeatures? = null, +): Boolean { + val allowedByRole = when (entry.access) { + MenuAccess.PUBLIC -> true + MenuAccess.SIGNED_IN -> session is Session.SignedIn + MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff) + MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff + MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator } + return allowedByRole && (entry.feature == null || canSee(features, entry.feature)) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt new file mode 100644 index 0000000..5ba3a89 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt @@ -0,0 +1,171 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull + +/** + * Apply the admin's stored public-nav overrides to the app's coded menu + * (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's + * `client/src/lib/navOverrides.js`, narrowed to what a drawer can express. + * + * **This is presentation, never authorization.** An override carries `label`, + * `order` and `hidden` and nothing else: it cannot introduce a route, cannot + * touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything — + * `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs + * **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3). + * + * Fail-safe throughout, matching the web: anything unrecognized — an unknown path, + * a non-string label, a path the app doesn't surface in its menu — is ignored + * rather than rejected, so a stale or hand-edited settings row degrades to the + * coded menu instead of rendering a broken drawer. + */ + +/** A usable override for one menu row. Absent fields mean "as coded". */ +internal data class NavOverride( + val label: String? = null, + val order: Double? = null, + val hidden: Boolean = false, + /** + * The id of the section this row was dropped into, or null for a top-level + * row. Read here but honored only by the tree build (`NavTree.kt`) — the flat + * [applyNavOverrides] has nowhere to put it. Not validated against the stored + * sections here; that is the tree's job, since only it knows them. + */ + val section: String? = null, +) { + /** + * Nothing a **flat** list can express. [section] is deliberately not part of + * this: to [applyNavOverrides] a section-only override says nothing, so an + * instance that only ever grouped rows still gets its coded list back by + * identity. The tree build adds its own check. + */ + val isEmpty: Boolean get() = label == null && order == null && !hidden +} + +/** + * The `items` map out of a stored `nav_public` value. + * + * Two shapes exist, because website phase 10 added sections and links without + * migrating what phases 6-8 had already stored: `{items, sections, links}` and a + * bare map of path → override. A bare map is unambiguous — every key is a path, + * so a key can never be the string `items`. + * + * `sections` and `links` come out of the same wrapper, and only ever out of the + * wrapped shape — see [sectionsOf] and [linksOf]. + */ +internal fun itemsOf(navPublic: JsonObject?): Map { + if (navPublic == null) return emptyMap() + val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic + return items.entries + .mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } } + .toMap() +} + +/** + * The stored value as the wrapped `{items, sections, links}` shape, or null when + * it is the bare items map phases 6-8 wrote. The discriminator is the web's: an + * `items` **object**, which a bare map can never carry because every key in one is + * a path. + */ +private fun wrapperOf(navPublic: JsonObject?): JsonObject? = + navPublic?.takeIf { it["items"] is JsonObject } + +internal fun sectionsOf(navPublic: JsonObject?): List = jsonObjectsAt(navPublic,"sections") + +internal fun linksOf(navPublic: JsonObject?): List = jsonObjectsAt(navPublic,"links") + +private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List = + (wrapperOf(navPublic)?.get(key) as? JsonArray) + ?.mapNotNull { it as? JsonObject } + .orEmpty() + +// Field by field, like every other read in M12: a bad `label` must not discard a +// good `order` beside it. +// +// `group` is ignored — it names a section of the *admin sidebar*, a nav the app +// never renders, and a value it cannot honor is better dropped than half-applied. +internal fun cleanOverride(raw: JsonObject): NavOverride { + val label = (raw["label"] as? JsonPrimitive) + ?.takeIf { it.isString } + ?.content + ?.trim() + ?.takeIf { it.isNotEmpty() } + val order = (raw["order"] as? JsonPrimitive) + ?.takeIf { !it.isString } + ?.doubleOrNull + ?.takeIf { it.isFinite() } + val hidden = (raw["hidden"] as? JsonPrimitive) + ?.takeIf { !it.isString } + ?.booleanOrNull == true + val section = (raw["section"] as? JsonPrimitive) + ?.takeIf { it.isString } + ?.content + ?.takeIf { it.isNotEmpty() } + return NavOverride(label = label, order = order, hidden = hidden, section = section) +} + +/** + * [base] with the admin's overrides applied: rows relabeled, reordered and + * dropped as the stored row asks. + * + * @param base the coded menu — the only source of `route`, `access` and `feature` + * @param navPublic the parsed `nav_public` row, or null when the admin never + * edited the nav. Null, malformed, and "nothing usable in it" all return [base] + * itself, which is what makes an untouched instance's drawer provably today's + * (§2, AC-1). + */ +fun applyNavOverrides(base: List, navPublic: JsonObject?): List { + val items = itemsOf(navPublic) + if (items.isEmpty()) return base + + val coded = base.map { it.route }.toSet() + // Keyed by app route, and only for a route the coded menu actually declares. + // This is where an override for a path the app doesn't surface in its drawer — + // a news category tab, a Shard hub board — is dropped (§6.2). The web does the + // same with an unknown `to`. + val overrides = buildMap { + for ((path, raw) in items) { + val route = appRouteForWebPath(path) ?: continue + if (route !in coded) continue + val override = cleanOverride(raw) + if (!override.isEmpty) put(route, override) + } + } + if (overrides.isEmpty()) return base + + // Rows the website's nav knows about are the ones an override can move; the + // app's own surfaces (Contact, Account, the player groups, the staff rows) + // have no counterpart to be reordered against and keep their coded order, + // appended after the public block — which is exactly where they sit today, so + // this partition is the current layout rather than a new one (§6.2). + val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER } + + val sorted = mapped + // An untouched row's sort key is its index in the WEBSITE's nav, not the + // app's: a stored `order` is a position in that list, so both keys have to + // sit on one number line to be comparable at all. + // + // Two tie-breaks, the web's: an explicit order beats a coincidental index + // (the admin said "first", so first), and two explicit orders keep code + // order, because the sort is stable. + .sortedWith( + compareBy { entry -> + overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble() + }.thenByDescending { overrides[it.route]?.order != null }, + ) + + return (sorted + appOnly).mapNotNull { entry -> + val override = overrides[entry.route] ?: return@mapNotNull entry + when { + override.hidden -> null + override.label != null -> entry.copy(label = override.label) + else -> entry + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt new file mode 100644 index 0000000..381d02a --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt @@ -0,0 +1,220 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.data.repository.ContentRepository.PostCategory + +/** + * The website path → app route table (THEMING_AND_NAV.md §6.2). + * + * The public nav an admin edits is keyed by **website** paths, so honoring it in + * the app needs a translation. This is the one new piece of cross-repo coupling + * the milestone introduces, which is why it lives in a single file with the + * website's own array quoted right beside it — the coupling is visible and + * reviewable in one place rather than spread across the drawer's call sites. + * + * Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the + * exported owner of the list (`export const NAV`, and Admin → Navigation edits + * exactly it): + * + * ```js + * export const NAV = [ + * { label: 'Home', to: '/', end: true }, + * { label: 'News', to: '/site/news' }, + * { label: 'Screenshots', to: '/site/screenshots' }, + * { label: 'Five on Friday', to: '/site/five-on-friday' }, + * { label: 'Newsletter', to: '/site/newsletter' }, + * { label: 'Wiki', to: '/wiki' }, + * { label: 'Shard', to: '/site/shard', feature: 'status' }, + * { label: 'Champions', to: '/site/champs', feature: 'champs' }, + * { label: 'Guilds', to: '/site/guilds', feature: 'guilds' }, + * { label: 'Governors', to: '/site/governors', feature: 'governors' }, + * { label: 'Houses', to: '/site/houses', feature: 'houses' }, + * { label: 'Rules', to: '/site/rules', feature: 'ruleset' }, + * { label: 'Atlas', to: '/site/atlas', feature: 'atlas' }, + * { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' }, + * { label: 'Market', to: '/site/market', feature: 'market' }, + * { label: 'About', to: '/site/about' }, + * ] + * ``` + * + * The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the + * app's own source of truth for gating, and a second copy of a security-relevant + * value that drifts silently is worth more than it costs. This table carries the + * mapping and nothing else. + * + * Not every row maps to something the app shows in its drawer, and that is the + * design rather than an omission — see [WEB_PATH_TO_ROUTE]. + */ + +/** One row of the website's public nav: its path, and the app route it opens. */ +data class WebNavPath(val path: String, val route: String) + +/** + * The website's public nav in **its** order, mapped to app routes. + * + * The order is load-bearing, not decorative: a stored `order` is an index into + * *this* list (the admin's editor writes the position a row holds on the web), so + * a row the admin never moved has to take its key from the same number line or + * explicit and implicit keys would be incomparable. See `NavOverrides.kt`. + */ +val WEBSITE_PUBLIC_NAV: List = listOf( + WebNavPath("/", Routes.HOME), + WebNavPath("/site/news", Routes.NEWS), + // The app's News screen carries all four categories as tabs, so these three + // have a route but no drawer row of their own — see the note below. + WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)), + WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)), + WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)), + WebNavPath("/wiki", Routes.WIKI), + WebNavPath("/site/shard", Routes.SHARD), + // Behind the Shard hub in the app, deliberately — no drawer row either. + WebNavPath("/site/champs", Routes.SHARD_CHAMPS), + WebNavPath("/site/guilds", Routes.SHARD_GUILDS), + WebNavPath("/site/governors", Routes.SHARD_GOVERNORS), + WebNavPath("/site/houses", Routes.SHARD_HOUSES), + WebNavPath("/site/rules", Routes.SHARD_RULES), + WebNavPath("/site/atlas", Routes.ATLAS), + WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS), + WebNavPath("/site/market", Routes.SHARD_MARKET), + WebNavPath("/site/about", Routes.page("about")), +) + +/** + * The same table as a lookup. + * + * **A mapped route is not the same thing as a drawer row.** Seven of these paths + * resolve to a screen the app reaches some other way: the three news categories + * are tabs on one News screen, and champs / guilds / governors / houses sit behind + * the Shard hub because that is the better shape on a phone. An override for one + * of them is **ignored** — §6.1's rule is that a nav override may never introduce + * navigation, and the hub is a design decision, not an accident to correct. The + * merge enforces that by intersecting with [APP_MENU]; nothing here needs to know + * which rows those are. + * + * The mapping still exists for all sixteen because phase 6's added links resolve + * an admin-authored path against the same table, and *there* a category tab or a + * hub board is a perfectly good destination — the admin asked for it by path. + */ +val WEB_PATH_TO_ROUTE: Map = + WEBSITE_PUBLIC_NAV.associate { it.path to it.route } + +/** + * Each app route's index in the website's own nav order — the sort key a row the + * admin never moved takes, so it lands on the same number line as a stored + * `order`. All sixteen routes are distinct, so this loses nothing. + */ +internal val WEB_ROUTE_ORDER: Map = + WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index } + +/** + * The app route a website nav path opens, or null when the app has no screen for + * it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited + * settings row may carry one; the root path is left alone. + */ +fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)] + +/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */ +private fun normalizeWebPath(path: String?): String? { + val trimmed = path?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed + return normalized.ifEmpty { "/" } +} + +/** + * The website's top-level paths that are **not** CMS pages. + * + * The site serves its CMS pages from a top-level `/` (React Router ranks its + * static routes above that dynamic one), which is what lets [resolveWebPath]'s + * last rule open an admin-authored page natively. These are the segments that rule + * must not swallow: the SPA's own sections, and the two server mounts. A link to + * one of them hands off to the browser, which is where they actually live. + */ +private val RESERVED_TOP_LEVEL = setOf( + "admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads", +) + +/** + * The app route an **arbitrary** website path opens, or null when the app has no + * screen for it and the link must hand off to a Custom Tab (§6.3). + * + * [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this + * answers for a path an admin typed into an added link, which may name any page on + * the site. It is the app's read of the site's own route table, and like the table + * above it is cross-repo coupling kept in one file — quoted here for the same + * reason, from `website/client/src/App.jsx`: + * + * ```jsx + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market + * } /> + * } /> + * } /> + * } /> + * } /> + * // CMS pages: top-level /:slug, matched only after the named routes above + * } /> + * ``` + * + * Note what is *not* in it: no `/site/news/` (a news item renders on its + * category page; the newsletter's is the site's one post-detail route), no + * `/page/`, and no `/contact` — the app's contact form is app-only (§6.2). + * + * ``` + * / → HOME + * /site/news → NEWS + * /site/{screenshots,five-on-friday,newsletter} + * → NEWS, that category's tab + * /site/newsletter/ → POST (the site's one post-detail route) + * /wiki → WIKI + * /wiki/ → WIKI_PAGE + * /site/ → the mapped shard route (§6.2) + * /site/atlas/ → ATLAS_CREATURE + * /site/market/vendors/ → SHARD_MARKET_VENDOR + * /site/about → PAGE("about") + * / → PAGE(slug), unless is reserved + * anything else → null, i.e. the Custom Tab + * ``` + * + * **A path carrying a query or a fragment hands off**, whatever its route part + * says. No app route takes either, so a native match would quietly drop what the + * admin wrote; the browser honors it exactly. + * + * Resolving a path is not the same as being allowed to see the screen behind it. + * A link to `/site/market` on a shard that does not publish the market lands on + * the Market screen's honest "not published here" state, which is what typing the + * URL on the web does too (§6.3). + */ +fun resolveWebPath(path: String?): String? { + val normalized = normalizeWebPath(path) ?: return null + if (normalized.any { it == '?' || it == '#' }) return null + WEB_PATH_TO_ROUTE[normalized]?.let { return it } + if (!normalized.startsWith("/")) return null + + // Blank segments ("/site//news") mean a malformed path, not a slug. + val segments = normalized.removePrefix("/").split('/') + if (segments.any { it.isBlank() }) return null + + return when { + segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page) + segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1]) + segments[0] != "site" -> null + segments.size == 3 && segments[1] == "newsletter" -> + Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2]) + segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2]) + segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" -> + Routes.marketVendor(segments[3]) + else -> null + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt new file mode 100644 index 0000000..c889aa6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt @@ -0,0 +1,239 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull + +/** + * The drawer as a one-level tree: the coded menu, plus the **sections** an admin + * grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md + * §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`. + * + * The public nav is the one nav an admin can restructure rather than only reorder, + * and §6.1's invariant survives that structurally rather than by vigilance: a + * coded row is still keyed by a website path the app's own table declares, so an + * override still cannot invent a destination or touch a gate, while everything + * that *can* name an arbitrary path lives in [NavNode.Link], where the path rule + * is applied and the result is resolved through [resolveWebPath]. + * + * An added link carries no gate and needs none — the screen behind it enforces its + * own access, so a link to somewhere this caller cannot reach lands on that + * screen's own honest state, exactly as typing the URL on the web does. + */ +sealed interface NavNode { + + /** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */ + data class Item(val entry: MenuEntry) : NavNode + + /** + * An admin-authored link to a page on this site. + * + * @param path the stored website path, already validated — this is what a + * Custom Tab opens, resolved against the site's base URL + * @param route the app route [path] maps to, or null when the app has no + * screen for it and the link must hand off (§6.3) + */ + data class Link( + val id: String, + val label: String, + val path: String, + val route: String?, + ) : NavNode + + /** + * A drawer group: its [label] as a header, its [items] beneath it. + * + * The website renders these as click-to-open dropdowns; a drawer is already a + * vertical list, so the app renders the group open (§6.3). Never empty — see + * [pruneNav]. + */ + data class Section( + val id: String, + val label: String, + val items: List, + ) : NavNode +} + +/** A usable `sections` entry. */ +private data class SectionSpec(val id: String, val label: String, val order: Double?) + +/** A usable `links` entry, with its section already checked against the stored ones. */ +private data class LinkSpec( + val id: String, + val label: String, + val to: String, + val order: Double?, + val section: String?, +) + +/** One node waiting to be placed: its sort key, and whether that key was stored. */ +private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean) + +/** + * Characters that must never appear in a stored link path. The same rule the + * website applies on read: a value that would leave the origin, or carry markup + * into a link, is dropped rather than rendered. + */ +private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""") + +// Forgiving, like every other read in M12: an entry that is not usable is dropped +// and its neighbours kept. A repeated id is dropped too — the first wins, since +// the id is what a link's identity in the drawer is. +private fun readSections(raw: List): List { + val seen = mutableSetOf() + return raw.mapNotNull { section -> + val id = section.stringOrNull("id") ?: return@mapNotNull null + val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + if (!seen.add(id)) return@mapNotNull null + SectionSpec(id = id, label = label, order = section.orderOrNull()) + } +} + +private fun readLinks(raw: List, knownSections: Set): List { + val seen = mutableSetOf() + return raw.mapNotNull { link -> + val id = link.stringOrNull("id") ?: return@mapNotNull null + val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val to = link.stringOrNull("to") ?: return@mapNotNull null + if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) { + return@mapNotNull null + } + if (!seen.add(id)) return@mapNotNull null + LinkSpec( + id = id, + label = label, + to = to, + order = link.orderOrNull(), + // A link naming a section that does not exist is a top-level link, not + // a dropped one: the admin's destination is still good. + section = link.stringOrNull("section")?.takeIf { it in knownSections }, + ) + } +} + +private fun JsonObject.stringOrNull(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonObject.orderOrNull(): Double? = + (this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() } + +// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental +// index (the admin said "first", so first), and two explicit orders keep +// declaration order, because the sort is stable. +private fun List.place(): List = + sortedWith(compareBy { it.key }.thenByDescending { it.explicit }).map { it.node } + +/** + * The coded menu with the admin's `nav_public` applied in full: relabeled, + * reordered and hidden as phase 5 already did, plus grouped into sections and + * joined by added links. + * + * With no sections and no links this **is** phase 5 — [applyNavOverrides] answers, + * so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof + * is unchanged (§2). The tree build only runs when the admin actually created + * structure. + * + * @param base the coded menu — the only source of `route`, `access` and `feature` + * @param navPublic the parsed `nav_public` row, or null when the admin never + * edited the nav + */ +fun buildNavTree(base: List, navPublic: JsonObject?): List { + val sections = readSections(sectionsOf(navPublic)) + val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet()) + if (sections.isEmpty() && links.isEmpty()) { + return applyNavOverrides(base, navPublic).map { NavNode.Item(it) } + } + + val knownSections = sections.map { it.id }.toSet() + val coded = base.map { it.route }.toSet() + // Keyed by app route, and only for a route the coded menu declares — the same + // narrowing as the flat merge, so an override for a path the app maps but does + // not surface (a news category tab, a Shard hub board) is dropped here too. + val overrides = buildMap { + for ((path, raw) in itemsOf(navPublic)) { + val route = appRouteForWebPath(path) ?: continue + if (route !in coded) continue + val override = cleanOverride(raw) + // A section the stored value never declares is no section at all. + val section = override.section?.takeIf { it in knownSections } + if (!override.isEmpty || section != null) put(route, override.copy(section = section)) + } + } + + // The app's own surfaces (Contact, Account, the player groups, the staff rows) + // have no website counterpart to be reordered against or grouped under, so they + // keep their coded order after the public block — where they already sit (§6.2). + val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER } + + val placed = mutableListOf() + for (entry in mapped) { + val override = overrides[entry.route] + if (override?.hidden == true) continue + placed += Placed( + node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry), + section = override?.section, + // An untouched row's key is its index in the WEBSITE's nav, so stored + // and implicit keys sit on one number line (phase 5). + key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(), + explicit = override?.order != null, + ) + } + // An admin-created entity with no stored order appends after the coded rows, in + // creation order, rather than jumping to the front on a 0 default. + var next = WEBSITE_PUBLIC_NAV.size + for (section in sections) { + placed += Placed( + node = NavNode.Section(section.id, section.label, emptyList()), + section = null, + key = section.order ?: (next++).toDouble(), + explicit = section.order != null, + ) + } + for (link in links) { + placed += Placed( + node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)), + section = link.section, + key = link.order ?: (next++).toDouble(), + explicit = link.order != null, + ) + } + + val top = placed.filter { it.node is NavNode.Section || it.section == null }.place() + return top.map { node -> + if (node !is NavNode.Section) { + node + } else { + node.copy(items = placed.filter { it.section == node.id }.place()) + } + } + appOnly.map { NavNode.Item(it) } +} + +/** + * The tree with this caller's gates applied — and a section they empty dropped. + * + * This is the boundary, and it runs **after** [buildNavTree], never before: an + * override is presentation, so a row it relabels, moves or marks `hidden: false` + * is still shown only if [isVisible] says so (§6.1, AC-3). + * + * The empty-section case is the one with real correctness risk and the reason the + * rule is ported rather than left to the drawer: a group whose every member is + * withheld by the caller's role or by the shard's visibility config must not draw + * as a header with nothing under it. + * + * Links are not gated — see [NavNode]. + * + * @param isVisible the caller's own predicate, applied to coded items only, so + * this file stays ignorant of sessions and shard features + */ +fun pruneNav(tree: List, isVisible: (MenuEntry) -> Boolean): List { + fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry) + return tree.mapNotNull { node -> + when (node) { + is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() } + else -> node.takeIf { keep(it) } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 449f980..ac0dc4d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -3,6 +3,8 @@ */ package com.runicgateway.app.ui.navigation +import com.runicgateway.app.data.repository.ContentRepository + /** * Navigation destinations for the M1 public surface (PLAN.md §5). Routes are * plain strings for Navigation-Compose; argument-bearing routes expose a @@ -14,6 +16,19 @@ object Routes { const val WIKI = "wiki" const val CONTACT = "contact" + /** + * The News hub's NavHost pattern: [NEWS] plus an optional category, so a link + * to one of the website's three category pages can land on the matching tab + * (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern + * with no argument and opens the default tab, so every existing call site — + * the drawer, [forStream] — is unaffected. + * + * Declared beside [NEWS] rather than replacing it because the two are used for + * different things: this is what `composable()` and `destination.route` speak, + * [NEWS] is what callers navigate to. + */ + const val NEWS_ROUTE = "news?category={category}" + /** Native login (§4.1) and the signed-in account surface (§5). */ const val LOGIN = "login" const val ACCOUNT = "account" @@ -79,6 +94,13 @@ object Routes { fun page(slug: String) = "page/$slug" fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug" + + /** + * The News hub with [category] preselected. Takes the enum rather than a slug + * so an unmapped category cannot reach the NavHost — the screen's tabs are the + * enum's entries, and a slug it doesn't know would select nothing. + */ + fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}" fun wikiPage(slug: String) = "wiki/$slug" /** The character-sheet route for an in-game serial (e.g. "0x24C"). */ diff --git a/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt index e3fdd2c..f2bc95c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** News hub with category tabs and a post list (PLAN.md §6.1). */ @Composable @@ -82,7 +82,7 @@ private fun PostList( @Composable private fun PostRow(post: PostDto, onClick: () -> Unit) { - Card( + ShardCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) diff --git a/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt index c4107b3..1deca01 100644 --- a/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt @@ -3,12 +3,14 @@ */ package com.runicgateway.app.ui.news +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.data.api.dto.PostDto import com.runicgateway.app.data.repository.ContentRepository import com.runicgateway.app.data.repository.ContentRepository.PostCategory import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.toUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -21,9 +23,15 @@ import javax.inject.Inject @HiltViewModel class NewsViewModel @Inject constructor( private val contentRepository: ContentRepository, + savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _category = MutableStateFlow(PostCategory.NEWS) + // Which tab to open on. Absent — every route into this screen except an + // admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the + // default feed, and so is a slug the app doesn't know. + private val _category = MutableStateFlow( + PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS, + ) val category: StateFlow = _category.asStateFlow() private val _state = MutableStateFlow>>(UiState.Loading) diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt index 790835e..8948c20 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatBar import kotlinx.serialization.json.jsonPrimitive @@ -289,7 +289,7 @@ private fun EquipmentBlock(equipment: List) { @Composable private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium) content() diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt index 41b85a8..9f7e01c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -90,7 +90,7 @@ fun CharactersScreen( @Composable private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) { var code by rememberSaveable { mutableStateOf("") } - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium) Text( @@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) { var account by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium) OutlinedTextField( @@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) { @Composable private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) { - Card( + ShardCard( Modifier .fillMaxWidth() .padding(vertical = 4.dp) diff --git a/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt index 89d0dc1..e60bc11 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** * The player's own houses with home/decay status (PLAN.md §6.3), text-only. An @@ -60,7 +60,7 @@ fun MyHousesScreen( @Composable private fun HouseCard(house: PlayerHouseDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt index c06c2a9..e9c4a89 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard import java.text.DateFormat import java.util.Date @@ -79,7 +79,7 @@ fun VendorsScreen( @Composable private fun SalesCard(sales: UiState>) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium) when (sales) { @@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) { @Composable private fun VendorCard(vendor: VendorDto) { - Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Column(Modifier.padding(16.dp)) { Text( vendor.shopName ?: stringResource(R.string.player_vendor_fallback), diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt index 0619dd2..ea24312 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -41,6 +40,7 @@ import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -95,7 +95,7 @@ fun AtlasScreen( @Composable private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) { val slug = creature.slug - Card( + ShardCard( Modifier .fillMaxWidth() .then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier), diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt index 7560337..5ee98e1 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.ChampDto import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */ @@ -44,7 +44,7 @@ fun ChampsScreen( @Composable private fun ChampCard(champ: ChampDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt index 2d33b91..d627336 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */ @Composable @@ -84,7 +84,7 @@ private fun CityCard( onExpand: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column { Column( Modifier diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt index 9df373c..262c2d4 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.GuildDto +import com.runicgateway.app.ui.components.ShardCard /** The guild board (PLAN.md §6.2), live via SSE deltas. */ @Composable @@ -42,7 +42,7 @@ fun GuildsScreen( @Composable private fun GuildCard(guild: GuildDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt index 0d6fe96..b0fec7d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.HouseDto import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */ @@ -44,7 +44,7 @@ fun HousesScreen( @Composable private fun HouseCard(house: HouseDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt index dc6cb14..abe8c49 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,6 +25,7 @@ import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.PointsBoardDto import com.runicgateway.app.data.api.dto.PointsEntryDto import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard /** * The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via @@ -52,7 +52,7 @@ fun LeaderboardsScreen( @Composable private fun BoardCard(board: PointsBoardDto, placeholderName: String) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt index ab2ddd6..05e9f72 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard /** * The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock. @@ -99,7 +99,7 @@ fun MarketScreen( @Composable private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) { val vendorSerial = listing.vendor?.serial - Card( + ShardCard( Modifier .fillMaxWidth() .then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier), diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt index 53a7225..7187ab2 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,6 +29,7 @@ import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -167,7 +167,7 @@ private fun CapsCard(caps: RulesetCapsDto) { @Composable private fun RuleCard(title: String, content: @Composable () -> Unit) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(title, style = MaterialTheme.typography.titleMedium) content() diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt index bef6d86..e653692 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.runicgateway.app.data.repository.ShardFeatures import com.runicgateway.app.data.repository.canSee import com.runicgateway.app.ui.components.PillTone import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -193,7 +193,7 @@ private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Un } } if (boards.isEmpty()) return - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column { boards.forEachIndexed { index, (board, labelRes) -> Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt b/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt index ab95ce3..efe8b2f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt @@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color * without the leading `#`) into a Compose [Color]. Returns null for anything * unparseable so the theme falls back to its default scheme (PLAN.md §3, §5). * Pure logic — covered by JVM unit tests. + * + * Also the parser for every color token in the shard's resolved theme map + * ([ShardPalette.resolve], M12): the server validates those as `#RGB` or + * `#RRGGBB` on write, and a null here is what makes a token that slipped + * through anyway cost only itself. */ fun parseBrandColor(hex: String?): Color? { if (hex.isNullOrBlank()) return null diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/Font.kt b/app/src/main/java/com/runicgateway/app/ui/theme/Font.kt index f3f783d..74f854d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/Font.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/Font.kt @@ -6,35 +6,117 @@ package com.runicgateway.app.ui.theme import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontWeight import com.runicgateway.app.R /** - * Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5). + * Every type family the app can draw with — the eight the admin's Appearance page + * can select between (THEMING_AND_NAV.md §5.3) plus the two system stacks its + * "shipped default" options resolve to. * - * - [Cinzel] — the engraved serif display face used for headings, screen titles, - * and the top-bar title. Shipped as a single weight-axis **variable** font - * (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`); - * the 500/600/700 instances the design uses are pinned via [FontVariation] - * (supported on API 26+, and our minSdk is 29). - * - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif, - * which reads as the design's Georgia body copy without bundling another binary. - * - [AppSans] — the label/meta/button face (the design's "Helvetica Neue" runs). + * All eight webfonts are **bundled**, not downloadable: the Play Store font + * provider is the only downloadable-font source Compose ships with, so a + * de-Googled device would silently fall back and every text style would gain an + * async loading state. The binaries are taken verbatim from `google/fonts`, which + * is how [Cinzel] arrived in M5; each carries its SIL OFL licence under + * `app/licenses/`, **never** under `res/font/` (aapt rejects a `.txt` there). + * + * A family reaches a text style through [ShardTypeface], which is what maps a + * shard's `--display` / `--serif` / `--sans` stacks onto these. The three + * declared *shipped* roles are [Cinzel] for the engraved display/headline/title + * block, [AppSerif] for parchment body copy, and [AppSans] for the letter-spaced + * label/meta/button block. + * + * ## Weights + * + * The type scale asks for four: 400 (body), 500 and 700 (labels), 600 (display). + * Every family here must supply all four, because a family is **not** confined to + * the role its dropdown lives in — the `modern` preset puts Work Sans in the + * display slot and the `fantasy` preset puts EB Garamond in the sans slot, both + * bypassing the server's per-role option list (see [ShardTypeface]). The variable + * families pin the four instances through [FontVariation] (API 26+; minSdk is 29). + * + * Two families are exceptions, both upstream facts rather than choices: + * - **[IMFellEnglish] has a single weight.** Its one 400 face answers all four + * requests and Android synthesises the bold. The website's dropdown labels it + * "(no bold weight)" for the same reason. + * - **[Cinzel] is left at the 500/600/700 it shipped with in M5.** It is the only + * family the server offers in the display role alone, so nothing can ask it for + * 400; adding an instance would have edited M5's type for no reachable case. + * + * ## Italics + * + * Four families carry a true italic — the same four `client/index.html` requests + * one for. The rest are upright-only and Compose skews them, which is what the + * app already did for every family before this milestone and what the website + * does for its own upright-only faces. The app draws italic in two places. */ @OptIn(ExperimentalTextApi::class) -private fun cinzel(weight: FontWeight) = +private fun variable(resId: Int, weight: FontWeight, style: FontStyle = FontStyle.Normal) = Font( - R.font.cinzel_variable, + resId, weight = weight, + style = style, variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)), ) +// The four weights the type scale asks for, in the order Compose prefers to match. +private val ScaleWeights = listOf( + FontWeight.Normal, // 400 — body + FontWeight.Medium, // 500 — labelMedium / labelSmall + FontWeight.SemiBold, // 600 — display / headline / title + FontWeight.Bold, // 700 — labelLarge +) + +/** A variable family pinned at the four scale weights, upright only. */ +private fun variableFamily(resId: Int) = + FontFamily(ScaleWeights.map { variable(resId, it) }) + +/** A variable family pinned at the four scale weights, upright and italic. */ +private fun variableFamily(uprightResId: Int, italicResId: Int) = + FontFamily( + ScaleWeights.map { variable(uprightResId, it) } + + ScaleWeights.map { variable(italicResId, it, FontStyle.Italic) }, + ) + +private fun cinzel(weight: FontWeight) = variable(R.font.cinzel_variable, weight) + val Cinzel = FontFamily( cinzel(FontWeight.Medium), // 500 cinzel(FontWeight.SemiBold), // 600 cinzel(FontWeight.Bold), // 700 ) +val EBGaramond = variableFamily(R.font.eb_garamond_variable, R.font.eb_garamond_italic) +val Merriweather = variableFamily(R.font.merriweather_variable, R.font.merriweather_italic) +val PlayfairDisplay = + variableFamily(R.font.playfair_display_variable, R.font.playfair_display_italic) +val Inter = variableFamily(R.font.inter_variable) +val WorkSans = variableFamily(R.font.work_sans_variable) +val SourceSans3 = variableFamily(R.font.source_sans_3_variable) + +/** + * IM Fell English, whose upstream release is a single 400 face per style — there + * is no weight axis and no bold cut to pin. Declared once per style so a request + * at 500/600/700 lands on it rather than falling out of the family. + */ +val IMFellEnglish = FontFamily( + Font(R.font.im_fell_english_regular, weight = FontWeight.Normal), + Font(R.font.im_fell_english_italic, weight = FontWeight.Normal, style = FontStyle.Italic), +) + +/** + * The platform serif (Noto Serif), which is what the website's + * `Georgia, "Times New Roman", serif` stack resolves to on Android — and the + * app's shipped body face since M5. + */ val AppSerif = FontFamily.Serif + +/** + * The platform sans (Roboto), which the website's + * `"Helvetica Neue", Arial, sans-serif` stack resolves to on Android — and the + * app's shipped label face since M5. + */ val AppSans = FontFamily.SansSerif diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt b/app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt new file mode 100644 index 0000000..5b8b947 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt @@ -0,0 +1,126 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * The shard's resolved color palette — the fifteen themable tokens of + * `GET /public/settings`' `theme` map, parsed into Compose colors + * (THEMING_AND_NAV.md §5.1). + * + * **The default value of every field is the shipped constant from + * [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette + * *is* the website's `runic-gateway` preset, value for value, because both were + * drawn from the same `theme.css`. So [Shipped] renders exactly as the app did + * before this milestone, and an instance with no `theme_visual` row resolves + * back to it token by token (§2, AC-1). + * + * The palette has two consumers and one resolution: ten of the fifteen tokens + * have a Material role and are fed into the [androidx.compose.material3.ColorScheme] + * by [shardColorScheme]; the other five have none, and reach the screens that + * need them through [LocalShardPalette]. + */ +@Immutable +data class ShardPalette( + /** `--bg-deep` — the page behind everything. */ + val page: Color = ShardPage, + /** `--bg` — the screen background. */ + val surface: Color = ShardSurface, + /** `--panel-flat` — top bar, inputs, drawer, list tracks. */ + val elevated: Color = ShardElevated, + /** `--panel-a` — feature-card gradient, top. No Material role. */ + val cardTop: Color = ShardCardTop, + /** `--panel-b` — feature-card gradient, bottom. No Material role. */ + val cardBottom: Color = ShardCardBottom, + /** `--line` — borders and input outlines. */ + val outline: Color = ShardOutline, + /** `--line-soft` — hairline row dividers. */ + val divider: Color = ShardDivider, + /** `--ink` — the brightest headings. No Material role. */ + val heading: Color = ShardHeading, + /** `--head` — heading on a surface. No Material role. */ + val headingDim: Color = ShardHeadingDim, + /** `--text` — body copy. */ + val body: Color = ShardBody, + /** `--muted` — secondary text. */ + val muted: Color = ShardMuted, + /** `--dim` — meta and faint labels. No Material role. */ + val faint: Color = ShardFaint, + /** `--accent` — links and secondary highlights. */ + val accent: Color = ShardAccent, + /** `--accent-bright` — the filled CTA surface. */ + val cta: Color = ShardCta, + /** `--blue` — the neutral/info pill background. */ + val pillBg: Color = ShardPillBg, +) { + /** + * Text drawn on the [cta] fill. **Derived, never themed** — it tracks + * `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a + * literal (§5.1). A value expressed in terms of another token must follow + * it, or a future light preset inherits a dark one and looks broken. + */ + val onCta: Color get() = page + + /** + * The neutral/info pill's foreground. Also derived: `ShardPillFg` and + * `ShardCta` are the same `--accent-bright` value, so the pill's text + * follows the CTA fill rather than being a sixteenth token the contract + * does not have. + */ + val pillFg: Color get() = cta + + companion object { + /** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */ + val Shipped = ShardPalette() + + /** + * Resolve a `theme` token map into a palette, **field by field** (§2). + * A token that is missing, blank or unparseable falls back to its + * shipped value on its own; a bad `--accent` must never discard a good + * `--bg` beside it (AC-2). + * + * [brandAccent] is the pre-feature branding path and must keep working: + * an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row + * still tints its links and highlights. It seeds `--accent` only — the + * server resolves `brand.accent` as `theme['--accent'] || env`, so the + * token always wins where both exist. + */ + fun resolve(theme: Map, brandAccent: Color? = null): ShardPalette { + if (theme.isEmpty() && brandAccent == null) return Shipped + fun token(name: String, shipped: Color): Color = + parseBrandColor(theme[name]) ?: shipped + return ShardPalette( + page = token("--bg-deep", ShardPage), + surface = token("--bg", ShardSurface), + elevated = token("--panel-flat", ShardElevated), + cardTop = token("--panel-a", ShardCardTop), + cardBottom = token("--panel-b", ShardCardBottom), + outline = token("--line", ShardOutline), + divider = token("--line-soft", ShardDivider), + heading = token("--ink", ShardHeading), + headingDim = token("--head", ShardHeadingDim), + body = token("--text", ShardBody), + muted = token("--muted", ShardMuted), + faint = token("--dim", ShardFaint), + accent = token("--accent", brandAccent ?: ShardAccent), + cta = token("--accent-bright", ShardCta), + pillBg = token("--blue", ShardPillBg), + ) + } + } +} + +/** + * The live palette, for the five tokens with no Material role and for the + * components that draw the card gradient. Everything that *can* go through + * `MaterialTheme.colorScheme` still should — this is the escape hatch, not the + * front door. + * + * Defaulted to [ShardPalette.Shipped] so previews and any composable outside + * [RunicGatewayTheme] still draw the shipped palette rather than crashing. + */ +val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped } diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt b/app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt new file mode 100644 index 0000000..5e64655 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt @@ -0,0 +1,179 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * The shard's resolved corner radii and card depth — the `structure` half of the + * admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to + * [ShardPalette]. + * + * **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came + * from the M5 mockup and the website's from `theme.css`; the two scales genuinely + * differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in + * would restyle an untouched app the day this milestone shipped, so each field is + * scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped + * theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every + * field and is a provable no-op (§2, AC-1). + * + * Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation]. + */ +@Immutable +data class ShardStructure( + /** The Material shape scale, ratio-scaled off the app's own shipped dp values. */ + val shapes: Shapes = ShippedShapes, + /** + * `--radius-pill`. Not part of [shapes]: the app draws its chips with + * [CircleShape], which is a percentage and so has no dp for a ratio to scale. + * Resolved as a literal instead — the only rule available — see [pillShape]. + */ + val pill: Shape = CircleShape, + /** `--shadow-card`, mapped onto Material elevation (§5.4). */ + val cardElevation: Dp = ShippedCardElevation, +) { + companion object { + /** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */ + val Shipped = ShardStructure() + + /** + * Resolve a `theme` token map into a structure, **field by field** (§2): + * a `--radius-panel` the server never validated must not cost the + * `--radius-card` beside it, exactly as in [ShardPalette.resolve]. + */ + fun resolve(theme: Map): ShardStructure { + if (theme.isEmpty()) return Shipped + val input = ratio(theme["--radius-input"], BaseInputPx) + val card = ratio(theme["--radius-card"], BaseCardPx) + val panel = ratio(theme["--radius-panel"], BasePanelPx) + return ShardStructure( + shapes = Shapes( + extraSmall = corner(ShippedExtraSmallDp, input), + small = corner(ShippedSmallDp, input), + medium = corner(ShippedMediumDp, card), + // extraLarge has no web counterpart and follows the panel + // ratio, since it is the panel family. + large = corner(ShippedLargeDp, panel), + extraLarge = corner(ShippedExtraLargeDp, panel), + ), + pill = pillShape(theme["--radius-pill"]), + cardElevation = elevation(theme["--shadow-card"]), + ) + } + } +} + +/** + * The live structure, for the two things Material's theme cannot carry: the pill + * shape, and a card elevation ([androidx.compose.material3.Card] takes its + * elevation as a default argument, not from a composition local). The shape + * scale itself reaches screens through `MaterialTheme.shapes` and needs nothing + * here. + */ +val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped } + +// ── the shipped scale ────────────────────────────────────────────────────── +// +// The app's own dp values, which the ratios scale. Kept here rather than in +// Theme.kt so the resolution and the thing it resolves back to sit together. + +private const val ShippedExtraSmallDp = 8 +private const val ShippedSmallDp = 8 +private const val ShippedMediumDp = 12 +private const val ShippedLargeDp = 16 +private const val ShippedExtraLargeDp = 24 + +/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */ +internal val ShippedShapes = Shapes( + extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp), + small = RoundedCornerShape(ShippedSmallDp.dp), + medium = RoundedCornerShape(ShippedMediumDp.dp), + large = RoundedCornerShape(ShippedLargeDp.dp), + extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp), +) + +/** + * The depth an unthemed instance draws its cards at. + * + * **This is the one field of this milestone that is deliberately not a no-op.** + * The app has been flat since M5 — Material's filled `Card` is `Level0` and + * `FeatureCard` never had the shadow its own docs claimed — while the + * `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is + * applied as written rather than rebased on the app's flat baseline, so every + * card gains this depth and the admin's four-step control reads the same on the + * phone as on the web. Approved by the org lead as an amendment to §2. + */ +private val ShippedCardElevation = 4.dp + +// ── the runic-gateway baselines ─────────────────────────────────────────── +// +// The preset the app's own scale corresponds to (server/src/config/themePresets.js). +// A resolved value is meaningful only against these: the ratio, not the number, +// is what crosses from the web scale to the app's. + +private const val BaseInputPx = 8f +private const val BaseCardPx = 10f +private const val BasePanelPx = 12f +private const val BasePillPx = 999f + +/** + * Below half the pill baseline the chip stops reading as a pill and becomes a + * rounded rectangle, so an admin who squares the site off squares off the app's + * chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s + * 999px does not. + */ +private const val PillCircleFloorPx = BasePillPx / 2f + +// A radius as the server writes it: an integer count of px, 0..999, always with +// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value +// this app can scale, and falls back to the shipped dp on its own. +private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""") + +// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no +// unit, so the blur is the second px length. +private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""") + +/** + * `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact + * string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`, + * but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value + * `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass + * through that dropdown. An exact match would have missed the one preset whose + * point is a heavier shadow. Matching the blur puts any future preset on the + * nearest step instead of silently on the default. + */ +private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp) + +private fun parseRadiusPx(raw: String?): Float? = + raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull() + +private fun ratio(raw: String?, baselinePx: Float): Float = + parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f + +/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */ +private fun corner(shippedDp: Int, ratio: Float) = + RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp) + +private fun pillShape(raw: String?): Shape { + val px = parseRadiusPx(raw) ?: return CircleShape + return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp) +} + +private fun elevation(raw: String?): Dp { + val value = raw?.trim() ?: return ShippedCardElevation + if (value.equals("none", ignoreCase = true)) return 0.dp + val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull() + ?.groupValues?.get(1)?.toFloatOrNull() + ?: return ShippedCardElevation + return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation +} diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/ShardTypeface.kt b/app/src/main/java/com/runicgateway/app/ui/theme/ShardTypeface.kt new file mode 100644 index 0000000..ad3535e --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/theme/ShardTypeface.kt @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.font.FontFamily + +/** + * The shard's resolved type families — the `fonts` third of the admin's + * Appearance page (THEMING_AND_NAV.md §5.3), alongside [ShardPalette] and + * [ShardStructure]. + * + * The three roles map onto the M5 type scale's three groups verbatim: `--display` + * carries the Cinzel display/headline/title block, `--serif` the body block, and + * `--sans` the label/meta/button block. Sizes, weights and tracking do not move — + * only the family, which is why [shardTypography] is a one-field substitution and + * an unthemed shard is a provable no-op (§2, AC-1). + * + * Resolution is pure, so the acceptance tests need no Compose rule. + */ +@Immutable +data class ShardTypeface( + /** `--display`. */ + val display: FontFamily = Cinzel, + /** `--serif`. */ + val serif: FontFamily = AppSerif, + /** `--sans`. */ + val sans: FontFamily = AppSans, +) { + companion object { + /** The shipped app: the three M5 families. */ + val Shipped = ShardTypeface() + + /** + * Resolve a `theme` token map into three families, **field by field** (§2): + * an unreadable `--sans` must not cost the `--serif` beside it, exactly as + * in [ShardPalette.resolve] and [ShardStructure.resolve]. + */ + fun resolve(theme: Map): ShardTypeface { + if (theme.isEmpty()) return Shipped + return ShardTypeface( + display = family(theme["--display"]) ?: Shipped.display, + serif = family(theme["--serif"]) ?: Shipped.serif, + sans = family(theme["--sans"]) ?: Shipped.sans, + ) + } + } +} + +/** + * Every family name the server can publish, keyed by the lowercased first family + * of the stack. + * + * **This map is deliberately global rather than per-role**, and that is not a + * simplification. The server validates an *admin-entered* font against + * `FONT_OPTIONS[role]`, but a preset's tokens are copied verbatim by + * `resolveThemeTokens` and never pass through that list — `modern` publishes + * `--display: 'Work Sans', Arial, sans-serif`, which the display dropdown does + * not offer, and `fantasy` publishes `--sans: 'EB Garamond', Georgia, serif`, + * which the sans dropdown does not either. A per-role lookup would have missed + * the display face of one preset and the label face of the other. It is the same + * trap phase 2 hit with `--shadow-card`, in a different token group. + * + * The two system entries are the "shipped default" options: neither pulls in a + * webfont on the web, and on Android both resolve to the platform family the app + * has drawn with since M5. + */ +private val FamiliesByFirstName: Map = mapOf( + "cinzel" to Cinzel, + "eb garamond" to EBGaramond, + "merriweather" to Merriweather, + "playfair display" to PlayfairDisplay, + "im fell english" to IMFellEnglish, + "inter" to Inter, + "work sans" to WorkSans, + "source sans 3" to SourceSans3, + "georgia" to AppSerif, + "helvetica neue" to AppSans, +) + +/** + * Resolve a CSS font stack to a family by **its first name**, which is how the + * value is constructed server-side and the only part of it that carries the + * admin's choice — everything after the first comma is the web's fallback chain, + * which Android has no use for. + * + * Returns `null` for a stack this app cannot draw, so the caller falls back to + * the role's shipped family rather than to some other role's. + */ +private fun family(stack: String?): FontFamily? { + val first = stack?.substringBefore(',')?.trim()?.trim('\'', '"')?.trim() + if (first.isNullOrEmpty()) return null + return FamiliesByFirstName[first.lowercase()] +} diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt index 8ce1d0f..5439f7c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt @@ -3,78 +3,100 @@ */ package com.runicgateway.app.ui.theme -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import com.runicgateway.app.data.appearance.SiteAppearance /** - * The shard-website color scheme (M5 design pass). The app is **dark-only** — the - * design is a single deep blue-black theme, so there is no light variant and the - * system light/dark setting is intentionally ignored. Material roles are mapped - * onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on - * the theme without per-screen color work. + * Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md + * §5.1). The app is **dark-only** — the design is a single deep blue-black + * theme, so there is no light variant and the system light/dark setting is + * intentionally ignored; every v1 preset on the website is dark too. + * + * Ten of the palette's fifteen tokens land here, which is why the ~20 + * token-based screens take on a shard's theme with no per-screen color work. + * Pure, so the no-op proof (AC-1) can assert on it directly. */ -private val ShardColorScheme = darkColorScheme( - primary = ShardCta, // filled CTA buttons - onPrimary = ShardOnCta, - secondary = ShardAccent, // links / secondary highlights - onSecondary = ShardOnCta, - tertiary = ShardAccent, - onTertiary = ShardOnCta, - background = ShardPage, - onBackground = ShardBody, - surface = ShardSurface, - onSurface = ShardBody, - surfaceVariant = ShardElevated, - onSurfaceVariant = ShardMuted, - surfaceContainer = ShardElevated, - surfaceContainerHigh = ShardElevated, - surfaceContainerLow = ShardSurface, - outline = ShardOutline, - outlineVariant = ShardDivider, - secondaryContainer = ShardPillBg, // neutral chips / selected drawer item - onSecondaryContainer = ShardPillFg, +internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme( + primary = palette.cta, // filled CTA buttons + onPrimary = palette.onCta, + secondary = palette.accent, // links / secondary highlights + onSecondary = palette.onCta, + tertiary = palette.accent, + onTertiary = palette.onCta, + background = palette.page, + onBackground = palette.body, + surface = palette.surface, + onSurface = palette.body, + surfaceVariant = palette.elevated, + onSurfaceVariant = palette.muted, + surfaceContainer = palette.elevated, + surfaceContainerHigh = palette.elevated, + // Material's filled Card takes its container from surfaceContainerHighest — + // FilledCardTokens.ContainerColor, checked in the 1.3.0 artifact's bytecode. + // Leaving it unmapped is what made every ShardCard draw in darkColorScheme()'s + // default grey instead of --panel-flat, on themed AND untouched instances alike + // (found on device in phase 8's AC-5 walk; see "Phase 8 as landed"). + surfaceContainerHighest = palette.elevated, + surfaceContainerLow = palette.surface, + surfaceContainerLowest = palette.surface, + outline = palette.outline, + outlineVariant = palette.divider, + secondaryContainer = palette.pillBg, // neutral chips / selected drawer item + onSecondaryContainer = palette.pillFg, + // Semantic, never themed — mirrors the server's FIXED_TOKENS (§4). error = ShardDanger, - onError = ShardOnCta, + onError = palette.onCta, errorContainer = ShardDangerBg, onErrorContainer = ShardDanger, ) -/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */ -private val ShardShapes = Shapes( - extraSmall = RoundedCornerShape(8.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(24.dp), -) - /** - * App theme. The color scheme is the fixed shard-website dark palette; when a shard - * publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and - * secondary roles so buttons and highlights carry that shard's color while the rest - * of the deep blue-black system stays intact. With no accent, the slate default is - * used. + * App theme, themed by the shard (M12). [appearance] carries the resolved token + * map the admin's Appearance page publishes; it is applied field by field over + * the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings + * rows, a backend that predates the feature, or a settings call that failed — + * renders as the app did before this milestone (§2), the one exception being the + * card depth [ShardStructure] documents. + * + * The palette reaches screens two ways: through [MaterialTheme]'s color scheme + * for the ten tokens with a Material role, and through [LocalShardPalette] for + * the five without one. The radii split the same way — [MaterialTheme]'s shape + * scale for everything Material draws, [LocalShardStructure] for the pill and + * the card depth, which it cannot carry. The type families need no split and so + * no composition local: every text style in the app comes from + * [MaterialTheme.typography], and the two that override anything override the + * style rather than the family. */ @Composable fun RunicGatewayTheme( - accent: Color? = null, + appearance: SiteAppearance = SiteAppearance.NONE, content: @Composable () -> Unit, ) { - val colorScheme = if (accent != null) { - ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent) - } else { - ShardColorScheme + val palette = remember(appearance) { + ShardPalette.resolve( + theme = appearance.theme, + brandAccent = parseBrandColor(appearance.brand?.accent), + ) } + val colorScheme = remember(palette) { shardColorScheme(palette) } + val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) } + val typeface = remember(appearance) { ShardTypeface.resolve(appearance.theme) } + val typography = remember(typeface) { shardTypography(typeface) } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - shapes = ShardShapes, - content = content, - ) + CompositionLocalProvider( + LocalShardPalette provides palette, + LocalShardStructure provides structure, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = structure.shapes, + content = content, + ) + } } diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/Type.kt b/app/src/main/java/com/runicgateway/app/ui/theme/Type.kt index 8df3e97..8a49e5c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/Type.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/Type.kt @@ -9,73 +9,82 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp /** - * The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]): - * [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif) - * for parchment body copy, and [AppSans] for the letter-spaced label/meta/button - * roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup. + * The M5 type scale, drawn in whichever three families the shard resolved to + * (THEMING_AND_NAV.md §5.3). + * + * Only the family moves. Every size, weight, line height and tracking below is + * the M5 value from the "Runic Gateway Screens" mockup, so `ShardTypeface.Shipped` + * — an unthemed instance, a backend that predates the feature, a settings call + * that failed — reproduces the pre-M12 scale exactly. [androidx.compose.material3.Typography] + * implements `equals`, so AC-1 asserts that in one comparison. + * + * The three groups map onto the three roles verbatim: [ShardTypeface.display] + * carries the engraved display/headline/title block, [ShardTypeface.serif] the + * parchment body copy, and [ShardTypeface.sans] the letter-spaced + * label/meta/button roles. */ -val Typography = Typography( - // Display / headline / title — Cinzel engraved serif +internal fun shardTypography(faces: ShardTypeface) = Typography( + // Display / headline / title — the engraved serif role displayLarge = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp, ), displayMedium = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp, ), displaySmall = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp, ), headlineLarge = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp, ), headlineMedium = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp, ), headlineSmall = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp, ), titleLarge = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp, ), titleMedium = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp, ), titleSmall = TextStyle( - fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontFamily = faces.display, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp, ), - // Body — parchment serif + // Body — the parchment serif role bodyLarge = TextStyle( - fontFamily = AppSerif, fontWeight = FontWeight.Normal, + fontFamily = faces.serif, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp, ), bodyMedium = TextStyle( - fontFamily = AppSerif, fontWeight = FontWeight.Normal, + fontFamily = faces.serif, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp, ), bodySmall = TextStyle( - fontFamily = AppSerif, fontWeight = FontWeight.Normal, + fontFamily = faces.serif, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp, ), - // Labels / meta / buttons — sans, letter-spaced + // Labels / meta / buttons — the sans role, letter-spaced labelLarge = TextStyle( - fontFamily = AppSans, fontWeight = FontWeight.Bold, + fontFamily = faces.sans, fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp, ), labelMedium = TextStyle( - fontFamily = AppSans, fontWeight = FontWeight.Medium, + fontFamily = faces.sans, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp, ), labelSmall = TextStyle( - fontFamily = AppSans, fontWeight = FontWeight.Medium, + fontFamily = faces.sans, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp, ), ) diff --git a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt index 77810c9..ee265d7 100644 --- a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** Wiki index: search field + page list (PLAN.md §6.1). */ @Composable @@ -72,7 +72,7 @@ fun WikiScreen( private fun WikiList(pages: List, onOpenPage: (String) -> Unit) { LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { items(pages, key = { it.id }) { page -> - Card( + ShardCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) diff --git a/app/src/main/res/font/eb_garamond_italic.ttf b/app/src/main/res/font/eb_garamond_italic.ttf new file mode 100644 index 0000000..f9ad996 Binary files /dev/null and b/app/src/main/res/font/eb_garamond_italic.ttf differ diff --git a/app/src/main/res/font/eb_garamond_variable.ttf b/app/src/main/res/font/eb_garamond_variable.ttf new file mode 100644 index 0000000..52d34b8 Binary files /dev/null and b/app/src/main/res/font/eb_garamond_variable.ttf differ diff --git a/app/src/main/res/font/im_fell_english_italic.ttf b/app/src/main/res/font/im_fell_english_italic.ttf new file mode 100644 index 0000000..d9afa7e Binary files /dev/null and b/app/src/main/res/font/im_fell_english_italic.ttf differ diff --git a/app/src/main/res/font/im_fell_english_regular.ttf b/app/src/main/res/font/im_fell_english_regular.ttf new file mode 100644 index 0000000..275d754 Binary files /dev/null and b/app/src/main/res/font/im_fell_english_regular.ttf differ diff --git a/app/src/main/res/font/inter_variable.ttf b/app/src/main/res/font/inter_variable.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/app/src/main/res/font/inter_variable.ttf differ diff --git a/app/src/main/res/font/merriweather_italic.ttf b/app/src/main/res/font/merriweather_italic.ttf new file mode 100644 index 0000000..f161207 Binary files /dev/null and b/app/src/main/res/font/merriweather_italic.ttf differ diff --git a/app/src/main/res/font/merriweather_variable.ttf b/app/src/main/res/font/merriweather_variable.ttf new file mode 100644 index 0000000..cb775b8 Binary files /dev/null and b/app/src/main/res/font/merriweather_variable.ttf differ diff --git a/app/src/main/res/font/playfair_display_italic.ttf b/app/src/main/res/font/playfair_display_italic.ttf new file mode 100644 index 0000000..94fae1f Binary files /dev/null and b/app/src/main/res/font/playfair_display_italic.ttf differ diff --git a/app/src/main/res/font/playfair_display_variable.ttf b/app/src/main/res/font/playfair_display_variable.ttf new file mode 100644 index 0000000..7a09eb7 Binary files /dev/null and b/app/src/main/res/font/playfair_display_variable.ttf differ diff --git a/app/src/main/res/font/source_sans_3_variable.ttf b/app/src/main/res/font/source_sans_3_variable.ttf new file mode 100644 index 0000000..d259aa4 Binary files /dev/null and b/app/src/main/res/font/source_sans_3_variable.ttf differ diff --git a/app/src/main/res/font/work_sans_variable.ttf b/app/src/main/res/font/work_sans_variable.ttf new file mode 100644 index 0000000..588dc07 Binary files /dev/null and b/app/src/main/res/font/work_sans_variable.ttf differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5317643..6f04826 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,8 @@ Open navigation menu + + Opens in your browser Home News Wiki diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt index 64b10af..e1288cb 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt @@ -4,6 +4,8 @@ package com.runicgateway.app.data.api.dto import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -69,5 +71,32 @@ class PublicDtoTest { assertFalse(dto.registration.password) assertEquals("", dto.brand.name) assertEquals(null, dto.push.ntfyUrl) + // …and one that predates admin theming: both M12 fields are simply absent + // (THEMING_AND_NAV.md §2 — absence means the shipped defaults). + assertEquals(null, dto.theme) + assertEquals(null, dto.navPublic) + } + + @Test fun settingsDecodesTheThemeAndNavRows() { + val dto = json.decodeFromString( + """{ + "theme":{"--accent":"#c8a45c","--radius-card":"3px"}, + "nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}", + "theme_visual":"{\"preset\":\"fantasy\"}" + }""", + ) + assertEquals("#c8a45c", (dto.theme as JsonObject)["--accent"]?.jsonPrimitive?.content) + // nav_public stays a raw string here: settings.value is TEXT, so it is + // parsed a second time by SiteAppearance. + assertEquals("""{"/wiki":{"label":"Codex"}}""", dto.navPublic) + } + + @Test fun anUnexpectedThemeKindStillDecodesTheRest() { + // `theme` is a raw JsonElement precisely so a value we did not expect + // cannot fail the decode and take brand/push with it. + val dto = json.decodeFromString( + """{"theme":"nonsense","brand":{"name":"UOMysticmoon"}}""", + ) + assertEquals("UOMysticmoon", dto.brand.name) } } diff --git a/app/src/test/java/com/runicgateway/app/data/appearance/SettingsJsonTest.kt b/app/src/test/java/com/runicgateway/app/data/appearance/SettingsJsonTest.kt new file mode 100644 index 0000000..a0f36fe --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/appearance/SettingsJsonTest.kt @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.appearance + +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The second-stage parse of a JSON-valued settings row (THEMING_AND_NAV.md §3). + * The rule under test is the one the web client's `parseJsonSetting` states: + * anything that is not a plain object reads as **absent**, never as an error. + */ +class SettingsJsonTest { + + @Test fun parsesAPlainObject() { + val parsed = parseJsonSetting("""{"/wiki":{"label":"Codex","order":0}}""") + assertEquals(1, parsed!!.size) + assertEquals(setOf("/wiki"), parsed.keys) + } + + @Test fun parsesTheWrappedPublicShape() { + val parsed = parseJsonSetting( + """{"items":{"/":{"hidden":true}},"sections":[{"id":"s1","label":"Play"}],"links":[]}""", + ) + assertEquals(setOf("items", "sections", "links"), parsed!!.keys) + } + + @Test fun absentValuesReadAsNull() { + assertNull(parseJsonSetting(null)) + assertNull(parseJsonSetting("")) + } + + @Test fun malformedJsonReadsAsNull() { + assertNull(parseJsonSetting("{")) + assertNull(parseJsonSetting("""{"a":}""")) + assertNull(parseJsonSetting("not json at all")) + } + + @Test fun nonObjectJsonReadsAsNull() { + // A stored `null`, number, string or array is as unusable to every + // consumer of these keys as a syntax error is. + assertNull(parseJsonSetting("null")) + assertNull(parseJsonSetting("4")) + assertNull(parseJsonSetting("\"x\"")) + assertNull(parseJsonSetting("[]")) + } + + @Test fun unusualKeysAndValuesSurviveVerbatim() { + // The parse stage validates the *kind*, not the shape — a nonsense entry + // is dropped later, by the phase that reads it. + val parsed = parseJsonSetting("""{"/site/news":{"order":"first"},"nonsense":7}""") + assertEquals(JsonPrimitive(7), parsed!!["nonsense"]) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/appearance/SiteAppearanceTest.kt b/app/src/test/java/com/runicgateway/app/data/appearance/SiteAppearanceTest.kt new file mode 100644 index 0000000..ef64b31 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/appearance/SiteAppearanceTest.kt @@ -0,0 +1,97 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.appearance + +import com.runicgateway.app.data.api.dto.SettingsDto +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [SiteAppearance.from] — the coercion between the settings payload and what the + * theme and the drawer read (THEMING_AND_NAV.md §2, §3). + * + * The claims that matter here are the two the milestone rests on: an untouched + * instance resolves to *nothing* (so the shipped app renders), and a bad token + * costs exactly its own token. + */ +class SiteAppearanceTest { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private fun appearanceOf(body: String) = + SiteAppearance.from(json.decodeFromString(body)) + + @Test fun untouchedInstanceResolvesToNoOverrides() { + // No theme_visual row, no nav_public row: the shipped app, exactly (§2). + val appearance = appearanceOf("""{"brand":{"name":"UOMysticmoon"}}""") + assertTrue(appearance.theme.isEmpty()) + assertNull(appearance.navPublic) + assertEquals("UOMysticmoon", appearance.brand?.name) + } + + @Test fun failedSettingsCallIsTheSameAsNoOverrides() { + assertSame(SiteAppearance.NONE, SiteAppearance.from(null)) + assertNull(SiteAppearance.NONE.brand) + assertTrue(SiteAppearance.NONE.theme.isEmpty()) + assertNull(SiteAppearance.NONE.navPublic) + } + + @Test fun resolvedThemeTokensAreReadAsAMap() { + val appearance = appearanceOf( + """{"theme":{"--accent":"#c8a45c","--bg":"#1a1410","--radius-card":"10px", + "--shadow-card":"none","--sans":"Inter, sans-serif"}}""", + ) + assertEquals("#c8a45c", appearance.theme["--accent"]) + assertEquals("#1a1410", appearance.theme["--bg"]) + assertEquals("10px", appearance.theme["--radius-card"]) + assertEquals("none", appearance.theme["--shadow-card"]) + assertEquals("Inter, sans-serif", appearance.theme["--sans"]) + } + + @Test fun anEmptyThemeMapIsTheSameAsAbsent() { + // The server returns null rather than {} — the app must not depend on that. + assertTrue(appearanceOf("""{"theme":{}}""").theme.isEmpty()) + } + + @Test fun aBadTokenCostsOnlyItself() { + // AC-2 in miniature at the decode boundary: a non-string or blank value is + // dropped field-by-field, and its neighbours still apply. + val appearance = appearanceOf( + """{"theme":{"--accent":"#c8a45c","--bg":7,"--line":null,"--ink":" "}}""", + ) + assertEquals(mapOf("--accent" to "#c8a45c"), appearance.theme) + } + + @Test fun aThemeOfTheWrongKindDoesNotCostTheBrand() { + // The whole reason `theme` is modeled as a raw JsonElement: one unexpected + // value must not fail the decode and take brand and push down with it. + val appearance = appearanceOf( + """{"theme":"not an object","brand":{"name":"UOMysticmoon","accent":"#7f99bd"}, + "push":{"ntfyUrl":"https://ntfy.example.com"}}""", + ) + assertTrue(appearance.theme.isEmpty()) + assertEquals("#7f99bd", appearance.brand?.accent) + } + + @Test fun navPublicIsParsedASecondTime() { + // It arrives as a JSON string inside a JSON object, because settings.value + // is TEXT. + val appearance = appearanceOf("""{"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}"}""") + assertEquals(setOf("/wiki"), appearance.navPublic?.keys) + } + + @Test fun aMalformedNavPublicDoesNotCostTheTheme() { + val appearance = appearanceOf("""{"nav_public":"{oops","theme":{"--accent":"#c8a45c"}}""") + assertNull(appearance.navPublic) + assertEquals("#c8a45c", appearance.theme["--accent"]) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt index fedb2da..470f222 100644 --- a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt @@ -38,15 +38,22 @@ class ContentViewModelTest { private val settings = SettingsRepository(api) // ── News hub ────────────────────────────────────────────────────────── + /** No category argument: how every route into the hub but §6.2's arrives. */ + private fun newsViewModel(category: String? = null) = + NewsViewModel( + content, + SavedStateHandle(category?.let { mapOf(Routes.Args.CATEGORY to it) } ?: emptyMap()), + ) + @Test fun newsLoadsSelectedCategory() { api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi")) - val vm = NewsViewModel(content) + val vm = newsViewModel() assertTrue(vm.state.value is UiState.Success) assertEquals(1, (vm.state.value as UiState.Success).data.size) } @Test fun newsSelectCategoryReloads() { - val vm = NewsViewModel(content) + val vm = newsViewModel() api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N")) vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER) assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value) @@ -55,7 +62,20 @@ class ContentViewModelTest { @Test fun newsServerErrorIsUiError() { api.error = httpError(500) - assertTrue(NewsViewModel(content).state.value is UiState.Error) + assertTrue(newsViewModel().state.value is UiState.Error) + } + + @Test fun newsOpensOnTheCategoryTheRouteAsksFor() { + // The app's half of an admin's nav override or added link pointing at one of + // the website's three category pages (THEMING_AND_NAV.md §6.2). + val vm = newsViewModel("five-on-friday") + assertEquals(ContentRepository.PostCategory.FIVE_ON_FRIDAY, vm.category.value) + } + + @Test fun newsFallsBackToTheDefaultFeedForAnUnknownCategory() { + // A hand-edited settings row, or a category the site has and the app doesn't. + assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel("bogus").category.value) + assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel().category.value) } // ── Post detail (SavedStateHandle args) ───────────────────────────────── diff --git a/app/src/test/java/com/runicgateway/app/ui/components/BrandAssetsTest.kt b/app/src/test/java/com/runicgateway/app/ui/components/BrandAssetsTest.kt new file mode 100644 index 0000000..71c23c3 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/components/BrandAssetsTest.kt @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.components + +import com.runicgateway.app.data.api.dto.BrandDto +import com.runicgateway.app.data.appearance.SiteAppearance +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * §5.6's one testable rule: **an empty slot resolves to nothing.** The drawing + * itself is out of reach here — the app carries no Robolectric, so a composable + * body cannot run in a JVM test and phase 4's layout is AC-5's job — but the + * decision of whether to draw at all is pure, and it is the decision that keeps + * an unbranded instance laying out as it did before M12. + * + * The resolver is faked as the absolute-URL join the real one performs + * (`AppViewModel.resolveAsset`, unchanged by this phase), so these assert + * [brandAssetUrl]'s own contract rather than re-testing the network layer. + */ +class BrandAssetsTest { + + private val resolve: (String?) -> String? = { path -> + when { + path.isNullOrBlank() -> null + path.startsWith("http") -> path + else -> "https://shard.example${if (path.startsWith("/")) "" else "/"}$path" + } + } + + // --- the empty slot: every shape "not set" arrives in ------------------ + + @Test + fun `a null slot resolves to nothing`() { + assertNull(brandAssetUrl(null, resolve)) + } + + @Test + fun `an empty slot resolves to nothing`() { + // The server publishes "" for an asset that was never uploaded, and BrandDto + // defaults to it — this is the case that carries the untouched instance. + assertNull(brandAssetUrl("", resolve)) + } + + @Test + fun `a whitespace-only slot resolves to nothing`() { + assertNull(brandAssetUrl(" ", resolve)) + } + + @Test + fun `the shipped brand has neither a logo nor a hero`() { + // AC-1 for phase 4: nothing about a default BrandDto puts an image on screen. + val brand = BrandDto() + assertNull(brandAssetUrl(brand.logo, resolve)) + assertNull(brandAssetUrl(brand.hero, resolve)) + } + + @Test + fun `a failed settings load leaves no brand to draw`() { + // SiteAppearance.NONE is what a dead backend produces (§2). It has no brand + // at all, so both slots are absent rather than empty. + val brand: BrandDto? = SiteAppearance.NONE.brand + assertNull(brand) + assertNull(brandAssetUrl(brand?.logo, resolve)) + assertNull(brandAssetUrl(brand?.hero, resolve)) + } + + // --- the filled slot --------------------------------------------------- + + @Test + fun `a site-relative upload resolves against the shard's base`() { + assertEquals( + "https://shard.example/uploads/brand/logo.png", + brandAssetUrl("/uploads/brand/logo.png", resolve), + ) + } + + @Test + fun `an absolute URL passes through`() { + // BRAND_LOGO may be set to an off-site URL; the resolver leaves those alone. + assertEquals( + "https://cdn.example/logo.svg", + brandAssetUrl("https://cdn.example/logo.svg", resolve), + ) + } + + // --- the second blank check ------------------------------------------- + + @Test + fun `a resolver that returns nothing resolves to nothing`() { + // No base URL configured yet: the real resolver hands the path back or gives + // up. Either way the slot must not become an image request. + assertNull(brandAssetUrl("/uploads/brand/logo.png") { null }) + } + + @Test + fun `a resolver that returns blank resolves to nothing`() { + // Why the blank check is on both sides of the resolver, not just the input. + assertNull(brandAssetUrl("/uploads/brand/logo.png") { "" }) + assertNull(brandAssetUrl("/uploads/brand/logo.png") { " " }) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt new file mode 100644 index 0000000..9c12aa3 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt @@ -0,0 +1,271 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Role +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionUser +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The public-nav override merge (THEMING_AND_NAV.md §6): label, order and hidden, + * applied to the coded [APP_MENU] and nothing else. + * + * Two things these tests are really about. **AC-1** — an instance whose admin never + * touched the nav must get the drawer the app shipped with, which here is the + * strongest possible assertion: the same list instance back. And **AC-3** — the + * merge runs before [visibleEntries] and cannot reach past it, so a `hidden: false` + * on a gated row still shows nothing. + */ +class NavOverridesTest { + + private fun nav(vararg items: Pair): JsonObject = + buildJsonObject { for ((path, entry) in items) put(path, entry) } + + private fun entry( + label: String? = null, + order: Int? = null, + hidden: Boolean? = null, + ): JsonObject = buildJsonObject { + label?.let { put("label", it) } + order?.let { put("order", it) } + hidden?.let { put("hidden", it) } + } + + private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route } + + /** The public block's routes, in coded order — the first nine of APP_MENU. */ + private val codedPublic = listOf( + Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES, + Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"), + ) + + // ── AC-1: the untouched instance ───────────────────────────────────── + + @Test fun noStoredRowReturnsTheCodedMenuItself() { + // Identity, not equality: the drawer of an instance that never edited its + // nav is the shipped one, and nothing was rebuilt to arrive at it. + assertSame(APP_MENU, applyNavOverrides(APP_MENU, null)) + } + + @Test fun anEmptyRowReturnsTheCodedMenuItself() { + assertSame(APP_MENU, applyNavOverrides(APP_MENU, buildJsonObject { })) + } + + @Test fun aRowWithNothingUsableInItReturnsTheCodedMenuItself() { + // A blank label, a non-finite order, `hidden: false`, a path the app has no + // screen for, and a path it maps but doesn't put in the drawer. None of it + // says anything, so none of it may cost the coded menu. + val stored = nav( + "/" to entry(label = " "), + "/site/news" to entry(hidden = false), + "/admin/appearance" to entry(label = "Nope"), + "/site/screenshots" to entry(label = "Shots", order = 0), + "/site/champs" to entry(hidden = true), + ) + + assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored)) + } + + @Test fun aMalformedEntryIsDroppedAndItsNeighbourKept() { + val stored = buildJsonObject { + put("/site/news", "not an object") + put("/wiki", entry(label = "Codex")) + } + + val merged = applyNavOverrides(APP_MENU, stored) + + assertEquals(codedPublic, merged.take(9).map { it.route }) + assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label) + assertNull(merged.first { it.route == Routes.NEWS }.label) + } + + // ── Labels ─────────────────────────────────────────────────────────── + + @Test fun aLabelOverridesTheBundledString() { + val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm "))) + + val shard = merged.first { it.route == Routes.SHARD } + assertEquals("The Realm", shard.label) + // The override lands on `label` and nothing else — the gates are untouched. + assertEquals(ShardFeature.STATUS, shard.feature) + assertEquals(MenuAccess.PUBLIC, shard.access) + assertEquals(codedPublic, merged.take(9).map { it.route }) + } + + @Test fun aNonStringLabelIsIgnored() { + val stored = buildJsonObject { put("/wiki", buildJsonObject { put("label", 7) }) } + + assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored)) + } + + // ── Hidden ─────────────────────────────────────────────────────────── + + @Test fun hiddenDropsTheRow() { + val routes = routes(nav("/site/market" to entry(hidden = true))) + + assertTrue(Routes.SHARD_MARKET !in routes) + assertEquals(APP_MENU.size - 1, routes.size) + } + + @Test fun homeCanBeHidden() { + // Mirrors the website, where `/` is hideable too. Home stays the NavHost's + // start destination and stays reachable by back-press; the app does not + // invent a policy the site doesn't have. + val routes = routes(nav("/" to entry(hidden = true))) + + assertTrue(Routes.HOME !in routes) + } + + @Test fun hiddenFalseHidesNothing() { + assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false)))) + } + + @Test fun hiddenWinsOverALabelOnTheSameRow() { + val routes = routes(nav("/wiki" to entry(label = "Codex", hidden = true))) + + assertTrue(Routes.WIKI !in routes) + } + + // ── Order ──────────────────────────────────────────────────────────── + + @Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() { + // The website's own indices: About is 15 and Home is 0, so swapping them + // is what an admin dragging About to the top writes. + val routes = routes( + nav( + "/site/about" to entry(order = 0), + "/" to entry(order = 15), + ), + ) + + assertEquals( + listOf( + Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES, + Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME, + ), + routes.take(9), + ) + } + + @Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() { + // The tie-break that needs the website's order rather than the app's: an + // explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where + // the three news categories sit between News and Wiki). Explicit wins. + val routes = routes(nav("/site/about" to entry(order = 5))) + + assertEquals( + listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI), + routes.take(4), + ) + } + + @Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() { + // Contact, Account, Notifications, the three player groups and the four + // staff rows have no website counterpart to be reordered against (§6.2). + val tail = APP_MENU.drop(9).map { it.route } + + val merged = routes(nav("/site/about" to entry(order = 0))) + + assertEquals(tail, merged.drop(9)) + } + + @Test fun reorderingAndHidingCompose() { + val routes = routes( + nav( + "/site/about" to entry(order = 0), + "/" to entry(hidden = true), + ), + ) + + assertEquals(Routes.page("about"), routes.first()) + assertTrue(Routes.HOME !in routes) + } + + // ── The two stored shapes ──────────────────────────────────────────── + + @Test fun theWrappedShapeIsRead() { + // Website phase 10 wraps the map as {items, sections, links} without + // migrating what phases 6-8 stored bare, so both shapes are live. + val stored = buildJsonObject { + put("items", nav("/wiki" to entry(label = "Codex"))) + put("sections", buildJsonObject { }) + put("links", buildJsonObject { }) + } + + val merged = applyNavOverrides(APP_MENU, stored) + + assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label) + } + + @Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() { + // This merge is items-only; `buildNavTree` is what renders the structure + // around them (§6.3), and it leans on this staying true — an `items` map + // that says nothing still returns the coded menu itself. + val stored = buildJsonObject { + put("items", buildJsonObject { }) + put("sections", buildJsonObject { put("id", "lore") }) + } + + assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored)) + } + + // ── AC-3: the merge cannot reach past the gates ────────────────────── + + @Test fun anOverrideCannotUnhideAFeatureGatedRow() { + val stored = nav( + "/site/market" to entry(label = "Bazaar", hidden = false, order = 0), + ) + + val visible = visibleEntries( + applyNavOverrides(APP_MENU, stored), + Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)), + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ).map { it.route } + + // Relabeled and moved to the front, and still not shown: the shard does not + // publish the market, and an admin does not outrank that. + assertTrue(Routes.SHARD_MARKET !in visible) + assertTrue(Routes.SHARD in visible) + } + + @Test fun anOverrideCannotUnhideARoleGatedRow() { + val stored = nav("/" to entry(order = 99)) + + val visible = visibleEntries( + applyNavOverrides(APP_MENU, stored), + Session.SignedOut, + features = null, + ).map { it.route } + + assertTrue(Routes.ACCOUNT !in visible) + assertTrue(Routes.ADMIN_DASHBOARD !in visible) + assertTrue(Routes.PLAYER_CHARACTERS !in visible) + } + + @Test fun theGatesRunOnTheMergedListNotTheCodedOne() { + // Hiding is subtractive on top of the gates, so the two compose: the row an + // admin hid is gone, and so is the row this caller may not see. + val stored = nav("/wiki" to entry(hidden = true)) + + val visible = visibleEntries( + applyNavOverrides(APP_MENU, stored), + Session.SignedOut, + ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)), + ).map { it.route } + + assertTrue(Routes.WIKI !in visible) + assertTrue(Routes.SHARD_MARKET !in visible) + assertTrue(Routes.HOME in visible) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt new file mode 100644 index 0000000..d4cc3a2 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt @@ -0,0 +1,180 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.data.repository.ContentRepository.PostCategory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The website path → app route table (THEMING_AND_NAV.md §6.2). + * + * This is the milestone's one piece of cross-repo coupling, so the tests are + * mostly about the table's *shape* — that it stays complete, unambiguous, and + * honest about which rows the app actually surfaces in its drawer. + */ +class NavPathsTest { + + @Test fun everyWebsiteNavPathIsMapped() { + // The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the + // site adds one, this is the test that says so — a path with no mapping is + // silently unresolvable in phase 6's link handling. + assertEquals(16, WEBSITE_PUBLIC_NAV.size) + assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size) + } + + @Test fun everyMappedRouteIsDistinct() { + // WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a + // row's position from the sort. + assertEquals(WEBSITE_PUBLIC_NAV.size, WEBSITE_PUBLIC_NAV.map { it.route }.toSet().size) + assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_ROUTE_ORDER.size) + } + + @Test fun theWebsitesOrderIsPreserved() { + // Load-bearing: a stored `order` is an index into this list. + assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME]) + assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS]) + assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI]) + assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")]) + } + + @Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() { + // Nine of the sixteen have a drawer row. The other seven are mapped but not + // surfaced — three news category tabs and the four Shard hub boards — and + // an override for one of them is ignored rather than obeyed (§6.2). + val coded = APP_MENU.map { it.route }.toSet() + val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path } + + assertEquals( + listOf( + "/", "/site/news", "/wiki", "/site/shard", "/site/rules", + "/site/atlas", "/site/leaderboards", "/site/market", "/site/about", + ), + surfaced, + ) + } + + @Test fun theSevenUnsurfacedPathsStillResolveToAScreen() { + // Phase 6's added links resolve against the same table, and there a category + // tab or a hub board is a perfectly good destination. + val unsurfaced = listOf( + "/site/screenshots", "/site/five-on-friday", "/site/newsletter", + "/site/champs", "/site/guilds", "/site/governors", "/site/houses", + ) + + assertTrue(unsurfaced.all { appRouteForWebPath(it) != null }) + assertTrue(unsurfaced.none { appRouteForWebPath(it) in APP_MENU.map { e -> e.route } }) + } + + @Test fun theNewsCategoriesMapToTheirTab() { + assertEquals("news?category=screenshots", appRouteForWebPath("/site/screenshots")) + assertEquals("news?category=five-on-friday", appRouteForWebPath("/site/five-on-friday")) + assertEquals("news?category=newsletter", appRouteForWebPath("/site/newsletter")) + // The plain news path is the un-argumented route, so it matches the drawer's + // coded row and opens the default tab. + assertEquals(Routes.NEWS, appRouteForWebPath("/site/news")) + } + + @Test fun theCategoryRouteMatchesTheNavHostPattern() { + // The pattern the NavHost declares and the value callers navigate to have to + // agree on the query key, or the argument arrives as null and the screen + // silently opens the default tab. + assertEquals("news?category={category}", Routes.NEWS_ROUTE) + assertTrue(Routes.NEWS_ROUTE.startsWith("${Routes.NEWS}?")) + for (category in PostCategory.entries) { + assertEquals("${Routes.NEWS}?category=${category.urlSlug}", Routes.news(category)) + } + } + + @Test fun theRoutePatternStripsToTheTopLevelRoute() { + // How RunicApp recognizes the News destination: `destination.route` is the + // pattern, and the drawer's row is the bare route. + assertEquals(Routes.NEWS, Routes.NEWS_ROUTE.substringBefore('?')) + assertEquals(Routes.NEWS, Routes.news(PostCategory.NEWSLETTER).substringBefore('?')) + } + + // ── Lookup hygiene ─────────────────────────────────────────────────── + + @Test fun anUnknownPathResolvesToNothing() { + assertNull(appRouteForWebPath("/admin/appearance")) + assertNull(appRouteForWebPath("/site/news/some-post")) + assertNull(appRouteForWebPath("https://elsewhere.example/")) + } + + @Test fun blankAndNullResolveToNothing() { + assertNull(appRouteForWebPath(null)) + assertNull(appRouteForWebPath("")) + assertNull(appRouteForWebPath(" ")) + } + + @Test fun aTrailingSlashIsTolerated() { + // A hand-edited settings row may carry one; the root is left alone. + assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/")) + assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ ")) + assertEquals(Routes.HOME, appRouteForWebPath("/")) + } + + // ── resolveWebPath: an added link may name any page on the site (§6.3) ── + + @Test fun theNavTablesSixteenPathsResolveTheSameWay() { + // An added link to a path the nav already knows must land where the nav row + // does, or the same destination would behave differently depending on how + // the admin reached it. + for (row in WEBSITE_PUBLIC_NAV) { + assertEquals(row.route, resolveWebPath(row.path)) + } + } + + @Test fun theSitesDetailRoutesResolve() { + // Read off website/client/src/App.jsx. Note what is NOT here: the site has + // no /site/news/ route — its one post-detail route is the newsletter's. + assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing")) + assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon")) + assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C")) + assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12")) + } + + @Test fun aTopLevelSlugIsACmsPage() { + // The site serves CMS pages from a top-level /, so this is the rule + // that opens an admin's own page natively rather than in a browser. + assertEquals(Routes.page("donate"), resolveWebPath("/donate")) + assertEquals(Routes.page("about"), resolveWebPath("/site/about")) + } + + @Test fun theSitesOwnSectionsAreNotCmsPages() { + // React Router ranks its static routes above /:slug, and so must the app — + // otherwise a link to the admin panel would open a 404 CMS page in-app + // instead of the real thing in a browser. + for (path in listOf("/admin", "/account", "/player", "/site", "/invite", "/preview", "/api", "/uploads")) { + assertNull(path, resolveWebPath(path)) + } + // /wiki is reserved from the catch-all but mapped by the table above it. + assertEquals(Routes.WIKI, resolveWebPath("/wiki")) + } + + @Test fun aPathTheAppHasNoScreenForHandsOff() { + assertNull(resolveWebPath("/site/status")) + assertNull(resolveWebPath("/site/shard/activity")) + assertNull(resolveWebPath("/account/login")) + assertNull(resolveWebPath("/admin/navigation")) + assertNull(resolveWebPath("/site/atlas/dragon/extra")) + } + + @Test fun aQueryOrFragmentHandsOff() { + // No app route takes either, so a native match would quietly drop what the + // admin wrote. The browser honors it exactly. + assertNull(resolveWebPath("/site/news?tag=patch")) + assertNull(resolveWebPath("/donate#tiers")) + assertEquals(Routes.NEWS, resolveWebPath("/site/news")) + } + + @Test fun aMalformedPathResolvesToNothing() { + assertNull(resolveWebPath(null)) + assertNull(resolveWebPath("")) + assertNull(resolveWebPath("/site//news")) + assertNull(resolveWebPath("https://elsewhere.example/donate")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt new file mode 100644 index 0000000..43745ef --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt @@ -0,0 +1,455 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Role +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionUser +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and + * the gate that prunes it. + * + * Three things these tests are really about. **AC-1**: an admin who created no + * structure gets phase 5 back untouched, and an untouched instance gets the coded + * [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and + * remains the boundary — including inside a section, and including the case where + * it empties one. And the link path rule, which is what keeps "an override may + * never introduce navigation" true of a feature whose whole job is to add entries: + * a link may name any page **on this site**, and nothing else. + */ +class NavTreeTest { + + // ── Fixtures ───────────────────────────────────────────────────────── + + private fun stored( + items: JsonObject = buildJsonObject { }, + sections: List = emptyList(), + links: List = emptyList(), + ): JsonObject = buildJsonObject { + put("items", items) + put("sections", buildJsonArray { sections.forEach { add(it) } }) + put("links", buildJsonArray { links.forEach { add(it) } }) + } + + private fun items(vararg entries: Pair): JsonObject = + buildJsonObject { for ((path, entry) in entries) put(path, entry) } + + private fun item( + label: String? = null, + order: Int? = null, + hidden: Boolean? = null, + section: String? = null, + ): JsonObject = buildJsonObject { + label?.let { put("label", it) } + order?.let { put("order", it) } + hidden?.let { put("hidden", it) } + section?.let { put("section", it) } + } + + private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject = + buildJsonObject { + put("id", id) + label?.let { put("label", it) } + order?.let { put("order", it) } + } + + private fun link( + id: String = "l1", + label: String? = "Donate", + to: String? = "/donate", + order: Int? = null, + section: String? = null, + ): JsonObject = buildJsonObject { + put("id", id) + label?.let { put("label", it) } + to?.let { put("to", it) } + order?.let { put("order", it) } + section?.let { put("section", it) } + } + + private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic) + + /** Top-level routes, with a section standing in as `section:`. */ + private fun List.shape(): List = map { + when (it) { + is NavNode.Item -> it.entry.route + is NavNode.Link -> "link:${it.id}" + is NavNode.Section -> "section:${it.id}" + } + } + + private fun List.section(id: String): NavNode.Section = + filterIsInstance().first { it.id == id } + + private fun List.link(id: String): NavNode.Link = + filterIsInstance().first { it.id == id } + + // ── AC-1: no structure means phase 5, unchanged ────────────────────── + + @Test fun noStoredRowIsTheCodedMenu() { + val nodes = tree(null) + + assertEquals(APP_MENU.size, nodes.size) + // The entries themselves, not copies: with nothing stored, nothing was + // rebuilt to arrive at the drawer the app shipped with. + APP_MENU.forEachIndexed { index, entry -> + assertSame(entry, (nodes[index] as NavNode.Item).entry) + } + } + + @Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() { + // Phase 6 adds structure; it does not re-implement phase 5. An items-only + // row must give exactly what applyNavOverrides gives. + val row = stored(items = items("/site/about" to item(order = 0))) + + assertEquals( + applyNavOverrides(APP_MENU, row).map { it.route }, + tree(row).shape(), + ) + } + + @Test fun malformedSectionsAndLinksAreNotStructure() { + // Wrong kinds where the arrays should be — a hand-edited row, or the bare + // items map phases 6-8 stored. Neither is structure, so neither may cost + // the coded menu. + val row = buildJsonObject { + put("items", buildJsonObject { }) + put("sections", buildJsonObject { put("id", "lore") }) + put("links", "nope") + } + + assertEquals(APP_MENU.map { it.route }, tree(row).shape()) + } + + // ── Sections ───────────────────────────────────────────────────────── + + @Test fun aSectionCollectsItsMembersBeneathIt() { + val row = stored( + items = items( + "/wiki" to item(section = "lore"), + "/site/about" to item(section = "lore"), + ), + sections = listOf(section("lore", label = " The Realm ")), + ) + + val nodes = tree(row) + + assertTrue(Routes.WIKI !in nodes.shape()) + assertEquals("The Realm", nodes.section("lore").label) + assertEquals( + listOf(Routes.WIKI, Routes.page("about")), + nodes.section("lore").items.shape(), + ) + } + + @Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() { + // An admin-created entity with no stored order appends in creation order + // rather than jumping to the front on a 0 default. The app's own rows stay + // behind it, where they already sit (§6.2). + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore")), + ) + + val shape = tree(row).shape() + + // Eight public rows are left at the top level (Wiki moved into the section), + // then the section, then the app's own rows. + assertEquals("section:lore", shape[8]) + assertEquals(Routes.CONTACT, shape[9]) + } + + @Test fun aSectionsOrderPlacesItAmongTheCodedRows() { + // Sections sort on the same number line as everything else: the website's + // sixteen indices, then admin-created entities after them. + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore", order = 0)), + ) + + assertEquals("section:lore", tree(row).shape().first()) + } + + @Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore", label = " ")), + ) + + val nodes = tree(row) + + assertTrue(nodes.filterIsInstance().isEmpty()) + // The section never existed, so the reference to it is dangling and the row + // is an ordinary top-level one — not a row that vanished with its section. + assertTrue(Routes.WIKI in nodes.shape()) + } + + @Test fun aRepeatedSectionIdKeepsTheFirst() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore", label = "First"), section("lore", label = "Second")), + ) + + val sections = tree(row).filterIsInstance() + + assertEquals(1, sections.size) + assertEquals("First", sections.single().label) + } + + @Test fun anItemNamingAnUnknownSectionStaysTopLevel() { + val row = stored( + items = items("/wiki" to item(section = "nope")), + sections = listOf(section("lore")), + ) + + val nodes = tree(row) + + assertTrue(Routes.WIKI in nodes.shape()) + assertTrue(nodes.section("lore").items.isEmpty()) + } + + @Test fun aHiddenItemIsDroppedEvenInsideASection() { + val row = stored( + items = items("/wiki" to item(hidden = true, section = "lore")), + sections = listOf(section("lore")), + ) + + val nodes = tree(row) + + assertTrue(Routes.WIKI !in nodes.shape()) + assertTrue(nodes.section("lore").items.isEmpty()) + } + + @Test fun aLabelStillLandsOnASectionedRow() { + val row = stored( + items = items("/wiki" to item(label = "Codex", section = "lore")), + sections = listOf(section("lore")), + ) + + val wiki = tree(row).section("lore").items.filterIsInstance().single() + + assertEquals("Codex", wiki.entry.label) + // The override lands on the label and nothing else — the gates are untouched. + assertEquals(MenuAccess.PUBLIC, wiki.entry.access) + assertNull(wiki.entry.feature) + } + + @Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() { + // Same rule as phase 5's: the app puts the hub boards behind the Shard hub + // deliberately, and grouping is no more an invitation to surface one than + // relabelling was (§6.2). + val row = stored( + items = items("/site/champs" to item(section = "lore", label = "Champs")), + sections = listOf(section("lore")), + ) + + val nodes = tree(row) + + assertTrue(nodes.section("lore").items.isEmpty()) + assertTrue(Routes.SHARD_CHAMPS !in nodes.shape()) + } + + // ── Added links ────────────────────────────────────────────────────── + + @Test fun aLinkTheAppCanResolveCarriesItsRoute() { + val row = stored(links = listOf(link(to = "/wiki/smithing"))) + + assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route) + } + + @Test fun aLinkTheAppCannotResolveHandsOff() { + // A null route is the Custom Tab; the path is kept verbatim so the browser + // gets exactly what the admin wrote. + val row = stored(links = listOf(link(to = "/site/status"))) + + val node = tree(row).link("l1") + + assertNull(node.route) + assertEquals("/site/status", node.path) + } + + @Test fun aLinkThatWouldLeaveTheOriginIsDropped() { + // The website's own read rule, ported: a stored value that is not a + // single-slash site path is dropped rather than rendered, so a hand-edited + // row cannot put an off-site link in the drawer. + val bad = listOf( + "//evil.example/x", "https://evil.example", "donate", "/don ate", + "/don\"ate", "/don'ate", "/don().isEmpty()) + } + } + + @Test fun aLinkWithoutAnIdLabelOrPathIsDropped() { + val row = stored( + links = listOf( + buildJsonObject { + put("label", "No id") + put("to", "/a") + }, + link(id = "no-label", label = null), + link(id = "no-to", to = null), + link(id = "blank-label", label = " "), + link(id = "good"), + ), + ) + + assertEquals(listOf("good"), tree(row).filterIsInstance().map { it.id }) + } + + @Test fun aRepeatedLinkIdKeepsTheFirst() { + val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second"))) + + assertEquals("First", tree(row).link("l1").label) + } + + @Test fun linksAppendAfterTheCodedRowsInCreationOrder() { + val row = stored(links = listOf(link(id = "a"), link(id = "b"))) + + val shape = tree(row).shape() + + assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") }) + assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1]) + } + + @Test fun aLinksOrderPlacesItAmongTheCodedRows() { + val row = stored(links = listOf(link(order = 0))) + + assertEquals("link:l1", tree(row).shape().first()) + } + + @Test fun aLinkCanSitInsideASection() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore")), + links = listOf(link(section = "lore"), link(id = "top")), + ) + + val nodes = tree(row) + + assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape()) + assertTrue("link:top" in nodes.shape()) + } + + @Test fun aLinkNamingAnUnknownSectionStaysTopLevel() { + // Its destination is still good; only the grouping was wrong. + val row = stored(links = listOf(link(section = "nope"))) + + assertTrue("link:l1" in tree(row).shape()) + } + + // ── AC-3: the gates run after the build, and empty a section honestly ── + + private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)) + + private fun prune(nodes: List, session: Session, features: ShardFeatures?) = + pruneNav(nodes) { isEntryVisible(it, session, features) } + + @Test fun aSectionEmptiedByTheGatesIsDropped() { + // The case the rule exists for: a group whose every member is withheld by + // the shard's visibility config must not draw as a header over nothing. + val row = stored( + items = items("/site/market" to item(section = "lore")), + sections = listOf(section("lore")), + ) + + val pruned = prune( + tree(row), + admin, + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ) + + assertTrue(pruned.filterIsInstance().isEmpty()) + } + + @Test fun aSectionKeepsTheMembersThisCallerMaySee() { + val row = stored( + items = items( + "/site/market" to item(section = "lore"), + "/wiki" to item(section = "lore"), + ), + sections = listOf(section("lore")), + ) + + val pruned = prune( + tree(row), + admin, + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ) + + assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape()) + } + + @Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() { + // Relabelled, moved to the front, marked `hidden: false` and tucked into a + // section of its own — and still not shown, because the shard does not + // publish the market and an admin does not outrank that. + val row = stored( + items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")), + sections = listOf(section("lore", order = 0)), + ) + + val pruned = prune( + tree(row), + admin, + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ) + + assertTrue(pruned.filterIsInstance().isEmpty()) + assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET }) + } + + @Test fun aSectionSurvivesOnALinkAlone() { + // Links carry no gate — the page behind one enforces its own access — so a + // section holding one is never emptied by the caller's role. + val row = stored( + items = items("/site/market" to item(section = "lore")), + sections = listOf(section("lore")), + links = listOf(link(section = "lore")), + ) + + val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet())) + + assertEquals(listOf("link:l1"), pruned.section("lore").items.shape()) + } + + @Test fun theAppsOwnRowsAreStillGatedInTheTree() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore")), + ) + + val shape = prune(tree(row), Session.SignedOut, features = null).shape() + + assertTrue(Routes.ACCOUNT !in shape) + assertTrue(Routes.ADMIN_DASHBOARD !in shape) + assertTrue(Routes.PLAYER_CHARACTERS !in shape) + assertTrue(Routes.CONTACT in shape) + } + + @Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() { + // The two paths through the drawer have to agree: prune(tree) for a caller + // is exactly visibleEntries of the coded menu for that caller. + val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET)) + + assertEquals( + visibleEntries(APP_MENU, admin, features).map { it.route }, + prune(tree(null), admin, features).shape(), + ) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt new file mode 100644 index 0000000..9ed232f --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt @@ -0,0 +1,171 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * **AC-1, the no-op proof** (THEMING_AND_NAV.md §7). An instance with no + * `theme_visual` row must resolve to a color scheme equal to the one the app + * shipped before M12 — not "close enough", identical, because the app's M5 + * palette *is* the website's `runic-gateway` preset. + * + * Two things this test has to work around: + * + * - **`ColorScheme` does not implement `equals`** (checked against material3 + * 1.3.0), so "the full scheme, not a spot check" is a field-by-field compare. + * It is done by reflection over every color-valued getter rather than by a + * hand-written list, so a role that is added to Material — or one the mapping + * forgets — cannot silently escape the assertion. + * - The expected value is a **verbatim copy of the pre-M12 `ShardColorScheme`**, + * kept here rather than referenced, so the proof is against what the app used + * to do and not against whatever [shardColorScheme] does today. + */ +class ShardColorSchemeTest { + + /** The scheme exactly as `ui/theme/Theme.kt` declared it before M12. */ + private val preM12Scheme = darkColorScheme( + primary = ShardCta, + onPrimary = ShardOnCta, + secondary = ShardAccent, + onSecondary = ShardOnCta, + tertiary = ShardAccent, + onTertiary = ShardOnCta, + background = ShardPage, + onBackground = ShardBody, + surface = ShardSurface, + onSurface = ShardBody, + surfaceVariant = ShardElevated, + onSurfaceVariant = ShardMuted, + surfaceContainer = ShardElevated, + surfaceContainerHigh = ShardElevated, + surfaceContainerLow = ShardSurface, + outline = ShardOutline, + outlineVariant = ShardDivider, + secondaryContainer = ShardPillBg, + onSecondaryContainer = ShardPillFg, + error = ShardDanger, + onError = ShardOnCta, + errorContainer = ShardDangerBg, + onErrorContainer = ShardDanger, + ) + + /** + * The roles phase 8 deliberately moves off Material's defaults, and the values + * they move to. + * + * `surfaceContainerHighest` is the one that matters: it is + * `FilledCardTokens.ContainerColor`, so it is what every `ShardCard` paints with. + * Leaving it unmapped meant all 26 of them drew in `darkColorScheme()`'s grey + * rather than `--panel-a` — on themed instances *and* on untouched ones, which is + * why this is a visible change to the shipped app and not only a theming fix. It + * had been that way since M5; the AC-5 walk in phase 8 is what surfaced it, + * because M12 themed everything around the cards and left them behind. + * + * `surfaceContainerLowest` has no reader in this app today (the phase 8 sweep + * checked every Material component the app draws) and is mapped for consistency + * with `surfaceContainerLow`, not to fix anything. + * + * Everything else stays exactly where it was — that is what the test below is for. + */ + private val deliberatelyChanged = mapOf( + "surfaceContainerHighest" to ShardElevated, + "surfaceContainerLowest" to ShardSurface, + ) + + @Test + fun `the shipped palette reproduces the pre-M12 color scheme but for the card container`() { + assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.Shipped)) + } + + /** The same claim from the other end: an absent theme map is the shipped app. */ + @Test + fun `an absent theme map reproduces the pre-M12 color scheme`() { + assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.resolve(emptyMap()))) + } + + /** + * Every role but [deliberatelyChanged] is byte-for-byte the pre-M12 value, and + * each of those really did move — asserting the new value alone would still pass + * if Material's default happened to equal it. + */ + private fun assertPreM12ApartFromTheCardContainer(actual: ColorScheme) { + val before = roles(preM12Scheme) + val after = roles(actual) + assertEquals(before - deliberatelyChanged.keys, after - deliberatelyChanged.keys) + for ((role, expected) in deliberatelyChanged) { + assertEquals("$role should follow the palette", expected, after[role]) + assertNotEquals("$role was already the palette's value", expected, before[role]) + } + } + + /** Sanity: the comparison is capable of failing, and covers the whole scheme. */ + @Test + fun `the role comparison sees every color role`() { + val roles = roles(preM12Scheme) + // material3 1.3.0 declares 36 color roles; fewer than that means the + // reflection has stopped seeing them and the comparison above went hollow. + assertTrue("expected the full Material role set, got ${roles.keys}", roles.size >= 36) + assertEquals(ShardCta, roles["primary"]) + assertNotEquals( + roles, + roles(shardColorScheme(ShardPalette.Shipped.copy(cta = Color(0xFFC9973F)))), + ) + } + + /** A themed shard moves the roles its tokens own, and only those. */ + @Test + fun `a theme token reaches its Material role`() { + val themed = shardColorScheme( + ShardPalette.resolve(mapOf("--accent" to "#c9973f", "--bg-deep" to "#120c07")), + ) + val roles = roles(themed) + assertEquals(Color(0xFFC9973F), roles["secondary"]) + assertEquals(Color(0xFFC9973F), roles["tertiary"]) + assertEquals(Color(0xFF120C07), roles["background"]) + assertEquals(Color(0xFF120C07), roles["onPrimary"]) // derived: onCta tracks --bg-deep + assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens + } + + /** + * The phase 8 fix, stated as the thing a shard operator actually sees: set + * `--panel-flat` and the app's cards follow. This is the assertion that would have + * failed before the AC-5 walk, when `surfaceContainerHighest` — Material's filled + * `Card` container — was left at `darkColorScheme()`'s grey. + */ + @Test + fun `--panel-flat reaches the Material card container`() { + val roles = roles(shardColorScheme(ShardPalette.resolve(mapOf("--panel-flat" to "#1f160d")))) + val panel = Color(0xFF1F160D) + assertEquals(panel, roles["surfaceContainerHighest"]) // CardDefaults.cardColors() + assertEquals(panel, roles["surfaceVariant"]) + assertEquals(panel, roles["surfaceContainer"]) + assertEquals(panel, roles["surfaceContainerHigh"]) + } + + /** + * Every color role of a scheme, by name. `Color` is a value class, so the + * roles are the `long`-returning getters and their names carry Kotlin's + * mangling suffix (`getPrimary-0d7_KjU`), which is stripped here. + */ + private fun roles(scheme: ColorScheme): Map = + ColorScheme::class.java.declaredMethods + .filter { + it.name.startsWith("get") && + it.returnType == java.lang.Long.TYPE && + it.parameterCount == 0 + } + .associate { method -> + val name = method.name.removePrefix("get").substringBefore('-') + .replaceFirstChar { it.lowercase() } + // The getter hands back Color's packed ULong bits, not an ARGB int. + name to Color((method.invoke(scheme) as Long).toULong()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt new file mode 100644 index 0000000..6138576 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The token → palette resolution (THEMING_AND_NAV.md §5.1) and its + * forgiving-on-read rule (§2, AC-2). + */ +class ShardPaletteTest { + + /** AC-1, the palette half: no theme row resolves to the shipped M5 palette. */ + @Test + fun `no tokens resolve to the shipped palette`() { + assertSame(ShardPalette.Shipped, ShardPalette.resolve(emptyMap())) + } + + /** Every one of the fifteen has a home, and lands in the right one. */ + @Test + fun `all fifteen tokens map to their palette field`() { + val p = ShardPalette.resolve( + mapOf( + "--bg" to "#010101", + "--bg-deep" to "#020202", + "--panel-a" to "#030303", + "--panel-b" to "#040404", + "--panel-flat" to "#050505", + "--line" to "#060606", + "--line-soft" to "#070707", + "--accent" to "#080808", + "--accent-bright" to "#090909", + "--ink" to "#0a0a0a", + "--head" to "#0b0b0b", + "--text" to "#0c0c0c", + "--muted" to "#0d0d0d", + "--dim" to "#0e0e0e", + "--blue" to "#0f0f0f", + ), + ) + assertEquals(Color(0xFF010101), p.surface) + assertEquals(Color(0xFF020202), p.page) + assertEquals(Color(0xFF030303), p.cardTop) + assertEquals(Color(0xFF040404), p.cardBottom) + assertEquals(Color(0xFF050505), p.elevated) + assertEquals(Color(0xFF060606), p.outline) + assertEquals(Color(0xFF070707), p.divider) + assertEquals(Color(0xFF080808), p.accent) + assertEquals(Color(0xFF090909), p.cta) + assertEquals(Color(0xFF0A0A0A), p.heading) + assertEquals(Color(0xFF0B0B0B), p.headingDim) + assertEquals(Color(0xFF0C0C0C), p.body) + assertEquals(Color(0xFF0D0D0D), p.muted) + assertEquals(Color(0xFF0E0E0E), p.faint) + assertEquals(Color(0xFF0F0F0F), p.pillBg) + } + + /** + * AC-2. One valid token applies; four malformed ones each fall back on their + * own, and none of them costs the good one beside it. + */ + @Test + fun `a malformed token costs only itself`() { + val p = ShardPalette.resolve( + mapOf( + "--accent" to "#c9973f", // the one valid token + "--bg" to "not a color", + "--ink" to "", + "--line" to "#12", + "--text" to "rgb(1, 2, 3)", + ), + ) + assertEquals(Color(0xFFC9973F), p.accent) + assertEquals(ShardPalette.Shipped.surface, p.surface) + assertEquals(ShardPalette.Shipped.heading, p.heading) + assertEquals(ShardPalette.Shipped.outline, p.outline) + assertEquals(ShardPalette.Shipped.body, p.body) + } + + /** A token the app does not know is not an error — it is simply not read. */ + @Test + fun `unknown tokens are ignored`() { + val p = ShardPalette.resolve(mapOf("--mode-live" to "#ff0000", "--radius-card" to "2px")) + assertEquals(ShardPalette.Shipped, p) + } + + /** + * The pre-feature branding path: a `BRAND_ACCENT_COLOR` with no theme row + * still tints links and highlights, and nothing else moves. + */ + @Test + fun `brand accent seeds only the accent token`() { + val p = ShardPalette.resolve(emptyMap(), brandAccent = Color(0xFFC9973F)) + assertEquals(Color(0xFFC9973F), p.accent) + assertEquals(ShardPalette.Shipped.copy(accent = Color(0xFFC9973F)), p) + } + + /** + * The server resolves `brand.accent` as `theme['--accent'] || env`, so the two + * can only disagree if a client is holding a stale brand — the token wins. + */ + @Test + fun `the accent token beats the brand accent`() { + val p = ShardPalette.resolve(mapOf("--accent" to "#4f8ef7"), brandAccent = Color(0xFFC9973F)) + assertEquals(Color(0xFF4F8EF7), p.accent) + } + + /** An unparseable brand accent is the same as none. */ + @Test + fun `a malformed brand accent falls back to the shipped accent`() { + assertEquals(ShardPalette.Shipped, ShardPalette.resolve(emptyMap(), parseBrandColor("nope"))) + } + + /** + * The derived pair (§5.1): expressed in terms of another token, so they must + * follow it rather than being frozen at the shipped literal. + */ + @Test + fun `derived colors track the tokens they are expressed in`() { + val p = ShardPalette.resolve(mapOf("--bg-deep" to "#120c07", "--accent-bright" to "#e8c374")) + assertEquals(Color(0xFF120C07), p.onCta) + assertEquals(Color(0xFFE8C374), p.pillFg) + } + + /** And on the shipped palette they are exactly today's two constants. */ + @Test + fun `derived colors are the shipped constants by default`() { + assertEquals(ShardOnCta, ShardPalette.Shipped.onCta) + assertEquals(ShardPillFg, ShardPalette.Shipped.pillFg) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt new file mode 100644 index 0000000..b5e17d1 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * [ShardStructure.resolve] — the radius ratio (§5.2) and the shadow map (§5.4). + * + * The no-op proof is easier here than it was for the palette: material3's + * [Shapes] *does* implement `equals` (unlike `ColorScheme`), so a whole shape + * scale can be compared in one assertion. As in [ShardColorSchemeTest] the + * expected value is a **verbatim copy of the pre-M12 scale**, kept here rather + * than referenced, so the proof is against what the app used to draw and not + * against whatever [ShippedShapes] says today. + */ +class ShardStructureTest { + + /** The scale exactly as `ui/theme/Theme.kt` declared it before M12. */ + private val preM12Shapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) + + /** The `runic-gateway` preset's structure tokens, as the server publishes them. */ + private val runicGateway = mapOf( + "--radius-pill" to "999px", + "--radius-panel" to "12px", + "--radius-card" to "10px", + "--radius-input" to "8px", + "--shadow-card" to "0 14px 34px rgba(0, 0, 0, 0.3)", + ) + + private val fantasy = mapOf( + "--radius-pill" to "4px", + "--radius-panel" to "3px", + "--radius-card" to "2px", + "--radius-input" to "2px", + "--shadow-card" to "0 16px 38px rgba(0, 0, 0, 0.45)", + ) + + private val modern = mapOf( + "--radius-pill" to "8px", + "--radius-panel" to "8px", + "--radius-card" to "6px", + "--radius-input" to "6px", + "--shadow-card" to "0 8px 20px rgba(0, 0, 0, 0.25)", + ) + + @Test + fun `the shipped scale is the pre-M12 scale`() { + assertEquals(preM12Shapes, ShardStructure.Shipped.shapes) + assertSame(CircleShape, ShardStructure.Shipped.pill) + } + + @Test + fun `no theme resolves to the shipped structure`() { + assertEquals(ShardStructure.Shipped, ShardStructure.resolve(emptyMap())) + } + + @Test + fun `a theme with no structure tokens resolves to the shipped structure`() { + // A shard that themed its colors only still draws the app's own radii: + // every ratio is 1.0 because every token is absent. + val colorsOnly = mapOf("--accent" to "#7f99bd", "--bg" to "#0b1220") + assertEquals(ShardStructure.Shipped, ShardStructure.resolve(colorsOnly)) + } + + @Test + fun `the runic-gateway preset is a no-op`() { + // AC-1 for the structure half: an admin who explicitly picks the preset + // the app was drawn from gets ratio 1.0 on all four fields. + assertEquals(ShardStructure.Shipped, ShardStructure.resolve(runicGateway)) + } + + @Test + fun `fantasy scales the app's own dp, it does not adopt the web's`() { + val s = ShardStructure.resolve(fantasy) + // 2/8 -> 8dp becomes 2dp; the web's own value is also 2px, coincidentally. + assertEquals(RoundedCornerShape(2.dp), s.shapes.extraSmall) + assertEquals(RoundedCornerShape(2.dp), s.shapes.small) + // 2/10 -> 12dp * 0.2 = 2.4, rounded. + assertEquals(RoundedCornerShape(2.dp), s.shapes.medium) + // 3/12 -> 16dp * 0.25. The web value is 3px; the app's is 4dp, which is + // the whole point of the ratio. + assertEquals(RoundedCornerShape(4.dp), s.shapes.large) + // extraLarge has no web counterpart and follows the panel ratio. + assertEquals(RoundedCornerShape(6.dp), s.shapes.extraLarge) + assertEquals(RoundedCornerShape(4.dp), s.pill) + // 38px blur is nearer Default's 34 than Deep's 44. + assertEquals(4.dp, s.cardElevation) + } + + @Test + fun `modern scales the app's own dp`() { + val s = ShardStructure.resolve(modern) + assertEquals(RoundedCornerShape(6.dp), s.shapes.extraSmall) + assertEquals(RoundedCornerShape(7.dp), s.shapes.medium) + assertEquals(RoundedCornerShape(11.dp), s.shapes.large) + assertEquals(RoundedCornerShape(16.dp), s.shapes.extraLarge) + assertEquals(RoundedCornerShape(8.dp), s.pill) + assertEquals(2.dp, s.cardElevation) + } + + @Test + fun `a bad radius costs only its own field`() { + val s = ShardStructure.resolve( + mapOf( + "--radius-input" to "8", // no unit; the server never writes this + "--radius-card" to "huge", + "--radius-panel" to "3px", // good, and must still apply + "--radius-pill" to "", + ), + ) + assertEquals(preM12Shapes.extraSmall, s.shapes.extraSmall) + assertEquals(preM12Shapes.medium, s.shapes.medium) + assertEquals(RoundedCornerShape(4.dp), s.shapes.large) + assertSame(CircleShape, s.pill) + } + + @Test + fun `a zero radius squares the corner off rather than clamping to the shipped value`() { + val s = ShardStructure.resolve(mapOf("--radius-card" to "0px", "--radius-input" to "0px")) + assertEquals(RoundedCornerShape(0.dp), s.shapes.medium) + assertEquals(RoundedCornerShape(0.dp), s.shapes.extraSmall) + assertNotEquals(preM12Shapes, s.shapes) + } + + @Test + fun `the pill keeps its circle until the site is squared off`() { + assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "999px")).pill) + assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "500px")).pill) + assertEquals(RoundedCornerShape(499.dp), ShardStructure.resolve(mapOf("--radius-pill" to "499px")).pill) + assertEquals(RoundedCornerShape(0.dp), ShardStructure.resolve(mapOf("--radius-pill" to "0px")).pill) + } + + @Test + fun `every shadow option lands on its step`() { + fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation + assertEquals(0.dp, elevation("none")) + assertEquals(2.dp, elevation("0 8px 20px rgba(0, 0, 0, 0.25)")) + assertEquals(4.dp, elevation("0 14px 34px rgba(0, 0, 0, 0.3)")) + assertEquals(8.dp, elevation("0 18px 44px rgba(0, 0, 0, 0.45)")) + } + + @Test + fun `a shadow the options do not contain lands on the nearest step`() { + // The reason the match is on blur and not on the exact string: the + // Fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS' + // four values, because a preset's tokens never pass through that + // dropdown. An exact match would have dropped it on the floor. + fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation + assertEquals(4.dp, elevation("0 16px 38px rgba(0, 0, 0, 0.45)")) + assertEquals(8.dp, elevation("0 20px 60px rgba(0, 0, 0, 0.5)")) + assertEquals(2.dp, elevation("0 2px 4px rgba(0, 0, 0, 0.2)")) + } + + @Test + fun `an unreadable shadow keeps the shipped depth`() { + fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation + assertEquals(ShardStructure.Shipped.cardElevation, elevation("inset 0 0 nonsense")) + assertEquals(ShardStructure.Shipped.cardElevation, elevation("")) + // One length is an offset, not a blur — an incomplete value is not a + // reason to flatten every card on the shard. + assertEquals(ShardStructure.Shipped.cardElevation, elevation("0 14px")) + } + + @Test + fun `the shipped depth is the runic-gateway default, not flat`() { + // The one deliberate departure from §2: the app has been flat since M5 + // (material3's filled Card is Level0 and FeatureCard drew no shadow), + // while the preset the app was drawn from selects the "Default" shadow. + // §5.4 is applied as written, so an untouched instance gains this depth. + assertEquals(4.dp, ShardStructure.Shipped.cardElevation) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardTypefaceTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardTypefaceTest.kt new file mode 100644 index 0000000..fdfa57a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardTypefaceTest.kt @@ -0,0 +1,279 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertSame +import org.junit.Test +import androidx.compose.material3.Typography as MaterialTypography + +/** + * [ShardTypeface.resolve] and [shardTypography] — the font third of the theme + * (§5.3). + * + * Like the shape scale and unlike the color scheme, material3's [MaterialTypography] + * implements `equals` (checked in the 1.3.0 bytecode), so the no-op proof is one + * comparison against a **verbatim copy of the pre-M12 scale** kept in this file. + * Copying it rather than referencing `shardTypography(Shipped)` is the point: the + * assertion is against what the app used to draw, so a stray edit to a size or a + * letter-spacing in `Type.kt` fails here rather than quietly redefining "shipped". + */ +class ShardTypefaceTest { + + // ── the stacks, exactly as the server publishes them ────────────────────── + + private val runicGateway = mapOf( + "--serif" to "Georgia, \"Times New Roman\", serif", + "--display" to "Cinzel, Georgia, serif", + "--sans" to "\"Helvetica Neue\", Arial, sans-serif", + ) + + private val fantasy = mapOf( + "--serif" to "'EB Garamond', Georgia, serif", + "--display" to "Cinzel, Georgia, serif", + "--sans" to "'EB Garamond', Georgia, serif", + ) + + private val modern = mapOf( + "--serif" to "Inter, Arial, sans-serif", + "--display" to "'Work Sans', Arial, sans-serif", + "--sans" to "Inter, Arial, sans-serif", + ) + + // ── the no-op proof (AC-1) ──────────────────────────────────────────────── + + @Test + fun `no theme resolves to the shipped families`() { + assertEquals(ShardTypeface.Shipped, ShardTypeface.resolve(emptyMap())) + assertSame(Cinzel, ShardTypeface.Shipped.display) + assertSame(FontFamily.Serif, ShardTypeface.Shipped.serif) + assertSame(FontFamily.SansSerif, ShardTypeface.Shipped.sans) + } + + @Test + fun `the shipped scale is the pre-M12 scale`() { + assertEquals(preM12Typography, shardTypography(ShardTypeface.Shipped)) + } + + @Test + fun `the runic-gateway preset is a no-op`() { + assertEquals(ShardTypeface.Shipped, ShardTypeface.resolve(runicGateway)) + assertEquals(preM12Typography, shardTypography(ShardTypeface.resolve(runicGateway))) + } + + // ── the presets that bypass the per-role dropdown ───────────────────────── + + @Test + fun `the fantasy preset puts EB Garamond in the serif and sans roles`() { + val faces = ShardTypeface.resolve(fantasy) + assertSame(Cinzel, faces.display) + assertSame(EBGaramond, faces.serif) + // Not an option the sans dropdown offers — a preset's tokens are copied + // verbatim and never pass through it. + assertSame(EBGaramond, faces.sans) + } + + @Test + fun `the modern preset puts Work Sans in the display role and Inter in the serif role`() { + val faces = ShardTypeface.resolve(modern) + // Neither of these is in its role's FONT_OPTIONS list. + assertSame(WorkSans, faces.display) + assertSame(Inter, faces.serif) + assertSame(Inter, faces.sans) + } + + // ── every option on the shortlist ───────────────────────────────────────── + + @Test + fun `every serif option resolves`() { + assertSame(EBGaramond, serifFor("'EB Garamond', Georgia, serif")) + assertSame(Merriweather, serifFor("Merriweather, Georgia, serif")) + assertSame(PlayfairDisplay, serifFor("'Playfair Display', Georgia, serif")) + assertSame(IMFellEnglish, serifFor("'IM Fell English', Georgia, serif")) + assertSame(FontFamily.Serif, serifFor("Georgia, \"Times New Roman\", serif")) + } + + @Test + fun `every display option resolves`() { + assertSame(Cinzel, displayFor("Cinzel, Georgia, serif")) + assertSame(PlayfairDisplay, displayFor("'Playfair Display', Georgia, serif")) + assertSame(EBGaramond, displayFor("'EB Garamond', Georgia, serif")) + assertSame(IMFellEnglish, displayFor("'IM Fell English', Georgia, serif")) + } + + @Test + fun `every sans option resolves`() { + assertSame(Inter, sansFor("Inter, Arial, sans-serif")) + assertSame(WorkSans, sansFor("'Work Sans', Arial, sans-serif")) + assertSame(SourceSans3, sansFor("'Source Sans 3', Arial, sans-serif")) + assertSame(FontFamily.SansSerif, sansFor("\"Helvetica Neue\", Arial, sans-serif")) + } + + // ── parsing: only the first name carries the choice ─────────────────────── + + @Test + fun `the fallback chain after the first comma is ignored`() { + // Same family however the web fallbacks are written, and a serif stack in + // the sans role still resolves to what it names. + assertSame(Merriweather, sansFor("Merriweather, Georgia, serif")) + assertSame(Merriweather, sansFor("Merriweather")) + assertSame(Merriweather, sansFor("Merriweather , whatever , serif")) + } + + @Test + fun `quoting and casing do not matter`() { + assertSame(WorkSans, sansFor("'Work Sans', Arial, sans-serif")) + assertSame(WorkSans, sansFor("\"Work Sans\", Arial, sans-serif")) + assertSame(WorkSans, sansFor("Work Sans, Arial, sans-serif")) + assertSame(WorkSans, sansFor(" 'WORK SANS' , Arial ")) + } + + // ── per-field fallback (AC-2) ───────────────────────────────────────────── + + @Test + fun `an unreadable role falls back to its own shipped family, not another role's`() { + val faces = ShardTypeface.resolve( + mapOf( + "--display" to "'Playfair Display', Georgia, serif", + "--serif" to "Comic Sans MS, cursive", + "--sans" to "", + ), + ) + assertSame(PlayfairDisplay, faces.display) + assertSame(ShardTypeface.Shipped.serif, faces.serif) + assertSame(ShardTypeface.Shipped.sans, faces.sans) + } + + @Test + fun `a theme with no font tokens keeps all three shipped families`() { + // A non-empty theme that touches only colors must not disturb the type. + assertEquals( + ShardTypeface.Shipped, + ShardTypeface.resolve(mapOf("--accent" to "#c9a227")), + ) + } + + @Test + fun `a blank or comma-only stack falls back`() { + assertSame(ShardTypeface.Shipped.sans, sansFor(" ")) + assertSame(ShardTypeface.Shipped.sans, sansFor(",")) + assertSame(ShardTypeface.Shipped.sans, sansFor("'', Arial, sans-serif")) + } + + // ── the scale itself only ever changes family ───────────────────────────── + + @Test + fun `a themed scale differs from the shipped one only in its families`() { + val themed = shardTypography(ShardTypeface.resolve(fantasy)) + assertNotEquals(preM12Typography, themed) + assertEquals( + preM12Typography.bodyLarge, + themed.bodyLarge.copy(fontFamily = ShardTypeface.Shipped.serif), + ) + assertEquals( + preM12Typography.labelLarge, + themed.labelLarge.copy(fontFamily = ShardTypeface.Shipped.sans), + ) + assertEquals( + preM12Typography.displayLarge, + themed.displayLarge.copy(fontFamily = ShardTypeface.Shipped.display), + ) + } + + @Test + fun `each role reaches the styles it owns`() { + val themed = shardTypography(ShardTypeface.resolve(modern)) + // display/headline/title + assertSame(WorkSans, themed.displaySmall.fontFamily) + assertSame(WorkSans, themed.headlineMedium.fontFamily) + assertSame(WorkSans, themed.titleSmall.fontFamily) + // body + assertSame(Inter, themed.bodyLarge.fontFamily) + assertSame(Inter, themed.bodySmall.fontFamily) + // labels + assertSame(Inter, themed.labelLarge.fontFamily) + assertSame(Inter, themed.labelSmall.fontFamily) + } + + private fun serifFor(stack: String) = + ShardTypeface.resolve(mapOf("--serif" to stack)).serif + + private fun displayFor(stack: String) = + ShardTypeface.resolve(mapOf("--display" to stack)).display + + private fun sansFor(stack: String) = + ShardTypeface.resolve(mapOf("--sans" to stack)).sans + + /** + * The type scale exactly as `ui/theme/Type.kt` declared it before M12, with the + * three families it named directly. + */ + private val preM12Typography = MaterialTypography( + displayLarge = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp, + ), + displayMedium = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp, + ), + displaySmall = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp, + ), + headlineLarge = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp, + ), + headlineMedium = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp, + ), + headlineSmall = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp, + ), + titleLarge = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp, + ), + titleMedium = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = Cinzel, fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal, + fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal, + fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal, + fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, + fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp, + ), + labelMedium = TextStyle( + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium, + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp, + ), + labelSmall = TextStyle( + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium, + fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp, + ), + ) +} diff --git a/sonar-project.properties b/sonar-project.properties index e22a60b..a429479 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -32,10 +32,17 @@ sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/j # tests), and Android-framework glue (Keystore-backed stores, foreground push service, # notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and # pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1. +# +# ui/theme/ is excluded FILE BY FILE, not as a directory. It held only constants and +# composables when COVERAGE_PLAN.md §2 phase 0 drew the list; M12 added three pure +# resolvers to it (ShardPalette, ShardStructure, ShardTypeface) which are the +# milestone's core logic and are covered 98–100%. A `ui/theme/**` glob would drop them +# out of the denominator and hide a future regression in them. Theme.kt is the one +# composable left in the directory. sonar.coverage.exclusions=\ app/src/main/java/**/ui/**/*Screen.kt,\ app/src/main/java/**/ui/**/*Screen*.kt,\ - app/src/main/java/**/ui/theme/**,\ + app/src/main/java/**/ui/theme/Theme.kt,\ app/src/main/java/**/ui/components/**,\ app/src/main/java/**/ui/page/BlockRenderer.kt,\ app/src/main/java/**/ui/shard/ShardComponents.kt,\