@hackcove/ui 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +183 -4
  2. package/dist/AppShell.d.ts +37 -0
  3. package/dist/AppShell.js +61 -0
  4. package/dist/Avatar.d.ts +9 -4
  5. package/dist/Avatar.js +9 -3
  6. package/dist/BulletList.d.ts +3 -0
  7. package/dist/BulletList.js +6 -0
  8. package/dist/Code.d.ts +3 -0
  9. package/dist/Code.js +6 -0
  10. package/dist/ColourSchemeToggle.d.ts +7 -0
  11. package/dist/ColourSchemeToggle.js +25 -0
  12. package/dist/Container.d.ts +12 -0
  13. package/dist/Container.js +9 -0
  14. package/dist/DateCalendar.d.ts +4 -0
  15. package/dist/DateCalendar.js +40 -0
  16. package/dist/DateField.d.ts +49 -0
  17. package/dist/DateField.js +77 -0
  18. package/dist/DatePicker.d.ts +8 -0
  19. package/dist/DatePicker.js +27 -0
  20. package/dist/Divider.d.ts +3 -0
  21. package/dist/Divider.js +6 -0
  22. package/dist/InlineLinks.d.ts +11 -0
  23. package/dist/InlineLinks.js +11 -0
  24. package/dist/Markdown.js +1 -1
  25. package/dist/PageHeader.d.ts +19 -0
  26. package/dist/PageHeader.js +11 -0
  27. package/dist/PageLayouts.d.ts +73 -0
  28. package/dist/PageLayouts.js +63 -0
  29. package/dist/Stack.d.ts +30 -0
  30. package/dist/Stack.js +28 -0
  31. package/dist/TextLink.d.ts +12 -0
  32. package/dist/TextLink.js +8 -0
  33. package/dist/Video.d.ts +10 -0
  34. package/dist/Video.js +10 -0
  35. package/dist/colourScheme.d.ts +12 -0
  36. package/dist/colourScheme.js +40 -0
  37. package/dist/index.d.ts +18 -1
  38. package/dist/index.js +16 -0
  39. package/dist/isoDate.d.ts +5 -0
  40. package/dist/isoDate.js +16 -0
  41. package/dist/shownDate.d.ts +45 -0
  42. package/dist/shownDate.js +88 -0
  43. package/dist/typography.d.ts +32 -24
  44. package/dist/typography.js +39 -9
  45. package/dist/ui.d.ts +1 -0
  46. package/dist/ui.js +3 -1
  47. package/package.json +24 -1
