@starci/hfs 4.16.5 → 4.17.2

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 (76) hide show
  1. package/CHANGELOG.md +36 -3
  2. package/README.md +4 -4
  3. package/codemod/loading-states.d.mts +50 -0
  4. package/codemod/loading-states.mjs +384 -0
  5. package/codemod/main.d.mts +1 -1
  6. package/codemod/main.mjs +46 -5
  7. package/codemod/page-navigation.d.mts +50 -0
  8. package/codemod/page-navigation.mjs +278 -0
  9. package/codemod/section-frame.d.mts +50 -0
  10. package/codemod/section-frame.mjs +483 -0
  11. package/codemod/small-renames.d.mts +54 -0
  12. package/codemod/small-renames.mjs +614 -0
  13. package/codemod/surface-card-kinds.d.mts +49 -0
  14. package/codemod/surface-card-kinds.mjs +404 -0
  15. package/codemod/theme-tokens.d.mts +45 -0
  16. package/codemod/theme-tokens.mjs +114 -0
  17. package/codemod/tier-binding.d.mts +50 -0
  18. package/codemod/tier-binding.mjs +214 -0
  19. package/package.json +1 -1
  20. package/runtime/knowledge/grammars/core/playbook.yaml +41 -51
  21. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +5 -5
  22. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  23. package/runtime/knowledge/hfs/rules.yaml +255 -30
  24. package/runtime/knowledge/hfs/slots.yaml +12 -5
  25. package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
  26. package/runtime/modules/kernel/failure-codes.yaml +23 -13
  27. package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
  28. package/runtime/scripts/hfs/architecture/component-tiers.d.mts +46 -0
  29. package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
  30. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  31. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
  33. package/runtime/scripts/hfs/architecture/index.mjs +6 -0
  34. package/runtime/scripts/hfs/architecture/render-graph.d.mts +125 -0
  35. package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
  36. package/runtime/scripts/hfs/architecture/render-prop-across-server.d.mts +23 -0
  37. package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
  38. package/runtime/scripts/hfs/architecture/surface-nesting.d.mts +44 -0
  39. package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
  40. package/runtime/scripts/hfs/architecture/unit-spec-providers.d.mts +2 -1
  41. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +12 -5
  42. package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
  43. package/runtime/scripts/work/draw/draw-dna.mjs +1 -1
  44. package/scaffold/add-fe-layout.mjs +3 -3
  45. package/scaffold/add-fe-overlay.mjs +4 -4
  46. package/scaffold/add-fe-page.mjs +1 -1
  47. package/scaffold/add-fe-unit.d.mts +2 -1
  48. package/scaffold/add-fe-unit.mjs +14 -12
  49. package/scaffold/add.d.mts +1 -1
  50. package/scaffold/add.mjs +1 -1
  51. package/src/main.mjs +4 -4
  52. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +3 -4
  53. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +8 -5
  54. package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +1 -3
  55. package/templates/fe/skeleton/packages/__project__-ui/src/branches/Providers/index.tsx +3 -3
  56. package/templates/fe/skeleton/packages/__project__-ui/src/composites/LocaleShell/index.tsx +3 -5
  57. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -1
  58. package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +1 -1
  59. package/templates/fe/skeleton-lite/apps/web/src/modules/brand/brand.css +1 -2
  60. package/templates/fe/units/block/component.tsx.tpl +2 -3
  61. package/templates/fe/units/branch/index.tsx.tpl +4 -3
  62. package/templates/fe/units/composite/index.tsx.tpl +3 -3
  63. package/templates/fe/units/frame/index.tsx.tpl +10 -0
  64. package/templates/fe/units/layout/index.tsx.tpl +2 -2
  65. package/templates/fe/units/leaf/index.tsx.tpl +3 -3
  66. package/templates/fe/units/page/component.tsx.tpl +2 -2
  67. package/templates/fe/units/page/index.tsx.tpl +2 -2
  68. package/templates/fe/units/page/stories.tsx.tpl +2 -2
  69. package/templates/fe/units/slot/SlotView.tsx.tpl +11 -10
  70. package/templates/fe/units/slot/data-slot.ts.tpl +20 -8
  71. package/templates/fe/units/slot/slot.shared.ts.tpl +52 -11
  72. package/templates/fe/units/slot/useSlotOfReads.ts.tpl +19 -0
  73. /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/classNames.ts +0 -0
  74. /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/index.tsx +0 -0
  75. /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/classNames.ts +0 -0
  76. /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/index.tsx +0 -0
