diff --git a/docs/screenshots.md b/docs/screenshots.md
index f8cd95d..e1e1d41 100644
--- a/docs/screenshots.md
+++ b/docs/screenshots.md
@@ -6,11 +6,11 @@
With the server running:
- go run ./tools/screenshot -url http://localhost:8080/directory/ -out screenshots/directory.png -wait header
+ go run ./tools/screenshot -url http://localhost:8080/people -out screenshots/directory.png -wait .sidebar
Flags:
-- `-url` — page to capture (default `http://localhost:8080/directory/`)
+- `-url` — page to capture (default `http://localhost:8080/people`)
- `-out` — output PNG path (default `screenshots/capture.png`); `screenshots/` is gitignored
- `-wait` — CSS selector that must be visible before capture (default `body`); pass a selector the page's JavaScript renders (for example `.card`) to capture after data loads
@@ -67,7 +67,7 @@ The login session persists in the profile, so the next `tools/capturebrowser` la
One self-contained command that starts the server, captures, and shuts down:
go run . &
- go run ./tools/screenshot -out screenshots/directory.png -wait header
+ go run ./tools/screenshot -out screenshots/directory.png -wait .sidebar
kill $(lsof -ti :8080)
Then read `screenshots/directory.png` to inspect the result.
diff --git a/internal/directory/directory.go b/internal/directory/directory.go
index bc0d831..c3874f1 100644
--- a/internal/directory/directory.go
+++ b/internal/directory/directory.go
@@ -6,27 +6,60 @@ import (
"html/template"
"log"
"net/http"
+ "strings"
+
+ "heliosian/internal/auth"
)
+var sections = []string{"people", "classrooms", "my-family", "staff", "map", "email-list", "data-view", "bug-report", "about"}
+
+var legacy = map[string]string{
+ "people": "/people",
+ "explore": "/classrooms",
+ "myfamily": "/my-family",
+ "staff": "/staff",
+ "map": "/map",
+ "emails": "/email-list",
+ "33234e": "/data-view",
+ "ee614d": "/bug-report",
+ "255ce0": "/about",
+}
+
type app struct {
cache *Cache
}
func Register(mux *http.ServeMux, cache *Cache) {
a := app{cache: cache}
- mux.HandleFunc("GET /directory/{$}", a.index)
- mux.HandleFunc("GET /directory/api/model", a.model)
- mux.Handle("GET /directory/static/", http.StripPrefix("/directory/static/", http.FileServer(http.Dir("web/directory/static"))))
+ for _, section := range sections {
+ mux.HandleFunc("GET /"+section, a.page)
+ }
+ mux.HandleFunc("GET /dl/", a.legacyRedirect)
+ mux.HandleFunc("GET /api/directory/model", a.model)
}
-func (a app) index(w http.ResponseWriter, r *http.Request) {
+func (a app) legacyRedirect(w http.ResponseWriter, r *http.Request) {
+ first, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/dl/"), "/")
+ target, ok := legacy[first]
+ if !ok {
+ target = "/people"
+ }
+ http.Redirect(w, r, target, http.StatusMovedPermanently)
+}
+
+func (a app) page(w http.ResponseWriter, r *http.Request) {
t, err := template.ParseFiles("web/directory/index.html")
if err != nil {
serverError(w, err)
return
}
- if err := t.Execute(w, nil); err != nil {
- log.Printf("[ERROR] render directory index: %v", err)
+ name := a.cache.Model().DisplayName(auth.Email(r))
+ data := map[string]string{
+ "UserName": name,
+ "UserInitial": strings.ToUpper(name[:1]),
+ }
+ if err := t.Execute(w, data); err != nil {
+ log.Printf("[ERROR] render directory page: %v", err)
}
}
diff --git a/internal/directory/model.go b/internal/directory/model.go
index cd0e128..0821b31 100644
--- a/internal/directory/model.go
+++ b/internal/directory/model.go
@@ -54,6 +54,15 @@ type Grade struct {
NextBand string `json:"nextBand,omitempty"`
}
+func (m *Model) DisplayName(email string) string {
+ for _, p := range m.People {
+ if p.Email == email {
+ return p.FullName
+ }
+ }
+ return email
+}
+
type Model struct {
People []Person `json:"people"`
Families map[string]Family `json:"families"`
diff --git a/main.go b/main.go
index a54054d..82bf4a7 100644
--- a/main.go
+++ b/main.go
@@ -65,7 +65,7 @@ func main() {
mux := http.NewServeMux()
authn.Register(mux)
directory.Register(mux, cache)
- mux.Handle("GET /{$}", http.RedirectHandler("/directory/", http.StatusFound))
+ mux.Handle("GET /{$}", http.RedirectHandler("/people", http.StatusFound))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
port := os.Getenv("PORT")
if port == "" {
diff --git a/tools/browse/main.go b/tools/browse/main.go
index 5dddce3..c7ebbcc 100644
--- a/tools/browse/main.go
+++ b/tools/browse/main.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/chromedp/cdproto/input"
+ "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/target"
"github.com/chromedp/chromedp"
)
@@ -108,7 +109,9 @@ func main() {
typeText := flag.String("type", "", "insert text into the focused element")
key := flag.String("key", "", "press a key: enter, tab, escape, backspace, or a literal character")
wait := flag.String("wait", "", "css selector that must be visible before capturing")
+ cookie := flag.String("cookie", "", "set a name=value cookie on localhost before acting")
mobile := flag.Bool("mobile", false, "emulate a phone viewport (390x844, touch) instead of desktop 1280x800")
+ size := flag.String("size", "", "viewport size as WxH, overriding the desktop default")
dump := flag.Bool("dump", false, "print page html instead of writing a screenshot")
eval := flag.String("eval", "", "evaluate javascript in the page and print the json result instead of writing a screenshot")
out := flag.String("out", "screenshots/browse.png", "output png path")
@@ -128,7 +131,23 @@ func main() {
if *mobile {
viewport = chromedp.EmulateViewport(390, 844, chromedp.EmulateMobile)
}
+ if *size != "" {
+ w, h, ok := strings.Cut(*size, "x")
+ width, werr := strconv.ParseInt(w, 10, 64)
+ height, herr := strconv.ParseInt(h, 10, 64)
+ if !ok || werr != nil || herr != nil {
+ log.Fatalf("[ERROR] size must be WxH, got %q", *size)
+ }
+ viewport = chromedp.EmulateViewport(width, height)
+ }
actions := []chromedp.Action{viewport}
+ if *cookie != "" {
+ name, value, ok := strings.Cut(*cookie, "=")
+ if !ok {
+ log.Fatalf("[ERROR] cookie must be name=value, got %q", *cookie)
+ }
+ actions = append(actions, network.SetCookie(name, value).WithDomain("localhost").WithPath("/"))
+ }
if *nav != "" {
actions = append(actions, chromedp.Navigate(*nav))
}
diff --git a/tools/screenshot/main.go b/tools/screenshot/main.go
index 3351ae9..63a8267 100644
--- a/tools/screenshot/main.go
+++ b/tools/screenshot/main.go
@@ -13,7 +13,7 @@ import (
)
func main() {
- url := flag.String("url", "http://localhost:8080/directory/", "page to capture")
+ url := flag.String("url", "http://localhost:8080/people", "page to capture")
out := flag.String("out", "screenshots/capture.png", "output png path")
wait := flag.String("wait", "body", "css selector that must be visible before capturing")
remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome")
diff --git a/web/directory/index.html b/web/directory/index.html
index b15016d..4132bad 100644
--- a/web/directory/index.html
+++ b/web/directory/index.html
@@ -2,16 +2,21 @@
-
-Helios Directory
-
+
+
+Helios Who?
+
+
+
+
-
-
-
+
+
+
diff --git a/web/directory/static/app.js b/web/directory/static/app.js
deleted file mode 100644
index c0c2ca5..0000000
--- a/web/directory/static/app.js
+++ /dev/null
@@ -1,104 +0,0 @@
-const state = {model: null};
-
-function hue(text) {
- let h = 0;
- for (const c of text) {
- h = (h * 31 + c.codePointAt(0)) % 360;
- }
- return h;
-}
-
-function el(tag, className, text) {
- const node = document.createElement(tag);
- if (className) {
- node.className = className;
- }
- if (text) {
- node.textContent = text;
- }
- return node;
-}
-
-function initials(name) {
- const words = name.trim().split(/\s+/);
- if (words.length === 0 || !words[0]) {
- return '?';
- }
- const first = words[0][0];
- return words.length > 1 ? first + words[words.length - 1][0] : first;
-}
-
-function roles(p) {
- return [p.isStudent && 'Student', p.isParent && 'Parent', p.isStaff && 'Staff'].filter(Boolean);
-}
-
-function card(p, family) {
- const root = el('div', 'card');
- if (p.photoUrl) {
- const img = el('img', 'avatar');
- img.src = p.photoUrl;
- img.loading = 'lazy';
- root.append(img);
- } else {
- const avatar = el('div', 'avatar', initials(p.fullName));
- avatar.style.background = `hsl(${hue(p.fullName)} 60% 45%)`;
- root.append(avatar);
- }
- const info = el('div');
- info.append(el('div', 'name', p.fullName));
- if (p.pronouns) {
- info.append(el('div', 'pronouns', p.pronouns));
- }
- const who = [roles(p).join(' / ')];
- if (p.grade) {
- who.push([p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '));
- }
- if (p.jobTitle) {
- who.push(p.jobTitle);
- }
- info.append(el('div', 'detail', who.join(' · ')));
- if (family && family.name) {
- info.append(el('div', 'detail', family.name));
- }
- if (family && family.address) {
- info.append(el('div', 'detail', family.address));
- }
- const contact = [p.phone, p.email].filter(Boolean).join(' · ');
- if (contact) {
- info.append(el('div', 'detail', contact));
- }
- root.append(info);
- return root;
-}
-
-function render() {
- const q = document.querySelector('#search').value.trim().toLowerCase();
- const list = document.querySelector('#people');
- list.replaceChildren();
- if (!state.model) {
- return;
- }
- const matches = state.model.people.filter(p => {
- const family = state.model.families[p.familyKey];
- return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q);
- });
- if (matches.length === 0) {
- list.append(el('div', 'empty', 'No matches.'));
- return;
- }
- for (const p of matches) {
- list.append(card(p, state.model.families[p.familyKey]));
- }
-}
-
-async function load() {
- const res = await fetch('/directory/api/model');
- if (!res.ok) {
- throw new Error(`loading model failed: ${res.status}`);
- }
- state.model = await res.json();
- render();
-}
-
-document.querySelector('#search').addEventListener('input', render);
-load();
diff --git a/web/directory/static/style.css b/web/directory/static/style.css
deleted file mode 100644
index 125617b..0000000
--- a/web/directory/static/style.css
+++ /dev/null
@@ -1,97 +0,0 @@
-:root {
- --bg: #f6f7f9;
- --card: #ffffff;
- --line: #e2e6eb;
- --text: #1c2430;
- --muted: #5b6572;
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- font-family: system-ui, sans-serif;
- background: var(--bg);
- color: var(--text);
-}
-
-header {
- position: sticky;
- top: 0;
- background: var(--card);
- border-bottom: 1px solid var(--line);
- padding: 1rem 1.5rem;
- display: flex;
- align-items: center;
- gap: 1.5rem;
-}
-
-header h1 {
- font-size: 1.25rem;
- margin: 0;
-}
-
-#search {
- flex: 1;
- max-width: 24rem;
- padding: 0.5rem 0.75rem;
- border: 1px solid #cdd3db;
- border-radius: 0.5rem;
- font-size: 1rem;
-}
-
-#people {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
- gap: 1rem;
- padding: 1.5rem;
- max-width: 72rem;
- margin: 0 auto;
-}
-
-.card {
- background: var(--card);
- border: 1px solid var(--line);
- border-radius: 0.75rem;
- padding: 1rem;
- display: flex;
- gap: 0.75rem;
-}
-
-.avatar {
- width: 3rem;
- height: 3rem;
- border-radius: 50%;
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: 600;
- flex-shrink: 0;
- object-fit: cover;
-}
-
-.name {
- font-weight: 600;
-}
-
-.pronunciation {
- color: var(--muted);
- font-style: italic;
- font-size: 0.875rem;
-}
-
-.detail {
- color: var(--muted);
- font-size: 0.875rem;
- margin-top: 0.25rem;
-}
-
-.empty {
- color: var(--muted);
- padding: 3rem;
- text-align: center;
- grid-column: 1 / -1;
-}
diff --git a/web/static/directory/app.js b/web/static/directory/app.js
new file mode 100644
index 0000000..8b13e6a
--- /dev/null
+++ b/web/static/directory/app.js
@@ -0,0 +1,334 @@
+const state = {model: null, tab: 'everyone', q: ''};
+let byEmail = {};
+
+const icons = {
+ people: '',
+ classrooms: '',
+ 'my-family': '',
+ staff: '',
+ map: '',
+ 'email-list': '',
+ 'data-view': '',
+ 'bug-report': '',
+ about: '',
+ everyone: '',
+ students: '',
+ families: '',
+ 'staff-tab': '',
+ search: '',
+ filter: '',
+ chevron: '',
+};
+
+const navSections = [
+ {path: 'people', label: 'People'},
+ {path: 'classrooms', label: 'Classrooms'},
+ {path: 'my-family', label: 'My Family'},
+ {path: 'staff', label: 'Staff'},
+ {path: 'map', label: 'Map'},
+ {path: 'email-list', label: 'Email List'},
+ {divider: true},
+ {path: 'data-view', label: 'Data View'},
+ {path: 'bug-report', label: 'Bug Report'},
+ {path: 'about', label: 'Share & About'},
+];
+
+const peopleTabs = [
+ {key: 'everyone', label: 'Everyone'},
+ {key: 'students', label: 'Students'},
+ {key: 'families', label: 'Families'},
+ {key: 'staff', label: 'Staff'},
+];
+
+function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) {
+ node.className = className;
+ }
+ if (text) {
+ node.textContent = text;
+ }
+ return node;
+}
+
+function svg(name) {
+ const holder = document.createElement('template');
+ holder.innerHTML = icons[name];
+ return holder.content.firstChild;
+}
+
+function section() {
+ return location.pathname.replaceAll('/', '');
+}
+
+function renderNav() {
+ const nav = document.querySelector('#nav');
+ nav.replaceChildren();
+ for (const item of navSections) {
+ if (item.divider) {
+ nav.append(el('div', 'nav-divider'));
+ continue;
+ }
+ const a = el('a');
+ a.href = '/' + item.path;
+ if (item.path === section()) {
+ a.className = 'active';
+ }
+ a.append(svg(item.path), el('span', '', item.label));
+ nav.append(a);
+ }
+}
+
+function hue(text) {
+ let h = 0;
+ for (const c of text) {
+ h = (h * 31 + c.codePointAt(0)) % 360;
+ }
+ return h;
+}
+
+function firstName(fullName) {
+ return fullName.trim().split(/\s+/)[0];
+}
+
+function photoOrInitials(url, name, className) {
+ if (url) {
+ const img = el('img', className);
+ img.src = url;
+ img.loading = 'lazy';
+ img.alt = '';
+ return img;
+ }
+ const div = el('div', className, name.trim().split(/\s+/).map(w => w[0]).slice(0, 2).join(''));
+ div.style.background = `hsl(${hue(name)} 45% 55%)`;
+ return div;
+}
+
+function roleLabel(p) {
+ let role = 'Parent';
+ if (p.isStudent) {
+ role = 'Student';
+ } else if (p.isStaff) {
+ role = 'Staff';
+ }
+ return (p.pronouns ? `${role} (${p.pronouns})` : role).toUpperCase();
+}
+
+function personContext(p) {
+ if (p.isStudent) {
+ return [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ ');
+ }
+ if (p.isStaff && p.jobTitle) {
+ return p.jobTitle;
+ }
+ const family = state.model.families[p.familyKey];
+ if (family) {
+ return (family.kidEmails || []).map(e => byEmail[e]?.fullName).filter(Boolean).join(', ');
+ }
+ return '';
+}
+
+function personCard(p) {
+ const card = el('div', 'person-card');
+ card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo'));
+ card.append(el('div', 'role-label', roleLabel(p)));
+ card.append(el('div', 'person-name', p.fullName));
+ const context = personContext(p);
+ if (context) {
+ card.append(el('div', 'person-sub', context));
+ }
+ return card;
+}
+
+function renderEveryone(grid) {
+ grid.className = 'people-grid';
+ const q = state.q;
+ const matches = state.model.people.filter(p => {
+ const family = state.model.families[p.familyKey];
+ return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q);
+ });
+ for (const p of matches) {
+ grid.append(personCard(p));
+ }
+ return matches.length;
+}
+
+function renderStudents(grid) {
+ grid.className = 'student-grid';
+ const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q));
+ for (const p of matches) {
+ const card = el('div', 'student-card');
+ if (p.photoUrl) {
+ const img = el('img', 'student-photo');
+ img.src = p.photoUrl;
+ img.loading = 'lazy';
+ img.alt = '';
+ card.append(img);
+ }
+ card.append(el('div', 'student-first', firstName(p.fullName)));
+ card.append(el('div', 'student-last', p.fullName.replace(firstName(p.fullName), '').trim()));
+ card.append(el('div', 'student-line', [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ ')));
+ if (p.pronouns) {
+ card.append(el('div', 'student-pronouns', p.pronouns));
+ }
+ grid.append(card);
+ }
+ return matches.length;
+}
+
+function familyEntries() {
+ const entries = Object.values(state.model.families).map(f => {
+ const members = [...(f.kidEmails || []), ...(f.adultEmails || [])];
+ const kidGrades = [...new Set((f.kidEmails || []).map(e => byEmail[e]?.grade).filter(Boolean))];
+ return {
+ name: (f.name || '').replace(/ Family$/, ''),
+ label: kidGrades.length ? kidGrades.join(', ') : 'Staff',
+ members: members.map(e => byEmail[e] ? firstName(byEmail[e].fullName) : '').filter(Boolean),
+ photoUrl: f.photoUrl,
+ };
+ });
+ for (const p of state.model.people) {
+ if (p.isStaff && !p.isParent && !p.isStudent && !state.model.families[p.familyKey]) {
+ entries.push({
+ name: p.fullName.trim().split(/\s+/).slice(-1)[0],
+ label: 'Staff',
+ members: [firstName(p.fullName)],
+ photoUrl: p.photoUrl,
+ });
+ }
+ }
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ return entries;
+}
+
+function renderFamilies(grid) {
+ grid.className = 'family-grid';
+ const matches = familyEntries().filter(f =>
+ `${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q));
+ for (const f of matches) {
+ const card = el('div', 'family-card');
+ card.append(photoOrInitials(f.photoUrl, f.name, 'family-photo'));
+ card.append(el('div', 'family-label', f.label));
+ card.append(el('div', 'family-name', f.name));
+ card.append(el('div', 'family-kids', f.members.join(', ')));
+ grid.append(card);
+ }
+ return matches.length;
+}
+
+function renderStaff(grid) {
+ grid.className = '';
+ const staff = state.model.people.filter(p =>
+ p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q));
+ const departments = state.model.departments || [];
+ const groups = new Map();
+ for (const p of staff) {
+ const dept = p.department || 'Staff';
+ if (!groups.has(dept)) {
+ groups.set(dept, []);
+ }
+ groups.get(dept).push(p);
+ }
+ const ordered = [...groups.keys()].sort((a, b) => {
+ const ia = departments.indexOf(a);
+ const ib = departments.indexOf(b);
+ return (ia < 0 ? departments.length : ia) - (ib < 0 ? departments.length : ib);
+ });
+ let count = 0;
+ for (const dept of ordered) {
+ grid.append(el('h2', 'staff-section', dept));
+ const deptGrid = el('div', 'people-grid');
+ for (const p of groups.get(dept)) {
+ const card = el('div', 'person-card');
+ card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo'));
+ card.append(el('div', 'role-label', p.jobTitle || 'Staff'));
+ card.append(el('div', 'person-name', p.fullName));
+ deptGrid.append(card);
+ count++;
+ }
+ grid.append(deptGrid);
+ }
+ return count;
+}
+
+const tabRenderers = {
+ everyone: renderEveryone,
+ students: renderStudents,
+ families: renderFamilies,
+ staff: renderStaff,
+};
+
+function renderPeople() {
+ const main = document.querySelector('#main');
+ main.replaceChildren();
+
+ const tabs = el('div', 'tabs');
+ const tabsRow = el('div', 'container tabs-row');
+ for (const tab of peopleTabs) {
+ const node = el('div', 'tab' + (tab.key === state.tab ? ' active' : ''));
+ node.append(svg(tab.key === 'staff' ? 'staff-tab' : tab.key), el('span', '', tab.label));
+ node.addEventListener('click', () => {
+ state.tab = tab.key;
+ state.q = '';
+ renderPeople();
+ });
+ tabsRow.append(node);
+ }
+ tabs.append(tabsRow);
+ main.append(tabs);
+
+ const content = el('div', 'content container');
+ const header = el('div', 'content-header');
+ header.append(el('h1', '', peopleTabs.find(t => t.key === state.tab).label));
+ const controls = el('div', 'controls');
+ const search = el('div', 'search');
+ search.append(svg('search'));
+ const input = el('input');
+ input.placeholder = 'Search';
+ input.value = state.q;
+ input.addEventListener('input', () => {
+ state.q = input.value.trim().toLowerCase();
+ renderGrid();
+ });
+ search.append(input);
+ const filter = el('button', 'filter-button');
+ filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron'));
+ controls.append(search, filter);
+ header.append(controls);
+ content.append(header);
+
+ const grid = el('div');
+ content.append(grid);
+ main.append(content);
+
+ function renderGrid() {
+ grid.replaceChildren();
+ if (tabRenderers[state.tab](grid) === 0) {
+ grid.append(el('div', 'empty', 'No matches.'));
+ }
+ }
+ renderGrid();
+ input.focus();
+}
+
+function render() {
+ renderNav();
+ if (section() === 'people') {
+ renderPeople();
+ }
+}
+
+async function load() {
+ const res = await fetch('/api/directory/model');
+ if (!res.ok) {
+ throw new Error(`loading model failed: ${res.status}`);
+ }
+ state.model = await res.json();
+ byEmail = {};
+ for (const p of state.model.people) {
+ byEmail[p.email] = p;
+ }
+ render();
+}
+
+load();
diff --git a/web/static/directory/style.css b/web/static/directory/style.css
new file mode 100644
index 0000000..4ea1196
--- /dev/null
+++ b/web/static/directory/style.css
@@ -0,0 +1,428 @@
+:root {
+ --brand: #014e54;
+ --sidebar: #173c41;
+ --sidebar-active: #2f5054;
+ --line: #e2e6eb;
+ --ink: #0d0d0d;
+ --muted: #707070;
+ --label: #1f4d53;
+ --input: #efefef;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: Inter, system-ui, sans-serif;
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+ color: var(--ink);
+ background: #fff;
+}
+
+.sidebar {
+ width: 256px;
+ flex-shrink: 0;
+ background: var(--sidebar);
+ color: #fff;
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px 12px;
+ font-weight: 700;
+ font-size: 16px;
+}
+
+.brand img {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+}
+
+nav {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ flex: 1;
+}
+
+nav a {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ height: 36px;
+ color: rgba(255, 255, 255, 0.8);
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0 12px;
+ border-radius: 8px;
+}
+
+nav a svg {
+ width: 20px;
+ height: 20px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ flex-shrink: 0;
+}
+
+nav a:hover {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+nav a.active {
+ background: var(--sidebar-active);
+ color: #fff;
+}
+
+.nav-divider {
+ border-top: 1px solid rgba(255, 255, 255, 0.15);
+ margin: 8px 12px;
+}
+
+.user {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.user-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: #f5f1d0;
+ color: #333;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+main {
+ flex: 1;
+ overflow-y: auto;
+ background: #fff;
+}
+
+.container {
+ max-width: 1260px;
+ width: 100%;
+ margin: 0 auto;
+ padding-left: 30px;
+ padding-right: 30px;
+}
+
+.tabs {
+ border-bottom: 1px solid var(--line);
+ padding-top: 13px;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 2;
+}
+
+.tabs-row {
+ display: flex;
+ gap: 4px;
+}
+
+.tab {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 43px;
+ padding: 0 12px;
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(51, 51, 51, 0.7);
+ cursor: pointer;
+}
+
+.tab svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.tab.active {
+ color: rgba(0, 0, 0, 0.95);
+}
+
+.tab.active::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 3px;
+ height: 2px;
+ border-radius: 1px;
+ background: var(--label);
+}
+
+.content {
+ padding-top: 15px;
+ padding-bottom: 48px;
+}
+
+.content-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin: 4px 0 26px;
+}
+
+.content-header h1 {
+ font-size: 24px;
+ font-weight: 700;
+ margin: 0;
+}
+
+.controls {
+ display: flex;
+ gap: 10px;
+}
+
+.search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--input);
+ border-radius: 8px;
+ padding: 0 12px;
+ width: 231px;
+ color: var(--muted);
+}
+
+.search svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.8;
+ flex-shrink: 0;
+}
+
+.search input {
+ border: 0;
+ background: transparent;
+ outline: 0;
+ font: inherit;
+ width: 100%;
+ padding: 9px 0;
+ color: var(--ink);
+}
+
+.filter-button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--input);
+ border-radius: 8px;
+ padding: 9px 14px;
+ font: inherit;
+ font-size: 14px;
+ color: #444;
+ border: 0;
+}
+
+.filter-button svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.8;
+}
+
+.people-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 32px 16px;
+}
+
+.person-card {
+ text-align: center;
+}
+
+.person-photo {
+ width: 62%;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ object-fit: cover;
+ display: block;
+ margin: 0 auto 12px;
+ background: #eef1f3;
+ color: #fff;
+}
+
+div.person-photo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2.5rem;
+ font-weight: 600;
+}
+
+.role-label {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--label);
+ text-transform: uppercase;
+ margin-bottom: 3px;
+}
+
+.person-name {
+ font-weight: 700;
+ font-size: 15px;
+}
+
+.person-sub {
+ color: var(--muted);
+ font-size: 13px;
+ margin-top: 3px;
+ line-height: 1.4;
+}
+
+.student-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 24px;
+}
+
+.student-card {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ overflow: hidden;
+ text-align: center;
+ padding-bottom: 20px;
+ background: #fff;
+}
+
+.student-photo {
+ width: 100%;
+ aspect-ratio: 1.35;
+ object-fit: cover;
+ display: block;
+ background: #fff;
+}
+
+.student-first {
+ font-size: 24px;
+ font-weight: 700;
+ margin-top: 16px;
+}
+
+.student-last {
+ color: var(--muted);
+ font-size: 15px;
+ margin-top: 2px;
+}
+
+.student-line {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 14px;
+}
+
+.student-pronouns {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--label);
+ text-transform: uppercase;
+ margin-top: 10px;
+}
+
+.family-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 32px 24px;
+}
+
+.family-photo {
+ width: 100%;
+ aspect-ratio: 1.32;
+ object-fit: cover;
+ display: block;
+ border-radius: 6px;
+ background: #f4f5f6;
+}
+
+.family-label {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--label);
+ text-transform: uppercase;
+ margin: 12px 0 2px;
+}
+
+.family-name {
+ font-weight: 700;
+ font-size: 15px;
+}
+
+.family-kids {
+ color: var(--muted);
+ font-size: 13px;
+ margin-top: 3px;
+}
+
+.staff-section {
+ margin: 10px 0 20px;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+.staff-section:not(:first-child) {
+ margin-top: 36px;
+}
+
+.empty {
+ color: var(--muted);
+ padding: 3rem;
+ text-align: center;
+ grid-column: 1 / -1;
+}
+
+@media (max-width: 900px) {
+ .container {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+ .people-grid,
+ .family-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .student-grid {
+ grid-template-columns: 1fr;
+ }
+ .search {
+ width: 160px;
+ }
+}