@@ -0,0 +1,77 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CalendarDate as CalendarDateClass, DateFormatter, } from "@internationalized/date";
3
+ import { useContext, useEffect } from "react";
4
+ import { DateField as AriaDateField, DateFieldStateContext, DateInput, DateSegment, FieldError, I18nProvider, Label, Text, } from "react-aria-components";
5
+ import { cx, focusRing } from "./cx.js";
6
+ import { toISODate } from "./isoDate.js";
7
+ import { ShownInput, useShownDate } from "./shownDate.js";
8
+ /** Fixed, whatever the browser's locale: DD/MM/YYYY and Monday-first weeks. */
9
+ export const LOCALE = "en-GB";
10
+ const MONTHS = new DateFormatter(LOCALE, { month: "long", timeZone: "UTC" });
11
+ export const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => ({
12
+ month: i + 1,
13
+ name: MONTHS.format(new Date(Date.UTC(2000, i, 1))),
14
+ }));
15
+ function readSegments(segments) {
16
+ const editable = segments.filter((segment) => segment.isEditable);
17
+ const blank = editable.filter((segment) => segment.isPlaceholder).length;
18
+ const none = { typed: "", impossible: null, partial: blank > 0 && blank < editable.length };
19
+ if (editable.length === 0 || blank > 0)
20
+ return none;
21
+ const part = (type) => editable.find((s) => s.type === type)?.value ?? 0;
22
+ const [day, month, year] = [part("day"), part("month"), part("year")];
23
+ if (day < 1 || month < 1 || year < 1)
24
+ return none;
25
+ const first = new CalendarDateClass(year, month, 1);
26
+ const days = first.calendar.getDaysInMonth(first);
27
+ if (day <= days)
28
+ return { ...none, typed: toISODate(first.set({ day })) };
29
+ return {
30
+ ...none,
31
+ impossible: `${MONTH_OPTIONS[month - 1]?.name ?? ""} ${year} has ${days} days.`,
32
+ };
33
+ }
34
+ /** One segment. The day segment also reports what the segments show to the field. */
35
+ export function FieldSegment({ segment, onRead, }) {
36
+ const state = useContext(DateFieldStateContext);
37
+ const { typed, impossible, partial } = readSegments(state?.segments ?? []);
38
+ useEffect(() => onRead?.({ typed, impossible, partial }), [typed, impossible, partial, onRead]);
39
+ return (_jsx(DateSegment, { segment: segment, className: ({ isFocused, isPlaceholder, isInvalid, type }) => {
40
+ if (type === "literal")
41
+ return "text-slate-400";
42
+ let tone = "text-slate-900";
43
+ if (isFocused)
44
+ tone = "bg-violet-50 text-violet-600";
45
+ else if (isPlaceholder)
46
+ tone = "text-slate-400";
47
+ else if (isInvalid)
48
+ tone = "text-red-700";
49
+ return cx("rounded px-0.5 tabular-nums outline-none", tone);
50
+ }, children: ({ isPlaceholder, placeholder, text }) => (isPlaceholder ? placeholder.toUpperCase() : text) }));
51
+ }
52
+ export function frameClass({ isInvalid, isFocusWithin, isDisabled, }) {
53
+ let tone = "border-slate-300 bg-white text-slate-900 hover:border-violet-400";
54
+ if (isInvalid)
55
+ tone = "border-red-200 bg-red-50 text-red-700";
56
+ else if (isFocusWithin)
57
+ tone = "border-violet-600 bg-white text-slate-900";
58
+ return cx("flex w-full items-center rounded-lg border py-2 pl-2.5 text-sm transition", tone, isDisabled && "opacity-50");
59
+ }
60
+ /** The label, the description or the error line: the shared outside of both components. */
61
+ export function Notes({ description, errorMessage, impossible, isInvalid, }) {
62
+ return (_jsxs(_Fragment, { children: [!isInvalid && description ? (_jsx(Text, { slot: "description", className: "text-xs text-slate-500", children: description })) : null, _jsx(FieldError, { className: "text-xs text-red-700", children: ({ validationErrors }) => errorMessage ?? impossible ?? validationErrors.join(" ") })] }));
63
+ }
64
+ export const labelClass = "text-sm font-medium text-slate-700";
65
+ export const iconButtonClass = `inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 disabled:opacity-50 ${focusRing}`;
66
+ /** A segmented DD / MM / YYYY date input. Values are `CalendarDate`s; `name` posts it as `YYYY-MM-DD`. */
67
+ export function DateField({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }) {
68
+ const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
69
+ value,
70
+ defaultValue,
71
+ onChange,
72
+ minValue: rest.minValue,
73
+ maxValue: rest.maxValue,
74
+ isRequired: rest.isRequired,
75
+ });
76
+ return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDateField, { ...rest, value: inner, onChange: setInner, validate: validate, ...(impossible ? { isInvalid: true } : {}), isReadOnly: leaving, onBlur: onLeft, granularity: "day", shouldForceLeadingZeros: true, className: cx("flex flex-col gap-1.5", className), children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(DateInput, { className: (state) => cx(frameClass(state), "pr-2.5"), children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, impossible: impossible, isInvalid: isInvalid })] })) }) }));
77
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from "react";
2
+ import { type DateFieldProps } from "./DateField.js";
3
+ export type DatePickerProps = DateFieldProps & {
4
+ /** Open the calendar on mount. */
5
+ defaultOpen?: boolean;
6
+ };
7
+ /** A `DateField` with a calendar button that opens a month view in a popover. */
8
+ export declare function DatePicker({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }: DatePickerProps): ReactElement;
@@ -0,0 +1,27 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext } from "react";
3
+ import { Button, DatePicker as AriaDatePicker, DateFieldContext, DateInput, Group, I18nProvider, Label, } from "react-aria-components";
4
+ import { cx } from "./cx.js";
5
+ import { CalendarPopover, calendarIcon } from "./DateCalendar.js";
6
+ import { FieldSegment, LOCALE, Notes, frameClass, iconButtonClass, labelClass, } from "./DateField.js";
7
+ import { ShownInput, useShownDate } from "./shownDate.js";
8
+ /**
9
+ * The picker's input, read-only while `leaving`. Only the field: a read-only picker would also
10
+ * disable the calendar button that focus may be moving to.
11
+ */
12
+ function PickerInput({ leaving, onLeft, children, }) {
13
+ const field = useContext(DateFieldContext);
14
+ return (_jsx(DateFieldContext.Provider, { value: { ...field, isReadOnly: leaving, onBlur: onLeft }, children: children }));
15
+ }
16
+ /** A `DateField` with a calendar button that opens a month view in a popover. */
17
+ export function DatePicker({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }) {
18
+ const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
19
+ value,
20
+ defaultValue,
21
+ onChange,
22
+ minValue: rest.minValue,
23
+ maxValue: rest.maxValue,
24
+ isRequired: rest.isRequired,
25
+ });
26
+ return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDatePicker, { ...rest, value: inner, onChange: setInner, validate: validate, ...(impossible ? { isInvalid: true } : {}), granularity: "day", shouldForceLeadingZeros: true, className: cx("flex flex-col gap-1.5", className), children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsxs(Group, { className: (state) => cx(frameClass(state), "gap-1 pr-1"), children: [_jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(PickerInput, { leaving: leaving, onLeft: onLeft, children: _jsx(DateInput, { className: "flex flex-1", children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }) }), _jsx(Button, { className: iconButtonClass, children: calendarIcon })] }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, impossible: impossible, isInvalid: isInvalid }), _jsx(CalendarPopover, {})] })) }) }));
27
+ }
@@ -0,0 +1,3 @@
1
+ import type { HTMLAttributes, ReactElement } from "react";
2
+ /** A hairline rule in the theme's line colour: between a heading and its content, or above a footer. Spacing is the caller's, on `className`. */
3
+ export declare function Divider({ className, ...rest }: Omit<HTMLAttributes<HTMLHRElement>, "children">): ReactElement;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** A hairline rule in the theme's line colour: between a heading and its content, or above a footer. Spacing is the caller's, on `className`. */
4
+ export function Divider({ className, ...rest }) {
5
+ return _jsx("hr", { className: cx("border-t border-slate-200/70", className), ...rest });
6
+ }
@@ -0,0 +1,11 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export type InlineLinksSeparator = "·" | "•" | "none";
3
+ /**
4
+ * A wrapping row of links, small text, joined by a separator. The separator is `aria-hidden` and
5
+ * leads each link but the first, held to its link so a wrap never leaves one at a line's end.
6
+ */
7
+ export declare function InlineLinks({ separator, className, children, }: {
8
+ separator?: InlineLinksSeparator;
9
+ className?: string;
10
+ children: ReactNode;
11
+ }): ReactElement;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { Caption } from "./typography.js";
5
+ /**
6
+ * A wrapping row of links, small text, joined by a separator. The separator is `aria-hidden` and
7
+ * leads each link but the first, held to its link so a wrap never leaves one at a line's end.
8
+ */
9
+ export function InlineLinks({ separator = "·", className, children, }) {
10
+ return (_jsx("div", { className: cx("flex flex-wrap items-center gap-x-3 gap-y-1 text-sm", className), children: Children.toArray(children).map((child, index) => (_jsxs("span", { className: "inline-flex items-center gap-x-3 whitespace-nowrap", children: [index > 0 && separator !== "none" && _jsx(Caption, { "aria-hidden": "true", children: separator }), child] }, index))) }));
11
+ }
package/dist/Markdown.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- // A minimal, SAFE markdown renderer for the standup (headings / bullets / bold). No
2
+ // A minimal, SAFE markdown renderer (headings / bullets / bold). No
3
3
  // dangerouslySetInnerHTML — text is rendered as React nodes, so it can't inject HTML.