@@ -2,11 +2,12 @@ import { useState } from "react"
2
2
  import type { ReactNode } from "react"
3
3
  import { Button } from "@starci/grammar/common"
4
4
 
5
- /** The resolved labels of the toggle and the content it shows; the branch owns only whether it is open. */
5
+ /** The resolved labels of the toggle and the slot it shows; the branch owns only whether it is open, and takes no children. */
6
6
  type {{Name}}Props = {
7
7
  readonly showLabel: string
8
8
  readonly hideLabel: string
9
- readonly children: ReactNode
9
+ /** What the toggle shows when open. */
10
+ readonly body: ReactNode
10
11
  }
11
12
 
12
13
  /** Holds its intrinsic state alone (open or closed); it reads no data, resolves no copy and renders below a client block. */
@@ -17,7 +18,7 @@ export const {{Name}} = (props: {{Name}}Props) => {
17
18
  <Button intent="neutral" onPress={() => setIsOpen((current) => !current)}>
18
19
  {isOpen ? props.hideLabel : props.showLabel}
19
20
  </Button>
20
- {isOpen ? props.children : null}
21
+ {isOpen ? props.body : null}
21
22
  </>
22
23
  )
23
24
  }
@@ -4,15 +4,15 @@ import { Text } from "@starci/grammar/common"
4
4
  type {{Name}}Props = {
5
5
  readonly term: string
6
6
  readonly value: string
7
- readonly isSkeleton: boolean
7
+ readonly isLoading: boolean
8
8
  }
9
9
 
10
10
  /** A closed arrangement of Grammar parts: a term and its value, each resting as itself while its block waits. */
11
11
  export const {{Name}} = (props: {{Name}}Props) => (
12
12
  <>
13
- <Text tone="muted" isSkeleton={props.isSkeleton}>
13
+ <Text tone="muted" isLoading={props.isLoading}>
14
14
  {props.term}
15
15
  </Text>
16
- <Text isSkeleton={props.isSkeleton}>{props.value}</Text>
16
+ <Text isLoading={props.isLoading}>{props.value}</Text>
17
17
  </>
18
18
  )
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react"
2
+ import { Stack } from "@starci/grammar/common"
3
+
4
+ /** What {{Name}} holds: it owns a region and lays out whatever its caller puts in it. */
5
+ type {{Name}}Props = {
6
+ readonly children: ReactNode
7
+ }
8
+
9
+ /** A frame owns a region of the screen and adds no content of its own; only a frame takes `children`. */
10
+ export const {{Name}} = (props: {{Name}}Props) => <Stack gap="section">{props.children}</Stack>
@@ -1,11 +1,11 @@
1
1
  import type { ReactNode } from "react"
2
- import { PrimaryRailLayout } from "@starci/grammar/common"
2
+ import { Grid } from "@starci/grammar/common"
3
3
  {{blockImports}}
4
4
  /** Framework-layout boundary input: the routed page of the {{segment}} segment and nothing else. */
5
5
  type {{Name}}Props = { readonly content: ReactNode }
6
6
 
7
7
  /**
8
- * The {{segment}} segment's frame: Grammar's PrimaryRailLayout places the routed page and {{railDoc}}. A frame only places
8
+ * The {{segment}} segment's frame: Grammar's Grid (kind main-rail) places the routed page and {{railDoc}}. A frame only places
9
9
  * regions and never insets a page; the Page inside owns its inset and rhythm.
10
10
  */
