its-swiss 0.1.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +57 -0
  3. data/LICENSE +21 -0
  4. data/README.md +262 -0
  5. data/app/assets/javascripts/its_swiss/clipboard_controller.js +33 -0
  6. data/app/assets/stylesheets/its-swiss.css +16 -0
  7. data/app/assets/stylesheets/its_swiss/components.css +403 -0
  8. data/app/assets/stylesheets/its_swiss/grid.css +67 -0
  9. data/app/assets/stylesheets/its_swiss/reset.css +50 -0
  10. data/app/assets/stylesheets/its_swiss/specimen.css +37 -0
  11. data/app/assets/stylesheets/its_swiss/tokens.css +142 -0
  12. data/app/assets/stylesheets/its_swiss/transitions.css +47 -0
  13. data/app/assets/stylesheets/its_swiss/type.css +109 -0
  14. data/app/controllers/its_swiss/specimen_controller.rb +24 -0
  15. data/app/helpers/its_swiss/application_helper.rb +73 -0
  16. data/app/views/its_swiss/shared/_errors.html.erb +14 -0
  17. data/app/views/its_swiss/shared/_flash.html.erb +8 -0
  18. data/app/views/its_swiss/shared/_masthead.html.erb +15 -0
  19. data/app/views/its_swiss/shared/_pagination.html.erb +26 -0
  20. data/app/views/its_swiss/specimen/_buttons.html.erb +15 -0
  21. data/app/views/its_swiss/specimen/_footer.html.erb +7 -0
  22. data/app/views/its_swiss/specimen/_form.html.erb +18 -0
  23. data/app/views/its_swiss/specimen/_grid.html.erb +14 -0
  24. data/app/views/its_swiss/specimen/_masthead.html.erb +15 -0
  25. data/app/views/its_swiss/specimen/_messages.html.erb +13 -0
  26. data/app/views/its_swiss/specimen/_pagination.html.erb +4 -0
  27. data/app/views/its_swiss/specimen/_pairs.html.erb +8 -0
  28. data/app/views/its_swiss/specimen/_table.html.erb +15 -0
  29. data/app/views/its_swiss/specimen/_take.html.erb +19 -0
  30. data/app/views/its_swiss/specimen/_type.html.erb +24 -0
  31. data/app/views/its_swiss/specimen/_values.html.erb +18 -0
  32. data/app/views/its_swiss/specimen/show.html.erb +27 -0
  33. data/app/views/layouts/its_swiss/shell.html.erb +55 -0
  34. data/config/importmap.rb +5 -0
  35. data/config/routes.rb +6 -0
  36. data/lib/generators/its_swiss/install/install_generator.rb +70 -0
  37. data/lib/generators/its_swiss/install/templates/theme.css +44 -0
  38. data/lib/its-swiss.rb +3 -0
  39. data/lib/its_swiss/engine.rb +39 -0
  40. data/lib/its_swiss/form_builder.rb +140 -0
  41. data/lib/its_swiss/version.rb +3 -0
  42. data/lib/its_swiss.rb +46 -0
  43. metadata +172 -0
