@starci/hfs 4.7.33 → 4.7.35

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 (48) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +8 -11
  3. package/lint/run.mjs +70 -29
  4. package/package.json +2 -2
  5. package/runtime/knowledge/grammars/index.yaml +102 -0
  6. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  7. package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
  8. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  9. package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
  10. package/runtime/knowledge/hfs/rules.yaml +133 -83
  11. package/runtime/knowledge/hfs/slots.yaml +3 -3
  12. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  13. package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
  14. package/runtime/modules/kernel/failure-codes.yaml +40 -20
  15. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  16. package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
  17. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  18. package/runtime/scripts/gates/gate.mjs +3 -36
  19. package/runtime/scripts/gates/output.mjs +40 -0
  20. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  21. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  22. package/runtime/scripts/hfs/check.mjs +1 -1
  23. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  25. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  26. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  28. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  29. package/runtime/scripts/hfs/slots.mjs +1 -0
  30. package/runtime/scripts/hfs/view.mjs +1 -0
  31. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  32. package/runtime/scripts/lib/example-refs.mjs +1 -1
  33. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  34. package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
  35. package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
  36. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  37. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  38. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  39. package/scaffold/app.mjs +7 -5
  40. package/src/main.mjs +26 -13
  41. package/stack/config.mjs +2 -1
  42. package/stack/render.mjs +24 -3
  43. package/sync/index.mjs +8 -2
  44. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  45. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  46. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  47. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  48. package/templates/fe/tool-config/eslint.config.mjs +4 -1
