Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions src/components/primitives/chip-overflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type ChipOverflowProps<T> = Omit<React.ComponentProps<"div">, "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. */
Expand Down Expand Up @@ -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<T>({ items, max = 2, renderItem, itemLabel, className, ...props }: ChipOverflowProps<T>) {
export function ChipOverflow<T>({
items,
max = 2,
renderItem,
itemLabel,
chipText = itemLabel,
className,
...props
}: ChipOverflowProps<T>) {
const ref = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState<number | null>(null)
useLayoutEffect(() => {
Expand All @@ -58,10 +68,9 @@ export function ChipOverflow<T>({ 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 (
<div
ref={ref}
Expand Down
5 changes: 3 additions & 2 deletions src/components/primitives/copyable-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ export function CopyableText({ value, what, children, className }: CopyableTextP
aria-label={`Copy ${what} ${value}`}
onClick={() => 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}
<span className="block truncate">{children ?? value}</span>
</button>
)
}
7 changes: 5 additions & 2 deletions src/components/primitives/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/primitives/group-label-badges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) => (
<LabelChip
key={label.label}
Expand Down
7 changes: 6 additions & 1 deletion src/components/primitives/label-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type LabelChipProps = {
export function LabelChip({ label, href, render, className }: LabelChipProps) {
const link = render ?? (href === undefined ? undefined : <a href={href} />)
const title = labelDisplayName(label.label)
const text = isCategoryLabel(label.label) ? formatLabelCompact(label.label) : title
const text = labelChipText(label.label)
return (
<Hint label={title} overflowOnly={text === title}>
<Chip
Expand All @@ -54,3 +54,8 @@ export function LabelChip({ label, href, render, className }: LabelChipProps) {
</Hint>
)
}

/** 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)
}
2 changes: 1 addition & 1 deletion src/components/primitives/unset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span role="img" aria-label="Not set" className={cn("text-(--pn-fg-muted)", className)}>
<span role="img" aria-label="Not set" data-unset="" className={cn("text-(--pn-fg-muted)", className)}>
—
</span>
)
Expand Down
14 changes: 7 additions & 7 deletions src/components/shell/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand All @@ -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…",
},
],
}
Expand Down Expand Up @@ -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…",
},
],
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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`
}
23 changes: 16 additions & 7 deletions src/components/shell/page-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<Count value={42} noun="group" />`.
* ", {n} {noun}" segments. Pass the singular noun: `<Count value={42} noun="group" />`.
*/
export function Count({ parts = [], className, ...first }: CountProps) {
const text = [first, ...parts].map(formatCountPart).join(" · ")
const text = [first, ...parts].map(formatCountPart).join(", ")
return (
<span
aria-live="polite"
Expand Down Expand Up @@ -306,7 +306,7 @@ export function SearchField({ value, onChange, placeholder, label, inputRef, cla
autoComplete="off"
spellCheck={false}
data-page-search=""
className="h-10 w-full rounded-[9px] border border-(--pn-line-strong) bg-(--pn-surface) pr-10 pl-10 text-[14px] text-(--pn-fg) transition-[border-color] duration-120 placeholder:text-(--pn-fg-subtle) hover:border-(--pn-hover-line) max-shell:text-base [&::-webkit-search-cancel-button]:appearance-none"
className="h-10 w-full rounded-[9px] border border-(--pn-line-strong) bg-(--pn-surface) pr-10 pl-10 text-[14px] text-(--pn-fg) transition-[border-color] duration-120 text-ellipsis placeholder:text-(--pn-fg-subtle) hover:border-(--pn-hover-line) max-shell:text-base [&::-webkit-search-cancel-button]:appearance-none"
/>
{value !== "" ? (
<button
Expand All @@ -320,7 +320,7 @@ export function SearchField({ value, onChange, placeholder, label, inputRef, cla
) : (
<kbd
aria-hidden
className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded-[5px] border border-(--pn-line-strong) bg-(--pn-surface-raised) px-1.5 font-mono text-[11px] leading-4 text-(--pn-fg-muted) group-focus-within/search:hidden pointer-coarse:hidden"
className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded-[5px] border border-(--pn-line-strong) bg-(--pn-surface-raised) px-1.5 font-mono text-[11px] leading-4 text-(--pn-fg-muted) group-focus-within/search:hidden pointer-coarse:hidden max-shell:hidden"
>
/
</kbd>
Expand All @@ -345,16 +345,25 @@ export type ToolbarProps = {
*/
export function Toolbar({ lead, search, filters, count }: ToolbarProps) {
return (
<div className="flex w-full min-w-0 flex-wrap items-center gap-2.5 max-shell:[&_[role=group]]:max-w-full max-shell:[&_[role=group]]:overflow-x-auto">
<div
className={cn(
"flex w-full min-w-0 flex-wrap items-center gap-2.5 max-shell:gap-2",
// Below 760px a segmented control takes the rest of its row (basis 0, so it never wraps alone), its segments
// share that width, and it scrolls sideways rather than overflow.
"max-shell:[&_[role=group]]:max-w-full max-shell:[&_[role=group]]:min-w-0 max-shell:[&_[role=group]]:flex-1 max-shell:[&_[role=group]]:basis-0 max-shell:[&_[role=group]]:overflow-x-auto max-shell:[&_[role=group]]:[scrollbar-width:none] max-shell:[&_[role=group]>*]:flex-[1_0_auto] max-shell:[&_[role=group]>*]:gap-1 max-shell:[&_[role=group]>*]:px-2"
)}
>
{lead}
{search ? (
<SearchField
{...search}
className={cn("max-w-115 flex-[1_1_320px] max-shell:max-w-none max-shell:basis-full", search.className)}
className={cn("max-w-115 flex-[1_1_300px] max-shell:max-w-none max-shell:basis-full", search.className)}
/>
) : null}
{filters}
{count ? <div className="ml-auto flex shrink-0 pl-1">{count}</div> : null}
{count ? (
<div className="ml-auto flex shrink-0 pl-1 max-shell:ml-0 max-shell:basis-full max-shell:pl-0">{count}</div>
) : null}
</div>
)
}
Loading
Loading