4
4
  function inline(text) {
5
5
  return text.split(/(\*\*[^*]+\*\*)/g).map((p, i) => p.startsWith("**") && p.endsWith("**") ? (_jsx("strong", { className: "font-semibold text-slate-900", children: p.slice(2, -2) }, i)) : (_jsx("span", { children: p }, i)));
@@ -0,0 +1,19 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ export type PageHeaderProps = {
3
+ /** A `Breadcrumbs` above the title. */
4
+ breadcrumbs?: ReactNode;
5
+ title: ReactNode;
6
+ subtitle?: ReactNode;
7
+ /** Right of the title on `sm` and up, below it on a phone. */
8
+ actions?: ReactNode;
9
+ /** A `NavTabs` (or similar) under the title. */
10
+ tabs?: ReactNode;
11
+ };
12
+ /** The header block without its bottom margin; PageHeader adds the margin, PageTitle does not. */
13
+ export declare function PageHeaderContent({ breadcrumbs, title, subtitle, actions, tabs, className, }: PageHeaderProps & {
14
+ className?: string;
15
+ }): ReactElement;
16
+ /** The top of a page: breadcrumbs, title, subtitle, actions and tabs. Its fixed bottom margin puts every page's first block at the same y. */
17
+ export declare function PageHeader({ className, ...props }: PageHeaderProps & {
18
+ className?: string;
19
+ }): ReactElement;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { PageHeading, Subtitle } from "./typography.js";
4
+ /** The header block without its bottom margin; PageHeader adds the margin, PageTitle does not. */
5
+ export function PageHeaderContent({ breadcrumbs, title, subtitle, actions, tabs, className, }) {
6
+ return (_jsxs("div", { className: className, children: [breadcrumbs && _jsx("div", { className: "mb-2", children: breadcrumbs }), _jsxs("div", { className: "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsx(PageHeading, { children: title }), subtitle && _jsx(Subtitle, { className: "mt-1", children: subtitle })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), tabs && _jsx("div", { className: "mt-4", children: tabs })] }));
7
+ }
8
+ /** The top of a page: breadcrumbs, title, subtitle, actions and tabs. Its fixed bottom margin puts every page's first block at the same y. */
9
+ export function PageHeader({ className, ...props }) {
10
+ return _jsx(PageHeaderContent, { ...props, className: cx("mb-8", className) });
11
+ }
@@ -0,0 +1,73 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ import { type PageHeaderProps } from "./PageHeader.js";
3
+ import { type StackGap } from "./Stack.js";
4
+ /** A list page: header, an optional `filters` slot, then the list, or `empty` when `isEmpty`. */
5
+ export declare function ListLayout({ filters, empty, isEmpty, children, ...header }: PageHeaderProps & {
6
+ filters?: ReactNode;
7
+ /** Shown in place of `children` when `isEmpty`. */
8
+ empty?: ReactNode;
9
+ isEmpty?: boolean;
10
+ children?: ReactNode;
11
+ }): ReactElement;
12
+ /** A detail page: header, then `children` (two thirds from `lg`) beside `aside` (one third), which stacks below on a smaller screen. */
13
+ export declare function DetailLayout({ aside, children, ...header }: PageHeaderProps & {
14
+ aside: ReactNode;
15
+ children: ReactNode;
16
+ }): ReactElement;
17
+ /** A form page: header (no header `actions`), then a `max-w-3xl` column that starts at the Container's left edge (it is not re-centred), with an `actions` row at the end. Other props go on the `<form>`. */
18
+ export declare function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, className, ...form }: Omit<PageHeaderProps, "actions"> & {
19
+ actions?: ReactNode;
20
+ children: ReactNode;
21
+ } & Omit<ComponentPropsWithoutRef<"form">, "title">): ReactElement;
22
+ /**
23
+ * A text page: header, then `children` stacked `gap` apart (`md` by default), all in one `narrow`
24
+ * (`max-w-3xl`) reading column that starts at the Container's left edge (it is not re-centred, so
25
+ * the title does not jump). Unlike `FormLayout` the header sits inside the column, so its `actions`
26
+ * stay beside the text instead of at the Container's far edge. `className` goes on the column.
27
+ */
28
+ export declare function ProseLayout({ gap, className, children, ...header }: PageHeaderProps & {
29
+ gap?: StackGap | undefined;
30
+ className?: string;
31
+ children?: ReactNode;
32
+ }): ReactElement;
33
+ declare const COLUMNS: {
34
+ readonly 2: "lg:grid-cols-2";
35
+ readonly 3: "lg:grid-cols-3";
36
+ readonly 4: "lg:grid-cols-4";
37
+ };
38
+ /** A responsive grid of tiles: one column on a phone, two from `sm`, `columns` from `lg`. */
39
+ export declare function CardGrid({ columns, className, children, }: {
40
+ columns?: keyof typeof COLUMNS;
41
+ className?: string;
42
+ children: ReactNode;
43
+ }): ReactElement;
44
+ /**
45
+ * A full-height `rail` beside the `children` canvas. Render it in `<AppShell bleed>`: it fills what
46
+ * the shell's flex column leaves under the header, so no header height is written down. The rail is
47
+ * `md:w-60` and stacks above the canvas below `md`; the canvas keeps the Container's padding.
48
+ *
49
+ * It deliberately breaks the shared left edge from `md` up: a full-height rail needs the viewport's
50
+ * left edge, so the rail starts at x=0 and the canvas at the rail's width plus the Container's
51
+ * padding (264 at 1280), not at the header logo's x. Below `md` every layout agrees.
52
+ */
53
+ export declare function SidebarLayout({ rail, children, }: {
54
+ rail: ReactNode;
55
+ children: ReactNode;
56
+ }): ReactElement;
57
+ /**
58
+ * Edge-to-edge `children` with an optional right `panel` (`lg:w-72`) that stacks below on a smaller
59
+ * screen. Render it in `<AppShell bleed>`. It deliberately breaks the shared left edge: a viewer
60
+ * that needs the whole width has no Container of its own, so `children` start at the viewport's
61
+ * left edge and add their own padding.
62
+ */
63
+ export declare function FullBleedLayout({ panel, children, }: {
64
+ panel?: ReactNode;
65
+ children: ReactNode;
66
+ }): ReactElement;
67
+ /** A narrow card (`max-w-md`) centred in the Container at a fixed offset from the top, for sign-in, landing, not-found and error pages. The one layout that does not share the left edge. */
68
+ export declare function CenteredLayout({ title, subtitle, children, }: {
69
+ title: ReactNode;
70
+ subtitle?: ReactNode;
71
+ children?: ReactNode;
72
+ }): ReactElement;
73
+ export {};
@@ -0,0 +1,63 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card } from "./ui.js";
3
+ import { Container } from "./Container.js";
4
+ import { cx } from "./cx.js";
5
+ import { PageHeader, PageHeaderContent } from "./PageHeader.js";
6
+ import { STACK_WIDTH, Stack } from "./Stack.js";
7
+ // Compositions of the layout foundation. None sets a page width or outer padding of its own; the
8
+ // ones that narrow their content do it inside the Container they are rendered in.
9
+ /** A list page: header, an optional `filters` slot, then the list, or `empty` when `isEmpty`. */
10
+ export function ListLayout({ filters, empty, isEmpty = false, children, ...header }) {
11
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...header }), _jsxs(Stack, { children: [filters, isEmpty ? empty : children] })] }));
12
+ }
13
+ /** A detail page: header, then `children` (two thirds from `lg`) beside `aside` (one third), which stacks below on a smaller screen. */
14
+ export function DetailLayout({ aside, children, ...header }) {
15
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...header }), _jsxs("div", { className: "grid gap-6 lg:grid-cols-3", children: [_jsx(Stack, { className: "min-w-0 lg:col-span-2", children: children }), _jsx("aside", { className: "min-w-0", children: _jsx(Stack, { children: aside }) })] })] }));
16
+ }
17
+ /** A form page: header (no header `actions`), then a `max-w-3xl` column that starts at the Container's left edge (it is not re-centred), with an `actions` row at the end. Other props go on the `<form>`. */
18
+ export function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, className, ...form }) {
19
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { breadcrumbs: breadcrumbs, title: title, subtitle: subtitle, tabs: tabs }), _jsx("form", { className: cx("max-w-3xl", className), ...form, children: _jsxs(Stack, { children: [children, actions && _jsx("div", { className: "flex flex-wrap items-center gap-2", children: actions })] }) })] }));
20
+ }
21
+ /**
22
+ * A text page: header, then `children` stacked `gap` apart (`md` by default), all in one `narrow`
23
+ * (`max-w-3xl`) reading column that starts at the Container's left edge (it is not re-centred, so
24
+ * the title does not jump). Unlike `FormLayout` the header sits inside the column, so its `actions`
25
+ * stay beside the text instead of at the Container's far edge. `className` goes on the column.
26
+ */
27
+ export function ProseLayout({ gap, className, children, ...header }) {
28
+ return (_jsxs("div", { className: cx(STACK_WIDTH.narrow, className), children: [_jsx(PageHeader, { ...header }), _jsx(Stack, { gap: gap, children: children })] }));
29
+ }
30
+ const COLUMNS = {
31
+ 2: "lg:grid-cols-2",
32
+ 3: "lg:grid-cols-3",
33
+ 4: "lg:grid-cols-4",
34
+ };
35
+ /** A responsive grid of tiles: one column on a phone, two from `sm`, `columns` from `lg`. */
36
+ export function CardGrid({ columns = 3, className, children, }) {
37
+ return (_jsx("div", { className: cx("grid grid-cols-1 gap-4 sm:grid-cols-2", COLUMNS[columns], className), children: children }));
38
+ }
39
+ /**
40
+ * A full-height `rail` beside the `children` canvas. Render it in `<AppShell bleed>`: it fills what
41
+ * the shell's flex column leaves under the header, so no header height is written down. The rail is
42
+ * `md:w-60` and stacks above the canvas below `md`; the canvas keeps the Container's padding.
43
+ *
44
+ * It deliberately breaks the shared left edge from `md` up: a full-height rail needs the viewport's
45
+ * left edge, so the rail starts at x=0 and the canvas at the rail's width plus the Container's
46
+ * padding (264 at 1280), not at the header logo's x. Below `md` every layout agrees.
47
+ */
48
+ export function SidebarLayout({ rail, children, }) {
49
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [_jsx("aside", { className: "w-full shrink-0 border-b border-slate-200 bg-slate-50 p-4 md:w-60 md:border-r md:border-b-0", children: rail }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(Container, { className: "py-8", children: children }) })] }));
50
+ }
51
+ /**
52
+ * Edge-to-edge `children` with an optional right `panel` (`lg:w-72`) that stacks below on a smaller
53
+ * screen. Render it in `<AppShell bleed>`. It deliberately breaks the shared left edge: a viewer
54
+ * that needs the whole width has no Container of its own, so `children` start at the viewport's
55
+ * left edge and add their own padding.
56
+ */
57
+ export function FullBleedLayout({ panel, children, }) {
58
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [_jsx("div", { className: "min-w-0 flex-1", children: children }), panel && (_jsx("aside", { className: "w-full shrink-0 border-t border-slate-200 bg-slate-50 p-4 lg:w-72 lg:border-t-0 lg:border-l", children: panel }))] }));
59
+ }
60
+ /** A narrow card (`max-w-md`) centred in the Container at a fixed offset from the top, for sign-in, landing, not-found and error pages. The one layout that does not share the left edge. */
61
+ export function CenteredLayout({ title, subtitle, children, }) {
62
+ return (_jsx("div", { className: "mx-auto w-full max-w-md pt-16", children: _jsx(Card, { as: "div", children: _jsxs(Stack, { children: [_jsx(PageHeaderContent, { title: title, subtitle: subtitle }), children] }) }) }));
63
+ }
@@ -0,0 +1,30 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export type StackGap = "xs" | "sm" | "md" | "lg";
3
+ export type StackWidth = "narrow" | "default" | "wide" | "full";
4
+ /** `default` adds nothing: the Stack is as wide as its parent. */
5
+ export declare const STACK_WIDTH: Record<StackWidth, string | undefined>;
6
+ /**
7
+ * A vertical column of blocks a fixed `gap` apart: `xs` between lines of text (a heading and the
8
+ * line under it), `sm` inside a group, `md` (default) between blocks, `lg` between groups.
9
+ *
10
+ * `width` caps the column: `narrow` is a reading measure (`max-w-3xl`), `wide` is `max-w-5xl`, `full`
11
+ * is the parent's whole width and `default` sets nothing. A capped Stack starts at its parent's left
12
+ * edge (it is never re-centred), so its first line lines up with every other block on the page.
13
+ */
14
+ export declare function Stack({ gap, width, as: Tag, className, children, ...rest }: {
15
+ gap?: StackGap | undefined;
16
+ width?: StackWidth | undefined;
17
+ as?: "div" | "section";
18
+ className?: string | undefined;
19
+ children?: ReactNode;
20
+ "aria-labelledby"?: string | undefined;
21
+ }): ReactElement;
22
+ /** A `<section>` Stack with an optional `SectionHeading` title (which names it) and `actions` beside it. */
23
+ export declare function Section({ title, actions, gap, width, className, children, }: {
24
+ title?: ReactNode;
25
+ actions?: ReactNode;
26
+ gap?: StackGap | undefined;
27
+ width?: StackWidth | undefined;
28
+ className?: string;
29
+ children?: ReactNode;
30
+ }): ReactElement;
package/dist/Stack.js ADDED
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { SectionHeading } from "./typography.js";
5
+ const GAP = { xs: "gap-2", sm: "gap-4", md: "gap-6", lg: "gap-8" };
6
+ /** `default` adds nothing: the Stack is as wide as its parent. */
7
+ export const STACK_WIDTH = {
8
+ narrow: "max-w-3xl",
9
+ default: undefined,
10
+ wide: "max-w-5xl",
11
+ full: "w-full",
12
+ };
13
+ /**
14
+ * A vertical column of blocks a fixed `gap` apart: `xs` between lines of text (a heading and the
15
+ * line under it), `sm` inside a group, `md` (default) between blocks, `lg` between groups.
16
+ *
17
+ * `width` caps the column: `narrow` is a reading measure (`max-w-3xl`), `wide` is `max-w-5xl`, `full`
18
+ * is the parent's whole width and `default` sets nothing. A capped Stack starts at its parent's left
19
+ * edge (it is never re-centred), so its first line lines up with every other block on the page.
20
+ */
21
+ export function Stack({ gap = "md", width = "default", as: Tag = "div", className, children, ...rest }) {
22
+ return (_jsx(Tag, { className: cx("flex flex-col", GAP[gap], STACK_WIDTH[width], className), ...rest, children: children }));
23
+ }
24
+ /** A `<section>` Stack with an optional `SectionHeading` title (which names it) and `actions` beside it. */
25
+ export function Section({ title, actions, gap = "sm", width, className, children, }) {
26
+ const titleId = useId();
27
+ return (_jsxs(Stack, { as: "section", gap: gap, width: width, className: className, "aria-labelledby": title ? titleId : undefined, children: [(title || actions) && (_jsxs("div", { className: "flex items-center justify-between gap-4", children: [title && _jsx(SectionHeading, { id: titleId, children: title }), actions && _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })] })), children] }));
28
+ }
@@ -0,0 +1,12 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ /** The classes of an inline text link, for a router's own <Link> that should look like one. */
3
+ export declare const linkClass: string;
4
+ type TextLinkProps = {
5
+ /** Opens in a new tab: adds `target="_blank"` and `rel="noreferrer"`. */
6
+ external?: boolean | undefined;
7
+ } & ComponentPropsWithoutRef<"a"> & {
8
+ href: string;
9
+ };
10
+ /** An inline text link in the accent colour. Every anchor prop goes on the `<a>`. */
11
+ export declare function TextLink({ external, className, children, ...rest }: TextLinkProps): ReactElement;
12
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ /** The classes of an inline text link, for a router's own <Link> that should look like one. */
4
+ export const linkClass = cx("rounded font-medium text-violet-600 underline-offset-2 transition-colors hover:text-violet-700", focusRing);
5
+ /** An inline text link in the accent colour. Every anchor prop goes on the `<a>`. */
6
+ export function TextLink({ external = false, className, children, ...rest }) {
7
+ return (_jsx("a", { className: cx(linkClass, className), ...(external ? { target: "_blank", rel: "noreferrer" } : {}), ...rest, children: children }));
8
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactElement, VideoHTMLAttributes } from "react";
2
+ /**
3
+ * A `<video>` with controls, full width and rounded; it loads metadata only, so the page does not
4
+ * pull the file until it is played. `src` is the app's own video; `captions` is a WebVTT file,
5
+ * offered as the default track. `children` is the fallback for a browser that cannot play it.
6
+ */
7
+ export declare function Video({ src, captions, className, children, ...rest }: {
8
+ src: string;
9
+ captions?: string | undefined;
10
+ } & Omit<VideoHTMLAttributes<HTMLVideoElement>, "src" | "controls">): ReactElement;
package/dist/Video.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /**
4
+ * A `<video>` with controls, full width and rounded; it loads metadata only, so the page does not
5
+ * pull the file until it is played. `src` is the app's own video; `captions` is a WebVTT file,
6
+ * offered as the default track. `children` is the fallback for a browser that cannot play it.
7
+ */
8
+ export function Video({ src, captions, className, children, ...rest }) {
9
+ return (_jsxs("video", { controls: true, preload: "metadata", src: src, className: cx("w-full rounded-lg", className), ...rest, children: [captions && _jsx("track", { kind: "captions", src: captions, default: true }), children] }));
10
+ }
@@ -0,0 +1,12 @@
1
+ export type ColourScheme = "light" | "dark";
2
+ /** Put `scheme` on `root` (the `<html>` element by default): the `dark` class, and `color-scheme` so native controls and scrollbars follow. */
3
+ export declare function applyColourScheme(scheme: ColourScheme, root?: HTMLElement): void;
4
+ /** The stored choice, else the system preference. `fallback` (default `"light"`) is for when neither is available: server rendering, or storage that throws with no `matchMedia`. */
5
+ export declare function readColourScheme(fallback?: ColourScheme): ColourScheme;
6
+ /** Remember `scheme` for the next visit. A storage error is swallowed, so private mode does not crash. */
7
+ export declare function storeColourScheme(scheme: ColourScheme): void;
8
+ /**
9
+ * JavaScript to inline in `<head>`, before first paint: applies the stored scheme, else the
10
+ * system one, to `<html>` as `applyColourScheme` does. Without it a light user sees a dark flash.
11
+ */
12
+ export declare const colourSchemeScript: string;
@@ -0,0 +1,40 @@
1
+ const STORAGE_KEY = "hackcove:colour-scheme";
2
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
3
+ const isScheme = (value) => value === "light" || value === "dark";
4
+ /** Put `scheme` on `root` (the `<html>` element by default): the `dark` class, and `color-scheme` so native controls and scrollbars follow. */
5
+ export function applyColourScheme(scheme, root = document.documentElement) {
6
+ root.classList.toggle("dark", scheme === "dark");
7
+ root.style.colorScheme = scheme;
8
+ }
9
+ function storedScheme() {
10
+ try {
11
+ const stored = localStorage.getItem(STORAGE_KEY);
12
+ return isScheme(stored) ? stored : undefined;
13
+ }
14
+ catch {
15
+ return undefined; // storage blocked: private mode, or no window at all
16
+ }
17
+ }
18
+ function systemScheme() {
19
+ if (typeof matchMedia !== "function")
20
+ return undefined;
21
+ return matchMedia(DARK_QUERY).matches ? "dark" : "light";
22
+ }
23
+ /** The stored choice, else the system preference. `fallback` (default `"light"`) is for when neither is available: server rendering, or storage that throws with no `matchMedia`. */
24
+ export function readColourScheme(fallback = "light") {
25
+ return storedScheme() ?? systemScheme() ?? fallback;
26
+ }
27
+ /** Remember `scheme` for the next visit. A storage error is swallowed, so private mode does not crash. */
28
+ export function storeColourScheme(scheme) {
29
+ try {
30
+ localStorage.setItem(STORAGE_KEY, scheme);
31
+ }
32
+ catch {
33
+ // Not remembered; the page still switches for this visit.
34
+ }
35
+ }
36
+ /**
37
+ * JavaScript to inline in `<head>`, before first paint: applies the stored scheme, else the
38
+ * system one, to `<html>` as `applyColourScheme` does. Without it a light user sees a dark flash.
39
+ */
40
+ export const colourSchemeScript = `(function(){var r=document.documentElement,s;try{s=localStorage.getItem(${JSON.stringify(STORAGE_KEY)})}catch(e){}if(s!=="light"&&s!=="dark")s=typeof matchMedia==="function"?matchMedia(${JSON.stringify(DARK_QUERY)}).matches?"dark":"light":null;if(s){r.classList.toggle("dark",s==="dark");r.style.colorScheme=s}})()`;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  export { Card, EpicHeader, Empty, PageTitle, Pill, groupByEpic } from "./ui.js";
2
2
  export { Markdown } from "./Markdown.js";
