@12-apps/ui 1.6.3 → 1.7.0

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 (32) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
  3. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
  4. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  5. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  6. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  7. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  8. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  9. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  10. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  11. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  12. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  13. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  14. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  15. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  16. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  17. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  18. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  19. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  20. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  21. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  22. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  23. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  24. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  25. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  26. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  27. package/src/components/data-display/DataViews/index.ts +1 -0
  28. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  29. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  30. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  31. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  32. package/src/components/layout/ContentToolbar/MultiSelectTriggers.tsx +39 -1
@@ -14,7 +14,7 @@ import {
14
14
  } from "./data-views-layout-context";
15
15
  import { FilterDialog, GridFilterPanel } from "./data-views-filter-panel";
16
16
  import { GridMain } from "./data-views-grid-bodies";
17
- import { InlineFilterChips, InlineFilterControls } from "./data-views-inline-bar";
17
+ import { InlineFilterControls } from "./data-views-inline-bar";
18
18
  import { toOverflowFields, useFilterOverflow, type OverflowSplit } from "./data-views-overflow";
19
19
  import { ShellToolbar } from "./data-views-shell-toolbar";
20
20
  import type { DisplayPanelView } from "./data-views-display-panel";
@@ -22,7 +22,8 @@ import type { DataViewExport } from "./data-views-export";
22
22
  import { DataViewsEmpty } from "./data-views-empty";
23
23
  import { DataViewsPagination } from "./data-views-pagination";
24
24
  import type { BoardConfig } from "./DataViewsBoard";
25
- import { DataViewsScopeTabs, type ScopeConfig } from "./data-views-scopes";
25
+ import { type ScopeConfig } from "./data-views-scopes";
26
+ import { ScopeTabsSlot } from "./data-views-scope-tabs";
26
27
  import { togglePillValues } from "./data-views-grid-helpers";
