From 677b453003a4122e4decd7c6fdc1371ccf965e83 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 7 Oct 2026 13:40:29 +0200 Subject: [PATCH 01/74] feat(dashboard): checkpoint Rail redesign Port the Rail shell, shared primitives, and dashboard pages to production routes and existing server functions. Preserve this checkpoint before applying the correctness review findings. --- AGENTS.md | 17 + docs/design.md | 1058 +++++++++++++++++ package.json | 1 - pnpm-lock.yaml | 47 +- src/components/app-mark.tsx | 41 +- src/components/dashboard-breadcrumb.tsx | 80 -- src/components/dashboard-frame.tsx | 65 - src/components/dashboard-navigation.ts | 116 -- src/components/dashboard-sidebar.tsx | 267 ----- src/components/data-toolbar.tsx | 89 -- src/components/empty-state.tsx | 29 - src/components/group-action-buttons.tsx | 73 -- src/components/loading-skeleton.tsx | 93 -- src/components/page-header.tsx | 26 - src/components/pagination.tsx | 132 -- src/components/primitives/avatar-group.tsx | 55 + src/components/primitives/chip-overflow.tsx | 42 + src/components/primitives/chip.tsx | 26 + .../primitives/color-swatch-select.tsx | 114 ++ src/components/primitives/combobox.tsx | 67 ++ src/components/primitives/confirm-dialog.tsx | 130 ++ src/components/primitives/container-hide.ts | 48 + src/components/primitives/count-badge.tsx | 29 + src/components/primitives/data-table.tsx | 329 +++++ src/components/primitives/edit-slot.tsx | 44 + src/components/primitives/empty-state.tsx | 30 + src/components/primitives/field-counter.tsx | 22 + src/components/primitives/file-button.tsx | 93 ++ src/components/primitives/form-dialog.tsx | 199 ++++ src/components/primitives/form-field.tsx | 59 + .../primitives/group-label-badges.tsx | 50 + src/components/primitives/hint.tsx | 26 + src/components/primitives/icon-button.tsx | 62 + src/components/primitives/index.ts | 55 + src/components/primitives/inline-alert.tsx | 37 + src/components/primitives/inline-edit.tsx | 222 ++++ .../primitives/invite-link-button.tsx | 33 + src/components/primitives/key-value-list.tsx | 57 + src/components/primitives/label-chip.tsx | 71 ++ src/components/primitives/label-name.ts | 40 + .../primitives/label-tree-selector.tsx | 273 +++++ src/components/primitives/loading-button.tsx | 48 + src/components/primitives/menu.tsx | 56 + src/components/primitives/motion.ts | 18 + src/components/primitives/nav-card.tsx | 49 + src/components/primitives/platform-glyph.tsx | 15 + src/components/primitives/record-header.tsx | 51 + src/components/primitives/reveal.tsx | 21 + src/components/primitives/section-card.tsx | 78 ++ src/components/primitives/section-empty.tsx | 22 + src/components/primitives/section-heading.tsx | 35 + .../primitives/segmented-control.tsx | 80 ++ src/components/primitives/skeletons.tsx | 149 +++ src/components/primitives/spinner.tsx | 7 + src/components/primitives/stat-tile.tsx | 47 + src/components/primitives/status-badge.tsx | 32 + .../primitives/table-pagination.tsx | 127 ++ src/components/primitives/unset.tsx | 10 + .../primitives/use-focus-after-removal.ts | 58 + src/components/primitives/use-modifier-key.ts | 10 + .../primitives/use-open-generation.ts | 15 + .../primitives/visibility-toggle.tsx | 31 + src/components/reui/stepper.tsx | 418 ------- src/components/route-error.tsx | 79 +- src/components/shell/account-avatar.tsx | 29 + src/components/shell/command-palette.tsx | 136 +++ src/components/shell/index.ts | 40 + src/components/shell/nav.ts | 369 ++++++ src/components/shell/page-bar.tsx | 394 ++++++ src/components/shell/panel-sheet.tsx | 196 +++ src/components/shell/panel.tsx | 103 ++ src/components/shell/rail.tsx | 214 ++++ src/components/shell/service-glyph.tsx | 11 + src/components/shell/shell.tsx | 194 +++ src/components/shell/theme.ts | 39 + src/components/shell/toast.ts | 12 + src/components/shell/use-can-write.ts | 21 + .../shell/use-keyboard-shortcuts.ts | 43 + src/components/shell/use-sign-out.ts | 28 + .../telegram/create-grant-dialog.tsx | 672 +++++------ .../telegram/grant-date-time-fields.tsx | 360 +++--- src/components/telegram/telegram-user.ts | 36 + src/components/theme-toggle.tsx | 34 +- src/components/ui/accordion.tsx | 2 +- src/components/ui/badge.tsx | 2 +- src/components/ui/breadcrumb.tsx | 90 -- src/components/ui/dialog.tsx | 4 +- src/components/ui/pagination.tsx | 93 -- src/components/ui/sidebar.tsx | 665 ----------- src/components/ui/sonner.tsx | 21 +- src/components/ui/toggle.tsx | 2 +- src/features/account/account-page.tsx | 175 +-- src/features/account/account.validation.ts | 12 +- src/features/account/confirmation-dialog.tsx | 53 - src/features/account/profile-card.tsx | 218 ++++ src/features/account/profile-sections.tsx | 181 --- src/features/account/security-cards.tsx | 237 ++++ src/features/account/security-sections.tsx | 213 ---- src/features/account/types.ts | 2 - src/features/account/use-account.ts | 255 ++-- .../associations/association-card.tsx | 484 +++----- .../associations/association-dialogs.tsx | 250 ---- .../associations/association-links-dialog.tsx | 212 ++-- .../associations/associations-page.tsx | 359 +++--- .../associations/associations.constants.ts | 53 +- src/features/associations/types.ts | 4 +- src/features/auth/login-page.tsx | 10 +- src/features/azure/group-membership.tsx | 288 ----- src/features/azure/groups-page.tsx | 417 ++++--- src/features/azure/member-dialog.tsx | 274 +++-- src/features/azure/members-page.tsx | 534 ++++----- src/features/azure/membership-dialog.tsx | 305 +++++ src/features/dashboard/overview-page.tsx | 270 +++-- .../faqs/components/add-category-dialog.tsx | 136 --- .../faqs/components/category-switcher.tsx | 94 -- .../faqs/components/delete-popover.tsx | 44 - .../faqs/components/faq-accordion-item.tsx | 259 ---- .../faqs/components/faq-accordion-list.tsx | 96 -- src/features/faqs/components/faq-button.tsx | 31 - .../faqs/components/faq-page-header.tsx | 31 - .../faqs/components/language-badge.tsx | 21 - src/features/faqs/faq-category-dialog.tsx | 177 +++ .../faqs/{components => }/faq-icon.tsx | 10 +- src/features/faqs/faq-items.tsx | 242 ++++ src/features/faqs/faqs-page.tsx | 679 ++++++----- .../group-labels/add-category-dialog.tsx | 218 ++-- src/features/group-labels/add-tag-dialog.tsx | 179 +-- .../group-labels/group-label-badges.tsx | 53 - .../group-labels/group-label-card.tsx | 278 ----- .../group-labels/group-label-color-picker.tsx | 36 - .../group-labels/group-label-tree-row.tsx | 141 --- .../group-labels/group-labels-dialog.tsx | 171 ++- .../group-labels/group-labels-page.tsx | 760 +++++++++--- .../group-labels/group-labels.constants.ts | 4 - src/features/group-labels/label-dot.tsx | 8 - .../group-labels/label-name-field.tsx | 93 ++ .../group-labels/label-tree-selector.tsx | 262 ---- src/features/group-labels/label-tree.ts | 10 + .../group-labels/rename-label-dialog.tsx | 226 ++-- src/features/group-labels/types.ts | 7 - .../group-labels/use-group-label-rows.ts | 62 - .../group-link-reports/reports-page.tsx | 531 ++++++--- .../add-child-label-dialog.tsx | 175 +-- .../add-group-to-label-dialog.tsx | 746 ++++++------ .../groups-by-label/categories-page.tsx | 53 + .../groups-by-label/category-page.tsx | 217 ++++ .../groups-by-label/combined-groups-table.tsx | 421 ++----- .../groups-by-label/groups-by-label-page.tsx | 159 --- src/features/groups-by-label/label-groups.ts | 47 + .../publish-tag-groups-dialog.tsx | 171 ++- .../groups-by-label/tag-groups-page.tsx | 246 ++-- .../groups-by-label/use-label-group-rows.ts | 78 -- src/features/groups/group-row-actions.tsx | 252 ++++ src/features/groups/labels-filter-popover.tsx | 256 ++++ src/features/guides/guide-dialogs.tsx | 390 +++--- src/features/guides/guides-page.tsx | 283 +++-- src/features/guides/guides.validation.ts | 9 +- src/features/onboarding/use-telegram-link.ts | 2 +- src/features/projects/project-card.tsx | 627 ++++------ src/features/projects/projects-page.tsx | 563 +++++---- src/features/projects/projects.constants.ts | 20 +- src/features/projects/types.ts | 13 +- src/features/telegram/grants-page.tsx | 528 ++++---- src/features/telegram/grants.functions.ts | 20 + src/features/telegram/groups-page.tsx | 324 ++--- src/features/telegram/groups-table.tsx | 238 ---- src/features/telegram/leave-group-dialog.tsx | 110 +- .../telegram/user-detail/grant-dialogs.tsx | 106 +- .../user-detail/group-admin-dialog.tsx | 291 ++--- src/features/telegram/user-detail/profile.tsx | 689 ++++++----- .../telegram/user-detail/role-dialog.tsx | 234 ++-- .../telegram/user-detail/sections.tsx | 89 -- src/features/telegram/user-detail/types.ts | 14 +- src/features/telegram/users-page.tsx | 266 ++--- src/features/web/logo-upload.tsx | 134 +++ src/features/web/web-card.tsx | 25 + .../whatsapp/create-edit-group-dialog.tsx | 176 --- src/features/whatsapp/delete-group-dialog.tsx | 81 +- .../whatsapp/whatsapp-group-dialog.tsx | 160 +++ .../whatsapp/whatsapp-group-fields.tsx | 66 - .../whatsapp/whatsapp-groups-page.tsx | 485 +++----- src/hooks/use-group-visibility-toggle.ts | 58 - src/hooks/use-mobile.ts | 19 - src/lib/format.ts | 64 + src/lib/table.ts | 31 - src/router.tsx | 4 + src/routes/__root.tsx | 6 +- src/routes/dashboard.tsx | 25 +- src/routes/dashboard/account.tsx | 2 +- src/routes/dashboard/azure/groups.tsx | 21 +- src/routes/dashboard/azure/members.tsx | 14 +- src/routes/dashboard/index.tsx | 47 +- src/routes/dashboard/reports/group-links.tsx | 18 +- src/routes/dashboard/reports/resolved.tsx | 18 +- src/routes/dashboard/telegram/grants.tsx | 18 +- src/routes/dashboard/telegram/groups.tsx | 33 +- .../dashboard/telegram/users/$userId.tsx | 36 +- src/routes/dashboard/telegram/users/index.tsx | 9 +- src/routes/dashboard/web/associations.tsx | 9 +- src/routes/dashboard/web/faqs.tsx | 21 +- src/routes/dashboard/web/group-labels.tsx | 15 +- .../dashboard/web/groups-by-label/$.tsx | 31 +- .../dashboard/web/groups-by-label/index.tsx | 44 +- src/routes/dashboard/web/guides.tsx | 15 +- src/routes/dashboard/web/projects.tsx | 9 +- src/routes/dashboard/web/tags/$tag.tsx | 33 +- src/routes/dashboard/whatsapp/groups.tsx | 33 +- src/routes/onboarding/unauthorized.tsx | 2 +- src/styles.css | 533 +++++---- tests/server-security.test.mjs | 42 +- 210 files changed, 16138 insertions(+), 13114 deletions(-) create mode 100644 docs/design.md delete mode 100644 src/components/dashboard-breadcrumb.tsx delete mode 100644 src/components/dashboard-frame.tsx delete mode 100644 src/components/dashboard-navigation.ts delete mode 100644 src/components/dashboard-sidebar.tsx delete mode 100644 src/components/data-toolbar.tsx delete mode 100644 src/components/empty-state.tsx delete mode 100644 src/components/group-action-buttons.tsx delete mode 100644 src/components/loading-skeleton.tsx delete mode 100644 src/components/page-header.tsx delete mode 100644 src/components/pagination.tsx create mode 100644 src/components/primitives/avatar-group.tsx create mode 100644 src/components/primitives/chip-overflow.tsx create mode 100644 src/components/primitives/chip.tsx create mode 100644 src/components/primitives/color-swatch-select.tsx create mode 100644 src/components/primitives/combobox.tsx create mode 100644 src/components/primitives/confirm-dialog.tsx create mode 100644 src/components/primitives/container-hide.ts create mode 100644 src/components/primitives/count-badge.tsx create mode 100644 src/components/primitives/data-table.tsx create mode 100644 src/components/primitives/edit-slot.tsx create mode 100644 src/components/primitives/empty-state.tsx create mode 100644 src/components/primitives/field-counter.tsx create mode 100644 src/components/primitives/file-button.tsx create mode 100644 src/components/primitives/form-dialog.tsx create mode 100644 src/components/primitives/form-field.tsx create mode 100644 src/components/primitives/group-label-badges.tsx create mode 100644 src/components/primitives/hint.tsx create mode 100644 src/components/primitives/icon-button.tsx create mode 100644 src/components/primitives/index.ts create mode 100644 src/components/primitives/inline-alert.tsx create mode 100644 src/components/primitives/inline-edit.tsx create mode 100644 src/components/primitives/invite-link-button.tsx create mode 100644 src/components/primitives/key-value-list.tsx create mode 100644 src/components/primitives/label-chip.tsx create mode 100644 src/components/primitives/label-name.ts create mode 100644 src/components/primitives/label-tree-selector.tsx create mode 100644 src/components/primitives/loading-button.tsx create mode 100644 src/components/primitives/menu.tsx create mode 100644 src/components/primitives/motion.ts create mode 100644 src/components/primitives/nav-card.tsx create mode 100644 src/components/primitives/platform-glyph.tsx create mode 100644 src/components/primitives/record-header.tsx create mode 100644 src/components/primitives/reveal.tsx create mode 100644 src/components/primitives/section-card.tsx create mode 100644 src/components/primitives/section-empty.tsx create mode 100644 src/components/primitives/section-heading.tsx create mode 100644 src/components/primitives/segmented-control.tsx create mode 100644 src/components/primitives/skeletons.tsx create mode 100644 src/components/primitives/spinner.tsx create mode 100644 src/components/primitives/stat-tile.tsx create mode 100644 src/components/primitives/status-badge.tsx create mode 100644 src/components/primitives/table-pagination.tsx create mode 100644 src/components/primitives/unset.tsx create mode 100644 src/components/primitives/use-focus-after-removal.ts create mode 100644 src/components/primitives/use-modifier-key.ts create mode 100644 src/components/primitives/use-open-generation.ts create mode 100644 src/components/primitives/visibility-toggle.tsx delete mode 100644 src/components/reui/stepper.tsx create mode 100644 src/components/shell/account-avatar.tsx create mode 100644 src/components/shell/command-palette.tsx create mode 100644 src/components/shell/index.ts create mode 100644 src/components/shell/nav.ts create mode 100644 src/components/shell/page-bar.tsx create mode 100644 src/components/shell/panel-sheet.tsx create mode 100644 src/components/shell/panel.tsx create mode 100644 src/components/shell/rail.tsx create mode 100644 src/components/shell/service-glyph.tsx create mode 100644 src/components/shell/shell.tsx create mode 100644 src/components/shell/theme.ts create mode 100644 src/components/shell/toast.ts create mode 100644 src/components/shell/use-can-write.ts create mode 100644 src/components/shell/use-keyboard-shortcuts.ts create mode 100644 src/components/shell/use-sign-out.ts create mode 100644 src/components/telegram/telegram-user.ts delete mode 100644 src/components/ui/breadcrumb.tsx delete mode 100644 src/components/ui/pagination.tsx delete mode 100644 src/components/ui/sidebar.tsx delete mode 100644 src/features/account/confirmation-dialog.tsx create mode 100644 src/features/account/profile-card.tsx delete mode 100644 src/features/account/profile-sections.tsx create mode 100644 src/features/account/security-cards.tsx delete mode 100644 src/features/account/security-sections.tsx delete mode 100644 src/features/associations/association-dialogs.tsx delete mode 100644 src/features/azure/group-membership.tsx create mode 100644 src/features/azure/membership-dialog.tsx delete mode 100644 src/features/faqs/components/add-category-dialog.tsx delete mode 100644 src/features/faqs/components/category-switcher.tsx delete mode 100644 src/features/faqs/components/delete-popover.tsx delete mode 100644 src/features/faqs/components/faq-accordion-item.tsx delete mode 100644 src/features/faqs/components/faq-accordion-list.tsx delete mode 100644 src/features/faqs/components/faq-button.tsx delete mode 100644 src/features/faqs/components/faq-page-header.tsx delete mode 100644 src/features/faqs/components/language-badge.tsx create mode 100644 src/features/faqs/faq-category-dialog.tsx rename src/features/faqs/{components => }/faq-icon.tsx (87%) create mode 100644 src/features/faqs/faq-items.tsx delete mode 100644 src/features/group-labels/group-label-badges.tsx delete mode 100644 src/features/group-labels/group-label-card.tsx delete mode 100644 src/features/group-labels/group-label-color-picker.tsx delete mode 100644 src/features/group-labels/group-label-tree-row.tsx delete mode 100644 src/features/group-labels/label-dot.tsx create mode 100644 src/features/group-labels/label-name-field.tsx delete mode 100644 src/features/group-labels/label-tree-selector.tsx delete mode 100644 src/features/group-labels/use-group-label-rows.ts create mode 100644 src/features/groups-by-label/categories-page.tsx create mode 100644 src/features/groups-by-label/category-page.tsx delete mode 100644 src/features/groups-by-label/groups-by-label-page.tsx create mode 100644 src/features/groups-by-label/label-groups.ts delete mode 100644 src/features/groups-by-label/use-label-group-rows.ts create mode 100644 src/features/groups/group-row-actions.tsx create mode 100644 src/features/groups/labels-filter-popover.tsx delete mode 100644 src/features/telegram/groups-table.tsx delete mode 100644 src/features/telegram/user-detail/sections.tsx create mode 100644 src/features/web/logo-upload.tsx create mode 100644 src/features/web/web-card.tsx delete mode 100644 src/features/whatsapp/create-edit-group-dialog.tsx create mode 100644 src/features/whatsapp/whatsapp-group-dialog.tsx delete mode 100644 src/features/whatsapp/whatsapp-group-fields.tsx delete mode 100644 src/hooks/use-group-visibility-toggle.ts delete mode 100644 src/hooks/use-mobile.ts create mode 100644 src/lib/format.ts delete mode 100644 src/lib/table.ts diff --git a/AGENTS.md b/AGENTS.md index 6728c91..f0d1db6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,23 @@ always verify that normal behavior still works with `AGENT_MODE=false`. > Do not run destructive actions across multiple rows, unless specific prompt indication or > ask for user confirmation ALWAYS. +## Design system + +UI work follows [`docs/design.md`](docs/design.md): tokens, shell, page templates, component rules and copy. +Build pages from the shell (`@/components/shell`: `PageBar`, `Toolbar`, `PageContent`, `appToast`, +`useCanWrite`) and the primitives (`@/components/primitives`); use the `--pn-*` tokens, never raw colors. + +Data and error conventions (details in `docs/design.md` §9.1): + +- Call mutation server functions from event handlers through `useServerFn(fn)`, and add each new POST + server function with its consumer file to the map in `tests/server-security.test.mjs`. +- After a mutation, `await router.invalidate({ sync: true })` so loaders have reloaded before the UI settles. +- Every `catch` block and `.catch(handler)` logs the caught error with `console.error(error)` (enforced by `pnpm test`). +- Toasts go through `appToast`; route errors and not-found states come from the router defaults + (`RouteError`, `RouteNotFound`), so routes do not declare `errorComponent`. + +Before handing work back run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. + ## Git commits Use Conventional Commits for every commit message: diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..0c0ebfd --- /dev/null +++ b/docs/design.md @@ -0,0 +1,1058 @@ +# PoliNetwork Admin — design system + +Scope: every page of the admin dashboard. The shell is the "Rail" design (rail · panel · header bar). This document is the single source of truth for builders; where it conflicts with the code, this document wins and the code is the defect. Every sentence is a rule or a measurement. Nothing here is optional unless marked "only when". §9 maps it to files. + +Vocabulary used throughout: + +- **Service** — a rail entry: Overview, Telegram, WhatsApp, Microsoft 365, Web, Reports, Account. +- **Section** — a panel entry inside a service (Telegram › Groups). +- **Deep page** — a page reached from a section that is not itself in the panel (user detail, category node, tag page). +- **Header bar** — the 52px bar at the top of the content column. It is rendered by the page through `PageBar`. +- **Toolbar** — the search / filter / count / primary-action group. On section pages it lives _inside_ the header bar. + +--- + +## 0. Principles + +1. **Say it once.** A location is named in exactly one place per level: service → panel header, section → active panel item, record → one `h1` in the content. _Therefore:_ no breadcrumb, no eyebrow, no page `h1` on section pages, no dialog eyebrow. +2. **The chrome is for finding; the content is for doing.** Rail, panel and header bar locate and filter; nothing in them explains. _Therefore:_ page descriptions are removed; help lives in empty states, field hints and `(i)` tooltips on section headings only when the inventory had a description that carried real instruction. +3. **One accent, neutral everything else.** Brand blue marks the active location, the single primary action, links and focus. _Therefore:_ icons are one color (`--pn-fg-muted`), the only multicolor marks are the three branded service logos (Telegram, WhatsApp, Microsoft 365; see §2.1), status is expressed with five fixed tones and never with ad-hoc colors. +4. **Borders separate; shadows float.** Static surfaces (cards, tables, panel) use a 1px line; only layers that hover over the page (popover, menu, dialog, toast, drag ghost) cast a shadow. _Therefore:_ no card shadows, no hover-lift, dark mode swaps shadows for a 1px ring. +5. **Motion confirms, never decorates.** Animate only state changes the user caused and only on `transform`/`opacity`, under 200ms, ease-out in, faster ease-in out. _Therefore:_ switching sections, sorting, filtering, paging, keyboard navigation and theme toggling do not animate at all. + +--- + +## 1. Wayfinding and hierarchy + +### 1.1 Decision + +Today the same word appears four times (sidebar item, breadcrumb, eyebrow, `h1`). The new rule assigns each level of location to one element and gives the freed space to the thing the user is there to do: + +- **Service** is stated once as text in the **panel header** (icon + "Telegram"). The rail only highlights it (icon + indicator, name on hover tooltip). +- **Section** is stated once by the **active panel item**. Section pages therefore have **no visible `h1`**; the shell renders one `

