From 89f6c571d6b2bc21175c5844f1eaa54182528f44 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 5 Oct 2026 16:02:11 +0200 Subject: [PATCH 01/26] wip(site-chrome): portal schema and content API serve the header search, the account button, the footer button and contact column, and the sign-in cards --- l10n/en.js | 55 +++++- l10n/en.json | 55 +++++- l10n/nl.js | 55 +++++- l10n/nl.json | 55 +++++- lib/Controller/PortalPageController.php | 15 +- lib/Service/Cms/PortalShell.php | 196 ++++++++++++++++++- lib/Service/PortalThemeResolver.php | 32 ++++ lib/Settings/portaliq_register.json | 209 +++++++++++++++++++++ node_modules | 1 + templates/site.php | 6 + tests/Unit/Service/Cms/PortalShellTest.php | 105 ++++++++++- vendor | 1 + 12 files changed, 777 insertions(+), 8 deletions(-) create mode 120000 node_modules create mode 120000 vendor diff --git a/l10n/en.js b/l10n/en.js index ceb283612..be33d0834 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2077,7 +2077,60 @@ OC.L10N.register( "Add a page with route /, then publish it.": "Add a page with route /, then publish it.", "Open this page": "Open this page", "Open the pages of this portal": "Open the pages of this portal", - "The home page of this portal could not be checked.": "The home page of this portal could not be checked." + "The home page of this portal could not be checked.": "The home page of this portal could not be checked.", + "A box under the cards, for example what to do the first time.": "A box under the cards, for example what to do the first time.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.", + "A line under the cards for staff, with a link to where they sign in.": "A line under the cards for staff, with a link to where they sign in.", + "A panel beside the cards with what the visitor finds after signing in.": "A panel beside the cards with what the visitor finds after signing in.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.", + "A short line under the button, such as what you need to sign in.": "A short line under the button, such as what you need to sign in.", + "Button text": "Button text", + "Button to the own area": "Button to the own area", + "Card heading": "Card heading", + "Card text": "Card text", + "Column heading": "Column heading", + "Contact column": "Contact column", + "Footer button": "Footer button", + "Hint": "Hint", + "Hint in the box": "Hint in the box", + "Introduction": "Introduction", + "Lines": "Lines", + "Link for staff": "Link for staff", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.", + "One line more about it.": "One line more about it.", + "One or two sentences under the heading: who signs in how.": "One or two sentences under the heading: who signs in how.", + "Points": "Points", + "Search in the header": "Search in the header", + "Search page": "Search page", + "Show the search box": "Show the search box", + "Side panel": "Side panel", + "Sign-in cards": "Sign-in cards", + "Sign-in page": "Sign-in page", + "The heading above the lines, such as Contact.": "The heading above the lines, such as Contact.", + "The heading of the box.": "The heading of the box.", + "The heading of the panel.": "The heading of the panel.", + "The lines of the column, in order.": "The lines of the column, in order.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "The name of an icon for the card. DigiD and eHerkenning show their own mark.", + "The name of an icon for the point.": "The name of an icon for the point.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.", + "The point in a few words.": "The point in a few words.", + "The points in the panel.": "The points in the panel.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "The sentence before the link, such as Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "The text around the sign-in cards. Every part is optional.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.", + "The text of the box.": "The text of the box.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.", + "The text of the link.": "The text of the link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "The text of the sign-in button. Empty shows the standard text for this way in.", + "What the line says.": "What the line says.", + "What the visitor can do after signing in this way.": "What the visitor can do after signing in this way.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.", + "Where the link goes.": "Where the link goes.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.", + "Whether the header shows the search box.": "Whether the header shows the search box.", + "Who this way in is for, such as Ik ben leerling.": "Who this way in is for, such as Ik ben leerling." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 70a85ff6f..6b0a4011d 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -2088,7 +2088,60 @@ "Add a page with route /, then publish it.": "Add a page with route /, then publish it.", "Open this page": "Open this page", "Open the pages of this portal": "Open the pages of this portal", - "The home page of this portal could not be checked.": "The home page of this portal could not be checked." + "The home page of this portal could not be checked.": "The home page of this portal could not be checked.", + "A box under the cards, for example what to do the first time.": "A box under the cards, for example what to do the first time.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.", + "A line under the cards for staff, with a link to where they sign in.": "A line under the cards for staff, with a link to where they sign in.", + "A panel beside the cards with what the visitor finds after signing in.": "A panel beside the cards with what the visitor finds after signing in.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.", + "A short line under the button, such as what you need to sign in.": "A short line under the button, such as what you need to sign in.", + "Button text": "Button text", + "Button to the own area": "Button to the own area", + "Card heading": "Card heading", + "Card text": "Card text", + "Column heading": "Column heading", + "Contact column": "Contact column", + "Footer button": "Footer button", + "Hint": "Hint", + "Hint in the box": "Hint in the box", + "Introduction": "Introduction", + "Lines": "Lines", + "Link for staff": "Link for staff", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.", + "One line more about it.": "One line more about it.", + "One or two sentences under the heading: who signs in how.": "One or two sentences under the heading: who signs in how.", + "Points": "Points", + "Search in the header": "Search in the header", + "Search page": "Search page", + "Show the search box": "Show the search box", + "Side panel": "Side panel", + "Sign-in cards": "Sign-in cards", + "Sign-in page": "Sign-in page", + "The heading above the lines, such as Contact.": "The heading above the lines, such as Contact.", + "The heading of the box.": "The heading of the box.", + "The heading of the panel.": "The heading of the panel.", + "The lines of the column, in order.": "The lines of the column, in order.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "The name of an icon for the card. DigiD and eHerkenning show their own mark.", + "The name of an icon for the point.": "The name of an icon for the point.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.", + "The point in a few words.": "The point in a few words.", + "The points in the panel.": "The points in the panel.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "The sentence before the link, such as Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "The text around the sign-in cards. Every part is optional.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.", + "The text of the box.": "The text of the box.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.", + "The text of the link.": "The text of the link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "The text of the sign-in button. Empty shows the standard text for this way in.", + "What the line says.": "What the line says.", + "What the visitor can do after signing in this way.": "What the visitor can do after signing in this way.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.", + "Where the link goes.": "Where the link goes.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.", + "Whether the header shows the search box.": "Whether the header shows the search box.", + "Who this way in is for, such as Ik ben leerling.": "Who this way in is for, such as Ik ben leerling." }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/nl.js b/l10n/nl.js index e38fc85bc..7f94a213a 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2142,7 +2142,60 @@ OC.L10N.register( "Add a page with route /, then publish it.": "Voeg een pagina toe met route /, en publiceer die.", "Open this page": "Deze pagina openen", "Open the pages of this portal": "De pagina's van dit portaal openen", - "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd." + "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd.", + "A box under the cards, for example what to do the first time.": "Een vak onder de kaarten, bijvoorbeeld wat u de eerste keer doet.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "Een kolom met contactgegevens naast de tekst in de voettekst: een adres, een telefoonnummer, een e-mailadres. Een regel met een link wordt als link getoond.", + "A line under the cards for staff, with a link to where they sign in.": "Een regel onder de kaarten voor medewerkers, met een link naar de plek waar zij inloggen.", + "A panel beside the cards with what the visitor finds after signing in.": "Een vak naast de kaarten met wat de bezoeker vindt na het inloggen.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "Een zoekvak in de kop, op elke pagina. Het opent de zoekpagina van het portaal met wat de bezoeker typte. Uit tot u het aanzet.", + "A short line under the button, such as what you need to sign in.": "Een korte regel onder de knop, bijvoorbeeld wat u nodig heeft om in te loggen.", + "Button text": "Tekst op de knop", + "Button to the own area": "Knop naar de eigen omgeving", + "Card heading": "Kop van de kaart", + "Card text": "Tekst van de kaart", + "Column heading": "Kop van de kolom", + "Contact column": "Kolom met contactgegevens", + "Footer button": "Knop in de voettekst", + "Hint": "Hint", + "Hint in the box": "Hint in het vak", + "Introduction": "Inleiding", + "Lines": "Regels", + "Link for staff": "Link voor medewerkers", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "Eén knop onder de tekst in de voettekst, bijvoorbeeld naar de contactpagina. Weggelaten als de tekst of de bestemming ontbreekt.", + "One line more about it.": "Eén regel meer erover.", + "One or two sentences under the heading: who signs in how.": "Een of twee zinnen onder de kop: wie logt hoe in.", + "Points": "Punten", + "Search in the header": "Zoeken in de kop", + "Search page": "Zoekpagina", + "Show the search box": "Zoekvak tonen", + "Side panel": "Zijvak", + "Sign-in cards": "Inlogkaarten", + "Sign-in page": "Inlogpagina", + "The heading above the lines, such as Contact.": "De kop boven de regels, bijvoorbeeld Contact.", + "The heading of the box.": "De kop van het vak.", + "The heading of the panel.": "De kop van het vak.", + "The lines of the column, in order.": "De regels van de kolom, op volgorde.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "De naam van een pictogram voor de kaart. DigiD en eHerkenning tonen hun eigen logo.", + "The name of an icon for the point.": "De naam van een pictogram voor het punt.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "De kop van de pagina, bijvoorbeeld Inloggen bij Mijn Wilgenboom. Leeg toont Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "De pagina van dit portaal die de resultaten toont, bijvoorbeeld /zoeken. Leeg of geen pagina van dit portaal betekent /zoeken.", + "The point in a few words.": "Het punt in een paar woorden.", + "The points in the panel.": "De punten in het vak.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "De zin voor de link, bijvoorbeeld Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "De korte tekst in het lege vak, die ook de naam voor schermlezers is. Leeg toont \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "De tekst rond de inlogkaarten. Elk deel mag leeg blijven.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "De tekst van één knop in de kop die de eigen omgeving van de bezoeker opent, waar die kiest hoe hij inlogt. Leeg toont in plaats daarvan een link voor elke manier van inloggen.", + "The text of the box.": "De tekst van het vak.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "De tekst van de kaart voor elke manier van inloggen op de inlogpagina, per manier (digid, nextcloud, eherkenning, oidc, local, eidas). Een manier zonder kaart toont één gewone knop.", + "The text of the link.": "De tekst van de link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "De tekst van de inlogknop. Leeg toont de gewone tekst voor deze manier van inloggen.", + "What the line says.": "Wat de regel zegt.", + "What the visitor can do after signing in this way.": "Wat de bezoeker kan doen na het inloggen op deze manier.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Waar de regel naar linkt: een pagina van dit portaal, of een web-, mail- of telefoonadres. Leeg toont gewone tekst.", + "Where the link goes.": "Waar de link naartoe gaat.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Waar de link naartoe gaat: een pagina van dit portaal zoals /contact, of een volledig web-, mail- of telefoonadres.", + "Whether the header shows the search box.": "Of de kop het zoekvak toont.", + "Who this way in is for, such as Ik ben leerling.": "Voor wie deze manier van inloggen is, bijvoorbeeld Ik ben leerling." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 0535a47c4..de6b7ce21 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2153,7 +2153,60 @@ "Add a page with route /, then publish it.": "Voeg een pagina toe met route /, en publiceer die.", "Open this page": "Deze pagina openen", "Open the pages of this portal": "De pagina's van dit portaal openen", - "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd." + "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd.", + "A box under the cards, for example what to do the first time.": "Een vak onder de kaarten, bijvoorbeeld wat u de eerste keer doet.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "Een kolom met contactgegevens naast de tekst in de voettekst: een adres, een telefoonnummer, een e-mailadres. Een regel met een link wordt als link getoond.", + "A line under the cards for staff, with a link to where they sign in.": "Een regel onder de kaarten voor medewerkers, met een link naar de plek waar zij inloggen.", + "A panel beside the cards with what the visitor finds after signing in.": "Een vak naast de kaarten met wat de bezoeker vindt na het inloggen.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "Een zoekvak in de kop, op elke pagina. Het opent de zoekpagina van het portaal met wat de bezoeker typte. Uit tot u het aanzet.", + "A short line under the button, such as what you need to sign in.": "Een korte regel onder de knop, bijvoorbeeld wat u nodig heeft om in te loggen.", + "Button text": "Tekst op de knop", + "Button to the own area": "Knop naar de eigen omgeving", + "Card heading": "Kop van de kaart", + "Card text": "Tekst van de kaart", + "Column heading": "Kop van de kolom", + "Contact column": "Kolom met contactgegevens", + "Footer button": "Knop in de voettekst", + "Hint": "Hint", + "Hint in the box": "Hint in het vak", + "Introduction": "Inleiding", + "Lines": "Regels", + "Link for staff": "Link voor medewerkers", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "Eén knop onder de tekst in de voettekst, bijvoorbeeld naar de contactpagina. Weggelaten als de tekst of de bestemming ontbreekt.", + "One line more about it.": "Eén regel meer erover.", + "One or two sentences under the heading: who signs in how.": "Een of twee zinnen onder de kop: wie logt hoe in.", + "Points": "Punten", + "Search in the header": "Zoeken in de kop", + "Search page": "Zoekpagina", + "Show the search box": "Zoekvak tonen", + "Side panel": "Zijvak", + "Sign-in cards": "Inlogkaarten", + "Sign-in page": "Inlogpagina", + "The heading above the lines, such as Contact.": "De kop boven de regels, bijvoorbeeld Contact.", + "The heading of the box.": "De kop van het vak.", + "The heading of the panel.": "De kop van het vak.", + "The lines of the column, in order.": "De regels van de kolom, op volgorde.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "De naam van een pictogram voor de kaart. DigiD en eHerkenning tonen hun eigen logo.", + "The name of an icon for the point.": "De naam van een pictogram voor het punt.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "De kop van de pagina, bijvoorbeeld Inloggen bij Mijn Wilgenboom. Leeg toont Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "De pagina van dit portaal die de resultaten toont, bijvoorbeeld /zoeken. Leeg of geen pagina van dit portaal betekent /zoeken.", + "The point in a few words.": "Het punt in een paar woorden.", + "The points in the panel.": "De punten in het vak.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "De zin voor de link, bijvoorbeeld Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "De korte tekst in het lege vak, die ook de naam voor schermlezers is. Leeg toont \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "De tekst rond de inlogkaarten. Elk deel mag leeg blijven.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "De tekst van één knop in de kop die de eigen omgeving van de bezoeker opent, waar die kiest hoe hij inlogt. Leeg toont in plaats daarvan een link voor elke manier van inloggen.", + "The text of the box.": "De tekst van het vak.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "De tekst van de kaart voor elke manier van inloggen op de inlogpagina, per manier (digid, nextcloud, eherkenning, oidc, local, eidas). Een manier zonder kaart toont één gewone knop.", + "The text of the link.": "De tekst van de link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "De tekst van de inlogknop. Leeg toont de gewone tekst voor deze manier van inloggen.", + "What the line says.": "Wat de regel zegt.", + "What the visitor can do after signing in this way.": "Wat de bezoeker kan doen na het inloggen op deze manier.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Waar de regel naar linkt: een pagina van dit portaal, of een web-, mail- of telefoonadres. Leeg toont gewone tekst.", + "Where the link goes.": "Waar de link naartoe gaat.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Waar de link naartoe gaat: een pagina van dit portaal zoals /contact, of een volledig web-, mail- of telefoonadres.", + "Whether the header shows the search box.": "Of de kop het zoekvak toont.", + "Who this way in is for, such as Ik ben leerling.": "Voor wie deze manier van inloggen is, bijvoorbeeld Ik ben leerling." }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/lib/Controller/PortalPageController.php b/lib/Controller/PortalPageController.php index 3f6bf794d..8c9dd2652 100644 --- a/lib/Controller/PortalPageController.php +++ b/lib/Controller/PortalPageController.php @@ -265,6 +265,9 @@ public function site(): TemplateResponse { // contract withholds; it only decides which stylesheet tag to emit. 'themeStylesheet' => $this->siteThemeStylesheet(), 'themeLogoUrl' => $this->siteThemeLogoUrl(), + // The set's light logo for the dark footer band, or '' + // (site-chrome-follows-the-design). + 'themeLogoInverseUrl' => $this->siteThemeLogoUrl(inverse: true), 'themeAppSheets' => $this->siteThemeAppSheets(), // The NLDS token set this app ships for the serving portal's // theme, when it has one. Separate from the line above because @@ -576,11 +579,17 @@ private function siteThemeAppSheets(): array { * So the resolution happens here, where the theme app's real path is * known, and the template emits the result after the token stylesheets. * + * With `$inverse` it is the set's light logo, for the dark footer band + * (site-chrome-follows-the-design), or '' when the set ships none. + * + * @param bool $inverse Whether the light logo is asked for. + * * @return string An absolute URL, or '' when there is no logo to serve. * * @spec openspec/specs/portaliq-cms/spec.md#requirement-a-portals-theme-must-change-what-a-visitor-sees + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first */ - private function siteThemeLogoUrl(): string { + private function siteThemeLogoUrl(bool $inverse=false): string { $stylesheet = $this->siteThemeStylesheet(); if ($stylesheet === '') { return ''; @@ -594,6 +603,10 @@ private function siteThemeLogoUrl(): string { try { $relative = $this->themeResolver->logoFileFor(theme: $theme); + if ($inverse === true) { + $relative = $this->themeResolver->inverseLogoFileFor(theme: $theme); + } + if ($relative === null) { return ''; } diff --git a/lib/Service/Cms/PortalShell.php b/lib/Service/Cms/PortalShell.php index b54e86014..44d6adb8f 100644 --- a/lib/Service/Cms/PortalShell.php +++ b/lib/Service/Cms/PortalShell.php @@ -39,6 +39,12 @@ class PortalShell { */ public const HEADER_VARIANTS = ['double', 'single']; + /** + * The ways in a sign-in card may be written for (the portal's modes + * besides `public`). + */ + public const SIGN_IN_MODES = ['nextcloud', 'local', 'oidc', 'digid', 'eherkenning', 'eidas']; + /** * Constructor. * @@ -54,19 +60,51 @@ public function __construct( * * @param array $portal The portal record. * - * @return array `authentication`, `headerVariant`, `footer` and `regions`. + * @return array `authentication`, `headerVariant`, `headerSearch`, `accountLabel`, `footer` and `regions`. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-the-header-must-be-a-block-whose-shape-the-portal-chooses-req-ptb-004 + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-header-must-carry-the-search-box-and-one-way-to-the-own-area */ public function project(array $portal): array { return [ 'authentication' => $this->authentication(portal: $portal), 'headerVariant' => $this->headerVariant(portal: $portal), + 'headerSearch' => $this->headerSearch(portal: $portal), + 'accountLabel' => $this->text(value: ($portal['accountLabel'] ?? '')), 'footer' => $this->footer(portal: $portal), 'regions' => $this->publicRegions(portal: $portal), ]; }//end project() + /** + * The search box in the header: whether it shows, its hint and the + * portal's search page. A route that is not an in-site path falls back to + * `/zoeken`, the page the hero search has always opened. + * + * @param array $portal The portal record. + * + * @return array{enabled: bool, placeholder: string, route: string} The box. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-header-must-carry-the-search-box-and-one-way-to-the-own-area + */ + public function headerSearch(array $portal): array { + $search = $portal['headerSearch'] ?? []; + if (is_array($search) === false) { + $search = []; + } + + $route = $this->text(value: ($search['route'] ?? '')); + if (preg_match('#^/(?!/)#', $route) !== 1) { + $route = '/zoeken'; + } + + return [ + 'enabled' => ($search['enabled'] ?? false) === true, + 'placeholder' => $this->text(value: ($search['placeholder'] ?? '')), + 'route' => $route, + ]; + }//end headerSearch() + /** * The header variant: the portal's choice when it is known, else `double`. * @@ -96,6 +134,7 @@ public function headerVariant(array $portal): string { * @return array `{modes, register?, registerLabel?}`. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-the-header-must-be-a-block-whose-shape-the-portal-chooses-req-ptb-004 + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role */ public function authentication(array $portal): array { $auth = (array)($portal['authentication'] ?? []); @@ -108,9 +147,120 @@ public function authentication(array $portal): array { } } + $labels = $this->modeLabels(labels: ($auth['modeLabels'] ?? [])); + if ($labels !== []) { + $public['modeLabels'] = $labels; + } + + $page = $this->signInPage(page: ($auth['signInPage'] ?? [])); + if ($page !== []) { + $public['signInPage'] = $page; + } + return $public; }//end authentication() + /** + * The sign-in card per way in: title, text, button, hint and icon, each + * plain text. A mode the portal could not offer is dropped; so is a card + * that says nothing. + * + * @param mixed $labels The authored map, mode to card. + * + * @return array> Mode to `{title?, text?, button?, hint?, icon?}`. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + public function modeLabels(mixed $labels): array { + if (is_array($labels) === false) { + return []; + } + + $kept = []; + foreach (self::SIGN_IN_MODES as $mode) { + $card = $labels[$mode] ?? null; + if (is_array($card) === false) { + continue; + } + + $texts = $this->texts(source: $card, keys: ['title', 'text', 'button', 'hint', 'icon']); + if ($texts !== []) { + $kept[$mode] = $texts; + } + } + + return $kept; + }//end modeLabels() + + /** + * The text around the sign-in cards: the page's title and intro, a + * notice under the cards, a line for staff with its link, and a side + * panel of points. Plain text; a link only when it can be followed. + * + * @param mixed $page The authored block. + * + * @return array The parts that say something. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + public function signInPage(mixed $page): array { + if (is_array($page) === false) { + return []; + } + + $kept = $this->texts(source: $page, keys: ['title', 'intro']); + + $notice = (is_array($page['notice'] ?? null) === true) ? $this->texts(source: $page['notice'], keys: ['title', 'text']) : []; + if (($notice['text'] ?? '') !== '') { + $kept['notice'] = $notice; + } + + $staff = (is_array($page['staffLink'] ?? null) === true) ? $this->texts(source: $page['staffLink'], keys: ['text', 'label', 'href']) : []; + if (($staff['label'] ?? '') !== '' && $this->followable(href: ($staff['href'] ?? '')) === true) { + $kept['staffLink'] = $staff; + } + + $panel = $page['panel'] ?? null; + if (is_array($panel) === true) { + $items = []; + foreach ((array)($panel['items'] ?? []) as $item) { + $texts = (is_array($item) === true) ? $this->texts(source: $item, keys: ['title', 'text', 'icon']) : []; + if (($texts['title'] ?? '') !== '') { + $items[] = $texts; + } + } + + $title = $this->text(value: ($panel['title'] ?? '')); + if ($title !== '' || $items !== []) { + $kept['panel'] = ['title' => $title, 'items' => $items]; + } + } + + return $kept; + }//end signInPage() + + /** + * The named keys of an authored block that hold text, trimmed. + * + * @param array $source The block. + * @param list $keys The keys to keep. + * + * @return array The keys that say something. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + private function texts(array $source, array $keys): array { + $kept = []; + foreach ($keys as $key) { + $value = $this->text(value: ($source[$key] ?? '')); + if ($value !== '') { + $kept[$key] = $value; + } + } + + return $kept; + }//end texts() + /** * The portal's footer content, on named keys only. * @@ -120,9 +270,10 @@ public function authentication(array $portal): array { * * @param array $portal The portal record. * - * @return array `{description, colophon, socials, legalLinks, badges}`; each list holds `{label, href}` entries. + * @return array `{description, colophon, socials, legalLinks, badges, cta, contact}`; each list holds `{label, href}` entries. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-the-footer-must-be-a-block-whose-bands-are-styled-by-role-req-ptb-005 + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first */ public function footer(array $portal): array { $footer = $portal['footer'] ?? []; @@ -130,15 +281,56 @@ public function footer(array $portal): array { $footer = []; } + $cta = $this->links(entries: [($footer['cta'] ?? null)], extra: null); + return [ 'description' => $this->text(value: ($footer['description'] ?? '')), 'colophon' => $this->text(value: ($footer['colophon'] ?? '')), 'socials' => $this->links(entries: ($footer['socials'] ?? []), extra: 'icon'), 'legalLinks' => $this->links(entries: ($footer['legalLinks'] ?? []), extra: null), 'badges' => $this->links(entries: ($footer['badges'] ?? []), extra: null), + 'cta' => ($cta[0] ?? null), + 'contact' => $this->contact(contact: ($footer['contact'] ?? null)), ]; }//end footer() + /** + * The footer's contact column: a title and plain lines, a line with a + * followable `href` rendered as a link. Null when it has no line. + * + * @param mixed $contact The authored column. + * + * @return array{title: string, lines: list>}|null The column. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first + */ + public function contact(mixed $contact): ?array { + if (is_array($contact) === false) { + return null; + } + + $lines = []; + foreach ((array)($contact['lines'] ?? []) as $line) { + if (is_array($line) === false) { + continue; + } + + $text = $this->text(value: ($line['text'] ?? '')); + if ($text === '') { + continue; + } + + $href = $this->text(value: ($line['href'] ?? '')); + $lines[] = ($this->followable(href: $href) === true) ? ['text' => $text, 'href' => $href] : ['text' => $text]; + } + + if ($lines === []) { + return null; + } + + return ['title' => $this->text(value: ($contact['title'] ?? '')), 'lines' => $lines]; + }//end contact() + /** * The portal's own region contents, the middle step of resolution. * diff --git a/lib/Service/PortalThemeResolver.php b/lib/Service/PortalThemeResolver.php index 95e2400be..94bafcb17 100644 --- a/lib/Service/PortalThemeResolver.php +++ b/lib/Service/PortalThemeResolver.php @@ -221,6 +221,38 @@ public function logoFileFor(string $theme): ?string { }//end logoFileFor() + /** + * The theme app path of a set's light logo, for a dark band, or null. + * + * The footer of a school portal is a dark band, and the set's own logo + * (dark ink) disappears on it. Every school set ships a light variant as + * `img/logos/-dark.svg` (named after the dark scheme it was made + * for). The bridge shows it on the footer through + * `--nldesign-logo-inverse-url` (thematiq brand-motif-on-portals); a set + * without one keeps its own logo there, as before. + * + * @param string $theme The portal's theme reference. + * + * @return string|null The path relative to the theme app, or null. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first + */ + public function inverseLogoFileFor(string $theme): ?string { + $own = $this->logoFileFor(theme: $theme); + $root = $this->themeAppPath(); + if ($own === null || $root === null) { + return null; + } + + $relative = 'img/logos/' . $theme . '-dark.svg'; + if (is_file($root . '/' . $relative) === false) { + return null; + } + + return $relative; + }//end inverseLogoFileFor() + + /** * Whether the theme app's catalogue offers this set. * diff --git a/lib/Settings/portaliq_register.json b/lib/Settings/portaliq_register.json index e91e8492b..56ff50dd0 100644 --- a/lib/Settings/portaliq_register.json +++ b/lib/Settings/portaliq_register.json @@ -2356,6 +2356,56 @@ } } } + }, + "cta": { + "title": "Footer button", + "type": "object", + "description": "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.", + "properties": { + "label": { + "title": "Label", + "type": "string", + "description": "The text a visitor reads or a screen reader announces." + }, + "href": { + "title": "Link", + "type": "string", + "description": "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address." + } + } + }, + "contact": { + "title": "Contact column", + "type": "object", + "description": "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.", + "properties": { + "title": { + "title": "Column heading", + "type": "string", + "description": "The heading above the lines, such as Contact.", + "example": "Contact" + }, + "lines": { + "title": "Lines", + "type": "array", + "description": "The lines of the column, in order.", + "items": { + "type": "object", + "properties": { + "text": { + "title": "Text", + "type": "string", + "description": "What the line says." + }, + "href": { + "title": "Link", + "type": "string", + "description": "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text." + } + } + } + } + } } } }, @@ -2728,6 +2778,37 @@ ], "example": "double" }, + "headerSearch": { + "title": "Search in the header", + "type": "object", + "description": "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.", + "properties": { + "enabled": { + "title": "Show the search box", + "type": "boolean", + "description": "Whether the header shows the search box.", + "example": true + }, + "placeholder": { + "title": "Hint in the box", + "type": "string", + "description": "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".", + "example": "Zoek een cursus" + }, + "route": { + "title": "Search page", + "type": "string", + "description": "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.", + "example": "/zoeken" + } + } + }, + "accountLabel": { + "title": "Button to the own area", + "type": "string", + "description": "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.", + "example": "Mijn Wilgenboom" + }, "locales": { "title": "Locales", "type": "array", @@ -2863,6 +2944,134 @@ "type": "string", "description": "The text on the register button. Empty shows Registreren.", "example": "Account aanmaken" + }, + "modeLabels": { + "title": "Sign-in cards", + "type": "object", + "description": "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.", + "additionalProperties": { + "type": "object", + "properties": { + "title": { + "title": "Card heading", + "type": "string", + "description": "Who this way in is for, such as Ik ben leerling." + }, + "text": { + "title": "Card text", + "type": "string", + "description": "What the visitor can do after signing in this way." + }, + "button": { + "title": "Button text", + "type": "string", + "description": "The text of the sign-in button. Empty shows the standard text for this way in." + }, + "hint": { + "title": "Hint", + "type": "string", + "description": "A short line under the button, such as what you need to sign in." + }, + "icon": { + "title": "Icon", + "type": "string", + "description": "The name of an icon for the card. DigiD and eHerkenning show their own mark." + } + } + } + }, + "signInPage": { + "title": "Sign-in page", + "type": "object", + "description": "The text around the sign-in cards. Every part is optional.", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom." + }, + "intro": { + "title": "Introduction", + "type": "string", + "description": "One or two sentences under the heading: who signs in how." + }, + "notice": { + "title": "Notice", + "type": "object", + "description": "A box under the cards, for example what to do the first time.", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The heading of the box." + }, + "text": { + "title": "Text", + "type": "string", + "description": "The text of the box." + } + } + }, + "staffLink": { + "title": "Link for staff", + "type": "object", + "description": "A line under the cards for staff, with a link to where they sign in.", + "properties": { + "text": { + "title": "Text", + "type": "string", + "description": "The sentence before the link, such as Werkt u bij De Wilgenboom?" + }, + "label": { + "title": "Link text", + "type": "string", + "description": "The text of the link." + }, + "href": { + "title": "Link", + "type": "string", + "description": "Where the link goes." + } + } + }, + "panel": { + "title": "Side panel", + "type": "object", + "description": "A panel beside the cards with what the visitor finds after signing in.", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The heading of the panel." + }, + "items": { + "title": "Points", + "type": "array", + "description": "The points in the panel.", + "items": { + "type": "object", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The point in a few words." + }, + "text": { + "title": "Text", + "type": "string", + "description": "One line more about it." + }, + "icon": { + "title": "Icon", + "type": "string", + "description": "The name of an icon for the point." + } + } + } + } + } + } + } } } }, diff --git a/node_modules b/node_modules new file mode 120000 index 000000000..32eb98175 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/rubenlinde/memcap-work/ui-open/portaliq/node_modules \ No newline at end of file diff --git a/templates/site.php b/templates/site.php index b35fe8521..5d1672568 100644 --- a/templates/site.php +++ b/templates/site.php @@ -149,6 +149,9 @@ // // So the app that knows where the theme app lives resolves it, once, here. $themeLogoUrl = (string)($_['themeLogoUrl'] ?? ''); +// The set's light logo for the dark footer band, absolute for the same reason +// (site-chrome-follows-the-design); '' when the set ships none. +$themeLogoInverseUrl = (string)($_['themeLogoInverseUrl'] ?? ''); // NO DARK LAYER IS LINKED HERE, AND THAT IS A MEASURED DECISION. // @@ -343,6 +346,9 @@ --> + + + +
+ + + + + +
+

{{ t('Welcome') }} @@ -186,6 +219,8 @@ export default { components: { // Loaded only while the resident acts for someone else. ActingForBar: defineAsyncComponent(() => import('./mijn/ActingForBar.vue')), + // Loaded only on a portal that writes its sign-in cards. + SignInPage: defineAsyncComponent(() => import('./chrome/SignInPage.vue')), ResidentMenu, WaysIn, }, @@ -256,6 +291,32 @@ export default { }, computed: { + /** + * The portal's sign-in page text, or an empty object. + * + * @return {object} `authentication.signInPage`. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + signInPageText() { + return this.portal?.authentication?.signInPage || {} + }, + + /** + * Whether the portal wrote its sign-in page: page text or a card for + * one of its ways in. Otherwise the plain list of buttons stays. + * + * @return {boolean} + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + signInDesigned() { + return ( + Object.keys(this.signInPageText).length > 0 + || this.signInRoutes.some((way) => way.card) + ) + }, + /** * Whether the resident menu shows: signed in, with groups to show. * Signed out this area is the way in and keeps its full width. diff --git a/src/site/components/BrandHeader.vue b/src/site/components/BrandHeader.vue index 8e6f8760a..78d40c903 100644 --- a/src/site/components/BrandHeader.vue +++ b/src/site/components/BrandHeader.vue @@ -13,7 +13,10 @@ masthead. `single` puts it on the masthead's row and renders no second bar, so each link is in the accessibility tree once. --> -
+