3
+ export { AppShell, appShellRootClass } from "./AppShell.js";
4
+ export { Container } from "./Container.js";
5
+ export { PageHeader, type PageHeaderProps } from "./PageHeader.js";
6
+ export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, SidebarLayout, } from "./PageLayouts.js";
7
+ export { Section, Stack, type StackGap, type StackWidth } from "./Stack.js";
3
8
  export { Button, buttonClass, type ButtonVariant } from "./Button.js";
4
9
  export { Avatar, avatarColourClass, initialsOf, type AvatarSize } from "./Avatar.js";
5
10
  export { Breadcrumbs, crumbLinkClass } from "./Breadcrumbs.js";
11
+ export { BulletList } from "./BulletList.js";
6
12
  export { Callout, type CalloutTone } from "./Callout.js";
13
+ export { Code } from "./Code.js";
14
+ export { Divider } from "./Divider.js";
7
15
  export { Dialog, type DialogProps } from "./Dialog.js";
8
16
  export { IconButton } from "./IconButton.js";
17
+ export { ColourSchemeToggle } from "./ColourSchemeToggle.js";
18
+ export { applyColourScheme, colourSchemeScript, readColourScheme, storeColourScheme, type ColourScheme, } from "./colourScheme.js";
9
19
  export { NavTabs, navTabClass, type NavTab } from "./NavTabs.js";