@@ -0,0 +1,403 @@
1
+ /* Components.
2
+ *
3
+ * Separation is done with hairlines and whitespace. There are no cards, no
4
+ * rounded corners and no shadows in this file, and nothing is set in
5
+ * capitals. A component here says only how it differs from the registers and
6
+ * the two arrangements already declared in type.css and grid.css.
7
+ *
8
+ * Every one of these is laid out on its own, in flex, and never on the page's
9
+ * field. The field is the application's — a component that placed itself on
10
+ * it would be a component that broke the moment the application decided its
11
+ * problem had eight fields rather than six.
12
+ */
13
+
14
+ @layer its-swiss.components {
15
+ /* --- Masthead and navigation -------------------------------------------
16
+ A band, a mark at one end, the destinations at the other, and a hairline
17
+ underneath. The mark is the application's to fill; the library only knows
18
+ that it does not break and that the nav sits beside it. */
19
+
20
+ .masthead {
21
+ display: flex;
22
+ flex-wrap: wrap;
23
+ align-items: baseline;
24
+ gap: var(--space-2) var(--space-4);
25
+ padding-block: var(--space-3);
26
+ border-bottom: var(--rule-hair) solid var(--rule);
27
+ }
28
+
29
+ /* A wordmark is not free text and may not be broken, either inside itself
30
+ or away from whatever leads it. */
31
+ .masthead__mark {
32
+ font-weight: 700;
33
+ letter-spacing: -0.015em;
34
+ white-space: nowrap;
35
+ }
36
+
37
+ .masthead__mark a { text-decoration: none; }
38
+
39
+ .masthead__mark a:hover { text-decoration: underline; text-underline-offset: 0.2em; }
40
+
41
+ /* Off the left edge rather than pushed to the right: the asymmetry is the
42
+ point, and it lines the nav up with the body column below it. An
43
+ application that wants it flush right sets margin-inline-start: auto,
44
+ which is one line in its own stylesheet. */
45
+ .nav {
46
+ display: flex;
47
+ flex-wrap: wrap;
48
+ align-items: baseline;
49
+ gap: var(--space-3);
50
+ }
51
+
52
+ .nav a { text-decoration: none; }
53
+
54
+ .nav a:hover { color: var(--ink); }
55
+
56
+ /* Where you are, said twice. Colour alone is not a signal — a reader who
57
+ cannot separate the accent from the quiet gray would have no current
58
+ state at all — so the weight says it as well. */
59
+ .nav [aria-current="page"] {
60
+ color: var(--accent);
61
+ font-weight: 700;
62
+ }
63
+
64
+ /* --- Footer -------------------------------------------------------------
65
+ A hairline, the micro register, and whatever the application has to say
66
+ after the page. It carries no links of its own: what belongs down there
67
+ is not something a library can know. */
68
+
69
+ .footer {
70
+ margin-top: var(--space-13);
71
+ padding-block: var(--space-3);
72
+ border-top: var(--rule-hair) solid var(--rule);
73
+ font-size: var(--size-1);
74
+ line-height: var(--space-3);
75
+ }
76
+
77
+ /* --- Rules --------------------------------------------------------------
78
+ The one horizontal mark in the style, at the two weights tokens.css
79
+ declares. */
80
+
81
+ hr {
82
+ height: 0;
83
+ border: 0;
84
+ border-top: var(--rule-hair) solid var(--rule);
85
+ margin-block: var(--space-3);
86
+ }
87
+
88
+ hr.rule--heavy { border-top-width: var(--rule-heavy); border-color: var(--ink); }
89
+
90
+ /* --- Table --------------------------------------------------------------
91
+ A grid of values, ruled horizontally and not vertically: the columns are
92
+ already held apart by the type, and a vertical rule between them draws a
93
+ box, which this style does not do. No zebra striping for the same reason
94
+ — a filled row is a filled area, and filled areas are the accent's alone.
95
+
96
+ Figures are tabular, because a column of numbers is read down. */
97
+
98
+ .table { width: 100%; }
99
+
100
+ .table caption {
101
+ color: var(--ink-quiet);
102
+ margin-bottom: var(--space-1);
103
+ }
104
+
105
+ .table th,
106
+ .table td {
107
+ padding-block: var(--space-1);
108
+ padding-inline-end: var(--space-3);
109
+ border-bottom: var(--rule-hair) solid var(--rule);
110
+ vertical-align: baseline;
111
+ }
112
+
113
+ .table th {
114
+ color: var(--ink-quiet);
115
+ font-weight: 700;
116
+ }
117
+
118
+ .table thead th { border-bottom-color: var(--ink); }
119
+
120
+ .table td:last-child,
121
+ .table th:last-child { padding-inline-end: 0; }
122
+
123
+ /* A column of numbers reads right-aligned against its own heading. */
124
+ .table .numeric { text-align: right; padding-inline: var(--space-3) 0; }
125
+
126
+ /* --- Definition list ----------------------------------------------------
127
+ Term and value in two columns, the term set in the quiet register and the
128
+ value in ink. The weight tells them apart at a glance rather than making
129
+ a reader count characters. */
130
+
131
+ .pairs {
132
+ display: grid;
133
+ grid-template-columns: max-content 1fr;
134
+ column-gap: var(--space-3);
135
+ row-gap: var(--space-1);
136
+ }
137
+
138
+ .pairs dt { font-weight: 700; }
139
+
140
+ /* When the terms are long enough that a two-column set leaves the values
141
+ stranded across the page. */
142
+ .pairs--stacked {
143
+ grid-template-columns: 1fr;
144
+ row-gap: 0;
145
+ }
146
+
147
+ .pairs--stacked dt { margin-top: var(--space-2); }
148
+
149
+ .pairs--stacked dt:first-child { margin-top: 0; }
150
+
151
+ /* --- Forms --------------------------------------------------------------
152
+ A field is a label, a control and, when there is something to say, a
153
+ hint or the reason it was refused. The control is a rule under the text
154
+ rather than a box around it: the box is a border drawn for its own sake,
155
+ and this style does not draw them. */
156
+
157
+ .form {
158
+ display: flex;
159
+ flex-direction: column;
160
+ align-items: flex-start;
161
+ gap: var(--space-3);
162
+ max-width: var(--measure);
163
+ }
164
+
165
+ .form--inline {
166
+ flex-direction: row;
167
+ flex-wrap: wrap;
168
+ align-items: flex-end;
169
+ gap: var(--space-2);
170
+ }
171
+
172
+ .field {
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: var(--space-1);
176
+ width: 100%;
177
+ }
178
+
179
+ .field--inline {
180
+ flex-direction: row;
181
+ align-items: baseline;
182
+ gap: var(--space-2);
183
+ }
184
+
185
+ .field input,
186
+ .field select,
187
+ .field textarea {
188
+ width: 100%;
189
+ border: 0;
190
+ border-bottom: var(--rule-hair) solid var(--ink);
191
+ background: transparent;
192
+ color: inherit;
193
+ font: inherit;
194
+ /* No padding above it: the rule belongs under the text, not a line's
195
+ height below it, or the label reads as belonging to nothing. */
196
+ padding-block: 0 var(--space-1);
197
+ }
198
+
199
+ .field textarea { resize: vertical; }
200
+
201
+ /* A checkbox or a radio is the one control whose label sits beside it. */
202
+ .choice {
203
+ display: inline-flex;
204
+ align-items: baseline;
205
+ gap: var(--space-1);
206
+ }
207
+
208
+ .choice input { width: auto; border: revert; }
209
+
210
+ /* Refused. The accent is the whole signal here and it is a rule, not a
211
+ fill: the field is still the field. */
212
+ .field--invalid input,
213
+ .field--invalid select,
214
+ .field--invalid textarea { border-bottom-color: var(--accent); }
215
+
216
+ .field__error {
217
+ color: var(--accent);
218
+ font-size: var(--size-1);
219
+ line-height: var(--space-2);
220
+ }
221
+
222
+ /* --- Buttons ------------------------------------------------------------
223
+ A button is a block; a link is underlined text. Both at once leaves the
224
+ page saying "follow me" and "press me" in the same breath.
225
+
226
+ Every one of them is the same box, so a row of them sits on one line and
227
+ the only difference between them is what fills it. No radius, no shadow,
228
+ and the keyline is the hairline drawn everywhere else. */
229
+
230
+ .button {
231
+ display: inline-block;
232
+ border: var(--rule-hair) solid var(--rule);
233
+ background: transparent;
234
+ color: var(--ink);
235
+ font-family: inherit;
236
+ padding: var(--space-1) var(--space-2);
237
+ cursor: pointer;
238
+ text-decoration: none;
239
+ }
240
+
241
+ .button:hover { border-color: var(--ink); }
242
+
243
+ /* A solid block, the way a Swiss poster sets a call to action. Ink rather
244
+ than the accent: the primary action is the one you were going to take,
245
+ and the accent is for the one you have to be told about. */
246
+ .button--primary {
247
+ border-color: var(--ink);
248
+ background: var(--ink);
249
+ color: var(--paper);
250
+ }
251
+
252
+ .button--primary:hover { border-color: var(--ink-quiet); background: var(--ink-quiet); }
253
+
254
+ /* The one filled area the accent gets, for the action a page exists to
255
+ offer. Used once per page or not at all. */
256
+ .button--accent {
257
+ border-color: var(--accent);
258
+ background: var(--accent);
259
+ color: var(--accent-ink);
260
+ }
261
+
262
+ .button--quiet:hover { color: var(--ink); }
263
+
264
+ /* Destroying and reordering are one shape apart from each other otherwise.
265
+ The page stays monochrome at rest; the accent arrives at the moment you
266
+ reach for the one thing that cannot be undone. Focus as well as hover,
267
+ because a keyboard reaches this button and touch never hovers at all. */
268
+ .button--danger:hover,
269
+ .button--danger:focus-visible {
270
+ border-color: var(--accent);
271
+ color: var(--accent);
272
+ }
273
+
274
+ /* Set apart before it is coloured: in a row of actions, destroying and
275
+ reordering are the same gray word otherwise, and a gap is read before a
276
+ word is.
277
+
278
+ A step on the ladder rather than an auto margin. Auto puts the gap at
279
+ whatever is left over, which in a narrow row is right and across a whole
280
+ page is most of a page — and an action a page away from the actions it
281
+ belongs with reads as a different control, not a separated one.
282
+
283
+ :has, because button_to wraps its button in a form, so the flex child is
284
+ the form and the margin belongs to the child. */
285
+ .run > :has(> .button--danger),
286
+ .run > .button--danger { margin-inline-start: var(--space-6); }
287
+
288
+ form.button_to { display: inline; }
289
+
290
+ .link-quiet { text-underline-offset: 0.25em; }
291
+
292
+ .link-quiet:hover { color: var(--ink); }
293
+
294
+ /* --- Copy ---------------------------------------------------------------
295
+ A value on screen to be taken somewhere else is a button that copies
296
+ itself. The value stays visible text inside it, which is what keeps it
297
+ usable when the clipboard is not available. */
298
+
299
+ .copy {
300
+ border: 0;
301
+ padding: 0;
302
+ background: none;
303
+ color: inherit;
304
+ cursor: pointer;
305
+ text-align: left;
306
+ }
307
+
308
+ .copy::after {
309
+ content: "";
310
+ color: var(--ink-quiet);
311
+ font-weight: 400;
312
+ letter-spacing: 0;
313
+ }
314
+
315
+ .copy:hover::after,
316
+ .copy:focus-visible::after { content: " copy"; }
317
+
318
+ /* Says it worked, in the one place you are already looking. */
319
+ .copy[data-copied]::after,
320
+ .copy[data-copied]:hover::after { content: " copied"; color: var(--accent); }
321
+
322
+ /* --- Pagination ---------------------------------------------------------
323
+ A run of numbers under a hairline. The current page carries weight as
324
+ well as the accent, for the same reason the current nav item does. */
325
+
326
+ .pagination {
327
+ display: flex;
328
+ flex-wrap: wrap;
329
+ align-items: baseline;
330
+ gap: var(--space-1) var(--space-2);
331
+ margin-top: var(--space-5);
332
+ padding-top: var(--space-2);
333
+ border-top: var(--rule-hair) solid var(--rule);
334
+ }
335
+
336
+ /* inline-block so the line box is the tap target: an inline box takes its
337
+ height from the font's metrics and ignores the leading set for it. */
338
+ .pagination a,
339
+ .pagination [aria-current] { display: inline-block; }
340
+
341
+ .pagination a { color: var(--ink-quiet); text-underline-offset: 0.25em; }
342
+
343
+ .pagination a:hover { color: var(--ink); }
344
+
345
+ .pagination [aria-current] {
346
+ color: var(--accent);
347
+ font-weight: 700;
348
+ }
349
+
350
+ .pagination__gap { color: var(--ink-quiet); }
351
+
352
+ /* --- What a page has to say --------------------------------------------
353
+ Three registers: it worked, it was refused, there is nothing here. The
354
+ refusal is the only one that interrupts, and it interrupts with a rule
355
+ rather than a filled panel. */
356
+
357
+ .errors {
358
+ width: 100%;
359
+ padding-top: var(--space-2);
360
+ border-top: var(--rule-heavy) solid var(--accent);
361
+ }
362
+
363
+ .errors__title { font-weight: 700; }
364
+
365
+ .errors ul {
366
+ color: var(--ink-quiet);
367
+ margin-top: var(--space-1);
368
+ }
369
+
370
+ .empty { color: var(--ink-quiet); }
371
+
372
+ /* --- Off screen ---------------------------------------------------------
373
+ For text that has to exist without being seen. Clipped rather than hidden
374
+ with display or visibility, because both of those take the element out of
375
+ the accessibility tree as well as off the page, which is the one thing
376
+ this must not do. */
377
+
378
+ .visually-hidden {
379
+ position: absolute;
380
+ width: 1px;
381
+ height: 1px;
382
+ margin: -1px;
383
+ padding: 0;
384
+ overflow: hidden;
385
+ clip-path: inset(50%);
386
+ white-space: nowrap;
387
+ }
388
+
389
+ /* The way past the masthead for a keyboard. Off screen until it is focused,
390
+ at which point it has to be somewhere you can read it. */
391
+ .skip-link {
392
+ position: absolute;
393
+ left: var(--space-2);
394
+ top: var(--space-2);
395
+ z-index: 1;
396
+ padding: var(--space-1) var(--space-2);
397
+ background: var(--paper);
398
+ border: var(--rule-hair) solid var(--ink);
399
+ transform: translateY(calc(-100% - var(--space-4)));
400
+ }
401
+
402
+ .skip-link:focus { transform: none; }
403
+ }
@@ -0,0 +1,67 @@
1
+ /* Grid primitives.
2
+ *
3
+ * The library ships the machinery and never the grid: how many fields a page
4
+ * has depends on what it lays out, and a library that answered that for every
5
+ * application would be answering it for none of them. An application declares
6
+ * --columns in its own stylesheet, and everything here re-proportions.
7
+ *
8
+ * Three primitives, and that is the whole file. A page, a field, and a way to
9
+ * say how many fields something takes.
10
+ */
11
+
12
+ @layer its-swiss.grid {
13
+ /* The page: the one measured container, centred, with a margin that is a
14
+ step on the ladder rather than a number chosen for the look of it. */
15
+ .page {
16
+ max-width: var(--page-max);
17
+ margin-inline: auto;
18
+ padding-inline: var(--page-inset);
19
+ }
20
+
21
+ /* The field. Every list that has to line up with the page uses this same
22
+ track definition, which is the point of a modular grid and the thing
23
+ separate per-component grids can only manage by coincidence. */
24
+ .grid {
25
+ display: grid;
26
+ grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
27
+ column-gap: var(--gutter);
28
+ }
29
+
30
+ /* Placement is the default and the exception is what gets written down. A
31
+ child says how many fields it wants by setting --span; a child that says
32
+ nothing runs the whole field, because that is what nearly everything on a
33
+ page does, and because an unplaced item silently taking one column is the
34
+ bug this replaces — a frame, a panel and a line of prose each took a
35
+ sixth of the page in turn before it was written this way round. */
36
+ .grid > * { grid-column: span var(--span, var(--columns)); }
37
+
38
+ /* A custom element has no default display, so it arrives on the field as an
39
+ unplaced inline box and takes whatever it wraps down with it. */
40
+ turbo-frame { display: block; }
41
+
42
+ /* Field-width helpers. An application sizes something to a run of fields
43
+ with these rather than with a measured rem value that lands between two
44
+ field lines. */
45
+ .measure { max-width: var(--measure); }
46
+
47
+ .fields { width: calc(var(--field) * var(--span, 1) + var(--gutter) * (var(--span, 1) - 1)); }
48
+
49
+ /* --- Runs and columns ---------------------------------------------------
50
+ Almost every horizontal arrangement in an interface is one thing: items
51
+ on a shared baseline, a space apart, wrapping when they must. Said once
52
+ here the way the field's tracks are, so a component below says only how
53
+ it differs. */
54
+ .run {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ align-items: baseline;
58
+ gap: var(--space-2);
59
+ }
60
+
61
+ /* The same idea turned ninety degrees. */
62
+ .stack {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--space-3);
66
+ }
67
+ }
@@ -0,0 +1,50 @@
1
+ /* Reset. Only what the library's own type and spacing decisions require: a
2
+ * predictable box, no margins the ladder did not put there, and no list
3
+ * bullets, because a list in this style is set on the grid rather than
4
+ * indented behind a dot. */
5
+
6
+ @layer its-swiss.reset {
7
+ *,
8
+ *::before,
9
+ *::after { box-sizing: border-box; }
10
+
11
+ html { -webkit-text-size-adjust: 100%; }
12
+
13
+ body {
14
+ margin: 0;
15
+ background: var(--paper);
16
+ color: var(--ink);
17
+ }
18
+
19
+ h1, h2, h3, h4, p, ul, ol, dl, dd, figure, fieldset, legend, blockquote {
20
+ margin: 0;
21
+ padding: 0;
22
+ }
23
+
24
+ ul, ol { list-style: none; }
25
+
26
+ fieldset { border: 0; }
27
+
28
+ img, svg, video { max-width: 100%; display: block; }
29
+
30
+ /* A table is a grid of values, and a gap between its cells is a gap the
31
+ spacing ladder should be setting rather than the browser. */
32
+ table { border-collapse: collapse; border-spacing: 0; }
33
+
34
+ /* The browser hides [hidden] with display: none, and any author rule that
35
+ sets display at all outranks it — a .button says inline-block, so a
36
+ hidden button stays on the page. The attribute means hidden, so it is
37
+ made to mean it once, here, rather than remembered at every element that
38
+ might one day carry it. The library's one !important, and the reason it
39
+ needs one: the rule it has to beat is inside the same layer. */
40
+ [hidden] { display: none !important; }
41
+
42
+ a { color: inherit; }
43
+
44
+ /* The accent's first job. Unset it and this is an ink outline, which is
45
+ still a focus ring. */
46
+ :focus-visible {
47
+ outline: var(--rule-heavy) solid var(--accent);
48
+ outline-offset: 3px;
49
+ }
50
+ }
@@ -0,0 +1,37 @@
1
+ /* The specimen's own furniture: the blocks of value, the field diagram, and
2
+ * the spacing between sections. Not part of the library — nothing here is a
3
+ * component an application should reach for. Loaded only by the specimen
4
+ * page, which is why it is not in ItsSwiss::STYLESHEETS.
5
+ */
6
+
7
+ @layer its-swiss.specimen {
8
+ [data-specimen-take] { margin-top: var(--space-13); }
9
+
10
+ [data-specimen] { margin-top: var(--space-6); }
11
+
12
+ [data-specimen] > h3 { margin-bottom: var(--space-2); }
13
+
14
+ [data-specimen] > * + * { margin-top: var(--space-2); }
15
+
16
+ /* A block of value, the height of a line box so it sits on the baseline
17
+ beside the token that names it. */
18
+ .specimen__value {
19
+ display: block;
20
+ width: var(--space-13);
21
+ height: var(--space-3);
22
+ box-shadow: inset 0 0 0 var(--rule-hair) color-mix(in oklch, var(--ink) 12%, transparent);
23
+ }
24
+
25
+ /* The field, drawn. Each child is one span, and the bar inside it shows
26
+ where that span starts and stops against the page. */
27
+ .specimen__grid { row-gap: var(--space-1); }
28
+
29
+ .specimen__field {
30
+ display: block;
31
+ height: var(--space-2);
32
+ background: var(--value-2);
33
+ }
34
+
35
+ .specimen__grid > [style*="--span: 2"] .specimen__field,
36
+ .specimen__grid > [style*="--span: 4"] .specimen__field { background: var(--value-3); }
37
+ }
@@ -0,0 +1,142 @@
1
+ /* Tokens.
2
+ *
3
+ * Every measurement and every value in the library is declared here and
4
+ * nowhere else. The four blocks below are the whole system: a value scale, an
5
+ * accent slot, a type scale, and one ladder for everything vertical.
6
+ *
7
+ * A consuming application fills three slots — the accent, the typeface, and
8
+ * how many fields its grid has. Everything else it inherits, and everything
9
+ * else it can still override, because these are unlayered properties read
10
+ * from inside a layer: setting --baseline in the application's own stylesheet
11
+ * reproportions the library without touching it.
12
+ */
13
+
14
+ @layer its-swiss.tokens {
15
+ :root {
16
+ /* --- The value scale ---------------------------------------------------
17
+ Six steps of OKLCH lightness from paper to ink. OKLCH because the steps
18
+ have to be perceptually even to be read as a scale at all: the same
19
+ arithmetic in sRGB puts four of the six inside the top quarter of the
20
+ range and leaves the interface with two grays and a cliff.
21
+
22
+ The ladder is not evenly spaced, and that is the point. A hairline has
23
+ to be barely there, so the top of the scale is dense; the middle only
24
+ has to hold secondary text away from primary, so it is sparse. An even
25
+ ladder gives a usable mid-gray and no usable rule.
26
+
27
+ Neutral, because hue is the application's. Warmth arrives through the
28
+ two slots beneath: a consumer that wants Pandatone's warm grays sets
29
+ --value-chroma: 0.006 and --value-hue: 95 once, and the whole scale
30
+ turns together. */
31
+ --value-chroma: 0;
32
+ --value-hue: 0;
33
+
34
+ --value-0: oklch(98% var(--value-chroma) var(--value-hue)); /* paper */
35
+ --value-1: oklch(94% var(--value-chroma) var(--value-hue)); /* a shaded ground */
36
+ --value-2: oklch(89% var(--value-chroma) var(--value-hue)); /* a hairline */
37
+ --value-3: oklch(72% var(--value-chroma) var(--value-hue)); /* a rule that has to be seen */
38
+ --value-4: oklch(54% var(--value-chroma) var(--value-hue)); /* secondary text: 4.7:1 on paper */
39
+ --value-5: oklch(18% var(--value-chroma) var(--value-hue)); /* ink */
40
+
41
+ /* The names the rest of the library uses. Aliases rather than values of
42
+ their own, so the day the ground is darkened the ladder still describes
43
+ the page. */
44
+ --paper: var(--value-0);
45
+ --paper-shaded: var(--value-1);
46
+ --rule: var(--value-2);
47
+ --rule-strong: var(--value-3);
48
+ --ink-quiet: var(--value-4);
49
+ --ink: var(--value-5);
50
+
51
+ /* --- The accent --------------------------------------------------------
52
+ State and emphasis only: the current item, focus, a link, the one
53
+ primary action, the moment before something cannot be undone. Never a
54
+ filled area for its own sake.
55
+
56
+ Unset, it is ink. That is the library's argument in one line — if a
57
+ page reads correctly with the accent collapsed onto the value scale,
58
+ the values were doing the work. The specimen renders both ways for
59
+ exactly this reason. */
60
+ --accent: var(--ink);
61
+ /* What is legible on top of the accent when it is filled. */
62
+ --accent-ink: var(--paper);
63
+
64
+ /* --- Rules -------------------------------------------------------------
65
+ Two weights. A hairline separates; a heavier one interrupts, and is
66
+ kept for the two or three places a page has something to insist on. */
67
+ --rule-hair: 1px;
68
+ --rule-heavy: 2px;
69
+
70
+ /* --- Type --------------------------------------------------------------
71
+ Five sizes on an alternating 1.33 / 1.5 ratio, every one a multiple of
72
+ four. Five is the count an interface actually uses: labels, body, a
73
+ section, a subhead, a page title. A sixth gets chosen for a page and
74
+ then used nowhere else.
75
+
76
+ The family is the application's, for the same reason the accent is. A
77
+ library that ships a typeface ships a voice, and ships a binary with
78
+ it. The fallback stack below is what an application gets until it says
79
+ otherwise, and it is a grotesque, which is the tradition. */
80
+ --font-family: ui-sans-serif, system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif;
81
+ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
82
+
83
+ --size-1: 0.75rem; /* 12 — labels and metadata */
84
+ --size-2: 1rem; /* 16 — body */
85
+ --size-3: 1.5rem; /* 24 — section */
86
+ --size-4: 2rem; /* 32 — subhead, large values */
87
+ --size-5: 3rem; /* 48 — page title */
88
+
89
+ /* --- The vertical ladder -----------------------------------------------
90
+ One ladder, numbered in baselines, used for leading as much as for
91
+ space. Two ladders — a set for space and another for line boxes — agree
92
+ at three values and diverge everywhere else, which is how a heading
93
+ ends up impossible to separate from the block beneath it by the height
94
+ of its own line: the value simply does not exist.
95
+
96
+ Changing --baseline reproportions everything. */
97
+ --baseline: 0.5rem;
98
+
99
+ --space-1: calc(var(--baseline) * 1); /* 8 */
100
+ --space-2: calc(var(--baseline) * 2); /* 16 */
101
+ --space-3: calc(var(--baseline) * 3); /* 24 */
102
+ --space-4: calc(var(--baseline) * 4); /* 32 */
103
+ --space-5: calc(var(--baseline) * 5); /* 40 */
104
+ --space-6: calc(var(--baseline) * 6); /* 48 */
105
+ --space-8: calc(var(--baseline) * 8); /* 64 */
106
+ --space-13: calc(var(--baseline) * 13); /* 104 */
107
+
108
+ /* --- The grid ----------------------------------------------------------
109
+ Primitives, not a grid. Six fields divide into halves and thirds and is
110
+ where an application starts; the point of declaring it here is that it
111
+ is one number to change, in the application's own stylesheet, once the
112
+ problem has said how many fields it has.
113
+
114
+ A field's width is derived rather than measured, and every width helper
115
+ in the library is built from it — which is what keeps a block of prose
116
+ stopping on a field line instead of three pixels short of one. */
117
+ --columns: 6;
118
+ --gutter: 1.5rem;
119
+ --page-max: 82rem;
120
+ --page-inset: var(--space-3);
121
+
122
+ /* The page's own margin comes out before the fields are divided. Left in,
123
+ every helper built on --field is a gutter and a half too wide, and prose
124
+ that is supposed to stop on the third field line stops past it — which
125
+ is invisible on a page with nothing beside the prose and glaring on one
126
+ with a column next to it. */
127
+ --field: calc(
128
+ (var(--page-max) - var(--page-inset) * 2 - var(--gutter) * (var(--columns) - 1)) / var(--columns)
129
+ );
130
+ --measure: calc(var(--field) * 3 + var(--gutter) * 2);
131
+
132
+ /* --- Transitions -------------------------------------------------------
133
+ The page crossfade is short enough to read as a replacement rather than
134
+ an animation. A morph runs longer, because the thing travelling has to
135
+ stay readable while it does. */
136
+ --transition-page: 180ms;
137
+ --transition-morph: 240ms;
138
+ --transition-ease: cubic-bezier(0.2, 0, 0.2, 1);
139
+
140
+ color-scheme: light;
141
+ }
142
+ }