oficina / home Public

Merge branch 'codex/oficina-icons' into main

paul@keymail.dev f1caf3ad821d11e92eacc73134cfe3d763db10ec parent c5cf9bdparent 6a9cf7f
20 files changed, +223 −10
  • internal/home/app.go +1 −0
  • internal/home/brand.go +34 −0
  • internal/home/brand_test.go +73 −0
  • internal/home/directory.go +1 −0
  • internal/home/directory_catalog.go +7 −7
  • internal/home/origins_test.go +4 −2
  • internal/home/page.go +4 −0
  • internal/home/static/brand/LICENSE.lucide +43 −0
  • internal/home/static/brand/apple-touch-icon.png binary
  • internal/home/static/brand/brand.css +19 −0
  • internal/home/static/brand/calendar.svg +10 −0
  • internal/home/static/brand/docs.svg +5 −0
  • internal/home/static/brand/icon-32.png binary
  • internal/home/static/brand/icon.svg +3 −0
  • internal/home/static/brand/meet.svg +2 −0
  • internal/home/static/brand/memoria.svg +4 −0
  • internal/home/static/brand/sheets.svg +4 −0
  • internal/home/static/brand/slides.svg +3 −0
  • internal/home/templates/directory.html +2 −1
  • internal/home/templates/layout.html +4 −0

CI passed — run details