10
20
  export { ToggleChip } from "./ToggleChip.js";
11
21
  export { FilterGroup } from "./FilterGroup.js";
@@ -15,8 +25,15 @@ export { CopyLine } from "./CopyLine.js";
15
25
  export { Field } from "./Field.js";
16
26
  export { FieldDialog, type FieldDialogProps } from "./FieldDialog.js";
17
27
  export { Input } from "./Input.js";
28
+ export { InlineLinks, type InlineLinksSeparator } from "./InlineLinks.js";
18
29
  export { Kbd } from "./Kbd.js";
19
30
  export { KeyValue, KeyValueList } from "./KeyValueList.js";
20
31
  export { Select } from "./Select.js";
21
32
  export { Textarea } from "./Textarea.js";
22
- export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, Subtitle, type TypographyColor, type TypographyProps, } from "./typography.js";
33
+ export { Video } from "./Video.js";
34
+ export { linkClass, TextLink } from "./TextLink.js";
35
+ export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, type SubsectionHeadingSize, Subtitle, type TypographyColor, type TypographyProps, } from "./typography.js";
36
+ export { DateField, type DateFieldProps } from "./DateField.js";
37
+ export { DatePicker, type DatePickerProps } from "./DatePicker.js";
38
+ export { fromISODate, toISODate } from "./isoDate.js";
39
+ export type { CalendarDate } from "@internationalized/date";
package/dist/index.js CHANGED
@@ -1,11 +1,21 @@
1
1
  export { Card, EpicHeader, Empty, PageTitle, Pill, groupByEpic } from "./ui.js";
