@starci/hfs 4.9.5 → 4.9.8

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 (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +24 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
  5. package/runtime/knowledge/hfs/rules.yaml +1 -1
  6. package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +4 -3
  7. package/scaffold/add-fe-layout.mjs +46 -0
  8. package/scaffold/add-fe-overlay.mjs +108 -0
  9. package/scaffold/add-fe-page.mjs +137 -0
  10. package/scaffold/add-fe-unit.mjs +288 -0
  11. package/scaffold/add.mjs +20 -1
  12. package/src/main.mjs +11 -5
  13. package/templates/app/hooks/husky/pre-push +4 -1
  14. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +7 -5
  15. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/component.tsx +7 -6
  16. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +4 -5
  17. package/templates/fe/units/block/component.tsx.tpl +35 -0
  18. package/templates/fe/units/block/index.tsx.tpl +29 -0
  19. package/templates/fe/units/block/stories.tsx.tpl +29 -0
  20. package/templates/fe/units/branch/index.tsx.tpl +23 -0
  21. package/templates/fe/units/composite/index.tsx.tpl +18 -0
  22. package/templates/fe/units/layout/index.tsx.tpl +11 -0
  23. package/templates/fe/units/layout/route-layout.tsx.tpl +9 -0
  24. package/templates/fe/units/leaf/index.tsx.tpl +10 -0
  25. package/templates/fe/units/overlay/component.tsx.tpl +27 -0
  26. package/templates/fe/units/overlay/index.tsx.tpl +23 -0
  27. package/templates/fe/units/overlay/stories.tsx.tpl +29 -0
  28. package/templates/fe/units/page/component.tsx.tpl +35 -0
  29. package/templates/fe/units/page/error-component.tsx.tpl +18 -0
  30. package/templates/fe/units/page/error-index.tsx.tpl +11 -0
  31. package/templates/fe/units/page/index.tsx.tpl +29 -0
  32. package/templates/fe/units/page/route-error.tsx.tpl +10 -0
  33. package/templates/fe/units/page/route-loading.tsx.tpl +6 -0
  34. package/templates/fe/units/page/route-page.tsx.tpl +9 -0
  35. package/templates/fe/units/page/stories.tsx.tpl +34 -0
  36. package/templates/fe/units/slot/SlotView.tsx.tpl +28 -0
  37. package/templates/fe/units/slot/data-slot.ts.tpl +18 -0
  38. package/templates/fe/units/slot/slot.shared.ts.tpl +35 -0
  39. package/templates/fe/units/slot/useSlotLabels.ts.tpl +8 -0
@@ -0,0 +1,10 @@
1
+ import { Badge } from "@starci/grammar/common"
2
+
3
+ /** One resolved label and the waiting flag of the block that draws it. */
4
+ type {{Name}}Props = {
5
+ readonly label: string
6
+ readonly isSkeleton: boolean
7
+ }
8
+
9
+ /** A fixed semantic unit over one Grammar leaf; it rests as itself by passing `isSkeleton` on. */
10
+ export const {{Name}} = (props: {{Name}}Props) => <Badge isSkeleton={props.isSkeleton}>{props.label}</Badge>
@@ -0,0 +1,27 @@
1
+ import { {{grammarImports}} } from "@starci/grammar/common"
2
+
3
+ /** The copy, the open state and the actions the connected {{Name}} supplies; atoms only. */
4
+ export interface {{Name}}BaseProps {
5
+ readonly props: {
6
+ readonly isOpen: boolean
7
+ readonly triggerLabel: string
8
+ readonly title: string
9
+ {{labelMembers}} readonly cancelLabel: string
10
+ readonly submitLabel: string
11
+ }
12
+ readonly on: {
13
+ readonly openChange: (isOpen: boolean) => void
14
+ {{submitMember}} }
15
+ }
16
+
17
+ /** {{doc}} */
18
+ export const {{Name}}Base = (props: {{Name}}BaseProps) => (
19
+ <{{shell}}
20
+ isOpen={props.props.isOpen}
21
+ onOpenChange={props.on.openChange}
22
+ title={props.props.title}
23
+ {{shellAttributes}} trigger={<Button intent="neutral">{props.props.triggerLabel}</Button>}
24
+ {{footer}} >
25
+ {{body}}
26
+ </{{shell}}>
27
+ )
@@ -0,0 +1,23 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useTranslations } from "next-intl"
5
+ import { {{Name}}Base } from "./component"
6
+
7
+ /** The connected {{Name}}: owns whether it is open, resolves every label and renders its pure half. */
8
+ export const {{Name}} = () => {
9
+ const t = useTranslations("{{nameCamel}}")
10
+ const [isOpen, setIsOpen] = useState(false)
11
+ return (
12
+ <{{Name}}Base
13
+ props={{
14
+ isOpen,
15
+ triggerLabel: t("open"),
16
+ title: t("title"),
17
+ {{labelsResolved}} cancelLabel: t("cancel"),
18
+ submitLabel: t("submit"),
19
+ }}
20
+ on={{ openChange: setIsOpen, {{submitHandler}} }}
21
+ />
22
+ )
23
+ }
@@ -0,0 +1,29 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { {{Name}}Base } from "../../src/features/overlays/{{Name}}/component"
3
+ import type { {{Name}}BaseProps } from "../../src/features/overlays/{{Name}}/component"
4
+
5
+ /** The copy of the overlay. */
6
+ const COPY: {{Name}}BaseProps["props"] = {
7
+ isOpen: false,
8
+ triggerLabel: "Open",
9
+ title: "{{title}}",
10
+ {{labelsFixture}} cancelLabel: "Cancel",
11
+ submitLabel: "Save",
12
+ }
13
+
14
+ /** The pure half of {{Name}}: closed behind its trigger, and open. */
15
+ const meta = {
16
+ title: "{{Name}}",
17
+ component: {{Name}}Base,
18
+ args: { props: COPY, on: { openChange: () => undefined, {{submitFixture}} } },
19
+ } satisfies Meta<typeof {{Name}}Base>
20
+
21
+ export default meta
22
+
23
+ type Story = StoryObj<typeof meta>
24
+
25
+ /** Closed: only its trigger shows. */
26
+ export const Closed: Story = {}
27
+
28
+ /** Open above the page. */
29
+ export const Open: Story = { args: { props: { ...COPY, isOpen: true } } }
@@ -0,0 +1,35 @@
1
+ import { {{grammarImports}} } from "@starci/grammar/common"
2
+ {{blockImports}}
3
+ /** The copy of {{Name}} and whether its subject is still unresolved, supplied by the page owner. */
4
+ export interface {{Name}}BaseProps {
5
+ readonly props: {
6
+ readonly title: string
7
+ readonly breadcrumbLabel: string
8
+ readonly homeLabel: string
9
+ readonly homeHref: string
10
+ {{emptyMember}}{{tabsMember}} /** True for the route's loading view: the same page, its header resting in its own boxes (STATE-8). */
11
+ readonly isSkeleton: boolean
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Draws {{Name}} as one Grammar Page (R325): the header band carries the trail and the heading, and page commands go to
17
+ * `header.actions`, page views to `header.tabs` (R324); the body composes the page's blocks, each owning its own data and loading.
18
+ */
19
+ export const {{Name}}Base = (props: {{Name}}BaseProps) => (
20
+ <Page
21
+ header={{
22
+ title: props.props.title,
23
+ breadcrumbs: {
24
+ label: props.props.breadcrumbLabel,
25
+ items: [
26
+ { id: "home", label: props.props.homeLabel, href: props.props.homeHref },
27
+ { id: "{{routeId}}", label: props.props.title },
28
+ ],
29
+ },
30
+ {{tabsHeader}} }}
31
+ isSkeleton={props.props.isSkeleton}
32
+ >
33
+ {{body}}
34
+ </Page>
35
+ )
@@ -0,0 +1,18 @@
1
+ import { Alert, Page } from "@starci/grammar/common"
2
+
3
+ /** The copy of the {{route}} route's failure and the retry its boundary hands over. */
4
+ export interface {{ErrorName}}BaseProps {
5
+ readonly props: {
6
+ readonly title: string
7
+ readonly message: string
8
+ readonly retryLabel: string
9
+ }
10
+ readonly on: { readonly retry: () => void }
11
+ }
12
+
13
+ /** A failed {{route}} route keeps its page and puts one negative Alert where the first region would be (STATE-8). */
14
+ export const {{ErrorName}}Base = (props: {{ErrorName}}BaseProps) => (
15
+ <Page header={{ title: props.props.title }}>
16
+ <Alert title={props.props.message} tone="negative" action={{ label: props.props.retryLabel, onAction: props.on.retry }} />
17
+ </Page>
18
+ )
@@ -0,0 +1,11 @@
1
+ import { useTranslations } from "next-intl"
2
+ import { {{ErrorName}}Base } from "./component"
3
+
4
+ /** The public props of the {{route}} error page: the boundary hands it the retry. */
5
+ type {{ErrorName}}Props = { readonly onRetry: () => void }
6
+
7
+ /** A render failure under the {{route}} route shows this page instead of a blank one; retry renders the segment again. */
8
+ export const {{ErrorName}} = (props: {{ErrorName}}Props) => {
9
+ const t = useTranslations("{{nameCamel}}")
10
+ return <{{ErrorName}}Base props={{ title: t("title"), message: t("failed"), retryLabel: t("retry") }} on={{ retry: props.onRetry }} />
11
+ }
@@ -0,0 +1,29 @@
1
+ import type { Metadata } from "next"
2
+ import { getTranslations } from "next-intl/server"
3
+ import { APP_ROUTES } from "@/modules/routes"
4
+ import { {{Name}}Base } from "./component"
5
+ import type { {{Name}}BaseProps } from "./component"
6
+
7
+ /** The document title of {{Name}}, from the catalog of the requested locale. */
8
+ export const {{nameCamel}}Metadata = async (): Promise<Metadata> => {
9
+ const t = await getTranslations("{{nameCamel}}")
10
+ return { title: t("title") }
11
+ }
12
+
13
+ /** The copy of {{Name}}, resolved once on the server for the page and for its loading view. */
14
+ const copyOf = async (isSkeleton: boolean): Promise<{{Name}}BaseProps["props"]> => {
15
+ const t = await getTranslations("{{nameCamel}}")
16
+ return {
17
+ title: t("title"),
18
+ breadcrumbLabel: t("breadcrumb"),
19
+ homeLabel: t("home"),
20
+ homeHref: APP_ROUTES.home,
21
+ {{emptyResolved}}{{tabsResolved}} isSkeleton,
22
+ }
23
+ }
24
+
25
+ /** The {{route}} page: a server owner that resolves its copy and composes its blocks; it reads no data. */
26
+ export const {{Name}} = async () => <{{Name}}Base props={await copyOf(false)} />
27
+
28
+ /** The {{route}} route's loading view: the same page drawn in skeleton mode, never a separate layout (STATE-8). */
29
+ export const {{LoadingName}} = async () => <{{Name}}Base props={await copyOf(true)} />
@@ -0,0 +1,10 @@
1
+ "use client"
2
+
3
+ import { {{ErrorName}} } from "@/features/pages/{{ErrorName}}"
4
+
5
+ type ErrorProps = { readonly reset: () => void }
6
+
7
+ /** The {{route}} route's error boundary slot: it mounts the route's error page and hands it the segment's retry. */
8
+ const ErrorBoundary = (props: ErrorProps) => <{{ErrorName}} onRetry={props.reset} />
9
+
10
+ export default ErrorBoundary
@@ -0,0 +1,6 @@
1
+ import { {{LoadingName}} } from "@/features/pages/{{Name}}"
2
+
3
+ /** The {{route}} route's loading slot: it mounts the page in skeleton mode and nothing else. */
4
+ const Loading = () => <{{LoadingName}} />
5
+
6
+ export default Loading
@@ -0,0 +1,9 @@
1
+ import { {{Name}} } from "@/features/pages/{{Name}}"
2
+
3
+ /** The document title of this page, from the catalog of the requested locale. */
4
+ export { {{nameCamel}}Metadata as generateMetadata } from "@/features/pages/{{Name}}"
5
+
6
+ /** The {{route}} route's page slot: it mounts {{Name}} and nothing else. */
7
+ const Page = () => <{{Name}} />
8
+
9
+ export default Page
@@ -0,0 +1,34 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { {{Name}}Base } from "../../src/features/pages/{{Name}}/component"
3
+ import type { {{Name}}BaseProps } from "../../src/features/pages/{{Name}}/component"
4
+
5
+ /** The copy of the resolved page. */
6
+ const READY: {{Name}}BaseProps["props"] = {
7
+ title: "{{title}}",
8
+ breadcrumbLabel: "Breadcrumb",
9
+ homeLabel: "Home",
10
+ homeHref: "/",
11
+ {{emptyFixture}}{{tabsFixture}} isSkeleton: false,
12
+ }
13
+
14
+ /** The pure half of {{Name}}: the resolved page and its loading view, paired for the DNA gate. */
15
+ const meta = {
16
+ title: "{{Name}}",
17
+ component: {{Name}}Base,
18
+ args: { props: READY },
19
+ } satisfies Meta<typeof {{Name}}Base>
20
+
21
+ export default meta
22
+
23
+ type Story = StoryObj<typeof meta>
24
+
25
+ /** The resolved page. */
26
+ export const Ready: Story = {
27
+ parameters: { dna: { pair: "Ready", phase: "ready" } },
28
+ }
29
+
30
+ /** The route's loading view: the same page, its header resting in its own boxes. */
31
+ export const ReadySkeleton: Story = {
32
+ args: { props: { ...READY, isSkeleton: true } },
33
+ parameters: { dna: { pair: "Ready", phase: "loading" } },
34
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react"
2
+ import { EmptyNotice } from "@starci/grammar/common"
3
+ {{slotTypes}}
4
+ /** One region's read, its same-shape placeholder, its resolved status copy and the ready tree it draws. */
5
+ type SlotViewProps<T> = {
6
+ readonly slot: DataSlot<T>
7
+ /** A value of the same shape as the items: while loading, the ready tree draws it with `isSkeleton`. */
8
+ readonly placeholder: T
9
+ readonly labels: SlotLabels
10
+ /** A primary region says it is forbidden in place; a secondary one disappears. */
11
+ readonly forbidden?: "notice" | "hide"
12
+ /** Re-runs exactly the read of this slot. */
13
+ readonly onRetry: () => void
14
+ readonly children: (items: T, isSkeleton: boolean) => ReactNode
15
+ }
16
+
17
+ /** The one status view (FE-ERROR-1): every region renders its loading, forbidden, error and empty status through here. */
18
+ export const SlotView = <T,>(props: SlotViewProps<T>) => {
19
+ if (props.slot.isLoading === true) return props.children(props.placeholder, true)
20
+ if (props.slot.isForbidden === true) return props.forbidden === "hide" ? null : <EmptyNotice message={props.labels.forbidden} />
21
+ if (props.slot.isError === true) {
22
+ return <EmptyNotice message={props.labels.error} actionLabel={props.labels.retry} onAction={props.onRetry} />
23
+ }
24
+ const items = props.slot.items
25
+ // No items, or an empty list, is the region's empty sentence.
26
+ if (items === undefined || (Array.isArray(items) && items.length === 0)) return <EmptyNotice message={props.labels.empty} />
27
+ return props.children(items, false)
28
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The data status of one read (FE-ERROR-1). Every field is an atom, so a slot crosses into a pure half; a region with
3
+ * two reads carries two slots, and each one shows its own status. No flag set and no items means empty.
4
+ */
5
+ export interface DataSlot<T> {
6
+ readonly items: T | undefined
7
+ readonly isLoading?: boolean
8
+ readonly isError?: boolean
9
+ readonly isForbidden?: boolean
10
+ }
11
+
12
+ /** The resolved copy of the status view for one slot: the shared status sentences plus the region's own empty sentence. */
13
+ export interface SlotLabels {
14
+ readonly empty: string
15
+ readonly forbidden: string
16
+ readonly error: string
17
+ readonly retry: string
18
+ }
@@ -0,0 +1,35 @@
1
+ import type { DataSlot } from "@/modules/types"
2
+
3
+ /**
4
+ * The part of an operation's GraphQLResponse a slot reads: whether it succeeded, its value and the refusal kind. Only hooks/swr
5
+ * imports an operation's types, so the slot owner reads the outcome by these fields alone.
6
+ */
7
+ interface SettledRead<T> {
8
+ readonly success: boolean
9
+ readonly data?: T
10
+ readonly kind?: string
11
+ }
12
+
13
+ /** What an SWR read of one operation hands its caller: the operation's outcome, the fetch error and whether it is on its way. */
14
+ interface SwrRead<T> {
15
+ readonly data: SettledRead<T> | undefined
16
+ readonly error?: unknown
17
+ readonly isLoading: boolean
18
+ }
19
+
20
+ /** Folds one SWR read into its slot: a refusal of kind `forbidden` is its own status, every other failure is an error. */
21
+ export const slotOfSwr = <T>(read: SwrRead<T>): DataSlot<T> => {
22
+ if (read.isLoading) return { items: undefined, isLoading: true }
23
+ if (read.data === undefined) return { items: undefined, isError: read.error !== undefined }
24
+ if (read.data.success) return { items: read.data.data }
25
+ return read.data.kind === "forbidden" ? { items: undefined, isForbidden: true } : { items: undefined, isError: true }
26
+ }
27
+
28
+ /** Carries a slot's status over to the view made of its items. */
29
+ export const mapSlot = <T, U>(slot: DataSlot<T>, map: (items: T) => U): DataSlot<U> => ({
30
+ ...slot,
31
+ items: slot.items === undefined ? undefined : map(slot.items),
32
+ })
33
+
34
+ /** The slot of a read still on its way: what a skeleton story and a region waiting on its owner render. */
35
+ export const loadingSlot = <T>(): DataSlot<T> => ({ items: undefined, isLoading: true })
@@ -0,0 +1,8 @@
1
+ import { useTranslations } from "next-intl"
2
+ import type { SlotLabels } from "@/modules/types"
3
+
4
+ /** Resolves the shared status copy once; the returned function adds one region's own empty sentence. */
5
+ export const useSlotLabels = () => {
6
+ const t = useTranslations("status")
7
+ return (empty: string): SlotLabels => ({ empty, forbidden: t("forbidden"), error: t("error"), retry: t("retry") })
8
+ }