diff --git a/internal/home/app.go b/internal/home/app.go
index 5e4be83..c3cddc6 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -234,6 +234,7 @@ func (a *App) Router() http.Handler {
// `rastrillo doctor --fix` is what reconciles it on an upgrade.
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
+ mountBrand(r)
return r
}
diff --git a/internal/home/brand.go b/internal/home/brand.go
new file mode 100644
index 0000000..08bbe85
--- /dev/null
+++ b/internal/home/brand.go
@@ -0,0 +1,34 @@
+package home
+
+import (
+ "embed"
+
+ "github.com/go-chi/chi/v5"
+)
+
+//go:embed static/brand
+var brandFS embed.FS
+
+// Brand assets are public on both Home origins. The closed file list avoids
+// exposing directory listings or treating a request path as a file name.
+func mountBrand(r chi.Router) {
+ for name, contentType := range map[string]string{
+ "icon.svg": "image/svg+xml",
+ "icon-32.png": "image/png",
+ "apple-touch-icon.png": "image/png",
+ "brand.css": "text/css; charset=utf-8",
+ "LICENSE.lucide": "text/plain; charset=utf-8",
+ "docs.svg": "image/svg+xml",
+ "sheets.svg": "image/svg+xml",
+ "slides.svg": "image/svg+xml",
+ "calendar.svg": "image/svg+xml",
+ "meet.svg": "image/svg+xml",
+ "memoria.svg": "image/svg+xml",
+ } {
+ body, err := brandFS.ReadFile("static/brand/" + name)
+ if err != nil {
+ panic(err) // A missing embedded release asset is a build error.
+ }
+ r.Get("/static/brand/"+name, serveAsset(body, contentType))
+ }
+}
diff --git a/internal/home/brand_test.go b/internal/home/brand_test.go
new file mode 100644
index 0000000..9ead1f9
--- /dev/null
+++ b/internal/home/brand_test.go
@@ -0,0 +1,73 @@
+package home
+
+import (
+ "bytes"
+ "image/png"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// Mutation-checked 2026-09-12 with cases chosen by an independent reviewer:
+// wrong icon.svg MIME type and removal of the directory mount both fail.
+func TestDirectoryBrandAssetsArePublicAndListingKeepsNames(t *testing.T) {
+ f := newMountedChooser(t)
+ page := f.request("GET", "/", "", "", f.session(t, chooserPerson))
+ if page.Code != 200 {
+ t.Fatal(page.Code)
+ }
+ body := page.Body.String()
+ for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Meet", "Memoria"} {
+ path := "/static/brand/" + strings.ToLower(name) + ".svg"
+ if !strings.Contains(body, `src="`+path+`" alt=""`) || !strings.Contains(body, ">"+name+"<") {
+ t.Fatal("listing lost icon or name", name)
+ }
+ // No session on the asset request: every sign-in surface needs its
+ // branding before the reader has authenticated.
+ asset := f.request("GET", path, "", "", nil)
+ if asset.Code != 200 || asset.Header().Get("Content-Type") != "image/svg+xml" || !strings.Contains(asset.Body.String(), "<title>"+name+"</title>") || len(asset.Result().Cookies()) != 0 {
+ t.Fatal("wrong or inaccessible product artwork", name, asset.Code)
+ }
+ }
+ // Every asset linked by the rendered directory must work anonymously,
+ // including Oficina's own favicon and the PNG home-screen exports.
+ for _, match := range regexp.MustCompile(`(?:href|src)="(/static/[^" ]+)"`).FindAllStringSubmatch(body, -1) {
+ asset := f.request("GET", match[1], "", "", nil)
+ if asset.Code != 200 || asset.Body.Len() == 0 || len(asset.Result().Cookies()) != 0 {
+ t.Fatal("public page asset unavailable", match[1])
+ }
+ switch filepath.Ext(match[1]) {
+ case ".svg":
+ if asset.Header().Get("Content-Type") != "image/svg+xml" || !bytes.Contains(asset.Body.Bytes(), []byte("<svg")) {
+ t.Fatal("SVG cannot render", match[1])
+ }
+ case ".png":
+ if _, err := png.DecodeConfig(bytes.NewReader(asset.Body.Bytes())); err != nil || asset.Header().Get("Content-Type") != "image/png" {
+ t.Fatal("PNG cannot render", match[1], err)
+ }
+ case ".css":
+ if !strings.HasPrefix(asset.Header().Get("Content-Type"), "text/css") {
+ t.Fatal("stylesheet cannot load", match[1])
+ }
+ }
+ }
+ if output := os.Getenv("HOME_BRAND_EVIDENCE_DIR"); output != "" {
+ if err := os.MkdirAll(output, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(output, "page.html"), page.Body.Bytes(), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, match := range regexp.MustCompile(`(?:href|src)="(/static/[^" ]+)"`).FindAllStringSubmatch(body, -1) {
+ asset := f.request("GET", match[1], "", "", nil)
+ if asset.Code != 200 {
+ t.Fatal("page asset unavailable", match[1])
+ }
+ if err := os.WriteFile(filepath.Join(output, filepath.Base(match[1])), asset.Body.Bytes(), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+}
diff --git a/internal/home/directory.go b/internal/home/directory.go
index cead9f3..691e2be 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -94,6 +94,7 @@ func (a *App) directoryRouter() http.Handler {
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
+ mountBrand(r)
})
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == rosterPath {
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go
index 6cdab5a..fb0a76c 100644
--- a/internal/home/directory_catalog.go
+++ b/internal/home/directory_catalog.go
@@ -3,24 +3,24 @@ package home
import "strings"
type catalogApp struct {
- Name, Href, Status string
+ Name, Href, Status, Icon string
}
// This display catalog grants no integration or provisioning capability.
// Those remain governed by directoryApps and the destination checks.
func directoryCatalog(cfg Runtime) []catalogApp {
- docs := catalogApp{Name: "Docs", Href: docsEntryPath, Status: "Choose a team"}
+ docs := catalogApp{Name: "Docs", Icon: "/static/brand/docs.svg", Href: docsEntryPath, Status: "Choose a team"}
if cfg.docsPreviewOrigin != "" {
docs.Href = cfg.docsPreviewOrigin + "/signin"
docs.Status = "Open preview"
}
apps := []catalogApp{
docs,
- {Name: "Sheets", Status: "Not connected to Home yet"},
- {Name: "Slides", Status: "Not connected to Home yet"},
- {Name: "Calendar", Status: "Not connected to Home yet"},
- {Name: "Meet", Status: "Coming soon"},
- {Name: "Memoria", Status: "Not connected to Home yet"},
+ {Name: "Sheets", Icon: "/static/brand/sheets.svg", Status: "Not connected to Home yet"},
+ {Name: "Slides", Icon: "/static/brand/slides.svg", Status: "Not connected to Home yet"},
+ {Name: "Calendar", Icon: "/static/brand/calendar.svg", Status: "Not connected to Home yet"},
+ {Name: "Meet", Icon: "/static/brand/meet.svg", Status: "Coming soon"},
+ {Name: "Memoria", Icon: "/static/brand/memoria.svg", Status: "Not connected to Home yet"},
{Name: "Mail", Status: "Separate sign-in"},
{Name: "Files", Status: "Separate sign-in"},
}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
index 51b8fa6..89647ac 100644
--- a/internal/home/origins_test.go
+++ b/internal/home/origins_test.go
@@ -15,7 +15,8 @@ func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) {
count := 0
err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
switch route {
- case "/", "/healthz", "/static/tokens.css", "/static/theme.css":
+ case "/", "/healthz", "/static/tokens.css", "/static/theme.css",
+ "/static/brand/icon.svg", "/static/brand/icon-32.png", "/static/brand/apple-touch-icon.png", "/static/brand/brand.css", "/static/brand/LICENSE.lucide", "/static/brand/docs.svg", "/static/brand/sheets.svg", "/static/brand/slides.svg", "/static/brand/calendar.svg", "/static/brand/meet.svg", "/static/brand/memoria.svg":
return nil
}
count++
@@ -80,7 +81,8 @@ func TestDirectoryIsNotWithinCredentialRP(t *testing.T) {
func TestSharedRoutesOnBothOrigins(t *testing.T) {
app := newTestApp(t)
for _, host := range []string{credentialHost, directoryHost} {
- for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} {
+ for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css",
+ "/static/brand/icon.svg", "/static/brand/icon-32.png", "/static/brand/apple-touch-icon.png", "/static/brand/brand.css", "/static/brand/LICENSE.lucide", "/static/brand/docs.svg", "/static/brand/sheets.svg", "/static/brand/slides.svg", "/static/brand/calendar.svg", "/static/brand/meet.svg", "/static/brand/memoria.svg"} {
req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil)
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
diff --git a/internal/home/page.go b/internal/home/page.go
index 43d8a7c..796e11d 100644
--- a/internal/home/page.go
+++ b/internal/home/page.go
@@ -16,6 +16,10 @@ import "net/http"
const indexPage = `<!doctype html>
<html lang="en">
<head>
+<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
+<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
+<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
+<link rel="stylesheet" href="/static/brand/brand.css">
<meta charset="utf-8">
<title>Oficina home</title>
</head>
diff --git a/internal/home/static/brand/LICENSE.lucide b/internal/home/static/brand/LICENSE.lucide
new file mode 100644
index 0000000..718bb3f
--- /dev/null
+++ b/internal/home/static/brand/LICENSE.lucide
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/internal/home/static/brand/apple-touch-icon.png b/internal/home/static/brand/apple-touch-icon.png
new file mode 100644
index 0000000..97b522c
Binary files /dev/null and b/internal/home/static/brand/apple-touch-icon.png differ
diff --git a/internal/home/static/brand/brand.css b/internal/home/static/brand/brand.css
new file mode 100644
index 0000000..2e194c6
--- /dev/null
+++ b/internal/home/static/brand/brand.css
@@ -0,0 +1,19 @@
+/* Oficina brand artwork. Source: oficina/estilo brand/. */
+[estilo-brand] {
+ display: inline-flex;
+ align-items: center;
+ gap: .5rem;
+ min-block-size: 24px;
+}
+[estilo-brand-icon] {
+ display: inline-block;
+ flex-shrink: 0;
+ inline-size: 1.75rem;
+ block-size: 1.75rem;
+ border-radius: 21%;
+ vertical-align: middle;
+}
+[estilo-brand-icon="listing"] {
+ inline-size: 2.5rem;
+ block-size: 2.5rem;
+}
diff --git a/internal/home/static/brand/calendar.svg b/internal/home/static/brand/calendar.svg
new file mode 100644
index 0000000..97108f8
--- /dev/null
+++ b/internal/home/static/brand/calendar.svg
@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Calendar"><title>Calendar</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#CB3F50"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" />
+ <path d="M16 2v3" />
+ <rect x="3" y="3" width="18" height="18" rx="2" />
+ <path d="M3 9h18" />
+ <path d="M8 13h.01" />
+ <path d="M12 13h.01" />
+ <path d="M16 13h.01" />
+ <path d="M8 17h.01" />
+ <path d="M12 17h.01" />
+ <path d="M16 17h.01" /></g></svg>
diff --git a/internal/home/static/brand/docs.svg b/internal/home/static/brand/docs.svg
new file mode 100644
index 0000000..9b7f4c6
--- /dev/null
+++ b/internal/home/static/brand/docs.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Docs"><title>Docs</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#2563EB"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
+ <path d="M14 2v5a1 1 0 0 0 1 1h5" />
+ <path d="M10 9H8" />
+ <path d="M16 13H8" />
+ <path d="M16 17H8" /></g></svg>
diff --git a/internal/home/static/brand/icon-32.png b/internal/home/static/brand/icon-32.png
new file mode 100644
index 0000000..bbfaca7
Binary files /dev/null and b/internal/home/static/brand/icon-32.png differ
diff --git a/internal/home/static/brand/icon.svg b/internal/home/static/brand/icon.svg
new file mode 100644
index 0000000..f30e545
--- /dev/null
+++ b/internal/home/static/brand/icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Oficina"><title>Oficina</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#303642"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" />
+ <path d="M3 9h18" />
+ <path d="M9 21V9" /></g></svg>
diff --git a/internal/home/static/brand/meet.svg b/internal/home/static/brand/meet.svg
new file mode 100644
index 0000000..a027150
--- /dev/null
+++ b/internal/home/static/brand/meet.svg
@@ -0,0 +1,2 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Meet"><title>Meet</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#087F8C"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
+ <rect x="2" y="6" width="14" height="12" rx="2" /></g></svg>
diff --git a/internal/home/static/brand/memoria.svg b/internal/home/static/brand/memoria.svg
new file mode 100644
index 0000000..6d7c4c8
--- /dev/null
+++ b/internal/home/static/brand/memoria.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Memoria"><title>Memoria</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#7C3FC8"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" />
+ <path d="m21 22-1.879-1.878" />
+ <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" />
+ <circle cx="17" cy="18" r="3" /></g></svg>
diff --git a/internal/home/static/brand/sheets.svg b/internal/home/static/brand/sheets.svg
new file mode 100644
index 0000000..3739ca0
--- /dev/null
+++ b/internal/home/static/brand/sheets.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Sheets"><title>Sheets</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#168049"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18" />
+ <rect width="18" height="18" x="3" y="3" rx="2" />
+ <path d="M3 9h18" />
+ <path d="M3 15h18" /></g></svg>
diff --git a/internal/home/static/brand/slides.svg b/internal/home/static/brand/slides.svg
new file mode 100644
index 0000000..ea6ead1
--- /dev/null
+++ b/internal/home/static/brand/slides.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Slides"><title>Slides</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#C45B16"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20" />
+ <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" />
+ <path d="m7 21 5-5 5 5" /></g></svg>
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html
index 10bebfb..91dfe76 100644
--- a/internal/home/templates/directory.html
+++ b/internal/home/templates/directory.html
@@ -1,12 +1,13 @@
{{define "title"}}Oficina{{end}}
{{define "content"}}
-<h1>Oficina</h1>
+<h1 estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="28" height="28">Oficina</h1>
<p>You are signed in.</p>
<section aria-labelledby="apps-heading">
<h2 id="apps-heading">Apps</h2>
<div rst-list>
{{range .Apps}}
<div rst-row>
+{{if .Icon}}<img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40">{{end}}
<div rst-row-main>
{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}<strong>{{.Name}}</strong>{{end}}
<span rst-row-sub>{{.Status}}</span>
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html
index 6054667..4735f6f 100644
--- a/internal/home/templates/layout.html
+++ b/internal/home/templates/layout.html
@@ -1,6 +1,10 @@
{{define "layout"}}<!doctype html>
<html lang="{{block "lang" .}}en{{end}}">
<head>
+<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
+<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
+<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
+<link rel="stylesheet" href="/static/brand/brand.css">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Oficina{{end}}</title>