2
2
  export { Markdown } from "./Markdown.js";
3
+ export { AppShell, appShellRootClass } from "./AppShell.js";
4
+ export { Container } from "./Container.js";
5
+ export { PageHeader } from "./PageHeader.js";
6
+ export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, SidebarLayout, } from "./PageLayouts.js";
7
+ export { Section, Stack } from "./Stack.js";
3
8
  export { Button, buttonClass } from "./Button.js";
4
9
  export { Avatar, avatarColourClass, initialsOf } from "./Avatar.js";
5
10
  export { Breadcrumbs, crumbLinkClass } from "./Breadcrumbs.js";
11
+ export { BulletList } from "./BulletList.js";
6
12
  export { Callout } from "./Callout.js";
13
+ export { Code } from "./Code.js";
14
+ export { Divider } from "./Divider.js";
7
15
  export { Dialog } from "./Dialog.js";
8
16
  export { IconButton } from "./IconButton.js";
17
+ export { ColourSchemeToggle } from "./ColourSchemeToggle.js";
18
+ export { applyColourScheme, colourSchemeScript, readColourScheme, storeColourScheme, } from "./colourScheme.js";
9
19
  export { NavTabs, navTabClass } from "./NavTabs.js";
10
20
  export { ToggleChip } from "./ToggleChip.js";