@@ -0,0 +1,1159 @@
1
+ schema: starci/knowledge-source@1
2
+ id: grammar.starci.playbook
3
+ title: StarCi Core — playbook
4
+ purpose: |
5
+ Which idioms a business shape asks for, and what a supplied reference is allowed to contribute to
6
+ that answer. The idioms themselves are in idioms.yaml; what the package publishes is in DNA.yaml.
7
+ A shape not listed here is not a house idiom. It may still be composed for the approved product
8
+ direction when existing Common semantics, family anatomy and product facts can express it; only
9
+ promotion into the family's reusable idiom set belongs to the family owner.
10
+ Shapes marked `since: "0.5.0"` compose the forms, overlay and navigation renderers Common added in
11
+ 0.5.0. They name renderers directly (`composition`) rather than idioms, because no product source
12
+ has yet shown them twice; `guidance` holds the rules every Core app built on them follows.
13
+ appliesTo:
14
+ - grammar
15
+ family: starci
16
+ provenance:
17
+ publicEntry: "@starci/grammar/common"
18
+ package: "@starci/grammar"
19
+ version: "0.5.0"
20
+ captured: "2026-09-23"
21
+ limitations: |
22
+ Use this playbook only when this family is selected in the current .starciwork specification.
23
+ Examples below are historical references; verify actually installed exports (prefer
24
+ @starci/grammar/common as the current public entry where supported), renderers and CSS before
25
+ promising a capability. Reading steps below are reasoning within one op, not an auto-running op
26
+ chain. Do not invent Grammar component APIs absent from DNA/installed source.
27
+ shapes:
28
+ - id: purchase-decision-page
29
+ title: Purchase decision page — one offer, one price, one commitment (subscriptions)
30
+ referenceMayContribute: |
31
+ Keep: the order of regions (what is read before the price), which single decision dominates, and
32
+ what folds away instead of competing with it. Never crosses over: page layout, brand, palette,
33
+ type scale, component anatomy, or any control the reference draws that this Grammar does not
34
+ publish.
35
+ idiomSequence:
36
+ - idiom: joined-bands-flush-card
37
+ role: Explanatory column in primary of PrimaryRailLayout
38
+ - idiom: neutral-summary-band
39
+ role: A neutral band opens the card with its summary for its intro line
40
+ - idiom: generated-art-band
41
+ role: Generated art is a band, not a card for the journey asset
42
+ - idiom: title-supporting-line
43
+ role: Title and one supporting line for each included outcome
44
+ - idiom: one-highlighted-card
45
+ role: One highlighted card on the decision itself
46
+ - idiom: bottom-action-band
47
+ role: The card's one action closes the bottom band for the purchase
48
+ - idiom: pending-same-tree
49
+ role: Pending is the same tree, resting across the offer read
50
+ notes: |
51
+ Secondary explanation folds away; whether it folds into a disclosure is a
52
+ DIRECTION_CHOICE_REQUIRED (seen once). Decision card sits in rail.
53
+
54
+ - id: learner-dashboard
55
+ title: Learner dashboard — many peer sections, one intended next step
56
+ referenceMayContribute: |
57
+ Keep: which sections exist and their reading order, and which single section is the reader's
58
+ next action. Never crosses over: grid geometry, card chrome, densities, or a section that would
59
+ need a renderer this family does not publish.
60
+ idiomSequence:
61
+ - idiom: joined-bands-flush-card
62
+ role: Per section
63
+ - idiom: neutral-summary-band
64
+ role: Where the section states a measure
65
+ - idiom: title-supporting-line
66
+ role: Inside every row
67
+ - idiom: bottom-action-band
68
+ role: Where a section leads somewhere
69
+ - idiom: pending-same-tree
70
+ role: For every read, one slot per api through guidance state-recipes
71
+ - idiom: one-highlighted-card
72
+ role: Across the page exactly once, on the resume or next-step section
73
+
74
+ - id: sign-in
75
+ title: Sign-in — one surface, several journeys, one submit per step
76
+ referenceMayContribute: |
77
+ Keep: the order of the ways in (shortcut before form, or the reverse), which journeys share one
78
+ surface, and where the way to the other journey sits. Never crosses over: provider branding,
79
+ illustration, field chrome, or a social-login row this product does not have a provider for.
80
+ idiomSequence:
81
+ - idiom: title-supporting-line
82
+ role: Surface header (Heading + muted subtitle)
83
+ - idiom: single-column-form-stack
84
+ role: Per step, one Button variant="primary" type="submit" each
85
+ - idiom: pending-same-tree
86
+ role: Through isPending and isDisabled rather than a replaced tree
87
+ notes: |
88
+ The way onward as TextAction, last. Whether the provider shortcut precedes the form is seen
89
+ once; carry the reference's order and say so.
90
+
91
+ - id: account-settings-form
92
+ title: Settings or profile form — named groups, one submit, server errors in place
93
+ since: "0.5.0"
94
+ referenceMayContribute: |
95
+ Keep: which groups exist, their order, and which fields are required. Never crosses over: field
96
+ chrome, validation copy style, or a control the package does not publish.
97
+ composition:
98
+ - renderer: Form
99
+ role: |
100
+ The boundary: `label` (form landmark), `onSubmit(FormData)`, `validationErrors` keyed by field
101
+ `name` for server-side refusals, `isPending` while the submit is in flight
102
+ - renderer: Fieldset
103
+ role: One per named group (`legend`, optional `description`, cross-field `errorMessage`)
104
+ - renderer: "Select / ComboBox / Textarea / NumberField / SearchField / DateField / DatePicker"
105
+ role: |
106
+ Each control carries its own `label`, `description`, `errorMessage`, `isRequired`, `name` —
107
+ the shared Field contract; a placeholder is never the name
108
+ - renderer: "Checkbox / CheckboxGroup / RadioGroup / Switch / SegmentedControl / Slider"
109
+ role: |
110
+ Choices; a Switch is an immediate setting, a Checkbox a value submitted with the form
111
+ - renderer: Field
112
+ role: |
113
+ Only for a control Grammar does not publish (a third-party widget, OtpInput, a native input):
114
+ its render prop hands over the id and `aria-describedby`/`aria-invalid` so the control is
115
+ announced like a Grammar one
116
+ - renderer: Button
117
+ role: One `variant="primary" type="submit"` after the last group; `isPending` mirrors the Form's
118
+ notes: |
119
+ The base `Input` still speaks `hint`/`isError`; the 0.5.0 controls speak
120
+ `description`/`errorMessage`/`isInvalid`. Do not mix the two vocabularies in one form without
121
+ checking that the Form's `validationErrors` reaches the control. In Core, fields are white fills
122
+ with a transparent border (see family.yaml `fields-on-a-surface`): on a top-level SurfaceCard
123
+ verify the rendered field edge.
124
+
125
+ - id: destructive-confirmation
126
+ title: Destructive confirmation — one irreversible decision
127
+ since: "0.5.0"
128
+ composition:
129
+ - renderer: AlertDialog
130
+ role: |
131
+ `tone="negative"` (the default), `title` naming the consequence, `confirmLabel` and
132
+ `cancelLabel` in the product's words; `onConfirm` may return a promise, which keeps the confirm
133
+ pending and the dialog open until it settles
134
+ - renderer: Button
135
+ role: The trigger, inside the Core root; never a red Button (Core has no destructive variant)
136
+ notes: |
137
+ Initial focus lands on the cancel action and an outside press never dismisses. A reversible
138
+ action is not a confirmation: act, then offer undo through a Toast `action`.
139
+
140
+ - id: secondary-task-sheet
141
+ title: Secondary task beside the page — filters, details, an edit
142
+ since: "0.5.0"
143
+ composition:
144
+ - renderer: Drawer
145
+ role: |
146
+ `placement="right"` at wide widths; `placement="bottom"` (grab handle shown by default) when
147
+ the task is thumb-driven on a phone. `footer` may take a render function receiving `close`
148
+ - renderer: Dialog
149
+ role: When the task must block the page and is short (`size` sm/md/lg/full)
150
+ notes: |
151
+ Both portal into the Core root and restore focus to their trigger. One overlay at a time; a
152
+ Popover or DropdownMenu opened from inside a Drawer stays inside the same root.
153
+
154
+ - id: app-shell-mobile-first
155
+ title: App shell — WorkspaceShell, navbar and sidebar drawer
156
+ since: "0.5.0"
157
+ ownership: FE-RULE-OWNERSHIP
158
+ knowledge: knowledge/ui/composition/app-shell.yaml
159
+ composition:
160
+ - renderer: CoreGrammarRoot
161
+ role: The single root around everything below
162
+ - renderer: WorkspaceShell
163
+ role: |
164
+ `viewport="fixed"` realizes FE-APP-SHELL-1: exactly 100dvh, a sticky navbar, optional sidebar,
165
+ and independently scrolling navigation and primary content. The document never scrolls.
166
+ Under viewport="fixed", navigation resolves navigationVisibility to wide and provides
167
+ navigationDrawer or a compactHeader containing a left Drawer with Sidebar presentation="drawer".
168
+ Both opener paths default visibility to wide; an explicit visibility also resolves to wide.
169
+ Under viewport="fixed",
170
+ navigationTrack defaults to intrinsic so a collapsed sidebar leaves no empty track.
171
+ - renderer: NavigationFeatureNav
172
+ role: |
173
+ The navbar may use this renderer or the WorkspaceShell header with TopBar. Its family CSS
174
+ supplies sticky top placement, a 4rem band, a solid surface and a full-width bottom separator.
175
+ - renderer: Sidebar
176
+ role: |
177
+ Desktop navigation uses the expanded or collapsed rail; the left mobile Drawer composes
178
+ `presentation="drawer"`. Its label, destinations and selectedKey come from the application.
179
+ - renderer: Toaster
180
+ role: Once, last child of the root
181
+ recipes:
182
+ - id: navbar-controls
183
+ implements: [FE-APP-SHELL-2]
184
+ compose: |
185
+ Every bar control uses `size="control"`, the 2.75rem shared height. Icon uses the family
186
+ size-5 token (20px) throughout the region. SearchField or its trigger shows Ctrl K and opens
187
+ on Ctrl/Cmd+K; inside a surface its variant resolves through the nesting context.
188
+ - id: workspace-identity
189
+ implements: [FE-APP-SHELL-2]
190
+ compose: |
191
+ IdentityLockup sits inside PlainPressable size="control". Its square logo mark has a
192
+ transparent background, no radius and object-fit contain, with a monogram fallback. The title
193
+ has a muted role subtitle and no caret. PlainPressable has no button shape or hover effect;
194
+ press uses scale(0.96), focus remains visible and reduced motion removes the zoom.
195
+ - id: navbar-right-cluster
196
+ implements: [FE-APP-SHELL-3]
197
+ compose: |
198
+ In order: a round tertiary IconButton with the Heroicons Language glyph opening a
199
+ single-select DropdownMenu, Switch size="control" with sun/moon size-5 thumb icons and
200
+ isLabelHidden, a bell IconButton with count badge opening Popover, then a circle-only
201
+ AvatarButton size="control" opening the account DropdownMenu. AvatarButton uses uploaded src
202
+ first and otherwise a DiceBear fallback seeded by userId. DropdownMenu preserves HeroUI's
203
+ anatomy and default selection indicator. The supported language labels use the "Vietnamese"
204
+ and "English" entries in modules/i18n/messages/app-shell.yaml; each language keeps its autonym.
205
+ - id: account-menu
206
+ implements: [FE-APP-SHELL-3]
207
+ compose: |
208
+ A workspace section shows identity marks and single selection. An account section shows an
209
+ icon, account name and description email · role. The sign-out item and every destructive item
210
+ use `tone="danger"`; the family prop realizes the universal danger semantics. Sections
211
+ receive automatic separators. Localized sign-out copy comes from
212
+ modules/i18n/messages/app-shell.yaml.
213
+ - id: mobile-navigation
214
+ implements: [FE-APP-SHELL-4]
215
+ compose: |
216
+ Below md the navbar holds identity, search trigger, bell and avatar; language and theme move
217
+ into the drawer. The compactHeader band directly below it is also 4rem, with an edge-to-edge
218
+ separator and one vertically centred round tertiary IconButton carrying the Heroicons
219
+ ellipsis at size-5. It opens Drawer placement="left" containing Sidebar
220
+ presentation="drawer" with every group plus language and theme. The drawer closes on item,
221
+ backdrop or Escape, traps focus and scrolls independently. The opener band hides at desktop.
222
+ Inline navigation is hidden below md with navigationVisibility="wide"; navigationDrawer or
223
+ the compactHeader containing the left Drawer and its drawer Sidebar supplies the opener.
224
+ The shell has neither tabs in the opener band nor a BottomNav.
225
+ - id: create-actions
226
+ implements: [FE-APP-SHELL-5]
227
+ compose: |
228
+ The create action stays on its page. The static matcher reads appShell.product from
229
+ modules/goal/source-phrases.yaml; the navbar never receives that page command.
230
+ - id: sidebar-rail
231
+ implements: [FE-APP-SHELL-6]
232
+ compose: |
233
+ Sidebar fills the height below the navbar, uses the surface background in both themes and
234
+ scrolls independently. Groups use translated headings such as Main, Operations and Settings
235
+ from modules/i18n/messages/app-shell.yaml. The expanded item has a size-5 icon, label and
236
+ Grammar current-page treatment. A keyboard-accessible toggle collapses it into icon-only
237
+ destinations with size-5 icons in 2.75rem targets, current-page highlighting, count badges
238
+ and hover/focus tooltips. Group order is stable, spacing replaces group headings and
239
+ settings-type groups stay at the end. The application persists collapse state per user.
240
+ - id: conversation-panel
241
+ implements: [FE-APP-SHELL-7]
242
+ compose: |
243
+ The panel has its title, a nested SearchField and a filter Tabs row, then one list sorted by
244
+ latest message. The approved reference categories are All, Groups, Customers and Unread
245
+ when those categories exist in the product; labels come from modules/i18n/messages/ui.yaml
246
+ and modules/i18n/messages/app-shell.yaml. ListBox panel alignment keeps rows and section
247
+ captions on the same content edge without padding wrappers. A row shows avatar, name,
248
+ trailing time, last message and unread badge; the open row is highlighted. Group identity
249
+ uses AvatarGroup layout="grid": a 2×2 composite with the first three members and a +N
250
+ overflow count when more than four exist, plus a group icon before the name.
251
+ notes: |
252
+ FE-RULE-OWNERSHIP in knowledge/patterns/fe/index.yaml separates universal app-shell semantics
253
+ from these family APIs, values, shapes and effects. @starci/eslint-grammar-starci owns their
254
+ code checks. Inspect installed Grammar exports and the running shell when proving the recipe.
255
+
256
+ readingSteps:
257
+ - id: business-shape-first
258
+ order: 1
259
+ title: Business shape first
260
+ instruction: |
261
+ Name the shape and the one outcome the surface exists to produce. The shape selects the idiom
262
+ sequence; nothing else does.
263
+ - id: then-reference
264
+ order: 2
265
+ title: Then the reference, if the request supplied one
266
+ instruction: |
267
+ A reference contributes region order, which decision dominates, and what folds away. It never
268
+ contributes layout, brand, palette, or component anatomy — those are already owned, by this
269
+ family and by knowledge/ui. A reference that can only be honoured by breaking an idiom is
270
+ evidence about the reference, not about StarCi.
271
+ - id: then-idioms
272
+ order: 3
273
+ title: Then the idioms
274
+ instruction: |
275
+ Composed in the order the shape's row gives, each one taken whole: an idiom is a relationship,
276
+ and half of it is a different shape.
277
+ - id: compare-dna
278
+ order: 4
279
+ title: Compare DNA with actually installed source
280
+ instruction: |
281
+ Confirm named renderers/props against DNA.yaml and installed exports (public entry
282
+ @starci/grammar/common where supported), and read the gaps in family.yaml before promising a
283
+ capability. A composition that needs a recorded gap is not a direction yet.
284
+ - id: resolve-product-composition
285
+ order: 5
286
+ title: Then resolve the remaining product composition
287
+ instruction: |
288
+ Reuse an established idiom where it fits; otherwise compose existing Common semantics and family
289
+ anatomy, extend an existing owner when evidence shows its declared gap, and add a new owner only
290
+ for an evidenced gap. A shape absent from this table and a shape seen once are evidence limits
291
+ on house style, not reasons to stop creative work already inside the approved product scope.
292
+
293
+ authoritySplit:
294
+ directionChoiceRequired: |
295
+ DIRECTION_CHOICE_REQUIRED is reserved for a material product choice whose acceptable options lead
296
+ to different outcomes and whose answer is absent from the request. A reference/idiom conflict is
297
+ resolved by the authority split: product facts and approved region order come from the request,
298
+ Common semantics come from knowledge/ui, and family anatomy and reusable style come from the
299
+ selected Grammar source. State assumptions for minor in-scope choices; when a material choice has
300
+ no answer, ask the user. Do not invent a fallback algorithm or run the next op.
301
+ imageryClarification:
302
+ date: "2026-09-05"
303
+ guidance: |
304
+ Imagery can highlight the key idea, explain content, strengthen recognition or create a relevant,
305
+ evocative focal point. Deliberate visual emphasis is a concrete role; an image need not be a
306
+ functional diagram to earn its place. Name the subject, the idea or claim it highlights, and the
307
+ intended order of attention in the selected .starciwork design node. Judge its scale, contrast and
308
+ crop on the rendered surface: it should support that hierarchy without obscuring the content or
309
+ competing with the primary action. Blank space alone is never a reason to add an image; filling
310
+ space or balancing density without a content or emphasis role does not qualify. Record no image
311
+ when imagery would not improve the composition.
312
+ limitation: |
313
+ Extending or adding an owner is not authorized by this playbook; it needs an op with explicit
314
+ library-edit scope. Historical image notes are design references, not decisions for the current
315
+ product.
316
+ guidance:
317
+ - id: nested-controls-and-panel-lists
318
+ ownership: FE-RULE-OWNERSHIP
319
+ implements: [ANATOMY-2, PADDING-0]
320
+ requirement: |
321
+ SurfaceCard (always painted), SurfaceListCard, a painted SurfaceAccordionCard, Sidebar, Dialog,
322
+ AlertDialog, Drawer and Popover establish the surface nesting context; Rail and an unpainted
323
+ SurfaceAccordionCard pass their parent's context through. Input, SearchField, Textarea,
324
+ NumberField, DateField, TimeField, Select, ComboBox, DatePicker, DateRangePicker, Checkbox,
325
+ CheckboxGroup, RadioGroup, OtpInput and PressableField resolve variant="secondary" inside it and
326
+ variant="primary" outside. Compose the surface and omit the field variant.
327
+ Switch and Slider have no layer variant; a Radio follows its RadioGroup. ChatWorkspace's
328
+ compact drawer also establishes the context. SurfaceCard and bounded SurfaceAccordionCard derive
329
+ data-grammar-surface-depth from the parent context. The top layer is raised and a nested layer is
330
+ subordinate; never raise both. The family DNA owns their surface paint.
331
+ FE_NESTED_VARIANT rejects a static field variant
332
+ anywhere in app code and primary Tabs inside a painted surface; FE_GRAMMAR_LAYOUT_ONLY rejects
333
+ application paint utilities, arbitrary paint properties and inline style paint.
334
+ DRAW_NESTED_VARIANT checks the corresponding rendered variant and
335
+ surface stamps.
336
+ ListBox resolves inset="panel" inside that context; callers may select the same value explicitly
337
+ elsewhere. Its rows and sections captions share the panel edge. SurfaceListCard owns its list
338
+ shell with no added inset; SurfaceCard composition="joined" owns flush bands. A grouping wrapper
339
+ adds no padding. An extra px-*, ps-* or pe-* around ListBox or a section caption stacks the inset
340
+ and fails FE_LIST_PANEL_PADDING.
341
+ rationale: |
342
+ ANATOMY-2 owns nested separation and PADDING-0 owns the single content edge. Their props and
343
+ variant values belong here under FE-RULE-OWNERSHIP in knowledge/patterns/fe/index.yaml; R318
344
+ executes the composition checks in the universal @starci/eslint-canon-fe because they hold for
345
+ every family that publishes the shared surface and field API.
346
+ - id: overlays-inside-the-root
347
+ requirement: |
348
+ Every overlay trigger (Dialog, AlertDialog, Drawer, Popover, DropdownMenu, and the Select,
349
+ ComboBox and DatePicker fields) must render inside `CoreGrammarRoot`. They portal into the nearest
350
+ `.grammar-common-root`; a trigger in a separate React tree or outside the root opens its overlay
351
+ in `<body>`, where no Core token, theme or forced-colours rule applies.
352
+ rationale: |
353
+ Core's DNA is scoped to `[data-grammar-family="core"]`; an overlay is only Core while it is a DOM
354
+ descendant of that scope.
355
+ - id: one-toaster-placed-clear-of-chrome
356
+ requirement: |
357
+ Mount exactly one `Toaster` (with `label` and `dismissLabel`) inside the root and add toasts with
358
+ `toastQueue.add({ title, description, tone, action })`. Keep the default `bottom-end` on a page
359
+ with no fixed bottom chrome; choose a placement clear of any page-owned fixed content. A toast
360
+ with an `action`, or `tone` negative/pending, persists until dismissed.
361
+ rationale: |
362
+ A second Toaster duplicates announcements; fixed content must not cover an action.
363
+ - id: forms-share-the-field-contract
364
+ requirement: |
365
+ Compose forms from the Field-contract controls inside `Form` (and `Fieldset` for named groups);
366
+ reach for `Field` only to give a non-Grammar control the same label/description/error anatomy.
367
+ Server refusals go in `Form validationErrors` by field `name`, not in a separate banner.
368
+ rationale: |
369
+ One contract means one error location a screen reader can find, and one set of
370
+ `data-grammar-field-*` hooks every family paints.
371
+ - id: mobile-chrome-by-width
372
+ requirement: |
373
+ Follow FE-APP-SHELL-4 in knowledge/ui/composition/app-shell.yaml for mobile navbar content,
374
+ the subnav trigger row and the Sidebar drawer.
375
+ rationale: |
376
+ The shell canon owns responsive navigation composition.
377
+ - id: state-recipes
378
+ title: stateRecipes — the one recipe for per-slot data status
379
+ requirement: |
380
+ A data status — loading, unauthorized, forbidden, error, empty — belongs to one slot, the data of one api, and
381
+ renders only through the product's one `SlotView` composite with the recipes below. A data status
382
+ is never drawn and never a shape: a surface's `XState` lists drawn shapes only
383
+ (`starci-fe/no-data-status-shape`), and a Block's `component.tsx` never branches on `isLoading`,
384
+ `isError` or `isForbidden` itself (`starci-grammar/slot-status-through-slotview`). Only a Block owns
385
+ slots; a page, layout or overlay has no data status. A block with several apis carries one slot
386
+ per api and each resolves its status independently. The block keeps its SurfaceCard and label in
387
+ every status; the recipe replaces only that slot's region. Every product built on this family
388
+ applies it.
389
+ rationale: |
390
+ One recipe per status keeps every block's loading, 403, failure and empty identical, and keeps
391
+ the owner's approved drawings free of states nobody drew.
392
+ reference:
393
+ example: examples/shape-slot
394
+ files:
395
+ - apps/shape-slot/src/components/composites/SlotView/index.tsx # SlotView: the recipe, in status order
396
+ - apps/shape-slot/src/modules/slot/index.ts # Slot, toSlot (403 is its own status), SlotLabels
397
+ - apps/shape-slot/src/hooks/slot/useSlotLabels.ts # shared forbidden/error/retry copy + the slot's empty sentence
398
+ codeExample: examples/index.yaml#connected-block
399
+ consolidates:
400
+ - idioms.yaml pending-same-tree
401
+ - knowledge/ui/composition/state.yaml STATE-2, STATE-3
402
+ - knowledge/ui/composition/feedback.yaml FEEDBACK-1, FEEDBACK-2
403
+ - DNA.yaml Skeleton, EmptyNotice, Alert
404
+ order: [loading, unauthorized, forbidden, error, empty, ready]
405
+ recipes:
406
+ - status: loading
407
+ uiRules: [STATE-2]
408
+ render: |
409
+ The ready tree, drawn from placeholder data of the slot's type, in the shape the unit's
410
+ `xDefaultState` names, with isSkeleton on every owner: same card, same bands, same row count
411
+ (idiom pending-same-tree). Never a spinner and never a different tree. The skeleton renders
412
+ inert geometry and states no value; an unknown number is a bar, never a rendered zero.
413
+ skeletonMap:
414
+ - part: Text line or Heading
415
+ render: "`isSkeleton` on the owner: one bar at that type-scale step's line height, as long as the placeholder text"
416
+ - part: Media (Image, MediaFrame viewport)
417
+ render: "`Skeleton shape=\"rect\"` at the frame's `ratio` (square, landscape, wide); `shape=\"circle\"` for an avatar. A block at the ratio, never a collapsed box"
418
+ - part: Button
419
+ render: |
420
+ A bar at the Button's height with the Button's pill corner (family.yaml button-pill):
421
+ `isSkeleton` where the installed Button publishes it, otherwise `Skeleton shape="rect"` in
422
+ the Button's box. It never fires (idiom pending-same-tree action-must-not-fire)
423
+ - part: A region that exists only on ready data (a disclosure of the loaded lines)
424
+ render: Absent while isSkeleton (STATE-3)
425
+ - status: unauthorized
426
+ uiRules: [STATE-3]
427
+ render: |
428
+ 401 is its own status, never forbidden and never an error. The slot shows one sign-in prompt in
429
+ place: `EmptyNotice message={labels.unauthorized} actionLabel={labels.signIn}
430
+ actionVariant="primary" onAction={signIn}`, where `signIn` opens the product's sign-in route and
431
+ returns to this address. The prompt discloses none of the slot's data. A secondary slot hides, as
432
+ forbidden does, so a page whose reads all need a session shows the prompt once, in its primary slot.
433
+ - status: forbidden
434
+ uiRules: [STATE-3]
435
+ render: |
436
+ 403 is its own status, never an error (`toSlot`). The slot's role decides: a secondary slot
437
+ uses `hide` — it unmounts with its wrapper and leaves nothing focusable; a primary slot uses
438
+ `notice` — `EmptyNotice message={labels.forbidden}` in place. A primary slot is never hidden
439
+ and a secondary slot never shows a notice.
440
+ - status: error
441
+ uiRules: [FEEDBACK-2]
442
+ render: |
443
+ Retry at the slot, re-running that slot's read only (the query's `mutate`), never a page
444
+ reload. Block form, where the slot is a region: `EmptyNotice message={labels.error}
445
+ actionLabel={labels.retry} actionVariant="secondary" onAction={retry}` with `isActionPending`
446
+ bound while the retry runs. Inline form, where the slot is one line inside a row or band too
447
+ small for a notice: `Alert tone="negative"` in the slot's place, carrying the retry action. A
448
+ command that is unsafe to repeat is not retried (FEEDBACK-2 case-4); a rejected field value
449
+ is a field error (FEEDBACK-1), never a slot status.
450
+ - status: empty
451
+ uiRules: [FEEDBACK-2, STATE-3]
452
+ render: |
453
+ `EmptyNotice message={labels.empty}` with the slot's own empty sentence; `actionLabel` and
454
+ `onAction` only when a real next step exists, otherwise both are omitted.
455
+ - status: ready
456
+ render: The slot's items through the same tree, isSkeleton false.
457
+ notFound: |
458
+ A route whose resource does not exist calls `notFound()` in its route adapter (FE-FUNCTION-7
459
+ case-2). 404 is never a page state, a shape or a slot status.
460
+ copy: |
461
+ unauthorized, signIn, forbidden, error and retry come from the shared `slot` message namespace; each slot adds its own
462
+ empty sentence. The connected half resolves them into `SlotLabels`; no status string is written
463
+ in a component.
464
+
465
+ presentation:
466
+ glyphs:
467
+ ownership: FE-RULE-OWNERSHIP
468
+ implements: FE-ICON
469
+ source: knowledge/hfs/fe-stack.yaml
470
+ requirement: |
471
+ The glyph sources and cuts declared by the stack catalog are realized through the Icon leaf.
472
+ StarCi's Grammar lint checks that concrete boundary and its measured icon scale. Verify the
473
+ selected region's value against the family DNA and styles rather than inventing an app value.
474
+ A menu item's brand mark is passed as DropdownMenuItem.mark with iconSource omitted; the public
475
+ type makes them mutually exclusive and Grammar sizes the mark in the icon's leading slot.
476
+ groupIdentity:
477
+ ownership: FE-RULE-OWNERSHIP
478
+ implements: COLLECTION-3
479
+ requirement: |
480
+ Compose AvatarGroup layout="grid" at one Avatar size, including size="control". Two members
481
+ split the round mark; three show one member above two below; four occupy a 2 by 2 grid. A hairline
482
+ separates cells. Above four members the first three remain visible and the fourth cell shows
483
+ the remaining-member count as +N. Each member uses Avatar's image, initials or DiceBear fallback.
484
+ A single person uses Avatar. Pass the uploaded avatar as `src`; otherwise pass the user or identity
485
+ id as `seed` on Avatar, AvatarButton or each AvatarGroup member, and Grammar draws its fixed
486
+ deterministic DiceBear style; with neither, Grammar uses initials. Apps neither declare nor import
487
+ `@dicebear/*`. Apps supply member identity data; the renderer owns the geometry.
488
+ padding:
489
+ ownership: FE-RULE-OWNERSHIP
490
+ knowledge: knowledge/ui/presentation/padding.yaml
491
+ ownerVocabulary:
492
+ owners:
493
+ - name: App
494
+ meaning: The boundary belongs to the application
495
+ applicationWrites: The class
496
+ - name: A component name
497
+ meaning: Common already applies the inset inside that component
498
+ applicationWrites: Nothing. Compose it
499
+ - name: —
500
+ meaning: Common exposes no public path for this boundary
501
+ applicationWrites: The class, recorded as a workaround
502
+ notes: |-
503
+ Each case names who owns the inset. The owner decides whether the application writes a class at all.
504
+
505
+ Writing a class where a component is the owner is `APP_REIMPLEMENTATION`. Reaching into a Grammar
506
+ component with a selector or a passed class to change its inset is `APP_OVERRIDE`.
507
+ scale:
508
+ steps:
509
+ - id: PADDING-0
510
+ class: p-0
511
+ value: "0"
512
+ - id: PADDING-1
513
+ class: p-1
514
+ value: .25rem
515
+ - id: PADDING-2
516
+ class: p-2
517
+ value: .5rem
518
+ - id: PADDING-3
519
+ class: p-3
520
+ value: .75rem
521
+ - id: PADDING-4
522
+ class: p-4
523
+ value: 1rem
524
+ - id: PADDING-5
525
+ class: p-6
526
+ value: 1.5rem
527
+ - id: PADDING-6
528
+ class: p-8
529
+ value: 2rem
530
+ notes: |-
531
+ `COMMON_SPACING_SCALE` is closed. The rule ID is the ordinal position on that scale. It is not the
532
+ Tailwind step number, and the two diverge from PADDING-5 onward.
533
+
534
+ Common publishes no padding token. The internal insets below are reached through the components that
535
+ own them, not through a variable the application may set.
536
+ The page inset is the one exception and is not on this scale: `--grammar-page-inset` resolves to
537
+ `clamp(1rem, 3vw, 2rem)`, a responsive value owned by `PageContainer`. An application never
538
+ reproduces it with a fixed class.
539
+ The rem values resolve to `0 / 4 / 8 / 12 / 16 / 24 / 32` CSS pixels only at a computed root of
540
+ `16px`. Runtime checks use `expectedPx = remFactor * observedRootFontPx`.
541
+ componentOwnership:
542
+ title: Padding Common already owns
543
+ notes: |-
544
+ Retained ownership reference, not proof of the current package/API. Verify actual installed source/props/tokens before use; correct outdated rows within selected maintenance scope using [source and maintenance](index.yaml#source-and-maintenance).
545
+ A part that carries one rule per side or per property names that CSS property after the part (`item padding-inline`); one part and property has one rule.
546
+ rows:
547
+ - component: ChatWorkspace
548
+ element: drawer body, hasRail, isCompactRail
549
+ rule: PADDING-0
550
+ - component: ChatWorkspace
551
+ element: overlay rail, hasRail, isCompactRail
552
+ rule: PADDING-4
553
+ - component: ChatWorkspace
554
+ element: overlay rail, hasRail, not isCompactRail
555
+ rule: PADDING-4
556
+ - component: ChatWorkspace
557
+ element: rail trigger boundary padding-block, hasRail, isCompactRail
558
+ rule: PADDING-2
559
+ - component: ChatWorkspace
560
+ element: rail trigger boundary padding-inline, hasRail, isCompactRail
561
+ rule: PADDING-4
562
+ - component: ChatWorkspace
563
+ element: rail trigger, hasRail, isCompactRail
564
+ rule: PADDING-3
565
+ - component: EmptyNotice
566
+ element: root
567
+ rule: PADDING-4
568
+ - component: HorizontalScrollRegion
569
+ element: root
570
+ rule: PADDING-1
571
+ - component: NavigationFeatureNav
572
+ element: primary
573
+ rule: PADDING-3
574
+ - component: OtpInput
575
+ element: root
576
+ rule: PADDING-1
577
+ - component: PressableField
578
+ element: root
579
+ rule: PADDING-3
580
+ - component: PressableField
581
+ element: shortcut, shortcut!=undefined
582
+ rule: PADDING-1
583
+ - component: Rail
584
+ element: body padding-inline, inset="content"
585
+ rule: PADDING-3
586
+ - component: Rail
587
+ element: body padding-block, inset="content"
588
+ rule: PADDING-5
589
+ - component: Sidebar
590
+ element: footer padding-inline padding-block-end, not collapsed, footer!=undefined
591
+ rule: PADDING-3
592
+ - component: Sidebar
593
+ element: footer padding-block-start, not collapsed, footer!=undefined
594
+ rule: PADDING-0
595
+ - component: Sidebar
596
+ element: header, not collapsed, header!=undefined
597
+ rule: PADDING-3
598
+ - component: Sidebar
599
+ element: list, collapsed
600
+ rule: PADDING-2
601
+ - component: Sidebar
602
+ element: list, not collapsed
603
+ rule: PADDING-3
604
+ - component: Sidebar
605
+ element: section label padding-block-end, group.label!=undefined, not collapsed
606
+ rule: PADDING-1
607
+ - component: Sidebar
608
+ element: section label padding-inline, group.label!=undefined, not collapsed
609
+ rule: PADDING-2
610
+ - component: Sidebar
611
+ element: section label padding-block-start, group.label!=undefined, not collapsed
612
+ rule: PADDING-3
613
+ - component: Sidebar
614
+ element: toggle, canToggle
615
+ rule: PADDING-2
616
+ - component: StaticStateRow
617
+ element: root
618
+ rule: PADDING-4
619
+ - component: ListRow
620
+ element: root (carries starci-core-static-row, @starci/grammar 0.9.1)
621
+ rule: PADDING-4
622
+ - component: Subnav
623
+ element: root
624
+ rule: PADDING-3
625
+ - component: SurfaceAccordionCard
626
+ element: accordion panel
627
+ rule: PADDING-0
628
+ - component: SurfaceAccordionCard
629
+ element: accordion trigger
630
+ rule: PADDING-4
631
+ - component: SurfaceAccordionCard
632
+ element: body padding-top
633
+ rule: PADDING-0
634
+ - component: SurfaceAccordionCard
635
+ element: body padding-bottom
636
+ rule: PADDING-3
637
+ - component: SurfaceAccordionCard
638
+ element: body padding-right
639
+ rule: PADDING-4
640
+ - component: SurfaceAccordionCard
641
+ element: body padding-left
642
+ rule: PADDING-5
643
+ - component: SurfaceCard
644
+ element: content, composition!="joined"
645
+ rule: PADDING-4
646
+ - component: SurfaceCard
647
+ element: content, composition="joined"
648
+ rule: PADDING-0
649
+ - component: SurfaceListCard
650
+ element: root
651
+ rule: PADDING-0
652
+ - component: Tabs
653
+ element: hero tabs tab
654
+ rule: PADDING-3
655
+ - component: Tabs
656
+ element: root, inset="page"
657
+ rule: PADDING-5
658
+ - component: TextAction
659
+ element: root padding-inline, appearance="choice"
660
+ rule: PADDING-2
661
+ - component: TextAction
662
+ element: root padding-inline, appearance="route"
663
+ rule: PADDING-3
664
+ - component: TextAction
665
+ element: root padding-inline, appearance="section"
666
+ rule: PADDING-3
667
+ - component: TextAction
668
+ element: root padding-block, appearance="tab"
669
+ rule: PADDING-3
670
+ - component: Tooltip
671
+ element: content padding-block
672
+ rule: PADDING-1
673
+ - component: Tooltip
674
+ element: content padding-inline
675
+ rule: PADDING-2
676
+ guidance:
677
+ - id: side-contact
678
+ title: Side contact
679
+ requirement: |-
680
+ A boundary does not always take one value on all four sides. When a surface is flush and its child
681
+ bands own their own inset, each side takes its value from what that side touches.
682
+ | What the side touches | Value | Rule |
683
+ | --- | --- | --- |
684
+ | The outer edge of the surface | `1rem` | PADDING-4 |
685
+ | A separator between bands | `.75rem` | PADDING-3 |
686
+ | Nothing, because a child owns the edge | `0` | PADDING-0 |
687
+ A separator already draws the boundary, so the side meeting it needs less clearance than a bare outer
688
+ edge. Inline sides of a band always meet the outer edge, so they stay at `1rem`.
689
+ For a stack of bands this resolves to `px-4` throughout, `pt-4` on the first band, `pb-4` on the last,
690
+ and `.75rem` on every side that meets a separator. A band between two separators is `px-4 py-3`.
691
+ Common owns the flush surface itself through `composition="joined"`, which sets the content inset to
692
+ `0`. It exposes no path for the band insets, so those remain an application workaround.
693
+ - id: composite-insets
694
+ title: Composite insets
695
+ requirement: |-
696
+ `PADDING-0` through `PADDING-6` are the scale: one rule, one value. From `PADDING-7` onward the rules
697
+ are named recipes whose sides deliberately take different values. The number keeps counting, but it
698
+ stops meaning a position on the scale, so a composite rule never appears in the scale table and never
699
+ introduces a value of its own. Every side of a composite resolves to a base rule.
700
+ Append the next number when a genuinely new asymmetric pattern appears. State the reason each side
701
+ differs; a recipe without one is a product decision, not a rule.
702
+ - id: axis-variants
703
+ title: Axis variants
704
+ requirement: |-
705
+ `px-*` and `py-*` are not separate rules. They apply an existing rule to one axis when the two axes
706
+ carry different boundary decisions.
707
+ | Axis | Class | Meaning |
708
+ | --- | --- | --- |
709
+ | Inline | `px-*` | The chosen rule applies to logical start and end only |
710
+ | Block | `py-*` | The chosen rule applies to block start and end only |
711
+ | One side | `pt-*`, `pb-*`, `ps-*`, `pe-*` | The chosen rule applies to that side only |
712
+ Per-side classes are what the side-contact rule needs, because the two block sides of one band often
713
+ take different values. `px-4 pt-4 pb-3` is PADDING-4 on three sides and PADDING-3 on the side meeting
714
+ a separator, and every side still names its own case.
715
+ On the same element the narrower class wins the sides it names rather than adding, so `p-4 pb-3`
716
+ resolves to `1rem` on three sides and `.75rem` at the bottom. Between nested elements padding does
717
+ add, which is why a wrapper introduced only to change one side produces an inset nobody intended.
718
+ recipes:
719
+ - rule: PADDING-0
720
+ title: "`p-0` / `0`"
721
+ cases:
722
+ - id: case-1
723
+ when: A container only groups children which each carry their own boundary
724
+ owner: "`App`"
725
+ render: '`<section className="p-0">`'
726
+ - id: case-2
727
+ when: Surface bands run flush to the card edge
728
+ owner: "`SurfaceCard`"
729
+ render: '`composition="joined"` already sets `0`'
730
+ - id: case-3
731
+ when: A Grammar list sits inside a panel or its rows own their own inset
732
+ owner: "`SurfaceListCard` / `ListBox`"
733
+ render: ListBox resolves inset="panel" in a surface nesting context, or callers select it explicitly. Its
734
+ list container extends into the panel gutter; items retain HeroUI's default padding, and row
735
+ backgrounds extend into that gutter. Its sections captions share the row content edge. Padding
736
+ wrappers with px-*, ps-* or pe-* stack this inset and fail FE_LIST_PANEL_PADDING in the universal
737
+ FE canon (R318).
738
+ - rule: PADDING-1
739
+ title: "`p-1` / `.25rem`"
740
+ cases:
741
+ - id: case-1
742
+ when: A compact marker or key floating over an app-owned canvas
743
+ owner: "`App`"
744
+ render: '`<aside className="p-1">` with `<Text size="xs">`'
745
+ - id: case-2
746
+ when: A scroll region reserving room so focus rings are not clipped
747
+ owner: "`HorizontalScrollRegion`"
748
+ render: Compose the region; no padding
749
+ - id: case-3
750
+ when: A short hint attached to a control
751
+ owner: "`Tooltip`"
752
+ render: Compose the tooltip; no padding
753
+ - rule: PADDING-2
754
+ title: "`p-2` / `.5rem`"
755
+ cases:
756
+ - id: case-1
757
+ when: A small auxiliary strip whose four sides share one inset
758
+ owner: "`App`"
759
+ render: '`<aside className="p-2">` with `<Text size="sm">`'
760
+ - id: case-2
761
+ when: A compact bar of controls above app-owned content
762
+ owner: "`App`"
763
+ render: '`<div className="flex items-center gap-2 p-2">`'
764
+ - rule: PADDING-3
765
+ title: "`p-3` / `.75rem`"
766
+ cases:
767
+ - id: case-1
768
+ when: A nested app container that owns a boundary distinct from its parent's
769
+ owner: "`App`"
770
+ render: '`<aside className="p-3">` inside a `p-4` section'
771
+ - id: case-2
772
+ when: The inline inset of a tab strip or secondary navigation
773
+ owner: "`Tabs`, `Subnav`"
774
+ render: Compose the component; no padding
775
+ - id: case-3
776
+ when: A band side that meets a separator inside a flush surface
777
+ owner: "`—`"
778
+ render: '`<div className="px-4 py-3">` between two separators'
779
+ - id: case-4
780
+ when: The separator-facing side of a metric cell or list row
781
+ owner: '`DescriptionList` layout="stat-strip" (its divided cells, @starci/grammar 0.6.0), else `—`'
782
+ render: '`<div className="p-4 pb-3">`, or `pt-3` when the separator is above'
783
+ - rule: PADDING-4
784
+ title: "`p-4` / `1rem`"
785
+ cases:
786
+ - id: case-1
787
+ when: An app section that owns the full distance from its border to its content
788
+ owner: "`App`"
789
+ render: '`<section className="p-4">` with `<Heading level={2}>`'
790
+ - id: case-2
791
+ when: Content inside a card
792
+ owner: "`SurfaceCard`"
793
+ render: Compose the card; no padding
794
+ - id: case-3
795
+ when: A disclosure trigger and its expanded panel
796
+ owner: "`SurfaceAccordionCard`"
797
+ render: Compose the card; no padding
798
+ - id: case-4
799
+ when: An empty-state notice
800
+ owner: "`EmptyNotice`"
801
+ render: Compose the notice; no padding
802
+ - id: case-5
803
+ when: A fenced code block
804
+ owner: "`FencedCodeBlock`"
805
+ render: Compose the block; no padding
806
+ - id: case-6
807
+ when: A band side that meets the outer edge of a flush surface
808
+ owner: "`—`"
809
+ render: "`pt-4` on the first band, `pb-4` on the last"
810
+ - id: case-7
811
+ when: The inline sides of any band inside a flush surface
812
+ owner: "`—`"
813
+ render: "`px-4` on every band, unchanged by position"
814
+ - rule: PADDING-5
815
+ title: "`p-6` / `1.5rem`"
816
+ cases:
817
+ - id: case-1
818
+ when: A section whose four sides share one deliberately generous boundary decision
819
+ owner: "`App`"
820
+ render: '`<section className="p-6">`'
821
+ - id: case-2
822
+ when: A tab frame carrying the page-level inline inset
823
+ owner: "`Tabs`"
824
+ render: '`inset="page"` already sets `1.5rem`'
825
+ - rule: PADDING-6
826
+ title: "`p-8` / `2rem`"
827
+ cases:
828
+ - id: case-1
829
+ when: A page region that needs strong equal clearance on every side
830
+ owner: "`App`"
831
+ render: '`<section className="p-8">`'
832
+ - id: case-2
833
+ when: An empty or waiting region occupying most of the viewport
834
+ owner: "`App`"
835
+ render: '`<section className="p-8">` with `<EmptyNotice>`'
836
+ - rule: PADDING-7
837
+ title: Inset step at the wider breakpoint
838
+ cases:
839
+ - id: case-1
840
+ when: A decision surface whose inline inset opens up on a wider viewport
841
+ owner: "`—`"
842
+ render: '`<div className="px-4 sm:px-6">`'
843
+ - id: case-2
844
+ when: The same surface's outer-edge block sides, stepping with it
845
+ owner: "`—`"
846
+ render: '`<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6 sm:pb-6">`'
847
+ - id: case-3
848
+ when: A separator-facing side inside that surface
849
+ owner: "`—`"
850
+ render: "`pt-3` stays `.75rem` at both widths"
851
+ - rule: PADDING-8
852
+ title: Subordinate content indented at the inline start
853
+ cases:
854
+ - id: case-1
855
+ when: A disclosure body that reads as the answer belonging to the trigger above it
856
+ owner: "`—`"
857
+ render: '`<div className="ps-6 pe-4 py-3">`'
858
+ - id: case-2
859
+ when: A nested group whose rows belong to a named parent row
860
+ owner: "`—`"
861
+ render: Same recipe, one step per level of nesting
862
+ - rule: PADDING-9
863
+ title: Block inset of a routed block at the wider breakpoint
864
+ cases:
865
+ - id: case-1
866
+ when: The `main` of a routed block that sits directly under the page shell
867
+ owner: "`App`"
868
+ render: '`<main className="py-6 sm:py-8">`'
869
+ font:
870
+ ownership: FE-RULE-OWNERSHIP
871
+ knowledge: knowledge/ui/presentation/font.yaml
872
+ ownerVocabulary:
873
+ owners:
874
+ - name: A component name
875
+ meaning: "`Text` or `Heading` renders this rank"
876
+ applicationWrites: The prop only
877
+ - name: —
878
+ meaning: The rank has no public prop
879
+ applicationWrites: Nothing. Report the gap
880
+ notes: |-
881
+ Typography belongs to Grammar. The owner cell names which component renders the rank.
882
+
883
+ There is no `App` owner in this file. An application typography class is `APP_OVERRIDE`, and a raw
884
+ `font-size` or `font-weight` that changes semantic rank is rejected even when the pixels match.
885
+ scale:
886
+ steps:
887
+ - id: FONT-1
888
+ - id: FONT-2
889
+ - id: FONT-3
890
+ - id: FONT-4
891
+ - id: FONT-5
892
+ - id: FONT-6
893
+ notes: |-
894
+ The type scale is closed and shared by `Text` and `Heading`. The rule ID is the ordinal position on
895
+ that scale, smallest first. It is not a Tailwind step and not a heading level.
896
+
897
+ `Text` and `Heading` are not interchangeable at the same size. A heading declares document structure;
898
+ a text line does not. Choosing `Heading` to obtain a size, or `Text` to avoid an outline entry, is a
899
+ structure error rather than a typography one.
900
+ Tracking is not an application decision. `Heading` applies `tracking-tight` at FONT-4 and FONT-6 only.
901
+ componentOwnership:
902
+ title: Font Common already owns
903
+ notes: Retained ownership reference, not proof of the current package/API. Verify actual installed
904
+ source/props/tokens before use; correct outdated rows within selected maintenance scope using [source
905
+ and maintenance](index.yaml#source-and-maintenance).
906
+ rows:
907
+ - component: ChatWorkspace
908
+ element: rail trigger, hasRail, isCompactRail
909
+ rule: FONT-2
910
+ - component: Heading
911
+ element: root, scale!="display", level=1
912
+ rule: FONT-4
913
+ - component: Heading
914
+ element: root, scale!="display", level=2
915
+ rule: FONT-3
916
+ - component: Heading
917
+ element: root, scale!="display", level=3
918
+ rule: FONT-2
919
+ - component: Heading
920
+ element: root, scale!="display", level=4
921
+ rule: FONT-1
922
+ - component: Heading
923
+ element: root, scale="display"
924
+ rule: FONT-6
925
+ - component: Input
926
+ element: reveal or copy label, action is present and its icon is absent
927
+ rule: FONT-1
928
+ - component: Label
929
+ element: root, depth!="nested"
930
+ rule: FONT-2
931
+ - component: Label
932
+ element: root, depth="nested"
933
+ rule: FONT-1
934
+ - component: PressableField
935
+ element: placeholder
936
+ rule: FONT-2
937
+ - component: PressableField
938
+ element: shortcut, shortcut!=undefined
939
+ rule: FONT-1
940
+ - component: Sidebar
941
+ element: item label, not collapsed
942
+ rule: FONT-2
943
+ - component: Sidebar
944
+ element: section label, group.label!=undefined, not collapsed
945
+ rule: FONT-1
946
+ - component: Text
947
+ element: root, size="md"
948
+ rule: FONT-3
949
+ - component: Text
950
+ element: root, size="metric-lead"
951
+ rule: FONT-5
952
+ - component: Text
953
+ element: root, size="sm"
954
+ rule: FONT-2
955
+ - component: Text
956
+ element: root, size="xs"
957
+ rule: FONT-1
958
+ - component: TextAction
959
+ element: root, size="md"
960
+ rule: FONT-3
961
+ - component: TextAction
962
+ element: root, size="sm"
963
+ rule: FONT-2
964
+ - component: TextAction
965
+ element: root, size="xs"
966
+ rule: FONT-1
967
+ - component: Tooltip
968
+ element: content
969
+ rule: FONT-1
970
+ - component: SurfaceListCard
971
+ element: fact, label is visible and fact is present, depth="nested"
972
+ rule: FONT-1
973
+ - component: SurfaceListCard
974
+ element: fact, label is visible and fact is present, depth!="nested"
975
+ rule: FONT-2
976
+ guidance:
977
+ - id: weight
978
+ title: Weight
979
+ requirement: |-
980
+ Weight is a second axis, not a position on the size scale. Its public set is closed.
981
+ | Prop | Rendered | Use for |
982
+ | --- | --- | --- |
983
+ | `weight="normal"` | `font-normal`, the default | Reading copy and ordinary facts |
984
+ | `weight="medium"` | `font-medium` | A compact label, or a title above its own description |
985
+ | `weight="semibold"` | `font-semibold` | A fact that must be found first within its region |
986
+ Weight refines a rank the application has already chosen. It never manufactures a new hierarchy
987
+ level, and it never substitutes for a heading. `Heading` applies its own weight per level and takes
988
+ no weight prop.
989
+ Bold beyond `semibold` has no public prop. Treat an intent that needs it as a gap rather than writing
990
+ a class.
991
+ recipes:
992
+ - rule: FONT-1
993
+ title: "`text-xs` / `leading-4`"
994
+ cases:
995
+ - id: case-1
996
+ when: Brief metadata that stays understandable at the smallest size
997
+ owner: "`Text`"
998
+ render: '`<Text size="xs">Optional</Text>`'
999
+ - id: case-2
1000
+ when: The label of a nested region inside an already-titled section
1001
+ owner: "`Heading`"
1002
+ render: "`<Heading level={4}>Card details</Heading>`"
1003
+ remark: |-
1004
+ `Text size="xs"` always resolves to muted and cannot be promoted to another tone. `Heading level={4}`
1005
+ carries the same muted treatment.
1006
+ notThisRule: ordinary sentences, or any fact the reader must not miss. Use FONT-2 or FONT-3.
1007
+ - rule: FONT-2
1008
+ title: "`text-sm` / `leading-5`"
1009
+ cases:
1010
+ - id: case-1
1011
+ when: A complete line ranked below normal body copy, such as a timestamp or short fact
1012
+ owner: "`Text`"
1013
+ render: '`<Text size="sm">Updated 2 minutes ago</Text>`'
1014
+ - id: case-2
1015
+ when: A third-level region heading inside a section
1016
+ owner: "`Heading`"
1017
+ render: "`<Heading level={3}>Payment method</Heading>`"
1018
+ notThisRule: shrinking body copy so it fits a container that is too small. Fix the container.
1019
+ - rule: FONT-3
1020
+ title: "`text-base` / `leading-6`"
1021
+ cases:
1022
+ - id: case-1
1023
+ when: Ordinary explanatory copy with normal reading priority
1024
+ owner: "`Text`"
1025
+ render: "`<Text>Your subscription renews on 12 March.</Text>`"
1026
+ - id: case-2
1027
+ when: The heading of a section inside a page
1028
+ owner: "`Heading`"
1029
+ render: "`<Heading level={2}>Billing</Heading>`"
1030
+ - rule: FONT-4
1031
+ title: "`text-xl`"
1032
+ cases:
1033
+ - id: case-1
1034
+ when: The single heading that names the whole page or route
1035
+ owner: "`Heading`"
1036
+ render: "`<Heading level={1}>Account</Heading>`"
1037
+ notThisRule: a second `level={1}` on the same page. One document, one top-level heading.
1038
+ - rule: FONT-5
1039
+ title: "`text-3xl` / `leading-9`"
1040
+ cases:
1041
+ - id: case-1
1042
+ when: The single leading metric or short value that is the main fact of its region
1043
+ owner: "`Text`"
1044
+ render: '`<Text size="metric-lead" weight="semibold">84%</Text>`'
1045
+ notThisRule: sentences, decoration, or imitating a heading. It creates no heading semantics.
1046
+ - rule: FONT-6
1047
+ title: "`text-4xl` / `leading-tight`"
1048
+ cases:
1049
+ - id: case-1
1050
+ when: A page-root heading that must carry marketing or landing weight
1051
+ owner: "`Heading`"
1052
+ render: '`<Heading level={1} scale="display">Learn without limits</Heading>`'
1053
+ notThisRule: raising the visual size of a nested heading. `scale` changes the recipe, never the outline position.
1054
+ anatomyObservations:
1055
+ - id: prompt-shape-b2
1056
+ lane: ui
1057
+ kind: proven-template
1058
+ summary: |-
1059
+ Verified in the draw lab against the real SurfaceListCard render — the prompt shape that
1060
+ produced a direction both faithful and beautiful:
1061
+ 1. Frame: flat product UI screenshot, size, light theme.
1062
+ 2. SUBJECT: screen name + product name.
1063
+ 3. FIXED ANATOMY: literal layout top-to-bottom; containment spelled as inside/outside the
1064
+ surface; known-confusion regions get the anti-pattern callout plus the correct structure.
1065
+ 4. BRAND + TOKENS: background, surface, radius, shadow, exact hex for primary and where it
1066
+ may appear; nested controls use the secondary variant.
1067
+ 5. CREATIVE LICENSE: an explicit "You MAY" list and an explicit "You MAY NOT" list.
1068
+ 6. Text rule: every label spelled exactly, no lorem ipsum.
1069
+ Control runs: the same prompt without section 3's literal containment drew the section
1070
+ label inside the card (wrong vs the real render); the same prompt with unbounded freedom
1071
+ produced a beautiful screen for a different product.
1072
+ - id: surface-label-placement
1073
+ lane: ui
1074
+ kind: anatomy-detail
1075
+ summary: |-
1076
+ The two card components place their labels differently, and a prompt that treats them
1077
+ alike invents one of them. `SurfaceCard` paints one material box around label AND
1078
+ content — its label ("New task") sits inside the card at the top. `SurfaceListCard`
1079
+ renders its label and fact as siblings on the page background ABOVE the list shell —
1080
+ the card bounds only the rows, the footer sits below it.
1081
+ - id: list-separator-bleed
1082
+ lane: ui
1083
+ kind: anatomy-detail
1084
+ summary: |-
1085
+ List separators are edge-to-edge, not inset. The list shell ships `PADDING-0`; the
1086
+ divider is a `border-top` on the row element itself, so it spans the card's full width
1087
+ and touches both edges. Only the row's content is inset by the row's own padding.
1088
+ Rows opt into this shipped geometry via `starci-core-static-row` — an app-owned row
1089
+ without the class renders no separator at all.
1090
+ - id: list-row-anatomy
1091
+ lane: ui
1092
+ kind: anatomy-detail
1093
+ summary: |-
1094
+ An interactive row in a SurfaceListCard is the grammar `ListRow` (@starci/grammar 0.9.1), never an
1095
+ app-owned `<li>` and never a second `<ul>` nested in the card (the card already renders the list,
1096
+ so a nested list loses the row inset and the separator - a defect that leaves rows flush against
1097
+ the card edge). Its slots, in reading order: `leading`
1098
+ (an IconTile, an avatar, or a logo; a horizontal bank wordmark takes a fixed 64x40 frame rather
1099
+ than a square tile, which would shrink it to a sliver), `title`, `badges` (at most two,
1100
+ DISCIPLINE-1 case-3), one muted `description`, `actions` (one visible Button, then a DropdownMenu
1101
+ with destructive commands last behind an AlertDialog), and a `children` detail region under the
1102
+ copy for an Alert about the row or revealed values. It carries `starci-core-static-row`, so the
1103
+ inset, row gap and edge-to-edge separator are the static row's. Collapsed by default (the collapse
1104
+ law): mark and copy share the first line, actions and detail take their own; the surface container
1105
+ query at 28.001rem lays it out leading | copy | actions. Several accounts of one provider are one
1106
+ ListRow each, told apart by the leading mark, the name and the account's last digits; the card's
1107
+ `fact` carries the count and `labelEnd` the add action (0.9.1 renders both).
1108
+ - id: card-division-and-measure
1109
+ lane: ui
1110
+ kind: anatomy-detail
1111
+ summary: |-
1112
+ Grammar ships card division and card width — a direction that invents either is
1113
+ app-owned and must say so. `SurfaceCard composition="joined"` stacks children
1114
+ edge-to-edge inside one card with shipped separators (the reference
1115
+ joined-card pattern); it divides VERTICALLY only — a
1116
+ side-by-side split inside one card is not shipped anatomy. `SurfaceCard
1117
+ measure="form"/"formCompact"` is the owned width contract for compact forms: a
1118
+ direction drawing a form card full-width and filling the dead space is answering
1119
+ a question `measure` already answers better.
1120
+ - id: joined-band-treatment
1121
+ lane: ui
1122
+ kind: anatomy-detail
1123
+ summary: |-
1124
+ Emphasis inside a joined card is a flush band, not an inset panel. The reference
1125
+ dashboard's anatomy: children stack full-bleed inside the bounded
1126
+ surface (`joined` sets the content to gap:0/padding:0), adjacent bands separate
1127
+ with an edge-to-edge `border-t` hairline, and a band that needs emphasis carries
1128
+ `bg-surface-secondary` (neutral) or `bg-accent-soft` (accent tint) across the
1129
+ card's full width with its own padding (px-4/pt-4/pb-3 top, p-3 middle). A
1130
+ nested rounded box floating inside the card is not the pattern.
1131
+ - id: special-card-recipe
1132
+ lane: ui
1133
+ kind: anatomy-detail
1134
+ summary: |-
1135
+ The dashboard's "special card" formula is composition, not decoration: IMAGE × joined
1136
+ plus HIGHLIGHT × joined. Proof in source — the reference task card: a joined card whose first band
1137
+ is an accent-filled hero slab (`bg-accent`) carrying a decorative 3D illustration
1138
+ anchored to the trailing edge (`aria-hidden`, `pointer-events-none`, bottom/right
1139
+ absolute); separator; a reward band that is neutral by default and turns
1140
+ `bg-success-soft` ONLY on the claimed state ("only a proven claimed outcome receives
1141
+ success"); separator; a task-cell grid using `divide-y`/`divide-x` for edge-to-edge
1142
+ cell lines. the reference purchase card: joined card = intro band + full-bleed journey
1143
+ illustration band + benefit list, with MISSING-ASSET discipline — the illustration is
1144
+ withheld entirely under the dark scheme rather than repainted. Grammar ships the
1145
+ card-level "special" affordance itself: `SurfaceCard isHighlight` wraps the surface in
1146
+ an animated conic-gradient edge sweep (`starci-core-surface-highlight-sweep`, 3s spin,
1147
+ suppressed while `pending`). Direction prompts MAY therefore use: a tinted hero slab
1148
+ band with anchored decorative art, state-semantic tinted bands (accent-soft /
1149
+ surface-secondary / success-soft), and the card edge highlight — but a highlight or
1150
+ tint must always MEAN a state, never mere prettiness.
1151
+ - id: direction-scale-drift
1152
+ lane: ui
1153
+ kind: known-drift
1154
+ summary: |-
1155
+ Image models inflate type scale — surface labels ship at 0.875rem (0.75rem nested)
1156
+ but directions routinely draw them 1.3–1.5x larger. Scale is token truth, not
1157
+ direction truth: the image fixes containment and composition; the component's own
1158
+ CSS fixes size. A direction drawn with oversized labels is not falsified for it —
1159
+ but an implementation capture that reproduces the oversized size is.