11
11
  export const {{Name}} = (props: {{Name}}Props) => {{frame}}
@@ -3,8 +3,8 @@ import { Badge } from "@starci/grammar/common"
3
3
  /** One resolved label and the waiting flag of the block that draws it. */
4
4
  type {{Name}}Props = {
5
5
  readonly label: string
6
- readonly isSkeleton: boolean
6
+ readonly isLoading: boolean
7
7
  }
8
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>
9
+ /** A fixed semantic unit over one Grammar leaf; it rests as itself by passing `isLoading` on. */
10
+ export const {{Name}} = (props: {{Name}}Props) => <Badge isLoading={props.isLoading}>{props.label}</Badge>
@@ -8,7 +8,7 @@ export interface {{Name}}BaseProps {
8
8
  readonly homeLabel: string
9
9
  readonly homeHref: string
10
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
11
+ readonly isLoading: boolean
12
12
  }
13
13
  }
14
14
 
@@ -28,7 +28,7 @@ export const {{Name}}Base = (props: {{Name}}BaseProps) => (
28
28
  ],
29
29
  },
30
30
  {{tabsHeader}} }}
31
- isSkeleton={props.props.isSkeleton}
31
+ isLoading={props.props.isLoading}
32
32
  >
33
33
  {{body}}
34
34
  </Page>
@@ -11,14 +11,14 @@ export const {{nameCamel}}Metadata = async (): Promise<Metadata> => {
11
11
  }
12
12
 
13
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"]> => {
14
+ const copyOf = async (isLoading: boolean): Promise<{{Name}}BaseProps["props"]> => {
15
15
  const t = await getTranslations("{{nameCamel}}")
16
16
  return {
17
17
  title: t("title"),
18
18
  breadcrumbLabel: t("breadcrumb"),
19
19
  homeLabel: t("home"),
20
20
  homeHref: APP_ROUTES.home,
21
- {{emptyResolved}}{{tabsResolved}} isSkeleton,
21
+ {{emptyResolved}}{{tabsResolved}} isLoading,
22
22
  }
23
23
  }
24
24
 
@@ -8,7 +8,7 @@ const READY: {{Name}}BaseProps["props"] = {
8
8
  breadcrumbLabel: "Breadcrumb",
9
9
  homeLabel: "Home",
10
10
  homeHref: "/",
11
- {{emptyFixture}}{{tabsFixture}} isSkeleton: false,
11
+ {{emptyFixture}}{{tabsFixture}} isLoading: false,
12
12
  }
13
13
 
14
14
  /** The pure half of {{Name}}: the resolved page and its loading view, paired for the DNA gate. */
@@ -29,6 +29,6 @@ export const Ready: Story = {
29
29
 
30
30
  /** The route's loading view: the same page, its header resting in its own boxes. */
31
31
  export const ReadySkeleton: Story = {
32
- args: { props: { ...READY, isSkeleton: true } },
32
+ args: { props: { ...READY, isLoading: true } },
33
33
  parameters: { dna: { pair: "Ready", phase: "loading" } },
34
34
  }
@@ -4,25 +4,26 @@ import { EmptyNotice } from "@starci/grammar/common"
4
4
  /** One region's read, its same-shape placeholder, its resolved status copy and the ready tree it draws. */
5
5
  type SlotViewProps<T> = {
6
6
  readonly slot: DataSlot<T>
7
- /** A value of the same shape as the items: while loading, the ready tree draws it with `isSkeleton`. */
7
+ /** A value of the same shape as the items: while loading, the ready tree draws it with `isLoading` on each Grammar component. */
8
8
  readonly placeholder: T
9
9
  readonly labels: SlotLabels
10
10
  /** A primary region says it is forbidden in place; a secondary one disappears. */
11
11
  readonly forbidden?: "notice" | "hide"
12
12
  /** Re-runs exactly the read of this slot. */
13
13
  readonly onRetry: () => void
14
- readonly children: (items: T, isSkeleton: boolean) => ReactNode
14
+ readonly render: (items: T, isLoading: boolean) => ReactNode
15
15
  }
16
16
 
17
17
  /** The one status view (FE-ERROR-1): every region renders its loading, forbidden, error and empty status through here. */
18
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} />
19
+ const { slot } = props
20
+ if (slot.isLoading === true) return props.render(props.placeholder, true)
21
+ if (slot.isForbidden === true) return props.forbidden === "hide" ? null : <EmptyNotice message={props.labels.forbidden} />
22
+ if (slot.isError === true) {
23
+ return <EmptyNotice isError message={props.labels.error} actionLabel={props.labels.retry} onAction={props.onRetry} />
23
24
  }
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)
25
+ if (slot.isEmpty === true) return <EmptyNotice message={props.labels.empty} />
26
+ // A refresh (`isValidating`) beside data keeps the content: it never draws a skeleton.
27
+ if (Array.isArray(slot.items) && slot.items.length === 0) return <EmptyNotice message={props.labels.empty} />
28
+ return props.render(slot.items, false)
28
29
  }
