@astryxdesign/cli 0.4.4 → 0.4.5-canary.1fdecb1
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.
- package/CHANGELOG.md +5 -0
- package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +91 -0
- package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +28 -4
- package/assets/docs/layout.doc.dense.mjs +275 -30
- package/assets/docs/layout.doc.mjs +412 -78
- package/assets/templates/blocks/components/BottomSheet/BottomSheetSnapPoints.doc.mjs +23 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetSnapPoints.tsx +155 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @xds/cli
|
|
2
2
|
|
|
3
|
+
# 0.4.5
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
3
7
|
# 0.4.4
|
|
4
8
|
|
|
5
9
|
#### New Components
|
|
@@ -16,6 +20,7 @@
|
|
|
16
20
|
Three more lessons came out of building a real app on it. The page now `<link>`s the theme's webfont from Google Fonts, because the theme _names_ Figtree and never loads it, so every viewer silently got the fallback stack (#5015 again). It imports the theme OBJECT and wraps in `<Theme theme={neutralTheme} mode="system">`, so light and dark follow the OS — the `data-astryx-theme` attribute alone scopes the stylesheet but cannot switch modes. And `#root:empty` carries a "Loading…" state, because ESM-from-CDN has real latency and a blank page reads as broken. Markup is `htm`, with a comment saying it is optional and `createElement` is the dependency-free alternative.
|
|
17
21
|
|
|
18
22
|
A recipe that is only read is a recipe that is only assumed to work, so CI renders it: `.github/scripts/cdn-template-smoke-test.mjs` scaffolds the page with the real CLI and opens it in headless Chromium, failing on any console error, page error or failed request, and on a page that loads without rendering.
|
|
23
|
+
|
|
19
24
|
- `astryx theme build` takes any number of theme files — `astryx theme build themes/*.ts` compiles them all in one process, so an app with several themes no longer hand-rolls a loop that re-enters the CLI once per theme. Outputs are byte-identical to the serial invocations; the run stops at the first failure and names the theme that failed. The CLI's Node floor (>=22.13) is now declared in `engines`, so a package manager can enforce it at install instead of the build failing later (#5121).
|
|
20
25
|
- `defineTheme`: `color.accent` accepts a `[light, dark]` tuple (#2279)
|
|
21
26
|
`ColorScaleConfig.accent` now takes either a single hex or a `[light, dark]` tuple, matching `TokenValue`. With a tuple, `expandColorScale` derives the light half of every generated `light-dark()` pair from the light seed's palettes and the dark half from the dark seed's, so each scheme gets a consistent derived palette (muted, on-accent, neutrals) instead of the `tokens['--color-accent']` workaround that skips scale generation. Single-string configs are unchanged, token for token. Also documents the precedence between `color` and `tokens` for accent-derived values: `tokens` entries win token by token, the `var(--color-accent)` reference tokens follow a `--color-accent` override at runtime, and the baked `--color-on-accent` stays derived from the `color.accent` seed.
|
|
@@ -212,4 +212,95 @@ describe('drop-xds-prefix-imports', () => {
|
|
|
212
212
|
const output = await applyTransform(input);
|
|
213
213
|
expect(output).toBe(input);
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
it('aliases a component wrapper collision in place (XDS -> Astryx), leaving the local fn untouched', async () => {
|
|
217
|
+
const input = [
|
|
218
|
+
`import {XDSLinkProvider} from '@xds/core/Link';`,
|
|
219
|
+
`export default function LinkProvider({children}) {`,
|
|
220
|
+
` return <XDSLinkProvider component={NextLink}>{children}</XDSLinkProvider>;`,
|
|
221
|
+
`}`,
|
|
222
|
+
].join('\n');
|
|
223
|
+
const output = await applyTransform(input);
|
|
224
|
+
// Import aliased in place, local wrapper + its name untouched.
|
|
225
|
+
expect(output).toContain('import {LinkProvider as AstryxLinkProvider}');
|
|
226
|
+
expect(output).toContain('function LinkProvider({children})');
|
|
227
|
+
// JSX rewritten to the alias -> no self-recursion.
|
|
228
|
+
expect(output).toContain('<AstryxLinkProvider component={NextLink}>');
|
|
229
|
+
expect(output).not.toContain('XDSLinkProvider');
|
|
230
|
+
// No duplicate bare LinkProvider import.
|
|
231
|
+
expect(output).not.toMatch(/import \{LinkProvider\}/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('aliases a hook collision as use<Astryx>Name (not <Astryx>use)', async () => {
|
|
235
|
+
const input = [
|
|
236
|
+
`import {useXDSToast} from '@xds/core';`,
|
|
237
|
+
`function useToast() {`,
|
|
238
|
+
` return useXDSToast();`,
|
|
239
|
+
`}`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
const output = await applyTransform(input);
|
|
242
|
+
expect(output).toContain('import {useToast as useAstryxToast}');
|
|
243
|
+
expect(output).toContain('function useToast()');
|
|
244
|
+
expect(output).toContain('return useAstryxToast();');
|
|
245
|
+
expect(output).not.toContain('useXDSToast');
|
|
246
|
+
// Must not produce the malformed `Astryxuse...` form.
|
|
247
|
+
expect(output).not.toContain('AstryxuseToast');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('aliases on collision with a local default import binding', async () => {
|
|
251
|
+
const input = [
|
|
252
|
+
`import Link from 'next/link';`,
|
|
253
|
+
`import {XDSLink} from '@xds/core';`,
|
|
254
|
+
`export const a = <XDSLink href="/" />;`,
|
|
255
|
+
`export const b = <Link href="/" />;`,
|
|
256
|
+
].join('\n');
|
|
257
|
+
const output = await applyTransform(input);
|
|
258
|
+
expect(output).toContain('import {Link as AstryxLink}');
|
|
259
|
+
expect(output).toContain(`import Link from 'next/link';`);
|
|
260
|
+
expect(output).toContain('<AstryxLink href="/" />');
|
|
261
|
+
expect(output).toContain('<Link href="/" />');
|
|
262
|
+
expect(output).not.toContain('XDSLink');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('aliases on collision with a local type alias', async () => {
|
|
266
|
+
const input = [
|
|
267
|
+
`import type {XDSTab} from '@xds/core';`,
|
|
268
|
+
`type Tab = {id: string};`,
|
|
269
|
+
`const active: XDSTab = null as any;`,
|
|
270
|
+
`const local: Tab = {id: '1'};`,
|
|
271
|
+
].join('\n');
|
|
272
|
+
const output = await applyTransform(input);
|
|
273
|
+
expect(output).toContain('Tab as AstryxTab');
|
|
274
|
+
expect(output).toContain('type Tab = {id: string};');
|
|
275
|
+
expect(output).toContain('const active: AstryxTab');
|
|
276
|
+
expect(output).toContain('const local: Tab');
|
|
277
|
+
expect(output).not.toContain('XDSTab');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('aliases on collision with a local interface', async () => {
|
|
281
|
+
const input = [
|
|
282
|
+
`import type {XDSTheme} from '@xds/core';`,
|
|
283
|
+
`interface Theme {}`,
|
|
284
|
+
`const t: XDSTheme = null as any;`,
|
|
285
|
+
`const local: Theme = {};`,
|
|
286
|
+
].join('\n');
|
|
287
|
+
const output = await applyTransform(input);
|
|
288
|
+
expect(output).toContain('Theme as AstryxTheme');
|
|
289
|
+
expect(output).toContain('interface Theme {}');
|
|
290
|
+
expect(output).toContain('const t: AstryxTheme');
|
|
291
|
+
expect(output).not.toContain('XDSTheme');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('still bare-renames when there is NO colliding local binding', async () => {
|
|
295
|
+
const input = [
|
|
296
|
+
`import {XDSButton} from '@xds/core';`,
|
|
297
|
+
`export const App = () => <XDSButton label="Hi" />;`,
|
|
298
|
+
].join('\n');
|
|
299
|
+
const output = await applyTransform(input);
|
|
300
|
+
// No local `Button` binding -> existing blind bare-rename behavior kept.
|
|
301
|
+
expect(output).toContain(`import {Button} from '@xds/core';`);
|
|
302
|
+
expect(output).toContain('<Button label="Hi" />');
|
|
303
|
+
expect(output).not.toContain('AstryxButton');
|
|
304
|
+
expect(output).not.toContain('XDSButton');
|
|
305
|
+
});
|
|
215
306
|
});
|
|
@@ -64,6 +64,22 @@ export const meta = {
|
|
|
64
64
|
|
|
65
65
|
const XDS_CORE_SOURCE = /^@xds\/core(\/.*)?$/;
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Compute a collision alias for an XDS-prefixed identifier by replacing the
|
|
69
|
+
* `XDS` segment IN PLACE with `Astryx` (as opposed to prefixing the bare name).
|
|
70
|
+
* This keeps hook names well-formed:
|
|
71
|
+
*
|
|
72
|
+
* XDSButton -> AstryxButton
|
|
73
|
+
* XDSLinkProvider -> AstryxLinkProvider
|
|
74
|
+
* useXDSToast -> useAstryxToast (NOT AstryxuseToast)
|
|
75
|
+
*
|
|
76
|
+
* Only the first `XDS` occurrence is replaced, matching how `bareName` strips a
|
|
77
|
+
* single leading prefix.
|
|
78
|
+
*/
|
|
79
|
+
function aliasName(/** @type {any} */ name) {
|
|
80
|
+
return name.replace('XDS', 'Astryx');
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
/**
|
|
68
84
|
* Compute the bare (unprefixed) name for an XDS-prefixed identifier.
|
|
69
85
|
* Returns null if the name is not XDS-prefixed in a renameable way.
|
|
@@ -190,7 +206,12 @@ export default function transformer(file, api) {
|
|
|
190
206
|
if (!node) return;
|
|
191
207
|
if (
|
|
192
208
|
(node.type === 'FunctionDeclaration' ||
|
|
193
|
-
node.type === 'ClassDeclaration'
|
|
209
|
+
node.type === 'ClassDeclaration' ||
|
|
210
|
+
// Type-only bindings share the module namespace for our purposes: a
|
|
211
|
+
// `type Tab`/`interface Theme` collides with an un-prefixed `XDSTab`/
|
|
212
|
+
// `XDSTheme` type import and would create a duplicate declaration.
|
|
213
|
+
node.type === 'TSTypeAliasDeclaration' ||
|
|
214
|
+
node.type === 'TSInterfaceDeclaration') &&
|
|
194
215
|
node.id
|
|
195
216
|
) {
|
|
196
217
|
existingBindings.add(node.id.name);
|
|
@@ -239,10 +260,13 @@ export default function transformer(file, api) {
|
|
|
239
260
|
if (bare !== importedName && existingBindings.has(bare)) {
|
|
240
261
|
// COLLISION: the bare name is already a top-level binding in this file
|
|
241
262
|
// (e.g. a local `export function CodeBlock` alongside imported
|
|
242
|
-
// `XDSCodeBlock`). Un-prefixing
|
|
243
|
-
//
|
|
263
|
+
// `XDSCodeBlock`, or a `type Tab` alongside `XDSTab`). Un-prefixing
|
|
264
|
+
// directly would create a duplicate declaration (TS2451), so alias the
|
|
265
|
+
// import by replacing `XDS` with `Astryx` in place and rename the
|
|
244
266
|
// import's references to that alias, leaving the local binding intact.
|
|
245
|
-
|
|
267
|
+
// In-place replacement keeps hooks well-formed: `useXDSToast` becomes
|
|
268
|
+
// `useAstryxToast`, not `AstryxuseToast`.
|
|
269
|
+
const alias = aliasName(importedName);
|
|
246
270
|
localRenames.set(importedName, alias);
|
|
247
271
|
existingBindings.add(alias);
|
|
248
272
|
hasChanges = true;
|
|
@@ -4,91 +4,336 @@
|
|
|
4
4
|
|
|
5
5
|
export const docsDense = {
|
|
6
6
|
description:
|
|
7
|
-
'
|
|
7
|
+
'outside-in app layout: scaffold -> structure -> spacing -> breakpoints.',
|
|
8
8
|
sections: [
|
|
9
9
|
{
|
|
10
|
-
section: '
|
|
11
|
-
title: '
|
|
10
|
+
section: 'Overview',
|
|
11
|
+
title: 'Overview',
|
|
12
12
|
content: [
|
|
13
13
|
{
|
|
14
14
|
type: 'prose',
|
|
15
|
-
text: '
|
|
15
|
+
text: 'build outside-in. settle the shell + region budgets before any content, then work inward. content-first drifts into a padded column of cards, each section inventing its own container.',
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
type: 'list',
|
|
19
19
|
items: [
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
20
|
+
'scaffold: pick shell, budget regions, choose nav',
|
|
21
|
+
'structure: rank content per region, pick the weakest container that groups it',
|
|
22
|
+
'spacing: hold one content line per region, then tune gaps + density',
|
|
23
|
+
'breakpoints: decide what each region does as width changes',
|
|
24
24
|
],
|
|
25
25
|
},
|
|
26
|
-
|
|
26
|
+
{
|
|
27
|
+
type: 'prose',
|
|
28
|
+
text: 'decides layout, not component APIs. npx astryx build "<idea>" = closest template for your app type. npx astryx component <Name> = props.',
|
|
29
|
+
},
|
|
27
30
|
],
|
|
28
31
|
},
|
|
29
32
|
{
|
|
30
|
-
section: '
|
|
31
|
-
title: '
|
|
33
|
+
section: 'Scaffold',
|
|
34
|
+
title: 'Scaffold',
|
|
32
35
|
content: [
|
|
36
|
+
// Shell
|
|
37
|
+
null,
|
|
33
38
|
{
|
|
34
39
|
type: 'prose',
|
|
35
|
-
text: '
|
|
40
|
+
text: 'pick the shell + budget its regions before any content exists. structural widths are the one place raw px belongs; everything inside uses the scale.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'list',
|
|
44
|
+
items: [
|
|
45
|
+
'pick frame: AppShell (nav apps) | Layout + LayoutPanel in a start/end slot (multi-pane tools) | plain column (docs/forms)',
|
|
46
|
+
'give every fixed region a width budget, so none negotiates for space at render time',
|
|
47
|
+
'read content to set fill vs capped: tables/charts/boards fill; prose/forms/lists cap via Layout contentWidth',
|
|
48
|
+
'set container policy (rows or card grid) before writing content',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
{
|
|
53
|
+
type: 'prose',
|
|
54
|
+
text: 'verify: every region has a width budget + fill-or-capped + a container policy written down before any content.',
|
|
55
|
+
},
|
|
56
|
+
// Navigation
|
|
57
|
+
null,
|
|
58
|
+
{
|
|
59
|
+
type: 'prose',
|
|
60
|
+
text: 'nav left open? default SideNav: it absorbs destinations you have not planned yet. app type + destination count are guiding indicators, not determining rules.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'list',
|
|
64
|
+
items: [
|
|
65
|
+
'SideNav (default): need grouping, customizable, items carry secondary actions, or must collapse. trackers, consoles, settings usually start here',
|
|
66
|
+
'TopNav: shallow nav you expect to stay shallow, context must stay visible, or control/filter-heavy page; + TabList for a 2nd level. media libraries often sit over grid content',
|
|
67
|
+
'both: a genuine suite. TopNav = ecosystem concerns (context switcher, global search), SideNav = product nav',
|
|
68
|
+
'neither: messaging/feeds use a column frame of rail, nav, stream, panel',
|
|
69
|
+
],
|
|
36
70
|
},
|
|
37
71
|
null,
|
|
38
72
|
{
|
|
39
73
|
type: 'prose',
|
|
40
|
-
text: '
|
|
74
|
+
text: 'verify: you can state the reason in one sentence, and it still holds if the nav doubles. npx astryx build "<idea>" names the closest template, --skeleton shows the pairing wired up.',
|
|
75
|
+
},
|
|
76
|
+
// Best practices
|
|
77
|
+
null,
|
|
78
|
+
{
|
|
79
|
+
type: 'list',
|
|
80
|
+
items: [
|
|
81
|
+
'decide frame + region width budgets + fill-or-capped before content',
|
|
82
|
+
'state the reason for the nav choice, or inherit template pairing',
|
|
83
|
+
'raw px for structural widths; interior = tokens',
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
type: 'list',
|
|
88
|
+
items: [
|
|
89
|
+
'build content-first, Card-wrapping each section',
|
|
90
|
+
'stretch prose/forms/lists across a wide region instead of capping with contentWidth',
|
|
91
|
+
'SideNav when the nav is really filters/controls, or must hold wide elements like breadcrumbs',
|
|
92
|
+
'TopNav when top-slot ownership is unclear, or hierarchy is deep or still growing',
|
|
93
|
+
'both bars when the ecosystem layer is thin',
|
|
94
|
+
'break template nav pairing without a reason',
|
|
95
|
+
],
|
|
41
96
|
},
|
|
42
97
|
],
|
|
43
98
|
},
|
|
44
99
|
{
|
|
45
|
-
section: '
|
|
46
|
-
title: '
|
|
100
|
+
section: 'Structure',
|
|
101
|
+
title: 'Structure',
|
|
47
102
|
content: [
|
|
103
|
+
// Type hierarchy
|
|
104
|
+
null,
|
|
105
|
+
{
|
|
106
|
+
type: 'prose',
|
|
107
|
+
text: 'one lead per region, then rank with weight+color, not size. two text colors only: primary + secondary, nothing dimmer. body copy needs no props.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'list',
|
|
111
|
+
items: [
|
|
112
|
+
'body (default): plain Text, no type/color/size prop',
|
|
113
|
+
'lead: Heading at the level matching page depth, or body Text at a heavier weight',
|
|
114
|
+
'support: step to secondary color, not to a smaller size',
|
|
115
|
+
'meta: the supporting type, or StatusDot/Token instead of prose',
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
null,
|
|
119
|
+
{
|
|
120
|
+
type: 'prose',
|
|
121
|
+
text: 'squint test: read lead, then support, then groups, in order. everything at once = raise contrast (weight/color), not borders and not smaller text.',
|
|
122
|
+
},
|
|
123
|
+
// Containers
|
|
124
|
+
null,
|
|
125
|
+
{
|
|
126
|
+
type: 'prose',
|
|
127
|
+
text: 'weakest container that reads as a group, escalate only when it fails. weakest to strongest:',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: 'list',
|
|
131
|
+
items: [
|
|
132
|
+
'spacing/gap: related items inside one group. the default rhythm',
|
|
133
|
+
'Divider: peers in a dense list/toolbar, or fencing a header from a scrollable body',
|
|
134
|
+
'Section: default page-structure unit, related content under a heading. no border',
|
|
135
|
+
'Card: self-contained widget (KPI tile, chart, gallery entry) or hard boundary',
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
{
|
|
140
|
+
type: 'prose',
|
|
141
|
+
text: 'test: records -> rows (Table columnar, List single-line); self-contained widget or hard boundary -> Card; everything else -> Section.',
|
|
142
|
+
},
|
|
143
|
+
// Headers and footers
|
|
144
|
+
null,
|
|
145
|
+
{
|
|
146
|
+
type: 'prose',
|
|
147
|
+
text: 'a region can pin a header/footer while its body scrolls. both are Layout slots, and padding set once on Layout reaches all three, so header/body/footer share one content line.',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'list',
|
|
151
|
+
items: [
|
|
152
|
+
'LayoutHeader in the header slot: region title + its primary action',
|
|
153
|
+
'Toolbar instead of LayoutHeader when the header carries interactive controls',
|
|
154
|
+
'LayoutFooter in the footer slot: actions that commit the work + must stay reachable',
|
|
155
|
+
'defaultHasDividers on Layout fences both at once, rather than hasDivider per slot',
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
null,
|
|
159
|
+
{
|
|
160
|
+
type: 'prose',
|
|
161
|
+
text: 'verify: scroll the body. header + footer stay put, dividers run full-bleed, all three still share one left content line.',
|
|
162
|
+
},
|
|
163
|
+
// Side panels
|
|
164
|
+
null,
|
|
165
|
+
{
|
|
166
|
+
type: 'prose',
|
|
167
|
+
text: 'master-detail: select a row -> fixed-width side panel, no navigation away.',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: 'list',
|
|
171
|
+
items: [
|
|
172
|
+
'LayoutPanel in the start or end slot of Layout, holding a fixed width budget',
|
|
173
|
+
'hasDivider fences it from content; isScrollable so long detail scrolls on its own',
|
|
174
|
+
'user-adjustable width: useResizable() + ResizeHandle on the panel inner edge. after the panel in a start slot, before it in an end slot w/ isReversed',
|
|
175
|
+
'the handle then owns the divider, so the panel sets hasDivider={false}',
|
|
176
|
+
'EmptyState when nothing is selected, so the region never collapses',
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
null,
|
|
48
180
|
{
|
|
49
181
|
type: 'prose',
|
|
50
|
-
text: '
|
|
182
|
+
text: 'verify: at narrow widths the panel yields width instead of squeezing content (see Breakpoints), and only one element between the regions draws a border.',
|
|
51
183
|
},
|
|
184
|
+
// Best practices
|
|
185
|
+
null,
|
|
52
186
|
{
|
|
53
187
|
type: 'list',
|
|
54
188
|
items: [
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'
|
|
189
|
+
'one lead per region; rank via weight+color; one primary action',
|
|
190
|
+
'leave body copy at its defaults; demote via weight+color, not size',
|
|
191
|
+
'default Section; weakest container that reads as a group',
|
|
192
|
+
'collections = rows (Table/List), edge-to-edge with dividers',
|
|
193
|
+
'side panel on select; it yields width at narrow sizes',
|
|
59
194
|
],
|
|
60
195
|
},
|
|
61
196
|
{
|
|
62
197
|
type: 'list',
|
|
63
198
|
items: [
|
|
64
|
-
'
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
'
|
|
199
|
+
'grey + shrink body copy, so a whole region reads as metadata',
|
|
200
|
+
'the disabled color for content; it fails contrast, it is for disabled controls',
|
|
201
|
+
'card soup: each record in its own Card',
|
|
202
|
+
'cards-in-cards, or full-width Cards as page structure',
|
|
203
|
+
'a header/footer rebuilt inside the body, where it scrolls away with the rows',
|
|
204
|
+
'flexbox soup instead of Grid/Layout/Section/FormLayout',
|
|
205
|
+
'two competing primary actions in one region',
|
|
206
|
+
'Badge as decoration; use StatusDot/Token for status',
|
|
68
207
|
],
|
|
69
208
|
},
|
|
70
209
|
],
|
|
71
210
|
},
|
|
72
211
|
{
|
|
73
|
-
section: '
|
|
74
|
-
title: '
|
|
212
|
+
section: 'Spacing',
|
|
213
|
+
title: 'Spacing',
|
|
75
214
|
content: [
|
|
215
|
+
// Alignment
|
|
216
|
+
null,
|
|
217
|
+
{
|
|
218
|
+
type: 'prose',
|
|
219
|
+
text: 'container owns padding + child gaps; children zero margins; interior spacing = token. one content line per region, hold the line not the padding: container_inset = content_line - component_intrinsic_inset.',
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
type: 'list',
|
|
223
|
+
items: [
|
|
224
|
+
'Text/Heading carry no inset -> container takes the full padding',
|
|
225
|
+
'List/Tab/Menu/nav items carry a small inset -> container gives up padding, component owns the line',
|
|
226
|
+
'Table cells carry a larger inset -> container gives up padding, cell owns the line',
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
null,
|
|
76
230
|
{
|
|
77
231
|
type: 'prose',
|
|
78
|
-
text: '
|
|
232
|
+
text: 'verify: draw one vertical line down the left. every label touches it; only hover/selected backgrounds cross it.',
|
|
79
233
|
},
|
|
234
|
+
// Rhythm
|
|
80
235
|
null,
|
|
236
|
+
{
|
|
237
|
+
type: 'prose',
|
|
238
|
+
text: 'grouping = contrast between tight and generous gaps, not one repeated value. same step everywhere = proximity does no work.',
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
type: 'list',
|
|
242
|
+
items: [
|
|
243
|
+
'tight gaps bind: the smallest steps, inside an item or field',
|
|
244
|
+
'generous gaps separate: several steps up, between sections',
|
|
245
|
+
'reach for the in-between steps to tune cadence, not the same two values everywhere',
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
null,
|
|
249
|
+
{
|
|
250
|
+
type: 'prose',
|
|
251
|
+
text: 'verify: borders removed, you can still name the groups from spacing alone. cannot = intervals too uniform. form fields excepted: FormLayout owns their spacing.',
|
|
252
|
+
},
|
|
253
|
+
// Density and size
|
|
254
|
+
null,
|
|
255
|
+
{
|
|
256
|
+
type: 'prose',
|
|
257
|
+
text: 'density by use frequency; every control in a row shares one size so heights share a baseline.',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
type: 'list',
|
|
261
|
+
items: [
|
|
262
|
+
'compact: high-volume, fast scan (logs, monitors, large datasets)',
|
|
263
|
+
'balanced: most Table/List surfaces',
|
|
264
|
+
'spacious: low-frequency or high-stakes rows (settings, short selection list)',
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
null,
|
|
268
|
+
{
|
|
269
|
+
type: 'prose',
|
|
270
|
+
text: 'verify: one size per row, paired with the density of the region it sits in.',
|
|
271
|
+
},
|
|
272
|
+
// Best practices
|
|
273
|
+
null,
|
|
274
|
+
{
|
|
275
|
+
type: 'list',
|
|
276
|
+
items: [
|
|
277
|
+
'container owns padding; children zero margins',
|
|
278
|
+
'one content line: text on line, hover bleeds to edge',
|
|
279
|
+
'one padding token across region header/body/footer',
|
|
280
|
+
'contrast tight vs generous gaps',
|
|
281
|
+
'one control size per row; density by use frequency',
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
type: 'list',
|
|
286
|
+
items: [
|
|
287
|
+
'double padding (component past its heading); keep one inset owner',
|
|
288
|
+
'raw px for interior spacing; tokens only',
|
|
289
|
+
'one repeated gap everywhere',
|
|
290
|
+
'mixed control sizes in one row',
|
|
291
|
+
],
|
|
292
|
+
},
|
|
81
293
|
],
|
|
82
294
|
},
|
|
83
295
|
{
|
|
84
|
-
section: '
|
|
85
|
-
title: '
|
|
296
|
+
section: 'Breakpoints',
|
|
297
|
+
title: 'Breakpoints',
|
|
86
298
|
content: [
|
|
299
|
+
// Responsive contract
|
|
300
|
+
null,
|
|
87
301
|
{
|
|
88
302
|
type: 'prose',
|
|
89
|
-
text: '
|
|
303
|
+
text: 'lock what each region does as width changes; pair each contract line with the prop/hook that enforces it.',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
type: 'list',
|
|
307
|
+
items: [
|
|
308
|
+
'divide: how many regions survive at each width',
|
|
309
|
+
'reveal: which regions earn their width only when there is room, opening on demand below that',
|
|
310
|
+
'resize: content flexes, fixed regions hold their budgets, text capped by contentWidth so line length holds',
|
|
311
|
+
'swap: nav -> MobileNav at the AppShell mobileNav breakpoint; side panel -> Dialog/BottomSheet via useMediaQuery',
|
|
312
|
+
],
|
|
90
313
|
},
|
|
91
314
|
null,
|
|
315
|
+
{
|
|
316
|
+
type: 'prose',
|
|
317
|
+
text: 'verify: every contract line names a mechanism, so the comment cannot drift from the behavior.',
|
|
318
|
+
},
|
|
319
|
+
// Best practices
|
|
320
|
+
null,
|
|
321
|
+
{
|
|
322
|
+
type: 'list',
|
|
323
|
+
items: [
|
|
324
|
+
'write the contract down for every region before calling the layout done',
|
|
325
|
+
'decide per region: revealed, resized, or swapped at each width',
|
|
326
|
+
'drop a region rather than let it fight for width it lacks',
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
type: 'list',
|
|
331
|
+
items: [
|
|
332
|
+
'3 regions at a width where none has usable space',
|
|
333
|
+
'shrink every region uniformly instead of swapping/dropping one',
|
|
334
|
+
'a CSS breakpoint the contract comment never mentions',
|
|
335
|
+
],
|
|
336
|
+
},
|
|
92
337
|
],
|
|
93
338
|
},
|
|
94
339
|
],
|