@starci/hfs 4.9.6 → 4.9.9

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 (50) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +24 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  5. package/scaffold/add-fe-layout.mjs +46 -0
  6. package/scaffold/add-fe-overlay.mjs +108 -0
  7. package/scaffold/add-fe-page.mjs +137 -0
  8. package/scaffold/add-fe-unit.mjs +288 -0
  9. package/scaffold/add.mjs +20 -1
  10. package/scaffold/app.mjs +3 -1
  11. package/src/main.mjs +11 -5
  12. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
  13. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
  14. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
  15. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -0
  16. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
  17. package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  18. package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
  19. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  20. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
  21. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +72 -46
  22. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +20 -25
  23. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  24. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
  25. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +7 -5
  26. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/component.tsx +7 -6
  27. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +4 -5
  28. package/templates/fe/units/block/component.tsx.tpl +35 -0
  29. package/templates/fe/units/block/index.tsx.tpl +29 -0
  30. package/templates/fe/units/block/stories.tsx.tpl +29 -0
  31. package/templates/fe/units/branch/index.tsx.tpl +23 -0
  32. package/templates/fe/units/composite/index.tsx.tpl +18 -0
  33. package/templates/fe/units/layout/index.tsx.tpl +11 -0
  34. package/templates/fe/units/layout/route-layout.tsx.tpl +9 -0
  35. package/templates/fe/units/leaf/index.tsx.tpl +10 -0
  36. package/templates/fe/units/overlay/component.tsx.tpl +27 -0
  37. package/templates/fe/units/overlay/index.tsx.tpl +23 -0
  38. package/templates/fe/units/overlay/stories.tsx.tpl +29 -0
  39. package/templates/fe/units/page/component.tsx.tpl +35 -0
  40. package/templates/fe/units/page/error-component.tsx.tpl +18 -0
  41. package/templates/fe/units/page/error-index.tsx.tpl +11 -0
  42. package/templates/fe/units/page/index.tsx.tpl +29 -0
  43. package/templates/fe/units/page/route-error.tsx.tpl +10 -0
  44. package/templates/fe/units/page/route-loading.tsx.tpl +6 -0
  45. package/templates/fe/units/page/route-page.tsx.tpl +9 -0
  46. package/templates/fe/units/page/stories.tsx.tpl +34 -0
  47. package/templates/fe/units/slot/SlotView.tsx.tpl +28 -0
  48. package/templates/fe/units/slot/data-slot.ts.tpl +18 -0
  49. package/templates/fe/units/slot/slot.shared.ts.tpl +35 -0
  50. package/templates/fe/units/slot/useSlotLabels.ts.tpl +8 -0
@@ -3,29 +3,34 @@ import type { ExecutionContext, Type } from "@nestjs/common"
3
3
  import type { Request } from "express"
4
4
  import { Reflector } from "@nestjs/core"
5
5
  import { Test } from "@nestjs/testing"
6
- import { FakeClock, mock } from "@starci/jest-preset"
6
+ import { FakeClock, mock, mockEntityManager } from "@starci/jest-preset"
7
+ import type { MockEntityManager } from "@starci/jest-preset"
7
8
  import { CLOCK } from "@modules/platform/clock"
9
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
8
10
  import { HttpSecurityErrorCode } from "./errors/http-security.error"
9
11
  import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
10
12
  import type { HttpSecurityOptions } from "./http-security.options"
13
+ import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
11
14
  import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
12
15
 
13
16
  const AT = "2026-02-03T04:05:06.000Z"
17
+ const RESET_AT = "2026-02-03T04:06:06.000Z"
14
18
  const options: HttpSecurityOptions = {
15
19
  allowedOrigins: [],
16
20
  rateLimit: { windowMs: 60_000, defaultLimit: 2, strictLimit: 1 },
17
21
  webhooks: {},
18
22
  }
19
23
 