@@ -1,13 +1,25 @@
1
+ /** The flags no member of `DataSlot` sets: reading one is allowed and gives `undefined`. */
2
+ interface Unset {
3
+ readonly isLoading?: undefined
4
+ readonly isError?: undefined
5
+ readonly isForbidden?: undefined
6
+ readonly isEmpty?: undefined
7
+ readonly isValidating?: undefined
8
+ }
9
+
1
10
  /**
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.
11
+ * The status of one read (FE-ERROR-1), a union over the flags so an impossible mix (loading and error together) cannot be built.
12
+ * Only `slotOfSwr`, `slotOfReads`, `mapSlot` and `loadingSlot` build one. `items` exists only on the ready member. `isLoading` is a
13
+ * first load with no data. `isValidating` is a refresh beside data: the content stays and no skeleton is drawn. `isEmpty` is a
14
+ * finished read with nothing, never a failed one. Every field is an atom, so a slot crosses into a pure half; a region with two
15
+ * reads carries two slots, and each one shows its own status.
4
16
  */
5
- export interface DataSlot<T> {
6
- readonly items: T | undefined
7
- readonly isLoading?: boolean
8
- readonly isError?: boolean
9
- readonly isForbidden?: boolean
10
- }
17
+ export type DataSlot<T> =
18
+ | (Omit<Unset, "isLoading"> & { readonly isLoading: true })
19
+ | (Omit<Unset, "isForbidden"> & { readonly isForbidden: true })
20
+ | (Omit<Unset, "isError"> & { readonly isError: true })
21
+ | (Omit<Unset, "isEmpty" | "isValidating"> & { readonly isEmpty: true; readonly isValidating?: boolean })
22
+ | (Omit<Unset, "isValidating"> & { readonly items: T; readonly isValidating?: boolean })
11
23
 
12
24
  /** The resolved copy of the status view for one slot: the shared status sentences plus the region's own empty sentence. */
13
25
  export interface SlotLabels {
@@ -11,25 +11,66 @@ interface SettledRead<T> {
11
11
  }
12
12
 
13
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> {
14
+ export interface SwrRead<T> {
15
15
  readonly data: SettledRead<T> | undefined
16
16
  readonly error?: unknown
17
+ /** SWR's first load with no data. */
17
18
  readonly isLoading: boolean
19
+ /** SWR's refresh beside data. It rides on the slot and never draws a skeleton. */
20
+ readonly isValidating?: boolean
18
21
  }
19
22
 
20
- /** Folds one SWR read into its slot: a refusal of kind `forbidden` is its own status, every other failure is an error. */
23
+ /**
24
+ * Folds one SWR read into its slot: a first load with no data is `isLoading` (a refresh keeps the data), a refusal of kind `forbidden`
25
+ * is its own status, every other failure is an error. A refresh beside data is `isValidating`.
26
+ */
21
27
  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 }
28
+ if (read.isLoading) return { isLoading: true }
29
+ if (read.data === undefined) return read.error !== undefined ? { isError: true } : { isEmpty: true }
30
+ if (read.data.success && read.data.data !== undefined) return read.isValidating === true ? { items: read.data.data, isValidating: true } : { items: read.data.data }
31
+ if (read.data.success) return { isEmpty: true }
32
+ return read.data.kind === "forbidden" ? { isForbidden: true } : { isError: true }
26
33
  }
