diff --git a/docs/design.md b/docs/design.md index b82ddef3..537fa711 100644 --- a/docs/design.md +++ b/docs/design.md @@ -101,7 +101,8 @@ content in place. Between 760px and 1439px, the content follows the panel as it **Page header.** Scrolls with the page (the page, not the document, scrolls: the column below the top bar is the scroll container). `h1` 24px (22px on mobile) with the context line in 14px muted, actions on the right at 40px; below -760px the actions take the full width under the title. The toolbar row follows 20px below. `/` focuses the page +760px the actions take the full width under the title. The toolbar row follows 20px below. Below 760px the search takes its own row and a segmented filter fills the rest of the row it shares (scrolling +sideways when its options don't fit) and the `Count` takes its own row under them; the `/` hint is hidden. `/` focuses the page search from anywhere; `Esc` clears it. A service with a single section (WhatsApp) shows its icon in a 44px tile next to the `h1`. @@ -113,7 +114,8 @@ the `h1`. **Small screens** (below 760px). Rail and panel leave the layout. A 56px top bar holds the logo (Overview), the service title ("PoliNetwork Admin" on Overview), Search, the theme toggle and the avatar; the service's sections follow -as horizontally scrolling pills that scroll away with the page; a 64px bottom nav lists the five services (current one +as horizontally scrolling pills that scroll away with the page. The current pill stays in view after navigation, +font loading and changes to the bar's width, including when it first appears below 760px. A 64px bottom nav lists the five services (current one accent with a top bar, Reports with the open-reports dot). Section pills and bottom nav are the only navigation; there is no sheet. @@ -142,7 +144,9 @@ does not move when data arrives. **List.** A `DataTable` with search and filters in the toolbar. One row height (52px), no dense mode. Columns declare a priority; from 760px the lowest hide first when the table surface narrows, never the first or the actions column. Below 760px every row is a stacked card: the first column on its own line, the others inline as 12px meta -(`mobile: "full"` gives a column its own line, `"hidden"` drops it), the actions on their own line. Give columns +(`mobile: "full"` gives a column its own line, `"hidden"` drops it), the actions on their own line. An unset value +(`Unset`) is left out of the card rather than shown as a lone dash (an unset `text` line collapses but still ends the line above it, so the meta never climbs up beside the name), and copyable values keep a 36px touch target +without making their line taller. Give columns whose content length varies a `width` (the table switches to fixed layout and the title column takes the rest), so nothing jumps across search, filters and paging. Pagination shows in the table footer once rows exceed the smallest page size (20; options 20/50/100). The page resets to page 1 itself when search, filters or sort change (`DataTable` @@ -174,8 +178,9 @@ further apart, without a red border. **Overview.** A 52px search that opens the command palette; a hero card with the Telegram users total, its growth over the selected range and a TanStack Charts line (3M per day, 1Y per week, All per month; one 2px accent line, a light grid, month labels, a dashed hover guide and a tooltip with the date, total and growth), and Telegram groups, Open -reports and Active grants as links on its right (a row under it on mobile); then "Areas": one card per service whose -whole surface leads where the rail icon does, with each section as a 44px button. +reports and Active grants as links on its right (a row under it on mobile, numbers on one baseline, labelled "Groups" rather than "Telegram groups", and without the "To +review" flag); then "Areas": one card per service whose whole surface leads where the rail icon does, with each section +as a 44px button (two per row on mobile). **States.** While a route loads, its `pendingComponent` renders `PageBar`, `PageContent` and the template's skeleton (`TableSkeleton`, `CardsSkeleton`, `RecordSkeleton`, `SettingsListSkeleton`, labelled "Loading {things}…"), so nothing @@ -291,7 +296,7 @@ warning sign unless the action has its own icon). Footer right-aligned, buttons full width, rounded top, sliding up above the navigation, with a scrolling body and a visible footer. Microsoft 365 member pickers use the same 18px title, then radio, avatar and name with a monospace email below; names and emails wrap independently. Grant dialogs put Change user in the selected-user card, full-width filled date controls on mobile, -duration shortcuts on their own row, and an always-visible "Optional · n/500" hint under the reason. `Enter` submits single-line fields, +duration shortcuts on their own row, and an always-visible "Optional, n/500" hint under the reason. `Enter` submits single-line fields, `⌘/Ctrl+Enter` textareas. On error an `InlineAlert` appears above the footer and the dialog stays open. On success a toast fires and the dialog closes: `ConfirmDialog` closes itself, `FormDialog` doesn't, so call `onOpenChange(false)` after the mutation, and keep the dialog's record in state after closing so its content survives the exit animation. A @@ -301,7 +306,7 @@ only. **Popover or dialog.** Popover only when all hold: not destructive, at most one input or a picker, at most 320px wide, and closing without acting is harmless. Otherwise a dialog. Every delete is a `ConfirmDialog`. Group label filters use compact selected chips and an Add picker, one -include/exclude tree open at a time, and a "Clear label filters · n groups" footer. On mobile the filter uses a +include/exclude tree open at a time, and a "Clear label filters" button. On mobile the filter uses a bottom-sheet dialog with focus trapping and a reachable footer. **Inline editing** (`InlineEditCard`, `InlineEditRow`, `useEditSlot`), used by Projects, Associations, FAQs and Labels: @@ -376,7 +381,7 @@ the theme reveal and the skeleton pulse switch off. view is a subset ("No open reports", "Nothing needs attention"); one sentence on what will appear and how, or how to widen the filter. Action: the page's create action as `outline` (the header primary stays), or a `ghost` "Clear search"/"Clear filters". -- **Counts** (`Count`, `Intl.PluralRules`): "{n} {noun}" or "{n} of {total} {noun}", joined with " · ", never "results". +- **Counts** (`Count`, `Intl.PluralRules`): "{n} {noun}" or "{n} of {total} {noun}", joined with ", ", never "results". The page header's context line carries the list's total, so the toolbar `Count` appears only while search or a filter narrows the list ("12 of 680 groups"). Segmented filters show a bare total per option over the whole list. Next to such a filter the toolbar `Count` appears only while search or another filter narrows the list further. @@ -413,12 +418,12 @@ Rules that the templates above don't imply. - **Projects**: one column at every width, because card order is the order on the website; the whole card drags to reorder (pointer after 5px, so its buttons and links still click; touch after a 250ms press; keyboard with Space/Enter on the focused card). A grip marks the card's left edge from 760px and its border takes the hover line. - A failed reorder reverts. Mobile project links wrap. Segments replace `Count`. + A failed reorder reverts. Project links truncate at every width (the hint shows the full URL). Segments replace `Count`. - **Associations**: 17px semibold titles, IT/EN tags in a left column beside the description, and a divided footer with the public-link count left and Manage links right; edit/delete stay in the header. Links are edited in their own dialog, not inline. -- **FAQs**: the categories are a list beside the questions (icon, Italian title, count; pills below 1100px) ending in a - dashed "Add category"; the selected category's header holds its edit and delete actions and the question search. +- **FAQs**: the categories are a list beside the questions (icon, Italian title, count; pills below 1100px, running to + the screen edges on phones) ending in a dashed "Add category"; on phones questions wrap up to three lines; the selected category's header holds its edit and delete actions and the question search. - **Freshman guide**: the latest edition is a card (version, "Latest", publication date, Download PDF) above the "All editions" table. Mobile editions remain compact single rows: version, date, PDF and delete. - **Categories**: each fixed root is a card that opens it, with its first five sub-categories as chips carrying their diff --git a/src/components/primitives/chip-overflow.tsx b/src/components/primitives/chip-overflow.tsx index b2084aa3..f4de4ee0 100644 --- a/src/components/primitives/chip-overflow.tsx +++ b/src/components/primitives/chip-overflow.tsx @@ -15,6 +15,8 @@ type ChipOverflowProps = Omit, "children"> & { renderItem: (item: T) => ReactNode /** The item's full name in the overflow popover and its accessible label. */ itemLabel: (item: T) => string + /** The text the chip shows, when shorter than `itemLabel`; sizes the fit. */ + chipText?: (item: T) => string } /** Estimated chip geometry (12px DM Sans): padding and dot, average glyph width, the chip's max width. */ @@ -46,7 +48,15 @@ function fittingCount(labels: string[], max: number, width: number) { * truncating a chip to a stub, a "+n" `CountBadge` lists the remaining items in a hoverable popover that also opens * by click or keyboard. */ -export function ChipOverflow({ items, max = 2, renderItem, itemLabel, className, ...props }: ChipOverflowProps) { +export function ChipOverflow({ + items, + max = 2, + renderItem, + itemLabel, + chipText = itemLabel, + className, + ...props +}: ChipOverflowProps) { const ref = useRef(null) const [width, setWidth] = useState(null) useLayoutEffect(() => { @@ -58,10 +68,9 @@ export function ChipOverflow({ items, max = 2, renderItem, itemLabel, classNa observer.observe(element) return () => observer.disconnect() }, []) - const labels = items.map(itemLabel) - const count = width === null ? max : Math.min(max, fittingCount(labels, max, width)) + const count = width === null ? max : Math.min(max, fittingCount(items.map(chipText), max, width)) const shown = items.slice(0, count) - const rest = labels.slice(count) + const rest = items.slice(count).map(itemLabel) return (
void copy()} className={cn( - "relative block max-w-full max-shell:min-h-9 max-shell:min-w-9 cursor-pointer truncate rounded-(--pn-r-1) text-left", + // The ::after pad gives touch a 36px target without making the line taller in stacked cards. + "relative block max-w-full cursor-pointer rounded-(--pn-r-1) text-left max-shell:after:absolute max-shell:after:-inset-y-2 max-shell:after:-inset-x-1 max-shell:after:min-w-9", className )} > - {children ?? value} + {children ?? value} ) } diff --git a/src/components/primitives/data-table.tsx b/src/components/primitives/data-table.tsx index 18307c97..ba345248 100644 --- a/src/components/primitives/data-table.tsx +++ b/src/components/primitives/data-table.tsx @@ -118,8 +118,11 @@ const mobileCellRole = { main: "max-shell:basis-full max-shell:whitespace-normal", inline: "max-shell:min-w-0 max-shell:flex-1 max-shell:text-left max-shell:text-[14px] max-shell:font-medium", end: "max-shell:ml-auto max-shell:shrink-0", - text: "max-shell:basis-full max-shell:min-w-0 max-shell:text-[14px] max-shell:whitespace-normal max-shell:text-(--pn-fg)", - meta: "max-shell:min-w-0 max-shell:text-left max-shell:text-xs max-shell:text-(--pn-fg-muted)", + // An unset text line collapses to nothing but still ends the line above it, so the meta values never climb up beside + // the name and status (the row gap above is cancelled by the negative margin). + text: "max-shell:basis-full max-shell:min-w-0 max-shell:text-[14px] max-shell:whitespace-normal max-shell:text-(--pn-fg) max-shell:[&:has(>[data-unset])]:invisible max-shell:[&:has(>[data-unset])]:-mt-1.5 max-shell:[&:has(>[data-unset])]:max-h-0 max-shell:[&:has(>[data-unset])]:overflow-hidden", + // An unset value (`Unset`, "—") is dropped from the card instead of showing a lone dash. + meta: "max-shell:min-w-0 max-shell:text-left max-shell:text-xs max-shell:text-(--pn-fg-muted) max-shell:[&:has(>[data-unset])]:hidden", full: "max-shell:basis-full max-shell:min-w-0", hidden: "max-shell:hidden", } diff --git a/src/components/primitives/group-label-badges.tsx b/src/components/primitives/group-label-badges.tsx index 01d9a7ad..0313d6c7 100644 --- a/src/components/primitives/group-label-badges.tsx +++ b/src/components/primitives/group-label-badges.tsx @@ -4,7 +4,7 @@ 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 { LabelChip, labelChipText } from "./label-chip" import { labelDisplayName } from "./label-name" import { Unset } from "./unset" @@ -34,6 +34,7 @@ export function GroupLabelBadges({ labels, max = 2, hrefForLabel, renderLink, cl items={ordered} max={max} itemLabel={(label) => labelDisplayName(label.label)} + chipText={(label) => labelChipText(label.label)} renderItem={(label) => ( ) const title = labelDisplayName(label.label) - const text = isCategoryLabel(label.label) ? formatLabelCompact(label.label) : title + const text = labelChipText(label.label) return ( ) } + +/** The text a `LabelChip` shows: categories as their last two segments, other labels by display name. */ +export function labelChipText(label: string) { + return isCategoryLabel(label) ? formatLabelCompact(label) : labelDisplayName(label) +} diff --git a/src/components/primitives/unset.tsx b/src/components/primitives/unset.tsx index f15689fe..8c099f63 100644 --- a/src/components/primitives/unset.tsx +++ b/src/components/primitives/unset.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils" /** The em dash that stands in for every absent value. */ export function Unset({ className }: { className?: string }) { return ( - + — ) diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 3403b81f..c606f126 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -114,7 +114,7 @@ const telegram: Service = { icon: UserRound, path: "/dashboard/telegram/users", permission: "tg:users:read", - searchPlaceholder: "Search by name, username or Telegram ID…", + searchPlaceholder: "Search name, username or ID…", queryable: true, }, { @@ -131,7 +131,7 @@ const telegram: Service = { icon: ShieldCheck, path: "/dashboard/telegram/grants", permission: "tg:grants:read", - searchPlaceholder: "Search users, authorizers or reasons…", + searchPlaceholder: "Search user, reason or grantor…", }, ], } @@ -177,7 +177,7 @@ const m365: Service = { icon: UserRound, path: "/dashboard/azure/members", anyPermission: ["azure:members:read", "azure:members:create"], - searchPlaceholder: "Search by name, email or member ID…", + searchPlaceholder: "Search name, email or ID…", }, ], } @@ -216,7 +216,7 @@ const web: Service = { title: "Labels", icon: Tags, path: "/dashboard/web/group-labels", - searchPlaceholder: "Search categories, attributes and publications…", + searchPlaceholder: "Search labels…", }, { id: "categories", @@ -400,10 +400,10 @@ export function isDeepPage(match: PageMatch): boolean { return match.kind === "telegram-user" || match.kind === "web-category" || match.kind === "web-tag" } -/** `document.title`: "{Section} · {Service} · PoliNetwork Admin", or "{Service} · PoliNetwork Admin". */ +/** `document.title`: "{Section} - {Service} - PoliNetwork Admin", or "{Service} - PoliNetwork Admin". */ export function documentTitle(match: PageMatch): string { - if (match.kind === "not-found") return "Page not found · PoliNetwork Admin" + if (match.kind === "not-found") return "Page not found - PoliNetwork Admin" const service = serviceFor(match) const current = sectionFor(match) - return current ? `${current.title} · ${service.title} · PoliNetwork Admin` : `${service.title} · PoliNetwork Admin` + return current ? `${current.title} - ${service.title} - PoliNetwork Admin` : `${service.title} - PoliNetwork Admin` } diff --git a/src/components/shell/page-bar.tsx b/src/components/shell/page-bar.tsx index ce7c2551..2d6700bd 100644 --- a/src/components/shell/page-bar.tsx +++ b/src/components/shell/page-bar.tsx @@ -247,10 +247,10 @@ function formatCountPart({ value, total, noun, plural }: CountPart) { /** * "{n} {noun}" or "{n} of {total} {noun}", pluralized with Intl.PluralRules; `parts` append - * " · {n} {noun}" segments. Pass the singular noun: ``. + * ", {n} {noun}" segments. Pass the singular noun: ``. */ export function Count({ parts = [], className, ...first }: CountProps) { - const text = [first, ...parts].map(formatCountPart).join(" · ") + const text = [first, ...parts].map(formatCountPart).join(", ") return ( {value !== "" ? (