27
28
  import type {
28
29
  DataViewCardSelection,
@@ -233,8 +234,13 @@ interface GridShellProps<T extends Record<string, unknown>> {
233
234
  board?: BoardConfig<T>;
234
235
  /** Opt-in "Lista" layout: one full-width, entity-rendered row per record. */
235
236
  renderListRow?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
236
- /** The page-level partition rendered as tabs above the toolbar. */
237
+ /** The page-level partition rendered as tabs under the toolbar. */
237
238
  scopes?: ScopeConfig[];
239
+ /**
240
+ * The row field the scopes partition by. Compared against the board's
241
+ * `groupBy` so the two are not shown as the same partition twice.
242
+ */
243
+ scopeFieldId?: string;
238
244
  /** Per-sort-field value kind, so directions read in the column's own terms. */
239
245
  sortKinds?: Record<string, string>;
240
246
  /** The saved-view chrome bracketing the Exibir panel. */
@@ -289,14 +295,6 @@ function ShellStack<T extends Record<string, unknown>>({
289
295
  <TableFilter open={c.filterOpen} onOpenChange={c.setFilterOpen} hasActiveFilters={c.activeFilterCount > 0}>
290
296
  <Stack spacing={0} data-testid={dataTestId ? `${dataTestId}-container` : undefined}>
291
297
  <GridHeaderRow title={props.title} headerActions={props.headerActions} testIdPrefix={testIdPrefix} />
292
- {/* Renders nothing (and reserves nothing) for an empty scope list. */}
293
- <DataViewsScopeTabs
294
- scopes={scopes}
295
- value={c.scope}
296
- onChange={c.setScope}
297
- counts={c.scopeCounts}
298
- testIdPrefix={testIdPrefix}
299
- />
300
298
  <ShellToolbar
301
299
  c={c}
302
300
  rows={props.rows}
@@ -308,6 +306,7 @@ function ShellStack<T extends Record<string, unknown>>({
308
306
  <InlineFilterControls
309
307
  {...filterProps}
310
308
  split={split}
309
+ activeFilterCount={c.activeFilterCount}
311
310
  onControlOpenChange={onControlOpenChange}
312
311
  />
313
312
  ) : undefined
@@ -318,11 +317,18 @@ function ShellStack<T extends Record<string, unknown>>({
318
317
  bulkActions={props.bulkActions}
319
318
  toolbarRightSlot={props.toolbarRightSlot}
320
319
  compactControls={showInline && split.compactControls}
320
+ counterHidden={showInline && split.counterHidden}
321
321
  showInline={showInline}
322
322
  />
323
- {/* Only the applied-filter chips live under the toolbar now — the
324
- controls themselves ride on the toolbar line above. */}
325
- {inlineVisible && <InlineFilterChips {...filterProps} />}
323
+ <ScopeTabsSlot
324
+ scopes={scopes}
325
+ scopeFieldId={props.scopeFieldId}
326
+ board={props.board}
327
+ value={c.scope}
328
+ onChange={c.setScope}
329
+ counts={c.scopeCounts}
330
+ testIdPrefix={testIdPrefix}
331
+ />
326
332
  <TableFilter.Layout>
327
333
  <TableFilter.Main>
328
334
  <GridMain
@@ -3,24 +3,29 @@
3
3
  /**
4
4
  * The INLINE filter bar — the horizontal row of controls a grid renders instead
5
5
  * of the slide-in panel on a wide screen (`inlineFilters`): the compact keyword
6
- * box, one pill per facet, one pill per range, and the "Filtros ativos" chips
7
- * underneath.
6
+ * box, one pill per facet, one pill per range, and "Mais N" for whatever had no
7
+ * room.
8
+ *
9
+ * There is no "Filtros ativos" row beneath it. Listing every applied filter a
10
+ * second time, under the controls already showing them, was the same
11
+ * information twice — and put the ✕ that removes a filter somewhere other than
12
+ * the control that applied it. Each pill carries its own.
8
13
  *
9
14
  * Split from `data-views-filter-panel` because they are two surfaces, not one:
10
15
  * the panel is a column of stacked fields, this is a row of pills, and the only
11
16
  * thing they share is the prop shape they are both driven by.
12
17
  */
13
- import { Box, Button, Chip, Typography } from "@mui/material";
18
+ import { Box } from "@mui/material";
14
19
  import { useState } from "react";
15
20
 
16
21
  import { MultiSelectDropdown } from "../../layout/ContentToolbar";
17
22
 
23
+ import { ClearAllControl, CloseSearchControl } from "./data-views-bar-controls";
18
24
  import type { GridFilterPanelProps } from "./data-views-filter-panel";
19
25
  import { MoreFilters } from "./data-views-more-filters";
20
26
  import type { OverflowField, OverflowSplit } from "./data-views-overflow";
21
27
  import { CollapsedSearch, InlineKeyword } from "./data-views-search";
22
28
  import { RangePill } from "./data-views-range-pill";
23
- import { isRangeSet, rangeChipLabel } from "./data-views-range-values";
24
29
  import type { RangeValue } from "./data-views-types";
25
30
 
26
31
 
@@ -33,6 +38,7 @@ function SearchSlot({
33
38
  onSearchChange,
34
39
  collapsed,
35
40
  expanded,
41
+ fill,
36
42
  onOpen,
37
43
  onEscape,
38
44
  testIdPrefix,
@@ -41,6 +47,8 @@ function SearchSlot({
41
47
  onSearchChange: (value: string) => void;
42
48
  collapsed: boolean;
43
49
  expanded: boolean;
50
+ /** The box owns the cluster alone, so it may shrink below its usual floor. */
51
+ fill: boolean;
44
52
  onOpen: () => void;
45
53
  onEscape: () => void;
46
54
  testIdPrefix: string;
@@ -57,6 +65,7 @@ function SearchSlot({
57
65
  testId={`${testIdPrefix}-search-all`}
58
66
  autoFocus={expanded}
59
67
  onEscape={onEscape}
68
+ fill={fill}
60
69
  />
61
70
  );
62
71
  }
@@ -80,70 +89,6 @@ type InlineFilterBarProps<T extends Record<string, unknown>> = Pick<
80
89
  split: OverflowSplit<T>;
81
90
  };
82
91
 
83
- /** One removable active-filter chip: a search term, a pill value or a window. */
84
- interface ActiveChip {
85
- key: string;
86
- label: string;
87
- onDelete: () => void;
88
- }
89
-
90
- /**
91
- * Every applied filter flattened into removable chips. A pill contributes one
92
- * chip per selected value (deleting one leaves the rest), while a RANGE
93
- * contributes a single chip for the whole window — "remove the period" means
94
- * both ends, not an arbitrary one.
95
- */
96
- function activeChips<T extends Record<string, unknown>>({
97
- search,
98
- fields,
99
- rangeFields,
100
- pills,
101
- ranges,
102
- onSearchChange,
103
- onTogglePill,
104
- onChangeRange,
105
- }: Pick<
106
- GridFilterPanelProps<T>,
107
- | "search"
108
- | "fields"
109
- | "rangeFields"
110
- | "pills"
111
- | "ranges"
112
- | "onSearchChange"
113
- | "onTogglePill"
114
- | "onChangeRange"
115
- >): ActiveChip[] {
116
- const searchChip: ActiveChip[] =
117
- search.trim() === ""
118
- ? []
119
- : [{ key: "__search", label: `Busca: ${search}`, onDelete: () => onSearchChange("") }];
120
- const pillChips: ActiveChip[] = fields.flatMap((field) =>
121
- (pills[field.id] ?? []).map((value) => ({
122
- key: `${field.id}:${value}`,
123
- label: `${field.label}: ${field.options.find((option) => option.value === value)?.label ?? value}`,
124
- onDelete: () => onTogglePill(field.id, value, false),
125
- })),
126
- );
127
- const rangeChips: ActiveChip[] = rangeFields.flatMap((field) => {
128
- const range = ranges[field.id];
129
- if (!range || !isRangeSet(range)) return [];
130
- return [
131
- {
132
- key: `range:${field.id}`,
133
- label: rangeChipLabel(field, range),
134
- onDelete: () => onChangeRange(field.id, {}),
135
- },
136
- ];
137
- });
138
- return [...searchChip, ...pillChips, ...rangeChips];
139
- }
140
-
141
- /**
142
- * A horizontal filter bar (used instead of the slide-in {@link GridFilterPanel} on
143
- * wide screens): the compact keyword search, each field as a rounded pill dropdown,
144
- * and — when anything is applied — a row of removable "active filter" chips. Range
145
- * filters are not shown here (the slide-in panel keeps those).
146
- */
147
92
  /**
148
93
  * One filter control on the bar: a multi-select pill or a min/max range. The
149
94
  * two are the same thing from the bar's point of view — a labelled control that
@@ -201,6 +146,46 @@ function InlineControl<T extends Record<string, unknown>>({
201
146
  return null;
202
147
  }
203
148
 
149
+ /**
150
+ * WHICH OF THE SEARCH'S THREE STATES THE BAR IS IN.
151
+ *
152
+ * Resting, expanded-and-sharing, or expanded-and-owning-the-cluster. They were
153
+ * one boolean until a phone showed why they are not:
154
+ *
155
+ * - `fill` — an expanded box drops its usual 200px floor and takes whatever the
156
+ * cluster has. That floor is right for a box that lives on the row; it is
157
+ * wrong for one expanded into a cluster the ladder sized for an icon, where
158
+ * insisting on it made the row scroll sideways (154px of overhang at 320px,
159
+ * 84px at 390px, 42px at 500px).
160
+ * - `takesOver` — the narrower case, where shrinking has run out of road and
161
+ * what is left would be too small to read. Only then do the filters stand
162
+ * down. A large phone has room to share, and evicting them there cost the
163
+ * operator their filters for nothing.
164
+ *
165
+ * Once CLICKED the box stays open until Escape or its ✕, so typing is never
166
+ * interrupted by a re-measure.
167
+ */
168
+ function useSearchMode<T extends Record<string, unknown>>(
169
+ split: OverflowSplit<T>,
170
+ ): {
171
+ showBox: boolean;
172
+ expanded: boolean;
173
+ fill: boolean;
174
+ takesOver: boolean;
175
+ open: () => void;
176
+ close: () => void;
177
+ } {
178
+ const [searchOpen, setSearchOpen] = useState(false);
179
+ return {
180
+ showBox: !split.searchCollapsed || searchOpen,
181
+ expanded: searchOpen,
182
+ fill: split.searchCollapsed && searchOpen,
183
+ takesOver: searchOpen && split.searchTakeover,
184
+ open: () => setSearchOpen(true),
185
+ close: () => setSearchOpen(false),
186
+ };
187
+ }
188
+
204
189
  /**
205
190
  * The filter CONTROLS alone — search, the fields that fit, and "Mais N" for the
206
191
  * ones that don't — with no row chrome of its own, because it is rendered ON
@@ -219,8 +204,12 @@ export function InlineFilterControls<T extends Record<string, unknown>>({
219
204
  onChangeRange,
220
205
  onClearField,
221
206
  split,
207
+ onClearAll,
208
+ activeFilterCount,
222
209
  onControlOpenChange,
223
- }: Omit<InlineFilterBarProps<T>, "fields" | "rangeFields" | "onClearAll"> & {
210
+ }: Omit<InlineFilterBarProps<T>, "fields" | "rangeFields"> & {
211
+ /** How many filters are applied — decides whether "Limpar" is on the bar at all. */
212
+ activeFilterCount: number;
224
213
  /** Reports a control opening/closing so the shell can freeze the measurement. */
225
214
  onControlOpenChange?: (open: boolean) => void;
226
215
  }): React.JSX.Element {
@@ -230,11 +219,8 @@ export function InlineFilterControls<T extends Record<string, unknown>>({
230
219
  // `RESERVED` prices the counter and Exibir/Exportar, so measuring only the
231
220
  // controls would charge for that furniture a second time and collapse the
232
221
  // ladder while the row still had hundreds of free pixels.
233
- const { inline, overflow, searchCollapsed } = split;
234
- // Expanded by the operator: a collapsed search that was CLICKED stays open
235
- // until Escape, so typing is never interrupted by a re-measure.
236
- const [searchOpen, setSearchOpen] = useState(false);
237
- const showBox = !searchCollapsed || searchOpen;
222
+ const { inline, overflow, compactControls, clearAllHidden } = split;
223
+ const { showBox, expanded, fill, takesOver, open, close } = useSearchMode(split);
238
224
  return (
239
225
  <Box
240
226
  data-testid={`${testIdPrefix}-inline-filters`}
@@ -253,111 +239,104 @@ export function InlineFilterControls<T extends Record<string, unknown>>({
253
239
  search={search}
254
240
  onSearchChange={onSearchChange}
255
241
  collapsed={!showBox}
256
- expanded={searchOpen}
257
- onOpen={() => setSearchOpen(true)}
258
- onEscape={() => setSearchOpen(false)}
242
+ expanded={expanded}
243
+ fill={fill}
244
+ onOpen={open}
245
+ onEscape={close}
259
246
  testIdPrefix={testIdPrefix}
260
247
  />
261
- {inline.map((field) => (
262
- <InlineControl
263
- key={field.id}
264
- field={field}
265
- onOpenChange={onControlOpenChange}
266
- pills={pills}
267
- ranges={ranges}
268
- onTogglePill={onTogglePill}
269
- onChangeRange={onChangeRange}
270
- onClearField={onClearField}
271
- testIdPrefix={testIdPrefix}
272
- />
273
- ))}
274
- <MoreFilters
275
- fields={overflow}
248
+ {takesOver && <CloseSearchControl onClose={close} testIdPrefix={testIdPrefix} />}
249
+ {!takesOver && (
250
+ <FilterSlots
251
+ inline={inline}
252
+ overflow={overflow}
276
253
  pills={pills}
277
254
  ranges={ranges}
278
255
  onTogglePill={onTogglePill}
279
256
  onChangeRange={onChangeRange}
257
+ onClearField={onClearField}
258
+ onClearAll={onClearAll}
259
+ onControlOpenChange={onControlOpenChange}
260
+ activeFilterCount={activeFilterCount}
261
+ compactControls={compactControls}
262
+ clearAllHidden={clearAllHidden}
280
263
  testIdPrefix={testIdPrefix}
281
264
  />
265
+ )}
282
266
  </Box>
283
267
  );
284
268
  }
285
269
 
286
270
  /**
287
- * The applied-filter chips, on their own line under the toolbar. Split from the
288
- * controls because the two no longer share a row: the controls sit on the
289
- * toolbar, and this renders nothing at all until something is applied.
271
+ * Everything to the right of the search: the fields that fit, "Mais N" for the
272
+ * ones that don't, and "Limpar". Split out of `InlineFilterControls` only to
273
+ * keep that function inside the size gate — the three are one cluster and the
274
+ * order they render in is the order they shed in.
290
275
  */
291
- export function InlineFilterChips<T extends Record<string, unknown>>({
292
- testIdPrefix,
293
- search,
294
- fields,
295
- rangeFields,
276
+ function FilterSlots<T extends Record<string, unknown>>({
277
+ inline,
278
+ overflow,
296
279
  pills,
297
280
  ranges,
298
- onSearchChange,
299
281
  onTogglePill,
300
282
  onChangeRange,
283
+ onClearField,
301
284
  onClearAll,
302
- }: Omit<InlineFilterBarProps<T>, "split" | "onClearField">): React.JSX.Element | null {
303
- const chips = activeChips({
304
- search,
305
- fields,
306
- rangeFields,
307
- pills,
308
- ranges,
309
- onSearchChange,
310
- onTogglePill,
311
- onChangeRange,
312
- });
313
- if (chips.length === 0) return null;
314
- return (
315
- <Box sx={{ py: 1.5 }}>
316
- <ActiveChipRow chips={chips} testIdPrefix={testIdPrefix} onClearAll={onClearAll} />
317
- </Box>
318
- );
319
- }
320
-
321
- /**
322
- * The "Filtros ativos:" row — one removable chip per applied filter plus the
323
- * single "Limpar" that drops them all. Renders nothing when nothing is applied,
324
- * so the bar collapses to its controls.
325
- */
326
- function ActiveChipRow({
327
- chips,
285
+ onControlOpenChange,
286
+ activeFilterCount,
287
+ compactControls,
288
+ clearAllHidden,
328
289
  testIdPrefix,
329
- onClearAll,
330
290
  }: {
331
- chips: ActiveChip[];
332
- testIdPrefix: string;
291
+ inline: OverflowField<T>[];
292
+ overflow: OverflowField<T>[];
293
+ pills: Record<string, string[]>;
294
+ ranges: Record<string, RangeValue>;
295
+ onTogglePill: (fieldId: string, value: string, checked: boolean) => void;
296
+ onChangeRange: (fieldId: string, range: RangeValue) => void;
297
+ onClearField: (fieldId: string) => void;
333
298
  onClearAll: () => void;
334
- }): React.JSX.Element | null {
335
- if (chips.length === 0) return null;
299
+ onControlOpenChange?: (open: boolean) => void;
300
+ activeFilterCount: number;
301
+ compactControls: boolean;
302
+ clearAllHidden: boolean;
303
+ testIdPrefix: string;
304
+ }): React.JSX.Element {
336
305
  return (
337
- <Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1 }}>
338
- <Typography component="span" sx={{ fontSize: "0.75rem", color: "text.secondary" }}>
339
- Filtros ativos:
340
- </Typography>
341
- {chips.map((chip) => (
342
- <Chip
343
- key={chip.key}
344
- label={chip.label}
345
- size="small"
346
- variant="outlined"
347
- onDelete={chip.onDelete}
348
- data-testid={`${testIdPrefix}-active-${chip.key}`}
306
+ <>
307
+ {inline.map((field) => (
308
+ <InlineControl
309
+ key={field.id}
310
+ field={field}
311
+ onOpenChange={onControlOpenChange}
312
+ pills={pills}
313
+ ranges={ranges}
314
+ onTogglePill={onTogglePill}
315
+ onChangeRange={onChangeRange}
316
+ onClearField={onClearField}
317
+ testIdPrefix={testIdPrefix}
349
318
  />
350
319
  ))}
351
- <Button
352
- variant="text"
353
- size="small"
354
- color="inherit"
355
- onClick={onClearAll}
356
- data-testid={`${testIdPrefix}-clear-filters`}
357
- sx={{ fontSize: "0.75rem", color: "text.secondary", textTransform: "none" }}
358
- >
359
- Limpar
360
- </Button>
361
- </Box>
320
+ <MoreFilters
321
+ onOpenChange={onControlOpenChange}
322
+ onClearAll={onClearAll}
323
+ anyApplied={activeFilterCount > 0}
324
+ compact={compactControls}
325
+ fields={overflow}
326
+ pills={pills}
327
+ ranges={ranges}
328
+ onTogglePill={onTogglePill}
329
+ onChangeRange={onChangeRange}
330
+ testIdPrefix={testIdPrefix}
331
+ />
332
+ {activeFilterCount > 0 && !clearAllHidden && (
333
+ <ClearAllControl
334
+ onClearAll={onClearAll}
335
+ compact={compactControls}
336
+ testIdPrefix={testIdPrefix}
337
+ />
338
+ )}
339
+ </>
362
340
  );
363
341
  }
342
+
@@ -88,6 +88,27 @@ export const DENSITY_CARD_COLUMNS: Record<DataViewsDensity, number> = {
88
88
  comfortable: 3,
89
89
  };
90
90
 
91
+ /**
92
+ * The BOARD's column-width multiplier per density.
93
+ *
94
+ * Density means "how much air a record gets", and on a board that is the column
95
+ * width — narrow columns fit more etapas on screen, wide ones give each card
96
+ * room to be read. Without this the board was the one layout the Exibição tab
97
+ * offered nothing for: the format tile switched to Quadro and the whole density
98
+ * section vanished, which reads as a control that broke rather than one that
99
+ * does not apply.
100
+ *
101
+ * The spread is deliberately narrower than the card grid's. A board column has
102
+ * a hard floor (`MIN_COLUMN_WIDTH` — a card below it stops being readable), so
103
+ * a multiplier that undershoots it buys nothing and makes "Estreita" a tile
104
+ * that does nothing on a phone.
105
+ */
106
+ export const DENSITY_BOARD_SCALE: Record<DataViewsDensity, number> = {
107
+ compact: 0.95,
108
+ cozy: 1,
109
+ comfortable: 1.25,
110
+ };
111
+
91
112
  /**
92
113
  * Map the 0–100 zoom to the card SIZE multiplier. This is the single knob: the
93
114
  * card's width is {@link BASE_CARD_WIDTH} × scale and its padding + typography