27
34
 
28
35
  /** 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
- })
36
+ export const mapSlot = <T, U>(slot: DataSlot<T>, map: (items: T) => U): DataSlot<U> =>
37
+ "items" in slot ? { ...slot, items: map(slot.items) } : slot
33
38
 
34
39
  /** 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 })
40
+ export const loadingSlot = <T>(): DataSlot<T> => ({ isLoading: true })
41
+
42
+ /** The part of an SWR read a frame folds with its other reads. */
43
+ export interface FrameRead<T> {
44
+ readonly data: T | undefined
45
+ readonly isLoading: boolean
46
+ readonly error?: unknown
47
+ readonly isValidating?: boolean
48
+ }
49
+
50
+ const slotOfFrameRead = <T>(read: FrameRead<T>): DataSlot<T> => {
51
+ if (read.data !== undefined) return read.isValidating === true ? { items: read.data, isValidating: true } : { items: read.data }
52
+ if (read.error !== undefined) return { isError: true }
53
+ return read.isLoading ? { isLoading: true } : { isEmpty: true }
54
+ }
55
+
56
+ /**
57
+ * Folds EVERY read a frame draws into one slot in a single pass. A frame never borrows the status of one of its reads for the others
58
+ * (lint `no-foreign-slot-status`), and never folds a read to `[]` (`no-swr-default-array`). `view` gets the reads and builds the items.
59
+ *
60
+ * - `isError` when a read failed with no data of its own: a failure never reads as empty;
61
+ * - `isLoading` when any read is loading with no data, unless the frame `hasDrawn` (latched after the first draw): a read that
62
+ * reloads on a key change leaves the frame standing, and the region keyed by that read shows its own loading;
63
+ * - `isEmpty` when every read finished with nothing; otherwise the items, `view(reads)`.
64
+ */
65
+ export const slotOfReads = <Reads extends Readonly<Record<string, FrameRead<unknown>>>, V>(
66
+ reads: Reads,
67
+ view: (reads: Reads) => V,
68
+ hasDrawn = false,
69
+ ): DataSlot<V> => {
70
+ const slots = Object.values(reads).map((read) => slotOfFrameRead(read))
71
+ if (slots.some((slot) => slot.isError === true)) return { isError: true }
72
+ if (slots.some((slot) => slot.isLoading === true) && !hasDrawn) return { isLoading: true }
73
+ if (slots.every((slot) => slot.isEmpty === true)) return { isEmpty: true }
74
+ const items = view(reads)
75
+ return Object.values(reads).some((read) => read.isValidating === true) ? { items, isValidating: true } : { items }
76
+ }
@@ -0,0 +1,19 @@
1
+ import { useEffect, useRef } from "react"
2
+ import type { DataSlot } from "@/modules/types"
3
+ import { slotOfReads, type FrameRead } from "./slot.shared"
4
+
5
+ /**
6
+ * `slotOfReads` for a component: it remembers that the frame has drawn its content, so a read that reloads on a key change (the open
7
+ * room, the page) leaves the frame standing. Only a first load with no data is `isLoading`.
8
+ */
9
+ export const useSlotOfReads = <Reads extends Readonly<Record<string, FrameRead<unknown>>>, V>(
10
+ reads: Reads,
11
+ view: (reads: Reads) => V,
12
+ ): DataSlot<V> => {
13
+ const hasDrawn = useRef(false)
14
+ const slot = slotOfReads(reads, view, hasDrawn.current)
15
+ useEffect(() => {
16
+ if ("items" in slot) hasDrawn.current = true
17
+ }, [slot])
18
+ return slot
19
+ }