` with the section name (from `sectionFor(match)`) for assistive tech and `document.title` is `"{Section} · {Service} · PoliNetwork Admin"`. +- **The header bar on a section page holds the toolbar**, not a title. Search, filters and the count sit left; the one primary action sits right. Because the header bar is fixed above the scrolling content, the search field never scrolls away. +- **Deep pages** state their record once as an `h1` **in the content** (`RecordHeader`), and the header bar's left slot states the **parent context** they return to: an icon-only back button followed by the parent's identifier (the list name, the parent category path or the tag kind). The panel keeps the parent section highlighted, so the route is always visible: panel = section, header bar = parent path, content = record. No element repeats another. +- **Pages without a panel** (Overview, Account) put their `h1` in the header bar's left slot, because nothing else names them. +- **There is no breadcrumb component anywhere.** + +### 1.2 Header bar contents by template + +| Template | Left slot | Right slot | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Section page (list, cards, tree, browser root, grouped, queue) | Toolbar: search field → filters → `Count` | One primary action (plus at most one `outline` secondary) | +| Deep page (record detail, category node, tag page) | `BackButton` (icon-only, tooltip "Back to {parent}") → parent context text (`--pn-fg-muted`, 13px; mono when it is an identifier) → `ScrollTitle` (see 1.5) | Record-level actions (max 1 primary + 1 outline + overflow) | +| Overview | `h1` "Overview" (15/600) | — | +| Account | `h1` "Account" (15/600) | — | +| Any page `< 1024px` | Row 1: panel toggle button → `h1` or parent context; Row 2 (section pages only): the toolbar | Row 1: primary action | + +### 1.3 Shell wireframe at 1440px (Telegram › Groups) + +``` +┌──────┬────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐ +│ [PN] │ ✈ Telegram │ [🔍 Search by name or tag… ] [Labels ▾] 1,318 groups [+ Add group]│ 52px +│ ├────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤ +│ ▦ Ov │ Users │ │ +│ 🔍 ⌘K│ ▌ Groups │ Group Telegram ID Tag Labels ⋯ │ +│──────│ Grants │ ─────────────────────────────────────────────────────────────────────────── │ +│ ▌✈ Tg│ │ Analisi Matematica 1 -1001234567 @am1 Didattica › Primo ⎙ 👁 🏷 🗑│ +│ ◯ Wa│ │ Fisica Tecnica -1009876543 — Extra › Lecco ⎙ 👁 🏷 🗑│ +│ ☁ M3│ │ … │ +│ ⊕ We│ │ ─────────────────────────────────────────────────────────────────────────── │ +│ ⚑ Re│ │ Showing 1–20 of 1,318 Rows 20 ▾ ‹ 1 2 3 … 66 › │ +│ │ │ │ +│ ☾ │ │ │ +│ (LC) │ │ │ +└──────┴────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘ + 56px 224px content, max 1280px, 32px side padding +``` + +"Telegram" appears once (panel header). "Groups" appears once (panel). The `▌` bar is the active indicator on both rail and panel. + +### 1.4 Shell wireframe at 1024px (same page) + +``` +┌──────┬──────────────────┬────────────────────────────────────────────────────────────────┐ +│ [PN] │ ✈ Telegram │ [🔍 Search by name or tag… ] [Labels ▾] 1,318 groups [+ Add] │ 52 +│──────├──────────────────┼────────────────────────────────────────────────────────────────┤ +│ ▦ │ Users │ Group Telegram ID Labels ⋯ │ +│ 🔍 │ ▌ Groups │ … │ +│──────│ Grants │ │ +│ ▌✈ │ │ │ +│ ◯ │ │ │ +│ ☁ │ │ │ +│ ⊕ │ │ │ +│ ⚑ │ │ │ +│ ☾ │ │ │ +│ (LC) │ │ │ +└──────┴──────────────────┴────────────────────────────────────────────────────────────────┘ + 56 224 content 744px, 24px side padding +``` + +At exactly 1024 the panel is still visible. Below 1024 the panel is removed from the flow and becomes a left `Sheet` (see 2.2). The "Tag" column is the first to drop (column priority in 4.1). + +### 1.5 Deep-page rule (Telegram user detail) + +``` +┌──────┬────────────────┬──────────────────────────────────────────────────────────────────────────┐ +│ rail │ ✈ Telegram │ [←] 184220371 [Add grant] [End grant] [⋯] │ 52 +│ ├────────────────┼──────────────────────────────────────────────────────────────────────────┤ +│ │ ▌ Users │ (GF) Giulia Ferrari │ +│ │ Groups │ @giuliaf · 2 roles │ +│ │ Grants │ [admin] [hr] [+ Assign role] [− Remove role] +│ │ │ ─────────────────────────────────────────────────────────────────────── │ +``` + +- Header bar left: back button + parent identifier. For a record the identifier is its stable id (mono, `--pn-fg-muted`). For a category node it is the parent path ("Didattica › Ingegneria"). For a tag page it is the kind ("Attributes" or "Publications"). +- Content starts with `RecordHeader`: 40px avatar, `h1` 20/600 name, 13px muted meta line, optional chips row, actions right (only when `canWrite`). +- `ScrollTitle`: when the content `h1` scrolls out of view, the record name fades into the header bar after the parent identifier, separated by a 1px × 16px vertical rule. 150ms opacity + 4px translateY, ease-out. Reduced motion: instant. This is the only moment a name appears twice, and only when the original is off-screen. +- The parent panel item stays active on deep pages (`aria-current="page"` moves to the content `h1` region; the panel item gets `data-ancestor`). + +### 1.6 Descriptions and help text + +- All page descriptions and eyebrows of the previous dashboard are removed. The ones that carried instruction become hints where the instruction is needed: + - Group labels: the three section explanations ("A browsable hierarchy…", "Permanent tags…", "Temporary batches…") become one-line `SectionHeading` descriptions (13px muted) because users confuse the three kinds. They are the only section descriptions in the app. + - Grants: "board-authorized periods…" moves into the Grants empty state text. + - Azure groups: "Looking for groups with zero or one member?" info alert is removed; the collapsed section's own heading "Groups with 0–1 member (n)" carries it. + - Overview intro card and bullets are removed. +- Help on individual controls uses field hints (12px under the field) or an `(i)` icon with a tooltip. No paragraphs above tables. + +--- + +## 2. Shell spec + +### 2.1 Rail + +| Property | Value | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Width | 56px, full viewport height, `--pn-nav` background, 1px right border `--pn-line` | +| Padding | 8px top/bottom, items centered | +| Order (top → bottom) | Logo button (home) · Overview · Search (⌘K) · divider (24×1px `--pn-line`, 8px margin) · Telegram · WhatsApp · Microsoft 365 · Web · Reports · _spacer_ · Theme toggle · Account avatar | +| Item geometry | 40×40 button, radius `--pn-r-3` (8px), 4px vertical gap | +| Icon | 20px. Telegram, WhatsApp and Microsoft 365 use their branded logos (`src/assets/svg/telegram.svg`, `whatsapp.svg`, `azure.svg`) as ``, everywhere the service is named: rail, panel header, sheet, Overview service cards, command palette (`ServiceGlyph`). Everything else is lucide, stroke 1.75, `--pn-fg-muted`: Overview `LayoutDashboard`, Search `Search`, Web `Globe`, Reports `Flag`, Theme `Sun`/`Moon`. | +| Logo | 28px `logo.png` in a 40×40 button, aria-label "PoliNetwork Admin — Overview", bottom margin 8px | +| Hover | background `--pn-muted`, icon `--pn-fg`, 120ms color/background | +| Active | background `--pn-accent-soft`, icon `--pn-accent`; indicator: 2px × 20px bar, radius 1px, `--pn-accent`, positioned at the rail's left edge (x = 0), vertically centered on the item. The indicator does not animate between items. | +| Focus | `outline: 2px solid --pn-focus; outline-offset: 2px` (focus-visible only) | +| Pressed | none (high-frequency control) | +| Tooltip | right side, 8px offset, label = service name (plus "⌘K" kbd for Search). First tooltip opens after 400ms; moving to a sibling within 300ms opens instantly (Base UI `TooltipProvider delay={400} closeDelay={0}` and group timeout 300). | +| Keyboard | The rail is one tab stop (roving tabindex on the active item). `↑/↓` move focus, `Home/End` jump, `Enter`/`Space` activate. `Tab` leaves to the panel (or header bar when no panel). | +| Click behaviour | Service → navigates to the service's first section (or the last visited section of that service, remembered in session storage). On `< 1024px` a service with more than one section opens the panel sheet instead of navigating; one-section services navigate directly. | +| Account | 28px `Avatar` with initials on `--pn-accent-solid`, inside the same 40×40 button; same active/hover treatment; tooltip shows the user's name. Clicking navigates to `/dashboard/account`. Sign out is on the Account page, not in a menu. | +| Theme | Same geometry; no active state; icon swaps instantly; `aria-label` "Switch to dark mode"/"Switch to light mode". Theme switch disables all transitions for one frame (`:root[data-theme-switching] * { transition: none !important }`). | + +### 2.2 Panel + +| Property | Value | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Presence | Rendered for every service with sections (Telegram, WhatsApp, Microsoft 365, Web, Reports). Not rendered for Overview and Account; the content column takes the width. The change in content width on navigation is not animated. | +| Width | 224px, `--pn-nav` background, 1px right border | +| Header | 52px tall (aligned with the header bar so the top border is one continuous line across panel and content), 16px side padding, 16px service glyph in `--pn-fg-muted` + service name 14/600 `--pn-fg`. No count. | +| List | `nav` with `aria-label="{Service} sections"`, 8px padding, 2px gap | +| Item | 32px tall, 10px side padding, radius `--pn-r-2` (6px), 13px/500 (weight never changes), 16px icon `--pn-fg-muted`, 8px gap. | +| Item hover | background `--pn-muted`, 120ms | +| Item active | background `--pn-nav-active`, text `--pn-fg`, icon `--pn-accent`, indicator 2px × 16px `--pn-accent` at the item's left inner edge (x = 0 of the item), `aria-current="page"`. On deep pages the parent item uses the same visual with `data-ancestor` and no `aria-current`. | +| Counts | Only Reports › Open shows a count: pending reports, 12px tabular `--pn-fg-muted`, right-aligned. Zero hides it. No other badges in the panel. | +| Keyboard | Natural tab order through items; `↑/↓` also move within the list. | +| `< 1024px` | Panel is not in the layout. The header bar gains a 36px icon button (`PanelLeft`) at the far left that opens a `Sheet` from the left, 280px wide, containing: the rail services as a list (56px rows with glyph + name) with the active service expanded to show its sections underneath (same item spec). Choosing a section navigates and closes the sheet. Sheet motion: 200ms translateX ease-out in, 150ms ease-in out, backdrop `--pn-scrim`. | +| `< 640px` | Rail is also hidden; the sheet is the only navigation and includes Overview, Search, Theme and Account rows at the bottom. | + +### 2.3 Header bar (`PageBar`) + +| Property | Value | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Height | 52px (`≥1024`); two rows of 52 + 44 on section pages below 1024 | +| Background | `--pn-bg`; 1px bottom border `--pn-line` | +| Padding | 0 24px (`≥1024`: 0 32px when the content container is 1280 wide) | +| Position | Outside the scroll container: the `main` element scrolls; the header bar never moves | +| Layout | `grid-template-columns: minmax(0,1fr) auto; gap 16px; align-items center` | +| Left slot | See 1.2. Items are spaced 8px; the `Count` is separated from filters by 16px. | +| Right slot | Buttons spaced 8px; primary is the right-most element. | +| Search field | 36px tall, 280px wide at `≥1280` (up to 360px when the placeholder needs it: Labels, Members), 240px at `≥1024`, full row width below. Leading `Search` icon 16px; trailing clear button appears only with a value. Placeholder text per page from §7. `/` focuses it from anywhere on the page. Filtering is deferred (`useDeferredValue`) and resets the page to 1. | +| Filters | `outline` buttons 36px with a trailing `ChevronDown`; when active they show a count chip ("Labels · 2") and the border becomes `--pn-accent` at 40% mix. Segmented filters use `ToggleGroup` (36px). | + +### 2.4 Content container + +| Template | Max width | Side padding | Top padding | +| ---------------------------------------------------- | --------- | -------------------- | ----------- | +| List/table, Card-collection, Browser, Queue, Grouped | 1280px | 24px (`≥1440`: 32px) | 24px | +| Tree | 960px | same | 24px | +| Record detail | 1040px | same | 24px | +| Settings (Account) | 720px | same | 32px | +| Overview | 1040px | same | 24px | + +Content is centered. Bottom padding 48px. The scroll container is `main` (`overflow-y: auto; overscroll-behavior: contain`); the document itself never scrolls. + +### 2.5 Command palette (⌘K) + +It exists. One dialog, `cmdk` inside `Dialog`, 560px wide, top-aligned at 15vh, no entrance animation (power-user tool), backdrop `--pn-scrim`. + +| Group | Items | Action | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Sections | every section as "{Service} › {Section}", with the service glyph | navigate | +| Records | Telegram users (name, @username, id), Telegram groups (title, tag), WhatsApp groups (title), labels (humanized path), Microsoft 365 groups (displayName). Max 5 per type, matched by `includes` on normalized text. Shown only when the query has ≥ 2 characters. | navigate to the detail page or to the list pre-filtered (`?q=`) | +| Actions | "Switch to dark/light theme", "Open account", "Sign out" | run | + +The Records group is not built yet: it needs a client-side search source over users, groups, labels and Microsoft 365 groups. Until then the palette lists Sections and Actions only. + +Keyboard: `⌘K`/`Ctrl+K` toggles; `↑/↓` move; `Enter` runs; `Esc` closes; typing filters. Row 40px, 14px text, meta 12px muted on the right, selected row `--pn-muted` background. Empty: "Nothing matches “{q}”." in 13px muted, 32px padding. + +--- + +## 3. Tokens + +All variables are defined in `src/styles.css` on `:root` (light) and `.dark` (dark). Builders use **only** these variables and the Tailwind utilities that resolve to them. Raw hex in components is a defect. + +### 3.1 Brand scale (hue 257.4, derived from #1156ae at step 600) + +| Step | OKLCH | Hex | Use | +| ---- | -------------------------- | --------- | ------------------------------------------------ | +| 50 | `oklch(0.975 0.011 257.4)` | `#f2f7fe` | light soft fill (`--pn-accent-soft`) | +| 100 | `oklch(0.945 0.026 257.4)` | `#e2eeff` | light soft hover | +| 200 | `oklch(0.890 0.054 257.4)` | `#c5ddff` | selection background | +| 300 | `oklch(0.800 0.100 257.4)` | `#95c0fe` | dark accent text/icons, dark focus | +| 400 | `oklch(0.690 0.140 257.4)` | `#619cf1` | dark solid button | +| 500 | `oklch(0.580 0.160 257.4)` | `#3578d7` | light focus ring | +| 600 | `oklch(0.466 0.155 257.4)` | `#1156ae` | **brand**; light accent text, light solid button | +| 700 | `oklch(0.400 0.135 257.4)` | `#0a448e` | light solid hover, light info text | +| 800 | `oklch(0.340 0.110 257.4)` | `#0a366f` | — | +| 900 | `oklch(0.285 0.085 257.4)` | `#0a2953` | dark soft fill | +| 950 | `oklch(0.220 0.060 257.4)` | `#071a36` | dark soft hover | + +Contrast (WCAG): 600 on white 7.08; 700 on 50 8.75; white on 600 7.08; 300 on dark card 9.58; n950 text on 400 6.96. All pairs used below pass AA for normal text. + +### 3.2 Neutrals (cool, hue 250 — blue UIs want cool greys) + +| Step | OKLCH | Hex | +| ---- | ------------------------ | --------- | +| 0 | `oklch(1 0 0)` | `#ffffff` | +| 50 | `oklch(0.985 0.003 250)` | `#f9fafc` | +| 100 | `oklch(0.967 0.005 250)` | `#f2f4f7` | +| 200 | `oklch(0.930 0.008 250)` | `#e4e8ed` | +| 300 | `oklch(0.870 0.011 250)` | `#cfd5db` | +| 400 | `oklch(0.720 0.016 250)` | `#9da6ae` | +| 500 | `oklch(0.580 0.020 250)` | `#727c86` | +| 600 | `oklch(0.490 0.020 250)` | `#58626c` | +| 700 | `oklch(0.400 0.020 250)` | `#404952` | +| 800 | `oklch(0.300 0.022 250)` | `#262f39` | +| 900 | `oklch(0.220 0.024 250)` | `#121b25` | +| 950 | `oklch(0.160 0.022 250)` | `#060e16` | + +### 3.3 Semantic variables + +| Variable | Light | Dark | Notes | +| ------------------------- | ------------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------- | +| `--pn-bg` | n0 `#ffffff` | `oklch(0.165 0.016 250)` `#090f15` | content canvas | +| `--pn-surface` | n0 `#ffffff` | `oklch(0.205 0.018 250)` `#11181f` | cards, table surface | +| `--pn-surface-raised` | n0 `#ffffff` | `oklch(0.240 0.018 250)` `#192028` | popover, menu, dialog, toast | +| `--pn-nav` | n50 `#f9fafc` | `oklch(0.145 0.016 250)` `#060b11` | rail + panel | +| `--pn-nav-active` | n200 `#e4e8ed` | `oklch(1 0 0 / 0.08)` | active panel item | +| `--pn-muted` | n100 `#f2f4f7` | `oklch(0.250 0.018 250)` `#1b222a` | hover fills, skeleton, neutral chip bg | +| `--pn-line` | `oklch(0.22 0.024 250 / 0.10)` | `#262f38` (solid) | all static borders; alpha in light so it composites, solid in dark so it does not glow | +| `--pn-line-strong` | `oklch(0.22 0.024 250 / 0.18)` | `#353e47` | input borders, dividers that must read on `--pn-muted` | +| `--pn-fg` | n900 `#121b25` | n100 `#f2f4f7` | body text (17.4 / 16.2) | +| `--pn-fg-muted` | n600 `#58626c` | n400 `#9da6ae` | secondary text, icons (6.2 / 7.2 on surface) | +| `--pn-fg-subtle` | n500 `#727c86` | n500 `#727c86` | placeholders and disabled text only (4.25 — never for information) | +| `--pn-accent` | brand-600 | brand-300 | text, icons, active marks, links | +| `--pn-accent-hover` | brand-700 | brand-200 | link hover | +| `--pn-accent-solid` | brand-600 | brand-400 | primary button background | +| `--pn-accent-solid-hover` | brand-700 | brand-300 | | +| `--pn-accent-solid-fg` | `#ffffff` | n950 `#060e16` | primary button text | +| `--pn-accent-soft` | brand-50 | brand-900 | active rail item, selected list rows, soft brand badge | +| `--pn-accent-soft-hover` | brand-100 | brand-950 | | +| `--pn-selection` | brand-200 | brand-800 | `::selection` | +| `--pn-focus` | brand-500 `#3578d7` | brand-300 `#95c0fe` | focus outline | +| `--pn-success-fg` | `#00642b` | `#83d494` | text/icon | +| `--pn-success-bg` | `#e8f9eb` | `#122d19` | soft badge bg (6.7 / 8.35 with fg) | +| `--pn-success-solid` | `#007835` | `#83d494` | dots, solid fills (white text 5.6) | +| `--pn-warning-fg` | `#774500` | `#f3ad66` | | +| `--pn-warning-bg` | `#fff1e4` | `#372108` | 7.2 / 7.9 | +| `--pn-warning-solid` | `#8e5300` | `#f3ad66` | | +| `--pn-danger-fg` | `#8c2d28` | `#ffa196` | | +| `--pn-danger-bg` | `#ffefed` | `#3b1c19` | 7.5 / 7.9 | +| `--pn-danger-solid` | `#a43b35` | `#c9524a` | destructive confirm button bg (white text 6.45 / 4.6) | +| `--pn-info-fg` | brand-700 | brand-300 | | +| `--pn-info-bg` | brand-50 | brand-900 | | +| `--pn-scrim` | `oklch(0.16 0.02 250 / 0.40)` | `oklch(0 0 0 / 0.60)` | dialog/sheet backdrop, no blur | + +Hue values used: success 150, warning 65, danger 27, info = brand. Tints are built by lowering chroma, not by opacity, so they do not go grey. + +### 3.4 Surface layering + +| Layer | Light | Dark | Edge | +| ------------------------------------- | --------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | +| Nav (rail, panel) | `--pn-nav` | `--pn-nav` | 1px `--pn-line` right | +| Canvas | `--pn-bg` | `--pn-bg` | — | +| Card / table surface | `--pn-surface` | `--pn-surface` (lighter than canvas) | 1px `--pn-line`, no shadow | +| Raised (popover, menu, combobox list) | `--pn-surface-raised` | `--pn-surface-raised` | light: `--pn-shadow-float`; dark: `0 0 0 1px --pn-line-strong` + `0 8px 24px oklch(0 0 0 / 0.5)` | +| Modal (dialog, sheet) | `--pn-surface-raised` | `--pn-surface-raised` | light: `--pn-shadow-modal`; dark: ring 1px `--pn-line-strong` + `0 24px 48px oklch(0 0 0 / 0.6)` | +| Toast | `--pn-surface-raised` | `--pn-surface-raised` | same as raised | + +```css +--pn-shadow-float: + 0 0 0 1px oklch(0.22 0.024 250 / 0.06), 0 2px 4px oklch(0.22 0.024 250 / 0.04), + 0 8px 24px oklch(0.22 0.024 250 / 0.08); +--pn-shadow-modal: + 0 0 0 1px oklch(0.22 0.024 250 / 0.06), 0 4px 8px oklch(0.22 0.024 250 / 0.04), + 0 24px 48px oklch(0.22 0.024 250 / 0.12); +``` + +Images and avatars get `box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.10)` (light) / `oklch(1 0 0 / 0.10)` (dark). + +### 3.5 Radii + +| Variable | Value | Use | +| ------------- | ----- | ----------------------------------------------------------------------------------- | +| `--pn-r-1` | 4px | chips inside inputs, kbd, color swatches | +| `--pn-r-2` | 6px | panel items, menu items, table-internal buttons, inner cards (outer 10 − padding 4) | +| `--pn-r-3` | 8px | buttons, inputs, rail items, badges-as-tiles | +| `--pn-r-4` | 10px | cards, table surface, popovers | +| `--pn-r-5` | 12px | dialogs, sheets, toasts | +| `--pn-r-full` | 999px | status badges, chips, avatars | + +Nested rule: inner radius = outer radius − padding (a 10px card with 16px padding holds 0px-radius children; a 10px card with 4px padding holds 6px children). + +### 3.6 Spacing rhythm + +4px base. Allowed steps: 4, 8, 12, 16, 20, 24, 32, 48. Use `gap`, never per-child margins. Vertical rhythm inside content: sections 32px apart; heading to body 12px; card padding 16px (20px for Settings cards); table cell padding 12px vertical × 16px horizontal. + +### 3.7 Type + +Family: DM Sans (opsz axis on, `font-optical-sizing: auto`), DM Mono for identifiers. Weights loaded: 400, 500, 600 (Sans), 400, 500 (Mono). `-webkit-font-smoothing: antialiased`. Body `font-feature-settings: "ss01"` off; `font-variant-numeric: tabular-nums` on every numeric cell, count, badge number, date, time, and stat. + +| Variable | Size/line | Weight | Tracking | Use | +| ---------------- | --------- | ------ | -------- | ------------------------------------------------------------------ | +| `--pn-text-xs` | 12/16 | 400 | 0 | captions, field hints, table header, meta lines | +| `--pn-text-sm` | 13/20 | 400 | 0 | table cells, panel items, list rows, dialog descriptions | +| `--pn-text-base` | 14/20 | 400 | 0 | body, inputs (desktop), buttons | +| `--pn-text-md` | 15/22 | 600 | −0.005em | header bar `h1`, dialog title, section card title | +| `--pn-text-lg` | 18/24 | 600 | −0.01em | record `h1` on cards, empty-state title | +| `--pn-text-xl` | 20/28 | 600 | −0.015em | `RecordHeader` `h1` | +| `--pn-text-2xl` | 28/32 | 600 | −0.02em | Overview stat numbers (tabular) | +| `--pn-text-mono` | 13/20 | 400 | 0 | Telegram IDs, chat ids, label paths, versions, codes, IP addresses | + +Rules: uppercase text is not used anywhere (no eyebrows, no uppercase table headers). Mono is used only for identifiers listed above; never for labels or keys. Italic is not used; "unset" values are rendered as `—` in `--pn-fg-muted` (see §8). Headings use `text-wrap: balance`; body `text-wrap: pretty`. Truncation uses `text-overflow: ellipsis` plus a `title` attribute. Real `…` and `’` characters in copy. + +Touch: `@media (pointer: coarse)` inputs and textareas are 16px. + +### 3.8 Focus ring + +```css +:focus-visible { + outline: 2px solid var(--pn-focus); + outline-offset: 2px; +} +:is(input, textarea, [data-slot="combobox-input"]):focus-visible { + outline: none; + border-color: var(--pn-focus); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--pn-focus) 25%, transparent); +} +tr:focus-visible { + outline-offset: -2px; +} +``` + +### 3.9 Mapping shadcn variables + +So that every `@/components/ui/*` component re-themes without edits: + +```css +:root { + --background: var(--pn-bg); + --foreground: var(--pn-fg); + --card: var(--pn-surface); + --card-foreground: var(--pn-fg); + --popover: var(--pn-surface-raised); + --popover-foreground: var(--pn-fg); + --primary: var(--pn-accent-solid); + --primary-foreground: var(--pn-accent-solid-fg); + --secondary: var(--pn-muted); + --secondary-foreground: var(--pn-fg); + --muted: var(--pn-muted); + --muted-foreground: var(--pn-fg-muted); + --accent: var(--pn-accent-soft); + --accent-foreground: var(--pn-accent); + --destructive: var(--pn-danger-solid); + --border: var(--pn-line); + --input: var(--pn-line-strong); + --ring: var(--pn-focus); + --radius: 8px; + color-scheme: light; +} +.dark { + color-scheme: dark; /* dark values of every --pn-* */ +} +``` + +`styles.css` declares the theme with `@theme inline`, so Tailwind utilities reference `var(--primary)` etc. at runtime and resolve to the `--pn-*` palette. Login and onboarding pages use the same tokens. + +--- + +## 4. Page templates + +Slot names in brackets are props of the shared primitives in §9. + +### 4.1 List/table page + +Used by: Telegram users, Telegram groups, Grants, WhatsApp groups, Microsoft 365 members, Freshman guide, tag page table, category node table, Reports (queue variant 4.8). + +``` +PageBar [left: Search][Filter…][Count] [right: Primary] +────────────────────────────────────────────────────────────────────────────── +DataTable surface (border, r-4) + thead 36px Col A Col B ↓ Col C numeric actions + tr 44px … + tfoot Showing 1–20 of 1,318 Rows per page [20▾] ‹ 1 2 3 … 66 › +``` + +- Toolbar: search (when the inventory page has search, plus the additions in §7), then segmented or popover filters, then `Count`. Primary action right. `canWrite=false` hides the primary action and the actions column. +- Density: one row height, 44px. No dense mode. +- Column priority: each column declares `priority: 1..n`; when the table surface is narrower than the sum of column min-widths, the lowest-priority columns hide (CSS container query on the surface; thresholds per page in §7). The first column and the actions column never hide. +- Pagination: rendered in the table footer only when `total > pageSize`. Default page size per page as in the inventory (25 users/members, 20 everything else). Page-size options 20 / 50 / 100 (the 10 and 25 options are dropped; users and members default to 25 → changed to 20 for one rule). Changing search, filter or sort resets to page 1. `< 640px`: "‹ 3 / 66 ›" only. +- Loading: `TableSkeleton` with the real column count and 8 rows of 44px; thead is real. +- Empty: `EmptyState` rendered inside the surface spanning all columns (no dashed border). Two variants per page: filtered ("No {things} match" + "Clear search" ghost action) and true-empty ("No {things} yet" + create action when one exists). +- Error: `InlineAlert tone=danger` in place of the table body with "Retry" `outline` button; the header bar stays. + +### 4.2 Card-collection page + +Used by: Projects, Associations. + +``` +PageBar [left: Search / Segments][Count] [right: + Add …] +────────────────────────────────────────────────────────────────────────────── +grid gap 16 (2 columns ≥ 1024, 1 below) + ┌ InlineEditCard ───────────────────────────────────────────────────────┐ + │ [logo 40] Title [⋮] [✎] [🗑] │ + │ link (muted, 13) │ + │ IT …description, 4-line clamp… │ + │ EN … │ + └───────────────────────────────────────────────────────────────────────┘ +``` + +- Cards: `--pn-surface`, 1px `--pn-line`, `--pn-r-4`, 16px padding, 12px internal gap. No hover elevation; hover shows nothing except on the action buttons. +- Edit mode replaces text with fields of identical line boxes (see §5.9); card width and position do not change. +- Drafts (new, unsaved) get a `StatusBadge tone=warning` "Draft" after the title and are inserted at the top of the grid. +- Drag (Projects): handle icon `GripVertical` 16px at the card's left edge, visible always in `--pn-fg-subtle`, `--pn-fg-muted` on card hover. The dragged card becomes a ghost (`opacity .9`, `--pn-shadow-float`, `scale 1.01`); siblings translate with 160ms ease-in-out; on drop the ghost settles with 200ms ease-out. Reduced motion: siblings swap instantly. +- Loading: 4 card skeletons with the card geometry. Empty: `EmptyState` centered in the content. + +### 4.3 Tree page + +Used by: Labels (today "Group labels"). + +``` +PageBar [left: Search][Count] [right: Add tag ▾][+ Add category] +────────────────────────────────────────────────────────────────────────────── +SectionHeading Categories (i) description 13px + ▸ Didattica [⋮] + ▾ Extra + ▸ ● Lecco Groups at the Lecco campus [⋮][✎][🗑] + ● Primo Anno — [⋮][✎][🗑] +SectionHeading Attributes + ● Italian Language of the group [✎][🗑] +SectionHeading Publications [Create publication] + ● release-2026-27 Batch for the 2026/27 freshmen [✎][🗑] +``` + +- Row actions read `⋮ ✎ 🗑` (menu, edit, delete) everywhere, as on Projects cards (§4.2): the destructive action stays last. +- Rows 44px, flat list surfaces per section (one bordered surface, rows separated by 1px `--pn-line`). Depth is indentation of 24px per level; no vertical guide lines. +- Chevron 16px `ChevronRight`, rotates 90° in 150ms ease-out when expanded (the one rotating icon in the app); `Expand {name}`/`Collapse {name}` aria-labels. Expanded state persists in session storage; search force-expands. +- Pure grouping nodes show the segment name in `--pn-fg` and `—` in the description cell; real labels show a colored dot (8px) before the name. +- The `Add tag ▾` button is a dropdown with "Add attribute" and "Create publication" (the two dialogs). The Publications `SectionHeading` keeps its inline "Create publication" `outline` button as the inventory specifies. + +### 4.4 Browser page + +Used by: Categories root and nodes, tag page. + +``` +Root: +PageBar [left: Count "2 roots · 18 categories"] [right: + Add category] + grid of NavCards (2 col ≥ 768): [▸ Didattica 12 categories] [▸ Extra 6 categories] + +Node: +PageBar [← ] Didattica › Ingegneria [+ Add category] [+ Add group] + RecordHeader h1 "Informatica" (dot in label color) 38 groups + SectionHeading Sub-categories + NavCards: [▸ Primo Anno 9] [▸ Secondo Anno 11] … + SectionHeading Groups [Search by group name or tag…] + DataTable (CombinedGroupsTable) +``` + +- `NavCard`: 56px tall, name 14/500, count 12 muted right, chevron 16 right, hover `--pn-muted`, focus ring; the whole card is one `` with the name as accessible text. +- On the node page the search field lives in the Groups `SectionHeading` right slot (the header bar left is occupied by the parent path). This is the one template where search is not in the header bar. +- Tag page is the node layout without the Sub-categories block; header bar left = `[←] Attributes` or `[←] Publications`; right = `[Publish 12 groups]` (publications only, when rows exist) + `[+ Add group]`. + +### 4.5 Record detail page + +Used by: Telegram user detail. Full spec in §7.4. + +``` +PageBar [←] 184220371 ·(scroll title) [Add grant][End grant] + RecordHeader avatar 40 · h1 20/600 · meta 13 muted · chips · actions + ─ divider 1px + SectionCard Grants (status chip) [Add grant] + SectionCard Group administration 3 [Add group] + SectionCard Recent messages 15 + SectionCard Audit log 4 +``` + +- Single column, 1040 max. Sections are `SectionCard`s stacked 24px apart. No 3-up summary cards. +- Loading: `RecordSkeleton` (header block + 3 section blocks). Error: `RouteError` content. + +### 4.6 Settings page + +Used by: Account. Full spec in §7.2. + +- Single column, 720 max, `SectionCard`s stacked 24px apart, each with title 15/600, description 13 muted, and a right-aligned action slot in the card header row. Card body 20px padding. Lists inside cards use 56px rows separated by 1px lines. +- Destructive section last, separated by 48px (not 24px), no red border. + +### 4.7 Grouped-collapsible page + +Used by: Microsoft 365 groups. + +``` +PageBar [left: Search by group or email…][Count "42 groups · 318 memberships"] + Collapsible surface ▾ Groups with 2+ members (36) + row 56px Board board@polinetwork.org 9 members (avatars ×7 +2) [+👤][−👤] + … + Collapsible surface ▸ Groups with 0–1 member (6) +``` + +- Collapsible header 48px, chevron + title 14/500 + `Count` muted. Second group collapsed by default; state persists in session storage. Search expands both. +- Row: name 13/500, mail 12 muted mono-free, `Count` "9 members" 12 muted tabular, `AvatarGroup` (24px, max 7, "+N" chip), actions (icon buttons `UserPlus`/`UserMinus`, `canWrite`). +- Content animates with `grid-template-rows 0fr→1fr` 200ms ease-in-out plus opacity 150ms; reduced motion instant. + +### 4.8 Queue page + +Used by: Reports › Open, Reports › Closed. + +- List template with: segmented filter `All | Telegram | WhatsApp | Missing group` (Open) and `All | Resolved | Dismissed` (Closed), search by reference/details, `Count`. +- Row 44px; grouped duplicates show a neutral `CountBadge` "×3" after the reference. Row click navigates (see §7.17). Actions (Open only): `Dismiss` (icon `X`) and `Resolve` (icon `Check`); both are instant with optimistic removal of the row (opacity 0 in 120ms, then row collapses without animation) and a toast; failure restores the row and shows an error toast. + +--- + +## 5. Component rules + +### 5.1 Tables (`DataTable`) + +| Property | Rule | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Surface | `--pn-surface`, 1px `--pn-line`, `--pn-r-4`, overflow hidden; horizontal scroll inside when needed | +| Header row | 36px, 12/500 `--pn-fg-muted`, sentence case, transparent background, 1px bottom `--pn-line`; sticky to the surface top when the surface scrolls horizontally only | +| Sort indicator | sortable headers are buttons (36px hit area, full cell); unsorted shows no icon; hover shows `ChevronsUpDown` 12px `--pn-fg-subtle`; sorted shows `ChevronUp`/`ChevronDown` 12px `--pn-fg`; `aria-sort` set | +| Rows | 44px; 1px `--pn-line` between rows; last row no border | +| Cells | 13/400; first column 13/500 `--pn-fg`; padding 12 × 16, first cell 20 left, last cell 20 right; `vertical-align: middle` | +| Numeric / id | right-aligned when numeric quantities (counts, member id); mono left-aligned when identifiers (Telegram IDs) | +| Hover | `--pn-muted` background 120ms, only when the row is interactive or has actions | +| Clickable row | `cursor: pointer`, `tabindex=0`, `Enter`/`Space` open, `aria-label="Open {name}"`; the first cell text is also a real `` for middle-click | +| Selected | `--pn-accent-soft` background (used only in pick lists) | +| Actions column | right-aligned, 36px icon buttons `ghost`, always visible in `--pn-fg-muted`, hover `--pn-fg` on `--pn-muted`; the destructive action is last, separated by 8px extra, hover `--pn-danger-fg` on `--pn-danger-bg`; max 4 icons, further actions go into a `⋯` menu | +| Truncation | the title column takes the width the other columns leave (`fill`), `truncate`, `title` attr; no fixed `max-w-*` caps; link cells show the host only | +| Secondary line | allowed only in the first column (13 → 12 muted), row remains 44px via 20+16 line boxes | + +### 5.2 Status badges (`StatusBadge`) and chips (`Chip`) + +`StatusBadge`: 22px tall, `--pn-r-full`, 8px padding, 12/500, 6px dot + label, soft background + tone foreground. Used **only** for states. `Chip`: same geometry without dot, `--pn-line` border, transparent background, `--pn-fg`; used for categorical values (licenses, roles, language). Label colors use `LabelChip` (dot in label color, outline) for categories and solid-tinted pills for tags, as today. + +| Value (where) | Element | Tone | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------- | +| Active (grant ongoing) | StatusBadge | success | +| Scheduled (grant) | StatusBadge | brand | +| Expired / Interrupted (grant, if shown) | StatusBadge | neutral | +| Pending (report) | StatusBadge | warning | +| Resolved (report) | StatusBadge | success | +| Dismissed (report) | StatusBadge | neutral | +| Current (session) | StatusBadge | brand | +| Latest (guide edition) | StatusBadge | brand | +| Draft / Unsaved (card) | StatusBadge | warning | +| Hidden (group) | not a badge: the visibility toggle shows `EyeOff` in `--pn-warning-fg`; visible shows `Eye` in `--pn-fg-muted` | — | +| Public / Visible | absence; no badge | — | +| Published | absence (publications disappear when published) | — | +| Broken link / Missing group (report issue) | plain text in the Issue cell with the platform glyph | — | +| Telegram / WhatsApp (platform) | 14px glyph with `aria-label`, no text badge | — | +| @tag (group tag) | mono text | — | +| Roles | Chip | — | +| Licenses | Chip (underscores → spaces) | — | +| IT / EN language markers | 18px `Chip` variant `tiny` 11/500, uppercase is **not** used: text is "IT"/"EN" as proper nouns | — | +| Member counts, group counts, FAQ counts, report duplicates | `Count` text or `CountBadge` (neutral) | neutral | +| Report group count >1 | `CountBadge` "×{n}" | neutral (was destructive) | + +### 5.3 Buttons + +| Rule | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sizes | one text size: `size="sm"` (36px, 13px text); icon buttons `size="icon-sm"` (36px). `default` (40) is used only inside dialog footers. `xs` is not used. | +| Hierarchy per view | exactly one `default` (primary) button visible per page region (header bar or dialog footer); secondaries are `outline`; tertiary are `ghost`; links use `link`. | +| Destructive | in content: `outline` with `--pn-danger-fg` text and 40% danger border ("Sign out of this device", "End grant"); in confirm dialogs: solid `--pn-danger-solid` with white text. Never red solid in page content. | +| Icon buttons | always `aria-label`; tooltip after 400ms with the same text; 36px hit area even when the glyph is 16px | +| Pressed | `translateY(1px)` (existing `active:` rule) kept for text buttons; icon buttons in tables have no pressed transform | +| Loading | spinner 16px replaces the leading icon (or is prepended); label unchanged; button `disabled` and `aria-busy`; width locked by `min-width` so it does not shrink | +| Disabled | `--pn-fg-subtle` text, `--pn-muted` background, no opacity (except primary which uses 50% mix with surface) | +| Labels | verb + object, sentence case ("Add group", "Save labels", "Publish 12 groups") | + +### 5.4 Dialogs (`Dialog`, `ConfirmDialog`) + +| Rule | Value | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sizes | `sm` 400px (confirm), `md` 480px (one-column forms), `lg` 640px (two-column forms: association links; add-group-to-label; grant with stepper). Mobile: `calc(100vw − 32px)`, max-height `calc(100dvh − 32px)`, content scrolls, header/footer fixed | +| Header anatomy | title 15/600 + description 13 `--pn-fg-muted`, 20px padding, no icon, **no eyebrow** (USER ROLES, GROUP ADMINISTRATION, AZURE MEMBERS, WEB · GUIDES, WEB · ASSOCIATION LINKS are removed), close `X` top-right on form dialogs only | +| Body | 20px padding, fields 16px apart | +| Footer | 16/20 padding, top 1px `--pn-line`, right-aligned: `Cancel` (`outline`) then the primary; destructive confirms: `Cancel` then solid danger. Buttons 40px. On `< 480px` footer stacks full width, primary first. | +| Confirm dialogs | `size=sm`, `AlertDialog` (no close X, no outside-click close), title "{Verb} {object}?", description one sentence stating the consequence, no "Are you sure". Focus lands on `Cancel`. | +| Dirty close | any form dialog whose values differ from initial intercepts Esc/X/outside-click/Cancel and opens a nested `ConfirmDialog`: "Discard changes?" / "Your edits to this {noun} will be lost." / `Keep editing` (cancel) + `Discard` (danger). This replaces the grant toast and the guide inline alert. | +| Autofocus | first field on fine pointers; none on coarse pointers | +| Submit | `Enter` in single-line inputs submits; `⌘/Ctrl+Enter` in textareas submits; the primary shows loading; on success the dialog closes and a toast fires; on error an `InlineAlert tone=danger` appears above the footer inside the dialog (dialog stays open) | +| Motion | overlay and panel share 200ms ease-out in (`opacity 0→1`, `scale .97→1`), 150ms ease-in out; see §6 | + +### 5.5 Popover vs dialog rule + +Popover when **all** hold: non-destructive; ≤ 1 input or a picker; ≤ 320px wide; closing without action is harmless. Otherwise dialog. Consequences: label filter → popover; color picker, date picker, time wheel → popover; "Move project to category" → dropdown menu; FAQ delete and FAQ-category delete → `ConfirmDialog` (were popovers); every create/edit → dialog or inline edit. + +### 5.6 Inline alerts (`InlineAlert`) + +Tones neutral/info/success/warning/danger; 12px padding, `--pn-r-3`, tone soft background, 16px tone icon, 13px text, optional action right. Used for: persistent load errors, dialog submit errors, partial-save warnings. Not used for action success (toast). + +### 5.7 Toasts + +| Rule | Value | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Position | bottom-right, 24px offset; `< 640px` bottom-center | +| Visual | `--pn-surface-raised`, `--pn-shadow-float`, `--pn-r-5`, 12px padding, 16px tone icon (`CircleCheck` success, `Info`, `TriangleAlert`, `CircleX`) in tone color, text 13 `--pn-fg`. Backgrounds are **not** tinted. The one `Toaster` is mounted in the root document (`ui/sonner.tsx`, toast class `app-toast`, styled in `styles.css`); pages call `appToast`. | +| Duration | success/info 4000ms; warning 6000ms; error 8000ms; never infinite | +| Max visible | 3, stacked | +| Copy | §8.3 | +| Actions | at most one text action ("Undo" is not offered anywhere in this scope) | + +### 5.8 Comboboxes, selects, label tree selector + +- `Combobox` (Base UI): 36px input, list `--pn-surface-raised` raised layer, items 36px 13px, highlighted `--pn-muted`, selected check 16px right. Empty row 13 muted "No {things} match". +- `Select`: same list; trigger is an `outline` button with trailing chevron. +- `LabelTreeSelector`: 320px tall bordered box (`--pn-line`, `--pn-r-3`); search 36px at top; selected chips row (removable, max 2 lines then scroll) under the search; body sections "Categories", "Attributes", "Publications" as 12/500 muted headings (sentence case, not uppercase); tree rows 32px with 24px indent, selected state `--pn-accent-soft` + `Check`, partial `Minus`; attribute/publication chips as `Chip` with pressed state filled in label color at 15% chroma mix. Searching flattens categories into "Didattica › Ingegneria › Informatica" rows. + +### 5.9 Inline editing (`InlineEditCard` / `InlineEditRow`) + +One pattern for Projects, Associations, FAQs, label cards: + +1. View mode shows an `✎` icon button (36px) among the row/card actions. Only one item per page is in edit mode; starting another cancels the current one if clean, or asks "Discard changes?" if dirty. +2. Edit mode swaps each text element for a field **with the same line box** (input height = text line-height + 16px padding, so the card grows by a fixed 16px per field rather than reflowing); the card keeps its width and grid position; no animation. +3. Fields: inputs 36px for titles/links; textareas auto-grow from 3 rows; a `maxLength` counter (12px muted, "148/160") appears only in the last 20% of the limit. +4. Footer row inside the card: left = validation message or nothing; right = `Cancel` (`ghost`) + `Save` (`default`, disabled until valid and dirty). `Esc` cancels; `Enter` saves in single-line inputs; `⌘/Ctrl+Enter` saves in textareas. The hint "Press Ctrl + Enter to save, Esc to cancel" is removed; the shortcuts are listed in the Save button tooltip. +5. Save shows the spinner in the Save button; on success the card returns to view mode and a toast fires; on error an `InlineAlert` appears in the footer-left slot. + +### 5.10 Forms + +| Rule | Value | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Labels | above the field, 13/500 `--pn-fg`, 6px gap; always real ` - - Open invite link - - ) -} - -export function VisibilityToggleButton({ - title, - visible, - pending, - onToggle, -}: { - title: string - visible: boolean - pending: boolean - onToggle: () => void -}) { - return ( - - ) -} - -export function EditLabelsButton({ title, onClick }: { title: string; onClick: () => void }) { - return ( - - ) -} diff --git a/src/components/loading-skeleton.tsx b/src/components/loading-skeleton.tsx deleted file mode 100644 index 5a187d3..0000000 --- a/src/components/loading-skeleton.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton" - -export function DataPageSkeleton({ - columns = 4, - rows = 7, - withTabs = false, -}: { - columns?: number - rows?: number - withTabs?: boolean -}) { - return ( -
- Loading page data… -
- - - -
-
- - -
- {withTabs && ( -
- - - -
- )} -
-
- {Array.from({ length: columns }, (_, index) => ( - - ))} -
- {Array.from({ length: rows }, (_, row) => ( -
- {Array.from({ length: columns }, (_, column) => ( - - ))} -
- ))} -
-
- ) -} - -export function DetailPageSkeleton() { - return ( -
- Loading user details… - -
- -
- - - -
-
-
- {Array.from({ length: 3 }, (_, index) => ( -
- - - - -
- ))} -
- {Array.from({ length: 3 }, (_, section) => ( -
-
- - -
-
- - -
-
- ))} -
- ) -} diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx deleted file mode 100644 index eb93b99..0000000 --- a/src/components/page-header.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ReactNode } from "react" - -export function PageHeader({ - eyebrow, - title, - description, - action, -}: { - eyebrow: string - title: string - description: string - action?: ReactNode -}) { - return ( -
-
-

{eyebrow}

-

- {title} -

-

{description}

-
- {action &&
{action}
} -
- ) -} diff --git a/src/components/pagination.tsx b/src/components/pagination.tsx deleted file mode 100644 index b106a98..0000000 --- a/src/components/pagination.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, MoreHorizontal } from "lucide-react" -import { Fragment } from "react" - -import { Button } from "@/components/ui/button" - -export function Pagination({ - page, - pageCount, - onPageChange, - pageSize, - total, - pageSizeOptions = [10, 20, 25, 50, 100], - onPageSizeChange, -}: { - page: number - pageCount: number - onPageChange: (page: number) => void - pageSize: number - total?: number - pageSizeOptions?: number[] - onPageSizeChange: (pageSize: number) => void -}) { - const pages = [...new Set([1, page - 1, page, page + 1, pageCount])] - .filter((value) => value >= 1 && value <= pageCount) - .sort((a, b) => a - b) - const rangeStart = total ? (page - 1) * pageSize + 1 : 0 - const rangeEnd = total ? Math.min(page * pageSize, total) : 0 - - return ( -
-
- - {total !== undefined && ( -

- Showing {rangeStart.toLocaleString()}–{rangeEnd.toLocaleString()} of {total.toLocaleString()} -

- )} -
- {pageCount > 1 && ( - - )} -
- ) -} - -function PageButton({ - label, - disabled, - onClick, - children, -}: { - label: string - disabled: boolean - onClick: () => void - children: React.ReactNode -}) { - return ( - - ) -} diff --git a/src/components/primitives/avatar-group.tsx b/src/components/primitives/avatar-group.tsx new file mode 100644 index 0000000..2ba6a08 --- /dev/null +++ b/src/components/primitives/avatar-group.tsx @@ -0,0 +1,55 @@ +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { pluralize } from "@/lib/format" +import { cn } from "@/lib/utils" + +import { Hint } from "./hint" + +export type AvatarPerson = { id: string | number; name: string } + +/** + * First and last initials of `name`; with an empty name, of the email's local part ("lorenzo.corallo@…" → + * "LC"); "?" when both are empty. + */ +export function initialsOf(name: string, email = ""): string { + const words = (name.trim() ? name.trim().split(/\s+/) : (email.split("@")[0] ?? "").split(/[._-]+/)).filter(Boolean) + const letters = words.length > 1 ? [words[0], words[words.length - 1]] : words + return letters.map((word) => word.charAt(0).toLocaleUpperCase()).join("") || "?" +} + +const avatarRing = "size-6 ring-2 ring-(--pn-surface)" + +type AvatarGroupProps = { people: AvatarPerson[]; max?: number; className?: string } + +/** Up to `max` 24px avatars with name tooltips, then a "+N" chip whose tooltip lists the rest. */ +export function AvatarGroup({ people, max = 7, className }: AvatarGroupProps) { + const shown = people.slice(0, max) + const rest = people.slice(max) + + return ( +
person.name).join(", ")} + className={cn("flex items-center -space-x-0.5", className)} + > + {shown.map((person) => ( + + + + {initialsOf(person.name)} + + + + ))} + {rest.length > 0 && ( + person.name).join(", ")}> + + +{rest.length} + + + )} +
+ ) +} diff --git a/src/components/primitives/chip-overflow.tsx b/src/components/primitives/chip-overflow.tsx new file mode 100644 index 0000000..be646e4 --- /dev/null +++ b/src/components/primitives/chip-overflow.tsx @@ -0,0 +1,42 @@ +import type * as React from "react" +import type { ReactNode } from "react" + +import { cn } from "@/lib/utils" + +import { CountBadge } from "./count-badge" +import { Hint } from "./hint" + +type ChipOverflowProps = Omit, "children"> & { + items: T[] + /** Chips shown before the rest collapse into a "+n" badge. Defaults to 2, what fits a 44px row. */ + max?: number + /** One keyed chip. */ + renderItem: (item: T) => ReactNode + /** The item's name in the "+n" tooltip and its accessible label. */ + itemLabel: (item: T) => string +} + +/** + * A row of chips that never wraps, so a table row stays 44px: past `max`, a "+n" `CountBadge` lists the remaining + * items in its tooltip. + */ +export function ChipOverflow({ items, max = 2, renderItem, itemLabel, className, ...props }: ChipOverflowProps) { + const shown = items.slice(0, max) + const rest = items.slice(max).map(itemLabel) + return ( +
+ {shown.map(renderItem)} + {rest.length > 0 && ( + + + + )} +
+ ) +} diff --git a/src/components/primitives/chip.tsx b/src/components/primitives/chip.tsx new file mode 100644 index 0000000..7cb05fd --- /dev/null +++ b/src/components/primitives/chip.tsx @@ -0,0 +1,26 @@ +import type * as React from "react" + +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +const sizes = { + default: "h-[22px] px-2 text-xs", + tiny: "h-[18px] px-1.5 text-[11px] leading-none", +} + +type ChipProps = Omit, "variant"> & { size?: keyof typeof sizes } + +/** Categorical value (role, license, language): outlined, no dot. */ +export function Chip({ size = "default", className, ...props }: ChipProps) { + return ( + + ) +} diff --git a/src/components/primitives/color-swatch-select.tsx b/src/components/primitives/color-swatch-select.tsx new file mode 100644 index 0000000..7171146 --- /dev/null +++ b/src/components/primitives/color-swatch-select.tsx @@ -0,0 +1,114 @@ +import { ChevronDown } from "lucide-react" +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { GROUP_LABEL_COLORS } from "@/features/group-labels/group-labels.constants" +import { cn } from "@/lib/utils" + +import { Hint } from "./hint" +import { LabelDot } from "./label-chip" +import { buttonMotion, floatingMotion, raisedSurface } from "./motion" + +const swatchButton = + "flex size-9 items-center justify-center rounded-(--pn-r-2) transition-[background-color] duration-120 hover:bg-(--pn-muted) aria-pressed:bg-(--pn-accent-soft)" + +function Swatch({ color, custom, className }: { color: string; custom: boolean; className?: string }) { + if (custom) { + return ( + + ) + } + return +} + +function isPaletteColor(value: string) { + return GROUP_LABEL_COLORS.some((color) => color.hex.toLowerCase() === value.toLowerCase()) +} + +type ColorSwatchSelectProps = { + value: string + onChange: (hex: string) => void + label?: string + className?: string +} + +/** 20px swatch trigger opening the 10-color palette; colors outside it show as "Custom". */ +export function ColorSwatchSelect({ value, onChange, label = "Label color", className }: ColorSwatchSelectProps) { + const [open, setOpen] = useState(false) + const custom = !isPaletteColor(value) + + function choose(hex: string) { + onChange(hex) + setOpen(false) + } + + return ( + + + + } + > + + + + + +
+ {GROUP_LABEL_COLORS.map((color) => { + const selected = color.hex.toLowerCase() === value.toLowerCase() + return ( + + + + ) + })} +
+ {custom && ( + + )} +
+
+ ) +} diff --git a/src/components/primitives/combobox.tsx b/src/components/primitives/combobox.tsx new file mode 100644 index 0000000..4f2f386 --- /dev/null +++ b/src/components/primitives/combobox.tsx @@ -0,0 +1,67 @@ +import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox" +import { ChevronDown } from "lucide-react" +import type * as React from "react" + +import { + Combobox, + ComboboxContent as UiComboboxContent, + ComboboxEmpty as UiComboboxEmpty, + ComboboxItem as UiComboboxItem, + ComboboxList, +} from "@/components/ui/combobox" +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group" +import { cn } from "@/lib/utils" + +import { fieldControl } from "./form-field" +import { floatingMotion, raisedSurface } from "./motion" + +type ComboboxInputProps = ComboboxPrimitive.Input.Props & { + /** What the list holds, plural ("roles", "groups"): the trigger reads "Show {listLabel}". */ + listLabel: string +} + +/** §5.8 combobox input: 36px field with a chevron trigger named after the list. */ +export function ComboboxInput({ listLabel, disabled = false, className, ...props }: ComboboxInputProps) { + return ( + + } {...props} /> + + } + disabled={disabled} + aria-label={`Show ${listLabel}`} + className="data-pressed:bg-transparent" + > + + + + + ) +} + +/** Raised list surface, 160/120ms from the input. */ +export function ComboboxContent({ className, ...props }: React.ComponentProps) { + return +} + +/** 36px, 13px item highlighted on `--pn-muted`; selected check on the right. */ +export function ComboboxItem({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +/** "No {things} match" row. */ +export function ComboboxEmpty({ className, ...props }: React.ComponentProps) { + return +} + +export { Combobox, ComboboxList } diff --git a/src/components/primitives/confirm-dialog.tsx b/src/components/primitives/confirm-dialog.tsx new file mode 100644 index 0000000..88658f4 --- /dev/null +++ b/src/components/primitives/confirm-dialog.tsx @@ -0,0 +1,130 @@ +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" +import { type ReactElement, type ReactNode, useRef, useState } from "react" + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogDescription, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { cn } from "@/lib/utils" + +import { InlineAlert } from "./inline-alert" +import { LoadingButton } from "./loading-button" +import { buttonMotion, dialogMotion, scrimClasses } from "./motion" + +export const dialogPanel = + "fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-32px)] w-[calc(100vw-32px)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-(--pn-r-5) bg-(--pn-surface-raised) text-(--pn-fg) shadow-(--pn-shadow-modal) outline-none after:pointer-events-none after:absolute after:inset-0 after:bg-(--pn-scrim) after:opacity-0 after:transition-opacity after:duration-200 data-nested-dialog-open:after:opacity-100" + +export const dialogFooter = + "flex shrink-0 flex-col-reverse gap-2 border-t border-(--pn-line) px-5 py-4 min-[480px]:flex-row min-[480px]:items-center min-[480px]:justify-end" + +export const dialogFooterButton = cn(buttonMotion, "w-full min-[480px]:w-auto") + +export const FALLBACK_ERROR = "Something went wrong. Try again." + +type ConfirmDialogProps = { + /** Omit with `trigger` to let the dialog manage its own open state. */ + open?: boolean + onOpenChange?: (open: boolean) => void + /** "{Verb} {object}?" */ + title: string + /** One sentence stating the consequence. */ + description: ReactNode + confirmLabel: string + cancelLabel?: string + tone?: "danger" | "primary" + /** May throw; the message is shown inside the dialog and it stays open. */ + onConfirm: () => Promise + trigger?: ReactElement + /** Element to focus on close, e.g. `useFocusAfterRemoval().target` when confirming removes the trigger's row. */ + finalFocus?: () => HTMLElement | null +} + +/** 400px confirmation: focus on Cancel, spinner while confirming, closes on success, inline error on failure. */ +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + cancelLabel = "Cancel", + tone = "danger", + onConfirm, + trigger, + finalFocus, +}: ConfirmDialogProps) { + const [ownOpen, setOwnOpen] = useState(false) + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + const cancelRef = useRef(null) + const isOpen = open ?? ownOpen + + function commitOpen(next: boolean) { + setOwnOpen(next) + onOpenChange?.(next) + } + + async function confirm() { + setPending(true) + setError(null) + try { + await onConfirm() + commitOpen(false) + } catch (caught) { + console.error(caught) + setError(caught instanceof Error ? caught.message : FALLBACK_ERROR) + } finally { + setPending(false) + } + } + + return ( + { + if (!pending) commitOpen(next) + }} + onOpenChangeComplete={(next) => { + if (!next) setError(null) + }} + > + {trigger && } + + + finalFocus() ?? true : undefined} + className={cn(dialogPanel, dialogMotion, "max-w-[400px]")} + > +
+ + {title} + + + {description} + + {error && {error}} +
+
+ + {cancelLabel} + + + {confirmLabel} + +
+
+
+
+ ) +} diff --git a/src/components/primitives/container-hide.ts b/src/components/primitives/container-hide.ts new file mode 100644 index 0000000..84b1416 --- /dev/null +++ b/src/components/primitives/container-hide.ts @@ -0,0 +1,48 @@ +/** + * Static container-query classes so Tailwind can see them; a column's computed threshold rounds up to the next + * 40px step. + */ +const HIDE_BELOW = new Map([ + [320, "@max-[320px]:hidden"], + [360, "@max-[360px]:hidden"], + [400, "@max-[400px]:hidden"], + [440, "@max-[440px]:hidden"], + [480, "@max-[480px]:hidden"], + [520, "@max-[520px]:hidden"], + [560, "@max-[560px]:hidden"], + [600, "@max-[600px]:hidden"], + [640, "@max-[640px]:hidden"], + [680, "@max-[680px]:hidden"], + [720, "@max-[720px]:hidden"], + [760, "@max-[760px]:hidden"], + [800, "@max-[800px]:hidden"], + [840, "@max-[840px]:hidden"], + [880, "@max-[880px]:hidden"], + [920, "@max-[920px]:hidden"], + [960, "@max-[960px]:hidden"], + [1000, "@max-[1000px]:hidden"], + [1040, "@max-[1040px]:hidden"], + [1080, "@max-[1080px]:hidden"], + [1120, "@max-[1120px]:hidden"], + [1160, "@max-[1160px]:hidden"], + [1200, "@max-[1200px]:hidden"], + [1240, "@max-[1240px]:hidden"], + [1280, "@max-[1280px]:hidden"], + [1320, "@max-[1320px]:hidden"], + [1360, "@max-[1360px]:hidden"], + [1400, "@max-[1400px]:hidden"], + [1440, "@max-[1440px]:hidden"], + [1480, "@max-[1480px]:hidden"], + [1520, "@max-[1520px]:hidden"], + [1560, "@max-[1560px]:hidden"], + [1600, "@max-[1600px]:hidden"], +]) + +const HIDE_STEP = 40 +const HIDE_MIN = 320 +const HIDE_MAX = 1600 + +export function hideBelowClass(threshold: number): string | undefined { + const step = Math.min(HIDE_MAX, Math.max(HIDE_MIN, Math.ceil(threshold / HIDE_STEP) * HIDE_STEP)) + return HIDE_BELOW.get(step) +} diff --git a/src/components/primitives/count-badge.tsx b/src/components/primitives/count-badge.tsx new file mode 100644 index 0000000..9fc5caa --- /dev/null +++ b/src/components/primitives/count-badge.tsx @@ -0,0 +1,29 @@ +import type * as React from "react" + +import { formatNumber } from "@/lib/format" +import { cn } from "@/lib/utils" + +type CountBadgeProps = Omit, "children" | "aria-label"> & { + value: number + /** "×3" for grouped duplicates, "+3" for overflow ("3 more"). */ + prefix?: "×" | "+" + /** Accessible text, e.g. "3 reports". Defaults to the visible text. */ + label?: string +} + +/** Neutral pill with a tabular number; spreads span props so it can be a tooltip trigger. */ +export function CountBadge({ value, prefix, label, className, ...props }: CountBadgeProps) { + return ( + + {prefix} + {formatNumber(value)} + + ) +} diff --git a/src/components/primitives/data-table.tsx b/src/components/primitives/data-table.tsx new file mode 100644 index 0000000..e7ef93b --- /dev/null +++ b/src/components/primitives/data-table.tsx @@ -0,0 +1,329 @@ +import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react" +import type { KeyboardEvent, MouseEvent, ReactNode } from "react" + +import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" + +import { hideBelowClass } from "./container-hide" +import { InlineAlert } from "./inline-alert" +import { buttonMotion } from "./motion" +import { SkeletonRows } from "./skeletons" +import { type TablePagination, TablePaginationBar } from "./table-pagination" + +export type DataTableColumn = { + id: string + label: string + cell: (row: T) => ReactNode + sortable?: boolean + /** `end` for numeric quantities. */ + align?: "start" | "end" + /** Identifiers (Telegram IDs, chat ids, label paths). */ + mono?: boolean + /** 1 = most important. Columns without a priority, the first column and actions never hide. */ + priority?: number + /** Width in px this column needs; drives when lower-priority columns hide. Defaults to 140. */ + minWidth?: number + /** + * Takes the width the other columns leave, so a `truncate` title uses the free space instead of a fixed + * `max-w-*` cap. Use on one column: the title, or the free-text column when the title is short (Grants › Reason). + */ + fill?: boolean + className?: string +} + +export type TableSort = { column: string; direction: "asc" | "desc" } + +export type DataTableError = { message: ReactNode; onRetry: () => void; retrying?: boolean } + +export type DataTableRowProps = { className?: string; "data-state"?: string } + +type DataTableProps = { + /** Accessible name of the table. */ + label: string + columns: DataTableColumn[] + rows: T[] + getRowId: (row: T) => string | number + sort?: TableSort + onSort?: (sort: TableSort) => void + onRowClick?: (row: T) => void + /** Name used in the row's "Open {name}" label. */ + rowLabel?: (row: T) => string + /** + * Makes the first cell a real link too, so middle-click and ⌘-click open it. `null`/`undefined` marks a row with + * nowhere to go: it is neither a link nor clickable (no focus stop, no pointer, `onRowClick` is not called). + */ + rowHref?: (row: T) => string | null | undefined + /** Icon buttons for the row; a `tone="danger"` button last gets the extra gap. */ + actions?: (row: T) => ReactNode + /** Width in px the actions column needs (36px per icon plus gaps and padding). Defaults to 176. */ + actionsWidth?: number + /** Extra row attributes, e.g. a fade-out class on a row being removed. */ + rowProps?: (row: T) => DataTableRowProps + pagination?: TablePagination + loading?: boolean + loadingLabel?: string + empty: ReactNode + error?: DataTableError + selectedRowId?: string | number + className?: string +} + +const DEFAULT_MIN_WIDTH = 140 +// `max-width: 0` drops the column's min-content width, so the auto table layout gives it the leftover space. +const fillClass = "w-full max-w-0" +const DEFAULT_ACTIONS_WIDTH = 176 + +function columnHideClasses(columns: DataTableColumn[], actionsWidth: number): Map { + const fixed = columns.filter((column, index) => index === 0 || column.priority === undefined) + const ranked = columns + .filter((column, index) => index > 0 && column.priority !== undefined) + .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + + let width = fixed.reduce((sum, column) => sum + (column.minWidth ?? DEFAULT_MIN_WIDTH), 0) + width += actionsWidth + + const classes = new Map() + for (const column of ranked) { + width += column.minWidth ?? DEFAULT_MIN_WIDTH + const hide = hideBelowClass(width) + if (hide) classes.set(column.id, hide) + } + return classes +} + +function edgePadding(index: number, count: number) { + return cn("px-4", index === 0 && "pl-5", index === count - 1 && "pr-5") +} + +function SortIcon({ direction }: { direction: "asc" | "desc" | null }) { + if (direction === "asc") return + if (direction === "desc") return + return ( + + ) +} + +/** Right-aligned icon actions; a `data-tone="danger"` child gets 8px extra separation. */ +export function RowActions({ children, className }: { children: ReactNode; className?: string }) { + return ( +
[data-tone=danger]]:ml-2", className)}>{children}
+ ) +} + +function isPlainClick(event: MouseEvent) { + return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey +} + +/** Surface, sortable 36px header, 44px rows, actions column, footer pagination, loading/empty/error slots. */ +export function DataTable({ + label, + columns, + rows, + getRowId, + sort, + onSort, + onRowClick, + rowLabel, + rowHref, + actions, + actionsWidth = DEFAULT_ACTIONS_WIDTH, + rowProps, + pagination, + loading = false, + loadingLabel = "Loading…", + empty, + error, + selectedRowId, + className, +}: DataTableProps) { + const hasActions = actions !== undefined + const cellCount = columns.length + (hasActions ? 1 : 0) + const hideClasses = columnHideClasses(columns, hasActions ? actionsWidth : 0) + const showRows = !loading && !error && rows.length > 0 + const showPagination = showRows && pagination !== undefined && pagination.total > pagination.pageSize + + function toggleSort(column: string) { + if (!onSort) return + const direction = sort?.column === column && sort.direction === "asc" ? "desc" : "asc" + onSort({ column, direction }) + } + + function openRow(event: KeyboardEvent, row: T) { + if (event.target !== event.currentTarget) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + onRowClick?.(row) + } + + function renderFirstCell(href: string | null | undefined, content: ReactNode) { + if (href == null) return content + return ( + { + if (isPlainClick(event)) event.preventDefault() + }} + className="text-inherit" + > + {content} + + ) + } + + function renderBody() { + if (loading) return + if (error) { + return ( + + + + Retry + + } + > + {error.message} + + + + ) + } + if (!rows.length) { + return ( + + + {empty} + + + ) + } + return rows.map((row) => { + const id = getRowId(row) + const extra = rowProps?.(row) + const href = rowHref?.(row) + const interactive = onRowClick !== undefined && (rowHref === undefined || href != null) + return ( + onRowClick(row) : undefined} + onKeyDown={interactive ? (event) => openRow(event, row) : undefined} + className={cn( + "h-11 border-(--pn-line) transition-[background-color] duration-120", + interactive || hasActions ? "hover:bg-(--pn-muted)" : "hover:bg-transparent", + interactive && "cursor-pointer", + selectedRowId === id && "bg-(--pn-accent-soft) hover:bg-(--pn-accent-soft-hover)", + extra?.className + )} + > + {columns.map((column, index) => ( + + {index === 0 ? renderFirstCell(href, column.cell(row)) : column.cell(row)} + + ))} + {hasActions && ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {actions(row)} + + )} + + ) + }) + } + + return ( +
+ + {loading ? loadingLabel : ""} + + + + + + {columns.map((column, index) => { + const direction = sort?.column === column.id ? sort.direction : null + const sortable = column.sortable && onSort !== undefined + return ( + + {sortable ? ( + + ) : ( + column.label + )} + + ) + })} + {hasActions && ( + + Actions + + )} + + + {renderBody()} +
{label}
+ {showPagination && } +
+ ) +} diff --git a/src/components/primitives/edit-slot.tsx b/src/components/primitives/edit-slot.tsx new file mode 100644 index 0000000..1c72bb5 --- /dev/null +++ b/src/components/primitives/edit-slot.tsx @@ -0,0 +1,44 @@ +import { useState } from "react" + +import { ConfirmDialog } from "./confirm-dialog" + +/** + * Keeps one inline edit open per page. Starting another edit while the current one is dirty asks + * "Discard changes?"; render `discardDialog` once. + */ +export function useEditSlot(noun: string) { + const [editingId, setEditingId] = useState(null) + const [queued, setQueued] = useState<{ id: Id | null; onSwitch?: () => void } | null>(null) + + /** + * Switches the open edit to `id` (`null` closes it). `onSwitch` runs once the switch happens: right away, or + * after the user confirms "Discard" — so a choice that needed the confirmation (another category) is kept. + */ + function start(id: Id | null, currentIsDirty: boolean, onSwitch?: () => void) { + if (editingId !== null && currentIsDirty) { + setQueued({ id, onSwitch }) + return + } + setEditingId(id) + onSwitch?.() + } + + const discardDialog = ( + { + if (!open) setQueued(null) + }} + title="Discard changes?" + description={`Your edits to this ${noun} will be lost.`} + confirmLabel="Discard" + cancelLabel="Keep editing" + onConfirm={async () => { + setEditingId(queued?.id ?? null) + queued?.onSwitch?.() + }} + /> + ) + + return { editingId, start, stop: () => setEditingId(null), discardDialog } +} diff --git a/src/components/primitives/empty-state.tsx b/src/components/primitives/empty-state.tsx new file mode 100644 index 0000000..14e89d2 --- /dev/null +++ b/src/components/primitives/empty-state.tsx @@ -0,0 +1,30 @@ +import type { LucideIcon } from "lucide-react" +import type { ReactNode } from "react" + +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { cn } from "@/lib/utils" + +type EmptyStateProps = { + /** The section's panel icon. */ + icon: LucideIcon + title: string + text: ReactNode + action?: ReactNode + className?: string +} + +/** "No {things} yet" / "No {things} match" + one sentence + optional action. */ +export function EmptyState({ icon: Icon, title, text, action, className }: EmptyStateProps) { + return ( + + + + + + {title} + {text} + + {action && {action}} + + ) +} diff --git a/src/components/primitives/field-counter.tsx b/src/components/primitives/field-counter.tsx new file mode 100644 index 0000000..081c3c8 --- /dev/null +++ b/src/components/primitives/field-counter.tsx @@ -0,0 +1,22 @@ +import { formatNumber } from "@/lib/format" +import { cn } from "@/lib/utils" + +export function isCounterVisible(length: number, max: number): boolean { + return length >= max * 0.8 +} + +type FieldCounterProps = { length: number; max: number; id?: string; className?: string } + +/** "148/160", shown only in the last 20% of `max`. */ +export function FieldCounter({ length, max, id, className }: FieldCounterProps) { + if (!isCounterVisible(length, max)) return null + return ( + = max && "text-(--pn-danger-fg)", className)} + > + {formatNumber(length)}/{formatNumber(max)} + + ) +} diff --git a/src/components/primitives/file-button.tsx b/src/components/primitives/file-button.tsx new file mode 100644 index 0000000..b64cfcf --- /dev/null +++ b/src/components/primitives/file-button.tsx @@ -0,0 +1,93 @@ +import { FileText, Upload, X } from "lucide-react" +import { useId, useRef } from "react" + +import { cn } from "@/lib/utils" + +import { Hint } from "./hint" +import { LoadingButton } from "./loading-button" + +type FileButtonProps = { + file: File | null + onChange: (file: File | null) => void + /** e.g. "image/png,image/jpeg" */ + accept?: string + /** Type and size limit, e.g. "PNG or JPEG, up to 1 MB." */ + hint?: string + label?: string + invalid?: boolean + id?: string + className?: string +} + +/** Outline "Choose file" button + selected filename chip with remove, and the type/size hint. */ +export function FileButton({ + file, + onChange, + accept, + hint, + label = "Choose file", + invalid = false, + id, + className, +}: FileButtonProps) { + const inputRef = useRef(null) + const generatedId = useId() + const inputId = id ?? generatedId + const hintId = `${inputId}-hint` + + function clear() { + if (inputRef.current) inputRef.current.value = "" + onChange(null) + } + + return ( +
+
+ onChange(event.target.files?.[0] ?? null)} + className="sr-only" + tabIndex={-1} + /> + inputRef.current?.click()} + className={cn("shadow-none", invalid && "border-(--pn-danger-solid)")} + > + {label} + + {file && ( + + + + {file.name} + + + + + + )} +
+ {hint && ( +

+ {hint} +

+ )} +
+ ) +} diff --git a/src/components/primitives/form-dialog.tsx b/src/components/primitives/form-dialog.tsx new file mode 100644 index 0000000..39c762e --- /dev/null +++ b/src/components/primitives/form-dialog.tsx @@ -0,0 +1,199 @@ +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { X } from "lucide-react" +import { type KeyboardEvent, type ReactElement, type ReactNode, useRef, useState } from "react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +import { ConfirmDialog, dialogFooter, dialogFooterButton, dialogPanel, FALLBACK_ERROR } from "./confirm-dialog" +import { IconButton } from "./icon-button" +import { InlineAlert } from "./inline-alert" +import { LoadingButton } from "./loading-button" +import { dialogMotion, scrimClasses } from "./motion" + +const sizes = { + md: "max-w-[480px]", + lg: "max-w-[640px]", +} + +const FIELD_SELECTOR = + "input:not([type=hidden]):not([disabled]), textarea:not([disabled]), [data-slot=combobox-input]:not([disabled])" + +type FormDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + size?: keyof typeof sizes + title: string + description?: ReactNode + /** Names the record in "Your edits to this {noun} will be lost." */ + noun: string + /** Values differ from the initial ones; closing then asks to discard. */ + dirty: boolean + /** Omit on steps without a submit (a chooser): the footer then shows Cancel only. */ + submitLabel?: string + /** May throw; the message appears above the footer. Close the dialog yourself on success. */ + onSubmit: () => Promise | void + canSubmit?: boolean + /** + * Enter in a single-line input submits (default). Turn off for steps whose inputs use Enter themselves, + * such as a lookup field; ⌘/Ctrl+Enter in textareas still submits. + */ + submitOnEnter?: boolean + /** Page-provided submit error, shown as a danger alert above the footer. */ + error?: ReactNode + /** Any other alert above the footer (partial-save warning, info). */ + notice?: ReactNode + /** Footer content on the left, e.g. a "Back" ghost button in a stepper. */ + footerStart?: ReactNode + trigger?: ReactElement + children: ReactNode +} + +/** Form dialog: header, scrolling body, Cancel + primary footer, keyboard submit, dirty-close confirmation. */ +export function FormDialog({ + open, + onOpenChange, + size = "md", + title, + description, + noun, + dirty, + submitLabel, + onSubmit, + canSubmit = true, + submitOnEnter = true, + error, + notice, + footerStart, + trigger, + children, +}: FormDialogProps) { + const [pending, setPending] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [confirmingDiscard, setConfirmingDiscard] = useState(false) + const popupRef = useRef(null) + const bodyRef = useRef(null) + const shownError = error ?? submitError + + function requestClose() { + if (pending) return + if (dirty) setConfirmingDiscard(true) + else onOpenChange(false) + } + + async function submit() { + if (pending || !canSubmit || submitLabel === undefined) return + setPending(true) + setSubmitError(null) + try { + await onSubmit() + } catch (caught) { + console.error(caught) + setSubmitError(caught instanceof Error ? caught.message : FALLBACK_ERROR) + } finally { + setPending(false) + } + } + + function onFormKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter") return + if (event.target instanceof HTMLTextAreaElement) { + if (!(event.metaKey || event.ctrlKey)) return + event.preventDefault() + event.currentTarget.requestSubmit() + return + } + // Stops the browser's implicit submission; the input's own handler still sees the key. + if (!submitOnEnter && event.target instanceof HTMLInputElement) event.preventDefault() + } + + function initialFocus() { + if (!window.matchMedia("(pointer: fine)").matches) return popupRef.current + return bodyRef.current?.querySelector(FIELD_SELECTOR) ?? true + } + + return ( + (next ? onOpenChange(true) : requestClose())} + onOpenChangeComplete={(next) => { + if (!next) setSubmitError(null) + }} + > + {trigger && } + + + +
+ + {title} + + {description && ( + + {description} + + )} + +
+
{ + event.preventDefault() + void submit() + }} + onKeyDown={onFormKeyDown} + className="flex min-h-0 flex-1 flex-col" + > +
+ {children} +
+ {(shownError || notice) && ( +
+ {notice} + {shownError && {shownError}} +
+ )} +
+ {footerStart &&
{footerStart}
} + + {submitLabel !== undefined && ( + + {submitLabel} + + )} +
+
+ onOpenChange(false)} + /> +
+
+
+ ) +} diff --git a/src/components/primitives/form-field.tsx b/src/components/primitives/form-field.tsx new file mode 100644 index 0000000..f3ad280 --- /dev/null +++ b/src/components/primitives/form-field.tsx @@ -0,0 +1,59 @@ +import { CircleAlert } from "lucide-react" +import type { ReactNode } from "react" + +import { cn } from "@/lib/utils" + +import { FieldCounter } from "./field-counter" + +type FormFieldProps = { + label: string + htmlFor: string + /** Adds "(optional)" to the label. Required fields are not marked. */ + optional?: boolean + hint?: ReactNode + error?: ReactNode + /** Current length and limit; the counter shows in the last 20%. */ + counter?: { length: number; max: number } + children: ReactNode + className?: string +} + +/** Label above (13/500), control, then a 12px hint or error. Give the control `aria-describedby={fieldHintId(id)}`. */ +export function FormField({ label, htmlFor, optional, hint, error, counter, children, className }: FormFieldProps) { + const describedBy = fieldHintId(htmlFor) + return ( +
+
+ + {counter && } +
+ {children} + {error ? ( +

+ + {error} +

+ ) : ( + hint && ( +

+ {hint} +

+ ) + )} +
+ ) +} + +export function fieldHintId(id: string): string { + return `${id}-description` +} + +/** Checkbox in a §5.10 checkbox row; checked colours come from the remapped `--primary`. */ +export const checkboxControl = "mt-0.5 border-(--pn-line-strong) shadow-none" + +/** Input/textarea classes for §5.10: 36px, 14px (16px on coarse pointers), strong border, surface background. */ +export const fieldControl = + "border-(--pn-line-strong) bg-(--pn-surface) text-sm text-(--pn-fg) shadow-none placeholder:text-(--pn-fg-subtle) aria-invalid:border-(--pn-danger-solid) pointer-coarse:text-base dark:bg-(--pn-surface)" diff --git a/src/components/primitives/group-label-badges.tsx b/src/components/primitives/group-label-badges.tsx new file mode 100644 index 0000000..01d9a7a --- /dev/null +++ b/src/components/primitives/group-label-badges.tsx @@ -0,0 +1,50 @@ +import type { ReactElement } from "react" + +import { isCategoryLabel } from "@/features/group-labels/label-tree" +import type { GroupLabel } from "@/features/group-labels/types" + +import { ChipOverflow } from "./chip-overflow" +import { LabelChip } from "./label-chip" +import { labelDisplayName } from "./label-name" +import { Unset } from "./unset" + +type GroupLabelBadgesProps = { + labels: GroupLabel[] + /** Chips shown before the rest collapse into a "+n" badge. Defaults to 2, what fits a 44px row. */ + max?: number + hrefForLabel?: (label: string) => string + /** Builds the link element for a label, e.g. `(label) => `. Wins over `hrefForLabel`. */ + renderLink?: (label: string) => ReactElement + className?: string +} + +/** + * A group's labels: categories first, then tags; `—` when it has none. Past `max` chips a "+n" badge lists + * the remaining labels in its tooltip. Clicks never reach the row. + */ +export function GroupLabelBadges({ labels, max = 2, hrefForLabel, renderLink, className }: GroupLabelBadgesProps) { + if (!labels.length) return + + const ordered = [ + ...labels.filter((label) => isCategoryLabel(label.label)), + ...labels.filter((label) => !isCategoryLabel(label.label)), + ] + return ( + labelDisplayName(label.label)} + renderItem={(label) => ( + + )} + className={className} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + /> + ) +} diff --git a/src/components/primitives/hint.tsx b/src/components/primitives/hint.tsx new file mode 100644 index 0000000..eebd6a4 --- /dev/null +++ b/src/components/primitives/hint.tsx @@ -0,0 +1,26 @@ +import type { ReactElement, ReactNode } from "react" + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" + +const tooltipMotion = + "bg-(--pn-fg) text-(--pn-bg) duration-120 ease-(--pn-ease-out) data-closed:duration-80 data-closed:ease-(--pn-ease-in) [--tw-enter-scale:1]! [--tw-exit-scale:1]! data-[side=top]:[--tw-enter-translate-y:4px]! data-[side=bottom]:[--tw-enter-translate-y:-4px]! data-[side=left]:[--tw-enter-translate-x:4px]! data-[side=right]:[--tw-enter-translate-x:-4px]! [--tw-exit-translate-x:0]! [--tw-exit-translate-y:0]!" + +type HintProps = { + label: ReactNode + children: ReactElement + side?: "top" | "bottom" | "left" | "right" + className?: string +} + +/** Tooltip around a single trigger element; the trigger keeps its own `aria-label`. */ +export function Hint({ label, children, side = "top", className }: HintProps) { + return ( + + + + {label} + + + ) +} diff --git a/src/components/primitives/icon-button.tsx b/src/components/primitives/icon-button.tsx new file mode 100644 index 0000000..95a19ac --- /dev/null +++ b/src/components/primitives/icon-button.tsx @@ -0,0 +1,62 @@ +import type { LucideIcon } from "lucide-react" +import type * as React from "react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +import { Hint } from "./hint" +import { buttonMotion } from "./motion" +import { Spinner } from "./spinner" + +type IconButtonProps = Omit, "size" | "variant" | "children" | "aria-label"> & { + /** Tooltip text; also the `aria-label` unless `ariaLabel` is given. */ + label: string + /** Names the record for screen readers ("Leave Analisi 1") while the tooltip stays short ("Leave group"). */ + ariaLabel?: string + icon: LucideIcon + tone?: "default" | "danger" + pending?: boolean + iconClassName?: string + tooltipSide?: "top" | "bottom" | "left" | "right" +} + +const toneClasses = { + default: "hover:bg-(--pn-muted) hover:text-(--pn-fg) aria-expanded:bg-(--pn-muted) aria-expanded:text-(--pn-fg)", + danger: "hover:bg-(--pn-danger-bg) hover:text-(--pn-danger-fg)", +} + +/** 36px ghost icon button with matching `aria-label` and tooltip. */ +export function IconButton({ + label, + ariaLabel, + icon: Icon, + tone = "default", + pending = false, + disabled, + className, + iconClassName, + tooltipSide, + ...props +}: IconButtonProps) { + return ( + + + + ) +} diff --git a/src/components/primitives/index.ts b/src/components/primitives/index.ts new file mode 100644 index 0000000..99827d8 --- /dev/null +++ b/src/components/primitives/index.ts @@ -0,0 +1,55 @@ +export { AvatarGroup, initialsOf, type AvatarPerson } from "./avatar-group" +export { Chip } from "./chip" +export { ChipOverflow } from "./chip-overflow" +export { ColorSwatchSelect } from "./color-swatch-select" +export { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "./combobox" +export { ConfirmDialog, dialogFooter, dialogFooterButton, dialogPanel } from "./confirm-dialog" +export { CountBadge } from "./count-badge" +export { DataTable, RowActions, type DataTableColumn, type DataTableError, type TableSort } from "./data-table" +export { useEditSlot } from "./edit-slot" +export { EmptyState } from "./empty-state" +export { FieldCounter, isCounterVisible } from "./field-counter" +export { FileButton } from "./file-button" +export { checkboxControl, fieldControl, fieldHintId, FormField } from "./form-field" +export { FormDialog } from "./form-dialog" +export { GroupLabelBadges } from "./group-label-badges" +export { Hint } from "./hint" +export { IconButton } from "./icon-button" +export { InlineAlert, type AlertTone } from "./inline-alert" +export { InlineEditCard, InlineEditInput, InlineEditRow, InlineEditTextarea } from "./inline-edit" +export { InviteLinkButton } from "./invite-link-button" +export { KeyValueList, type KeyValueItem } from "./key-value-list" +export { LabelChip, LabelDot } from "./label-chip" +export { labelDisplayName, labelKind, publicationName, type LabelKind } from "./label-name" +export { LabelTreeSelector } from "./label-tree-selector" +export { buttonTones, LoadingButton } from "./loading-button" +export { + Menu, + MenuContent, + MenuGroup, + MenuItem, + MenuLabel, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./menu" +export { buttonMotion, dialogMotion, floatingMotion, raisedSurface, scrimClasses } from "./motion" +export { NavCard } from "./nav-card" +export { PlatformGlyph } from "./platform-glyph" +export { RecordHeader } from "./record-header" +export { Reveal } from "./reveal" +export { SectionCard } from "./section-card" +export { SectionEmpty } from "./section-empty" +export { SectionHeading } from "./section-heading" +export { SegmentedControl, type SegmentedItem } from "./segmented-control" +export { CardsSkeleton, RecordSkeleton, SettingsListSkeleton, SkeletonRows, TableSkeleton } from "./skeletons" +export { Spinner } from "./spinner" +export { StatTile } from "./stat-tile" +export { StatusBadge, type StatusTone } from "./status-badge" +export { PAGE_SIZES, TablePaginationBar, type TablePagination } from "./table-pagination" +export { Unset } from "./unset" +export { useModifierKey } from "./use-modifier-key" +export { useOpenGeneration } from "./use-open-generation" +export { VisibilityToggle } from "./visibility-toggle" +export { useFocusAfterRemoval } from "./use-focus-after-removal" diff --git a/src/components/primitives/inline-alert.tsx b/src/components/primitives/inline-alert.tsx new file mode 100644 index 0000000..270500a --- /dev/null +++ b/src/components/primitives/inline-alert.tsx @@ -0,0 +1,37 @@ +import { CircleCheck, CircleX, Info, TriangleAlert } from "lucide-react" +import type { ReactNode } from "react" + +import { cn } from "@/lib/utils" + +export type AlertTone = "neutral" | "info" | "success" | "warning" | "danger" + +const tones = { + neutral: { icon: Info, surface: "bg-(--pn-muted) text-(--pn-fg)", glyph: "text-(--pn-fg-muted)" }, + info: { icon: Info, surface: "bg-(--pn-info-bg) text-(--pn-fg)", glyph: "text-(--pn-info-fg)" }, + success: { icon: CircleCheck, surface: "bg-(--pn-success-bg) text-(--pn-fg)", glyph: "text-(--pn-success-fg)" }, + warning: { icon: TriangleAlert, surface: "bg-(--pn-warning-bg) text-(--pn-fg)", glyph: "text-(--pn-warning-fg)" }, + danger: { icon: CircleX, surface: "bg-(--pn-danger-bg) text-(--pn-fg)", glyph: "text-(--pn-danger-fg)" }, +} + +type InlineAlertProps = { + tone?: AlertTone + children: ReactNode + action?: ReactNode + className?: string +} + +/** Persistent load errors, dialog submit errors, partial-save warnings. Never for success. */ +export function InlineAlert({ tone = "danger", children, action, className }: InlineAlertProps) { + const { icon: Icon, surface, glyph } = tones[tone] + const urgent = tone === "danger" || tone === "warning" + return ( +
+ +
{children}
+ {action &&
{action}
} +
+ ) +} diff --git a/src/components/primitives/inline-edit.tsx b/src/components/primitives/inline-edit.tsx new file mode 100644 index 0000000..cc843af --- /dev/null +++ b/src/components/primitives/inline-edit.tsx @@ -0,0 +1,222 @@ +import { CircleAlert, Pencil } from "lucide-react" +import type * as React from "react" +import { type KeyboardEvent, type ReactNode, useId } from "react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" + +import { RowActions } from "./data-table" +import { FieldCounter, isCounterVisible } from "./field-counter" +import { Hint } from "./hint" +import { IconButton } from "./icon-button" +import { InlineAlert } from "./inline-alert" +import { LoadingButton } from "./loading-button" +import { buttonMotion } from "./motion" +import { useModifierKey } from "./use-modifier-key" + +type InlineEditProps = { + editing: boolean + onEdit: () => void + onCancel: () => void + onSave: () => void + dirty: boolean + valid: boolean + saving: boolean + /** Save failure; shown as an alert in the footer's left slot. */ + error?: ReactNode + /** Validation message for the footer's left slot. */ + message?: ReactNode + view: ReactNode + edit: ReactNode + /** View-mode actions placed before the edit button (e.g. a `⋮` menu). */ + actions?: ReactNode + /** Destructive view-mode action placed last, after the edit button. */ + deleteAction?: ReactNode + /** Tooltip of the edit button ("Edit project"); also its accessible label unless `editAriaLabel` is set. */ + editLabel?: string + /** Accessible label naming the record, e.g. "Edit PoliNetwork". */ + editAriaLabel?: string + /** Drag handle for sortable cards, positioned by the caller against the card (`relative`). */ + handle?: ReactNode + /** Viewers without write access: only `view` renders; no edit, delete, `actions` or `handle`. */ + readOnly?: boolean + className?: string +} + +function useEditKeys({ editing, readOnly, saving, valid, dirty, onCancel, onSave }: InlineEditProps) { + return (event: KeyboardEvent) => { + if (!editing || readOnly || !(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return + if (event.key === "Escape") { + event.preventDefault() + if (!saving) onCancel() + return + } + if (event.key !== "Enter") return + const inTextarea = event.target instanceof HTMLTextAreaElement + const inInput = event.target instanceof HTMLInputElement + if (!(inInput || (inTextarea && (event.metaKey || event.ctrlKey)))) return + event.preventDefault() + if (valid && dirty && !saving) onSave() + } +} + +function EditFooter({ onCancel, onSave, dirty, valid, saving, error, message }: InlineEditProps) { + const modifier = useModifierKey() + return ( +
+
+ {error ? ( + {error} + ) : ( + message && ( +

+ + {message} +

+ ) + )} +
+
+ + + + Save + + +
+
+ ) +} + +function ViewActions({ onEdit, actions, deleteAction, editLabel = "Edit", editAriaLabel }: InlineEditProps) { + return ( + + {actions} + + {deleteAction} + + ) +} + +/** Card that swaps text for same-line-box fields in place; the card keeps its width and position. */ +export function InlineEditCard(props: InlineEditProps) { + const onKeyDown = useEditKeys(props) + const editing = props.editing && !props.readOnly + return ( +
+ {!props.readOnly && !props.editing && props.handle} +
+
{editing ? props.edit : props.view}
+ {!editing && !props.readOnly && } +
+ {editing && } +
+ ) +} + +/** 44px list row variant of `InlineEditCard` (labels tree, FAQ items). */ +export function InlineEditRow(props: InlineEditProps) { + const onKeyDown = useEditKeys(props) + if (!props.editing || props.readOnly) { + return ( +
+ {!props.readOnly && props.handle} +
{props.view}
+ {!props.readOnly && } +
+ ) + } + return ( +
+
{props.edit}
+ +
+ ) +} + +const fieldClasses = + "border-(--pn-line-strong) bg-(--pn-surface) text-sm leading-5 text-(--pn-fg) shadow-none placeholder:text-(--pn-fg-subtle) aria-invalid:border-(--pn-danger-solid) pointer-coarse:text-base dark:bg-(--pn-surface)" + +/** §5.10 error line under an inline field: 12px danger text with a 14px icon. */ +function FieldError({ id, children }: { id: string; children: ReactNode }) { + return ( +

+ + {children} +

+ ) +} + +type InlineEditInputProps = Omit, "value"> & { + label: string + value: string + /** Field error shown under the input; also sets `aria-invalid` and `aria-describedby`. */ + error?: string | null +} + +/** 36px input: a 20px line box + 16px padding. Shows the counter in the last 20% of `maxLength`. */ +export function InlineEditInput({ label, value, maxLength, error, className, ...props }: InlineEditInputProps) { + const errorId = useId() + const counting = maxLength !== undefined && isCounterVisible(value.length, maxLength) + return ( +
+
+ + {maxLength !== undefined && ( + + )} +
+ {error && {error}} +
+ ) +} + +type InlineEditTextareaProps = Omit, "value"> & { + label: string + value: string + /** Field error shown under the textarea; also sets `aria-invalid` and `aria-describedby`. */ + error?: string | null +} + +/** Auto-growing textarea, three rows minimum. */ +export function InlineEditTextarea({ label, value, maxLength, error, className, ...props }: InlineEditTextareaProps) { + const errorId = useId() + return ( +
+
+