11
21
  export { FilterGroup } from "./FilterGroup.js";
@@ -15,8 +25,14 @@ export { CopyLine } from "./CopyLine.js";
15
25
  export { Field } from "./Field.js";
16
26
  export { FieldDialog } from "./FieldDialog.js";
17
27
  export { Input } from "./Input.js";
28
+ export { InlineLinks } from "./InlineLinks.js";
18
29
  export { Kbd } from "./Kbd.js";
19
30
  export { KeyValue, KeyValueList } from "./KeyValueList.js";
20
31
  export { Select } from "./Select.js";
21
32
  export { Textarea } from "./Textarea.js";
33
+ export { Video } from "./Video.js";
34
+ export { linkClass, TextLink } from "./TextLink.js";
22
35
  export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, Subtitle, } from "./typography.js";
36
+ export { DateField } from "./DateField.js";
37
+ export { DatePicker } from "./DatePicker.js";
38
+ export { fromISODate, toISODate } from "./isoDate.js";
@@ -0,0 +1,5 @@
1
+ import { CalendarDate } from "@internationalized/date";
2
+ /** `"1990-05-17"` for `CalendarDate(1990, 5, 17)`; `""` for no date, so it drops into a form field. */
3
+ export declare function toISODate(date: CalendarDate | null | undefined): string;
4
+ /** The `CalendarDate` of a `"YYYY-MM-DD"` string; `null` for an empty, malformed or impossible one. */
5
+ export declare function fromISODate(iso: string | null | undefined): CalendarDate | null;
@@ -0,0 +1,16 @@
1
+ import { CalendarDate } from "@internationalized/date";
2
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
3
+ /** `"1990-05-17"` for `CalendarDate(1990, 5, 17)`; `""` for no date, so it drops into a form field. */
4
+ export function toISODate(date) {
5
+ return date ? date.toString() : "";
6
+ }
7
+ /** The `CalendarDate` of a `"YYYY-MM-DD"` string; `null` for an empty, malformed or impossible one. */
8
+ export function fromISODate(iso) {
9
+ const match = ISO_DATE.exec(iso ?? "");
10
+ if (!match)
11
+ return null;
12
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
13
+ const date = new CalendarDate(year, month, day);
14
+ // CalendarDate constrains 1990-02-31 to 1990-02-28; a string that does not survive is impossible.
15
+ return date.toString() === iso ? date : null;
16
+ }