@starci/hfs 4.7.34 → 4.7.36

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