20
- const build = async (clock: FakeClock, tier?: RateTier) => {
24
+ const build = async (entityManager: MockEntityManager, tier?: RateTier) => {
21
25
  const reflector = mock<Reflector>()
22
26
  reflector.getAllAndOverride.mockReturnValue(tier)
23
27
  const moduleRef = await Test.createTestingModule({
24
28
  providers: [
25
29
  RateLimitGuard,
26
30
  { provide: Reflector, useValue: reflector },
27
- { provide: CLOCK, useValue: clock },
31
+ { provide: CLOCK, useValue: new FakeClock(AT) },
28
32
  { provide: HTTP_SECURITY_OPTIONS, useValue: options },
33
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
29
34
  ],
30
35
  }).compile()
31
36
  return { guard: moduleRef.get(RateLimitGuard), reflector }
@@ -33,79 +38,100 @@ const build = async (clock: FakeClock, tier?: RateTier) => {
33
38
 
34
39
  /** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
35
40
  const contextOf = (ip: string | undefined, handler = () => undefined, door: Type<unknown> = class Door {}) => {
36
- let address = ip
37
41
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
38
- http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip: address }))
42
+ http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
39
43
  const context = mock<ExecutionContext>()
40
44
  context.switchToHttp.mockReturnValue(http)
41
45
  context.getHandler.mockReturnValue(handler)
42
46
  context.getClass.mockReturnValue(door)
43
- return { context, setIp: (value: string | undefined) => (address = value) }
47
+ return context
44
48
  }
45
49
 
46
50
  describe("RateLimit", () => {
47
51
  it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
48
52
  class Door {}
49
53
  RateLimit(RateTier.Strict)(Door)
50
- const { guard, reflector } = await build(new FakeClock(AT), RateTier.Strict)
54
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
55
+ const { guard, reflector } = await build(entityManager, RateTier.Strict)
51
56
  const handler = () => undefined
52
- const { context } = contextOf("127.0.0.1", handler, Door)
53
57
 
54
58
  expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
55
- expect(guard.canActivate(context)).toBe(true)
56
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
59
+ await expect(guard.canActivate(contextOf("127.0.0.1", handler, Door))).rejects.toMatchObject({
60
+ code: HttpSecurityErrorCode.RateLimited,
61
+ })
57
62
  expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
63
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
64
+ "strict:127.0.0.1",
65
+ new Date(AT),
66
+ new Date(RESET_AT),
67
+ ])
58
68
  })
59
69
  })
60
70
 
61
71
  describe("RateLimitGuard", () => {
62
- it("counts an unmarked door under the default limit", async () => {
63
- const { guard } = await build(new FakeClock(AT))
64
- const { context } = contextOf("127.0.0.1")
65
-
66
- expect(guard.canActivate(context)).toBe(true)
67
- expect(guard.canActivate(context)).toBe(true)
68
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
72
+ it("admits a caller under the default limit of an unmarked door, counting in the shared window", async () => {
73
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
74
+ const { guard } = await build(entityManager)
75
+
76
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
77
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
78
+ "default:127.0.0.1",
79
+ new Date(AT),
80
+ new Date(RESET_AT),
81
+ ])
69
82
  })
70
83
 
71
- it("opens a fresh window for a caller whose bucket expired", async () => {
72
- const clock = new FakeClock(AT)
73
- const { guard } = await build(clock)
74
- const { context } = contextOf("127.0.0.1")
75
-
76
- guard.canActivate(context)
77
- guard.canActivate(context)
78
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
84
+ it("refuses a caller whose shared window is over the default limit", async () => {
85
+ const { guard } = await build(mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 3 }]] }))
79
86
 
80
- clock.advance(60_001)
87
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
88
+ code: HttpSecurityErrorCode.RateLimited,
89
+ })
90
+ })
81
91
 
82
- expect(guard.canActivate(context)).toBe(true)
92
+ it("prunes the expired windows when a hit opens a fresh one", async () => {
93
+ const entityManager = mockEntityManager({
94
+ query: [
95
+ [HIT_RATE_WINDOW, [{ hits: 1 }]],
96
+ [PRUNE_RATE_WINDOWS, []],
97
+ ],
98
+ })
99
+ const { guard } = await build(entityManager)
100
+
101
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
102
+ expect(entityManager.query).toHaveBeenCalledWith(PRUNE_RATE_WINDOWS, [new Date(AT)])
83
103
  })
84
104
 
85
- it("counts a request without an address under the unknown bucket", async () => {
86
- const { guard } = await build(new FakeClock(AT))
87
- const { context } = contextOf(undefined)
105
+ it("counts a request without an address under the unknown key", async () => {
106
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
107
+ const { guard } = await build(entityManager)
88
108
 
89
- guard.canActivate(context)
90
- guard.canActivate(context)
91
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
109
+ await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
110
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
111
+ "default:unknown",
112
+ new Date(AT),
113
+ new Date(RESET_AT),
114
+ ])
92
115
  })
93
116
 
94
- it("prunes the expired buckets when the map outgrows the bucket cap", async () => {
95
- const clock = new FakeClock(AT)
96
- const { guard } = await build(clock)
97
- const { context, setIp } = contextOf("0.0.0.0")
117
+ it("bounds the window key to the length the table stores", async () => {
118
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
119
+ const { guard } = await build(entityManager)
98
120
 
99
- for (let index = 0; index <= 10_000; index += 1) {
100
- setIp(`10.${(index >> 16) % 256}.${(index >> 8) % 256}.${index % 256}`)
101
- guard.canActivate(context)
102
- }
121
+ await guard.canActivate(contextOf("f".repeat(300)))
122
+
123
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
124
+ `default:${"f".repeat(120)}`,
125
+ new Date(AT),
126
+ new Date(RESET_AT),
127
+ ])
128
+ })
103
129
 
104
- clock.advance(60_001)
105
- setIp("127.0.0.1")
130
+ it("refuses the request when the store answers no window", async () => {
131
+ const { guard } = await build(mockEntityManager({ query: [HIT_RATE_WINDOW, []] }))
106
132
 
107
- expect(guard.canActivate(context)).toBe(true)
108
- expect(guard.canActivate(context)).toBe(true)
109
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
133
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
134
+ code: HttpSecurityErrorCode.RateLimited,
135
+ })
110
136
  })
111
137
  })
@@ -1,13 +1,17 @@
1
1
  import { Injectable, SetMetadata } from "@nestjs/common"
2
2
  import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
3
  import type { Reflector } from "@nestjs/core"
4
+ import type { EntityManager } from "typeorm"
4
5
  import { InjectClock } from "@modules/platform/clock"
5
6
  import type { Clock } from "@modules/platform/clock"
6
7
  import { InjectReflector } from "@modules/platform/composition"
8
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
7
9
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
8
10
  import { requestOf } from "./execution-request.mapper"
9
11
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
10
12
  import type { HttpSecurityOptions } from "./http-security.options"
13
+ import type { RateWindowRow } from "./persistence/rate-limit.rows"
14
+ import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
11
15
 
12
16
  /** The rate limit tiers of a door. */
13
17
  export enum RateTier {
@@ -18,29 +22,28 @@ export enum RateTier {
18
22
  }
19
23
 
20
24
  const RATE_TIER_KEY = "platform.http-security.rate-tier"
21
- const BUCKETS_MAX = 10_000
22
-
23
- interface Bucket {
24
- count: number
25
- resetAt: number
26
- }
25
+ /** The longest window key the table stores: the tier, a colon and the caller address. */
26
+ const KEY_MAX = 128
27
27
 
28
28
  /** Marks a door with a rate limit tier other than the default. */
29
29
  export const RateLimit = (tier: RateTier): ReturnType<typeof SetMetadata> => SetMetadata(RATE_TIER_KEY, tier)
30
30
 
31
31
  @Injectable()
32
- /** The first app guard: a fixed-window counter per caller address and tier, kept in process memory and stamped by the Clock. */
32
+ /**
33
+ * The first app guard: a fixed-window counter per caller address and tier, stamped by the Clock and kept in the primary
34
+ * database, so every replica counts against the same window. A hit that opens a fresh window also prunes a bounded batch of
35
+ * expired windows.
36
+ */
33
37
  export class RateLimitGuard implements CanActivate {
34
- private readonly buckets = new Map<string, Bucket>()
35
-
36
38
  constructor(
37
39
  @InjectReflector() private readonly reflector: Reflector,
38
40
  @InjectClock() private readonly clock: Clock,
39
41
  @InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
42
+ @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
40
43
  ) {}
41
44
 
42
45
  /** Counts the request and refuses it when the caller is over the limit of the door tier. */
43
- canActivate(context: ExecutionContext): boolean {
46
+ async canActivate(context: ExecutionContext): Promise<boolean> {
44
47
  const tier =
45
48
  this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, [
46
49
  context.getHandler(),
@@ -48,21 +51,13 @@ export class RateLimitGuard implements CanActivate {
48
51
  ]) ?? RateTier.Default
49
52
  const limit =
50
53
  tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
51
- const now = this.clock.now().getTime()
52
- const key = `${tier}:${requestOf(context).ip ?? "unknown"}`
53
- const current = this.buckets.get(key)
54
- const bucket =
55
- current && current.resetAt > now ? current : { count: 0, resetAt: now + this.options.rateLimit.windowMs }
56
- bucket.count += 1
57
- this.buckets.set(key, bucket)
58
- if (this.buckets.size > BUCKETS_MAX) this.prune(now)
59
- if (bucket.count > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
54
+ const now = this.clock.now()
55
+ const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
56
+ const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
57
+ const rows: Array<RateWindowRow> = await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt])
58
+ const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
59
+ if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
60
+ if (hits > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
60
61
  return true
61
62
  }
62
-
63
- private prune(now: number): void {
64
- for (const [key, bucket] of this.buckets) {
65
- if (bucket.resetAt <= now) this.buckets.delete(key)
66
- }
67
- }
68
63
  }
@@ -5,11 +5,14 @@ import type { ErrorKind } from "@modules/platform/errors"
5
5
  export enum DatabaseErrorCode {
6
6
  /** A configured connection has no declared EntityManager injection token. */
7
7
  ConnectionUndeclared = "DATABASE_CONNECTION_UNDECLARED",
8
+ /** A dynamic SQL identifier was not on the names its caller allows. */
9
+ IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
8
10
  }
9
11
 
10
12
  /** How each database code travels. */
11
13
  export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
12
14
  [DatabaseErrorCode.ConnectionUndeclared]: "internal",
15
+ [DatabaseErrorCode.IdentifierRejected]: "internal",
13
16
  }
14
17
 
15
18
  /** The one error class of the database capability. */
@@ -1,7 +1,8 @@
1
1
  export { DatabaseModule } from "./database.module"
2
- export { DATABASE_PROBE, InjectDatabaseProbe } from "./primary.decorators"
2
+ export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
3
3
  export { DatabaseProbeService } from "./database-probe.service"
4
4
  export type { DatabaseConnectionOptions } from "./database.options"
5
5
  export { PRIMARY_DATA_SOURCE } from "./database.port"
6
+ export { sql } from "./database.sql"
6
7
  export { DATABASE_ERROR_KINDS } from "./errors/database.error"
7
8
  export { parsePrimaryDatabaseConfig } from "./primary.config"
@@ -1,4 +1,4 @@
1
- import { Heading } from "@starci/grammar/common"
1
+ import { Page } from "@starci/grammar/common"
2
2
  import { SignInForm } from "@/components/blocks/SignInForm"
3
3
  import { SignOutButton } from "@/components/blocks/SignOutButton"
4
4
 
@@ -8,11 +8,13 @@ export type AppHomePageBaseProps = {
8
8
  readonly props: { readonly title: string }
9
9
  }
10
10
 
11
- /** Draw the front door of the product app: the sign-in form for a signed-out reader, the reader and sign-out once signed in. */
11
+ /**
12
+ * Draw the front door of the product app as one Grammar Page (R325): the title heads the page band, and the body holds
13
+ * the sign-in form for a signed-out reader, the reader and sign-out once signed in.
14
+ */
12
15
  export const AppHomePageBase = (props: AppHomePageBaseProps) => (
13
- <>
14
- <Heading level={1}>{props.props.title}</Heading>
16
+ <Page header={{ title: props.props.title }}>
15
17
  <SignInForm />
16
18
  <SignOutButton />
17
- </>
19
+ </Page>
18
20
  )
@@ -1,4 +1,4 @@
1
- import { Button, Heading, Text } from "@starci/grammar/common"
1
+ import { Button, Page } from "@starci/grammar/common"
2
2
 
3
3
  /** Props for {@link LandingPageBase}. */
4
4
  export type LandingPageBaseProps = {
@@ -12,13 +12,14 @@ export type LandingPageBaseProps = {
12
12
  }
13
13
  }
14
14
 
15
- /** Draw the public front door: the welcome, one sentence about the product and the way into the product app. */
15
+ /**
16
+ * Draw the public front door as one Grammar Page (R325): the welcome heads the band with one sentence about the product
17
+ * under it, and the body holds the way into the product app.
18
+ */
16
19
  export const LandingPageBase = (props: LandingPageBaseProps) => (
17
- <>
18
- <Heading level={1}>{props.props.title}</Heading>
19
- <Text tone="muted">{props.props.lede}</Text>
20
+ <Page header={{ title: props.props.title, description: props.props.lede }}>
20
21
  <Button href={props.props.appHref} intent="commit">
21
22
  {props.props.openApp}
22
23
  </Button>
23
- </>
24
+ </Page>
24
25
  )
@@ -1,4 +1,4 @@
1
- import { Heading } from "@starci/grammar/common"
1
+ import { Page } from "@starci/grammar/common"
2
2
  import { SignInForm } from "@/components/blocks/SignInForm"
3
3
  import { SignOutButton } from "@/components/blocks/SignOutButton"
4
4
 
@@ -7,11 +7,10 @@ export type AppHomePageBaseProps = {
7
7
  readonly props: { readonly title: string }
8
8
  }
9
9
 
10
- /** Draws the front door: the sign-in form for a signed-out reader, the reader and sign-out once signed in. */
10
+ /** Draws the front door as one Grammar Page (R325): the title heads the band, the body the sign-in form or the reader and sign-out. */
11
11
  export const AppHomePageBase = (props: AppHomePageBaseProps) => (
12
- <>
13
- <Heading level={1}>{props.props.title}</Heading>
12
+ <Page header={{ title: props.props.title }}>
14
13
  <SignInForm />
15
14
  <SignOutButton />
16
- </>
15
+ </Page>
17
16
  )
@@ -0,0 +1,35 @@
1
+ import { SurfaceCard, Text } from "@starci/grammar/common"
2
+ import { SlotView } from "{{slotViewFrom}}"
3
+ import type { DataSlot, SlotLabels } from "@/modules/types"
4
+ {{shapeDeclarations}}
5
+ /** One read value as {{Name}} draws it. */
6
+ export interface {{Name}}Item {
7
+ readonly id: string
8
+ {{itemShapeMember}}}
9
+
10
+ /** Copy, the one read and the retry the connected {{Name}} supplies; atoms only. */
11
+ export interface {{Name}}BaseProps {
12
+ {{shapeMember}} readonly props: {
13
+ readonly title: string
14
+ {{shapeLabelsMember}} readonly item: DataSlot<{{Name}}Item>
15
+ readonly slotLabels: SlotLabels
16
+ }
17
+ readonly on: { readonly retry: () => void }
18
+ }
19
+
20
+ /** A value of the same shape as the read: the skeleton draws the ready tree from it{{placeholderShapeDoc}}. */
21
+ export const {{NAME_UPPER}}_PLACEHOLDER: {{Name}}Item = { id: ""{{placeholderShape}} }
22
+ {{shapeBody}}
23
+ /** Draws {{Name}} from atoms: its read renders every status through SlotView, inside the unchanged surface. */
24
+ export const {{Name}}Base = (props: {{Name}}BaseProps) => (
25
+ <SurfaceCard label={props.props.title}>
26
+ <SlotView
27
+ slot={props.props.item}
28
+ placeholder={{{NAME_UPPER}}_PLACEHOLDER}
29
+ labels={props.props.slotLabels}
30
+ onRetry={props.on.retry}
31
+ >
32
+ {(item, isSkeleton) => {{readyTree}}}
33
+ </SlotView>
34
+ </SurfaceCard>
35
+ )
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import { useTranslations } from "next-intl"
4
+ import { mapSlot, slotOfSwr, useSlotLabels } from "@/hooks/slot"
5
+ import { useQuery{{Read}}Swr } from "@/hooks/swr"
6
+ import { {{componentImports}} } from "./component"
7
+ {{componentTypeImport}}
8
+ /** Input of {{Name}}: the id of the value it reads. */
9
+ type {{Name}}Props = {
10
+ readonly id: string
11
+ }
12
+ {{shapeOfHelper}}
13
+ /** The connected {{Name}}: folds its one read into a slot, {{connectedDoc}}resolves every label and the retry, and renders its pure half. */
14
+ export const {{Name}} = (props: {{Name}}Props) => {
15
+ const t = useTranslations("{{nameCamel}}")
16
+ const slotLabels = useSlotLabels()
17
+ const read = useQuery{{Read}}Swr(props.id)
18
+ const item = mapSlot(slotOfSwr(read), (value) => ({ id: value.id{{itemShapeMapped}} }))
19
+ return (
20
+ <{{Name}}Base
21
+ {{shapeAttribute}} props={{
22
+ title: t("title"),
23
+ {{shapeLabelsResolved}} item,
24
+ slotLabels: slotLabels(t("empty")),
25
+ }}
26
+ on={{ retry: () => void read.mutate() }}
27
+ />
28
+ )
29
+ }
@@ -0,0 +1,29 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { {{Name}}Base } from "../../src/components/blocks/{{Name}}/component"
3
+ import type { {{Name}}BaseProps } from "../../src/components/blocks/{{Name}}/component"
4
+ import { loadingSlot } from "../../src/hooks/slot/slot.shared"
5
+
6
+ /** The ready read every story draws: one value, settled. */
7
+ const READY: {{Name}}BaseProps["props"] = {
8
+ title: "{{title}}",
9
+ {{shapeLabelsFixture}} item: { items: { id: "{{nameKebab}}-1"{{fixtureShape}} } },
10
+ slotLabels: { empty: "Nothing yet", forbidden: "Not allowed", error: "Could not load", retry: "Try again" },
11
+ }
12
+
13
+ /** The pure half of {{Name}} drawn from fixtures: {{storiesDoc}} and the loading twin of the first. */
14
+ const meta = {
15
+ title: "{{Name}}",
16
+ component: {{Name}}Base,
17
+ args: { {{shapeArg}}props: READY, on: { retry: () => undefined } },
18
+ } satisfies Meta<typeof {{Name}}Base>
19
+
20
+ export default meta
21
+
22
+ type Story = StoryObj<typeof meta>
23
+ {{readyStories}}
24
+
25
+ /** The loading twin of {{firstStory}}: the same tree drawn from the placeholder with every leaf resting. */
26
+ export const {{firstStory}}Skeleton: Story = {
27
+ args: { {{shapeArg}}props: { ...READY, item: loadingSlot() } },
28
+ parameters: { dna: { pair: "{{firstStory}}", phase: "loading" } },
29
+ }
@@ -0,0 +1,23 @@
1
+ import { useState } from "react"
2
+ import type { ReactNode } from "react"
3
+ import { Button } from "@starci/grammar/common"
4
+
5
+ /** The resolved labels of the toggle and the content it shows; the branch owns only whether it is open. */
6
+ type {{Name}}Props = {
7
+ readonly showLabel: string
8
+ readonly hideLabel: string
9
+ readonly children: ReactNode
10
+ }
11
+
12
+ /** Holds its intrinsic state alone (open or closed); it reads no data, resolves no copy and renders below a client block. */
13
+ export const {{Name}} = (props: {{Name}}Props) => {
14
+ const [isOpen, setIsOpen] = useState(false)
15
+ return (
16
+ <>
17
+ <Button intent="neutral" onPress={() => setIsOpen((current) => !current)}>
18
+ {isOpen ? props.hideLabel : props.showLabel}
19
+ </Button>
20
+ {isOpen ? props.children : null}
21
+ </>
22
+ )
23
+ }
@@ -0,0 +1,18 @@
1
+ import { Text } from "@starci/grammar/common"
2
+
3
+ /** The resolved fields {{Name}} draws; its block resolves the copy and passes the waiting flag down. */
4
+ type {{Name}}Props = {
5
+ readonly term: string
6
+ readonly value: string
7
+ readonly isSkeleton: boolean
8
+ }
9
+
10
+ /** A closed arrangement of Grammar parts: a term and its value, each resting as itself while its block waits. */
11
+ export const {{Name}} = (props: {{Name}}Props) => (
12
+ <>
13
+ <Text tone="muted" isSkeleton={props.isSkeleton}>
14
+ {props.term}
15
+ </Text>
16
+ <Text isSkeleton={props.isSkeleton}>{props.value}</Text>
17
+ </>
18
+ )
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react"
2
+ import { PrimaryRailLayout } from "@starci/grammar/common"
3
+ {{blockImports}}
4
+ /** Framework-layout boundary input: the routed page of the {{segment}} segment and nothing else. */
5
+ type {{Name}}Props = { readonly content: ReactNode }
6
+
7
+ /**
8
+ * The {{segment}} segment's frame: Grammar's PrimaryRailLayout places the routed page and {{railDoc}}. A frame only places
9
+ * regions and never insets a page; the Page inside owns its inset and rhythm.
10
+ */
11
+ export const {{Name}} = (props: {{Name}}Props) => {{frame}}
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react"
2
+ import { {{Name}} } from "@/features/layouts/{{Name}}"
3
+
4
+ type LayoutProps = { readonly children: ReactNode }
5
+
6
+ /** The {{segment}} segment's layout slot: it mounts {{Name}} around the routed page and nothing else. */
7
+ const Layout = (props: LayoutProps) => <{{Name}} content={props.children} />
8
+
9
+ export default Layout
@@ -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 } } }